Detailed changes
@@ -23,6 +23,10 @@ workspace-members = [
]
third-party = [
{ name = "reqwest", version = "0.11.27" },
+ # build of remote_server should not include scap / its x11 dependency
+ { name = "scap", git = "https://github.com/zed-industries/scap", rev = "808aa5c45b41e8f44729d02e38fd00a2fe2722e7" },
+ # build of remote_server should not need to include on libalsa through rodio
+ { name = "rodio" },
]
[final-excludes]
@@ -30,7 +34,6 @@ workspace-members = [
"zed_extension_api",
# exclude all extensions
- "zed_emmet",
"zed_glsl",
"zed_html",
"zed_proto",
@@ -0,0 +1,35 @@
+name: Bug Report (Windows Alpha)
+description: Zed Windows Alpha Related Bugs
+type: "Bug"
+labels: ["windows"]
+title: "Windows Alpha: <a short description of the Windows bug>"
+body:
+ - type: textarea
+ attributes:
+ label: Summary
+ description: Describe the bug with a one-line summary, and provide detailed reproduction steps
+ value: |
+ <!-- Please insert a one-line summary of the issue below -->
+ SUMMARY_SENTENCE_HERE
+
+ ### Description
+ <!-- Describe with sufficient detail to reproduce from a clean Zed install. -->
+ Steps to trigger the problem:
+ 1.
+ 2.
+ 3.
+
+ **Expected Behavior**:
+ **Actual Behavior**:
+
+ validations:
+ required: true
+ - type: textarea
+ id: environment
+ attributes:
+ label: Zed Version and System Specs
+ description: 'Open Zed, and in the command palette select "zed: copy system specs into clipboard"'
+ placeholder: |
+ Output of "zed: copy system specs into clipboard"
+ validations:
+ required: true
@@ -0,0 +1,45 @@
+# Configuration related to self-hosted runner.
+self-hosted-runner:
+ # Labels of self-hosted runner in array of strings.
+ labels:
+ # GitHub-hosted Runners
+ - github-8vcpu-ubuntu-2404
+ - github-16vcpu-ubuntu-2404
+ - github-32vcpu-ubuntu-2404
+ - github-8vcpu-ubuntu-2204
+ - github-16vcpu-ubuntu-2204
+ - github-32vcpu-ubuntu-2204
+ - github-16vcpu-ubuntu-2204-arm
+ - windows-2025-16
+ - windows-2025-32
+ - windows-2025-64
+ # Namespace Ubuntu 20.04 (Release builds)
+ - namespace-profile-16x32-ubuntu-2004
+ - namespace-profile-32x64-ubuntu-2004
+ - namespace-profile-16x32-ubuntu-2004-arm
+ - namespace-profile-32x64-ubuntu-2004-arm
+ # Namespace Ubuntu 22.04 (Everything else)
+ - namespace-profile-4x8-ubuntu-2204
+ - namespace-profile-8x16-ubuntu-2204
+ - namespace-profile-16x32-ubuntu-2204
+ - namespace-profile-32x64-ubuntu-2204
+ # Namespace Ubuntu 24.04 (like ubuntu-latest)
+ - namespace-profile-2x4-ubuntu-2404
+ # Namespace Limited Preview
+ - namespace-profile-8x16-ubuntu-2004-arm-m4
+ - namespace-profile-8x32-ubuntu-2004-arm-m4
+ # Self Hosted Runners
+ - self-mini-macos
+ - self-32vcpu-windows-2022
+
+# Disable shellcheck because it doesn't like powershell
+# This should have been triggered with initial rollout of actionlint
+# but https://github.com/zed-industries/zed/pull/36693
+# somehow caused actionlint to actually check those windows jobs
+# where previously they were being skipped. Likely caused by an
+# unknown bug in actionlint where parsing of `runs-on: [ ]`
+# breaks something else. (yuck)
+paths:
+ .github/workflows/{ci,release_nightly}.yml:
+ ignore:
+ - "shellcheck"
@@ -13,13 +13,13 @@ runs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- cache-provider: "buildjet"
+ # cache-provider: "buildjet"
- name: Install Linux dependencies
shell: bash -euxo pipefail {0}
run: ./script/linux
- - name: Check for broken links
+ - name: Check for broken links (in MD)
uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.4.1
with:
args: --no-progress --exclude '^http' './docs/src/**/*'
@@ -30,3 +30,9 @@ runs:
run: |
mkdir -p target/deploy
mdbook build ./docs --dest-dir=../target/deploy/docs/
+
+ - name: Check for broken links (in HTML)
+ uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.4.1
+ with:
+ args: --no-progress --exclude '^http' 'target/deploy/docs/'
+ fail: true
@@ -20,7 +20,167 @@ runs:
with:
node-version: "18"
+ - name: Configure crash dumps
+ shell: powershell
+ run: |
+ # Record the start time for this CI run
+ $runStartTime = Get-Date
+ $runStartTimeStr = $runStartTime.ToString("yyyy-MM-dd HH:mm:ss")
+ Write-Host "CI run started at: $runStartTimeStr"
+
+ # Save the timestamp for later use
+ echo "CI_RUN_START_TIME=$($runStartTime.Ticks)" >> $env:GITHUB_ENV
+
+ # Create crash dump directory in workspace (non-persistent)
+ $dumpPath = "$env:GITHUB_WORKSPACE\crash_dumps"
+ New-Item -ItemType Directory -Force -Path $dumpPath | Out-Null
+
+ Write-Host "Setting up crash dump detection..."
+ Write-Host "Workspace dump path: $dumpPath"
+
+ # Note: We're NOT modifying registry on stateful runners
+ # Instead, we'll check default Windows crash locations after tests
+
- name: Run tests
shell: powershell
working-directory: ${{ inputs.working-directory }}
- run: cargo nextest run --workspace --no-fail-fast
+ run: |
+ $env:RUST_BACKTRACE = "full"
+
+ # Enable Windows debugging features
+ $env:_NT_SYMBOL_PATH = "srv*https://msdl.microsoft.com/download/symbols"
+
+ # .NET crash dump environment variables (ephemeral)
+ $env:COMPlus_DbgEnableMiniDump = "1"
+ $env:COMPlus_DbgMiniDumpType = "4"
+ $env:COMPlus_CreateDumpDiagnostics = "1"
+
+ cargo nextest run --workspace --no-fail-fast
+
+ - name: Analyze crash dumps
+ if: always()
+ shell: powershell
+ run: |
+ Write-Host "Checking for crash dumps..."
+
+ # Get the CI run start time from the environment
+ $runStartTime = [DateTime]::new([long]$env:CI_RUN_START_TIME)
+ Write-Host "Only analyzing dumps created after: $($runStartTime.ToString('yyyy-MM-dd HH:mm:ss'))"
+
+ # Check all possible crash dump locations
+ $searchPaths = @(
+ "$env:GITHUB_WORKSPACE\crash_dumps",
+ "$env:LOCALAPPDATA\CrashDumps",
+ "$env:TEMP",
+ "$env:GITHUB_WORKSPACE",
+ "$env:USERPROFILE\AppData\Local\CrashDumps",
+ "C:\Windows\System32\config\systemprofile\AppData\Local\CrashDumps"
+ )
+
+ $dumps = @()
+ foreach ($path in $searchPaths) {
+ if (Test-Path $path) {
+ Write-Host "Searching in: $path"
+ $found = Get-ChildItem "$path\*.dmp" -ErrorAction SilentlyContinue | Where-Object {
+ $_.CreationTime -gt $runStartTime
+ }
+ if ($found) {
+ $dumps += $found
+ Write-Host " Found $($found.Count) dump(s) from this CI run"
+ }
+ }
+ }
+
+ if ($dumps) {
+ Write-Host "Found $($dumps.Count) crash dump(s)"
+
+ # Install debugging tools if not present
+ $cdbPath = "C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\cdb.exe"
+ if (-not (Test-Path $cdbPath)) {
+ Write-Host "Installing Windows Debugging Tools..."
+ $url = "https://go.microsoft.com/fwlink/?linkid=2237387"
+ Invoke-WebRequest -Uri $url -OutFile winsdksetup.exe
+ Start-Process -Wait winsdksetup.exe -ArgumentList "/features OptionId.WindowsDesktopDebuggers /quiet"
+ }
+
+ foreach ($dump in $dumps) {
+ Write-Host "`n=================================="
+ Write-Host "Analyzing crash dump: $($dump.Name)"
+ Write-Host "Size: $([math]::Round($dump.Length / 1MB, 2)) MB"
+ Write-Host "Time: $($dump.CreationTime)"
+ Write-Host "=================================="
+
+ # Set symbol path
+ $env:_NT_SYMBOL_PATH = "srv*C:\symbols*https://msdl.microsoft.com/download/symbols"
+
+ # Run analysis
+ $analysisOutput = & $cdbPath -z $dump.FullName -c "!analyze -v; ~*k; lm; q" 2>&1 | Out-String
+
+ # Extract key information
+ if ($analysisOutput -match "ExceptionCode:\s*([\w]+)") {
+ Write-Host "Exception Code: $($Matches[1])"
+ if ($Matches[1] -eq "c0000005") {
+ Write-Host "Exception Type: ACCESS VIOLATION"
+ }
+ }
+
+ if ($analysisOutput -match "EXCEPTION_RECORD:\s*(.+)") {
+ Write-Host "Exception Record: $($Matches[1])"
+ }
+
+ if ($analysisOutput -match "FAULTING_IP:\s*\n(.+)") {
+ Write-Host "Faulting Instruction: $($Matches[1])"
+ }
+
+ # Save full analysis
+ $analysisFile = "$($dump.FullName).analysis.txt"
+ $analysisOutput | Out-File -FilePath $analysisFile
+ Write-Host "`nFull analysis saved to: $analysisFile"
+
+ # Print stack trace section
+ Write-Host "`n--- Stack Trace Preview ---"
+ $stackSection = $analysisOutput -split "STACK_TEXT:" | Select-Object -Last 1
+ $stackLines = $stackSection -split "`n" | Select-Object -First 20
+ $stackLines | ForEach-Object { Write-Host $_ }
+ Write-Host "--- End Stack Trace Preview ---"
+ }
+
+ Write-Host "`n⚠️ Crash dumps detected! Download the 'crash-dumps' artifact for detailed analysis."
+
+ # Copy dumps to workspace for artifact upload
+ $artifactPath = "$env:GITHUB_WORKSPACE\crash_dumps_collected"
+ New-Item -ItemType Directory -Force -Path $artifactPath | Out-Null
+
+ foreach ($dump in $dumps) {
+ $destName = "$($dump.Directory.Name)_$($dump.Name)"
+ Copy-Item $dump.FullName -Destination "$artifactPath\$destName"
+ if (Test-Path "$($dump.FullName).analysis.txt") {
+ Copy-Item "$($dump.FullName).analysis.txt" -Destination "$artifactPath\$destName.analysis.txt"
+ }
+ }
+
+ Write-Host "Copied $($dumps.Count) dump(s) to artifact directory"
+ } else {
+ Write-Host "No crash dumps from this CI run found"
+ }
+
+ - name: Upload crash dumps
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: crash-dumps-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ crash_dumps_collected/*.dmp
+ crash_dumps_collected/*.txt
+ if-no-files-found: ignore
+ retention-days: 7
+
+ - name: Check test results
+ shell: powershell
+ working-directory: ${{ inputs.working-directory }}
+ run: |
+ # Re-check test results to fail the job if tests failed
+ if ($LASTEXITCODE -ne 0) {
+ Write-Host "Tests failed with exit code: $LASTEXITCODE"
+ exit $LASTEXITCODE
+ }
@@ -8,7 +8,7 @@ on:
jobs:
update-collab-staging-tag:
if: github.repository_owner == 'zed-industries'
- runs-on: ubuntu-latest
+ runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -16,7 +16,7 @@ jobs:
bump_patch_version:
if: github.repository_owner == 'zed-industries'
runs-on:
- - buildjet-16vcpu-ubuntu-2204
+ - namespace-profile-16x32-ubuntu-2204
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -28,7 +28,7 @@ jobs:
run: |
set -eux
- channel=$(cat crates/zed/RELEASE_CHANNEL)
+ channel="$(cat crates/zed/RELEASE_CHANNEL)"
tag_suffix=""
case $channel in
@@ -43,9 +43,9 @@ jobs:
;;
esac
which cargo-set-version > /dev/null || cargo install cargo-edit
- output=$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')
+ output="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')"
export GIT_COMMITTER_NAME="Zed Bot"
export GIT_COMMITTER_EMAIL="hi@zed.dev"
git commit -am "Bump to $output for @$GITHUB_ACTOR" --author "Zed Bot <hi@zed.dev>"
- git tag v${output}${tag_suffix}
- git push origin HEAD v${output}${tag_suffix}
+ git tag "v${output}${tag_suffix}"
+ git push origin HEAD "v${output}${tag_suffix}"
@@ -24,6 +24,7 @@ env:
DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
+ ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }}
jobs:
job_spec:
@@ -34,8 +35,9 @@ jobs:
run_license: ${{ steps.filter.outputs.run_license }}
run_docs: ${{ steps.filter.outputs.run_docs }}
run_nix: ${{ steps.filter.outputs.run_nix }}
+ run_actionlint: ${{ steps.filter.outputs.run_actionlint }}
runs-on:
- - ubuntu-latest
+ - namespace-profile-2x4-ubuntu-2404
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -47,39 +49,40 @@ jobs:
run: |
if [ -z "$GITHUB_BASE_REF" ]; then
echo "Not in a PR context (i.e., push to main/stable/preview)"
- COMPARE_REV=$(git rev-parse HEAD~1)
+ COMPARE_REV="$(git rev-parse HEAD~1)"
else
echo "In a PR context comparing to pull_request.base.ref"
git fetch origin "$GITHUB_BASE_REF" --depth=350
- COMPARE_REV=$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)
+ COMPARE_REV="$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)"
fi
- # Specify anything which should skip full CI in this regex:
+ CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" ${{ github.sha }})"
+
+ # Specify anything which should potentially skip full test suite in this regex:
# - docs/
# - script/update_top_ranking_issues/
# - .github/ISSUE_TEMPLATE/
# - .github/workflows/ (except .github/workflows/ci.yml)
SKIP_REGEX='^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!ci)))'
- if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -vP "$SKIP_REGEX") ]]; then
- echo "run_tests=true" >> $GITHUB_OUTPUT
- else
- echo "run_tests=false" >> $GITHUB_OUTPUT
- fi
- if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep '^docs/') ]]; then
- echo "run_docs=true" >> $GITHUB_OUTPUT
- else
- echo "run_docs=false" >> $GITHUB_OUTPUT
- fi
- if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -P '^(Cargo.lock|script/.*licenses)') ]]; then
- echo "run_license=true" >> $GITHUB_OUTPUT
- else
- echo "run_license=false" >> $GITHUB_OUTPUT
- fi
- NIX_REGEX='^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)'
- if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -P "$NIX_REGEX") ]]; then
- echo "run_nix=true" >> $GITHUB_OUTPUT
- else
- echo "run_nix=false" >> $GITHUB_OUTPUT
- fi
+
+ echo "$CHANGED_FILES" | grep -qvP "$SKIP_REGEX" && \
+ echo "run_tests=true" >> "$GITHUB_OUTPUT" || \
+ echo "run_tests=false" >> "$GITHUB_OUTPUT"
+
+ echo "$CHANGED_FILES" | grep -qP '^docs/' && \
+ echo "run_docs=true" >> "$GITHUB_OUTPUT" || \
+ echo "run_docs=false" >> "$GITHUB_OUTPUT"
+
+ echo "$CHANGED_FILES" | grep -qP '^\.github/(workflows/|actions/|actionlint.yml)' && \
+ echo "run_actionlint=true" >> "$GITHUB_OUTPUT" || \
+ echo "run_actionlint=false" >> "$GITHUB_OUTPUT"
+
+ echo "$CHANGED_FILES" | grep -qP '^(Cargo.lock|script/.*licenses)' && \
+ echo "run_license=true" >> "$GITHUB_OUTPUT" || \
+ echo "run_license=false" >> "$GITHUB_OUTPUT"
+
+ echo "$CHANGED_FILES" | grep -qP '^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)' && \
+ echo "run_nix=true" >> "$GITHUB_OUTPUT" || \
+ echo "run_nix=false" >> "$GITHUB_OUTPUT"
migration_checks:
name: Check Postgres and Protobuf migrations, mergability
@@ -89,8 +92,7 @@ jobs:
needs.job_spec.outputs.run_tests == 'true'
timeout-minutes: 60
runs-on:
- - self-hosted
- - macOS
+ - self-mini-macos
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -112,11 +114,11 @@ jobs:
run: |
if [ -z "$GITHUB_BASE_REF" ];
then
- echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> $GITHUB_ENV
+ echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> "$GITHUB_ENV"
else
git checkout -B temp
- git merge -q origin/$GITHUB_BASE_REF -m "merge main into temp"
- echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> $GITHUB_ENV
+ git merge -q "origin/$GITHUB_BASE_REF" -m "merge main into temp"
+ echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> "$GITHUB_ENV"
fi
- uses: bufbuild/buf-setup-action@v1
@@ -135,12 +137,12 @@ jobs:
github.repository_owner == 'zed-industries' &&
needs.job_spec.outputs.run_tests == 'true'
runs-on:
- - buildjet-8vcpu-ubuntu-2204
+ - namespace-profile-8x16-ubuntu-2204
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Add Rust to the PATH
- run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+ run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Install cargo-hakari
uses: clechasseur/rs-cargo@8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386 # v2
with:
@@ -166,7 +168,7 @@ jobs:
needs: [job_spec]
if: github.repository_owner == 'zed-industries'
runs-on:
- - buildjet-8vcpu-ubuntu-2204
+ - namespace-profile-4x8-ubuntu-2204
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -178,7 +180,7 @@ jobs:
- name: Prettier Check on /docs
working-directory: ./docs
run: |
- pnpm dlx prettier@${PRETTIER_VERSION} . --check || {
+ pnpm dlx "prettier@${PRETTIER_VERSION}" . --check || {
echo "To fix, run from the root of the Zed repo:"
echo " cd docs && pnpm dlx prettier@${PRETTIER_VERSION} . --write && cd .."
false
@@ -188,7 +190,7 @@ jobs:
- name: Prettier Check on default.json
run: |
- pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --check || {
+ pnpm dlx "prettier@${PRETTIER_VERSION}" assets/settings/default.json --check || {
echo "To fix, run from the root of the Zed repo:"
echo " pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --write"
false
@@ -219,7 +221,7 @@ jobs:
github.repository_owner == 'zed-industries' &&
(needs.job_spec.outputs.run_tests == 'true' || needs.job_spec.outputs.run_docs == 'true')
runs-on:
- - buildjet-8vcpu-ubuntu-2204
+ - namespace-profile-8x16-ubuntu-2204
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -234,6 +236,20 @@ jobs:
- name: Build docs
uses: ./.github/actions/build_docs
+ actionlint:
+ runs-on: namespace-profile-2x4-ubuntu-2404
+ if: github.repository_owner == 'zed-industries' && needs.job_spec.outputs.run_actionlint == 'true'
+ needs: [job_spec]
+ steps:
+ - uses: actions/checkout@v4
+ - name: Download actionlint
+ id: get_actionlint
+ run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
+ shell: bash
+ - name: Check workflow files
+ run: ${{ steps.get_actionlint.outputs.executable }} -color
+ shell: bash
+
macos_tests:
timeout-minutes: 60
name: (macOS) Run Clippy and tests
@@ -242,8 +258,7 @@ jobs:
github.repository_owner == 'zed-industries' &&
needs.job_spec.outputs.run_tests == 'true'
runs-on:
- - self-hosted
- - macOS
+ - self-mini-macos
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -255,6 +270,10 @@ jobs:
mkdir -p ./../.cargo
cp ./.cargo/ci-config.toml ./../.cargo/config.toml
+ - name: Check that Cargo.lock is up to date
+ run: |
+ cargo update --locked --workspace
+
- name: cargo clippy
run: ./script/clippy
@@ -309,10 +328,10 @@ jobs:
github.repository_owner == 'zed-industries' &&
needs.job_spec.outputs.run_tests == 'true'
runs-on:
- - buildjet-16vcpu-ubuntu-2204
+ - namespace-profile-16x32-ubuntu-2204
steps:
- name: Add Rust to the PATH
- run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+ run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -323,7 +342,7 @@ jobs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- cache-provider: "buildjet"
+ # cache-provider: "buildjet"
- name: Install Linux dependencies
run: ./script/linux
@@ -361,10 +380,10 @@ jobs:
github.repository_owner == 'zed-industries' &&
needs.job_spec.outputs.run_tests == 'true'
runs-on:
- - buildjet-8vcpu-ubuntu-2204
+ - namespace-profile-16x32-ubuntu-2204
steps:
- name: Add Rust to the PATH
- run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+ run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -375,7 +394,7 @@ jobs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- cache-provider: "buildjet"
+ # cache-provider: "buildjet"
- name: Install Clang & Mold
run: ./script/remote-server && ./script/install-mold 2.34.0
@@ -399,7 +418,7 @@ jobs:
if: |
github.repository_owner == 'zed-industries' &&
needs.job_spec.outputs.run_tests == 'true'
- runs-on: [self-hosted, Windows, X64]
+ runs-on: [self-32vcpu-windows-2022]
steps:
- name: Environment Setup
run: |
@@ -439,11 +458,12 @@ jobs:
tests_pass:
name: Tests Pass
- runs-on: ubuntu-latest
+ runs-on: namespace-profile-2x4-ubuntu-2404
needs:
- job_spec
- style
- check_docs
+ - actionlint
- migration_checks
# run_tests: If adding required tests, add them here and to script below.
- workspace_hack
@@ -465,6 +485,11 @@ jobs:
if [[ "${{ needs.job_spec.outputs.run_docs }}" == "true" ]]; then
[[ "${{ needs.check_docs.result }}" != 'success' ]] && { RET_CODE=1; echo "docs checks failed"; }
fi
+
+ if [[ "${{ needs.job_spec.outputs.run_actionlint }}" == "true" ]]; then
+ [[ "${{ needs.actionlint.result }}" != 'success' ]] && { RET_CODE=1; echo "actionlint checks failed"; }
+ fi
+
# Only check test jobs if they were supposed to run
if [[ "${{ needs.job_spec.outputs.run_tests }}" == "true" ]]; then
[[ "${{ needs.workspace_hack.result }}" != 'success' ]] && { RET_CODE=1; echo "Workspace Hack failed"; }
@@ -484,11 +509,10 @@ jobs:
timeout-minutes: 120
name: Create a macOS bundle
runs-on:
- - self-hosted
- - bundle
+ - self-mini-macos
if: |
- startsWith(github.ref, 'refs/tags/v')
- || contains(github.event.pull_request.labels.*.name, 'run-bundling')
+ ( startsWith(github.ref, 'refs/tags/v')
+ || contains(github.event.pull_request.labels.*.name, 'run-bundling') )
needs: [macos_tests]
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
@@ -502,6 +526,11 @@ jobs:
with:
node-version: "18"
+ - name: Setup Sentry CLI
+ uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2
+ with:
+ token: ${{ SECRETS.SENTRY_AUTH_TOKEN }}
+
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
@@ -573,10 +602,10 @@ jobs:
timeout-minutes: 60
name: Linux x86_x64 release bundle
runs-on:
- - buildjet-16vcpu-ubuntu-2004 # ubuntu 20.04 for minimal glibc
+ - namespace-profile-16x32-ubuntu-2004 # ubuntu 20.04 for minimal glibc
if: |
- startsWith(github.ref, 'refs/tags/v')
- || contains(github.event.pull_request.labels.*.name, 'run-bundling')
+ ( startsWith(github.ref, 'refs/tags/v')
+ || contains(github.event.pull_request.labels.*.name, 'run-bundling') )
needs: [linux_tests]
steps:
- name: Checkout repo
@@ -587,6 +616,11 @@ jobs:
- name: Install Linux dependencies
run: ./script/linux && ./script/install-mold 2.34.0
+ - name: Setup Sentry CLI
+ uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2
+ with:
+ token: ${{ SECRETS.SENTRY_AUTH_TOKEN }}
+
- name: Determine version and release channel
if: startsWith(github.ref, 'refs/tags/v')
run: |
@@ -626,7 +660,7 @@ jobs:
timeout-minutes: 60
name: Linux arm64 release bundle
runs-on:
- - buildjet-16vcpu-ubuntu-2204-arm
+ - namespace-profile-8x32-ubuntu-2004-arm-m4 # ubuntu 20.04 for minimal glibc
if: |
startsWith(github.ref, 'refs/tags/v')
|| contains(github.event.pull_request.labels.*.name, 'run-bundling')
@@ -640,6 +674,11 @@ jobs:
- name: Install Linux dependencies
run: ./script/linux
+ - name: Setup Sentry CLI
+ uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2
+ with:
+ token: ${{ SECRETS.SENTRY_AUTH_TOKEN }}
+
- name: Determine version and release channel
if: startsWith(github.ref, 'refs/tags/v')
run: |
@@ -679,8 +718,8 @@ jobs:
timeout-minutes: 60
runs-on: github-8vcpu-ubuntu-2404
if: |
- startsWith(github.ref, 'refs/tags/v')
- || contains(github.event.pull_request.labels.*.name, 'run-bundling')
+ false && ( startsWith(github.ref, 'refs/tags/v')
+ || contains(github.event.pull_request.labels.*.name, 'run-bundling') )
needs: [linux_tests]
name: Build Zed on FreeBSD
steps:
@@ -745,8 +784,9 @@ jobs:
bundle-windows-x64:
timeout-minutes: 120
name: Create a Windows installer
- runs-on: [self-hosted, Windows, X64]
- if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
+ runs-on: [self-32vcpu-windows-2022]
+ if: contains(github.event.pull_request.labels.*.name, 'run-bundling')
+ # if: (startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling'))
needs: [windows_tests]
env:
AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }}
@@ -764,6 +804,11 @@ jobs:
with:
clean: false
+ - name: Setup Sentry CLI
+ uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2
+ with:
+ token: ${{ SECRETS.SENTRY_AUTH_TOKEN }}
+
- name: Determine version and release channel
working-directory: ${{ env.ZED_WORKSPACE }}
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
@@ -785,7 +830,7 @@ jobs:
- name: Upload Artifacts to release
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
# Re-enable when we are ready to publish windows preview releases
- if: false && ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) && env.RELEASE_CHANNEL == 'preview' }} # upload only preview
+ if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) && env.RELEASE_CHANNEL == 'preview' }} # upload only preview
with:
draft: true
prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }}
@@ -798,12 +843,20 @@ jobs:
if: |
startsWith(github.ref, 'refs/tags/v')
&& endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')
- needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, bundle-windows-x64, freebsd]
+ needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, bundle-windows-x64]
runs-on:
- - self-hosted
- - bundle
+ - self-mini-macos
steps:
- name: gh release
- run: gh release edit $GITHUB_REF_NAME --draft=false
+ run: gh release edit "$GITHUB_REF_NAME" --draft=false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Create Sentry release
+ uses: getsentry/action-release@526942b68292201ac6bbb99b9a0747d4abee354c # v3
+ env:
+ SENTRY_ORG: zed-dev
+ SENTRY_PROJECT: zed
+ SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+ with:
+ environment: production
@@ -18,7 +18,7 @@ jobs:
URL="https://zed.dev/releases/stable/latest"
fi
- echo "URL=$URL" >> $GITHUB_OUTPUT
+ echo "URL=$URL" >> "$GITHUB_OUTPUT"
- name: Get content
uses: 2428392/gh-truncate-string-action@b3ff790d21cf42af3ca7579146eedb93c8fb0757 # v1.4.1
id: get-content
@@ -50,9 +50,9 @@ jobs:
PREVIEW_TAG="${VERSION}-pre"
if git rev-parse "$PREVIEW_TAG" > /dev/null 2>&1; then
- echo "was_promoted_from_preview=true" >> $GITHUB_OUTPUT
+ echo "was_promoted_from_preview=true" >> "$GITHUB_OUTPUT"
else
- echo "was_promoted_from_preview=false" >> $GITHUB_OUTPUT
+ echo "was_promoted_from_preview=false" >> "$GITHUB_OUTPUT"
fi
- name: Send release notes email
@@ -12,7 +12,7 @@ on:
jobs:
danger:
if: github.repository_owner == 'zed-industries'
- runs-on: ubuntu-latest
+ runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -9,7 +9,7 @@ jobs:
deploy-docs:
name: Deploy Docs
if: github.repository_owner == 'zed-industries'
- runs-on: buildjet-16vcpu-ubuntu-2204
+ runs-on: namespace-profile-16x32-ubuntu-2204
steps:
- name: Checkout repo
@@ -61,7 +61,7 @@ jobs:
- style
- tests
runs-on:
- - buildjet-16vcpu-ubuntu-2204
+ - namespace-profile-16x32-ubuntu-2204
steps:
- name: Install doctl
uses: digitalocean/action-doctl@v2
@@ -79,12 +79,12 @@ jobs:
- name: Build docker image
run: |
docker build -f Dockerfile-collab \
- --build-arg GITHUB_SHA=$GITHUB_SHA \
- --tag registry.digitalocean.com/zed/collab:$GITHUB_SHA \
+ --build-arg "GITHUB_SHA=$GITHUB_SHA" \
+ --tag "registry.digitalocean.com/zed/collab:$GITHUB_SHA" \
.
- name: Publish docker image
- run: docker push registry.digitalocean.com/zed/collab:${GITHUB_SHA}
+ run: docker push "registry.digitalocean.com/zed/collab:${GITHUB_SHA}"
- name: Prune Docker system
run: docker system prune --filter 'until=72h' -f
@@ -94,7 +94,7 @@ jobs:
needs:
- publish
runs-on:
- - buildjet-16vcpu-ubuntu-2204
+ - namespace-profile-16x32-ubuntu-2204
steps:
- name: Checkout repo
@@ -131,17 +131,20 @@ jobs:
source script/lib/deploy-helpers.sh
export_vars_for_environment $ZED_KUBE_NAMESPACE
- export ZED_DO_CERTIFICATE_ID=$(doctl compute certificate list --format ID --no-header)
+ ZED_DO_CERTIFICATE_ID="$(doctl compute certificate list --format ID --no-header)"
+ export ZED_DO_CERTIFICATE_ID
export ZED_IMAGE_ID="registry.digitalocean.com/zed/collab:${GITHUB_SHA}"
export ZED_SERVICE_NAME=collab
export ZED_LOAD_BALANCER_SIZE_UNIT=$ZED_COLLAB_LOAD_BALANCER_SIZE_UNIT
+ export DATABASE_MAX_CONNECTIONS=850
envsubst < crates/collab/k8s/collab.template.yml | kubectl apply -f -
kubectl -n "$ZED_KUBE_NAMESPACE" rollout status deployment/$ZED_SERVICE_NAME --watch
echo "deployed ${ZED_SERVICE_NAME} to ${ZED_KUBE_NAMESPACE}"
export ZED_SERVICE_NAME=api
export ZED_LOAD_BALANCER_SIZE_UNIT=$ZED_API_LOAD_BALANCER_SIZE_UNIT
+ export DATABASE_MAX_CONNECTIONS=60
envsubst < crates/collab/k8s/collab.template.yml | kubectl apply -f -
kubectl -n "$ZED_KUBE_NAMESPACE" rollout status deployment/$ZED_SERVICE_NAME --watch
echo "deployed ${ZED_SERVICE_NAME} to ${ZED_KUBE_NAMESPACE}"
@@ -32,10 +32,10 @@ jobs:
github.repository_owner == 'zed-industries' &&
(github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-eval'))
runs-on:
- - buildjet-16vcpu-ubuntu-2204
+ - namespace-profile-16x32-ubuntu-2204
steps:
- name: Add Rust to the PATH
- run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+ run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -46,7 +46,7 @@ jobs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- cache-provider: "buildjet"
+ # cache-provider: "buildjet"
- name: Install Linux dependencies
run: ./script/linux
@@ -20,7 +20,7 @@ jobs:
matrix:
system:
- os: x86 Linux
- runner: buildjet-16vcpu-ubuntu-2204
+ runner: namespace-profile-16x32-ubuntu-2204
install_nix: true
- os: arm Mac
runner: [macOS, ARM64, test]
@@ -29,6 +29,7 @@ jobs:
runs-on: ${{ matrix.system.runner }}
env:
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
+ ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }}
ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }}
GIT_LFS_SKIP_SMUDGE: 1 # breaks the livekit rust sdk examples which we don't actually depend on
steps:
@@ -43,8 +44,8 @@ jobs:
- name: Set path
if: ${{ ! matrix.system.install_nix }}
run: |
- echo "/nix/var/nix/profiles/default/bin" >> $GITHUB_PATH
- echo "/Users/administrator/.nix-profile/bin" >> $GITHUB_PATH
+ echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
+ echo "/Users/administrator/.nix-profile/bin" >> "$GITHUB_PATH"
- uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f # v31
if: ${{ matrix.system.install_nix }}
@@ -56,11 +57,13 @@ jobs:
name: zed
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
pushFilter: "${{ inputs.cachix-filter }}"
- cachixArgs: '-v'
+ cachixArgs: "-v"
- run: nix build .#${{ inputs.flake-output }} -L --accept-flake-config
- name: Limit /nix/store to 50GB on macs
if: ${{ ! matrix.system.install_nix }}
run: |
- [ $(du -sm /nix/store | cut -f1) -gt 50000 ] && nix-collect-garbage -d || :
+ if [ "$(du -sm /nix/store | cut -f1)" -gt 50000 ]; then
+ nix-collect-garbage -d || true
+ fi
@@ -20,7 +20,7 @@ jobs:
name: Run randomized tests
if: github.repository_owner == 'zed-industries'
runs-on:
- - buildjet-16vcpu-ubuntu-2204
+ - namespace-profile-16x32-ubuntu-2204
steps:
- name: Install Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@@ -13,6 +13,7 @@ env:
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
+ ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }}
DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
@@ -58,7 +59,7 @@ jobs:
timeout-minutes: 60
name: Run tests on Windows
if: github.repository_owner == 'zed-industries'
- runs-on: [self-hosted, Windows, X64]
+ runs-on: [self-32vcpu-windows-2022]
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -85,8 +86,7 @@ jobs:
name: Create a macOS bundle
if: github.repository_owner == 'zed-industries'
runs-on:
- - self-hosted
- - bundle
+ - self-mini-macos
needs: tests
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
@@ -112,6 +112,11 @@ jobs:
echo "Publishing version: ${version} on release channel nightly"
echo "nightly" > crates/zed/RELEASE_CHANNEL
+ - name: Setup Sentry CLI
+ uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2
+ with:
+ token: ${{ SECRETS.SENTRY_AUTH_TOKEN }}
+
- name: Create macOS app bundle
run: script/bundle-mac
@@ -123,7 +128,7 @@ jobs:
name: Create a Linux *.tar.gz bundle for x86
if: github.repository_owner == 'zed-industries'
runs-on:
- - buildjet-16vcpu-ubuntu-2004
+ - namespace-profile-16x32-ubuntu-2004 # ubuntu 20.04 for minimal glibc
needs: tests
steps:
- name: Checkout repo
@@ -132,11 +137,16 @@ jobs:
clean: false
- name: Add Rust to the PATH
- run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+ run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Install Linux dependencies
run: ./script/linux && ./script/install-mold 2.34.0
+ - name: Setup Sentry CLI
+ uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2
+ with:
+ token: ${{ SECRETS.SENTRY_AUTH_TOKEN }}
+
- name: Limit target directory size
run: script/clear-target-dir-if-larger-than 100
@@ -158,7 +168,7 @@ jobs:
name: Create a Linux *.tar.gz bundle for ARM
if: github.repository_owner == 'zed-industries'
runs-on:
- - buildjet-16vcpu-ubuntu-2204-arm
+ - namespace-profile-8x32-ubuntu-2004-arm-m4 # ubuntu 20.04 for minimal glibc
needs: tests
steps:
- name: Checkout repo
@@ -169,6 +179,11 @@ jobs:
- name: Install Linux dependencies
run: ./script/linux
+ - name: Setup Sentry CLI
+ uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2
+ with:
+ token: ${{ SECRETS.SENTRY_AUTH_TOKEN }}
+
- name: Limit target directory size
run: script/clear-target-dir-if-larger-than 100
@@ -187,13 +202,10 @@ jobs:
freebsd:
timeout-minutes: 60
- if: github.repository_owner == 'zed-industries'
+ if: false && github.repository_owner == 'zed-industries'
runs-on: github-8vcpu-ubuntu-2404
needs: tests
name: Build Zed on FreeBSD
- # env:
- # MYTOKEN : ${{ secrets.MYTOKEN }}
- # MYTOKEN2: "value2"
steps:
- uses: actions/checkout@v4
- name: Build FreeBSD remote-server
@@ -228,7 +240,6 @@ jobs:
bundle-nix:
name: Build and cache Nix package
- if: false
needs: tests
secrets: inherit
uses: ./.github/workflows/nix.yml
@@ -237,7 +248,7 @@ jobs:
timeout-minutes: 60
name: Create a Windows installer
if: github.repository_owner == 'zed-industries'
- runs-on: [self-hosted, Windows, X64]
+ runs-on: [self-32vcpu-windows-2022]
needs: windows-tests
env:
AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }}
@@ -263,6 +274,11 @@ jobs:
Write-Host "Publishing version: $version on release channel nightly"
"nightly" | Set-Content -Path "crates/zed/RELEASE_CHANNEL"
+ - name: Setup Sentry CLI
+ uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2
+ with:
+ token: ${{ SECRETS.SENTRY_AUTH_TOKEN }}
+
- name: Build Zed installer
working-directory: ${{ env.ZED_WORKSPACE }}
run: script/bundle-windows.ps1
@@ -274,7 +290,7 @@ jobs:
update-nightly-tag:
name: Update nightly tag
if: github.repository_owner == 'zed-industries'
- runs-on: ubuntu-latest
+ runs-on: namespace-profile-2x4-ubuntu-2404
needs:
- bundle-mac
- bundle-linux-x86
@@ -296,3 +312,12 @@ jobs:
git config user.email github-actions@github.com
git tag -f nightly
git push origin nightly --force
+
+ - name: Create Sentry release
+ uses: getsentry/action-release@526942b68292201ac6bbb99b9a0747d4abee354c # v3
+ env:
+ SENTRY_ORG: zed-dev
+ SENTRY_PROJECT: zed
+ SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+ with:
+ environment: production
@@ -12,7 +12,7 @@ jobs:
shellcheck:
name: "ShellCheck Scripts"
if: github.repository_owner == 'zed-industries'
- runs-on: ubuntu-latest
+ runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -3,7 +3,7 @@ name: Run Unit Evals
on:
schedule:
# GitHub might drop jobs at busy times, so we choose a random time in the middle of the night.
- - cron: "47 1 * * *"
+ - cron: "47 1 * * 2"
workflow_dispatch:
concurrency:
@@ -23,10 +23,10 @@ jobs:
timeout-minutes: 60
name: Run unit evals
runs-on:
- - buildjet-16vcpu-ubuntu-2204
+ - namespace-profile-16x32-ubuntu-2204
steps:
- name: Add Rust to the PATH
- run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+ run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -37,7 +37,7 @@ jobs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- cache-provider: "buildjet"
+ # cache-provider: "buildjet"
- name: Install Linux dependencies
run: ./script/linux
@@ -3,33 +3,88 @@
version = 4
[[package]]
-name = "acp"
+name = "acp_thread"
version = "0.1.0"
dependencies = [
- "agent_servers",
- "agentic-coding-protocol",
+ "action_log",
+ "agent-client-protocol",
"anyhow",
- "assistant_tool",
- "async-pipe",
"buffer_diff",
+ "collections",
"editor",
"env_logger 0.11.8",
+ "file_icons",
"futures 0.3.31",
"gpui",
"indoc",
"itertools 0.14.0",
"language",
+ "language_model",
"markdown",
+ "parking_lot",
"project",
+ "prompt_store",
+ "rand 0.8.5",
+ "serde",
"serde_json",
"settings",
"smol",
"tempfile",
+ "terminal",
"ui",
+ "url",
"util",
+ "uuid",
+ "watch",
"workspace-hack",
]
+[[package]]
+name = "acp_tools"
+version = "0.1.0"
+dependencies = [
+ "agent-client-protocol",
+ "collections",
+ "gpui",
+ "language",
+ "markdown",
+ "project",
+ "serde",
+ "serde_json",
+ "settings",
+ "theme",
+ "ui",
+ "util",
+ "workspace",
+ "workspace-hack",
+]
+
+[[package]]
+name = "action_log"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "buffer_diff",
+ "clock",
+ "collections",
+ "ctor",
+ "futures 0.3.31",
+ "gpui",
+ "indoc",
+ "language",
+ "log",
+ "pretty_assertions",
+ "project",
+ "rand 0.8.5",
+ "serde_json",
+ "settings",
+ "text",
+ "util",
+ "watch",
+ "workspace-hack",
+ "zlog",
+]
+
[[package]]
name = "activity_indicator"
version = "0.1.0"
@@ -82,6 +137,7 @@ dependencies = [
name = "agent"
version = "0.1.0"
dependencies = [
+ "action_log",
"agent_settings",
"anyhow",
"assistant_context",
@@ -89,11 +145,11 @@ dependencies = [
"assistant_tools",
"chrono",
"client",
+ "cloud_llm_client",
"collections",
"component",
"context_server",
"convert_case 0.8.0",
- "feature_flags",
"fs",
"futures 0.3.31",
"git",
@@ -112,7 +168,6 @@ dependencies = [
"pretty_assertions",
"project",
"prompt_store",
- "proto",
"rand 0.8.5",
"ref-cast",
"rope",
@@ -131,7 +186,97 @@ dependencies = [
"uuid",
"workspace",
"workspace-hack",
- "zed_llm_client",
+ "zstd",
+]
+
+[[package]]
+name = "agent-client-protocol"
+version = "0.0.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "289eb34ee17213dadcca47eedadd386a5e7678094095414e475965d1bcca2860"
+dependencies = [
+ "anyhow",
+ "async-broadcast",
+ "futures 0.3.31",
+ "log",
+ "parking_lot",
+ "schemars",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "agent2"
+version = "0.1.0"
+dependencies = [
+ "acp_thread",
+ "action_log",
+ "agent",
+ "agent-client-protocol",
+ "agent_servers",
+ "agent_settings",
+ "anyhow",
+ "assistant_context",
+ "assistant_tool",
+ "assistant_tools",
+ "chrono",
+ "client",
+ "clock",
+ "cloud_llm_client",
+ "collections",
+ "context_server",
+ "ctor",
+ "db",
+ "editor",
+ "env_logger 0.11.8",
+ "fs",
+ "futures 0.3.31",
+ "git",
+ "gpui",
+ "gpui_tokio",
+ "handlebars 4.5.0",
+ "html_to_markdown",
+ "http_client",
+ "indoc",
+ "itertools 0.14.0",
+ "language",
+ "language_model",
+ "language_models",
+ "log",
+ "lsp",
+ "open",
+ "parking_lot",
+ "paths",
+ "portable-pty",
+ "pretty_assertions",
+ "project",
+ "prompt_store",
+ "reqwest_client",
+ "rust-embed",
+ "schemars",
+ "serde",
+ "serde_json",
+ "settings",
+ "smol",
+ "sqlez",
+ "task",
+ "telemetry",
+ "tempfile",
+ "terminal",
+ "text",
+ "theme",
+ "thiserror 2.0.12",
+ "tree-sitter-rust",
+ "ui",
+ "unindent",
+ "util",
+ "uuid",
+ "watch",
+ "web_search",
+ "which 6.0.3",
+ "workspace-hack",
+ "worktree",
+ "zlog",
"zstd",
]
@@ -139,16 +284,45 @@ dependencies = [
name = "agent_servers"
version = "0.1.0"
dependencies = [
+ "acp_thread",
+ "acp_tools",
+ "action_log",
+ "agent-client-protocol",
+ "agent_settings",
"anyhow",
+ "client",
"collections",
+ "context_server",
+ "env_logger 0.11.8",
+ "fs",
"futures 0.3.31",
"gpui",
+ "gpui_tokio",
+ "indoc",
+ "itertools 0.14.0",
+ "language",
+ "language_model",
+ "language_models",
+ "libc",
+ "log",
+ "nix 0.29.0",
"paths",
"project",
+ "rand 0.8.5",
+ "reqwest_client",
"schemars",
+ "semver",
"serde",
+ "serde_json",
"settings",
+ "smol",
+ "strum 0.27.1",
+ "tempfile",
+ "thiserror 2.0.12",
+ "ui",
"util",
+ "uuid",
+ "watch",
"which 6.0.3",
"workspace-hack",
]
@@ -158,6 +332,7 @@ name = "agent_settings"
version = "0.1.0"
dependencies = [
"anyhow",
+ "cloud_llm_client",
"collections",
"fs",
"gpui",
@@ -169,18 +344,20 @@ dependencies = [
"serde_json_lenient",
"settings",
"workspace-hack",
- "zed_llm_client",
]
[[package]]
name = "agent_ui"
version = "0.1.0"
dependencies = [
- "acp",
+ "acp_thread",
+ "action_log",
"agent",
+ "agent-client-protocol",
+ "agent2",
"agent_servers",
"agent_settings",
- "agentic-coding-protocol",
+ "ai_onboarding",
"anyhow",
"assistant_context",
"assistant_slash_command",
@@ -191,7 +368,9 @@ dependencies = [
"buffer_diff",
"chrono",
"client",
+ "cloud_llm_client",
"collections",
+ "command_palette_hooks",
"component",
"context_server",
"db",
@@ -206,13 +385,13 @@ dependencies = [
"gpui",
"html_to_markdown",
"http_client",
- "indexed_docs",
"indoc",
"inventory",
"itertools 0.14.0",
"jsonschema",
"language",
"language_model",
+ "language_models",
"languages",
"log",
"lsp",
@@ -224,6 +403,7 @@ dependencies = [
"parking_lot",
"paths",
"picker",
+ "postage",
"pretty_assertions",
"project",
"prompt_store",
@@ -251,7 +431,9 @@ dependencies = [
"time_format",
"tree-sitter-md",
"ui",
+ "ui_input",
"unindent",
+ "url",
"urlencoding",
"util",
"uuid",
@@ -259,25 +441,6 @@ dependencies = [
"workspace",
"workspace-hack",
"zed_actions",
- "zed_llm_client",
-]
-
-[[package]]
-name = "agentic-coding-protocol"
-version = "0.0.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e276b798eddd02562a339340a96919d90bbfcf78de118fdddc932524646fac7"
-dependencies = [
- "anyhow",
- "chrono",
- "derive_more 2.0.1",
- "futures 0.3.31",
- "log",
- "parking_lot",
- "schemars",
- "semver",
- "serde",
- "serde_json",
]
[[package]]
@@ -315,6 +478,23 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "ai_onboarding"
+version = "0.1.0"
+dependencies = [
+ "client",
+ "cloud_llm_client",
+ "component",
+ "gpui",
+ "language_model",
+ "serde",
+ "smallvec",
+ "telemetry",
+ "ui",
+ "workspace-hack",
+ "zed_actions",
+]
+
[[package]]
name = "alacritty_terminal"
version = "0.25.1-dev"
@@ -635,6 +815,7 @@ dependencies = [
"chrono",
"client",
"clock",
+ "cloud_llm_client",
"collections",
"context_server",
"fs",
@@ -668,7 +849,6 @@ dependencies = [
"uuid",
"workspace",
"workspace-hack",
- "zed_llm_client",
]
[[package]]
@@ -678,7 +858,7 @@ dependencies = [
"anyhow",
"async-trait",
"collections",
- "derive_more 0.99.19",
+ "derive_more",
"extension",
"futures 0.3.31",
"gpui",
@@ -712,7 +892,6 @@ dependencies = [
"gpui",
"html_to_markdown",
"http_client",
- "indexed_docs",
"language",
"pretty_assertions",
"project",
@@ -736,15 +915,16 @@ dependencies = [
name = "assistant_tool"
version = "0.1.0"
dependencies = [
+ "action_log",
"anyhow",
"buffer_diff",
"clock",
"collections",
"ctor",
- "derive_more 0.99.19",
- "futures 0.3.31",
+ "derive_more",
"gpui",
"icons",
+ "indoc",
"language",
"language_model",
"log",
@@ -758,7 +938,6 @@ dependencies = [
"settings",
"text",
"util",
- "watch",
"workspace",
"workspace-hack",
"zlog",
@@ -768,6 +947,7 @@ dependencies = [
name = "assistant_tools"
version = "0.1.0"
dependencies = [
+ "action_log",
"agent_settings",
"anyhow",
"assistant_tool",
@@ -775,9 +955,11 @@ dependencies = [
"chrono",
"client",
"clock",
+ "cloud_llm_client",
"collections",
"component",
- "derive_more 0.99.19",
+ "derive_more",
+ "diffy",
"editor",
"feature_flags",
"fs",
@@ -827,7 +1009,6 @@ dependencies = [
"which 6.0.3",
"workspace",
"workspace-hack",
- "zed_llm_client",
"zlog",
]
@@ -1021,17 +1202,6 @@ dependencies = [
"tracing",
]
-[[package]]
-name = "async-recursion"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7d78656ba01f1b93024b7c3a0467f1608e4be67d725749fdcd7d2c7678fd7a2"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
[[package]]
name = "async-recursion"
version = "1.1.1"
@@ -1111,26 +1281,6 @@ dependencies = [
"syn 2.0.101",
]
-[[package]]
-name = "async-stripe"
-version = "0.40.0"
-source = "git+https://github.com/zed-industries/async-stripe?rev=3672dd4efb7181aa597bf580bf5a2f5d23db6735#3672dd4efb7181aa597bf580bf5a2f5d23db6735"
-dependencies = [
- "chrono",
- "futures-util",
- "http-types",
- "hyper 0.14.32",
- "hyper-rustls 0.24.2",
- "serde",
- "serde_json",
- "serde_path_to_error",
- "serde_qs 0.10.1",
- "smart-default",
- "smol_str 0.1.24",
- "thiserror 1.0.69",
- "tokio",
-]
-
[[package]]
name = "async-tar"
version = "0.5.0"
@@ -1153,9 +1303,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
-version = "0.1.88"
+version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
@@ -1234,10 +1384,11 @@ version = "0.1.0"
dependencies = [
"anyhow",
"collections",
- "derive_more 0.99.19",
"gpui",
- "parking_lot",
"rodio",
+ "schemars",
+ "serde",
+ "settings",
"util",
"workspace-hack",
]
@@ -1324,7 +1475,7 @@ dependencies = [
"anyhow",
"arrayvec",
"log",
- "nom",
+ "nom 7.1.3",
"num-rational",
"v_frame",
]
@@ -1833,9 +1984,7 @@ version = "0.1.0"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
- "futures 0.3.31",
"http_client",
- "tokio",
"workspace-hack",
]
@@ -1934,12 +2083,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce"
-[[package]]
-name = "base64"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
-
[[package]]
name = "base64"
version = "0.21.7"
@@ -2146,7 +2289,7 @@ dependencies = [
[[package]]
name = "blade-graphics"
version = "0.6.0"
-source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad"
+source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5"
dependencies = [
"ash",
"ash-window",
@@ -2179,7 +2322,7 @@ dependencies = [
[[package]]
name = "blade-macros"
version = "0.3.0"
-source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad"
+source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5"
dependencies = [
"proc-macro2",
"quote",
@@ -2189,7 +2332,7 @@ dependencies = [
[[package]]
name = "blade-util"
version = "0.2.0"
-source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad"
+source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5"
dependencies = [
"blade-graphics",
"bytemuck",
@@ -2700,7 +2843,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
- "nom",
+ "nom 7.1.3",
]
[[package]]
@@ -2919,15 +3062,16 @@ name = "client"
version = "0.1.0"
dependencies = [
"anyhow",
- "async-recursion 0.3.2",
"async-tungstenite",
"base64 0.22.1",
"chrono",
"clock",
+ "cloud_api_client",
+ "cloud_llm_client",
"cocoa 0.26.0",
"collections",
"credentials_provider",
- "derive_more 0.99.19",
+ "derive_more",
"feature_flags",
"fs",
"futures 0.3.31",
@@ -2948,6 +3092,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
+ "serde_urlencoded",
"settings",
"sha2",
"smol",
@@ -2966,7 +3111,6 @@ dependencies = [
"windows 0.61.1",
"workspace-hack",
"worktree",
- "zed_llm_client",
]
[[package]]
@@ -2979,6 +3123,49 @@ dependencies = [
"workspace-hack",
]
+[[package]]
+name = "cloud_api_client"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "cloud_api_types",
+ "futures 0.3.31",
+ "gpui",
+ "gpui_tokio",
+ "http_client",
+ "parking_lot",
+ "serde_json",
+ "workspace-hack",
+ "yawc",
+]
+
+[[package]]
+name = "cloud_api_types"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "chrono",
+ "ciborium",
+ "cloud_llm_client",
+ "pretty_assertions",
+ "serde",
+ "serde_json",
+ "workspace-hack",
+]
+
+[[package]]
+name = "cloud_llm_client"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "pretty_assertions",
+ "serde",
+ "serde_json",
+ "strum 0.27.1",
+ "uuid",
+ "workspace-hack",
+]
+
[[package]]
name = "clru"
version = "0.6.2"
@@ -3089,7 +3276,6 @@ dependencies = [
"anyhow",
"assistant_context",
"assistant_slash_command",
- "async-stripe",
"async-trait",
"async-tungstenite",
"audio",
@@ -3115,7 +3301,6 @@ dependencies = [
"dap_adapters",
"dashmap 6.1.0",
"debugger_ui",
- "derive_more 0.99.19",
"editor",
"envy",
"extension",
@@ -3131,7 +3316,6 @@ dependencies = [
"http_client",
"hyper 0.14.32",
"indoc",
- "jsonwebtoken",
"language",
"language_model",
"livekit_api",
@@ -3168,6 +3352,7 @@ dependencies = [
"session",
"settings",
"sha2",
+ "smol",
"sqlx",
"strum 0.27.1",
"subtle",
@@ -3176,7 +3361,6 @@ dependencies = [
"telemetry_events",
"text",
"theme",
- "thiserror 2.0.12",
"time",
"tokio",
"toml 0.8.20",
@@ -3190,7 +3374,6 @@ dependencies = [
"workspace",
"workspace-hack",
"worktree",
- "zed_llm_client",
"zlog",
]
@@ -3320,7 +3503,7 @@ name = "command_palette_hooks"
version = "0.1.0"
dependencies = [
"collections",
- "derive_more 0.99.19",
+ "derive_more",
"gpui",
"workspace-hack",
]
@@ -3408,12 +3591,14 @@ dependencies = [
"futures 0.3.31",
"gpui",
"log",
+ "net",
"parking_lot",
"postage",
"schemars",
"serde",
"serde_json",
"smol",
+ "tempfile",
"url",
"util",
"workspace-hack",
@@ -3456,13 +3641,13 @@ dependencies = [
"command_palette_hooks",
"ctor",
"dirs 4.0.0",
+ "edit_prediction",
"editor",
"fs",
"futures 0.3.31",
"gpui",
"http_client",
"indoc",
- "inline_completion",
"itertools 0.14.0",
"language",
"log",
@@ -3476,6 +3661,7 @@ dependencies = [
"serde",
"serde_json",
"settings",
+ "sum_tree",
"task",
"theme",
"ui",
@@ -3629,17 +3815,6 @@ dependencies = [
"libm",
]
-[[package]]
-name = "coreaudio-rs"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
-dependencies = [
- "bitflags 1.3.2",
- "core-foundation-sys",
- "coreaudio-sys",
-]
-
[[package]]
name = "coreaudio-rs"
version = "0.12.1"
@@ -3687,7 +3862,7 @@ dependencies = [
"rustc-hash 1.1.0",
"rustybuzz 0.14.1",
"self_cell",
- "smol_str 0.2.2",
+ "smol_str",
"swash",
"sys-locale",
"ttf-parser 0.21.1",
@@ -3697,29 +3872,6 @@ dependencies = [
"unicode-segmentation",
]
-[[package]]
-name = "cpal"
-version = "0.15.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
-dependencies = [
- "alsa",
- "core-foundation-sys",
- "coreaudio-rs 0.11.3",
- "dasp_sample",
- "jni",
- "js-sys",
- "libc",
- "mach2",
- "ndk 0.8.0",
- "ndk-context",
- "oboe",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "web-sys",
- "windows 0.54.0",
-]
-
[[package]]
name = "cpal"
version = "0.16.0"
@@ -3732,8 +3884,8 @@ dependencies = [
"jni",
"js-sys",
"libc",
- "mach2",
- "ndk 0.9.0",
+ "mach2 0.4.2",
+ "ndk",
"ndk-context",
"num-derive",
"num-traits",
@@ -3874,6 +4026,48 @@ dependencies = [
"target-lexicon 0.13.2",
]
+[[package]]
+name = "crash-context"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031ed29858d90cfdf27fe49fae28028a1f20466db97962fa2f4ea34809aeebf3"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "mach2 0.4.2",
+]
+
+[[package]]
+name = "crash-handler"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2066907075af649bcb8bcb1b9b986329b243677e6918b2d920aa64b0aac5ace3"
+dependencies = [
+ "cfg-if",
+ "crash-context",
+ "libc",
+ "mach2 0.4.2",
+ "parking_lot",
+]
+
+[[package]]
+name = "crashes"
+version = "0.1.0"
+dependencies = [
+ "bincode",
+ "crash-handler",
+ "log",
+ "mach2 0.5.0",
+ "minidumper",
+ "paths",
+ "release_channel",
+ "serde",
+ "serde_json",
+ "smol",
+ "system_specs",
+ "workspace-hack",
+]
+
[[package]]
name = "crc"
version = "3.2.1"
@@ -4203,7 +4397,7 @@ dependencies = [
[[package]]
name = "dap-types"
version = "0.0.1"
-source = "git+https://github.com/zed-industries/dap-types?rev=7f39295b441614ca9dbf44293e53c32f666897f9#7f39295b441614ca9dbf44293e53c32f666897f9"
+source = "git+https://github.com/zed-industries/dap-types?rev=1b461b310481d01e02b2603c16d7144b926339f8#1b461b310481d01e02b2603c16d7144b926339f8"
dependencies = [
"schemars",
"serde",
@@ -4229,46 +4423,12 @@ dependencies = [
"serde",
"serde_json",
"shlex",
+ "smol",
"task",
"util",
"workspace-hack",
]
-[[package]]
-name = "darling"
-version = "0.20.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
-dependencies = [
- "darling_core",
- "darling_macro",
-]
-
-[[package]]
-name = "darling_core"
-version = "0.20.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
-dependencies = [
- "fnv",
- "ident_case",
- "proc-macro2",
- "quote",
- "strsim",
- "syn 2.0.101",
-]
-
-[[package]]
-name = "darling_macro"
-version = "0.20.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
-dependencies = [
- "darling_core",
- "quote",
- "syn 2.0.101",
-]
-
[[package]]
name = "dashmap"
version = "5.5.3"
@@ -4409,6 +4569,7 @@ dependencies = [
"pretty_assertions",
"project",
"rpc",
+ "schemars",
"serde",
"serde_json",
"serde_json_lenient",
@@ -4433,6 +4594,15 @@ dependencies = [
"zlog",
]
+[[package]]
+name = "debugid"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d"
+dependencies = [
+ "uuid",
+]
+
[[package]]
name = "deepseek"
version = "0.1.0"
@@ -4483,37 +4653,6 @@ dependencies = [
"serde",
]
-[[package]]
-name = "derive_builder"
-version = "0.20.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
-dependencies = [
- "derive_builder_macro",
-]
-
-[[package]]
-name = "derive_builder_core"
-version = "0.20.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
-dependencies = [
- "darling",
- "proc-macro2",
- "quote",
- "syn 2.0.101",
-]
-
-[[package]]
-name = "derive_builder_macro"
-version = "0.20.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
-dependencies = [
- "derive_builder_core",
- "syn 2.0.101",
-]
-
[[package]]
name = "derive_more"
version = "0.99.19"
@@ -1,12 +1,16 @@
[workspace]
resolver = "2"
members = [
+ "crates/acp_tools",
+ "crates/acp_thread",
+ "crates/action_log",
"crates/activity_indicator",
- "crates/acp",
- "crates/agent_ui",
"crates/agent",
- "crates/agent_settings",
+ "crates/agent2",
"crates/agent_servers",
+ "crates/agent_settings",
+ "crates/agent_ui",
+ "crates/ai_onboarding",
"crates/anthropic",
"crates/askpass",
"crates/assets",
@@ -28,6 +32,9 @@ members = [
"crates/cli",
"crates/client",
"crates/clock",
+ "crates/cloud_api_client",
+ "crates/cloud_api_types",
+ "crates/cloud_llm_client",
"crates/collab",
"crates/collab_ui",
"crates/collections",
@@ -36,6 +43,7 @@ members = [
"crates/component",
"crates/context_server",
"crates/copilot",
+ "crates/crashes",
"crates/credentials_provider",
"crates/dap",
"crates/dap_adapters",
@@ -47,8 +55,8 @@ members = [
"crates/diagnostics",
"crates/docs_preprocessor",
"crates/editor",
- "crates/explorer_command_injector",
"crates/eval",
+ "crates/explorer_command_injector",
"crates/extension",
"crates/extension_api",
"crates/extension_cli",
@@ -69,15 +77,13 @@ members = [
"crates/gpui",
"crates/gpui_macros",
"crates/gpui_tokio",
-
"crates/html_to_markdown",
"crates/http_client",
"crates/http_client_tls",
"crates/icons",
"crates/image_viewer",
- "crates/indexed_docs",
- "crates/inline_completion",
- "crates/inline_completion_button",
+ "crates/edit_prediction",
+ "crates/edit_prediction_button",
"crates/inspector_ui",
"crates/install_cli",
"crates/jj",
@@ -98,14 +104,15 @@ members = [
"crates/markdown_preview",
"crates/media",
"crates/menu",
- "crates/svg_preview",
"crates/migrator",
"crates/mistral",
"crates/multi_buffer",
+ "crates/nc",
"crates/net",
"crates/node_runtime",
"crates/notifications",
"crates/ollama",
+ "crates/onboarding",
"crates/open_ai",
"crates/open_router",
"crates/outline",
@@ -137,6 +144,7 @@ members = [
"crates/semantic_version",
"crates/session",
"crates/settings",
+ "crates/settings_profile_selector",
"crates/settings_ui",
"crates/snippet",
"crates/snippet_provider",
@@ -148,7 +156,9 @@ members = [
"crates/streaming_diff",
"crates/sum_tree",
"crates/supermaven",
+ "crates/system_specs",
"crates/supermaven_api",
+ "crates/svg_preview",
"crates/tab_switcher",
"crates/task",
"crates/tasks_ui",
@@ -176,12 +186,13 @@ members = [
"crates/watch",
"crates/web_search",
"crates/web_search_providers",
- "crates/welcome",
"crates/workspace",
"crates/worktree",
+ "crates/x_ai",
"crates/zed",
"crates/zed_actions",
"crates/zeta",
+ "crates/zeta_cli",
"crates/zlog",
"crates/zlog_settings",
@@ -189,7 +200,6 @@ members = [
# Extensions
#
- "extensions/emmet",
"extensions/glsl",
"extensions/html",
"extensions/proto",
@@ -218,13 +228,17 @@ edition = "2024"
# Workspace member crates
#
-acp = { path = "crates/acp" }
+acp_tools = { path = "crates/acp_tools" }
+acp_thread = { path = "crates/acp_thread" }
+action_log = { path = "crates/action_log" }
agent = { path = "crates/agent" }
+agent2 = { path = "crates/agent2" }
activity_indicator = { path = "crates/activity_indicator" }
agent_ui = { path = "crates/agent_ui" }
agent_settings = { path = "crates/agent_settings" }
agent_servers = { path = "crates/agent_servers" }
ai = { path = "crates/ai" }
+ai_onboarding = { path = "crates/ai_onboarding" }
anthropic = { path = "crates/anthropic" }
askpass = { path = "crates/askpass" }
assets = { path = "crates/assets" }
@@ -246,6 +260,9 @@ channel = { path = "crates/channel" }
cli = { path = "crates/cli" }
client = { path = "crates/client" }
clock = { path = "crates/clock" }
+cloud_api_client = { path = "crates/cloud_api_client" }
+cloud_api_types = { path = "crates/cloud_api_types" }
+cloud_llm_client = { path = "crates/cloud_llm_client" }
collab = { path = "crates/collab" }
collab_ui = { path = "crates/collab_ui" }
collections = { path = "crates/collections" }
@@ -254,6 +271,7 @@ command_palette_hooks = { path = "crates/command_palette_hooks" }
component = { path = "crates/component" }
context_server = { path = "crates/context_server" }
copilot = { path = "crates/copilot" }
+crashes = { path = "crates/crashes" }
credentials_provider = { path = "crates/credentials_provider" }
dap = { path = "crates/dap" }
dap_adapters = { path = "crates/dap_adapters" }
@@ -289,9 +307,8 @@ http_client = { path = "crates/http_client" }
http_client_tls = { path = "crates/http_client_tls" }
icons = { path = "crates/icons" }
image_viewer = { path = "crates/image_viewer" }
-indexed_docs = { path = "crates/indexed_docs" }
-inline_completion = { path = "crates/inline_completion" }
-inline_completion_button = { path = "crates/inline_completion_button" }
+edit_prediction = { path = "crates/edit_prediction" }
+edit_prediction_button = { path = "crates/edit_prediction_button" }
inspector_ui = { path = "crates/inspector_ui" }
install_cli = { path = "crates/install_cli" }
jj = { path = "crates/jj" }
@@ -316,10 +333,12 @@ menu = { path = "crates/menu" }
migrator = { path = "crates/migrator" }
mistral = { path = "crates/mistral" }
multi_buffer = { path = "crates/multi_buffer" }
+nc = { path = "crates/nc" }
net = { path = "crates/net" }
node_runtime = { path = "crates/node_runtime" }
notifications = { path = "crates/notifications" }
ollama = { path = "crates/ollama" }
+onboarding = { path = "crates/onboarding" }
open_ai = { path = "crates/open_ai" }
open_router = { path = "crates/open_router", features = ["schemars"] }
outline = { path = "crates/outline" }
@@ -330,6 +349,7 @@ picker = { path = "crates/picker" }
plugin = { path = "crates/plugin" }
plugin_macros = { path = "crates/plugin_macros" }
prettier = { path = "crates/prettier" }
+settings_profile_selector = { path = "crates/settings_profile_selector" }
project = { path = "crates/project" }
project_panel = { path = "crates/project_panel" }
project_symbols = { path = "crates/project_symbols" }
@@ -343,6 +363,7 @@ remote_server = { path = "crates/remote_server" }
repl = { path = "crates/repl" }
reqwest_client = { path = "crates/reqwest_client" }
rich_text = { path = "crates/rich_text" }
+rodio = { version = "0.21.1", default-features = false }
rope = { path = "crates/rope" }
rpc = { path = "crates/rpc" }
rules_library = { path = "crates/rules_library" }
@@ -363,6 +384,7 @@ streaming_diff = { path = "crates/streaming_diff" }
sum_tree = { path = "crates/sum_tree" }
supermaven = { path = "crates/supermaven" }
supermaven_api = { path = "crates/supermaven_api" }
+system_specs = { path = "crates/system_specs" }
tab_switcher = { path = "crates/tab_switcher" }
task = { path = "crates/task" }
tasks_ui = { path = "crates/tasks_ui" }
@@ -391,9 +413,9 @@ vim_mode_setting = { path = "crates/vim_mode_setting" }
watch = { path = "crates/watch" }
web_search = { path = "crates/web_search" }
web_search_providers = { path = "crates/web_search_providers" }
-welcome = { path = "crates/welcome" }
workspace = { path = "crates/workspace" }
worktree = { path = "crates/worktree" }
+x_ai = { path = "crates/x_ai" }
zed = { path = "crates/zed" }
zed_actions = { path = "crates/zed_actions" }
zeta = { path = "crates/zeta" }
@@ -404,7 +426,7 @@ zlog_settings = { path = "crates/zlog_settings" }
# External crates
#
-agentic-coding-protocol = { version = "0.0.9" }
+agent-client-protocol = "0.0.31"
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
any_vec = "0.14"
@@ -431,15 +453,17 @@ aws-sdk-bedrockruntime = { version = "1.80.0", features = [
aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] }
aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] }
base64 = "0.22"
+bincode = "1.2.1"
bitflags = "2.6.0"
-blade-graphics = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" }
-blade-macros = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" }
-blade-util = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" }
+blade-graphics = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" }
+blade-macros = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" }
+blade-util = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" }
blake3 = "1.5.3"
bytes = "1.0"
cargo_metadata = "0.19"
cargo_toml = "0.21"
chrono = { version = "0.4", features = ["serde"] }
+ciborium = "0.2"
circular-buffer = "1.0"
clap = { version = "4.4", features = ["derive"] }
cocoa = "0.26"
@@ -449,9 +473,10 @@ core-foundation = "0.10.0"
core-foundation-sys = "0.8.6"
core-video = { version = "0.4.3", features = ["metal"] }
cpal = "0.16"
+crash-handler = "0.6"
criterion = { version = "0.5", features = ["html_reports"] }
ctor = "0.4.0"
-dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "7f39295b441614ca9dbf44293e53c32f666897f9" }
+dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "1b461b310481d01e02b2603c16d7144b926339f8" }
dashmap = "6.0"
derive_more = "0.99.17"
dirs = "4.0"
@@ -472,8 +497,10 @@ handlebars = "4.3"
heck = "0.5"
heed = { version = "0.21.0", features = ["read-txn-no-tls"] }
hex = "0.4.3"
+human_bytes = "0.4.1"
html5ever = "0.27.0"
http = "1.1"
+http-body = "1.0"
hyper = "0.14"
ignore = "0.4.22"
image = "0.25.1"
@@ -487,18 +514,20 @@ json_dotpath = "1.1"
jsonschema = "0.30.0"
jsonwebtoken = "9.3"
jupyter-protocol = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
-jupyter-websocket-client = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
+jupyter-websocket-client = { git = "https://github.com/ConradIrwin/runtimed" ,rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
libc = "0.2"
libsqlite3-sys = { version = "0.30.1", features = ["bundled"] }
linkify = "0.10.0"
log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
-lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "6add7052b598ea1f40f7e8913622c3958b009b60" }
+lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "39f629bdd03d59abd786ed9fc27e8bca02c0c0ec" }
+mach2 = "0.5"
markup5ever_rcdom = "0.3.0"
metal = "0.29"
+minidumper = "0.8"
moka = { version = "0.12.10", features = ["sync"] }
naga = { version = "25.0", features = ["wgsl-in"] }
nanoid = "0.4"
-nbformat = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
+nbformat = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
nix = "0.29"
num-format = "0.4.4"
objc = "0.2"
@@ -508,6 +537,7 @@ palette = { version = "0.7.5", default-features = false, features = ["std"] }
parking_lot = "0.12.1"
partial-json-fixer = "0.5.3"
parse_int = "0.9"
+pciid-parser = "0.8.0"
pathdiff = "0.2"
pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" }
pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" }
@@ -534,12 +564,13 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "951c77
"charset",
"http2",
"macos-system-configuration",
+ "multipart",
"rustls-tls-native-roots",
"socks",
"stream",
] }
rsa = "0.9.6"
-runtimelib = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734", default-features = false, features = [
+runtimelib = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734", default-features = false, features = [
"async-dispatcher-runtime",
] }
rust-embed = { version = "8.4", features = ["include-exclude"] }
@@ -547,7 +578,7 @@ rustc-demangle = "0.1.23"
rustc-hash = "2.1.0"
rustls = { version = "0.23.26" }
rustls-platform-verifier = "0.5.0"
-scap = { git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318", default-features = false }
+scap = { git = "https://github.com/zed-industries/scap", rev = "808aa5c45b41e8f44729d02e38fd00a2fe2722e7", default-features = false }
schemars = { version = "1.0", features = ["indexmap2"] }
semver = "1.0"
serde = { version = "1.0", features = ["derive", "rc"] }
@@ -558,6 +589,7 @@ serde_json_lenient = { version = "0.2", features = [
"raw_value",
] }
serde_repr = "0.1"
+serde_urlencoded = "0.7"
sha2 = "0.10"
shellexpand = "2.1.0"
shlex = "1.3.0"
@@ -565,6 +597,7 @@ simplelog = "0.12.2"
smallvec = { version = "1.6", features = ["union"] }
smol = "2.0"
sqlformat = "0.2"
+stacksafe = "0.1"
streaming-iterator = "0.1"
strsim = "0.11"
strum = { version = "0.27.0", features = ["derive"] }
@@ -575,7 +608,7 @@ sysinfo = "0.31.0"
take-until = "0.2.0"
tempfile = "3.20.0"
thiserror = "2.0.12"
-tiktoken-rs = "0.7.0"
+tiktoken-rs = { git = "https://github.com/zed-industries/tiktoken-rs", rev = "30c32a4522751699adeda0d5840c71c3b75ae73d" }
time = { version = "0.3", features = [
"macros",
"parsing",
@@ -635,23 +668,9 @@ which = "6.0.0"
windows-core = "0.61"
wit-component = "0.221"
workspace-hack = "0.1.0"
-zed_llm_client = "= 0.8.6"
+yawc = "0.2.5"
zstd = "0.11"
-[workspace.dependencies.async-stripe]
-git = "https://github.com/zed-industries/async-stripe"
-rev = "3672dd4efb7181aa597bf580bf5a2f5d23db6735"
-default-features = false
-features = [
- "runtime-tokio-hyper-rustls",
- "billing",
- "checkout",
- "events",
- # The features below are only enabled to get the `events` feature to build.
- "chrono",
- "connect",
-]
-
[workspace.dependencies.windows]
version = "0.61"
features = [
@@ -662,14 +681,16 @@ features = [
"UI_ViewManagement",
"Wdk_System_SystemServices",
"Win32_Globalization",
- "Win32_Graphics_Direct2D",
- "Win32_Graphics_Direct2D_Common",
+ "Win32_Graphics_Direct3D",
+ "Win32_Graphics_Direct3D11",
+ "Win32_Graphics_Direct3D_Fxc",
+ "Win32_Graphics_DirectComposition",
"Win32_Graphics_DirectWrite",
"Win32_Graphics_Dwm",
+ "Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
"Win32_Graphics_Gdi",
"Win32_Graphics_Imaging",
- "Win32_Graphics_Imaging_D2D",
"Win32_Networking_WinSock",
"Win32_Security",
"Win32_Security_Credentials",
@@ -682,6 +703,7 @@ features = [
"Win32_System_LibraryLoader",
"Win32_System_Memory",
"Win32_System_Ole",
+ "Win32_System_Performance",
"Win32_System_Pipes",
"Win32_System_SystemInformation",
"Win32_System_SystemServices",
@@ -701,6 +723,7 @@ features = [
[patch.crates-io]
notify = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" }
notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" }
+windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" }
# Makes the workspace hack crate refer to the local one, but only when you're building locally
workspace-hack = { path = "tooling/workspace-hack" }
@@ -709,6 +732,11 @@ workspace-hack = { path = "tooling/workspace-hack" }
split-debuginfo = "unpacked"
codegen-units = 16
+# mirror configuration for crates compiled for the build platform
+# (without this cargo will compile ~400 crates twice)
+[profile.dev.build-override]
+codegen-units = 16
+
[profile.dev.package]
taffy = { opt-level = 3 }
cranelift-codegen = { opt-level = 3 }
@@ -731,7 +759,7 @@ feature_flags = { codegen-units = 1 }
file_icons = { codegen-units = 1 }
fsevent = { codegen-units = 1 }
image_viewer = { codegen-units = 1 }
-inline_completion_button = { codegen-units = 1 }
+edit_prediction_button = { codegen-units = 1 }
install_cli = { codegen-units = 1 }
journal = { codegen-units = 1 }
lmstudio = { codegen-units = 1 }
@@ -780,38 +808,33 @@ unexpected_cfgs = { level = "allow" }
dbg_macro = "deny"
todo = "deny"
-# Motivation: We use `vec![a..b]` a lot when dealing with ranges in text, so
-# warning on this rule produces a lot of noise.
-single_range_in_vec_init = "allow"
+# This is not a style lint, see https://github.com/rust-lang/rust-clippy/pull/15454
+# Remove when the lint gets promoted to `suspicious`.
+declare_interior_mutable_const = "deny"
+
+redundant_clone = "deny"
-# These are all of the rules that currently have violations in the Zed
-# codebase.
+# We currently do not restrict any style rules
+# as it slows down shipping code to Zed.
#
-# We'll want to drive this list down by either:
-# 1. fixing violations of the rule and begin enforcing it
-# 2. deciding we want to allow the rule permanently, at which point
-# we should codify that separately above.
+# Running ./script/clippy can take several minutes, and so it's
+# common to skip that step and let CI do it. Any unexpected failures
+# (which also take minutes to discover) thus require switching back
+# to an old branch, manual fixing, and re-pushing.
#
-# This list shouldn't be added to; it should only get shorter.
-# =============================================================================
-
-# There are a bunch of rules currently failing in the `style` group, so
-# allow all of those, for now.
+# In the future we could improve this by either making sure
+# Zed can surface clippy errors in diagnostics (in addition to the
+# rust-analyzer errors), or by having CI fix style nits automatically.
style = { level = "allow", priority = -1 }
-# Temporary list of style lints that we've fixed so far.
-module_inception = { level = "deny" }
-question_mark = { level = "deny" }
-redundant_closure = { level = "deny" }
# Individual rules that have violations in the codebase:
type_complexity = "allow"
-# We often return trait objects from `new` functions.
-new_ret_no_self = { level = "allow" }
-# We have a few `next` functions that differ in lifetimes
-# compared to Iterator::next. Yet, clippy complains about those.
-should_implement_trait = { level = "allow" }
let_underscore_future = "allow"
+# Motivation: We use `vec![a..b]` a lot when dealing with ranges in text, so
+# warning on this rule produces a lot of noise.
+single_range_in_vec_init = "allow"
+
# in Rust it can be very tedious to reduce argument count without
# running afoul of the borrow checker.
too_many_arguments = "allow"
@@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1.2
-FROM rust:1.88-bookworm as builder
+FROM rust:1.89-bookworm as builder
WORKDIR app
COPY . .
@@ -1,3 +1,4 @@
collab: RUST_LOG=${RUST_LOG:-info} cargo run --package=collab serve all
+cloud: cd ../cloud; cargo make dev
livekit: livekit-server --dev
blob_store: ./script/run-local-minio
@@ -0,0 +1,2 @@
+postgrest_llm: postgrest crates/collab/postgrest_llm.conf
+website: cd ../zed.dev; npm run dev -- --port=3000
@@ -1,5 +1,6 @@
# Zed
+[](https://zed.dev)
[](https://github.com/zed-industries/zed/actions/workflows/ci.yml)
Welcome to Zed, a high-performance, multiplayer code editor from the creators of [Atom](https://github.com/atom/atom) and [Tree-sitter](https://github.com/tree-sitter/tree-sitter).
@@ -0,0 +1,8 @@
+{
+ "label": "",
+ "message": "Zed",
+ "logoSvg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 96 96\"><rect width=\"96\" height=\"96\" fill=\"#000\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M9 6C7.34315 6 6 7.34315 6 9V75H0V9C0 4.02944 4.02944 0 9 0H89.3787C93.3878 0 95.3955 4.84715 92.5607 7.68198L43.0551 57.1875H57V51H63V58.6875C63 61.1728 60.9853 63.1875 58.5 63.1875H37.0551L26.7426 73.5H73.5V36H79.5V73.5C79.5 76.8137 76.8137 79.5 73.5 79.5H20.7426L10.2426 90H87C88.6569 90 90 88.6569 90 87V21H96V87C96 91.9706 91.9706 96 87 96H6.62132C2.61224 96 0.604504 91.1529 3.43934 88.318L52.7574 39H39V45H33V37.5C33 35.0147 35.0147 33 37.5 33H58.7574L69.2574 22.5H22.5V60H16.5V22.5C16.5 19.1863 19.1863 16.5 22.5 16.5H75.2574L85.7574 6H9Z\" fill=\"#fff\"/></svg>",
+ "logoWidth": 16,
+ "labelColor": "black",
+ "color": "white"
+}
@@ -1,8 +1,9 @@
-Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
+Copyright 2019 The Lilex Project Authors (https://github.com/mishamyrt/Lilex)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
-http://scripts.sil.org/OFL
+https://scripts.sil.org/OFL
+
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
@@ -89,4 +90,4 @@ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.
+OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M10.5 8.75V10.5C8.43097 10.5 7.56903 10.5 5.5 10.5V10L10.5 6V5.5H5.5V7.25" stroke="black" stroke-width="1.5"/>
+ <path d="M10.5 8.75V10.5C8.43097 10.5 7.56903 10.5 5.5 10.5V10L10.5 6V5.5H5.5V7.25" stroke="black" stroke-width="1.2"/>
<path d="M1.5 8.5C1.77614 8.5 2 8.27614 2 8C2 7.72386 1.77614 7.5 1.5 7.5C1.22386 7.5 1 7.72386 1 8C1 8.27614 1.22386 8.5 1.5 8.5Z" fill="black"/>
<path d="M2.49976 6.33002C2.7759 6.33002 2.99976 6.10616 2.99976 5.83002C2.99976 5.55387 2.7759 5.33002 2.49976 5.33002C2.22361 5.33002 1.99976 5.55387 1.99976 5.83002C1.99976 6.10616 2.22361 6.33002 2.49976 6.33002Z" fill="black"/>
<path d="M2.49976 10.66C2.7759 10.66 2.99976 10.4361 2.99976 10.16C2.99976 9.88383 2.7759 9.65997 2.49976 9.65997C2.22361 9.65997 1.99976 9.88383 1.99976 10.16C1.99976 10.4361 2.22361 10.66 2.49976 10.66Z" fill="black"/>
@@ -1,4 +1,8 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="283.6413 127.3453 56 55.9999" width="16px" height="16px">
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1 +1,3 @@
@@ -1 +1,3 @@
-<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Gemini</title><path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.44 12.27C7.81333 13.1217 8 14.0317 8 15C8 14.0317 8.18083 13.1217 8.5425 12.27C8.91583 11.4183 9.4175 10.6775 10.0475 10.0475C10.6775 9.4175 11.4183 8.92167 12.27 8.56C13.1217 8.18667 14.0317 8 15 8C14.0317 8 13.1217 7.81917 12.27 7.4575C11.4411 7.1001 10.6871 6.5895 10.0475 5.9525C9.4105 5.31293 8.8999 4.55891 8.5425 3.73C8.18083 2.87833 8 1.96833 8 1C8 1.96833 7.81333 2.87833 7.44 3.73C7.07833 4.58167 6.5825 5.3225 5.9525 5.9525C5.31293 6.5895 4.55891 7.1001 3.73 7.4575C2.87833 7.81917 1.96833 8 1 8C1.96833 8 2.87833 8.18667 3.73 8.56C4.58167 8.92167 5.3225 9.4175 5.9525 10.0475C6.5825 10.6775 7.07833 11.4183 7.44 12.27Z" fill="black"/>
+</svg>
@@ -1,33 +1,15 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
- <title>Artboard</title>
- <g id="Artboard" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
- <rect id="Rectangle" stroke="black" stroke-width="1.26" x="1.22" y="1.22" width="13.56" height="13.56" rx="2.66"></rect>
- <g id="Group-7" transform="translate(2.44, 3.03)" fill="black">
- <g id="Group" transform="translate(0.37, 0)">
- <rect id="Rectangle" opacity="0.487118676" x="1.9" y="0" width="6.28" height="1.43" rx="0.71"></rect>
- <rect id="Rectangle" opacity="0.845098586" x="0" y="0" width="6.28" height="1.43" rx="0.71"></rect>
- </g>
- <g id="Group-2" transform="translate(2.88, 1.7)">
- <rect id="Rectangle" opacity="0.487118676" x="1.9" y="0" width="6.28" height="1.43" rx="0.71"></rect>
- <rect id="Rectangle" opacity="0.845098586" x="0" y="0" width="6.28" height="1.43" rx="0.71"></rect>
- </g>
- <g id="Group-3" transform="translate(1.53, 3.38)">
- <rect id="Rectangle" opacity="0.487118676" x="1.92" y="0" width="6.28" height="1.43" rx="0.71"></rect>
- <rect id="Rectangle" opacity="0.845098586" x="0" y="0" width="6.28" height="1.43" rx="0.71"></rect>
- </g>
- <g id="Group-4" transform="translate(0, 5.09)">
- <rect id="Rectangle" opacity="0.487118676" x="1.9" y="0" width="6.28" height="1.43" rx="0.71"></rect>
- <rect id="Rectangle" opacity="0.845098586" x="0" y="0" width="6.28" height="1.43" rx="0.71"></rect>
- </g>
- <g id="Group-5" transform="translate(1.64, 6.77)">
- <rect id="Rectangle" opacity="0.487118676" x="1.94" y="0" width="5.46" height="1.43" rx="0.71"></rect>
- <rect id="Rectangle" opacity="0.845098586" x="0" y="0" width="5.46" height="1.43" rx="0.71"></rect>
- </g>
- <g id="Group-6" transform="translate(4.24, 8.47)">
- <rect id="Rectangle" opacity="0.487118676" x="2.11" y="0" width="4.56" height="1.43" rx="0.71"></rect>
- <rect id="Rectangle" opacity="0.845098586" x="0" y="0" width="4.56" height="1.43" rx="0.71"></rect>
- </g>
- </g>
- </g>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.146 2H4.85398C3.55391 2 2.5 3.05391 2.5 4.35398V11.646C2.5 12.9461 3.55391 14 4.85398 14H12.146C13.4461 14 14.5 12.9461 14.5 11.646V4.35398C14.5 3.05391 13.4461 2 12.146 2Z" stroke="black" stroke-width="1.11504"/>
+<path opacity="0.487119" d="M10.5177 3.60177H6.21681C5.8698 3.60177 5.58849 3.88308 5.58849 4.23009V4.23894C5.58849 4.58595 5.8698 4.86726 6.21681 4.86726H10.5177C10.8647 4.86726 11.146 4.58595 11.146 4.23894V4.23009C11.146 3.88308 10.8647 3.60177 10.5177 3.60177Z" fill="black"/>
+<path opacity="0.845099" d="M8.83628 3.60177H4.53539C4.18838 3.60177 3.90707 3.88308 3.90707 4.23009V4.23894C3.90707 4.58595 4.18838 4.86726 4.53539 4.86726H8.83628C9.18329 4.86726 9.4646 4.58595 9.4646 4.23894V4.23009C9.4646 3.88308 9.18329 3.60177 8.83628 3.60177Z" fill="black"/>
+<path opacity="0.487119" d="M12.7389 5.10619H8.43806C8.09105 5.10619 7.80974 5.3875 7.80974 5.73451V5.74336C7.80974 6.09037 8.09105 6.37168 8.43806 6.37168H12.7389C13.086 6.37168 13.3673 6.09037 13.3673 5.74336V5.73451C13.3673 5.3875 13.086 5.10619 12.7389 5.10619Z" fill="black"/>
+<path opacity="0.845099" d="M11.0575 5.10619H6.75664C6.40963 5.10619 6.12832 5.3875 6.12832 5.73451V5.74336C6.12832 6.09037 6.40963 6.37168 6.75664 6.37168H11.0575C11.4045 6.37168 11.6858 6.09037 11.6858 5.74336V5.73451C11.6858 5.3875 11.4045 5.10619 11.0575 5.10619Z" fill="black"/>
+<path opacity="0.487119" d="M11.5619 6.59292H7.26106C6.91405 6.59292 6.63274 6.87423 6.63274 7.22124V7.23009C6.63274 7.5771 6.91405 7.85841 7.26106 7.85841H11.5619C11.909 7.85841 12.1903 7.5771 12.1903 7.23009V7.22124C12.1903 6.87423 11.909 6.59292 11.5619 6.59292Z" fill="black"/>
+<path opacity="0.845099" d="M9.86284 6.59292H5.56195C5.21494 6.59292 4.93363 6.87423 4.93363 7.22124V7.23009C4.93363 7.5771 5.21494 7.85841 5.56195 7.85841H9.86284C10.2098 7.85841 10.4912 7.5771 10.4912 7.23009V7.22124C10.4912 6.87423 10.2098 6.59292 9.86284 6.59292Z" fill="black"/>
+<path opacity="0.487119" d="M10.1903 8.10619H5.88937C5.54236 8.10619 5.26105 8.3875 5.26105 8.73451V8.74336C5.26105 9.09037 5.54236 9.37168 5.88937 9.37168H10.1903C10.5373 9.37168 10.8186 9.09037 10.8186 8.74336V8.73451C10.8186 8.3875 10.5373 8.10619 10.1903 8.10619Z" fill="black"/>
+<path opacity="0.845099" d="M8.50886 8.10619H4.20797C3.86096 8.10619 3.57965 8.3875 3.57965 8.73451V8.74336C3.57965 9.09037 3.86096 9.37168 4.20797 9.37168H8.50886C8.85587 9.37168 9.13717 9.09037 9.13717 8.74336V8.73451C9.13717 8.3875 8.85587 8.10619 8.50886 8.10619Z" fill="black"/>
+<path opacity="0.487119" d="M10.9513 9.59292H7.37611C7.0291 9.59292 6.74779 9.87423 6.74779 10.2212V10.2301C6.74779 10.5771 7.0291 10.8584 7.37611 10.8584H10.9513C11.2983 10.8584 11.5796 10.5771 11.5796 10.2301V10.2212C11.5796 9.87423 11.2983 9.59292 10.9513 9.59292Z" fill="black"/>
+<path opacity="0.845099" d="M9.23452 9.59292H5.65929C5.31228 9.59292 5.03098 9.87423 5.03098 10.2212V10.2301C5.03098 10.5771 5.31228 10.8584 5.65929 10.8584H9.23452C9.58153 10.8584 9.86283 10.5771 9.86283 10.2301V10.2212C9.86283 9.87423 9.58153 9.59292 9.23452 9.59292Z" fill="black"/>
+<path opacity="0.487119" d="M12.6062 11.0973H9.82744C9.48043 11.0973 9.19912 11.3787 9.19912 11.7257V11.7345C9.19912 12.0815 9.48043 12.3628 9.82744 12.3628H12.6062C12.9532 12.3628 13.2345 12.0815 13.2345 11.7345V11.7257C13.2345 11.3787 12.9532 11.0973 12.6062 11.0973Z" fill="black"/>
+<path opacity="0.845099" d="M10.7389 11.0973H7.96017C7.61316 11.0973 7.33186 11.3787 7.33186 11.7257V11.7345C7.33186 12.0815 7.61316 12.3628 7.96017 12.3628H10.7389C11.0859 12.3628 11.3673 12.0815 11.3673 11.7345V11.7257C11.3673 11.3787 11.0859 11.0973 10.7389 11.0973Z" fill="black"/>
</svg>
@@ -1 +1,8 @@
-<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><g><path d="M15 6v4h-2V6h2zm4-4v4h-2V2h2zM3 2H1h2zM1 2h2v20H1V2zm8 12h2v4H9v-4zm8 0h2v8h-2v-8z"></path><path d="M19 2h4v4h-4V2zM3 2h4v4H3V2z" opacity=".4"></path><path d="M15 10V6h8v4h-8zM3 10V6h8v4H3z" opacity=".5"></path><path d="M3 14v-4h20v4z" opacity=".6"></path><path d="M11 14h4v4h-4v-4zm8 0h4v4h-4v-4zM3 14h4v4H3v-4z" opacity=".7"></path><path d="M19 18h4v4h-4v-4zM3 18h4v4H3v-4z" opacity=".8"></path></g></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4 4.4V6.8H9.2V4.4H10.4ZM12.8 2V4.4H11.6V2H12.8ZM2 2H3.2V14H2V2ZM6.8 9.2H8V11.6H6.8V9.2ZM11.6 9.2H12.8V14H11.6V9.2Z" fill="black"/>
+<path opacity="0.4" fill-rule="evenodd" clip-rule="evenodd" d="M12.8 2H15.2V4.4H12.8V2ZM3.2 2H5.6V4.4H3.2V2Z" fill="black"/>
+<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M10.4 6.8V4.4H15.2V6.8H10.4ZM3.2 6.8V4.4H8V6.8H3.2Z" fill="black"/>
+<path opacity="0.6" fill-rule="evenodd" clip-rule="evenodd" d="M3.2 9.2V6.8H15.2V9.2H3.2Z" fill="black"/>
+<path opacity="0.7" fill-rule="evenodd" clip-rule="evenodd" d="M8 9.2H10.4V11.6H8V9.2ZM12.8 9.2H15.2V11.6H12.8V9.2ZM3.2 9.2H5.6V11.6H3.2V9.2Z" fill="black"/>
+<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M12.8 11.6H15.2V14H12.8V11.6ZM3.2 11.6H5.6V14H3.2V11.6Z" fill="black"/>
+</svg>
@@ -1,14 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_1896_38)">
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1,8 +1,8 @@
-<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor" stroke="currentColor">
- <g clip-path="url(#clip0_205_3)">
- <path d="M0.094 7.78c0.469 0 2.281 -0.405 3.219 -0.936s0.938 -0.531 2.875 -1.906c2.453 -1.741 4.188 -1.158 7.031 -1.158" stroke-width="2.8125" />
- <path d="m15.969 3.797 -4.805 2.774V1.023z" />
- <path d="M0 7.781c0.469 0 2.281 0.405 3.219 0.936s0.938 0.531 2.875 1.906C8.547 12.364 10.281 11.781 13.125 11.781" stroke-width="2.8125" />
- <path d="m15.875 11.764 -4.805 -2.774v5.548z" />
- </g>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2.54131 7.78012C2.89456 7.78012 4.25937 7.47507 4.96588 7.07512C5.67239 6.67517 5.67239 6.67517 7.13135 5.63951C8.97897 4.32817 10.2858 4.76729 12.4272 4.76729" fill="black"/>
+<path d="M2.54131 7.78012C2.89456 7.78012 4.25937 7.47507 4.96588 7.07512C5.67239 6.67517 5.67239 6.67517 7.13135 5.63951C8.97897 4.32817 10.2858 4.76729 12.4272 4.76729" stroke="black" stroke-width="2.8125"/>
+<path d="M14.4985 4.7801L10.8793 6.86949V2.6907L14.4985 4.7801Z" fill="black" stroke="black"/>
+<path d="M2.47052 7.78088C2.82377 7.78088 4.18859 8.08593 4.8951 8.48588C5.60161 8.88583 5.6016 8.88583 7.06057 9.92149C8.90819 11.2328 10.2142 10.7937 12.3564 10.7937" fill="black"/>
+<path d="M2.47052 7.78088C2.82377 7.78088 4.18859 8.08593 4.8951 8.48588C5.60161 8.88583 5.6016 8.88583 7.06057 9.92149C8.90819 11.2328 10.2142 10.7937 12.3564 10.7937" stroke="black" stroke-width="2.8125"/>
+<path d="M14.4277 10.7809L10.8085 8.6915V12.8703L14.4277 10.7809Z" fill="black" stroke="black"/>
</svg>
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.8451 5.50949L13.1109 15H15.2342L15.5 2.05527L12.8451 5.50949ZM15.499 1H12.2574L7.17206 7.61904L8.79335 9.72761L15.499 1ZM1.5 14.999H4.73963L6.36092 12.8905L4.73963 10.7809L1.5 14.999ZM1.5 5.50851L8.79335 14.999H12.034L4.74061 5.50949L1.5 5.50851Z" fill="black"/>
+</svg>
@@ -1,10 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_1882_101)">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M2.3125 1.875C2.07088 1.875 1.875 2.07088 1.875 2.3125V11.9375H1V2.3125C1 1.58763 1.58763 1 2.3125 1H14.0344C14.6191 1 14.9118 1.70688 14.4984 2.12029L7.27887 9.33984H9.3125V8.4375H10.1875V9.55859C10.1875 9.92103 9.89369 10.2148 9.53125 10.2148H6.40387L4.89996 11.7187H11.7187V6.25H12.5937V11.7187C12.5937 12.202 12.202 12.5937 11.7187 12.5937H4.02496L2.49371 14.125H13.6875C13.9291 14.125 14.125 13.9291 14.125 13.6875V4.0625H15V13.6875C15 14.4124 14.4124 15 13.6875 15H1.96561C1.38095 15 1.08816 14.2931 1.50157 13.8797L8.69379 6.6875H6.6875V7.5625H5.8125V6.46875C5.8125 6.10631 6.10631 5.8125 6.46875 5.8125H9.56879L11.1 4.28125H4.28125V9.75H3.40625V4.28125C3.40625 3.798 3.798 3.40625 4.28125 3.40625H11.975L13.5063 1.875H2.3125Z" fill="black"/>
-</g>
-<defs>
-<clipPath id="clip0_1882_101">
-<rect width="14" height="14" fill="white" transform="translate(1 1)"/>
-</clipPath>
-</defs>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M3.625 2.75C3.4179 2.75 3.25 2.9179 3.25 3.125V11.375H2.5V3.125C2.5 2.50368 3.00368 2 3.625 2H13.6723C14.1735 2 14.4244 2.6059 14.0701 2.96025L7.88189 9.14843H9.625V8.375H10.375V9.33593C10.375 9.6466 10.1232 9.8984 9.8125 9.8984H7.13189L5.84282 11.1875H11.6875V6.5H12.4375V11.1875C12.4375 11.6017 12.1017 11.9375 11.6875 11.9375H5.09282L3.78032 13.25H13.375C13.5821 13.25 13.75 13.0821 13.75 12.875V4.625H14.5V12.875C14.5 13.4963 13.9963 14 13.375 14H3.32767C2.82653 14 2.57557 13.3941 2.92992 13.0397L9.09468 6.875H7.375V7.625H6.625V6.6875C6.625 6.37684 6.87684 6.125 7.1875 6.125H9.84468L11.1571 4.8125H5.3125V9.5H4.5625V4.8125C4.5625 4.39829 4.89829 4.0625 5.3125 4.0625H11.9071L13.2197 2.75H3.625Z" fill="black"/>
</svg>
@@ -1,6 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3 8C3 6.67392 3.52678 5.40215 4.46446 4.46447C5.40214 3.52679 6.67391 3.00001 7.99999 3.00001C9.39779 3.00527 10.7394 3.55069 11.7444 4.52223L13 5.77778" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M13 3.00001V5.77778H10.2222" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M13 8C13 9.32608 12.4732 10.5978 11.5355 11.5355C10.5978 12.4732 9.32607 13 7.99999 13C6.60219 12.9947 5.26054 12.4493 4.25555 11.4778L3 10.2222" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5.77777 10.2222H3V13" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.8989 5.77778L11.6434 4.52222C10.6384 3.55068 9.29673 3.00526 7.89893 3C6.57285 3 5.30103 3.52678 4.36343 4.46447C3.78887 5.03901 3.36856 5.73897 3.12921 6.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.8989 3V5.77778H10.1211" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.1012 10.2222L4.3568 11.4778C5.3618 12.4493 6.70342 12.9947 8.10122 13C9.42731 13 10.6991 12.4732 11.6368 11.5355C12.2163 10.956 12.6389 10.2487 12.8772 9.47994" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.87891 10.2222H3.10111V13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8.00001 12L3.5 7.50001M8.00001 12L12.5 7.50001M8.00001 12L8.00001 3.00001" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 13L12.5 8.5M8 13L3.5 8.5M8 13V3" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down10-icon lucide-arrow-down-1-0"><path d="m3 16 4 4 4-4"/><path d="M7 20V4"/><path d="M17 10V4h-2"/><path d="M15 10h4"/><rect x="15" y="14" width="4" height="6" ry="2"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="m2.5 10.667 2.667 2.666 2.666-2.666M5.167 13.333V2.667M11.833 6.667v-4H10.5M10.5 6.667h2.667M13.167 10.667a1.333 1.333 0 0 0-2.667 0V12a1.333 1.333 0 0 0 2.667 0v-1.333Z"/></svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-from-line"><path d="M19 3H5"/><path d="M12 21V7"/><path d="m6 15 6 6 6-6"/></svg>
@@ -1 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-right-icon lucide-arrow-down-right"><path d="m7 7 10 10"/><path d="M17 7v10H7"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.25 4.25L11.125 11.125" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.75 4.25006V11.7501H4.25" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3.5 7.50001L8 3M3.5 7.50001L8 12M3.5 7.50001H12.5" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.5 7.50001L8 3M3.5 7.50001L8 12M3.5 7.50001H12.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M12.5 7.5L8 12M12.5 7.5L8 3M12.5 7.5L3.5 7.5" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.5 8L8 12.5M12.5 8L8 3.5M12.5 8H3.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +1,6 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right-left"><path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 2L13 4.5L11 7" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.5 4.5H2.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 14L3 11.5L5 9" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3 11.5H13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7.99999 3.00001L12.5 7.50001M7.99999 3.00001L3.49999 7.50001M7.99999 3.00001L7.99999 12" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 3L12.5 7.5M8 3L3.5 7.5M8 3V13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,3 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8 3.5L12.5 8M8 3.5L3.5 8M8 3.5V12.5" stroke="black" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-from-line"><path d="m18 9-6-6-6 6"/><path d="M12 3v14"/><path d="M5 21h14"/></svg>
@@ -1 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right-icon lucide-arrow-up-right"><path d="M7 7h10v10"/><path d="M7 17 17 7"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.5 11.5L11.5 4.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.5 4.5L11.5 4.5L11.5 11.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,3 +0,0 @@
-<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5.4 2.6H5.75C5.75 2.50717 5.71312 2.41815 5.64749 2.35251C5.58185 2.28688 5.49283 2.25 5.4 2.25V2.6ZM2.6 2.25C2.4067 2.25 2.25 2.4067 2.25 2.6C2.25 2.7933 2.4067 2.95 2.6 2.95V2.25ZM5.05 5.4C5.05 5.5933 5.2067 5.75 5.4 5.75C5.5933 5.75 5.75 5.5933 5.75 5.4H5.05ZM2.35252 5.15251C2.21583 5.2892 2.21583 5.5108 2.35252 5.64748C2.4892 5.78417 2.7108 5.78417 2.84749 5.64748L2.35252 5.15251ZM5.4 2.25H2.6V2.95H5.4V2.25ZM5.05 2.6V5.4H5.75V2.6H5.05ZM5.15252 2.35251L2.35252 5.15251L2.84749 5.64748L5.64749 2.84748L5.15252 2.35251Z" fill="black"/>
-</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-at-sign"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/></svg>
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.37288 4.48506L7.43539 10.6638C7.43539 10.9365 7.54373 11.1981 7.73655 11.3909C7.92938 11.5837 8.19092 11.6921 8.46362 11.6921C8.73632 11.6921 8.99785 11.5837 9.19068 11.3909C9.38351 11.1981 9.49184 10.9366 9.49184 10.6638L9.42933 4.48506C9.42933 3.93975 9.2127 3.41678 8.82711 3.03119C8.44152 2.6456 7.91855 2.42898 7.37324 2.42898C6.82794 2.42898 6.30496 2.6456 5.91937 3.03119C5.53378 3.41678 5.31716 3.93975 5.31716 4.48506L5.37968 10.6384C5.37636 11.0455 5.45368 11.4492 5.60718 11.8263C5.76067 12.2034 5.98731 12.5463 6.27401 12.8354C6.56071 13.1244 6.9018 13.3538 7.27761 13.5104C7.65341 13.667 8.0565 13.7476 8.46362 13.7476C8.87073 13.7476 9.27382 13.667 9.64963 13.5104C10.0254 13.3538 10.3665 13.1244 10.6532 12.8354C10.9399 12.5463 11.1666 12.2034 11.3201 11.8263C11.4736 11.4492 11.5509 11.0455 11.5476 10.6384L11.485 4.48506" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +1,7 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-volume-off"><path d="M16 9a5 5 0 0 1 .95 2.293"/><path d="M19.364 5.636a9 9 0 0 1 1.889 9.96"/><path d="m2 2 20 20"/><path d="m7 7-.587.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298V11"/><path d="M9.828 4.172A.686.686 0 0 1 11 4.657v.686"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.6667 6C11.003 6.44823 11.2208 6.97398 11.3001 7.52867" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.9094 3.75732C13.7621 4.6095 14.3383 5.69876 14.5629 6.88315C14.7875 8.06754 14.6502 9.29213 14.1688 10.3973" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2.66675 2L13.6667 13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.33333 4.66669L4.942 5.05802C4.85494 5.1456 4.75136 5.21504 4.63726 5.2623C4.52317 5.30957 4.40083 5.33372 4.27733 5.33335H2.66667C2.48986 5.33335 2.32029 5.40359 2.19526 5.52862C2.07024 5.65364 2 5.82321 2 6.00002V10C2 10.1768 2.07024 10.3464 2.19526 10.4714C2.32029 10.5964 2.48986 10.6667 2.66667 10.6667H4.27733C4.40083 10.6663 4.52317 10.6905 4.63726 10.7377C4.75136 10.785 4.85494 10.8544 4.942 10.942L7.19733 13.198C7.26307 13.2639 7.34687 13.3088 7.43813 13.3269C7.52939 13.3451 7.62399 13.3358 7.70995 13.3002C7.79591 13.2646 7.86936 13.2042 7.921 13.1268C7.97263 13.0494 8.00013 12.9584 8 12.8654V7.33335" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.21875 2.78136C7.28267 2.71719 7.36421 2.67345 7.45303 2.65568C7.54184 2.63791 7.63393 2.64691 7.71762 2.68154C7.80132 2.71618 7.87284 2.77488 7.92312 2.85022C7.97341 2.92555 8.0002 3.01412 8.00008 3.10469V3.56202" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +1,5 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-volume-2"><path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z"/><path d="M16 9a5 5 0 0 1 0 6"/><path d="M19.364 18.364a9 9 0 0 0 0-12.728"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 3.13467C7.99987 3.04181 7.97223 2.95107 7.92057 2.8739C7.86892 2.79674 7.79557 2.7366 7.70977 2.70108C7.62397 2.66557 7.52958 2.65626 7.43849 2.67434C7.34741 2.69242 7.26373 2.73707 7.198 2.80266L4.942 5.058C4.85494 5.14558 4.75136 5.21502 4.63726 5.26228C4.52317 5.30954 4.40083 5.33369 4.27733 5.33333H2.66667C2.48986 5.33333 2.32029 5.40357 2.19526 5.52859C2.07024 5.65362 2 5.82319 2 6V10C2 10.1768 2.07024 10.3464 2.19526 10.4714C2.32029 10.5964 2.48986 10.6667 2.66667 10.6667H4.27733C4.40083 10.6663 4.52317 10.6905 4.63726 10.7377C4.75136 10.785 4.85494 10.8544 4.942 10.942L7.19733 13.198C7.26307 13.2639 7.34687 13.3087 7.43813 13.3269C7.52939 13.3451 7.62399 13.3358 7.70995 13.3002C7.79591 13.2645 7.86936 13.2042 7.921 13.1268C7.97263 13.0494 8.00013 12.9584 8 12.8653V3.13467Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.6667 6C11.0995 6.57699 11.3334 7.27877 11.3334 8C11.3334 8.72123 11.0995 9.42301 10.6667 10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.9094 12.2427C13.4666 11.6855 13.9085 11.0241 14.2101 10.2961C14.5116 9.56815 14.6668 8.78793 14.6668 7.99999C14.6668 7.21205 14.5116 6.43183 14.2101 5.70387C13.9085 4.97591 13.4666 4.31448 12.9094 3.75732" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,3 +1,5 @@
-<svg width="15" height="11" viewBox="0 0 15 11" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5.24432 11L0.183239 5.90909L5.24432 0.818182H14.75V11H5.24432ZM5.68679 9.90625H13.6761V1.91193H5.68679L1.70952 5.90909L5.68679 9.90625ZM11.7223 8.15625L10.9964 8.89205L5.75639 3.66193L6.48224 2.92614L11.7223 8.15625ZM6.48224 8.89205L5.75639 8.15625L10.9964 2.92614L11.7223 3.66193L6.48224 8.89205Z" fill="black"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.79998 4C6.50183 4.00002 6.21436 4.10574 5.99358 4.29657L2.19677 7.57657C2.1348 7.63013 2.08528 7.69545 2.05139 7.76832C2.01751 7.8412 2 7.92001 2 7.99971C2 8.07941 2.01751 8.15823 2.05139 8.23111C2.08528 8.30398 2.1348 8.36929 2.19677 8.42286L5.99358 11.7034C6.21436 11.8943 6.50183 12 6.79998 12H12.8C13.1183 12 13.4235 11.8796 13.6485 11.6653C13.8736 11.4509 14 11.1602 14 10.8571V5.14286C14 4.83975 13.8736 4.54906 13.6485 4.33474C13.4235 4.12041 13.1183 4 12.8 4H6.79998Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8.5 6.5L11.5 9.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.5 6.5L8.5 9.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M5.33333 5.8C5.33333 5.05739 5.61429 4.3452 6.11438 3.8201C6.61448 3.295 7.29276 3 8 3C8.70724 3 9.38552 3.295 9.88562 3.8201C10.3857 4.3452 10.6667 5.05739 10.6667 5.8C10.6667 9.06667 12 10 12 10H4C4 10 5.33333 9.06667 5.33333 5.8Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M6 12.5C6.19692 12.8028 6.48641 13.0554 6.83822 13.2313C7.19004 13.4072 7.59127 13.5 8 13.5C8.40873 13.5 8.80996 13.4072 9.16178 13.2313C9.51359 13.0554 9.80308 12.8028 10 12.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M5.33333 5.8C5.33333 5.05739 5.61429 4.3452 6.11438 3.8201C6.61448 3.295 7.29276 3 8 3C8.70724 3 9.38552 3.295 9.88562 3.8201C10.3857 4.3452 10.6667 5.05739 10.6667 5.8C10.6667 9.06667 12 10 12 10H4C4 10 5.33333 9.06667 5.33333 5.8Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M6 12.5C6.19692 12.8028 6.48641 13.0554 6.83822 13.2313C7.19004 13.4072 7.59127 13.5 8 13.5C8.40873 13.5 8.80996 13.4072 9.16178 13.2313C9.51359 13.0554 9.80308 12.8028 10 12.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M4.86142 8.6961C4.47786 9.66547 4 9.99997 4 9.99997H12C12 9.99997 10.6667 9.06664 10.6667 5.79997C10.6667 5.05737 10.3857 4.34518 9.88562 3.82007C9.52389 3.44026 9.06893 3.18083 8.57722 3.06635" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M6 12.5C6.19692 12.8028 6.48641 13.0554 6.83822 13.2313C7.19004 13.4072 7.59127 13.5 8 13.5C8.40873 13.5 8.80996 13.4072 9.16178 13.2313C9.51359 13.0554 9.80308 12.8028 10 12.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M4.86142 8.6961C4.47786 9.66547 4 9.99997 4 9.99997H12C12 9.99997 10.6667 9.06664 10.6667 5.79997C10.6667 5.05737 10.3857 4.34518 9.88562 3.82007C9.52389 3.44026 9.06893 3.18083 8.57722 3.06635" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M6 12.5C6.19692 12.8028 6.48641 13.0554 6.83822 13.2313C7.19004 13.4072 7.59127 13.5 8 13.5C8.40873 13.5 8.80996 13.4072 9.16178 13.2313C9.51359 13.0554 9.80308 12.8028 10 12.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="4.5" cy="4.5" r="2.5" fill="black"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M10.6667 5.8C10.6667 5.05739 10.3857 4.3452 9.88562 3.8201C9.38552 3.295 8.70724 3 8 3C7.29276 3 6.61448 3.295 6.11438 3.8201C5.61428 4.3452 5.33333 5.05739 5.33333 5.8C5.33333 9.06667 4 10 4 10H7.375" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M6 12.5C6.19692 12.8028 6.48641 13.0554 6.83822 13.2313C7.19004 13.4072 7.59127 13.5 8 13.5C8.40873 13.5 8.80996 13.4072 9.16178 13.2313C9.51359 13.0554 9.80308 12.8028 10 12.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M4 3L12.5 11.5" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+ <path d="M10.6667 5.8C10.6667 5.05739 10.3857 4.3452 9.88562 3.8201C9.38552 3.295 8.70724 3 8 3C7.29276 3 6.61448 3.295 6.11438 3.8201C5.61428 4.3452 5.33333 5.05739 5.33333 5.8C5.33333 9.06667 4 10 4 10H7.375" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M6 12.5C6.19692 12.8028 6.48641 13.0554 6.83822 13.2313C7.19004 13.4072 7.59127 13.5 8 13.5C8.40873 13.5 8.80996 13.4072 9.16178 13.2313C9.51359 13.0554 9.80308 12.8028 10 12.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M4 3L12.5 11.5" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,6 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M5.33333 5.8C5.33333 5.05739 5.61429 4.3452 6.11438 3.8201C6.61448 3.295 7.29276 3 8 3C8.70724 3 9.38552 3.295 9.88562 3.8201C10.3857 4.3452 10.6667 5.05739 10.6667 5.8C10.6667 9.06667 12 10 12 10H4C4 10 5.33333 9.06667 5.33333 5.8Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M6 12.5C6.19692 12.8028 6.48641 13.0554 6.83822 13.2313C7.19004 13.4072 7.59127 13.5 8 13.5C8.40873 13.5 8.80996 13.4072 9.16178 13.2313C9.51359 13.0554 9.80308 12.8028 10 12.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M12 2.02081C12.617 2.89491 13.0754 3.88797 13.2528 5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M4 2.02081C3.38299 2.89491 2.92461 3.88797 2.74719 5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M5.33333 5.8C5.33333 5.05739 5.61429 4.3452 6.11438 3.8201C6.61448 3.295 7.29276 3 8 3C8.70724 3 9.38552 3.295 9.88562 3.8201C10.3857 4.3452 10.6667 5.05739 10.6667 5.8C10.6667 9.06667 12 10 12 10H4C4 10 5.33333 9.06667 5.33333 5.8Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M6 12.5C6.19692 12.8028 6.48641 13.0554 6.83822 13.2313C7.19004 13.4072 7.59127 13.5 8 13.5C8.40873 13.5 8.80996 13.4072 9.16178 13.2313C9.51359 13.0554 9.80308 12.8028 10 12.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M12 2.02081C12.617 2.89491 13.0754 3.88797 13.2528 5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M4 2.02081C3.38299 2.89491 2.92461 3.88797 2.74719 5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-binary-icon lucide-binary"><rect x="14" y="14" width="4" height="6" rx="2"/><rect x="6" y="4" width="4" height="6" rx="2"/><path d="M6 20h4"/><path d="M14 10h4"/><path d="M6 14h2v6"/><path d="M14 4h2v6"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M12 10.667a1.333 1.333 0 1 0-2.667 0V12A1.333 1.333 0 1 0 12 12v-1.333ZM6.667 4A1.333 1.333 0 0 0 4 4v1.333a1.333 1.333 0 1 0 2.667 0V4ZM4 13.333h2.667M9.333 6.667H12M4 9.333h1.333v4M9.333 2.667h1.334v4"/></svg>
@@ -1 +1,3 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-blocks-icon lucide-blocks"><rect width="7" height="7" x="14" y="3" rx="1"/><path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1,3 +0,0 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M9.3 1.75L3 7.35H5.8L4.7 12.25L11 6.65H8.2L9.3 1.75Z" stroke="black" stroke-width="1.25" stroke-linejoin="round"/>
-</svg>
@@ -1,3 +1,3 @@
-<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4.76019 3.50003H6.50231C6.71012 3.50003 6.89761 3.62971 6.95698 3.82346C7.04292 4.01876 6.98823 4.23906 6.83199 4.37656L2.83214 7.87643C2.65558 8.02954 2.39731 8.04204 2.20857 7.90455C2.01967 7.76705 1.95092 7.51706 2.04295 7.30301L3.24462 4.49999H1.48844C1.29423 4.49999 1.10767 4.37031 1.0344 4.17657C0.961132 3.98126 1.01643 3.76096 1.17323 3.62346L5.17261 0.123753C5.34917 -0.0299914 5.60697 -0.0417097 5.79603 0.0954726C5.98508 0.232749 6.05383 0.482177 5.96165 0.69695L4.76013 3.49981L4.76019 3.50003Z" fill="white"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.48836 2.06572C9.62447 2.1282 9.73467 2.23101 9.80181 2.35814C9.86896 2.48527 9.88927 2.62958 9.8596 2.76863L9.10795 6.28572H12.8525C12.9843 6.28571 13.1133 6.32112 13.2242 6.38774C13.335 6.45435 13.4231 6.54936 13.4779 6.66146C13.5326 6.77354 13.5518 6.89799 13.5331 7.01997C13.5143 7.14197 13.4585 7.25635 13.3722 7.34951L7.41396 13.7785C7.31457 13.8856 7.18007 13.959 7.03146 13.9872C6.88284 14.0153 6.72841 13.9968 6.59222 13.9344C6.45604 13.872 6.34575 13.7693 6.27851 13.6421C6.21127 13.515 6.19086 13.3707 6.22048 13.2316L6.97213 9.71452H3.22758C3.0958 9.71453 2.96679 9.67912 2.85591 9.61251C2.74505 9.54589 2.65697 9.45088 2.60221 9.33879C2.54744 9.22671 2.52829 9.10225 2.54702 8.98027C2.56575 8.85827 2.62157 8.7439 2.70784 8.65074L8.66611 2.22173C8.76554 2.1145 8.90011 2.04105 9.04884 2.01284C9.19758 1.98462 9.3521 2.00321 9.48836 2.06572Z" fill="black"/>
</svg>
@@ -1,3 +0,0 @@
-<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M6.98749 1.67322C7.08029 1.71878 7.15543 1.79374 7.20121 1.88643C7.24699 1.97912 7.26084 2.08434 7.24061 2.18572L6.72812 4.75007H9.28122C9.37107 4.75006 9.45903 4.77588 9.53463 4.82445C9.61022 4.87302 9.67027 4.94229 9.70761 5.02402C9.74495 5.10574 9.75801 5.19648 9.74524 5.28542C9.73247 5.37437 9.69441 5.45776 9.63559 5.52569L5.57313 10.2131C5.50536 10.2912 5.41366 10.3447 5.31233 10.3653C5.211 10.3858 5.10571 10.3723 5.01285 10.3268C4.92 10.2813 4.8448 10.2064 4.79896 10.1137C4.75311 10.021 4.7392 9.9158 4.75939 9.81439L5.27188 7.25004H2.71878C2.62893 7.25005 2.54097 7.22423 2.46537 7.17566C2.38978 7.12709 2.32973 7.05782 2.29239 6.97609C2.25505 6.89437 2.24199 6.80363 2.25476 6.71469C2.26753 6.62574 2.30559 6.54235 2.36441 6.47443L6.42687 1.78697C6.49466 1.70879 6.58641 1.65524 6.68782 1.63467C6.78923 1.61409 6.89459 1.62765 6.98749 1.67322Z" fill="black"/>
-</svg>
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.29787 2.8462C9.41607 2.90046 9.51178 2.98975 9.5701 3.10016C9.62841 3.21057 9.64605 3.3359 9.62028 3.45666L8.96749 6.51117H12.2195C12.334 6.51115 12.446 6.54191 12.5423 6.59976C12.6386 6.65762 12.7151 6.74013 12.7627 6.83748C12.8102 6.93482 12.8269 7.04291 12.8106 7.14885C12.7943 7.2548 12.7458 7.35413 12.6709 7.43504L7.49631 13.0184C7.40998 13.1115 7.29318 13.1752 7.16411 13.1997C7.03504 13.2241 6.90092 13.2081 6.78264 13.1539C6.66437 13.0997 6.56859 13.0104 6.5102 12.9C6.4518 12.7896 6.43408 12.6643 6.45979 12.5435L7.11259 9.48899H3.86054C3.74609 9.489 3.63405 9.45825 3.53776 9.40039C3.44147 9.34254 3.36498 9.26003 3.31742 9.16268C3.26986 9.06534 3.25322 8.95725 3.26949 8.85131C3.28576 8.74536 3.33423 8.64603 3.40916 8.56513L8.58377 2.98169C8.67012 2.88856 8.78699 2.82478 8.91616 2.80028C9.04533 2.77576 9.17953 2.79192 9.29787 2.8462Z" fill="black" fill-opacity="0.15" stroke="black" stroke-width="1.2"/>
+</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M4 12.125v-8.25c0-.365.132-.714.366-.972.235-.258.552-.403.884-.403H12v11H5.25c-.332 0-.65-.145-.884-.403A1.448 1.448 0 0 1 4 12.125Zm0 0c0-.365.132-.714.366-.972.235-.258.552-.403.884-.403H12"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-copy"><path d="M2 16V4a2 2 0 0 1 2-2h11"/><path d="M5 14H4a2 2 0 1 0 0 4h1"/><path d="M22 18H11a2 2 0 1 0 0 4h11V6H11a2 2 0 0 0-2 2v12"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M2.5 10.5V3.643c0-.303.113-.594.315-.808.202-.215.476-.335.762-.335H9.5"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M4.5 9.5h-.667c-.353 0-.692.105-.942.293-.25.187-.391.442-.391.707 0 .265.14.52.39.707.25.188.59.293.943.293H4.5M13.5 11.25H7.577c-.286 0-.56.118-.762.33a1.151 1.151 0 0 0-.315.795m0 0c0 .298.113.585.315.796.202.21.476.329.762.329H13.5v-9H7.577c-.286 0-.56.119-.762.33a1.151 1.151 0 0 0-.315.795v6.75Z"/></svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-plus"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/><path d="M9 10h6"/><path d="M12 7v6"/></svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-brain"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/><path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/><path d="M3.477 10.896a4 4 0 0 1 .585-.396"/><path d="M19.938 10.5a4 4 0 0 1 .585.396"/><path d="M6 18a4 4 0 0 1-1.967-.516"/><path d="M19.967 17.484A4 4 0 0 1 18 18"/></svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bug-off-icon lucide-bug-off"><path d="M15 7.13V6a3 3 0 0 0-5.14-2.1L8 2"/><path d="M14.12 3.88 16 2"/><path d="M22 13h-4v-2a4 4 0 0 0-4-4h-1.3"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="m2 2 20 20"/><path d="M7.7 7.7A4 4 0 0 0 6 11v3a6 6 0 0 0 11.13 3.13"/><path d="M12 20v-8"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/></svg>
@@ -1,8 +0,0 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path
- fill-rule="evenodd"
- clip-rule="evenodd"
- d="M4.18179 6.18181C4.35753 6.00608 4.64245 6.00608 4.81819 6.18181L7.49999 8.86362L10.1818 6.18181C10.3575 6.00608 10.6424 6.00608 10.8182 6.18181C10.9939 6.35755 10.9939 6.64247 10.8182 6.81821L7.81819 9.81821C7.73379 9.9026 7.61934 9.95001 7.49999 9.95001C7.38064 9.95001 7.26618 9.9026 7.18179 9.81821L4.18179 6.81821C4.00605 6.64247 4.00605 6.35755 4.18179 6.18181Z"
- fill="currentColor"
- />
-</svg>
@@ -1,8 +0,0 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path
- fill-rule="evenodd"
- clip-rule="evenodd"
- d="M4.18179 8.81819C4.00605 8.64245 4.00605 8.35753 4.18179 8.18179L7.18179 5.18179C7.26618 5.0974 7.38064 5.04999 7.49999 5.04999C7.61933 5.04999 7.73379 5.0974 7.81819 5.18179L10.8182 8.18179C10.9939 8.35753 10.9939 8.64245 10.8182 8.81819C10.6424 8.99392 10.3575 8.99392 10.1818 8.81819L7.49999 6.13638L4.81819 8.81819C4.64245 8.99392 4.35753 8.99392 4.18179 8.81819Z"
- fill="currentColor"
- />
-</svg>
@@ -1,8 +1 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="12px" height="12px" viewBox="0 0 12 12" version="1.1">
-<g id="surface1">
-<path style=" stroke:none;fill-rule:nonzero;fill:rgb(47.058824%,49.019608%,52.941176%);fill-opacity:1;" d="M 2.976562 2.746094 L 4.226562 2.746094 L 6.105469 9.296875 L 5.285156 9.296875 L 4.804688 7.640625 L 2.386719 7.640625 L 1.914062 9.296875 L 1.097656 9.296875 Z M 4.621094 6.917969 L 3.640625 3.449219 L 3.5625 3.449219 L 2.582031 6.917969 Z M 4.621094 6.917969 "/>
-<path style=" stroke:none;fill-rule:evenodd;fill:rgb(47.058824%,49.019608%,52.941176%);fill-opacity:1;" d="M 2.878906 2.617188 L 4.324219 2.617188 L 6.277344 9.425781 L 5.191406 9.425781 L 4.707031 7.769531 L 2.484375 7.769531 L 2.011719 9.425781 L 0.925781 9.425781 Z M 3.601562 3.785156 L 2.75 6.789062 L 4.453125 6.789062 Z M 3.601562 3.785156 "/>
@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.17279 8.26346C4.87566 8.62402 5.68419 8.72168 6.4527 8.53885C7.2212 8.35601 7.89913 7.90471 8.36433 7.26626C8.82953 6.62781 9.0514 5.8442 8.98996 5.05664C8.92852 4.26908 8.58781 3.52936 8.02922 2.97078C7.47064 2.41219 6.73092 2.07148 5.94336 2.01004C5.1558 1.9486 4.37219 2.17047 3.73374 2.63567C3.09529 3.10087 2.64399 3.7788 2.46115 4.5473C2.27832 5.31581 2.37598 6.12435 2.73654 6.82721L2 9L4.17279 8.26346Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.07168 11C7.16761 11.4537 7.35843 11.8857 7.63567 12.2662C8.10087 12.9047 8.7788 13.356 9.5473 13.5388C10.3158 13.7217 11.1243 13.624 11.8272 13.2634L14 14L13.2635 11.8272C13.624 11.1243 13.7217 10.3158 13.5388 9.54728C13.356 8.77877 12.9047 8.10084 12.2663 7.63564C11.8858 7.3584 11.4537 7.16759 11 7.07166" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M4.625 8.98121L7.03402 10.7714L11.3437 4.75989" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.625 8.98121L7.03402 10.7714L11.3437 4.75989" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1,4 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5 8L6.5 9L9 5.5" stroke="#11181C" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<circle cx="7" cy="7" r="4.875" stroke="#11181C" stroke-width="1.25"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5.94873 9.02564L7.48722 10.0513L10.0514 6.46149" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z" stroke="black" stroke-width="1.2"/>
</svg>
@@ -1 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-check-icon lucide-check-check"><path d="M18 6 7 17l-5-5"/><path d="m22 10-7.5 7.5L13 16"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.5999 4.38336L4.99996 10.9833L2 7.98332" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M14 6.78339L9.50009 11.2833L8.6001 10.3833" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,3 +1,3 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3.63281 5.66406L6.99344 8.89844L10.3672 5.66406" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.15186 6.47321L7.99258 10.1696L11.8483 6.47321" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,3 +0,0 @@
-<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3.49574 4.74787L5.99574 7.25214L8.49574 4.74787" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1,3 +1,3 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8.35938 3.63281L5.125 6.99344L8.35938 10.3672" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.55361 4.15179L5.85718 7.99251L9.55361 11.8482" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,3 +1,3 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5.64062 3.64062L8.89062 7.00125L5.64062 10.375" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.44653 4.16071L10.1608 8.00143L6.44653 11.8571" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,3 +1,3 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3.63281 8.36719L6.99344 5.13281L10.3672 8.36719" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.15186 9.56252L7.99258 5.86609L11.8483 9.56252" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.66675 10L8.00008 13.3333L11.3334 10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.66675 6.00002L8.00008 2.66669L11.3334 6.00002" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +1,3 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="7.25" cy="7.25" r="3" fill="currentColor"></circle></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z" fill="black"/>
+</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM12.1187 5.99372L10.8813 4.75628L6.6875 8.95006L5.11872 7.38128L3.88128 8.61872L6.6875 11.4249L12.1187 5.99372Z" fill="white"/>
+<path d="M8 2.5C11.0376 2.5 13.5 4.96243 13.5 8C13.5 11.0376 11.0376 13.5 8 13.5C4.96243 13.5 2.5 11.0376 2.5 8C2.5 4.96243 4.96243 2.5 8 2.5ZM10.6699 5.37598C10.1196 5.06178 9.40923 5.20902 9.0332 5.73535L7.17188 8.33887L6.6416 7.98633L6.62207 7.97461L6.55566 7.93457L6.54395 7.92676L6.53125 7.91992C6.00582 7.64262 5.35445 7.7754 4.97949 8.23633L4.9082 8.33301C4.55035 8.87107 4.66306 9.58687 5.15234 9.99023L5.16211 9.99805L5.17188 10.0049L5.2334 10.0508L5.25488 10.0664L6.79297 11.0918C7.35476 11.4663 8.11112 11.3257 8.50293 10.7783H8.50391L11.0684 7.18848V7.1875C11.4684 6.62649 11.3395 5.84687 10.7783 5.44531V5.44434L10.6699 5.37598Z" fill="black" stroke="black"/>
</svg>
@@ -1 +1,5 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-help-icon lucide-circle-help"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.54492 6.5C6.66248 6.16582 6.8945 5.88404 7.19991 5.70455C7.50532 5.52506 7.86439 5.45945 8.21354 5.51934C8.56268 5.57922 8.87937 5.76075 9.1075 6.03175C9.33564 6.30276 9.4605 6.64576 9.45997 7C9.45997 8.00002 7.95994 8.50003 7.95994 8.50003" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 10.5H8.005" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-off-icon lucide-circle-off"><path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0 1 21.3 15.65"/><path d="M19.08 19.08A10 10 0 1 1 4.92 4.92"/></svg>
@@ -1,3 +1,3 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M9.82843 4.17157L4.17157 9.82842M9.82843 9.82842L4.17157 4.17157" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.70581 4.5L11.294 11.5M11.294 4.5L4.70581 11.5" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/></svg>
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8.001 9v4l-2-2M8.001 13l2-2"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M3.436 10.389a4.215 4.215 0 0 1-1.424-3.484 4.227 4.227 0 0 1 1.92-3.236 4.19 4.19 0 0 1 5.335.665 4.22 4.22 0 0 1 .96 1.677H11.3c.584 0 1.151.19 1.618.54a2.71 2.71 0 0 1 .913 3.116A2.709 2.709 0 0 1 12.762 11"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-code-xml"><path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="m11.75 10.5 2.5-2.5-2.5-2.5M4.25 5.5 1.75 8l2.5 2.5M9.563 3 6.437 13"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cog"><path d="M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z"/><path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/><path d="M12 2v2"/><path d="M12 22v-2"/><path d="m17 20.66-1-1.73"/><path d="M11 10.27 7 3.34"/><path d="m20.66 17-1.73-1"/><path d="m3.34 7 1.73 1"/><path d="M14 12h8"/><path d="M2 12h2"/><path d="m20.66 7-1.73 1"/><path d="m3.34 17 1.73-1"/><path d="m17 3.34-1 1.73"/><path d="m11 13.73-4 6.93"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8 12.8a4.8 4.8 0 1 0 0-9.6 4.8 4.8 0 0 0 0 9.6Z"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8 9.2a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4ZM8 2v1.2M8 14v-1.2M11 13.196l-.6-1.038M7.4 6.962 5 2.804M13.196 11l-1.038-.6M2.804 5l1.038.6M9.2 8H14M2 8h1.2M13.196 5l-1.038.6M2.804 11l1.038-.6M11 2.804l-.6 1.038M7.4 9.038 5 13.196"/></svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1,6 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2Z" stroke="#888888" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M9 5H5" stroke="#888888" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10.5 8H5" stroke="#888888" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M9 10.9502H5" stroke="#888888" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3.5 6.12488L7.64656 1.97853C7.84183 1.78328 8.1584 1.78329 8.35366 1.97854L12.5 6.12488" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<path d="M3.5 6.12487L7.64656 1.97852C7.84183 1.78327 8.1584 1.78328 8.35366 1.97853L12.5 6.12487" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,9 +1,9 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M5.64063 7.67017C5.97718 7.67017 6.25 7.94437 6.25 8.28263V9.60963C6.25 9.94786 5.97718 10.2221 5.64063 10.2221C5.30408 10.2221 5.03125 9.94786 5.03125 9.60963V8.28263C5.03125 7.94437 5.30408 7.67017 5.64063 7.67017Z" fill="black"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M8.37537 7.67017C8.71192 7.67017 8.98474 7.94437 8.98474 8.28263V9.60963C8.98474 9.94786 8.71192 10.2221 8.37537 10.2221C8.03882 10.2221 7.76599 9.94786 7.76599 9.60963V8.28263C7.76599 7.94437 8.03882 7.67017 8.37537 7.67017Z" fill="black"/>
-<path d="M7 3.65625C7 5.84375 5.10754 6.3718 3.76562 6.3718C2.42371 6.3718 2.1405 5.3854 2.1405 4.16861C2.1405 2.95182 3.22834 1.96542 4.57025 1.96542C5.91216 1.96542 7 2.43946 7 3.65625Z" fill="black" fill-opacity="0.5" stroke="black" stroke-width="1.25"/>
-<path d="M7 3.65625C7 5.84375 8.89246 6.3718 10.2344 6.3718C11.5763 6.3718 11.8595 5.3854 11.8595 4.16861C11.8595 2.95182 10.7717 1.96542 9.42975 1.96542C8.08784 1.96542 7 2.43946 7 3.65625Z" fill="black" fill-opacity="0.5" stroke="black" stroke-width="1.25"/>
-<path d="M11.0156 6.01562C11.0156 6.01562 11.6735 6.43636 12 7.07348C12.3265 7.7106 12.3281 9.18621 12 9.7181C11.6719 10.25 11.2813 10.625 10.2931 11.16C9.30501 11.695 8 12.0156 8 12.0156H6C6 12.0156 4.70312 11.7344 3.70687 11.16C2.71061 10.5856 2.23437 10.2188 2 9.7181C1.76562 9.21746 1.6875 7.75 2 7.07348C2.31249 6.39695 3 6.01562 3 6.01562" stroke="black" stroke-width="1.25" stroke-linejoin="round"/>
-<path d="M10.4454 11.0264V6.41934L12.1671 6.99323V9.5598L10.4454 11.0264Z" fill="black" fill-opacity="0.75"/>
-<path d="M3.51556 11.0264V6.41934L1.79388 6.99323V9.5598L3.51556 11.0264Z" fill="black" fill-opacity="0.75"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M6.44643 8.76593C6.83106 8.76593 7.14286 9.0793 7.14286 9.46588V10.9825C7.14286 11.369 6.83106 11.6824 6.44643 11.6824C6.06181 11.6824 5.75 11.369 5.75 10.9825V9.46588C5.75 9.0793 6.06181 8.76593 6.44643 8.76593Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.57168 8.76593C9.95631 8.76593 10.2681 9.0793 10.2681 9.46588V10.9825C10.2681 11.369 9.95631 11.6824 9.57168 11.6824C9.18705 11.6824 8.87524 11.369 8.87524 10.9825V9.46588C8.87524 9.0793 9.18705 8.76593 9.57168 8.76593Z" fill="black"/>
+<path d="M7.99976 4.17853C7.99976 6.67853 5.83695 7.28202 4.30332 7.28202C2.76971 7.28202 2.44604 6.1547 2.44604 4.76409C2.44604 3.37347 3.68929 2.24615 5.2229 2.24615C6.75651 2.24615 7.99976 2.78791 7.99976 4.17853Z" fill="black" fill-opacity="0.5" stroke="black" stroke-width="1.2"/>
+<path d="M8 4.17853C8 6.67853 10.1628 7.28202 11.6965 7.28202C13.2301 7.28202 13.5537 6.1547 13.5537 4.76409C13.5537 3.37347 12.3105 2.24615 10.7769 2.24615C9.24325 2.24615 8 2.78791 8 4.17853Z" fill="black" fill-opacity="0.5" stroke="black" stroke-width="1.2"/>
+<path d="M12.5894 6.875C12.5894 6.875 13.3413 7.35585 13.7144 8.08398C14.0876 8.81212 14.0894 10.4985 13.7144 11.1064C13.3395 11.7143 12.8931 12.1429 11.7637 12.7543C10.6344 13.3657 9.143 13.7321 9.143 13.7321H6.85728C6.85728 13.7321 5.37513 13.4107 4.23656 12.7543C3.09798 12.0978 2.55371 11.6786 2.28585 11.1064C2.01799 10.5342 1.92871 8.85715 2.28585 8.08398C2.64299 7.31081 3.42871 6.875 3.42871 6.875" stroke="black" stroke-width="1.2" stroke-linejoin="round"/>
+<path d="M11.9375 12.6016V7.33636L13.9052 7.99224V10.9255L11.9375 12.6016Z" fill="black" fill-opacity="0.75"/>
+<path d="M4.01793 12.6016V7.33636L2.05029 7.99224V10.9255L4.01793 12.6016Z" fill="black" fill-opacity="0.75"/>
</svg>
@@ -1,9 +1,9 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.5">
@@ -1,7 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.5">
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1,4 +1,4 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M18.4286 9H10.5714C9.70355 9 9 9.70355 9 10.5714V18.4286C9 19.2964 9.70355 20 10.5714 20H18.4286C19.2964 20 20 19.2964 20 18.4286V10.5714C20 9.70355 19.2964 9 18.4286 9Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5.57143 15C4.70714 15 4 14.2929 4 13.4286V5.57143C4 4.70714 4.70714 4 5.57143 4H13.4286C14.2929 4 15 4.70714 15 5.57143" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.486 6.2H7.24795C6.66895 6.2 6.19995 6.669 6.19995 7.248V12.486C6.19995 13.064 6.66895 13.533 7.24795 13.533H12.486C13.064 13.533 13.533 13.064 13.533 12.486V7.248C13.533 6.669 13.064 6.2 12.486 6.2Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.91712 10.203C3.63951 10.2022 3.37351 10.0915 3.1773 9.89511C2.98109 9.69872 2.87064 9.43261 2.87012 9.155V3.917C2.87091 3.63956 2.98147 3.37371 3.17765 3.17753C3.37383 2.98135 3.63968 2.87079 3.91712 2.87H9.15512C9.43273 2.87053 9.69883 2.98097 9.89523 3.17718C10.0916 3.37339 10.2023 3.63939 10.2031 3.917" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +1 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.15 7.49998C13.15 4.66458 10.9402 1.84998 7.50002 1.84998C4.7217 1.84998 3.34851 3.90636 2.76336 4.99997H4.5C4.77614 4.99997 5 5.22383 5 5.49997C5 5.77611 4.77614 5.99997 4.5 5.99997H1.5C1.22386 5.99997 1 5.77611 1 5.49997V2.49997C1 2.22383 1.22386 1.99997 1.5 1.99997C1.77614 1.99997 2 2.22383 2 2.49997V4.31318C2.70453 3.07126 4.33406 0.849976 7.50002 0.849976C11.5628 0.849976 14.15 4.18537 14.15 7.49998C14.15 10.8146 11.5628 14.15 7.50002 14.15C5.55618 14.15 3.93778 13.3808 2.78548 12.2084C2.16852 11.5806 1.68668 10.839 1.35816 10.0407C1.25306 9.78536 1.37488 9.49315 1.63024 9.38806C1.8856 9.28296 2.17781 9.40478 2.2829 9.66014C2.56374 10.3425 2.97495 10.9745 3.4987 11.5074C4.47052 12.4963 5.83496 13.15 7.50002 13.15C10.9402 13.15 13.15 10.3354 13.15 7.49998ZM7 10V5.00001H8V10H7Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M6.8 2h2.4M8 9.2l1.8-1.8M8 14a4.8 4.8 0 1 0 0-9.6A4.8 4.8 0 0 0 8 14Z"/></svg>
@@ -1,7 +1,7 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7 12C9.76142 12 12 9.76142 12 7C12 4.23858 9.76142 2 7 2C4.23858 2 2 4.23858 2 7C2 9.76142 4.23858 12 7 12Z" stroke="black" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11.5 7H9" stroke="black" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M4.5 7H2" stroke="black" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7 4.5V2" stroke="black" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7 11.5V9" stroke="black" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 14C11.3137 14 14 11.3137 14 8C14 4.6863 11.3137 2 8 2C4.6863 2 2 4.6863 2 8C2 11.3137 4.6863 14 8 14Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M14 8L11 8" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 8H2" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 5V2" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 14V11" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M11 13H10.4C9.76346 13 9.15302 12.7893 8.70296 12.4142C8.25284 12.0391 8 11.5304 8 11V5C8 4.46957 8.25284 3.96086 8.70296 3.58579C9.15302 3.21071 9.76346 3 10.4 3H11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5 13H5.6C6.23654 13 6.84698 12.7893 7.29704 12.4142C7.74716 12.0391 8 11.5304 8 11V5C8 4.46957 7.74716 3.96086 7.29704 3.58579C6.84698 3.21071 6.23654 3 5.6 3H5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11 13H10.4C9.76346 13 9.15302 12.7893 8.70296 12.4142C8.25284 12.0391 8 11.5304 8 11V5C8 4.46957 8.25284 3.96086 8.70296 3.58579C9.15302 3.21071 9.76346 3 10.4 3H11" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 13H5.6C6.23654 13 6.84698 12.7893 7.29704 12.4142C7.74716 12.0391 8 11.5304 8 11V5C8 4.46957 7.74716 3.96086 7.29704 3.58579C6.84698 3.21071 6.23654 3 5.6 3H5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus"><path d="M5 12h14"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M3.333 8h9.334"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-database-zap"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 15 21.84"/><path d="M21 5V8"/><path d="M21 12L18 17H22L19 22"/><path d="M3 12A9 3 0 0 0 14.59 14.87"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8.017 5.625c2.974 0 5.385-.804 5.385-1.795 0-.991-2.41-1.795-5.385-1.795-2.973 0-5.384.804-5.384 1.795 0 .991 2.41 1.795 5.384 1.795Z"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M2.633 3.83v8.376c-.003.288.201.571.596.827.394.256.967.477 1.671.643a13.12 13.12 0 0 0 2.373.314c.854.04 1.725.01 2.54-.085M13.402 3.83v1.795M13.402 8.018l-1.795 2.991H14l-1.795 2.992"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M2.633 8.018c0 .28.198.556.575.805.378.25.925.467 1.599.634.673.167 1.454.279 2.28.327.827.048 1.676.032 2.48-.049"/></svg>
@@ -1,12 +1,12 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5.49219 2.29071L6.41455 3.1933" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M9.61816 3.1933L10.508 2.29071" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5.7042 5.89221V5.15749C5.69033 4.85975 5.73943 4.56239 5.84856 4.28336C5.95768 4.00434 6.12456 3.74943 6.33913 3.53402C6.55369 3.31862 6.81149 3.14718 7.09697 3.03005C7.38245 2.91292 7.68969 2.85254 8.00014 2.85254C8.3106 2.85254 8.61784 2.91292 8.90332 3.03005C9.18879 3.14718 9.44659 3.31862 9.66116 3.53402C9.87572 3.74943 10.0426 4.00434 10.1517 4.28336C10.2609 4.56239 10.31 4.85975 10.2961 5.15749V5.89221" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M8.00006 13.0426C6.13263 13.0426 4.60474 11.6005 4.60474 9.83792V8.23558C4.60474 7.66895 4.84322 7.12554 5.26772 6.72487C5.69221 6.32421 6.26796 6.09912 6.86829 6.09912H9.13184C9.73217 6.09912 10.3079 6.32421 10.7324 6.72487C11.1569 7.12554 11.3954 7.66895 11.3954 8.23558V9.83792C11.3954 11.6005 9.86749 13.0426 8.00006 13.0426Z" fill="black" fill-opacity="0.15" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M4.60452 6.25196C3.51235 6.13878 2.60693 5.17677 2.60693 3.9884" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M4.60462 8.81659H2.34106" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M2.4541 13.3186C2.4541 12.1302 3.41611 11.1116 4.60448 11.0551" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M13.0761 3.9884C13.0761 5.17677 12.1706 6.13878 11.0955 6.25196" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M13.6591 8.81659H11.3955" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11.3955 11.0551C12.5839 11.1116 13.5459 12.1302 13.5459 13.3186" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.44727 2.19177L6.38617 3.11055" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.64722 3.11055L10.553 2.19177" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.66298 6.07369L5.66298 5.10997C5.64886 4.80689 5.69884 4.50419 5.80993 4.22016C5.92101 3.93613 6.09088 3.67665 6.3093 3.45738C6.52771 3.23811 6.79013 3.0636 7.08074 2.94437C7.37134 2.82514 7.68409 2.76367 8.00011 2.76367C8.31614 2.76367 8.62889 2.82514 8.91949 2.94437C9.21008 3.0636 9.4725 3.23811 9.69092 3.45738C9.90933 3.67665 10.0792 3.93613 10.1903 4.22016C10.3014 4.50419 10.3514 4.80689 10.3373 5.10997V6.07369" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8.00017 13.1366C6.09924 13.1366 4.54395 11.6686 4.54395 9.87441V8.24333C4.54395 7.66653 4.7867 7.11337 5.21882 6.70552C5.65092 6.29767 6.237 6.06854 6.8481 6.06854H9.15225C9.76335 6.06854 10.3494 6.29767 10.7815 6.70552C11.2136 7.11337 11.4564 7.66653 11.4564 8.24333V9.87441C11.4564 11.6686 9.9011 13.1366 8.00017 13.1366Z" fill="black" fill-opacity="0.15" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.54343 6.22415C3.43167 6.10894 2.51001 5.12967 2.51001 3.91998" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.54367 8.83472H2.2395" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2.35449 13.4175C2.35449 12.2078 3.33376 11.1709 4.54345 11.1134" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.1673 3.91998C13.1673 5.12967 12.2455 6.10894 11.1511 6.22415" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.7605 8.83472H11.4563" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.4563 11.1134C12.666 11.1709 13.6453 12.2078 13.6453 13.4175" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +1,3 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle"><circle cx="12" cy="12" r="10"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z" fill="black" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-step-forward"><line x1="6" x2="6" y1="4" y2="20"/><polygon points="10,4 20,12 10,20"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M4.167 3v10M7.167 3l6 5-6 5V3Z"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-unplug"><path d="m19 5 3-3"/><path d="m2 22 3-3"/><path d="M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z"/><path d="M7.5 13.5 10 11"/><path d="M10.5 16.5 13 14"/><path d="m12 6 6 6 2.3-2.3a2.4 2.4 0 0 0 0-3.4l-2.6-2.6a2.4 2.4 0 0 0-3.4 0Z"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" clip-path="url(#a)"><path d="m13 3 2-2M1 15l2-2M4.202 13.53a1.598 1.598 0 0 0 2.266 0L8 11.997 4.003 8 2.47 9.532a1.6 1.6 0 0 0 0 2.265l1.732 1.733ZM5 9l1.5-1.5M7 11l1.5-1.5M8 4.003 11.997 8l1.533-1.532a1.599 1.599 0 0 0 0-2.266L11.798 2.47a1.598 1.598 0 0 0-2.266 0L8 4.003Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
@@ -1 +1,3 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle"><circle cx="12" cy="12" r="10"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +1,5 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.8889 2H4.11111C3.49746 2 3 2.59695 3 3.33333V12.6667C3 13.403 3.49746 14 4.11111 14H11.8889C12.5025 14 13 13.403 13 12.6667V3.33333C13 2.59695 12.5025 2 11.8889 2Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 6H6" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10 10H6" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +1,3 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-off"><path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0 1 21.3 15.65"/><path d="M19.08 19.08A10 10 0 1 1 4.92 4.92"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2 2L14 14M5.81044 2.41392C6.89676 1.98976 8.08314 1.89138 9.22449 2.13079C10.3658 2.37021 11.4127 2.93705 12.237 3.76199C13.0613 4.58693 13.6273 5.6342 13.8658 6.77573C14.1044 7.91727 14.0051 9.10357 13.5801 10.1896M12.2484 12.2484C11.1176 13.3558 9.59562 13.9724 8.01292 13.9642C6.43021 13.956 4.91467 13.3236 3.79552 12.2045C2.67636 11.0853 2.044 9.56979 2.03578 7.98708C2.02757 6.40438 2.64417 4.88236 3.75165 3.75165" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +1,3 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.8887 1.25C13.0386 1.25 13.7498 2.31634 13.75 3.33301V12.667C13.7499 13.6836 13.0386 14.75 11.8887 14.75H4.11133C2.96134 14.75 2.25014 13.6836 2.25 12.667V3.33301C2.25015 2.31635 2.96136 1.25 4.11133 1.25H11.8887ZM6 9.25C5.58579 9.25 5.25 9.58579 5.25 10C5.25 10.4142 5.58579 10.75 6 10.75H10C10.4142 10.75 10.75 10.4142 10.75 10C10.75 9.58579 10.4142 9.25 10 9.25H6ZM6 5.25C5.58579 5.25 5.25 5.58579 5.25 6C5.25 6.41421 5.58579 6.75 6 6.75H9C9.41421 6.75 9.75 6.41421 9.75 6C9.75 5.58579 9.41421 5.25 9 5.25H6Z" fill="black"/>
+</svg>
@@ -1 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pause"><rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.5 4H9.5C9.22386 4 9 4.22386 9 4.5V11.5001C9 11.7762 9.22386 12.0001 9.5 12.0001H10.5C10.7762 12.0001 11 11.7762 11 11.5001V4.5C11 4.22386 10.7762 4 10.5 4Z" fill="black" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.50001 4H5.5C5.22386 4 5 4.22386 5 4.5V11.5001C5 11.7762 5.22386 12.0001 5.5 12.0001H6.50001C6.77616 12.0001 7.00002 11.7762 7.00002 11.5001V4.5C7.00002 4.22386 6.77616 4 6.50001 4Z" fill="black" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-undo-dot"><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/><path d="M3 7v6h6"/><circle cx="12" cy="17" r="1"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M14 11.333A6 6 0 0 0 4 6.867l-1 .9"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333" d="M2 4.667v4h4"/><path fill="#000" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8 12a.667.667 0 1 0 0-1.333A.667.667 0 0 0 8 12Z"/></svg>
@@ -1,5 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-from-dot">
- <path d="m5 15 7 7 7-7"/>
- <path d="M12 8v14"/>
- <circle cx="12" cy="3" r="1"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M3.333 10 8 14.667 12.667 10M8 5.333v9.334"/><path fill="#000" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8 2.667a.667.667 0 1 0 0-1.334.667.667 0 0 0 0 1.334Z"/></svg>
@@ -1,5 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-from-dot">
- <path d="m3 10 9-8 9 8"/>
- <path d="M12 17V2"/>
- <circle cx="12" cy="21" r="1"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M3.333 6 8 1.333 12.667 6M8 10.667V1.333"/><path fill="#000" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8 13.333a.667.667 0 1 1 0 1.334.667.667 0 0 1 0-1.334Z"/></svg>
@@ -1,5 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-redo-dot">
- <circle cx="12" cy="17" r="1"/>
- <path d="M21 7v6h-6"/>
- <path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M2 11.333a6 6 0 0 1 10-4.466l1 .9"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333" d="M14 4.667v4h-4"/><path fill="#000" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8 12a.667.667 0 1 1 0-1.333A.667.667 0 0 1 8 12Z"/></svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square"><rect width="18" height="18" x="3" y="3" rx="2"/></svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-delete"><path d="M20 5H9l-7 7 7 7h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2Z"/><line x1="18" x2="12" y1="9" y2="15"/><line x1="12" x2="18" y1="9" y2="15"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-diff"><path d="M12 3v14"/><path d="M5 10h14"/><path d="M5 21h14"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8 3v8M4 7h8M4 13h8"/></svg>
@@ -1,3 +1 @@
-<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M2.59892 2.76222C3.14641 2.17067 3.93013 1.80018 4.80011 1.80018C5.91195 1.80018 6.8813 2.40485 7.40066 3.30389C7.68565 3.09577 8.03064 3.00015 8.4 3.00015C9.39372 3.00015 10.1999 3.7895 10.1999 4.80009C10.1999 5.02884 10.1568 5.24633 10.08 5.44882C11.1749 5.67007 11.9999 6.63941 11.9999 7.80001C11.9999 8.48623 11.7112 9.10497 11.233 9.52683L11.8274 9.99557C12.0224 10.1493 12.058 10.4324 11.9043 10.6274C11.7505 10.8224 11.4674 10.858 11.2724 10.7043L0.172556 2.00436C-0.0231882 1.85099 -0.0574997 1.56825 0.0958708 1.37251C0.249241 1.17676 0.531795 1.14245 0.727671 1.29582L2.59868 2.76184L2.59892 2.76222ZM1.82213 4.43635L9.13873 10.1999H2.70017C1.20903 10.1999 0.000248596 8.99059 0.000248596 7.50001C0.000248596 6.32255 0.753414 5.32133 1.80395 4.95196C1.80151 4.90134 1.8002 4.85072 1.8002 4.80009C1.8002 4.67635 1.8077 4.55448 1.82213 4.43635Z" fill="white"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="m2 2 12 12M4.269 4.27a4.2 4.2 0 0 0 1.93 7.93h5.1c.267 0 .53-.039.785-.116M13.72 10.7a2.699 2.699 0 0 0-2.42-3.9h-1.074A4.204 4.204 0 0 0 6.8 3.842"/></svg>
@@ -1,3 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
- <path fill-rule="evenodd" d="M4.5 2A1.5 1.5 0 0 0 3 3.5v13A1.5 1.5 0 0 0 4.5 18h11a1.5 1.5 0 0 0 1.5-1.5V7.621a1.5 1.5 0 0 0-.44-1.06l-4.12-4.122A1.5 1.5 0 0 0 11.378 2H4.5Zm2.25 8.5a.75.75 0 0 0 0 1.5h6.5a.75.75 0 0 0 0-1.5h-6.5Zm0 3a.75.75 0 0 0 0 1.5h6.5a.75.75 0 0 0 0-1.5h-6.5Z" clip-rule="evenodd" />
-</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M13 9.667v2.222A1.111 1.111 0 0 1 11.889 13H4.11A1.111 1.111 0 0 1 3 11.889V9.667M5.222 6.889 8 9.667l2.778-2.778M8 9.667V3"/></svg>
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -0,0 +1,9 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.6" d="M3.5 11V5.5L8.5 8L3.5 11Z" fill="black"/>
+<path opacity="0.4" d="M8.5 14L3.5 11L8.5 8V14Z" fill="black"/>
+<path opacity="0.6" d="M8.5 5.5H3.5L8.5 2.5L8.5 5.5Z" fill="black"/>
+<path opacity="0.8" d="M8.5 5.5V2.5L13.5 5.5H8.5Z" fill="black"/>
+<path opacity="0.2" d="M13.5 11L8.5 14L11 9.5L13.5 11Z" fill="black"/>
+<path opacity="0.5" d="M13.5 11L11 9.5L13.5 5V11Z" fill="black"/>
+<path d="M3.5 11V5L8.5 2.11325L13.5 5V11L8.5 13.8868L3.5 11Z" stroke="black"/>
+</svg>
@@ -0,0 +1,10 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_2716_663)">
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0945 8.01611C13.0945 7.87619 12.9911 7.79551 12.8642 7.8356L4.13456 10.6038C4.00742 10.6441 3.90427 10.7904 3.90427 10.9301V13.7593C3.90427 13.8992 4.00742 13.9801 4.13456 13.9398L12.8642 11.1719C12.9911 11.1315 13.0945 10.9852 13.0945 10.8453V8.01611Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M3.90427 7.92597C3.90427 8.06588 4.00742 8.21218 4.13456 8.25252L12.8655 11.0209C12.9926 11.0613 13.0958 10.9803 13.0958 10.8407V8.01124C13.0958 7.87158 12.9926 7.72529 12.8655 7.68494L4.13456 4.91652C4.00742 4.87618 3.90427 4.95686 3.90427 5.09677V7.92597Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0945 2.20248C13.0945 2.06256 12.9911 1.98163 12.8642 2.02197L4.13456 4.78988C4.00742 4.83022 3.90427 4.97652 3.90427 5.11644V7.94563C3.90427 8.08554 4.00742 8.16622 4.13456 8.12614L12.8642 5.35797C12.9911 5.31763 13.0945 5.17133 13.0945 5.03167V2.20248Z" fill="black"/>
+</svg>
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M11.0094 13.9181C11.1984 13.9917 11.4139 13.987 11.6047 13.8952L14.0753 12.7064C14.3349 12.5814 14.5 12.3187 14.5 12.0305V3.9696C14.5 3.68136 14.3349 3.41862 14.0753 3.2937L11.6047 2.10485C11.3543 1.98438 11.0614 2.01389 10.8416 2.17363C10.8102 2.19645 10.7803 2.22193 10.7523 2.25001L6.02261 6.56498L3.96246 5.00115C3.77068 4.85558 3.50244 4.86751 3.32432 5.02953L2.66356 5.63059C2.44569 5.82877 2.44544 6.17152 2.66302 6.37004L4.44965 8.00001L2.66302 9.62998C2.44544 9.82849 2.44569 10.1713 2.66356 10.3694L3.32432 10.9705C3.50244 11.1325 3.77068 11.1444 3.96246 10.9989L6.02261 9.43504L10.7523 13.75C10.8271 13.8249 10.915 13.8812 11.0094 13.9181ZM11.5018 5.27587L7.91309 8.00001L11.5018 10.7241V5.27587Z" fill="black"/>
+</svg>
@@ -1,5 +1,5 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<circle cx="7" cy="7" r="1" fill="black"/>
-<circle cx="11" cy="7" r="1" fill="black"/>
-<circle cx="3" cy="7" r="1" fill="black"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<circle cx="3" cy="8" r="1" fill="black" stroke="black" stroke-width="0.5"/>
+<circle cx="8" cy="8" r="1" fill="black" stroke="black" stroke-width="0.5"/>
+<circle cx="13" cy="8" r="1" fill="black" stroke="black" stroke-width="0.5"/>
</svg>
@@ -1 +1,5 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis-vertical"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<circle cx="8" cy="3" r="1" fill="black" stroke="black" stroke-width="0.5"/>
+<circle cx="8" cy="8" r="1" fill="black" stroke="black" stroke-width="0.5"/>
+<circle cx="8" cy="13" r="1" fill="black" stroke="black" stroke-width="0.5"/>
+</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M13 4C13.5523 4 14 4.44772 14 5V11C14 11.5523 13.5523 12 13 12H3C2.44772 12 2 11.5523 2 11V5C2 4.44772 2.44772 4 3 4H13Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M13.5 5L7.9999 8.5L2.5 5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M13 4C13.5523 4 14 4.44772 14 5V11C14 11.5523 13.5523 12 13 12H3C2.44772 12 2 11.5523 2 11V5C2 4.44772 2.44772 4 3 4H13Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M13.5 5L7.9999 8.5L2.5 5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eraser">
- <path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/>
- <path d="M22 21H7"/><path d="m5 11 9 9"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="m5.015 12.983-2.567-2.382c-.597-.554-.597-1.385 0-1.884L8.179 3.4c.597-.554 1.493-.554 2.03 0L13.552 6.5c.597.554.597 1.385 0 1.884l-4.955 4.598M14 12.983H5M4.5 7.483l5 5"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-left-from-circle"><path d="M2 8V2h6"/><path d="m2 2 10 10"/><path d="M12 2A10 10 0 1 1 2 12"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M3 6V3h3M3 3l5 5M8 3a5 5 0 1 1-5 5"/></svg>
@@ -1,8 +1,5 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path
- fill-rule="evenodd"
- clip-rule="evenodd"
- d="M3 1C2.44771 1 2 1.44772 2 2V13C2 13.5523 2.44772 14 3 14H10.5C10.7761 14 11 13.7761 11 13.5C11 13.2239 10.7761 13 10.5 13H3V2L10.5 2C10.7761 2 11 1.77614 11 1.5C11 1.22386 10.7761 1 10.5 1H3ZM12.6036 4.89645C12.4083 4.70118 12.0917 4.70118 11.8964 4.89645C11.7012 5.09171 11.7012 5.40829 11.8964 5.60355L13.2929 7H6.5C6.22386 7 6 7.22386 6 7.5C6 7.77614 6.22386 8 6.5 8H13.2929L11.8964 9.39645C11.7012 9.59171 11.7012 9.90829 11.8964 10.1036C12.0917 10.2988 12.4083 10.2988 12.6036 10.1036L14.8536 7.85355C15.0488 7.65829 15.0488 7.34171 14.8536 7.14645L12.6036 4.89645Z"
- fill="currentColor"
- />
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.437 11.0461L13.4831 8L10.437 4.95392" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13 8L8 8" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.6553 13.4659H4.21843C3.89528 13.4659 3.58537 13.3375 3.35687 13.109C3.12837 12.8805 3 12.5706 3 12.2475V3.71843C3 3.39528 3.12837 3.08537 3.35687 2.85687C3.58537 2.62837 3.89528 2.5 4.21843 2.5H6.6553" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1,4 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M10.5 8.5L7.5 11.5M7.5 11.5L4.5 8.5M7.5 11.5L7.5 5.5" stroke="black" stroke-linecap="square"/>
-<path d="M5 3.5L10 3.5" stroke="black"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.1998 9.60002L7.9998 12.8M7.9998 12.8L4.7998 9.60002M7.9998 12.8V6.40002" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.33325 3.73334H10.6666" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,4 +1,4 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4.5 6.5L7.5 3.5M7.5 3.5L10.5 6.5M7.5 3.5V9.5" stroke="black" stroke-linecap="square"/>
-<path d="M5 11.5H10" stroke="black"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.80005 6.93334L8.00005 3.73334M8.00005 3.73334L11.2 6.93334M8.00005 3.73334V10.1333" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.3335 12.8H10.6668" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-unfold-vertical"><path d="M12 22v-6"/><path d="M12 8V2"/><path d="M4 12H2"/><path d="M10 12H8"/><path d="M16 12h-2"/><path d="M22 12h-2"/><path d="m15 19-3 3-3-3"/><path d="m15 5-3-3-3 3"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" clip-path="url(#a)"><path d="M8 14.667v-4M8 5.333v-4M2.667 8H1.333M6.667 8H5.333M10.667 8H9.333M14.667 8h-1.334M10 12.667l-2 2-2-2M10 3.333l-2-2-2 2"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
@@ -1,5 +0,0 @@
-<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5.5 1H7.5H8.75C8.88807 1 9 1.11193 9 1.25V4.5" stroke="#838994" stroke-linecap="round"/>
-<path d="M3.64645 5.64645C3.45118 5.84171 3.45118 6.15829 3.64645 6.35355C3.84171 6.54882 4.15829 6.54882 4.35355 6.35355L3.64645 5.64645ZM8.64645 0.646447L3.64645 5.64645L4.35355 6.35355L9.35355 1.35355L8.64645 0.646447Z" fill="#838994"/>
-<path d="M7.5 6.5V9C7.5 9.27614 7.27614 9.5 7 9.5H1C0.723858 9.5 0.5 9.27614 0.5 9V3C0.5 2.72386 0.723858 2.5 1 2.5H3.5" stroke="#838994" stroke-linecap="round"/>
-</svg>
@@ -1 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2.0375 8.2088C1.9875 8.07409 1.9875 7.92592 2.0375 7.79122C2.5245 6.61039 3.35114 5.60076 4.41264 4.89031C5.47414 4.17986 6.72268 3.8006 7.99999 3.8006C9.2773 3.8006 10.5258 4.17986 11.5873 4.89031C12.6488 5.60076 13.4755 6.61039 13.9625 7.79122C14.0125 7.92592 14.0125 8.07409 13.9625 8.2088C13.4755 9.38962 12.6488 10.3993 11.5873 11.1097C10.5258 11.8202 9.2773 12.1994 7.99999 12.1994C6.72268 12.1994 5.47414 11.8202 4.41264 11.1097C3.35114 10.3993 2.5245 9.38962 2.0375 8.2088Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8.0001 9.79988C8.99416 9.79988 9.80001 8.99404 9.80001 7.99998C9.80001 7.00592 8.99416 6.20007 8.0001 6.20007C7.00604 6.20007 6.2002 7.00592 6.2002 7.99998C6.2002 8.99404 7.00604 9.79988 8.0001 9.79988Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,4 +1 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M10.0001 1.33334H4.00008C3.64646 1.33334 3.30732 1.47382 3.05727 1.72387C2.80722 1.97392 2.66675 2.31305 2.66675 2.66668V13.3333C2.66675 13.687 2.80722 14.0261 3.05727 14.2762C3.30732 14.5262 3.64646 14.6667 4.00008 14.6667H12.0001C12.3537 14.6667 12.6928 14.5262 12.9429 14.2762C13.1929 14.0261 13.3334 13.687 13.3334 13.3333V4.66668L10.0001 1.33334Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M9.33325 1.33334V4.00001C9.33325 4.35363 9.47373 4.69277 9.72378 4.94282C9.97383 5.19287 10.313 5.33334 10.6666 5.33334H13.3333" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M9.875 2H4.25c-.332 0-.65.126-.884.351-.234.226-.366.53-.366.849v9.6c0 .318.132.623.366.849.235.225.552.351.884.351h7.5c.332 0 .65-.127.884-.351.234-.225.366-.53.366-.85V5L9.875 2Z"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M9 2v2.667A1.333 1.333 0 0 0 10.333 6H13"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-code"><path d="M10 12.5 8 15l2 2.5"/><path d="m14 12.5 2 2.5-2 2.5"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M6.8 8.3 5.6 9.8l1.2 1.5M9.2 8.3l1.2 1.5-1.2 1.5M9.2 2v2.4a1.2 1.2 0 0 0 1.2 1.2h2.4"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M9.8 2H4.4a1.2 1.2 0 0 0-1.2 1.2v9.6A1.2 1.2 0 0 0 4.4 14h7.2a1.2 1.2 0 0 0 1.2-1.2V5l-3-3Z"/></svg>
@@ -1,5 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M10.0001 1.33334H4.00008C3.64646 1.33334 3.30732 1.47382 3.05727 1.72387C2.80722 1.97392 2.66675 2.31305 2.66675 2.66668V13.3333C2.66675 13.687 2.80722 14.0261 3.05727 14.2762C3.30732 14.5262 3.64646 14.6667 4.00008 14.6667H12.0001C12.3537 14.6667 12.6928 14.5262 12.9429 14.2762C13.1929 14.0261 13.3334 13.687 13.3334 13.3333V4.66668L10.0001 1.33334Z" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M6 8H10" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M8 10V6" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-diff"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M9 10h6"/><path d="M12 13V7"/><path d="M9 17h6"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M9.8 2H4.4a1.2 1.2 0 0 0-1.2 1.2v9.6A1.2 1.2 0 0 0 4.4 14h7.2a1.2 1.2 0 0 0 1.2-1.2V5l-3-3ZM6.2 6.8h3.6M8 8.6V5M6.2 11h3.6"/></svg>
@@ -1,6 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M13 11V11.8374C13 11.9431 12.9665 12.046 12.9044 12.1315L12.1498 13.1691C12.0557 13.2985 11.9054 13.375 11.7454 13.375H4.25461C4.09464 13.375 3.94433 13.2985 3.85024 13.1691L3.09563 12.1315C3.03348 12.046 3 11.9431 3 11.8374V3" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M13 11V11.8374C13 11.9431 12.9665 12.046 12.9044 12.1315L12.1498 13.1691C12.0557 13.2985 11.9054 13.375 11.7454 13.375H4.25461C4.09464 13.375 3.94433 13.2985 3.85024 13.1691L3.09563 12.1315C3.03348 12.046 3 11.9431 3 11.8374V3" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
<path d="M3 13V11L8 12H13V13H3Z" fill="black"/>
-<path d="M6.63246 3.04418C7.44914 3.31641 8 4.08069 8 4.94155V11.7306C8 12.0924 7.62757 12.3345 7.29693 12.1875L3.79693 10.632C3.61637 10.5518 3.5 10.3727 3.5 10.1751V2.69374C3.5 2.35246 3.83435 2.11148 4.15811 2.2194L6.63246 3.04418Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M9.5 3C8.67157 3 8 3.67157 8 4.5V13C8 12.1954 11.2366 12.0382 12.5017 12.0075C12.7778 12.0008 13 11.7761 13 11.5V3.5C13 3.22386 12.7761 3 12.5 3H9.5Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.63246 3.04418C7.44914 3.31641 8 4.08069 8 4.94155V11.7306C8 12.0924 7.62757 12.3345 7.29693 12.1875L3.79693 10.632C3.61637 10.5518 3.5 10.3727 3.5 10.1751V2.69374C3.5 2.35246 3.83435 2.11148 4.15811 2.2194L6.63246 3.04418Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.5 3C8.67157 3 8 3.67157 8 4.5V13C8 12.1954 11.2366 12.0382 12.5017 12.0075C12.7778 12.0008 13 11.7761 13 11.5V3.5C13 3.22386 12.7761 3 12.5 3H9.5Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3 5H11" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M3 8H13" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M3 11H9" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M3 5H11" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M3 8H13" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M3 11H9" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,6 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5 13C6.10457 13 7 12.1046 7 11C7 9.89543 6.10457 9 5 9C3.89543 9 3 9.89543 3 11C3 12.1046 3.89543 13 5 13Z" stroke="black" stroke-width="1.5"/>
-<path d="M11 7C12.1046 7 13 6.10457 13 5C13 3.89543 12.1046 3 11 3C9.89543 3 9 3.89543 9 5C9 6.10457 9.89543 7 11 7Z" fill="black" stroke="black" stroke-width="1.5"/>
-<path d="M4.625 3.625V8.375" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M11 7C11 9.20914 9.20914 11 7 11" stroke="black" stroke-width="1.5"/>
+<path d="M5 13C6.10457 13 7 12.1046 7 11C7 9.89543 6.10457 9 5 9C3.89543 9 3 9.89543 3 11C3 12.1046 3.89543 13 5 13Z" stroke="black" stroke-width="1.2"/>
+<path d="M11 7C12.1046 7 13 6.10457 13 5C13 3.89543 12.1046 3 11 3C9.89543 3 9 3.89543 9 5C9 6.10457 9.89543 7 11 7Z" fill="black" stroke="black" stroke-width="1.2"/>
+<path d="M4.625 3.625V8.375" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M11 7C11 9.20914 9.20914 11 7 11" stroke="black" stroke-width="1.2"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M10.5 8.75V10.5C8.43097 10.5 7.56903 10.5 5.5 10.5V10L10.5 6V5.5H5.5V7.25" stroke="black" stroke-width="1.5"/>
+ <path d="M10.5 8.75V10.5C8.43097 10.5 7.56903 10.5 5.5 10.5V10L10.5 6V5.5H5.5V7.25" stroke="black" stroke-width="1.2"/>
<path d="M1.5 8.5C1.77614 8.5 2 8.27614 2 8C2 7.72386 1.77614 7.5 1.5 7.5C1.22386 7.5 1 7.72386 1 8C1 8.27614 1.22386 8.5 1.5 8.5Z" fill="black"/>
<path d="M2.49976 6.33002C2.7759 6.33002 2.99976 6.10616 2.99976 5.83002C2.99976 5.55387 2.7759 5.33002 2.49976 5.33002C2.22361 5.33002 1.99976 5.55387 1.99976 5.83002C1.99976 6.10616 2.22361 6.33002 2.49976 6.33002Z" fill="black"/>
<path d="M2.49976 10.66C2.7759 10.66 2.99976 10.4361 2.99976 10.16C2.99976 9.88383 2.7759 9.65997 2.49976 9.65997C2.22361 9.65997 1.99976 9.88383 1.99976 10.16C1.99976 10.4361 2.22361 10.66 2.49976 10.66Z" fill="black"/>
@@ -1,8 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3.5 6.66666V8.66666" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5.5 5V11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7.5 3V13" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M9.5 5.33334V10" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11.5 4V12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M13.5 6.66666V8.66666" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.5 6.66666V8.66666" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.5 5V11" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.5 3V13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.5 5.33334V10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.5 4V12" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.5 6.66666V8.66666" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,6 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M13 11V11.8374C13 11.9431 12.9665 12.046 12.9044 12.1315L12.1498 13.1691C12.0557 13.2985 11.9054 13.375 11.7454 13.375H4.25461C4.09464 13.375 3.94433 13.2985 3.85024 13.1691L3.09563 12.1315C3.03348 12.046 3 11.9431 3 11.8374V3" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M13 11V11.8374C13 11.9431 12.9665 12.046 12.9044 12.1315L12.1498 13.1691C12.0557 13.2985 11.9054 13.375 11.7454 13.375H4.25461C4.09464 13.375 3.94433 13.2985 3.85024 13.1691L3.09563 12.1315C3.03348 12.046 3 11.9431 3 11.8374V3" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
<path d="M3 13V11L8 12H13V13H3Z" fill="black"/>
-<path d="M6.63246 3.04418C7.44914 3.31641 8 4.08069 8 4.94155V11.7306C8 12.0924 7.62757 12.3345 7.29693 12.1875L3.79693 10.632C3.61637 10.5518 3.5 10.3727 3.5 10.1751V2.69374C3.5 2.35246 3.83435 2.11148 4.15811 2.2194L6.63246 3.04418Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M9.5 3C8.67157 3 8 3.67157 8 4.5V13C8 12.1954 11.2366 12.0382 12.5017 12.0075C12.7778 12.0008 13 11.7761 13 11.5V3.5C13 3.22386 12.7761 3 12.5 3H9.5Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.63246 3.04418C7.44914 3.31641 8 4.08069 8 4.94155V11.7306C8 12.0924 7.62757 12.3345 7.29693 12.1875L3.79693 10.632C3.61637 10.5518 3.5 10.3727 3.5 10.1751V2.69374C3.5 2.35246 3.83435 2.11148 4.15811 2.2194L6.63246 3.04418Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.5 3C8.67157 3 8 3.67157 8 4.5V13C8 12.1954 11.2366 12.0382 12.5017 12.0075C12.7778 12.0008 13 11.7761 13 11.5V3.5C13 3.22386 12.7761 3 12.5 3H9.5Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M11.0426 5.32305L11.0426 5.32306L11.0457 5.32471C12.4534 6.05668 13.25 7.21804 13.25 8.42984C13.25 9.40862 12.7315 10.3471 11.7886 11.0652C10.845 11.7839 9.50819 12.25 8 12.25C6.49181 12.25 5.155 11.7839 4.21141 11.0652C3.2685 10.3471 2.75 9.40862 2.75 8.42984C2.75 7.21804 3.54655 6.05668 4.95426 5.32471L4.95427 5.32473L4.95849 5.3225C5.44976 5.06306 5.93128 4.79038 6.4063 4.50125C6.82126 4.25139 7.14467 4.05839 7.42857 3.92422C7.71398 3.78934 7.88783 3.75 8 3.75C8.28571 3.75 8.57685 3.89469 9.43489 4.41073L9.43488 4.41075L9.43944 4.41345C9.47377 4.43377 9.50881 4.45453 9.54456 4.47572C9.94472 4.71289 10.4345 5.00316 11.0426 5.32305Z" stroke="black" stroke-width="1.5"/>
+ <path d="M11.0426 5.32305L11.0426 5.32306L11.0457 5.32471C12.4534 6.05668 13.25 7.21804 13.25 8.42984C13.25 9.40862 12.7315 10.3471 11.7886 11.0652C10.845 11.7839 9.50819 12.25 8 12.25C6.49181 12.25 5.155 11.7839 4.21141 11.0652C3.2685 10.3471 2.75 9.40862 2.75 8.42984C2.75 7.21804 3.54655 6.05668 4.95426 5.32471L4.95427 5.32473L4.95849 5.3225C5.44976 5.06306 5.93128 4.79038 6.4063 4.50125C6.82126 4.25139 7.14467 4.05839 7.42857 3.92422C7.71398 3.78934 7.88783 3.75 8 3.75C8.28571 3.75 8.57685 3.89469 9.43489 4.41073L9.43488 4.41075L9.43944 4.41345C9.47377 4.43377 9.50881 4.45453 9.54456 4.47572C9.94472 4.71289 10.4345 5.00316 11.0426 5.32305Z" stroke="black" stroke-width="1.2"/>
<path d="M13 7C15.5 12.5 7.92993 15 3.92993 11" stroke="black" stroke-width="1.25"/>
<circle cx="6" cy="7.75" r="1" fill="black"/>
<circle cx="10" cy="7.75" r="1" fill="black"/>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4.63281 6.66406L7.99344 9.89844L11.3672 6.66406" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.63281 6.66406L7.99344 9.89844L11.3672 6.66406" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M9.35938 4.63281L6.125 7.99344L9.35938 11.3672" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.35938 4.63281L6.125 7.99344L9.35938 11.3672" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M6.64062 4.64062L9.89062 8.00125L6.64062 11.375" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.64062 4.64062L9.89062 8.00125L6.64062 11.375" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4.63281 9.36719L7.99344 6.13281L11.3672 9.36719" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.63281 9.36719L7.99344 6.13281L11.3672 9.36719" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5.78125 3C3.90625 3 3.90625 4.5 3.90625 5.5C3.90625 6.5 3.40625 7.50106 2.40625 8C3.40625 8.50106 3.90625 9.5 3.90625 10.5C3.90625 11.5 3.90625 13 5.78125 13" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10.2422 3C12.1172 3 12.1172 4.5 12.1172 5.5C12.1172 6.5 12.6172 7.50106 13.6172 8C12.6172 8.50106 12.1172 9.5 12.1172 10.5C12.1172 11.5 12.1172 13 10.2422 13" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.78125 3C3.90625 3 3.90625 4.5 3.90625 5.5C3.90625 6.5 3.40625 7.50106 2.40625 8C3.40625 8.50106 3.90625 9.5 3.90625 10.5C3.90625 11.5 3.90625 13 5.78125 13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.2422 3C12.1172 3 12.1172 4.5 12.1172 5.5C12.1172 6.5 12.6172 7.50106 13.6172 8C12.6172 8.50106 12.1172 9.5 12.1172 10.5C12.1172 11.5 12.1172 13 10.2422 13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3061 6.5778C11.2118 7.65229 5.59818 7.64305 3.55456 6.49718C3.34826 6.38151 3.00857 6.53238 3.02549 6.76829C3.25878 10.0209 5.09256 13 8.49998 13C11.8648 13 13.6714 10.1058 13.9591 6.91373C13.9819 6.66029 13.5325 6.46164 13.3061 6.5778Z" fill="black"/>
<path d="M10.0555 5.53646C10.4444 6.0547 11.9998 6.57297 12 4.49998C12.0002 2.42709 9.66679 2.94528 8.50013 4.49998C7.33348 6.05467 4.99991 6.57294 5 4.5C5.00009 2.42706 6.55548 2.94528 6.94443 3.46352" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
- <circle cx="4" cy="10.5" r="1.75" stroke="black" stroke-width="1.5"/>
+ <circle cx="4" cy="10.5" r="1.75" stroke="black" stroke-width="1.2"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.46115 9.43419C8.30678 9.43419 9.92229 8.43411 9.92229 6.21171C9.92229 3.98933 8.30678 2.98926 6.46115 2.98926C4.61553 2.98926 3 3.98933 3 6.21171C3 7.028 3.21794 7.67935 3.58519 8.17685C3.7184 8.35732 3.69033 8.77795 3.58387 8.97539C3.32908 9.44793 3.81048 9.9657 4.33372 9.84571C4.72539 9.75597 5.13621 9.63447 5.49574 9.4715C5.62736 9.41181 5.7727 9.38777 5.91631 9.40402C6.09471 9.42416 6.27678 9.43419 6.46115 9.43419Z" fill="black" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M12.3385 7.24835C12.7049 7.74561 12.9224 8.39641 12.9224 9.2117C12.9224 10.028 12.7044 10.6793 12.3372 11.1768C12.204 11.3573 12.232 11.7779 12.3385 11.9754C12.5933 12.4479 12.1119 12.9657 11.5886 12.8457C11.197 12.756 10.7862 12.6345 10.4266 12.4715C10.295 12.4118 10.1497 12.3878 10.0061 12.404C9.82765 12.4242 9.64558 12.4342 9.46121 12.4342C8.61469 12.4342 7.81658 12.2238 7.20055 11.7816" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.3385 7.24835C12.7049 7.74561 12.9224 8.39641 12.9224 9.2117C12.9224 10.028 12.7044 10.6793 12.3372 11.1768C12.204 11.3573 12.232 11.7779 12.3385 11.9754C12.5933 12.4479 12.1119 12.9657 11.5886 12.8457C11.197 12.756 10.7862 12.6345 10.4266 12.4715C10.295 12.4118 10.1497 12.3878 10.0061 12.404C9.82765 12.4242 9.64558 12.4342 9.46121 12.4342C8.61469 12.4342 7.81658 12.2238 7.20055 11.7816" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M12 11.25H11.25V12V13.25H7.31066L2.35809 8.29743L3.60151 4.56717L7.81937 2.88003L13.25 8.31066V11.25H12Z" stroke="black" stroke-width="1.5"/>
+ <path d="M12 11.25H11.25V12V13.25H7.31066L2.35809 8.29743L3.60151 4.56717L7.81937 2.88003L13.25 8.31066V11.25H12Z" stroke="black" stroke-width="1.2"/>
<path d="M10.928 11.4328L3.5 4.5H9.89645C9.96275 4.5 10.0263 4.52634 10.0732 4.57322L13.4268 7.92678C13.4737 7.97366 13.5 8.03725 13.5 8.10355V11.25C13.5 11.3881 13.3881 11.5 13.25 11.5H11.0985C11.0352 11.5 10.9743 11.476 10.928 11.4328Z" fill="black"/>
<path d="M4 11L4.5 5C3.97221 4.7361 3.33305 5.00085 3.14645 5.56066L2.19544 8.41368C2.07566 8.77302 2.16918 9.16918 2.43702 9.43702L4 11Z" fill="black"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7.99993 6.85713C11.1558 6.85713 13.7142 5.83379 13.7142 4.57142C13.7142 3.30905 11.1558 2.28571 7.99993 2.28571C4.84402 2.28571 2.28564 3.30905 2.28564 4.57142C2.28564 5.83379 4.84402 6.85713 7.99993 6.85713Z" fill="black" stroke="black" stroke-width="1.5"/>
-<path d="M13.7142 4.57141V11.4286C13.7142 12.691 11.1558 13.7143 7.99993 13.7143C4.84402 13.7143 2.28564 12.691 2.28564 11.4286V4.57141" stroke="black" stroke-width="1.5"/>
-<path d="M13.7142 8C13.7142 9.26237 11.1558 10.2857 7.99993 10.2857C4.84402 10.2857 2.28564 9.26237 2.28564 8" stroke="black" stroke-width="1.5"/>
+<path d="M7.99993 6.85713C11.1558 6.85713 13.7142 5.83379 13.7142 4.57142C13.7142 3.30905 11.1558 2.28571 7.99993 2.28571C4.84402 2.28571 2.28564 3.30905 2.28564 4.57142C2.28564 5.83379 4.84402 6.85713 7.99993 6.85713Z" fill="black" stroke="black" stroke-width="1.2"/>
+<path d="M13.7142 4.57141V11.4286C13.7142 12.691 11.1558 13.7143 7.99993 13.7143C4.84402 13.7143 2.28564 12.691 2.28564 11.4286V4.57141" stroke="black" stroke-width="1.2"/>
+<path d="M13.7142 8C13.7142 9.26237 11.1558 10.2857 7.99993 10.2857C4.84402 10.2857 2.28564 9.26237 2.28564 8" stroke="black" stroke-width="1.2"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8.5 3L8.5 10" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5 6.5H12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5 13H12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8.5 3L8.5 10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 6.5H12" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 13H12" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M13.5413 8.31248C13.6529 8.11911 13.6529 7.88086 13.5413 7.68748L11.0413 3.35736C10.9296 3.16398 10.7233 3.04486 10.5 3.04486H5.5C5.27671 3.04486 5.07038 3.16398 4.95873 3.35736L2.45873 7.68748C2.34709 7.88086 2.34709 8.11911 2.45873 8.31248L4.95873 12.6426C5.07038 12.836 5.27671 12.9551 5.5 12.9551H10.5C10.7233 12.9551 10.9296 12.836 11.0413 12.6426L13.5413 8.31248Z" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>
+ <path d="M13.5413 8.31248C13.6529 8.11911 13.6529 7.88086 13.5413 7.68748L11.0413 3.35736C10.9296 3.16398 10.7233 3.04486 10.5 3.04486H5.5C5.27671 3.04486 5.07038 3.16398 4.95873 3.35736L2.45873 7.68748C2.34709 7.88086 2.34709 8.11911 2.45873 8.31248L4.95873 12.6426C5.07038 12.836 5.27671 12.9551 5.5 12.9551H10.5C10.7233 12.9551 10.9296 12.836 11.0413 12.6426L13.5413 8.31248Z" stroke="black" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M7.74994 5.14432C7.90464 5.055 8.09524 5.055 8.24994 5.14432L10.348 6.35564C10.5027 6.44496 10.598 6.61002 10.598 6.78866V9.2113C10.598 9.38994 10.5027 9.555 10.348 9.64432L8.24994 10.8556C8.09524 10.945 7.90464 10.945 7.74994 10.8556L5.65186 9.64432C5.49716 9.555 5.40186 9.38994 5.40186 9.2113V6.78866C5.40186 6.61002 5.49716 6.44496 5.65186 6.35564L7.74994 5.14432Z" fill="black"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3 5H11" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M3 8H13" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M3 11H9" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M3 5H11" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M3 8H13" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M3 11H9" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8.26046 3.97337C8.3527 4.17617 8.4795 4.47151 8.57375 4.69341C8.65258 4.87898 8.83437 4.99999 9.03599 4.99999H12.5C12.7761 4.99999 13 5.22385 13 5.49999V12.125C13 12.4011 12.7761 12.625 12.5 12.625H3.5C3.22386 12.625 3 12.4011 3 12.125V3.86932C3 3.59318 3.22386 3.36932 3.5 3.36932H7.34219C7.74141 3.36932 8.09483 3.60924 8.26046 3.97337Z" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M8.26046 3.97337C8.3527 4.17617 8.4795 4.47151 8.57375 4.69341C8.65258 4.87898 8.83437 4.99999 9.03599 4.99999H12.5C12.7761 4.99999 13 5.22385 13 5.49999V12.125C13 12.4011 12.7761 12.625 12.5 12.625H3.5C3.22386 12.625 3 12.4011 3 12.125V3.86932C3 3.59318 3.22386 3.36932 3.5 3.36932H7.34219C7.74141 3.36932 8.09483 3.60924 8.26046 3.97337Z" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4.42782 7.2487C4.43495 6.97194 4.65009 6.75 4.91441 6.75H13.5293C13.7935 6.75 14.007 6.97194 13.9998 7.2487C13.9628 8.6885 13.7533 12.75 12.5721 12.75H3.375C4.55631 12.75 4.3907 8.6885 4.42782 7.2487Z" fill="black" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>
-<path d="M5.19598 12.625H3.66515C3.42618 12.625 3.22289 12.4453 3.18626 12.2017L1.94333 3.93602C1.89776 3.63295 2.12496 3.35938 2.42223 3.35938H5.78585C6.11241 3.35938 6.41702 3.52903 6.59618 3.81071L6.94517 4.35938H9.92811C10.4007 4.35938 10.8044 4.71102 10.8836 5.1917L11.1251 6.65624" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.42782 7.2487C4.43495 6.97194 4.65009 6.75 4.91441 6.75H13.5293C13.7935 6.75 14.007 6.97194 13.9998 7.2487C13.9628 8.6885 13.7533 12.75 12.5721 12.75H3.375C4.55631 12.75 4.3907 8.6885 4.42782 7.2487Z" fill="black" stroke="black" stroke-width="1.2" stroke-linejoin="round"/>
+<path d="M5.19598 12.625H3.66515C3.42618 12.625 3.22289 12.4453 3.18626 12.2017L1.94333 3.93602C1.89776 3.63295 2.12496 3.35938 2.42223 3.35938H5.78585C6.11241 3.35938 6.41702 3.52903 6.59618 3.81071L6.94517 4.35938H9.92811C10.4007 4.35938 10.8044 4.71102 10.8836 5.1917L11.1251 6.65624" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M10.3352 13.2519H12.375M4.49719 13.2519L8.00001 2.74811L11.5028 13.2519M3.625 13.2519H5.6648M9.74908 9.16761H6.25095" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.3352 13.2519H12.375M4.49719 13.2519L8.00001 2.74811L11.5028 13.2519M3.625 13.2519H5.6648M9.74908 9.16761H6.25095" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,6 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5 13C6.10457 13 7 12.1046 7 11C7 9.89543 6.10457 9 5 9C3.89543 9 3 9.89543 3 11C3 12.1046 3.89543 13 5 13Z" stroke="black" stroke-width="1.5"/>
-<path d="M11 7C12.1046 7 13 6.10457 13 5C13 3.89543 12.1046 3 11 3C9.89543 3 9 3.89543 9 5C9 6.10457 9.89543 7 11 7Z" fill="black" stroke="black" stroke-width="1.5"/>
-<path d="M4.625 3.625V8.375" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M11 7C11 9.20914 9.20914 11 7 11" stroke="black" stroke-width="1.5"/>
+<path d="M5 13C6.10457 13 7 12.1046 7 11C7 9.89543 6.10457 9 5 9C3.89543 9 3 9.89543 3 11C3 12.1046 3.89543 13 5 13Z" stroke="black" stroke-width="1.2"/>
+<path d="M11 7C12.1046 7 13 6.10457 13 5C13 3.89543 12.1046 3 11 3C9.89543 3 9 3.89543 9 5C9 6.10457 9.89543 7 11 7Z" fill="black" stroke="black" stroke-width="1.2"/>
+<path d="M4.625 3.625V8.375" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M11 7C11 9.20914 9.20914 11 7 11" stroke="black" stroke-width="1.2"/>
</svg>
@@ -1,7 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.3848 9.30444C7.3848 9.30444 7.53254 10.2646 8.53248 10.0882C9.53242 9.91193 9.36378 8.95549 9.36378 8.95549" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5.54155 5.54157C6.12355 4.90104 6.01688 2.62541 7.22875 2.3985C8.44063 2.17158 9.19097 4.33148 9.91982 4.6814C10.6487 5.03133 12.8517 4.3028 13.4381 5.38734C14.0244 6.47188 12.1395 7.95973 12.026 8.64088C11.9126 9.32203 13.3614 11.2416 12.4675 12.1701C11.5736 13.0986 9.73005 11.7545 8.90486 11.8834C8.07966 12.0123 6.79244 13.9095 5.67367 13.3502C4.55491 12.7909 5.16702 10.5455 4.82437 9.87612C4.48171 9.20673 2.34028 8.54978 2.4525 7.35049C2.56471 6.15121 4.95956 6.1821 5.54155 5.54157Z" stroke="#FF7676" stroke-opacity="0.52" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5.54155 5.54157C6.12355 4.90104 6.01688 2.62541 7.22875 2.3985C8.44063 2.17158 9.19097 4.33148 9.91982 4.6814C10.6487 5.03133 12.8517 4.3028 13.4381 5.38734C14.0244 6.47188 12.1395 7.95973 12.026 8.64088C11.9126 9.32203 13.3614 11.2416 12.4675 12.1701C11.5736 13.0986 9.73005 11.7545 8.90486 11.8834C8.07966 12.0123 6.79244 13.9095 5.67367 13.3502C4.55491 12.7909 5.16702 10.5455 4.82437 9.87612C4.48171 9.20673 2.34028 8.54978 2.4525 7.35049C2.56471 6.15121 4.95956 6.1821 5.54155 5.54157Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.54155 5.54157C6.12355 4.90104 6.01688 2.62541 7.22875 2.3985C8.44063 2.17158 9.19097 4.33148 9.91982 4.6814C10.6487 5.03133 12.8517 4.3028 13.4381 5.38734C14.0244 6.47188 12.1395 7.95973 12.026 8.64088C11.9126 9.32203 13.3614 11.2416 12.4675 12.1701C11.5736 13.0986 9.73005 11.7545 8.90486 11.8834C8.07966 12.0123 6.79244 13.9095 5.67367 13.3502C4.55491 12.7909 5.16702 10.5455 4.82437 9.87612C4.48171 9.20673 2.34028 8.54978 2.4525 7.35049C2.56471 6.15121 4.95956 6.1821 5.54155 5.54157Z" stroke="#FF7676" stroke-opacity="0.52" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.54155 5.54157C6.12355 4.90104 6.01688 2.62541 7.22875 2.3985C8.44063 2.17158 9.19097 4.33148 9.91982 4.6814C10.6487 5.03133 12.8517 4.3028 13.4381 5.38734C14.0244 6.47188 12.1395 7.95973 12.026 8.64088C11.9126 9.32203 13.3614 11.2416 12.4675 12.1701C11.5736 13.0986 9.73005 11.7545 8.90486 11.8834C8.07966 12.0123 6.79244 13.9095 5.67367 13.3502C4.55491 12.7909 5.16702 10.5455 4.82437 9.87612C4.48171 9.20673 2.34028 8.54978 2.4525 7.35049C2.56471 6.15121 4.95956 6.1821 5.54155 5.54157Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="6.25098" cy="7.75" r="0.75" fill="black"/>
<circle cx="10.1035" cy="7.25" r="0.75" fill="black"/>
</svg>
@@ -1,6 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8 5L10.5981 9.5H5.40192L8 5Z" stroke="black" stroke-width="1.5"/>
-<path d="M8 3L12.3301 5.5V10.5L8 13L3.66987 10.5V5.5L8 3Z" stroke="black" stroke-width="1.5"/>
+<path d="M8 5L10.5981 9.5H5.40192L8 5Z" stroke="black" stroke-width="1.2"/>
+<path d="M8 3L12.3301 5.5V10.5L8 13L3.66987 10.5V5.5L8 3Z" stroke="black" stroke-width="1.2"/>
<circle cx="3.5" cy="5.5" r="1" fill="black"/>
<circle cx="12.5" cy="5.5" r="1" fill="black"/>
<circle cx="8" cy="3" r="1" fill="black"/>
@@ -1,6 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M11.2795 3.63849L8.7478 12.0142" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M7.26626 3.99597L4.73462 12.3717" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M4.15991 6.37988H12.9099" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M3.09839 9.62408H11.8484" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M11.2795 3.63849L8.7478 12.0142" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M7.26626 3.99597L4.73462 12.3717" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M4.15991 6.37988H12.9099" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M3.09839 9.62408H11.8484" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<rect x="2" y="2" width="12" height="12" rx="2" stroke="black" stroke-width="1.5"/>
+<rect x="2" y="2" width="12" height="12" rx="2" stroke="black" stroke-width="1.2"/>
<path d="M6.74217 7.13317V4.26172V4.13672H6.61717H5.50781H5.38281V4.26172V8.8824V9.07567L5.55908 8.9964L6.33378 8.64803L6.33378 8.64803L6.33389 8.64798L6.33443 8.64774L6.3347 8.64762L6.3369 8.64667L6.34717 8.64225C6.35635 8.63832 6.37013 8.63248 6.38816 8.62501C6.42423 8.61006 6.47729 8.58857 6.54454 8.56272C6.67911 8.511 6.87014 8.44194 7.09531 8.3729C7.54765 8.2342 8.12948 8.09817 8.66592 8.09817C8.92095 8.09817 9.05676 8.16584 9.12979 8.241C9.2037 8.31708 9.23311 8.42118 9.23311 8.53361V11.7383V11.8633H9.35811H10.4922H10.6172V11.7383V8.53361V8.53322C10.6172 8.43725 10.6172 7.81276 10.1093 7.32276C9.86619 7.08825 9.42187 6.80777 8.69373 6.80777C8.00022 6.80777 7.28721 6.96253 6.74217 7.13317ZM8.45652 5.91932L8.29694 6.12171H8.55468H9.66094H9.71713L9.75443 6.07969C10.2672 5.502 10.5291 4.91889 10.616 4.27855L10.6353 4.13672H10.4922H9.38592H9.28153L9.26292 4.23944C9.1561 4.82914 8.88874 5.37113 8.45652 5.91932Z" fill="black" stroke="black" stroke-width="0.25"/>
<path d="M5.38281 11.7383V12.01L5.58915 11.8332L6.83447 10.766L6.94523 10.671L6.83447 10.5761L5.58915 9.50891L5.38281 9.33207V9.60382V11.7383Z" fill="black" stroke="black" stroke-width="0.25"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M9.15741 4.17108L6.84277 11.8289" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M4.74951 6L2.74951 8L4.74951 10" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11.25 10L13.25 8L11.25 6" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.15741 4.17108L6.84277 11.8289" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M4.74951 6L2.74951 8L4.74951 10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.25 10L13.25 8L11.25 6" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,7 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 4C7.91421 4 8.25 3.66421 8.25 3.25C8.25 2.83579 7.91421 2.5 7.5 2.5C7.08579 2.5 6.75 2.83579 6.75 3.25C6.75 3.66421 7.08579 4 7.5 4Z" fill="black" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 8L10 6L12 8H8Z" fill="black"/>
-<path d="M3 11L6 8L8.375 10.375" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7 9L8.5 7.5L10 6L11.5 7.5L13 9" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M4.375 3H3.5C3.22386 3 3 3.22386 3 3.5V12.5C3 12.7761 3.22386 13 3.5 13H8.35938M10.6406 3H12.5C12.7761 3 13 3.22386 13 3.5V12.5C13 12.7761 12.7761 13 12.5 13H11.125" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M3 11L6 8L8.375 10.375" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7 9L8.5 7.5L10 6L11.5 7.5L13 9" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.375 3H3.5C3.22386 3 3 3.22386 3 3.5V12.5C3 12.7761 3.22386 13 3.5 13H8.35938M10.6406 3H12.5C12.7761 3 13 3.22386 13 3.5V12.5C13 12.7761 12.7761 13 12.5 13H11.125" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,7 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4.99219 8.56632C6.5 9 10.5415 8.99989 12 7.99995C13.4585 7 12.5 9.49999 12.5 10" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M11.5 13C9 13.5781 6 13.5938 4 13" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M10.0156 10.9844C8.51562 11.2031 6.5 11.2031 5 10.8906" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M6.5625 6.5C6.34375 6 6.06838 4.93125 6.99999 4.03125C7.93161 3.13125 8.58082 3.33636 9.00002 2" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M9.18477 6.50002C8.88168 6.05002 8.40637 5.71875 9.00014 5.40625C9.5939 5.09375 10.3126 4.65625 10.8751 3.53125" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M4.99219 8.56632C6.5 9 10.5415 8.99989 12 7.99995C13.4585 7 12.5 9.49999 12.5 10" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M11.5 13C9 13.5781 6 13.5938 4 13" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M10.0156 10.9844C8.51562 11.2031 6.5 11.2031 5 10.8906" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M6.5625 6.5C6.34375 6 6.06838 4.93125 6.99999 4.03125C7.93161 3.13125 8.58082 3.33636 9.00002 2" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M9.18477 6.50002C8.88168 6.05002 8.40637 5.71875 9.00014 5.40625C9.5939 5.09375 10.3126 4.65625 10.8751 3.53125" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -0,0 +1 @@
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5 5C5 3.89543 5.89543 3 7 3H9C10.1046 3 11 3.89543 11 5V6H5V5Z" stroke="black" stroke-width="1.5"/>
+<path d="M5 5C5 3.89543 5.89543 3 7 3H9C10.1046 3 11 3.89543 11 5V6H5V5Z" stroke="black" stroke-width="1.2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.25 6.5C3.25 5.80964 3.80964 5.25 4.5 5.25H11.5C12.1904 5.25 12.75 5.80964 12.75 6.5V12.5C12.75 13.1904 12.1904 13.75 11.5 13.75H4.5C3.80964 13.75 3.25 13.1904 3.25 12.5V6.5ZM8.75 9.66146C8.90559 9.48517 9 9.25361 9 9C9 8.44772 8.55228 8 8 8C7.44772 8 7 8.44772 7 9C7 9.25361 7.09441 9.48517 7.25 9.66146V11C7.25 11.4142 7.58579 11.75 8 11.75C8.41421 11.75 8.75 11.4142 8.75 11V9.66146Z" fill="black"/>
</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M13 13L10.4138 10.4138M3 7.31034C3 4.92981 4.92981 3 7.31034 3C9.6909 3 11.6207 4.92981 11.6207 7.31034C11.6207 9.6909 9.6909 11.6207 7.31034 11.6207C4.92981 11.6207 3 9.6909 3 7.31034Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13 13L10.4138 10.4138M3 7.31034C3 4.92981 4.92981 3 7.31034 3C9.6909 3 11.6207 4.92981 11.6207 7.31034C11.6207 9.6909 9.6909 11.6207 7.31034 11.6207C4.92981 11.6207 3 9.6909 3 7.31034Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,8 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M6.00005 4.76556L4.76569 2.74996M6.00005 4.76556L3.75 4.76563M6.00005 4.76556L7.25006 4.7656" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M10.0232 11.2311L11.2675 13.2406M10.0232 11.2311L12.2732 11.2199M10.0232 11.2311L8.7732 11.2373" stroke="black" stroke-opacity="0.5" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M9.99025 4.91551L10.9985 2.77781M9.99025 4.91551L8.75599 3.03419M9.99025 4.91551L10.6759 5.9607" stroke="black" stroke-opacity="0.5" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M6.0323 11.1009L5.03465 13.2436M6.0323 11.1009L7.27585 12.9761M6.0323 11.1009L5.34151 10.0592" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M11.883 8.19023L14.2466 8.19287M11.883 8.19023L13.0602 6.27268M11.883 8.19023L11.229 9.25547" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M4.12354 7.8356L1.76002 7.84465M4.12354 7.8356L2.95585 9.75894M4.12354 7.8356L4.7723 6.76713" stroke="black" stroke-opacity="0.5" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M6.00005 4.76556L4.76569 2.74996M6.00005 4.76556L3.75 4.76563M6.00005 4.76556L7.25006 4.7656" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M10.0232 11.2311L11.2675 13.2406M10.0232 11.2311L12.2732 11.2199M10.0232 11.2311L8.7732 11.2373" stroke="black" stroke-opacity="0.5" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M9.99025 4.91551L10.9985 2.77781M9.99025 4.91551L8.75599 3.03419M9.99025 4.91551L10.6759 5.9607" stroke="black" stroke-opacity="0.5" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M6.0323 11.1009L5.03465 13.2436M6.0323 11.1009L7.27585 12.9761M6.0323 11.1009L5.34151 10.0592" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M11.883 8.19023L14.2466 8.19287M11.883 8.19023L13.0602 6.27268M11.883 8.19023L11.229 9.25547" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M4.12354 7.8356L1.76002 7.84465M4.12354 7.8356L2.95585 9.75894M4.12354 7.8356L4.7723 6.76713" stroke="black" stroke-opacity="0.5" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,8 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.03125 3.96875C3.03125 3.41647 3.47897 2.96875 4.03125 2.96875H6V13H4.03125C3.47897 13 3.03125 12.5523 3.03125 12V3.96875Z" fill="black"/>
-<path d="M12.5 3H3.5C3.22386 3 3 3.22386 3 3.5V12.5C3 12.7761 3.22386 13 3.5 13H12.5C12.7761 13 13 12.7761 13 12.5V3.5C13 3.22386 12.7761 3 12.5 3Z" stroke="black" stroke-width="1.5"/>
-<path d="M10.5 5.75H8.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10.5 8H8.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10.5 10.25H8.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M6 3V14" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.5 3H3.5C3.22386 3 3 3.22386 3 3.5V12.5C3 12.7761 3.22386 13 3.5 13H12.5C12.7761 13 13 12.7761 13 12.5V3.5C13 3.22386 12.7761 3 12.5 3Z" stroke="black" stroke-width="1.2"/>
+<path d="M10.5 5.75H8.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.5 8H8.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.5 10.25H8.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6 3V14" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M2.62671 4.88474L7.99977 7.78519M2.62671 4.88474L2.63131 10.9001L8.00436 13.8005M2.62671 4.88474L5.31111 3.54213M7.99977 7.78519L8.00436 13.8005M7.99977 7.78519L10.6841 6.33086M8.00436 13.8005L13.3729 10.8919L13.3683 4.87654M5.31111 3.54213L7.9955 2.19952L13.3683 4.87654M5.31111 3.54213L10.6841 6.33086M10.6841 6.33086L13.3683 4.87654" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2.62671 4.88474L7.99977 7.78519M2.62671 4.88474L2.63131 10.9001L8.00436 13.8005M2.62671 4.88474L5.31111 3.54213M7.99977 7.78519L8.00436 13.8005M7.99977 7.78519L10.6841 6.33086M8.00436 13.8005L13.3729 10.8919L13.3683 4.87654M5.31111 3.54213L7.9955 2.19952L13.3683 4.87654M5.31111 3.54213L10.6841 6.33086M10.6841 6.33086L13.3683 4.87654" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.03125 13.5625V7.78125L2.5625 4.9375V10.75L8.03125 13.5625Z" fill="black"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M13 9C13 8.32138 12.9375 7.5 12.7188 6.75C12.0625 7.53125 10.875 8.1875 10 8.5C10.75 5.90625 9.5625 3.1875 8 3C8 4.96875 7.625 5.90625 6.5 7.5C5 5 3.5 6.5 3 7C3.5 7.5 4.21832 8.24064 4.34375 9.3125C4.6875 12.25 6.75 13 8.5 13C10.25 13 10.5 11 12.5 12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M13 9C13 8.32138 12.9375 7.5 12.7188 6.75C12.0625 7.53125 10.875 8.1875 10 8.5C10.75 5.90625 9.5625 3.1875 8 3C8 4.96875 7.625 5.90625 6.5 7.5C5 5 3.5 6.5 3 7C3.5 7.5 4.21832 8.24064 4.34375 9.3125C4.6875 12.25 6.75 13 8.5 13C10.25 13 10.5 11 12.5 12" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.03125 9.15625C5.87694 9.15625 6.5625 8.47069 6.5625 7.625C6.5625 6.77931 5.87694 6.09375 5.03125 6.09375C4.18556 6.09375 3.5 6.77931 3.5 7.625C3.5 8.47069 4.18556 9.15625 5.03125 9.15625Z" fill="black"/>
</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8 4V12M12 8H4" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M8 4V12M12 8H4" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,12 +1,12 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M3 3.86328H9.51563" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
- <path d="M12 3.86328H13" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
- <path d="M10.6406 6.62628H13" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
- <path d="M5.79688 6.62628H8.15625" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
- <path d="M3 6.62628H3.35937" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
- <path d="M8.15625 9.37372H13" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
- <path d="M3 9.37372H5.64062" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
- <path d="M3 12.1094H4.54687" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
- <path d="M6.97656 12.1094H9.35938" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
- <path d="M11.8203 12.1094H13" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+ <path d="M3 3.86328H9.51563" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+ <path d="M12 3.86328H13" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+ <path d="M10.6406 6.62628H13" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+ <path d="M5.79688 6.62628H8.15625" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+ <path d="M3 6.62628H3.35937" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+ <path d="M8.15625 9.37372H13" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+ <path d="M3 9.37372H5.64062" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+ <path d="M3 12.1094H4.54687" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+ <path d="M6.97656 12.1094H9.35938" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+ <path d="M11.8203 12.1094H13" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3.03125 3V3.03125M3.03125 3.03125V9M3.03125 3.03125C3.03125 5 6 5 6 5M3.03125 9C3.03125 11 6 11 6 11M3.03125 9V12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.03125 3V3.03125M3.03125 3.03125V9M3.03125 3.03125C3.03125 5 6 5 6 5M3.03125 9C3.03125 11 6 11 6 11M3.03125 9V12" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="8" y="2.5" width="6" height="5" rx="1.5" fill="black"/>
<rect x="8" y="8.46875" width="6" height="5.0625" rx="1.5" fill="black"/>
</svg>
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="17" height="16" fill="none"><path fill="#000" d="M12.5 9.639V6.354H9.683L8.18 5.007V2.5H4.5v3.285h2.817l1.51 1.347v1.736l-1.51 1.347H4.5V13.5h3.681v-2.507l1.51-1.347H12.5v-.007ZM5.727 3.595h1.227V4.69H5.727V3.595Zm1.227 8.803H5.727v-1.095h1.227v1.095Z"/></svg>
@@ -1,6 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7.18452 2.91638C6.01625 2.91638 4.98489 3.77623 4.91991 4.94678H4.72024C3.81569 4.94678 3 5.63731 3 6.58698V8.10978C3 9.05945 3.81569 9.74998 4.72024 9.74998H5.33631C5.67376 9.74998 6.02976 9.48559 6.02976 9.06153C6.02976 8.46056 6.51694 7.97338 7.11791 7.97338H8.27976C9.18431 7.97338 10 7.28286 10 6.33318V5.06418C10 3.83417 8.93913 2.91638 7.73214 2.91638H7.18452Z" stroke="black" stroke-width="1.5"/>
-<path d="M8.79613 13.0836C9.97889 13.0836 11.0103 12.2025 11.0702 11.0191H11.2738C12.1885 11.0191 13 10.3146 13 9.36187V7.8135C13 6.86077 12.1885 6.15625 11.2738 6.15625H10.6544C10.3099 6.15625 9.96057 6.42749 9.96057 6.84577C9.96057 7.46262 9.46051 7.96268 8.84365 7.96268H7.69494C6.78027 7.96268 5.96875 8.6672 5.96875 9.61993V10.9102C5.96875 12.148 7.02678 13.0836 8.24554 13.0836H8.79613Z" stroke="black" stroke-width="1.5"/>
+<path d="M7.18452 2.91638C6.01625 2.91638 4.98489 3.77623 4.91991 4.94678H4.72024C3.81569 4.94678 3 5.63731 3 6.58698V8.10978C3 9.05945 3.81569 9.74998 4.72024 9.74998H5.33631C5.67376 9.74998 6.02976 9.48559 6.02976 9.06153C6.02976 8.46056 6.51694 7.97338 7.11791 7.97338H8.27976C9.18431 7.97338 10 7.28286 10 6.33318V5.06418C10 3.83417 8.93913 2.91638 7.73214 2.91638H7.18452Z" stroke="black" stroke-width="1.2"/>
+<path d="M8.79613 13.0836C9.97889 13.0836 11.0103 12.2025 11.0702 11.0191H11.2738C12.1885 11.0191 13 10.3146 13 9.36187V7.8135C13 6.86077 12.1885 6.15625 11.2738 6.15625H10.6544C10.3099 6.15625 9.96057 6.42749 9.96057 6.84577C9.96057 7.46262 9.46051 7.96268 8.84365 7.96268H7.69494C6.78027 7.96268 5.96875 8.6672 5.96875 9.61993V10.9102C5.96875 12.148 7.02678 13.0836 8.24554 13.0836H8.79613Z" stroke="black" stroke-width="1.2"/>
<path d="M7.20312 6.01758C7.64323 6.01758 8 5.6608 8 5.2207C8 4.7806 7.64323 4.42383 7.20312 4.42383C6.76302 4.42383 6.40625 4.7806 6.40625 5.2207C6.40625 5.6608 6.76302 6.01758 7.20312 6.01758Z" fill="black"/>
<path d="M8.79687 11.5939C9.23698 11.5939 9.59375 11.2372 9.59375 10.7971C9.59375 10.357 9.23698 10.0002 8.79687 10.0002C8.35677 10.0002 8 10.357 8 10.7971C8 11.2372 8.35677 11.5939 8.79687 11.5939Z" fill="black"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7.99988 13C5.97267 13 4.22723 11.7936 3.44238 10.0595M7.99988 3C10.1122 3 11.9185 4.30981 12.6511 6.16152" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.99988 13C5.97267 13 4.22723 11.7936 3.44238 10.0595M7.99988 3C10.1122 3 11.9185 4.30981 12.6511 6.16152" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.65625 3.29688C3.00143 3.29688 3.28125 3.01705 3.28125 2.67188C3.28125 2.3267 3.00143 2.04688 2.65625 2.04688C2.31107 2.04688 2.03125 2.3267 2.03125 2.67188C2.03125 3.01705 2.31107 3.29688 2.65625 3.29688Z" fill="black"/>
<path d="M4.71094 3.29688C5.05612 3.29688 5.33594 3.01705 5.33594 2.67188C5.33594 2.3267 5.05612 2.04688 4.71094 2.04688C4.36576 2.04688 4.08594 2.3267 4.08594 2.67188C4.08594 3.01705 4.36576 3.29688 4.71094 3.29688Z" fill="black"/>
<path d="M5.96094 4.99219C6.30612 4.99219 6.58594 4.71237 6.58594 4.36719C6.58594 4.02201 6.30612 3.74219 5.96094 3.74219C5.61576 3.74219 5.33594 4.02201 5.33594 4.36719C5.33594 4.71237 5.61576 4.99219 5.96094 4.99219Z" fill="black"/>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M8.5 6.88672C10.433 6.88672 12 8.45372 12 10.3867V13M12 13L14 11M12 13L10 11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M8.5 6.88672C10.433 6.88672 12 8.45372 12 10.3867V13M12 13L14 11M12 13L10 11" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5.27935 10.9821C5.32063 10.4038 4.9204 9.89049 4.35998 9.80276L3.60081 9.68387C3.37979 9.64945 3.20167 9.48001 3.15225 9.25614L3.01378 8.63511C2.96382 8.41235 3.05233 8.1807 3.23696 8.05125L3.8631 7.61242C4.33337 7.28297 4.47456 6.6369 4.18621 6.13364L3.79467 5.45092C3.68118 5.25261 3.69801 5.00374 3.83757 4.82321L4.22314 4.32436C4.3627 4.14438 4.59621 4.06994 4.81071 4.13772L5.57531 4.37769C6.11944 4.54879 6.70048 4.26159 6.90683 3.71886L7.1811 2.99782C7.26255 2.78395 7.46345 2.64285 7.68772 2.6423L8.31007 2.64063C8.53434 2.64007 8.73579 2.78006 8.81834 2.99337L9.09965 3.72275C9.30821 4.26214 9.88655 4.54712 10.429 4.37714L11.1632 4.14716C11.3772 4.07994 11.6096 4.15382 11.7492 4.3327L12.1374 4.83099" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.27935 10.9821C5.32063 10.4038 4.9204 9.89049 4.35998 9.80276L3.60081 9.68387C3.37979 9.64945 3.20167 9.48001 3.15225 9.25614L3.01378 8.63511C2.96382 8.41235 3.05233 8.1807 3.23696 8.05125L3.8631 7.61242C4.33337 7.28297 4.47456 6.6369 4.18621 6.13364L3.79467 5.45092C3.68118 5.25261 3.69801 5.00374 3.83757 4.82321L4.22314 4.32436C4.3627 4.14438 4.59621 4.06994 4.81071 4.13772L5.57531 4.37769C6.11944 4.54879 6.70048 4.26159 6.90683 3.71886L7.1811 2.99782C7.26255 2.78395 7.46345 2.64285 7.68772 2.6423L8.31007 2.64063C8.53434 2.64007 8.73579 2.78006 8.81834 2.99337L9.09965 3.72275C9.30821 4.26214 9.88655 4.54712 10.429 4.37714L11.1632 4.14716C11.3772 4.07994 11.6096 4.15382 11.7492 4.3327L12.1374 4.83099" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -1,7 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5827 1.27457C10.4978 2.48798 6.32365 2.94703 3.49893 2.99561C3.22283 3.00036 3.00001 3.22384 3.00001 3.49998L3.00002 6.00003C3.00002 6.27617 3.22284 6.50038 3.49895 6.49563C6.42334 6.44533 10.7942 5.95506 12.7954 4.64327C12.927 4.557 13 4.40741 13 4.25004L13 1.50027C13 1.29426 12.7607 1.17094 12.5827 1.27457Z" fill="black"/>
<path d="M12.3072 6.51584C12.6851 6.6855 12.8539 7.12936 12.6842 7.50724C12.5145 7.88511 12.0707 8.05391 11.6928 7.88425L12.3072 6.51584ZM3 5.02142C4.32178 5.02142 6.01669 5.1159 7.68605 5.34579C9.34359 5.57406 11.0313 5.94302 12.3072 6.51584L11.6928 7.88425C10.611 7.39853 9.08921 7.05318 7.48142 6.83177C5.88546 6.61199 4.25922 6.52142 3 6.52142L3 5.02142Z" fill="black"/>
-<path d="M3 10.0214C5.581 10.0214 9.64229 10.3915 12 11.45" stroke="black" stroke-width="1.5"/>
+<path d="M3 10.0214C5.581 10.0214 9.64229 10.3915 12 11.45" stroke="black" stroke-width="1.2"/>
<path d="M12.1401 10.0067C9.94879 11.0472 6.13586 11.4503 3.49893 11.4956C3.22283 11.5004 3.00001 11.7238 3.00001 12L3.00002 14.5C3.00002 14.7762 3.22284 15.0004 3.49895 14.9956C6.42334 14.9453 10.7942 14.4551 12.7954 13.1433C12.927 13.057 13 12.9074 13 12.75L13 10.5002C13 10.0882 12.5123 9.82995 12.1401 10.0067Z" fill="black"/>
<path d="M12.1401 5.75668C9.94879 6.7972 6.13586 7.20026 3.49893 7.24561C3.22283 7.25036 3.00001 7.47384 3.00001 7.74998L3.00002 10.25C3.00002 10.5262 3.22284 10.7504 3.49895 10.7456C6.42334 10.6953 10.7942 10.2051 12.7954 8.89327C12.927 8.807 13 8.65741 13 8.50004L13 6.25023C13 5.83821 12.5123 5.57995 12.1401 5.75668Z" fill="black"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.628 11.0743V10.4575H8.45562L8.65084 10.2445C8.75911 10.1264 8.96952 9.79454 9.11862 9.50789C9.52153 8.73047 9.51798 7.25107 9.11862 6.43992C8.58614 5.35722 7.49453 4.56381 6.24942 4.35703C4.59252 4.08192 2.86196 5.00312 2.14045 6.54287C1.77038 7.33182 1.77038 8.64437 2.14045 9.43333C2.45905 10.1122 3.11309 10.8204 3.73609 11.1595C4.51439 11.5828 5.18264 11.676 7.51312 11.6848L9.62627 11.6928L9.628 11.0743ZM5.30605 10.169C4.24109 10.0111 3.45215 9.07124 3.45659 7.96813C3.45659 7.33004 3.70064 6.80022 4.18697 6.36182C4.67685 5.91986 5.1312 5.77344 5.86602 5.82048C7.00287 5.89236 7.82382 6.79845 7.82382 7.98056C7.82382 8.61332 7.71996 8.91682 7.33036 9.42534C6.90172 9.98444 6.08345 10.2853 5.30692 10.1699M15.1374 10.9802V10.2684H11.8138V4.47509H10.1986V11.6928H15.1374V10.9802Z" fill="black"/>
+</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3.03125 13C3.46875 12.2812 3.68556 12.0378 4.0625 11.5312M4.0625 11.5312C4.0625 9.86595 4.27768 8.02844 4.75687 7M4.0625 11.5312C4.75687 10.5981 6.6875 8.57812 7.92188 7.54688M4.0625 11.5312C7.875 11.5312 10.0507 9.46738 11.4062 8.03125C11.5818 7.84528 11.2307 7.34164 10.9157 6.96235C10.7718 6.78906 10.8964 6.50073 11.1213 6.48823C11.6657 6.45798 12.3874 6.36175 12.5 6.06684C12.7544 5.4003 12.9585 4.2437 13.0409 3.28832C13.0541 3.13644 12.9264 3.01119 12.7745 3.0243C10.5824 3.21343 8.22052 3.5262 6.5 4.82764" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M3.03125 13C3.46875 12.2812 3.68556 12.0378 4.0625 11.5312M4.0625 11.5312C4.0625 9.86595 4.27768 8.02844 4.75687 7M4.0625 11.5312C4.75687 10.5981 6.6875 8.57812 7.92188 7.54688M4.0625 11.5312C7.875 11.5312 10.0507 9.46738 11.4062 8.03125C11.5818 7.84528 11.2307 7.34164 10.9157 6.96235C10.7718 6.78906 10.8964 6.50073 11.1213 6.48823C11.6657 6.45798 12.3874 6.36175 12.5 6.06684C12.7544 5.4003 12.9585 4.2437 13.0409 3.28832C13.0541 3.13644 12.9264 3.01119 12.7745 3.0243C10.5824 3.21343 8.22052 3.5262 6.5 4.82764" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M6 6H10" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M8 6V11" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M5 3H3.5C3.22386 3 3 3.22386 3 3.5V12.5C3 12.7761 3.22386 13 3.5 13H5M11 3H12.5C12.7761 3 13 3.22386 13 3.5V12.5C13 12.7761 12.7761 13 12.5 13H11" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M6 6H10" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M8 6V11" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M5 3H3.5C3.22386 3 3 3.22386 3 3.5V12.5C3 12.7761 3.22386 13 3.5 13H5M11 3H12.5C12.7761 3 13 3.22386 13 3.5V12.5C13 12.7761 12.7761 13 12.5 13H11" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M2.65625 3H12.8437C13.1199 3 13.3438 3.22386 13.3438 3.5V10.3438M13.3438 13H3.15625C2.88011 13 2.65625 12.7761 2.65625 12.5V5.65625" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M10 8.01562L6.65625 10.3125V5.6875L10 8.01562Z" fill="black" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2.65625 3H12.8437C13.1199 3 13.3438 3.22386 13.3438 3.5V10.3438M13.3438 13H3.15625C2.88011 13 2.65625 12.7761 2.65625 12.5V5.65625" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M10 8.01562L6.65625 10.3125V5.6875L10 8.01562Z" fill="black" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 9.13502L4.578 3.21202L4.47016 3.02509C4.42551 2.9477 4.34295 2.90002 4.25361 2.90002H2.43302C2.24057 2.90002 2.12029 3.10836 2.21651 3.27503L7.7835 12.917C7.87972 13.0837 8.12028 13.0837 8.2165 12.917L13.7835 3.27503C13.8797 3.10836 13.7594 2.90002 13.567 2.90002H11.7443C11.655 2.90002 11.5725 2.94767 11.5278 3.02502L8 9.13502Z" fill="black"/>
-<path d="M3.5 3.65002H6.80469L8 5.73596L9.20312 3.65002H12.5234" stroke="black" stroke-width="1.5"/>
+<path d="M3.5 3.65002H6.80469L8 5.73596L9.20312 3.65002H12.5234" stroke="black" stroke-width="1.2"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5 5C5 3.89543 5.89543 3 7 3H9C10.1046 3 11 3.89543 11 5V6H5V5Z" stroke="black" stroke-width="1.5"/>
+<path d="M5 5C5 3.89543 5.89543 3 7 3H9C10.1046 3 11 3.89543 11 5V6H5V5Z" stroke="black" stroke-width="1.2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.25 6.5C3.25 5.80964 3.80964 5.25 4.5 5.25H11.5C12.1904 5.25 12.75 5.80964 12.75 6.5V12.5C12.75 13.1904 12.1904 13.75 11.5 13.75H4.5C3.80964 13.75 3.25 13.1904 3.25 12.5V6.5ZM8.75 9.66146C8.90559 9.48517 9 9.25361 9 9C9 8.44772 8.55228 8 8 8C7.44772 8 7 8.44772 7 9C7 9.25361 7.09441 9.48517 7.25 9.66146V11C7.25 11.4142 7.58579 11.75 8 11.75C8.41421 11.75 8.75 11.4142 8.75 11V9.66146Z" fill="black"/>
</svg>
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" clip-path="url(#a)"><path d="M13 13.5v-8L9.5 2h-6a.5.5 0 0 0-.5.5V6"/><path d="M9 2v4h4M8 9.5V13h1a1.75 1.75 0 0 0 0-3.5H8ZM6 13V9.5L4.25 12 2.5 9.5V13"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5.27935 10.9821C5.32063 10.4038 4.9204 9.89049 4.35998 9.80276L3.60081 9.68387C3.37979 9.64945 3.20167 9.48001 3.15225 9.25614L3.01378 8.63511C2.96382 8.41235 3.05233 8.1807 3.23696 8.05125L3.8631 7.61242C4.33337 7.28297 4.47456 6.6369 4.18621 6.13364L3.79467 5.45092C3.68118 5.25261 3.69801 5.00374 3.83757 4.82321L4.22314 4.32436C4.3627 4.14438 4.59621 4.06994 4.81071 4.13772L5.57531 4.37769C6.11944 4.54879 6.70048 4.26159 6.90683 3.71886L7.1811 2.99782C7.26255 2.78395 7.46345 2.64285 7.68772 2.6423L8.31007 2.64063C8.53434 2.64007 8.73579 2.78006 8.81834 2.99337L9.09965 3.72275C9.30821 4.26214 9.88655 4.54712 10.429 4.37714L11.1632 4.14716C11.3772 4.07994 11.6096 4.15382 11.7492 4.3327L12.1374 4.83099" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.27935 10.9821C5.32063 10.4038 4.9204 9.89049 4.35998 9.80276L3.60081 9.68387C3.37979 9.64945 3.20167 9.48001 3.15225 9.25614L3.01378 8.63511C2.96382 8.41235 3.05233 8.1807 3.23696 8.05125L3.8631 7.61242C4.33337 7.28297 4.47456 6.6369 4.18621 6.13364L3.79467 5.45092C3.68118 5.25261 3.69801 5.00374 3.83757 4.82321L4.22314 4.32436C4.3627 4.14438 4.59621 4.06994 4.81071 4.13772L5.57531 4.37769C6.11944 4.54879 6.70048 4.26159 6.90683 3.71886L7.1811 2.99782C7.26255 2.78395 7.46345 2.64285 7.68772 2.6423L8.31007 2.64063C8.53434 2.64007 8.73579 2.78006 8.81834 2.99337L9.09965 3.72275C9.30821 4.26214 9.88655 4.54712 10.429 4.37714L11.1632 4.14716C11.3772 4.07994 11.6096 4.15382 11.7492 4.3327L12.1374 4.83099" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -1,5 +0,0 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5.2345 20.1C5.38772 20.373 5.60794 20.5998 5.87313 20.7577C6.13832 20.9157 6.43919 20.9992 6.74562 21H17.25C17.7141 21 18.1592 20.8104 18.4874 20.4728C18.8156 20.1352 19 19.6774 19 19.2V7.5L14.625 3H6.75C6.28587 3 5.84075 3.18964 5.51256 3.52721C5.18437 3.86477 5 4.32261 5 4.8V6.5" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10 16.8182L8.5 15.3182" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M6 15.8182C7.65685 15.8182 9 14.475 9 12.8182C9 11.1613 7.65685 9.81818 6 9.81818C4.34315 9.81818 3 11.1613 3 12.8182C3 14.475 4.34315 15.8182 6 15.8182Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-text"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.875 1.25C10.0686 1.25 10.2549 1.3249 10.3945 1.45898L13.5195 4.45898C13.6668 4.60041 13.75 4.79582 13.75 5V12.7998C13.75 13.327 13.5316 13.8263 13.1533 14.1895C12.7762 14.5515 12.2709 14.75 11.75 14.75H4.25C3.72915 14.75 3.22383 14.5515 2.84668 14.1895C2.46849 13.8264 2.25 13.3271 2.25 12.7998V3.2002C2.25 2.67294 2.46848 2.17365 2.84668 1.81055C3.22388 1.44843 3.72922 1.25 4.25 1.25H9.875ZM5.73242 9.9707C5.31832 9.97084 4.98242 10.3066 4.98242 10.7207C4.98242 11.1348 5.31832 11.4706 5.73242 11.4707H10.2666C10.6808 11.4707 11.0166 11.1349 11.0166 10.7207C11.0166 10.3065 10.6808 9.9707 10.2666 9.9707H5.73242ZM5.73242 7.75C5.31832 7.75013 4.98242 8.08587 4.98242 8.5C4.98242 8.91413 5.31832 9.24987 5.73242 9.25H8.45312C8.86734 9.25 9.20312 8.91421 9.20312 8.5C9.20312 8.08579 8.86734 7.75 8.45312 7.75H5.73242Z" fill="black"/>
+</svg>
@@ -0,0 +1,6 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.87504 2H4.25001C3.91848 2 3.60055 2.12643 3.36612 2.35148C3.1317 2.57652 3 2.88174 3 3.2V12.8C3 13.1182 3.1317 13.4234 3.36612 13.6485C3.60055 13.8735 3.91848 14 4.25001 14H11.75C12.0816 14 12.3995 13.8735 12.6339 13.6485C12.8683 13.4234 13 13.1182 13 12.8V5L9.87504 2Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 2V4.66666C9 5.02029 9.14048 5.35942 9.39053 5.60948C9.64059 5.85952 9.97972 6 10.3333 6H13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8.4534 8.5H5.73267" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.2672 10.7207H5.73267" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M6 6H10" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M8 6V11" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M5 3H3.5C3.22386 3 3 3.22386 3 3.5V12.5C3 12.7761 3.22386 13 3.5 13H5M11 3H12.5C12.7761 3 13 3.22386 13 3.5V12.5C13 12.7761 12.7761 13 12.5 13H11" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M6 6H10" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M8 6V11" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M5 3H3.5C3.22386 3 3 3.22386 3 3.5V12.5C3 12.7761 3.22386 13 3.5 13H5M11 3H12.5C12.7761 3 13 3.22386 13 3.5V12.5C13 12.7761 12.7761 13 12.5 13H11" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3 3V3.03125M3 3.03125V9M3 3.03125C3 5 5.96875 5 5.96875 5M3 9C3 11 5.96875 11 5.96875 11M3 9V12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<rect x="8" y="3" width="5.5" height="4" rx="1.5" fill="black"/>
-<rect x="8" y="9" width="5.5" height="4" rx="1.5" fill="black"/>
+<path d="M3 2.5V3.5M3 3.5V9M3 3.5C3 5.46875 5.96875 5 5.96875 5M3 9C3 11 5.96875 11 5.96875 11M3 9V12.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12 3H9.5C8.67157 3 8 3.67157 8 4.5V5.5C8 6.32843 8.67157 7 9.5 7H12C12.8284 7 13.5 6.32843 13.5 5.5V4.5C13.5 3.67157 12.8284 3 12 3Z" fill="black"/>
+<path d="M12 9H9.5C8.67157 9 8 9.67157 8 10.5V11.5C8 12.3284 8.67157 13 9.5 13H12C12.8284 13 13.5 12.3284 13.5 11.5V10.5C13.5 9.67157 12.8284 9 12 9Z" fill="black"/>
</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M12.9416 2.99643C13.08 2.79636 12.9568 2.5 12.7352 2.5H3.26475C3.04317 2.5 2.91999 2.79636 3.0584 2.99643L6.04033 7.30646C6.24713 7.60535 6.35981 7.97674 6.35981 8.3596C6.35981 9.18422 6.35981 11.4639 6.35981 12.891C6.35981 13.2285 6.59643 13.5 6.88831 13.5H9.11168C9.40357 13.5 9.64019 13.2285 9.64019 12.891C9.64019 11.4639 9.64019 9.18422 9.64019 8.3596C9.64019 7.97674 9.75289 7.60535 9.95969 7.30646L12.9416 2.99643Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.9416 2.99643C13.08 2.79636 12.9568 2.5 12.7352 2.5H3.26475C3.04317 2.5 2.91999 2.79636 3.0584 2.99643L6.04033 7.30646C6.24713 7.60535 6.35981 7.97674 6.35981 8.3596C6.35981 9.18422 6.35981 11.4639 6.35981 12.891C6.35981 13.2285 6.59643 13.5 6.88831 13.5H9.11168C9.40357 13.5 9.64019 13.2285 9.64019 12.891C9.64019 11.4639 9.64019 9.18422 9.64019 8.3596C9.64019 7.97674 9.75289 7.60535 9.95969 7.30646L12.9416 2.99643Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-flame-icon lucide-flame"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M5.5 9.868c.474 0 .928-.18 1.263-.5.335-.321.523-.756.523-1.21 0-.944-.357-1.369-.715-2.053C5.806 4.64 6.411 3.331 8 2c.357 1.71 1.429 3.353 2.857 4.447C12.286 7.542 13 8.842 13 10.21c0 .63-.13 1.252-.38 1.833a4.781 4.781 0 0 1-1.084 1.554 5.021 5.021 0 0 1-1.623 1.038 5.191 5.191 0 0 1-3.826 0 5.02 5.02 0 0 1-1.623-1.038 4.78 4.78 0 0 1-1.083-1.554A4.615 4.615 0 0 1 3 10.211c0-.79.31-1.57.714-2.053 0 .454.188.889.523 1.21.335.32.79.5 1.263.5Z"/></svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M13.3333 13.3333C13.6869 13.3333 14.026 13.1929 14.2761 12.9428C14.5261 12.6928 14.6666 12.3536 14.6666 12V5.33333C14.6666 4.97971 14.5261 4.64057 14.2761 4.39052C14.026 4.14048 13.6869 4 13.3333 4H8.06659C7.84359 4.00219 7.62362 3.94841 7.42679 3.84359C7.22996 3.73877 7.06256 3.58625 6.93992 3.4L6.39992 2.6C6.27851 2.41565 6.11324 2.26432 5.91892 2.1596C5.7246 2.05488 5.50732 2.00004 5.28659 2H2.66659C2.31296 2 1.97382 2.14048 1.72378 2.39052C1.47373 2.64057 1.33325 2.97971 1.33325 3.33333V12C1.33325 12.3536 1.47373 12.6928 1.72378 12.9428C1.97382 13.1929 2.31296 13.3333 2.66659 13.3333H13.3333Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.8 13C13.1183 13 13.4235 12.8761 13.6486 12.6554C13.8735 12.4349 14 12.1356 14 11.8236V5.94118C14 5.62916 13.8735 5.32992 13.6486 5.10929C13.4235 4.88866 13.1183 4.76471 12.8 4.76471H8.06C7.8593 4.76664 7.66133 4.71919 7.48418 4.6267C7.30703 4.53421 7.15637 4.39964 7.046 4.2353L6.56 3.52941C6.45073 3.36675 6.30199 3.23322 6.1271 3.14082C5.95221 3.04842 5.75666 3.00004 5.558 3H3.2C2.88174 3 2.57651 3.12395 2.35148 3.34458C2.12643 3.56521 2 3.86445 2 4.17647V11.8236C2 12.1356 2.12643 12.4349 2.35148 12.6554C2.57651 12.8761 2.88174 13 3.2 13H12.8Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4.42782 7.2487C4.43495 6.97194 4.65009 6.75 4.91441 6.75H13.5293C13.7935 6.75 14.007 6.97194 13.9998 7.2487C13.9628 8.6885 13.7533 12.75 12.5721 12.75H3.375C4.55631 12.75 4.3907 8.6885 4.42782 7.2487Z" fill="black" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>
-<path d="M5.19598 12.625H3.66515C3.42618 12.625 3.22289 12.4453 3.18626 12.2017L1.94333 3.93602C1.89776 3.63295 2.12496 3.35938 2.42223 3.35938H5.78585C6.11241 3.35938 6.41702 3.52903 6.59618 3.81071L6.94517 4.35938H9.92811C10.4007 4.35938 10.8044 4.71102 10.8836 5.1917L11.1251 6.65624" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.42782 7.2487C4.43495 6.97194 4.65009 6.75 4.91441 6.75H13.5293C13.7935 6.75 14.007 6.97194 13.9998 7.2487C13.9628 8.6885 13.7533 12.75 12.5721 12.75H3.375C4.55631 12.75 4.3907 8.6885 4.42782 7.2487Z" fill="black" stroke="black" stroke-width="1.2" stroke-linejoin="round"/>
+<path d="M5.19598 12.625H3.66515C3.42618 12.625 3.22289 12.4453 3.18626 12.2017L1.94333 3.93602C1.89776 3.63295 2.12496 3.35938 2.42223 3.35938H5.78585C6.11241 3.35938 6.41702 3.52903 6.59618 3.81071L6.94517 4.35938H9.92811C10.4007 4.35938 10.8044 4.71102 10.8836 5.1917L11.1251 6.65624" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M14.0001 13.9999L12.7334 12.7333" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.3333 13.3334C12.4378 13.3334 13.3333 12.4379 13.3333 11.3334C13.3333 10.2288 12.4378 9.33337 11.3333 9.33337C10.2287 9.33337 9.33325 10.2288 9.33325 11.3334C9.33325 12.4379 10.2287 13.3334 11.3333 13.3334Z" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6 13H3.2C2.88174 13 2.57651 12.8761 2.35148 12.6554C2.12643 12.4349 2 12.1356 2 11.8236V4.17647C2 3.86445 2.12643 3.56521 2.35148 3.34458C2.57651 3.12395 2.88174 3 3.2 3H5.558C5.75666 3.00004 5.95221 3.04842 6.1271 3.14082C6.30199 3.23322 6.45073 3.36675 6.56 3.52941L7.046 4.2353C7.15637 4.39964 7.30703 4.53421 7.48418 4.6267C7.66133 4.71919 7.8593 4.76664 8.06 4.76471H12.8C13.1183 4.76471 13.4235 4.88866 13.6486 5.10929C13.8735 5.32992 14 5.62916 14 5.94118V7" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,5 +0,0 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7.70312 4L7.26046 2.97339C7.10239 2.60679 6.74141 2.36933 6.34219 2.36933H2.5C2.22386 2.36933 2 2.59319 2 2.86933V4.375V8" stroke="#11181C" stroke-width="1.25" stroke-linecap="round"/>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-type"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" x2="15" y1="20" y2="20"/><line x1="12" x2="12" y1="4" y2="20"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M3 5V3h10v2M6 13h4M8 3v10"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-a-large-small"><path d="M21 14h-5"/><path d="M16 16v-3.5a2.5 2.5 0 0 1 5 0V16"/><path d="M4.5 13h6"/><path d="m3 16 4.5-9 4.5 9"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M14 9.333h-3.333M10.667 10.667V8.333a1.667 1.667 0 1 1 3.333 0v2.334"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333" d="M3 8.667h4"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="m2 10.667 3-6 3 6"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bold"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M4.27 8h5.626a2.5 2.5 0 1 1 0 5h-5a.625.625 0 0 1-.625-.625v-8.75A.625.625 0 0 1 4.896 3H9.27a2.5 2.5 0 1 1 0 5"/></svg>
@@ -1 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-forward-icon lucide-forward"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10 11.3334L13.3333 8.00002L10 4.66669" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2.66675 12V10.6667C2.66675 9.95942 2.9477 9.28115 3.4478 8.78105C3.94789 8.28095 4.62617 8 5.33341 8H13.3334" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-function-icon lucide-square-function"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3"/><path d="M9 11.2h5.7"/></svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M11.5 4.5H4.5V11.5H11.5V4.5Z" stroke="#FBF1C7"/>
+<path d="M11.5 4.5H4.5V11.5H11.5V4.5Z" stroke="black"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M9.5 6.5H3.5V12.5H9.5V6.5Z" stroke="#FBF1C7"/>
-<path d="M10 8.5L12.5 8.5L12.5 3.5L7.5 3.5L7.5 6" stroke="#FBF1C7"/>
+<path d="M9.5 6.5H3.5V12.5H9.5V6.5Z" stroke="black"/>
+<path d="M10 8.5L12.5 8.5L12.5 3.5L7.5 3.5L7.5 6" stroke="black"/>
</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-git-branch"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M5 3v7M11.5 6a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM4.5 13a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM11 6a5 5 0 0 1-5 5"/></svg>
@@ -0,0 +1,7 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.5 14C5.32843 14 6 13.3284 6 12.5C6 11.6716 5.32843 11 4.5 11C3.67157 11 3 11.6716 3 12.5C3 13.3284 3.67157 14 4.5 14Z" stroke="black" stroke-width="1.2"/>
+<path d="M4.5 11V5.5" stroke="black" stroke-width="1.2"/>
+<path d="M4.5 10C4.5 10 4.875 8 6.5 8C7.29195 8 9.00787 8 9.87553 8C10.773 8 11.5 7.32843 11.5 6.5V5.5" stroke="black" stroke-width="1.2"/>
+<path d="M4.5 6C5.32843 6 6 5.32843 6 4.5C6 3.67157 5.32843 3 4.5 3C3.67157 3 3 3.67157 3 4.5C3 5.32843 3.67157 6 4.5 6Z" stroke="black" stroke-width="1.2"/>
+<path d="M11.5 6C12.3284 6 13 5.32843 13 4.5C13 3.67157 12.3284 3 11.5 3C10.6716 3 10 3.67157 10 4.5C10 5.32843 10.6716 6 11.5 6Z" stroke="black" stroke-width="1.2"/>
+</svg>
@@ -1,7 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<circle cx="5" cy="12" r="1.25" stroke="black" stroke-width="1.5"/>
-<path d="M5 11V5" stroke="black" stroke-width="1.5"/>
-<path d="M5 10C5 10 5.5 8 7 8C7.73103 8 8.69957 8 9.50049 8C10.3289 8 11 7.32843 11 6.5V5" stroke="black" stroke-width="1.5"/>
-<circle cx="5" cy="4" r="1.25" stroke="black" stroke-width="1.5"/>
-<circle cx="11" cy="4" r="1.25" stroke="black" stroke-width="1.5"/>
-</svg>
@@ -1,40 +0,0 @@
-<svg width="400" height="120" xmlns="http://www.w3.org/2000/svg">
- <defs>
- <pattern id="tilePattern" width="124" height="24" patternUnits="userSpaceOnUse">
- <svg width="124" height="24" viewBox="0 0 124 24" fill="none" xmlns="http://www.w3.org/2000/svg">
- <g opacity="0.2">
- <path d="M16.666 12.0013L11.9993 16.668L7.33268 12.0013" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M12 7.33464L12 16.668" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
- <path fill-rule="evenodd" clip-rule="evenodd" d="M29 8.33464C29.3682 8.33464 29.6667 8.03616 29.6667 7.66797C29.6667 7.29978 29.3682 7.0013 29 7.0013C28.6318 7.0013 28.3333 7.29978 28.3333 7.66797C28.3333 8.03616 28.6318 8.33464 29 8.33464ZM29 9.66797C30.1046 9.66797 31 8.77254 31 7.66797C31 6.5634 30.1046 5.66797 29 5.66797C27.8954 5.66797 27 6.5634 27 7.66797C27 8.77254 27.8954 9.66797 29 9.66797Z" fill="white"/>
- <path fill-rule="evenodd" clip-rule="evenodd" d="M35 8.33464C35.3682 8.33464 35.6667 8.03616 35.6667 7.66797C35.6667 7.29978 35.3682 7.0013 35 7.0013C34.6318 7.0013 34.3333 7.29978 34.3333 7.66797C34.3333 8.03616 34.6318 8.33464 35 8.33464ZM35 9.66797C36.1046 9.66797 37 8.77254 37 7.66797C37 6.5634 36.1046 5.66797 35 5.66797C33.8954 5.66797 33 6.5634 33 7.66797C33 8.77254 33.8954 9.66797 35 9.66797Z" fill="white"/>
- <path fill-rule="evenodd" clip-rule="evenodd" d="M29 16.9987C29.3682 16.9987 29.6667 16.7002 29.6667 16.332C29.6667 15.9638 29.3682 15.6654 29 15.6654C28.6318 15.6654 28.3333 15.9638 28.3333 16.332C28.3333 16.7002 28.6318 16.9987 29 16.9987ZM29 18.332C30.1046 18.332 31 17.4366 31 16.332C31 15.2275 30.1046 14.332 29 14.332C27.8954 14.332 27 15.2275 27 16.332C27 17.4366 27.8954 18.332 29 18.332Z" fill="white"/>
- <path fill-rule="evenodd" clip-rule="evenodd" d="M28.334 9H29.6673V11.4615C30.2383 11.1443 31.0005 11 32.0007 11H33.6675C34.0356 11 34.334 10.7017 34.334 10.3333V9H35.6673V10.3333C35.6673 11.4378 34.7723 12.3333 33.6675 12.3333H32.0007C30.8614 12.3333 30.3692 12.5484 30.1298 12.7549C29.9016 12.9516 29.7857 13.2347 29.6673 13.742V15H28.334V9Z" fill="white"/>
- <path d="M48.668 8.66406H55.3346V15.3307" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M48.668 15.3307L55.3346 8.66406" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M76.5871 9.40624C76.8514 9.14195 77 8.78346 77 8.40965C77 8.03583 76.8516 7.67731 76.5873 7.41295C76.323 7.14859 75.9645 7.00005 75.5907 7C75.2169 6.99995 74.8584 7.14841 74.594 7.4127L67.921 14.0874C67.8049 14.2031 67.719 14.3456 67.671 14.5024L67.0105 16.6784C66.9975 16.7217 66.9966 16.7676 67.0076 16.8113C67.0187 16.8551 67.0414 16.895 67.0734 16.9269C67.1053 16.9588 67.1453 16.9815 67.1891 16.9925C67.2328 17.0035 67.2788 17.0024 67.322 16.9894L69.4985 16.3294C69.6551 16.2818 69.7976 16.1964 69.9135 16.0809L76.5871 9.40624Z" stroke="white" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M74 8L76 10" stroke="white" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M70.3877 7.53516V6.53516" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M73.5693 16.6992V17.6992" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M66.3877 10.5352H67.3877" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M77.5693 13.6992H76.5693" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M68.3877 8.53516L67.3877 7.53516" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M75.5693 15.6992L76.5693 16.6992" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M87.334 11.9987L92.0007 7.33203L96.6673 11.9987" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M92 16.6654V7.33203" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M117 12C117 10.6739 116.473 9.40215 115.536 8.46447C114.598 7.52678 113.326 7 112 7C110.602 7.00526 109.261 7.55068 108.256 8.52222L107 9.77778" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M107 7V9.77778H109.778" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M107 12C107 13.3261 107.527 14.5979 108.464 15.5355C109.402 16.4732 110.674 17 112 17C113.398 16.9947 114.739 16.4493 115.744 15.4778L117 14.2222" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M114.223 14.2188H117V16.9965" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
- </g>
- </svg>
- </pattern>
- <linearGradient id="fade" y2="1" x2="0">
- <stop offset="0" stop-color="white" stop-opacity=".52"/>
- <stop offset="1" stop-color="white" stop-opacity="0"/>
- </linearGradient>
- <mask id="fadeMask" maskContentUnits="objectBoundingBox">
- <rect width="1" height="1" fill="url(#fade)"/>
- </mask>
- </defs>
- <rect width="100%" height="100%" fill="url(#tilePattern)" mask="url(#fadeMask)"/>
-</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-github"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M9.849 14.288v-2.515a3.018 3.018 0 0 0-.629-2.201c1.887 0 3.773-1.258 3.773-3.459.05-.786-.17-1.559-.629-2.2a4.65 4.65 0 0 0 0-2.201s-.629 0-1.886.943a13.533 13.533 0 0 0-5.03 0c-1.259-.943-1.887-.943-1.887-.943a4.35 4.35 0 0 0 0 2.2 3.398 3.398 0 0 0-.63 2.201c0 2.201 1.887 3.459 3.774 3.459a2.965 2.965 0 0 0-.63 2.2v2.516"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M6.076 11.773c-2.836 1.258-3.144-1.258-4.402-1.258"/></svg>
@@ -1,12 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_2226_61)">
-<path d="M7.99992 14.6667C11.6818 14.6667 14.6666 11.6819 14.6666 8C14.6666 4.3181 11.6818 1.33333 7.99992 1.33333C4.31802 1.33333 1.33325 4.3181 1.33325 8C1.33325 11.6819 4.31802 14.6667 7.99992 14.6667Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7.99992 1.33333C6.28807 3.13076 5.33325 5.51782 5.33325 8C5.33325 10.4822 6.28807 12.8692 7.99992 14.6667C9.71176 12.8692 10.6666 10.4822 10.6666 8C10.6666 5.51782 9.71176 3.13076 7.99992 1.33333Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M1.33325 8H14.6666" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</g>
-<defs>
-<clipPath id="clip0_2226_61">
-<rect width="16" height="16" fill="white"/>
-</clipPath>
-</defs>
-</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hammer-icon lucide-hammer"><path d="m15 12-8.373 8.373a1 1 0 1 1-3-3L12 9"/><path d="m18 15 4-4"/><path d="m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172V7l-2.26-2.26a6 6 0 0 0-4.202-1.756L9 2.96l.92.82A6.18 6.18 0 0 1 12 8.4V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5"/></svg>
@@ -1,6 +1 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<line x1="10.2795" y1="2.63847" x2="7.74786" y2="11.0142" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
-<line x1="6.26625" y1="2.99597" x2="3.73461" y2="11.3717" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
-<line x1="3.15979" y1="5.3799" x2="11.9098" y2="5.3799" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
-<line x1="2.09833" y1="8.62407" x2="10.8483" y2="8.62407" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-width="1.2" d="m11.748 3.015-2.893 9.573M7.161 3.424l-2.893 9.572M3.611 6.148h10M2.398 9.856h10"/></svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M2 8C2 9.18669 2.35189 10.3467 3.01118 11.3334C3.67047 12.3201 4.60754 13.0892 5.7039 13.5433C6.80026 13.9974 8.00666 14.1162 9.17054 13.8847C10.3344 13.6532 11.4035 13.0818 12.2426 12.2426C13.0818 11.4035 13.6532 10.3344 13.8847 9.17054C14.1162 8.00666 13.9974 6.80026 13.5433 5.7039C13.0892 4.60754 12.3201 3.67047 11.3334 3.01118C10.3467 2.35189 9.18669 2 8 2C6.32263 2.00631 4.71265 2.66082 3.50667 3.82667L2 5.33333" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M2 2V5.33333H5.33333" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M8 5V8.5L10 9.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2 8C2 9.18669 2.35189 10.3467 3.01118 11.3334C3.67047 12.3201 4.60754 13.0892 5.7039 13.5433C6.80026 13.9974 8.00666 14.1162 9.17054 13.8847C10.3344 13.6532 11.4035 13.0818 12.2426 12.2426C13.0818 11.4035 13.6532 10.3344 13.8847 9.17054C14.1162 8.00666 13.9974 6.80026 13.5433 5.7039C13.0892 4.60754 12.3201 3.67047 11.3334 3.01118C10.3467 2.35189 9.18669 2 8 2C6.32263 2.00631 4.71265 2.66082 3.50667 3.82667L2 5.33333" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2 2V5.33333H5.33333" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 5V8.5L10 9.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-image-icon lucide-image"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M11.889 3H4.11C3.497 3 3 3.497 3 4.111v7.778C3 12.503 3.497 13 4.111 13h7.778c.614 0 1.111-.498 1.111-1.111V4.11C13 3.497 12.502 3 11.889 3Z"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M6.333 7.444a1.111 1.111 0 1 0 0-2.222 1.111 1.111 0 0 0 0 2.222ZM13 9.667l-1.714-1.715a1.11 1.11 0 0 0-1.571 0L4.667 13"/></svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z" stroke="black" stroke-width="1.5"/>
-<path d="M7 11H8M8 11H9M8 11V8.1C8 8.04477 7.95523 8 7.9 8H7" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z" stroke="black" stroke-width="1.2"/>
+<path d="M7 11H8M8 11H9M8 11V8.1C8 8.04477 7.95523 8 7.9 8H7" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
<path d="M8 6.5C8.55228 6.5 9 6.05228 9 5.5C9 4.94772 8.55228 4.5 8 4.5C7.44772 4.5 7 4.94772 7 5.5C7 6.05228 7.44772 6.5 8 6.5Z" fill="black"/>
</svg>
@@ -1,5 +0,0 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<circle cx="3" cy="9" r="1" fill="black"/>
-<circle cx="3" cy="5" r="1" fill="black"/>
-<path d="M7 3H10M13 3H10M10 3C10 3 10 11 10 11.5" stroke="black" stroke-width="1.25"/>
-</svg>
@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5.78125 3C3.90625 3 3.90625 4.5 3.90625 5.5C3.90625 6.5 3.40625 7.50106 2.40625 8C3.40625 8.50106 3.90625 9.5 3.90625 10.5C3.90625 11.5 3.90625 13 5.78125 13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.2422 3C12.1172 3 12.1172 4.5 12.1172 5.5C12.1172 6.5 12.6172 7.50106 13.6172 8C12.6172 8.50106 12.1172 9.5 12.1172 10.5C12.1172 11.5 12.1172 13 10.2422 13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-keyboard"><path d="M10 8h.01"/><path d="M12 12h.01"/><path d="M14 8h.01"/><path d="M16 12h.01"/><path d="M18 8h.01"/><path d="M6 8h.01"/><path d="M7 16h10"/><path d="M8 12h.01"/><rect width="20" height="16" x="2" y="4" rx="2"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M6.75 5.5h.007M8 8h.007M9.25 5.5h.007M10.5 8h.007M11.75 5.5h.007M4.25 5.5h.007M4.875 10.5h6.25M5.5 8h.007M13 3H3c-.69 0-1.25.56-1.25 1.25v7.5c0 .69.56 1.25 1.25 1.25h10c.69 0 1.25-.56 1.25-1.25v-7.5C14.25 3.56 13.69 3 13 3Z"/></svg>
@@ -1,3 +1,3 @@
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3 3L5.5 5.5M8 8L5.5 5.5M5.5 5.5L3 8M5.5 5.5L8 3" stroke="black" stroke-width="1.5"/>
+<path d="M3 3L5.5 5.5M8 8L5.5 5.5M5.5 5.5L3 8M5.5 5.5L8 3" stroke="black" stroke-width="1.2"/>
</svg>
@@ -1,5 +0,0 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M20 14H4C3.44772 14 3 14.4477 3 15V20C3 20.5523 3.44772 21 4 21H20C20.5523 21 21 20.5523 21 20V15C21 14.4477 20.5523 14 20 14Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11 3H4C3.44772 3 3 3.44772 3 4V9C3 9.55228 3.44772 10 4 10H11C11.5523 10 12 9.55228 12 9V4C12 3.44772 11.5523 3 11 3Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M20 3H17C16.4477 3 16 3.44772 16 4V9C16 9.55228 16.4477 10 17 10H20C20.5523 10 21 9.55228 21 9V4C21 3.44772 20.5523 3 20 3Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1 +1,6 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-library"><path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.6667 4L13.3334 13.3333" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 4V13.3333" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.33325 5.33331V13.3333" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2.66675 2.66669V13.3334" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,3 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M10.1331 11.3776C10.2754 10.6665 10.1331 9.78593 11.1998 8.53327C11.82 7.80489 12.2664 6.96894 12.2664 6.04456C12.2664 4.91305 11.8169 3.82788 11.0168 3.02778C10.2167 2.22769 9.13152 1.7782 8.00001 1.7782C6.8685 1.7782 5.78334 2.22769 4.98324 3.02778C4.18314 3.82788 3.73364 4.91305 3.73364 6.04456C3.73364 6.75562 3.87586 7.6089 4.80024 8.53327C5.86683 9.80679 5.72462 10.6665 5.86683 11.3776M10.1331 11.3776V12.8821C10.1331 13.622 9.53341 14.2218 8.79353 14.2218H7.2065C6.46662 14.2218 5.86683 13.622 5.86683 12.8821V11.3776M10.1331 11.3776H5.86683" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1,6 +1 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4 13.6667H12" stroke="#B3B3B3" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M4 2.33333H12" stroke="#B3B3B3" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5 11L8 5L11 11" stroke="#B3B3B3" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M6 9H10" stroke="#B3B3B3" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M4 13.667h8M4 2.333h8M5 11l3-6 3 6M6 9h4"/></svg>
@@ -1,3 +0,0 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-collapse-icon lucide-list-collapse"><path d="m3 10 2.5-2.5L3 5"/><path d="m3 19 2.5-2.5L3 14"/><path d="M10 6h11"/><path d="M10 12h11"/><path d="M10 18h11"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M2.857 6.857 4.286 5.43 2.857 4M2.857 12l1.429-1.429-1.429-1.428M6.857 4.571h6.286M6.857 8h6.286M6.857 11.428h6.286"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-todo-icon lucide-list-todo"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M5.333 3.333H2.667A.667.667 0 0 0 2 4v2.667c0 .368.298.666.667.666h2.666A.667.667 0 0 0 6 6.667V4a.667.667 0 0 0-.667-.667ZM2 11.333l1.333 1.334L6 10M8.667 4H14M8.667 8H14M8.667 12H14"/></svg>
@@ -1,7 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M13.5 8H9.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M13.5 4L6.5 4" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M13.5 12H9.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M3 3.5V6.33333C3 7.25 3.72 8 4.6 8H7" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M3 6V10.5C3 11.325 3.72 12 4.6 12H7" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.5 8H9.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.5 4L6.5 4" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.5 12H9.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3 3.5V6.33333C3 7.25 3.72 8 4.6 8H7" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3 6V10.5C3 11.325 3.72 12 4.6 12H7" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,7 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8.33333 8H3" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11.6667 4H3" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11.6667 12H3" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M13.6667 6.66663L11 9.33329" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11 6.66663L13.6667 9.33329" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8.33333 8H3" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.6667 4H3" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.6667 12H3" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.6667 6.66663L11 9.33329" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11 6.66663L13.6667 9.33329" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-loader-circle-icon lucide-loader-circle"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M13 8a5 5 0 1 1-3.455-4.755"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-location-edit-icon lucide-location-edit"><path d="M17.97 9.304A8 8 0 0 0 2 10c0 4.69 4.887 9.562 7.022 11.468"/><path d="M21.378 16.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z"/><circle cx="10" cy="10" r="3"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M12 6.502a4.904 4.904 0 0 0-1.686-3.28 5.059 5.059 0 0 0-3.522-1.22A5.045 5.045 0 0 0 3.39 3.52 4.889 4.889 0 0 0 2 6.93c0 2.89 3.06 5.893 4.397 7.068M13.655 11.013a1.18 1.18 0 0 0-1.67-1.67l-2.227 2.23a1.112 1.112 0 0 0-.282.475l-.465 1.594a.278.278 0 0 0 .345.345l1.594-.465a1.11 1.11 0 0 0 .475-.281l2.23-2.228Z"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M7 8.998a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/></svg>
@@ -1,6 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5 5C5 3.89543 5.89543 3 7 3H9C10.1046 3 11 3.89543 11 5V6H5V5Z" stroke="black" stroke-width="1.5"/>
-<path d="M8 9V11" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M5 5C5 3.89543 5.89543 3 7 3H9C10.1046 3 11 3.89543 11 5V6H5V5Z" stroke="black" stroke-width="1.2"/>
+<path d="M8 9V11" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
<circle cx="8" cy="9" r="1" fill="black"/>
-<rect x="3.75" y="5.75" width="8.5" height="7.5" rx="1.25" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>
+<rect x="3.75" y="5.75" width="8.5" height="7.5" rx="1.25" stroke="black" stroke-width="1.2" stroke-linejoin="round"/>
</svg>
@@ -1,3 +0,0 @@
-<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M9 6C7.34315 6 6 7.34315 6 9V75H0V9C0 4.02944 4.02944 0 9 0H89.3787C93.3878 0 95.3955 4.84715 92.5607 7.68198L43.0551 57.1875H57V51H63V58.6875C63 61.1728 60.9853 63.1875 58.5 63.1875H37.0551L26.7426 73.5H73.5V36H79.5V73.5C79.5 76.8137 76.8137 79.5 73.5 79.5H20.7426L10.2426 90H87C88.6569 90 90 88.6569 90 87V21H96V87C96 91.9706 91.9706 96 87 96H6.62132C2.61224 96 0.604504 91.1529 3.43934 88.318L52.7574 39H39V45H33V37.5C33 35.0147 35.0147 33 37.5 33H58.7574L69.2574 22.5H22.5V60H16.5V22.5C16.5 19.1863 19.1863 16.5 22.5 16.5H75.2574L85.7574 6H9Z" fill="white"/>
-</svg>
@@ -1,12 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M6 3L7 4" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M9 4L10 3" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M6.002 6V5.51658C5.98992 5.32067 6.03266 5.12502 6.12762 4.94143C6.22259 4.75784 6.36781 4.59012 6.55453 4.44839C6.74125 4.30666 6.9656 4.19386 7.21403 4.1168C7.46246 4.03973 7.72983 4 8 4C8.27017 4 8.53754 4.03973 8.78597 4.1168C9.0344 4.19386 9.25875 4.30666 9.44547 4.44839C9.63219 4.59012 9.77741 4.75784 9.87238 4.94143C9.96734 5.12502 10.0101 5.32067 9.998 5.51658V6" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M8 13C6.35 13 5 11.5462 5 9.76923V8.15385C5 7.58261 5.21071 7.03477 5.58579 6.63085C5.96086 6.22692 6.46957 6 7 6H9C9.53043 6 10.0391 6.22692 10.4142 6.63085C10.7893 7.03477 11 7.58261 11 8.15385V9.76923C11 11.5462 9.65 13 8 13Z" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5 6.16663C3.90652 6.06663 3 5.21663 3 4.16663" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5 9H3" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M3 13C3 11.95 3.89474 11.05 5 11" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M13 4C13 5.05 12.0857 5.9 11 6" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M13 9H11" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11 11C12.1053 11.05 13 11.95 13 13" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1,4 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3.84265 10.7778C4.39206 11.6001 5.17295 12.241 6.08658 12.6194C7.00021 12.9978 8.00555 13.0969 8.97545 12.9039C9.94535 12.711 10.8363 12.2348 11.5355 11.5355C12.2348 10.8363 12.711 9.94535 12.9039 8.97545C13.0969 8.00555 12.9978 7.00021 12.6194 6.08658C12.241 5.17295 11.6001 4.39206 10.7778 3.84265C9.9556 3.29324 8.9889 3 8 3C6.60219 3.00526 5.26054 3.55068 4.25556 4.52222L3 5.77778" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M3 3V6H6" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1,4 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8 13C10.7614 13 13 10.7614 13 8C13 5.23858 10.7614 3 8 3C5.23858 3 3 5.23858 3 8C3 10.7614 5.23858 13 8 13Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5 5L11 11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1,3 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M13 13L10.4138 10.4138M3 7.31034C3 4.92981 4.92981 3 7.31034 3C9.6909 3 11.6207 4.92981 11.6207 7.31034C11.6207 9.6909 9.6909 11.6207 7.31034 11.6207C4.92981 11.6207 3 9.6909 3 7.31034Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13 13L10.4138 10.4138ZM3 7.31034C3 4.92981 4.92981 3 7.31034 3C9.6909 3 11.6207 4.92981 11.6207 7.31034C11.6207 9.6909 9.6909 11.6207 7.31034 11.6207C4.92981 11.6207 3 9.6909 3 7.31034Z" fill="black" fill-opacity="0.15"/>
+<path d="M13 13L10.4138 10.4138M3 7.31034C3 4.92981 4.92981 3 7.31034 3C9.6909 3 11.6207 4.92981 11.6207 7.31034C11.6207 9.6909 9.6909 11.6207 7.31034 11.6207C4.92981 11.6207 3 9.6909 3 7.31034Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mail-open"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></svg>
@@ -1 +1,6 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize-2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10 3H13V6" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6 13H3V10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13 3L9.5 6.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3 13L6.5 9.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-menu"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M2.667 8h10.666M2.667 4h10.666M2.667 12h10.666"/></svg>
@@ -1,5 +1,3 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4 12H16" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M4 6H20" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M4 18H12" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2.66699 8H10.667M2.66699 4H13.333M2.66699 12H7.99999" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13.333 10H8M13.333 6H2.66701" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,6 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5.26659 13.3333C6.53897 13.986 8.00264 14.1628 9.39384 13.8319C10.785 13.5009 12.0123 12.6839 12.8544 11.5281C13.6966 10.3724 14.0982 8.95381 13.987 7.52811C13.8758 6.10241 13.259 4.76332 12.2478 3.75213C11.2366 2.74095 9.89751 2.12417 8.47181 2.01295C7.04611 1.90173 5.62757 2.30337 4.4718 3.1455C3.31603 3.98764 2.49905 5.21488 2.16807 6.60608C1.83709 7.99728 2.01388 9.46095 2.66659 10.7333L1.33325 14.6667L5.26659 13.3333Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5.33325 8H5.33992" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M8 8H8.00667" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10.6667 8H10.6734" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1,3 +1,5 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1,3 +1,8 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-microscope"><path d="M6 18h8"/><path d="M3 22h18"/><path d="M14 22a7 7 0 1 0 0-14h-1"/><path d="M9 14h2"/><path d="M9 12a2 2 0 0 1-2-2V6h6v4a2 2 0 0 1-2 2Z"/><path d="M12 6V3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3"/></svg>
@@ -1 +1,6 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minimize-2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" x2="21" y1="10" y2="3"/><line x1="3" x2="10" y1="21" y2="14"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.5 9.5H6.5V12.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.5 6.5H9.5V3.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.5 6.5L13 3" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3 13L6.5 9.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" clip-path="url(#a)"><path d="M6 8h4M6 10.5h4M3 3h10v9.565c0 .38-.158.746-.44 1.015-.28.269-.662.42-1.06.42h-7c-.398 0-.78-.151-1.06-.42A1.404 1.404 0 0 1 3 12.565V3ZM6.5 1v4M9.5 1v4"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
@@ -1,3 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M5.35606 1.005H1.62545C1.28002 1.005 1 1.28502 1 1.63044C1 1.97587 1.28002 2.25589 1.62545 2.25589L5.35606 2.25589C5.62311 2.25589 5.8607 2.42545 5.94752 2.67799L9.75029 13.7387C10.0108 14.4963 10.7235 15.005 11.5247 15.005H14.3746C14.72 15.005 15 14.725 15 14.3796C15 14.0341 14.72 13.7541 14.3746 13.7541H11.5247C11.2576 13.7541 11.02 13.5845 10.9332 13.332L7.13046 2.27128C6.86998 1.51366 6.15721 1.005 5.35606 1.005ZM14.3745 1.005H9.75125C9.40582 1.005 9.1258 1.28502 9.1258 1.63044C9.1258 1.97587 9.40582 2.25589 9.75125 2.25589L14.3745 2.25589C14.72 2.25589 15 1.97587 15 1.63044C15 1.28502 14.72 1.005 14.3745 1.005Z" fill="black"/>
+<path d="M3 3H6.33333L9.66667 13H13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.11108 3H13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-left"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-right"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>
@@ -1,3 +1,4 @@
-<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="m12 6.668 2-2L11.332 2l-2 2M12 6.668l-6.668 6.664H2.668v-2.664L9.332 4M12 6.668 9.332 4" stroke="black" stroke-width="1" stroke-linejoin="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.5871 5.40582C12.8514 5.14152 13 4.78304 13 4.40922C13 4.03541 12.8516 3.67688 12.5873 3.41252C12.323 3.14816 11.9645 2.99962 11.5907 2.99957C11.2169 2.99953 10.8584 3.14798 10.594 3.41227L3.92098 10.0869C3.80488 10.2027 3.71903 10.3452 3.67097 10.5019L3.01047 12.678C2.99754 12.7212 2.99657 12.7672 3.00764 12.8109C3.01872 12.8547 3.04143 12.8946 3.07337 12.9265C3.1053 12.9584 3.14528 12.981 3.18905 12.992C3.23282 13.003 3.27875 13.002 3.32197 12.989L5.49849 12.329C5.65508 12.2813 5.79758 12.196 5.91349 12.0805L12.5871 5.40582Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 5L11 7" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -0,0 +1,6 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.5 5.50621L10.5941 3.41227C10.8585 3.14798 11.217 2.99953 11.5908 2.99957C11.9646 2.99962 12.3231 3.14816 12.5874 3.41252C12.8517 3.67688 13.0001 4.03541 13.0001 4.40922C13.0001 4.78304 12.8515 5.14152 12.5872 5.40582L10.493 7.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.50789 8.5L3.92098 10.0869C3.80488 10.2027 3.71903 10.3452 3.67097 10.5019L3.01047 12.678C2.99754 12.7212 2.99657 12.7672 3.00764 12.8109C3.01872 12.8547 3.04143 12.8946 3.07337 12.9265C3.1053 12.9584 3.14528 12.981 3.18905 12.992C3.23282 13.003 3.27875 13.002 3.32197 12.989L5.49849 12.329C5.65508 12.2813 5.79758 12.196 5.91349 12.0805L7.49184 10.5019" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 5L11 7" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3 3L13 13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,4 +1 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M12.6666 14V12.6667C12.6666 11.9594 12.3856 11.2811 11.8855 10.781C11.3854 10.281 10.7072 10 9.99992 10H5.99992C5.29267 10 4.6144 10.281 4.1143 10.781C3.6142 11.2811 3.33325 11.9594 3.33325 12.6667V14" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7.99992 7.33333C9.47268 7.33333 10.6666 6.13943 10.6666 4.66667C10.6666 3.19391 9.47268 2 7.99992 2C6.52716 2 5.33325 3.19391 5.33325 4.66667C5.33325 6.13943 6.52716 7.33333 7.99992 7.33333Z" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M12.667 14v-1.333A2.667 2.667 0 0 0 10 10H6a2.667 2.667 0 0 0-2.667 2.667V14M8 7.333A2.667 2.667 0 1 0 8 2a2.667 2.667 0 0 0 0 5.333Z"/></svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-phone-incoming"><polyline points="16 2 16 8 22 8"/><line x1="22" x2="16" y1="2" y2="8"/><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8 10V13" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
@@ -1,3 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M5 4L12 8L5 12V4Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1,8 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4 12C2.35977 11.85 1 10.575 1 9" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M1.00875 15.2C1.00875 13.625 0.683456 12.275 4.00001 12.2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7 9C7 10.575 5.62857 11.85 4 12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M4 12.2C6.98117 12.2 7 13.625 7 15.2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<rect x="2.5" y="9" width="3" height="6" rx="1.5" fill="black"/>
-<path d="M9 10L13 8L4 3V7.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1,3 +1,3 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5 4L10 7L5 10V4Z" fill="black" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5 4L12 8L5 12V4Z" fill="black" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4 3L13 8L4 13V3Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 4L12 8L5 12V4Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3.33325 8H12.6666" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M8 3.33333V12.6667" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.33325 8H12.6666" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 3.33333V12.6667" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pocket-knife"><path d="M3 2v1c0 1 2 1 2 2S3 6 3 7s2 1 2 2-2 1-2 2 2 1 2 2"/><path d="M18 6h.01"/><path d="M6 18h.01"/><path d="M20.83 8.83a4 4 0 0 0-5.66-5.66l-12 12a4 4 0 1 0 5.66 5.66Z"/><path d="M18 11.66V22a4 4 0 0 0 4-4V6"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-power-icon lucide-power"><path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8 2v5M11.536 4a5.368 5.368 0 0 1 1.367 2.696 5.54 5.54 0 0 1-.28 3.042 5.223 5.223 0 0 1-1.836 2.367A4.82 4.82 0 0 1 8.016 13a4.817 4.817 0 0 1-2.777-.877 5.22 5.22 0 0 1-1.85-2.354 5.54 5.54 0 0 1-.298-3.041 5.371 5.371 0 0 1 1.35-2.705"/></svg>
@@ -1,3 +1 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M3.74393 2.00204C3.41963 1.97524 3.13502 2.37572 3.10823 2.70001C3.08143 3.0243 3.32558 3.47321 3.64986 3.50001C7.99878 3.85934 11.1406 7.00122 11.5 11.3501C11.5267 11.6744 11.9756 12.0269 12.3 12C12.6243 11.9733 13.0247 11.5804 12.998 11.2561C12.5912 6.33295 8.66704 2.40882 3.74393 2.00204ZM2.9 6.00001C2.96411 5.68099 3.33084 5.29361 3.64986 5.35772C6.66377 5.96341 9.03654 8.33618 9.64223 11.3501C9.70634 11.6691 9.319 12.0359 8.99999 12.1C8.68097 12.1641 8.06411 11.819 7.99999 11.5C7.48788 8.95167 6.0483 7.51213 3.49999 7.00001C3.18097 6.9359 2.8359 6.31902 2.9 6.00001ZM2 9.20001C2.0641 8.88099 2.38635 8.65788 2.70537 8.722C4.50255 9.08317 5.91684 10.4975 6.27801 12.2946C6.34212 12.6137 6.13547 12.9242 5.81646 12.9883C5.49744 13.0525 4.86411 12.819 4.8 12.5C4.53239 11.1683 3.83158 10.4676 2.5 10.2C2.18098 10.1359 1.93588 9.51902 2 9.20001Z" fill="black"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M4 6.375A5.625 5.625 0 0 1 9.625 12M4 10a2 2 0 0 1 2 2M4 3a9 9 0 0 1 9 9"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-git-pull-request-arrow"><circle cx="5" cy="6" r="3"/><path d="M5 9v12"/><circle cx="19" cy="18" r="3"/><path d="m15 9-3-3 3-3"/><path d="M12 6h5a2 2 0 0 1 2 2v7"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M4 6a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM4 6v7M12 13a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM10 6.5l-2-2 2-2"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8.5 4.389h2.357c.303 0 .594.117.808.325.215.209.335.491.335.786V10"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-text-quote"><path d="M17 6H3"/><path d="M21 12H8"/><path d="M21 18H8"/><path d="M3 12v6"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M11.333 4H2M14 8H5.333M12 12H5M2 8v4"/></svg>
@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.8889 2H4.11111C3.49746 2 3 2.59695 3 3.33333V12.6667C3 13.403 3.49746 14 4.11111 14H11.8889C12.5025 14 13 13.403 13 12.6667V3.33333C13 2.59695 12.5025 2 11.8889 2Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 6H6" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10 10H6" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,5 +1 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7 21V12M7 12H3M7 12H11" stroke="black" stroke-width="2" stroke-linecap="round"/>
-<path d="M21 19L16 19L16 14" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7.99987 5.07027L7.49915 4.20467L7.99987 5.07027ZM6.04652 5.25026C5.63245 5.61573 5.59305 6.24766 5.95851 6.66173C6.32398 7.0758 6.95592 7.1152 7.36999 6.74974L6.04652 5.25026ZM11.9999 5C15.8659 5 18.9999 8.13401 18.9999 12H20.9999C20.9999 7.02944 16.9705 3 11.9999 3V5ZM18.9999 12C18.9999 14.2101 17.9768 16.1806 16.3744 17.4651L17.6254 19.0256C19.6809 17.3779 20.9999 14.8426 20.9999 12H18.9999ZM8.5006 5.93588C9.5292 5.34086 10.7232 5 11.9999 5V3C10.3623 3 8.82395 3.4383 7.49915 4.20467L8.5006 5.93588ZM7.36999 6.74974C7.71803 6.44255 8.09667 6.16954 8.5006 5.93588L7.49915 4.20467C6.9797 4.50515 6.49329 4.85593 6.04652 5.25026L7.36999 6.74974Z" fill="black"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-width="1.2" d="M4.667 14V8m0 0H2m2.667 0h2.666"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M14 12.667h-3.333V9.333"/><path fill="#000" d="M4.03 3.5a.667.667 0 0 0 .883 1l-.882-1ZM8 3.333A4.667 4.667 0 0 1 12.666 8H14a6 6 0 0 0-6-6v1.333ZM12.666 8a4.656 4.656 0 0 1-1.75 3.643l.834 1.04A5.99 5.99 0 0 0 14 8h-1.334ZM5.667 3.957A4.642 4.642 0 0 1 8 3.333V2a5.976 5.976 0 0 0-3 .803l.667 1.154Zm-.754.543c.232-.205.485-.387.754-.543l-.668-1.154c-.346.2-.67.434-.968.697l.882 1Z"/></svg>
@@ -1,4 +1,4 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<circle cx="4" cy="11" r="1" fill="#787D87"/>
-<path d="M9 2.5V5M9 5V7.5M9 5H11.5M9 5H6.5M9 5L10.6667 3.33333M9 5L7.33333 6.6667M9 5L10.6667 6.6667M9 5L7.33333 3.33333" stroke="#787D87" stroke-width="1.25" stroke-linecap="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.57132 13.7143C5.20251 13.7143 5.71418 13.2026 5.71418 12.5714C5.71418 11.9403 5.20251 11.4286 4.57132 11.4286C3.94014 11.4286 3.42847 11.9403 3.42847 12.5714C3.42847 13.2026 3.94014 13.7143 4.57132 13.7143Z" fill="black"/>
+<path d="M10.2856 2.85712V5.71426M10.2856 5.71426V8.5714M10.2856 5.71426H13.1428M10.2856 5.71426H7.42847M10.2856 5.71426L12.1904 3.80949M10.2856 5.71426L8.38084 7.61906M10.2856 5.71426L12.1904 7.61906M10.2856 5.71426L8.38084 3.80949" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,13 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_62_95)">
-<path d="M4.5 6C3.67157 6 3 5.32843 3 4.5C3 3.67157 3.67157 3 4.5 3C5.32843 3 6 3.67157 6 4.5C6 5.32843 5.32843 6 4.5 6Z" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M6.54433 13.4334C6.87775 13.5227 7.22046 13.3249 7.3098 12.9914C7.39914 12.658 7.20127 12.3153 6.86786 12.226L6.54433 13.4334ZM3.77426 6.86772L3.93603 6.26401L2.72862 5.94049L2.56686 6.54419L3.77426 6.86772ZM6.86786 12.226C4.53394 11.6006 3.14889 9.20163 3.77426 6.86772L2.56686 6.54419C1.76281 9.54494 3.54359 12.6293 6.54433 13.4334L6.86786 12.226Z" fill="white"/>
-<path d="M11.5 13C10.6716 13 10 12.3284 10 11.5C10 10.6716 10.6716 10 11.5 10C12.3284 10 13 10.6716 13 11.5C13 12.3284 12.3284 13 11.5 13Z" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10.1875 4.21113C9.88852 4.03854 9.7861 3.65629 9.95869 3.35736C10.1313 3.05843 10.5135 2.95601 10.8125 3.12859L10.1875 4.21113ZM11.7888 10.1875C12.9969 8.09496 12.28 5.41925 10.1875 4.21113L10.8125 3.12859C13.5028 4.6819 14.4246 8.12209 12.8713 10.8125L11.7888 10.1875Z" fill="white"/>
-</g>
-<defs>
-<clipPath id="clip0_62_95">
-<rect width="16" height="16" fill="white" transform="matrix(-1 0 0 1 16 0)"/>
-</clipPath>
-</defs>
+<path d="M12.5 6C13.3284 6 14 5.32843 14 4.5C14 3.67157 13.3284 3 12.5 3C11.6716 3 11 3.67157 11 4.5C11 5.32843 11.6716 6 12.5 6Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.5 13C4.32843 13 5 12.3284 5 11.5C5 10.6716 4.32843 10 3.5 10C2.67157 10 2 10.6716 2 11.5C2 12.3284 2.67157 13 3.5 13Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.00391 13.9293C8.16208 14.1122 9.35055 13.9659 10.4234 13.5085C11.4962 13.0511 12.4066 12.3024 13.0426 11.3545C13.6787 10.4066 14.0128 9.30075 14.0037 8.17293C13.9977 7.42342 13.8404 6.68587 13.5444 6" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.00391 2.07233C7.83928 1.90288 6.64809 2.05936 5.57504 2.52278C4.502 2.9862 3.59331 3.73659 2.95939 4.68279C2.32547 5.62899 1.99362 6.73024 2.00415 7.85274C2.0111 8.59299 2.16675 9.32147 2.45883 10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,20 +1,11 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_39_129)">
-<path d="M22.0209 11.9553C22.0059 10.0068 21.4219 8.10512 20.3408 6.48401" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10.1001 2.18C11.355 1.93537 12.1493 1.93674 13.5027 2.10594" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M21.8198 10.1C22.0644 11.3548 22.0644 12.6451 21.8198 13.9" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M20.2898 17.6C19.5716 18.6622 18.6548 19.5757 17.5898 20.29" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M13.9008 21.82C12.6459 22.0644 11.6432 22.1543 10.3883 21.91" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M2.18005 13.9C1.93543 12.6451 1.93543 11.3548 2.18005 10.1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M3.70996 6.40002C4.42822 5.33775 5.34503 4.42433 6.40996 3.71002" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M12 13C12.5523 13 13 12.5523 13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12C11 12.5523 11.4477 13 12 13Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M19 7C20.1046 7 21 6.10457 21 5C21 3.89543 20.1046 3 19 3C17.8954 3 17 3.89543 17 5C17 6.10457 17.8954 7 19 7Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5 21C6.10457 21 7 20.1046 7 19C7 17.8954 6.10457 17 5 17C3.89543 17 3 17.8954 3 19C3 20.1046 3.89543 21 5 21Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M1.99072 12.0748C2.00804 14.0118 2.58758 15.9021 3.65891 17.516" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-</g>
-<defs>
-<clipPath id="clip0_39_129">
-<rect width="24" height="24" fill="white"/>
-</clipPath>
-</defs>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M14.0039 8.01361C13.9981 7.31989 13.8495 6.63699 13.5698 6" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.00391 2.01361C7.74152 2.01361 8.2084 2.01361 9.00391 2.01361" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.0039 11.0136C12.4719 11.8034 11.7928 12.4825 11.0039 13.0136" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.00391 14.0136C8.28937 14.0136 7.71844 14.0136 7.00391 14.0136" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.00391 5.01361C3.53595 4.22382 4.21507 3.5447 5.00391 3.01361" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8.00391 9.01361C8.55621 9.01361 9.00391 8.56591 9.00391 8.01361C9.00391 7.46131 8.55621 7.01361 8.00391 7.01361C7.45161 7.01361 7.00391 7.46131 7.00391 8.01361C7.00391 8.56591 7.45161 9.01361 8.00391 9.01361Z" fill="black" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.5 6C13.3284 6 14 5.32843 14 4.5C14 3.67157 13.3284 3 12.5 3C11.6716 3 11 3.67157 11 4.5C11 5.32843 11.6716 6 12.5 6Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.5 13C4.32843 13 5 12.3284 5 11.5C5 10.6716 4.32843 10 3.5 10C2.67157 10 2 10.6716 2 11.5C2 12.3284 2.67157 13 3.5 13Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2.00391 8.01361C2.01055 8.69737 2.15535 9.37058 2.42723 10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,15 +1,8 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_32_70)">
-<path d="M19 7C20.1046 7 21 6.10457 21 5C21 3.89543 20.1046 3 19 3C17.8954 3 17 3.89543 17 5C17 6.10457 17.8954 7 19 7Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5 21C6.10457 21 7 20.1046 7 19C7 17.8954 6.10457 17 5 17C3.89543 17 3 17.8954 3 19C3 20.1046 3.89543 21 5 21Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10.3999 21.9C12.3227 22.2159 14.2958 21.9632 16.0769 21.173C17.858 20.3827 19.3694 19.0893 20.4254 17.4517C21.4814 15.8142 22.036 13.9037 22.021 11.9553C22.006 10.0068 21.422 8.10512 20.3409 6.48401" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M13.4998 2.10002C11.5849 1.8076 9.62631 2.07763 7.86198 2.87732C6.09765 3.677 4.60356 4.9719 3.56126 6.60468C2.51896 8.23745 1.97332 10.1378 1.99063 12.0748C2.00795 14.0118 2.58749 15.9021 3.65882 17.516" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10 15V9" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M14 15V9" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-</g>
-<defs>
-<clipPath id="clip0_32_70">
-<rect width="24" height="24" fill="white"/>
-</clipPath>
-</defs>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.5 6C13.3284 6 14 5.32843 14 4.5C14 3.67157 13.3284 3 12.5 3C11.6716 3 11 3.67157 11 4.5C11 5.32843 11.6716 6 12.5 6Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.5 13C4.32843 13 5 12.3284 5 11.5C5 10.6716 4.32843 10 3.5 10C2.67157 10 2 10.6716 2 11.5C2 12.3284 2.67157 13 3.5 13Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.00391 13.9293C8.16208 14.1122 9.35055 13.9659 10.4234 13.5085C11.4962 13.0511 12.4066 12.3024 13.0426 11.3545C13.6787 10.4066 14.0128 9.30075 14.0037 8.17293C13.9977 7.42342 13.8404 6.68587 13.5444 6" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.00391 2.07233C7.83928 1.90288 6.64809 2.05936 5.57504 2.52278C4.502 2.9862 3.59331 3.73659 2.95939 4.68279C2.32547 5.62899 1.99362 6.73024 2.00415 7.85274C2.0111 8.59299 2.16675 9.32147 2.45883 10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.00391 10.0059V6.00592" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.00391 10.0059V6.00592" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,14 +1,7 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_32_64)">
-<path d="M19 7C20.1046 7 21 6.10457 21 5C21 3.89543 20.1046 3 19 3C17.8954 3 17 3.89543 17 5C17 6.10457 17.8954 7 19 7Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5 21C6.10457 21 7 20.1046 7 19C7 17.8954 6.10457 17 5 17C3.89543 17 3 17.8954 3 19C3 20.1046 3.89543 21 5 21Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10.3999 21.9C12.3227 22.2159 14.2958 21.9632 16.0769 21.173C17.858 20.3827 19.3694 19.0893 20.4254 17.4517C21.4814 15.8142 22.036 13.9037 22.021 11.9553C22.006 10.0068 21.422 8.10512 20.3409 6.48401" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M13.4998 2.10002C11.5849 1.8076 9.62631 2.07763 7.86198 2.87732C6.09765 3.677 4.60356 4.9719 3.56126 6.60468C2.51896 8.23745 1.97332 10.1378 1.99063 12.0748C2.00795 14.0118 2.58749 15.9021 3.65882 17.516" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10 8.56055C10 8.32095 10.267 8.17803 10.4664 8.31094L15.6256 11.7504C15.8037 11.8691 15.8037 12.1309 15.6256 12.2496L10.4664 15.6891C10.267 15.822 10 15.6791 10 15.4394V8.56055Z" fill="white" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
-</g>
-<defs>
-<clipPath id="clip0_32_64">
-<rect width="24" height="24" fill="white"/>
-</clipPath>
-</defs>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.00366 6.16662C7.00366 6.03849 7.14274 5.96206 7.24661 6.03313L9.93408 7.87243C10.0269 7.93591 10.0269 8.07591 9.93408 8.13939L7.24661 9.97871C7.14274 10.0498 7.00366 9.97336 7.00366 9.84518V6.16662Z" fill="black" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.5 6C13.3284 6 14 5.32843 14 4.5C14 3.67157 13.3284 3 12.5 3C11.6716 3 11 3.67157 11 4.5C11 5.32843 11.6716 6 12.5 6Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.5 13C4.32843 13 5 12.3284 5 11.5C5 10.6716 4.32843 10 3.5 10C2.67157 10 2 10.6716 2 11.5C2 12.3284 2.67157 13 3.5 13Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.00391 13.9293C8.16208 14.1122 9.35055 13.9659 10.4234 13.5085C11.4962 13.0511 12.4066 12.3024 13.0426 11.3545C13.6787 10.4066 14.0128 9.30075 14.0037 8.17293C13.9977 7.42342 13.8404 6.68587 13.5444 6" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.00391 2.07233C7.83928 1.90288 6.64809 2.05936 5.57504 2.52278C4.502 2.9862 3.59331 3.73659 2.95939 4.68279C2.32547 5.62899 1.99362 6.73024 2.00415 7.85274C2.0111 8.59299 2.16675 9.32147 2.45883 10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7.99988 13C5.97267 13 4.22723 11.7936 3.44238 10.0595M7.99988 3C10.1122 3 11.9185 4.30981 12.6511 6.16152" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.99988 13C5.97267 13 4.22723 11.7936 3.44238 10.0595M7.99988 3C10.1122 3 11.9185 4.30981 12.6511 6.16152" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.65625 3.29688C3.00143 3.29688 3.28125 3.01705 3.28125 2.67188C3.28125 2.3267 3.00143 2.04688 2.65625 2.04688C2.31107 2.04688 2.03125 2.3267 2.03125 2.67188C2.03125 3.01705 2.31107 3.29688 2.65625 3.29688Z" fill="black"/>
<path d="M4.71094 3.29688C5.05612 3.29688 5.33594 3.01705 5.33594 2.67188C5.33594 2.3267 5.05612 2.04688 4.71094 2.04688C4.36576 2.04688 4.08594 2.3267 4.08594 2.67188C4.08594 3.01705 4.36576 3.29688 4.71094 3.29688Z" fill="black"/>
<path d="M5.96094 4.99219C6.30612 4.99219 6.58594 4.71237 6.58594 4.36719C6.58594 4.02201 6.30612 3.74219 5.96094 3.74219C5.61576 3.74219 5.33594 4.02201 5.33594 4.36719C5.33594 4.71237 5.61576 4.99219 5.96094 4.99219Z" fill="black"/>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M8.5 6.88672C10.433 6.88672 12 8.45372 12 10.3867V13M12 13L14 11M12 13L10 11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M8.5 6.88672C10.433 6.88672 12 8.45372 12 10.3867V13M12 13L14 11M12 13L10 11" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -1,7 +1 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3 12C3 9.61305 3.94821 7.32387 5.63604 5.63604C7.32387 3.94821 9.61305 3 12 3C14.516 3.00947 16.931 3.99122 18.74 5.74L21 8" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M21 3V8H16" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M21 12C21 14.3869 20.0518 16.6761 18.364 18.364C16.6761 20.0518 14.3869 21 12 21C9.48395 20.9905 7.06897 20.0088 5.26 18.26L3 16" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M8 16H3V21" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10 9.37052C10 8.98462 10.4186 8.74419 10.7519 8.93863L15.2596 11.5681C15.5904 11.761 15.5904 12.2389 15.2596 12.4319L10.7519 15.0614C10.4186 15.2558 10 15.0154 10 14.6295V9.37052Z" fill="black"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M2 8a6 6 0 0 1 6-6 6.5 6.5 0 0 1 4.493 1.827L14 5.333"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M14 2v3.333h-3.333M14 8a6 6 0 0 1-6 6 6.5 6.5 0 0 1-4.493-1.827L2 10.667"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M5.333 10.667H2V14"/><path fill="#000" d="M6.667 6.247c0-.257.279-.418.501-.288l3.005 1.753c.22.129.22.447 0 .576L7.168 10.04a.333.333 0 0 1-.501-.288V6.247Z"/></svg>
@@ -1 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-corner-down-left"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.00008 6.66669L2.66675 10L6.00008 13.3334" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.3334 2.66669V7.33335C13.3334 8.0406 13.0525 8.71888 12.5524 9.21897C12.0523 9.71907 11.374 10 10.6667 10H2.66675" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-search"><circle cx="17" cy="17" r="3"/><path d="M10.7 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v4.1"/><path d="m21 21-1.5-1.5"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="m3 5.778 1.256-1.256A5.417 5.417 0 0 1 8 3a5 5 0 1 1-4.583 7"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M3 3v3h3"/></svg>
@@ -1,4 +1 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M12 6.5L9.99556 4.21778C9.27778 3.5 8.12 3 7 3C6.20888 3 5.43552 3.2346 4.77772 3.67412C4.11992 4.11365 3.60723 4.73836 3.30448 5.46927C3.00173 6.20017 2.92252 7.00444 3.07686 7.78036C3.2312 8.55628 3.61216 9.26902 4.17157 9.82842C4.73098 10.3878 5.44372 10.7688 6.21964 10.9231C6.99556 11.0775 7.79983 10.9983 8.53073 10.6955C8.88113 10.5504 9.20712 10.357 9.5 10.1225" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M12 4V6.5H9.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="m13 5.778-1.256-1.256A5.416 5.416 0 0 0 8 3a5 5 0 1 0 4.583 7"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M13 3v3h-3"/></svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-route"><circle cx="6" cy="19" r="3"/><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15"/><circle cx="18" cy="5" r="3"/></svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-save"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></svg>
@@ -1 +1,3 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-scissors-icon lucide-scissors"><circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/><path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.03641 5.53641L8.33797 7.83797M13.0825 3.0934L6.03641 10.1395M9.99896 9.49896L13.0825 12.5825M4.77932 6.05864C5.25123 6.05864 5.7038 5.87118 6.03749 5.53749C6.37118 5.2038 6.55864 4.75123 6.55864 4.27932C6.55864 3.80742 6.37118 3.35484 6.03749 3.02115C5.7038 2.68746 5.25123 2.5 4.77932 2.5C4.30742 2.5 3.85484 2.68746 3.52115 3.02115C3.18746 3.35484 3 3.80742 3 4.27932C3 4.75123 3.18746 5.2038 3.52115 5.53749C3.85484 5.87118 4.30742 6.05864 4.77932 6.05864ZM4.77932 13.1759C5.25123 13.1759 5.7038 12.9885 6.03749 12.6548C6.37118 12.3211 6.55864 11.8685 6.55864 11.3966C6.55864 10.9247 6.37118 10.4721 6.03749 10.1384C5.7038 9.80475 5.25123 9.61729 4.77932 9.61729C4.30742 9.61729 3.85484 9.80475 3.52115 10.1384C3.18746 10.4721 3 10.9247 3 11.3966C3 11.8685 3.18746 12.3211 3.52115 12.6548C3.85484 12.9885 4.30742 13.1759 4.77932 13.1759Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,8 +1,5 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path
- fill-rule="evenodd"
- clip-rule="evenodd"
- d="M1 3.25C1 3.11193 1.11193 3 1.25 3H13.75C13.8881 3 14 3.11193 14 3.25V10.75C14 10.8881 13.8881 11 13.75 11H1.25C1.11193 11 1 10.8881 1 10.75V3.25ZM1.25 2C0.559643 2 0 2.55964 0 3.25V10.75C0 11.4404 0.559644 12 1.25 12H5.07341L4.82991 13.2986C4.76645 13.6371 5.02612 13.95 5.37049 13.95H9.62951C9.97389 13.95 10.2336 13.6371 10.1701 13.2986L9.92659 12H13.75C14.4404 12 15 11.4404 15 10.75V3.25C15 2.55964 14.4404 2 13.75 2H1.25ZM9.01091 12H5.98909L5.79222 13.05H9.20778L9.01091 12Z"
- fill="currentColor"
- />
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.8 3H3.2C2.53726 3 2 3.51167 2 4.14286V9.85714C2 10.4883 2.53726 11 3.2 11H12.8C13.4627 11 14 10.4883 14 9.85714V4.14286C14 3.51167 13.4627 3 12.8 3Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.33325 14H10.6666" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 11.3333V14" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-scroll-text-icon lucide-scroll-text"><path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/></svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-text-quote"><path d="M17 6H3"/><path d="M21 12H8"/><path d="M21 18H8"/><path d="M3 12v6"/></svg>
@@ -1,5 +1 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M9.5 7V9.5M9.5 9.5V12M9.5 9.5H12M9.5 9.5H7M9.5 9.5L11.1667 7.83333M9.5 9.5L7.83333 11.1667M9.5 9.5L11.1667 11.1667M9.5 9.5L7.83333 7.83333" stroke="#687076" stroke-width="1.25" stroke-linecap="round"/>
@@ -1,4 +1 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3.09666 3.02263C3.0567 3.00312 3.01178 2.9961 2.96778 3.0025C2.92377 3.00889 2.88271 3.02839 2.84995 3.05847C2.8172 3.08854 2.79426 3.12778 2.78413 3.17108C2.77401 3.21439 2.77716 3.25973 2.79319 3.30121L4.05638 6.69C4.13088 6.89005 4.13088 7.11022 4.05638 7.31027L2.79363 10.6991C2.77769 10.7405 2.77457 10.7858 2.78469 10.829C2.79481 10.8722 2.8177 10.9114 2.85038 10.9414C2.88306 10.9715 2.92402 10.991 2.96794 10.9975C3.01186 11.0039 3.05671 10.997 3.09666 10.9776L11.0943 7.20097C11.1324 7.18297 11.1645 7.15455 11.187 7.11899C11.2096 7.08344 11.2215 7.04222 11.2215 7.00014C11.2215 6.95805 11.2096 6.91683 11.187 6.88128C11.1645 6.84573 11.1324 6.8173 11.0943 6.79931L3.09666 3.02263Z" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M4.11255 7.00014H11.2216" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M3.377 3.028a.25.25 0 0 0-.292.045.291.291 0 0 0-.067.303l1.496 4.236c.088.25.088.526 0 .776l-1.496 4.236a.291.291 0 0 0 .067.303.25.25 0 0 0 .292.045l9.472-4.72a.267.267 0 0 0 .11-.103.288.288 0 0 0-.11-.4L3.377 3.028ZM5 8h8"/></svg>
@@ -1,16 +1,6 @@
-<svg
- xmlns="http://www.w3.org/2000/svg"
- width="24"
- height="24"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
->
- <rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
- <rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
- <line x1="6" x2="6.01" y1="6" y2="6" />
- <line x1="6" x2="6.01" y1="18" y2="18" />
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.8 9H3.2C2.53726 9 2 9.44772 2 10V12C2 12.5523 2.53726 13 3.2 13H12.8C13.4627 13 14 12.5523 14 12V10C14 9.44772 13.4627 9 12.8 9Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.8 3H3.2C2.53726 3 2 3.44772 2 4V6C2 6.55228 2.53726 7 3.2 7H12.8C13.4627 7 14 6.55228 14 6V4C14 3.44772 13.4627 3 12.8 3Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 11H4.00667" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 5H4.00667" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1,6 +0,0 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3 4H8" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M6 10L11 10" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<circle cx="4" cy="10" r="1.875" stroke="black" stroke-width="1.5"/>
-<circle cx="10" cy="4" r="1.875" stroke="black" stroke-width="1.5"/>
-</svg>
@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13.0001 8.62505C13.0001 11.75 10.8126 13.3125 8.21266 14.2187C8.07651 14.2648 7.92862 14.2626 7.79392 14.2125C5.18771 13.3125 3.00024 11.75 3.00024 8.62505V4.25012C3.00024 4.08436 3.06609 3.92539 3.1833 3.80818C3.30051 3.69098 3.45948 3.62513 3.62523 3.62513C4.87521 3.62513 6.43769 2.87514 7.52517 1.92516C7.65758 1.81203 7.82601 1.74988 8.00016 1.74988C8.17431 1.74988 8.34275 1.81203 8.47515 1.92516C9.56889 2.88139 11.1251 3.62513 12.3751 3.62513C12.5408 3.62513 12.6998 3.69098 12.817 3.80818C12.9342 3.92539 13.0001 4.08436 13.0001 4.25012V8.62505Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6 8.00002L7.33333 9.33335L10 6.66669" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M2.46475 7.99652L7.85304 2.15921C7.93223 2.07342 8.06777 2.07341 8.14696 2.15921L13.5352 7.99652C13.7126 8.18869 13.5763 8.5 13.3148 8.5H10.5V13.7C10.5 13.8657 10.3657 14 10.2 14H5.8C5.63431 14 5.5 13.8657 5.5 13.7V8.5H2.6852C2.42367 8.5 2.28737 8.18869 2.46475 7.99652Z" stroke="black" stroke-width="1.25"/>
+<path d="M3.07136 7.95724L7.86916 3.05405C7.93967 2.98199 8.06036 2.98198 8.13087 3.05405L12.9286 7.95724C13.0866 8.11865 12.9652 8.38015 12.7324 8.38015H10.226V12.748C10.226 12.8872 10.1065 13 9.95892 13H6.04111C5.89358 13 5.77399 12.8872 5.77399 12.748V8.38015H3.26765C3.03479 8.38015 2.91342 8.11865 3.07136 7.95724Z" stroke="black" stroke-width="1.2"/>
</svg>
@@ -1 +1,3 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-slash"><path d="M22 2 2 22"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.9999 2.99988L2.99976 13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-slash"><rect width="18" height="18" x="3" y="3" rx="2"/><line x1="9" x2="15" y1="15" y2="9"/></svg>
@@ -1,8 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M2 5H4" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M8 5L14 5" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M12 11L14 11" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path d="M2 11H8" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<circle cx="6" cy="5" r="2" fill="black" fill-opacity="0.1" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<circle cx="10" cy="11" r="2" fill="black" fill-opacity="0.1" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M2 5H4" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M8 5L14 5" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M12 11L14 11" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M2 11H8" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<circle cx="6" cy="5" r="2" fill="black" fill-opacity="0.1" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
+<circle cx="10" cy="11" r="2" fill="black" fill-opacity="0.1" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,6 +0,0 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3 4H8" stroke="black" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M6 10L11 10" stroke="black" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
-<circle cx="4" cy="10" r="1.875" stroke="black" stroke-width="1.75"/>
-<circle cx="10" cy="4" r="1.875" stroke="black" stroke-width="1.75"/>
-</svg>
@@ -1,11 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3.6665 14V9.33333" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M3.6665 6.66667V2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M8 14V8" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M8 5.33333V2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M12.3335 14V10.6667" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M12.3335 8V2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M2.3335 9.33333H5.00016" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M6.6665 5.33334H9.33317" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11 10.6667H13.6667" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-scissors"><circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/><path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/></svg>
@@ -1 +1,3 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-space"><path d="M22 17v1c0 .5-.5 1-1 1H3c-.5 0-1-.5-1-1v-1"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13.9999 11.4V12C13.9999 12.3 13.6999 12.6 13.3999 12.6H2.59976C2.29976 12.6 1.99976 12.3 1.99976 12V11.4" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkle"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M6.762 10.1a1.2 1.2 0 0 0-.862-.862l-3.68-.95a.3.3 0 0 1 0-.577l3.68-.95a1.2 1.2 0 0 0 .862-.86l.95-3.682a.3.3 0 0 1 .577 0L9.238 5.9a1.2 1.2 0 0 0 .862.862l3.68.949a.3.3 0 0 1 0 .578l-3.68.949a1.2 1.2 0 0 0-.862.862l-.95 3.68a.3.3 0 0 1-.577 0l-.949-3.68Z"/></svg>
@@ -1,3 +0,0 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M6 6C5.69062 6.30938 4.56159 6.55977 3.51192 6.73263C3.27345 6.7719 3.27345 7.2281 3.51192 7.26737C4.56159 7.44023 5.69062 7.69062 6 8C6.30938 8.30938 6.55977 9.43841 6.73263 10.4881C6.7719 10.7266 7.2281 10.7266 7.26737 10.4881C7.44023 9.43841 7.69062 8.30938 8 8C8.30938 7.69062 9.43841 7.44023 10.4881 7.26737C10.7266 7.2281 10.7266 6.7719 10.4881 6.73263C9.43841 6.55977 8.30938 6.30938 8 6C7.69062 5.69062 7.44023 4.56159 7.26737 3.51192C7.2281 3.27345 6.7719 3.27345 6.73263 3.51192C6.55977 4.56159 6.30938 5.69062 6 6Z" stroke="black" stroke-width="1.25" stroke-linejoin="round"/>
-</svg>
@@ -1,3 +0,0 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1,8 +0,0 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path
- fill-rule="evenodd"
- clip-rule="evenodd"
@@ -1,13 +0,0 @@
-<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_1803_28)">
-<path d="M0.5 2C0.5 1.17157 1.17157 0.5 2 0.5V0.5C2.82843 0.5 3.5 1.17157 3.5 2V2C3.5 2.82843 2.82843 3.5 2 3.5V3.5C1.17157 3.5 0.5 2.82843 0.5 2V2Z" fill="black" fill-opacity="0.3"/>
-<path d="M7.5 6C7.5 6.82843 6.82843 7.5 6 7.5V7.5C5.17157 7.5 4.5 6.82843 4.5 6V6C4.5 5.17157 5.17157 4.5 6 4.5V4.5C6.82843 4.5 7.5 5.17157 7.5 6V6Z" fill="black" fill-opacity="0.6"/>
-<path d="M2 7.5C1.17157 7.5 0.5 6.82843 0.5 6V6C0.5 5.17157 1.17157 4.5 2 4.5V4.5C2.82843 4.5 3.5 5.17157 3.5 6V6C3.5 6.82843 2.82843 7.5 2 7.5V7.5Z" fill="black" fill-opacity="0.8"/>
-<path d="M6 0.5C6.82843 0.5 7.5 1.17157 7.5 2V2C7.5 2.82843 6.82843 3.5 6 3.5V3.5C5.17157 3.5 4.5 2.82843 4.5 2V2C4.5 1.17157 5.17157 0.5 6 0.5V0.5Z" fill="black"/>
-</g>
-<defs>
-<clipPath id="clip0_1803_28">
-<rect width="8" height="8" fill="white"/>
-</clipPath>
-</defs>
-</svg>
@@ -1,5 +1,5 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7 2H10C11.1046 2 12 2.89543 12 4V10C12 11.1046 11.1046 12 10 12H7V2Z" fill="black" fill-opacity="0.25"/>
-<rect x="2" y="2" width="10" height="10" rx="0.5" stroke="black" stroke-width="1.25"/>
-<line x1="7" y1="2" x2="7" y2="12" stroke="black" stroke-width="1.25"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="8" y="2" width="6" height="12" fill="black" fill-opacity="0.25"/>
+<path d="M13.4 2H2.6C2.26863 2 2 2.26863 2 2.6V13.4C2 13.7314 2.26863 14 2.6 14H13.4C13.7314 14 14 13.7314 14 13.4V2.6C14 2.26863 13.7314 2 13.4 2Z" stroke="black" stroke-width="1.2"/>
+<path d="M8 2L8 14" stroke="black" stroke-width="1.2"/>
</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-split-icon lucide-split"><path d="M16 3h5v5"/><path d="M8 3H3v5"/><path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"/><path d="m15 9 6-6"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M10 2.833h3v3M6 2.833H3v3"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8 13.833V9.028a2.401 2.401 0 0 0-.165-.9 2.325 2.325 0 0 0-.486-.763L3 2.833M10 5.833l3-3"/></svg>
@@ -1 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-dot"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="12" cy="12" r="1"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<circle cx="8" cy="8" r="1.25" fill="black" stroke="black" stroke-width="0.5"/>
+</svg>
@@ -1 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-minus"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M8 12h8"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6 8H10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +1,5 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-plus"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6 8H10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 6V10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1,3 +1,3 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4 9.8V4.2C4 4.08954 4.08954 4 4.2 4H9.8C9.91046 4 10 4.08954 10 4.2V9.8C10 9.91046 9.91046 10 9.8 10H4.2C4.08954 10 4 9.91046 4 9.8Z" stroke="#C56757" stroke-width="1.25" stroke-linejoin="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5 10.8V5.2C5 5.08954 5.08954 5 5.2 5H10.8C10.9105 5 11 5.08954 11 5.2V10.8C11 10.9105 10.9105 11 10.8 11H5.2C5.08954 11 5 10.9105 5 10.8Z" fill="black" stroke="black" stroke-width="1.2" stroke-linejoin="round"/>
</svg>
@@ -1,3 +0,0 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4 9.8V4.2C4 4.08954 4.08954 4 4.2 4H9.8C9.91046 4 10 4.08954 10 4.2V9.8C10 9.91046 9.91046 10 9.8 10H4.2C4.08954 10 4 9.91046 4 9.8Z" fill="#C56757" stroke="#C56757" stroke-width="1.25" stroke-linejoin="round"/>
-</svg>
@@ -1,3 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3 4L13 12" stroke="black" stroke-width="2" stroke-linecap="round"/>
-</svg>
@@ -1,8 +1,8 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3.30859 13.0703C3.80693 13.0703 4.21094 12.6663 4.21094 12.168C4.21094 11.6696 3.80693 11.2656 3.30859 11.2656C2.81025 11.2656 2.40625 11.6696 2.40625 12.168C2.40625 12.6663 2.81025 13.0703 3.30859 13.0703Z" fill="black"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M6.53516 8.03849L4.10799 12.6055L2.51562 11.7584L4.94279 7.19141L6.53516 8.03849Z" fill="black"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M7.38281 2.62443L4.93916 7.19141L3.33594 6.34432L5.77959 1.77734L7.38281 2.62443Z" fill="black"/>
-<path d="M6.5625 3.08984C7.06084 3.08984 7.46484 2.68585 7.46484 2.1875C7.46484 1.68915 7.06084 1.28516 6.5625 1.28516C6.06416 1.28516 5.66016 1.68915 5.66016 2.1875C5.66016 2.68585 6.06416 3.08984 6.5625 3.08984Z" fill="black"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M10.882 1.31204C11.2842 1.41224 11.5664 1.7732 11.5664 2.18737V12.168H9.76084V5.8056L8.12938 8.87176L6.53516 8.02471L9.86653 1.76385C10.0611 1.39816 10.4799 1.21184 10.882 1.31204Z" fill="black"/>
-<path d="M10.6641 13.0703C11.1624 13.0703 11.5664 12.6663 11.5664 12.168C11.5664 11.6696 11.1624 11.2656 10.6641 11.2656C10.1657 11.2656 9.76172 11.6696 9.76172 12.168C9.76172 12.6663 10.1657 13.0703 10.6641 13.0703Z" fill="black"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.78125 14.9375C4.35078 14.9375 4.8125 14.4758 4.8125 13.9063C4.8125 13.3367 4.35078 12.875 3.78125 12.875C3.21171 12.875 2.75 13.3367 2.75 13.9063C2.75 14.4758 3.21171 14.9375 3.78125 14.9375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M7.46876 9.18684L4.69485 14.4063L2.875 13.4382L5.64891 8.21875L7.46876 9.18684Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M8.43749 2.99935L5.64475 8.21876L3.8125 7.25066L6.60524 2.03125L8.43749 2.99935Z" fill="black"/>
+<path d="M7.5 3.53124C8.06953 3.53124 8.53124 3.06954 8.53124 2.5C8.53124 1.93045 8.06953 1.46875 7.5 1.46875C6.93046 1.46875 6.46875 1.93045 6.46875 2.5C6.46875 3.06954 6.93046 3.53124 7.5 3.53124Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.4366 1.49947C12.8962 1.61399 13.2187 2.02651 13.2187 2.49985V13.9063H11.1552V6.63497L9.29072 10.1392L7.46875 9.1711L11.276 2.01583C11.4984 1.5979 11.977 1.38496 12.4366 1.49947Z" fill="black"/>
+<path d="M12.1875 14.9375C12.757 14.9375 13.2187 14.4758 13.2187 13.9063C13.2187 13.3367 12.757 12.875 12.1875 12.875C11.6179 12.875 11.1562 13.3367 11.1562 13.9063C11.1562 14.4758 11.6179 14.9375 12.1875 14.9375Z" fill="black"/>
</svg>
@@ -1,15 +1 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g opacity="0.5">
-<path d="M3.78125 14.9375C4.35078 14.9375 4.8125 14.4758 4.8125 13.9062C4.8125 13.3367 4.35078 12.875 3.78125 12.875C3.21172 12.875 2.75 13.3367 2.75 13.9062C2.75 14.4758 3.21172 14.9375 3.78125 14.9375Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M7.46875 9.18684L4.69484 14.4062L2.875 13.4382L5.64891 8.21875L7.46875 9.18684Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M8.4375 2.99935L5.64475 8.21875L3.8125 7.25066L6.60525 2.03125L8.4375 2.99935Z" fill="white"/>
-<path d="M7.5 3.53125C8.06953 3.53125 8.53125 3.06954 8.53125 2.5C8.53125 1.93046 8.06953 1.46875 7.5 1.46875C6.93047 1.46875 6.46875 1.93046 6.46875 2.5C6.46875 3.06954 6.93047 3.53125 7.5 3.53125Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M12.4366 1.49947C12.8962 1.61399 13.2188 2.02651 13.2188 2.49985V13.9063H11.1552V6.63497L9.29072 10.1392L7.46875 9.17109L11.276 2.01583C11.4984 1.59789 11.977 1.38496 12.4366 1.49947Z" fill="white"/>
-<path d="M12.1875 14.9375C12.757 14.9375 13.2188 14.4758 13.2188 13.9062C13.2188 13.3367 12.757 12.875 12.1875 12.875C11.618 12.875 11.1562 13.3367 11.1562 13.9062C11.1562 14.4758 11.618 14.9375 12.1875 14.9375Z" fill="white"/>
-</g>
-<g>
-<path d="M0.906311 6.42261L1.75155 4.60999L15.3462 10.9493L14.5009 12.7619L0.906311 6.42261Z" fill="white"/>
-<circle cx="14.7841" cy="11.7906" r="1" transform="rotate(-65 14.7841 11.7906)" fill="white"/>
-<circle cx="1.32893" cy="5.51631" r="1" transform="rotate(-65 1.32893 5.51631)" fill="white"/>
-</g>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g fill="#000" clip-path="url(#a)"><g opacity=".5"><path d="M3.781 14.938a1.031 1.031 0 1 0 0-2.063 1.031 1.031 0 0 0 0 2.063Z"/><path fill-rule="evenodd" d="m7.469 9.187-2.774 5.22-1.82-.969 2.774-5.22 1.82.969ZM8.438 3 5.644 8.218 3.813 7.25l2.792-5.22L8.437 3Z" clip-rule="evenodd"/><path d="M7.5 3.531a1.031 1.031 0 1 0 0-2.062 1.031 1.031 0 0 0 0 2.062Z"/><path fill-rule="evenodd" d="M12.437 1.5c.46.114.782.527.782 1v11.406h-2.064V6.635l-1.864 3.504-1.822-.968 3.807-7.155c.222-.418.701-.631 1.16-.517Z" clip-rule="evenodd"/><path d="M12.188 14.938a1.031 1.031 0 1 0 0-2.063 1.031 1.031 0 0 0 0 2.063Z"/></g><path d="m.906 6.423.845-1.813 13.595 6.34-.845 1.812L.906 6.422Z"/><path d="M15.69 12.213a1 1 0 1 0-1.812-.845 1 1 0 0 0 1.812.845ZM2.235 5.939a1 1 0 1 0-1.812-.845 1 1 0 0 0 1.812.845Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
@@ -1,11 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.5">
-<path d="M3.78125 14.9375C4.35078 14.9375 4.8125 14.4758 4.8125 13.9062C4.8125 13.3367 4.35078 12.875 3.78125 12.875C3.21172 12.875 2.75 13.3367 2.75 13.9062C2.75 14.4758 3.21172 14.9375 3.78125 14.9375Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M7.46875 9.18684L4.69484 14.4062L2.875 13.4382L5.64891 8.21875L7.46875 9.18684Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M8.4375 2.99935L5.64475 8.21875L3.8125 7.25066L6.60525 2.03125L8.4375 2.99935Z" fill="white"/>
-<path d="M7.5 3.53125C8.06953 3.53125 8.53125 3.06954 8.53125 2.5C8.53125 1.93046 8.06953 1.46875 7.5 1.46875C6.93047 1.46875 6.46875 1.93046 6.46875 2.5C6.46875 3.06954 6.93047 3.53125 7.5 3.53125Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M12.4366 1.49947C12.8962 1.61399 13.2188 2.02651 13.2188 2.49985V13.9063H11.1552V6.63497L9.29072 10.1392L7.46875 9.17109L11.276 2.01583C11.4984 1.59789 11.977 1.38496 12.4366 1.49947Z" fill="white"/>
-<path d="M12.1875 14.9375C12.757 14.9375 13.2188 14.4758 13.2188 13.9062C13.2188 13.3367 12.757 12.875 12.1875 12.875C11.618 12.875 11.1562 13.3367 11.1562 13.9062C11.1562 14.4758 11.618 14.9375 12.1875 14.9375Z" fill="white"/>
+<path d="M3.78125 14.9375C4.35078 14.9375 4.8125 14.4758 4.8125 13.9062C4.8125 13.3367 4.35078 12.875 3.78125 12.875C3.21172 12.875 2.75 13.3367 2.75 13.9062C2.75 14.4758 3.21172 14.9375 3.78125 14.9375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M7.46875 9.18684L4.69484 14.4062L2.875 13.4382L5.64891 8.21875L7.46875 9.18684Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M8.4375 2.99935L5.64475 8.21875L3.8125 7.25066L6.60525 2.03125L8.4375 2.99935Z" fill="black"/>
+<path d="M7.5 3.53125C8.06953 3.53125 8.53125 3.06954 8.53125 2.5C8.53125 1.93046 8.06953 1.46875 7.5 1.46875C6.93047 1.46875 6.46875 1.93046 6.46875 2.5C6.46875 3.06954 6.93047 3.53125 7.5 3.53125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.4366 1.49947C12.8962 1.61399 13.2188 2.02651 13.2188 2.49985V13.9063H11.1552V6.63497L9.29072 10.1392L7.46875 9.17109L11.276 2.01583C11.4984 1.59789 11.977 1.38496 12.4366 1.49947Z" fill="black"/>
+<path d="M12.1875 14.9375C12.757 14.9375 13.2188 14.4758 13.2188 13.9062C13.2188 13.3367 12.757 12.875 12.1875 12.875C11.618 12.875 11.1562 13.3367 11.1562 13.9062C11.1562 14.4758 11.618 14.9375 12.1875 14.9375Z" fill="black"/>
</g>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M14.6847 15.9265C14.7823 16.0241 14.9406 16.0241 15.0382 15.9265L15.9259 15.0387C16.0235 14.9411 16.0235 14.7828 15.9259 14.6851L14.2408 12.9999L15.9259 11.3146C16.0236 11.217 16.0236 11.0587 15.9259 10.961L15.0382 10.0733C14.9406 9.97561 14.7823 9.97561 14.6847 10.0733L12.9996 11.7585L11.3145 10.0732C11.2169 9.97559 11.0586 9.97559 10.9609 10.0732L10.0732 10.961C9.97559 11.0587 9.97559 11.217 10.0732 11.3146L11.7584 12.9999L10.0732 14.6851C9.97562 14.7828 9.97562 14.9411 10.0732 15.0387L10.9609 15.9265C11.0586 16.0242 11.2169 16.0242 11.3145 15.9265L12.9996 14.2413L14.6847 15.9265Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M14.6847 15.9265C14.7823 16.0241 14.9406 16.0241 15.0382 15.9265L15.9259 15.0387C16.0235 14.9411 16.0235 14.7828 15.9259 14.6851L14.2408 12.9999L15.9259 11.3146C16.0236 11.217 16.0236 11.0587 15.9259 10.961L15.0382 10.0733C14.9406 9.97562 14.7823 9.97562 14.6847 10.0733L12.9996 11.7585L11.3145 10.0732C11.2169 9.9756 11.0586 9.9756 10.9609 10.0732L10.0732 10.961C9.9756 11.0587 9.9756 11.217 10.0732 11.3146L11.7584 12.9999L10.0732 14.6851C9.97563 14.7828 9.97563 14.9411 10.0732 15.0387L10.9609 15.9265C11.0586 16.0242 11.2169 16.0242 11.3145 15.9265L12.9996 14.2413L14.6847 15.9265Z" fill="black"/>
</svg>
@@ -1,11 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.5">
-<path d="M3.78125 14.9375C4.35078 14.9375 4.8125 14.4758 4.8125 13.9062C4.8125 13.3367 4.35078 12.875 3.78125 12.875C3.21172 12.875 2.75 13.3367 2.75 13.9062C2.75 14.4758 3.21172 14.9375 3.78125 14.9375Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M7.46875 9.18684L4.69484 14.4062L2.875 13.4382L5.64891 8.21875L7.46875 9.18684Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M8.4375 2.99935L5.64475 8.21875L3.8125 7.25066L6.60525 2.03125L8.4375 2.99935Z" fill="white"/>
-<path d="M7.5 3.53125C8.06953 3.53125 8.53125 3.06954 8.53125 2.5C8.53125 1.93046 8.06953 1.46875 7.5 1.46875C6.93047 1.46875 6.46875 1.93046 6.46875 2.5C6.46875 3.06954 6.93047 3.53125 7.5 3.53125Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M12.4366 1.49947C12.8962 1.61399 13.2188 2.02651 13.2188 2.49985V13.9063H11.1552V6.63497L9.29072 10.1392L7.46875 9.17109L11.276 2.01583C11.4984 1.59789 11.977 1.38496 12.4366 1.49947Z" fill="white"/>
-<path d="M12.1875 14.9375C12.757 14.9375 13.2188 14.4758 13.2188 13.9062C13.2188 13.3367 12.757 12.875 12.1875 12.875C11.618 12.875 11.1562 13.3367 11.1562 13.9062C11.1562 14.4758 11.618 14.9375 12.1875 14.9375Z" fill="white"/>
+<path d="M3.78125 14.9375C4.35078 14.9375 4.8125 14.4758 4.8125 13.9062C4.8125 13.3367 4.35078 12.875 3.78125 12.875C3.21172 12.875 2.75 13.3367 2.75 13.9062C2.75 14.4758 3.21172 14.9375 3.78125 14.9375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M7.46875 9.18684L4.69484 14.4062L2.875 13.4382L5.64891 8.21875L7.46875 9.18684Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M8.4375 2.99935L5.64475 8.21875L3.8125 7.25066L6.60525 2.03125L8.4375 2.99935Z" fill="black"/>
+<path d="M7.5 3.53125C8.06953 3.53125 8.53125 3.06954 8.53125 2.5C8.53125 1.93046 8.06953 1.46875 7.5 1.46875C6.93047 1.46875 6.46875 1.93046 6.46875 2.5C6.46875 3.06954 6.93047 3.53125 7.5 3.53125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.4366 1.49947C12.8962 1.61399 13.2188 2.02651 13.2188 2.49985V13.9063H11.1552V6.63497L9.29072 10.1392L7.46875 9.17109L11.276 2.01583C11.4984 1.59789 11.977 1.38496 12.4366 1.49947Z" fill="black"/>
+<path d="M12.1875 14.9375C12.757 14.9375 13.2188 14.4758 13.2188 13.9062C13.2188 13.3367 12.757 12.875 12.1875 12.875C11.618 12.875 11.1562 13.3367 11.1562 13.9062C11.1562 14.4758 11.618 14.9375 12.1875 14.9375Z" fill="black"/>
</g>
-<circle cx="13" cy="13" r="3" fill="white"/>
+<path d="M13 16C14.6569 16 16 14.6569 16 13C16 11.3431 14.6569 10 13 10C11.3431 10 10 11.3431 10 13C10 14.6569 11.3431 16 13 16Z" fill="black"/>
</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-swatch-book"><path d="M11 17a4 4 0 0 1-8 0V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2Z"/><path d="M16.7 13H19a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H7"/><path d="M 7 17h.01"/><path d="m11 8 2.3-2.3a2.4 2.4 0 0 1 3.404.004L18.6 7.6a2.4 2.4 0 0 1 .026 3.434L9.9 19.8"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M7.333 11.333a2.667 2.667 0 1 1-5.333 0v-8A1.333 1.333 0 0 1 3.333 2H6a1.333 1.333 0 0 1 1.333 1.333v8Z"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M11.133 8.667h1.534A1.333 1.333 0 0 1 14 10v2.667A1.334 1.334 0 0 1 12.667 14h-8M4.667 11.333h.006"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M7.333 5.333 8.867 3.8a1.6 1.6 0 0 1 2.269.003L12.4 5.067a1.6 1.6 0 0 1 .017 2.289L6.6 13.2"/></svg>
@@ -1 +1,5 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right-to-line"><path d="M17 12H3"/><path d="m11 18 6-6-6-6"/><path d="M21 5v14"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.3333 8H2" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.33325 12L10.3333 8L6.33325 4" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13 3.33331V12.6666" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M11.8889 3H4.11111C3.49746 3 3 3.49746 3 4.11111V11.8889C3 12.5025 3.49746 13 4.11111 13H11.8889C12.5025 13 13 12.5025 13 11.8889V4.11111C13 3.49746 12.5025 3 11.8889 3Z" fill="black" fill-opacity="0.15" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M8.37939 10.3243H10.3794" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5.64966 9.32837L7.64966 7.32837L5.64966 5.32837" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.2782 2.49951H3.72184C3.04677 2.49951 2.49951 3.04677 2.49951 3.72184V12.2782C2.49951 12.9532 3.04677 13.5005 3.72184 13.5005H12.2782C12.9532 13.5005 13.5005 12.9532 13.5005 12.2782V3.72184C13.5005 3.04677 12.9532 2.49951 12.2782 2.49951Z" fill="black" fill-opacity="0.15" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 10.7502H10.7502" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.24976 9.21777L7.08325 7.38428L5.24976 5.55078" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-text-select"><path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h1"/><path d="M14 3h1"/><path d="M14 21h1"/><path d="M3 9v1"/><path d="M21 9v1"/><path d="M3 14v1"/><path d="M21 14v1"/><line x1="7" x2="15" y1="8" y2="8"/><line x1="7" x2="17" y1="12" y2="12"/><line x1="7" x2="13" y1="16" y2="16"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M3.333 2A1.333 1.333 0 0 0 2 3.333M12.667 2A1.334 1.334 0 0 1 14 3.333M14 12.667A1.334 1.334 0 0 1 12.667 14M3.333 14A1.334 1.334 0 0 1 2 12.667M6 2h.667M6 14h.667M9.333 2H10M9.333 14H10M2 6v.667M14 6v.667M2 9.333V10M14 9.333V10M4.667 5.333H10M4.667 8h6.666M4.667 10.667h4"/></svg>
@@ -0,0 +1,7 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.33333 8H2" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.6667 5H2" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 11H2" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12 7V11" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M14 9H10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.31254 12.549C7.3841 13.0987 8.61676 13.2476 9.78839 12.9688C10.96 12.6901 11.9936 12.0021 12.7028 11.0287C13.412 10.0554 13.7503 8.8607 13.6566 7.66002C13.5629 6.45934 13.0435 5.33159 12.1919 4.48C11.3403 3.62841 10.2126 3.10898 9.01188 3.01531C7.8112 2.92164 6.61655 3.2599 5.64319 3.96912C4.66984 4.67834 3.9818 5.71188 3.70306 6.88351C3.42432 8.05514 3.5732 9.2878 4.12289 10.3594L3 13.6719L6.31254 12.549Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,6 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.98795 10.4323C9.40771 10.9919 9.99294 11.4054 10.6607 11.614C11.3285 11.8226 12.045 11.8158 12.7087 11.5945C13.3724 11.3733 13.9497 10.9488 14.3588 10.3813C14.7678 9.81373 14.9879 9.13186 14.9879 8.43225C14.9879 7.6366 14.6719 6.87354 14.1093 6.31093C13.5467 5.74832 12.7836 5.43225 11.9879 5.43225C11.6685 5.43225 11.3595 5.47897 11.0677 5.56586C10.0571 5.86681 9.46945 6.84992 8.98796 7.78806V7.78806" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8.01318 5.93652V8.60319H10.6799" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8.01318 5.93652V8.60319H10.6799" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.00558 12.4263C6.16246 12.4494 5.3211 12.2612 4.56083 11.8712L1.24829 12.994L2.37119 9.68151C1.8215 8.60995 1.67261 7.37729 1.95135 6.20566C2.23009 5.03403 2.91813 4.00048 3.89148 3.29126C4.86484 2.58204 6.05949 2.24379 7.26018 2.33746C7.86645 2.38475 8.45413 2.54061 8.99705 2.79296" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,3 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
- <path d="M18.905 12.75a1.25 1.25 0 1 1-2.5 0v-7.5a1.25 1.25 0 0 1 2.5 0v7.5ZM8.905 17v1.3c0 .268-.14.526-.395.607A2 2 0 0 1 5.905 17c0-.995.182-1.948.514-2.826.204-.54-.166-1.174-.744-1.174h-2.52c-1.243 0-2.261-1.01-2.146-2.247.193-2.08.651-4.082 1.341-5.974C2.752 3.678 3.833 3 5.005 3h3.192a3 3 0 0 1 1.341.317l2.734 1.366A3 3 0 0 0 13.613 5h1.292v7h-.963c-.685 0-1.258.482-1.612 1.068a4.01 4.01 0 0 1-2.166 1.73c-.432.143-.853.386-1.011.814-.16.432-.248.9-.248 1.388Z" />
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" clip-path="url(#a)"><path d="M3.013 8.57h2.478V3.2H3.013a.413.413 0 0 0-.413.413v4.544a.413.413 0 0 0 .413.413ZM5.491 8.57l2.066 4.13a1.652 1.652 0 0 0 1.652-1.652v-1.24h3.304a.826.826 0 0 0 .82-.929l-.62-4.956a.827.827 0 0 0-.82-.723H5.492"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
@@ -1,3 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
- <path d="M1 8.25a1.25 1.25 0 1 1 2.5 0v7.5a1.25 1.25 0 1 1-2.5 0v-7.5ZM11 3V1.7c0-.268.14-.526.395-.607A2 2 0 0 1 14 3c0 .995-.182 1.948-.514 2.826-.204.54.166 1.174.744 1.174h2.52c1.243 0 2.261 1.01 2.146 2.247a23.864 23.864 0 0 1-1.341 5.974C17.153 16.323 16.072 17 14.9 17h-3.192a3 3 0 0 1-1.341-.317l-2.734-1.366A3 3 0 0 0 6.292 15H5V8h.963c.685 0 1.258-.483 1.612-1.068a4.011 4.011 0 0 1 2.166-1.73c.432-.143.853-.386 1.011-.814.16-.432.248-.9.248-1.388Z" />
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" clip-path="url(#a)"><path d="M3.013 7.33h2.478v5.37H3.013a.413.413 0 0 1-.413-.413V7.743a.413.413 0 0 1 .413-.413ZM5.491 7.33 7.557 3.2a1.652 1.652 0 0 1 1.652 1.652v1.24h3.304a.826.826 0 0 1 .82.929l-.62 4.956a.827.827 0 0 1-.82.723H5.492"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8 13A5 5 0 1 0 8 3a5 5 0 0 0 0 10Z"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="m5.949 9.026 1.538 1.025 2.564-3.59"/></svg>
@@ -0,0 +1,10 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7 3C7.66045 3 8.33955 3 9 3" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11 4C11.3949 4.26602 11.7345 4.60558 12 5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13 7C13 7.66045 13 8.33955 13 9" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12 11C11.734 11.3949 11.3944 11.7345 11 12" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 13C8.33954 13 7.66046 13 7 13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 12C4.6051 11.734 4.26554 11.3944 4 11" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3 9C3 8.33955 3 7.66045 3 7" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 5C4.26602 4.6051 4.60558 4.26554 5 4" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,11 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7 3C7.66045 3 8.33955 3 9 3" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11 4C11.3949 4.26602 11.7345 4.60558 12 5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13 7C13 7.66045 13 8.33955 13 9" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12 11C11.734 11.3949 11.3944 11.7345 11 12" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 13C8.33954 13 7.66046 13 7 13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 12C4.6051 11.734 4.26554 11.3944 4 11" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3 9C3 8.33955 3 7.66045 3 7" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 5C4.26602 4.6051 4.60558 4.26554 5 4" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8.00016 8.66665C8.36835 8.66665 8.66683 8.36817 8.66683 7.99998C8.66683 7.63179 8.36835 7.33331 8.00016 7.33331C7.63197 7.33331 7.3335 7.63179 7.3335 7.99998C7.3335 8.36817 7.63197 8.66665 8.00016 8.66665Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,3 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M10.4174 10.2159C10.5454 9.58974 10.4174 9.57261 11.3762 8.46959C11.9337 7.82822 12.335 7.09214 12.335 6.27818C12.335 5.28184 11.9309 4.32631 11.2118 3.62179C10.4926 2.91728 9.5171 2.52148 8.50001 2.52148C7.48291 2.52148 6.50748 2.91728 5.78828 3.62179C5.06909 4.32631 4.66504 5.28184 4.66504 6.27818C4.66504 6.9043 4.79288 7.65565 5.62379 8.46959C6.58253 9.59098 6.45474 9.58974 6.58257 10.2159M10.4174 10.2159L10.4174 12.2989C10.4174 12.9504 9.87836 13.4786 9.21329 13.4786H7.78674C7.12167 13.4786 6.58253 12.9504 6.58253 12.2989L6.58257 10.2159M10.4174 10.2159H8.50001H6.58257" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M9.5 2.5H6.5C6.22386 2.5 6 2.83579 6 3.25V4.75C6 5.16421 6.22386 5.5 6.5 5.5H9.5C9.77614 5.5 10 5.16421 10 4.75V3.25C10 2.83579 9.77614 2.5 9.5 2.5Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10 3.5H11C11.2652 3.5 11.5196 3.61706 11.7071 3.82544C11.8946 4.03381 12 4.31643 12 4.61111V12.3889C12 12.6836 11.8946 12.9662 11.7071 13.1746C11.5196 13.3829 11.2652 13.5 11 13.5H5C4.73478 13.5 4.48043 13.3829 4.29289 13.1746C4.10536 12.9662 4 12.6836 4 12.3889V4.61111C4 4.31643 4.10536 4.03381 4.29289 3.82544C4.48043 3.61706 4.73478 3.5 5 3.5H6" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.5 2.5H6.5C6.22386 2.5 6 2.83579 6 3.25V4.75C6 5.16421 6.22386 5.5 6.5 5.5H9.5C9.77614 5.5 10 5.16421 10 4.75V3.25C10 2.83579 9.77614 2.5 9.5 2.5Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10 3.5H11C11.2652 3.5 11.5196 3.61706 11.7071 3.82544C11.8946 4.03381 12 4.31643 12 4.61111V12.3889C12 12.6836 11.8946 12.9662 11.7071 13.1746C11.5196 13.3829 11.2652 13.5 11 13.5H5C4.73478 13.5 4.48043 13.3829 4.29289 13.1746C4.10536 12.9662 4 12.6836 4 12.3889V4.61111C4 4.31643 4.10536 4.03381 4.29289 3.82544C4.48043 3.61706 4.73478 3.5 5 3.5H6" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M9.50002 2.5H5C4.73478 2.5 4.48043 2.6159 4.29289 2.82219C4.10535 3.02848 4 3.30826 4 3.6V12.3999C4 12.6917 4.10535 12.9715 4.29289 13.1778C4.48043 13.3841 4.73478 13.5 5 13.5H11C11.2652 13.5 11.5195 13.3841 11.7071 13.1778C11.8946 12.9715 12 12.6917 12 12.3999V5.25L9.50002 2.5Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M9.3427 6.82379L6.65698 9.5095" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M6.65698 6.82379L9.3427 9.5095" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.50002 2.5H5C4.73478 2.5 4.48043 2.6159 4.29289 2.82219C4.10535 3.02848 4 3.30826 4 3.6V12.3999C4 12.6917 4.10535 12.9715 4.29289 13.1778C4.48043 13.3841 4.73478 13.5 5 13.5H11C11.2652 13.5 11.5195 13.3841 11.7071 13.1778C11.8946 12.9715 12 12.6917 12 12.3999V5.25L9.50002 2.5Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.3427 6.82379L6.65698 9.5095" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.65698 6.82379L9.3427 9.5095" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M13.7244 11.5299L9.01711 3.2922C8.91447 3.11109 8.76562 2.96045 8.58576 2.85564C8.4059 2.75084 8.20145 2.69562 7.99328 2.69562C7.7851 2.69562 7.58066 2.75084 7.40079 2.85564C7.22093 2.96045 7.07209 3.11109 6.96945 3.2922L2.26218 11.5299C2.15844 11.7096 2.10404 11.9135 2.1045 12.121C2.10495 12.3285 2.16026 12.5321 2.2648 12.7113C2.36934 12.8905 2.5194 13.0389 2.69978 13.1415C2.88015 13.244 3.08443 13.297 3.2919 13.2951H12.7064C12.9129 13.2949 13.1157 13.2404 13.2944 13.137C13.4731 13.0336 13.6215 12.8851 13.7247 12.7062C13.8278 12.5273 13.8821 12.3245 13.882 12.118C13.882 11.9115 13.8276 11.7087 13.7244 11.5299Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7.99927 6.23425V8.58788" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7.99927 10.9415H8.00492" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.7244 11.5299L9.01711 3.2922C8.91447 3.11109 8.76562 2.96045 8.58576 2.85564C8.4059 2.75084 8.20145 2.69562 7.99328 2.69562C7.7851 2.69562 7.58066 2.75084 7.40079 2.85564C7.22093 2.96045 7.07209 3.11109 6.96945 3.2922L2.26218 11.5299C2.15844 11.7096 2.10404 11.9135 2.1045 12.121C2.10495 12.3285 2.16026 12.5321 2.2648 12.7113C2.36934 12.8905 2.5194 13.0389 2.69978 13.1415C2.88015 13.244 3.08443 13.297 3.2919 13.2951H12.7064C12.9129 13.2949 13.1157 13.2404 13.2944 13.137C13.4731 13.0336 13.6215 12.8851 13.7247 12.7062C13.8278 12.5273 13.8821 12.3245 13.882 12.118C13.882 11.9115 13.8276 11.7087 13.7244 11.5299Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.99927 6.23425V8.58788" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.99927 10.9415H8.00492" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M12.4 12.5C12.6917 12.5 12.9715 12.3884 13.1778 12.1899C13.3841 11.9913 13.5 11.722 13.5 11.4412V6.14706C13.5 5.86624 13.3841 5.59693 13.1778 5.39836C12.9715 5.19979 12.6917 5.08824 12.4 5.08824H8.055C7.87103 5.08997 7.68955 5.04726 7.52717 4.96402C7.36478 4.88078 7.22668 4.75967 7.1255 4.61176L6.68 3.97647C6.57984 3.83007 6.44349 3.7099 6.28317 3.62674C6.12286 3.54358 5.94361 3.50003 5.7615 3.5H3.6C3.30826 3.5 3.02847 3.61155 2.82218 3.81012C2.61589 4.00869 2.5 4.27801 2.5 4.55882V11.4412C2.5 11.722 2.61589 11.9913 2.82218 12.1899C3.02847 12.3884 3.30826 12.5 3.6 12.5H12.4Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.8 13C13.1183 13 13.4235 12.8761 13.6486 12.6554C13.8735 12.4349 14 12.1356 14 11.8236V5.94118C14 5.62916 13.8735 5.32992 13.6486 5.10929C13.4235 4.88866 13.1183 4.76471 12.8 4.76471H8.06C7.8593 4.76664 7.66133 4.71919 7.48418 4.6267C7.30703 4.53421 7.15637 4.39964 7.046 4.2353L6.56 3.52941C6.45073 3.36675 6.30199 3.23322 6.1271 3.14082C5.95221 3.04842 5.75666 3.00004 5.558 3H3.2C2.88174 3 2.57651 3.12395 2.35148 3.34458C2.12643 3.56521 2 3.86445 2 4.17647V11.8236C2 12.1356 2.12643 12.4349 2.35148 12.6554C2.57651 12.8761 2.88174 13 3.2 13H12.8Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M9 8.5L4.94864 12.6222C4.71647 12.8544 4.40157 12.9848 4.07323 12.9848C3.74488 12.9848 3.42999 12.8544 3.19781 12.6222C2.96564 12.39 2.83521 12.0751 2.83521 11.7468C2.83521 11.4185 2.96564 11.1036 3.19781 10.8714L7.5 6.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10.8352 9.98474L13.8352 6.98474" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M12.8352 7.42495L11.7634 6.4298C11.5533 6.23484 11.4353 5.97039 11.4352 5.69462V5.08526L10.1696 3.91022C9.54495 3.33059 8.69961 3.00261 7.81649 2.99722L5.83521 2.98474L6.35041 3.41108C6.71634 3.71233 7.00935 4.08216 7.21013 4.4962C7.4109 4.91024 7.51488 5.35909 7.51521 5.81316L7.5 6.5L9 8.5L9.5 8C9.5 8 9.87337 7.79457 10.0834 7.98959L11.1552 8.98474" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 8.5L4.94864 12.6222C4.71647 12.8544 4.40157 12.9848 4.07323 12.9848C3.74488 12.9848 3.42999 12.8544 3.19781 12.6222C2.96564 12.39 2.83521 12.0751 2.83521 11.7468C2.83521 11.4185 2.96564 11.1036 3.19781 10.8714L7.5 6.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.8352 9.98474L13.8352 6.98474" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.8352 7.42495L11.7634 6.4298C11.5533 6.23484 11.4353 5.97039 11.4352 5.69462V5.08526L10.1696 3.91022C9.54495 3.33059 8.69961 3.00261 7.81649 2.99722L5.83521 2.98474L6.35041 3.41108C6.71634 3.71233 7.00935 4.08216 7.21013 4.4962C7.4109 4.91024 7.51488 5.35909 7.51521 5.81316L7.5 6.5L9 8.5L9.5 8C9.5 8 9.87337 7.79457 10.0834 7.98959L11.1552 8.98474" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M6.5 12C6.65203 12.304 6.87068 12.5565 7.13399 12.7321C7.39729 12.9076 7.69597 13 8 13C8.30403 13 8.60271 12.9076 8.86601 12.7321C9.12932 12.5565 9.34797 12.304 9.5 12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M3.63088 9.21874C3.56556 9.28556 3.52246 9.36865 3.50681 9.45791C3.49116 9.54718 3.50364 9.63876 3.54273 9.72152C3.58183 9.80429 3.64585 9.87467 3.72701 9.92409C3.80817 9.97352 3.90298 9.99987 3.99989 9.99994H12.0001C12.097 9.99997 12.1918 9.97372 12.273 9.92439C12.3542 9.87505 12.4183 9.80476 12.4575 9.72205C12.4967 9.63934 12.5093 9.54778 12.4938 9.45851C12.4783 9.36924 12.4353 9.2861 12.3701 9.21921C11.705 8.57941 11 7.89947 11 5.79994C11 5.05733 10.684 4.34514 10.1213 3.82004C9.55872 3.29494 8.79564 2.99994 7.99997 2.99994C7.20431 2.99994 6.44123 3.29494 5.87861 3.82004C5.31599 4.34514 4.99991 5.05733 4.99991 5.79994C4.99991 7.89947 4.2944 8.57941 3.63088 9.21874Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.5 12C6.65203 12.304 6.87068 12.5565 7.13399 12.7321C7.39729 12.9076 7.69597 13 8 13C8.30403 13 8.60271 12.9076 8.86601 12.7321C9.12932 12.5565 9.34797 12.304 9.5 12" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.63088 9.21874C3.56556 9.28556 3.52246 9.36865 3.50681 9.45791C3.49116 9.54718 3.50364 9.63876 3.54273 9.72152C3.58183 9.80429 3.64585 9.87467 3.72701 9.92409C3.80817 9.97352 3.90298 9.99987 3.99989 9.99994H12.0001C12.097 9.99997 12.1918 9.97372 12.273 9.92439C12.3542 9.87505 12.4183 9.80476 12.4575 9.72205C12.4967 9.63934 12.5093 9.54778 12.4938 9.45851C12.4783 9.36924 12.4353 9.2861 12.3701 9.21921C11.705 8.57941 11 7.89947 11 5.79994C11 5.05733 10.684 4.34514 10.1213 3.82004C9.55872 3.29494 8.79564 2.99994 7.99997 2.99994C7.20431 2.99994 6.44123 3.29494 5.87861 3.82004C5.31599 4.34514 4.99991 5.05733 4.99991 5.79994C4.99991 7.89947 4.2944 8.57941 3.63088 9.21874Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M12.5871 5.40582C12.8514 5.14152 13 4.78304 13 4.40922C13 4.03541 12.8516 3.67688 12.5873 3.41252C12.323 3.14816 11.9645 2.99962 11.5907 2.99957C11.2169 2.99953 10.8584 3.14798 10.594 3.41227L3.92098 10.0869C3.80488 10.2027 3.71903 10.3452 3.67097 10.5019L3.01047 12.678C2.99754 12.7212 2.99657 12.7672 3.00764 12.8109C3.01872 12.8547 3.04143 12.8946 3.07337 12.9265C3.1053 12.9584 3.14528 12.981 3.18905 12.992C3.23282 13.003 3.27875 13.002 3.32197 12.989L5.49849 12.329C5.65508 12.2813 5.79758 12.196 5.91349 12.0805L12.5871 5.40582Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M9 5L11 7" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.5871 5.40582C12.8514 5.14152 13 4.78304 13 4.40922C13 4.03541 12.8516 3.67688 12.5873 3.41252C12.323 3.14816 11.9645 2.99962 11.5907 2.99957C11.2169 2.99953 10.8584 3.14798 10.594 3.41227L3.92098 10.0869C3.80488 10.2027 3.71903 10.3452 3.67097 10.5019L3.01047 12.678C2.99754 12.7212 2.99657 12.7672 3.00764 12.8109C3.01872 12.8547 3.04143 12.8946 3.07337 12.9265C3.1053 12.9584 3.14528 12.981 3.18905 12.992C3.23282 13.003 3.27875 13.002 3.32197 12.989L5.49849 12.329C5.65508 12.2813 5.79758 12.196 5.91349 12.0805L12.5871 5.40582Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 5L11 7" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,7 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3 5.66667V4.33333C3 3.97971 3.14048 3.64057 3.39052 3.39052C3.64057 3.14048 3.97971 3 4.33333 3H5.66667" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M10.3333 3H11.6666C12.0202 3 12.3593 3.14048 12.6094 3.39052C12.8594 3.64057 12.9999 3.97971 12.9999 4.33333V5.66667" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M12.9999 10.3333V11.6666C12.9999 12.0203 12.8594 12.3594 12.6094 12.6095C12.3593 12.8595 12.0202 13 11.6666 13H10.3333" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5.66667 13H4.33333C3.97971 13 3.64057 12.8595 3.39052 12.6095C3.14048 12.3594 3 12.0203 3 11.6666V10.3333" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M5.5 8H10.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3 5.66667V4.33333C3 3.97971 3.14048 3.64057 3.39052 3.39052C3.64057 3.14048 3.97971 3 4.33333 3H5.66667" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.3333 3H11.6666C12.0202 3 12.3593 3.14048 12.6094 3.39052C12.8594 3.64057 12.9999 3.97971 12.9999 4.33333V5.66667" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.9999 10.3333V11.6666C12.9999 12.0203 12.8594 12.3594 12.6094 12.6095C12.3593 12.8595 12.0202 13 11.6666 13H10.3333" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.66667 13H4.33333C3.97971 13 3.64057 12.8595 3.39052 12.6095C3.14048 12.3594 3 12.0203 3 11.6666V10.3333" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.5 8H10.5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.57132 13.7143C5.20251 13.7143 5.71418 13.2026 5.71418 12.5714C5.71418 11.9403 5.20251 11.4286 4.57132 11.4286C3.94014 11.4286 3.42847 11.9403 3.42847 12.5714C3.42847 13.2026 3.94014 13.7143 4.57132 13.7143Z" fill="black"/>
-<path d="M10.2856 2.85712V5.71426M10.2856 5.71426V8.5714M10.2856 5.71426H13.1428M10.2856 5.71426H7.42847M10.2856 5.71426L12.1904 3.80949M10.2856 5.71426L8.38084 7.61906M10.2856 5.71426L12.1904 7.61906M10.2856 5.71426L8.38084 3.80949" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+<path d="M10.2856 2.85712V5.71426M10.2856 5.71426V8.5714M10.2856 5.71426H13.1428M10.2856 5.71426H7.42847M10.2856 5.71426L12.1904 3.80949M10.2856 5.71426L8.38084 7.61906M10.2856 5.71426L12.1904 7.61906M10.2856 5.71426L8.38084 3.80949" stroke="black" stroke-width="1.2" stroke-linecap="round"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M13 13L11 11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7.5 12C9.98528 12 12 9.98528 12 7.5C12 5.01472 9.98528 3 7.5 3C5.01472 3 3 5.01472 3 7.5C3 9.98528 5.01472 12 7.5 12Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13 13L11 11" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.5 12C9.98528 12 12 9.98528 12 7.5C12 5.01472 9.98528 3 7.5 3C5.01472 3 3 5.01472 3 7.5C3 9.98528 5.01472 12 7.5 12Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5.99487 8.44023L7.32821 7.10689L5.99487 5.77356" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7.33838 10.2264H10.005" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11.8889 3H4.11111C3.49746 3 3 3.49746 3 4.11111V11.8889C3 12.5025 3.49746 13 4.11111 13H11.8889C12.5025 13 13 12.5025 13 11.8889V4.11111C13 3.49746 12.5025 3 11.8889 3Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.2782 2.49951H3.72184C3.04677 2.49951 2.49951 3.04677 2.49951 3.72184V12.2782C2.49951 12.9532 3.04677 13.5005 3.72184 13.5005H12.2782C12.9532 13.5005 13.5005 12.9532 13.5005 12.2782V3.72184C13.5005 3.04677 12.9532 2.49951 12.2782 2.49951Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 10.7502H10.7502" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.24976 9.21777L7.08325 7.38428L5.24976 5.55078" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.9526 10.2625C10.0833 9.62316 9.9526 9.60566 10.9315 8.47946C11.5008 7.82461 11.9105 7.07306 11.9105 6.242C11.9105 5.22472 11.4979 4.2491 10.7637 3.52978C10.0294 2.81046 9.03338 2.40634 7.99491 2.40634C6.95644 2.40634 5.96051 2.81046 5.22619 3.52978C4.49189 4.2491 4.07935 5.22472 4.07935 6.242C4.07935 6.88128 4.20987 7.64842 5.05825 8.47946C6.03714 9.62442 5.90666 9.62316 6.03718 10.2625M9.9526 10.2625V12.3893C9.9526 13.0544 9.40223 13.5937 8.72319 13.5937H7.26665C6.58761 13.5937 6.03714 13.0544 6.03714 12.3893L6.03718 10.2625M9.9526 10.2625H7.99491H6.03718" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7.99993 13.4804C11.0267 13.4804 13.4803 11.0267 13.4803 7.99999C13.4803 4.97325 11.0267 2.51959 7.99993 2.51959C4.97319 2.51959 2.51953 4.97325 2.51953 7.99999C2.51953 11.0267 4.97319 13.4804 7.99993 13.4804Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M8 3C6.71611 4.34807 6 6.13836 6 7.99999C6 9.86163 6.71611 11.6519 8 13C9.28387 11.6519 10 9.86163 10 7.99999C10 6.13836 9.28387 4.34807 8 3Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M3.24121 7.04827C4.52425 8.27022 6.22817 8.95178 7.99999 8.95178C9.77182 8.95178 11.4757 8.27022 12.7588 7.04827" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.99993 13.4804C11.0267 13.4804 13.4803 11.0267 13.4803 7.99999C13.4803 4.97325 11.0267 2.51959 7.99993 2.51959C4.97319 2.51959 2.51953 4.97325 2.51953 7.99999C2.51953 11.0267 4.97319 13.4804 7.99993 13.4804Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 3C6.71611 4.34807 6 6.13836 6 7.99999C6 9.86163 6.71611 11.6519 8 13C9.28387 11.6519 10 9.86163 10 7.99999C10 6.13836 9.28387 4.34807 8 3Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.24121 7.04827C4.52425 8.27022 6.22817 8.95178 7.99999 8.95178C9.77182 8.95178 11.4757 8.27022 12.7588 7.04827" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +1,5 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3 5L13 5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12 5V12.875C12 13.4375 11.4286 14 10.8571 14H5.14286C4.57143 14 4 13.4375 4 12.875V5" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10 5V3C10 2.44772 9.55228 2 9 2H7C6.44772 2 6 2.44772 6 3V5" stroke="black" stroke-width="1.2"/>
+</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
@@ -1,3 +1,3 @@
-<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8.5 3L3 12H14L8.5 3Z" fill="black"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.99996 3L2.82349 12H13.1764L7.99996 3Z" fill="black"/>
</svg>
@@ -1 +1,3 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 11L6 4L10.5 7.5L6 11Z" fill="currentColor"></path></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6 11.4667V4L10.8 7.73333L6 11.4667Z" fill="black"/>
+</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-undo"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M2.6 5v3.6h3.6"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M13.4 11A5.4 5.4 0 0 0 8 5.6a5.4 5.4 0 0 0-3.6 1.38L2.6 8.6"/></svg>
@@ -1,8 +0,0 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path
- fill-rule="evenodd"
- clip-rule="evenodd"
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-round-check-icon lucide-user-round-check"><path d="M2 21a8 8 0 0 1 13.292-6"/><circle cx="10" cy="8" r="5"/><path d="m16 19 2 2 4-4"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M2 13.4a4.8 4.8 0 0 1 7.975-3.6"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M6.8 8.6a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM10.4 12.2l1.2 1.2L14 11"/></svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M6.79118 8.27005C8.27568 8.27005 9.4791 7.06663 9.4791 5.58214C9.4791 4.09765 8.27568 2.89423 6.79118 2.89423C5.30669 2.89423 4.10327 4.09765 4.10327 5.58214C4.10327 7.06663 5.30669 8.27005 6.79118 8.27005Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M6.79112 8.60443C4.19441 8.60443 2.08936 10.7095 2.08936 13.3062H11.4929C11.4929 10.7095 9.38784 8.60443 6.79112 8.60443Z" fill="black" fill-opacity="0.15" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M14.6984 12.9263C14.6984 10.8893 13.4895 8.99736 12.2806 8.09067C12.6779 7.79254 12.9957 7.40104 13.2057 6.95083C13.4157 6.50062 13.5115 6.00558 13.4846 5.50952C13.4577 5.01346 13.309 4.53168 13.0515 4.10681C12.7941 3.68194 12.4358 3.3271 12.0085 3.07367" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.79118 8.27005C8.27568 8.27005 9.4791 7.06663 9.4791 5.58214C9.4791 4.09765 8.27568 2.89423 6.79118 2.89423C5.30669 2.89423 4.10327 4.09765 4.10327 5.58214C4.10327 7.06663 5.30669 8.27005 6.79118 8.27005Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.79112 8.60443C4.19441 8.60443 2.08936 10.7095 2.08936 13.3062H11.4929C11.4929 10.7095 9.38784 8.60443 6.79112 8.60443Z" fill="black" fill-opacity="0.15" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M14.6984 12.9263C14.6984 10.8893 13.4895 8.99736 12.2806 8.09067C12.6779 7.79254 12.9957 7.40104 13.2057 6.95083C13.4157 6.50062 13.5115 6.00558 13.4846 5.50952C13.4577 5.01346 13.309 4.53168 13.0515 4.10681C12.7941 3.68194 12.4358 3.3271 12.0085 3.07367" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-round-pen-icon lucide-user-round-pen"><path d="M2 21a8 8 0 0 1 10.821-7.487"/><path d="M21.378 16.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z"/><circle cx="10" cy="8" r="5"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M2 13.433a4.8 4.8 0 0 1 6.493-4.492M13.627 10.809a1.275 1.275 0 0 0-1.803-1.803l-2.406 2.408a1.2 1.2 0 0 0-.303.512l-.502 1.722a.3.3 0 0 0 .372.372l1.722-.502c.193-.057.37-.161.512-.304l2.408-2.405Z"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M6.8 8.633a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"/></svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-wand"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8 19 13"/><path d="M15 9h.01"/><path d="M17.8 6.2 19 5"/><path d="m3 21 9-9"/><path d="M12.2 6.2 11 5"/></svg>
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-alert-triangle"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M13.84 11.6 9.037 3.199a1.2 1.2 0 0 0-2.089 0l-4.802 8.403a1.2 1.2 0 0 0 1.05 1.8h9.604a1.201 1.201 0 0 0 1.038-1.8ZM8 6v2.667M8 11.333h.007"/></svg>
@@ -1,5 +1 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M4.74672 9.48686L4.07031 6.03232L3.38584 9.48686H2.17614L1.00281 4.00781H2.27559L2.81566 7.41754L3.48439 4.01752H4.65865L5.31819 7.41176L5.85736 4.00781H7.13014L5.9568 9.48686H4.74672Z" fill="#787D87"/>
@@ -1,3 +0,0 @@
-<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5 4.5L12 11.5M12 4.5L5 11.5" stroke="black" stroke-width="2" stroke-linecap="round"/>
-</svg>
@@ -1,4 +1 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8.86396 2C8.99657 2 9.12375 2.05268 9.21751 2.14645L11.8536 4.78249C11.9473 4.87625 12 5.00343 12 5.13604L12 8.86396C12 8.99657 11.9473 9.12375 11.8536 9.21751L9.21751 11.8536C9.12375 11.9473 8.99657 12 8.86396 12L5.13604 12C5.00343 12 4.87625 11.9473 4.78249 11.8536L2.14645 9.21751C2.05268 9.12375 2 8.99657 2 8.86396L2 5.13604C2 5.00343 2.05268 4.87625 2.14645 4.78249L4.78249 2.14645C4.87625 2.05268 5.00343 2 5.13604 2L8.86396 2Z" fill="#001A33" fill-opacity="0.157" stroke="#11181C" stroke-width="1.25" stroke-linejoin="round"/>
-<path d="M8.89063 5.10938L5.10937 8.89063M8.89063 8.89063L5.10937 5.10938" stroke="#11181C" stroke-width="1.25" stroke-linecap="round"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path fill="#000" fill-opacity=".157" stroke="#000" stroke-linejoin="round" stroke-width="1.2" d="M9.864 3a.5.5 0 0 1 .353.146l2.636 2.636a.5.5 0 0 1 .147.354v3.728a.5.5 0 0 1-.146.353l-2.637 2.637a.5.5 0 0 1-.353.146H6.136a.5.5 0 0 1-.354-.146l-2.636-2.636A.5.5 0 0 1 3 9.864V6.136a.5.5 0 0 1 .146-.354l2.636-2.636A.5.5 0 0 1 6.136 3h3.728Z"/><path stroke="#000" stroke-linecap="round" stroke-width="1.2" d="m9.5 6.5-3 3m3 0-3-3"/></svg>
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 2C11.3137 2 14 4.68629 14 8C14 11.3137 11.3137 14 8 14C4.68629 14 2 11.3137 2 8C2 4.68629 4.68629 2 8 2ZM10.4238 5.57617C10.1895 5.34187 9.81049 5.3419 9.57617 5.57617L8 7.15234L6.42383 5.57617C6.18953 5.34187 5.81049 5.3419 5.57617 5.57617C5.34186 5.81049 5.34186 6.18951 5.57617 6.42383L7.15234 8L5.57617 9.57617C5.34186 9.81049 5.34186 10.1895 5.57617 10.4238C5.81049 10.6581 6.18954 10.6581 6.42383 10.4238L8 8.84766L9.57617 10.4238C9.81049 10.6581 10.1895 10.6581 10.4238 10.4238C10.6581 10.1895 10.658 9.81048 10.4238 9.57617L8.84766 8L10.4238 6.42383C10.6581 6.18954 10.658 5.81048 10.4238 5.57617Z" fill="black"/>
+</svg>
@@ -0,0 +1,27 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 8.75V10.5C8.93097 10.5 8.06903 10.5 6 10.5V10L11 6V5.5H6V7.25" stroke="black" stroke-width="1.5"/>
+<path d="M2 8.5C2.27614 8.5 2.5 8.27614 2.5 8C2.5 7.72386 2.27614 7.5 2 7.5C1.72386 7.5 1.5 7.72386 1.5 8C1.5 8.27614 1.72386 8.5 2 8.5Z" fill="black"/>
+<path opacity="0.6" d="M2.99976 6.33002C3.2759 6.33002 3.49976 6.10616 3.49976 5.83002C3.49976 5.55387 3.2759 5.33002 2.99976 5.33002C2.72361 5.33002 2.49976 5.55387 2.49976 5.83002C2.49976 6.10616 2.72361 6.33002 2.99976 6.33002Z" fill="black"/>
+<path opacity="0.6" d="M2.99976 10.66C3.2759 10.66 3.49976 10.4361 3.49976 10.16C3.49976 9.88383 3.2759 9.65997 2.99976 9.65997C2.72361 9.65997 2.49976 9.88383 2.49976 10.16C2.49976 10.4361 2.72361 10.66 2.99976 10.66Z" fill="black"/>
+<path d="M15 8.5C15.2761 8.5 15.5 8.27614 15.5 8C15.5 7.72386 15.2761 7.5 15 7.5C14.7239 7.5 14.5 7.72386 14.5 8C14.5 8.27614 14.7239 8.5 15 8.5Z" fill="black"/>
+<path opacity="0.6" d="M14 6.33002C14.2761 6.33002 14.5 6.10616 14.5 5.83002C14.5 5.55387 14.2761 5.33002 14 5.33002C13.7239 5.33002 13.5 5.55387 13.5 5.83002C13.5 6.10616 13.7239 6.33002 14 6.33002Z" fill="black"/>
+<path opacity="0.6" d="M14 10.66C14.2761 10.66 14.5 10.4361 14.5 10.16C14.5 9.88383 14.2761 9.65997 14 9.65997C13.7239 9.65997 13.5 9.88383 13.5 10.16C13.5 10.4361 13.7239 10.66 14 10.66Z" fill="black"/>
+<path d="M8.49219 2C8.76833 2 8.99219 1.77614 8.99219 1.5C8.99219 1.22386 8.76833 1 8.49219 1C8.21605 1 7.99219 1.22386 7.99219 1.5C7.99219 1.77614 8.21605 2 8.49219 2Z" fill="black"/>
+<path opacity="0.6" d="M6 3C6.27614 3 6.5 2.77614 6.5 2.5C6.5 2.22386 6.27614 2 6 2C5.72386 2 5.5 2.22386 5.5 2.5C5.5 2.77614 5.72386 3 6 3Z" fill="black"/>
+<path d="M4 4C4.27614 4 4.5 3.77614 4.5 3.5C4.5 3.22386 4.27614 3 4 3C3.72386 3 3.5 3.22386 3.5 3.5C3.5 3.77614 3.72386 4 4 4Z" fill="black"/>
+<path d="M3.99976 13C4.2759 13 4.49976 12.7761 4.49976 12.5C4.49976 12.2239 4.2759 12 3.99976 12C3.72361 12 3.49976 12.2239 3.49976 12.5C3.49976 12.7761 3.72361 13 3.99976 13Z" fill="black"/>
+<path opacity="0.2" d="M2 12.5C2.27614 12.5 2.5 12.2761 2.5 12C2.5 11.7239 2.27614 11.5 2 11.5C1.72386 11.5 1.5 11.7239 1.5 12C1.5 12.2761 1.72386 12.5 2 12.5Z" fill="black"/>
+<path opacity="0.2" d="M2 4.5C2.27614 4.5 2.5 4.27614 2.5 4C2.5 3.72386 2.27614 3.5 2 3.5C1.72386 3.5 1.5 3.72386 1.5 4C1.5 4.27614 1.72386 4.5 2 4.5Z" fill="black"/>
+<path opacity="0.2" d="M15 12.5C15.2761 12.5 15.5 12.2761 15.5 12C15.5 11.7239 15.2761 11.5 15 11.5C14.7239 11.5 14.5 11.7239 14.5 12C14.5 12.2761 14.7239 12.5 15 12.5Z" fill="black"/>
+<path opacity="0.2" d="M15 4.5C15.2761 4.5 15.5 4.27614 15.5 4C15.5 3.72386 15.2761 3.5 15 3.5C14.7239 3.5 14.5 3.72386 14.5 4C14.5 4.27614 14.7239 4.5 15 4.5Z" fill="black"/>
+<path opacity="0.5" d="M3.99976 15C4.2759 15 4.49976 14.7761 4.49976 14.5C4.49976 14.2239 4.2759 14 3.99976 14C3.72361 14 3.49976 14.2239 3.49976 14.5C3.49976 14.7761 3.72361 15 3.99976 15Z" fill="black"/>
+<path opacity="0.5" d="M4 2C4.27614 2 4.5 1.77614 4.5 1.5C4.5 1.22386 4.27614 1 4 1C3.72386 1 3.5 1.22386 3.5 1.5C3.5 1.77614 3.72386 2 4 2Z" fill="black"/>
+<path opacity="0.5" d="M13 15C13.2761 15 13.5 14.7761 13.5 14.5C13.5 14.2239 13.2761 14 13 14C12.7239 14 12.5 14.2239 12.5 14.5C12.5 14.7761 12.7239 15 13 15Z" fill="black"/>
+<path opacity="0.5" d="M13 2C13.2761 2 13.5 1.77614 13.5 1.5C13.5 1.22386 13.2761 1 13 1C12.7239 1 12.5 1.22386 12.5 1.5C12.5 1.77614 12.7239 2 13 2Z" fill="black"/>
+<path d="M13 4C13.2761 4 13.5 3.77614 13.5 3.5C13.5 3.22386 13.2761 3 13 3C12.7239 3 12.5 3.22386 12.5 3.5C12.5 3.77614 12.7239 4 13 4Z" fill="black"/>
+<path d="M13 13C13.2761 13 13.5 12.7761 13.5 12.5C13.5 12.2239 13.2761 12 13 12C12.7239 12 12.5 12.2239 12.5 12.5C12.5 12.7761 12.7239 13 13 13Z" fill="black"/>
+<path opacity="0.6" d="M11 3C11.2761 3 11.5 2.77614 11.5 2.5C11.5 2.22386 11.2761 2 11 2C10.7239 2 10.5 2.22386 10.5 2.5C10.5 2.77614 10.7239 3 11 3Z" fill="black"/>
+<path d="M8.5 15C8.77614 15 9 14.7761 9 14.5C9 14.2239 8.77614 14 8.5 14C8.22386 14 8 14.2239 8 14.5C8 14.7761 8.22386 15 8.5 15Z" fill="black"/>
+<path opacity="0.6" d="M6 14C6.27614 14 6.5 13.7761 6.5 13.5C6.5 13.2239 6.27614 13 6 13C5.72386 13 5.5 13.2239 5.5 13.5C5.5 13.7761 5.72386 14 6 14Z" fill="black"/>
+<path opacity="0.6" d="M11 14C11.2761 14 11.5 13.7761 11.5 13.5C11.5 13.2239 11.2761 13 11 13C10.7239 13 10.5 13.2239 10.5 13.5C10.5 13.7761 10.7239 14 11 14Z" fill="black"/>
+</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8 2.93652L6.9243 6.20697C6.86924 6.37435 6.77565 6.52646 6.65105 6.65105C6.52646 6.77565 6.37435 6.86924 6.20697 6.9243L2.93652 8L6.20697 9.0757C6.37435 9.13076 6.52646 9.22435 6.65105 9.34895C6.77565 9.47354 6.86924 9.62565 6.9243 9.79306L8 13.0635L9.0757 9.79306C9.13076 9.62565 9.22435 9.47354 9.34895 9.34895C9.47354 9.22435 9.62565 9.13076 9.79306 9.0757L13.0635 8L9.79306 6.9243C9.62565 6.86924 9.47354 6.77565 9.34895 6.65105C9.22435 6.52646 9.13076 6.37435 9.0757 6.20697L8 2.93652Z" fill="black" fill-opacity="0.15" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M3.33334 2V4.66666M2 3.33334H4.66666" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M12.6665 11.3333V14M11.3333 12.6666H13.9999" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 2.93652L6.9243 6.20697C6.86924 6.37435 6.77565 6.52646 6.65105 6.65105C6.52646 6.77565 6.37435 6.86924 6.20697 6.9243L2.93652 8L6.20697 9.0757C6.37435 9.13076 6.52646 9.22435 6.65105 9.34895C6.77565 9.47354 6.86924 9.62565 6.9243 9.79306L8 13.0635L9.0757 9.79306C9.13076 9.62565 9.22435 9.47354 9.34895 9.34895C9.47354 9.22435 9.62565 9.13076 9.79306 9.0757L13.0635 8L9.79306 6.9243C9.62565 6.86924 9.47354 6.77565 9.34895 6.65105C9.22435 6.52646 9.13076 6.37435 9.0757 6.20697L8 2.93652Z" fill="black" fill-opacity="0.15" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.33334 2V4.66666M2 3.33334H4.66666" stroke="black" stroke-opacity="0.75" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.6665 11.3333V14M11.3333 12.6666H13.9999" stroke="black" stroke-opacity="0.75" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,5 +0,0 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7 1.75L5.88467 5.14092C5.82759 5.31446 5.73055 5.47218 5.60136 5.60136C5.47218 5.73055 5.31446 5.82759 5.14092 5.88467L1.75 7L5.14092 8.11533C5.31446 8.17241 5.47218 8.26945 5.60136 8.39864C5.73055 8.52782 5.82759 8.68554 5.88467 8.85908L7 12.25L8.11533 8.85908C8.17241 8.68554 8.26945 8.52782 8.39864 8.39864C8.52782 8.26945 8.68554 8.17241 8.85908 8.11533L12.25 7L8.85908 5.88467C8.68554 5.82759 8.52782 5.73055 8.39864 5.60136C8.26945 5.47218 8.17241 5.31446 8.11533 5.14092L7 1.75Z" fill="black" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M2.91667 1.75V4.08333M1.75 2.91667H4.08333" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11.0833 9.91667V12.25M9.91667 11.0833H12.25" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
@@ -1,3 +1,3 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4.99207 8.14741C5.37246 8.14741 5.73726 7.9963 6.00623 7.72733C6.27521 7.45836 6.42631 7.09355 6.42631 6.71317C6.42631 5.92147 6.13946 5.56578 5.85262 4.99208C5.23761 3.76265 5.72411 2.66631 7.00001 1.5499C7.28686 2.98414 8.1474 4.36101 9.2948 5.27893C10.4422 6.19684 11.0159 7.28687 11.0159 8.43426C11.0159 8.96163 10.912 9.48384 10.7102 9.97107C10.5084 10.4583 10.2126 10.901 9.83967 11.2739C9.46676 11.6468 9.02405 11.9426 8.53682 12.1444C8.04959 12.3463 7.52738 12.4501 7.00001 12.4501C6.47264 12.4501 5.95043 12.3463 5.4632 12.1444C4.97597 11.9426 4.53326 11.6468 4.16035 11.2739C3.78745 10.901 3.49164 10.4583 3.28982 9.97107C3.088 9.48384 2.98413 8.96163 2.98413 8.43426C2.98413 7.77279 3.23254 7.1182 3.55783 6.71317C3.55783 7.09355 3.70894 7.45836 3.97791 7.72733C4.24688 7.9963 4.61169 8.14741 4.99207 8.14741Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5.70519 9.31137C6.13992 9.31137 6.55683 9.13868 6.86423 8.83128C7.17163 8.52389 7.34432 8.10696 7.34432 7.67224C7.34432 6.76744 7.01649 6.36094 6.68868 5.70528C5.98581 4.30022 6.54181 3.04726 7.99998 1.77136C8.32781 3.41049 9.31128 4.98406 10.6226 6.03311C11.9339 7.08215 12.5896 8.3279 12.5896 9.6392C12.5896 10.2419 12.4708 10.8387 12.2402 11.3956C12.0096 11.9524 11.6715 12.4583 11.2453 12.8845C10.8191 13.3107 10.3132 13.6487 9.75633 13.8794C9.1995 14.1101 8.60269 14.2287 7.99998 14.2287C7.39727 14.2287 6.80046 14.1101 6.24362 13.8794C5.68679 13.6487 5.18083 13.3107 4.75465 12.8845C4.32848 12.4583 3.99041 11.9524 3.75976 11.3956C3.52911 10.8387 3.4104 10.2419 3.4104 9.6392C3.4104 8.88324 3.6943 8.13513 4.06606 7.67224C4.06606 8.10696 4.23875 8.52389 4.54615 8.83128C4.85354 9.13868 5.27047 9.31137 5.70519 9.31137Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,13 +1 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_2595_5640)">
-<path d="M4.99207 8.14741C5.37246 8.14741 5.73726 7.9963 6.00623 7.72733C6.27521 7.45836 6.42631 7.09355 6.42631 6.71317C6.42631 5.92147 6.13946 5.56578 5.85262 4.99208C5.23761 3.76265 5.72411 2.66631 7.00001 1.5499C7.28686 2.98414 8.1474 4.36101 9.2948 5.27893C10.4422 6.19684 11.0159 7.28687 11.0159 8.43426C11.0159 8.96163 10.912 9.48384 10.7102 9.97107C10.5084 10.4583 10.2126 10.901 9.83967 11.2739C9.46676 11.6468 9.02405 11.9426 8.53682 12.1444C8.04959 12.3463 7.52738 12.4501 7.00001 12.4501C6.47264 12.4501 5.95043 12.3463 5.4632 12.1444C4.97597 11.9426 4.53326 11.6468 4.16035 11.2739C3.78745 10.901 3.49164 10.4583 3.28982 9.97107C3.088 9.48384 2.98413 8.96163 2.98413 8.43426C2.98413 7.77279 3.23254 7.1182 3.55783 6.71317C3.55783 7.09355 3.70894 7.45836 3.97791 7.72733C4.24688 7.9963 4.61169 8.14741 4.99207 8.14741Z" fill="black" fill-opacity="0.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M2 4C2.55228 4 3 3.55228 3 3C3 2.44772 2.55228 2 2 2C1.44772 2 1 2.44772 1 3C1 3.55228 1.44772 4 2 4Z" fill="black"/>
-<path d="M10 2C10.5523 2 11 1.55228 11 1C11 0.44772 10.5523 0 10 0C9.44772 0 9 0.44772 9 1C9 1.55228 9.44772 2 10 2Z" fill="black"/>
-<path d="M13 5C13.5522 5 14 4.55228 14 4C14 3.44772 13.5522 3 13 3C12.4478 3 12 3.44772 12 4C12 4.55228 12.4478 5 13 5Z" fill="black"/>
-</g>
-<defs>
-<clipPath id="clip0_2595_5640">
-<rect width="14" height="14" fill="white"/>
-</clipPath>
-</defs>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g fill="#000" clip-path="url(#a)"><path fill-opacity=".5" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M5.705 9.311a1.64 1.64 0 0 0 1.64-1.639c0-.905-.328-1.311-.656-1.967C5.986 4.3 6.542 3.047 8 1.771c.328 1.64 1.312 3.213 2.623 4.262 1.311 1.05 1.967 2.295 1.967 3.606a4.59 4.59 0 1 1-9.18 0c0-.756.285-1.504.656-1.967a1.64 1.64 0 0 0 1.64 1.64Z"/><path d="M2.286 4.571a1.143 1.143 0 1 0 0-2.285 1.143 1.143 0 0 0 0 2.285ZM11.429 2.286a1.143 1.143 0 1 0 0-2.286 1.143 1.143 0 0 0 0 2.286ZM14.857 5.714a1.143 1.143 0 1 0 0-2.286 1.143 1.143 0 0 0 0 2.286Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<rect opacity="0.3" x="2" y="2" width="12" height="12" rx="2" stroke="black" stroke-width="1.5"/>
+<rect opacity="0.3" x="2" y="2" width="12" height="12" rx="2" stroke="black" stroke-width="1.2"/>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<rect opacity="0.3" x="2" y="2" width="12" height="12" rx="2" stroke="black" stroke-width="1.5"/>
+<rect opacity="0.3" x="2" y="2" width="12" height="12" rx="2" stroke="black" stroke-width="1.2"/>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M12 5L14 8L12 11" stroke="black" stroke-width="1.5"/>
-<path d="M10 6.5L11 8L10 9.5" stroke="black" stroke-width="1.5"/>
-<path d="M7.5 8.9V11C5.43097 11 4.56903 11 2.5 11V10.4L7.5 5.6V5H2.5V7.1" stroke="black" stroke-width="1.5"/>
+<path d="M12 5L14 8L12 11" stroke="black" stroke-width="1.2"/>
+<path d="M10 6.5L11 8L10 9.5" stroke="black" stroke-width="1.2"/>
+<path d="M7.5 8.9V11C5.43097 11 4.56903 11 2.5 11V10.4L7.5 5.6V5H2.5V7.1" stroke="black" stroke-width="1.2"/>
</svg>
@@ -1,19 +0,0 @@
-<svg width="550" height="128" xmlns="http://www.w3.org/2000/svg">
- <defs>
- <pattern id="tilePattern" width="23" height="23" patternUnits="userSpaceOnUse">
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M12 5L14 8L12 11" stroke="black" stroke-width="1.5"/>
- <path d="M10 6.5L11 8L10 9.5" stroke="black" stroke-width="1.5"/>
- <path d="M7.5 8.9V11C5.43097 11 4.56903 11 2.5 11V10.4L7.5 5.6V5H2.5V7.1" stroke="black" stroke-width="1.5"/>
- </svg>
- </pattern>
- <linearGradient id="fade" y2="1" x2="0">
- <stop offset="0" stop-color="white" stop-opacity=".24"/>
- <stop offset="1" stop-color="white" stop-opacity="0"/>
- </linearGradient>
- <mask id="fadeMask" maskContentUnits="objectBoundingBox">
- <rect width="1" height="1" fill="url(#fade)"/>
- </mask>
- </defs>
- <rect width="100%" height="100%" fill="url(#tilePattern)" mask="url(#fadeMask)"/>
-</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M15 9.33333L14.5 9.66667L12.5 11L10.5 9.66667L10 9.33333" stroke="black" stroke-width="1.5"/>
-<path d="M12.5 11V4.5" stroke="black" stroke-width="1.5"/>
-<path d="M7.5 8.9V11C5.43097 11 4.56903 11 2.5 11V10.4L7.5 5.6V5H2.5V7.1" stroke="black" stroke-width="1.5"/>
+<path d="M15 9.33333L14.5 9.66667L12.5 11L10.5 9.66667L10 9.33333" stroke="black" stroke-width="1.2"/>
+<path d="M12.5 11V4.5" stroke="black" stroke-width="1.2"/>
+<path d="M7.5 8.9V11C5.43097 11 4.56903 11 2.5 11V10.4L7.5 5.6V5H2.5V7.1" stroke="black" stroke-width="1.2"/>
</svg>
@@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path opacity="0.6" d="M7.5 8.9V11C5.43097 11 4.56903 11 2.5 11V10.4L7.5 5.6V5H2.5V7.1" stroke="black" stroke-width="1.5"/>
-<path d="M14 8L10 12M14 12L10 8" stroke="black" stroke-width="1.5"/>
+<path opacity="0.6" d="M7.5 8.9V11C5.43097 11 4.56903 11 2.5 11V10.4L7.5 5.6V5H2.5V7.1" stroke="black" stroke-width="1.2"/>
+<path d="M14 8L10 12M14 12L10 8" stroke="black" stroke-width="1.2"/>
</svg>
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M10 6.66667L10.5 6.33333L12.5 5L14.5 6.33333L15 6.66667" stroke="black" stroke-width="1.5"/>
-<path d="M12.5 11V5" stroke="black" stroke-width="1.5"/>
-<path d="M7.5 8.9V11C5.43097 11 4.56903 11 2.5 11V10.4L7.5 5.6V5H2.5V7.1" stroke="black" stroke-width="1.5"/>
+<path d="M10 6.66667L10.5 6.33333L12.5 5L14.5 6.33333L15 6.66667" stroke="black" stroke-width="1.2"/>
+<path d="M12.5 11V5" stroke="black" stroke-width="1.2"/>
+<path d="M7.5 8.9V11C5.43097 11 4.56903 11 2.5 11V10.4L7.5 5.6V5H2.5V7.1" stroke="black" stroke-width="1.2"/>
</svg>
@@ -1,14 +0,0 @@
-<svg width="93" height="32" viewBox="0 0 93 32" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M9.03996 7.04962C8.00936 7.67635 7.30396 8.63219 7.30396 10.0149C7.30396 11.6908 7.72425 12.5893 8.2047 13.0744C8.68381 13.5581 9.40526 13.8149 10.4054 13.8149C11.815 13.8149 13.0291 13.5336 13.8802 12.9464C14.6756 12.3977 15.2708 11.5042 15.3438 9.96182C15.3991 8.79382 15.3678 8.01341 15.0568 7.45711C14.8094 7.01449 14.2326 6.47436 12.4901 6.27416C11.4684 6.15678 10.1114 6.39804 9.03996 7.04962ZM7.87312 5.13084C9.39147 4.2075 11.2531 3.87155 12.7464 4.04312C14.8843 4.28874 16.2844 5.05049 17.0171 6.36142C17.6863 7.55867 17.6384 8.98348 17.587 10.068C17.484 12.2439 16.5804 13.8118 15.1554 14.7949C13.7861 15.7396 12.0582 16.0606 10.4054 16.0606C9.04201 16.0606 7.65128 15.7069 6.60913 14.6547C5.56832 13.6038 5.05825 12.0408 5.05825 10.0149C5.05825 7.6958 6.3139 6.07903 7.87312 5.13084Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M13.983 18.2811C14.6595 18.2811 15.2079 18.8295 15.2079 19.506V22.16C15.2079 22.8365 14.6595 23.385 13.983 23.385C13.3065 23.385 12.758 22.8365 12.758 22.16V19.506C12.758 18.8295 13.3065 18.2811 13.983 18.2811Z" fill="white"/>
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -16,7 +16,6 @@
"up": "menu::SelectPrevious",
"enter": "menu::Confirm",
"ctrl-enter": "menu::SecondaryConfirm",
- "ctrl-escape": "menu::Cancel",
"ctrl-c": "menu::Cancel",
"escape": "menu::Cancel",
"alt-shift-enter": "menu::Restart",
@@ -138,7 +137,7 @@
"find": "buffer_search::Deploy",
"ctrl-f": "buffer_search::Deploy",
"ctrl-h": "buffer_search::DeployReplace",
- "ctrl->": "assistant::QuoteSelection",
+ "ctrl->": "agent::QuoteSelection",
"ctrl-<": "assistant::InsertIntoEditor",
"ctrl-alt-e": "editor::SelectEnclosingSymbol",
"ctrl-shift-backspace": "editor::GoToPreviousChange",
@@ -232,15 +231,16 @@
"ctrl-n": "agent::NewThread",
"ctrl-alt-n": "agent::NewTextThread",
"ctrl-shift-h": "agent::OpenHistory",
- "ctrl-alt-c": "agent::OpenConfiguration",
+ "ctrl-alt-c": "agent::OpenSettings",
"ctrl-alt-p": "agent::OpenRulesLibrary",
"ctrl-i": "agent::ToggleProfileSelector",
"ctrl-alt-/": "agent::ToggleModelSelector",
"ctrl-shift-a": "agent::ToggleContextPicker",
"ctrl-shift-j": "agent::ToggleNavigationMenu",
"ctrl-shift-i": "agent::ToggleOptionsMenu",
+ "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu",
"shift-alt-escape": "agent::ExpandMessageEditor",
- "ctrl->": "assistant::QuoteSelection",
+ "ctrl->": "agent::QuoteSelection",
"ctrl-alt-e": "agent::RemoveAllContext",
"ctrl-shift-e": "project_panel::ToggleFocus",
"ctrl-shift-enter": "agent::ContinueThread",
@@ -269,15 +269,15 @@
}
},
{
- "context": "AgentPanel && acp_thread",
+ "context": "AgentPanel && external_agent_thread",
"use_key_equivalents": true,
"bindings": {
- "ctrl-n": "agent::NewAcpThread",
+ "ctrl-n": "agent::NewExternalAgentThread",
"ctrl-alt-t": "agent::NewThread"
}
},
{
- "context": "MessageEditor > Editor",
+ "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send",
"bindings": {
"enter": "agent::Chat",
"ctrl-enter": "agent::ChatWithFollow",
@@ -287,6 +287,17 @@
"ctrl-shift-n": "agent::RejectAll"
}
},
+ {
+ "context": "MessageEditor && !Picker > Editor && use_modifier_to_send",
+ "bindings": {
+ "ctrl-enter": "agent::Chat",
+ "enter": "editor::Newline",
+ "ctrl-i": "agent::ToggleProfileSelector",
+ "shift-ctrl-r": "agent::OpenAgentDiff",
+ "ctrl-shift-y": "agent::KeepAll",
+ "ctrl-shift-n": "agent::RejectAll"
+ }
+ },
{
"context": "EditMessageEditor > Editor",
"bindings": {
@@ -315,13 +326,23 @@
}
},
{
- "context": "AcpThread > Editor",
+ "context": "AcpThread > Editor && !use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
- "up": "agent::PreviousHistoryMessage",
- "down": "agent::NextHistoryMessage",
- "shift-ctrl-r": "agent::OpenAgentDiff"
+ "shift-ctrl-r": "agent::OpenAgentDiff",
+ "ctrl-shift-y": "agent::KeepAll",
+ "ctrl-shift-n": "agent::RejectAll"
+ }
+ },
+ {
+ "context": "AcpThread > Editor && use_modifier_to_send",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-enter": "agent::Chat",
+ "shift-ctrl-r": "agent::OpenAgentDiff",
+ "ctrl-shift-y": "agent::KeepAll",
+ "ctrl-shift-n": "agent::RejectAll"
}
},
{
@@ -419,7 +440,7 @@
"ctrl-shift-pagedown": "pane::SwapItemRight",
"ctrl-f4": ["pane::CloseActiveItem", { "close_pinned": false }],
"ctrl-w": ["pane::CloseActiveItem", { "close_pinned": false }],
- "alt-ctrl-t": ["pane::CloseInactiveItems", { "close_pinned": false }],
+ "alt-ctrl-t": ["pane::CloseOtherItems", { "close_pinned": false }],
"alt-ctrl-shift-w": "workspace::CloseInactiveTabsAndPanes",
"ctrl-k e": ["pane::CloseItemsToTheLeft", { "close_pinned": false }],
"ctrl-k t": ["pane::CloseItemsToTheRight", { "close_pinned": false }],
@@ -472,9 +493,8 @@
"ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip
"ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch
"ctrl-k ctrl-i": "editor::Hover",
+ "ctrl-k ctrl-b": "editor::BlameHover",
"ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }],
- "ctrl-u": "editor::UndoSelection",
- "ctrl-shift-u": "editor::RedoSelection",
"f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
"shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
"f2": "editor::Rename",
@@ -485,7 +505,7 @@
"shift-f12": "editor::GoToImplementation",
"alt-ctrl-f12": "editor::GoToTypeDefinitionSplit",
"alt-shift-f12": "editor::FindAllReferences",
- "ctrl-m": "editor::MoveToEnclosingBracket",
+ "ctrl-m": "editor::MoveToEnclosingBracket", // from jetbrains
"ctrl-|": "editor::MoveToEnclosingBracket",
"ctrl-{": "editor::Fold",
"ctrl-}": "editor::UnfoldLines",
@@ -586,8 +606,9 @@
"ctrl-shift-f": "pane::DeploySearch",
"ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }],
"ctrl-shift-t": "pane::ReopenClosedItem",
- "ctrl-k ctrl-s": "zed::OpenKeymap",
+ "ctrl-k ctrl-s": "zed::OpenKeymapEditor",
"ctrl-k ctrl-t": "theme_selector::Toggle",
+ "ctrl-alt-super-p": "settings_profile_selector::Toggle",
"ctrl-t": "project_symbols::Toggle",
"ctrl-p": "file_finder::Toggle",
"ctrl-tab": "tab_switcher::Toggle",
@@ -652,6 +673,8 @@
{
"context": "Editor",
"bindings": {
+ "ctrl-u": "editor::UndoSelection",
+ "ctrl-shift-u": "editor::RedoSelection",
"ctrl-shift-j": "editor::JoinLines",
"ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart",
"ctrl-alt-h": "editor::DeleteToPreviousSubwordStart",
@@ -832,7 +855,8 @@
"ctrl-backspace": ["project_panel::Delete", { "skip_prompt": false }],
"ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }],
"alt-ctrl-r": "project_panel::RevealInFileManager",
- "ctrl-shift-enter": "project_panel::OpenWithSystem",
+ "ctrl-shift-enter": "workspace::OpenWithSystem",
+ "alt-d": "project_panel::CompareMarkedFiles",
"shift-find": "project_panel::NewSearchInDirectory",
"ctrl-alt-shift-f": "project_panel::NewSearchInDirectory",
"shift-down": "menu::SelectNext",
@@ -860,8 +884,6 @@
"tab": "git_panel::FocusEditor",
"shift-tab": "git_panel::FocusEditor",
"escape": "git_panel::ToggleFocus",
- "ctrl-enter": "git::Commit",
- "ctrl-shift-enter": "git::Amend",
"alt-enter": "menu::SecondaryConfirm",
"delete": ["git::RestoreFile", { "skip_prompt": false }],
"backspace": ["git::RestoreFile", { "skip_prompt": false }],
@@ -898,7 +920,9 @@
"ctrl-g backspace": "git::RestoreTrackedFiles",
"ctrl-g shift-backspace": "git::TrashUntrackedFiles",
"ctrl-space": "git::StageAll",
- "ctrl-shift-space": "git::UnstageAll"
+ "ctrl-shift-space": "git::UnstageAll",
+ "ctrl-enter": "git::Commit",
+ "ctrl-shift-enter": "git::Amend"
}
},
{
@@ -917,7 +941,7 @@
}
},
{
- "context": "GitPanel > Editor",
+ "context": "CommitEditor > Editor",
"bindings": {
"escape": "git_panel::FocusChanges",
"tab": "git_panel::FocusChanges",
@@ -963,9 +987,14 @@
"context": "CollabPanel && not_editing",
"bindings": {
"ctrl-backspace": "collab_panel::Remove",
- "space": "menu::Confirm",
- "ctrl-up": "collab_panel::MoveChannelUp",
- "ctrl-down": "collab_panel::MoveChannelDown"
+ "space": "menu::Confirm"
+ }
+ },
+ {
+ "context": "CollabPanel",
+ "bindings": {
+ "alt-up": "collab_panel::MoveChannelUp",
+ "alt-down": "collab_panel::MoveChannelDown"
}
},
{
@@ -1082,6 +1111,13 @@
"ctrl-enter": "menu::Confirm"
}
},
+ {
+ "context": "OnboardingAiConfigurationModal",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "menu::Cancel"
+ }
+ },
{
"context": "Diagnostics",
"use_key_equivalents": true,
@@ -1118,7 +1154,56 @@
"ctrl-f": "search::FocusSearch",
"alt-find": "keymap_editor::ToggleKeystrokeSearch",
"alt-ctrl-f": "keymap_editor::ToggleKeystrokeSearch",
- "alt-c": "keymap_editor::ToggleConflictFilter"
+ "alt-c": "keymap_editor::ToggleConflictFilter",
+ "enter": "keymap_editor::EditBinding",
+ "alt-enter": "keymap_editor::CreateBinding",
+ "ctrl-c": "keymap_editor::CopyAction",
+ "ctrl-shift-c": "keymap_editor::CopyContext",
+ "ctrl-t": "keymap_editor::ShowMatchingKeybinds"
+ }
+ },
+ {
+ "context": "KeystrokeInput",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "keystroke_input::StartRecording",
+ "escape escape escape": "keystroke_input::StopRecording",
+ "delete": "keystroke_input::ClearKeystrokes"
+ }
+ },
+ {
+ "context": "KeybindEditorModal",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-enter": "menu::Confirm",
+ "escape": "menu::Cancel"
+ }
+ },
+ {
+ "context": "KeybindEditorModal > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "up": "menu::SelectPrevious",
+ "down": "menu::SelectNext"
+ }
+ },
+ {
+ "context": "Onboarding",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-1": "onboarding::ActivateBasicsPage",
+ "ctrl-2": "onboarding::ActivateEditingPage",
+ "ctrl-3": "onboarding::ActivateAISetupPage",
+ "ctrl-enter": "onboarding::Finish",
+ "alt-shift-l": "onboarding::SignIn",
+ "alt-shift-a": "onboarding::OpenAccount"
+ }
+ },
+ {
+ "context": "InvalidBuffer",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-shift-enter": "workspace::OpenWithSystem"
}
}
]
@@ -162,7 +162,7 @@
"cmd-alt-f": "buffer_search::DeployReplace",
"cmd-alt-l": ["buffer_search::Deploy", { "selection_search_enabled": true }],
"cmd-e": ["buffer_search::Deploy", { "focus": false }],
- "cmd->": "assistant::QuoteSelection",
+ "cmd->": "agent::QuoteSelection",
"cmd-<": "assistant::InsertIntoEditor",
"cmd-alt-e": "editor::SelectEnclosingSymbol",
"alt-enter": "editor::OpenSelectionsInMultibuffer"
@@ -272,15 +272,16 @@
"cmd-n": "agent::NewThread",
"cmd-alt-n": "agent::NewTextThread",
"cmd-shift-h": "agent::OpenHistory",
- "cmd-alt-c": "agent::OpenConfiguration",
+ "cmd-alt-c": "agent::OpenSettings",
"cmd-alt-p": "agent::OpenRulesLibrary",
"cmd-i": "agent::ToggleProfileSelector",
"cmd-alt-/": "agent::ToggleModelSelector",
"cmd-shift-a": "agent::ToggleContextPicker",
"cmd-shift-j": "agent::ToggleNavigationMenu",
"cmd-shift-i": "agent::ToggleOptionsMenu",
+ "cmd-alt-shift-n": "agent::ToggleNewThreadMenu",
"shift-alt-escape": "agent::ExpandMessageEditor",
- "cmd->": "assistant::QuoteSelection",
+ "cmd->": "agent::QuoteSelection",
"cmd-alt-e": "agent::RemoveAllContext",
"cmd-shift-e": "project_panel::ToggleFocus",
"cmd-ctrl-b": "agent::ToggleBurnMode",
@@ -310,15 +311,15 @@
}
},
{
- "context": "AgentPanel && acp_thread",
+ "context": "AgentPanel && external_agent_thread",
"use_key_equivalents": true,
"bindings": {
- "cmd-n": "agent::NewAcpThread",
+ "cmd-n": "agent::NewExternalAgentThread",
"cmd-alt-t": "agent::NewThread"
}
},
{
- "context": "MessageEditor > Editor",
+ "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
@@ -329,6 +330,18 @@
"cmd-shift-n": "agent::RejectAll"
}
},
+ {
+ "context": "MessageEditor && !Picker > Editor && use_modifier_to_send",
+ "use_key_equivalents": true,
+ "bindings": {
+ "cmd-enter": "agent::Chat",
+ "enter": "editor::Newline",
+ "cmd-i": "agent::ToggleProfileSelector",
+ "shift-ctrl-r": "agent::OpenAgentDiff",
+ "cmd-shift-y": "agent::KeepAll",
+ "cmd-shift-n": "agent::RejectAll"
+ }
+ },
{
"context": "EditMessageEditor > Editor",
"use_key_equivalents": true,
@@ -366,13 +379,23 @@
}
},
{
- "context": "AcpThread > Editor",
+ "context": "AcpThread > Editor && !use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
- "up": "agent::PreviousHistoryMessage",
- "down": "agent::NextHistoryMessage",
- "shift-ctrl-r": "agent::OpenAgentDiff"
+ "shift-ctrl-r": "agent::OpenAgentDiff",
+ "cmd-shift-y": "agent::KeepAll",
+ "cmd-shift-n": "agent::RejectAll"
+ }
+ },
+ {
+ "context": "AcpThread > Editor && use_modifier_to_send",
+ "use_key_equivalents": true,
+ "bindings": {
+ "cmd-enter": "agent::Chat",
+ "shift-ctrl-r": "agent::OpenAgentDiff",
+ "cmd-shift-y": "agent::KeepAll",
+ "cmd-shift-n": "agent::RejectAll"
}
},
{
@@ -477,7 +500,7 @@
"ctrl-shift-pageup": "pane::SwapItemLeft",
"ctrl-shift-pagedown": "pane::SwapItemRight",
"cmd-w": ["pane::CloseActiveItem", { "close_pinned": false }],
- "alt-cmd-t": ["pane::CloseInactiveItems", { "close_pinned": false }],
+ "alt-cmd-t": ["pane::CloseOtherItems", { "close_pinned": false }],
"ctrl-alt-cmd-w": "workspace::CloseInactiveTabsAndPanes",
"cmd-k e": ["pane::CloseItemsToTheLeft", { "close_pinned": false }],
"cmd-k t": ["pane::CloseItemsToTheRight", { "close_pinned": false }],
@@ -525,9 +548,8 @@
"ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToPreviousFindMatch
"cmd-k ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch
"cmd-k cmd-i": "editor::Hover",
+ "cmd-k cmd-b": "editor::BlameHover",
"cmd-/": ["editor::ToggleComments", { "advance_downwards": false }],
- "cmd-u": "editor::UndoSelection",
- "cmd-shift-u": "editor::RedoSelection",
"f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
"shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
"f2": "editor::Rename",
@@ -538,7 +560,7 @@
"alt-cmd-f12": "editor::GoToTypeDefinitionSplit",
"alt-shift-f12": "editor::FindAllReferences",
"cmd-|": "editor::MoveToEnclosingBracket",
- "ctrl-m": "editor::MoveToEnclosingBracket",
+ "ctrl-m": "editor::MoveToEnclosingBracket", // From Jetbrains
"alt-cmd-[": "editor::Fold",
"alt-cmd-]": "editor::UnfoldLines",
"cmd-k cmd-l": "editor::ToggleFold",
@@ -652,8 +674,9 @@
"cmd-shift-f": "pane::DeploySearch",
"cmd-shift-h": ["pane::DeploySearch", { "replace_enabled": true }],
"cmd-shift-t": "pane::ReopenClosedItem",
- "cmd-k cmd-s": "zed::OpenKeymap",
+ "cmd-k cmd-s": "zed::OpenKeymapEditor",
"cmd-k cmd-t": "theme_selector::Toggle",
+ "ctrl-alt-cmd-p": "settings_profile_selector::Toggle",
"cmd-t": "project_symbols::Toggle",
"cmd-p": "file_finder::Toggle",
"ctrl-tab": "tab_switcher::Toggle",
@@ -714,6 +737,8 @@
"context": "Editor",
"use_key_equivalents": true,
"bindings": {
+ "cmd-u": "editor::UndoSelection",
+ "cmd-shift-u": "editor::RedoSelection",
"ctrl-j": "editor::JoinLines",
"ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart",
"ctrl-alt-h": "editor::DeleteToPreviousSubwordStart",
@@ -890,7 +915,8 @@
"cmd-backspace": ["project_panel::Trash", { "skip_prompt": true }],
"cmd-delete": ["project_panel::Delete", { "skip_prompt": false }],
"alt-cmd-r": "project_panel::RevealInFileManager",
- "ctrl-shift-enter": "project_panel::OpenWithSystem",
+ "ctrl-shift-enter": "workspace::OpenWithSystem",
+ "alt-d": "project_panel::CompareMarkedFiles",
"cmd-alt-backspace": ["project_panel::Delete", { "skip_prompt": false }],
"cmd-alt-shift-f": "project_panel::NewSearchInDirectory",
"shift-down": "menu::SelectNext",
@@ -937,8 +963,6 @@
"tab": "git_panel::FocusEditor",
"shift-tab": "git_panel::FocusEditor",
"escape": "git_panel::ToggleFocus",
- "cmd-enter": "git::Commit",
- "cmd-shift-enter": "git::Amend",
"backspace": ["git::RestoreFile", { "skip_prompt": false }],
"delete": ["git::RestoreFile", { "skip_prompt": false }],
"cmd-backspace": ["git::RestoreFile", { "skip_prompt": true }],
@@ -963,7 +987,7 @@
}
},
{
- "context": "GitPanel > Editor",
+ "context": "CommitEditor > Editor",
"use_key_equivalents": true,
"bindings": {
"enter": "editor::Newline",
@@ -988,7 +1012,9 @@
"ctrl-g backspace": "git::RestoreTrackedFiles",
"ctrl-g shift-backspace": "git::TrashUntrackedFiles",
"cmd-ctrl-y": "git::StageAll",
- "cmd-ctrl-shift-y": "git::UnstageAll"
+ "cmd-ctrl-shift-y": "git::UnstageAll",
+ "cmd-enter": "git::Commit",
+ "cmd-shift-enter": "git::Amend"
}
},
{
@@ -1024,9 +1050,15 @@
"use_key_equivalents": true,
"bindings": {
"ctrl-backspace": "collab_panel::Remove",
- "space": "menu::Confirm",
- "cmd-up": "collab_panel::MoveChannelUp",
- "cmd-down": "collab_panel::MoveChannelDown"
+ "space": "menu::Confirm"
+ }
+ },
+ {
+ "context": "CollabPanel",
+ "use_key_equivalents": true,
+ "bindings": {
+ "alt-up": "collab_panel::MoveChannelUp",
+ "alt-down": "collab_panel::MoveChannelDown"
}
},
{
@@ -1105,7 +1137,9 @@
"ctrl-enter": "assistant::InlineAssist",
"ctrl-_": null, // emacs undo
// Some nice conveniences
- "cmd-backspace": ["terminal::SendText", "\u0015"],
+ "cmd-backspace": ["terminal::SendText", "\u0015"], // ctrl-u: clear line
+ "alt-delete": ["terminal::SendText", "\u001bd"], // alt-d: delete word forward
+ "cmd-delete": ["terminal::SendText", "\u000b"], // ctrl-k: delete to end of line
"cmd-right": ["terminal::SendText", "\u0005"],
"cmd-left": ["terminal::SendText", "\u0001"],
// Terminal.app compatibility
@@ -1180,6 +1214,13 @@
"cmd-enter": "menu::Confirm"
}
},
+ {
+ "context": "OnboardingAiConfigurationModal",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "menu::Cancel"
+ }
+ },
{
"context": "Diagnostics",
"use_key_equivalents": true,
@@ -1214,8 +1255,58 @@
"context": "KeymapEditor",
"use_key_equivalents": true,
"bindings": {
+ "cmd-f": "search::FocusSearch",
"cmd-alt-f": "keymap_editor::ToggleKeystrokeSearch",
- "cmd-alt-c": "keymap_editor::ToggleConflictFilter"
+ "cmd-alt-c": "keymap_editor::ToggleConflictFilter",
+ "enter": "keymap_editor::EditBinding",
+ "alt-enter": "keymap_editor::CreateBinding",
+ "cmd-c": "keymap_editor::CopyAction",
+ "cmd-shift-c": "keymap_editor::CopyContext",
+ "cmd-t": "keymap_editor::ShowMatchingKeybinds"
+ }
+ },
+ {
+ "context": "KeystrokeInput",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "keystroke_input::StartRecording",
+ "escape escape escape": "keystroke_input::StopRecording",
+ "delete": "keystroke_input::ClearKeystrokes"
+ }
+ },
+ {
+ "context": "KeybindEditorModal",
+ "use_key_equivalents": true,
+ "bindings": {
+ "cmd-enter": "menu::Confirm",
+ "escape": "menu::Cancel"
+ }
+ },
+ {
+ "context": "KeybindEditorModal > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "up": "menu::SelectPrevious",
+ "down": "menu::SelectNext"
+ }
+ },
+ {
+ "context": "Onboarding",
+ "use_key_equivalents": true,
+ "bindings": {
+ "cmd-1": "onboarding::ActivateBasicsPage",
+ "cmd-2": "onboarding::ActivateEditingPage",
+ "cmd-3": "onboarding::ActivateAISetupPage",
+ "cmd-escape": "onboarding::Finish",
+ "alt-tab": "onboarding::SignIn",
+ "alt-shift-a": "onboarding::OpenAccount"
+ }
+ },
+ {
+ "context": "InvalidBuffer",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-shift-enter": "workspace::OpenWithSystem"
}
}
]
@@ -13,9 +13,9 @@
}
},
{
- "context": "Editor && vim_mode == insert && !menu",
+ "context": "Editor && vim_mode == insert",
"bindings": {
- // "j k": "vim::SwitchToNormalMode"
+ // "j k": "vim::NormalBefore"
}
}
]
@@ -8,7 +8,7 @@
"ctrl-shift-i": "agent::ToggleFocus",
"ctrl-l": "agent::ToggleFocus",
"ctrl-shift-l": "agent::ToggleFocus",
- "ctrl-shift-j": "agent::OpenConfiguration"
+ "ctrl-shift-j": "agent::OpenSettings"
}
},
{
@@ -17,8 +17,8 @@
"bindings": {
"ctrl-i": "agent::ToggleFocus",
"ctrl-shift-i": "agent::ToggleFocus",
- "ctrl-shift-l": "assistant::QuoteSelection", // In cursor uses "Ask" mode
- "ctrl-l": "assistant::QuoteSelection", // In cursor uses "Agent" mode
+ "ctrl-shift-l": "agent::QuoteSelection", // In cursor uses "Ask" mode
+ "ctrl-l": "agent::QuoteSelection", // In cursor uses "Agent" mode
"ctrl-k": "assistant::InlineAssist",
"ctrl-shift-k": "assistant::InsertIntoEditor"
}
@@ -114,7 +114,7 @@
"ctrl-x o": "workspace::ActivateNextPane", // other-window
"ctrl-x k": "pane::CloseActiveItem", // kill-buffer
"ctrl-x 0": "pane::CloseActiveItem", // delete-window
- "ctrl-x 1": "pane::CloseInactiveItems", // delete-other-windows
+ "ctrl-x 1": "pane::CloseOtherItems", // delete-other-windows
"ctrl-x 2": "pane::SplitDown", // split-window-below
"ctrl-x 3": "pane::SplitRight", // split-window-right
"ctrl-x ctrl-f": "file_finder::Toggle", // find-file
@@ -4,6 +4,7 @@
"ctrl-alt-s": "zed::OpenSettings",
"ctrl-{": "pane::ActivatePreviousItem",
"ctrl-}": "pane::ActivateNextItem",
+ "shift-escape": null, // Unmap workspace::zoom
"ctrl-f2": "debugger::Stop",
"f6": "debugger::Pause",
"f7": "debugger::StepInto",
@@ -44,8 +45,8 @@
"ctrl-alt-right": "pane::GoForward",
"alt-f7": "editor::FindAllReferences",
"ctrl-alt-f7": "editor::FindAllReferences",
- // "ctrl-b": "editor::GoToDefinition", // Conflicts with workspace::ToggleLeftDock
- // "ctrl-alt-b": "editor::GoToDefinitionSplit", // Conflicts with workspace::ToggleLeftDock
+ "ctrl-b": "editor::GoToDefinition", // Conflicts with workspace::ToggleLeftDock
+ "ctrl-alt-b": "editor::GoToDefinitionSplit", // Conflicts with workspace::ToggleRightDock
"ctrl-shift-b": "editor::GoToTypeDefinition",
"ctrl-alt-shift-b": "editor::GoToTypeDefinitionSplit",
"f2": "editor::GoToDiagnostic",
@@ -66,22 +67,66 @@
"context": "Editor && mode == full",
"bindings": {
"ctrl-f12": "outline::Toggle",
- "alt-7": "outline::Toggle",
+ "ctrl-r": ["buffer_search::Deploy", { "replace_enabled": true }],
"ctrl-shift-n": "file_finder::Toggle",
"ctrl-g": "go_to_line::Toggle",
"alt-enter": "editor::ToggleCodeActions"
}
},
+ {
+ "context": "BufferSearchBar",
+ "bindings": {
+ "shift-enter": "search::SelectPreviousMatch"
+ }
+ },
+ {
+ "context": "BufferSearchBar || ProjectSearchBar",
+ "bindings": {
+ "alt-c": "search::ToggleCaseSensitive",
+ "alt-e": "search::ToggleSelection",
+ "alt-x": "search::ToggleRegex",
+ "alt-w": "search::ToggleWholeWord"
+ }
+ },
{
"context": "Workspace",
"bindings": {
+ "ctrl-shift-f12": "workspace::CloseAllDocks",
+ "ctrl-shift-r": ["pane::DeploySearch", { "replace_enabled": true }],
+ "alt-shift-f10": "task::Spawn",
+ "ctrl-e": "file_finder::Toggle",
+ // "ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor
"ctrl-shift-n": "file_finder::Toggle",
"ctrl-shift-a": "command_palette::Toggle",
"shift shift": "command_palette::Toggle",
"ctrl-alt-shift-n": "project_symbols::Toggle",
- "alt-1": "workspace::ToggleLeftDock",
- "ctrl-e": "tab_switcher::Toggle",
- "alt-6": "diagnostics::Deploy"
+ "alt-0": "git_panel::ToggleFocus",
+ "alt-1": "project_panel::ToggleFocus",
+ "alt-5": "debug_panel::ToggleFocus",
+ "alt-6": "diagnostics::Deploy",
+ "alt-7": "outline_panel::ToggleFocus"
+ }
+ },
+ {
+ "context": "Pane", // this is to override the default Pane mappings to switch tabs
+ "bindings": {
+ "alt-1": "project_panel::ToggleFocus",
+ "alt-2": null, // Bookmarks (left dock)
+ "alt-3": null, // Find Panel (bottom dock)
+ "alt-4": null, // Run Panel (bottom dock)
+ "alt-5": "debug_panel::ToggleFocus",
+ "alt-6": "diagnostics::Deploy",
+ "alt-7": "outline_panel::ToggleFocus",
+ "alt-8": null, // Services (bottom dock)
+ "alt-9": null, // Git History (bottom dock)
+ "alt-0": "git_panel::ToggleFocus"
+ }
+ },
+ {
+ "context": "Workspace || Editor",
+ "bindings": {
+ "alt-f12": "terminal_panel::ToggleFocus",
+ "ctrl-shift-k": "git::Push"
}
},
{
@@ -95,10 +140,36 @@
"context": "ProjectPanel",
"bindings": {
"enter": "project_panel::Open",
+ "ctrl-shift-f": "project_panel::NewSearchInDirectory",
"backspace": ["project_panel::Trash", { "skip_prompt": false }],
"delete": ["project_panel::Trash", { "skip_prompt": false }],
"shift-delete": ["project_panel::Delete", { "skip_prompt": false }],
"shift-f6": "project_panel::Rename"
}
+ },
+ {
+ "context": "Terminal",
+ "bindings": {
+ "ctrl-shift-t": "workspace::NewTerminal",
+ "alt-f12": "workspace::CloseActiveDock",
+ "alt-left": "pane::ActivatePreviousItem",
+ "alt-right": "pane::ActivateNextItem",
+ "ctrl-up": "terminal::ScrollLineUp",
+ "ctrl-down": "terminal::ScrollLineDown",
+ "shift-pageup": "terminal::ScrollPageUp",
+ "shift-pagedown": "terminal::ScrollPageDown"
+ }
+ },
+ { "context": "GitPanel", "bindings": { "alt-0": "workspace::CloseActiveDock" } },
+ { "context": "ProjectPanel", "bindings": { "alt-1": "workspace::CloseActiveDock" } },
+ { "context": "DebugPanel", "bindings": { "alt-5": "workspace::CloseActiveDock" } },
+ { "context": "Diagnostics > Editor", "bindings": { "alt-6": "pane::CloseActiveItem" } },
+ { "context": "OutlinePanel", "bindings": { "alt-7": "workspace::CloseActiveDock" } },
+ {
+ "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)",
+ "bindings": {
+ "escape": "editor::ToggleFocus",
+ "shift-escape": "workspace::CloseActiveDock"
+ }
}
]
@@ -8,7 +8,7 @@
"cmd-shift-i": "agent::ToggleFocus",
"cmd-l": "agent::ToggleFocus",
"cmd-shift-l": "agent::ToggleFocus",
- "cmd-shift-j": "agent::OpenConfiguration"
+ "cmd-shift-j": "agent::OpenSettings"
}
},
{
@@ -17,8 +17,8 @@
"bindings": {
"cmd-i": "agent::ToggleFocus",
"cmd-shift-i": "agent::ToggleFocus",
- "cmd-shift-l": "assistant::QuoteSelection", // In cursor uses "Ask" mode
- "cmd-l": "assistant::QuoteSelection", // In cursor uses "Agent" mode
+ "cmd-shift-l": "agent::QuoteSelection", // In cursor uses "Ask" mode
+ "cmd-l": "agent::QuoteSelection", // In cursor uses "Agent" mode
"cmd-k": "assistant::InlineAssist",
"cmd-shift-k": "assistant::InsertIntoEditor"
}
@@ -114,7 +114,7 @@
"ctrl-x o": "workspace::ActivateNextPane", // other-window
"ctrl-x k": "pane::CloseActiveItem", // kill-buffer
"ctrl-x 0": "pane::CloseActiveItem", // delete-window
- "ctrl-x 1": "pane::CloseInactiveItems", // delete-other-windows
+ "ctrl-x 1": "pane::CloseOtherItems", // delete-other-windows
"ctrl-x 2": "pane::SplitDown", // split-window-below
"ctrl-x 3": "pane::SplitRight", // split-window-right
"ctrl-x ctrl-f": "file_finder::Toggle", // find-file
@@ -3,6 +3,8 @@
"bindings": {
"cmd-{": "pane::ActivatePreviousItem",
"cmd-}": "pane::ActivateNextItem",
+ "cmd-0": "git_panel::ToggleFocus", // overrides `cmd-0` zoom reset
+ "shift-escape": null, // Unmap workspace::zoom
"ctrl-f2": "debugger::Stop",
"f6": "debugger::Pause",
"f7": "debugger::StepInto",
@@ -63,28 +65,70 @@
"context": "Editor && mode == full",
"bindings": {
"cmd-f12": "outline::Toggle",
- "cmd-7": "outline::Toggle",
+ "cmd-r": ["buffer_search::Deploy", { "replace_enabled": true }],
"cmd-shift-o": "file_finder::Toggle",
"cmd-l": "go_to_line::Toggle",
"alt-enter": "editor::ToggleCodeActions"
}
},
{
- "context": "BufferSearchBar > Editor",
+ "context": "BufferSearchBar",
"bindings": {
"shift-enter": "search::SelectPreviousMatch"
}
},
+ {
+ "context": "BufferSearchBar || ProjectSearchBar",
+ "bindings": {
+ "alt-c": "search::ToggleCaseSensitive",
+ "alt-e": "search::ToggleSelection",
+ "alt-x": "search::ToggleRegex",
+ "alt-w": "search::ToggleWholeWord",
+ "ctrl-alt-c": "search::ToggleCaseSensitive",
+ "ctrl-alt-e": "search::ToggleSelection",
+ "ctrl-alt-w": "search::ToggleWholeWord",
+ "ctrl-alt-x": "search::ToggleRegex"
+ }
+ },
{
"context": "Workspace",
"bindings": {
+ "cmd-shift-f12": "workspace::CloseAllDocks",
+ "cmd-shift-r": ["pane::DeploySearch", { "replace_enabled": true }],
+ "ctrl-alt-r": "task::Spawn",
+ "cmd-e": "file_finder::Toggle",
+ // "cmd-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor
"cmd-shift-o": "file_finder::Toggle",
"cmd-shift-a": "command_palette::Toggle",
"shift shift": "command_palette::Toggle",
"cmd-alt-o": "project_symbols::Toggle", // JetBrains: Go to Symbol
"cmd-o": "project_symbols::Toggle", // JetBrains: Go to Class
- "cmd-1": "workspace::ToggleLeftDock",
- "cmd-6": "diagnostics::Deploy"
+ "cmd-1": "project_panel::ToggleFocus",
+ "cmd-5": "debug_panel::ToggleFocus",
+ "cmd-6": "diagnostics::Deploy",
+ "cmd-7": "outline_panel::ToggleFocus"
+ }
+ },
+ {
+ "context": "Pane", // this is to override the default Pane mappings to switch tabs
+ "bindings": {
+ "cmd-1": "project_panel::ToggleFocus",
+ "cmd-2": null, // Bookmarks (left dock)
+ "cmd-3": null, // Find Panel (bottom dock)
+ "cmd-4": null, // Run Panel (bottom dock)
+ "cmd-5": "debug_panel::ToggleFocus",
+ "cmd-6": "diagnostics::Deploy",
+ "cmd-7": "outline_panel::ToggleFocus",
+ "cmd-8": null, // Services (bottom dock)
+ "cmd-9": null, // Git History (bottom dock)
+ "cmd-0": "git_panel::ToggleFocus"
+ }
+ },
+ {
+ "context": "Workspace || Editor",
+ "bindings": {
+ "alt-f12": "terminal_panel::ToggleFocus",
+ "cmd-shift-k": "git::Push"
}
},
{
@@ -98,11 +142,35 @@
"context": "ProjectPanel",
"bindings": {
"enter": "project_panel::Open",
+ "cmd-shift-f": "project_panel::NewSearchInDirectory",
"cmd-backspace": ["project_panel::Trash", { "skip_prompt": false }],
"backspace": ["project_panel::Trash", { "skip_prompt": false }],
"delete": ["project_panel::Trash", { "skip_prompt": false }],
"shift-delete": ["project_panel::Delete", { "skip_prompt": false }],
"shift-f6": "project_panel::Rename"
}
+ },
+ {
+ "context": "Terminal",
+ "bindings": {
+ "cmd-t": "workspace::NewTerminal",
+ "alt-f12": "workspace::CloseActiveDock",
+ "cmd-up": "terminal::ScrollLineUp",
+ "cmd-down": "terminal::ScrollLineDown",
+ "shift-pageup": "terminal::ScrollPageUp",
+ "shift-pagedown": "terminal::ScrollPageDown"
+ }
+ },
+ { "context": "GitPanel", "bindings": { "cmd-0": "workspace::CloseActiveDock" } },
+ { "context": "ProjectPanel", "bindings": { "cmd-1": "workspace::CloseActiveDock" } },
+ { "context": "DebugPanel", "bindings": { "cmd-5": "workspace::CloseActiveDock" } },
+ { "context": "Diagnostics > Editor", "bindings": { "cmd-6": "pane::CloseActiveItem" } },
+ { "context": "OutlinePanel", "bindings": { "cmd-7": "workspace::CloseActiveDock" } },
+ {
+ "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)",
+ "bindings": {
+ "escape": "editor::ToggleFocus",
+ "shift-escape": "workspace::CloseActiveDock"
+ }
}
]
@@ -6,7 +6,7 @@
}
},
{
- "context": "Editor",
+ "context": "Editor && mode == full",
"bindings": {
"cmd-l": "go_to_line::Toggle",
"ctrl-shift-d": "editor::DuplicateLineDown",
@@ -15,7 +15,12 @@
"cmd-enter": "editor::NewlineBelow",
"cmd-alt-enter": "editor::NewlineAbove",
"cmd-shift-l": "editor::SelectLine",
- "cmd-shift-t": "outline::Toggle",
+ "cmd-shift-t": "outline::Toggle"
+ }
+ },
+ {
+ "context": "Editor",
+ "bindings": {
"alt-backspace": "editor::DeleteToPreviousWordStart",
"alt-shift-backspace": "editor::DeleteToNextWordEnd",
"alt-delete": "editor::DeleteToNextWordEnd",
@@ -39,10 +44,6 @@
"ctrl-_": "editor::ConvertToSnakeCase"
}
},
- {
- "context": "Editor && mode == full",
- "bindings": {}
- },
{
"context": "BufferSearchBar",
"bindings": {
@@ -58,6 +58,8 @@
"[ space": "vim::InsertEmptyLineAbove",
"[ e": "editor::MoveLineUp",
"] e": "editor::MoveLineDown",
+ "[ f": "workspace::FollowNextCollaborator",
+ "] f": "workspace::FollowNextCollaborator",
// Word motions
"w": "vim::NextWordStart",
@@ -124,6 +126,7 @@
"g r a": "editor::ToggleCodeActions",
"g g": "vim::StartOfDocument",
"g h": "editor::Hover",
+ "g B": "editor::BlameHover",
"g t": "pane::ActivateNextItem",
"g shift-t": "pane::ActivatePreviousItem",
"g d": "editor::GoToDefinition",
@@ -219,6 +222,8 @@
{
"context": "vim_mode == normal",
"bindings": {
+ "i": "vim::InsertBefore",
+ "a": "vim::InsertAfter",
"ctrl-[": "editor::Cancel",
":": "command_palette::Toggle",
"c": "vim::PushChange",
@@ -330,10 +335,14 @@
"ctrl-x ctrl-c": "editor::ShowEditPrediction", // zed specific
"ctrl-x ctrl-l": "editor::ToggleCodeActions", // zed specific
"ctrl-x ctrl-z": "editor::Cancel",
+ "ctrl-x ctrl-e": "vim::LineDown",
+ "ctrl-x ctrl-y": "vim::LineUp",
"ctrl-w": "editor::DeleteToPreviousWordStart",
"ctrl-u": "editor::DeleteToBeginningOfLine",
"ctrl-t": "vim::Indent",
"ctrl-d": "vim::Outdent",
+ "ctrl-y": "vim::InsertFromAbove",
+ "ctrl-e": "vim::InsertFromBelow",
"ctrl-k": ["vim::PushDigraph", {}],
"ctrl-v": ["vim::PushLiteral", {}],
"ctrl-shift-v": "editor::Paste", // note: this is *very* similar to ctrl-v in vim, but ctrl-shift-v on linux is the typical shortcut for paste when ctrl-v is already in use.
@@ -352,9 +361,7 @@
"shift-d": "vim::DeleteToEndOfLine",
"shift-j": "vim::JoinLines",
"shift-y": "vim::YankLine",
- "i": "vim::InsertBefore",
"shift-i": "vim::InsertFirstNonWhitespace",
- "a": "vim::InsertAfter",
"shift-a": "vim::InsertEndOfLine",
"o": "vim::InsertLineBelow",
"shift-o": "vim::InsertLineAbove",
@@ -376,13 +383,16 @@
{
"context": "vim_mode == helix_normal && !menu",
"bindings": {
+ "i": "vim::HelixInsert",
+ "a": "vim::HelixAppend",
"ctrl-[": "editor::Cancel",
+ ";": "vim::HelixCollapseSelection",
":": "command_palette::Toggle",
"left": "vim::WrappingLeft",
"right": "vim::WrappingRight",
"h": "vim::WrappingLeft",
"l": "vim::WrappingRight",
- "y": "editor::Copy",
+ "y": "vim::HelixYank",
"alt-;": "vim::OtherEnd",
"ctrl-r": "vim::Redo",
"f": ["vim::PushFindForward", { "before": false, "multiline": true }],
@@ -399,6 +409,7 @@
"g w": "vim::PushRewrap",
"insert": "vim::InsertBefore",
"alt-.": "vim::RepeatFind",
+ "alt-s": ["editor::SplitSelectionIntoLines", { "keep_selections": true }],
// tree-sitter related commands
"[ x": "editor::SelectLargerSyntaxNode",
"] x": "editor::SelectSmallerSyntaxNode",
@@ -723,7 +734,7 @@
}
},
{
- "context": "AgentPanel || GitPanel || ProjectPanel || CollabPanel || OutlinePanel || ChatPanel || VimControl || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView || DebugPanel",
+ "context": "VimControl || !Editor && !Terminal",
"bindings": {
// window related commands (ctrl-w X)
"ctrl-w": null,
@@ -781,7 +792,7 @@
}
},
{
- "context": "ChangesList || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView || Welcome",
+ "context": "!Editor && !Terminal",
"bindings": {
":": "command_palette::Toggle",
"g /": "pane::DeploySearch"
@@ -808,7 +819,8 @@
"v": "project_panel::OpenPermanent",
"p": "project_panel::Open",
"x": "project_panel::RevealInFileManager",
- "s": "project_panel::OpenWithSystem",
+ "s": "workspace::OpenWithSystem",
+ "z d": "project_panel::CompareMarkedFiles",
"] c": "project_panel::SelectNextGitEntry",
"[ c": "project_panel::SelectPrevGitEntry",
"] d": "project_panel::SelectNextDiagnostic",
@@ -857,6 +869,14 @@
"shift-n": null
}
},
+ {
+ "context": "Picker > Editor",
+ "bindings": {
+ "ctrl-h": "editor::Backspace",
+ "ctrl-u": "editor::DeleteToBeginningOfLine",
+ "ctrl-w": "editor::DeleteToPreviousWordStart"
+ }
+ },
{
"context": "GitCommit > Editor && VimControl && vim_mode == normal",
"bindings": {
@@ -28,7 +28,9 @@
"edit_prediction_provider": "zed"
},
// The name of a font to use for rendering text in the editor
- "buffer_font_family": "Zed Plex Mono",
+ // ".ZedMono" currently aliases to Lilex
+ // but this may change in the future.
+ "buffer_font_family": ".ZedMono",
// Set the buffer text's font fallbacks, this will be merged with
// the platform's default fallbacks.
"buffer_font_fallbacks": null,
@@ -54,7 +56,9 @@
"buffer_line_height": "comfortable",
// The name of a font to use for rendering text in the UI
// You can set this to ".SystemUIFont" to use the system font
- "ui_font_family": "Zed Plex Sans",
+ // ".ZedSans" currently aliases to "IBM Plex Sans", but this may
+ // change in the future
+ "ui_font_family": ".ZedSans",
// Set the UI's font fallbacks, this will be merged with the platform's
// default font fallbacks.
"ui_font_fallbacks": null,
@@ -67,8 +71,8 @@
"ui_font_weight": 400,
// The default font size for text in the UI
"ui_font_size": 16,
- // The default font size for text in the agent panel
- "agent_font_size": 16,
+ // The default font size for text in the agent panel. Falls back to the UI font size if unset.
+ "agent_font_size": null,
// How much to fade out unused code.
"unnecessary_code_fade": 0.3,
// Active pane styling settings.
@@ -82,10 +86,10 @@
// Layout mode of the bottom dock. Defaults to "contained"
// choices: contained, full, left_aligned, right_aligned
"bottom_dock_layout": "contained",
- // The direction that you want to split panes horizontally. Defaults to "up"
- "pane_split_direction_horizontal": "up",
- // The direction that you want to split panes horizontally. Defaults to "left"
- "pane_split_direction_vertical": "left",
+ // The direction that you want to split panes horizontally. Defaults to "down"
+ "pane_split_direction_horizontal": "down",
+ // The direction that you want to split panes vertically. Defaults to "right"
+ "pane_split_direction_vertical": "right",
// Centered layout related settings.
"centered_layout": {
// The relative width of the left padding of the central pane from the
@@ -197,6 +201,8 @@
// "inline"
// 3. Place snippets at the bottom of the completion list:
// "bottom"
+ // 4. Do not show snippets in the completion list:
+ // "none"
"snippet_sort_order": "inline",
// How to highlight the current line in the editor.
//
@@ -280,6 +286,8 @@
// bracket, brace, single or double quote characters.
// For example, when you select text and type (, Zed will surround the text with ().
"use_auto_surround": true,
+ /// Whether indentation should be adjusted based on the context whilst typing.
+ "auto_indent": true,
// Whether indentation of pasted content should be adjusted based on the context.
"auto_indent_on_paste": true,
// Controls how the editor handles the autoclosed characters.
@@ -594,6 +602,8 @@
// when a corresponding project entry becomes active.
// Gitignored entries are never auto revealed.
"auto_reveal_entries": true,
+ // Whether the project panel should open on startup.
+ "starts_open": true,
// Whether to fold directories automatically and show compact folders
// (e.g. "a/b/c" ) when a directory has only one subdirectory inside.
"auto_fold_dirs": true,
@@ -689,7 +699,10 @@
// 5. Never show the scrollbar:
// "never"
"show": null
- }
+ },
+ // Default depth to expand outline items in the current file.
+ // Set to 0 to collapse all items that have children, 1 or higher to collapse items at that depth or deeper.
+ "expand_outlines_with_depth": 100
},
"collaboration_panel": {
// Whether to show the collaboration panel button in the status bar.
@@ -704,7 +717,7 @@
// Can be 'never', 'always', or 'when_in_call',
// or a boolean (interpreted as 'never'/'always').
"button": "when_in_call",
- // Where to the chat panel. Can be 'left' or 'right'.
+ // Where to dock the chat panel. Can be 'left' or 'right'.
"dock": "right",
// Default width of the chat panel.
"default_width": 240
@@ -712,7 +725,7 @@
"git_panel": {
// Whether to show the git panel button in the status bar.
"button": true,
- // Where to show the git panel. Can be 'left' or 'right'.
+ // Where to dock the git panel. Can be 'left' or 'right'.
"dock": "left",
// Default width of the git panel.
"default_width": 360,
@@ -817,7 +830,7 @@
"edit_file": true,
"fetch": true,
"list_directory": true,
- "project_notifications": true,
+ "project_notifications": false,
"move_path": true,
"now": true,
"find_path": true,
@@ -837,7 +850,7 @@
"diagnostics": true,
"fetch": true,
"list_directory": true,
- "project_notifications": true,
+ "project_notifications": false,
"now": true,
"find_path": true,
"read_file": true,
@@ -876,11 +889,6 @@
},
// The settings for slash commands.
"slash_commands": {
- // Settings for the `/docs` slash command.
- "docs": {
- // Whether `/docs` is enabled.
- "enabled": false
- },
// Settings for the `/project` slash command.
"project": {
// Whether `/project` is enabled.
@@ -1074,6 +1082,10 @@
// Send anonymized usage data like what languages you're using Zed with.
"metrics": true
},
+ // Whether to disable all AI features in Zed.
+ //
+ // Default: false
+ "disable_ai": false,
// Automatically update Zed. This setting may be ignored on Linux if
// installed through a package manager.
"auto_update": true,
@@ -1121,11 +1133,6 @@
// The minimum severity of the diagnostics to show inline.
// Inherits editor's diagnostics' max severity settings when `null`.
"max_severity": null
- },
- "cargo": {
- // When enabled, Zed disables rust-analyzer's check on save and starts to query
- // Cargo diagnostics separately.
- "fetch_cargo_diagnostics": false
}
},
// Files or globs of files that will be excluded by Zed entirely. They will be skipped during file
@@ -1162,6 +1169,9 @@
// Sets a delay after which the inline blame information is shown.
// Delay is restarted with every cursor movement.
"delay_ms": 0,
+ // The amount of padding between the end of the source line and the start
+ // of the inline blame in units of em widths.
+ "padding": 7,
// Whether or not to display the git commit summary on the same line.
"show_commit_summary": false,
// The minimum column number to show the inline blame information at
@@ -1196,7 +1206,18 @@
// Any addition to this list will be merged with the default list.
// Globs are matched relative to the worktree root,
// except when starting with a slash (/) or equivalent in Windows.
- "disabled_globs": ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/.dev.vars", "**/secrets.yml"],
+ "disabled_globs": [
+ "**/.env*",
+ "**/*.pem",
+ "**/*.key",
+ "**/*.cert",
+ "**/*.crt",
+ "**/.dev.vars",
+ "**/secrets.yml",
+ "**/.zed/settings.json", // zed project settings
+ "/**/zed/settings.json", // zed user settings
+ "/**/zed/keymap.json"
+ ],
// When to show edit predictions previews in buffer.
// This setting takes two possible values:
// 1. Display predictions inline when there are no language server completions available.
@@ -1224,6 +1245,13 @@
// 2. hour24
"hour_format": "hour12"
},
+ // Status bar-related settings.
+ "status_bar": {
+ // Whether to show the active language button in the status bar.
+ "active_language_button": true,
+ // Whether to show the cursor position button in the status bar.
+ "cursor_position_button": true
+ },
// Settings specific to the terminal
"terminal": {
// What shell to use when opening a terminal. May take 3 values:
@@ -1372,7 +1400,7 @@
// "font_size": 15,
// Set the terminal's font family. If this option is not included,
// the terminal will default to matching the buffer's font family.
- // "font_family": "Zed Plex Mono",
+ // "font_family": ".ZedMono",
// Set the terminal's font fallbacks. If this option is not included,
// the terminal will default to matching the buffer's font fallbacks.
// This will be merged with the platform's default font fallbacks
@@ -1470,6 +1498,11 @@
//
// Default: fallback
"words": "fallback",
+ // Minimum number of characters required to automatically trigger word-based completions.
+ // Before that value, it's still possible to trigger the words-based completion manually with the corresponding editor command.
+ //
+ // Default: 3
+ "words_min_length": 3,
// Whether to fetch LSP completions or not.
//
// Default: true
@@ -1609,9 +1642,6 @@
"use_on_type_format": false,
"allow_rewrap": "anywhere",
"soft_wrap": "editor_width",
- "completions": {
- "words": "disabled"
- },
"prettier": {
"allowed": true
}
@@ -1625,9 +1655,6 @@
}
},
"Plain Text": {
- "completions": {
- "words": "disabled"
- },
"allow_rewrap": "anywhere"
},
"Python": {
@@ -1671,6 +1698,10 @@
"allowed": true
}
},
+ "SystemVerilog": {
+ "format_on_save": "off",
+ "use_on_type_format": false
+ },
"Vue.js": {
"language_servers": ["vue-language-server", "..."],
"prettier": {
@@ -1706,6 +1737,7 @@
"openai": {
"api_url": "https://api.openai.com/v1"
},
+ "openai_compatible": {},
"open_router": {
"api_url": "https://openrouter.ai/api/v1"
},
@@ -1863,5 +1895,25 @@
"save_breakpoints": true,
"dock": "bottom",
"button": true
- }
+ },
+ // Configures any number of settings profiles that are temporarily applied on
+ // top of your existing user settings when selected from
+ // `settings profile selector: toggle`.
+ // Examples:
+ // "profiles": {
+ // "Presenting": {
+ // "agent_font_size": 20.0,
+ // "buffer_font_size": 20.0,
+ // "theme": "One Light",
+ // "ui_font_size": 20.0
+ // },
+ // "Python (ty)": {
+ // "languages": {
+ // "Python": {
+ // "language_servers": ["ty"]
+ // }
+ // }
+ // }
+ // }
+ "profiles": []
}
@@ -15,13 +15,15 @@
"adapter": "JavaScript",
"program": "$ZED_FILE",
"request": "launch",
- "cwd": "$ZED_WORKTREE_ROOT"
+ "cwd": "$ZED_WORKTREE_ROOT",
+ "type": "pwa-node"
},
{
"label": "JavaScript debug terminal",
"adapter": "JavaScript",
"request": "launch",
"cwd": "$ZED_WORKTREE_ROOT",
- "console": "integratedTerminal"
+ "console": "integratedTerminal",
+ "type": "pwa-node"
}
]
@@ -8,7 +8,7 @@
// command palette (cmd-shift-p / ctrl-shift-p)
{
"ui_font_size": 16,
- "buffer_font_size": 16,
+ "buffer_font_size": 15,
"theme": {
"mode": "system",
"light": "One Light",
@@ -93,7 +93,7 @@
"terminal.ansi.bright_cyan": "#4c806fff",
"terminal.ansi.dim_cyan": "#cbf2e4ff",
"terminal.ansi.white": "#bfbdb6ff",
- "terminal.ansi.bright_white": "#bfbdb6ff",
+ "terminal.ansi.bright_white": "#fafafaff",
"terminal.ansi.dim_white": "#787876ff",
"link_text.hover": "#5ac1feff",
"conflict": "#feb454ff",
@@ -479,7 +479,7 @@
"terminal.ansi.bright_cyan": "#ace0cbff",
"terminal.ansi.dim_cyan": "#2a5f4aff",
"terminal.ansi.white": "#fcfcfcff",
- "terminal.ansi.bright_white": "#fcfcfcff",
+ "terminal.ansi.bright_white": "#ffffffff",
"terminal.ansi.dim_white": "#bcbec0ff",
"link_text.hover": "#3b9ee5ff",
"conflict": "#f1ad49ff",
@@ -865,7 +865,7 @@
"terminal.ansi.bright_cyan": "#4c806fff",
"terminal.ansi.dim_cyan": "#cbf2e4ff",
"terminal.ansi.white": "#cccac2ff",
- "terminal.ansi.bright_white": "#cccac2ff",
+ "terminal.ansi.bright_white": "#fafafaff",
"terminal.ansi.dim_white": "#898a8aff",
"link_text.hover": "#72cffeff",
"conflict": "#fecf72ff",
@@ -94,7 +94,7 @@
"terminal.ansi.bright_cyan": "#45603eff",
"terminal.ansi.dim_cyan": "#c7dfbdff",
"terminal.ansi.white": "#fbf1c7ff",
- "terminal.ansi.bright_white": "#fbf1c7ff",
+ "terminal.ansi.bright_white": "#ffffffff",
"terminal.ansi.dim_white": "#b0a189ff",
"link_text.hover": "#83a598ff",
"version_control.added": "#b7bb26ff",
@@ -494,7 +494,7 @@
"terminal.ansi.bright_cyan": "#45603eff",
"terminal.ansi.dim_cyan": "#c7dfbdff",
"terminal.ansi.white": "#fbf1c7ff",
- "terminal.ansi.bright_white": "#fbf1c7ff",
+ "terminal.ansi.bright_white": "#ffffffff",
"terminal.ansi.dim_white": "#b0a189ff",
"link_text.hover": "#83a598ff",
"version_control.added": "#b7bb26ff",
@@ -894,7 +894,7 @@
"terminal.ansi.bright_cyan": "#45603eff",
"terminal.ansi.dim_cyan": "#c7dfbdff",
"terminal.ansi.white": "#fbf1c7ff",
- "terminal.ansi.bright_white": "#fbf1c7ff",
+ "terminal.ansi.bright_white": "#ffffffff",
"terminal.ansi.dim_white": "#b0a189ff",
"link_text.hover": "#83a598ff",
"version_control.added": "#b7bb26ff",
@@ -1294,7 +1294,7 @@
"terminal.ansi.bright_cyan": "#9fbca8ff",
"terminal.ansi.dim_cyan": "#253e2eff",
"terminal.ansi.white": "#fbf1c7ff",
- "terminal.ansi.bright_white": "#fbf1c7ff",
+ "terminal.ansi.bright_white": "#ffffffff",
"terminal.ansi.dim_white": "#b0a189ff",
"link_text.hover": "#0b6678ff",
"version_control.added": "#797410ff",
@@ -1694,7 +1694,7 @@
"terminal.ansi.bright_cyan": "#9fbca8ff",
"terminal.ansi.dim_cyan": "#253e2eff",
"terminal.ansi.white": "#f9f5d7ff",
- "terminal.ansi.bright_white": "#f9f5d7ff",
+ "terminal.ansi.bright_white": "#ffffffff",
"terminal.ansi.dim_white": "#b0a189ff",
"link_text.hover": "#0b6678ff",
"version_control.added": "#797410ff",
@@ -2094,7 +2094,7 @@
"terminal.ansi.bright_cyan": "#9fbca8ff",
"terminal.ansi.dim_cyan": "#253e2eff",
"terminal.ansi.white": "#f2e5bcff",
- "terminal.ansi.bright_white": "#f2e5bcff",
+ "terminal.ansi.bright_white": "#ffffffff",
"terminal.ansi.dim_white": "#b0a189ff",
"link_text.hover": "#0b6678ff",
"version_control.added": "#797410ff",
@@ -86,14 +86,14 @@
"terminal.ansi.blue": "#74ade8ff",
"terminal.ansi.bright_blue": "#385378ff",
"terminal.ansi.dim_blue": "#bed5f4ff",
- "terminal.ansi.magenta": "#be5046ff",
- "terminal.ansi.bright_magenta": "#5e2b26ff",
- "terminal.ansi.dim_magenta": "#e6a79eff",
+ "terminal.ansi.magenta": "#b477cfff",
+ "terminal.ansi.bright_magenta": "#d6b4e4ff",
+ "terminal.ansi.dim_magenta": "#612a79ff",
"terminal.ansi.cyan": "#6eb4bfff",
"terminal.ansi.bright_cyan": "#3a565bff",
"terminal.ansi.dim_cyan": "#b9d9dfff",
"terminal.ansi.white": "#dce0e5ff",
- "terminal.ansi.bright_white": "#dce0e5ff",
+ "terminal.ansi.bright_white": "#fafafaff",
"terminal.ansi.dim_white": "#575d65ff",
"link_text.hover": "#74ade8ff",
"version_control.added": "#27a657ff",
@@ -468,7 +468,7 @@
"terminal.bright_foreground": "#242529ff",
"terminal.dim_foreground": "#fafafaff",
"terminal.ansi.black": "#242529ff",
- "terminal.ansi.bright_black": "#242529ff",
+ "terminal.ansi.bright_black": "#747579ff",
"terminal.ansi.dim_black": "#97979aff",
"terminal.ansi.red": "#d36151ff",
"terminal.ansi.bright_red": "#f0b0a4ff",
@@ -489,7 +489,7 @@
"terminal.ansi.bright_cyan": "#a3bedaff",
"terminal.ansi.dim_cyan": "#254058ff",
"terminal.ansi.white": "#fafafaff",
- "terminal.ansi.bright_white": "#fafafaff",
+ "terminal.ansi.bright_white": "#ffffffff",
"terminal.ansi.dim_white": "#aaaaaaff",
"link_text.hover": "#5c78e2ff",
"version_control.added": "#27a657ff",
@@ -59,5 +59,11 @@ services:
depends_on:
- postgres
+ stripe-mock:
+ image: stripe/stripe-mock:v0.178.0
+ ports:
+ - 12111:12111
+ - 12112:12112
+
volumes:
postgres_data:
@@ -1,1926 +0,0 @@
-pub use acp::ToolCallId;
-use agent_servers::AgentServer;
-use agentic_coding_protocol::{self as acp, UserMessageChunk};
-use anyhow::{Context as _, Result, anyhow};
-use assistant_tool::ActionLog;
-use buffer_diff::BufferDiff;
-use editor::{MultiBuffer, PathKey};
-use futures::{FutureExt, channel::oneshot, future::BoxFuture};
-use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity};
-use itertools::Itertools;
-use language::{
- Anchor, Buffer, BufferSnapshot, Capability, LanguageRegistry, OffsetRangeExt as _, Point,
- text_diff,
-};
-use markdown::Markdown;
-use project::{AgentLocation, Project};
-use std::collections::HashMap;
-use std::error::Error;
-use std::fmt::{Formatter, Write};
-use std::{
- fmt::Display,
- mem,
- path::{Path, PathBuf},
- sync::Arc,
-};
-use ui::{App, IconName};
-use util::ResultExt;
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct UserMessage {
- pub content: Entity<Markdown>,
-}
-
-impl UserMessage {
- pub fn from_acp(
- message: &acp::SendUserMessageParams,
- language_registry: Arc<LanguageRegistry>,
- cx: &mut App,
- ) -> Self {
- let mut md_source = String::new();
-
- for chunk in &message.chunks {
- match chunk {
- UserMessageChunk::Text { text } => md_source.push_str(&text),
- UserMessageChunk::Path { path } => {
- write!(&mut md_source, "{}", MentionPath(&path)).unwrap()
- }
- }
- }
-
- Self {
- content: cx
- .new(|cx| Markdown::new(md_source.into(), Some(language_registry), None, cx)),
- }
- }
-
- fn to_markdown(&self, cx: &App) -> String {
- format!("## User\n\n{}\n\n", self.content.read(cx).source())
- }
-}
-
-#[derive(Debug)]
-pub struct MentionPath<'a>(&'a Path);
-
-impl<'a> MentionPath<'a> {
- const PREFIX: &'static str = "@file:";
-
- pub fn new(path: &'a Path) -> Self {
- MentionPath(path)
- }
-
- pub fn try_parse(url: &'a str) -> Option<Self> {
- let path = url.strip_prefix(Self::PREFIX)?;
- Some(MentionPath(Path::new(path)))
- }
-
- pub fn path(&self) -> &Path {
- self.0
- }
-}
-
-impl Display for MentionPath<'_> {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(
- f,
- "[@{}]({}{})",
- self.0.file_name().unwrap_or_default().display(),
- Self::PREFIX,
- self.0.display()
- )
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct AssistantMessage {
- pub chunks: Vec<AssistantMessageChunk>,
-}
-
-impl AssistantMessage {
- fn to_markdown(&self, cx: &App) -> String {
- format!(
- "## Assistant\n\n{}\n\n",
- self.chunks
- .iter()
- .map(|chunk| chunk.to_markdown(cx))
- .join("\n\n")
- )
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum AssistantMessageChunk {
- Text { chunk: Entity<Markdown> },
- Thought { chunk: Entity<Markdown> },
-}
-
-impl AssistantMessageChunk {
- pub fn from_acp(
- chunk: acp::AssistantMessageChunk,
- language_registry: Arc<LanguageRegistry>,
- cx: &mut App,
- ) -> Self {
- match chunk {
- acp::AssistantMessageChunk::Text { text } => Self::Text {
- chunk: cx.new(|cx| Markdown::new(text.into(), Some(language_registry), None, cx)),
- },
- acp::AssistantMessageChunk::Thought { thought } => Self::Thought {
- chunk: cx
- .new(|cx| Markdown::new(thought.into(), Some(language_registry), None, cx)),
- },
- }
- }
-
- pub fn from_str(chunk: &str, language_registry: Arc<LanguageRegistry>, cx: &mut App) -> Self {
- Self::Text {
- chunk: cx.new(|cx| {
- Markdown::new(chunk.to_owned().into(), Some(language_registry), None, cx)
- }),
- }
- }
-
- fn to_markdown(&self, cx: &App) -> String {
- match self {
- Self::Text { chunk } => chunk.read(cx).source().to_string(),
- Self::Thought { chunk } => {
- format!("<thinking>\n{}\n</thinking>", chunk.read(cx).source())
- }
- }
- }
-}
-
-#[derive(Debug)]
-pub enum AgentThreadEntry {
- UserMessage(UserMessage),
- AssistantMessage(AssistantMessage),
- ToolCall(ToolCall),
-}
-
-impl AgentThreadEntry {
- fn to_markdown(&self, cx: &App) -> String {
- match self {
- Self::UserMessage(message) => message.to_markdown(cx),
- Self::AssistantMessage(message) => message.to_markdown(cx),
- Self::ToolCall(too_call) => too_call.to_markdown(cx),
- }
- }
-
- pub fn diff(&self) -> Option<&Diff> {
- if let AgentThreadEntry::ToolCall(ToolCall {
- content: Some(ToolCallContent::Diff { diff }),
- ..
- }) = self
- {
- Some(&diff)
- } else {
- None
- }
- }
-
- pub fn locations(&self) -> Option<&[acp::ToolCallLocation]> {
- if let AgentThreadEntry::ToolCall(ToolCall { locations, .. }) = self {
- Some(locations)
- } else {
- None
- }
- }
-}
-
-#[derive(Debug)]
-pub struct ToolCall {
- pub id: acp::ToolCallId,
- pub label: Entity<Markdown>,
- pub icon: IconName,
- pub content: Option<ToolCallContent>,
- pub status: ToolCallStatus,
- pub locations: Vec<acp::ToolCallLocation>,
-}
-
-impl ToolCall {
- fn to_markdown(&self, cx: &App) -> String {
- let mut markdown = format!(
- "**Tool Call: {}**\nStatus: {}\n\n",
- self.label.read(cx).source(),
- self.status
- );
- if let Some(content) = &self.content {
- markdown.push_str(content.to_markdown(cx).as_str());
- markdown.push_str("\n\n");
- }
- markdown
- }
-}
-
-#[derive(Debug)]
-pub enum ToolCallStatus {
- WaitingForConfirmation {
- confirmation: ToolCallConfirmation,
- respond_tx: oneshot::Sender<acp::ToolCallConfirmationOutcome>,
- },
- Allowed {
- status: acp::ToolCallStatus,
- },
- Rejected,
- Canceled,
-}
-
-impl Display for ToolCallStatus {
- fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
- write!(
- f,
- "{}",
- match self {
- ToolCallStatus::WaitingForConfirmation { .. } => "Waiting for confirmation",
- ToolCallStatus::Allowed { status } => match status {
- acp::ToolCallStatus::Running => "Running",
- acp::ToolCallStatus::Finished => "Finished",
- acp::ToolCallStatus::Error => "Error",
- },
- ToolCallStatus::Rejected => "Rejected",
- ToolCallStatus::Canceled => "Canceled",
- }
- )
- }
-}
-
-#[derive(Debug)]
-pub enum ToolCallConfirmation {
- Edit {
- description: Option<Entity<Markdown>>,
- },
- Execute {
- command: String,
- root_command: String,
- description: Option<Entity<Markdown>>,
- },
- Mcp {
- server_name: String,
- tool_name: String,
- tool_display_name: String,
- description: Option<Entity<Markdown>>,
- },
- Fetch {
- urls: Vec<SharedString>,
- description: Option<Entity<Markdown>>,
- },
- Other {
- description: Entity<Markdown>,
- },
-}
-
-impl ToolCallConfirmation {
- pub fn from_acp(
- confirmation: acp::ToolCallConfirmation,
- language_registry: Arc<LanguageRegistry>,
- cx: &mut App,
- ) -> Self {
- let to_md = |description: String, cx: &mut App| -> Entity<Markdown> {
- cx.new(|cx| {
- Markdown::new(
- description.into(),
- Some(language_registry.clone()),
- None,
- cx,
- )
- })
- };
-
- match confirmation {
- acp::ToolCallConfirmation::Edit { description } => Self::Edit {
- description: description.map(|description| to_md(description, cx)),
- },
- acp::ToolCallConfirmation::Execute {
- command,
- root_command,
- description,
- } => Self::Execute {
- command,
- root_command,
- description: description.map(|description| to_md(description, cx)),
- },
- acp::ToolCallConfirmation::Mcp {
- server_name,
- tool_name,
- tool_display_name,
- description,
- } => Self::Mcp {
- server_name,
- tool_name,
- tool_display_name,
- description: description.map(|description| to_md(description, cx)),
- },
- acp::ToolCallConfirmation::Fetch { urls, description } => Self::Fetch {
- urls: urls.iter().map(|url| url.into()).collect(),
- description: description.map(|description| to_md(description, cx)),
- },
- acp::ToolCallConfirmation::Other { description } => Self::Other {
- description: to_md(description, cx),
- },
- }
- }
-}
-
-#[derive(Debug)]
-pub enum ToolCallContent {
- Markdown { markdown: Entity<Markdown> },
- Diff { diff: Diff },
-}
-
-impl ToolCallContent {
- pub fn from_acp(
- content: acp::ToolCallContent,
- language_registry: Arc<LanguageRegistry>,
- cx: &mut App,
- ) -> Self {
- match content {
- acp::ToolCallContent::Markdown { markdown } => Self::Markdown {
- markdown: cx.new(|cx| Markdown::new_text(markdown.into(), cx)),
- },
- acp::ToolCallContent::Diff { diff } => Self::Diff {
- diff: Diff::from_acp(diff, language_registry, cx),
- },
- }
- }
-
- fn to_markdown(&self, cx: &App) -> String {
- match self {
- Self::Markdown { markdown } => markdown.read(cx).source().to_string(),
- Self::Diff { diff } => diff.to_markdown(cx),
- }
- }
-}
-
-#[derive(Debug)]
-pub struct Diff {
- pub multibuffer: Entity<MultiBuffer>,
- pub path: PathBuf,
- pub new_buffer: Entity<Buffer>,
- pub old_buffer: Entity<Buffer>,
- _task: Task<Result<()>>,
-}
-
-impl Diff {
- pub fn from_acp(
- diff: acp::Diff,
- language_registry: Arc<LanguageRegistry>,
- cx: &mut App,
- ) -> Self {
- let acp::Diff {
- path,
- old_text,
- new_text,
- } = diff;
-
- let multibuffer = cx.new(|_cx| MultiBuffer::without_headers(Capability::ReadOnly));
-
- let new_buffer = cx.new(|cx| Buffer::local(new_text, cx));
- let old_buffer = cx.new(|cx| Buffer::local(old_text.unwrap_or("".into()), cx));
- let new_buffer_snapshot = new_buffer.read(cx).text_snapshot();
- let old_buffer_snapshot = old_buffer.read(cx).snapshot();
- let buffer_diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot, cx));
- let diff_task = buffer_diff.update(cx, |diff, cx| {
- diff.set_base_text(
- old_buffer_snapshot,
- Some(language_registry.clone()),
- new_buffer_snapshot,
- cx,
- )
- });
-
- let task = cx.spawn({
- let multibuffer = multibuffer.clone();
- let path = path.clone();
- let new_buffer = new_buffer.clone();
- async move |cx| {
- diff_task.await?;
-
- multibuffer
- .update(cx, |multibuffer, cx| {
- let hunk_ranges = {
- let buffer = new_buffer.read(cx);
- let diff = buffer_diff.read(cx);
- diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx)
- .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer))
- .collect::<Vec<_>>()
- };
-
- multibuffer.set_excerpts_for_path(
- PathKey::for_buffer(&new_buffer, cx),
- new_buffer.clone(),
- hunk_ranges,
- editor::DEFAULT_MULTIBUFFER_CONTEXT,
- cx,
- );
- multibuffer.add_diff(buffer_diff.clone(), cx);
- })
- .log_err();
-
- if let Some(language) = language_registry
- .language_for_file_path(&path)
- .await
- .log_err()
- {
- new_buffer.update(cx, |buffer, cx| buffer.set_language(Some(language), cx))?;
- }
-
- anyhow::Ok(())
- }
- });
-
- Self {
- multibuffer,
- path,
- new_buffer,
- old_buffer,
- _task: task,
- }
- }
-
- fn to_markdown(&self, cx: &App) -> String {
- let buffer_text = self
- .multibuffer
- .read(cx)
- .all_buffers()
- .iter()
- .map(|buffer| buffer.read(cx).text())
- .join("\n");
- format!("Diff: {}\n```\n{}\n```\n", self.path.display(), buffer_text)
- }
-}
-
-pub struct AcpThread {
- entries: Vec<AgentThreadEntry>,
- title: SharedString,
- project: Entity<Project>,
- action_log: Entity<ActionLog>,
- shared_buffers: HashMap<Entity<Buffer>, BufferSnapshot>,
- send_task: Option<Task<()>>,
- connection: Arc<acp::AgentConnection>,
- child_status: Option<Task<Result<()>>>,
- _io_task: Task<()>,
-}
-
-pub enum AcpThreadEvent {
- NewEntry,
- EntryUpdated(usize),
-}
-
-impl EventEmitter<AcpThreadEvent> for AcpThread {}
-
-#[derive(PartialEq, Eq)]
-pub enum ThreadStatus {
- Idle,
- WaitingForToolConfirmation,
- Generating,
-}
-
-#[derive(Debug, Clone)]
-pub enum LoadError {
- Unsupported { current_version: SharedString },
- Exited(i32),
- Other(SharedString),
-}
-
-impl Display for LoadError {
- fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
- match self {
- LoadError::Unsupported { current_version } => {
- write!(
- f,
- "Your installed version of Gemini {} doesn't support the Agentic Coding Protocol (ACP).",
- current_version
- )
- }
- LoadError::Exited(status) => write!(f, "Server exited with status {}", status),
- LoadError::Other(msg) => write!(f, "{}", msg),
- }
- }
-}
-
-impl Error for LoadError {}
-
-impl AcpThread {
- pub async fn spawn(
- server: impl AgentServer + 'static,
- root_dir: &Path,
- project: Entity<Project>,
- cx: &mut AsyncApp,
- ) -> Result<Entity<Self>> {
- let command = match server.command(&project, cx).await {
- Ok(command) => command,
- Err(e) => return Err(anyhow!(LoadError::Other(format!("{e}").into()))),
- };
-
- let mut child = util::command::new_smol_command(&command.path)
- .args(command.args.iter())
- .current_dir(root_dir)
- .stdin(std::process::Stdio::piped())
- .stdout(std::process::Stdio::piped())
- .stderr(std::process::Stdio::inherit())
- .kill_on_drop(true)
- .spawn()?;
-
- let stdin = child.stdin.take().unwrap();
- let stdout = child.stdout.take().unwrap();
-
- cx.new(|cx| {
- let foreground_executor = cx.foreground_executor().clone();
-
- let (connection, io_fut) = acp::AgentConnection::connect_to_agent(
- AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()),
- stdin,
- stdout,
- move |fut| foreground_executor.spawn(fut).detach(),
- );
-
- let io_task = cx.background_spawn(async move {
- io_fut.await.log_err();
- });
-
- let child_status = cx.background_spawn(async move {
- match child.status().await {
- Err(e) => Err(anyhow!(e)),
- Ok(result) if result.success() => Ok(()),
- Ok(result) => {
- if let Some(version) = server.version(&command).await.log_err()
- && !version.supported
- {
- Err(anyhow!(LoadError::Unsupported {
- current_version: version.current_version
- }))
- } else {
- Err(anyhow!(LoadError::Exited(result.code().unwrap_or(-127))))
- }
- }
- }
- });
-
- let action_log = cx.new(|_| ActionLog::new(project.clone()));
-
- Self {
- action_log,
- shared_buffers: Default::default(),
- entries: Default::default(),
- title: "ACP Thread".into(),
- project,
- send_task: None,
- connection: Arc::new(connection),
- child_status: Some(child_status),
- _io_task: io_task,
- }
- })
- }
-
- pub fn action_log(&self) -> &Entity<ActionLog> {
- &self.action_log
- }
-
- pub fn project(&self) -> &Entity<Project> {
- &self.project
- }
-
- #[cfg(test)]
- pub fn fake(
- stdin: async_pipe::PipeWriter,
- stdout: async_pipe::PipeReader,
- project: Entity<Project>,
- cx: &mut Context<Self>,
- ) -> Self {
- let foreground_executor = cx.foreground_executor().clone();
-
- let (connection, io_fut) = acp::AgentConnection::connect_to_agent(
- AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()),
- stdin,
- stdout,
- move |fut| {
- foreground_executor.spawn(fut).detach();
- },
- );
-
- let io_task = cx.background_spawn({
- async move {
- io_fut.await.log_err();
- }
- });
-
- let action_log = cx.new(|_| ActionLog::new(project.clone()));
-
- Self {
- action_log,
- shared_buffers: Default::default(),
- entries: Default::default(),
- title: "ACP Thread".into(),
- project,
- send_task: None,
- connection: Arc::new(connection),
- child_status: None,
- _io_task: io_task,
- }
- }
-
- pub fn title(&self) -> SharedString {
- self.title.clone()
- }
-
- pub fn entries(&self) -> &[AgentThreadEntry] {
- &self.entries
- }
-
- pub fn status(&self) -> ThreadStatus {
- if self.send_task.is_some() {
- if self.waiting_for_tool_confirmation() {
- ThreadStatus::WaitingForToolConfirmation
- } else {
- ThreadStatus::Generating
- }
- } else {
- ThreadStatus::Idle
- }
- }
-
- pub fn has_pending_edit_tool_calls(&self) -> bool {
- for entry in self.entries.iter().rev() {
- match entry {
- AgentThreadEntry::UserMessage(_) => return false,
- AgentThreadEntry::ToolCall(ToolCall {
- status:
- ToolCallStatus::Allowed {
- status: acp::ToolCallStatus::Running,
- ..
- },
- content: Some(ToolCallContent::Diff { .. }),
- ..
- }) => return true,
- AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {}
- }
- }
-
- false
- }
-
- pub fn push_entry(&mut self, entry: AgentThreadEntry, cx: &mut Context<Self>) {
- self.entries.push(entry);
- cx.emit(AcpThreadEvent::NewEntry);
- }
-
- pub fn push_assistant_chunk(
- &mut self,
- chunk: acp::AssistantMessageChunk,
- cx: &mut Context<Self>,
- ) {
- let entries_len = self.entries.len();
- if let Some(last_entry) = self.entries.last_mut()
- && let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry
- {
- cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1));
-
- match (chunks.last_mut(), &chunk) {
- (
- Some(AssistantMessageChunk::Text { chunk: old_chunk }),
- acp::AssistantMessageChunk::Text { text: new_chunk },
- )
- | (
- Some(AssistantMessageChunk::Thought { chunk: old_chunk }),
- acp::AssistantMessageChunk::Thought { thought: new_chunk },
- ) => {
- old_chunk.update(cx, |old_chunk, cx| {
- old_chunk.append(&new_chunk, cx);
- });
- }
- _ => {
- chunks.push(AssistantMessageChunk::from_acp(
- chunk,
- self.project.read(cx).languages().clone(),
- cx,
- ));
- }
- }
- } else {
- let chunk = AssistantMessageChunk::from_acp(
- chunk,
- self.project.read(cx).languages().clone(),
- cx,
- );
-
- self.push_entry(
- AgentThreadEntry::AssistantMessage(AssistantMessage {
- chunks: vec![chunk],
- }),
- cx,
- );
- }
- }
-
- pub fn request_tool_call(
- &mut self,
- tool_call: acp::RequestToolCallConfirmationParams,
- cx: &mut Context<Self>,
- ) -> ToolCallRequest {
- let (tx, rx) = oneshot::channel();
-
- let status = ToolCallStatus::WaitingForConfirmation {
- confirmation: ToolCallConfirmation::from_acp(
- tool_call.confirmation,
- self.project.read(cx).languages().clone(),
- cx,
- ),
- respond_tx: tx,
- };
-
- let id = self.insert_tool_call(tool_call.tool_call, status, cx);
- ToolCallRequest { id, outcome: rx }
- }
-
- pub fn push_tool_call(
- &mut self,
- request: acp::PushToolCallParams,
- cx: &mut Context<Self>,
- ) -> acp::ToolCallId {
- let status = ToolCallStatus::Allowed {
- status: acp::ToolCallStatus::Running,
- };
-
- self.insert_tool_call(request, status, cx)
- }
-
- fn insert_tool_call(
- &mut self,
- tool_call: acp::PushToolCallParams,
- status: ToolCallStatus,
- cx: &mut Context<Self>,
- ) -> acp::ToolCallId {
- let language_registry = self.project.read(cx).languages().clone();
- let id = acp::ToolCallId(self.entries.len() as u64);
- let call = ToolCall {
- id,
- label: cx.new(|cx| {
- Markdown::new(
- tool_call.label.into(),
- Some(language_registry.clone()),
- None,
- cx,
- )
- }),
- icon: acp_icon_to_ui_icon(tool_call.icon),
- content: tool_call
- .content
- .map(|content| ToolCallContent::from_acp(content, language_registry, cx)),
- locations: tool_call.locations,
- status,
- };
-
- self.push_entry(AgentThreadEntry::ToolCall(call), cx);
-
- id
- }
-
- pub fn authorize_tool_call(
- &mut self,
- id: acp::ToolCallId,
- outcome: acp::ToolCallConfirmationOutcome,
- cx: &mut Context<Self>,
- ) {
- let Some((ix, call)) = self.tool_call_mut(id) else {
- return;
- };
-
- let new_status = if outcome == acp::ToolCallConfirmationOutcome::Reject {
- ToolCallStatus::Rejected
- } else {
- ToolCallStatus::Allowed {
- status: acp::ToolCallStatus::Running,
- }
- };
-
- let curr_status = mem::replace(&mut call.status, new_status);
-
- if let ToolCallStatus::WaitingForConfirmation { respond_tx, .. } = curr_status {
- respond_tx.send(outcome).log_err();
- } else if cfg!(debug_assertions) {
- panic!("tried to authorize an already authorized tool call");
- }
-
- cx.emit(AcpThreadEvent::EntryUpdated(ix));
- }
-
- pub fn update_tool_call(
- &mut self,
- id: acp::ToolCallId,
- new_status: acp::ToolCallStatus,
- new_content: Option<acp::ToolCallContent>,
- cx: &mut Context<Self>,
- ) -> Result<()> {
- let language_registry = self.project.read(cx).languages().clone();
- let (ix, call) = self.tool_call_mut(id).context("Entry not found")?;
-
- call.content = new_content
- .map(|new_content| ToolCallContent::from_acp(new_content, language_registry, cx));
-
- match &mut call.status {
- ToolCallStatus::Allowed { status } => {
- *status = new_status;
- }
- ToolCallStatus::WaitingForConfirmation { .. } => {
- anyhow::bail!("Tool call hasn't been authorized yet")
- }
- ToolCallStatus::Rejected => {
- anyhow::bail!("Tool call was rejected and therefore can't be updated")
- }
- ToolCallStatus::Canceled => {
- call.status = ToolCallStatus::Allowed { status: new_status };
- }
- }
-
- cx.emit(AcpThreadEvent::EntryUpdated(ix));
- Ok(())
- }
-
- fn tool_call_mut(&mut self, id: acp::ToolCallId) -> Option<(usize, &mut ToolCall)> {
- let entry = self.entries.get_mut(id.0 as usize);
- debug_assert!(
- entry.is_some(),
- "We shouldn't give out ids to entries that don't exist"
- );
- match entry {
- Some(AgentThreadEntry::ToolCall(call)) if call.id == id => Some((id.0 as usize, call)),
- _ => {
- if cfg!(debug_assertions) {
- panic!("entry is not a tool call");
- }
- None
- }
- }
- }
-
- /// Returns true if the last turn is awaiting tool authorization
- pub fn waiting_for_tool_confirmation(&self) -> bool {
- for entry in self.entries.iter().rev() {
- match &entry {
- AgentThreadEntry::ToolCall(call) => match call.status {
- ToolCallStatus::WaitingForConfirmation { .. } => return true,
- ToolCallStatus::Allowed { .. }
- | ToolCallStatus::Rejected
- | ToolCallStatus::Canceled => continue,
- },
- AgentThreadEntry::UserMessage(_) | AgentThreadEntry::AssistantMessage(_) => {
- // Reached the beginning of the turn
- return false;
- }
- }
- }
- false
- }
-
- pub fn initialize(
- &self,
- ) -> impl use<> + Future<Output = Result<acp::InitializeResponse, acp::Error>> {
- let connection = self.connection.clone();
- async move { connection.initialize().await }
- }
-
- pub fn authenticate(&self) -> impl use<> + Future<Output = Result<(), acp::Error>> {
- let connection = self.connection.clone();
- async move { connection.request(acp::AuthenticateParams).await }
- }
-
- #[cfg(test)]
- pub fn send_raw(
- &mut self,
- message: &str,
- cx: &mut Context<Self>,
- ) -> BoxFuture<'static, Result<(), acp::Error>> {
- self.send(
- acp::SendUserMessageParams {
- chunks: vec![acp::UserMessageChunk::Text {
- text: message.to_string(),
- }],
- },
- cx,
- )
- }
-
- pub fn send(
- &mut self,
- message: acp::SendUserMessageParams,
- cx: &mut Context<Self>,
- ) -> BoxFuture<'static, Result<(), acp::Error>> {
- let agent = self.connection.clone();
- self.push_entry(
- AgentThreadEntry::UserMessage(UserMessage::from_acp(
- &message,
- self.project.read(cx).languages().clone(),
- cx,
- )),
- cx,
- );
-
- let (tx, rx) = oneshot::channel();
- let cancel = self.cancel(cx);
-
- self.send_task = Some(cx.spawn(async move |this, cx| {
- cancel.await.log_err();
-
- let result = agent.request(message).await;
- tx.send(result).log_err();
- this.update(cx, |this, _cx| this.send_task.take()).log_err();
- }));
-
- async move {
- match rx.await {
- Ok(Err(e)) => Err(e)?,
- _ => Ok(()),
- }
- }
- .boxed()
- }
-
- pub fn cancel(&mut self, cx: &mut Context<Self>) -> Task<Result<(), acp::Error>> {
- let agent = self.connection.clone();
-
- if self.send_task.take().is_some() {
- cx.spawn(async move |this, cx| {
- agent.request(acp::CancelSendMessageParams).await?;
-
- this.update(cx, |this, _cx| {
- for entry in this.entries.iter_mut() {
- if let AgentThreadEntry::ToolCall(call) = entry {
- let cancel = matches!(
- call.status,
- ToolCallStatus::WaitingForConfirmation { .. }
- | ToolCallStatus::Allowed {
- status: acp::ToolCallStatus::Running
- }
- );
-
- if cancel {
- let curr_status =
- mem::replace(&mut call.status, ToolCallStatus::Canceled);
-
- if let ToolCallStatus::WaitingForConfirmation {
- respond_tx, ..
- } = curr_status
- {
- respond_tx
- .send(acp::ToolCallConfirmationOutcome::Cancel)
- .ok();
- }
- }
- }
- }
- })?;
- Ok(())
- })
- } else {
- Task::ready(Ok(()))
- }
- }
-
- pub fn read_text_file(
- &self,
- request: acp::ReadTextFileParams,
- cx: &mut Context<Self>,
- ) -> Task<Result<String>> {
- let project = self.project.clone();
- let action_log = self.action_log.clone();
- cx.spawn(async move |this, cx| {
- let load = project.update(cx, |project, cx| {
- let path = project
- .project_path_for_absolute_path(&request.path, cx)
- .context("invalid path")?;
- anyhow::Ok(project.open_buffer(path, cx))
- });
- let buffer = load??.await?;
-
- action_log.update(cx, |action_log, cx| {
- action_log.buffer_read(buffer.clone(), cx);
- })?;
- project.update(cx, |project, cx| {
- let position = buffer
- .read(cx)
- .snapshot()
- .anchor_before(Point::new(request.line.unwrap_or_default(), 0));
- project.set_agent_location(
- Some(AgentLocation {
- buffer: buffer.downgrade(),
- position,
- }),
- cx,
- );
- })?;
- let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?;
- this.update(cx, |this, _| {
- let text = snapshot.text();
- this.shared_buffers.insert(buffer.clone(), snapshot);
- text
- })
- })
- }
-
- pub fn write_text_file(
- &self,
- path: PathBuf,
- content: String,
- cx: &mut Context<Self>,
- ) -> Task<Result<()>> {
- let project = self.project.clone();
- let action_log = self.action_log.clone();
- cx.spawn(async move |this, cx| {
- let load = project.update(cx, |project, cx| {
- let path = project
- .project_path_for_absolute_path(&path, cx)
- .context("invalid path")?;
- anyhow::Ok(project.open_buffer(path, cx))
- });
- let buffer = load??.await?;
- let snapshot = this.update(cx, |this, cx| {
- this.shared_buffers
- .get(&buffer)
- .cloned()
- .unwrap_or_else(|| buffer.read(cx).snapshot())
- })?;
- let edits = cx
- .background_executor()
- .spawn(async move {
- let old_text = snapshot.text();
- text_diff(old_text.as_str(), &content)
- .into_iter()
- .map(|(range, replacement)| {
- (
- snapshot.anchor_after(range.start)
- ..snapshot.anchor_before(range.end),
- replacement,
- )
- })
- .collect::<Vec<_>>()
- })
- .await;
- cx.update(|cx| {
- project.update(cx, |project, cx| {
- project.set_agent_location(
- Some(AgentLocation {
- buffer: buffer.downgrade(),
- position: edits
- .last()
- .map(|(range, _)| range.end)
- .unwrap_or(Anchor::MIN),
- }),
- cx,
- );
- });
-
- action_log.update(cx, |action_log, cx| {
- action_log.buffer_read(buffer.clone(), cx);
- });
- buffer.update(cx, |buffer, cx| {
- buffer.edit(edits, None, cx);
- });
- action_log.update(cx, |action_log, cx| {
- action_log.buffer_edited(buffer.clone(), cx);
- });
- })?;
- project
- .update(cx, |project, cx| project.save_buffer(buffer, cx))?
- .await
- })
- }
-
- pub fn child_status(&mut self) -> Option<Task<Result<()>>> {
- self.child_status.take()
- }
-
- pub fn to_markdown(&self, cx: &App) -> String {
- self.entries.iter().map(|e| e.to_markdown(cx)).collect()
- }
-}
-
-struct AcpClientDelegate {
- thread: WeakEntity<AcpThread>,
- cx: AsyncApp,
- // sent_buffer_versions: HashMap<Entity<Buffer>, HashMap<u64, BufferSnapshot>>,
-}
-
-impl AcpClientDelegate {
- fn new(thread: WeakEntity<AcpThread>, cx: AsyncApp) -> Self {
- Self { thread, cx }
- }
-}
-
-impl acp::Client for AcpClientDelegate {
- async fn stream_assistant_message_chunk(
- &self,
- params: acp::StreamAssistantMessageChunkParams,
- ) -> Result<(), acp::Error> {
- let cx = &mut self.cx.clone();
-
- cx.update(|cx| {
- self.thread
- .update(cx, |thread, cx| {
- thread.push_assistant_chunk(params.chunk, cx)
- })
- .ok();
- })?;
-
- Ok(())
- }
-
- async fn request_tool_call_confirmation(
- &self,
- request: acp::RequestToolCallConfirmationParams,
- ) -> Result<acp::RequestToolCallConfirmationResponse, acp::Error> {
- let cx = &mut self.cx.clone();
- let ToolCallRequest { id, outcome } = cx
- .update(|cx| {
- self.thread
- .update(cx, |thread, cx| thread.request_tool_call(request, cx))
- })?
- .context("Failed to update thread")?;
-
- Ok(acp::RequestToolCallConfirmationResponse {
- id,
- outcome: outcome.await.map_err(acp::Error::into_internal_error)?,
- })
- }
-
- async fn push_tool_call(
- &self,
- request: acp::PushToolCallParams,
- ) -> Result<acp::PushToolCallResponse, acp::Error> {
- let cx = &mut self.cx.clone();
- let id = cx
- .update(|cx| {
- self.thread
- .update(cx, |thread, cx| thread.push_tool_call(request, cx))
- })?
- .context("Failed to update thread")?;
-
- Ok(acp::PushToolCallResponse { id })
- }
-
- async fn update_tool_call(&self, request: acp::UpdateToolCallParams) -> Result<(), acp::Error> {
- let cx = &mut self.cx.clone();
-
- cx.update(|cx| {
- self.thread.update(cx, |thread, cx| {
- thread.update_tool_call(request.tool_call_id, request.status, request.content, cx)
- })
- })?
- .context("Failed to update thread")??;
-
- Ok(())
- }
-
- async fn read_text_file(
- &self,
- request: acp::ReadTextFileParams,
- ) -> Result<acp::ReadTextFileResponse, acp::Error> {
- let content = self
- .cx
- .update(|cx| {
- self.thread
- .update(cx, |thread, cx| thread.read_text_file(request, cx))
- })?
- .context("Failed to update thread")?
- .await?;
- Ok(acp::ReadTextFileResponse { content })
- }
-
- async fn write_text_file(&self, request: acp::WriteTextFileParams) -> Result<(), acp::Error> {
- self.cx
- .update(|cx| {
- self.thread.update(cx, |thread, cx| {
- thread.write_text_file(request.path, request.content, cx)
- })
- })?
- .context("Failed to update thread")?
- .await?;
-
- Ok(())
- }
-}
-
-fn acp_icon_to_ui_icon(icon: acp::Icon) -> IconName {
- match icon {
- acp::Icon::FileSearch => IconName::ToolSearch,
- acp::Icon::Folder => IconName::ToolFolder,
- acp::Icon::Globe => IconName::ToolWeb,
- acp::Icon::Hammer => IconName::ToolHammer,
- acp::Icon::LightBulb => IconName::ToolBulb,
- acp::Icon::Pencil => IconName::ToolPencil,
- acp::Icon::Regex => IconName::ToolRegex,
- acp::Icon::Terminal => IconName::ToolTerminal,
- }
-}
-
-pub struct ToolCallRequest {
- pub id: acp::ToolCallId,
- pub outcome: oneshot::Receiver<acp::ToolCallConfirmationOutcome>,
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use agent_servers::{AgentServerCommand, AgentServerVersion};
- use async_pipe::{PipeReader, PipeWriter};
- use futures::{channel::mpsc, future::LocalBoxFuture, select};
- use gpui::{AsyncApp, TestAppContext};
- use indoc::indoc;
- use project::FakeFs;
- use serde_json::json;
- use settings::SettingsStore;
- use smol::{future::BoxedLocal, stream::StreamExt as _};
- use std::{cell::RefCell, env, path::Path, rc::Rc, time::Duration};
- use util::path;
-
- fn init_test(cx: &mut TestAppContext) {
- env_logger::try_init().ok();
- cx.update(|cx| {
- let settings_store = SettingsStore::test(cx);
- cx.set_global(settings_store);
- Project::init_settings(cx);
- language::init(cx);
- });
- }
-
- #[gpui::test]
- async fn test_thinking_concatenation(cx: &mut TestAppContext) {
- init_test(cx);
-
- let fs = FakeFs::new(cx.executor());
- let project = Project::test(fs, [], cx).await;
- let (thread, fake_server) = fake_acp_thread(project, cx);
-
- fake_server.update(cx, |fake_server, _| {
- fake_server.on_user_message(move |_, server, mut cx| async move {
- server
- .update(&mut cx, |server, _| {
- server.send_to_zed(acp::StreamAssistantMessageChunkParams {
- chunk: acp::AssistantMessageChunk::Thought {
- thought: "Thinking ".into(),
- },
- })
- })?
- .await
- .unwrap();
- server
- .update(&mut cx, |server, _| {
- server.send_to_zed(acp::StreamAssistantMessageChunkParams {
- chunk: acp::AssistantMessageChunk::Thought {
- thought: "hard!".into(),
- },
- })
- })?
- .await
- .unwrap();
-
- Ok(())
- })
- });
-
- thread
- .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx))
- .await
- .unwrap();
-
- let output = thread.read_with(cx, |thread, cx| thread.to_markdown(cx));
- assert_eq!(
- output,
- indoc! {r#"
- ## User
-
- Hello from Zed!
-
- ## Assistant
-
- <thinking>
- Thinking hard!
- </thinking>
-
- "#}
- );
- }
-
- #[gpui::test]
- async fn test_edits_concurrently_to_user(cx: &mut TestAppContext) {
- init_test(cx);
-
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(path!("/tmp"), json!({"foo": "one\ntwo\nthree\n"}))
- .await;
- let project = Project::test(fs.clone(), [], cx).await;
- let (thread, fake_server) = fake_acp_thread(project.clone(), cx);
- let (worktree, pathbuf) = project
- .update(cx, |project, cx| {
- project.find_or_create_worktree(path!("/tmp/foo"), true, cx)
- })
- .await
- .unwrap();
- let buffer = project
- .update(cx, |project, cx| {
- project.open_buffer((worktree.read(cx).id(), pathbuf), cx)
- })
- .await
- .unwrap();
-
- let (read_file_tx, read_file_rx) = oneshot::channel::<()>();
- let read_file_tx = Rc::new(RefCell::new(Some(read_file_tx)));
-
- fake_server.update(cx, |fake_server, _| {
- fake_server.on_user_message(move |_, server, mut cx| {
- let read_file_tx = read_file_tx.clone();
- async move {
- let content = server
- .update(&mut cx, |server, _| {
- server.send_to_zed(acp::ReadTextFileParams {
- path: path!("/tmp/foo").into(),
- line: None,
- limit: None,
- })
- })?
- .await
- .unwrap();
- assert_eq!(content.content, "one\ntwo\nthree\n");
- read_file_tx.take().unwrap().send(()).unwrap();
- server
- .update(&mut cx, |server, _| {
- server.send_to_zed(acp::WriteTextFileParams {
- path: path!("/tmp/foo").into(),
- content: "one\ntwo\nthree\nfour\nfive\n".to_string(),
- })
- })?
- .await
- .unwrap();
- Ok(())
- }
- })
- });
-
- let request = thread.update(cx, |thread, cx| {
- thread.send_raw("Extend the count in /tmp/foo", cx)
- });
- read_file_rx.await.ok();
- buffer.update(cx, |buffer, cx| {
- buffer.edit([(0..0, "zero\n".to_string())], None, cx);
- });
- cx.run_until_parked();
- assert_eq!(
- buffer.read_with(cx, |buffer, _| buffer.text()),
- "zero\none\ntwo\nthree\nfour\nfive\n"
- );
- assert_eq!(
- String::from_utf8(fs.read_file_sync(path!("/tmp/foo")).unwrap()).unwrap(),
- "zero\none\ntwo\nthree\nfour\nfive\n"
- );
- request.await.unwrap();
- }
-
- #[gpui::test]
- async fn test_succeeding_canceled_toolcall(cx: &mut TestAppContext) {
- init_test(cx);
-
- let fs = FakeFs::new(cx.executor());
- let project = Project::test(fs, [], cx).await;
- let (thread, fake_server) = fake_acp_thread(project, cx);
-
- let (end_turn_tx, end_turn_rx) = oneshot::channel::<()>();
-
- let tool_call_id = Rc::new(RefCell::new(None));
- let end_turn_rx = Rc::new(RefCell::new(Some(end_turn_rx)));
- fake_server.update(cx, |fake_server, _| {
- let tool_call_id = tool_call_id.clone();
- fake_server.on_user_message(move |_, server, mut cx| {
- let end_turn_rx = end_turn_rx.clone();
- let tool_call_id = tool_call_id.clone();
- async move {
- let tool_call_result = server
- .update(&mut cx, |server, _| {
- server.send_to_zed(acp::PushToolCallParams {
- label: "Fetch".to_string(),
- icon: acp::Icon::Globe,
- content: None,
- locations: vec![],
- })
- })?
- .await
- .unwrap();
- *tool_call_id.clone().borrow_mut() = Some(tool_call_result.id);
- end_turn_rx.take().unwrap().await.ok();
-
- Ok(())
- }
- })
- });
-
- let request = thread.update(cx, |thread, cx| {
- thread.send_raw("Fetch https://example.com", cx)
- });
-
- run_until_first_tool_call(&thread, cx).await;
-
- thread.read_with(cx, |thread, _| {
- assert!(matches!(
- thread.entries[1],
- AgentThreadEntry::ToolCall(ToolCall {
- status: ToolCallStatus::Allowed {
- status: acp::ToolCallStatus::Running,
- ..
- },
- ..
- })
- ));
- });
-
- cx.run_until_parked();
-
- thread
- .update(cx, |thread, cx| thread.cancel(cx))
- .await
- .unwrap();
-
- thread.read_with(cx, |thread, _| {
- assert!(matches!(
- &thread.entries[1],
- AgentThreadEntry::ToolCall(ToolCall {
- status: ToolCallStatus::Canceled,
- ..
- })
- ));
- });
-
- fake_server
- .update(cx, |fake_server, _| {
- fake_server.send_to_zed(acp::UpdateToolCallParams {
- tool_call_id: tool_call_id.borrow().unwrap(),
- status: acp::ToolCallStatus::Finished,
- content: None,
- })
- })
- .await
- .unwrap();
-
- drop(end_turn_tx);
- request.await.unwrap();
-
- thread.read_with(cx, |thread, _| {
- assert!(matches!(
- thread.entries[1],
- AgentThreadEntry::ToolCall(ToolCall {
- status: ToolCallStatus::Allowed {
- status: acp::ToolCallStatus::Finished,
- ..
- },
- ..
- })
- ));
- });
- }
-
- #[gpui::test]
- #[cfg_attr(not(feature = "gemini"), ignore)]
- async fn test_gemini_basic(cx: &mut TestAppContext) {
- init_test(cx);
-
- cx.executor().allow_parking();
-
- let fs = FakeFs::new(cx.executor());
- let project = Project::test(fs, [], cx).await;
- let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
- thread
- .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx))
- .await
- .unwrap();
-
- thread.read_with(cx, |thread, _| {
- assert_eq!(thread.entries.len(), 2);
- assert!(matches!(
- thread.entries[0],
- AgentThreadEntry::UserMessage(_)
- ));
- assert!(matches!(
- thread.entries[1],
- AgentThreadEntry::AssistantMessage(_)
- ));
- });
- }
-
- #[gpui::test]
- #[cfg_attr(not(feature = "gemini"), ignore)]
- async fn test_gemini_path_mentions(cx: &mut TestAppContext) {
- init_test(cx);
-
- cx.executor().allow_parking();
- let tempdir = tempfile::tempdir().unwrap();
- std::fs::write(
- tempdir.path().join("foo.rs"),
- indoc! {"
- fn main() {
- println!(\"Hello, world!\");
- }
- "},
- )
- .expect("failed to write file");
- let project = Project::example([tempdir.path()], &mut cx.to_async()).await;
- let thread = gemini_acp_thread(project.clone(), tempdir.path(), cx).await;
- thread
- .update(cx, |thread, cx| {
- thread.send(
- acp::SendUserMessageParams {
- chunks: vec![
- acp::UserMessageChunk::Text {
- text: "Read the file ".into(),
- },
- acp::UserMessageChunk::Path {
- path: Path::new("foo.rs").into(),
- },
- acp::UserMessageChunk::Text {
- text: " and tell me what the content of the println! is".into(),
- },
- ],
- },
- cx,
- )
- })
- .await
- .unwrap();
-
- thread.read_with(cx, |thread, cx| {
- assert_eq!(thread.entries.len(), 3);
- assert!(matches!(
- thread.entries[0],
- AgentThreadEntry::UserMessage(_)
- ));
- assert!(matches!(thread.entries[1], AgentThreadEntry::ToolCall(_)));
- let AgentThreadEntry::AssistantMessage(assistant_message) = &thread.entries[2] else {
- panic!("Expected AssistantMessage")
- };
- assert!(
- assistant_message.to_markdown(cx).contains("Hello, world!"),
- "unexpected assistant message: {:?}",
- assistant_message.to_markdown(cx)
- );
- });
- }
-
- #[gpui::test]
- #[cfg_attr(not(feature = "gemini"), ignore)]
- async fn test_gemini_tool_call(cx: &mut TestAppContext) {
- init_test(cx);
-
- cx.executor().allow_parking();
-
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/private/tmp"),
- json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}),
- )
- .await;
- let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
- let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
- thread
- .update(cx, |thread, cx| {
- thread.send_raw(
- "Read the '/private/tmp/foo' file and tell me what you see.",
- cx,
- )
- })
- .await
- .unwrap();
- thread.read_with(cx, |thread, _cx| {
- assert!(matches!(
- &thread.entries()[2],
- AgentThreadEntry::ToolCall(ToolCall {
- status: ToolCallStatus::Allowed { .. },
- ..
- })
- ));
-
- assert!(matches!(
- thread.entries[3],
- AgentThreadEntry::AssistantMessage(_)
- ));
- });
- }
-
- #[gpui::test]
- #[cfg_attr(not(feature = "gemini"), ignore)]
- async fn test_gemini_tool_call_with_confirmation(cx: &mut TestAppContext) {
- init_test(cx);
-
- cx.executor().allow_parking();
-
- let fs = FakeFs::new(cx.executor());
- let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
- let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
- let full_turn = thread.update(cx, |thread, cx| {
- thread.send_raw(r#"Run `echo "Hello, world!"`"#, cx)
- });
-
- run_until_first_tool_call(&thread, cx).await;
-
- let tool_call_id = thread.read_with(cx, |thread, _cx| {
- let AgentThreadEntry::ToolCall(ToolCall {
- id,
- status:
- ToolCallStatus::WaitingForConfirmation {
- confirmation: ToolCallConfirmation::Execute { root_command, .. },
- ..
- },
- ..
- }) = &thread.entries()[2]
- else {
- panic!();
- };
-
- assert_eq!(root_command, "echo");
-
- *id
- });
-
- thread.update(cx, |thread, cx| {
- thread.authorize_tool_call(tool_call_id, acp::ToolCallConfirmationOutcome::Allow, cx);
-
- assert!(matches!(
- &thread.entries()[2],
- AgentThreadEntry::ToolCall(ToolCall {
- status: ToolCallStatus::Allowed { .. },
- ..
- })
- ));
- });
-
- full_turn.await.unwrap();
-
- thread.read_with(cx, |thread, cx| {
- let AgentThreadEntry::ToolCall(ToolCall {
- content: Some(ToolCallContent::Markdown { markdown }),
- status: ToolCallStatus::Allowed { .. },
- ..
- }) = &thread.entries()[2]
- else {
- panic!();
- };
-
- markdown.read_with(cx, |md, _cx| {
- assert!(
- md.source().contains("Hello, world!"),
- r#"Expected '{}' to contain "Hello, world!""#,
- md.source()
- );
- });
- });
- }
-
- #[gpui::test]
- #[cfg_attr(not(feature = "gemini"), ignore)]
- async fn test_gemini_cancel(cx: &mut TestAppContext) {
- init_test(cx);
-
- cx.executor().allow_parking();
-
- let fs = FakeFs::new(cx.executor());
- let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
- let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
- let full_turn = thread.update(cx, |thread, cx| {
- thread.send_raw(r#"Run `echo "Hello, world!"`"#, cx)
- });
-
- let first_tool_call_ix = run_until_first_tool_call(&thread, cx).await;
-
- thread.read_with(cx, |thread, _cx| {
- let AgentThreadEntry::ToolCall(ToolCall {
- id,
- status:
- ToolCallStatus::WaitingForConfirmation {
- confirmation: ToolCallConfirmation::Execute { root_command, .. },
- ..
- },
- ..
- }) = &thread.entries()[first_tool_call_ix]
- else {
- panic!("{:?}", thread.entries()[1]);
- };
-
- assert_eq!(root_command, "echo");
-
- *id
- });
-
- thread
- .update(cx, |thread, cx| thread.cancel(cx))
- .await
- .unwrap();
- full_turn.await.unwrap();
- thread.read_with(cx, |thread, _| {
- let AgentThreadEntry::ToolCall(ToolCall {
- status: ToolCallStatus::Canceled,
- ..
- }) = &thread.entries()[first_tool_call_ix]
- else {
- panic!();
- };
- });
-
- thread
- .update(cx, |thread, cx| {
- thread.send_raw(r#"Stop running and say goodbye to me."#, cx)
- })
- .await
- .unwrap();
- thread.read_with(cx, |thread, _| {
- assert!(matches!(
- &thread.entries().last().unwrap(),
- AgentThreadEntry::AssistantMessage(..),
- ))
- });
- }
-
- async fn run_until_first_tool_call(
- thread: &Entity<AcpThread>,
- cx: &mut TestAppContext,
- ) -> usize {
- let (mut tx, mut rx) = mpsc::channel::<usize>(1);
-
- let subscription = cx.update(|cx| {
- cx.subscribe(thread, move |thread, _, cx| {
- for (ix, entry) in thread.read(cx).entries.iter().enumerate() {
- if matches!(entry, AgentThreadEntry::ToolCall(_)) {
- return tx.try_send(ix).unwrap();
- }
- }
- })
- });
-
- select! {
- _ = futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(10))) => {
- panic!("Timeout waiting for tool call")
- }
- ix = rx.next().fuse() => {
- drop(subscription);
- ix.unwrap()
- }
- }
- }
-
- pub async fn gemini_acp_thread(
- project: Entity<Project>,
- current_dir: impl AsRef<Path>,
- cx: &mut TestAppContext,
- ) -> Entity<AcpThread> {
- struct DevGemini;
-
- impl agent_servers::AgentServer for DevGemini {
- async fn command(
- &self,
- _project: &Entity<Project>,
- _cx: &mut AsyncApp,
- ) -> Result<agent_servers::AgentServerCommand> {
- let cli_path = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("../../../gemini-cli/packages/cli")
- .to_string_lossy()
- .to_string();
-
- Ok(AgentServerCommand {
- path: "node".into(),
- args: vec![cli_path, "--acp".into()],
- env: None,
- })
- }
-
- async fn version(
- &self,
- _command: &agent_servers::AgentServerCommand,
- ) -> Result<AgentServerVersion> {
- Ok(AgentServerVersion {
- current_version: "0.1.0".into(),
- supported: true,
- })
- }
- }
-
- let thread = AcpThread::spawn(DevGemini, current_dir.as_ref(), project, &mut cx.to_async())
- .await
- .unwrap();
-
- thread
- .update(cx, |thread, _| thread.initialize())
- .await
- .unwrap();
- thread
- }
-
- pub fn fake_acp_thread(
- project: Entity<Project>,
- cx: &mut TestAppContext,
- ) -> (Entity<AcpThread>, Entity<FakeAcpServer>) {
- let (stdin_tx, stdin_rx) = async_pipe::pipe();
- let (stdout_tx, stdout_rx) = async_pipe::pipe();
- let thread = cx.update(|cx| cx.new(|cx| AcpThread::fake(stdin_tx, stdout_rx, project, cx)));
- let agent = cx.update(|cx| cx.new(|cx| FakeAcpServer::new(stdin_rx, stdout_tx, cx)));
- (thread, agent)
- }
-
- pub struct FakeAcpServer {
- connection: acp::ClientConnection,
- _io_task: Task<()>,
- on_user_message: Option<
- Rc<
- dyn Fn(
- acp::SendUserMessageParams,
- Entity<FakeAcpServer>,
- AsyncApp,
- ) -> LocalBoxFuture<'static, Result<(), acp::Error>>,
- >,
- >,
- }
-
- #[derive(Clone)]
- struct FakeAgent {
- server: Entity<FakeAcpServer>,
- cx: AsyncApp,
- }
-
- impl acp::Agent for FakeAgent {
- async fn initialize(
- &self,
- params: acp::InitializeParams,
- ) -> Result<acp::InitializeResponse, acp::Error> {
- Ok(acp::InitializeResponse {
- protocol_version: params.protocol_version,
- is_authenticated: true,
- })
- }
-
- async fn authenticate(&self) -> Result<(), acp::Error> {
- Ok(())
- }
-
- async fn cancel_send_message(&self) -> Result<(), acp::Error> {
- Ok(())
- }
-
- async fn send_user_message(
- &self,
- request: acp::SendUserMessageParams,
- ) -> Result<(), acp::Error> {
- let mut cx = self.cx.clone();
- let handler = self
- .server
- .update(&mut cx, |server, _| server.on_user_message.clone())
- .ok()
- .flatten();
- if let Some(handler) = handler {
- handler(request, self.server.clone(), self.cx.clone()).await
- } else {
- Err(anyhow::anyhow!("No handler for on_user_message").into())
- }
- }
- }
-
- impl FakeAcpServer {
- fn new(stdin: PipeReader, stdout: PipeWriter, cx: &Context<Self>) -> Self {
- let agent = FakeAgent {
- server: cx.entity(),
- cx: cx.to_async(),
- };
- let foreground_executor = cx.foreground_executor().clone();
-
- let (connection, io_fut) = acp::ClientConnection::connect_to_client(
- agent.clone(),
- stdout,
- stdin,
- move |fut| {
- foreground_executor.spawn(fut).detach();
- },
- );
- FakeAcpServer {
- connection: connection,
- on_user_message: None,
- _io_task: cx.background_spawn(async move {
- io_fut.await.log_err();
- }),
- }
- }
-
- fn on_user_message<F>(
- &mut self,
- handler: impl for<'a> Fn(acp::SendUserMessageParams, Entity<FakeAcpServer>, AsyncApp) -> F
- + 'static,
- ) where
- F: Future<Output = Result<(), acp::Error>> + 'static,
- {
- self.on_user_message
- .replace(Rc::new(move |request, server, cx| {
- handler(request, server, cx).boxed_local()
- }));
- }
-
- fn send_to_zed<T: acp::ClientRequest + 'static>(
- &self,
- message: T,
- ) -> BoxedLocal<Result<T::Response>> {
- self.connection
- .request(message)
- .map(|f| f.map_err(|err| anyhow!(err)))
- .boxed_local()
- }
- }
-}
@@ -1,5 +1,5 @@
[package]
-name = "acp"
+name = "acp_thread"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
@@ -9,39 +9,48 @@ license = "GPL-3.0-or-later"
workspace = true
[lib]
-path = "src/acp.rs"
+path = "src/acp_thread.rs"
doctest = false
[features]
-test-support = ["gpui/test-support", "project/test-support"]
-gemini = []
+test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"]
[dependencies]
-agent_servers.workspace = true
-agentic-coding-protocol.workspace = true
+action_log.workspace = true
+agent-client-protocol.workspace = true
anyhow.workspace = true
-assistant_tool.workspace = true
buffer_diff.workspace = true
+collections.workspace = true
editor.workspace = true
+file_icons.workspace = true
futures.workspace = true
gpui.workspace = true
itertools.workspace = true
language.workspace = true
+language_model.workspace = true
markdown.workspace = true
+parking_lot = { workspace = true, optional = true }
project.workspace = true
+prompt_store.workspace = true
+serde.workspace = true
+serde_json.workspace = true
settings.workspace = true
smol.workspace = true
+terminal.workspace = true
ui.workspace = true
+url.workspace = true
util.workspace = true
+uuid.workspace = true
+watch.workspace = true
workspace-hack.workspace = true
[dev-dependencies]
-async-pipe.workspace = true
env_logger.workspace = true
gpui = { workspace = true, "features" = ["test-support"] }
indoc.workspace = true
+parking_lot.workspace = true
project = { workspace = true, "features" = ["test-support"] }
-serde_json.workspace = true
+rand.workspace = true
tempfile.workspace = true
util.workspace = true
settings.workspace = true
@@ -0,0 +1,2687 @@
+mod connection;
+mod diff;
+mod mention;
+mod terminal;
+
+use collections::HashSet;
+pub use connection::*;
+pub use diff::*;
+use language::language_settings::FormatOnSave;
+pub use mention::*;
+use project::lsp_store::{FormatTrigger, LspFormatTarget};
+use serde::{Deserialize, Serialize};
+pub use terminal::*;
+
+use action_log::ActionLog;
+use agent_client_protocol as acp;
+use anyhow::{Context as _, Result, anyhow};
+use editor::Bias;
+use futures::{FutureExt, channel::oneshot, future::BoxFuture};
+use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity};
+use itertools::Itertools;
+use language::{Anchor, Buffer, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff};
+use markdown::Markdown;
+use project::{AgentLocation, Project, git_store::GitStoreCheckpoint};
+use std::collections::HashMap;
+use std::error::Error;
+use std::fmt::{Formatter, Write};
+use std::ops::Range;
+use std::process::ExitStatus;
+use std::rc::Rc;
+use std::time::{Duration, Instant};
+use std::{fmt::Display, mem, path::PathBuf, sync::Arc};
+use ui::App;
+use util::ResultExt;
+
+#[derive(Debug)]
+pub struct UserMessage {
+ pub id: Option<UserMessageId>,
+ pub content: ContentBlock,
+ pub chunks: Vec<acp::ContentBlock>,
+ pub checkpoint: Option<Checkpoint>,
+}
+
+#[derive(Debug)]
+pub struct Checkpoint {
+ git_checkpoint: GitStoreCheckpoint,
+ pub show: bool,
+}
+
+impl UserMessage {
+ fn to_markdown(&self, cx: &App) -> String {
+ let mut markdown = String::new();
+ if self
+ .checkpoint
+ .as_ref()
+ .is_some_and(|checkpoint| checkpoint.show)
+ {
+ writeln!(markdown, "## User (checkpoint)").unwrap();
+ } else {
+ writeln!(markdown, "## User").unwrap();
+ }
+ writeln!(markdown).unwrap();
+ writeln!(markdown, "{}", self.content.to_markdown(cx)).unwrap();
+ writeln!(markdown).unwrap();
+ markdown
+ }
+}
+
+#[derive(Debug, PartialEq)]
+pub struct AssistantMessage {
+ pub chunks: Vec<AssistantMessageChunk>,
+}
+
+impl AssistantMessage {
+ pub fn to_markdown(&self, cx: &App) -> String {
+ format!(
+ "## Assistant\n\n{}\n\n",
+ self.chunks
+ .iter()
+ .map(|chunk| chunk.to_markdown(cx))
+ .join("\n\n")
+ )
+ }
+}
+
+#[derive(Debug, PartialEq)]
+pub enum AssistantMessageChunk {
+ Message { block: ContentBlock },
+ Thought { block: ContentBlock },
+}
+
+impl AssistantMessageChunk {
+ pub fn from_str(chunk: &str, language_registry: &Arc<LanguageRegistry>, cx: &mut App) -> Self {
+ Self::Message {
+ block: ContentBlock::new(chunk.into(), language_registry, cx),
+ }
+ }
+
+ fn to_markdown(&self, cx: &App) -> String {
+ match self {
+ Self::Message { block } => block.to_markdown(cx).to_string(),
+ Self::Thought { block } => {
+ format!("<thinking>\n{}\n</thinking>", block.to_markdown(cx))
+ }
+ }
+ }
+}
+
+#[derive(Debug)]
+pub enum AgentThreadEntry {
+ UserMessage(UserMessage),
+ AssistantMessage(AssistantMessage),
+ ToolCall(ToolCall),
+}
+
+impl AgentThreadEntry {
+ pub fn to_markdown(&self, cx: &App) -> String {
+ match self {
+ Self::UserMessage(message) => message.to_markdown(cx),
+ Self::AssistantMessage(message) => message.to_markdown(cx),
+ Self::ToolCall(tool_call) => tool_call.to_markdown(cx),
+ }
+ }
+
+ pub fn user_message(&self) -> Option<&UserMessage> {
+ if let AgentThreadEntry::UserMessage(message) = self {
+ Some(message)
+ } else {
+ None
+ }
+ }
+
+ pub fn diffs(&self) -> impl Iterator<Item = &Entity<Diff>> {
+ if let AgentThreadEntry::ToolCall(call) = self {
+ itertools::Either::Left(call.diffs())
+ } else {
+ itertools::Either::Right(std::iter::empty())
+ }
+ }
+
+ pub fn terminals(&self) -> impl Iterator<Item = &Entity<Terminal>> {
+ if let AgentThreadEntry::ToolCall(call) = self {
+ itertools::Either::Left(call.terminals())
+ } else {
+ itertools::Either::Right(std::iter::empty())
+ }
+ }
+
+ pub fn location(&self, ix: usize) -> Option<(acp::ToolCallLocation, AgentLocation)> {
+ if let AgentThreadEntry::ToolCall(ToolCall {
+ locations,
+ resolved_locations,
+ ..
+ }) = self
+ {
+ Some((
+ locations.get(ix)?.clone(),
+ resolved_locations.get(ix)?.clone()?,
+ ))
+ } else {
+ None
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct ToolCall {
+ pub id: acp::ToolCallId,
+ pub label: Entity<Markdown>,
+ pub kind: acp::ToolKind,
+ pub content: Vec<ToolCallContent>,
+ pub status: ToolCallStatus,
+ pub locations: Vec<acp::ToolCallLocation>,
+ pub resolved_locations: Vec<Option<AgentLocation>>,
+ pub raw_input: Option<serde_json::Value>,
+ pub raw_output: Option<serde_json::Value>,
+}
+
+impl ToolCall {
+ fn from_acp(
+ tool_call: acp::ToolCall,
+ status: ToolCallStatus,
+ language_registry: Arc<LanguageRegistry>,
+ cx: &mut App,
+ ) -> Self {
+ Self {
+ id: tool_call.id,
+ label: cx.new(|cx| {
+ Markdown::new(
+ tool_call.title.into(),
+ Some(language_registry.clone()),
+ None,
+ cx,
+ )
+ }),
+ kind: tool_call.kind,
+ content: tool_call
+ .content
+ .into_iter()
+ .map(|content| ToolCallContent::from_acp(content, language_registry.clone(), cx))
+ .collect(),
+ locations: tool_call.locations,
+ resolved_locations: Vec::default(),
+ status,
+ raw_input: tool_call.raw_input,
+ raw_output: tool_call.raw_output,
+ }
+ }
+
+ fn update_fields(
+ &mut self,
+ fields: acp::ToolCallUpdateFields,
+ language_registry: Arc<LanguageRegistry>,
+ cx: &mut App,
+ ) {
+ let acp::ToolCallUpdateFields {
+ kind,
+ status,
+ title,
+ content,
+ locations,
+ raw_input,
+ raw_output,
+ } = fields;
+
+ if let Some(kind) = kind {
+ self.kind = kind;
+ }
+
+ if let Some(status) = status {
+ self.status = status.into();
+ }
+
+ if let Some(title) = title {
+ self.label.update(cx, |label, cx| {
+ label.replace(title, cx);
+ });
+ }
+
+ if let Some(content) = content {
+ let new_content_len = content.len();
+ let mut content = content.into_iter();
+
+ // Reuse existing content if we can
+ for (old, new) in self.content.iter_mut().zip(content.by_ref()) {
+ old.update_from_acp(new, language_registry.clone(), cx);
+ }
+ for new in content {
+ self.content.push(ToolCallContent::from_acp(
+ new,
+ language_registry.clone(),
+ cx,
+ ))
+ }
+ self.content.truncate(new_content_len);
+ }
+
+ if let Some(locations) = locations {
+ self.locations = locations;
+ }
+
+ if let Some(raw_input) = raw_input {
+ self.raw_input = Some(raw_input);
+ }
+
+ if let Some(raw_output) = raw_output {
+ if self.content.is_empty()
+ && let Some(markdown) = markdown_for_raw_output(&raw_output, &language_registry, cx)
+ {
+ self.content
+ .push(ToolCallContent::ContentBlock(ContentBlock::Markdown {
+ markdown,
+ }));
+ }
+ self.raw_output = Some(raw_output);
+ }
+ }
+
+ pub fn diffs(&self) -> impl Iterator<Item = &Entity<Diff>> {
+ self.content.iter().filter_map(|content| match content {
+ ToolCallContent::Diff(diff) => Some(diff),
+ ToolCallContent::ContentBlock(_) => None,
+ ToolCallContent::Terminal(_) => None,
+ })
+ }
+
+ pub fn terminals(&self) -> impl Iterator<Item = &Entity<Terminal>> {
+ self.content.iter().filter_map(|content| match content {
+ ToolCallContent::Terminal(terminal) => Some(terminal),
+ ToolCallContent::ContentBlock(_) => None,
+ ToolCallContent::Diff(_) => None,
+ })
+ }
+
+ fn to_markdown(&self, cx: &App) -> String {
+ let mut markdown = format!(
+ "**Tool Call: {}**\nStatus: {}\n\n",
+ self.label.read(cx).source(),
+ self.status
+ );
+ for content in &self.content {
+ markdown.push_str(content.to_markdown(cx).as_str());
+ markdown.push_str("\n\n");
+ }
+ markdown
+ }
+
+ async fn resolve_location(
+ location: acp::ToolCallLocation,
+ project: WeakEntity<Project>,
+ cx: &mut AsyncApp,
+ ) -> Option<AgentLocation> {
+ let buffer = project
+ .update(cx, |project, cx| {
+ project
+ .project_path_for_absolute_path(&location.path, cx)
+ .map(|path| project.open_buffer(path, cx))
+ })
+ .ok()??;
+ let buffer = buffer.await.log_err()?;
+ let position = buffer
+ .update(cx, |buffer, _| {
+ if let Some(row) = location.line {
+ let snapshot = buffer.snapshot();
+ let column = snapshot.indent_size_for_line(row).len;
+ let point = snapshot.clip_point(Point::new(row, column), Bias::Left);
+ snapshot.anchor_before(point)
+ } else {
+ Anchor::MIN
+ }
+ })
+ .ok()?;
+
+ Some(AgentLocation {
+ buffer: buffer.downgrade(),
+ position,
+ })
+ }
+
+ fn resolve_locations(
+ &self,
+ project: Entity<Project>,
+ cx: &mut App,
+ ) -> Task<Vec<Option<AgentLocation>>> {
+ let locations = self.locations.clone();
+ project.update(cx, |_, cx| {
+ cx.spawn(async move |project, cx| {
+ let mut new_locations = Vec::new();
+ for location in locations {
+ new_locations.push(Self::resolve_location(location, project.clone(), cx).await);
+ }
+ new_locations
+ })
+ })
+ }
+}
+
+#[derive(Debug)]
+pub enum ToolCallStatus {
+ /// The tool call hasn't started running yet, but we start showing it to
+ /// the user.
+ Pending,
+ /// The tool call is waiting for confirmation from the user.
+ WaitingForConfirmation {
+ options: Vec<acp::PermissionOption>,
+ respond_tx: oneshot::Sender<acp::PermissionOptionId>,
+ },
+ /// The tool call is currently running.
+ InProgress,
+ /// The tool call completed successfully.
+ Completed,
+ /// The tool call failed.
+ Failed,
+ /// The user rejected the tool call.
+ Rejected,
+ /// The user canceled generation so the tool call was canceled.
+ Canceled,
+}
+
+impl From<acp::ToolCallStatus> for ToolCallStatus {
+ fn from(status: acp::ToolCallStatus) -> Self {
+ match status {
+ acp::ToolCallStatus::Pending => Self::Pending,
+ acp::ToolCallStatus::InProgress => Self::InProgress,
+ acp::ToolCallStatus::Completed => Self::Completed,
+ acp::ToolCallStatus::Failed => Self::Failed,
+ }
+ }
+}
+
+impl Display for ToolCallStatus {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ write!(
+ f,
+ "{}",
+ match self {
+ ToolCallStatus::Pending => "Pending",
+ ToolCallStatus::WaitingForConfirmation { .. } => "Waiting for confirmation",
+ ToolCallStatus::InProgress => "In Progress",
+ ToolCallStatus::Completed => "Completed",
+ ToolCallStatus::Failed => "Failed",
+ ToolCallStatus::Rejected => "Rejected",
+ ToolCallStatus::Canceled => "Canceled",
+ }
+ )
+ }
+}
+
+#[derive(Debug, PartialEq, Clone)]
+pub enum ContentBlock {
+ Empty,
+ Markdown { markdown: Entity<Markdown> },
+ ResourceLink { resource_link: acp::ResourceLink },
+}
+
+impl ContentBlock {
+ pub fn new(
+ block: acp::ContentBlock,
+ language_registry: &Arc<LanguageRegistry>,
+ cx: &mut App,
+ ) -> Self {
+ let mut this = Self::Empty;
+ this.append(block, language_registry, cx);
+ this
+ }
+
+ pub fn new_combined(
+ blocks: impl IntoIterator<Item = acp::ContentBlock>,
+ language_registry: Arc<LanguageRegistry>,
+ cx: &mut App,
+ ) -> Self {
+ let mut this = Self::Empty;
+ for block in blocks {
+ this.append(block, &language_registry, cx);
+ }
+ this
+ }
+
+ pub fn append(
+ &mut self,
+ block: acp::ContentBlock,
+ language_registry: &Arc<LanguageRegistry>,
+ cx: &mut App,
+ ) {
+ if matches!(self, ContentBlock::Empty)
+ && let acp::ContentBlock::ResourceLink(resource_link) = block
+ {
+ *self = ContentBlock::ResourceLink { resource_link };
+ return;
+ }
+
+ let new_content = self.block_string_contents(block);
+
+ match self {
+ ContentBlock::Empty => {
+ *self = Self::create_markdown_block(new_content, language_registry, cx);
+ }
+ ContentBlock::Markdown { markdown } => {
+ markdown.update(cx, |markdown, cx| markdown.append(&new_content, cx));
+ }
+ ContentBlock::ResourceLink { resource_link } => {
+ let existing_content = Self::resource_link_md(&resource_link.uri);
+ let combined = format!("{}\n{}", existing_content, new_content);
+
+ *self = Self::create_markdown_block(combined, language_registry, cx);
+ }
+ }
+ }
+
+ fn create_markdown_block(
+ content: String,
+ language_registry: &Arc<LanguageRegistry>,
+ cx: &mut App,
+ ) -> ContentBlock {
+ ContentBlock::Markdown {
+ markdown: cx
+ .new(|cx| Markdown::new(content.into(), Some(language_registry.clone()), None, cx)),
+ }
+ }
+
+ fn block_string_contents(&self, block: acp::ContentBlock) -> String {
+ match block {
+ acp::ContentBlock::Text(text_content) => text_content.text,
+ acp::ContentBlock::ResourceLink(resource_link) => {
+ Self::resource_link_md(&resource_link.uri)
+ }
+ acp::ContentBlock::Resource(acp::EmbeddedResource {
+ resource:
+ acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents {
+ uri,
+ ..
+ }),
+ ..
+ }) => Self::resource_link_md(&uri),
+ acp::ContentBlock::Image(image) => Self::image_md(&image),
+ acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => String::new(),
+ }
+ }
+
+ fn resource_link_md(uri: &str) -> String {
+ if let Some(uri) = MentionUri::parse(uri).log_err() {
+ uri.as_link().to_string()
+ } else {
+ uri.to_string()
+ }
+ }
+
+ fn image_md(_image: &acp::ImageContent) -> String {
+ "`Image`".into()
+ }
+
+ pub fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str {
+ match self {
+ ContentBlock::Empty => "",
+ ContentBlock::Markdown { markdown } => markdown.read(cx).source(),
+ ContentBlock::ResourceLink { resource_link } => &resource_link.uri,
+ }
+ }
+
+ pub fn markdown(&self) -> Option<&Entity<Markdown>> {
+ match self {
+ ContentBlock::Empty => None,
+ ContentBlock::Markdown { markdown } => Some(markdown),
+ ContentBlock::ResourceLink { .. } => None,
+ }
+ }
+
+ pub fn resource_link(&self) -> Option<&acp::ResourceLink> {
+ match self {
+ ContentBlock::ResourceLink { resource_link } => Some(resource_link),
+ _ => None,
+ }
+ }
+}
+
+#[derive(Debug)]
+pub enum ToolCallContent {
+ ContentBlock(ContentBlock),
+ Diff(Entity<Diff>),
+ Terminal(Entity<Terminal>),
+}
+
+impl ToolCallContent {
+ pub fn from_acp(
+ content: acp::ToolCallContent,
+ language_registry: Arc<LanguageRegistry>,
+ cx: &mut App,
+ ) -> Self {
+ match content {
+ acp::ToolCallContent::Content { content } => {
+ Self::ContentBlock(ContentBlock::new(content, &language_registry, cx))
+ }
+ acp::ToolCallContent::Diff { diff } => Self::Diff(cx.new(|cx| {
+ Diff::finalized(
+ diff.path,
+ diff.old_text,
+ diff.new_text,
+ language_registry,
+ cx,
+ )
+ })),
+ }
+ }
+
+ pub fn update_from_acp(
+ &mut self,
+ new: acp::ToolCallContent,
+ language_registry: Arc<LanguageRegistry>,
+ cx: &mut App,
+ ) {
+ let needs_update = match (&self, &new) {
+ (Self::Diff(old_diff), acp::ToolCallContent::Diff { diff: new_diff }) => {
+ old_diff.read(cx).needs_update(
+ new_diff.old_text.as_deref().unwrap_or(""),
+ &new_diff.new_text,
+ cx,
+ )
+ }
+ _ => true,
+ };
+
+ if needs_update {
+ *self = Self::from_acp(new, language_registry, cx);
+ }
+ }
+
+ pub fn to_markdown(&self, cx: &App) -> String {
+ match self {
+ Self::ContentBlock(content) => content.to_markdown(cx).to_string(),
+ Self::Diff(diff) => diff.read(cx).to_markdown(cx),
+ Self::Terminal(terminal) => terminal.read(cx).to_markdown(cx),
+ }
+ }
+}
+
+#[derive(Debug, PartialEq)]
+pub enum ToolCallUpdate {
+ UpdateFields(acp::ToolCallUpdate),
+ UpdateDiff(ToolCallUpdateDiff),
+ UpdateTerminal(ToolCallUpdateTerminal),
+}
+
+impl ToolCallUpdate {
+ fn id(&self) -> &acp::ToolCallId {
+ match self {
+ Self::UpdateFields(update) => &update.id,
+ Self::UpdateDiff(diff) => &diff.id,
+ Self::UpdateTerminal(terminal) => &terminal.id,
+ }
+ }
+}
+
+impl From<acp::ToolCallUpdate> for ToolCallUpdate {
+ fn from(update: acp::ToolCallUpdate) -> Self {
+ Self::UpdateFields(update)
+ }
+}
+
+impl From<ToolCallUpdateDiff> for ToolCallUpdate {
+ fn from(diff: ToolCallUpdateDiff) -> Self {
+ Self::UpdateDiff(diff)
+ }
+}
+
+#[derive(Debug, PartialEq)]
+pub struct ToolCallUpdateDiff {
+ pub id: acp::ToolCallId,
+ pub diff: Entity<Diff>,
+}
+
+impl From<ToolCallUpdateTerminal> for ToolCallUpdate {
+ fn from(terminal: ToolCallUpdateTerminal) -> Self {
+ Self::UpdateTerminal(terminal)
+ }
+}
+
+#[derive(Debug, PartialEq)]
+pub struct ToolCallUpdateTerminal {
+ pub id: acp::ToolCallId,
+ pub terminal: Entity<Terminal>,
+}
+
+#[derive(Debug, Default)]
+pub struct Plan {
+ pub entries: Vec<PlanEntry>,
+}
+
+#[derive(Debug)]
+pub struct PlanStats<'a> {
+ pub in_progress_entry: Option<&'a PlanEntry>,
+ pub pending: u32,
+ pub completed: u32,
+}
+
+impl Plan {
+ pub fn is_empty(&self) -> bool {
+ self.entries.is_empty()
+ }
+
+ pub fn stats(&self) -> PlanStats<'_> {
+ let mut stats = PlanStats {
+ in_progress_entry: None,
+ pending: 0,
+ completed: 0,
+ };
+
+ for entry in &self.entries {
+ match &entry.status {
+ acp::PlanEntryStatus::Pending => {
+ stats.pending += 1;
+ }
+ acp::PlanEntryStatus::InProgress => {
+ stats.in_progress_entry = stats.in_progress_entry.or(Some(entry));
+ }
+ acp::PlanEntryStatus::Completed => {
+ stats.completed += 1;
+ }
+ }
+ }
+
+ stats
+ }
+}
+
+#[derive(Debug)]
+pub struct PlanEntry {
+ pub content: Entity<Markdown>,
+ pub priority: acp::PlanEntryPriority,
+ pub status: acp::PlanEntryStatus,
+}
+
+impl PlanEntry {
+ pub fn from_acp(entry: acp::PlanEntry, cx: &mut App) -> Self {
+ Self {
+ content: cx.new(|cx| Markdown::new(entry.content.into(), None, None, cx)),
+ priority: entry.priority,
+ status: entry.status,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct TokenUsage {
+ pub max_tokens: u64,
+ pub used_tokens: u64,
+}
+
+impl TokenUsage {
+ pub fn ratio(&self) -> TokenUsageRatio {
+ #[cfg(debug_assertions)]
+ let warning_threshold: f32 = std::env::var("ZED_THREAD_WARNING_THRESHOLD")
+ .unwrap_or("0.8".to_string())
+ .parse()
+ .unwrap();
+ #[cfg(not(debug_assertions))]
+ let warning_threshold: f32 = 0.8;
+
+ // When the maximum is unknown because there is no selected model,
+ // avoid showing the token limit warning.
+ if self.max_tokens == 0 {
+ TokenUsageRatio::Normal
+ } else if self.used_tokens >= self.max_tokens {
+ TokenUsageRatio::Exceeded
+ } else if self.used_tokens as f32 / self.max_tokens as f32 >= warning_threshold {
+ TokenUsageRatio::Warning
+ } else {
+ TokenUsageRatio::Normal
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum TokenUsageRatio {
+ Normal,
+ Warning,
+ Exceeded,
+}
+
+#[derive(Debug, Clone)]
+pub struct RetryStatus {
+ pub last_error: SharedString,
+ pub attempt: usize,
+ pub max_attempts: usize,
+ pub started_at: Instant,
+ pub duration: Duration,
+}
+
+pub struct AcpThread {
+ title: SharedString,
+ entries: Vec<AgentThreadEntry>,
+ plan: Plan,
+ project: Entity<Project>,
+ action_log: Entity<ActionLog>,
+ shared_buffers: HashMap<Entity<Buffer>, BufferSnapshot>,
+ send_task: Option<Task<()>>,
+ connection: Rc<dyn AgentConnection>,
+ session_id: acp::SessionId,
+ token_usage: Option<TokenUsage>,
+}
+
+#[derive(Debug)]
+pub enum AcpThreadEvent {
+ NewEntry,
+ TitleUpdated,
+ TokenUsageUpdated,
+ EntryUpdated(usize),
+ EntriesRemoved(Range<usize>),
+ ToolAuthorizationRequired,
+ Retry(RetryStatus),
+ Stopped,
+ Error,
+ LoadError(LoadError),
+}
+
+impl EventEmitter<AcpThreadEvent> for AcpThread {}
+
+#[derive(PartialEq, Eq)]
+pub enum ThreadStatus {
+ Idle,
+ WaitingForToolConfirmation,
+ Generating,
+}
+
+#[derive(Debug, Clone)]
+pub enum LoadError {
+ NotInstalled {
+ error_message: SharedString,
+ install_message: SharedString,
+ install_command: String,
+ },
+ Unsupported {
+ error_message: SharedString,
+ upgrade_message: SharedString,
+ upgrade_command: String,
+ },
+ Exited {
+ status: ExitStatus,
+ },
+ Other(SharedString),
+}
+
+impl Display for LoadError {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ match self {
+ LoadError::NotInstalled { error_message, .. }
+ | LoadError::Unsupported { error_message, .. } => {
+ write!(f, "{error_message}")
+ }
+ LoadError::Exited { status } => write!(f, "Server exited with status {status}"),
+ LoadError::Other(msg) => write!(f, "{}", msg),
+ }
+ }
+}
+
+impl Error for LoadError {}
+
+impl AcpThread {
+ pub fn new(
+ title: impl Into<SharedString>,
+ connection: Rc<dyn AgentConnection>,
+ project: Entity<Project>,
+ action_log: Entity<ActionLog>,
+ session_id: acp::SessionId,
+ ) -> Self {
+ Self {
+ action_log,
+ shared_buffers: Default::default(),
+ entries: Default::default(),
+ plan: Default::default(),
+ title: title.into(),
+ project,
+ send_task: None,
+ connection,
+ session_id,
+ token_usage: None,
+ }
+ }
+
+ pub fn connection(&self) -> &Rc<dyn AgentConnection> {
+ &self.connection
+ }
+
+ pub fn action_log(&self) -> &Entity<ActionLog> {
+ &self.action_log
+ }
+
+ pub fn project(&self) -> &Entity<Project> {
+ &self.project
+ }
+
+ pub fn title(&self) -> SharedString {
+ self.title.clone()
+ }
+
+ pub fn entries(&self) -> &[AgentThreadEntry] {
+ &self.entries
+ }
+
+ pub fn session_id(&self) -> &acp::SessionId {
+ &self.session_id
+ }
+
+ pub fn status(&self) -> ThreadStatus {
+ if self.send_task.is_some() {
+ if self.waiting_for_tool_confirmation() {
+ ThreadStatus::WaitingForToolConfirmation
+ } else {
+ ThreadStatus::Generating
+ }
+ } else {
+ ThreadStatus::Idle
+ }
+ }
+
+ pub fn token_usage(&self) -> Option<&TokenUsage> {
+ self.token_usage.as_ref()
+ }
+
+ pub fn has_pending_edit_tool_calls(&self) -> bool {
+ for entry in self.entries.iter().rev() {
+ match entry {
+ AgentThreadEntry::UserMessage(_) => return false,
+ AgentThreadEntry::ToolCall(
+ call @ ToolCall {
+ status: ToolCallStatus::InProgress | ToolCallStatus::Pending,
+ ..
+ },
+ ) if call.diffs().next().is_some() => {
+ return true;
+ }
+ AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {}
+ }
+ }
+
+ false
+ }
+
+ pub fn used_tools_since_last_user_message(&self) -> bool {
+ for entry in self.entries.iter().rev() {
+ match entry {
+ AgentThreadEntry::UserMessage(..) => return false,
+ AgentThreadEntry::AssistantMessage(..) => continue,
+ AgentThreadEntry::ToolCall(..) => return true,
+ }
+ }
+
+ false
+ }
+
+ pub fn handle_session_update(
+ &mut self,
+ update: acp::SessionUpdate,
+ cx: &mut Context<Self>,
+ ) -> Result<(), acp::Error> {
+ match update {
+ acp::SessionUpdate::UserMessageChunk { content } => {
+ self.push_user_content_block(None, content, cx);
+ }
+ acp::SessionUpdate::AgentMessageChunk { content } => {
+ self.push_assistant_content_block(content, false, cx);
+ }
+ acp::SessionUpdate::AgentThoughtChunk { content } => {
+ self.push_assistant_content_block(content, true, cx);
+ }
+ acp::SessionUpdate::ToolCall(tool_call) => {
+ self.upsert_tool_call(tool_call, cx)?;
+ }
+ acp::SessionUpdate::ToolCallUpdate(tool_call_update) => {
+ self.update_tool_call(tool_call_update, cx)?;
+ }
+ acp::SessionUpdate::Plan(plan) => {
+ self.update_plan(plan, cx);
+ }
+ }
+ Ok(())
+ }
+
+ pub fn push_user_content_block(
+ &mut self,
+ message_id: Option<UserMessageId>,
+ chunk: acp::ContentBlock,
+ cx: &mut Context<Self>,
+ ) {
+ let language_registry = self.project.read(cx).languages().clone();
+ let entries_len = self.entries.len();
+
+ if let Some(last_entry) = self.entries.last_mut()
+ && let AgentThreadEntry::UserMessage(UserMessage {
+ id,
+ content,
+ chunks,
+ ..
+ }) = last_entry
+ {
+ *id = message_id.or(id.take());
+ content.append(chunk.clone(), &language_registry, cx);
+ chunks.push(chunk);
+ let idx = entries_len - 1;
+ cx.emit(AcpThreadEvent::EntryUpdated(idx));
+ } else {
+ let content = ContentBlock::new(chunk.clone(), &language_registry, cx);
+ self.push_entry(
+ AgentThreadEntry::UserMessage(UserMessage {
+ id: message_id,
+ content,
+ chunks: vec![chunk],
+ checkpoint: None,
+ }),
+ cx,
+ );
+ }
+ }
+
+ pub fn push_assistant_content_block(
+ &mut self,
+ chunk: acp::ContentBlock,
+ is_thought: bool,
+ cx: &mut Context<Self>,
+ ) {
+ let language_registry = self.project.read(cx).languages().clone();
+ let entries_len = self.entries.len();
+ if let Some(last_entry) = self.entries.last_mut()
+ && let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry
+ {
+ let idx = entries_len - 1;
+ cx.emit(AcpThreadEvent::EntryUpdated(idx));
+ match (chunks.last_mut(), is_thought) {
+ (Some(AssistantMessageChunk::Message { block }), false)
+ | (Some(AssistantMessageChunk::Thought { block }), true) => {
+ block.append(chunk, &language_registry, cx)
+ }
+ _ => {
+ let block = ContentBlock::new(chunk, &language_registry, cx);
+ if is_thought {
+ chunks.push(AssistantMessageChunk::Thought { block })
+ } else {
+ chunks.push(AssistantMessageChunk::Message { block })
+ }
+ }
+ }
+ } else {
+ let block = ContentBlock::new(chunk, &language_registry, cx);
+ let chunk = if is_thought {
+ AssistantMessageChunk::Thought { block }
+ } else {
+ AssistantMessageChunk::Message { block }
+ };
+
+ self.push_entry(
+ AgentThreadEntry::AssistantMessage(AssistantMessage {
+ chunks: vec![chunk],
+ }),
+ cx,
+ );
+ }
+ }
+
+ fn push_entry(&mut self, entry: AgentThreadEntry, cx: &mut Context<Self>) {
+ self.entries.push(entry);
+ cx.emit(AcpThreadEvent::NewEntry);
+ }
+
+ pub fn can_set_title(&mut self, cx: &mut Context<Self>) -> bool {
+ self.connection.set_title(&self.session_id, cx).is_some()
+ }
+
+ pub fn set_title(&mut self, title: SharedString, cx: &mut Context<Self>) -> Task<Result<()>> {
+ if title != self.title {
+ self.title = title.clone();
+ cx.emit(AcpThreadEvent::TitleUpdated);
+ if let Some(set_title) = self.connection.set_title(&self.session_id, cx) {
+ return set_title.run(title, cx);
+ }
+ }
+ Task::ready(Ok(()))
+ }
+
+ pub fn update_token_usage(&mut self, usage: Option<TokenUsage>, cx: &mut Context<Self>) {
+ self.token_usage = usage;
+ cx.emit(AcpThreadEvent::TokenUsageUpdated);
+ }
+
+ pub fn update_retry_status(&mut self, status: RetryStatus, cx: &mut Context<Self>) {
+ cx.emit(AcpThreadEvent::Retry(status));
+ }
+
+ pub fn update_tool_call(
+ &mut self,
+ update: impl Into<ToolCallUpdate>,
+ cx: &mut Context<Self>,
+ ) -> Result<()> {
+ let update = update.into();
+ let languages = self.project.read(cx).languages().clone();
+
+ let (ix, current_call) = self
+ .tool_call_mut(update.id())
+ .context("Tool call not found")?;
+ match update {
+ ToolCallUpdate::UpdateFields(update) => {
+ let location_updated = update.fields.locations.is_some();
+ current_call.update_fields(update.fields, languages, cx);
+ if location_updated {
+ self.resolve_locations(update.id, cx);
+ }
+ }
+ ToolCallUpdate::UpdateDiff(update) => {
+ current_call.content.clear();
+ current_call
+ .content
+ .push(ToolCallContent::Diff(update.diff));
+ }
+ ToolCallUpdate::UpdateTerminal(update) => {
+ current_call.content.clear();
+ current_call
+ .content
+ .push(ToolCallContent::Terminal(update.terminal));
+ }
+ }
+
+ cx.emit(AcpThreadEvent::EntryUpdated(ix));
+
+ Ok(())
+ }
+
+ /// Updates a tool call if id matches an existing entry, otherwise inserts a new one.
+ pub fn upsert_tool_call(
+ &mut self,
+ tool_call: acp::ToolCall,
+ cx: &mut Context<Self>,
+ ) -> Result<(), acp::Error> {
+ let status = tool_call.status.into();
+ self.upsert_tool_call_inner(tool_call.into(), status, cx)
+ }
+
+ /// Fails if id does not match an existing entry.
+ pub fn upsert_tool_call_inner(
+ &mut self,
+ tool_call_update: acp::ToolCallUpdate,
+ status: ToolCallStatus,
+ cx: &mut Context<Self>,
+ ) -> Result<(), acp::Error> {
+ let language_registry = self.project.read(cx).languages().clone();
+ let id = tool_call_update.id.clone();
+
+ if let Some((ix, current_call)) = self.tool_call_mut(&id) {
+ current_call.update_fields(tool_call_update.fields, language_registry, cx);
+ current_call.status = status;
+
+ cx.emit(AcpThreadEvent::EntryUpdated(ix));
+ } else {
+ let call =
+ ToolCall::from_acp(tool_call_update.try_into()?, status, language_registry, cx);
+ self.push_entry(AgentThreadEntry::ToolCall(call), cx);
+ };
+
+ self.resolve_locations(id, cx);
+ Ok(())
+ }
+
+ fn tool_call_mut(&mut self, id: &acp::ToolCallId) -> Option<(usize, &mut ToolCall)> {
+ // The tool call we are looking for is typically the last one, or very close to the end.
+ // At the moment, it doesn't seem like a hashmap would be a good fit for this use case.
+ self.entries
+ .iter_mut()
+ .enumerate()
+ .rev()
+ .find_map(|(index, tool_call)| {
+ if let AgentThreadEntry::ToolCall(tool_call) = tool_call
+ && &tool_call.id == id
+ {
+ Some((index, tool_call))
+ } else {
+ None
+ }
+ })
+ }
+
+ pub fn tool_call(&mut self, id: &acp::ToolCallId) -> Option<(usize, &ToolCall)> {
+ self.entries
+ .iter()
+ .enumerate()
+ .rev()
+ .find_map(|(index, tool_call)| {
+ if let AgentThreadEntry::ToolCall(tool_call) = tool_call
+ && &tool_call.id == id
+ {
+ Some((index, tool_call))
+ } else {
+ None
+ }
+ })
+ }
+
+ pub fn resolve_locations(&mut self, id: acp::ToolCallId, cx: &mut Context<Self>) {
+ let project = self.project.clone();
+ let Some((_, tool_call)) = self.tool_call_mut(&id) else {
+ return;
+ };
+ let task = tool_call.resolve_locations(project, cx);
+ cx.spawn(async move |this, cx| {
+ let resolved_locations = task.await;
+ this.update(cx, |this, cx| {
+ let project = this.project.clone();
+ let Some((ix, tool_call)) = this.tool_call_mut(&id) else {
+ return;
+ };
+ if let Some(Some(location)) = resolved_locations.last() {
+ project.update(cx, |project, cx| {
+ if let Some(agent_location) = project.agent_location() {
+ let should_ignore = agent_location.buffer == location.buffer
+ && location
+ .buffer
+ .update(cx, |buffer, _| {
+ let snapshot = buffer.snapshot();
+ let old_position =
+ agent_location.position.to_point(&snapshot);
+ let new_position = location.position.to_point(&snapshot);
+ // ignore this so that when we get updates from the edit tool
+ // the position doesn't reset to the startof line
+ old_position.row == new_position.row
+ && old_position.column > new_position.column
+ })
+ .ok()
+ .unwrap_or_default();
+ if !should_ignore {
+ project.set_agent_location(Some(location.clone()), cx);
+ }
+ }
+ });
+ }
+ if tool_call.resolved_locations != resolved_locations {
+ tool_call.resolved_locations = resolved_locations;
+ cx.emit(AcpThreadEvent::EntryUpdated(ix));
+ }
+ })
+ })
+ .detach();
+ }
+
+ pub fn request_tool_call_authorization(
+ &mut self,
+ tool_call: acp::ToolCallUpdate,
+ options: Vec<acp::PermissionOption>,
+ cx: &mut Context<Self>,
+ ) -> Result<oneshot::Receiver<acp::PermissionOptionId>, acp::Error> {
+ let (tx, rx) = oneshot::channel();
+
+ let status = ToolCallStatus::WaitingForConfirmation {
+ options,
+ respond_tx: tx,
+ };
+
+ self.upsert_tool_call_inner(tool_call, status, cx)?;
+ cx.emit(AcpThreadEvent::ToolAuthorizationRequired);
+ Ok(rx)
+ }
+
+ pub fn authorize_tool_call(
+ &mut self,
+ id: acp::ToolCallId,
+ option_id: acp::PermissionOptionId,
+ option_kind: acp::PermissionOptionKind,
+ cx: &mut Context<Self>,
+ ) {
+ let Some((ix, call)) = self.tool_call_mut(&id) else {
+ return;
+ };
+
+ let new_status = match option_kind {
+ acp::PermissionOptionKind::RejectOnce | acp::PermissionOptionKind::RejectAlways => {
+ ToolCallStatus::Rejected
+ }
+ acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways => {
+ ToolCallStatus::InProgress
+ }
+ };
+
+ let curr_status = mem::replace(&mut call.status, new_status);
+
+ if let ToolCallStatus::WaitingForConfirmation { respond_tx, .. } = curr_status {
+ respond_tx.send(option_id).log_err();
+ } else if cfg!(debug_assertions) {
+ panic!("tried to authorize an already authorized tool call");
+ }
+
+ cx.emit(AcpThreadEvent::EntryUpdated(ix));
+ }
+
+ /// Returns true if the last turn is awaiting tool authorization
+ pub fn waiting_for_tool_confirmation(&self) -> bool {
+ for entry in self.entries.iter().rev() {
+ match &entry {
+ AgentThreadEntry::ToolCall(call) => match call.status {
+ ToolCallStatus::WaitingForConfirmation { .. } => return true,
+ ToolCallStatus::Pending
+ | ToolCallStatus::InProgress
+ | ToolCallStatus::Completed
+ | ToolCallStatus::Failed
+ | ToolCallStatus::Rejected
+ | ToolCallStatus::Canceled => continue,
+ },
+ AgentThreadEntry::UserMessage(_) | AgentThreadEntry::AssistantMessage(_) => {
+ // Reached the beginning of the turn
+ return false;
+ }
+ }
+ }
+ false
+ }
+
+ pub fn plan(&self) -> &Plan {
+ &self.plan
+ }
+
+ pub fn update_plan(&mut self, request: acp::Plan, cx: &mut Context<Self>) {
+ let new_entries_len = request.entries.len();
+ let mut new_entries = request.entries.into_iter();
+
+ // Reuse existing markdown to prevent flickering
+ for (old, new) in self.plan.entries.iter_mut().zip(new_entries.by_ref()) {
+ let PlanEntry {
+ content,
+ priority,
+ status,
+ } = old;
+ content.update(cx, |old, cx| {
+ old.replace(new.content, cx);
+ });
+ *priority = new.priority;
+ *status = new.status;
+ }
+ for new in new_entries {
+ self.plan.entries.push(PlanEntry::from_acp(new, cx))
+ }
+ self.plan.entries.truncate(new_entries_len);
+
+ cx.notify();
+ }
+
+ fn clear_completed_plan_entries(&mut self, cx: &mut Context<Self>) {
+ self.plan
+ .entries
+ .retain(|entry| !matches!(entry.status, acp::PlanEntryStatus::Completed));
+ cx.notify();
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn send_raw(
+ &mut self,
+ message: &str,
+ cx: &mut Context<Self>,
+ ) -> BoxFuture<'static, Result<()>> {
+ self.send(
+ vec![acp::ContentBlock::Text(acp::TextContent {
+ text: message.to_string(),
+ annotations: None,
+ })],
+ cx,
+ )
+ }
+
+ pub fn send(
+ &mut self,
+ message: Vec<acp::ContentBlock>,
+ cx: &mut Context<Self>,
+ ) -> BoxFuture<'static, Result<()>> {
+ let block = ContentBlock::new_combined(
+ message.clone(),
+ self.project.read(cx).languages().clone(),
+ cx,
+ );
+ let request = acp::PromptRequest {
+ prompt: message.clone(),
+ session_id: self.session_id.clone(),
+ };
+ let git_store = self.project.read(cx).git_store().clone();
+
+ let message_id = if self.connection.truncate(&self.session_id, cx).is_some() {
+ Some(UserMessageId::new())
+ } else {
+ None
+ };
+
+ self.run_turn(cx, async move |this, cx| {
+ this.update(cx, |this, cx| {
+ this.push_entry(
+ AgentThreadEntry::UserMessage(UserMessage {
+ id: message_id.clone(),
+ content: block,
+ chunks: message,
+ checkpoint: None,
+ }),
+ 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,
+ });
+ }
+ this.connection.prompt(message_id, request, cx)
+ })?
+ .await
+ })
+ }
+
+ pub fn can_resume(&self, cx: &App) -> bool {
+ self.connection.resume(&self.session_id, cx).is_some()
+ }
+
+ pub fn resume(&mut self, cx: &mut Context<Self>) -> BoxFuture<'static, Result<()>> {
+ self.run_turn(cx, async move |this, cx| {
+ this.update(cx, |this, cx| {
+ this.connection
+ .resume(&this.session_id, cx)
+ .map(|resume| resume.run(cx))
+ })?
+ .context("resuming a session is not supported")?
+ .await
+ })
+ }
+
+ fn run_turn(
+ &mut self,
+ cx: &mut Context<Self>,
+ f: impl 'static + AsyncFnOnce(WeakEntity<Self>, &mut AsyncApp) -> Result<acp::PromptResponse>,
+ ) -> BoxFuture<'static, Result<()>> {
+ self.clear_completed_plan_entries(cx);
+
+ let (tx, rx) = oneshot::channel();
+ let cancel_task = self.cancel(cx);
+
+ self.send_task = Some(cx.spawn(async move |this, cx| {
+ cancel_task.await;
+ tx.send(f(this, cx).await).ok();
+ }));
+
+ cx.spawn(async move |this, cx| {
+ let response = rx.await;
+
+ this.update(cx, |this, cx| this.update_last_checkpoint(cx))?
+ .await?;
+
+ this.update(cx, |this, cx| {
+ this.project
+ .update(cx, |project, cx| project.set_agent_location(None, cx));
+ match response {
+ Ok(Err(e)) => {
+ this.send_task.take();
+ cx.emit(AcpThreadEvent::Error);
+ Err(e)
+ }
+ result => {
+ let canceled = matches!(
+ result,
+ Ok(Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::Cancelled
+ }))
+ );
+
+ // We only take the task if the current prompt wasn't canceled.
+ //
+ // This prompt may have been canceled because another one was sent
+ // while it was still generating. In these cases, dropping `send_task`
+ // would cause the next generation to be canceled.
+ if !canceled {
+ this.send_task.take();
+ }
+
+ // Truncate entries if the last prompt was refused.
+ if let Ok(Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::Refusal,
+ })) = result
+ && let Some((ix, _)) = this.last_user_message()
+ {
+ let range = ix..this.entries.len();
+ this.entries.truncate(ix);
+ cx.emit(AcpThreadEvent::EntriesRemoved(range));
+ }
+
+ cx.emit(AcpThreadEvent::Stopped);
+ Ok(())
+ }
+ }
+ })?
+ })
+ .boxed()
+ }
+
+ pub fn cancel(&mut self, cx: &mut Context<Self>) -> Task<()> {
+ let Some(send_task) = self.send_task.take() else {
+ return Task::ready(());
+ };
+
+ 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;
+ }
+ }
+ }
+
+ self.connection.cancel(&self.session_id, cx);
+
+ // Wait for the send task to complete
+ cx.foreground_executor().spawn(send_task)
+ }
+
+ /// Rewinds this thread to before the entry at `index`, removing it and all
+ /// subsequent entries while reverting any changes made from that point.
+ pub fn rewind(&mut self, id: UserMessageId, cx: &mut Context<Self>) -> Task<Result<()>> {
+ let Some(truncate) = self.connection.truncate(&self.session_id, cx) else {
+ return Task::ready(Err(anyhow!("not supported")));
+ };
+ let Some(message) = self.user_message(&id) else {
+ return Task::ready(Err(anyhow!("message not found")));
+ };
+
+ let checkpoint = message
+ .checkpoint
+ .as_ref()
+ .map(|c| c.git_checkpoint.clone());
+
+ let git_store = self.project.read(cx).git_store().clone();
+ cx.spawn(async move |this, cx| {
+ if let Some(checkpoint) = checkpoint {
+ git_store
+ .update(cx, |git, cx| git.restore_checkpoint(checkpoint, cx))?
+ .await?;
+ }
+
+ cx.update(|cx| truncate.run(id.clone(), cx))?.await?;
+ this.update(cx, |this, cx| {
+ if let Some((ix, _)) = this.user_message_mut(&id) {
+ let range = ix..this.entries.len();
+ this.entries.truncate(ix);
+ cx.emit(AcpThreadEvent::EntriesRemoved(range));
+ }
+ })
+ })
+ }
+
+ fn update_last_checkpoint(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
+ let git_store = self.project.read(cx).git_store().clone();
+
+ let old_checkpoint = if let Some((_, message)) = self.last_user_message() {
+ if let Some(checkpoint) = message.checkpoint.as_ref() {
+ checkpoint.git_checkpoint.clone()
+ } else {
+ return Task::ready(Ok(()));
+ }
+ } else {
+ return Task::ready(Ok(()));
+ };
+
+ let new_checkpoint = git_store.update(cx, |git, cx| git.checkpoint(cx));
+ cx.spawn(async move |this, cx| {
+ let new_checkpoint = new_checkpoint
+ .await
+ .context("failed to get new checkpoint")
+ .log_err();
+ if let Some(new_checkpoint) = new_checkpoint {
+ let equal = git_store
+ .update(cx, |git, cx| {
+ git.compare_checkpoints(old_checkpoint.clone(), new_checkpoint, cx)
+ })?
+ .await
+ .unwrap_or(true);
+ this.update(cx, |this, cx| {
+ let (ix, message) = this.last_user_message().context("no user message")?;
+ let checkpoint = message.checkpoint.as_mut().context("no checkpoint")?;
+ checkpoint.show = !equal;
+ cx.emit(AcpThreadEvent::EntryUpdated(ix));
+ anyhow::Ok(())
+ })??;
+ }
+
+ Ok(())
+ })
+ }
+
+ fn last_user_message(&mut self) -> Option<(usize, &mut UserMessage)> {
+ self.entries
+ .iter_mut()
+ .enumerate()
+ .rev()
+ .find_map(|(ix, entry)| {
+ if let AgentThreadEntry::UserMessage(message) = entry {
+ Some((ix, message))
+ } else {
+ None
+ }
+ })
+ }
+
+ fn user_message(&self, id: &UserMessageId) -> Option<&UserMessage> {
+ self.entries.iter().find_map(|entry| {
+ if let AgentThreadEntry::UserMessage(message) = entry {
+ if message.id.as_ref() == Some(id) {
+ Some(message)
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ })
+ }
+
+ fn user_message_mut(&mut self, id: &UserMessageId) -> Option<(usize, &mut UserMessage)> {
+ self.entries.iter_mut().enumerate().find_map(|(ix, entry)| {
+ if let AgentThreadEntry::UserMessage(message) = entry {
+ if message.id.as_ref() == Some(id) {
+ Some((ix, message))
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ })
+ }
+
+ pub fn read_text_file(
+ &self,
+ path: PathBuf,
+ line: Option<u32>,
+ limit: Option<u32>,
+ reuse_shared_snapshot: bool,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<String>> {
+ let project = self.project.clone();
+ let action_log = self.action_log.clone();
+ cx.spawn(async move |this, cx| {
+ let load = project.update(cx, |project, cx| {
+ let path = project
+ .project_path_for_absolute_path(&path, cx)
+ .context("invalid path")?;
+ anyhow::Ok(project.open_buffer(path, cx))
+ });
+ let buffer = load??.await?;
+
+ let snapshot = if reuse_shared_snapshot {
+ this.read_with(cx, |this, _| {
+ this.shared_buffers.get(&buffer.clone()).cloned()
+ })
+ .log_err()
+ .flatten()
+ } else {
+ None
+ };
+
+ let snapshot = if let Some(snapshot) = snapshot {
+ snapshot
+ } else {
+ action_log.update(cx, |action_log, cx| {
+ action_log.buffer_read(buffer.clone(), cx);
+ })?;
+ project.update(cx, |project, cx| {
+ let position = buffer
+ .read(cx)
+ .snapshot()
+ .anchor_before(Point::new(line.unwrap_or_default(), 0));
+ project.set_agent_location(
+ Some(AgentLocation {
+ buffer: buffer.downgrade(),
+ position,
+ }),
+ cx,
+ );
+ })?;
+
+ buffer.update(cx, |buffer, _| buffer.snapshot())?
+ };
+
+ this.update(cx, |this, _| {
+ let text = snapshot.text();
+ this.shared_buffers.insert(buffer.clone(), snapshot);
+ if line.is_none() && limit.is_none() {
+ return Ok(text);
+ }
+ let limit = limit.unwrap_or(u32::MAX) as usize;
+ let Some(line) = line else {
+ return Ok(text.lines().take(limit).collect::<String>());
+ };
+
+ let count = text.lines().count();
+ if count < line as usize {
+ anyhow::bail!("There are only {} lines", count);
+ }
+ Ok(text
+ .lines()
+ .skip(line as usize + 1)
+ .take(limit)
+ .collect::<String>())
+ })?
+ })
+ }
+
+ pub fn write_text_file(
+ &self,
+ path: PathBuf,
+ content: String,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ let project = self.project.clone();
+ let action_log = self.action_log.clone();
+ cx.spawn(async move |this, cx| {
+ let load = project.update(cx, |project, cx| {
+ let path = project
+ .project_path_for_absolute_path(&path, cx)
+ .context("invalid path")?;
+ anyhow::Ok(project.open_buffer(path, cx))
+ });
+ let buffer = load??.await?;
+ let snapshot = this.update(cx, |this, cx| {
+ this.shared_buffers
+ .get(&buffer)
+ .cloned()
+ .unwrap_or_else(|| buffer.read(cx).snapshot())
+ })?;
+ let edits = cx
+ .background_executor()
+ .spawn(async move {
+ let old_text = snapshot.text();
+ text_diff(old_text.as_str(), &content)
+ .into_iter()
+ .map(|(range, replacement)| {
+ (
+ snapshot.anchor_after(range.start)
+ ..snapshot.anchor_before(range.end),
+ replacement,
+ )
+ })
+ .collect::<Vec<_>>()
+ })
+ .await;
+
+ project.update(cx, |project, cx| {
+ project.set_agent_location(
+ Some(AgentLocation {
+ buffer: buffer.downgrade(),
+ position: edits
+ .last()
+ .map(|(range, _)| range.end)
+ .unwrap_or(Anchor::MIN),
+ }),
+ cx,
+ );
+ })?;
+
+ let format_on_save = cx.update(|cx| {
+ action_log.update(cx, |action_log, cx| {
+ action_log.buffer_read(buffer.clone(), cx);
+ });
+
+ let format_on_save = buffer.update(cx, |buffer, cx| {
+ buffer.edit(edits, None, cx);
+
+ let settings = language::language_settings::language_settings(
+ buffer.language().map(|l| l.name()),
+ buffer.file(),
+ cx,
+ );
+
+ settings.format_on_save != FormatOnSave::Off
+ });
+ action_log.update(cx, |action_log, cx| {
+ action_log.buffer_edited(buffer.clone(), cx);
+ });
+ format_on_save
+ })?;
+
+ if format_on_save {
+ let format_task = project.update(cx, |project, cx| {
+ project.format(
+ HashSet::from_iter([buffer.clone()]),
+ LspFormatTarget::Buffers,
+ false,
+ FormatTrigger::Save,
+ cx,
+ )
+ })?;
+ format_task.await.log_err();
+
+ action_log.update(cx, |action_log, cx| {
+ action_log.buffer_edited(buffer.clone(), cx);
+ })?;
+ }
+
+ project
+ .update(cx, |project, cx| project.save_buffer(buffer, cx))?
+ .await
+ })
+ }
+
+ pub fn to_markdown(&self, cx: &App) -> String {
+ self.entries.iter().map(|e| e.to_markdown(cx)).collect()
+ }
+
+ pub fn emit_load_error(&mut self, error: LoadError, cx: &mut Context<Self>) {
+ cx.emit(AcpThreadEvent::LoadError(error));
+ }
+}
+
+fn markdown_for_raw_output(
+ raw_output: &serde_json::Value,
+ language_registry: &Arc<LanguageRegistry>,
+ cx: &mut App,
+) -> Option<Entity<Markdown>> {
+ match raw_output {
+ serde_json::Value::Null => None,
+ serde_json::Value::Bool(value) => Some(cx.new(|cx| {
+ Markdown::new(
+ value.to_string().into(),
+ Some(language_registry.clone()),
+ None,
+ cx,
+ )
+ })),
+ serde_json::Value::Number(value) => Some(cx.new(|cx| {
+ Markdown::new(
+ value.to_string().into(),
+ Some(language_registry.clone()),
+ None,
+ cx,
+ )
+ })),
+ serde_json::Value::String(value) => Some(cx.new(|cx| {
+ Markdown::new(
+ value.clone().into(),
+ Some(language_registry.clone()),
+ None,
+ cx,
+ )
+ })),
+ value => Some(cx.new(|cx| {
+ Markdown::new(
+ format!("```json\n{}\n```", value).into(),
+ Some(language_registry.clone()),
+ None,
+ cx,
+ )
+ })),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use anyhow::anyhow;
+ use futures::{channel::mpsc, future::LocalBoxFuture, select};
+ use gpui::{App, AsyncApp, TestAppContext, WeakEntity};
+ use indoc::indoc;
+ use project::{FakeFs, Fs};
+ use rand::Rng as _;
+ use serde_json::json;
+ use settings::SettingsStore;
+ use smol::stream::StreamExt as _;
+ use std::{
+ any::Any,
+ cell::RefCell,
+ path::Path,
+ rc::Rc,
+ sync::atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst},
+ time::Duration,
+ };
+ use util::path;
+
+ fn init_test(cx: &mut TestAppContext) {
+ env_logger::try_init().ok();
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ Project::init_settings(cx);
+ language::init(cx);
+ });
+ }
+
+ #[gpui::test]
+ async fn test_push_user_content_block(cx: &mut gpui::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_thread(project, Path::new(path!("/test")), cx))
+ .await
+ .unwrap();
+
+ // Test creating a new user message
+ thread.update(cx, |thread, cx| {
+ thread.push_user_content_block(
+ None,
+ acp::ContentBlock::Text(acp::TextContent {
+ annotations: None,
+ text: "Hello, ".to_string(),
+ }),
+ cx,
+ );
+ });
+
+ thread.update(cx, |thread, cx| {
+ assert_eq!(thread.entries.len(), 1);
+ if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[0] {
+ assert_eq!(user_msg.id, None);
+ assert_eq!(user_msg.content.to_markdown(cx), "Hello, ");
+ } else {
+ panic!("Expected UserMessage");
+ }
+ });
+
+ // Test appending to existing user message
+ let message_1_id = UserMessageId::new();
+ thread.update(cx, |thread, cx| {
+ thread.push_user_content_block(
+ Some(message_1_id.clone()),
+ acp::ContentBlock::Text(acp::TextContent {
+ annotations: None,
+ text: "world!".to_string(),
+ }),
+ cx,
+ );
+ });
+
+ thread.update(cx, |thread, cx| {
+ assert_eq!(thread.entries.len(), 1);
+ if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[0] {
+ assert_eq!(user_msg.id, Some(message_1_id));
+ assert_eq!(user_msg.content.to_markdown(cx), "Hello, world!");
+ } else {
+ panic!("Expected UserMessage");
+ }
+ });
+
+ // Test creating new user message after assistant message
+ thread.update(cx, |thread, cx| {
+ thread.push_assistant_content_block(
+ acp::ContentBlock::Text(acp::TextContent {
+ annotations: None,
+ text: "Assistant response".to_string(),
+ }),
+ false,
+ cx,
+ );
+ });
+
+ let message_2_id = UserMessageId::new();
+ thread.update(cx, |thread, cx| {
+ thread.push_user_content_block(
+ Some(message_2_id.clone()),
+ acp::ContentBlock::Text(acp::TextContent {
+ annotations: None,
+ text: "New user message".to_string(),
+ }),
+ cx,
+ );
+ });
+
+ thread.update(cx, |thread, cx| {
+ assert_eq!(thread.entries.len(), 3);
+ if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[2] {
+ assert_eq!(user_msg.id, Some(message_2_id));
+ assert_eq!(user_msg.content.to_markdown(cx), "New user message");
+ } else {
+ panic!("Expected UserMessage at index 2");
+ }
+ });
+ }
+
+ #[gpui::test]
+ async fn test_thinking_concatenation(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ let project = Project::test(fs, [], cx).await;
+ let connection = Rc::new(FakeAgentConnection::new().on_user_message(
+ |_, thread, mut cx| {
+ async move {
+ thread.update(&mut cx, |thread, cx| {
+ thread
+ .handle_session_update(
+ acp::SessionUpdate::AgentThoughtChunk {
+ content: "Thinking ".into(),
+ },
+ cx,
+ )
+ .unwrap();
+ thread
+ .handle_session_update(
+ acp::SessionUpdate::AgentThoughtChunk {
+ content: "hard!".into(),
+ },
+ cx,
+ )
+ .unwrap();
+ })?;
+ Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::EndTurn,
+ })
+ }
+ .boxed_local()
+ },
+ ));
+
+ let thread = cx
+ .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .await
+ .unwrap();
+
+ thread
+ .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx))
+ .await
+ .unwrap();
+
+ let output = thread.read_with(cx, |thread, cx| thread.to_markdown(cx));
+ assert_eq!(
+ output,
+ indoc! {r#"
+ ## User
+
+ Hello from Zed!
+
+ ## Assistant
+
+ <thinking>
+ Thinking hard!
+ </thinking>
+
+ "#}
+ );
+ }
+
+ #[gpui::test]
+ async fn test_edits_concurrently_to_user(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(path!("/tmp"), json!({"foo": "one\ntwo\nthree\n"}))
+ .await;
+ let project = Project::test(fs.clone(), [], cx).await;
+ let (read_file_tx, read_file_rx) = oneshot::channel::<()>();
+ let read_file_tx = Rc::new(RefCell::new(Some(read_file_tx)));
+ let connection = Rc::new(FakeAgentConnection::new().on_user_message(
+ move |_, thread, mut cx| {
+ let read_file_tx = read_file_tx.clone();
+ async move {
+ let content = thread
+ .update(&mut cx, |thread, cx| {
+ thread.read_text_file(path!("/tmp/foo").into(), None, None, false, cx)
+ })
+ .unwrap()
+ .await
+ .unwrap();
+ assert_eq!(content, "one\ntwo\nthree\n");
+ read_file_tx.take().unwrap().send(()).unwrap();
+ thread
+ .update(&mut cx, |thread, cx| {
+ thread.write_text_file(
+ path!("/tmp/foo").into(),
+ "one\ntwo\nthree\nfour\nfive\n".to_string(),
+ cx,
+ )
+ })
+ .unwrap()
+ .await
+ .unwrap();
+ Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::EndTurn,
+ })
+ }
+ .boxed_local()
+ },
+ ));
+
+ let (worktree, pathbuf) = project
+ .update(cx, |project, cx| {
+ project.find_or_create_worktree(path!("/tmp/foo"), true, cx)
+ })
+ .await
+ .unwrap();
+ let buffer = project
+ .update(cx, |project, cx| {
+ project.open_buffer((worktree.read(cx).id(), pathbuf), cx)
+ })
+ .await
+ .unwrap();
+
+ let thread = cx
+ .update(|cx| connection.new_thread(project, Path::new(path!("/tmp")), cx))
+ .await
+ .unwrap();
+
+ let request = thread.update(cx, |thread, cx| {
+ thread.send_raw("Extend the count in /tmp/foo", cx)
+ });
+ read_file_rx.await.ok();
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit([(0..0, "zero\n".to_string())], None, cx);
+ });
+ cx.run_until_parked();
+ assert_eq!(
+ buffer.read_with(cx, |buffer, _| buffer.text()),
+ "zero\none\ntwo\nthree\nfour\nfive\n"
+ );
+ assert_eq!(
+ String::from_utf8(fs.read_file_sync(path!("/tmp/foo")).unwrap()).unwrap(),
+ "zero\none\ntwo\nthree\nfour\nfive\n"
+ );
+ request.await.unwrap();
+ }
+
+ #[gpui::test]
+ async fn test_succeeding_canceled_toolcall(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ let project = Project::test(fs, [], cx).await;
+ let id = acp::ToolCallId("test".into());
+
+ let connection = Rc::new(FakeAgentConnection::new().on_user_message({
+ let id = id.clone();
+ move |_, thread, mut cx| {
+ let id = id.clone();
+ async move {
+ thread
+ .update(&mut cx, |thread, cx| {
+ thread.handle_session_update(
+ acp::SessionUpdate::ToolCall(acp::ToolCall {
+ id: id.clone(),
+ title: "Label".into(),
+ kind: acp::ToolKind::Fetch,
+ status: acp::ToolCallStatus::InProgress,
+ content: vec![],
+ locations: vec![],
+ raw_input: None,
+ raw_output: None,
+ }),
+ cx,
+ )
+ })
+ .unwrap()
+ .unwrap();
+ Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::EndTurn,
+ })
+ }
+ .boxed_local()
+ }
+ }));
+
+ let thread = cx
+ .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .await
+ .unwrap();
+
+ let request = thread.update(cx, |thread, cx| {
+ thread.send_raw("Fetch https://example.com", cx)
+ });
+
+ run_until_first_tool_call(&thread, cx).await;
+
+ thread.read_with(cx, |thread, _| {
+ assert!(matches!(
+ thread.entries[1],
+ AgentThreadEntry::ToolCall(ToolCall {
+ status: ToolCallStatus::InProgress,
+ ..
+ })
+ ));
+ });
+
+ thread.update(cx, |thread, cx| thread.cancel(cx)).await;
+
+ thread.read_with(cx, |thread, _| {
+ assert!(matches!(
+ &thread.entries[1],
+ AgentThreadEntry::ToolCall(ToolCall {
+ status: ToolCallStatus::Canceled,
+ ..
+ })
+ ));
+ });
+
+ thread
+ .update(cx, |thread, cx| {
+ thread.handle_session_update(
+ acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate {
+ id,
+ fields: acp::ToolCallUpdateFields {
+ status: Some(acp::ToolCallStatus::Completed),
+ ..Default::default()
+ },
+ }),
+ cx,
+ )
+ })
+ .unwrap();
+
+ request.await.unwrap();
+
+ thread.read_with(cx, |thread, _| {
+ assert!(matches!(
+ thread.entries[1],
+ AgentThreadEntry::ToolCall(ToolCall {
+ status: ToolCallStatus::Completed,
+ ..
+ })
+ ));
+ });
+ }
+
+ #[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 {
+ id: acp::ToolCallId("test".into()),
+ title: "Label".into(),
+ kind: acp::ToolKind::Edit,
+ status: acp::ToolCallStatus::Completed,
+ content: vec![acp::ToolCallContent::Diff {
+ diff: acp::Diff {
+ path: "/test/test.txt".into(),
+ old_text: None,
+ new_text: "foo".into(),
+ },
+ }],
+ locations: vec![],
+ raw_input: None,
+ raw_output: None,
+ }),
+ cx,
+ )
+ })
+ .unwrap()
+ .unwrap();
+ Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::EndTurn,
+ })
+ }
+ .boxed_local()
+ }
+ }));
+
+ let thread = cx
+ .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .await
+ .unwrap();
+
+ cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["Hi".into()], cx)))
+ .await
+ .unwrap();
+
+ assert!(cx.read(|cx| !thread.read(cx).has_pending_edit_tool_calls()));
+ }
+
+ #[gpui::test(iterations = 10)]
+ async fn test_checkpoints(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(
+ path!("/test"),
+ json!({
+ ".git": {}
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
+
+ let simulate_changes = Arc::new(AtomicBool::new(true));
+ let next_filename = Arc::new(AtomicUsize::new(0));
+ let connection = Rc::new(FakeAgentConnection::new().on_user_message({
+ let simulate_changes = simulate_changes.clone();
+ let next_filename = next_filename.clone();
+ let fs = fs.clone();
+ move |request, thread, mut cx| {
+ let fs = fs.clone();
+ let simulate_changes = simulate_changes.clone();
+ let next_filename = next_filename.clone();
+ async move {
+ if simulate_changes.load(SeqCst) {
+ let filename = format!("/test/file-{}", next_filename.fetch_add(1, SeqCst));
+ fs.write(Path::new(&filename), b"").await?;
+ }
+
+ let acp::ContentBlock::Text(content) = &request.prompt[0] else {
+ panic!("expected text content block");
+ };
+ thread.update(&mut cx, |thread, cx| {
+ thread
+ .handle_session_update(
+ acp::SessionUpdate::AgentMessageChunk {
+ content: content.text.to_uppercase().into(),
+ },
+ cx,
+ )
+ .unwrap();
+ })?;
+ Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::EndTurn,
+ })
+ }
+ .boxed_local()
+ }
+ }));
+ let thread = cx
+ .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .await
+ .unwrap();
+
+ cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["Lorem".into()], cx)))
+ .await
+ .unwrap();
+ thread.read_with(cx, |thread, cx| {
+ assert_eq!(
+ thread.to_markdown(cx),
+ indoc! {"
+ ## User (checkpoint)
+
+ Lorem
+
+ ## Assistant
+
+ LOREM
+
+ "}
+ );
+ });
+ assert_eq!(fs.files(), vec![Path::new(path!("/test/file-0"))]);
+
+ cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["ipsum".into()], cx)))
+ .await
+ .unwrap();
+ thread.read_with(cx, |thread, cx| {
+ assert_eq!(
+ thread.to_markdown(cx),
+ indoc! {"
+ ## User (checkpoint)
+
+ Lorem
+
+ ## Assistant
+
+ LOREM
+
+ ## User (checkpoint)
+
+ ipsum
+
+ ## Assistant
+
+ IPSUM
+
+ "}
+ );
+ });
+ assert_eq!(
+ fs.files(),
+ vec![
+ Path::new(path!("/test/file-0")),
+ Path::new(path!("/test/file-1"))
+ ]
+ );
+
+ // Checkpoint isn't stored when there are no changes.
+ simulate_changes.store(false, SeqCst);
+ cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["dolor".into()], cx)))
+ .await
+ .unwrap();
+ thread.read_with(cx, |thread, cx| {
+ assert_eq!(
+ thread.to_markdown(cx),
+ indoc! {"
+ ## User (checkpoint)
+
+ Lorem
+
+ ## Assistant
+
+ LOREM
+
+ ## User (checkpoint)
+
+ ipsum
+
+ ## Assistant
+
+ IPSUM
+
+ ## User
+
+ dolor
+
+ ## Assistant
+
+ DOLOR
+
+ "}
+ );
+ });
+ assert_eq!(
+ fs.files(),
+ vec![
+ Path::new(path!("/test/file-0")),
+ Path::new(path!("/test/file-1"))
+ ]
+ );
+
+ // Rewinding the conversation truncates the history and restores the checkpoint.
+ thread
+ .update(cx, |thread, cx| {
+ let AgentThreadEntry::UserMessage(message) = &thread.entries[2] else {
+ panic!("unexpected entries {:?}", thread.entries)
+ };
+ thread.rewind(message.id.clone().unwrap(), cx)
+ })
+ .await
+ .unwrap();
+ thread.read_with(cx, |thread, cx| {
+ assert_eq!(
+ thread.to_markdown(cx),
+ indoc! {"
+ ## User (checkpoint)
+
+ Lorem
+
+ ## Assistant
+
+ LOREM
+
+ "}
+ );
+ });
+ assert_eq!(fs.files(), vec![Path::new(path!("/test/file-0"))]);
+ }
+
+ #[gpui::test]
+ async fn test_refusal(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(path!("/"), json!({})).await;
+ let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await;
+
+ let refuse_next = Arc::new(AtomicBool::new(false));
+ let connection = Rc::new(FakeAgentConnection::new().on_user_message({
+ let refuse_next = refuse_next.clone();
+ move |request, thread, mut cx| {
+ let refuse_next = refuse_next.clone();
+ async move {
+ if refuse_next.load(SeqCst) {
+ return Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::Refusal,
+ });
+ }
+
+ let acp::ContentBlock::Text(content) = &request.prompt[0] else {
+ panic!("expected text content block");
+ };
+ thread.update(&mut cx, |thread, cx| {
+ thread
+ .handle_session_update(
+ acp::SessionUpdate::AgentMessageChunk {
+ content: content.text.to_uppercase().into(),
+ },
+ cx,
+ )
+ .unwrap();
+ })?;
+ Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::EndTurn,
+ })
+ }
+ .boxed_local()
+ }
+ }));
+ let thread = cx
+ .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .await
+ .unwrap();
+
+ cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx)))
+ .await
+ .unwrap();
+ thread.read_with(cx, |thread, cx| {
+ assert_eq!(
+ thread.to_markdown(cx),
+ indoc! {"
+ ## User
+
+ hello
+
+ ## Assistant
+
+ HELLO
+
+ "}
+ );
+ });
+
+ // Simulate refusing the second message, ensuring the conversation gets
+ // truncated to before sending it.
+ refuse_next.store(true, SeqCst);
+ cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["world".into()], cx)))
+ .await
+ .unwrap();
+ thread.read_with(cx, |thread, cx| {
+ assert_eq!(
+ thread.to_markdown(cx),
+ indoc! {"
+ ## User
+
+ hello
+
+ ## Assistant
+
+ HELLO
+
+ "}
+ );
+ });
+ }
+
+ async fn run_until_first_tool_call(
+ thread: &Entity<AcpThread>,
+ cx: &mut TestAppContext,
+ ) -> usize {
+ let (mut tx, mut rx) = mpsc::channel::<usize>(1);
+
+ let subscription = cx.update(|cx| {
+ cx.subscribe(thread, move |thread, _, cx| {
+ for (ix, entry) in thread.read(cx).entries.iter().enumerate() {
+ if matches!(entry, AgentThreadEntry::ToolCall(_)) {
+ return tx.try_send(ix).unwrap();
+ }
+ }
+ })
+ });
+
+ select! {
+ _ = futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(10))) => {
+ panic!("Timeout waiting for tool call")
+ }
+ ix = rx.next().fuse() => {
+ drop(subscription);
+ ix.unwrap()
+ }
+ }
+ }
+
+ #[derive(Clone, Default)]
+ struct FakeAgentConnection {
+ auth_methods: Vec<acp::AuthMethod>,
+ sessions: Arc<parking_lot::Mutex<HashMap<acp::SessionId, WeakEntity<AcpThread>>>>,
+ on_user_message: Option<
+ Rc<
+ dyn Fn(
+ acp::PromptRequest,
+ WeakEntity<AcpThread>,
+ AsyncApp,
+ ) -> LocalBoxFuture<'static, Result<acp::PromptResponse>>
+ + 'static,
+ >,
+ >,
+ }
+
+ impl FakeAgentConnection {
+ fn new() -> Self {
+ Self {
+ auth_methods: Vec::new(),
+ on_user_message: None,
+ sessions: Arc::default(),
+ }
+ }
+
+ #[expect(unused)]
+ fn with_auth_methods(mut self, auth_methods: Vec<acp::AuthMethod>) -> Self {
+ self.auth_methods = auth_methods;
+ self
+ }
+
+ fn on_user_message(
+ mut self,
+ handler: impl Fn(
+ acp::PromptRequest,
+ WeakEntity<AcpThread>,
+ AsyncApp,
+ ) -> LocalBoxFuture<'static, Result<acp::PromptResponse>>
+ + 'static,
+ ) -> Self {
+ self.on_user_message.replace(Rc::new(handler));
+ self
+ }
+ }
+
+ impl AgentConnection for FakeAgentConnection {
+ fn auth_methods(&self) -> &[acp::AuthMethod] {
+ &self.auth_methods
+ }
+
+ fn new_thread(
+ self: Rc<Self>,
+ project: Entity<Project>,
+ _cwd: &Path,
+ cx: &mut App,
+ ) -> Task<gpui::Result<Entity<AcpThread>>> {
+ let session_id = acp::SessionId(
+ rand::thread_rng()
+ .sample_iter(&rand::distributions::Alphanumeric)
+ .take(7)
+ .map(char::from)
+ .collect::<String>()
+ .into(),
+ );
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+ let thread = cx.new(|_cx| {
+ AcpThread::new(
+ "Test",
+ self.clone(),
+ project,
+ action_log,
+ session_id.clone(),
+ )
+ });
+ self.sessions.lock().insert(session_id, thread.downgrade());
+ Task::ready(Ok(thread))
+ }
+
+ fn authenticate(&self, method: acp::AuthMethodId, _cx: &mut App) -> Task<gpui::Result<()>> {
+ if self.auth_methods().iter().any(|m| m.id == method) {
+ Task::ready(Ok(()))
+ } else {
+ Task::ready(Err(anyhow!("Invalid Auth Method")))
+ }
+ }
+
+ fn prompt(
+ &self,
+ _id: Option<UserMessageId>,
+ params: acp::PromptRequest,
+ cx: &mut App,
+ ) -> Task<gpui::Result<acp::PromptResponse>> {
+ let sessions = self.sessions.lock();
+ let thread = sessions.get(¶ms.session_id).unwrap();
+ if let Some(handler) = &self.on_user_message {
+ let handler = handler.clone();
+ let thread = thread.clone();
+ cx.spawn(async move |cx| handler(params, thread, cx.clone()).await)
+ } else {
+ Task::ready(Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::EndTurn,
+ }))
+ }
+ }
+
+ fn prompt_capabilities(&self) -> acp::PromptCapabilities {
+ acp::PromptCapabilities {
+ image: true,
+ audio: true,
+ embedded_context: true,
+ }
+ }
+
+ fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) {
+ let sessions = self.sessions.lock();
+ let thread = sessions.get(session_id).unwrap().clone();
+
+ cx.spawn(async move |cx| {
+ thread
+ .update(cx, |thread, cx| thread.cancel(cx))
+ .unwrap()
+ .await
+ })
+ .detach();
+ }
+
+ fn truncate(
+ &self,
+ session_id: &acp::SessionId,
+ _cx: &App,
+ ) -> Option<Rc<dyn AgentSessionTruncate>> {
+ Some(Rc::new(FakeAgentSessionEditor {
+ _session_id: session_id.clone(),
+ }))
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ self
+ }
+ }
+
+ struct FakeAgentSessionEditor {
+ _session_id: acp::SessionId,
+ }
+
+ impl AgentSessionTruncate for FakeAgentSessionEditor {
+ fn run(&self, _message_id: UserMessageId, _cx: &mut App) -> Task<Result<()>> {
+ Task::ready(Ok(()))
+ }
+ }
+}
@@ -0,0 +1,462 @@
+use crate::AcpThread;
+use agent_client_protocol::{self as acp};
+use anyhow::Result;
+use collections::IndexMap;
+use gpui::{Entity, SharedString, Task};
+use language_model::LanguageModelProviderId;
+use project::Project;
+use serde::{Deserialize, Serialize};
+use std::{any::Any, error::Error, fmt, path::Path, rc::Rc, sync::Arc};
+use ui::{App, IconName};
+use uuid::Uuid;
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
+pub struct UserMessageId(Arc<str>);
+
+impl UserMessageId {
+ pub fn new() -> Self {
+ Self(Uuid::new_v4().to_string().into())
+ }
+}
+
+pub trait AgentConnection {
+ fn new_thread(
+ self: Rc<Self>,
+ project: Entity<Project>,
+ cwd: &Path,
+ cx: &mut App,
+ ) -> Task<Result<Entity<AcpThread>>>;
+
+ fn auth_methods(&self) -> &[acp::AuthMethod];
+
+ fn authenticate(&self, method: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>>;
+
+ fn prompt(
+ &self,
+ user_message_id: Option<UserMessageId>,
+ params: acp::PromptRequest,
+ cx: &mut App,
+ ) -> Task<Result<acp::PromptResponse>>;
+
+ fn prompt_capabilities(&self) -> acp::PromptCapabilities;
+
+ fn resume(
+ &self,
+ _session_id: &acp::SessionId,
+ _cx: &App,
+ ) -> Option<Rc<dyn AgentSessionResume>> {
+ None
+ }
+
+ fn cancel(&self, session_id: &acp::SessionId, cx: &mut App);
+
+ fn truncate(
+ &self,
+ _session_id: &acp::SessionId,
+ _cx: &App,
+ ) -> Option<Rc<dyn AgentSessionTruncate>> {
+ None
+ }
+
+ fn set_title(
+ &self,
+ _session_id: &acp::SessionId,
+ _cx: &App,
+ ) -> Option<Rc<dyn AgentSessionSetTitle>> {
+ None
+ }
+
+ /// Returns this agent as an [Rc<dyn ModelSelector>] if the model selection capability is supported.
+ ///
+ /// If the agent does not support model selection, returns [None].
+ /// This allows sharing the selector in UI components.
+ fn model_selector(&self) -> Option<Rc<dyn AgentModelSelector>> {
+ None
+ }
+
+ fn telemetry(&self) -> Option<Rc<dyn AgentTelemetry>> {
+ None
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
+}
+
+impl dyn AgentConnection {
+ pub fn downcast<T: 'static + AgentConnection + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
+ self.into_any().downcast().ok()
+ }
+}
+
+pub trait AgentSessionTruncate {
+ fn run(&self, message_id: UserMessageId, cx: &mut App) -> Task<Result<()>>;
+}
+
+pub trait AgentSessionResume {
+ fn run(&self, cx: &mut App) -> Task<Result<acp::PromptResponse>>;
+}
+
+pub trait AgentSessionSetTitle {
+ fn run(&self, title: SharedString, cx: &mut App) -> Task<Result<()>>;
+}
+
+pub trait AgentTelemetry {
+ /// The name of the agent used for telemetry.
+ fn agent_name(&self) -> String;
+
+ /// A representation of the current thread state that can be serialized for
+ /// storage with telemetry events.
+ fn thread_data(
+ &self,
+ session_id: &acp::SessionId,
+ cx: &mut App,
+ ) -> Task<Result<serde_json::Value>>;
+}
+
+#[derive(Debug)]
+pub struct AuthRequired {
+ pub description: Option<String>,
+ pub provider_id: Option<LanguageModelProviderId>,
+}
+
+impl AuthRequired {
+ pub fn new() -> Self {
+ Self {
+ description: None,
+ provider_id: None,
+ }
+ }
+
+ pub fn with_description(mut self, description: String) -> Self {
+ self.description = Some(description);
+ self
+ }
+
+ pub fn with_language_model_provider(mut self, provider_id: LanguageModelProviderId) -> Self {
+ self.provider_id = Some(provider_id);
+ self
+ }
+}
+
+impl Error for AuthRequired {}
+impl fmt::Display for AuthRequired {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "Authentication required")
+ }
+}
+
+/// Trait for agents that support listing, selecting, and querying language models.
+///
+/// This is an optional capability; agents indicate support via [AgentConnection::model_selector].
+pub trait AgentModelSelector: 'static {
+ /// Lists all available language models for this agent.
+ ///
+ /// # Parameters
+ /// - `cx`: The GPUI app context for async operations and global access.
+ ///
+ /// # Returns
+ /// A task resolving to the list of models or an error (e.g., if no models are configured).
+ fn list_models(&self, cx: &mut App) -> Task<Result<AgentModelList>>;
+
+ /// Selects a model for a specific session (thread).
+ ///
+ /// This sets the default model for future interactions in the session.
+ /// If the session doesn't exist or the model is invalid, it returns an error.
+ ///
+ /// # Parameters
+ /// - `session_id`: The ID of the session (thread) to apply the model to.
+ /// - `model`: The model to select (should be one from [list_models]).
+ /// - `cx`: The GPUI app context.
+ ///
+ /// # Returns
+ /// A task resolving to `Ok(())` on success or an error.
+ fn select_model(
+ &self,
+ session_id: acp::SessionId,
+ model_id: AgentModelId,
+ cx: &mut App,
+ ) -> Task<Result<()>>;
+
+ /// Retrieves the currently selected model for a specific session (thread).
+ ///
+ /// # Parameters
+ /// - `session_id`: The ID of the session (thread) to query.
+ /// - `cx`: The GPUI app context.
+ ///
+ /// # Returns
+ /// A task resolving to the selected model (always set) or an error (e.g., session not found).
+ fn selected_model(
+ &self,
+ session_id: &acp::SessionId,
+ cx: &mut App,
+ ) -> Task<Result<AgentModelInfo>>;
+
+ /// Whenever the model list is updated the receiver will be notified.
+ fn watch(&self, cx: &mut App) -> watch::Receiver<()>;
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct AgentModelId(pub SharedString);
+
+impl std::ops::Deref for AgentModelId {
+ type Target = SharedString;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl fmt::Display for AgentModelId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AgentModelInfo {
+ pub id: AgentModelId,
+ pub name: SharedString,
+ pub icon: Option<IconName>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct AgentModelGroupName(pub SharedString);
+
+#[derive(Debug, Clone)]
+pub enum AgentModelList {
+ Flat(Vec<AgentModelInfo>),
+ Grouped(IndexMap<AgentModelGroupName, Vec<AgentModelInfo>>),
+}
+
+impl AgentModelList {
+ pub fn is_empty(&self) -> bool {
+ match self {
+ AgentModelList::Flat(models) => models.is_empty(),
+ AgentModelList::Grouped(groups) => groups.is_empty(),
+ }
+ }
+}
+
+#[cfg(feature = "test-support")]
+mod test_support {
+ use std::sync::Arc;
+
+ use action_log::ActionLog;
+ use collections::HashMap;
+ use futures::{channel::oneshot, future::try_join_all};
+ use gpui::{AppContext as _, WeakEntity};
+ use parking_lot::Mutex;
+
+ use super::*;
+
+ #[derive(Clone, Default)]
+ pub struct StubAgentConnection {
+ sessions: Arc<Mutex<HashMap<acp::SessionId, Session>>>,
+ permission_requests: HashMap<acp::ToolCallId, Vec<acp::PermissionOption>>,
+ next_prompt_updates: Arc<Mutex<Vec<acp::SessionUpdate>>>,
+ }
+
+ struct Session {
+ thread: WeakEntity<AcpThread>,
+ response_tx: Option<oneshot::Sender<acp::StopReason>>,
+ }
+
+ impl StubAgentConnection {
+ pub fn new() -> Self {
+ Self {
+ next_prompt_updates: Default::default(),
+ permission_requests: HashMap::default(),
+ sessions: Arc::default(),
+ }
+ }
+
+ pub fn set_next_prompt_updates(&self, updates: Vec<acp::SessionUpdate>) {
+ *self.next_prompt_updates.lock() = updates;
+ }
+
+ pub fn with_permission_requests(
+ mut self,
+ permission_requests: HashMap<acp::ToolCallId, Vec<acp::PermissionOption>>,
+ ) -> Self {
+ self.permission_requests = permission_requests;
+ self
+ }
+
+ pub fn send_update(
+ &self,
+ session_id: acp::SessionId,
+ update: acp::SessionUpdate,
+ cx: &mut App,
+ ) {
+ assert!(
+ self.next_prompt_updates.lock().is_empty(),
+ "Use either send_update or set_next_prompt_updates"
+ );
+
+ self.sessions
+ .lock()
+ .get(&session_id)
+ .unwrap()
+ .thread
+ .update(cx, |thread, cx| {
+ thread.handle_session_update(update, cx).unwrap();
+ })
+ .unwrap();
+ }
+
+ pub fn end_turn(&self, session_id: acp::SessionId, stop_reason: acp::StopReason) {
+ self.sessions
+ .lock()
+ .get_mut(&session_id)
+ .unwrap()
+ .response_tx
+ .take()
+ .expect("No pending turn")
+ .send(stop_reason)
+ .unwrap();
+ }
+ }
+
+ impl AgentConnection for StubAgentConnection {
+ fn auth_methods(&self) -> &[acp::AuthMethod] {
+ &[]
+ }
+
+ fn new_thread(
+ self: Rc<Self>,
+ project: Entity<Project>,
+ _cwd: &Path,
+ cx: &mut gpui::App,
+ ) -> Task<gpui::Result<Entity<AcpThread>>> {
+ let session_id = acp::SessionId(self.sessions.lock().len().to_string().into());
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+ let thread = cx.new(|_cx| {
+ AcpThread::new(
+ "Test",
+ self.clone(),
+ project,
+ action_log,
+ session_id.clone(),
+ )
+ });
+ self.sessions.lock().insert(
+ session_id,
+ Session {
+ thread: thread.downgrade(),
+ response_tx: None,
+ },
+ );
+ Task::ready(Ok(thread))
+ }
+
+ fn prompt_capabilities(&self) -> acp::PromptCapabilities {
+ acp::PromptCapabilities {
+ image: true,
+ audio: true,
+ embedded_context: true,
+ }
+ }
+
+ fn authenticate(
+ &self,
+ _method_id: acp::AuthMethodId,
+ _cx: &mut App,
+ ) -> Task<gpui::Result<()>> {
+ unimplemented!()
+ }
+
+ fn prompt(
+ &self,
+ _id: Option<UserMessageId>,
+ params: acp::PromptRequest,
+ cx: &mut App,
+ ) -> Task<gpui::Result<acp::PromptResponse>> {
+ let mut sessions = self.sessions.lock();
+ let Session {
+ thread,
+ response_tx,
+ } = sessions.get_mut(¶ms.session_id).unwrap();
+ let mut tasks = vec![];
+ if self.next_prompt_updates.lock().is_empty() {
+ let (tx, rx) = oneshot::channel();
+ response_tx.replace(tx);
+ cx.spawn(async move |_| {
+ let stop_reason = rx.await?;
+ Ok(acp::PromptResponse { stop_reason })
+ })
+ } else {
+ for update in self.next_prompt_updates.lock().drain(..) {
+ let thread = thread.clone();
+ let update = update.clone();
+ let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) =
+ &update
+ && let Some(options) = self.permission_requests.get(&tool_call.id)
+ {
+ Some((tool_call.clone(), options.clone()))
+ } else {
+ None
+ };
+ let task = cx.spawn(async move |cx| {
+ if let Some((tool_call, options)) = permission_request {
+ let permission = thread.update(cx, |thread, cx| {
+ thread.request_tool_call_authorization(
+ tool_call.clone().into(),
+ options.clone(),
+ cx,
+ )
+ })?;
+ permission?.await?;
+ }
+ thread.update(cx, |thread, cx| {
+ thread.handle_session_update(update.clone(), cx).unwrap();
+ })?;
+ anyhow::Ok(())
+ });
+ tasks.push(task);
+ }
+
+ cx.spawn(async move |_| {
+ try_join_all(tasks).await?;
+ Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::EndTurn,
+ })
+ })
+ }
+ }
+
+ fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) {
+ if let Some(end_turn_tx) = self
+ .sessions
+ .lock()
+ .get_mut(session_id)
+ .unwrap()
+ .response_tx
+ .take()
+ {
+ end_turn_tx.send(acp::StopReason::Cancelled).unwrap();
+ }
+ }
+
+ fn truncate(
+ &self,
+ _session_id: &agent_client_protocol::SessionId,
+ _cx: &App,
+ ) -> Option<Rc<dyn AgentSessionTruncate>> {
+ Some(Rc::new(StubAgentSessionEditor))
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ self
+ }
+ }
+
+ struct StubAgentSessionEditor;
+
+ impl AgentSessionTruncate for StubAgentSessionEditor {
+ fn run(&self, _: UserMessageId, _: &mut App) -> Task<Result<()>> {
+ Task::ready(Ok(()))
+ }
+ }
+}
+
+#[cfg(feature = "test-support")]
+pub use test_support::*;
@@ -0,0 +1,424 @@
+use anyhow::Result;
+use buffer_diff::{BufferDiff, BufferDiffSnapshot};
+use editor::{MultiBuffer, PathKey};
+use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task};
+use itertools::Itertools;
+use language::{
+ Anchor, Buffer, Capability, LanguageRegistry, OffsetRangeExt as _, Point, Rope, TextBuffer,
+};
+use std::{
+ cmp::Reverse,
+ ops::Range,
+ path::{Path, PathBuf},
+ sync::Arc,
+};
+use util::ResultExt;
+
+pub enum Diff {
+ Pending(PendingDiff),
+ Finalized(FinalizedDiff),
+}
+
+impl Diff {
+ pub fn finalized(
+ path: PathBuf,
+ old_text: Option<String>,
+ new_text: String,
+ language_registry: Arc<LanguageRegistry>,
+ cx: &mut Context<Self>,
+ ) -> 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 = old_text.clone().unwrap_or(String::new()).into();
+ let task = cx.spawn({
+ let multibuffer = multibuffer.clone();
+ let path = path.clone();
+ let buffer = new_buffer.clone();
+ async move |_, cx| {
+ let language = language_registry
+ .language_for_file_path(&path)
+ .await
+ .log_err();
+
+ buffer.update(cx, |buffer, cx| buffer.set_language(language.clone(), cx))?;
+
+ let diff = build_buffer_diff(
+ old_text.unwrap_or("".into()).into(),
+ &buffer,
+ Some(language_registry.clone()),
+ cx,
+ )
+ .await?;
+
+ multibuffer
+ .update(cx, |multibuffer, cx| {
+ let hunk_ranges = {
+ let buffer = buffer.read(cx);
+ let diff = diff.read(cx);
+ diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx)
+ .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer))
+ .collect::<Vec<_>>()
+ };
+
+ multibuffer.set_excerpts_for_path(
+ PathKey::for_buffer(&buffer, cx),
+ buffer.clone(),
+ hunk_ranges,
+ editor::DEFAULT_MULTIBUFFER_CONTEXT,
+ cx,
+ );
+ multibuffer.add_diff(diff, cx);
+ })
+ .log_err();
+
+ anyhow::Ok(())
+ }
+ });
+
+ Self::Finalized(FinalizedDiff {
+ multibuffer,
+ path,
+ base_text,
+ new_buffer,
+ _update_diff: task,
+ })
+ }
+
+ pub fn new(buffer: Entity<Buffer>, cx: &mut Context<Self>) -> Self {
+ let buffer_text_snapshot = buffer.read(cx).text_snapshot();
+ let base_text_snapshot = buffer.read(cx).snapshot();
+ let base_text = base_text_snapshot.text();
+ debug_assert_eq!(buffer_text_snapshot.text(), base_text);
+ let buffer_diff = cx.new(|cx| {
+ let mut diff = BufferDiff::new_unchanged(&buffer_text_snapshot, base_text_snapshot);
+ let snapshot = diff.snapshot(cx);
+ let secondary_diff = cx.new(|cx| {
+ let mut diff = BufferDiff::new(&buffer_text_snapshot, cx);
+ diff.set_snapshot(snapshot, &buffer_text_snapshot, cx);
+ diff
+ });
+ diff.set_secondary_diff(secondary_diff);
+ diff
+ });
+
+ let multibuffer = cx.new(|cx| {
+ let mut multibuffer = MultiBuffer::without_headers(Capability::ReadOnly);
+ multibuffer.add_diff(buffer_diff.clone(), cx);
+ multibuffer
+ });
+
+ Self::Pending(PendingDiff {
+ multibuffer,
+ base_text: Arc::new(base_text),
+ _subscription: cx.observe(&buffer, |this, _, cx| {
+ if let Diff::Pending(diff) = this {
+ diff.update(cx);
+ }
+ }),
+ new_buffer: buffer,
+ diff: buffer_diff,
+ revealed_ranges: Vec::new(),
+ update_diff: Task::ready(Ok(())),
+ })
+ }
+
+ pub fn reveal_range(&mut self, range: Range<Anchor>, cx: &mut Context<Self>) {
+ if let Self::Pending(diff) = self {
+ diff.reveal_range(range, cx);
+ }
+ }
+
+ pub fn finalize(&mut self, cx: &mut Context<Self>) {
+ if let Self::Pending(diff) = self {
+ *self = Self::Finalized(diff.finalize(cx));
+ }
+ }
+
+ pub fn multibuffer(&self) -> &Entity<MultiBuffer> {
+ match self {
+ Self::Pending(PendingDiff { multibuffer, .. }) => multibuffer,
+ Self::Finalized(FinalizedDiff { multibuffer, .. }) => multibuffer,
+ }
+ }
+
+ pub fn to_markdown(&self, cx: &App) -> String {
+ let buffer_text = self
+ .multibuffer()
+ .read(cx)
+ .all_buffers()
+ .iter()
+ .map(|buffer| buffer.read(cx).text())
+ .join("\n");
+ let path = match self {
+ Diff::Pending(PendingDiff {
+ new_buffer: buffer, ..
+ }) => buffer.read(cx).file().map(|file| file.path().as_ref()),
+ Diff::Finalized(FinalizedDiff { path, .. }) => Some(path.as_path()),
+ };
+ format!(
+ "Diff: {}\n```\n{}\n```\n",
+ path.unwrap_or(Path::new("untitled")).display(),
+ buffer_text
+ )
+ }
+
+ pub fn has_revealed_range(&self, cx: &App) -> bool {
+ self.multibuffer().read(cx).excerpt_paths().next().is_some()
+ }
+
+ pub fn needs_update(&self, old_text: &str, new_text: &str, cx: &App) -> bool {
+ match self {
+ Diff::Pending(PendingDiff {
+ base_text,
+ new_buffer,
+ ..
+ }) => {
+ base_text.as_str() != old_text
+ || !new_buffer.read(cx).as_rope().chunks().equals_str(new_text)
+ }
+ Diff::Finalized(FinalizedDiff {
+ base_text,
+ new_buffer,
+ ..
+ }) => {
+ base_text.as_str() != old_text
+ || !new_buffer.read(cx).as_rope().chunks().equals_str(new_text)
+ }
+ }
+ }
+}
+
+pub struct PendingDiff {
+ multibuffer: Entity<MultiBuffer>,
+ base_text: Arc<String>,
+ new_buffer: Entity<Buffer>,
+ diff: Entity<BufferDiff>,
+ revealed_ranges: Vec<Range<Anchor>>,
+ _subscription: Subscription,
+ update_diff: Task<Result<()>>,
+}
+
+impl PendingDiff {
+ pub fn update(&mut self, cx: &mut Context<Diff>) {
+ let buffer = self.new_buffer.clone();
+ let buffer_diff = self.diff.clone();
+ 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 diff_snapshot = BufferDiff::update_diff(
+ buffer_diff.clone(),
+ text_snapshot.clone(),
+ Some(base_text),
+ false,
+ false,
+ None,
+ None,
+ cx,
+ )
+ .await?;
+ buffer_diff.update(cx, |diff, cx| {
+ diff.set_snapshot(diff_snapshot.clone(), &text_snapshot, cx);
+ diff.secondary_diff().unwrap().update(cx, |diff, cx| {
+ diff.set_snapshot(diff_snapshot.clone(), &text_snapshot, cx);
+ });
+ })?;
+ diff.update(cx, |diff, cx| {
+ if let Diff::Pending(diff) = diff {
+ diff.update_visible_ranges(cx);
+ }
+ })
+ });
+ }
+
+ pub fn reveal_range(&mut self, range: Range<Anchor>, cx: &mut Context<Diff>) {
+ self.revealed_ranges.push(range);
+ self.update_visible_ranges(cx);
+ }
+
+ fn finalize(&self, cx: &mut Context<Diff>) -> FinalizedDiff {
+ let ranges = self.excerpt_ranges(cx);
+ let base_text = self.base_text.clone();
+ let language_registry = self.new_buffer.read(cx).language_registry();
+
+ let path = self
+ .new_buffer
+ .read(cx)
+ .file()
+ .map(|file| file.path().as_ref())
+ .unwrap_or(Path::new("untitled"))
+ .into();
+
+ // Replace the buffer in the multibuffer with the snapshot
+ let buffer = cx.new(|cx| {
+ let language = self.new_buffer.read(cx).language().cloned();
+ let buffer = TextBuffer::new_normalized(
+ 0,
+ cx.entity_id().as_non_zero_u64().into(),
+ self.new_buffer.read(cx).line_ending(),
+ self.new_buffer.read(cx).as_rope().clone(),
+ );
+ let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
+ buffer.set_language(language, cx);
+ buffer
+ });
+
+ let buffer_diff = cx.spawn({
+ let buffer = buffer.clone();
+ async move |_this, cx| {
+ build_buffer_diff(base_text, &buffer, language_registry, cx).await
+ }
+ });
+
+ let update_diff = cx.spawn(async move |this, cx| {
+ let buffer_diff = buffer_diff.await?;
+ this.update(cx, |this, cx| {
+ this.multibuffer().update(cx, |multibuffer, cx| {
+ let path_key = PathKey::for_buffer(&buffer, cx);
+ multibuffer.clear(cx);
+ multibuffer.set_excerpts_for_path(
+ path_key,
+ buffer,
+ ranges,
+ editor::DEFAULT_MULTIBUFFER_CONTEXT,
+ cx,
+ );
+ multibuffer.add_diff(buffer_diff.clone(), cx);
+ });
+
+ cx.notify();
+ })
+ });
+
+ FinalizedDiff {
+ path,
+ base_text: self.base_text.clone(),
+ multibuffer: self.multibuffer.clone(),
+ new_buffer: self.new_buffer.clone(),
+ _update_diff: update_diff,
+ }
+ }
+
+ fn update_visible_ranges(&mut self, cx: &mut Context<Diff>) {
+ let ranges = self.excerpt_ranges(cx);
+ self.multibuffer.update(cx, |multibuffer, cx| {
+ multibuffer.set_excerpts_for_path(
+ PathKey::for_buffer(&self.new_buffer, cx),
+ self.new_buffer.clone(),
+ ranges,
+ editor::DEFAULT_MULTIBUFFER_CONTEXT,
+ cx,
+ );
+ let end = multibuffer.len(cx);
+ Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
+ });
+ cx.notify();
+ }
+
+ fn excerpt_ranges(&self, cx: &App) -> Vec<Range<Point>> {
+ let buffer = self.new_buffer.read(cx);
+ let diff = self.diff.read(cx);
+ let mut ranges = diff
+ .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx)
+ .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer))
+ .collect::<Vec<_>>();
+ ranges.extend(
+ self.revealed_ranges
+ .iter()
+ .map(|range| range.to_point(buffer)),
+ );
+ ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end)));
+
+ // Merge adjacent ranges
+ let mut ranges = ranges.into_iter().peekable();
+ let mut merged_ranges = Vec::new();
+ while let Some(mut range) = ranges.next() {
+ while let Some(next_range) = ranges.peek() {
+ if range.end >= next_range.start {
+ range.end = range.end.max(next_range.end);
+ ranges.next();
+ } else {
+ break;
+ }
+ }
+
+ merged_ranges.push(range);
+ }
+ merged_ranges
+ }
+}
+
+pub struct FinalizedDiff {
+ path: PathBuf,
+ base_text: Arc<String>,
+ new_buffer: Entity<Buffer>,
+ multibuffer: Entity<MultiBuffer>,
+ _update_diff: Task<Result<()>>,
+}
+
+async fn build_buffer_diff(
+ old_text: Arc<String>,
+ buffer: &Entity<Buffer>,
+ language_registry: Option<Arc<LanguageRegistry>>,
+ cx: &mut AsyncApp,
+) -> Result<Entity<BufferDiff>> {
+ let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
+
+ let old_text_rope = cx
+ .background_spawn({
+ let old_text = old_text.clone();
+ async move { Rope::from(old_text.as_str()) }
+ })
+ .await;
+ let base_buffer = cx
+ .update(|cx| {
+ Buffer::build_snapshot(
+ old_text_rope,
+ buffer.language().cloned(),
+ language_registry,
+ cx,
+ )
+ })?
+ .await;
+
+ let diff_snapshot = cx
+ .update(|cx| {
+ BufferDiffSnapshot::new_with_base_buffer(
+ buffer.text.clone(),
+ Some(old_text),
+ base_buffer,
+ cx,
+ )
+ })?
+ .await;
+
+ let secondary_diff = cx.new(|cx| {
+ let mut diff = BufferDiff::new(&buffer, cx);
+ diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
+ diff
+ })?;
+
+ cx.new(|cx| {
+ let mut diff = BufferDiff::new(&buffer.text, cx);
+ diff.set_snapshot(diff_snapshot, &buffer, cx);
+ diff.set_secondary_diff(secondary_diff);
+ diff
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use gpui::{AppContext as _, TestAppContext};
+ use language::Buffer;
+
+ use crate::Diff;
+
+ #[gpui::test]
+ async fn test_pending_diff(cx: &mut TestAppContext) {
+ let buffer = cx.new(|cx| Buffer::local("hello!", cx));
+ let _diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
+ buffer.update(cx, |buffer, cx| {
+ buffer.set_text("HELLO!", cx);
+ });
+ cx.run_until_parked();
+ }
+}
@@ -0,0 +1,502 @@
+use agent_client_protocol as acp;
+use anyhow::{Context as _, Result, bail};
+use file_icons::FileIcons;
+use prompt_store::{PromptId, UserPromptId};
+use serde::{Deserialize, Serialize};
+use std::{
+ fmt,
+ ops::RangeInclusive,
+ path::{Path, PathBuf},
+ str::FromStr,
+};
+use ui::{App, IconName, SharedString};
+use url::Url;
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
+pub enum MentionUri {
+ File {
+ abs_path: PathBuf,
+ },
+ PastedImage,
+ Directory {
+ abs_path: PathBuf,
+ },
+ Symbol {
+ abs_path: PathBuf,
+ name: String,
+ line_range: RangeInclusive<u32>,
+ },
+ Thread {
+ id: acp::SessionId,
+ name: String,
+ },
+ TextThread {
+ path: PathBuf,
+ name: String,
+ },
+ Rule {
+ id: PromptId,
+ name: String,
+ },
+ Selection {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ abs_path: Option<PathBuf>,
+ line_range: RangeInclusive<u32>,
+ },
+ Fetch {
+ url: Url,
+ },
+}
+
+impl MentionUri {
+ pub fn parse(input: &str) -> Result<Self> {
+ fn parse_line_range(fragment: &str) -> Result<RangeInclusive<u32>> {
+ let range = fragment
+ .strip_prefix("L")
+ .context("Line range must start with \"L\"")?;
+ let (start, end) = range
+ .split_once(":")
+ .context("Line range must use colon as separator")?;
+ let range = start
+ .parse::<u32>()
+ .context("Parsing line range start")?
+ .checked_sub(1)
+ .context("Line numbers should be 1-based")?
+ ..=end
+ .parse::<u32>()
+ .context("Parsing line range end")?
+ .checked_sub(1)
+ .context("Line numbers should be 1-based")?;
+ Ok(range)
+ }
+
+ let url = url::Url::parse(input)?;
+ let path = url.path();
+ match url.scheme() {
+ "file" => {
+ let path = url.to_file_path().ok().context("Extracting file path")?;
+ if let Some(fragment) = url.fragment() {
+ let line_range = parse_line_range(fragment)?;
+ if let Some(name) = single_query_param(&url, "symbol")? {
+ Ok(Self::Symbol {
+ name,
+ abs_path: path,
+ line_range,
+ })
+ } else {
+ Ok(Self::Selection {
+ abs_path: Some(path),
+ line_range,
+ })
+ }
+ } else if input.ends_with("/") {
+ Ok(Self::Directory { abs_path: path })
+ } else {
+ Ok(Self::File { abs_path: path })
+ }
+ }
+ "zed" => {
+ if let Some(thread_id) = path.strip_prefix("/agent/thread/") {
+ let name = single_query_param(&url, "name")?.context("Missing thread name")?;
+ Ok(Self::Thread {
+ id: acp::SessionId(thread_id.into()),
+ name,
+ })
+ } else if let Some(path) = path.strip_prefix("/agent/text-thread/") {
+ let name = single_query_param(&url, "name")?.context("Missing thread name")?;
+ Ok(Self::TextThread {
+ path: path.into(),
+ name,
+ })
+ } else if let Some(rule_id) = path.strip_prefix("/agent/rule/") {
+ let name = single_query_param(&url, "name")?.context("Missing rule name")?;
+ let rule_id = UserPromptId(rule_id.parse()?);
+ Ok(Self::Rule {
+ id: rule_id.into(),
+ name,
+ })
+ } else if path.starts_with("/agent/pasted-image") {
+ Ok(Self::PastedImage)
+ } else if path.starts_with("/agent/untitled-buffer") {
+ let fragment = url
+ .fragment()
+ .context("Missing fragment for untitled buffer selection")?;
+ let line_range = parse_line_range(fragment)?;
+ Ok(Self::Selection {
+ abs_path: None,
+ line_range,
+ })
+ } else {
+ bail!("invalid zed url: {:?}", input);
+ }
+ }
+ "http" | "https" => Ok(MentionUri::Fetch { url }),
+ other => bail!("unrecognized scheme {:?}", other),
+ }
+ }
+
+ pub fn name(&self) -> String {
+ match self {
+ MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => abs_path
+ .file_name()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .into_owned(),
+ MentionUri::PastedImage => "Image".to_string(),
+ MentionUri::Symbol { name, .. } => name.clone(),
+ MentionUri::Thread { name, .. } => name.clone(),
+ MentionUri::TextThread { name, .. } => name.clone(),
+ MentionUri::Rule { name, .. } => name.clone(),
+ MentionUri::Selection {
+ abs_path: path,
+ line_range,
+ ..
+ } => selection_name(path.as_deref(), line_range),
+ MentionUri::Fetch { url } => url.to_string(),
+ }
+ }
+
+ pub fn icon_path(&self, cx: &mut App) -> SharedString {
+ match self {
+ MentionUri::File { abs_path } => {
+ FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into())
+ }
+ MentionUri::PastedImage => IconName::Image.path().into(),
+ MentionUri::Directory { .. } => FileIcons::get_folder_icon(false, cx)
+ .unwrap_or_else(|| IconName::Folder.path().into()),
+ MentionUri::Symbol { .. } => IconName::Code.path().into(),
+ MentionUri::Thread { .. } => IconName::Thread.path().into(),
+ MentionUri::TextThread { .. } => IconName::Thread.path().into(),
+ MentionUri::Rule { .. } => IconName::Reader.path().into(),
+ MentionUri::Selection { .. } => IconName::Reader.path().into(),
+ MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
+ }
+ }
+
+ pub fn as_link<'a>(&'a self) -> MentionLink<'a> {
+ MentionLink(self)
+ }
+
+ pub fn to_uri(&self) -> Url {
+ match self {
+ MentionUri::File { abs_path } => {
+ Url::from_file_path(abs_path).expect("mention path should be absolute")
+ }
+ MentionUri::PastedImage => Url::parse("zed:///agent/pasted-image").unwrap(),
+ MentionUri::Directory { abs_path } => {
+ Url::from_directory_path(abs_path).expect("mention path should be absolute")
+ }
+ MentionUri::Symbol {
+ abs_path,
+ name,
+ line_range,
+ } => {
+ let mut url =
+ Url::from_file_path(abs_path).expect("mention path should be absolute");
+ url.query_pairs_mut().append_pair("symbol", name);
+ url.set_fragment(Some(&format!(
+ "L{}:{}",
+ line_range.start() + 1,
+ line_range.end() + 1
+ )));
+ url
+ }
+ MentionUri::Selection {
+ abs_path: path,
+ line_range,
+ } => {
+ let mut url = if let Some(path) = path {
+ Url::from_file_path(path).expect("mention path should be absolute")
+ } else {
+ let mut url = Url::parse("zed:///").unwrap();
+ url.set_path("/agent/untitled-buffer");
+ url
+ };
+ url.set_fragment(Some(&format!(
+ "L{}:{}",
+ line_range.start() + 1,
+ line_range.end() + 1
+ )));
+ url
+ }
+ MentionUri::Thread { name, id } => {
+ let mut url = Url::parse("zed:///").unwrap();
+ url.set_path(&format!("/agent/thread/{id}"));
+ url.query_pairs_mut().append_pair("name", name);
+ url
+ }
+ MentionUri::TextThread { path, name } => {
+ let mut url = Url::parse("zed:///").unwrap();
+ url.set_path(&format!(
+ "/agent/text-thread/{}",
+ path.to_string_lossy().trim_start_matches('/')
+ ));
+ url.query_pairs_mut().append_pair("name", name);
+ url
+ }
+ MentionUri::Rule { name, id } => {
+ let mut url = Url::parse("zed:///").unwrap();
+ url.set_path(&format!("/agent/rule/{id}"));
+ url.query_pairs_mut().append_pair("name", name);
+ url
+ }
+ MentionUri::Fetch { url } => url.clone(),
+ }
+ }
+}
+
+impl FromStr for MentionUri {
+ type Err = anyhow::Error;
+
+ fn from_str(s: &str) -> anyhow::Result<Self> {
+ Self::parse(s)
+ }
+}
+
+pub struct MentionLink<'a>(&'a MentionUri);
+
+impl fmt::Display for MentionLink<'_> {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "[@{}]({})", self.0.name(), self.0.to_uri())
+ }
+}
+
+fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
+ let pairs = url.query_pairs().collect::<Vec<_>>();
+ match pairs.as_slice() {
+ [] => Ok(None),
+ [(k, v)] => {
+ if k != name {
+ bail!("invalid query parameter")
+ }
+
+ Ok(Some(v.to_string()))
+ }
+ _ => bail!("too many query pairs"),
+ }
+}
+
+pub fn selection_name(path: Option<&Path>, line_range: &RangeInclusive<u32>) -> String {
+ format!(
+ "{} ({}:{})",
+ path.and_then(|path| path.file_name())
+ .unwrap_or("Untitled".as_ref())
+ .display(),
+ *line_range.start() + 1,
+ *line_range.end() + 1
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use util::{path, uri};
+
+ use super::*;
+
+ #[test]
+ fn test_parse_file_uri() {
+ let file_uri = uri!("file:///path/to/file.rs");
+ let parsed = MentionUri::parse(file_uri).unwrap();
+ match &parsed {
+ MentionUri::File { abs_path } => {
+ assert_eq!(abs_path.to_str().unwrap(), path!("/path/to/file.rs"));
+ }
+ _ => panic!("Expected File variant"),
+ }
+ assert_eq!(parsed.to_uri().to_string(), file_uri);
+ }
+
+ #[test]
+ fn test_parse_directory_uri() {
+ let file_uri = uri!("file:///path/to/dir/");
+ let parsed = MentionUri::parse(file_uri).unwrap();
+ match &parsed {
+ MentionUri::Directory { abs_path } => {
+ assert_eq!(abs_path.to_str().unwrap(), path!("/path/to/dir/"));
+ }
+ _ => panic!("Expected Directory variant"),
+ }
+ assert_eq!(parsed.to_uri().to_string(), file_uri);
+ }
+
+ #[test]
+ fn test_to_directory_uri_with_slash() {
+ let uri = MentionUri::Directory {
+ abs_path: PathBuf::from(path!("/path/to/dir/")),
+ };
+ let expected = uri!("file:///path/to/dir/");
+ assert_eq!(uri.to_uri().to_string(), expected);
+ }
+
+ #[test]
+ fn test_to_directory_uri_without_slash() {
+ let uri = MentionUri::Directory {
+ abs_path: PathBuf::from(path!("/path/to/dir")),
+ };
+ let expected = uri!("file:///path/to/dir/");
+ assert_eq!(uri.to_uri().to_string(), expected);
+ }
+
+ #[test]
+ fn test_parse_symbol_uri() {
+ let symbol_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20");
+ let parsed = MentionUri::parse(symbol_uri).unwrap();
+ match &parsed {
+ MentionUri::Symbol {
+ abs_path: path,
+ name,
+ line_range,
+ } => {
+ assert_eq!(path.to_str().unwrap(), path!("/path/to/file.rs"));
+ assert_eq!(name, "MySymbol");
+ assert_eq!(line_range.start(), &9);
+ assert_eq!(line_range.end(), &19);
+ }
+ _ => panic!("Expected Symbol variant"),
+ }
+ assert_eq!(parsed.to_uri().to_string(), symbol_uri);
+ }
+
+ #[test]
+ fn test_parse_selection_uri() {
+ let selection_uri = uri!("file:///path/to/file.rs#L5:15");
+ let parsed = MentionUri::parse(selection_uri).unwrap();
+ match &parsed {
+ MentionUri::Selection {
+ abs_path: path,
+ line_range,
+ } => {
+ assert_eq!(
+ path.as_ref().unwrap().to_str().unwrap(),
+ path!("/path/to/file.rs")
+ );
+ assert_eq!(line_range.start(), &4);
+ assert_eq!(line_range.end(), &14);
+ }
+ _ => panic!("Expected Selection variant"),
+ }
+ assert_eq!(parsed.to_uri().to_string(), selection_uri);
+ }
+
+ #[test]
+ fn test_parse_untitled_selection_uri() {
+ let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10");
+ let parsed = MentionUri::parse(selection_uri).unwrap();
+ match &parsed {
+ MentionUri::Selection {
+ abs_path: None,
+ line_range,
+ } => {
+ assert_eq!(line_range.start(), &0);
+ assert_eq!(line_range.end(), &9);
+ }
+ _ => panic!("Expected Selection variant without path"),
+ }
+ assert_eq!(parsed.to_uri().to_string(), selection_uri);
+ }
+
+ #[test]
+ fn test_parse_thread_uri() {
+ let thread_uri = "zed:///agent/thread/session123?name=Thread+name";
+ let parsed = MentionUri::parse(thread_uri).unwrap();
+ match &parsed {
+ MentionUri::Thread {
+ id: thread_id,
+ name,
+ } => {
+ assert_eq!(thread_id.to_string(), "session123");
+ assert_eq!(name, "Thread name");
+ }
+ _ => panic!("Expected Thread variant"),
+ }
+ assert_eq!(parsed.to_uri().to_string(), thread_uri);
+ }
+
+ #[test]
+ fn test_parse_rule_uri() {
+ let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule";
+ let parsed = MentionUri::parse(rule_uri).unwrap();
+ match &parsed {
+ MentionUri::Rule { id, name } => {
+ assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52");
+ assert_eq!(name, "Some rule");
+ }
+ _ => panic!("Expected Rule variant"),
+ }
+ assert_eq!(parsed.to_uri().to_string(), rule_uri);
+ }
+
+ #[test]
+ fn test_parse_fetch_http_uri() {
+ let http_uri = "http://example.com/path?query=value#fragment";
+ let parsed = MentionUri::parse(http_uri).unwrap();
+ match &parsed {
+ MentionUri::Fetch { url } => {
+ assert_eq!(url.to_string(), http_uri);
+ }
+ _ => panic!("Expected Fetch variant"),
+ }
+ assert_eq!(parsed.to_uri().to_string(), http_uri);
+ }
+
+ #[test]
+ fn test_parse_fetch_https_uri() {
+ let https_uri = "https://example.com/api/endpoint";
+ let parsed = MentionUri::parse(https_uri).unwrap();
+ match &parsed {
+ MentionUri::Fetch { url } => {
+ assert_eq!(url.to_string(), https_uri);
+ }
+ _ => panic!("Expected Fetch variant"),
+ }
+ assert_eq!(parsed.to_uri().to_string(), https_uri);
+ }
+
+ #[test]
+ fn test_invalid_scheme() {
+ assert!(MentionUri::parse("ftp://example.com").is_err());
+ assert!(MentionUri::parse("ssh://example.com").is_err());
+ assert!(MentionUri::parse("unknown://example.com").is_err());
+ }
+
+ #[test]
+ fn test_invalid_zed_path() {
+ assert!(MentionUri::parse("zed:///invalid/path").is_err());
+ assert!(MentionUri::parse("zed:///agent/unknown/test").is_err());
+ }
+
+ #[test]
+ fn test_invalid_line_range_format() {
+ // Missing L prefix
+ assert!(MentionUri::parse(uri!("file:///path/to/file.rs#10:20")).is_err());
+
+ // Missing colon separator
+ assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L1020")).is_err());
+
+ // Invalid numbers
+ assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L10:abc")).is_err());
+ assert!(MentionUri::parse(uri!("file:///path/to/file.rs#Labc:20")).is_err());
+ }
+
+ #[test]
+ fn test_invalid_query_parameters() {
+ // Invalid query parameter name
+ assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L10:20?invalid=test")).is_err());
+
+ // Too many query parameters
+ assert!(
+ MentionUri::parse(uri!(
+ "file:///path/to/file.rs#L10:20?symbol=test&another=param"
+ ))
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn test_zero_based_line_numbers() {
+ // Test that 0-based line numbers are rejected (should be 1-based)
+ assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L0:10")).is_err());
+ assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L1:0")).is_err());
+ assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L0:0")).is_err());
+ }
+}
@@ -0,0 +1,93 @@
+use gpui::{App, AppContext, Context, Entity};
+use language::LanguageRegistry;
+use markdown::Markdown;
+use std::{path::PathBuf, process::ExitStatus, sync::Arc, time::Instant};
+
+pub struct Terminal {
+ command: Entity<Markdown>,
+ working_dir: Option<PathBuf>,
+ terminal: Entity<terminal::Terminal>,
+ started_at: Instant,
+ output: Option<TerminalOutput>,
+}
+
+pub struct TerminalOutput {
+ pub ended_at: Instant,
+ pub exit_status: Option<ExitStatus>,
+ pub was_content_truncated: bool,
+ pub original_content_len: usize,
+ pub content_line_count: usize,
+ pub finished_with_empty_output: bool,
+}
+
+impl Terminal {
+ pub fn new(
+ command: String,
+ working_dir: Option<PathBuf>,
+ terminal: Entity<terminal::Terminal>,
+ language_registry: Arc<LanguageRegistry>,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ Self {
+ command: cx.new(|cx| {
+ Markdown::new(
+ format!("```\n{}\n```", command).into(),
+ Some(language_registry.clone()),
+ None,
+ cx,
+ )
+ }),
+ working_dir,
+ terminal,
+ started_at: Instant::now(),
+ output: None,
+ }
+ }
+
+ pub fn finish(
+ &mut self,
+ exit_status: Option<ExitStatus>,
+ original_content_len: usize,
+ truncated_content_len: usize,
+ content_line_count: usize,
+ finished_with_empty_output: bool,
+ cx: &mut Context<Self>,
+ ) {
+ self.output = Some(TerminalOutput {
+ ended_at: Instant::now(),
+ exit_status,
+ was_content_truncated: truncated_content_len < original_content_len,
+ original_content_len,
+ content_line_count,
+ finished_with_empty_output,
+ });
+ cx.notify();
+ }
+
+ pub fn command(&self) -> &Entity<Markdown> {
+ &self.command
+ }
+
+ pub fn working_dir(&self) -> &Option<PathBuf> {
+ &self.working_dir
+ }
+
+ pub fn started_at(&self) -> Instant {
+ self.started_at
+ }
+
+ pub fn output(&self) -> Option<&TerminalOutput> {
+ self.output.as_ref()
+ }
+
+ pub fn inner(&self) -> &Entity<terminal::Terminal> {
+ &self.terminal
+ }
+
+ pub fn to_markdown(&self, cx: &App) -> String {
+ format!(
+ "Terminal:\n```\n{}\n```\n",
+ self.terminal.read(cx).get_content()
+ )
+ }
+}
@@ -0,0 +1,30 @@
+[package]
+name = "acp_tools"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/acp_tools.rs"
+doctest = false
+
+[dependencies]
+agent-client-protocol.workspace = true
+collections.workspace = true
+gpui.workspace = true
+language.workspace= true
+markdown.workspace = true
+project.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+settings.workspace = true
+theme.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace-hack.workspace = true
+workspace.workspace = true
@@ -0,0 +1,494 @@
+use std::{
+ cell::RefCell,
+ collections::HashSet,
+ fmt::Display,
+ rc::{Rc, Weak},
+ sync::Arc,
+};
+
+use agent_client_protocol as acp;
+use collections::HashMap;
+use gpui::{
+ App, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment, ListState,
+ StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list, prelude::*,
+};
+use language::LanguageRegistry;
+use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle};
+use project::Project;
+use settings::Settings;
+use theme::ThemeSettings;
+use ui::prelude::*;
+use util::ResultExt as _;
+use workspace::{Item, Workspace};
+
+actions!(acp, [OpenDebugTools]);
+
+pub fn init(cx: &mut App) {
+ cx.observe_new(
+ |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
+ workspace.register_action(|workspace, _: &OpenDebugTools, window, cx| {
+ let acp_tools =
+ Box::new(cx.new(|cx| AcpTools::new(workspace.project().clone(), cx)));
+ workspace.add_item_to_active_pane(acp_tools, None, true, window, cx);
+ });
+ },
+ )
+ .detach();
+}
+
+struct GlobalAcpConnectionRegistry(Entity<AcpConnectionRegistry>);
+
+impl Global for GlobalAcpConnectionRegistry {}
+
+#[derive(Default)]
+pub struct AcpConnectionRegistry {
+ active_connection: RefCell<Option<ActiveConnection>>,
+}
+
+struct ActiveConnection {
+ server_name: SharedString,
+ connection: Weak<acp::ClientSideConnection>,
+}
+
+impl AcpConnectionRegistry {
+ pub fn default_global(cx: &mut App) -> Entity<Self> {
+ if cx.has_global::<GlobalAcpConnectionRegistry>() {
+ cx.global::<GlobalAcpConnectionRegistry>().0.clone()
+ } else {
+ let registry = cx.new(|_cx| AcpConnectionRegistry::default());
+ cx.set_global(GlobalAcpConnectionRegistry(registry.clone()));
+ registry
+ }
+ }
+
+ pub fn set_active_connection(
+ &self,
+ server_name: impl Into<SharedString>,
+ connection: &Rc<acp::ClientSideConnection>,
+ cx: &mut Context<Self>,
+ ) {
+ self.active_connection.replace(Some(ActiveConnection {
+ server_name: server_name.into(),
+ connection: Rc::downgrade(connection),
+ }));
+ cx.notify();
+ }
+}
+
+struct AcpTools {
+ project: Entity<Project>,
+ focus_handle: FocusHandle,
+ expanded: HashSet<usize>,
+ watched_connection: Option<WatchedConnection>,
+ connection_registry: Entity<AcpConnectionRegistry>,
+ _subscription: Subscription,
+}
+
+struct WatchedConnection {
+ server_name: SharedString,
+ messages: Vec<WatchedConnectionMessage>,
+ list_state: ListState,
+ connection: Weak<acp::ClientSideConnection>,
+ incoming_request_methods: HashMap<i32, Arc<str>>,
+ outgoing_request_methods: HashMap<i32, Arc<str>>,
+ _task: Task<()>,
+}
+
+impl AcpTools {
+ fn new(project: Entity<Project>, cx: &mut Context<Self>) -> Self {
+ let connection_registry = AcpConnectionRegistry::default_global(cx);
+
+ let subscription = cx.observe(&connection_registry, |this, _, cx| {
+ this.update_connection(cx);
+ cx.notify();
+ });
+
+ let mut this = Self {
+ project,
+ focus_handle: cx.focus_handle(),
+ expanded: HashSet::default(),
+ watched_connection: None,
+ connection_registry,
+ _subscription: subscription,
+ };
+ this.update_connection(cx);
+ this
+ }
+
+ fn update_connection(&mut self, cx: &mut Context<Self>) {
+ let active_connection = self.connection_registry.read(cx).active_connection.borrow();
+ let Some(active_connection) = active_connection.as_ref() else {
+ return;
+ };
+
+ if let Some(watched_connection) = self.watched_connection.as_ref() {
+ if Weak::ptr_eq(
+ &watched_connection.connection,
+ &active_connection.connection,
+ ) {
+ return;
+ }
+ }
+
+ if let Some(connection) = active_connection.connection.upgrade() {
+ let mut receiver = connection.subscribe();
+ let task = cx.spawn(async move |this, cx| {
+ while let Ok(message) = receiver.recv().await {
+ this.update(cx, |this, cx| {
+ this.push_stream_message(message, cx);
+ })
+ .ok();
+ }
+ });
+
+ self.watched_connection = Some(WatchedConnection {
+ server_name: active_connection.server_name.clone(),
+ messages: vec![],
+ list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)),
+ connection: active_connection.connection.clone(),
+ incoming_request_methods: HashMap::default(),
+ outgoing_request_methods: HashMap::default(),
+ _task: task,
+ });
+ }
+ }
+
+ fn push_stream_message(&mut self, stream_message: acp::StreamMessage, cx: &mut Context<Self>) {
+ let Some(connection) = self.watched_connection.as_mut() else {
+ return;
+ };
+ let language_registry = self.project.read(cx).languages().clone();
+ let index = connection.messages.len();
+
+ let (request_id, method, message_type, params) = match stream_message.message {
+ acp::StreamMessageContent::Request { id, method, params } => {
+ let method_map = match stream_message.direction {
+ acp::StreamMessageDirection::Incoming => {
+ &mut connection.incoming_request_methods
+ }
+ acp::StreamMessageDirection::Outgoing => {
+ &mut connection.outgoing_request_methods
+ }
+ };
+
+ method_map.insert(id, method.clone());
+ (Some(id), method.into(), MessageType::Request, Ok(params))
+ }
+ acp::StreamMessageContent::Response { id, result } => {
+ let method_map = match stream_message.direction {
+ acp::StreamMessageDirection::Incoming => {
+ &mut connection.outgoing_request_methods
+ }
+ acp::StreamMessageDirection::Outgoing => {
+ &mut connection.incoming_request_methods
+ }
+ };
+
+ if let Some(method) = method_map.remove(&id) {
+ (Some(id), method.into(), MessageType::Response, result)
+ } else {
+ (
+ Some(id),
+ "[unrecognized response]".into(),
+ MessageType::Response,
+ result,
+ )
+ }
+ }
+ acp::StreamMessageContent::Notification { method, params } => {
+ (None, method.into(), MessageType::Notification, Ok(params))
+ }
+ };
+
+ let message = WatchedConnectionMessage {
+ name: method,
+ message_type,
+ request_id,
+ direction: stream_message.direction,
+ collapsed_params_md: match params.as_ref() {
+ Ok(params) => params
+ .as_ref()
+ .map(|params| collapsed_params_md(params, &language_registry, cx)),
+ Err(err) => {
+ if let Ok(err) = &serde_json::to_value(err) {
+ Some(collapsed_params_md(&err, &language_registry, cx))
+ } else {
+ None
+ }
+ }
+ },
+
+ expanded_params_md: None,
+ params,
+ };
+
+ connection.messages.push(message);
+ connection.list_state.splice(index..index, 1);
+ cx.notify();
+ }
+
+ fn render_message(
+ &mut self,
+ index: usize,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> AnyElement {
+ let Some(connection) = self.watched_connection.as_ref() else {
+ return Empty.into_any();
+ };
+
+ let Some(message) = connection.messages.get(index) else {
+ return Empty.into_any();
+ };
+
+ let base_size = TextSize::Editor.rems(cx);
+
+ let theme_settings = ThemeSettings::get_global(cx);
+ let text_style = window.text_style();
+
+ let colors = cx.theme().colors();
+ let expanded = self.expanded.contains(&index);
+
+ v_flex()
+ .w_full()
+ .px_4()
+ .py_3()
+ .border_color(colors.border)
+ .border_b_1()
+ .gap_2()
+ .items_start()
+ .font_buffer(cx)
+ .text_size(base_size)
+ .id(index)
+ .group("message")
+ .hover(|this| this.bg(colors.element_background.opacity(0.5)))
+ .on_click(cx.listener(move |this, _, _, cx| {
+ if this.expanded.contains(&index) {
+ this.expanded.remove(&index);
+ } else {
+ this.expanded.insert(index);
+ let Some(connection) = &mut this.watched_connection else {
+ return;
+ };
+ let Some(message) = connection.messages.get_mut(index) else {
+ return;
+ };
+ message.expanded(this.project.read(cx).languages().clone(), cx);
+ connection.list_state.scroll_to_reveal_item(index);
+ }
+ cx.notify()
+ }))
+ .child(
+ h_flex()
+ .w_full()
+ .gap_2()
+ .items_center()
+ .flex_shrink_0()
+ .child(match message.direction {
+ acp::StreamMessageDirection::Incoming => {
+ ui::Icon::new(ui::IconName::ArrowDown).color(Color::Error)
+ }
+ acp::StreamMessageDirection::Outgoing => {
+ ui::Icon::new(ui::IconName::ArrowUp).color(Color::Success)
+ }
+ })
+ .child(
+ Label::new(message.name.clone())
+ .buffer_font(cx)
+ .color(Color::Muted),
+ )
+ .child(div().flex_1())
+ .child(
+ div()
+ .child(ui::Chip::new(message.message_type.to_string()))
+ .visible_on_hover("message"),
+ )
+ .children(
+ message
+ .request_id
+ .map(|req_id| div().child(ui::Chip::new(req_id.to_string()))),
+ ),
+ )
+ // I'm aware using markdown is a hack. Trying to get something working for the demo.
+ // Will clean up soon!
+ .when_some(
+ if expanded {
+ message.expanded_params_md.clone()
+ } else {
+ message.collapsed_params_md.clone()
+ },
+ |this, params| {
+ this.child(
+ div().pl_6().w_full().child(
+ MarkdownElement::new(
+ params,
+ MarkdownStyle {
+ base_text_style: text_style,
+ selection_background_color: colors.element_selection_background,
+ syntax: cx.theme().syntax().clone(),
+ code_block_overflow_x_scroll: true,
+ code_block: StyleRefinement {
+ text: Some(TextStyleRefinement {
+ font_family: Some(
+ theme_settings.buffer_font.family.clone(),
+ ),
+ font_size: Some((base_size * 0.8).into()),
+ ..Default::default()
+ }),
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ )
+ .code_block_renderer(
+ CodeBlockRenderer::Default {
+ copy_button: false,
+ copy_button_on_hover: expanded,
+ border: false,
+ },
+ ),
+ ),
+ )
+ },
+ )
+ .into_any()
+ }
+}
+
+struct WatchedConnectionMessage {
+ name: SharedString,
+ request_id: Option<i32>,
+ direction: acp::StreamMessageDirection,
+ message_type: MessageType,
+ params: Result<Option<serde_json::Value>, acp::Error>,
+ collapsed_params_md: Option<Entity<Markdown>>,
+ expanded_params_md: Option<Entity<Markdown>>,
+}
+
+impl WatchedConnectionMessage {
+ fn expanded(&mut self, language_registry: Arc<LanguageRegistry>, cx: &mut App) {
+ let params_md = match &self.params {
+ Ok(Some(params)) => Some(expanded_params_md(params, &language_registry, cx)),
+ Err(err) => {
+ if let Some(err) = &serde_json::to_value(err).log_err() {
+ Some(expanded_params_md(&err, &language_registry, cx))
+ } else {
+ None
+ }
+ }
+ _ => None,
+ };
+ self.expanded_params_md = params_md;
+ }
+}
+
+fn collapsed_params_md(
+ params: &serde_json::Value,
+ language_registry: &Arc<LanguageRegistry>,
+ cx: &mut App,
+) -> Entity<Markdown> {
+ let params_json = serde_json::to_string(params).unwrap_or_default();
+ let mut spaced_out_json = String::with_capacity(params_json.len() + params_json.len() / 4);
+
+ for ch in params_json.chars() {
+ match ch {
+ '{' => spaced_out_json.push_str("{ "),
+ '}' => spaced_out_json.push_str(" }"),
+ ':' => spaced_out_json.push_str(": "),
+ ',' => spaced_out_json.push_str(", "),
+ c => spaced_out_json.push(c),
+ }
+ }
+
+ let params_md = format!("```json\n{}\n```", spaced_out_json);
+ cx.new(|cx| Markdown::new(params_md.into(), Some(language_registry.clone()), None, cx))
+}
+
+fn expanded_params_md(
+ params: &serde_json::Value,
+ language_registry: &Arc<LanguageRegistry>,
+ cx: &mut App,
+) -> Entity<Markdown> {
+ let params_json = serde_json::to_string_pretty(params).unwrap_or_default();
+ let params_md = format!("```json\n{}\n```", params_json);
+ cx.new(|cx| Markdown::new(params_md.into(), Some(language_registry.clone()), None, cx))
+}
+
+enum MessageType {
+ Request,
+ Response,
+ Notification,
+}
+
+impl Display for MessageType {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ MessageType::Request => write!(f, "Request"),
+ MessageType::Response => write!(f, "Response"),
+ MessageType::Notification => write!(f, "Notification"),
+ }
+ }
+}
+
+enum AcpToolsEvent {}
+
+impl EventEmitter<AcpToolsEvent> for AcpTools {}
+
+impl Item for AcpTools {
+ type Event = AcpToolsEvent;
+
+ fn tab_content_text(&self, _detail: usize, _cx: &App) -> ui::SharedString {
+ format!(
+ "ACP: {}",
+ self.watched_connection
+ .as_ref()
+ .map_or("Disconnected", |connection| &connection.server_name)
+ )
+ .into()
+ }
+
+ fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
+ Some(ui::Icon::new(IconName::Thread))
+ }
+}
+
+impl Focusable for AcpTools {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl Render for AcpTools {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ v_flex()
+ .track_focus(&self.focus_handle)
+ .size_full()
+ .bg(cx.theme().colors().editor_background)
+ .child(match self.watched_connection.as_ref() {
+ Some(connection) => {
+ if connection.messages.is_empty() {
+ h_flex()
+ .size_full()
+ .justify_center()
+ .items_center()
+ .child("No messages recorded yet")
+ .into_any()
+ } else {
+ list(
+ connection.list_state.clone(),
+ cx.processor(Self::render_message),
+ )
+ .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
+ .flex_grow()
+ .into_any()
+ }
+ }
+ None => h_flex()
+ .size_full()
+ .justify_center()
+ .items_center()
+ .child("No active connection")
+ .into_any(),
+ })
+ }
+}
@@ -0,0 +1,45 @@
+[package]
+name = "action_log"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lib]
+path = "src/action_log.rs"
+
+[lints]
+workspace = true
+
+[dependencies]
+anyhow.workspace = true
+buffer_diff.workspace = true
+clock.workspace = true
+collections.workspace = true
+futures.workspace = true
+gpui.workspace = true
+language.workspace = true
+project.workspace = true
+text.workspace = true
+util.workspace = true
+watch.workspace = true
+workspace-hack.workspace = true
+
+
+[dev-dependencies]
+buffer_diff = { workspace = true, features = ["test-support"] }
+collections = { workspace = true, features = ["test-support"] }
+clock = { workspace = true, features = ["test-support"] }
+ctor.workspace = true
+gpui = { workspace = true, features = ["test-support"] }
+indoc.workspace = true
+language = { workspace = true, features = ["test-support"] }
+log.workspace = true
+pretty_assertions.workspace = true
+project = { workspace = true, features = ["test-support"] }
+rand.workspace = true
+serde_json.workspace = true
+settings = { workspace = true, features = ["test-support"] }
+text = { workspace = true, features = ["test-support"] }
+util = { workspace = true, features = ["test-support"] }
+zlog.workspace = true
@@ -8,18 +8,17 @@ use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint};
use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle};
use std::{cmp, ops::Range, sync::Arc};
use text::{Edit, Patch, Rope};
-use util::{RangeExt, ResultExt as _};
+use util::{
+ RangeExt, ResultExt as _,
+ paths::{PathStyle, RemotePathBuf},
+};
/// Tracks actions performed by tools in a thread
pub struct ActionLog {
/// Buffers that we want to notify the model about when they change.
tracked_buffers: BTreeMap<Entity<Buffer>, TrackedBuffer>,
- /// Has the model edited a file since it last checked diagnostics?
- edited_since_project_diagnostics_check: bool,
/// The project this action log is associated with
project: Entity<Project>,
- /// Tracks which buffer versions have already been notified as changed externally
- notified_versions: BTreeMap<Entity<Buffer>, clock::Global>,
}
impl ActionLog {
@@ -27,9 +26,7 @@ impl ActionLog {
pub fn new(project: Entity<Project>) -> Self {
Self {
tracked_buffers: BTreeMap::default(),
- edited_since_project_diagnostics_check: false,
project,
- notified_versions: BTreeMap::default(),
}
}
@@ -37,18 +34,63 @@ impl ActionLog {
&self.project
}
- /// Notifies a diagnostics check
- pub fn checked_project_diagnostics(&mut self) {
- self.edited_since_project_diagnostics_check = false;
+ pub fn latest_snapshot(&self, buffer: &Entity<Buffer>) -> Option<text::BufferSnapshot> {
+ Some(self.tracked_buffers.get(buffer)?.snapshot.clone())
}
- /// Returns true if any files have been edited since the last project diagnostics check
- pub fn has_edited_files_since_project_diagnostics_check(&self) -> bool {
- self.edited_since_project_diagnostics_check
+ /// Return a unified diff patch with user edits made since last read or notification
+ pub fn unnotified_user_edits(&self, cx: &Context<Self>) -> Option<String> {
+ let diffs = self
+ .tracked_buffers
+ .values()
+ .filter_map(|tracked| {
+ if !tracked.may_have_unnotified_user_edits {
+ return None;
+ }
+
+ let text_with_latest_user_edits = tracked.diff_base.to_string();
+ let text_with_last_seen_user_edits = tracked.last_seen_base.to_string();
+ if text_with_latest_user_edits == text_with_last_seen_user_edits {
+ return None;
+ }
+ let patch = language::unified_diff(
+ &text_with_last_seen_user_edits,
+ &text_with_latest_user_edits,
+ );
+
+ let buffer = tracked.buffer.clone();
+ let file_path = buffer
+ .read(cx)
+ .file()
+ .map(|file| RemotePathBuf::new(file.full_path(cx), PathStyle::Posix).to_proto())
+ .unwrap_or_else(|| format!("buffer_{}", buffer.entity_id()));
+
+ let mut result = String::new();
+ result.push_str(&format!("--- a/{}\n", file_path));
+ result.push_str(&format!("+++ b/{}\n", file_path));
+ result.push_str(&patch);
+
+ Some(result)
+ })
+ .collect::<Vec<_>>();
+
+ if diffs.is_empty() {
+ return None;
+ }
+
+ let unified_diff = diffs.join("\n\n");
+ Some(unified_diff)
}
- pub fn latest_snapshot(&self, buffer: &Entity<Buffer>) -> Option<text::BufferSnapshot> {
- Some(self.tracked_buffers.get(buffer)?.snapshot.clone())
+ /// Return a unified diff patch with user edits made since last read/notification
+ /// and mark them as notified
+ pub fn flush_unnotified_user_edits(&mut self, cx: &Context<Self>) -> Option<String> {
+ let patch = self.unnotified_user_edits(cx);
+ self.tracked_buffers.values_mut().for_each(|tracked| {
+ tracked.may_have_unnotified_user_edits = false;
+ tracked.last_seen_base = tracked.diff_base.clone();
+ });
+ patch
}
fn track_buffer_internal(
@@ -59,7 +101,6 @@ impl ActionLog {
) -> &mut TrackedBuffer {
let status = if is_created {
if let Some(tracked) = self.tracked_buffers.remove(&buffer) {
- self.notified_versions.remove(&buffer);
match tracked.status {
TrackedBufferStatus::Created {
existing_file_content,
@@ -75,7 +116,7 @@ impl ActionLog {
} else if buffer
.read(cx)
.file()
- .map_or(false, |file| file.disk_state().exists())
+ .is_some_and(|file| file.disk_state().exists())
{
TrackedBufferStatus::Created {
existing_file_content: Some(buffer.read(cx).as_rope().clone()),
@@ -101,26 +142,31 @@ impl ActionLog {
let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
let (diff_update_tx, diff_update_rx) = mpsc::unbounded();
let diff_base;
+ let last_seen_base;
let unreviewed_edits;
if is_created {
diff_base = Rope::default();
+ last_seen_base = Rope::default();
unreviewed_edits = Patch::new(vec![Edit {
old: 0..1,
new: 0..text_snapshot.max_point().row + 1,
}])
} else {
diff_base = buffer.read(cx).as_rope().clone();
+ last_seen_base = diff_base.clone();
unreviewed_edits = Patch::default();
}
TrackedBuffer {
buffer: buffer.clone(),
diff_base,
+ last_seen_base,
unreviewed_edits,
- snapshot: text_snapshot.clone(),
+ snapshot: text_snapshot,
status,
version: buffer.read(cx).version(),
diff,
diff_update: diff_update_tx,
+ may_have_unnotified_user_edits: false,
_open_lsp_handle: open_lsp_handle,
_maintain_diff: cx.spawn({
let buffer = buffer.clone();
@@ -144,7 +190,7 @@ impl ActionLog {
cx: &mut Context<Self>,
) {
match event {
- BufferEvent::Edited { .. } => self.handle_buffer_edited(buffer, cx),
+ BufferEvent::Edited => self.handle_buffer_edited(buffer, cx),
BufferEvent::FileHandleChanged => {
self.handle_buffer_file_changed(buffer, cx);
}
@@ -169,12 +215,11 @@ impl ActionLog {
if buffer
.read(cx)
.file()
- .map_or(false, |file| file.disk_state() == DiskState::Deleted)
+ .is_some_and(|file| file.disk_state() == DiskState::Deleted)
{
// If the buffer had been edited by a tool, but it got
// deleted externally, we want to stop tracking it.
self.tracked_buffers.remove(&buffer);
- self.notified_versions.remove(&buffer);
}
cx.notify();
}
@@ -182,13 +227,12 @@ impl ActionLog {
if buffer
.read(cx)
.file()
- .map_or(false, |file| file.disk_state() != DiskState::Deleted)
+ .is_some_and(|file| file.disk_state() != DiskState::Deleted)
{
// If the buffer had been deleted by a tool, but it got
// resurrected externally, we want to clear the edits we
// were tracking and reset the buffer's state.
self.tracked_buffers.remove(&buffer);
- self.notified_versions.remove(&buffer);
self.track_buffer_internal(buffer, false, cx);
}
cx.notify();
@@ -220,15 +264,14 @@ impl ActionLog {
if let Some((git_diff, (buffer_repo, _))) = git_diff.as_ref().zip(buffer_repo) {
cx.update(|cx| {
let mut old_head = buffer_repo.read(cx).head_commit.clone();
- Some(cx.subscribe(git_diff, move |_, event, cx| match event {
- buffer_diff::BufferDiffEvent::DiffChanged { .. } => {
+ Some(cx.subscribe(git_diff, move |_, event, cx| {
+ if let buffer_diff::BufferDiffEvent::DiffChanged { .. } = event {
let new_head = buffer_repo.read(cx).head_commit.clone();
if new_head != old_head {
old_head = new_head;
git_diff_updates_tx.send(()).ok();
}
}
- _ => {}
}))
})?
} else {
@@ -246,7 +289,7 @@ impl ActionLog {
}
_ = git_diff_updates_rx.changed().fuse() => {
if let Some(git_diff) = git_diff.as_ref() {
- Self::keep_committed_edits(&this, &buffer, &git_diff, cx).await?;
+ Self::keep_committed_edits(&this, &buffer, git_diff, cx).await?;
}
}
}
@@ -262,10 +305,10 @@ impl ActionLog {
buffer_snapshot: text::BufferSnapshot,
cx: &mut AsyncApp,
) -> Result<()> {
- let rebase = this.read_with(cx, |this, cx| {
+ let rebase = this.update(cx, |this, cx| {
let tracked_buffer = this
.tracked_buffers
- .get(buffer)
+ .get_mut(buffer)
.context("buffer not tracked")?;
let rebase = cx.background_spawn({
@@ -273,23 +316,35 @@ impl ActionLog {
let old_snapshot = tracked_buffer.snapshot.clone();
let new_snapshot = buffer_snapshot.clone();
let unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
+ let edits = diff_snapshots(&old_snapshot, &new_snapshot);
+ let mut has_user_changes = false;
async move {
- let edits = diff_snapshots(&old_snapshot, &new_snapshot);
if let ChangeAuthor::User = author {
- apply_non_conflicting_edits(
+ has_user_changes = apply_non_conflicting_edits(
&unreviewed_edits,
edits,
&mut base_text,
new_snapshot.as_rope(),
);
}
- (Arc::new(base_text.to_string()), base_text)
+
+ (Arc::new(base_text.to_string()), base_text, has_user_changes)
}
});
anyhow::Ok(rebase)
})??;
- let (new_base_text, new_diff_base) = rebase.await;
+ let (new_base_text, new_diff_base, has_user_changes) = rebase.await;
+
+ this.update(cx, |this, _| {
+ let tracked_buffer = this
+ .tracked_buffers
+ .get_mut(buffer)
+ .context("buffer not tracked")
+ .unwrap();
+ tracked_buffer.may_have_unnotified_user_edits |= has_user_changes;
+ })?;
+
Self::update_diff(
this,
buffer,
@@ -406,7 +461,7 @@ impl ActionLog {
anyhow::Ok((
tracked_buffer.diff.clone(),
buffer.read(cx).language().cloned(),
- buffer.read(cx).language_registry().clone(),
+ buffer.read(cx).language_registry(),
))
})??;
let diff_snapshot = BufferDiff::update_diff(
@@ -442,7 +497,7 @@ impl ActionLog {
new: new_range,
},
&new_diff_base,
- &buffer_snapshot.as_rope(),
+ buffer_snapshot.as_rope(),
));
}
unreviewed_edits
@@ -474,15 +529,12 @@ impl ActionLog {
/// Mark a buffer as created by agent, so we can refresh it in the context
pub fn buffer_created(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
- self.edited_since_project_diagnostics_check = true;
- self.track_buffer_internal(buffer.clone(), true, cx);
+ self.track_buffer_internal(buffer, true, cx);
}
/// Mark a buffer as edited by agent, so we can refresh it in the context
pub fn buffer_edited(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
- self.edited_since_project_diagnostics_check = true;
-
- let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx);
+ let tracked_buffer = self.track_buffer_internal(buffer, false, cx);
if let TrackedBufferStatus::Deleted = tracked_buffer.status {
tracked_buffer.status = TrackedBufferStatus::Modified;
}
@@ -494,7 +546,6 @@ impl ActionLog {
match tracked_buffer.status {
TrackedBufferStatus::Created { .. } => {
self.tracked_buffers.remove(&buffer);
- self.notified_versions.remove(&buffer);
cx.notify();
}
TrackedBufferStatus::Modified => {
@@ -520,7 +571,6 @@ impl ActionLog {
match tracked_buffer.status {
TrackedBufferStatus::Deleted => {
self.tracked_buffers.remove(&buffer);
- self.notified_versions.remove(&buffer);
cx.notify();
}
_ => {
@@ -563,6 +613,11 @@ impl ActionLog {
false
}
});
+ if tracked_buffer.unreviewed_edits.is_empty()
+ && let TrackedBufferStatus::Created { .. } = &mut tracked_buffer.status
+ {
+ tracked_buffer.status = TrackedBufferStatus::Modified;
+ }
tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
}
}
@@ -629,7 +684,6 @@ impl ActionLog {
};
self.tracked_buffers.remove(&buffer);
- self.notified_versions.remove(&buffer);
cx.notify();
task
}
@@ -643,7 +697,6 @@ impl ActionLog {
// Clear all tracked edits for this buffer and start over as if we just read it.
self.tracked_buffers.remove(&buffer);
- self.notified_versions.remove(&buffer);
self.buffer_read(buffer.clone(), cx);
cx.notify();
save
@@ -710,6 +763,9 @@ impl ActionLog {
.retain(|_buffer, tracked_buffer| match tracked_buffer.status {
TrackedBufferStatus::Deleted => false,
_ => {
+ if let TrackedBufferStatus::Created { .. } = &mut tracked_buffer.status {
+ tracked_buffer.status = TrackedBufferStatus::Modified;
+ }
tracked_buffer.unreviewed_edits.clear();
tracked_buffer.diff_base = tracked_buffer.snapshot.as_rope().clone();
tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
@@ -744,33 +800,6 @@ impl ActionLog {
.collect()
}
- /// Returns stale buffers that haven't been notified yet
- pub fn unnotified_stale_buffers<'a>(
- &'a self,
- cx: &'a App,
- ) -> impl Iterator<Item = &'a Entity<Buffer>> {
- self.stale_buffers(cx).filter(|buffer| {
- let buffer_entity = buffer.read(cx);
- self.notified_versions
- .get(buffer)
- .map_or(true, |notified_version| {
- *notified_version != buffer_entity.version
- })
- })
- }
-
- /// Marks the given buffers as notified at their current versions
- pub fn mark_buffers_as_notified(
- &mut self,
- buffers: impl IntoIterator<Item = Entity<Buffer>>,
- cx: &App,
- ) {
- for buffer in buffers {
- let version = buffer.read(cx).version.clone();
- self.notified_versions.insert(buffer, version);
- }
- }
-
/// Iterate over buffers changed since last read or edited by the model
pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a Entity<Buffer>> {
self.tracked_buffers
@@ -781,7 +810,7 @@ impl ActionLog {
tracked.version != buffer.version
&& buffer
.file()
- .map_or(false, |file| file.disk_state() != DiskState::Deleted)
+ .is_some_and(|file| file.disk_state() != DiskState::Deleted)
})
.map(|(buffer, _)| buffer)
}
@@ -792,11 +821,12 @@ fn apply_non_conflicting_edits(
edits: Vec<Edit<u32>>,
old_text: &mut Rope,
new_text: &Rope,
-) {
+) -> bool {
let mut old_edits = patch.edits().iter().cloned().peekable();
let mut new_edits = edits.into_iter().peekable();
let mut applied_delta = 0i32;
let mut rebased_delta = 0i32;
+ let mut has_made_changes = false;
while let Some(mut new_edit) = new_edits.next() {
let mut conflict = false;
@@ -816,7 +846,7 @@ fn apply_non_conflicting_edits(
conflict = true;
if new_edits
.peek()
- .map_or(false, |next_edit| next_edit.old.overlaps(&old_edit.new))
+ .is_some_and(|next_edit| next_edit.old.overlaps(&old_edit.new))
{
new_edit = new_edits.next().unwrap();
} else {
@@ -846,8 +876,10 @@ fn apply_non_conflicting_edits(
&new_text.chunks_in_range(new_bytes).collect::<String>(),
);
applied_delta += new_edit.new_len() as i32 - new_edit.old_len() as i32;
+ has_made_changes = true;
}
}
+ has_made_changes
}
fn diff_snapshots(
@@ -914,12 +946,14 @@ enum TrackedBufferStatus {
struct TrackedBuffer {
buffer: Entity<Buffer>,
diff_base: Rope,
+ last_seen_base: Rope,
unreviewed_edits: Patch<u32>,
status: TrackedBufferStatus,
version: clock::Global,
diff: Entity<BufferDiff>,
snapshot: text::BufferSnapshot,
diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>,
+ may_have_unnotified_user_edits: bool,
_open_lsp_handle: OpenLspBufferHandle,
_maintain_diff: Task<()>,
_subscription: Subscription,
@@ -929,7 +963,7 @@ impl TrackedBuffer {
fn has_edits(&self, cx: &App) -> bool {
self.diff
.read(cx)
- .hunks(&self.buffer.read(cx), cx)
+ .hunks(self.buffer.read(cx), cx)
.next()
.is_some()
}
@@ -950,6 +984,7 @@ mod tests {
use super::*;
use buffer_diff::DiffHunkStatusKind;
use gpui::TestAppContext;
+ use indoc::indoc;
use language::Point;
use project::{FakeFs, Fs, Project, RemoveOptions};
use rand::prelude::*;
@@ -1232,6 +1267,110 @@ mod tests {
assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
}
+ #[gpui::test(iterations = 10)]
+ async fn test_user_edits_notifications(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/dir"),
+ json!({"file": indoc! {"
+ abc
+ def
+ ghi
+ jkl
+ mno"}}),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+ let file_path = project
+ .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
+ .unwrap();
+ let buffer = project
+ .update(cx, |project, cx| project.open_buffer(file_path, cx))
+ .await
+ .unwrap();
+
+ // Agent edits
+ cx.update(|cx| {
+ action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
+ buffer.update(cx, |buffer, cx| {
+ buffer
+ .edit([(Point::new(1, 2)..Point::new(2, 3), "F\nGHI")], None, cx)
+ .unwrap()
+ });
+ action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
+ });
+ cx.run_until_parked();
+ assert_eq!(
+ buffer.read_with(cx, |buffer, _| buffer.text()),
+ indoc! {"
+ abc
+ deF
+ GHI
+ jkl
+ mno"}
+ );
+ assert_eq!(
+ unreviewed_hunks(&action_log, cx),
+ vec![(
+ buffer.clone(),
+ vec![HunkStatus {
+ range: Point::new(1, 0)..Point::new(3, 0),
+ diff_status: DiffHunkStatusKind::Modified,
+ old_text: "def\nghi\n".into(),
+ }],
+ )]
+ );
+
+ // User edits
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit(
+ [
+ (Point::new(0, 2)..Point::new(0, 2), "X"),
+ (Point::new(3, 0)..Point::new(3, 0), "Y"),
+ ],
+ None,
+ cx,
+ )
+ });
+ cx.run_until_parked();
+ assert_eq!(
+ buffer.read_with(cx, |buffer, _| buffer.text()),
+ indoc! {"
+ abXc
+ deF
+ GHI
+ Yjkl
+ mno"}
+ );
+
+ // User edits should be stored separately from agent's
+ let user_edits = action_log.update(cx, |log, cx| log.unnotified_user_edits(cx));
+ assert_eq!(
+ user_edits.expect("should have some user edits"),
+ indoc! {"
+ --- a/dir/file
+ +++ b/dir/file
+ @@ -1,5 +1,5 @@
+ -abc
+ +abXc
+ def
+ ghi
+ -jkl
+ +Yjkl
+ mno
+ "}
+ );
+
+ action_log.update(cx, |log, cx| {
+ log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), cx)
+ });
+ cx.run_until_parked();
+ assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
+ }
+
#[gpui::test(iterations = 10)]
async fn test_creating_files(cx: &mut TestAppContext) {
init_test(cx);
@@ -1927,6 +2066,134 @@ mod tests {
assert_eq!(content, "ai content\nuser added this line");
}
+ #[gpui::test]
+ async fn test_reject_after_accepting_hunk_on_created_file(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+
+ let file_path = project
+ .read_with(cx, |project, cx| {
+ project.find_project_path("dir/new_file", cx)
+ })
+ .unwrap();
+ let buffer = project
+ .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
+ .await
+ .unwrap();
+
+ // AI creates file with initial content
+ cx.update(|cx| {
+ action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
+ buffer.update(cx, |buffer, cx| buffer.set_text("ai content v1", cx));
+ action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
+ });
+ project
+ .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
+ .await
+ .unwrap();
+ cx.run_until_parked();
+ assert_ne!(unreviewed_hunks(&action_log, cx), vec![]);
+
+ // User accepts the single hunk
+ action_log.update(cx, |log, cx| {
+ log.keep_edits_in_range(buffer.clone(), Anchor::MIN..Anchor::MAX, cx)
+ });
+ cx.run_until_parked();
+ assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
+ assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
+
+ // AI modifies the file
+ cx.update(|cx| {
+ buffer.update(cx, |buffer, cx| buffer.set_text("ai content v2", cx));
+ action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
+ });
+ project
+ .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
+ .await
+ .unwrap();
+ cx.run_until_parked();
+ assert_ne!(unreviewed_hunks(&action_log, cx), vec![]);
+
+ // User rejects the hunk
+ action_log
+ .update(cx, |log, cx| {
+ log.reject_edits_in_ranges(buffer.clone(), vec![Anchor::MIN..Anchor::MAX], cx)
+ })
+ .await
+ .unwrap();
+ cx.run_until_parked();
+ assert!(fs.is_file(path!("/dir/new_file").as_ref()).await,);
+ assert_eq!(
+ buffer.read_with(cx, |buffer, _| buffer.text()),
+ "ai content v1"
+ );
+ assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
+ }
+
+ #[gpui::test]
+ async fn test_reject_edits_on_previously_accepted_created_file(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+
+ let file_path = project
+ .read_with(cx, |project, cx| {
+ project.find_project_path("dir/new_file", cx)
+ })
+ .unwrap();
+ let buffer = project
+ .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
+ .await
+ .unwrap();
+
+ // AI creates file with initial content
+ cx.update(|cx| {
+ action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
+ buffer.update(cx, |buffer, cx| buffer.set_text("ai content v1", cx));
+ action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
+ });
+ project
+ .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
+ .await
+ .unwrap();
+ cx.run_until_parked();
+
+ // User clicks "Accept All"
+ action_log.update(cx, |log, cx| log.keep_all_edits(cx));
+ cx.run_until_parked();
+ assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
+ assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); // Hunks are cleared
+
+ // AI modifies file again
+ cx.update(|cx| {
+ buffer.update(cx, |buffer, cx| buffer.set_text("ai content v2", cx));
+ action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
+ });
+ project
+ .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
+ .await
+ .unwrap();
+ cx.run_until_parked();
+ assert_ne!(unreviewed_hunks(&action_log, cx), vec![]);
+
+ // User clicks "Reject All"
+ action_log
+ .update(cx, |log, cx| log.reject_all_edits(cx))
+ .await;
+ cx.run_until_parked();
+ assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
+ assert_eq!(
+ buffer.read_with(cx, |buffer, _| buffer.text()),
+ "ai content v1"
+ );
+ assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
+ }
+
#[gpui::test(iterations = 100)]
async fn test_random_diffs(mut rng: StdRng, cx: &mut TestAppContext) {
init_test(cx);
@@ -2000,7 +2267,7 @@ mod tests {
log::info!("quiescing...");
cx.run_until_parked();
action_log.update(cx, |log, cx| {
- let tracked_buffer = log.tracked_buffers.get(&buffer).unwrap();
+ let tracked_buffer = log.tracked_buffers.get(buffer).unwrap();
let mut old_text = tracked_buffer.diff_base.clone();
let new_text = buffer.read(cx).as_rope();
for edit in tracked_buffer.unreviewed_edits.edits() {
@@ -2158,7 +2425,7 @@ mod tests {
assert_eq!(
unreviewed_hunks(&action_log, cx),
vec![(
- buffer.clone(),
+ buffer,
vec![
HunkStatus {
range: Point::new(6, 0)..Point::new(7, 0),
@@ -2221,4 +2488,61 @@ mod tests {
.collect()
})
}
+
+ #[gpui::test]
+ async fn test_format_patch(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/dir"),
+ json!({"test.txt": "line 1\nline 2\nline 3\n"}),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+
+ let file_path = project
+ .read_with(cx, |project, cx| {
+ project.find_project_path("dir/test.txt", cx)
+ })
+ .unwrap();
+ let buffer = project
+ .update(cx, |project, cx| project.open_buffer(file_path, cx))
+ .await
+ .unwrap();
+
+ cx.update(|cx| {
+ // Track the buffer and mark it as read first
+ action_log.update(cx, |log, cx| {
+ log.buffer_read(buffer.clone(), cx);
+ });
+
+ // Make some edits to create a patch
+ buffer.update(cx, |buffer, cx| {
+ buffer
+ .edit([(Point::new(1, 0)..Point::new(1, 6), "CHANGED")], None, cx)
+ .unwrap(); // Replace "line2" with "CHANGED"
+ });
+ });
+
+ cx.run_until_parked();
+
+ // Get the patch
+ let patch = action_log.update(cx, |log, cx| log.unnotified_user_edits(cx));
+
+ // Verify the patch format contains expected unified diff elements
+ assert_eq!(
+ patch.unwrap(),
+ indoc! {"
+ --- a/dir/test.txt
+ +++ b/dir/test.txt
+ @@ -1,3 +1,3 @@
+ line 1
+ -line 2
+ +CHANGED
+ line 3
+ "}
+ );
+ }
}
@@ -103,26 +103,21 @@ impl ActivityIndicator {
cx.subscribe_in(
&workspace_handle,
window,
- |activity_indicator, _, event, window, cx| match event {
- workspace::Event::ClearActivityIndicator { .. } => {
- if activity_indicator.statuses.pop().is_some() {
- activity_indicator.dismiss_error_message(
- &DismissErrorMessage,
- window,
- cx,
- );
- cx.notify();
- }
+ |activity_indicator, _, event, window, cx| {
+ if let workspace::Event::ClearActivityIndicator = event
+ && activity_indicator.statuses.pop().is_some()
+ {
+ activity_indicator.dismiss_error_message(&DismissErrorMessage, window, cx);
+ cx.notify();
}
- _ => {}
},
)
.detach();
cx.subscribe(
&project.read(cx).lsp_store(),
- |activity_indicator, _, event, cx| match event {
- LspStoreEvent::LanguageServerUpdate { name, message, .. } => {
+ |activity_indicator, _, event, cx| {
+ if let LspStoreEvent::LanguageServerUpdate { name, message, .. } = event {
if let proto::update_language_server::Variant::StatusUpdate(status_update) =
message
{
@@ -191,7 +186,6 @@ impl ActivityIndicator {
}
cx.notify()
}
- _ => {}
},
)
.detach();
@@ -206,9 +200,10 @@ impl ActivityIndicator {
cx.subscribe(
&project.read(cx).git_store().clone(),
- |_, _, event: &GitStoreEvent, cx| match event {
- project::git_store::GitStoreEvent::JobsUpdated => cx.notify(),
- _ => {}
+ |_, _, event: &GitStoreEvent, cx| {
+ if let project::git_store::GitStoreEvent::JobsUpdated = event {
+ cx.notify()
+ }
},
)
.detach();
@@ -231,7 +226,6 @@ impl ActivityIndicator {
status,
} => {
let create_buffer = project.update(cx, |project, cx| project.create_buffer(cx));
- let project = project.clone();
let status = status.clone();
let server_name = server_name.clone();
cx.spawn_in(window, async move |workspace, cx| {
@@ -247,8 +241,7 @@ impl ActivityIndicator {
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(
Box::new(cx.new(|cx| {
- let mut editor =
- Editor::for_buffer(buffer, Some(project.clone()), window, cx);
+ let mut editor = Editor::for_buffer(buffer, None, window, cx);
editor.set_read_only(true);
editor
})),
@@ -460,26 +453,24 @@ impl ActivityIndicator {
.map(|r| r.read(cx))
.and_then(Repository::current_job);
// Show any long-running git command
- if let Some(job_info) = current_job {
- if Instant::now() - job_info.start >= GIT_OPERATION_DELAY {
- return Some(Content {
- icon: Some(
- Icon::new(IconName::ArrowCircle)
- .size(IconSize::Small)
- .with_animation(
- "arrow-circle",
- Animation::new(Duration::from_secs(2)).repeat(),
- |icon, delta| {
- icon.transform(Transformation::rotate(percentage(delta)))
- },
- )
- .into_any_element(),
- ),
- message: job_info.message.into(),
- on_click: None,
- tooltip_message: None,
- });
- }
+ if let Some(job_info) = current_job
+ && Instant::now() - job_info.start >= GIT_OPERATION_DELAY
+ {
+ return Some(Content {
+ icon: Some(
+ Icon::new(IconName::ArrowCircle)
+ .size(IconSize::Small)
+ .with_animation(
+ "arrow-circle",
+ Animation::new(Duration::from_secs(2)).repeat(),
+ |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
+ )
+ .into_any_element(),
+ ),
+ message: job_info.message.into(),
+ on_click: None,
+ tooltip_message: None,
+ });
}
// Show any language server installation info.
@@ -704,7 +695,7 @@ impl ActivityIndicator {
on_click: Some(Arc::new(|this, window, cx| {
this.dismiss_error_message(&DismissErrorMessage, window, cx)
})),
- tooltip_message: Some(Self::version_tooltip_message(&version)),
+ tooltip_message: Some(Self::version_tooltip_message(version)),
}),
AutoUpdateStatus::Installing { version } => Some(Content {
icon: Some(
@@ -716,21 +707,13 @@ impl ActivityIndicator {
on_click: Some(Arc::new(|this, window, cx| {
this.dismiss_error_message(&DismissErrorMessage, window, cx)
})),
- tooltip_message: Some(Self::version_tooltip_message(&version)),
+ tooltip_message: Some(Self::version_tooltip_message(version)),
}),
- AutoUpdateStatus::Updated {
- binary_path,
- version,
- } => Some(Content {
+ AutoUpdateStatus::Updated { version } => Some(Content {
icon: None,
message: "Click to restart and update Zed".to_string(),
- on_click: Some(Arc::new({
- let reload = workspace::Reload {
- binary_path: Some(binary_path.clone()),
- };
- move |_, _, cx| workspace::reload(&reload, cx)
- })),
- tooltip_message: Some(Self::version_tooltip_message(&version)),
+ on_click: Some(Arc::new(move |_, _, cx| workspace::reload(cx))),
+ tooltip_message: Some(Self::version_tooltip_message(version)),
}),
AutoUpdateStatus::Errored => Some(Content {
icon: Some(
@@ -750,21 +733,20 @@ impl ActivityIndicator {
if let Some(extension_store) =
ExtensionStore::try_global(cx).map(|extension_store| extension_store.read(cx))
+ && let Some(extension_id) = extension_store.outstanding_operations().keys().next()
{
- if let Some(extension_id) = extension_store.outstanding_operations().keys().next() {
- return Some(Content {
- icon: Some(
- Icon::new(IconName::Download)
- .size(IconSize::Small)
- .into_any_element(),
- ),
- message: format!("Updating {extension_id} extension…"),
- on_click: Some(Arc::new(|this, window, cx| {
- this.dismiss_error_message(&DismissErrorMessage, window, cx)
- })),
- tooltip_message: None,
- });
- }
+ return Some(Content {
+ icon: Some(
+ Icon::new(IconName::Download)
+ .size(IconSize::Small)
+ .into_any_element(),
+ ),
+ message: format!("Updating {extension_id} extension…"),
+ on_click: Some(Arc::new(|this, window, cx| {
+ this.dismiss_error_message(&DismissErrorMessage, window, cx)
+ })),
+ tooltip_message: None,
+ });
}
None
@@ -19,25 +19,26 @@ test-support = [
]
[dependencies]
+action_log.workspace = true
agent_settings.workspace = true
anyhow.workspace = true
assistant_context.workspace = true
assistant_tool.workspace = true
chrono.workspace = true
client.workspace = true
+cloud_llm_client.workspace = true
collections.workspace = true
component.workspace = true
context_server.workspace = true
convert_case.workspace = true
-feature_flags.workspace = true
fs.workspace = true
futures.workspace = true
git.workspace = true
gpui.workspace = true
heed.workspace = true
+http_client.workspace = true
icons.workspace = true
indoc.workspace = true
-http_client.workspace = true
itertools.workspace = true
language.workspace = true
language_model.workspace = true
@@ -46,7 +47,6 @@ paths.workspace = true
postage.workspace = true
project.workspace = true
prompt_store.workspace = true
-proto.workspace = true
ref-cast.workspace = true
rope.workspace = true
schemars.workspace = true
@@ -63,7 +63,6 @@ time.workspace = true
util.workspace = true
uuid.workspace = true
workspace-hack.workspace = true
-zed_llm_client.workspace = true
zstd.workspace = true
[dev-dependencies]
@@ -90,7 +90,7 @@ impl AgentProfile {
return false;
};
- return Self::is_enabled(settings, source, tool_name);
+ Self::is_enabled(settings, source, tool_name)
}
fn is_enabled(settings: &AgentProfileSettings, source: ToolSource, name: String) -> bool {
@@ -132,7 +132,7 @@ mod tests {
});
let tool_set = default_tool_set(cx);
- let profile = AgentProfile::new(id.clone(), tool_set);
+ let profile = AgentProfile::new(id, tool_set);
let mut enabled_tools = cx
.read(|cx| profile.enabled_tools(cx))
@@ -169,7 +169,7 @@ mod tests {
});
let tool_set = default_tool_set(cx);
- let profile = AgentProfile::new(id.clone(), tool_set);
+ let profile = AgentProfile::new(id, tool_set);
let mut enabled_tools = cx
.read(|cx| profile.enabled_tools(cx))
@@ -202,7 +202,7 @@ mod tests {
});
let tool_set = default_tool_set(cx);
- let profile = AgentProfile::new(id.clone(), tool_set);
+ let profile = AgentProfile::new(id, tool_set);
let mut enabled_tools = cx
.read(|cx| profile.enabled_tools(cx))
@@ -308,7 +308,12 @@ mod tests {
unimplemented!()
}
- fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool {
+ fn needs_confirmation(
+ &self,
+ _input: &serde_json::Value,
+ _project: &Entity<Project>,
+ _cx: &App,
+ ) -> bool {
unimplemented!()
}
@@ -321,7 +326,7 @@ mod tests {
_input: serde_json::Value,
_request: Arc<language_model::LanguageModelRequest>,
_project: Entity<Project>,
- _action_log: Entity<assistant_tool::ActionLog>,
+ _action_log: Entity<action_log::ActionLog>,
_model: Arc<dyn language_model::LanguageModel>,
_window: Option<gpui::AnyWindowHandle>,
_cx: &mut App,
@@ -20,7 +20,7 @@ use text::{Anchor, OffsetRangeExt as _};
use util::markdown::MarkdownCodeBlock;
use util::{ResultExt as _, post_inc};
-pub const RULES_ICON: IconName = IconName::Context;
+pub const RULES_ICON: IconName = IconName::Reader;
pub enum ContextKind {
File,
@@ -40,10 +40,10 @@ impl ContextKind {
ContextKind::File => IconName::File,
ContextKind::Directory => IconName::Folder,
ContextKind::Symbol => IconName::Code,
- ContextKind::Selection => IconName::Context,
- ContextKind::FetchedUrl => IconName::Globe,
- ContextKind::Thread => IconName::MessageBubbles,
- ContextKind::TextThread => IconName::MessageBubbles,
+ ContextKind::Selection => IconName::Reader,
+ ContextKind::FetchedUrl => IconName::ToolWeb,
+ ContextKind::Thread => IconName::Thread,
+ ContextKind::TextThread => IconName::TextThread,
ContextKind::Rules => RULES_ICON,
ContextKind::Image => IconName::Image,
}
@@ -201,24 +201,24 @@ impl FileContextHandle {
parse_status.changed().await.log_err();
}
- if let Ok(snapshot) = buffer.read_with(cx, |buffer, _| buffer.snapshot()) {
- if let Some(outline) = snapshot.outline(None) {
- let items = outline
- .items
- .into_iter()
- .map(|item| item.to_point(&snapshot));
-
- if let Ok(outline_text) =
- outline::render_outline(items, None, 0, usize::MAX).await
- {
- let context = AgentContext::File(FileContext {
- handle: self,
- full_path,
- text: outline_text.into(),
- is_outline: true,
- });
- return Some((context, vec![buffer]));
- }
+ if let Ok(snapshot) = buffer.read_with(cx, |buffer, _| buffer.snapshot())
+ && let Some(outline) = snapshot.outline(None)
+ {
+ let items = outline
+ .items
+ .into_iter()
+ .map(|item| item.to_point(&snapshot));
+
+ if let Ok(outline_text) =
+ outline::render_outline(items, None, 0, usize::MAX).await
+ {
+ let context = AgentContext::File(FileContext {
+ handle: self,
+ full_path,
+ text: outline_text.into(),
+ is_outline: true,
+ });
+ return Some((context, vec![buffer]));
}
}
}
@@ -362,7 +362,7 @@ impl Display for DirectoryContext {
let mut is_first = true;
for descendant in &self.descendants {
if !is_first {
- write!(f, "\n")?;
+ writeln!(f)?;
} else {
is_first = false;
}
@@ -650,7 +650,7 @@ impl TextThreadContextHandle {
impl Display for TextThreadContext {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
// TODO: escape title?
- write!(f, "<text_thread title=\"{}\">\n", self.title)?;
+ writeln!(f, "<text_thread title=\"{}\">", self.title)?;
write!(f, "{}", self.text.trim())?;
write!(f, "\n</text_thread>")
}
@@ -716,7 +716,7 @@ impl RulesContextHandle {
impl Display for RulesContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(title) = &self.title {
- write!(f, "Rules title: {}\n", title)?;
+ writeln!(f, "Rules title: {}", title)?;
}
let code_block = MarkdownCodeBlock {
tag: "",
@@ -1,7 +1,8 @@
use std::sync::Arc;
+use action_log::ActionLog;
use anyhow::{Result, anyhow, bail};
-use assistant_tool::{ActionLog, Tool, ToolResult, ToolSource};
+use assistant_tool::{Tool, ToolResult, ToolSource};
use context_server::{ContextServerId, types};
use gpui::{AnyWindowHandle, App, Entity, Task};
use icons::IconName;
@@ -38,7 +39,7 @@ impl Tool for ContextServerTool {
}
fn icon(&self) -> IconName {
- IconName::Cog
+ IconName::ToolHammer
}
fn source(&self) -> ToolSource {
@@ -47,7 +48,7 @@ impl Tool for ContextServerTool {
}
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
true
}
@@ -85,15 +86,13 @@ impl Tool for ContextServerTool {
) -> ToolResult {
if let Some(server) = self.store.read(cx).get_running_server(&self.server_id) {
let tool_name = self.tool.name.clone();
- let server_clone = server.clone();
- let input_clone = input.clone();
cx.spawn(async move |_cx| {
- let Some(protocol) = server_clone.client() else {
+ let Some(protocol) = server.client() else {
bail!("Context server not initialized");
};
- let arguments = if let serde_json::Value::Object(map) = input_clone {
+ let arguments = if let serde_json::Value::Object(map) = input {
Some(map.into_iter().collect())
} else {
None
@@ -338,11 +338,9 @@ impl ContextStore {
image_task,
context_id: self.next_context_id.post_inc(),
});
- if self.has_context(&context) {
- if remove_if_exists {
- self.remove_context(&context, cx);
- return None;
- }
+ if self.has_context(&context) && remove_if_exists {
+ self.remove_context(&context, cx);
+ return None;
}
self.insert_context(context.clone(), cx);
@@ -212,7 +212,16 @@ impl HistoryStore {
fn load_recently_opened_entries(cx: &AsyncApp) -> Task<Result<Vec<HistoryEntryId>>> {
cx.background_spawn(async move {
let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH);
- let contents = smol::fs::read_to_string(path).await?;
+ let contents = match smol::fs::read_to_string(path).await {
+ Ok(it) => it,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ return Ok(Vec::new());
+ }
+ Err(e) => {
+ return Err(e)
+ .context("deserializing persisted agent panel navigation history");
+ }
+ };
let entries = serde_json::from_str::<Vec<SerializedRecentOpen>>(&contents)
.context("deserializing persisted agent panel navigation history")?
.into_iter()
@@ -245,10 +254,9 @@ impl HistoryStore {
}
pub fn remove_recently_opened_thread(&mut self, id: ThreadId, cx: &mut Context<Self>) {
- self.recently_opened_entries.retain(|entry| match entry {
- HistoryEntryId::Thread(thread_id) if thread_id == &id => false,
- _ => true,
- });
+ self.recently_opened_entries.retain(
+ |entry| !matches!(entry, HistoryEntryId::Thread(thread_id) if thread_id == &id),
+ );
self.save_recently_opened_entries(cx);
}
@@ -8,19 +8,24 @@ use crate::{
},
tool_use::{PendingToolUse, ToolUse, ToolUseMetadata, ToolUseState},
};
-use agent_settings::{AgentProfileId, AgentSettings, CompletionMode};
+use action_log::ActionLog;
+use agent_settings::{
+ AgentProfileId, AgentSettings, CompletionMode, SUMMARIZE_THREAD_DETAILED_PROMPT,
+ SUMMARIZE_THREAD_PROMPT,
+};
use anyhow::{Result, anyhow};
-use assistant_tool::{ActionLog, AnyToolCard, Tool, ToolWorkingSet};
+use assistant_tool::{AnyToolCard, Tool, ToolWorkingSet};
use chrono::{DateTime, Utc};
use client::{ModelRequestUsage, RequestUsage};
+use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, Plan, UsageLimit};
use collections::HashMap;
-use feature_flags::{self, FeatureFlagAppExt};
use futures::{FutureExt, StreamExt as _, future::Shared};
use git::repository::DiffType;
use gpui::{
AnyWindowHandle, App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task,
WeakEntity, Window,
};
+use http_client::StatusCode;
use language_model::{
ConfiguredModel, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelExt as _, LanguageModelId, LanguageModelRegistry, LanguageModelRequest,
@@ -35,7 +40,6 @@ use project::{
git_store::{GitStore, GitStoreCheckpoint, RepositoryState},
};
use prompt_store::{ModelContext, PromptBuilder};
-use proto::Plan;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
@@ -46,12 +50,23 @@ use std::{
time::{Duration, Instant},
};
use thiserror::Error;
-use util::{ResultExt as _, debug_panic, post_inc};
+use util::{ResultExt as _, post_inc};
use uuid::Uuid;
-use zed_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit};
-const MAX_RETRY_ATTEMPTS: u8 = 3;
-const BASE_RETRY_DELAY_SECS: u64 = 5;
+const MAX_RETRY_ATTEMPTS: u8 = 4;
+const BASE_RETRY_DELAY: Duration = Duration::from_secs(5);
+
+#[derive(Debug, Clone)]
+enum RetryStrategy {
+ ExponentialBackoff {
+ initial_delay: Duration,
+ max_attempts: u8,
+ },
+ Fixed {
+ delay: Duration,
+ max_attempts: u8,
+ },
+}
#[derive(
Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize, JsonSchema,
@@ -95,7 +110,7 @@ impl std::fmt::Display for PromptId {
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)]
-pub struct MessageId(pub(crate) usize);
+pub struct MessageId(pub usize);
impl MessageId {
fn post_inc(&mut self) -> Self {
@@ -166,7 +181,7 @@ impl Message {
}
}
- pub fn to_string(&self) -> String {
+ pub fn to_message_content(&self) -> String {
let mut result = String::new();
if !self.loaded_context.text.is_empty() {
@@ -372,10 +387,8 @@ pub struct Thread {
cumulative_token_usage: TokenUsage,
exceeded_window_error: Option<ExceededWindowError>,
tool_use_limit_reached: bool,
- feedback: Option<ThreadFeedback>,
retry_state: Option<RetryState>,
message_feedback: HashMap<MessageId, ThreadFeedback>,
- last_auto_capture_at: Option<Instant>,
last_received_chunk_at: Option<Instant>,
request_callback: Option<
Box<dyn FnMut(&LanguageModelRequest, &[Result<LanguageModelCompletionEvent, String>])>,
@@ -383,6 +396,7 @@ pub struct Thread {
remaining_turns: u32,
configured_model: Option<ConfiguredModel>,
profile: AgentProfile,
+ last_error_context: Option<(Arc<dyn LanguageModel>, CompletionIntent)>,
}
#[derive(Clone, Debug)]
@@ -472,10 +486,9 @@ impl Thread {
cumulative_token_usage: TokenUsage::default(),
exceeded_window_error: None,
tool_use_limit_reached: false,
- feedback: None,
retry_state: None,
message_feedback: HashMap::default(),
- last_auto_capture_at: None,
+ last_error_context: None,
last_received_chunk_at: None,
request_callback: None,
remaining_turns: u32::MAX,
@@ -517,7 +530,7 @@ impl Thread {
.and_then(|model| {
let model = SelectedModel {
provider: model.provider.clone().into(),
- model: model.model.clone().into(),
+ model: model.model.into(),
};
registry.select_model(&model, cx)
})
@@ -597,9 +610,8 @@ impl Thread {
cumulative_token_usage: serialized.cumulative_token_usage,
exceeded_window_error: None,
tool_use_limit_reached: serialized.tool_use_limit_reached,
- feedback: None,
message_feedback: HashMap::default(),
- last_auto_capture_at: None,
+ last_error_context: None,
last_received_chunk_at: None,
request_callback: None,
remaining_turns: u32::MAX,
@@ -652,7 +664,7 @@ impl Thread {
}
pub fn get_or_init_configured_model(&mut self, cx: &App) -> Option<ConfiguredModel> {
- if self.configured_model.is_none() {
+ if self.configured_model.is_none() || self.messages.is_empty() {
self.configured_model = LanguageModelRegistry::read_global(cx).default_model();
}
self.configured_model.clone()
@@ -828,11 +840,17 @@ impl Thread {
.await
.unwrap_or(false);
- if !equal {
- this.update(cx, |this, cx| {
- this.insert_checkpoint(pending_checkpoint, cx)
- })?;
- }
+ this.update(cx, |this, cx| {
+ this.pending_checkpoint = if equal {
+ Some(pending_checkpoint)
+ } else {
+ this.insert_checkpoint(pending_checkpoint, cx);
+ Some(ThreadCheckpoint {
+ message_id: this.next_message_id,
+ git_checkpoint: final_checkpoint,
+ })
+ }
+ })?;
Ok(())
}
@@ -926,7 +944,7 @@ impl Thread {
}
pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec<ToolUse> {
- self.tool_use.tool_uses_for_message(id, cx)
+ self.tool_use.tool_uses_for_message(id, &self.project, cx)
}
pub fn tool_results_for_message(
@@ -1011,8 +1029,6 @@ impl Thread {
});
}
- self.auto_capture_telemetry(cx);
-
message_id
}
@@ -1251,9 +1267,58 @@ impl Thread {
self.flush_notifications(model.clone(), intent, cx);
- let request = self.to_completion_request(model.clone(), intent, cx);
+ let _checkpoint = self.finalize_pending_checkpoint(cx);
+ self.stream_completion(
+ self.to_completion_request(model.clone(), intent, cx),
+ model,
+ intent,
+ window,
+ cx,
+ );
+ }
+
+ pub fn retry_last_completion(
+ &mut self,
+ window: Option<AnyWindowHandle>,
+ cx: &mut Context<Self>,
+ ) {
+ // Clear any existing error state
+ self.retry_state = None;
+
+ // Use the last error context if available, otherwise fall back to configured model
+ let (model, intent) = if let Some((model, intent)) = self.last_error_context.take() {
+ (model, intent)
+ } else if let Some(configured_model) = self.configured_model.as_ref() {
+ let model = configured_model.model.clone();
+ let intent = if self.has_pending_tool_uses() {
+ CompletionIntent::ToolResults
+ } else {
+ CompletionIntent::UserPrompt
+ };
+ (model, intent)
+ } else if let Some(configured_model) = self.get_or_init_configured_model(cx) {
+ let model = configured_model.model.clone();
+ let intent = if self.has_pending_tool_uses() {
+ CompletionIntent::ToolResults
+ } else {
+ CompletionIntent::UserPrompt
+ };
+ (model, intent)
+ } else {
+ return;
+ };
+
+ self.send_to_model(model, intent, window, cx);
+ }
- self.stream_completion(request, model, intent, window, cx);
+ pub fn enable_burn_mode_and_retry(
+ &mut self,
+ window: Option<AnyWindowHandle>,
+ cx: &mut Context<Self>,
+ ) {
+ self.completion_mode = CompletionMode::Burn;
+ cx.emit(ThreadEvent::ProfileChanged);
+ self.retry_last_completion(window, cx);
}
pub fn used_tools_since_last_user_message(&self) -> bool {
@@ -1517,21 +1582,21 @@ impl Thread {
model: Arc<dyn LanguageModel>,
cx: &mut App,
) -> Option<PendingToolUse> {
- let action_log = self.action_log.read(cx);
-
- action_log.unnotified_stale_buffers(cx).next()?;
-
// Represent notification as a simulated `project_notifications` tool call
let tool_name = Arc::from("project_notifications");
- let Some(tool) = self.tools.read(cx).tool(&tool_name, cx) else {
- debug_panic!("`project_notifications` tool not found");
- return None;
- };
+ let tool = self.tools.read(cx).tool(&tool_name, cx)?;
if !self.profile.is_tool_enabled(tool.source(), tool.name(), cx) {
return None;
}
+ if self
+ .action_log
+ .update(cx, |log, cx| log.unnotified_user_edits(cx).is_none())
+ {
+ return None;
+ }
+
let input = serde_json::json!({});
let request = Arc::new(LanguageModelRequest::default()); // unused
let window = None;
@@ -1578,17 +1643,15 @@ impl Thread {
};
self.tool_use
- .request_tool_use(tool_message_id, tool_use, tool_use_metadata.clone(), cx);
+ .request_tool_use(tool_message_id, tool_use, tool_use_metadata, cx);
- let pending_tool_use = self.tool_use.insert_tool_output(
- tool_use_id.clone(),
+ self.tool_use.insert_tool_output(
+ tool_use_id,
tool_name,
tool_output,
self.configured_model.as_ref(),
self.completion_mode,
- );
-
- pending_tool_use
+ )
}
pub fn stream_completion(
@@ -1616,12 +1679,12 @@ impl Thread {
let completion_mode = request
.mode
- .unwrap_or(zed_llm_client::CompletionMode::Normal);
+ .unwrap_or(cloud_llm_client::CompletionMode::Normal);
self.last_received_chunk_at = Some(Instant::now());
let task = cx.spawn(async move |thread, cx| {
- let stream_completion_future = model.stream_completion(request, &cx);
+ let stream_completion_future = model.stream_completion(request, cx);
let initial_token_usage =
thread.read_with(cx, |thread, _cx| thread.cumulative_token_usage);
let stream_completion = async {
@@ -1753,7 +1816,7 @@ impl Thread {
let streamed_input = if tool_use.is_input_complete {
None
} else {
- Some((&tool_use.input).clone())
+ Some(tool_use.input.clone())
};
let ui_text = thread.tool_use.request_tool_use(
@@ -1835,7 +1898,6 @@ impl Thread {
cx.emit(ThreadEvent::StreamedCompletion);
cx.notify();
- thread.auto_capture_telemetry(cx);
Ok(())
})??;
@@ -1903,11 +1965,9 @@ impl Thread {
if let Some(prev_message) =
thread.messages.get(ix - 1)
- {
- if prev_message.role == Role::Assistant {
+ && prev_message.role == Role::Assistant {
break;
}
- }
}
}
@@ -1933,18 +1993,6 @@ impl Thread {
project.set_agent_location(None, cx);
});
- fn emit_generic_error(error: &anyhow::Error, cx: &mut Context<Thread>) {
- let error_message = error
- .chain()
- .map(|err| err.to_string())
- .collect::<Vec<_>>()
- .join("\n");
- cx.emit(ThreadEvent::ShowError(ThreadError::Message {
- header: "Error interacting with language model".into(),
- message: SharedString::from(error_message.clone()),
- }));
- }
-
if error.is::<PaymentRequiredError>() {
cx.emit(ThreadEvent::ShowError(ThreadError::PaymentRequired));
} else if let Some(error) =
@@ -1956,9 +2004,10 @@ impl Thread {
} else if let Some(completion_error) =
error.downcast_ref::<LanguageModelCompletionError>()
{
- use LanguageModelCompletionError::*;
match &completion_error {
- PromptTooLarge { tokens, .. } => {
+ LanguageModelCompletionError::PromptTooLarge {
+ tokens, ..
+ } => {
let tokens = tokens.unwrap_or_else(|| {
// We didn't get an exact token count from the API, so fall back on our estimate.
thread
@@ -1979,63 +2028,28 @@ impl Thread {
});
cx.notify();
}
- RateLimitExceeded {
- retry_after: Some(retry_after),
- ..
- }
- | ServerOverloaded {
- retry_after: Some(retry_after),
- ..
- } => {
- thread.handle_rate_limit_error(
- &completion_error,
- *retry_after,
- model.clone(),
- intent,
- window,
- cx,
- );
- retry_scheduled = true;
- }
- RateLimitExceeded { .. } | ServerOverloaded { .. } => {
- retry_scheduled = thread.handle_retryable_error(
- &completion_error,
- model.clone(),
- intent,
- window,
- cx,
- );
- if !retry_scheduled {
- emit_generic_error(error, cx);
- }
- }
- ApiInternalServerError { .. }
- | ApiReadResponseError { .. }
- | HttpSend { .. } => {
- retry_scheduled = thread.handle_retryable_error(
- &completion_error,
- model.clone(),
- intent,
- window,
- cx,
- );
- if !retry_scheduled {
- emit_generic_error(error, cx);
+ _ => {
+ if let Some(retry_strategy) =
+ Thread::get_retry_strategy(completion_error)
+ {
+ log::info!(
+ "Retrying with {:?} for language model completion error {:?}",
+ retry_strategy,
+ completion_error
+ );
+
+ retry_scheduled = thread
+ .handle_retryable_error_with_delay(
+ completion_error,
+ Some(retry_strategy),
+ model.clone(),
+ intent,
+ window,
+ cx,
+ );
}
}
- NoApiKey { .. }
- | HttpResponseError { .. }
- | BadRequestFormat { .. }
- | AuthenticationError { .. }
- | PermissionError { .. }
- | ApiEndpointNotFound { .. }
- | SerializeRequest { .. }
- | BuildRequestBody { .. }
- | DeserializeResponse { .. }
- | Other { .. } => emit_generic_error(error, cx),
}
- } else {
- emit_generic_error(error, cx);
}
if !retry_scheduled {
@@ -2056,8 +2070,6 @@ impl Thread {
request_callback(request, response_events);
}
- thread.auto_capture_telemetry(cx);
-
if let Ok(initial_usage) = initial_token_usage {
let usage = thread.cumulative_token_usage - initial_usage;
@@ -2085,7 +2097,7 @@ impl Thread {
}
pub fn summarize(&mut self, cx: &mut Context<Self>) {
- let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model() else {
+ let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model(cx) else {
println!("No thread summary model");
return;
};
@@ -2094,12 +2106,10 @@ impl Thread {
return;
}
- let added_user_message = include_str!("./prompts/summarize_thread_prompt.txt");
-
let request = self.to_summarize_request(
&model.model,
CompletionIntent::ThreadSummarization,
- added_user_message.into(),
+ SUMMARIZE_THREAD_PROMPT.into(),
cx,
);
@@ -2107,7 +2117,7 @@ impl Thread {
self.pending_summary = cx.spawn(async move |this, cx| {
let result = async {
- let mut messages = model.model.stream_completion(request, &cx).await?;
+ let mut messages = model.model.stream_completion(request, cx).await?;
let mut new_summary = String::new();
while let Some(event) = messages.next().await {
@@ -2162,73 +2172,150 @@ impl Thread {
});
}
- fn handle_rate_limit_error(
- &mut self,
- error: &LanguageModelCompletionError,
- retry_after: Duration,
- model: Arc<dyn LanguageModel>,
- intent: CompletionIntent,
- window: Option<AnyWindowHandle>,
- cx: &mut Context<Self>,
- ) {
- // For rate limit errors, we only retry once with the specified duration
- let retry_message = format!("{error}. Retrying in {} seconds…", retry_after.as_secs());
- log::warn!(
- "Retrying completion request in {} seconds: {error:?}",
- retry_after.as_secs(),
- );
-
- // Add a UI-only message instead of a regular message
- let id = self.next_message_id.post_inc();
- self.messages.push(Message {
- id,
- role: Role::System,
- segments: vec![MessageSegment::Text(retry_message)],
- loaded_context: LoadedContext::default(),
- creases: Vec::new(),
- is_hidden: false,
- ui_only: true,
- });
- cx.emit(ThreadEvent::MessageAdded(id));
- // Schedule the retry
- let thread_handle = cx.entity().downgrade();
-
- cx.spawn(async move |_thread, cx| {
- cx.background_executor().timer(retry_after).await;
+ fn get_retry_strategy(error: &LanguageModelCompletionError) -> Option<RetryStrategy> {
+ use LanguageModelCompletionError::*;
- thread_handle
- .update(cx, |thread, cx| {
- // Retry the completion
- thread.send_to_model(model, intent, window, cx);
+ // General strategy here:
+ // - If retrying won't help (e.g. invalid API key or payload too large), return None so we don't retry at all.
+ // - If it's a time-based issue (e.g. server overloaded, rate limit exceeded), retry up to 4 times with exponential backoff.
+ // - If it's an issue that *might* be fixed by retrying (e.g. internal server error), retry up to 3 times.
+ match error {
+ HttpResponseError {
+ status_code: StatusCode::TOO_MANY_REQUESTS,
+ ..
+ } => Some(RetryStrategy::ExponentialBackoff {
+ initial_delay: BASE_RETRY_DELAY,
+ max_attempts: MAX_RETRY_ATTEMPTS,
+ }),
+ ServerOverloaded { retry_after, .. } | RateLimitExceeded { retry_after, .. } => {
+ Some(RetryStrategy::Fixed {
+ delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
+ max_attempts: MAX_RETRY_ATTEMPTS,
})
- .log_err();
- })
- .detach();
- }
-
- fn handle_retryable_error(
- &mut self,
- error: &LanguageModelCompletionError,
- model: Arc<dyn LanguageModel>,
- intent: CompletionIntent,
- window: Option<AnyWindowHandle>,
- cx: &mut Context<Self>,
- ) -> bool {
- self.handle_retryable_error_with_delay(error, None, model, intent, window, cx)
+ }
+ UpstreamProviderError {
+ status,
+ retry_after,
+ ..
+ } => match *status {
+ StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE => {
+ Some(RetryStrategy::Fixed {
+ delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
+ max_attempts: MAX_RETRY_ATTEMPTS,
+ })
+ }
+ StatusCode::INTERNAL_SERVER_ERROR => Some(RetryStrategy::Fixed {
+ delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
+ // Internal Server Error could be anything, retry up to 3 times.
+ max_attempts: 3,
+ }),
+ status => {
+ // There is no StatusCode variant for the unofficial HTTP 529 ("The service is overloaded"),
+ // but we frequently get them in practice. See https://http.dev/529
+ if status.as_u16() == 529 {
+ Some(RetryStrategy::Fixed {
+ delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
+ max_attempts: MAX_RETRY_ATTEMPTS,
+ })
+ } else {
+ Some(RetryStrategy::Fixed {
+ delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
+ max_attempts: 2,
+ })
+ }
+ }
+ },
+ ApiInternalServerError { .. } => Some(RetryStrategy::Fixed {
+ delay: BASE_RETRY_DELAY,
+ max_attempts: 3,
+ }),
+ ApiReadResponseError { .. }
+ | HttpSend { .. }
+ | DeserializeResponse { .. }
+ | BadRequestFormat { .. } => Some(RetryStrategy::Fixed {
+ delay: BASE_RETRY_DELAY,
+ max_attempts: 3,
+ }),
+ // Retrying these errors definitely shouldn't help.
+ HttpResponseError {
+ status_code:
+ StatusCode::PAYLOAD_TOO_LARGE | StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED,
+ ..
+ }
+ | AuthenticationError { .. }
+ | PermissionError { .. }
+ | NoApiKey { .. }
+ | ApiEndpointNotFound { .. }
+ | PromptTooLarge { .. } => None,
+ // These errors might be transient, so retry them
+ SerializeRequest { .. } | BuildRequestBody { .. } => Some(RetryStrategy::Fixed {
+ delay: BASE_RETRY_DELAY,
+ max_attempts: 1,
+ }),
+ // Retry all other 4xx and 5xx errors once.
+ HttpResponseError { status_code, .. }
+ if status_code.is_client_error() || status_code.is_server_error() =>
+ {
+ Some(RetryStrategy::Fixed {
+ delay: BASE_RETRY_DELAY,
+ max_attempts: 3,
+ })
+ }
+ Other(err)
+ if err.is::<PaymentRequiredError>()
+ || err.is::<ModelRequestLimitReachedError>() =>
+ {
+ // Retrying won't help for Payment Required or Model Request Limit errors (where
+ // the user must upgrade to usage-based billing to get more requests, or else wait
+ // for a significant amount of time for the request limit to reset).
+ None
+ }
+ // Conservatively assume that any other errors are non-retryable
+ HttpResponseError { .. } | Other(..) => Some(RetryStrategy::Fixed {
+ delay: BASE_RETRY_DELAY,
+ max_attempts: 2,
+ }),
+ }
}
fn handle_retryable_error_with_delay(
&mut self,
error: &LanguageModelCompletionError,
- custom_delay: Option<Duration>,
+ strategy: Option<RetryStrategy>,
model: Arc<dyn LanguageModel>,
intent: CompletionIntent,
window: Option<AnyWindowHandle>,
cx: &mut Context<Self>,
) -> bool {
+ // Store context for the Retry button
+ self.last_error_context = Some((model.clone(), intent));
+
+ // Only auto-retry if Burn Mode is enabled
+ if self.completion_mode != CompletionMode::Burn {
+ // Show error with retry options
+ cx.emit(ThreadEvent::ShowError(ThreadError::RetryableError {
+ message: format!(
+ "{}\n\nTo automatically retry when similar errors happen, enable Burn Mode.",
+ error
+ )
+ .into(),
+ can_enable_burn_mode: true,
+ }));
+ return false;
+ }
+
+ let Some(strategy) = strategy.or_else(|| Self::get_retry_strategy(error)) else {
+ return false;
+ };
+
+ let max_attempts = match &strategy {
+ RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts,
+ RetryStrategy::Fixed { max_attempts, .. } => *max_attempts,
+ };
+
let retry_state = self.retry_state.get_or_insert(RetryState {
attempt: 0,
- max_attempts: MAX_RETRY_ATTEMPTS,
+ max_attempts,
intent,
});
@@ -2238,20 +2325,24 @@ impl Thread {
let intent = retry_state.intent;
if attempt <= max_attempts {
- // Use custom delay if provided (e.g., from rate limit), otherwise exponential backoff
- let delay = if let Some(custom_delay) = custom_delay {
- custom_delay
- } else {
- let delay_secs = BASE_RETRY_DELAY_SECS * 2u64.pow((attempt - 1) as u32);
- Duration::from_secs(delay_secs)
+ let delay = match &strategy {
+ RetryStrategy::ExponentialBackoff { initial_delay, .. } => {
+ let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32);
+ Duration::from_secs(delay_secs)
+ }
+ RetryStrategy::Fixed { delay, .. } => *delay,
};
// Add a transient message to inform the user
let delay_secs = delay.as_secs();
- let retry_message = format!(
- "{error}. Retrying (attempt {attempt} of {max_attempts}) \
- in {delay_secs} seconds..."
- );
+ let retry_message = if max_attempts == 1 {
+ format!("{error}. Retrying in {delay_secs} seconds...")
+ } else {
+ format!(
+ "{error}. Retrying (attempt {attempt} of {max_attempts}) \
+ in {delay_secs} seconds..."
+ )
+ };
log::warn!(
"Retrying completion request (attempt {attempt} of {max_attempts}) \
in {delay_secs} seconds: {error:?}",
@@ -2290,18 +2381,15 @@ impl Thread {
// Max retries exceeded
self.retry_state = None;
- let notification_text = if max_attempts == 1 {
- "Failed after retrying.".into()
- } else {
- format!("Failed after retrying {} times.", max_attempts).into()
- };
-
// Stop generating since we're giving up on retrying.
self.pending_completions.clear();
- cx.emit(ThreadEvent::RetriesFailed {
- message: notification_text,
- });
+ // Show error alongside a Retry button, but no
+ // Enable Burn Mode button (since it's already enabled)
+ cx.emit(ThreadEvent::ShowError(ThreadError::RetryableError {
+ message: format!("Failed after retrying: {}", error).into(),
+ can_enable_burn_mode: false,
+ }));
false
}
@@ -2328,7 +2416,7 @@ impl Thread {
}
let Some(ConfiguredModel { model, provider }) =
- LanguageModelRegistry::read_global(cx).thread_summary_model()
+ LanguageModelRegistry::read_global(cx).thread_summary_model(cx)
else {
return;
};
@@ -2337,12 +2425,10 @@ impl Thread {
return;
}
- let added_user_message = include_str!("./prompts/summarize_thread_detailed_prompt.txt");
-
let request = self.to_summarize_request(
&model,
CompletionIntent::ThreadContextSummarization,
- added_user_message.into(),
+ SUMMARIZE_THREAD_DETAILED_PROMPT.into(),
cx,
);
@@ -2355,7 +2441,7 @@ impl Thread {
// which result to prefer (the old task could complete after the new one, resulting in a
// stale summary).
self.detailed_summary_task = cx.spawn(async move |thread, cx| {
- let stream = model.stream_completion_text(request, &cx);
+ let stream = model.stream_completion_text(request, cx);
let Some(mut messages) = stream.await.log_err() else {
thread
.update(cx, |thread, _cx| {
@@ -2384,13 +2470,13 @@ impl Thread {
.ok()?;
// Save thread so its summary can be reused later
- if let Some(thread) = thread.upgrade() {
- if let Ok(Ok(save_task)) = cx.update(|cx| {
+ if let Some(thread) = thread.upgrade()
+ && let Ok(Ok(save_task)) = cx.update(|cx| {
thread_store
.update(cx, |thread_store, cx| thread_store.save_thread(&thread, cx))
- }) {
- save_task.await.log_err();
- }
+ })
+ {
+ save_task.await.log_err();
}
Some(())
@@ -2435,7 +2521,6 @@ impl Thread {
model: Arc<dyn LanguageModel>,
cx: &mut Context<Self>,
) -> Vec<PendingToolUse> {
- self.auto_capture_telemetry(cx);
let request =
Arc::new(self.to_completion_request(model.clone(), CompletionIntent::ToolResults, cx));
let pending_tool_uses = self
@@ -2469,7 +2554,7 @@ impl Thread {
return self.handle_hallucinated_tool_use(tool_use.id, tool_use.name, window, cx);
}
- if tool.needs_confirmation(&tool_use.input, cx)
+ if tool.needs_confirmation(&tool_use.input, &self.project, cx)
&& !AgentSettings::get_global(cx).always_allow_tool_actions
{
self.tool_use.confirm_tool_use(
@@ -2639,13 +2724,11 @@ impl Thread {
window: Option<AnyWindowHandle>,
cx: &mut Context<Self>,
) {
- if self.all_tools_finished() {
- if let Some(ConfiguredModel { model, .. }) = self.configured_model.as_ref() {
- if !canceled {
- self.send_to_model(model.clone(), CompletionIntent::ToolResults, window, cx);
- }
- self.auto_capture_telemetry(cx);
- }
+ if self.all_tools_finished()
+ && let Some(ConfiguredModel { model, .. }) = self.configured_model.as_ref()
+ && !canceled
+ {
+ self.send_to_model(model.clone(), CompletionIntent::ToolResults, window, cx);
}
cx.emit(ThreadEvent::ToolFinished {
@@ -2701,10 +2784,6 @@ impl Thread {
cx.emit(ThreadEvent::CancelEditing);
}
- pub fn feedback(&self) -> Option<ThreadFeedback> {
- self.feedback
- }
-
pub fn message_feedback(&self, message_id: MessageId) -> Option<ThreadFeedback> {
self.message_feedback.get(&message_id).copied()
}
@@ -2737,7 +2816,7 @@ impl Thread {
let message_content = self
.message(message_id)
- .map(|msg| msg.to_string())
+ .map(|msg| msg.to_message_content())
.unwrap_or_default();
cx.background_spawn(async move {
@@ -2766,52 +2845,6 @@ impl Thread {
})
}
- pub fn report_feedback(
- &mut self,
- feedback: ThreadFeedback,
- cx: &mut Context<Self>,
- ) -> Task<Result<()>> {
- let last_assistant_message_id = self
- .messages
- .iter()
- .rev()
- .find(|msg| msg.role == Role::Assistant)
- .map(|msg| msg.id);
-
- if let Some(message_id) = last_assistant_message_id {
- self.report_message_feedback(message_id, feedback, cx)
- } else {
- let final_project_snapshot = Self::project_snapshot(self.project.clone(), cx);
- let serialized_thread = self.serialize(cx);
- let thread_id = self.id().clone();
- let client = self.project.read(cx).client();
- self.feedback = Some(feedback);
- cx.notify();
-
- cx.background_spawn(async move {
- let final_project_snapshot = final_project_snapshot.await;
- let serialized_thread = serialized_thread.await?;
- let thread_data = serde_json::to_value(serialized_thread)
- .unwrap_or_else(|_| serde_json::Value::Null);
-
- let rating = match feedback {
- ThreadFeedback::Positive => "positive",
- ThreadFeedback::Negative => "negative",
- };
- telemetry::event!(
- "Assistant Thread Rated",
- rating,
- thread_id,
- thread_data,
- final_project_snapshot
- );
- client.telemetry().flush_events().await;
-
- Ok(())
- })
- }
- }
-
/// Create a snapshot of the current project state including git information and unsaved buffers.
fn project_snapshot(
project: Entity<Project>,
@@ -2832,11 +2865,11 @@ impl Thread {
let buffer_store = project.read(app_cx).buffer_store();
for buffer_handle in buffer_store.read(app_cx).buffers() {
let buffer = buffer_handle.read(app_cx);
- if buffer.is_dirty() {
- if let Some(file) = buffer.file() {
- let path = file.path().to_string_lossy().to_string();
- unsaved_buffers.push(path);
- }
+ if buffer.is_dirty()
+ && let Some(file) = buffer.file()
+ {
+ let path = file.path().to_string_lossy().to_string();
+ unsaved_buffers.push(path);
}
}
})
@@ -3046,50 +3079,6 @@ impl Thread {
&self.project
}
- pub fn auto_capture_telemetry(&mut self, cx: &mut Context<Self>) {
- if !cx.has_flag::<feature_flags::ThreadAutoCaptureFeatureFlag>() {
- return;
- }
-
- let now = Instant::now();
- if let Some(last) = self.last_auto_capture_at {
- if now.duration_since(last).as_secs() < 10 {
- return;
- }
- }
-
- self.last_auto_capture_at = Some(now);
-
- let thread_id = self.id().clone();
- let github_login = self
- .project
- .read(cx)
- .user_store()
- .read(cx)
- .current_user()
- .map(|user| user.github_login.clone());
- let client = self.project.read(cx).client();
- let serialize_task = self.serialize(cx);
-
- cx.background_executor()
- .spawn(async move {
- if let Ok(serialized_thread) = serialize_task.await {
- if let Ok(thread_data) = serde_json::to_value(serialized_thread) {
- telemetry::event!(
- "Agent Thread Auto-Captured",
- thread_id = thread_id.to_string(),
- thread_data = thread_data,
- auto_capture_reason = "tracked_user",
- github_login = github_login
- );
-
- client.telemetry().flush_events().await;
- }
- }
- })
- .detach();
- }
-
pub fn cumulative_token_usage(&self) -> TokenUsage {
self.cumulative_token_usage
}
@@ -3132,13 +3121,13 @@ impl Thread {
.model
.max_token_count_for_mode(self.completion_mode().into());
- if let Some(exceeded_error) = &self.exceeded_window_error {
- if model.model.id() == exceeded_error.model_id {
- return Some(TotalTokenUsage {
- total: exceeded_error.token_count,
- max,
- });
- }
+ if let Some(exceeded_error) = &self.exceeded_window_error
+ && model.model.id() == exceeded_error.model_id
+ {
+ return Some(TotalTokenUsage {
+ total: exceeded_error.token_count,
+ max,
+ });
}
let total = self
@@ -41,6 +41,9 @@ use std::{
};
use util::ResultExt as _;
+pub static ZED_STATELESS: std::sync::LazyLock<bool> =
+ std::sync::LazyLock::new(|| std::env::var("ZED_STATELESS").is_ok_and(|v| !v.is_empty()));
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DataType {
#[serde(rename = "json")]
@@ -71,7 +74,7 @@ impl Column for DataType {
}
}
-const RULES_FILE_NAMES: [&'static str; 9] = [
+const RULES_FILE_NAMES: [&str; 9] = [
".rules",
".cursorrules",
".windsurfrules",
@@ -202,6 +205,22 @@ impl ThreadStore {
(this, ready_rx)
}
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn fake(project: Entity<Project>, cx: &mut App) -> Self {
+ Self {
+ project,
+ tools: cx.new(|_| ToolWorkingSet::default()),
+ prompt_builder: Arc::new(PromptBuilder::new(None).unwrap()),
+ prompt_store: None,
+ context_server_tool_ids: HashMap::default(),
+ threads: Vec::new(),
+ project_context: SharedProjectContext::default(),
+ reload_system_prompt_tx: mpsc::channel(0).0,
+ _reload_system_prompt_task: Task::ready(()),
+ _subscriptions: vec![],
+ }
+ }
+
fn handle_project_event(
&mut self,
_project: Entity<Project>,
@@ -562,33 +581,32 @@ impl ThreadStore {
return;
};
- if protocol.capable(context_server::protocol::ServerCapability::Tools) {
- if let Some(response) = protocol
+ if protocol.capable(context_server::protocol::ServerCapability::Tools)
+ && let Some(response) = protocol
.request::<context_server::types::requests::ListTools>(())
.await
.log_err()
- {
- let tool_ids = tool_working_set
- .update(cx, |tool_working_set, cx| {
- tool_working_set.extend(
- response.tools.into_iter().map(|tool| {
- Arc::new(ContextServerTool::new(
- context_server_store.clone(),
- server.id(),
- tool,
- )) as Arc<dyn Tool>
- }),
- cx,
- )
- })
- .log_err();
-
- if let Some(tool_ids) = tool_ids {
- this.update(cx, |this, _| {
- this.context_server_tool_ids.insert(server_id, tool_ids);
- })
- .log_err();
- }
+ {
+ let tool_ids = tool_working_set
+ .update(cx, |tool_working_set, cx| {
+ tool_working_set.extend(
+ response.tools.into_iter().map(|tool| {
+ Arc::new(ContextServerTool::new(
+ context_server_store.clone(),
+ server.id(),
+ tool,
+ )) as Arc<dyn Tool>
+ }),
+ cx,
+ )
+ })
+ .log_err();
+
+ if let Some(tool_ids) = tool_ids {
+ this.update(cx, |this, _| {
+ this.context_server_tool_ids.insert(server_id, tool_ids);
+ })
+ .log_err();
}
}
})
@@ -678,13 +696,14 @@ impl SerializedThreadV0_1_0 {
let mut messages: Vec<SerializedMessage> = Vec::with_capacity(self.0.messages.len());
for message in self.0.messages {
- if message.role == Role::User && !message.tool_results.is_empty() {
- if let Some(last_message) = messages.last_mut() {
- debug_assert!(last_message.role == Role::Assistant);
-
- last_message.tool_results = message.tool_results;
- continue;
- }
+ if message.role == Role::User
+ && !message.tool_results.is_empty()
+ && let Some(last_message) = messages.last_mut()
+ {
+ debug_assert!(last_message.role == Role::Assistant);
+
+ last_message.tool_results = message.tool_results;
+ continue;
}
messages.push(message);
@@ -874,7 +893,22 @@ impl ThreadsDatabase {
let needs_migration_from_heed = mdb_path.exists();
- let connection = Connection::open_file(&sqlite_path.to_string_lossy());
+ let connection = if *ZED_STATELESS {
+ Connection::open_memory(Some("THREAD_FALLBACK_DB"))
+ } else if cfg!(any(feature = "test-support", test)) {
+ // rust stores the name of the test on the current thread.
+ // We use this to automatically create a database that will
+ // be shared within the test (for the test_retrieve_old_thread)
+ // but not with concurrent tests.
+ let thread = std::thread::current();
+ let test_name = thread.name();
+ Connection::open_memory(Some(&format!(
+ "THREAD_FALLBACK_{}",
+ test_name.unwrap_or_default()
+ )))
+ } else {
+ Connection::open_file(&sqlite_path.to_string_lossy())
+ };
connection.exec(indoc! {"
CREATE TABLE IF NOT EXISTS threads (
@@ -112,19 +112,13 @@ impl ToolUseState {
},
);
- if let Some(window) = &mut window {
- if let Some(tool) = this.tools.read(cx).tool(tool_use, cx) {
- if let Some(output) = tool_result.output.clone() {
- if let Some(card) = tool.deserialize_card(
- output,
- project.clone(),
- window,
- cx,
- ) {
- this.tool_result_cards.insert(tool_use_id, card);
- }
- }
- }
+ if let Some(window) = &mut window
+ && let Some(tool) = this.tools.read(cx).tool(tool_use, cx)
+ && let Some(output) = tool_result.output.clone()
+ && let Some(card) =
+ tool.deserialize_card(output, project.clone(), window, cx)
+ {
+ this.tool_result_cards.insert(tool_use_id, card);
}
}
}
@@ -137,7 +131,7 @@ impl ToolUseState {
}
pub fn cancel_pending(&mut self) -> Vec<PendingToolUse> {
- let mut cancelled_tool_uses = Vec::new();
+ let mut canceled_tool_uses = Vec::new();
self.pending_tool_uses_by_id
.retain(|tool_use_id, tool_use| {
if matches!(tool_use.status, PendingToolUseStatus::Error { .. }) {
@@ -155,17 +149,22 @@ impl ToolUseState {
is_error: true,
},
);
- cancelled_tool_uses.push(tool_use.clone());
+ canceled_tool_uses.push(tool_use.clone());
false
});
- cancelled_tool_uses
+ canceled_tool_uses
}
pub fn pending_tool_uses(&self) -> Vec<&PendingToolUse> {
self.pending_tool_uses_by_id.values().collect()
}
- pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec<ToolUse> {
+ pub fn tool_uses_for_message(
+ &self,
+ id: MessageId,
+ project: &Entity<Project>,
+ cx: &App,
+ ) -> Vec<ToolUse> {
let Some(tool_uses_for_message) = &self.tool_uses_by_assistant_message.get(&id) else {
return Vec::new();
};
@@ -211,7 +210,10 @@ impl ToolUseState {
let (icon, needs_confirmation) =
if let Some(tool) = self.tools.read(cx).tool(&tool_use.name, cx) {
- (tool.icon(), tool.needs_confirmation(&tool_use.input, cx))
+ (
+ tool.icon(),
+ tool.needs_confirmation(&tool_use.input, project, cx),
+ )
} else {
(IconName::Cog, false)
};
@@ -273,7 +275,7 @@ impl ToolUseState {
pub fn message_has_tool_results(&self, assistant_message_id: MessageId) -> bool {
self.tool_uses_by_assistant_message
.get(&assistant_message_id)
- .map_or(false, |results| !results.is_empty())
+ .is_some_and(|results| !results.is_empty())
}
pub fn tool_result(
@@ -0,0 +1,103 @@
+[package]
+name = "agent2"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lib]
+path = "src/agent2.rs"
+
+[features]
+test-support = ["db/test-support"]
+e2e = []
+
+[lints]
+workspace = true
+
+[dependencies]
+acp_thread.workspace = true
+action_log.workspace = true
+agent.workspace = true
+agent-client-protocol.workspace = true
+agent_servers.workspace = true
+agent_settings.workspace = true
+anyhow.workspace = true
+assistant_context.workspace = true
+assistant_tool.workspace = true
+assistant_tools.workspace = true
+chrono.workspace = true
+client.workspace = true
+cloud_llm_client.workspace = true
+collections.workspace = true
+context_server.workspace = true
+db.workspace = true
+fs.workspace = true
+futures.workspace = true
+git.workspace = true
+gpui.workspace = true
+handlebars = { workspace = true, features = ["rust-embed"] }
+html_to_markdown.workspace = true
+http_client.workspace = true
+indoc.workspace = true
+itertools.workspace = true
+language.workspace = true
+language_model.workspace = true
+language_models.workspace = true
+log.workspace = true
+open.workspace = true
+parking_lot.workspace = true
+paths.workspace = true
+portable-pty.workspace = true
+project.workspace = true
+prompt_store.workspace = true
+rust-embed.workspace = true
+schemars.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+settings.workspace = true
+smol.workspace = true
+sqlez.workspace = true
+task.workspace = true
+telemetry.workspace = true
+terminal.workspace = true
+thiserror.workspace = true
+text.workspace = true
+ui.workspace = true
+util.workspace = true
+uuid.workspace = true
+watch.workspace = true
+web_search.workspace = true
+which.workspace = true
+workspace-hack.workspace = true
+zstd.workspace = true
+
+[dev-dependencies]
+agent = { workspace = true, "features" = ["test-support"] }
+agent_servers = { workspace = true, "features" = ["test-support"] }
+assistant_context = { workspace = true, "features" = ["test-support"] }
+ctor.workspace = true
+client = { workspace = true, "features" = ["test-support"] }
+clock = { workspace = true, "features" = ["test-support"] }
+context_server = { workspace = true, "features" = ["test-support"] }
+db = { workspace = true, "features" = ["test-support"] }
+editor = { workspace = true, "features" = ["test-support"] }
+env_logger.workspace = true
+fs = { workspace = true, "features" = ["test-support"] }
+git = { workspace = true, "features" = ["test-support"] }
+gpui = { workspace = true, "features" = ["test-support"] }
+gpui_tokio.workspace = true
+language = { workspace = true, "features" = ["test-support"] }
+language_model = { workspace = true, "features" = ["test-support"] }
+lsp = { workspace = true, "features" = ["test-support"] }
+pretty_assertions.workspace = true
+project = { workspace = true, "features" = ["test-support"] }
+reqwest_client.workspace = true
+settings = { workspace = true, "features" = ["test-support"] }
+tempfile.workspace = true
+terminal = { workspace = true, "features" = ["test-support"] }
+theme = { workspace = true, "features" = ["test-support"] }
+tree-sitter-rust.workspace = true
+unindent = { workspace = true }
+worktree = { workspace = true, "features" = ["test-support"] }
+zlog.workspace = true
@@ -0,0 +1,1428 @@
+use crate::{
+ ContextServerRegistry, Thread, ThreadEvent, ThreadsDatabase, ToolCallAuthorization,
+ UserMessageContent, templates::Templates,
+};
+use crate::{HistoryStore, TitleUpdated, TokenUsageUpdated};
+use acp_thread::{AcpThread, AgentModelSelector};
+use action_log::ActionLog;
+use agent_client_protocol as acp;
+use agent_settings::AgentSettings;
+use anyhow::{Context as _, Result, anyhow};
+use collections::{HashSet, IndexMap};
+use fs::Fs;
+use futures::channel::mpsc;
+use futures::{StreamExt, future};
+use gpui::{
+ App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
+};
+use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry};
+use project::{Project, ProjectItem, ProjectPath, Worktree};
+use prompt_store::{
+ ProjectContext, PromptId, PromptStore, RulesFileContext, UserRulesContext, WorktreeContext,
+};
+use settings::update_settings_file;
+use std::any::Any;
+use std::collections::HashMap;
+use std::path::Path;
+use std::rc::Rc;
+use std::sync::Arc;
+use util::ResultExt;
+
+const RULES_FILE_NAMES: [&str; 9] = [
+ ".rules",
+ ".cursorrules",
+ ".windsurfrules",
+ ".clinerules",
+ ".github/copilot-instructions.md",
+ "CLAUDE.md",
+ "AGENT.md",
+ "AGENTS.md",
+ "GEMINI.md",
+];
+
+pub struct RulesLoadingError {
+ pub message: SharedString,
+}
+
+/// Holds both the internal Thread and the AcpThread for a session
+struct Session {
+ /// The internal thread that processes messages
+ thread: Entity<Thread>,
+ /// The ACP thread that handles protocol communication
+ acp_thread: WeakEntity<acp_thread::AcpThread>,
+ pending_save: Task<()>,
+ _subscriptions: Vec<Subscription>,
+}
+
+pub struct LanguageModels {
+ /// Access language model by ID
+ models: HashMap<acp_thread::AgentModelId, Arc<dyn LanguageModel>>,
+ /// Cached list for returning language model information
+ model_list: acp_thread::AgentModelList,
+ refresh_models_rx: watch::Receiver<()>,
+ refresh_models_tx: watch::Sender<()>,
+}
+
+impl LanguageModels {
+ fn new(cx: &App) -> Self {
+ let (refresh_models_tx, refresh_models_rx) = watch::channel(());
+ let mut this = Self {
+ models: HashMap::default(),
+ model_list: acp_thread::AgentModelList::Grouped(IndexMap::default()),
+ refresh_models_rx,
+ refresh_models_tx,
+ };
+ this.refresh_list(cx);
+ this
+ }
+
+ fn refresh_list(&mut self, cx: &App) {
+ let providers = LanguageModelRegistry::global(cx)
+ .read(cx)
+ .providers()
+ .into_iter()
+ .filter(|provider| provider.is_authenticated(cx))
+ .collect::<Vec<_>>();
+
+ let mut language_model_list = IndexMap::default();
+ let mut recommended_models = HashSet::default();
+
+ let mut recommended = Vec::new();
+ for provider in &providers {
+ for model in provider.recommended_models(cx) {
+ recommended_models.insert(model.id());
+ recommended.push(Self::map_language_model_to_info(&model, provider));
+ }
+ }
+ if !recommended.is_empty() {
+ language_model_list.insert(
+ acp_thread::AgentModelGroupName("Recommended".into()),
+ recommended,
+ );
+ }
+
+ let mut models = HashMap::default();
+ for provider in providers {
+ let mut provider_models = Vec::new();
+ for model in provider.provided_models(cx) {
+ let model_info = Self::map_language_model_to_info(&model, &provider);
+ let model_id = model_info.id.clone();
+ if !recommended_models.contains(&model.id()) {
+ provider_models.push(model_info);
+ }
+ models.insert(model_id, model);
+ }
+ if !provider_models.is_empty() {
+ language_model_list.insert(
+ acp_thread::AgentModelGroupName(provider.name().0.clone()),
+ provider_models,
+ );
+ }
+ }
+
+ self.models = models;
+ self.model_list = acp_thread::AgentModelList::Grouped(language_model_list);
+ self.refresh_models_tx.send(()).ok();
+ }
+
+ fn watch(&self) -> watch::Receiver<()> {
+ self.refresh_models_rx.clone()
+ }
+
+ pub fn model_from_id(
+ &self,
+ model_id: &acp_thread::AgentModelId,
+ ) -> Option<Arc<dyn LanguageModel>> {
+ self.models.get(model_id).cloned()
+ }
+
+ fn map_language_model_to_info(
+ model: &Arc<dyn LanguageModel>,
+ provider: &Arc<dyn LanguageModelProvider>,
+ ) -> acp_thread::AgentModelInfo {
+ acp_thread::AgentModelInfo {
+ id: Self::model_id(model),
+ name: model.name().0,
+ icon: Some(provider.icon()),
+ }
+ }
+
+ fn model_id(model: &Arc<dyn LanguageModel>) -> acp_thread::AgentModelId {
+ acp_thread::AgentModelId(format!("{}/{}", model.provider_id().0, model.id().0).into())
+ }
+}
+
+pub struct NativeAgent {
+ /// Session ID -> Session mapping
+ sessions: HashMap<acp::SessionId, Session>,
+ history: Entity<HistoryStore>,
+ /// Shared project context for all threads
+ project_context: Entity<ProjectContext>,
+ project_context_needs_refresh: watch::Sender<()>,
+ _maintain_project_context: Task<Result<()>>,
+ context_server_registry: Entity<ContextServerRegistry>,
+ /// Shared templates for all threads
+ templates: Arc<Templates>,
+ /// Cached model information
+ models: LanguageModels,
+ project: Entity<Project>,
+ prompt_store: Option<Entity<PromptStore>>,
+ fs: Arc<dyn Fs>,
+ _subscriptions: Vec<Subscription>,
+}
+
+impl NativeAgent {
+ pub async fn new(
+ project: Entity<Project>,
+ history: Entity<HistoryStore>,
+ templates: Arc<Templates>,
+ prompt_store: Option<Entity<PromptStore>>,
+ fs: Arc<dyn Fs>,
+ cx: &mut AsyncApp,
+ ) -> Result<Entity<NativeAgent>> {
+ log::debug!("Creating new NativeAgent");
+
+ let project_context = cx
+ .update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx))?
+ .await;
+
+ cx.new(|cx| {
+ let mut subscriptions = vec![
+ cx.subscribe(&project, Self::handle_project_event),
+ cx.subscribe(
+ &LanguageModelRegistry::global(cx),
+ Self::handle_models_updated_event,
+ ),
+ ];
+ if let Some(prompt_store) = prompt_store.as_ref() {
+ subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event))
+ }
+
+ let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) =
+ watch::channel(());
+ Self {
+ sessions: HashMap::new(),
+ history,
+ project_context: cx.new(|_| project_context),
+ project_context_needs_refresh: project_context_needs_refresh_tx,
+ _maintain_project_context: cx.spawn(async move |this, cx| {
+ Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await
+ }),
+ context_server_registry: cx.new(|cx| {
+ ContextServerRegistry::new(project.read(cx).context_server_store(), cx)
+ }),
+ templates,
+ models: LanguageModels::new(cx),
+ project,
+ prompt_store,
+ fs,
+ _subscriptions: subscriptions,
+ }
+ })
+ }
+
+ fn register_session(
+ &mut self,
+ thread_handle: Entity<Thread>,
+ cx: &mut Context<Self>,
+ ) -> Entity<AcpThread> {
+ let connection = Rc::new(NativeAgentConnection(cx.entity()));
+ let registry = LanguageModelRegistry::read_global(cx);
+ let summarization_model = registry.thread_summary_model(cx).map(|c| c.model);
+
+ thread_handle.update(cx, |thread, cx| {
+ thread.set_summarization_model(summarization_model, cx);
+ thread.add_default_tools(cx)
+ });
+
+ let thread = thread_handle.read(cx);
+ let session_id = thread.id().clone();
+ let title = thread.title();
+ let project = thread.project.clone();
+ let action_log = thread.action_log.clone();
+ let acp_thread = cx.new(|_cx| {
+ acp_thread::AcpThread::new(
+ title,
+ connection,
+ project.clone(),
+ action_log.clone(),
+ session_id.clone(),
+ )
+ });
+ let subscriptions = vec![
+ cx.observe_release(&acp_thread, |this, acp_thread, _cx| {
+ this.sessions.remove(acp_thread.session_id());
+ }),
+ cx.subscribe(&thread_handle, Self::handle_thread_title_updated),
+ cx.subscribe(&thread_handle, Self::handle_thread_token_usage_updated),
+ cx.observe(&thread_handle, move |this, thread, cx| {
+ this.save_thread(thread, cx)
+ }),
+ ];
+
+ self.sessions.insert(
+ session_id,
+ Session {
+ thread: thread_handle,
+ acp_thread: acp_thread.downgrade(),
+ _subscriptions: subscriptions,
+ pending_save: Task::ready(()),
+ },
+ );
+ acp_thread
+ }
+
+ pub fn models(&self) -> &LanguageModels {
+ &self.models
+ }
+
+ async fn maintain_project_context(
+ this: WeakEntity<Self>,
+ mut needs_refresh: watch::Receiver<()>,
+ cx: &mut AsyncApp,
+ ) -> Result<()> {
+ while needs_refresh.changed().await.is_ok() {
+ let project_context = this
+ .update(cx, |this, cx| {
+ Self::build_project_context(&this.project, this.prompt_store.as_ref(), cx)
+ })?
+ .await;
+ this.update(cx, |this, cx| {
+ this.project_context = cx.new(|_| project_context);
+ })?;
+ }
+
+ Ok(())
+ }
+
+ fn build_project_context(
+ project: &Entity<Project>,
+ prompt_store: Option<&Entity<PromptStore>>,
+ cx: &mut App,
+ ) -> Task<ProjectContext> {
+ let worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
+ let worktree_tasks = worktrees
+ .into_iter()
+ .map(|worktree| {
+ Self::load_worktree_info_for_system_prompt(worktree, project.clone(), cx)
+ })
+ .collect::<Vec<_>>();
+ let default_user_rules_task = if let Some(prompt_store) = prompt_store.as_ref() {
+ prompt_store.read_with(cx, |prompt_store, cx| {
+ let prompts = prompt_store.default_prompt_metadata();
+ let load_tasks = prompts.into_iter().map(|prompt_metadata| {
+ let contents = prompt_store.load(prompt_metadata.id, cx);
+ async move { (contents.await, prompt_metadata) }
+ });
+ cx.background_spawn(future::join_all(load_tasks))
+ })
+ } else {
+ Task::ready(vec![])
+ };
+
+ cx.spawn(async move |_cx| {
+ let (worktrees, default_user_rules) =
+ future::join(future::join_all(worktree_tasks), default_user_rules_task).await;
+
+ let worktrees = worktrees
+ .into_iter()
+ .map(|(worktree, _rules_error)| {
+ // TODO: show error message
+ // if let Some(rules_error) = rules_error {
+ // this.update(cx, |_, cx| cx.emit(rules_error)).ok();
+ // }
+ worktree
+ })
+ .collect::<Vec<_>>();
+
+ let default_user_rules = default_user_rules
+ .into_iter()
+ .flat_map(|(contents, prompt_metadata)| match contents {
+ Ok(contents) => Some(UserRulesContext {
+ uuid: match prompt_metadata.id {
+ PromptId::User { uuid } => uuid,
+ PromptId::EditWorkflow => return None,
+ },
+ title: prompt_metadata.title.map(|title| title.to_string()),
+ contents,
+ }),
+ Err(_err) => {
+ // TODO: show error message
+ // this.update(cx, |_, cx| {
+ // cx.emit(RulesLoadingError {
+ // message: format!("{err:?}").into(),
+ // });
+ // })
+ // .ok();
+ None
+ }
+ })
+ .collect::<Vec<_>>();
+
+ ProjectContext::new(worktrees, default_user_rules)
+ })
+ }
+
+ fn load_worktree_info_for_system_prompt(
+ worktree: Entity<Worktree>,
+ project: Entity<Project>,
+ cx: &mut App,
+ ) -> Task<(WorktreeContext, Option<RulesLoadingError>)> {
+ let tree = worktree.read(cx);
+ let root_name = tree.root_name().into();
+ let abs_path = tree.abs_path();
+
+ let mut context = WorktreeContext {
+ root_name,
+ abs_path,
+ rules_file: None,
+ };
+
+ let rules_task = Self::load_worktree_rules_file(worktree, project, cx);
+ let Some(rules_task) = rules_task else {
+ return Task::ready((context, None));
+ };
+
+ cx.spawn(async move |_| {
+ let (rules_file, rules_file_error) = match rules_task.await {
+ Ok(rules_file) => (Some(rules_file), None),
+ Err(err) => (
+ None,
+ Some(RulesLoadingError {
+ message: format!("{err}").into(),
+ }),
+ ),
+ };
+ context.rules_file = rules_file;
+ (context, rules_file_error)
+ })
+ }
+
+ fn load_worktree_rules_file(
+ worktree: Entity<Worktree>,
+ project: Entity<Project>,
+ cx: &mut App,
+ ) -> Option<Task<Result<RulesFileContext>>> {
+ let worktree = worktree.read(cx);
+ let worktree_id = worktree.id();
+ let selected_rules_file = RULES_FILE_NAMES
+ .into_iter()
+ .filter_map(|name| {
+ worktree
+ .entry_for_path(name)
+ .filter(|entry| entry.is_file())
+ .map(|entry| entry.path.clone())
+ })
+ .next();
+
+ // Note that Cline supports `.clinerules` being a directory, but that is not currently
+ // supported. This doesn't seem to occur often in GitHub repositories.
+ selected_rules_file.map(|path_in_worktree| {
+ let project_path = ProjectPath {
+ worktree_id,
+ path: path_in_worktree.clone(),
+ };
+ let buffer_task =
+ project.update(cx, |project, cx| project.open_buffer(project_path, cx));
+ let rope_task = cx.spawn(async move |cx| {
+ buffer_task.await?.read_with(cx, |buffer, cx| {
+ let project_entry_id = buffer.entry_id(cx).context("buffer has no file")?;
+ anyhow::Ok((project_entry_id, buffer.as_rope().clone()))
+ })?
+ });
+ // Build a string from the rope on a background thread.
+ cx.background_spawn(async move {
+ let (project_entry_id, rope) = rope_task.await?;
+ anyhow::Ok(RulesFileContext {
+ path_in_worktree,
+ text: rope.to_string().trim().to_string(),
+ project_entry_id: project_entry_id.to_usize(),
+ })
+ })
+ })
+ }
+
+ fn handle_thread_title_updated(
+ &mut self,
+ thread: Entity<Thread>,
+ _: &TitleUpdated,
+ cx: &mut Context<Self>,
+ ) {
+ let session_id = thread.read(cx).id();
+ let Some(session) = self.sessions.get(session_id) else {
+ return;
+ };
+ let thread = thread.downgrade();
+ let acp_thread = session.acp_thread.clone();
+ cx.spawn(async move |_, cx| {
+ let title = thread.read_with(cx, |thread, _| thread.title())?;
+ let task = acp_thread.update(cx, |acp_thread, cx| acp_thread.set_title(title, cx))?;
+ task.await
+ })
+ .detach_and_log_err(cx);
+ }
+
+ fn handle_thread_token_usage_updated(
+ &mut self,
+ thread: Entity<Thread>,
+ usage: &TokenUsageUpdated,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(session) = self.sessions.get(thread.read(cx).id()) else {
+ return;
+ };
+ session
+ .acp_thread
+ .update(cx, |acp_thread, cx| {
+ acp_thread.update_token_usage(usage.0.clone(), cx);
+ })
+ .ok();
+ }
+
+ fn handle_project_event(
+ &mut self,
+ _project: Entity<Project>,
+ event: &project::Event,
+ _cx: &mut Context<Self>,
+ ) {
+ match event {
+ project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => {
+ self.project_context_needs_refresh.send(()).ok();
+ }
+ project::Event::WorktreeUpdatedEntries(_, items) => {
+ if items.iter().any(|(path, _, _)| {
+ RULES_FILE_NAMES
+ .iter()
+ .any(|name| path.as_ref() == Path::new(name))
+ }) {
+ self.project_context_needs_refresh.send(()).ok();
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn handle_prompts_updated_event(
+ &mut self,
+ _prompt_store: Entity<PromptStore>,
+ _event: &prompt_store::PromptsUpdatedEvent,
+ _cx: &mut Context<Self>,
+ ) {
+ self.project_context_needs_refresh.send(()).ok();
+ }
+
+ fn handle_models_updated_event(
+ &mut self,
+ _registry: Entity<LanguageModelRegistry>,
+ _event: &language_model::Event,
+ cx: &mut Context<Self>,
+ ) {
+ self.models.refresh_list(cx);
+
+ let registry = LanguageModelRegistry::read_global(cx);
+ let default_model = registry.default_model().map(|m| m.model);
+ let summarization_model = registry.thread_summary_model(cx).map(|m| m.model);
+
+ for session in self.sessions.values_mut() {
+ session.thread.update(cx, |thread, cx| {
+ if thread.model().is_none()
+ && let Some(model) = default_model.clone()
+ {
+ thread.set_model(model, cx);
+ cx.notify();
+ }
+ thread.set_summarization_model(summarization_model.clone(), cx);
+ });
+ }
+ }
+
+ pub fn open_thread(
+ &mut self,
+ id: acp::SessionId,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<Entity<AcpThread>>> {
+ let database_future = ThreadsDatabase::connect(cx);
+ cx.spawn(async move |this, cx| {
+ let database = database_future.await.map_err(|err| anyhow!(err))?;
+ let db_thread = database
+ .load_thread(id.clone())
+ .await?
+ .with_context(|| format!("no thread found with ID: {id:?}"))?;
+
+ let thread = this.update(cx, |this, cx| {
+ let action_log = cx.new(|_cx| ActionLog::new(this.project.clone()));
+ cx.new(|cx| {
+ Thread::from_db(
+ id.clone(),
+ db_thread,
+ this.project.clone(),
+ this.project_context.clone(),
+ this.context_server_registry.clone(),
+ action_log.clone(),
+ this.templates.clone(),
+ cx,
+ )
+ })
+ })?;
+ let acp_thread =
+ this.update(cx, |this, cx| this.register_session(thread.clone(), cx))?;
+ let events = thread.update(cx, |thread, cx| thread.replay(cx))?;
+ cx.update(|cx| {
+ NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx)
+ })?
+ .await?;
+ Ok(acp_thread)
+ })
+ }
+
+ pub fn thread_summary(
+ &mut self,
+ id: acp::SessionId,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<SharedString>> {
+ let thread = self.open_thread(id.clone(), cx);
+ cx.spawn(async move |this, cx| {
+ let acp_thread = thread.await?;
+ let result = this
+ .update(cx, |this, cx| {
+ this.sessions
+ .get(&id)
+ .unwrap()
+ .thread
+ .update(cx, |thread, cx| thread.summary(cx))
+ })?
+ .await?;
+ drop(acp_thread);
+ Ok(result)
+ })
+ }
+
+ fn save_thread(&mut self, thread: Entity<Thread>, cx: &mut Context<Self>) {
+ if thread.read(cx).is_empty() {
+ return;
+ }
+
+ let database_future = ThreadsDatabase::connect(cx);
+ let (id, db_thread) =
+ thread.update(cx, |thread, cx| (thread.id().clone(), thread.to_db(cx)));
+ let Some(session) = self.sessions.get_mut(&id) else {
+ return;
+ };
+ let history = self.history.clone();
+ session.pending_save = cx.spawn(async move |_, cx| {
+ let Some(database) = database_future.await.map_err(|err| anyhow!(err)).log_err() else {
+ return;
+ };
+ let db_thread = db_thread.await;
+ database.save_thread(id, db_thread).await.log_err();
+ history.update(cx, |history, cx| history.reload(cx)).ok();
+ });
+ }
+}
+
+/// Wrapper struct that implements the AgentConnection trait
+#[derive(Clone)]
+pub struct NativeAgentConnection(pub Entity<NativeAgent>);
+
+impl NativeAgentConnection {
+ pub fn thread(&self, session_id: &acp::SessionId, cx: &App) -> Option<Entity<Thread>> {
+ self.0
+ .read(cx)
+ .sessions
+ .get(session_id)
+ .map(|session| session.thread.clone())
+ }
+
+ fn run_turn(
+ &self,
+ session_id: acp::SessionId,
+ cx: &mut App,
+ f: impl 'static
+ + FnOnce(Entity<Thread>, &mut App) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>>,
+ ) -> Task<Result<acp::PromptResponse>> {
+ let Some((thread, acp_thread)) = self.0.update(cx, |agent, _cx| {
+ agent
+ .sessions
+ .get_mut(&session_id)
+ .map(|s| (s.thread.clone(), s.acp_thread.clone()))
+ }) else {
+ return Task::ready(Err(anyhow!("Session not found")));
+ };
+ log::debug!("Found session for: {}", session_id);
+
+ let response_stream = match f(thread, cx) {
+ Ok(stream) => stream,
+ Err(err) => return Task::ready(Err(err)),
+ };
+ Self::handle_thread_events(response_stream, acp_thread, cx)
+ }
+
+ fn handle_thread_events(
+ mut events: mpsc::UnboundedReceiver<Result<ThreadEvent>>,
+ acp_thread: WeakEntity<AcpThread>,
+ cx: &App,
+ ) -> Task<Result<acp::PromptResponse>> {
+ cx.spawn(async move |cx| {
+ // Handle response stream and forward to session.acp_thread
+ while let Some(result) = events.next().await {
+ match result {
+ Ok(event) => {
+ log::trace!("Received completion event: {:?}", event);
+
+ match event {
+ ThreadEvent::UserMessage(message) => {
+ acp_thread.update(cx, |thread, cx| {
+ for content in message.content {
+ thread.push_user_content_block(
+ Some(message.id.clone()),
+ content.into(),
+ cx,
+ );
+ }
+ })?;
+ }
+ ThreadEvent::AgentText(text) => {
+ acp_thread.update(cx, |thread, cx| {
+ thread.push_assistant_content_block(
+ acp::ContentBlock::Text(acp::TextContent {
+ text,
+ annotations: None,
+ }),
+ false,
+ cx,
+ )
+ })?;
+ }
+ ThreadEvent::AgentThinking(text) => {
+ acp_thread.update(cx, |thread, cx| {
+ thread.push_assistant_content_block(
+ acp::ContentBlock::Text(acp::TextContent {
+ text,
+ annotations: None,
+ }),
+ true,
+ cx,
+ )
+ })?;
+ }
+ ThreadEvent::ToolCallAuthorization(ToolCallAuthorization {
+ tool_call,
+ options,
+ response,
+ }) => {
+ let recv = acp_thread.update(cx, |thread, cx| {
+ thread.request_tool_call_authorization(tool_call, options, cx)
+ })?;
+ cx.background_spawn(async move {
+ if let Some(recv) = recv.log_err()
+ && let Some(option) = recv
+ .await
+ .context("authorization sender was dropped")
+ .log_err()
+ {
+ response
+ .send(option)
+ .map(|_| anyhow!("authorization receiver was dropped"))
+ .log_err();
+ }
+ })
+ .detach();
+ }
+ ThreadEvent::ToolCall(tool_call) => {
+ acp_thread.update(cx, |thread, cx| {
+ thread.upsert_tool_call(tool_call, cx)
+ })??;
+ }
+ ThreadEvent::ToolCallUpdate(update) => {
+ acp_thread.update(cx, |thread, cx| {
+ thread.update_tool_call(update, cx)
+ })??;
+ }
+ ThreadEvent::Retry(status) => {
+ acp_thread.update(cx, |thread, cx| {
+ thread.update_retry_status(status, cx)
+ })?;
+ }
+ ThreadEvent::Stop(stop_reason) => {
+ log::debug!("Assistant message complete: {:?}", stop_reason);
+ return Ok(acp::PromptResponse { stop_reason });
+ }
+ }
+ }
+ Err(e) => {
+ log::error!("Error in model response stream: {:?}", e);
+ return Err(e);
+ }
+ }
+ }
+
+ log::debug!("Response stream completed");
+ anyhow::Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::EndTurn,
+ })
+ })
+ }
+}
+
+impl AgentModelSelector for NativeAgentConnection {
+ fn list_models(&self, cx: &mut App) -> Task<Result<acp_thread::AgentModelList>> {
+ log::debug!("NativeAgentConnection::list_models called");
+ let list = self.0.read(cx).models.model_list.clone();
+ Task::ready(if list.is_empty() {
+ Err(anyhow::anyhow!("No models available"))
+ } else {
+ Ok(list)
+ })
+ }
+
+ fn select_model(
+ &self,
+ session_id: acp::SessionId,
+ model_id: acp_thread::AgentModelId,
+ cx: &mut App,
+ ) -> Task<Result<()>> {
+ log::debug!("Setting model for session {}: {}", session_id, model_id);
+ let Some(thread) = self
+ .0
+ .read(cx)
+ .sessions
+ .get(&session_id)
+ .map(|session| session.thread.clone())
+ else {
+ return Task::ready(Err(anyhow!("Session not found")));
+ };
+
+ let Some(model) = self.0.read(cx).models.model_from_id(&model_id) else {
+ return Task::ready(Err(anyhow!("Invalid model ID {}", model_id)));
+ };
+
+ thread.update(cx, |thread, cx| {
+ thread.set_model(model.clone(), cx);
+ });
+
+ update_settings_file::<AgentSettings>(
+ self.0.read(cx).fs.clone(),
+ cx,
+ move |settings, _cx| {
+ settings.set_model(model);
+ },
+ );
+
+ Task::ready(Ok(()))
+ }
+
+ fn selected_model(
+ &self,
+ session_id: &acp::SessionId,
+ cx: &mut App,
+ ) -> Task<Result<acp_thread::AgentModelInfo>> {
+ let session_id = session_id.clone();
+
+ let Some(thread) = self
+ .0
+ .read(cx)
+ .sessions
+ .get(&session_id)
+ .map(|session| session.thread.clone())
+ else {
+ return Task::ready(Err(anyhow!("Session not found")));
+ };
+ let Some(model) = thread.read(cx).model() else {
+ return Task::ready(Err(anyhow!("Model not found")));
+ };
+ let Some(provider) = LanguageModelRegistry::read_global(cx).provider(&model.provider_id())
+ else {
+ return Task::ready(Err(anyhow!("Provider not found")));
+ };
+ Task::ready(Ok(LanguageModels::map_language_model_to_info(
+ model, &provider,
+ )))
+ }
+
+ fn watch(&self, cx: &mut App) -> watch::Receiver<()> {
+ self.0.read(cx).models.watch()
+ }
+}
+
+impl acp_thread::AgentConnection for NativeAgentConnection {
+ fn new_thread(
+ self: Rc<Self>,
+ project: Entity<Project>,
+ cwd: &Path,
+ cx: &mut App,
+ ) -> Task<Result<Entity<acp_thread::AcpThread>>> {
+ let agent = self.0.clone();
+ log::debug!("Creating new thread for project at: {:?}", cwd);
+
+ cx.spawn(async move |cx| {
+ log::debug!("Starting thread creation in async context");
+
+ // Create Thread
+ let thread = agent.update(
+ cx,
+ |agent, cx: &mut gpui::Context<NativeAgent>| -> Result<_> {
+ // Fetch default model from registry settings
+ let registry = LanguageModelRegistry::read_global(cx);
+ // Log available models for debugging
+ let available_count = registry.available_models(cx).count();
+ log::debug!("Total available models: {}", available_count);
+
+ let default_model = registry.default_model().and_then(|default_model| {
+ agent
+ .models
+ .model_from_id(&LanguageModels::model_id(&default_model.model))
+ });
+ Ok(cx.new(|cx| {
+ Thread::new(
+ project.clone(),
+ agent.project_context.clone(),
+ agent.context_server_registry.clone(),
+ agent.templates.clone(),
+ default_model,
+ cx,
+ )
+ }))
+ },
+ )??;
+ agent.update(cx, |agent, cx| agent.register_session(thread, cx))
+ })
+ }
+
+ fn auth_methods(&self) -> &[acp::AuthMethod] {
+ &[] // No auth for in-process
+ }
+
+ fn authenticate(&self, _method: acp::AuthMethodId, _cx: &mut App) -> Task<Result<()>> {
+ Task::ready(Ok(()))
+ }
+
+ fn model_selector(&self) -> Option<Rc<dyn AgentModelSelector>> {
+ Some(Rc::new(self.clone()) as Rc<dyn AgentModelSelector>)
+ }
+
+ fn prompt(
+ &self,
+ id: Option<acp_thread::UserMessageId>,
+ params: acp::PromptRequest,
+ cx: &mut App,
+ ) -> Task<Result<acp::PromptResponse>> {
+ let id = id.expect("UserMessageId is required");
+ let session_id = params.session_id.clone();
+ log::info!("Received prompt request for session: {}", session_id);
+ log::debug!("Prompt blocks count: {}", params.prompt.len());
+
+ self.run_turn(session_id, cx, |thread, cx| {
+ let content: Vec<UserMessageContent> = params
+ .prompt
+ .into_iter()
+ .map(Into::into)
+ .collect::<Vec<_>>();
+ log::debug!("Converted prompt to message: {} chars", content.len());
+ log::debug!("Message id: {:?}", id);
+ log::debug!("Message content: {:?}", content);
+
+ thread.update(cx, |thread, cx| thread.send(id, content, cx))
+ })
+ }
+
+ fn prompt_capabilities(&self) -> acp::PromptCapabilities {
+ acp::PromptCapabilities {
+ image: true,
+ audio: false,
+ embedded_context: true,
+ }
+ }
+
+ fn resume(
+ &self,
+ session_id: &acp::SessionId,
+ _cx: &App,
+ ) -> Option<Rc<dyn acp_thread::AgentSessionResume>> {
+ Some(Rc::new(NativeAgentSessionResume {
+ connection: self.clone(),
+ session_id: session_id.clone(),
+ }) as _)
+ }
+
+ fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) {
+ log::info!("Cancelling on session: {}", session_id);
+ self.0.update(cx, |agent, cx| {
+ if let Some(agent) = agent.sessions.get(session_id) {
+ agent.thread.update(cx, |thread, cx| thread.cancel(cx));
+ }
+ });
+ }
+
+ fn truncate(
+ &self,
+ session_id: &agent_client_protocol::SessionId,
+ cx: &App,
+ ) -> Option<Rc<dyn acp_thread::AgentSessionTruncate>> {
+ self.0.read_with(cx, |agent, _cx| {
+ agent.sessions.get(session_id).map(|session| {
+ Rc::new(NativeAgentSessionEditor {
+ thread: session.thread.clone(),
+ acp_thread: session.acp_thread.clone(),
+ }) as _
+ })
+ })
+ }
+
+ fn set_title(
+ &self,
+ session_id: &acp::SessionId,
+ _cx: &App,
+ ) -> Option<Rc<dyn acp_thread::AgentSessionSetTitle>> {
+ Some(Rc::new(NativeAgentSessionSetTitle {
+ connection: self.clone(),
+ session_id: session_id.clone(),
+ }) as _)
+ }
+
+ fn telemetry(&self) -> Option<Rc<dyn acp_thread::AgentTelemetry>> {
+ Some(Rc::new(self.clone()) as Rc<dyn acp_thread::AgentTelemetry>)
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ self
+ }
+}
+
+impl acp_thread::AgentTelemetry for NativeAgentConnection {
+ fn agent_name(&self) -> String {
+ "Zed".into()
+ }
+
+ fn thread_data(
+ &self,
+ session_id: &acp::SessionId,
+ cx: &mut App,
+ ) -> Task<Result<serde_json::Value>> {
+ let Some(session) = self.0.read(cx).sessions.get(session_id) else {
+ return Task::ready(Err(anyhow!("Session not found")));
+ };
+
+ let task = session.thread.read(cx).to_db(cx);
+ cx.background_spawn(async move {
+ serde_json::to_value(task.await).context("Failed to serialize thread")
+ })
+ }
+}
+
+struct NativeAgentSessionEditor {
+ thread: Entity<Thread>,
+ acp_thread: WeakEntity<AcpThread>,
+}
+
+impl acp_thread::AgentSessionTruncate for NativeAgentSessionEditor {
+ fn run(&self, message_id: acp_thread::UserMessageId, cx: &mut App) -> Task<Result<()>> {
+ match self.thread.update(cx, |thread, cx| {
+ thread.truncate(message_id.clone(), cx)?;
+ Ok(thread.latest_token_usage())
+ }) {
+ Ok(usage) => {
+ self.acp_thread
+ .update(cx, |thread, cx| {
+ thread.update_token_usage(usage, cx);
+ })
+ .ok();
+ Task::ready(Ok(()))
+ }
+ Err(error) => Task::ready(Err(error)),
+ }
+ }
+}
+
+struct NativeAgentSessionResume {
+ connection: NativeAgentConnection,
+ session_id: acp::SessionId,
+}
+
+impl acp_thread::AgentSessionResume for NativeAgentSessionResume {
+ fn run(&self, cx: &mut App) -> Task<Result<acp::PromptResponse>> {
+ self.connection
+ .run_turn(self.session_id.clone(), cx, |thread, cx| {
+ thread.update(cx, |thread, cx| thread.resume(cx))
+ })
+ }
+}
+
+struct NativeAgentSessionSetTitle {
+ connection: NativeAgentConnection,
+ session_id: acp::SessionId,
+}
+
+impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle {
+ fn run(&self, title: SharedString, cx: &mut App) -> Task<Result<()>> {
+ let Some(session) = self.connection.0.read(cx).sessions.get(&self.session_id) else {
+ return Task::ready(Err(anyhow!("session not found")));
+ };
+ let thread = session.thread.clone();
+ thread.update(cx, |thread, cx| thread.set_title(title, cx));
+ Task::ready(Ok(()))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::HistoryEntryId;
+
+ use super::*;
+ use acp_thread::{
+ AgentConnection, AgentModelGroupName, AgentModelId, AgentModelInfo, MentionUri,
+ };
+ use fs::FakeFs;
+ use gpui::TestAppContext;
+ use indoc::indoc;
+ use language_model::fake_provider::FakeLanguageModel;
+ use serde_json::json;
+ use settings::SettingsStore;
+ use util::path;
+
+ #[gpui::test]
+ async fn test_maintaining_project_context(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/",
+ json!({
+ "a": {}
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [], cx).await;
+ let context_store = cx.new(|cx| assistant_context::ContextStore::fake(project.clone(), cx));
+ let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
+ let agent = NativeAgent::new(
+ project.clone(),
+ history_store,
+ Templates::new(),
+ None,
+ fs.clone(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ agent.read_with(cx, |agent, cx| {
+ assert_eq!(agent.project_context.read(cx).worktrees, vec![])
+ });
+
+ let worktree = project
+ .update(cx, |project, cx| project.create_worktree("/a", true, cx))
+ .await
+ .unwrap();
+ cx.run_until_parked();
+ agent.read_with(cx, |agent, cx| {
+ assert_eq!(
+ agent.project_context.read(cx).worktrees,
+ vec![WorktreeContext {
+ root_name: "a".into(),
+ abs_path: Path::new("/a").into(),
+ rules_file: None
+ }]
+ )
+ });
+
+ // Creating `/a/.rules` updates the project context.
+ fs.insert_file("/a/.rules", Vec::new()).await;
+ cx.run_until_parked();
+ agent.read_with(cx, |agent, cx| {
+ let rules_entry = worktree.read(cx).entry_for_path(".rules").unwrap();
+ assert_eq!(
+ agent.project_context.read(cx).worktrees,
+ vec![WorktreeContext {
+ root_name: "a".into(),
+ abs_path: Path::new("/a").into(),
+ rules_file: Some(RulesFileContext {
+ path_in_worktree: Path::new(".rules").into(),
+ text: "".into(),
+ project_entry_id: rules_entry.id.to_usize()
+ })
+ }]
+ )
+ });
+ }
+
+ #[gpui::test]
+ async fn test_listing_models(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/", json!({ "a": {} })).await;
+ let project = Project::test(fs.clone(), [], cx).await;
+ let context_store = cx.new(|cx| assistant_context::ContextStore::fake(project.clone(), cx));
+ let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
+ let connection = NativeAgentConnection(
+ NativeAgent::new(
+ project.clone(),
+ history_store,
+ Templates::new(),
+ None,
+ fs.clone(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap(),
+ );
+
+ let models = cx.update(|cx| connection.list_models(cx)).await.unwrap();
+
+ let acp_thread::AgentModelList::Grouped(models) = models else {
+ panic!("Unexpected model group");
+ };
+ assert_eq!(
+ models,
+ IndexMap::from_iter([(
+ AgentModelGroupName("Fake".into()),
+ vec![AgentModelInfo {
+ id: AgentModelId("fake/fake".into()),
+ name: "Fake".into(),
+ icon: Some(ui::IconName::ZedAssistant),
+ }]
+ )])
+ );
+ }
+
+ #[gpui::test]
+ async fn test_model_selection_persists_to_settings(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.create_dir(paths::settings_file().parent().unwrap())
+ .await
+ .unwrap();
+ fs.insert_file(
+ paths::settings_file(),
+ json!({
+ "agent": {
+ "default_model": {
+ "provider": "foo",
+ "model": "bar"
+ }
+ }
+ })
+ .to_string()
+ .into_bytes(),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [], cx).await;
+
+ let context_store = cx.new(|cx| assistant_context::ContextStore::fake(project.clone(), cx));
+ let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
+
+ // Create the agent and connection
+ let agent = NativeAgent::new(
+ project.clone(),
+ history_store,
+ Templates::new(),
+ None,
+ fs.clone(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ let connection = NativeAgentConnection(agent.clone());
+
+ // Create a thread/session
+ let acp_thread = cx
+ .update(|cx| {
+ Rc::new(connection.clone()).new_thread(project.clone(), Path::new("/a"), cx)
+ })
+ .await
+ .unwrap();
+
+ let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
+
+ // Select a model
+ let model_id = AgentModelId("fake/fake".into());
+ cx.update(|cx| connection.select_model(session_id.clone(), model_id.clone(), cx))
+ .await
+ .unwrap();
+
+ // Verify the thread has the selected model
+ agent.read_with(cx, |agent, _| {
+ let session = agent.sessions.get(&session_id).unwrap();
+ session.thread.read_with(cx, |thread, _| {
+ assert_eq!(thread.model().unwrap().id().0, "fake");
+ });
+ });
+
+ cx.run_until_parked();
+
+ // Verify settings file was updated
+ let settings_content = fs.load(paths::settings_file()).await.unwrap();
+ let settings_json: serde_json::Value = serde_json::from_str(&settings_content).unwrap();
+
+ // Check that the agent settings contain the selected model
+ assert_eq!(
+ settings_json["agent"]["default_model"]["model"],
+ json!("fake")
+ );
+ assert_eq!(
+ settings_json["agent"]["default_model"]["provider"],
+ json!("fake")
+ );
+ }
+
+ #[gpui::test]
+ #[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows
+ async fn test_save_load_thread(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/",
+ json!({
+ "a": {
+ "b.md": "Lorem"
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
+ let context_store = cx.new(|cx| assistant_context::ContextStore::fake(project.clone(), cx));
+ let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
+ let agent = NativeAgent::new(
+ project.clone(),
+ history_store.clone(),
+ Templates::new(),
+ None,
+ fs.clone(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ let connection = Rc::new(NativeAgentConnection(agent.clone()));
+
+ let acp_thread = cx
+ .update(|cx| {
+ connection
+ .clone()
+ .new_thread(project.clone(), Path::new(""), cx)
+ })
+ .await
+ .unwrap();
+ let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
+ let thread = agent.read_with(cx, |agent, _| {
+ agent.sessions.get(&session_id).unwrap().thread.clone()
+ });
+
+ // Ensure empty threads are not saved, even if they get mutated.
+ let model = Arc::new(FakeLanguageModel::default());
+ let summary_model = Arc::new(FakeLanguageModel::default());
+ thread.update(cx, |thread, cx| {
+ thread.set_model(model.clone(), cx);
+ thread.set_summarization_model(Some(summary_model.clone()), cx);
+ });
+ cx.run_until_parked();
+ assert_eq!(history_entries(&history_store, cx), vec![]);
+
+ let send = acp_thread.update(cx, |thread, cx| {
+ thread.send(
+ vec![
+ "What does ".into(),
+ acp::ContentBlock::ResourceLink(acp::ResourceLink {
+ name: "b.md".into(),
+ uri: MentionUri::File {
+ abs_path: path!("/a/b.md").into(),
+ }
+ .to_uri()
+ .to_string(),
+ annotations: None,
+ description: None,
+ mime_type: None,
+ size: None,
+ title: None,
+ }),
+ " mean?".into(),
+ ],
+ cx,
+ )
+ });
+ let send = cx.foreground_executor().spawn(send);
+ cx.run_until_parked();
+
+ model.send_last_completion_stream_text_chunk("Lorem.");
+ model.end_last_completion_stream();
+ cx.run_until_parked();
+ summary_model.send_last_completion_stream_text_chunk("Explaining /a/b.md");
+ summary_model.end_last_completion_stream();
+
+ send.await.unwrap();
+ acp_thread.read_with(cx, |thread, cx| {
+ assert_eq!(
+ thread.to_markdown(cx),
+ indoc! {"
+ ## User
+
+ What does [@b.md](file:///a/b.md) mean?
+
+ ## Assistant
+
+ Lorem.
+
+ "}
+ )
+ });
+
+ cx.run_until_parked();
+
+ // Drop the ACP thread, which should cause the session to be dropped as well.
+ cx.update(|_| {
+ drop(thread);
+ drop(acp_thread);
+ });
+ agent.read_with(cx, |agent, _| {
+ assert_eq!(agent.sessions.keys().cloned().collect::<Vec<_>>(), []);
+ });
+
+ // Ensure the thread can be reloaded from disk.
+ assert_eq!(
+ history_entries(&history_store, cx),
+ vec![(
+ HistoryEntryId::AcpThread(session_id.clone()),
+ "Explaining /a/b.md".into()
+ )]
+ );
+ let acp_thread = agent
+ .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx))
+ .await
+ .unwrap();
+ acp_thread.read_with(cx, |thread, cx| {
+ assert_eq!(
+ thread.to_markdown(cx),
+ indoc! {"
+ ## User
+
+ What does [@b.md](file:///a/b.md) mean?
+
+ ## Assistant
+
+ Lorem.
+
+ "}
+ )
+ });
+ }
+
+ fn history_entries(
+ history: &Entity<HistoryStore>,
+ cx: &mut TestAppContext,
+ ) -> Vec<(HistoryEntryId, String)> {
+ history.read_with(cx, |history, _| {
+ history
+ .entries()
+ .map(|e| (e.id(), e.title().to_string()))
+ .collect::<Vec<_>>()
+ })
+ }
+
+ fn init_test(cx: &mut TestAppContext) {
+ env_logger::try_init().ok();
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ Project::init_settings(cx);
+ agent_settings::init(cx);
+ language::init(cx);
+ LanguageModelRegistry::test(cx);
+ });
+ }
+}
@@ -0,0 +1,19 @@
+mod agent;
+mod db;
+mod history_store;
+mod native_agent_server;
+mod templates;
+mod thread;
+mod tool_schema;
+mod tools;
+
+#[cfg(test)]
+mod tests;
+
+pub use agent::*;
+pub use db::*;
+pub use history_store::*;
+pub use native_agent_server::NativeAgentServer;
+pub use templates::*;
+pub use thread::*;
+pub use tools::*;
@@ -0,0 +1,499 @@
+use crate::{AgentMessage, AgentMessageContent, UserMessage, UserMessageContent};
+use acp_thread::UserMessageId;
+use agent::{thread::DetailedSummaryState, thread_store};
+use agent_client_protocol as acp;
+use agent_settings::{AgentProfileId, CompletionMode};
+use anyhow::{Result, anyhow};
+use chrono::{DateTime, Utc};
+use collections::{HashMap, IndexMap};
+use futures::{FutureExt, future::Shared};
+use gpui::{BackgroundExecutor, Global, Task};
+use indoc::indoc;
+use parking_lot::Mutex;
+use serde::{Deserialize, Serialize};
+use sqlez::{
+ bindable::{Bind, Column},
+ connection::Connection,
+ statement::Statement,
+};
+use std::sync::Arc;
+use ui::{App, SharedString};
+
+pub type DbMessage = crate::Message;
+pub type DbSummary = DetailedSummaryState;
+pub type DbLanguageModel = thread_store::SerializedLanguageModel;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DbThreadMetadata {
+ pub id: acp::SessionId,
+ #[serde(alias = "summary")]
+ pub title: SharedString,
+ pub updated_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct DbThread {
+ pub title: SharedString,
+ pub messages: Vec<DbMessage>,
+ pub updated_at: DateTime<Utc>,
+ #[serde(default)]
+ pub detailed_summary: Option<SharedString>,
+ #[serde(default)]
+ pub initial_project_snapshot: Option<Arc<agent::thread::ProjectSnapshot>>,
+ #[serde(default)]
+ pub cumulative_token_usage: language_model::TokenUsage,
+ #[serde(default)]
+ pub request_token_usage: HashMap<acp_thread::UserMessageId, language_model::TokenUsage>,
+ #[serde(default)]
+ pub model: Option<DbLanguageModel>,
+ #[serde(default)]
+ pub completion_mode: Option<CompletionMode>,
+ #[serde(default)]
+ pub profile: Option<AgentProfileId>,
+}
+
+impl DbThread {
+ pub const VERSION: &'static str = "0.3.0";
+
+ pub fn from_json(json: &[u8]) -> Result<Self> {
+ let saved_thread_json = serde_json::from_slice::<serde_json::Value>(json)?;
+ match saved_thread_json.get("version") {
+ Some(serde_json::Value::String(version)) => match version.as_str() {
+ Self::VERSION => Ok(serde_json::from_value(saved_thread_json)?),
+ _ => Self::upgrade_from_agent_1(agent::SerializedThread::from_json(json)?),
+ },
+ _ => Self::upgrade_from_agent_1(agent::SerializedThread::from_json(json)?),
+ }
+ }
+
+ fn upgrade_from_agent_1(thread: agent::SerializedThread) -> Result<Self> {
+ let mut messages = Vec::new();
+ let mut request_token_usage = HashMap::default();
+
+ let mut last_user_message_id = None;
+ for (ix, msg) in thread.messages.into_iter().enumerate() {
+ let message = match msg.role {
+ language_model::Role::User => {
+ let mut content = Vec::new();
+
+ // Convert segments to content
+ for segment in msg.segments {
+ match segment {
+ thread_store::SerializedMessageSegment::Text { text } => {
+ content.push(UserMessageContent::Text(text));
+ }
+ thread_store::SerializedMessageSegment::Thinking { text, .. } => {
+ // User messages don't have thinking segments, but handle gracefully
+ content.push(UserMessageContent::Text(text));
+ }
+ thread_store::SerializedMessageSegment::RedactedThinking { .. } => {
+ // User messages don't have redacted thinking, skip.
+ }
+ }
+ }
+
+ // If no content was added, add context as text if available
+ if content.is_empty() && !msg.context.is_empty() {
+ content.push(UserMessageContent::Text(msg.context));
+ }
+
+ let id = UserMessageId::new();
+ last_user_message_id = Some(id.clone());
+
+ crate::Message::User(UserMessage {
+ // MessageId from old format can't be meaningfully converted, so generate a new one
+ id,
+ content,
+ })
+ }
+ language_model::Role::Assistant => {
+ let mut content = Vec::new();
+
+ // Convert segments to content
+ for segment in msg.segments {
+ match segment {
+ thread_store::SerializedMessageSegment::Text { text } => {
+ content.push(AgentMessageContent::Text(text));
+ }
+ thread_store::SerializedMessageSegment::Thinking {
+ text,
+ signature,
+ } => {
+ content.push(AgentMessageContent::Thinking { text, signature });
+ }
+ thread_store::SerializedMessageSegment::RedactedThinking { data } => {
+ content.push(AgentMessageContent::RedactedThinking(data));
+ }
+ }
+ }
+
+ // Convert tool uses
+ let mut tool_names_by_id = HashMap::default();
+ for tool_use in msg.tool_uses {
+ tool_names_by_id.insert(tool_use.id.clone(), tool_use.name.clone());
+ content.push(AgentMessageContent::ToolUse(
+ language_model::LanguageModelToolUse {
+ id: tool_use.id,
+ name: tool_use.name.into(),
+ raw_input: serde_json::to_string(&tool_use.input)
+ .unwrap_or_default(),
+ input: tool_use.input,
+ is_input_complete: true,
+ },
+ ));
+ }
+
+ // Convert tool results
+ let mut tool_results = IndexMap::default();
+ for tool_result in msg.tool_results {
+ let name = tool_names_by_id
+ .remove(&tool_result.tool_use_id)
+ .unwrap_or_else(|| SharedString::from("unknown"));
+ tool_results.insert(
+ tool_result.tool_use_id.clone(),
+ language_model::LanguageModelToolResult {
+ tool_use_id: tool_result.tool_use_id,
+ tool_name: name.into(),
+ is_error: tool_result.is_error,
+ content: tool_result.content,
+ output: tool_result.output,
+ },
+ );
+ }
+
+ if let Some(last_user_message_id) = &last_user_message_id
+ && let Some(token_usage) = thread.request_token_usage.get(ix).copied()
+ {
+ request_token_usage.insert(last_user_message_id.clone(), token_usage);
+ }
+
+ crate::Message::Agent(AgentMessage {
+ content,
+ tool_results,
+ })
+ }
+ language_model::Role::System => {
+ // Skip system messages as they're not supported in the new format
+ continue;
+ }
+ };
+
+ messages.push(message);
+ }
+
+ Ok(Self {
+ title: thread.summary,
+ messages,
+ updated_at: thread.updated_at,
+ detailed_summary: match thread.detailed_summary_state {
+ DetailedSummaryState::NotGenerated | DetailedSummaryState::Generating { .. } => {
+ None
+ }
+ DetailedSummaryState::Generated { text, .. } => Some(text),
+ },
+ initial_project_snapshot: thread.initial_project_snapshot,
+ cumulative_token_usage: thread.cumulative_token_usage,
+ request_token_usage,
+ model: thread.model,
+ completion_mode: thread.completion_mode,
+ profile: thread.profile,
+ })
+ }
+}
+
+pub static ZED_STATELESS: std::sync::LazyLock<bool> =
+ std::sync::LazyLock::new(|| std::env::var("ZED_STATELESS").is_ok_and(|v| !v.is_empty()));
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum DataType {
+ #[serde(rename = "json")]
+ Json,
+ #[serde(rename = "zstd")]
+ Zstd,
+}
+
+impl Bind for DataType {
+ fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+ let value = match self {
+ DataType::Json => "json",
+ DataType::Zstd => "zstd",
+ };
+ value.bind(statement, start_index)
+ }
+}
+
+impl Column for DataType {
+ fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+ let (value, next_index) = String::column(statement, start_index)?;
+ let data_type = match value.as_str() {
+ "json" => DataType::Json,
+ "zstd" => DataType::Zstd,
+ _ => anyhow::bail!("Unknown data type: {}", value),
+ };
+ Ok((data_type, next_index))
+ }
+}
+
+pub(crate) struct ThreadsDatabase {
+ executor: BackgroundExecutor,
+ connection: Arc<Mutex<Connection>>,
+}
+
+struct GlobalThreadsDatabase(Shared<Task<Result<Arc<ThreadsDatabase>, Arc<anyhow::Error>>>>);
+
+impl Global for GlobalThreadsDatabase {}
+
+impl ThreadsDatabase {
+ pub fn connect(cx: &mut App) -> Shared<Task<Result<Arc<ThreadsDatabase>, Arc<anyhow::Error>>>> {
+ if cx.has_global::<GlobalThreadsDatabase>() {
+ return cx.global::<GlobalThreadsDatabase>().0.clone();
+ }
+ let executor = cx.background_executor().clone();
+ let task = executor
+ .spawn({
+ let executor = executor.clone();
+ async move {
+ match ThreadsDatabase::new(executor) {
+ Ok(db) => Ok(Arc::new(db)),
+ Err(err) => Err(Arc::new(err)),
+ }
+ }
+ })
+ .shared();
+
+ cx.set_global(GlobalThreadsDatabase(task.clone()));
+ task
+ }
+
+ pub fn new(executor: BackgroundExecutor) -> Result<Self> {
+ let connection = if *ZED_STATELESS {
+ Connection::open_memory(Some("THREAD_FALLBACK_DB"))
+ } else if cfg!(any(feature = "test-support", test)) {
+ // rust stores the name of the test on the current thread.
+ // We use this to automatically create a database that will
+ // be shared within the test (for the test_retrieve_old_thread)
+ // but not with concurrent tests.
+ let thread = std::thread::current();
+ let test_name = thread.name();
+ Connection::open_memory(Some(&format!(
+ "THREAD_FALLBACK_{}",
+ test_name.unwrap_or_default()
+ )))
+ } else {
+ let threads_dir = paths::data_dir().join("threads");
+ std::fs::create_dir_all(&threads_dir)?;
+ let sqlite_path = threads_dir.join("threads.db");
+ Connection::open_file(&sqlite_path.to_string_lossy())
+ };
+
+ connection.exec(indoc! {"
+ CREATE TABLE IF NOT EXISTS threads (
+ id TEXT PRIMARY KEY,
+ summary TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ data_type TEXT NOT NULL,
+ data BLOB NOT NULL
+ )
+ "})?()
+ .map_err(|e| anyhow!("Failed to create threads table: {}", e))?;
+
+ let db = Self {
+ executor,
+ connection: Arc::new(Mutex::new(connection)),
+ };
+
+ Ok(db)
+ }
+
+ fn save_thread_sync(
+ connection: &Arc<Mutex<Connection>>,
+ id: acp::SessionId,
+ thread: DbThread,
+ ) -> Result<()> {
+ const COMPRESSION_LEVEL: i32 = 3;
+
+ #[derive(Serialize)]
+ struct SerializedThread {
+ #[serde(flatten)]
+ thread: DbThread,
+ version: &'static str,
+ }
+
+ let title = thread.title.to_string();
+ let updated_at = thread.updated_at.to_rfc3339();
+ let json_data = serde_json::to_string(&SerializedThread {
+ thread,
+ version: DbThread::VERSION,
+ })?;
+
+ let connection = connection.lock();
+
+ let compressed = zstd::encode_all(json_data.as_bytes(), COMPRESSION_LEVEL)?;
+ let data_type = DataType::Zstd;
+ let data = compressed;
+
+ let mut insert = connection.exec_bound::<(Arc<str>, String, String, DataType, Vec<u8>)>(indoc! {"
+ INSERT OR REPLACE INTO threads (id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?)
+ "})?;
+
+ insert((id.0, title, updated_at, data_type, data))?;
+
+ Ok(())
+ }
+
+ pub fn list_threads(&self) -> Task<Result<Vec<DbThreadMetadata>>> {
+ let connection = self.connection.clone();
+
+ self.executor.spawn(async move {
+ let connection = connection.lock();
+
+ let mut select =
+ connection.select_bound::<(), (Arc<str>, String, String)>(indoc! {"
+ SELECT id, summary, updated_at FROM threads ORDER BY updated_at DESC
+ "})?;
+
+ let rows = select(())?;
+ let mut threads = Vec::new();
+
+ for (id, summary, updated_at) in rows {
+ threads.push(DbThreadMetadata {
+ id: acp::SessionId(id),
+ title: summary.into(),
+ updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc),
+ });
+ }
+
+ Ok(threads)
+ })
+ }
+
+ pub fn load_thread(&self, id: acp::SessionId) -> Task<Result<Option<DbThread>>> {
+ let connection = self.connection.clone();
+
+ self.executor.spawn(async move {
+ let connection = connection.lock();
+ let mut select = connection.select_bound::<Arc<str>, (DataType, Vec<u8>)>(indoc! {"
+ SELECT data_type, data FROM threads WHERE id = ? LIMIT 1
+ "})?;
+
+ let rows = select(id.0)?;
+ if let Some((data_type, data)) = rows.into_iter().next() {
+ let json_data = match data_type {
+ DataType::Zstd => {
+ let decompressed = zstd::decode_all(&data[..])?;
+ String::from_utf8(decompressed)?
+ }
+ DataType::Json => String::from_utf8(data)?,
+ };
+ let thread = DbThread::from_json(json_data.as_bytes())?;
+ Ok(Some(thread))
+ } else {
+ Ok(None)
+ }
+ })
+ }
+
+ pub fn save_thread(&self, id: acp::SessionId, thread: DbThread) -> Task<Result<()>> {
+ let connection = self.connection.clone();
+
+ self.executor
+ .spawn(async move { Self::save_thread_sync(&connection, id, thread) })
+ }
+
+ pub fn delete_thread(&self, id: acp::SessionId) -> Task<Result<()>> {
+ let connection = self.connection.clone();
+
+ self.executor.spawn(async move {
+ let connection = connection.lock();
+
+ let mut delete = connection.exec_bound::<Arc<str>>(indoc! {"
+ DELETE FROM threads WHERE id = ?
+ "})?;
+
+ delete(id.0)?;
+
+ Ok(())
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+
+ use super::*;
+ use agent::MessageSegment;
+ use agent::context::LoadedContext;
+ use client::Client;
+ use fs::FakeFs;
+ use gpui::AppContext;
+ use gpui::TestAppContext;
+ use http_client::FakeHttpClient;
+ use language_model::Role;
+ use project::Project;
+ use settings::SettingsStore;
+
+ fn init_test(cx: &mut TestAppContext) {
+ env_logger::try_init().ok();
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ Project::init_settings(cx);
+ language::init(cx);
+
+ let http_client = FakeHttpClient::with_404_response();
+ let clock = Arc::new(clock::FakeSystemClock::new());
+ let client = Client::new(clock, http_client, cx);
+ agent::init(cx);
+ agent_settings::init(cx);
+ language_model::init(client, cx);
+ });
+ }
+
+ #[gpui::test]
+ async fn test_retrieving_old_thread(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ let project = Project::test(fs, [], cx).await;
+
+ // Save a thread using the old agent.
+ let thread_store = cx.new(|cx| agent::ThreadStore::fake(project, cx));
+ let thread = thread_store.update(cx, |thread_store, cx| thread_store.create_thread(cx));
+ thread.update(cx, |thread, cx| {
+ thread.insert_message(
+ Role::User,
+ vec![MessageSegment::Text("Hey!".into())],
+ LoadedContext::default(),
+ vec![],
+ false,
+ cx,
+ );
+ thread.insert_message(
+ Role::Assistant,
+ vec![MessageSegment::Text("How're you doing?".into())],
+ LoadedContext::default(),
+ vec![],
+ false,
+ cx,
+ )
+ });
+ thread_store
+ .update(cx, |thread_store, cx| thread_store.save_thread(&thread, cx))
+ .await
+ .unwrap();
+
+ // Open that same thread using the new agent.
+ let db = cx.update(ThreadsDatabase::connect).await.unwrap();
+ let threads = db.list_threads().await.unwrap();
+ assert_eq!(threads.len(), 1);
+ let thread = db
+ .load_thread(threads[0].id.clone())
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(thread.messages[0].to_markdown(), "## User\n\nHey!\n");
+ assert_eq!(
+ thread.messages[1].to_markdown(),
+ "## Assistant\n\nHow're you doing?\n"
+ );
+ }
+}
@@ -0,0 +1,357 @@
+use crate::{DbThreadMetadata, ThreadsDatabase};
+use acp_thread::MentionUri;
+use agent_client_protocol as acp;
+use anyhow::{Context as _, Result, anyhow};
+use assistant_context::{AssistantContext, SavedContextMetadata};
+use chrono::{DateTime, Utc};
+use db::kvp::KEY_VALUE_STORE;
+use gpui::{App, AsyncApp, Entity, SharedString, Task, prelude::*};
+use itertools::Itertools;
+use paths::contexts_dir;
+use serde::{Deserialize, Serialize};
+use std::{collections::VecDeque, path::Path, sync::Arc, time::Duration};
+use ui::ElementId;
+use util::ResultExt as _;
+
+const MAX_RECENTLY_OPENED_ENTRIES: usize = 6;
+const RECENTLY_OPENED_THREADS_KEY: &str = "recent-agent-threads";
+const SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE: Duration = Duration::from_millis(50);
+
+const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread");
+
+#[derive(Clone, Debug)]
+pub enum HistoryEntry {
+ AcpThread(DbThreadMetadata),
+ TextThread(SavedContextMetadata),
+}
+
+impl HistoryEntry {
+ pub fn updated_at(&self) -> DateTime<Utc> {
+ match self {
+ HistoryEntry::AcpThread(thread) => thread.updated_at,
+ HistoryEntry::TextThread(context) => context.mtime.to_utc(),
+ }
+ }
+
+ pub fn id(&self) -> HistoryEntryId {
+ match self {
+ HistoryEntry::AcpThread(thread) => HistoryEntryId::AcpThread(thread.id.clone()),
+ HistoryEntry::TextThread(context) => HistoryEntryId::TextThread(context.path.clone()),
+ }
+ }
+
+ pub fn mention_uri(&self) -> MentionUri {
+ match self {
+ HistoryEntry::AcpThread(thread) => MentionUri::Thread {
+ id: thread.id.clone(),
+ name: thread.title.to_string(),
+ },
+ HistoryEntry::TextThread(context) => MentionUri::TextThread {
+ path: context.path.as_ref().to_owned(),
+ name: context.title.to_string(),
+ },
+ }
+ }
+
+ pub fn title(&self) -> &SharedString {
+ match self {
+ HistoryEntry::AcpThread(thread) if thread.title.is_empty() => DEFAULT_TITLE,
+ HistoryEntry::AcpThread(thread) => &thread.title,
+ HistoryEntry::TextThread(context) => &context.title,
+ }
+ }
+}
+
+/// Generic identifier for a history entry.
+#[derive(Clone, PartialEq, Eq, Debug, Hash)]
+pub enum HistoryEntryId {
+ AcpThread(acp::SessionId),
+ TextThread(Arc<Path>),
+}
+
+impl Into<ElementId> for HistoryEntryId {
+ fn into(self) -> ElementId {
+ match self {
+ HistoryEntryId::AcpThread(session_id) => ElementId::Name(session_id.0.into()),
+ HistoryEntryId::TextThread(path) => ElementId::Path(path),
+ }
+ }
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+enum SerializedRecentOpen {
+ AcpThread(String),
+ TextThread(String),
+}
+
+pub struct HistoryStore {
+ threads: Vec<DbThreadMetadata>,
+ entries: Vec<HistoryEntry>,
+ context_store: Entity<assistant_context::ContextStore>,
+ recently_opened_entries: VecDeque<HistoryEntryId>,
+ _subscriptions: Vec<gpui::Subscription>,
+ _save_recently_opened_entries_task: Task<()>,
+}
+
+impl HistoryStore {
+ pub fn new(
+ context_store: Entity<assistant_context::ContextStore>,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let subscriptions = vec![cx.observe(&context_store, |this, _, cx| this.update_entries(cx))];
+
+ cx.spawn(async move |this, cx| {
+ let entries = Self::load_recently_opened_entries(cx).await;
+ this.update(cx, |this, cx| {
+ if let Some(entries) = entries.log_err() {
+ this.recently_opened_entries = entries;
+ }
+
+ this.reload(cx);
+ })
+ .ok();
+ })
+ .detach();
+
+ Self {
+ context_store,
+ recently_opened_entries: VecDeque::default(),
+ threads: Vec::default(),
+ entries: Vec::default(),
+ _subscriptions: subscriptions,
+ _save_recently_opened_entries_task: Task::ready(()),
+ }
+ }
+
+ pub fn thread_from_session_id(&self, session_id: &acp::SessionId) -> Option<&DbThreadMetadata> {
+ self.threads.iter().find(|thread| &thread.id == session_id)
+ }
+
+ pub fn delete_thread(
+ &mut self,
+ id: acp::SessionId,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ let database_future = ThreadsDatabase::connect(cx);
+ cx.spawn(async move |this, cx| {
+ let database = database_future.await.map_err(|err| anyhow!(err))?;
+ database.delete_thread(id.clone()).await?;
+ this.update(cx, |this, cx| this.reload(cx))
+ })
+ }
+
+ pub fn delete_text_thread(
+ &mut self,
+ path: Arc<Path>,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ self.context_store.update(cx, |context_store, cx| {
+ context_store.delete_local_context(path, cx)
+ })
+ }
+
+ pub fn load_text_thread(
+ &self,
+ path: Arc<Path>,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<Entity<AssistantContext>>> {
+ self.context_store.update(cx, |context_store, cx| {
+ context_store.open_local_context(path, cx)
+ })
+ }
+
+ pub fn reload(&self, cx: &mut Context<Self>) {
+ let database_future = ThreadsDatabase::connect(cx);
+ cx.spawn(async move |this, cx| {
+ let threads = database_future
+ .await
+ .map_err(|err| anyhow!(err))?
+ .list_threads()
+ .await?;
+
+ this.update(cx, |this, cx| {
+ if this.recently_opened_entries.len() < MAX_RECENTLY_OPENED_ENTRIES {
+ for thread in threads
+ .iter()
+ .take(MAX_RECENTLY_OPENED_ENTRIES - this.recently_opened_entries.len())
+ .rev()
+ {
+ this.push_recently_opened_entry(
+ HistoryEntryId::AcpThread(thread.id.clone()),
+ cx,
+ )
+ }
+ }
+ this.threads = threads;
+ this.update_entries(cx);
+ })
+ })
+ .detach_and_log_err(cx);
+ }
+
+ fn update_entries(&mut self, cx: &mut Context<Self>) {
+ #[cfg(debug_assertions)]
+ if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() {
+ return;
+ }
+ let mut history_entries = Vec::new();
+ history_entries.extend(self.threads.iter().cloned().map(HistoryEntry::AcpThread));
+ history_entries.extend(
+ self.context_store
+ .read(cx)
+ .unordered_contexts()
+ .cloned()
+ .map(HistoryEntry::TextThread),
+ );
+
+ history_entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.updated_at()));
+ self.entries = history_entries;
+ cx.notify()
+ }
+
+ pub fn is_empty(&self, _cx: &App) -> bool {
+ self.entries.is_empty()
+ }
+
+ pub fn recently_opened_entries(&self, cx: &App) -> Vec<HistoryEntry> {
+ #[cfg(debug_assertions)]
+ if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() {
+ return Vec::new();
+ }
+
+ let thread_entries = self.threads.iter().flat_map(|thread| {
+ self.recently_opened_entries
+ .iter()
+ .enumerate()
+ .flat_map(|(index, entry)| match entry {
+ HistoryEntryId::AcpThread(id) if &thread.id == id => {
+ Some((index, HistoryEntry::AcpThread(thread.clone())))
+ }
+ _ => None,
+ })
+ });
+
+ let context_entries =
+ self.context_store
+ .read(cx)
+ .unordered_contexts()
+ .flat_map(|context| {
+ self.recently_opened_entries
+ .iter()
+ .enumerate()
+ .flat_map(|(index, entry)| match entry {
+ HistoryEntryId::TextThread(path) if &context.path == path => {
+ Some((index, HistoryEntry::TextThread(context.clone())))
+ }
+ _ => None,
+ })
+ });
+
+ thread_entries
+ .chain(context_entries)
+ // optimization to halt iteration early
+ .take(self.recently_opened_entries.len())
+ .sorted_unstable_by_key(|(index, _)| *index)
+ .map(|(_, entry)| entry)
+ .collect()
+ }
+
+ fn save_recently_opened_entries(&mut self, cx: &mut Context<Self>) {
+ let serialized_entries = self
+ .recently_opened_entries
+ .iter()
+ .filter_map(|entry| match entry {
+ HistoryEntryId::TextThread(path) => path.file_name().map(|file| {
+ SerializedRecentOpen::TextThread(file.to_string_lossy().to_string())
+ }),
+ HistoryEntryId::AcpThread(id) => {
+ Some(SerializedRecentOpen::AcpThread(id.to_string()))
+ }
+ })
+ .collect::<Vec<_>>();
+
+ self._save_recently_opened_entries_task = cx.spawn(async move |_, cx| {
+ let content = serde_json::to_string(&serialized_entries).unwrap();
+ cx.background_executor()
+ .timer(SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE)
+ .await;
+
+ if cfg!(any(feature = "test-support", test)) {
+ return;
+ }
+ KEY_VALUE_STORE
+ .write_kvp(RECENTLY_OPENED_THREADS_KEY.to_owned(), content)
+ .await
+ .log_err();
+ });
+ }
+
+ fn load_recently_opened_entries(cx: &AsyncApp) -> Task<Result<VecDeque<HistoryEntryId>>> {
+ cx.background_spawn(async move {
+ if cfg!(any(feature = "test-support", test)) {
+ anyhow::bail!("history store does not persist in tests");
+ }
+ let json = KEY_VALUE_STORE
+ .read_kvp(RECENTLY_OPENED_THREADS_KEY)?
+ .unwrap_or("[]".to_string());
+ let entries = serde_json::from_str::<Vec<SerializedRecentOpen>>(&json)
+ .context("deserializing persisted agent panel navigation history")?
+ .into_iter()
+ .take(MAX_RECENTLY_OPENED_ENTRIES)
+ .flat_map(|entry| match entry {
+ SerializedRecentOpen::AcpThread(id) => Some(HistoryEntryId::AcpThread(
+ acp::SessionId(id.as_str().into()),
+ )),
+ SerializedRecentOpen::TextThread(file_name) => Some(
+ HistoryEntryId::TextThread(contexts_dir().join(file_name).into()),
+ ),
+ })
+ .collect();
+ Ok(entries)
+ })
+ }
+
+ pub fn push_recently_opened_entry(&mut self, entry: HistoryEntryId, cx: &mut Context<Self>) {
+ self.recently_opened_entries
+ .retain(|old_entry| old_entry != &entry);
+ self.recently_opened_entries.push_front(entry);
+ self.recently_opened_entries
+ .truncate(MAX_RECENTLY_OPENED_ENTRIES);
+ self.save_recently_opened_entries(cx);
+ }
+
+ pub fn remove_recently_opened_thread(&mut self, id: acp::SessionId, cx: &mut Context<Self>) {
+ self.recently_opened_entries.retain(
+ |entry| !matches!(entry, HistoryEntryId::AcpThread(thread_id) if thread_id == &id),
+ );
+ self.save_recently_opened_entries(cx);
+ }
+
+ pub fn replace_recently_opened_text_thread(
+ &mut self,
+ old_path: &Path,
+ new_path: &Arc<Path>,
+ cx: &mut Context<Self>,
+ ) {
+ for entry in &mut self.recently_opened_entries {
+ match entry {
+ HistoryEntryId::TextThread(path) if path.as_ref() == old_path => {
+ *entry = HistoryEntryId::TextThread(new_path.clone());
+ break;
+ }
+ _ => {}
+ }
+ }
+ self.save_recently_opened_entries(cx);
+ }
+
+ pub fn remove_recently_opened_entry(&mut self, entry: &HistoryEntryId, cx: &mut Context<Self>) {
+ self.recently_opened_entries
+ .retain(|old_entry| old_entry != entry);
+ self.save_recently_opened_entries(cx);
+ }
+
+ pub fn entries(&self) -> impl Iterator<Item = HistoryEntry> {
+ self.entries.iter().cloned()
+ }
+}
@@ -0,0 +1,124 @@
+use std::{any::Any, path::Path, rc::Rc, sync::Arc};
+
+use agent_servers::AgentServer;
+use anyhow::Result;
+use fs::Fs;
+use gpui::{App, Entity, SharedString, Task};
+use project::Project;
+use prompt_store::PromptStore;
+
+use crate::{HistoryStore, NativeAgent, NativeAgentConnection, templates::Templates};
+
+#[derive(Clone)]
+pub struct NativeAgentServer {
+ fs: Arc<dyn Fs>,
+ history: Entity<HistoryStore>,
+}
+
+impl NativeAgentServer {
+ pub fn new(fs: Arc<dyn Fs>, history: Entity<HistoryStore>) -> Self {
+ Self { fs, history }
+ }
+}
+
+impl AgentServer for NativeAgentServer {
+ fn name(&self) -> SharedString {
+ "Zed Agent".into()
+ }
+
+ fn empty_state_headline(&self) -> SharedString {
+ self.name()
+ }
+
+ fn empty_state_message(&self) -> SharedString {
+ "".into()
+ }
+
+ fn logo(&self) -> ui::IconName {
+ ui::IconName::ZedAgent
+ }
+
+ fn connect(
+ &self,
+ _root_dir: &Path,
+ project: &Entity<Project>,
+ cx: &mut App,
+ ) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> {
+ log::debug!(
+ "NativeAgentServer::connect called for path: {:?}",
+ _root_dir
+ );
+ let project = project.clone();
+ let fs = self.fs.clone();
+ let history = self.history.clone();
+ let prompt_store = PromptStore::global(cx);
+ cx.spawn(async move |cx| {
+ log::debug!("Creating templates for native agent");
+ let templates = Templates::new();
+ let prompt_store = prompt_store.await?;
+
+ log::debug!("Creating native agent entity");
+ let agent =
+ NativeAgent::new(project, history, templates, Some(prompt_store), fs, cx).await?;
+
+ // Create the connection wrapper
+ let connection = NativeAgentConnection(agent);
+ log::debug!("NativeAgentServer connection established successfully");
+
+ Ok(Rc::new(connection) as Rc<dyn acp_thread::AgentConnection>)
+ })
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ self
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ use assistant_context::ContextStore;
+ use gpui::AppContext;
+
+ agent_servers::e2e_tests::common_e2e_tests!(
+ async |fs, project, cx| {
+ let auth = cx.update(|cx| {
+ prompt_store::init(cx);
+ terminal::init(cx);
+
+ let registry = language_model::LanguageModelRegistry::read_global(cx);
+ let auth = registry
+ .provider(&language_model::ANTHROPIC_PROVIDER_ID)
+ .unwrap()
+ .authenticate(cx);
+
+ cx.spawn(async move |_| auth.await)
+ });
+
+ auth.await.unwrap();
+
+ cx.update(|cx| {
+ let registry = language_model::LanguageModelRegistry::global(cx);
+
+ registry.update(cx, |registry, cx| {
+ registry.select_default_model(
+ Some(&language_model::SelectedModel {
+ provider: language_model::ANTHROPIC_PROVIDER_ID,
+ model: language_model::LanguageModelId("claude-sonnet-4-latest".into()),
+ }),
+ cx,
+ );
+ });
+ });
+
+ let history = cx.update(|cx| {
+ let context_store = cx.new(move |cx| ContextStore::fake(project.clone(), cx));
+ cx.new(move |cx| HistoryStore::new(context_store, cx))
+ });
+
+ NativeAgentServer::new(fs.clone(), history)
+ },
+ allow_option_id = "allow"
+ );
+}
@@ -0,0 +1,87 @@
+use anyhow::Result;
+use gpui::SharedString;
+use handlebars::Handlebars;
+use rust_embed::RustEmbed;
+use serde::Serialize;
+use std::sync::Arc;
+
+#[derive(RustEmbed)]
+#[folder = "src/templates"]
+#[include = "*.hbs"]
+struct Assets;
+
+pub struct Templates(Handlebars<'static>);
+
+impl Templates {
+ pub fn new() -> Arc<Self> {
+ let mut handlebars = Handlebars::new();
+ handlebars.set_strict_mode(true);
+ handlebars.register_helper("contains", Box::new(contains));
+ handlebars.register_embed_templates::<Assets>().unwrap();
+ Arc::new(Self(handlebars))
+ }
+}
+
+pub trait Template: Sized {
+ const TEMPLATE_NAME: &'static str;
+
+ fn render(&self, templates: &Templates) -> Result<String>
+ where
+ Self: Serialize + Sized,
+ {
+ Ok(templates.0.render(Self::TEMPLATE_NAME, self)?)
+ }
+}
+
+#[derive(Serialize)]
+pub struct SystemPromptTemplate<'a> {
+ #[serde(flatten)]
+ pub project: &'a prompt_store::ProjectContext,
+ pub available_tools: Vec<SharedString>,
+}
+
+impl Template for SystemPromptTemplate<'_> {
+ const TEMPLATE_NAME: &'static str = "system_prompt.hbs";
+}
+
+/// Handlebars helper for checking if an item is in a list
+fn contains(
+ h: &handlebars::Helper,
+ _: &handlebars::Handlebars,
+ _: &handlebars::Context,
+ _: &mut handlebars::RenderContext,
+ out: &mut dyn handlebars::Output,
+) -> handlebars::HelperResult {
+ let list = h
+ .param(0)
+ .and_then(|v| v.value().as_array())
+ .ok_or_else(|| {
+ handlebars::RenderError::new("contains: missing or invalid list parameter")
+ })?;
+ let query = h.param(1).map(|v| v.value()).ok_or_else(|| {
+ handlebars::RenderError::new("contains: missing or invalid query parameter")
+ })?;
+
+ if list.contains(query) {
+ out.write("true")?;
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_system_prompt_template() {
+ let project = prompt_store::ProjectContext::default();
+ let template = SystemPromptTemplate {
+ project: &project,
+ available_tools: vec!["echo".into()],
+ };
+ let templates = Templates::new();
+ let rendered = template.render(&templates).unwrap();
+ assert!(rendered.contains("## Fixing Diagnostics"));
+ }
+}
@@ -0,0 +1,178 @@
+You are a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
+
+## Communication
+
+1. Be conversational but professional.
+2. Refer to the user in the second person and yourself in the first person.
+3. Format your responses in markdown. Use backticks to format file, directory, function, and class names.
+4. NEVER lie or make things up.
+5. Refrain from apologizing all the time when results are unexpected. Instead, just try your best to proceed or explain the circumstances to the user without apologizing.
+
+{{#if (gt (len available_tools) 0)}}
+## Tool Use
+
+1. Make sure to adhere to the tools schema.
+2. Provide every required argument.
+3. DO NOT use tools to access items that are already available in the context section.
+4. Use only the tools that are currently available.
+5. DO NOT use a tool that is not available just because it appears in the conversation. This means the user turned it off.
+6. NEVER run commands that don't terminate on their own such as web servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers.
+7. Avoid HTML entity escaping - use plain characters instead.
+
+## Searching and Reading
+
+If you are unsure how to fulfill the user's request, gather more information with tool calls and/or clarifying questions.
+
+If appropriate, use tool calls to explore the current project, which contains the following root directories:
+
+{{#each worktrees}}
+- `{{abs_path}}`
+{{/each}}
+
+- Bias towards not asking the user for help if you can find the answer yourself.
+- When providing paths to tools, the path should always start with the name of a project root directory listed above.
+- Before you read or edit a file, you must first find the full path. DO NOT ever guess a file path!
+{{# if (contains available_tools 'grep') }}
+- When looking for symbols in the project, prefer the `grep` tool.
+- As you learn about the structure of the project, use that information to scope `grep` searches to targeted subtrees of the project.
+- The user might specify a partial file path. If you don't know the full path, use `find_path` (not `grep`) before you read the file.
+{{/if}}
+{{else}}
+You are being tasked with providing a response, but you have no ability to use tools or to read or write any aspect of the user's system (other than any context the user might have provided to you).
+
+As such, if you need the user to perform any actions for you, you must request them explicitly. Bias towards giving a response to the best of your ability, and then making requests for the user to take action (e.g. to give you more context) only optionally.
+
+The one exception to this is if the user references something you don't know about - for example, the name of a source code file, function, type, or other piece of code that you have no awareness of. In this case, you MUST NOT MAKE SOMETHING UP, or assume you know what that thing is or how it works. Instead, you must ask the user for clarification rather than giving a response.
+{{/if}}
+
+## Code Block Formatting
+
+Whenever you mention a code block, you MUST use ONLY use the following format:
+```path/to/Something.blah#L123-456
+(code goes here)
+```
+The `#L123-456` means the line number range 123 through 456, and the path/to/Something.blah
+is a path in the project. (If there is no valid path in the project, then you can use
+/dev/null/path.extension for its path.) This is the ONLY valid way to format code blocks, because the Markdown parser
+does not understand the more common ```language syntax, or bare ``` blocks. It only
+understands this path-based syntax, and if the path is missing, then it will error and you will have to do it over again.
+Just to be really clear about this, if you ever find yourself writing three backticks followed by a language name, STOP!
+You have made a mistake. You can only ever put paths after triple backticks!
+<example>
+Based on all the information I've gathered, here's a summary of how this system works:
+1. The README file is loaded into the system.
+2. The system finds the first two headers, including everything in between. In this case, that would be:
+```path/to/README.md#L8-12
+# First Header
+This is the info under the first header.
+## Sub-header
+```
+3. Then the system finds the last header in the README:
+```path/to/README.md#L27-29
+## Last Header
+This is the last header in the README.
+```
+4. Finally, it passes this information on to the next process.
+</example>
+<example>
+In Markdown, hash marks signify headings. For example:
+```/dev/null/example.md#L1-3
+# Level 1 heading
+## Level 2 heading
+### Level 3 heading
+```
+</example>
+Here are examples of ways you must never render code blocks:
+<bad_example_do_not_do_this>
+In Markdown, hash marks signify headings. For example:
+```
+# Level 1 heading
+## Level 2 heading
+### Level 3 heading
+```
+</bad_example_do_not_do_this>
+This example is unacceptable because it does not include the path.
+<bad_example_do_not_do_this>
+In Markdown, hash marks signify headings. For example:
+```markdown
+# Level 1 heading
+## Level 2 heading
+### Level 3 heading
+```
+</bad_example_do_not_do_this>
+This example is unacceptable because it has the language instead of the path.
+<bad_example_do_not_do_this>
+In Markdown, hash marks signify headings. For example:
+ # Level 1 heading
+ ## Level 2 heading
+ ### Level 3 heading
+</bad_example_do_not_do_this>
+This example is unacceptable because it uses indentation to mark the code block
+instead of backticks with a path.
+<bad_example_do_not_do_this>
+In Markdown, hash marks signify headings. For example:
+```markdown
+/dev/null/example.md#L1-3
+# Level 1 heading
+## Level 2 heading
+### Level 3 heading
+```
+</bad_example_do_not_do_this>
+This example is unacceptable because the path is in the wrong place. The path must be directly after the opening backticks.
+
+{{#if (gt (len available_tools) 0)}}
+## Fixing Diagnostics
+
+1. Make 1-2 attempts at fixing diagnostics, then defer to the user.
+2. Never simplify code you've written just to solve diagnostics. Complete, mostly correct code is more valuable than perfect code that doesn't solve the problem.
+
+## Debugging
+
+When debugging, only make code changes if you are certain that you can solve the problem.
+Otherwise, follow debugging best practices:
+1. Address the root cause instead of the symptoms.
+2. Add descriptive logging statements and error messages to track variable and code state.
+3. Add test functions and statements to isolate the problem.
+
+{{/if}}
+## Calling External APIs
+
+1. Unless explicitly requested by the user, use the best suited external APIs and packages to solve the task. There is no need to ask the user for permission.
+2. When selecting which version of an API or package to use, choose one that is compatible with the user's dependency management file(s). If no such file exists or if the package is not present, use the latest version that is in your training data.
+3. If an external API requires an API Key, be sure to point this out to the user. Adhere to best security practices (e.g. DO NOT hardcode an API key in a place where it can be exposed)
+
+## System Information
+
+Operating System: {{os}}
+Default Shell: {{shell}}
+
+{{#if (or has_rules has_user_rules)}}
+## User's Custom Instructions
+
+The following additional instructions are provided by the user, and should be followed to the best of your ability{{#if (gt (len available_tools) 0)}} without interfering with the tool use guidelines{{/if}}.
+
+{{#if has_rules}}
+There are project rules that apply to these root directories:
+{{#each worktrees}}
+{{#if rules_file}}
+`{{root_name}}/{{rules_file.path_in_worktree}}`:
+``````
+{{{rules_file.text}}}
+``````
+{{/if}}
+{{/each}}
+{{/if}}
+
+{{#if has_user_rules}}
+The user has specified the following rules that should be applied:
+{{#each user_rules}}
+
+{{#if title}}
+Rules title: {{title}}
+{{/if}}
+``````
+{{contents}}}
+``````
+{{/each}}
+{{/if}}
+{{/if}}
@@ -0,0 +1,2531 @@
+use super::*;
+use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelList, UserMessageId};
+use agent_client_protocol::{self as acp};
+use agent_settings::AgentProfileId;
+use anyhow::Result;
+use client::{Client, UserStore};
+use cloud_llm_client::CompletionIntent;
+use collections::IndexMap;
+use context_server::{ContextServer, ContextServerCommand, ContextServerId};
+use fs::{FakeFs, Fs};
+use futures::{
+ StreamExt,
+ channel::{
+ mpsc::{self, UnboundedReceiver},
+ oneshot,
+ },
+};
+use gpui::{
+ App, AppContext, Entity, Task, TestAppContext, UpdateGlobal, http_client::FakeHttpClient,
+};
+use indoc::indoc;
+use language_model::{
+ LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId,
+ LanguageModelProviderName, LanguageModelRegistry, LanguageModelRequest,
+ LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolSchemaFormat,
+ LanguageModelToolUse, MessageContent, Role, StopReason, fake_provider::FakeLanguageModel,
+};
+use pretty_assertions::assert_eq;
+use project::{
+ Project, context_server_store::ContextServerStore, project_settings::ProjectSettings,
+};
+use prompt_store::ProjectContext;
+use reqwest_client::ReqwestClient;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use settings::{Settings, SettingsStore};
+use std::{path::Path, rc::Rc, sync::Arc, time::Duration};
+use util::path;
+
+mod test_tools;
+use test_tools::*;
+
+#[gpui::test]
+async fn test_echo(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let events = thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Testing: Reply with 'Hello'"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ fake_model.send_last_completion_stream_text_chunk("Hello");
+ fake_model
+ .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
+ fake_model.end_last_completion_stream();
+
+ let events = events.collect().await;
+ thread.update(cx, |thread, _cx| {
+ assert_eq!(
+ thread.last_message().unwrap().to_markdown(),
+ indoc! {"
+ ## Assistant
+
+ Hello
+ "}
+ )
+ });
+ assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]);
+}
+
+#[gpui::test]
+async fn test_thinking(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let events = thread
+ .update(cx, |thread, cx| {
+ thread.send(
+ UserMessageId::new(),
+ [indoc! {"
+ Testing:
+
+ Generate a thinking step where you just think the word 'Think',
+ and have your final answer be 'Hello'
+ "}],
+ cx,
+ )
+ })
+ .unwrap();
+ cx.run_until_parked();
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Thinking {
+ text: "Think".to_string(),
+ signature: None,
+ });
+ fake_model.send_last_completion_stream_text_chunk("Hello");
+ fake_model
+ .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
+ fake_model.end_last_completion_stream();
+
+ let events = events.collect().await;
+ thread.update(cx, |thread, _cx| {
+ assert_eq!(
+ thread.last_message().unwrap().to_markdown(),
+ indoc! {"
+ ## Assistant
+
+ <think>Think</think>
+ Hello
+ "}
+ )
+ });
+ assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]);
+}
+
+#[gpui::test]
+async fn test_system_prompt(cx: &mut TestAppContext) {
+ let ThreadTest {
+ model,
+ thread,
+ project_context,
+ ..
+ } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ project_context.update(cx, |project_context, _cx| {
+ project_context.shell = "test-shell".into()
+ });
+ thread.update(cx, |thread, _| thread.add_tool(EchoTool));
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["abc"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ let mut pending_completions = fake_model.pending_completions();
+ assert_eq!(
+ pending_completions.len(),
+ 1,
+ "unexpected pending completions: {:?}",
+ pending_completions
+ );
+
+ let pending_completion = pending_completions.pop().unwrap();
+ assert_eq!(pending_completion.messages[0].role, Role::System);
+
+ let system_message = &pending_completion.messages[0];
+ let system_prompt = system_message.content[0].to_str().unwrap();
+ assert!(
+ system_prompt.contains("test-shell"),
+ "unexpected system message: {:?}",
+ system_message
+ );
+ assert!(
+ system_prompt.contains("## Fixing Diagnostics"),
+ "unexpected system message: {:?}",
+ system_message
+ );
+}
+
+#[gpui::test]
+async fn test_prompt_caching(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ // Send initial user message and verify it's cached
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Message 1"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ let completion = fake_model.pending_completions().pop().unwrap();
+ assert_eq!(
+ completion.messages[1..],
+ vec![LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["Message 1".into()],
+ cache: true
+ }]
+ );
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text(
+ "Response to Message 1".into(),
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ // Send another user message and verify only the latest is cached
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Message 2"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ let completion = fake_model.pending_completions().pop().unwrap();
+ assert_eq!(
+ completion.messages[1..],
+ vec![
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["Message 1".into()],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec!["Response to Message 1".into()],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["Message 2".into()],
+ cache: true
+ }
+ ]
+ );
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text(
+ "Response to Message 2".into(),
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ // Simulate a tool call and verify that the latest tool result is cached
+ thread.update(cx, |thread, _| thread.add_tool(EchoTool));
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Use the echo tool"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ let tool_use = LanguageModelToolUse {
+ id: "tool_1".into(),
+ name: EchoTool::name().into(),
+ raw_input: json!({"text": "test"}).to_string(),
+ input: json!({"text": "test"}),
+ is_input_complete: true,
+ };
+ fake_model
+ .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone()));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ let completion = fake_model.pending_completions().pop().unwrap();
+ let tool_result = LanguageModelToolResult {
+ tool_use_id: "tool_1".into(),
+ tool_name: EchoTool::name().into(),
+ is_error: false,
+ content: "test".into(),
+ output: Some("test".into()),
+ };
+ assert_eq!(
+ completion.messages[1..],
+ vec![
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["Message 1".into()],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec!["Response to Message 1".into()],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["Message 2".into()],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec!["Response to Message 2".into()],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["Use the echo tool".into()],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec![MessageContent::ToolUse(tool_use)],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![MessageContent::ToolResult(tool_result)],
+ cache: true
+ }
+ ]
+ );
+}
+
+#[gpui::test]
+#[cfg_attr(not(feature = "e2e"), ignore)]
+async fn test_basic_tool_calls(cx: &mut TestAppContext) {
+ let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await;
+
+ // Test a tool call that's likely to complete *before* streaming stops.
+ let events = thread
+ .update(cx, |thread, cx| {
+ thread.add_tool(EchoTool);
+ thread.send(
+ UserMessageId::new(),
+ ["Now test the echo tool with 'Hello'. Does it work? Say 'Yes' or 'No'."],
+ cx,
+ )
+ })
+ .unwrap()
+ .collect()
+ .await;
+ assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]);
+
+ // Test a tool calls that's likely to complete *after* streaming stops.
+ let events = thread
+ .update(cx, |thread, cx| {
+ thread.remove_tool(&EchoTool::name());
+ thread.add_tool(DelayTool);
+ thread.send(
+ UserMessageId::new(),
+ [
+ "Now call the delay tool with 200ms.",
+ "When the timer goes off, then you echo the output of the tool.",
+ ],
+ cx,
+ )
+ })
+ .unwrap()
+ .collect()
+ .await;
+ assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]);
+ thread.update(cx, |thread, _cx| {
+ assert!(
+ thread
+ .last_message()
+ .unwrap()
+ .as_agent_message()
+ .unwrap()
+ .content
+ .iter()
+ .any(|content| {
+ if let AgentMessageContent::Text(text) = content {
+ text.contains("Ding")
+ } else {
+ false
+ }
+ }),
+ "{}",
+ thread.to_markdown()
+ );
+ });
+}
+
+#[gpui::test]
+#[cfg_attr(not(feature = "e2e"), ignore)]
+async fn test_streaming_tool_calls(cx: &mut TestAppContext) {
+ let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await;
+
+ // Test a tool call that's likely to complete *before* streaming stops.
+ let mut events = thread
+ .update(cx, |thread, cx| {
+ thread.add_tool(WordListTool);
+ thread.send(UserMessageId::new(), ["Test the word_list tool."], cx)
+ })
+ .unwrap();
+
+ let mut saw_partial_tool_use = false;
+ while let Some(event) = events.next().await {
+ if let Ok(ThreadEvent::ToolCall(tool_call)) = event {
+ thread.update(cx, |thread, _cx| {
+ // Look for a tool use in the thread's last message
+ let message = thread.last_message().unwrap();
+ let agent_message = message.as_agent_message().unwrap();
+ let last_content = agent_message.content.last().unwrap();
+ if let AgentMessageContent::ToolUse(last_tool_use) = last_content {
+ assert_eq!(last_tool_use.name.as_ref(), "word_list");
+ if tool_call.status == acp::ToolCallStatus::Pending {
+ if !last_tool_use.is_input_complete
+ && last_tool_use.input.get("g").is_none()
+ {
+ saw_partial_tool_use = true;
+ }
+ } else {
+ last_tool_use
+ .input
+ .get("a")
+ .expect("'a' has streamed because input is now complete");
+ last_tool_use
+ .input
+ .get("g")
+ .expect("'g' has streamed because input is now complete");
+ }
+ } else {
+ panic!("last content should be a tool use");
+ }
+ });
+ }
+ }
+
+ assert!(
+ saw_partial_tool_use,
+ "should see at least one partially streamed tool use in the history"
+ );
+}
+
+#[gpui::test]
+async fn test_tool_authorization(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let mut events = thread
+ .update(cx, |thread, cx| {
+ thread.add_tool(ToolRequiringPermission);
+ thread.send(UserMessageId::new(), ["abc"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: "tool_id_1".into(),
+ name: ToolRequiringPermission::name().into(),
+ raw_input: "{}".into(),
+ input: json!({}),
+ is_input_complete: true,
+ },
+ ));
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: "tool_id_2".into(),
+ name: ToolRequiringPermission::name().into(),
+ raw_input: "{}".into(),
+ input: json!({}),
+ is_input_complete: true,
+ },
+ ));
+ fake_model.end_last_completion_stream();
+ let tool_call_auth_1 = next_tool_call_authorization(&mut events).await;
+ let tool_call_auth_2 = next_tool_call_authorization(&mut events).await;
+
+ // Approve the first
+ tool_call_auth_1
+ .response
+ .send(tool_call_auth_1.options[1].id.clone())
+ .unwrap();
+ cx.run_until_parked();
+
+ // Reject the second
+ tool_call_auth_2
+ .response
+ .send(tool_call_auth_1.options[2].id.clone())
+ .unwrap();
+ cx.run_until_parked();
+
+ let completion = fake_model.pending_completions().pop().unwrap();
+ let message = completion.messages.last().unwrap();
+ assert_eq!(
+ message.content,
+ vec![
+ language_model::MessageContent::ToolResult(LanguageModelToolResult {
+ tool_use_id: tool_call_auth_1.tool_call.id.0.to_string().into(),
+ tool_name: ToolRequiringPermission::name().into(),
+ is_error: false,
+ content: "Allowed".into(),
+ output: Some("Allowed".into())
+ }),
+ language_model::MessageContent::ToolResult(LanguageModelToolResult {
+ tool_use_id: tool_call_auth_2.tool_call.id.0.to_string().into(),
+ tool_name: ToolRequiringPermission::name().into(),
+ is_error: true,
+ content: "Permission to run tool denied by user".into(),
+ output: None
+ })
+ ]
+ );
+
+ // Simulate yet another tool call.
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: "tool_id_3".into(),
+ name: ToolRequiringPermission::name().into(),
+ raw_input: "{}".into(),
+ input: json!({}),
+ is_input_complete: true,
+ },
+ ));
+ fake_model.end_last_completion_stream();
+
+ // Respond by always allowing tools.
+ let tool_call_auth_3 = next_tool_call_authorization(&mut events).await;
+ tool_call_auth_3
+ .response
+ .send(tool_call_auth_3.options[0].id.clone())
+ .unwrap();
+ cx.run_until_parked();
+ let completion = fake_model.pending_completions().pop().unwrap();
+ let message = completion.messages.last().unwrap();
+ assert_eq!(
+ message.content,
+ vec![language_model::MessageContent::ToolResult(
+ LanguageModelToolResult {
+ tool_use_id: tool_call_auth_3.tool_call.id.0.to_string().into(),
+ tool_name: ToolRequiringPermission::name().into(),
+ is_error: false,
+ content: "Allowed".into(),
+ output: Some("Allowed".into())
+ }
+ )]
+ );
+
+ // Simulate a final tool call, ensuring we don't trigger authorization.
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: "tool_id_4".into(),
+ name: ToolRequiringPermission::name().into(),
+ raw_input: "{}".into(),
+ input: json!({}),
+ is_input_complete: true,
+ },
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+ let completion = fake_model.pending_completions().pop().unwrap();
+ let message = completion.messages.last().unwrap();
+ assert_eq!(
+ message.content,
+ vec![language_model::MessageContent::ToolResult(
+ LanguageModelToolResult {
+ tool_use_id: "tool_id_4".into(),
+ tool_name: ToolRequiringPermission::name().into(),
+ is_error: false,
+ content: "Allowed".into(),
+ output: Some("Allowed".into())
+ }
+ )]
+ );
+}
+
+#[gpui::test]
+async fn test_tool_hallucination(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let mut events = thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["abc"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: "tool_id_1".into(),
+ name: "nonexistent_tool".into(),
+ raw_input: "{}".into(),
+ input: json!({}),
+ is_input_complete: true,
+ },
+ ));
+ fake_model.end_last_completion_stream();
+
+ let tool_call = expect_tool_call(&mut events).await;
+ assert_eq!(tool_call.title, "nonexistent_tool");
+ assert_eq!(tool_call.status, acp::ToolCallStatus::Pending);
+ let update = expect_tool_call_update_fields(&mut events).await;
+ assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Failed));
+}
+
+#[gpui::test]
+async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let events = thread
+ .update(cx, |thread, cx| {
+ thread.add_tool(EchoTool);
+ thread.send(UserMessageId::new(), ["abc"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ let tool_use = LanguageModelToolUse {
+ id: "tool_id_1".into(),
+ name: EchoTool::name().into(),
+ raw_input: "{}".into(),
+ input: serde_json::to_value(&EchoToolInput { text: "def".into() }).unwrap(),
+ is_input_complete: true,
+ };
+ fake_model
+ .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone()));
+ fake_model.end_last_completion_stream();
+
+ cx.run_until_parked();
+ let completion = fake_model.pending_completions().pop().unwrap();
+ let tool_result = LanguageModelToolResult {
+ tool_use_id: "tool_id_1".into(),
+ tool_name: EchoTool::name().into(),
+ is_error: false,
+ content: "def".into(),
+ output: Some("def".into()),
+ };
+ assert_eq!(
+ completion.messages[1..],
+ vec![
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["abc".into()],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec![MessageContent::ToolUse(tool_use.clone())],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![MessageContent::ToolResult(tool_result.clone())],
+ cache: true
+ },
+ ]
+ );
+
+ // Simulate reaching tool use limit.
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::StatusUpdate(
+ cloud_llm_client::CompletionRequestStatus::ToolUseLimitReached,
+ ));
+ fake_model.end_last_completion_stream();
+ let last_event = events.collect::<Vec<_>>().await.pop().unwrap();
+ assert!(
+ last_event
+ .unwrap_err()
+ .is::<language_model::ToolUseLimitReachedError>()
+ );
+
+ let events = thread.update(cx, |thread, cx| thread.resume(cx)).unwrap();
+ cx.run_until_parked();
+ let completion = fake_model.pending_completions().pop().unwrap();
+ assert_eq!(
+ completion.messages[1..],
+ vec![
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["abc".into()],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec![MessageContent::ToolUse(tool_use)],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![MessageContent::ToolResult(tool_result)],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["Continue where you left off".into()],
+ cache: true
+ }
+ ]
+ );
+
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text("Done".into()));
+ fake_model.end_last_completion_stream();
+ events.collect::<Vec<_>>().await;
+ thread.read_with(cx, |thread, _cx| {
+ assert_eq!(
+ thread.last_message().unwrap().to_markdown(),
+ indoc! {"
+ ## Assistant
+
+ Done
+ "}
+ )
+ });
+}
+
+#[gpui::test]
+async fn test_send_after_tool_use_limit(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let events = thread
+ .update(cx, |thread, cx| {
+ thread.add_tool(EchoTool);
+ thread.send(UserMessageId::new(), ["abc"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ let tool_use = LanguageModelToolUse {
+ id: "tool_id_1".into(),
+ name: EchoTool::name().into(),
+ raw_input: "{}".into(),
+ input: serde_json::to_value(&EchoToolInput { text: "def".into() }).unwrap(),
+ is_input_complete: true,
+ };
+ let tool_result = LanguageModelToolResult {
+ tool_use_id: "tool_id_1".into(),
+ tool_name: EchoTool::name().into(),
+ is_error: false,
+ content: "def".into(),
+ output: Some("def".into()),
+ };
+ fake_model
+ .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone()));
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::StatusUpdate(
+ cloud_llm_client::CompletionRequestStatus::ToolUseLimitReached,
+ ));
+ fake_model.end_last_completion_stream();
+ let last_event = events.collect::<Vec<_>>().await.pop().unwrap();
+ assert!(
+ last_event
+ .unwrap_err()
+ .is::<language_model::ToolUseLimitReachedError>()
+ );
+
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), vec!["ghi"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ let completion = fake_model.pending_completions().pop().unwrap();
+ assert_eq!(
+ completion.messages[1..],
+ vec![
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["abc".into()],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec![MessageContent::ToolUse(tool_use)],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![MessageContent::ToolResult(tool_result)],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["ghi".into()],
+ cache: true
+ }
+ ]
+ );
+}
+
+async fn expect_tool_call(events: &mut UnboundedReceiver<Result<ThreadEvent>>) -> acp::ToolCall {
+ let event = events
+ .next()
+ .await
+ .expect("no tool call authorization event received")
+ .unwrap();
+ match event {
+ ThreadEvent::ToolCall(tool_call) => tool_call,
+ event => {
+ panic!("Unexpected event {event:?}");
+ }
+ }
+}
+
+async fn expect_tool_call_update_fields(
+ events: &mut UnboundedReceiver<Result<ThreadEvent>>,
+) -> acp::ToolCallUpdate {
+ let event = events
+ .next()
+ .await
+ .expect("no tool call authorization event received")
+ .unwrap();
+ match event {
+ ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) => update,
+ event => {
+ panic!("Unexpected event {event:?}");
+ }
+ }
+}
+
+async fn next_tool_call_authorization(
+ events: &mut UnboundedReceiver<Result<ThreadEvent>>,
+) -> ToolCallAuthorization {
+ loop {
+ let event = events
+ .next()
+ .await
+ .expect("no tool call authorization event received")
+ .unwrap();
+ if let ThreadEvent::ToolCallAuthorization(tool_call_authorization) = event {
+ let permission_kinds = tool_call_authorization
+ .options
+ .iter()
+ .map(|o| o.kind)
+ .collect::<Vec<_>>();
+ assert_eq!(
+ permission_kinds,
+ vec![
+ acp::PermissionOptionKind::AllowAlways,
+ acp::PermissionOptionKind::AllowOnce,
+ acp::PermissionOptionKind::RejectOnce,
+ ]
+ );
+ return tool_call_authorization;
+ }
+ }
+}
+
+#[gpui::test]
+#[cfg_attr(not(feature = "e2e"), ignore)]
+async fn test_concurrent_tool_calls(cx: &mut TestAppContext) {
+ let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await;
+
+ // Test concurrent tool calls with different delay times
+ let events = thread
+ .update(cx, |thread, cx| {
+ thread.add_tool(DelayTool);
+ thread.send(
+ UserMessageId::new(),
+ [
+ "Call the delay tool twice in the same message.",
+ "Once with 100ms. Once with 300ms.",
+ "When both timers are complete, describe the outputs.",
+ ],
+ cx,
+ )
+ })
+ .unwrap()
+ .collect()
+ .await;
+
+ let stop_reasons = stop_events(events);
+ assert_eq!(stop_reasons, vec![acp::StopReason::EndTurn]);
+
+ thread.update(cx, |thread, _cx| {
+ let last_message = thread.last_message().unwrap();
+ let agent_message = last_message.as_agent_message().unwrap();
+ let text = agent_message
+ .content
+ .iter()
+ .filter_map(|content| {
+ if let AgentMessageContent::Text(text) = content {
+ Some(text.as_str())
+ } else {
+ None
+ }
+ })
+ .collect::<String>();
+
+ assert!(text.contains("Ding"));
+ });
+}
+
+#[gpui::test]
+async fn test_profiles(cx: &mut TestAppContext) {
+ let ThreadTest {
+ model, thread, fs, ..
+ } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ thread.update(cx, |thread, _cx| {
+ thread.add_tool(DelayTool);
+ thread.add_tool(EchoTool);
+ thread.add_tool(InfiniteTool);
+ });
+
+ // Override profiles and wait for settings to be loaded.
+ fs.insert_file(
+ paths::settings_file(),
+ json!({
+ "agent": {
+ "profiles": {
+ "test-1": {
+ "name": "Test Profile 1",
+ "tools": {
+ EchoTool::name(): true,
+ DelayTool::name(): true,
+ }
+ },
+ "test-2": {
+ "name": "Test Profile 2",
+ "tools": {
+ InfiniteTool::name(): true,
+ }
+ }
+ }
+ }
+ })
+ .to_string()
+ .into_bytes(),
+ )
+ .await;
+ cx.run_until_parked();
+
+ // Test that test-1 profile (default) has echo and delay tools
+ thread
+ .update(cx, |thread, cx| {
+ thread.set_profile(AgentProfileId("test-1".into()));
+ thread.send(UserMessageId::new(), ["test"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ let mut pending_completions = fake_model.pending_completions();
+ assert_eq!(pending_completions.len(), 1);
+ let completion = pending_completions.pop().unwrap();
+ let tool_names: Vec<String> = completion
+ .tools
+ .iter()
+ .map(|tool| tool.name.clone())
+ .collect();
+ assert_eq!(tool_names, vec![DelayTool::name(), EchoTool::name()]);
+ fake_model.end_last_completion_stream();
+
+ // Switch to test-2 profile, and verify that it has only the infinite tool.
+ thread
+ .update(cx, |thread, cx| {
+ thread.set_profile(AgentProfileId("test-2".into()));
+ thread.send(UserMessageId::new(), ["test2"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ let mut pending_completions = fake_model.pending_completions();
+ assert_eq!(pending_completions.len(), 1);
+ let completion = pending_completions.pop().unwrap();
+ let tool_names: Vec<String> = completion
+ .tools
+ .iter()
+ .map(|tool| tool.name.clone())
+ .collect();
+ assert_eq!(tool_names, vec![InfiniteTool::name()]);
+}
+
+#[gpui::test]
+async fn test_mcp_tools(cx: &mut TestAppContext) {
+ let ThreadTest {
+ model,
+ thread,
+ context_server_store,
+ fs,
+ ..
+ } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ // Override profiles and wait for settings to be loaded.
+ fs.insert_file(
+ paths::settings_file(),
+ json!({
+ "agent": {
+ "profiles": {
+ "test": {
+ "name": "Test Profile",
+ "enable_all_context_servers": true,
+ "tools": {
+ EchoTool::name(): true,
+ }
+ },
+ }
+ }
+ })
+ .to_string()
+ .into_bytes(),
+ )
+ .await;
+ cx.run_until_parked();
+ thread.update(cx, |thread, _| {
+ thread.set_profile(AgentProfileId("test".into()))
+ });
+
+ let mut mcp_tool_calls = setup_context_server(
+ "test_server",
+ vec![context_server::types::Tool {
+ name: "echo".into(),
+ description: None,
+ input_schema: serde_json::to_value(
+ EchoTool.input_schema(LanguageModelToolSchemaFormat::JsonSchema),
+ )
+ .unwrap(),
+ output_schema: None,
+ annotations: None,
+ }],
+ &context_server_store,
+ cx,
+ );
+
+ let events = thread.update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Hey"], cx).unwrap()
+ });
+ cx.run_until_parked();
+
+ // Simulate the model calling the MCP tool.
+ let completion = fake_model.pending_completions().pop().unwrap();
+ assert_eq!(tool_names_for_completion(&completion), vec!["echo"]);
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: "tool_1".into(),
+ name: "echo".into(),
+ raw_input: json!({"text": "test"}).to_string(),
+ input: json!({"text": "test"}),
+ is_input_complete: true,
+ },
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap();
+ assert_eq!(tool_call_params.name, "echo");
+ assert_eq!(tool_call_params.arguments, Some(json!({"text": "test"})));
+ tool_call_response
+ .send(context_server::types::CallToolResponse {
+ content: vec![context_server::types::ToolResponseContent::Text {
+ text: "test".into(),
+ }],
+ is_error: None,
+ meta: None,
+ structured_content: None,
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ assert_eq!(tool_names_for_completion(&completion), vec!["echo"]);
+ fake_model.send_last_completion_stream_text_chunk("Done!");
+ fake_model.end_last_completion_stream();
+ events.collect::<Vec<_>>().await;
+
+ // Send again after adding the echo tool, ensuring the name collision is resolved.
+ let events = thread.update(cx, |thread, cx| {
+ thread.add_tool(EchoTool);
+ thread.send(UserMessageId::new(), ["Go"], cx).unwrap()
+ });
+ cx.run_until_parked();
+ let completion = fake_model.pending_completions().pop().unwrap();
+ assert_eq!(
+ tool_names_for_completion(&completion),
+ vec!["echo", "test_server_echo"]
+ );
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: "tool_2".into(),
+ name: "test_server_echo".into(),
+ raw_input: json!({"text": "mcp"}).to_string(),
+ input: json!({"text": "mcp"}),
+ is_input_complete: true,
+ },
+ ));
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: "tool_3".into(),
+ name: "echo".into(),
+ raw_input: json!({"text": "native"}).to_string(),
+ input: json!({"text": "native"}),
+ is_input_complete: true,
+ },
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap();
+ assert_eq!(tool_call_params.name, "echo");
+ assert_eq!(tool_call_params.arguments, Some(json!({"text": "mcp"})));
+ tool_call_response
+ .send(context_server::types::CallToolResponse {
+ content: vec![context_server::types::ToolResponseContent::Text { text: "mcp".into() }],
+ is_error: None,
+ meta: None,
+ structured_content: None,
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ // Ensure the tool results were inserted with the correct names.
+ let completion = fake_model.pending_completions().pop().unwrap();
+ assert_eq!(
+ completion.messages.last().unwrap().content,
+ vec![
+ MessageContent::ToolResult(LanguageModelToolResult {
+ tool_use_id: "tool_3".into(),
+ tool_name: "echo".into(),
+ is_error: false,
+ content: "native".into(),
+ output: Some("native".into()),
+ },),
+ MessageContent::ToolResult(LanguageModelToolResult {
+ tool_use_id: "tool_2".into(),
+ tool_name: "test_server_echo".into(),
+ is_error: false,
+ content: "mcp".into(),
+ output: Some("mcp".into()),
+ },),
+ ]
+ );
+ fake_model.end_last_completion_stream();
+ events.collect::<Vec<_>>().await;
+}
+
+#[gpui::test]
+async fn test_mcp_tool_truncation(cx: &mut TestAppContext) {
+ let ThreadTest {
+ model,
+ thread,
+ context_server_store,
+ fs,
+ ..
+ } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ // Set up a profile with all tools enabled
+ fs.insert_file(
+ paths::settings_file(),
+ json!({
+ "agent": {
+ "profiles": {
+ "test": {
+ "name": "Test Profile",
+ "enable_all_context_servers": true,
+ "tools": {
+ EchoTool::name(): true,
+ DelayTool::name(): true,
+ WordListTool::name(): true,
+ ToolRequiringPermission::name(): true,
+ InfiniteTool::name(): true,
+ }
+ },
+ }
+ }
+ })
+ .to_string()
+ .into_bytes(),
+ )
+ .await;
+ cx.run_until_parked();
+
+ thread.update(cx, |thread, _| {
+ thread.set_profile(AgentProfileId("test".into()));
+ thread.add_tool(EchoTool);
+ thread.add_tool(DelayTool);
+ thread.add_tool(WordListTool);
+ thread.add_tool(ToolRequiringPermission);
+ thread.add_tool(InfiniteTool);
+ });
+
+ // Set up multiple context servers with some overlapping tool names
+ let _server1_calls = setup_context_server(
+ "xxx",
+ vec![
+ context_server::types::Tool {
+ name: "echo".into(), // Conflicts with native EchoTool
+ description: None,
+ input_schema: serde_json::to_value(
+ EchoTool.input_schema(LanguageModelToolSchemaFormat::JsonSchema),
+ )
+ .unwrap(),
+ output_schema: None,
+ annotations: None,
+ },
+ context_server::types::Tool {
+ name: "unique_tool_1".into(),
+ description: None,
+ input_schema: json!({"type": "object", "properties": {}}),
+ output_schema: None,
+ annotations: None,
+ },
+ ],
+ &context_server_store,
+ cx,
+ );
+
+ let _server2_calls = setup_context_server(
+ "yyy",
+ vec![
+ context_server::types::Tool {
+ name: "echo".into(), // Also conflicts with native EchoTool
+ description: None,
+ input_schema: serde_json::to_value(
+ EchoTool.input_schema(LanguageModelToolSchemaFormat::JsonSchema),
+ )
+ .unwrap(),
+ output_schema: None,
+ annotations: None,
+ },
+ context_server::types::Tool {
+ name: "unique_tool_2".into(),
+ description: None,
+ input_schema: json!({"type": "object", "properties": {}}),
+ output_schema: None,
+ annotations: None,
+ },
+ context_server::types::Tool {
+ name: "a".repeat(MAX_TOOL_NAME_LENGTH - 2),
+ description: None,
+ input_schema: json!({"type": "object", "properties": {}}),
+ output_schema: None,
+ annotations: None,
+ },
+ context_server::types::Tool {
+ name: "b".repeat(MAX_TOOL_NAME_LENGTH - 1),
+ description: None,
+ input_schema: json!({"type": "object", "properties": {}}),
+ output_schema: None,
+ annotations: None,
+ },
+ ],
+ &context_server_store,
+ cx,
+ );
+ let _server3_calls = setup_context_server(
+ "zzz",
+ vec![
+ context_server::types::Tool {
+ name: "a".repeat(MAX_TOOL_NAME_LENGTH - 2),
+ description: None,
+ input_schema: json!({"type": "object", "properties": {}}),
+ output_schema: None,
+ annotations: None,
+ },
+ context_server::types::Tool {
+ name: "b".repeat(MAX_TOOL_NAME_LENGTH - 1),
+ description: None,
+ input_schema: json!({"type": "object", "properties": {}}),
+ output_schema: None,
+ annotations: None,
+ },
+ context_server::types::Tool {
+ name: "c".repeat(MAX_TOOL_NAME_LENGTH + 1),
+ description: None,
+ input_schema: json!({"type": "object", "properties": {}}),
+ output_schema: None,
+ annotations: None,
+ },
+ ],
+ &context_server_store,
+ cx,
+ );
+
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Go"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ let completion = fake_model.pending_completions().pop().unwrap();
+ assert_eq!(
+ tool_names_for_completion(&completion),
+ vec![
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "delay",
+ "echo",
+ "infinite",
+ "tool_requiring_permission",
+ "unique_tool_1",
+ "unique_tool_2",
+ "word_list",
+ "xxx_echo",
+ "y_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "yyy_echo",
+ "z_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ ]
+ );
+}
+
+#[gpui::test]
+#[cfg_attr(not(feature = "e2e"), ignore)]
+async fn test_cancellation(cx: &mut TestAppContext) {
+ let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await;
+
+ let mut events = thread
+ .update(cx, |thread, cx| {
+ thread.add_tool(InfiniteTool);
+ thread.add_tool(EchoTool);
+ thread.send(
+ UserMessageId::new(),
+ ["Call the echo tool, then call the infinite tool, then explain their output"],
+ cx,
+ )
+ })
+ .unwrap();
+
+ // Wait until both tools are called.
+ let mut expected_tools = vec!["Echo", "Infinite Tool"];
+ let mut echo_id = None;
+ let mut echo_completed = false;
+ while let Some(event) = events.next().await {
+ match event.unwrap() {
+ ThreadEvent::ToolCall(tool_call) => {
+ assert_eq!(tool_call.title, expected_tools.remove(0));
+ if tool_call.title == "Echo" {
+ echo_id = Some(tool_call.id);
+ }
+ }
+ ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(
+ acp::ToolCallUpdate {
+ id,
+ fields:
+ acp::ToolCallUpdateFields {
+ status: Some(acp::ToolCallStatus::Completed),
+ ..
+ },
+ },
+ )) if Some(&id) == echo_id.as_ref() => {
+ echo_completed = true;
+ }
+ _ => {}
+ }
+
+ if expected_tools.is_empty() && echo_completed {
+ break;
+ }
+ }
+
+ // Cancel the current send and ensure that the event stream is closed, even
+ // if one of the tools is still running.
+ thread.update(cx, |thread, cx| thread.cancel(cx));
+ let events = events.collect::<Vec<_>>().await;
+ let last_event = events.last();
+ assert!(
+ matches!(
+ last_event,
+ Some(Ok(ThreadEvent::Stop(acp::StopReason::Cancelled)))
+ ),
+ "unexpected event {last_event:?}"
+ );
+
+ // Ensure we can still send a new message after cancellation.
+ let events = thread
+ .update(cx, |thread, cx| {
+ thread.send(
+ UserMessageId::new(),
+ ["Testing: reply with 'Hello' then stop."],
+ cx,
+ )
+ })
+ .unwrap()
+ .collect::<Vec<_>>()
+ .await;
+ thread.update(cx, |thread, _cx| {
+ let message = thread.last_message().unwrap();
+ let agent_message = message.as_agent_message().unwrap();
+ assert_eq!(
+ agent_message.content,
+ vec![AgentMessageContent::Text("Hello".to_string())]
+ );
+ });
+ assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]);
+}
+
+#[gpui::test]
+async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let events_1 = thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Hello 1"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ fake_model.send_last_completion_stream_text_chunk("Hey 1!");
+ cx.run_until_parked();
+
+ let events_2 = thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Hello 2"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ fake_model.send_last_completion_stream_text_chunk("Hey 2!");
+ fake_model
+ .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
+ fake_model.end_last_completion_stream();
+
+ let events_1 = events_1.collect::<Vec<_>>().await;
+ assert_eq!(stop_events(events_1), vec![acp::StopReason::Cancelled]);
+ let events_2 = events_2.collect::<Vec<_>>().await;
+ assert_eq!(stop_events(events_2), vec![acp::StopReason::EndTurn]);
+}
+
+#[gpui::test]
+async fn test_subsequent_successful_sends_dont_cancel(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let events_1 = thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Hello 1"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ fake_model.send_last_completion_stream_text_chunk("Hey 1!");
+ fake_model
+ .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
+ fake_model.end_last_completion_stream();
+ let events_1 = events_1.collect::<Vec<_>>().await;
+
+ let events_2 = thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Hello 2"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ fake_model.send_last_completion_stream_text_chunk("Hey 2!");
+ fake_model
+ .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
+ fake_model.end_last_completion_stream();
+ let events_2 = events_2.collect::<Vec<_>>().await;
+
+ assert_eq!(stop_events(events_1), vec![acp::StopReason::EndTurn]);
+ assert_eq!(stop_events(events_2), vec![acp::StopReason::EndTurn]);
+}
+
+#[gpui::test]
+async fn test_refusal(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let events = thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Hello"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(
+ thread.to_markdown(),
+ indoc! {"
+ ## User
+
+ Hello
+ "}
+ );
+ });
+
+ fake_model.send_last_completion_stream_text_chunk("Hey!");
+ cx.run_until_parked();
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(
+ thread.to_markdown(),
+ indoc! {"
+ ## User
+
+ Hello
+
+ ## Assistant
+
+ Hey!
+ "}
+ );
+ });
+
+ // If the model refuses to continue, the thread should remove all the messages after the last user message.
+ fake_model
+ .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::Refusal));
+ let events = events.collect::<Vec<_>>().await;
+ assert_eq!(stop_events(events), vec![acp::StopReason::Refusal]);
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(thread.to_markdown(), "");
+ });
+}
+
+#[gpui::test]
+async fn test_truncate_first_message(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let message_id = UserMessageId::new();
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(message_id.clone(), ["Hello"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(
+ thread.to_markdown(),
+ indoc! {"
+ ## User
+
+ Hello
+ "}
+ );
+ assert_eq!(thread.latest_token_usage(), None);
+ });
+
+ fake_model.send_last_completion_stream_text_chunk("Hey!");
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
+ language_model::TokenUsage {
+ input_tokens: 32_000,
+ output_tokens: 16_000,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ },
+ ));
+ cx.run_until_parked();
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(
+ thread.to_markdown(),
+ indoc! {"
+ ## User
+
+ Hello
+
+ ## Assistant
+
+ Hey!
+ "}
+ );
+ assert_eq!(
+ thread.latest_token_usage(),
+ Some(acp_thread::TokenUsage {
+ used_tokens: 32_000 + 16_000,
+ max_tokens: 1_000_000,
+ })
+ );
+ });
+
+ thread
+ .update(cx, |thread, cx| thread.truncate(message_id, cx))
+ .unwrap();
+ cx.run_until_parked();
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(thread.to_markdown(), "");
+ assert_eq!(thread.latest_token_usage(), None);
+ });
+
+ // Ensure we can still send a new message after truncation.
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Hi"], cx)
+ })
+ .unwrap();
+ thread.update(cx, |thread, _cx| {
+ assert_eq!(
+ thread.to_markdown(),
+ indoc! {"
+ ## User
+
+ Hi
+ "}
+ );
+ });
+ cx.run_until_parked();
+ fake_model.send_last_completion_stream_text_chunk("Ahoy!");
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
+ language_model::TokenUsage {
+ input_tokens: 40_000,
+ output_tokens: 20_000,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ },
+ ));
+ cx.run_until_parked();
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(
+ thread.to_markdown(),
+ indoc! {"
+ ## User
+
+ Hi
+
+ ## Assistant
+
+ Ahoy!
+ "}
+ );
+
+ assert_eq!(
+ thread.latest_token_usage(),
+ Some(acp_thread::TokenUsage {
+ used_tokens: 40_000 + 20_000,
+ max_tokens: 1_000_000,
+ })
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_truncate_second_message(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(), ["Message 1"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ fake_model.send_last_completion_stream_text_chunk("Message 1 response");
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
+ language_model::TokenUsage {
+ input_tokens: 32_000,
+ output_tokens: 16_000,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ },
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ let assert_first_message_state = |cx: &mut TestAppContext| {
+ thread.clone().read_with(cx, |thread, _| {
+ assert_eq!(
+ thread.to_markdown(),
+ indoc! {"
+ ## User
+
+ Message 1
+
+ ## Assistant
+
+ Message 1 response
+ "}
+ );
+
+ assert_eq!(
+ thread.latest_token_usage(),
+ Some(acp_thread::TokenUsage {
+ used_tokens: 32_000 + 16_000,
+ max_tokens: 1_000_000,
+ })
+ );
+ });
+ };
+
+ assert_first_message_state(cx);
+
+ let second_message_id = UserMessageId::new();
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(second_message_id.clone(), ["Message 2"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ fake_model.send_last_completion_stream_text_chunk("Message 2 response");
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
+ language_model::TokenUsage {
+ input_tokens: 40_000,
+ output_tokens: 20_000,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ },
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(
+ thread.to_markdown(),
+ indoc! {"
+ ## User
+
+ Message 1
+
+ ## Assistant
+
+ Message 1 response
+
+ ## User
+
+ Message 2
+
+ ## Assistant
+
+ Message 2 response
+ "}
+ );
+
+ assert_eq!(
+ thread.latest_token_usage(),
+ Some(acp_thread::TokenUsage {
+ used_tokens: 40_000 + 20_000,
+ max_tokens: 1_000_000,
+ })
+ );
+ });
+
+ thread
+ .update(cx, |thread, cx| thread.truncate(second_message_id, cx))
+ .unwrap();
+ cx.run_until_parked();
+
+ assert_first_message_state(cx);
+}
+
+#[gpui::test]
+async fn test_title_generation(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let summary_model = Arc::new(FakeLanguageModel::default());
+ 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(), ["Hello"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ fake_model.send_last_completion_stream_text_chunk("Hey!");
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+ thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "New Thread"));
+
+ // Ensure the summary model has been invoked to generate a title.
+ summary_model.send_last_completion_stream_text_chunk("Hello ");
+ summary_model.send_last_completion_stream_text_chunk("world\nG");
+ summary_model.send_last_completion_stream_text_chunk("oodnight Moon");
+ summary_model.end_last_completion_stream();
+ send.collect::<Vec<_>>().await;
+ cx.run_until_parked();
+ thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "Hello world"));
+
+ // Send another message, ensuring no title is generated this time.
+ let send = thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Hello again"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ fake_model.send_last_completion_stream_text_chunk("Hey again!");
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+ assert_eq!(summary_model.pending_completions(), Vec::new());
+ send.collect::<Vec<_>>().await;
+ thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "Hello world"));
+}
+
+#[gpui::test]
+async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let _events = thread
+ .update(cx, |thread, cx| {
+ thread.add_tool(ToolRequiringPermission);
+ thread.add_tool(EchoTool);
+ thread.send(UserMessageId::new(), ["Hey!"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ let permission_tool_use = LanguageModelToolUse {
+ id: "tool_id_1".into(),
+ name: ToolRequiringPermission::name().into(),
+ raw_input: "{}".into(),
+ input: json!({}),
+ is_input_complete: true,
+ };
+ let echo_tool_use = LanguageModelToolUse {
+ id: "tool_id_2".into(),
+ name: EchoTool::name().into(),
+ raw_input: json!({"text": "test"}).to_string(),
+ input: json!({"text": "test"}),
+ is_input_complete: true,
+ };
+ fake_model.send_last_completion_stream_text_chunk("Hi!");
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ permission_tool_use,
+ ));
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ echo_tool_use.clone(),
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ // Ensure pending tools are skipped when building a request.
+ let request = thread
+ .read_with(cx, |thread, cx| {
+ thread.build_completion_request(CompletionIntent::EditFile, cx)
+ })
+ .unwrap();
+ assert_eq!(
+ request.messages[1..],
+ vec![
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["Hey!".into()],
+ cache: true
+ },
+ LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec![
+ MessageContent::Text("Hi!".into()),
+ MessageContent::ToolUse(echo_tool_use.clone())
+ ],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![MessageContent::ToolResult(LanguageModelToolResult {
+ tool_use_id: echo_tool_use.id.clone(),
+ tool_name: echo_tool_use.name,
+ is_error: false,
+ content: "test".into(),
+ output: Some("test".into())
+ })],
+ cache: false
+ },
+ ],
+ );
+}
+
+#[gpui::test]
+async fn test_agent_connection(cx: &mut TestAppContext) {
+ cx.update(settings::init);
+ let templates = Templates::new();
+
+ // Initialize language model system with test provider
+ cx.update(|cx| {
+ gpui_tokio::init(cx);
+ client::init_settings(cx);
+
+ let http_client = FakeHttpClient::with_404_response();
+ let clock = Arc::new(clock::FakeSystemClock::new());
+ let client = Client::new(clock, http_client, cx);
+ let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
+ Project::init_settings(cx);
+ agent_settings::init(cx);
+ language_model::init(client.clone(), cx);
+ language_models::init(user_store, client.clone(), cx);
+ LanguageModelRegistry::test(cx);
+ });
+ cx.executor().forbid_parking();
+
+ // Create a project for new_thread
+ let fake_fs = cx.update(|cx| fs::FakeFs::new(cx.background_executor().clone()));
+ fake_fs.insert_tree(path!("/test"), json!({})).await;
+ let project = Project::test(fake_fs.clone(), [Path::new("/test")], cx).await;
+ let cwd = Path::new("/test");
+ let context_store = cx.new(|cx| assistant_context::ContextStore::fake(project.clone(), cx));
+ let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
+
+ // Create agent and connection
+ let agent = NativeAgent::new(
+ project.clone(),
+ history_store,
+ templates.clone(),
+ None,
+ fake_fs.clone(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ let connection = NativeAgentConnection(agent.clone());
+
+ // Test model_selector returns Some
+ let selector_opt = connection.model_selector();
+ assert!(
+ selector_opt.is_some(),
+ "agent2 should always support ModelSelector"
+ );
+ let selector = selector_opt.unwrap();
+
+ // Test list_models
+ let listed_models = cx
+ .update(|cx| selector.list_models(cx))
+ .await
+ .expect("list_models should succeed");
+ let AgentModelList::Grouped(listed_models) = listed_models else {
+ panic!("Unexpected model list type");
+ };
+ assert!(!listed_models.is_empty(), "should have at least one model");
+ assert_eq!(
+ listed_models[&AgentModelGroupName("Fake".into())][0].id.0,
+ "fake/fake"
+ );
+
+ // Create a thread using new_thread
+ let connection_rc = Rc::new(connection.clone());
+ let acp_thread = cx
+ .update(|cx| connection_rc.new_thread(project, cwd, cx))
+ .await
+ .expect("new_thread should succeed");
+
+ // Get the session_id from the AcpThread
+ let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
+
+ // Test selected_model returns the default
+ let model = cx
+ .update(|cx| selector.selected_model(&session_id, cx))
+ .await
+ .expect("selected_model should succeed");
+ let model = cx
+ .update(|cx| agent.read(cx).models().model_from_id(&model.id))
+ .unwrap();
+ let model = model.as_fake();
+ assert_eq!(model.id().0, "fake", "should return default model");
+
+ let request = acp_thread.update(cx, |thread, cx| thread.send(vec!["abc".into()], cx));
+ cx.run_until_parked();
+ model.send_last_completion_stream_text_chunk("def");
+ cx.run_until_parked();
+ acp_thread.read_with(cx, |thread, cx| {
+ assert_eq!(
+ thread.to_markdown(cx),
+ indoc! {"
+ ## User
+
+ abc
+
+ ## Assistant
+
+ def
+
+ "}
+ )
+ });
+
+ // Test cancel
+ cx.update(|cx| connection.cancel(&session_id, cx));
+ request.await.expect("prompt should fail gracefully");
+
+ // Ensure that dropping the ACP thread causes the native thread to be
+ // dropped as well.
+ cx.update(|_| drop(acp_thread));
+ let result = cx
+ .update(|cx| {
+ connection.prompt(
+ Some(acp_thread::UserMessageId::new()),
+ acp::PromptRequest {
+ session_id: session_id.clone(),
+ prompt: vec!["ghi".into()],
+ },
+ cx,
+ )
+ })
+ .await;
+ assert_eq!(
+ result.as_ref().unwrap_err().to_string(),
+ "Session not found",
+ "unexpected result: {:?}",
+ result
+ );
+}
+
+#[gpui::test]
+async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
+ let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
+ thread.update(cx, |thread, _cx| thread.add_tool(ThinkingTool));
+ let fake_model = model.as_fake();
+
+ let mut events = thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Think"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ // Simulate streaming partial input.
+ let input = json!({});
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: "1".into(),
+ name: ThinkingTool::name().into(),
+ raw_input: input.to_string(),
+ input,
+ is_input_complete: false,
+ },
+ ));
+
+ // Input streaming completed
+ let input = json!({ "content": "Thinking hard!" });
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: "1".into(),
+ name: "thinking".into(),
+ raw_input: input.to_string(),
+ input,
+ is_input_complete: true,
+ },
+ ));
+ 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 {
+ id: acp::ToolCallId("1".into()),
+ title: "Thinking".into(),
+ kind: acp::ToolKind::Think,
+ status: acp::ToolCallStatus::Pending,
+ content: vec![],
+ locations: vec![],
+ raw_input: Some(json!({})),
+ raw_output: None,
+ }
+ );
+ let update = expect_tool_call_update_fields(&mut events).await;
+ assert_eq!(
+ update,
+ acp::ToolCallUpdate {
+ id: acp::ToolCallId("1".into()),
+ fields: acp::ToolCallUpdateFields {
+ title: Some("Thinking".into()),
+ kind: Some(acp::ToolKind::Think),
+ raw_input: Some(json!({ "content": "Thinking hard!" })),
+ ..Default::default()
+ },
+ }
+ );
+ let update = expect_tool_call_update_fields(&mut events).await;
+ assert_eq!(
+ update,
+ acp::ToolCallUpdate {
+ id: acp::ToolCallId("1".into()),
+ fields: acp::ToolCallUpdateFields {
+ status: Some(acp::ToolCallStatus::InProgress),
+ ..Default::default()
+ },
+ }
+ );
+ let update = expect_tool_call_update_fields(&mut events).await;
+ assert_eq!(
+ update,
+ acp::ToolCallUpdate {
+ id: acp::ToolCallId("1".into()),
+ fields: acp::ToolCallUpdateFields {
+ content: Some(vec!["Thinking hard!".into()]),
+ ..Default::default()
+ },
+ }
+ );
+ let update = expect_tool_call_update_fields(&mut events).await;
+ assert_eq!(
+ update,
+ acp::ToolCallUpdate {
+ id: acp::ToolCallId("1".into()),
+ fields: acp::ToolCallUpdateFields {
+ status: Some(acp::ToolCallStatus::Completed),
+ raw_output: Some("Finished thinking.".into()),
+ ..Default::default()
+ },
+ }
+ );
+}
+
+#[gpui::test]
+async fn test_send_no_retry_on_success(cx: &mut TestAppContext) {
+ let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let mut events = thread
+ .update(cx, |thread, cx| {
+ thread.set_completion_mode(agent_settings::CompletionMode::Burn, cx);
+ thread.send(UserMessageId::new(), ["Hello!"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ fake_model.send_last_completion_stream_text_chunk("Hey!");
+ fake_model.end_last_completion_stream();
+
+ let mut retry_events = Vec::new();
+ while let Some(Ok(event)) = events.next().await {
+ match event {
+ ThreadEvent::Retry(retry_status) => {
+ retry_events.push(retry_status);
+ }
+ ThreadEvent::Stop(..) => break,
+ _ => {}
+ }
+ }
+
+ assert_eq!(retry_events.len(), 0);
+ thread.read_with(cx, |thread, _cx| {
+ assert_eq!(
+ thread.to_markdown(),
+ indoc! {"
+ ## User
+
+ Hello!
+
+ ## Assistant
+
+ Hey!
+ "}
+ )
+ });
+}
+
+#[gpui::test]
+async fn test_send_retry_on_error(cx: &mut TestAppContext) {
+ let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let mut events = thread
+ .update(cx, |thread, cx| {
+ thread.set_completion_mode(agent_settings::CompletionMode::Burn, cx);
+ thread.send(UserMessageId::new(), ["Hello!"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ fake_model.send_last_completion_stream_text_chunk("Hey,");
+ fake_model.send_last_completion_stream_error(LanguageModelCompletionError::ServerOverloaded {
+ provider: LanguageModelProviderName::new("Anthropic"),
+ retry_after: Some(Duration::from_secs(3)),
+ });
+ fake_model.end_last_completion_stream();
+
+ cx.executor().advance_clock(Duration::from_secs(3));
+ cx.run_until_parked();
+
+ fake_model.send_last_completion_stream_text_chunk("there!");
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ let mut retry_events = Vec::new();
+ while let Some(Ok(event)) = events.next().await {
+ match event {
+ ThreadEvent::Retry(retry_status) => {
+ retry_events.push(retry_status);
+ }
+ ThreadEvent::Stop(..) => break,
+ _ => {}
+ }
+ }
+
+ assert_eq!(retry_events.len(), 1);
+ assert!(matches!(
+ retry_events[0],
+ acp_thread::RetryStatus { attempt: 1, .. }
+ ));
+ thread.read_with(cx, |thread, _cx| {
+ assert_eq!(
+ thread.to_markdown(),
+ indoc! {"
+ ## User
+
+ Hello!
+
+ ## Assistant
+
+ Hey,
+
+ [resume]
+
+ ## Assistant
+
+ there!
+ "}
+ )
+ });
+}
+
+#[gpui::test]
+async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) {
+ let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let events = thread
+ .update(cx, |thread, cx| {
+ thread.set_completion_mode(agent_settings::CompletionMode::Burn, cx);
+ thread.add_tool(EchoTool);
+ thread.send(UserMessageId::new(), ["Call the echo tool!"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ let tool_use_1 = LanguageModelToolUse {
+ id: "tool_1".into(),
+ name: EchoTool::name().into(),
+ raw_input: json!({"text": "test"}).to_string(),
+ input: json!({"text": "test"}),
+ is_input_complete: true,
+ };
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ tool_use_1.clone(),
+ ));
+ fake_model.send_last_completion_stream_error(LanguageModelCompletionError::ServerOverloaded {
+ provider: LanguageModelProviderName::new("Anthropic"),
+ retry_after: Some(Duration::from_secs(3)),
+ });
+ fake_model.end_last_completion_stream();
+
+ cx.executor().advance_clock(Duration::from_secs(3));
+ let completion = fake_model.pending_completions().pop().unwrap();
+ assert_eq!(
+ completion.messages[1..],
+ vec![
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["Call the echo tool!".into()],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec![language_model::MessageContent::ToolUse(tool_use_1.clone())],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![language_model::MessageContent::ToolResult(
+ LanguageModelToolResult {
+ tool_use_id: tool_use_1.id.clone(),
+ tool_name: tool_use_1.name.clone(),
+ is_error: false,
+ content: "test".into(),
+ output: Some("test".into())
+ }
+ )],
+ cache: true
+ },
+ ]
+ );
+
+ fake_model.send_last_completion_stream_text_chunk("Done");
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+ events.collect::<Vec<_>>().await;
+ thread.read_with(cx, |thread, _cx| {
+ assert_eq!(
+ thread.last_message(),
+ Some(Message::Agent(AgentMessage {
+ content: vec![AgentMessageContent::Text("Done".into())],
+ tool_results: IndexMap::default()
+ }))
+ );
+ })
+}
+
+#[gpui::test]
+async fn test_send_max_retries_exceeded(cx: &mut TestAppContext) {
+ let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let mut events = thread
+ .update(cx, |thread, cx| {
+ thread.set_completion_mode(agent_settings::CompletionMode::Burn, cx);
+ thread.send(UserMessageId::new(), ["Hello!"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ for _ in 0..crate::thread::MAX_RETRY_ATTEMPTS + 1 {
+ fake_model.send_last_completion_stream_error(
+ LanguageModelCompletionError::ServerOverloaded {
+ provider: LanguageModelProviderName::new("Anthropic"),
+ retry_after: Some(Duration::from_secs(3)),
+ },
+ );
+ fake_model.end_last_completion_stream();
+ cx.executor().advance_clock(Duration::from_secs(3));
+ cx.run_until_parked();
+ }
+
+ let mut errors = Vec::new();
+ let mut retry_events = Vec::new();
+ while let Some(event) = events.next().await {
+ match event {
+ Ok(ThreadEvent::Retry(retry_status)) => {
+ retry_events.push(retry_status);
+ }
+ Ok(ThreadEvent::Stop(..)) => break,
+ Err(error) => errors.push(error),
+ _ => {}
+ }
+ }
+
+ assert_eq!(
+ retry_events.len(),
+ crate::thread::MAX_RETRY_ATTEMPTS as usize
+ );
+ for i in 0..crate::thread::MAX_RETRY_ATTEMPTS as usize {
+ assert_eq!(retry_events[i].attempt, i + 1);
+ }
+ assert_eq!(errors.len(), 1);
+ let error = errors[0]
+ .downcast_ref::<LanguageModelCompletionError>()
+ .unwrap();
+ assert!(matches!(
+ error,
+ LanguageModelCompletionError::ServerOverloaded { .. }
+ ));
+}
+
+/// Filters out the stop events for asserting against in tests
+fn stop_events(result_events: Vec<Result<ThreadEvent>>) -> Vec<acp::StopReason> {
+ result_events
+ .into_iter()
+ .filter_map(|event| match event.unwrap() {
+ ThreadEvent::Stop(stop_reason) => Some(stop_reason),
+ _ => None,
+ })
+ .collect()
+}
+
+struct ThreadTest {
+ model: Arc<dyn LanguageModel>,
+ thread: Entity<Thread>,
+ project_context: Entity<ProjectContext>,
+ context_server_store: Entity<ContextServerStore>,
+ fs: Arc<FakeFs>,
+}
+
+enum TestModel {
+ Sonnet4,
+ Fake,
+}
+
+impl TestModel {
+ fn id(&self) -> LanguageModelId {
+ match self {
+ TestModel::Sonnet4 => LanguageModelId("claude-sonnet-4-latest".into()),
+ TestModel::Fake => unreachable!(),
+ }
+ }
+}
+
+async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
+ cx.executor().allow_parking();
+
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.create_dir(paths::settings_file().parent().unwrap())
+ .await
+ .unwrap();
+ fs.insert_file(
+ paths::settings_file(),
+ json!({
+ "agent": {
+ "default_profile": "test-profile",
+ "profiles": {
+ "test-profile": {
+ "name": "Test Profile",
+ "tools": {
+ EchoTool::name(): true,
+ DelayTool::name(): true,
+ WordListTool::name(): true,
+ ToolRequiringPermission::name(): true,
+ InfiniteTool::name(): true,
+ ThinkingTool::name(): true,
+ }
+ }
+ }
+ }
+ })
+ .to_string()
+ .into_bytes(),
+ )
+ .await;
+
+ cx.update(|cx| {
+ settings::init(cx);
+ Project::init_settings(cx);
+ agent_settings::init(cx);
+ gpui_tokio::init(cx);
+ let http_client = ReqwestClient::user_agent("agent tests").unwrap();
+ cx.set_http_client(Arc::new(http_client));
+
+ client::init_settings(cx);
+ let client = Client::production(cx);
+ let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
+ language_model::init(client.clone(), cx);
+ language_models::init(user_store, client.clone(), cx);
+
+ watch_settings(fs.clone(), cx);
+ });
+
+ let templates = Templates::new();
+
+ fs.insert_tree(path!("/test"), json!({})).await;
+ let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
+
+ let model = cx
+ .update(|cx| {
+ if let TestModel::Fake = model {
+ Task::ready(Arc::new(FakeLanguageModel::default()) as Arc<_>)
+ } else {
+ let model_id = model.id();
+ let models = LanguageModelRegistry::read_global(cx);
+ let model = models
+ .available_models(cx)
+ .find(|model| model.id() == model_id)
+ .unwrap();
+
+ let provider = models.provider(&model.provider_id()).unwrap();
+ let authenticated = provider.authenticate(cx);
+
+ cx.spawn(async move |_cx| {
+ authenticated.await.unwrap();
+ model
+ })
+ }
+ })
+ .await;
+
+ let project_context = cx.new(|_cx| ProjectContext::default());
+ let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
+ let thread = cx.new(|cx| {
+ Thread::new(
+ project,
+ project_context.clone(),
+ context_server_registry,
+ templates,
+ Some(model.clone()),
+ cx,
+ )
+ });
+ ThreadTest {
+ model,
+ thread,
+ project_context,
+ context_server_store,
+ fs,
+ }
+}
+
+#[cfg(test)]
+#[ctor::ctor]
+fn init_logger() {
+ if std::env::var("RUST_LOG").is_ok() {
+ env_logger::init();
+ }
+}
+
+fn watch_settings(fs: Arc<dyn Fs>, cx: &mut App) {
+ let fs = fs.clone();
+ cx.spawn({
+ async move |cx| {
+ let mut new_settings_content_rx = settings::watch_config_file(
+ cx.background_executor(),
+ fs,
+ paths::settings_file().clone(),
+ );
+
+ while let Some(new_settings_content) = new_settings_content_rx.next().await {
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |settings, cx| {
+ settings.set_user_settings(&new_settings_content, cx)
+ })
+ })
+ .ok();
+ }
+ }
+ })
+ .detach();
+}
+
+fn tool_names_for_completion(completion: &LanguageModelRequest) -> Vec<String> {
+ completion
+ .tools
+ .iter()
+ .map(|tool| tool.name.clone())
+ .collect()
+}
+
+fn setup_context_server(
+ name: &'static str,
+ tools: Vec<context_server::types::Tool>,
+ context_server_store: &Entity<ContextServerStore>,
+ cx: &mut TestAppContext,
+) -> mpsc::UnboundedReceiver<(
+ context_server::types::CallToolParams,
+ oneshot::Sender<context_server::types::CallToolResponse>,
+)> {
+ cx.update(|cx| {
+ let mut settings = ProjectSettings::get_global(cx).clone();
+ settings.context_servers.insert(
+ name.into(),
+ project::project_settings::ContextServerSettings::Custom {
+ enabled: true,
+ command: ContextServerCommand {
+ path: "somebinary".into(),
+ args: Vec::new(),
+ env: None,
+ },
+ },
+ );
+ ProjectSettings::override_global(settings, cx);
+ });
+
+ let (mcp_tool_calls_tx, mcp_tool_calls_rx) = mpsc::unbounded();
+ let fake_transport = context_server::test::create_fake_transport(name, cx.executor())
+ .on_request::<context_server::types::requests::Initialize, _>(move |_params| async move {
+ context_server::types::InitializeResponse {
+ protocol_version: context_server::types::ProtocolVersion(
+ context_server::types::LATEST_PROTOCOL_VERSION.to_string(),
+ ),
+ server_info: context_server::types::Implementation {
+ name: name.into(),
+ version: "1.0.0".to_string(),
+ },
+ capabilities: context_server::types::ServerCapabilities {
+ tools: Some(context_server::types::ToolsCapabilities {
+ list_changed: Some(true),
+ }),
+ ..Default::default()
+ },
+ meta: None,
+ }
+ })
+ .on_request::<context_server::types::requests::ListTools, _>(move |_params| {
+ let tools = tools.clone();
+ async move {
+ context_server::types::ListToolsResponse {
+ tools,
+ next_cursor: None,
+ meta: None,
+ }
+ }
+ })
+ .on_request::<context_server::types::requests::CallTool, _>(move |params| {
+ let mcp_tool_calls_tx = mcp_tool_calls_tx.clone();
+ async move {
+ let (response_tx, response_rx) = oneshot::channel();
+ mcp_tool_calls_tx
+ .unbounded_send((params, response_tx))
+ .unwrap();
+ response_rx.await.unwrap()
+ }
+ });
+ context_server_store.update(cx, |store, cx| {
+ store.start_server(
+ Arc::new(ContextServer::new(
+ ContextServerId(name.into()),
+ Arc::new(fake_transport),
+ )),
+ cx,
+ );
+ });
+ cx.run_until_parked();
+ mcp_tool_calls_rx
+}
@@ -0,0 +1,201 @@
+use super::*;
+use anyhow::Result;
+use gpui::{App, SharedString, Task};
+use std::future;
+
+/// A tool that echoes its input
+#[derive(JsonSchema, Serialize, Deserialize)]
+pub struct EchoToolInput {
+ /// The text to echo.
+ pub text: String,
+}
+
+pub struct EchoTool;
+
+impl AgentTool for EchoTool {
+ type Input = EchoToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "echo"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Other
+ }
+
+ fn initial_title(&self, _input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ "Echo".into()
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ _cx: &mut App,
+ ) -> Task<Result<String>> {
+ Task::ready(Ok(input.text))
+ }
+}
+
+/// A tool that waits for a specified delay
+#[derive(JsonSchema, Serialize, Deserialize)]
+pub struct DelayToolInput {
+ /// The delay in milliseconds.
+ ms: u64,
+}
+
+pub struct DelayTool;
+
+impl AgentTool for DelayTool {
+ type Input = DelayToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "delay"
+ }
+
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ if let Ok(input) = input {
+ format!("Delay {}ms", input.ms).into()
+ } else {
+ "Delay".into()
+ }
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Other
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<String>>
+ where
+ Self: Sized,
+ {
+ cx.foreground_executor().spawn(async move {
+ smol::Timer::after(Duration::from_millis(input.ms)).await;
+ Ok("Ding".to_string())
+ })
+ }
+}
+
+#[derive(JsonSchema, Serialize, Deserialize)]
+pub struct ToolRequiringPermissionInput {}
+
+pub struct ToolRequiringPermission;
+
+impl AgentTool for ToolRequiringPermission {
+ type Input = ToolRequiringPermissionInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "tool_requiring_permission"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Other
+ }
+
+ fn initial_title(&self, _input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ "This tool requires permission".into()
+ }
+
+ fn run(
+ self: Arc<Self>,
+ _input: Self::Input,
+ event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<String>> {
+ let authorize = event_stream.authorize("Authorize?", cx);
+ cx.foreground_executor().spawn(async move {
+ authorize.await?;
+ Ok("Allowed".to_string())
+ })
+ }
+}
+
+#[derive(JsonSchema, Serialize, Deserialize)]
+pub struct InfiniteToolInput {}
+
+pub struct InfiniteTool;
+
+impl AgentTool for InfiniteTool {
+ type Input = InfiniteToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "infinite"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Other
+ }
+
+ fn initial_title(&self, _input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ "Infinite Tool".into()
+ }
+
+ fn run(
+ self: Arc<Self>,
+ _input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<String>> {
+ cx.foreground_executor().spawn(async move {
+ future::pending::<()>().await;
+ unreachable!()
+ })
+ }
+}
+
+/// A tool that takes an object with map from letters to random words starting with that letter.
+/// All fiealds are required! Pass a word for every letter!
+#[derive(JsonSchema, Serialize, Deserialize)]
+pub struct WordListInput {
+ /// Provide a random word that starts with A.
+ a: Option<String>,
+ /// Provide a random word that starts with B.
+ b: Option<String>,
+ /// Provide a random word that starts with C.
+ c: Option<String>,
+ /// Provide a random word that starts with D.
+ d: Option<String>,
+ /// Provide a random word that starts with E.
+ e: Option<String>,
+ /// Provide a random word that starts with F.
+ f: Option<String>,
+ /// Provide a random word that starts with G.
+ g: Option<String>,
+}
+
+pub struct WordListTool;
+
+impl AgentTool for WordListTool {
+ type Input = WordListInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "word_list"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Other
+ }
+
+ fn initial_title(&self, _input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ "List of random words".into()
+ }
+
+ fn run(
+ self: Arc<Self>,
+ _input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ _cx: &mut App,
+ ) -> Task<Result<String>> {
+ Task::ready(Ok("ok".to_string()))
+ }
+}
@@ -0,0 +1,2578 @@
+use crate::{
+ ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread,
+ DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool,
+ ListDirectoryTool, MovePathTool, NowTool, OpenTool, ReadFileTool, SystemPromptTemplate,
+ Template, Templates, TerminalTool, ThinkingTool, WebSearchTool,
+};
+use acp_thread::{MentionUri, UserMessageId};
+use action_log::ActionLog;
+use agent::thread::{GitState, ProjectSnapshot, WorktreeSnapshot};
+use agent_client_protocol as acp;
+use agent_settings::{
+ AgentProfileId, AgentProfileSettings, AgentSettings, CompletionMode,
+ SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT,
+};
+use anyhow::{Context as _, Result, anyhow};
+use assistant_tool::adapt_schema_to_format;
+use chrono::{DateTime, Utc};
+use client::{ModelRequestUsage, RequestUsage};
+use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit};
+use collections::{HashMap, HashSet, IndexMap};
+use fs::Fs;
+use futures::{
+ FutureExt,
+ channel::{mpsc, oneshot},
+ future::Shared,
+ stream::FuturesUnordered,
+};
+use git::repository::DiffType;
+use gpui::{
+ App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity,
+};
+use language_model::{
+ LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelExt,
+ LanguageModelImage, LanguageModelProviderId, LanguageModelRegistry, LanguageModelRequest,
+ LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult,
+ LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse,
+ LanguageModelToolUseId, Role, SelectedModel, StopReason, TokenUsage,
+};
+use project::{
+ Project,
+ git_store::{GitStore, RepositoryState},
+};
+use prompt_store::ProjectContext;
+use schemars::{JsonSchema, Schema};
+use serde::{Deserialize, Serialize};
+use settings::{Settings, update_settings_file};
+use smol::stream::StreamExt;
+use std::fmt::Write;
+use std::{
+ collections::BTreeMap,
+ ops::RangeInclusive,
+ path::Path,
+ sync::Arc,
+ time::{Duration, Instant},
+};
+use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock};
+use uuid::Uuid;
+
+const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user";
+pub const MAX_TOOL_NAME_LENGTH: usize = 64;
+
+/// The ID of the user prompt that initiated a request.
+///
+/// This equates to the user physically submitting a message to the model (e.g., by pressing the Enter key).
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)]
+pub struct PromptId(Arc<str>);
+
+impl PromptId {
+ pub fn new() -> Self {
+ Self(Uuid::new_v4().to_string().into())
+ }
+}
+
+impl std::fmt::Display for PromptId {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+pub(crate) const MAX_RETRY_ATTEMPTS: u8 = 4;
+pub(crate) const BASE_RETRY_DELAY: Duration = Duration::from_secs(5);
+
+#[derive(Debug, Clone)]
+enum RetryStrategy {
+ ExponentialBackoff {
+ initial_delay: Duration,
+ max_attempts: u8,
+ },
+ Fixed {
+ delay: Duration,
+ max_attempts: u8,
+ },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Message {
+ User(UserMessage),
+ Agent(AgentMessage),
+ Resume,
+}
+
+impl Message {
+ pub fn as_agent_message(&self) -> Option<&AgentMessage> {
+ match self {
+ Message::Agent(agent_message) => Some(agent_message),
+ _ => None,
+ }
+ }
+
+ pub fn to_request(&self) -> Vec<LanguageModelRequestMessage> {
+ match self {
+ Message::User(message) => vec![message.to_request()],
+ Message::Agent(message) => message.to_request(),
+ Message::Resume => vec![LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["Continue where you left off".into()],
+ cache: false,
+ }],
+ }
+ }
+
+ pub fn to_markdown(&self) -> String {
+ match self {
+ Message::User(message) => message.to_markdown(),
+ Message::Agent(message) => message.to_markdown(),
+ Message::Resume => "[resume]\n".into(),
+ }
+ }
+
+ pub fn role(&self) -> Role {
+ match self {
+ Message::User(_) | Message::Resume => Role::User,
+ Message::Agent(_) => Role::Assistant,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct UserMessage {
+ pub id: UserMessageId,
+ pub content: Vec<UserMessageContent>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum UserMessageContent {
+ Text(String),
+ Mention { uri: MentionUri, content: String },
+ Image(LanguageModelImage),
+}
+
+impl UserMessage {
+ pub fn to_markdown(&self) -> String {
+ let mut markdown = String::from("## User\n\n");
+
+ for content in &self.content {
+ match content {
+ UserMessageContent::Text(text) => {
+ markdown.push_str(text);
+ markdown.push('\n');
+ }
+ UserMessageContent::Image(_) => {
+ markdown.push_str("<image />\n");
+ }
+ UserMessageContent::Mention { uri, content } => {
+ if !content.is_empty() {
+ let _ = writeln!(&mut markdown, "{}\n\n{}", uri.as_link(), content);
+ } else {
+ let _ = writeln!(&mut markdown, "{}", uri.as_link());
+ }
+ }
+ }
+ }
+
+ markdown
+ }
+
+ fn to_request(&self) -> LanguageModelRequestMessage {
+ let mut message = LanguageModelRequestMessage {
+ role: Role::User,
+ content: Vec::with_capacity(self.content.len()),
+ cache: false,
+ };
+
+ const OPEN_CONTEXT: &str = "<context>\n\
+ The following items were attached by the user. \
+ They are up-to-date and don't need to be re-read.\n\n";
+
+ const OPEN_FILES_TAG: &str = "<files>";
+ const OPEN_DIRECTORIES_TAG: &str = "<directories>";
+ const OPEN_SYMBOLS_TAG: &str = "<symbols>";
+ const OPEN_SELECTIONS_TAG: &str = "<selections>";
+ const OPEN_THREADS_TAG: &str = "<threads>";
+ const OPEN_FETCH_TAG: &str = "<fetched_urls>";
+ const OPEN_RULES_TAG: &str =
+ "<rules>\nThe user has specified the following rules that should be applied:\n";
+
+ let mut file_context = OPEN_FILES_TAG.to_string();
+ let mut directory_context = OPEN_DIRECTORIES_TAG.to_string();
+ let mut symbol_context = OPEN_SYMBOLS_TAG.to_string();
+ let mut selection_context = OPEN_SELECTIONS_TAG.to_string();
+ let mut thread_context = OPEN_THREADS_TAG.to_string();
+ let mut fetch_context = OPEN_FETCH_TAG.to_string();
+ let mut rules_context = OPEN_RULES_TAG.to_string();
+
+ for chunk in &self.content {
+ let chunk = match chunk {
+ UserMessageContent::Text(text) => {
+ language_model::MessageContent::Text(text.clone())
+ }
+ UserMessageContent::Image(value) => {
+ language_model::MessageContent::Image(value.clone())
+ }
+ UserMessageContent::Mention { uri, content } => {
+ match uri {
+ MentionUri::File { abs_path } => {
+ write!(
+ &mut file_context,
+ "\n{}",
+ MarkdownCodeBlock {
+ tag: &codeblock_tag(abs_path, None),
+ text: &content.to_string(),
+ }
+ )
+ .ok();
+ }
+ MentionUri::PastedImage => {
+ debug_panic!("pasted image URI should not be used in mention content")
+ }
+ MentionUri::Directory { .. } => {
+ write!(&mut directory_context, "\n{}\n", content).ok();
+ }
+ MentionUri::Symbol {
+ abs_path: path,
+ line_range,
+ ..
+ } => {
+ write!(
+ &mut symbol_context,
+ "\n{}",
+ MarkdownCodeBlock {
+ tag: &codeblock_tag(path, Some(line_range)),
+ text: content
+ }
+ )
+ .ok();
+ }
+ MentionUri::Selection {
+ abs_path: path,
+ line_range,
+ ..
+ } => {
+ write!(
+ &mut selection_context,
+ "\n{}",
+ MarkdownCodeBlock {
+ tag: &codeblock_tag(
+ path.as_deref().unwrap_or("Untitled".as_ref()),
+ Some(line_range)
+ ),
+ text: content
+ }
+ )
+ .ok();
+ }
+ MentionUri::Thread { .. } => {
+ write!(&mut thread_context, "\n{}\n", content).ok();
+ }
+ MentionUri::TextThread { .. } => {
+ write!(&mut thread_context, "\n{}\n", content).ok();
+ }
+ MentionUri::Rule { .. } => {
+ write!(
+ &mut rules_context,
+ "\n{}",
+ MarkdownCodeBlock {
+ tag: "",
+ text: content
+ }
+ )
+ .ok();
+ }
+ MentionUri::Fetch { url } => {
+ write!(&mut fetch_context, "\nFetch: {}\n\n{}", url, content).ok();
+ }
+ }
+
+ language_model::MessageContent::Text(uri.as_link().to_string())
+ }
+ };
+
+ message.content.push(chunk);
+ }
+
+ let len_before_context = message.content.len();
+
+ if file_context.len() > OPEN_FILES_TAG.len() {
+ file_context.push_str("</files>\n");
+ message
+ .content
+ .push(language_model::MessageContent::Text(file_context));
+ }
+
+ if directory_context.len() > OPEN_DIRECTORIES_TAG.len() {
+ directory_context.push_str("</directories>\n");
+ message
+ .content
+ .push(language_model::MessageContent::Text(directory_context));
+ }
+
+ if symbol_context.len() > OPEN_SYMBOLS_TAG.len() {
+ symbol_context.push_str("</symbols>\n");
+ message
+ .content
+ .push(language_model::MessageContent::Text(symbol_context));
+ }
+
+ if selection_context.len() > OPEN_SELECTIONS_TAG.len() {
+ selection_context.push_str("</selections>\n");
+ message
+ .content
+ .push(language_model::MessageContent::Text(selection_context));
+ }
+
+ if thread_context.len() > OPEN_THREADS_TAG.len() {
+ thread_context.push_str("</threads>\n");
+ message
+ .content
+ .push(language_model::MessageContent::Text(thread_context));
+ }
+
+ if fetch_context.len() > OPEN_FETCH_TAG.len() {
+ fetch_context.push_str("</fetched_urls>\n");
+ message
+ .content
+ .push(language_model::MessageContent::Text(fetch_context));
+ }
+
+ if rules_context.len() > OPEN_RULES_TAG.len() {
+ rules_context.push_str("</user_rules>\n");
+ message
+ .content
+ .push(language_model::MessageContent::Text(rules_context));
+ }
+
+ if message.content.len() > len_before_context {
+ message.content.insert(
+ len_before_context,
+ language_model::MessageContent::Text(OPEN_CONTEXT.into()),
+ );
+ message
+ .content
+ .push(language_model::MessageContent::Text("</context>".into()));
+ }
+
+ message
+ }
+}
+
+fn codeblock_tag(full_path: &Path, line_range: Option<&RangeInclusive<u32>>) -> String {
+ let mut result = String::new();
+
+ if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) {
+ let _ = write!(result, "{} ", extension);
+ }
+
+ let _ = write!(result, "{}", full_path.display());
+
+ if let Some(range) = line_range {
+ if range.start() == range.end() {
+ let _ = write!(result, ":{}", range.start() + 1);
+ } else {
+ let _ = write!(result, ":{}-{}", range.start() + 1, range.end() + 1);
+ }
+ }
+
+ result
+}
+
+impl AgentMessage {
+ pub fn to_markdown(&self) -> String {
+ let mut markdown = String::from("## Assistant\n\n");
+
+ for content in &self.content {
+ match content {
+ AgentMessageContent::Text(text) => {
+ markdown.push_str(text);
+ markdown.push('\n');
+ }
+ AgentMessageContent::Thinking { text, .. } => {
+ markdown.push_str("<think>");
+ markdown.push_str(text);
+ markdown.push_str("</think>\n");
+ }
+ AgentMessageContent::RedactedThinking(_) => {
+ markdown.push_str("<redacted_thinking />\n")
+ }
+ AgentMessageContent::ToolUse(tool_use) => {
+ markdown.push_str(&format!(
+ "**Tool Use**: {} (ID: {})\n",
+ tool_use.name, tool_use.id
+ ));
+ markdown.push_str(&format!(
+ "{}\n",
+ MarkdownCodeBlock {
+ tag: "json",
+ text: &format!("{:#}", tool_use.input)
+ }
+ ));
+ }
+ }
+ }
+
+ for tool_result in self.tool_results.values() {
+ markdown.push_str(&format!(
+ "**Tool Result**: {} (ID: {})\n\n",
+ tool_result.tool_name, tool_result.tool_use_id
+ ));
+ if tool_result.is_error {
+ markdown.push_str("**ERROR:**\n");
+ }
+
+ match &tool_result.content {
+ LanguageModelToolResultContent::Text(text) => {
+ writeln!(markdown, "{text}\n").ok();
+ }
+ LanguageModelToolResultContent::Image(_) => {
+ writeln!(markdown, "<image />\n").ok();
+ }
+ }
+
+ if let Some(output) = tool_result.output.as_ref() {
+ writeln!(
+ markdown,
+ "**Debug Output**:\n\n```json\n{}\n```\n",
+ serde_json::to_string_pretty(output).unwrap()
+ )
+ .unwrap();
+ }
+ }
+
+ markdown
+ }
+
+ pub fn to_request(&self) -> Vec<LanguageModelRequestMessage> {
+ let mut assistant_message = LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: Vec::with_capacity(self.content.len()),
+ cache: false,
+ };
+ for chunk in &self.content {
+ match chunk {
+ AgentMessageContent::Text(text) => {
+ assistant_message
+ .content
+ .push(language_model::MessageContent::Text(text.clone()));
+ }
+ AgentMessageContent::Thinking { text, signature } => {
+ assistant_message
+ .content
+ .push(language_model::MessageContent::Thinking {
+ text: text.clone(),
+ signature: signature.clone(),
+ });
+ }
+ AgentMessageContent::RedactedThinking(value) => {
+ assistant_message.content.push(
+ language_model::MessageContent::RedactedThinking(value.clone()),
+ );
+ }
+ AgentMessageContent::ToolUse(tool_use) => {
+ if self.tool_results.contains_key(&tool_use.id) {
+ assistant_message
+ .content
+ .push(language_model::MessageContent::ToolUse(tool_use.clone()));
+ }
+ }
+ };
+ }
+
+ let mut user_message = LanguageModelRequestMessage {
+ role: Role::User,
+ content: Vec::new(),
+ cache: false,
+ };
+
+ for tool_result in self.tool_results.values() {
+ user_message
+ .content
+ .push(language_model::MessageContent::ToolResult(
+ tool_result.clone(),
+ ));
+ }
+
+ let mut messages = Vec::new();
+ if !assistant_message.content.is_empty() {
+ messages.push(assistant_message);
+ }
+ if !user_message.content.is_empty() {
+ messages.push(user_message);
+ }
+ messages
+ }
+}
+
+#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentMessage {
+ pub content: Vec<AgentMessageContent>,
+ pub tool_results: IndexMap<LanguageModelToolUseId, LanguageModelToolResult>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum AgentMessageContent {
+ Text(String),
+ Thinking {
+ text: String,
+ signature: Option<String>,
+ },
+ RedactedThinking(String),
+ ToolUse(LanguageModelToolUse),
+}
+
+#[derive(Debug)]
+pub enum ThreadEvent {
+ UserMessage(UserMessage),
+ AgentText(String),
+ AgentThinking(String),
+ ToolCall(acp::ToolCall),
+ ToolCallUpdate(acp_thread::ToolCallUpdate),
+ ToolCallAuthorization(ToolCallAuthorization),
+ Retry(acp_thread::RetryStatus),
+ Stop(acp::StopReason),
+}
+
+#[derive(Debug)]
+pub struct ToolCallAuthorization {
+ pub tool_call: acp::ToolCallUpdate,
+ pub options: Vec<acp::PermissionOption>,
+ pub response: oneshot::Sender<acp::PermissionOptionId>,
+}
+
+#[derive(Debug, thiserror::Error)]
+enum CompletionError {
+ #[error("max tokens")]
+ MaxTokens,
+ #[error("refusal")]
+ Refusal,
+ #[error(transparent)]
+ Other(#[from] anyhow::Error),
+}
+
+pub struct Thread {
+ id: acp::SessionId,
+ prompt_id: PromptId,
+ updated_at: DateTime<Utc>,
+ title: Option<SharedString>,
+ pending_title_generation: Option<Task<()>>,
+ summary: Option<SharedString>,
+ messages: Vec<Message>,
+ completion_mode: CompletionMode,
+ /// Holds the task that handles agent interaction until the end of the turn.
+ /// Survives across multiple requests as the model performs tool calls and
+ /// we run tools, report their results.
+ running_turn: Option<RunningTurn>,
+ pending_message: Option<AgentMessage>,
+ tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
+ tool_use_limit_reached: bool,
+ request_token_usage: HashMap<UserMessageId, language_model::TokenUsage>,
+ #[allow(unused)]
+ cumulative_token_usage: TokenUsage,
+ #[allow(unused)]
+ initial_project_snapshot: Shared<Task<Option<Arc<ProjectSnapshot>>>>,
+ context_server_registry: Entity<ContextServerRegistry>,
+ profile_id: AgentProfileId,
+ project_context: Entity<ProjectContext>,
+ templates: Arc<Templates>,
+ model: Option<Arc<dyn LanguageModel>>,
+ summarization_model: Option<Arc<dyn LanguageModel>>,
+ pub(crate) project: Entity<Project>,
+ pub(crate) action_log: Entity<ActionLog>,
+}
+
+impl Thread {
+ pub fn new(
+ project: Entity<Project>,
+ project_context: Entity<ProjectContext>,
+ context_server_registry: Entity<ContextServerRegistry>,
+ templates: Arc<Templates>,
+ model: Option<Arc<dyn LanguageModel>>,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let profile_id = AgentSettings::get_global(cx).default_profile.clone();
+ let action_log = cx.new(|_cx| ActionLog::new(project.clone()));
+ Self {
+ id: acp::SessionId(uuid::Uuid::new_v4().to_string().into()),
+ prompt_id: PromptId::new(),
+ updated_at: Utc::now(),
+ title: None,
+ pending_title_generation: None,
+ summary: None,
+ messages: Vec::new(),
+ completion_mode: AgentSettings::get_global(cx).preferred_completion_mode,
+ running_turn: None,
+ pending_message: None,
+ tools: BTreeMap::default(),
+ tool_use_limit_reached: false,
+ request_token_usage: HashMap::default(),
+ cumulative_token_usage: TokenUsage::default(),
+ initial_project_snapshot: {
+ let project_snapshot = Self::project_snapshot(project.clone(), cx);
+ cx.foreground_executor()
+ .spawn(async move { Some(project_snapshot.await) })
+ .shared()
+ },
+ context_server_registry,
+ profile_id,
+ project_context,
+ templates,
+ model,
+ summarization_model: None,
+ project,
+ action_log,
+ }
+ }
+
+ pub fn id(&self) -> &acp::SessionId {
+ &self.id
+ }
+
+ pub fn replay(
+ &mut self,
+ cx: &mut Context<Self>,
+ ) -> mpsc::UnboundedReceiver<Result<ThreadEvent>> {
+ let (tx, rx) = mpsc::unbounded();
+ let stream = ThreadEventStream(tx);
+ for message in &self.messages {
+ match message {
+ Message::User(user_message) => stream.send_user_message(user_message),
+ Message::Agent(assistant_message) => {
+ for content in &assistant_message.content {
+ match content {
+ AgentMessageContent::Text(text) => stream.send_text(text),
+ AgentMessageContent::Thinking { text, .. } => {
+ stream.send_thinking(text)
+ }
+ AgentMessageContent::RedactedThinking(_) => {}
+ AgentMessageContent::ToolUse(tool_use) => {
+ self.replay_tool_call(
+ tool_use,
+ assistant_message.tool_results.get(&tool_use.id),
+ &stream,
+ cx,
+ );
+ }
+ }
+ }
+ }
+ Message::Resume => {}
+ }
+ }
+ rx
+ }
+
+ fn replay_tool_call(
+ &self,
+ tool_use: &LanguageModelToolUse,
+ tool_result: Option<&LanguageModelToolResult>,
+ stream: &ThreadEventStream,
+ cx: &mut Context<Self>,
+ ) {
+ let tool = self.tools.get(tool_use.name.as_ref()).cloned().or_else(|| {
+ self.context_server_registry
+ .read(cx)
+ .servers()
+ .find_map(|(_, tools)| {
+ if let Some(tool) = tools.get(tool_use.name.as_ref()) {
+ Some(tool.clone())
+ } else {
+ None
+ }
+ })
+ });
+
+ let Some(tool) = tool else {
+ stream
+ .0
+ .unbounded_send(Ok(ThreadEvent::ToolCall(acp::ToolCall {
+ id: acp::ToolCallId(tool_use.id.to_string().into()),
+ title: tool_use.name.to_string(),
+ kind: acp::ToolKind::Other,
+ status: acp::ToolCallStatus::Failed,
+ content: Vec::new(),
+ locations: Vec::new(),
+ raw_input: Some(tool_use.input.clone()),
+ raw_output: None,
+ })))
+ .ok();
+ return;
+ };
+
+ let title = tool.initial_title(tool_use.input.clone());
+ let kind = tool.kind();
+ stream.send_tool_call(&tool_use.id, title, kind, tool_use.input.clone());
+
+ let output = tool_result
+ .as_ref()
+ .and_then(|result| result.output.clone());
+ if let Some(output) = output.clone() {
+ let tool_event_stream = ToolCallEventStream::new(
+ tool_use.id.clone(),
+ stream.clone(),
+ Some(self.project.read(cx).fs().clone()),
+ );
+ tool.replay(tool_use.input.clone(), output, tool_event_stream, cx)
+ .log_err();
+ }
+
+ stream.update_tool_call_fields(
+ &tool_use.id,
+ acp::ToolCallUpdateFields {
+ status: Some(acp::ToolCallStatus::Completed),
+ raw_output: output,
+ ..Default::default()
+ },
+ );
+ }
+
+ pub fn from_db(
+ id: acp::SessionId,
+ db_thread: DbThread,
+ project: Entity<Project>,
+ project_context: Entity<ProjectContext>,
+ context_server_registry: Entity<ContextServerRegistry>,
+ action_log: Entity<ActionLog>,
+ templates: Arc<Templates>,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let profile_id = db_thread
+ .profile
+ .unwrap_or_else(|| AgentSettings::get_global(cx).default_profile.clone());
+ let model = LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
+ db_thread
+ .model
+ .and_then(|model| {
+ let model = SelectedModel {
+ provider: model.provider.clone().into(),
+ model: model.model.into(),
+ };
+ registry.select_model(&model, cx)
+ })
+ .or_else(|| registry.default_model())
+ .map(|model| model.model)
+ });
+
+ Self {
+ id,
+ prompt_id: PromptId::new(),
+ title: if db_thread.title.is_empty() {
+ None
+ } else {
+ Some(db_thread.title.clone())
+ },
+ pending_title_generation: None,
+ summary: db_thread.detailed_summary,
+ messages: db_thread.messages,
+ completion_mode: db_thread.completion_mode.unwrap_or_default(),
+ running_turn: None,
+ pending_message: None,
+ tools: BTreeMap::default(),
+ tool_use_limit_reached: false,
+ request_token_usage: db_thread.request_token_usage.clone(),
+ cumulative_token_usage: db_thread.cumulative_token_usage,
+ initial_project_snapshot: Task::ready(db_thread.initial_project_snapshot).shared(),
+ context_server_registry,
+ profile_id,
+ project_context,
+ templates,
+ model,
+ summarization_model: None,
+ project,
+ action_log,
+ updated_at: db_thread.updated_at,
+ }
+ }
+
+ pub fn to_db(&self, cx: &App) -> Task<DbThread> {
+ let initial_project_snapshot = self.initial_project_snapshot.clone();
+ let mut thread = DbThread {
+ title: self.title(),
+ messages: self.messages.clone(),
+ updated_at: self.updated_at,
+ detailed_summary: self.summary.clone(),
+ initial_project_snapshot: None,
+ cumulative_token_usage: self.cumulative_token_usage,
+ request_token_usage: self.request_token_usage.clone(),
+ model: self.model.as_ref().map(|model| DbLanguageModel {
+ provider: model.provider_id().to_string(),
+ model: model.name().0.to_string(),
+ }),
+ completion_mode: Some(self.completion_mode),
+ profile: Some(self.profile_id.clone()),
+ };
+
+ cx.background_spawn(async move {
+ let initial_project_snapshot = initial_project_snapshot.await;
+ thread.initial_project_snapshot = initial_project_snapshot;
+ thread
+ })
+ }
+
+ /// Create a snapshot of the current project state including git information and unsaved buffers.
+ fn project_snapshot(
+ project: Entity<Project>,
+ cx: &mut Context<Self>,
+ ) -> Task<Arc<agent::thread::ProjectSnapshot>> {
+ let git_store = project.read(cx).git_store().clone();
+ let worktree_snapshots: Vec<_> = project
+ .read(cx)
+ .visible_worktrees(cx)
+ .map(|worktree| Self::worktree_snapshot(worktree, git_store.clone(), cx))
+ .collect();
+
+ cx.spawn(async move |_, cx| {
+ let worktree_snapshots = futures::future::join_all(worktree_snapshots).await;
+
+ let mut unsaved_buffers = Vec::new();
+ cx.update(|app_cx| {
+ let buffer_store = project.read(app_cx).buffer_store();
+ for buffer_handle in buffer_store.read(app_cx).buffers() {
+ let buffer = buffer_handle.read(app_cx);
+ if buffer.is_dirty()
+ && let Some(file) = buffer.file()
+ {
+ let path = file.path().to_string_lossy().to_string();
+ unsaved_buffers.push(path);
+ }
+ }
+ })
+ .ok();
+
+ Arc::new(ProjectSnapshot {
+ worktree_snapshots,
+ unsaved_buffer_paths: unsaved_buffers,
+ timestamp: Utc::now(),
+ })
+ })
+ }
+
+ fn worktree_snapshot(
+ worktree: Entity<project::Worktree>,
+ git_store: Entity<GitStore>,
+ cx: &App,
+ ) -> Task<agent::thread::WorktreeSnapshot> {
+ cx.spawn(async move |cx| {
+ // Get worktree path and snapshot
+ let worktree_info = cx.update(|app_cx| {
+ let worktree = worktree.read(app_cx);
+ let path = worktree.abs_path().to_string_lossy().to_string();
+ let snapshot = worktree.snapshot();
+ (path, snapshot)
+ });
+
+ let Ok((worktree_path, _snapshot)) = worktree_info else {
+ return WorktreeSnapshot {
+ worktree_path: String::new(),
+ git_state: None,
+ };
+ };
+
+ let git_state = git_store
+ .update(cx, |git_store, cx| {
+ git_store
+ .repositories()
+ .values()
+ .find(|repo| {
+ repo.read(cx)
+ .abs_path_to_repo_path(&worktree.read(cx).abs_path())
+ .is_some()
+ })
+ .cloned()
+ })
+ .ok()
+ .flatten()
+ .map(|repo| {
+ repo.update(cx, |repo, _| {
+ let current_branch =
+ repo.branch.as_ref().map(|branch| branch.name().to_owned());
+ repo.send_job(None, |state, _| async move {
+ let RepositoryState::Local { backend, .. } = state else {
+ return GitState {
+ remote_url: None,
+ head_sha: None,
+ current_branch,
+ diff: None,
+ };
+ };
+
+ let remote_url = backend.remote_url("origin");
+ let head_sha = backend.head_sha().await;
+ let diff = backend.diff(DiffType::HeadToWorktree).await.ok();
+
+ GitState {
+ remote_url,
+ head_sha,
+ current_branch,
+ diff,
+ }
+ })
+ })
+ });
+
+ let git_state = match git_state {
+ Some(git_state) => match git_state.ok() {
+ Some(git_state) => git_state.await.ok(),
+ None => None,
+ },
+ None => None,
+ };
+
+ WorktreeSnapshot {
+ worktree_path,
+ git_state,
+ }
+ })
+ }
+
+ pub fn project_context(&self) -> &Entity<ProjectContext> {
+ &self.project_context
+ }
+
+ pub fn project(&self) -> &Entity<Project> {
+ &self.project
+ }
+
+ pub fn action_log(&self) -> &Entity<ActionLog> {
+ &self.action_log
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.messages.is_empty() && self.title.is_none()
+ }
+
+ pub fn model(&self) -> Option<&Arc<dyn LanguageModel>> {
+ self.model.as_ref()
+ }
+
+ pub fn set_model(&mut self, model: Arc<dyn LanguageModel>, cx: &mut Context<Self>) {
+ let old_usage = self.latest_token_usage();
+ self.model = Some(model);
+ let new_usage = self.latest_token_usage();
+ if old_usage != new_usage {
+ cx.emit(TokenUsageUpdated(new_usage));
+ }
+ cx.notify()
+ }
+
+ pub fn summarization_model(&self) -> Option<&Arc<dyn LanguageModel>> {
+ self.summarization_model.as_ref()
+ }
+
+ pub fn set_summarization_model(
+ &mut self,
+ model: Option<Arc<dyn LanguageModel>>,
+ cx: &mut Context<Self>,
+ ) {
+ self.summarization_model = model;
+ cx.notify()
+ }
+
+ pub fn completion_mode(&self) -> CompletionMode {
+ self.completion_mode
+ }
+
+ pub fn set_completion_mode(&mut self, mode: CompletionMode, cx: &mut Context<Self>) {
+ let old_usage = self.latest_token_usage();
+ self.completion_mode = mode;
+ let new_usage = self.latest_token_usage();
+ if old_usage != new_usage {
+ cx.emit(TokenUsageUpdated(new_usage));
+ }
+ cx.notify()
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn last_message(&self) -> Option<Message> {
+ if let Some(message) = self.pending_message.clone() {
+ Some(Message::Agent(message))
+ } else {
+ self.messages.last().cloned()
+ }
+ }
+
+ pub fn add_default_tools(&mut self, cx: &mut Context<Self>) {
+ let language_registry = self.project.read(cx).languages().clone();
+ self.add_tool(CopyPathTool::new(self.project.clone()));
+ self.add_tool(CreateDirectoryTool::new(self.project.clone()));
+ self.add_tool(DeletePathTool::new(
+ self.project.clone(),
+ self.action_log.clone(),
+ ));
+ self.add_tool(DiagnosticsTool::new(self.project.clone()));
+ self.add_tool(EditFileTool::new(cx.weak_entity(), language_registry));
+ self.add_tool(FetchTool::new(self.project.read(cx).client().http_client()));
+ self.add_tool(FindPathTool::new(self.project.clone()));
+ self.add_tool(GrepTool::new(self.project.clone()));
+ self.add_tool(ListDirectoryTool::new(self.project.clone()));
+ self.add_tool(MovePathTool::new(self.project.clone()));
+ self.add_tool(NowTool);
+ self.add_tool(OpenTool::new(self.project.clone()));
+ self.add_tool(ReadFileTool::new(
+ self.project.clone(),
+ self.action_log.clone(),
+ ));
+ self.add_tool(TerminalTool::new(self.project.clone(), cx));
+ self.add_tool(ThinkingTool);
+ self.add_tool(WebSearchTool);
+ }
+
+ pub fn add_tool<T: AgentTool>(&mut self, tool: T) {
+ self.tools.insert(T::name().into(), tool.erase());
+ }
+
+ pub fn remove_tool(&mut self, name: &str) -> bool {
+ self.tools.remove(name).is_some()
+ }
+
+ pub fn profile(&self) -> &AgentProfileId {
+ &self.profile_id
+ }
+
+ pub fn set_profile(&mut self, profile_id: AgentProfileId) {
+ self.profile_id = profile_id;
+ }
+
+ pub fn cancel(&mut self, cx: &mut Context<Self>) {
+ if let Some(running_turn) = self.running_turn.take() {
+ running_turn.cancel();
+ }
+ self.flush_pending_message(cx);
+ }
+
+ fn update_token_usage(&mut self, update: language_model::TokenUsage, cx: &mut Context<Self>) {
+ let Some(last_user_message) = self.last_user_message() else {
+ return;
+ };
+
+ self.request_token_usage
+ .insert(last_user_message.id.clone(), update);
+ cx.emit(TokenUsageUpdated(self.latest_token_usage()));
+ cx.notify();
+ }
+
+ pub fn truncate(&mut self, message_id: UserMessageId, cx: &mut Context<Self>) -> Result<()> {
+ self.cancel(cx);
+ let Some(position) = self.messages.iter().position(
+ |msg| matches!(msg, Message::User(UserMessage { id, .. }) if id == &message_id),
+ ) else {
+ return Err(anyhow!("Message not found"));
+ };
+
+ for message in self.messages.drain(position..) {
+ match message {
+ Message::User(message) => {
+ self.request_token_usage.remove(&message.id);
+ }
+ Message::Agent(_) | Message::Resume => {}
+ }
+ }
+ self.summary = None;
+ cx.notify();
+ Ok(())
+ }
+
+ pub fn latest_token_usage(&self) -> Option<acp_thread::TokenUsage> {
+ let last_user_message = self.last_user_message()?;
+ let tokens = self.request_token_usage.get(&last_user_message.id)?;
+ let model = self.model.clone()?;
+
+ Some(acp_thread::TokenUsage {
+ max_tokens: model.max_token_count_for_mode(self.completion_mode.into()),
+ used_tokens: tokens.total_tokens(),
+ })
+ }
+
+ pub fn resume(
+ &mut self,
+ cx: &mut Context<Self>,
+ ) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
+ self.messages.push(Message::Resume);
+ cx.notify();
+
+ log::debug!("Total messages in thread: {}", self.messages.len());
+ self.run_turn(cx)
+ }
+
+ /// Sending a message results in the model streaming a response, which could include tool calls.
+ /// After calling tools, the model will stops and waits for any outstanding tool calls to be completed and their results sent.
+ /// The returned channel will report all the occurrences in which the model stops before erroring or ending its turn.
+ pub fn send<T>(
+ &mut self,
+ id: UserMessageId,
+ content: impl IntoIterator<Item = T>,
+ cx: &mut Context<Self>,
+ ) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>>
+ where
+ T: Into<UserMessageContent>,
+ {
+ let model = self.model().context("No language model configured")?;
+
+ log::info!("Thread::send called with model: {}", model.name().0);
+ self.advance_prompt_id();
+
+ let content = content.into_iter().map(Into::into).collect::<Vec<_>>();
+ log::debug!("Thread::send content: {:?}", content);
+
+ self.messages
+ .push(Message::User(UserMessage { id, content }));
+ cx.notify();
+
+ log::debug!("Total messages in thread: {}", self.messages.len());
+ self.run_turn(cx)
+ }
+
+ fn run_turn(
+ &mut self,
+ cx: &mut Context<Self>,
+ ) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
+ self.cancel(cx);
+
+ let model = self.model.clone().context("No language model configured")?;
+ let profile = AgentSettings::get_global(cx)
+ .profiles
+ .get(&self.profile_id)
+ .context("Profile not found")?;
+ let (events_tx, events_rx) = mpsc::unbounded::<Result<ThreadEvent>>();
+ let event_stream = ThreadEventStream(events_tx);
+ let message_ix = self.messages.len().saturating_sub(1);
+ self.tool_use_limit_reached = false;
+ self.summary = None;
+ self.running_turn = Some(RunningTurn {
+ event_stream: event_stream.clone(),
+ tools: self.enabled_tools(profile, &model, cx),
+ _task: cx.spawn(async move |this, cx| {
+ log::debug!("Starting agent turn execution");
+
+ let turn_result: Result<()> = async {
+ let mut intent = CompletionIntent::UserPrompt;
+ loop {
+ Self::stream_completion(&this, &model, intent, &event_stream, cx).await?;
+
+ let mut end_turn = true;
+ this.update(cx, |this, cx| {
+ // Generate title if needed.
+ if this.title.is_none() && this.pending_title_generation.is_none() {
+ this.generate_title(cx);
+ }
+
+ // End the turn if the model didn't use tools.
+ let message = this.pending_message.as_ref();
+ end_turn =
+ message.map_or(true, |message| message.tool_results.is_empty());
+ this.flush_pending_message(cx);
+ })?;
+
+ if this.read_with(cx, |this, _| this.tool_use_limit_reached)? {
+ log::info!("Tool use limit reached, completing turn");
+ return Err(language_model::ToolUseLimitReachedError.into());
+ } else if end_turn {
+ log::debug!("No tool uses found, completing turn");
+ return Ok(());
+ } else {
+ intent = CompletionIntent::ToolResults;
+ }
+ }
+ }
+ .await;
+ _ = this.update(cx, |this, cx| this.flush_pending_message(cx));
+
+ match turn_result {
+ Ok(()) => {
+ log::debug!("Turn execution completed");
+ event_stream.send_stop(acp::StopReason::EndTurn);
+ }
+ Err(error) => {
+ log::error!("Turn execution failed: {:?}", error);
+ match error.downcast::<CompletionError>() {
+ Ok(CompletionError::Refusal) => {
+ event_stream.send_stop(acp::StopReason::Refusal);
+ _ = this.update(cx, |this, _| this.messages.truncate(message_ix));
+ }
+ Ok(CompletionError::MaxTokens) => {
+ event_stream.send_stop(acp::StopReason::MaxTokens);
+ }
+ Ok(CompletionError::Other(error)) | Err(error) => {
+ event_stream.send_error(error);
+ }
+ }
+ }
+ }
+
+ _ = this.update(cx, |this, _| this.running_turn.take());
+ }),
+ });
+ Ok(events_rx)
+ }
+
+ async fn stream_completion(
+ this: &WeakEntity<Self>,
+ model: &Arc<dyn LanguageModel>,
+ completion_intent: CompletionIntent,
+ event_stream: &ThreadEventStream,
+ cx: &mut AsyncApp,
+ ) -> Result<()> {
+ log::debug!("Stream completion started successfully");
+
+ let mut attempt = None;
+ loop {
+ let request = this.update(cx, |this, cx| {
+ this.build_completion_request(completion_intent, cx)
+ })??;
+
+ telemetry::event!(
+ "Agent Thread Completion",
+ thread_id = this.read_with(cx, |this, _| this.id.to_string())?,
+ prompt_id = this.read_with(cx, |this, _| this.prompt_id.to_string())?,
+ model = model.telemetry_id(),
+ model_provider = model.provider_id().to_string(),
+ attempt
+ );
+
+ log::debug!(
+ "Calling model.stream_completion, attempt {}",
+ attempt.unwrap_or(0)
+ );
+ let mut events = model
+ .stream_completion(request, cx)
+ .await
+ .map_err(|error| anyhow!(error))?;
+ let mut tool_results = FuturesUnordered::new();
+ let mut error = None;
+
+ while let Some(event) = events.next().await {
+ match event {
+ Ok(event) => {
+ log::trace!("Received completion event: {:?}", event);
+ tool_results.extend(this.update(cx, |this, cx| {
+ this.handle_streamed_completion_event(event, event_stream, cx)
+ })??);
+ }
+ Err(err) => {
+ error = Some(err);
+ break;
+ }
+ }
+ }
+
+ while let Some(tool_result) = tool_results.next().await {
+ log::debug!("Tool finished {:?}", tool_result);
+
+ event_stream.update_tool_call_fields(
+ &tool_result.tool_use_id,
+ acp::ToolCallUpdateFields {
+ status: Some(if tool_result.is_error {
+ acp::ToolCallStatus::Failed
+ } else {
+ acp::ToolCallStatus::Completed
+ }),
+ raw_output: tool_result.output.clone(),
+ ..Default::default()
+ },
+ );
+ this.update(cx, |this, _cx| {
+ this.pending_message()
+ .tool_results
+ .insert(tool_result.tool_use_id.clone(), tool_result);
+ })?;
+ }
+
+ if let Some(error) = error {
+ let completion_mode = this.read_with(cx, |thread, _cx| thread.completion_mode())?;
+ if completion_mode == CompletionMode::Normal {
+ return Err(anyhow!(error))?;
+ }
+
+ let Some(strategy) = Self::retry_strategy_for(&error) else {
+ return Err(anyhow!(error))?;
+ };
+
+ let max_attempts = match &strategy {
+ RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts,
+ RetryStrategy::Fixed { max_attempts, .. } => *max_attempts,
+ };
+
+ let attempt = attempt.get_or_insert(0u8);
+
+ *attempt += 1;
+
+ let attempt = *attempt;
+ if attempt > max_attempts {
+ return Err(anyhow!(error))?;
+ }
+
+ let delay = match &strategy {
+ RetryStrategy::ExponentialBackoff { initial_delay, .. } => {
+ let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32);
+ Duration::from_secs(delay_secs)
+ }
+ RetryStrategy::Fixed { delay, .. } => *delay,
+ };
+ log::debug!("Retry attempt {attempt} with delay {delay:?}");
+
+ event_stream.send_retry(acp_thread::RetryStatus {
+ last_error: error.to_string().into(),
+ attempt: attempt as usize,
+ max_attempts: max_attempts as usize,
+ started_at: Instant::now(),
+ duration: delay,
+ });
+ cx.background_executor().timer(delay).await;
+ this.update(cx, |this, cx| {
+ this.flush_pending_message(cx);
+ if let Some(Message::Agent(message)) = this.messages.last() {
+ if message.tool_results.is_empty() {
+ this.messages.push(Message::Resume);
+ }
+ }
+ })?;
+ } else {
+ return Ok(());
+ }
+ }
+ }
+
+ /// A helper method that's called on every streamed completion event.
+ /// Returns an optional tool result task, which the main agentic loop will
+ /// send back to the model when it resolves.
+ fn handle_streamed_completion_event(
+ &mut self,
+ event: LanguageModelCompletionEvent,
+ event_stream: &ThreadEventStream,
+ cx: &mut Context<Self>,
+ ) -> Result<Option<Task<LanguageModelToolResult>>> {
+ log::trace!("Handling streamed completion event: {:?}", event);
+ use LanguageModelCompletionEvent::*;
+
+ match event {
+ StartMessage { .. } => {
+ self.flush_pending_message(cx);
+ self.pending_message = Some(AgentMessage::default());
+ }
+ Text(new_text) => self.handle_text_event(new_text, event_stream, cx),
+ Thinking { text, signature } => {
+ self.handle_thinking_event(text, signature, event_stream, cx)
+ }
+ RedactedThinking { data } => self.handle_redacted_thinking_event(data, cx),
+ ToolUse(tool_use) => {
+ return Ok(self.handle_tool_use_event(tool_use, event_stream, cx));
+ }
+ ToolUseJsonParseError {
+ id,
+ tool_name,
+ raw_input,
+ json_parse_error,
+ } => {
+ return Ok(Some(Task::ready(
+ self.handle_tool_use_json_parse_error_event(
+ id,
+ tool_name,
+ raw_input,
+ json_parse_error,
+ ),
+ )));
+ }
+ UsageUpdate(usage) => {
+ telemetry::event!(
+ "Agent Thread Completion Usage Updated",
+ thread_id = self.id.to_string(),
+ prompt_id = self.prompt_id.to_string(),
+ model = self.model.as_ref().map(|m| m.telemetry_id()),
+ model_provider = self.model.as_ref().map(|m| m.provider_id().to_string()),
+ input_tokens = usage.input_tokens,
+ output_tokens = usage.output_tokens,
+ cache_creation_input_tokens = usage.cache_creation_input_tokens,
+ cache_read_input_tokens = usage.cache_read_input_tokens,
+ );
+ self.update_token_usage(usage, cx);
+ }
+ StatusUpdate(CompletionRequestStatus::UsageUpdated { amount, limit }) => {
+ self.update_model_request_usage(amount, limit, cx);
+ }
+ StatusUpdate(
+ CompletionRequestStatus::Started
+ | CompletionRequestStatus::Queued { .. }
+ | CompletionRequestStatus::Failed { .. },
+ ) => {}
+ StatusUpdate(CompletionRequestStatus::ToolUseLimitReached) => {
+ self.tool_use_limit_reached = true;
+ }
+ Stop(StopReason::Refusal) => return Err(CompletionError::Refusal.into()),
+ Stop(StopReason::MaxTokens) => return Err(CompletionError::MaxTokens.into()),
+ Stop(StopReason::ToolUse | StopReason::EndTurn) => {}
+ }
+
+ Ok(None)
+ }
+
+ fn handle_text_event(
+ &mut self,
+ new_text: String,
+ event_stream: &ThreadEventStream,
+ cx: &mut Context<Self>,
+ ) {
+ event_stream.send_text(&new_text);
+
+ let last_message = self.pending_message();
+ if let Some(AgentMessageContent::Text(text)) = last_message.content.last_mut() {
+ text.push_str(&new_text);
+ } else {
+ last_message
+ .content
+ .push(AgentMessageContent::Text(new_text));
+ }
+
+ cx.notify();
+ }
+
+ fn handle_thinking_event(
+ &mut self,
+ new_text: String,
+ new_signature: Option<String>,
+ event_stream: &ThreadEventStream,
+ cx: &mut Context<Self>,
+ ) {
+ event_stream.send_thinking(&new_text);
+
+ let last_message = self.pending_message();
+ if let Some(AgentMessageContent::Thinking { text, signature }) =
+ last_message.content.last_mut()
+ {
+ text.push_str(&new_text);
+ *signature = new_signature.or(signature.take());
+ } else {
+ last_message.content.push(AgentMessageContent::Thinking {
+ text: new_text,
+ signature: new_signature,
+ });
+ }
+
+ cx.notify();
+ }
+
+ fn handle_redacted_thinking_event(&mut self, data: String, cx: &mut Context<Self>) {
+ let last_message = self.pending_message();
+ last_message
+ .content
+ .push(AgentMessageContent::RedactedThinking(data));
+ cx.notify();
+ }
+
+ fn handle_tool_use_event(
+ &mut self,
+ tool_use: LanguageModelToolUse,
+ event_stream: &ThreadEventStream,
+ cx: &mut Context<Self>,
+ ) -> Option<Task<LanguageModelToolResult>> {
+ cx.notify();
+
+ let tool = self.tool(tool_use.name.as_ref());
+ let mut title = SharedString::from(&tool_use.name);
+ let mut kind = acp::ToolKind::Other;
+ if let Some(tool) = tool.as_ref() {
+ title = tool.initial_title(tool_use.input.clone());
+ kind = tool.kind();
+ }
+
+ // Ensure the last message ends in the current tool use
+ let last_message = self.pending_message();
+ let push_new_tool_use = last_message.content.last_mut().is_none_or(|content| {
+ if let AgentMessageContent::ToolUse(last_tool_use) = content {
+ if last_tool_use.id == tool_use.id {
+ *last_tool_use = tool_use.clone();
+ false
+ } else {
+ true
+ }
+ } else {
+ true
+ }
+ });
+
+ if push_new_tool_use {
+ event_stream.send_tool_call(&tool_use.id, title, kind, tool_use.input.clone());
+ last_message
+ .content
+ .push(AgentMessageContent::ToolUse(tool_use.clone()));
+ } else {
+ event_stream.update_tool_call_fields(
+ &tool_use.id,
+ acp::ToolCallUpdateFields {
+ title: Some(title.into()),
+ kind: Some(kind),
+ raw_input: Some(tool_use.input.clone()),
+ ..Default::default()
+ },
+ );
+ }
+
+ if !tool_use.is_input_complete {
+ return None;
+ }
+
+ let Some(tool) = tool else {
+ let content = format!("No tool named {} exists", tool_use.name);
+ return Some(Task::ready(LanguageModelToolResult {
+ content: LanguageModelToolResultContent::Text(Arc::from(content)),
+ tool_use_id: tool_use.id,
+ tool_name: tool_use.name,
+ is_error: true,
+ output: None,
+ }));
+ };
+
+ let fs = self.project.read(cx).fs().clone();
+ let tool_event_stream =
+ ToolCallEventStream::new(tool_use.id.clone(), event_stream.clone(), Some(fs));
+ tool_event_stream.update_fields(acp::ToolCallUpdateFields {
+ status: Some(acp::ToolCallStatus::InProgress),
+ ..Default::default()
+ });
+ let supports_images = self.model().is_some_and(|model| model.supports_images());
+ let tool_result = tool.run(tool_use.input, tool_event_stream, cx);
+ log::debug!("Running tool {}", tool_use.name);
+ Some(cx.foreground_executor().spawn(async move {
+ let tool_result = tool_result.await.and_then(|output| {
+ if let LanguageModelToolResultContent::Image(_) = &output.llm_output
+ && !supports_images
+ {
+ return Err(anyhow!(
+ "Attempted to read an image, but this model doesn't support it.",
+ ));
+ }
+ Ok(output)
+ });
+
+ match tool_result {
+ Ok(output) => LanguageModelToolResult {
+ tool_use_id: tool_use.id,
+ tool_name: tool_use.name,
+ is_error: false,
+ content: output.llm_output,
+ output: Some(output.raw_output),
+ },
+ Err(error) => LanguageModelToolResult {
+ tool_use_id: tool_use.id,
+ tool_name: tool_use.name,
+ is_error: true,
+ content: LanguageModelToolResultContent::Text(Arc::from(error.to_string())),
+ output: None,
+ },
+ }
+ }))
+ }
+
+ fn handle_tool_use_json_parse_error_event(
+ &mut self,
+ tool_use_id: LanguageModelToolUseId,
+ tool_name: Arc<str>,
+ raw_input: Arc<str>,
+ json_parse_error: String,
+ ) -> LanguageModelToolResult {
+ let tool_output = format!("Error parsing input JSON: {json_parse_error}");
+ LanguageModelToolResult {
+ tool_use_id,
+ tool_name,
+ is_error: true,
+ content: LanguageModelToolResultContent::Text(tool_output.into()),
+ output: Some(serde_json::Value::String(raw_input.to_string())),
+ }
+ }
+
+ fn update_model_request_usage(&self, amount: usize, limit: UsageLimit, cx: &mut Context<Self>) {
+ self.project
+ .read(cx)
+ .user_store()
+ .update(cx, |user_store, cx| {
+ user_store.update_model_request_usage(
+ ModelRequestUsage(RequestUsage {
+ amount: amount as i32,
+ limit,
+ }),
+ cx,
+ )
+ });
+ }
+
+ pub fn title(&self) -> SharedString {
+ self.title.clone().unwrap_or("New Thread".into())
+ }
+
+ pub fn summary(&mut self, cx: &mut Context<Self>) -> Task<Result<SharedString>> {
+ if let Some(summary) = self.summary.as_ref() {
+ return Task::ready(Ok(summary.clone()));
+ }
+ let Some(model) = self.summarization_model.clone() else {
+ return Task::ready(Err(anyhow!("No summarization model available")));
+ };
+ let mut request = LanguageModelRequest {
+ intent: Some(CompletionIntent::ThreadContextSummarization),
+ temperature: AgentSettings::temperature_for_model(&model, cx),
+ ..Default::default()
+ };
+
+ for message in &self.messages {
+ request.messages.extend(message.to_request());
+ }
+
+ request.messages.push(LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![SUMMARIZE_THREAD_DETAILED_PROMPT.into()],
+ cache: false,
+ });
+ cx.spawn(async move |this, cx| {
+ let mut summary = String::new();
+ 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,
+ LanguageModelCompletionEvent::StatusUpdate(
+ CompletionRequestStatus::UsageUpdated { amount, limit },
+ ) => {
+ this.update(cx, |thread, cx| {
+ thread.update_model_request_usage(amount, limit, cx);
+ })?;
+ continue;
+ }
+ _ => continue,
+ };
+
+ let mut lines = text.lines();
+ summary.extend(lines.next());
+ }
+
+ log::debug!("Setting summary: {}", summary);
+ let summary = SharedString::from(summary);
+
+ this.update(cx, |this, cx| {
+ this.summary = Some(summary.clone());
+ cx.notify()
+ })?;
+
+ Ok(summary)
+ })
+ }
+
+ fn generate_title(&mut self, cx: &mut Context<Self>) {
+ let Some(model) = self.summarization_model.clone() else {
+ return;
+ };
+
+ 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()
+ };
+
+ for message in &self.messages {
+ request.messages.extend(message.to_request());
+ }
+
+ request.messages.push(LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![SUMMARIZE_THREAD_PROMPT.into()],
+ cache: false,
+ });
+ self.pending_title_generation = Some(cx.spawn(async move |this, cx| {
+ let mut title = String::new();
+
+ 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,
+ LanguageModelCompletionEvent::StatusUpdate(
+ CompletionRequestStatus::UsageUpdated { amount, limit },
+ ) => {
+ this.update(cx, |thread, cx| {
+ thread.update_model_request_usage(amount, limit, cx);
+ })?;
+ continue;
+ }
+ _ => continue,
+ };
+
+ let mut lines = text.lines();
+ title.extend(lines.next());
+
+ // Stop if the LLM generated multiple lines.
+ if lines.next().is_some() {
+ break;
+ }
+ }
+ anyhow::Ok(())
+ };
+
+ if generate.await.context("failed to generate title").is_ok() {
+ _ = this.update(cx, |this, cx| this.set_title(title.into(), cx));
+ }
+ _ = this.update(cx, |this, _| this.pending_title_generation = None);
+ }));
+ }
+
+ pub fn set_title(&mut self, title: SharedString, cx: &mut Context<Self>) {
+ self.pending_title_generation = None;
+ if Some(&title) != self.title.as_ref() {
+ self.title = Some(title);
+ cx.emit(TitleUpdated);
+ cx.notify();
+ }
+ }
+
+ fn last_user_message(&self) -> Option<&UserMessage> {
+ self.messages
+ .iter()
+ .rev()
+ .find_map(|message| match message {
+ Message::User(user_message) => Some(user_message),
+ Message::Agent(_) => None,
+ Message::Resume => None,
+ })
+ }
+
+ fn pending_message(&mut self) -> &mut AgentMessage {
+ self.pending_message.get_or_insert_default()
+ }
+
+ fn flush_pending_message(&mut self, cx: &mut Context<Self>) {
+ let Some(mut message) = self.pending_message.take() else {
+ return;
+ };
+
+ if message.content.is_empty() {
+ return;
+ }
+
+ for content in &message.content {
+ let AgentMessageContent::ToolUse(tool_use) = content else {
+ continue;
+ };
+
+ if !message.tool_results.contains_key(&tool_use.id) {
+ message.tool_results.insert(
+ tool_use.id.clone(),
+ LanguageModelToolResult {
+ tool_use_id: tool_use.id.clone(),
+ tool_name: tool_use.name.clone(),
+ is_error: true,
+ content: LanguageModelToolResultContent::Text(TOOL_CANCELED_MESSAGE.into()),
+ output: None,
+ },
+ );
+ }
+ }
+
+ self.messages.push(Message::Agent(message));
+ self.updated_at = Utc::now();
+ self.summary = None;
+ cx.notify()
+ }
+
+ pub(crate) fn build_completion_request(
+ &self,
+ completion_intent: CompletionIntent,
+ cx: &App,
+ ) -> Result<LanguageModelRequest> {
+ let model = self.model().context("No language model configured")?;
+ let tools = if let Some(turn) = self.running_turn.as_ref() {
+ turn.tools
+ .iter()
+ .filter_map(|(tool_name, tool)| {
+ log::trace!("Including tool: {}", tool_name);
+ Some(LanguageModelRequestTool {
+ name: tool_name.to_string(),
+ description: tool.description().to_string(),
+ input_schema: tool.input_schema(model.tool_input_format()).log_err()?,
+ })
+ })
+ .collect::<Vec<_>>()
+ } else {
+ Vec::new()
+ };
+
+ log::debug!("Building completion request");
+ log::debug!("Completion intent: {:?}", completion_intent);
+ log::debug!("Completion mode: {:?}", self.completion_mode);
+
+ let messages = self.build_request_messages(cx);
+ log::debug!("Request will include {} messages", messages.len());
+ log::debug!("Request includes {} tools", tools.len());
+
+ let request = LanguageModelRequest {
+ thread_id: Some(self.id.to_string()),
+ prompt_id: Some(self.prompt_id.to_string()),
+ intent: Some(completion_intent),
+ mode: Some(self.completion_mode.into()),
+ messages,
+ tools,
+ tool_choice: None,
+ stop: Vec::new(),
+ temperature: AgentSettings::temperature_for_model(model, cx),
+ thinking_allowed: true,
+ };
+
+ log::debug!("Completion request built successfully");
+ Ok(request)
+ }
+
+ fn enabled_tools(
+ &self,
+ profile: &AgentProfileSettings,
+ model: &Arc<dyn LanguageModel>,
+ cx: &App,
+ ) -> BTreeMap<SharedString, Arc<dyn AnyAgentTool>> {
+ fn truncate(tool_name: &SharedString) -> SharedString {
+ if tool_name.len() > MAX_TOOL_NAME_LENGTH {
+ let mut truncated = tool_name.to_string();
+ truncated.truncate(MAX_TOOL_NAME_LENGTH);
+ truncated.into()
+ } else {
+ tool_name.clone()
+ }
+ }
+
+ let mut tools = self
+ .tools
+ .iter()
+ .filter_map(|(tool_name, tool)| {
+ if tool.supported_provider(&model.provider_id())
+ && profile.is_tool_enabled(tool_name)
+ {
+ Some((truncate(tool_name), tool.clone()))
+ } else {
+ None
+ }
+ })
+ .collect::<BTreeMap<_, _>>();
+
+ let mut context_server_tools = Vec::new();
+ let mut seen_tools = tools.keys().cloned().collect::<HashSet<_>>();
+ let mut duplicate_tool_names = HashSet::default();
+ for (server_id, server_tools) in self.context_server_registry.read(cx).servers() {
+ for (tool_name, tool) in server_tools {
+ if profile.is_context_server_tool_enabled(&server_id.0, &tool_name) {
+ let tool_name = truncate(tool_name);
+ if !seen_tools.insert(tool_name.clone()) {
+ duplicate_tool_names.insert(tool_name.clone());
+ }
+ context_server_tools.push((server_id.clone(), tool_name, tool.clone()));
+ }
+ }
+ }
+
+ // When there are duplicate tool names, disambiguate by prefixing them
+ // with the server ID. In the rare case there isn't enough space for the
+ // disambiguated tool name, keep only the last tool with this name.
+ for (server_id, tool_name, tool) in context_server_tools {
+ if duplicate_tool_names.contains(&tool_name) {
+ let available = MAX_TOOL_NAME_LENGTH.saturating_sub(tool_name.len());
+ if available >= 2 {
+ let mut disambiguated = server_id.0.to_string();
+ disambiguated.truncate(available - 1);
+ disambiguated.push('_');
+ disambiguated.push_str(&tool_name);
+ tools.insert(disambiguated.into(), tool.clone());
+ } else {
+ tools.insert(tool_name, tool.clone());
+ }
+ } else {
+ tools.insert(tool_name, tool.clone());
+ }
+ }
+
+ tools
+ }
+
+ fn tool(&self, name: &str) -> Option<Arc<dyn AnyAgentTool>> {
+ self.running_turn.as_ref()?.tools.get(name).cloned()
+ }
+
+ fn build_request_messages(&self, cx: &App) -> Vec<LanguageModelRequestMessage> {
+ log::trace!(
+ "Building request messages from {} thread messages",
+ self.messages.len()
+ );
+
+ let system_prompt = SystemPromptTemplate {
+ project: self.project_context.read(cx),
+ available_tools: self.tools.keys().cloned().collect(),
+ }
+ .render(&self.templates)
+ .context("failed to build system prompt")
+ .expect("Invalid template");
+ let mut messages = vec![LanguageModelRequestMessage {
+ role: Role::System,
+ content: vec![system_prompt.into()],
+ cache: false,
+ }];
+ for message in &self.messages {
+ messages.extend(message.to_request());
+ }
+
+ if let Some(last_message) = messages.last_mut() {
+ last_message.cache = true;
+ }
+
+ if let Some(message) = self.pending_message.as_ref() {
+ messages.extend(message.to_request());
+ }
+
+ messages
+ }
+
+ 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');
+ }
+ markdown.push_str(&message.to_markdown());
+ }
+
+ if let Some(message) = self.pending_message.as_ref() {
+ markdown.push('\n');
+ markdown.push_str(&message.to_markdown());
+ }
+
+ markdown
+ }
+
+ fn advance_prompt_id(&mut self) {
+ self.prompt_id = PromptId::new();
+ }
+
+ fn retry_strategy_for(error: &LanguageModelCompletionError) -> Option<RetryStrategy> {
+ use LanguageModelCompletionError::*;
+ use http_client::StatusCode;
+
+ // General strategy here:
+ // - If retrying won't help (e.g. invalid API key or payload too large), return None so we don't retry at all.
+ // - If it's a time-based issue (e.g. server overloaded, rate limit exceeded), retry up to 4 times with exponential backoff.
+ // - If it's an issue that *might* be fixed by retrying (e.g. internal server error), retry up to 3 times.
+ match error {
+ HttpResponseError {
+ status_code: StatusCode::TOO_MANY_REQUESTS,
+ ..
+ } => Some(RetryStrategy::ExponentialBackoff {
+ initial_delay: BASE_RETRY_DELAY,
+ max_attempts: MAX_RETRY_ATTEMPTS,
+ }),
+ ServerOverloaded { retry_after, .. } | RateLimitExceeded { retry_after, .. } => {
+ Some(RetryStrategy::Fixed {
+ delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
+ max_attempts: MAX_RETRY_ATTEMPTS,
+ })
+ }
+ UpstreamProviderError {
+ status,
+ retry_after,
+ ..
+ } => match *status {
+ StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE => {
+ Some(RetryStrategy::Fixed {
+ delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
+ max_attempts: MAX_RETRY_ATTEMPTS,
+ })
+ }
+ StatusCode::INTERNAL_SERVER_ERROR => Some(RetryStrategy::Fixed {
+ delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
+ // Internal Server Error could be anything, retry up to 3 times.
+ max_attempts: 3,
+ }),
+ status => {
+ // There is no StatusCode variant for the unofficial HTTP 529 ("The service is overloaded"),
+ // but we frequently get them in practice. See https://http.dev/529
+ if status.as_u16() == 529 {
+ Some(RetryStrategy::Fixed {
+ delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
+ max_attempts: MAX_RETRY_ATTEMPTS,
+ })
+ } else {
+ Some(RetryStrategy::Fixed {
+ delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
+ max_attempts: 2,
+ })
+ }
+ }
+ },
+ ApiInternalServerError { .. } => Some(RetryStrategy::Fixed {
+ delay: BASE_RETRY_DELAY,
+ max_attempts: 3,
+ }),
+ ApiReadResponseError { .. }
+ | HttpSend { .. }
+ | DeserializeResponse { .. }
+ | BadRequestFormat { .. } => Some(RetryStrategy::Fixed {
+ delay: BASE_RETRY_DELAY,
+ max_attempts: 3,
+ }),
+ // Retrying these errors definitely shouldn't help.
+ HttpResponseError {
+ status_code:
+ StatusCode::PAYLOAD_TOO_LARGE | StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED,
+ ..
+ }
+ | AuthenticationError { .. }
+ | PermissionError { .. }
+ | NoApiKey { .. }
+ | ApiEndpointNotFound { .. }
+ | PromptTooLarge { .. } => None,
+ // These errors might be transient, so retry them
+ SerializeRequest { .. } | BuildRequestBody { .. } => Some(RetryStrategy::Fixed {
+ delay: BASE_RETRY_DELAY,
+ max_attempts: 1,
+ }),
+ // Retry all other 4xx and 5xx errors once.
+ HttpResponseError { status_code, .. }
+ if status_code.is_client_error() || status_code.is_server_error() =>
+ {
+ Some(RetryStrategy::Fixed {
+ delay: BASE_RETRY_DELAY,
+ max_attempts: 3,
+ })
+ }
+ Other(err)
+ if err.is::<language_model::PaymentRequiredError>()
+ || err.is::<language_model::ModelRequestLimitReachedError>() =>
+ {
+ // Retrying won't help for Payment Required or Model Request Limit errors (where
+ // the user must upgrade to usage-based billing to get more requests, or else wait
+ // for a significant amount of time for the request limit to reset).
+ None
+ }
+ // Conservatively assume that any other errors are non-retryable
+ HttpResponseError { .. } | Other(..) => Some(RetryStrategy::Fixed {
+ delay: BASE_RETRY_DELAY,
+ max_attempts: 2,
+ }),
+ }
+ }
+}
+
+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
+ /// we run tools, report their results.
+ _task: Task<()>,
+ /// The current event stream for the running turn. Used to report a final
+ /// cancellation event if we cancel the turn.
+ event_stream: ThreadEventStream,
+ /// The tools that were enabled for this turn.
+ tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
+}
+
+impl RunningTurn {
+ fn cancel(self) {
+ log::debug!("Cancelling in progress turn");
+ self.event_stream.send_canceled();
+ }
+}
+
+pub struct TokenUsageUpdated(pub Option<acp_thread::TokenUsage>);
+
+impl EventEmitter<TokenUsageUpdated> for Thread {}
+
+pub struct TitleUpdated;
+
+impl EventEmitter<TitleUpdated> for Thread {}
+
+pub trait AgentTool
+where
+ Self: 'static + Sized,
+{
+ type Input: for<'de> Deserialize<'de> + Serialize + JsonSchema;
+ type Output: for<'de> Deserialize<'de> + Serialize + Into<LanguageModelToolResultContent>;
+
+ fn name() -> &'static str;
+
+ fn description(&self) -> SharedString {
+ let schema = schemars::schema_for!(Self::Input);
+ SharedString::new(
+ schema
+ .get("description")
+ .and_then(|description| description.as_str())
+ .unwrap_or_default(),
+ )
+ }
+
+ fn kind() -> acp::ToolKind;
+
+ /// The initial tool title to display. Can be updated during the tool run.
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString;
+
+ /// Returns the JSON schema that describes the tool's input.
+ fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Schema {
+ crate::tool_schema::root_schema_for::<Self::Input>(format)
+ }
+
+ /// Some tools rely on a provider for the underlying billing or other reasons.
+ /// Allow the tool to check if they are compatible, or should be filtered out.
+ fn supported_provider(&self, _provider: &LanguageModelProviderId) -> bool {
+ true
+ }
+
+ /// Runs the tool with the provided input.
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output>>;
+
+ /// Emits events for a previous execution of the tool.
+ fn replay(
+ &self,
+ _input: Self::Input,
+ _output: Self::Output,
+ _event_stream: ToolCallEventStream,
+ _cx: &mut App,
+ ) -> Result<()> {
+ Ok(())
+ }
+
+ fn erase(self) -> Arc<dyn AnyAgentTool> {
+ Arc::new(Erased(Arc::new(self)))
+ }
+}
+
+pub struct Erased<T>(T);
+
+pub struct AgentToolOutput {
+ pub llm_output: LanguageModelToolResultContent,
+ pub raw_output: serde_json::Value,
+}
+
+pub trait AnyAgentTool {
+ fn name(&self) -> SharedString;
+ fn description(&self) -> SharedString;
+ fn kind(&self) -> acp::ToolKind;
+ fn initial_title(&self, input: serde_json::Value) -> SharedString;
+ fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value>;
+ fn supported_provider(&self, _provider: &LanguageModelProviderId) -> bool {
+ true
+ }
+ fn run(
+ self: Arc<Self>,
+ input: serde_json::Value,
+ event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<AgentToolOutput>>;
+ fn replay(
+ &self,
+ input: serde_json::Value,
+ output: serde_json::Value,
+ event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Result<()>;
+}
+
+impl<T> AnyAgentTool for Erased<Arc<T>>
+where
+ T: AgentTool,
+{
+ fn name(&self) -> SharedString {
+ T::name().into()
+ }
+
+ fn description(&self) -> SharedString {
+ self.0.description()
+ }
+
+ fn kind(&self) -> agent_client_protocol::ToolKind {
+ T::kind()
+ }
+
+ fn initial_title(&self, input: serde_json::Value) -> SharedString {
+ let parsed_input = serde_json::from_value(input.clone()).map_err(|_| input);
+ self.0.initial_title(parsed_input)
+ }
+
+ fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
+ let mut json = serde_json::to_value(self.0.input_schema(format))?;
+ adapt_schema_to_format(&mut json, format)?;
+ Ok(json)
+ }
+
+ fn supported_provider(&self, provider: &LanguageModelProviderId) -> bool {
+ self.0.supported_provider(provider)
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: serde_json::Value,
+ event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<AgentToolOutput>> {
+ cx.spawn(async move |cx| {
+ let input = serde_json::from_value(input)?;
+ let output = cx
+ .update(|cx| self.0.clone().run(input, event_stream, cx))?
+ .await?;
+ let raw_output = serde_json::to_value(&output)?;
+ Ok(AgentToolOutput {
+ llm_output: output.into(),
+ raw_output,
+ })
+ })
+ }
+
+ fn replay(
+ &self,
+ input: serde_json::Value,
+ output: serde_json::Value,
+ event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Result<()> {
+ let input = serde_json::from_value(input)?;
+ let output = serde_json::from_value(output)?;
+ self.0.replay(input, output, event_stream, cx)
+ }
+}
+
+#[derive(Clone)]
+struct ThreadEventStream(mpsc::UnboundedSender<Result<ThreadEvent>>);
+
+impl ThreadEventStream {
+ fn send_user_message(&self, message: &UserMessage) {
+ self.0
+ .unbounded_send(Ok(ThreadEvent::UserMessage(message.clone())))
+ .ok();
+ }
+
+ fn send_text(&self, text: &str) {
+ self.0
+ .unbounded_send(Ok(ThreadEvent::AgentText(text.to_string())))
+ .ok();
+ }
+
+ fn send_thinking(&self, text: &str) {
+ self.0
+ .unbounded_send(Ok(ThreadEvent::AgentThinking(text.to_string())))
+ .ok();
+ }
+
+ fn send_tool_call(
+ &self,
+ id: &LanguageModelToolUseId,
+ title: SharedString,
+ kind: acp::ToolKind,
+ input: serde_json::Value,
+ ) {
+ self.0
+ .unbounded_send(Ok(ThreadEvent::ToolCall(Self::initial_tool_call(
+ id,
+ title.to_string(),
+ kind,
+ input,
+ ))))
+ .ok();
+ }
+
+ fn initial_tool_call(
+ id: &LanguageModelToolUseId,
+ title: String,
+ kind: acp::ToolKind,
+ input: serde_json::Value,
+ ) -> acp::ToolCall {
+ acp::ToolCall {
+ id: acp::ToolCallId(id.to_string().into()),
+ title,
+ kind,
+ status: acp::ToolCallStatus::Pending,
+ content: vec![],
+ locations: vec![],
+ raw_input: Some(input),
+ raw_output: None,
+ }
+ }
+
+ fn update_tool_call_fields(
+ &self,
+ tool_use_id: &LanguageModelToolUseId,
+ fields: acp::ToolCallUpdateFields,
+ ) {
+ self.0
+ .unbounded_send(Ok(ThreadEvent::ToolCallUpdate(
+ acp::ToolCallUpdate {
+ id: acp::ToolCallId(tool_use_id.to_string().into()),
+ fields,
+ }
+ .into(),
+ )))
+ .ok();
+ }
+
+ fn send_retry(&self, status: acp_thread::RetryStatus) {
+ self.0.unbounded_send(Ok(ThreadEvent::Retry(status))).ok();
+ }
+
+ fn send_stop(&self, reason: acp::StopReason) {
+ self.0.unbounded_send(Ok(ThreadEvent::Stop(reason))).ok();
+ }
+
+ fn send_canceled(&self) {
+ self.0
+ .unbounded_send(Ok(ThreadEvent::Stop(acp::StopReason::Cancelled)))
+ .ok();
+ }
+
+ fn send_error(&self, error: impl Into<anyhow::Error>) {
+ self.0.unbounded_send(Err(error.into())).ok();
+ }
+}
+
+#[derive(Clone)]
+pub struct ToolCallEventStream {
+ tool_use_id: LanguageModelToolUseId,
+ stream: ThreadEventStream,
+ fs: Option<Arc<dyn Fs>>,
+}
+
+impl ToolCallEventStream {
+ #[cfg(test)]
+ pub fn test() -> (Self, ToolCallEventStreamReceiver) {
+ let (events_tx, events_rx) = mpsc::unbounded::<Result<ThreadEvent>>();
+
+ let stream = ToolCallEventStream::new("test_id".into(), ThreadEventStream(events_tx), None);
+
+ (stream, ToolCallEventStreamReceiver(events_rx))
+ }
+
+ fn new(
+ tool_use_id: LanguageModelToolUseId,
+ stream: ThreadEventStream,
+ fs: Option<Arc<dyn Fs>>,
+ ) -> Self {
+ Self {
+ tool_use_id,
+ stream,
+ fs,
+ }
+ }
+
+ pub fn update_fields(&self, fields: acp::ToolCallUpdateFields) {
+ self.stream
+ .update_tool_call_fields(&self.tool_use_id, fields);
+ }
+
+ pub fn update_diff(&self, diff: Entity<acp_thread::Diff>) {
+ self.stream
+ .0
+ .unbounded_send(Ok(ThreadEvent::ToolCallUpdate(
+ acp_thread::ToolCallUpdateDiff {
+ id: acp::ToolCallId(self.tool_use_id.to_string().into()),
+ diff,
+ }
+ .into(),
+ )))
+ .ok();
+ }
+
+ pub fn update_terminal(&self, terminal: Entity<acp_thread::Terminal>) {
+ self.stream
+ .0
+ .unbounded_send(Ok(ThreadEvent::ToolCallUpdate(
+ acp_thread::ToolCallUpdateTerminal {
+ id: acp::ToolCallId(self.tool_use_id.to_string().into()),
+ terminal,
+ }
+ .into(),
+ )))
+ .ok();
+ }
+
+ pub fn authorize(&self, title: impl Into<String>, cx: &mut App) -> Task<Result<()>> {
+ if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
+ return Task::ready(Ok(()));
+ }
+
+ let (response_tx, response_rx) = oneshot::channel();
+ self.stream
+ .0
+ .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization(
+ ToolCallAuthorization {
+ tool_call: acp::ToolCallUpdate {
+ id: acp::ToolCallId(self.tool_use_id.to_string().into()),
+ fields: acp::ToolCallUpdateFields {
+ title: Some(title.into()),
+ ..Default::default()
+ },
+ },
+ options: vec![
+ acp::PermissionOption {
+ id: acp::PermissionOptionId("always_allow".into()),
+ name: "Always Allow".into(),
+ kind: acp::PermissionOptionKind::AllowAlways,
+ },
+ acp::PermissionOption {
+ id: acp::PermissionOptionId("allow".into()),
+ name: "Allow".into(),
+ kind: acp::PermissionOptionKind::AllowOnce,
+ },
+ acp::PermissionOption {
+ id: acp::PermissionOptionId("deny".into()),
+ name: "Deny".into(),
+ kind: acp::PermissionOptionKind::RejectOnce,
+ },
+ ],
+ response: response_tx,
+ },
+ )))
+ .ok();
+ let fs = self.fs.clone();
+ cx.spawn(async move |cx| match response_rx.await?.0.as_ref() {
+ "always_allow" => {
+ if let Some(fs) = fs.clone() {
+ cx.update(|cx| {
+ update_settings_file::<AgentSettings>(fs, cx, |settings, _| {
+ settings.set_always_allow_tool_actions(true);
+ });
+ })?;
+ }
+
+ Ok(())
+ }
+ "allow" => Ok(()),
+ _ => Err(anyhow!("Permission to run tool denied by user")),
+ })
+ }
+}
+
+#[cfg(test)]
+pub struct ToolCallEventStreamReceiver(mpsc::UnboundedReceiver<Result<ThreadEvent>>);
+
+#[cfg(test)]
+impl ToolCallEventStreamReceiver {
+ pub async fn expect_authorization(&mut self) -> ToolCallAuthorization {
+ let event = self.0.next().await;
+ if let Some(Ok(ThreadEvent::ToolCallAuthorization(auth))) = event {
+ auth
+ } else {
+ panic!("Expected ToolCallAuthorization but got: {:?}", event);
+ }
+ }
+
+ pub async fn expect_terminal(&mut self) -> Entity<acp_thread::Terminal> {
+ let event = self.0.next().await;
+ if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateTerminal(
+ update,
+ )))) = event
+ {
+ update.terminal
+ } else {
+ panic!("Expected terminal but got: {:?}", event);
+ }
+ }
+}
+
+#[cfg(test)]
+impl std::ops::Deref for ToolCallEventStreamReceiver {
+ type Target = mpsc::UnboundedReceiver<Result<ThreadEvent>>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+#[cfg(test)]
+impl std::ops::DerefMut for ToolCallEventStreamReceiver {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.0
+ }
+}
+
+impl From<&str> for UserMessageContent {
+ fn from(text: &str) -> Self {
+ Self::Text(text.into())
+ }
+}
+
+impl From<acp::ContentBlock> for UserMessageContent {
+ fn from(value: acp::ContentBlock) -> Self {
+ match value {
+ acp::ContentBlock::Text(text_content) => Self::Text(text_content.text),
+ acp::ContentBlock::Image(image_content) => Self::Image(convert_image(image_content)),
+ acp::ContentBlock::Audio(_) => {
+ // TODO
+ Self::Text("[audio]".to_string())
+ }
+ acp::ContentBlock::ResourceLink(resource_link) => {
+ match MentionUri::parse(&resource_link.uri) {
+ Ok(uri) => Self::Mention {
+ uri,
+ content: String::new(),
+ },
+ Err(err) => {
+ log::error!("Failed to parse mention link: {}", err);
+ Self::Text(format!("[{}]({})", resource_link.name, resource_link.uri))
+ }
+ }
+ }
+ acp::ContentBlock::Resource(resource) => match resource.resource {
+ acp::EmbeddedResourceResource::TextResourceContents(resource) => {
+ match MentionUri::parse(&resource.uri) {
+ Ok(uri) => Self::Mention {
+ uri,
+ content: resource.text,
+ },
+ Err(err) => {
+ log::error!("Failed to parse mention link: {}", err);
+ Self::Text(
+ MarkdownCodeBlock {
+ tag: &resource.uri,
+ text: &resource.text,
+ }
+ .to_string(),
+ )
+ }
+ }
+ }
+ acp::EmbeddedResourceResource::BlobResourceContents(_) => {
+ // TODO
+ Self::Text("[blob]".to_string())
+ }
+ },
+ }
+ }
+}
+
+impl From<UserMessageContent> for acp::ContentBlock {
+ fn from(content: UserMessageContent) -> Self {
+ match content {
+ UserMessageContent::Text(text) => acp::ContentBlock::Text(acp::TextContent {
+ text,
+ annotations: None,
+ }),
+ UserMessageContent::Image(image) => acp::ContentBlock::Image(acp::ImageContent {
+ data: image.source.to_string(),
+ mime_type: "image/png".to_string(),
+ annotations: None,
+ uri: None,
+ }),
+ UserMessageContent::Mention { uri, content } => {
+ acp::ContentBlock::Resource(acp::EmbeddedResource {
+ resource: acp::EmbeddedResourceResource::TextResourceContents(
+ acp::TextResourceContents {
+ mime_type: None,
+ text: content,
+ uri: uri.to_uri().to_string(),
+ },
+ ),
+ annotations: None,
+ })
+ }
+ }
+ }
+}
+
+fn convert_image(image_content: acp::ImageContent) -> LanguageModelImage {
+ LanguageModelImage {
+ source: image_content.data.into(),
+ // TODO: make this optional?
+ size: gpui::Size::new(0.into(), 0.into()),
+ }
+}
@@ -0,0 +1,43 @@
+use language_model::LanguageModelToolSchemaFormat;
+use schemars::{
+ JsonSchema, Schema,
+ generate::SchemaSettings,
+ transform::{Transform, transform_subschemas},
+};
+
+pub(crate) fn root_schema_for<T: JsonSchema>(format: LanguageModelToolSchemaFormat) -> Schema {
+ let mut generator = match format {
+ LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07().into_generator(),
+ LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::openapi3()
+ .with(|settings| {
+ settings.meta_schema = None;
+ settings.inline_subschemas = true;
+ })
+ .with_transform(ToJsonSchemaSubsetTransform)
+ .into_generator(),
+ };
+ generator.root_schema_for::<T>()
+}
+
+#[derive(Debug, Clone)]
+struct ToJsonSchemaSubsetTransform;
+
+impl Transform for ToJsonSchemaSubsetTransform {
+ fn transform(&mut self, schema: &mut Schema) {
+ // Ensure that the type field is not an array, this happens when we use
+ // Option<T>, the type will be [T, "null"].
+ if let Some(type_field) = schema.get_mut("type")
+ && let Some(types) = type_field.as_array()
+ && let Some(first_type) = types.first()
+ {
+ *type_field = first_type.clone();
+ }
+
+ // oneOf is not supported, use anyOf instead
+ if let Some(one_of) = schema.remove("oneOf") {
+ schema.insert("anyOf".to_string(), one_of);
+ }
+
+ transform_subschemas(self, schema);
+ }
+}
@@ -0,0 +1,60 @@
+mod context_server_registry;
+mod copy_path_tool;
+mod create_directory_tool;
+mod delete_path_tool;
+mod diagnostics_tool;
+mod edit_file_tool;
+mod fetch_tool;
+mod find_path_tool;
+mod grep_tool;
+mod list_directory_tool;
+mod move_path_tool;
+mod now_tool;
+mod open_tool;
+mod read_file_tool;
+mod terminal_tool;
+mod thinking_tool;
+mod web_search_tool;
+
+/// A list of all built in tool names, for use in deduplicating MCP tool names
+pub fn default_tool_names() -> impl Iterator<Item = &'static str> {
+ [
+ CopyPathTool::name(),
+ CreateDirectoryTool::name(),
+ DeletePathTool::name(),
+ DiagnosticsTool::name(),
+ EditFileTool::name(),
+ FetchTool::name(),
+ FindPathTool::name(),
+ GrepTool::name(),
+ ListDirectoryTool::name(),
+ MovePathTool::name(),
+ NowTool::name(),
+ OpenTool::name(),
+ ReadFileTool::name(),
+ TerminalTool::name(),
+ ThinkingTool::name(),
+ WebSearchTool::name(),
+ ]
+ .into_iter()
+}
+
+pub use context_server_registry::*;
+pub use copy_path_tool::*;
+pub use create_directory_tool::*;
+pub use delete_path_tool::*;
+pub use diagnostics_tool::*;
+pub use edit_file_tool::*;
+pub use fetch_tool::*;
+pub use find_path_tool::*;
+pub use grep_tool::*;
+pub use list_directory_tool::*;
+pub use move_path_tool::*;
+pub use now_tool::*;
+pub use open_tool::*;
+pub use read_file_tool::*;
+pub use terminal_tool::*;
+pub use thinking_tool::*;
+pub use web_search_tool::*;
+
+use crate::AgentTool;
@@ -0,0 +1,239 @@
+use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream};
+use agent_client_protocol::ToolKind;
+use anyhow::{Result, anyhow, bail};
+use collections::{BTreeMap, HashMap};
+use context_server::ContextServerId;
+use gpui::{App, Context, Entity, SharedString, Task};
+use project::context_server_store::{ContextServerStatus, ContextServerStore};
+use std::sync::Arc;
+use util::ResultExt;
+
+pub struct ContextServerRegistry {
+ server_store: Entity<ContextServerStore>,
+ registered_servers: HashMap<ContextServerId, RegisteredContextServer>,
+ _subscription: gpui::Subscription,
+}
+
+struct RegisteredContextServer {
+ tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
+ load_tools: Task<Result<()>>,
+}
+
+impl ContextServerRegistry {
+ pub fn new(server_store: Entity<ContextServerStore>, cx: &mut Context<Self>) -> Self {
+ let mut this = Self {
+ server_store: server_store.clone(),
+ registered_servers: HashMap::default(),
+ _subscription: cx.subscribe(&server_store, Self::handle_context_server_store_event),
+ };
+ for server in server_store.read(cx).running_servers() {
+ this.reload_tools_for_server(server.id(), cx);
+ }
+ this
+ }
+
+ pub fn servers(
+ &self,
+ ) -> impl Iterator<
+ Item = (
+ &ContextServerId,
+ &BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
+ ),
+ > {
+ self.registered_servers
+ .iter()
+ .map(|(id, server)| (id, &server.tools))
+ }
+
+ fn reload_tools_for_server(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
+ let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else {
+ return;
+ };
+ let Some(client) = server.client() else {
+ return;
+ };
+ if !client.capable(context_server::protocol::ServerCapability::Tools) {
+ return;
+ }
+
+ let registered_server =
+ self.registered_servers
+ .entry(server_id.clone())
+ .or_insert(RegisteredContextServer {
+ tools: BTreeMap::default(),
+ load_tools: Task::ready(Ok(())),
+ });
+ registered_server.load_tools = cx.spawn(async move |this, cx| {
+ let response = client
+ .request::<context_server::types::requests::ListTools>(())
+ .await;
+
+ this.update(cx, |this, cx| {
+ let Some(registered_server) = this.registered_servers.get_mut(&server_id) else {
+ return;
+ };
+
+ registered_server.tools.clear();
+ if let Some(response) = response.log_err() {
+ for tool in response.tools {
+ let tool = Arc::new(ContextServerTool::new(
+ this.server_store.clone(),
+ server.id(),
+ tool,
+ ));
+ registered_server.tools.insert(tool.name(), tool);
+ }
+ cx.notify();
+ }
+ })
+ });
+ }
+
+ fn handle_context_server_store_event(
+ &mut self,
+ _: Entity<ContextServerStore>,
+ event: &project::context_server_store::Event,
+ cx: &mut Context<Self>,
+ ) {
+ match event {
+ project::context_server_store::Event::ServerStatusChanged { server_id, status } => {
+ match status {
+ ContextServerStatus::Starting => {}
+ ContextServerStatus::Running => {
+ self.reload_tools_for_server(server_id.clone(), cx);
+ }
+ ContextServerStatus::Stopped | ContextServerStatus::Error(_) => {
+ self.registered_servers.remove(server_id);
+ cx.notify();
+ }
+ }
+ }
+ }
+ }
+}
+
+struct ContextServerTool {
+ store: Entity<ContextServerStore>,
+ server_id: ContextServerId,
+ tool: context_server::types::Tool,
+}
+
+impl ContextServerTool {
+ fn new(
+ store: Entity<ContextServerStore>,
+ server_id: ContextServerId,
+ tool: context_server::types::Tool,
+ ) -> Self {
+ Self {
+ store,
+ server_id,
+ tool,
+ }
+ }
+}
+
+impl AnyAgentTool for ContextServerTool {
+ fn name(&self) -> SharedString {
+ self.tool.name.clone().into()
+ }
+
+ fn description(&self) -> SharedString {
+ self.tool.description.clone().unwrap_or_default().into()
+ }
+
+ fn kind(&self) -> ToolKind {
+ ToolKind::Other
+ }
+
+ fn initial_title(&self, _input: serde_json::Value) -> SharedString {
+ format!("Run MCP tool `{}`", self.tool.name).into()
+ }
+
+ fn input_schema(
+ &self,
+ format: language_model::LanguageModelToolSchemaFormat,
+ ) -> Result<serde_json::Value> {
+ let mut schema = self.tool.input_schema.clone();
+ assistant_tool::adapt_schema_to_format(&mut schema, format)?;
+ Ok(match schema {
+ serde_json::Value::Null => {
+ serde_json::json!({ "type": "object", "properties": [] })
+ }
+ serde_json::Value::Object(map) if map.is_empty() => {
+ serde_json::json!({ "type": "object", "properties": [] })
+ }
+ _ => schema,
+ })
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: serde_json::Value,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<AgentToolOutput>> {
+ let Some(server) = self.store.read(cx).get_running_server(&self.server_id) else {
+ return Task::ready(Err(anyhow!("Context server not found")));
+ };
+ let tool_name = self.tool.name.clone();
+
+ cx.spawn(async move |_cx| {
+ let Some(protocol) = server.client() else {
+ bail!("Context server not initialized");
+ };
+
+ let arguments = if let serde_json::Value::Object(map) = input {
+ Some(map.into_iter().collect())
+ } else {
+ None
+ };
+
+ log::trace!(
+ "Running tool: {} with arguments: {:?}",
+ tool_name,
+ arguments
+ );
+ let response = protocol
+ .request::<context_server::types::requests::CallTool>(
+ context_server::types::CallToolParams {
+ name: tool_name,
+ arguments,
+ meta: None,
+ },
+ )
+ .await?;
+
+ let mut result = String::new();
+ for content in response.content {
+ match content {
+ context_server::types::ToolResponseContent::Text { text } => {
+ result.push_str(&text);
+ }
+ context_server::types::ToolResponseContent::Image { .. } => {
+ log::warn!("Ignoring image content from tool response");
+ }
+ context_server::types::ToolResponseContent::Audio { .. } => {
+ log::warn!("Ignoring audio content from tool response");
+ }
+ context_server::types::ToolResponseContent::Resource { .. } => {
+ log::warn!("Ignoring resource content from tool response");
+ }
+ }
+ }
+ Ok(AgentToolOutput {
+ raw_output: result.clone().into(),
+ llm_output: result.into(),
+ })
+ })
+ }
+
+ fn replay(
+ &self,
+ _input: serde_json::Value,
+ _output: serde_json::Value,
+ _event_stream: ToolCallEventStream,
+ _cx: &mut App,
+ ) -> Result<()> {
+ Ok(())
+ }
+}
@@ -0,0 +1,111 @@
+use crate::{AgentTool, ToolCallEventStream};
+use agent_client_protocol::ToolKind;
+use anyhow::{Context as _, Result, anyhow};
+use gpui::{App, AppContext, Entity, Task};
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+use util::markdown::MarkdownInlineCode;
+
+/// Copies a file or directory in the project, and returns confirmation that the copy succeeded.
+/// Directory contents will be copied recursively (like `cp -r`).
+///
+/// This tool should be used when it's desirable to create a copy of a file or directory without modifying the original.
+/// It's much more efficient than doing this by separately reading and then writing the file or directory's contents, so this tool should be preferred over that approach whenever copying is the goal.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct CopyPathToolInput {
+ /// The source path of the file or directory to copy.
+ /// If a directory is specified, its contents will be copied recursively (like `cp -r`).
+ ///
+ /// <example>
+ /// If the project has the following files:
+ ///
+ /// - directory1/a/something.txt
+ /// - directory2/a/things.txt
+ /// - directory3/a/other.txt
+ ///
+ /// You can copy the first file by providing a source_path of "directory1/a/something.txt"
+ /// </example>
+ pub source_path: String,
+ /// The destination path where the file or directory should be copied to.
+ ///
+ /// <example>
+ /// To copy "directory1/a/something.txt" to "directory2/b/copy.txt", provide a destination_path of "directory2/b/copy.txt"
+ /// </example>
+ pub destination_path: String,
+}
+
+pub struct CopyPathTool {
+ project: Entity<Project>,
+}
+
+impl CopyPathTool {
+ pub fn new(project: Entity<Project>) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for CopyPathTool {
+ type Input = CopyPathToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "copy_path"
+ }
+
+ fn kind() -> ToolKind {
+ ToolKind::Move
+ }
+
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> ui::SharedString {
+ if let Ok(input) = input {
+ let src = MarkdownInlineCode(&input.source_path);
+ let dest = MarkdownInlineCode(&input.destination_path);
+ format!("Copy {src} to {dest}").into()
+ } else {
+ "Copy path".into()
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output>> {
+ let copy_task = self.project.update(cx, |project, cx| {
+ match project
+ .find_project_path(&input.source_path, cx)
+ .and_then(|project_path| project.entry_for_path(&project_path, cx))
+ {
+ Some(entity) => match project.find_project_path(&input.destination_path, cx) {
+ Some(project_path) => {
+ project.copy_entry(entity.id, None, project_path.path, cx)
+ }
+ None => Task::ready(Err(anyhow!(
+ "Destination path {} was outside the project.",
+ input.destination_path
+ ))),
+ },
+ None => Task::ready(Err(anyhow!(
+ "Source path {} was not found in the project.",
+ input.source_path
+ ))),
+ }
+ });
+
+ cx.background_spawn(async move {
+ let _ = copy_task.await.with_context(|| {
+ format!(
+ "Copying {} to {}",
+ input.source_path, input.destination_path
+ )
+ })?;
+ Ok(format!(
+ "Copied {} to {}",
+ input.source_path, input.destination_path
+ ))
+ })
+ }
+}
@@ -0,0 +1,86 @@
+use agent_client_protocol::ToolKind;
+use anyhow::{Context as _, Result, anyhow};
+use gpui::{App, Entity, SharedString, Task};
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+use util::markdown::MarkdownInlineCode;
+
+use crate::{AgentTool, ToolCallEventStream};
+
+/// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created.
+///
+/// This tool creates a directory and all necessary parent directories (similar to `mkdir -p`). It should be used whenever you need to create new directories within the project.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct CreateDirectoryToolInput {
+ /// The path of the new directory.
+ ///
+ /// <example>
+ /// If the project has the following structure:
+ ///
+ /// - directory1/
+ /// - directory2/
+ ///
+ /// You can create a new directory by providing a path of "directory1/new_directory"
+ /// </example>
+ pub path: String,
+}
+
+pub struct CreateDirectoryTool {
+ project: Entity<Project>,
+}
+
+impl CreateDirectoryTool {
+ pub fn new(project: Entity<Project>) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for CreateDirectoryTool {
+ type Input = CreateDirectoryToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "create_directory"
+ }
+
+ fn kind() -> ToolKind {
+ ToolKind::Read
+ }
+
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ if let Ok(input) = input {
+ format!("Create directory {}", MarkdownInlineCode(&input.path)).into()
+ } else {
+ "Create directory".into()
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output>> {
+ let project_path = match self.project.read(cx).find_project_path(&input.path, cx) {
+ Some(project_path) => project_path,
+ None => {
+ return Task::ready(Err(anyhow!("Path to create was outside the project")));
+ }
+ };
+ let destination_path: Arc<str> = input.path.as_str().into();
+
+ let create_entry = self.project.update(cx, |project, cx| {
+ project.create_entry(project_path.clone(), true, cx)
+ });
+
+ cx.spawn(async move |_cx| {
+ create_entry
+ .await
+ .with_context(|| format!("Creating directory {destination_path}"))?;
+
+ Ok(format!("Created directory {destination_path}"))
+ })
+ }
+}
@@ -0,0 +1,136 @@
+use crate::{AgentTool, ToolCallEventStream};
+use action_log::ActionLog;
+use agent_client_protocol::ToolKind;
+use anyhow::{Context as _, Result, anyhow};
+use futures::{SinkExt, StreamExt, channel::mpsc};
+use gpui::{App, AppContext, Entity, SharedString, Task};
+use project::{Project, ProjectPath};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+
+/// Deletes the file or directory (and the directory's contents, recursively) at the specified path in the project, and returns confirmation of the deletion.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct DeletePathToolInput {
+ /// The path of the file or directory to delete.
+ ///
+ /// <example>
+ /// If the project has the following files:
+ ///
+ /// - directory1/a/something.txt
+ /// - directory2/a/things.txt
+ /// - directory3/a/other.txt
+ ///
+ /// You can delete the first file by providing a path of "directory1/a/something.txt"
+ /// </example>
+ pub path: String,
+}
+
+pub struct DeletePathTool {
+ project: Entity<Project>,
+ action_log: Entity<ActionLog>,
+}
+
+impl DeletePathTool {
+ pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> Self {
+ Self {
+ project,
+ action_log,
+ }
+ }
+}
+
+impl AgentTool for DeletePathTool {
+ type Input = DeletePathToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "delete_path"
+ }
+
+ fn kind() -> ToolKind {
+ ToolKind::Delete
+ }
+
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ if let Ok(input) = input {
+ format!("Delete “`{}`”", input.path).into()
+ } else {
+ "Delete path".into()
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output>> {
+ let path = input.path;
+ let Some(project_path) = self.project.read(cx).find_project_path(&path, cx) else {
+ return Task::ready(Err(anyhow!(
+ "Couldn't delete {path} because that path isn't in this project."
+ )));
+ };
+
+ let Some(worktree) = self
+ .project
+ .read(cx)
+ .worktree_for_id(project_path.worktree_id, cx)
+ else {
+ return Task::ready(Err(anyhow!(
+ "Couldn't delete {path} because that path isn't in this project."
+ )));
+ };
+
+ let worktree_snapshot = worktree.read(cx).snapshot();
+ let (mut paths_tx, mut paths_rx) = mpsc::channel(256);
+ cx.background_spawn({
+ let project_path = project_path.clone();
+ async move {
+ for entry in
+ worktree_snapshot.traverse_from_path(true, false, false, &project_path.path)
+ {
+ if !entry.path.starts_with(&project_path.path) {
+ break;
+ }
+ paths_tx
+ .send(ProjectPath {
+ worktree_id: project_path.worktree_id,
+ path: entry.path.clone(),
+ })
+ .await?;
+ }
+ anyhow::Ok(())
+ }
+ })
+ .detach();
+
+ let project = self.project.clone();
+ let action_log = self.action_log.clone();
+ cx.spawn(async move |cx| {
+ while let Some(path) = paths_rx.next().await {
+ if let Ok(buffer) = project
+ .update(cx, |project, cx| project.open_buffer(path, cx))?
+ .await
+ {
+ action_log.update(cx, |action_log, cx| {
+ action_log.will_delete_buffer(buffer.clone(), cx)
+ })?;
+ }
+ }
+
+ let deletion_task = project
+ .update(cx, |project, cx| {
+ project.delete_file(project_path, false, cx)
+ })?
+ .with_context(|| {
+ format!("Couldn't delete {path} because that path isn't in this project.")
+ })?;
+ deletion_task
+ .await
+ .with_context(|| format!("Deleting {path}"))?;
+ Ok(format!("Deleted {path}"))
+ })
+ }
+}
@@ -0,0 +1,163 @@
+use crate::{AgentTool, ToolCallEventStream};
+use agent_client_protocol as acp;
+use anyhow::{Result, anyhow};
+use gpui::{App, Entity, Task};
+use language::{DiagnosticSeverity, OffsetRangeExt};
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::{fmt::Write, path::Path, sync::Arc};
+use ui::SharedString;
+use util::markdown::MarkdownInlineCode;
+
+/// Get errors and warnings for the project or a specific file.
+///
+/// This tool can be invoked after a series of edits to determine if further edits are necessary, or if the user asks to fix errors or warnings in their codebase.
+///
+/// When a path is provided, shows all diagnostics for that specific file.
+/// When no path is provided, shows a summary of error and warning counts for all files in the project.
+///
+/// <example>
+/// To get diagnostics for a specific file:
+/// {
+/// "path": "src/main.rs"
+/// }
+///
+/// To get a project-wide diagnostic summary:
+/// {}
+/// </example>
+///
+/// <guidelines>
+/// - If you think you can fix a diagnostic, make 1-2 attempts and then give up.
+/// - Don't remove code you've generated just because you can't fix an error. The user can help you fix it.
+/// </guidelines>
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct DiagnosticsToolInput {
+ /// The path to get diagnostics for. If not provided, returns a project-wide summary.
+ ///
+ /// This path should never be absolute, and the first component
+ /// of the path should always be a root directory in a project.
+ ///
+ /// <example>
+ /// If the project has the following root directories:
+ ///
+ /// - lorem
+ /// - ipsum
+ ///
+ /// If you wanna access diagnostics for `dolor.txt` in `ipsum`, you should use the path `ipsum/dolor.txt`.
+ /// </example>
+ pub path: Option<String>,
+}
+
+pub struct DiagnosticsTool {
+ project: Entity<Project>,
+}
+
+impl DiagnosticsTool {
+ pub fn new(project: Entity<Project>) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for DiagnosticsTool {
+ type Input = DiagnosticsToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "diagnostics"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Read
+ }
+
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ if let Some(path) = input.ok().and_then(|input| match input.path {
+ Some(path) if !path.is_empty() => Some(path),
+ _ => None,
+ }) {
+ format!("Check diagnostics for {}", MarkdownInlineCode(&path)).into()
+ } else {
+ "Check project diagnostics".into()
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output>> {
+ match input.path {
+ Some(path) if !path.is_empty() => {
+ let Some(project_path) = self.project.read(cx).find_project_path(&path, cx) else {
+ return Task::ready(Err(anyhow!("Could not find path {path} in project",)));
+ };
+
+ let buffer = self
+ .project
+ .update(cx, |project, cx| project.open_buffer(project_path, cx));
+
+ cx.spawn(async move |cx| {
+ let mut output = String::new();
+ let buffer = buffer.await?;
+ let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
+
+ for (_, group) in snapshot.diagnostic_groups(None) {
+ let entry = &group.entries[group.primary_ix];
+ let range = entry.range.to_point(&snapshot);
+ let severity = match entry.diagnostic.severity {
+ DiagnosticSeverity::ERROR => "error",
+ DiagnosticSeverity::WARNING => "warning",
+ _ => continue,
+ };
+
+ writeln!(
+ output,
+ "{} at line {}: {}",
+ severity,
+ range.start.row + 1,
+ entry.diagnostic.message
+ )?;
+ }
+
+ if output.is_empty() {
+ Ok("File doesn't have errors or warnings!".to_string())
+ } else {
+ Ok(output)
+ }
+ })
+ }
+ _ => {
+ let project = self.project.read(cx);
+ let mut output = String::new();
+ let mut has_diagnostics = false;
+
+ for (project_path, _, summary) in project.diagnostic_summaries(true, cx) {
+ if summary.error_count > 0 || summary.warning_count > 0 {
+ let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx)
+ else {
+ continue;
+ };
+
+ has_diagnostics = true;
+ output.push_str(&format!(
+ "{}: {} error(s), {} warning(s)\n",
+ Path::new(worktree.read(cx).root_name())
+ .join(project_path.path)
+ .display(),
+ summary.error_count,
+ summary.warning_count
+ ));
+ }
+ }
+
+ if has_diagnostics {
+ Task::ready(Ok(output))
+ } else {
+ Task::ready(Ok("No errors or warnings found in the project.".into()))
+ }
+ }
+ }
+ }
+}
@@ -0,0 +1,1558 @@
+use crate::{AgentTool, Thread, ToolCallEventStream};
+use acp_thread::Diff;
+use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};
+use anyhow::{Context as _, Result, anyhow};
+use assistant_tools::edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat};
+use cloud_llm_client::CompletionIntent;
+use collections::HashSet;
+use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
+use indoc::formatdoc;
+use language::language_settings::{self, FormatOnSave};
+use language::{LanguageRegistry, ToPoint};
+use language_model::LanguageModelToolResultContent;
+use paths;
+use project::lsp_store::{FormatTrigger, LspFormatTarget};
+use project::{Project, ProjectPath};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::Settings;
+use smol::stream::StreamExt as _;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use ui::SharedString;
+use util::ResultExt;
+
+const DEFAULT_UI_TEXT: &str = "Editing file";
+
+/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `terminal` tool with the 'mv' command instead.
+///
+/// Before using this tool:
+///
+/// 1. Use the `read_file` tool to understand the file's contents and context
+///
+/// 2. Verify the directory path is correct (only applicable when creating new files):
+/// - Use the `list_directory` tool to verify the parent directory exists and is the correct location
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct EditFileToolInput {
+ /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI and also passed to another model to perform the edit.
+ ///
+ /// Be terse, but also descriptive in what you want to achieve with this edit. Avoid generic instructions.
+ ///
+ /// NEVER mention the file path in this description.
+ ///
+ /// <example>Fix API endpoint URLs</example>
+ /// <example>Update copyright year in `page_footer`</example>
+ ///
+ /// Make sure to include this field before all the others in the input object so that we can display it immediately.
+ pub display_description: String,
+
+ /// The full path of the file to create or modify in the project.
+ ///
+ /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories.
+ ///
+ /// The following examples assume we have two root directories in the project:
+ /// - /a/b/backend
+ /// - /c/d/frontend
+ ///
+ /// <example>
+ /// `backend/src/main.rs`
+ ///
+ /// Notice how the file path starts with `backend`. Without that, the path would be ambiguous and the call would fail!
+ /// </example>
+ ///
+ /// <example>
+ /// `frontend/db.js`
+ /// </example>
+ pub path: PathBuf,
+ /// The mode of operation on the file. Possible values:
+ /// - 'edit': Make granular edits to an existing file.
+ /// - 'create': Create a new file if it doesn't exist.
+ /// - 'overwrite': Replace the entire contents of an existing file.
+ ///
+ /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.
+ pub mode: EditFileMode,
+}
+
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+struct EditFileToolPartialInput {
+ #[serde(default)]
+ path: String,
+ #[serde(default)]
+ display_description: String,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum EditFileMode {
+ Edit,
+ Create,
+ Overwrite,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct EditFileToolOutput {
+ #[serde(alias = "original_path")]
+ input_path: PathBuf,
+ new_text: String,
+ old_text: Arc<String>,
+ #[serde(default)]
+ diff: String,
+ #[serde(alias = "raw_output")]
+ edit_agent_output: EditAgentOutput,
+}
+
+impl From<EditFileToolOutput> for LanguageModelToolResultContent {
+ fn from(output: EditFileToolOutput) -> Self {
+ if output.diff.is_empty() {
+ "No edits were made.".into()
+ } else {
+ format!(
+ "Edited {}:\n\n```diff\n{}\n```",
+ output.input_path.display(),
+ output.diff
+ )
+ .into()
+ }
+ }
+}
+
+pub struct EditFileTool {
+ thread: WeakEntity<Thread>,
+ language_registry: Arc<LanguageRegistry>,
+}
+
+impl EditFileTool {
+ pub fn new(thread: WeakEntity<Thread>, language_registry: Arc<LanguageRegistry>) -> Self {
+ Self {
+ thread,
+ language_registry,
+ }
+ }
+
+ fn authorize(
+ &self,
+ input: &EditFileToolInput,
+ event_stream: &ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<()>> {
+ if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
+ return Task::ready(Ok(()));
+ }
+
+ // If any path component matches the local settings folder, then this could affect
+ // the editor in ways beyond the project source, so prompt.
+ let local_settings_folder = paths::local_settings_folder_relative_path();
+ let path = Path::new(&input.path);
+ if path
+ .components()
+ .any(|component| component.as_os_str() == local_settings_folder.as_os_str())
+ {
+ return event_stream.authorize(
+ format!("{} (local settings)", input.display_description),
+ cx,
+ );
+ }
+
+ // It's also possible that the global config dir is configured to be inside the project,
+ // so check for that edge case too.
+ if let Ok(canonical_path) = std::fs::canonicalize(&input.path)
+ && canonical_path.starts_with(paths::config_dir())
+ {
+ return event_stream.authorize(
+ format!("{} (global settings)", input.display_description),
+ cx,
+ );
+ }
+
+ // Check if path is inside the global config directory
+ // First check if it's already inside project - if not, try to canonicalize
+ let Ok(project_path) = self.thread.read_with(cx, |thread, cx| {
+ thread.project().read(cx).find_project_path(&input.path, cx)
+ }) else {
+ return Task::ready(Err(anyhow!("thread was dropped")));
+ };
+
+ // If the path is inside the project, and it's not one of the above edge cases,
+ // then no confirmation is necessary. Otherwise, confirmation is necessary.
+ if project_path.is_some() {
+ Task::ready(Ok(()))
+ } else {
+ event_stream.authorize(&input.display_description, cx)
+ }
+ }
+}
+
+impl AgentTool for EditFileTool {
+ type Input = EditFileToolInput;
+ type Output = EditFileToolOutput;
+
+ fn name() -> &'static str {
+ "edit_file"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Edit
+ }
+
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ match input {
+ Ok(input) => input.display_description.into(),
+ Err(raw_input) => {
+ if let Some(input) =
+ serde_json::from_value::<EditFileToolPartialInput>(raw_input).ok()
+ {
+ let description = input.display_description.trim();
+ if !description.is_empty() {
+ return description.to_string().into();
+ }
+
+ let path = input.path.trim().to_string();
+ if !path.is_empty() {
+ return path.into();
+ }
+ }
+
+ DEFAULT_UI_TEXT.into()
+ }
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output>> {
+ let Ok(project) = self
+ .thread
+ .read_with(cx, |thread, _cx| thread.project().clone())
+ else {
+ return Task::ready(Err(anyhow!("thread was dropped")));
+ };
+ let project_path = match resolve_path(&input, project.clone(), cx) {
+ Ok(path) => path,
+ Err(err) => return Task::ready(Err(anyhow!(err))),
+ };
+ let abs_path = project.read(cx).absolute_path(&project_path, cx);
+ if let Some(abs_path) = abs_path.clone() {
+ event_stream.update_fields(ToolCallUpdateFields {
+ locations: Some(vec![acp::ToolCallLocation {
+ path: abs_path,
+ line: None,
+ }]),
+ ..Default::default()
+ });
+ }
+
+ let authorize = self.authorize(&input, &event_stream, cx);
+ cx.spawn(async move |cx: &mut AsyncApp| {
+ authorize.await?;
+
+ let (request, model, action_log) = self.thread.update(cx, |thread, cx| {
+ let request = thread.build_completion_request(CompletionIntent::ToolResults, cx);
+ (request, thread.model().cloned(), thread.action_log().clone())
+ })?;
+ let request = request?;
+ let model = model.context("No language model configured")?;
+
+ let edit_format = EditFormat::from_model(model.clone())?;
+ let edit_agent = EditAgent::new(
+ model,
+ project.clone(),
+ action_log.clone(),
+ // TODO: move edit agent to this crate so we can use our templates
+ assistant_tools::templates::Templates::new(),
+ edit_format,
+ );
+
+ let buffer = project
+ .update(cx, |project, cx| {
+ project.open_buffer(project_path.clone(), cx)
+ })?
+ .await?;
+
+ let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?;
+ event_stream.update_diff(diff.clone());
+
+ let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
+ let old_text = cx
+ .background_spawn({
+ let old_snapshot = old_snapshot.clone();
+ async move { Arc::new(old_snapshot.text()) }
+ })
+ .await;
+
+
+ let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
+ edit_agent.edit(
+ buffer.clone(),
+ input.display_description.clone(),
+ &request,
+ cx,
+ )
+ } else {
+ edit_agent.overwrite(
+ buffer.clone(),
+ input.display_description.clone(),
+ &request,
+ cx,
+ )
+ };
+
+ let mut hallucinated_old_text = false;
+ let mut ambiguous_ranges = Vec::new();
+ let mut emitted_location = false;
+ while let Some(event) = events.next().await {
+ match event {
+ EditAgentOutputEvent::Edited(range) => {
+ if !emitted_location {
+ let line = buffer.update(cx, |buffer, _cx| {
+ range.start.to_point(&buffer.snapshot()).row
+ }).ok();
+ if let Some(abs_path) = abs_path.clone() {
+ event_stream.update_fields(ToolCallUpdateFields {
+ locations: Some(vec![ToolCallLocation { path: abs_path, line }]),
+ ..Default::default()
+ });
+ }
+ emitted_location = true;
+ }
+ },
+ EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
+ EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
+ EditAgentOutputEvent::ResolvingEditRange(range) => {
+ diff.update(cx, |card, cx| card.reveal_range(range.clone(), cx))?;
+ // if !emitted_location {
+ // let line = buffer.update(cx, |buffer, _cx| {
+ // range.start.to_point(&buffer.snapshot()).row
+ // }).ok();
+ // if let Some(abs_path) = abs_path.clone() {
+ // event_stream.update_fields(ToolCallUpdateFields {
+ // locations: Some(vec![ToolCallLocation { path: abs_path, line }]),
+ // ..Default::default()
+ // });
+ // }
+ // }
+ }
+ }
+ }
+
+ // If format_on_save is enabled, format the buffer
+ let format_on_save_enabled = buffer
+ .read_with(cx, |buffer, cx| {
+ let settings = language_settings::language_settings(
+ buffer.language().map(|l| l.name()),
+ buffer.file(),
+ cx,
+ );
+ settings.format_on_save != FormatOnSave::Off
+ })
+ .unwrap_or(false);
+
+ let edit_agent_output = output.await?;
+
+ if format_on_save_enabled {
+ action_log.update(cx, |log, cx| {
+ log.buffer_edited(buffer.clone(), cx);
+ })?;
+
+ let format_task = project.update(cx, |project, cx| {
+ project.format(
+ HashSet::from_iter([buffer.clone()]),
+ LspFormatTarget::Buffers,
+ false, // Don't push to history since the tool did it.
+ FormatTrigger::Save,
+ cx,
+ )
+ })?;
+ format_task.await.log_err();
+ }
+
+ project
+ .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
+ .await?;
+
+ action_log.update(cx, |log, cx| {
+ log.buffer_edited(buffer.clone(), cx);
+ })?;
+
+ let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
+ let (new_text, unified_diff) = cx
+ .background_spawn({
+ let new_snapshot = new_snapshot.clone();
+ let old_text = old_text.clone();
+ async move {
+ let new_text = new_snapshot.text();
+ let diff = language::unified_diff(&old_text, &new_text);
+ (new_text, diff)
+ }
+ })
+ .await;
+
+ diff.update(cx, |diff, cx| diff.finalize(cx)).ok();
+
+ let input_path = input.path.display();
+ if unified_diff.is_empty() {
+ anyhow::ensure!(
+ !hallucinated_old_text,
+ formatdoc! {"
+ Some edits were produced but none of them could be applied.
+ Read the relevant sections of {input_path} again so that
+ I can perform the requested edits.
+ "}
+ );
+ anyhow::ensure!(
+ ambiguous_ranges.is_empty(),
+ {
+ let line_numbers = ambiguous_ranges
+ .iter()
+ .map(|range| range.start.to_string())
+ .collect::<Vec<_>>()
+ .join(", ");
+ formatdoc! {"
+ <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
+ relevant sections of {input_path} again and extend <old_text> so
+ that I can perform the requested edits.
+ "}
+ }
+ );
+ }
+
+ Ok(EditFileToolOutput {
+ input_path: input.path,
+ new_text,
+ old_text,
+ diff: unified_diff,
+ edit_agent_output,
+ })
+ })
+ }
+
+ fn replay(
+ &self,
+ _input: Self::Input,
+ output: Self::Output,
+ event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Result<()> {
+ event_stream.update_diff(cx.new(|cx| {
+ Diff::finalized(
+ output.input_path,
+ Some(output.old_text.to_string()),
+ output.new_text,
+ self.language_registry.clone(),
+ cx,
+ )
+ }));
+ Ok(())
+ }
+}
+
+/// Validate that the file path is valid, meaning:
+///
+/// - For `edit` and `overwrite`, the path must point to an existing file.
+/// - For `create`, the file must not already exist, but it's parent dir must exist.
+fn resolve_path(
+ input: &EditFileToolInput,
+ project: Entity<Project>,
+ cx: &mut App,
+) -> Result<ProjectPath> {
+ let project = project.read(cx);
+
+ match input.mode {
+ EditFileMode::Edit | EditFileMode::Overwrite => {
+ let path = project
+ .find_project_path(&input.path, cx)
+ .context("Can't edit file: path not found")?;
+
+ let entry = project
+ .entry_for_path(&path, cx)
+ .context("Can't edit file: path not found")?;
+
+ anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
+ Ok(path)
+ }
+
+ EditFileMode::Create => {
+ if let Some(path) = project.find_project_path(&input.path, cx) {
+ anyhow::ensure!(
+ project.entry_for_path(&path, cx).is_none(),
+ "Can't create file: file already exists"
+ );
+ }
+
+ let parent_path = input
+ .path
+ .parent()
+ .context("Can't create file: incorrect path")?;
+
+ let parent_project_path = project.find_project_path(&parent_path, cx);
+
+ let parent_entry = parent_project_path
+ .as_ref()
+ .and_then(|path| project.entry_for_path(path, cx))
+ .context("Can't create file: parent directory doesn't exist")?;
+
+ anyhow::ensure!(
+ parent_entry.is_dir(),
+ "Can't create file: parent is not a directory"
+ );
+
+ let file_name = input
+ .path
+ .file_name()
+ .context("Can't create file: invalid filename")?;
+
+ let new_file_path = parent_project_path.map(|parent| ProjectPath {
+ path: Arc::from(parent.path.join(file_name)),
+ ..parent
+ });
+
+ new_file_path.context("Can't create file")
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{ContextServerRegistry, Templates};
+ use client::TelemetrySettings;
+ use fs::Fs;
+ use gpui::{TestAppContext, UpdateGlobal};
+ use language_model::fake_provider::FakeLanguageModel;
+ use prompt_store::ProjectContext;
+ use serde_json::json;
+ use settings::SettingsStore;
+ use util::path;
+
+ #[gpui::test]
+ async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = project::FakeFs::new(cx.executor());
+ fs.insert_tree("/root", json!({})).await;
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
+ let model = Arc::new(FakeLanguageModel::default());
+ let thread = cx.new(|cx| {
+ Thread::new(
+ project,
+ cx.new(|_cx| ProjectContext::default()),
+ context_server_registry,
+ Templates::new(),
+ Some(model),
+ cx,
+ )
+ });
+ let result = cx
+ .update(|cx| {
+ let input = EditFileToolInput {
+ display_description: "Some edit".into(),
+ path: "root/nonexistent_file.txt".into(),
+ mode: EditFileMode::Edit,
+ };
+ Arc::new(EditFileTool::new(thread.downgrade(), language_registry)).run(
+ input,
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
+ .await;
+ assert_eq!(
+ result.unwrap_err().to_string(),
+ "Can't edit file: path not found"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
+ let mode = &EditFileMode::Create;
+
+ let result = test_resolve_path(mode, "root/new.txt", cx);
+ assert_resolved_path_eq(result.await, "new.txt");
+
+ let result = test_resolve_path(mode, "new.txt", cx);
+ assert_resolved_path_eq(result.await, "new.txt");
+
+ let result = test_resolve_path(mode, "dir/new.txt", cx);
+ assert_resolved_path_eq(result.await, "dir/new.txt");
+
+ let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
+ assert_eq!(
+ result.await.unwrap_err().to_string(),
+ "Can't create file: file already exists"
+ );
+
+ let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
+ assert_eq!(
+ result.await.unwrap_err().to_string(),
+ "Can't create file: parent directory doesn't exist"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
+ let mode = &EditFileMode::Edit;
+
+ let path_with_root = "root/dir/subdir/existing.txt";
+ let path_without_root = "dir/subdir/existing.txt";
+ let result = test_resolve_path(mode, path_with_root, cx);
+ assert_resolved_path_eq(result.await, path_without_root);
+
+ let result = test_resolve_path(mode, path_without_root, cx);
+ assert_resolved_path_eq(result.await, path_without_root);
+
+ let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
+ assert_eq!(
+ result.await.unwrap_err().to_string(),
+ "Can't edit file: path not found"
+ );
+
+ let result = test_resolve_path(mode, "root/dir", cx);
+ assert_eq!(
+ result.await.unwrap_err().to_string(),
+ "Can't edit file: path is a directory"
+ );
+ }
+
+ async fn test_resolve_path(
+ mode: &EditFileMode,
+ path: &str,
+ cx: &mut TestAppContext,
+ ) -> anyhow::Result<ProjectPath> {
+ init_test(cx);
+
+ let fs = project::FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "dir": {
+ "subdir": {
+ "existing.txt": "hello"
+ }
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+
+ let input = EditFileToolInput {
+ display_description: "Some edit".into(),
+ path: path.into(),
+ mode: mode.clone(),
+ };
+
+ cx.update(|cx| resolve_path(&input, project, cx))
+ }
+
+ fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
+ let actual = path
+ .expect("Should return valid path")
+ .path
+ .to_str()
+ .unwrap()
+ .replace("\\", "/"); // Naive Windows paths normalization
+ assert_eq!(actual, expected);
+ }
+
+ #[gpui::test]
+ async fn test_format_on_save(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = project::FakeFs::new(cx.executor());
+ fs.insert_tree("/root", json!({"src": {}})).await;
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+
+ // Set up a Rust language with LSP formatting support
+ let rust_language = Arc::new(language::Language::new(
+ language::LanguageConfig {
+ name: "Rust".into(),
+ matcher: language::LanguageMatcher {
+ path_suffixes: vec!["rs".to_string()],
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ None,
+ ));
+
+ // Register the language and fake LSP
+ let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+ language_registry.add(rust_language);
+
+ let mut fake_language_servers = language_registry.register_fake_lsp(
+ "Rust",
+ language::FakeLspAdapter {
+ capabilities: lsp::ServerCapabilities {
+ document_formatting_provider: Some(lsp::OneOf::Left(true)),
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ );
+
+ // Create the file
+ fs.save(
+ path!("/root/src/main.rs").as_ref(),
+ &"initial content".into(),
+ language::LineEnding::Unix,
+ )
+ .await
+ .unwrap();
+
+ // Open the buffer to trigger LSP initialization
+ let buffer = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(path!("/root/src/main.rs"), cx)
+ })
+ .await
+ .unwrap();
+
+ // Register the buffer with language servers
+ let _handle = project.update(cx, |project, cx| {
+ project.register_buffer_with_language_servers(&buffer, cx)
+ });
+
+ const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
+ const FORMATTED_CONTENT: &str =
+ "This file was formatted by the fake formatter in the test.\n";
+
+ // Get the fake language server and set up formatting handler
+ let fake_language_server = fake_language_servers.next().await.unwrap();
+ fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
+ |_, _| async move {
+ Ok(Some(vec![lsp::TextEdit {
+ range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
+ new_text: FORMATTED_CONTENT.to_string(),
+ }]))
+ }
+ });
+
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
+ let model = Arc::new(FakeLanguageModel::default());
+ let thread = cx.new(|cx| {
+ Thread::new(
+ project,
+ cx.new(|_cx| ProjectContext::default()),
+ context_server_registry,
+ Templates::new(),
+ Some(model.clone()),
+ cx,
+ )
+ });
+
+ // First, test with format_on_save enabled
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings::<language::language_settings::AllLanguageSettings>(
+ cx,
+ |settings| {
+ settings.defaults.format_on_save = Some(FormatOnSave::On);
+ settings.defaults.formatter =
+ Some(language::language_settings::SelectedFormatter::Auto);
+ },
+ );
+ });
+ });
+
+ // Have the model stream unformatted content
+ let edit_result = {
+ let edit_task = cx.update(|cx| {
+ let input = EditFileToolInput {
+ display_description: "Create main function".into(),
+ path: "root/src/main.rs".into(),
+ mode: EditFileMode::Overwrite,
+ };
+ Arc::new(EditFileTool::new(
+ thread.downgrade(),
+ language_registry.clone(),
+ ))
+ .run(input, ToolCallEventStream::test().0, cx)
+ });
+
+ // Stream the unformatted content
+ cx.executor().run_until_parked();
+ model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
+ model.end_last_completion_stream();
+
+ edit_task.await
+ };
+ assert!(edit_result.is_ok());
+
+ // Wait for any async operations (e.g. formatting) to complete
+ cx.executor().run_until_parked();
+
+ // Read the file to verify it was formatted automatically
+ let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
+ assert_eq!(
+ // Ignore carriage returns on Windows
+ new_content.replace("\r\n", "\n"),
+ FORMATTED_CONTENT,
+ "Code should be formatted when format_on_save is enabled"
+ );
+
+ let stale_buffer_count = thread
+ .read_with(cx, |thread, _cx| thread.action_log.clone())
+ .read_with(cx, |log, cx| log.stale_buffers(cx).count());
+
+ assert_eq!(
+ stale_buffer_count, 0,
+ "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
+ This causes the agent to think the file was modified externally when it was just formatted.",
+ stale_buffer_count
+ );
+
+ // Next, test with format_on_save disabled
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings::<language::language_settings::AllLanguageSettings>(
+ cx,
+ |settings| {
+ settings.defaults.format_on_save = Some(FormatOnSave::Off);
+ },
+ );
+ });
+ });
+
+ // Stream unformatted edits again
+ let edit_result = {
+ let edit_task = cx.update(|cx| {
+ let input = EditFileToolInput {
+ display_description: "Update main function".into(),
+ path: "root/src/main.rs".into(),
+ mode: EditFileMode::Overwrite,
+ };
+ Arc::new(EditFileTool::new(thread.downgrade(), language_registry)).run(
+ input,
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ });
+
+ // Stream the unformatted content
+ cx.executor().run_until_parked();
+ model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
+ model.end_last_completion_stream();
+
+ edit_task.await
+ };
+ assert!(edit_result.is_ok());
+
+ // Wait for any async operations (e.g. formatting) to complete
+ cx.executor().run_until_parked();
+
+ // Verify the file was not formatted
+ let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
+ assert_eq!(
+ // Ignore carriage returns on Windows
+ new_content.replace("\r\n", "\n"),
+ UNFORMATTED_CONTENT,
+ "Code should not be formatted when format_on_save is disabled"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = project::FakeFs::new(cx.executor());
+ fs.insert_tree("/root", json!({"src": {}})).await;
+
+ // Create a simple file with trailing whitespace
+ fs.save(
+ path!("/root/src/main.rs").as_ref(),
+ &"initial content".into(),
+ language::LineEnding::Unix,
+ )
+ .await
+ .unwrap();
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
+ let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
+ let model = Arc::new(FakeLanguageModel::default());
+ let thread = cx.new(|cx| {
+ Thread::new(
+ project,
+ cx.new(|_cx| ProjectContext::default()),
+ context_server_registry,
+ Templates::new(),
+ Some(model.clone()),
+ cx,
+ )
+ });
+
+ // First, test with remove_trailing_whitespace_on_save enabled
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings::<language::language_settings::AllLanguageSettings>(
+ cx,
+ |settings| {
+ settings.defaults.remove_trailing_whitespace_on_save = Some(true);
+ },
+ );
+ });
+ });
+
+ const CONTENT_WITH_TRAILING_WHITESPACE: &str =
+ "fn main() { \n println!(\"Hello!\"); \n}\n";
+
+ // Have the model stream content that contains trailing whitespace
+ let edit_result = {
+ let edit_task = cx.update(|cx| {
+ let input = EditFileToolInput {
+ display_description: "Create main function".into(),
+ path: "root/src/main.rs".into(),
+ mode: EditFileMode::Overwrite,
+ };
+ Arc::new(EditFileTool::new(
+ thread.downgrade(),
+ language_registry.clone(),
+ ))
+ .run(input, ToolCallEventStream::test().0, cx)
+ });
+
+ // Stream the content with trailing whitespace
+ cx.executor().run_until_parked();
+ model.send_last_completion_stream_text_chunk(
+ CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
+ );
+ model.end_last_completion_stream();
+
+ edit_task.await
+ };
+ assert!(edit_result.is_ok());
+
+ // Wait for any async operations (e.g. formatting) to complete
+ cx.executor().run_until_parked();
+
+ // Read the file to verify trailing whitespace was removed automatically
+ assert_eq!(
+ // Ignore carriage returns on Windows
+ fs.load(path!("/root/src/main.rs").as_ref())
+ .await
+ .unwrap()
+ .replace("\r\n", "\n"),
+ "fn main() {\n println!(\"Hello!\");\n}\n",
+ "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
+ );
+
+ // Next, test with remove_trailing_whitespace_on_save disabled
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings::<language::language_settings::AllLanguageSettings>(
+ cx,
+ |settings| {
+ settings.defaults.remove_trailing_whitespace_on_save = Some(false);
+ },
+ );
+ });
+ });
+
+ // Stream edits again with trailing whitespace
+ let edit_result = {
+ let edit_task = cx.update(|cx| {
+ let input = EditFileToolInput {
+ display_description: "Update main function".into(),
+ path: "root/src/main.rs".into(),
+ mode: EditFileMode::Overwrite,
+ };
+ Arc::new(EditFileTool::new(thread.downgrade(), language_registry)).run(
+ input,
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ });
+
+ // Stream the content with trailing whitespace
+ cx.executor().run_until_parked();
+ model.send_last_completion_stream_text_chunk(
+ CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
+ );
+ model.end_last_completion_stream();
+
+ edit_task.await
+ };
+ assert!(edit_result.is_ok());
+
+ // Wait for any async operations (e.g. formatting) to complete
+ cx.executor().run_until_parked();
+
+ // Verify the file still has trailing whitespace
+ // Read the file again - it should still have trailing whitespace
+ let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
+ assert_eq!(
+ // Ignore carriage returns on Windows
+ final_content.replace("\r\n", "\n"),
+ CONTENT_WITH_TRAILING_WHITESPACE,
+ "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_authorize(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = project::FakeFs::new(cx.executor());
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
+ let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
+ let model = Arc::new(FakeLanguageModel::default());
+ let thread = cx.new(|cx| {
+ Thread::new(
+ project,
+ cx.new(|_cx| ProjectContext::default()),
+ context_server_registry,
+ Templates::new(),
+ Some(model.clone()),
+ cx,
+ )
+ });
+ let tool = Arc::new(EditFileTool::new(thread.downgrade(), language_registry));
+ fs.insert_tree("/root", json!({})).await;
+
+ // Test 1: Path with .zed component should require confirmation
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ let _auth = cx.update(|cx| {
+ tool.authorize(
+ &EditFileToolInput {
+ display_description: "test 1".into(),
+ path: ".zed/settings.json".into(),
+ mode: EditFileMode::Edit,
+ },
+ &stream_tx,
+ cx,
+ )
+ });
+
+ let event = stream_rx.expect_authorization().await;
+ assert_eq!(
+ event.tool_call.fields.title,
+ Some("test 1 (local settings)".into())
+ );
+
+ // Test 2: Path outside project should require confirmation
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ let _auth = cx.update(|cx| {
+ tool.authorize(
+ &EditFileToolInput {
+ display_description: "test 2".into(),
+ path: "/etc/hosts".into(),
+ mode: EditFileMode::Edit,
+ },
+ &stream_tx,
+ cx,
+ )
+ });
+
+ let event = stream_rx.expect_authorization().await;
+ assert_eq!(event.tool_call.fields.title, Some("test 2".into()));
+
+ // Test 3: Relative path without .zed should not require confirmation
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ cx.update(|cx| {
+ tool.authorize(
+ &EditFileToolInput {
+ display_description: "test 3".into(),
+ path: "root/src/main.rs".into(),
+ mode: EditFileMode::Edit,
+ },
+ &stream_tx,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ assert!(stream_rx.try_next().is_err());
+
+ // Test 4: Path with .zed in the middle should require confirmation
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ let _auth = cx.update(|cx| {
+ tool.authorize(
+ &EditFileToolInput {
+ display_description: "test 4".into(),
+ path: "root/.zed/tasks.json".into(),
+ mode: EditFileMode::Edit,
+ },
+ &stream_tx,
+ cx,
+ )
+ });
+ let event = stream_rx.expect_authorization().await;
+ assert_eq!(
+ event.tool_call.fields.title,
+ Some("test 4 (local settings)".into())
+ );
+
+ // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.always_allow_tool_actions = true;
+ agent_settings::AgentSettings::override_global(settings, cx);
+ });
+
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ cx.update(|cx| {
+ tool.authorize(
+ &EditFileToolInput {
+ display_description: "test 5.1".into(),
+ path: ".zed/settings.json".into(),
+ mode: EditFileMode::Edit,
+ },
+ &stream_tx,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ assert!(stream_rx.try_next().is_err());
+
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ cx.update(|cx| {
+ tool.authorize(
+ &EditFileToolInput {
+ display_description: "test 5.2".into(),
+ path: "/etc/hosts".into(),
+ mode: EditFileMode::Edit,
+ },
+ &stream_tx,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ assert!(stream_rx.try_next().is_err());
+ }
+
+ #[gpui::test]
+ async fn test_authorize_global_config(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = project::FakeFs::new(cx.executor());
+ fs.insert_tree("/project", json!({})).await;
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+ let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
+ let model = Arc::new(FakeLanguageModel::default());
+ let thread = cx.new(|cx| {
+ Thread::new(
+ project,
+ cx.new(|_cx| ProjectContext::default()),
+ context_server_registry,
+ Templates::new(),
+ Some(model.clone()),
+ cx,
+ )
+ });
+ let tool = Arc::new(EditFileTool::new(thread.downgrade(), language_registry));
+
+ // Test global config paths - these should require confirmation if they exist and are outside the project
+ let test_cases = vec![
+ (
+ "/etc/hosts",
+ true,
+ "System file should require confirmation",
+ ),
+ (
+ "/usr/local/bin/script",
+ true,
+ "System bin file should require confirmation",
+ ),
+ (
+ "project/normal_file.rs",
+ false,
+ "Normal project file should not require confirmation",
+ ),
+ ];
+
+ for (path, should_confirm, description) in test_cases {
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ let auth = cx.update(|cx| {
+ tool.authorize(
+ &EditFileToolInput {
+ display_description: "Edit file".into(),
+ path: path.into(),
+ mode: EditFileMode::Edit,
+ },
+ &stream_tx,
+ cx,
+ )
+ });
+
+ if should_confirm {
+ stream_rx.expect_authorization().await;
+ } else {
+ auth.await.unwrap();
+ assert!(
+ stream_rx.try_next().is_err(),
+ "Failed for case: {} - path: {} - expected no confirmation but got one",
+ description,
+ path
+ );
+ }
+ }
+ }
+
+ #[gpui::test]
+ async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = project::FakeFs::new(cx.executor());
+
+ // Create multiple worktree directories
+ fs.insert_tree(
+ "/workspace/frontend",
+ json!({
+ "src": {
+ "main.js": "console.log('frontend');"
+ }
+ }),
+ )
+ .await;
+ fs.insert_tree(
+ "/workspace/backend",
+ json!({
+ "src": {
+ "main.rs": "fn main() {}"
+ }
+ }),
+ )
+ .await;
+ fs.insert_tree(
+ "/workspace/shared",
+ json!({
+ ".zed": {
+ "settings.json": "{}"
+ }
+ }),
+ )
+ .await;
+
+ // Create project with multiple worktrees
+ let project = Project::test(
+ fs.clone(),
+ [
+ path!("/workspace/frontend").as_ref(),
+ path!("/workspace/backend").as_ref(),
+ path!("/workspace/shared").as_ref(),
+ ],
+ cx,
+ )
+ .await;
+ let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
+ let model = Arc::new(FakeLanguageModel::default());
+ let thread = cx.new(|cx| {
+ Thread::new(
+ project.clone(),
+ cx.new(|_cx| ProjectContext::default()),
+ context_server_registry.clone(),
+ Templates::new(),
+ Some(model.clone()),
+ cx,
+ )
+ });
+ let tool = Arc::new(EditFileTool::new(thread.downgrade(), language_registry));
+
+ // Test files in different worktrees
+ let test_cases = vec![
+ ("frontend/src/main.js", false, "File in first worktree"),
+ ("backend/src/main.rs", false, "File in second worktree"),
+ (
+ "shared/.zed/settings.json",
+ true,
+ ".zed file in third worktree",
+ ),
+ ("/etc/hosts", true, "Absolute path outside all worktrees"),
+ (
+ "../outside/file.txt",
+ true,
+ "Relative path outside worktrees",
+ ),
+ ];
+
+ for (path, should_confirm, description) in test_cases {
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ let auth = cx.update(|cx| {
+ tool.authorize(
+ &EditFileToolInput {
+ display_description: "Edit file".into(),
+ path: path.into(),
+ mode: EditFileMode::Edit,
+ },
+ &stream_tx,
+ cx,
+ )
+ });
+
+ if should_confirm {
+ stream_rx.expect_authorization().await;
+ } else {
+ auth.await.unwrap();
+ assert!(
+ stream_rx.try_next().is_err(),
+ "Failed for case: {} - path: {} - expected no confirmation but got one",
+ description,
+ path
+ );
+ }
+ }
+ }
+
+ #[gpui::test]
+ async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = project::FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/project",
+ json!({
+ ".zed": {
+ "settings.json": "{}"
+ },
+ "src": {
+ ".zed": {
+ "local.json": "{}"
+ }
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+ let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
+ let model = Arc::new(FakeLanguageModel::default());
+ let thread = cx.new(|cx| {
+ Thread::new(
+ project.clone(),
+ cx.new(|_cx| ProjectContext::default()),
+ context_server_registry.clone(),
+ Templates::new(),
+ Some(model.clone()),
+ cx,
+ )
+ });
+ let tool = Arc::new(EditFileTool::new(thread.downgrade(), language_registry));
+
+ // Test edge cases
+ let test_cases = vec![
+ // Empty path - find_project_path returns Some for empty paths
+ ("", false, "Empty path is treated as project root"),
+ // Root directory
+ ("/", true, "Root directory should be outside project"),
+ // Parent directory references - find_project_path resolves these
+ (
+ "project/../other",
+ false,
+ "Path with .. is resolved by find_project_path",
+ ),
+ (
+ "project/./src/file.rs",
+ false,
+ "Path with . should work normally",
+ ),
+ // Windows-style paths (if on Windows)
+ #[cfg(target_os = "windows")]
+ ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
+ #[cfg(target_os = "windows")]
+ ("project\\src\\main.rs", false, "Windows-style project path"),
+ ];
+
+ for (path, should_confirm, description) in test_cases {
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ let auth = cx.update(|cx| {
+ tool.authorize(
+ &EditFileToolInput {
+ display_description: "Edit file".into(),
+ path: path.into(),
+ mode: EditFileMode::Edit,
+ },
+ &stream_tx,
+ cx,
+ )
+ });
+
+ if should_confirm {
+ stream_rx.expect_authorization().await;
+ } else {
+ auth.await.unwrap();
+ assert!(
+ stream_rx.try_next().is_err(),
+ "Failed for case: {} - path: {} - expected no confirmation but got one",
+ description,
+ path
+ );
+ }
+ }
+ }
+
+ #[gpui::test]
+ async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = project::FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/project",
+ json!({
+ "existing.txt": "content",
+ ".zed": {
+ "settings.json": "{}"
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+ let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
+ let model = Arc::new(FakeLanguageModel::default());
+ let thread = cx.new(|cx| {
+ Thread::new(
+ project.clone(),
+ cx.new(|_cx| ProjectContext::default()),
+ context_server_registry.clone(),
+ Templates::new(),
+ Some(model.clone()),
+ cx,
+ )
+ });
+ let tool = Arc::new(EditFileTool::new(thread.downgrade(), language_registry));
+
+ // Test different EditFileMode values
+ let modes = vec![
+ EditFileMode::Edit,
+ EditFileMode::Create,
+ EditFileMode::Overwrite,
+ ];
+
+ for mode in modes {
+ // Test .zed path with different modes
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ let _auth = cx.update(|cx| {
+ tool.authorize(
+ &EditFileToolInput {
+ display_description: "Edit settings".into(),
+ path: "project/.zed/settings.json".into(),
+ mode: mode.clone(),
+ },
+ &stream_tx,
+ cx,
+ )
+ });
+
+ stream_rx.expect_authorization().await;
+
+ // Test outside path with different modes
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ let _auth = cx.update(|cx| {
+ tool.authorize(
+ &EditFileToolInput {
+ display_description: "Edit file".into(),
+ path: "/outside/file.txt".into(),
+ mode: mode.clone(),
+ },
+ &stream_tx,
+ cx,
+ )
+ });
+
+ stream_rx.expect_authorization().await;
+
+ // Test normal path with different modes
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ cx.update(|cx| {
+ tool.authorize(
+ &EditFileToolInput {
+ display_description: "Edit file".into(),
+ path: "project/normal.txt".into(),
+ mode: mode.clone(),
+ },
+ &stream_tx,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ assert!(stream_rx.try_next().is_err());
+ }
+ }
+
+ #[gpui::test]
+ async fn test_initial_title_with_partial_input(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = project::FakeFs::new(cx.executor());
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+ let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
+ let model = Arc::new(FakeLanguageModel::default());
+ let thread = cx.new(|cx| {
+ Thread::new(
+ project.clone(),
+ cx.new(|_cx| ProjectContext::default()),
+ context_server_registry,
+ Templates::new(),
+ Some(model.clone()),
+ cx,
+ )
+ });
+ let tool = Arc::new(EditFileTool::new(thread.downgrade(), language_registry));
+
+ assert_eq!(
+ tool.initial_title(Err(json!({
+ "path": "src/main.rs",
+ "display_description": "",
+ "old_string": "old code",
+ "new_string": "new code"
+ }))),
+ "src/main.rs"
+ );
+ assert_eq!(
+ tool.initial_title(Err(json!({
+ "path": "",
+ "display_description": "Fix error handling",
+ "old_string": "old code",
+ "new_string": "new code"
+ }))),
+ "Fix error handling"
+ );
+ assert_eq!(
+ tool.initial_title(Err(json!({
+ "path": "src/main.rs",
+ "display_description": "Fix error handling",
+ "old_string": "old code",
+ "new_string": "new code"
+ }))),
+ "Fix error handling"
+ );
+ assert_eq!(
+ tool.initial_title(Err(json!({
+ "path": "",
+ "display_description": "",
+ "old_string": "old code",
+ "new_string": "new code"
+ }))),
+ DEFAULT_UI_TEXT
+ );
+ assert_eq!(
+ tool.initial_title(Err(serde_json::Value::Null)),
+ DEFAULT_UI_TEXT
+ );
+ }
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ language::init(cx);
+ TelemetrySettings::register(cx);
+ agent_settings::AgentSettings::register(cx);
+ Project::init_settings(cx);
+ });
+ }
+}
@@ -0,0 +1,155 @@
+use std::rc::Rc;
+use std::sync::Arc;
+use std::{borrow::Cow, cell::RefCell};
+
+use agent_client_protocol as acp;
+use anyhow::{Context as _, Result, bail};
+use futures::AsyncReadExt as _;
+use gpui::{App, AppContext as _, Task};
+use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown};
+use http_client::{AsyncBody, HttpClientWithUrl};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use ui::SharedString;
+use util::markdown::MarkdownEscaped;
+
+use crate::{AgentTool, ToolCallEventStream};
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+enum ContentType {
+ Html,
+ Plaintext,
+ Json,
+}
+
+/// Fetches a URL and returns the content as Markdown.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct FetchToolInput {
+ /// The URL to fetch.
+ url: String,
+}
+
+pub struct FetchTool {
+ http_client: Arc<HttpClientWithUrl>,
+}
+
+impl FetchTool {
+ pub fn new(http_client: Arc<HttpClientWithUrl>) -> Self {
+ Self { http_client }
+ }
+
+ async fn build_message(http_client: Arc<HttpClientWithUrl>, url: &str) -> Result<String> {
+ let url = if !url.starts_with("https://") && !url.starts_with("http://") {
+ Cow::Owned(format!("https://{url}"))
+ } else {
+ Cow::Borrowed(url)
+ };
+
+ let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
+
+ let mut body = Vec::new();
+ response
+ .body_mut()
+ .read_to_end(&mut body)
+ .await
+ .context("error reading response body")?;
+
+ if response.status().is_client_error() {
+ let text = String::from_utf8_lossy(body.as_slice());
+ bail!(
+ "status error {}, response: {text:?}",
+ response.status().as_u16()
+ );
+ }
+
+ let Some(content_type) = response.headers().get("content-type") else {
+ bail!("missing Content-Type header");
+ };
+ let content_type = content_type
+ .to_str()
+ .context("invalid Content-Type header")?;
+
+ let content_type = if content_type.starts_with("text/plain") {
+ ContentType::Plaintext
+ } else if content_type.starts_with("application/json") {
+ ContentType::Json
+ } else {
+ ContentType::Html
+ };
+
+ match content_type {
+ ContentType::Html => {
+ let mut handlers: Vec<TagHandler> = vec![
+ Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
+ Rc::new(RefCell::new(markdown::ParagraphHandler)),
+ Rc::new(RefCell::new(markdown::HeadingHandler)),
+ Rc::new(RefCell::new(markdown::ListHandler)),
+ Rc::new(RefCell::new(markdown::TableHandler::new())),
+ Rc::new(RefCell::new(markdown::StyledTextHandler)),
+ ];
+ if url.contains("wikipedia.org") {
+ use html_to_markdown::structure::wikipedia;
+
+ handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
+ handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
+ handlers.push(Rc::new(
+ RefCell::new(wikipedia::WikipediaCodeHandler::new()),
+ ));
+ } else {
+ handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
+ }
+
+ convert_html_to_markdown(&body[..], &mut handlers)
+ }
+ ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
+ ContentType::Json => {
+ let json: serde_json::Value = serde_json::from_slice(&body)?;
+
+ Ok(format!(
+ "```json\n{}\n```",
+ serde_json::to_string_pretty(&json)?
+ ))
+ }
+ }
+ }
+}
+
+impl AgentTool for FetchTool {
+ type Input = FetchToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "fetch"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Fetch
+ }
+
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ match input {
+ Ok(input) => format!("Fetch {}", MarkdownEscaped(&input.url)).into(),
+ Err(_) => "Fetch URL".into(),
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output>> {
+ let text = cx.background_spawn({
+ let http_client = self.http_client.clone();
+ async move { Self::build_message(http_client, &input.url).await }
+ });
+
+ cx.foreground_executor().spawn(async move {
+ let text = text.await?;
+ if text.trim().is_empty() {
+ bail!("no textual content found");
+ }
+ Ok(text)
+ })
+ }
+}
@@ -0,0 +1,244 @@
+use crate::{AgentTool, ToolCallEventStream};
+use agent_client_protocol as acp;
+use anyhow::{Result, anyhow};
+use gpui::{App, AppContext, Entity, SharedString, Task};
+use language_model::LanguageModelToolResultContent;
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::fmt::Write;
+use std::{cmp, path::PathBuf, sync::Arc};
+use util::paths::PathMatcher;
+
+/// Fast file path pattern matching tool that works with any codebase size
+///
+/// - Supports glob patterns like "**/*.js" or "src/**/*.ts"
+/// - Returns matching file paths sorted alphabetically
+/// - Prefer the `grep` tool to this tool when searching for symbols unless you have specific information about paths.
+/// - Use this tool when you need to find files by name patterns
+/// - Results are paginated with 50 matches per page. Use the optional 'offset' parameter to request subsequent pages.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct FindPathToolInput {
+ /// The glob to match against every path in the project.
+ ///
+ /// <example>
+ /// If the project has the following root directories:
+ ///
+ /// - directory1/a/something.txt
+ /// - directory2/a/things.txt
+ /// - directory3/a/other.txt
+ ///
+ /// You can get back the first two paths by providing a glob of "*thing*.txt"
+ /// </example>
+ pub glob: String,
+ /// Optional starting position for paginated results (0-based).
+ /// When not provided, starts from the beginning.
+ #[serde(default)]
+ pub offset: usize,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct FindPathToolOutput {
+ offset: usize,
+ current_matches_page: Vec<PathBuf>,
+ all_matches_len: usize,
+}
+
+impl From<FindPathToolOutput> for LanguageModelToolResultContent {
+ fn from(output: FindPathToolOutput) -> Self {
+ if output.current_matches_page.is_empty() {
+ "No matches found".into()
+ } else {
+ let mut llm_output = format!("Found {} total matches.", output.all_matches_len);
+ if output.all_matches_len > RESULTS_PER_PAGE {
+ write!(
+ &mut llm_output,
+ "\nShowing results {}-{} (provide 'offset' parameter for more results):",
+ output.offset + 1,
+ output.offset + output.current_matches_page.len()
+ )
+ .unwrap();
+ }
+
+ for mat in output.current_matches_page {
+ write!(&mut llm_output, "\n{}", mat.display()).unwrap();
+ }
+
+ llm_output.into()
+ }
+ }
+}
+
+const RESULTS_PER_PAGE: usize = 50;
+
+pub struct FindPathTool {
+ project: Entity<Project>,
+}
+
+impl FindPathTool {
+ pub fn new(project: Entity<Project>) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for FindPathTool {
+ type Input = FindPathToolInput;
+ type Output = FindPathToolOutput;
+
+ fn name() -> &'static str {
+ "find_path"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Search
+ }
+
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ let mut title = "Find paths".to_string();
+ if let Ok(input) = input {
+ title.push_str(&format!(" matching “`{}`”", input.glob));
+ }
+ title.into()
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<FindPathToolOutput>> {
+ let search_paths_task = search_paths(&input.glob, self.project.clone(), cx);
+
+ cx.background_spawn(async move {
+ let matches = search_paths_task.await?;
+ let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len())
+ ..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())];
+
+ event_stream.update_fields(acp::ToolCallUpdateFields {
+ title: Some(if paginated_matches.is_empty() {
+ "No matches".into()
+ } else if paginated_matches.len() == 1 {
+ "1 match".into()
+ } else {
+ format!("{} matches", paginated_matches.len())
+ }),
+ content: Some(
+ paginated_matches
+ .iter()
+ .map(|path| acp::ToolCallContent::Content {
+ content: acp::ContentBlock::ResourceLink(acp::ResourceLink {
+ uri: format!("file://{}", path.display()),
+ name: path.to_string_lossy().into(),
+ annotations: None,
+ description: None,
+ mime_type: None,
+ size: None,
+ title: None,
+ }),
+ })
+ .collect(),
+ ),
+ ..Default::default()
+ });
+
+ Ok(FindPathToolOutput {
+ offset: input.offset,
+ current_matches_page: paginated_matches.to_vec(),
+ all_matches_len: matches.len(),
+ })
+ })
+ }
+}
+
+fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Result<Vec<PathBuf>>> {
+ let path_matcher = match PathMatcher::new([
+ // Sometimes models try to search for "". In this case, return all paths in the project.
+ if glob.is_empty() { "*" } else { glob },
+ ]) {
+ Ok(matcher) => matcher,
+ Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))),
+ };
+ let snapshots: Vec<_> = project
+ .read(cx)
+ .worktrees(cx)
+ .map(|worktree| worktree.read(cx).snapshot())
+ .collect();
+
+ cx.background_spawn(async move {
+ Ok(snapshots
+ .iter()
+ .flat_map(|snapshot| {
+ let root_name = PathBuf::from(snapshot.root_name());
+ snapshot
+ .entries(false, 0)
+ .map(move |entry| root_name.join(&entry.path))
+ .filter(|path| path_matcher.is_match(&path))
+ })
+ .collect())
+ })
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use gpui::TestAppContext;
+ use project::{FakeFs, Project};
+ use settings::SettingsStore;
+ use util::path;
+
+ #[gpui::test]
+ async fn test_find_path_tool(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root",
+ serde_json::json!({
+ "apple": {
+ "banana": {
+ "carrot": "1",
+ },
+ "bandana": {
+ "carbonara": "2",
+ },
+ "endive": "3"
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+
+ let matches = cx
+ .update(|cx| search_paths("root/**/car*", project.clone(), cx))
+ .await
+ .unwrap();
+ assert_eq!(
+ matches,
+ &[
+ PathBuf::from("root/apple/banana/carrot"),
+ PathBuf::from("root/apple/bandana/carbonara")
+ ]
+ );
+
+ let matches = cx
+ .update(|cx| search_paths("**/car*", project.clone(), cx))
+ .await
+ .unwrap();
+ assert_eq!(
+ matches,
+ &[
+ PathBuf::from("root/apple/banana/carrot"),
+ PathBuf::from("root/apple/bandana/carbonara")
+ ]
+ );
+ }
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ language::init(cx);
+ Project::init_settings(cx);
+ });
+ }
+}
@@ -0,0 +1,1182 @@
+use crate::{AgentTool, ToolCallEventStream};
+use agent_client_protocol as acp;
+use anyhow::{Result, anyhow};
+use futures::StreamExt;
+use gpui::{App, Entity, SharedString, Task};
+use language::{OffsetRangeExt, ParseStatus, Point};
+use project::{
+ Project, WorktreeSettings,
+ search::{SearchQuery, SearchResult},
+};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::Settings;
+use std::{cmp, fmt::Write, sync::Arc};
+use util::RangeExt;
+use util::markdown::MarkdownInlineCode;
+use util::paths::PathMatcher;
+
+/// Searches the contents of files in the project with a regular expression
+///
+/// - Prefer this tool to path search when searching for symbols in the project, because you won't need to guess what path it's in.
+/// - Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.)
+/// - Pass an `include_pattern` if you know how to narrow your search on the files system
+/// - Never use this tool to search for paths. Only search file contents with this tool.
+/// - Use this tool when you need to find files containing specific patterns
+/// - Results are paginated with 20 matches per page. Use the optional 'offset' parameter to request subsequent pages.
+/// - DO NOT use HTML entities solely to escape characters in the tool parameters.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct GrepToolInput {
+ /// A regex pattern to search for in the entire project. Note that the regex will be parsed by the Rust `regex` crate.
+ ///
+ /// Do NOT specify a path here! This will only be matched against the code **content**.
+ pub regex: String,
+ /// A glob pattern for the paths of files to include in the search.
+ /// Supports standard glob patterns like "**/*.rs" or "src/**/*.ts".
+ /// If omitted, all files in the project will be searched.
+ pub include_pattern: Option<String>,
+ /// Optional starting position for paginated results (0-based).
+ /// When not provided, starts from the beginning.
+ #[serde(default)]
+ pub offset: u32,
+ /// Whether the regex is case-sensitive. Defaults to false (case-insensitive).
+ #[serde(default)]
+ pub case_sensitive: bool,
+}
+
+impl GrepToolInput {
+ /// Which page of search results this is.
+ pub fn page(&self) -> u32 {
+ 1 + (self.offset / RESULTS_PER_PAGE)
+ }
+}
+
+const RESULTS_PER_PAGE: u32 = 20;
+
+pub struct GrepTool {
+ project: Entity<Project>,
+}
+
+impl GrepTool {
+ pub fn new(project: Entity<Project>) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for GrepTool {
+ type Input = GrepToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "grep"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Search
+ }
+
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ match input {
+ Ok(input) => {
+ let page = input.page();
+ let regex_str = MarkdownInlineCode(&input.regex);
+ let case_info = if input.case_sensitive {
+ " (case-sensitive)"
+ } else {
+ ""
+ };
+
+ if page > 1 {
+ format!("Get page {page} of search results for regex {regex_str}{case_info}")
+ } else {
+ format!("Search files for regex {regex_str}{case_info}")
+ }
+ }
+ Err(_) => "Search with regex".into(),
+ }
+ .into()
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output>> {
+ const CONTEXT_LINES: u32 = 2;
+ const MAX_ANCESTOR_LINES: u32 = 10;
+
+ let include_matcher = match PathMatcher::new(
+ input
+ .include_pattern
+ .as_ref()
+ .into_iter()
+ .collect::<Vec<_>>(),
+ ) {
+ Ok(matcher) => matcher,
+ Err(error) => {
+ return Task::ready(Err(anyhow!("invalid include glob pattern: {error}")));
+ }
+ };
+
+ // Exclude global file_scan_exclusions and private_files settings
+ let exclude_matcher = {
+ let global_settings = WorktreeSettings::get_global(cx);
+ let exclude_patterns = global_settings
+ .file_scan_exclusions
+ .sources()
+ .iter()
+ .chain(global_settings.private_files.sources().iter());
+
+ match PathMatcher::new(exclude_patterns) {
+ Ok(matcher) => matcher,
+ Err(error) => {
+ return Task::ready(Err(anyhow!("invalid exclude pattern: {error}")));
+ }
+ }
+ };
+
+ let query = match SearchQuery::regex(
+ &input.regex,
+ false,
+ input.case_sensitive,
+ false,
+ false,
+ include_matcher,
+ exclude_matcher,
+ true, // Always match file include pattern against *full project paths* that start with a project root.
+ None,
+ ) {
+ Ok(query) => query,
+ Err(error) => return Task::ready(Err(error)),
+ };
+
+ let results = self
+ .project
+ .update(cx, |project, cx| project.search(query, cx));
+
+ let project = self.project.downgrade();
+ cx.spawn(async move |cx| {
+ futures::pin_mut!(results);
+
+ let mut output = String::new();
+ let mut skips_remaining = input.offset;
+ let mut matches_found = 0;
+ let mut has_more_matches = false;
+
+ 'outer: while let Some(SearchResult::Buffer { buffer, ranges }) = results.next().await {
+ if ranges.is_empty() {
+ continue;
+ }
+
+ let Ok((Some(path), mut parse_status)) = buffer.read_with(cx, |buffer, cx| {
+ (buffer.file().map(|file| file.full_path(cx)), buffer.parse_status())
+ }) else {
+ continue;
+ };
+
+ // Check if this file should be excluded based on its worktree settings
+ if let Ok(Some(project_path)) = project.read_with(cx, |project, cx| {
+ project.find_project_path(&path, cx)
+ })
+ && cx.update(|cx| {
+ let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
+ worktree_settings.is_path_excluded(&project_path.path)
+ || worktree_settings.is_path_private(&project_path.path)
+ }).unwrap_or(false) {
+ continue;
+ }
+
+ while *parse_status.borrow() != ParseStatus::Idle {
+ parse_status.changed().await?;
+ }
+
+ let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
+
+ let mut ranges = ranges
+ .into_iter()
+ .map(|range| {
+ let matched = range.to_point(&snapshot);
+ let matched_end_line_len = snapshot.line_len(matched.end.row);
+ let full_lines = Point::new(matched.start.row, 0)..Point::new(matched.end.row, matched_end_line_len);
+ let symbols = snapshot.symbols_containing(matched.start, None);
+
+ if let Some(ancestor_node) = snapshot.syntax_ancestor(full_lines.clone()) {
+ let full_ancestor_range = ancestor_node.byte_range().to_point(&snapshot);
+ let end_row = full_ancestor_range.end.row.min(full_ancestor_range.start.row + MAX_ANCESTOR_LINES);
+ let end_col = snapshot.line_len(end_row);
+ let capped_ancestor_range = Point::new(full_ancestor_range.start.row, 0)..Point::new(end_row, end_col);
+
+ if capped_ancestor_range.contains_inclusive(&full_lines) {
+ return (capped_ancestor_range, Some(full_ancestor_range), symbols)
+ }
+ }
+
+ let mut matched = matched;
+ matched.start.column = 0;
+ matched.start.row =
+ matched.start.row.saturating_sub(CONTEXT_LINES);
+ matched.end.row = cmp::min(
+ snapshot.max_point().row,
+ matched.end.row + CONTEXT_LINES,
+ );
+ matched.end.column = snapshot.line_len(matched.end.row);
+
+ (matched, None, symbols)
+ })
+ .peekable();
+
+ let mut file_header_written = false;
+
+ while let Some((mut range, ancestor_range, parent_symbols)) = ranges.next(){
+ if skips_remaining > 0 {
+ skips_remaining -= 1;
+ continue;
+ }
+
+ // We'd already found a full page of matches, and we just found one more.
+ if matches_found >= RESULTS_PER_PAGE {
+ has_more_matches = true;
+ break 'outer;
+ }
+
+ while let Some((next_range, _, _)) = ranges.peek() {
+ if range.end.row >= next_range.start.row {
+ range.end = next_range.end;
+ ranges.next();
+ } else {
+ break;
+ }
+ }
+
+ if !file_header_written {
+ writeln!(output, "\n## Matches in {}", path.display())?;
+ file_header_written = true;
+ }
+
+ let end_row = range.end.row;
+ output.push_str("\n### ");
+
+ if let Some(parent_symbols) = &parent_symbols {
+ for symbol in parent_symbols {
+ write!(output, "{} › ", symbol.text)?;
+ }
+ }
+
+ if range.start.row == end_row {
+ writeln!(output, "L{}", range.start.row + 1)?;
+ } else {
+ writeln!(output, "L{}-{}", range.start.row + 1, end_row + 1)?;
+ }
+
+ output.push_str("```\n");
+ output.extend(snapshot.text_for_range(range));
+ output.push_str("\n```\n");
+
+ if let Some(ancestor_range) = ancestor_range
+ && end_row < ancestor_range.end.row {
+ let remaining_lines = ancestor_range.end.row - end_row;
+ writeln!(output, "\n{} lines remaining in ancestor node. Read the file to see all.", remaining_lines)?;
+ }
+
+ matches_found += 1;
+ }
+ }
+
+ if matches_found == 0 {
+ Ok("No matches found".into())
+ } else if has_more_matches {
+ Ok(format!(
+ "Showing matches {}-{} (there were more matches found; use offset: {} to see next page):\n{output}",
+ input.offset + 1,
+ input.offset + matches_found,
+ input.offset + RESULTS_PER_PAGE,
+ ))
+ } else {
+ Ok(format!("Found {matches_found} matches:\n{output}"))
+ }
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::ToolCallEventStream;
+
+ use super::*;
+ use gpui::{TestAppContext, UpdateGlobal};
+ use language::{Language, LanguageConfig, LanguageMatcher};
+ use project::{FakeFs, Project, WorktreeSettings};
+ use serde_json::json;
+ use settings::SettingsStore;
+ use unindent::Unindent;
+ use util::path;
+
+ #[gpui::test]
+ async fn test_grep_tool_with_include_pattern(cx: &mut TestAppContext) {
+ init_test(cx);
+ cx.executor().allow_parking();
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ serde_json::json!({
+ "src": {
+ "main.rs": "fn main() {\n println!(\"Hello, world!\");\n}",
+ "utils": {
+ "helper.rs": "fn helper() {\n println!(\"I'm a helper!\");\n}",
+ },
+ },
+ "tests": {
+ "test_main.rs": "fn test_main() {\n assert!(true);\n}",
+ }
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+
+ // Test with include pattern for Rust files inside the root of the project
+ let input = GrepToolInput {
+ regex: "println".to_string(),
+ include_pattern: Some("root/**/*.rs".to_string()),
+ offset: 0,
+ case_sensitive: false,
+ };
+
+ let result = run_grep_tool(input, project.clone(), cx).await;
+ assert!(result.contains("main.rs"), "Should find matches in main.rs");
+ assert!(
+ result.contains("helper.rs"),
+ "Should find matches in helper.rs"
+ );
+ assert!(
+ !result.contains("test_main.rs"),
+ "Should not include test_main.rs even though it's a .rs file (because it doesn't have the pattern)"
+ );
+
+ // Test with include pattern for src directory only
+ let input = GrepToolInput {
+ regex: "fn".to_string(),
+ include_pattern: Some("root/**/src/**".to_string()),
+ offset: 0,
+ case_sensitive: false,
+ };
+
+ let result = run_grep_tool(input, project.clone(), cx).await;
+ assert!(
+ result.contains("main.rs"),
+ "Should find matches in src/main.rs"
+ );
+ assert!(
+ result.contains("helper.rs"),
+ "Should find matches in src/utils/helper.rs"
+ );
+ assert!(
+ !result.contains("test_main.rs"),
+ "Should not include test_main.rs as it's not in src directory"
+ );
+
+ // Test with empty include pattern (should default to all files)
+ let input = GrepToolInput {
+ regex: "fn".to_string(),
+ include_pattern: None,
+ offset: 0,
+ case_sensitive: false,
+ };
+
+ let result = run_grep_tool(input, project.clone(), cx).await;
+ assert!(result.contains("main.rs"), "Should find matches in main.rs");
+ assert!(
+ result.contains("helper.rs"),
+ "Should find matches in helper.rs"
+ );
+ assert!(
+ result.contains("test_main.rs"),
+ "Should include test_main.rs"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_grep_tool_with_case_sensitivity(cx: &mut TestAppContext) {
+ init_test(cx);
+ cx.executor().allow_parking();
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ serde_json::json!({
+ "case_test.txt": "This file has UPPERCASE and lowercase text.\nUPPERCASE patterns should match only with case_sensitive: true",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+
+ // Test case-insensitive search (default)
+ let input = GrepToolInput {
+ regex: "uppercase".to_string(),
+ include_pattern: Some("**/*.txt".to_string()),
+ offset: 0,
+ case_sensitive: false,
+ };
+
+ let result = run_grep_tool(input, project.clone(), cx).await;
+ assert!(
+ result.contains("UPPERCASE"),
+ "Case-insensitive search should match uppercase"
+ );
+
+ // Test case-sensitive search
+ let input = GrepToolInput {
+ regex: "uppercase".to_string(),
+ include_pattern: Some("**/*.txt".to_string()),
+ offset: 0,
+ case_sensitive: true,
+ };
+
+ let result = run_grep_tool(input, project.clone(), cx).await;
+ assert!(
+ !result.contains("UPPERCASE"),
+ "Case-sensitive search should not match uppercase"
+ );
+
+ // Test case-sensitive search
+ let input = GrepToolInput {
+ regex: "LOWERCASE".to_string(),
+ include_pattern: Some("**/*.txt".to_string()),
+ offset: 0,
+ case_sensitive: true,
+ };
+
+ let result = run_grep_tool(input, project.clone(), cx).await;
+
+ assert!(
+ !result.contains("lowercase"),
+ "Case-sensitive search should match lowercase"
+ );
+
+ // Test case-sensitive search for lowercase pattern
+ let input = GrepToolInput {
+ regex: "lowercase".to_string(),
+ include_pattern: Some("**/*.txt".to_string()),
+ offset: 0,
+ case_sensitive: true,
+ };
+
+ let result = run_grep_tool(input, project.clone(), cx).await;
+ assert!(
+ result.contains("lowercase"),
+ "Case-sensitive search should match lowercase text"
+ );
+ }
+
+ /// Helper function to set up a syntax test environment
+ async fn setup_syntax_test(cx: &mut TestAppContext) -> Entity<Project> {
+ use unindent::Unindent;
+ init_test(cx);
+ cx.executor().allow_parking();
+
+ let fs = FakeFs::new(cx.executor());
+
+ // Create test file with syntax structures
+ fs.insert_tree(
+ path!("/root"),
+ serde_json::json!({
+ "test_syntax.rs": r#"
+ fn top_level_function() {
+ println!("This is at the top level");
+ }
+
+ mod feature_module {
+ pub mod nested_module {
+ pub fn nested_function(
+ first_arg: String,
+ second_arg: i32,
+ ) {
+ println!("Function in nested module");
+ println!("{first_arg}");
+ println!("{second_arg}");
+ }
+ }
+ }
+
+ struct MyStruct {
+ field1: String,
+ field2: i32,
+ }
+
+ impl MyStruct {
+ fn method_with_block() {
+ let condition = true;
+ if condition {
+ println!("Inside if block");
+ }
+ }
+
+ fn long_function() {
+ println!("Line 1");
+ println!("Line 2");
+ println!("Line 3");
+ println!("Line 4");
+ println!("Line 5");
+ println!("Line 6");
+ println!("Line 7");
+ println!("Line 8");
+ println!("Line 9");
+ println!("Line 10");
+ println!("Line 11");
+ println!("Line 12");
+ }
+ }
+
+ trait Processor {
+ fn process(&self, input: &str) -> String;
+ }
+
+ impl Processor for MyStruct {
+ fn process(&self, input: &str) -> String {
+ format!("Processed: {}", input)
+ }
+ }
+ "#.unindent().trim(),
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+
+ project.update(cx, |project, _cx| {
+ project.languages().add(rust_lang().into())
+ });
+
+ project
+ }
+
+ #[gpui::test]
+ async fn test_grep_top_level_function(cx: &mut TestAppContext) {
+ let project = setup_syntax_test(cx).await;
+
+ // Test: Line at the top level of the file
+ let input = GrepToolInput {
+ regex: "This is at the top level".to_string(),
+ include_pattern: Some("**/*.rs".to_string()),
+ offset: 0,
+ case_sensitive: false,
+ };
+
+ let result = run_grep_tool(input, project.clone(), cx).await;
+ let expected = r#"
+ Found 1 matches:
+
+ ## Matches in root/test_syntax.rs
+
+ ### fn top_level_function › L1-3
+ ```
+ fn top_level_function() {
+ println!("This is at the top level");
+ }
+ ```
+ "#
+ .unindent();
+ assert_eq!(result, expected);
+ }
+
+ #[gpui::test]
+ async fn test_grep_function_body(cx: &mut TestAppContext) {
+ let project = setup_syntax_test(cx).await;
+
+ // Test: Line inside a function body
+ let input = GrepToolInput {
+ regex: "Function in nested module".to_string(),
+ include_pattern: Some("**/*.rs".to_string()),
+ offset: 0,
+ case_sensitive: false,
+ };
+
+ let result = run_grep_tool(input, project.clone(), cx).await;
+ let expected = r#"
+ Found 1 matches:
+
+ ## Matches in root/test_syntax.rs
+
+ ### mod feature_module › pub mod nested_module › pub fn nested_function › L10-14
+ ```
+ ) {
+ println!("Function in nested module");
+ println!("{first_arg}");
+ println!("{second_arg}");
+ }
+ ```
+ "#
+ .unindent();
+ assert_eq!(result, expected);
+ }
+
+ #[gpui::test]
+ async fn test_grep_function_args_and_body(cx: &mut TestAppContext) {
+ let project = setup_syntax_test(cx).await;
+
+ // Test: Line with a function argument
+ let input = GrepToolInput {
+ regex: "second_arg".to_string(),
+ include_pattern: Some("**/*.rs".to_string()),
+ offset: 0,
+ case_sensitive: false,
+ };
+
+ let result = run_grep_tool(input, project.clone(), cx).await;
+ let expected = r#"
+ Found 1 matches:
+
+ ## Matches in root/test_syntax.rs
+
+ ### mod feature_module › pub mod nested_module › pub fn nested_function › L7-14
+ ```
+ pub fn nested_function(
+ first_arg: String,
+ second_arg: i32,
+ ) {
+ println!("Function in nested module");
+ println!("{first_arg}");
+ println!("{second_arg}");
+ }
+ ```
+ "#
+ .unindent();
+ assert_eq!(result, expected);
+ }
+
+ #[gpui::test]
+ async fn test_grep_if_block(cx: &mut TestAppContext) {
+ use unindent::Unindent;
+ let project = setup_syntax_test(cx).await;
+
+ // Test: Line inside an if block
+ let input = GrepToolInput {
+ regex: "Inside if block".to_string(),
+ include_pattern: Some("**/*.rs".to_string()),
+ offset: 0,
+ case_sensitive: false,
+ };
+
+ let result = run_grep_tool(input, project.clone(), cx).await;
+ let expected = r#"
+ Found 1 matches:
+
+ ## Matches in root/test_syntax.rs
+
+ ### impl MyStruct › fn method_with_block › L26-28
+ ```
+ if condition {
+ println!("Inside if block");
+ }
+ ```
+ "#
+ .unindent();
+ assert_eq!(result, expected);
+ }
+
+ #[gpui::test]
+ async fn test_grep_long_function_top(cx: &mut TestAppContext) {
+ use unindent::Unindent;
+ let project = setup_syntax_test(cx).await;
+
+ // Test: Line in the middle of a long function - should show message about remaining lines
+ let input = GrepToolInput {
+ regex: "Line 5".to_string(),
+ include_pattern: Some("**/*.rs".to_string()),
+ offset: 0,
+ case_sensitive: false,
+ };
+
+ let result = run_grep_tool(input, project.clone(), cx).await;
+ let expected = r#"
+ Found 1 matches:
+
+ ## Matches in root/test_syntax.rs
+
+ ### impl MyStruct › fn long_function › L31-41
+ ```
+ fn long_function() {
+ println!("Line 1");
+ println!("Line 2");
+ println!("Line 3");
+ println!("Line 4");
+ println!("Line 5");
+ println!("Line 6");
+ println!("Line 7");
+ println!("Line 8");
+ println!("Line 9");
+ println!("Line 10");
+ ```
+
+ 3 lines remaining in ancestor node. Read the file to see all.
+ "#
+ .unindent();
+ assert_eq!(result, expected);
+ }
+
+ #[gpui::test]
+ async fn test_grep_long_function_bottom(cx: &mut TestAppContext) {
+ use unindent::Unindent;
+ let project = setup_syntax_test(cx).await;
+
+ // Test: Line in the long function
+ let input = GrepToolInput {
+ regex: "Line 12".to_string(),
+ include_pattern: Some("**/*.rs".to_string()),
+ offset: 0,
+ case_sensitive: false,
+ };
+
+ let result = run_grep_tool(input, project.clone(), cx).await;
+ let expected = r#"
+ Found 1 matches:
+
+ ## Matches in root/test_syntax.rs
+
+ ### impl MyStruct › fn long_function › L41-45
+ ```
+ println!("Line 10");
+ println!("Line 11");
+ println!("Line 12");
+ }
+ }
+ ```
+ "#
+ .unindent();
+ assert_eq!(result, expected);
+ }
+
+ async fn run_grep_tool(
+ input: GrepToolInput,
+ project: Entity<Project>,
+ cx: &mut TestAppContext,
+ ) -> String {
+ let tool = Arc::new(GrepTool { project });
+ let task = cx.update(|cx| tool.run(input, ToolCallEventStream::test().0, cx));
+
+ match task.await {
+ Ok(result) => {
+ if cfg!(windows) {
+ result.replace("root\\", "root/")
+ } else {
+ result
+ }
+ }
+ Err(e) => panic!("Failed to run grep tool: {}", e),
+ }
+ }
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ language::init(cx);
+ Project::init_settings(cx);
+ });
+ }
+
+ fn rust_lang() -> Language {
+ Language::new(
+ LanguageConfig {
+ name: "Rust".into(),
+ matcher: LanguageMatcher {
+ path_suffixes: vec!["rs".to_string()],
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ Some(tree_sitter_rust::LANGUAGE.into()),
+ )
+ .with_outline_query(include_str!("../../../languages/src/rust/outline.scm"))
+ .unwrap()
+ }
+
+ #[gpui::test]
+ async fn test_grep_security_boundaries(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+
+ fs.insert_tree(
+ path!("/"),
+ json!({
+ "project_root": {
+ "allowed_file.rs": "fn main() { println!(\"This file is in the project\"); }",
+ ".mysecrets": "SECRET_KEY=abc123\nfn secret() { /* private */ }",
+ ".secretdir": {
+ "config": "fn special_configuration() { /* excluded */ }"
+ },
+ ".mymetadata": "fn custom_metadata() { /* excluded */ }",
+ "subdir": {
+ "normal_file.rs": "fn normal_file_content() { /* Normal */ }",
+ "special.privatekey": "fn private_key_content() { /* private */ }",
+ "data.mysensitive": "fn sensitive_data() { /* private */ }"
+ }
+ },
+ "outside_project": {
+ "sensitive_file.rs": "fn outside_function() { /* This file is outside the project */ }"
+ }
+ }),
+ )
+ .await;
+
+ cx.update(|cx| {
+ use gpui::UpdateGlobal;
+ use project::WorktreeSettings;
+ use settings::SettingsStore;
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings::<WorktreeSettings>(cx, |settings| {
+ settings.file_scan_exclusions = Some(vec![
+ "**/.secretdir".to_string(),
+ "**/.mymetadata".to_string(),
+ ]);
+ settings.private_files = Some(vec![
+ "**/.mysecrets".to_string(),
+ "**/*.privatekey".to_string(),
+ "**/*.mysensitive".to_string(),
+ ]);
+ });
+ });
+ });
+
+ let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
+
+ // Searching for files outside the project worktree should return no results
+ let result = run_grep_tool(
+ GrepToolInput {
+ regex: "outside_function".to_string(),
+ include_pattern: None,
+ offset: 0,
+ case_sensitive: false,
+ },
+ project.clone(),
+ cx,
+ )
+ .await;
+ let paths = extract_paths_from_results(&result);
+ assert!(
+ paths.is_empty(),
+ "grep_tool should not find files outside the project worktree"
+ );
+
+ // Searching within the project should succeed
+ let result = run_grep_tool(
+ GrepToolInput {
+ regex: "main".to_string(),
+ include_pattern: None,
+ offset: 0,
+ case_sensitive: false,
+ },
+ project.clone(),
+ cx,
+ )
+ .await;
+ let paths = extract_paths_from_results(&result);
+ assert!(
+ paths.iter().any(|p| p.contains("allowed_file.rs")),
+ "grep_tool should be able to search files inside worktrees"
+ );
+
+ // Searching files that match file_scan_exclusions should return no results
+ let result = run_grep_tool(
+ GrepToolInput {
+ regex: "special_configuration".to_string(),
+ include_pattern: None,
+ offset: 0,
+ case_sensitive: false,
+ },
+ project.clone(),
+ cx,
+ )
+ .await;
+ let paths = extract_paths_from_results(&result);
+ assert!(
+ paths.is_empty(),
+ "grep_tool should not search files in .secretdir (file_scan_exclusions)"
+ );
+
+ let result = run_grep_tool(
+ GrepToolInput {
+ regex: "custom_metadata".to_string(),
+ include_pattern: None,
+ offset: 0,
+ case_sensitive: false,
+ },
+ project.clone(),
+ cx,
+ )
+ .await;
+ let paths = extract_paths_from_results(&result);
+ assert!(
+ paths.is_empty(),
+ "grep_tool should not search .mymetadata files (file_scan_exclusions)"
+ );
+
+ // Searching private files should return no results
+ let result = run_grep_tool(
+ GrepToolInput {
+ regex: "SECRET_KEY".to_string(),
+ include_pattern: None,
+ offset: 0,
+ case_sensitive: false,
+ },
+ project.clone(),
+ cx,
+ )
+ .await;
+ let paths = extract_paths_from_results(&result);
+ assert!(
+ paths.is_empty(),
+ "grep_tool should not search .mysecrets (private_files)"
+ );
+
+ let result = run_grep_tool(
+ GrepToolInput {
+ regex: "private_key_content".to_string(),
+ include_pattern: None,
+ offset: 0,
+ case_sensitive: false,
+ },
+ project.clone(),
+ cx,
+ )
+ .await;
+ let paths = extract_paths_from_results(&result);
+
+ assert!(
+ paths.is_empty(),
+ "grep_tool should not search .privatekey files (private_files)"
+ );
+
+ let result = run_grep_tool(
+ GrepToolInput {
+ regex: "sensitive_data".to_string(),
+ include_pattern: None,
+ offset: 0,
+ case_sensitive: false,
+ },
+ project.clone(),
+ cx,
+ )
+ .await;
+ let paths = extract_paths_from_results(&result);
+ assert!(
+ paths.is_empty(),
+ "grep_tool should not search .mysensitive files (private_files)"
+ );
+
+ // Searching a normal file should still work, even with private_files configured
+ let result = run_grep_tool(
+ GrepToolInput {
+ regex: "normal_file_content".to_string(),
+ include_pattern: None,
+ offset: 0,
+ case_sensitive: false,
+ },
+ project.clone(),
+ cx,
+ )
+ .await;
+ let paths = extract_paths_from_results(&result);
+ assert!(
+ paths.iter().any(|p| p.contains("normal_file.rs")),
+ "Should be able to search normal files"
+ );
+
+ // Path traversal attempts with .. in include_pattern should not escape project
+ let result = run_grep_tool(
+ GrepToolInput {
+ regex: "outside_function".to_string(),
+ include_pattern: Some("../outside_project/**/*.rs".to_string()),
+ offset: 0,
+ case_sensitive: false,
+ },
+ project.clone(),
+ cx,
+ )
+ .await;
+ let paths = extract_paths_from_results(&result);
+ assert!(
+ paths.is_empty(),
+ "grep_tool should not allow escaping project boundaries with relative paths"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_grep_with_multiple_worktree_settings(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+
+ // Create first worktree with its own private files
+ fs.insert_tree(
+ path!("/worktree1"),
+ json!({
+ ".zed": {
+ "settings.json": r#"{
+ "file_scan_exclusions": ["**/fixture.*"],
+ "private_files": ["**/secret.rs"]
+ }"#
+ },
+ "src": {
+ "main.rs": "fn main() { let secret_key = \"hidden\"; }",
+ "secret.rs": "const API_KEY: &str = \"secret_value\";",
+ "utils.rs": "pub fn get_config() -> String { \"config\".to_string() }"
+ },
+ "tests": {
+ "test.rs": "fn test_secret() { assert!(true); }",
+ "fixture.sql": "SELECT * FROM secret_table;"
+ }
+ }),
+ )
+ .await;
+
+ // Create second worktree with different private files
+ fs.insert_tree(
+ path!("/worktree2"),
+ json!({
+ ".zed": {
+ "settings.json": r#"{
+ "file_scan_exclusions": ["**/internal.*"],
+ "private_files": ["**/private.js", "**/data.json"]
+ }"#
+ },
+ "lib": {
+ "public.js": "export function getSecret() { return 'public'; }",
+ "private.js": "const SECRET_KEY = \"private_value\";",
+ "data.json": "{\"secret_data\": \"hidden\"}"
+ },
+ "docs": {
+ "README.md": "# Documentation with secret info",
+ "internal.md": "Internal secret documentation"
+ }
+ }),
+ )
+ .await;
+
+ // Set global settings
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings::<WorktreeSettings>(cx, |settings| {
+ settings.file_scan_exclusions =
+ Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
+ settings.private_files = Some(vec!["**/.env".to_string()]);
+ });
+ });
+ });
+
+ let project = Project::test(
+ fs.clone(),
+ [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
+ cx,
+ )
+ .await;
+
+ // Wait for worktrees to be fully scanned
+ cx.executor().run_until_parked();
+
+ // Search for "secret" - should exclude files based on worktree-specific settings
+ let result = run_grep_tool(
+ GrepToolInput {
+ regex: "secret".to_string(),
+ include_pattern: None,
+ offset: 0,
+ case_sensitive: false,
+ },
+ project.clone(),
+ cx,
+ )
+ .await;
+ let paths = extract_paths_from_results(&result);
+
+ // Should find matches in non-private files
+ assert!(
+ paths.iter().any(|p| p.contains("main.rs")),
+ "Should find 'secret' in worktree1/src/main.rs"
+ );
+ assert!(
+ paths.iter().any(|p| p.contains("test.rs")),
+ "Should find 'secret' in worktree1/tests/test.rs"
+ );
+ assert!(
+ paths.iter().any(|p| p.contains("public.js")),
+ "Should find 'secret' in worktree2/lib/public.js"
+ );
+ assert!(
+ paths.iter().any(|p| p.contains("README.md")),
+ "Should find 'secret' in worktree2/docs/README.md"
+ );
+
+ // Should NOT find matches in private/excluded files based on worktree settings
+ assert!(
+ !paths.iter().any(|p| p.contains("secret.rs")),
+ "Should not search in worktree1/src/secret.rs (local private_files)"
+ );
+ assert!(
+ !paths.iter().any(|p| p.contains("fixture.sql")),
+ "Should not search in worktree1/tests/fixture.sql (local file_scan_exclusions)"
+ );
+ assert!(
+ !paths.iter().any(|p| p.contains("private.js")),
+ "Should not search in worktree2/lib/private.js (local private_files)"
+ );
+ assert!(
+ !paths.iter().any(|p| p.contains("data.json")),
+ "Should not search in worktree2/lib/data.json (local private_files)"
+ );
+ assert!(
+ !paths.iter().any(|p| p.contains("internal.md")),
+ "Should not search in worktree2/docs/internal.md (local file_scan_exclusions)"
+ );
+
+ // Test with `include_pattern` specific to one worktree
+ let result = run_grep_tool(
+ GrepToolInput {
+ regex: "secret".to_string(),
+ include_pattern: Some("worktree1/**/*.rs".to_string()),
+ offset: 0,
+ case_sensitive: false,
+ },
+ project.clone(),
+ cx,
+ )
+ .await;
+
+ let paths = extract_paths_from_results(&result);
+
+ // Should only find matches in worktree1 *.rs files (excluding private ones)
+ assert!(
+ paths.iter().any(|p| p.contains("main.rs")),
+ "Should find match in worktree1/src/main.rs"
+ );
+ assert!(
+ paths.iter().any(|p| p.contains("test.rs")),
+ "Should find match in worktree1/tests/test.rs"
+ );
+ assert!(
+ !paths.iter().any(|p| p.contains("secret.rs")),
+ "Should not find match in excluded worktree1/src/secret.rs"
+ );
+ assert!(
+ paths.iter().all(|p| !p.contains("worktree2")),
+ "Should not find any matches in worktree2"
+ );
+ }
+
+ // Helper function to extract file paths from grep results
+ fn extract_paths_from_results(results: &str) -> Vec<String> {
+ results
+ .lines()
+ .filter(|line| line.starts_with("## Matches in "))
+ .map(|line| {
+ line.strip_prefix("## Matches in ")
+ .unwrap()
+ .trim()
+ .to_string()
+ })
+ .collect()
+ }
+}
@@ -0,0 +1,662 @@
+use crate::{AgentTool, ToolCallEventStream};
+use agent_client_protocol::ToolKind;
+use anyhow::{Result, anyhow};
+use gpui::{App, Entity, SharedString, Task};
+use project::{Project, WorktreeSettings};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::Settings;
+use std::fmt::Write;
+use std::{path::Path, sync::Arc};
+use util::markdown::MarkdownInlineCode;
+
+/// Lists files and directories in a given path. Prefer the `grep` or `find_path` tools when searching the codebase.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct ListDirectoryToolInput {
+ /// The fully-qualified path of the directory to list in the project.
+ ///
+ /// This path should never be absolute, and the first component of the path should always be a root directory in a project.
+ ///
+ /// <example>
+ /// If the project has the following root directories:
+ ///
+ /// - directory1
+ /// - directory2
+ ///
+ /// You can list the contents of `directory1` by using the path `directory1`.
+ /// </example>
+ ///
+ /// <example>
+ /// If the project has the following root directories:
+ ///
+ /// - foo
+ /// - bar
+ ///
+ /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`.
+ /// </example>
+ pub path: String,
+}
+
+pub struct ListDirectoryTool {
+ project: Entity<Project>,
+}
+
+impl ListDirectoryTool {
+ pub fn new(project: Entity<Project>) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for ListDirectoryTool {
+ type Input = ListDirectoryToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "list_directory"
+ }
+
+ fn kind() -> ToolKind {
+ ToolKind::Read
+ }
+
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ if let Ok(input) = input {
+ let path = MarkdownInlineCode(&input.path);
+ format!("List the {path} directory's contents").into()
+ } else {
+ "List directory".into()
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output>> {
+ // Sometimes models will return these even though we tell it to give a path and not a glob.
+ // When this happens, just list the root worktree directories.
+ if matches!(input.path.as_str(), "." | "" | "./" | "*") {
+ let output = self
+ .project
+ .read(cx)
+ .worktrees(cx)
+ .filter_map(|worktree| {
+ worktree.read(cx).root_entry().and_then(|entry| {
+ if entry.is_dir() {
+ entry.path.to_str()
+ } else {
+ None
+ }
+ })
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ return Task::ready(Ok(output));
+ }
+
+ let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else {
+ return Task::ready(Err(anyhow!("Path {} not found in project", input.path)));
+ };
+ let Some(worktree) = self
+ .project
+ .read(cx)
+ .worktree_for_id(project_path.worktree_id, cx)
+ else {
+ return Task::ready(Err(anyhow!("Worktree not found")));
+ };
+
+ // Check if the directory whose contents we're listing is itself excluded or private
+ let global_settings = WorktreeSettings::get_global(cx);
+ if global_settings.is_path_excluded(&project_path.path) {
+ return Task::ready(Err(anyhow!(
+ "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",
+ &input.path
+ )));
+ }
+
+ if global_settings.is_path_private(&project_path.path) {
+ return Task::ready(Err(anyhow!(
+ "Cannot list directory because its path matches the user's global `private_files` setting: {}",
+ &input.path
+ )));
+ }
+
+ let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
+ if worktree_settings.is_path_excluded(&project_path.path) {
+ return Task::ready(Err(anyhow!(
+ "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}",
+ &input.path
+ )));
+ }
+
+ if worktree_settings.is_path_private(&project_path.path) {
+ return Task::ready(Err(anyhow!(
+ "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",
+ &input.path
+ )));
+ }
+
+ let worktree_snapshot = worktree.read(cx).snapshot();
+ let worktree_root_name = worktree.read(cx).root_name().to_string();
+
+ let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
+ return Task::ready(Err(anyhow!("Path not found: {}", input.path)));
+ };
+
+ if !entry.is_dir() {
+ return Task::ready(Err(anyhow!("{} is not a directory.", input.path)));
+ }
+ let worktree_snapshot = worktree.read(cx).snapshot();
+
+ let mut folders = Vec::new();
+ let mut files = Vec::new();
+
+ for entry in worktree_snapshot.child_entries(&project_path.path) {
+ // Skip private and excluded files and directories
+ if global_settings.is_path_private(&entry.path)
+ || global_settings.is_path_excluded(&entry.path)
+ {
+ continue;
+ }
+
+ if self
+ .project
+ .read(cx)
+ .find_project_path(&entry.path, cx)
+ .map(|project_path| {
+ let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
+
+ worktree_settings.is_path_excluded(&project_path.path)
+ || worktree_settings.is_path_private(&project_path.path)
+ })
+ .unwrap_or(false)
+ {
+ continue;
+ }
+
+ let full_path = Path::new(&worktree_root_name)
+ .join(&entry.path)
+ .display()
+ .to_string();
+ if entry.is_dir() {
+ folders.push(full_path);
+ } else {
+ files.push(full_path);
+ }
+ }
+
+ let mut output = String::new();
+
+ if !folders.is_empty() {
+ writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap();
+ }
+
+ if !files.is_empty() {
+ writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap();
+ }
+
+ if output.is_empty() {
+ writeln!(output, "{} is empty.", input.path).unwrap();
+ }
+
+ Task::ready(Ok(output))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use gpui::{TestAppContext, UpdateGlobal};
+ use indoc::indoc;
+ use project::{FakeFs, Project, WorktreeSettings};
+ use serde_json::json;
+ use settings::SettingsStore;
+ use util::path;
+
+ fn platform_paths(path_str: &str) -> String {
+ if cfg!(target_os = "windows") {
+ path_str.replace("/", "\\")
+ } else {
+ path_str.to_string()
+ }
+ }
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ language::init(cx);
+ Project::init_settings(cx);
+ });
+ }
+
+ #[gpui::test]
+ async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/project"),
+ json!({
+ "src": {
+ "main.rs": "fn main() {}",
+ "lib.rs": "pub fn hello() {}",
+ "models": {
+ "user.rs": "struct User {}",
+ "post.rs": "struct Post {}"
+ },
+ "utils": {
+ "helper.rs": "pub fn help() {}"
+ }
+ },
+ "tests": {
+ "integration_test.rs": "#[test] fn test() {}"
+ },
+ "README.md": "# Project",
+ "Cargo.toml": "[package]"
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+ let tool = Arc::new(ListDirectoryTool::new(project));
+
+ // Test listing root directory
+ let input = ListDirectoryToolInput {
+ path: "project".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert_eq!(
+ output,
+ platform_paths(indoc! {"
+ # Folders:
+ project/src
+ project/tests
+
+ # Files:
+ project/Cargo.toml
+ project/README.md
+ "})
+ );
+
+ // Test listing src directory
+ let input = ListDirectoryToolInput {
+ path: "project/src".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert_eq!(
+ output,
+ platform_paths(indoc! {"
+ # Folders:
+ project/src/models
+ project/src/utils
+
+ # Files:
+ project/src/lib.rs
+ project/src/main.rs
+ "})
+ );
+
+ // Test listing directory with only files
+ let input = ListDirectoryToolInput {
+ path: "project/tests".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert!(!output.contains("# Folders:"));
+ assert!(output.contains("# Files:"));
+ assert!(output.contains(&platform_paths("project/tests/integration_test.rs")));
+ }
+
+ #[gpui::test]
+ async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/project"),
+ json!({
+ "empty_dir": {}
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+ let tool = Arc::new(ListDirectoryTool::new(project));
+
+ let input = ListDirectoryToolInput {
+ path: "project/empty_dir".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert_eq!(output, "project/empty_dir is empty.\n");
+ }
+
+ #[gpui::test]
+ async fn test_list_directory_error_cases(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/project"),
+ json!({
+ "file.txt": "content"
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+ let tool = Arc::new(ListDirectoryTool::new(project));
+
+ // Test non-existent path
+ let input = ListDirectoryToolInput {
+ path: "project/nonexistent".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await;
+ assert!(output.unwrap_err().to_string().contains("Path not found"));
+
+ // Test trying to list a file instead of directory
+ let input = ListDirectoryToolInput {
+ path: "project/file.txt".into(),
+ };
+ let output = cx
+ .update(|cx| tool.run(input, ToolCallEventStream::test().0, cx))
+ .await;
+ assert!(
+ output
+ .unwrap_err()
+ .to_string()
+ .contains("is not a directory")
+ );
+ }
+
+ #[gpui::test]
+ async fn test_list_directory_security(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/project"),
+ json!({
+ "normal_dir": {
+ "file1.txt": "content",
+ "file2.txt": "content"
+ },
+ ".mysecrets": "SECRET_KEY=abc123",
+ ".secretdir": {
+ "config": "special configuration",
+ "secret.txt": "secret content"
+ },
+ ".mymetadata": "custom metadata",
+ "visible_dir": {
+ "normal.txt": "normal content",
+ "special.privatekey": "private key content",
+ "data.mysensitive": "sensitive data",
+ ".hidden_subdir": {
+ "hidden_file.txt": "hidden content"
+ }
+ }
+ }),
+ )
+ .await;
+
+ // Configure settings explicitly
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings::<WorktreeSettings>(cx, |settings| {
+ settings.file_scan_exclusions = Some(vec![
+ "**/.secretdir".to_string(),
+ "**/.mymetadata".to_string(),
+ "**/.hidden_subdir".to_string(),
+ ]);
+ settings.private_files = Some(vec![
+ "**/.mysecrets".to_string(),
+ "**/*.privatekey".to_string(),
+ "**/*.mysensitive".to_string(),
+ ]);
+ });
+ });
+ });
+
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+ let tool = Arc::new(ListDirectoryTool::new(project));
+
+ // Listing root directory should exclude private and excluded files
+ let input = ListDirectoryToolInput {
+ path: "project".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+
+ // Should include normal directories
+ assert!(output.contains("normal_dir"), "Should list normal_dir");
+ assert!(output.contains("visible_dir"), "Should list visible_dir");
+
+ // Should NOT include excluded or private files
+ assert!(
+ !output.contains(".secretdir"),
+ "Should not list .secretdir (file_scan_exclusions)"
+ );
+ assert!(
+ !output.contains(".mymetadata"),
+ "Should not list .mymetadata (file_scan_exclusions)"
+ );
+ assert!(
+ !output.contains(".mysecrets"),
+ "Should not list .mysecrets (private_files)"
+ );
+
+ // Trying to list an excluded directory should fail
+ let input = ListDirectoryToolInput {
+ path: "project/.secretdir".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await;
+ assert!(
+ output
+ .unwrap_err()
+ .to_string()
+ .contains("file_scan_exclusions"),
+ "Error should mention file_scan_exclusions"
+ );
+
+ // Listing a directory should exclude private files within it
+ let input = ListDirectoryToolInput {
+ path: "project/visible_dir".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+
+ // Should include normal files
+ assert!(output.contains("normal.txt"), "Should list normal.txt");
+
+ // Should NOT include private files
+ assert!(
+ !output.contains("privatekey"),
+ "Should not list .privatekey files (private_files)"
+ );
+ assert!(
+ !output.contains("mysensitive"),
+ "Should not list .mysensitive files (private_files)"
+ );
+
+ // Should NOT include subdirectories that match exclusions
+ assert!(
+ !output.contains(".hidden_subdir"),
+ "Should not list .hidden_subdir (file_scan_exclusions)"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+
+ // Create first worktree with its own private files
+ fs.insert_tree(
+ path!("/worktree1"),
+ json!({
+ ".zed": {
+ "settings.json": r#"{
+ "file_scan_exclusions": ["**/fixture.*"],
+ "private_files": ["**/secret.rs", "**/config.toml"]
+ }"#
+ },
+ "src": {
+ "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
+ "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
+ "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
+ },
+ "tests": {
+ "test.rs": "mod tests { fn test_it() {} }",
+ "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
+ }
+ }),
+ )
+ .await;
+
+ // Create second worktree with different private files
+ fs.insert_tree(
+ path!("/worktree2"),
+ json!({
+ ".zed": {
+ "settings.json": r#"{
+ "file_scan_exclusions": ["**/internal.*"],
+ "private_files": ["**/private.js", "**/data.json"]
+ }"#
+ },
+ "lib": {
+ "public.js": "export function greet() { return 'Hello from worktree2'; }",
+ "private.js": "const SECRET_TOKEN = \"private_token_2\";",
+ "data.json": "{\"api_key\": \"json_secret_key\"}"
+ },
+ "docs": {
+ "README.md": "# Public Documentation",
+ "internal.md": "# Internal Secrets and Configuration"
+ }
+ }),
+ )
+ .await;
+
+ // Set global settings
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings::<WorktreeSettings>(cx, |settings| {
+ settings.file_scan_exclusions =
+ Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
+ settings.private_files = Some(vec!["**/.env".to_string()]);
+ });
+ });
+ });
+
+ let project = Project::test(
+ fs.clone(),
+ [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
+ cx,
+ )
+ .await;
+
+ // Wait for worktrees to be fully scanned
+ cx.executor().run_until_parked();
+
+ let tool = Arc::new(ListDirectoryTool::new(project));
+
+ // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
+ let input = ListDirectoryToolInput {
+ path: "worktree1/src".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert!(output.contains("main.rs"), "Should list main.rs");
+ assert!(
+ !output.contains("secret.rs"),
+ "Should not list secret.rs (local private_files)"
+ );
+ assert!(
+ !output.contains("config.toml"),
+ "Should not list config.toml (local private_files)"
+ );
+
+ // Test listing worktree1/tests - should exclude fixture.sql based on local settings
+ let input = ListDirectoryToolInput {
+ path: "worktree1/tests".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert!(output.contains("test.rs"), "Should list test.rs");
+ assert!(
+ !output.contains("fixture.sql"),
+ "Should not list fixture.sql (local file_scan_exclusions)"
+ );
+
+ // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
+ let input = ListDirectoryToolInput {
+ path: "worktree2/lib".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert!(output.contains("public.js"), "Should list public.js");
+ assert!(
+ !output.contains("private.js"),
+ "Should not list private.js (local private_files)"
+ );
+ assert!(
+ !output.contains("data.json"),
+ "Should not list data.json (local private_files)"
+ );
+
+ // Test listing worktree2/docs - should exclude internal.md based on local settings
+ let input = ListDirectoryToolInput {
+ path: "worktree2/docs".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert!(output.contains("README.md"), "Should list README.md");
+ assert!(
+ !output.contains("internal.md"),
+ "Should not list internal.md (local file_scan_exclusions)"
+ );
+
+ // Test trying to list an excluded directory directly
+ let input = ListDirectoryToolInput {
+ path: "worktree1/src/secret.rs".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await;
+ assert!(
+ output
+ .unwrap_err()
+ .to_string()
+ .contains("Cannot list directory"),
+ );
+ }
+}
@@ -0,0 +1,120 @@
+use crate::{AgentTool, ToolCallEventStream};
+use agent_client_protocol::ToolKind;
+use anyhow::{Context as _, Result, anyhow};
+use gpui::{App, AppContext, Entity, SharedString, Task};
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::{path::Path, sync::Arc};
+use util::markdown::MarkdownInlineCode;
+
+/// Moves or rename a file or directory in the project, and returns confirmation that the move succeeded.
+///
+/// If the source and destination directories are the same, but the filename is different, this performs a rename. Otherwise, it performs a move.
+///
+/// This tool should be used when it's desirable to move or rename a file or directory without changing its contents at all.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct MovePathToolInput {
+ /// The source path of the file or directory to move/rename.
+ ///
+ /// <example>
+ /// If the project has the following files:
+ ///
+ /// - directory1/a/something.txt
+ /// - directory2/a/things.txt
+ /// - directory3/a/other.txt
+ ///
+ /// You can move the first file by providing a source_path of "directory1/a/something.txt"
+ /// </example>
+ pub source_path: String,
+
+ /// The destination path where the file or directory should be moved/renamed to.
+ /// If the paths are the same except for the filename, then this will be a rename.
+ ///
+ /// <example>
+ /// To move "directory1/a/something.txt" to "directory2/b/renamed.txt",
+ /// provide a destination_path of "directory2/b/renamed.txt"
+ /// </example>
+ pub destination_path: String,
+}
+
+pub struct MovePathTool {
+ project: Entity<Project>,
+}
+
+impl MovePathTool {
+ pub fn new(project: Entity<Project>) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for MovePathTool {
+ type Input = MovePathToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "move_path"
+ }
+
+ fn kind() -> ToolKind {
+ ToolKind::Move
+ }
+
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ if let Ok(input) = input {
+ let src = MarkdownInlineCode(&input.source_path);
+ let dest = MarkdownInlineCode(&input.destination_path);
+ let src_path = Path::new(&input.source_path);
+ let dest_path = Path::new(&input.destination_path);
+
+ match dest_path
+ .file_name()
+ .and_then(|os_str| os_str.to_os_string().into_string().ok())
+ {
+ Some(filename) if src_path.parent() == dest_path.parent() => {
+ let filename = MarkdownInlineCode(&filename);
+ format!("Rename {src} to {filename}").into()
+ }
+ _ => format!("Move {src} to {dest}").into(),
+ }
+ } else {
+ "Move path".into()
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output>> {
+ let rename_task = self.project.update(cx, |project, cx| {
+ match project
+ .find_project_path(&input.source_path, cx)
+ .and_then(|project_path| project.entry_for_path(&project_path, cx))
+ {
+ Some(entity) => match project.find_project_path(&input.destination_path, cx) {
+ Some(project_path) => project.rename_entry(entity.id, project_path.path, cx),
+ None => Task::ready(Err(anyhow!(
+ "Destination path {} was outside the project.",
+ input.destination_path
+ ))),
+ },
+ None => Task::ready(Err(anyhow!(
+ "Source path {} was not found in the project.",
+ input.source_path
+ ))),
+ }
+ });
+
+ cx.background_spawn(async move {
+ let _ = rename_task.await.with_context(|| {
+ format!("Moving {} to {}", input.source_path, input.destination_path)
+ })?;
+ Ok(format!(
+ "Moved {} to {}",
+ input.source_path, input.destination_path
+ ))
+ })
+ }
+}
@@ -0,0 +1,59 @@
+use std::sync::Arc;
+
+use agent_client_protocol as acp;
+use anyhow::Result;
+use chrono::{Local, Utc};
+use gpui::{App, SharedString, Task};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+use crate::{AgentTool, ToolCallEventStream};
+
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum Timezone {
+ /// Use UTC for the datetime.
+ Utc,
+ /// Use local time for the datetime.
+ Local,
+}
+
+/// Returns the current datetime in RFC 3339 format.
+/// Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct NowToolInput {
+ /// The timezone to use for the datetime.
+ timezone: Timezone,
+}
+
+pub struct NowTool;
+
+impl AgentTool for NowTool {
+ type Input = NowToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "now"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Other
+ }
+
+ fn initial_title(&self, _input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ "Get current time".into()
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ _cx: &mut App,
+ ) -> Task<Result<String>> {
+ let now = match input.timezone {
+ Timezone::Utc => Utc::now().to_rfc3339(),
+ Timezone::Local => Local::now().to_rfc3339(),
+ };
+ Task::ready(Ok(format!("The current datetime is {now}.")))
+ }
+}
@@ -0,0 +1,166 @@
+use crate::AgentTool;
+use agent_client_protocol::ToolKind;
+use anyhow::{Context as _, Result};
+use gpui::{App, AppContext, Entity, SharedString, Task};
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::{path::PathBuf, sync::Arc};
+use util::markdown::MarkdownEscaped;
+
+/// This tool opens a file or URL with the default application associated with it on the user's operating system:
+///
+/// - On macOS, it's equivalent to the `open` command
+/// - On Windows, it's equivalent to `start`
+/// - On Linux, it uses something like `xdg-open`, `gio open`, `gnome-open`, `kde-open`, `wslview` as appropriate
+///
+/// For example, it can open a web browser with a URL, open a PDF file with the default PDF viewer, etc.
+///
+/// You MUST ONLY use this tool when the user has explicitly requested opening something. You MUST NEVER assume that the user would like for you to use this tool.
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+pub struct OpenToolInput {
+ /// The path or URL to open with the default application.
+ path_or_url: String,
+}
+
+pub struct OpenTool {
+ project: Entity<Project>,
+}
+
+impl OpenTool {
+ pub fn new(project: Entity<Project>) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for OpenTool {
+ type Input = OpenToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "open"
+ }
+
+ fn kind() -> ToolKind {
+ ToolKind::Execute
+ }
+
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ if let Ok(input) = input {
+ format!("Open `{}`", MarkdownEscaped(&input.path_or_url)).into()
+ } else {
+ "Open file or URL".into()
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ event_stream: crate::ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output>> {
+ // If path_or_url turns out to be a path in the project, make it absolute.
+ let abs_path = to_absolute_path(&input.path_or_url, self.project.clone(), cx);
+ let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())), cx);
+ cx.background_spawn(async move {
+ authorize.await?;
+
+ match abs_path {
+ Some(path) => open::that(path),
+ None => open::that(&input.path_or_url),
+ }
+ .context("Failed to open URL or file path")?;
+
+ Ok(format!("Successfully opened {}", input.path_or_url))
+ })
+ }
+}
+
+fn to_absolute_path(
+ potential_path: &str,
+ project: Entity<Project>,
+ cx: &mut App,
+) -> Option<PathBuf> {
+ let project = project.read(cx);
+ project
+ .find_project_path(PathBuf::from(potential_path), cx)
+ .and_then(|project_path| project.absolute_path(&project_path, cx))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use gpui::TestAppContext;
+ use project::{FakeFs, Project};
+ use settings::SettingsStore;
+ use std::path::Path;
+ use tempfile::TempDir;
+
+ #[gpui::test]
+ async fn test_to_absolute_path(cx: &mut TestAppContext) {
+ init_test(cx);
+ let temp_dir = TempDir::new().expect("Failed to create temp directory");
+ let temp_path = temp_dir.path().to_string_lossy().to_string();
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ &temp_path,
+ serde_json::json!({
+ "src": {
+ "main.rs": "fn main() {}",
+ "lib.rs": "pub fn lib_fn() {}"
+ },
+ "docs": {
+ "readme.md": "# Project Documentation"
+ }
+ }),
+ )
+ .await;
+
+ // Use the temp_path as the root directory, not just its filename
+ let project = Project::test(fs.clone(), [temp_dir.path()], cx).await;
+
+ // Test cases where the function should return Some
+ cx.update(|cx| {
+ // Project-relative paths should return Some
+ // Create paths using the last segment of the temp path to simulate a project-relative path
+ let root_dir_name = Path::new(&temp_path)
+ .file_name()
+ .unwrap_or_else(|| std::ffi::OsStr::new("temp"))
+ .to_string_lossy();
+
+ assert!(
+ to_absolute_path(&format!("{root_dir_name}/src/main.rs"), project.clone(), cx)
+ .is_some(),
+ "Failed to resolve main.rs path"
+ );
+
+ assert!(
+ to_absolute_path(
+ &format!("{root_dir_name}/docs/readme.md",),
+ project.clone(),
+ cx,
+ )
+ .is_some(),
+ "Failed to resolve readme.md path"
+ );
+
+ // External URL should return None
+ let result = to_absolute_path("https://example.com", project.clone(), cx);
+ assert_eq!(result, None, "External URLs should return None");
+
+ // Path outside project
+ let result = to_absolute_path("../invalid/path", project.clone(), cx);
+ assert_eq!(result, None, "Paths outside the project should return None");
+ });
+ }
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ language::init(cx);
+ Project::init_settings(cx);
+ });
+ }
+}
@@ -0,0 +1,936 @@
+use action_log::ActionLog;
+use agent_client_protocol::{self as acp, ToolCallUpdateFields};
+use anyhow::{Context as _, Result, anyhow};
+use assistant_tool::outline;
+use gpui::{App, Entity, SharedString, Task};
+use indoc::formatdoc;
+use language::Point;
+use language_model::{LanguageModelImage, LanguageModelToolResultContent};
+use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::Settings;
+use std::{path::Path, sync::Arc};
+
+use crate::{AgentTool, ToolCallEventStream};
+
+/// Reads the content of the given file in the project.
+///
+/// - Never attempt to read a path that hasn't been previously mentioned.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct ReadFileToolInput {
+ /// The relative path of the file to read.
+ ///
+ /// This path should never be absolute, and the first component of the path should always be a root directory in a project.
+ ///
+ /// <example>
+ /// If the project has the following root directories:
+ ///
+ /// - /a/b/directory1
+ /// - /c/d/directory2
+ ///
+ /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
+ /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
+ /// </example>
+ pub path: String,
+ /// Optional line number to start reading on (1-based index)
+ #[serde(default)]
+ pub start_line: Option<u32>,
+ /// Optional line number to end reading on (1-based index, inclusive)
+ #[serde(default)]
+ pub end_line: Option<u32>,
+}
+
+pub struct ReadFileTool {
+ project: Entity<Project>,
+ action_log: Entity<ActionLog>,
+}
+
+impl ReadFileTool {
+ pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> Self {
+ Self {
+ project,
+ action_log,
+ }
+ }
+}
+
+impl AgentTool for ReadFileTool {
+ type Input = ReadFileToolInput;
+ type Output = LanguageModelToolResultContent;
+
+ fn name() -> &'static str {
+ "read_file"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Read
+ }
+
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ input
+ .ok()
+ .as_ref()
+ .and_then(|input| Path::new(&input.path).file_name())
+ .map(|file_name| file_name.to_string_lossy().to_string().into())
+ .unwrap_or_default()
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<LanguageModelToolResultContent>> {
+ let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else {
+ return Task::ready(Err(anyhow!("Path {} not found in project", &input.path)));
+ };
+
+ // Error out if this path is either excluded or private in global settings
+ let global_settings = WorktreeSettings::get_global(cx);
+ if global_settings.is_path_excluded(&project_path.path) {
+ return Task::ready(Err(anyhow!(
+ "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
+ &input.path
+ )));
+ }
+
+ if global_settings.is_path_private(&project_path.path) {
+ return Task::ready(Err(anyhow!(
+ "Cannot read file because its path matches the global `private_files` setting: {}",
+ &input.path
+ )));
+ }
+
+ // Error out if this path is either excluded or private in worktree settings
+ let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
+ if worktree_settings.is_path_excluded(&project_path.path) {
+ return Task::ready(Err(anyhow!(
+ "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
+ &input.path
+ )));
+ }
+
+ if worktree_settings.is_path_private(&project_path.path) {
+ return Task::ready(Err(anyhow!(
+ "Cannot read file because its path matches the worktree `private_files` setting: {}",
+ &input.path
+ )));
+ }
+
+ let file_path = input.path.clone();
+
+ if image_store::is_image_file(&self.project, &project_path, cx) {
+ return cx.spawn(async move |cx| {
+ let image_entity: Entity<ImageItem> = cx
+ .update(|cx| {
+ self.project.update(cx, |project, cx| {
+ project.open_image(project_path.clone(), cx)
+ })
+ })?
+ .await?;
+
+ let image =
+ image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?;
+
+ let language_model_image = cx
+ .update(|cx| LanguageModelImage::from_image(image, cx))?
+ .await
+ .context("processing image")?;
+
+ Ok(language_model_image.into())
+ });
+ }
+
+ let project = self.project.clone();
+ let action_log = self.action_log.clone();
+
+ cx.spawn(async move |cx| {
+ let buffer = cx
+ .update(|cx| {
+ project.update(cx, |project, cx| {
+ project.open_buffer(project_path.clone(), cx)
+ })
+ })?
+ .await?;
+ if buffer.read_with(cx, |buffer, _| {
+ buffer
+ .file()
+ .as_ref()
+ .is_none_or(|file| !file.disk_state().exists())
+ })? {
+ anyhow::bail!("{file_path} not found");
+ }
+
+ let mut anchor = None;
+
+ // Check if specific line ranges are provided
+ let result = if input.start_line.is_some() || input.end_line.is_some() {
+ let result = buffer.read_with(cx, |buffer, _cx| {
+ let text = buffer.text();
+ // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
+ let start = input.start_line.unwrap_or(1).max(1);
+ let start_row = start - 1;
+ if start_row <= buffer.max_point().row {
+ let column = buffer.line_indent_for_row(start_row).raw_len();
+ anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
+ }
+
+ let lines = text.split('\n').skip(start_row as usize);
+ if let Some(end) = input.end_line {
+ let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line
+ itertools::intersperse(lines.take(count as usize), "\n").collect::<String>()
+ } else {
+ itertools::intersperse(lines, "\n").collect::<String>()
+ }
+ })?;
+
+ action_log.update(cx, |log, cx| {
+ log.buffer_read(buffer.clone(), cx);
+ })?;
+
+ Ok(result.into())
+ } else {
+ // No line ranges specified, so check file size to see if it's too big.
+ let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?;
+
+ if file_size <= outline::AUTO_OUTLINE_SIZE {
+ // File is small enough, so return its contents.
+ let result = buffer.read_with(cx, |buffer, _cx| buffer.text())?;
+
+ action_log.update(cx, |log, cx| {
+ log.buffer_read(buffer.clone(), cx);
+ })?;
+
+ Ok(result.into())
+ } else {
+ // File is too big, so return the outline
+ // and a suggestion to read again with line numbers.
+ let outline =
+ outline::file_outline(project.clone(), file_path, action_log, None, cx)
+ .await?;
+ Ok(formatdoc! {"
+ This file was too big to read all at once.
+
+ Here is an outline of its symbols:
+
+ {outline}
+
+ Using the line numbers in this outline, you can call this tool again
+ while specifying the start_line and end_line fields to see the
+ implementations of symbols in the outline.
+
+ Alternatively, you can fall back to the `grep` tool (if available)
+ to search the file for specific content."
+ }
+ .into())
+ }
+ };
+
+ project.update(cx, |project, cx| {
+ if let Some(abs_path) = project.absolute_path(&project_path, cx) {
+ project.set_agent_location(
+ Some(AgentLocation {
+ buffer: buffer.downgrade(),
+ position: anchor.unwrap_or(text::Anchor::MIN),
+ }),
+ cx,
+ );
+ event_stream.update_fields(ToolCallUpdateFields {
+ locations: Some(vec![acp::ToolCallLocation {
+ path: abs_path,
+ line: input.start_line.map(|line| line.saturating_sub(1)),
+ }]),
+ ..Default::default()
+ });
+ }
+ })?;
+
+ result
+ })
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
+ use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
+ use project::{FakeFs, Project};
+ use serde_json::json;
+ use settings::SettingsStore;
+ use util::path;
+
+ #[gpui::test]
+ async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(path!("/root"), json!({})).await;
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+ let tool = Arc::new(ReadFileTool::new(project, action_log));
+ let (event_stream, _) = ToolCallEventStream::test();
+
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "root/nonexistent_file.txt".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.run(input, event_stream, cx)
+ })
+ .await;
+ assert_eq!(
+ result.unwrap_err().to_string(),
+ "root/nonexistent_file.txt not found"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_read_small_file(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ json!({
+ "small_file.txt": "This is a small file content"
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+ let tool = Arc::new(ReadFileTool::new(project, action_log));
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "root/small_file.txt".into(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+ assert_eq!(result.unwrap(), "This is a small file content".into());
+ }
+
+ #[gpui::test]
+ async fn test_read_large_file(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ json!({
+ "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+ language_registry.add(Arc::new(rust_lang()));
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+ let tool = Arc::new(ReadFileTool::new(project, action_log));
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "root/large_file.rs".into(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await
+ .unwrap();
+ let content = result.to_str().unwrap();
+
+ assert_eq!(
+ content.lines().skip(4).take(6).collect::<Vec<_>>(),
+ vec![
+ "struct Test0 [L1-4]",
+ " a [L2]",
+ " b [L3]",
+ "struct Test1 [L5-8]",
+ " a [L6]",
+ " b [L7]",
+ ]
+ );
+
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "root/large_file.rs".into(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await
+ .unwrap();
+ let content = result.to_str().unwrap();
+ let expected_content = (0..1000)
+ .flat_map(|i| {
+ vec![
+ format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
+ format!(" a [L{}]", i * 4 + 2),
+ format!(" b [L{}]", i * 4 + 3),
+ ]
+ })
+ .collect::<Vec<_>>();
+ pretty_assertions::assert_eq!(
+ content
+ .lines()
+ .skip(4)
+ .take(expected_content.len())
+ .collect::<Vec<_>>(),
+ expected_content
+ );
+ }
+
+ #[gpui::test]
+ async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ json!({
+ "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+ let tool = Arc::new(ReadFileTool::new(project, action_log));
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "root/multiline.txt".to_string(),
+ start_line: Some(2),
+ end_line: Some(4),
+ };
+ tool.run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+ assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4".into());
+ }
+
+ #[gpui::test]
+ async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ json!({
+ "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+ let tool = Arc::new(ReadFileTool::new(project, action_log));
+
+ // start_line of 0 should be treated as 1
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "root/multiline.txt".to_string(),
+ start_line: Some(0),
+ end_line: Some(2),
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+ assert_eq!(result.unwrap(), "Line 1\nLine 2".into());
+
+ // end_line of 0 should result in at least 1 line
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "root/multiline.txt".to_string(),
+ start_line: Some(1),
+ end_line: Some(0),
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+ assert_eq!(result.unwrap(), "Line 1".into());
+
+ // when start_line > end_line, should still return at least 1 line
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "root/multiline.txt".to_string(),
+ start_line: Some(3),
+ end_line: Some(2),
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+ assert_eq!(result.unwrap(), "Line 3".into());
+ }
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ language::init(cx);
+ Project::init_settings(cx);
+ });
+ }
+
+ fn rust_lang() -> Language {
+ Language::new(
+ LanguageConfig {
+ name: "Rust".into(),
+ matcher: LanguageMatcher {
+ path_suffixes: vec!["rs".to_string()],
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ Some(tree_sitter_rust::LANGUAGE.into()),
+ )
+ .with_outline_query(
+ r#"
+ (line_comment) @annotation
+
+ (struct_item
+ "struct" @context
+ name: (_) @name) @item
+ (enum_item
+ "enum" @context
+ name: (_) @name) @item
+ (enum_variant
+ name: (_) @name) @item
+ (field_declaration
+ name: (_) @name) @item
+ (impl_item
+ "impl" @context
+ trait: (_)? @name
+ "for"? @context
+ type: (_) @name
+ body: (_ "{" (_)* "}")) @item
+ (function_item
+ "fn" @context
+ name: (_) @name) @item
+ (mod_item
+ "mod" @context
+ name: (_) @name) @item
+ "#,
+ )
+ .unwrap()
+ }
+
+ #[gpui::test]
+ async fn test_read_file_security(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+
+ fs.insert_tree(
+ path!("/"),
+ json!({
+ "project_root": {
+ "allowed_file.txt": "This file is in the project",
+ ".mysecrets": "SECRET_KEY=abc123",
+ ".secretdir": {
+ "config": "special configuration"
+ },
+ ".mymetadata": "custom metadata",
+ "subdir": {
+ "normal_file.txt": "Normal file content",
+ "special.privatekey": "private key content",
+ "data.mysensitive": "sensitive data"
+ }
+ },
+ "outside_project": {
+ "sensitive_file.txt": "This file is outside the project"
+ }
+ }),
+ )
+ .await;
+
+ cx.update(|cx| {
+ use gpui::UpdateGlobal;
+ use project::WorktreeSettings;
+ use settings::SettingsStore;
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings::<WorktreeSettings>(cx, |settings| {
+ settings.file_scan_exclusions = Some(vec![
+ "**/.secretdir".to_string(),
+ "**/.mymetadata".to_string(),
+ ]);
+ settings.private_files = Some(vec![
+ "**/.mysecrets".to_string(),
+ "**/*.privatekey".to_string(),
+ "**/*.mysensitive".to_string(),
+ ]);
+ });
+ });
+ });
+
+ let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+ let tool = Arc::new(ReadFileTool::new(project, action_log));
+
+ // Reading a file outside the project worktree should fail
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "/outside_project/sensitive_file.txt".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+ assert!(
+ result.is_err(),
+ "read_file_tool should error when attempting to read an absolute path outside a worktree"
+ );
+
+ // Reading a file within the project should succeed
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "project_root/allowed_file.txt".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+ assert!(
+ result.is_ok(),
+ "read_file_tool should be able to read files inside worktrees"
+ );
+
+ // Reading files that match file_scan_exclusions should fail
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "project_root/.secretdir/config".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+ assert!(
+ result.is_err(),
+ "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
+ );
+
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "project_root/.mymetadata".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+ assert!(
+ result.is_err(),
+ "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
+ );
+
+ // Reading private files should fail
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "project_root/.mysecrets".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+ assert!(
+ result.is_err(),
+ "read_file_tool should error when attempting to read .mysecrets (private_files)"
+ );
+
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "project_root/subdir/special.privatekey".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+ assert!(
+ result.is_err(),
+ "read_file_tool should error when attempting to read .privatekey files (private_files)"
+ );
+
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "project_root/subdir/data.mysensitive".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+ assert!(
+ result.is_err(),
+ "read_file_tool should error when attempting to read .mysensitive files (private_files)"
+ );
+
+ // Reading a normal file should still work, even with private_files configured
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "project_root/subdir/normal_file.txt".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+ assert!(result.is_ok(), "Should be able to read normal files");
+ assert_eq!(result.unwrap(), "Normal file content".into());
+
+ // Path traversal attempts with .. should fail
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "project_root/../outside_project/sensitive_file.txt".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+ assert!(
+ result.is_err(),
+ "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+
+ // Create first worktree with its own private_files setting
+ fs.insert_tree(
+ path!("/worktree1"),
+ json!({
+ "src": {
+ "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
+ "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
+ "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
+ },
+ "tests": {
+ "test.rs": "mod tests { fn test_it() {} }",
+ "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
+ },
+ ".zed": {
+ "settings.json": r#"{
+ "file_scan_exclusions": ["**/fixture.*"],
+ "private_files": ["**/secret.rs", "**/config.toml"]
+ }"#
+ }
+ }),
+ )
+ .await;
+
+ // Create second worktree with different private_files setting
+ fs.insert_tree(
+ path!("/worktree2"),
+ json!({
+ "lib": {
+ "public.js": "export function greet() { return 'Hello from worktree2'; }",
+ "private.js": "const SECRET_TOKEN = \"private_token_2\";",
+ "data.json": "{\"api_key\": \"json_secret_key\"}"
+ },
+ "docs": {
+ "README.md": "# Public Documentation",
+ "internal.md": "# Internal Secrets and Configuration"
+ },
+ ".zed": {
+ "settings.json": r#"{
+ "file_scan_exclusions": ["**/internal.*"],
+ "private_files": ["**/private.js", "**/data.json"]
+ }"#
+ }
+ }),
+ )
+ .await;
+
+ // Set global settings
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings::<WorktreeSettings>(cx, |settings| {
+ settings.file_scan_exclusions =
+ Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
+ settings.private_files = Some(vec!["**/.env".to_string()]);
+ });
+ });
+ });
+
+ let project = Project::test(
+ fs.clone(),
+ [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
+ cx,
+ )
+ .await;
+
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+ let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone()));
+
+ // Test reading allowed files in worktree1
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "worktree1/src/main.rs".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await
+ .unwrap();
+
+ assert_eq!(
+ result,
+ "fn main() { println!(\"Hello from worktree1\"); }".into()
+ );
+
+ // Test reading private file in worktree1 should fail
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "worktree1/src/secret.rs".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+
+ assert!(result.is_err());
+ assert!(
+ result
+ .unwrap_err()
+ .to_string()
+ .contains("worktree `private_files` setting"),
+ "Error should mention worktree private_files setting"
+ );
+
+ // Test reading excluded file in worktree1 should fail
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "worktree1/tests/fixture.sql".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+
+ assert!(result.is_err());
+ assert!(
+ result
+ .unwrap_err()
+ .to_string()
+ .contains("worktree `file_scan_exclusions` setting"),
+ "Error should mention worktree file_scan_exclusions setting"
+ );
+
+ // Test reading allowed files in worktree2
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "worktree2/lib/public.js".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await
+ .unwrap();
+
+ assert_eq!(
+ result,
+ "export function greet() { return 'Hello from worktree2'; }".into()
+ );
+
+ // Test reading private file in worktree2 should fail
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "worktree2/lib/private.js".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+
+ assert!(result.is_err());
+ assert!(
+ result
+ .unwrap_err()
+ .to_string()
+ .contains("worktree `private_files` setting"),
+ "Error should mention worktree private_files setting"
+ );
+
+ // Test reading excluded file in worktree2 should fail
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "worktree2/docs/internal.md".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+
+ assert!(result.is_err());
+ assert!(
+ result
+ .unwrap_err()
+ .to_string()
+ .contains("worktree `file_scan_exclusions` setting"),
+ "Error should mention worktree file_scan_exclusions setting"
+ );
+
+ // Test that files allowed in one worktree but not in another are handled correctly
+ // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
+ let result = cx
+ .update(|cx| {
+ let input = ReadFileToolInput {
+ path: "worktree1/src/config.toml".to_string(),
+ start_line: None,
+ end_line: None,
+ };
+ tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ })
+ .await;
+
+ assert!(result.is_err());
+ assert!(
+ result
+ .unwrap_err()
+ .to_string()
+ .contains("worktree `private_files` setting"),
+ "Config.toml should be blocked by worktree1's private_files setting"
+ );
+ }
+}
@@ -0,0 +1,468 @@
+use agent_client_protocol as acp;
+use anyhow::Result;
+use futures::{FutureExt as _, future::Shared};
+use gpui::{App, AppContext, Entity, SharedString, Task};
+use project::{Project, terminals::TerminalKind};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::{
+ path::{Path, PathBuf},
+ sync::Arc,
+};
+use util::{ResultExt, get_system_shell, markdown::MarkdownInlineCode};
+
+use crate::{AgentTool, ToolCallEventStream};
+
+const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024;
+
+/// Executes a shell one-liner and returns the combined output.
+///
+/// This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result.
+///
+/// The output results will be shown to the user already, only list it again if necessary, avoid being redundant.
+///
+/// Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error.
+///
+/// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own.
+///
+/// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations.
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+pub struct TerminalToolInput {
+ /// The one-liner command to execute.
+ command: String,
+ /// Working directory for the command. This must be one of the root directories of the project.
+ cd: String,
+}
+
+pub struct TerminalTool {
+ project: Entity<Project>,
+ determine_shell: Shared<Task<String>>,
+}
+
+impl TerminalTool {
+ pub fn new(project: Entity<Project>, cx: &mut App) -> Self {
+ let determine_shell = cx.background_spawn(async move {
+ if cfg!(windows) {
+ return get_system_shell();
+ }
+
+ if which::which("bash").is_ok() {
+ "bash".into()
+ } else {
+ get_system_shell()
+ }
+ });
+ Self {
+ project,
+ determine_shell: determine_shell.shared(),
+ }
+ }
+}
+
+impl AgentTool for TerminalTool {
+ type Input = TerminalToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "terminal"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Execute
+ }
+
+ fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ if let Ok(input) = input {
+ let mut lines = input.command.lines();
+ let first_line = lines.next().unwrap_or_default();
+ let remaining_line_count = lines.count();
+ match remaining_line_count {
+ 0 => MarkdownInlineCode(first_line).to_string().into(),
+ 1 => MarkdownInlineCode(&format!(
+ "{} - {} more line",
+ first_line, remaining_line_count
+ ))
+ .to_string()
+ .into(),
+ n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
+ .to_string()
+ .into(),
+ }
+ } else {
+ "Run terminal command".into()
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output>> {
+ let language_registry = self.project.read(cx).languages().clone();
+ let working_dir = match working_dir(&input, &self.project, cx) {
+ Ok(dir) => dir,
+ Err(err) => return Task::ready(Err(err)),
+ };
+ let program = self.determine_shell.clone();
+ let command = if cfg!(windows) {
+ format!("$null | & {{{}}}", input.command.replace("\"", "'"))
+ } else if let Some(cwd) = working_dir
+ .as_ref()
+ .and_then(|cwd| cwd.as_os_str().to_str())
+ {
+ // Make sure once we're *inside* the shell, we cd into `cwd`
+ format!("(cd {cwd}; {}) </dev/null", input.command)
+ } else {
+ format!("({}) </dev/null", input.command)
+ };
+ let args = vec!["-c".into(), command];
+
+ let env = match &working_dir {
+ Some(dir) => self.project.update(cx, |project, cx| {
+ project.directory_environment(dir.as_path().into(), cx)
+ }),
+ None => Task::ready(None).shared(),
+ };
+
+ let env = cx.spawn(async move |_| {
+ let mut env = env.await.unwrap_or_default();
+ if cfg!(unix) {
+ env.insert("PAGER".into(), "cat".into());
+ }
+ env
+ });
+
+ let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())), cx);
+
+ cx.spawn({
+ async move |cx| {
+ authorize.await?;
+
+ let program = program.await;
+ let env = env.await;
+ let terminal = self
+ .project
+ .update(cx, |project, cx| {
+ project.create_terminal(
+ TerminalKind::Task(task::SpawnInTerminal {
+ command: Some(program),
+ args,
+ cwd: working_dir.clone(),
+ env,
+ ..Default::default()
+ }),
+ cx,
+ )
+ })?
+ .await?;
+ let acp_terminal = cx.new(|cx| {
+ acp_thread::Terminal::new(
+ input.command.clone(),
+ working_dir.clone(),
+ terminal.clone(),
+ language_registry,
+ cx,
+ )
+ })?;
+ event_stream.update_terminal(acp_terminal.clone());
+
+ let exit_status = terminal
+ .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
+ .await;
+ let (content, content_line_count) = terminal.read_with(cx, |terminal, _| {
+ (terminal.get_content(), terminal.total_lines())
+ })?;
+
+ let (processed_content, finished_with_empty_output) = process_content(
+ &content,
+ &input.command,
+ exit_status.map(portable_pty::ExitStatus::from),
+ );
+
+ acp_terminal
+ .update(cx, |terminal, cx| {
+ terminal.finish(
+ exit_status,
+ content.len(),
+ processed_content.len(),
+ content_line_count,
+ finished_with_empty_output,
+ cx,
+ );
+ })
+ .log_err();
+
+ Ok(processed_content)
+ }
+ })
+ }
+}
+
+fn process_content(
+ content: &str,
+ command: &str,
+ exit_status: Option<portable_pty::ExitStatus>,
+) -> (String, bool) {
+ let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
+
+ let content = if should_truncate {
+ let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
+ while !content.is_char_boundary(end_ix) {
+ end_ix -= 1;
+ }
+ // Don't truncate mid-line, clear the remainder of the last line
+ end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
+ &content[..end_ix]
+ } else {
+ content
+ };
+ let content = content.trim();
+ let is_empty = content.is_empty();
+ let content = format!("```\n{content}\n```");
+ let content = if should_truncate {
+ format!(
+ "Command output too long. The first {} bytes:\n\n{content}",
+ content.len(),
+ )
+ } else {
+ content
+ };
+
+ let content = match exit_status {
+ Some(exit_status) if exit_status.success() => {
+ if is_empty {
+ "Command executed successfully.".to_string()
+ } else {
+ content
+ }
+ }
+ Some(exit_status) => {
+ if is_empty {
+ format!(
+ "Command \"{command}\" failed with exit code {}.",
+ exit_status.exit_code()
+ )
+ } else {
+ format!(
+ "Command \"{command}\" failed with exit code {}.\n\n{content}",
+ exit_status.exit_code()
+ )
+ }
+ }
+ None => {
+ format!(
+ "Command failed or was interrupted.\nPartial output captured:\n\n{}",
+ content,
+ )
+ }
+ };
+ (content, is_empty)
+}
+
+fn working_dir(
+ input: &TerminalToolInput,
+ project: &Entity<Project>,
+ cx: &mut App,
+) -> Result<Option<PathBuf>> {
+ let project = project.read(cx);
+ let cd = &input.cd;
+
+ if cd == "." || cd.is_empty() {
+ // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
+ let mut worktrees = project.worktrees(cx);
+
+ match worktrees.next() {
+ Some(worktree) => {
+ anyhow::ensure!(
+ worktrees.next().is_none(),
+ "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
+ );
+ Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
+ }
+ None => Ok(None),
+ }
+ } else {
+ let input_path = Path::new(cd);
+
+ if input_path.is_absolute() {
+ // Absolute paths are allowed, but only if they're in one of the project's worktrees.
+ if project
+ .worktrees(cx)
+ .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
+ {
+ return Ok(Some(input_path.into()));
+ }
+ } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
+ return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
+ }
+
+ anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use agent_settings::AgentSettings;
+ use editor::EditorSettings;
+ use fs::RealFs;
+ use gpui::{BackgroundExecutor, TestAppContext};
+ use pretty_assertions::assert_eq;
+ use serde_json::json;
+ use settings::{Settings, SettingsStore};
+ use terminal::terminal_settings::TerminalSettings;
+ use theme::ThemeSettings;
+ use util::test::TempTree;
+
+ use crate::ThreadEvent;
+
+ use super::*;
+
+ fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) {
+ zlog::init_test();
+
+ executor.allow_parking();
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ language::init(cx);
+ Project::init_settings(cx);
+ ThemeSettings::register(cx);
+ TerminalSettings::register(cx);
+ EditorSettings::register(cx);
+ AgentSettings::register(cx);
+ });
+ }
+
+ #[gpui::test]
+ async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+ if cfg!(windows) {
+ return;
+ }
+
+ init_test(&executor, cx);
+
+ let fs = Arc::new(RealFs::new(None, executor));
+ let tree = TempTree::new(json!({
+ "project": {},
+ }));
+ let project: Entity<Project> =
+ Project::test(fs, [tree.path().join("project").as_path()], cx).await;
+
+ let input = TerminalToolInput {
+ command: "cat".to_owned(),
+ cd: tree
+ .path()
+ .join("project")
+ .as_path()
+ .to_string_lossy()
+ .to_string(),
+ };
+ let (event_stream_tx, mut event_stream_rx) = ToolCallEventStream::test();
+ let result = cx
+ .update(|cx| Arc::new(TerminalTool::new(project, cx)).run(input, event_stream_tx, cx));
+
+ let auth = event_stream_rx.expect_authorization().await;
+ auth.response.send(auth.options[0].id.clone()).unwrap();
+ event_stream_rx.expect_terminal().await;
+ assert_eq!(result.await.unwrap(), "Command executed successfully.");
+ }
+
+ #[gpui::test]
+ async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+ if cfg!(windows) {
+ return;
+ }
+
+ init_test(&executor, cx);
+
+ let fs = Arc::new(RealFs::new(None, executor));
+ let tree = TempTree::new(json!({
+ "project": {},
+ "other-project": {},
+ }));
+ let project: Entity<Project> =
+ Project::test(fs, [tree.path().join("project").as_path()], cx).await;
+
+ let check = |input, expected, cx: &mut TestAppContext| {
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ let result = cx.update(|cx| {
+ Arc::new(TerminalTool::new(project.clone(), cx)).run(input, stream_tx, cx)
+ });
+ cx.run_until_parked();
+ let event = stream_rx.try_next();
+ if let Ok(Some(Ok(ThreadEvent::ToolCallAuthorization(auth)))) = event {
+ auth.response.send(auth.options[0].id.clone()).unwrap();
+ }
+
+ cx.spawn(async move |_| {
+ let output = result.await;
+ assert_eq!(output.ok(), expected);
+ })
+ };
+
+ check(
+ TerminalToolInput {
+ command: "pwd".into(),
+ cd: ".".into(),
+ },
+ Some(format!(
+ "```\n{}\n```",
+ tree.path().join("project").display()
+ )),
+ cx,
+ )
+ .await;
+
+ check(
+ TerminalToolInput {
+ command: "pwd".into(),
+ cd: "other-project".into(),
+ },
+ None, // other-project is a dir, but *not* a worktree (yet)
+ cx,
+ )
+ .await;
+
+ // Absolute path above the worktree root
+ check(
+ TerminalToolInput {
+ command: "pwd".into(),
+ cd: tree.path().to_string_lossy().into(),
+ },
+ None,
+ cx,
+ )
+ .await;
+
+ project
+ .update(cx, |project, cx| {
+ project.create_worktree(tree.path().join("other-project"), true, cx)
+ })
+ .await
+ .unwrap();
+
+ check(
+ TerminalToolInput {
+ command: "pwd".into(),
+ cd: "other-project".into(),
+ },
+ Some(format!(
+ "```\n{}\n```",
+ tree.path().join("other-project").display()
+ )),
+ cx,
+ )
+ .await;
+
+ check(
+ TerminalToolInput {
+ command: "pwd".into(),
+ cd: ".".into(),
+ },
+ None,
+ cx,
+ )
+ .await;
+ }
+}
@@ -0,0 +1,48 @@
+use agent_client_protocol as acp;
+use anyhow::Result;
+use gpui::{App, SharedString, Task};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+
+use crate::{AgentTool, ToolCallEventStream};
+
+/// A tool for thinking through problems, brainstorming ideas, or planning without executing any actions.
+/// Use this tool when you need to work through complex problems, develop strategies, or outline approaches before taking action.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct ThinkingToolInput {
+ /// Content to think about. This should be a description of what to think about or a problem to solve.
+ content: String,
+}
+
+pub struct ThinkingTool;
+
+impl AgentTool for ThinkingTool {
+ type Input = ThinkingToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "thinking"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Think
+ }
+
+ fn initial_title(&self, _input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ "Thinking".into()
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ event_stream: ToolCallEventStream,
+ _cx: &mut App,
+ ) -> Task<Result<String>> {
+ event_stream.update_fields(acp::ToolCallUpdateFields {
+ content: Some(vec![input.content.into()]),
+ ..Default::default()
+ });
+ Task::ready(Ok("Finished thinking.".to_string()))
+ }
+}
@@ -0,0 +1,127 @@
+use std::sync::Arc;
+
+use crate::{AgentTool, ToolCallEventStream};
+use agent_client_protocol as acp;
+use anyhow::{Result, anyhow};
+use cloud_llm_client::WebSearchResponse;
+use gpui::{App, AppContext, Task};
+use language_model::{
+ LanguageModelProviderId, LanguageModelToolResultContent, ZED_CLOUD_PROVIDER_ID,
+};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use ui::prelude::*;
+use web_search::WebSearchRegistry;
+
+/// Search the web for information using your query.
+/// Use this when you need real-time information, facts, or data that might not be in your training.
+/// Results will include snippets and links from relevant web pages.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct WebSearchToolInput {
+ /// The search term or question to query on the web.
+ query: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct WebSearchToolOutput(WebSearchResponse);
+
+impl From<WebSearchToolOutput> for LanguageModelToolResultContent {
+ fn from(value: WebSearchToolOutput) -> Self {
+ serde_json::to_string(&value.0)
+ .expect("Failed to serialize WebSearchResponse")
+ .into()
+ }
+}
+
+pub struct WebSearchTool;
+
+impl AgentTool for WebSearchTool {
+ type Input = WebSearchToolInput;
+ type Output = WebSearchToolOutput;
+
+ fn name() -> &'static str {
+ "web_search"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Fetch
+ }
+
+ fn initial_title(&self, _input: Result<Self::Input, serde_json::Value>) -> SharedString {
+ "Searching the Web".into()
+ }
+
+ /// We currently only support Zed Cloud as a provider.
+ fn supported_provider(&self, provider: &LanguageModelProviderId) -> bool {
+ provider == &ZED_CLOUD_PROVIDER_ID
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output>> {
+ let Some(provider) = WebSearchRegistry::read_global(cx).active_provider() else {
+ return Task::ready(Err(anyhow!("Web search is not available.")));
+ };
+
+ let search_task = provider.search(input.query, cx);
+ cx.background_spawn(async move {
+ let response = match search_task.await {
+ Ok(response) => response,
+ Err(err) => {
+ event_stream.update_fields(acp::ToolCallUpdateFields {
+ title: Some("Web Search Failed".to_string()),
+ ..Default::default()
+ });
+ return Err(err);
+ }
+ };
+
+ emit_update(&response, &event_stream);
+ Ok(WebSearchToolOutput(response))
+ })
+ }
+
+ fn replay(
+ &self,
+ _input: Self::Input,
+ output: Self::Output,
+ event_stream: ToolCallEventStream,
+ _cx: &mut App,
+ ) -> Result<()> {
+ emit_update(&output.0, &event_stream);
+ Ok(())
+ }
+}
+
+fn emit_update(response: &WebSearchResponse, event_stream: &ToolCallEventStream) {
+ let result_text = if response.results.len() == 1 {
+ "1 result".to_string()
+ } else {
+ format!("{} results", response.results.len())
+ };
+ event_stream.update_fields(acp::ToolCallUpdateFields {
+ title: Some(format!("Searched the web: {result_text}")),
+ content: Some(
+ response
+ .results
+ .iter()
+ .map(|result| acp::ToolCallContent::Content {
+ content: acp::ContentBlock::ResourceLink(acp::ResourceLink {
+ name: result.title.clone(),
+ uri: result.url.clone(),
+ title: Some(result.title.clone()),
+ description: Some(result.text.clone()),
+ mime_type: None,
+ annotations: None,
+ size: None,
+ }),
+ })
+ .collect(),
+ ),
+ ..Default::default()
+ });
+}
@@ -5,6 +5,10 @@ edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
+[features]
+test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:env_logger", "fs", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"]
+e2e = []
+
[lints]
workspace = true
@@ -13,15 +17,57 @@ path = "src/agent_servers.rs"
doctest = false
[dependencies]
+acp_tools.workspace = true
+acp_thread.workspace = true
+action_log.workspace = true
+agent-client-protocol.workspace = true
+agent_settings.workspace = true
anyhow.workspace = true
+client = { workspace = true, optional = true }
collections.workspace = true
+context_server.workspace = true
+env_logger = { workspace = true, optional = true }
+fs = { workspace = true, optional = true }
futures.workspace = true
gpui.workspace = true
+gpui_tokio = { workspace = true, optional = true }
+indoc.workspace = true
+itertools.workspace = true
+language.workspace = true
+language_model.workspace = true
+language_models.workspace = true
+log.workspace = true
paths.workspace = true
project.workspace = true
+rand.workspace = true
+reqwest_client = { workspace = true, optional = true }
schemars.workspace = true
+semver.workspace = true
serde.workspace = true
+serde_json.workspace = true
settings.workspace = true
+smol.workspace = true
+strum.workspace = true
+tempfile.workspace = true
+thiserror.workspace = true
+ui.workspace = true
util.workspace = true
+uuid.workspace = true
+watch.workspace = true
which.workspace = true
workspace-hack.workspace = true
+
+[target.'cfg(unix)'.dependencies]
+libc.workspace = true
+nix.workspace = true
+
+[dev-dependencies]
+client = { workspace = true, features = ["test-support"] }
+env_logger.workspace = true
+fs.workspace = true
+language.workspace = true
+indoc.workspace = true
+acp_thread = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support"] }
+gpui_tokio.workspace = true
+reqwest_client = { workspace = true, features = ["test-support"] }
@@ -0,0 +1,391 @@
+use crate::AgentServerCommand;
+use acp_thread::AgentConnection;
+use acp_tools::AcpConnectionRegistry;
+use action_log::ActionLog;
+use agent_client_protocol::{self as acp, Agent as _, ErrorCode};
+use anyhow::anyhow;
+use collections::HashMap;
+use futures::AsyncBufReadExt as _;
+use futures::channel::oneshot;
+use futures::io::BufReader;
+use project::Project;
+use serde::Deserialize;
+use std::{any::Any, cell::RefCell};
+use std::{path::Path, rc::Rc};
+use thiserror::Error;
+
+use anyhow::{Context as _, Result};
+use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task, WeakEntity};
+
+use acp_thread::{AcpThread, AuthRequired, LoadError};
+
+#[derive(Debug, Error)]
+#[error("Unsupported version")]
+pub struct UnsupportedVersion;
+
+pub struct AcpConnection {
+ server_name: SharedString,
+ connection: Rc<acp::ClientSideConnection>,
+ sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
+ auth_methods: Vec<acp::AuthMethod>,
+ prompt_capabilities: acp::PromptCapabilities,
+ _io_task: Task<Result<()>>,
+}
+
+pub struct AcpSession {
+ thread: WeakEntity<AcpThread>,
+ suppress_abort_err: bool,
+}
+
+pub async fn connect(
+ server_name: SharedString,
+ command: AgentServerCommand,
+ root_dir: &Path,
+ cx: &mut AsyncApp,
+) -> Result<Rc<dyn AgentConnection>> {
+ let conn = AcpConnection::stdio(server_name, command.clone(), root_dir, cx).await?;
+ Ok(Rc::new(conn) as _)
+}
+
+const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1;
+
+impl AcpConnection {
+ pub async fn stdio(
+ server_name: SharedString,
+ command: AgentServerCommand,
+ root_dir: &Path,
+ cx: &mut AsyncApp,
+ ) -> Result<Self> {
+ let mut child = util::command::new_smol_command(&command.path)
+ .args(command.args.iter().map(|arg| arg.as_str()))
+ .envs(command.env.iter().flatten())
+ .current_dir(root_dir)
+ .stdin(std::process::Stdio::piped())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped())
+ .kill_on_drop(true)
+ .spawn()?;
+
+ let stdout = child.stdout.take().context("Failed to take stdout")?;
+ let stdin = child.stdin.take().context("Failed to take stdin")?;
+ let stderr = child.stderr.take().context("Failed to take stderr")?;
+ log::trace!("Spawned (pid: {})", child.id());
+
+ let sessions = Rc::new(RefCell::new(HashMap::default()));
+
+ let client = ClientDelegate {
+ sessions: sessions.clone(),
+ cx: cx.clone(),
+ };
+ let (connection, io_task) = acp::ClientSideConnection::new(client, stdin, stdout, {
+ let foreground_executor = cx.foreground_executor().clone();
+ move |fut| {
+ foreground_executor.spawn(fut).detach();
+ }
+ });
+
+ let io_task = cx.background_spawn(io_task);
+
+ cx.background_spawn(async move {
+ let mut stderr = BufReader::new(stderr);
+ let mut line = String::new();
+ while let Ok(n) = stderr.read_line(&mut line).await
+ && n > 0
+ {
+ log::warn!("agent stderr: {}", &line);
+ line.clear();
+ }
+ })
+ .detach();
+
+ cx.spawn({
+ let sessions = sessions.clone();
+ async move |cx| {
+ let status = child.status().await?;
+
+ for session in sessions.borrow().values() {
+ session
+ .thread
+ .update(cx, |thread, cx| {
+ thread.emit_load_error(LoadError::Exited { status }, cx)
+ })
+ .ok();
+ }
+
+ anyhow::Ok(())
+ }
+ })
+ .detach();
+
+ let connection = Rc::new(connection);
+
+ cx.update(|cx| {
+ AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| {
+ registry.set_active_connection(server_name.clone(), &connection, cx)
+ });
+ })?;
+
+ let response = connection
+ .initialize(acp::InitializeRequest {
+ protocol_version: acp::VERSION,
+ client_capabilities: acp::ClientCapabilities {
+ fs: acp::FileSystemCapability {
+ read_text_file: true,
+ write_text_file: true,
+ },
+ },
+ })
+ .await?;
+
+ if response.protocol_version < MINIMUM_SUPPORTED_VERSION {
+ return Err(UnsupportedVersion.into());
+ }
+
+ Ok(Self {
+ auth_methods: response.auth_methods,
+ connection,
+ server_name,
+ sessions,
+ prompt_capabilities: response.agent_capabilities.prompt_capabilities,
+ _io_task: io_task,
+ })
+ }
+}
+
+impl AgentConnection for AcpConnection {
+ fn new_thread(
+ self: Rc<Self>,
+ project: Entity<Project>,
+ cwd: &Path,
+ cx: &mut App,
+ ) -> Task<Result<Entity<AcpThread>>> {
+ let conn = self.connection.clone();
+ let sessions = self.sessions.clone();
+ let cwd = cwd.to_path_buf();
+ cx.spawn(async move |cx| {
+ let response = conn
+ .new_session(acp::NewSessionRequest {
+ mcp_servers: vec![],
+ cwd,
+ })
+ .await
+ .map_err(|err| {
+ if err.code == acp::ErrorCode::AUTH_REQUIRED.code {
+ let mut error = AuthRequired::new();
+
+ if err.message != acp::ErrorCode::AUTH_REQUIRED.message {
+ error = error.with_description(err.message);
+ }
+
+ anyhow!(error)
+ } else {
+ anyhow!(err)
+ }
+ })?;
+
+ let session_id = response.session_id;
+ let action_log = cx.new(|_| ActionLog::new(project.clone()))?;
+ let thread = cx.new(|_cx| {
+ AcpThread::new(
+ self.server_name.clone(),
+ self.clone(),
+ project,
+ action_log,
+ session_id.clone(),
+ )
+ })?;
+
+ let session = AcpSession {
+ thread: thread.downgrade(),
+ suppress_abort_err: false,
+ };
+ sessions.borrow_mut().insert(session_id, session);
+
+ Ok(thread)
+ })
+ }
+
+ fn auth_methods(&self) -> &[acp::AuthMethod] {
+ &self.auth_methods
+ }
+
+ fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>> {
+ let conn = self.connection.clone();
+ cx.foreground_executor().spawn(async move {
+ let result = conn
+ .authenticate(acp::AuthenticateRequest {
+ method_id: method_id.clone(),
+ })
+ .await?;
+
+ Ok(result)
+ })
+ }
+
+ fn prompt(
+ &self,
+ _id: Option<acp_thread::UserMessageId>,
+ params: acp::PromptRequest,
+ cx: &mut App,
+ ) -> Task<Result<acp::PromptResponse>> {
+ let conn = self.connection.clone();
+ let sessions = self.sessions.clone();
+ let session_id = params.session_id.clone();
+ cx.foreground_executor().spawn(async move {
+ let result = conn.prompt(params).await;
+
+ let mut suppress_abort_err = false;
+
+ if let Some(session) = sessions.borrow_mut().get_mut(&session_id) {
+ suppress_abort_err = session.suppress_abort_err;
+ session.suppress_abort_err = false;
+ }
+
+ match result {
+ Ok(response) => Ok(response),
+ Err(err) => {
+ if err.code != ErrorCode::INTERNAL_ERROR.code {
+ anyhow::bail!(err)
+ }
+
+ let Some(data) = &err.data else {
+ anyhow::bail!(err)
+ };
+
+ // Temporary workaround until the following PR is generally available:
+ // https://github.com/google-gemini/gemini-cli/pull/6656
+
+ #[derive(Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct ErrorDetails {
+ details: Box<str>,
+ }
+
+ match serde_json::from_value(data.clone()) {
+ Ok(ErrorDetails { details }) => {
+ if suppress_abort_err && details.contains("This operation was aborted")
+ {
+ Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::Cancelled,
+ })
+ } else {
+ Err(anyhow!(details))
+ }
+ }
+ Err(_) => Err(anyhow!(err)),
+ }
+ }
+ }
+ })
+ }
+
+ fn prompt_capabilities(&self) -> acp::PromptCapabilities {
+ self.prompt_capabilities
+ }
+
+ fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) {
+ if let Some(session) = self.sessions.borrow_mut().get_mut(session_id) {
+ session.suppress_abort_err = true;
+ }
+ let conn = self.connection.clone();
+ let params = acp::CancelNotification {
+ session_id: session_id.clone(),
+ };
+ cx.foreground_executor()
+ .spawn(async move { conn.cancel(params).await })
+ .detach();
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ self
+ }
+}
+
+struct ClientDelegate {
+ sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
+ cx: AsyncApp,
+}
+
+impl acp::Client for ClientDelegate {
+ async fn request_permission(
+ &self,
+ arguments: acp::RequestPermissionRequest,
+ ) -> Result<acp::RequestPermissionResponse, acp::Error> {
+ let cx = &mut self.cx.clone();
+ let rx = self
+ .sessions
+ .borrow()
+ .get(&arguments.session_id)
+ .context("Failed to get session")?
+ .thread
+ .update(cx, |thread, cx| {
+ thread.request_tool_call_authorization(arguments.tool_call, arguments.options, cx)
+ })?;
+
+ let result = rx?.await;
+
+ let outcome = match result {
+ Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option },
+ Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled,
+ };
+
+ Ok(acp::RequestPermissionResponse { outcome })
+ }
+
+ async fn write_text_file(
+ &self,
+ arguments: acp::WriteTextFileRequest,
+ ) -> Result<(), acp::Error> {
+ let cx = &mut self.cx.clone();
+ let task = self
+ .sessions
+ .borrow()
+ .get(&arguments.session_id)
+ .context("Failed to get session")?
+ .thread
+ .update(cx, |thread, cx| {
+ thread.write_text_file(arguments.path, arguments.content, cx)
+ })?;
+
+ task.await?;
+
+ Ok(())
+ }
+
+ async fn read_text_file(
+ &self,
+ arguments: acp::ReadTextFileRequest,
+ ) -> Result<acp::ReadTextFileResponse, acp::Error> {
+ let cx = &mut self.cx.clone();
+ let task = self
+ .sessions
+ .borrow()
+ .get(&arguments.session_id)
+ .context("Failed to get session")?
+ .thread
+ .update(cx, |thread, cx| {
+ thread.read_text_file(arguments.path, arguments.line, arguments.limit, false, cx)
+ })?;
+
+ let content = task.await?;
+
+ Ok(acp::ReadTextFileResponse { content })
+ }
+
+ async fn session_notification(
+ &self,
+ notification: acp::SessionNotification,
+ ) -> Result<(), acp::Error> {
+ let cx = &mut self.cx.clone();
+ let sessions = self.sessions.borrow();
+ let session = sessions
+ .get(¬ification.session_id)
+ .context("Failed to get session")?;
+
+ session.thread.update(cx, |thread, cx| {
+ thread.handle_session_update(notification.update, cx)
+ })??;
+
+ Ok(())
+ }
+}
@@ -0,0 +1,524 @@
+// Translates old acp agents into the new schema
+use action_log::ActionLog;
+use agent_client_protocol as acp;
+use agentic_coding_protocol::{self as acp_old, AgentRequest as _};
+use anyhow::{Context as _, Result, anyhow};
+use futures::channel::oneshot;
+use gpui::{AppContext as _, AsyncApp, Entity, Task, WeakEntity};
+use project::Project;
+use std::{any::Any, cell::RefCell, path::Path, rc::Rc};
+use ui::App;
+use util::ResultExt as _;
+
+use crate::AgentServerCommand;
+use acp_thread::{AcpThread, AgentConnection, AuthRequired};
+
+#[derive(Clone)]
+struct OldAcpClientDelegate {
+ thread: Rc<RefCell<WeakEntity<AcpThread>>>,
+ cx: AsyncApp,
+ next_tool_call_id: Rc<RefCell<u64>>,
+ // sent_buffer_versions: HashMap<Entity<Buffer>, HashMap<u64, BufferSnapshot>>,
+}
+
+impl OldAcpClientDelegate {
+ fn new(thread: Rc<RefCell<WeakEntity<AcpThread>>>, cx: AsyncApp) -> Self {
+ Self {
+ thread,
+ cx,
+ next_tool_call_id: Rc::new(RefCell::new(0)),
+ }
+ }
+}
+
+impl acp_old::Client for OldAcpClientDelegate {
+ async fn stream_assistant_message_chunk(
+ &self,
+ params: acp_old::StreamAssistantMessageChunkParams,
+ ) -> Result<(), acp_old::Error> {
+ let cx = &mut self.cx.clone();
+
+ cx.update(|cx| {
+ self.thread
+ .borrow()
+ .update(cx, |thread, cx| match params.chunk {
+ acp_old::AssistantMessageChunk::Text { text } => {
+ thread.push_assistant_content_block(text.into(), false, cx)
+ }
+ acp_old::AssistantMessageChunk::Thought { thought } => {
+ thread.push_assistant_content_block(thought.into(), true, cx)
+ }
+ })
+ .log_err();
+ })?;
+
+ Ok(())
+ }
+
+ async fn request_tool_call_confirmation(
+ &self,
+ request: acp_old::RequestToolCallConfirmationParams,
+ ) -> Result<acp_old::RequestToolCallConfirmationResponse, acp_old::Error> {
+ let cx = &mut self.cx.clone();
+
+ let old_acp_id = *self.next_tool_call_id.borrow() + 1;
+ self.next_tool_call_id.replace(old_acp_id);
+
+ let tool_call = into_new_tool_call(
+ acp::ToolCallId(old_acp_id.to_string().into()),
+ request.tool_call,
+ );
+
+ let mut options = match request.confirmation {
+ acp_old::ToolCallConfirmation::Edit { .. } => vec![(
+ acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
+ acp::PermissionOptionKind::AllowAlways,
+ "Always Allow Edits".to_string(),
+ )],
+ acp_old::ToolCallConfirmation::Execute { root_command, .. } => vec![(
+ acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
+ acp::PermissionOptionKind::AllowAlways,
+ format!("Always Allow {}", root_command),
+ )],
+ acp_old::ToolCallConfirmation::Mcp {
+ server_name,
+ tool_name,
+ ..
+ } => vec![
+ (
+ acp_old::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
+ acp::PermissionOptionKind::AllowAlways,
+ format!("Always Allow {}", server_name),
+ ),
+ (
+ acp_old::ToolCallConfirmationOutcome::AlwaysAllowTool,
+ acp::PermissionOptionKind::AllowAlways,
+ format!("Always Allow {}", tool_name),
+ ),
+ ],
+ acp_old::ToolCallConfirmation::Fetch { .. } => vec![(
+ acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
+ acp::PermissionOptionKind::AllowAlways,
+ "Always Allow".to_string(),
+ )],
+ acp_old::ToolCallConfirmation::Other { .. } => vec![(
+ acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
+ acp::PermissionOptionKind::AllowAlways,
+ "Always Allow".to_string(),
+ )],
+ };
+
+ options.extend([
+ (
+ acp_old::ToolCallConfirmationOutcome::Allow,
+ acp::PermissionOptionKind::AllowOnce,
+ "Allow".to_string(),
+ ),
+ (
+ acp_old::ToolCallConfirmationOutcome::Reject,
+ acp::PermissionOptionKind::RejectOnce,
+ "Reject".to_string(),
+ ),
+ ]);
+
+ let mut outcomes = Vec::with_capacity(options.len());
+ let mut acp_options = Vec::with_capacity(options.len());
+
+ for (index, (outcome, kind, label)) in options.into_iter().enumerate() {
+ outcomes.push(outcome);
+ acp_options.push(acp::PermissionOption {
+ id: acp::PermissionOptionId(index.to_string().into()),
+ name: label,
+ kind,
+ })
+ }
+
+ let response = cx
+ .update(|cx| {
+ self.thread.borrow().update(cx, |thread, cx| {
+ thread.request_tool_call_authorization(tool_call.into(), acp_options, cx)
+ })
+ })??
+ .context("Failed to update thread")?
+ .await;
+
+ let outcome = match response {
+ Ok(option_id) => outcomes[option_id.0.parse::<usize>().unwrap_or(0)],
+ Err(oneshot::Canceled) => acp_old::ToolCallConfirmationOutcome::Cancel,
+ };
+
+ Ok(acp_old::RequestToolCallConfirmationResponse {
+ id: acp_old::ToolCallId(old_acp_id),
+ outcome,
+ })
+ }
+
+ async fn push_tool_call(
+ &self,
+ request: acp_old::PushToolCallParams,
+ ) -> Result<acp_old::PushToolCallResponse, acp_old::Error> {
+ let cx = &mut self.cx.clone();
+
+ let old_acp_id = *self.next_tool_call_id.borrow() + 1;
+ self.next_tool_call_id.replace(old_acp_id);
+
+ cx.update(|cx| {
+ self.thread.borrow().update(cx, |thread, cx| {
+ thread.upsert_tool_call(
+ into_new_tool_call(acp::ToolCallId(old_acp_id.to_string().into()), request),
+ cx,
+ )
+ })
+ })??
+ .context("Failed to update thread")?;
+
+ Ok(acp_old::PushToolCallResponse {
+ id: acp_old::ToolCallId(old_acp_id),
+ })
+ }
+
+ async fn update_tool_call(
+ &self,
+ request: acp_old::UpdateToolCallParams,
+ ) -> Result<(), acp_old::Error> {
+ let cx = &mut self.cx.clone();
+
+ cx.update(|cx| {
+ self.thread.borrow().update(cx, |thread, cx| {
+ thread.update_tool_call(
+ acp::ToolCallUpdate {
+ id: acp::ToolCallId(request.tool_call_id.0.to_string().into()),
+ fields: acp::ToolCallUpdateFields {
+ status: Some(into_new_tool_call_status(request.status)),
+ content: Some(
+ request
+ .content
+ .into_iter()
+ .map(into_new_tool_call_content)
+ .collect::<Vec<_>>(),
+ ),
+ ..Default::default()
+ },
+ },
+ cx,
+ )
+ })
+ })?
+ .context("Failed to update thread")??;
+
+ Ok(())
+ }
+
+ async fn update_plan(&self, request: acp_old::UpdatePlanParams) -> Result<(), acp_old::Error> {
+ let cx = &mut self.cx.clone();
+
+ cx.update(|cx| {
+ self.thread.borrow().update(cx, |thread, cx| {
+ thread.update_plan(
+ acp::Plan {
+ entries: request
+ .entries
+ .into_iter()
+ .map(into_new_plan_entry)
+ .collect(),
+ },
+ cx,
+ )
+ })
+ })?
+ .context("Failed to update thread")?;
+
+ Ok(())
+ }
+
+ async fn read_text_file(
+ &self,
+ acp_old::ReadTextFileParams { path, line, limit }: acp_old::ReadTextFileParams,
+ ) -> Result<acp_old::ReadTextFileResponse, acp_old::Error> {
+ let content = self
+ .cx
+ .update(|cx| {
+ self.thread.borrow().update(cx, |thread, cx| {
+ thread.read_text_file(path, line, limit, false, cx)
+ })
+ })?
+ .context("Failed to update thread")?
+ .await?;
+ Ok(acp_old::ReadTextFileResponse { content })
+ }
+
+ async fn write_text_file(
+ &self,
+ acp_old::WriteTextFileParams { path, content }: acp_old::WriteTextFileParams,
+ ) -> Result<(), acp_old::Error> {
+ self.cx
+ .update(|cx| {
+ self.thread
+ .borrow()
+ .update(cx, |thread, cx| thread.write_text_file(path, content, cx))
+ })?
+ .context("Failed to update thread")?
+ .await?;
+
+ Ok(())
+ }
+}
+
+fn into_new_tool_call(id: acp::ToolCallId, request: acp_old::PushToolCallParams) -> acp::ToolCall {
+ acp::ToolCall {
+ id,
+ title: request.label,
+ kind: acp_kind_from_old_icon(request.icon),
+ status: acp::ToolCallStatus::InProgress,
+ content: request
+ .content
+ .into_iter()
+ .map(into_new_tool_call_content)
+ .collect(),
+ locations: request
+ .locations
+ .into_iter()
+ .map(into_new_tool_call_location)
+ .collect(),
+ raw_input: None,
+ raw_output: None,
+ }
+}
+
+fn acp_kind_from_old_icon(icon: acp_old::Icon) -> acp::ToolKind {
+ match icon {
+ acp_old::Icon::FileSearch => acp::ToolKind::Search,
+ acp_old::Icon::Folder => acp::ToolKind::Search,
+ acp_old::Icon::Globe => acp::ToolKind::Search,
+ acp_old::Icon::Hammer => acp::ToolKind::Other,
+ acp_old::Icon::LightBulb => acp::ToolKind::Think,
+ acp_old::Icon::Pencil => acp::ToolKind::Edit,
+ acp_old::Icon::Regex => acp::ToolKind::Search,
+ acp_old::Icon::Terminal => acp::ToolKind::Execute,
+ }
+}
+
+fn into_new_tool_call_status(status: acp_old::ToolCallStatus) -> acp::ToolCallStatus {
+ match status {
+ acp_old::ToolCallStatus::Running => acp::ToolCallStatus::InProgress,
+ acp_old::ToolCallStatus::Finished => acp::ToolCallStatus::Completed,
+ acp_old::ToolCallStatus::Error => acp::ToolCallStatus::Failed,
+ }
+}
+
+fn into_new_tool_call_content(content: acp_old::ToolCallContent) -> acp::ToolCallContent {
+ match content {
+ acp_old::ToolCallContent::Markdown { markdown } => markdown.into(),
+ acp_old::ToolCallContent::Diff { diff } => acp::ToolCallContent::Diff {
+ diff: into_new_diff(diff),
+ },
+ }
+}
+
+fn into_new_diff(diff: acp_old::Diff) -> acp::Diff {
+ acp::Diff {
+ path: diff.path,
+ old_text: diff.old_text,
+ new_text: diff.new_text,
+ }
+}
+
+fn into_new_tool_call_location(location: acp_old::ToolCallLocation) -> acp::ToolCallLocation {
+ acp::ToolCallLocation {
+ path: location.path,
+ line: location.line,
+ }
+}
+
+fn into_new_plan_entry(entry: acp_old::PlanEntry) -> acp::PlanEntry {
+ acp::PlanEntry {
+ content: entry.content,
+ priority: into_new_plan_priority(entry.priority),
+ status: into_new_plan_status(entry.status),
+ }
+}
+
+fn into_new_plan_priority(priority: acp_old::PlanEntryPriority) -> acp::PlanEntryPriority {
+ match priority {
+ acp_old::PlanEntryPriority::Low => acp::PlanEntryPriority::Low,
+ acp_old::PlanEntryPriority::Medium => acp::PlanEntryPriority::Medium,
+ acp_old::PlanEntryPriority::High => acp::PlanEntryPriority::High,
+ }
+}
+
+fn into_new_plan_status(status: acp_old::PlanEntryStatus) -> acp::PlanEntryStatus {
+ match status {
+ acp_old::PlanEntryStatus::Pending => acp::PlanEntryStatus::Pending,
+ acp_old::PlanEntryStatus::InProgress => acp::PlanEntryStatus::InProgress,
+ acp_old::PlanEntryStatus::Completed => acp::PlanEntryStatus::Completed,
+ }
+}
+
+pub struct AcpConnection {
+ pub name: &'static str,
+ pub connection: acp_old::AgentConnection,
+ pub _child_status: Task<Result<()>>,
+ pub current_thread: Rc<RefCell<WeakEntity<AcpThread>>>,
+}
+
+impl AcpConnection {
+ pub fn stdio(
+ name: &'static str,
+ command: AgentServerCommand,
+ root_dir: &Path,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<Self>> {
+ let root_dir = root_dir.to_path_buf();
+
+ cx.spawn(async move |cx| {
+ let mut child = util::command::new_smol_command(&command.path)
+ .args(command.args.iter())
+ .current_dir(root_dir)
+ .stdin(std::process::Stdio::piped())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::inherit())
+ .kill_on_drop(true)
+ .spawn()?;
+
+ let stdin = child.stdin.take().unwrap();
+ let stdout = child.stdout.take().unwrap();
+ log::trace!("Spawned (pid: {})", child.id());
+
+ let foreground_executor = cx.foreground_executor().clone();
+
+ let thread_rc = Rc::new(RefCell::new(WeakEntity::new_invalid()));
+
+ let (connection, io_fut) = acp_old::AgentConnection::connect_to_agent(
+ OldAcpClientDelegate::new(thread_rc.clone(), cx.clone()),
+ stdin,
+ stdout,
+ move |fut| foreground_executor.spawn(fut).detach(),
+ );
+
+ let io_task = cx.background_spawn(async move {
+ io_fut.await.log_err();
+ });
+
+ let child_status = cx.background_spawn(async move {
+ let result = match child.status().await {
+ Err(e) => Err(anyhow!(e)),
+ Ok(result) if result.success() => Ok(()),
+ Ok(result) => Err(anyhow!(result)),
+ };
+ drop(io_task);
+ result
+ });
+
+ Ok(Self {
+ name,
+ connection,
+ _child_status: child_status,
+ current_thread: thread_rc,
+ })
+ })
+ }
+}
+
+impl AgentConnection for AcpConnection {
+ fn new_thread(
+ self: Rc<Self>,
+ project: Entity<Project>,
+ _cwd: &Path,
+ cx: &mut App,
+ ) -> Task<Result<Entity<AcpThread>>> {
+ let task = self.connection.request_any(
+ acp_old::InitializeParams {
+ protocol_version: acp_old::ProtocolVersion::latest(),
+ }
+ .into_any(),
+ );
+ let current_thread = self.current_thread.clone();
+ cx.spawn(async move |cx| {
+ let result = task.await?;
+ let result = acp_old::InitializeParams::response_from_any(result)?;
+
+ if !result.is_authenticated {
+ anyhow::bail!(AuthRequired::new())
+ }
+
+ cx.update(|cx| {
+ let thread = cx.new(|cx| {
+ let session_id = acp::SessionId("acp-old-no-id".into());
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+ AcpThread::new(self.name, self.clone(), project, action_log, session_id)
+ });
+ current_thread.replace(thread.downgrade());
+ thread
+ })
+ })
+ }
+
+ fn auth_methods(&self) -> &[acp::AuthMethod] {
+ &[]
+ }
+
+ fn authenticate(&self, _method_id: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>> {
+ let task = self
+ .connection
+ .request_any(acp_old::AuthenticateParams.into_any());
+ cx.foreground_executor().spawn(async move {
+ task.await?;
+ Ok(())
+ })
+ }
+
+ fn prompt(
+ &self,
+ _id: Option<acp_thread::UserMessageId>,
+ params: acp::PromptRequest,
+ cx: &mut App,
+ ) -> Task<Result<acp::PromptResponse>> {
+ let chunks = params
+ .prompt
+ .into_iter()
+ .filter_map(|block| match block {
+ acp::ContentBlock::Text(text) => {
+ Some(acp_old::UserMessageChunk::Text { text: text.text })
+ }
+ acp::ContentBlock::ResourceLink(link) => Some(acp_old::UserMessageChunk::Path {
+ path: link.uri.into(),
+ }),
+ _ => None,
+ })
+ .collect();
+
+ let task = self
+ .connection
+ .request_any(acp_old::SendUserMessageParams { chunks }.into_any());
+ cx.foreground_executor().spawn(async move {
+ task.await?;
+ anyhow::Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::EndTurn,
+ })
+ })
+ }
+
+ fn prompt_capabilities(&self) -> acp::PromptCapabilities {
+ acp::PromptCapabilities {
+ image: false,
+ audio: false,
+ embedded_context: false,
+ }
+ }
+
+ fn cancel(&self, _session_id: &acp::SessionId, cx: &mut App) {
+ let task = self
+ .connection
+ .request_any(acp_old::CancelSendMessageParams.into_any());
+ cx.foreground_executor()
+ .spawn(async move {
+ task.await?;
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx)
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ self
+ }
+}
@@ -0,0 +1,376 @@
+use acp_tools::AcpConnectionRegistry;
+use action_log::ActionLog;
+use agent_client_protocol::{self as acp, Agent as _, ErrorCode};
+use anyhow::anyhow;
+use collections::HashMap;
+use futures::AsyncBufReadExt as _;
+use futures::channel::oneshot;
+use futures::io::BufReader;
+use project::Project;
+use serde::Deserialize;
+use std::path::Path;
+use std::rc::Rc;
+use std::{any::Any, cell::RefCell};
+
+use anyhow::{Context as _, Result};
+use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity};
+
+use crate::{AgentServerCommand, acp::UnsupportedVersion};
+use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError};
+
+pub struct AcpConnection {
+ server_name: &'static str,
+ connection: Rc<acp::ClientSideConnection>,
+ sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
+ auth_methods: Vec<acp::AuthMethod>,
+ prompt_capabilities: acp::PromptCapabilities,
+ _io_task: Task<Result<()>>,
+}
+
+pub struct AcpSession {
+ thread: WeakEntity<AcpThread>,
+ suppress_abort_err: bool,
+}
+
+const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1;
+
+impl AcpConnection {
+ pub async fn stdio(
+ server_name: &'static str,
+ command: AgentServerCommand,
+ root_dir: &Path,
+ cx: &mut AsyncApp,
+ ) -> Result<Self> {
+ let mut child = util::command::new_smol_command(&command.path)
+ .args(command.args.iter().map(|arg| arg.as_str()))
+ .envs(command.env.iter().flatten())
+ .current_dir(root_dir)
+ .stdin(std::process::Stdio::piped())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped())
+ .kill_on_drop(true)
+ .spawn()?;
+
+ let stdout = child.stdout.take().context("Failed to take stdout")?;
+ let stdin = child.stdin.take().context("Failed to take stdin")?;
+ let stderr = child.stderr.take().context("Failed to take stderr")?;
+ log::trace!("Spawned (pid: {})", child.id());
+
+ let sessions = Rc::new(RefCell::new(HashMap::default()));
+
+ let client = ClientDelegate {
+ sessions: sessions.clone(),
+ cx: cx.clone(),
+ };
+ let (connection, io_task) = acp::ClientSideConnection::new(client, stdin, stdout, {
+ let foreground_executor = cx.foreground_executor().clone();
+ move |fut| {
+ foreground_executor.spawn(fut).detach();
+ }
+ });
+
+ let io_task = cx.background_spawn(io_task);
+
+ cx.background_spawn(async move {
+ let mut stderr = BufReader::new(stderr);
+ let mut line = String::new();
+ while let Ok(n) = stderr.read_line(&mut line).await
+ && n > 0
+ {
+ log::warn!("agent stderr: {}", &line);
+ line.clear();
+ }
+ })
+ .detach();
+
+ cx.spawn({
+ let sessions = sessions.clone();
+ async move |cx| {
+ let status = child.status().await?;
+
+ for session in sessions.borrow().values() {
+ session
+ .thread
+ .update(cx, |thread, cx| {
+ thread.emit_load_error(LoadError::Exited { status }, cx)
+ })
+ .ok();
+ }
+
+ anyhow::Ok(())
+ }
+ })
+ .detach();
+
+ let connection = Rc::new(connection);
+
+ cx.update(|cx| {
+ AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| {
+ registry.set_active_connection(server_name, &connection, cx)
+ });
+ })?;
+
+ let response = connection
+ .initialize(acp::InitializeRequest {
+ protocol_version: acp::VERSION,
+ client_capabilities: acp::ClientCapabilities {
+ fs: acp::FileSystemCapability {
+ read_text_file: true,
+ write_text_file: true,
+ },
+ },
+ })
+ .await?;
+
+ if response.protocol_version < MINIMUM_SUPPORTED_VERSION {
+ return Err(UnsupportedVersion.into());
+ }
+
+ Ok(Self {
+ auth_methods: response.auth_methods,
+ connection,
+ server_name,
+ sessions,
+ prompt_capabilities: response.agent_capabilities.prompt_capabilities,
+ _io_task: io_task,
+ })
+ }
+}
+
+impl AgentConnection for AcpConnection {
+ fn new_thread(
+ self: Rc<Self>,
+ project: Entity<Project>,
+ cwd: &Path,
+ cx: &mut App,
+ ) -> Task<Result<Entity<AcpThread>>> {
+ let conn = self.connection.clone();
+ let sessions = self.sessions.clone();
+ let cwd = cwd.to_path_buf();
+ cx.spawn(async move |cx| {
+ let response = conn
+ .new_session(acp::NewSessionRequest {
+ mcp_servers: vec![],
+ cwd,
+ })
+ .await
+ .map_err(|err| {
+ if err.code == acp::ErrorCode::AUTH_REQUIRED.code {
+ let mut error = AuthRequired::new();
+
+ if err.message != acp::ErrorCode::AUTH_REQUIRED.message {
+ error = error.with_description(err.message);
+ }
+
+ anyhow!(error)
+ } else {
+ anyhow!(err)
+ }
+ })?;
+
+ let session_id = response.session_id;
+ let action_log = cx.new(|_| ActionLog::new(project.clone()))?;
+ let thread = cx.new(|_cx| {
+ AcpThread::new(
+ self.server_name,
+ self.clone(),
+ project,
+ action_log,
+ session_id.clone(),
+ )
+ })?;
+
+ let session = AcpSession {
+ thread: thread.downgrade(),
+ suppress_abort_err: false,
+ };
+ sessions.borrow_mut().insert(session_id, session);
+
+ Ok(thread)
+ })
+ }
+
+ fn auth_methods(&self) -> &[acp::AuthMethod] {
+ &self.auth_methods
+ }
+
+ fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>> {
+ let conn = self.connection.clone();
+ cx.foreground_executor().spawn(async move {
+ let result = conn
+ .authenticate(acp::AuthenticateRequest {
+ method_id: method_id.clone(),
+ })
+ .await?;
+
+ Ok(result)
+ })
+ }
+
+ fn prompt(
+ &self,
+ _id: Option<acp_thread::UserMessageId>,
+ params: acp::PromptRequest,
+ cx: &mut App,
+ ) -> Task<Result<acp::PromptResponse>> {
+ let conn = self.connection.clone();
+ let sessions = self.sessions.clone();
+ let session_id = params.session_id.clone();
+ cx.foreground_executor().spawn(async move {
+ let result = conn.prompt(params).await;
+
+ let mut suppress_abort_err = false;
+
+ if let Some(session) = sessions.borrow_mut().get_mut(&session_id) {
+ suppress_abort_err = session.suppress_abort_err;
+ session.suppress_abort_err = false;
+ }
+
+ match result {
+ Ok(response) => Ok(response),
+ Err(err) => {
+ if err.code != ErrorCode::INTERNAL_ERROR.code {
+ anyhow::bail!(err)
+ }
+
+ let Some(data) = &err.data else {
+ anyhow::bail!(err)
+ };
+
+ // Temporary workaround until the following PR is generally available:
+ // https://github.com/google-gemini/gemini-cli/pull/6656
+
+ #[derive(Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct ErrorDetails {
+ details: Box<str>,
+ }
+
+ match serde_json::from_value(data.clone()) {
+ Ok(ErrorDetails { details }) => {
+ if suppress_abort_err && details.contains("This operation was aborted")
+ {
+ Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::Cancelled,
+ })
+ } else {
+ Err(anyhow!(details))
+ }
+ }
+ Err(_) => Err(anyhow!(err)),
+ }
+ }
+ }
+ })
+ }
+
+ fn prompt_capabilities(&self) -> acp::PromptCapabilities {
+ self.prompt_capabilities
+ }
+
+ fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) {
+ if let Some(session) = self.sessions.borrow_mut().get_mut(session_id) {
+ session.suppress_abort_err = true;
+ }
+ let conn = self.connection.clone();
+ let params = acp::CancelNotification {
+ session_id: session_id.clone(),
+ };
+ cx.foreground_executor()
+ .spawn(async move { conn.cancel(params).await })
+ .detach();
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ self
+ }
+}
+
+struct ClientDelegate {
+ sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
+ cx: AsyncApp,
+}
+
+impl acp::Client for ClientDelegate {
+ async fn request_permission(
+ &self,
+ arguments: acp::RequestPermissionRequest,
+ ) -> Result<acp::RequestPermissionResponse, acp::Error> {
+ let cx = &mut self.cx.clone();
+ let rx = self
+ .sessions
+ .borrow()
+ .get(&arguments.session_id)
+ .context("Failed to get session")?
+ .thread
+ .update(cx, |thread, cx| {
+ thread.request_tool_call_authorization(arguments.tool_call, arguments.options, cx)
+ })?;
+
+ let result = rx?.await;
+
+ let outcome = match result {
+ Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option },
+ Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled,
+ };
+
+ Ok(acp::RequestPermissionResponse { outcome })
+ }
+
+ async fn write_text_file(
+ &self,
+ arguments: acp::WriteTextFileRequest,
+ ) -> Result<(), acp::Error> {
+ let cx = &mut self.cx.clone();
+ let task = self
+ .sessions
+ .borrow()
+ .get(&arguments.session_id)
+ .context("Failed to get session")?
+ .thread
+ .update(cx, |thread, cx| {
+ thread.write_text_file(arguments.path, arguments.content, cx)
+ })?;
+
+ task.await?;
+
+ Ok(())
+ }
+
+ async fn read_text_file(
+ &self,
+ arguments: acp::ReadTextFileRequest,
+ ) -> Result<acp::ReadTextFileResponse, acp::Error> {
+ let cx = &mut self.cx.clone();
+ let task = self
+ .sessions
+ .borrow()
+ .get(&arguments.session_id)
+ .context("Failed to get session")?
+ .thread
+ .update(cx, |thread, cx| {
+ thread.read_text_file(arguments.path, arguments.line, arguments.limit, false, cx)
+ })?;
+
+ let content = task.await?;
+
+ Ok(acp::ReadTextFileResponse { content })
+ }
+
+ async fn session_notification(
+ &self,
+ notification: acp::SessionNotification,
+ ) -> Result<(), acp::Error> {
+ let cx = &mut self.cx.clone();
+ let sessions = self.sessions.borrow();
+ let session = sessions
+ .get(¬ification.session_id)
+ .context("Failed to get session")?;
+
+ session.thread.update(cx, |thread, cx| {
+ thread.handle_session_update(notification.update, cx)
+ })??;
+
+ Ok(())
+ }
+}
@@ -1,30 +1,90 @@
-use std::{
- path::{Path, PathBuf},
- sync::Arc,
-};
-
-use anyhow::{Context as _, Result};
+mod acp;
+mod claude;
+mod custom;
+mod gemini;
+mod settings;
+
+#[cfg(any(test, feature = "test-support"))]
+pub mod e2e_tests;
+
+pub use claude::*;
+pub use custom::*;
+pub use gemini::*;
+pub use settings::*;
+
+use acp_thread::AgentConnection;
+use anyhow::Result;
use collections::HashMap;
-use gpui::{App, AsyncApp, Entity, SharedString};
+use gpui::{App, AsyncApp, Entity, SharedString, Task};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources, SettingsStore};
-use util::{ResultExt, paths};
+use std::{
+ any::Any,
+ path::{Path, PathBuf},
+ rc::Rc,
+ sync::Arc,
+};
+use util::ResultExt as _;
pub fn init(cx: &mut App) {
- AllAgentServersSettings::register(cx);
+ settings::init(cx);
+}
+
+pub trait AgentServer: Send {
+ fn logo(&self) -> ui::IconName;
+ fn name(&self) -> SharedString;
+ fn empty_state_headline(&self) -> SharedString;
+ fn empty_state_message(&self) -> SharedString;
+
+ fn connect(
+ &self,
+ root_dir: &Path,
+ project: &Entity<Project>,
+ cx: &mut App,
+ ) -> Task<Result<Rc<dyn AgentConnection>>>;
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
}
-#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)]
-pub struct AllAgentServersSettings {
- gemini: Option<AgentServerSettings>,
+impl dyn AgentServer {
+ pub fn downcast<T: 'static + AgentServer + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
+ self.into_any().downcast().ok()
+ }
+}
+
+impl std::fmt::Debug for AgentServerCommand {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let filtered_env = self.env.as_ref().map(|env| {
+ env.iter()
+ .map(|(k, v)| {
+ (
+ k,
+ if util::redact::should_redact(k) {
+ "[REDACTED]"
+ } else {
+ v
+ },
+ )
+ })
+ .collect::<Vec<_>>()
+ });
+
+ f.debug_struct("AgentServerCommand")
+ .field("path", &self.path)
+ .field("args", &self.args)
+ .field("env", &filtered_env)
+ .finish()
+ }
}
-#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)]
-pub struct AgentServerSettings {
- #[serde(flatten)]
- command: AgentServerCommand,
+pub enum AgentServerVersion {
+ Supported,
+ Unsupported {
+ error_message: SharedString,
+ upgrade_message: SharedString,
+ upgrade_command: String,
+ },
}
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
@@ -36,105 +96,46 @@ pub struct AgentServerCommand {
pub env: Option<HashMap<String, String>>,
}
-pub struct Gemini;
-
-pub struct AgentServerVersion {
- pub current_version: SharedString,
- pub supported: bool,
-}
-
-pub trait AgentServer: Send {
- fn command(
- &self,
- project: &Entity<Project>,
- cx: &mut AsyncApp,
- ) -> impl Future<Output = Result<AgentServerCommand>>;
-
- fn version(
- &self,
- command: &AgentServerCommand,
- ) -> impl Future<Output = Result<AgentServerVersion>> + Send;
-}
-
-const GEMINI_ACP_ARG: &str = "--acp";
-
-impl AgentServer for Gemini {
- async fn command(
- &self,
+impl AgentServerCommand {
+ pub(crate) async fn resolve(
+ path_bin_name: &'static str,
+ extra_args: &[&'static str],
+ fallback_path: Option<&Path>,
+ settings: Option<AgentServerSettings>,
project: &Entity<Project>,
cx: &mut AsyncApp,
- ) -> Result<AgentServerCommand> {
- let custom_command = cx.read_global(|settings: &SettingsStore, _| {
- let settings = settings.get::<AllAgentServersSettings>(None);
- settings
- .gemini
- .as_ref()
- .map(|gemini_settings| AgentServerCommand {
- path: gemini_settings.command.path.clone(),
- args: gemini_settings
- .command
- .args
- .iter()
- .cloned()
- .chain(std::iter::once(GEMINI_ACP_ARG.into()))
- .collect(),
- env: gemini_settings.command.env.clone(),
- })
- })?;
-
- if let Some(custom_command) = custom_command {
- return Ok(custom_command);
- }
-
- if let Some(path) = find_bin_in_path("gemini", project, cx).await {
- return Ok(AgentServerCommand {
- path,
- args: vec![GEMINI_ACP_ARG.into()],
- env: None,
- });
+ ) -> Option<Self> {
+ if let Some(agent_settings) = settings {
+ Some(Self {
+ path: agent_settings.command.path,
+ args: agent_settings
+ .command
+ .args
+ .into_iter()
+ .chain(extra_args.iter().map(|arg| arg.to_string()))
+ .collect(),
+ env: agent_settings.command.env,
+ })
+ } else {
+ match find_bin_in_path(path_bin_name, project, cx).await {
+ Some(path) => Some(Self {
+ path,
+ args: extra_args.iter().map(|arg| arg.to_string()).collect(),
+ env: None,
+ }),
+ None => fallback_path.and_then(|path| {
+ if path.exists() {
+ Some(Self {
+ path: path.to_path_buf(),
+ args: extra_args.iter().map(|arg| arg.to_string()).collect(),
+ env: None,
+ })
+ } else {
+ None
+ }
+ }),
+ }
}
-
- let (fs, node_runtime) = project.update(cx, |project, _| {
- (project.fs().clone(), project.node_runtime().cloned())
- })?;
- let node_runtime = node_runtime.context("gemini not found on path")?;
-
- let directory = ::paths::agent_servers_dir().join("gemini");
- fs.create_dir(&directory).await?;
- node_runtime
- .npm_install_packages(&directory, &[("@google/gemini-cli", "latest")])
- .await?;
- let path = directory.join("node_modules/.bin/gemini");
-
- Ok(AgentServerCommand {
- path,
- args: vec![GEMINI_ACP_ARG.into()],
- env: None,
- })
- }
-
- async fn version(&self, command: &AgentServerCommand) -> Result<AgentServerVersion> {
- let version_fut = util::command::new_smol_command(&command.path)
- .args(command.args.iter())
- .arg("--version")
- .kill_on_drop(true)
- .output();
-
- let help_fut = util::command::new_smol_command(&command.path)
- .args(command.args.iter())
- .arg("--help")
- .kill_on_drop(true)
- .output();
-
- let (version_output, help_output) = futures::future::join(version_fut, help_fut).await;
-
- let current_version = String::from_utf8(version_output?.stdout)?.into();
- let supported = String::from_utf8(help_output?.stdout)?.contains(GEMINI_ACP_ARG);
-
- Ok(AgentServerVersion {
- current_version,
- supported,
- })
}
}
@@ -184,48 +185,3 @@ async fn find_bin_in_path(
})
.await
}
-
-impl std::fmt::Debug for AgentServerCommand {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- let filtered_env = self.env.as_ref().map(|env| {
- env.iter()
- .map(|(k, v)| {
- (
- k,
- if util::redact::should_redact(k) {
- "[REDACTED]"
- } else {
- v
- },
- )
- })
- .collect::<Vec<_>>()
- });
-
- f.debug_struct("AgentServerCommand")
- .field("path", &self.path)
- .field("args", &self.args)
- .field("env", &filtered_env)
- .finish()
- }
-}
-
-impl settings::Settings for AllAgentServersSettings {
- const KEY: Option<&'static str> = Some("agent_servers");
-
- type FileContent = Self;
-
- fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
- let mut settings = AllAgentServersSettings::default();
-
- for value in sources.defaults_and_customizations() {
- if value.gemini.is_some() {
- settings.gemini = value.gemini.clone();
- }
- }
-
- Ok(settings)
- }
-
- fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
-}
@@ -0,0 +1,1375 @@
+mod edit_tool;
+mod mcp_server;
+mod permission_tool;
+mod read_tool;
+pub mod tools;
+mod write_tool;
+
+use action_log::ActionLog;
+use collections::HashMap;
+use context_server::listener::McpServerTool;
+use language_models::provider::anthropic::AnthropicLanguageModelProvider;
+use project::Project;
+use settings::SettingsStore;
+use smol::process::Child;
+use std::any::Any;
+use std::cell::RefCell;
+use std::fmt::Display;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+use util::command::new_smol_command;
+use uuid::Uuid;
+
+use agent_client_protocol as acp;
+use anyhow::{Context as _, Result, anyhow};
+use futures::channel::oneshot;
+use futures::{AsyncBufReadExt, AsyncWriteExt};
+use futures::{
+ AsyncRead, AsyncWrite, FutureExt, StreamExt,
+ channel::mpsc::{self, UnboundedReceiver, UnboundedSender},
+ io::BufReader,
+ select_biased,
+};
+use gpui::{App, AppContext, AsyncApp, Entity, SharedString, Task, WeakEntity};
+use serde::{Deserialize, Serialize};
+use util::{ResultExt, debug_panic};
+
+use crate::claude::mcp_server::{ClaudeZedMcpServer, McpConfig};
+use crate::claude::tools::ClaudeTool;
+use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings};
+use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri};
+
+#[derive(Clone)]
+pub struct ClaudeCode;
+
+impl AgentServer for ClaudeCode {
+ fn name(&self) -> SharedString {
+ "Claude Code".into()
+ }
+
+ fn empty_state_headline(&self) -> SharedString {
+ self.name()
+ }
+
+ fn empty_state_message(&self) -> SharedString {
+ "How can I help you today?".into()
+ }
+
+ fn logo(&self) -> ui::IconName {
+ ui::IconName::AiClaude
+ }
+
+ fn connect(
+ &self,
+ _root_dir: &Path,
+ _project: &Entity<Project>,
+ _cx: &mut App,
+ ) -> Task<Result<Rc<dyn AgentConnection>>> {
+ let connection = ClaudeAgentConnection {
+ sessions: Default::default(),
+ };
+
+ Task::ready(Ok(Rc::new(connection) as _))
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ self
+ }
+}
+
+struct ClaudeAgentConnection {
+ sessions: Rc<RefCell<HashMap<acp::SessionId, ClaudeAgentSession>>>,
+}
+
+impl AgentConnection for ClaudeAgentConnection {
+ fn new_thread(
+ self: Rc<Self>,
+ project: Entity<Project>,
+ cwd: &Path,
+ cx: &mut App,
+ ) -> Task<Result<Entity<AcpThread>>> {
+ let cwd = cwd.to_owned();
+ cx.spawn(async move |cx| {
+ let settings = cx.read_global(|settings: &SettingsStore, _| {
+ settings.get::<AllAgentServersSettings>(None).claude.clone()
+ })?;
+
+ let Some(command) = AgentServerCommand::resolve(
+ "claude",
+ &[],
+ Some(&util::paths::home_dir().join(".claude/local/claude")),
+ settings,
+ &project,
+ cx,
+ )
+ .await
+ else {
+ return Err(LoadError::NotInstalled {
+ error_message: "Failed to find Claude Code binary".into(),
+ install_message: "Install Claude Code".into(),
+ install_command: "npm install -g @anthropic-ai/claude-code@latest".into(),
+ }.into());
+ };
+
+ let api_key =
+ cx.update(AnthropicLanguageModelProvider::api_key)?
+ .await
+ .map_err(|err| {
+ if err.is::<language_model::AuthenticateError>() {
+ anyhow!(AuthRequired::new().with_language_model_provider(
+ language_model::ANTHROPIC_PROVIDER_ID
+ ))
+ } else {
+ anyhow!(err)
+ }
+ })?;
+
+ let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid());
+ let fs = project.read_with(cx, |project, _cx| project.fs().clone())?;
+ let permission_mcp_server = ClaudeZedMcpServer::new(thread_rx.clone(), fs, cx).await?;
+
+ let mut mcp_servers = HashMap::default();
+ mcp_servers.insert(
+ mcp_server::SERVER_NAME.to_string(),
+ permission_mcp_server.server_config()?,
+ );
+ let mcp_config = McpConfig { mcp_servers };
+
+ let mcp_config_file = tempfile::NamedTempFile::new()?;
+ let (mcp_config_file, mcp_config_path) = mcp_config_file.into_parts();
+
+ let mut mcp_config_file = smol::fs::File::from(mcp_config_file);
+ mcp_config_file
+ .write_all(serde_json::to_string(&mcp_config)?.as_bytes())
+ .await?;
+ mcp_config_file.flush().await?;
+
+ let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded();
+ let (outgoing_tx, outgoing_rx) = mpsc::unbounded();
+
+ let session_id = acp::SessionId(Uuid::new_v4().to_string().into());
+
+ log::trace!("Starting session with id: {}", session_id);
+
+ let mut child = spawn_claude(
+ &command,
+ ClaudeSessionMode::Start,
+ session_id.clone(),
+ api_key,
+ &mcp_config_path,
+ &cwd,
+ )?;
+
+ let stdout = child.stdout.take().context("Failed to take stdout")?;
+ let stdin = child.stdin.take().context("Failed to take stdin")?;
+ let stderr = child.stderr.take().context("Failed to take stderr")?;
+
+ let pid = child.id();
+ log::trace!("Spawned (pid: {})", pid);
+
+ cx.background_spawn(async move {
+ let mut stderr = BufReader::new(stderr);
+ let mut line = String::new();
+ while let Ok(n) = stderr.read_line(&mut line).await
+ && n > 0
+ {
+ log::warn!("agent stderr: {}", &line);
+ line.clear();
+ }
+ })
+ .detach();
+
+ cx.background_spawn(async move {
+ let mut outgoing_rx = Some(outgoing_rx);
+
+ ClaudeAgentSession::handle_io(
+ outgoing_rx.take().unwrap(),
+ incoming_message_tx.clone(),
+ stdin,
+ stdout,
+ )
+ .await?;
+
+ log::trace!("Stopped (pid: {})", pid);
+
+ drop(mcp_config_path);
+ anyhow::Ok(())
+ })
+ .detach();
+
+ let turn_state = Rc::new(RefCell::new(TurnState::None));
+
+ let handler_task = cx.spawn({
+ let turn_state = turn_state.clone();
+ let mut thread_rx = thread_rx.clone();
+ async move |cx| {
+ while let Some(message) = incoming_message_rx.next().await {
+ ClaudeAgentSession::handle_message(
+ thread_rx.clone(),
+ message,
+ turn_state.clone(),
+ cx,
+ )
+ .await
+ }
+
+ if let Some(status) = child.status().await.log_err()
+ && let Some(thread) = thread_rx.recv().await.ok()
+ {
+ let version = claude_version(command.path.clone(), cx).await.log_err();
+ let help = claude_help(command.path.clone(), cx).await.log_err();
+ thread
+ .update(cx, |thread, cx| {
+ let error = if let Some(version) = version
+ && let Some(help) = help
+ && (!help.contains("--input-format")
+ || !help.contains("--session-id"))
+ {
+ LoadError::Unsupported {
+ error_message: format!(
+ "Your installed version of Claude Code ({}, version {}) does not have required features for use with Zed.",
+ command.path.to_string_lossy(),
+ version,
+ )
+ .into(),
+ upgrade_message: "Upgrade Claude Code to latest".into(),
+ upgrade_command: format!(
+ "{} update",
+ command.path.to_string_lossy()
+ ),
+ }
+ } else {
+ LoadError::Exited { status }
+ };
+ thread.emit_load_error(error, cx);
+ })
+ .ok();
+ }
+ }
+ });
+
+ let action_log = cx.new(|_| ActionLog::new(project.clone()))?;
+ let thread = cx.new(|_cx| {
+ AcpThread::new(
+ "Claude Code",
+ self.clone(),
+ project,
+ action_log,
+ session_id.clone(),
+ )
+ })?;
+
+ thread_tx.send(thread.downgrade())?;
+
+ let session = ClaudeAgentSession {
+ outgoing_tx,
+ turn_state,
+ _handler_task: handler_task,
+ _mcp_server: Some(permission_mcp_server),
+ };
+
+ self.sessions.borrow_mut().insert(session_id, session);
+
+ Ok(thread)
+ })
+ }
+
+ fn auth_methods(&self) -> &[acp::AuthMethod] {
+ &[]
+ }
+
+ fn authenticate(&self, _: acp::AuthMethodId, _cx: &mut App) -> Task<Result<()>> {
+ Task::ready(Err(anyhow!("Authentication not supported")))
+ }
+
+ fn prompt(
+ &self,
+ _id: Option<acp_thread::UserMessageId>,
+ params: acp::PromptRequest,
+ cx: &mut App,
+ ) -> Task<Result<acp::PromptResponse>> {
+ let sessions = self.sessions.borrow();
+ let Some(session) = sessions.get(¶ms.session_id) else {
+ return Task::ready(Err(anyhow!(
+ "Attempted to send message to nonexistent session {}",
+ params.session_id
+ )));
+ };
+
+ let (end_tx, end_rx) = oneshot::channel();
+ session.turn_state.replace(TurnState::InProgress { end_tx });
+
+ let content = acp_content_to_claude(params.prompt);
+
+ if let Err(err) = session.outgoing_tx.unbounded_send(SdkMessage::User {
+ message: Message {
+ role: Role::User,
+ content: Content::Chunks(content),
+ id: None,
+ model: None,
+ stop_reason: None,
+ stop_sequence: None,
+ usage: None,
+ },
+ session_id: Some(params.session_id.to_string()),
+ }) {
+ return Task::ready(Err(anyhow!(err)));
+ }
+
+ cx.foreground_executor().spawn(async move { end_rx.await? })
+ }
+
+ fn prompt_capabilities(&self) -> acp::PromptCapabilities {
+ acp::PromptCapabilities {
+ image: true,
+ audio: false,
+ embedded_context: true,
+ }
+ }
+
+ fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) {
+ let sessions = self.sessions.borrow();
+ let Some(session) = sessions.get(session_id) else {
+ log::warn!("Attempted to cancel nonexistent session {}", session_id);
+ return;
+ };
+
+ let request_id = new_request_id();
+
+ let turn_state = session.turn_state.take();
+ let TurnState::InProgress { end_tx } = turn_state else {
+ // Already canceled or idle, put it back
+ session.turn_state.replace(turn_state);
+ return;
+ };
+
+ session.turn_state.replace(TurnState::CancelRequested {
+ end_tx,
+ request_id: request_id.clone(),
+ });
+
+ session
+ .outgoing_tx
+ .unbounded_send(SdkMessage::ControlRequest {
+ request_id,
+ request: ControlRequest::Interrupt,
+ })
+ .log_err();
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ self
+ }
+}
+
+#[derive(Clone, Copy)]
+enum ClaudeSessionMode {
+ Start,
+ #[expect(dead_code)]
+ Resume,
+}
+
+fn spawn_claude(
+ command: &AgentServerCommand,
+ mode: ClaudeSessionMode,
+ session_id: acp::SessionId,
+ api_key: language_models::provider::anthropic::ApiKey,
+ mcp_config_path: &Path,
+ root_dir: &Path,
+) -> Result<Child> {
+ let child = util::command::new_smol_command(&command.path)
+ .args([
+ "--input-format",
+ "stream-json",
+ "--output-format",
+ "stream-json",
+ "--print",
+ "--verbose",
+ "--mcp-config",
+ mcp_config_path.to_string_lossy().as_ref(),
+ "--permission-prompt-tool",
+ &format!(
+ "mcp__{}__{}",
+ mcp_server::SERVER_NAME,
+ permission_tool::PermissionTool::NAME,
+ ),
+ "--allowedTools",
+ &format!(
+ "mcp__{}__{}",
+ mcp_server::SERVER_NAME,
+ read_tool::ReadTool::NAME
+ ),
+ "--disallowedTools",
+ "Read,Write,Edit,MultiEdit",
+ ])
+ .args(match mode {
+ ClaudeSessionMode::Start => ["--session-id".to_string(), session_id.to_string()],
+ ClaudeSessionMode::Resume => ["--resume".to_string(), session_id.to_string()],
+ })
+ .args(command.args.iter().map(|arg| arg.as_str()))
+ .envs(command.env.iter().flatten())
+ .env("ANTHROPIC_API_KEY", api_key.key)
+ .current_dir(root_dir)
+ .stdin(std::process::Stdio::piped())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped())
+ .kill_on_drop(true)
+ .spawn()?;
+
+ Ok(child)
+}
+
+fn claude_version(path: PathBuf, cx: &mut AsyncApp) -> Task<Result<semver::Version>> {
+ cx.background_spawn(async move {
+ let output = new_smol_command(path).arg("--version").output().await?;
+ let output = String::from_utf8(output.stdout)?;
+ let version = output
+ .trim()
+ .strip_suffix(" (Claude Code)")
+ .context("parsing Claude version")?;
+ let version = semver::Version::parse(version)?;
+ anyhow::Ok(version)
+ })
+}
+
+fn claude_help(path: PathBuf, cx: &mut AsyncApp) -> Task<Result<String>> {
+ cx.background_spawn(async move {
+ let output = new_smol_command(path).arg("--help").output().await?;
+ let output = String::from_utf8(output.stdout)?;
+ anyhow::Ok(output)
+ })
+}
+
+struct ClaudeAgentSession {
+ outgoing_tx: UnboundedSender<SdkMessage>,
+ turn_state: Rc<RefCell<TurnState>>,
+ _mcp_server: Option<ClaudeZedMcpServer>,
+ _handler_task: Task<()>,
+}
+
+#[derive(Debug, Default)]
+enum TurnState {
+ #[default]
+ None,
+ InProgress {
+ end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
+ },
+ CancelRequested {
+ end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
+ request_id: String,
+ },
+ CancelConfirmed {
+ end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
+ },
+}
+
+impl TurnState {
+ fn is_canceled(&self) -> bool {
+ matches!(self, TurnState::CancelConfirmed { .. })
+ }
+
+ fn end_tx(self) -> Option<oneshot::Sender<Result<acp::PromptResponse>>> {
+ match self {
+ TurnState::None => None,
+ TurnState::InProgress { end_tx, .. } => Some(end_tx),
+ TurnState::CancelRequested { end_tx, .. } => Some(end_tx),
+ TurnState::CancelConfirmed { end_tx } => Some(end_tx),
+ }
+ }
+
+ fn confirm_cancellation(self, id: &str) -> Self {
+ match self {
+ TurnState::CancelRequested { request_id, end_tx } if request_id == id => {
+ TurnState::CancelConfirmed { end_tx }
+ }
+ _ => self,
+ }
+ }
+}
+
+impl ClaudeAgentSession {
+ async fn handle_message(
+ mut thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
+ message: SdkMessage,
+ turn_state: Rc<RefCell<TurnState>>,
+ cx: &mut AsyncApp,
+ ) {
+ match message {
+ // we should only be sending these out, they don't need to be in the thread
+ SdkMessage::ControlRequest { .. } => {}
+ SdkMessage::User {
+ message,
+ session_id: _,
+ } => {
+ let Some(thread) = thread_rx
+ .recv()
+ .await
+ .log_err()
+ .and_then(|entity| entity.upgrade())
+ else {
+ log::error!("Received an SDK message but thread is gone");
+ return;
+ };
+
+ for chunk in message.content.chunks() {
+ match chunk {
+ ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
+ if !turn_state.borrow().is_canceled() {
+ thread
+ .update(cx, |thread, cx| {
+ thread.push_user_content_block(None, text.into(), cx)
+ })
+ .log_err();
+ }
+ }
+ ContentChunk::ToolResult {
+ content,
+ tool_use_id,
+ } => {
+ let content = content.to_string();
+ thread
+ .update(cx, |thread, cx| {
+ let id = acp::ToolCallId(tool_use_id.into());
+ let set_new_content = !content.is_empty()
+ && thread.tool_call(&id).is_none_or(|(_, tool_call)| {
+ // preserve rich diff if we have one
+ tool_call.diffs().next().is_none()
+ });
+
+ thread.update_tool_call(
+ acp::ToolCallUpdate {
+ id,
+ fields: acp::ToolCallUpdateFields {
+ status: if turn_state.borrow().is_canceled() {
+ // Do not set to completed if turn was canceled
+ None
+ } else {
+ Some(acp::ToolCallStatus::Completed)
+ },
+ content: set_new_content
+ .then(|| vec![content.into()]),
+ ..Default::default()
+ },
+ },
+ cx,
+ )
+ })
+ .log_err();
+ }
+ ContentChunk::Thinking { .. }
+ | ContentChunk::RedactedThinking
+ | ContentChunk::ToolUse { .. } => {
+ debug_panic!(
+ "Should not get {:?} with role: assistant. should we handle this?",
+ chunk
+ );
+ }
+ ContentChunk::Image { source } => {
+ if !turn_state.borrow().is_canceled() {
+ thread
+ .update(cx, |thread, cx| {
+ thread.push_user_content_block(None, source.into(), cx)
+ })
+ .log_err();
+ }
+ }
+
+ ContentChunk::Document | ContentChunk::WebSearchToolResult => {
+ thread
+ .update(cx, |thread, cx| {
+ thread.push_assistant_content_block(
+ format!("Unsupported content: {:?}", chunk).into(),
+ false,
+ cx,
+ )
+ })
+ .log_err();
+ }
+ }
+ }
+ }
+ SdkMessage::Assistant {
+ message,
+ session_id: _,
+ } => {
+ let Some(thread) = thread_rx
+ .recv()
+ .await
+ .log_err()
+ .and_then(|entity| entity.upgrade())
+ else {
+ log::error!("Received an SDK message but thread is gone");
+ return;
+ };
+
+ for chunk in message.content.chunks() {
+ match chunk {
+ ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
+ thread
+ .update(cx, |thread, cx| {
+ thread.push_assistant_content_block(text.into(), false, cx)
+ })
+ .log_err();
+ }
+ ContentChunk::Thinking { thinking } => {
+ thread
+ .update(cx, |thread, cx| {
+ thread.push_assistant_content_block(thinking.into(), true, cx)
+ })
+ .log_err();
+ }
+ ContentChunk::RedactedThinking => {
+ thread
+ .update(cx, |thread, cx| {
+ thread.push_assistant_content_block(
+ "[REDACTED]".into(),
+ true,
+ cx,
+ )
+ })
+ .log_err();
+ }
+ ContentChunk::ToolUse { id, name, input } => {
+ let claude_tool = ClaudeTool::infer(&name, input);
+
+ thread
+ .update(cx, |thread, cx| {
+ if let ClaudeTool::TodoWrite(Some(params)) = claude_tool {
+ thread.update_plan(
+ acp::Plan {
+ entries: params
+ .todos
+ .into_iter()
+ .map(Into::into)
+ .collect(),
+ },
+ cx,
+ )
+ } else {
+ thread.upsert_tool_call(
+ claude_tool.as_acp(acp::ToolCallId(id.into())),
+ cx,
+ )?;
+ }
+ anyhow::Ok(())
+ })
+ .log_err();
+ }
+ ContentChunk::ToolResult { .. } | ContentChunk::WebSearchToolResult => {
+ debug_panic!(
+ "Should not get tool results with role: assistant. should we handle this?"
+ );
+ }
+ ContentChunk::Image { source } => {
+ thread
+ .update(cx, |thread, cx| {
+ thread.push_assistant_content_block(source.into(), false, cx)
+ })
+ .log_err();
+ }
+ ContentChunk::Document => {
+ thread
+ .update(cx, |thread, cx| {
+ thread.push_assistant_content_block(
+ format!("Unsupported content: {:?}", chunk).into(),
+ false,
+ cx,
+ )
+ })
+ .log_err();
+ }
+ }
+ }
+ }
+ SdkMessage::Result {
+ is_error,
+ subtype,
+ result,
+ ..
+ } => {
+ let turn_state = turn_state.take();
+ let was_canceled = turn_state.is_canceled();
+ let Some(end_turn_tx) = turn_state.end_tx() else {
+ debug_panic!("Received `SdkMessage::Result` but there wasn't an active turn");
+ return;
+ };
+
+ if is_error || (!was_canceled && subtype == ResultErrorType::ErrorDuringExecution) {
+ end_turn_tx
+ .send(Err(anyhow!(
+ "Error: {}",
+ result.unwrap_or_else(|| subtype.to_string())
+ )))
+ .ok();
+ } else {
+ let stop_reason = match subtype {
+ ResultErrorType::Success => acp::StopReason::EndTurn,
+ ResultErrorType::ErrorMaxTurns => acp::StopReason::MaxTurnRequests,
+ ResultErrorType::ErrorDuringExecution => acp::StopReason::Cancelled,
+ };
+ end_turn_tx
+ .send(Ok(acp::PromptResponse { stop_reason }))
+ .ok();
+ }
+ }
+ SdkMessage::ControlResponse { response } => {
+ if matches!(response.subtype, ResultErrorType::Success) {
+ let new_state = turn_state.take().confirm_cancellation(&response.request_id);
+ turn_state.replace(new_state);
+ } else {
+ log::error!("Control response error: {:?}", response);
+ }
+ }
+ SdkMessage::System { .. } => {}
+ }
+ }
+
+ async fn handle_io(
+ mut outgoing_rx: UnboundedReceiver<SdkMessage>,
+ incoming_tx: UnboundedSender<SdkMessage>,
+ mut outgoing_bytes: impl Unpin + AsyncWrite,
+ incoming_bytes: impl Unpin + AsyncRead,
+ ) -> Result<UnboundedReceiver<SdkMessage>> {
+ let mut output_reader = BufReader::new(incoming_bytes);
+ let mut outgoing_line = Vec::new();
+ let mut incoming_line = String::new();
+ loop {
+ select_biased! {
+ message = outgoing_rx.next() => {
+ if let Some(message) = message {
+ outgoing_line.clear();
+ serde_json::to_writer(&mut outgoing_line, &message)?;
+ log::trace!("send: {}", String::from_utf8_lossy(&outgoing_line));
+ outgoing_line.push(b'\n');
+ outgoing_bytes.write_all(&outgoing_line).await.ok();
+ } else {
+ break;
+ }
+ }
+ bytes_read = output_reader.read_line(&mut incoming_line).fuse() => {
+ if bytes_read? == 0 {
+ break
+ }
+ log::trace!("recv: {}", &incoming_line);
+ match serde_json::from_str::<SdkMessage>(&incoming_line) {
+ Ok(message) => {
+ incoming_tx.unbounded_send(message).log_err();
+ }
+ Err(error) => {
+ log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}");
+ }
+ }
+ incoming_line.clear();
+ }
+ }
+ }
+
+ Ok(outgoing_rx)
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct Message {
+ role: Role,
+ content: Content,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ model: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ stop_reason: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ stop_sequence: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ usage: Option<Usage>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(untagged)]
+enum Content {
+ UntaggedText(String),
+ Chunks(Vec<ContentChunk>),
+}
+
+impl Content {
+ pub fn chunks(self) -> impl Iterator<Item = ContentChunk> {
+ match self {
+ Self::Chunks(chunks) => chunks.into_iter(),
+ Self::UntaggedText(text) => vec![ContentChunk::Text { text }].into_iter(),
+ }
+ }
+}
+
+impl Display for Content {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Content::UntaggedText(txt) => write!(f, "{}", txt),
+ Content::Chunks(chunks) => {
+ for chunk in chunks {
+ write!(f, "{}", chunk)?;
+ }
+ Ok(())
+ }
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+enum ContentChunk {
+ Text {
+ text: String,
+ },
+ ToolUse {
+ id: String,
+ name: String,
+ input: serde_json::Value,
+ },
+ ToolResult {
+ content: Content,
+ tool_use_id: String,
+ },
+ Thinking {
+ thinking: String,
+ },
+ RedactedThinking,
+ Image {
+ source: ImageSource,
+ },
+ // TODO
+ Document,
+ WebSearchToolResult,
+ #[serde(untagged)]
+ UntaggedText(String),
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+enum ImageSource {
+ Base64 { data: String, media_type: String },
+ Url { url: String },
+}
+
+impl Into<acp::ContentBlock> for ImageSource {
+ fn into(self) -> acp::ContentBlock {
+ match self {
+ ImageSource::Base64 { data, media_type } => {
+ acp::ContentBlock::Image(acp::ImageContent {
+ annotations: None,
+ data,
+ mime_type: media_type,
+ uri: None,
+ })
+ }
+ ImageSource::Url { url } => acp::ContentBlock::Image(acp::ImageContent {
+ annotations: None,
+ data: "".to_string(),
+ mime_type: "".to_string(),
+ uri: Some(url),
+ }),
+ }
+ }
+}
+
+impl Display for ContentChunk {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ContentChunk::Text { text } => write!(f, "{}", text),
+ ContentChunk::Thinking { thinking } => write!(f, "Thinking: {}", thinking),
+ ContentChunk::RedactedThinking => write!(f, "Thinking: [REDACTED]"),
+ ContentChunk::UntaggedText(text) => write!(f, "{}", text),
+ ContentChunk::ToolResult { content, .. } => write!(f, "{}", content),
+ ContentChunk::Image { .. }
+ | ContentChunk::Document
+ | ContentChunk::ToolUse { .. }
+ | ContentChunk::WebSearchToolResult => {
+ write!(f, "\n{:?}\n", &self)
+ }
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct Usage {
+ input_tokens: u32,
+ cache_creation_input_tokens: u32,
+ cache_read_input_tokens: u32,
+ output_tokens: u32,
+ service_tier: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+enum Role {
+ System,
+ Assistant,
+ User,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct MessageParam {
+ role: Role,
+ content: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+enum SdkMessage {
+ // An assistant message
+ Assistant {
+ message: Message, // from Anthropic SDK
+ #[serde(skip_serializing_if = "Option::is_none")]
+ session_id: Option<String>,
+ },
+ // A user message
+ User {
+ message: Message, // from Anthropic SDK
+ #[serde(skip_serializing_if = "Option::is_none")]
+ session_id: Option<String>,
+ },
+ // Emitted as the last message in a conversation
+ Result {
+ subtype: ResultErrorType,
+ duration_ms: f64,
+ duration_api_ms: f64,
+ is_error: bool,
+ num_turns: i32,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ result: Option<String>,
+ session_id: String,
+ total_cost_usd: f64,
+ },
+ // Emitted as the first message at the start of a conversation
+ System {
+ cwd: String,
+ session_id: String,
+ tools: Vec<String>,
+ model: String,
+ mcp_servers: Vec<McpServer>,
+ #[serde(rename = "apiKeySource")]
+ api_key_source: String,
+ #[serde(rename = "permissionMode")]
+ permission_mode: PermissionMode,
+ },
+ /// Messages used to control the conversation, outside of chat messages to the model
+ ControlRequest {
+ request_id: String,
+ request: ControlRequest,
+ },
+ /// Response to a control request
+ ControlResponse { response: ControlResponse },
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "subtype", rename_all = "snake_case")]
+enum ControlRequest {
+ /// Cancel the current conversation
+ Interrupt,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct ControlResponse {
+ request_id: String,
+ subtype: ResultErrorType,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
+#[serde(rename_all = "snake_case")]
+enum ResultErrorType {
+ Success,
+ ErrorMaxTurns,
+ ErrorDuringExecution,
+}
+
+impl Display for ResultErrorType {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ResultErrorType::Success => write!(f, "success"),
+ ResultErrorType::ErrorMaxTurns => write!(f, "error_max_turns"),
+ ResultErrorType::ErrorDuringExecution => write!(f, "error_during_execution"),
+ }
+ }
+}
+
+fn acp_content_to_claude(prompt: Vec<acp::ContentBlock>) -> Vec<ContentChunk> {
+ let mut content = Vec::with_capacity(prompt.len());
+ let mut context = Vec::with_capacity(prompt.len());
+
+ for chunk in prompt {
+ match chunk {
+ acp::ContentBlock::Text(text_content) => {
+ content.push(ContentChunk::Text {
+ text: text_content.text,
+ });
+ }
+ acp::ContentBlock::ResourceLink(resource_link) => {
+ match MentionUri::parse(&resource_link.uri) {
+ Ok(uri) => {
+ content.push(ContentChunk::Text {
+ text: format!("{}", uri.as_link()),
+ });
+ }
+ Err(_) => {
+ content.push(ContentChunk::Text {
+ text: resource_link.uri,
+ });
+ }
+ }
+ }
+ acp::ContentBlock::Resource(resource) => match resource.resource {
+ acp::EmbeddedResourceResource::TextResourceContents(resource) => {
+ match MentionUri::parse(&resource.uri) {
+ Ok(uri) => {
+ content.push(ContentChunk::Text {
+ text: format!("{}", uri.as_link()),
+ });
+ }
+ Err(_) => {
+ content.push(ContentChunk::Text {
+ text: resource.uri.clone(),
+ });
+ }
+ }
+
+ context.push(ContentChunk::Text {
+ text: format!(
+ "\n<context ref=\"{}\">\n{}\n</context>",
+ resource.uri, resource.text
+ ),
+ });
+ }
+ acp::EmbeddedResourceResource::BlobResourceContents(_) => {
+ // Unsupported by SDK
+ }
+ },
+ acp::ContentBlock::Image(acp::ImageContent {
+ data, mime_type, ..
+ }) => content.push(ContentChunk::Image {
+ source: ImageSource::Base64 {
+ data,
+ media_type: mime_type,
+ },
+ }),
+ acp::ContentBlock::Audio(_) => {
+ // Unsupported by SDK
+ }
+ }
+ }
+
+ content.extend(context);
+ content
+}
+
+fn new_request_id() -> String {
+ use rand::Rng;
+ // In the Claude Code TS SDK they just generate a random 12 character string,
+ // `Math.random().toString(36).substring(2, 15)`
+ rand::thread_rng()
+ .sample_iter(&rand::distributions::Alphanumeric)
+ .take(12)
+ .map(char::from)
+ .collect()
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct McpServer {
+ name: String,
+ status: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum PermissionMode {
+ Default,
+ AcceptEdits,
+ BypassPermissions,
+ Plan,
+}
+
+#[cfg(test)]
+pub(crate) mod tests {
+ use super::*;
+ use crate::e2e_tests;
+ use gpui::TestAppContext;
+ use serde_json::json;
+
+ crate::common_e2e_tests!(async |_, _, _| ClaudeCode, allow_option_id = "allow");
+
+ pub fn local_command() -> AgentServerCommand {
+ AgentServerCommand {
+ path: "claude".into(),
+ args: vec![],
+ env: None,
+ }
+ }
+
+ #[gpui::test]
+ #[cfg_attr(not(feature = "e2e"), ignore)]
+ async fn test_todo_plan(cx: &mut TestAppContext) {
+ let fs = e2e_tests::init_test(cx).await;
+ let project = Project::test(fs, [], cx).await;
+ let thread =
+ e2e_tests::new_test_thread(ClaudeCode, project.clone(), "/private/tmp", cx).await;
+
+ thread
+ .update(cx, |thread, cx| {
+ thread.send_raw(
+ "Create a todo plan for initializing a new React app. I'll follow it myself, do not execute on it.",
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ let mut entries_len = 0;
+
+ thread.read_with(cx, |thread, _| {
+ entries_len = thread.plan().entries.len();
+ assert!(!thread.plan().entries.is_empty(), "Empty plan");
+ });
+
+ thread
+ .update(cx, |thread, cx| {
+ thread.send_raw(
+ "Mark the first entry status as in progress without acting on it.",
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ thread.read_with(cx, |thread, _| {
+ assert!(matches!(
+ thread.plan().entries[0].status,
+ acp::PlanEntryStatus::InProgress
+ ));
+ assert_eq!(thread.plan().entries.len(), entries_len);
+ });
+
+ thread
+ .update(cx, |thread, cx| {
+ thread.send_raw(
+ "Now mark the first entry as completed without acting on it.",
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ thread.read_with(cx, |thread, _| {
+ assert!(matches!(
+ thread.plan().entries[0].status,
+ acp::PlanEntryStatus::Completed
+ ));
+ assert_eq!(thread.plan().entries.len(), entries_len);
+ });
+ }
+
+ #[test]
+ fn test_deserialize_content_untagged_text() {
+ let json = json!("Hello, world!");
+ let content: Content = serde_json::from_value(json).unwrap();
+ match content {
+ Content::UntaggedText(text) => assert_eq!(text, "Hello, world!"),
+ _ => panic!("Expected UntaggedText variant"),
+ }
+ }
+
+ #[test]
+ fn test_deserialize_content_chunks() {
+ let json = json!([
+ {
+ "type": "text",
+ "text": "Hello"
+ },
+ {
+ "type": "tool_use",
+ "id": "tool_123",
+ "name": "calculator",
+ "input": {"operation": "add", "a": 1, "b": 2}
+ }
+ ]);
+ let content: Content = serde_json::from_value(json).unwrap();
+ match content {
+ Content::Chunks(chunks) => {
+ assert_eq!(chunks.len(), 2);
+ match &chunks[0] {
+ ContentChunk::Text { text } => assert_eq!(text, "Hello"),
+ _ => panic!("Expected Text chunk"),
+ }
+ match &chunks[1] {
+ ContentChunk::ToolUse { id, name, input } => {
+ assert_eq!(id, "tool_123");
+ assert_eq!(name, "calculator");
+ assert_eq!(input["operation"], "add");
+ assert_eq!(input["a"], 1);
+ assert_eq!(input["b"], 2);
+ }
+ _ => panic!("Expected ToolUse chunk"),
+ }
+ }
+ _ => panic!("Expected Chunks variant"),
+ }
+ }
+
+ #[test]
+ fn test_deserialize_tool_result_untagged_text() {
+ let json = json!({
+ "type": "tool_result",
+ "content": "Result content",
+ "tool_use_id": "tool_456"
+ });
+ let chunk: ContentChunk = serde_json::from_value(json).unwrap();
+ match chunk {
+ ContentChunk::ToolResult {
+ content,
+ tool_use_id,
+ } => {
+ match content {
+ Content::UntaggedText(text) => assert_eq!(text, "Result content"),
+ _ => panic!("Expected UntaggedText content"),
+ }
+ assert_eq!(tool_use_id, "tool_456");
+ }
+ _ => panic!("Expected ToolResult variant"),
+ }
+ }
+
+ #[test]
+ fn test_deserialize_tool_result_chunks() {
+ let json = json!({
+ "type": "tool_result",
+ "content": [
+ {
+ "type": "text",
+ "text": "Processing complete"
+ },
+ {
+ "type": "text",
+ "text": "Result: 42"
+ }
+ ],
+ "tool_use_id": "tool_789"
+ });
+ let chunk: ContentChunk = serde_json::from_value(json).unwrap();
+ match chunk {
+ ContentChunk::ToolResult {
+ content,
+ tool_use_id,
+ } => {
+ match content {
+ Content::Chunks(chunks) => {
+ assert_eq!(chunks.len(), 2);
+ match &chunks[0] {
+ ContentChunk::Text { text } => assert_eq!(text, "Processing complete"),
+ _ => panic!("Expected Text chunk"),
+ }
+ match &chunks[1] {
+ ContentChunk::Text { text } => assert_eq!(text, "Result: 42"),
+ _ => panic!("Expected Text chunk"),
+ }
+ }
+ _ => panic!("Expected Chunks content"),
+ }
+ assert_eq!(tool_use_id, "tool_789");
+ }
+ _ => panic!("Expected ToolResult variant"),
+ }
+ }
+
+ #[test]
+ fn test_acp_content_to_claude() {
+ let acp_content = vec![
+ acp::ContentBlock::Text(acp::TextContent {
+ text: "Hello world".to_string(),
+ annotations: None,
+ }),
+ acp::ContentBlock::Image(acp::ImageContent {
+ data: "base64data".to_string(),
+ mime_type: "image/png".to_string(),
+ annotations: None,
+ uri: None,
+ }),
+ acp::ContentBlock::ResourceLink(acp::ResourceLink {
+ uri: "file:///path/to/example.rs".to_string(),
+ name: "example.rs".to_string(),
+ annotations: None,
+ description: None,
+ mime_type: None,
+ size: None,
+ title: None,
+ }),
+ acp::ContentBlock::Resource(acp::EmbeddedResource {
+ annotations: None,
+ resource: acp::EmbeddedResourceResource::TextResourceContents(
+ acp::TextResourceContents {
+ mime_type: None,
+ text: "fn main() { println!(\"Hello!\"); }".to_string(),
+ uri: "file:///path/to/code.rs".to_string(),
+ },
+ ),
+ }),
+ acp::ContentBlock::ResourceLink(acp::ResourceLink {
+ uri: "invalid_uri_format".to_string(),
+ name: "invalid.txt".to_string(),
+ annotations: None,
+ description: None,
+ mime_type: None,
+ size: None,
+ title: None,
+ }),
+ ];
+
+ let claude_content = acp_content_to_claude(acp_content);
+
+ assert_eq!(claude_content.len(), 6);
+
+ match &claude_content[0] {
+ ContentChunk::Text { text } => assert_eq!(text, "Hello world"),
+ _ => panic!("Expected Text chunk"),
+ }
+
+ match &claude_content[1] {
+ ContentChunk::Image { source } => match source {
+ ImageSource::Base64 { data, media_type } => {
+ assert_eq!(data, "base64data");
+ assert_eq!(media_type, "image/png");
+ }
+ _ => panic!("Expected Base64 image source"),
+ },
+ _ => panic!("Expected Image chunk"),
+ }
+
+ match &claude_content[2] {
+ ContentChunk::Text { text } => {
+ assert!(text.contains("example.rs"));
+ assert!(text.contains("file:///path/to/example.rs"));
+ }
+ _ => panic!("Expected Text chunk for ResourceLink"),
+ }
+
+ match &claude_content[3] {
+ ContentChunk::Text { text } => {
+ assert!(text.contains("code.rs"));
+ assert!(text.contains("file:///path/to/code.rs"));
+ }
+ _ => panic!("Expected Text chunk for Resource"),
+ }
+
+ match &claude_content[4] {
+ ContentChunk::Text { text } => {
+ assert_eq!(text, "invalid_uri_format");
+ }
+ _ => panic!("Expected Text chunk for invalid URI"),
+ }
+
+ match &claude_content[5] {
+ ContentChunk::Text { text } => {
+ assert!(text.contains("<context ref=\"file:///path/to/code.rs\">"));
+ assert!(text.contains("fn main() { println!(\"Hello!\"); }"));
+ assert!(text.contains("</context>"));
+ }
+ _ => panic!("Expected Text chunk for context"),
+ }
+ }
+}
@@ -0,0 +1,178 @@
+use acp_thread::AcpThread;
+use anyhow::Result;
+use context_server::{
+ listener::{McpServerTool, ToolResponse},
+ types::{ToolAnnotations, ToolResponseContent},
+};
+use gpui::{AsyncApp, WeakEntity};
+use language::unified_diff;
+use util::markdown::MarkdownCodeBlock;
+
+use crate::tools::EditToolParams;
+
+#[derive(Clone)]
+pub struct EditTool {
+ thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
+}
+
+impl EditTool {
+ pub fn new(thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
+ Self { thread_rx }
+ }
+}
+
+impl McpServerTool for EditTool {
+ type Input = EditToolParams;
+ type Output = ();
+
+ const NAME: &'static str = "Edit";
+
+ fn annotations(&self) -> ToolAnnotations {
+ ToolAnnotations {
+ title: Some("Edit file".to_string()),
+ read_only_hint: Some(false),
+ destructive_hint: Some(false),
+ open_world_hint: Some(false),
+ idempotent_hint: Some(false),
+ }
+ }
+
+ async fn run(
+ &self,
+ input: Self::Input,
+ cx: &mut AsyncApp,
+ ) -> Result<ToolResponse<Self::Output>> {
+ let mut thread_rx = self.thread_rx.clone();
+ let Some(thread) = thread_rx.recv().await?.upgrade() else {
+ anyhow::bail!("Thread closed");
+ };
+
+ let content = thread
+ .update(cx, |thread, cx| {
+ thread.read_text_file(input.abs_path.clone(), None, None, true, cx)
+ })?
+ .await?;
+
+ let (new_content, diff) = cx
+ .background_executor()
+ .spawn(async move {
+ let new_content = content.replace(&input.old_text, &input.new_text);
+ if new_content == content {
+ return Err(anyhow::anyhow!("Failed to find `old_text`",));
+ }
+ let diff = unified_diff(&content, &new_content);
+
+ Ok((new_content, diff))
+ })
+ .await?;
+
+ thread
+ .update(cx, |thread, cx| {
+ thread.write_text_file(input.abs_path, new_content, cx)
+ })?
+ .await?;
+
+ Ok(ToolResponse {
+ content: vec![ToolResponseContent::Text {
+ text: MarkdownCodeBlock {
+ tag: "diff",
+ text: diff.as_str().trim_end_matches('\n'),
+ }
+ .to_string(),
+ }],
+ structured_content: (),
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::rc::Rc;
+
+ use acp_thread::{AgentConnection, StubAgentConnection};
+ use gpui::{Entity, TestAppContext};
+ use indoc::indoc;
+ use project::{FakeFs, Project};
+ use serde_json::json;
+ use settings::SettingsStore;
+ use util::path;
+
+ use super::*;
+
+ #[gpui::test]
+ async fn old_text_not_found(cx: &mut TestAppContext) {
+ let (_thread, tool) = init_test(cx).await;
+
+ let result = tool
+ .run(
+ EditToolParams {
+ abs_path: path!("/root/file.txt").into(),
+ old_text: "hi".into(),
+ new_text: "bye".into(),
+ },
+ &mut cx.to_async(),
+ )
+ .await;
+
+ assert_eq!(result.unwrap_err().to_string(), "Failed to find `old_text`");
+ }
+
+ #[gpui::test]
+ async fn found_and_replaced(cx: &mut TestAppContext) {
+ let (_thread, tool) = init_test(cx).await;
+
+ let result = tool
+ .run(
+ EditToolParams {
+ abs_path: path!("/root/file.txt").into(),
+ old_text: "hello".into(),
+ new_text: "hi".into(),
+ },
+ &mut cx.to_async(),
+ )
+ .await;
+
+ assert_eq!(
+ result.unwrap().content[0].text().unwrap(),
+ indoc! {
+ r"
+ ```diff
+ @@ -1,1 +1,1 @@
+ -hello
+ +hi
+ ```
+ "
+ }
+ );
+ }
+
+ async fn init_test(cx: &mut TestAppContext) -> (Entity<AcpThread>, EditTool) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ language::init(cx);
+ Project::init_settings(cx);
+ });
+
+ let connection = Rc::new(StubAgentConnection::new());
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ json!({
+ "file.txt": "hello"
+ }),
+ )
+ .await;
+ let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+ let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid());
+
+ let thread = cx
+ .update(|cx| connection.new_thread(project, path!("/test").as_ref(), cx))
+ .await
+ .unwrap();
+
+ thread_tx.send(thread.downgrade()).unwrap();
+
+ (thread, EditTool::new(thread_rx))
+ }
+}
@@ -0,0 +1,99 @@
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use crate::claude::edit_tool::EditTool;
+use crate::claude::permission_tool::PermissionTool;
+use crate::claude::read_tool::ReadTool;
+use crate::claude::write_tool::WriteTool;
+use acp_thread::AcpThread;
+#[cfg(not(test))]
+use anyhow::Context as _;
+use anyhow::Result;
+use collections::HashMap;
+use context_server::types::{
+ Implementation, InitializeParams, InitializeResponse, ProtocolVersion, ServerCapabilities,
+ ToolsCapabilities, requests,
+};
+use gpui::{App, AsyncApp, Task, WeakEntity};
+use project::Fs;
+use serde::Serialize;
+
+pub struct ClaudeZedMcpServer {
+ server: context_server::listener::McpServer,
+}
+
+pub const SERVER_NAME: &str = "zed";
+
+impl ClaudeZedMcpServer {
+ pub async fn new(
+ thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
+ fs: Arc<dyn Fs>,
+ cx: &AsyncApp,
+ ) -> Result<Self> {
+ let mut mcp_server = context_server::listener::McpServer::new(cx).await?;
+ mcp_server.handle_request::<requests::Initialize>(Self::handle_initialize);
+
+ mcp_server.add_tool(PermissionTool::new(fs.clone(), thread_rx.clone()));
+ mcp_server.add_tool(ReadTool::new(thread_rx.clone()));
+ mcp_server.add_tool(EditTool::new(thread_rx.clone()));
+ mcp_server.add_tool(WriteTool::new(thread_rx.clone()));
+
+ Ok(Self { server: mcp_server })
+ }
+
+ pub fn server_config(&self) -> Result<McpServerConfig> {
+ #[cfg(not(test))]
+ let zed_path = std::env::current_exe()
+ .context("finding current executable path for use in mcp_server")?;
+
+ #[cfg(test)]
+ let zed_path = crate::e2e_tests::get_zed_path();
+
+ Ok(McpServerConfig {
+ command: zed_path,
+ args: vec![
+ "--nc".into(),
+ self.server.socket_path().display().to_string(),
+ ],
+ env: None,
+ })
+ }
+
+ fn handle_initialize(_: InitializeParams, cx: &App) -> Task<Result<InitializeResponse>> {
+ cx.foreground_executor().spawn(async move {
+ Ok(InitializeResponse {
+ protocol_version: ProtocolVersion("2025-06-18".into()),
+ capabilities: ServerCapabilities {
+ experimental: None,
+ logging: None,
+ completions: None,
+ prompts: None,
+ resources: None,
+ tools: Some(ToolsCapabilities {
+ list_changed: Some(false),
+ }),
+ },
+ server_info: Implementation {
+ name: SERVER_NAME.into(),
+ version: "0.1.0".into(),
+ },
+ meta: None,
+ })
+ })
+ }
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct McpConfig {
+ pub mcp_servers: HashMap<String, McpServerConfig>,
+}
+
+#[derive(Serialize, Clone)]
+#[serde(rename_all = "camelCase")]
+pub struct McpServerConfig {
+ pub command: PathBuf,
+ pub args: Vec<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub env: Option<HashMap<String, String>>,
+}
@@ -0,0 +1,158 @@
+use std::sync::Arc;
+
+use acp_thread::AcpThread;
+use agent_client_protocol as acp;
+use agent_settings::AgentSettings;
+use anyhow::{Context as _, Result};
+use context_server::{
+ listener::{McpServerTool, ToolResponse},
+ types::ToolResponseContent,
+};
+use gpui::{AsyncApp, WeakEntity};
+use project::Fs;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::{Settings as _, update_settings_file};
+use util::debug_panic;
+
+use crate::tools::ClaudeTool;
+
+#[derive(Clone)]
+pub struct PermissionTool {
+ fs: Arc<dyn Fs>,
+ thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
+}
+
+/// Request permission for tool calls
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct PermissionToolParams {
+ tool_name: String,
+ input: serde_json::Value,
+ tool_use_id: Option<String>,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PermissionToolResponse {
+ behavior: PermissionToolBehavior,
+ updated_input: serde_json::Value,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "snake_case")]
+enum PermissionToolBehavior {
+ Allow,
+ Deny,
+}
+
+impl PermissionTool {
+ pub fn new(fs: Arc<dyn Fs>, thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
+ Self { fs, thread_rx }
+ }
+}
+
+impl McpServerTool for PermissionTool {
+ type Input = PermissionToolParams;
+ type Output = ();
+
+ const NAME: &'static str = "Confirmation";
+
+ async fn run(
+ &self,
+ input: Self::Input,
+ cx: &mut AsyncApp,
+ ) -> Result<ToolResponse<Self::Output>> {
+ if agent_settings::AgentSettings::try_read_global(cx, |settings| {
+ settings.always_allow_tool_actions
+ })
+ .unwrap_or(false)
+ {
+ let response = PermissionToolResponse {
+ behavior: PermissionToolBehavior::Allow,
+ updated_input: input.input,
+ };
+
+ return Ok(ToolResponse {
+ content: vec![ToolResponseContent::Text {
+ text: serde_json::to_string(&response)?,
+ }],
+ structured_content: (),
+ });
+ }
+
+ let mut thread_rx = self.thread_rx.clone();
+ let Some(thread) = thread_rx.recv().await?.upgrade() else {
+ anyhow::bail!("Thread closed");
+ };
+
+ let claude_tool = ClaudeTool::infer(&input.tool_name, input.input.clone());
+ let tool_call_id = acp::ToolCallId(input.tool_use_id.context("Tool ID required")?.into());
+
+ const ALWAYS_ALLOW: &str = "always_allow";
+ const ALLOW: &str = "allow";
+ const REJECT: &str = "reject";
+
+ let chosen_option = thread
+ .update(cx, |thread, cx| {
+ thread.request_tool_call_authorization(
+ claude_tool.as_acp(tool_call_id).into(),
+ vec![
+ acp::PermissionOption {
+ id: acp::PermissionOptionId(ALWAYS_ALLOW.into()),
+ name: "Always Allow".into(),
+ kind: acp::PermissionOptionKind::AllowAlways,
+ },
+ acp::PermissionOption {
+ id: acp::PermissionOptionId(ALLOW.into()),
+ name: "Allow".into(),
+ kind: acp::PermissionOptionKind::AllowOnce,
+ },
+ acp::PermissionOption {
+ id: acp::PermissionOptionId(REJECT.into()),
+ name: "Reject".into(),
+ kind: acp::PermissionOptionKind::RejectOnce,
+ },
+ ],
+ cx,
+ )
+ })??
+ .await?;
+
+ let response = match chosen_option.0.as_ref() {
+ ALWAYS_ALLOW => {
+ cx.update(|cx| {
+ update_settings_file::<AgentSettings>(self.fs.clone(), cx, |settings, _| {
+ settings.set_always_allow_tool_actions(true);
+ });
+ })?;
+
+ PermissionToolResponse {
+ behavior: PermissionToolBehavior::Allow,
+ updated_input: input.input,
+ }
+ }
+ ALLOW => PermissionToolResponse {
+ behavior: PermissionToolBehavior::Allow,
+ updated_input: input.input,
+ },
+ REJECT => PermissionToolResponse {
+ behavior: PermissionToolBehavior::Deny,
+ updated_input: input.input,
+ },
+ opt => {
+ debug_panic!("Unexpected option: {}", opt);
+ PermissionToolResponse {
+ behavior: PermissionToolBehavior::Deny,
+ updated_input: input.input,
+ }
+ }
+ };
+
+ Ok(ToolResponse {
+ content: vec![ToolResponseContent::Text {
+ text: serde_json::to_string(&response)?,
+ }],
+ structured_content: (),
+ })
+ }
+}
@@ -0,0 +1,59 @@
+use acp_thread::AcpThread;
+use anyhow::Result;
+use context_server::{
+ listener::{McpServerTool, ToolResponse},
+ types::{ToolAnnotations, ToolResponseContent},
+};
+use gpui::{AsyncApp, WeakEntity};
+
+use crate::tools::ReadToolParams;
+
+#[derive(Clone)]
+pub struct ReadTool {
+ thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
+}
+
+impl ReadTool {
+ pub fn new(thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
+ Self { thread_rx }
+ }
+}
+
+impl McpServerTool for ReadTool {
+ type Input = ReadToolParams;
+ type Output = ();
+
+ const NAME: &'static str = "Read";
+
+ fn annotations(&self) -> ToolAnnotations {
+ ToolAnnotations {
+ title: Some("Read file".to_string()),
+ read_only_hint: Some(true),
+ destructive_hint: Some(false),
+ open_world_hint: Some(false),
+ idempotent_hint: None,
+ }
+ }
+
+ async fn run(
+ &self,
+ input: Self::Input,
+ cx: &mut AsyncApp,
+ ) -> Result<ToolResponse<Self::Output>> {
+ let mut thread_rx = self.thread_rx.clone();
+ let Some(thread) = thread_rx.recv().await?.upgrade() else {
+ anyhow::bail!("Thread closed");
+ };
+
+ let content = thread
+ .update(cx, |thread, cx| {
+ thread.read_text_file(input.abs_path, input.offset, input.limit, false, cx)
+ })?
+ .await?;
+
+ Ok(ToolResponse {
+ content: vec![ToolResponseContent::Text { text: content }],
+ structured_content: (),
+ })
+ }
+}
@@ -0,0 +1,688 @@
+use std::path::PathBuf;
+
+use agent_client_protocol as acp;
+use itertools::Itertools;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use util::ResultExt;
+
+pub enum ClaudeTool {
+ Task(Option<TaskToolParams>),
+ NotebookRead(Option<NotebookReadToolParams>),
+ NotebookEdit(Option<NotebookEditToolParams>),
+ Edit(Option<EditToolParams>),
+ MultiEdit(Option<MultiEditToolParams>),
+ ReadFile(Option<ReadToolParams>),
+ Write(Option<WriteToolParams>),
+ Ls(Option<LsToolParams>),
+ Glob(Option<GlobToolParams>),
+ Grep(Option<GrepToolParams>),
+ Terminal(Option<BashToolParams>),
+ WebFetch(Option<WebFetchToolParams>),
+ WebSearch(Option<WebSearchToolParams>),
+ TodoWrite(Option<TodoWriteToolParams>),
+ ExitPlanMode(Option<ExitPlanModeToolParams>),
+ Other {
+ name: String,
+ input: serde_json::Value,
+ },
+}
+
+impl ClaudeTool {
+ pub fn infer(tool_name: &str, input: serde_json::Value) -> Self {
+ match tool_name {
+ // Known tools
+ "mcp__zed__Read" => Self::ReadFile(serde_json::from_value(input).log_err()),
+ "mcp__zed__Edit" => Self::Edit(serde_json::from_value(input).log_err()),
+ "mcp__zed__Write" => Self::Write(serde_json::from_value(input).log_err()),
+ "MultiEdit" => Self::MultiEdit(serde_json::from_value(input).log_err()),
+ "Write" => Self::Write(serde_json::from_value(input).log_err()),
+ "LS" => Self::Ls(serde_json::from_value(input).log_err()),
+ "Glob" => Self::Glob(serde_json::from_value(input).log_err()),
+ "Grep" => Self::Grep(serde_json::from_value(input).log_err()),
+ "Bash" => Self::Terminal(serde_json::from_value(input).log_err()),
+ "WebFetch" => Self::WebFetch(serde_json::from_value(input).log_err()),
+ "WebSearch" => Self::WebSearch(serde_json::from_value(input).log_err()),
+ "TodoWrite" => Self::TodoWrite(serde_json::from_value(input).log_err()),
+ "exit_plan_mode" => Self::ExitPlanMode(serde_json::from_value(input).log_err()),
+ "Task" => Self::Task(serde_json::from_value(input).log_err()),
+ "NotebookRead" => Self::NotebookRead(serde_json::from_value(input).log_err()),
+ "NotebookEdit" => Self::NotebookEdit(serde_json::from_value(input).log_err()),
+ // Inferred from name
+ _ => {
+ let tool_name = tool_name.to_lowercase();
+
+ if tool_name.contains("edit") || tool_name.contains("write") {
+ Self::Edit(None)
+ } else if tool_name.contains("terminal") {
+ Self::Terminal(None)
+ } else {
+ Self::Other {
+ name: tool_name,
+ input,
+ }
+ }
+ }
+ }
+ }
+
+ pub fn label(&self) -> String {
+ match &self {
+ Self::Task(Some(params)) => params.description.clone(),
+ Self::Task(None) => "Task".into(),
+ Self::NotebookRead(Some(params)) => {
+ format!("Read Notebook {}", params.notebook_path.display())
+ }
+ Self::NotebookRead(None) => "Read Notebook".into(),
+ Self::NotebookEdit(Some(params)) => {
+ format!("Edit Notebook {}", params.notebook_path.display())
+ }
+ Self::NotebookEdit(None) => "Edit Notebook".into(),
+ Self::Terminal(Some(params)) => format!("`{}`", params.command),
+ Self::Terminal(None) => "Terminal".into(),
+ Self::ReadFile(_) => "Read File".into(),
+ Self::Ls(Some(params)) => {
+ format!("List Directory {}", params.path.display())
+ }
+ Self::Ls(None) => "List Directory".into(),
+ Self::Edit(Some(params)) => {
+ format!("Edit {}", params.abs_path.display())
+ }
+ Self::Edit(None) => "Edit".into(),
+ Self::MultiEdit(Some(params)) => {
+ format!("Multi Edit {}", params.file_path.display())
+ }
+ Self::MultiEdit(None) => "Multi Edit".into(),
+ Self::Write(Some(params)) => {
+ format!("Write {}", params.abs_path.display())
+ }
+ Self::Write(None) => "Write".into(),
+ Self::Glob(Some(params)) => {
+ format!("Glob `{params}`")
+ }
+ Self::Glob(None) => "Glob".into(),
+ Self::Grep(Some(params)) => format!("`{params}`"),
+ Self::Grep(None) => "Grep".into(),
+ Self::WebFetch(Some(params)) => format!("Fetch {}", params.url),
+ Self::WebFetch(None) => "Fetch".into(),
+ Self::WebSearch(Some(params)) => format!("Web Search: {}", params),
+ Self::WebSearch(None) => "Web Search".into(),
+ Self::TodoWrite(Some(params)) => format!(
+ "Update TODOs: {}",
+ params.todos.iter().map(|todo| &todo.content).join(", ")
+ ),
+ Self::TodoWrite(None) => "Update TODOs".into(),
+ Self::ExitPlanMode(_) => "Exit Plan Mode".into(),
+ Self::Other { name, .. } => name.clone(),
+ }
+ }
+ pub fn content(&self) -> Vec<acp::ToolCallContent> {
+ match &self {
+ Self::Other { input, .. } => vec![
+ format!(
+ "```json\n{}```",
+ serde_json::to_string_pretty(&input).unwrap_or("{}".to_string())
+ )
+ .into(),
+ ],
+ Self::Task(Some(params)) => vec![params.prompt.clone().into()],
+ Self::NotebookRead(Some(params)) => {
+ vec![params.notebook_path.display().to_string().into()]
+ }
+ Self::NotebookEdit(Some(params)) => vec![params.new_source.clone().into()],
+ Self::Terminal(Some(params)) => vec![
+ format!(
+ "`{}`\n\n{}",
+ params.command,
+ params.description.as_deref().unwrap_or_default()
+ )
+ .into(),
+ ],
+ Self::ReadFile(Some(params)) => vec![params.abs_path.display().to_string().into()],
+ Self::Ls(Some(params)) => vec![params.path.display().to_string().into()],
+ Self::Glob(Some(params)) => vec![params.to_string().into()],
+ Self::Grep(Some(params)) => vec![format!("`{params}`").into()],
+ Self::WebFetch(Some(params)) => vec![params.prompt.clone().into()],
+ Self::WebSearch(Some(params)) => vec![params.to_string().into()],
+ Self::ExitPlanMode(Some(params)) => vec![params.plan.clone().into()],
+ Self::Edit(Some(params)) => vec![acp::ToolCallContent::Diff {
+ diff: acp::Diff {
+ path: params.abs_path.clone(),
+ old_text: Some(params.old_text.clone()),
+ new_text: params.new_text.clone(),
+ },
+ }],
+ Self::Write(Some(params)) => vec![acp::ToolCallContent::Diff {
+ diff: acp::Diff {
+ path: params.abs_path.clone(),
+ old_text: None,
+ new_text: params.content.clone(),
+ },
+ }],
+ Self::MultiEdit(Some(params)) => {
+ // todo: show multiple edits in a multibuffer?
+ params
+ .edits
+ .first()
+ .map(|edit| {
+ vec![acp::ToolCallContent::Diff {
+ diff: acp::Diff {
+ path: params.file_path.clone(),
+ old_text: Some(edit.old_string.clone()),
+ new_text: edit.new_string.clone(),
+ },
+ }]
+ })
+ .unwrap_or_default()
+ }
+ Self::TodoWrite(Some(_)) => {
+ // These are mapped to plan updates later
+ vec![]
+ }
+ Self::Task(None)
+ | Self::NotebookRead(None)
+ | Self::NotebookEdit(None)
+ | Self::Terminal(None)
+ | Self::ReadFile(None)
+ | Self::Ls(None)
+ | Self::Glob(None)
+ | Self::Grep(None)
+ | Self::WebFetch(None)
+ | Self::WebSearch(None)
+ | Self::TodoWrite(None)
+ | Self::ExitPlanMode(None)
+ | Self::Edit(None)
+ | Self::Write(None)
+ | Self::MultiEdit(None) => vec![],
+ }
+ }
+
+ pub fn kind(&self) -> acp::ToolKind {
+ match self {
+ Self::Task(_) => acp::ToolKind::Think,
+ Self::NotebookRead(_) => acp::ToolKind::Read,
+ Self::NotebookEdit(_) => acp::ToolKind::Edit,
+ Self::Edit(_) => acp::ToolKind::Edit,
+ Self::MultiEdit(_) => acp::ToolKind::Edit,
+ Self::Write(_) => acp::ToolKind::Edit,
+ Self::ReadFile(_) => acp::ToolKind::Read,
+ Self::Ls(_) => acp::ToolKind::Search,
+ Self::Glob(_) => acp::ToolKind::Search,
+ Self::Grep(_) => acp::ToolKind::Search,
+ Self::Terminal(_) => acp::ToolKind::Execute,
+ Self::WebSearch(_) => acp::ToolKind::Search,
+ Self::WebFetch(_) => acp::ToolKind::Fetch,
+ Self::TodoWrite(_) => acp::ToolKind::Think,
+ Self::ExitPlanMode(_) => acp::ToolKind::Think,
+ Self::Other { .. } => acp::ToolKind::Other,
+ }
+ }
+
+ pub fn locations(&self) -> Vec<acp::ToolCallLocation> {
+ match &self {
+ Self::Edit(Some(EditToolParams { abs_path, .. })) => vec![acp::ToolCallLocation {
+ path: abs_path.clone(),
+ line: None,
+ }],
+ Self::MultiEdit(Some(MultiEditToolParams { file_path, .. })) => {
+ vec![acp::ToolCallLocation {
+ path: file_path.clone(),
+ line: None,
+ }]
+ }
+ Self::Write(Some(WriteToolParams {
+ abs_path: file_path,
+ ..
+ })) => {
+ vec![acp::ToolCallLocation {
+ path: file_path.clone(),
+ line: None,
+ }]
+ }
+ Self::ReadFile(Some(ReadToolParams {
+ abs_path, offset, ..
+ })) => vec![acp::ToolCallLocation {
+ path: abs_path.clone(),
+ line: *offset,
+ }],
+ Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
+ vec![acp::ToolCallLocation {
+ path: notebook_path.clone(),
+ line: None,
+ }]
+ }
+ Self::NotebookEdit(Some(NotebookEditToolParams { notebook_path, .. })) => {
+ vec![acp::ToolCallLocation {
+ path: notebook_path.clone(),
+ line: None,
+ }]
+ }
+ Self::Glob(Some(GlobToolParams {
+ path: Some(path), ..
+ })) => vec![acp::ToolCallLocation {
+ path: path.clone(),
+ line: None,
+ }],
+ Self::Ls(Some(LsToolParams { path, .. })) => vec![acp::ToolCallLocation {
+ path: path.clone(),
+ line: None,
+ }],
+ Self::Grep(Some(GrepToolParams {
+ path: Some(path), ..
+ })) => vec![acp::ToolCallLocation {
+ path: PathBuf::from(path),
+ line: None,
+ }],
+ Self::Task(_)
+ | Self::NotebookRead(None)
+ | Self::NotebookEdit(None)
+ | Self::Edit(None)
+ | Self::MultiEdit(None)
+ | Self::Write(None)
+ | Self::ReadFile(None)
+ | Self::Ls(None)
+ | Self::Glob(_)
+ | Self::Grep(_)
+ | Self::Terminal(_)
+ | Self::WebFetch(_)
+ | Self::WebSearch(_)
+ | Self::TodoWrite(_)
+ | Self::ExitPlanMode(_)
+ | Self::Other { .. } => vec![],
+ }
+ }
+
+ pub fn as_acp(&self, id: acp::ToolCallId) -> acp::ToolCall {
+ acp::ToolCall {
+ id,
+ kind: self.kind(),
+ status: acp::ToolCallStatus::InProgress,
+ title: self.label(),
+ content: self.content(),
+ locations: self.locations(),
+ raw_input: None,
+ raw_output: None,
+ }
+ }
+}
+
+/// Edit a file.
+///
+/// In sessions with mcp__zed__Edit always use it instead of Edit as it will
+/// allow the user to conveniently review changes.
+///
+/// File editing instructions:
+/// - The `old_text` param must match existing file content, including indentation.
+/// - The `old_text` param must come from the actual file, not an outline.
+/// - The `old_text` section must not be empty.
+/// - Be minimal with replacements:
+/// - For unique lines, include only those lines.
+/// - For non-unique lines, include enough context to identify them.
+/// - Do not escape quotes, newlines, or other characters.
+/// - Only edit the specified file.
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct EditToolParams {
+ /// The absolute path to the file to read.
+ pub abs_path: PathBuf,
+ /// The old text to replace (must be unique in the file)
+ pub old_text: String,
+ /// The new text.
+ pub new_text: String,
+}
+
+/// Reads the content of the given file in the project.
+///
+/// Never attempt to read a path that hasn't been previously mentioned.
+///
+/// In sessions with mcp__zed__Read always use it instead of Read as it contains the most up-to-date contents.
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct ReadToolParams {
+ /// The absolute path to the file to read.
+ pub abs_path: PathBuf,
+ /// Which line to start reading from. Omit to start from the beginning.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub offset: Option<u32>,
+ /// How many lines to read. Omit for the whole file.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub limit: Option<u32>,
+}
+
+/// Writes content to the specified file in the project.
+///
+/// In sessions with mcp__zed__Write always use it instead of Write as it will
+/// allow the user to conveniently review changes.
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct WriteToolParams {
+ /// The absolute path of the file to write.
+ pub abs_path: PathBuf,
+ /// The full content to write.
+ pub content: String,
+}
+
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct BashToolParams {
+ /// Shell command to execute
+ pub command: String,
+ /// 5-10 word description of what command does
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub description: Option<String>,
+ /// Timeout in ms (max 600000ms/10min, default 120000ms)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub timeout: Option<u32>,
+}
+
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct GlobToolParams {
+ /// Glob pattern like **/*.js or src/**/*.ts
+ pub pattern: String,
+ /// Directory to search in (omit for current directory)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub path: Option<PathBuf>,
+}
+
+impl std::fmt::Display for GlobToolParams {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ if let Some(path) = &self.path {
+ write!(f, "{}", path.display())?;
+ }
+ write!(f, "{}", self.pattern)
+ }
+}
+
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct LsToolParams {
+ /// Absolute path to directory
+ pub path: PathBuf,
+ /// Array of glob patterns to ignore
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub ignore: Vec<String>,
+}
+
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct GrepToolParams {
+ /// Regex pattern to search for
+ pub pattern: String,
+ /// File/directory to search (defaults to current directory)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub path: Option<String>,
+ /// "content" (shows lines), "files_with_matches" (default), "count"
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub output_mode: Option<GrepOutputMode>,
+ /// Filter files with glob pattern like "*.js"
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub glob: Option<String>,
+ /// File type filter like "js", "py", "rust"
+ #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
+ pub file_type: Option<String>,
+ /// Case insensitive search
+ #[serde(rename = "-i", default, skip_serializing_if = "is_false")]
+ pub case_insensitive: bool,
+ /// Show line numbers (content mode only)
+ #[serde(rename = "-n", default, skip_serializing_if = "is_false")]
+ pub line_numbers: bool,
+ /// Lines after match (content mode only)
+ #[serde(rename = "-A", skip_serializing_if = "Option::is_none")]
+ pub after_context: Option<u32>,
+ /// Lines before match (content mode only)
+ #[serde(rename = "-B", skip_serializing_if = "Option::is_none")]
+ pub before_context: Option<u32>,
+ /// Lines before and after match (content mode only)
+ #[serde(rename = "-C", skip_serializing_if = "Option::is_none")]
+ pub context: Option<u32>,
+ /// Enable multiline/cross-line matching
+ #[serde(default, skip_serializing_if = "is_false")]
+ pub multiline: bool,
+ /// Limit output to first N results
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub head_limit: Option<u32>,
+}
+
+impl std::fmt::Display for GrepToolParams {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "grep")?;
+
+ // Boolean flags
+ if self.case_insensitive {
+ write!(f, " -i")?;
+ }
+ if self.line_numbers {
+ write!(f, " -n")?;
+ }
+
+ // Context options
+ if let Some(after) = self.after_context {
+ write!(f, " -A {}", after)?;
+ }
+ if let Some(before) = self.before_context {
+ write!(f, " -B {}", before)?;
+ }
+ if let Some(context) = self.context {
+ write!(f, " -C {}", context)?;
+ }
+
+ // Output mode
+ if let Some(mode) = &self.output_mode {
+ match mode {
+ GrepOutputMode::FilesWithMatches => write!(f, " -l")?,
+ GrepOutputMode::Count => write!(f, " -c")?,
+ GrepOutputMode::Content => {} // Default mode
+ }
+ }
+
+ // Head limit
+ if let Some(limit) = self.head_limit {
+ write!(f, " | head -{}", limit)?;
+ }
+
+ // Glob pattern
+ if let Some(glob) = &self.glob {
+ write!(f, " --include=\"{}\"", glob)?;
+ }
+
+ // File type
+ if let Some(file_type) = &self.file_type {
+ write!(f, " --type={}", file_type)?;
+ }
+
+ // Multiline
+ if self.multiline {
+ write!(f, " -P")?; // Perl-compatible regex for multiline
+ }
+
+ // Pattern (escaped if contains special characters)
+ write!(f, " \"{}\"", self.pattern)?;
+
+ // Path
+ if let Some(path) = &self.path {
+ write!(f, " {}", path)?;
+ }
+
+ Ok(())
+ }
+}
+
+#[derive(Default, Deserialize, Serialize, JsonSchema, strum::Display, Debug)]
+#[serde(rename_all = "snake_case")]
+pub enum TodoPriority {
+ High,
+ #[default]
+ Medium,
+ Low,
+}
+
+impl Into<acp::PlanEntryPriority> for TodoPriority {
+ fn into(self) -> acp::PlanEntryPriority {
+ match self {
+ TodoPriority::High => acp::PlanEntryPriority::High,
+ TodoPriority::Medium => acp::PlanEntryPriority::Medium,
+ TodoPriority::Low => acp::PlanEntryPriority::Low,
+ }
+ }
+}
+
+#[derive(Deserialize, Serialize, JsonSchema, Debug)]
+#[serde(rename_all = "snake_case")]
+pub enum TodoStatus {
+ Pending,
+ InProgress,
+ Completed,
+}
+
+impl Into<acp::PlanEntryStatus> for TodoStatus {
+ fn into(self) -> acp::PlanEntryStatus {
+ match self {
+ TodoStatus::Pending => acp::PlanEntryStatus::Pending,
+ TodoStatus::InProgress => acp::PlanEntryStatus::InProgress,
+ TodoStatus::Completed => acp::PlanEntryStatus::Completed,
+ }
+ }
+}
+
+#[derive(Deserialize, Serialize, JsonSchema, Debug)]
+pub struct Todo {
+ /// Task description
+ pub content: String,
+ /// Current status of the todo
+ pub status: TodoStatus,
+ /// Priority level of the todo
+ #[serde(default)]
+ pub priority: TodoPriority,
+}
+
+impl Into<acp::PlanEntry> for Todo {
+ fn into(self) -> acp::PlanEntry {
+ acp::PlanEntry {
+ content: self.content,
+ priority: self.priority.into(),
+ status: self.status.into(),
+ }
+ }
+}
+
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct TodoWriteToolParams {
+ pub todos: Vec<Todo>,
+}
+
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct ExitPlanModeToolParams {
+ /// Implementation plan in markdown format
+ pub plan: String,
+}
+
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct TaskToolParams {
+ /// Short 3-5 word description of task
+ pub description: String,
+ /// Detailed task for agent to perform
+ pub prompt: String,
+}
+
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct NotebookReadToolParams {
+ /// Absolute path to .ipynb file
+ pub notebook_path: PathBuf,
+ /// Specific cell ID to read
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub cell_id: Option<String>,
+}
+
+#[derive(Deserialize, Serialize, JsonSchema, Debug)]
+#[serde(rename_all = "snake_case")]
+pub enum CellType {
+ Code,
+ Markdown,
+}
+
+#[derive(Deserialize, Serialize, JsonSchema, Debug)]
+#[serde(rename_all = "snake_case")]
+pub enum EditMode {
+ Replace,
+ Insert,
+ Delete,
+}
+
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct NotebookEditToolParams {
+ /// Absolute path to .ipynb file
+ pub notebook_path: PathBuf,
+ /// New cell content
+ pub new_source: String,
+ /// Cell ID to edit
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub cell_id: Option<String>,
+ /// Type of cell (code or markdown)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub cell_type: Option<CellType>,
+ /// Edit operation mode
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub edit_mode: Option<EditMode>,
+}
+
+#[derive(Deserialize, Serialize, JsonSchema, Debug)]
+pub struct MultiEditItem {
+ /// The text to search for and replace
+ pub old_string: String,
+ /// The replacement text
+ pub new_string: String,
+ /// Whether to replace all occurrences or just the first
+ #[serde(default, skip_serializing_if = "is_false")]
+ pub replace_all: bool,
+}
+
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct MultiEditToolParams {
+ /// Absolute path to file
+ pub file_path: PathBuf,
+ /// List of edits to apply
+ pub edits: Vec<MultiEditItem>,
+}
+
+fn is_false(v: &bool) -> bool {
+ !*v
+}
+
+#[derive(Deserialize, JsonSchema, Debug)]
+#[serde(rename_all = "snake_case")]
+pub enum GrepOutputMode {
+ Content,
+ FilesWithMatches,
+ Count,
+}
+
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct WebFetchToolParams {
+ /// Valid URL to fetch
+ #[serde(rename = "url")]
+ pub url: String,
+ /// What to extract from content
+ pub prompt: String,
+}
+
+#[derive(Deserialize, JsonSchema, Debug)]
+pub struct WebSearchToolParams {
+ /// Search query (min 2 chars)
+ pub query: String,
+ /// Only include these domains
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub allowed_domains: Vec<String>,
+ /// Exclude these domains
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub blocked_domains: Vec<String>,
+}
+
+impl std::fmt::Display for WebSearchToolParams {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "\"{}\"", self.query)?;
+
+ if !self.allowed_domains.is_empty() {
+ write!(f, " (allowed: {})", self.allowed_domains.join(", "))?;
+ }
+
+ if !self.blocked_domains.is_empty() {
+ write!(f, " (blocked: {})", self.blocked_domains.join(", "))?;
+ }
+
+ Ok(())
+ }
+}
@@ -0,0 +1,59 @@
+use acp_thread::AcpThread;
+use anyhow::Result;
+use context_server::{
+ listener::{McpServerTool, ToolResponse},
+ types::ToolAnnotations,
+};
+use gpui::{AsyncApp, WeakEntity};
+
+use crate::tools::WriteToolParams;
+
+#[derive(Clone)]
+pub struct WriteTool {
+ thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
+}
+
+impl WriteTool {
+ pub fn new(thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
+ Self { thread_rx }
+ }
+}
+
+impl McpServerTool for WriteTool {
+ type Input = WriteToolParams;
+ type Output = ();
+
+ const NAME: &'static str = "Write";
+
+ fn annotations(&self) -> ToolAnnotations {
+ ToolAnnotations {
+ title: Some("Write file".to_string()),
+ read_only_hint: Some(false),
+ destructive_hint: Some(false),
+ open_world_hint: Some(false),
+ idempotent_hint: Some(false),
+ }
+ }
+
+ async fn run(
+ &self,
+ input: Self::Input,
+ cx: &mut AsyncApp,
+ ) -> Result<ToolResponse<Self::Output>> {
+ let mut thread_rx = self.thread_rx.clone();
+ let Some(thread) = thread_rx.recv().await?.upgrade() else {
+ anyhow::bail!("Thread closed");
+ };
+
+ thread
+ .update(cx, |thread, cx| {
+ thread.write_text_file(input.abs_path, input.content, cx)
+ })?
+ .await?;
+
+ Ok(ToolResponse {
+ content: vec![],
+ structured_content: (),
+ })
+ }
+}
@@ -0,0 +1,59 @@
+use crate::{AgentServerCommand, AgentServerSettings};
+use acp_thread::AgentConnection;
+use anyhow::Result;
+use gpui::{App, Entity, SharedString, Task};
+use project::Project;
+use std::{path::Path, rc::Rc};
+use ui::IconName;
+
+/// A generic agent server implementation for custom user-defined agents
+pub struct CustomAgentServer {
+ name: SharedString,
+ command: AgentServerCommand,
+}
+
+impl CustomAgentServer {
+ pub fn new(name: SharedString, settings: &AgentServerSettings) -> Self {
+ Self {
+ name,
+ command: settings.command.clone(),
+ }
+ }
+}
+
+impl crate::AgentServer for CustomAgentServer {
+ fn name(&self) -> SharedString {
+ self.name.clone()
+ }
+
+ fn logo(&self) -> IconName {
+ IconName::Terminal
+ }
+
+ fn empty_state_headline(&self) -> SharedString {
+ "No conversations yet".into()
+ }
+
+ fn empty_state_message(&self) -> SharedString {
+ format!("Start a conversation with {}", self.name).into()
+ }
+
+ fn connect(
+ &self,
+ root_dir: &Path,
+ _project: &Entity<Project>,
+ cx: &mut App,
+ ) -> Task<Result<Rc<dyn AgentConnection>>> {
+ let server_name = self.name();
+ let command = self.command.clone();
+ let root_dir = root_dir.to_path_buf();
+
+ cx.spawn(async move |mut cx| {
+ crate::acp::connect(server_name, command, &root_dir, &mut cx).await
+ })
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn std::any::Any> {
+ self
+ }
+}
@@ -0,0 +1,556 @@
+use crate::AgentServer;
+use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
+use agent_client_protocol as acp;
+use futures::{FutureExt, StreamExt, channel::mpsc, select};
+use gpui::{AppContext, Entity, TestAppContext};
+use indoc::indoc;
+use project::{FakeFs, Project};
+use std::{
+ path::{Path, PathBuf},
+ sync::Arc,
+ time::Duration,
+};
+use util::path;
+
+pub async fn test_basic<T, F>(server: F, cx: &mut TestAppContext)
+where
+ T: AgentServer + 'static,
+ F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+{
+ let fs = init_test(cx).await as Arc<dyn fs::Fs>;
+ let project = Project::test(fs.clone(), [], cx).await;
+ let thread = new_test_thread(
+ server(&fs, &project, cx).await,
+ project.clone(),
+ "/private/tmp",
+ cx,
+ )
+ .await;
+
+ thread
+ .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx))
+ .await
+ .unwrap();
+
+ thread.read_with(cx, |thread, _| {
+ assert!(
+ thread.entries().len() >= 2,
+ "Expected at least 2 entries. Got: {:?}",
+ thread.entries()
+ );
+ assert!(matches!(
+ thread.entries()[0],
+ AgentThreadEntry::UserMessage(_)
+ ));
+ assert!(matches!(
+ thread.entries()[1],
+ AgentThreadEntry::AssistantMessage(_)
+ ));
+ });
+}
+
+pub async fn test_path_mentions<T, F>(server: F, cx: &mut TestAppContext)
+where
+ T: AgentServer + 'static,
+ F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+{
+ let fs = init_test(cx).await as _;
+
+ let tempdir = tempfile::tempdir().unwrap();
+ std::fs::write(
+ tempdir.path().join("foo.rs"),
+ indoc! {"
+ fn main() {
+ println!(\"Hello, world!\");
+ }
+ "},
+ )
+ .expect("failed to write file");
+ let project = Project::example([tempdir.path()], &mut cx.to_async()).await;
+ let thread = new_test_thread(
+ server(&fs, &project, cx).await,
+ project.clone(),
+ tempdir.path(),
+ cx,
+ )
+ .await;
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(
+ vec![
+ acp::ContentBlock::Text(acp::TextContent {
+ text: "Read the file ".into(),
+ annotations: None,
+ }),
+ acp::ContentBlock::ResourceLink(acp::ResourceLink {
+ uri: "foo.rs".into(),
+ name: "foo.rs".into(),
+ annotations: None,
+ description: None,
+ mime_type: None,
+ size: None,
+ title: None,
+ }),
+ acp::ContentBlock::Text(acp::TextContent {
+ text: " and tell me what the content of the println! is".into(),
+ annotations: None,
+ }),
+ ],
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ thread.read_with(cx, |thread, cx| {
+ assert!(matches!(
+ thread.entries()[0],
+ AgentThreadEntry::UserMessage(_)
+ ));
+ let assistant_message = &thread
+ .entries()
+ .iter()
+ .rev()
+ .find_map(|entry| match entry {
+ AgentThreadEntry::AssistantMessage(msg) => Some(msg),
+ _ => None,
+ })
+ .unwrap();
+
+ assert!(
+ assistant_message.to_markdown(cx).contains("Hello, world!"),
+ "unexpected assistant message: {:?}",
+ assistant_message.to_markdown(cx)
+ );
+ });
+
+ drop(tempdir);
+}
+
+pub async fn test_tool_call<T, F>(server: F, cx: &mut TestAppContext)
+where
+ T: AgentServer + 'static,
+ F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+{
+ let fs = init_test(cx).await as _;
+
+ let tempdir = tempfile::tempdir().unwrap();
+ let foo_path = tempdir.path().join("foo");
+ std::fs::write(&foo_path, "Lorem ipsum dolor").expect("failed to write file");
+
+ let project = Project::example([tempdir.path()], &mut cx.to_async()).await;
+ let thread = new_test_thread(
+ server(&fs, &project, cx).await,
+ project.clone(),
+ "/private/tmp",
+ cx,
+ )
+ .await;
+
+ thread
+ .update(cx, |thread, cx| {
+ thread.send_raw(
+ &format!("Read {} and tell me what you see.", foo_path.display()),
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ thread.read_with(cx, |thread, _cx| {
+ assert!(thread.entries().iter().any(|entry| {
+ matches!(
+ entry,
+ AgentThreadEntry::ToolCall(ToolCall {
+ status: ToolCallStatus::Pending
+ | ToolCallStatus::InProgress
+ | ToolCallStatus::Completed,
+ ..
+ })
+ )
+ }));
+ assert!(
+ thread
+ .entries()
+ .iter()
+ .any(|entry| { matches!(entry, AgentThreadEntry::AssistantMessage(_)) })
+ );
+ });
+
+ drop(tempdir);
+}
+
+pub async fn test_tool_call_with_permission<T, F>(
+ server: F,
+ allow_option_id: acp::PermissionOptionId,
+ cx: &mut TestAppContext,
+) where
+ T: AgentServer + 'static,
+ F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+{
+ let fs = init_test(cx).await as Arc<dyn fs::Fs>;
+ let project = Project::test(fs.clone(), [path!("/private/tmp").as_ref()], cx).await;
+ let thread = new_test_thread(
+ server(&fs, &project, cx).await,
+ project.clone(),
+ "/private/tmp",
+ cx,
+ )
+ .await;
+ let full_turn = thread.update(cx, |thread, cx| {
+ thread.send_raw(
+ r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#,
+ cx,
+ )
+ });
+
+ run_until_first_tool_call(
+ &thread,
+ |entry| {
+ matches!(
+ entry,
+ AgentThreadEntry::ToolCall(ToolCall {
+ status: ToolCallStatus::WaitingForConfirmation { .. },
+ ..
+ })
+ )
+ },
+ cx,
+ )
+ .await;
+
+ let tool_call_id = thread.read_with(cx, |thread, cx| {
+ let AgentThreadEntry::ToolCall(ToolCall {
+ id,
+ label,
+ status: ToolCallStatus::WaitingForConfirmation { .. },
+ ..
+ }) = &thread
+ .entries()
+ .iter()
+ .find(|entry| matches!(entry, AgentThreadEntry::ToolCall(_)))
+ .unwrap()
+ else {
+ panic!();
+ };
+
+ let label = label.read(cx).source();
+ assert!(label.contains("touch"), "Got: {}", label);
+
+ id.clone()
+ });
+
+ thread.update(cx, |thread, cx| {
+ thread.authorize_tool_call(
+ tool_call_id,
+ allow_option_id,
+ acp::PermissionOptionKind::AllowOnce,
+ cx,
+ );
+
+ assert!(thread.entries().iter().any(|entry| matches!(
+ entry,
+ AgentThreadEntry::ToolCall(ToolCall {
+ status: ToolCallStatus::Pending
+ | ToolCallStatus::InProgress
+ | ToolCallStatus::Completed,
+ ..
+ })
+ )));
+ });
+
+ full_turn.await.unwrap();
+
+ thread.read_with(cx, |thread, cx| {
+ let AgentThreadEntry::ToolCall(ToolCall {
+ content,
+ status: ToolCallStatus::Pending
+ | ToolCallStatus::InProgress
+ | ToolCallStatus::Completed,
+ ..
+ }) = thread
+ .entries()
+ .iter()
+ .find(|entry| matches!(entry, AgentThreadEntry::ToolCall(_)))
+ .unwrap()
+ else {
+ panic!();
+ };
+
+ assert!(
+ content.iter().any(|c| c.to_markdown(cx).contains("Hello")),
+ "Expected content to contain 'Hello'"
+ );
+ });
+}
+
+pub async fn test_cancel<T, F>(server: F, cx: &mut TestAppContext)
+where
+ T: AgentServer + 'static,
+ F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+{
+ let fs = init_test(cx).await as Arc<dyn fs::Fs>;
+
+ let project = Project::test(fs.clone(), [path!("/private/tmp").as_ref()], cx).await;
+ let thread = new_test_thread(
+ server(&fs, &project, cx).await,
+ project.clone(),
+ "/private/tmp",
+ cx,
+ )
+ .await;
+ let _ = thread.update(cx, |thread, cx| {
+ thread.send_raw(
+ r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#,
+ cx,
+ )
+ });
+
+ let first_tool_call_ix = run_until_first_tool_call(
+ &thread,
+ |entry| {
+ matches!(
+ entry,
+ AgentThreadEntry::ToolCall(ToolCall {
+ status: ToolCallStatus::WaitingForConfirmation { .. },
+ ..
+ })
+ )
+ },
+ cx,
+ )
+ .await;
+
+ thread.read_with(cx, |thread, cx| {
+ let AgentThreadEntry::ToolCall(ToolCall {
+ id,
+ label,
+ status: ToolCallStatus::WaitingForConfirmation { .. },
+ ..
+ }) = &thread.entries()[first_tool_call_ix]
+ else {
+ panic!("{:?}", thread.entries()[1]);
+ };
+
+ let label = label.read(cx).source();
+ assert!(label.contains("touch"), "Got: {}", label);
+
+ id.clone()
+ });
+
+ thread.update(cx, |thread, cx| thread.cancel(cx)).await;
+ thread.read_with(cx, |thread, _cx| {
+ let AgentThreadEntry::ToolCall(ToolCall {
+ status: ToolCallStatus::Canceled,
+ ..
+ }) = &thread.entries()[first_tool_call_ix]
+ else {
+ panic!();
+ };
+ });
+
+ thread
+ .update(cx, |thread, cx| {
+ thread.send_raw(r#"Stop running and say goodbye to me."#, cx)
+ })
+ .await
+ .unwrap();
+ thread.read_with(cx, |thread, _| {
+ assert!(matches!(
+ &thread.entries().last().unwrap(),
+ AgentThreadEntry::AssistantMessage(..),
+ ))
+ });
+}
+
+pub async fn test_thread_drop<T, F>(server: F, cx: &mut TestAppContext)
+where
+ T: AgentServer + 'static,
+ F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+{
+ let fs = init_test(cx).await as Arc<dyn fs::Fs>;
+ let project = Project::test(fs.clone(), [], cx).await;
+ let thread = new_test_thread(
+ server(&fs, &project, cx).await,
+ project.clone(),
+ "/private/tmp",
+ cx,
+ )
+ .await;
+
+ thread
+ .update(cx, |thread, cx| thread.send_raw("Hello from test!", cx))
+ .await
+ .unwrap();
+
+ thread.read_with(cx, |thread, _| {
+ assert!(thread.entries().len() >= 2, "Expected at least 2 entries");
+ });
+
+ let weak_thread = thread.downgrade();
+ drop(thread);
+
+ cx.executor().run_until_parked();
+ assert!(!weak_thread.is_upgradable());
+}
+
+#[macro_export]
+macro_rules! common_e2e_tests {
+ ($server:expr, allow_option_id = $allow_option_id:expr) => {
+ mod common_e2e {
+ use super::*;
+
+ #[::gpui::test]
+ #[cfg_attr(not(feature = "e2e"), ignore)]
+ async fn basic(cx: &mut ::gpui::TestAppContext) {
+ $crate::e2e_tests::test_basic($server, cx).await;
+ }
+
+ #[::gpui::test]
+ #[cfg_attr(not(feature = "e2e"), ignore)]
+ async fn path_mentions(cx: &mut ::gpui::TestAppContext) {
+ $crate::e2e_tests::test_path_mentions($server, cx).await;
+ }
+
+ #[::gpui::test]
+ #[cfg_attr(not(feature = "e2e"), ignore)]
+ async fn tool_call(cx: &mut ::gpui::TestAppContext) {
+ $crate::e2e_tests::test_tool_call($server, cx).await;
+ }
+
+ #[::gpui::test]
+ #[cfg_attr(not(feature = "e2e"), ignore)]
+ async fn tool_call_with_permission(cx: &mut ::gpui::TestAppContext) {
+ $crate::e2e_tests::test_tool_call_with_permission(
+ $server,
+ ::agent_client_protocol::PermissionOptionId($allow_option_id.into()),
+ cx,
+ )
+ .await;
+ }
+
+ #[::gpui::test]
+ #[cfg_attr(not(feature = "e2e"), ignore)]
+ async fn cancel(cx: &mut ::gpui::TestAppContext) {
+ $crate::e2e_tests::test_cancel($server, cx).await;
+ }
+
+ #[::gpui::test]
+ #[cfg_attr(not(feature = "e2e"), ignore)]
+ async fn thread_drop(cx: &mut ::gpui::TestAppContext) {
+ $crate::e2e_tests::test_thread_drop($server, cx).await;
+ }
+ }
+ };
+}
+pub use common_e2e_tests;
+
+// Helpers
+
+pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
+ #[cfg(test)]
+ use settings::Settings;
+
+ env_logger::try_init().ok();
+
+ cx.update(|cx| {
+ let settings_store = settings::SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ Project::init_settings(cx);
+ language::init(cx);
+ gpui_tokio::init(cx);
+ let http_client = reqwest_client::ReqwestClient::user_agent("agent tests").unwrap();
+ cx.set_http_client(Arc::new(http_client));
+ client::init_settings(cx);
+ let client = client::Client::production(cx);
+ let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx));
+ language_model::init(client.clone(), cx);
+ language_models::init(user_store, client, cx);
+ agent_settings::init(cx);
+ crate::settings::init(cx);
+
+ #[cfg(test)]
+ crate::AllAgentServersSettings::override_global(
+ crate::AllAgentServersSettings {
+ claude: Some(crate::AgentServerSettings {
+ command: crate::claude::tests::local_command(),
+ }),
+ gemini: Some(crate::AgentServerSettings {
+ command: crate::gemini::tests::local_command(),
+ }),
+ custom: collections::HashMap::default(),
+ },
+ cx,
+ );
+ });
+
+ cx.executor().allow_parking();
+
+ FakeFs::new(cx.executor())
+}
+
+pub async fn new_test_thread(
+ server: impl AgentServer + 'static,
+ project: Entity<Project>,
+ current_dir: impl AsRef<Path>,
+ cx: &mut TestAppContext,
+) -> Entity<AcpThread> {
+ let connection = cx
+ .update(|cx| server.connect(current_dir.as_ref(), &project, cx))
+ .await
+ .unwrap();
+
+ cx.update(|cx| connection.new_thread(project.clone(), current_dir.as_ref(), cx))
+ .await
+ .unwrap()
+}
+
+pub async fn run_until_first_tool_call(
+ thread: &Entity<AcpThread>,
+ wait_until: impl Fn(&AgentThreadEntry) -> bool + 'static,
+ cx: &mut TestAppContext,
+) -> usize {
+ let (mut tx, mut rx) = mpsc::channel::<usize>(1);
+
+ let subscription = cx.update(|cx| {
+ cx.subscribe(thread, move |thread, _, cx| {
+ for (ix, entry) in thread.read(cx).entries().iter().enumerate() {
+ if wait_until(entry) {
+ return tx.try_send(ix).unwrap();
+ }
+ }
+ })
+ });
+
+ select! {
+ // We have to use a smol timer here because
+ // cx.background_executor().timer isn't real in the test context
+ _ = futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(20))) => {
+ panic!("Timeout waiting for tool call")
+ }
+ ix = rx.next().fuse() => {
+ drop(subscription);
+ ix.unwrap()
+ }
+ }
+}
+
+pub fn get_zed_path() -> PathBuf {
+ let mut zed_path = std::env::current_exe().unwrap();
+
+ while zed_path
+ .file_name()
+ .is_none_or(|name| name.to_string_lossy() != "debug")
+ {
+ if !zed_path.pop() {
+ panic!("Could not find target directory");
+ }
+ }
+
+ zed_path.push("zed");
+
+ if !zed_path.exists() {
+ panic!("\n🚨 Run `cargo build` at least once before running e2e tests\n\n");
+ }
+
+ zed_path
+}
@@ -0,0 +1,124 @@
+use std::rc::Rc;
+use std::{any::Any, path::Path};
+
+use crate::{AgentServer, AgentServerCommand};
+use acp_thread::{AgentConnection, LoadError};
+use anyhow::Result;
+use gpui::{App, Entity, SharedString, Task};
+use language_models::provider::google::GoogleLanguageModelProvider;
+use project::Project;
+use settings::SettingsStore;
+
+use crate::AllAgentServersSettings;
+
+#[derive(Clone)]
+pub struct Gemini;
+
+const ACP_ARG: &str = "--experimental-acp";
+
+impl AgentServer for Gemini {
+ fn name(&self) -> SharedString {
+ "Gemini CLI".into()
+ }
+
+ fn empty_state_headline(&self) -> SharedString {
+ self.name()
+ }
+
+ fn empty_state_message(&self) -> SharedString {
+ "Ask questions, edit files, run commands".into()
+ }
+
+ fn logo(&self) -> ui::IconName {
+ ui::IconName::AiGemini
+ }
+
+ fn connect(
+ &self,
+ root_dir: &Path,
+ project: &Entity<Project>,
+ cx: &mut App,
+ ) -> Task<Result<Rc<dyn AgentConnection>>> {
+ let project = project.clone();
+ let root_dir = root_dir.to_path_buf();
+ let server_name = self.name();
+ cx.spawn(async move |cx| {
+ let settings = cx.read_global(|settings: &SettingsStore, _| {
+ settings.get::<AllAgentServersSettings>(None).gemini.clone()
+ })?;
+
+ let Some(mut command) =
+ AgentServerCommand::resolve("gemini", &[ACP_ARG], None, settings, &project, cx).await
+ else {
+ return Err(LoadError::NotInstalled {
+ error_message: "Failed to find Gemini CLI binary".into(),
+ install_message: "Install Gemini CLI".into(),
+ install_command: "npm install -g @google/gemini-cli@preview".into()
+ }.into());
+ };
+
+ if let Some(api_key)= cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() {
+ command.env.get_or_insert_default().insert("GEMINI_API_KEY".to_owned(), api_key.key);
+ }
+
+ let result = crate::acp::connect(server_name, command.clone(), &root_dir, cx).await;
+ if result.is_err() {
+ let version_fut = util::command::new_smol_command(&command.path)
+ .args(command.args.iter())
+ .arg("--version")
+ .kill_on_drop(true)
+ .output();
+
+ let help_fut = util::command::new_smol_command(&command.path)
+ .args(command.args.iter())
+ .arg("--help")
+ .kill_on_drop(true)
+ .output();
+
+ let (version_output, help_output) = futures::future::join(version_fut, help_fut).await;
+
+ let current_version = String::from_utf8(version_output?.stdout)?;
+ let supported = String::from_utf8(help_output?.stdout)?.contains(ACP_ARG);
+
+ if !supported {
+ return Err(LoadError::Unsupported {
+ error_message: format!(
+ "Your installed version of Gemini CLI ({}, version {}) doesn't support the Agentic Coding Protocol (ACP).",
+ command.path.to_string_lossy(),
+ current_version
+ ).into(),
+ upgrade_message: "Upgrade Gemini CLI to latest".into(),
+ upgrade_command: "npm install -g @google/gemini-cli@preview".into(),
+ }.into())
+ }
+ }
+ result
+ })
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ self
+ }
+}
+
+#[cfg(test)]
+pub(crate) mod tests {
+ use super::*;
+ use crate::AgentServerCommand;
+ use std::path::Path;
+
+ crate::common_e2e_tests!(async |_, _, _| Gemini, allow_option_id = "proceed_once");
+
+ pub fn local_command() -> AgentServerCommand {
+ let cli_path = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../../../gemini-cli/packages/cli")
+ .to_string_lossy()
+ .to_string();
+
+ AgentServerCommand {
+ path: "node".into(),
+ args: vec![cli_path],
+ env: None,
+ }
+ }
+}
@@ -0,0 +1,63 @@
+use crate::AgentServerCommand;
+use anyhow::Result;
+use collections::HashMap;
+use gpui::{App, SharedString};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::{Settings, SettingsSources};
+
+pub fn init(cx: &mut App) {
+ AllAgentServersSettings::register(cx);
+}
+
+#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)]
+pub struct AllAgentServersSettings {
+ pub gemini: Option<AgentServerSettings>,
+ pub claude: Option<AgentServerSettings>,
+
+ /// Custom agent servers configured by the user
+ #[serde(flatten)]
+ pub custom: HashMap<SharedString, AgentServerSettings>,
+}
+
+#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
+pub struct AgentServerSettings {
+ #[serde(flatten)]
+ pub command: AgentServerCommand,
+}
+
+impl settings::Settings for AllAgentServersSettings {
+ const KEY: Option<&'static str> = Some("agent_servers");
+
+ type FileContent = Self;
+
+ fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
+ let mut settings = AllAgentServersSettings::default();
+
+ for AllAgentServersSettings {
+ gemini,
+ claude,
+ custom,
+ } in sources.defaults_and_customizations()
+ {
+ if gemini.is_some() {
+ settings.gemini = gemini.clone();
+ }
+ if claude.is_some() {
+ settings.claude = claude.clone();
+ }
+
+ // Merge custom agents
+ for (name, config) in custom {
+ // Skip built-in agent names to avoid conflicts
+ if name != "gemini" && name != "claude" {
+ settings.custom.insert(name.clone(), config.clone());
+ }
+ }
+ }
+
+ Ok(settings)
+ }
+
+ fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
+}
@@ -13,6 +13,7 @@ path = "src/agent_settings.rs"
[dependencies]
anyhow.workspace = true
+cloud_llm_client.workspace = true
collections.workspace = true
gpui.workspace = true
language_model.workspace = true
@@ -20,7 +21,6 @@ schemars.workspace = true
serde.workspace = true
settings.workspace = true
workspace-hack.workspace = true
-zed_llm_client.workspace = true
[dev-dependencies]
fs.workspace = true
@@ -48,6 +48,20 @@ pub struct AgentProfileSettings {
pub context_servers: IndexMap<Arc<str>, ContextServerPreset>,
}
+impl AgentProfileSettings {
+ pub fn is_tool_enabled(&self, tool_name: &str) -> bool {
+ self.tools.get(tool_name) == Some(&true)
+ }
+
+ pub fn is_context_server_tool_enabled(&self, server_id: &str, tool_name: &str) -> bool {
+ self.enable_all_context_servers
+ || self
+ .context_servers
+ .get(server_id)
+ .is_some_and(|preset| preset.tools.get(tool_name) == Some(&true))
+ }
+}
+
#[derive(Debug, Clone, Default)]
pub struct ContextServerPreset {
pub tools: IndexMap<Arc<str>, bool>,
@@ -13,6 +13,11 @@ use std::borrow::Cow;
pub use crate::agent_profile::*;
+pub const SUMMARIZE_THREAD_PROMPT: &str =
+ include_str!("../../agent/src/prompts/summarize_thread_prompt.txt");
+pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str =
+ include_str!("../../agent/src/prompts/summarize_thread_detailed_prompt.txt");
+
pub fn init(cx: &mut App) {
AgentSettings::register(cx);
}
@@ -69,6 +74,7 @@ pub struct AgentSettings {
pub enable_feedback: bool,
pub expand_edit_card: bool,
pub expand_terminal_card: bool,
+ pub use_modifier_to_send: bool,
}
impl AgentSettings {
@@ -112,15 +118,15 @@ pub struct LanguageModelParameters {
impl LanguageModelParameters {
pub fn matches(&self, model: &Arc<dyn LanguageModel>) -> bool {
- if let Some(provider) = &self.provider {
- if provider.0 != model.provider_id().0 {
- return false;
- }
+ if let Some(provider) = &self.provider
+ && provider.0 != model.provider_id().0
+ {
+ return false;
}
- if let Some(setting_model) = &self.model {
- if *setting_model != model.id().0 {
- return false;
- }
+ if let Some(setting_model) = &self.model
+ && *setting_model != model.id().0
+ {
+ return false;
}
true
}
@@ -174,6 +180,10 @@ impl AgentSettingsContent {
self.single_file_review = Some(allow);
}
+ pub fn set_use_modifier_to_send(&mut self, always_use: bool) {
+ self.use_modifier_to_send = Some(always_use);
+ }
+
pub fn set_profile(&mut self, profile_id: AgentProfileId) {
self.default_profile = Some(profile_id);
}
@@ -301,6 +311,10 @@ pub struct AgentSettingsContent {
///
/// Default: true
expand_terminal_card: Option<bool>,
+ /// Whether to always use cmd-enter (or ctrl-enter on Linux or Windows) to send messages in the agent panel.
+ ///
+ /// Default: false
+ use_modifier_to_send: Option<bool>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
@@ -312,11 +326,11 @@ pub enum CompletionMode {
Burn,
}
-impl From<CompletionMode> for zed_llm_client::CompletionMode {
+impl From<CompletionMode> for cloud_llm_client::CompletionMode {
fn from(value: CompletionMode) -> Self {
match value {
- CompletionMode::Normal => zed_llm_client::CompletionMode::Normal,
- CompletionMode::Burn => zed_llm_client::CompletionMode::Max,
+ CompletionMode::Normal => cloud_llm_client::CompletionMode::Normal,
+ CompletionMode::Burn => cloud_llm_client::CompletionMode::Max,
}
}
}
@@ -430,10 +444,6 @@ impl Settings for AgentSettings {
&mut settings.inline_alternatives,
value.inline_alternatives.clone(),
);
- merge(
- &mut settings.always_allow_tool_actions,
- value.always_allow_tool_actions,
- );
merge(
&mut settings.notify_when_agent_waiting,
value.notify_when_agent_waiting,
@@ -456,6 +466,10 @@ impl Settings for AgentSettings {
&mut settings.expand_terminal_card,
value.expand_terminal_card,
);
+ merge(
+ &mut settings.use_modifier_to_send,
+ value.use_modifier_to_send,
+ );
settings
.model_parameters
@@ -491,6 +505,19 @@ impl Settings for AgentSettings {
}
}
+ debug_assert!(
+ !sources.default.always_allow_tool_actions.unwrap_or(false),
+ "For security, agent.always_allow_tool_actions should always be false in default.json. If it's true, that is a bug that should be fixed!"
+ );
+
+ // For security reasons, only trust the user's global settings for whether to always allow tool actions.
+ // If this could be overridden locally, an attacker could (e.g. by committing to source control and
+ // convincing you to switch branches) modify your project-local settings to disable the agent's safety checks.
+ settings.always_allow_tool_actions = sources
+ .user
+ .and_then(|setting| setting.always_allow_tool_actions)
+ .unwrap_or(false);
+
Ok(settings)
}
@@ -16,11 +16,14 @@ doctest = false
test-support = ["gpui/test-support", "language/test-support"]
[dependencies]
-acp.workspace = true
+acp_thread.workspace = true
+action_log.workspace = true
+agent-client-protocol.workspace = true
agent.workspace = true
-agentic-coding-protocol.workspace = true
-agent_settings.workspace = true
+agent2.workspace = true
agent_servers.workspace = true
+agent_settings.workspace = true
+ai_onboarding.workspace = true
anyhow.workspace = true
assistant_context.workspace = true
assistant_slash_command.workspace = true
@@ -30,7 +33,9 @@ audio.workspace = true
buffer_diff.workspace = true
chrono.workspace = true
client.workspace = true
+cloud_llm_client.workspace = true
collections.workspace = true
+command_palette_hooks.workspace = true
component.workspace = true
context_server.workspace = true
db.workspace = true
@@ -44,14 +49,14 @@ futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true
html_to_markdown.workspace = true
-indoc.workspace = true
http_client.workspace = true
-indexed_docs.workspace = true
+indoc.workspace = true
inventory.workspace = true
itertools.workspace = true
jsonschema.workspace = true
language.workspace = true
language_model.workspace = true
+language_models.workspace = true
log.workspace = true
lsp.workspace = true
markdown.workspace = true
@@ -62,6 +67,7 @@ ordered-float.workspace = true
parking_lot.workspace = true
paths.workspace = true
picker.workspace = true
+postage.workspace = true
project.workspace = true
prompt_store.workspace = true
proto.workspace = true
@@ -86,6 +92,8 @@ theme.workspace = true
time.workspace = true
time_format.workspace = true
ui.workspace = true
+ui_input.workspace = true
+url.workspace = true
urlencoding.workspace = true
util.workspace = true
uuid.workspace = true
@@ -93,11 +101,15 @@ watch.workspace = true
workspace-hack.workspace = true
workspace.workspace = true
zed_actions.workspace = true
-zed_llm_client.workspace = true
[dev-dependencies]
+acp_thread = { workspace = true, features = ["test-support"] }
+agent = { workspace = true, features = ["test-support"] }
+agent2 = { workspace = true, features = ["test-support"] }
+assistant_context = { workspace = true, features = ["test-support"] }
assistant_tools.workspace = true
buffer_diff = { workspace = true, features = ["test-support"] }
+db = { workspace = true, features = ["test-support"] }
editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, "features" = ["test-support"] }
indoc.workspace = true
@@ -1,6 +1,12 @@
mod completion_provider;
-mod message_history;
+mod entry_view_state;
+mod message_editor;
+mod model_selector;
+mod model_selector_popover;
+mod thread_history;
mod thread_view;
-pub use message_history::MessageHistory;
+pub use model_selector::AcpModelSelector;
+pub use model_selector_popover::AcpModelSelectorPopover;
+pub use thread_history::*;
pub use thread_view::AcpThreadView;
@@ -1,101 +1,222 @@
+use std::cell::Cell;
use std::ops::Range;
-use std::path::Path;
+use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
+use acp_thread::MentionUri;
+use agent_client_protocol as acp;
+use agent2::{HistoryEntry, HistoryStore};
use anyhow::Result;
-use collections::HashMap;
-use editor::display_map::CreaseId;
use editor::{CompletionProvider, Editor, ExcerptId};
-use file_icons::FileIcons;
+use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{App, Entity, Task, WeakEntity};
use language::{Buffer, CodeLabel, HighlightId};
use lsp::CompletionContext;
-use parking_lot::Mutex;
-use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, WorktreeId};
+use project::{
+ Completion, CompletionIntent, CompletionResponse, Project, ProjectPath, Symbol, WorktreeId,
+};
+use prompt_store::PromptStore;
use rope::Point;
-use text::{Anchor, ToPoint};
+use text::{Anchor, ToPoint as _};
use ui::prelude::*;
use workspace::Workspace;
-use crate::context_picker::MentionLink;
-use crate::context_picker::file_context_picker::{extract_file_name_and_directory, search_files};
-
-#[derive(Default)]
-pub struct MentionSet {
- paths_by_crease_id: HashMap<CreaseId, ProjectPath>,
+use crate::AgentPanel;
+use crate::acp::message_editor::MessageEditor;
+use crate::context_picker::file_context_picker::{FileMatch, search_files};
+use crate::context_picker::rules_context_picker::{RulesContextEntry, search_rules};
+use crate::context_picker::symbol_context_picker::SymbolMatch;
+use crate::context_picker::symbol_context_picker::search_symbols;
+use crate::context_picker::{
+ ContextPickerAction, ContextPickerEntry, ContextPickerMode, selection_ranges,
+};
+
+pub(crate) enum Match {
+ File(FileMatch),
+ Symbol(SymbolMatch),
+ Thread(HistoryEntry),
+ RecentThread(HistoryEntry),
+ Fetch(SharedString),
+ Rules(RulesContextEntry),
+ Entry(EntryMatch),
}
-impl MentionSet {
- pub fn insert(&mut self, crease_id: CreaseId, path: ProjectPath) {
- self.paths_by_crease_id.insert(crease_id, path);
- }
-
- pub fn path_for_crease_id(&self, crease_id: CreaseId) -> Option<ProjectPath> {
- self.paths_by_crease_id.get(&crease_id).cloned()
- }
+pub struct EntryMatch {
+ mat: Option<StringMatch>,
+ entry: ContextPickerEntry,
+}
- pub fn drain(&mut self) -> impl Iterator<Item = CreaseId> {
- self.paths_by_crease_id.drain().map(|(id, _)| id)
+impl Match {
+ pub fn score(&self) -> f64 {
+ match self {
+ Match::File(file) => file.mat.score,
+ Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.),
+ Match::Thread(_) => 1.,
+ Match::RecentThread(_) => 1.,
+ Match::Symbol(_) => 1.,
+ Match::Rules(_) => 1.,
+ Match::Fetch(_) => 1.,
+ }
}
}
pub struct ContextPickerCompletionProvider {
+ message_editor: WeakEntity<MessageEditor>,
workspace: WeakEntity<Workspace>,
- editor: WeakEntity<Editor>,
- mention_set: Arc<Mutex<MentionSet>>,
+ history_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
+ prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
}
impl ContextPickerCompletionProvider {
pub fn new(
- mention_set: Arc<Mutex<MentionSet>>,
+ message_editor: WeakEntity<MessageEditor>,
workspace: WeakEntity<Workspace>,
- editor: WeakEntity<Editor>,
+ history_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
+ prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
) -> Self {
Self {
- mention_set,
+ message_editor,
workspace,
- editor,
+ history_store,
+ prompt_store,
+ prompt_capabilities,
+ }
+ }
+
+ fn completion_for_entry(
+ entry: ContextPickerEntry,
+ source_range: Range<Anchor>,
+ message_editor: WeakEntity<MessageEditor>,
+ workspace: &Entity<Workspace>,
+ cx: &mut App,
+ ) -> Option<Completion> {
+ match entry {
+ ContextPickerEntry::Mode(mode) => Some(Completion {
+ replace_range: source_range,
+ new_text: format!("@{} ", mode.keyword()),
+ label: CodeLabel::plain(mode.label().to_string(), None),
+ icon_path: Some(mode.icon().path().into()),
+ documentation: None,
+ source: project::CompletionSource::Custom,
+ insert_text_mode: None,
+ // This ensures that when a user accepts this completion, the
+ // completion menu will still be shown after "@category " is
+ // inserted
+ confirm: Some(Arc::new(|_, _, _| true)),
+ }),
+ ContextPickerEntry::Action(action) => {
+ Self::completion_for_action(action, source_range, message_editor, workspace, cx)
+ }
+ }
+ }
+
+ fn completion_for_thread(
+ thread_entry: HistoryEntry,
+ source_range: Range<Anchor>,
+ recent: bool,
+ editor: WeakEntity<MessageEditor>,
+ cx: &mut App,
+ ) -> Completion {
+ let uri = thread_entry.mention_uri();
+
+ let icon_for_completion = if recent {
+ IconName::HistoryRerun.path().into()
+ } else {
+ uri.icon_path(cx)
+ };
+
+ let new_text = format!("{} ", uri.as_link());
+
+ let new_text_len = new_text.len();
+ Completion {
+ replace_range: source_range.clone(),
+ new_text,
+ label: CodeLabel::plain(thread_entry.title().to_string(), None),
+ documentation: None,
+ insert_text_mode: None,
+ source: project::CompletionSource::Custom,
+ icon_path: Some(icon_for_completion),
+ confirm: Some(confirm_completion_callback(
+ thread_entry.title().clone(),
+ source_range.start,
+ new_text_len - 1,
+ editor,
+ uri,
+ )),
}
}
- fn completion_for_path(
+ fn completion_for_rules(
+ rule: RulesContextEntry,
+ source_range: Range<Anchor>,
+ editor: WeakEntity<MessageEditor>,
+ cx: &mut App,
+ ) -> Completion {
+ let uri = MentionUri::Rule {
+ id: rule.prompt_id.into(),
+ name: rule.title.to_string(),
+ };
+ let new_text = format!("{} ", uri.as_link());
+ let new_text_len = new_text.len();
+ let icon_path = uri.icon_path(cx);
+ Completion {
+ replace_range: source_range.clone(),
+ new_text,
+ label: CodeLabel::plain(rule.title.to_string(), None),
+ documentation: None,
+ insert_text_mode: None,
+ source: project::CompletionSource::Custom,
+ icon_path: Some(icon_path),
+ confirm: Some(confirm_completion_callback(
+ rule.title,
+ source_range.start,
+ new_text_len - 1,
+ editor,
+ uri,
+ )),
+ }
+ }
+
+ pub(crate) fn completion_for_path(
project_path: ProjectPath,
path_prefix: &str,
is_recent: bool,
is_directory: bool,
- excerpt_id: ExcerptId,
source_range: Range<Anchor>,
- editor: Entity<Editor>,
- mention_set: Arc<Mutex<MentionSet>>,
- cx: &App,
- ) -> Completion {
+ message_editor: WeakEntity<MessageEditor>,
+ project: Entity<Project>,
+ cx: &mut App,
+ ) -> Option<Completion> {
let (file_name, directory) =
- extract_file_name_and_directory(&project_path.path, path_prefix);
+ crate::context_picker::file_context_picker::extract_file_name_and_directory(
+ &project_path.path,
+ path_prefix,
+ );
let label =
build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx);
- let full_path = if let Some(directory) = directory {
- format!("{}{}", directory, file_name)
- } else {
- file_name.to_string()
- };
- let crease_icon_path = if is_directory {
- FileIcons::get_folder_icon(false, cx).unwrap_or_else(|| IconName::Folder.path().into())
+ let abs_path = project.read(cx).absolute_path(&project_path, cx)?;
+
+ let uri = if is_directory {
+ MentionUri::Directory { abs_path }
} else {
- FileIcons::get_icon(Path::new(&full_path), cx)
- .unwrap_or_else(|| IconName::File.path().into())
+ MentionUri::File { abs_path }
};
+
+ let crease_icon_path = uri.icon_path(cx);
let completion_icon_path = if is_recent {
IconName::HistoryRerun.path().into()
} else {
- crease_icon_path.clone()
+ crease_icon_path
};
- let new_text = format!("{} ", MentionLink::for_file(&file_name, &full_path));
+ let new_text = format!("{} ", uri.as_link());
let new_text_len = new_text.len();
- Completion {
+ Some(Completion {
replace_range: source_range.clone(),
new_text,
label,
@@ -104,28 +225,409 @@ impl ContextPickerCompletionProvider {
icon_path: Some(completion_icon_path),
insert_text_mode: None,
confirm: Some(confirm_completion_callback(
- crease_icon_path,
file_name,
- project_path,
- excerpt_id,
source_range.start,
new_text_len - 1,
- editor,
- mention_set,
+ message_editor,
+ uri,
)),
+ })
+ }
+
+ fn completion_for_symbol(
+ symbol: Symbol,
+ source_range: Range<Anchor>,
+ message_editor: WeakEntity<MessageEditor>,
+ workspace: Entity<Workspace>,
+ cx: &mut App,
+ ) -> Option<Completion> {
+ let project = workspace.read(cx).project().clone();
+
+ let label = CodeLabel::plain(symbol.name.clone(), None);
+
+ let abs_path = project.read(cx).absolute_path(&symbol.path, cx)?;
+ let uri = MentionUri::Symbol {
+ abs_path,
+ name: symbol.name.clone(),
+ line_range: symbol.range.start.0.row..=symbol.range.end.0.row,
+ };
+ let new_text = format!("{} ", uri.as_link());
+ let new_text_len = new_text.len();
+ let icon_path = uri.icon_path(cx);
+ Some(Completion {
+ replace_range: source_range.clone(),
+ new_text,
+ label,
+ documentation: None,
+ source: project::CompletionSource::Custom,
+ icon_path: Some(icon_path),
+ insert_text_mode: None,
+ confirm: Some(confirm_completion_callback(
+ symbol.name.into(),
+ source_range.start,
+ new_text_len - 1,
+ message_editor,
+ uri,
+ )),
+ })
+ }
+
+ fn completion_for_fetch(
+ source_range: Range<Anchor>,
+ url_to_fetch: SharedString,
+ message_editor: WeakEntity<MessageEditor>,
+ cx: &mut App,
+ ) -> Option<Completion> {
+ let new_text = format!("@fetch {} ", url_to_fetch);
+ let url_to_fetch = url::Url::parse(url_to_fetch.as_ref())
+ .or_else(|_| url::Url::parse(&format!("https://{url_to_fetch}")))
+ .ok()?;
+ let mention_uri = MentionUri::Fetch {
+ url: url_to_fetch.clone(),
+ };
+ let icon_path = mention_uri.icon_path(cx);
+ Some(Completion {
+ replace_range: source_range.clone(),
+ new_text: new_text.clone(),
+ label: CodeLabel::plain(url_to_fetch.to_string(), None),
+ documentation: None,
+ source: project::CompletionSource::Custom,
+ icon_path: Some(icon_path),
+ insert_text_mode: None,
+ confirm: Some(confirm_completion_callback(
+ url_to_fetch.to_string().into(),
+ source_range.start,
+ new_text.len() - 1,
+ message_editor,
+ mention_uri,
+ )),
+ })
+ }
+
+ pub(crate) fn completion_for_action(
+ action: ContextPickerAction,
+ source_range: Range<Anchor>,
+ message_editor: WeakEntity<MessageEditor>,
+ workspace: &Entity<Workspace>,
+ cx: &mut App,
+ ) -> Option<Completion> {
+ let (new_text, on_action) = match action {
+ ContextPickerAction::AddSelections => {
+ const PLACEHOLDER: &str = "selection ";
+ let selections = selection_ranges(workspace, cx)
+ .into_iter()
+ .enumerate()
+ .map(|(ix, (buffer, range))| {
+ (
+ buffer,
+ range,
+ (PLACEHOLDER.len() * ix)..(PLACEHOLDER.len() * (ix + 1) - 1),
+ )
+ })
+ .collect::<Vec<_>>();
+
+ let new_text: String = PLACEHOLDER.repeat(selections.len());
+
+ let callback = Arc::new({
+ let source_range = source_range.clone();
+ move |_, window: &mut Window, cx: &mut App| {
+ let selections = selections.clone();
+ let message_editor = message_editor.clone();
+ let source_range = source_range.clone();
+ window.defer(cx, move |window, cx| {
+ message_editor
+ .update(cx, |message_editor, cx| {
+ message_editor.confirm_mention_for_selection(
+ source_range,
+ selections,
+ window,
+ cx,
+ )
+ })
+ .ok();
+ });
+ false
+ }
+ });
+
+ (new_text, callback)
+ }
+ };
+
+ Some(Completion {
+ replace_range: source_range,
+ new_text,
+ label: CodeLabel::plain(action.label().to_string(), None),
+ icon_path: Some(action.icon().path().into()),
+ documentation: None,
+ source: project::CompletionSource::Custom,
+ insert_text_mode: None,
+ // This ensures that when a user accepts this completion, the
+ // completion menu will still be shown after "@category " is
+ // inserted
+ confirm: Some(on_action),
+ })
+ }
+
+ fn search(
+ &self,
+ mode: Option<ContextPickerMode>,
+ query: String,
+ cancellation_flag: Arc<AtomicBool>,
+ cx: &mut App,
+ ) -> Task<Vec<Match>> {
+ let Some(workspace) = self.workspace.upgrade() else {
+ return Task::ready(Vec::default());
+ };
+ match mode {
+ Some(ContextPickerMode::File) => {
+ let search_files_task = search_files(query, cancellation_flag, &workspace, cx);
+ cx.background_spawn(async move {
+ search_files_task
+ .await
+ .into_iter()
+ .map(Match::File)
+ .collect()
+ })
+ }
+
+ Some(ContextPickerMode::Symbol) => {
+ let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx);
+ cx.background_spawn(async move {
+ search_symbols_task
+ .await
+ .into_iter()
+ .map(Match::Symbol)
+ .collect()
+ })
+ }
+
+ Some(ContextPickerMode::Thread) => {
+ let search_threads_task =
+ search_threads(query, cancellation_flag, &self.history_store, cx);
+ cx.background_spawn(async move {
+ search_threads_task
+ .await
+ .into_iter()
+ .map(Match::Thread)
+ .collect()
+ })
+ }
+
+ Some(ContextPickerMode::Fetch) => {
+ if !query.is_empty() {
+ Task::ready(vec![Match::Fetch(query.into())])
+ } else {
+ Task::ready(Vec::new())
+ }
+ }
+
+ Some(ContextPickerMode::Rules) => {
+ if let Some(prompt_store) = self.prompt_store.as_ref() {
+ let search_rules_task =
+ search_rules(query, cancellation_flag, prompt_store, cx);
+ cx.background_spawn(async move {
+ search_rules_task
+ .await
+ .into_iter()
+ .map(Match::Rules)
+ .collect::<Vec<_>>()
+ })
+ } else {
+ Task::ready(Vec::new())
+ }
+ }
+
+ None if query.is_empty() => {
+ let mut matches = self.recent_context_picker_entries(&workspace, cx);
+
+ matches.extend(
+ self.available_context_picker_entries(&workspace, cx)
+ .into_iter()
+ .map(|mode| {
+ Match::Entry(EntryMatch {
+ entry: mode,
+ mat: None,
+ })
+ }),
+ );
+
+ Task::ready(matches)
+ }
+ None => {
+ let executor = cx.background_executor().clone();
+
+ let search_files_task =
+ search_files(query.clone(), cancellation_flag, &workspace, cx);
+
+ let entries = self.available_context_picker_entries(&workspace, cx);
+ let entry_candidates = entries
+ .iter()
+ .enumerate()
+ .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
+ .collect::<Vec<_>>();
+
+ cx.background_spawn(async move {
+ let mut matches = search_files_task
+ .await
+ .into_iter()
+ .map(Match::File)
+ .collect::<Vec<_>>();
+
+ let entry_matches = fuzzy::match_strings(
+ &entry_candidates,
+ &query,
+ false,
+ true,
+ 100,
+ &Arc::new(AtomicBool::default()),
+ executor,
+ )
+ .await;
+
+ matches.extend(entry_matches.into_iter().map(|mat| {
+ Match::Entry(EntryMatch {
+ entry: entries[mat.candidate_id],
+ mat: Some(mat),
+ })
+ }));
+
+ matches.sort_by(|a, b| {
+ b.score()
+ .partial_cmp(&a.score())
+ .unwrap_or(std::cmp::Ordering::Equal)
+ });
+
+ matches
+ })
+ }
}
}
+
+ fn recent_context_picker_entries(
+ &self,
+ workspace: &Entity<Workspace>,
+ cx: &mut App,
+ ) -> Vec<Match> {
+ let mut recent = Vec::with_capacity(6);
+
+ let mut mentions = self
+ .message_editor
+ .read_with(cx, |message_editor, _cx| message_editor.mentions())
+ .unwrap_or_default();
+ let workspace = workspace.read(cx);
+ let project = workspace.project().read(cx);
+
+ if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx)
+ && let Some(thread) = agent_panel.read(cx).active_agent_thread(cx)
+ {
+ let thread = thread.read(cx);
+ mentions.insert(MentionUri::Thread {
+ id: thread.session_id().clone(),
+ name: thread.title().into(),
+ });
+ }
+
+ recent.extend(
+ workspace
+ .recent_navigation_history_iter(cx)
+ .filter(|(_, abs_path)| {
+ abs_path.as_ref().is_none_or(|path| {
+ !mentions.contains(&MentionUri::File {
+ abs_path: path.clone(),
+ })
+ })
+ })
+ .take(4)
+ .filter_map(|(project_path, _)| {
+ project
+ .worktree_for_id(project_path.worktree_id, cx)
+ .map(|worktree| {
+ let path_prefix = worktree.read(cx).root_name().into();
+ Match::File(FileMatch {
+ mat: fuzzy::PathMatch {
+ score: 1.,
+ positions: Vec::new(),
+ worktree_id: project_path.worktree_id.to_usize(),
+ path: project_path.path,
+ path_prefix,
+ is_dir: false,
+ distance_to_relative_ancestor: 0,
+ },
+ is_recent: true,
+ })
+ })
+ }),
+ );
+
+ if self.prompt_capabilities.get().embedded_context {
+ const RECENT_COUNT: usize = 2;
+ let threads = self
+ .history_store
+ .read(cx)
+ .recently_opened_entries(cx)
+ .into_iter()
+ .filter(|thread| !mentions.contains(&thread.mention_uri()))
+ .take(RECENT_COUNT)
+ .collect::<Vec<_>>();
+
+ recent.extend(threads.into_iter().map(Match::RecentThread));
+ }
+
+ recent
+ }
+
+ fn available_context_picker_entries(
+ &self,
+ workspace: &Entity<Workspace>,
+ cx: &mut App,
+ ) -> Vec<ContextPickerEntry> {
+ let embedded_context = self.prompt_capabilities.get().embedded_context;
+ let mut entries = if embedded_context {
+ vec![
+ ContextPickerEntry::Mode(ContextPickerMode::File),
+ ContextPickerEntry::Mode(ContextPickerMode::Symbol),
+ ContextPickerEntry::Mode(ContextPickerMode::Thread),
+ ]
+ } else {
+ // File is always available, but we don't need a mode entry
+ vec![]
+ };
+
+ let has_selection = workspace
+ .read(cx)
+ .active_item(cx)
+ .and_then(|item| item.downcast::<Editor>())
+ .is_some_and(|editor| {
+ editor.update(cx, |editor, cx| editor.has_non_empty_selection(cx))
+ });
+ if has_selection {
+ entries.push(ContextPickerEntry::Action(
+ ContextPickerAction::AddSelections,
+ ));
+ }
+
+ if embedded_context {
+ if self.prompt_store.is_some() {
+ entries.push(ContextPickerEntry::Mode(ContextPickerMode::Rules));
+ }
+
+ entries.push(ContextPickerEntry::Mode(ContextPickerMode::Fetch));
+ }
+
+ entries
+ }
}
fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel {
let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
let mut label = CodeLabel::default();
- label.push_str(&file_name, None);
+ label.push_str(file_name, None);
label.push_str(" ", None);
if let Some(directory) = directory {
- label.push_str(&directory, comment_id);
+ label.push_str(directory, comment_id);
}
label.filter_range = 0..label.text().len();
@@ -136,7 +638,7 @@ fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx:
impl CompletionProvider for ContextPickerCompletionProvider {
fn completions(
&self,
- excerpt_id: ExcerptId,
+ _excerpt_id: ExcerptId,
buffer: &Entity<Buffer>,
buffer_position: Anchor,
_trigger: CompletionContext,
@@ -149,7 +651,11 @@ impl CompletionProvider for ContextPickerCompletionProvider {
let offset_to_line = buffer.point_to_offset(line_start);
let mut lines = buffer.text_for_range(line_start..position).lines();
let line = lines.next()?;
- MentionCompletion::try_parse(line, offset_to_line)
+ MentionCompletion::try_parse(
+ self.prompt_capabilities.get().embedded_context,
+ line,
+ offset_to_line,
+ )
});
let Some(state) = state else {
return Task::ready(Ok(Vec::new()));
@@ -159,44 +665,88 @@ impl CompletionProvider for ContextPickerCompletionProvider {
return Task::ready(Ok(Vec::new()));
};
+ let project = workspace.read(cx).project().clone();
let snapshot = buffer.read(cx).snapshot();
let source_range = snapshot.anchor_before(state.source_range.start)
..snapshot.anchor_after(state.source_range.end);
- let editor = self.editor.clone();
- let mention_set = self.mention_set.clone();
- let MentionCompletion { argument, .. } = state;
+ let editor = self.message_editor.clone();
+
+ let MentionCompletion { mode, argument, .. } = state;
let query = argument.unwrap_or_else(|| "".to_string());
- let search_task = search_files(query.clone(), Arc::<AtomicBool>::default(), &workspace, cx);
+ let search_task = self.search(mode, query, Arc::<AtomicBool>::default(), cx);
cx.spawn(async move |_, cx| {
let matches = search_task.await;
- let Some(editor) = editor.upgrade() else {
- return Ok(Vec::new());
- };
let completions = cx.update(|cx| {
matches
.into_iter()
- .map(|mat| {
- let path_match = &mat.mat;
- let project_path = ProjectPath {
- worktree_id: WorktreeId::from_usize(path_match.worktree_id),
- path: path_match.path.clone(),
- };
-
- Self::completion_for_path(
- project_path,
- &path_match.path_prefix,
- mat.is_recent,
- path_match.is_dir,
- excerpt_id,
+ .filter_map(|mat| match mat {
+ Match::File(FileMatch { mat, is_recent }) => {
+ let project_path = ProjectPath {
+ worktree_id: WorktreeId::from_usize(mat.worktree_id),
+ path: mat.path.clone(),
+ };
+
+ Self::completion_for_path(
+ project_path,
+ &mat.path_prefix,
+ is_recent,
+ mat.is_dir,
+ source_range.clone(),
+ editor.clone(),
+ project.clone(),
+ cx,
+ )
+ }
+
+ Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol(
+ symbol,
source_range.clone(),
editor.clone(),
- mention_set.clone(),
+ workspace.clone(),
cx,
- )
+ ),
+
+ Match::Thread(thread) => Some(Self::completion_for_thread(
+ thread,
+ source_range.clone(),
+ false,
+ editor.clone(),
+ cx,
+ )),
+
+ Match::RecentThread(thread) => Some(Self::completion_for_thread(
+ thread,
+ source_range.clone(),
+ true,
+ editor.clone(),
+ cx,
+ )),
+
+ Match::Rules(user_rules) => Some(Self::completion_for_rules(
+ user_rules,
+ source_range.clone(),
+ editor.clone(),
+ cx,
+ )),
+
+ Match::Fetch(url) => Self::completion_for_fetch(
+ source_range.clone(),
+ url,
+ editor.clone(),
+ cx,
+ ),
+
+ Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry(
+ entry,
+ source_range.clone(),
+ editor.clone(),
+ &workspace,
+ cx,
+ ),
})
.collect()
})?;
@@ -225,12 +775,16 @@ impl CompletionProvider for ContextPickerCompletionProvider {
let offset_to_line = buffer.point_to_offset(line_start);
let mut lines = buffer.text_for_range(line_start..position).lines();
if let Some(line) = lines.next() {
- MentionCompletion::try_parse(line, offset_to_line)
- .map(|completion| {
- completion.source_range.start <= offset_to_line + position.column as usize
- && completion.source_range.end >= offset_to_line + position.column as usize
- })
- .unwrap_or(false)
+ MentionCompletion::try_parse(
+ self.prompt_capabilities.get().embedded_context,
+ line,
+ offset_to_line,
+ )
+ .map(|completion| {
+ completion.source_range.start <= offset_to_line + position.column as usize
+ && completion.source_range.end >= offset_to_line + position.column as usize
+ })
+ .unwrap_or(false)
} else {
false
}
@@ -245,36 +799,69 @@ impl CompletionProvider for ContextPickerCompletionProvider {
}
}
+pub(crate) fn search_threads(
+ query: String,
+ cancellation_flag: Arc<AtomicBool>,
+ history_store: &Entity<HistoryStore>,
+ cx: &mut App,
+) -> Task<Vec<HistoryEntry>> {
+ let threads = history_store.read(cx).entries().collect();
+ if query.is_empty() {
+ return Task::ready(threads);
+ }
+
+ let executor = cx.background_executor().clone();
+ cx.background_spawn(async move {
+ let candidates = threads
+ .iter()
+ .enumerate()
+ .map(|(id, thread)| StringMatchCandidate::new(id, thread.title()))
+ .collect::<Vec<_>>();
+ let matches = fuzzy::match_strings(
+ &candidates,
+ &query,
+ false,
+ true,
+ 100,
+ &cancellation_flag,
+ executor,
+ )
+ .await;
+
+ matches
+ .into_iter()
+ .map(|mat| threads[mat.candidate_id].clone())
+ .collect()
+ })
+}
+
fn confirm_completion_callback(
- crease_icon_path: SharedString,
crease_text: SharedString,
- project_path: ProjectPath,
- excerpt_id: ExcerptId,
start: Anchor,
content_len: usize,
- editor: Entity<Editor>,
- mention_set: Arc<Mutex<MentionSet>>,
+ message_editor: WeakEntity<MessageEditor>,
+ mention_uri: MentionUri,
) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
Arc::new(move |_, window, cx| {
+ let message_editor = message_editor.clone();
let crease_text = crease_text.clone();
- let crease_icon_path = crease_icon_path.clone();
- let editor = editor.clone();
- let project_path = project_path.clone();
- let mention_set = mention_set.clone();
+ let mention_uri = mention_uri.clone();
window.defer(cx, move |window, cx| {
- let crease_id = crate::context_picker::insert_crease_for_mention(
- excerpt_id,
- start,
- content_len,
- crease_text.clone(),
- crease_icon_path,
- editor.clone(),
- window,
- cx,
- );
- if let Some(crease_id) = crease_id {
- mention_set.lock().insert(crease_id, project_path);
- }
+ message_editor
+ .clone()
+ .update(cx, |message_editor, cx| {
+ message_editor
+ .confirm_completion(
+ crease_text,
+ start,
+ content_len,
+ mention_uri,
+ window,
+ cx,
+ )
+ .detach();
+ })
+ .ok();
});
false
})
@@ -283,11 +870,12 @@ fn confirm_completion_callback(
#[derive(Debug, Default, PartialEq)]
struct MentionCompletion {
source_range: Range<usize>,
+ mode: Option<ContextPickerMode>,
argument: Option<String>,
}
impl MentionCompletion {
- fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
+ fn try_parse(allow_non_file_mentions: bool, line: &str, offset_to_line: usize) -> Option<Self> {
let last_mention_start = line.rfind('@')?;
if last_mention_start >= line.len() {
return Some(Self::default());
@@ -296,23 +884,45 @@ impl MentionCompletion {
&& line
.chars()
.nth(last_mention_start - 1)
- .map_or(false, |c| !c.is_whitespace())
+ .is_some_and(|c| !c.is_whitespace())
{
return None;
}
let rest_of_line = &line[last_mention_start + 1..];
+
+ let mut mode = None;
let mut argument = None;
let mut parts = rest_of_line.split_whitespace();
let mut end = last_mention_start + 1;
- if let Some(argument_text) = parts.next() {
- end += argument_text.len();
- argument = Some(argument_text.to_string());
+ if let Some(mode_text) = parts.next() {
+ end += mode_text.len();
+
+ if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok()
+ && (allow_non_file_mentions || matches!(parsed_mode, ContextPickerMode::File))
+ {
+ mode = Some(parsed_mode);
+ } else {
+ argument = Some(mode_text.to_string());
+ }
+ match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) {
+ Some(whitespace_count) => {
+ if let Some(argument_text) = parts.next() {
+ argument = Some(argument_text.to_string());
+ end += whitespace_count + argument_text.len();
+ }
+ }
+ None => {
+ // Rest of line is entirely whitespace
+ end += rest_of_line.len() - mode_text.len();
+ }
+ }
}
Some(Self {
source_range: last_mention_start + offset_to_line..end + offset_to_line,
+ mode,
argument,
})
}
@@ -321,254 +931,96 @@ impl MentionCompletion {
#[cfg(test)]
mod tests {
use super::*;
- use gpui::{EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext};
- use project::{Project, ProjectPath};
- use serde_json::json;
- use settings::SettingsStore;
- use std::{ops::Deref, rc::Rc};
- use util::path;
- use workspace::{AppState, Item};
#[test]
fn test_mention_completion_parse() {
- assert_eq!(MentionCompletion::try_parse("Lorem Ipsum", 0), None);
+ assert_eq!(MentionCompletion::try_parse(true, "Lorem Ipsum", 0), None);
assert_eq!(
- MentionCompletion::try_parse("Lorem @", 0),
+ MentionCompletion::try_parse(true, "Lorem @", 0),
Some(MentionCompletion {
source_range: 6..7,
+ mode: None,
argument: None,
})
);
assert_eq!(
- MentionCompletion::try_parse("Lorem @main", 0),
+ MentionCompletion::try_parse(true, "Lorem @file", 0),
Some(MentionCompletion {
source_range: 6..11,
- argument: Some("main".to_string()),
+ mode: Some(ContextPickerMode::File),
+ argument: None,
})
);
- assert_eq!(MentionCompletion::try_parse("test@", 0), None);
- }
-
- struct AtMentionEditor(Entity<Editor>);
-
- impl Item for AtMentionEditor {
- type Event = ();
-
- fn include_in_nav_history() -> bool {
- false
- }
-
- fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
- "Test".into()
- }
- }
-
- impl EventEmitter<()> for AtMentionEditor {}
-
- impl Focusable for AtMentionEditor {
- fn focus_handle(&self, cx: &App) -> FocusHandle {
- self.0.read(cx).focus_handle(cx).clone()
- }
- }
-
- impl Render for AtMentionEditor {
- fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
- self.0.clone().into_any_element()
- }
- }
-
- #[gpui::test]
- async fn test_context_completion_provider(cx: &mut TestAppContext) {
- init_test(cx);
-
- let app_state = cx.update(AppState::test);
-
- cx.update(|cx| {
- language::init(cx);
- editor::init(cx);
- workspace::init(app_state.clone(), cx);
- Project::init_settings(cx);
- });
-
- app_state
- .fs
- .as_fake()
- .insert_tree(
- path!("/dir"),
- json!({
- "editor": "",
- "a": {
- "one.txt": "",
- "two.txt": "",
- "three.txt": "",
- "four.txt": ""
- },
- "b": {
- "five.txt": "",
- "six.txt": "",
- "seven.txt": "",
- "eight.txt": "",
- }
- }),
- )
- .await;
-
- let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
- let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
- let workspace = window.root(cx).unwrap();
-
- let worktree = project.update(cx, |project, cx| {
- let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
- assert_eq!(worktrees.len(), 1);
- worktrees.pop().unwrap()
- });
- let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
-
- let mut cx = VisualTestContext::from_window(*window.deref(), cx);
-
- let paths = vec![
- path!("a/one.txt"),
- path!("a/two.txt"),
- path!("a/three.txt"),
- path!("a/four.txt"),
- path!("b/five.txt"),
- path!("b/six.txt"),
- path!("b/seven.txt"),
- path!("b/eight.txt"),
- ];
-
- let mut opened_editors = Vec::new();
- for path in paths {
- let buffer = workspace
- .update_in(&mut cx, |workspace, window, cx| {
- workspace.open_path(
- ProjectPath {
- worktree_id,
- path: Path::new(path).into(),
- },
- None,
- false,
- window,
- cx,
- )
- })
- .await
- .unwrap();
- opened_editors.push(buffer);
- }
-
- let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
- let editor = cx.new(|cx| {
- Editor::new(
- editor::EditorMode::full(),
- multi_buffer::MultiBuffer::build_simple("", cx),
- None,
- window,
- cx,
- )
- });
- workspace.active_pane().update(cx, |pane, cx| {
- pane.add_item(
- Box::new(cx.new(|_| AtMentionEditor(editor.clone()))),
- true,
- true,
- None,
- window,
- cx,
- );
- });
- editor
- });
-
- let mention_set = Arc::new(Mutex::new(MentionSet::default()));
+ assert_eq!(
+ MentionCompletion::try_parse(true, "Lorem @file ", 0),
+ Some(MentionCompletion {
+ source_range: 6..12,
+ mode: Some(ContextPickerMode::File),
+ argument: None,
+ })
+ );
- let editor_entity = editor.downgrade();
- editor.update_in(&mut cx, |editor, window, cx| {
- window.focus(&editor.focus_handle(cx));
- editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
- mention_set.clone(),
- workspace.downgrade(),
- editor_entity,
- ))));
- });
+ assert_eq!(
+ MentionCompletion::try_parse(true, "Lorem @file main.rs", 0),
+ Some(MentionCompletion {
+ source_range: 6..19,
+ mode: Some(ContextPickerMode::File),
+ argument: Some("main.rs".to_string()),
+ })
+ );
- cx.simulate_input("Lorem ");
+ assert_eq!(
+ MentionCompletion::try_parse(true, "Lorem @file main.rs ", 0),
+ Some(MentionCompletion {
+ source_range: 6..19,
+ mode: Some(ContextPickerMode::File),
+ argument: Some("main.rs".to_string()),
+ })
+ );
- editor.update(&mut cx, |editor, cx| {
- assert_eq!(editor.text(cx), "Lorem ");
- assert!(!editor.has_visible_completions_menu());
- });
+ assert_eq!(
+ MentionCompletion::try_parse(true, "Lorem @file main.rs Ipsum", 0),
+ Some(MentionCompletion {
+ source_range: 6..19,
+ mode: Some(ContextPickerMode::File),
+ argument: Some("main.rs".to_string()),
+ })
+ );
- cx.simulate_input("@");
-
- editor.update(&mut cx, |editor, cx| {
- assert_eq!(editor.text(cx), "Lorem @");
- assert!(editor.has_visible_completions_menu());
- assert_eq!(
- current_completion_labels(editor),
- &[
- "eight.txt dir/b/",
- "seven.txt dir/b/",
- "six.txt dir/b/",
- "five.txt dir/b/",
- "four.txt dir/a/",
- "three.txt dir/a/",
- "two.txt dir/a/",
- "one.txt dir/a/",
- "dir ",
- "a dir/",
- "four.txt dir/a/",
- "one.txt dir/a/",
- "three.txt dir/a/",
- "two.txt dir/a/",
- "b dir/",
- "eight.txt dir/b/",
- "five.txt dir/b/",
- "seven.txt dir/b/",
- "six.txt dir/b/",
- "editor dir/"
- ]
- );
- });
+ assert_eq!(
+ MentionCompletion::try_parse(true, "Lorem @main", 0),
+ Some(MentionCompletion {
+ source_range: 6..11,
+ mode: None,
+ argument: Some("main".to_string()),
+ })
+ );
- // Select and confirm "File"
- editor.update_in(&mut cx, |editor, window, cx| {
- assert!(editor.has_visible_completions_menu());
- editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
- editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
- editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
- editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
- editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
- });
+ assert_eq!(MentionCompletion::try_parse(true, "test@", 0), None);
- cx.run_until_parked();
+ // Allowed non-file mentions
- editor.update(&mut cx, |editor, cx| {
- assert_eq!(editor.text(cx), "Lorem [@four.txt](@file:dir/a/four.txt) ");
- });
- }
+ assert_eq!(
+ MentionCompletion::try_parse(true, "Lorem @symbol main", 0),
+ Some(MentionCompletion {
+ source_range: 6..18,
+ mode: Some(ContextPickerMode::Symbol),
+ argument: Some("main".to_string()),
+ })
+ );
- fn current_completion_labels(editor: &Editor) -> Vec<String> {
- let completions = editor.current_completions().expect("Missing completions");
- completions
- .into_iter()
- .map(|completion| completion.label.text.to_string())
- .collect::<Vec<_>>()
- }
+ // Disallowed non-file mentions
- pub(crate) fn init_test(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let store = SettingsStore::test(cx);
- cx.set_global(store);
- theme::init(theme::LoadThemes::JustBase, cx);
- client::init_settings(cx);
- language::init(cx);
- Project::init_settings(cx);
- workspace::init_settings(cx);
- editor::init_settings(cx);
- });
+ assert_eq!(
+ MentionCompletion::try_parse(false, "Lorem @symbol main", 0),
+ Some(MentionCompletion {
+ source_range: 6..18,
+ mode: None,
+ argument: Some("main".to_string()),
+ })
+ );
}
}
@@ -0,0 +1,482 @@
+use std::{cell::Cell, ops::Range, rc::Rc};
+
+use acp_thread::{AcpThread, AgentThreadEntry};
+use agent_client_protocol::{PromptCapabilities, ToolCallId};
+use agent2::HistoryStore;
+use collections::HashMap;
+use editor::{Editor, EditorMode, MinimapVisibility};
+use gpui::{
+ AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, Focusable,
+ TextStyleRefinement, WeakEntity, Window,
+};
+use language::language_settings::SoftWrap;
+use project::Project;
+use prompt_store::PromptStore;
+use settings::Settings as _;
+use terminal_view::TerminalView;
+use theme::ThemeSettings;
+use ui::{Context, TextSize};
+use workspace::Workspace;
+
+use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
+
+pub struct EntryViewState {
+ workspace: WeakEntity<Workspace>,
+ project: Entity<Project>,
+ history_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
+ entries: Vec<Entry>,
+ prevent_slash_commands: bool,
+ prompt_capabilities: Rc<Cell<PromptCapabilities>>,
+}
+
+impl EntryViewState {
+ pub fn new(
+ workspace: WeakEntity<Workspace>,
+ project: Entity<Project>,
+ history_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
+ prompt_capabilities: Rc<Cell<PromptCapabilities>>,
+ prevent_slash_commands: bool,
+ ) -> Self {
+ Self {
+ workspace,
+ project,
+ history_store,
+ prompt_store,
+ entries: Vec::new(),
+ prevent_slash_commands,
+ prompt_capabilities,
+ }
+ }
+
+ pub fn entry(&self, index: usize) -> Option<&Entry> {
+ self.entries.get(index)
+ }
+
+ pub fn sync_entry(
+ &mut self,
+ index: usize,
+ thread: &Entity<AcpThread>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(thread_entry) = thread.read(cx).entries().get(index) else {
+ return;
+ };
+
+ match thread_entry {
+ AgentThreadEntry::UserMessage(message) => {
+ let has_id = message.id.is_some();
+ let chunks = message.chunks.clone();
+ if let Some(Entry::UserMessage(editor)) = self.entries.get_mut(index) {
+ if !editor.focus_handle(cx).is_focused(window) {
+ // Only update if we are not editing.
+ // If we are, cancelling the edit will set the message to the newest content.
+ editor.update(cx, |editor, cx| {
+ editor.set_message(chunks, window, cx);
+ });
+ }
+ } else {
+ let message_editor = cx.new(|cx| {
+ let mut editor = MessageEditor::new(
+ self.workspace.clone(),
+ self.project.clone(),
+ self.history_store.clone(),
+ self.prompt_store.clone(),
+ self.prompt_capabilities.clone(),
+ "Edit message - @ to include context",
+ self.prevent_slash_commands,
+ editor::EditorMode::AutoHeight {
+ min_lines: 1,
+ max_lines: None,
+ },
+ window,
+ cx,
+ );
+ if !has_id {
+ editor.set_read_only(true, cx);
+ }
+ editor.set_message(chunks, window, cx);
+ editor
+ });
+ cx.subscribe(&message_editor, move |_, editor, event, cx| {
+ cx.emit(EntryViewEvent {
+ entry_index: index,
+ view_event: ViewEvent::MessageEditorEvent(editor, *event),
+ })
+ })
+ .detach();
+ self.set_entry(index, Entry::UserMessage(message_editor));
+ }
+ }
+ AgentThreadEntry::ToolCall(tool_call) => {
+ let id = tool_call.id.clone();
+ let terminals = tool_call.terminals().cloned().collect::<Vec<_>>();
+ let diffs = tool_call.diffs().cloned().collect::<Vec<_>>();
+
+ let views = if let Some(Entry::Content(views)) = self.entries.get_mut(index) {
+ views
+ } else {
+ self.set_entry(index, Entry::empty());
+ let Some(Entry::Content(views)) = self.entries.get_mut(index) else {
+ unreachable!()
+ };
+ views
+ };
+
+ for terminal in terminals {
+ views.entry(terminal.entity_id()).or_insert_with(|| {
+ let element = create_terminal(
+ self.workspace.clone(),
+ self.project.clone(),
+ terminal.clone(),
+ window,
+ cx,
+ )
+ .into_any();
+ cx.emit(EntryViewEvent {
+ entry_index: index,
+ view_event: ViewEvent::NewTerminal(id.clone()),
+ });
+ element
+ });
+ }
+
+ for diff in diffs {
+ views.entry(diff.entity_id()).or_insert_with(|| {
+ let element = create_editor_diff(diff.clone(), window, cx).into_any();
+ cx.emit(EntryViewEvent {
+ entry_index: index,
+ view_event: ViewEvent::NewDiff(id.clone()),
+ });
+ element
+ });
+ }
+ }
+ AgentThreadEntry::AssistantMessage(_) => {
+ if index == self.entries.len() {
+ self.entries.push(Entry::empty())
+ }
+ }
+ };
+ }
+
+ fn set_entry(&mut self, index: usize, entry: Entry) {
+ if index == self.entries.len() {
+ self.entries.push(entry);
+ } else {
+ self.entries[index] = entry;
+ }
+ }
+
+ pub fn remove(&mut self, range: Range<usize>) {
+ self.entries.drain(range);
+ }
+
+ pub fn settings_changed(&mut self, cx: &mut App) {
+ for entry in self.entries.iter() {
+ match entry {
+ Entry::UserMessage { .. } => {}
+ Entry::Content(response_views) => {
+ for view in response_views.values() {
+ if let Ok(diff_editor) = view.clone().downcast::<Editor>() {
+ diff_editor.update(cx, |diff_editor, cx| {
+ diff_editor.set_text_style_refinement(
+ diff_editor_text_style_refinement(cx),
+ );
+ cx.notify();
+ })
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+impl EventEmitter<EntryViewEvent> for EntryViewState {}
+
+pub struct EntryViewEvent {
+ pub entry_index: usize,
+ pub view_event: ViewEvent,
+}
+
+pub enum ViewEvent {
+ NewDiff(ToolCallId),
+ NewTerminal(ToolCallId),
+ MessageEditorEvent(Entity<MessageEditor>, MessageEditorEvent),
+}
+
+#[derive(Debug)]
+pub enum Entry {
+ UserMessage(Entity<MessageEditor>),
+ Content(HashMap<EntityId, AnyEntity>),
+}
+
+impl Entry {
+ pub fn message_editor(&self) -> Option<&Entity<MessageEditor>> {
+ match self {
+ Self::UserMessage(editor) => Some(editor),
+ Entry::Content(_) => None,
+ }
+ }
+
+ pub fn editor_for_diff(&self, diff: &Entity<acp_thread::Diff>) -> Option<Entity<Editor>> {
+ self.content_map()?
+ .get(&diff.entity_id())
+ .cloned()
+ .map(|entity| entity.downcast::<Editor>().unwrap())
+ }
+
+ pub fn terminal(
+ &self,
+ terminal: &Entity<acp_thread::Terminal>,
+ ) -> Option<Entity<TerminalView>> {
+ self.content_map()?
+ .get(&terminal.entity_id())
+ .cloned()
+ .map(|entity| entity.downcast::<TerminalView>().unwrap())
+ }
+
+ fn content_map(&self) -> Option<&HashMap<EntityId, AnyEntity>> {
+ match self {
+ Self::Content(map) => Some(map),
+ _ => None,
+ }
+ }
+
+ fn empty() -> Self {
+ Self::Content(HashMap::default())
+ }
+
+ #[cfg(test)]
+ pub fn has_content(&self) -> bool {
+ match self {
+ Self::Content(map) => !map.is_empty(),
+ Self::UserMessage(_) => false,
+ }
+ }
+}
+
+fn create_terminal(
+ workspace: WeakEntity<Workspace>,
+ project: Entity<Project>,
+ terminal: Entity<acp_thread::Terminal>,
+ window: &mut Window,
+ cx: &mut App,
+) -> Entity<TerminalView> {
+ cx.new(|cx| {
+ let mut view = TerminalView::new(
+ terminal.read(cx).inner().clone(),
+ workspace.clone(),
+ None,
+ project.downgrade(),
+ window,
+ cx,
+ );
+ view.set_embedded_mode(Some(1000), cx);
+ view
+ })
+}
+
+fn create_editor_diff(
+ diff: Entity<acp_thread::Diff>,
+ window: &mut Window,
+ cx: &mut App,
+) -> Entity<Editor> {
+ cx.new(|cx| {
+ let mut editor = Editor::new(
+ EditorMode::Full {
+ scale_ui_elements_with_buffer_font_size: false,
+ show_active_line_background: false,
+ sized_by_content: true,
+ },
+ diff.read(cx).multibuffer().clone(),
+ None,
+ window,
+ cx,
+ );
+ editor.set_show_gutter(false, cx);
+ editor.disable_inline_diagnostics();
+ editor.disable_expand_excerpt_buttons(cx);
+ editor.set_show_vertical_scrollbar(false, cx);
+ editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
+ editor.set_soft_wrap_mode(SoftWrap::None, cx);
+ editor.scroll_manager.set_forbid_vertical_scroll(true);
+ editor.set_show_indent_guides(false, cx);
+ editor.set_read_only(true);
+ editor.set_show_breakpoints(false, cx);
+ editor.set_show_code_actions(false, cx);
+ editor.set_show_git_diff_gutter(false, cx);
+ editor.set_expand_all_diff_hunks(cx);
+ editor.set_text_style_refinement(diff_editor_text_style_refinement(cx));
+ editor
+ })
+}
+
+fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement {
+ TextStyleRefinement {
+ font_size: Some(
+ TextSize::Small
+ .rems(cx)
+ .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
+ .into(),
+ ),
+ ..Default::default()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{path::Path, rc::Rc};
+
+ use acp_thread::{AgentConnection, StubAgentConnection};
+ use agent_client_protocol as acp;
+ use agent_settings::AgentSettings;
+ use agent2::HistoryStore;
+ use assistant_context::ContextStore;
+ use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
+ use editor::{EditorSettings, RowInfo};
+ use fs::FakeFs;
+ use gpui::{AppContext as _, SemanticVersion, TestAppContext};
+
+ use crate::acp::entry_view_state::EntryViewState;
+ use multi_buffer::MultiBufferRow;
+ use pretty_assertions::assert_matches;
+ use project::Project;
+ use serde_json::json;
+ use settings::{Settings as _, SettingsStore};
+ use theme::ThemeSettings;
+ use util::path;
+ use workspace::Workspace;
+
+ #[gpui::test]
+ async fn test_diff_sync(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/project",
+ json!({
+ "hello.txt": "hi world"
+ }),
+ )
+ .await;
+ let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
+
+ let (workspace, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+ let tool_call = acp::ToolCall {
+ id: acp::ToolCallId("tool".into()),
+ title: "Tool call".into(),
+ kind: acp::ToolKind::Other,
+ status: acp::ToolCallStatus::InProgress,
+ content: vec![acp::ToolCallContent::Diff {
+ diff: acp::Diff {
+ path: "/project/hello.txt".into(),
+ old_text: Some("hi world".into()),
+ new_text: "hello world".into(),
+ },
+ }],
+ locations: vec![],
+ raw_input: None,
+ raw_output: None,
+ };
+ let connection = Rc::new(StubAgentConnection::new());
+ let thread = cx
+ .update(|_, cx| {
+ connection
+ .clone()
+ .new_thread(project.clone(), Path::new(path!("/project")), cx)
+ })
+ .await
+ .unwrap();
+ let session_id = thread.update(cx, |thread, _| thread.session_id().clone());
+
+ cx.update(|_, cx| {
+ connection.send_update(session_id, acp::SessionUpdate::ToolCall(tool_call), cx)
+ });
+
+ let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
+ let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
+
+ let view_state = cx.new(|_cx| {
+ EntryViewState::new(
+ workspace.downgrade(),
+ project.clone(),
+ history_store,
+ None,
+ Default::default(),
+ false,
+ )
+ });
+
+ view_state.update_in(cx, |view_state, window, cx| {
+ view_state.sync_entry(0, &thread, window, cx)
+ });
+
+ let diff = thread.read_with(cx, |thread, _cx| {
+ thread
+ .entries()
+ .get(0)
+ .unwrap()
+ .diffs()
+ .next()
+ .unwrap()
+ .clone()
+ });
+
+ cx.run_until_parked();
+
+ let diff_editor = view_state.read_with(cx, |view_state, _cx| {
+ view_state.entry(0).unwrap().editor_for_diff(&diff).unwrap()
+ });
+ assert_eq!(
+ diff_editor.read_with(cx, |editor, cx| editor.text(cx)),
+ "hi world\nhello world"
+ );
+ let row_infos = diff_editor.read_with(cx, |editor, cx| {
+ let multibuffer = editor.buffer().read(cx);
+ multibuffer
+ .snapshot(cx)
+ .row_infos(MultiBufferRow(0))
+ .collect::<Vec<_>>()
+ });
+ assert_matches!(
+ row_infos.as_slice(),
+ [
+ RowInfo {
+ multibuffer_row: Some(MultiBufferRow(0)),
+ diff_status: Some(DiffHunkStatus {
+ kind: DiffHunkStatusKind::Deleted,
+ ..
+ }),
+ ..
+ },
+ RowInfo {
+ multibuffer_row: Some(MultiBufferRow(1)),
+ diff_status: Some(DiffHunkStatus {
+ kind: DiffHunkStatusKind::Added,
+ ..
+ }),
+ ..
+ }
+ ]
+ );
+ }
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ language::init(cx);
+ Project::init_settings(cx);
+ AgentSettings::register(cx);
+ workspace::init_settings(cx);
+ ThemeSettings::register(cx);
+ release_channel::init(SemanticVersion::default(), cx);
+ EditorSettings::register(cx);
+ });
+ }
+}
@@ -0,0 +1,2286 @@
+use crate::{
+ acp::completion_provider::ContextPickerCompletionProvider,
+ context_picker::{ContextPickerAction, fetch_context_picker::fetch_url_content},
+};
+use acp_thread::{MentionUri, selection_name};
+use agent_client_protocol as acp;
+use agent_servers::AgentServer;
+use agent2::HistoryStore;
+use anyhow::{Result, anyhow};
+use assistant_slash_commands::codeblock_fence_for_path;
+use collections::{HashMap, HashSet};
+use editor::{
+ Addon, Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
+ EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer,
+ SemanticsProvider, ToOffset,
+ actions::Paste,
+ display_map::{Crease, CreaseId, FoldId},
+};
+use futures::{
+ FutureExt as _,
+ future::{Shared, join_all},
+};
+use gpui::{
+ Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Entity, EntityId,
+ EventEmitter, FocusHandle, Focusable, HighlightStyle, Image, ImageFormat, Img, KeyContext,
+ Subscription, Task, TextStyle, UnderlineStyle, WeakEntity, pulsating_between,
+};
+use language::{Buffer, Language};
+use language_model::LanguageModelImage;
+use postage::stream::Stream as _;
+use project::{CompletionIntent, Project, ProjectItem, ProjectPath, Worktree};
+use prompt_store::{PromptId, PromptStore};
+use rope::Point;
+use settings::Settings;
+use std::{
+ cell::Cell,
+ ffi::OsStr,
+ fmt::Write,
+ ops::{Range, RangeInclusive},
+ path::{Path, PathBuf},
+ rc::Rc,
+ sync::Arc,
+ time::Duration,
+};
+use text::{OffsetRangeExt, ToOffset as _};
+use theme::ThemeSettings;
+use ui::{
+ ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Element as _,
+ FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label,
+ LabelCommon, LabelSize, ParentElement, Render, SelectableButton, SharedString, Styled,
+ TextSize, TintColor, Toggleable, Window, div, h_flex, px,
+};
+use util::{ResultExt, debug_panic};
+use workspace::{Workspace, notifications::NotifyResultExt as _};
+use zed_actions::agent::Chat;
+
+const PARSE_SLASH_COMMAND_DEBOUNCE: Duration = Duration::from_millis(50);
+
+pub struct MessageEditor {
+ mention_set: MentionSet,
+ editor: Entity<Editor>,
+ project: Entity<Project>,
+ workspace: WeakEntity<Workspace>,
+ history_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
+ prevent_slash_commands: bool,
+ prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
+ _subscriptions: Vec<Subscription>,
+ _parse_slash_command_task: Task<()>,
+}
+
+#[derive(Clone, Copy, Debug)]
+pub enum MessageEditorEvent {
+ Send,
+ Cancel,
+ Focus,
+ LostFocus,
+}
+
+impl EventEmitter<MessageEditorEvent> for MessageEditor {}
+
+impl MessageEditor {
+ pub fn new(
+ workspace: WeakEntity<Workspace>,
+ project: Entity<Project>,
+ history_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
+ prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
+ placeholder: impl Into<Arc<str>>,
+ prevent_slash_commands: bool,
+ mode: EditorMode,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let language = Language::new(
+ language::LanguageConfig {
+ completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
+ ..Default::default()
+ },
+ None,
+ );
+ let completion_provider = ContextPickerCompletionProvider::new(
+ cx.weak_entity(),
+ workspace.clone(),
+ history_store.clone(),
+ prompt_store.clone(),
+ prompt_capabilities.clone(),
+ );
+ let semantics_provider = Rc::new(SlashCommandSemanticsProvider {
+ range: Cell::new(None),
+ });
+ let mention_set = MentionSet::default();
+ let editor = cx.new(|cx| {
+ let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
+ let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+
+ let mut editor = Editor::new(mode, buffer, None, window, cx);
+ editor.set_placeholder_text(placeholder, cx);
+ editor.set_show_indent_guides(false, cx);
+ editor.set_soft_wrap();
+ editor.set_use_modal_editing(true);
+ editor.set_completion_provider(Some(Rc::new(completion_provider)));
+ editor.set_context_menu_options(ContextMenuOptions {
+ min_entries_visible: 12,
+ max_entries_visible: 12,
+ placement: Some(ContextMenuPlacement::Above),
+ });
+ if prevent_slash_commands {
+ editor.set_semantics_provider(Some(semantics_provider.clone()));
+ }
+ editor.register_addon(MessageEditorAddon::new());
+ editor
+ });
+
+ cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| {
+ cx.emit(MessageEditorEvent::Focus)
+ })
+ .detach();
+ cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| {
+ cx.emit(MessageEditorEvent::LostFocus)
+ })
+ .detach();
+
+ let mut subscriptions = Vec::new();
+ subscriptions.push(cx.subscribe_in(&editor, window, {
+ let semantics_provider = semantics_provider.clone();
+ move |this, editor, event, window, cx| {
+ if let EditorEvent::Edited { .. } = event {
+ if prevent_slash_commands {
+ this.highlight_slash_command(
+ semantics_provider.clone(),
+ editor.clone(),
+ window,
+ cx,
+ );
+ }
+ let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx));
+ this.mention_set.remove_invalid(snapshot);
+ cx.notify();
+ }
+ }
+ }));
+
+ Self {
+ editor,
+ project,
+ mention_set,
+ workspace,
+ history_store,
+ prompt_store,
+ prevent_slash_commands,
+ prompt_capabilities,
+ _subscriptions: subscriptions,
+ _parse_slash_command_task: Task::ready(()),
+ }
+ }
+
+ pub fn insert_thread_summary(
+ &mut self,
+ thread: agent2::DbThreadMetadata,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let start = self.editor.update(cx, |editor, cx| {
+ editor.set_text(format!("{}\n", thread.title), window, cx);
+ editor
+ .buffer()
+ .read(cx)
+ .snapshot(cx)
+ .anchor_before(Point::zero())
+ .text_anchor
+ });
+
+ self.confirm_completion(
+ thread.title.clone(),
+ start,
+ thread.title.len(),
+ MentionUri::Thread {
+ id: thread.id.clone(),
+ name: thread.title.to_string(),
+ },
+ window,
+ cx,
+ )
+ .detach();
+ }
+
+ #[cfg(test)]
+ pub(crate) fn editor(&self) -> &Entity<Editor> {
+ &self.editor
+ }
+
+ #[cfg(test)]
+ pub(crate) fn mention_set(&mut self) -> &mut MentionSet {
+ &mut self.mention_set
+ }
+
+ pub fn is_empty(&self, cx: &App) -> bool {
+ self.editor.read(cx).is_empty(cx)
+ }
+
+ pub fn mentions(&self) -> HashSet<MentionUri> {
+ self.mention_set
+ .mentions
+ .values()
+ .map(|(uri, _)| uri.clone())
+ .collect()
+ }
+
+ pub fn confirm_completion(
+ &mut self,
+ crease_text: SharedString,
+ start: text::Anchor,
+ content_len: usize,
+ mention_uri: MentionUri,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Task<()> {
+ let snapshot = self
+ .editor
+ .update(cx, |editor, cx| editor.snapshot(window, cx));
+ let Some((excerpt_id, _, _)) = snapshot.buffer_snapshot.as_singleton() else {
+ return Task::ready(());
+ };
+ let Some(start_anchor) = snapshot
+ .buffer_snapshot
+ .anchor_in_excerpt(*excerpt_id, start)
+ else {
+ return Task::ready(());
+ };
+ let end_anchor = snapshot
+ .buffer_snapshot
+ .anchor_before(start_anchor.to_offset(&snapshot.buffer_snapshot) + content_len + 1);
+
+ let crease = if let MentionUri::File { abs_path } = &mention_uri
+ && let Some(extension) = abs_path.extension()
+ && let Some(extension) = extension.to_str()
+ && Img::extensions().contains(&extension)
+ && !extension.contains("svg")
+ {
+ let Some(project_path) = self
+ .project
+ .read(cx)
+ .project_path_for_absolute_path(&abs_path, cx)
+ else {
+ log::error!("project path not found");
+ return Task::ready(());
+ };
+ let image = self
+ .project
+ .update(cx, |project, cx| project.open_image(project_path, cx));
+ let image = cx
+ .spawn(async move |_, cx| {
+ let image = image.await.map_err(|e| e.to_string())?;
+ let image = image
+ .update(cx, |image, _| image.image.clone())
+ .map_err(|e| e.to_string())?;
+ Ok(image)
+ })
+ .shared();
+ insert_crease_for_mention(
+ *excerpt_id,
+ start,
+ content_len,
+ mention_uri.name().into(),
+ IconName::Image.path().into(),
+ Some(image),
+ self.editor.clone(),
+ window,
+ cx,
+ )
+ } else {
+ insert_crease_for_mention(
+ *excerpt_id,
+ start,
+ content_len,
+ crease_text,
+ mention_uri.icon_path(cx),
+ None,
+ self.editor.clone(),
+ window,
+ cx,
+ )
+ };
+ let Some((crease_id, tx)) = crease else {
+ return Task::ready(());
+ };
+
+ let task = match mention_uri.clone() {
+ MentionUri::Fetch { url } => self.confirm_mention_for_fetch(url, cx),
+ MentionUri::Directory { abs_path } => self.confirm_mention_for_directory(abs_path, cx),
+ MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx),
+ MentionUri::TextThread { path, .. } => self.confirm_mention_for_text_thread(path, cx),
+ MentionUri::File { abs_path } => self.confirm_mention_for_file(abs_path, cx),
+ MentionUri::Symbol {
+ abs_path,
+ line_range,
+ ..
+ } => self.confirm_mention_for_symbol(abs_path, line_range, cx),
+ MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
+ MentionUri::PastedImage => {
+ debug_panic!("pasted image URI should not be included in completions");
+ Task::ready(Err(anyhow!(
+ "pasted imaged URI should not be included in completions"
+ )))
+ }
+ MentionUri::Selection { .. } => {
+ // Handled elsewhere
+ debug_panic!("unexpected selection URI");
+ Task::ready(Err(anyhow!("unexpected selection URI")))
+ }
+ };
+ let task = cx
+ .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
+ .shared();
+ self.mention_set
+ .mentions
+ .insert(crease_id, (mention_uri, task.clone()));
+
+ // Notify the user if we failed to load the mentioned context
+ cx.spawn_in(window, async move |this, cx| {
+ let result = task.await.notify_async_err(cx);
+ drop(tx);
+ if result.is_none() {
+ this.update(cx, |this, cx| {
+ this.editor.update(cx, |editor, cx| {
+ // Remove mention
+ editor.edit([(start_anchor..end_anchor, "")], cx);
+ });
+ this.mention_set.mentions.remove(&crease_id);
+ })
+ .ok();
+ }
+ })
+ }
+
+ fn confirm_mention_for_file(
+ &mut self,
+ abs_path: PathBuf,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<Mention>> {
+ let Some(project_path) = self
+ .project
+ .read(cx)
+ .project_path_for_absolute_path(&abs_path, cx)
+ else {
+ return Task::ready(Err(anyhow!("project path not found")));
+ };
+ let extension = abs_path
+ .extension()
+ .and_then(OsStr::to_str)
+ .unwrap_or_default();
+
+ if Img::extensions().contains(&extension) && !extension.contains("svg") {
+ if !self.prompt_capabilities.get().image {
+ return Task::ready(Err(anyhow!("This agent does not support images yet")));
+ }
+ let task = self
+ .project
+ .update(cx, |project, cx| project.open_image(project_path, cx));
+ return cx.spawn(async move |_, cx| {
+ let image = task.await?;
+ let image = image.update(cx, |image, _| image.image.clone())?;
+ let format = image.format;
+ let image = cx
+ .update(|cx| LanguageModelImage::from_image(image, cx))?
+ .await;
+ if let Some(image) = image {
+ Ok(Mention::Image(MentionImage {
+ data: image.source,
+ format,
+ }))
+ } else {
+ Err(anyhow!("Failed to convert image"))
+ }
+ });
+ }
+
+ let buffer = self
+ .project
+ .update(cx, |project, cx| project.open_buffer(project_path, cx));
+ cx.spawn(async move |_, cx| {
+ let buffer = buffer.await?;
+ let mention = buffer.update(cx, |buffer, cx| Mention::Text {
+ content: buffer.text(),
+ tracked_buffers: vec![cx.entity()],
+ })?;
+ anyhow::Ok(mention)
+ })
+ }
+
+ fn confirm_mention_for_directory(
+ &mut self,
+ abs_path: PathBuf,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<Mention>> {
+ fn collect_files_in_path(worktree: &Worktree, path: &Path) -> Vec<(Arc<Path>, PathBuf)> {
+ let mut files = Vec::new();
+
+ for entry in worktree.child_entries(path) {
+ if entry.is_dir() {
+ files.extend(collect_files_in_path(worktree, &entry.path));
+ } else if entry.is_file() {
+ files.push((entry.path.clone(), worktree.full_path(&entry.path)));
+ }
+ }
+
+ files
+ }
+
+ let Some(project_path) = self
+ .project
+ .read(cx)
+ .project_path_for_absolute_path(&abs_path, cx)
+ else {
+ return Task::ready(Err(anyhow!("project path not found")));
+ };
+ let Some(entry) = self.project.read(cx).entry_for_path(&project_path, cx) else {
+ return Task::ready(Err(anyhow!("project entry not found")));
+ };
+ let Some(worktree) = self.project.read(cx).worktree_for_entry(entry.id, cx) else {
+ return Task::ready(Err(anyhow!("worktree not found")));
+ };
+ let project = self.project.clone();
+ cx.spawn(async move |_, cx| {
+ let directory_path = entry.path.clone();
+
+ let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id())?;
+ let file_paths = worktree.read_with(cx, |worktree, _cx| {
+ collect_files_in_path(worktree, &directory_path)
+ })?;
+ let descendants_future = cx.update(|cx| {
+ join_all(file_paths.into_iter().map(|(worktree_path, full_path)| {
+ let rel_path = worktree_path
+ .strip_prefix(&directory_path)
+ .log_err()
+ .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into());
+
+ let open_task = project.update(cx, |project, cx| {
+ project.buffer_store().update(cx, |buffer_store, cx| {
+ let project_path = ProjectPath {
+ worktree_id,
+ path: worktree_path,
+ };
+ buffer_store.open_buffer(project_path, cx)
+ })
+ });
+
+ // TODO: report load errors instead of just logging
+ let rope_task = cx.spawn(async move |cx| {
+ let buffer = open_task.await.log_err()?;
+ let rope = buffer
+ .read_with(cx, |buffer, _cx| buffer.as_rope().clone())
+ .log_err()?;
+ Some((rope, buffer))
+ });
+
+ cx.background_spawn(async move {
+ let (rope, buffer) = rope_task.await?;
+ Some((rel_path, full_path, rope.to_string(), buffer))
+ })
+ }))
+ })?;
+
+ let contents = cx
+ .background_spawn(async move {
+ let (contents, tracked_buffers) = descendants_future
+ .await
+ .into_iter()
+ .flatten()
+ .map(|(rel_path, full_path, rope, buffer)| {
+ ((rel_path, full_path, rope), buffer)
+ })
+ .unzip();
+ Mention::Text {
+ content: render_directory_contents(contents),
+ tracked_buffers,
+ }
+ })
+ .await;
+ anyhow::Ok(contents)
+ })
+ }
+
+ fn confirm_mention_for_fetch(
+ &mut self,
+ url: url::Url,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<Mention>> {
+ let http_client = match self
+ .workspace
+ .update(cx, |workspace, _| workspace.client().http_client())
+ {
+ Ok(http_client) => http_client,
+ Err(e) => return Task::ready(Err(e)),
+ };
+ cx.background_executor().spawn(async move {
+ let content = fetch_url_content(http_client, url.to_string()).await?;
+ Ok(Mention::Text {
+ content,
+ tracked_buffers: Vec::new(),
+ })
+ })
+ }
+
+ fn confirm_mention_for_symbol(
+ &mut self,
+ abs_path: PathBuf,
+ line_range: RangeInclusive<u32>,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<Mention>> {
+ let Some(project_path) = self
+ .project
+ .read(cx)
+ .project_path_for_absolute_path(&abs_path, cx)
+ else {
+ return Task::ready(Err(anyhow!("project path not found")));
+ };
+ let buffer = self
+ .project
+ .update(cx, |project, cx| project.open_buffer(project_path, cx));
+ cx.spawn(async move |_, cx| {
+ let buffer = buffer.await?;
+ let mention = buffer.update(cx, |buffer, cx| {
+ let start = Point::new(*line_range.start(), 0).min(buffer.max_point());
+ let end = Point::new(*line_range.end() + 1, 0).min(buffer.max_point());
+ let content = buffer.text_for_range(start..end).collect();
+ Mention::Text {
+ content,
+ tracked_buffers: vec![cx.entity()],
+ }
+ })?;
+ anyhow::Ok(mention)
+ })
+ }
+
+ fn confirm_mention_for_rule(
+ &mut self,
+ id: PromptId,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<Mention>> {
+ let Some(prompt_store) = self.prompt_store.clone() else {
+ return Task::ready(Err(anyhow!("missing prompt store")));
+ };
+ let prompt = prompt_store.read(cx).load(id, cx);
+ cx.spawn(async move |_, _| {
+ let prompt = prompt.await?;
+ Ok(Mention::Text {
+ content: prompt,
+ tracked_buffers: Vec::new(),
+ })
+ })
+ }
+
+ pub fn confirm_mention_for_selection(
+ &mut self,
+ source_range: Range<text::Anchor>,
+ selections: Vec<(Entity<Buffer>, Range<text::Anchor>, Range<usize>)>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
+ let Some((&excerpt_id, _, _)) = snapshot.as_singleton() else {
+ return;
+ };
+ let Some(start) = snapshot.anchor_in_excerpt(excerpt_id, source_range.start) else {
+ return;
+ };
+
+ let offset = start.to_offset(&snapshot);
+
+ for (buffer, selection_range, range_to_fold) in selections {
+ let range = snapshot.anchor_after(offset + range_to_fold.start)
+ ..snapshot.anchor_after(offset + range_to_fold.end);
+
+ let abs_path = buffer
+ .read(cx)
+ .project_path(cx)
+ .and_then(|project_path| self.project.read(cx).absolute_path(&project_path, cx));
+ let snapshot = buffer.read(cx).snapshot();
+
+ let text = snapshot
+ .text_for_range(selection_range.clone())
+ .collect::<String>();
+ let point_range = selection_range.to_point(&snapshot);
+ let line_range = point_range.start.row..=point_range.end.row;
+
+ let uri = MentionUri::Selection {
+ abs_path: abs_path.clone(),
+ line_range: line_range.clone(),
+ };
+ let crease = crate::context_picker::crease_for_mention(
+ selection_name(abs_path.as_deref(), &line_range).into(),
+ uri.icon_path(cx),
+ range,
+ self.editor.downgrade(),
+ );
+
+ let crease_id = self.editor.update(cx, |editor, cx| {
+ let crease_ids = editor.insert_creases(vec![crease.clone()], cx);
+ editor.fold_creases(vec![crease], false, window, cx);
+ crease_ids.first().copied().unwrap()
+ });
+
+ self.mention_set.mentions.insert(
+ crease_id,
+ (
+ uri,
+ Task::ready(Ok(Mention::Text {
+ content: text,
+ tracked_buffers: vec![buffer],
+ }))
+ .shared(),
+ ),
+ );
+ }
+ }
+
+ fn confirm_mention_for_thread(
+ &mut self,
+ id: acp::SessionId,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<Mention>> {
+ let server = Rc::new(agent2::NativeAgentServer::new(
+ self.project.read(cx).fs().clone(),
+ self.history_store.clone(),
+ ));
+ let connection = server.connect(Path::new(""), &self.project, cx);
+ cx.spawn(async move |_, cx| {
+ let agent = connection.await?;
+ let agent = agent.downcast::<agent2::NativeAgentConnection>().unwrap();
+ let summary = agent
+ .0
+ .update(cx, |agent, cx| agent.thread_summary(id, cx))?
+ .await?;
+ anyhow::Ok(Mention::Text {
+ content: summary.to_string(),
+ tracked_buffers: Vec::new(),
+ })
+ })
+ }
+
+ fn confirm_mention_for_text_thread(
+ &mut self,
+ path: PathBuf,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<Mention>> {
+ let context = self.history_store.update(cx, |text_thread_store, cx| {
+ text_thread_store.load_text_thread(path.as_path().into(), cx)
+ });
+ cx.spawn(async move |_, cx| {
+ let context = context.await?;
+ let xml = context.update(cx, |context, cx| context.to_xml(cx))?;
+ Ok(Mention::Text {
+ content: xml,
+ tracked_buffers: Vec::new(),
+ })
+ })
+ }
+
+ pub fn contents(
+ &self,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
+ let contents = self
+ .mention_set
+ .contents(&self.prompt_capabilities.get(), cx);
+ let editor = self.editor.clone();
+ let prevent_slash_commands = self.prevent_slash_commands;
+
+ cx.spawn(async move |_, cx| {
+ let contents = contents.await?;
+ let mut all_tracked_buffers = Vec::new();
+
+ editor.update(cx, |editor, cx| {
+ let mut ix = 0;
+ let mut chunks: Vec<acp::ContentBlock> = Vec::new();
+ let text = editor.text(cx);
+ editor.display_map.update(cx, |map, cx| {
+ let snapshot = map.snapshot(cx);
+ for (crease_id, crease) in snapshot.crease_snapshot.creases() {
+ let Some((uri, mention)) = contents.get(&crease_id) else {
+ continue;
+ };
+
+ let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
+ if crease_range.start > ix {
+ let chunk = if prevent_slash_commands
+ && ix == 0
+ && parse_slash_command(&text[ix..]).is_some()
+ {
+ format!(" {}", &text[ix..crease_range.start]).into()
+ } else {
+ text[ix..crease_range.start].into()
+ };
+ chunks.push(chunk);
+ }
+ let chunk = match mention {
+ Mention::Text {
+ content,
+ tracked_buffers,
+ } => {
+ all_tracked_buffers.extend(tracked_buffers.iter().cloned());
+ acp::ContentBlock::Resource(acp::EmbeddedResource {
+ annotations: None,
+ resource: acp::EmbeddedResourceResource::TextResourceContents(
+ acp::TextResourceContents {
+ mime_type: None,
+ text: content.clone(),
+ uri: uri.to_uri().to_string(),
+ },
+ ),
+ })
+ }
+ Mention::Image(mention_image) => {
+ let uri = match uri {
+ MentionUri::File { .. } => Some(uri.to_uri().to_string()),
+ MentionUri::PastedImage => None,
+ other => {
+ debug_panic!(
+ "unexpected mention uri for image: {:?}",
+ other
+ );
+ None
+ }
+ };
+ acp::ContentBlock::Image(acp::ImageContent {
+ annotations: None,
+ data: mention_image.data.to_string(),
+ mime_type: mention_image.format.mime_type().into(),
+ uri,
+ })
+ }
+ Mention::UriOnly => {
+ acp::ContentBlock::ResourceLink(acp::ResourceLink {
+ name: uri.name(),
+ uri: uri.to_uri().to_string(),
+ annotations: None,
+ description: None,
+ mime_type: None,
+ size: None,
+ title: None,
+ })
+ }
+ };
+ chunks.push(chunk);
+ ix = crease_range.end;
+ }
+
+ if ix < text.len() {
+ let last_chunk = if prevent_slash_commands
+ && ix == 0
+ && parse_slash_command(&text[ix..]).is_some()
+ {
+ format!(" {}", text[ix..].trim_end())
+ } else {
+ text[ix..].trim_end().to_owned()
+ };
+ if !last_chunk.is_empty() {
+ chunks.push(last_chunk.into());
+ }
+ }
+ });
+
+ (chunks, all_tracked_buffers)
+ })
+ })
+ }
+
+ pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ self.editor.update(cx, |editor, cx| {
+ editor.clear(window, cx);
+ editor.remove_creases(
+ self.mention_set
+ .mentions
+ .drain()
+ .map(|(crease_id, _)| crease_id),
+ cx,
+ )
+ });
+ }
+
+ fn send(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
+ if self.is_empty(cx) {
+ return;
+ }
+ cx.emit(MessageEditorEvent::Send)
+ }
+
+ fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
+ cx.emit(MessageEditorEvent::Cancel)
+ }
+
+ fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
+ if !self.prompt_capabilities.get().image {
+ return;
+ }
+
+ let images = cx
+ .read_from_clipboard()
+ .map(|item| {
+ item.into_entries()
+ .filter_map(|entry| {
+ if let ClipboardEntry::Image(image) = entry {
+ Some(image)
+ } else {
+ None
+ }
+ })
+ .collect::<Vec<_>>()
+ })
+ .unwrap_or_default();
+
+ if images.is_empty() {
+ return;
+ }
+ cx.stop_propagation();
+
+ let replacement_text = MentionUri::PastedImage.as_link().to_string();
+ for image in images {
+ let (excerpt_id, text_anchor, multibuffer_anchor) =
+ self.editor.update(cx, |message_editor, cx| {
+ let snapshot = message_editor.snapshot(window, cx);
+ let (excerpt_id, _, buffer_snapshot) =
+ snapshot.buffer_snapshot.as_singleton().unwrap();
+
+ let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len());
+ let multibuffer_anchor = snapshot
+ .buffer_snapshot
+ .anchor_in_excerpt(*excerpt_id, text_anchor);
+ message_editor.edit(
+ [(
+ multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
+ format!("{replacement_text} "),
+ )],
+ cx,
+ );
+ (*excerpt_id, text_anchor, multibuffer_anchor)
+ });
+
+ let content_len = replacement_text.len();
+ let Some(start_anchor) = multibuffer_anchor else {
+ continue;
+ };
+ let end_anchor = self.editor.update(cx, |editor, cx| {
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
+ });
+ let image = Arc::new(image);
+ let Some((crease_id, tx)) = insert_crease_for_mention(
+ excerpt_id,
+ text_anchor,
+ content_len,
+ MentionUri::PastedImage.name().into(),
+ IconName::Image.path().into(),
+ Some(Task::ready(Ok(image.clone())).shared()),
+ self.editor.clone(),
+ window,
+ cx,
+ ) else {
+ continue;
+ };
+ let task = cx
+ .spawn_in(window, {
+ async move |_, cx| {
+ let format = image.format;
+ let image = cx
+ .update(|_, cx| LanguageModelImage::from_image(image, cx))
+ .map_err(|e| e.to_string())?
+ .await;
+ drop(tx);
+ if let Some(image) = image {
+ Ok(Mention::Image(MentionImage {
+ data: image.source,
+ format,
+ }))
+ } else {
+ Err("Failed to convert image".into())
+ }
+ }
+ })
+ .shared();
+
+ self.mention_set
+ .mentions
+ .insert(crease_id, (MentionUri::PastedImage, task.clone()));
+
+ cx.spawn_in(window, async move |this, cx| {
+ if task.await.notify_async_err(cx).is_none() {
+ this.update(cx, |this, cx| {
+ this.editor.update(cx, |editor, cx| {
+ editor.edit([(start_anchor..end_anchor, "")], cx);
+ });
+ this.mention_set.mentions.remove(&crease_id);
+ })
+ .ok();
+ }
+ })
+ .detach();
+ }
+ }
+
+ pub fn insert_dragged_files(
+ &mut self,
+ paths: Vec<project::ProjectPath>,
+ added_worktrees: Vec<Entity<Worktree>>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let buffer = self.editor.read(cx).buffer().clone();
+ let Some(buffer) = buffer.read(cx).as_singleton() else {
+ return;
+ };
+ let mut tasks = Vec::new();
+ for path in paths {
+ let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else {
+ continue;
+ };
+ let Some(abs_path) = self.project.read(cx).absolute_path(&path, cx) else {
+ continue;
+ };
+ let path_prefix = abs_path
+ .file_name()
+ .unwrap_or(path.path.as_os_str())
+ .display()
+ .to_string();
+ let (file_name, _) =
+ crate::context_picker::file_context_picker::extract_file_name_and_directory(
+ &path.path,
+ &path_prefix,
+ );
+
+ let uri = if entry.is_dir() {
+ MentionUri::Directory { abs_path }
+ } else {
+ MentionUri::File { abs_path }
+ };
+
+ let new_text = format!("{} ", uri.as_link());
+ let content_len = new_text.len() - 1;
+
+ let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
+
+ self.editor.update(cx, |message_editor, cx| {
+ message_editor.edit(
+ [(
+ multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
+ new_text,
+ )],
+ cx,
+ );
+ });
+ tasks.push(self.confirm_completion(file_name, anchor, content_len, uri, window, cx));
+ }
+ cx.spawn(async move |_, _| {
+ join_all(tasks).await;
+ drop(added_worktrees);
+ })
+ .detach();
+ }
+
+ pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let buffer = self.editor.read(cx).buffer().clone();
+ let Some(buffer) = buffer.read(cx).as_singleton() else {
+ return;
+ };
+ let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
+ let Some(workspace) = self.workspace.upgrade() else {
+ return;
+ };
+ let Some(completion) = ContextPickerCompletionProvider::completion_for_action(
+ ContextPickerAction::AddSelections,
+ anchor..anchor,
+ cx.weak_entity(),
+ &workspace,
+ cx,
+ ) else {
+ return;
+ };
+ self.editor.update(cx, |message_editor, cx| {
+ message_editor.edit(
+ [(
+ multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
+ completion.new_text,
+ )],
+ cx,
+ );
+ });
+ if let Some(confirm) = completion.confirm {
+ confirm(CompletionIntent::Complete, window, cx);
+ }
+ }
+
+ pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
+ self.editor.update(cx, |message_editor, cx| {
+ message_editor.set_read_only(read_only);
+ cx.notify()
+ })
+ }
+
+ pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
+ self.editor.update(cx, |editor, cx| {
+ editor.set_mode(mode);
+ cx.notify()
+ });
+ }
+
+ pub fn set_message(
+ &mut self,
+ message: Vec<acp::ContentBlock>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.clear(window, cx);
+
+ let mut text = String::new();
+ let mut mentions = Vec::new();
+
+ for chunk in message {
+ match chunk {
+ acp::ContentBlock::Text(text_content) => {
+ text.push_str(&text_content.text);
+ }
+ acp::ContentBlock::Resource(acp::EmbeddedResource {
+ resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
+ ..
+ }) => {
+ let Some(mention_uri) = MentionUri::parse(&resource.uri).log_err() else {
+ continue;
+ };
+ let start = text.len();
+ write!(&mut text, "{}", mention_uri.as_link()).ok();
+ let end = text.len();
+ mentions.push((
+ start..end,
+ mention_uri,
+ Mention::Text {
+ content: resource.text,
+ tracked_buffers: Vec::new(),
+ },
+ ));
+ }
+ acp::ContentBlock::ResourceLink(resource) => {
+ if let Some(mention_uri) = MentionUri::parse(&resource.uri).log_err() {
+ let start = text.len();
+ write!(&mut text, "{}", mention_uri.as_link()).ok();
+ let end = text.len();
+ mentions.push((start..end, mention_uri, Mention::UriOnly));
+ }
+ }
+ acp::ContentBlock::Image(acp::ImageContent {
+ uri,
+ data,
+ mime_type,
+ annotations: _,
+ }) => {
+ let mention_uri = if let Some(uri) = uri {
+ MentionUri::parse(&uri)
+ } else {
+ Ok(MentionUri::PastedImage)
+ };
+ let Some(mention_uri) = mention_uri.log_err() else {
+ continue;
+ };
+ let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
+ log::error!("failed to parse MIME type for image: {mime_type:?}");
+ continue;
+ };
+ let start = text.len();
+ write!(&mut text, "{}", mention_uri.as_link()).ok();
+ let end = text.len();
+ mentions.push((
+ start..end,
+ mention_uri,
+ Mention::Image(MentionImage {
+ data: data.into(),
+ format,
+ }),
+ ));
+ }
+ acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => {}
+ }
+ }
+
+ let snapshot = self.editor.update(cx, |editor, cx| {
+ editor.set_text(text, window, cx);
+ editor.buffer().read(cx).snapshot(cx)
+ });
+
+ for (range, mention_uri, mention) in mentions {
+ let anchor = snapshot.anchor_before(range.start);
+ let Some((crease_id, tx)) = insert_crease_for_mention(
+ anchor.excerpt_id,
+ anchor.text_anchor,
+ range.end - range.start,
+ mention_uri.name().into(),
+ mention_uri.icon_path(cx),
+ None,
+ self.editor.clone(),
+ window,
+ cx,
+ ) else {
+ continue;
+ };
+ drop(tx);
+
+ self.mention_set.mentions.insert(
+ crease_id,
+ (mention_uri.clone(), Task::ready(Ok(mention)).shared()),
+ );
+ }
+ cx.notify();
+ }
+
+ fn highlight_slash_command(
+ &mut self,
+ semantics_provider: Rc<SlashCommandSemanticsProvider>,
+ editor: Entity<Editor>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ struct InvalidSlashCommand;
+
+ self._parse_slash_command_task = cx.spawn_in(window, async move |_, cx| {
+ cx.background_executor()
+ .timer(PARSE_SLASH_COMMAND_DEBOUNCE)
+ .await;
+ editor
+ .update_in(cx, |editor, window, cx| {
+ let snapshot = editor.snapshot(window, cx);
+ let range = parse_slash_command(&editor.text(cx));
+ semantics_provider.range.set(range);
+ if let Some((start, end)) = range {
+ editor.highlight_text::<InvalidSlashCommand>(
+ vec![
+ snapshot.buffer_snapshot.anchor_after(start)
+ ..snapshot.buffer_snapshot.anchor_before(end),
+ ],
+ HighlightStyle {
+ underline: Some(UnderlineStyle {
+ thickness: px(1.),
+ color: Some(gpui::red()),
+ wavy: true,
+ }),
+ ..Default::default()
+ },
+ cx,
+ );
+ } else {
+ editor.clear_highlights::<InvalidSlashCommand>(cx);
+ }
+ })
+ .ok();
+ })
+ }
+
+ pub fn text(&self, cx: &App) -> String {
+ self.editor.read(cx).text(cx)
+ }
+
+ #[cfg(test)]
+ pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
+ self.editor.update(cx, |editor, cx| {
+ editor.set_text(text, window, cx);
+ });
+ }
+}
+
+fn render_directory_contents(entries: Vec<(Arc<Path>, PathBuf, String)>) -> String {
+ let mut output = String::new();
+ for (_relative_path, full_path, content) in entries {
+ let fence = codeblock_fence_for_path(Some(&full_path), None);
+ write!(output, "\n{fence}\n{content}\n```").unwrap();
+ }
+ output
+}
+
+impl Focusable for MessageEditor {
+ fn focus_handle(&self, cx: &App) -> FocusHandle {
+ self.editor.focus_handle(cx)
+ }
+}
+
+impl Render for MessageEditor {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ div()
+ .key_context("MessageEditor")
+ .on_action(cx.listener(Self::send))
+ .on_action(cx.listener(Self::cancel))
+ .capture_action(cx.listener(Self::paste))
+ .flex_1()
+ .child({
+ let settings = ThemeSettings::get_global(cx);
+ let font_size = TextSize::Small
+ .rems(cx)
+ .to_pixels(settings.agent_font_size(cx));
+ let line_height = settings.buffer_line_height.value() * font_size;
+
+ let text_style = TextStyle {
+ color: cx.theme().colors().text,
+ font_family: settings.buffer_font.family.clone(),
+ font_fallbacks: settings.buffer_font.fallbacks.clone(),
+ font_features: settings.buffer_font.features.clone(),
+ font_size: font_size.into(),
+ line_height: line_height.into(),
+ ..Default::default()
+ };
+
+ EditorElement::new(
+ &self.editor,
+ EditorStyle {
+ background: cx.theme().colors().editor_background,
+ local_player: cx.theme().players().local(),
+ text: text_style,
+ syntax: cx.theme().syntax().clone(),
+ ..Default::default()
+ },
+ )
+ })
+ }
+}
+
+pub(crate) fn insert_crease_for_mention(
+ excerpt_id: ExcerptId,
+ anchor: text::Anchor,
+ content_len: usize,
+ crease_label: SharedString,
+ crease_icon: SharedString,
+ // abs_path: Option<Arc<Path>>,
+ image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
+ editor: Entity<Editor>,
+ window: &mut Window,
+ cx: &mut App,
+) -> Option<(CreaseId, postage::barrier::Sender)> {
+ let (tx, rx) = postage::barrier::channel();
+
+ let crease_id = editor.update(cx, |editor, cx| {
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+
+ let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?;
+
+ let start = start.bias_right(&snapshot);
+ let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
+
+ let placeholder = FoldPlaceholder {
+ render: render_fold_icon_button(
+ crease_label,
+ crease_icon,
+ start..end,
+ rx,
+ image,
+ cx.weak_entity(),
+ cx,
+ ),
+ merge_adjacent: false,
+ ..Default::default()
+ };
+
+ let crease = Crease::Inline {
+ range: start..end,
+ placeholder,
+ render_toggle: None,
+ render_trailer: None,
+ metadata: None,
+ };
+
+ let ids = editor.insert_creases(vec![crease.clone()], cx);
+ editor.fold_creases(vec![crease], false, window, cx);
+
+ Some(ids[0])
+ })?;
+
+ Some((crease_id, tx))
+}
+
+fn render_fold_icon_button(
+ label: SharedString,
+ icon: SharedString,
+ range: Range<Anchor>,
+ mut loading_finished: postage::barrier::Receiver,
+ image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
+ editor: WeakEntity<Editor>,
+ cx: &mut App,
+) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
+ let loading = cx.new(|cx| {
+ let loading = cx.spawn(async move |this, cx| {
+ loading_finished.recv().await;
+ this.update(cx, |this: &mut LoadingContext, cx| {
+ this.loading = None;
+ cx.notify();
+ })
+ .ok();
+ });
+ LoadingContext {
+ id: cx.entity_id(),
+ label,
+ icon,
+ range,
+ editor,
+ loading: Some(loading),
+ image: image_task.clone(),
+ }
+ });
+ Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
+}
+
+struct LoadingContext {
+ id: EntityId,
+ label: SharedString,
+ icon: SharedString,
+ range: Range<Anchor>,
+ editor: WeakEntity<Editor>,
+ loading: Option<Task<()>>,
+ image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
+}
+
+impl Render for LoadingContext {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let is_in_text_selection = self
+ .editor
+ .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
+ .unwrap_or_default();
+ ButtonLike::new(("loading-context", self.id))
+ .style(ButtonStyle::Filled)
+ .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+ .toggle_state(is_in_text_selection)
+ .when_some(self.image.clone(), |el, image_task| {
+ el.hoverable_tooltip(move |_, cx| {
+ let image = image_task.peek().cloned().transpose().ok().flatten();
+ let image_task = image_task.clone();
+ cx.new::<ImageHover>(|cx| ImageHover {
+ image,
+ _task: cx.spawn(async move |this, cx| {
+ if let Ok(image) = image_task.clone().await {
+ this.update(cx, |this, cx| {
+ if this.image.replace(image).is_none() {
+ cx.notify();
+ }
+ })
+ .ok();
+ }
+ }),
+ })
+ .into()
+ })
+ })
+ .child(
+ h_flex()
+ .gap_1()
+ .child(
+ Icon::from_path(self.icon.clone())
+ .size(IconSize::XSmall)
+ .color(Color::Muted),
+ )
+ .child(
+ Label::new(self.label.clone())
+ .size(LabelSize::Small)
+ .buffer_font(cx)
+ .single_line(),
+ )
+ .map(|el| {
+ if self.loading.is_some() {
+ el.with_animation(
+ "loading-context-crease",
+ Animation::new(Duration::from_secs(2))
+ .repeat()
+ .with_easing(pulsating_between(0.4, 0.8)),
+ |label, delta| label.opacity(delta),
+ )
+ .into_any()
+ } else {
+ el.into_any()
+ }
+ }),
+ )
+ }
+}
+
+struct ImageHover {
+ image: Option<Arc<Image>>,
+ _task: Task<()>,
+}
+
+impl Render for ImageHover {
+ fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+ if let Some(image) = self.image.clone() {
+ gpui::img(image).max_w_96().max_h_96().into_any_element()
+ } else {
+ gpui::Empty.into_any_element()
+ }
+ }
+}
+
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub enum Mention {
+ Text {
+ content: String,
+ tracked_buffers: Vec<Entity<Buffer>>,
+ },
+ Image(MentionImage),
+ UriOnly,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MentionImage {
+ pub data: SharedString,
+ pub format: ImageFormat,
+}
+
+#[derive(Default)]
+pub struct MentionSet {
+ mentions: HashMap<CreaseId, (MentionUri, Shared<Task<Result<Mention, String>>>)>,
+}
+
+impl MentionSet {
+ fn contents(
+ &self,
+ prompt_capabilities: &acp::PromptCapabilities,
+ cx: &mut App,
+ ) -> Task<Result<HashMap<CreaseId, (MentionUri, Mention)>>> {
+ if !prompt_capabilities.embedded_context {
+ let mentions = self
+ .mentions
+ .iter()
+ .map(|(crease_id, (uri, _))| (*crease_id, (uri.clone(), Mention::UriOnly)))
+ .collect();
+
+ return Task::ready(Ok(mentions));
+ }
+
+ let mentions = self.mentions.clone();
+ cx.spawn(async move |_cx| {
+ let mut contents = HashMap::default();
+ for (crease_id, (mention_uri, task)) in mentions {
+ contents.insert(
+ crease_id,
+ (mention_uri, task.await.map_err(|e| anyhow!("{e}"))?),
+ );
+ }
+ Ok(contents)
+ })
+ }
+
+ fn remove_invalid(&mut self, snapshot: EditorSnapshot) {
+ for (crease_id, crease) in snapshot.crease_snapshot.creases() {
+ if !crease.range().start.is_valid(&snapshot.buffer_snapshot) {
+ self.mentions.remove(&crease_id);
+ }
+ }
+ }
+}
+
+struct SlashCommandSemanticsProvider {
+ range: Cell<Option<(usize, usize)>>,
+}
+
+impl SemanticsProvider for SlashCommandSemanticsProvider {
+ fn hover(
+ &self,
+ buffer: &Entity<Buffer>,
+ position: text::Anchor,
+ cx: &mut App,
+ ) -> Option<Task<Option<Vec<project::Hover>>>> {
+ let snapshot = buffer.read(cx).snapshot();
+ let offset = position.to_offset(&snapshot);
+ let (start, end) = self.range.get()?;
+ if !(start..end).contains(&offset) {
+ return None;
+ }
+ let range = snapshot.anchor_after(start)..snapshot.anchor_after(end);
+ Some(Task::ready(Some(vec![project::Hover {
+ contents: vec![project::HoverBlock {
+ text: "Slash commands are not supported".into(),
+ kind: project::HoverBlockKind::PlainText,
+ }],
+ range: Some(range),
+ language: None,
+ }])))
+ }
+
+ fn inline_values(
+ &self,
+ _buffer_handle: Entity<Buffer>,
+ _range: Range<text::Anchor>,
+ _cx: &mut App,
+ ) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
+ None
+ }
+
+ fn inlay_hints(
+ &self,
+ _buffer_handle: Entity<Buffer>,
+ _range: Range<text::Anchor>,
+ _cx: &mut App,
+ ) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
+ None
+ }
+
+ fn resolve_inlay_hint(
+ &self,
+ _hint: project::InlayHint,
+ _buffer_handle: Entity<Buffer>,
+ _server_id: lsp::LanguageServerId,
+ _cx: &mut App,
+ ) -> Option<Task<anyhow::Result<project::InlayHint>>> {
+ None
+ }
+
+ fn supports_inlay_hints(&self, _buffer: &Entity<Buffer>, _cx: &mut App) -> bool {
+ false
+ }
+
+ fn document_highlights(
+ &self,
+ _buffer: &Entity<Buffer>,
+ _position: text::Anchor,
+ _cx: &mut App,
+ ) -> Option<Task<Result<Vec<project::DocumentHighlight>>>> {
+ None
+ }
+
+ fn definitions(
+ &self,
+ _buffer: &Entity<Buffer>,
+ _position: text::Anchor,
+ _kind: editor::GotoDefinitionKind,
+ _cx: &mut App,
+ ) -> Option<Task<Result<Option<Vec<project::LocationLink>>>>> {
+ None
+ }
+
+ fn range_for_rename(
+ &self,
+ _buffer: &Entity<Buffer>,
+ _position: text::Anchor,
+ _cx: &mut App,
+ ) -> Option<Task<Result<Option<Range<text::Anchor>>>>> {
+ None
+ }
+
+ fn perform_rename(
+ &self,
+ _buffer: &Entity<Buffer>,
+ _position: text::Anchor,
+ _new_name: String,
+ _cx: &mut App,
+ ) -> Option<Task<Result<project::ProjectTransaction>>> {
+ None
+ }
+}
+
+fn parse_slash_command(text: &str) -> Option<(usize, usize)> {
+ if let Some(remainder) = text.strip_prefix('/') {
+ let pos = remainder
+ .find(char::is_whitespace)
+ .unwrap_or(remainder.len());
+ let command = &remainder[..pos];
+ if !command.is_empty() && command.chars().all(char::is_alphanumeric) {
+ return Some((0, 1 + command.len()));
+ }
+ }
+ None
+}
+
+pub struct MessageEditorAddon {}
+
+impl MessageEditorAddon {
+ pub fn new() -> Self {
+ Self {}
+ }
+}
+
+impl Addon for MessageEditorAddon {
+ fn to_any(&self) -> &dyn std::any::Any {
+ self
+ }
+
+ fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
+ Some(self)
+ }
+
+ fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
+ let settings = agent_settings::AgentSettings::get_global(cx);
+ if settings.use_modifier_to_send {
+ key_context.add("use_modifier_to_send");
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{cell::Cell, ops::Range, path::Path, rc::Rc, sync::Arc};
+
+ use acp_thread::MentionUri;
+ use agent_client_protocol as acp;
+ use agent2::HistoryStore;
+ use assistant_context::ContextStore;
+ use editor::{AnchorRangeExt as _, Editor, EditorMode};
+ use fs::FakeFs;
+ use futures::StreamExt as _;
+ use gpui::{
+ AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext,
+ };
+ use lsp::{CompletionContext, CompletionTriggerKind};
+ use project::{CompletionIntent, Project, ProjectPath};
+ use serde_json::json;
+ use text::Point;
+ use ui::{App, Context, IntoElement, Render, SharedString, Window};
+ use util::{path, uri};
+ use workspace::{AppState, Item, Workspace};
+
+ use crate::acp::{
+ message_editor::{Mention, MessageEditor},
+ thread_view::tests::init_test,
+ };
+
+ #[gpui::test]
+ async fn test_at_mention_removal(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/project", json!({"file": ""})).await;
+ let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
+
+ let (workspace, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+ let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
+ let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
+
+ let message_editor = cx.update(|window, cx| {
+ cx.new(|cx| {
+ MessageEditor::new(
+ workspace.downgrade(),
+ project.clone(),
+ history_store.clone(),
+ None,
+ Default::default(),
+ "Test",
+ false,
+ EditorMode::AutoHeight {
+ min_lines: 1,
+ max_lines: None,
+ },
+ window,
+ cx,
+ )
+ })
+ });
+ let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
+
+ cx.run_until_parked();
+
+ let excerpt_id = editor.update(cx, |editor, cx| {
+ editor
+ .buffer()
+ .read(cx)
+ .excerpt_ids()
+ .into_iter()
+ .next()
+ .unwrap()
+ });
+ let completions = editor.update_in(cx, |editor, window, cx| {
+ editor.set_text("Hello @file ", window, cx);
+ let buffer = editor.buffer().read(cx).as_singleton().unwrap();
+ let completion_provider = editor.completion_provider().unwrap();
+ completion_provider.completions(
+ excerpt_id,
+ &buffer,
+ text::Anchor::MAX,
+ CompletionContext {
+ trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
+ trigger_character: Some("@".into()),
+ },
+ window,
+ cx,
+ )
+ });
+ let [_, completion]: [_; 2] = completions
+ .await
+ .unwrap()
+ .into_iter()
+ .flat_map(|response| response.completions)
+ .collect::<Vec<_>>()
+ .try_into()
+ .unwrap();
+
+ editor.update_in(cx, |editor, window, cx| {
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ let start = snapshot
+ .anchor_in_excerpt(excerpt_id, completion.replace_range.start)
+ .unwrap();
+ let end = snapshot
+ .anchor_in_excerpt(excerpt_id, completion.replace_range.end)
+ .unwrap();
+ editor.edit([(start..end, completion.new_text)], cx);
+ (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
+ });
+
+ cx.run_until_parked();
+
+ // Backspace over the inserted crease (and the following space).
+ editor.update_in(cx, |editor, window, cx| {
+ editor.backspace(&Default::default(), window, cx);
+ editor.backspace(&Default::default(), window, cx);
+ });
+
+ let (content, _) = message_editor
+ .update(cx, |message_editor, cx| message_editor.contents(cx))
+ .await
+ .unwrap();
+
+ // We don't send a resource link for the deleted crease.
+ pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
+ }
+
+ struct MessageEditorItem(Entity<MessageEditor>);
+
+ impl Item for MessageEditorItem {
+ type Event = ();
+
+ fn include_in_nav_history() -> bool {
+ false
+ }
+
+ fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
+ "Test".into()
+ }
+ }
+
+ impl EventEmitter<()> for MessageEditorItem {}
+
+ impl Focusable for MessageEditorItem {
+ fn focus_handle(&self, cx: &App) -> FocusHandle {
+ self.0.read(cx).focus_handle(cx)
+ }
+ }
+
+ impl Render for MessageEditorItem {
+ fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+ self.0.clone().into_any_element()
+ }
+ }
+
+ #[gpui::test]
+ async fn test_context_completion_provider(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let app_state = cx.update(AppState::test);
+
+ cx.update(|cx| {
+ language::init(cx);
+ editor::init(cx);
+ workspace::init(app_state.clone(), cx);
+ Project::init_settings(cx);
+ });
+
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ path!("/dir"),
+ json!({
+ "editor": "",
+ "a": {
+ "one.txt": "1",
+ "two.txt": "2",
+ "three.txt": "3",
+ "four.txt": "4"
+ },
+ "b": {
+ "five.txt": "5",
+ "six.txt": "6",
+ "seven.txt": "7",
+ "eight.txt": "8",
+ },
+ "x.png": "",
+ }),
+ )
+ .await;
+
+ let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
+ let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let workspace = window.root(cx).unwrap();
+
+ let worktree = project.update(cx, |project, cx| {
+ let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
+ assert_eq!(worktrees.len(), 1);
+ worktrees.pop().unwrap()
+ });
+ let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
+
+ let mut cx = VisualTestContext::from_window(*window, cx);
+
+ let paths = vec![
+ path!("a/one.txt"),
+ path!("a/two.txt"),
+ path!("a/three.txt"),
+ path!("a/four.txt"),
+ path!("b/five.txt"),
+ path!("b/six.txt"),
+ path!("b/seven.txt"),
+ path!("b/eight.txt"),
+ ];
+
+ let mut opened_editors = Vec::new();
+ for path in paths {
+ let buffer = workspace
+ .update_in(&mut cx, |workspace, window, cx| {
+ workspace.open_path(
+ ProjectPath {
+ worktree_id,
+ path: Path::new(path).into(),
+ },
+ None,
+ false,
+ window,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ opened_editors.push(buffer);
+ }
+
+ let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
+ let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
+ let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default()));
+
+ let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
+ let workspace_handle = cx.weak_entity();
+ let message_editor = cx.new(|cx| {
+ MessageEditor::new(
+ workspace_handle,
+ project.clone(),
+ history_store.clone(),
+ None,
+ prompt_capabilities.clone(),
+ "Test",
+ false,
+ EditorMode::AutoHeight {
+ max_lines: None,
+ min_lines: 1,
+ },
+ window,
+ cx,
+ )
+ });
+ workspace.active_pane().update(cx, |pane, cx| {
+ pane.add_item(
+ Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
+ true,
+ true,
+ None,
+ window,
+ cx,
+ );
+ });
+ message_editor.read(cx).focus_handle(cx).focus(window);
+ let editor = message_editor.read(cx).editor().clone();
+ (message_editor, editor)
+ });
+
+ cx.simulate_input("Lorem @");
+
+ editor.update_in(&mut cx, |editor, window, cx| {
+ assert_eq!(editor.text(cx), "Lorem @");
+ assert!(editor.has_visible_completions_menu());
+
+ // Only files since we have default capabilities
+ assert_eq!(
+ current_completion_labels(editor),
+ &[
+ "eight.txt dir/b/",
+ "seven.txt dir/b/",
+ "six.txt dir/b/",
+ "five.txt dir/b/",
+ ]
+ );
+ editor.set_text("", window, cx);
+ });
+
+ prompt_capabilities.set(acp::PromptCapabilities {
+ image: true,
+ audio: true,
+ embedded_context: true,
+ });
+
+ cx.simulate_input("Lorem ");
+
+ editor.update(&mut cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "Lorem ");
+ assert!(!editor.has_visible_completions_menu());
+ });
+
+ cx.simulate_input("@");
+
+ editor.update(&mut cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "Lorem @");
+ assert!(editor.has_visible_completions_menu());
+ assert_eq!(
+ current_completion_labels(editor),
+ &[
+ "eight.txt dir/b/",
+ "seven.txt dir/b/",
+ "six.txt dir/b/",
+ "five.txt dir/b/",
+ "Files & Directories",
+ "Symbols",
+ "Threads",
+ "Fetch"
+ ]
+ );
+ });
+
+ // Select and confirm "File"
+ editor.update_in(&mut cx, |editor, window, cx| {
+ assert!(editor.has_visible_completions_menu());
+ editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
+ editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
+ editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
+ editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
+ editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
+ });
+
+ cx.run_until_parked();
+
+ editor.update(&mut cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "Lorem @file ");
+ assert!(editor.has_visible_completions_menu());
+ });
+
+ cx.simulate_input("one");
+
+ editor.update(&mut cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "Lorem @file one");
+ assert!(editor.has_visible_completions_menu());
+ assert_eq!(current_completion_labels(editor), vec!["one.txt dir/a/"]);
+ });
+
+ editor.update_in(&mut cx, |editor, window, cx| {
+ assert!(editor.has_visible_completions_menu());
+ editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
+ });
+
+ let url_one = uri!("file:///dir/a/one.txt");
+ editor.update(&mut cx, |editor, cx| {
+ let text = editor.text(cx);
+ assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
+ assert!(!editor.has_visible_completions_menu());
+ assert_eq!(fold_ranges(editor, cx).len(), 1);
+ });
+
+ let all_prompt_capabilities = acp::PromptCapabilities {
+ image: true,
+ audio: true,
+ embedded_context: true,
+ };
+
+ let contents = message_editor
+ .update(&mut cx, |message_editor, cx| {
+ message_editor
+ .mention_set()
+ .contents(&all_prompt_capabilities, cx)
+ })
+ .await
+ .unwrap()
+ .into_values()
+ .collect::<Vec<_>>();
+
+ {
+ let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
+ panic!("Unexpected mentions");
+ };
+ pretty_assertions::assert_eq!(content, "1");
+ pretty_assertions::assert_eq!(uri, &url_one.parse::<MentionUri>().unwrap());
+ }
+
+ let contents = message_editor
+ .update(&mut cx, |message_editor, cx| {
+ message_editor
+ .mention_set()
+ .contents(&acp::PromptCapabilities::default(), cx)
+ })
+ .await
+ .unwrap()
+ .into_values()
+ .collect::<Vec<_>>();
+
+ {
+ let [(uri, Mention::UriOnly)] = contents.as_slice() else {
+ panic!("Unexpected mentions");
+ };
+ pretty_assertions::assert_eq!(uri, &url_one.parse::<MentionUri>().unwrap());
+ }
+
+ cx.simulate_input(" ");
+
+ editor.update(&mut cx, |editor, cx| {
+ let text = editor.text(cx);
+ assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
+ assert!(!editor.has_visible_completions_menu());
+ assert_eq!(fold_ranges(editor, cx).len(), 1);
+ });
+
+ cx.simulate_input("Ipsum ");
+
+ editor.update(&mut cx, |editor, cx| {
+ let text = editor.text(cx);
+ assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
+ assert!(!editor.has_visible_completions_menu());
+ assert_eq!(fold_ranges(editor, cx).len(), 1);
+ });
+
+ cx.simulate_input("@file ");
+
+ editor.update(&mut cx, |editor, cx| {
+ let text = editor.text(cx);
+ assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
+ assert!(editor.has_visible_completions_menu());
+ assert_eq!(fold_ranges(editor, cx).len(), 1);
+ });
+
+ editor.update_in(&mut cx, |editor, window, cx| {
+ editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
+ });
+
+ cx.run_until_parked();
+
+ let contents = message_editor
+ .update(&mut cx, |message_editor, cx| {
+ message_editor
+ .mention_set()
+ .contents(&all_prompt_capabilities, cx)
+ })
+ .await
+ .unwrap()
+ .into_values()
+ .collect::<Vec<_>>();
+
+ let url_eight = uri!("file:///dir/b/eight.txt");
+
+ {
+ let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
+ panic!("Unexpected mentions");
+ };
+ pretty_assertions::assert_eq!(content, "8");
+ pretty_assertions::assert_eq!(uri, &url_eight.parse::<MentionUri>().unwrap());
+ }
+
+ editor.update(&mut cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
+ );
+ assert!(!editor.has_visible_completions_menu());
+ assert_eq!(fold_ranges(editor, cx).len(), 2);
+ });
+
+ let plain_text_language = Arc::new(language::Language::new(
+ language::LanguageConfig {
+ name: "Plain Text".into(),
+ matcher: language::LanguageMatcher {
+ path_suffixes: vec!["txt".to_string()],
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ None,
+ ));
+
+ // Register the language and fake LSP
+ let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
+ language_registry.add(plain_text_language);
+
+ let mut fake_language_servers = language_registry.register_fake_lsp(
+ "Plain Text",
+ language::FakeLspAdapter {
+ capabilities: lsp::ServerCapabilities {
+ workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ );
+
+ // Open the buffer to trigger LSP initialization
+ let buffer = project
+ .update(&mut cx, |project, cx| {
+ project.open_local_buffer(path!("/dir/a/one.txt"), cx)
+ })
+ .await
+ .unwrap();
+
+ // Register the buffer with language servers
+ let _handle = project.update(&mut cx, |project, cx| {
+ project.register_buffer_with_language_servers(&buffer, cx)
+ });
+
+ cx.run_until_parked();
+
+ let fake_language_server = fake_language_servers.next().await.unwrap();
+ fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
+ move |_, _| async move {
+ Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
+ #[allow(deprecated)]
+ lsp::SymbolInformation {
+ name: "MySymbol".into(),
+ location: lsp::Location {
+ uri: lsp::Url::from_file_path(path!("/dir/a/one.txt")).unwrap(),
+ range: lsp::Range::new(
+ lsp::Position::new(0, 0),
+ lsp::Position::new(0, 1),
+ ),
+ },
+ kind: lsp::SymbolKind::CONSTANT,
+ tags: None,
+ container_name: None,
+ deprecated: None,
+ },
+ ])))
+ },
+ );
+
+ cx.simulate_input("@symbol ");
+
+ editor.update(&mut cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
+ );
+ assert!(editor.has_visible_completions_menu());
+ assert_eq!(current_completion_labels(editor), &["MySymbol"]);
+ });
+
+ editor.update_in(&mut cx, |editor, window, cx| {
+ editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
+ });
+
+ let contents = message_editor
+ .update(&mut cx, |message_editor, cx| {
+ message_editor
+ .mention_set()
+ .contents(&all_prompt_capabilities, cx)
+ })
+ .await
+ .unwrap()
+ .into_values()
+ .collect::<Vec<_>>();
+
+ {
+ let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
+ panic!("Unexpected mentions");
+ };
+ pretty_assertions::assert_eq!(content, "1");
+ pretty_assertions::assert_eq!(
+ uri,
+ &format!("{url_one}?symbol=MySymbol#L1:1")
+ .parse::<MentionUri>()
+ .unwrap()
+ );
+ }
+
+ cx.run_until_parked();
+
+ editor.read_with(&cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({url_one}?symbol=MySymbol#L1:1) ")
+ );
+ });
+
+ // Try to mention an "image" file that will fail to load
+ cx.simulate_input("@file x.png");
+
+ editor.update(&mut cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({url_one}?symbol=MySymbol#L1:1) @file x.png")
+ );
+ assert!(editor.has_visible_completions_menu());
+ assert_eq!(current_completion_labels(editor), &["x.png dir/"]);
+ });
+
+ editor.update_in(&mut cx, |editor, window, cx| {
+ editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
+ });
+
+ // Getting the message contents fails
+ message_editor
+ .update(&mut cx, |message_editor, cx| {
+ message_editor
+ .mention_set()
+ .contents(&all_prompt_capabilities, cx)
+ })
+ .await
+ .expect_err("Should fail to load x.png");
+
+ cx.run_until_parked();
+
+ // Mention was removed
+ editor.read_with(&cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({url_one}?symbol=MySymbol#L1:1) ")
+ );
+ });
+
+ // Once more
+ cx.simulate_input("@file x.png");
+
+ editor.update(&mut cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({url_one}?symbol=MySymbol#L1:1) @file x.png")
+ );
+ assert!(editor.has_visible_completions_menu());
+ assert_eq!(current_completion_labels(editor), &["x.png dir/"]);
+ });
+
+ editor.update_in(&mut cx, |editor, window, cx| {
+ editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
+ });
+
+ // This time don't immediately get the contents, just let the confirmed completion settle
+ cx.run_until_parked();
+
+ // Mention was removed
+ editor.read_with(&cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({url_one}?symbol=MySymbol#L1:1) ")
+ );
+ });
+
+ // Now getting the contents succeeds, because the invalid mention was removed
+ let contents = message_editor
+ .update(&mut cx, |message_editor, cx| {
+ message_editor
+ .mention_set()
+ .contents(&all_prompt_capabilities, cx)
+ })
+ .await
+ .unwrap();
+ assert_eq!(contents.len(), 3);
+ }
+
+ fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ editor.display_map.update(cx, |display_map, cx| {
+ display_map
+ .snapshot(cx)
+ .folds_in_range(0..snapshot.len())
+ .map(|fold| fold.range.to_point(&snapshot))
+ .collect()
+ })
+ }
+
+ fn current_completion_labels(editor: &Editor) -> Vec<String> {
+ let completions = editor.current_completions().expect("Missing completions");
+ completions
+ .into_iter()
+ .map(|completion| completion.label.text)
+ .collect::<Vec<_>>()
+ }
+}
@@ -1,87 +0,0 @@
-pub struct MessageHistory<T> {
- items: Vec<T>,
- current: Option<usize>,
-}
-
-impl<T> Default for MessageHistory<T> {
- fn default() -> Self {
- MessageHistory {
- items: Vec::new(),
- current: None,
- }
- }
-}
-
-impl<T> MessageHistory<T> {
- pub fn push(&mut self, message: T) {
- self.current.take();
- self.items.push(message);
- }
-
- pub fn reset_position(&mut self) {
- self.current.take();
- }
-
- pub fn prev(&mut self) -> Option<&T> {
- if self.items.is_empty() {
- return None;
- }
-
- let new_ix = self
- .current
- .get_or_insert(self.items.len())
- .saturating_sub(1);
-
- self.current = Some(new_ix);
- self.items.get(new_ix)
- }
-
- pub fn next(&mut self) -> Option<&T> {
- let current = self.current.as_mut()?;
- *current += 1;
-
- self.items.get(*current).or_else(|| {
- self.current.take();
- None
- })
- }
-}
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_prev_next() {
- let mut history = MessageHistory::default();
-
- // Test empty history
- assert_eq!(history.prev(), None);
- assert_eq!(history.next(), None);
-
- // Add some messages
- history.push("first");
- history.push("second");
- history.push("third");
-
- // Test prev navigation
- assert_eq!(history.prev(), Some(&"third"));
- assert_eq!(history.prev(), Some(&"second"));
- assert_eq!(history.prev(), Some(&"first"));
- assert_eq!(history.prev(), Some(&"first"));
-
- assert_eq!(history.next(), Some(&"second"));
-
- // Test mixed navigation
- history.push("fourth");
- assert_eq!(history.prev(), Some(&"fourth"));
- assert_eq!(history.prev(), Some(&"third"));
- assert_eq!(history.next(), Some(&"fourth"));
- assert_eq!(history.next(), None);
-
- // Test that push resets navigation
- history.prev();
- history.prev();
- history.push("fifth");
- assert_eq!(history.prev(), Some(&"fifth"));
- }
-}
@@ -0,0 +1,472 @@
+use std::{cmp::Reverse, rc::Rc, sync::Arc};
+
+use acp_thread::{AgentModelInfo, AgentModelList, AgentModelSelector};
+use agent_client_protocol as acp;
+use anyhow::Result;
+use collections::IndexMap;
+use futures::FutureExt;
+use fuzzy::{StringMatchCandidate, match_strings};
+use gpui::{Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, Task, WeakEntity};
+use ordered_float::OrderedFloat;
+use picker::{Picker, PickerDelegate};
+use ui::{
+ AnyElement, App, Context, IntoElement, ListItem, ListItemSpacing, SharedString, Window,
+ prelude::*, rems,
+};
+use util::ResultExt;
+
+pub type AcpModelSelector = Picker<AcpModelPickerDelegate>;
+
+pub fn acp_model_selector(
+ session_id: acp::SessionId,
+ selector: Rc<dyn AgentModelSelector>,
+ window: &mut Window,
+ cx: &mut Context<AcpModelSelector>,
+) -> AcpModelSelector {
+ let delegate = AcpModelPickerDelegate::new(session_id, selector, window, cx);
+ Picker::list(delegate, window, cx)
+ .show_scrollbar(true)
+ .width(rems(20.))
+ .max_height(Some(rems(20.).into()))
+}
+
+enum AcpModelPickerEntry {
+ Separator(SharedString),
+ Model(AgentModelInfo),
+}
+
+pub struct AcpModelPickerDelegate {
+ session_id: acp::SessionId,
+ selector: Rc<dyn AgentModelSelector>,
+ filtered_entries: Vec<AcpModelPickerEntry>,
+ models: Option<AgentModelList>,
+ selected_index: usize,
+ selected_model: Option<AgentModelInfo>,
+ _refresh_models_task: Task<()>,
+}
+
+impl AcpModelPickerDelegate {
+ fn new(
+ session_id: acp::SessionId,
+ selector: Rc<dyn AgentModelSelector>,
+ window: &mut Window,
+ cx: &mut Context<AcpModelSelector>,
+ ) -> Self {
+ let mut rx = selector.watch(cx);
+ let refresh_models_task = cx.spawn_in(window, {
+ let session_id = session_id.clone();
+ async move |this, cx| {
+ async fn refresh(
+ this: &WeakEntity<Picker<AcpModelPickerDelegate>>,
+ session_id: &acp::SessionId,
+ cx: &mut AsyncWindowContext,
+ ) -> Result<()> {
+ let (models_task, selected_model_task) = this.update(cx, |this, cx| {
+ (
+ this.delegate.selector.list_models(cx),
+ this.delegate.selector.selected_model(session_id, cx),
+ )
+ })?;
+
+ let (models, selected_model) = futures::join!(models_task, selected_model_task);
+
+ this.update_in(cx, |this, window, cx| {
+ this.delegate.models = models.ok();
+ this.delegate.selected_model = selected_model.ok();
+ this.delegate.update_matches(this.query(cx), window, cx)
+ })?
+ .await;
+
+ Ok(())
+ }
+
+ refresh(&this, &session_id, cx).await.log_err();
+ while let Ok(()) = rx.recv().await {
+ refresh(&this, &session_id, cx).await.log_err();
+ }
+ }
+ });
+
+ Self {
+ session_id,
+ selector,
+ filtered_entries: Vec::new(),
+ models: None,
+ selected_model: None,
+ selected_index: 0,
+ _refresh_models_task: refresh_models_task,
+ }
+ }
+
+ pub fn active_model(&self) -> Option<&AgentModelInfo> {
+ self.selected_model.as_ref()
+ }
+}
+
+impl PickerDelegate for AcpModelPickerDelegate {
+ type ListItem = AnyElement;
+
+ fn match_count(&self) -> usize {
+ self.filtered_entries.len()
+ }
+
+ fn selected_index(&self) -> usize {
+ self.selected_index
+ }
+
+ fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
+ self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
+ cx.notify();
+ }
+
+ fn can_select(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ _cx: &mut Context<Picker<Self>>,
+ ) -> bool {
+ match self.filtered_entries.get(ix) {
+ Some(AcpModelPickerEntry::Model(_)) => true,
+ Some(AcpModelPickerEntry::Separator(_)) | None => false,
+ }
+ }
+
+ fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+ "Select a model…".into()
+ }
+
+ fn update_matches(
+ &mut self,
+ query: String,
+ window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> Task<()> {
+ cx.spawn_in(window, async move |this, cx| {
+ let filtered_models = match this
+ .read_with(cx, |this, cx| {
+ this.delegate.models.clone().map(move |models| {
+ fuzzy_search(models, query, cx.background_executor().clone())
+ })
+ })
+ .ok()
+ .flatten()
+ {
+ Some(task) => task.await,
+ None => AgentModelList::Flat(vec![]),
+ };
+
+ this.update_in(cx, |this, window, cx| {
+ this.delegate.filtered_entries =
+ info_list_to_picker_entries(filtered_models).collect();
+ // Finds the currently selected model in the list
+ let new_index = this
+ .delegate
+ .selected_model
+ .as_ref()
+ .and_then(|selected| {
+ this.delegate.filtered_entries.iter().position(|entry| {
+ if let AcpModelPickerEntry::Model(model_info) = entry {
+ model_info.id == selected.id
+ } else {
+ false
+ }
+ })
+ })
+ .unwrap_or(0);
+ this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx);
+ cx.notify();
+ })
+ .ok();
+ })
+ }
+
+ fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+ if let Some(AcpModelPickerEntry::Model(model_info)) =
+ self.filtered_entries.get(self.selected_index)
+ {
+ self.selector
+ .select_model(self.session_id.clone(), model_info.id.clone(), cx)
+ .detach_and_log_err(cx);
+ self.selected_model = Some(model_info.clone());
+ let current_index = self.selected_index;
+ self.set_selected_index(current_index, window, cx);
+
+ cx.emit(DismissEvent);
+ }
+ }
+
+ fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
+ cx.emit(DismissEvent);
+ }
+
+ fn render_match(
+ &self,
+ ix: usize,
+ selected: bool,
+ _: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> Option<Self::ListItem> {
+ match self.filtered_entries.get(ix)? {
+ AcpModelPickerEntry::Separator(title) => Some(
+ div()
+ .px_2()
+ .pb_1()
+ .when(ix > 1, |this| {
+ this.mt_1()
+ .pt_2()
+ .border_t_1()
+ .border_color(cx.theme().colors().border_variant)
+ })
+ .child(
+ Label::new(title)
+ .size(LabelSize::XSmall)
+ .color(Color::Muted),
+ )
+ .into_any_element(),
+ ),
+ AcpModelPickerEntry::Model(model_info) => {
+ let is_selected = Some(model_info) == self.selected_model.as_ref();
+
+ let model_icon_color = if is_selected {
+ Color::Accent
+ } else {
+ Color::Muted
+ };
+
+ Some(
+ ListItem::new(ix)
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .start_slot::<Icon>(model_info.icon.map(|icon| {
+ Icon::new(icon)
+ .color(model_icon_color)
+ .size(IconSize::Small)
+ }))
+ .child(
+ h_flex()
+ .w_full()
+ .pl_0p5()
+ .gap_1p5()
+ .w(px(240.))
+ .child(Label::new(model_info.name.clone()).truncate()),
+ )
+ .end_slot(div().pr_3().when(is_selected, |this| {
+ this.child(
+ Icon::new(IconName::Check)
+ .color(Color::Accent)
+ .size(IconSize::Small),
+ )
+ }))
+ .into_any_element(),
+ )
+ }
+ }
+ }
+
+ fn render_footer(
+ &self,
+ _: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> Option<gpui::AnyElement> {
+ Some(
+ h_flex()
+ .w_full()
+ .border_t_1()
+ .border_color(cx.theme().colors().border_variant)
+ .p_1()
+ .gap_4()
+ .justify_between()
+ .child(
+ Button::new("configure", "Configure")
+ .icon(IconName::Settings)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .icon_position(IconPosition::Start)
+ .on_click(|_, window, cx| {
+ window.dispatch_action(
+ zed_actions::agent::OpenSettings.boxed_clone(),
+ cx,
+ );
+ }),
+ )
+ .into_any(),
+ )
+ }
+}
+
+fn info_list_to_picker_entries(
+ model_list: AgentModelList,
+) -> impl Iterator<Item = AcpModelPickerEntry> {
+ match model_list {
+ AgentModelList::Flat(list) => {
+ itertools::Either::Left(list.into_iter().map(AcpModelPickerEntry::Model))
+ }
+ AgentModelList::Grouped(index_map) => {
+ itertools::Either::Right(index_map.into_iter().flat_map(|(group_name, models)| {
+ std::iter::once(AcpModelPickerEntry::Separator(group_name.0))
+ .chain(models.into_iter().map(AcpModelPickerEntry::Model))
+ }))
+ }
+ }
+}
+
+async fn fuzzy_search(
+ model_list: AgentModelList,
+ query: String,
+ executor: BackgroundExecutor,
+) -> AgentModelList {
+ async fn fuzzy_search_list(
+ model_list: Vec<AgentModelInfo>,
+ query: &str,
+ executor: BackgroundExecutor,
+ ) -> Vec<AgentModelInfo> {
+ let candidates = model_list
+ .iter()
+ .enumerate()
+ .map(|(ix, model)| {
+ StringMatchCandidate::new(ix, &format!("{}/{}", model.id, model.name))
+ })
+ .collect::<Vec<_>>();
+ let mut matches = match_strings(
+ &candidates,
+ query,
+ false,
+ true,
+ 100,
+ &Default::default(),
+ executor,
+ )
+ .await;
+
+ matches.sort_unstable_by_key(|mat| {
+ let candidate = &candidates[mat.candidate_id];
+ (Reverse(OrderedFloat(mat.score)), candidate.id)
+ });
+
+ matches
+ .into_iter()
+ .map(|mat| model_list[mat.candidate_id].clone())
+ .collect()
+ }
+
+ match model_list {
+ AgentModelList::Flat(model_list) => {
+ AgentModelList::Flat(fuzzy_search_list(model_list, &query, executor).await)
+ }
+ AgentModelList::Grouped(index_map) => {
+ let groups =
+ futures::future::join_all(index_map.into_iter().map(|(group_name, models)| {
+ fuzzy_search_list(models, &query, executor.clone())
+ .map(|results| (group_name, results))
+ }))
+ .await;
+ AgentModelList::Grouped(IndexMap::from_iter(
+ groups
+ .into_iter()
+ .filter(|(_, results)| !results.is_empty()),
+ ))
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use gpui::TestAppContext;
+
+ use super::*;
+
+ fn create_model_list(grouped_models: Vec<(&str, Vec<&str>)>) -> AgentModelList {
+ AgentModelList::Grouped(IndexMap::from_iter(grouped_models.into_iter().map(
+ |(group, models)| {
+ (
+ acp_thread::AgentModelGroupName(group.to_string().into()),
+ models
+ .into_iter()
+ .map(|model| acp_thread::AgentModelInfo {
+ id: acp_thread::AgentModelId(model.to_string().into()),
+ name: model.to_string().into(),
+ icon: None,
+ })
+ .collect::<Vec<_>>(),
+ )
+ },
+ )))
+ }
+
+ fn assert_models_eq(result: AgentModelList, expected: Vec<(&str, Vec<&str>)>) {
+ let AgentModelList::Grouped(groups) = result else {
+ panic!("Expected LanguageModelInfoList::Grouped, got {:?}", result);
+ };
+
+ assert_eq!(
+ groups.len(),
+ expected.len(),
+ "Number of groups doesn't match"
+ );
+
+ for (i, (expected_group, expected_models)) in expected.iter().enumerate() {
+ let (actual_group, actual_models) = groups.get_index(i).unwrap();
+ assert_eq!(
+ actual_group.0.as_ref(),
+ *expected_group,
+ "Group at position {} doesn't match expected group",
+ i
+ );
+ assert_eq!(
+ actual_models.len(),
+ expected_models.len(),
+ "Number of models in group {} doesn't match",
+ expected_group
+ );
+
+ for (j, expected_model_name) in expected_models.iter().enumerate() {
+ assert_eq!(
+ actual_models[j].name, *expected_model_name,
+ "Model at position {} in group {} doesn't match expected model",
+ j, expected_group
+ );
+ }
+ }
+ }
+
+ #[gpui::test]
+ async fn test_fuzzy_match(cx: &mut TestAppContext) {
+ let models = create_model_list(vec![
+ (
+ "zed",
+ vec![
+ "Claude 3.7 Sonnet",
+ "Claude 3.7 Sonnet Thinking",
+ "gpt-4.1",
+ "gpt-4.1-nano",
+ ],
+ ),
+ ("openai", vec!["gpt-3.5-turbo", "gpt-4.1", "gpt-4.1-nano"]),
+ ("ollama", vec!["mistral", "deepseek"]),
+ ]);
+
+ // Results should preserve models order whenever possible.
+ // In the case below, `zed/gpt-4.1` and `openai/gpt-4.1` have identical
+ // similarity scores, but `zed/gpt-4.1` was higher in the models list,
+ // so it should appear first in the results.
+ let results = fuzzy_search(models.clone(), "41".into(), cx.executor()).await;
+ assert_models_eq(
+ results,
+ vec![
+ ("zed", vec!["gpt-4.1", "gpt-4.1-nano"]),
+ ("openai", vec!["gpt-4.1", "gpt-4.1-nano"]),
+ ],
+ );
+
+ // Fuzzy search
+ let results = fuzzy_search(models.clone(), "4n".into(), cx.executor()).await;
+ assert_models_eq(
+ results,
+ vec![
+ ("zed", vec!["gpt-4.1-nano"]),
+ ("openai", vec!["gpt-4.1-nano"]),
+ ],
+ );
+ }
+}
@@ -0,0 +1,85 @@
+use std::rc::Rc;
+
+use acp_thread::AgentModelSelector;
+use agent_client_protocol as acp;
+use gpui::{Entity, FocusHandle};
+use picker::popover_menu::PickerPopoverMenu;
+use ui::{
+ ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, Tooltip, Window, prelude::*,
+};
+use zed_actions::agent::ToggleModelSelector;
+
+use crate::acp::{AcpModelSelector, model_selector::acp_model_selector};
+
+pub struct AcpModelSelectorPopover {
+ selector: Entity<AcpModelSelector>,
+ menu_handle: PopoverMenuHandle<AcpModelSelector>,
+ focus_handle: FocusHandle,
+}
+
+impl AcpModelSelectorPopover {
+ pub(crate) fn new(
+ session_id: acp::SessionId,
+ selector: Rc<dyn AgentModelSelector>,
+ menu_handle: PopoverMenuHandle<AcpModelSelector>,
+ focus_handle: FocusHandle,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ Self {
+ selector: cx.new(move |cx| acp_model_selector(session_id, selector, window, cx)),
+ menu_handle,
+ focus_handle,
+ }
+ }
+
+ pub fn toggle(&self, window: &mut Window, cx: &mut Context<Self>) {
+ self.menu_handle.toggle(window, cx);
+ }
+}
+
+impl Render for AcpModelSelectorPopover {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let model = self.selector.read(cx).delegate.active_model();
+ let model_name = model
+ .as_ref()
+ .map(|model| model.name.clone())
+ .unwrap_or_else(|| SharedString::from("Select a Model"));
+
+ let model_icon = model.as_ref().and_then(|model| model.icon);
+
+ let focus_handle = self.focus_handle.clone();
+
+ PickerPopoverMenu::new(
+ self.selector.clone(),
+ ButtonLike::new("active-model")
+ .when_some(model_icon, |this, icon| {
+ this.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall))
+ })
+ .child(
+ Label::new(model_name)
+ .color(Color::Muted)
+ .size(LabelSize::Small)
+ .ml_0p5(),
+ )
+ .child(
+ Icon::new(IconName::ChevronDown)
+ .color(Color::Muted)
+ .size(IconSize::XSmall),
+ ),
+ move |window, cx| {
+ Tooltip::for_action_in(
+ "Change Model",
+ &ToggleModelSelector,
+ &focus_handle,
+ window,
+ cx,
+ )
+ },
+ gpui::Corner::BottomRight,
+ cx,
+ )
+ .with_handle(self.menu_handle.clone())
+ .render(window, cx)
+ }
+}
@@ -0,0 +1,825 @@
+use crate::acp::AcpThreadView;
+use crate::{AgentPanel, RemoveSelectedThread};
+use agent2::{HistoryEntry, HistoryStore};
+use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
+use editor::{Editor, EditorEvent};
+use fuzzy::StringMatchCandidate;
+use gpui::{
+ App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Stateful, Task,
+ UniformListScrollHandle, WeakEntity, Window, uniform_list,
+};
+use std::{fmt::Display, ops::Range};
+use text::Bias;
+use time::{OffsetDateTime, UtcOffset};
+use ui::{
+ HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Scrollbar, ScrollbarState,
+ Tooltip, prelude::*,
+};
+
+pub struct AcpThreadHistory {
+ pub(crate) history_store: Entity<HistoryStore>,
+ scroll_handle: UniformListScrollHandle,
+ selected_index: usize,
+ hovered_index: Option<usize>,
+ search_editor: Entity<Editor>,
+ search_query: SharedString,
+
+ visible_items: Vec<ListItemType>,
+
+ scrollbar_visibility: bool,
+ scrollbar_state: ScrollbarState,
+ local_timezone: UtcOffset,
+
+ _update_task: Task<()>,
+ _subscriptions: Vec<gpui::Subscription>,
+}
+
+enum ListItemType {
+ BucketSeparator(TimeBucket),
+ Entry {
+ entry: HistoryEntry,
+ format: EntryTimeFormat,
+ },
+ SearchResult {
+ entry: HistoryEntry,
+ positions: Vec<usize>,
+ },
+}
+
+impl ListItemType {
+ fn history_entry(&self) -> Option<&HistoryEntry> {
+ match self {
+ ListItemType::Entry { entry, .. } => Some(entry),
+ ListItemType::SearchResult { entry, .. } => Some(entry),
+ _ => None,
+ }
+ }
+}
+
+pub enum ThreadHistoryEvent {
+ Open(HistoryEntry),
+}
+
+impl EventEmitter<ThreadHistoryEvent> for AcpThreadHistory {}
+
+impl AcpThreadHistory {
+ pub(crate) fn new(
+ history_store: Entity<agent2::HistoryStore>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let search_editor = cx.new(|cx| {
+ let mut editor = Editor::single_line(window, cx);
+ editor.set_placeholder_text("Search threads...", cx);
+ editor
+ });
+
+ let search_editor_subscription =
+ cx.subscribe(&search_editor, |this, search_editor, event, cx| {
+ if let EditorEvent::BufferEdited = event {
+ let query = search_editor.read(cx).text(cx);
+ if this.search_query != query {
+ this.search_query = query.into();
+ this.update_visible_items(false, cx);
+ }
+ }
+ });
+
+ let history_store_subscription = cx.observe(&history_store, |this, _, cx| {
+ this.update_visible_items(true, cx);
+ });
+
+ let scroll_handle = UniformListScrollHandle::default();
+ let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
+
+ let mut this = Self {
+ history_store,
+ scroll_handle,
+ selected_index: 0,
+ hovered_index: None,
+ visible_items: Default::default(),
+ search_editor,
+ scrollbar_visibility: true,
+ scrollbar_state,
+ local_timezone: UtcOffset::from_whole_seconds(
+ chrono::Local::now().offset().local_minus_utc(),
+ )
+ .unwrap(),
+ search_query: SharedString::default(),
+ _subscriptions: vec![search_editor_subscription, history_store_subscription],
+ _update_task: Task::ready(()),
+ };
+ this.update_visible_items(false, cx);
+ this
+ }
+
+ fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
+ let entries = self
+ .history_store
+ .update(cx, |store, _| store.entries().collect());
+ let new_list_items = if self.search_query.is_empty() {
+ self.add_list_separators(entries, cx)
+ } else {
+ self.filter_search_results(entries, cx)
+ };
+ let selected_history_entry = if preserve_selected_item {
+ self.selected_history_entry().cloned()
+ } else {
+ None
+ };
+
+ self._update_task = cx.spawn(async move |this, cx| {
+ let new_visible_items = new_list_items.await;
+ this.update(cx, |this, cx| {
+ let new_selected_index = if let Some(history_entry) = selected_history_entry {
+ let history_entry_id = history_entry.id();
+ new_visible_items
+ .iter()
+ .position(|visible_entry| {
+ visible_entry
+ .history_entry()
+ .is_some_and(|entry| entry.id() == history_entry_id)
+ })
+ .unwrap_or(0)
+ } else {
+ 0
+ };
+
+ this.visible_items = new_visible_items;
+ this.set_selected_index(new_selected_index, Bias::Right, cx);
+ cx.notify();
+ })
+ .ok();
+ });
+ }
+
+ fn add_list_separators(&self, entries: Vec<HistoryEntry>, cx: &App) -> Task<Vec<ListItemType>> {
+ cx.background_spawn(async move {
+ let mut items = Vec::with_capacity(entries.len() + 1);
+ let mut bucket = None;
+ let today = Local::now().naive_local().date();
+
+ for entry in entries.into_iter() {
+ let entry_date = entry
+ .updated_at()
+ .with_timezone(&Local)
+ .naive_local()
+ .date();
+ let entry_bucket = TimeBucket::from_dates(today, entry_date);
+
+ if Some(entry_bucket) != bucket {
+ bucket = Some(entry_bucket);
+ items.push(ListItemType::BucketSeparator(entry_bucket));
+ }
+
+ items.push(ListItemType::Entry {
+ entry,
+ format: entry_bucket.into(),
+ });
+ }
+ items
+ })
+ }
+
+ fn filter_search_results(
+ &self,
+ entries: Vec<HistoryEntry>,
+ cx: &App,
+ ) -> Task<Vec<ListItemType>> {
+ let query = self.search_query.clone();
+ cx.background_spawn({
+ let executor = cx.background_executor().clone();
+ async move {
+ let mut candidates = Vec::with_capacity(entries.len());
+
+ for (idx, entry) in entries.iter().enumerate() {
+ candidates.push(StringMatchCandidate::new(idx, entry.title()));
+ }
+
+ const MAX_MATCHES: usize = 100;
+
+ let matches = fuzzy::match_strings(
+ &candidates,
+ &query,
+ false,
+ true,
+ MAX_MATCHES,
+ &Default::default(),
+ executor,
+ )
+ .await;
+
+ matches
+ .into_iter()
+ .map(|search_match| ListItemType::SearchResult {
+ entry: entries[search_match.candidate_id].clone(),
+ positions: search_match.positions,
+ })
+ .collect()
+ }
+ })
+ }
+
+ fn search_produced_no_matches(&self) -> bool {
+ self.visible_items.is_empty() && !self.search_query.is_empty()
+ }
+
+ fn selected_history_entry(&self) -> Option<&HistoryEntry> {
+ self.get_history_entry(self.selected_index)
+ }
+
+ fn get_history_entry(&self, visible_items_ix: usize) -> Option<&HistoryEntry> {
+ self.visible_items.get(visible_items_ix)?.history_entry()
+ }
+
+ fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
+ if self.visible_items.len() == 0 {
+ self.selected_index = 0;
+ return;
+ }
+ while matches!(
+ self.visible_items.get(index),
+ None | Some(ListItemType::BucketSeparator(..))
+ ) {
+ index = match bias {
+ Bias::Left => {
+ if index == 0 {
+ self.visible_items.len() - 1
+ } else {
+ index - 1
+ }
+ }
+ Bias::Right => {
+ if index >= self.visible_items.len() - 1 {
+ 0
+ } else {
+ index + 1
+ }
+ }
+ };
+ }
+ self.selected_index = index;
+ self.scroll_handle
+ .scroll_to_item(index, ScrollStrategy::Top);
+ cx.notify()
+ }
+
+ pub fn select_previous(
+ &mut self,
+ _: &menu::SelectPrevious,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if self.selected_index == 0 {
+ self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
+ } else {
+ self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
+ }
+ }
+
+ pub fn select_next(
+ &mut self,
+ _: &menu::SelectNext,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if self.selected_index == self.visible_items.len() - 1 {
+ self.set_selected_index(0, Bias::Right, cx);
+ } else {
+ self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
+ }
+ }
+
+ fn select_first(
+ &mut self,
+ _: &menu::SelectFirst,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.set_selected_index(0, Bias::Right, cx);
+ }
+
+ fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+ self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
+ }
+
+ fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
+ self.confirm_entry(self.selected_index, cx);
+ }
+
+ fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
+ let Some(entry) = self.get_history_entry(ix) else {
+ return;
+ };
+ cx.emit(ThreadHistoryEvent::Open(entry.clone()));
+ }
+
+ fn remove_selected_thread(
+ &mut self,
+ _: &RemoveSelectedThread,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.remove_thread(self.selected_index, cx)
+ }
+
+ fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
+ let Some(entry) = self.get_history_entry(visible_item_ix) else {
+ return;
+ };
+
+ let task = match entry {
+ HistoryEntry::AcpThread(thread) => self
+ .history_store
+ .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)),
+ HistoryEntry::TextThread(context) => self.history_store.update(cx, |this, cx| {
+ this.delete_text_thread(context.path.clone(), cx)
+ }),
+ };
+ task.detach_and_log_err(cx);
+ }
+
+ fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
+ if !(self.scrollbar_visibility || self.scrollbar_state.is_dragging()) {
+ return None;
+ }
+
+ Some(
+ div()
+ .occlude()
+ .id("thread-history-scroll")
+ .h_full()
+ .bg(cx.theme().colors().panel_background.opacity(0.8))
+ .border_l_1()
+ .border_color(cx.theme().colors().border_variant)
+ .absolute()
+ .right_1()
+ .top_0()
+ .bottom_0()
+ .w_4()
+ .pl_1()
+ .cursor_default()
+ .on_mouse_move(cx.listener(|_, _, _window, cx| {
+ cx.notify();
+ cx.stop_propagation()
+ }))
+ .on_hover(|_, _window, cx| {
+ cx.stop_propagation();
+ })
+ .on_any_mouse_down(|_, _window, cx| {
+ cx.stop_propagation();
+ })
+ .on_scroll_wheel(cx.listener(|_, _, _window, cx| {
+ cx.notify();
+ }))
+ .children(Scrollbar::vertical(self.scrollbar_state.clone())),
+ )
+ }
+
+ fn render_list_items(
+ &mut self,
+ range: Range<usize>,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Vec<AnyElement> {
+ self.visible_items
+ .get(range.clone())
+ .into_iter()
+ .flatten()
+ .enumerate()
+ .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
+ .collect()
+ }
+
+ fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
+ match item {
+ ListItemType::Entry { entry, format } => self
+ .render_history_entry(entry, *format, ix, Vec::default(), cx)
+ .into_any(),
+ ListItemType::SearchResult { entry, positions } => self.render_history_entry(
+ entry,
+ EntryTimeFormat::DateAndTime,
+ ix,
+ positions.clone(),
+ cx,
+ ),
+ ListItemType::BucketSeparator(bucket) => div()
+ .px(DynamicSpacing::Base06.rems(cx))
+ .pt_2()
+ .pb_1()
+ .child(
+ Label::new(bucket.to_string())
+ .size(LabelSize::XSmall)
+ .color(Color::Muted),
+ )
+ .into_any_element(),
+ }
+ }
+
+ fn render_history_entry(
+ &self,
+ entry: &HistoryEntry,
+ format: EntryTimeFormat,
+ ix: usize,
+ highlight_positions: Vec<usize>,
+ cx: &Context<Self>,
+ ) -> AnyElement {
+ let selected = ix == self.selected_index;
+ let hovered = Some(ix) == self.hovered_index;
+ let timestamp = entry.updated_at().timestamp();
+ let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
+
+ h_flex()
+ .w_full()
+ .pb_1()
+ .child(
+ ListItem::new(ix)
+ .rounded()
+ .toggle_state(selected)
+ .spacing(ListItemSpacing::Sparse)
+ .start_slot(
+ h_flex()
+ .w_full()
+ .gap_2()
+ .justify_between()
+ .child(
+ HighlightedLabel::new(entry.title(), highlight_positions)
+ .size(LabelSize::Small)
+ .truncate(),
+ )
+ .child(
+ Label::new(thread_timestamp)
+ .color(Color::Muted)
+ .size(LabelSize::XSmall),
+ ),
+ )
+ .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
+ if *is_hovered {
+ this.hovered_index = Some(ix);
+ } else if this.hovered_index == Some(ix) {
+ this.hovered_index = None;
+ }
+
+ cx.notify();
+ }))
+ .end_slot::<IconButton>(if hovered || selected {
+ Some(
+ IconButton::new("delete", IconName::Trash)
+ .shape(IconButtonShape::Square)
+ .icon_size(IconSize::XSmall)
+ .icon_color(Color::Muted)
+ .tooltip(move |window, cx| {
+ Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
+ })
+ .on_click(
+ cx.listener(move |this, _, _, cx| this.remove_thread(ix, cx)),
+ ),
+ )
+ } else {
+ None
+ })
+ .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
+ )
+ .into_any_element()
+ }
+}
+
+impl Focusable for AcpThreadHistory {
+ fn focus_handle(&self, cx: &App) -> FocusHandle {
+ self.search_editor.focus_handle(cx)
+ }
+}
+
+impl Render for AcpThreadHistory {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ v_flex()
+ .key_context("ThreadHistory")
+ .size_full()
+ .on_action(cx.listener(Self::select_previous))
+ .on_action(cx.listener(Self::select_next))
+ .on_action(cx.listener(Self::select_first))
+ .on_action(cx.listener(Self::select_last))
+ .on_action(cx.listener(Self::confirm))
+ .on_action(cx.listener(Self::remove_selected_thread))
+ .when(!self.history_store.read(cx).is_empty(cx), |parent| {
+ parent.child(
+ h_flex()
+ .h(px(41.)) // Match the toolbar perfectly
+ .w_full()
+ .py_1()
+ .px_2()
+ .gap_2()
+ .justify_between()
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .child(
+ Icon::new(IconName::MagnifyingGlass)
+ .color(Color::Muted)
+ .size(IconSize::Small),
+ )
+ .child(self.search_editor.clone()),
+ )
+ })
+ .child({
+ let view = v_flex()
+ .id("list-container")
+ .relative()
+ .overflow_hidden()
+ .flex_grow();
+
+ if self.history_store.read(cx).is_empty(cx) {
+ view.justify_center()
+ .child(
+ h_flex().w_full().justify_center().child(
+ Label::new("You don't have any past threads yet.")
+ .size(LabelSize::Small),
+ ),
+ )
+ } else if self.search_produced_no_matches() {
+ view.justify_center().child(
+ h_flex().w_full().justify_center().child(
+ Label::new("No threads match your search.").size(LabelSize::Small),
+ ),
+ )
+ } else {
+ view.pr_5()
+ .child(
+ uniform_list(
+ "thread-history",
+ self.visible_items.len(),
+ cx.processor(|this, range: Range<usize>, window, cx| {
+ this.render_list_items(range, window, cx)
+ }),
+ )
+ .p_1()
+ .track_scroll(self.scroll_handle.clone())
+ .flex_grow(),
+ )
+ .when_some(self.render_scrollbar(cx), |div, scrollbar| {
+ div.child(scrollbar)
+ })
+ }
+ })
+ }
+}
+
+#[derive(IntoElement)]
+pub struct AcpHistoryEntryElement {
+ entry: HistoryEntry,
+ thread_view: WeakEntity<AcpThreadView>,
+ selected: bool,
+ hovered: bool,
+ on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
+}
+
+impl AcpHistoryEntryElement {
+ pub fn new(entry: HistoryEntry, thread_view: WeakEntity<AcpThreadView>) -> Self {
+ Self {
+ entry,
+ thread_view,
+ selected: false,
+ hovered: false,
+ on_hover: Box::new(|_, _, _| {}),
+ }
+ }
+
+ pub fn hovered(mut self, hovered: bool) -> Self {
+ self.hovered = hovered;
+ self
+ }
+
+ pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
+ self.on_hover = Box::new(on_hover);
+ self
+ }
+}
+
+impl RenderOnce for AcpHistoryEntryElement {
+ fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+ let id = self.entry.id();
+ let title = self.entry.title();
+ let timestamp = self.entry.updated_at();
+
+ let formatted_time = {
+ let now = chrono::Utc::now();
+ let duration = now.signed_duration_since(timestamp);
+
+ if duration.num_days() > 0 {
+ format!("{}d", duration.num_days())
+ } else if duration.num_hours() > 0 {
+ format!("{}h ago", duration.num_hours())
+ } else if duration.num_minutes() > 0 {
+ format!("{}m ago", duration.num_minutes())
+ } else {
+ "Just now".to_string()
+ }
+ };
+
+ ListItem::new(id)
+ .rounded()
+ .toggle_state(self.selected)
+ .spacing(ListItemSpacing::Sparse)
+ .start_slot(
+ h_flex()
+ .w_full()
+ .gap_2()
+ .justify_between()
+ .child(Label::new(title).size(LabelSize::Small).truncate())
+ .child(
+ Label::new(formatted_time)
+ .color(Color::Muted)
+ .size(LabelSize::XSmall),
+ ),
+ )
+ .on_hover(self.on_hover)
+ .end_slot::<IconButton>(if self.hovered || self.selected {
+ Some(
+ IconButton::new("delete", IconName::Trash)
+ .shape(IconButtonShape::Square)
+ .icon_size(IconSize::XSmall)
+ .icon_color(Color::Muted)
+ .tooltip(move |window, cx| {
+ Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
+ })
+ .on_click({
+ let thread_view = self.thread_view.clone();
+ let entry = self.entry.clone();
+
+ move |_event, _window, cx| {
+ if let Some(thread_view) = thread_view.upgrade() {
+ thread_view.update(cx, |thread_view, cx| {
+ thread_view.delete_history_entry(entry.clone(), cx);
+ });
+ }
+ }
+ }),
+ )
+ } else {
+ None
+ })
+ .on_click({
+ let thread_view = self.thread_view.clone();
+ let entry = self.entry;
+
+ move |_event, window, cx| {
+ if let Some(workspace) = thread_view
+ .upgrade()
+ .and_then(|view| view.read(cx).workspace().upgrade())
+ {
+ match &entry {
+ HistoryEntry::AcpThread(thread_metadata) => {
+ if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.load_agent_thread(
+ thread_metadata.clone(),
+ window,
+ cx,
+ );
+ });
+ }
+ }
+ HistoryEntry::TextThread(context) => {
+ if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel
+ .open_saved_prompt_editor(
+ context.path.clone(),
+ window,
+ cx,
+ )
+ .detach_and_log_err(cx);
+ });
+ }
+ }
+ }
+ }
+ }
+ })
+ }
+}
+
+#[derive(Clone, Copy)]
+pub enum EntryTimeFormat {
+ DateAndTime,
+ TimeOnly,
+}
+
+impl EntryTimeFormat {
+ fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
+ let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
+
+ match self {
+ EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
+ timestamp,
+ OffsetDateTime::now_utc(),
+ timezone,
+ time_format::TimestampFormat::EnhancedAbsolute,
+ ),
+ EntryTimeFormat::TimeOnly => time_format::format_time(timestamp),
+ }
+ }
+}
+
+impl From<TimeBucket> for EntryTimeFormat {
+ fn from(bucket: TimeBucket) -> Self {
+ match bucket {
+ TimeBucket::Today => EntryTimeFormat::TimeOnly,
+ TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
+ TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
+ TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
+ TimeBucket::All => EntryTimeFormat::DateAndTime,
+ }
+ }
+}
+
+#[derive(PartialEq, Eq, Clone, Copy, Debug)]
+enum TimeBucket {
+ Today,
+ Yesterday,
+ ThisWeek,
+ PastWeek,
+ All,
+}
+
+impl TimeBucket {
+ fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
+ if date == reference {
+ return TimeBucket::Today;
+ }
+
+ if date == reference - TimeDelta::days(1) {
+ return TimeBucket::Yesterday;
+ }
+
+ let week = date.iso_week();
+
+ if reference.iso_week() == week {
+ return TimeBucket::ThisWeek;
+ }
+
+ let last_week = (reference - TimeDelta::days(7)).iso_week();
+
+ if week == last_week {
+ return TimeBucket::PastWeek;
+ }
+
+ TimeBucket::All
+ }
+}
+
+impl Display for TimeBucket {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ TimeBucket::Today => write!(f, "Today"),
+ TimeBucket::Yesterday => write!(f, "Yesterday"),
+ TimeBucket::ThisWeek => write!(f, "This Week"),
+ TimeBucket::PastWeek => write!(f, "Past Week"),
+ TimeBucket::All => write!(f, "All"),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use chrono::NaiveDate;
+
+ #[test]
+ fn test_time_bucket_from_dates() {
+ let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
+
+ let date = today;
+ assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
+
+ let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
+ assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
+
+ let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
+ assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
+
+ let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
+ assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
+
+ let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
+ assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
+
+ let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
+ assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
+
+ // All: not in this week or last week
+ let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
+ assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
+
+ // Test year boundary cases
+ let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
+
+ let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
+ assert_eq!(
+ TimeBucket::from_dates(new_year, date),
+ TimeBucket::Yesterday
+ );
+
+ let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
+ assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
+ }
+}
@@ -1,69 +1,284 @@
-use std::cell::RefCell;
-use std::collections::BTreeMap;
-use std::path::Path;
-use std::rc::Rc;
-use std::sync::Arc;
-use std::time::Duration;
-
-use agentic_coding_protocol::{self as acp};
-use assistant_tool::ActionLog;
+use acp_thread::{
+ AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk,
+ AuthRequired, LoadError, MentionUri, RetryStatus, ThreadStatus, ToolCall, ToolCallContent,
+ ToolCallStatus, UserMessageId,
+};
+use acp_thread::{AgentConnection, Plan};
+use action_log::ActionLog;
+use agent_client_protocol::{self as acp, PromptCapabilities};
+use agent_servers::{AgentServer, ClaudeCode};
+use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
+use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore};
+use anyhow::bail;
+use audio::{Audio, Sound};
use buffer_diff::BufferDiff;
+use client::zed_urls;
use collections::{HashMap, HashSet};
-use editor::{
- AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode,
- EditorStyle, MinimapVisibility, MultiBuffer, PathKey,
-};
+use editor::scroll::Autoscroll;
+use editor::{Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects};
use file_icons::FileIcons;
-use futures::channel::oneshot;
+use fs::Fs;
use gpui::{
- Action, Animation, AnimationExt, App, BorderStyle, EdgesRefinement, Empty, Entity, EntityId,
- FocusHandle, Focusable, Hsla, Length, ListOffset, ListState, SharedString, StyleRefinement,
- Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity,
- Window, div, linear_color_stop, linear_gradient, list, percentage, point, prelude::*,
- pulsating_between,
+ Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
+ EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset,
+ ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription,
+ Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window,
+ WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, point,
+ prelude::*, pulsating_between,
};
-use language::language_settings::SoftWrap;
-use language::{Buffer, Language};
+use language::Buffer;
+
+use language_model::LanguageModelRegistry;
use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
-use parking_lot::Mutex;
-use project::Project;
-use settings::Settings as _;
+use project::{Project, ProjectEntryId};
+use prompt_store::{PromptId, PromptStore};
+use rope::Point;
+use settings::{Settings as _, SettingsStore};
+use std::cell::Cell;
+use std::sync::Arc;
+use std::time::Instant;
+use std::{collections::BTreeMap, rc::Rc, time::Duration};
use text::Anchor;
use theme::ThemeSettings;
-use ui::{Disclosure, Divider, DividerColor, KeyBinding, Tooltip, prelude::*};
-use util::ResultExt;
-use workspace::{CollaboratorId, Workspace};
-use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage};
-
-use ::acp::{
- AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, Diff,
- LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallConfirmation, ToolCallContent,
- ToolCallId, ToolCallStatus,
+use ui::{
+ Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle,
+ Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*,
};
+use util::{ResultExt, size::format_file_size, time::duration_alt_display};
+use workspace::{CollaboratorId, Workspace};
+use zed_actions::agent::{Chat, ToggleModelSelector};
+use zed_actions::assistant::OpenRulesLibrary;
-use crate::acp::completion_provider::{ContextPickerCompletionProvider, MentionSet};
-use crate::acp::message_history::MessageHistory;
+use super::entry_view_state::EntryViewState;
+use crate::acp::AcpModelSelectorPopover;
+use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent};
+use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
use crate::agent_diff::AgentDiff;
-use crate::{AgentDiffPane, Follow, KeepAll, OpenAgentDiff, RejectAll};
+use crate::profile_selector::{ProfileProvider, ProfileSelector};
+
+use crate::ui::preview::UsageCallout;
+use crate::ui::{
+ AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip,
+};
+use crate::{
+ AgentDiffPane, AgentPanel, ContinueThread, ContinueWithBurnMode, ExpandMessageEditor, Follow,
+ KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, ToggleProfileSelector,
+};
const RESPONSE_PADDING_X: Pixels = px(19.);
+pub const MIN_EDITOR_LINES: usize = 4;
+pub const MAX_EDITOR_LINES: usize = 8;
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+enum ThreadFeedback {
+ Positive,
+ Negative,
+}
+
+enum ThreadError {
+ PaymentRequired,
+ ModelRequestLimitReached(cloud_llm_client::Plan),
+ ToolUseLimitReached,
+ AuthenticationRequired(SharedString),
+ Other(SharedString),
+}
+
+impl ThreadError {
+ fn from_err(error: anyhow::Error, agent: &Rc<dyn AgentServer>) -> Self {
+ if error.is::<language_model::PaymentRequiredError>() {
+ Self::PaymentRequired
+ } else if error.is::<language_model::ToolUseLimitReachedError>() {
+ Self::ToolUseLimitReached
+ } else if let Some(error) =
+ error.downcast_ref::<language_model::ModelRequestLimitReachedError>()
+ {
+ Self::ModelRequestLimitReached(error.plan)
+ } else {
+ let string = error.to_string();
+ // TODO: we should have Gemini return better errors here.
+ if agent.clone().downcast::<agent_servers::Gemini>().is_some()
+ && string.contains("Could not load the default credentials")
+ || string.contains("API key not valid")
+ || string.contains("Request had invalid authentication credentials")
+ {
+ Self::AuthenticationRequired(string.into())
+ } else {
+ Self::Other(error.to_string().into())
+ }
+ }
+ }
+}
+
+impl ProfileProvider for Entity<agent2::Thread> {
+ fn profile_id(&self, cx: &App) -> AgentProfileId {
+ self.read(cx).profile().clone()
+ }
+
+ fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) {
+ self.update(cx, |thread, _cx| {
+ thread.set_profile(profile_id);
+ });
+ }
+
+ fn profiles_supported(&self, cx: &App) -> bool {
+ self.read(cx)
+ .model()
+ .is_some_and(|model| model.supports_tools())
+ }
+}
+
+#[derive(Default)]
+struct ThreadFeedbackState {
+ feedback: Option<ThreadFeedback>,
+ comments_editor: Option<Entity<Editor>>,
+}
+
+impl ThreadFeedbackState {
+ pub fn submit(
+ &mut self,
+ thread: Entity<AcpThread>,
+ feedback: ThreadFeedback,
+ window: &mut Window,
+ cx: &mut App,
+ ) {
+ let Some(telemetry) = thread.read(cx).connection().telemetry() else {
+ return;
+ };
+
+ if self.feedback == Some(feedback) {
+ return;
+ }
+
+ self.feedback = Some(feedback);
+ match feedback {
+ ThreadFeedback::Positive => {
+ self.comments_editor = None;
+ }
+ ThreadFeedback::Negative => {
+ self.comments_editor = Some(Self::build_feedback_comments_editor(window, cx));
+ }
+ }
+ let session_id = thread.read(cx).session_id().clone();
+ let agent_name = telemetry.agent_name();
+ let task = telemetry.thread_data(&session_id, cx);
+ let rating = match feedback {
+ ThreadFeedback::Positive => "positive",
+ ThreadFeedback::Negative => "negative",
+ };
+ cx.background_spawn(async move {
+ let thread = task.await?;
+ telemetry::event!(
+ "Agent Thread Rated",
+ session_id = session_id,
+ rating = rating,
+ agent = agent_name,
+ thread = thread
+ );
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
+ pub fn submit_comments(&mut self, thread: Entity<AcpThread>, cx: &mut App) {
+ let Some(telemetry) = thread.read(cx).connection().telemetry() else {
+ return;
+ };
+
+ let Some(comments) = self
+ .comments_editor
+ .as_ref()
+ .map(|editor| editor.read(cx).text(cx))
+ .filter(|text| !text.trim().is_empty())
+ else {
+ return;
+ };
+
+ self.comments_editor.take();
+
+ let session_id = thread.read(cx).session_id().clone();
+ let agent_name = telemetry.agent_name();
+ let task = telemetry.thread_data(&session_id, cx);
+ cx.background_spawn(async move {
+ let thread = task.await?;
+ telemetry::event!(
+ "Agent Thread Feedback Comments",
+ session_id = session_id,
+ comments = comments,
+ agent = agent_name,
+ thread = thread
+ );
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
+ pub fn clear(&mut self) {
+ *self = Self::default()
+ }
+
+ pub fn dismiss_comments(&mut self) {
+ self.comments_editor.take();
+ }
+
+ fn build_feedback_comments_editor(window: &mut Window, cx: &mut App) -> Entity<Editor> {
+ let buffer = cx.new(|cx| {
+ let empty_string = String::new();
+ MultiBuffer::singleton(cx.new(|cx| Buffer::local(empty_string, cx)), cx)
+ });
+
+ let editor = cx.new(|cx| {
+ let mut editor = Editor::new(
+ editor::EditorMode::AutoHeight {
+ min_lines: 1,
+ max_lines: Some(4),
+ },
+ buffer,
+ None,
+ window,
+ cx,
+ );
+ editor.set_placeholder_text(
+ "What went wrong? Share your feedback so we can improve.",
+ cx,
+ );
+ editor
+ });
+
+ editor.read(cx).focus_handle(cx).focus(window);
+ editor
+ }
+}
pub struct AcpThreadView {
+ agent: Rc<dyn AgentServer>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
thread_state: ThreadState,
- diff_editors: HashMap<EntityId, Entity<Editor>>,
- message_editor: Entity<Editor>,
- message_set_from_history: bool,
- _message_editor_subscription: Subscription,
- mention_set: Arc<Mutex<MentionSet>>,
- last_error: Option<Entity<Markdown>>,
+ history_store: Entity<HistoryStore>,
+ hovered_recent_history_item: Option<usize>,
+ entry_view_state: Entity<EntryViewState>,
+ message_editor: Entity<MessageEditor>,
+ focus_handle: FocusHandle,
+ model_selector: Option<Entity<AcpModelSelectorPopover>>,
+ profile_selector: Option<Entity<ProfileSelector>>,
+ notifications: Vec<WindowHandle<AgentNotification>>,
+ notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
+ thread_retry_status: Option<RetryStatus>,
+ thread_error: Option<ThreadError>,
+ thread_feedback: ThreadFeedbackState,
list_state: ListState,
+ scrollbar_state: ScrollbarState,
auth_task: Option<Task<()>>,
- expanded_tool_calls: HashSet<ToolCallId>,
+ expanded_tool_calls: HashSet<acp::ToolCallId>,
expanded_thinking_blocks: HashSet<(usize, usize)>,
edits_expanded: bool,
- message_history: Rc<RefCell<MessageHistory<acp::SendUserMessageParams>>>,
+ plan_expanded: bool,
+ editor_expanded: bool,
+ editing_message: Option<usize>,
+ prompt_capabilities: Rc<Cell<PromptCapabilities>>,
+ is_loading_contents: bool,
+ _cancel_task: Option<Task<()>>,
+ _subscriptions: [Subscription; 3],
}
enum ThreadState {
@@ -72,111 +287,117 @@ enum ThreadState {
},
Ready {
thread: Entity<AcpThread>,
- _subscription: [Subscription; 2],
+ title_editor: Option<Entity<Editor>>,
+ _subscriptions: Vec<Subscription>,
},
LoadError(LoadError),
Unauthenticated {
- thread: Entity<AcpThread>,
+ connection: Rc<dyn AgentConnection>,
+ description: Option<Entity<Markdown>>,
+ configuration_view: Option<AnyView>,
+ pending_auth_method: Option<acp::AuthMethodId>,
+ _subscription: Option<Subscription>,
},
}
impl AcpThreadView {
pub fn new(
+ agent: Rc<dyn AgentServer>,
+ resume_thread: Option<DbThreadMetadata>,
+ summarize_thread: Option<DbThreadMetadata>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
- message_history: Rc<RefCell<MessageHistory<acp::SendUserMessageParams>>>,
+ history_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
- let language = Language::new(
- language::LanguageConfig {
- completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
- ..Default::default()
- },
- None,
- );
+ let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default()));
+ let prevent_slash_commands = agent.clone().downcast::<ClaudeCode>().is_some();
- let mention_set = Arc::new(Mutex::new(MentionSet::default()));
+ let placeholder = if agent.name() == "Zed Agent" {
+ format!("Message the {} — @ to include context", agent.name())
+ } else {
+ format!("Message {} — @ to include context", agent.name())
+ };
let message_editor = cx.new(|cx| {
- let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
- let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
-
- let mut editor = Editor::new(
+ let mut editor = MessageEditor::new(
+ workspace.clone(),
+ project.clone(),
+ history_store.clone(),
+ prompt_store.clone(),
+ prompt_capabilities.clone(),
+ placeholder,
+ prevent_slash_commands,
editor::EditorMode::AutoHeight {
- min_lines: 4,
- max_lines: None,
+ min_lines: MIN_EDITOR_LINES,
+ max_lines: Some(MAX_EDITOR_LINES),
},
- buffer,
- None,
window,
cx,
);
- editor.set_placeholder_text("Message the agent - @ to include files", cx);
- editor.set_show_indent_guides(false, cx);
- editor.set_soft_wrap();
- editor.set_use_modal_editing(true);
- editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
- mention_set.clone(),
- workspace.clone(),
- cx.weak_entity(),
- ))));
- editor.set_context_menu_options(ContextMenuOptions {
- min_entries_visible: 12,
- max_entries_visible: 12,
- placement: Some(ContextMenuPlacement::Above),
- });
+ if let Some(entry) = summarize_thread {
+ editor.insert_thread_summary(entry, window, cx);
+ }
editor
});
- let message_editor_subscription = cx.subscribe(&message_editor, |this, _, event, _| {
- if let editor::EditorEvent::BufferEdited = &event {
- if !this.message_set_from_history {
- this.message_history.borrow_mut().reset_position();
- }
- this.message_set_from_history = false;
- }
- });
+ let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
- let mention_set = mention_set.clone();
+ let entry_view_state = cx.new(|_| {
+ EntryViewState::new(
+ workspace.clone(),
+ project.clone(),
+ history_store.clone(),
+ prompt_store.clone(),
+ prompt_capabilities.clone(),
+ prevent_slash_commands,
+ )
+ });
- let list_state = ListState::new(
- 0,
- gpui::ListAlignment::Bottom,
- px(2048.0),
- cx.processor({
- move |this: &mut Self, index: usize, window, cx| {
- let Some((entry, len)) = this.thread().and_then(|thread| {
- let entries = &thread.read(cx).entries();
- Some((entries.get(index)?, entries.len()))
- }) else {
- return Empty.into_any();
- };
- this.render_entry(index, len, entry, window, cx)
- }
- }),
- );
+ let subscriptions = [
+ cx.observe_global_in::<SettingsStore>(window, Self::settings_changed),
+ cx.subscribe_in(&message_editor, window, Self::handle_message_editor_event),
+ cx.subscribe_in(&entry_view_state, window, Self::handle_entry_view_event),
+ ];
Self {
+ agent: agent.clone(),
workspace: workspace.clone(),
project: project.clone(),
- thread_state: Self::initial_state(workspace, project, window, cx),
+ entry_view_state,
+ thread_state: Self::initial_state(agent, resume_thread, workspace, project, window, cx),
message_editor,
- message_set_from_history: false,
- _message_editor_subscription: message_editor_subscription,
- mention_set,
- diff_editors: Default::default(),
- list_state: list_state,
- last_error: None,
+ model_selector: None,
+ profile_selector: None,
+ notifications: Vec::new(),
+ notification_subscriptions: HashMap::default(),
+ list_state: list_state.clone(),
+ scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()),
+ thread_retry_status: None,
+ thread_error: None,
+ thread_feedback: Default::default(),
auth_task: None,
expanded_tool_calls: HashSet::default(),
expanded_thinking_blocks: HashSet::default(),
+ editing_message: None,
edits_expanded: false,
- message_history,
+ plan_expanded: false,
+ editor_expanded: false,
+ history_store,
+ hovered_recent_history_item: None,
+ prompt_capabilities,
+ is_loading_contents: false,
+ _subscriptions: subscriptions,
+ _cancel_task: None,
+ focus_handle: cx.focus_handle(),
}
}
fn initial_state(
+ agent: Rc<dyn AgentServer>,
+ resume_thread: Option<DbThreadMetadata>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
window: &mut Window,
@@ -189,13 +410,17 @@ impl AcpThreadView {
.map(|worktree| worktree.read(cx).abs_path())
.unwrap_or_else(|| paths::home_dir().as_path().into());
+ let connect_task = agent.connect(&root_dir, &project, cx);
let load_task = cx.spawn_in(window, async move |this, cx| {
- let thread = match AcpThread::spawn(agent_servers::Gemini, &root_dir, project, cx).await
- {
- Ok(thread) => thread,
+ let connection = match connect_task.await {
+ Ok(connection) => connection,
Err(err) => {
- this.update(cx, |this, cx| {
- this.handle_load_error(err, cx);
+ this.update_in(cx, |this, window, cx| {
+ if err.downcast_ref::<LoadError>().is_some() {
+ this.handle_load_error(err, window, cx);
+ } else {
+ this.handle_thread_error(err, cx);
+ }
cx.notify();
})
.log_err();
@@ -203,69 +428,132 @@ impl AcpThreadView {
}
};
- let init_response = async {
- let resp = thread
- .read_with(cx, |thread, _cx| thread.initialize())?
- .await?;
- anyhow::Ok(resp)
+ let result = if let Some(native_agent) = connection
+ .clone()
+ .downcast::<agent2::NativeAgentConnection>()
+ && let Some(resume) = resume_thread.clone()
+ {
+ cx.update(|_, cx| {
+ native_agent
+ .0
+ .update(cx, |agent, cx| agent.open_thread(resume.id, cx))
+ })
+ .log_err()
+ } else {
+ cx.update(|_, cx| {
+ connection
+ .clone()
+ .new_thread(project.clone(), &root_dir, cx)
+ })
+ .log_err()
};
- let result = match init_response.await {
- Err(e) => {
- let mut cx = cx.clone();
- if e.downcast_ref::<oneshot::Canceled>().is_some() {
- let child_status = thread
- .update(&mut cx, |thread, _| thread.child_status())
- .ok()
- .flatten();
- if let Some(child_status) = child_status {
- match child_status.await {
- Ok(_) => Err(e),
- Err(e) => Err(e),
- }
- } else {
- Err(e)
- }
- } else {
- Err(e)
- }
- }
- Ok(response) => {
- if !response.is_authenticated {
- this.update(cx, |this, _| {
- this.thread_state = ThreadState::Unauthenticated { thread };
+ let Some(result) = result else {
+ return;
+ };
+
+ let result = match result.await {
+ Err(e) => match e.downcast::<acp_thread::AuthRequired>() {
+ Ok(err) => {
+ cx.update(|window, cx| {
+ Self::handle_auth_required(this, err, agent, connection, window, cx)
})
- .ok();
+ .log_err();
return;
- };
- Ok(())
- }
+ }
+ Err(err) => Err(err),
+ },
+ Ok(thread) => Ok(thread),
};
this.update_in(cx, |this, window, cx| {
match result {
- Ok(()) => {
- let thread_subscription =
- cx.subscribe_in(&thread, window, Self::handle_thread_event);
-
+ Ok(thread) => {
let action_log = thread.read(cx).action_log().clone();
- let action_log_subscription =
- cx.observe(&action_log, |_, _, cx| cx.notify());
- this.list_state
- .splice(0..0, thread.read(cx).entries().len());
+ this.prompt_capabilities
+ .set(connection.prompt_capabilities());
+
+ let count = thread.read(cx).entries().len();
+ this.list_state.splice(0..0, count);
+ this.entry_view_state.update(cx, |view_state, cx| {
+ for ix in 0..count {
+ view_state.sync_entry(ix, &thread, window, cx);
+ }
+ });
+
+ if let Some(resume) = resume_thread {
+ this.history_store.update(cx, |history, cx| {
+ history.push_recently_opened_entry(
+ HistoryEntryId::AcpThread(resume.id),
+ cx,
+ );
+ });
+ }
AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx);
+ this.model_selector =
+ thread
+ .read(cx)
+ .connection()
+ .model_selector()
+ .map(|selector| {
+ cx.new(|cx| {
+ AcpModelSelectorPopover::new(
+ thread.read(cx).session_id().clone(),
+ selector,
+ PopoverMenuHandle::default(),
+ this.focus_handle(cx),
+ window,
+ cx,
+ )
+ })
+ });
+
+ let mut subscriptions = vec![
+ cx.subscribe_in(&thread, window, Self::handle_thread_event),
+ cx.observe(&action_log, |_, _, cx| cx.notify()),
+ ];
+
+ let title_editor =
+ if thread.update(cx, |thread, cx| thread.can_set_title(cx)) {
+ let editor = cx.new(|cx| {
+ let mut editor = Editor::single_line(window, cx);
+ editor.set_text(thread.read(cx).title(), window, cx);
+ editor
+ });
+ subscriptions.push(cx.subscribe_in(
+ &editor,
+ window,
+ Self::handle_title_editor_event,
+ ));
+ Some(editor)
+ } else {
+ None
+ };
this.thread_state = ThreadState::Ready {
thread,
- _subscription: [thread_subscription, action_log_subscription],
+ title_editor,
+ _subscriptions: subscriptions,
};
+ this.message_editor.focus_handle(cx).focus(window);
+
+ this.profile_selector = this.as_native_thread(cx).map(|thread| {
+ cx.new(|cx| {
+ ProfileSelector::new(
+ <dyn Fs>::global(cx),
+ Arc::new(thread.clone()),
+ this.focus_handle(cx),
+ cx,
+ )
+ })
+ });
cx.notify();
}
Err(err) => {
- this.handle_load_error(err, cx);
+ this.handle_load_error(err, window, cx);
}
};
})
@@ -275,214 +563,444 @@ impl AcpThreadView {
ThreadState::Loading { _task: load_task }
}
- fn handle_load_error(&mut self, err: anyhow::Error, cx: &mut Context<Self>) {
+ fn handle_auth_required(
+ this: WeakEntity<Self>,
+ err: AuthRequired,
+ agent: Rc<dyn AgentServer>,
+ connection: Rc<dyn AgentConnection>,
+ window: &mut Window,
+ cx: &mut App,
+ ) {
+ let agent_name = agent.name();
+ let (configuration_view, subscription) = if let Some(provider_id) = err.provider_id {
+ let registry = LanguageModelRegistry::global(cx);
+
+ let sub = window.subscribe(®istry, cx, {
+ let provider_id = provider_id.clone();
+ let this = this.clone();
+ move |_, ev, window, cx| {
+ if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev
+ && &provider_id == updated_provider_id
+ {
+ this.update(cx, |this, cx| {
+ this.thread_state = Self::initial_state(
+ agent.clone(),
+ None,
+ this.workspace.clone(),
+ this.project.clone(),
+ window,
+ cx,
+ );
+ cx.notify();
+ })
+ .ok();
+ }
+ }
+ });
+
+ let view = registry.read(cx).provider(&provider_id).map(|provider| {
+ provider.configuration_view(
+ language_model::ConfigurationViewTargetAgent::Other(agent_name.clone()),
+ window,
+ cx,
+ )
+ });
+
+ (view, Some(sub))
+ } else {
+ (None, None)
+ };
+
+ this.update(cx, |this, cx| {
+ this.thread_state = ThreadState::Unauthenticated {
+ pending_auth_method: None,
+ connection,
+ configuration_view,
+ description: err
+ .description
+ .clone()
+ .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))),
+ _subscription: subscription,
+ };
+ if this.message_editor.focus_handle(cx).is_focused(window) {
+ this.focus_handle.focus(window)
+ }
+ cx.notify();
+ })
+ .ok();
+ }
+
+ fn handle_load_error(
+ &mut self,
+ err: anyhow::Error,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
if let Some(load_err) = err.downcast_ref::<LoadError>() {
self.thread_state = ThreadState::LoadError(load_err.clone());
} else {
self.thread_state = ThreadState::LoadError(LoadError::Other(err.to_string().into()))
}
+ if self.message_editor.focus_handle(cx).is_focused(window) {
+ self.focus_handle.focus(window)
+ }
cx.notify();
}
+ pub fn workspace(&self) -> &WeakEntity<Workspace> {
+ &self.workspace
+ }
+
pub fn thread(&self) -> Option<&Entity<AcpThread>> {
match &self.thread_state {
- ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => {
- Some(thread)
- }
- ThreadState::Loading { .. } | ThreadState::LoadError(..) => None,
+ ThreadState::Ready { thread, .. } => Some(thread),
+ ThreadState::Unauthenticated { .. }
+ | ThreadState::Loading { .. }
+ | ThreadState::LoadError { .. } => None,
}
}
- pub fn title(&self, cx: &App) -> SharedString {
+ pub fn title(&self) -> SharedString {
match &self.thread_state {
- ThreadState::Ready { thread, .. } => thread.read(cx).title(),
+ ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(),
ThreadState::Loading { .. } => "Loading…".into(),
ThreadState::LoadError(_) => "Failed to load".into(),
- ThreadState::Unauthenticated { .. } => "Not authenticated".into(),
}
}
- pub fn cancel(&mut self, cx: &mut Context<Self>) {
- self.last_error.take();
-
- if let Some(thread) = self.thread() {
- thread.update(cx, |thread, cx| thread.cancel(cx)).detach();
+ pub fn title_editor(&self) -> Option<Entity<Editor>> {
+ if let ThreadState::Ready { title_editor, .. } = &self.thread_state {
+ title_editor.clone()
+ } else {
+ None
}
}
- fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
- self.last_error.take();
-
- let mut ix = 0;
- let mut chunks: Vec<acp::UserMessageChunk> = Vec::new();
- let project = self.project.clone();
- self.message_editor.update(cx, |editor, cx| {
- let text = editor.text(cx);
- editor.display_map.update(cx, |map, cx| {
- let snapshot = map.snapshot(cx);
- for (crease_id, crease) in snapshot.crease_snapshot.creases() {
- if let Some(project_path) =
- self.mention_set.lock().path_for_crease_id(crease_id)
- {
- let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
- if crease_range.start > ix {
- chunks.push(acp::UserMessageChunk::Text {
- text: text[ix..crease_range.start].to_string(),
- });
- }
- if let Some(abs_path) = project.read(cx).absolute_path(&project_path, cx) {
- chunks.push(acp::UserMessageChunk::Path { path: abs_path });
- }
- ix = crease_range.end;
- }
- }
-
- if ix < text.len() {
- let last_chunk = text[ix..].trim();
- if !last_chunk.is_empty() {
- chunks.push(acp::UserMessageChunk::Text {
- text: last_chunk.into(),
- });
- }
- }
- })
- });
+ pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
+ self.thread_error.take();
+ self.thread_retry_status.take();
- if chunks.is_empty() {
- return;
+ if let Some(thread) = self.thread() {
+ self._cancel_task = Some(thread.update(cx, |thread, cx| thread.cancel(cx)));
}
+ }
- let Some(thread) = self.thread() else { return };
- let message = acp::SendUserMessageParams { chunks };
- let task = thread.update(cx, |thread, cx| thread.send(message.clone(), cx));
-
- cx.spawn(async move |this, cx| {
- let result = task.await;
-
- this.update(cx, |this, cx| {
- if let Err(err) = result {
- this.last_error =
- Some(cx.new(|cx| Markdown::new(err.to_string().into(), None, None, cx)))
- }
- })
- })
- .detach();
-
- let mention_set = self.mention_set.clone();
+ pub fn expand_message_editor(
+ &mut self,
+ _: &ExpandMessageEditor,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.set_editor_is_expanded(!self.editor_expanded, cx);
+ cx.notify();
+ }
+ fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
+ self.editor_expanded = is_expanded;
self.message_editor.update(cx, |editor, cx| {
- editor.clear(window, cx);
- editor.remove_creases(mention_set.lock().drain(), cx)
+ if is_expanded {
+ editor.set_mode(
+ EditorMode::Full {
+ scale_ui_elements_with_buffer_font_size: false,
+ show_active_line_background: false,
+ sized_by_content: false,
+ },
+ cx,
+ )
+ } else {
+ editor.set_mode(
+ EditorMode::AutoHeight {
+ min_lines: MIN_EDITOR_LINES,
+ max_lines: Some(MAX_EDITOR_LINES),
+ },
+ cx,
+ )
+ }
});
-
- self.message_history.borrow_mut().push(message);
+ cx.notify();
}
- fn previous_history_message(
+ pub fn handle_title_editor_event(
&mut self,
- _: &PreviousHistoryMessage,
+ title_editor: &Entity<Editor>,
+ event: &EditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
- self.message_set_from_history = Self::set_draft_message(
- self.message_editor.clone(),
- self.mention_set.clone(),
- self.project.clone(),
- self.message_history.borrow_mut().prev(),
- window,
- cx,
- );
+ let Some(thread) = self.thread() else { return };
+
+ match event {
+ EditorEvent::BufferEdited => {
+ let new_title = title_editor.read(cx).text(cx);
+ thread.update(cx, |thread, cx| {
+ thread
+ .set_title(new_title.into(), cx)
+ .detach_and_log_err(cx);
+ })
+ }
+ EditorEvent::Blurred => {
+ if title_editor.read(cx).text(cx).is_empty() {
+ title_editor.update(cx, |editor, cx| {
+ editor.set_text("New Thread", window, cx);
+ });
+ }
+ }
+ _ => {}
+ }
}
- fn next_history_message(
+ pub fn handle_message_editor_event(
&mut self,
- _: &NextHistoryMessage,
+ _: &Entity<MessageEditor>,
+ event: &MessageEditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
- self.message_set_from_history = Self::set_draft_message(
- self.message_editor.clone(),
- self.mention_set.clone(),
- self.project.clone(),
- self.message_history.borrow_mut().next(),
- window,
- cx,
- );
+ match event {
+ MessageEditorEvent::Send => self.send(window, cx),
+ MessageEditorEvent::Cancel => self.cancel_generation(cx),
+ MessageEditorEvent::Focus => {
+ self.cancel_editing(&Default::default(), window, cx);
+ }
+ MessageEditorEvent::LostFocus => {}
+ }
}
- fn set_draft_message(
- message_editor: Entity<Editor>,
- mention_set: Arc<Mutex<MentionSet>>,
- project: Entity<Project>,
- message: Option<&acp::SendUserMessageParams>,
+ pub fn handle_entry_view_event(
+ &mut self,
+ _: &Entity<EntryViewState>,
+ event: &EntryViewEvent,
window: &mut Window,
cx: &mut Context<Self>,
- ) -> bool {
- cx.notify();
-
- let Some(message) = message else {
- return false;
- };
-
- let mut text = String::new();
- let mut mentions = Vec::new();
-
- for chunk in &message.chunks {
- match chunk {
- acp::UserMessageChunk::Text { text: chunk } => {
- text.push_str(&chunk);
+ ) {
+ match &event.view_event {
+ ViewEvent::NewDiff(tool_call_id) => {
+ if AgentSettings::get_global(cx).expand_edit_card {
+ self.expanded_tool_calls.insert(tool_call_id.clone());
}
- acp::UserMessageChunk::Path { path } => {
- let start = text.len();
- let content = MentionPath::new(path).to_string();
- text.push_str(&content);
- let end = text.len();
- if let Some(project_path) =
- project.read(cx).project_path_for_absolute_path(path, cx)
- {
- let filename: SharedString = path
- .file_name()
- .unwrap_or_default()
- .to_string_lossy()
- .to_string()
- .into();
- mentions.push((start..end, project_path, filename));
+ }
+ ViewEvent::NewTerminal(tool_call_id) => {
+ if AgentSettings::get_global(cx).expand_terminal_card {
+ self.expanded_tool_calls.insert(tool_call_id.clone());
+ }
+ }
+ ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
+ if let Some(thread) = self.thread()
+ && let Some(AgentThreadEntry::UserMessage(user_message)) =
+ thread.read(cx).entries().get(event.entry_index)
+ && user_message.id.is_some()
+ {
+ self.editing_message = Some(event.entry_index);
+ cx.notify();
+ }
+ }
+ ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => {
+ if let Some(thread) = self.thread()
+ && let Some(AgentThreadEntry::UserMessage(user_message)) =
+ thread.read(cx).entries().get(event.entry_index)
+ && user_message.id.is_some()
+ {
+ if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) {
+ self.editing_message = None;
+ cx.notify();
}
}
}
+ ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
+ self.regenerate(event.entry_index, editor, window, cx);
+ }
+ ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
+ self.cancel_editing(&Default::default(), window, cx);
+ }
}
+ }
- let snapshot = message_editor.update(cx, |editor, cx| {
- editor.set_text(text, window, cx);
- editor.buffer().read(cx).snapshot(cx)
- });
+ fn resume_chat(&mut self, cx: &mut Context<Self>) {
+ self.thread_error.take();
+ let Some(thread) = self.thread() else {
+ return;
+ };
+ if !thread.read(cx).can_resume(cx) {
+ return;
+ }
- for (range, project_path, filename) in mentions {
- let crease_icon_path = if project_path.path.is_dir() {
- FileIcons::get_folder_icon(false, cx)
- .unwrap_or_else(|| IconName::Folder.path().into())
- } else {
- FileIcons::get_icon(Path::new(project_path.path.as_ref()), cx)
- .unwrap_or_else(|| IconName::File.path().into())
- };
+ let task = thread.update(cx, |thread, cx| thread.resume(cx));
+ cx.spawn(async move |this, cx| {
+ let result = task.await;
- let anchor = snapshot.anchor_before(range.start);
- let crease_id = crate::context_picker::insert_crease_for_mention(
- anchor.excerpt_id,
- anchor.text_anchor,
- range.end - range.start,
- filename,
- crease_icon_path,
- message_editor.clone(),
- window,
+ this.update(cx, |this, cx| {
+ if let Err(err) = result {
+ this.handle_thread_error(err, cx);
+ }
+ })
+ })
+ .detach();
+ }
+
+ fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let Some(thread) = self.thread() else { return };
+
+ if self.is_loading_contents {
+ return;
+ }
+
+ self.history_store.update(cx, |history, cx| {
+ history.push_recently_opened_entry(
+ HistoryEntryId::AcpThread(thread.read(cx).session_id().clone()),
cx,
);
- if let Some(crease_id) = crease_id {
- mention_set.lock().insert(crease_id, project_path);
+ });
+
+ if thread.read(cx).status() != ThreadStatus::Idle {
+ self.stop_current_and_send_new_message(window, cx);
+ return;
+ }
+
+ let contents = self
+ .message_editor
+ .update(cx, |message_editor, cx| message_editor.contents(cx));
+ self.send_impl(contents, window, cx)
+ }
+
+ fn stop_current_and_send_new_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let Some(thread) = self.thread().cloned() else {
+ return;
+ };
+
+ let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
+
+ let contents = self
+ .message_editor
+ .update(cx, |message_editor, cx| message_editor.contents(cx));
+
+ cx.spawn_in(window, async move |this, cx| {
+ cancelled.await;
+
+ this.update_in(cx, |this, window, cx| {
+ this.send_impl(contents, window, cx);
+ })
+ .ok();
+ })
+ .detach();
+ }
+
+ fn send_impl(
+ &mut self,
+ contents: Task<anyhow::Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.thread_error.take();
+ self.editing_message.take();
+ self.thread_feedback.clear();
+
+ let Some(thread) = self.thread().cloned() else {
+ return;
+ };
+
+ self.is_loading_contents = true;
+ let guard = cx.new(|_| ());
+ cx.observe_release(&guard, |this, _guard, cx| {
+ this.is_loading_contents = false;
+ cx.notify();
+ })
+ .detach();
+
+ let task = cx.spawn_in(window, async move |this, cx| {
+ let (contents, tracked_buffers) = contents.await?;
+
+ if contents.is_empty() {
+ return Ok(());
+ }
+
+ this.update_in(cx, |this, window, cx| {
+ this.set_editor_is_expanded(false, cx);
+ this.scroll_to_bottom(cx);
+ this.message_editor.update(cx, |message_editor, cx| {
+ message_editor.clear(window, cx);
+ });
+ })?;
+ let send = thread.update(cx, |thread, cx| {
+ thread.action_log().update(cx, |action_log, cx| {
+ for buffer in tracked_buffers {
+ action_log.buffer_read(buffer, cx)
+ }
+ });
+ drop(guard);
+ thread.send(contents, cx)
+ })?;
+ send.await
+ });
+
+ cx.spawn(async move |this, cx| {
+ if let Err(err) = task.await {
+ this.update(cx, |this, cx| {
+ this.handle_thread_error(err, cx);
+ })
+ .ok();
}
+ })
+ .detach();
+ }
+
+ fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
+ let Some(thread) = self.thread().cloned() else {
+ return;
+ };
+
+ if let Some(index) = self.editing_message.take()
+ && let Some(editor) = self
+ .entry_view_state
+ .read(cx)
+ .entry(index)
+ .and_then(|e| e.message_editor())
+ .cloned()
+ {
+ editor.update(cx, |editor, cx| {
+ if let Some(user_message) = thread
+ .read(cx)
+ .entries()
+ .get(index)
+ .and_then(|e| e.user_message())
+ {
+ editor.set_message(user_message.chunks.clone(), window, cx);
+ }
+ })
+ };
+ self.focus_handle(cx).focus(window);
+ cx.notify();
+ }
+
+ fn regenerate(
+ &mut self,
+ entry_ix: usize,
+ message_editor: &Entity<MessageEditor>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(thread) = self.thread().cloned() else {
+ return;
+ };
+ if self.is_loading_contents {
+ return;
}
- true
+ let Some(user_message_id) = thread.update(cx, |thread, _| {
+ thread.entries().get(entry_ix)?.user_message()?.id.clone()
+ }) else {
+ return;
+ };
+
+ let contents = message_editor.update(cx, |message_editor, cx| message_editor.contents(cx));
+
+ let task = cx.spawn(async move |_, cx| {
+ let contents = contents.await?;
+ thread
+ .update(cx, |thread, cx| thread.rewind(user_message_id, cx))?
+ .await?;
+ Ok(contents)
+ });
+ self.send_impl(task, window, cx);
}
fn open_agent_diff(&mut self, _: &OpenAgentDiff, window: &mut Window, cx: &mut Context<Self>) {
@@ -14,6 +14,7 @@ use agent_settings::{AgentSettings, NotifyWhenAgentWaiting};
use anyhow::Context as _;
use assistant_tool::ToolUseStatus;
use audio::{Audio, Sound};
+use cloud_llm_client::CompletionIntent;
use collections::{HashMap, HashSet};
use editor::actions::{MoveUp, Paste};
use editor::scroll::Autoscroll;
@@ -52,7 +53,6 @@ use util::ResultExt as _;
use util::markdown::MarkdownCodeBlock;
use workspace::{CollaboratorId, Workspace};
use zed_actions::assistant::OpenRulesLibrary;
-use zed_llm_client::CompletionIntent;
const CODEBLOCK_CONTAINER_GROUP: &str = "codeblock_container";
const EDIT_PREVIOUS_MESSAGE_MIN_LINES: usize = 1;
@@ -69,8 +69,6 @@ pub struct ActiveThread {
messages: Vec<MessageId>,
list_state: ListState,
scrollbar_state: ScrollbarState,
- show_scrollbar: bool,
- hide_scrollbar_task: Option<Task<()>>,
rendered_messages_by_id: HashMap<MessageId, RenderedMessage>,
rendered_tool_uses: HashMap<LanguageModelToolUseId, RenderedToolUse>,
editing_message: Option<(MessageId, EditingMessageState)>,
@@ -436,7 +434,7 @@ fn render_markdown_code_block(
.child(content)
.child(
Icon::new(IconName::ArrowUpRight)
- .size(IconSize::XSmall)
+ .size(IconSize::Small)
.color(Color::Ignored),
),
)
@@ -493,7 +491,7 @@ fn render_markdown_code_block(
.on_click({
let active_thread = active_thread.clone();
let parsed_markdown = parsed_markdown.clone();
- let code_block_range = metadata.content_range.clone();
+ let code_block_range = metadata.content_range;
move |_event, _window, cx| {
active_thread.update(cx, |this, cx| {
this.copied_code_block_ids.insert((message_id, ix));
@@ -534,7 +532,6 @@ fn render_markdown_code_block(
"Expand Code"
}))
.on_click({
- let active_thread = active_thread.clone();
move |_event, _window, cx| {
active_thread.update(cx, |this, cx| {
this.toggle_codeblock_expanded(message_id, ix);
@@ -780,21 +777,13 @@ impl ActiveThread {
cx.observe_global::<SettingsStore>(|_, cx| cx.notify()),
];
- let list_state = ListState::new(0, ListAlignment::Bottom, px(2048.), {
- let this = cx.entity().downgrade();
- move |ix, window: &mut Window, cx: &mut App| {
- this.update(cx, |this, cx| this.render_message(ix, window, cx))
- .unwrap()
- }
- });
+ let list_state = ListState::new(0, ListAlignment::Bottom, px(2048.));
- let workspace_subscription = if let Some(workspace) = workspace.upgrade() {
- Some(cx.observe_release(&workspace, |this, _, cx| {
+ let workspace_subscription = workspace.upgrade().map(|workspace| {
+ cx.observe_release(&workspace, |this, _, cx| {
this.dismiss_notifications(cx);
- }))
- } else {
- None
- };
+ })
+ });
let mut this = Self {
language_registry,
@@ -811,9 +800,7 @@ impl ActiveThread {
expanded_thinking_segments: HashMap::default(),
expanded_code_blocks: HashMap::default(),
list_state: list_state.clone(),
- scrollbar_state: ScrollbarState::new(list_state),
- show_scrollbar: false,
- hide_scrollbar_task: None,
+ scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()),
editing_message: None,
last_error: None,
copied_code_block_ids: HashSet::default(),
@@ -926,7 +913,7 @@ impl ActiveThread {
) {
let rendered = self
.rendered_tool_uses
- .entry(tool_use_id.clone())
+ .entry(tool_use_id)
.or_insert_with(|| RenderedToolUse {
label: cx.new(|cx| {
Markdown::new("".into(), Some(self.language_registry.clone()), None, cx)
@@ -996,30 +983,57 @@ impl ActiveThread {
| ThreadEvent::SummaryChanged => {
self.save_thread(cx);
}
- ThreadEvent::Stopped(reason) => match reason {
- Ok(StopReason::EndTurn | StopReason::MaxTokens) => {
- let used_tools = self.thread.read(cx).used_tools_since_last_user_message();
- self.play_notification_sound(window, cx);
- self.show_notification(
- if used_tools {
- "Finished running tools"
- } else {
- "New message"
- },
- IconName::ZedAssistant,
- window,
- cx,
- );
+ ThreadEvent::Stopped(reason) => {
+ match reason {
+ Ok(StopReason::EndTurn | StopReason::MaxTokens) => {
+ let used_tools = self.thread.read(cx).used_tools_since_last_user_message();
+ self.notify_with_sound(
+ if used_tools {
+ "Finished running tools"
+ } else {
+ "New message"
+ },
+ IconName::ZedAssistant,
+ window,
+ cx,
+ );
+ }
+ Ok(StopReason::ToolUse) => {
+ // Don't notify for intermediate tool use
+ }
+ Ok(StopReason::Refusal) => {
+ self.notify_with_sound(
+ "Language model refused to respond",
+ IconName::Warning,
+ window,
+ cx,
+ );
+ }
+ Err(error) => {
+ self.notify_with_sound(
+ "Agent stopped due to an error",
+ IconName::Warning,
+ window,
+ cx,
+ );
+
+ let error_message = error
+ .chain()
+ .map(|err| err.to_string())
+ .collect::<Vec<_>>()
+ .join("\n");
+ self.last_error = Some(ThreadError::Message {
+ header: "Error".into(),
+ message: error_message.into(),
+ });
+ }
}
- _ => {}
- },
+ }
ThreadEvent::ToolConfirmationNeeded => {
- self.play_notification_sound(window, cx);
- self.show_notification("Waiting for tool confirmation", IconName::Info, window, cx);
+ self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
}
ThreadEvent::ToolUseLimitReached => {
- self.play_notification_sound(window, cx);
- self.show_notification(
+ self.notify_with_sound(
"Consecutive tool use limit reached.",
IconName::Warning,
window,
@@ -1027,12 +1041,12 @@ impl ActiveThread {
);
}
ThreadEvent::StreamedAssistantText(message_id, text) => {
- if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) {
+ if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(message_id) {
rendered_message.append_text(text, cx);
}
}
ThreadEvent::StreamedAssistantThinking(message_id, text) => {
- if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) {
+ if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(message_id) {
rendered_message.append_thinking(text, cx);
}
}
@@ -1055,8 +1069,8 @@ impl ActiveThread {
}
ThreadEvent::MessageEdited(message_id) => {
self.clear_last_error();
- if let Some(index) = self.messages.iter().position(|id| id == message_id) {
- if let Some(rendered_message) = self.thread.update(cx, |thread, cx| {
+ if let Some(index) = self.messages.iter().position(|id| id == message_id)
+ && let Some(rendered_message) = self.thread.update(cx, |thread, cx| {
thread.message(*message_id).map(|message| {
let mut rendered_message = RenderedMessage {
language_registry: self.language_registry.clone(),
@@ -1067,14 +1081,14 @@ impl ActiveThread {
}
rendered_message
})
- }) {
- self.list_state.splice(index..index + 1, 1);
- self.rendered_messages_by_id
- .insert(*message_id, rendered_message);
- self.scroll_to_bottom(cx);
- self.save_thread(cx);
- cx.notify();
- }
+ })
+ {
+ self.list_state.splice(index..index + 1, 1);
+ self.rendered_messages_by_id
+ .insert(*message_id, rendered_message);
+ self.scroll_to_bottom(cx);
+ self.save_thread(cx);
+ cx.notify();
}
}
ThreadEvent::MessageDeleted(message_id) => {
@@ -1162,9 +1176,6 @@ impl ActiveThread {
self.save_thread(cx);
cx.notify();
}
- ThreadEvent::RetriesFailed { message } => {
- self.show_notification(message, ui::IconName::Warning, window, cx);
- }
}
}
@@ -1204,7 +1215,7 @@ impl ActiveThread {
match AgentSettings::get_global(cx).notify_when_agent_waiting {
NotifyWhenAgentWaiting::PrimaryScreen => {
if let Some(primary) = cx.primary_display() {
- self.pop_up(icon, caption.into(), title.clone(), window, primary, cx);
+ self.pop_up(icon, caption.into(), title, window, primary, cx);
}
}
NotifyWhenAgentWaiting::AllScreens => {
@@ -1219,6 +1230,17 @@ impl ActiveThread {
}
}
+ fn notify_with_sound(
+ &mut self,
+ caption: impl Into<SharedString>,
+ icon: IconName,
+ window: &mut Window,
+ cx: &mut Context<ActiveThread>,
+ ) {
+ self.play_notification_sound(window, cx);
+ self.show_notification(caption, icon, window, cx);
+ }
+
fn pop_up(
&mut self,
icon: IconName,
@@ -1247,62 +1269,61 @@ impl ActiveThread {
})
})
.log_err()
+ && let Some(pop_up) = screen_window.entity(cx).log_err()
{
- if let Some(pop_up) = screen_window.entity(cx).log_err() {
- self.notification_subscriptions
- .entry(screen_window)
- .or_insert_with(Vec::new)
- .push(cx.subscribe_in(&pop_up, window, {
- |this, _, event, window, cx| match event {
- AgentNotificationEvent::Accepted => {
- let handle = window.window_handle();
- cx.activate(true);
-
- let workspace_handle = this.workspace.clone();
-
- // If there are multiple Zed windows, activate the correct one.
- cx.defer(move |cx| {
- handle
- .update(cx, |_view, window, _cx| {
- window.activate_window();
-
- if let Some(workspace) = workspace_handle.upgrade() {
- workspace.update(_cx, |workspace, cx| {
- workspace.focus_panel::<AgentPanel>(window, cx);
- });
- }
- })
- .log_err();
- });
+ self.notification_subscriptions
+ .entry(screen_window)
+ .or_insert_with(Vec::new)
+ .push(cx.subscribe_in(&pop_up, window, {
+ |this, _, event, window, cx| match event {
+ AgentNotificationEvent::Accepted => {
+ let handle = window.window_handle();
+ cx.activate(true);
+
+ let workspace_handle = this.workspace.clone();
+
+ // If there are multiple Zed windows, activate the correct one.
+ cx.defer(move |cx| {
+ handle
+ .update(cx, |_view, window, _cx| {
+ window.activate_window();
+
+ if let Some(workspace) = workspace_handle.upgrade() {
+ workspace.update(_cx, |workspace, cx| {
+ workspace.focus_panel::<AgentPanel>(window, cx);
+ });
+ }
+ })
+ .log_err();
+ });
- this.dismiss_notifications(cx);
- }
- AgentNotificationEvent::Dismissed => {
- this.dismiss_notifications(cx);
- }
+ this.dismiss_notifications(cx);
}
- }));
-
- self.notifications.push(screen_window);
-
- // If the user manually refocuses the original window, dismiss the popup.
- self.notification_subscriptions
- .entry(screen_window)
- .or_insert_with(Vec::new)
- .push({
- let pop_up_weak = pop_up.downgrade();
-
- cx.observe_window_activation(window, move |_, window, cx| {
- if window.is_window_active() {
- if let Some(pop_up) = pop_up_weak.upgrade() {
- pop_up.update(cx, |_, cx| {
- cx.emit(AgentNotificationEvent::Dismissed);
- });
- }
- }
- })
- });
- }
+ AgentNotificationEvent::Dismissed => {
+ this.dismiss_notifications(cx);
+ }
+ }
+ }));
+
+ self.notifications.push(screen_window);
+
+ // If the user manually refocuses the original window, dismiss the popup.
+ self.notification_subscriptions
+ .entry(screen_window)
+ .or_insert_with(Vec::new)
+ .push({
+ let pop_up_weak = pop_up.downgrade();
+
+ cx.observe_window_activation(window, move |_, window, cx| {
+ if window.is_window_active()
+ && let Some(pop_up) = pop_up_weak.upgrade()
+ {
+ pop_up.update(cx, |_, cx| {
+ cx.emit(AgentNotificationEvent::Dismissed);
+ });
+ }
+ })
+ });
}
}
@@ -1349,12 +1370,12 @@ impl ActiveThread {
editor.focus_handle(cx).focus(window);
editor.move_to_end(&editor::actions::MoveToEnd, window, cx);
});
- let buffer_edited_subscription = cx.subscribe(&editor, |this, _, event, cx| match event {
- EditorEvent::BufferEdited => {
- this.update_editing_message_token_count(true, cx);
- }
- _ => {}
- });
+ let buffer_edited_subscription =
+ cx.subscribe(&editor, |this, _, event: &EditorEvent, cx| {
+ if event == &EditorEvent::BufferEdited {
+ this.update_editing_message_token_count(true, cx);
+ }
+ });
let context_picker_menu_handle = PopoverMenuHandle::default();
let context_strip = cx.new(|cx| {
@@ -1574,11 +1595,6 @@ impl ActiveThread {
return;
};
- if model.provider.must_accept_terms(cx) {
- cx.notify();
- return;
- }
-
let edited_text = state.editor.read(cx).text(cx);
let creases = state.editor.update(cx, extract_message_creases);
@@ -1741,7 +1757,7 @@ impl ActiveThread {
.thread
.read(cx)
.message(message_id)
- .map(|msg| msg.to_string())
+ .map(|msg| msg.to_message_content())
.unwrap_or_default();
telemetry::event!(
@@ -1811,7 +1827,12 @@ impl ActiveThread {
)))
}
- fn render_message(&self, ix: usize, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
+ fn render_message(
+ &mut self,
+ ix: usize,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> AnyElement {
let message_id = self.messages[ix];
let workspace = self.workspace.clone();
let thread = self.thread.read(cx);
@@ -1866,8 +1887,9 @@ impl ActiveThread {
(colors.editor_background, colors.panel_background)
};
- let open_as_markdown = IconButton::new(("open-as-markdown", ix), IconName::DocumentText)
- .icon_size(IconSize::XSmall)
+ let open_as_markdown = IconButton::new(("open-as-markdown", ix), IconName::FileMarkdown)
+ .shape(ui::IconButtonShape::Square)
+ .icon_size(IconSize::Small)
.icon_color(Color::Ignored)
.tooltip(Tooltip::text("Open Thread as Markdown"))
.on_click({
@@ -1881,8 +1903,9 @@ impl ActiveThread {
}
});
- let scroll_to_top = IconButton::new(("scroll_to_top", ix), IconName::ArrowUpAlt)
- .icon_size(IconSize::XSmall)
+ let scroll_to_top = IconButton::new(("scroll_to_top", ix), IconName::ArrowUp)
+ .shape(ui::IconButtonShape::Square)
+ .icon_size(IconSize::Small)
.icon_color(Color::Ignored)
.tooltip(Tooltip::text("Scroll To Top"))
.on_click(cx.listener(move |this, _, _, cx| {
@@ -1896,6 +1919,7 @@ impl ActiveThread {
.py_2()
.px(RESPONSE_PADDING_X)
.mr_1()
+ .gap_1()
.opacity(0.4)
.hover(|style| style.opacity(1.))
.gap_1p5()
@@ -1919,7 +1943,8 @@ impl ActiveThread {
h_flex()
.child(
IconButton::new(("feedback-thumbs-up", ix), IconName::ThumbsUp)
- .icon_size(IconSize::XSmall)
+ .shape(ui::IconButtonShape::Square)
+ .icon_size(IconSize::Small)
.icon_color(match feedback {
ThreadFeedback::Positive => Color::Accent,
ThreadFeedback::Negative => Color::Ignored,
@@ -1936,7 +1961,8 @@ impl ActiveThread {
)
.child(
IconButton::new(("feedback-thumbs-down", ix), IconName::ThumbsDown)
- .icon_size(IconSize::XSmall)
+ .shape(ui::IconButtonShape::Square)
+ .icon_size(IconSize::Small)
.icon_color(match feedback {
ThreadFeedback::Positive => Color::Ignored,
ThreadFeedback::Negative => Color::Accent,
@@ -1969,7 +1995,8 @@ impl ActiveThread {
h_flex()
.child(
IconButton::new(("feedback-thumbs-up", ix), IconName::ThumbsUp)
- .icon_size(IconSize::XSmall)
+ .shape(ui::IconButtonShape::Square)
+ .icon_size(IconSize::Small)
.icon_color(Color::Ignored)
.tooltip(Tooltip::text("Helpful Response"))
.on_click(cx.listener(move |this, _, window, cx| {
@@ -1983,7 +2010,8 @@ impl ActiveThread {
)
.child(
IconButton::new(("feedback-thumbs-down", ix), IconName::ThumbsDown)
- .icon_size(IconSize::XSmall)
+ .shape(ui::IconButtonShape::Square)
+ .icon_size(IconSize::Small)
.icon_color(Color::Ignored)
.tooltip(Tooltip::text("Not Helpful"))
.on_click(cx.listener(move |this, _, window, cx| {
@@ -2076,7 +2104,7 @@ impl ActiveThread {
.gap_1()
.children(message_content)
.when_some(editing_message_state, |this, state| {
- let focus_handle = state.editor.focus_handle(cx).clone();
+ let focus_handle = state.editor.focus_handle(cx);
this.child(
h_flex()
@@ -2137,7 +2165,6 @@ impl ActiveThread {
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
.tooltip({
- let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Regenerate",
@@ -2210,9 +2237,7 @@ impl ActiveThread {
let after_editing_message = self
.editing_message
.as_ref()
- .map_or(false, |(editing_message_id, _)| {
- message_id > *editing_message_id
- });
+ .is_some_and(|(editing_message_id, _)| message_id > *editing_message_id);
let backdrop = div()
.id(("backdrop", ix))
@@ -2232,13 +2257,12 @@ impl ActiveThread {
let mut error = None;
if let Some(last_restore_checkpoint) =
self.thread.read(cx).last_restore_checkpoint()
+ && last_restore_checkpoint.message_id() == message_id
{
- if last_restore_checkpoint.message_id() == message_id {
- match last_restore_checkpoint {
- LastRestoreCheckpoint::Pending { .. } => is_pending = true,
- LastRestoreCheckpoint::Error { error: err, .. } => {
- error = Some(err.clone());
- }
+ match last_restore_checkpoint {
+ LastRestoreCheckpoint::Pending { .. } => is_pending = true,
+ LastRestoreCheckpoint::Error { error: err, .. } => {
+ error = Some(err.clone());
}
}
}
@@ -2279,7 +2303,7 @@ impl ActiveThread {
.into_any_element()
} else if let Some(error) = error {
restore_checkpoint_button
- .tooltip(Tooltip::text(error.to_string()))
+ .tooltip(Tooltip::text(error))
.into_any_element()
} else {
restore_checkpoint_button.into_any_element()
@@ -2320,7 +2344,6 @@ impl ActiveThread {
this.submit_feedback_message(message_id, cx);
cx.notify();
}))
- .on_action(cx.listener(Self::confirm_editing_message))
.mb_2()
.mx_4()
.p_2()
@@ -2436,7 +2459,7 @@ impl ActiveThread {
message_id,
index,
content.clone(),
- &scroll_handle,
+ scroll_handle,
Some(index) == pending_thinking_segment_index,
window,
cx,
@@ -2560,7 +2583,7 @@ impl ActiveThread {
.id(("message-container", ix))
.py_1()
.px_2p5()
- .child(Banner::new().severity(ui::Severity::Warning).child(message))
+ .child(Banner::new().severity(Severity::Warning).child(message))
}
fn render_message_thinking_segment(
@@ -2594,7 +2617,7 @@ impl ActiveThread {
h_flex()
.gap_1p5()
.child(
- Icon::new(IconName::ToolBulb)
+ Icon::new(IconName::ToolThink)
.size(IconSize::Small)
.color(Color::Muted),
)
@@ -2720,7 +2743,7 @@ impl ActiveThread {
h_flex()
.gap_1p5()
.child(
- Icon::new(IconName::LightBulb)
+ Icon::new(IconName::ToolThink)
.size(IconSize::XSmall)
.color(Color::Muted),
)
@@ -3167,7 +3190,10 @@ impl ActiveThread {
.border_color(self.tool_card_border_color(cx))
.rounded_b_lg()
.child(
- LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)
+ div()
+ .min_w(rems_from_px(145.))
+ .child(LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)
+ )
)
.child(
h_flex()
@@ -3212,7 +3238,6 @@ impl ActiveThread {
},
))
})
- .child(ui::Divider::vertical())
.child({
let tool_id = tool_use.id.clone();
Button::new("allow-tool-action", "Allow")
@@ -3330,7 +3355,7 @@ impl ActiveThread {
.mr_0p5(),
)
.child(
- IconButton::new("open-prompt-library", IconName::ArrowUpRightAlt)
+ IconButton::new("open-prompt-library", IconName::ArrowUpRight)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::XSmall)
.icon_color(Color::Ignored)
@@ -3365,7 +3390,7 @@ impl ActiveThread {
.mr_0p5(),
)
.child(
- IconButton::new("open-rule", IconName::ArrowUpRightAlt)
+ IconButton::new("open-rule", IconName::ArrowUpRight)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::XSmall)
.icon_color(Color::Ignored)
@@ -3466,60 +3491,37 @@ impl ActiveThread {
}
}
- fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
- if !self.show_scrollbar && !self.scrollbar_state.is_dragging() {
- return None;
- }
-
- Some(
- div()
- .occlude()
- .id("active-thread-scrollbar")
- .on_mouse_move(cx.listener(|_, _, _, cx| {
- cx.notify();
- cx.stop_propagation()
- }))
- .on_hover(|_, _, cx| {
- cx.stop_propagation();
- })
- .on_any_mouse_down(|_, _, cx| {
+ fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
+ div()
+ .occlude()
+ .id("active-thread-scrollbar")
+ .on_mouse_move(cx.listener(|_, _, _, cx| {
+ cx.notify();
+ cx.stop_propagation()
+ }))
+ .on_hover(|_, _, cx| {
+ cx.stop_propagation();
+ })
+ .on_any_mouse_down(|_, _, cx| {
+ cx.stop_propagation();
+ })
+ .on_mouse_up(
+ MouseButton::Left,
+ cx.listener(|_, _, _, cx| {
cx.stop_propagation();
- })
- .on_mouse_up(
- MouseButton::Left,
- cx.listener(|_, _, _, cx| {
- cx.stop_propagation();
- }),
- )
- .on_scroll_wheel(cx.listener(|_, _, _, cx| {
- cx.notify();
- }))
- .h_full()
- .absolute()
- .right_1()
- .top_1()
- .bottom_0()
- .w(px(12.))
- .cursor_default()
- .children(Scrollbar::vertical(self.scrollbar_state.clone())),
- )
- }
-
- fn hide_scrollbar_later(&mut self, cx: &mut Context<Self>) {
- const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
- self.hide_scrollbar_task = Some(cx.spawn(async move |thread, cx| {
- cx.background_executor()
- .timer(SCROLLBAR_SHOW_INTERVAL)
- .await;
- thread
- .update(cx, |thread, cx| {
- if !thread.scrollbar_state.is_dragging() {
- thread.show_scrollbar = false;
- cx.notify();
- }
- })
- .log_err();
- }))
+ }),
+ )
+ .on_scroll_wheel(cx.listener(|_, _, _, cx| {
+ cx.notify();
+ }))
+ .h_full()
+ .absolute()
+ .right_1()
+ .top_1()
+ .bottom_0()
+ .w(px(12.))
+ .cursor_default()
+ .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
}
pub fn is_codeblock_expanded(&self, message_id: MessageId, ix: usize) -> bool {
@@ -3560,26 +3562,8 @@ impl Render for ActiveThread {
.size_full()
.relative()
.bg(cx.theme().colors().panel_background)
- .on_mouse_move(cx.listener(|this, _, _, cx| {
- this.show_scrollbar = true;
- this.hide_scrollbar_later(cx);
- cx.notify();
- }))
- .on_scroll_wheel(cx.listener(|this, _, _, cx| {
- this.show_scrollbar = true;
- this.hide_scrollbar_later(cx);
- cx.notify();
- }))
- .on_mouse_up(
- MouseButton::Left,
- cx.listener(|this, _, _, cx| {
- this.hide_scrollbar_later(cx);
- }),
- )
- .child(list(self.list_state.clone()).flex_grow())
- .when_some(self.render_vertical_scrollbar(cx), |this, scrollbar| {
- this.child(scrollbar)
- })
+ .child(list(self.list_state.clone(), cx.processor(Self::render_message)).flex_grow())
+ .child(self.render_vertical_scrollbar(cx))
}
}
@@ -3687,8 +3671,11 @@ pub(crate) fn open_context(
AgentContextHandle::Thread(thread_context) => workspace.update(cx, |workspace, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
- panel.update(cx, |panel, cx| {
- panel.open_thread(thread_context.thread.clone(), window, cx);
+ let thread = thread_context.thread.clone();
+ window.defer(cx, move |window, cx| {
+ panel.update(cx, |panel, cx| {
+ panel.open_thread(thread, window, cx);
+ });
});
}
}),
@@ -3696,8 +3683,11 @@ pub(crate) fn open_context(
AgentContextHandle::TextThread(text_thread_context) => {
workspace.update(cx, |workspace, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
- panel.update(cx, |panel, cx| {
- panel.open_prompt_editor(text_thread_context.context.clone(), window, cx)
+ let context = text_thread_context.context.clone();
+ window.defer(cx, move |window, cx| {
+ panel.update(cx, |panel, cx| {
+ panel.open_prompt_editor(context, window, cx)
+ });
});
}
})
@@ -3852,7 +3842,7 @@ mod tests {
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
registry.set_default_model(
Some(ConfiguredModel {
- provider: Arc::new(FakeLanguageModelProvider),
+ provider: Arc::new(FakeLanguageModelProvider::default()),
model,
}),
cx,
@@ -3936,7 +3926,7 @@ mod tests {
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
registry.set_default_model(
Some(ConfiguredModel {
- provider: Arc::new(FakeLanguageModelProvider),
+ provider: Arc::new(FakeLanguageModelProvider::default()),
model: model.clone(),
}),
cx,
@@ -4016,7 +4006,7 @@ mod tests {
cx.run_until_parked();
- // Verify that the previous completion was cancelled
+ // Verify that the previous completion was canceled
assert_eq!(cancellation_events.lock().unwrap().len(), 1);
// Verify that a new request was started after cancellation
@@ -1,3 +1,4 @@
+mod add_llm_provider_modal;
mod configure_context_server_modal;
mod manage_profiles_modal;
mod tool_picker;
@@ -6,6 +7,7 @@ use std::{sync::Arc, time::Duration};
use agent_settings::AgentSettings;
use assistant_tool::{ToolSource, ToolWorkingSet};
+use cloud_llm_client::Plan;
use collections::HashMap;
use context_server::ContextServerId;
use extension::ExtensionManifest;
@@ -26,8 +28,8 @@ use project::{
};
use settings::{Settings, update_settings_file};
use ui::{
- ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu,
- Scrollbar, ScrollbarState, Switch, SwitchColor, Tooltip, prelude::*,
+ Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu,
+ Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*,
};
use util::ResultExt as _;
use workspace::Workspace;
@@ -36,7 +38,10 @@ use zed_actions::ExtensionCategoryFilter;
pub(crate) use configure_context_server_modal::ConfigureContextServerModal;
pub(crate) use manage_profiles_modal::ManageProfilesModal;
-use crate::AddContextServer;
+use crate::{
+ AddContextServer,
+ agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider},
+};
pub struct AgentConfiguration {
fs: Arc<dyn Fs>,
@@ -88,14 +93,6 @@ impl AgentConfiguration {
let scroll_handle = ScrollHandle::new();
let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
- let mut expanded_provider_configurations = HashMap::default();
- if LanguageModelRegistry::read_global(cx)
- .provider(&ZED_CLOUD_PROVIDER_ID)
- .map_or(false, |cloud_provider| cloud_provider.must_accept_terms(cx))
- {
- expanded_provider_configurations.insert(ZED_CLOUD_PROVIDER_ID, true);
- }
-
let mut this = Self {
fs,
language_registry,
@@ -104,7 +101,7 @@ impl AgentConfiguration {
configuration_views_by_provider: HashMap::default(),
context_server_store,
expanded_context_server_tools: HashMap::default(),
- expanded_provider_configurations,
+ expanded_provider_configurations: HashMap::default(),
tools,
_registry_subscription: registry_subscription,
scroll_handle,
@@ -132,7 +129,11 @@ impl AgentConfiguration {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let configuration_view = provider.configuration_view(window, cx);
+ let configuration_view = provider.configuration_view(
+ language_model::ConfigurationViewTargetAgent::ZedAgent,
+ window,
+ cx,
+ );
self.configuration_views_by_provider
.insert(provider.id(), configuration_view);
}
@@ -156,8 +157,8 @@ impl AgentConfiguration {
provider: &Arc<dyn LanguageModelProvider>,
cx: &mut Context<Self>,
) -> impl IntoElement + use<> {
- let provider_id = provider.id().0.clone();
- let provider_name = provider.name().0.clone();
+ let provider_id = provider.id().0;
+ let provider_name = provider.name().0;
let provider_id_string = SharedString::from(format!("provider-disclosure-{provider_id}"));
let configuration_view = self
@@ -171,7 +172,24 @@ impl AgentConfiguration {
.copied()
.unwrap_or(false);
+ let is_zed_provider = provider.id() == ZED_CLOUD_PROVIDER_ID;
+ let current_plan = if is_zed_provider {
+ self.workspace
+ .upgrade()
+ .and_then(|workspace| workspace.read(cx).user_store().read(cx).plan())
+ } else {
+ None
+ };
+
+ let is_signed_in = self
+ .workspace
+ .read_with(cx, |workspace, _| {
+ !workspace.client().status().borrow().is_signed_out()
+ })
+ .unwrap_or(false);
+
v_flex()
+ .w_full()
.when(is_expanded, |this| this.mb_2())
.child(
div()
@@ -202,20 +220,39 @@ impl AgentConfiguration {
.hover(|hover| hover.bg(cx.theme().colors().element_hover))
.child(
h_flex()
+ .w_full()
.gap_2()
.child(
Icon::new(provider.icon())
.size(IconSize::Small)
.color(Color::Muted),
)
- .child(Label::new(provider_name.clone()).size(LabelSize::Large))
- .when(
- provider.is_authenticated(cx) && !is_expanded,
- |parent| {
- parent.child(
- Icon::new(IconName::Check).color(Color::Success),
+ .child(
+ h_flex()
+ .w_full()
+ .gap_1()
+ .child(
+ Label::new(provider_name.clone())
+ .size(LabelSize::Large),
)
- },
+ .map(|this| {
+ if is_zed_provider && is_signed_in {
+ this.child(
+ self.render_zed_plan_info(current_plan, cx),
+ )
+ } else {
+ this.when(
+ provider.is_authenticated(cx)
+ && !is_expanded,
+ |parent| {
+ parent.child(
+ Icon::new(IconName::Check)
+ .color(Color::Success),
+ )
+ },
+ )
+ }
+ }),
),
)
.child(
@@ -224,7 +261,7 @@ impl AgentConfiguration {
.closed_icon(IconName::ChevronDown),
)
.on_click(cx.listener({
- let provider_id = provider.id().clone();
+ let provider_id = provider.id();
move |this, _event, _window, _cx| {
let is_expanded = this
.expanded_provider_configurations
@@ -259,6 +296,7 @@ impl AgentConfiguration {
)
.child(
div()
+ .w_full()
.px_2()
.when(is_expanded, |parent| match configuration_view {
Some(configuration_view) => parent.child(configuration_view),
@@ -276,21 +314,78 @@ impl AgentConfiguration {
let providers = LanguageModelRegistry::read_global(cx).providers();
v_flex()
+ .w_full()
.child(
- v_flex()
+ h_flex()
.p(DynamicSpacing::Base16.rems(cx))
.pr(DynamicSpacing::Base20.rems(cx))
.pb_0()
.mb_2p5()
- .gap_0p5()
- .child(Headline::new("LLM Providers"))
+ .items_start()
+ .justify_between()
.child(
- Label::new("Add at least one provider to use AI-powered features.")
- .color(Color::Muted),
+ v_flex()
+ .w_full()
+ .gap_0p5()
+ .child(
+ h_flex()
+ .w_full()
+ .gap_2()
+ .justify_between()
+ .child(Headline::new("LLM Providers"))
+ .child(
+ PopoverMenu::new("add-provider-popover")
+ .trigger(
+ Button::new("add-provider", "Add Provider")
+ .icon_position(IconPosition::Start)
+ .icon(IconName::Plus)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .label_size(LabelSize::Small),
+ )
+ .anchor(gpui::Corner::TopRight)
+ .menu({
+ let workspace = self.workspace.clone();
+ move |window, cx| {
+ 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();
+ }
+ },
+ )
+ },
+ ))
+ }
+ }),
+ ),
+ )
+ .child(
+ Label::new("Add at least one provider to use AI-powered features.")
+ .color(Color::Muted),
+ ),
),
)
.child(
div()
+ .w_full()
.pl(DynamicSpacing::Base08.rems(cx))
.pr(DynamicSpacing::Base20.rems(cx))
.children(
@@ -303,119 +398,80 @@ impl AgentConfiguration {
fn render_command_permission(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let always_allow_tool_actions = AgentSettings::get_global(cx).always_allow_tool_actions;
+ let fs = self.fs.clone();
- h_flex()
- .gap_4()
- .justify_between()
- .flex_wrap()
- .child(
- v_flex()
- .gap_0p5()
- .max_w_5_6()
- .child(Label::new("Allow running editing tools without asking for confirmation"))
- .child(
- Label::new(
- "The agent can perform potentially destructive actions without asking for your confirmation.",
- )
- .color(Color::Muted),
- ),
- )
- .child(
- Switch::new(
- "always-allow-tool-actions-switch",
- always_allow_tool_actions.into(),
- )
- .color(SwitchColor::Accent)
- .on_click({
- let fs = self.fs.clone();
- move |state, _window, cx| {
- let allow = state == &ToggleState::Selected;
- update_settings_file::<AgentSettings>(
- fs.clone(),
- cx,
- move |settings, _| {
- settings.set_always_allow_tool_actions(allow);
- },
- );
- }
- }),
- )
+ SwitchField::new(
+ "always-allow-tool-actions-switch",
+ "Allow running commands without asking for confirmation",
+ Some(
+ "The agent can perform potentially destructive actions without asking for your confirmation.".into(),
+ ),
+ always_allow_tool_actions,
+ move |state, _window, cx| {
+ let allow = state == &ToggleState::Selected;
+ update_settings_file::<AgentSettings>(fs.clone(), cx, move |settings, _| {
+ settings.set_always_allow_tool_actions(allow);
+ });
+ },
+ )
}
fn render_single_file_review(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let single_file_review = AgentSettings::get_global(cx).single_file_review;
+ let fs = self.fs.clone();
- h_flex()
- .gap_4()
- .justify_between()
- .flex_wrap()
- .child(
- v_flex()
- .gap_0p5()
- .max_w_5_6()
- .child(Label::new("Enable single-file agent reviews"))
- .child(
- Label::new(
- "Agent edits are also displayed in single-file editors for review.",
- )
- .color(Color::Muted),
- ),
- )
- .child(
- Switch::new("single-file-review-switch", single_file_review.into())
- .color(SwitchColor::Accent)
- .on_click({
- let fs = self.fs.clone();
- move |state, _window, cx| {
- let allow = state == &ToggleState::Selected;
- update_settings_file::<AgentSettings>(
- fs.clone(),
- cx,
- move |settings, _| {
- settings.set_single_file_review(allow);
- },
- );
- }
- }),
- )
+ SwitchField::new(
+ "single-file-review",
+ "Enable single-file agent reviews",
+ Some("Agent edits are also displayed in single-file editors for review.".into()),
+ single_file_review,
+ move |state, _window, cx| {
+ let allow = state == &ToggleState::Selected;
+ update_settings_file::<AgentSettings>(fs.clone(), cx, move |settings, _| {
+ settings.set_single_file_review(allow);
+ });
+ },
+ )
}
fn render_sound_notification(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let play_sound_when_agent_done = AgentSettings::get_global(cx).play_sound_when_agent_done;
+ let fs = self.fs.clone();
- h_flex()
- .gap_4()
- .justify_between()
- .flex_wrap()
- .child(
- v_flex()
- .gap_0p5()
- .max_w_5_6()
- .child(Label::new("Play sound when finished generating"))
- .child(
- Label::new(
- "Hear a notification sound when the agent is done generating changes or needs your input.",
- )
- .color(Color::Muted),
- ),
- )
- .child(
- Switch::new("play-sound-notification-switch", play_sound_when_agent_done.into())
- .color(SwitchColor::Accent)
- .on_click({
- let fs = self.fs.clone();
- move |state, _window, cx| {
- let allow = state == &ToggleState::Selected;
- update_settings_file::<AgentSettings>(
- fs.clone(),
- cx,
- move |settings, _| {
- settings.set_play_sound_when_agent_done(allow);
- },
- );
- }
- }),
- )
+ SwitchField::new(
+ "sound-notification",
+ "Play sound when finished generating",
+ Some(
+ "Hear a notification sound when the agent is done generating changes or needs your input.".into(),
+ ),
+ play_sound_when_agent_done,
+ move |state, _window, cx| {
+ let allow = state == &ToggleState::Selected;
+ update_settings_file::<AgentSettings>(fs.clone(), cx, move |settings, _| {
+ settings.set_play_sound_when_agent_done(allow);
+ });
+ },
+ )
+ }
+
+ fn render_modifier_to_send(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
+ let use_modifier_to_send = AgentSettings::get_global(cx).use_modifier_to_send;
+ let fs = self.fs.clone();
+
+ SwitchField::new(
+ "modifier-send",
+ "Use modifier to submit a message",
+ Some(
+ "Make a modifier (cmd-enter on macOS, ctrl-enter on Linux or Windows) required to send messages.".into(),
+ ),
+ use_modifier_to_send,
+ move |state, _window, cx| {
+ let allow = state == &ToggleState::Selected;
+ update_settings_file::<AgentSettings>(fs.clone(), cx, move |settings, _| {
+ settings.set_use_modifier_to_send(allow);
+ });
+ },
+ )
}
fn render_general_settings_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
@@ -429,6 +485,38 @@ impl AgentConfiguration {
.child(self.render_command_permission(cx))
.child(self.render_single_file_review(cx))
.child(self.render_sound_notification(cx))
+ .child(self.render_modifier_to_send(cx))
+ }
+
+ fn render_zed_plan_info(&self, plan: Option<Plan>, cx: &mut Context<Self>) -> impl IntoElement {
+ if let Some(plan) = plan {
+ let free_chip_bg = cx
+ .theme()
+ .colors()
+ .editor_background
+ .opacity(0.5)
+ .blend(cx.theme().colors().text_accent.opacity(0.05));
+
+ let pro_chip_bg = cx
+ .theme()
+ .colors()
+ .editor_background
+ .opacity(0.5)
+ .blend(cx.theme().colors().text_accent.opacity(0.2));
+
+ let (plan_name, label_color, bg_color) = match plan {
+ Plan::ZedFree => ("Free", Color::Default, free_chip_bg),
+ Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg),
+ Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg),
+ };
+
+ Chip::new(plan_name.to_string())
+ .bg_color(bg_color)
+ .label_color(label_color)
+ .into_any_element()
+ } else {
+ div().into_any_element()
+ }
}
fn render_context_servers_section(
@@ -448,7 +536,7 @@ impl AgentConfiguration {
v_flex()
.gap_0p5()
.child(Headline::new("Model Context Protocol (MCP) Servers"))
- .child(Label::new("Connect to context servers via the Model Context Protocol either via Zed extensions or directly.").color(Color::Muted)),
+ .child(Label::new("Connect to context servers through the Model Context Protocol, either using Zed extensions or directly.").color(Color::Muted)),
)
.children(
context_server_ids.into_iter().map(|context_server_id| {
@@ -482,7 +570,7 @@ impl AgentConfiguration {
.style(ButtonStyle::Filled)
.layer(ElevationIndex::ModalSurface)
.full_width()
- .icon(IconName::Hammer)
+ .icon(IconName::ToolHammer)
.icon_size(IconSize::Small)
.icon_position(IconPosition::Start)
.on_click(|_event, window, cx| {
@@ -491,6 +579,7 @@ impl AgentConfiguration {
category_filter: Some(
ExtensionCategoryFilter::ContextServers,
),
+ id: None,
}
.boxed_clone(),
cx,
@@ -568,7 +657,7 @@ impl AgentConfiguration {
.size(IconSize::XSmall)
.color(Color::Accent)
.with_animation(
- SharedString::from(format!("{}-starting", context_server_id.0.clone(),)),
+ SharedString::from(format!("{}-starting", context_server_id.0,)),
Animation::new(Duration::from_secs(3)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
)
@@ -768,7 +857,6 @@ impl AgentConfiguration {
.on_click({
let context_server_manager =
self.context_server_store.clone();
- let context_server_id = context_server_id.clone();
let fs = self.fs.clone();
move |state, _window, cx| {
@@ -861,7 +949,7 @@ impl AgentConfiguration {
}
parent.child(v_flex().py_1p5().px_1().gap_1().children(
- tools.into_iter().enumerate().map(|(ix, tool)| {
+ tools.iter().enumerate().map(|(ix, tool)| {
h_flex()
.id(("tool-item", ix))
.px_1()
@@ -943,7 +1031,6 @@ fn extension_only_provides_context_server(manifest: &ExtensionManifest) -> bool
&& manifest.grammars.is_empty()
&& manifest.language_servers.is_empty()
&& manifest.slash_commands.is_empty()
- && manifest.indexed_docs_providers.is_empty()
&& manifest.snippets.is_none()
&& manifest.debug_locators.is_empty()
}
@@ -979,7 +1066,6 @@ fn show_unable_to_uninstall_extension_with_context_server(
cx,
move |this, _cx| {
let workspace_handle = workspace_handle.clone();
- let context_server_id = context_server_id.clone();
this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning))
.dismiss_button(true)
@@ -0,0 +1,799 @@
+use std::sync::Arc;
+
+use anyhow::Result;
+use collections::HashSet;
+use fs::Fs;
+use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, Task};
+use language_model::LanguageModelRegistry;
+use language_models::{
+ AllLanguageModelSettings, OpenAiCompatibleSettingsContent,
+ provider::open_ai_compatible::{AvailableModel, ModelCapabilities},
+};
+use settings::update_settings_file;
+use ui::{
+ Banner, Checkbox, KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, prelude::*,
+};
+use ui_input::SingleLineInput;
+use workspace::{ModalView, Workspace};
+
+#[derive(Clone, Copy)]
+pub enum LlmCompatibleProvider {
+ OpenAi,
+}
+
+impl LlmCompatibleProvider {
+ fn name(&self) -> &'static str {
+ match self {
+ LlmCompatibleProvider::OpenAi => "OpenAI",
+ }
+ }
+
+ fn api_url(&self) -> &'static str {
+ match self {
+ LlmCompatibleProvider::OpenAi => "https://api.openai.com/v1",
+ }
+ }
+}
+
+struct AddLlmProviderInput {
+ provider_name: Entity<SingleLineInput>,
+ api_url: Entity<SingleLineInput>,
+ api_key: Entity<SingleLineInput>,
+ models: Vec<ModelInput>,
+}
+
+impl AddLlmProviderInput {
+ fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut App) -> Self {
+ let provider_name = single_line_input("Provider Name", provider.name(), None, window, cx);
+ let api_url = single_line_input("API URL", provider.api_url(), None, window, cx);
+ let api_key = single_line_input(
+ "API Key",
+ "000000000000000000000000000000000000000000000000",
+ None,
+ window,
+ cx,
+ );
+
+ Self {
+ provider_name,
+ api_url,
+ api_key,
+ models: vec![ModelInput::new(window, cx)],
+ }
+ }
+
+ fn add_model(&mut self, window: &mut Window, cx: &mut App) {
+ self.models.push(ModelInput::new(window, cx));
+ }
+
+ fn remove_model(&mut self, index: usize) {
+ self.models.remove(index);
+ }
+}
+
+struct ModelCapabilityToggles {
+ pub supports_tools: ToggleState,
+ pub supports_images: ToggleState,
+ pub supports_parallel_tool_calls: ToggleState,
+ pub supports_prompt_cache_key: ToggleState,
+}
+
+struct ModelInput {
+ name: Entity<SingleLineInput>,
+ max_completion_tokens: Entity<SingleLineInput>,
+ max_output_tokens: Entity<SingleLineInput>,
+ max_tokens: Entity<SingleLineInput>,
+ capabilities: ModelCapabilityToggles,
+}
+
+impl ModelInput {
+ fn new(window: &mut Window, cx: &mut App) -> Self {
+ let model_name = single_line_input(
+ "Model Name",
+ "e.g. gpt-4o, claude-opus-4, gemini-2.5-pro",
+ None,
+ window,
+ cx,
+ );
+ let max_completion_tokens = single_line_input(
+ "Max Completion Tokens",
+ "200000",
+ Some("200000"),
+ window,
+ cx,
+ );
+ let max_output_tokens = single_line_input(
+ "Max Output Tokens",
+ "Max Output Tokens",
+ Some("32000"),
+ window,
+ cx,
+ );
+ let max_tokens = single_line_input("Max Tokens", "Max Tokens", Some("200000"), window, cx);
+ let ModelCapabilities {
+ tools,
+ images,
+ parallel_tool_calls,
+ prompt_cache_key,
+ } = ModelCapabilities::default();
+ Self {
+ name: model_name,
+ max_completion_tokens,
+ max_output_tokens,
+ max_tokens,
+ capabilities: ModelCapabilityToggles {
+ supports_tools: tools.into(),
+ supports_images: images.into(),
+ supports_parallel_tool_calls: parallel_tool_calls.into(),
+ supports_prompt_cache_key: prompt_cache_key.into(),
+ },
+ }
+ }
+
+ fn parse(&self, cx: &App) -> Result<AvailableModel, SharedString> {
+ let name = self.name.read(cx).text(cx);
+ if name.is_empty() {
+ return Err(SharedString::from("Model Name cannot be empty"));
+ }
+ Ok(AvailableModel {
+ name,
+ display_name: None,
+ max_completion_tokens: Some(
+ self.max_completion_tokens
+ .read(cx)
+ .text(cx)
+ .parse::<u64>()
+ .map_err(|_| SharedString::from("Max Completion Tokens must be a number"))?,
+ ),
+ max_output_tokens: Some(
+ self.max_output_tokens
+ .read(cx)
+ .text(cx)
+ .parse::<u64>()
+ .map_err(|_| SharedString::from("Max Output Tokens must be a number"))?,
+ ),
+ max_tokens: self
+ .max_tokens
+ .read(cx)
+ .text(cx)
+ .parse::<u64>()
+ .map_err(|_| SharedString::from("Max Tokens must be a number"))?,
+ capabilities: ModelCapabilities {
+ tools: self.capabilities.supports_tools.selected(),
+ images: self.capabilities.supports_images.selected(),
+ parallel_tool_calls: self.capabilities.supports_parallel_tool_calls.selected(),
+ prompt_cache_key: self.capabilities.supports_prompt_cache_key.selected(),
+ },
+ })
+ }
+}
+
+fn single_line_input(
+ label: impl Into<SharedString>,
+ placeholder: impl Into<SharedString>,
+ text: Option<&str>,
+ window: &mut Window,
+ cx: &mut App,
+) -> Entity<SingleLineInput> {
+ cx.new(|cx| {
+ let input = SingleLineInput::new(window, cx, placeholder).label(label);
+ if let Some(text) = text {
+ input
+ .editor()
+ .update(cx, |editor, cx| editor.set_text(text, window, cx));
+ }
+ input
+ })
+}
+
+fn save_provider_to_settings(
+ input: &AddLlmProviderInput,
+ cx: &mut App,
+) -> Task<Result<(), SharedString>> {
+ let provider_name: Arc<str> = input.provider_name.read(cx).text(cx).into();
+ if provider_name.is_empty() {
+ return Task::ready(Err("Provider Name cannot be empty".into()));
+ }
+
+ if LanguageModelRegistry::read_global(cx)
+ .providers()
+ .iter()
+ .any(|provider| {
+ provider.id().0.as_ref() == provider_name.as_ref()
+ || provider.name().0.as_ref() == provider_name.as_ref()
+ })
+ {
+ return Task::ready(Err(
+ "Provider Name is already taken by another provider".into()
+ ));
+ }
+
+ let api_url = input.api_url.read(cx).text(cx);
+ if api_url.is_empty() {
+ return Task::ready(Err("API URL cannot be empty".into()));
+ }
+
+ let api_key = input.api_key.read(cx).text(cx);
+ if api_key.is_empty() {
+ return Task::ready(Err("API Key cannot be empty".into()));
+ }
+
+ let mut models = Vec::new();
+ let mut model_names: HashSet<String> = 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 fs = <dyn Fs>::global(cx);
+ let task = cx.write_credentials(&api_url, "Bearer", api_key.as_bytes());
+ cx.spawn(async move |cx| {
+ task.await
+ .map_err(|_| "Failed to write API key to keychain")?;
+ cx.update(|cx| {
+ update_settings_file::<AllLanguageModelSettings>(fs, cx, |settings, _cx| {
+ settings.openai_compatible.get_or_insert_default().insert(
+ provider_name,
+ OpenAiCompatibleSettingsContent {
+ api_url,
+ available_models: models,
+ },
+ );
+ });
+ })
+ .ok();
+ Ok(())
+ })
+}
+
+pub struct AddLlmProviderModal {
+ provider: LlmCompatibleProvider,
+ input: AddLlmProviderInput,
+ focus_handle: FocusHandle,
+ last_error: Option<SharedString>,
+}
+
+impl AddLlmProviderModal {
+ pub fn toggle(
+ provider: LlmCompatibleProvider,
+ workspace: &mut Workspace,
+ window: &mut Window,
+ cx: &mut Context<Workspace>,
+ ) {
+ workspace.toggle_modal(window, cx, |window, cx| Self::new(provider, window, cx));
+ }
+
+ fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut Context<Self>) -> Self {
+ Self {
+ input: AddLlmProviderInput::new(provider, window, cx),
+ provider,
+ last_error: None,
+ focus_handle: cx.focus_handle(),
+ }
+ }
+
+ fn confirm(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context<Self>) {
+ let task = save_provider_to_settings(&self.input, cx);
+ cx.spawn(async move |this, cx| {
+ let result = task.await;
+ this.update(cx, |this, cx| match result {
+ Ok(_) => {
+ cx.emit(DismissEvent);
+ }
+ Err(error) => {
+ this.last_error = Some(error);
+ cx.notify();
+ }
+ })
+ })
+ .detach_and_log_err(cx);
+ }
+
+ fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
+ cx.emit(DismissEvent);
+ }
+
+ fn render_model_section(&self, cx: &mut Context<Self>) -> impl IntoElement {
+ v_flex()
+ .mt_1()
+ .gap_2()
+ .child(
+ h_flex()
+ .justify_between()
+ .child(Label::new("Models").size(LabelSize::Small))
+ .child(
+ Button::new("add-model", "Add Model")
+ .icon(IconName::Plus)
+ .icon_position(IconPosition::Start)
+ .icon_size(IconSize::XSmall)
+ .icon_color(Color::Muted)
+ .label_size(LabelSize::Small)
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.input.add_model(window, cx);
+ cx.notify();
+ })),
+ ),
+ )
+ .children(
+ self.input
+ .models
+ .iter()
+ .enumerate()
+ .map(|(ix, _)| self.render_model(ix, cx)),
+ )
+ }
+
+ fn render_model(&self, ix: usize, cx: &mut Context<Self>) -> impl IntoElement + use<> {
+ let has_more_than_one_model = self.input.models.len() > 1;
+ let model = &self.input.models[ix];
+
+ v_flex()
+ .p_2()
+ .gap_2()
+ .rounded_sm()
+ .border_1()
+ .border_dashed()
+ .border_color(cx.theme().colors().border.opacity(0.6))
+ .bg(cx.theme().colors().element_active.opacity(0.15))
+ .child(model.name.clone())
+ .child(
+ h_flex()
+ .gap_2()
+ .child(model.max_completion_tokens.clone())
+ .child(model.max_output_tokens.clone()),
+ )
+ .child(model.max_tokens.clone())
+ .child(
+ v_flex()
+ .gap_1()
+ .child(
+ Checkbox::new(("supports-tools", ix), model.capabilities.supports_tools)
+ .label("Supports tools")
+ .on_click(cx.listener(move |this, checked, _window, cx| {
+ this.input.models[ix].capabilities.supports_tools = *checked;
+ cx.notify();
+ })),
+ )
+ .child(
+ Checkbox::new(("supports-images", ix), model.capabilities.supports_images)
+ .label("Supports images")
+ .on_click(cx.listener(move |this, checked, _window, cx| {
+ this.input.models[ix].capabilities.supports_images = *checked;
+ 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();
+ },
+ )),
+ ),
+ )
+ .when(has_more_than_one_model, |this| {
+ this.child(
+ Button::new(("remove-model", ix), "Remove Model")
+ .icon(IconName::Trash)
+ .icon_position(IconPosition::Start)
+ .icon_size(IconSize::XSmall)
+ .icon_color(Color::Muted)
+ .label_size(LabelSize::Small)
+ .style(ButtonStyle::Outlined)
+ .full_width()
+ .on_click(cx.listener(move |this, _, _window, cx| {
+ this.input.remove_model(ix);
+ cx.notify();
+ })),
+ )
+ })
+ }
+}
+
+impl EventEmitter<DismissEvent> for AddLlmProviderModal {}
+
+impl Focusable for AddLlmProviderModal {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl ModalView for AddLlmProviderModal {}
+
+impl Render for AddLlmProviderModal {
+ fn render(&mut self, window: &mut ui::Window, cx: &mut ui::Context<Self>) -> impl IntoElement {
+ let focus_handle = self.focus_handle(cx);
+
+ div()
+ .id("add-llm-provider-modal")
+ .key_context("AddLlmProviderModal")
+ .w(rems(34.))
+ .elevation_3(cx)
+ .on_action(cx.listener(Self::cancel))
+ .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
+ this.focus_handle(cx).focus(window);
+ }))
+ .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."
+ }
+ },
+ ))
+ .when_some(self.last_error.clone(), |this, error| {
+ this.section(
+ Section::new().child(
+ Banner::new()
+ .severity(Severity::Warning)
+ .child(div().text_xs().child(error)),
+ ),
+ )
+ })
+ .child(
+ v_flex()
+ .id("modal_content")
+ .size_full()
+ .max_h_128()
+ .overflow_y_scroll()
+ .px(DynamicSpacing::Base12.rems(cx))
+ .gap(DynamicSpacing::Base04.rems(cx))
+ .child(self.input.provider_name.clone())
+ .child(self.input.api_url.clone())
+ .child(self.input.api_key.clone())
+ .child(self.render_model_section(cx)),
+ )
+ .footer(
+ ModalFooter::new().end_slot(
+ h_flex()
+ .gap_1()
+ .child(
+ Button::new("cancel", "Cancel")
+ .key_binding(
+ KeyBinding::for_action_in(
+ &menu::Cancel,
+ &focus_handle,
+ window,
+ cx,
+ )
+ .map(|kb| kb.size(rems_from_px(12.))),
+ )
+ .on_click(cx.listener(|this, _event, window, cx| {
+ this.cancel(&menu::Cancel, window, cx)
+ })),
+ )
+ .child(
+ Button::new("save-server", "Save Provider")
+ .key_binding(
+ KeyBinding::for_action_in(
+ &menu::Confirm,
+ &focus_handle,
+ window,
+ cx,
+ )
+ .map(|kb| kb.size(rems_from_px(12.))),
+ )
+ .on_click(cx.listener(|this, _event, window, cx| {
+ this.confirm(&menu::Confirm, window, cx)
+ })),
+ ),
+ ),
+ ),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use editor::EditorSettings;
+ use fs::FakeFs;
+ use gpui::{TestAppContext, VisualTestContext};
+ use language::language_settings;
+ use language_model::{
+ LanguageModelProviderId, LanguageModelProviderName,
+ fake_provider::FakeLanguageModelProvider,
+ };
+ use project::Project;
+ use settings::{Settings as _, SettingsStore};
+ use util::path;
+
+ #[gpui::test]
+ 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())
+ );
+
+ assert_eq!(
+ save_provider_validation_errors("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(
+ "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(
+ "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(
+ "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", "32000"),
+ ("somemodel", "200000", "200000", "32000"),
+ ],
+ cx,
+ )
+ .await,
+ Some("Model Names must be unique".into())
+ );
+ }
+
+ #[gpui::test]
+ async fn test_save_provider_name_conflict(cx: &mut TestAppContext) {
+ let cx = setup_test(cx).await;
+
+ cx.update(|_window, cx| {
+ LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
+ registry.register_provider(
+ FakeLanguageModelProvider::new(
+ LanguageModelProviderId::new("someprovider"),
+ LanguageModelProviderName::new("Some Provider"),
+ ),
+ cx,
+ );
+ });
+ });
+
+ assert_eq!(
+ save_provider_validation_errors(
+ "someprovider",
+ "someurl",
+ "someapikey",
+ vec![("somemodel", "200000", "200000", "32000")],
+ cx,
+ )
+ .await,
+ Some("Provider Name is already taken by another provider".into())
+ );
+ }
+
+ #[gpui::test]
+ async fn test_model_input_default_capabilities(cx: &mut TestAppContext) {
+ let cx = setup_test(cx).await;
+
+ cx.update(|window, cx| {
+ let model_input = ModelInput::new(window, cx);
+ model_input.name.update(cx, |input, cx| {
+ input.editor().update(cx, |editor, cx| {
+ editor.set_text("somemodel", window, cx);
+ });
+ });
+ assert_eq!(
+ model_input.capabilities.supports_tools,
+ ToggleState::Selected
+ );
+ assert_eq!(
+ model_input.capabilities.supports_images,
+ ToggleState::Unselected
+ );
+ assert_eq!(
+ model_input.capabilities.supports_parallel_tool_calls,
+ ToggleState::Unselected
+ );
+ assert_eq!(
+ model_input.capabilities.supports_prompt_cache_key,
+ ToggleState::Unselected
+ );
+
+ let parsed_model = model_input.parse(cx).unwrap();
+ assert!(parsed_model.capabilities.tools);
+ assert!(!parsed_model.capabilities.images);
+ assert!(!parsed_model.capabilities.parallel_tool_calls);
+ assert!(!parsed_model.capabilities.prompt_cache_key);
+ });
+ }
+
+ #[gpui::test]
+ async fn test_model_input_deselected_capabilities(cx: &mut TestAppContext) {
+ let cx = setup_test(cx).await;
+
+ cx.update(|window, cx| {
+ let mut model_input = ModelInput::new(window, cx);
+ model_input.name.update(cx, |input, cx| {
+ input.editor().update(cx, |editor, cx| {
+ editor.set_text("somemodel", window, cx);
+ });
+ });
+
+ model_input.capabilities.supports_tools = ToggleState::Unselected;
+ model_input.capabilities.supports_images = ToggleState::Unselected;
+ model_input.capabilities.supports_parallel_tool_calls = ToggleState::Unselected;
+ model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected;
+
+ let parsed_model = model_input.parse(cx).unwrap();
+ assert!(!parsed_model.capabilities.tools);
+ assert!(!parsed_model.capabilities.images);
+ assert!(!parsed_model.capabilities.parallel_tool_calls);
+ assert!(!parsed_model.capabilities.prompt_cache_key);
+ });
+ }
+
+ #[gpui::test]
+ async fn test_model_input_with_name_and_capabilities(cx: &mut TestAppContext) {
+ let cx = setup_test(cx).await;
+
+ cx.update(|window, cx| {
+ let mut model_input = ModelInput::new(window, cx);
+ model_input.name.update(cx, |input, cx| {
+ input.editor().update(cx, |editor, cx| {
+ editor.set_text("somemodel", window, cx);
+ });
+ });
+
+ model_input.capabilities.supports_tools = ToggleState::Selected;
+ model_input.capabilities.supports_images = ToggleState::Unselected;
+ model_input.capabilities.supports_parallel_tool_calls = ToggleState::Selected;
+ model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected;
+
+ let parsed_model = model_input.parse(cx).unwrap();
+ assert_eq!(parsed_model.name, "somemodel");
+ assert!(parsed_model.capabilities.tools);
+ assert!(!parsed_model.capabilities.images);
+ assert!(parsed_model.capabilities.parallel_tool_calls);
+ assert!(!parsed_model.capabilities.prompt_cache_key);
+ });
+ }
+
+ async fn setup_test(cx: &mut TestAppContext) -> &mut VisualTestContext {
+ cx.update(|cx| {
+ let store = SettingsStore::test(cx);
+ cx.set_global(store);
+ workspace::init_settings(cx);
+ Project::init_settings(cx);
+ theme::init(theme::LoadThemes::JustBase, cx);
+ language_settings::init(cx);
+ EditorSettings::register(cx);
+ language_model::init_settings(cx);
+ language_models::init_settings(cx);
+ });
+
+ let fs = FakeFs::new(cx.executor());
+ cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
+ let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
+ let (_, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+ cx
+ }
+
+ async fn save_provider_validation_errors(
+ provider_name: &str,
+ api_url: &str,
+ api_key: &str,
+ models: Vec<(&str, &str, &str, &str)>,
+ cx: &mut VisualTestContext,
+ ) -> Option<SharedString> {
+ fn set_text(
+ input: &Entity<SingleLineInput>,
+ text: &str,
+ window: &mut Window,
+ cx: &mut App,
+ ) {
+ input.update(cx, |input, cx| {
+ input.editor().update(cx, |editor, cx| {
+ editor.set_text(text, window, cx);
+ });
+ });
+ }
+
+ let task = cx.update(|window, cx| {
+ let mut input = AddLlmProviderInput::new(LlmCompatibleProvider::OpenAi, 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);
+
+ for (i, (name, max_tokens, max_completion_tokens, max_output_tokens)) in
+ models.iter().enumerate()
+ {
+ if i >= input.models.len() {
+ input.models.push(ModelInput::new(window, cx));
+ }
+ let model = &mut input.models[i];
+ set_text(&model.name, name, window, cx);
+ set_text(&model.max_tokens, max_tokens, window, cx);
+ set_text(
+ &model.max_completion_tokens,
+ max_completion_tokens,
+ window,
+ cx,
+ );
+ set_text(&model.max_output_tokens, max_output_tokens, window, cx);
+ }
+ save_provider_to_settings(&input, cx)
+ });
+
+ task.await.err()
+ }
+}
@@ -1,4 +1,5 @@
use std::{
+ path::PathBuf,
sync::{Arc, Mutex},
time::Duration,
};
@@ -162,10 +163,10 @@ impl ConfigurationSource {
.read(cx)
.text(cx);
let settings = serde_json_lenient::from_str::<serde_json::Value>(&text)?;
- if let Some(settings_validator) = settings_validator {
- if let Err(error) = settings_validator.validate(&settings) {
- return Err(anyhow::anyhow!(error.to_string()));
- }
+ if let Some(settings_validator) = settings_validator
+ && let Err(error) = settings_validator.validate(&settings)
+ {
+ return Err(anyhow::anyhow!(error.to_string()));
}
Ok((
id.clone(),
@@ -188,7 +189,7 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)
}
None => (
"some-mcp-server".to_string(),
- "".to_string(),
+ PathBuf::new(),
"[]".to_string(),
"{}".to_string(),
),
@@ -199,13 +200,14 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)
/// The name of your MCP server
"{name}": {{
/// The command which runs the MCP server
- "command": "{command}",
+ "command": "{}",
/// The arguments to pass to the MCP server
"args": {args},
/// The environment variables to set
"env": {env}
}}
-}}"#
+}}"#,
+ command.display()
)
}
@@ -259,7 +261,6 @@ impl ConfigureContextServerModal {
_cx: &mut Context<Workspace>,
) {
workspace.register_action({
- let language_registry = language_registry.clone();
move |_workspace, _: &AddContextServer, window, cx| {
let workspace_handle = cx.weak_entity();
let language_registry = language_registry.clone();
@@ -436,7 +437,7 @@ impl ConfigureContextServerModal {
format!("{} configured successfully.", id.0),
cx,
|this, _cx| {
- this.icon(ToastIcon::new(IconName::Hammer).color(Color::Muted))
+ this.icon(ToastIcon::new(IconName::ToolHammer).color(Color::Muted))
.action("Dismiss", |_, _| {})
},
);
@@ -485,7 +486,7 @@ impl ConfigureContextServerModal {
}
fn render_modal_description(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
- const MODAL_DESCRIPTION: &'static str = "Visit the MCP server configuration docs to find all necessary arguments and environment variables.";
+ const MODAL_DESCRIPTION: &str = "Visit the MCP server configuration docs to find all necessary arguments and environment variables.";
if let ConfigurationSource::Extension {
installation_instructions: Some(installation_instructions),
@@ -565,7 +566,7 @@ impl ConfigureContextServerModal {
Button::new("open-repository", "Open Repository")
.icon(IconName::ArrowUpRight)
.icon_color(Color::Muted)
- .icon_size(IconSize::XSmall)
+ .icon_size(IconSize::Small)
.tooltip({
let repository_url = repository_url.clone();
move |window, cx| {
@@ -714,24 +715,24 @@ fn wait_for_context_server(
project::context_server_store::Event::ServerStatusChanged { server_id, status } => {
match status {
ContextServerStatus::Running => {
- if server_id == &context_server_id {
- if let Some(tx) = tx.lock().unwrap().take() {
- let _ = tx.send(Ok(()));
- }
+ if server_id == &context_server_id
+ && let Some(tx) = tx.lock().unwrap().take()
+ {
+ let _ = tx.send(Ok(()));
}
}
ContextServerStatus::Stopped => {
- if server_id == &context_server_id {
- if let Some(tx) = tx.lock().unwrap().take() {
- let _ = tx.send(Err("Context server stopped running".into()));
- }
+ if server_id == &context_server_id
+ && let Some(tx) = tx.lock().unwrap().take()
+ {
+ let _ = tx.send(Err("Context server stopped running".into()));
}
}
ContextServerStatus::Error(error) => {
- if server_id == &context_server_id {
- if let Some(tx) = tx.lock().unwrap().take() {
- let _ = tx.send(Err(error.clone()));
- }
+ if server_id == &context_server_id
+ && let Some(tx) = tx.lock().unwrap().take()
+ {
+ let _ = tx.send(Err(error.clone()));
}
}
_ => {}
@@ -464,7 +464,7 @@ impl ManageProfilesModal {
},
))
.child(ListSeparator)
- .child(h_flex().p_2().child(mode.name_editor.clone()))
+ .child(h_flex().p_2().child(mode.name_editor))
}
fn render_view_profile(
@@ -483,7 +483,7 @@ impl ManageProfilesModal {
let icon = match mode.profile_id.as_str() {
"write" => IconName::Pencil,
- "ask" => IconName::MessageBubbles,
+ "ask" => IconName::Chat,
_ => IconName::UserRoundPen,
};
@@ -594,7 +594,7 @@ impl ManageProfilesModal {
.inset(true)
.spacing(ListItemSpacing::Sparse)
.start_slot(
- Icon::new(IconName::Hammer)
+ Icon::new(IconName::ToolHammer)
.size(IconSize::Small)
.color(Color::Muted),
)
@@ -763,7 +763,7 @@ impl Render for ManageProfilesModal {
.pb_1()
.child(ProfileModalHeader::new(
format!("{profile_name} — Configure MCP Tools"),
- Some(IconName::Hammer),
+ Some(IconName::ToolHammer),
))
.child(ListSeparator)
.child(tool_picker.clone())
@@ -191,10 +191,10 @@ impl PickerDelegate for ToolPickerDelegate {
BTreeMap::default();
for item in all_items.iter() {
- if let PickerItem::Tool { server_id, name } = item.clone() {
- if name.contains(&query) {
- tools_by_provider.entry(server_id).or_default().push(name);
- }
+ if let PickerItem::Tool { server_id, name } = item.clone()
+ && name.contains(&query)
+ {
+ tools_by_provider.entry(server_id).or_default().push(name);
}
}
@@ -1,9 +1,9 @@
use crate::{Keep, KeepAll, OpenAgentDiff, Reject, RejectAll};
-use acp::{AcpThread, AcpThreadEvent};
+use acp_thread::{AcpThread, AcpThreadEvent};
+use action_log::ActionLog;
use agent::{Thread, ThreadEvent, ThreadSummary};
use agent_settings::AgentSettings;
use anyhow::Result;
-use assistant_tool::ActionLog;
use buffer_diff::DiffHunkStatus;
use collections::{HashMap, HashSet};
use editor::{
@@ -81,7 +81,7 @@ impl AgentDiffThread {
match self {
AgentDiffThread::Native(thread) => thread.read(cx).is_generating(),
AgentDiffThread::AcpThread(thread) => {
- thread.read(cx).status() == acp::ThreadStatus::Generating
+ thread.read(cx).status() == acp_thread::ThreadStatus::Generating
}
}
}
@@ -185,7 +185,7 @@ impl AgentDiffPane {
let focus_handle = cx.focus_handle();
let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
- let project = thread.project(cx).clone();
+ let project = thread.project(cx);
let editor = cx.new(|cx| {
let mut editor =
Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
@@ -196,27 +196,24 @@ impl AgentDiffPane {
editor
});
- let action_log = thread.action_log(cx).clone();
+ let action_log = thread.action_log(cx);
let mut this = Self {
- _subscriptions: [
- Some(
- cx.observe_in(&action_log, window, |this, _action_log, window, cx| {
- this.update_excerpts(window, cx)
- }),
- ),
+ _subscriptions: vec![
+ cx.observe_in(&action_log, window, |this, _action_log, window, cx| {
+ this.update_excerpts(window, cx)
+ }),
match &thread {
- AgentDiffThread::Native(thread) => {
- Some(cx.subscribe(&thread, |this, _thread, event, cx| {
- this.handle_thread_event(event, cx)
- }))
- }
- AgentDiffThread::AcpThread(_) => None,
+ AgentDiffThread::Native(thread) => cx
+ .subscribe(thread, |this, _thread, event, cx| {
+ this.handle_native_thread_event(event, cx)
+ }),
+ AgentDiffThread::AcpThread(thread) => cx
+ .subscribe(thread, |this, _thread, event, cx| {
+ this.handle_acp_thread_event(event, cx)
+ }),
},
- ]
- .into_iter()
- .flatten()
- .collect(),
+ ],
title: SharedString::default(),
multibuffer,
editor,
@@ -288,7 +285,7 @@ impl AgentDiffPane {
&& buffer
.read(cx)
.file()
- .map_or(false, |file| file.disk_state() == DiskState::Deleted)
+ .is_some_and(|file| file.disk_state() == DiskState::Deleted)
{
editor.fold_buffer(snapshot.text.remote_id(), cx)
}
@@ -324,10 +321,15 @@ impl AgentDiffPane {
}
}
- fn handle_thread_event(&mut self, event: &ThreadEvent, cx: &mut Context<Self>) {
- match event {
- ThreadEvent::SummaryGenerated => self.update_title(cx),
- _ => {}
+ fn handle_native_thread_event(&mut self, event: &ThreadEvent, cx: &mut Context<Self>) {
+ if let ThreadEvent::SummaryGenerated = event {
+ self.update_title(cx)
+ }
+ }
+
+ fn handle_acp_thread_event(&mut self, event: &AcpThreadEvent, cx: &mut Context<Self>) {
+ if let AcpThreadEvent::TitleUpdated = event {
+ self.update_title(cx)
}
}
@@ -398,7 +400,7 @@ fn keep_edits_in_selection(
.disjoint_anchor_ranges()
.collect::<Vec<_>>();
- keep_edits_in_ranges(editor, buffer_snapshot, &thread, ranges, window, cx)
+ keep_edits_in_ranges(editor, buffer_snapshot, thread, ranges, window, cx)
}
fn reject_edits_in_selection(
@@ -412,7 +414,7 @@ fn reject_edits_in_selection(
.selections
.disjoint_anchor_ranges()
.collect::<Vec<_>>();
- reject_edits_in_ranges(editor, buffer_snapshot, &thread, ranges, window, cx)
+ reject_edits_in_ranges(editor, buffer_snapshot, thread, ranges, window, cx)
}
fn keep_edits_in_ranges(
@@ -503,8 +505,7 @@ fn update_editor_selection(
&[last_kept_hunk_end..editor::Anchor::max()],
buffer_snapshot,
)
- .skip(1)
- .next()
+ .nth(1)
})
.or_else(|| {
let first_kept_hunk = diff_hunks.first()?;
@@ -1001,7 +1002,7 @@ impl AgentDiffToolbar {
return;
};
- *state = agent_diff.read(cx).editor_state(&editor);
+ *state = agent_diff.read(cx).editor_state(editor);
self.update_location(cx);
cx.notify();
}
@@ -1044,23 +1045,23 @@ impl ToolbarItemView for AgentDiffToolbar {
return self.location(cx);
}
- if let Some(editor) = item.act_as::<Editor>(cx) {
- if editor.read(cx).mode().is_full() {
- let agent_diff = AgentDiff::global(cx);
+ if let Some(editor) = item.act_as::<Editor>(cx)
+ && editor.read(cx).mode().is_full()
+ {
+ let agent_diff = AgentDiff::global(cx);
- self.active_item = Some(AgentDiffToolbarItem::Editor {
- editor: editor.downgrade(),
- state: agent_diff.read(cx).editor_state(&editor.downgrade()),
- _diff_subscription: cx.observe(&agent_diff, Self::handle_diff_notify),
- });
+ self.active_item = Some(AgentDiffToolbarItem::Editor {
+ editor: editor.downgrade(),
+ state: agent_diff.read(cx).editor_state(&editor.downgrade()),
+ _diff_subscription: cx.observe(&agent_diff, Self::handle_diff_notify),
+ });
- return self.location(cx);
- }
+ return self.location(cx);
}
}
self.active_item = None;
- return self.location(cx);
+ self.location(cx)
}
fn pane_focus_update(
@@ -1311,7 +1312,7 @@ impl AgentDiff {
let entity = cx.new(|_cx| Self::default());
let global = AgentDiffGlobal(entity.clone());
cx.set_global(global);
- entity.clone()
+ entity
})
}
@@ -1333,7 +1334,7 @@ impl AgentDiff {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let action_log = thread.action_log(cx).clone();
+ let action_log = thread.action_log(cx);
let action_log_subscription = cx.observe_in(&action_log, window, {
let workspace = workspace.clone();
@@ -1343,13 +1344,13 @@ impl AgentDiff {
});
let thread_subscription = match &thread {
- AgentDiffThread::Native(thread) => cx.subscribe_in(&thread, window, {
+ AgentDiffThread::Native(thread) => cx.subscribe_in(thread, window, {
let workspace = workspace.clone();
move |this, _thread, event, window, cx| {
this.handle_native_thread_event(&workspace, event, window, cx)
}
}),
- AgentDiffThread::AcpThread(thread) => cx.subscribe_in(&thread, window, {
+ AgentDiffThread::AcpThread(thread) => cx.subscribe_in(thread, window, {
let workspace = workspace.clone();
move |this, thread, event, window, cx| {
this.handle_acp_thread_event(&workspace, thread, event, window, cx)
@@ -1357,11 +1358,11 @@ impl AgentDiff {
}),
};
- if let Some(workspace_thread) = self.workspace_threads.get_mut(&workspace) {
+ if let Some(workspace_thread) = self.workspace_threads.get_mut(workspace) {
// replace thread and action log subscription, but keep editors
workspace_thread.thread = thread.downgrade();
workspace_thread._thread_subscriptions = (action_log_subscription, thread_subscription);
- self.update_reviewing_editors(&workspace, window, cx);
+ self.update_reviewing_editors(workspace, window, cx);
return;
}
@@ -1488,7 +1489,6 @@ impl AgentDiff {
| ThreadEvent::ToolConfirmationNeeded
| ThreadEvent::ToolUseLimitReached
| ThreadEvent::CancelEditing
- | ThreadEvent::RetriesFailed { .. }
| ThreadEvent::ProfileChanged => {}
}
}
@@ -1507,8 +1507,7 @@ impl AgentDiff {
.read(cx)
.entries()
.last()
- .and_then(|entry| entry.diff())
- .is_some()
+ .is_some_and(|entry| entry.diffs().next().is_some())
{
self.update_reviewing_editors(workspace, window, cx);
}
@@ -1518,12 +1517,19 @@ impl AgentDiff {
.read(cx)
.entries()
.get(*ix)
- .and_then(|entry| entry.diff())
- .is_some()
+ .is_some_and(|entry| entry.diffs().next().is_some())
{
self.update_reviewing_editors(workspace, window, cx);
}
}
+ AcpThreadEvent::Stopped | AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) => {
+ self.update_reviewing_editors(workspace, window, cx);
+ }
+ AcpThreadEvent::TitleUpdated
+ | AcpThreadEvent::TokenUsageUpdated
+ | AcpThreadEvent::EntriesRemoved(_)
+ | AcpThreadEvent::ToolAuthorizationRequired
+ | AcpThreadEvent::Retry(_) => {}
}
}
@@ -1534,21 +1540,11 @@ impl AgentDiff {
window: &mut Window,
cx: &mut Context<Self>,
) {
- match event {
- workspace::Event::ItemAdded { item } => {
- if let Some(editor) = item.downcast::<Editor>() {
- if let Some(buffer) = Self::full_editor_buffer(editor.read(cx), cx) {
- self.register_editor(
- workspace.downgrade(),
- buffer.clone(),
- editor,
- window,
- cx,
- );
- }
- }
- }
- _ => {}
+ if let workspace::Event::ItemAdded { item } = event
+ && let Some(editor) = item.downcast::<Editor>()
+ && let Some(buffer) = Self::full_editor_buffer(editor.read(cx), cx)
+ {
+ self.register_editor(workspace.downgrade(), buffer, editor, window, cx);
}
}
@@ -1647,7 +1643,7 @@ impl AgentDiff {
continue;
};
- for (weak_editor, _) in buffer_editors {
+ for weak_editor in buffer_editors.keys() {
let Some(editor) = weak_editor.upgrade() else {
continue;
};
@@ -1675,7 +1671,7 @@ impl AgentDiff {
editor.register_addon(EditorAgentDiffAddon);
});
} else {
- unaffected.remove(&weak_editor);
+ unaffected.remove(weak_editor);
}
if new_state == EditorState::Reviewing && previous_state != Some(new_state) {
@@ -1708,7 +1704,7 @@ impl AgentDiff {
.read_with(cx, |editor, _cx| editor.workspace())
.ok()
.flatten()
- .map_or(false, |editor_workspace| {
+ .is_some_and(|editor_workspace| {
editor_workspace.entity_id() == workspace.entity_id()
});
@@ -1728,7 +1724,7 @@ impl AgentDiff {
fn editor_state(&self, editor: &WeakEntity<Editor>) -> EditorState {
self.reviewing_editors
- .get(&editor)
+ .get(editor)
.cloned()
.unwrap_or(EditorState::Idle)
}
@@ -1848,26 +1844,26 @@ impl AgentDiff {
let thread = thread.upgrade()?;
- if let PostReviewState::AllReviewed = review(&editor, &thread, window, cx) {
- if let Some(curr_buffer) = editor.read(cx).buffer().read(cx).as_singleton() {
- let changed_buffers = thread.action_log(cx).read(cx).changed_buffers(cx);
-
- let mut keys = changed_buffers.keys().cycle();
- keys.find(|k| *k == &curr_buffer);
- let next_project_path = keys
- .next()
- .filter(|k| *k != &curr_buffer)
- .and_then(|after| after.read(cx).project_path(cx));
-
- if let Some(path) = next_project_path {
- let task = workspace.open_path(path, None, true, window, cx);
- let task = cx.spawn(async move |_, _cx| task.await.map(|_| ()));
- return Some(task);
- }
+ if let PostReviewState::AllReviewed = review(&editor, &thread, window, cx)
+ && let Some(curr_buffer) = editor.read(cx).buffer().read(cx).as_singleton()
+ {
+ let changed_buffers = thread.action_log(cx).read(cx).changed_buffers(cx);
+
+ let mut keys = changed_buffers.keys().cycle();
+ keys.find(|k| *k == &curr_buffer);
+ let next_project_path = keys
+ .next()
+ .filter(|k| *k != &curr_buffer)
+ .and_then(|after| after.read(cx).project_path(cx));
+
+ if let Some(path) = next_project_path {
+ let task = workspace.open_path(path, None, true, window, cx);
+ let task = cx.spawn(async move |_, _cx| task.await.map(|_| ()));
+ return Some(task);
}
}
- return Some(Task::ready(Ok(())));
+ Some(Task::ready(Ok(())))
}
}
@@ -1,8 +1,6 @@
use crate::{
ModelUsageContext,
- language_model_selector::{
- LanguageModelSelector, ToggleModelSelector, language_model_selector,
- },
+ language_model_selector::{LanguageModelSelector, language_model_selector},
};
use agent_settings::AgentSettings;
use fs::Fs;
@@ -12,6 +10,7 @@ use picker::popover_menu::PickerPopoverMenu;
use settings::update_settings_file;
use std::sync::Arc;
use ui::{ButtonLike, PopoverMenuHandle, Tooltip, prelude::*};
+use zed_actions::agent::ToggleModelSelector;
pub struct AgentModelSelector {
selector: Entity<LanguageModelSelector>,
@@ -67,10 +66,8 @@ impl AgentModelSelector {
fs.clone(),
cx,
move |settings, _cx| {
- settings.set_inline_assistant_model(
- provider.clone(),
- model_id.clone(),
- );
+ settings
+ .set_inline_assistant_model(provider.clone(), model_id);
},
);
}
@@ -96,22 +93,18 @@ impl Render for AgentModelSelector {
let model_name = model
.as_ref()
.map(|model| model.model.name().0)
- .unwrap_or_else(|| SharedString::from("No model selected"));
- let provider_icon = model
- .as_ref()
- .map(|model| model.provider.icon())
- .unwrap_or_else(|| IconName::Ai);
+ .unwrap_or_else(|| SharedString::from("Select a Model"));
+
+ let provider_icon = model.as_ref().map(|model| model.provider.icon());
let focus_handle = self.focus_handle.clone();
PickerPopoverMenu::new(
self.selector.clone(),
ButtonLike::new("active-model")
- .child(
- Icon::new(provider_icon)
- .color(Color::Muted)
- .size(IconSize::XSmall),
- )
+ .when_some(provider_icon, |this, icon| {
+ this.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall))
+ })
.child(
Label::new(model_name)
.color(Color::Muted)
@@ -1,21 +1,23 @@
-use std::cell::RefCell;
-use std::ops::Range;
+use std::ops::{Not, Range};
use std::path::Path;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
+use acp_thread::AcpThread;
+use agent_servers::AgentServerSettings;
+use agent2::{DbThreadMetadata, HistoryEntry};
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use serde::{Deserialize, Serialize};
-use crate::NewAcpThread;
+use crate::acp::{AcpThreadHistory, ThreadHistoryEvent};
use crate::agent_diff::AgentDiffThread;
-use crate::language_model_selector::ToggleModelSelector;
use crate::{
AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode,
DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell,
- ResetTrialUpsell, ToggleBurnMode, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu,
+ ResetTrialUpsell, ToggleBurnMode, ToggleContextPicker, ToggleNavigationMenu,
+ ToggleNewThreadMenu, ToggleOptionsMenu,
acp::AcpThreadView,
active_thread::{self, ActiveThread, ActiveThreadEvent},
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
@@ -27,8 +29,9 @@ use crate::{
render_remaining_tokens,
},
thread_history::{HistoryEntryElement, ThreadHistory},
- ui::AgentOnboardingModal,
+ ui::{AgentOnboardingModal, EndTrialUpsell},
};
+use crate::{ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary};
use agent::{
Thread, ThreadError, ThreadEvent, ThreadId, ThreadSummary, TokenUsageRatio,
context_store::ContextStore,
@@ -36,27 +39,25 @@ use agent::{
thread_store::{TextThreadStore, ThreadStore},
};
use agent_settings::{AgentDockPosition, AgentSettings, CompletionMode, DefaultView};
+use ai_onboarding::AgentPanelOnboarding;
use anyhow::{Result, anyhow};
use assistant_context::{AssistantContext, ContextEvent, ContextSummary};
use assistant_slash_command::SlashCommandWorkingSet;
use assistant_tool::ToolWorkingSet;
use client::{UserStore, zed_urls};
+use cloud_llm_client::{CompletionIntent, Plan, UsageLimit};
use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
-use feature_flags::{self, FeatureFlagAppExt};
+use feature_flags::{self, ClaudeCodeFeatureFlag, FeatureFlagAppExt, GeminiAndNativeFeatureFlag};
use fs::Fs;
use gpui::{
Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem,
- Corner, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, Hsla,
- KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, linear_color_stop,
- linear_gradient, prelude::*, pulsating_between,
+ Corner, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext,
+ Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
};
use language::LanguageRegistry;
-use language_model::{
- ConfigurationError, LanguageModelProviderTosView, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID,
-};
-use project::{Project, ProjectPath, Worktree};
+use language_model::{ConfigurationError, ConfiguredModel, LanguageModelRegistry};
+use project::{DisableAiSettings, Project, ProjectPath, Worktree};
use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
-use proto::Plan;
use rules_library::{RulesLibrary, open_rules_library};
use search::{BufferSearchBar, buffer_search};
use settings::{Settings, update_settings_file};
@@ -64,8 +65,8 @@ use theme::ThemeSettings;
use time::UtcOffset;
use ui::utils::WithRemSize;
use ui::{
- Banner, Callout, CheckboxWithLabel, ContextMenu, ElevationIndex, KeyBinding, PopoverMenu,
- PopoverMenuHandle, ProgressBar, Tab, Tooltip, Vector, VectorName, prelude::*,
+ Banner, Callout, ContextMenu, ContextMenuEntry, ElevationIndex, KeyBinding, PopoverMenu,
+ PopoverMenuHandle, ProgressBar, Tab, Tooltip, prelude::*,
};
use util::ResultExt as _;
use workspace::{
@@ -74,16 +75,16 @@ use workspace::{
};
use zed_actions::{
DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
- agent::{OpenConfiguration, OpenOnboardingModal, ResetOnboarding},
+ agent::{OpenOnboardingModal, OpenSettings, ResetOnboarding, ToggleModelSelector},
assistant::{OpenRulesLibrary, ToggleFocus},
};
-use zed_llm_client::{CompletionIntent, UsageLimit};
const AGENT_PANEL_KEY: &str = "agent_panel";
#[derive(Serialize, Deserialize)]
struct SerializedAgentPanel {
width: Option<Pixels>,
+ selected_agent: Option<AgentType>,
}
pub fn init(cx: &mut App) {
@@ -96,13 +97,23 @@ pub fn init(cx: &mut App) {
workspace.focus_panel::<AgentPanel>(window, cx);
}
})
+ .register_action(
+ |workspace, action: &NewNativeAgentThreadFromSummary, window, cx| {
+ if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.new_native_agent_thread_from_summary(action, window, cx)
+ });
+ workspace.focus_panel::<AgentPanel>(window, cx);
+ }
+ },
+ )
.register_action(|workspace, _: &OpenHistory, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| panel.open_history(window, cx));
}
})
- .register_action(|workspace, _: &OpenConfiguration, window, cx| {
+ .register_action(|workspace, _: &OpenSettings, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| panel.open_configuration(window, cx));
@@ -114,10 +125,12 @@ pub fn init(cx: &mut App) {
panel.update(cx, |panel, cx| panel.new_prompt_editor(window, cx));
}
})
- .register_action(|workspace, _: &NewAcpThread, window, cx| {
+ .register_action(|workspace, action: &NewExternalAgentThread, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
- panel.update(cx, |panel, cx| panel.new_gemini_thread(window, cx));
+ panel.update(cx, |panel, cx| {
+ panel.external_thread(action.agent.clone(), None, None, window, cx)
+ });
}
})
.register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
@@ -136,7 +149,7 @@ pub fn init(cx: &mut App) {
let thread = thread.read(cx).thread().clone();
AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
}
- ActiveView::AcpThread { .. }
+ ActiveView::ExternalAgentThread { .. }
| ActiveView::TextThread { .. }
| ActiveView::History
| ActiveView::Configuration => {}
@@ -175,6 +188,14 @@ pub fn init(cx: &mut App) {
});
}
})
+ .register_action(|workspace, _: &ToggleNewThreadMenu, window, cx| {
+ if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+ workspace.focus_panel::<AgentPanel>(window, cx);
+ panel.update(cx, |panel, cx| {
+ panel.toggle_new_thread_menu(&ToggleNewThreadMenu, window, cx);
+ });
+ }
+ })
.register_action(|workspace, _: &OpenOnboardingModal, window, cx| {
AgentOnboardingModal::toggle(workspace, window, cx)
})
@@ -183,7 +204,7 @@ pub fn init(cx: &mut App) {
window.refresh();
})
.register_action(|_workspace, _: &ResetTrialUpsell, _window, cx| {
- Upsell::set_dismissed(false, cx);
+ OnboardingUpsell::set_dismissed(false, cx);
})
.register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
TrialEndUpsell::set_dismissed(false, cx);
@@ -200,7 +221,7 @@ enum ActiveView {
message_editor: Entity<MessageEditor>,
_subscriptions: Vec<gpui::Subscription>,
},
- AcpThread {
+ ExternalAgentThread {
thread_view: Entity<AcpThreadView>,
},
TextThread {
@@ -219,12 +240,47 @@ enum WhichFontSize {
None,
}
+#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
+pub enum AgentType {
+ #[default]
+ Zed,
+ TextThread,
+ Gemini,
+ ClaudeCode,
+ NativeAgent,
+ Custom {
+ name: SharedString,
+ settings: AgentServerSettings,
+ },
+}
+
+impl AgentType {
+ fn label(&self) -> SharedString {
+ match self {
+ Self::Zed | Self::TextThread => "Zed Agent".into(),
+ Self::NativeAgent => "Agent 2".into(),
+ Self::Gemini => "Gemini CLI".into(),
+ Self::ClaudeCode => "Claude Code".into(),
+ Self::Custom { name, .. } => name.into(),
+ }
+ }
+
+ fn icon(&self) -> Option<IconName> {
+ match self {
+ Self::Zed | Self::NativeAgent | Self::TextThread => None,
+ Self::Gemini => Some(IconName::AiGemini),
+ Self::ClaudeCode => Some(IconName::AiClaude),
+ Self::Custom { .. } => Some(IconName::Terminal),
+ }
+ }
+}
+
impl ActiveView {
pub fn which_font_size_used(&self) -> WhichFontSize {
match self {
- ActiveView::Thread { .. } | ActiveView::AcpThread { .. } | ActiveView::History => {
- WhichFontSize::AgentFont
- }
+ ActiveView::Thread { .. }
+ | ActiveView::ExternalAgentThread { .. }
+ | ActiveView::History => WhichFontSize::AgentFont,
ActiveView::TextThread { .. } => WhichFontSize::BufferFont,
ActiveView::Configuration => WhichFontSize::None,
}
@@ -255,7 +311,7 @@ impl ActiveView {
thread.scroll_to_bottom(cx);
});
}
- ActiveView::AcpThread { .. } => {}
+ ActiveView::ExternalAgentThread { .. } => {}
ActiveView::TextThread { .. }
| ActiveView::History
| ActiveView::Configuration => {}
@@ -313,7 +369,7 @@ impl ActiveView {
Self::Thread {
change_title_editor: editor,
thread: active_thread,
- message_editor: message_editor,
+ message_editor,
_subscriptions: subscriptions,
}
}
@@ -321,6 +377,7 @@ impl ActiveView {
pub fn prompt_editor(
context_editor: Entity<TextThreadEditor>,
history_store: Entity<HistoryStore>,
+ acp_history_store: Entity<agent2::HistoryStore>,
language_registry: Arc<LanguageRegistry>,
window: &mut Window,
cx: &mut App,
@@ -398,6 +455,18 @@ impl ActiveView {
);
}
});
+
+ acp_history_store.update(cx, |history_store, cx| {
+ if let Some(old_path) = old_path {
+ history_store
+ .replace_recently_opened_text_thread(old_path, new_path, cx);
+ } else {
+ history_store.push_recently_opened_entry(
+ agent2::HistoryEntryId::TextThread(new_path.clone()),
+ cx,
+ );
+ }
+ });
}
_ => {}
}
@@ -426,6 +495,8 @@ pub struct AgentPanel {
fs: Arc<dyn Fs>,
language_registry: Arc<LanguageRegistry>,
thread_store: Entity<ThreadStore>,
+ acp_history: Entity<AcpThreadHistory>,
+ acp_history_store: Entity<agent2::HistoryStore>,
_default_model_subscription: Subscription,
context_store: Entity<TextThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
@@ -434,8 +505,6 @@ pub struct AgentPanel {
configuration_subscription: Option<Subscription>,
local_timezone: UtcOffset,
active_view: ActiveView,
- acp_message_history:
- Rc<RefCell<crate::acp::MessageHistory<agentic_coding_protocol::SendUserMessageParams>>>,
previous_view: Option<ActiveView>,
history_store: Entity<HistoryStore>,
history: Entity<ThreadHistory>,
@@ -448,22 +517,28 @@ pub struct AgentPanel {
height: Option<Pixels>,
zoomed: bool,
pending_serialization: Option<Task<Result<()>>>,
- hide_upsell: bool,
+ onboarding: Entity<AgentPanelOnboarding>,
+ selected_agent: AgentType,
}
impl AgentPanel {
fn serialize(&mut self, cx: &mut Context<Self>) {
let width = self.width;
+ let selected_agent = self.selected_agent.clone();
self.pending_serialization = Some(cx.background_spawn(async move {
KEY_VALUE_STORE
.write_kvp(
AGENT_PANEL_KEY.into(),
- serde_json::to_string(&SerializedAgentPanel { width })?,
+ serde_json::to_string(&SerializedAgentPanel {
+ width,
+ selected_agent: Some(selected_agent),
+ })?,
)
.await?;
anyhow::Ok(())
}));
}
+
pub fn load(
workspace: WeakEntity<Workspace>,
prompt_builder: Arc<PromptBuilder>,
@@ -513,6 +588,17 @@ impl AgentPanel {
None
};
+ // Wait for the Gemini/Native feature flag to be available.
+ let client = workspace.read_with(cx, |workspace, _| workspace.client().clone())?;
+ if !client.status().borrow().is_signed_out() {
+ cx.update(|_, cx| {
+ cx.wait_for_flag_or_timeout::<feature_flags::GeminiAndNativeFeatureFlag>(
+ Duration::from_secs(2),
+ )
+ })?
+ .await;
+ }
+
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| {
Self::new(
@@ -527,6 +613,10 @@ impl AgentPanel {
if let Some(serialized_panel) = serialized_panel {
panel.update(cx, |panel, cx| {
panel.width = serialized_panel.width.map(|w| w.round());
+ if let Some(selected_agent) = serialized_panel.selected_agent {
+ panel.selected_agent = selected_agent.clone();
+ panel.new_agent_thread(selected_agent, window, cx);
+ }
cx.notify();
});
}
@@ -550,6 +640,7 @@ impl AgentPanel {
let user_store = workspace.app_state().user_store.clone();
let project = workspace.project();
let language_registry = project.read(cx).languages().clone();
+ let client = workspace.client().clone();
let workspace = workspace.weak_handle();
let weak_self = cx.entity().downgrade();
@@ -558,30 +649,54 @@ impl AgentPanel {
let inline_assist_context_store =
cx.new(|_cx| ContextStore::new(project.downgrade(), Some(thread_store.downgrade())));
+ let thread_id = thread.read(cx).id().clone();
+
+ let history_store = cx.new(|cx| {
+ HistoryStore::new(
+ thread_store.clone(),
+ context_store.clone(),
+ [HistoryEntryId::Thread(thread_id)],
+ cx,
+ )
+ });
+
let message_editor = cx.new(|cx| {
MessageEditor::new(
fs.clone(),
workspace.clone(),
- user_store.clone(),
message_editor_context_store.clone(),
prompt_store.clone(),
thread_store.downgrade(),
context_store.downgrade(),
+ Some(history_store.downgrade()),
thread.clone(),
window,
cx,
)
});
- let thread_id = thread.read(cx).id().clone();
- let history_store = cx.new(|cx| {
- HistoryStore::new(
- thread_store.clone(),
- context_store.clone(),
- [HistoryEntryId::Thread(thread_id)],
- cx,
- )
- });
+ let acp_history_store = cx.new(|cx| agent2::HistoryStore::new(context_store.clone(), cx));
+ let acp_history = cx.new(|cx| AcpThreadHistory::new(acp_history_store.clone(), window, cx));
+ cx.subscribe_in(
+ &acp_history,
+ window,
+ |this, _, event, window, cx| match event {
+ ThreadHistoryEvent::Open(HistoryEntry::AcpThread(thread)) => {
+ this.external_thread(
+ Some(crate::ExternalAgent::NativeAgent),
+ Some(thread.clone()),
+ None,
+ window,
+ cx,
+ );
+ }
+ ThreadHistoryEvent::Open(HistoryEntry::TextThread(thread)) => {
+ this.open_saved_prompt_editor(thread.path.clone(), window, cx)
+ .detach_and_log_err(cx);
+ }
+ },
+ )
+ .detach();
cx.observe(&history_store, |_, _, cx| cx.notify()).detach();
@@ -621,6 +736,7 @@ impl AgentPanel {
ActiveView::prompt_editor(
context_editor,
history_store.clone(),
+ acp_history_store.clone(),
language_registry.clone(),
window,
cx,
@@ -637,7 +753,11 @@ impl AgentPanel {
let assistant_navigation_menu =
ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| {
if let Some(panel) = panel.upgrade() {
- menu = Self::populate_recently_opened_menu_section(menu, panel, cx);
+ if cx.has_flag::<GeminiAndNativeFeatureFlag>() {
+ menu = Self::populate_recently_opened_menu_section_new(menu, panel, cx);
+ } else {
+ menu = Self::populate_recently_opened_menu_section_old(menu, panel, cx);
+ }
}
menu.action("View All", Box::new(OpenHistory))
.end_slot_action(DeleteRecentlyOpenThread.boxed_clone())
@@ -663,25 +783,36 @@ impl AgentPanel {
.ok();
});
- let _default_model_subscription = cx.subscribe(
- &LanguageModelRegistry::global(cx),
- |this, _, event: &language_model::Event, cx| match event {
- language_model::Event::DefaultModelChanged => match &this.active_view {
- ActiveView::Thread { thread, .. } => {
- thread
- .read(cx)
- .thread()
- .clone()
- .update(cx, |thread, cx| thread.get_or_init_configured_model(cx));
+ let _default_model_subscription =
+ cx.subscribe(
+ &LanguageModelRegistry::global(cx),
+ |this, _, event: &language_model::Event, cx| {
+ if let language_model::Event::DefaultModelChanged = event {
+ match &this.active_view {
+ ActiveView::Thread { thread, .. } => {
+ thread.read(cx).thread().clone().update(cx, |thread, cx| {
+ thread.get_or_init_configured_model(cx)
+ });
+ }
+ ActiveView::ExternalAgentThread { .. }
+ | ActiveView::TextThread { .. }
+ | ActiveView::History
+ | ActiveView::Configuration => {}
+ }
}
- ActiveView::AcpThread { .. }
- | ActiveView::TextThread { .. }
- | ActiveView::History
- | ActiveView::Configuration => {}
},
- _ => {}
- },
- );
+ );
+
+ let onboarding = cx.new(|cx| {
+ AgentPanelOnboarding::new(
+ user_store.clone(),
+ client,
+ |_window, cx| {
+ OnboardingUpsell::set_dismissed(true, cx);
+ },
+ cx,
+ )
+ });
Self {
active_view,
@@ -702,7 +833,6 @@ impl AgentPanel {
.unwrap(),
inline_assist_context_store,
previous_view: None,
- acp_message_history: Default::default(),
history_store: history_store.clone(),
history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, window, cx)),
hovered_recent_history_item: None,
@@ -714,7 +844,10 @@ impl AgentPanel {
height: None,
zoomed: false,
pending_serialization: None,
- hide_upsell: false,
+ onboarding,
+ acp_history,
+ acp_history_store,
+ selected_agent: AgentType::default(),
}
}
@@ -727,6 +860,7 @@ impl AgentPanel {
if workspace
.panel::<Self>(cx)
.is_some_and(|panel| panel.read(cx).enabled(cx))
+ && !DisableAiSettings::get_global(cx).disable_ai
{
workspace.toggle_panel_focus::<Self>(window, cx);
}
@@ -757,17 +891,27 @@ impl AgentPanel {
ActiveView::Thread { thread, .. } => {
thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx));
}
- ActiveView::AcpThread { thread_view, .. } => {
- thread_view.update(cx, |thread_element, cx| thread_element.cancel(cx));
- }
- ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {}
+ ActiveView::ExternalAgentThread { .. }
+ | ActiveView::TextThread { .. }
+ | ActiveView::History
+ | ActiveView::Configuration => {}
}
}
fn active_message_editor(&self) -> Option<&Entity<MessageEditor>> {
match &self.active_view {
ActiveView::Thread { message_editor, .. } => Some(message_editor),
- ActiveView::AcpThread { .. }
+ ActiveView::ExternalAgentThread { .. }
+ | ActiveView::TextThread { .. }
+ | ActiveView::History
+ | ActiveView::Configuration => None,
+ }
+ }
+
+ fn active_thread_view(&self) -> Option<&Entity<AcpThreadView>> {
+ match &self.active_view {
+ ActiveView::ExternalAgentThread { thread_view, .. } => Some(thread_view),
+ ActiveView::Thread { .. }
| ActiveView::TextThread { .. }
| ActiveView::History
| ActiveView::Configuration => None,
@@ -775,6 +919,9 @@ impl AgentPanel {
}
fn new_thread(&mut self, action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
+ if cx.has_flag::<GeminiAndNativeFeatureFlag>() {
+ return self.new_agent_thread(AgentType::NativeAgent, window, cx);
+ }
// Preserve chat box text when using creating new thread
let preserved_text = self
.active_message_editor()
@@ -828,11 +975,11 @@ impl AgentPanel {
MessageEditor::new(
self.fs.clone(),
self.workspace.clone(),
- self.user_store.clone(),
context_store.clone(),
self.prompt_store.clone(),
self.thread_store.downgrade(),
self.context_store.downgrade(),
+ Some(self.history_store.downgrade()),
thread.clone(),
window,
cx,
@@ -847,12 +994,35 @@ impl AgentPanel {
message_editor.focus_handle(cx).focus(window);
- let thread_view = ActiveView::thread(active_thread.clone(), message_editor, window, cx);
+ let thread_view = ActiveView::thread(active_thread, message_editor, window, cx);
self.set_active_view(thread_view, window, cx);
AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx);
}
+ fn new_native_agent_thread_from_summary(
+ &mut self,
+ action: &NewNativeAgentThreadFromSummary,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(thread) = self
+ .acp_history_store
+ .read(cx)
+ .thread_from_session_id(&action.from_session_id)
+ else {
+ return;
+ };
+
+ self.external_thread(
+ Some(ExternalAgent::NativeAgent),
+ None,
+ Some(thread.clone()),
+ window,
+ cx,
+ );
+ }
+
fn new_prompt_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let context = self
.context_store
@@ -879,6 +1049,7 @@ impl AgentPanel {
ActiveView::prompt_editor(
context_editor.clone(),
self.history_store.clone(),
+ self.acp_history_store.clone(),
self.language_registry.clone(),
window,
cx,
@@ -889,35 +1060,98 @@ impl AgentPanel {
context_editor.focus_handle(cx).focus(window);
}
- fn new_gemini_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ fn external_thread(
+ &mut self,
+ agent_choice: Option<crate::ExternalAgent>,
+ resume_thread: Option<DbThreadMetadata>,
+ summarize_thread: Option<DbThreadMetadata>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
let workspace = self.workspace.clone();
let project = self.project.clone();
- let message_history = self.acp_message_history.clone();
+ let fs = self.fs.clone();
+
+ const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
+
+ #[derive(Default, Serialize, Deserialize)]
+ struct LastUsedExternalAgent {
+ agent: crate::ExternalAgent,
+ }
+
+ let history = self.acp_history_store.clone();
cx.spawn_in(window, async move |this, cx| {
- let thread_view = cx.new_window_entity(|window, cx| {
- crate::acp::AcpThreadView::new(
- workspace.clone(),
- project,
- message_history,
- window,
- cx,
- )
- })?;
+ let ext_agent = match agent_choice {
+ Some(agent) => {
+ cx.background_spawn({
+ let agent = agent.clone();
+ async move {
+ if let Some(serialized) =
+ serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
+ {
+ KEY_VALUE_STORE
+ .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
+ .await
+ .log_err();
+ }
+ }
+ })
+ .detach();
+
+ agent
+ }
+ None => {
+ cx.background_spawn(async move {
+ KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY)
+ })
+ .await
+ .log_err()
+ .flatten()
+ .and_then(|value| {
+ serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
+ })
+ .unwrap_or_default()
+ .agent
+ }
+ };
+
+ let server = ext_agent.server(fs, history);
+
this.update_in(cx, |this, window, cx| {
- this.set_active_view(
- ActiveView::AcpThread {
- thread_view: thread_view.clone(),
- },
- window,
- cx,
- );
- })
- .log_err();
+ match ext_agent {
+ crate::ExternalAgent::Gemini
+ | crate::ExternalAgent::NativeAgent
+ | crate::ExternalAgent::Custom { .. } => {
+ if !cx.has_flag::<GeminiAndNativeFeatureFlag>() {
+ return;
+ }
+ }
+ crate::ExternalAgent::ClaudeCode => {
+ if !cx.has_flag::<ClaudeCodeFeatureFlag>() {
+ return;
+ }
+ }
+ }
- anyhow::Ok(())
+ let thread_view = cx.new(|cx| {
+ crate::acp::AcpThreadView::new(
+ server,
+ resume_thread,
+ summarize_thread,
+ workspace.clone(),
+ project,
+ this.acp_history_store.clone(),
+ this.prompt_store.clone(),
+ window,
+ cx,
+ )
+ });
+
+ this.set_active_view(ActiveView::ExternalAgentThread { thread_view }, window, cx);
+ })
})
- .detach();
+ .detach_and_log_err(cx);
}
fn deploy_rules_library(
@@ -997,8 +1231,9 @@ impl AgentPanel {
});
self.set_active_view(
ActiveView::prompt_editor(
- editor.clone(),
+ editor,
self.history_store.clone(),
+ self.acp_history_store.clone(),
self.language_registry.clone(),
window,
cx,
@@ -1057,11 +1292,11 @@ impl AgentPanel {
MessageEditor::new(
self.fs.clone(),
self.workspace.clone(),
- self.user_store.clone(),
context_store,
self.prompt_store.clone(),
self.thread_store.downgrade(),
self.context_store.downgrade(),
+ Some(self.history_store.downgrade()),
thread.clone(),
window,
cx,
@@ -1069,7 +1304,7 @@ impl AgentPanel {
});
message_editor.focus_handle(cx).focus(window);
- let thread_view = ActiveView::thread(active_thread.clone(), message_editor, window, cx);
+ let thread_view = ActiveView::thread(active_thread, message_editor, window, cx);
self.set_active_view(thread_view, window, cx);
AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx);
}
@@ -1084,7 +1319,7 @@ impl AgentPanel {
ActiveView::Thread { message_editor, .. } => {
message_editor.focus_handle(cx).focus(window);
}
- ActiveView::AcpThread { thread_view } => {
+ ActiveView::ExternalAgentThread { thread_view } => {
thread_view.focus_handle(cx).focus(window);
}
ActiveView::TextThread { context_editor, .. } => {
@@ -1117,6 +1352,15 @@ impl AgentPanel {
self.agent_panel_menu_handle.toggle(window, cx);
}
+ pub fn toggle_new_thread_menu(
+ &mut self,
+ _: &ToggleNewThreadMenu,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.new_thread_menu_handle.toggle(window, cx);
+ }
+
pub fn increase_font_size(
&mut self,
action: &IncreaseBufferFontSize,
@@ -1147,13 +1391,11 @@ impl AgentPanel {
ThemeSettings::get_global(cx).agent_font_size(cx) + delta;
let _ = settings
.agent_font_size
- .insert(theme::clamp_font_size(agent_font_size).0);
+ .insert(Some(theme::clamp_font_size(agent_font_size).into()));
},
);
} else {
- theme::adjust_agent_font_size(cx, |size| {
- *size += delta;
- });
+ theme::adjust_agent_font_size(cx, |size| size + delta);
}
}
WhichFontSize::BufferFont => {
@@ -1211,7 +1453,7 @@ impl AgentPanel {
})
.log_err();
}
- ActiveView::AcpThread { .. }
+ ActiveView::ExternalAgentThread { .. }
| ActiveView::TextThread { .. }
| ActiveView::History
| ActiveView::Configuration => {}
@@ -1267,7 +1509,7 @@ impl AgentPanel {
)
.detach_and_log_err(cx);
}
- ActiveView::AcpThread { thread_view } => {
+ ActiveView::ExternalAgentThread { thread_view } => {
thread_view
.update(cx, |thread_view, cx| {
thread_view.open_thread_as_markdown(workspace, window, cx)
@@ -1289,18 +1531,30 @@ impl AgentPanel {
AssistantConfigurationEvent::NewThread(provider) => {
if LanguageModelRegistry::read_global(cx)
.default_model()
- .map_or(true, |model| model.provider.id() != provider.id())
+ .is_none_or(|model| model.provider.id() != provider.id())
+ && let Some(model) = provider.default_model(cx)
{
- if let Some(model) = provider.default_model(cx) {
- update_settings_file::<AgentSettings>(
- self.fs.clone(),
- cx,
- move |settings, _| settings.set_model(model),
- );
- }
+ update_settings_file::<AgentSettings>(
+ self.fs.clone(),
+ cx,
+ move |settings, _| settings.set_model(model),
+ );
}
self.new_thread(&NewThread::default(), window, cx);
+ if let Some((thread, model)) =
+ self.active_thread(cx).zip(provider.default_model(cx))
+ {
+ thread.update(cx, |thread, cx| {
+ thread.set_configured_model(
+ Some(ConfiguredModel {
+ provider: provider.clone(),
+ model,
+ }),
+ cx,
+ );
+ });
+ }
}
}
}
@@ -1311,6 +1565,14 @@ impl AgentPanel {
_ => None,
}
}
+ pub(crate) fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
+ match &self.active_view {
+ ActiveView::ExternalAgentThread { thread_view, .. } => {
+ thread_view.read(cx).thread().cloned()
+ }
+ _ => None,
+ }
+ }
pub(crate) fn delete_thread(
&mut self,
@@ -1331,7 +1593,7 @@ impl AgentPanel {
return;
}
- let model = thread_state.configured_model().map(|cm| cm.model.clone());
+ let model = thread_state.configured_model().map(|cm| cm.model);
if let Some(model) = model {
thread.update(cx, |active_thread, cx| {
active_thread.thread().update(cx, |thread, cx| {
@@ -1403,17 +1665,14 @@ impl AgentPanel {
let current_is_special = current_is_history || current_is_config;
let new_is_special = new_is_history || new_is_config;
- match &self.active_view {
- ActiveView::Thread { thread, .. } => {
- let thread = thread.read(cx);
- if thread.is_empty() {
- let id = thread.thread().read(cx).id().clone();
- self.history_store.update(cx, |store, cx| {
- store.remove_recently_opened_thread(id, cx);
- });
- }
+ if let ActiveView::Thread { thread, .. } = &self.active_view {
+ let thread = thread.read(cx);
+ if thread.is_empty() {
+ let id = thread.thread().read(cx).id().clone();
+ self.history_store.update(cx, |store, cx| {
+ store.remove_recently_opened_thread(id, cx);
+ });
}
- _ => {}
}
match &new_view {
@@ -1426,9 +1685,17 @@ impl AgentPanel {
if let Some(path) = context_editor.read(cx).context().read(cx).path() {
store.push_recently_opened_entry(HistoryEntryId::Context(path.clone()), cx)
}
+ });
+ self.acp_history_store.update(cx, |store, cx| {
+ if let Some(path) = context_editor.read(cx).context().read(cx).path() {
+ store.push_recently_opened_entry(
+ agent2::HistoryEntryId::TextThread(path.clone()),
+ cx,
+ )
+ }
})
}
- ActiveView::AcpThread { .. } => {}
+ ActiveView::ExternalAgentThread { .. } => {}
ActiveView::History | ActiveView::Configuration => {}
}
@@ -1443,12 +1710,10 @@ impl AgentPanel {
self.active_view = new_view;
}
- self.acp_message_history.borrow_mut().reset_position();
-
self.focus_handle(cx).focus(window);
}
- fn populate_recently_opened_menu_section(
+ fn populate_recently_opened_menu_section_old(
mut menu: ContextMenu,
panel: Entity<Self>,
cx: &mut Context<ContextMenu>,
@@ -1483,7 +1748,7 @@ impl AgentPanel {
.open_thread_by_id(&id, window, cx)
.detach_and_log_err(cx),
HistoryEntryId::Context(path) => this
- .open_saved_prompt_editor(path.clone(), window, cx)
+ .open_saved_prompt_editor(path, window, cx)
.detach_and_log_err(cx),
})
.ok();
@@ -1511,52 +1776,203 @@ impl AgentPanel {
menu
}
-}
-
-impl Focusable for AgentPanel {
- fn focus_handle(&self, cx: &App) -> FocusHandle {
- match &self.active_view {
- ActiveView::Thread { message_editor, .. } => message_editor.focus_handle(cx),
- ActiveView::AcpThread { thread_view, .. } => thread_view.focus_handle(cx),
- ActiveView::History => self.history.focus_handle(cx),
- ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx),
- ActiveView::Configuration => {
- if let Some(configuration) = self.configuration.as_ref() {
- configuration.focus_handle(cx)
- } else {
- cx.focus_handle()
- }
- }
- }
- }
-}
-
-fn agent_panel_dock_position(cx: &App) -> DockPosition {
- match AgentSettings::get_global(cx).dock {
- AgentDockPosition::Left => DockPosition::Left,
- AgentDockPosition::Bottom => DockPosition::Bottom,
- AgentDockPosition::Right => DockPosition::Right,
- }
-}
-impl EventEmitter<PanelEvent> for AgentPanel {}
+ fn populate_recently_opened_menu_section_new(
+ mut menu: ContextMenu,
+ panel: Entity<Self>,
+ cx: &mut Context<ContextMenu>,
+ ) -> ContextMenu {
+ let entries = panel
+ .read(cx)
+ .acp_history_store
+ .read(cx)
+ .recently_opened_entries(cx);
-impl Panel for AgentPanel {
- fn persistent_name() -> &'static str {
- "AgentPanel"
- }
+ if entries.is_empty() {
+ return menu;
+ }
- fn position(&self, _window: &Window, cx: &App) -> DockPosition {
- agent_panel_dock_position(cx)
- }
+ menu = menu.header("Recently Opened");
- fn position_is_valid(&self, position: DockPosition) -> bool {
- position != DockPosition::Bottom
- }
+ for entry in entries {
+ let title = entry.title().clone();
- fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
- settings::update_settings_file::<AgentSettings>(self.fs.clone(), cx, move |settings, _| {
- let dock = match position {
+ menu = menu.entry_with_end_slot_on_hover(
+ title,
+ None,
+ {
+ let panel = panel.downgrade();
+ let entry = entry.clone();
+ move |window, cx| {
+ let entry = entry.clone();
+ panel
+ .update(cx, move |this, cx| match &entry {
+ agent2::HistoryEntry::AcpThread(entry) => this.external_thread(
+ Some(ExternalAgent::NativeAgent),
+ Some(entry.clone()),
+ None,
+ window,
+ cx,
+ ),
+ agent2::HistoryEntry::TextThread(entry) => this
+ .open_saved_prompt_editor(entry.path.clone(), window, cx)
+ .detach_and_log_err(cx),
+ })
+ .ok();
+ }
+ },
+ IconName::Close,
+ "Close Entry".into(),
+ {
+ let panel = panel.downgrade();
+ let id = entry.id();
+ move |_window, cx| {
+ panel
+ .update(cx, |this, cx| {
+ this.acp_history_store.update(cx, |history_store, cx| {
+ history_store.remove_recently_opened_entry(&id, cx);
+ });
+ })
+ .ok();
+ }
+ },
+ );
+ }
+
+ menu = menu.separator();
+
+ menu
+ }
+
+ pub fn set_selected_agent(
+ &mut self,
+ agent: AgentType,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if self.selected_agent != agent {
+ self.selected_agent = agent.clone();
+ self.serialize(cx);
+ }
+ self.new_agent_thread(agent, window, cx);
+ }
+
+ pub fn selected_agent(&self) -> AgentType {
+ self.selected_agent.clone()
+ }
+
+ pub fn new_agent_thread(
+ &mut self,
+ agent: AgentType,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ match agent {
+ AgentType::Zed => {
+ window.dispatch_action(
+ NewThread {
+ from_thread_id: None,
+ }
+ .boxed_clone(),
+ cx,
+ );
+ }
+ AgentType::TextThread => {
+ window.dispatch_action(NewTextThread.boxed_clone(), cx);
+ }
+ AgentType::NativeAgent => self.external_thread(
+ Some(crate::ExternalAgent::NativeAgent),
+ None,
+ None,
+ window,
+ cx,
+ ),
+ AgentType::Gemini => {
+ self.external_thread(Some(crate::ExternalAgent::Gemini), None, None, window, cx)
+ }
+ AgentType::ClaudeCode => self.external_thread(
+ Some(crate::ExternalAgent::ClaudeCode),
+ None,
+ None,
+ window,
+ cx,
+ ),
+ AgentType::Custom { name, settings } => self.external_thread(
+ Some(crate::ExternalAgent::Custom { name, settings }),
+ None,
+ None,
+ window,
+ cx,
+ ),
+ }
+ }
+
+ pub fn load_agent_thread(
+ &mut self,
+ thread: DbThreadMetadata,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.external_thread(
+ Some(ExternalAgent::NativeAgent),
+ Some(thread),
+ None,
+ window,
+ cx,
+ );
+ }
+}
+
+impl Focusable for AgentPanel {
+ fn focus_handle(&self, cx: &App) -> FocusHandle {
+ match &self.active_view {
+ ActiveView::Thread { message_editor, .. } => message_editor.focus_handle(cx),
+ ActiveView::ExternalAgentThread { thread_view, .. } => thread_view.focus_handle(cx),
+ ActiveView::History => {
+ if cx.has_flag::<feature_flags::GeminiAndNativeFeatureFlag>() {
+ self.acp_history.focus_handle(cx)
+ } else {
+ self.history.focus_handle(cx)
+ }
+ }
+ ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx),
+ ActiveView::Configuration => {
+ if let Some(configuration) = self.configuration.as_ref() {
+ configuration.focus_handle(cx)
+ } else {
+ cx.focus_handle()
+ }
+ }
+ }
+ }
+}
+
+fn agent_panel_dock_position(cx: &App) -> DockPosition {
+ match AgentSettings::get_global(cx).dock {
+ AgentDockPosition::Left => DockPosition::Left,
+ AgentDockPosition::Bottom => DockPosition::Bottom,
+ AgentDockPosition::Right => DockPosition::Right,
+ }
+}
+
+impl EventEmitter<PanelEvent> for AgentPanel {}
+
+impl Panel for AgentPanel {
+ fn persistent_name() -> &'static str {
+ "AgentPanel"
+ }
+
+ fn position(&self, _window: &Window, cx: &App) -> DockPosition {
+ agent_panel_dock_position(cx)
+ }
+
+ fn position_is_valid(&self, position: DockPosition) -> bool {
+ position != DockPosition::Bottom
+ }
+
+ fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
+ settings::update_settings_file::<AgentSettings>(self.fs.clone(), cx, move |settings, _| {
+ let dock = match position {
DockPosition::Left => AgentDockPosition::Left,
DockPosition::Bottom => AgentDockPosition::Bottom,
DockPosition::Right => AgentDockPosition::Right,
@@ -5,7 +5,6 @@ mod agent_diff;
mod agent_model_selector;
mod agent_panel;
mod buffer_codegen;
-mod burn_mode_tooltip;
mod context_picker;
mod context_server_configuration;
mod context_strip;
@@ -25,23 +24,28 @@ mod thread_history;
mod tool_compatibility;
mod ui;
+use std::rc::Rc;
use std::sync::Arc;
use agent::{Thread, ThreadId};
+use agent_servers::AgentServerSettings;
use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection};
use assistant_slash_command::SlashCommandRegistry;
use client::Client;
+use command_palette_hooks::CommandPaletteFilter;
use feature_flags::FeatureFlagAppExt as _;
use fs::Fs;
-use gpui::{Action, App, Entity, actions};
+use gpui::{Action, App, Entity, SharedString, actions};
use language::LanguageRegistry;
use language_model::{
ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry,
};
+use project::DisableAiSettings;
use prompt_store::PromptBuilder;
use schemars::JsonSchema;
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
use settings::{Settings as _, SettingsStore};
+use std::any::TypeId;
pub use crate::active_thread::ActiveThread;
use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
@@ -51,16 +55,17 @@ use crate::slash_command_settings::SlashCommandSettings;
pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor};
pub use ui::preview::{all_agent_previews, get_agent_preview};
+use zed_actions;
actions!(
agent,
[
/// Creates a new text-based conversation thread.
NewTextThread,
- /// Creates a new external agent conversation thread.
- NewAcpThread,
/// Toggles the context picker interface for adding files, symbols, or other context.
ToggleContextPicker,
+ /// Toggles the menu to create new agent threads.
+ ToggleNewThreadMenu,
/// Toggles the navigation menu for switching between threads and views.
ToggleNavigationMenu,
/// Toggles the options menu for agent settings and preferences.
@@ -124,6 +129,12 @@ actions!(
]
);
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Action)]
+#[action(namespace = agent)]
+#[action(deprecated_aliases = ["assistant::QuoteSelection"])]
+/// Quotes the current selection in the agent panel's message editor.
+pub struct QuoteSelection;
+
/// Creates a new conversation thread, optionally based on an existing thread.
#[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
@@ -133,6 +144,53 @@ pub struct NewThread {
from_thread_id: Option<ThreadId>,
}
+/// Creates a new external agent conversation thread.
+#[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)]
+#[action(namespace = agent)]
+#[serde(deny_unknown_fields)]
+pub struct NewExternalAgentThread {
+ /// Which agent to use for the conversation.
+ agent: Option<ExternalAgent>,
+}
+
+#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
+#[action(namespace = agent)]
+#[serde(deny_unknown_fields)]
+pub struct NewNativeAgentThreadFromSummary {
+ from_session_id: agent_client_protocol::SessionId,
+}
+
+#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+enum ExternalAgent {
+ #[default]
+ Gemini,
+ ClaudeCode,
+ NativeAgent,
+ Custom {
+ name: SharedString,
+ settings: AgentServerSettings,
+ },
+}
+
+impl ExternalAgent {
+ pub fn server(
+ &self,
+ fs: Arc<dyn fs::Fs>,
+ history: Entity<agent2::HistoryStore>,
+ ) -> Rc<dyn agent_servers::AgentServer> {
+ match self {
+ Self::Gemini => Rc::new(agent_servers::Gemini),
+ Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
+ Self::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)),
+ Self::Custom { name, settings } => Rc::new(agent_servers::CustomAgentServer::new(
+ name.clone(),
+ settings,
+ )),
+ }
+ }
+}
+
/// Opens the profile management interface for configuring agent tools and settings.
#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
@@ -204,18 +262,75 @@ pub fn init(
client.telemetry().clone(),
cx,
);
- terminal_inline_assistant::init(
- fs.clone(),
- prompt_builder.clone(),
- client.telemetry().clone(),
- cx,
- );
- indexed_docs::init(cx);
+ terminal_inline_assistant::init(fs.clone(), prompt_builder, client.telemetry().clone(), cx);
cx.observe_new(move |workspace, window, cx| {
ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx)
})
.detach();
cx.observe_new(ManageProfilesModal::register).detach();
+
+ // Update command palette filter based on AI settings
+ update_command_palette_filter(cx);
+
+ // Watch for settings changes
+ cx.observe_global::<SettingsStore>(|app_cx| {
+ // When settings change, update the command palette filter
+ update_command_palette_filter(app_cx);
+ })
+ .detach();
+}
+
+fn update_command_palette_filter(cx: &mut App) {
+ let disable_ai = DisableAiSettings::get_global(cx).disable_ai;
+ CommandPaletteFilter::update_global(cx, |filter, _| {
+ if disable_ai {
+ filter.hide_namespace("agent");
+ filter.hide_namespace("assistant");
+ filter.hide_namespace("copilot");
+ filter.hide_namespace("supermaven");
+ filter.hide_namespace("zed_predict_onboarding");
+ filter.hide_namespace("edit_prediction");
+
+ use editor::actions::{
+ AcceptEditPrediction, AcceptPartialEditPrediction, NextEditPrediction,
+ PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction,
+ };
+ let edit_prediction_actions = [
+ TypeId::of::<AcceptEditPrediction>(),
+ TypeId::of::<AcceptPartialEditPrediction>(),
+ TypeId::of::<ShowEditPrediction>(),
+ TypeId::of::<NextEditPrediction>(),
+ TypeId::of::<PreviousEditPrediction>(),
+ TypeId::of::<ToggleEditPrediction>(),
+ ];
+ filter.hide_action_types(&edit_prediction_actions);
+ filter.hide_action_types(&[TypeId::of::<zed_actions::OpenZedPredictOnboarding>()]);
+ } else {
+ filter.show_namespace("agent");
+ filter.show_namespace("assistant");
+ filter.show_namespace("copilot");
+ filter.show_namespace("zed_predict_onboarding");
+
+ filter.show_namespace("edit_prediction");
+
+ use editor::actions::{
+ AcceptEditPrediction, AcceptPartialEditPrediction, NextEditPrediction,
+ PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction,
+ };
+ let edit_prediction_actions = [
+ TypeId::of::<AcceptEditPrediction>(),
+ TypeId::of::<AcceptPartialEditPrediction>(),
+ TypeId::of::<ShowEditPrediction>(),
+ TypeId::of::<NextEditPrediction>(),
+ TypeId::of::<PreviousEditPrediction>(),
+ TypeId::of::<ToggleEditPrediction>(),
+ ];
+ filter.show_action_types(edit_prediction_actions.iter());
+
+ filter
+ .show_action_types([TypeId::of::<zed_actions::OpenZedPredictOnboarding>()].iter());
+ }
+ });
}
fn init_language_model_settings(cx: &mut App) {
@@ -226,7 +341,7 @@ fn init_language_model_settings(cx: &mut App) {
cx.subscribe(
&LanguageModelRegistry::global(cx),
|_, event: &language_model::Event, cx| match event {
- language_model::Event::ProviderStateChanged
+ language_model::Event::ProviderStateChanged(_)
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_) => {
update_active_language_model_from_settings(cx);
@@ -293,7 +408,6 @@ fn register_slash_commands(cx: &mut App) {
slash_command_registry.register_command(assistant_slash_commands::FetchSlashCommand, true);
cx.observe_flag::<assistant_slash_commands::StreamingExampleSlashCommandFeatureFlag, _>({
- let slash_command_registry = slash_command_registry.clone();
move |is_enabled, _cx| {
if is_enabled {
slash_command_registry.register_command(
@@ -314,12 +428,6 @@ fn update_slash_commands_from_settings(cx: &mut App) {
let slash_command_registry = SlashCommandRegistry::global(cx);
let settings = SlashCommandSettings::get_global(cx);
- if settings.docs.enabled {
- slash_command_registry.register_command(assistant_slash_commands::DocsSlashCommand, true);
- } else {
- slash_command_registry.unregister_command(assistant_slash_commands::DocsSlashCommand);
- }
-
if settings.cargo_workspace.enabled {
slash_command_registry
.register_command(assistant_slash_commands::CargoWorkspaceSlashCommand, true);
@@ -6,6 +6,7 @@ use agent::{
use agent_settings::AgentSettings;
use anyhow::{Context as _, Result};
use client::telemetry::Telemetry;
+use cloud_llm_client::CompletionIntent;
use collections::HashSet;
use editor::{Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset as _, ToPoint};
use futures::{
@@ -35,7 +36,6 @@ use std::{
};
use streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff};
use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase};
-use zed_llm_client::CompletionIntent;
pub struct BufferCodegen {
alternatives: Vec<Entity<CodegenAlternative>>,
@@ -352,12 +352,12 @@ impl CodegenAlternative {
event: &multi_buffer::Event,
cx: &mut Context<Self>,
) {
- if let multi_buffer::Event::TransactionUndone { transaction_id } = event {
- if self.transformation_transaction_id == Some(*transaction_id) {
- self.transformation_transaction_id = None;
- self.generation = Task::ready(());
- cx.emit(CodegenEvent::Undone);
- }
+ if let multi_buffer::Event::TransactionUndone { transaction_id } = event
+ && self.transformation_transaction_id == Some(*transaction_id)
+ {
+ self.transformation_transaction_id = None;
+ self.generation = Task::ready(());
+ cx.emit(CodegenEvent::Undone);
}
}
@@ -388,7 +388,7 @@ impl CodegenAlternative {
} else {
let request = self.build_request(&model, user_prompt, cx)?;
cx.spawn(async move |_, cx| {
- Ok(model.stream_completion_text(request.await, &cx).await?)
+ Ok(model.stream_completion_text(request.await, cx).await?)
})
.boxed_local()
};
@@ -447,7 +447,7 @@ impl CodegenAlternative {
}
});
- let temperature = AgentSettings::temperature_for_model(&model, cx);
+ let temperature = AgentSettings::temperature_for_model(model, cx);
Ok(cx.spawn(async move |_cx| {
let mut request_message = LanguageModelRequestMessage {
@@ -576,38 +576,34 @@ impl CodegenAlternative {
let mut lines = chunk.split('\n').peekable();
while let Some(line) = lines.next() {
new_text.push_str(line);
- if line_indent.is_none() {
- if let Some(non_whitespace_ch_ix) =
+ if line_indent.is_none()
+ && let Some(non_whitespace_ch_ix) =
new_text.find(|ch: char| !ch.is_whitespace())
- {
- line_indent = Some(non_whitespace_ch_ix);
- base_indent = base_indent.or(line_indent);
-
- let line_indent = line_indent.unwrap();
- let base_indent = base_indent.unwrap();
- let indent_delta =
- line_indent as i32 - base_indent as i32;
- let mut corrected_indent_len = cmp::max(
- 0,
- suggested_line_indent.len as i32 + indent_delta,
- )
- as usize;
- if first_line {
- corrected_indent_len = corrected_indent_len
- .saturating_sub(
- selection_start.column as usize,
- );
- }
-
- let indent_char = suggested_line_indent.char();
- let mut indent_buffer = [0; 4];
- let indent_str =
- indent_char.encode_utf8(&mut indent_buffer);
- new_text.replace_range(
- ..line_indent,
- &indent_str.repeat(corrected_indent_len),
- );
+ {
+ line_indent = Some(non_whitespace_ch_ix);
+ base_indent = base_indent.or(line_indent);
+
+ let line_indent = line_indent.unwrap();
+ let base_indent = base_indent.unwrap();
+ let indent_delta = line_indent as i32 - base_indent as i32;
+ let mut corrected_indent_len = cmp::max(
+ 0,
+ suggested_line_indent.len as i32 + indent_delta,
+ )
+ as usize;
+ if first_line {
+ corrected_indent_len = corrected_indent_len
+ .saturating_sub(selection_start.column as usize);
}
+
+ let indent_char = suggested_line_indent.char();
+ let mut indent_buffer = [0; 4];
+ let indent_str =
+ indent_char.encode_utf8(&mut indent_buffer);
+ new_text.replace_range(
+ ..line_indent,
+ &indent_str.repeat(corrected_indent_len),
+ );
}
if line_indent.is_some() {
@@ -1028,7 +1024,7 @@ where
chunk.push('\n');
}
- chunk.push_str(&line);
+ chunk.push_str(line);
}
consumed += line.len();
@@ -1133,7 +1129,7 @@ mod tests {
)
});
- let chunks_tx = simulate_response_stream(codegen.clone(), cx);
+ let chunks_tx = simulate_response_stream(&codegen, cx);
let mut new_text = concat!(
" let mut x = 0;\n",
@@ -1200,7 +1196,7 @@ mod tests {
)
});
- let chunks_tx = simulate_response_stream(codegen.clone(), cx);
+ let chunks_tx = simulate_response_stream(&codegen, cx);
cx.background_executor.run_until_parked();
@@ -1269,7 +1265,7 @@ mod tests {
)
});
- let chunks_tx = simulate_response_stream(codegen.clone(), cx);
+ let chunks_tx = simulate_response_stream(&codegen, cx);
cx.background_executor.run_until_parked();
@@ -1338,7 +1334,7 @@ mod tests {
)
});
- let chunks_tx = simulate_response_stream(codegen.clone(), cx);
+ let chunks_tx = simulate_response_stream(&codegen, cx);
let new_text = concat!(
"func main() {\n",
"\tx := 0\n",
@@ -1395,7 +1391,7 @@ mod tests {
)
});
- let chunks_tx = simulate_response_stream(codegen.clone(), cx);
+ let chunks_tx = simulate_response_stream(&codegen, cx);
chunks_tx
.unbounded_send("let mut x = 0;\nx += 1;".to_string())
.unwrap();
@@ -1477,7 +1473,7 @@ mod tests {
}
fn simulate_response_stream(
- codegen: Entity<CodegenAlternative>,
+ codegen: &Entity<CodegenAlternative>,
cx: &mut TestAppContext,
) -> mpsc::UnboundedSender<String> {
let (chunks_tx, chunks_rx) = mpsc::unbounded();
@@ -1,61 +0,0 @@
-use gpui::{Context, FontWeight, IntoElement, Render, Window};
-use ui::{prelude::*, tooltip_container};
-
-pub struct BurnModeTooltip {
- selected: bool,
-}
-
-impl BurnModeTooltip {
- pub fn new() -> Self {
- Self { selected: false }
- }
-
- pub fn selected(mut self, selected: bool) -> Self {
- self.selected = selected;
- self
- }
-}
-
-impl Render for BurnModeTooltip {
- fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let (icon, color) = if self.selected {
- (IconName::ZedBurnModeOn, Color::Error)
- } else {
- (IconName::ZedBurnMode, Color::Default)
- };
-
- let turned_on = h_flex()
- .h_4()
- .px_1()
- .border_1()
- .border_color(cx.theme().colors().border)
- .bg(cx.theme().colors().text_accent.opacity(0.1))
- .rounded_sm()
- .child(
- Label::new("ON")
- .size(LabelSize::XSmall)
- .weight(FontWeight::SEMIBOLD)
- .color(Color::Accent),
- );
-
- let title = h_flex()
- .gap_1p5()
- .child(Icon::new(icon).size(IconSize::Small).color(color))
- .child(Label::new("Burn Mode"))
- .when(self.selected, |title| title.child(turned_on));
-
- tooltip_container(window, cx, |this, _, _| {
- this
- .child(title)
- .child(
- div()
- .max_w_64()
- .child(
- Label::new("Enables models to use large context windows, unlimited tool calls, and other capabilities for expanded reasoning.")
- .size(LabelSize::Small)
- .color(Color::Muted)
- )
- )
- })
- }
-}
@@ -1,18 +1,19 @@
mod completion_provider;
-mod fetch_context_picker;
+pub(crate) mod fetch_context_picker;
pub(crate) mod file_context_picker;
-mod rules_context_picker;
-mod symbol_context_picker;
-mod thread_context_picker;
+pub(crate) mod rules_context_picker;
+pub(crate) mod symbol_context_picker;
+pub(crate) mod thread_context_picker;
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Result, anyhow};
+use collections::HashSet;
pub use completion_provider::ContextPickerCompletionProvider;
use editor::display_map::{Crease, CreaseId, CreaseMetadata, FoldId};
-use editor::{Anchor, AnchorRangeExt as _, Editor, ExcerptId, FoldPlaceholder, ToOffset};
+use editor::{Anchor, Editor, ExcerptId, FoldPlaceholder, ToOffset};
use fetch_context_picker::FetchContextPicker;
use file_context_picker::FileContextPicker;
use file_context_picker::render_file_context_entry;
@@ -45,7 +46,7 @@ use agent::{
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum ContextPickerEntry {
+pub(crate) enum ContextPickerEntry {
Mode(ContextPickerMode),
Action(ContextPickerAction),
}
@@ -74,7 +75,7 @@ impl ContextPickerEntry {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum ContextPickerMode {
+pub(crate) enum ContextPickerMode {
File,
Symbol,
Fetch,
@@ -83,7 +84,7 @@ enum ContextPickerMode {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum ContextPickerAction {
+pub(crate) enum ContextPickerAction {
AddSelections,
}
@@ -102,7 +103,7 @@ impl ContextPickerAction {
pub fn icon(&self) -> IconName {
match self {
- Self::AddSelections => IconName::Context,
+ Self::AddSelections => IconName::Reader,
}
}
}
@@ -147,8 +148,8 @@ impl ContextPickerMode {
match self {
Self::File => IconName::File,
Self::Symbol => IconName::Code,
- Self::Fetch => IconName::Globe,
- Self::Thread => IconName::MessageBubbles,
+ Self::Fetch => IconName::ToolWeb,
+ Self::Thread => IconName::Thread,
Self::Rules => RULES_ICON,
}
}
@@ -227,7 +228,7 @@ impl ContextPicker {
}
fn build_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Entity<ContextMenu> {
- let context_picker = cx.entity().clone();
+ let context_picker = cx.entity();
let menu = ContextMenu::build(window, cx, move |menu, _window, cx| {
let recent = self.recent_entries(cx);
@@ -384,12 +385,11 @@ impl ContextPicker {
}
pub fn select_first(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- match &self.mode {
- ContextPickerState::Default(entity) => entity.update(cx, |entity, cx| {
+ // Other variants already select their first entry on open automatically
+ if let ContextPickerState::Default(entity) = &self.mode {
+ entity.update(cx, |entity, cx| {
entity.select_first(&Default::default(), window, cx)
- }),
- // Other variants already select their first entry on open automatically
- _ => {}
+ })
}
}
@@ -531,7 +531,7 @@ impl ContextPicker {
return vec![];
};
- recent_context_picker_entries(
+ recent_context_picker_entries_with_store(
context_store,
self.thread_store.clone(),
self.text_thread_store.clone(),
@@ -585,7 +585,8 @@ impl Render for ContextPicker {
})
}
}
-enum RecentEntry {
+
+pub(crate) enum RecentEntry {
File {
project_path: ProjectPath,
path_prefix: Arc<str>,
@@ -593,7 +594,7 @@ enum RecentEntry {
Thread(ThreadContextEntry),
}
-fn available_context_picker_entries(
+pub(crate) fn available_context_picker_entries(
prompt_store: &Option<Entity<PromptStore>>,
thread_store: &Option<WeakEntity<ThreadStore>>,
workspace: &Entity<Workspace>,
@@ -608,9 +609,7 @@ fn available_context_picker_entries(
.read(cx)
.active_item(cx)
.and_then(|item| item.downcast::<Editor>())
- .map_or(false, |editor| {
- editor.update(cx, |editor, cx| editor.has_non_empty_selection(cx))
- });
+ .is_some_and(|editor| editor.update(cx, |editor, cx| editor.has_non_empty_selection(cx)));
if has_selection {
entries.push(ContextPickerEntry::Action(
ContextPickerAction::AddSelections,
@@ -630,24 +629,56 @@ fn available_context_picker_entries(
entries
}
-fn recent_context_picker_entries(
+fn recent_context_picker_entries_with_store(
context_store: Entity<ContextStore>,
thread_store: Option<WeakEntity<ThreadStore>>,
text_thread_store: Option<WeakEntity<TextThreadStore>>,
workspace: Entity<Workspace>,
exclude_path: Option<ProjectPath>,
cx: &App,
+) -> Vec<RecentEntry> {
+ let project = workspace.read(cx).project();
+
+ let mut exclude_paths = context_store.read(cx).file_paths(cx);
+ exclude_paths.extend(exclude_path);
+
+ let exclude_paths = exclude_paths
+ .into_iter()
+ .filter_map(|project_path| project.read(cx).absolute_path(&project_path, cx))
+ .collect();
+
+ let exclude_threads = context_store.read(cx).thread_ids();
+
+ recent_context_picker_entries(
+ thread_store,
+ text_thread_store,
+ workspace,
+ &exclude_paths,
+ exclude_threads,
+ cx,
+ )
+}
+
+pub(crate) fn recent_context_picker_entries(
+ thread_store: Option<WeakEntity<ThreadStore>>,
+ text_thread_store: Option<WeakEntity<TextThreadStore>>,
+ workspace: Entity<Workspace>,
+ exclude_paths: &HashSet<PathBuf>,
+ exclude_threads: &HashSet<ThreadId>,
+ cx: &App,
) -> Vec<RecentEntry> {
let mut recent = Vec::with_capacity(6);
- let mut current_files = context_store.read(cx).file_paths(cx);
- current_files.extend(exclude_path);
let workspace = workspace.read(cx);
let project = workspace.project().read(cx);
recent.extend(
workspace
.recent_navigation_history_iter(cx)
- .filter(|(path, _)| !current_files.contains(path))
+ .filter(|(_, abs_path)| {
+ abs_path
+ .as_ref()
+ .is_none_or(|path| !exclude_paths.contains(path.as_path()))
+ })
.take(4)
.filter_map(|(project_path, _)| {
project
@@ -659,8 +690,6 @@ fn recent_context_picker_entries(
}),
);
- let current_threads = context_store.read(cx).thread_ids();
-
let active_thread_id = workspace
.panel::<AgentPanel>(cx)
.and_then(|panel| Some(panel.read(cx).active_thread(cx)?.read(cx).id()));
@@ -672,7 +701,7 @@ fn recent_context_picker_entries(
let mut threads = unordered_thread_entries(thread_store, text_thread_store, cx)
.filter(|(_, thread)| match thread {
ThreadContextEntry::Thread { id, .. } => {
- Some(id) != active_thread_id && !current_threads.contains(id)
+ Some(id) != active_thread_id && !exclude_threads.contains(id)
}
ThreadContextEntry::Context { .. } => true,
})
@@ -710,7 +739,7 @@ fn add_selections_as_context(
})
}
-fn selection_ranges(
+pub(crate) fn selection_ranges(
workspace: &Entity<Workspace>,
cx: &mut App,
) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {
@@ -789,13 +818,8 @@ pub fn crease_for_mention(
let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
- Crease::inline(
- range,
- placeholder.clone(),
- fold_toggle("mention"),
- render_trailer,
- )
- .with_metadata(CreaseMetadata { icon_path, label })
+ Crease::inline(range, placeholder, fold_toggle("mention"), render_trailer)
+ .with_metadata(CreaseMetadata { icon_path, label })
}
fn render_fold_icon_button(
@@ -805,42 +829,9 @@ fn render_fold_icon_button(
) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
Arc::new({
move |fold_id, fold_range, cx| {
- let is_in_text_selection = editor.upgrade().is_some_and(|editor| {
- editor.update(cx, |editor, cx| {
- let snapshot = editor
- .buffer()
- .update(cx, |multi_buffer, cx| multi_buffer.snapshot(cx));
-
- let is_in_pending_selection = || {
- editor
- .selections
- .pending
- .as_ref()
- .is_some_and(|pending_selection| {
- pending_selection
- .selection
- .range()
- .includes(&fold_range, &snapshot)
- })
- };
-
- let mut is_in_complete_selection = || {
- editor
- .selections
- .disjoint_in_range::<usize>(fold_range.clone(), cx)
- .into_iter()
- .any(|selection| {
- // This is needed to cover a corner case, if we just check for an existing
- // selection in the fold range, having a cursor at the start of the fold
- // marks it as selected. Non-empty selections don't cause this.
- let length = selection.end - selection.start;
- length > 0
- })
- };
-
- is_in_pending_selection() || is_in_complete_selection()
- })
- });
+ let is_in_text_selection = editor
+ .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
+ .unwrap_or_default();
ButtonLike::new(fold_id)
.style(ButtonStyle::Filled)
@@ -35,7 +35,7 @@ use super::symbol_context_picker::search_symbols;
use super::thread_context_picker::{ThreadContextEntry, ThreadMatch, search_threads};
use super::{
ContextPickerAction, ContextPickerEntry, ContextPickerMode, MentionLink, RecentEntry,
- available_context_picker_entries, recent_context_picker_entries, selection_ranges,
+ available_context_picker_entries, recent_context_picker_entries_with_store, selection_ranges,
};
use crate::message_editor::ContextCreasesAddon;
@@ -79,8 +79,7 @@ fn search(
) -> Task<Vec<Match>> {
match mode {
Some(ContextPickerMode::File) => {
- let search_files_task =
- search_files(query.clone(), cancellation_flag.clone(), &workspace, cx);
+ let search_files_task = search_files(query, cancellation_flag, &workspace, cx);
cx.background_spawn(async move {
search_files_task
.await
@@ -91,8 +90,7 @@ fn search(
}
Some(ContextPickerMode::Symbol) => {
- let search_symbols_task =
- search_symbols(query.clone(), cancellation_flag.clone(), &workspace, cx);
+ let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx);
cx.background_spawn(async move {
search_symbols_task
.await
@@ -108,13 +106,8 @@ fn search(
.and_then(|t| t.upgrade())
.zip(text_thread_context_store.as_ref().and_then(|t| t.upgrade()))
{
- let search_threads_task = search_threads(
- query.clone(),
- cancellation_flag.clone(),
- thread_store,
- context_store,
- cx,
- );
+ let search_threads_task =
+ search_threads(query, cancellation_flag, thread_store, context_store, cx);
cx.background_spawn(async move {
search_threads_task
.await
@@ -137,8 +130,7 @@ fn search(
Some(ContextPickerMode::Rules) => {
if let Some(prompt_store) = prompt_store.as_ref() {
- let search_rules_task =
- search_rules(query.clone(), cancellation_flag.clone(), prompt_store, cx);
+ let search_rules_task = search_rules(query, cancellation_flag, prompt_store, cx);
cx.background_spawn(async move {
search_rules_task
.await
@@ -196,7 +188,7 @@ fn search(
let executor = cx.background_executor().clone();
let search_files_task =
- search_files(query.clone(), cancellation_flag.clone(), &workspace, cx);
+ search_files(query.clone(), cancellation_flag, &workspace, cx);
let entries =
available_context_picker_entries(&prompt_store, &thread_store, &workspace, cx);
@@ -283,7 +275,7 @@ impl ContextPickerCompletionProvider {
) -> Option<Completion> {
match entry {
ContextPickerEntry::Mode(mode) => Some(Completion {
- replace_range: source_range.clone(),
+ replace_range: source_range,
new_text: format!("@{} ", mode.keyword()),
label: CodeLabel::plain(mode.label().to_string(), None),
icon_path: Some(mode.icon().path().into()),
@@ -330,9 +322,6 @@ impl ContextPickerCompletionProvider {
);
let callback = Arc::new({
- let context_store = context_store.clone();
- let selections = selections.clone();
- let selection_infos = selection_infos.clone();
move |_, window: &mut Window, cx: &mut App| {
context_store.update(cx, |context_store, cx| {
for (buffer, range) in &selections {
@@ -371,7 +360,7 @@ impl ContextPickerCompletionProvider {
line_range.end.row + 1
)
.into(),
- IconName::Context.path().into(),
+ IconName::Reader.path().into(),
range,
editor.downgrade(),
);
@@ -423,7 +412,7 @@ impl ContextPickerCompletionProvider {
let icon_for_completion = if recent {
IconName::HistoryRerun
} else {
- IconName::MessageBubbles
+ IconName::Thread
};
let new_text = format!("{} ", MentionLink::for_thread(&thread_entry));
let new_text_len = new_text.len();
@@ -436,12 +425,12 @@ impl ContextPickerCompletionProvider {
source: project::CompletionSource::Custom,
icon_path: Some(icon_for_completion.path().into()),
confirm: Some(confirm_completion_callback(
- IconName::MessageBubbles.path().into(),
+ IconName::Thread.path().into(),
thread_entry.title().clone(),
excerpt_id,
source_range.start,
new_text_len - 1,
- editor.clone(),
+ editor,
context_store.clone(),
move |window, cx| match &thread_entry {
ThreadContextEntry::Thread { id, .. } => {
@@ -510,7 +499,7 @@ impl ContextPickerCompletionProvider {
excerpt_id,
source_range.start,
new_text_len - 1,
- editor.clone(),
+ editor,
context_store.clone(),
move |_, cx| {
let user_prompt_id = rules.prompt_id;
@@ -539,15 +528,15 @@ impl ContextPickerCompletionProvider {
label: CodeLabel::plain(url_to_fetch.to_string(), None),
documentation: None,
source: project::CompletionSource::Custom,
- icon_path: Some(IconName::Globe.path().into()),
+ icon_path: Some(IconName::ToolWeb.path().into()),
insert_text_mode: None,
confirm: Some(confirm_completion_callback(
- IconName::Globe.path().into(),
+ IconName::ToolWeb.path().into(),
url_to_fetch.clone(),
excerpt_id,
source_range.start,
new_text_len - 1,
- editor.clone(),
+ editor,
context_store.clone(),
move |_, cx| {
let context_store = context_store.clone();
@@ -704,16 +693,16 @@ impl ContextPickerCompletionProvider {
excerpt_id,
source_range.start,
new_text_len - 1,
- editor.clone(),
+ editor,
context_store.clone(),
move |_, cx| {
let symbol = symbol.clone();
let context_store = context_store.clone();
let workspace = workspace.clone();
let result = super::symbol_context_picker::add_symbol(
- symbol.clone(),
+ symbol,
false,
- workspace.clone(),
+ workspace,
context_store.downgrade(),
cx,
);
@@ -728,11 +717,11 @@ fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx:
let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
let mut label = CodeLabel::default();
- label.push_str(&file_name, None);
+ label.push_str(file_name, None);
label.push_str(" ", None);
if let Some(directory) = directory {
- label.push_str(&directory, comment_id);
+ label.push_str(directory, comment_id);
}
label.filter_range = 0..label.text().len();
@@ -787,7 +776,7 @@ impl CompletionProvider for ContextPickerCompletionProvider {
.and_then(|b| b.read(cx).file())
.map(|file| ProjectPath::from_file(file.as_ref(), cx));
- let recent_entries = recent_context_picker_entries(
+ let recent_entries = recent_context_picker_entries_with_store(
context_store.clone(),
thread_store.clone(),
text_thread_store.clone(),
@@ -1020,7 +1009,7 @@ impl MentionCompletion {
&& line
.chars()
.nth(last_mention_start - 1)
- .map_or(false, |c| !c.is_whitespace())
+ .is_some_and(|c| !c.is_whitespace())
{
return None;
}
@@ -1162,7 +1151,7 @@ mod tests {
impl Focusable for AtMentionEditor {
fn focus_handle(&self, cx: &App) -> FocusHandle {
- self.0.read(cx).focus_handle(cx).clone()
+ self.0.read(cx).focus_handle(cx)
}
}
@@ -1480,7 +1469,7 @@ mod tests {
let completions = editor.current_completions().expect("Missing completions");
completions
.into_iter()
- .map(|completion| completion.label.text.to_string())
+ .map(|completion| completion.label.text)
.collect::<Vec<_>>()
}
@@ -226,9 +226,10 @@ impl PickerDelegate for FetchContextPickerDelegate {
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
- let added = self.context_store.upgrade().map_or(false, |context_store| {
- context_store.read(cx).includes_url(&self.url)
- });
+ let added = self
+ .context_store
+ .upgrade()
+ .is_some_and(|context_store| context_store.read(cx).includes_url(&self.url));
Some(
ListItem::new(ix)
@@ -239,9 +239,7 @@ pub(crate) fn search_files(
PathMatchCandidateSet {
snapshot: worktree.snapshot(),
- include_ignored: worktree
- .root_entry()
- .map_or(false, |entry| entry.is_ignored),
+ include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored),
include_root_name: true,
candidates: project::Candidates::Entries,
}
@@ -315,7 +313,7 @@ pub fn render_file_context_entry(
context_store: WeakEntity<ContextStore>,
cx: &App,
) -> Stateful<Div> {
- let (file_name, directory) = extract_file_name_and_directory(&path, path_prefix);
+ let (file_name, directory) = extract_file_name_and_directory(path, path_prefix);
let added = context_store.upgrade().and_then(|context_store| {
let project_path = ProjectPath {
@@ -334,7 +332,7 @@ pub fn render_file_context_entry(
let file_icon = if is_directory {
FileIcons::get_folder_icon(false, cx)
} else {
- FileIcons::get_icon(&path, cx)
+ FileIcons::get_icon(path, cx)
}
.map(Icon::from_path)
.unwrap_or_else(|| Icon::new(IconName::File));
@@ -159,7 +159,7 @@ pub fn render_thread_context_entry(
context_store: WeakEntity<ContextStore>,
cx: &mut App,
) -> Div {
- let added = context_store.upgrade().map_or(false, |context_store| {
+ let added = context_store.upgrade().is_some_and(|context_store| {
context_store
.read(cx)
.includes_user_rules(user_rules.prompt_id)
@@ -289,12 +289,12 @@ pub(crate) fn search_symbols(
.iter()
.enumerate()
.map(|(id, symbol)| {
- StringMatchCandidate::new(id, &symbol.label.filter_text())
+ StringMatchCandidate::new(id, symbol.label.filter_text())
})
.partition(|candidate| {
project
.entry_for_path(&symbols[candidate.id].path, cx)
- .map_or(false, |e| !e.is_ignored)
+ .is_some_and(|e| !e.is_ignored)
})
})
.log_err()
@@ -167,7 +167,7 @@ impl PickerDelegate for ThreadContextPickerDelegate {
return;
};
let open_thread_task =
- thread_store.update(cx, |this, cx| this.open_thread(&id, window, cx));
+ thread_store.update(cx, |this, cx| this.open_thread(id, window, cx));
cx.spawn(async move |this, cx| {
let thread = open_thread_task.await?;
@@ -236,12 +236,10 @@ pub fn render_thread_context_entry(
let is_added = match entry {
ThreadContextEntry::Thread { id, .. } => context_store
.upgrade()
- .map_or(false, |ctx_store| ctx_store.read(cx).includes_thread(&id)),
- ThreadContextEntry::Context { path, .. } => {
- context_store.upgrade().map_or(false, |ctx_store| {
- ctx_store.read(cx).includes_text_thread(path)
- })
- }
+ .is_some_and(|ctx_store| ctx_store.read(cx).includes_thread(id)),
+ ThreadContextEntry::Context { path, .. } => context_store
+ .upgrade()
+ .is_some_and(|ctx_store| ctx_store.read(cx).includes_text_thread(path)),
};
h_flex()
@@ -253,7 +251,7 @@ pub fn render_thread_context_entry(
.gap_1p5()
.max_w_72()
.child(
- Icon::new(IconName::MessageBubbles)
+ Icon::new(IconName::Thread)
.size(IconSize::XSmall)
.color(Color::Muted),
)
@@ -338,7 +336,7 @@ pub(crate) fn search_threads(
let candidates = threads
.iter()
.enumerate()
- .map(|(id, (_, thread))| StringMatchCandidate::new(id, &thread.title()))
+ .map(|(id, (_, thread))| StringMatchCandidate::new(id, thread.title()))
.collect::<Vec<_>>();
let matches = fuzzy::match_strings(
&candidates,
@@ -145,7 +145,7 @@ impl ContextStrip {
}
let file_name = active_buffer.file()?.file_name(cx);
- let icon_path = FileIcons::get_icon(&Path::new(&file_name), cx);
+ let icon_path = FileIcons::get_icon(Path::new(&file_name), cx);
Some(SuggestedContext::File {
name: file_name.to_string_lossy().into_owned().into(),
buffer: active_buffer_entity.downgrade(),
@@ -368,16 +368,16 @@ impl ContextStrip {
_window: &mut Window,
cx: &mut Context<Self>,
) {
- if let Some(suggested) = self.suggested_context(cx) {
- if self.is_suggested_focused(&self.added_contexts(cx)) {
- self.add_suggested_context(&suggested, cx);
- }
+ if let Some(suggested) = self.suggested_context(cx)
+ && self.is_suggested_focused(&self.added_contexts(cx))
+ {
+ self.add_suggested_context(&suggested, cx);
}
}
fn add_suggested_context(&mut self, suggested: &SuggestedContext, cx: &mut Context<Self>) {
self.context_store.update(cx, |context_store, cx| {
- context_store.add_suggested_context(&suggested, cx)
+ context_store.add_suggested_context(suggested, cx)
});
cx.notify();
}
@@ -504,7 +504,7 @@ impl Render for ContextStrip {
)
.on_click({
Rc::new(cx.listener(move |this, event: &ClickEvent, window, cx| {
- if event.down.click_count > 1 {
+ if event.click_count() > 1 {
this.open_context(&context, window, cx);
} else {
this.focused_index = Some(i);
@@ -1,10 +1,10 @@
#![allow(unused, dead_code)]
use client::{ModelRequestUsage, RequestUsage};
+use cloud_llm_client::{Plan, UsageLimit};
use gpui::Global;
use std::ops::{Deref, DerefMut};
use ui::prelude::*;
-use zed_llm_client::{Plan, UsageLimit};
/// Debug only: Used for testing various account states
///
@@ -39,7 +39,7 @@ use language_model::{
};
use multi_buffer::MultiBufferRow;
use parking_lot::Mutex;
-use project::{CodeAction, LspAction, Project, ProjectTransaction};
+use project::{CodeAction, DisableAiSettings, LspAction, Project, ProjectTransaction};
use prompt_store::{PromptBuilder, PromptStore};
use settings::{Settings, SettingsStore};
use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase};
@@ -48,7 +48,7 @@ use text::{OffsetRangeExt, ToPoint as _};
use ui::prelude::*;
use util::{RangeExt, ResultExt, maybe};
use workspace::{ItemHandle, Toast, Workspace, dock::Panel, notifications::NotificationId};
-use zed_actions::agent::OpenConfiguration;
+use zed_actions::agent::OpenSettings;
pub fn init(
fs: Arc<dyn Fs>,
@@ -57,11 +57,22 @@ pub fn init(
cx: &mut App,
) {
cx.set_global(InlineAssistant::new(fs, prompt_builder, telemetry));
+
+ cx.observe_global::<SettingsStore>(|cx| {
+ if DisableAiSettings::get_global(cx).disable_ai {
+ // Hide any active inline assist UI when AI is disabled
+ InlineAssistant::update_global(cx, |assistant, cx| {
+ assistant.cancel_all_active_completions(cx);
+ });
+ }
+ })
+ .detach();
+
cx.observe_new(|_workspace: &mut Workspace, window, cx| {
let Some(window) = window else {
return;
};
- let workspace = cx.entity().clone();
+ let workspace = cx.entity();
InlineAssistant::update_global(cx, |inline_assistant, cx| {
inline_assistant.register_workspace(&workspace, window, cx)
});
@@ -141,6 +152,26 @@ impl InlineAssistant {
.detach();
}
+ /// Hides all active inline assists when AI is disabled
+ pub fn cancel_all_active_completions(&mut self, cx: &mut App) {
+ // Cancel all active completions in editors
+ for (editor_handle, _) in self.assists_by_editor.iter() {
+ if let Some(editor) = editor_handle.upgrade() {
+ let windows = cx.windows();
+ if !windows.is_empty() {
+ let window = windows[0];
+ let _ = window.update(cx, |_, window, cx| {
+ editor.update(cx, |editor, cx| {
+ if editor.has_active_edit_prediction() {
+ editor.cancel(&Default::default(), window, cx);
+ }
+ });
+ });
+ }
+ }
+ }
+ }
+
fn handle_workspace_event(
&mut self,
workspace: Entity<Workspace>,
@@ -151,13 +182,13 @@ impl InlineAssistant {
match event {
workspace::Event::UserSavedItem { item, .. } => {
// When the user manually saves an editor, automatically accepts all finished transformations.
- if let Some(editor) = item.upgrade().and_then(|item| item.act_as::<Editor>(cx)) {
- if let Some(editor_assists) = self.assists_by_editor.get(&editor.downgrade()) {
- for assist_id in editor_assists.assist_ids.clone() {
- let assist = &self.assists[&assist_id];
- if let CodegenStatus::Done = assist.codegen.read(cx).status(cx) {
- self.finish_assist(assist_id, false, window, cx)
- }
+ if let Some(editor) = item.upgrade().and_then(|item| item.act_as::<Editor>(cx))
+ && let Some(editor_assists) = self.assists_by_editor.get(&editor.downgrade())
+ {
+ for assist_id in editor_assists.assist_ids.clone() {
+ let assist = &self.assists[&assist_id];
+ if let CodegenStatus::Done = assist.codegen.read(cx).status(cx) {
+ self.finish_assist(assist_id, false, window, cx)
}
}
}
@@ -176,7 +207,7 @@ impl InlineAssistant {
window: &mut Window,
cx: &mut App,
) {
- let is_assistant2_enabled = true;
+ let is_assistant2_enabled = !DisableAiSettings::get_global(cx).disable_ai;
if let Some(editor) = item.act_as::<Editor>(cx) {
editor.update(cx, |editor, cx| {
@@ -199,6 +230,13 @@ impl InlineAssistant {
cx,
);
+ if DisableAiSettings::get_global(cx).disable_ai {
+ // Cancel any active edit predictions
+ if editor.has_active_edit_prediction() {
+ editor.cancel(&Default::default(), window, cx);
+ }
+ }
+
// Remove the Assistant1 code action provider, as it still might be registered.
editor.remove_code_action_provider("assistant".into(), window, cx);
} else {
@@ -219,7 +257,7 @@ impl InlineAssistant {
cx: &mut Context<Workspace>,
) {
let settings = AgentSettings::get_global(cx);
- if !settings.enabled {
+ if !settings.enabled || DisableAiSettings::get_global(cx).disable_ai {
return;
}
@@ -304,13 +342,11 @@ impl InlineAssistant {
)
.await
.ok();
- if let Some(answer) = answer {
- if answer == 0 {
- cx.update(|window, cx| {
- window.dispatch_action(Box::new(OpenConfiguration), cx)
- })
+ if let Some(answer) = answer
+ && answer == 0
+ {
+ cx.update(|window, cx| window.dispatch_action(Box::new(OpenSettings), cx))
.ok();
- }
}
anyhow::Ok(())
})
@@ -397,11 +433,11 @@ impl InlineAssistant {
}
}
- if let Some(prev_selection) = selections.last_mut() {
- if selection.start <= prev_selection.end {
- prev_selection.end = selection.end;
- continue;
- }
+ if let Some(prev_selection) = selections.last_mut()
+ && selection.start <= prev_selection.end
+ {
+ prev_selection.end = selection.end;
+ continue;
}
let latest_selection = newest_selection.get_or_insert_with(|| selection.clone());
@@ -488,9 +524,9 @@ impl InlineAssistant {
if assist_to_focus.is_none() {
let focus_assist = if newest_selection.reversed {
- range.start.to_point(&snapshot) == newest_selection.start
+ range.start.to_point(snapshot) == newest_selection.start
} else {
- range.end.to_point(&snapshot) == newest_selection.end
+ range.end.to_point(snapshot) == newest_selection.end
};
if focus_assist {
assist_to_focus = Some(assist_id);
@@ -512,7 +548,7 @@ impl InlineAssistant {
let editor_assists = self
.assists_by_editor
.entry(editor.downgrade())
- .or_insert_with(|| EditorInlineAssists::new(&editor, window, cx));
+ .or_insert_with(|| EditorInlineAssists::new(editor, window, cx));
let mut assist_group = InlineAssistGroup::new();
for (assist_id, range, prompt_editor, prompt_block_id, end_block_id) in assists {
let codegen = prompt_editor.read(cx).codegen().clone();
@@ -611,7 +647,7 @@ impl InlineAssistant {
let editor_assists = self
.assists_by_editor
.entry(editor.downgrade())
- .or_insert_with(|| EditorInlineAssists::new(&editor, window, cx));
+ .or_insert_with(|| EditorInlineAssists::new(editor, window, cx));
let mut assist_group = InlineAssistGroup::new();
self.assists.insert(
@@ -947,14 +983,13 @@ impl InlineAssistant {
EditorEvent::SelectionsChanged { .. } => {
for assist_id in editor_assists.assist_ids.clone() {
let assist = &self.assists[&assist_id];
- if let Some(decorations) = assist.decorations.as_ref() {
- if decorations
+ if let Some(decorations) = assist.decorations.as_ref()
+ && decorations
.prompt_editor
.focus_handle(cx)
.is_focused(window)
- {
- return;
- }
+ {
+ return;
}
}
@@ -1085,7 +1120,7 @@ impl InlineAssistant {
if editor_assists
.scroll_lock
.as_ref()
- .map_or(false, |lock| lock.assist_id == assist_id)
+ .is_some_and(|lock| lock.assist_id == assist_id)
{
editor_assists.scroll_lock = None;
}
@@ -1465,20 +1500,18 @@ impl InlineAssistant {
window: &mut Window,
cx: &mut App,
) -> Option<InlineAssistTarget> {
- if let Some(terminal_panel) = workspace.panel::<TerminalPanel>(cx) {
- if terminal_panel
+ if let Some(terminal_panel) = workspace.panel::<TerminalPanel>(cx)
+ && terminal_panel
.read(cx)
.focus_handle(cx)
.contains_focused(window, cx)
- {
- if let Some(terminal_view) = terminal_panel.read(cx).pane().and_then(|pane| {
- pane.read(cx)
- .active_item()
- .and_then(|t| t.downcast::<TerminalView>())
- }) {
- return Some(InlineAssistTarget::Terminal(terminal_view));
- }
- }
+ && let Some(terminal_view) = terminal_panel.read(cx).pane().and_then(|pane| {
+ pane.read(cx)
+ .active_item()
+ .and_then(|t| t.downcast::<TerminalView>())
+ })
+ {
+ return Some(InlineAssistTarget::Terminal(terminal_view));
}
let context_editor = agent_panel
@@ -1499,13 +1532,11 @@ impl InlineAssistant {
.and_then(|item| item.act_as::<Editor>(cx))
{
Some(InlineAssistTarget::Editor(workspace_editor))
- } else if let Some(terminal_view) = workspace
- .active_item(cx)
- .and_then(|item| item.act_as::<TerminalView>(cx))
- {
- Some(InlineAssistTarget::Terminal(terminal_view))
} else {
- None
+ workspace
+ .active_item(cx)
+ .and_then(|item| item.act_as::<TerminalView>(cx))
+ .map(InlineAssistTarget::Terminal)
}
}
}
@@ -1660,7 +1691,7 @@ impl InlineAssist {
}),
range,
codegen: codegen.clone(),
- workspace: workspace.clone(),
+ workspace,
_subscriptions: vec![
window.on_focus_in(&prompt_editor_focus_handle, cx, move |_, cx| {
InlineAssistant::update_global(cx, |this, cx| {
@@ -1703,22 +1734,20 @@ impl InlineAssist {
return;
};
- if let CodegenStatus::Error(error) = codegen.read(cx).status(cx) {
- if assist.decorations.is_none() {
- if let Some(workspace) = assist.workspace.upgrade() {
- let error = format!("Inline assistant error: {}", error);
- workspace.update(cx, |workspace, cx| {
- struct InlineAssistantError;
-
- let id =
- NotificationId::composite::<InlineAssistantError>(
- assist_id.0,
- );
-
- workspace.show_toast(Toast::new(id, error), cx);
- })
- }
- }
+ if let CodegenStatus::Error(error) = codegen.read(cx).status(cx)
+ && assist.decorations.is_none()
+ && let Some(workspace) = assist.workspace.upgrade()
+ {
+ let error = format!("Inline assistant error: {}", error);
+ workspace.update(cx, |workspace, cx| {
+ struct InlineAssistantError;
+
+ let id = NotificationId::composite::<InlineAssistantError>(
+ assist_id.0,
+ );
+
+ workspace.show_toast(Toast::new(id, error), cx);
+ })
}
if assist.decorations.is_none() {
@@ -1783,18 +1812,18 @@ impl CodeActionProvider for AssistantCodeActionProvider {
has_diagnostics = true;
}
if has_diagnostics {
- if let Some(symbols_containing_start) = snapshot.symbols_containing(range.start, None) {
- if let Some(symbol) = symbols_containing_start.last() {
- range.start = cmp::min(range.start, symbol.range.start.to_point(&snapshot));
- range.end = cmp::max(range.end, symbol.range.end.to_point(&snapshot));
- }
+ if let Some(symbols_containing_start) = snapshot.symbols_containing(range.start, None)
+ && let Some(symbol) = symbols_containing_start.last()
+ {
+ range.start = cmp::min(range.start, symbol.range.start.to_point(&snapshot));
+ range.end = cmp::max(range.end, symbol.range.end.to_point(&snapshot));
}
- if let Some(symbols_containing_end) = snapshot.symbols_containing(range.end, None) {
- if let Some(symbol) = symbols_containing_end.last() {
- range.start = cmp::min(range.start, symbol.range.start.to_point(&snapshot));
- range.end = cmp::max(range.end, symbol.range.end.to_point(&snapshot));
- }
+ if let Some(symbols_containing_end) = snapshot.symbols_containing(range.end, None)
+ && let Some(symbol) = symbols_containing_end.last()
+ {
+ range.start = cmp::min(range.start, symbol.range.start.to_point(&snapshot));
+ range.end = cmp::max(range.end, symbol.range.end.to_point(&snapshot));
}
Task::ready(Ok(vec![CodeAction {
@@ -2,7 +2,6 @@ use crate::agent_model_selector::AgentModelSelector;
use crate::buffer_codegen::BufferCodegen;
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider};
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
-use crate::language_model_selector::ToggleModelSelector;
use crate::message_editor::{ContextCreasesAddon, extract_message_creases, insert_message_creases};
use crate::terminal_codegen::TerminalCodegen;
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
@@ -38,6 +37,7 @@ use ui::{
CheckboxWithLabel, IconButtonShape, KeyBinding, Popover, PopoverMenuHandle, Tooltip, prelude::*,
};
use workspace::Workspace;
+use zed_actions::agent::ToggleModelSelector;
pub struct PromptEditor<T> {
pub editor: Entity<Editor>,
@@ -75,7 +75,7 @@ impl<T: 'static> Render for PromptEditor<T> {
let codegen = codegen.read(cx);
if codegen.alternative_count(cx) > 1 {
- buttons.push(self.render_cycle_controls(&codegen, cx));
+ buttons.push(self.render_cycle_controls(codegen, cx));
}
let editor_margins = editor_margins.lock();
@@ -345,7 +345,7 @@ impl<T: 'static> PromptEditor<T> {
let prompt = self.editor.read(cx).text(cx);
if self
.prompt_history_ix
- .map_or(true, |ix| self.prompt_history[ix] != prompt)
+ .is_none_or(|ix| self.prompt_history[ix] != prompt)
{
self.prompt_history_ix.take();
self.pending_prompt = prompt;
@@ -541,7 +541,7 @@ impl<T: 'static> PromptEditor<T> {
match &self.mode {
PromptEditorMode::Terminal { .. } => vec![
accept,
- IconButton::new("confirm", IconName::Play)
+ IconButton::new("confirm", IconName::PlayFilled)
.icon_color(Color::Info)
.shape(IconButtonShape::Square)
.tooltip(|window, cx| {
@@ -1229,27 +1229,27 @@ pub enum GenerationMode {
impl GenerationMode {
fn start_label(self) -> &'static str {
match self {
- GenerationMode::Generate { .. } => "Generate",
+ GenerationMode::Generate => "Generate",
GenerationMode::Transform => "Transform",
}
}
fn tooltip_interrupt(self) -> &'static str {
match self {
- GenerationMode::Generate { .. } => "Interrupt Generation",
+ GenerationMode::Generate => "Interrupt Generation",
GenerationMode::Transform => "Interrupt Transform",
}
}
fn tooltip_restart(self) -> &'static str {
match self {
- GenerationMode::Generate { .. } => "Restart Generation",
+ GenerationMode::Generate => "Restart Generation",
GenerationMode::Transform => "Restart Transform",
}
}
fn tooltip_accept(self) -> &'static str {
match self {
- GenerationMode::Generate { .. } => "Accept Generation",
+ GenerationMode::Generate => "Accept Generation",
GenerationMode::Transform => "Accept Transform",
}
}
@@ -1,29 +1,17 @@
use std::{cmp::Reverse, sync::Arc};
+use cloud_llm_client::Plan;
use collections::{HashSet, IndexMap};
use feature_flags::ZedProFeatureFlag;
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
-use gpui::{
- Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task, actions,
-};
+use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task};
use language_model::{
- AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId,
- LanguageModelRegistry,
+ ConfiguredModel, LanguageModel, LanguageModelProviderId, LanguageModelRegistry,
};
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
-use proto::Plan;
use ui::{ListItem, ListItemSpacing, prelude::*};
-actions!(
- agent,
- [
- /// Toggles the language model selector dropdown.
- #[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])]
- ToggleModelSelector
- ]
-);
-
const TRY_ZED_PRO_URL: &str = "https://zed.dev/pro";
type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
@@ -88,7 +76,6 @@ pub struct LanguageModelPickerDelegate {
all_models: Arc<GroupedModels>,
filtered_entries: Vec<LanguageModelPickerEntry>,
selected_index: usize,
- _authenticate_all_providers_task: Task<()>,
_subscriptions: Vec<Subscription>,
}
@@ -104,18 +91,17 @@ impl LanguageModelPickerDelegate {
let entries = models.entries();
Self {
- on_model_changed: on_model_changed.clone(),
+ on_model_changed,
all_models: Arc::new(models),
selected_index: Self::get_active_model_index(&entries, get_active_model(cx)),
filtered_entries: entries,
get_active_model: Arc::new(get_active_model),
- _authenticate_all_providers_task: Self::authenticate_all_providers(cx),
_subscriptions: vec![cx.subscribe_in(
&LanguageModelRegistry::global(cx),
window,
|picker, _, event, window, cx| {
match event {
- language_model::Event::ProviderStateChanged
+ language_model::Event::ProviderStateChanged(_)
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_) => {
let query = picker.query(cx);
@@ -153,56 +139,6 @@ impl LanguageModelPickerDelegate {
.unwrap_or(0)
}
- /// Authenticates all providers in the [`LanguageModelRegistry`].
- ///
- /// We do this so that we can populate the language selector with all of the
- /// models from the configured providers.
- fn authenticate_all_providers(cx: &mut App) -> Task<()> {
- let authenticate_all_providers = LanguageModelRegistry::global(cx)
- .read(cx)
- .providers()
- .iter()
- .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
- .collect::<Vec<_>>();
-
- cx.spawn(async move |_cx| {
- for (provider_id, provider_name, authenticate_task) in authenticate_all_providers {
- if let Err(err) = authenticate_task.await {
- if matches!(err, AuthenticateError::CredentialsNotFound) {
- // Since we're authenticating these providers in the
- // background for the purposes of populating the
- // language selector, we don't care about providers
- // where the credentials are not found.
- } else {
- // Some providers have noisy failure states that we
- // don't want to spam the logs with every time the
- // language model selector is initialized.
- //
- // Ideally these should have more clear failure modes
- // that we know are safe to ignore here, like what we do
- // with `CredentialsNotFound` above.
- match provider_id.0.as_ref() {
- "lmstudio" | "ollama" => {
- // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated".
- //
- // These fail noisily, so we don't log them.
- }
- "copilot_chat" => {
- // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors.
- }
- _ => {
- log::error!(
- "Failed to authenticate provider: {}: {err}",
- provider_name.0
- );
- }
- }
- }
- }
- }
- })
- }
-
pub fn active_model(&self, cx: &App) -> Option<ConfiguredModel> {
(self.get_active_model)(cx)
}
@@ -307,7 +243,7 @@ impl ModelMatcher {
pub fn fuzzy_search(&self, query: &str) -> Vec<ModelInfo> {
let mut matches = self.bg_executor.block(match_strings(
&self.candidates,
- &query,
+ query,
false,
true,
100,
@@ -525,7 +461,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
.pl_0p5()
.gap_1p5()
.w(px(240.))
- .child(Label::new(model_info.model.name().0.clone()).truncate()),
+ .child(Label::new(model_info.model.name().0).truncate()),
)
.end_slot(div().pr_3().when(is_selected, |this| {
this.child(
@@ -547,7 +483,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
) -> Option<gpui::AnyElement> {
use feature_flags::FeatureFlagAppExt;
- let plan = proto::Plan::ZedPro;
+ let plan = Plan::ZedPro;
Some(
h_flex()
@@ -568,7 +504,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
window
.dispatch_action(Box::new(zed_actions::OpenAccountSettings), cx)
}),
- Plan::Free | Plan::ZedProTrial => Button::new(
+ Plan::ZedFree | Plan::ZedProTrial => Button::new(
"try-pro",
if plan == Plan::ZedProTrial {
"Upgrade to Pro"
@@ -587,7 +523,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
.icon_position(IconPosition::Start)
.on_click(|_, window, cx| {
window.dispatch_action(
- zed_actions::agent::OpenConfiguration.boxed_clone(),
+ zed_actions::agent::OpenSettings.boxed_clone(),
cx,
);
}),
@@ -4,19 +4,20 @@ use std::sync::Arc;
use crate::agent_diff::AgentDiffThread;
use crate::agent_model_selector::AgentModelSelector;
-use crate::language_model_selector::ToggleModelSelector;
use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip};
use crate::ui::{
- MaxModeTooltip,
+ BurnModeTooltip,
preview::{AgentPreview, UsageCallout},
};
+use agent::history_store::HistoryStore;
use agent::{
context::{AgentContextKey, ContextLoadResult, load_context},
context_store::ContextStoreEvent,
};
-use agent_settings::{AgentSettings, CompletionMode};
+use agent_settings::{AgentProfileId, AgentSettings, CompletionMode};
+use ai_onboarding::ApiKeysWithProviders;
use buffer_diff::BufferDiff;
-use client::UserStore;
+use cloud_llm_client::CompletionIntent;
use collections::{HashMap, HashSet};
use editor::actions::{MoveUp, Paste};
use editor::display_map::CreaseId;
@@ -29,17 +30,18 @@ use fs::Fs;
use futures::future::Shared;
use futures::{FutureExt as _, future};
use gpui::{
- Animation, AnimationExt, App, Entity, EventEmitter, Focusable, Subscription, Task, TextStyle,
- WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
+ Animation, AnimationExt, App, Entity, EventEmitter, Focusable, IntoElement, KeyContext,
+ Subscription, Task, TextStyle, WeakEntity, linear_color_stop, linear_gradient, point,
+ pulsating_between,
};
use language::{Buffer, Language, Point};
use language_model::{
- ConfiguredModel, LanguageModelRequestMessage, MessageContent, ZED_CLOUD_PROVIDER_ID,
+ ConfiguredModel, LanguageModelRegistry, LanguageModelRequestMessage, MessageContent,
+ ZED_CLOUD_PROVIDER_ID,
};
use multi_buffer;
use project::Project;
use prompt_store::PromptStore;
-use proto::Plan;
use settings::Settings;
use std::time::Duration;
use theme::ThemeSettings;
@@ -49,11 +51,11 @@ use ui::{
use util::ResultExt as _;
use workspace::{CollaboratorId, Workspace};
use zed_actions::agent::Chat;
-use zed_llm_client::CompletionIntent;
+use zed_actions::agent::ToggleModelSelector;
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention};
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
-use crate::profile_selector::ProfileSelector;
+use crate::profile_selector::{ProfileProvider, ProfileSelector};
use crate::{
ActiveThread, AgentDiffPane, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll,
ModelUsageContext, NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode,
@@ -65,6 +67,9 @@ use agent::{
thread_store::{TextThreadStore, ThreadStore},
};
+pub const MIN_EDITOR_LINES: usize = 4;
+pub const MAX_EDITOR_LINES: usize = 8;
+
#[derive(RegisterComponent)]
pub struct MessageEditor {
thread: Entity<Thread>,
@@ -72,9 +77,9 @@ pub struct MessageEditor {
editor: Entity<Editor>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
- user_store: Entity<UserStore>,
context_store: Entity<ContextStore>,
prompt_store: Option<Entity<PromptStore>>,
+ history_store: Option<WeakEntity<HistoryStore>>,
context_strip: Entity<ContextStrip>,
context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
model_selector: Entity<AgentModelSelector>,
@@ -88,9 +93,6 @@ pub struct MessageEditor {
_subscriptions: Vec<Subscription>,
}
-const MIN_EDITOR_LINES: usize = 4;
-const MAX_EDITOR_LINES: usize = 8;
-
pub(crate) fn create_editor(
workspace: WeakEntity<Workspace>,
context_store: WeakEntity<ContextStore>,
@@ -115,7 +117,7 @@ pub(crate) fn create_editor(
let mut editor = Editor::new(
editor::EditorMode::AutoHeight {
min_lines,
- max_lines: max_lines,
+ max_lines,
},
buffer,
None,
@@ -132,6 +134,7 @@ pub(crate) fn create_editor(
placement: Some(ContextMenuPlacement::Above),
});
editor.register_addon(ContextCreasesAddon::new());
+ editor.register_addon(MessageEditorAddon::new());
editor
});
@@ -149,15 +152,33 @@ pub(crate) fn create_editor(
editor
}
+impl ProfileProvider for Entity<Thread> {
+ fn profiles_supported(&self, cx: &App) -> bool {
+ self.read(cx)
+ .configured_model()
+ .is_some_and(|model| model.model.supports_tools())
+ }
+
+ fn profile_id(&self, cx: &App) -> AgentProfileId {
+ self.read(cx).profile().id().clone()
+ }
+
+ fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) {
+ self.update(cx, |this, cx| {
+ this.set_profile(profile_id, cx);
+ });
+ }
+}
+
impl MessageEditor {
pub fn new(
fs: Arc<dyn Fs>,
workspace: WeakEntity<Workspace>,
- user_store: Entity<UserStore>,
context_store: Entity<ContextStore>,
prompt_store: Option<Entity<PromptStore>>,
thread_store: WeakEntity<ThreadStore>,
text_thread_store: WeakEntity<TextThreadStore>,
+ history_store: Option<WeakEntity<HistoryStore>>,
thread: Entity<Thread>,
window: &mut Window,
cx: &mut Context<Self>,
@@ -194,9 +215,10 @@ impl MessageEditor {
let subscriptions = vec![
cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event),
- cx.subscribe(&editor, |this, _, event, cx| match event {
- EditorEvent::BufferEdited => this.handle_message_changed(cx),
- _ => {}
+ cx.subscribe(&editor, |this, _, event: &EditorEvent, cx| {
+ if event == &EditorEvent::BufferEdited {
+ this.handle_message_changed(cx)
+ }
}),
cx.observe(&context_store, |this, _, cx| {
// When context changes, reload it for token counting.
@@ -218,18 +240,19 @@ impl MessageEditor {
)
});
- let profile_selector =
- cx.new(|cx| ProfileSelector::new(fs, thread.clone(), editor.focus_handle(cx), cx));
+ let profile_selector = cx.new(|cx| {
+ ProfileSelector::new(fs, Arc::new(thread.clone()), editor.focus_handle(cx), cx)
+ });
Self {
editor: editor.clone(),
project: thread.read(cx).project().clone(),
- user_store,
thread,
- incompatible_tools_state: incompatible_tools.clone(),
+ incompatible_tools_state: incompatible_tools,
workspace,
context_store,
prompt_store,
+ history_store,
context_strip,
context_picker_menu_handle,
load_context_task: None,
@@ -355,18 +378,13 @@ impl MessageEditor {
}
fn send_to_model(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- let Some(ConfiguredModel { model, provider }) = self
+ let Some(ConfiguredModel { model, .. }) = self
.thread
.update(cx, |thread, cx| thread.get_or_init_configured_model(cx))
else {
return;
};
- if provider.must_accept_terms(cx) {
- cx.notify();
- return;
- }
-
let (user_message, user_message_creases) = self.editor.update(cx, |editor, cx| {
let creases = extract_message_creases(editor, cx);
let text = editor.text(cx);
@@ -419,11 +437,11 @@ impl MessageEditor {
thread.cancel_editing(cx);
});
- let cancelled = self.thread.update(cx, |thread, cx| {
+ let canceled = self.thread.update(cx, |thread, cx| {
thread.cancel_last_completion(Some(window.window_handle()), cx)
});
- if cancelled {
+ if canceled {
self.set_editor_is_expanded(false, cx);
self.send_to_model(window, cx);
}
@@ -602,14 +620,18 @@ impl MessageEditor {
this.toggle_burn_mode(&ToggleBurnMode, window, cx);
}))
.tooltip(move |_window, cx| {
- cx.new(|_| MaxModeTooltip::new().selected(burn_mode_enabled))
+ cx.new(|_| BurnModeTooltip::new().selected(burn_mode_enabled))
.into()
})
.into_any_element(),
)
}
- fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
+ fn render_follow_toggle(
+ &self,
+ is_model_selected: bool,
+ cx: &mut Context<Self>,
+ ) -> impl IntoElement {
let following = self
.workspace
.read_with(cx, |workspace, _| {
@@ -618,6 +640,7 @@ impl MessageEditor {
.unwrap_or(false);
IconButton::new("follow-agent", IconName::Crosshair)
+ .disabled(!is_model_selected)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.toggle_state(following)
@@ -663,11 +686,7 @@ impl MessageEditor {
.as_ref()
.map(|model| {
self.incompatible_tools_state.update(cx, |state, cx| {
- state
- .incompatible_tools(&model.model, cx)
- .iter()
- .cloned()
- .collect::<Vec<_>>()
+ state.incompatible_tools(&model.model, cx).to_vec()
})
})
.unwrap_or_default();
@@ -705,11 +724,11 @@ impl MessageEditor {
cx.listener(|this, _: &RejectAll, window, cx| this.handle_reject_all(window, cx)),
)
.capture_action(cx.listener(Self::paste))
- .gap_2()
.p_2()
- .bg(editor_bg_color)
+ .gap_2()
.border_t_1()
.border_color(cx.theme().colors().border)
+ .bg(editor_bg_color)
.child(
h_flex()
.justify_between()
@@ -717,7 +736,7 @@ impl MessageEditor {
.when(focus_handle.is_focused(window), |this| {
this.child(
IconButton::new("toggle-height", expand_icon)
- .icon_size(IconSize::XSmall)
+ .icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip({
let focus_handle = focus_handle.clone();
@@ -786,7 +805,7 @@ impl MessageEditor {
.justify_between()
.child(
h_flex()
- .child(self.render_follow_toggle(cx))
+ .child(self.render_follow_toggle(is_model_selected, cx))
.children(self.render_burn_mode_toggle(cx)),
)
.child(
@@ -815,7 +834,6 @@ impl MessageEditor {
.child(self.profile_selector.clone())
.child(self.model_selector.clone())
.map({
- let focus_handle = focus_handle.clone();
move |parent| {
if is_generating {
parent
@@ -823,7 +841,7 @@ impl MessageEditor {
parent.child(
IconButton::new(
"stop-generation",
- IconName::StopFilled,
+ IconName::Stop,
)
.icon_color(Color::Error)
.style(ButtonStyle::Tinted(
@@ -902,6 +920,10 @@ impl MessageEditor {
.on_click({
let focus_handle = focus_handle.clone();
move |_event, window, cx| {
+ telemetry::event!(
+ "Agent Message Sent",
+ agent = "zed",
+ );
focus_handle.dispatch_action(
&Chat, window, cx,
);
@@ -1105,7 +1127,7 @@ impl MessageEditor {
)
.when(is_edit_changes_expanded, |parent| {
parent.child(
- v_flex().children(changed_buffers.into_iter().enumerate().flat_map(
+ v_flex().children(changed_buffers.iter().enumerate().flat_map(
|(index, (buffer, _diff))| {
let file = buffer.read(cx).file()?;
let path = file.path();
@@ -1135,7 +1157,7 @@ impl MessageEditor {
.buffer_font(cx)
});
- let file_icon = FileIcons::get_icon(&path, cx)
+ let file_icon = FileIcons::get_icon(path, cx)
.map(Icon::from_path)
.map(|icon| icon.color(Color::Muted).size(IconSize::Small))
.unwrap_or_else(|| {
@@ -1262,7 +1284,7 @@ impl MessageEditor {
self.thread
.read(cx)
.configured_model()
- .map_or(false, |model| model.provider.id() == ZED_CLOUD_PROVIDER_ID)
+ .is_some_and(|model| model.provider.id() == ZED_CLOUD_PROVIDER_ID)
}
fn render_usage_callout(&self, line_height: Pixels, cx: &mut Context<Self>) -> Option<Div> {
@@ -1270,24 +1292,12 @@ impl MessageEditor {
return None;
}
- let user_store = self.user_store.read(cx);
-
- let ubb_enable = user_store
- .usage_based_billing_enabled()
- .map_or(false, |enabled| enabled);
-
- if ubb_enable {
+ let user_store = self.project.read(cx).user_store().read(cx);
+ if user_store.is_usage_based_billing_enabled() {
return None;
}
- let plan = user_store
- .current_plan()
- .map(|plan| match plan {
- Plan::Free => zed_llm_client::Plan::ZedFree,
- Plan::ZedPro => zed_llm_client::Plan::ZedPro,
- Plan::ZedProTrial => zed_llm_client::Plan::ZedProTrial,
- })
- .unwrap_or(zed_llm_client::Plan::ZedFree);
+ let plan = user_store.plan().unwrap_or(cloud_llm_client::Plan::ZedFree);
let usage = user_store.model_request_usage()?;
@@ -1304,14 +1314,10 @@ impl MessageEditor {
token_usage_ratio: TokenUsageRatio,
cx: &mut Context<Self>,
) -> Option<Div> {
- let icon = if token_usage_ratio == TokenUsageRatio::Exceeded {
- Icon::new(IconName::X)
- .color(Color::Error)
- .size(IconSize::XSmall)
+ let (icon, severity) = if token_usage_ratio == TokenUsageRatio::Exceeded {
+ (IconName::Close, Severity::Error)
} else {
- Icon::new(IconName::Warning)
- .color(Color::Warning)
- .size(IconSize::XSmall)
+ (IconName::Warning, Severity::Warning)
};
let title = if token_usage_ratio == TokenUsageRatio::Exceeded {
@@ -1326,29 +1332,33 @@ impl MessageEditor {
"To continue, start a new thread from a summary."
};
- let mut callout = Callout::new()
+ let callout = Callout::new()
.line_height(line_height)
+ .severity(severity)
.icon(icon)
.title(title)
.description(description)
- .primary_action(
- Button::new("start-new-thread", "Start New Thread")
- .label_size(LabelSize::Small)
- .on_click(cx.listener(|this, _, window, cx| {
- let from_thread_id = Some(this.thread.read(cx).id().clone());
- window.dispatch_action(Box::new(NewThread { from_thread_id }), cx);
- })),
- );
-
- if self.is_using_zed_provider(cx) {
- callout = callout.secondary_action(
- IconButton::new("burn-mode-callout", IconName::ZedBurnMode)
- .icon_size(IconSize::XSmall)
- .on_click(cx.listener(|this, _event, window, cx| {
- this.toggle_burn_mode(&ToggleBurnMode, window, cx);
- })),
+ .actions_slot(
+ h_flex()
+ .gap_0p5()
+ .when(self.is_using_zed_provider(cx), |this| {
+ this.child(
+ IconButton::new("burn-mode-callout", IconName::ZedBurnMode)
+ .icon_size(IconSize::XSmall)
+ .on_click(cx.listener(|this, _event, window, cx| {
+ this.toggle_burn_mode(&ToggleBurnMode, window, cx);
+ })),
+ )
+ })
+ .child(
+ Button::new("start-new-thread", "Start New Thread")
+ .label_size(LabelSize::Small)
+ .on_click(cx.listener(|this, _, window, cx| {
+ let from_thread_id = Some(this.thread.read(cx).id().clone());
+ window.dispatch_action(Box::new(NewThread { from_thread_id }), cx);
+ })),
+ ),
);
- }
Some(
div()
@@ -1385,7 +1395,7 @@ impl MessageEditor {
})
.ok();
});
- // Replace existing load task, if any, causing it to be cancelled.
+ // Replace existing load task, if any, causing it to be canceled.
let load_task = load_task.shared();
self.load_context_task = Some(load_task.clone());
cx.spawn(async move |this, cx| {
@@ -1427,7 +1437,7 @@ impl MessageEditor {
let message_text = editor.read(cx).text(cx);
if message_text.is_empty()
- && loaded_context.map_or(true, |loaded_context| loaded_context.is_empty())
+ && loaded_context.is_none_or(|loaded_context| loaded_context.is_empty())
{
return None;
}
@@ -1489,6 +1499,31 @@ pub struct ContextCreasesAddon {
_subscription: Option<Subscription>,
}
+pub struct MessageEditorAddon {}
+
+impl MessageEditorAddon {
+ pub fn new() -> Self {
+ Self {}
+ }
+}
+
+impl Addon for MessageEditorAddon {
+ fn to_any(&self) -> &dyn std::any::Any {
+ self
+ }
+
+ fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
+ Some(self)
+ }
+
+ fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
+ let settings = agent_settings::AgentSettings::get_global(cx);
+ if settings.use_modifier_to_send {
+ key_context.add("use_modifier_to_send");
+ }
+ }
+}
+
impl Addon for ContextCreasesAddon {
fn to_any(&self) -> &dyn std::any::Any {
self
@@ -1515,9 +1550,8 @@ impl ContextCreasesAddon {
cx: &mut Context<Editor>,
) {
self.creases.entry(key).or_default().extend(creases);
- self._subscription = Some(cx.subscribe(
- &context_store,
- |editor, _, event, cx| match event {
+ self._subscription = Some(
+ cx.subscribe(context_store, |editor, _, event, cx| match event {
ContextStoreEvent::ContextRemoved(key) => {
let Some(this) = editor.addon_mut::<Self>() else {
return;
@@ -1537,8 +1571,8 @@ impl ContextCreasesAddon {
editor.edit(ranges.into_iter().zip(replacement_texts), cx);
cx.notify();
}
- },
- ))
+ }),
+ )
}
pub fn into_inner(self) -> HashMap<AgentContextKey, Vec<(CreaseId, SharedString)>> {
@@ -1566,7 +1600,8 @@ pub fn extract_message_creases(
.collect::<HashMap<_, _>>();
// Filter the addon's list of creases based on what the editor reports,
// since the addon might have removed creases in it.
- let creases = editor.display_map.update(cx, |display_map, cx| {
+
+ editor.display_map.update(cx, |display_map, cx| {
display_map
.snapshot(cx)
.crease_snapshot
@@ -1590,8 +1625,7 @@ pub fn extract_message_creases(
}
})
.collect()
- });
- creases
+ })
}
impl EventEmitter<MessageEditorEvent> for MessageEditor {}
@@ -1624,10 +1658,39 @@ impl Render for MessageEditor {
let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5;
+ let has_configured_providers = LanguageModelRegistry::read_global(cx)
+ .providers()
+ .iter()
+ .filter(|provider| {
+ provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
+ })
+ .count()
+ > 0;
+
+ let is_signed_out = self
+ .workspace
+ .read_with(cx, |workspace, _| {
+ workspace.client().status().borrow().is_signed_out()
+ })
+ .unwrap_or(true);
+
+ let has_history = self
+ .history_store
+ .as_ref()
+ .and_then(|hs| hs.update(cx, |hs, cx| !hs.entries(cx).is_empty()).ok())
+ .unwrap_or(false)
+ || self
+ .thread
+ .read_with(cx, |thread, _| thread.messages().len() > 0);
+
v_flex()
.size_full()
.bg(cx.theme().colors().panel_background)
- .when(changed_buffers.len() > 0, |parent| {
+ .when(
+ !has_history && is_signed_out && has_configured_providers,
+ |this| this.child(cx.new(ApiKeysWithProviders::new)),
+ )
+ .when(!changed_buffers.is_empty(), |parent| {
parent.child(self.render_edits_bar(&changed_buffers, window, cx))
})
.child(self.render_editor(window, cx))
@@ -1698,7 +1761,6 @@ impl AgentPreview for MessageEditor {
) -> Option<AnyElement> {
if let Some(workspace) = workspace.upgrade() {
let fs = workspace.read(cx).app_state().fs.clone();
- let user_store = workspace.read(cx).app_state().user_store.clone();
let project = workspace.read(cx).project().clone();
let weak_project = project.downgrade();
let context_store = cx.new(|_cx| ContextStore::new(weak_project, None));
@@ -1711,11 +1773,11 @@ impl AgentPreview for MessageEditor {
MessageEditor::new(
fs,
workspace.downgrade(),
- user_store,
context_store,
None,
thread_store.downgrade(),
text_thread_store.downgrade(),
+ None,
thread,
window,
cx,
@@ -1733,7 +1795,7 @@ impl AgentPreview for MessageEditor {
.bg(cx.theme().colors().panel_background)
.border_1()
.border_color(cx.theme().colors().border)
- .child(default_message_editor.clone())
+ .child(default_message_editor)
.into_any_element(),
)])
.into_any_element(),
@@ -1,12 +1,8 @@
use crate::{ManageProfiles, ToggleProfileSelector};
-use agent::{
- Thread,
- agent_profile::{AgentProfile, AvailableProfiles},
-};
+use agent::agent_profile::{AgentProfile, AvailableProfiles};
use agent_settings::{AgentDockPosition, AgentProfileId, AgentSettings, builtin_profiles};
use fs::Fs;
-use gpui::{Action, Empty, Entity, FocusHandle, Subscription, prelude::*};
-use language_model::LanguageModelRegistry;
+use gpui::{Action, Entity, FocusHandle, Subscription, prelude::*};
use settings::{Settings as _, SettingsStore, update_settings_file};
use std::sync::Arc;
use ui::{
@@ -14,10 +10,22 @@ use ui::{
prelude::*,
};
+/// Trait for types that can provide and manage agent profiles
+pub trait ProfileProvider {
+ /// Get the current profile ID
+ fn profile_id(&self, cx: &App) -> AgentProfileId;
+
+ /// Set the profile ID
+ fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App);
+
+ /// Check if profiles are supported in the current context (e.g. if the model that is selected has tool support)
+ fn profiles_supported(&self, cx: &App) -> bool;
+}
+
pub struct ProfileSelector {
profiles: AvailableProfiles,
fs: Arc<dyn Fs>,
- thread: Entity<Thread>,
+ provider: Arc<dyn ProfileProvider>,
menu_handle: PopoverMenuHandle<ContextMenu>,
focus_handle: FocusHandle,
_subscriptions: Vec<Subscription>,
@@ -26,7 +34,7 @@ pub struct ProfileSelector {
impl ProfileSelector {
pub fn new(
fs: Arc<dyn Fs>,
- thread: Entity<Thread>,
+ provider: Arc<dyn ProfileProvider>,
focus_handle: FocusHandle,
cx: &mut Context<Self>,
) -> Self {
@@ -37,7 +45,7 @@ impl ProfileSelector {
Self {
profiles: AgentProfile::available_profiles(cx),
fs,
- thread,
+ provider,
menu_handle: PopoverMenuHandle::default(),
focus_handle,
_subscriptions: vec![settings_subscription],
@@ -113,10 +121,10 @@ impl ProfileSelector {
builtin_profiles::MINIMAL => Some("Chat about anything with no tools."),
_ => None,
};
- let thread_profile_id = self.thread.read(cx).profile().id();
+ let thread_profile_id = self.provider.profile_id(cx);
let entry = ContextMenuEntry::new(profile_name.clone())
- .toggleable(IconPosition::End, &profile_id == thread_profile_id);
+ .toggleable(IconPosition::End, profile_id == thread_profile_id);
let entry = if let Some(doc_text) = documentation {
entry.documentation_aside(documentation_side(settings.dock), move |_| {
@@ -128,19 +136,16 @@ impl ProfileSelector {
entry.handler({
let fs = self.fs.clone();
- let thread = self.thread.clone();
- let profile_id = profile_id.clone();
+ let provider = self.provider.clone();
move |_window, cx| {
update_settings_file::<AgentSettings>(fs.clone(), cx, {
let profile_id = profile_id.clone();
move |settings, _cx| {
- settings.set_profile(profile_id.clone());
+ settings.set_profile(profile_id);
}
});
- thread.update(cx, |this, cx| {
- this.set_profile(profile_id.clone(), cx);
- });
+ provider.set_profile(profile_id.clone(), cx);
}
})
}
@@ -149,23 +154,15 @@ impl ProfileSelector {
impl Render for ProfileSelector {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let settings = AgentSettings::get_global(cx);
- let profile_id = self.thread.read(cx).profile().id();
- let profile = settings.profiles.get(profile_id);
+ let profile_id = self.provider.profile_id(cx);
+ let profile = settings.profiles.get(&profile_id);
let selected_profile = profile
.map(|profile| profile.name.clone())
.unwrap_or_else(|| "Unknown".into());
- let configured_model = self.thread.read(cx).configured_model().or_else(|| {
- let model_registry = LanguageModelRegistry::read_global(cx);
- model_registry.default_model()
- });
- let Some(configured_model) = configured_model else {
- return Empty.into_any_element();
- };
-
- if configured_model.model.supports_tools() {
- let this = cx.entity().clone();
+ if self.provider.profiles_supported(cx) {
+ let this = cx.entity();
let focus_handle = self.focus_handle.clone();
let trigger_button = Button::new("profile-selector-model", selected_profile)
.label_size(LabelSize::Small)
@@ -177,7 +174,6 @@ impl Render for ProfileSelector {
PopoverMenu::new("profile-selector")
.trigger_with_tooltip(trigger_button, {
- let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Toggle Profile Menu",
@@ -88,8 +88,6 @@ impl SlashCommandCompletionProvider {
.map(|(editor, workspace)| {
let command_name = mat.string.clone();
let command_range = command_range.clone();
- let editor = editor.clone();
- let workspace = workspace.clone();
Arc::new(
move |intent: CompletionIntent,
window: &mut Window,
@@ -158,7 +156,7 @@ impl SlashCommandCompletionProvider {
if let Some(command) = self.slash_commands.command(command_name, cx) {
let completions = command.complete_argument(
arguments,
- new_cancel_flag.clone(),
+ new_cancel_flag,
self.workspace.clone(),
window,
cx,
@@ -140,12 +140,10 @@ impl PickerDelegate for SlashCommandDelegate {
);
ret.push(index - 1);
}
- } else {
- if let SlashCommandEntry::Advert { .. } = command {
- previous_is_advert = true;
- if index != 0 {
- ret.push(index - 1);
- }
+ } else if let SlashCommandEntry::Advert { .. } = command {
+ previous_is_advert = true;
+ if index != 0 {
+ ret.push(index - 1);
}
}
}
@@ -214,7 +212,7 @@ impl PickerDelegate for SlashCommandDelegate {
let mut label = format!("{}", info.name);
if let Some(args) = info.args.as_ref().filter(|_| selected)
{
- label.push_str(&args);
+ label.push_str(args);
}
Label::new(label)
.single_line()
@@ -306,7 +304,7 @@ where
)
.child(
Icon::new(IconName::ArrowUpRight)
- .size(IconSize::XSmall)
+ .size(IconSize::Small)
.color(Color::Muted),
),
)
@@ -329,9 +327,7 @@ where
};
let picker_view = cx.new(|cx| {
- let picker =
- Picker::uniform_list(delegate, window, cx).max_height(Some(rems(20.).into()));
- picker
+ Picker::uniform_list(delegate, window, cx).max_height(Some(rems(20.).into()))
});
let handle = self
@@ -7,22 +7,11 @@ use settings::{Settings, SettingsSources};
/// Settings for slash commands.
#[derive(Deserialize, Serialize, Debug, Default, Clone, JsonSchema)]
pub struct SlashCommandSettings {
- /// Settings for the `/docs` slash command.
- #[serde(default)]
- pub docs: DocsCommandSettings,
/// Settings for the `/cargo-workspace` slash command.
#[serde(default)]
pub cargo_workspace: CargoWorkspaceCommandSettings,
}
-/// Settings for the `/docs` slash command.
-#[derive(Deserialize, Serialize, Debug, Default, Clone, JsonSchema)]
-pub struct DocsCommandSettings {
- /// Whether `/docs` is enabled.
- #[serde(default)]
- pub enabled: bool,
-}
-
/// Settings for the `/cargo-workspace` slash command.
#[derive(Deserialize, Serialize, Debug, Default, Clone, JsonSchema)]
pub struct CargoWorkspaceCommandSettings {
@@ -48,7 +48,7 @@ impl TerminalCodegen {
let prompt = prompt_task.await;
let model_telemetry_id = model.telemetry_id();
let model_provider_id = model.provider_id();
- let response = model.stream_completion_text(prompt, &cx).await;
+ let response = model.stream_completion_text(prompt, cx).await;
let generate = async {
let message_id = response
.as_ref()
@@ -10,6 +10,7 @@ use agent::{
use agent_settings::AgentSettings;
use anyhow::{Context as _, Result};
use client::telemetry::Telemetry;
+use cloud_llm_client::CompletionIntent;
use collections::{HashMap, VecDeque};
use editor::{MultiBuffer, actions::SelectAll};
use fs::Fs;
@@ -27,7 +28,6 @@ use terminal_view::TerminalView;
use ui::prelude::*;
use util::ResultExt;
use workspace::{Toast, Workspace, notifications::NotificationId};
-use zed_llm_client::CompletionIntent;
pub fn init(
fs: Arc<dyn Fs>,
@@ -388,20 +388,20 @@ impl TerminalInlineAssistant {
window: &mut Window,
cx: &mut App,
) {
- if let Some(assist) = self.assists.get_mut(&assist_id) {
- if let Some(prompt_editor) = assist.prompt_editor.as_ref().cloned() {
- assist
- .terminal
- .update(cx, |terminal, cx| {
- terminal.clear_block_below_cursor(cx);
- let block = terminal_view::BlockProperties {
- height,
- render: Box::new(move |_| prompt_editor.clone().into_any_element()),
- };
- terminal.set_block_below_cursor(block, window, cx);
- })
- .log_err();
- }
+ if let Some(assist) = self.assists.get_mut(&assist_id)
+ && let Some(prompt_editor) = assist.prompt_editor.as_ref().cloned()
+ {
+ assist
+ .terminal
+ .update(cx, |terminal, cx| {
+ terminal.clear_block_below_cursor(cx);
+ let block = terminal_view::BlockProperties {
+ height,
+ render: Box::new(move |_| prompt_editor.clone().into_any_element()),
+ };
+ terminal.set_block_below_cursor(block, window, cx);
+ })
+ .log_err();
}
}
}
@@ -432,7 +432,7 @@ impl TerminalInlineAssist {
terminal: terminal.downgrade(),
prompt_editor: Some(prompt_editor.clone()),
codegen: codegen.clone(),
- workspace: workspace.clone(),
+ workspace,
context_store,
prompt_store,
_subscriptions: vec![
@@ -450,23 +450,20 @@ impl TerminalInlineAssist {
return;
};
- if let CodegenStatus::Error(error) = &codegen.read(cx).status {
- if assist.prompt_editor.is_none() {
- if let Some(workspace) = assist.workspace.upgrade() {
- let error =
- format!("Terminal inline assistant error: {}", error);
- workspace.update(cx, |workspace, cx| {
- struct InlineAssistantError;
-
- let id =
- NotificationId::composite::<InlineAssistantError>(
- assist_id.0,
- );
-
- workspace.show_toast(Toast::new(id, error), cx);
- })
- }
- }
+ if let CodegenStatus::Error(error) = &codegen.read(cx).status
+ && assist.prompt_editor.is_none()
+ && let Some(workspace) = assist.workspace.upgrade()
+ {
+ let error = format!("Terminal inline assistant error: {}", error);
+ workspace.update(cx, |workspace, cx| {
+ struct InlineAssistantError;
+
+ let id = NotificationId::composite::<InlineAssistantError>(
+ assist_id.0,
+ );
+
+ workspace.show_toast(Toast::new(id, error), cx);
+ })
}
if assist.prompt_editor.is_none() {
@@ -1,20 +1,16 @@
use crate::{
- burn_mode_tooltip::BurnModeTooltip,
- language_model_selector::{
- LanguageModelSelector, ToggleModelSelector, language_model_selector,
- },
+ QuoteSelection,
+ language_model_selector::{LanguageModelSelector, language_model_selector},
+ ui::BurnModeTooltip,
};
use agent_settings::{AgentSettings, CompletionMode};
use anyhow::Result;
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection, SlashCommandWorkingSet};
-use assistant_slash_commands::{
- DefaultSlashCommand, DocsSlashCommand, DocsSlashCommandArgs, FileSlashCommand,
- selections_creases,
-};
+use assistant_slash_commands::{DefaultSlashCommand, FileSlashCommand, selections_creases};
use client::{proto, zed_urls};
use collections::{BTreeSet, HashMap, HashSet, hash_map};
use editor::{
- Anchor, Editor, EditorEvent, MenuInlineCompletionsPolicy, MultiBuffer, MultiBufferSnapshot,
+ Anchor, Editor, EditorEvent, MenuEditPredictionsPolicy, MultiBuffer, MultiBufferSnapshot,
RowExt, ToOffset as _, ToPoint,
actions::{MoveToEndOfLine, Newline, ShowCompletions},
display_map::{
@@ -32,14 +28,12 @@ use gpui::{
StatefulInteractiveElement, Styled, Subscription, Task, Transformation, WeakEntity, actions,
div, img, percentage, point, prelude::*, pulsating_between, size,
};
-use indexed_docs::IndexedDocsStore;
use language::{
BufferSnapshot, LspAdapterDelegate, ToOffset,
language_settings::{SoftWrap, all_language_settings},
};
use language_model::{
- ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelProviderTosView,
- LanguageModelRegistry, Role,
+ ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelRegistry, Role,
};
use multi_buffer::MultiBufferRow;
use picker::{Picker, popover_menu::PickerPopoverMenu};
@@ -74,12 +68,13 @@ use workspace::{
pane,
searchable::{SearchEvent, SearchableItem},
};
+use zed_actions::agent::ToggleModelSelector;
use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker};
use assistant_context::{
AssistantContext, CacheStatus, Content, ContextEvent, ContextId, InvokedSlashCommandId,
InvokedSlashCommandStatus, Message, MessageId, MessageMetadata, MessageStatus,
- ParsedSlashCommand, PendingSlashCommandStatus, ThoughtProcessOutputSection,
+ PendingSlashCommandStatus, ThoughtProcessOutputSection,
};
actions!(
@@ -95,8 +90,6 @@ actions!(
CycleMessageRole,
/// Inserts the selected text into the active editor.
InsertIntoEditor,
- /// Quotes the current selection in the assistant conversation.
- QuoteSelection,
/// Splits the conversation at the current cursor position.
Split,
]
@@ -197,7 +190,6 @@ pub struct TextThreadEditor {
invoked_slash_command_creases: HashMap<InvokedSlashCommandId, CreaseId>,
_subscriptions: Vec<Subscription>,
last_error: Option<AssistError>,
- show_accept_terms: bool,
pub(crate) slash_menu_handle:
PopoverMenuHandle<Picker<slash_command_picker::SlashCommandDelegate>>,
// dragged_file_worktrees is used to keep references to worktrees that were added
@@ -256,7 +248,7 @@ impl TextThreadEditor {
editor.set_show_wrap_guides(false, cx);
editor.set_show_indent_guides(false, cx);
editor.set_completion_provider(Some(Rc::new(completion_provider)));
- editor.set_menu_inline_completions_policy(MenuInlineCompletionsPolicy::Never);
+ editor.set_menu_edit_predictions_policy(MenuEditPredictionsPolicy::Never);
editor.set_collaboration_hub(Box::new(project.clone()));
let show_edit_predictions = all_language_settings(None, cx)
@@ -296,7 +288,6 @@ impl TextThreadEditor {
invoked_slash_command_creases: HashMap::default(),
_subscriptions,
last_error: None,
- show_accept_terms: false,
slash_menu_handle: Default::default(),
dragged_file_worktrees: Vec::new(),
language_model_selector: cx.new(|cx| {
@@ -374,20 +365,7 @@ impl TextThreadEditor {
}
fn send_to_model(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- let provider = LanguageModelRegistry::read_global(cx)
- .default_model()
- .map(|default| default.provider);
- if provider
- .as_ref()
- .map_or(false, |provider| provider.must_accept_terms(cx))
- {
- self.show_accept_terms = true;
- cx.notify();
- return;
- }
-
self.last_error = None;
-
if let Some(user_message) = self.context.update(cx, |context, cx| context.assist(cx)) {
let new_selection = {
let cursor = user_message
@@ -463,7 +441,7 @@ impl TextThreadEditor {
|| snapshot
.chars_at(newest_cursor)
.next()
- .map_or(false, |ch| ch != '\n')
+ .is_some_and(|ch| ch != '\n')
{
editor.move_to_end_of_line(
&MoveToEndOfLine {
@@ -546,7 +524,7 @@ impl TextThreadEditor {
let context = self.context.read(cx);
let sections = context
.slash_command_output_sections()
- .into_iter()
+ .iter()
.filter(|section| section.is_valid(context.buffer().read(cx)))
.cloned()
.collect::<Vec<_>>();
@@ -703,19 +681,7 @@ impl TextThreadEditor {
}
};
let render_trailer = {
- let command = command.clone();
- move |row, _unfold, _window: &mut Window, cx: &mut App| {
- // TODO: In the future we should investigate how we can expose
- // this as a hook on the `SlashCommand` trait so that we don't
- // need to special-case it here.
- if command.name == DocsSlashCommand::NAME {
- return render_docs_slash_command_trailer(
- row,
- command.clone(),
- cx,
- );
- }
-
+ move |_row, _unfold, _window: &mut Window, _cx: &mut App| {
Empty.into_any()
}
};
@@ -763,32 +729,27 @@ impl TextThreadEditor {
) {
if let Some(invoked_slash_command) =
self.context.read(cx).invoked_slash_command(&command_id)
+ && let InvokedSlashCommandStatus::Finished = invoked_slash_command.status
{
- if let InvokedSlashCommandStatus::Finished = invoked_slash_command.status {
- let run_commands_in_ranges = invoked_slash_command
- .run_commands_in_ranges
- .iter()
- .cloned()
- .collect::<Vec<_>>();
- for range in run_commands_in_ranges {
- let commands = self.context.update(cx, |context, cx| {
- context.reparse(cx);
- context
- .pending_commands_for_range(range.clone(), cx)
- .to_vec()
- });
+ let run_commands_in_ranges = invoked_slash_command.run_commands_in_ranges.clone();
+ for range in run_commands_in_ranges {
+ let commands = self.context.update(cx, |context, cx| {
+ context.reparse(cx);
+ context
+ .pending_commands_for_range(range.clone(), cx)
+ .to_vec()
+ });
- for command in commands {
- self.run_command(
- command.source_range,
- &command.name,
- &command.arguments,
- false,
- self.workspace.clone(),
- window,
- cx,
- );
- }
+ for command in commands {
+ self.run_command(
+ command.source_range,
+ &command.name,
+ &command.arguments,
+ false,
+ self.workspace.clone(),
+ window,
+ cx,
+ );
}
}
}
@@ -1260,7 +1221,7 @@ impl TextThreadEditor {
let mut new_blocks = vec![];
let mut block_index_to_message = vec![];
for message in self.context.read(cx).messages(cx) {
- if let Some(_) = blocks_to_remove.remove(&message.id) {
+ if blocks_to_remove.remove(&message.id).is_some() {
// This is an old message that we might modify.
let Some((meta, block_id)) = old_blocks.get_mut(&message.id) else {
debug_assert!(
@@ -1298,7 +1259,7 @@ impl TextThreadEditor {
context_editor_view: &Entity<TextThreadEditor>,
cx: &mut Context<Workspace>,
) -> Option<(String, bool)> {
- const CODE_FENCE_DELIMITER: &'static str = "```";
+ const CODE_FENCE_DELIMITER: &str = "```";
let context_editor = context_editor_view.read(cx).editor.clone();
context_editor.update(cx, |context_editor, cx| {
@@ -1762,7 +1723,7 @@ impl TextThreadEditor {
render_slash_command_output_toggle,
|_, _, _, _| Empty.into_any(),
)
- .with_metadata(metadata.crease.clone())
+ .with_metadata(metadata.crease)
}),
cx,
);
@@ -1833,7 +1794,7 @@ impl TextThreadEditor {
.filter_map(|(anchor, render_image)| {
const MAX_HEIGHT_IN_LINES: u32 = 8;
let anchor = buffer.anchor_in_excerpt(excerpt_id, anchor).unwrap();
- let image = render_image.clone();
+ let image = render_image;
anchor.is_valid(&buffer).then(|| BlockProperties {
placement: BlockPlacement::Above(anchor),
height: Some(MAX_HEIGHT_IN_LINES),
@@ -1895,110 +1856,8 @@ impl TextThreadEditor {
.update(cx, |context, cx| context.summarize(true, cx));
}
- fn render_notice(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
- // This was previously gated behind the `zed-pro` feature flag. Since we
- // aren't planning to ship that right now, we're just hard-coding this
- // value to not show the nudge.
- let nudge = Some(false);
-
- let model_registry = LanguageModelRegistry::read_global(cx);
-
- if nudge.map_or(false, |value| value) {
- Some(
- h_flex()
- .p_3()
- .border_b_1()
- .border_color(cx.theme().colors().border_variant)
- .bg(cx.theme().colors().editor_background)
- .justify_between()
- .child(
- h_flex()
- .gap_3()
- .child(Icon::new(IconName::ZedAssistant).color(Color::Accent))
- .child(Label::new("Zed AI is here! Get started by signing in →")),
- )
- .child(
- Button::new("sign-in", "Sign in")
- .size(ButtonSize::Compact)
- .style(ButtonStyle::Filled)
- .on_click(cx.listener(|this, _event, _window, cx| {
- let client = this
- .workspace
- .read_with(cx, |workspace, _| workspace.client().clone())
- .log_err();
-
- if let Some(client) = client {
- cx.spawn(async move |context_editor, cx| {
- match client.authenticate_and_connect(true, cx).await {
- util::ConnectionResult::Timeout => {
- log::error!("Authentication timeout")
- }
- util::ConnectionResult::ConnectionReset => {
- log::error!("Connection reset")
- }
- util::ConnectionResult::Result(r) => {
- if r.log_err().is_some() {
- context_editor
- .update(cx, |_, cx| cx.notify())
- .ok();
- }
- }
- }
- })
- .detach()
- }
- })),
- )
- .into_any_element(),
- )
- } else if let Some(configuration_error) =
- model_registry.configuration_error(model_registry.default_model(), cx)
- {
- Some(
- h_flex()
- .px_3()
- .py_2()
- .border_b_1()
- .border_color(cx.theme().colors().border_variant)
- .bg(cx.theme().colors().editor_background)
- .justify_between()
- .child(
- h_flex()
- .gap_3()
- .child(
- Icon::new(IconName::Warning)
- .size(IconSize::Small)
- .color(Color::Warning),
- )
- .child(Label::new(configuration_error.to_string())),
- )
- .child(
- Button::new("open-configuration", "Configure Providers")
- .size(ButtonSize::Compact)
- .icon(Some(IconName::SlidersVertical))
- .icon_size(IconSize::Small)
- .icon_position(IconPosition::Start)
- .style(ButtonStyle::Filled)
- .on_click({
- let focus_handle = self.focus_handle(cx).clone();
- move |_event, window, cx| {
- focus_handle.dispatch_action(
- &zed_actions::agent::OpenConfiguration,
- window,
- cx,
- );
- }
- }),
- )
- .into_any_element(),
- )
- } else {
- None
- }
- }
-
fn render_send_button(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let focus_handle = self.focus_handle(cx).clone();
+ let focus_handle = self.focus_handle(cx);
let (style, tooltip) = match token_state(&self.context, cx) {
Some(TokenState::NoTokensLeft { .. }) => (
@@ -2056,7 +1915,6 @@ impl TextThreadEditor {
ConfigurationError::NoProvider
| ConfigurationError::ModelNotFound
| ConfigurationError::ProviderNotAuthenticated(_) => true,
- ConfigurationError::ProviderPendingTermsAcceptance(_) => self.show_accept_terms,
}
}
@@ -2128,18 +1986,19 @@ impl TextThreadEditor {
.map(|default| default.model);
let model_name = match active_model {
Some(model) => model.name().0,
- None => SharedString::from("No model selected"),
+ None => SharedString::from("Select Model"),
};
let active_provider = LanguageModelRegistry::read_global(cx)
.default_model()
.map(|default| default.provider);
+
let provider_icon = match active_provider {
Some(provider) => provider.icon(),
None => IconName::Ai,
};
- let focus_handle = self.editor().focus_handle(cx).clone();
+ let focus_handle = self.editor().focus_handle(cx);
PickerPopoverMenu::new(
self.language_model_selector.clone(),
@@ -2285,8 +2144,8 @@ impl TextThreadEditor {
/// Returns the contents of the *outermost* fenced code block that contains the given offset.
fn find_surrounding_code_block(snapshot: &BufferSnapshot, offset: usize) -> Option<Range<usize>> {
- const CODE_BLOCK_NODE: &'static str = "fenced_code_block";
- const CODE_BLOCK_CONTENT: &'static str = "code_fence_content";
+ const CODE_BLOCK_NODE: &str = "fenced_code_block";
+ const CODE_BLOCK_CONTENT: &str = "code_fence_content";
let layer = snapshot.syntax_layers().next()?;
@@ -2336,7 +2195,7 @@ fn render_thought_process_fold_icon_button(
let button = match status {
ThoughtProcessStatus::Pending => button
.child(
- Icon::new(IconName::LightBulb)
+ Icon::new(IconName::ToolThink)
.size(IconSize::Small)
.color(Color::Muted),
)
@@ -2351,7 +2210,7 @@ fn render_thought_process_fold_icon_button(
),
ThoughtProcessStatus::Completed => button
.style(ButtonStyle::Filled)
- .child(Icon::new(IconName::LightBulb).size(IconSize::Small))
+ .child(Icon::new(IconName::ToolThink).size(IconSize::Small))
.child(Label::new("Thought Process").single_line()),
};
@@ -2501,70 +2360,6 @@ fn render_pending_slash_command_gutter_decoration(
icon.into_any_element()
}
-fn render_docs_slash_command_trailer(
- row: MultiBufferRow,
- command: ParsedSlashCommand,
- cx: &mut App,
-) -> AnyElement {
- if command.arguments.is_empty() {
- return Empty.into_any();
- }
- let args = DocsSlashCommandArgs::parse(&command.arguments);
-
- let Some(store) = args
- .provider()
- .and_then(|provider| IndexedDocsStore::try_global(provider, cx).ok())
- else {
- return Empty.into_any();
- };
-
- let Some(package) = args.package() else {
- return Empty.into_any();
- };
-
- let mut children = Vec::new();
-
- if store.is_indexing(&package) {
- children.push(
- div()
- .id(("crates-being-indexed", row.0))
- .child(Icon::new(IconName::ArrowCircle).with_animation(
- "arrow-circle",
- Animation::new(Duration::from_secs(4)).repeat(),
- |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
- ))
- .tooltip({
- let package = package.clone();
- Tooltip::text(format!("Indexing {package}…"))
- })
- .into_any_element(),
- );
- }
-
- if let Some(latest_error) = store.latest_error_for_package(&package) {
- children.push(
- div()
- .id(("latest-error", row.0))
- .child(
- Icon::new(IconName::Warning)
- .size(IconSize::Small)
- .color(Color::Warning),
- )
- .tooltip(Tooltip::text(format!("Failed to index: {latest_error}")))
- .into_any_element(),
- )
- }
-
- let is_indexing = store.is_indexing(&package);
- let latest_error = store.latest_error_for_package(&package);
-
- if !is_indexing && latest_error.is_none() {
- return Empty.into_any();
- }
-
- h_flex().gap_2().children(children).into_any_element()
-}
-
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CopyMetadata {
creases: Vec<SelectedCreaseMetadata>,
@@ -2581,20 +2376,7 @@ impl EventEmitter<SearchEvent> for TextThreadEditor {}
impl Render for TextThreadEditor {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let provider = LanguageModelRegistry::read_global(cx)
- .default_model()
- .map(|default| default.provider);
-
- let accept_terms = if self.show_accept_terms {
- provider.as_ref().and_then(|provider| {
- provider.render_accept_terms(LanguageModelProviderTosView::PromptEditorPopup, cx)
- })
- } else {
- None
- };
-
let language_model_selector = self.language_model_selector_menu_handle.clone();
- let burn_mode_toggle = self.render_burn_mode_toggle(cx);
v_flex()
.key_context("ContextEditor")
@@ -2611,28 +2393,12 @@ impl Render for TextThreadEditor {
language_model_selector.toggle(window, cx);
})
.size_full()
- .children(self.render_notice(cx))
.child(
div()
.flex_grow()
.bg(cx.theme().colors().editor_background)
.child(self.editor.clone()),
)
- .when_some(accept_terms, |this, element| {
- this.child(
- div()
- .absolute()
- .right_3()
- .bottom_12()
- .max_w_96()
- .py_2()
- .px_3()
- .elevation_2(cx)
- .bg(cx.theme().colors().surface_background)
- .occlude()
- .child(element),
- )
- })
.children(self.render_last_error(cx))
.child(
h_flex()
@@ -2649,7 +2415,7 @@ impl Render for TextThreadEditor {
h_flex()
.gap_0p5()
.child(self.render_inject_context_menu(cx))
- .when_some(burn_mode_toggle, |this, element| this.child(element)),
+ .children(self.render_burn_mode_toggle(cx)),
)
.child(
h_flex()
@@ -3346,7 +3112,7 @@ mod tests {
let context_editor = window
.update(&mut cx, |_, window, cx| {
cx.new(|cx| {
- let editor = TextThreadEditor::for_context(
+ TextThreadEditor::for_context(
context.clone(),
fs,
workspace.downgrade(),
@@ -3354,8 +3120,7 @@ mod tests {
None,
window,
cx,
- );
- editor
+ )
})
})
.unwrap();
@@ -166,14 +166,13 @@ impl ThreadHistory {
this.all_entries.len().saturating_sub(1),
cx,
);
- } else if let Some(prev_id) = previously_selected_entry {
- if let Some(new_ix) = this
+ } else if let Some(prev_id) = previously_selected_entry
+ && let Some(new_ix) = this
.all_entries
.iter()
.position(|probe| probe.id() == prev_id)
- {
- this.set_selected_entry_index(new_ix, cx);
- }
+ {
+ this.set_selected_entry_index(new_ix, cx);
}
}
SearchState::Searching { query, .. } | SearchState::Searched { query, .. } => {
@@ -541,6 +540,7 @@ impl Render for ThreadHistory {
v_flex()
.key_context("ThreadHistory")
.size_full()
+ .bg(cx.theme().colors().panel_background)
.on_action(cx.listener(Self::select_previous))
.on_action(cx.listener(Self::select_next))
.on_action(cx.listener(Self::select_first))
@@ -701,7 +701,7 @@ impl RenderOnce for HistoryEntryElement {
.on_hover(self.on_hover)
.end_slot::<IconButton>(if self.hovered || self.selected {
Some(
- IconButton::new("delete", IconName::TrashAlt)
+ IconButton::new("delete", IconName::Trash)
.shape(IconButtonShape::Square)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
@@ -14,13 +14,11 @@ pub struct IncompatibleToolsState {
impl IncompatibleToolsState {
pub fn new(thread: Entity<Thread>, cx: &mut Context<Self>) -> Self {
- let _tool_working_set_subscription =
- cx.subscribe(&thread, |this, _, event, _| match event {
- ThreadEvent::ProfileChanged => {
- this.cache.clear();
- }
- _ => {}
- });
+ let _tool_working_set_subscription = cx.subscribe(&thread, |this, _, event, _| {
+ if let ThreadEvent::ProfileChanged = event {
+ this.cache.clear();
+ }
+ });
Self {
cache: HashMap::default(),
@@ -1,11 +1,14 @@
mod agent_notification;
mod burn_mode_tooltip;
mod context_pill;
+mod end_trial_upsell;
mod onboarding_modal;
pub mod preview;
-mod upsell;
+mod unavailable_editing_tooltip;
pub use agent_notification::*;
pub use burn_mode_tooltip::*;
pub use context_pill::*;
+pub use end_trial_upsell::*;
pub use onboarding_modal::*;
+pub use unavailable_editing_tooltip::*;
@@ -2,11 +2,11 @@ use crate::ToggleBurnMode;
use gpui::{Context, FontWeight, IntoElement, Render, Window};
use ui::{KeyBinding, prelude::*, tooltip_container};
-pub struct MaxModeTooltip {
+pub struct BurnModeTooltip {
selected: bool,
}
-impl MaxModeTooltip {
+impl BurnModeTooltip {
pub fn new() -> Self {
Self { selected: false }
}
@@ -17,7 +17,7 @@ impl MaxModeTooltip {
}
}
-impl Render for MaxModeTooltip {
+impl Render for BurnModeTooltip {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (icon, color) = if self.selected {
(IconName::ZedBurnModeOn, Color::Error)
@@ -353,7 +353,7 @@ impl AddedContext {
name,
parent,
tooltip: Some(full_path_string),
- icon_path: FileIcons::get_icon(&full_path, cx),
+ icon_path: FileIcons::get_icon(full_path, cx),
status: ContextStatus::Ready,
render_hover: None,
handle: AgentContextHandle::File(handle),
@@ -499,7 +499,7 @@ impl AddedContext {
let thread = handle.thread.clone();
Some(Rc::new(move |_, cx| {
let text = thread.read(cx).latest_detailed_summary_or_text();
- ContextPillHover::new_text(text.clone(), cx).into()
+ ContextPillHover::new_text(text, cx).into()
}))
},
handle: AgentContextHandle::Thread(handle),
@@ -574,7 +574,7 @@ impl AddedContext {
.unwrap_or_else(|| "Unnamed Rule".into());
Some(AddedContext {
kind: ContextKind::Rules,
- name: title.clone(),
+ name: title,
parent: None,
tooltip: None,
icon_path: None,
@@ -615,7 +615,7 @@ impl AddedContext {
let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
let (name, parent) =
extract_file_name_and_directory_from_full_path(full_path, &full_path_string);
- let icon_path = FileIcons::get_icon(&full_path, cx);
+ let icon_path = FileIcons::get_icon(full_path, cx);
(name, parent, icon_path)
} else {
("Image".into(), None, None)
@@ -706,7 +706,7 @@ impl ContextFileExcerpt {
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned().into());
- let icon_path = FileIcons::get_icon(&full_path, cx);
+ let icon_path = FileIcons::get_icon(full_path, cx);
ContextFileExcerpt {
file_name_and_range: file_name_and_range.into(),
@@ -0,0 +1,117 @@
+use std::sync::Arc;
+
+use ai_onboarding::{AgentPanelOnboardingCard, PlanDefinitions};
+use client::zed_urls;
+use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
+use ui::{Divider, Tooltip, prelude::*};
+
+#[derive(IntoElement, RegisterComponent)]
+pub struct EndTrialUpsell {
+ dismiss_upsell: Arc<dyn Fn(&mut Window, &mut App)>,
+}
+
+impl EndTrialUpsell {
+ pub fn new(dismiss_upsell: Arc<dyn Fn(&mut Window, &mut App)>) -> Self {
+ Self { dismiss_upsell }
+ }
+}
+
+impl RenderOnce for EndTrialUpsell {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let plan_definitions = PlanDefinitions;
+
+ let pro_section = v_flex()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(
+ Label::new("Pro")
+ .size(LabelSize::Small)
+ .color(Color::Accent)
+ .buffer_font(cx),
+ )
+ .child(Divider::horizontal()),
+ )
+ .child(plan_definitions.pro_plan(false))
+ .child(
+ Button::new("cta-button", "Upgrade to Zed Pro")
+ .full_width()
+ .style(ButtonStyle::Tinted(ui::TintColor::Accent))
+ .on_click(move |_, _window, cx| {
+ telemetry::event!("Upgrade To Pro Clicked", state = "end-of-trial");
+ cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))
+ }),
+ );
+
+ let free_section = v_flex()
+ .mt_1p5()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(
+ Label::new("Free")
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .buffer_font(cx),
+ )
+ .child(
+ Label::new("(Current Plan)")
+ .size(LabelSize::Small)
+ .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.6)))
+ .buffer_font(cx),
+ )
+ .child(Divider::horizontal()),
+ )
+ .child(plan_definitions.free_plan());
+
+ AgentPanelOnboardingCard::new()
+ .child(Headline::new("Your Zed Pro Trial has expired"))
+ .child(
+ Label::new("You've been automatically reset to the Free plan.")
+ .color(Color::Muted)
+ .mb_2(),
+ )
+ .child(pro_section)
+ .child(free_section)
+ .child(
+ h_flex().absolute().top_4().right_4().child(
+ IconButton::new("dismiss_onboarding", IconName::Close)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Dismiss"))
+ .on_click({
+ let callback = self.dismiss_upsell.clone();
+ move |_, window, cx| {
+ telemetry::event!("Banner Dismissed", source = "AI Onboarding");
+ callback(window, cx)
+ }
+ }),
+ ),
+ )
+ }
+}
+
+impl Component for EndTrialUpsell {
+ fn scope() -> ComponentScope {
+ ComponentScope::Onboarding
+ }
+
+ fn name() -> &'static str {
+ "End of Trial Upsell Banner"
+ }
+
+ fn sort_name() -> &'static str {
+ "End of Trial Upsell Banner"
+ }
+
+ fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+ Some(
+ v_flex()
+ .child(EndTrialUpsell {
+ dismiss_upsell: Arc::new(|_, _| {}),
+ })
+ .into_any_element(),
+ )
+ }
+}
@@ -139,7 +139,7 @@ impl Render for AgentOnboardingModal {
.child(Headline::new("Agentic Editing in Zed").size(HeadlineSize::Large)),
)
.child(h_flex().absolute().top_2().right_2().child(
- IconButton::new("cancel", IconName::X).on_click(cx.listener(
+ IconButton::new("cancel", IconName::Close).on_click(cx.listener(
|_, _: &ClickEvent, _window, cx| {
agent_onboarding_event!("Cancelled", trigger = "X click");
cx.emit(DismissEvent);
@@ -1,8 +1,8 @@
use client::{ModelRequestUsage, RequestUsage, zed_urls};
+use cloud_llm_client::{Plan, UsageLimit};
use component::{empty_example, example_group_with_title, single_example};
use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
use ui::{Callout, prelude::*};
-use zed_llm_client::{Plan, UsageLimit};
#[derive(IntoElement, RegisterComponent)]
pub struct UsageCallout {
@@ -80,31 +80,24 @@ impl RenderOnce for UsageCallout {
}
};
- let icon = if is_limit_reached {
- Icon::new(IconName::X)
- .color(Color::Error)
- .size(IconSize::XSmall)
+ let (icon, severity) = if is_limit_reached {
+ (IconName::Close, Severity::Error)
} else {
- Icon::new(IconName::Warning)
- .color(Color::Warning)
- .size(IconSize::XSmall)
+ (IconName::Warning, Severity::Warning)
};
- div()
- .border_t_1()
- .border_color(cx.theme().colors().border)
- .child(
- Callout::new()
- .icon(icon)
- .title(title)
- .description(message)
- .primary_action(
- Button::new("upgrade", button_text)
- .label_size(LabelSize::Small)
- .on_click(move |_, _, cx| {
- cx.open_url(&url);
- }),
- ),
+ Callout::new()
+ .icon(icon)
+ .severity(severity)
+ .icon(icon)
+ .title(title)
+ .description(message)
+ .actions_slot(
+ Button::new("upgrade", button_text)
+ .label_size(LabelSize::Small)
+ .on_click(move |_, _, cx| {
+ cx.open_url(&url);
+ }),
)
.into_any_element()
}
@@ -0,0 +1,29 @@
+use gpui::{Context, IntoElement, Render, Window};
+use ui::{prelude::*, tooltip_container};
+
+pub struct UnavailableEditingTooltip {
+ agent_name: SharedString,
+}
+
+impl UnavailableEditingTooltip {
+ pub fn new(agent_name: SharedString) -> Self {
+ Self { agent_name }
+ }
+}
+
+impl Render for UnavailableEditingTooltip {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ tooltip_container(window, cx, |this, _, _| {
+ this.child(Label::new("Unavailable Editing")).child(
+ div().max_w_64().child(
+ Label::new(format!(
+ "Editing previous messages is not available for {} yet.",
+ self.agent_name
+ ))
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ })
+ }
+}
@@ -1,163 +0,0 @@
-use component::{Component, ComponentScope, single_example};
-use gpui::{
- AnyElement, App, ClickEvent, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
- Window,
-};
-use theme::ActiveTheme;
-use ui::{
- Button, ButtonCommon, ButtonStyle, Checkbox, Clickable, Color, Label, LabelCommon,
- RegisterComponent, ToggleState, h_flex, v_flex,
-};
-
-/// A component that displays an upsell message with a call-to-action button
-///
-/// # Example
-/// ```
-/// let upsell = Upsell::new(
-/// "Upgrade to Zed Pro",
-/// "Get access to advanced AI features and more",
-/// "Upgrade Now",
-/// Box::new(|_, _window, cx| {
-/// cx.open_url("https://zed.dev/pricing");
-/// }),
-/// Box::new(|_, _window, cx| {
-/// // Handle dismiss
-/// }),
-/// Box::new(|checked, window, cx| {
-/// // Handle don't show again
-/// }),
-/// );
-/// ```
-#[derive(IntoElement, RegisterComponent)]
-pub struct Upsell {
- title: SharedString,
- message: SharedString,
- cta_text: SharedString,
- on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
- on_dismiss: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
- on_dont_show_again: Box<dyn Fn(bool, &mut Window, &mut App) + 'static>,
-}
-
-impl Upsell {
- /// Create a new upsell component
- pub fn new(
- title: impl Into<SharedString>,
- message: impl Into<SharedString>,
- cta_text: impl Into<SharedString>,
- on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
- on_dismiss: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
- on_dont_show_again: Box<dyn Fn(bool, &mut Window, &mut App) + 'static>,
- ) -> Self {
- Self {
- title: title.into(),
- message: message.into(),
- cta_text: cta_text.into(),
- on_click,
- on_dismiss,
- on_dont_show_again,
- }
- }
-}
-
-impl RenderOnce for Upsell {
- fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
- v_flex()
- .w_full()
- .p_4()
- .gap_3()
- .bg(cx.theme().colors().surface_background)
- .rounded_md()
- .border_1()
- .border_color(cx.theme().colors().border)
- .child(
- v_flex()
- .gap_1()
- .child(
- Label::new(self.title)
- .size(ui::LabelSize::Large)
- .weight(gpui::FontWeight::BOLD),
- )
- .child(Label::new(self.message).color(Color::Muted)),
- )
- .child(
- h_flex()
- .w_full()
- .justify_between()
- .items_center()
- .child(
- h_flex()
- .items_center()
- .gap_1()
- .child(
- Checkbox::new("dont-show-again", ToggleState::Unselected).on_click(
- move |_, window, cx| {
- (self.on_dont_show_again)(true, window, cx);
- },
- ),
- )
- .child(
- Label::new("Don't show again")
- .color(Color::Muted)
- .size(ui::LabelSize::Small),
- ),
- )
- .child(
- h_flex()
- .gap_2()
- .child(
- Button::new("dismiss-button", "No Thanks")
- .style(ButtonStyle::Subtle)
- .on_click(self.on_dismiss),
- )
- .child(
- Button::new("cta-button", self.cta_text)
- .style(ButtonStyle::Filled)
- .on_click(self.on_click),
- ),
- ),
- )
- }
-}
-
-impl Component for Upsell {
- fn scope() -> ComponentScope {
- ComponentScope::Agent
- }
-
- fn name() -> &'static str {
- "Upsell"
- }
-
- fn description() -> Option<&'static str> {
- Some("A promotional component that displays a message with a call-to-action.")
- }
-
- fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
- let examples = vec![
- single_example(
- "Default",
- Upsell::new(
- "Upgrade to Zed Pro",
- "Get unlimited access to AI features and more with Zed Pro. Unlock advanced AI capabilities and other premium features.",
- "Upgrade Now",
- Box::new(|_, _, _| {}),
- Box::new(|_, _, _| {}),
- Box::new(|_, _, _| {}),
- ).render(window, cx).into_any_element(),
- ),
- single_example(
- "Short Message",
- Upsell::new(
- "Try Zed Pro for free",
- "Start your 7-day trial today.",
- "Start Trial",
- Box::new(|_, _, _| {}),
- Box::new(|_, _, _| {}),
- Box::new(|_, _, _| {}),
- ).render(window, cx).into_any_element(),
- ),
- ];
-
- Some(v_flex().gap_4().children(examples).into_any_element())
- }
-}
@@ -0,0 +1,28 @@
+[package]
+name = "ai_onboarding"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/ai_onboarding.rs"
+
+[features]
+default = []
+
+[dependencies]
+client.workspace = true
+cloud_llm_client.workspace = true
+component.workspace = true
+gpui.workspace = true
+language_model.workspace = true
+serde.workspace = true
+smallvec.workspace = true
+telemetry.workspace = true
+ui.workspace = true
+workspace-hack.workspace = true
+zed_actions.workspace = true
@@ -0,0 +1,141 @@
+use gpui::{Action, IntoElement, ParentElement, RenderOnce, point};
+use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
+use ui::{Divider, List, ListBulletItem, prelude::*};
+
+pub struct ApiKeysWithProviders {
+ configured_providers: Vec<(IconName, SharedString)>,
+}
+
+impl ApiKeysWithProviders {
+ pub fn new(cx: &mut Context<Self>) -> Self {
+ cx.subscribe(
+ &LanguageModelRegistry::global(cx),
+ |this: &mut Self, _registry, event: &language_model::Event, cx| match event {
+ language_model::Event::ProviderStateChanged(_)
+ | language_model::Event::AddedProvider(_)
+ | language_model::Event::RemovedProvider(_) => {
+ this.configured_providers = Self::compute_configured_providers(cx)
+ }
+ _ => {}
+ },
+ )
+ .detach();
+
+ Self {
+ configured_providers: Self::compute_configured_providers(cx),
+ }
+ }
+
+ fn compute_configured_providers(cx: &App) -> Vec<(IconName, SharedString)> {
+ LanguageModelRegistry::read_global(cx)
+ .providers()
+ .iter()
+ .filter(|provider| {
+ provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
+ })
+ .map(|provider| (provider.icon(), provider.name().0))
+ .collect()
+ }
+}
+
+impl Render for ApiKeysWithProviders {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let configured_providers_list =
+ self.configured_providers
+ .iter()
+ .cloned()
+ .map(|(icon, name)| {
+ h_flex()
+ .gap_1p5()
+ .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
+ .child(Label::new(name))
+ });
+ div()
+ .mx_2p5()
+ .p_1()
+ .pb_0()
+ .gap_2()
+ .rounded_t_lg()
+ .border_t_1()
+ .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.),
+ }])
+ .child(
+ h_flex()
+ .px_2p5()
+ .py_1p5()
+ .gap_2()
+ .flex_wrap()
+ .rounded_t(px(5.))
+ .overflow_hidden()
+ .border_t_1()
+ .border_x_1()
+ .border_color(cx.theme().colors().border)
+ .bg(cx.theme().colors().panel_background)
+ .child(
+ h_flex()
+ .min_w_0()
+ .gap_2()
+ .child(
+ Icon::new(IconName::Info)
+ .size(IconSize::XSmall)
+ .color(Color::Muted)
+ )
+ .child(
+ div()
+ .w_full()
+ .child(
+ Label::new("Start now using API keys from your environment for the following providers:")
+ .color(Color::Muted)
+ )
+ )
+ )
+ .children(configured_providers_list)
+ )
+ }
+}
+
+#[derive(IntoElement)]
+pub struct ApiKeysWithoutProviders;
+
+impl ApiKeysWithoutProviders {
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+impl RenderOnce for ApiKeysWithoutProviders {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ v_flex()
+ .mt_2()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(
+ Label::new("API Keys")
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .buffer_font(cx),
+ )
+ .child(Divider::horizontal()),
+ )
+ .child(List::new().child(ListBulletItem::new(
+ "Add your own keys to use AI without signing in.",
+ )))
+ .child(
+ Button::new("configure-providers", "Configure Providers")
+ .full_width()
+ .style(ButtonStyle::Outlined)
+ .on_click(move |_, window, cx| {
+ window.dispatch_action(zed_actions::agent::OpenSettings.boxed_clone(), cx);
+ }),
+ )
+ }
+}
@@ -0,0 +1,83 @@
+use gpui::{AnyElement, IntoElement, ParentElement, linear_color_stop, linear_gradient};
+use smallvec::SmallVec;
+use ui::{Vector, VectorName, prelude::*};
+
+#[derive(IntoElement)]
+pub struct AgentPanelOnboardingCard {
+ children: SmallVec<[AnyElement; 2]>,
+}
+
+impl AgentPanelOnboardingCard {
+ pub fn new() -> Self {
+ Self {
+ children: SmallVec::new(),
+ }
+ }
+}
+
+impl ParentElement for AgentPanelOnboardingCard {
+ fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
+ self.children.extend(elements)
+ }
+}
+
+impl RenderOnce for AgentPanelOnboardingCard {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ div()
+ .m_2p5()
+ .p(px(3.))
+ .elevation_2(cx)
+ .rounded_lg()
+ .bg(cx.theme().colors().background.alpha(0.5))
+ .child(
+ v_flex()
+ .relative()
+ .size_full()
+ .px_4()
+ .py_3()
+ .gap_2()
+ .border_1()
+ .rounded(px(5.))
+ .border_color(cx.theme().colors().text.alpha(0.1))
+ .overflow_hidden()
+ .bg(cx.theme().colors().panel_background)
+ .child(
+ div()
+ .opacity(0.5)
+ .absolute()
+ .top(px(-8.0))
+ .right_0()
+ .w(px(400.))
+ .h(px(92.))
+ .rounded_md()
+ .child(
+ Vector::new(
+ VectorName::AiGrid,
+ rems_from_px(400.),
+ rems_from_px(92.),
+ )
+ .color(Color::Custom(cx.theme().colors().text.alpha(0.32))),
+ ),
+ )
+ .child(
+ div()
+ .absolute()
+ .top_0p5()
+ .right_0p5()
+ .w(px(660.))
+ .h(px(401.))
+ .overflow_hidden()
+ .rounded_md()
+ .bg(linear_gradient(
+ 75.,
+ linear_color_stop(
+ cx.theme().colors().panel_background.alpha(0.01),
+ 1.0,
+ ),
+ linear_color_stop(cx.theme().colors().panel_background, 0.45),
+ )),
+ )
+ .children(self.children),
+ )
+ }
+}
@@ -0,0 +1,84 @@
+use std::sync::Arc;
+
+use client::{Client, UserStore};
+use cloud_llm_client::Plan;
+use gpui::{Entity, IntoElement, ParentElement};
+use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
+use ui::prelude::*;
+
+use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding};
+
+pub struct AgentPanelOnboarding {
+ user_store: Entity<UserStore>,
+ client: Arc<Client>,
+ configured_providers: Vec<(IconName, SharedString)>,
+ continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
+}
+
+impl AgentPanelOnboarding {
+ pub fn new(
+ user_store: Entity<UserStore>,
+ client: Arc<Client>,
+ continue_with_zed_ai: impl Fn(&mut Window, &mut App) + 'static,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ cx.subscribe(
+ &LanguageModelRegistry::global(cx),
+ |this: &mut Self, _registry, event: &language_model::Event, cx| match event {
+ language_model::Event::ProviderStateChanged(_)
+ | language_model::Event::AddedProvider(_)
+ | language_model::Event::RemovedProvider(_) => {
+ this.configured_providers = Self::compute_available_providers(cx)
+ }
+ _ => {}
+ },
+ )
+ .detach();
+
+ Self {
+ user_store,
+ client,
+ configured_providers: Self::compute_available_providers(cx),
+ continue_with_zed_ai: Arc::new(continue_with_zed_ai),
+ }
+ }
+
+ fn compute_available_providers(cx: &App) -> Vec<(IconName, SharedString)> {
+ LanguageModelRegistry::read_global(cx)
+ .providers()
+ .iter()
+ .filter(|provider| {
+ provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
+ })
+ .map(|provider| (provider.icon(), provider.name().0))
+ .collect()
+ }
+}
+
+impl Render for AgentPanelOnboarding {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let enrolled_in_trial = self.user_store.read(cx).plan() == Some(Plan::ZedProTrial);
+ let is_pro_user = self.user_store.read(cx).plan() == Some(Plan::ZedPro);
+
+ AgentPanelOnboardingCard::new()
+ .child(
+ ZedAiOnboarding::new(
+ self.client.clone(),
+ &self.user_store,
+ self.continue_with_zed_ai.clone(),
+ cx,
+ )
+ .with_dismiss({
+ let callback = self.continue_with_zed_ai.clone();
+ move |window, cx| callback(window, cx)
+ }),
+ )
+ .map(|this| {
+ if enrolled_in_trial || is_pro_user || !self.configured_providers.is_empty() {
+ this
+ } else {
+ this.child(ApiKeysWithoutProviders::new())
+ }
+ })
+ }
+}
@@ -0,0 +1,387 @@
+mod agent_api_keys_onboarding;
+mod agent_panel_onboarding_card;
+mod agent_panel_onboarding_content;
+mod ai_upsell_card;
+mod edit_prediction_onboarding_content;
+mod plan_definitions;
+mod young_account_banner;
+
+pub use agent_api_keys_onboarding::{ApiKeysWithProviders, ApiKeysWithoutProviders};
+pub use agent_panel_onboarding_card::AgentPanelOnboardingCard;
+pub use agent_panel_onboarding_content::AgentPanelOnboarding;
+pub use ai_upsell_card::AiUpsellCard;
+use cloud_llm_client::Plan;
+pub use edit_prediction_onboarding_content::EditPredictionOnboarding;
+pub use plan_definitions::PlanDefinitions;
+pub use young_account_banner::YoungAccountBanner;
+
+use std::sync::Arc;
+
+use client::{Client, UserStore, zed_urls};
+use gpui::{AnyElement, Entity, IntoElement, ParentElement};
+use ui::{Divider, RegisterComponent, Tooltip, prelude::*};
+
+#[derive(PartialEq)]
+pub enum SignInStatus {
+ SignedIn,
+ SigningIn,
+ SignedOut,
+}
+
+impl From<client::Status> for SignInStatus {
+ fn from(status: client::Status) -> Self {
+ if status.is_signing_in() {
+ Self::SigningIn
+ } else if status.is_signed_out() {
+ Self::SignedOut
+ } else {
+ Self::SignedIn
+ }
+ }
+}
+
+#[derive(RegisterComponent, IntoElement)]
+pub struct ZedAiOnboarding {
+ pub sign_in_status: SignInStatus,
+ pub plan: Option<Plan>,
+ pub account_too_young: bool,
+ pub continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
+ pub sign_in: Arc<dyn Fn(&mut Window, &mut App)>,
+ pub dismiss_onboarding: Option<Arc<dyn Fn(&mut Window, &mut App)>>,
+}
+
+impl ZedAiOnboarding {
+ pub fn new(
+ client: Arc<Client>,
+ user_store: &Entity<UserStore>,
+ continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
+ cx: &mut App,
+ ) -> Self {
+ let store = user_store.read(cx);
+ let status = *client.status().borrow();
+
+ Self {
+ sign_in_status: status.into(),
+ plan: store.plan(),
+ account_too_young: store.account_too_young(),
+ continue_with_zed_ai,
+ sign_in: Arc::new(move |_window, cx| {
+ cx.spawn({
+ let client = client.clone();
+ async move |cx| client.sign_in_with_optional_connect(true, cx).await
+ })
+ .detach_and_log_err(cx);
+ }),
+ dismiss_onboarding: None,
+ }
+ }
+
+ pub fn with_dismiss(
+ mut self,
+ dismiss_callback: impl Fn(&mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.dismiss_onboarding = Some(Arc::new(dismiss_callback));
+ self
+ }
+
+ fn render_sign_in_disclaimer(&self, _cx: &mut App) -> AnyElement {
+ let signing_in = matches!(self.sign_in_status, SignInStatus::SigningIn);
+ let plan_definitions = PlanDefinitions;
+
+ v_flex()
+ .gap_1()
+ .child(Headline::new("Welcome to Zed AI"))
+ .child(
+ Label::new("Sign in to try Zed Pro for 14 days, no credit card required.")
+ .color(Color::Muted)
+ .mb_2(),
+ )
+ .child(plan_definitions.pro_plan(false))
+ .child(
+ Button::new("sign_in", "Try Zed Pro for Free")
+ .disabled(signing_in)
+ .full_width()
+ .style(ButtonStyle::Tinted(ui::TintColor::Accent))
+ .on_click({
+ let callback = self.sign_in.clone();
+ move |_, window, cx| {
+ telemetry::event!("Start Trial Clicked", state = "pre-sign-in");
+ callback(window, cx)
+ }
+ }),
+ )
+ .into_any_element()
+ }
+
+ fn render_free_plan_state(&self, cx: &mut App) -> AnyElement {
+ let young_account_banner = YoungAccountBanner;
+ let plan_definitions = PlanDefinitions;
+
+ if self.account_too_young {
+ v_flex()
+ .relative()
+ .max_w_full()
+ .gap_1()
+ .child(Headline::new("Welcome to Zed AI"))
+ .child(young_account_banner)
+ .child(
+ v_flex()
+ .mt_2()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(
+ Label::new("Pro")
+ .size(LabelSize::Small)
+ .color(Color::Accent)
+ .buffer_font(cx),
+ )
+ .child(Divider::horizontal()),
+ )
+ .child(plan_definitions.pro_plan(true))
+ .child(
+ Button::new("pro", "Get Started")
+ .full_width()
+ .style(ButtonStyle::Tinted(ui::TintColor::Accent))
+ .on_click(move |_, _window, cx| {
+ telemetry::event!(
+ "Upgrade To Pro Clicked",
+ state = "young-account"
+ );
+ cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))
+ }),
+ ),
+ )
+ .into_any_element()
+ } else {
+ v_flex()
+ .relative()
+ .gap_1()
+ .child(Headline::new("Welcome to Zed AI"))
+ .child(
+ v_flex()
+ .mt_2()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(
+ Label::new("Free")
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .buffer_font(cx),
+ )
+ .child(
+ Label::new("(Current Plan)")
+ .size(LabelSize::Small)
+ .color(Color::Custom(
+ cx.theme().colors().text_muted.opacity(0.6),
+ ))
+ .buffer_font(cx),
+ )
+ .child(Divider::horizontal()),
+ )
+ .child(plan_definitions.free_plan()),
+ )
+ .when_some(
+ self.dismiss_onboarding.as_ref(),
+ |this, dismiss_callback| {
+ let callback = dismiss_callback.clone();
+
+ this.child(
+ h_flex().absolute().top_0().right_0().child(
+ IconButton::new("dismiss_onboarding", IconName::Close)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Dismiss"))
+ .on_click(move |_, window, cx| {
+ telemetry::event!(
+ "Banner Dismissed",
+ source = "AI Onboarding",
+ );
+ callback(window, cx)
+ }),
+ ),
+ )
+ },
+ )
+ .child(
+ v_flex()
+ .mt_2()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(
+ Label::new("Pro Trial")
+ .size(LabelSize::Small)
+ .color(Color::Accent)
+ .buffer_font(cx),
+ )
+ .child(Divider::horizontal()),
+ )
+ .child(plan_definitions.pro_trial(true))
+ .child(
+ Button::new("pro", "Start Free Trial")
+ .full_width()
+ .style(ButtonStyle::Tinted(ui::TintColor::Accent))
+ .on_click(move |_, _window, cx| {
+ telemetry::event!(
+ "Start Trial Clicked",
+ state = "post-sign-in"
+ );
+ cx.open_url(&zed_urls::start_trial_url(cx))
+ }),
+ ),
+ )
+ .into_any_element()
+ }
+ }
+
+ fn render_trial_state(&self, _cx: &mut App) -> AnyElement {
+ let plan_definitions = PlanDefinitions;
+
+ v_flex()
+ .relative()
+ .gap_1()
+ .child(Headline::new("Welcome to the Zed Pro Trial"))
+ .child(
+ Label::new("Here's what you get for the next 14 days:")
+ .color(Color::Muted)
+ .mb_2(),
+ )
+ .child(plan_definitions.pro_trial(false))
+ .when_some(
+ self.dismiss_onboarding.as_ref(),
+ |this, dismiss_callback| {
+ let callback = dismiss_callback.clone();
+ this.child(
+ h_flex().absolute().top_0().right_0().child(
+ IconButton::new("dismiss_onboarding", IconName::Close)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Dismiss"))
+ .on_click(move |_, window, cx| {
+ telemetry::event!(
+ "Banner Dismissed",
+ source = "AI Onboarding",
+ );
+ callback(window, cx)
+ }),
+ ),
+ )
+ },
+ )
+ .into_any_element()
+ }
+
+ fn render_pro_plan_state(&self, _cx: &mut App) -> AnyElement {
+ let plan_definitions = PlanDefinitions;
+
+ v_flex()
+ .gap_1()
+ .child(Headline::new("Welcome to Zed Pro"))
+ .child(
+ Label::new("Here's what you get:")
+ .color(Color::Muted)
+ .mb_2(),
+ )
+ .child(plan_definitions.pro_plan(false))
+ .when_some(
+ self.dismiss_onboarding.as_ref(),
+ |this, dismiss_callback| {
+ let callback = dismiss_callback.clone();
+ this.child(
+ h_flex().absolute().top_0().right_0().child(
+ IconButton::new("dismiss_onboarding", IconName::Close)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Dismiss"))
+ .on_click(move |_, window, cx| {
+ telemetry::event!(
+ "Banner Dismissed",
+ source = "AI Onboarding",
+ );
+ callback(window, cx)
+ }),
+ ),
+ )
+ },
+ )
+ .into_any_element()
+ }
+}
+
+impl RenderOnce for ZedAiOnboarding {
+ fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
+ if matches!(self.sign_in_status, SignInStatus::SignedIn) {
+ match self.plan {
+ None | Some(Plan::ZedFree) => self.render_free_plan_state(cx),
+ Some(Plan::ZedProTrial) => self.render_trial_state(cx),
+ Some(Plan::ZedPro) => self.render_pro_plan_state(cx),
+ }
+ } else {
+ self.render_sign_in_disclaimer(cx)
+ }
+ }
+}
+
+impl Component for ZedAiOnboarding {
+ fn scope() -> ComponentScope {
+ ComponentScope::Onboarding
+ }
+
+ fn name() -> &'static str {
+ "Agent Panel Banners"
+ }
+
+ fn sort_name() -> &'static str {
+ "Agent Panel Banners"
+ }
+
+ fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+ fn onboarding(
+ sign_in_status: SignInStatus,
+ plan: Option<Plan>,
+ account_too_young: bool,
+ ) -> AnyElement {
+ ZedAiOnboarding {
+ sign_in_status,
+ plan,
+ account_too_young,
+ continue_with_zed_ai: Arc::new(|_, _| {}),
+ sign_in: Arc::new(|_, _| {}),
+ dismiss_onboarding: None,
+ }
+ .into_any_element()
+ }
+
+ Some(
+ v_flex()
+ .gap_4()
+ .items_center()
+ .max_w_4_5()
+ .children(vec![
+ single_example(
+ "Not Signed-in",
+ onboarding(SignInStatus::SignedOut, None, false),
+ ),
+ single_example(
+ "Young Account",
+ onboarding(SignInStatus::SignedIn, None, true),
+ ),
+ single_example(
+ "Free Plan",
+ onboarding(SignInStatus::SignedIn, Some(Plan::ZedFree), false),
+ ),
+ single_example(
+ "Pro Trial",
+ onboarding(SignInStatus::SignedIn, Some(Plan::ZedProTrial), false),
+ ),
+ single_example(
+ "Pro Plan",
+ onboarding(SignInStatus::SignedIn, Some(Plan::ZedPro), false),
+ ),
+ ])
+ .into_any_element(),
+ )
+ }
+}
@@ -0,0 +1,366 @@
+use std::{sync::Arc, time::Duration};
+
+use client::{Client, UserStore, zed_urls};
+use cloud_llm_client::Plan;
+use gpui::{
+ Animation, AnimationExt, AnyElement, App, Entity, IntoElement, RenderOnce, Transformation,
+ Window, percentage,
+};
+use ui::{Divider, Vector, VectorName, prelude::*};
+
+use crate::{SignInStatus, YoungAccountBanner, plan_definitions::PlanDefinitions};
+
+#[derive(IntoElement, RegisterComponent)]
+pub struct AiUpsellCard {
+ pub sign_in_status: SignInStatus,
+ pub sign_in: Arc<dyn Fn(&mut Window, &mut App)>,
+ pub account_too_young: bool,
+ pub user_plan: Option<Plan>,
+ pub tab_index: Option<isize>,
+}
+
+impl AiUpsellCard {
+ pub fn new(
+ client: Arc<Client>,
+ user_store: &Entity<UserStore>,
+ user_plan: Option<Plan>,
+ cx: &mut App,
+ ) -> Self {
+ let status = *client.status().borrow();
+ let store = user_store.read(cx);
+
+ Self {
+ user_plan,
+ sign_in_status: status.into(),
+ sign_in: Arc::new(move |_window, cx| {
+ cx.spawn({
+ let client = client.clone();
+ async move |cx| client.sign_in_with_optional_connect(true, cx).await
+ })
+ .detach_and_log_err(cx);
+ }),
+ account_too_young: store.account_too_young(),
+ tab_index: None,
+ }
+ }
+}
+
+impl RenderOnce for AiUpsellCard {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let plan_definitions = PlanDefinitions;
+ let young_account_banner = YoungAccountBanner;
+
+ let pro_section = v_flex()
+ .flex_grow()
+ .w_full()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(
+ Label::new("Pro")
+ .size(LabelSize::Small)
+ .color(Color::Accent)
+ .buffer_font(cx),
+ )
+ .child(Divider::horizontal()),
+ )
+ .child(plan_definitions.pro_plan(false));
+
+ let free_section = v_flex()
+ .flex_grow()
+ .w_full()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(
+ Label::new("Free")
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .buffer_font(cx),
+ )
+ .child(Divider::horizontal()),
+ )
+ .child(plan_definitions.free_plan());
+
+ let grid_bg = h_flex().absolute().inset_0().w_full().h(px(240.)).child(
+ Vector::new(VectorName::Grid, rems_from_px(500.), rems_from_px(240.))
+ .color(Color::Custom(cx.theme().colors().border.opacity(0.05))),
+ );
+
+ let gradient_bg = div()
+ .absolute()
+ .inset_0()
+ .size_full()
+ .bg(gpui::linear_gradient(
+ 180.,
+ gpui::linear_color_stop(
+ cx.theme().colors().elevated_surface_background.opacity(0.8),
+ 0.,
+ ),
+ gpui::linear_color_stop(
+ cx.theme().colors().elevated_surface_background.opacity(0.),
+ 0.8,
+ ),
+ ));
+
+ let description = PlanDefinitions::AI_DESCRIPTION;
+
+ let card = v_flex()
+ .relative()
+ .flex_grow()
+ .p_4()
+ .pt_3()
+ .border_1()
+ .border_color(cx.theme().colors().border)
+ .rounded_lg()
+ .overflow_hidden()
+ .child(grid_bg)
+ .child(gradient_bg);
+
+ let plans_section = h_flex()
+ .w_full()
+ .mt_1p5()
+ .mb_2p5()
+ .items_start()
+ .gap_6()
+ .child(free_section)
+ .child(pro_section);
+
+ let footer_container = v_flex().items_center().gap_1();
+
+ let certified_user_stamp = div()
+ .absolute()
+ .top_2()
+ .right_2()
+ .size(rems_from_px(72.))
+ .child(
+ Vector::new(
+ VectorName::ProUserStamp,
+ rems_from_px(72.),
+ rems_from_px(72.),
+ )
+ .color(Color::Custom(cx.theme().colors().text_accent.alpha(0.3)))
+ .with_animation(
+ "loading_stamp",
+ Animation::new(Duration::from_secs(10)).repeat(),
+ |this, delta| this.transform(Transformation::rotate(percentage(delta))),
+ ),
+ );
+
+ let pro_trial_stamp = div()
+ .absolute()
+ .top_2()
+ .right_2()
+ .size(rems_from_px(72.))
+ .child(
+ Vector::new(
+ VectorName::ProTrialStamp,
+ rems_from_px(72.),
+ rems_from_px(72.),
+ )
+ .color(Color::Custom(cx.theme().colors().text.alpha(0.2))),
+ );
+
+ match self.sign_in_status {
+ SignInStatus::SignedIn => match self.user_plan {
+ None | Some(Plan::ZedFree) => card
+ .child(Label::new("Try Zed AI").size(LabelSize::Large))
+ .map(|this| {
+ if self.account_too_young {
+ this.child(young_account_banner).child(
+ v_flex()
+ .mt_2()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(
+ Label::new("Pro")
+ .size(LabelSize::Small)
+ .color(Color::Accent)
+ .buffer_font(cx),
+ )
+ .child(Divider::horizontal()),
+ )
+ .child(plan_definitions.pro_plan(true))
+ .child(
+ Button::new("pro", "Get Started")
+ .full_width()
+ .style(ButtonStyle::Tinted(ui::TintColor::Accent))
+ .on_click(move |_, _window, cx| {
+ telemetry::event!(
+ "Upgrade To Pro Clicked",
+ state = "young-account"
+ );
+ cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))
+ }),
+ ),
+ )
+ } else {
+ this.child(
+ div()
+ .max_w_3_4()
+ .mb_2()
+ .child(Label::new(description).color(Color::Muted)),
+ )
+ .child(plans_section)
+ .child(
+ footer_container
+ .child(
+ Button::new("start_trial", "Start 14-day Free Pro Trial")
+ .full_width()
+ .style(ButtonStyle::Tinted(ui::TintColor::Accent))
+ .when_some(self.tab_index, |this, tab_index| {
+ this.tab_index(tab_index)
+ })
+ .on_click(move |_, _window, cx| {
+ telemetry::event!(
+ "Start Trial Clicked",
+ state = "post-sign-in"
+ );
+ cx.open_url(&zed_urls::start_trial_url(cx))
+ }),
+ )
+ .child(
+ Label::new("No credit card required")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ }
+ }),
+ Some(Plan::ZedProTrial) => card
+ .child(pro_trial_stamp)
+ .child(Label::new("You're in the Zed Pro Trial").size(LabelSize::Large))
+ .child(
+ Label::new("Here's what you get for the next 14 days:")
+ .color(Color::Muted)
+ .mb_2(),
+ )
+ .child(plan_definitions.pro_trial(false)),
+ Some(Plan::ZedPro) => card
+ .child(certified_user_stamp)
+ .child(Label::new("You're in the Zed Pro plan").size(LabelSize::Large))
+ .child(
+ Label::new("Here's what you get:")
+ .color(Color::Muted)
+ .mb_2(),
+ )
+ .child(plan_definitions.pro_plan(false)),
+ },
+ // Signed Out State
+ _ => card
+ .child(Label::new("Try Zed AI").size(LabelSize::Large))
+ .child(
+ div()
+ .max_w_3_4()
+ .mb_2()
+ .child(Label::new(description).color(Color::Muted)),
+ )
+ .child(plans_section)
+ .child(
+ Button::new("sign_in", "Sign In")
+ .full_width()
+ .style(ButtonStyle::Tinted(ui::TintColor::Accent))
+ .when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index))
+ .on_click({
+ let callback = self.sign_in.clone();
+ move |_, window, cx| {
+ telemetry::event!("Start Trial Clicked", state = "pre-sign-in");
+ callback(window, cx)
+ }
+ }),
+ ),
+ }
+ }
+}
+
+impl Component for AiUpsellCard {
+ fn scope() -> ComponentScope {
+ ComponentScope::Onboarding
+ }
+
+ fn name() -> &'static str {
+ "AI Upsell Card"
+ }
+
+ fn sort_name() -> &'static str {
+ "AI Upsell Card"
+ }
+
+ fn description() -> Option<&'static str> {
+ Some("A card presenting the Zed AI product during user's first-open onboarding flow.")
+ }
+
+ fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+ Some(
+ v_flex()
+ .gap_4()
+ .items_center()
+ .max_w_4_5()
+ .child(single_example(
+ "Signed Out State",
+ AiUpsellCard {
+ sign_in_status: SignInStatus::SignedOut,
+ sign_in: Arc::new(|_, _| {}),
+ account_too_young: false,
+ user_plan: None,
+ tab_index: Some(0),
+ }
+ .into_any_element(),
+ ))
+ .child(example_group_with_title(
+ "Signed In States",
+ vec![
+ single_example(
+ "Free Plan",
+ AiUpsellCard {
+ sign_in_status: SignInStatus::SignedIn,
+ sign_in: Arc::new(|_, _| {}),
+ account_too_young: false,
+ user_plan: Some(Plan::ZedFree),
+ tab_index: Some(1),
+ }
+ .into_any_element(),
+ ),
+ single_example(
+ "Free Plan but Young Account",
+ AiUpsellCard {
+ sign_in_status: SignInStatus::SignedIn,
+ sign_in: Arc::new(|_, _| {}),
+ account_too_young: true,
+ user_plan: Some(Plan::ZedFree),
+ tab_index: Some(1),
+ }
+ .into_any_element(),
+ ),
+ single_example(
+ "Pro Trial",
+ AiUpsellCard {
+ sign_in_status: SignInStatus::SignedIn,
+ sign_in: Arc::new(|_, _| {}),
+ account_too_young: false,
+ user_plan: Some(Plan::ZedProTrial),
+ tab_index: Some(1),
+ }
+ .into_any_element(),
+ ),
+ single_example(
+ "Pro Plan",
+ AiUpsellCard {
+ sign_in_status: SignInStatus::SignedIn,
+ sign_in: Arc::new(|_, _| {}),
+ account_too_young: false,
+ user_plan: Some(Plan::ZedPro),
+ tab_index: Some(1),
+ }
+ .into_any_element(),
+ ),
+ ],
+ ))
+ .into_any_element(),
+ )
+ }
+}
@@ -0,0 +1,73 @@
+use std::sync::Arc;
+
+use client::{Client, UserStore};
+use gpui::{Entity, IntoElement, ParentElement};
+use ui::prelude::*;
+
+use crate::ZedAiOnboarding;
+
+pub struct EditPredictionOnboarding {
+ user_store: Entity<UserStore>,
+ client: Arc<Client>,
+ copilot_is_configured: bool,
+ continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
+ continue_with_copilot: Arc<dyn Fn(&mut Window, &mut App)>,
+}
+
+impl EditPredictionOnboarding {
+ pub fn new(
+ user_store: Entity<UserStore>,
+ client: Arc<Client>,
+ copilot_is_configured: bool,
+ continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
+ continue_with_copilot: Arc<dyn Fn(&mut Window, &mut App)>,
+ _cx: &mut Context<Self>,
+ ) -> Self {
+ Self {
+ user_store,
+ copilot_is_configured,
+ client,
+ continue_with_zed_ai,
+ continue_with_copilot,
+ }
+ }
+}
+
+impl Render for EditPredictionOnboarding {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let github_copilot = v_flex()
+ .gap_1()
+ .child(Label::new(if self.copilot_is_configured {
+ "Alternatively, you can continue to use GitHub Copilot as that's already set up."
+ } else {
+ "Alternatively, you can use GitHub Copilot as your edit prediction provider."
+ }))
+ .child(
+ Button::new(
+ "configure-copilot",
+ if self.copilot_is_configured {
+ "Use Copilot"
+ } else {
+ "Configure Copilot"
+ },
+ )
+ .full_width()
+ .style(ButtonStyle::Outlined)
+ .on_click({
+ let callback = self.continue_with_copilot.clone();
+ move |_, window, cx| callback(window, cx)
+ }),
+ );
+
+ v_flex()
+ .gap_2()
+ .child(ZedAiOnboarding::new(
+ self.client.clone(),
+ &self.user_store,
+ self.continue_with_zed_ai.clone(),
+ cx,
+ ))
+ .child(ui::Divider::horizontal())
+ .child(github_copilot)
+ }
+}
@@ -0,0 +1,39 @@
+use gpui::{IntoElement, ParentElement};
+use ui::{List, ListBulletItem, prelude::*};
+
+/// Centralized definitions for Zed AI plans
+pub struct PlanDefinitions;
+
+impl PlanDefinitions {
+ pub const AI_DESCRIPTION: &'static str = "Zed offers a complete agentic experience, with robust editing and reviewing features to collaborate with AI.";
+
+ pub fn free_plan(&self) -> impl IntoElement {
+ List::new()
+ .child(ListBulletItem::new("50 prompts with Claude models"))
+ .child(ListBulletItem::new("2,000 accepted edit predictions"))
+ }
+
+ pub fn pro_trial(&self, period: bool) -> impl IntoElement {
+ List::new()
+ .child(ListBulletItem::new("150 prompts with Claude models"))
+ .child(ListBulletItem::new(
+ "Unlimited edit predictions with Zeta, our open-source model",
+ ))
+ .when(period, |this| {
+ this.child(ListBulletItem::new(
+ "Try it out for 14 days for free, no credit card required",
+ ))
+ })
+ }
+
+ pub fn pro_plan(&self, price: bool) -> impl IntoElement {
+ List::new()
+ .child(ListBulletItem::new("500 prompts with Claude models"))
+ .child(ListBulletItem::new(
+ "Unlimited edit predictions with Zeta, our open-source model",
+ ))
+ .when(price, |this| {
+ this.child(ListBulletItem::new("$20 USD per month"))
+ })
+ }
+}
@@ -0,0 +1,22 @@
+use gpui::{IntoElement, ParentElement};
+use ui::{Banner, prelude::*};
+
+#[derive(IntoElement)]
+pub struct YoungAccountBanner;
+
+impl RenderOnce for YoungAccountBanner {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ const YOUNG_ACCOUNT_DISCLAIMER: &str = "To prevent abuse of our service, we cannot offer plans to GitHub accounts created fewer than 30 days ago. To request an exception, reach out to billing-support@zed.dev.";
+
+ let label = div()
+ .w_full()
+ .text_sm()
+ .text_color(cx.theme().colors().text_muted)
+ .child(YOUNG_ACCOUNT_DISCLAIMER);
+
+ div()
+ .max_w_full()
+ .my_1()
+ .child(Banner::new().severity(Severity::Warning).child(label))
+ }
+}
@@ -36,11 +36,18 @@ pub enum AnthropicModelMode {
pub enum Model {
#[serde(rename = "claude-opus-4", alias = "claude-opus-4-latest")]
ClaudeOpus4,
+ #[serde(rename = "claude-opus-4-1", alias = "claude-opus-4-1-latest")]
+ ClaudeOpus4_1,
#[serde(
rename = "claude-opus-4-thinking",
alias = "claude-opus-4-thinking-latest"
)]
ClaudeOpus4Thinking,
+ #[serde(
+ rename = "claude-opus-4-1-thinking",
+ alias = "claude-opus-4-1-thinking-latest"
+ )]
+ ClaudeOpus4_1Thinking,
#[default]
#[serde(rename = "claude-sonnet-4", alias = "claude-sonnet-4-latest")]
ClaudeSonnet4,
@@ -91,10 +98,18 @@ impl Model {
}
pub fn from_id(id: &str) -> Result<Self> {
+ if id.starts_with("claude-opus-4-1-thinking") {
+ return Ok(Self::ClaudeOpus4_1Thinking);
+ }
+
if id.starts_with("claude-opus-4-thinking") {
return Ok(Self::ClaudeOpus4Thinking);
}
+ if id.starts_with("claude-opus-4-1") {
+ return Ok(Self::ClaudeOpus4_1);
+ }
+
if id.starts_with("claude-opus-4") {
return Ok(Self::ClaudeOpus4);
}
@@ -141,7 +156,9 @@ impl Model {
pub fn id(&self) -> &str {
match self {
Self::ClaudeOpus4 => "claude-opus-4-latest",
+ Self::ClaudeOpus4_1 => "claude-opus-4-1-latest",
Self::ClaudeOpus4Thinking => "claude-opus-4-thinking-latest",
+ Self::ClaudeOpus4_1Thinking => "claude-opus-4-1-thinking-latest",
Self::ClaudeSonnet4 => "claude-sonnet-4-latest",
Self::ClaudeSonnet4Thinking => "claude-sonnet-4-thinking-latest",
Self::Claude3_5Sonnet => "claude-3-5-sonnet-latest",
@@ -159,6 +176,7 @@ impl Model {
pub fn request_id(&self) -> &str {
match self {
Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking => "claude-opus-4-20250514",
+ Self::ClaudeOpus4_1 | Self::ClaudeOpus4_1Thinking => "claude-opus-4-1-20250805",
Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking => "claude-sonnet-4-20250514",
Self::Claude3_5Sonnet => "claude-3-5-sonnet-latest",
Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => "claude-3-7-sonnet-latest",
@@ -173,7 +191,9 @@ impl Model {
pub fn display_name(&self) -> &str {
match self {
Self::ClaudeOpus4 => "Claude Opus 4",
+ Self::ClaudeOpus4_1 => "Claude Opus 4.1",
Self::ClaudeOpus4Thinking => "Claude Opus 4 Thinking",
+ Self::ClaudeOpus4_1Thinking => "Claude Opus 4.1 Thinking",
Self::ClaudeSonnet4 => "Claude Sonnet 4",
Self::ClaudeSonnet4Thinking => "Claude Sonnet 4 Thinking",
Self::Claude3_7Sonnet => "Claude 3.7 Sonnet",
@@ -192,7 +212,9 @@ impl Model {
pub fn cache_configuration(&self) -> Option<AnthropicModelCacheConfiguration> {
match self {
Self::ClaudeOpus4
+ | Self::ClaudeOpus4_1
| Self::ClaudeOpus4Thinking
+ | Self::ClaudeOpus4_1Thinking
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4Thinking
| Self::Claude3_5Sonnet
@@ -215,7 +237,9 @@ impl Model {
pub fn max_token_count(&self) -> u64 {
match self {
Self::ClaudeOpus4
+ | Self::ClaudeOpus4_1
| Self::ClaudeOpus4Thinking
+ | Self::ClaudeOpus4_1Thinking
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4Thinking
| Self::Claude3_5Sonnet
@@ -232,7 +256,9 @@ impl Model {
pub fn max_output_tokens(&self) -> u64 {
match self {
Self::ClaudeOpus4
+ | Self::ClaudeOpus4_1
| Self::ClaudeOpus4Thinking
+ | Self::ClaudeOpus4_1Thinking
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4Thinking
| Self::Claude3_5Sonnet
@@ -249,7 +275,9 @@ impl Model {
pub fn default_temperature(&self) -> f32 {
match self {
Self::ClaudeOpus4
+ | Self::ClaudeOpus4_1
| Self::ClaudeOpus4Thinking
+ | Self::ClaudeOpus4_1Thinking
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4Thinking
| Self::Claude3_5Sonnet
@@ -269,6 +297,7 @@ impl Model {
pub fn mode(&self) -> AnthropicModelMode {
match self {
Self::ClaudeOpus4
+ | Self::ClaudeOpus4_1
| Self::ClaudeSonnet4
| Self::Claude3_5Sonnet
| Self::Claude3_7Sonnet
@@ -277,6 +306,7 @@ impl Model {
| Self::Claude3Sonnet
| Self::Claude3Haiku => AnthropicModelMode::Default,
Self::ClaudeOpus4Thinking
+ | Self::ClaudeOpus4_1Thinking
| Self::ClaudeSonnet4Thinking
| Self::Claude3_7SonnetThinking => AnthropicModelMode::Thinking {
budget_tokens: Some(4_096),
@@ -177,11 +177,11 @@ impl AskPassSession {
_ = askpass_opened_rx.fuse() => {
// Note: this await can only resolve after we are dropped.
askpass_kill_master_rx.await.ok();
- return AskPassResult::CancelledByUser
+ AskPassResult::CancelledByUser
}
_ = futures::FutureExt::fuse(smol::Timer::after(connection_timeout)) => {
- return AskPassResult::Timedout
+ AskPassResult::Timedout
}
}
}
@@ -215,7 +215,7 @@ pub fn main(socket: &str) {
}
#[cfg(target_os = "windows")]
- while buffer.last().map_or(false, |&b| b == b'\n' || b == b'\r') {
+ while buffer.last().is_some_and(|&b| b == b'\n' || b == b'\r') {
buffer.pop();
}
if buffer.last() != Some(&b'\0') {
@@ -58,9 +58,7 @@ impl Assets {
pub fn load_test_fonts(&self, cx: &App) {
cx.text_system()
.add_fonts(vec![
- self.load("fonts/plex-mono/ZedPlexMono-Regular.ttf")
- .unwrap()
- .unwrap(),
+ self.load("fonts/lilex/Lilex-Regular.ttf").unwrap().unwrap(),
])
.unwrap()
}
@@ -11,6 +11,9 @@ workspace = true
[lib]
path = "src/assistant_context.rs"
+[features]
+test-support = []
+
[dependencies]
agent_settings.workspace = true
anyhow.workspace = true
@@ -19,6 +22,7 @@ assistant_slash_commands.workspace = true
chrono.workspace = true
client.workspace = true
clock.workspace = true
+cloud_llm_client.workspace = true
collections.workspace = true
context_server.workspace = true
fs.workspace = true
@@ -48,7 +52,6 @@ util.workspace = true
uuid.workspace = true
workspace-hack.workspace = true
workspace.workspace = true
-zed_llm_client.workspace = true
[dev-dependencies]
indoc.workspace = true
@@ -2,15 +2,16 @@
mod assistant_context_tests;
mod context_store;
-use agent_settings::AgentSettings;
+use agent_settings::{AgentSettings, SUMMARIZE_THREAD_PROMPT};
use anyhow::{Context as _, Result, bail};
use assistant_slash_command::{
SlashCommandContent, SlashCommandEvent, SlashCommandLine, SlashCommandOutputSection,
SlashCommandResult, SlashCommandWorkingSet,
};
use assistant_slash_commands::FileCommandMetadata;
-use client::{self, Client, proto, telemetry::Telemetry};
+use client::{self, Client, ModelRequestUsage, RequestUsage, proto, telemetry::Telemetry};
use clock::ReplicaId;
+use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit};
use collections::{HashMap, HashSet};
use fs::{Fs, RenameOptions};
use futures::{FutureExt, StreamExt, future::Shared};
@@ -46,7 +47,6 @@ use text::{BufferSnapshot, ToPoint};
use ui::IconName;
use util::{ResultExt, TryFutureExt, post_inc};
use uuid::Uuid;
-use zed_llm_client::CompletionIntent;
pub use crate::context_store::*;
@@ -590,17 +590,16 @@ impl From<&Message> for MessageMetadata {
impl MessageMetadata {
pub fn is_cache_valid(&self, buffer: &BufferSnapshot, range: &Range<usize>) -> bool {
- let result = match &self.cache {
+ match &self.cache {
Some(MessageCacheMetadata { cached_at, .. }) => !buffer.has_edits_since_in_range(
- &cached_at,
+ cached_at,
Range {
start: buffer.anchor_at(range.start, Bias::Right),
end: buffer.anchor_at(range.end, Bias::Left),
},
),
_ => false,
- };
- result
+ }
}
}
@@ -1023,9 +1022,11 @@ impl AssistantContext {
summary: new_summary,
..
} => {
- if self.summary.timestamp().map_or(true, |current_timestamp| {
- new_summary.timestamp > current_timestamp
- }) {
+ if self
+ .summary
+ .timestamp()
+ .is_none_or(|current_timestamp| new_summary.timestamp > current_timestamp)
+ {
self.summary = ContextSummary::Content(new_summary);
summary_generated = true;
}
@@ -1076,20 +1077,20 @@ impl AssistantContext {
timestamp,
..
} => {
- if let Some(slash_command) = self.invoked_slash_commands.get_mut(&id) {
- if timestamp > slash_command.timestamp {
- slash_command.timestamp = timestamp;
- match error_message {
- Some(message) => {
- slash_command.status =
- InvokedSlashCommandStatus::Error(message.into());
- }
- None => {
- slash_command.status = InvokedSlashCommandStatus::Finished;
- }
+ if let Some(slash_command) = self.invoked_slash_commands.get_mut(&id)
+ && timestamp > slash_command.timestamp
+ {
+ slash_command.timestamp = timestamp;
+ match error_message {
+ Some(message) => {
+ slash_command.status =
+ InvokedSlashCommandStatus::Error(message.into());
+ }
+ None => {
+ slash_command.status = InvokedSlashCommandStatus::Finished;
}
- cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id: id });
}
+ cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id: id });
}
}
ContextOperation::BufferOperation(_) => unreachable!(),
@@ -1339,7 +1340,7 @@ impl AssistantContext {
let is_invalid = self
.messages_metadata
.get(&message_id)
- .map_or(true, |metadata| {
+ .is_none_or(|metadata| {
!metadata.is_cache_valid(&buffer, &message.offset_range)
|| *encountered_invalid
});
@@ -1368,10 +1369,10 @@ impl AssistantContext {
continue;
}
- if let Some(last_anchor) = last_anchor {
- if message.id == last_anchor {
- hit_last_anchor = true;
- }
+ if let Some(last_anchor) = last_anchor
+ && message.id == last_anchor
+ {
+ hit_last_anchor = true;
}
new_anchor_needs_caching = new_anchor_needs_caching
@@ -1406,14 +1407,14 @@ impl AssistantContext {
if !self.pending_completions.is_empty() {
return;
}
- if let Some(cache_configuration) = cache_configuration {
- if !cache_configuration.should_speculate {
- return;
- }
+ if let Some(cache_configuration) = cache_configuration
+ && !cache_configuration.should_speculate
+ {
+ return;
}
let request = {
- let mut req = self.to_completion_request(Some(&model), cx);
+ let mut req = self.to_completion_request(Some(model), cx);
// Skip the last message because it's likely to change and
// therefore would be a waste to cache.
req.messages.pop();
@@ -1428,7 +1429,7 @@ impl AssistantContext {
let model = Arc::clone(model);
self.pending_cache_warming_task = cx.spawn(async move |this, cx| {
async move {
- match model.stream_completion(request, &cx).await {
+ match model.stream_completion(request, cx).await {
Ok(mut stream) => {
stream.next().await;
log::info!("Cache warming completed successfully");
@@ -1552,25 +1553,24 @@ impl AssistantContext {
})
.map(ToOwned::to_owned)
.collect::<SmallVec<_>>();
- if let Some(command) = self.slash_commands.command(name, cx) {
- if !command.requires_argument() || !arguments.is_empty() {
- let start_ix = offset + command_line.name.start - 1;
- let end_ix = offset
- + command_line
- .arguments
- .last()
- .map_or(command_line.name.end, |argument| argument.end);
- let source_range =
- buffer.anchor_after(start_ix)..buffer.anchor_after(end_ix);
- let pending_command = ParsedSlashCommand {
- name: name.to_string(),
- arguments,
- source_range,
- status: PendingSlashCommandStatus::Idle,
- };
- updated.push(pending_command.clone());
- new_commands.push(pending_command);
- }
+ if let Some(command) = self.slash_commands.command(name, cx)
+ && (!command.requires_argument() || !arguments.is_empty())
+ {
+ let start_ix = offset + command_line.name.start - 1;
+ let end_ix = offset
+ + command_line
+ .arguments
+ .last()
+ .map_or(command_line.name.end, |argument| argument.end);
+ let source_range = buffer.anchor_after(start_ix)..buffer.anchor_after(end_ix);
+ let pending_command = ParsedSlashCommand {
+ name: name.to_string(),
+ arguments,
+ source_range,
+ status: PendingSlashCommandStatus::Idle,
+ };
+ updated.push(pending_command.clone());
+ new_commands.push(pending_command);
}
}
@@ -1661,12 +1661,12 @@ impl AssistantContext {
) -> Range<usize> {
let buffer = self.buffer.read(cx);
let start_ix = match all_annotations
- .binary_search_by(|probe| probe.range().end.cmp(&range.start, &buffer))
+ .binary_search_by(|probe| probe.range().end.cmp(&range.start, buffer))
{
Ok(ix) | Err(ix) => ix,
};
let end_ix = match all_annotations
- .binary_search_by(|probe| probe.range().start.cmp(&range.end, &buffer))
+ .binary_search_by(|probe| probe.range().start.cmp(&range.end, buffer))
{
Ok(ix) => ix + 1,
Err(ix) => ix,
@@ -1799,14 +1799,13 @@ impl AssistantContext {
});
let end = this.buffer.read(cx).anchor_before(insert_position);
- if run_commands_in_text {
- if let Some(invoked_slash_command) =
+ if run_commands_in_text
+ && let Some(invoked_slash_command) =
this.invoked_slash_commands.get_mut(&command_id)
- {
- invoked_slash_command
- .run_commands_in_ranges
- .push(start..end);
- }
+ {
+ invoked_slash_command
+ .run_commands_in_ranges
+ .push(start..end);
}
}
SlashCommandEvent::EndSection => {
@@ -1862,7 +1861,7 @@ impl AssistantContext {
{
let newline_offset = insert_position.saturating_sub(1);
if buffer.contains_str_at(newline_offset, "\n")
- && last_section_range.map_or(true, |last_section_range| {
+ && last_section_range.is_none_or(|last_section_range| {
!last_section_range
.to_offset(buffer)
.contains(&newline_offset)
@@ -2045,7 +2044,7 @@ impl AssistantContext {
let task = cx.spawn({
async move |this, cx| {
- let stream = model.stream_completion(request, &cx);
+ let stream = model.stream_completion(request, cx);
let assistant_message_id = assistant_message.id;
let mut response_latency = None;
let stream_completion = async {
@@ -2080,7 +2079,15 @@ impl AssistantContext {
});
match event {
- LanguageModelCompletionEvent::StatusUpdate { .. } => {}
+ LanguageModelCompletionEvent::StatusUpdate(status_update) => {
+ if let CompletionRequestStatus::UsageUpdated { amount, limit } = status_update {
+ this.update_model_request_usage(
+ amount as u32,
+ limit,
+ cx,
+ );
+ }
+ }
LanguageModelCompletionEvent::StartMessage { .. } => {}
LanguageModelCompletionEvent::Stop(reason) => {
stop_reason = reason;
@@ -2275,7 +2282,7 @@ impl AssistantContext {
let mut contents = self.contents(cx).peekable();
fn collect_text_content(buffer: &Buffer, range: Range<usize>) -> Option<String> {
- let text: String = buffer.text_for_range(range.clone()).collect();
+ let text: String = buffer.text_for_range(range).collect();
if text.trim().is_empty() {
None
} else {
@@ -2304,10 +2311,7 @@ impl AssistantContext {
let mut request_message = LanguageModelRequestMessage {
role: message.role,
content: Vec::new(),
- cache: message
- .cache
- .as_ref()
- .map_or(false, |cache| cache.is_anchor),
+ cache: message.cache.as_ref().is_some_and(|cache| cache.is_anchor),
};
while let Some(content) = contents.peek() {
@@ -2677,10 +2681,7 @@ impl AssistantContext {
let mut request = self.to_completion_request(Some(&model.model), cx);
request.messages.push(LanguageModelRequestMessage {
role: Role::User,
- content: vec![
- "Generate a concise 3-7 word title for this conversation, omitting punctuation. Go straight to the title, without any preamble and prefix like `Here's a concise suggestion:...` or `Title:`"
- .into(),
- ],
+ content: vec![SUMMARIZE_THREAD_PROMPT.into()],
cache: false,
});
@@ -2700,7 +2701,7 @@ impl AssistantContext {
self.summary_task = cx.spawn(async move |this, cx| {
let result = async {
- let stream = model.model.stream_completion_text(request, &cx);
+ let stream = model.model.stream_completion_text(request, cx);
let mut messages = stream.await?;
let mut replaced = !replace_old;
@@ -2733,10 +2734,10 @@ impl AssistantContext {
}
this.read_with(cx, |this, _cx| {
- if let Some(summary) = this.summary.content() {
- if summary.text.is_empty() {
- bail!("Model generated an empty summary");
- }
+ if let Some(summary) = this.summary.content()
+ && summary.text.is_empty()
+ {
+ bail!("Model generated an empty summary");
}
Ok(())
})??;
@@ -2791,7 +2792,7 @@ impl AssistantContext {
let mut current_message = messages.next();
while let Some(offset) = offsets.next() {
// Locate the message that contains the offset.
- while current_message.as_ref().map_or(false, |message| {
+ while current_message.as_ref().is_some_and(|message| {
!message.offset_range.contains(&offset) && messages.peek().is_some()
}) {
current_message = messages.next();
@@ -2801,7 +2802,7 @@ impl AssistantContext {
};
// Skip offsets that are in the same message.
- while offsets.peek().map_or(false, |offset| {
+ while offsets.peek().is_some_and(|offset| {
message.offset_range.contains(offset) || messages.peek().is_none()
}) {
offsets.next();
@@ -2916,18 +2917,18 @@ impl AssistantContext {
fs.create_dir(contexts_dir().as_ref()).await?;
// rename before write ensures that only one file exists
- if let Some(old_path) = old_path.as_ref() {
- if new_path.as_path() != old_path.as_ref() {
- fs.rename(
- &old_path,
- &new_path,
- RenameOptions {
- overwrite: true,
- ignore_if_exists: true,
- },
- )
- .await?;
- }
+ if let Some(old_path) = old_path.as_ref()
+ && new_path.as_path() != old_path.as_ref()
+ {
+ fs.rename(
+ old_path,
+ &new_path,
+ RenameOptions {
+ overwrite: true,
+ ignore_if_exists: true,
+ },
+ )
+ .await?;
}
// update path before write in case it fails
@@ -2956,6 +2957,21 @@ impl AssistantContext {
summary.text = custom_summary;
cx.emit(ContextEvent::SummaryChanged);
}
+
+ fn update_model_request_usage(&self, amount: u32, limit: UsageLimit, cx: &mut App) {
+ let Some(project) = &self.project else {
+ return;
+ };
+ project.read(cx).user_store().update(cx, |user_store, cx| {
+ user_store.update_model_request_usage(
+ ModelRequestUsage(RequestUsage {
+ amount: amount as i32,
+ limit,
+ }),
+ cx,
+ )
+ });
+ }
}
#[derive(Debug, Default)]
@@ -1055,7 +1055,7 @@ fn test_mark_cache_anchors(cx: &mut App) {
assert_eq!(
messages_cache(&context, cx)
.iter()
- .filter(|(_, cache)| cache.as_ref().map_or(false, |cache| cache.is_anchor))
+ .filter(|(_, cache)| cache.as_ref().is_some_and(|cache| cache.is_anchor))
.count(),
0,
"Empty messages should not have any cache anchors."
@@ -1083,7 +1083,7 @@ fn test_mark_cache_anchors(cx: &mut App) {
assert_eq!(
messages_cache(&context, cx)
.iter()
- .filter(|(_, cache)| cache.as_ref().map_or(false, |cache| cache.is_anchor))
+ .filter(|(_, cache)| cache.as_ref().is_some_and(|cache| cache.is_anchor))
.count(),
0,
"Messages should not be marked for cache before going over the token minimum."
@@ -1098,7 +1098,7 @@ fn test_mark_cache_anchors(cx: &mut App) {
assert_eq!(
messages_cache(&context, cx)
.iter()
- .map(|(_, cache)| cache.as_ref().map_or(false, |cache| cache.is_anchor))
+ .map(|(_, cache)| cache.as_ref().is_some_and(|cache| cache.is_anchor))
.collect::<Vec<bool>>(),
vec![true, true, false],
"Last message should not be an anchor on speculative request."
@@ -1116,7 +1116,7 @@ fn test_mark_cache_anchors(cx: &mut App) {
assert_eq!(
messages_cache(&context, cx)
.iter()
- .map(|(_, cache)| cache.as_ref().map_or(false, |cache| cache.is_anchor))
+ .map(|(_, cache)| cache.as_ref().is_some_and(|cache| cache.is_anchor))
.collect::<Vec<bool>>(),
vec![false, true, true, false],
"Most recent message should also be cached if not a speculative request."
@@ -1210,8 +1210,8 @@ async fn test_summarization(cx: &mut TestAppContext) {
});
cx.run_until_parked();
- fake_model.stream_last_completion_response("Brief");
- fake_model.stream_last_completion_response(" Introduction");
+ fake_model.send_last_completion_stream_text_chunk("Brief");
+ fake_model.send_last_completion_stream_text_chunk(" Introduction");
fake_model.end_last_completion_stream();
cx.run_until_parked();
@@ -1274,7 +1274,7 @@ async fn test_thread_summary_error_retry(cx: &mut TestAppContext) {
});
cx.run_until_parked();
- fake_model.stream_last_completion_response("A successful summary");
+ fake_model.send_last_completion_stream_text_chunk("A successful summary");
fake_model.end_last_completion_stream();
cx.run_until_parked();
@@ -1300,7 +1300,7 @@ fn test_summarize_error(
context.assist(cx);
});
- simulate_successful_response(&model, cx);
+ simulate_successful_response(model, cx);
context.read_with(cx, |context, _| {
assert!(!context.summary().content().unwrap().done);
@@ -1321,9 +1321,9 @@ fn test_summarize_error(
fn setup_context_editor_with_fake_model(
cx: &mut TestAppContext,
) -> (Entity<AssistantContext>, Arc<FakeLanguageModel>) {
- let registry = Arc::new(LanguageRegistry::test(cx.executor().clone()));
+ let registry = Arc::new(LanguageRegistry::test(cx.executor()));
- let fake_provider = Arc::new(FakeLanguageModelProvider);
+ let fake_provider = Arc::new(FakeLanguageModelProvider::default());
let fake_model = Arc::new(fake_provider.test_model());
cx.update(|cx| {
@@ -1356,7 +1356,7 @@ fn setup_context_editor_with_fake_model(
fn simulate_successful_response(fake_model: &FakeLanguageModel, cx: &mut TestAppContext) {
cx.run_until_parked();
- fake_model.stream_last_completion_response("Assistant response");
+ fake_model.send_last_completion_stream_text_chunk("Assistant response");
fake_model.end_last_completion_stream();
cx.run_until_parked();
}
@@ -1376,7 +1376,7 @@ fn messages_cache(
context
.read(cx)
.messages(cx)
- .map(|message| (message.id, message.cache.clone()))
+ .map(|message| (message.id, message.cache))
.collect()
}
@@ -1436,6 +1436,6 @@ impl SlashCommand for FakeSlashCommand {
sections: vec![],
run_commands_in_text: false,
}
- .to_event_stream()))
+ .into_event_stream()))
}
}
@@ -138,6 +138,27 @@ impl ContextStore {
})
}
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn fake(project: Entity<Project>, cx: &mut Context<Self>) -> Self {
+ Self {
+ contexts: Default::default(),
+ contexts_metadata: Default::default(),
+ context_server_slash_command_ids: Default::default(),
+ host_contexts: Default::default(),
+ fs: project.read(cx).fs().clone(),
+ languages: project.read(cx).languages().clone(),
+ slash_commands: Arc::default(),
+ telemetry: project.read(cx).client().telemetry().clone(),
+ _watch_updates: Task::ready(None),
+ client: project.read(cx).client(),
+ project,
+ project_is_shared: false,
+ client_subscription: None,
+ _project_subscriptions: Default::default(),
+ prompt_builder: Arc::new(PromptBuilder::new(None).unwrap()),
+ }
+ }
+
async fn handle_advertise_contexts(
this: Entity<Self>,
envelope: TypedEnvelope<proto::AdvertiseContexts>,
@@ -299,7 +320,7 @@ impl ContextStore {
.client
.subscribe_to_entity(remote_id)
.log_err()
- .map(|subscription| subscription.set_entity(&cx.entity(), &mut cx.to_async()));
+ .map(|subscription| subscription.set_entity(&cx.entity(), &cx.to_async()));
self.advertise_contexts(cx);
} else {
self.client_subscription = None;
@@ -767,6 +788,11 @@ impl ContextStore {
fn reload(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
let fs = self.fs.clone();
cx.spawn(async move |this, cx| {
+ pub static ZED_STATELESS: LazyLock<bool> =
+ LazyLock::new(|| std::env::var("ZED_STATELESS").is_ok_and(|v| !v.is_empty()));
+ if *ZED_STATELESS {
+ return Ok(());
+ }
fs.create_dir(contexts_dir()).await?;
let mut paths = fs.read_dir(contexts_dir()).await?;
@@ -836,7 +862,7 @@ impl ContextStore {
ContextServerStatus::Running => {
self.load_context_server_slash_commands(
server_id.clone(),
- context_server_store.clone(),
+ context_server_store,
cx,
);
}
@@ -868,34 +894,33 @@ impl ContextStore {
return;
};
- if protocol.capable(context_server::protocol::ServerCapability::Prompts) {
- if let Some(response) = protocol
+ if protocol.capable(context_server::protocol::ServerCapability::Prompts)
+ && let Some(response) = protocol
.request::<context_server::types::requests::PromptsList>(())
.await
.log_err()
- {
- let slash_command_ids = response
- .prompts
- .into_iter()
- .filter(assistant_slash_commands::acceptable_prompt)
- .map(|prompt| {
- log::info!("registering context server command: {:?}", prompt.name);
- slash_command_working_set.insert(Arc::new(
- assistant_slash_commands::ContextServerSlashCommand::new(
- context_server_store.clone(),
- server.id(),
- prompt,
- ),
- ))
- })
- .collect::<Vec<_>>();
-
- this.update(cx, |this, _cx| {
- this.context_server_slash_command_ids
- .insert(server_id.clone(), slash_command_ids);
+ {
+ let slash_command_ids = response
+ .prompts
+ .into_iter()
+ .filter(assistant_slash_commands::acceptable_prompt)
+ .map(|prompt| {
+ log::info!("registering context server command: {:?}", prompt.name);
+ slash_command_working_set.insert(Arc::new(
+ assistant_slash_commands::ContextServerSlashCommand::new(
+ context_server_store.clone(),
+ server.id(),
+ prompt,
+ ),
+ ))
})
- .log_err();
- }
+ .collect::<Vec<_>>();
+
+ this.update(cx, |this, _cx| {
+ this.context_server_slash_command_ids
+ .insert(server_id.clone(), slash_command_ids);
+ })
+ .log_err();
}
})
.detach();
@@ -161,7 +161,7 @@ impl SlashCommandOutput {
}
/// Returns this [`SlashCommandOutput`] as a stream of [`SlashCommandEvent`]s.
- pub fn to_event_stream(mut self) -> BoxStream<'static, Result<SlashCommandEvent>> {
+ pub fn into_event_stream(mut self) -> BoxStream<'static, Result<SlashCommandEvent>> {
self.ensure_valid_section_ranges();
let mut events = Vec::new();
@@ -363,7 +363,7 @@ mod tests {
run_commands_in_text: false,
};
- let events = output.clone().to_event_stream().collect::<Vec<_>>().await;
+ let events = output.clone().into_event_stream().collect::<Vec<_>>().await;
let events = events
.into_iter()
.filter_map(|event| event.ok())
@@ -386,7 +386,7 @@ mod tests {
);
let new_output =
- SlashCommandOutput::from_event_stream(output.clone().to_event_stream())
+ SlashCommandOutput::from_event_stream(output.clone().into_event_stream())
.await
.unwrap();
@@ -415,7 +415,7 @@ mod tests {
run_commands_in_text: false,
};
- let events = output.clone().to_event_stream().collect::<Vec<_>>().await;
+ let events = output.clone().into_event_stream().collect::<Vec<_>>().await;
let events = events
.into_iter()
.filter_map(|event| event.ok())
@@ -452,7 +452,7 @@ mod tests {
);
let new_output =
- SlashCommandOutput::from_event_stream(output.clone().to_event_stream())
+ SlashCommandOutput::from_event_stream(output.clone().into_event_stream())
.await
.unwrap();
@@ -493,7 +493,7 @@ mod tests {
run_commands_in_text: false,
};
- let events = output.clone().to_event_stream().collect::<Vec<_>>().await;
+ let events = output.clone().into_event_stream().collect::<Vec<_>>().await;
let events = events
.into_iter()
.filter_map(|event| event.ok())
@@ -562,7 +562,7 @@ mod tests {
);
let new_output =
- SlashCommandOutput::from_event_stream(output.clone().to_event_stream())
+ SlashCommandOutput::from_event_stream(output.clone().into_event_stream())
.await
.unwrap();
@@ -166,7 +166,7 @@ impl SlashCommand for ExtensionSlashCommand {
.collect(),
run_commands_in_text: false,
}
- .to_event_stream())
+ .into_event_stream())
})
}
}
@@ -27,7 +27,6 @@ globset.workspace = true
gpui.workspace = true
html_to_markdown.workspace = true
http_client.workspace = true
-indexed_docs.workspace = true
language.workspace = true
project.workspace = true
prompt_store.workspace = true
@@ -3,7 +3,6 @@ mod context_server_command;
mod default_command;
mod delta_command;
mod diagnostics_command;
-mod docs_command;
mod fetch_command;
mod file_command;
mod now_command;
@@ -18,7 +17,6 @@ pub use crate::context_server_command::*;
pub use crate::default_command::*;
pub use crate::delta_command::*;
pub use crate::diagnostics_command::*;
-pub use crate::docs_command::*;
pub use crate::fetch_command::*;
pub use crate::file_command::*;
pub use crate::now_command::*;
@@ -150,7 +150,7 @@ impl SlashCommand for CargoWorkspaceSlashCommand {
}],
run_commands_in_text: false,
}
- .to_event_stream())
+ .into_event_stream())
})
});
output.unwrap_or_else(|error| Task::ready(Err(error)))
@@ -39,12 +39,12 @@ impl SlashCommand for ContextServerSlashCommand {
fn label(&self, cx: &App) -> language::CodeLabel {
let mut parts = vec![self.prompt.name.as_str()];
- if let Some(args) = &self.prompt.arguments {
- if let Some(arg) = args.first() {
- parts.push(arg.name.as_str());
- }
+ if let Some(args) = &self.prompt.arguments
+ && let Some(arg) = args.first()
+ {
+ parts.push(arg.name.as_str());
}
- create_label_for_command(&parts[0], &parts[1..], cx)
+ create_label_for_command(parts[0], &parts[1..], cx)
}
fn description(&self) -> String {
@@ -62,9 +62,10 @@ impl SlashCommand for ContextServerSlashCommand {
}
fn requires_argument(&self) -> bool {
- self.prompt.arguments.as_ref().map_or(false, |args| {
- args.iter().any(|arg| arg.required == Some(true))
- })
+ self.prompt
+ .arguments
+ .as_ref()
+ .is_some_and(|args| args.iter().any(|arg| arg.required == Some(true)))
}
fn complete_argument(
@@ -190,7 +191,7 @@ impl SlashCommand for ContextServerSlashCommand {
text: prompt,
run_commands_in_text: false,
}
- .to_event_stream())
+ .into_event_stream())
})
} else {
Task::ready(Err(anyhow!("Context server not found")))
@@ -85,7 +85,7 @@ impl SlashCommand for DefaultSlashCommand {
text,
run_commands_in_text: true,
}
- .to_event_stream())
+ .into_event_stream())
})
}
}
@@ -66,23 +66,22 @@ impl SlashCommand for DeltaSlashCommand {
.metadata
.as_ref()
.and_then(|value| serde_json::from_value::<FileCommandMetadata>(value.clone()).ok())
+ && paths.insert(metadata.path.clone())
{
- if paths.insert(metadata.path.clone()) {
- file_command_old_outputs.push(
- context_buffer
- .as_rope()
- .slice(section.range.to_offset(&context_buffer)),
- );
- file_command_new_outputs.push(Arc::new(FileSlashCommand).run(
- std::slice::from_ref(&metadata.path),
- context_slash_command_output_sections,
- context_buffer.clone(),
- workspace.clone(),
- delegate.clone(),
- window,
- cx,
- ));
- }
+ file_command_old_outputs.push(
+ context_buffer
+ .as_rope()
+ .slice(section.range.to_offset(&context_buffer)),
+ );
+ file_command_new_outputs.push(Arc::new(FileSlashCommand).run(
+ std::slice::from_ref(&metadata.path),
+ context_slash_command_output_sections,
+ context_buffer.clone(),
+ workspace.clone(),
+ delegate.clone(),
+ window,
+ cx,
+ ));
}
}
@@ -95,31 +94,31 @@ impl SlashCommand for DeltaSlashCommand {
.into_iter()
.zip(file_command_new_outputs)
{
- if let Ok(new_output) = new_output {
- if let Ok(new_output) = SlashCommandOutput::from_event_stream(new_output).await
- {
- if let Some(file_command_range) = new_output.sections.first() {
- let new_text = &new_output.text[file_command_range.range.clone()];
- if old_text.chars().ne(new_text.chars()) {
- changes_detected = true;
- output.sections.extend(new_output.sections.into_iter().map(
- |section| SlashCommandOutputSection {
- range: output.text.len() + section.range.start
- ..output.text.len() + section.range.end,
- icon: section.icon,
- label: section.label,
- metadata: section.metadata,
- },
- ));
- output.text.push_str(&new_output.text);
- }
- }
+ if let Ok(new_output) = new_output
+ && let Ok(new_output) = SlashCommandOutput::from_event_stream(new_output).await
+ && let Some(file_command_range) = new_output.sections.first()
+ {
+ let new_text = &new_output.text[file_command_range.range.clone()];
+ if old_text.chars().ne(new_text.chars()) {
+ changes_detected = true;
+ output
+ .sections
+ .extend(new_output.sections.into_iter().map(|section| {
+ SlashCommandOutputSection {
+ range: output.text.len() + section.range.start
+ ..output.text.len() + section.range.end,
+ icon: section.icon,
+ label: section.label,
+ metadata: section.metadata,
+ }
+ }));
+ output.text.push_str(&new_output.text);
}
}
}
anyhow::ensure!(changes_detected, "no new changes detected");
- Ok(output.to_event_stream())
+ Ok(output.into_event_stream())
})
}
}
@@ -44,7 +44,7 @@ impl DiagnosticsSlashCommand {
score: 0.,
positions: Vec::new(),
worktree_id: entry.worktree_id.to_usize(),
- path: entry.path.clone(),
+ path: entry.path,
path_prefix: path_prefix.clone(),
is_dir: false, // Diagnostics can't be produced for directories
distance_to_relative_ancestor: 0,
@@ -61,7 +61,7 @@ impl DiagnosticsSlashCommand {
snapshot: worktree.snapshot(),
include_ignored: worktree
.root_entry()
- .map_or(false, |entry| entry.is_ignored),
+ .is_some_and(|entry| entry.is_ignored),
include_root_name: true,
candidates: project::Candidates::Entries,
}
@@ -189,7 +189,7 @@ impl SlashCommand for DiagnosticsSlashCommand {
window.spawn(cx, async move |_| {
task.await?
- .map(|output| output.to_event_stream())
+ .map(|output| output.into_event_stream())
.context("No diagnostics found")
})
}
@@ -249,7 +249,7 @@ fn collect_diagnostics(
let worktree = worktree.read(cx);
let worktree_root_path = Path::new(worktree.root_name());
let relative_path = path.strip_prefix(worktree_root_path).ok()?;
- worktree.absolutize(&relative_path).ok()
+ worktree.absolutize(relative_path).ok()
})
})
.is_some()
@@ -280,10 +280,10 @@ fn collect_diagnostics(
let mut project_summary = DiagnosticSummary::default();
for (project_path, path, summary) in diagnostic_summaries {
- if let Some(path_matcher) = &options.path_matcher {
- if !path_matcher.is_match(&path) {
- continue;
- }
+ if let Some(path_matcher) = &options.path_matcher
+ && !path_matcher.is_match(&path)
+ {
+ continue;
}
project_summary.error_count += summary.error_count;
@@ -365,7 +365,7 @@ pub fn collect_buffer_diagnostics(
) {
for (_, group) in snapshot.diagnostic_groups(None) {
let entry = &group.entries[group.primary_ix];
- collect_diagnostic(output, entry, &snapshot, include_warnings)
+ collect_diagnostic(output, entry, snapshot, include_warnings)
}
}
@@ -396,7 +396,7 @@ fn collect_diagnostic(
let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE);
let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1;
let excerpt_range =
- Point::new(start_row, 0).to_offset(&snapshot)..Point::new(end_row, 0).to_offset(&snapshot);
+ Point::new(start_row, 0).to_offset(snapshot)..Point::new(end_row, 0).to_offset(snapshot);
output.text.push_str("```");
if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) {
@@ -1,543 +0,0 @@
-use std::path::Path;
-use std::sync::Arc;
-use std::sync::atomic::AtomicBool;
-use std::time::Duration;
-
-use anyhow::{Context as _, Result, anyhow, bail};
-use assistant_slash_command::{
- ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
- SlashCommandResult,
-};
-use gpui::{App, BackgroundExecutor, Entity, Task, WeakEntity};
-use indexed_docs::{
- DocsDotRsProvider, IndexedDocsRegistry, IndexedDocsStore, LocalRustdocProvider, PackageName,
- ProviderId,
-};
-use language::{BufferSnapshot, LspAdapterDelegate};
-use project::{Project, ProjectPath};
-use ui::prelude::*;
-use util::{ResultExt, maybe};
-use workspace::Workspace;
-
-pub struct DocsSlashCommand;
-
-impl DocsSlashCommand {
- pub const NAME: &'static str = "docs";
-
- fn path_to_cargo_toml(project: Entity<Project>, cx: &mut App) -> Option<Arc<Path>> {
- let worktree = project.read(cx).worktrees(cx).next()?;
- let worktree = worktree.read(cx);
- let entry = worktree.entry_for_path("Cargo.toml")?;
- let path = ProjectPath {
- worktree_id: worktree.id(),
- path: entry.path.clone(),
- };
- Some(Arc::from(
- project.read(cx).absolute_path(&path, cx)?.as_path(),
- ))
- }
-
- /// Ensures that the indexed doc providers for Rust are registered.
- ///
- /// Ideally we would do this sooner, but we need to wait until we're able to
- /// access the workspace so we can read the project.
- fn ensure_rust_doc_providers_are_registered(
- &self,
- workspace: Option<WeakEntity<Workspace>>,
- cx: &mut App,
- ) {
- let indexed_docs_registry = IndexedDocsRegistry::global(cx);
- if indexed_docs_registry
- .get_provider_store(LocalRustdocProvider::id())
- .is_none()
- {
- let index_provider_deps = maybe!({
- let workspace = workspace
- .as_ref()
- .context("no workspace")?
- .upgrade()
- .context("workspace dropped")?;
- let project = workspace.read(cx).project().clone();
- let fs = project.read(cx).fs().clone();
- let cargo_workspace_root = Self::path_to_cargo_toml(project, cx)
- .and_then(|path| path.parent().map(|path| path.to_path_buf()))
- .context("no Cargo workspace root found")?;
-
- anyhow::Ok((fs, cargo_workspace_root))
- });
-
- if let Some((fs, cargo_workspace_root)) = index_provider_deps.log_err() {
- indexed_docs_registry.register_provider(Box::new(LocalRustdocProvider::new(
- fs,
- cargo_workspace_root,
- )));
- }
- }
-
- if indexed_docs_registry
- .get_provider_store(DocsDotRsProvider::id())
- .is_none()
- {
- let http_client = maybe!({
- let workspace = workspace
- .as_ref()
- .context("no workspace")?
- .upgrade()
- .context("workspace was dropped")?;
- let project = workspace.read(cx).project().clone();
- anyhow::Ok(project.read(cx).client().http_client())
- });
-
- if let Some(http_client) = http_client.log_err() {
- indexed_docs_registry
- .register_provider(Box::new(DocsDotRsProvider::new(http_client)));
- }
- }
- }
-
- /// Runs just-in-time indexing for a given package, in case the slash command
- /// is run without any entries existing in the index.
- fn run_just_in_time_indexing(
- store: Arc<IndexedDocsStore>,
- key: String,
- package: PackageName,
- executor: BackgroundExecutor,
- ) -> Task<()> {
- executor.clone().spawn(async move {
- let (prefix, needs_full_index) = if let Some((prefix, _)) = key.split_once('*') {
- // If we have a wildcard in the search, we want to wait until
- // we've completely finished indexing so we get a full set of
- // results for the wildcard.
- (prefix.to_string(), true)
- } else {
- (key, false)
- };
-
- // If we already have some entries, we assume that we've indexed the package before
- // and don't need to do it again.
- let has_any_entries = store
- .any_with_prefix(prefix.clone())
- .await
- .unwrap_or_default();
- if has_any_entries {
- return ();
- };
-
- let index_task = store.clone().index(package.clone());
-
- if needs_full_index {
- _ = index_task.await;
- } else {
- loop {
- executor.timer(Duration::from_millis(200)).await;
-
- if store
- .any_with_prefix(prefix.clone())
- .await
- .unwrap_or_default()
- || !store.is_indexing(&package)
- {
- break;
- }
- }
- }
- })
- }
-}
-
-impl SlashCommand for DocsSlashCommand {
- fn name(&self) -> String {
- Self::NAME.into()
- }
-
- fn description(&self) -> String {
- "insert docs".into()
- }
-
- fn menu_text(&self) -> String {
- "Insert Documentation".into()
- }
-
- fn requires_argument(&self) -> bool {
- true
- }
-
- fn complete_argument(
- self: Arc<Self>,
- arguments: &[String],
- _cancel: Arc<AtomicBool>,
- workspace: Option<WeakEntity<Workspace>>,
- _: &mut Window,
- cx: &mut App,
- ) -> Task<Result<Vec<ArgumentCompletion>>> {
- self.ensure_rust_doc_providers_are_registered(workspace, cx);
-
- let indexed_docs_registry = IndexedDocsRegistry::global(cx);
- let args = DocsSlashCommandArgs::parse(arguments);
- let store = args
- .provider()
- .context("no docs provider specified")
- .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
- cx.background_spawn(async move {
- fn build_completions(items: Vec<String>) -> Vec<ArgumentCompletion> {
- items
- .into_iter()
- .map(|item| ArgumentCompletion {
- label: item.clone().into(),
- new_text: item.to_string(),
- after_completion: assistant_slash_command::AfterCompletion::Run,
- replace_previous_arguments: false,
- })
- .collect()
- }
-
- match args {
- DocsSlashCommandArgs::NoProvider => {
- let providers = indexed_docs_registry.list_providers();
- if providers.is_empty() {
- return Ok(vec![ArgumentCompletion {
- label: "No available docs providers.".into(),
- new_text: String::new(),
- after_completion: false.into(),
- replace_previous_arguments: false,
- }]);
- }
-
- Ok(providers
- .into_iter()
- .map(|provider| ArgumentCompletion {
- label: provider.to_string().into(),
- new_text: provider.to_string(),
- after_completion: false.into(),
- replace_previous_arguments: false,
- })
- .collect())
- }
- DocsSlashCommandArgs::SearchPackageDocs {
- provider,
- package,
- index,
- } => {
- let store = store?;
-
- if index {
- // We don't need to hold onto this task, as the `IndexedDocsStore` will hold it
- // until it completes.
- drop(store.clone().index(package.as_str().into()));
- }
-
- let suggested_packages = store.clone().suggest_packages().await?;
- let search_results = store.search(package).await;
-
- let mut items = build_completions(search_results);
- let workspace_crate_completions = suggested_packages
- .into_iter()
- .filter(|package_name| {
- !items
- .iter()
- .any(|item| item.label.text() == package_name.as_ref())
- })
- .map(|package_name| ArgumentCompletion {
- label: format!("{package_name} (unindexed)").into(),
- new_text: format!("{package_name}"),
- after_completion: true.into(),
- replace_previous_arguments: false,
- })
- .collect::<Vec<_>>();
- items.extend(workspace_crate_completions);
-
- if items.is_empty() {
- return Ok(vec![ArgumentCompletion {
- label: format!(
- "Enter a {package_term} name.",
- package_term = package_term(&provider)
- )
- .into(),
- new_text: provider.to_string(),
- after_completion: false.into(),
- replace_previous_arguments: false,
- }]);
- }
-
- Ok(items)
- }
- DocsSlashCommandArgs::SearchItemDocs { item_path, .. } => {
- let store = store?;
- let items = store.search(item_path).await;
- Ok(build_completions(items))
- }
- }
- })
- }
-
- fn run(
- self: Arc<Self>,
- arguments: &[String],
- _context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
- _context_buffer: BufferSnapshot,
- _workspace: WeakEntity<Workspace>,
- _delegate: Option<Arc<dyn LspAdapterDelegate>>,
- _: &mut Window,
- cx: &mut App,
- ) -> Task<SlashCommandResult> {
- if arguments.is_empty() {
- return Task::ready(Err(anyhow!("missing an argument")));
- };
-
- let args = DocsSlashCommandArgs::parse(arguments);
- let executor = cx.background_executor().clone();
- let task = cx.background_spawn({
- let store = args
- .provider()
- .context("no docs provider specified")
- .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
- async move {
- let (provider, key) = match args.clone() {
- DocsSlashCommandArgs::NoProvider => bail!("no docs provider specified"),
- DocsSlashCommandArgs::SearchPackageDocs {
- provider, package, ..
- } => (provider, package),
- DocsSlashCommandArgs::SearchItemDocs {
- provider,
- item_path,
- ..
- } => (provider, item_path),
- };
-
- if key.trim().is_empty() {
- bail!(
- "no {package_term} name provided",
- package_term = package_term(&provider)
- );
- }
-
- let store = store?;
-
- if let Some(package) = args.package() {
- Self::run_just_in_time_indexing(store.clone(), key.clone(), package, executor)
- .await;
- }
-
- let (text, ranges) = if let Some((prefix, _)) = key.split_once('*') {
- let docs = store.load_many_by_prefix(prefix.to_string()).await?;
-
- let mut text = String::new();
- let mut ranges = Vec::new();
-
- for (key, docs) in docs {
- let prev_len = text.len();
-
- text.push_str(&docs.0);
- text.push_str("\n");
- ranges.push((key, prev_len..text.len()));
- text.push_str("\n");
- }
-
- (text, ranges)
- } else {
- let item_docs = store.load(key.clone()).await?;
- let text = item_docs.to_string();
- let range = 0..text.len();
-
- (text, vec![(key, range)])
- };
-
- anyhow::Ok((provider, text, ranges))
- }
- });
-
- cx.foreground_executor().spawn(async move {
- let (provider, text, ranges) = task.await?;
- Ok(SlashCommandOutput {
- text,
- sections: ranges
- .into_iter()
- .map(|(key, range)| SlashCommandOutputSection {
- range,
- icon: IconName::FileDoc,
- label: format!("docs ({provider}): {key}",).into(),
- metadata: None,
- })
- .collect(),
- run_commands_in_text: false,
- }
- .to_event_stream())
- })
- }
-}
-
-fn is_item_path_delimiter(char: char) -> bool {
- !char.is_alphanumeric() && char != '-' && char != '_'
-}
-
-#[derive(Debug, PartialEq, Clone)]
-pub enum DocsSlashCommandArgs {
- NoProvider,
- SearchPackageDocs {
- provider: ProviderId,
- package: String,
- index: bool,
- },
- SearchItemDocs {
- provider: ProviderId,
- package: String,
- item_path: String,
- },
-}
-
-impl DocsSlashCommandArgs {
- pub fn parse(arguments: &[String]) -> Self {
- let Some(provider) = arguments
- .get(0)
- .cloned()
- .filter(|arg| !arg.trim().is_empty())
- else {
- return Self::NoProvider;
- };
- let provider = ProviderId(provider.into());
- let Some(argument) = arguments.get(1) else {
- return Self::NoProvider;
- };
-
- if let Some((package, rest)) = argument.split_once(is_item_path_delimiter) {
- if rest.trim().is_empty() {
- Self::SearchPackageDocs {
- provider,
- package: package.to_owned(),
- index: true,
- }
- } else {
- Self::SearchItemDocs {
- provider,
- package: package.to_owned(),
- item_path: argument.to_owned(),
- }
- }
- } else {
- Self::SearchPackageDocs {
- provider,
- package: argument.to_owned(),
- index: false,
- }
- }
- }
-
- pub fn provider(&self) -> Option<ProviderId> {
- match self {
- Self::NoProvider => None,
- Self::SearchPackageDocs { provider, .. } | Self::SearchItemDocs { provider, .. } => {
- Some(provider.clone())
- }
- }
- }
-
- pub fn package(&self) -> Option<PackageName> {
- match self {
- Self::NoProvider => None,
- Self::SearchPackageDocs { package, .. } | Self::SearchItemDocs { package, .. } => {
- Some(package.as_str().into())
- }
- }
- }
-}
-
-/// Returns the term used to refer to a package.
-fn package_term(provider: &ProviderId) -> &'static str {
- if provider == &DocsDotRsProvider::id() || provider == &LocalRustdocProvider::id() {
- return "crate";
- }
-
- "package"
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_parse_docs_slash_command_args() {
- assert_eq!(
- DocsSlashCommandArgs::parse(&["".to_string()]),
- DocsSlashCommandArgs::NoProvider
- );
- assert_eq!(
- DocsSlashCommandArgs::parse(&["rustdoc".to_string()]),
- DocsSlashCommandArgs::NoProvider
- );
-
- assert_eq!(
- DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "".to_string()]),
- DocsSlashCommandArgs::SearchPackageDocs {
- provider: ProviderId("rustdoc".into()),
- package: "".into(),
- index: false
- }
- );
- assert_eq!(
- DocsSlashCommandArgs::parse(&["gleam".to_string(), "".to_string()]),
- DocsSlashCommandArgs::SearchPackageDocs {
- provider: ProviderId("gleam".into()),
- package: "".into(),
- index: false
- }
- );
-
- assert_eq!(
- DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "gpui".to_string()]),
- DocsSlashCommandArgs::SearchPackageDocs {
- provider: ProviderId("rustdoc".into()),
- package: "gpui".into(),
- index: false,
- }
- );
- assert_eq!(
- DocsSlashCommandArgs::parse(&["gleam".to_string(), "gleam_stdlib".to_string()]),
- DocsSlashCommandArgs::SearchPackageDocs {
- provider: ProviderId("gleam".into()),
- package: "gleam_stdlib".into(),
- index: false
- }
- );
-
- // Adding an item path delimiter indicates we can start indexing.
- assert_eq!(
- DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "gpui:".to_string()]),
- DocsSlashCommandArgs::SearchPackageDocs {
- provider: ProviderId("rustdoc".into()),
- package: "gpui".into(),
- index: true,
- }
- );
- assert_eq!(
- DocsSlashCommandArgs::parse(&["gleam".to_string(), "gleam_stdlib/".to_string()]),
- DocsSlashCommandArgs::SearchPackageDocs {
- provider: ProviderId("gleam".into()),
- package: "gleam_stdlib".into(),
- index: true
- }
- );
-
- assert_eq!(
- DocsSlashCommandArgs::parse(&[
- "rustdoc".to_string(),
- "gpui::foo::bar::Baz".to_string()
- ]),
- DocsSlashCommandArgs::SearchItemDocs {
- provider: ProviderId("rustdoc".into()),
- package: "gpui".into(),
- item_path: "gpui::foo::bar::Baz".into()
- }
- );
- assert_eq!(
- DocsSlashCommandArgs::parse(&[
- "gleam".to_string(),
- "gleam_stdlib/gleam/int".to_string()
- ]),
- DocsSlashCommandArgs::SearchItemDocs {
- provider: ProviderId("gleam".into()),
- package: "gleam_stdlib".into(),
- item_path: "gleam_stdlib/gleam/int".into()
- }
- );
- }
-}
@@ -112,7 +112,7 @@ impl SlashCommand for FetchSlashCommand {
}
fn icon(&self) -> IconName {
- IconName::Globe
+ IconName::ToolWeb
}
fn menu_text(&self) -> String {
@@ -171,13 +171,13 @@ impl SlashCommand for FetchSlashCommand {
text,
sections: vec![SlashCommandOutputSection {
range,
- icon: IconName::Globe,
+ icon: IconName::ToolWeb,
label: format!("fetch {}", url).into(),
metadata: None,
}],
run_commands_in_text: false,
}
- .to_event_stream())
+ .into_event_stream())
})
}
}
@@ -92,7 +92,7 @@ impl FileSlashCommand {
snapshot: worktree.snapshot(),
include_ignored: worktree
.root_entry()
- .map_or(false, |entry| entry.is_ignored),
+ .is_some_and(|entry| entry.is_ignored),
include_root_name: true,
candidates: project::Candidates::Entries,
}
@@ -223,7 +223,7 @@ fn collect_files(
cx: &mut App,
) -> impl Stream<Item = Result<SlashCommandEvent>> + use<> {
let Ok(matchers) = glob_inputs
- .into_iter()
+ .iter()
.map(|glob_input| {
custom_path_matcher::PathMatcher::new(&[glob_input.to_owned()])
.with_context(|| format!("invalid path {glob_input}"))
@@ -371,7 +371,7 @@ fn collect_files(
&mut output,
)
.log_err();
- let mut buffer_events = output.to_event_stream();
+ let mut buffer_events = output.into_event_stream();
while let Some(event) = buffer_events.next().await {
events_tx.unbounded_send(event)?;
}
@@ -379,7 +379,7 @@ fn collect_files(
}
}
- while let Some(_) = directory_stack.pop() {
+ while directory_stack.pop().is_some() {
events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?;
}
}
@@ -491,7 +491,7 @@ mod custom_path_matcher {
impl PathMatcher {
pub fn new(globs: &[String]) -> Result<Self, globset::Error> {
let globs = globs
- .into_iter()
+ .iter()
.map(|glob| Glob::new(&SanitizedPath::from(glob).to_glob_string()))
.collect::<Result<Vec<_>, _>>()?;
let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
@@ -536,7 +536,7 @@ mod custom_path_matcher {
let path_str = path.to_string_lossy();
let separator = std::path::MAIN_SEPARATOR_STR;
if path_str.ends_with(separator) {
- return false;
+ false
} else {
self.glob.is_match(path_str.to_string() + separator)
}
@@ -66,6 +66,6 @@ impl SlashCommand for NowSlashCommand {
}],
run_commands_in_text: false,
}
- .to_event_stream()))
+ .into_event_stream()))
}
}
@@ -80,7 +80,7 @@ impl SlashCommand for PromptSlashCommand {
};
let store = PromptStore::global(cx);
- let title = SharedString::from(title.clone());
+ let title = SharedString::from(title);
let prompt = cx.spawn({
let title = title.clone();
async move |cx| {
@@ -117,7 +117,7 @@ impl SlashCommand for PromptSlashCommand {
}],
run_commands_in_text: true,
}
- .to_event_stream())
+ .into_event_stream())
})
}
}
@@ -92,7 +92,7 @@ impl SlashCommand for OutlineSlashCommand {
text: outline_text,
run_commands_in_text: false,
}
- .to_event_stream())
+ .into_event_stream())
})
});
@@ -157,7 +157,7 @@ impl SlashCommand for TabSlashCommand {
for (full_path, buffer, _) in tab_items_search.await? {
append_buffer_to_output(&buffer, full_path.as_deref(), &mut output).log_err();
}
- Ok(output.to_event_stream())
+ Ok(output.into_event_stream())
})
}
}
@@ -195,16 +195,14 @@ fn tab_items_for_queries(
}
for editor in workspace.items_of_type::<Editor>(cx) {
- if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() {
- if let Some(timestamp) =
+ if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton()
+ && let Some(timestamp) =
timestamps_by_entity_id.get(&editor.entity_id())
- {
- if visited_buffers.insert(buffer.read(cx).remote_id()) {
- let snapshot = buffer.read(cx).snapshot();
- let full_path = snapshot.resolve_file_path(cx, true);
- open_buffers.push((full_path, snapshot, *timestamp));
- }
- }
+ && visited_buffers.insert(buffer.read(cx).remote_id())
+ {
+ let snapshot = buffer.read(cx).snapshot();
+ let full_path = snapshot.resolve_file_path(cx, true);
+ open_buffers.push((full_path, snapshot, *timestamp));
}
}
@@ -12,12 +12,10 @@ workspace = true
path = "src/assistant_tool.rs"
[dependencies]
+action_log.workspace = true
anyhow.workspace = true
-buffer_diff.workspace = true
-clock.workspace = true
collections.workspace = true
derive_more.workspace = true
-futures.workspace = true
gpui.workspace = true
icons.workspace = true
language.workspace = true
@@ -30,7 +28,6 @@ serde.workspace = true
serde_json.workspace = true
text.workspace = true
util.workspace = true
-watch.workspace = true
workspace.workspace = true
workspace-hack.workspace = true
@@ -40,6 +37,7 @@ collections = { workspace = true, features = ["test-support"] }
clock = { workspace = true, features = ["test-support"] }
ctor.workspace = true
gpui = { workspace = true, features = ["test-support"] }
+indoc.workspace = true
language = { workspace = true, features = ["test-support"] }
language_model = { workspace = true, features = ["test-support"] }
log.workspace = true
@@ -1,4 +1,3 @@
-mod action_log;
pub mod outline;
mod tool_registry;
mod tool_schema;
@@ -10,6 +9,7 @@ use std::fmt::Formatter;
use std::ops::Deref;
use std::sync::Arc;
+use action_log::ActionLog;
use anyhow::Result;
use gpui::AnyElement;
use gpui::AnyWindowHandle;
@@ -25,7 +25,6 @@ use language_model::LanguageModelToolSchemaFormat;
use project::Project;
use workspace::Workspace;
-pub use crate::action_log::*;
pub use crate::tool_registry::*;
pub use crate::tool_schema::*;
pub use crate::tool_working_set::*;
@@ -216,7 +215,12 @@ pub trait Tool: 'static + Send + Sync {
/// Returns true if the tool needs the users's confirmation
/// before having permission to run.
- fn needs_confirmation(&self, input: &serde_json::Value, cx: &App) -> bool;
+ fn needs_confirmation(
+ &self,
+ input: &serde_json::Value,
+ project: &Entity<Project>,
+ cx: &App,
+ ) -> bool;
/// Returns true if the tool may perform edits.
fn may_perform_edits(&self) -> bool;
@@ -1,4 +1,4 @@
-use crate::ActionLog;
+use action_log::ActionLog;
use anyhow::{Context as _, Result};
use gpui::{AsyncApp, Entity};
use language::{OutlineItem, ParseStatus};
@@ -24,16 +24,16 @@ pub fn adapt_schema_to_format(
fn preprocess_json_schema(json: &mut Value) -> Result<()> {
// `additionalProperties` defaults to `false` unless explicitly specified.
// This prevents models from hallucinating tool parameters.
- if let Value::Object(obj) = json {
- if matches!(obj.get("type"), Some(Value::String(s)) if s == "object") {
- if !obj.contains_key("additionalProperties") {
- obj.insert("additionalProperties".to_string(), Value::Bool(false));
- }
+ if let Value::Object(obj) = json
+ && matches!(obj.get("type"), Some(Value::String(s)) if s == "object")
+ {
+ if !obj.contains_key("additionalProperties") {
+ obj.insert("additionalProperties".to_string(), Value::Bool(false));
+ }
- // OpenAI API requires non-missing `properties`
- if !obj.contains_key("properties") {
- obj.insert("properties".to_string(), Value::Object(Default::default()));
- }
+ // OpenAI API requires non-missing `properties`
+ if !obj.contains_key("properties") {
+ obj.insert("properties".to_string(), Value::Object(Default::default()));
}
}
Ok(())
@@ -59,10 +59,10 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> {
("optional", |value| value.is_boolean()),
];
for (key, predicate) in KEYS_TO_REMOVE {
- if let Some(value) = obj.get(key) {
- if predicate(value) {
- obj.remove(key);
- }
+ if let Some(value) = obj.get(key)
+ && predicate(value)
+ {
+ obj.remove(key);
}
}
@@ -77,12 +77,12 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> {
}
// Handle oneOf -> anyOf conversion
- if let Some(subschemas) = obj.get_mut("oneOf") {
- if subschemas.is_array() {
- let subschemas_clone = subschemas.clone();
- obj.remove("oneOf");
- obj.insert("anyOf".to_string(), subschemas_clone);
- }
+ if let Some(subschemas) = obj.get_mut("oneOf")
+ && subschemas.is_array()
+ {
+ let subschemas_clone = subschemas.clone();
+ obj.remove("oneOf");
+ obj.insert("anyOf".to_string(), subschemas_clone);
}
// Recursively process all nested objects and arrays
@@ -156,13 +156,13 @@ fn resolve_context_server_tool_name_conflicts(
if duplicated_tool_names.is_empty() {
return context_server_tools
- .into_iter()
+ .iter()
.map(|tool| (resolve_tool_name(tool).into(), tool.clone()))
.collect();
}
context_server_tools
- .into_iter()
+ .iter()
.filter_map(|tool| {
let mut tool_name = resolve_tool_name(tool);
if !duplicated_tool_names.contains(&tool_name) {
@@ -375,7 +375,12 @@ mod tests {
false
}
- fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool {
+ fn needs_confirmation(
+ &self,
+ _input: &serde_json::Value,
+ _project: &Entity<Project>,
+ _cx: &App,
+ ) -> bool {
true
}
@@ -15,14 +15,18 @@ path = "src/assistant_tools.rs"
eval = []
[dependencies]
+action_log.workspace = true
agent_settings.workspace = true
anyhow.workspace = true
assistant_tool.workspace = true
buffer_diff.workspace = true
chrono.workspace = true
+client.workspace = true
+cloud_llm_client.workspace = true
collections.workspace = true
component.workspace = true
derive_more.workspace = true
+diffy = "0.4.2"
editor.workspace = true
feature_flags.workspace = true
futures.workspace = true
@@ -62,7 +66,6 @@ web_search.workspace = true
which.workspace = true
workspace-hack.workspace = true
workspace.workspace = true
-zed_llm_client.workspace = true
[dev-dependencies]
lsp = { workspace = true, features = ["test-support"] }
@@ -2,7 +2,7 @@ mod copy_path_tool;
mod create_directory_tool;
mod delete_path_tool;
mod diagnostics_tool;
-mod edit_agent;
+pub mod edit_agent;
mod edit_file_tool;
mod fetch_tool;
mod find_path_tool;
@@ -14,20 +14,19 @@ mod open_tool;
mod project_notifications_tool;
mod read_file_tool;
mod schema;
-mod templates;
+pub mod templates;
mod terminal_tool;
mod thinking_tool;
mod ui;
mod web_search_tool;
-use std::sync::Arc;
-
use assistant_tool::ToolRegistry;
use copy_path_tool::CopyPathTool;
use gpui::{App, Entity};
use http_client::HttpClientWithUrl;
use language_model::LanguageModelRegistry;
use move_path_tool::MovePathTool;
+use std::sync::Arc;
use web_search_tool::WebSearchTool;
pub(crate) use templates::*;
@@ -37,13 +36,12 @@ use crate::delete_path_tool::DeletePathTool;
use crate::diagnostics_tool::DiagnosticsTool;
use crate::edit_file_tool::EditFileTool;
use crate::fetch_tool::FetchTool;
-use crate::find_path_tool::FindPathTool;
use crate::list_directory_tool::ListDirectoryTool;
use crate::now_tool::NowTool;
use crate::thinking_tool::ThinkingTool;
pub use edit_file_tool::{EditFileMode, EditFileToolInput};
-pub use find_path_tool::FindPathToolInput;
+pub use find_path_tool::*;
pub use grep_tool::{GrepTool, GrepToolInput};
pub use open_tool::OpenTool;
pub use project_notifications_tool::ProjectNotificationsTool;
@@ -74,11 +72,10 @@ pub fn init(http_client: Arc<HttpClientWithUrl>, cx: &mut App) {
register_web_search_tool(&LanguageModelRegistry::global(cx), cx);
cx.subscribe(
&LanguageModelRegistry::global(cx),
- move |registry, event, cx| match event {
- language_model::Event::DefaultModelChanged => {
+ move |registry, event, cx| {
+ if let language_model::Event::DefaultModelChanged = event {
register_web_search_tool(®istry, cx);
}
- _ => {}
},
)
.detach();
@@ -88,7 +85,7 @@ fn register_web_search_tool(registry: &Entity<LanguageModelRegistry>, cx: &mut A
let using_zed_provider = registry
.read(cx)
.default_model()
- .map_or(false, |default| default.is_provided_by_zed());
+ .is_some_and(|default| default.is_provided_by_zed());
if using_zed_provider {
ToolRegistry::global(cx).register_tool(WebSearchTool);
} else {
@@ -1,6 +1,7 @@
use crate::schema::json_schema_for;
+use action_log::ActionLog;
use anyhow::{Context as _, Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolResult};
+use assistant_tool::{Tool, ToolResult};
use gpui::AnyWindowHandle;
use gpui::{App, AppContext, Entity, Task};
use language_model::LanguageModel;
@@ -44,7 +45,7 @@ impl Tool for CopyPathTool {
"copy_path".into()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
@@ -1,6 +1,7 @@
use crate::schema::json_schema_for;
+use action_log::ActionLog;
use anyhow::{Context as _, Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolResult};
+use assistant_tool::{Tool, ToolResult};
use gpui::AnyWindowHandle;
use gpui::{App, Entity, Task};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
@@ -37,7 +38,7 @@ impl Tool for CreateDirectoryTool {
include_str!("./create_directory_tool/description.md").into()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
@@ -1,6 +1,7 @@
use crate::schema::json_schema_for;
+use action_log::ActionLog;
use anyhow::{Context as _, Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolResult};
+use assistant_tool::{Tool, ToolResult};
use futures::{SinkExt, StreamExt, channel::mpsc};
use gpui::{AnyWindowHandle, App, AppContext, Entity, Task};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
@@ -33,7 +34,7 @@ impl Tool for DeletePathTool {
"delete_path".into()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
@@ -1,6 +1,7 @@
use crate::schema::json_schema_for;
+use action_log::ActionLog;
use anyhow::{Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolResult};
+use assistant_tool::{Tool, ToolResult};
use gpui::{AnyWindowHandle, App, Entity, Task};
use language::{DiagnosticSeverity, OffsetRangeExt};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
@@ -46,7 +47,7 @@ impl Tool for DiagnosticsTool {
"diagnostics".into()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
@@ -85,7 +86,7 @@ impl Tool for DiagnosticsTool {
input: serde_json::Value,
_request: Arc<LanguageModelRequest>,
project: Entity<Project>,
- action_log: Entity<ActionLog>,
+ _action_log: Entity<ActionLog>,
_model: Arc<dyn LanguageModel>,
_window: Option<AnyWindowHandle>,
cx: &mut App,
@@ -158,10 +159,6 @@ impl Tool for DiagnosticsTool {
}
}
- action_log.update(cx, |action_log, _cx| {
- action_log.checked_project_diagnostics();
- });
-
if has_diagnostics {
Task::ready(Ok(output.into())).into()
} else {
@@ -5,8 +5,9 @@ mod evals;
mod streaming_fuzzy_matcher;
use crate::{Template, Templates};
+use action_log::ActionLog;
use anyhow::Result;
-use assistant_tool::ActionLog;
+use cloud_llm_client::CompletionIntent;
use create_file_parser::{CreateFileParser, CreateFileParserEvent};
pub use edit_parser::EditFormat;
use edit_parser::{EditParser, EditParserEvent, EditParserMetrics};
@@ -28,8 +29,6 @@ use serde::{Deserialize, Serialize};
use std::{cmp, iter, mem, ops::Range, path::PathBuf, pin::Pin, sync::Arc, task::Poll};
use streaming_diff::{CharOperation, StreamingDiff};
use streaming_fuzzy_matcher::StreamingFuzzyMatcher;
-use util::debug_panic;
-use zed_llm_client::CompletionIntent;
#[derive(Serialize)]
struct CreateFilePromptTemplate {
@@ -66,7 +65,7 @@ pub enum EditAgentOutputEvent {
ResolvingEditRange(Range<Anchor>),
UnresolvedEditRange,
AmbiguousEditRange(Vec<Range<usize>>),
- Edited,
+ Edited(Range<Anchor>),
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
@@ -179,7 +178,9 @@ impl EditAgent {
)
});
output_events_tx
- .unbounded_send(EditAgentOutputEvent::Edited)
+ .unbounded_send(EditAgentOutputEvent::Edited(
+ language::Anchor::MIN..language::Anchor::MAX,
+ ))
.ok();
})?;
@@ -201,7 +202,9 @@ impl EditAgent {
});
})?;
output_events_tx
- .unbounded_send(EditAgentOutputEvent::Edited)
+ .unbounded_send(EditAgentOutputEvent::Edited(
+ language::Anchor::MIN..language::Anchor::MAX,
+ ))
.ok();
}
}
@@ -337,8 +340,8 @@ impl EditAgent {
// Edit the buffer and report edits to the action log as part of the
// same effect cycle, otherwise the edit will be reported as if the
// user made it.
- cx.update(|cx| {
- let max_edit_end = buffer.update(cx, |buffer, cx| {
+ let (min_edit_start, max_edit_end) = cx.update(|cx| {
+ let (min_edit_start, max_edit_end) = buffer.update(cx, |buffer, cx| {
buffer.edit(edits.iter().cloned(), None, cx);
let max_edit_end = buffer
.summaries_for_anchors::<Point, _>(
@@ -346,7 +349,16 @@ impl EditAgent {
)
.max()
.unwrap();
- buffer.anchor_before(max_edit_end)
+ let min_edit_start = buffer
+ .summaries_for_anchors::<Point, _>(
+ edits.iter().map(|(range, _)| &range.start),
+ )
+ .min()
+ .unwrap();
+ (
+ buffer.anchor_after(min_edit_start),
+ buffer.anchor_before(max_edit_end),
+ )
});
self.action_log
.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
@@ -359,9 +371,10 @@ impl EditAgent {
cx,
);
});
+ (min_edit_start, max_edit_end)
})?;
output_events
- .unbounded_send(EditAgentOutputEvent::Edited)
+ .unbounded_send(EditAgentOutputEvent::Edited(min_edit_start..max_edit_end))
.ok();
}
@@ -659,34 +672,30 @@ impl EditAgent {
cx: &mut AsyncApp,
) -> Result<BoxStream<'static, Result<String, LanguageModelCompletionError>>> {
let mut messages_iter = conversation.messages.iter_mut();
- if let Some(last_message) = messages_iter.next_back() {
- if last_message.role == Role::Assistant {
- let old_content_len = last_message.content.len();
- last_message
- .content
- .retain(|content| !matches!(content, MessageContent::ToolUse(_)));
- let new_content_len = last_message.content.len();
-
- // We just removed pending tool uses from the content of the
- // last message, so it doesn't make sense to cache it anymore
- // (e.g., the message will look very different on the next
- // request). Thus, we move the flag to the message prior to it,
- // as it will still be a valid prefix of the conversation.
- if old_content_len != new_content_len && last_message.cache {
- if let Some(prev_message) = messages_iter.next_back() {
- last_message.cache = false;
- prev_message.cache = true;
- }
- }
+ if let Some(last_message) = messages_iter.next_back()
+ && last_message.role == Role::Assistant
+ {
+ let old_content_len = last_message.content.len();
+ last_message
+ .content
+ .retain(|content| !matches!(content, MessageContent::ToolUse(_)));
+ let new_content_len = last_message.content.len();
+
+ // We just removed pending tool uses from the content of the
+ // last message, so it doesn't make sense to cache it anymore
+ // (e.g., the message will look very different on the next
+ // request). Thus, we move the flag to the message prior to it,
+ // as it will still be a valid prefix of the conversation.
+ if old_content_len != new_content_len
+ && last_message.cache
+ && let Some(prev_message) = messages_iter.next_back()
+ {
+ last_message.cache = false;
+ prev_message.cache = true;
+ }
- if last_message.content.is_empty() {
- conversation.messages.pop();
- }
- } else {
- debug_panic!(
- "Last message must be an Assistant tool calling! Got {:?}",
- last_message.content
- );
+ if last_message.content.is_empty() {
+ conversation.messages.pop();
}
}
@@ -761,6 +770,7 @@ mod tests {
use gpui::{AppContext, TestAppContext};
use indoc::indoc;
use language_model::fake_provider::FakeLanguageModel;
+ use pretty_assertions::assert_matches;
use project::{AgentLocation, Project};
use rand::prelude::*;
use rand::rngs::StdRng;
@@ -962,7 +972,7 @@ mod tests {
);
cx.run_until_parked();
- model.stream_last_completion_response("<old_text>a");
+ model.send_last_completion_stream_text_chunk("<old_text>a");
cx.run_until_parked();
assert_eq!(drain_events(&mut events), vec![]);
assert_eq!(
@@ -974,7 +984,7 @@ mod tests {
None
);
- model.stream_last_completion_response("bc</old_text>");
+ model.send_last_completion_stream_text_chunk("bc</old_text>");
cx.run_until_parked();
assert_eq!(
drain_events(&mut events),
@@ -996,9 +1006,12 @@ mod tests {
})
);
- model.stream_last_completion_response("<new_text>abX");
+ model.send_last_completion_stream_text_chunk("<new_text>abX");
cx.run_until_parked();
- assert_eq!(drain_events(&mut events), [EditAgentOutputEvent::Edited]);
+ assert_matches!(
+ drain_events(&mut events).as_slice(),
+ [EditAgentOutputEvent::Edited(_)]
+ );
assert_eq!(
buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
"abXc\ndef\nghi\njkl"
@@ -1011,9 +1024,12 @@ mod tests {
})
);
- model.stream_last_completion_response("cY");
+ model.send_last_completion_stream_text_chunk("cY");
cx.run_until_parked();
- assert_eq!(drain_events(&mut events), [EditAgentOutputEvent::Edited]);
+ assert_matches!(
+ drain_events(&mut events).as_slice(),
+ [EditAgentOutputEvent::Edited { .. }]
+ );
assert_eq!(
buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
"abXcY\ndef\nghi\njkl"
@@ -1026,8 +1042,8 @@ mod tests {
})
);
- model.stream_last_completion_response("</new_text>");
- model.stream_last_completion_response("<old_text>hall");
+ model.send_last_completion_stream_text_chunk("</new_text>");
+ model.send_last_completion_stream_text_chunk("<old_text>hall");
cx.run_until_parked();
assert_eq!(drain_events(&mut events), vec![]);
assert_eq!(
@@ -1042,8 +1058,8 @@ mod tests {
})
);
- model.stream_last_completion_response("ucinated old</old_text>");
- model.stream_last_completion_response("<new_text>");
+ model.send_last_completion_stream_text_chunk("ucinated old</old_text>");
+ model.send_last_completion_stream_text_chunk("<new_text>");
cx.run_until_parked();
assert_eq!(
drain_events(&mut events),
@@ -1061,8 +1077,8 @@ mod tests {
})
);
- model.stream_last_completion_response("hallucinated new</new_");
- model.stream_last_completion_response("text>");
+ model.send_last_completion_stream_text_chunk("hallucinated new</new_");
+ model.send_last_completion_stream_text_chunk("text>");
cx.run_until_parked();
assert_eq!(drain_events(&mut events), vec![]);
assert_eq!(
@@ -1077,7 +1093,7 @@ mod tests {
})
);
- model.stream_last_completion_response("<old_text>\nghi\nj");
+ model.send_last_completion_stream_text_chunk("<old_text>\nghi\nj");
cx.run_until_parked();
assert_eq!(
drain_events(&mut events),
@@ -1099,8 +1115,8 @@ mod tests {
})
);
- model.stream_last_completion_response("kl</old_text>");
- model.stream_last_completion_response("<new_text>");
+ model.send_last_completion_stream_text_chunk("kl</old_text>");
+ model.send_last_completion_stream_text_chunk("<new_text>");
cx.run_until_parked();
assert_eq!(
drain_events(&mut events),
@@ -1122,11 +1138,11 @@ mod tests {
})
);
- model.stream_last_completion_response("GHI</new_text>");
+ model.send_last_completion_stream_text_chunk("GHI</new_text>");
cx.run_until_parked();
- assert_eq!(
- drain_events(&mut events),
- vec![EditAgentOutputEvent::Edited]
+ assert_matches!(
+ drain_events(&mut events).as_slice(),
+ [EditAgentOutputEvent::Edited { .. }]
);
assert_eq!(
buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
@@ -1171,9 +1187,9 @@ mod tests {
);
cx.run_until_parked();
- assert_eq!(
- drain_events(&mut events),
- vec![EditAgentOutputEvent::Edited]
+ assert_matches!(
+ drain_events(&mut events).as_slice(),
+ [EditAgentOutputEvent::Edited(_)]
);
assert_eq!(
buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
@@ -1189,9 +1205,9 @@ mod tests {
chunks_tx.unbounded_send("```\njkl\n").unwrap();
cx.run_until_parked();
- assert_eq!(
- drain_events(&mut events),
- vec![EditAgentOutputEvent::Edited]
+ assert_matches!(
+ drain_events(&mut events).as_slice(),
+ [EditAgentOutputEvent::Edited { .. }]
);
assert_eq!(
buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
@@ -1207,9 +1223,9 @@ mod tests {
chunks_tx.unbounded_send("mno\n").unwrap();
cx.run_until_parked();
- assert_eq!(
- drain_events(&mut events),
- vec![EditAgentOutputEvent::Edited]
+ assert_matches!(
+ drain_events(&mut events).as_slice(),
+ [EditAgentOutputEvent::Edited { .. }]
);
assert_eq!(
buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
@@ -1225,9 +1241,9 @@ mod tests {
chunks_tx.unbounded_send("pqr\n```").unwrap();
cx.run_until_parked();
- assert_eq!(
- drain_events(&mut events),
- vec![EditAgentOutputEvent::Edited]
+ assert_matches!(
+ drain_events(&mut events).as_slice(),
+ [EditAgentOutputEvent::Edited(_)],
);
assert_eq!(
buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
@@ -1367,7 +1383,9 @@ mod tests {
cx.background_spawn(async move {
for chunk in chunks {
executor.simulate_random_delay().await;
- model.as_fake().stream_last_completion_response(chunk);
+ model
+ .as_fake()
+ .send_last_completion_stream_text_chunk(chunk);
}
model.as_fake().end_last_completion_stream();
})
@@ -1,10 +1,11 @@
+use std::sync::OnceLock;
+
use regex::Regex;
use smallvec::SmallVec;
-use std::cell::LazyCell;
use util::debug_panic;
-const START_MARKER: LazyCell<Regex> = LazyCell::new(|| Regex::new(r"\n?```\S*\n").unwrap());
-const END_MARKER: LazyCell<Regex> = LazyCell::new(|| Regex::new(r"(^|\n)```\s*$").unwrap());
+static START_MARKER: OnceLock<Regex> = OnceLock::new();
+static END_MARKER: OnceLock<Regex> = OnceLock::new();
#[derive(Debug)]
pub enum CreateFileParserEvent {
@@ -43,10 +44,12 @@ impl CreateFileParser {
self.buffer.push_str(chunk);
let mut edit_events = SmallVec::new();
+ let start_marker_regex = START_MARKER.get_or_init(|| Regex::new(r"\n?```\S*\n").unwrap());
+ let end_marker_regex = END_MARKER.get_or_init(|| Regex::new(r"(^|\n)```\s*$").unwrap());
loop {
match &mut self.state {
ParserState::Pending => {
- if let Some(m) = START_MARKER.find(&self.buffer) {
+ if let Some(m) = start_marker_regex.find(&self.buffer) {
self.buffer.drain(..m.end());
self.state = ParserState::WithinText;
} else {
@@ -65,7 +68,7 @@ impl CreateFileParser {
break;
}
ParserState::Finishing => {
- if let Some(m) = END_MARKER.find(&self.buffer) {
+ if let Some(m) = end_marker_regex.find(&self.buffer) {
self.buffer.drain(m.start()..);
}
if !self.buffer.is_empty() {
@@ -12,6 +12,7 @@ use collections::HashMap;
use fs::FakeFs;
use futures::{FutureExt, future::LocalBoxFuture};
use gpui::{AppContext, TestAppContext, Timer};
+use http_client::StatusCode;
use indoc::{formatdoc, indoc};
use language_model::{
LanguageModelRegistry, LanguageModelRequestTool, LanguageModelToolResult,
@@ -1152,8 +1153,7 @@ impl EvalInput {
.expect("Conversation must end with an edit_file tool use")
.clone();
- let edit_file_input: EditFileToolInput =
- serde_json::from_value(tool_use.input.clone()).unwrap();
+ let edit_file_input: EditFileToolInput = serde_json::from_value(tool_use.input).unwrap();
EvalInput {
conversation,
@@ -1282,14 +1282,14 @@ impl EvalAssertion {
// Parse the score from the response
let re = regex::Regex::new(r"<score>(\d+)</score>").unwrap();
- if let Some(captures) = re.captures(&output) {
- if let Some(score_match) = captures.get(1) {
- let score = score_match.as_str().parse().unwrap_or(0);
- return Ok(EvalAssertionOutcome {
- score,
- message: Some(output),
- });
- }
+ if let Some(captures) = re.captures(&output)
+ && let Some(score_match) = captures.get(1)
+ {
+ let score = score_match.as_str().parse().unwrap_or(0);
+ return Ok(EvalAssertionOutcome {
+ score,
+ message: Some(output),
+ });
}
anyhow::bail!("No score found in response. Raw output: {output}");
@@ -1459,7 +1459,7 @@ impl EditAgentTest {
async fn new(cx: &mut TestAppContext) -> Self {
cx.executor().allow_parking();
- let fs = FakeFs::new(cx.executor().clone());
+ let fs = FakeFs::new(cx.executor());
cx.update(|cx| {
settings::init(cx);
gpui_tokio::init(cx);
@@ -1474,7 +1474,7 @@ impl EditAgentTest {
Project::init_settings(cx);
language::init(cx);
language_model::init(client.clone(), cx);
- language_models::init(user_store.clone(), client.clone(), cx);
+ language_models::init(user_store, client.clone(), cx);
crate::init(client.http_client(), cx);
});
@@ -1585,7 +1585,7 @@ impl EditAgentTest {
let has_system_prompt = eval
.conversation
.first()
- .map_or(false, |msg| msg.role == Role::System);
+ .is_some_and(|msg| msg.role == Role::System);
let messages = if has_system_prompt {
eval.conversation
} else {
@@ -1657,28 +1657,61 @@ impl EditAgentTest {
}
async fn retry_on_rate_limit<R>(mut request: impl AsyncFnMut() -> Result<R>) -> Result<R> {
+ const MAX_RETRIES: usize = 20;
let mut attempt = 0;
+
loop {
attempt += 1;
- match request().await {
- Ok(result) => return Ok(result),
- Err(err) => match err.downcast::<LanguageModelCompletionError>() {
- Ok(err) => match &err {
+ let response = request().await;
+
+ if attempt >= MAX_RETRIES {
+ return response;
+ }
+
+ let retry_delay = match &response {
+ Ok(_) => None,
+ Err(err) => match err.downcast_ref::<LanguageModelCompletionError>() {
+ Some(err) => match &err {
LanguageModelCompletionError::RateLimitExceeded { retry_after, .. }
| LanguageModelCompletionError::ServerOverloaded { retry_after, .. } => {
- let retry_after = retry_after.unwrap_or(Duration::from_secs(5));
- // Wait for the duration supplied, with some jitter to avoid all requests being made at the same time.
- let jitter = retry_after.mul_f64(rand::thread_rng().gen_range(0.0..1.0));
- eprintln!(
- "Attempt #{attempt}: {err}. Retry after {retry_after:?} + jitter of {jitter:?}"
- );
- Timer::after(retry_after + jitter).await;
- continue;
+ Some(retry_after.unwrap_or(Duration::from_secs(5)))
+ }
+ LanguageModelCompletionError::UpstreamProviderError {
+ status,
+ retry_after,
+ ..
+ } => {
+ // Only retry for specific status codes
+ let should_retry = matches!(
+ *status,
+ StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE
+ ) || status.as_u16() == 529;
+
+ if should_retry {
+ // Use server-provided retry_after if available, otherwise use default
+ Some(retry_after.unwrap_or(Duration::from_secs(5)))
+ } else {
+ None
+ }
}
- _ => return Err(err.into()),
+ LanguageModelCompletionError::ApiReadResponseError { .. }
+ | LanguageModelCompletionError::ApiInternalServerError { .. }
+ | LanguageModelCompletionError::HttpSend { .. } => {
+ // Exponential backoff for transient I/O and internal server errors
+ Some(Duration::from_secs(2_u64.pow((attempt - 1) as u32).min(30)))
+ }
+ _ => None,
},
- Err(err) => return Err(err),
+ _ => None,
},
+ };
+
+ if let Some(retry_after) = retry_delay {
+ let jitter = retry_after.mul_f64(rand::thread_rng().gen_range(0.0..1.0));
+ eprintln!("Attempt #{attempt}: Retry after {retry_after:?} + jitter of {jitter:?}");
+ Timer::after(retry_after + jitter).await;
+ } else {
+ return response;
}
}
}
@@ -319,7 +319,7 @@ mod tests {
);
let snapshot = buffer.snapshot();
- let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
+ let mut finder = StreamingFuzzyMatcher::new(snapshot);
assert_eq!(push(&mut finder, ""), None);
assert_eq!(finish(finder), None);
}
@@ -333,7 +333,7 @@ mod tests {
);
let snapshot = buffer.snapshot();
- let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
+ let mut finder = StreamingFuzzyMatcher::new(snapshot);
// Push partial query
assert_eq!(push(&mut finder, "This"), None);
@@ -365,7 +365,7 @@ mod tests {
);
let snapshot = buffer.snapshot();
- let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
+ let mut finder = StreamingFuzzyMatcher::new(snapshot);
// Push a fuzzy query that should match the first function
assert_eq!(
@@ -391,7 +391,7 @@ mod tests {
);
let snapshot = buffer.snapshot();
- let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
+ let mut finder = StreamingFuzzyMatcher::new(snapshot);
// No match initially
assert_eq!(push(&mut finder, "Lin"), None);
@@ -420,7 +420,7 @@ mod tests {
);
let snapshot = buffer.snapshot();
- let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
+ let mut finder = StreamingFuzzyMatcher::new(snapshot);
// Push text in small chunks across line boundaries
assert_eq!(push(&mut finder, "jumps "), None); // No newline yet
@@ -458,7 +458,7 @@ mod tests {
);
let snapshot = buffer.snapshot();
- let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
+ let mut finder = StreamingFuzzyMatcher::new(snapshot);
assert_eq!(
push(&mut finder, "impl Debug for User {\n"),
@@ -711,7 +711,7 @@ mod tests {
"Expected to match `second_function` based on the line hint"
);
- let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
+ let mut matcher = StreamingFuzzyMatcher::new(snapshot);
matcher.push(query, None);
matcher.finish();
let best_match = matcher.select_best_match();
@@ -727,7 +727,7 @@ mod tests {
let buffer = TextBuffer::new(0, BufferId::new(1).unwrap(), text.clone());
let snapshot = buffer.snapshot();
- let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
+ let mut matcher = StreamingFuzzyMatcher::new(snapshot);
// Split query into random chunks
let chunks = to_random_chunks(rng, query);
@@ -794,10 +794,8 @@ mod tests {
fn finish(mut finder: StreamingFuzzyMatcher) -> Option<String> {
let snapshot = finder.snapshot.clone();
let matches = finder.finish();
- if let Some(range) = matches.first() {
- Some(snapshot.text_for_range(range.clone()).collect::<String>())
- } else {
- None
- }
+ matches
+ .first()
+ .map(|range| snapshot.text_for_range(range.clone()).collect::<String>())
}
}
@@ -4,11 +4,11 @@ use crate::{
schema::json_schema_for,
ui::{COLLAPSED_LINES, ToolOutputPreview},
};
+use action_log::ActionLog;
use agent_settings;
use anyhow::{Context as _, Result, anyhow};
use assistant_tool::{
- ActionLog, AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput,
- ToolUseStatus,
+ AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus,
};
use buffer_diff::{BufferDiff, BufferDiffSnapshot};
use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey};
@@ -25,6 +25,7 @@ use language::{
};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
+use paths;
use project::{
Project, ProjectPath,
lsp_store::{FormatTrigger, LspFormatTarget},
@@ -126,8 +127,47 @@ impl Tool for EditFileTool {
"edit_file".into()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
- false
+ fn needs_confirmation(
+ &self,
+ input: &serde_json::Value,
+ project: &Entity<Project>,
+ cx: &App,
+ ) -> bool {
+ if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
+ return false;
+ }
+
+ let Ok(input) = serde_json::from_value::<EditFileToolInput>(input.clone()) else {
+ // If it's not valid JSON, it's going to error and confirming won't do anything.
+ return false;
+ };
+
+ // If any path component matches the local settings folder, then this could affect
+ // the editor in ways beyond the project source, so prompt.
+ let local_settings_folder = paths::local_settings_folder_relative_path();
+ let path = Path::new(&input.path);
+ if path
+ .components()
+ .any(|component| component.as_os_str() == local_settings_folder.as_os_str())
+ {
+ return true;
+ }
+
+ // It's also possible that the global config dir is configured to be inside the project,
+ // so check for that edge case too.
+ if let Ok(canonical_path) = std::fs::canonicalize(&input.path)
+ && canonical_path.starts_with(paths::config_dir())
+ {
+ return true;
+ }
+
+ // Check if path is inside the global config directory
+ // First check if it's already inside project - if not, try to canonicalize
+ let project_path = project.read(cx).find_project_path(&input.path, cx);
+
+ // If the path is inside the project, and it's not one of the above edge cases,
+ // then no confirmation is necessary. Otherwise, confirmation is necessary.
+ project_path.is_none()
}
fn may_perform_edits(&self) -> bool {
@@ -148,7 +188,25 @@ impl Tool for EditFileTool {
fn ui_text(&self, input: &serde_json::Value) -> String {
match serde_json::from_value::<EditFileToolInput>(input.clone()) {
- Ok(input) => input.display_description,
+ Ok(input) => {
+ let path = Path::new(&input.path);
+ let mut description = input.display_description.clone();
+
+ // Add context about why confirmation may be needed
+ let local_settings_folder = paths::local_settings_folder_relative_path();
+ if path
+ .components()
+ .any(|c| c.as_os_str() == local_settings_folder.as_os_str())
+ {
+ description.push_str(" (local settings)");
+ } else if let Ok(canonical_path) = std::fs::canonicalize(&input.path)
+ && canonical_path.starts_with(paths::config_dir())
+ {
+ description.push_str(" (global settings)");
+ }
+
+ description
+ }
Err(_) => "Editing file".to_string(),
}
}
@@ -249,7 +307,7 @@ impl Tool for EditFileTool {
let mut ambiguous_ranges = Vec::new();
while let Some(event) = events.next().await {
match event {
- EditAgentOutputEvent::Edited => {
+ EditAgentOutputEvent::Edited { .. } => {
if let Some(card) = card_clone.as_ref() {
card.update(cx, |card, cx| card.update_diff(cx))?;
}
@@ -278,6 +336,9 @@ impl Tool for EditFileTool {
.unwrap_or(false);
if format_on_save_enabled {
+ action_log.update(cx, |log, cx| {
+ log.buffer_edited(buffer.clone(), cx);
+ })?;
let format_task = project.update(cx, |project, cx| {
project.format(
HashSet::from_iter([buffer.clone()]),
@@ -315,7 +376,7 @@ impl Tool for EditFileTool {
let output = EditFileToolOutput {
original_path: project_path.path.to_path_buf(),
- new_text: new_text.clone(),
+ new_text,
old_text,
raw_output: Some(agent_output),
};
@@ -475,7 +536,7 @@ fn resolve_path(
let parent_entry = parent_project_path
.as_ref()
- .and_then(|path| project.entry_for_path(&path, cx))
+ .and_then(|path| project.entry_for_path(path, cx))
.context("Can't create file: parent directory doesn't exist")?;
anyhow::ensure!(
@@ -582,7 +643,7 @@ impl EditFileToolCard {
diff
});
- self.buffer = Some(buffer.clone());
+ self.buffer = Some(buffer);
self.base_text = Some(base_text.into());
self.buffer_diff = Some(buffer_diff.clone());
@@ -662,13 +723,13 @@ impl EditFileToolCard {
let buffer = buffer.read(cx);
let diff = diff.read(cx);
let mut ranges = diff
- .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx)
- .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer))
+ .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx)
+ .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer))
.collect::<Vec<_>>();
ranges.extend(
self.revealed_ranges
.iter()
- .map(|range| range.to_point(&buffer)),
+ .map(|range| range.to_point(buffer)),
);
ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end)));
@@ -715,7 +776,6 @@ impl EditFileToolCard {
let buffer_diff = cx.spawn({
let buffer = buffer.clone();
- let language_registry = language_registry.clone();
async move |_this, cx| {
build_buffer_diff(base_text, &buffer, &language_registry, cx).await
}
@@ -796,13 +856,12 @@ impl ToolCard for EditFileToolCard {
)
.child(
Icon::new(IconName::ArrowUpRight)
- .size(IconSize::XSmall)
+ .size(IconSize::Small)
.color(Color::Ignored),
),
)
.on_click({
let path = self.path.clone();
- let workspace = workspace.clone();
move |_, window, cx| {
workspace
.update(cx, {
@@ -1172,19 +1231,20 @@ async fn build_buffer_diff(
#[cfg(test)]
mod tests {
use super::*;
+ use ::fs::Fs;
use client::TelemetrySettings;
- use fs::{FakeFs, Fs};
use gpui::{TestAppContext, UpdateGlobal};
use language_model::fake_provider::FakeLanguageModel;
use serde_json::json;
use settings::SettingsStore;
+ use std::fs;
use util::path;
#[gpui::test]
async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
init_test(cx);
- let fs = FakeFs::new(cx.executor());
+ let fs = project::FakeFs::new(cx.executor());
fs.insert_tree("/root", json!({})).await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
@@ -1274,7 +1334,7 @@ mod tests {
) -> anyhow::Result<ProjectPath> {
init_test(cx);
- let fs = FakeFs::new(cx.executor());
+ let fs = project::FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
@@ -1294,8 +1354,7 @@ mod tests {
mode: mode.clone(),
};
- let result = cx.update(|cx| resolve_path(&input, project, cx));
- result
+ cx.update(|cx| resolve_path(&input, project, cx))
}
fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
@@ -1381,6 +1440,21 @@ mod tests {
cx.set_global(settings_store);
language::init(cx);
TelemetrySettings::register(cx);
+ agent_settings::AgentSettings::register(cx);
+ Project::init_settings(cx);
+ });
+ }
+
+ fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) {
+ cx.update(|cx| {
+ // Set custom data directory (config will be under data_dir/config)
+ paths::set_custom_data_dir(data_dir.to_str().unwrap());
+
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ language::init(cx);
+ TelemetrySettings::register(cx);
+ agent_settings::AgentSettings::register(cx);
Project::init_settings(cx);
});
}
@@ -1389,7 +1463,7 @@ mod tests {
async fn test_format_on_save(cx: &mut TestAppContext) {
init_test(cx);
- let fs = FakeFs::new(cx.executor());
+ let fs = project::FakeFs::new(cx.executor());
fs.insert_tree("/root", json!({"src": {}})).await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
@@ -1500,7 +1574,7 @@ mod tests {
// Stream the unformatted content
cx.executor().run_until_parked();
- model.stream_last_completion_response(UNFORMATTED_CONTENT.to_string());
+ model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
model.end_last_completion_stream();
edit_task.await
@@ -1564,7 +1638,7 @@ mod tests {
// Stream the unformatted content
cx.executor().run_until_parked();
- model.stream_last_completion_response(UNFORMATTED_CONTENT.to_string());
+ model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
model.end_last_completion_stream();
edit_task.await
@@ -1588,7 +1662,7 @@ mod tests {
async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
init_test(cx);
- let fs = FakeFs::new(cx.executor());
+ let fs = project::FakeFs::new(cx.executor());
fs.insert_tree("/root", json!({"src": {}})).await;
// Create a simple file with trailing whitespace
@@ -1643,7 +1717,9 @@ mod tests {
// Stream the content with trailing whitespace
cx.executor().run_until_parked();
- model.stream_last_completion_response(CONTENT_WITH_TRAILING_WHITESPACE.to_string());
+ model.send_last_completion_stream_text_chunk(
+ CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
+ );
model.end_last_completion_stream();
edit_task.await
@@ -1700,7 +1776,9 @@ mod tests {
// Stream the content with trailing whitespace
cx.executor().run_until_parked();
- model.stream_last_completion_response(CONTENT_WITH_TRAILING_WHITESPACE.to_string());
+ model.send_last_completion_stream_text_chunk(
+ CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
+ );
model.end_last_completion_stream();
edit_task.await
@@ -1720,4 +1798,641 @@ mod tests {
"Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
);
}
+
+ #[gpui::test]
+ async fn test_needs_confirmation(cx: &mut TestAppContext) {
+ init_test(cx);
+ let tool = Arc::new(EditFileTool);
+ let fs = project::FakeFs::new(cx.executor());
+ fs.insert_tree("/root", json!({})).await;
+
+ // Test 1: Path with .zed component should require confirmation
+ let input_with_zed = json!({
+ "display_description": "Edit settings",
+ "path": ".zed/settings.json",
+ "mode": "edit"
+ });
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ cx.update(|cx| {
+ assert!(
+ tool.needs_confirmation(&input_with_zed, &project, cx),
+ "Path with .zed component should require confirmation"
+ );
+ });
+
+ // Test 2: Absolute path should require confirmation
+ let input_absolute = json!({
+ "display_description": "Edit file",
+ "path": "/etc/hosts",
+ "mode": "edit"
+ });
+ cx.update(|cx| {
+ assert!(
+ tool.needs_confirmation(&input_absolute, &project, cx),
+ "Absolute path should require confirmation"
+ );
+ });
+
+ // Test 3: Relative path without .zed should not require confirmation
+ let input_relative = json!({
+ "display_description": "Edit file",
+ "path": "root/src/main.rs",
+ "mode": "edit"
+ });
+ cx.update(|cx| {
+ assert!(
+ !tool.needs_confirmation(&input_relative, &project, cx),
+ "Relative path without .zed should not require confirmation"
+ );
+ });
+
+ // Test 4: Path with .zed in the middle should require confirmation
+ let input_zed_middle = json!({
+ "display_description": "Edit settings",
+ "path": "root/.zed/tasks.json",
+ "mode": "edit"
+ });
+ cx.update(|cx| {
+ assert!(
+ tool.needs_confirmation(&input_zed_middle, &project, cx),
+ "Path with .zed in any component should require confirmation"
+ );
+ });
+
+ // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.always_allow_tool_actions = true;
+ agent_settings::AgentSettings::override_global(settings, cx);
+
+ assert!(
+ !tool.needs_confirmation(&input_with_zed, &project, cx),
+ "When always_allow_tool_actions is true, no confirmation should be needed"
+ );
+ assert!(
+ !tool.needs_confirmation(&input_absolute, &project, cx),
+ "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) {
+ // Set up a custom config directory for testing
+ let temp_dir = tempfile::tempdir().unwrap();
+ init_test_with_config(cx, temp_dir.path());
+
+ let tool = Arc::new(EditFileTool);
+
+ // Test ui_text shows context for various paths
+ let test_cases = vec![
+ (
+ json!({
+ "display_description": "Update config",
+ "path": ".zed/settings.json",
+ "mode": "edit"
+ }),
+ "Update config (local settings)",
+ ".zed path should show local settings context",
+ ),
+ (
+ json!({
+ "display_description": "Fix bug",
+ "path": "src/.zed/local.json",
+ "mode": "edit"
+ }),
+ "Fix bug (local settings)",
+ "Nested .zed path should show local settings context",
+ ),
+ (
+ json!({
+ "display_description": "Update readme",
+ "path": "README.md",
+ "mode": "edit"
+ }),
+ "Update readme",
+ "Normal path should not show additional context",
+ ),
+ (
+ json!({
+ "display_description": "Edit config",
+ "path": "config.zed",
+ "mode": "edit"
+ }),
+ "Edit config",
+ ".zed as extension should not show context",
+ ),
+ ];
+
+ for (input, expected_text, description) in test_cases {
+ cx.update(|_cx| {
+ let ui_text = tool.ui_text(&input);
+ assert_eq!(ui_text, expected_text, "Failed for case: {}", description);
+ });
+ }
+ }
+
+ #[gpui::test]
+ async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) {
+ init_test(cx);
+ let tool = Arc::new(EditFileTool);
+ let fs = project::FakeFs::new(cx.executor());
+
+ // Create a project in /project directory
+ fs.insert_tree("/project", json!({})).await;
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+
+ // Test file outside project requires confirmation
+ let input_outside = json!({
+ "display_description": "Edit file",
+ "path": "/outside/file.txt",
+ "mode": "edit"
+ });
+ cx.update(|cx| {
+ assert!(
+ tool.needs_confirmation(&input_outside, &project, cx),
+ "File outside project should require confirmation"
+ );
+ });
+
+ // Test file inside project doesn't require confirmation
+ let input_inside = json!({
+ "display_description": "Edit file",
+ "path": "project/file.txt",
+ "mode": "edit"
+ });
+ cx.update(|cx| {
+ assert!(
+ !tool.needs_confirmation(&input_inside, &project, cx),
+ "File inside project should not require confirmation"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) {
+ // Set up a custom data directory for testing
+ let temp_dir = tempfile::tempdir().unwrap();
+ init_test_with_config(cx, temp_dir.path());
+
+ let tool = Arc::new(EditFileTool);
+ let fs = project::FakeFs::new(cx.executor());
+ fs.insert_tree("/home/user/myproject", json!({})).await;
+ let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await;
+
+ // Get the actual local settings folder name
+ let local_settings_folder = paths::local_settings_folder_relative_path();
+
+ // Test various config path patterns
+ let test_cases = vec![
+ (
+ format!("{}/settings.json", local_settings_folder.display()),
+ true,
+ "Top-level local settings file".to_string(),
+ ),
+ (
+ format!(
+ "myproject/{}/settings.json",
+ local_settings_folder.display()
+ ),
+ true,
+ "Local settings in project path".to_string(),
+ ),
+ (
+ format!("src/{}/config.toml", local_settings_folder.display()),
+ true,
+ "Local settings in subdirectory".to_string(),
+ ),
+ (
+ ".zed.backup/file.txt".to_string(),
+ true,
+ ".zed.backup is outside project".to_string(),
+ ),
+ (
+ "my.zed/file.txt".to_string(),
+ true,
+ "my.zed is outside project".to_string(),
+ ),
+ (
+ "myproject/src/file.zed".to_string(),
+ false,
+ ".zed as file extension".to_string(),
+ ),
+ (
+ "myproject/normal/path/file.rs".to_string(),
+ false,
+ "Normal file without config paths".to_string(),
+ ),
+ ];
+
+ for (path, should_confirm, description) in test_cases {
+ let input = json!({
+ "display_description": "Edit file",
+ "path": path,
+ "mode": "edit"
+ });
+ cx.update(|cx| {
+ assert_eq!(
+ tool.needs_confirmation(&input, &project, cx),
+ should_confirm,
+ "Failed for case: {} - path: {}",
+ description,
+ path
+ );
+ });
+ }
+ }
+
+ #[gpui::test]
+ async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) {
+ // Set up a custom data directory for testing
+ let temp_dir = tempfile::tempdir().unwrap();
+ init_test_with_config(cx, temp_dir.path());
+
+ let tool = Arc::new(EditFileTool);
+ let fs = project::FakeFs::new(cx.executor());
+
+ // Create test files in the global config directory
+ let global_config_dir = paths::config_dir();
+ fs::create_dir_all(&global_config_dir).unwrap();
+ let global_settings_path = global_config_dir.join("settings.json");
+ fs::write(&global_settings_path, "{}").unwrap();
+
+ fs.insert_tree("/project", json!({})).await;
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+
+ // Test global config paths
+ let test_cases = vec![
+ (
+ global_settings_path.to_str().unwrap().to_string(),
+ true,
+ "Global settings file should require confirmation",
+ ),
+ (
+ global_config_dir
+ .join("keymap.json")
+ .to_str()
+ .unwrap()
+ .to_string(),
+ true,
+ "Global keymap file should require confirmation",
+ ),
+ (
+ "project/normal_file.rs".to_string(),
+ false,
+ "Normal project file should not require confirmation",
+ ),
+ ];
+
+ for (path, should_confirm, description) in test_cases {
+ let input = json!({
+ "display_description": "Edit file",
+ "path": path,
+ "mode": "edit"
+ });
+ cx.update(|cx| {
+ assert_eq!(
+ tool.needs_confirmation(&input, &project, cx),
+ should_confirm,
+ "Failed for case: {}",
+ description
+ );
+ });
+ }
+ }
+
+ #[gpui::test]
+ async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
+ init_test(cx);
+ let tool = Arc::new(EditFileTool);
+ let fs = project::FakeFs::new(cx.executor());
+
+ // Create multiple worktree directories
+ fs.insert_tree(
+ "/workspace/frontend",
+ json!({
+ "src": {
+ "main.js": "console.log('frontend');"
+ }
+ }),
+ )
+ .await;
+ fs.insert_tree(
+ "/workspace/backend",
+ json!({
+ "src": {
+ "main.rs": "fn main() {}"
+ }
+ }),
+ )
+ .await;
+ fs.insert_tree(
+ "/workspace/shared",
+ json!({
+ ".zed": {
+ "settings.json": "{}"
+ }
+ }),
+ )
+ .await;
+
+ // Create project with multiple worktrees
+ let project = Project::test(
+ fs.clone(),
+ [
+ path!("/workspace/frontend").as_ref(),
+ path!("/workspace/backend").as_ref(),
+ path!("/workspace/shared").as_ref(),
+ ],
+ cx,
+ )
+ .await;
+
+ // Test files in different worktrees
+ let test_cases = vec![
+ ("frontend/src/main.js", false, "File in first worktree"),
+ ("backend/src/main.rs", false, "File in second worktree"),
+ (
+ "shared/.zed/settings.json",
+ true,
+ ".zed file in third worktree",
+ ),
+ ("/etc/hosts", true, "Absolute path outside all worktrees"),
+ (
+ "../outside/file.txt",
+ true,
+ "Relative path outside worktrees",
+ ),
+ ];
+
+ for (path, should_confirm, description) in test_cases {
+ let input = json!({
+ "display_description": "Edit file",
+ "path": path,
+ "mode": "edit"
+ });
+ cx.update(|cx| {
+ assert_eq!(
+ tool.needs_confirmation(&input, &project, cx),
+ should_confirm,
+ "Failed for case: {} - path: {}",
+ description,
+ path
+ );
+ });
+ }
+ }
+
+ #[gpui::test]
+ async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
+ init_test(cx);
+ let tool = Arc::new(EditFileTool);
+ let fs = project::FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/project",
+ json!({
+ ".zed": {
+ "settings.json": "{}"
+ },
+ "src": {
+ ".zed": {
+ "local.json": "{}"
+ }
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+
+ // Test edge cases
+ let test_cases = vec![
+ // Empty path - find_project_path returns Some for empty paths
+ ("", false, "Empty path is treated as project root"),
+ // Root directory
+ ("/", true, "Root directory should be outside project"),
+ // Parent directory references - find_project_path resolves these
+ (
+ "project/../other",
+ false,
+ "Path with .. is resolved by find_project_path",
+ ),
+ (
+ "project/./src/file.rs",
+ false,
+ "Path with . should work normally",
+ ),
+ // Windows-style paths (if on Windows)
+ #[cfg(target_os = "windows")]
+ ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
+ #[cfg(target_os = "windows")]
+ ("project\\src\\main.rs", false, "Windows-style project path"),
+ ];
+
+ for (path, should_confirm, description) in test_cases {
+ let input = json!({
+ "display_description": "Edit file",
+ "path": path,
+ "mode": "edit"
+ });
+ cx.update(|cx| {
+ assert_eq!(
+ tool.needs_confirmation(&input, &project, cx),
+ should_confirm,
+ "Failed for case: {} - path: {}",
+ description,
+ path
+ );
+ });
+ }
+ }
+
+ #[gpui::test]
+ async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) {
+ init_test(cx);
+ let tool = Arc::new(EditFileTool);
+
+ // Test UI text for various scenarios
+ let test_cases = vec![
+ (
+ json!({
+ "display_description": "Update config",
+ "path": ".zed/settings.json",
+ "mode": "edit"
+ }),
+ "Update config (local settings)",
+ ".zed path should show local settings context",
+ ),
+ (
+ json!({
+ "display_description": "Fix bug",
+ "path": "src/.zed/local.json",
+ "mode": "edit"
+ }),
+ "Fix bug (local settings)",
+ "Nested .zed path should show local settings context",
+ ),
+ (
+ json!({
+ "display_description": "Update readme",
+ "path": "README.md",
+ "mode": "edit"
+ }),
+ "Update readme",
+ "Normal path should not show additional context",
+ ),
+ (
+ json!({
+ "display_description": "Edit config",
+ "path": "config.zed",
+ "mode": "edit"
+ }),
+ "Edit config",
+ ".zed as extension should not show context",
+ ),
+ ];
+
+ for (input, expected_text, description) in test_cases {
+ cx.update(|_cx| {
+ let ui_text = tool.ui_text(&input);
+ assert_eq!(ui_text, expected_text, "Failed for case: {}", description);
+ });
+ }
+ }
+
+ #[gpui::test]
+ async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
+ init_test(cx);
+ let tool = Arc::new(EditFileTool);
+ let fs = project::FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/project",
+ json!({
+ "existing.txt": "content",
+ ".zed": {
+ "settings.json": "{}"
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+
+ // Test different EditFileMode values
+ let modes = vec![
+ EditFileMode::Edit,
+ EditFileMode::Create,
+ EditFileMode::Overwrite,
+ ];
+
+ for mode in modes {
+ // Test .zed path with different modes
+ let input_zed = json!({
+ "display_description": "Edit settings",
+ "path": "project/.zed/settings.json",
+ "mode": mode
+ });
+ cx.update(|cx| {
+ assert!(
+ tool.needs_confirmation(&input_zed, &project, cx),
+ ".zed path should require confirmation regardless of mode: {:?}",
+ mode
+ );
+ });
+
+ // Test outside path with different modes
+ let input_outside = json!({
+ "display_description": "Edit file",
+ "path": "/outside/file.txt",
+ "mode": mode
+ });
+ cx.update(|cx| {
+ assert!(
+ tool.needs_confirmation(&input_outside, &project, cx),
+ "Outside path should require confirmation regardless of mode: {:?}",
+ mode
+ );
+ });
+
+ // Test normal path with different modes
+ let input_normal = json!({
+ "display_description": "Edit file",
+ "path": "project/normal.txt",
+ "mode": mode
+ });
+ cx.update(|cx| {
+ assert!(
+ !tool.needs_confirmation(&input_normal, &project, cx),
+ "Normal path should not require confirmation regardless of mode: {:?}",
+ mode
+ );
+ });
+ }
+ }
+
+ #[gpui::test]
+ async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) {
+ // Set up with custom directories for deterministic testing
+ let temp_dir = tempfile::tempdir().unwrap();
+ init_test_with_config(cx, temp_dir.path());
+
+ let tool = Arc::new(EditFileTool);
+ let fs = project::FakeFs::new(cx.executor());
+ fs.insert_tree("/project", json!({})).await;
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+
+ // Enable always_allow_tool_actions
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.always_allow_tool_actions = true;
+ agent_settings::AgentSettings::override_global(settings, cx);
+ });
+
+ // Test that all paths that normally require confirmation are bypassed
+ let global_settings_path = paths::config_dir().join("settings.json");
+ fs::create_dir_all(paths::config_dir()).unwrap();
+ fs::write(&global_settings_path, "{}").unwrap();
+
+ let test_cases = vec![
+ ".zed/settings.json",
+ "project/.zed/config.toml",
+ global_settings_path.to_str().unwrap(),
+ "/etc/hosts",
+ "/absolute/path/file.txt",
+ "../outside/project.txt",
+ ];
+
+ for path in test_cases {
+ let input = json!({
+ "display_description": "Edit file",
+ "path": path,
+ "mode": "edit"
+ });
+ cx.update(|cx| {
+ assert!(
+ !tool.needs_confirmation(&input, &project, cx),
+ "Path {} should not require confirmation when always_allow_tool_actions is true",
+ path
+ );
+ });
+ }
+
+ // Disable always_allow_tool_actions and verify confirmation is required again
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.always_allow_tool_actions = false;
+ agent_settings::AgentSettings::override_global(settings, cx);
+ });
+
+ // Verify .zed path requires confirmation again
+ let input = json!({
+ "display_description": "Edit file",
+ "path": ".zed/settings.json",
+ "mode": "edit"
+ });
+ cx.update(|cx| {
+ assert!(
+ tool.needs_confirmation(&input, &project, cx),
+ ".zed path should require confirmation when always_allow_tool_actions is false"
+ );
+ });
+ }
}
@@ -3,8 +3,9 @@ use std::sync::Arc;
use std::{borrow::Cow, cell::RefCell};
use crate::schema::json_schema_for;
+use action_log::ActionLog;
use anyhow::{Context as _, Result, anyhow, bail};
-use assistant_tool::{ActionLog, Tool, ToolResult};
+use assistant_tool::{Tool, ToolResult};
use futures::AsyncReadExt as _;
use gpui::{AnyWindowHandle, App, AppContext as _, Entity, Task};
use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown};
@@ -116,7 +117,7 @@ impl Tool for FetchTool {
"fetch".to_string()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
@@ -1,7 +1,8 @@
use crate::{schema::json_schema_for, ui::ToolCallCardHeader};
+use action_log::ActionLog;
use anyhow::{Result, anyhow};
use assistant_tool::{
- ActionLog, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus,
+ Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus,
};
use editor::Editor;
use futures::channel::oneshot::{self, Receiver};
@@ -55,7 +56,7 @@ impl Tool for FindPathTool {
"find_path".into()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
@@ -233,7 +234,7 @@ impl ToolCard for FindPathToolCard {
workspace: WeakEntity<Workspace>,
cx: &mut Context<Self>,
) -> impl IntoElement {
- let matches_label: SharedString = if self.paths.len() == 0 {
+ let matches_label: SharedString = if self.paths.is_empty() {
"No matches".into()
} else if self.paths.len() == 1 {
"1 match".into()
@@ -257,7 +258,7 @@ impl ToolCard for FindPathToolCard {
Button::new(("path", index), button_label)
.icon(IconName::ArrowUpRight)
- .icon_size(IconSize::XSmall)
+ .icon_size(IconSize::Small)
.icon_position(IconPosition::End)
.label_size(LabelSize::Small)
.color(Color::Muted)
@@ -1,6 +1,7 @@
use crate::schema::json_schema_for;
+use action_log::ActionLog;
use anyhow::{Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolResult};
+use assistant_tool::{Tool, ToolResult};
use futures::StreamExt;
use gpui::{AnyWindowHandle, App, Entity, Task};
use language::{OffsetRangeExt, ParseStatus, Point};
@@ -57,7 +58,7 @@ impl Tool for GrepTool {
"grep".into()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
@@ -187,15 +188,14 @@ impl Tool for GrepTool {
// Check if this file should be excluded based on its worktree settings
if let Ok(Some(project_path)) = project.read_with(cx, |project, cx| {
project.find_project_path(&path, cx)
- }) {
- if cx.update(|cx| {
+ })
+ && cx.update(|cx| {
let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
worktree_settings.is_path_excluded(&project_path.path)
|| worktree_settings.is_path_private(&project_path.path)
}).unwrap_or(false) {
continue;
}
- }
while *parse_status.borrow() != ParseStatus::Idle {
parse_status.changed().await?;
@@ -283,12 +283,11 @@ impl Tool for GrepTool {
output.extend(snapshot.text_for_range(range));
output.push_str("\n```\n");
- if let Some(ancestor_range) = ancestor_range {
- if end_row < ancestor_range.end.row {
+ if let Some(ancestor_range) = ancestor_range
+ && end_row < ancestor_range.end.row {
let remaining_lines = ancestor_range.end.row - end_row;
writeln!(output, "\n{} lines remaining in ancestor node. Read the file to see all.", remaining_lines)?;
}
- }
matches_found += 1;
}
@@ -328,7 +327,7 @@ mod tests {
init_test(cx);
cx.executor().allow_parking();
- let fs = FakeFs::new(cx.executor().clone());
+ let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
serde_json::json!({
@@ -416,7 +415,7 @@ mod tests {
init_test(cx);
cx.executor().allow_parking();
- let fs = FakeFs::new(cx.executor().clone());
+ let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
serde_json::json!({
@@ -495,7 +494,7 @@ mod tests {
init_test(cx);
cx.executor().allow_parking();
- let fs = FakeFs::new(cx.executor().clone());
+ let fs = FakeFs::new(cx.executor());
// Create test file with syntax structures
fs.insert_tree(
@@ -893,7 +892,7 @@ mod tests {
})
.await;
let results = result.unwrap();
- let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+ let paths = extract_paths_from_results(results.content.as_str().unwrap());
assert!(
paths.is_empty(),
"grep_tool should not find files outside the project worktree"
@@ -919,7 +918,7 @@ mod tests {
})
.await;
let results = result.unwrap();
- let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+ let paths = extract_paths_from_results(results.content.as_str().unwrap());
assert!(
paths.iter().any(|p| p.contains("allowed_file.rs")),
"grep_tool should be able to search files inside worktrees"
@@ -945,7 +944,7 @@ mod tests {
})
.await;
let results = result.unwrap();
- let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+ let paths = extract_paths_from_results(results.content.as_str().unwrap());
assert!(
paths.is_empty(),
"grep_tool should not search files in .secretdir (file_scan_exclusions)"
@@ -970,7 +969,7 @@ mod tests {
})
.await;
let results = result.unwrap();
- let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+ let paths = extract_paths_from_results(results.content.as_str().unwrap());
assert!(
paths.is_empty(),
"grep_tool should not search .mymetadata files (file_scan_exclusions)"
@@ -996,7 +995,7 @@ mod tests {
})
.await;
let results = result.unwrap();
- let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+ let paths = extract_paths_from_results(results.content.as_str().unwrap());
assert!(
paths.is_empty(),
"grep_tool should not search .mysecrets (private_files)"
@@ -1021,7 +1020,7 @@ mod tests {
})
.await;
let results = result.unwrap();
- let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+ let paths = extract_paths_from_results(results.content.as_str().unwrap());
assert!(
paths.is_empty(),
"grep_tool should not search .privatekey files (private_files)"
@@ -1046,7 +1045,7 @@ mod tests {
})
.await;
let results = result.unwrap();
- let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+ let paths = extract_paths_from_results(results.content.as_str().unwrap());
assert!(
paths.is_empty(),
"grep_tool should not search .mysensitive files (private_files)"
@@ -1072,7 +1071,7 @@ mod tests {
})
.await;
let results = result.unwrap();
- let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+ let paths = extract_paths_from_results(results.content.as_str().unwrap());
assert!(
paths.iter().any(|p| p.contains("normal_file.rs")),
"Should be able to search normal files"
@@ -1099,7 +1098,7 @@ mod tests {
})
.await;
let results = result.unwrap();
- let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+ let paths = extract_paths_from_results(results.content.as_str().unwrap());
assert!(
paths.is_empty(),
"grep_tool should not allow escaping project boundaries with relative paths"
@@ -1205,7 +1204,7 @@ mod tests {
.unwrap();
let content = result.content.as_str().unwrap();
- let paths = extract_paths_from_results(&content);
+ let paths = extract_paths_from_results(content);
// Should find matches in non-private files
assert!(
@@ -1270,7 +1269,7 @@ mod tests {
.unwrap();
let content = result.content.as_str().unwrap();
- let paths = extract_paths_from_results(&content);
+ let paths = extract_paths_from_results(content);
// Should only find matches in worktree1 *.rs files (excluding private ones)
assert!(
@@ -1,6 +1,7 @@
use crate::schema::json_schema_for;
+use action_log::ActionLog;
use anyhow::{Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolResult};
+use assistant_tool::{Tool, ToolResult};
use gpui::{AnyWindowHandle, App, Entity, Task};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
use project::{Project, WorktreeSettings};
@@ -45,7 +46,7 @@ impl Tool for ListDirectoryTool {
"list_directory".into()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
@@ -1,6 +1,7 @@
use crate::schema::json_schema_for;
+use action_log::ActionLog;
use anyhow::{Context as _, Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolResult};
+use assistant_tool::{Tool, ToolResult};
use gpui::{AnyWindowHandle, App, AppContext, Entity, Task};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
use project::Project;
@@ -42,7 +43,7 @@ impl Tool for MovePathTool {
"move_path".into()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
@@ -1,8 +1,9 @@
use std::sync::Arc;
use crate::schema::json_schema_for;
+use action_log::ActionLog;
use anyhow::{Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolResult};
+use assistant_tool::{Tool, ToolResult};
use chrono::{Local, Utc};
use gpui::{AnyWindowHandle, App, Entity, Task};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
@@ -33,7 +34,7 @@ impl Tool for NowTool {
"now".into()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
@@ -1,6 +1,7 @@
use crate::schema::json_schema_for;
+use action_log::ActionLog;
use anyhow::{Context as _, Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolResult};
+use assistant_tool::{Tool, ToolResult};
use gpui::{AnyWindowHandle, App, AppContext, Entity, Task};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
use project::Project;
@@ -23,7 +24,7 @@ impl Tool for OpenTool {
"open".to_string()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
true
}
fn may_perform_edits(&self) -> bool {
@@ -1,13 +1,13 @@
use crate::schema::json_schema_for;
+use action_log::ActionLog;
use anyhow::Result;
-use assistant_tool::{ActionLog, Tool, ToolResult};
+use assistant_tool::{Tool, ToolResult};
use gpui::{AnyWindowHandle, App, Entity, Task};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use std::fmt::Write as _;
-use std::sync::Arc;
+use std::{fmt::Write, sync::Arc};
use ui::IconName;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
@@ -20,7 +20,7 @@ impl Tool for ProjectNotificationsTool {
"project_notifications".to_string()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
fn may_perform_edits(&self) -> bool {
@@ -52,32 +52,105 @@ impl Tool for ProjectNotificationsTool {
_window: Option<AnyWindowHandle>,
cx: &mut App,
) -> ToolResult {
- let mut stale_files = String::new();
- let mut notified_buffers = Vec::new();
+ let Some(user_edits_diff) =
+ action_log.update(cx, |log, cx| log.flush_unnotified_user_edits(cx))
+ else {
+ return result("No new notifications");
+ };
- for stale_file in action_log.read(cx).unnotified_stale_buffers(cx) {
- if let Some(file) = stale_file.read(cx).file() {
- writeln!(&mut stale_files, "- {}", file.path().display()).ok();
- notified_buffers.push(stale_file.clone());
+ // NOTE: Changes to this prompt require a symmetric update in the LLM Worker
+ const HEADER: &str = include_str!("./project_notifications_tool/prompt_header.txt");
+ const MAX_BYTES: usize = 8000;
+ let diff = fit_patch_to_size(&user_edits_diff, MAX_BYTES);
+ result(&format!("{HEADER}\n\n```diff\n{diff}\n```\n").replace("\r\n", "\n"))
+ }
+}
+
+fn result(response: &str) -> ToolResult {
+ Task::ready(Ok(response.to_string().into())).into()
+}
+
+/// Make sure that the patch fits into the size limit (in bytes).
+/// Compress the patch by omitting some parts if needed.
+/// Unified diff format is assumed.
+fn fit_patch_to_size(patch: &str, max_size: usize) -> String {
+ if patch.len() <= max_size {
+ return patch.to_string();
+ }
+
+ // Compression level 1: remove context lines in diff bodies, but
+ // leave the counts and positions of inserted/deleted lines
+ let mut current_size = patch.len();
+ let mut file_patches = split_patch(patch);
+ file_patches.sort_by_key(|patch| patch.len());
+ let compressed_patches = file_patches
+ .iter()
+ .rev()
+ .map(|patch| {
+ if current_size > max_size {
+ let compressed = compress_patch(patch).unwrap_or_else(|_| patch.to_string());
+ current_size -= patch.len() - compressed.len();
+ compressed
+ } else {
+ patch.to_string()
}
- }
+ })
+ .collect::<Vec<_>>();
- if !notified_buffers.is_empty() {
- action_log.update(cx, |log, cx| {
- log.mark_buffers_as_notified(notified_buffers, cx);
- });
+ if current_size <= max_size {
+ return compressed_patches.join("\n\n");
+ }
+
+ // Compression level 2: list paths of the changed files only
+ let filenames = file_patches
+ .iter()
+ .map(|patch| {
+ let patch = diffy::Patch::from_str(patch).unwrap();
+ let path = patch
+ .modified()
+ .and_then(|path| path.strip_prefix("b/"))
+ .unwrap_or_default();
+ format!("- {path}\n")
+ })
+ .collect::<Vec<_>>();
+
+ filenames.join("")
+}
+
+/// Split a potentially multi-file patch into multiple single-file patches
+fn split_patch(patch: &str) -> Vec<String> {
+ let mut result = Vec::new();
+ let mut current_patch = String::new();
+
+ for line in patch.lines() {
+ if line.starts_with("---") && !current_patch.is_empty() {
+ result.push(current_patch.trim_end_matches('\n').into());
+ current_patch = String::new();
}
+ current_patch.push_str(line);
+ current_patch.push('\n');
+ }
- let response = if stale_files.is_empty() {
- "No new notifications".to_string()
- } else {
- // NOTE: Changes to this prompt require a symmetric update in the LLM Worker
- const HEADER: &str = include_str!("./project_notifications_tool/prompt_header.txt");
- format!("{HEADER}{stale_files}").replace("\r\n", "\n")
- };
+ if !current_patch.is_empty() {
+ result.push(current_patch.trim_end_matches('\n').into());
+ }
- Task::ready(Ok(response.into())).into()
+ result
+}
+
+fn compress_patch(patch: &str) -> anyhow::Result<String> {
+ let patch = diffy::Patch::from_str(patch)?;
+ let mut out = String::new();
+
+ writeln!(out, "--- {}", patch.original().unwrap_or("a"))?;
+ writeln!(out, "+++ {}", patch.modified().unwrap_or("b"))?;
+
+ for hunk in patch.hunks() {
+ writeln!(out, "@@ -{} +{} @@", hunk.old_range(), hunk.new_range())?;
+ writeln!(out, "[...skipped...]")?;
}
+
+ Ok(out)
}
#[cfg(test)]
@@ -85,6 +158,7 @@ mod tests {
use super::*;
use assistant_tool::ToolResultContent;
use gpui::{AppContext, TestAppContext};
+ use indoc::indoc;
use language_model::{LanguageModelRequest, fake_provider::FakeLanguageModelProvider};
use project::{FakeFs, Project};
use serde_json::json;
@@ -123,10 +197,11 @@ mod tests {
action_log.update(cx, |log, cx| {
log.buffer_read(buffer.clone(), cx);
});
+ cx.run_until_parked();
// Run the tool before any changes
let tool = Arc::new(ProjectNotificationsTool);
- let provider = Arc::new(FakeLanguageModelProvider);
+ let provider = Arc::new(FakeLanguageModelProvider::default());
let model: Arc<dyn LanguageModel> = Arc::new(provider.test_model());
let request = Arc::new(LanguageModelRequest::default());
let tool_input = json!({});
@@ -142,6 +217,7 @@ mod tests {
cx,
)
});
+ cx.run_until_parked();
let response = result.output.await.unwrap();
let response_text = match &response.content {
@@ -158,6 +234,7 @@ mod tests {
buffer.update(cx, |buffer, cx| {
buffer.edit([(1..1, "\nChange!\n")], None, cx);
});
+ cx.run_until_parked();
// Run the tool again
let result = cx.update(|cx| {
@@ -171,6 +248,7 @@ mod tests {
cx,
)
});
+ cx.run_until_parked();
// This time the buffer is stale, so the tool should return a notification
let response = result.output.await.unwrap();
@@ -179,10 +257,12 @@ mod tests {
_ => panic!("Expected text response"),
};
- let expected_content = "[The following is an auto-generated notification; do not reply]\n\nThese files have changed since the last read:\n- code.rs\n";
- assert_eq!(
- response_text.as_str(),
- expected_content,
+ assert!(
+ response_text.contains("These files have changed"),
+ "Tool should return the stale buffer notification"
+ );
+ assert!(
+ response_text.contains("test/code.rs"),
"Tool should return the stale buffer notification"
);
@@ -198,6 +278,7 @@ mod tests {
cx,
)
});
+ cx.run_until_parked();
let response = result.output.await.unwrap();
let response_text = match &response.content {
@@ -212,6 +293,61 @@ mod tests {
);
}
+ #[test]
+ fn test_patch_compression() {
+ // Given a patch that doesn't fit into the size budget
+ let patch = indoc! {"
+ --- a/dir/test.txt
+ +++ b/dir/test.txt
+ @@ -1,3 +1,3 @@
+ line 1
+ -line 2
+ +CHANGED
+ line 3
+ @@ -10,2 +10,2 @@
+ line 10
+ -line 11
+ +line eleven
+
+
+ --- a/dir/another.txt
+ +++ b/dir/another.txt
+ @@ -100,1 +1,1 @@
+ -before
+ +after
+ "};
+
+ // When the size deficit can be compensated by dropping the body,
+ // then the body should be trimmed for larger files first
+ let limit = patch.len() - 10;
+ let compressed = fit_patch_to_size(patch, limit);
+ let expected = indoc! {"
+ --- a/dir/test.txt
+ +++ b/dir/test.txt
+ @@ -1,3 +1,3 @@
+ [...skipped...]
+ @@ -10,2 +10,2 @@
+ [...skipped...]
+
+
+ --- a/dir/another.txt
+ +++ b/dir/another.txt
+ @@ -100,1 +1,1 @@
+ -before
+ +after"};
+ assert_eq!(compressed, expected);
+
+ // When the size deficit is too large, then only file paths
+ // should be returned
+ let limit = 10;
+ let compressed = fit_patch_to_size(patch, limit);
+ let expected = indoc! {"
+ - dir/another.txt
+ - dir/test.txt
+ "};
+ assert_eq!(compressed, expected);
+ }
+
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
@@ -1,6 +1,7 @@
use crate::schema::json_schema_for;
+use action_log::ActionLog;
use anyhow::{Context as _, Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolResult};
+use assistant_tool::{Tool, ToolResult};
use assistant_tool::{ToolResultContent, outline};
use gpui::{AnyWindowHandle, App, Entity, Task};
use project::{ImageItem, image_store};
@@ -54,7 +55,7 @@ impl Tool for ReadFileTool {
"read_file".into()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
@@ -200,7 +201,7 @@ impl Tool for ReadFileTool {
buffer
.file()
.as_ref()
- .map_or(true, |file| !file.disk_state().exists())
+ .is_none_or(|file| !file.disk_state().exists())
})? {
anyhow::bail!("{file_path} not found");
}
@@ -285,7 +286,10 @@ impl Tool for ReadFileTool {
Using the line numbers in this outline, you can call this tool again
while specifying the start_line and end_line fields to see the
- implementations of symbols in the outline."
+ implementations of symbols in the outline.
+
+ Alternatively, you can fall back to the `grep` tool (if available)
+ to search the file for specific content."
}
.into())
}
@@ -43,12 +43,11 @@ impl Transform for ToJsonSchemaSubsetTransform {
fn transform(&mut self, schema: &mut Schema) {
// Ensure that the type field is not an array, this happens when we use
// Option<T>, the type will be [T, "null"].
- if let Some(type_field) = schema.get_mut("type") {
- if let Some(types) = type_field.as_array() {
- if let Some(first_type) = types.first() {
- *type_field = first_type.clone();
- }
- }
+ if let Some(type_field) = schema.get_mut("type")
+ && let Some(types) = type_field.as_array()
+ && let Some(first_type) = types.first()
+ {
+ *type_field = first_type.clone();
}
// oneOf is not supported, use anyOf instead
@@ -2,9 +2,10 @@ use crate::{
schema::json_schema_for,
ui::{COLLAPSED_LINES, ToolOutputPreview},
};
+use action_log::ActionLog;
use agent_settings;
use anyhow::{Context as _, Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
+use assistant_tool::{Tool, ToolCard, ToolResult, ToolUseStatus};
use futures::{FutureExt as _, future::Shared};
use gpui::{
Animation, AnimationExt, AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task,
@@ -58,12 +59,9 @@ impl TerminalTool {
}
if which::which("bash").is_ok() {
- log::info!("agent selected bash for terminal tool");
"bash".into()
} else {
- let shell = get_system_shell();
- log::info!("agent selected {shell} for terminal tool");
- shell
+ get_system_shell()
}
});
Self {
@@ -77,7 +75,7 @@ impl Tool for TerminalTool {
Self::NAME.to_string()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
true
}
@@ -104,7 +102,7 @@ impl Tool for TerminalTool {
let first_line = lines.next().unwrap_or_default();
let remaining_line_count = lines.count();
match remaining_line_count {
- 0 => MarkdownInlineCode(&first_line).to_string(),
+ 0 => MarkdownInlineCode(first_line).to_string(),
1 => MarkdownInlineCode(&format!(
"{} - {} more line",
first_line, remaining_line_count
@@ -215,7 +213,8 @@ impl Tool for TerminalTool {
async move |cx| {
let program = program.await;
let env = env.await;
- let terminal = project
+
+ project
.update(cx, |project, cx| {
project.create_terminal(
TerminalKind::Task(task::SpawnInTerminal {
@@ -225,12 +224,10 @@ impl Tool for TerminalTool {
env,
..Default::default()
}),
- window,
cx,
)
})?
- .await;
- terminal
+ .await
}
});
@@ -353,7 +350,7 @@ fn process_content(
if is_empty {
"Command executed successfully.".to_string()
} else {
- content.to_string()
+ content
}
}
Some(exit_status) => {
@@ -387,7 +384,7 @@ fn working_dir(
let project = project.read(cx);
let cd = &input.cd;
- if cd == "." || cd == "" {
+ if cd == "." || cd.is_empty() {
// Accept "." or "" as meaning "the one worktree" if we only have one worktree.
let mut worktrees = project.worktrees(cx);
@@ -412,10 +409,8 @@ fn working_dir(
{
return Ok(Some(input_path.into()));
}
- } else {
- if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
- return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
- }
+ } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
+ return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
}
anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
@@ -1,8 +1,9 @@
use std::sync::Arc;
use crate::schema::json_schema_for;
+use action_log::ActionLog;
use anyhow::{Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolResult};
+use assistant_tool::{Tool, ToolResult};
use gpui::{AnyWindowHandle, App, Entity, Task};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
use project::Project;
@@ -24,7 +25,7 @@ impl Tool for ThinkingTool {
"thinking".to_string()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
@@ -37,7 +38,7 @@ impl Tool for ThinkingTool {
}
fn icon(&self) -> IconName {
- IconName::ToolBulb
+ IconName::ToolThink
}
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
@@ -101,14 +101,11 @@ impl RenderOnce for ToolCallCardHeader {
})
.when_some(secondary_text, |this, secondary_text| {
this.child(bullet_divider())
- .child(div().text_size(font_size).child(secondary_text.clone()))
+ .child(div().text_size(font_size).child(secondary_text))
})
.when_some(code_path, |this, code_path| {
- this.child(bullet_divider()).child(
- Label::new(code_path.clone())
- .size(LabelSize::Small)
- .inline_code(cx),
- )
+ this.child(bullet_divider())
+ .child(Label::new(code_path).size(LabelSize::Small).inline_code(cx))
})
.with_animation(
"loading-label",
@@ -2,10 +2,12 @@ use std::{sync::Arc, time::Duration};
use crate::schema::json_schema_for;
use crate::ui::ToolCallCardHeader;
+use action_log::ActionLog;
use anyhow::{Context as _, Result, anyhow};
use assistant_tool::{
- ActionLog, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus,
+ Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus,
};
+use cloud_llm_client::{WebSearchResponse, WebSearchResult};
use futures::{Future, FutureExt, TryFutureExt};
use gpui::{
AnyWindowHandle, App, AppContext, Context, Entity, IntoElement, Task, WeakEntity, Window,
@@ -17,7 +19,6 @@ use serde::{Deserialize, Serialize};
use ui::{IconName, Tooltip, prelude::*};
use web_search::WebSearchRegistry;
use workspace::Workspace;
-use zed_llm_client::{WebSearchResponse, WebSearchResult};
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct WebSearchToolInput {
@@ -32,7 +33,7 @@ impl Tool for WebSearchTool {
"web_search".into()
}
- fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
+ fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
@@ -45,7 +46,7 @@ impl Tool for WebSearchTool {
}
fn icon(&self) -> IconName {
- IconName::Globe
+ IconName::ToolWeb
}
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
@@ -177,7 +178,7 @@ impl ToolCard for WebSearchToolCard {
.label_size(LabelSize::Small)
.color(Color::Muted)
.icon(IconName::ArrowUpRight)
- .icon_size(IconSize::XSmall)
+ .icon_size(IconSize::Small)
.icon_position(IconPosition::End)
.truncate(true)
.tooltip({
@@ -192,10 +193,7 @@ impl ToolCard for WebSearchToolCard {
)
}
})
- .on_click({
- let url = url.clone();
- move |_, _, cx| cx.open_url(&url)
- })
+ .on_click(move |_, _, cx| cx.open_url(&url))
}))
.into_any(),
),
@@ -15,9 +15,10 @@ doctest = false
[dependencies]
anyhow.workspace = true
collections.workspace = true
-derive_more.workspace = true
gpui.workspace = true
-parking_lot.workspace = true
-rodio = { version = "0.20.0", default-features = false, features = ["wav"] }
+settings.workspace = true
+schemars.workspace = true
+serde.workspace = true
+rodio = { workspace = true, features = [ "wav", "playback", "tracing" ] }
util.workspace = true
workspace-hack.workspace = true
@@ -1,57 +0,0 @@
-use std::{io::Cursor, sync::Arc};
-
-use anyhow::{Context as _, Result};
-use collections::HashMap;
-use gpui::{App, AssetSource, Global};
-use rodio::{
- Decoder, Source,
- source::{Buffered, SamplesConverter},
-};
-
-type Sound = Buffered<SamplesConverter<Decoder<Cursor<Vec<u8>>>, f32>>;
-
-pub struct SoundRegistry {
- cache: Arc<parking_lot::Mutex<HashMap<String, Sound>>>,
- assets: Box<dyn AssetSource>,
-}
-
-struct GlobalSoundRegistry(Arc<SoundRegistry>);
-
-impl Global for GlobalSoundRegistry {}
-
-impl SoundRegistry {
- pub fn new(source: impl AssetSource) -> Arc<Self> {
- Arc::new(Self {
- cache: Default::default(),
- assets: Box::new(source),
- })
- }
-
- pub fn global(cx: &App) -> Arc<Self> {
- cx.global::<GlobalSoundRegistry>().0.clone()
- }
-
- pub(crate) fn set_global(source: impl AssetSource, cx: &mut App) {
- cx.set_global(GlobalSoundRegistry(SoundRegistry::new(source)));
- }
-
- pub fn get(&self, name: &str) -> Result<impl Source<Item = f32> + use<>> {
- if let Some(wav) = self.cache.lock().get(name) {
- return Ok(wav.clone());
- }
-
- let path = format!("sounds/{}.wav", name);
- let bytes = self
- .assets
- .load(&path)?
- .map(anyhow::Ok)
- .with_context(|| format!("No asset available for path {path}"))??
- .into_owned();
- let cursor = Cursor::new(bytes);
- let source = Decoder::new(cursor)?.convert_samples::<f32>().buffered();
-
- self.cache.lock().insert(name.to_string(), source.clone());
-
- Ok(source)
- }
-}
@@ -1,16 +1,19 @@
-use assets::SoundRegistry;
-use derive_more::{Deref, DerefMut};
-use gpui::{App, AssetSource, BorrowAppContext, Global};
-use rodio::{OutputStream, OutputStreamHandle};
+use anyhow::{Context as _, Result, anyhow};
+use collections::HashMap;
+use gpui::{App, BorrowAppContext, Global};
+use rodio::{Decoder, OutputStream, OutputStreamBuilder, Source, source::Buffered};
+use settings::Settings;
+use std::io::Cursor;
use util::ResultExt;
-mod assets;
+mod audio_settings;
+pub use audio_settings::AudioSettings;
-pub fn init(source: impl AssetSource, cx: &mut App) {
- SoundRegistry::set_global(source, cx);
- cx.set_global(GlobalAudio(Audio::new()));
+pub fn init(cx: &mut App) {
+ AudioSettings::register(cx);
}
+#[derive(Copy, Clone, Eq, Hash, PartialEq)]
pub enum Sound {
Joined,
Leave,
@@ -37,51 +40,66 @@ impl Sound {
#[derive(Default)]
pub struct Audio {
- _output_stream: Option<OutputStream>,
- output_handle: Option<OutputStreamHandle>,
+ output_handle: Option<OutputStream>,
+ source_cache: HashMap<Sound, Buffered<Decoder<Cursor<Vec<u8>>>>>,
}
-#[derive(Deref, DerefMut)]
-struct GlobalAudio(Audio);
-
-impl Global for GlobalAudio {}
+impl Global for Audio {}
impl Audio {
- pub fn new() -> Self {
- Self::default()
- }
-
- fn ensure_output_exists(&mut self) -> Option<&OutputStreamHandle> {
+ fn ensure_output_exists(&mut self) -> Option<&OutputStream> {
if self.output_handle.is_none() {
- let (_output_stream, output_handle) = OutputStream::try_default().log_err().unzip();
- self.output_handle = output_handle;
- self._output_stream = _output_stream;
+ self.output_handle = OutputStreamBuilder::open_default_stream().log_err();
}
self.output_handle.as_ref()
}
- pub fn play_sound(sound: Sound, cx: &mut App) {
- if !cx.has_global::<GlobalAudio>() {
- return;
- }
+ pub fn play_source(
+ source: impl rodio::Source + Send + 'static,
+ cx: &mut App,
+ ) -> anyhow::Result<()> {
+ cx.update_default_global(|this: &mut Self, _cx| {
+ let output_handle = this
+ .ensure_output_exists()
+ .ok_or_else(|| anyhow!("Could not open audio output"))?;
+ output_handle.mixer().add(source);
+ Ok(())
+ })
+ }
- cx.update_global::<GlobalAudio, _>(|this, cx| {
+ pub fn play_sound(sound: Sound, cx: &mut App) {
+ cx.update_default_global(|this: &mut Self, cx| {
+ let source = this.sound_source(sound, cx).log_err()?;
let output_handle = this.ensure_output_exists()?;
- let source = SoundRegistry::global(cx).get(sound.file()).log_err()?;
- output_handle.play_raw(source).log_err()?;
+ output_handle.mixer().add(source);
Some(())
});
}
pub fn end_call(cx: &mut App) {
- if !cx.has_global::<GlobalAudio>() {
- return;
- }
-
- cx.update_global::<GlobalAudio, _>(|this, _| {
- this._output_stream.take();
+ cx.update_default_global(|this: &mut Self, _cx| {
this.output_handle.take();
});
}
+
+ fn sound_source(&mut self, sound: Sound, cx: &App) -> Result<impl Source + use<>> {
+ if let Some(wav) = self.source_cache.get(&sound) {
+ return Ok(wav.clone());
+ }
+
+ let path = format!("sounds/{}.wav", sound.file());
+ let bytes = cx
+ .asset_source()
+ .load(&path)?
+ .map(anyhow::Ok)
+ .with_context(|| format!("No asset available for path {path}"))??
+ .into_owned();
+ let cursor = Cursor::new(bytes);
+ let source = Decoder::new(cursor)?.buffered();
+
+ self.source_cache.insert(sound, source.clone());
+
+ Ok(source)
+ }
}
@@ -0,0 +1,33 @@
+use anyhow::Result;
+use gpui::App;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::{Settings, SettingsSources};
+
+#[derive(Deserialize, Debug)]
+pub struct AudioSettings {
+ /// Opt into the new audio system.
+ #[serde(rename = "experimental.rodio_audio", default)]
+ pub rodio_audio: bool, // default is false
+}
+
+/// Configuration of audio in Zed.
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+#[serde(default)]
+pub struct AudioSettingsContent {
+ /// Whether to use the experimental audio system
+ #[serde(rename = "experimental.rodio_audio", default)]
+ pub rodio_audio: bool,
+}
+
+impl Settings for AudioSettings {
+ const KEY: Option<&'static str> = Some("audio");
+
+ type FileContent = AudioSettingsContent;
+
+ fn load(sources: SettingsSources<Self::FileContent>, _cx: &mut App) -> Result<Self> {
+ sources.json_merge()
+ }
+
+ fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
+}
@@ -59,16 +59,9 @@ pub enum VersionCheckType {
pub enum AutoUpdateStatus {
Idle,
Checking,
- Downloading {
- version: VersionCheckType,
- },
- Installing {
- version: VersionCheckType,
- },
- Updated {
- binary_path: PathBuf,
- version: VersionCheckType,
- },
+ Downloading { version: VersionCheckType },
+ Installing { version: VersionCheckType },
+ Updated { version: VersionCheckType },
Errored,
}
@@ -83,6 +76,7 @@ pub struct AutoUpdater {
current_version: SemanticVersion,
http_client: Arc<HttpClientWithUrl>,
pending_poll: Option<Task<Option<()>>>,
+ quit_subscription: Option<gpui::Subscription>,
}
#[derive(Deserialize, Clone, Debug)]
@@ -134,10 +128,15 @@ impl Settings for AutoUpdateSetting {
type FileContent = Option<AutoUpdateSettingContent>;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
- let auto_update = [sources.server, sources.release_channel, sources.user]
- .into_iter()
- .find_map(|value| value.copied().flatten())
- .unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
+ let auto_update = [
+ sources.server,
+ sources.release_channel,
+ sources.operating_system,
+ sources.user,
+ ]
+ .into_iter()
+ .find_map(|value| value.copied().flatten())
+ .unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
Ok(Self(auto_update.0))
}
@@ -159,7 +158,7 @@ pub fn init(http_client: Arc<HttpClientWithUrl>, cx: &mut App) {
AutoUpdateSetting::register(cx);
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
- workspace.register_action(|_, action: &Check, window, cx| check(action, window, cx));
+ workspace.register_action(|_, action, window, cx| check(action, window, cx));
workspace.register_action(|_, action, _, cx| {
view_release_notes(action, cx);
@@ -169,7 +168,7 @@ pub fn init(http_client: Arc<HttpClientWithUrl>, cx: &mut App) {
let version = release_channel::AppVersion::global(cx);
let auto_updater = cx.new(|cx| {
- let updater = AutoUpdater::new(version, http_client);
+ let updater = AutoUpdater::new(version, http_client, cx);
let poll_for_updates = ReleaseChannel::try_global(cx)
.map(|channel| channel.poll_for_updates())
@@ -316,12 +315,34 @@ impl AutoUpdater {
cx.default_global::<GlobalAutoUpdate>().0.clone()
}
- fn new(current_version: SemanticVersion, http_client: Arc<HttpClientWithUrl>) -> Self {
+ fn new(
+ current_version: SemanticVersion,
+ http_client: Arc<HttpClientWithUrl>,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ // On windows, executable files cannot be overwritten while they are
+ // running, so we must wait to overwrite the application until quitting
+ // or restarting. When quitting the app, we spawn the auto update helper
+ // to finish the auto update process after Zed exits. When restarting
+ // the app after an update, we use `set_restart_path` to run the auto
+ // update helper instead of the app, so that it can overwrite the app
+ // and then spawn the new binary.
+ let quit_subscription = Some(cx.on_app_quit(|_, _| async move {
+ #[cfg(target_os = "windows")]
+ finalize_auto_update_on_quit();
+ }));
+
+ cx.on_app_restart(|this, _| {
+ this.quit_subscription.take();
+ })
+ .detach();
+
Self {
status: AutoUpdateStatus::Idle,
current_version,
http_client,
pending_poll: None,
+ quit_subscription,
}
}
@@ -522,7 +543,7 @@ impl AutoUpdater {
async fn update(this: Entity<Self>, mut cx: AsyncApp) -> Result<()> {
let (client, installed_version, previous_status, release_channel) =
- this.read_with(&mut cx, |this, cx| {
+ this.read_with(&cx, |this, cx| {
(
this.http_client.clone(),
this.current_version,
@@ -531,6 +552,8 @@ impl AutoUpdater {
)
})?;
+ Self::check_dependencies()?;
+
this.update(&mut cx, |this, cx| {
this.status = AutoUpdateStatus::Checking;
cx.notify();
@@ -577,13 +600,15 @@ impl AutoUpdater {
cx.notify();
})?;
- let binary_path = Self::binary_path(installer_dir, target_path, &cx).await?;
+ let new_binary_path = Self::install_release(installer_dir, target_path, &cx).await?;
+ if let Some(new_binary_path) = new_binary_path {
+ cx.update(|cx| cx.set_restart_path(new_binary_path))?;
+ }
this.update(&mut cx, |this, cx| {
this.set_should_show_update_notification(true, cx)
.detach_and_log_err(cx);
this.status = AutoUpdateStatus::Updated {
- binary_path,
version: newer_version,
};
cx.notify();
@@ -634,6 +659,15 @@ impl AutoUpdater {
}
}
+ fn check_dependencies() -> Result<()> {
+ #[cfg(not(target_os = "windows"))]
+ anyhow::ensure!(
+ which::which("rsync").is_ok(),
+ "Aborting. Could not find rsync which is required for auto-updates."
+ );
+ Ok(())
+ }
+
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf> {
let filename = match OS {
"macos" => anyhow::Ok("Zed.dmg"),
@@ -642,20 +676,14 @@ impl AutoUpdater {
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
}?;
- #[cfg(not(target_os = "windows"))]
- anyhow::ensure!(
- which::which("rsync").is_ok(),
- "Aborting. Could not find rsync which is required for auto-updates."
- );
-
Ok(installer_dir.path().join(filename))
}
- async fn binary_path(
+ async fn install_release(
installer_dir: InstallerDir,
target_path: PathBuf,
cx: &AsyncApp,
- ) -> Result<PathBuf> {
+ ) -> Result<Option<PathBuf>> {
match OS {
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
"linux" => install_release_linux(&installer_dir, target_path, cx).await,
@@ -796,7 +824,7 @@ async fn install_release_linux(
temp_dir: &InstallerDir,
downloaded_tar_gz: PathBuf,
cx: &AsyncApp,
-) -> Result<PathBuf> {
+) -> Result<Option<PathBuf>> {
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())??;
@@ -856,14 +884,14 @@ async fn install_release_linux(
String::from_utf8_lossy(&output.stderr)
);
- Ok(to.join(expected_suffix))
+ Ok(Some(to.join(expected_suffix)))
}
async fn install_release_macos(
temp_dir: &InstallerDir,
downloaded_dmg: PathBuf,
cx: &AsyncApp,
-) -> Result<PathBuf> {
+) -> Result<Option<PathBuf>> {
let running_app_path = cx.update(|cx| cx.app_path())??;
let running_app_filename = running_app_path
.file_name()
@@ -905,10 +933,10 @@ async fn install_release_macos(
String::from_utf8_lossy(&output.stderr)
);
- Ok(running_app_path)
+ Ok(None)
}
-async fn install_release_windows(downloaded_installer: PathBuf) -> Result<PathBuf> {
+async fn install_release_windows(downloaded_installer: PathBuf) -> Result<Option<PathBuf>> {
let output = Command::new(downloaded_installer)
.arg("/verysilent")
.arg("/update=true")
@@ -921,29 +949,36 @@ async fn install_release_windows(downloaded_installer: PathBuf) -> Result<PathBu
"failed to start installer: {:?}",
String::from_utf8_lossy(&output.stderr)
);
- Ok(std::env::current_exe()?)
+ // We return the path to the update helper program, because it will
+ // perform the final steps of the update process, copying the new binary,
+ // deleting the old one, and launching the new binary.
+ let helper_path = std::env::current_exe()?
+ .parent()
+ .context("No parent dir for Zed.exe")?
+ .join("tools\\auto_update_helper.exe");
+ Ok(Some(helper_path))
}
-pub fn check_pending_installation() -> bool {
+pub fn finalize_auto_update_on_quit() {
let Some(installer_path) = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.join("updates")))
else {
- return false;
+ return;
};
// The installer will create a flag file after it finishes updating
let flag_file = installer_path.join("versions.txt");
- if flag_file.exists() {
- if let Some(helper) = installer_path
+ if flag_file.exists()
+ && let Some(helper) = installer_path
.parent()
.map(|p| p.join("tools\\auto_update_helper.exe"))
- {
- let _ = std::process::Command::new(helper).spawn();
- return true;
- }
+ {
+ let mut command = std::process::Command::new(helper);
+ command.arg("--launch");
+ command.arg("false");
+ let _ = command.spawn();
}
- false
}
#[cfg(test)]
@@ -997,7 +1032,6 @@ mod tests {
let app_commit_sha = Ok(Some("a".to_string()));
let installed_version = SemanticVersion::new(1, 0, 0);
let status = AutoUpdateStatus::Updated {
- binary_path: PathBuf::new(),
version: VersionCheckType::Semantic(SemanticVersion::new(1, 0, 1)),
};
let fetched_version = SemanticVersion::new(1, 0, 1);
@@ -1019,7 +1053,6 @@ mod tests {
let app_commit_sha = Ok(Some("a".to_string()));
let installed_version = SemanticVersion::new(1, 0, 0);
let status = AutoUpdateStatus::Updated {
- binary_path: PathBuf::new(),
version: VersionCheckType::Semantic(SemanticVersion::new(1, 0, 1)),
};
let fetched_version = SemanticVersion::new(1, 0, 2);
@@ -1085,7 +1118,6 @@ mod tests {
let app_commit_sha = Ok(Some("a".to_string()));
let installed_version = SemanticVersion::new(1, 0, 0);
let status = AutoUpdateStatus::Updated {
- binary_path: PathBuf::new(),
version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())),
};
let fetched_sha = "b".to_string();
@@ -1107,7 +1139,6 @@ mod tests {
let app_commit_sha = Ok(Some("a".to_string()));
let installed_version = SemanticVersion::new(1, 0, 0);
let status = AutoUpdateStatus::Updated {
- binary_path: PathBuf::new(),
version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())),
};
let fetched_sha = "c".to_string();
@@ -1155,7 +1186,6 @@ mod tests {
let app_commit_sha = Ok(None);
let installed_version = SemanticVersion::new(1, 0, 0);
let status = AutoUpdateStatus::Updated {
- binary_path: PathBuf::new(),
version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())),
};
let fetched_sha = "b".to_string();
@@ -1178,7 +1208,6 @@ mod tests {
let app_commit_sha = Ok(None);
let installed_version = SemanticVersion::new(1, 0, 0);
let status = AutoUpdateStatus::Updated {
- binary_path: PathBuf::new(),
version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())),
};
let fetched_sha = "c".to_string();
@@ -18,7 +18,7 @@ fn main() {}
#[cfg(target_os = "windows")]
mod windows_impl {
- use std::path::Path;
+ use std::{borrow::Cow, path::Path};
use super::dialog::create_dialog_window;
use super::updater::perform_update;
@@ -37,6 +37,11 @@ mod windows_impl {
pub(crate) const WM_JOB_UPDATED: u32 = WM_USER + 1;
pub(crate) const WM_TERMINATE: u32 = WM_USER + 2;
+ #[derive(Debug, Default)]
+ struct Args {
+ launch: bool,
+ }
+
pub(crate) fn run() -> Result<()> {
let helper_dir = std::env::current_exe()?
.parent()
@@ -51,8 +56,9 @@ mod windows_impl {
log::info!("======= Starting Zed update =======");
let (tx, rx) = std::sync::mpsc::channel();
let hwnd = create_dialog_window(rx)?.0 as isize;
+ let args = parse_args(std::env::args().skip(1));
std::thread::spawn(move || {
- let result = perform_update(app_dir.as_path(), Some(hwnd));
+ let result = perform_update(app_dir.as_path(), Some(hwnd), args.launch);
tx.send(result).ok();
unsafe { PostMessageW(Some(HWND(hwnd as _)), WM_TERMINATE, WPARAM(0), LPARAM(0)) }.ok();
});
@@ -77,6 +83,29 @@ mod windows_impl {
Ok(())
}
+ fn parse_args(input: impl IntoIterator<Item = String>) -> Args {
+ let mut args: Args = Args { launch: true };
+
+ let mut input = input.into_iter();
+ if let Some(arg) = input.next() {
+ let launch_arg;
+
+ if arg == "--launch" {
+ launch_arg = input.next().map(Cow::Owned);
+ } else if let Some(rest) = arg.strip_prefix("--launch=") {
+ launch_arg = Some(Cow::Borrowed(rest));
+ } else {
+ launch_arg = None;
+ }
+
+ if launch_arg.as_deref() == Some("false") {
+ args.launch = false;
+ }
+ }
+
+ args
+ }
+
pub(crate) fn show_error(mut content: String) {
if content.len() > 600 {
content.truncate(600);
@@ -91,4 +120,28 @@ mod windows_impl {
)
};
}
+
+ #[cfg(test)]
+ mod tests {
+ use crate::windows_impl::parse_args;
+
+ #[test]
+ fn test_parse_args() {
+ // launch can be specified via two separate arguments
+ assert!(parse_args(["--launch".into(), "true".into()]).launch);
+ assert!(!parse_args(["--launch".into(), "false".into()]).launch);
+
+ // launch can be specified via one single argument
+ assert!(parse_args(["--launch=true".into()]).launch);
+ assert!(!parse_args(["--launch=false".into()]).launch);
+
+ // launch defaults to true on no arguments
+ assert!(parse_args([]).launch);
+
+ // launch defaults to true on invalid arguments
+ assert!(parse_args(["--launch".into()]).launch);
+ assert!(parse_args(["--launch=".into()]).launch);
+ assert!(parse_args(["--launch=invalid".into()]).launch);
+ }
+ }
}
@@ -72,7 +72,7 @@ pub(crate) fn create_dialog_window(receiver: Receiver<Result<()>>) -> Result<HWN
let hwnd = CreateWindowExW(
WS_EX_TOPMOST,
class_name,
- windows::core::w!("Zed Editor"),
+ windows::core::w!("Zed"),
WS_VISIBLE | WS_POPUP | WS_CAPTION,
rect.right / 2 - width / 2,
rect.bottom / 2 - height / 2,
@@ -171,7 +171,7 @@ unsafe extern "system" fn wnd_proc(
&HSTRING::from(font_name),
);
let temp = SelectObject(hdc, font.into());
- let string = HSTRING::from("Zed Editor is updating...");
+ let string = HSTRING::from("Updating Zed...");
return_if_failed!(TextOutW(hdc, 20, 15, &string).ok());
return_if_failed!(DeleteObject(temp).ok());
@@ -186,11 +186,11 @@ unsafe extern "system" fn wnd_proc(
}),
WM_TERMINATE => {
with_dialog_data(hwnd, |data| {
- if let Ok(result) = data.borrow_mut().rx.recv() {
- if let Err(e) = result {
- log::error!("Failed to update Zed: {:?}", e);
- show_error(format!("Error: {:?}", e));
- }
+ if let Ok(result) = data.borrow_mut().rx.recv()
+ && let Err(e) = result
+ {
+ log::error!("Failed to update Zed: {:?}", e);
+ show_error(format!("Error: {:?}", e));
}
});
unsafe { PostQuitMessage(0) };
@@ -90,11 +90,7 @@ pub(crate) const JOBS: [Job; 2] = [
std::thread::sleep(Duration::from_millis(1000));
if let Ok(config) = std::env::var("ZED_AUTO_UPDATE") {
match config.as_str() {
- "err" => Err(std::io::Error::new(
- std::io::ErrorKind::Other,
- "Simulated error",
- ))
- .context("Anyhow!"),
+ "err" => Err(std::io::Error::other("Simulated error")).context("Anyhow!"),
_ => panic!("Unknown ZED_AUTO_UPDATE value: {}", config),
}
} else {
@@ -105,11 +101,7 @@ pub(crate) const JOBS: [Job; 2] = [
std::thread::sleep(Duration::from_millis(1000));
if let Ok(config) = std::env::var("ZED_AUTO_UPDATE") {
match config.as_str() {
- "err" => Err(std::io::Error::new(
- std::io::ErrorKind::Other,
- "Simulated error",
- ))
- .context("Anyhow!"),
+ "err" => Err(std::io::Error::other("Simulated error")).context("Anyhow!"),
_ => panic!("Unknown ZED_AUTO_UPDATE value: {}", config),
}
} else {
@@ -118,7 +110,7 @@ pub(crate) const JOBS: [Job; 2] = [
},
];
-pub(crate) fn perform_update(app_dir: &Path, hwnd: Option<isize>) -> Result<()> {
+pub(crate) fn perform_update(app_dir: &Path, hwnd: Option<isize>, launch: bool) -> Result<()> {
let hwnd = hwnd.map(|ptr| HWND(ptr as _));
for job in JOBS.iter() {
@@ -145,9 +137,11 @@ pub(crate) fn perform_update(app_dir: &Path, hwnd: Option<isize>) -> Result<()>
}
}
}
- let _ = std::process::Command::new(app_dir.join("Zed.exe"))
- .creation_flags(CREATE_NEW_PROCESS_GROUP.0)
- .spawn();
+ if launch {
+ let _ = std::process::Command::new(app_dir.join("Zed.exe"))
+ .creation_flags(CREATE_NEW_PROCESS_GROUP.0)
+ .spawn();
+ }
log::info!("Update completed successfully");
Ok(())
}
@@ -159,11 +153,11 @@ mod test {
#[test]
fn test_perform_update() {
let app_dir = std::path::Path::new("C:/");
- assert!(perform_update(app_dir, None).is_ok());
+ assert!(perform_update(app_dir, None, false).is_ok());
// Simulate a timeout
unsafe { std::env::set_var("ZED_AUTO_UPDATE", "err") };
- let ret = perform_update(app_dir, None);
+ let ret = perform_update(app_dir, None, false);
assert!(ret.is_err_and(|e| e.to_string().as_str() == "Timed out"));
}
}
@@ -114,7 +114,7 @@ fn view_release_notes_locally(
cx,
);
workspace.add_item_to_active_pane(
- Box::new(markdown_preview.clone()),
+ Box::new(markdown_preview),
None,
true,
window,
@@ -17,7 +17,5 @@ default = []
[dependencies]
aws-smithy-runtime-api.workspace = true
aws-smithy-types.workspace = true
-futures.workspace = true
http_client.workspace = true
-tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
workspace-hack.workspace = true
@@ -11,14 +11,11 @@ use aws_smithy_runtime_api::client::result::ConnectorError;
use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
use aws_smithy_runtime_api::http::{Headers, StatusCode};
use aws_smithy_types::body::SdkBody;
-use futures::AsyncReadExt;
-use http_client::{AsyncBody, Inner};
+use http_client::AsyncBody;
use http_client::{HttpClient, Request};
-use tokio::runtime::Handle;
struct AwsHttpConnector {
client: Arc<dyn HttpClient>,
- handle: Handle,
}
impl std::fmt::Debug for AwsHttpConnector {
@@ -42,18 +39,17 @@ impl AwsConnector for AwsHttpConnector {
.client
.send(Request::from_parts(parts, convert_to_async_body(body)));
- let handle = self.handle.clone();
-
HttpConnectorFuture::new(async move {
let response = match response.await {
Ok(response) => response,
Err(err) => return Err(ConnectorError::other(err.into(), None)),
};
let (parts, body) = response.into_parts();
- let body = convert_to_sdk_body(body, handle).await;
- let mut response =
- HttpResponse::new(StatusCode::try_from(parts.status.as_u16()).unwrap(), body);
+ let mut response = HttpResponse::new(
+ StatusCode::try_from(parts.status.as_u16()).unwrap(),
+ convert_to_sdk_body(body),
+ );
let headers = match Headers::try_from(parts.headers) {
Ok(headers) => headers,
@@ -70,7 +66,6 @@ impl AwsConnector for AwsHttpConnector {
#[derive(Clone)]
pub struct AwsHttpClient {
client: Arc<dyn HttpClient>,
- handler: Handle,
}
impl std::fmt::Debug for AwsHttpClient {
@@ -80,11 +75,8 @@ impl std::fmt::Debug for AwsHttpClient {
}
impl AwsHttpClient {
- pub fn new(client: Arc<dyn HttpClient>, handle: Handle) -> Self {
- Self {
- client,
- handler: handle,
- }
+ pub fn new(client: Arc<dyn HttpClient>) -> Self {
+ Self { client }
}
}
@@ -96,25 +88,12 @@ impl AwsClient for AwsHttpClient {
) -> SharedHttpConnector {
SharedHttpConnector::new(AwsHttpConnector {
client: self.client.clone(),
- handle: self.handler.clone(),
})
}
}
-pub async fn convert_to_sdk_body(body: AsyncBody, handle: Handle) -> SdkBody {
- match body.0 {
- Inner::Empty => SdkBody::empty(),
- Inner::Bytes(bytes) => SdkBody::from(bytes.into_inner()),
- Inner::AsyncReader(mut reader) => {
- let buffer = handle.spawn(async move {
- let mut buffer = Vec::new();
- let _ = reader.read_to_end(&mut buffer).await;
- buffer
- });
-
- SdkBody::from(buffer.await.unwrap_or_default())
- }
- }
+pub fn convert_to_sdk_body(body: AsyncBody) -> SdkBody {
+ SdkBody::from_body_1_x(body)
}
pub fn convert_to_async_body(body: SdkBody) -> AsyncBody {
@@ -54,11 +54,7 @@ pub async fn stream_completion(
)])));
}
- if request
- .tools
- .as_ref()
- .map_or(false, |t| !t.tools.is_empty())
- {
+ if request.tools.as_ref().is_some_and(|t| !t.tools.is_empty()) {
response = response.set_tool_config(request.tools);
}
@@ -32,11 +32,18 @@ pub enum Model {
ClaudeSonnet4Thinking,
#[serde(rename = "claude-opus-4", alias = "claude-opus-4-latest")]
ClaudeOpus4,
+ #[serde(rename = "claude-opus-4-1", alias = "claude-opus-4-1-latest")]
+ ClaudeOpus4_1,
#[serde(
rename = "claude-opus-4-thinking",
alias = "claude-opus-4-thinking-latest"
)]
ClaudeOpus4Thinking,
+ #[serde(
+ rename = "claude-opus-4-1-thinking",
+ alias = "claude-opus-4-1-thinking-latest"
+ )]
+ ClaudeOpus4_1Thinking,
#[serde(rename = "claude-3-5-sonnet-v2", alias = "claude-3-5-sonnet-latest")]
Claude3_5SonnetV2,
#[serde(rename = "claude-3-7-sonnet", alias = "claude-3-7-sonnet-latest")]
@@ -147,7 +154,9 @@ impl Model {
Model::ClaudeSonnet4 => "claude-4-sonnet",
Model::ClaudeSonnet4Thinking => "claude-4-sonnet-thinking",
Model::ClaudeOpus4 => "claude-4-opus",
+ Model::ClaudeOpus4_1 => "claude-4-opus-1",
Model::ClaudeOpus4Thinking => "claude-4-opus-thinking",
+ Model::ClaudeOpus4_1Thinking => "claude-4-opus-1-thinking",
Model::Claude3_5SonnetV2 => "claude-3-5-sonnet-v2",
Model::Claude3_5Sonnet => "claude-3-5-sonnet",
Model::Claude3Opus => "claude-3-opus",
@@ -208,6 +217,9 @@ impl Model {
Model::ClaudeOpus4 | Model::ClaudeOpus4Thinking => {
"anthropic.claude-opus-4-20250514-v1:0"
}
+ Model::ClaudeOpus4_1 | Model::ClaudeOpus4_1Thinking => {
+ "anthropic.claude-opus-4-1-20250805-v1:0"
+ }
Model::Claude3_5SonnetV2 => "anthropic.claude-3-5-sonnet-20241022-v2:0",
Model::Claude3_5Sonnet => "anthropic.claude-3-5-sonnet-20240620-v1:0",
Model::Claude3Opus => "anthropic.claude-3-opus-20240229-v1:0",
@@ -266,7 +278,9 @@ impl Model {
Self::ClaudeSonnet4 => "Claude Sonnet 4",
Self::ClaudeSonnet4Thinking => "Claude Sonnet 4 Thinking",
Self::ClaudeOpus4 => "Claude Opus 4",
+ Self::ClaudeOpus4_1 => "Claude Opus 4.1",
Self::ClaudeOpus4Thinking => "Claude Opus 4 Thinking",
+ Self::ClaudeOpus4_1Thinking => "Claude Opus 4.1 Thinking",
Self::Claude3_5SonnetV2 => "Claude 3.5 Sonnet v2",
Self::Claude3_5Sonnet => "Claude 3.5 Sonnet",
Self::Claude3Opus => "Claude 3 Opus",
@@ -330,8 +344,10 @@ impl Model {
| Self::Claude3_7Sonnet
| Self::ClaudeSonnet4
| Self::ClaudeOpus4
+ | Self::ClaudeOpus4_1
| Self::ClaudeSonnet4Thinking
- | Self::ClaudeOpus4Thinking => 200_000,
+ | Self::ClaudeOpus4Thinking
+ | Self::ClaudeOpus4_1Thinking => 200_000,
Self::AmazonNovaPremier => 1_000_000,
Self::PalmyraWriterX5 => 1_000_000,
Self::PalmyraWriterX4 => 128_000,
@@ -348,7 +364,9 @@ impl Model {
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4Thinking
| Self::ClaudeOpus4
- | Model::ClaudeOpus4Thinking => 128_000,
+ | Model::ClaudeOpus4Thinking
+ | Self::ClaudeOpus4_1
+ | Model::ClaudeOpus4_1Thinking => 128_000,
Self::Claude3_5SonnetV2 | Self::PalmyraWriterX4 | Self::PalmyraWriterX5 => 8_192,
Self::Custom {
max_output_tokens, ..
@@ -366,6 +384,8 @@ impl Model {
| Self::Claude3_7Sonnet
| Self::ClaudeOpus4
| Self::ClaudeOpus4Thinking
+ | Self::ClaudeOpus4_1
+ | Self::ClaudeOpus4_1Thinking
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4Thinking => 1.0,
Self::Custom {
@@ -387,6 +407,8 @@ impl Model {
| Self::Claude3_7SonnetThinking
| Self::ClaudeOpus4
| Self::ClaudeOpus4Thinking
+ | Self::ClaudeOpus4_1
+ | Self::ClaudeOpus4_1Thinking
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4Thinking
| Self::Claude3_5Haiku => true,
@@ -420,7 +442,9 @@ impl Model {
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4Thinking
| Self::ClaudeOpus4
- | Self::ClaudeOpus4Thinking => true,
+ | Self::ClaudeOpus4Thinking
+ | Self::ClaudeOpus4_1
+ | Self::ClaudeOpus4_1Thinking => true,
// Custom models - check if they have cache configuration
Self::Custom {
@@ -440,7 +464,9 @@ impl Model {
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4Thinking
| Self::ClaudeOpus4
- | Self::ClaudeOpus4Thinking => Some(BedrockModelCacheConfiguration {
+ | Self::ClaudeOpus4Thinking
+ | Self::ClaudeOpus4_1
+ | Self::ClaudeOpus4_1Thinking => Some(BedrockModelCacheConfiguration {
max_cache_anchors: 4,
min_total_token: 1024,
}),
@@ -467,9 +493,11 @@ impl Model {
Model::ClaudeSonnet4Thinking => BedrockModelMode::Thinking {
budget_tokens: Some(4096),
},
- Model::ClaudeOpus4Thinking => BedrockModelMode::Thinking {
- budget_tokens: Some(4096),
- },
+ Model::ClaudeOpus4Thinking | Model::ClaudeOpus4_1Thinking => {
+ BedrockModelMode::Thinking {
+ budget_tokens: Some(4096),
+ }
+ }
_ => BedrockModelMode::Default,
}
}
@@ -518,6 +546,8 @@ impl Model {
| Model::ClaudeSonnet4Thinking
| Model::ClaudeOpus4
| Model::ClaudeOpus4Thinking
+ | Model::ClaudeOpus4_1
+ | Model::ClaudeOpus4_1Thinking
| Model::Claude3Haiku
| Model::Claude3Opus
| Model::Claude3Sonnet
@@ -82,11 +82,12 @@ impl Render for Breadcrumbs {
}
text_style.color = Color::Muted.color(cx);
- if index == 0 && !TabBarSettings::get_global(cx).show && active_item.is_dirty(cx) {
- if let Some(styled_element) = apply_dirty_filename_style(&segment, &text_style, cx)
- {
- return styled_element;
- }
+ if index == 0
+ && !TabBarSettings::get_global(cx).show
+ && active_item.is_dirty(cx)
+ && let Some(styled_element) = apply_dirty_filename_style(&segment, &text_style, cx)
+ {
+ return styled_element;
}
StyledText::new(segment.text.replace('\n', "⏎"))
@@ -231,7 +232,7 @@ fn apply_dirty_filename_style(
let highlight = vec![(filename_position..text.len(), highlight_style)];
Some(
StyledText::new(text)
- .with_default_highlights(&text_style, highlight)
+ .with_default_highlights(text_style, highlight)
.into_any(),
)
}
@@ -162,6 +162,22 @@ impl BufferDiffSnapshot {
}
}
+ fn unchanged(
+ buffer: &text::BufferSnapshot,
+ base_text: language::BufferSnapshot,
+ ) -> BufferDiffSnapshot {
+ debug_assert_eq!(buffer.text(), base_text.text());
+ BufferDiffSnapshot {
+ inner: BufferDiffInner {
+ base_text,
+ hunks: SumTree::new(buffer),
+ pending_hunks: SumTree::new(buffer),
+ base_text_exists: false,
+ },
+ secondary_diff: None,
+ }
+ }
+
fn new_with_base_text(
buffer: text::BufferSnapshot,
base_text: Option<Arc<String>>,
@@ -175,12 +191,8 @@ impl BufferDiffSnapshot {
if let Some(text) = &base_text {
let base_text_rope = Rope::from(text.as_str());
base_text_pair = Some((text.clone(), base_text_rope.clone()));
- let snapshot = language::Buffer::build_snapshot(
- base_text_rope,
- language.clone(),
- language_registry.clone(),
- cx,
- );
+ let snapshot =
+ language::Buffer::build_snapshot(base_text_rope, language, language_registry, cx);
base_text_snapshot = cx.background_spawn(snapshot);
base_text_exists = true;
} else {
@@ -217,7 +229,10 @@ impl BufferDiffSnapshot {
cx: &App,
) -> impl Future<Output = Self> + use<> {
let base_text_exists = base_text.is_some();
- let base_text_pair = base_text.map(|text| (text, base_text_snapshot.as_rope().clone()));
+ let base_text_pair = base_text.map(|text| {
+ debug_assert_eq!(&*text, &base_text_snapshot.text());
+ (text, base_text_snapshot.as_rope().clone())
+ });
cx.background_executor()
.spawn_labeled(*CALCULATE_DIFF_TASK, async move {
Self {
@@ -343,8 +358,7 @@ impl BufferDiffInner {
..
} in hunks.iter().cloned()
{
- let preceding_pending_hunks =
- old_pending_hunks.slice(&buffer_range.start, Bias::Left, buffer);
+ let preceding_pending_hunks = old_pending_hunks.slice(&buffer_range.start, Bias::Left);
pending_hunks.append(preceding_pending_hunks, buffer);
// Skip all overlapping or adjacent old pending hunks
@@ -355,7 +369,7 @@ impl BufferDiffInner {
.cmp(&buffer_range.end, buffer)
.is_le()
}) {
- old_pending_hunks.next(buffer);
+ old_pending_hunks.next();
}
if (stage && secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk)
@@ -379,10 +393,10 @@ impl BufferDiffInner {
);
}
// append the remainder
- pending_hunks.append(old_pending_hunks.suffix(buffer), buffer);
+ pending_hunks.append(old_pending_hunks.suffix(), buffer);
let mut unstaged_hunk_cursor = unstaged_diff.hunks.cursor::<DiffHunkSummary>(buffer);
- unstaged_hunk_cursor.next(buffer);
+ unstaged_hunk_cursor.next();
// then, iterate over all pending hunks (both new ones and the existing ones) and compute the edits
let mut prev_unstaged_hunk_buffer_end = 0;
@@ -397,8 +411,7 @@ impl BufferDiffInner {
}) = pending_hunks_iter.next()
{
// Advance unstaged_hunk_cursor to skip unstaged hunks before current hunk
- let skipped_unstaged =
- unstaged_hunk_cursor.slice(&buffer_range.start, Bias::Left, buffer);
+ let skipped_unstaged = unstaged_hunk_cursor.slice(&buffer_range.start, Bias::Left);
if let Some(unstaged_hunk) = skipped_unstaged.last() {
prev_unstaged_hunk_base_text_end = unstaged_hunk.diff_base_byte_range.end;
@@ -425,7 +438,7 @@ impl BufferDiffInner {
buffer_offset_range.end =
buffer_offset_range.end.max(unstaged_hunk_offset_range.end);
- unstaged_hunk_cursor.next(buffer);
+ unstaged_hunk_cursor.next();
continue;
}
}
@@ -514,7 +527,7 @@ impl BufferDiffInner {
});
let anchor_iter = iter::from_fn(move || {
- cursor.next(buffer);
+ cursor.next();
cursor.item()
})
.flat_map(move |hunk| {
@@ -531,12 +544,12 @@ impl BufferDiffInner {
});
let mut pending_hunks_cursor = self.pending_hunks.cursor::<DiffHunkSummary>(buffer);
- pending_hunks_cursor.next(buffer);
+ pending_hunks_cursor.next();
let mut secondary_cursor = None;
if let Some(secondary) = secondary.as_ref() {
let mut cursor = secondary.hunks.cursor::<DiffHunkSummary>(buffer);
- cursor.next(buffer);
+ cursor.next();
secondary_cursor = Some(cursor);
}
@@ -564,7 +577,7 @@ impl BufferDiffInner {
.cmp(&pending_hunks_cursor.start().buffer_range.start, buffer)
.is_gt()
{
- pending_hunks_cursor.seek_forward(&start_anchor, Bias::Left, buffer);
+ pending_hunks_cursor.seek_forward(&start_anchor, Bias::Left);
}
if let Some(pending_hunk) = pending_hunks_cursor.item() {
@@ -574,14 +587,14 @@ impl BufferDiffInner {
pending_range.end.column = 0;
}
- if pending_range == (start_point..end_point) {
- if !buffer.has_edits_since_in_range(
+ if pending_range == (start_point..end_point)
+ && !buffer.has_edits_since_in_range(
&pending_hunk.buffer_version,
start_anchor..end_anchor,
- ) {
- has_pending = true;
- secondary_status = pending_hunk.new_status;
- }
+ )
+ {
+ has_pending = true;
+ secondary_status = pending_hunk.new_status;
}
}
@@ -590,7 +603,7 @@ impl BufferDiffInner {
.cmp(&secondary_cursor.start().buffer_range.start, buffer)
.is_gt()
{
- secondary_cursor.seek_forward(&start_anchor, Bias::Left, buffer);
+ secondary_cursor.seek_forward(&start_anchor, Bias::Left);
}
if let Some(secondary_hunk) = secondary_cursor.item() {
@@ -635,7 +648,7 @@ impl BufferDiffInner {
});
iter::from_fn(move || {
- cursor.prev(buffer);
+ cursor.prev();
let hunk = cursor.item()?;
let range = hunk.buffer_range.to_point(buffer);
@@ -653,8 +666,8 @@ impl BufferDiffInner {
fn compare(&self, old: &Self, new_snapshot: &text::BufferSnapshot) -> Option<Range<Anchor>> {
let mut new_cursor = self.hunks.cursor::<()>(new_snapshot);
let mut old_cursor = old.hunks.cursor::<()>(new_snapshot);
- old_cursor.next(new_snapshot);
- new_cursor.next(new_snapshot);
+ old_cursor.next();
+ new_cursor.next();
let mut start = None;
let mut end = None;
@@ -669,7 +682,7 @@ impl BufferDiffInner {
Ordering::Less => {
start.get_or_insert(new_hunk.buffer_range.start);
end.replace(new_hunk.buffer_range.end);
- new_cursor.next(new_snapshot);
+ new_cursor.next();
}
Ordering::Equal => {
if new_hunk != old_hunk {
@@ -686,25 +699,25 @@ impl BufferDiffInner {
}
}
- new_cursor.next(new_snapshot);
- old_cursor.next(new_snapshot);
+ new_cursor.next();
+ old_cursor.next();
}
Ordering::Greater => {
start.get_or_insert(old_hunk.buffer_range.start);
end.replace(old_hunk.buffer_range.end);
- old_cursor.next(new_snapshot);
+ old_cursor.next();
}
}
}
(Some(new_hunk), None) => {
start.get_or_insert(new_hunk.buffer_range.start);
end.replace(new_hunk.buffer_range.end);
- new_cursor.next(new_snapshot);
+ new_cursor.next();
}
(None, Some(old_hunk)) => {
start.get_or_insert(old_hunk.buffer_range.start);
end.replace(old_hunk.buffer_range.end);
- old_cursor.next(new_snapshot);
+ old_cursor.next();
}
(None, None) => break,
}
@@ -879,6 +892,18 @@ impl BufferDiff {
}
}
+ pub fn new_unchanged(
+ buffer: &text::BufferSnapshot,
+ base_text: language::BufferSnapshot,
+ ) -> Self {
+ debug_assert_eq!(buffer.text(), base_text.text());
+ BufferDiff {
+ buffer_id: buffer.remote_id(),
+ inner: BufferDiffSnapshot::unchanged(buffer, base_text).inner,
+ secondary_diff: None,
+ }
+ }
+
#[cfg(any(test, feature = "test-support"))]
pub fn new_with_base_text(
base_text: &str,
@@ -930,7 +955,7 @@ impl BufferDiff {
let new_index_text = self.inner.stage_or_unstage_hunks_impl(
&self.secondary_diff.as_ref()?.read(cx).inner,
stage,
- &hunks,
+ hunks,
buffer,
file_exists,
);
@@ -954,12 +979,12 @@ impl BufferDiff {
cx: &App,
) -> Option<Range<Anchor>> {
let start = self
- .hunks_intersecting_range(range.clone(), &buffer, cx)
+ .hunks_intersecting_range(range.clone(), buffer, cx)
.next()?
.buffer_range
.start;
let end = self
- .hunks_intersecting_range_rev(range.clone(), &buffer)
+ .hunks_intersecting_range_rev(range, buffer)
.next()?
.buffer_range
.end;
@@ -1033,21 +1058,20 @@ impl BufferDiff {
&& state.base_text.syntax_update_count()
== new_state.base_text.syntax_update_count() =>
{
- (false, new_state.compare(&state, buffer))
+ (false, new_state.compare(state, buffer))
}
_ => (true, Some(text::Anchor::MIN..text::Anchor::MAX)),
};
- if let Some(secondary_changed_range) = secondary_diff_change {
- if let Some(secondary_hunk_range) =
- self.range_to_hunk_range(secondary_changed_range, &buffer, cx)
- {
- if let Some(range) = &mut changed_range {
- range.start = secondary_hunk_range.start.min(&range.start, &buffer);
- range.end = secondary_hunk_range.end.max(&range.end, &buffer);
- } else {
- changed_range = Some(secondary_hunk_range);
- }
+ if let Some(secondary_changed_range) = secondary_diff_change
+ && let Some(secondary_hunk_range) =
+ self.range_to_hunk_range(secondary_changed_range, buffer, cx)
+ {
+ if let Some(range) = &mut changed_range {
+ range.start = secondary_hunk_range.start.min(&range.start, buffer);
+ range.end = secondary_hunk_range.end.max(&range.end, buffer);
+ } else {
+ changed_range = Some(secondary_hunk_range);
}
}
@@ -1059,8 +1083,8 @@ impl BufferDiff {
if let Some((first, last)) = state.pending_hunks.first().zip(state.pending_hunks.last())
{
if let Some(range) = &mut changed_range {
- range.start = range.start.min(&first.buffer_range.start, &buffer);
- range.end = range.end.max(&last.buffer_range.end, &buffer);
+ range.start = range.start.min(&first.buffer_range.start, buffer);
+ range.end = range.end.max(&last.buffer_range.end, buffer);
} else {
changed_range = Some(first.buffer_range.start..last.buffer_range.end);
}
@@ -1444,7 +1468,7 @@ mod tests {
.unindent();
let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text);
- let unstaged_diff = BufferDiffSnapshot::new_sync(buffer.clone(), index_text.clone(), cx);
+ let unstaged_diff = BufferDiffSnapshot::new_sync(buffer.clone(), index_text, cx);
let mut uncommitted_diff =
BufferDiffSnapshot::new_sync(buffer.clone(), head_text.clone(), cx);
uncommitted_diff.secondary_diff = Some(Box::new(unstaged_diff));
@@ -1799,7 +1823,7 @@ mod tests {
uncommitted_diff.update(cx, |diff, cx| {
let hunks = diff
- .hunks_intersecting_range(hunk_range.clone(), &buffer, &cx)
+ .hunks_intersecting_range(hunk_range.clone(), &buffer, cx)
.collect::<Vec<_>>();
for hunk in &hunks {
assert_ne!(
@@ -1814,7 +1838,7 @@ mod tests {
.to_string();
let hunks = diff
- .hunks_intersecting_range(hunk_range.clone(), &buffer, &cx)
+ .hunks_intersecting_range(hunk_range.clone(), &buffer, cx)
.collect::<Vec<_>>();
for hunk in &hunks {
assert_eq!(
@@ -1872,7 +1896,7 @@ mod tests {
.to_string();
assert_eq!(new_index_text, buffer_text);
- let hunk = diff.hunks(&buffer, &cx).next().unwrap();
+ let hunk = diff.hunks(&buffer, cx).next().unwrap();
assert_eq!(
hunk.secondary_status,
DiffHunkSecondaryStatus::SecondaryHunkRemovalPending
@@ -1884,7 +1908,7 @@ mod tests {
.to_string();
assert_eq!(index_text, head_text);
- let hunk = diff.hunks(&buffer, &cx).next().unwrap();
+ let hunk = diff.hunks(&buffer, cx).next().unwrap();
// optimistically unstaged (fine, could also be HasSecondaryHunk)
assert_eq!(
hunk.secondary_status,
@@ -2031,8 +2055,8 @@ mod tests {
fn gen_working_copy(rng: &mut StdRng, head: &str) -> String {
let mut old_lines = {
let mut old_lines = Vec::new();
- let mut old_lines_iter = head.lines();
- while let Some(line) = old_lines_iter.next() {
+ let old_lines_iter = head.lines();
+ for line in old_lines_iter {
assert!(!line.ends_with("\n"));
old_lines.push(line.to_owned());
}
@@ -2136,7 +2160,7 @@ mod tests {
diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &working_copy, cx)
.collect::<Vec<_>>()
});
- if hunks.len() == 0 {
+ if hunks.is_empty() {
return;
}
@@ -116,7 +116,7 @@ impl ActiveCall {
envelope: TypedEnvelope<proto::IncomingCall>,
mut cx: AsyncApp,
) -> Result<proto::Ack> {
- let user_store = this.read_with(&mut cx, |this, _| this.user_store.clone())?;
+ let user_store = this.read_with(&cx, |this, _| this.user_store.clone())?;
let call = IncomingCall {
room_id: envelope.payload.room_id,
participants: user_store
@@ -147,7 +147,7 @@ impl ActiveCall {
let mut incoming_call = this.incoming_call.0.borrow_mut();
if incoming_call
.as_ref()
- .map_or(false, |call| call.room_id == envelope.payload.room_id)
+ .is_some_and(|call| call.room_id == envelope.payload.room_id)
{
incoming_call.take();
}
@@ -64,7 +64,7 @@ pub struct RemoteParticipant {
impl RemoteParticipant {
pub fn has_video_tracks(&self) -> bool {
- return !self.video_tracks.is_empty();
+ !self.video_tracks.is_empty()
}
pub fn can_write(&self) -> bool {
@@ -10,16 +10,19 @@ use client::{
};
use collections::{BTreeMap, HashMap, HashSet};
use fs::Fs;
-use futures::{FutureExt, StreamExt};
-use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Task, WeakEntity};
+use futures::StreamExt;
+use gpui::{
+ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FutureExt as _,
+ ScreenCaptureSource, ScreenCaptureStream, Task, Timeout, WeakEntity,
+};
use gpui_tokio::Tokio;
use language::LanguageRegistry;
use livekit::{LocalTrackPublication, ParticipantIdentity, RoomEvent};
-use livekit_client::{self as livekit, TrackSid};
+use livekit_client::{self as livekit, AudioStream, TrackSid};
use postage::{sink::Sink, stream::Stream, watch};
use project::Project;
use settings::Settings as _;
-use std::{any::Any, future::Future, mem, rc::Rc, sync::Arc, time::Duration};
+use std::{future::Future, mem, rc::Rc, sync::Arc, time::Duration};
use util::{ResultExt, TryFutureExt, post_inc};
pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
@@ -367,57 +370,53 @@ impl Room {
})?;
// Wait for client to re-establish a connection to the server.
- {
- let mut reconnection_timeout =
- cx.background_executor().timer(RECONNECT_TIMEOUT).fuse();
- let client_reconnection = async {
- let mut remaining_attempts = 3;
- while remaining_attempts > 0 {
- if client_status.borrow().is_connected() {
- log::info!("client reconnected, attempting to rejoin room");
-
- let Some(this) = this.upgrade() else { break };
- match this.update(cx, |this, cx| this.rejoin(cx)) {
- Ok(task) => {
- if task.await.log_err().is_some() {
- return true;
- } else {
- remaining_attempts -= 1;
- }
+ let executor = cx.background_executor().clone();
+ let client_reconnection = async {
+ let mut remaining_attempts = 3;
+ while remaining_attempts > 0 {
+ if client_status.borrow().is_connected() {
+ log::info!("client reconnected, attempting to rejoin room");
+
+ let Some(this) = this.upgrade() else { break };
+ match this.update(cx, |this, cx| this.rejoin(cx)) {
+ Ok(task) => {
+ if task.await.log_err().is_some() {
+ return true;
+ } else {
+ remaining_attempts -= 1;
}
- Err(_app_dropped) => return false,
}
- } else if client_status.borrow().is_signed_out() {
- return false;
+ Err(_app_dropped) => return false,
}
-
- log::info!(
- "waiting for client status change, remaining attempts {}",
- remaining_attempts
- );
- client_status.next().await;
+ } else if client_status.borrow().is_signed_out() {
+ return false;
}
- false
+
+ log::info!(
+ "waiting for client status change, remaining attempts {}",
+ remaining_attempts
+ );
+ client_status.next().await;
}
- .fuse();
- futures::pin_mut!(client_reconnection);
-
- futures::select_biased! {
- reconnected = client_reconnection => {
- if reconnected {
- log::info!("successfully reconnected to room");
- // If we successfully joined the room, go back around the loop
- // waiting for future connection status changes.
- continue;
- }
- }
- _ = reconnection_timeout => {
- log::info!("room reconnection timeout expired");
- }
+ false
+ };
+
+ match client_reconnection
+ .with_timeout(RECONNECT_TIMEOUT, &executor)
+ .await
+ {
+ Ok(true) => {
+ log::info!("successfully reconnected to room");
+ // If we successfully joined the room, go back around the loop
+ // waiting for future connection status changes.
+ continue;
+ }
+ Ok(false) => break,
+ Err(Timeout) => {
+ log::info!("room reconnection timeout expired");
+ break;
}
}
-
- break;
}
}
@@ -828,24 +827,23 @@ impl Room {
);
Audio::play_sound(Sound::Joined, cx);
- if let Some(livekit_participants) = &livekit_participants {
- if let Some(livekit_participant) = livekit_participants
+ if let Some(livekit_participants) = &livekit_participants
+ && let Some(livekit_participant) = livekit_participants
.get(&ParticipantIdentity(user.id.to_string()))
+ {
+ for publication in
+ livekit_participant.track_publications().into_values()
{
- for publication in
- livekit_participant.track_publications().into_values()
- {
- if let Some(track) = publication.track() {
- this.livekit_room_updated(
- RoomEvent::TrackSubscribed {
- track,
- publication,
- participant: livekit_participant.clone(),
- },
- cx,
- )
- .warn_on_err();
- }
+ if let Some(track) = publication.track() {
+ this.livekit_room_updated(
+ RoomEvent::TrackSubscribed {
+ track,
+ publication,
+ participant: livekit_participant.clone(),
+ },
+ cx,
+ )
+ .warn_on_err();
}
}
}
@@ -941,10 +939,8 @@ impl Room {
self.client.user_id()
)
})?;
- if self.live_kit.as_ref().map_or(true, |kit| kit.deafened) {
- if publication.is_audio() {
- publication.set_enabled(false, cx);
- }
+ if self.live_kit.as_ref().is_none_or(|kit| kit.deafened) && publication.is_audio() {
+ publication.set_enabled(false, cx);
}
match track {
livekit_client::RemoteTrack::Audio(track) => {
@@ -1006,10 +1002,10 @@ impl Room {
for (sid, participant) in &mut self.remote_participants {
participant.speaking = speaker_ids.binary_search(sid).is_ok();
}
- if let Some(id) = self.client.user_id() {
- if let Some(room) = &mut self.live_kit {
- room.speaking = speaker_ids.binary_search(&id).is_ok();
- }
+ if let Some(id) = self.client.user_id()
+ && let Some(room) = &mut self.live_kit
+ {
+ room.speaking = speaker_ids.binary_search(&id).is_ok();
}
}
@@ -1043,18 +1039,16 @@ impl Room {
if let LocalTrack::Published {
track_publication, ..
} = &room.microphone_track
+ && track_publication.sid() == publication.sid()
{
- if track_publication.sid() == publication.sid() {
- room.microphone_track = LocalTrack::None;
- }
+ room.microphone_track = LocalTrack::None;
}
if let LocalTrack::Published {
track_publication, ..
} = &room.screen_track
+ && track_publication.sid() == publication.sid()
{
- if track_publication.sid() == publication.sid() {
- room.screen_track = LocalTrack::None;
- }
+ room.screen_track = LocalTrack::None;
}
}
}
@@ -1179,7 +1173,7 @@ impl Room {
this.update(cx, |this, cx| {
this.shared_projects.insert(project.downgrade());
let active_project = this.local_participant.active_project.as_ref();
- if active_project.map_or(false, |location| *location == project) {
+ if active_project.is_some_and(|location| *location == project) {
this.set_location(Some(&project), cx)
} else {
Task::ready(Ok(()))
@@ -1251,20 +1245,29 @@ impl Room {
})
}
- pub fn is_screen_sharing(&self) -> bool {
- self.live_kit.as_ref().map_or(false, |live_kit| {
- !matches!(live_kit.screen_track, LocalTrack::None)
+ pub fn is_sharing_screen(&self) -> bool {
+ self.live_kit
+ .as_ref()
+ .is_some_and(|live_kit| !matches!(live_kit.screen_track, LocalTrack::None))
+ }
+
+ pub fn shared_screen_id(&self) -> Option<u64> {
+ self.live_kit.as_ref().and_then(|lk| match lk.screen_track {
+ LocalTrack::Published { ref _stream, .. } => {
+ _stream.metadata().ok().map(|meta| meta.id)
+ }
+ _ => None,
})
}
pub fn is_sharing_mic(&self) -> bool {
- self.live_kit.as_ref().map_or(false, |live_kit| {
- !matches!(live_kit.microphone_track, LocalTrack::None)
- })
+ self.live_kit
+ .as_ref()
+ .is_some_and(|live_kit| !matches!(live_kit.microphone_track, LocalTrack::None))
}
pub fn is_muted(&self) -> bool {
- self.live_kit.as_ref().map_or(false, |live_kit| {
+ self.live_kit.as_ref().is_some_and(|live_kit| {
matches!(live_kit.microphone_track, LocalTrack::None)
|| live_kit.muted_by_user
|| live_kit.deafened
@@ -1274,13 +1277,13 @@ impl Room {
pub fn muted_by_user(&self) -> bool {
self.live_kit
.as_ref()
- .map_or(false, |live_kit| live_kit.muted_by_user)
+ .is_some_and(|live_kit| live_kit.muted_by_user)
}
pub fn is_speaking(&self) -> bool {
self.live_kit
.as_ref()
- .map_or(false, |live_kit| live_kit.speaking)
+ .is_some_and(|live_kit| live_kit.speaking)
}
pub fn is_deafened(&self) -> Option<bool> {
@@ -1369,11 +1372,15 @@ impl Room {
})
}
- pub fn share_screen(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
+ pub fn share_screen(
+ &mut self,
+ source: Rc<dyn ScreenCaptureSource>,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<()>> {
if self.status.is_offline() {
return Task::ready(Err(anyhow!("room is offline")));
}
- if self.is_screen_sharing() {
+ if self.is_sharing_screen() {
return Task::ready(Err(anyhow!("screen was already shared")));
}
@@ -1386,20 +1393,8 @@ impl Room {
return Task::ready(Err(anyhow!("live-kit was not initialized")));
};
- let sources = cx.screen_capture_sources();
-
cx.spawn(async move |this, cx| {
- let sources = sources
- .await
- .map_err(|error| error.into())
- .and_then(|sources| sources);
- let source =
- sources.and_then(|sources| sources.into_iter().next().context("no display found"));
-
- let publication = match source {
- Ok(source) => participant.publish_screenshare_track(&*source, cx).await,
- Err(error) => Err(error),
- };
+ let publication = participant.publish_screenshare_track(&*source, cx).await;
this.update(cx, |this, cx| {
let live_kit = this
@@ -1426,7 +1421,7 @@ impl Room {
} else {
live_kit.screen_track = LocalTrack::Published {
track_publication: publication,
- _stream: Box::new(stream),
+ _stream: stream,
};
cx.notify();
}
@@ -1484,15 +1479,13 @@ impl Room {
self.set_deafened(deafened, cx);
- if should_change_mute {
- if let Some(task) = self.set_mute(deafened, cx) {
- task.detach_and_log_err(cx);
- }
+ if should_change_mute && let Some(task) = self.set_mute(deafened, cx) {
+ task.detach_and_log_err(cx);
}
}
}
- pub fn unshare_screen(&mut self, cx: &mut Context<Self>) -> Result<()> {
+ pub fn unshare_screen(&mut self, play_sound: bool, cx: &mut Context<Self>) -> Result<()> {
anyhow::ensure!(!self.status.is_offline(), "room is offline");
let live_kit = self
@@ -1516,7 +1509,10 @@ impl Room {
cx.notify();
}
- Audio::play_sound(Sound::StopScreenshare, cx);
+ if play_sound {
+ Audio::play_sound(Sound::StopScreenshare, cx);
+ }
+
Ok(())
}
}
@@ -1624,8 +1620,8 @@ fn spawn_room_connection(
struct LiveKitRoom {
room: Rc<livekit::Room>,
- screen_track: LocalTrack,
- microphone_track: LocalTrack,
+ screen_track: LocalTrack<dyn ScreenCaptureStream>,
+ microphone_track: LocalTrack<AudioStream>,
/// Tracks whether we're currently in a muted state due to auto-mute from deafening or manual mute performed by user.
muted_by_user: bool,
deafened: bool,
@@ -1663,18 +1659,18 @@ impl LiveKitRoom {
}
}
-enum LocalTrack {
+enum LocalTrack<Stream: ?Sized> {
None,
Pending {
publish_id: usize,
},
Published {
track_publication: LocalTrackPublication,
- _stream: Box<dyn Any>,
+ _stream: Box<Stream>,
},
}
-impl Default for LocalTrack {
+impl<T: ?Sized> Default for LocalTrack<T> {
fn default() -> Self {
Self::None
}
@@ -82,7 +82,7 @@ impl ChannelBuffer {
collaborators: Default::default(),
acknowledge_task: None,
channel_id: channel.id,
- subscription: Some(subscription.set_entity(&cx.entity(), &mut cx.to_async())),
+ subscription: Some(subscription.set_entity(&cx.entity(), &cx.to_async())),
user_store,
channel_store,
};
@@ -110,7 +110,7 @@ impl ChannelBuffer {
let Ok(subscription) = self.client.subscribe_to_entity(self.channel_id.0) else {
return;
};
- self.subscription = Some(subscription.set_entity(&cx.entity(), &mut cx.to_async()));
+ self.subscription = Some(subscription.set_entity(&cx.entity(), &cx.to_async()));
cx.emit(ChannelBufferEvent::Connected);
}
}
@@ -135,7 +135,7 @@ impl ChannelBuffer {
}
}
- for (_, old_collaborator) in &self.collaborators {
+ for old_collaborator in self.collaborators.values() {
if !new_collaborators.contains_key(&old_collaborator.peer_id) {
self.buffer.update(cx, |buffer, cx| {
buffer.remove_peer(old_collaborator.replica_id, cx)
@@ -191,12 +191,11 @@ impl ChannelBuffer {
operation,
is_local: true,
} => {
- if *ZED_ALWAYS_ACTIVE {
- if let language::Operation::UpdateSelections { selections, .. } = operation {
- if selections.is_empty() {
- return;
- }
- }
+ if *ZED_ALWAYS_ACTIVE
+ && let language::Operation::UpdateSelections { selections, .. } = operation
+ && selections.is_empty()
+ {
+ return;
}
let operation = language::proto::serialize_operation(operation);
self.client
@@ -13,7 +13,7 @@ use std::{
ops::{ControlFlow, Range},
sync::Arc,
};
-use sum_tree::{Bias, SumTree};
+use sum_tree::{Bias, Dimensions, SumTree};
use time::OffsetDateTime;
use util::{ResultExt as _, TryFutureExt, post_inc};
@@ -329,22 +329,24 @@ impl ChannelChat {
loop {
let step = chat
.update(&mut cx, |chat, cx| {
- if let Some(first_id) = chat.first_loaded_message_id() {
- if first_id <= message_id {
- let mut cursor = chat.messages.cursor::<(ChannelMessageId, Count)>(&());
- let message_id = ChannelMessageId::Saved(message_id);
- cursor.seek(&message_id, Bias::Left, &());
- return ControlFlow::Break(
- if cursor
- .item()
- .map_or(false, |message| message.id == message_id)
- {
- Some(cursor.start().1.0)
- } else {
- None
- },
- );
- }
+ if let Some(first_id) = chat.first_loaded_message_id()
+ && first_id <= message_id
+ {
+ let mut cursor = chat
+ .messages
+ .cursor::<Dimensions<ChannelMessageId, Count>>(&());
+ let message_id = ChannelMessageId::Saved(message_id);
+ cursor.seek(&message_id, Bias::Left);
+ return ControlFlow::Break(
+ if cursor
+ .item()
+ .is_some_and(|message| message.id == message_id)
+ {
+ Some(cursor.start().1.0)
+ } else {
+ None
+ },
+ );
}
ControlFlow::Continue(chat.load_more_messages(cx))
})
@@ -357,22 +359,21 @@ impl ChannelChat {
}
pub fn acknowledge_last_message(&mut self, cx: &mut Context<Self>) {
- if let ChannelMessageId::Saved(latest_message_id) = self.messages.summary().max_id {
- if self
+ if let ChannelMessageId::Saved(latest_message_id) = self.messages.summary().max_id
+ && self
.last_acknowledged_id
- .map_or(true, |acknowledged_id| acknowledged_id < latest_message_id)
- {
- self.rpc
- .send(proto::AckChannelMessage {
- channel_id: self.channel_id.0,
- message_id: latest_message_id,
- })
- .ok();
- self.last_acknowledged_id = Some(latest_message_id);
- self.channel_store.update(cx, |store, cx| {
- store.acknowledge_message_id(self.channel_id, latest_message_id, cx);
- });
- }
+ .is_none_or(|acknowledged_id| acknowledged_id < latest_message_id)
+ {
+ self.rpc
+ .send(proto::AckChannelMessage {
+ channel_id: self.channel_id.0,
+ message_id: latest_message_id,
+ })
+ .ok();
+ self.last_acknowledged_id = Some(latest_message_id);
+ self.channel_store.update(cx, |store, cx| {
+ store.acknowledge_message_id(self.channel_id, latest_message_id, cx);
+ });
}
}
@@ -405,10 +406,10 @@ impl ChannelChat {
let missing_ancestors = loaded_messages
.iter()
.filter_map(|message| {
- if let Some(ancestor_id) = message.reply_to_message_id {
- if !loaded_message_ids.contains(&ancestor_id) {
- return Some(ancestor_id);
- }
+ if let Some(ancestor_id) = message.reply_to_message_id
+ && !loaded_message_ids.contains(&ancestor_id)
+ {
+ return Some(ancestor_id);
}
None
})
@@ -499,7 +500,7 @@ impl ChannelChat {
pub fn message(&self, ix: usize) -> &ChannelMessage {
let mut cursor = self.messages.cursor::<Count>(&());
- cursor.seek(&Count(ix), Bias::Right, &());
+ cursor.seek(&Count(ix), Bias::Right);
cursor.item().unwrap()
}
@@ -516,13 +517,13 @@ impl ChannelChat {
pub fn messages_in_range(&self, range: Range<usize>) -> impl Iterator<Item = &ChannelMessage> {
let mut cursor = self.messages.cursor::<Count>(&());
- cursor.seek(&Count(range.start), Bias::Right, &());
+ cursor.seek(&Count(range.start), Bias::Right);
cursor.take(range.len())
}
pub fn pending_messages(&self) -> impl Iterator<Item = &ChannelMessage> {
let mut cursor = self.messages.cursor::<ChannelMessageId>(&());
- cursor.seek(&ChannelMessageId::Pending(0), Bias::Left, &());
+ cursor.seek(&ChannelMessageId::Pending(0), Bias::Left);
cursor
}
@@ -531,7 +532,7 @@ impl ChannelChat {
message: TypedEnvelope<proto::ChannelMessageSent>,
mut cx: AsyncApp,
) -> Result<()> {
- let user_store = this.read_with(&mut cx, |this, _| this.user_store.clone())?;
+ let user_store = this.read_with(&cx, |this, _| this.user_store.clone())?;
let message = message.payload.message.context("empty message")?;
let message_id = message.id;
@@ -563,7 +564,7 @@ impl ChannelChat {
message: TypedEnvelope<proto::ChannelMessageUpdate>,
mut cx: AsyncApp,
) -> Result<()> {
- let user_store = this.read_with(&mut cx, |this, _| this.user_store.clone())?;
+ let user_store = this.read_with(&cx, |this, _| this.user_store.clone())?;
let message = message.payload.message.context("empty message")?;
let message = ChannelMessage::from_proto(message, &user_store, &mut cx).await?;
@@ -587,10 +588,12 @@ impl ChannelChat {
.map(|m| m.nonce)
.collect::<HashSet<_>>();
- let mut old_cursor = self.messages.cursor::<(ChannelMessageId, Count)>(&());
- let mut new_messages = old_cursor.slice(&first_message.id, Bias::Left, &());
+ let mut old_cursor = self
+ .messages
+ .cursor::<Dimensions<ChannelMessageId, Count>>(&());
+ let mut new_messages = old_cursor.slice(&first_message.id, Bias::Left);
let start_ix = old_cursor.start().1.0;
- let removed_messages = old_cursor.slice(&last_message.id, Bias::Right, &());
+ let removed_messages = old_cursor.slice(&last_message.id, Bias::Right);
let removed_count = removed_messages.summary().count;
let new_count = messages.summary().count;
let end_ix = start_ix + removed_count;
@@ -599,17 +602,17 @@ impl ChannelChat {
let mut ranges = Vec::<Range<usize>>::new();
if new_messages.last().unwrap().is_pending() {
- new_messages.append(old_cursor.suffix(&()), &());
+ new_messages.append(old_cursor.suffix(), &());
} else {
new_messages.append(
- old_cursor.slice(&ChannelMessageId::Pending(0), Bias::Left, &()),
+ old_cursor.slice(&ChannelMessageId::Pending(0), Bias::Left),
&(),
);
while let Some(message) = old_cursor.item() {
let message_ix = old_cursor.start().1.0;
if nonces.contains(&message.nonce) {
- if ranges.last().map_or(false, |r| r.end == message_ix) {
+ if ranges.last().is_some_and(|r| r.end == message_ix) {
ranges.last_mut().unwrap().end += 1;
} else {
ranges.push(message_ix..message_ix + 1);
@@ -617,7 +620,7 @@ impl ChannelChat {
} else {
new_messages.push(message.clone(), &());
}
- old_cursor.next(&());
+ old_cursor.next();
}
}
@@ -641,33 +644,33 @@ impl ChannelChat {
fn message_removed(&mut self, id: u64, cx: &mut Context<Self>) {
let mut cursor = self.messages.cursor::<ChannelMessageId>(&());
- let mut messages = cursor.slice(&ChannelMessageId::Saved(id), Bias::Left, &());
- if let Some(item) = cursor.item() {
- if item.id == ChannelMessageId::Saved(id) {
- let deleted_message_ix = messages.summary().count;
- cursor.next(&());
- messages.append(cursor.suffix(&()), &());
- drop(cursor);
- self.messages = messages;
-
- // If the message that was deleted was the last acknowledged message,
- // replace the acknowledged message with an earlier one.
- self.channel_store.update(cx, |store, _| {
- let summary = self.messages.summary();
- if summary.count == 0 {
- store.set_acknowledged_message_id(self.channel_id, None);
- } else if deleted_message_ix == summary.count {
- if let ChannelMessageId::Saved(id) = summary.max_id {
- store.set_acknowledged_message_id(self.channel_id, Some(id));
- }
- }
- });
+ let mut messages = cursor.slice(&ChannelMessageId::Saved(id), Bias::Left);
+ if let Some(item) = cursor.item()
+ && item.id == ChannelMessageId::Saved(id)
+ {
+ let deleted_message_ix = messages.summary().count;
+ cursor.next();
+ messages.append(cursor.suffix(), &());
+ drop(cursor);
+ self.messages = messages;
+
+ // If the message that was deleted was the last acknowledged message,
+ // replace the acknowledged message with an earlier one.
+ self.channel_store.update(cx, |store, _| {
+ let summary = self.messages.summary();
+ if summary.count == 0 {
+ store.set_acknowledged_message_id(self.channel_id, None);
+ } else if deleted_message_ix == summary.count
+ && let ChannelMessageId::Saved(id) = summary.max_id
+ {
+ store.set_acknowledged_message_id(self.channel_id, Some(id));
+ }
+ });
- cx.emit(ChannelChatEvent::MessagesUpdated {
- old_range: deleted_message_ix..deleted_message_ix + 1,
- new_count: 0,
- });
- }
+ cx.emit(ChannelChatEvent::MessagesUpdated {
+ old_range: deleted_message_ix..deleted_message_ix + 1,
+ new_count: 0,
+ });
}
}
@@ -680,7 +683,7 @@ impl ChannelChat {
cx: &mut Context<Self>,
) {
let mut cursor = self.messages.cursor::<ChannelMessageId>(&());
- let mut messages = cursor.slice(&id, Bias::Left, &());
+ let mut messages = cursor.slice(&id, Bias::Left);
let ix = messages.summary().count;
if let Some(mut message_to_update) = cursor.item().cloned() {
@@ -688,10 +691,10 @@ impl ChannelChat {
message_to_update.mentions = mentions;
message_to_update.edited_at = edited_at;
messages.push(message_to_update, &());
- cursor.next(&());
+ cursor.next();
}
- messages.append(cursor.suffix(&()), &());
+ messages.append(cursor.suffix(), &());
drop(cursor);
self.messages = messages;
@@ -126,7 +126,7 @@ impl ChannelMembership {
proto::channel_member::Kind::Member => 0,
proto::channel_member::Kind::Invitee => 1,
},
- username_order: self.user.github_login.as_str(),
+ username_order: &self.user.github_login,
}
}
}
@@ -262,13 +262,12 @@ impl ChannelStore {
}
}
status = status_receiver.next().fuse() => {
- if let Some(status) = status {
- if status.is_connected() {
+ if let Some(status) = status
+ && status.is_connected() {
this.update(cx, |this, _cx| {
this.initialize();
}).ok();
}
- }
continue;
}
_ = timer => {
@@ -336,10 +335,10 @@ impl ChannelStore {
}
pub fn has_open_channel_buffer(&self, channel_id: ChannelId, _cx: &App) -> bool {
- if let Some(buffer) = self.opened_buffers.get(&channel_id) {
- if let OpenEntityHandle::Open(buffer) = buffer {
- return buffer.upgrade().is_some();
- }
+ if let Some(buffer) = self.opened_buffers.get(&channel_id)
+ && let OpenEntityHandle::Open(buffer) = buffer
+ {
+ return buffer.upgrade().is_some();
}
false
}
@@ -408,13 +407,12 @@ impl ChannelStore {
pub fn last_acknowledge_message_id(&self, channel_id: ChannelId) -> Option<u64> {
self.channel_states.get(&channel_id).and_then(|state| {
- if let Some(last_message_id) = state.latest_chat_message {
- if state
+ if let Some(last_message_id) = state.latest_chat_message
+ && state
.last_acknowledged_message_id()
.is_some_and(|id| id < last_message_id)
- {
- return state.last_acknowledged_message_id();
- }
+ {
+ return state.last_acknowledged_message_id();
}
None
@@ -570,16 +568,14 @@ impl ChannelStore {
self.channel_index
.by_id()
.get(&channel_id)
- .map_or(false, |channel| channel.is_root_channel())
+ .is_some_and(|channel| channel.is_root_channel())
}
pub fn is_public_channel(&self, channel_id: ChannelId) -> bool {
self.channel_index
.by_id()
.get(&channel_id)
- .map_or(false, |channel| {
- channel.visibility == ChannelVisibility::Public
- })
+ .is_some_and(|channel| channel.visibility == ChannelVisibility::Public)
}
pub fn channel_capability(&self, channel_id: ChannelId) -> Capability {
@@ -910,9 +906,9 @@ impl ChannelStore {
async fn handle_update_channels(
this: Entity<Self>,
message: TypedEnvelope<proto::UpdateChannels>,
- mut cx: AsyncApp,
+ cx: AsyncApp,
) -> Result<()> {
- this.read_with(&mut cx, |this, _| {
+ this.read_with(&cx, |this, _| {
this.update_channels_tx
.unbounded_send(message.payload)
.unwrap();
@@ -962,27 +958,27 @@ impl ChannelStore {
self.disconnect_channel_buffers_task.take();
for chat in self.opened_chats.values() {
- if let OpenEntityHandle::Open(chat) = chat {
- if let Some(chat) = chat.upgrade() {
- chat.update(cx, |chat, cx| {
- chat.rejoin(cx);
- });
- }
+ if let OpenEntityHandle::Open(chat) = chat
+ && let Some(chat) = chat.upgrade()
+ {
+ chat.update(cx, |chat, cx| {
+ chat.rejoin(cx);
+ });
}
}
let mut buffer_versions = Vec::new();
for buffer in self.opened_buffers.values() {
- if let OpenEntityHandle::Open(buffer) = buffer {
- if let Some(buffer) = buffer.upgrade() {
- let channel_buffer = buffer.read(cx);
- let buffer = channel_buffer.buffer().read(cx);
- buffer_versions.push(proto::ChannelBufferVersion {
- channel_id: channel_buffer.channel_id.0,
- epoch: channel_buffer.epoch(),
- version: language::proto::serialize_version(&buffer.version()),
- });
- }
+ if let OpenEntityHandle::Open(buffer) = buffer
+ && let Some(buffer) = buffer.upgrade()
+ {
+ let channel_buffer = buffer.read(cx);
+ let buffer = channel_buffer.buffer().read(cx);
+ buffer_versions.push(proto::ChannelBufferVersion {
+ channel_id: channel_buffer.channel_id.0,
+ epoch: channel_buffer.epoch(),
+ version: language::proto::serialize_version(&buffer.version()),
+ });
}
}
@@ -1077,11 +1073,11 @@ impl ChannelStore {
if let Some(this) = this.upgrade() {
this.update(cx, |this, cx| {
- for (_, buffer) in &this.opened_buffers {
- if let OpenEntityHandle::Open(buffer) = &buffer {
- if let Some(buffer) = buffer.upgrade() {
- buffer.update(cx, |buffer, cx| buffer.disconnect(cx));
- }
+ for buffer in this.opened_buffers.values() {
+ if let OpenEntityHandle::Open(buffer) = &buffer
+ && let Some(buffer) = buffer.upgrade()
+ {
+ buffer.update(cx, |buffer, cx| buffer.disconnect(cx));
}
}
})
@@ -1157,10 +1153,9 @@ impl ChannelStore {
}
if let Some(OpenEntityHandle::Open(buffer)) =
self.opened_buffers.remove(&channel_id)
+ && let Some(buffer) = buffer.upgrade()
{
- if let Some(buffer) = buffer.upgrade() {
- buffer.update(cx, ChannelBuffer::disconnect);
- }
+ buffer.update(cx, ChannelBuffer::disconnect);
}
}
}
@@ -1170,12 +1165,11 @@ impl ChannelStore {
let id = ChannelId(channel.id);
let channel_changed = index.insert(channel);
- if channel_changed {
- if let Some(OpenEntityHandle::Open(buffer)) = self.opened_buffers.get(&id) {
- if let Some(buffer) = buffer.upgrade() {
- buffer.update(cx, ChannelBuffer::channel_changed);
- }
- }
+ if channel_changed
+ && let Some(OpenEntityHandle::Open(buffer)) = self.opened_buffers.get(&id)
+ && let Some(buffer) = buffer.upgrade()
+ {
+ buffer.update(cx, ChannelBuffer::channel_changed);
}
}
@@ -259,20 +259,6 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
assert_channels(&channel_store, &[(0, "the-channel".to_string())], cx);
});
- let get_users = server.receive::<proto::GetUsers>().await.unwrap();
- assert_eq!(get_users.payload.user_ids, vec![5]);
- server.respond(
- get_users.receipt(),
- proto::UsersResponse {
- users: vec![proto::User {
- id: 5,
- github_login: "nathansobo".into(),
- avatar_url: "http://avatar.com/nathansobo".into(),
- name: None,
- }],
- },
- );
-
// Join a channel and populate its existing messages.
let channel = channel_store.update(cx, |store, cx| {
let channel_id = store.ordered_channels().next().unwrap().1.id;
@@ -334,7 +320,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
.map(|message| (message.sender.github_login.clone(), message.body.clone()))
.collect::<Vec<_>>(),
&[
- ("nathansobo".into(), "a".into()),
+ ("user-5".into(), "a".into()),
("maxbrunsfeld".into(), "b".into())
]
);
@@ -437,7 +423,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
.map(|message| (message.sender.github_login.clone(), message.body.clone()))
.collect::<Vec<_>>(),
&[
- ("nathansobo".into(), "y".into()),
+ ("user-5".into(), "y".into()),
("maxbrunsfeld".into(), "z".into())
]
);
@@ -452,7 +438,7 @@ fn init_test(cx: &mut App) -> Entity<ChannelStore> {
let clock = Arc::new(FakeSystemClock::new());
let http = FakeHttpClient::with_404_response();
- let client = Client::new(clock, http.clone(), cx);
+ let client = Client::new(clock, http, cx);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
client::init(&client, cx);
@@ -363,7 +363,7 @@ fn anonymous_fd(path: &str) -> Option<fs::File> {
let fd: fd::RawFd = fd_str.parse().ok()?;
let file = unsafe { fs::File::from_raw_fd(fd) };
- return Some(file);
+ Some(file)
}
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
{
@@ -381,13 +381,13 @@ fn anonymous_fd(path: &str) -> Option<fs::File> {
}
let fd: fd::RawFd = fd_str.parse().ok()?;
let file = unsafe { fs::File::from_raw_fd(fd) };
- return Some(file);
+ Some(file)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))]
{
_ = path;
// not implemented for bsd, windows. Could be, but isn't yet
- return None;
+ None
}
}
@@ -400,7 +400,6 @@ mod linux {
os::unix::net::{SocketAddr, UnixDatagram},
path::{Path, PathBuf},
process::{self, ExitStatus},
- sync::LazyLock,
thread,
time::Duration,
};
@@ -411,9 +410,6 @@ mod linux {
use crate::{Detect, InstalledApp};
- static RELEASE_CHANNEL: LazyLock<String> =
- LazyLock::new(|| include_str!("../../zed/RELEASE_CHANNEL").trim().to_string());
-
struct App(PathBuf);
impl Detect {
@@ -444,10 +440,10 @@ mod linux {
fn zed_version_string(&self) -> String {
format!(
"Zed {}{}{} – {}",
- if *RELEASE_CHANNEL == "stable" {
+ if *release_channel::RELEASE_CHANNEL_NAME == "stable" {
"".to_string()
} else {
- format!("{} ", *RELEASE_CHANNEL)
+ format!("{} ", *release_channel::RELEASE_CHANNEL_NAME)
},
option_env!("RELEASE_VERSION").unwrap_or_default(),
match option_env!("ZED_COMMIT_SHA") {
@@ -459,7 +455,10 @@ mod linux {
}
fn launch(&self, ipc_url: String) -> anyhow::Result<()> {
- let sock_path = paths::data_dir().join(format!("zed-{}.sock", *RELEASE_CHANNEL));
+ let sock_path = paths::data_dir().join(format!(
+ "zed-{}.sock",
+ *release_channel::RELEASE_CHANNEL_NAME
+ ));
let sock = UnixDatagram::unbound()?;
if sock.connect(&sock_path).is_err() {
self.boot_background(ipc_url)?;
@@ -495,11 +494,11 @@ mod linux {
Ok(Fork::Parent(_)) => Ok(()),
Ok(Fork::Child) => {
unsafe { std::env::set_var(FORCE_CLI_MODE_ENV_VAR_NAME, "") };
- if let Err(_) = fork::setsid() {
+ if fork::setsid().is_err() {
eprintln!("failed to setsid: {}", std::io::Error::last_os_error());
process::exit(1);
}
- if let Err(_) = fork::close_fd() {
+ if fork::close_fd().is_err() {
eprintln!("failed to close_fd: {}", std::io::Error::last_os_error());
}
let error =
@@ -519,11 +518,11 @@ mod linux {
) -> Result<(), std::io::Error> {
for _ in 0..100 {
thread::sleep(Duration::from_millis(10));
- if sock.connect_addr(&sock_addr).is_ok() {
+ if sock.connect_addr(sock_addr).is_ok() {
return Ok(());
}
}
- sock.connect_addr(&sock_addr)
+ sock.connect_addr(sock_addr)
}
}
}
@@ -535,8 +534,8 @@ mod flatpak {
use std::process::Command;
use std::{env, process};
- const EXTRA_LIB_ENV_NAME: &'static str = "ZED_FLATPAK_LIB_PATH";
- const NO_ESCAPE_ENV_NAME: &'static str = "ZED_FLATPAK_NO_ESCAPE";
+ const EXTRA_LIB_ENV_NAME: &str = "ZED_FLATPAK_LIB_PATH";
+ const NO_ESCAPE_ENV_NAME: &str = "ZED_FLATPAK_NO_ESCAPE";
/// Adds bundled libraries to LD_LIBRARY_PATH if running under flatpak
pub fn ld_extra_libs() {
@@ -587,14 +586,11 @@ mod flatpak {
pub fn set_bin_if_no_escape(mut args: super::Args) -> super::Args {
if env::var(NO_ESCAPE_ENV_NAME).is_ok()
- && env::var("FLATPAK_ID").map_or(false, |id| id.starts_with("dev.zed.Zed"))
+ && env::var("FLATPAK_ID").is_ok_and(|id| id.starts_with("dev.zed.Zed"))
+ && args.zed.is_none()
{
- if args.zed.is_none() {
- args.zed = Some("/app/libexec/zed-editor".into());
- unsafe {
- env::set_var("ZED_UPDATE_EXPLANATION", "Please use flatpak to update zed")
- };
- }
+ args.zed = Some("/app/libexec/zed-editor".into());
+ unsafe { env::set_var("ZED_UPDATE_EXPLANATION", "Please use flatpak to update zed") };
}
args
}
@@ -930,7 +926,7 @@ mod mac_os {
fn path(&self) -> PathBuf {
match self {
- Bundle::App { app_bundle, .. } => app_bundle.join("Contents/MacOS/zed").clone(),
+ Bundle::App { app_bundle, .. } => app_bundle.join("Contents/MacOS/zed"),
Bundle::LocalPath { executable, .. } => executable.clone(),
}
}
@@ -958,17 +954,14 @@ mod mac_os {
) -> Result<()> {
use anyhow::bail;
- let app_id_prompt = format!("id of app \"{}\"", channel.display_name());
- let app_id_output = Command::new("osascript")
+ let app_path_prompt = format!(
+ "POSIX path of (path to application \"{}\")",
+ channel.display_name()
+ );
+ let app_path_output = Command::new("osascript")
.arg("-e")
- .arg(&app_id_prompt)
+ .arg(&app_path_prompt)
.output()?;
- if !app_id_output.status.success() {
- bail!("Could not determine app id for {}", channel.display_name());
- }
- let app_name = String::from_utf8(app_id_output.stdout)?.trim().to_owned();
- let app_path_prompt = format!("kMDItemCFBundleIdentifier == '{app_name}'");
- let app_path_output = Command::new("mdfind").arg(app_path_prompt).output()?;
if !app_path_output.status.success() {
bail!(
"Could not determine app path for {}",
@@ -17,11 +17,12 @@ test-support = ["clock/test-support", "collections/test-support", "gpui/test-sup
[dependencies]
anyhow.workspace = true
-async-recursion = "0.3"
async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manual-roots"] }
base64.workspace = true
chrono = { workspace = true, features = ["serde"] }
clock.workspace = true
+cloud_api_client.workspace = true
+cloud_llm_client.workspace = true
collections.workspace = true
credentials_provider.workspace = true
derive_more.workspace = true
@@ -33,8 +34,8 @@ http_client.workspace = true
http_client_tls.workspace = true
httparse = "1.10"
log.workspace = true
-paths.workspace = true
parking_lot.workspace = true
+paths.workspace = true
postage.workspace = true
rand.workspace = true
regex.workspace = true
@@ -43,22 +44,22 @@ rpc = { workspace = true, features = ["gpui"] }
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
+serde_urlencoded.workspace = true
settings.workspace = true
sha2.workspace = true
smol.workspace = true
+telemetry.workspace = true
telemetry_events.workspace = true
text.workspace = true
thiserror.workspace = true
time.workspace = true
tiny_http.workspace = true
tokio-socks = { version = "0.5.2", default-features = false, features = ["futures-io"] }
+tokio.workspace = true
url.workspace = true
util.workspace = true
-worktree.workspace = true
-telemetry.workspace = true
-tokio.workspace = true
workspace-hack.workspace = true
-zed_llm_client.workspace = true
+worktree.workspace = true
[dev-dependencies]
clock = { workspace = true, features = ["test-support"] }
@@ -6,22 +6,23 @@ pub mod telemetry;
pub mod user;
pub mod zed_urls;
-use anyhow::{Context as _, Result, anyhow, bail};
-use async_recursion::async_recursion;
+use anyhow::{Context as _, Result, anyhow};
use async_tungstenite::tungstenite::{
client::IntoClientRequest,
error::Error as WebsocketError,
http::{HeaderValue, Request, StatusCode},
};
-use chrono::{DateTime, Utc};
use clock::SystemClock;
+use cloud_api_client::CloudApiClient;
+use cloud_api_client::websocket_protocol::MessageToClient;
use credentials_provider::CredentialsProvider;
+use feature_flags::FeatureFlagAppExt as _;
use futures::{
AsyncReadExt, FutureExt, SinkExt, Stream, StreamExt, TryFutureExt as _, TryStreamExt,
channel::oneshot, future::BoxFuture,
};
use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions};
-use http_client::{AsyncBody, HttpClient, HttpClientWithUrl};
+use http_client::{HttpClient, HttpClientWithUrl, http};
use parking_lot::RwLock;
use postage::watch;
use proxy::connect_proxy_stream;
@@ -31,7 +32,6 @@ use rpc::proto::{AnyTypedEnvelope, EnvelopedMessage, PeerId, RequestMessage};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources};
-use std::pin::Pin;
use std::{
any::TypeId,
convert::TryFrom,
@@ -45,6 +45,7 @@ use std::{
},
time::{Duration, Instant},
};
+use std::{cmp, pin::Pin};
use telemetry::Telemetry;
use thiserror::Error;
use tokio::net::TcpStream;
@@ -65,6 +66,8 @@ pub static IMPERSONATE_LOGIN: LazyLock<Option<String>> = LazyLock::new(|| {
.and_then(|s| if s.is_empty() { None } else { Some(s) })
});
+pub static USE_WEB_LOGIN: LazyLock<bool> = LazyLock::new(|| std::env::var("ZED_WEB_LOGIN").is_ok());
+
pub static ADMIN_API_TOKEN: LazyLock<Option<String>> = LazyLock::new(|| {
std::env::var("ZED_ADMIN_API_TOKEN")
.ok()
@@ -75,10 +78,10 @@ pub static ZED_APP_PATH: LazyLock<Option<PathBuf>> =
LazyLock::new(|| std::env::var("ZED_APP_PATH").ok().map(PathBuf::from));
pub static ZED_ALWAYS_ACTIVE: LazyLock<bool> =
- LazyLock::new(|| std::env::var("ZED_ALWAYS_ACTIVE").map_or(false, |e| !e.is_empty()));
+ LazyLock::new(|| std::env::var("ZED_ALWAYS_ACTIVE").is_ok_and(|e| !e.is_empty()));
pub const INITIAL_RECONNECTION_DELAY: Duration = Duration::from_millis(500);
-pub const MAX_RECONNECTION_DELAY: Duration = Duration::from_secs(10);
+pub const MAX_RECONNECTION_DELAY: Duration = Duration::from_secs(30);
pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(20);
actions!(
@@ -161,20 +164,8 @@ pub fn init(client: &Arc<Client>, cx: &mut App) {
let client = client.clone();
move |_: &SignIn, cx| {
if let Some(client) = client.upgrade() {
- cx.spawn(
- async move |cx| match client.authenticate_and_connect(true, &cx).await {
- ConnectionResult::Timeout => {
- log::error!("Initial authentication timed out");
- }
- ConnectionResult::ConnectionReset => {
- log::error!("Initial authentication connection reset");
- }
- ConnectionResult::Result(r) => {
- r.log_err();
- }
- },
- )
- .detach();
+ cx.spawn(async move |cx| client.sign_in_with_optional_connect(true, cx).await)
+ .detach_and_log_err(cx);
}
}
});
@@ -184,7 +175,7 @@ pub fn init(client: &Arc<Client>, cx: &mut App) {
move |_: &SignOut, cx| {
if let Some(client) = client.upgrade() {
cx.spawn(async move |cx| {
- client.sign_out(&cx).await;
+ client.sign_out(cx).await;
})
.detach();
}
@@ -192,11 +183,11 @@ pub fn init(client: &Arc<Client>, cx: &mut App) {
});
cx.on_action({
- let client = client.clone();
+ let client = client;
move |_: &Reconnect, cx| {
if let Some(client) = client.upgrade() {
cx.spawn(async move |cx| {
- client.reconnect(&cx);
+ client.reconnect(cx);
})
.detach();
}
@@ -204,6 +195,8 @@ pub fn init(client: &Arc<Client>, cx: &mut App) {
});
}
+pub type MessageToClientHandler = Box<dyn Fn(&MessageToClient, &mut App) + Send + Sync + 'static>;
+
struct GlobalClient(Arc<Client>);
impl Global for GlobalClient {}
@@ -212,10 +205,12 @@ pub struct Client {
id: AtomicU64,
peer: Arc<Peer>,
http: Arc<HttpClientWithUrl>,
+ cloud_client: Arc<CloudApiClient>,
telemetry: Arc<Telemetry>,
credentials_provider: ClientCredentialsProvider,
state: RwLock<ClientState>,
handler_set: parking_lot::Mutex<ProtoMessageHandlerSet>,
+ message_to_client_handlers: parking_lot::Mutex<Vec<MessageToClientHandler>>,
#[allow(clippy::type_complexity)]
#[cfg(any(test, feature = "test-support"))]
@@ -282,6 +277,8 @@ pub enum Status {
SignedOut,
UpgradeRequired,
Authenticating,
+ Authenticated,
+ AuthenticationError,
Connecting,
ConnectionError,
Connected {
@@ -301,6 +298,13 @@ impl Status {
matches!(self, Self::Connected { .. })
}
+ pub fn is_signing_in(&self) -> bool {
+ matches!(
+ self,
+ Self::Authenticating | Self::Reauthenticating | Self::Connecting | Self::Reconnecting
+ )
+ }
+
pub fn is_signed_out(&self) -> bool {
matches!(self, Self::SignedOut | Self::UpgradeRequired)
}
@@ -551,10 +555,12 @@ impl Client {
id: AtomicU64::new(0),
peer: Peer::new(0),
telemetry: Telemetry::new(clock, http.clone(), cx),
+ cloud_client: Arc::new(CloudApiClient::new(http.clone())),
http,
credentials_provider: ClientCredentialsProvider::new(cx),
state: Default::default(),
handler_set: Default::default(),
+ message_to_client_handlers: parking_lot::Mutex::new(Vec::new()),
#[cfg(any(test, feature = "test-support"))]
authenticate: Default::default(),
@@ -583,6 +589,10 @@ impl Client {
self.http.clone()
}
+ pub fn cloud_client(&self) -> Arc<CloudApiClient> {
+ self.cloud_client.clone()
+ }
+
pub fn set_id(&self, id: u64) -> &Self {
self.id.store(id, Ordering::SeqCst);
self
@@ -669,7 +679,7 @@ impl Client {
let mut delay = INITIAL_RECONNECTION_DELAY;
loop {
- match client.authenticate_and_connect(true, &cx).await {
+ match client.connect(true, cx).await {
ConnectionResult::Timeout => {
log::error!("client connect attempt timed out")
}
@@ -685,18 +695,20 @@ impl Client {
}
}
- if matches!(*client.status().borrow(), Status::ConnectionError) {
+ if matches!(
+ *client.status().borrow(),
+ Status::AuthenticationError | Status::ConnectionError
+ ) {
client.set_status(
Status::ReconnectionError {
next_reconnection: Instant::now() + delay,
},
- &cx,
+ cx,
);
- cx.background_executor().timer(delay).await;
- delay = delay
- .mul_f32(rng.gen_range(0.5..=2.5))
- .max(INITIAL_RECONNECTION_DELAY)
- .min(MAX_RECONNECTION_DELAY);
+ let jitter =
+ Duration::from_millis(rng.gen_range(0..delay.as_millis() as u64));
+ cx.background_executor().timer(delay + jitter).await;
+ delay = cmp::min(delay * 2, MAX_RECONNECTION_DELAY);
} else {
break;
}
@@ -781,7 +793,7 @@ impl Client {
Arc::new(move |subscriber, envelope, client, cx| {
let subscriber = subscriber.downcast::<E>().unwrap();
let envelope = envelope.into_any().downcast::<TypedEnvelope<M>>().unwrap();
- handler(subscriber, *envelope, client.clone(), cx).boxed_local()
+ handler(subscriber, *envelope, client, cx).boxed_local()
}),
);
if prev_handler.is_some() {
@@ -840,40 +852,38 @@ impl Client {
.is_some()
}
- #[async_recursion(?Send)]
- pub async fn authenticate_and_connect(
+ pub async fn sign_in(
self: &Arc<Self>,
try_provider: bool,
cx: &AsyncApp,
- ) -> ConnectionResult<()> {
- let was_disconnected = match *self.status().borrow() {
- Status::SignedOut => true,
- Status::ConnectionError
- | Status::ConnectionLost
- | Status::Authenticating { .. }
- | Status::Reauthenticating { .. }
- | Status::ReconnectionError { .. } => false,
- Status::Connected { .. } | Status::Connecting { .. } | Status::Reconnecting { .. } => {
- return ConnectionResult::Result(Ok(()));
- }
- Status::UpgradeRequired => {
- return ConnectionResult::Result(
- Err(EstablishConnectionError::UpgradeRequired)
- .context("client auth and connect"),
- );
- }
- };
- if was_disconnected {
+ ) -> Result<Credentials> {
+ if self.status().borrow().is_signed_out() {
self.set_status(Status::Authenticating, cx);
} else {
- self.set_status(Status::Reauthenticating, cx)
+ self.set_status(Status::Reauthenticating, cx);
}
- let mut read_from_provider = false;
- let mut credentials = self.state.read().credentials.clone();
- if credentials.is_none() && try_provider {
- credentials = self.credentials_provider.read_credentials(cx).await;
- read_from_provider = credentials.is_some();
+ let mut credentials = None;
+
+ let old_credentials = self.state.read().credentials.clone();
+ if let Some(old_credentials) = old_credentials
+ && self.validate_credentials(&old_credentials, cx).await?
+ {
+ credentials = Some(old_credentials);
+ }
+
+ if credentials.is_none()
+ && try_provider
+ && let Some(stored_credentials) = self.credentials_provider.read_credentials(cx).await
+ {
+ if self.validate_credentials(&stored_credentials, cx).await? {
+ credentials = Some(stored_credentials);
+ } else {
+ self.credentials_provider
+ .delete_credentials(cx)
+ .await
+ .log_err();
+ }
}
if credentials.is_none() {
@@ -882,20 +892,163 @@ impl Client {
futures::select_biased! {
authenticate = self.authenticate(cx).fuse() => {
match authenticate {
- Ok(creds) => credentials = Some(creds),
+ Ok(creds) => {
+ if IMPERSONATE_LOGIN.is_none() {
+ self.credentials_provider
+ .write_credentials(creds.user_id, creds.access_token.clone(), cx)
+ .await
+ .log_err();
+ }
+
+ credentials = Some(creds);
+ },
Err(err) => {
- self.set_status(Status::ConnectionError, cx);
- return ConnectionResult::Result(Err(err));
+ self.set_status(Status::AuthenticationError, cx);
+ return Err(err);
}
}
}
_ = status_rx.next().fuse() => {
- return ConnectionResult::Result(Err(anyhow!("authentication canceled")));
+ return Err(anyhow!("authentication canceled"));
}
}
}
+
let credentials = credentials.unwrap();
self.set_id(credentials.user_id);
+ self.cloud_client
+ .set_credentials(credentials.user_id as u32, credentials.access_token.clone());
+ self.state.write().credentials = Some(credentials.clone());
+ self.set_status(Status::Authenticated, cx);
+
+ Ok(credentials)
+ }
+
+ async fn validate_credentials(
+ self: &Arc<Self>,
+ credentials: &Credentials,
+ cx: &AsyncApp,
+ ) -> Result<bool> {
+ match self
+ .cloud_client
+ .validate_credentials(credentials.user_id as u32, &credentials.access_token)
+ .await
+ {
+ Ok(valid) => Ok(valid),
+ Err(err) => {
+ self.set_status(Status::AuthenticationError, cx);
+ Err(anyhow!("failed to validate credentials: {}", err))
+ }
+ }
+ }
+
+ /// Establishes a WebSocket connection with Cloud for receiving updates from the server.
+ async fn connect_to_cloud(self: &Arc<Self>, cx: &AsyncApp) -> Result<()> {
+ let connect_task = cx.update({
+ let cloud_client = self.cloud_client.clone();
+ move |cx| cloud_client.connect(cx)
+ })??;
+ let connection = connect_task.await?;
+
+ let (mut messages, task) = cx.update(|cx| connection.spawn(cx))?;
+ task.detach();
+
+ cx.spawn({
+ let this = self.clone();
+ async move |cx| {
+ while let Some(message) = messages.next().await {
+ if let Some(message) = message.log_err() {
+ this.handle_message_to_client(message, cx);
+ }
+ }
+ }
+ })
+ .detach();
+
+ Ok(())
+ }
+
+ /// Performs a sign-in and also (optionally) connects to Collab.
+ ///
+ /// Only Zed staff automatically connect to Collab.
+ pub async fn sign_in_with_optional_connect(
+ self: &Arc<Self>,
+ try_provider: bool,
+ cx: &AsyncApp,
+ ) -> Result<()> {
+ // Don't try to sign in again if we're already connected to Collab, as it will temporarily disconnect us.
+ if self.status().borrow().is_connected() {
+ return Ok(());
+ }
+
+ let (is_staff_tx, is_staff_rx) = oneshot::channel::<bool>();
+ let mut is_staff_tx = Some(is_staff_tx);
+ cx.update(|cx| {
+ cx.on_flags_ready(move |state, _cx| {
+ if let Some(is_staff_tx) = is_staff_tx.take() {
+ is_staff_tx.send(state.is_staff).log_err();
+ }
+ })
+ .detach();
+ })
+ .log_err();
+
+ let credentials = self.sign_in(try_provider, cx).await?;
+
+ self.connect_to_cloud(cx).await.log_err();
+
+ cx.update(move |cx| {
+ cx.spawn({
+ let client = self.clone();
+ async move |cx| {
+ let is_staff = is_staff_rx.await?;
+ if is_staff {
+ match client.connect_with_credentials(credentials, cx).await {
+ ConnectionResult::Timeout => Err(anyhow!("connection timed out")),
+ ConnectionResult::ConnectionReset => Err(anyhow!("connection reset")),
+ ConnectionResult::Result(result) => {
+ result.context("client auth and connect")
+ }
+ }
+ } else {
+ Ok(())
+ }
+ }
+ })
+ .detach_and_log_err(cx);
+ })
+ .log_err();
+
+ Ok(())
+ }
+
+ pub async fn connect(
+ self: &Arc<Self>,
+ try_provider: bool,
+ cx: &AsyncApp,
+ ) -> ConnectionResult<()> {
+ let was_disconnected = match *self.status().borrow() {
+ Status::SignedOut | Status::Authenticated => true,
+ Status::ConnectionError
+ | Status::ConnectionLost
+ | Status::Authenticating
+ | Status::AuthenticationError
+ | Status::Reauthenticating
+ | Status::ReconnectionError { .. } => false,
+ Status::Connected { .. } | Status::Connecting | Status::Reconnecting => {
+ return ConnectionResult::Result(Ok(()));
+ }
+ Status::UpgradeRequired => {
+ return ConnectionResult::Result(
+ Err(EstablishConnectionError::UpgradeRequired)
+ .context("client auth and connect"),
+ );
+ }
+ };
+ let credentials = match self.sign_in(try_provider, cx).await {
+ Ok(credentials) => credentials,
+ Err(err) => return ConnectionResult::Result(Err(err)),
+ };
if was_disconnected {
self.set_status(Status::Connecting, cx);
@@ -903,17 +1056,20 @@ impl Client {
self.set_status(Status::Reconnecting, cx);
}
+ self.connect_with_credentials(credentials, cx).await
+ }
+
+ async fn connect_with_credentials(
+ self: &Arc<Self>,
+ credentials: Credentials,
+ cx: &AsyncApp,
+ ) -> ConnectionResult<()> {
let mut timeout =
futures::FutureExt::fuse(cx.background_executor().timer(CONNECTION_TIMEOUT));
futures::select_biased! {
connection = self.establish_connection(&credentials, cx).fuse() => {
match connection {
Ok(conn) => {
- self.state.write().credentials = Some(credentials.clone());
- if !read_from_provider && IMPERSONATE_LOGIN.is_none() {
- self.credentials_provider.write_credentials(credentials.user_id, credentials.access_token, cx).await.log_err();
- }
-
futures::select_biased! {
result = self.set_connection(conn, cx).fuse() => {
match result.context("client auth and connect") {
@@ -931,15 +1087,8 @@ impl Client {
}
}
Err(EstablishConnectionError::Unauthorized) => {
- self.state.write().credentials.take();
- if read_from_provider {
- self.credentials_provider.delete_credentials(cx).await.log_err();
- self.set_status(Status::SignedOut, cx);
- self.authenticate_and_connect(false, cx).await
- } else {
- self.set_status(Status::ConnectionError, cx);
- ConnectionResult::Result(Err(EstablishConnectionError::Unauthorized).context("client auth and connect"))
- }
+ self.set_status(Status::ConnectionError, cx);
+ ConnectionResult::Result(Err(EstablishConnectionError::Unauthorized).context("client auth and connect"))
}
Err(EstablishConnectionError::UpgradeRequired) => {
self.set_status(Status::UpgradeRequired, cx);
@@ -1010,7 +1159,7 @@ impl Client {
let this = self.clone();
async move |cx| {
while let Some(message) = incoming.next().await {
- this.handle_message(message, &cx);
+ this.handle_message(message, cx);
// Don't starve the main thread when receiving lots of messages at once.
smol::future::yield_now().await;
}
@@ -1028,12 +1177,12 @@ impl Client {
peer_id,
})
{
- this.set_status(Status::SignedOut, &cx);
+ this.set_status(Status::SignedOut, cx);
}
}
Err(err) => {
log::error!("connection error: {:?}", err);
- this.set_status(Status::ConnectionLost, &cx);
+ this.set_status(Status::ConnectionLost, cx);
}
}
})
@@ -1103,7 +1252,7 @@ impl Client {
.to_str()
.map_err(EstablishConnectionError::other)?
.to_string();
- Url::parse(&collab_url).with_context(|| format!("parsing colab rpc url {collab_url}"))
+ Url::parse(&collab_url).with_context(|| format!("parsing collab rpc url {collab_url}"))
}
}
@@ -1123,6 +1272,7 @@ impl Client {
let http = self.http.clone();
let proxy = http.proxy().cloned();
+ let user_agent = http.user_agent().cloned();
let credentials = credentials.clone();
let rpc_url = self.rpc_url(http, release_channel);
let system_id = self.telemetry.system_id();
@@ -1142,19 +1292,21 @@ impl Client {
"http" => Http,
_ => Err(anyhow!("invalid rpc url: {}", rpc_url))?,
};
- let rpc_host = rpc_url
- .host_str()
- .zip(rpc_url.port_or_known_default())
- .context("missing host in rpc url")?;
-
- let stream = {
- let handle = cx.update(|cx| gpui_tokio::Tokio::handle(cx)).ok().unwrap();
- let _guard = handle.enter();
- match proxy {
- Some(proxy) => connect_proxy_stream(&proxy, rpc_host).await?,
- None => Box::new(TcpStream::connect(rpc_host).await?),
+
+ let stream = gpui_tokio::Tokio::spawn_result(cx, {
+ let rpc_url = rpc_url.clone();
+ async move {
+ let rpc_host = rpc_url
+ .host_str()
+ .zip(rpc_url.port_or_known_default())
+ .context("missing host in rpc url")?;
+ Ok(match proxy {
+ Some(proxy) => connect_proxy_stream(&proxy, rpc_host).await?,
+ None => Box::new(TcpStream::connect(rpc_host).await?),
+ })
}
- };
+ })?
+ .await?;
log::info!("connected to rpc endpoint {}", rpc_url);
@@ -1174,7 +1326,7 @@ impl Client {
// We then modify the request to add our desired headers.
let request_headers = request.headers_mut();
request_headers.insert(
- "Authorization",
+ http::header::AUTHORIZATION,
HeaderValue::from_str(&credentials.authorization_header())?,
);
request_headers.insert(
@@ -1186,6 +1338,9 @@ impl Client {
"x-zed-release-channel",
HeaderValue::from_str(release_channel.map(|r| r.dev_name()).unwrap_or("unknown"))?,
);
+ if let Some(user_agent) = user_agent {
+ request_headers.insert(http::header::USER_AGENT, user_agent);
+ }
if let Some(system_id) = system_id {
request_headers.insert("x-zed-system-id", HeaderValue::from_str(&system_id)?);
}
@@ -1239,11 +1394,13 @@ impl Client {
if let Some((login, token)) =
IMPERSONATE_LOGIN.as_ref().zip(ADMIN_API_TOKEN.as_ref())
{
- eprintln!("authenticate as admin {login}, {token}");
+ if !*USE_WEB_LOGIN {
+ eprintln!("authenticate as admin {login}, {token}");
- return this
- .authenticate_as_admin(http, login.clone(), token.clone())
- .await;
+ return this
+ .authenticate_as_admin(http, login.clone(), token.clone())
+ .await;
+ }
}
// Start an HTTP server to receive the redirect from Zed's sign-in page.
@@ -1265,6 +1422,12 @@ impl Client {
open_url_tx.send(url).log_err();
+ #[derive(Deserialize)]
+ struct CallbackParams {
+ pub user_id: String,
+ pub access_token: String,
+ }
+
// Receive the HTTP request from the user's browser. Retrieve the user id and encrypted
// access token from the query params.
//
@@ -1275,17 +1438,13 @@ impl Client {
for _ in 0..100 {
if let Some(req) = server.recv_timeout(Duration::from_secs(1))? {
let path = req.url();
- let mut user_id = None;
- let mut access_token = None;
let url = Url::parse(&format!("http://example.com{}", path))
.context("failed to parse login notification url")?;
- for (key, value) in url.query_pairs() {
- if key == "access_token" {
- access_token = Some(value.to_string());
- } else if key == "user_id" {
- user_id = Some(value.to_string());
- }
- }
+ let callback_params: CallbackParams =
+ serde_urlencoded::from_str(url.query().unwrap_or_default())
+ .context(
+ "failed to parse sign-in callback query parameters",
+ )?;
let post_auth_url =
http.build_url("/native_app_signin_succeeded");
@@ -1300,8 +1459,8 @@ impl Client {
)
.context("failed to respond to login http request")?;
return Ok((
- user_id.context("missing user_id parameter")?,
- access_token.context("missing access_token parameter")?,
+ callback_params.user_id,
+ callback_params.access_token,
));
}
}
@@ -1330,96 +1489,31 @@ impl Client {
self: &Arc<Self>,
http: Arc<HttpClientWithUrl>,
login: String,
- mut api_token: String,
+ api_token: String,
) -> Result<Credentials> {
- #[derive(Deserialize)]
- struct AuthenticatedUserResponse {
- user: User,
+ #[derive(Serialize)]
+ struct ImpersonateUserBody {
+ github_login: String,
}
#[derive(Deserialize)]
- struct User {
- id: u64,
+ struct ImpersonateUserResponse {
+ user_id: u64,
+ access_token: String,
}
- let github_user = {
- #[derive(Deserialize)]
- struct GithubUser {
- id: i32,
- login: String,
- created_at: DateTime<Utc>,
- }
-
- let request = {
- let mut request_builder =
- Request::get(&format!("https://api.github.com/users/{login}"));
- if let Ok(github_token) = std::env::var("GITHUB_TOKEN") {
- request_builder =
- request_builder.header("Authorization", format!("Bearer {}", github_token));
- }
-
- request_builder.body(AsyncBody::empty())?
- };
-
- let mut response = http
- .send(request)
- .await
- .context("error fetching GitHub user")?;
-
- let mut body = Vec::new();
- response
- .body_mut()
- .read_to_end(&mut body)
- .await
- .context("error reading GitHub user")?;
-
- if !response.status().is_success() {
- let text = String::from_utf8_lossy(body.as_slice());
- bail!(
- "status error {}, response: {text:?}",
- response.status().as_u16()
- );
- }
-
- serde_json::from_slice::<GithubUser>(body.as_slice()).map_err(|err| {
- log::error!("Error deserializing: {:?}", err);
- log::error!(
- "GitHub API response text: {:?}",
- String::from_utf8_lossy(body.as_slice())
- );
- anyhow!("error deserializing GitHub user")
- })?
- };
-
- let query_params = [
- ("github_login", &github_user.login),
- ("github_user_id", &github_user.id.to_string()),
- (
- "github_user_created_at",
- &github_user.created_at.to_rfc3339(),
- ),
- ];
-
- // Use the collab server's admin API to retrieve the ID
- // of the impersonated user.
- let mut url = self.rpc_url(http.clone(), None).await?;
- url.set_path("/user");
- url.set_query(Some(
- &query_params
- .iter()
- .map(|(key, value)| {
- format!(
- "{}={}",
- key,
- url::form_urlencoded::byte_serialize(value.as_bytes()).collect::<String>()
- )
- })
- .collect::<Vec<String>>()
- .join("&"),
- ));
- let request: http_client::Request<AsyncBody> = Request::get(url.as_str())
- .header("Authorization", format!("token {api_token}"))
- .body("".into())?;
+ let url = self
+ .http
+ .build_zed_cloud_url("/internal/users/impersonate", &[])?;
+ let request = Request::post(url.as_str())
+ .header("Content-Type", "application/json")
+ .header("Authorization", format!("Bearer {api_token}"))
+ .body(
+ serde_json::to_string(&ImpersonateUserBody {
+ github_login: login,
+ })?
+ .into(),
+ )?;
let mut response = http.send(request).await?;
let mut body = String::new();
@@ -1430,18 +1524,17 @@ impl Client {
response.status().as_u16(),
body,
);
- let response: AuthenticatedUserResponse = serde_json::from_str(&body)?;
+ let response: ImpersonateUserResponse = serde_json::from_str(&body)?;
- // Use the admin API token to authenticate as the impersonated user.
- api_token.insert_str(0, "ADMIN_TOKEN:");
Ok(Credentials {
- user_id: response.user.id,
- access_token: api_token,
+ user_id: response.user_id,
+ access_token: response.access_token,
})
}
pub async fn sign_out(self: &Arc<Self>, cx: &AsyncApp) {
self.state.write().credentials = None;
+ self.cloud_client.clear_credentials();
self.disconnect(cx);
if self.has_credentials(cx).await {
@@ -1603,6 +1696,24 @@ impl Client {
}
}
+ pub fn add_message_to_client_handler(
+ self: &Arc<Client>,
+ handler: impl Fn(&MessageToClient, &mut App) + Send + Sync + 'static,
+ ) {
+ self.message_to_client_handlers
+ .lock()
+ .push(Box::new(handler));
+ }
+
+ fn handle_message_to_client(self: &Arc<Client>, message: MessageToClient, cx: &AsyncApp) {
+ cx.update(|cx| {
+ for handler in self.message_to_client_handlers.lock().iter() {
+ handler(&message, cx);
+ }
+ })
+ .ok();
+ }
+
pub fn telemetry(&self) -> &Arc<Telemetry> {
&self.telemetry
}
@@ -1670,7 +1781,7 @@ pub fn parse_zed_link<'a>(link: &'a str, cx: &App) -> Option<&'a str> {
#[cfg(test)]
mod tests {
use super::*;
- use crate::test::FakeServer;
+ use crate::test::{FakeServer, parse_authorization_header};
use clock::FakeSystemClock;
use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
@@ -1721,6 +1832,46 @@ mod tests {
assert_eq!(server.auth_count(), 2); // Client re-authenticated due to an invalid token
}
+ #[gpui::test(iterations = 10)]
+ async fn test_auth_failure_during_reconnection(cx: &mut TestAppContext) {
+ init_test(cx);
+ let http_client = FakeHttpClient::with_200_response();
+ let client =
+ cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client.clone(), cx));
+ let server = FakeServer::for_client(42, &client, cx).await;
+ let mut status = client.status();
+ assert!(matches!(
+ status.next().await,
+ Some(Status::Connected { .. })
+ ));
+ assert_eq!(server.auth_count(), 1);
+
+ // Simulate an auth failure during reconnection.
+ http_client
+ .as_fake()
+ .replace_handler(|_, _request| async move {
+ Ok(http_client::Response::builder()
+ .status(503)
+ .body("".into())
+ .unwrap())
+ });
+ server.disconnect();
+ while !matches!(status.next().await, Some(Status::ReconnectionError { .. })) {}
+
+ // Restore the ability to authenticate.
+ http_client
+ .as_fake()
+ .replace_handler(|_, _request| async move {
+ Ok(http_client::Response::builder()
+ .status(200)
+ .body("".into())
+ .unwrap())
+ });
+ cx.executor().advance_clock(Duration::from_secs(10));
+ while !matches!(status.next().await, Some(Status::Connected { .. })) {}
+ assert_eq!(server.auth_count(), 1); // Client reused the cached credentials when reconnecting
+ }
+
#[gpui::test(iterations = 10)]
async fn test_connection_timeout(executor: BackgroundExecutor, cx: &mut TestAppContext) {
init_test(cx);
@@ -1751,16 +1902,13 @@ mod tests {
});
let auth_and_connect = cx.spawn({
let client = client.clone();
- |cx| async move { client.authenticate_and_connect(false, &cx).await }
+ |cx| async move { client.connect(false, &cx).await }
});
executor.run_until_parked();
assert!(matches!(status.next().await, Some(Status::Connecting)));
executor.advance_clock(CONNECTION_TIMEOUT);
- assert!(matches!(
- status.next().await,
- Some(Status::ConnectionError { .. })
- ));
+ assert!(matches!(status.next().await, Some(Status::ConnectionError)));
auth_and_connect.await.into_response().unwrap_err();
// Allow the connection to be established.
@@ -1784,10 +1932,7 @@ mod tests {
})
});
executor.advance_clock(2 * INITIAL_RECONNECTION_DELAY);
- assert!(matches!(
- status.next().await,
- Some(Status::Reconnecting { .. })
- ));
+ assert!(matches!(status.next().await, Some(Status::Reconnecting)));
executor.advance_clock(CONNECTION_TIMEOUT);
assert!(matches!(
@@ -1796,6 +1941,75 @@ mod tests {
));
}
+ #[gpui::test(iterations = 10)]
+ async fn test_reauthenticate_only_if_unauthorized(cx: &mut TestAppContext) {
+ init_test(cx);
+ let auth_count = Arc::new(Mutex::new(0));
+ 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({
+ let auth_count = auth_count.clone();
+ move |cx| {
+ let auth_count = auth_count.clone();
+ cx.background_spawn(async move {
+ *auth_count.lock() += 1;
+ Ok(Credentials {
+ user_id: 1,
+ access_token: auth_count.lock().to_string(),
+ })
+ })
+ }
+ });
+
+ let credentials = client.sign_in(false, &cx.to_async()).await.unwrap();
+ assert_eq!(*auth_count.lock(), 1);
+ assert_eq!(credentials.access_token, "1");
+
+ // If credentials are still valid, signing in doesn't trigger authentication.
+ let credentials = client.sign_in(false, &cx.to_async()).await.unwrap();
+ assert_eq!(*auth_count.lock(), 1);
+ assert_eq!(credentials.access_token, "1");
+
+ // If the server is unavailable, signing in doesn't trigger authentication.
+ http_client
+ .as_fake()
+ .replace_handler(|_, _request| async move {
+ Ok(http_client::Response::builder()
+ .status(503)
+ .body("".into())
+ .unwrap())
+ });
+ client.sign_in(false, &cx.to_async()).await.unwrap_err();
+ assert_eq!(*auth_count.lock(), 1);
+
+ // If credentials became invalid, signing in triggers authentication.
+ http_client
+ .as_fake()
+ .replace_handler(|_, request| async move {
+ let credentials = parse_authorization_header(&request).unwrap();
+ if credentials.access_token == "2" {
+ Ok(http_client::Response::builder()
+ .status(200)
+ .body("".into())
+ .unwrap())
+ } else {
+ Ok(http_client::Response::builder()
+ .status(401)
+ .body("".into())
+ .unwrap())
+ }
+ });
+ let credentials = client.sign_in(false, &cx.to_async()).await.unwrap();
+ assert_eq!(*auth_count.lock(), 2);
+ assert_eq!(credentials.access_token, "2");
+ }
+
#[gpui::test(iterations = 10)]
async fn test_authenticating_more_than_once(
cx: &mut TestAppContext,
@@ -1828,16 +2042,13 @@ mod tests {
let _authenticate = cx.spawn({
let client = client.clone();
- move |cx| async move { client.authenticate_and_connect(false, &cx).await }
+ move |cx| async move { client.connect(false, &cx).await }
});
executor.run_until_parked();
assert_eq!(*auth_count.lock(), 1);
assert_eq!(*dropped_auth_count.lock(), 0);
- let _authenticate = cx.spawn({
- let client = client.clone();
- |cx| async move { client.authenticate_and_connect(false, &cx).await }
- });
+ let _authenticate = cx.spawn(|cx| async move { client.connect(false, &cx).await });
executor.run_until_parked();
assert_eq!(*auth_count.lock(), 2);
assert_eq!(*dropped_auth_count.lock(), 1);
@@ -74,6 +74,12 @@ static ZED_CLIENT_CHECKSUM_SEED: LazyLock<Option<Vec<u8>>> = LazyLock::new(|| {
})
});
+pub static MINIDUMP_ENDPOINT: LazyLock<Option<String>> = LazyLock::new(|| {
+ option_env!("ZED_MINIDUMP_ENDPOINT")
+ .map(str::to_string)
+ .or_else(|| env::var("ZED_MINIDUMP_ENDPOINT").ok())
+});
+
static DOTNET_PROJECT_FILES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(global\.json|Directory\.Build\.props|.*\.(csproj|fsproj|vbproj|sln))$").unwrap()
});
@@ -334,22 +340,35 @@ impl Telemetry {
}
pub fn log_edit_event(self: &Arc<Self>, environment: &'static str, is_via_ssh: bool) {
+ static LAST_EVENT_TIME: Mutex<Option<Instant>> = Mutex::new(None);
+
let mut state = self.state.lock();
let period_data = state.event_coalescer.log_event(environment);
drop(state);
- if let Some((start, end, environment)) = period_data {
- let duration = end
- .saturating_duration_since(start)
- .min(Duration::from_secs(60 * 60 * 24))
- .as_millis() as i64;
+ if let Some(mut last_event) = LAST_EVENT_TIME.try_lock() {
+ let current_time = std::time::Instant::now();
+ let last_time = last_event.get_or_insert(current_time);
- telemetry::event!(
- "Editor Edited",
- duration = duration,
- environment = environment,
- is_via_ssh = is_via_ssh
- );
+ if current_time.duration_since(*last_time) > Duration::from_secs(60 * 10) {
+ *last_time = current_time;
+ } else {
+ return;
+ }
+
+ if let Some((start, end, environment)) = period_data {
+ let duration = end
+ .saturating_duration_since(start)
+ .min(Duration::from_secs(60 * 60 * 24))
+ .as_millis() as i64;
+
+ telemetry::event!(
+ "Editor Edited",
+ duration = duration,
+ environment = environment,
+ is_via_ssh = is_via_ssh
+ );
+ }
}
}
@@ -358,13 +377,13 @@ impl Telemetry {
worktree_id: WorktreeId,
updated_entries_set: &UpdatedEntriesSet,
) {
- let Some(project_type_names) = self.detect_project_types(worktree_id, updated_entries_set)
+ let Some(project_types) = self.detect_project_types(worktree_id, updated_entries_set)
else {
return;
};
- for project_type_name in project_type_names {
- telemetry::event!("Project Opened", project_type = project_type_name);
+ for project_type in project_types {
+ telemetry::event!("Project Opened", project_type = project_type);
}
}
@@ -720,7 +739,7 @@ mod tests {
);
// Third scan of worktree does not double report, as we already reported
- test_project_discovery_helper(telemetry.clone(), vec!["package.json"], None, worktree_id);
+ test_project_discovery_helper(telemetry, vec!["package.json"], None, worktree_id);
}
#[gpui::test]
@@ -732,7 +751,7 @@ mod tests {
let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
test_project_discovery_helper(
- telemetry.clone(),
+ telemetry,
vec!["package.json", "pnpm-lock.yaml"],
Some(vec!["node", "pnpm"]),
1,
@@ -748,7 +767,7 @@ mod tests {
let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
test_project_discovery_helper(
- telemetry.clone(),
+ telemetry,
vec!["package.json", "yarn.lock"],
Some(vec!["node", "yarn"]),
1,
@@ -767,7 +786,7 @@ mod tests {
// project type for the same worktree multiple times
test_project_discovery_helper(
- telemetry.clone().clone(),
+ telemetry.clone(),
vec!["global.json"],
Some(vec!["dotnet"]),
1,
@@ -1,13 +1,12 @@
use crate::{Client, Connection, Credentials, EstablishConnectionError, UserStore};
use anyhow::{Context as _, Result, anyhow};
-use chrono::Duration;
+use cloud_api_client::{AuthenticatedUser, GetAuthenticatedUserResponse, PlanInfo};
+use cloud_llm_client::{CurrentUsage, Plan, UsageData, UsageLimit};
use futures::{StreamExt, stream::BoxStream};
use gpui::{AppContext as _, BackgroundExecutor, Entity, TestAppContext};
+use http_client::{AsyncBody, Method, Request, http};
use parking_lot::Mutex;
-use rpc::{
- ConnectionId, Peer, Receipt, TypedEnvelope,
- proto::{self, GetPrivateUserInfo, GetPrivateUserInfoResponse},
-};
+use rpc::{ConnectionId, Peer, Receipt, TypedEnvelope, proto};
use std::sync::Arc;
pub struct FakeServer {
@@ -39,6 +38,44 @@ impl FakeServer {
executor: cx.executor(),
};
+ client.http_client().as_fake().replace_handler({
+ let state = server.state.clone();
+ move |old_handler, req| {
+ let state = state.clone();
+ let old_handler = old_handler.clone();
+ async move {
+ match (req.method(), req.uri().path()) {
+ (&Method::GET, "/client/users/me") => {
+ let credentials = parse_authorization_header(&req);
+ if credentials
+ != Some(Credentials {
+ user_id: client_user_id,
+ access_token: state.lock().access_token.to_string(),
+ })
+ {
+ return Ok(http_client::Response::builder()
+ .status(401)
+ .body("Unauthorized".into())
+ .unwrap());
+ }
+
+ Ok(http_client::Response::builder()
+ .status(200)
+ .body(
+ serde_json::to_string(&make_get_authenticated_user_response(
+ client_user_id as i32,
+ format!("user-{client_user_id}"),
+ ))
+ .unwrap()
+ .into(),
+ )
+ .unwrap())
+ }
+ _ => old_handler(req).await,
+ }
+ }
+ }
+ });
client
.override_authenticate({
let state = Arc::downgrade(&server.state);
@@ -105,7 +142,7 @@ impl FakeServer {
});
client
- .authenticate_and_connect(false, &cx.to_async())
+ .connect(false, &cx.to_async())
.await
.into_response()
.unwrap();
@@ -146,50 +183,27 @@ impl FakeServer {
pub async fn receive<M: proto::EnvelopedMessage>(&self) -> Result<TypedEnvelope<M>> {
self.executor.start_waiting();
- loop {
- let message = self
- .state
- .lock()
- .incoming
- .as_mut()
- .expect("not connected")
- .next()
- .await
- .context("other half hung up")?;
- self.executor.finish_waiting();
- let type_name = message.payload_type_name();
- let message = message.into_any();
-
- if message.is::<TypedEnvelope<M>>() {
- return Ok(*message.downcast().unwrap());
- }
-
- let accepted_tos_at = chrono::Utc::now()
- .checked_sub_signed(Duration::hours(5))
- .expect("failed to build accepted_tos_at")
- .timestamp() as u64;
-
- if message.is::<TypedEnvelope<GetPrivateUserInfo>>() {
- self.respond(
- message
- .downcast::<TypedEnvelope<GetPrivateUserInfo>>()
- .unwrap()
- .receipt(),
- GetPrivateUserInfoResponse {
- metrics_id: "the-metrics-id".into(),
- staff: false,
- flags: Default::default(),
- accepted_tos_at: Some(accepted_tos_at),
- },
- );
- continue;
- }
+ let message = self
+ .state
+ .lock()
+ .incoming
+ .as_mut()
+ .expect("not connected")
+ .next()
+ .await
+ .context("other half hung up")?;
+ self.executor.finish_waiting();
+ let type_name = message.payload_type_name();
+ let message = message.into_any();
- panic!(
- "fake server received unexpected message type: {:?}",
- type_name
- );
+ if message.is::<TypedEnvelope<M>>() {
+ return Ok(*message.downcast().unwrap());
}
+
+ panic!(
+ "fake server received unexpected message type: {:?}",
+ type_name
+ );
}
pub fn respond<T: proto::RequestMessage>(&self, receipt: Receipt<T>, response: T::Response) {
@@ -223,3 +237,54 @@ impl Drop for FakeServer {
self.disconnect();
}
}
+
+pub fn parse_authorization_header(req: &Request<AsyncBody>) -> Option<Credentials> {
+ let mut auth_header = req
+ .headers()
+ .get(http::header::AUTHORIZATION)?
+ .to_str()
+ .ok()?
+ .split_whitespace();
+ let user_id = auth_header.next()?.parse().ok()?;
+ let access_token = auth_header.next()?;
+ Some(Credentials {
+ user_id,
+ access_token: access_token.to_string(),
+ })
+}
+
+pub fn make_get_authenticated_user_response(
+ user_id: i32,
+ github_login: String,
+) -> GetAuthenticatedUserResponse {
+ GetAuthenticatedUserResponse {
+ user: AuthenticatedUser {
+ id: user_id,
+ metrics_id: format!("metrics-id-{user_id}"),
+ avatar_url: "".to_string(),
+ github_login,
+ name: None,
+ is_staff: false,
+ accepted_tos_at: None,
+ },
+ feature_flags: vec![],
+ plan: PlanInfo {
+ plan: Plan::ZedPro,
+ subscription_period: None,
+ usage: CurrentUsage {
+ model_requests: UsageData {
+ used: 0,
+ limit: UsageLimit::Limited(500),
+ },
+ edit_predictions: UsageData {
+ used: 250,
+ limit: UsageLimit::Unlimited,
+ },
+ },
+ trial_started_at: None,
+ is_usage_based_billing_enabled: false,
+ is_account_too_young: false,
+ has_overdue_invoices: false,
+ },
+ }
+}
@@ -1,6 +1,12 @@
use super::{Client, Status, TypedEnvelope, proto};
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
use chrono::{DateTime, Utc};
+use cloud_api_client::websocket_protocol::MessageToClient;
+use cloud_api_client::{GetAuthenticatedUserResponse, PlanInfo};
+use cloud_llm_client::{
+ EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME,
+ MODEL_REQUESTS_USAGE_AMOUNT_HEADER_NAME, MODEL_REQUESTS_USAGE_LIMIT_HEADER_NAME, UsageLimit,
+};
use collections::{HashMap, HashSet, hash_map::Entry};
use derive_more::Deref;
use feature_flags::FeatureFlagAppExt;
@@ -16,11 +22,7 @@ use std::{
sync::{Arc, Weak},
};
use text::ReplicaId;
-use util::{TryFutureExt as _, maybe};
-use zed_llm_client::{
- EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME,
- MODEL_REQUESTS_USAGE_AMOUNT_HEADER_NAME, MODEL_REQUESTS_USAGE_LIMIT_HEADER_NAME, UsageLimit,
-};
+use util::{ResultExt, TryFutureExt as _};
pub type UserId = u64;
@@ -39,23 +41,18 @@ impl std::fmt::Display for ChannelId {
pub struct ProjectId(pub u64);
impl ProjectId {
- pub fn to_proto(&self) -> u64 {
+ pub fn to_proto(self) -> u64 {
self.0
}
}
-#[derive(
- Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, serde::Serialize, serde::Deserialize,
-)]
-pub struct DevServerProjectId(pub u64);
-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ParticipantIndex(pub u32);
#[derive(Default, Debug)]
pub struct User {
pub id: UserId,
- pub github_login: String,
+ pub github_login: SharedString,
pub avatar_uri: SharedUri,
pub name: Option<String>,
}
@@ -107,19 +104,13 @@ pub enum ContactRequestStatus {
pub struct UserStore {
users: HashMap<u64, Arc<User>>,
- by_github_login: HashMap<String, u64>,
+ by_github_login: HashMap<SharedString, u64>,
participant_indices: HashMap<u64, ParticipantIndex>,
update_contacts_tx: mpsc::UnboundedSender<UpdateContacts>,
- current_plan: Option<proto::Plan>,
- subscription_period: Option<(DateTime<Utc>, DateTime<Utc>)>,
- trial_started_at: Option<DateTime<Utc>>,
model_request_usage: Option<ModelRequestUsage>,
edit_prediction_usage: Option<EditPredictionUsage>,
- is_usage_based_billing_enabled: Option<bool>,
- account_too_young: Option<bool>,
- has_overdue_invoices: Option<bool>,
+ plan_info: Option<PlanInfo>,
current_user: watch::Receiver<Option<Arc<User>>>,
- accepted_tos_at: Option<Option<DateTime<Utc>>>,
contacts: Vec<Arc<Contact>>,
incoming_contact_requests: Vec<Arc<User>>,
outgoing_contact_requests: Vec<Arc<User>>,
@@ -145,6 +136,7 @@ pub enum Event {
ShowContacts,
ParticipantIndicesChanged,
PrivateUserInfoUpdated,
+ PlanUpdated,
}
#[derive(Clone, Copy)]
@@ -179,24 +171,23 @@ impl UserStore {
let (mut current_user_tx, current_user_rx) = watch::channel();
let (update_contacts_tx, mut update_contacts_rx) = mpsc::unbounded();
let rpc_subscriptions = vec![
- client.add_message_handler(cx.weak_entity(), Self::handle_update_plan),
client.add_message_handler(cx.weak_entity(), Self::handle_update_contacts),
client.add_message_handler(cx.weak_entity(), Self::handle_update_invite_info),
client.add_message_handler(cx.weak_entity(), Self::handle_show_contacts),
];
+
+ client.add_message_to_client_handler({
+ let this = cx.weak_entity();
+ move |message, cx| Self::handle_message_to_client(this.clone(), message, cx)
+ });
+
Self {
users: Default::default(),
by_github_login: Default::default(),
current_user: current_user_rx,
- current_plan: None,
- subscription_period: None,
- trial_started_at: None,
+ plan_info: None,
model_request_usage: None,
edit_prediction_usage: None,
- is_usage_based_billing_enabled: None,
- account_too_young: None,
- has_overdue_invoices: None,
- accepted_tos_at: None,
contacts: Default::default(),
incoming_contact_requests: Default::default(),
participant_indices: Default::default(),
@@ -225,61 +216,54 @@ impl UserStore {
return Ok(());
};
match status {
- Status::Connected { .. } => {
+ Status::Authenticated | Status::Connected { .. } => {
if let Some(user_id) = client.user_id() {
- let fetch_user = if let Ok(fetch_user) =
- this.update(cx, |this, cx| this.get_user(user_id, cx).log_err())
- {
- fetch_user
+ let response = client
+ .cloud_client()
+ .get_authenticated_user()
+ .await
+ .log_err();
+
+ let current_user_and_response = if let Some(response) = response {
+ let user = Arc::new(User {
+ id: user_id,
+ github_login: response.user.github_login.clone().into(),
+ avatar_uri: response.user.avatar_url.clone().into(),
+ name: response.user.name.clone(),
+ });
+
+ Some((user, response))
} else {
- break;
+ None
};
- let fetch_private_user_info =
- client.request(proto::GetPrivateUserInfo {}).log_err();
- let (user, info) =
- futures::join!(fetch_user, fetch_private_user_info);
+ current_user_tx
+ .send(
+ current_user_and_response
+ .as_ref()
+ .map(|(user, _)| user.clone()),
+ )
+ .await
+ .ok();
cx.update(|cx| {
- if let Some(info) = info {
- let staff =
- info.staff && !*feature_flags::ZED_DISABLE_STAFF;
- cx.update_flags(staff, info.flags);
- client.telemetry.set_authenticated_user_info(
- Some(info.metrics_id.clone()),
- staff,
- );
-
+ if let Some((user, response)) = current_user_and_response {
this.update(cx, |this, cx| {
- let accepted_tos_at = {
- #[cfg(debug_assertions)]
- if std::env::var("ZED_IGNORE_ACCEPTED_TOS").is_ok()
- {
- None
- } else {
- info.accepted_tos_at
- }
-
- #[cfg(not(debug_assertions))]
- info.accepted_tos_at
- };
-
- this.set_current_user_accepted_tos_at(accepted_tos_at);
- cx.emit(Event::PrivateUserInfoUpdated);
+ this.by_github_login
+ .insert(user.github_login.clone(), user_id);
+ this.users.insert(user_id, user);
+ this.update_authenticated_user(response, cx)
})
} else {
anyhow::Ok(())
}
})??;
- current_user_tx.send(user).await.ok();
-
this.update(cx, |_, cx| cx.notify())?;
}
}
Status::SignedOut => {
current_user_tx.send(None).await.ok();
this.update(cx, |this, cx| {
- this.accepted_tos_at = None;
cx.emit(Event::PrivateUserInfoUpdated);
cx.notify();
this.clear_contacts()
@@ -340,9 +324,9 @@ impl UserStore {
async fn handle_update_contacts(
this: Entity<Self>,
message: TypedEnvelope<proto::UpdateContacts>,
- mut cx: AsyncApp,
+ cx: AsyncApp,
) -> Result<()> {
- this.read_with(&mut cx, |this, _| {
+ this.read_with(&cx, |this, _| {
this.update_contacts_tx
.unbounded_send(UpdateContacts::Update(message.payload))
.unwrap();
@@ -350,63 +334,6 @@ impl UserStore {
Ok(())
}
- async fn handle_update_plan(
- this: Entity<Self>,
- message: TypedEnvelope<proto::UpdateUserPlan>,
- mut cx: AsyncApp,
- ) -> Result<()> {
- this.update(&mut cx, |this, cx| {
- this.current_plan = Some(message.payload.plan());
- this.subscription_period = maybe!({
- let period = message.payload.subscription_period?;
- let started_at = DateTime::from_timestamp(period.started_at as i64, 0)?;
- let ended_at = DateTime::from_timestamp(period.ended_at as i64, 0)?;
-
- Some((started_at, ended_at))
- });
- this.trial_started_at = message
- .payload
- .trial_started_at
- .and_then(|trial_started_at| DateTime::from_timestamp(trial_started_at as i64, 0));
- this.is_usage_based_billing_enabled = message.payload.is_usage_based_billing_enabled;
- this.account_too_young = message.payload.account_too_young;
- this.has_overdue_invoices = message.payload.has_overdue_invoices;
-
- if let Some(usage) = message.payload.usage {
- // limits are always present even though they are wrapped in Option
- this.model_request_usage = usage
- .model_requests_usage_limit
- .and_then(|limit| {
- RequestUsage::from_proto(usage.model_requests_usage_amount, limit)
- })
- .map(ModelRequestUsage);
- this.edit_prediction_usage = usage
- .edit_predictions_usage_limit
- .and_then(|limit| {
- RequestUsage::from_proto(usage.model_requests_usage_amount, limit)
- })
- .map(EditPredictionUsage);
- }
-
- cx.notify();
- })?;
- Ok(())
- }
-
- pub fn update_model_request_usage(&mut self, usage: ModelRequestUsage, cx: &mut Context<Self>) {
- self.model_request_usage = Some(usage);
- cx.notify();
- }
-
- pub fn update_edit_prediction_usage(
- &mut self,
- usage: EditPredictionUsage,
- cx: &mut Context<Self>,
- ) {
- self.edit_prediction_usage = Some(usage);
- cx.notify();
- }
-
fn update_contacts(&mut self, message: UpdateContacts, cx: &Context<Self>) -> Task<Result<()>> {
match message {
UpdateContacts::Wait(barrier) => {
@@ -763,73 +690,139 @@ impl UserStore {
self.current_user.borrow().clone()
}
- pub fn current_plan(&self) -> Option<proto::Plan> {
- self.current_plan
+ pub fn plan(&self) -> Option<cloud_llm_client::Plan> {
+ #[cfg(debug_assertions)]
+ if let Ok(plan) = std::env::var("ZED_SIMULATE_PLAN").as_ref() {
+ return match plan.as_str() {
+ "free" => Some(cloud_llm_client::Plan::ZedFree),
+ "trial" => Some(cloud_llm_client::Plan::ZedProTrial),
+ "pro" => Some(cloud_llm_client::Plan::ZedPro),
+ _ => {
+ panic!("ZED_SIMULATE_PLAN must be one of 'free', 'trial', or 'pro'");
+ }
+ };
+ }
+
+ self.plan_info.as_ref().map(|info| info.plan)
}
pub fn subscription_period(&self) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
- self.subscription_period
+ self.plan_info
+ .as_ref()
+ .and_then(|plan| plan.subscription_period)
+ .map(|subscription_period| {
+ (
+ subscription_period.started_at.0,
+ subscription_period.ended_at.0,
+ )
+ })
}
pub fn trial_started_at(&self) -> Option<DateTime<Utc>> {
- self.trial_started_at
+ self.plan_info
+ .as_ref()
+ .and_then(|plan| plan.trial_started_at)
+ .map(|trial_started_at| trial_started_at.0)
}
- pub fn usage_based_billing_enabled(&self) -> Option<bool> {
- self.is_usage_based_billing_enabled
+ /// Returns whether the user's account is too new to use the service.
+ pub fn account_too_young(&self) -> bool {
+ self.plan_info
+ .as_ref()
+ .map(|plan| plan.is_account_too_young)
+ .unwrap_or_default()
}
- pub fn model_request_usage(&self) -> Option<ModelRequestUsage> {
- self.model_request_usage
+ /// Returns whether the current user has overdue invoices and usage should be blocked.
+ pub fn has_overdue_invoices(&self) -> bool {
+ self.plan_info
+ .as_ref()
+ .map(|plan| plan.has_overdue_invoices)
+ .unwrap_or_default()
}
- pub fn edit_prediction_usage(&self) -> Option<EditPredictionUsage> {
- self.edit_prediction_usage
+ pub fn is_usage_based_billing_enabled(&self) -> bool {
+ self.plan_info
+ .as_ref()
+ .map(|plan| plan.is_usage_based_billing_enabled)
+ .unwrap_or_default()
}
- pub fn watch_current_user(&self) -> watch::Receiver<Option<Arc<User>>> {
- self.current_user.clone()
+ pub fn model_request_usage(&self) -> Option<ModelRequestUsage> {
+ self.model_request_usage
}
- /// Returns whether the user's account is too new to use the service.
- pub fn account_too_young(&self) -> bool {
- self.account_too_young.unwrap_or(false)
+ pub fn update_model_request_usage(&mut self, usage: ModelRequestUsage, cx: &mut Context<Self>) {
+ self.model_request_usage = Some(usage);
+ cx.notify();
}
- /// Returns whether the current user has overdue invoices and usage should be blocked.
- pub fn has_overdue_invoices(&self) -> bool {
- self.has_overdue_invoices.unwrap_or(false)
+ pub fn edit_prediction_usage(&self) -> Option<EditPredictionUsage> {
+ self.edit_prediction_usage
}
- pub fn current_user_has_accepted_terms(&self) -> Option<bool> {
- self.accepted_tos_at
- .map(|accepted_tos_at| accepted_tos_at.is_some())
+ pub fn update_edit_prediction_usage(
+ &mut self,
+ usage: EditPredictionUsage,
+ cx: &mut Context<Self>,
+ ) {
+ self.edit_prediction_usage = Some(usage);
+ cx.notify();
}
- pub fn accept_terms_of_service(&self, cx: &Context<Self>) -> Task<Result<()>> {
- if self.current_user().is_none() {
- return Task::ready(Err(anyhow!("no current user")));
- };
+ fn update_authenticated_user(
+ &mut self,
+ response: GetAuthenticatedUserResponse,
+ cx: &mut Context<Self>,
+ ) {
+ let staff = response.user.is_staff && !*feature_flags::ZED_DISABLE_STAFF;
+ cx.update_flags(staff, response.feature_flags);
+ if let Some(client) = self.client.upgrade() {
+ client
+ .telemetry
+ .set_authenticated_user_info(Some(response.user.metrics_id.clone()), staff);
+ }
- let client = self.client.clone();
- cx.spawn(async move |this, cx| -> anyhow::Result<()> {
- let client = client.upgrade().context("client not found")?;
- let response = client
- .request(proto::AcceptTermsOfService {})
- .await
- .context("error accepting tos")?;
- this.update(cx, |this, cx| {
- this.set_current_user_accepted_tos_at(Some(response.accepted_tos_at));
- cx.emit(Event::PrivateUserInfoUpdated);
- })?;
- Ok(())
+ self.model_request_usage = Some(ModelRequestUsage(RequestUsage {
+ limit: response.plan.usage.model_requests.limit,
+ amount: response.plan.usage.model_requests.used as i32,
+ }));
+ self.edit_prediction_usage = Some(EditPredictionUsage(RequestUsage {
+ limit: response.plan.usage.edit_predictions.limit,
+ amount: response.plan.usage.edit_predictions.used as i32,
+ }));
+ self.plan_info = Some(response.plan);
+ cx.emit(Event::PrivateUserInfoUpdated);
+ }
+
+ fn handle_message_to_client(this: WeakEntity<Self>, message: &MessageToClient, cx: &App) {
+ cx.spawn(async move |cx| {
+ match message {
+ MessageToClient::UserUpdated => {
+ let cloud_client = cx
+ .update(|cx| {
+ this.read_with(cx, |this, _cx| {
+ this.client.upgrade().map(|client| client.cloud_client())
+ })
+ })??
+ .ok_or(anyhow::anyhow!("Failed to get Cloud client"))?;
+
+ let response = cloud_client.get_authenticated_user().await?;
+ cx.update(|cx| {
+ this.update(cx, |this, cx| {
+ this.update_authenticated_user(response, cx);
+ })
+ })??;
+ }
+ }
+
+ anyhow::Ok(())
})
+ .detach_and_log_err(cx);
}
- fn set_current_user_accepted_tos_at(&mut self, accepted_tos_at: Option<u64>) {
- self.accepted_tos_at = Some(
- accepted_tos_at.and_then(|timestamp| DateTime::from_timestamp(timestamp as i64, 0)),
- );
+ pub fn watch_current_user(&self) -> watch::Receiver<Option<Arc<User>>> {
+ self.current_user.clone()
}
fn load_users(
@@ -854,10 +847,10 @@ impl UserStore {
let mut ret = Vec::with_capacity(users.len());
for user in users {
let user = User::new(user);
- if let Some(old) = self.users.insert(user.id, user.clone()) {
- if old.github_login != user.github_login {
- self.by_github_login.remove(&old.github_login);
- }
+ if let Some(old) = self.users.insert(user.id, user.clone())
+ && old.github_login != user.github_login
+ {
+ self.by_github_login.remove(&old.github_login);
}
self.by_github_login
.insert(user.github_login.clone(), user.id);
@@ -890,7 +883,7 @@ impl UserStore {
let mut missing_user_ids = Vec::new();
for id in user_ids {
if let Some(github_login) = self.get_cached_user(id).map(|u| u.github_login.clone()) {
- ret.insert(id, github_login.into());
+ ret.insert(id, github_login);
} else {
missing_user_ids.push(id)
}
@@ -911,7 +904,7 @@ impl User {
fn new(message: proto::User) -> Arc<Self> {
Arc::new(User {
id: message.id,
- github_login: message.github_login,
+ github_login: message.github_login.into(),
avatar_uri: message.avatar_url.into(),
name: message.name,
})
@@ -958,19 +951,6 @@ impl RequestUsage {
}
}
- pub fn from_proto(amount: u32, limit: proto::UsageLimit) -> Option<Self> {
- let limit = match limit.variant? {
- proto::usage_limit::Variant::Limited(limited) => {
- UsageLimit::Limited(limited.limit as i32)
- }
- proto::usage_limit::Variant::Unlimited(_) => UsageLimit::Unlimited,
- };
- Some(RequestUsage {
- limit,
- amount: amount as i32,
- })
- }
-
fn from_headers(
limit_name: &str,
amount_name: &str,
@@ -17,3 +17,29 @@ fn server_url(cx: &App) -> &str {
pub fn account_url(cx: &App) -> String {
format!("{server_url}/account", server_url = server_url(cx))
}
+
+/// Returns the URL to the start trial page on zed.dev.
+pub fn start_trial_url(cx: &App) -> String {
+ format!(
+ "{server_url}/account/start-trial",
+ server_url = server_url(cx)
+ )
+}
+
+/// Returns the URL to the upgrade page on zed.dev.
+pub fn upgrade_to_zed_pro_url(cx: &App) -> String {
+ format!("{server_url}/account/upgrade", server_url = server_url(cx))
+}
+
+/// Returns the URL to Zed's terms of service.
+pub fn terms_of_service(cx: &App) -> String {
+ format!("{server_url}/terms-of-service", server_url = server_url(cx))
+}
+
+/// Returns the URL to Zed AI's privacy and security docs.
+pub fn ai_privacy_and_security(cx: &App) -> String {
+ format!(
+ "{server_url}/docs/ai/privacy-and-security",
+ server_url = server_url(cx)
+ )
+}
@@ -0,0 +1,24 @@
+[package]
+name = "cloud_api_client"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "Apache-2.0"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/cloud_api_client.rs"
+
+[dependencies]
+anyhow.workspace = true
+cloud_api_types.workspace = true
+futures.workspace = true
+gpui.workspace = true
+gpui_tokio.workspace = true
+http_client.workspace = true
+parking_lot.workspace = true
+serde_json.workspace = true
+workspace-hack.workspace = true
+yawc.workspace = true
@@ -0,0 +1,197 @@
+mod websocket;
+
+use std::sync::Arc;
+
+use anyhow::{Context, Result, anyhow};
+use cloud_api_types::websocket_protocol::{PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER_NAME};
+pub use cloud_api_types::*;
+use futures::AsyncReadExt as _;
+use gpui::{App, Task};
+use gpui_tokio::Tokio;
+use http_client::http::request;
+use http_client::{AsyncBody, HttpClientWithUrl, Method, Request, StatusCode};
+use parking_lot::RwLock;
+use yawc::WebSocket;
+
+use crate::websocket::Connection;
+
+struct Credentials {
+ user_id: u32,
+ access_token: String,
+}
+
+pub struct CloudApiClient {
+ credentials: RwLock<Option<Credentials>>,
+ http_client: Arc<HttpClientWithUrl>,
+}
+
+impl CloudApiClient {
+ pub fn new(http_client: Arc<HttpClientWithUrl>) -> Self {
+ Self {
+ credentials: RwLock::new(None),
+ http_client,
+ }
+ }
+
+ pub fn has_credentials(&self) -> bool {
+ self.credentials.read().is_some()
+ }
+
+ pub fn set_credentials(&self, user_id: u32, access_token: String) {
+ *self.credentials.write() = Some(Credentials {
+ user_id,
+ access_token,
+ });
+ }
+
+ pub fn clear_credentials(&self) {
+ *self.credentials.write() = None;
+ }
+
+ fn build_request(
+ &self,
+ req: request::Builder,
+ body: impl Into<AsyncBody>,
+ ) -> Result<Request<AsyncBody>> {
+ let credentials = self.credentials.read();
+ let credentials = credentials.as_ref().context("no credentials provided")?;
+ build_request(req, body, credentials)
+ }
+
+ pub async fn get_authenticated_user(&self) -> Result<GetAuthenticatedUserResponse> {
+ let request = self.build_request(
+ Request::builder().method(Method::GET).uri(
+ self.http_client
+ .build_zed_cloud_url("/client/users/me", &[])?
+ .as_ref(),
+ ),
+ AsyncBody::default(),
+ )?;
+
+ 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 get authenticated user.\nStatus: {:?}\nBody: {body}",
+ response.status()
+ )
+ }
+
+ let mut body = String::new();
+ response.body_mut().read_to_string(&mut body).await?;
+
+ Ok(serde_json::from_str(&body)?)
+ }
+
+ pub fn connect(&self, cx: &App) -> Result<Task<Result<Connection>>> {
+ let mut connect_url = self
+ .http_client
+ .build_zed_cloud_url("/client/users/connect", &[])?;
+ connect_url
+ .set_scheme(match connect_url.scheme() {
+ "https" => "wss",
+ "http" => "ws",
+ scheme => Err(anyhow!("invalid URL scheme: {scheme}"))?,
+ })
+ .map_err(|_| anyhow!("failed to set URL scheme"))?;
+
+ let credentials = self.credentials.read();
+ let credentials = credentials.as_ref().context("no credentials provided")?;
+ let authorization_header = format!("{} {}", credentials.user_id, credentials.access_token);
+
+ Ok(Tokio::spawn_result(cx, async move {
+ let ws = WebSocket::connect(connect_url)
+ .with_request(
+ request::Builder::new()
+ .header("Authorization", authorization_header)
+ .header(PROTOCOL_VERSION_HEADER_NAME, PROTOCOL_VERSION.to_string()),
+ )
+ .await?;
+
+ Ok(Connection::new(ws))
+ }))
+ }
+
+ pub async fn create_llm_token(
+ &self,
+ system_id: Option<String>,
+ ) -> Result<CreateLlmTokenResponse> {
+ let mut request_builder = Request::builder().method(Method::POST).uri(
+ self.http_client
+ .build_zed_cloud_url("/client/llm_tokens", &[])?
+ .as_ref(),
+ );
+
+ if let Some(system_id) = system_id {
+ request_builder = request_builder.header(ZED_SYSTEM_ID_HEADER_NAME, system_id);
+ }
+
+ let request = self.build_request(request_builder, AsyncBody::default())?;
+
+ 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 create LLM token.\nStatus: {:?}\nBody: {body}",
+ response.status()
+ )
+ }
+
+ let mut body = String::new();
+ response.body_mut().read_to_string(&mut body).await?;
+
+ Ok(serde_json::from_str(&body)?)
+ }
+
+ pub async fn validate_credentials(&self, user_id: u32, access_token: &str) -> Result<bool> {
+ let request = build_request(
+ Request::builder().method(Method::GET).uri(
+ self.http_client
+ .build_zed_cloud_url("/client/users/me", &[])?
+ .as_ref(),
+ ),
+ AsyncBody::default(),
+ &Credentials {
+ user_id,
+ access_token: access_token.into(),
+ },
+ )?;
+
+ let mut response = self.http_client.send(request).await?;
+
+ if response.status().is_success() {
+ Ok(true)
+ } else {
+ let mut body = String::new();
+ response.body_mut().read_to_string(&mut body).await?;
+ if response.status() == StatusCode::UNAUTHORIZED {
+ Ok(false)
+ } else {
+ Err(anyhow!(
+ "Failed to get authenticated user.\nStatus: {:?}\nBody: {body}",
+ response.status()
+ ))
+ }
+ }
+ }
+}
+
+fn build_request(
+ req: request::Builder,
+ body: impl Into<AsyncBody>,
+ credentials: &Credentials,
+) -> Result<Request<AsyncBody>> {
+ Ok(req
+ .header("Content-Type", "application/json")
+ .header(
+ "Authorization",
+ format!("{} {}", credentials.user_id, credentials.access_token),
+ )
+ .body(body.into())?)
+}
@@ -0,0 +1,73 @@
+use std::pin::Pin;
+use std::time::Duration;
+
+use anyhow::Result;
+use cloud_api_types::websocket_protocol::MessageToClient;
+use futures::channel::mpsc::unbounded;
+use futures::stream::{SplitSink, SplitStream};
+use futures::{FutureExt as _, SinkExt as _, Stream, StreamExt as _, TryStreamExt as _, pin_mut};
+use gpui::{App, BackgroundExecutor, Task};
+use yawc::WebSocket;
+use yawc::frame::{FrameView, OpCode};
+
+const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(1);
+
+pub type MessageStream = Pin<Box<dyn Stream<Item = Result<MessageToClient>>>>;
+
+pub struct Connection {
+ tx: SplitSink<WebSocket, FrameView>,
+ rx: SplitStream<WebSocket>,
+}
+
+impl Connection {
+ pub fn new(ws: WebSocket) -> Self {
+ let (tx, rx) = ws.split();
+
+ Self { tx, rx }
+ }
+
+ pub fn spawn(self, cx: &App) -> (MessageStream, Task<()>) {
+ let (mut tx, rx) = (self.tx, self.rx);
+
+ let (message_tx, message_rx) = unbounded();
+
+ let handle_io = |executor: BackgroundExecutor| async move {
+ // Send messages on this frequency so the connection isn't closed.
+ let keepalive_timer = executor.timer(KEEPALIVE_INTERVAL).fuse();
+ futures::pin_mut!(keepalive_timer);
+
+ let rx = rx.fuse();
+ pin_mut!(rx);
+
+ loop {
+ futures::select_biased! {
+ _ = keepalive_timer => {
+ let _ = tx.send(FrameView::ping(Vec::new())).await;
+
+ keepalive_timer.set(executor.timer(KEEPALIVE_INTERVAL).fuse());
+ }
+ frame = rx.next() => {
+ let Some(frame) = frame else {
+ break;
+ };
+
+ match frame.opcode {
+ OpCode::Binary => {
+ let message_result = MessageToClient::deserialize(&frame.payload);
+ message_tx.unbounded_send(message_result).ok();
+ }
+ OpCode::Close => {
+ break;
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+ };
+
+ let task = cx.spawn(async move |cx| handle_io(cx.background_executor().clone()).await);
+
+ (message_rx.into_stream().boxed(), task)
+ }
+}
@@ -0,0 +1,24 @@
+[package]
+name = "cloud_api_types"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "Apache-2.0"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/cloud_api_types.rs"
+
+[dependencies]
+anyhow.workspace = true
+chrono.workspace = true
+ciborium.workspace = true
+cloud_llm_client.workspace = true
+serde.workspace = true
+workspace-hack.workspace = true
+
+[dev-dependencies]
+pretty_assertions.workspace = true
+serde_json.workspace = true
@@ -0,0 +1 @@
+../../LICENSE-APACHE
@@ -0,0 +1,56 @@
+mod timestamp;
+pub mod websocket_protocol;
+
+use serde::{Deserialize, Serialize};
+
+pub use crate::timestamp::Timestamp;
+
+pub const ZED_SYSTEM_ID_HEADER_NAME: &str = "x-zed-system-id";
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct GetAuthenticatedUserResponse {
+ pub user: AuthenticatedUser,
+ pub feature_flags: Vec<String>,
+ pub plan: PlanInfo,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct AuthenticatedUser {
+ pub id: i32,
+ pub metrics_id: String,
+ pub avatar_url: String,
+ pub github_login: String,
+ pub name: Option<String>,
+ pub is_staff: bool,
+ pub accepted_tos_at: Option<Timestamp>,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct PlanInfo {
+ pub plan: cloud_llm_client::Plan,
+ pub subscription_period: Option<SubscriptionPeriod>,
+ pub usage: cloud_llm_client::CurrentUsage,
+ pub trial_started_at: Option<Timestamp>,
+ pub is_usage_based_billing_enabled: bool,
+ pub is_account_too_young: bool,
+ pub has_overdue_invoices: bool,
+}
+
+#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
+pub struct SubscriptionPeriod {
+ pub started_at: Timestamp,
+ pub ended_at: Timestamp,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct AcceptTermsOfServiceResponse {
+ pub user: AuthenticatedUser,
+}
+
+#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
+pub struct LlmToken(pub String);
+
+#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
+pub struct CreateLlmTokenResponse {
+ pub token: LlmToken,
+}
@@ -0,0 +1,166 @@
+use chrono::{DateTime, NaiveDateTime, SecondsFormat, Utc};
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+/// A timestamp with a serialized representation in RFC 3339 format.
+#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
+pub struct Timestamp(pub DateTime<Utc>);
+
+impl Timestamp {
+ pub fn new(datetime: DateTime<Utc>) -> Self {
+ Self(datetime)
+ }
+}
+
+impl From<DateTime<Utc>> for Timestamp {
+ fn from(value: DateTime<Utc>) -> Self {
+ Self(value)
+ }
+}
+
+impl From<NaiveDateTime> for Timestamp {
+ fn from(value: NaiveDateTime) -> Self {
+ Self(value.and_utc())
+ }
+}
+
+impl Serialize for Timestamp {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ let rfc3339_string = self.0.to_rfc3339_opts(SecondsFormat::Millis, true);
+ serializer.serialize_str(&rfc3339_string)
+ }
+}
+
+impl<'de> Deserialize<'de> for Timestamp {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ let datetime = DateTime::parse_from_rfc3339(&value)
+ .map_err(serde::de::Error::custom)?
+ .to_utc();
+ Ok(Self(datetime))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use chrono::NaiveDate;
+ use pretty_assertions::assert_eq;
+
+ use super::*;
+
+ #[test]
+ fn test_timestamp_serialization() {
+ let datetime = DateTime::parse_from_rfc3339("2023-12-25T14:30:45.123Z")
+ .unwrap()
+ .to_utc();
+ let timestamp = Timestamp::new(datetime);
+
+ let json = serde_json::to_string(×tamp).unwrap();
+ assert_eq!(json, "\"2023-12-25T14:30:45.123Z\"");
+ }
+
+ #[test]
+ fn test_timestamp_deserialization() {
+ let json = "\"2023-12-25T14:30:45.123Z\"";
+ let timestamp: Timestamp = serde_json::from_str(json).unwrap();
+
+ let expected = DateTime::parse_from_rfc3339("2023-12-25T14:30:45.123Z")
+ .unwrap()
+ .to_utc();
+
+ assert_eq!(timestamp.0, expected);
+ }
+
+ #[test]
+ fn test_timestamp_roundtrip() {
+ let original = DateTime::parse_from_rfc3339("2023-12-25T14:30:45.123Z")
+ .unwrap()
+ .to_utc();
+
+ let timestamp = Timestamp::new(original);
+ let json = serde_json::to_string(×tamp).unwrap();
+ let deserialized: Timestamp = serde_json::from_str(&json).unwrap();
+
+ assert_eq!(deserialized.0, original);
+ }
+
+ #[test]
+ fn test_timestamp_from_datetime_utc() {
+ let datetime = DateTime::parse_from_rfc3339("2023-12-25T14:30:45.123Z")
+ .unwrap()
+ .to_utc();
+
+ let timestamp = Timestamp::from(datetime);
+ assert_eq!(timestamp.0, datetime);
+ }
+
+ #[test]
+ fn test_timestamp_from_naive_datetime() {
+ let naive_dt = NaiveDate::from_ymd_opt(2023, 12, 25)
+ .unwrap()
+ .and_hms_milli_opt(14, 30, 45, 123)
+ .unwrap();
+
+ let timestamp = Timestamp::from(naive_dt);
+ let expected = naive_dt.and_utc();
+
+ assert_eq!(timestamp.0, expected);
+ }
+
+ #[test]
+ fn test_timestamp_serialization_with_microseconds() {
+ // Test that microseconds are truncated to milliseconds
+ let datetime = NaiveDate::from_ymd_opt(2023, 12, 25)
+ .unwrap()
+ .and_hms_micro_opt(14, 30, 45, 123456)
+ .unwrap()
+ .and_utc();
+
+ let timestamp = Timestamp::new(datetime);
+ let json = serde_json::to_string(×tamp).unwrap();
+
+ // Should be truncated to milliseconds
+ assert_eq!(json, "\"2023-12-25T14:30:45.123Z\"");
+ }
+
+ #[test]
+ fn test_timestamp_deserialization_without_milliseconds() {
+ let json = "\"2023-12-25T14:30:45Z\"";
+ let timestamp: Timestamp = serde_json::from_str(json).unwrap();
+
+ let expected = NaiveDate::from_ymd_opt(2023, 12, 25)
+ .unwrap()
+ .and_hms_opt(14, 30, 45)
+ .unwrap()
+ .and_utc();
+
+ assert_eq!(timestamp.0, expected);
+ }
+
+ #[test]
+ fn test_timestamp_deserialization_with_timezone() {
+ let json = "\"2023-12-25T14:30:45.123+05:30\"";
+ let timestamp: Timestamp = serde_json::from_str(json).unwrap();
+
+ // Should be converted to UTC
+ let expected = NaiveDate::from_ymd_opt(2023, 12, 25)
+ .unwrap()
+ .and_hms_milli_opt(9, 0, 45, 123) // 14:30:45 + 5:30 = 20:00:45, but we want UTC so subtract 5:30
+ .unwrap()
+ .and_utc();
+
+ assert_eq!(timestamp.0, expected);
+ }
+
+ #[test]
+ fn test_timestamp_deserialization_with_invalid_format() {
+ let json = "\"invalid-date\"";
+ let result: Result<Timestamp, _> = serde_json::from_str(json);
+ assert!(result.is_err());
+ }
+}
@@ -0,0 +1,28 @@
+use anyhow::{Context as _, Result};
+use serde::{Deserialize, Serialize};
+
+/// The version of the Cloud WebSocket protocol.
+pub const PROTOCOL_VERSION: u32 = 0;
+
+/// The name of the header used to indicate the protocol version in use.
+pub const PROTOCOL_VERSION_HEADER_NAME: &str = "x-zed-protocol-version";
+
+/// A message from Cloud to the Zed client.
+#[derive(Debug, Serialize, Deserialize)]
+pub enum MessageToClient {
+ /// The user was updated and should be refreshed.
+ UserUpdated,
+}
+
+impl MessageToClient {
+ pub fn serialize(&self) -> Result<Vec<u8>> {
+ let mut buffer = Vec::new();
+ ciborium::into_writer(self, &mut buffer).context("failed to serialize message")?;
+
+ Ok(buffer)
+ }
+
+ pub fn deserialize(data: &[u8]) -> Result<Self> {
+ ciborium::from_reader(data).context("failed to deserialize message")
+ }
+}
@@ -0,0 +1,23 @@
+[package]
+name = "cloud_llm_client"
+version = "0.1.0"
+publish.workspace = true
+edition.workspace = true
+license = "Apache-2.0"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/cloud_llm_client.rs"
+
+[dependencies]
+anyhow.workspace = true
+serde = { workspace = true, features = ["derive", "rc"] }
+serde_json.workspace = true
+strum = { workspace = true, features = ["derive"] }
+uuid = { workspace = true, features = ["serde"] }
+workspace-hack.workspace = true
+
+[dev-dependencies]
+pretty_assertions.workspace = true
@@ -0,0 +1 @@
+../../LICENSE-APACHE
@@ -0,0 +1,386 @@
+use std::str::FromStr;
+use std::sync::Arc;
+
+use anyhow::Context as _;
+use serde::{Deserialize, Serialize};
+use strum::{Display, EnumIter, EnumString};
+use uuid::Uuid;
+
+/// The name of the header used to indicate which version of Zed the client is running.
+pub const ZED_VERSION_HEADER_NAME: &str = "x-zed-version";
+
+/// The name of the header used to indicate when a request failed due to an
+/// expired LLM token.
+///
+/// The client may use this as a signal to refresh the token.
+pub const EXPIRED_LLM_TOKEN_HEADER_NAME: &str = "x-zed-expired-token";
+
+/// The name of the header used to indicate what plan the user is currently on.
+pub const CURRENT_PLAN_HEADER_NAME: &str = "x-zed-plan";
+
+/// The name of the header used to indicate the usage limit for model requests.
+pub const MODEL_REQUESTS_USAGE_LIMIT_HEADER_NAME: &str = "x-zed-model-requests-usage-limit";
+
+/// The name of the header used to indicate the usage amount for model requests.
+pub const MODEL_REQUESTS_USAGE_AMOUNT_HEADER_NAME: &str = "x-zed-model-requests-usage-amount";
+
+/// The name of the header used to indicate the usage limit for edit predictions.
+pub const EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME: &str = "x-zed-edit-predictions-usage-limit";
+
+/// The name of the header used to indicate the usage amount for edit predictions.
+pub const EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME: &str = "x-zed-edit-predictions-usage-amount";
+
+/// The name of the header used to indicate the resource for which the subscription limit has been reached.
+pub const SUBSCRIPTION_LIMIT_RESOURCE_HEADER_NAME: &str = "x-zed-subscription-limit-resource";
+
+pub const MODEL_REQUESTS_RESOURCE_HEADER_VALUE: &str = "model_requests";
+pub const EDIT_PREDICTIONS_RESOURCE_HEADER_VALUE: &str = "edit_predictions";
+
+/// The name of the header used to indicate that the maximum number of consecutive tool uses has been reached.
+pub const TOOL_USE_LIMIT_REACHED_HEADER_NAME: &str = "x-zed-tool-use-limit-reached";
+
+/// The name of the header used to indicate the the minimum required Zed version.
+///
+/// This can be used to force a Zed upgrade in order to continue communicating
+/// with the LLM service.
+pub const MINIMUM_REQUIRED_VERSION_HEADER_NAME: &str = "x-zed-minimum-required-version";
+
+/// The name of the header used by the client to indicate to the server that it supports receiving status messages.
+pub const CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME: &str =
+ "x-zed-client-supports-status-messages";
+
+/// The name of the header used by the server to indicate to the client that it supports sending status messages.
+pub const SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME: &str =
+ "x-zed-server-supports-status-messages";
+
+#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum UsageLimit {
+ Limited(i32),
+ Unlimited,
+}
+
+impl FromStr for UsageLimit {
+ type Err = anyhow::Error;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ match value {
+ "unlimited" => Ok(Self::Unlimited),
+ limit => limit
+ .parse::<i32>()
+ .map(Self::Limited)
+ .context("failed to parse limit"),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Plan {
+ #[default]
+ #[serde(alias = "Free")]
+ ZedFree,
+ #[serde(alias = "ZedPro")]
+ ZedPro,
+ #[serde(alias = "ZedProTrial")]
+ ZedProTrial,
+}
+
+impl Plan {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Plan::ZedFree => "zed_free",
+ Plan::ZedPro => "zed_pro",
+ Plan::ZedProTrial => "zed_pro_trial",
+ }
+ }
+
+ pub fn model_requests_limit(&self) -> UsageLimit {
+ match self {
+ Plan::ZedPro => UsageLimit::Limited(500),
+ Plan::ZedProTrial => UsageLimit::Limited(150),
+ Plan::ZedFree => UsageLimit::Limited(50),
+ }
+ }
+
+ pub fn edit_predictions_limit(&self) -> UsageLimit {
+ match self {
+ Plan::ZedPro => UsageLimit::Unlimited,
+ Plan::ZedProTrial => UsageLimit::Unlimited,
+ Plan::ZedFree => UsageLimit::Limited(2_000),
+ }
+ }
+}
+
+impl FromStr for Plan {
+ type Err = anyhow::Error;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ match value {
+ "zed_free" => Ok(Plan::ZedFree),
+ "zed_pro" => Ok(Plan::ZedPro),
+ "zed_pro_trial" => Ok(Plan::ZedProTrial),
+ plan => Err(anyhow::anyhow!("invalid plan: {plan:?}")),
+ }
+ }
+}
+
+#[derive(
+ Debug, PartialEq, Eq, Hash, Clone, Copy, Serialize, Deserialize, EnumString, EnumIter, Display,
+)]
+#[serde(rename_all = "snake_case")]
+#[strum(serialize_all = "snake_case")]
+pub enum LanguageModelProvider {
+ Anthropic,
+ OpenAi,
+ Google,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PredictEditsBody {
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub outline: Option<String>,
+ pub input_events: String,
+ pub input_excerpt: String,
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub speculated_output: Option<String>,
+ /// Whether the user provided consent for sampling this interaction.
+ #[serde(default, alias = "data_collection_permission")]
+ pub can_collect_data: bool,
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub diagnostic_groups: Option<Vec<(String, serde_json::Value)>>,
+ /// Info about the git repository state, only present when can_collect_data is true.
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub git_info: Option<PredictEditsGitInfo>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PredictEditsGitInfo {
+ /// SHA of git HEAD commit at time of prediction.
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub head_sha: Option<String>,
+ /// URL of the remote called `origin`.
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub remote_origin_url: Option<String>,
+ /// URL of the remote called `upstream`.
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub remote_upstream_url: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PredictEditsResponse {
+ pub request_id: Uuid,
+ pub output_excerpt: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AcceptEditPredictionBody {
+ pub request_id: Uuid,
+}
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum CompletionMode {
+ Normal,
+ Max,
+}
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum CompletionIntent {
+ UserPrompt,
+ ToolResults,
+ ThreadSummarization,
+ ThreadContextSummarization,
+ CreateFile,
+ EditFile,
+ InlineAssist,
+ TerminalInlineAssist,
+ GenerateGitCommitMessage,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct CompletionBody {
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub thread_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub prompt_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub intent: Option<CompletionIntent>,
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub mode: Option<CompletionMode>,
+ pub provider: LanguageModelProvider,
+ pub model: String,
+ pub provider_request: serde_json::Value,
+}
+
+#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum CompletionRequestStatus {
+ Queued {
+ position: usize,
+ },
+ Started,
+ Failed {
+ code: String,
+ message: String,
+ request_id: Uuid,
+ /// Retry duration in seconds.
+ retry_after: Option<f64>,
+ },
+ UsageUpdated {
+ amount: usize,
+ limit: UsageLimit,
+ },
+ ToolUseLimitReached,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum CompletionEvent<T> {
+ Status(CompletionRequestStatus),
+ Event(T),
+}
+
+impl<T> CompletionEvent<T> {
+ pub fn into_status(self) -> Option<CompletionRequestStatus> {
+ match self {
+ Self::Status(status) => Some(status),
+ Self::Event(_) => None,
+ }
+ }
+
+ pub fn into_event(self) -> Option<T> {
+ match self {
+ Self::Event(event) => Some(event),
+ Self::Status(_) => None,
+ }
+ }
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct WebSearchBody {
+ pub query: String,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct WebSearchResponse {
+ pub results: Vec<WebSearchResult>,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct WebSearchResult {
+ pub title: String,
+ pub url: String,
+ pub text: String,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct CountTokensBody {
+ pub provider: LanguageModelProvider,
+ pub model: String,
+ pub provider_request: serde_json::Value,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct CountTokensResponse {
+ pub tokens: usize,
+}
+
+#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
+pub struct LanguageModelId(pub Arc<str>);
+
+impl std::fmt::Display for LanguageModelId {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct LanguageModel {
+ pub provider: LanguageModelProvider,
+ pub id: LanguageModelId,
+ pub display_name: String,
+ pub max_token_count: usize,
+ pub max_token_count_in_max_mode: Option<usize>,
+ pub max_output_tokens: usize,
+ pub supports_tools: bool,
+ pub supports_images: bool,
+ pub supports_thinking: bool,
+ pub supports_max_mode: bool,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ListModelsResponse {
+ pub models: Vec<LanguageModel>,
+ pub default_model: LanguageModelId,
+ pub default_fast_model: LanguageModelId,
+ pub recommended_models: Vec<LanguageModelId>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct GetSubscriptionResponse {
+ pub plan: Plan,
+ pub usage: Option<CurrentUsage>,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct CurrentUsage {
+ pub model_requests: UsageData,
+ pub edit_predictions: UsageData,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct UsageData {
+ pub used: u32,
+ pub limit: UsageLimit,
+}
+
+#[cfg(test)]
+mod tests {
+ use pretty_assertions::assert_eq;
+ use serde_json::json;
+
+ use super::*;
+
+ #[test]
+ fn test_plan_deserialize_snake_case() {
+ let plan = serde_json::from_value::<Plan>(json!("zed_free")).unwrap();
+ assert_eq!(plan, Plan::ZedFree);
+
+ let plan = serde_json::from_value::<Plan>(json!("zed_pro")).unwrap();
+ assert_eq!(plan, Plan::ZedPro);
+
+ let plan = serde_json::from_value::<Plan>(json!("zed_pro_trial")).unwrap();
+ assert_eq!(plan, Plan::ZedProTrial);
+ }
+
+ #[test]
+ fn test_plan_deserialize_aliases() {
+ let plan = serde_json::from_value::<Plan>(json!("Free")).unwrap();
+ assert_eq!(plan, Plan::ZedFree);
+
+ let plan = serde_json::from_value::<Plan>(json!("ZedPro")).unwrap();
+ assert_eq!(plan, Plan::ZedPro);
+
+ let plan = serde_json::from_value::<Plan>(json!("ZedProTrial")).unwrap();
+ assert_eq!(plan, Plan::ZedProTrial);
+ }
+
+ #[test]
+ fn test_usage_limit_from_str() {
+ let limit = UsageLimit::from_str("unlimited").unwrap();
+ assert!(matches!(limit, UsageLimit::Unlimited));
+
+ let limit = UsageLimit::from_str(&0.to_string()).unwrap();
+ assert!(matches!(limit, UsageLimit::Limited(0)));
+
+ let limit = UsageLimit::from_str(&50.to_string()).unwrap();
+ assert!(matches!(limit, UsageLimit::Limited(50)));
+
+ for value in ["not_a_number", "50xyz"] {
+ let limit = UsageLimit::from_str(value);
+ assert!(limit.is_err());
+ }
+ }
+}
@@ -19,12 +19,11 @@ test-support = ["sqlite"]
[dependencies]
anyhow.workspace = true
-async-stripe.workspace = true
async-trait.workspace = true
async-tungstenite.workspace = true
aws-config = { version = "1.1.5" }
-aws-sdk-s3 = { version = "1.15.0" }
aws-sdk-kinesis = "1.51.0"
+aws-sdk-s3 = { version = "1.15.0" }
axum = { version = "0.6", features = ["json", "headers", "ws"] }
axum-extra = { version = "0.4", features = ["erased-json"] }
base64.workspace = true
@@ -32,13 +31,11 @@ chrono.workspace = true
clock.workspace = true
collections.workspace = true
dashmap.workspace = true
-derive_more.workspace = true
envy = "0.4.2"
futures.workspace = true
-gpui = { workspace = true, features = ["screen-capture"] }
+gpui.workspace = true
hex.workspace = true
http_client.workspace = true
-jsonwebtoken.workspace = true
livekit_api.workspace = true
log.workspace = true
nanoid.workspace = true
@@ -64,7 +61,6 @@ subtle.workspace = true
supermaven_api.workspace = true
telemetry_events.workspace = true
text.workspace = true
-thiserror.workspace = true
time.workspace = true
tokio = { workspace = true, features = ["full"] }
toml.workspace = true
@@ -75,7 +71,6 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json", "re
util.workspace = true
uuid.workspace = true
workspace-hack.workspace = true
-zed_llm_client.workspace = true
[dev-dependencies]
agent_settings.workspace = true
@@ -127,6 +122,7 @@ sea-orm = { version = "1.1.0-rc.1", features = ["sqlx-sqlite"] }
serde_json.workspace = true
session = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
+smol.workspace = true
sqlx = { version = "0.8", features = ["sqlite"] }
task.workspace = true
theme.workspace = true
@@ -135,6 +131,3 @@ util.workspace = true
workspace = { workspace = true, features = ["test-support"] }
worktree = { workspace = true, features = ["test-support"] }
zlog.workspace = true
-
-[package.metadata.cargo-machete]
-ignored = ["async-stripe"]
@@ -219,12 +219,6 @@ spec:
secretKeyRef:
name: slack
key: panics_webhook
- - name: STRIPE_API_KEY
- valueFrom:
- secretKeyRef:
- name: stripe
- key: api_key
- optional: true
- name: COMPLETE_WITH_LANGUAGE_MODEL_RATE_LIMIT_PER_HOUR
value: "1000"
- name: SUPERMAVEN_ADMIN_API_KEY
@@ -2,5 +2,6 @@ ZED_ENVIRONMENT=production
RUST_LOG=info
INVITE_LINK_PREFIX=https://zed.dev/invites/
AUTO_JOIN_CHANNEL_ID=283
-DATABASE_MAX_CONNECTIONS=250
+# Set DATABASE_MAX_CONNECTIONS max connections in the `deploy_collab.yml`:
+# https://github.com/zed-industries/zed/blob/main/.github/workflows/deploy_collab.yml
LLM_DATABASE_MAX_CONNECTIONS=25
@@ -116,6 +116,7 @@ CREATE TABLE "project_repositories" (
"scan_id" INTEGER NOT NULL,
"is_deleted" BOOL NOT NULL,
"current_merge_conflicts" VARCHAR,
+ "merge_message" VARCHAR,
"branch_summary" VARCHAR,
"head_commit_details" VARCHAR,
PRIMARY KEY (project_id, id)
@@ -173,6 +174,7 @@ CREATE TABLE "language_servers" (
"id" INTEGER NOT NULL,
"project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
"name" VARCHAR NOT NULL,
+ "capabilities" TEXT NOT NULL,
PRIMARY KEY (project_id, id)
);
@@ -473,67 +475,6 @@ CREATE UNIQUE INDEX "index_extensions_external_id" ON "extensions" ("external_id
CREATE INDEX "index_extensions_total_download_count" ON "extensions" ("total_download_count");
-CREATE TABLE rate_buckets (
- user_id INT NOT NULL,
- rate_limit_name VARCHAR(255) NOT NULL,
- token_count INT NOT NULL,
- last_refill TIMESTAMP WITHOUT TIME ZONE NOT NULL,
- PRIMARY KEY (user_id, rate_limit_name),
- FOREIGN KEY (user_id) REFERENCES users (id)
-);
-
-CREATE INDEX idx_user_id_rate_limit ON rate_buckets (user_id, rate_limit_name);
-
-CREATE TABLE IF NOT EXISTS billing_preferences (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- user_id INTEGER NOT NULL REFERENCES users (id),
- max_monthly_llm_usage_spending_in_cents INTEGER NOT NULL,
- model_request_overages_enabled bool NOT NULL DEFAULT FALSE,
- model_request_overages_spend_limit_in_cents integer NOT NULL DEFAULT 0
-);
-
-CREATE UNIQUE INDEX "uix_billing_preferences_on_user_id" ON billing_preferences (user_id);
-
-CREATE TABLE IF NOT EXISTS billing_customers (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- user_id INTEGER NOT NULL REFERENCES users (id),
- has_overdue_invoices BOOLEAN NOT NULL DEFAULT FALSE,
- stripe_customer_id TEXT NOT NULL,
- trial_started_at TIMESTAMP
-);
-
-CREATE UNIQUE INDEX "uix_billing_customers_on_user_id" ON billing_customers (user_id);
-
-CREATE UNIQUE INDEX "uix_billing_customers_on_stripe_customer_id" ON billing_customers (stripe_customer_id);
-
-CREATE TABLE IF NOT EXISTS billing_subscriptions (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- billing_customer_id INTEGER NOT NULL REFERENCES billing_customers (id),
- stripe_subscription_id TEXT NOT NULL,
- stripe_subscription_status TEXT NOT NULL,
- stripe_cancel_at TIMESTAMP,
- stripe_cancellation_reason TEXT,
- kind TEXT,
- stripe_current_period_start BIGINT,
- stripe_current_period_end BIGINT
-);
-
-CREATE INDEX "ix_billing_subscriptions_on_billing_customer_id" ON billing_subscriptions (billing_customer_id);
-
-CREATE UNIQUE INDEX "uix_billing_subscriptions_on_stripe_subscription_id" ON billing_subscriptions (stripe_subscription_id);
-
-CREATE TABLE IF NOT EXISTS processed_stripe_events (
- stripe_event_id TEXT PRIMARY KEY,
- stripe_event_type TEXT NOT NULL,
- stripe_event_created_timestamp INTEGER NOT NULL,
- processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-
-CREATE INDEX "ix_processed_stripe_events_on_stripe_event_created_timestamp" ON processed_stripe_events (stripe_event_created_timestamp);
-
CREATE TABLE IF NOT EXISTS "breakpoints" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
@@ -0,0 +1,5 @@
+ALTER TABLE language_servers
+ ADD COLUMN capabilities TEXT NOT NULL DEFAULT '{}';
+
+ALTER TABLE language_servers
+ ALTER COLUMN capabilities DROP DEFAULT;
@@ -0,0 +1,2 @@
+alter table users
+alter column admin set not null;
@@ -0,0 +1,2 @@
+alter table billing_customers
+ add column orb_customer_id text;
@@ -0,0 +1 @@
+drop table rate_buckets;
@@ -0,0 +1 @@
+ALTER TABLE "project_repositories" ADD COLUMN "merge_message" VARCHAR;
@@ -0,0 +1,2 @@
+alter table billing_subscriptions
+ add column orb_subscription_id text;
@@ -0,0 +1,3 @@
+alter table billing_subscriptions
+ alter column stripe_subscription_id drop not null,
+ alter column stripe_subscription_status drop not null;
@@ -0,0 +1,4 @@
+alter table billing_subscriptions
+ add column orb_subscription_status text,
+ add column orb_current_billing_period_start_date timestamp without time zone,
+ add column orb_current_billing_period_end_date timestamp without time zone;
@@ -1,16 +1,10 @@
-pub mod billing;
pub mod contributors;
pub mod events;
pub mod extensions;
pub mod ips_file;
pub mod slack;
-use crate::db::Database;
-use crate::{
- AppState, Error, Result, auth,
- db::{User, UserId},
- rpc,
-};
+use crate::{AppState, Error, Result, auth, db::UserId, rpc};
use anyhow::Context as _;
use axum::{
Extension, Json, Router,
@@ -97,11 +91,8 @@ impl std::fmt::Display for SystemIdHeader {
pub fn routes(rpc_server: Arc<rpc::Server>) -> Router<(), Body> {
Router::new()
- .route("/user", get(update_or_create_authenticated_user))
- .route("/users/look_up", get(look_up_user))
.route("/users/:id/access_tokens", post(create_access_token))
.route("/rpc_server_snapshot", get(get_rpc_server_snapshot))
- .merge(billing::router())
.merge(contributors::router())
.layer(
ServiceBuilder::new()
@@ -141,141 +132,6 @@ pub async fn validate_api_token<B>(req: Request<B>, next: Next<B>) -> impl IntoR
Ok::<_, Error>(next.run(req).await)
}
-#[derive(Debug, Deserialize)]
-struct AuthenticatedUserParams {
- github_user_id: i32,
- github_login: String,
- github_email: Option<String>,
- github_name: Option<String>,
- github_user_created_at: chrono::DateTime<chrono::Utc>,
-}
-
-#[derive(Debug, Serialize)]
-struct AuthenticatedUserResponse {
- user: User,
- metrics_id: String,
- feature_flags: Vec<String>,
-}
-
-async fn update_or_create_authenticated_user(
- Query(params): Query<AuthenticatedUserParams>,
- Extension(app): Extension<Arc<AppState>>,
-) -> Result<Json<AuthenticatedUserResponse>> {
- let initial_channel_id = app.config.auto_join_channel_id;
-
- let user = app
- .db
- .update_or_create_user_by_github_account(
- ¶ms.github_login,
- params.github_user_id,
- params.github_email.as_deref(),
- params.github_name.as_deref(),
- params.github_user_created_at,
- initial_channel_id,
- )
- .await?;
- let metrics_id = app.db.get_user_metrics_id(user.id).await?;
- let feature_flags = app.db.get_user_flags(user.id).await?;
- Ok(Json(AuthenticatedUserResponse {
- user,
- metrics_id,
- feature_flags,
- }))
-}
-
-#[derive(Debug, Deserialize)]
-struct LookUpUserParams {
- identifier: String,
-}
-
-#[derive(Debug, Serialize)]
-struct LookUpUserResponse {
- user: Option<User>,
-}
-
-async fn look_up_user(
- Query(params): Query<LookUpUserParams>,
- Extension(app): Extension<Arc<AppState>>,
-) -> Result<Json<LookUpUserResponse>> {
- let user = resolve_identifier_to_user(&app.db, ¶ms.identifier).await?;
- let user = if let Some(user) = user {
- match user {
- UserOrId::User(user) => Some(user),
- UserOrId::Id(id) => app.db.get_user_by_id(id).await?,
- }
- } else {
- None
- };
-
- Ok(Json(LookUpUserResponse { user }))
-}
-
-enum UserOrId {
- User(User),
- Id(UserId),
-}
-
-async fn resolve_identifier_to_user(
- db: &Arc<Database>,
- identifier: &str,
-) -> Result<Option<UserOrId>> {
- if let Some(identifier) = identifier.parse::<i32>().ok() {
- let user = db.get_user_by_id(UserId(identifier)).await?;
-
- return Ok(user.map(UserOrId::User));
- }
-
- if identifier.starts_with("cus_") {
- let billing_customer = db
- .get_billing_customer_by_stripe_customer_id(&identifier)
- .await?;
-
- return Ok(billing_customer.map(|billing_customer| UserOrId::Id(billing_customer.user_id)));
- }
-
- if identifier.starts_with("sub_") {
- let billing_subscription = db
- .get_billing_subscription_by_stripe_subscription_id(&identifier)
- .await?;
-
- if let Some(billing_subscription) = billing_subscription {
- let billing_customer = db
- .get_billing_customer_by_id(billing_subscription.billing_customer_id)
- .await?;
-
- return Ok(
- billing_customer.map(|billing_customer| UserOrId::Id(billing_customer.user_id))
- );
- } else {
- return Ok(None);
- }
- }
-
- if identifier.contains('@') {
- let user = db.get_user_by_email(identifier).await?;
-
- return Ok(user.map(UserOrId::User));
- }
-
- if let Some(user) = db.get_user_by_github_login(identifier).await? {
- return Ok(Some(UserOrId::User(user)));
- }
-
- Ok(None)
-}
-
-#[derive(Deserialize, Debug)]
-struct CreateUserParams {
- github_user_id: i32,
- github_login: String,
- email_address: String,
- email_confirmation_code: Option<String>,
- #[serde(default)]
- admin: bool,
- #[serde(default)]
- invite_count: i32,
-}
-
async fn get_rpc_server_snapshot(
Extension(rpc_server): Extension<Arc<rpc::Server>>,
) -> Result<ErasedJson> {
@@ -1,1555 +0,0 @@
-use anyhow::{Context as _, bail};
-use axum::{
- Extension, Json, Router,
- extract::{self, Query},
- routing::{get, post},
-};
-use chrono::{DateTime, SecondsFormat, Utc};
-use collections::{HashMap, HashSet};
-use reqwest::StatusCode;
-use sea_orm::ActiveValue;
-use serde::{Deserialize, Serialize};
-use serde_json::json;
-use std::{str::FromStr, sync::Arc, time::Duration};
-use stripe::{
- BillingPortalSession, CancellationDetailsReason, CreateBillingPortalSession,
- CreateBillingPortalSessionFlowData, CreateBillingPortalSessionFlowDataAfterCompletion,
- CreateBillingPortalSessionFlowDataAfterCompletionRedirect,
- CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm,
- CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems,
- CreateBillingPortalSessionFlowDataType, CustomerId, EventObject, EventType, ListEvents,
- PaymentMethod, Subscription, SubscriptionId, SubscriptionStatus,
-};
-use util::{ResultExt, maybe};
-use zed_llm_client::LanguageModelProvider;
-
-use crate::api::events::SnowflakeRow;
-use crate::db::billing_subscription::{
- StripeCancellationReason, StripeSubscriptionStatus, SubscriptionKind,
-};
-use crate::llm::db::subscription_usage_meter::{self, CompletionMode};
-use crate::llm::{AGENT_EXTENDED_TRIAL_FEATURE_FLAG, DEFAULT_MAX_MONTHLY_SPEND};
-use crate::rpc::{ResultExt as _, Server};
-use crate::stripe_client::{
- StripeCancellationDetailsReason, StripeClient, StripeCustomerId, StripeSubscription,
- StripeSubscriptionId, UpdateCustomerParams,
-};
-use crate::{AppState, Error, Result};
-use crate::{db::UserId, llm::db::LlmDatabase};
-use crate::{
- db::{
- BillingSubscriptionId, CreateBillingCustomerParams, CreateBillingSubscriptionParams,
- CreateProcessedStripeEventParams, UpdateBillingCustomerParams,
- UpdateBillingPreferencesParams, UpdateBillingSubscriptionParams, billing_customer,
- },
- stripe_billing::StripeBilling,
-};
-
-pub fn router() -> Router {
- Router::new()
- .route(
- "/billing/preferences",
- get(get_billing_preferences).put(update_billing_preferences),
- )
- .route(
- "/billing/subscriptions",
- get(list_billing_subscriptions).post(create_billing_subscription),
- )
- .route(
- "/billing/subscriptions/manage",
- post(manage_billing_subscription),
- )
- .route(
- "/billing/subscriptions/sync",
- post(sync_billing_subscription),
- )
- .route("/billing/usage", get(get_current_usage))
-}
-
-#[derive(Debug, Deserialize)]
-struct GetBillingPreferencesParams {
- github_user_id: i32,
-}
-
-#[derive(Debug, Serialize)]
-struct BillingPreferencesResponse {
- trial_started_at: Option<String>,
- max_monthly_llm_usage_spending_in_cents: i32,
- model_request_overages_enabled: bool,
- model_request_overages_spend_limit_in_cents: i32,
-}
-
-async fn get_billing_preferences(
- Extension(app): Extension<Arc<AppState>>,
- Query(params): Query<GetBillingPreferencesParams>,
-) -> Result<Json<BillingPreferencesResponse>> {
- let user = app
- .db
- .get_user_by_github_user_id(params.github_user_id)
- .await?
- .context("user not found")?;
-
- let billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?;
- let preferences = app.db.get_billing_preferences(user.id).await?;
-
- Ok(Json(BillingPreferencesResponse {
- trial_started_at: billing_customer
- .and_then(|billing_customer| billing_customer.trial_started_at)
- .map(|trial_started_at| {
- trial_started_at
- .and_utc()
- .to_rfc3339_opts(SecondsFormat::Millis, true)
- }),
- max_monthly_llm_usage_spending_in_cents: preferences
- .as_ref()
- .map_or(DEFAULT_MAX_MONTHLY_SPEND.0 as i32, |preferences| {
- preferences.max_monthly_llm_usage_spending_in_cents
- }),
- model_request_overages_enabled: preferences.as_ref().map_or(false, |preferences| {
- preferences.model_request_overages_enabled
- }),
- model_request_overages_spend_limit_in_cents: preferences
- .as_ref()
- .map_or(0, |preferences| {
- preferences.model_request_overages_spend_limit_in_cents
- }),
- }))
-}
-
-#[derive(Debug, Deserialize)]
-struct UpdateBillingPreferencesBody {
- github_user_id: i32,
- #[serde(default)]
- max_monthly_llm_usage_spending_in_cents: i32,
- #[serde(default)]
- model_request_overages_enabled: bool,
- #[serde(default)]
- model_request_overages_spend_limit_in_cents: i32,
-}
-
-async fn update_billing_preferences(
- Extension(app): Extension<Arc<AppState>>,
- Extension(rpc_server): Extension<Arc<crate::rpc::Server>>,
- extract::Json(body): extract::Json<UpdateBillingPreferencesBody>,
-) -> Result<Json<BillingPreferencesResponse>> {
- let user = app
- .db
- .get_user_by_github_user_id(body.github_user_id)
- .await?
- .context("user not found")?;
-
- let billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?;
-
- let max_monthly_llm_usage_spending_in_cents =
- body.max_monthly_llm_usage_spending_in_cents.max(0);
- let model_request_overages_spend_limit_in_cents =
- body.model_request_overages_spend_limit_in_cents.max(0);
-
- let billing_preferences =
- if let Some(_billing_preferences) = app.db.get_billing_preferences(user.id).await? {
- app.db
- .update_billing_preferences(
- user.id,
- &UpdateBillingPreferencesParams {
- max_monthly_llm_usage_spending_in_cents: ActiveValue::set(
- max_monthly_llm_usage_spending_in_cents,
- ),
- model_request_overages_enabled: ActiveValue::set(
- body.model_request_overages_enabled,
- ),
- model_request_overages_spend_limit_in_cents: ActiveValue::set(
- model_request_overages_spend_limit_in_cents,
- ),
- },
- )
- .await?
- } else {
- app.db
- .create_billing_preferences(
- user.id,
- &crate::db::CreateBillingPreferencesParams {
- max_monthly_llm_usage_spending_in_cents,
- model_request_overages_enabled: body.model_request_overages_enabled,
- model_request_overages_spend_limit_in_cents,
- },
- )
- .await?
- };
-
- SnowflakeRow::new(
- "Billing Preferences Updated",
- Some(user.metrics_id),
- user.admin,
- None,
- json!({
- "user_id": user.id,
- "model_request_overages_enabled": billing_preferences.model_request_overages_enabled,
- "model_request_overages_spend_limit_in_cents": billing_preferences.model_request_overages_spend_limit_in_cents,
- "max_monthly_llm_usage_spending_in_cents": billing_preferences.max_monthly_llm_usage_spending_in_cents,
- }),
- )
- .write(&app.kinesis_client, &app.config.kinesis_stream)
- .await
- .log_err();
-
- rpc_server.refresh_llm_tokens_for_user(user.id).await;
-
- Ok(Json(BillingPreferencesResponse {
- trial_started_at: billing_customer
- .and_then(|billing_customer| billing_customer.trial_started_at)
- .map(|trial_started_at| {
- trial_started_at
- .and_utc()
- .to_rfc3339_opts(SecondsFormat::Millis, true)
- }),
- max_monthly_llm_usage_spending_in_cents: billing_preferences
- .max_monthly_llm_usage_spending_in_cents,
- model_request_overages_enabled: billing_preferences.model_request_overages_enabled,
- model_request_overages_spend_limit_in_cents: billing_preferences
- .model_request_overages_spend_limit_in_cents,
- }))
-}
-
-#[derive(Debug, Deserialize)]
-struct ListBillingSubscriptionsParams {
- github_user_id: i32,
-}
-
-#[derive(Debug, Serialize)]
-struct BillingSubscriptionJson {
- id: BillingSubscriptionId,
- name: String,
- status: StripeSubscriptionStatus,
- period: Option<BillingSubscriptionPeriodJson>,
- trial_end_at: Option<String>,
- cancel_at: Option<String>,
- /// Whether this subscription can be canceled.
- is_cancelable: bool,
-}
-
-#[derive(Debug, Serialize)]
-struct BillingSubscriptionPeriodJson {
- start_at: String,
- end_at: String,
-}
-
-#[derive(Debug, Serialize)]
-struct ListBillingSubscriptionsResponse {
- subscriptions: Vec<BillingSubscriptionJson>,
-}
-
-async fn list_billing_subscriptions(
- Extension(app): Extension<Arc<AppState>>,
- Query(params): Query<ListBillingSubscriptionsParams>,
-) -> Result<Json<ListBillingSubscriptionsResponse>> {
- let user = app
- .db
- .get_user_by_github_user_id(params.github_user_id)
- .await?
- .context("user not found")?;
-
- let subscriptions = app.db.get_billing_subscriptions(user.id).await?;
-
- Ok(Json(ListBillingSubscriptionsResponse {
- subscriptions: subscriptions
- .into_iter()
- .map(|subscription| BillingSubscriptionJson {
- id: subscription.id,
- name: match subscription.kind {
- Some(SubscriptionKind::ZedPro) => "Zed Pro".to_string(),
- Some(SubscriptionKind::ZedProTrial) => "Zed Pro (Trial)".to_string(),
- Some(SubscriptionKind::ZedFree) => "Zed Free".to_string(),
- None => "Zed LLM Usage".to_string(),
- },
- status: subscription.stripe_subscription_status,
- period: maybe!({
- let start_at = subscription.current_period_start_at()?;
- let end_at = subscription.current_period_end_at()?;
-
- Some(BillingSubscriptionPeriodJson {
- start_at: start_at.to_rfc3339_opts(SecondsFormat::Millis, true),
- end_at: end_at.to_rfc3339_opts(SecondsFormat::Millis, true),
- })
- }),
- trial_end_at: if subscription.kind == Some(SubscriptionKind::ZedProTrial) {
- maybe!({
- let end_at = subscription.stripe_current_period_end?;
- let end_at = DateTime::from_timestamp(end_at, 0)?;
-
- Some(end_at.to_rfc3339_opts(SecondsFormat::Millis, true))
- })
- } else {
- None
- },
- cancel_at: subscription.stripe_cancel_at.map(|cancel_at| {
- cancel_at
- .and_utc()
- .to_rfc3339_opts(SecondsFormat::Millis, true)
- }),
- is_cancelable: subscription.kind != Some(SubscriptionKind::ZedFree)
- && subscription.stripe_subscription_status.is_cancelable()
- && subscription.stripe_cancel_at.is_none(),
- })
- .collect(),
- }))
-}
-
-#[derive(Debug, PartialEq, Clone, Copy, Deserialize)]
-#[serde(rename_all = "snake_case")]
-enum ProductCode {
- ZedPro,
- ZedProTrial,
-}
-
-#[derive(Debug, Deserialize)]
-struct CreateBillingSubscriptionBody {
- github_user_id: i32,
- product: ProductCode,
-}
-
-#[derive(Debug, Serialize)]
-struct CreateBillingSubscriptionResponse {
- checkout_session_url: String,
-}
-
-/// Initiates a Stripe Checkout session for creating a billing subscription.
-async fn create_billing_subscription(
- Extension(app): Extension<Arc<AppState>>,
- extract::Json(body): extract::Json<CreateBillingSubscriptionBody>,
-) -> Result<Json<CreateBillingSubscriptionResponse>> {
- let user = app
- .db
- .get_user_by_github_user_id(body.github_user_id)
- .await?
- .context("user not found")?;
-
- let Some(stripe_billing) = app.stripe_billing.clone() else {
- log::error!("failed to retrieve Stripe billing object");
- Err(Error::http(
- StatusCode::NOT_IMPLEMENTED,
- "not supported".into(),
- ))?
- };
-
- if let Some(existing_subscription) = app.db.get_active_billing_subscription(user.id).await? {
- let is_checkout_allowed = body.product == ProductCode::ZedProTrial
- && existing_subscription.kind == Some(SubscriptionKind::ZedFree);
-
- if !is_checkout_allowed {
- return Err(Error::http(
- StatusCode::CONFLICT,
- "user already has an active subscription".into(),
- ));
- }
- }
-
- let existing_billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?;
- if let Some(existing_billing_customer) = &existing_billing_customer {
- if existing_billing_customer.has_overdue_invoices {
- return Err(Error::http(
- StatusCode::PAYMENT_REQUIRED,
- "user has overdue invoices".into(),
- ));
- }
- }
-
- let customer_id = if let Some(existing_customer) = &existing_billing_customer {
- let customer_id = StripeCustomerId(existing_customer.stripe_customer_id.clone().into());
- if let Some(email) = user.email_address.as_deref() {
- stripe_billing
- .client()
- .update_customer(&customer_id, UpdateCustomerParams { email: Some(email) })
- .await
- // Update of email address is best-effort - continue checkout even if it fails
- .context("error updating stripe customer email address")
- .log_err();
- }
- customer_id
- } else {
- stripe_billing
- .find_or_create_customer_by_email(user.email_address.as_deref())
- .await?
- };
-
- let success_url = format!(
- "{}/account?checkout_complete=1",
- app.config.zed_dot_dev_url()
- );
-
- let checkout_session_url = match body.product {
- ProductCode::ZedPro => {
- stripe_billing
- .checkout_with_zed_pro(&customer_id, &user.github_login, &success_url)
- .await?
- }
- ProductCode::ZedProTrial => {
- if let Some(existing_billing_customer) = &existing_billing_customer {
- if existing_billing_customer.trial_started_at.is_some() {
- return Err(Error::http(
- StatusCode::FORBIDDEN,
- "user already used free trial".into(),
- ));
- }
- }
-
- let feature_flags = app.db.get_user_flags(user.id).await?;
-
- stripe_billing
- .checkout_with_zed_pro_trial(
- &customer_id,
- &user.github_login,
- feature_flags,
- &success_url,
- )
- .await?
- }
- };
-
- Ok(Json(CreateBillingSubscriptionResponse {
- checkout_session_url,
- }))
-}
-
-#[derive(Debug, PartialEq, Deserialize)]
-#[serde(rename_all = "snake_case")]
-enum ManageSubscriptionIntent {
- /// The user intends to manage their subscription.
- ///
- /// This will open the Stripe billing portal without putting the user in a specific flow.
- ManageSubscription,
- /// The user intends to update their payment method.
- UpdatePaymentMethod,
- /// The user intends to upgrade to Zed Pro.
- UpgradeToPro,
- /// The user intends to cancel their subscription.
- Cancel,
- /// The user intends to stop the cancellation of their subscription.
- StopCancellation,
-}
-
-#[derive(Debug, Deserialize)]
-struct ManageBillingSubscriptionBody {
- github_user_id: i32,
- intent: ManageSubscriptionIntent,
- /// The ID of the subscription to manage.
- subscription_id: BillingSubscriptionId,
- redirect_to: Option<String>,
-}
-
-#[derive(Debug, Serialize)]
-struct ManageBillingSubscriptionResponse {
- billing_portal_session_url: Option<String>,
-}
-
-/// Initiates a Stripe customer portal session for managing a billing subscription.
-async fn manage_billing_subscription(
- Extension(app): Extension<Arc<AppState>>,
- extract::Json(body): extract::Json<ManageBillingSubscriptionBody>,
-) -> Result<Json<ManageBillingSubscriptionResponse>> {
- let user = app
- .db
- .get_user_by_github_user_id(body.github_user_id)
- .await?
- .context("user not found")?;
-
- let Some(stripe_client) = app.real_stripe_client.clone() else {
- log::error!("failed to retrieve Stripe client");
- Err(Error::http(
- StatusCode::NOT_IMPLEMENTED,
- "not supported".into(),
- ))?
- };
-
- let Some(stripe_billing) = app.stripe_billing.clone() else {
- log::error!("failed to retrieve Stripe billing object");
- Err(Error::http(
- StatusCode::NOT_IMPLEMENTED,
- "not supported".into(),
- ))?
- };
-
- let customer = app
- .db
- .get_billing_customer_by_user_id(user.id)
- .await?
- .context("billing customer not found")?;
- let customer_id = CustomerId::from_str(&customer.stripe_customer_id)
- .context("failed to parse customer ID")?;
-
- let subscription = app
- .db
- .get_billing_subscription_by_id(body.subscription_id)
- .await?
- .context("subscription not found")?;
- let subscription_id = SubscriptionId::from_str(&subscription.stripe_subscription_id)
- .context("failed to parse subscription ID")?;
-
- if body.intent == ManageSubscriptionIntent::StopCancellation {
- let updated_stripe_subscription = Subscription::update(
- &stripe_client,
- &subscription_id,
- stripe::UpdateSubscription {
- cancel_at_period_end: Some(false),
- ..Default::default()
- },
- )
- .await?;
-
- app.db
- .update_billing_subscription(
- subscription.id,
- &UpdateBillingSubscriptionParams {
- stripe_cancel_at: ActiveValue::set(
- updated_stripe_subscription
- .cancel_at
- .and_then(|cancel_at| DateTime::from_timestamp(cancel_at, 0))
- .map(|time| time.naive_utc()),
- ),
- ..Default::default()
- },
- )
- .await?;
-
- return Ok(Json(ManageBillingSubscriptionResponse {
- billing_portal_session_url: None,
- }));
- }
-
- let flow = match body.intent {
- ManageSubscriptionIntent::ManageSubscription => None,
- ManageSubscriptionIntent::UpgradeToPro => {
- let zed_pro_price_id: stripe::PriceId =
- stripe_billing.zed_pro_price_id().await?.try_into()?;
- let zed_free_price_id: stripe::PriceId =
- stripe_billing.zed_free_price_id().await?.try_into()?;
-
- let stripe_subscription =
- Subscription::retrieve(&stripe_client, &subscription_id, &[]).await?;
-
- let is_on_zed_pro_trial = stripe_subscription.status == SubscriptionStatus::Trialing
- && stripe_subscription.items.data.iter().any(|item| {
- item.price
- .as_ref()
- .map_or(false, |price| price.id == zed_pro_price_id)
- });
- if is_on_zed_pro_trial {
- let payment_methods = PaymentMethod::list(
- &stripe_client,
- &stripe::ListPaymentMethods {
- customer: Some(stripe_subscription.customer.id()),
- ..Default::default()
- },
- )
- .await?;
-
- let has_payment_method = !payment_methods.data.is_empty();
- if !has_payment_method {
- return Err(Error::http(
- StatusCode::BAD_REQUEST,
- "missing payment method".into(),
- ));
- }
-
- // If the user is already on a Zed Pro trial and wants to upgrade to Pro, we just need to end their trial early.
- Subscription::update(
- &stripe_client,
- &stripe_subscription.id,
- stripe::UpdateSubscription {
- trial_end: Some(stripe::Scheduled::now()),
- ..Default::default()
- },
- )
- .await?;
-
- return Ok(Json(ManageBillingSubscriptionResponse {
- billing_portal_session_url: None,
- }));
- }
-
- let subscription_item_to_update = stripe_subscription
- .items
- .data
- .iter()
- .find_map(|item| {
- let price = item.price.as_ref()?;
-
- if price.id == zed_free_price_id {
- Some(item.id.clone())
- } else {
- None
- }
- })
- .context("No subscription item to update")?;
-
- Some(CreateBillingPortalSessionFlowData {
- type_: CreateBillingPortalSessionFlowDataType::SubscriptionUpdateConfirm,
- subscription_update_confirm: Some(
- CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm {
- subscription: subscription.stripe_subscription_id,
- items: vec![
- CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems {
- id: subscription_item_to_update.to_string(),
- price: Some(zed_pro_price_id.to_string()),
- quantity: Some(1),
- },
- ],
- discounts: None,
- },
- ),
- ..Default::default()
- })
- }
- ManageSubscriptionIntent::UpdatePaymentMethod => Some(CreateBillingPortalSessionFlowData {
- type_: CreateBillingPortalSessionFlowDataType::PaymentMethodUpdate,
- after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion {
- type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect,
- redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect {
- return_url: format!(
- "{}{path}",
- app.config.zed_dot_dev_url(),
- path = body.redirect_to.unwrap_or_else(|| "/account".to_string())
- ),
- }),
- ..Default::default()
- }),
- ..Default::default()
- }),
- ManageSubscriptionIntent::Cancel => {
- if subscription.kind == Some(SubscriptionKind::ZedFree) {
- return Err(Error::http(
- StatusCode::BAD_REQUEST,
- "free subscription cannot be canceled".into(),
- ));
- }
-
- Some(CreateBillingPortalSessionFlowData {
- type_: CreateBillingPortalSessionFlowDataType::SubscriptionCancel,
- after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion {
- type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect,
- redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect {
- return_url: format!("{}/account", app.config.zed_dot_dev_url()),
- }),
- ..Default::default()
- }),
- subscription_cancel: Some(
- stripe::CreateBillingPortalSessionFlowDataSubscriptionCancel {
- subscription: subscription.stripe_subscription_id,
- retention: None,
- },
- ),
- ..Default::default()
- })
- }
- ManageSubscriptionIntent::StopCancellation => unreachable!(),
- };
-
- let mut params = CreateBillingPortalSession::new(customer_id);
- params.flow_data = flow;
- let return_url = format!("{}/account", app.config.zed_dot_dev_url());
- params.return_url = Some(&return_url);
-
- let session = BillingPortalSession::create(&stripe_client, params).await?;
-
- Ok(Json(ManageBillingSubscriptionResponse {
- billing_portal_session_url: Some(session.url),
- }))
-}
-
-#[derive(Debug, Deserialize)]
-struct SyncBillingSubscriptionBody {
- github_user_id: i32,
-}
-
-#[derive(Debug, Serialize)]
-struct SyncBillingSubscriptionResponse {
- stripe_customer_id: String,
-}
-
-async fn sync_billing_subscription(
- Extension(app): Extension<Arc<AppState>>,
- extract::Json(body): extract::Json<SyncBillingSubscriptionBody>,
-) -> Result<Json<SyncBillingSubscriptionResponse>> {
- let Some(stripe_client) = app.stripe_client.clone() else {
- log::error!("failed to retrieve Stripe client");
- Err(Error::http(
- StatusCode::NOT_IMPLEMENTED,
- "not supported".into(),
- ))?
- };
-
- let user = app
- .db
- .get_user_by_github_user_id(body.github_user_id)
- .await?
- .context("user not found")?;
-
- let billing_customer = app
- .db
- .get_billing_customer_by_user_id(user.id)
- .await?
- .context("billing customer not found")?;
- let stripe_customer_id = StripeCustomerId(billing_customer.stripe_customer_id.clone().into());
-
- let subscriptions = stripe_client
- .list_subscriptions_for_customer(&stripe_customer_id)
- .await?;
-
- for subscription in subscriptions {
- let subscription_id = subscription.id.clone();
-
- sync_subscription(&app, &stripe_client, subscription)
- .await
- .with_context(|| {
- format!(
- "failed to sync subscription {subscription_id} for user {}",
- user.id,
- )
- })?;
- }
-
- Ok(Json(SyncBillingSubscriptionResponse {
- stripe_customer_id: billing_customer.stripe_customer_id.clone(),
- }))
-}
-
-/// The amount of time we wait in between each poll of Stripe events.
-///
-/// This value should strike a balance between:
-/// 1. Being short enough that we update quickly when something in Stripe changes
-/// 2. Being long enough that we don't eat into our rate limits.
-///
-/// As a point of reference, the Sequin folks say they have this at **500ms**:
-///
-/// > We poll the Stripe /events endpoint every 500ms per account
-/// >
-/// > — https://blog.sequinstream.com/events-not-webhooks/
-const POLL_EVENTS_INTERVAL: Duration = Duration::from_secs(5);
-
-/// The maximum number of events to return per page.
-///
-/// We set this to 100 (the max) so we have to make fewer requests to Stripe.
-///
-/// > Limit can range between 1 and 100, and the default is 10.
-const EVENTS_LIMIT_PER_PAGE: u64 = 100;
-
-/// The number of pages consisting entirely of already-processed events that we
-/// will see before we stop retrieving events.
-///
-/// This is used to prevent over-fetching the Stripe events API for events we've
-/// already seen and processed.
-const NUMBER_OF_ALREADY_PROCESSED_PAGES_BEFORE_WE_STOP: usize = 4;
-
-/// Polls the Stripe events API periodically to reconcile the records in our
-/// database with the data in Stripe.
-pub fn poll_stripe_events_periodically(app: Arc<AppState>, rpc_server: Arc<Server>) {
- let Some(real_stripe_client) = app.real_stripe_client.clone() else {
- log::warn!("failed to retrieve Stripe client");
- return;
- };
- let Some(stripe_client) = app.stripe_client.clone() else {
- log::warn!("failed to retrieve Stripe client");
- return;
- };
-
- let executor = app.executor.clone();
- executor.spawn_detached({
- let executor = executor.clone();
- async move {
- loop {
- poll_stripe_events(&app, &rpc_server, &stripe_client, &real_stripe_client)
- .await
- .log_err();
-
- executor.sleep(POLL_EVENTS_INTERVAL).await;
- }
- }
- });
-}
-
-async fn poll_stripe_events(
- app: &Arc<AppState>,
- rpc_server: &Arc<Server>,
- stripe_client: &Arc<dyn StripeClient>,
- real_stripe_client: &stripe::Client,
-) -> anyhow::Result<()> {
- fn event_type_to_string(event_type: EventType) -> String {
- // Calling `to_string` on `stripe::EventType` members gives us a quoted string,
- // so we need to unquote it.
- event_type.to_string().trim_matches('"').to_string()
- }
-
- let event_types = [
- EventType::CustomerCreated,
- EventType::CustomerUpdated,
- EventType::CustomerSubscriptionCreated,
- EventType::CustomerSubscriptionUpdated,
- EventType::CustomerSubscriptionPaused,
- EventType::CustomerSubscriptionResumed,
- EventType::CustomerSubscriptionDeleted,
- ]
- .into_iter()
- .map(event_type_to_string)
- .collect::<Vec<_>>();
-
- let mut pages_of_already_processed_events = 0;
- let mut unprocessed_events = Vec::new();
-
- log::info!(
- "Stripe events: starting retrieval for {}",
- event_types.join(", ")
- );
- let mut params = ListEvents::new();
- params.types = Some(event_types.clone());
- params.limit = Some(EVENTS_LIMIT_PER_PAGE);
-
- let mut event_pages = stripe::Event::list(&real_stripe_client, ¶ms)
- .await?
- .paginate(params);
-
- loop {
- let processed_event_ids = {
- let event_ids = event_pages
- .page
- .data
- .iter()
- .map(|event| event.id.as_str())
- .collect::<Vec<_>>();
- app.db
- .get_processed_stripe_events_by_event_ids(&event_ids)
- .await?
- .into_iter()
- .map(|event| event.stripe_event_id)
- .collect::<Vec<_>>()
- };
-
- let mut processed_events_in_page = 0;
- let events_in_page = event_pages.page.data.len();
- for event in &event_pages.page.data {
- if processed_event_ids.contains(&event.id.to_string()) {
- processed_events_in_page += 1;
- log::debug!("Stripe events: already processed '{}', skipping", event.id);
- } else {
- unprocessed_events.push(event.clone());
- }
- }
-
- if processed_events_in_page == events_in_page {
- pages_of_already_processed_events += 1;
- }
-
- if event_pages.page.has_more {
- if pages_of_already_processed_events >= NUMBER_OF_ALREADY_PROCESSED_PAGES_BEFORE_WE_STOP
- {
- log::info!(
- "Stripe events: stopping, saw {pages_of_already_processed_events} pages of already-processed events"
- );
- break;
- } else {
- log::info!("Stripe events: retrieving next page");
- event_pages = event_pages.next(&real_stripe_client).await?;
- }
- } else {
- break;
- }
- }
-
- log::info!("Stripe events: unprocessed {}", unprocessed_events.len());
-
- // Sort all of the unprocessed events in ascending order, so we can handle them in the order they occurred.
- unprocessed_events.sort_by(|a, b| a.created.cmp(&b.created).then_with(|| a.id.cmp(&b.id)));
-
- for event in unprocessed_events {
- let event_id = event.id.clone();
- let processed_event_params = CreateProcessedStripeEventParams {
- stripe_event_id: event.id.to_string(),
- stripe_event_type: event_type_to_string(event.type_),
- stripe_event_created_timestamp: event.created,
- };
-
- // If the event has happened too far in the past, we don't want to
- // process it and risk overwriting other more-recent updates.
- //
- // 1 day was chosen arbitrarily. This could be made longer or shorter.
- let one_day = Duration::from_secs(24 * 60 * 60);
- let a_day_ago = Utc::now() - one_day;
- if a_day_ago.timestamp() > event.created {
- log::info!(
- "Stripe events: event '{}' is more than {one_day:?} old, marking as processed",
- event_id
- );
- app.db
- .create_processed_stripe_event(&processed_event_params)
- .await?;
-
- continue;
- }
-
- let process_result = match event.type_ {
- EventType::CustomerCreated | EventType::CustomerUpdated => {
- handle_customer_event(app, real_stripe_client, event).await
- }
- EventType::CustomerSubscriptionCreated
- | EventType::CustomerSubscriptionUpdated
- | EventType::CustomerSubscriptionPaused
- | EventType::CustomerSubscriptionResumed
- | EventType::CustomerSubscriptionDeleted => {
- handle_customer_subscription_event(app, rpc_server, stripe_client, event).await
- }
- _ => Ok(()),
- };
-
- if let Some(()) = process_result
- .with_context(|| format!("failed to process event {event_id} successfully"))
- .log_err()
- {
- app.db
- .create_processed_stripe_event(&processed_event_params)
- .await?;
- }
- }
-
- Ok(())
-}
-
-async fn handle_customer_event(
- app: &Arc<AppState>,
- _stripe_client: &stripe::Client,
- event: stripe::Event,
-) -> anyhow::Result<()> {
- let EventObject::Customer(customer) = event.data.object else {
- bail!("unexpected event payload for {}", event.id);
- };
-
- log::info!("handling Stripe {} event: {}", event.type_, event.id);
-
- let Some(email) = customer.email else {
- log::info!("Stripe customer has no email: skipping");
- return Ok(());
- };
-
- let Some(user) = app.db.get_user_by_email(&email).await? else {
- log::info!("no user found for email: skipping");
- return Ok(());
- };
-
- if let Some(existing_customer) = app
- .db
- .get_billing_customer_by_stripe_customer_id(&customer.id)
- .await?
- {
- app.db
- .update_billing_customer(
- existing_customer.id,
- &UpdateBillingCustomerParams {
- // For now we just leave the information as-is, as it is not
- // likely to change.
- ..Default::default()
- },
- )
- .await?;
- } else {
- app.db
- .create_billing_customer(&CreateBillingCustomerParams {
- user_id: user.id,
- stripe_customer_id: customer.id.to_string(),
- })
- .await?;
- }
-
- Ok(())
-}
-
-async fn sync_subscription(
- app: &Arc<AppState>,
- stripe_client: &Arc<dyn StripeClient>,
- subscription: StripeSubscription,
-) -> anyhow::Result<billing_customer::Model> {
- let subscription_kind = if let Some(stripe_billing) = &app.stripe_billing {
- stripe_billing
- .determine_subscription_kind(&subscription)
- .await
- } else {
- None
- };
-
- let billing_customer =
- find_or_create_billing_customer(app, stripe_client.as_ref(), &subscription.customer)
- .await?
- .context("billing customer not found")?;
-
- if let Some(SubscriptionKind::ZedProTrial) = subscription_kind {
- if subscription.status == SubscriptionStatus::Trialing {
- let current_period_start =
- DateTime::from_timestamp(subscription.current_period_start, 0)
- .context("No trial subscription period start")?;
-
- app.db
- .update_billing_customer(
- billing_customer.id,
- &UpdateBillingCustomerParams {
- trial_started_at: ActiveValue::set(Some(current_period_start.naive_utc())),
- ..Default::default()
- },
- )
- .await?;
- }
- }
-
- let was_canceled_due_to_payment_failure = subscription.status == SubscriptionStatus::Canceled
- && subscription
- .cancellation_details
- .as_ref()
- .and_then(|details| details.reason)
- .map_or(false, |reason| {
- reason == StripeCancellationDetailsReason::PaymentFailed
- });
-
- if was_canceled_due_to_payment_failure {
- app.db
- .update_billing_customer(
- billing_customer.id,
- &UpdateBillingCustomerParams {
- has_overdue_invoices: ActiveValue::set(true),
- ..Default::default()
- },
- )
- .await?;
- }
-
- if let Some(existing_subscription) = app
- .db
- .get_billing_subscription_by_stripe_subscription_id(subscription.id.0.as_ref())
- .await?
- {
- app.db
- .update_billing_subscription(
- existing_subscription.id,
- &UpdateBillingSubscriptionParams {
- billing_customer_id: ActiveValue::set(billing_customer.id),
- kind: ActiveValue::set(subscription_kind),
- stripe_subscription_id: ActiveValue::set(subscription.id.to_string()),
- stripe_subscription_status: ActiveValue::set(subscription.status.into()),
- stripe_cancel_at: ActiveValue::set(
- subscription
- .cancel_at
- .and_then(|cancel_at| DateTime::from_timestamp(cancel_at, 0))
- .map(|time| time.naive_utc()),
- ),
- stripe_cancellation_reason: ActiveValue::set(
- subscription
- .cancellation_details
- .and_then(|details| details.reason)
- .map(|reason| reason.into()),
- ),
- stripe_current_period_start: ActiveValue::set(Some(
- subscription.current_period_start,
- )),
- stripe_current_period_end: ActiveValue::set(Some(
- subscription.current_period_end,
- )),
- },
- )
- .await?;
- } else {
- if let Some(existing_subscription) = app
- .db
- .get_active_billing_subscription(billing_customer.user_id)
- .await?
- {
- if existing_subscription.kind == Some(SubscriptionKind::ZedFree)
- && subscription_kind == Some(SubscriptionKind::ZedProTrial)
- {
- let stripe_subscription_id = StripeSubscriptionId(
- existing_subscription.stripe_subscription_id.clone().into(),
- );
-
- stripe_client
- .cancel_subscription(&stripe_subscription_id)
- .await?;
- } else {
- // If the user already has an active billing subscription, ignore the
- // event and return an `Ok` to signal that it was processed
- // successfully.
- //
- // There is the possibility that this could cause us to not create a
- // subscription in the following scenario:
- //
- // 1. User has an active subscription A
- // 2. User cancels subscription A
- // 3. User creates a new subscription B
- // 4. We process the new subscription B before the cancellation of subscription A
- // 5. User ends up with no subscriptions
- //
- // In theory this situation shouldn't arise as we try to process the events in the order they occur.
-
- log::info!(
- "user {user_id} already has an active subscription, skipping creation of subscription {subscription_id}",
- user_id = billing_customer.user_id,
- subscription_id = subscription.id
- );
- return Ok(billing_customer);
- }
- }
-
- app.db
- .create_billing_subscription(&CreateBillingSubscriptionParams {
- billing_customer_id: billing_customer.id,
- kind: subscription_kind,
- stripe_subscription_id: subscription.id.to_string(),
- stripe_subscription_status: subscription.status.into(),
- stripe_cancellation_reason: subscription
- .cancellation_details
- .and_then(|details| details.reason)
- .map(|reason| reason.into()),
- stripe_current_period_start: Some(subscription.current_period_start),
- stripe_current_period_end: Some(subscription.current_period_end),
- })
- .await?;
- }
-
- if let Some(stripe_billing) = app.stripe_billing.as_ref() {
- if subscription.status == SubscriptionStatus::Canceled
- || subscription.status == SubscriptionStatus::Paused
- {
- let already_has_active_billing_subscription = app
- .db
- .has_active_billing_subscription(billing_customer.user_id)
- .await?;
- if !already_has_active_billing_subscription {
- let stripe_customer_id =
- StripeCustomerId(billing_customer.stripe_customer_id.clone().into());
-
- stripe_billing
- .subscribe_to_zed_free(stripe_customer_id)
- .await?;
- }
- }
- }
-
- Ok(billing_customer)
-}
-
-async fn handle_customer_subscription_event(
- app: &Arc<AppState>,
- rpc_server: &Arc<Server>,
- stripe_client: &Arc<dyn StripeClient>,
- event: stripe::Event,
-) -> anyhow::Result<()> {
- let EventObject::Subscription(subscription) = event.data.object else {
- bail!("unexpected event payload for {}", event.id);
- };
-
- log::info!("handling Stripe {} event: {}", event.type_, event.id);
-
- let billing_customer = sync_subscription(app, stripe_client, subscription.into()).await?;
-
- // When the user's subscription changes, push down any changes to their plan.
- rpc_server
- .update_plan_for_user(billing_customer.user_id)
- .await
- .trace_err();
-
- // When the user's subscription changes, we want to refresh their LLM tokens
- // to either grant/revoke access.
- rpc_server
- .refresh_llm_tokens_for_user(billing_customer.user_id)
- .await;
-
- Ok(())
-}
-
-#[derive(Debug, Deserialize)]
-struct GetCurrentUsageParams {
- github_user_id: i32,
-}
-
-#[derive(Debug, Serialize)]
-struct UsageCounts {
- pub used: i32,
- pub limit: Option<i32>,
- pub remaining: Option<i32>,
-}
-
-#[derive(Debug, Serialize)]
-struct ModelRequestUsage {
- pub model: String,
- pub mode: CompletionMode,
- pub requests: i32,
-}
-
-#[derive(Debug, Serialize)]
-struct CurrentUsage {
- pub model_requests: UsageCounts,
- pub model_request_usage: Vec<ModelRequestUsage>,
- pub edit_predictions: UsageCounts,
-}
-
-#[derive(Debug, Default, Serialize)]
-struct GetCurrentUsageResponse {
- pub plan: String,
- pub current_usage: Option<CurrentUsage>,
-}
-
-async fn get_current_usage(
- Extension(app): Extension<Arc<AppState>>,
- Query(params): Query<GetCurrentUsageParams>,
-) -> Result<Json<GetCurrentUsageResponse>> {
- let user = app
- .db
- .get_user_by_github_user_id(params.github_user_id)
- .await?
- .context("user not found")?;
-
- let feature_flags = app.db.get_user_flags(user.id).await?;
- let has_extended_trial = feature_flags
- .iter()
- .any(|flag| flag == AGENT_EXTENDED_TRIAL_FEATURE_FLAG);
-
- let Some(llm_db) = app.llm_db.clone() else {
- return Err(Error::http(
- StatusCode::NOT_IMPLEMENTED,
- "LLM database not available".into(),
- ));
- };
-
- let Some(subscription) = app.db.get_active_billing_subscription(user.id).await? else {
- return Ok(Json(GetCurrentUsageResponse::default()));
- };
-
- let subscription_period = maybe!({
- let period_start_at = subscription.current_period_start_at()?;
- let period_end_at = subscription.current_period_end_at()?;
-
- Some((period_start_at, period_end_at))
- });
-
- let Some((period_start_at, period_end_at)) = subscription_period else {
- return Ok(Json(GetCurrentUsageResponse::default()));
- };
-
- let usage = llm_db
- .get_subscription_usage_for_period(user.id, period_start_at, period_end_at)
- .await?;
-
- let plan = subscription
- .kind
- .map(Into::into)
- .unwrap_or(zed_llm_client::Plan::ZedFree);
-
- let model_requests_limit = match plan.model_requests_limit() {
- zed_llm_client::UsageLimit::Limited(limit) => {
- let limit = if plan == zed_llm_client::Plan::ZedProTrial && has_extended_trial {
- 1_000
- } else {
- limit
- };
-
- Some(limit)
- }
- zed_llm_client::UsageLimit::Unlimited => None,
- };
-
- let edit_predictions_limit = match plan.edit_predictions_limit() {
- zed_llm_client::UsageLimit::Limited(limit) => Some(limit),
- zed_llm_client::UsageLimit::Unlimited => None,
- };
-
- let Some(usage) = usage else {
- return Ok(Json(GetCurrentUsageResponse {
- plan: plan.as_str().to_string(),
- current_usage: Some(CurrentUsage {
- model_requests: UsageCounts {
- used: 0,
- limit: model_requests_limit,
- remaining: model_requests_limit,
- },
- model_request_usage: Vec::new(),
- edit_predictions: UsageCounts {
- used: 0,
- limit: edit_predictions_limit,
- remaining: edit_predictions_limit,
- },
- }),
- }));
- };
-
- let subscription_usage_meters = llm_db
- .get_current_subscription_usage_meters_for_user(user.id, Utc::now())
- .await?;
-
- let model_request_usage = subscription_usage_meters
- .into_iter()
- .filter_map(|(usage_meter, _usage)| {
- let model = llm_db.model_by_id(usage_meter.model_id).ok()?;
-
- Some(ModelRequestUsage {
- model: model.name.clone(),
- mode: usage_meter.mode,
- requests: usage_meter.requests,
- })
- })
- .collect::<Vec<_>>();
-
- Ok(Json(GetCurrentUsageResponse {
- plan: plan.as_str().to_string(),
- current_usage: Some(CurrentUsage {
- model_requests: UsageCounts {
- used: usage.model_requests,
- limit: model_requests_limit,
- remaining: model_requests_limit.map(|limit| (limit - usage.model_requests).max(0)),
- },
- model_request_usage,
- edit_predictions: UsageCounts {
- used: usage.edit_predictions,
- limit: edit_predictions_limit,
- remaining: edit_predictions_limit
- .map(|limit| (limit - usage.edit_predictions).max(0)),
- },
- }),
- }))
-}
-
-impl From<SubscriptionStatus> for StripeSubscriptionStatus {
- fn from(value: SubscriptionStatus) -> Self {
- match value {
- SubscriptionStatus::Incomplete => Self::Incomplete,
- SubscriptionStatus::IncompleteExpired => Self::IncompleteExpired,
- SubscriptionStatus::Trialing => Self::Trialing,
- SubscriptionStatus::Active => Self::Active,
- SubscriptionStatus::PastDue => Self::PastDue,
- SubscriptionStatus::Canceled => Self::Canceled,
- SubscriptionStatus::Unpaid => Self::Unpaid,
- SubscriptionStatus::Paused => Self::Paused,
- }
- }
-}
-
-impl From<CancellationDetailsReason> for StripeCancellationReason {
- fn from(value: CancellationDetailsReason) -> Self {
- match value {
- CancellationDetailsReason::CancellationRequested => Self::CancellationRequested,
- CancellationDetailsReason::PaymentDisputed => Self::PaymentDisputed,
- CancellationDetailsReason::PaymentFailed => Self::PaymentFailed,
- }
- }
-}
-
-/// Finds or creates a billing customer using the provided customer.
-pub async fn find_or_create_billing_customer(
- app: &Arc<AppState>,
- stripe_client: &dyn StripeClient,
- customer_id: &StripeCustomerId,
-) -> anyhow::Result<Option<billing_customer::Model>> {
- // If we already have a billing customer record associated with the Stripe customer,
- // there's nothing more we need to do.
- if let Some(billing_customer) = app
- .db
- .get_billing_customer_by_stripe_customer_id(customer_id.0.as_ref())
- .await?
- {
- return Ok(Some(billing_customer));
- }
-
- let customer = stripe_client.get_customer(customer_id).await?;
-
- let Some(email) = customer.email else {
- return Ok(None);
- };
-
- let Some(user) = app.db.get_user_by_email(&email).await? else {
- return Ok(None);
- };
-
- let billing_customer = app
- .db
- .create_billing_customer(&CreateBillingCustomerParams {
- user_id: user.id,
- stripe_customer_id: customer.id.to_string(),
- })
- .await?;
-
- Ok(Some(billing_customer))
-}
-
-const SYNC_LLM_REQUEST_USAGE_WITH_STRIPE_INTERVAL: Duration = Duration::from_secs(60);
-
-pub fn sync_llm_request_usage_with_stripe_periodically(app: Arc<AppState>) {
- let Some(stripe_billing) = app.stripe_billing.clone() else {
- log::warn!("failed to retrieve Stripe billing object");
- return;
- };
- let Some(llm_db) = app.llm_db.clone() else {
- log::warn!("failed to retrieve LLM database");
- return;
- };
-
- let executor = app.executor.clone();
- executor.spawn_detached({
- let executor = executor.clone();
- async move {
- loop {
- sync_model_request_usage_with_stripe(&app, &llm_db, &stripe_billing)
- .await
- .context("failed to sync LLM request usage to Stripe")
- .trace_err();
- executor
- .sleep(SYNC_LLM_REQUEST_USAGE_WITH_STRIPE_INTERVAL)
- .await;
- }
- }
- });
-}
-
-async fn sync_model_request_usage_with_stripe(
- app: &Arc<AppState>,
- llm_db: &Arc<LlmDatabase>,
- stripe_billing: &Arc<StripeBilling>,
-) -> anyhow::Result<()> {
- log::info!("Stripe usage sync: Starting");
- let started_at = Utc::now();
-
- let staff_users = app.db.get_staff_users().await?;
- let staff_user_ids = staff_users
- .iter()
- .map(|user| user.id)
- .collect::<HashSet<UserId>>();
-
- let usage_meters = llm_db
- .get_current_subscription_usage_meters(Utc::now())
- .await?;
- let mut usage_meters_by_user_id =
- HashMap::<UserId, Vec<subscription_usage_meter::Model>>::default();
- for (usage_meter, usage) in usage_meters {
- let meters = usage_meters_by_user_id.entry(usage.user_id).or_default();
- meters.push(usage_meter);
- }
-
- log::info!("Stripe usage sync: Retrieving Zed Pro subscriptions");
- let get_zed_pro_subscriptions_started_at = Utc::now();
- let billing_subscriptions = app.db.get_active_zed_pro_billing_subscriptions().await?;
- log::info!(
- "Stripe usage sync: Retrieved {} Zed Pro subscriptions in {}",
- billing_subscriptions.len(),
- Utc::now() - get_zed_pro_subscriptions_started_at
- );
-
- let claude_sonnet_4 = stripe_billing
- .find_price_by_lookup_key("claude-sonnet-4-requests")
- .await?;
- let claude_sonnet_4_max = stripe_billing
- .find_price_by_lookup_key("claude-sonnet-4-requests-max")
- .await?;
- let claude_opus_4 = stripe_billing
- .find_price_by_lookup_key("claude-opus-4-requests")
- .await?;
- let claude_opus_4_max = stripe_billing
- .find_price_by_lookup_key("claude-opus-4-requests-max")
- .await?;
- let claude_3_5_sonnet = stripe_billing
- .find_price_by_lookup_key("claude-3-5-sonnet-requests")
- .await?;
- let claude_3_7_sonnet = stripe_billing
- .find_price_by_lookup_key("claude-3-7-sonnet-requests")
- .await?;
- let claude_3_7_sonnet_max = stripe_billing
- .find_price_by_lookup_key("claude-3-7-sonnet-requests-max")
- .await?;
-
- let model_mode_combinations = [
- ("claude-opus-4", CompletionMode::Max),
- ("claude-opus-4", CompletionMode::Normal),
- ("claude-sonnet-4", CompletionMode::Max),
- ("claude-sonnet-4", CompletionMode::Normal),
- ("claude-3-7-sonnet", CompletionMode::Max),
- ("claude-3-7-sonnet", CompletionMode::Normal),
- ("claude-3-5-sonnet", CompletionMode::Normal),
- ];
-
- let billing_subscription_count = billing_subscriptions.len();
-
- log::info!("Stripe usage sync: Syncing {billing_subscription_count} Zed Pro subscriptions");
-
- for (user_id, (billing_customer, billing_subscription)) in billing_subscriptions {
- maybe!(async {
- if staff_user_ids.contains(&user_id) {
- return anyhow::Ok(());
- }
-
- let stripe_customer_id =
- StripeCustomerId(billing_customer.stripe_customer_id.clone().into());
- let stripe_subscription_id =
- StripeSubscriptionId(billing_subscription.stripe_subscription_id.clone().into());
-
- let usage_meters = usage_meters_by_user_id.get(&user_id);
-
- for (model, mode) in &model_mode_combinations {
- let Ok(model) =
- llm_db.model(LanguageModelProvider::Anthropic, model)
- else {
- log::warn!("Failed to load model for user {user_id}: {model}");
- continue;
- };
-
- let (price, meter_event_name) = match model.name.as_str() {
- "claude-opus-4" => match mode {
- CompletionMode::Normal => (&claude_opus_4, "claude_opus_4/requests"),
- CompletionMode::Max => (&claude_opus_4_max, "claude_opus_4/requests/max"),
- },
- "claude-sonnet-4" => match mode {
- CompletionMode::Normal => (&claude_sonnet_4, "claude_sonnet_4/requests"),
- CompletionMode::Max => {
- (&claude_sonnet_4_max, "claude_sonnet_4/requests/max")
- }
- },
- "claude-3-5-sonnet" => (&claude_3_5_sonnet, "claude_3_5_sonnet/requests"),
- "claude-3-7-sonnet" => match mode {
- CompletionMode::Normal => {
- (&claude_3_7_sonnet, "claude_3_7_sonnet/requests")
- }
- CompletionMode::Max => {
- (&claude_3_7_sonnet_max, "claude_3_7_sonnet/requests/max")
- }
- },
- model_name => {
- bail!("Attempted to sync usage meter for unsupported model: {model_name:?}")
- }
- };
-
- let model_requests = usage_meters
- .and_then(|usage_meters| {
- usage_meters
- .iter()
- .find(|meter| meter.model_id == model.id && meter.mode == *mode)
- })
- .map(|usage_meter| usage_meter.requests)
- .unwrap_or(0);
-
- if model_requests > 0 {
- stripe_billing
- .subscribe_to_price(&stripe_subscription_id, price)
- .await?;
- }
-
- stripe_billing
- .bill_model_request_usage(&stripe_customer_id, meter_event_name, model_requests)
- .await
- .with_context(|| {
- format!(
- "Failed to bill model request usage of {model_requests} for {stripe_customer_id}: {meter_event_name}",
- )
- })?;
- }
-
- Ok(())
- })
- .await
- .log_err();
- }
-
- log::info!(
- "Stripe usage sync: Synced {billing_subscription_count} Zed Pro subscriptions in {}",
- Utc::now() - started_at
- );
-
- Ok(())
-}
@@ -8,7 +8,6 @@ use axum::{
use chrono::{NaiveDateTime, SecondsFormat};
use serde::{Deserialize, Serialize};
-use crate::api::AuthenticatedUserParams;
use crate::db::ContributorSelector;
use crate::{AppState, Result};
@@ -104,9 +103,18 @@ impl RenovateBot {
}
}
+#[derive(Debug, Deserialize)]
+struct AddContributorBody {
+ github_user_id: i32,
+ github_login: String,
+ github_email: Option<String>,
+ github_name: Option<String>,
+ github_user_created_at: chrono::DateTime<chrono::Utc>,
+}
+
async fn add_contributor(
Extension(app): Extension<Arc<AppState>>,
- extract::Json(params): extract::Json<AuthenticatedUserParams>,
+ extract::Json(params): extract::Json<AddContributorBody>,
) -> Result<()> {
let initial_channel_id = app.config.auto_join_channel_id;
app.db
@@ -149,35 +149,35 @@ pub async fn post_crash(
"crash report"
);
- if let Some(kinesis_client) = app.kinesis_client.clone() {
- if let Some(stream) = app.config.kinesis_stream.clone() {
- let properties = json!({
- "app_version": report.header.app_version,
- "os_version": report.header.os_version,
- "os_name": "macOS",
- "bundle_id": report.header.bundle_id,
- "incident_id": report.header.incident_id,
- "installation_id": installation_id,
- "description": description,
- "backtrace": summary,
- });
- let row = SnowflakeRow::new(
- "Crash Reported",
- None,
- false,
- Some(installation_id),
- properties,
- );
- let data = serde_json::to_vec(&row)?;
- kinesis_client
- .put_record()
- .stream_name(stream)
- .partition_key(row.insert_id.unwrap_or_default())
- .data(data.into())
- .send()
- .await
- .log_err();
- }
+ if let Some(kinesis_client) = app.kinesis_client.clone()
+ && let Some(stream) = app.config.kinesis_stream.clone()
+ {
+ let properties = json!({
+ "app_version": report.header.app_version,
+ "os_version": report.header.os_version,
+ "os_name": "macOS",
+ "bundle_id": report.header.bundle_id,
+ "incident_id": report.header.incident_id,
+ "installation_id": installation_id,
+ "description": description,
+ "backtrace": summary,
+ });
+ let row = SnowflakeRow::new(
+ "Crash Reported",
+ None,
+ false,
+ Some(installation_id),
+ properties,
+ );
+ let data = serde_json::to_vec(&row)?;
+ kinesis_client
+ .put_record()
+ .stream_name(stream)
+ .partition_key(row.insert_id.unwrap_or_default())
+ .data(data.into())
+ .send()
+ .await
+ .log_err();
}
if let Some(slack_panics_webhook) = app.config.slack_panics_webhook.clone() {
@@ -280,7 +280,7 @@ pub async fn post_hang(
service = "client",
version = %report.app_version.unwrap_or_default().to_string(),
os_name = %report.os_name,
- os_version = report.os_version.unwrap_or_default().to_string(),
+ os_version = report.os_version.unwrap_or_default(),
incident_id = %incident_id,
installation_id = %report.installation_id.unwrap_or_default(),
backtrace = %backtrace,
@@ -359,83 +359,88 @@ pub async fn post_panic(
"panic report"
);
- if let Some(kinesis_client) = app.kinesis_client.clone() {
- if let Some(stream) = app.config.kinesis_stream.clone() {
- let properties = json!({
- "app_version": panic.app_version,
- "os_name": panic.os_name,
- "os_version": panic.os_version,
- "incident_id": incident_id,
- "installation_id": panic.installation_id,
- "description": panic.payload,
- "backtrace": backtrace,
- });
- let row = SnowflakeRow::new(
- "Panic Reported",
- None,
- false,
- panic.installation_id.clone(),
- properties,
- );
- let data = serde_json::to_vec(&row)?;
- kinesis_client
- .put_record()
- .stream_name(stream)
- .partition_key(row.insert_id.unwrap_or_default())
- .data(data.into())
- .send()
- .await
- .log_err();
- }
+ if let Some(kinesis_client) = app.kinesis_client.clone()
+ && let Some(stream) = app.config.kinesis_stream.clone()
+ {
+ let properties = json!({
+ "app_version": panic.app_version,
+ "os_name": panic.os_name,
+ "os_version": panic.os_version,
+ "incident_id": incident_id,
+ "installation_id": panic.installation_id,
+ "description": panic.payload,
+ "backtrace": backtrace,
+ });
+ let row = SnowflakeRow::new(
+ "Panic Reported",
+ None,
+ false,
+ panic.installation_id.clone(),
+ properties,
+ );
+ let data = serde_json::to_vec(&row)?;
+ kinesis_client
+ .put_record()
+ .stream_name(stream)
+ .partition_key(row.insert_id.unwrap_or_default())
+ .data(data.into())
+ .send()
+ .await
+ .log_err();
}
- let backtrace = if panic.backtrace.len() > 25 {
- let total = panic.backtrace.len();
- format!(
- "{}\n and {} more",
- panic
- .backtrace
- .iter()
- .take(20)
- .cloned()
- .collect::<Vec<_>>()
- .join("\n"),
- total - 20
- )
- } else {
- panic.backtrace.join("\n")
- };
-
if !report_to_slack(&panic) {
return Ok(());
}
- let backtrace_with_summary = panic.payload + "\n" + &backtrace;
-
if let Some(slack_panics_webhook) = app.config.slack_panics_webhook.clone() {
+ let backtrace = if panic.backtrace.len() > 25 {
+ let total = panic.backtrace.len();
+ format!(
+ "{}\n and {} more",
+ panic
+ .backtrace
+ .iter()
+ .take(20)
+ .cloned()
+ .collect::<Vec<_>>()
+ .join("\n"),
+ total - 20
+ )
+ } else {
+ panic.backtrace.join("\n")
+ };
+ let backtrace_with_summary = panic.payload + "\n" + &backtrace;
+
+ let version = if panic.release_channel == "nightly"
+ && !panic.app_version.contains("remote-server")
+ && let Some(sha) = panic.app_commit_sha
+ {
+ format!("Zed Nightly {}", sha.chars().take(7).collect::<String>())
+ } else {
+ panic.app_version
+ };
+
let payload = slack::WebhookBody::new(|w| {
w.add_section(|s| s.text(slack::Text::markdown("Panic request".to_string())))
.add_section(|s| {
- s.add_field(slack::Text::markdown(format!(
- "*Version:*\n {} ",
- panic.app_version
- )))
- .add_field({
- let hostname = app.config.blob_store_url.clone().unwrap_or_default();
- let hostname = hostname.strip_prefix("https://").unwrap_or_else(|| {
- hostname.strip_prefix("http://").unwrap_or_default()
- });
-
- slack::Text::markdown(format!(
- "*{} {}:*\n<https://{}.{}/{}.json|{}…>",
- panic.os_name,
- panic.os_version.unwrap_or_default(),
- CRASH_REPORTS_BUCKET,
- hostname,
- incident_id,
- incident_id.chars().take(8).collect::<String>(),
- ))
- })
+ s.add_field(slack::Text::markdown(format!("*Version:*\n {version} ",)))
+ .add_field({
+ let hostname = app.config.blob_store_url.clone().unwrap_or_default();
+ let hostname = hostname.strip_prefix("https://").unwrap_or_else(|| {
+ hostname.strip_prefix("http://").unwrap_or_default()
+ });
+
+ slack::Text::markdown(format!(
+ "*{} {}:*\n<https://{}.{}/{}.json|{}…>",
+ panic.os_name,
+ panic.os_version.unwrap_or_default(),
+ CRASH_REPORTS_BUCKET,
+ hostname,
+ incident_id,
+ incident_id.chars().take(8).collect::<String>(),
+ ))
+ })
})
.add_rich_text(|r| r.add_preformatted(|p| p.add_text(backtrace_with_summary)))
});
@@ -513,31 +518,31 @@ pub async fn post_events(
let first_event_at = chrono::Utc::now()
- chrono::Duration::milliseconds(last_event.milliseconds_since_first_event);
- if let Some(kinesis_client) = app.kinesis_client.clone() {
- if let Some(stream) = app.config.kinesis_stream.clone() {
- let mut request = kinesis_client.put_records().stream_name(stream);
- let mut has_records = false;
- for row in for_snowflake(
- request_body.clone(),
- first_event_at,
- country_code.clone(),
- checksum_matched,
- ) {
- if let Some(data) = serde_json::to_vec(&row).log_err() {
- request = request.records(
- aws_sdk_kinesis::types::PutRecordsRequestEntry::builder()
- .partition_key(request_body.system_id.clone().unwrap_or_default())
- .data(data.into())
- .build()
- .unwrap(),
- );
- has_records = true;
- }
- }
- if has_records {
- request.send().await.log_err();
+ if let Some(kinesis_client) = app.kinesis_client.clone()
+ && let Some(stream) = app.config.kinesis_stream.clone()
+ {
+ let mut request = kinesis_client.put_records().stream_name(stream);
+ let mut has_records = false;
+ for row in for_snowflake(
+ request_body.clone(),
+ first_event_at,
+ country_code.clone(),
+ checksum_matched,
+ ) {
+ if let Some(data) = serde_json::to_vec(&row).log_err() {
+ request = request.records(
+ aws_sdk_kinesis::types::PutRecordsRequestEntry::builder()
+ .partition_key(request_body.system_id.clone().unwrap_or_default())
+ .data(data.into())
+ .build()
+ .unwrap(),
+ );
+ has_records = true;
}
}
+ if has_records {
+ request.send().await.log_err();
+ }
};
Ok(())
@@ -559,170 +564,10 @@ fn for_snowflake(
country_code: Option<String>,
checksum_matched: bool,
) -> impl Iterator<Item = SnowflakeRow> {
- body.events.into_iter().filter_map(move |event| {
+ body.events.into_iter().map(move |event| {
let timestamp =
first_event_at + Duration::milliseconds(event.milliseconds_since_first_event);
- // We will need to double check, but I believe all of the events that
- // are being transformed here are now migrated over to use the
- // telemetry::event! macro, as of this commit so this code can go away
- // when we feel enough users have upgraded past this point.
let (event_type, mut event_properties) = match &event.event {
- Event::Editor(e) => (
- match e.operation.as_str() {
- "open" => "Editor Opened".to_string(),
- "save" => "Editor Saved".to_string(),
- _ => format!("Unknown Editor Event: {}", e.operation),
- },
- serde_json::to_value(e).unwrap(),
- ),
- Event::InlineCompletion(e) => (
- format!(
- "Edit Prediction {}",
- if e.suggestion_accepted {
- "Accepted"
- } else {
- "Discarded"
- }
- ),
- serde_json::to_value(e).unwrap(),
- ),
- Event::InlineCompletionRating(e) => (
- "Edit Prediction Rated".to_string(),
- serde_json::to_value(e).unwrap(),
- ),
- Event::Call(e) => {
- let event_type = match e.operation.trim() {
- "unshare project" => "Project Unshared".to_string(),
- "open channel notes" => "Channel Notes Opened".to_string(),
- "share project" => "Project Shared".to_string(),
- "join channel" => "Channel Joined".to_string(),
- "hang up" => "Call Ended".to_string(),
- "accept incoming" => "Incoming Call Accepted".to_string(),
- "invite" => "Participant Invited".to_string(),
- "disable microphone" => "Microphone Disabled".to_string(),
- "enable microphone" => "Microphone Enabled".to_string(),
- "enable screen share" => "Screen Share Enabled".to_string(),
- "disable screen share" => "Screen Share Disabled".to_string(),
- "decline incoming" => "Incoming Call Declined".to_string(),
- _ => format!("Unknown Call Event: {}", e.operation),
- };
-
- (event_type, serde_json::to_value(e).unwrap())
- }
- Event::Assistant(e) => (
- match e.phase {
- telemetry_events::AssistantPhase::Response => "Assistant Responded".to_string(),
- telemetry_events::AssistantPhase::Invoked => "Assistant Invoked".to_string(),
- telemetry_events::AssistantPhase::Accepted => {
- "Assistant Response Accepted".to_string()
- }
- telemetry_events::AssistantPhase::Rejected => {
- "Assistant Response Rejected".to_string()
- }
- },
- serde_json::to_value(e).unwrap(),
- ),
- Event::Cpu(_) | Event::Memory(_) => return None,
- Event::App(e) => {
- let mut properties = json!({});
- let event_type = match e.operation.trim() {
- // App
- "open" => "App Opened".to_string(),
- "first open" => "App First Opened".to_string(),
- "first open for release channel" => {
- "App First Opened For Release Channel".to_string()
- }
- "close" => "App Closed".to_string(),
-
- // Project
- "open project" => "Project Opened".to_string(),
- "open node project" => {
- properties["project_type"] = json!("node");
- "Project Opened".to_string()
- }
- "open pnpm project" => {
- properties["project_type"] = json!("pnpm");
- "Project Opened".to_string()
- }
- "open yarn project" => {
- properties["project_type"] = json!("yarn");
- "Project Opened".to_string()
- }
-
- // SSH
- "create ssh server" => "SSH Server Created".to_string(),
- "create ssh project" => "SSH Project Created".to_string(),
- "open ssh project" => "SSH Project Opened".to_string(),
-
- // Welcome Page
- "welcome page: change keymap" => "Welcome Keymap Changed".to_string(),
- "welcome page: change theme" => "Welcome Theme Changed".to_string(),
- "welcome page: close" => "Welcome Page Closed".to_string(),
- "welcome page: edit settings" => "Welcome Settings Edited".to_string(),
- "welcome page: install cli" => "Welcome CLI Installed".to_string(),
- "welcome page: open" => "Welcome Page Opened".to_string(),
- "welcome page: open extensions" => "Welcome Extensions Page Opened".to_string(),
- "welcome page: sign in to copilot" => "Welcome Copilot Signed In".to_string(),
- "welcome page: toggle diagnostic telemetry" => {
- "Welcome Diagnostic Telemetry Toggled".to_string()
- }
- "welcome page: toggle metric telemetry" => {
- "Welcome Metric Telemetry Toggled".to_string()
- }
- "welcome page: toggle vim" => "Welcome Vim Mode Toggled".to_string(),
- "welcome page: view docs" => "Welcome Documentation Viewed".to_string(),
-
- // Extensions
- "extensions page: open" => "Extensions Page Opened".to_string(),
- "extensions: install extension" => "Extension Installed".to_string(),
- "extensions: uninstall extension" => "Extension Uninstalled".to_string(),
-
- // Misc
- "markdown preview: open" => "Markdown Preview Opened".to_string(),
- "project diagnostics: open" => "Project Diagnostics Opened".to_string(),
- "project search: open" => "Project Search Opened".to_string(),
- "repl sessions: open" => "REPL Session Started".to_string(),
-
- // Feature Upsell
- "feature upsell: toggle vim" => {
- properties["source"] = json!("Feature Upsell");
- "Vim Mode Toggled".to_string()
- }
- _ => e
- .operation
- .strip_prefix("feature upsell: viewed docs (")
- .and_then(|s| s.strip_suffix(')'))
- .map_or_else(
- || format!("Unknown App Event: {}", e.operation),
- |docs_url| {
- properties["url"] = json!(docs_url);
- properties["source"] = json!("Feature Upsell");
- "Documentation Viewed".to_string()
- },
- ),
- };
- (event_type, properties)
- }
- Event::Setting(e) => (
- "Settings Changed".to_string(),
- serde_json::to_value(e).unwrap(),
- ),
- Event::Extension(e) => (
- "Extension Loaded".to_string(),
- serde_json::to_value(e).unwrap(),
- ),
- Event::Edit(e) => (
- "Editor Edited".to_string(),
- serde_json::to_value(e).unwrap(),
- ),
- Event::Action(e) => (
- "Action Invoked".to_string(),
- serde_json::to_value(e).unwrap(),
- ),
- Event::Repl(e) => (
- "Kernel Status Changed".to_string(),
- serde_json::to_value(e).unwrap(),
- ),
Event::Flexible(e) => (
e.event_type.clone(),
serde_json::to_value(&e.event_properties).unwrap(),
@@ -754,7 +599,7 @@ fn for_snowflake(
})
});
- Some(SnowflakeRow {
+ SnowflakeRow {
time: timestamp,
user_id: body.metrics_id.clone(),
device_id: body.system_id.clone(),
@@ -762,7 +607,7 @@ fn for_snowflake(
event_properties,
user_properties,
insert_id: Some(Uuid::new_v4().to_string()),
- })
+ }
})
}
@@ -337,8 +337,7 @@ async fn fetch_extensions_from_blob_store(
if known_versions
.binary_search_by_key(&published_version, |known_version| known_version)
.is_err()
- {
- if let Some(extension) = fetch_extension_manifest(
+ && let Some(extension) = fetch_extension_manifest(
blob_store_client,
blob_store_bucket,
extension_id,
@@ -346,12 +345,11 @@ async fn fetch_extensions_from_blob_store(
)
.await
.log_err()
- {
- new_versions
- .entry(extension_id)
- .or_default()
- .push(extension);
- }
+ {
+ new_versions
+ .entry(extension_id)
+ .or_default()
+ .push(extension);
}
}
}
@@ -79,27 +79,27 @@ pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl Into
verify_access_token(access_token, user_id, &state.db).await
};
- if let Ok(validate_result) = validate_result {
- if validate_result.is_valid {
- let user = state
+ if let Ok(validate_result) = validate_result
+ && validate_result.is_valid
+ {
+ let user = state
+ .db
+ .get_user_by_id(user_id)
+ .await?
+ .with_context(|| format!("user {user_id} not found"))?;
+
+ if let Some(impersonator_id) = validate_result.impersonator_id {
+ let admin = state
.db
- .get_user_by_id(user_id)
+ .get_user_by_id(impersonator_id)
.await?
- .with_context(|| format!("user {user_id} not found"))?;
-
- if let Some(impersonator_id) = validate_result.impersonator_id {
- let admin = state
- .db
- .get_user_by_id(impersonator_id)
- .await?
- .with_context(|| format!("user {impersonator_id} not found"))?;
- req.extensions_mut()
- .insert(Principal::Impersonated { user, admin });
- } else {
- req.extensions_mut().insert(Principal::User(user));
- };
- return Ok::<_, Error>(next.run(req).await);
- }
+ .with_context(|| format!("user {impersonator_id} not found"))?;
+ req.extensions_mut()
+ .insert(Principal::Impersonated { user, admin });
+ } else {
+ req.extensions_mut().insert(Principal::User(user));
+ };
+ return Ok::<_, Error>(next.run(req).await);
}
Err(Error::http(
@@ -236,7 +236,7 @@ mod test {
#[gpui::test]
async fn test_verify_access_token(cx: &mut gpui::TestAppContext) {
- let test_db = crate::db::TestDb::sqlite(cx.executor().clone());
+ let test_db = crate::db::TestDb::sqlite(cx.executor());
let db = test_db.db();
let user = db
@@ -1,83 +0,0 @@
-use serde::Serialize;
-
-/// A number of cents.
-#[derive(
- Debug,
- PartialEq,
- Eq,
- PartialOrd,
- Ord,
- Hash,
- Clone,
- Copy,
- derive_more::Add,
- derive_more::AddAssign,
- derive_more::Sub,
- derive_more::SubAssign,
- Serialize,
-)]
-pub struct Cents(pub u32);
-
-impl Cents {
- pub const ZERO: Self = Self(0);
-
- pub const fn new(cents: u32) -> Self {
- Self(cents)
- }
-
- pub const fn from_dollars(dollars: u32) -> Self {
- Self(dollars * 100)
- }
-
- pub fn saturating_sub(self, other: Cents) -> Self {
- Self(self.0.saturating_sub(other.0))
- }
-}
-
-#[cfg(test)]
-mod tests {
- use pretty_assertions::assert_eq;
-
- use super::*;
-
- #[test]
- fn test_cents_new() {
- assert_eq!(Cents::new(50), Cents(50));
- }
-
- #[test]
- fn test_cents_from_dollars() {
- assert_eq!(Cents::from_dollars(1), Cents(100));
- assert_eq!(Cents::from_dollars(5), Cents(500));
- }
-
- #[test]
- fn test_cents_zero() {
- assert_eq!(Cents::ZERO, Cents(0));
- }
-
- #[test]
- fn test_cents_add() {
- assert_eq!(Cents(50) + Cents(30), Cents(80));
- }
-
- #[test]
- fn test_cents_add_assign() {
- let mut cents = Cents(50);
- cents += Cents(30);
- assert_eq!(cents, Cents(80));
- }
-
- #[test]
- fn test_cents_saturating_sub() {
- assert_eq!(Cents(50).saturating_sub(Cents(30)), Cents(20));
- assert_eq!(Cents(30).saturating_sub(Cents(50)), Cents(0));
- }
-
- #[test]
- fn test_cents_ordering() {
- assert!(Cents(50) > Cents(30));
- assert!(Cents(30) < Cents(50));
- assert_eq!(Cents(50), Cents(50));
- }
-}
@@ -41,15 +41,7 @@ use worktree_settings_file::LocalSettingsKind;
pub use tests::TestDb;
pub use ids::*;
-pub use queries::billing_customers::{CreateBillingCustomerParams, UpdateBillingCustomerParams};
-pub use queries::billing_preferences::{
- CreateBillingPreferencesParams, UpdateBillingPreferencesParams,
-};
-pub use queries::billing_subscriptions::{
- CreateBillingSubscriptionParams, UpdateBillingSubscriptionParams,
-};
pub use queries::contributors::ContributorSelector;
-pub use queries::processed_stripe_events::CreateProcessedStripeEventParams;
pub use sea_orm::ConnectOptions;
pub use tables::user::Model as User;
pub use tables::*;
@@ -532,11 +524,17 @@ pub struct RejoinedProject {
pub worktrees: Vec<RejoinedWorktree>,
pub updated_repositories: Vec<proto::UpdateRepository>,
pub removed_repositories: Vec<u64>,
- pub language_servers: Vec<proto::LanguageServer>,
+ pub language_servers: Vec<LanguageServer>,
}
impl RejoinedProject {
pub fn to_proto(&self) -> proto::RejoinedProject {
+ let (language_servers, language_server_capabilities) = self
+ .language_servers
+ .clone()
+ .into_iter()
+ .map(|server| (server.server, server.capabilities))
+ .unzip();
proto::RejoinedProject {
id: self.id.to_proto(),
worktrees: self
@@ -554,7 +552,8 @@ impl RejoinedProject {
.iter()
.map(|collaborator| collaborator.to_proto())
.collect(),
- language_servers: self.language_servers.clone(),
+ language_servers,
+ language_server_capabilities,
}
}
}
@@ -601,7 +600,7 @@ pub struct Project {
pub collaborators: Vec<ProjectCollaborator>,
pub worktrees: BTreeMap<u64, Worktree>,
pub repositories: Vec<proto::UpdateRepository>,
- pub language_servers: Vec<proto::LanguageServer>,
+ pub language_servers: Vec<LanguageServer>,
}
pub struct ProjectCollaborator {
@@ -626,6 +625,12 @@ impl ProjectCollaborator {
}
}
+#[derive(Debug, Clone)]
+pub struct LanguageServer {
+ pub server: proto::LanguageServer,
+ pub capabilities: String,
+}
+
#[derive(Debug)]
pub struct LeftProject {
pub id: ProjectId,
@@ -680,7 +685,7 @@ impl LocalSettingsKind {
}
}
- pub fn to_proto(&self) -> proto::LocalSettingsKind {
+ pub fn to_proto(self) -> proto::LocalSettingsKind {
match self {
Self::Settings => proto::LocalSettingsKind::Settings,
Self::Tasks => proto::LocalSettingsKind::Tasks,
@@ -70,9 +70,6 @@ macro_rules! id_type {
}
id_type!(AccessTokenId);
-id_type!(BillingCustomerId);
-id_type!(BillingSubscriptionId);
-id_type!(BillingPreferencesId);
id_type!(BufferId);
id_type!(ChannelBufferCollaboratorId);
id_type!(ChannelChatParticipantId);
@@ -1,9 +1,6 @@
use super::*;
pub mod access_tokens;
-pub mod billing_customers;
-pub mod billing_preferences;
-pub mod billing_subscriptions;
pub mod buffers;
pub mod channels;
pub mod contacts;
@@ -12,7 +9,6 @@ pub mod embeddings;
pub mod extensions;
pub mod messages;
pub mod notifications;
-pub mod processed_stripe_events;
pub mod projects;
pub mod rooms;
pub mod servers;
@@ -1,100 +0,0 @@
-use super::*;
-
-#[derive(Debug)]
-pub struct CreateBillingCustomerParams {
- pub user_id: UserId,
- pub stripe_customer_id: String,
-}
-
-#[derive(Debug, Default)]
-pub struct UpdateBillingCustomerParams {
- pub user_id: ActiveValue<UserId>,
- pub stripe_customer_id: ActiveValue<String>,
- pub has_overdue_invoices: ActiveValue<bool>,
- pub trial_started_at: ActiveValue<Option<DateTime>>,
-}
-
-impl Database {
- /// Creates a new billing customer.
- pub async fn create_billing_customer(
- &self,
- params: &CreateBillingCustomerParams,
- ) -> Result<billing_customer::Model> {
- self.transaction(|tx| async move {
- let customer = billing_customer::Entity::insert(billing_customer::ActiveModel {
- user_id: ActiveValue::set(params.user_id),
- stripe_customer_id: ActiveValue::set(params.stripe_customer_id.clone()),
- ..Default::default()
- })
- .exec_with_returning(&*tx)
- .await?;
-
- Ok(customer)
- })
- .await
- }
-
- /// Updates the specified billing customer.
- pub async fn update_billing_customer(
- &self,
- id: BillingCustomerId,
- params: &UpdateBillingCustomerParams,
- ) -> Result<()> {
- self.transaction(|tx| async move {
- billing_customer::Entity::update(billing_customer::ActiveModel {
- id: ActiveValue::set(id),
- user_id: params.user_id.clone(),
- stripe_customer_id: params.stripe_customer_id.clone(),
- has_overdue_invoices: params.has_overdue_invoices.clone(),
- trial_started_at: params.trial_started_at.clone(),
- created_at: ActiveValue::not_set(),
- })
- .exec(&*tx)
- .await?;
-
- Ok(())
- })
- .await
- }
-
- pub async fn get_billing_customer_by_id(
- &self,
- id: BillingCustomerId,
- ) -> Result<Option<billing_customer::Model>> {
- self.transaction(|tx| async move {
- Ok(billing_customer::Entity::find()
- .filter(billing_customer::Column::Id.eq(id))
- .one(&*tx)
- .await?)
- })
- .await
- }
-
- /// Returns the billing customer for the user with the specified ID.
- pub async fn get_billing_customer_by_user_id(
- &self,
- user_id: UserId,
- ) -> Result<Option<billing_customer::Model>> {
- self.transaction(|tx| async move {
- Ok(billing_customer::Entity::find()
- .filter(billing_customer::Column::UserId.eq(user_id))
- .one(&*tx)
- .await?)
- })
- .await
- }
-
- /// Returns the billing customer for the user with the specified Stripe customer ID.
- pub async fn get_billing_customer_by_stripe_customer_id(
- &self,
- stripe_customer_id: &str,
- ) -> Result<Option<billing_customer::Model>> {
- self.transaction(|tx| async move {
- Ok(billing_customer::Entity::find()
- .filter(billing_customer::Column::StripeCustomerId.eq(stripe_customer_id))
- .one(&*tx)
- .await?)
- })
- .await
- }
-}
@@ -1,91 +0,0 @@
-use anyhow::Context as _;
-
-use super::*;
-
-#[derive(Debug)]
-pub struct CreateBillingPreferencesParams {
- pub max_monthly_llm_usage_spending_in_cents: i32,
- pub model_request_overages_enabled: bool,
- pub model_request_overages_spend_limit_in_cents: i32,
-}
-
-#[derive(Debug, Default)]
-pub struct UpdateBillingPreferencesParams {
- pub max_monthly_llm_usage_spending_in_cents: ActiveValue<i32>,
- pub model_request_overages_enabled: ActiveValue<bool>,
- pub model_request_overages_spend_limit_in_cents: ActiveValue<i32>,
-}
-
-impl Database {
- /// Returns the billing preferences for the given user, if they exist.
- pub async fn get_billing_preferences(
- &self,
- user_id: UserId,
- ) -> Result<Option<billing_preference::Model>> {
- self.transaction(|tx| async move {
- Ok(billing_preference::Entity::find()
- .filter(billing_preference::Column::UserId.eq(user_id))
- .one(&*tx)
- .await?)
- })
- .await
- }
-
- /// Creates new billing preferences for the given user.
- pub async fn create_billing_preferences(
- &self,
- user_id: UserId,
- params: &CreateBillingPreferencesParams,
- ) -> Result<billing_preference::Model> {
- self.transaction(|tx| async move {
- let preferences = billing_preference::Entity::insert(billing_preference::ActiveModel {
- user_id: ActiveValue::set(user_id),
- max_monthly_llm_usage_spending_in_cents: ActiveValue::set(
- params.max_monthly_llm_usage_spending_in_cents,
- ),
- model_request_overages_enabled: ActiveValue::set(
- params.model_request_overages_enabled,
- ),
- model_request_overages_spend_limit_in_cents: ActiveValue::set(
- params.model_request_overages_spend_limit_in_cents,
- ),
- ..Default::default()
- })
- .exec_with_returning(&*tx)
- .await?;
-
- Ok(preferences)
- })
- .await
- }
-
- /// Updates the billing preferences for the given user.
- pub async fn update_billing_preferences(
- &self,
- user_id: UserId,
- params: &UpdateBillingPreferencesParams,
- ) -> Result<billing_preference::Model> {
- self.transaction(|tx| async move {
- let preferences = billing_preference::Entity::update_many()
- .set(billing_preference::ActiveModel {
- max_monthly_llm_usage_spending_in_cents: params
- .max_monthly_llm_usage_spending_in_cents
- .clone(),
- model_request_overages_enabled: params.model_request_overages_enabled.clone(),
- model_request_overages_spend_limit_in_cents: params
- .model_request_overages_spend_limit_in_cents
- .clone(),
- ..Default::default()
- })
- .filter(billing_preference::Column::UserId.eq(user_id))
- .exec_with_returning(&*tx)
- .await?;
-
- Ok(preferences
- .into_iter()
- .next()
- .context("billing preferences not found")?)
- })
- .await
- }
-}
@@ -1,284 +0,0 @@
-use anyhow::Context as _;
-
-use crate::db::billing_subscription::{
- StripeCancellationReason, StripeSubscriptionStatus, SubscriptionKind,
-};
-
-use super::*;
-
-#[derive(Debug)]
-pub struct CreateBillingSubscriptionParams {
- pub billing_customer_id: BillingCustomerId,
- pub kind: Option<SubscriptionKind>,
- pub stripe_subscription_id: String,
- pub stripe_subscription_status: StripeSubscriptionStatus,
- pub stripe_cancellation_reason: Option<StripeCancellationReason>,
- pub stripe_current_period_start: Option<i64>,
- pub stripe_current_period_end: Option<i64>,
-}
-
-#[derive(Debug, Default)]
-pub struct UpdateBillingSubscriptionParams {
- pub billing_customer_id: ActiveValue<BillingCustomerId>,
- pub kind: ActiveValue<Option<SubscriptionKind>>,
- pub stripe_subscription_id: ActiveValue<String>,
- pub stripe_subscription_status: ActiveValue<StripeSubscriptionStatus>,
- pub stripe_cancel_at: ActiveValue<Option<DateTime>>,
- pub stripe_cancellation_reason: ActiveValue<Option<StripeCancellationReason>>,
- pub stripe_current_period_start: ActiveValue<Option<i64>>,
- pub stripe_current_period_end: ActiveValue<Option<i64>>,
-}
-
-impl Database {
- /// Creates a new billing subscription.
- pub async fn create_billing_subscription(
- &self,
- params: &CreateBillingSubscriptionParams,
- ) -> Result<billing_subscription::Model> {
- self.transaction(|tx| async move {
- let id = billing_subscription::Entity::insert(billing_subscription::ActiveModel {
- billing_customer_id: ActiveValue::set(params.billing_customer_id),
- kind: ActiveValue::set(params.kind),
- stripe_subscription_id: ActiveValue::set(params.stripe_subscription_id.clone()),
- stripe_subscription_status: ActiveValue::set(params.stripe_subscription_status),
- stripe_cancellation_reason: ActiveValue::set(params.stripe_cancellation_reason),
- stripe_current_period_start: ActiveValue::set(params.stripe_current_period_start),
- stripe_current_period_end: ActiveValue::set(params.stripe_current_period_end),
- ..Default::default()
- })
- .exec(&*tx)
- .await?
- .last_insert_id;
-
- Ok(billing_subscription::Entity::find_by_id(id)
- .one(&*tx)
- .await?
- .context("failed to retrieve inserted billing subscription")?)
- })
- .await
- }
-
- /// Updates the specified billing subscription.
- pub async fn update_billing_subscription(
- &self,
- id: BillingSubscriptionId,
- params: &UpdateBillingSubscriptionParams,
- ) -> Result<()> {
- self.transaction(|tx| async move {
- billing_subscription::Entity::update(billing_subscription::ActiveModel {
- id: ActiveValue::set(id),
- billing_customer_id: params.billing_customer_id.clone(),
- kind: params.kind.clone(),
- stripe_subscription_id: params.stripe_subscription_id.clone(),
- stripe_subscription_status: params.stripe_subscription_status.clone(),
- stripe_cancel_at: params.stripe_cancel_at.clone(),
- stripe_cancellation_reason: params.stripe_cancellation_reason.clone(),
- stripe_current_period_start: params.stripe_current_period_start.clone(),
- stripe_current_period_end: params.stripe_current_period_end.clone(),
- created_at: ActiveValue::not_set(),
- })
- .exec(&*tx)
- .await?;
-
- Ok(())
- })
- .await
- }
-
- /// Returns the billing subscription with the specified ID.
- pub async fn get_billing_subscription_by_id(
- &self,
- id: BillingSubscriptionId,
- ) -> Result<Option<billing_subscription::Model>> {
- self.transaction(|tx| async move {
- Ok(billing_subscription::Entity::find_by_id(id)
- .one(&*tx)
- .await?)
- })
- .await
- }
-
- /// Returns the billing subscription with the specified Stripe subscription ID.
- pub async fn get_billing_subscription_by_stripe_subscription_id(
- &self,
- stripe_subscription_id: &str,
- ) -> Result<Option<billing_subscription::Model>> {
- self.transaction(|tx| async move {
- Ok(billing_subscription::Entity::find()
- .filter(
- billing_subscription::Column::StripeSubscriptionId.eq(stripe_subscription_id),
- )
- .one(&*tx)
- .await?)
- })
- .await
- }
-
- pub async fn get_active_billing_subscription(
- &self,
- user_id: UserId,
- ) -> Result<Option<billing_subscription::Model>> {
- self.transaction(|tx| async move {
- Ok(billing_subscription::Entity::find()
- .inner_join(billing_customer::Entity)
- .filter(billing_customer::Column::UserId.eq(user_id))
- .filter(
- Condition::all()
- .add(
- Condition::any()
- .add(
- billing_subscription::Column::StripeSubscriptionStatus
- .eq(StripeSubscriptionStatus::Active),
- )
- .add(
- billing_subscription::Column::StripeSubscriptionStatus
- .eq(StripeSubscriptionStatus::Trialing),
- ),
- )
- .add(billing_subscription::Column::Kind.is_not_null()),
- )
- .one(&*tx)
- .await?)
- })
- .await
- }
-
- /// Returns all of the billing subscriptions for the user with the specified ID.
- ///
- /// Note that this returns the subscriptions regardless of their status.
- /// If you're wanting to check if a use has an active billing subscription,
- /// use `get_active_billing_subscriptions` instead.
- pub async fn get_billing_subscriptions(
- &self,
- user_id: UserId,
- ) -> Result<Vec<billing_subscription::Model>> {
- self.transaction(|tx| async move {
- let subscriptions = billing_subscription::Entity::find()
- .inner_join(billing_customer::Entity)
- .filter(billing_customer::Column::UserId.eq(user_id))
- .order_by_asc(billing_subscription::Column::Id)
- .all(&*tx)
- .await?;
-
- Ok(subscriptions)
- })
- .await
- }
-
- pub async fn get_active_billing_subscriptions(
- &self,
- user_ids: HashSet<UserId>,
- ) -> Result<HashMap<UserId, (billing_customer::Model, billing_subscription::Model)>> {
- self.transaction(|tx| {
- let user_ids = user_ids.clone();
- async move {
- let mut rows = billing_subscription::Entity::find()
- .inner_join(billing_customer::Entity)
- .select_also(billing_customer::Entity)
- .filter(billing_customer::Column::UserId.is_in(user_ids))
- .filter(
- billing_subscription::Column::StripeSubscriptionStatus
- .eq(StripeSubscriptionStatus::Active),
- )
- .filter(billing_subscription::Column::Kind.is_null())
- .order_by_asc(billing_subscription::Column::Id)
- .stream(&*tx)
- .await?;
-
- let mut subscriptions = HashMap::default();
- while let Some(row) = rows.next().await {
- if let (subscription, Some(customer)) = row? {
- subscriptions.insert(customer.user_id, (customer, subscription));
- }
- }
- Ok(subscriptions)
- }
- })
- .await
- }
-
- pub async fn get_active_zed_pro_billing_subscriptions(
- &self,
- ) -> Result<HashMap<UserId, (billing_customer::Model, billing_subscription::Model)>> {
- self.transaction(|tx| async move {
- let mut rows = billing_subscription::Entity::find()
- .inner_join(billing_customer::Entity)
- .select_also(billing_customer::Entity)
- .filter(
- billing_subscription::Column::StripeSubscriptionStatus
- .eq(StripeSubscriptionStatus::Active),
- )
- .filter(billing_subscription::Column::Kind.eq(SubscriptionKind::ZedPro))
- .order_by_asc(billing_subscription::Column::Id)
- .stream(&*tx)
- .await?;
-
- let mut subscriptions = HashMap::default();
- while let Some(row) = rows.next().await {
- if let (subscription, Some(customer)) = row? {
- subscriptions.insert(customer.user_id, (customer, subscription));
- }
- }
- Ok(subscriptions)
- })
- .await
- }
-
- pub async fn get_active_zed_pro_billing_subscriptions_for_users(
- &self,
- user_ids: HashSet<UserId>,
- ) -> Result<HashMap<UserId, (billing_customer::Model, billing_subscription::Model)>> {
- self.transaction(|tx| {
- let user_ids = user_ids.clone();
- async move {
- let mut rows = billing_subscription::Entity::find()
- .inner_join(billing_customer::Entity)
- .select_also(billing_customer::Entity)
- .filter(billing_customer::Column::UserId.is_in(user_ids))
- .filter(
- billing_subscription::Column::StripeSubscriptionStatus
- .eq(StripeSubscriptionStatus::Active),
- )
- .filter(billing_subscription::Column::Kind.eq(SubscriptionKind::ZedPro))
- .order_by_asc(billing_subscription::Column::Id)
- .stream(&*tx)
- .await?;
-
- let mut subscriptions = HashMap::default();
- while let Some(row) = rows.next().await {
- if let (subscription, Some(customer)) = row? {
- subscriptions.insert(customer.user_id, (customer, subscription));
- }
- }
- Ok(subscriptions)
- }
- })
- .await
- }
-
- /// Returns whether the user has an active billing subscription.
- pub async fn has_active_billing_subscription(&self, user_id: UserId) -> Result<bool> {
- Ok(self.count_active_billing_subscriptions(user_id).await? > 0)
- }
-
- /// Returns the count of the active billing subscriptions for the user with the specified ID.
- pub async fn count_active_billing_subscriptions(&self, user_id: UserId) -> Result<usize> {
- self.transaction(|tx| async move {
- let count = billing_subscription::Entity::find()
- .inner_join(billing_customer::Entity)
- .filter(
- billing_customer::Column::UserId.eq(user_id).and(
- billing_subscription::Column::StripeSubscriptionStatus
- .eq(StripeSubscriptionStatus::Active)
- .or(billing_subscription::Column::StripeSubscriptionStatus
- .eq(StripeSubscriptionStatus::Trialing)),
- ),
- )
- .count(&*tx)
- .await?;
-
- Ok(count as usize)
- })
- .await
- }
-}
@@ -786,6 +786,32 @@ impl Database {
})
.collect())
}
+
+ /// Update language server capabilities for a given id.
+ pub async fn update_server_capabilities(
+ &self,
+ project_id: ProjectId,
+ server_id: u64,
+ new_capabilities: String,
+ ) -> Result<()> {
+ self.transaction(|tx| {
+ let new_capabilities = new_capabilities.clone();
+ async move {
+ Ok(
+ language_server::Entity::update(language_server::ActiveModel {
+ project_id: ActiveValue::unchanged(project_id),
+ id: ActiveValue::unchanged(server_id as i64),
+ capabilities: ActiveValue::set(new_capabilities),
+ ..Default::default()
+ })
+ .exec(&*tx)
+ .await?,
+ )
+ }
+ })
+ .await?;
+ Ok(())
+ }
}
fn operation_to_storage(
@@ -87,10 +87,10 @@ impl Database {
continue;
};
- if let Some((_, max_extension_version)) = &max_versions.get(&version.extension_id) {
- if max_extension_version > &extension_version {
- continue;
- }
+ if let Some((_, max_extension_version)) = &max_versions.get(&version.extension_id)
+ && max_extension_version > &extension_version
+ {
+ continue;
}
if let Some(constraints) = constraints {
@@ -331,10 +331,10 @@ impl Database {
.exec_without_returning(&*tx)
.await?;
- if let Ok(db_version) = semver::Version::parse(&extension.latest_version) {
- if db_version >= latest_version.version {
- continue;
- }
+ if let Ok(db_version) = semver::Version::parse(&extension.latest_version)
+ && db_version >= latest_version.version
+ {
+ continue;
}
let mut extension = extension.into_active_model();
@@ -1,69 +0,0 @@
-use super::*;
-
-#[derive(Debug)]
-pub struct CreateProcessedStripeEventParams {
- pub stripe_event_id: String,
- pub stripe_event_type: String,
- pub stripe_event_created_timestamp: i64,
-}
-
-impl Database {
- /// Creates a new processed Stripe event.
- pub async fn create_processed_stripe_event(
- &self,
- params: &CreateProcessedStripeEventParams,
- ) -> Result<()> {
- self.transaction(|tx| async move {
- processed_stripe_event::Entity::insert(processed_stripe_event::ActiveModel {
- stripe_event_id: ActiveValue::set(params.stripe_event_id.clone()),
- stripe_event_type: ActiveValue::set(params.stripe_event_type.clone()),
- stripe_event_created_timestamp: ActiveValue::set(
- params.stripe_event_created_timestamp,
- ),
- ..Default::default()
- })
- .exec_without_returning(&*tx)
- .await?;
-
- Ok(())
- })
- .await
- }
-
- /// Returns the processed Stripe event with the specified event ID.
- pub async fn get_processed_stripe_event_by_event_id(
- &self,
- event_id: &str,
- ) -> Result<Option<processed_stripe_event::Model>> {
- self.transaction(|tx| async move {
- Ok(processed_stripe_event::Entity::find_by_id(event_id)
- .one(&*tx)
- .await?)
- })
- .await
- }
-
- /// Returns the processed Stripe events with the specified event IDs.
- pub async fn get_processed_stripe_events_by_event_ids(
- &self,
- event_ids: &[&str],
- ) -> Result<Vec<processed_stripe_event::Model>> {
- self.transaction(|tx| async move {
- Ok(processed_stripe_event::Entity::find()
- .filter(
- processed_stripe_event::Column::StripeEventId.is_in(event_ids.iter().copied()),
- )
- .all(&*tx)
- .await?)
- })
- .await
- }
-
- /// Returns whether the Stripe event with the specified ID has already been processed.
- pub async fn already_processed_stripe_event(&self, event_id: &str) -> Result<bool> {
- Ok(self
- .get_processed_stripe_event_by_event_id(event_id)
- .await?
- .is_some())
- }
-}
@@ -349,11 +349,11 @@ impl Database {
serde_json::to_string(&repository.current_merge_conflicts)
.unwrap(),
)),
-
- // Old clients do not use abs path, entry ids or head_commit_details.
+ // Old clients do not use abs path, entry ids, head_commit_details, or merge_message.
abs_path: ActiveValue::set(String::new()),
entry_ids: ActiveValue::set("[]".into()),
head_commit_details: ActiveValue::set(None),
+ merge_message: ActiveValue::set(None),
}
}),
)
@@ -502,6 +502,7 @@ impl Database {
current_merge_conflicts: ActiveValue::Set(Some(
serde_json::to_string(&update.current_merge_conflicts).unwrap(),
)),
+ merge_message: ActiveValue::set(update.merge_message.clone()),
})
.on_conflict(
OnConflict::columns([
@@ -515,6 +516,7 @@ impl Database {
project_repository::Column::AbsPath,
project_repository::Column::CurrentMergeConflicts,
project_repository::Column::HeadCommitDetails,
+ project_repository::Column::MergeMessage,
])
.to_owned(),
)
@@ -692,13 +694,17 @@ impl Database {
project_id: ActiveValue::set(project_id),
id: ActiveValue::set(server.id as i64),
name: ActiveValue::set(server.name.clone()),
+ capabilities: ActiveValue::set(update.capabilities.clone()),
})
.on_conflict(
OnConflict::columns([
language_server::Column::ProjectId,
language_server::Column::Id,
])
- .update_column(language_server::Column::Name)
+ .update_columns([
+ language_server::Column::Name,
+ language_server::Column::Capabilities,
+ ])
.to_owned(),
)
.exec(&*tx)
@@ -939,21 +945,21 @@ impl Database {
let current_merge_conflicts = db_repository_entry
.current_merge_conflicts
.as_ref()
- .map(|conflicts| serde_json::from_str(&conflicts))
+ .map(|conflicts| serde_json::from_str(conflicts))
.transpose()?
.unwrap_or_default();
let branch_summary = db_repository_entry
.branch_summary
.as_ref()
- .map(|branch_summary| serde_json::from_str(&branch_summary))
+ .map(|branch_summary| serde_json::from_str(branch_summary))
.transpose()?
.unwrap_or_default();
let head_commit_details = db_repository_entry
.head_commit_details
.as_ref()
- .map(|head_commit_details| serde_json::from_str(&head_commit_details))
+ .map(|head_commit_details| serde_json::from_str(head_commit_details))
.transpose()?
.unwrap_or_default();
@@ -986,6 +992,7 @@ impl Database {
head_commit_details,
scan_id: db_repository_entry.scan_id as u64,
is_last_update: true,
+ merge_message: db_repository_entry.merge_message,
});
}
}
@@ -1054,10 +1061,13 @@ impl Database {
repositories,
language_servers: language_servers
.into_iter()
- .map(|language_server| proto::LanguageServer {
- id: language_server.id as u64,
- name: language_server.name,
- worktree_id: None,
+ .map(|language_server| LanguageServer {
+ server: proto::LanguageServer {
+ id: language_server.id as u64,
+ name: language_server.name,
+ worktree_id: None,
+ },
+ capabilities: language_server.capabilities,
})
.collect(),
};
@@ -1311,10 +1321,10 @@ impl Database {
.await?;
let mut connection_ids = HashSet::default();
- if let Some(host_connection) = project.host_connection().log_err() {
- if !exclude_dev_server {
- connection_ids.insert(host_connection);
- }
+ if let Some(host_connection) = project.host_connection().log_err()
+ && !exclude_dev_server
+ {
+ connection_ids.insert(host_connection);
}
while let Some(collaborator) = collaborators.next().await {
@@ -746,21 +746,21 @@ impl Database {
let current_merge_conflicts = db_repository
.current_merge_conflicts
.as_ref()
- .map(|conflicts| serde_json::from_str(&conflicts))
+ .map(|conflicts| serde_json::from_str(conflicts))
.transpose()?
.unwrap_or_default();
let branch_summary = db_repository
.branch_summary
.as_ref()
- .map(|branch_summary| serde_json::from_str(&branch_summary))
+ .map(|branch_summary| serde_json::from_str(branch_summary))
.transpose()?
.unwrap_or_default();
let head_commit_details = db_repository
.head_commit_details
.as_ref()
- .map(|head_commit_details| serde_json::from_str(&head_commit_details))
+ .map(|head_commit_details| serde_json::from_str(head_commit_details))
.transpose()?
.unwrap_or_default();
@@ -793,6 +793,7 @@ impl Database {
abs_path: db_repository.abs_path,
scan_id: db_repository.scan_id as u64,
is_last_update: true,
+ merge_message: db_repository.merge_message,
});
}
}
@@ -804,10 +805,13 @@ impl Database {
.all(tx)
.await?
.into_iter()
- .map(|language_server| proto::LanguageServer {
- id: language_server.id as u64,
- name: language_server.name,
- worktree_id: None,
+ .map(|language_server| LanguageServer {
+ server: proto::LanguageServer {
+ id: language_server.id as u64,
+ name: language_server.name,
+ worktree_id: None,
+ },
+ capabilities: language_server.capabilities,
})
.collect::<Vec<_>>();
@@ -1,7 +1,4 @@
pub mod access_token;
-pub mod billing_customer;
-pub mod billing_preference;
-pub mod billing_subscription;
pub mod buffer;
pub mod buffer_operation;
pub mod buffer_snapshot;
@@ -23,7 +20,6 @@ pub mod notification;
pub mod notification_kind;
pub mod observed_buffer_edits;
pub mod observed_channel_messages;
-pub mod processed_stripe_event;
pub mod project;
pub mod project_collaborator;
pub mod project_repository;
@@ -1,41 +0,0 @@
-use crate::db::{BillingCustomerId, UserId};
-use sea_orm::entity::prelude::*;
-
-/// A billing customer.
-#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
-#[sea_orm(table_name = "billing_customers")]
-pub struct Model {
- #[sea_orm(primary_key)]
- pub id: BillingCustomerId,
- pub user_id: UserId,
- pub stripe_customer_id: String,
- pub has_overdue_invoices: bool,
- pub trial_started_at: Option<DateTime>,
- pub created_at: DateTime,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {
- #[sea_orm(
- belongs_to = "super::user::Entity",
- from = "Column::UserId",
- to = "super::user::Column::Id"
- )]
- User,
- #[sea_orm(has_many = "super::billing_subscription::Entity")]
- BillingSubscription,
-}
-
-impl Related<super::user::Entity> for Entity {
- fn to() -> RelationDef {
- Relation::User.def()
- }
-}
-
-impl Related<super::billing_subscription::Entity> for Entity {
- fn to() -> RelationDef {
- Relation::BillingSubscription.def()
- }
-}
-
-impl ActiveModelBehavior for ActiveModel {}
@@ -1,32 +0,0 @@
-use crate::db::{BillingPreferencesId, UserId};
-use sea_orm::entity::prelude::*;
-
-#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
-#[sea_orm(table_name = "billing_preferences")]
-pub struct Model {
- #[sea_orm(primary_key)]
- pub id: BillingPreferencesId,
- pub created_at: DateTime,
- pub user_id: UserId,
- pub max_monthly_llm_usage_spending_in_cents: i32,
- pub model_request_overages_enabled: bool,
- pub model_request_overages_spend_limit_in_cents: i32,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {
- #[sea_orm(
- belongs_to = "super::user::Entity",
- from = "Column::UserId",
- to = "super::user::Column::Id"
- )]
- User,
-}
-
-impl Related<super::user::Entity> for Entity {
- fn to() -> RelationDef {
- Relation::User.def()
- }
-}
-
-impl ActiveModelBehavior for ActiveModel {}
@@ -1,176 +0,0 @@
-use crate::db::{BillingCustomerId, BillingSubscriptionId};
-use crate::stripe_client;
-use chrono::{Datelike as _, NaiveDate, Utc};
-use sea_orm::entity::prelude::*;
-use serde::Serialize;
-
-/// A billing subscription.
-#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
-#[sea_orm(table_name = "billing_subscriptions")]
-pub struct Model {
- #[sea_orm(primary_key)]
- pub id: BillingSubscriptionId,
- pub billing_customer_id: BillingCustomerId,
- pub kind: Option<SubscriptionKind>,
- pub stripe_subscription_id: String,
- pub stripe_subscription_status: StripeSubscriptionStatus,
- pub stripe_cancel_at: Option<DateTime>,
- pub stripe_cancellation_reason: Option<StripeCancellationReason>,
- pub stripe_current_period_start: Option<i64>,
- pub stripe_current_period_end: Option<i64>,
- pub created_at: DateTime,
-}
-
-impl Model {
- pub fn current_period_start_at(&self) -> Option<DateTimeUtc> {
- let period_start = self.stripe_current_period_start?;
- chrono::DateTime::from_timestamp(period_start, 0)
- }
-
- pub fn current_period_end_at(&self) -> Option<DateTimeUtc> {
- let period_end = self.stripe_current_period_end?;
- chrono::DateTime::from_timestamp(period_end, 0)
- }
-
- pub fn current_period(
- subscription: Option<Self>,
- is_staff: bool,
- ) -> Option<(DateTimeUtc, DateTimeUtc)> {
- if is_staff {
- let now = Utc::now();
- let year = now.year();
- let month = now.month();
-
- let first_day_of_this_month =
- NaiveDate::from_ymd_opt(year, month, 1)?.and_hms_opt(0, 0, 0)?;
-
- let next_month = if month == 12 { 1 } else { month + 1 };
- let next_month_year = if month == 12 { year + 1 } else { year };
- let first_day_of_next_month =
- NaiveDate::from_ymd_opt(next_month_year, next_month, 1)?.and_hms_opt(23, 59, 59)?;
-
- let last_day_of_this_month = first_day_of_next_month - chrono::Days::new(1);
-
- Some((
- first_day_of_this_month.and_utc(),
- last_day_of_this_month.and_utc(),
- ))
- } else {
- let subscription = subscription?;
- let period_start_at = subscription.current_period_start_at()?;
- let period_end_at = subscription.current_period_end_at()?;
-
- Some((period_start_at, period_end_at))
- }
- }
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {
- #[sea_orm(
- belongs_to = "super::billing_customer::Entity",
- from = "Column::BillingCustomerId",
- to = "super::billing_customer::Column::Id"
- )]
- BillingCustomer,
-}
-
-impl Related<super::billing_customer::Entity> for Entity {
- fn to() -> RelationDef {
- Relation::BillingCustomer.def()
- }
-}
-
-impl ActiveModelBehavior for ActiveModel {}
-
-#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Hash, Serialize)]
-#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
-#[serde(rename_all = "snake_case")]
-pub enum SubscriptionKind {
- #[sea_orm(string_value = "zed_pro")]
- ZedPro,
- #[sea_orm(string_value = "zed_pro_trial")]
- ZedProTrial,
- #[sea_orm(string_value = "zed_free")]
- ZedFree,
-}
-
-impl From<SubscriptionKind> for zed_llm_client::Plan {
- fn from(value: SubscriptionKind) -> Self {
- match value {
- SubscriptionKind::ZedPro => Self::ZedPro,
- SubscriptionKind::ZedProTrial => Self::ZedProTrial,
- SubscriptionKind::ZedFree => Self::ZedFree,
- }
- }
-}
-
-/// The status of a Stripe subscription.
-///
-/// [Stripe docs](https://docs.stripe.com/api/subscriptions/object#subscription_object-status)
-#[derive(
- Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash, Serialize,
-)]
-#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
-#[serde(rename_all = "snake_case")]
-pub enum StripeSubscriptionStatus {
- #[default]
- #[sea_orm(string_value = "incomplete")]
- Incomplete,
- #[sea_orm(string_value = "incomplete_expired")]
- IncompleteExpired,
- #[sea_orm(string_value = "trialing")]
- Trialing,
- #[sea_orm(string_value = "active")]
- Active,
- #[sea_orm(string_value = "past_due")]
- PastDue,
- #[sea_orm(string_value = "canceled")]
- Canceled,
- #[sea_orm(string_value = "unpaid")]
- Unpaid,
- #[sea_orm(string_value = "paused")]
- Paused,
-}
-
-impl StripeSubscriptionStatus {
- pub fn is_cancelable(&self) -> bool {
- match self {
- Self::Trialing | Self::Active | Self::PastDue => true,
- Self::Incomplete
- | Self::IncompleteExpired
- | Self::Canceled
- | Self::Unpaid
- | Self::Paused => false,
- }
- }
-}
-
-/// The cancellation reason for a Stripe subscription.
-///
-/// [Stripe docs](https://docs.stripe.com/api/subscriptions/object#subscription_object-cancellation_details-reason)
-#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Hash, Serialize)]
-#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
-#[serde(rename_all = "snake_case")]
-pub enum StripeCancellationReason {
- #[sea_orm(string_value = "cancellation_requested")]
- CancellationRequested,
- #[sea_orm(string_value = "payment_disputed")]
- PaymentDisputed,
- #[sea_orm(string_value = "payment_failed")]
- PaymentFailed,
-}
-
-impl From<stripe_client::StripeCancellationDetailsReason> for StripeCancellationReason {
- fn from(value: stripe_client::StripeCancellationDetailsReason) -> Self {
- match value {
- stripe_client::StripeCancellationDetailsReason::CancellationRequested => {
- Self::CancellationRequested
- }
- stripe_client::StripeCancellationDetailsReason::PaymentDisputed => {
- Self::PaymentDisputed
- }
- stripe_client::StripeCancellationDetailsReason::PaymentFailed => Self::PaymentFailed,
- }
- }
-}
@@ -9,6 +9,7 @@ pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub name: String,
+ pub capabilities: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -1,16 +0,0 @@
-use sea_orm::entity::prelude::*;
-
-#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
-#[sea_orm(table_name = "processed_stripe_events")]
-pub struct Model {
- #[sea_orm(primary_key)]
- pub stripe_event_id: String,
- pub stripe_event_type: String,
- pub stripe_event_created_timestamp: i64,
- pub processed_at: DateTime,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {}
-
-impl ActiveModelBehavior for ActiveModel {}
@@ -16,6 +16,8 @@ pub struct Model {
pub is_deleted: bool,
// JSON array typed string
pub current_merge_conflicts: Option<String>,
+ // The suggested merge commit message
+ pub merge_message: Option<String>,
// A JSON object representing the current Branch values
pub branch_summary: Option<String>,
// A JSON object representing the current Head commit values
@@ -29,8 +29,6 @@ pub struct Model {
pub enum Relation {
#[sea_orm(has_many = "super::access_token::Entity")]
AccessToken,
- #[sea_orm(has_one = "super::billing_customer::Entity")]
- BillingCustomer,
#[sea_orm(has_one = "super::room_participant::Entity")]
RoomParticipant,
#[sea_orm(has_many = "super::project::Entity")]
@@ -68,12 +66,6 @@ impl Related<super::access_token::Entity> for Entity {
}
}
-impl Related<super::billing_customer::Entity> for Entity {
- fn to() -> RelationDef {
- Relation::BillingCustomer.def()
- }
-}
-
impl Related<super::room_participant::Entity> for Entity {
fn to() -> RelationDef {
Relation::RoomParticipant.def()
@@ -1,4 +1,3 @@
-mod billing_subscription_tests;
mod buffer_tests;
mod channel_tests;
mod contributor_tests;
@@ -9,7 +8,6 @@ mod embedding_tests;
mod extension_tests;
mod feature_flag_tests;
mod message_tests;
-mod processed_stripe_event_tests;
mod user_tests;
use crate::migrations::run_database_migrations;
@@ -1,96 +0,0 @@
-use std::sync::Arc;
-
-use crate::db::billing_subscription::StripeSubscriptionStatus;
-use crate::db::tests::new_test_user;
-use crate::db::{CreateBillingCustomerParams, CreateBillingSubscriptionParams};
-use crate::test_both_dbs;
-
-use super::Database;
-
-test_both_dbs!(
- test_get_active_billing_subscriptions,
- test_get_active_billing_subscriptions_postgres,
- test_get_active_billing_subscriptions_sqlite
-);
-
-async fn test_get_active_billing_subscriptions(db: &Arc<Database>) {
- // A user with no subscription has no active billing subscriptions.
- {
- let user_id = new_test_user(db, "no-subscription-user@example.com").await;
- let subscription_count = db
- .count_active_billing_subscriptions(user_id)
- .await
- .unwrap();
-
- assert_eq!(subscription_count, 0);
- }
-
- // A user with an active subscription has one active billing subscription.
- {
- let user_id = new_test_user(db, "active-user@example.com").await;
- let customer = db
- .create_billing_customer(&CreateBillingCustomerParams {
- user_id,
- stripe_customer_id: "cus_active_user".into(),
- })
- .await
- .unwrap();
- assert_eq!(customer.stripe_customer_id, "cus_active_user".to_string());
-
- db.create_billing_subscription(&CreateBillingSubscriptionParams {
- billing_customer_id: customer.id,
- kind: None,
- stripe_subscription_id: "sub_active_user".into(),
- stripe_subscription_status: StripeSubscriptionStatus::Active,
- stripe_cancellation_reason: None,
- stripe_current_period_start: None,
- stripe_current_period_end: None,
- })
- .await
- .unwrap();
-
- let subscriptions = db.get_billing_subscriptions(user_id).await.unwrap();
- assert_eq!(subscriptions.len(), 1);
-
- let subscription = &subscriptions[0];
- assert_eq!(
- subscription.stripe_subscription_id,
- "sub_active_user".to_string()
- );
- assert_eq!(
- subscription.stripe_subscription_status,
- StripeSubscriptionStatus::Active
- );
- }
-
- // A user with a past-due subscription has no active billing subscriptions.
- {
- let user_id = new_test_user(db, "past-due-user@example.com").await;
- let customer = db
- .create_billing_customer(&CreateBillingCustomerParams {
- user_id,
- stripe_customer_id: "cus_past_due_user".into(),
- })
- .await
- .unwrap();
- assert_eq!(customer.stripe_customer_id, "cus_past_due_user".to_string());
-
- db.create_billing_subscription(&CreateBillingSubscriptionParams {
- billing_customer_id: customer.id,
- kind: None,
- stripe_subscription_id: "sub_past_due_user".into(),
- stripe_subscription_status: StripeSubscriptionStatus::PastDue,
- stripe_cancellation_reason: None,
- stripe_current_period_start: None,
- stripe_current_period_end: None,
- })
- .await
- .unwrap();
-
- let subscription_count = db
- .count_active_billing_subscriptions(user_id)
- .await
- .unwrap();
- assert_eq!(subscription_count, 0);
- }
-}
@@ -8,7 +8,7 @@ use time::{Duration, OffsetDateTime, PrimitiveDateTime};
// SQLite does not support array arguments, so we only test this against a real postgres instance
#[gpui::test]
async fn test_get_embeddings_postgres(cx: &mut gpui::TestAppContext) {
- let test_db = TestDb::postgres(cx.executor().clone());
+ let test_db = TestDb::postgres(cx.executor());
let db = test_db.db();
let provider = "test_model";
@@ -38,7 +38,7 @@ async fn test_get_embeddings_postgres(cx: &mut gpui::TestAppContext) {
#[gpui::test]
async fn test_purge_old_embeddings(cx: &mut gpui::TestAppContext) {
- let test_db = TestDb::postgres(cx.executor().clone());
+ let test_db = TestDb::postgres(cx.executor());
let db = test_db.db();
let model = "test_model";
@@ -1,38 +0,0 @@
-use std::sync::Arc;
-
-use crate::test_both_dbs;
-
-use super::{CreateProcessedStripeEventParams, Database};
-
-test_both_dbs!(
- test_already_processed_stripe_event,
- test_already_processed_stripe_event_postgres,
- test_already_processed_stripe_event_sqlite
-);
-
-async fn test_already_processed_stripe_event(db: &Arc<Database>) {
- let unprocessed_event_id = "evt_1PiJOuRxOf7d5PNaw2zzWiyO".to_string();
- let processed_event_id = "evt_1PiIfMRxOf7d5PNakHrAUe8P".to_string();
-
- db.create_processed_stripe_event(&CreateProcessedStripeEventParams {
- stripe_event_id: processed_event_id.clone(),
- stripe_event_type: "customer.created".into(),
- stripe_event_created_timestamp: 1722355968,
- })
- .await
- .unwrap();
-
- assert!(
- db.already_processed_stripe_event(&processed_event_id)
- .await
- .unwrap(),
- "Expected {processed_event_id} to already be processed"
- );
-
- assert!(
- !db.already_processed_stripe_event(&unprocessed_event_id)
- .await
- .unwrap(),
- "Expected {unprocessed_event_id} to be unprocessed"
- );
-}
@@ -1,6 +1,5 @@
pub mod api;
pub mod auth;
-mod cents;
pub mod db;
pub mod env;
pub mod executor;
@@ -8,8 +7,6 @@ pub mod llm;
pub mod migrations;
pub mod rpc;
pub mod seed;
-pub mod stripe_billing;
-pub mod stripe_client;
pub mod user_backfiller;
#[cfg(test)]
@@ -21,24 +18,18 @@ use axum::{
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
-pub use cents::*;
use db::{ChannelId, Database};
use executor::Executor;
-use llm::db::LlmDatabase;
use serde::Deserialize;
use std::{path::PathBuf, sync::Arc};
use util::ResultExt;
-use crate::stripe_billing::StripeBilling;
-use crate::stripe_client::{RealStripeClient, StripeClient};
-
pub type Result<T, E = Error> = std::result::Result<T, E>;
pub enum Error {
Http(StatusCode, String, HeaderMap),
Database(sea_orm::error::DbErr),
Internal(anyhow::Error),
- Stripe(stripe::StripeError),
}
impl From<anyhow::Error> for Error {
@@ -53,12 +44,6 @@ impl From<sea_orm::error::DbErr> for Error {
}
}
-impl From<stripe::StripeError> for Error {
- fn from(error: stripe::StripeError) -> Self {
- Self::Stripe(error)
- }
-}
-
impl From<axum::Error> for Error {
fn from(error: axum::Error) -> Self {
Self::Internal(error.into())
@@ -106,14 +91,6 @@ impl IntoResponse for Error {
);
(StatusCode::INTERNAL_SERVER_ERROR, format!("{}", &error)).into_response()
}
- Error::Stripe(error) => {
- log::error!(
- "HTTP error {}: {:?}",
- StatusCode::INTERNAL_SERVER_ERROR,
- &error
- );
- (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", &error)).into_response()
- }
}
}
}
@@ -124,7 +101,6 @@ impl std::fmt::Debug for Error {
Error::Http(code, message, _headers) => (code, message).fmt(f),
Error::Database(error) => error.fmt(f),
Error::Internal(error) => error.fmt(f),
- Error::Stripe(error) => error.fmt(f),
}
}
}
@@ -135,7 +111,6 @@ impl std::fmt::Display for Error {
Error::Http(code, message, _) => write!(f, "{code}: {message}"),
Error::Database(error) => error.fmt(f),
Error::Internal(error) => error.fmt(f),
- Error::Stripe(error) => error.fmt(f),
}
}
}
@@ -181,7 +156,6 @@ pub struct Config {
pub zed_client_checksum_seed: Option<String>,
pub slack_panics_webhook: Option<String>,
pub auto_join_channel_id: Option<ChannelId>,
- pub stripe_api_key: Option<String>,
pub supermaven_admin_api_key: Option<Arc<str>>,
pub user_backfiller_github_access_token: Option<Arc<str>>,
}
@@ -236,7 +210,6 @@ impl Config {
auto_join_channel_id: None,
migrations_path: None,
seed_path: None,
- stripe_api_key: None,
supermaven_admin_api_key: None,
user_backfiller_github_access_token: None,
kinesis_region: None,
@@ -268,14 +241,8 @@ impl ServiceMode {
pub struct AppState {
pub db: Arc<Database>,
- pub llm_db: Option<Arc<LlmDatabase>>,
pub livekit_client: Option<Arc<dyn livekit_api::Client>>,
pub blob_store_client: Option<aws_sdk_s3::Client>,
- /// This is a real instance of the Stripe client; we're working to replace references to this with the
- /// [`StripeClient`] trait.
- pub real_stripe_client: Option<Arc<stripe::Client>>,
- pub stripe_client: Option<Arc<dyn StripeClient>>,
- pub stripe_billing: Option<Arc<StripeBilling>>,
pub executor: Executor,
pub kinesis_client: Option<::aws_sdk_kinesis::Client>,
pub config: Config,
@@ -288,20 +255,6 @@ impl AppState {
let mut db = Database::new(db_options).await?;
db.initialize_notification_kinds().await?;
- let llm_db = if let Some((llm_database_url, llm_database_max_connections)) = config
- .llm_database_url
- .clone()
- .zip(config.llm_database_max_connections)
- {
- let mut llm_db_options = db::ConnectOptions::new(llm_database_url);
- llm_db_options.max_connections(llm_database_max_connections);
- let mut llm_db = LlmDatabase::new(llm_db_options, executor.clone()).await?;
- llm_db.initialize().await?;
- Some(Arc::new(llm_db))
- } else {
- None
- };
-
let livekit_client = if let Some(((server, key), secret)) = config
.livekit_server
.as_ref()
@@ -318,18 +271,10 @@ impl AppState {
};
let db = Arc::new(db);
- let stripe_client = build_stripe_client(&config).map(Arc::new).log_err();
let this = Self {
db: db.clone(),
- llm_db,
livekit_client,
blob_store_client: build_blob_store_client(&config).await.log_err(),
- stripe_billing: stripe_client
- .clone()
- .map(|stripe_client| Arc::new(StripeBilling::new(stripe_client))),
- real_stripe_client: stripe_client.clone(),
- stripe_client: stripe_client
- .map(|stripe_client| Arc::new(RealStripeClient::new(stripe_client)) as _),
executor,
kinesis_client: if config.kinesis_access_key.is_some() {
build_kinesis_client(&config).await.log_err()
@@ -342,14 +287,6 @@ impl AppState {
}
}
-fn build_stripe_client(config: &Config) -> anyhow::Result<stripe::Client> {
- let api_key = config
- .stripe_api_key
- .as_ref()
- .context("missing stripe_api_key")?;
- Ok(stripe::Client::new(api_key))
-}
-
async fn build_blob_store_client(config: &Config) -> anyhow::Result<aws_sdk_s3::Client> {
let keys = aws_sdk_s3::config::Credentials::new(
config
@@ -1,20 +1 @@
pub mod db;
-mod token;
-
-use crate::Cents;
-
-pub use token::*;
-
-pub const AGENT_EXTENDED_TRIAL_FEATURE_FLAG: &str = "agent-extended-trial";
-
-/// The name of the feature flag that bypasses the account age check.
-pub const BYPASS_ACCOUNT_AGE_CHECK_FEATURE_FLAG: &str = "bypass-account-age-check";
-
-/// The minimum account age an account must have in order to use the LLM service.
-pub const MIN_ACCOUNT_AGE_FOR_LLM_USE: chrono::Duration = chrono::Duration::days(30);
-
-/// The default value to use for maximum spend per month if the user did not
-/// explicitly set a maximum spend.
-///
-/// Used to prevent surprise bills.
-pub const DEFAULT_MAX_MONTHLY_SPEND: Cents = Cents::from_dollars(10);
@@ -1,30 +1,9 @@
-mod ids;
-mod queries;
-mod seed;
-mod tables;
-
-#[cfg(test)]
-mod tests;
-
-use collections::HashMap;
-pub use ids::*;
-pub use seed::*;
-pub use tables::*;
-use zed_llm_client::LanguageModelProvider;
-
-#[cfg(test)]
-pub use tests::TestLlmDb;
-use usage_measure::UsageMeasure;
-
use std::future::Future;
use std::sync::Arc;
use anyhow::Context;
pub use sea_orm::ConnectOptions;
-use sea_orm::prelude::*;
-use sea_orm::{
- ActiveValue, DatabaseConnection, DatabaseTransaction, IsolationLevel, TransactionTrait,
-};
+use sea_orm::{DatabaseConnection, DatabaseTransaction, IsolationLevel, TransactionTrait};
use crate::Result;
use crate::db::TransactionHandle;
@@ -36,9 +15,6 @@ pub struct LlmDatabase {
pool: DatabaseConnection,
#[allow(unused)]
executor: Executor,
- provider_ids: HashMap<LanguageModelProvider, ProviderId>,
- models: HashMap<(LanguageModelProvider, String), model::Model>,
- usage_measure_ids: HashMap<UsageMeasure, UsageMeasureId>,
#[cfg(test)]
runtime: Option<tokio::runtime::Runtime>,
}
@@ -51,59 +27,11 @@ impl LlmDatabase {
options: options.clone(),
pool: sea_orm::Database::connect(options).await?,
executor,
- provider_ids: HashMap::default(),
- models: HashMap::default(),
- usage_measure_ids: HashMap::default(),
#[cfg(test)]
runtime: None,
})
}
- pub async fn initialize(&mut self) -> Result<()> {
- self.initialize_providers().await?;
- self.initialize_models().await?;
- self.initialize_usage_measures().await?;
- Ok(())
- }
-
- /// Returns the list of all known models, with their [`LanguageModelProvider`].
- pub fn all_models(&self) -> Vec<(LanguageModelProvider, model::Model)> {
- self.models
- .iter()
- .map(|((model_provider, _model_name), model)| (*model_provider, model.clone()))
- .collect::<Vec<_>>()
- }
-
- /// Returns the names of the known models for the given [`LanguageModelProvider`].
- pub fn model_names_for_provider(&self, provider: LanguageModelProvider) -> Vec<String> {
- self.models
- .keys()
- .filter_map(|(model_provider, model_name)| {
- if model_provider == &provider {
- Some(model_name)
- } else {
- None
- }
- })
- .cloned()
- .collect::<Vec<_>>()
- }
-
- pub fn model(&self, provider: LanguageModelProvider, name: &str) -> Result<&model::Model> {
- Ok(self
- .models
- .get(&(provider, name.to_string()))
- .with_context(|| format!("unknown model {provider:?}:{name}"))?)
- }
-
- pub fn model_by_id(&self, id: ModelId) -> Result<&model::Model> {
- Ok(self
- .models
- .values()
- .find(|model| model.id == id)
- .with_context(|| format!("no model for ID {id:?}"))?)
- }
-
pub fn options(&self) -> &ConnectOptions {
&self.options
}
@@ -1,11 +0,0 @@
-use sea_orm::{DbErr, entity::prelude::*};
-use serde::{Deserialize, Serialize};
-
-use crate::id_type;
-
-id_type!(BillingEventId);
-id_type!(ModelId);
-id_type!(ProviderId);
-id_type!(RevokedAccessTokenId);
-id_type!(UsageId);
-id_type!(UsageMeasureId);
@@ -1,6 +0,0 @@
-use super::*;
-
-pub mod providers;
-pub mod subscription_usage_meters;
-pub mod subscription_usages;
-pub mod usages;
@@ -1,134 +0,0 @@
-use super::*;
-use sea_orm::{QueryOrder, sea_query::OnConflict};
-use std::str::FromStr;
-use strum::IntoEnumIterator as _;
-
-pub struct ModelParams {
- pub provider: LanguageModelProvider,
- pub name: String,
- pub max_requests_per_minute: i64,
- pub max_tokens_per_minute: i64,
- pub max_tokens_per_day: i64,
- pub price_per_million_input_tokens: i32,
- pub price_per_million_output_tokens: i32,
-}
-
-impl LlmDatabase {
- pub async fn initialize_providers(&mut self) -> Result<()> {
- self.provider_ids = self
- .transaction(|tx| async move {
- let existing_providers = provider::Entity::find().all(&*tx).await?;
-
- let mut new_providers = LanguageModelProvider::iter()
- .filter(|provider| {
- !existing_providers
- .iter()
- .any(|p| p.name == provider.to_string())
- })
- .map(|provider| provider::ActiveModel {
- name: ActiveValue::set(provider.to_string()),
- ..Default::default()
- })
- .peekable();
-
- if new_providers.peek().is_some() {
- provider::Entity::insert_many(new_providers)
- .exec(&*tx)
- .await?;
- }
-
- let all_providers: HashMap<_, _> = provider::Entity::find()
- .all(&*tx)
- .await?
- .iter()
- .filter_map(|provider| {
- LanguageModelProvider::from_str(&provider.name)
- .ok()
- .map(|p| (p, provider.id))
- })
- .collect();
-
- Ok(all_providers)
- })
- .await?;
- Ok(())
- }
-
- pub async fn initialize_models(&mut self) -> Result<()> {
- let all_provider_ids = &self.provider_ids;
- self.models = self
- .transaction(|tx| async move {
- let all_models: HashMap<_, _> = model::Entity::find()
- .all(&*tx)
- .await?
- .into_iter()
- .filter_map(|model| {
- let provider = all_provider_ids.iter().find_map(|(provider, id)| {
- if *id == model.provider_id {
- Some(provider)
- } else {
- None
- }
- })?;
- Some(((*provider, model.name.clone()), model))
- })
- .collect();
- Ok(all_models)
- })
- .await?;
- Ok(())
- }
-
- pub async fn insert_models(&mut self, models: &[ModelParams]) -> Result<()> {
- let all_provider_ids = &self.provider_ids;
- self.transaction(|tx| async move {
- model::Entity::insert_many(models.iter().map(|model_params| {
- let provider_id = all_provider_ids[&model_params.provider];
- model::ActiveModel {
- provider_id: ActiveValue::set(provider_id),
- name: ActiveValue::set(model_params.name.clone()),
- max_requests_per_minute: ActiveValue::set(model_params.max_requests_per_minute),
- max_tokens_per_minute: ActiveValue::set(model_params.max_tokens_per_minute),
- max_tokens_per_day: ActiveValue::set(model_params.max_tokens_per_day),
- price_per_million_input_tokens: ActiveValue::set(
- model_params.price_per_million_input_tokens,
- ),
- price_per_million_output_tokens: ActiveValue::set(
- model_params.price_per_million_output_tokens,
- ),
- ..Default::default()
- }
- }))
- .on_conflict(
- OnConflict::columns([model::Column::ProviderId, model::Column::Name])
- .update_columns([
- model::Column::MaxRequestsPerMinute,
- model::Column::MaxTokensPerMinute,
- model::Column::MaxTokensPerDay,
- model::Column::PricePerMillionInputTokens,
- model::Column::PricePerMillionOutputTokens,
- ])
- .to_owned(),
- )
- .exec_without_returning(&*tx)
- .await?;
- Ok(())
- })
- .await?;
- self.initialize_models().await
- }
-
- /// Returns the list of LLM providers.
- pub async fn list_providers(&self) -> Result<Vec<LanguageModelProvider>> {
- self.transaction(|tx| async move {
- Ok(provider::Entity::find()
- .order_by_asc(provider::Column::Name)
- .all(&*tx)
- .await?
- .into_iter()
- .filter_map(|p| LanguageModelProvider::from_str(&p.name).ok())
- .collect())
- })
- .await
- }
-}
@@ -1,72 +0,0 @@
-use crate::db::UserId;
-use crate::llm::db::queries::subscription_usages::convert_chrono_to_time;
-
-use super::*;
-
-impl LlmDatabase {
- /// Returns all current subscription usage meters as of the given timestamp.
- pub async fn get_current_subscription_usage_meters(
- &self,
- now: DateTimeUtc,
- ) -> Result<Vec<(subscription_usage_meter::Model, subscription_usage::Model)>> {
- let now = convert_chrono_to_time(now)?;
-
- self.transaction(|tx| async move {
- let result = subscription_usage_meter::Entity::find()
- .inner_join(subscription_usage::Entity)
- .filter(
- subscription_usage::Column::PeriodStartAt
- .lte(now)
- .and(subscription_usage::Column::PeriodEndAt.gte(now)),
- )
- .select_also(subscription_usage::Entity)
- .all(&*tx)
- .await?;
-
- let result = result
- .into_iter()
- .filter_map(|(meter, usage)| {
- let usage = usage?;
- Some((meter, usage))
- })
- .collect();
-
- Ok(result)
- })
- .await
- }
-
- /// Returns all current subscription usage meters for the given user as of the given timestamp.
- pub async fn get_current_subscription_usage_meters_for_user(
- &self,
- user_id: UserId,
- now: DateTimeUtc,
- ) -> Result<Vec<(subscription_usage_meter::Model, subscription_usage::Model)>> {
- let now = convert_chrono_to_time(now)?;
-
- self.transaction(|tx| async move {
- let result = subscription_usage_meter::Entity::find()
- .inner_join(subscription_usage::Entity)
- .filter(subscription_usage::Column::UserId.eq(user_id))
- .filter(
- subscription_usage::Column::PeriodStartAt
- .lte(now)
- .and(subscription_usage::Column::PeriodEndAt.gte(now)),
- )
- .select_also(subscription_usage::Entity)
- .all(&*tx)
- .await?;
-
- let result = result
- .into_iter()
- .filter_map(|(meter, usage)| {
- let usage = usage?;
- Some((meter, usage))
- })
- .collect();
-
- Ok(result)
- })
- .await
- }
-}
@@ -1,59 +0,0 @@
-use time::PrimitiveDateTime;
-
-use crate::db::UserId;
-
-use super::*;
-
-pub fn convert_chrono_to_time(datetime: DateTimeUtc) -> anyhow::Result<PrimitiveDateTime> {
- use chrono::{Datelike as _, Timelike as _};
-
- let date = time::Date::from_calendar_date(
- datetime.year(),
- time::Month::try_from(datetime.month() as u8).unwrap(),
- datetime.day() as u8,
- )?;
-
- let time = time::Time::from_hms_nano(
- datetime.hour() as u8,
- datetime.minute() as u8,
- datetime.second() as u8,
- datetime.nanosecond(),
- )?;
-
- Ok(PrimitiveDateTime::new(date, time))
-}
-
-impl LlmDatabase {
- pub async fn get_subscription_usage_for_period(
- &self,
- user_id: UserId,
- period_start_at: DateTimeUtc,
- period_end_at: DateTimeUtc,
- ) -> Result<Option<subscription_usage::Model>> {
- self.transaction(|tx| async move {
- self.get_subscription_usage_for_period_in_tx(
- user_id,
- period_start_at,
- period_end_at,
- &tx,
- )
- .await
- })
- .await
- }
-
- async fn get_subscription_usage_for_period_in_tx(
- &self,
- user_id: UserId,
- period_start_at: DateTimeUtc,
- period_end_at: DateTimeUtc,
- tx: &DatabaseTransaction,
- ) -> Result<Option<subscription_usage::Model>> {
- Ok(subscription_usage::Entity::find()
- .filter(subscription_usage::Column::UserId.eq(user_id))
- .filter(subscription_usage::Column::PeriodStartAt.eq(period_start_at))
- .filter(subscription_usage::Column::PeriodEndAt.eq(period_end_at))
- .one(tx)
- .await?)
- }
-}
@@ -1,44 +0,0 @@
-use std::str::FromStr;
-use strum::IntoEnumIterator as _;
-
-use super::*;
-
-impl LlmDatabase {
- pub async fn initialize_usage_measures(&mut self) -> Result<()> {
- let all_measures = self
- .transaction(|tx| async move {
- let existing_measures = usage_measure::Entity::find().all(&*tx).await?;
-
- let new_measures = UsageMeasure::iter()
- .filter(|measure| {
- !existing_measures
- .iter()
- .any(|m| m.name == measure.to_string())
- })
- .map(|measure| usage_measure::ActiveModel {
- name: ActiveValue::set(measure.to_string()),
- ..Default::default()
- })
- .collect::<Vec<_>>();
-
- if !new_measures.is_empty() {
- usage_measure::Entity::insert_many(new_measures)
- .exec(&*tx)
- .await?;
- }
-
- Ok(usage_measure::Entity::find().all(&*tx).await?)
- })
- .await?;
-
- self.usage_measure_ids = all_measures
- .into_iter()
- .filter_map(|measure| {
- UsageMeasure::from_str(&measure.name)
- .ok()
- .map(|um| (um, measure.id))
- })
- .collect();
- Ok(())
- }
-}
@@ -1,45 +0,0 @@
-use super::*;
-use crate::{Config, Result};
-use queries::providers::ModelParams;
-
-pub async fn seed_database(_config: &Config, db: &mut LlmDatabase, _force: bool) -> Result<()> {
- db.insert_models(&[
- ModelParams {
- provider: LanguageModelProvider::Anthropic,
- name: "claude-3-5-sonnet".into(),
- max_requests_per_minute: 5,
- max_tokens_per_minute: 20_000,
- max_tokens_per_day: 300_000,
- price_per_million_input_tokens: 300, // $3.00/MTok
- price_per_million_output_tokens: 1500, // $15.00/MTok
- },
- ModelParams {
- provider: LanguageModelProvider::Anthropic,
- name: "claude-3-opus".into(),
- max_requests_per_minute: 5,
- max_tokens_per_minute: 10_000,
- max_tokens_per_day: 300_000,
- price_per_million_input_tokens: 1500, // $15.00/MTok
- price_per_million_output_tokens: 7500, // $75.00/MTok
- },
- ModelParams {
- provider: LanguageModelProvider::Anthropic,
- name: "claude-3-sonnet".into(),
- max_requests_per_minute: 5,
- max_tokens_per_minute: 20_000,
- max_tokens_per_day: 300_000,
- price_per_million_input_tokens: 1500, // $15.00/MTok
- price_per_million_output_tokens: 7500, // $75.00/MTok
- },
- ModelParams {
- provider: LanguageModelProvider::Anthropic,
- name: "claude-3-haiku".into(),
- max_requests_per_minute: 5,
- max_tokens_per_minute: 25_000,
- max_tokens_per_day: 300_000,
- price_per_million_input_tokens: 25, // $0.25/MTok
- price_per_million_output_tokens: 125, // $1.25/MTok
- },
- ])
- .await
-}
@@ -1,6 +0,0 @@
-pub mod model;
-pub mod provider;
-pub mod subscription_usage;
-pub mod subscription_usage_meter;
-pub mod usage;
-pub mod usage_measure;
@@ -1,48 +0,0 @@
-use sea_orm::entity::prelude::*;
-
-use crate::llm::db::{ModelId, ProviderId};
-
-/// An LLM model.
-#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
-#[sea_orm(table_name = "models")]
-pub struct Model {
- #[sea_orm(primary_key)]
- pub id: ModelId,
- pub provider_id: ProviderId,
- pub name: String,
- pub max_requests_per_minute: i64,
- pub max_tokens_per_minute: i64,
- pub max_input_tokens_per_minute: i64,
- pub max_output_tokens_per_minute: i64,
- pub max_tokens_per_day: i64,
- pub price_per_million_input_tokens: i32,
- pub price_per_million_cache_creation_input_tokens: i32,
- pub price_per_million_cache_read_input_tokens: i32,
- pub price_per_million_output_tokens: i32,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {
- #[sea_orm(
- belongs_to = "super::provider::Entity",
- from = "Column::ProviderId",
- to = "super::provider::Column::Id"
- )]
- Provider,
- #[sea_orm(has_many = "super::usage::Entity")]
- Usages,
-}
-
-impl Related<super::provider::Entity> for Entity {
- fn to() -> RelationDef {
- Relation::Provider.def()
- }
-}
-
-impl Related<super::usage::Entity> for Entity {
- fn to() -> RelationDef {
- Relation::Usages.def()
- }
-}
-
-impl ActiveModelBehavior for ActiveModel {}
@@ -1,25 +0,0 @@
-use crate::llm::db::ProviderId;
-use sea_orm::entity::prelude::*;
-
-/// An LLM provider.
-#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
-#[sea_orm(table_name = "providers")]
-pub struct Model {
- #[sea_orm(primary_key)]
- pub id: ProviderId,
- pub name: String,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {
- #[sea_orm(has_many = "super::model::Entity")]
- Models,
-}
-
-impl Related<super::model::Entity> for Entity {
- fn to() -> RelationDef {
- Relation::Models.def()
- }
-}
-
-impl ActiveModelBehavior for ActiveModel {}
@@ -1,22 +0,0 @@
-use crate::db::UserId;
-use crate::db::billing_subscription::SubscriptionKind;
-use sea_orm::entity::prelude::*;
-use time::PrimitiveDateTime;
-
-#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
-#[sea_orm(table_name = "subscription_usages_v2")]
-pub struct Model {
- #[sea_orm(primary_key)]
- pub id: Uuid,
- pub user_id: UserId,
- pub period_start_at: PrimitiveDateTime,
- pub period_end_at: PrimitiveDateTime,
- pub plan: SubscriptionKind,
- pub model_requests: i32,
- pub edit_predictions: i32,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {}
-
-impl ActiveModelBehavior for ActiveModel {}
@@ -1,55 +0,0 @@
-use sea_orm::entity::prelude::*;
-use serde::Serialize;
-
-use crate::llm::db::ModelId;
-
-#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
-#[sea_orm(table_name = "subscription_usage_meters_v2")]
-pub struct Model {
- #[sea_orm(primary_key)]
- pub id: Uuid,
- pub subscription_usage_id: Uuid,
- pub model_id: ModelId,
- pub mode: CompletionMode,
- pub requests: i32,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {
- #[sea_orm(
- belongs_to = "super::subscription_usage::Entity",
- from = "Column::SubscriptionUsageId",
- to = "super::subscription_usage::Column::Id"
- )]
- SubscriptionUsage,
- #[sea_orm(
- belongs_to = "super::model::Entity",
- from = "Column::ModelId",
- to = "super::model::Column::Id"
- )]
- Model,
-}
-
-impl Related<super::subscription_usage::Entity> for Entity {
- fn to() -> RelationDef {
- Relation::SubscriptionUsage.def()
- }
-}
-
-impl Related<super::model::Entity> for Entity {
- fn to() -> RelationDef {
- Relation::Model.def()
- }
-}
-
-impl ActiveModelBehavior for ActiveModel {}
-
-#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Hash, Serialize)]
-#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
-#[serde(rename_all = "snake_case")]
-pub enum CompletionMode {
- #[sea_orm(string_value = "normal")]
- Normal,
- #[sea_orm(string_value = "max")]
- Max,
-}
@@ -1,52 +0,0 @@
-use crate::{
- db::UserId,
- llm::db::{ModelId, UsageId, UsageMeasureId},
-};
-use sea_orm::entity::prelude::*;
-
-/// An LLM usage record.
-#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
-#[sea_orm(table_name = "usages")]
-pub struct Model {
- #[sea_orm(primary_key)]
- pub id: UsageId,
- /// The ID of the Zed user.
- ///
- /// Corresponds to the `users` table in the primary collab database.
- pub user_id: UserId,
- pub model_id: ModelId,
- pub measure_id: UsageMeasureId,
- pub timestamp: DateTime,
- pub buckets: Vec<i64>,
- pub is_staff: bool,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {
- #[sea_orm(
- belongs_to = "super::model::Entity",
- from = "Column::ModelId",
- to = "super::model::Column::Id"
- )]
- Model,
- #[sea_orm(
- belongs_to = "super::usage_measure::Entity",
- from = "Column::MeasureId",
- to = "super::usage_measure::Column::Id"
- )]
- UsageMeasure,
-}
-
-impl Related<super::model::Entity> for Entity {
- fn to() -> RelationDef {
- Relation::Model.def()
- }
-}
-
-impl Related<super::usage_measure::Entity> for Entity {
- fn to() -> RelationDef {
- Relation::UsageMeasure.def()
- }
-}
-
-impl ActiveModelBehavior for ActiveModel {}
@@ -1,36 +0,0 @@
-use crate::llm::db::UsageMeasureId;
-use sea_orm::entity::prelude::*;
-
-#[derive(
- Copy, Clone, Debug, PartialEq, Eq, Hash, strum::EnumString, strum::Display, strum::EnumIter,
-)]
-#[strum(serialize_all = "snake_case")]
-pub enum UsageMeasure {
- RequestsPerMinute,
- TokensPerMinute,
- InputTokensPerMinute,
- OutputTokensPerMinute,
- TokensPerDay,
-}
-
-#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
-#[sea_orm(table_name = "usage_measures")]
-pub struct Model {
- #[sea_orm(primary_key)]
- pub id: UsageMeasureId,
- pub name: String,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {
- #[sea_orm(has_many = "super::usage::Entity")]
- Usages,
-}
-
-impl Related<super::usage::Entity> for Entity {
- fn to() -> RelationDef {
- Relation::Usages.def()
- }
-}
-
-impl ActiveModelBehavior for ActiveModel {}
@@ -1,107 +0,0 @@
-mod provider_tests;
-
-use gpui::BackgroundExecutor;
-use parking_lot::Mutex;
-use rand::prelude::*;
-use sea_orm::ConnectionTrait;
-use sqlx::migrate::MigrateDatabase;
-use std::time::Duration;
-
-use crate::migrations::run_database_migrations;
-
-use super::*;
-
-pub struct TestLlmDb {
- pub db: Option<LlmDatabase>,
- pub connection: Option<sqlx::AnyConnection>,
-}
-
-impl TestLlmDb {
- pub fn postgres(background: BackgroundExecutor) -> Self {
- static LOCK: Mutex<()> = Mutex::new(());
-
- let _guard = LOCK.lock();
- let mut rng = StdRng::from_entropy();
- let url = format!(
- "postgres://postgres@localhost/zed-llm-test-{}",
- rng.r#gen::<u128>()
- );
- let runtime = tokio::runtime::Builder::new_current_thread()
- .enable_io()
- .enable_time()
- .build()
- .unwrap();
-
- let mut db = runtime.block_on(async {
- sqlx::Postgres::create_database(&url)
- .await
- .expect("failed to create test db");
- let mut options = ConnectOptions::new(url);
- options
- .max_connections(5)
- .idle_timeout(Duration::from_secs(0));
- let db = LlmDatabase::new(options, Executor::Deterministic(background))
- .await
- .unwrap();
- let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations_llm");
- run_database_migrations(db.options(), migrations_path)
- .await
- .unwrap();
- db
- });
-
- db.runtime = Some(runtime);
-
- Self {
- db: Some(db),
- connection: None,
- }
- }
-
- pub fn db(&mut self) -> &mut LlmDatabase {
- self.db.as_mut().unwrap()
- }
-}
-
-#[macro_export]
-macro_rules! test_llm_db {
- ($test_name:ident, $postgres_test_name:ident) => {
- #[gpui::test]
- async fn $postgres_test_name(cx: &mut gpui::TestAppContext) {
- if !cfg!(target_os = "macos") {
- return;
- }
-
- let mut test_db = $crate::llm::db::TestLlmDb::postgres(cx.executor().clone());
- $test_name(test_db.db()).await;
- }
- };
-}
-
-impl Drop for TestLlmDb {
- fn drop(&mut self) {
- let db = self.db.take().unwrap();
- if let sea_orm::DatabaseBackend::Postgres = db.pool.get_database_backend() {
- db.runtime.as_ref().unwrap().block_on(async {
- use util::ResultExt;
- let query = "
- SELECT pg_terminate_backend(pg_stat_activity.pid)
- FROM pg_stat_activity
- WHERE
- pg_stat_activity.datname = current_database() AND
- pid <> pg_backend_pid();
- ";
- db.pool
- .execute(sea_orm::Statement::from_string(
- db.pool.get_database_backend(),
- query,
- ))
- .await
- .log_err();
- sqlx::Postgres::drop_database(db.options.get_url())
- .await
- .log_err();
- })
- }
- }
-}
@@ -1,31 +0,0 @@
-use pretty_assertions::assert_eq;
-use zed_llm_client::LanguageModelProvider;
-
-use crate::llm::db::LlmDatabase;
-use crate::test_llm_db;
-
-test_llm_db!(
- test_initialize_providers,
- test_initialize_providers_postgres
-);
-
-async fn test_initialize_providers(db: &mut LlmDatabase) {
- let initial_providers = db.list_providers().await.unwrap();
- assert_eq!(initial_providers, vec![]);
-
- db.initialize_providers().await.unwrap();
-
- // Do it twice, to make sure the operation is idempotent.
- db.initialize_providers().await.unwrap();
-
- let providers = db.list_providers().await.unwrap();
-
- assert_eq!(
- providers,
- &[
- LanguageModelProvider::Anthropic,
- LanguageModelProvider::Google,
- LanguageModelProvider::OpenAi,
- ]
- )
-}
@@ -1,146 +0,0 @@
-use crate::db::billing_subscription::SubscriptionKind;
-use crate::db::{billing_customer, billing_subscription, user};
-use crate::llm::{AGENT_EXTENDED_TRIAL_FEATURE_FLAG, BYPASS_ACCOUNT_AGE_CHECK_FEATURE_FLAG};
-use crate::{Config, db::billing_preference};
-use anyhow::{Context as _, Result};
-use chrono::{NaiveDateTime, Utc};
-use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
-use serde::{Deserialize, Serialize};
-use std::time::Duration;
-use thiserror::Error;
-use uuid::Uuid;
-use zed_llm_client::Plan;
-
-#[derive(Clone, Debug, Default, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct LlmTokenClaims {
- pub iat: u64,
- pub exp: u64,
- pub jti: String,
- pub user_id: u64,
- pub system_id: Option<String>,
- pub metrics_id: Uuid,
- pub github_user_login: String,
- pub account_created_at: NaiveDateTime,
- pub is_staff: bool,
- pub has_llm_closed_beta_feature_flag: bool,
- pub bypass_account_age_check: bool,
- pub use_llm_request_queue: bool,
- pub plan: Plan,
- pub has_extended_trial: bool,
- pub subscription_period: (NaiveDateTime, NaiveDateTime),
- pub enable_model_request_overages: bool,
- pub model_request_overages_spend_limit_in_cents: u32,
- pub can_use_web_search_tool: bool,
- #[serde(default)]
- pub has_overdue_invoices: bool,
-}
-
-const LLM_TOKEN_LIFETIME: Duration = Duration::from_secs(60 * 60);
-
-impl LlmTokenClaims {
- pub fn create(
- user: &user::Model,
- is_staff: bool,
- billing_customer: billing_customer::Model,
- billing_preferences: Option<billing_preference::Model>,
- feature_flags: &Vec<String>,
- subscription: billing_subscription::Model,
- system_id: Option<String>,
- config: &Config,
- ) -> Result<String> {
- let secret = config
- .llm_api_secret
- .as_ref()
- .context("no LLM API secret")?;
-
- let plan = if is_staff {
- Plan::ZedPro
- } else {
- subscription.kind.map_or(Plan::ZedFree, |kind| match kind {
- SubscriptionKind::ZedFree => Plan::ZedFree,
- SubscriptionKind::ZedPro => Plan::ZedPro,
- SubscriptionKind::ZedProTrial => Plan::ZedProTrial,
- })
- };
- let subscription_period =
- billing_subscription::Model::current_period(Some(subscription), is_staff)
- .map(|(start, end)| (start.naive_utc(), end.naive_utc()))
- .context("A plan is required to use Zed's hosted models or edit predictions. Visit https://zed.dev/account to get started.")?;
-
- let now = Utc::now();
- let claims = Self {
- iat: now.timestamp() as u64,
- exp: (now + LLM_TOKEN_LIFETIME).timestamp() as u64,
- jti: uuid::Uuid::new_v4().to_string(),
- user_id: user.id.to_proto(),
- system_id,
- metrics_id: user.metrics_id,
- github_user_login: user.github_login.clone(),
- account_created_at: user.account_created_at(),
- is_staff,
- has_llm_closed_beta_feature_flag: feature_flags
- .iter()
- .any(|flag| flag == "llm-closed-beta"),
- bypass_account_age_check: feature_flags
- .iter()
- .any(|flag| flag == BYPASS_ACCOUNT_AGE_CHECK_FEATURE_FLAG),
- can_use_web_search_tool: true,
- use_llm_request_queue: feature_flags.iter().any(|flag| flag == "llm-request-queue"),
- plan,
- has_extended_trial: feature_flags
- .iter()
- .any(|flag| flag == AGENT_EXTENDED_TRIAL_FEATURE_FLAG),
- subscription_period,
- enable_model_request_overages: billing_preferences
- .as_ref()
- .map_or(false, |preferences| {
- preferences.model_request_overages_enabled
- }),
- model_request_overages_spend_limit_in_cents: billing_preferences
- .as_ref()
- .map_or(0, |preferences| {
- preferences.model_request_overages_spend_limit_in_cents as u32
- }),
- has_overdue_invoices: billing_customer.has_overdue_invoices,
- };
-
- Ok(jsonwebtoken::encode(
- &Header::default(),
- &claims,
- &EncodingKey::from_secret(secret.as_ref()),
- )?)
- }
-
- pub fn validate(token: &str, config: &Config) -> Result<LlmTokenClaims, ValidateLlmTokenError> {
- let secret = config
- .llm_api_secret
- .as_ref()
- .context("no LLM API secret")?;
-
- match jsonwebtoken::decode::<Self>(
- token,
- &DecodingKey::from_secret(secret.as_ref()),
- &Validation::default(),
- ) {
- Ok(token) => Ok(token.claims),
- Err(e) => {
- if e.kind() == &jsonwebtoken::errors::ErrorKind::ExpiredSignature {
- Err(ValidateLlmTokenError::Expired)
- } else {
- Err(ValidateLlmTokenError::JwtError(e))
- }
- }
- }
- }
-}
-
-#[derive(Error, Debug)]
-pub enum ValidateLlmTokenError {
- #[error("access token is expired")]
- Expired,
- #[error("access token validation error: {0}")]
- JwtError(#[from] jsonwebtoken::errors::Error),
- #[error("{0}")]
- Other(#[from] anyhow::Error),
-}
@@ -7,8 +7,8 @@ use axum::{
routing::get,
};
+use collab::ServiceMode;
use collab::api::CloudflareIpCountryHeader;
-use collab::api::billing::sync_llm_request_usage_with_stripe_periodically;
use collab::llm::db::LlmDatabase;
use collab::migrations::run_database_migrations;
use collab::user_backfiller::spawn_user_backfiller;
@@ -16,7 +16,6 @@ use collab::{
AppState, Config, Result, api::fetch_extensions_from_blob_store_periodically, db, env,
executor::Executor, rpc::ResultExt,
};
-use collab::{ServiceMode, api::billing::poll_stripe_events_periodically};
use db::Database;
use std::{
env::args,
@@ -31,7 +30,7 @@ use tower_http::trace::TraceLayer;
use tracing_subscriber::{
Layer, filter::EnvFilter, fmt::format::JsonFields, util::SubscriberInitExt,
};
-use util::{ResultExt as _, maybe};
+use util::ResultExt as _;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const REVISION: Option<&'static str> = option_env!("GITHUB_SHA");
@@ -63,13 +62,6 @@ async fn main() -> Result<()> {
db.initialize_notification_kinds().await?;
collab::seed::seed(&config, &db, false).await?;
-
- if let Some(llm_database_url) = config.llm_database_url.clone() {
- let db_options = db::ConnectOptions::new(llm_database_url);
- let mut db = LlmDatabase::new(db_options.clone(), Executor::Production).await?;
- db.initialize().await?;
- collab::llm::db::seed_database(&config, &mut db, true).await?;
- }
}
Some("serve") => {
let mode = match args.next().as_deref() {
@@ -103,13 +95,6 @@ async fn main() -> Result<()> {
let state = AppState::new(config, Executor::Production).await?;
- if let Some(stripe_billing) = state.stripe_billing.clone() {
- let executor = state.executor.clone();
- executor.spawn_detached(async move {
- stripe_billing.initialize().await.trace_err();
- });
- }
-
if mode.is_collab() {
state.db.purge_old_embeddings().await.trace_err();
@@ -120,8 +105,6 @@ async fn main() -> Result<()> {
let rpc_server = collab::rpc::Server::new(epoch, state.clone());
rpc_server.start().await?;
- poll_stripe_events_periodically(state.clone(), rpc_server.clone());
-
app = app
.merge(collab::api::routes(rpc_server.clone()))
.merge(collab::rpc::routes(rpc_server.clone()));
@@ -133,29 +116,6 @@ async fn main() -> Result<()> {
fetch_extensions_from_blob_store_periodically(state.clone());
spawn_user_backfiller(state.clone());
- let llm_db = maybe!(async {
- let database_url = state
- .config
- .llm_database_url
- .as_ref()
- .context("missing LLM_DATABASE_URL")?;
- let max_connections = state
- .config
- .llm_database_max_connections
- .context("missing LLM_DATABASE_MAX_CONNECTIONS")?;
-
- let mut db_options = db::ConnectOptions::new(database_url);
- db_options.max_connections(max_connections);
- LlmDatabase::new(db_options, state.executor.clone()).await
- })
- .await
- .trace_err();
-
- if let Some(mut llm_db) = llm_db {
- llm_db.initialize().await?;
- sync_llm_request_usage_with_stripe_periodically(state.clone());
- }
-
app = app
.merge(collab::api::events::router())
.merge(collab::api::extensions::router())
@@ -296,9 +256,6 @@ async fn setup_llm_database(config: &Config) -> Result<()> {
.llm_database_migrations_path
.as_deref()
.unwrap_or_else(|| {
- #[cfg(feature = "sqlite")]
- let default_migrations = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations_llm.sqlite");
- #[cfg(not(feature = "sqlite"))]
let default_migrations = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations_llm");
Path::new(default_migrations)
@@ -1,14 +1,6 @@
mod connection_pool;
-use crate::api::billing::find_or_create_billing_customer;
use crate::api::{CloudflareIpCountryHeader, SystemIdHeader};
-use crate::db::billing_subscription::SubscriptionKind;
-use crate::llm::db::LlmDatabase;
-use crate::llm::{
- AGENT_EXTENDED_TRIAL_FEATURE_FLAG, BYPASS_ACCOUNT_AGE_CHECK_FEATURE_FLAG, LlmTokenClaims,
- MIN_ACCOUNT_AGE_FOR_LLM_USE,
-};
-use crate::stripe_client::StripeCustomerId;
use crate::{
AppState, Error, Result, auth,
db::{
@@ -23,6 +15,7 @@ use anyhow::{Context as _, anyhow, bail};
use async_tungstenite::tungstenite::{
Message as TungsteniteMessage, protocol::CloseFrame as TungsteniteCloseFrame,
};
+use axum::headers::UserAgent;
use axum::{
Extension, Router, TypedHeader,
body::Body,
@@ -36,13 +29,14 @@ use axum::{
response::IntoResponse,
routing::get,
};
-use chrono::Utc;
use collections::{HashMap, HashSet};
pub use connection_pool::{ConnectionPool, ZedVersion};
use core::fmt::{self, Debug, Formatter};
+use futures::TryFutureExt as _;
use reqwest_client::ReqwestClient;
-use rpc::proto::split_repository_update;
+use rpc::proto::{MultiLspQuery, split_repository_update};
use supermaven_api::{CreateExternalUserRequest, SupermavenAdminApi};
+use tracing::Span;
use futures::{
FutureExt, SinkExt, StreamExt, TryStreamExt, channel::oneshot, future::BoxFuture,
@@ -93,8 +87,13 @@ const MAX_CONCURRENT_CONNECTIONS: usize = 512;
static CONCURRENT_CONNECTIONS: AtomicUsize = AtomicUsize::new(0);
+const TOTAL_DURATION_MS: &str = "total_duration_ms";
+const PROCESSING_DURATION_MS: &str = "processing_duration_ms";
+const QUEUE_DURATION_MS: &str = "queue_duration_ms";
+const HOST_WAITING_MS: &str = "host_waiting_ms";
+
type MessageHandler =
- Box<dyn Send + Sync + Fn(Box<dyn AnyTypedEnvelope>, Session) -> BoxFuture<'static, ()>>;
+ Box<dyn Send + Sync + Fn(Box<dyn AnyTypedEnvelope>, Session, Span) -> BoxFuture<'static, ()>>;
pub struct ConnectionGuard;
@@ -140,13 +139,6 @@ pub enum Principal {
}
impl Principal {
- fn user(&self) -> &User {
- match self {
- Principal::User(user) => user,
- Principal::Impersonated { user, .. } => user,
- }
- }
-
fn update_span(&self, span: &tracing::Span) {
match &self {
Principal::User(user) => {
@@ -162,6 +154,42 @@ impl Principal {
}
}
+#[derive(Clone)]
+struct MessageContext {
+ session: Session,
+ span: tracing::Span,
+}
+
+impl Deref for MessageContext {
+ type Target = Session;
+
+ fn deref(&self) -> &Self::Target {
+ &self.session
+ }
+}
+
+impl MessageContext {
+ pub fn forward_request<T: RequestMessage>(
+ &self,
+ receiver_id: ConnectionId,
+ request: T,
+ ) -> impl Future<Output = anyhow::Result<T::Response>> {
+ let request_start_time = Instant::now();
+ let span = self.span.clone();
+ tracing::info!("start forwarding request");
+ self.peer
+ .forward_request(self.connection_id, receiver_id, request)
+ .inspect(move |_| {
+ span.record(
+ HOST_WAITING_MS,
+ request_start_time.elapsed().as_micros() as f64 / 1000.0,
+ );
+ })
+ .inspect_err(|_| tracing::error!("error forwarding request"))
+ .inspect_ok(|_| tracing::info!("finished forwarding request"))
+ }
+}
+
#[derive(Clone)]
struct Session {
principal: Principal,
@@ -174,6 +202,7 @@ struct Session {
/// The GeoIP country code for the user.
#[allow(unused)]
geoip_country_code: Option<String>,
+ #[allow(unused)]
system_id: Option<String>,
_executor: Executor,
}
@@ -281,7 +310,7 @@ impl Server {
let mut server = Self {
id: parking_lot::Mutex::new(id),
peer: Peer::new(id.0 as u32),
- app_state: app_state.clone(),
+ app_state,
connection_pool: Default::default(),
handlers: Default::default(),
teardown: watch::channel(false).0,
@@ -314,7 +343,7 @@ impl Server {
.add_request_handler(forward_read_only_project_request::<proto::GetDefinition>)
.add_request_handler(forward_read_only_project_request::<proto::GetTypeDefinition>)
.add_request_handler(forward_read_only_project_request::<proto::GetReferences>)
- .add_request_handler(forward_find_search_candidates_request)
+ .add_request_handler(forward_read_only_project_request::<proto::FindSearchCandidates>)
.add_request_handler(forward_read_only_project_request::<proto::GetDocumentHighlights>)
.add_request_handler(forward_read_only_project_request::<proto::GetDocumentSymbols>)
.add_request_handler(forward_read_only_project_request::<proto::GetProjectSymbols>)
@@ -339,9 +368,6 @@ impl Server {
.add_request_handler(forward_read_only_project_request::<proto::LspExtCancelFlycheck>)
.add_request_handler(forward_read_only_project_request::<proto::LspExtRunFlycheck>)
.add_request_handler(forward_read_only_project_request::<proto::LspExtClearFlycheck>)
- .add_request_handler(
- forward_read_only_project_request::<proto::LanguageServerIdForName>,
- )
.add_request_handler(forward_read_only_project_request::<proto::GetDocumentDiagnostics>)
.add_request_handler(
forward_mutating_project_request::<proto::RegisterBufferWithLanguageServers>,
@@ -373,7 +399,9 @@ impl Server {
.add_request_handler(forward_mutating_project_request::<proto::OnTypeFormatting>)
.add_request_handler(forward_mutating_project_request::<proto::SaveBuffer>)
.add_request_handler(forward_mutating_project_request::<proto::BlameBuffer>)
- .add_request_handler(forward_mutating_project_request::<proto::MultiLspQuery>)
+ .add_request_handler(multi_lsp_query)
+ .add_request_handler(lsp_query)
+ .add_message_handler(broadcast_project_message_from_host::<proto::LspQueryResponse>)
.add_request_handler(forward_mutating_project_request::<proto::RestartLanguageServers>)
.add_request_handler(forward_mutating_project_request::<proto::StopLanguageServers>)
.add_request_handler(forward_mutating_project_request::<proto::LinkedEditingRange>)
@@ -422,9 +450,6 @@ impl Server {
.add_request_handler(follow)
.add_message_handler(unfollow)
.add_message_handler(update_followers)
- .add_request_handler(get_private_user_info)
- .add_request_handler(get_llm_api_token)
- .add_request_handler(accept_terms_of_service)
.add_message_handler(acknowledge_channel_message)
.add_message_handler(acknowledge_buffer_version)
.add_request_handler(get_supermaven_api_key)
@@ -433,6 +458,8 @@ impl Server {
.add_request_handler(forward_mutating_project_request::<proto::SynchronizeContexts>)
.add_request_handler(forward_mutating_project_request::<proto::Stage>)
.add_request_handler(forward_mutating_project_request::<proto::Unstage>)
+ .add_request_handler(forward_mutating_project_request::<proto::Stash>)
+ .add_request_handler(forward_mutating_project_request::<proto::StashPop>)
.add_request_handler(forward_mutating_project_request::<proto::Commit>)
.add_request_handler(forward_mutating_project_request::<proto::GitInit>)
.add_request_handler(forward_read_only_project_request::<proto::GetRemotes>)
@@ -591,10 +618,10 @@ impl Server {
}
}
- if let Some(live_kit) = livekit_client.as_ref() {
- if delete_livekit_room {
- live_kit.delete_room(livekit_room).await.trace_err();
- }
+ if let Some(live_kit) = livekit_client.as_ref()
+ && delete_livekit_room
+ {
+ live_kit.delete_room(livekit_room).await.trace_err();
}
}
}
@@ -646,42 +673,37 @@ impl Server {
fn add_handler<F, Fut, M>(&mut self, handler: F) -> &mut Self
where
- F: 'static + Send + Sync + Fn(TypedEnvelope<M>, Session) -> Fut,
+ F: 'static + Send + Sync + Fn(TypedEnvelope<M>, MessageContext) -> Fut,
Fut: 'static + Send + Future<Output = Result<()>>,
M: EnvelopedMessage,
{
let prev_handler = self.handlers.insert(
TypeId::of::<M>(),
- Box::new(move |envelope, session| {
+ Box::new(move |envelope, session, span| {
let envelope = envelope.into_any().downcast::<TypedEnvelope<M>>().unwrap();
let received_at = envelope.received_at;
tracing::info!("message received");
let start_time = Instant::now();
- let future = (handler)(*envelope, session);
+ let future = (handler)(
+ *envelope,
+ MessageContext {
+ session,
+ span: span.clone(),
+ },
+ );
async move {
let result = future.await;
let total_duration_ms = received_at.elapsed().as_micros() as f64 / 1000.0;
let processing_duration_ms = start_time.elapsed().as_micros() as f64 / 1000.0;
let queue_duration_ms = total_duration_ms - processing_duration_ms;
- let payload_type = M::NAME;
-
+ span.record(TOTAL_DURATION_MS, total_duration_ms);
+ span.record(PROCESSING_DURATION_MS, processing_duration_ms);
+ span.record(QUEUE_DURATION_MS, queue_duration_ms);
match result {
Err(error) => {
- tracing::error!(
- ?error,
- total_duration_ms,
- processing_duration_ms,
- queue_duration_ms,
- payload_type,
- "error handling message"
- )
+ tracing::error!(?error, "error handling message")
}
- Ok(()) => tracing::info!(
- total_duration_ms,
- processing_duration_ms,
- queue_duration_ms,
- "finished handling message"
- ),
+ Ok(()) => tracing::info!("finished handling message"),
}
}
.boxed()
@@ -695,7 +717,7 @@ impl Server {
fn add_message_handler<F, Fut, M>(&mut self, handler: F) -> &mut Self
where
- F: 'static + Send + Sync + Fn(M, Session) -> Fut,
+ F: 'static + Send + Sync + Fn(M, MessageContext) -> Fut,
Fut: 'static + Send + Future<Output = Result<()>>,
M: EnvelopedMessage,
{
@@ -705,7 +727,7 @@ impl Server {
fn add_request_handler<F, Fut, M>(&mut self, handler: F) -> &mut Self
where
- F: 'static + Send + Sync + Fn(M, Response<M>, Session) -> Fut,
+ F: 'static + Send + Sync + Fn(M, Response<M>, MessageContext) -> Fut,
Fut: Send + Future<Output = Result<()>>,
M: RequestMessage,
{
@@ -748,6 +770,8 @@ impl Server {
address: String,
principal: Principal,
zed_version: ZedVersion,
+ release_channel: Option<String>,
+ user_agent: Option<String>,
geoip_country_code: Option<String>,
system_id: Option<String>,
send_connection_id: Option<oneshot::Sender<ConnectionId>>,
@@ -760,9 +784,18 @@ impl Server {
user_id=field::Empty,
login=field::Empty,
impersonator=field::Empty,
- geoip_country_code=field::Empty
+ user_agent=field::Empty,
+ geoip_country_code=field::Empty,
+ release_channel=field::Empty,
);
principal.update_span(&span);
+ if let Some(user_agent) = user_agent {
+ span.record("user_agent", user_agent);
+ }
+ if let Some(release_channel) = release_channel {
+ span.record("release_channel", release_channel);
+ }
+
if let Some(country_code) = geoip_country_code.as_ref() {
span.record("geoip_country_code", country_code);
}
@@ -771,12 +804,11 @@ impl Server {
async move {
if *teardown.borrow() {
tracing::error!("server is tearing down");
- return
+ return;
}
- let (connection_id, handle_io, mut incoming_rx) = this
- .peer
- .add_connection(connection, {
+ let (connection_id, handle_io, mut incoming_rx) =
+ this.peer.add_connection(connection, {
let executor = executor.clone();
move |duration| executor.sleep(duration)
});
@@ -793,10 +825,14 @@ impl Server {
}
};
- let supermaven_client = this.app_state.config.supermaven_admin_api_key.clone().map(|supermaven_admin_api_key| Arc::new(SupermavenAdminApi::new(
- supermaven_admin_api_key.to_string(),
- http_client.clone(),
- )));
+ let supermaven_client = this.app_state.config.supermaven_admin_api_key.clone().map(
+ |supermaven_admin_api_key| {
+ Arc::new(SupermavenAdminApi::new(
+ supermaven_admin_api_key.to_string(),
+ http_client.clone(),
+ ))
+ },
+ );
let session = Session {
principal: principal.clone(),
@@ -811,7 +847,15 @@ impl Server {
supermaven_client,
};
- if let Err(error) = this.send_initial_client_update(connection_id, zed_version, send_connection_id, &session).await {
+ if let Err(error) = this
+ .send_initial_client_update(
+ connection_id,
+ zed_version,
+ send_connection_id,
+ &session,
+ )
+ .await
+ {
tracing::error!(?error, "failed to send initial client update");
return;
}
@@ -828,14 +872,22 @@ impl Server {
//
// This arrangement ensures we will attempt to process earlier messages first, but fall
// back to processing messages arrived later in the spirit of making progress.
+ const MAX_CONCURRENT_HANDLERS: usize = 256;
let mut foreground_message_handlers = FuturesUnordered::new();
- let concurrent_handlers = Arc::new(Semaphore::new(256));
+ let concurrent_handlers = Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS));
+ let get_concurrent_handlers = {
+ let concurrent_handlers = concurrent_handlers.clone();
+ move || MAX_CONCURRENT_HANDLERS - concurrent_handlers.available_permits()
+ };
loop {
let next_message = async {
let permit = concurrent_handlers.clone().acquire_owned().await.unwrap();
let message = incoming_rx.next().await;
- (permit, message)
- }.fuse();
+ // Cache the concurrent_handlers here, so that we know what the
+ // queue looks like as each handler starts
+ (permit, message, get_concurrent_handlers())
+ }
+ .fuse();
futures::pin_mut!(next_message);
futures::select_biased! {
_ = teardown.changed().fuse() => return,
@@ -847,21 +899,33 @@ impl Server {
}
_ = foreground_message_handlers.next() => {}
next_message = next_message => {
- let (permit, message) = next_message;
+ let (permit, message, concurrent_handlers) = next_message;
if let Some(message) = message {
let type_name = message.payload_type_name();
// note: we copy all the fields from the parent span so we can query them in the logs.
// (https://github.com/tokio-rs/tracing/issues/2670).
- let span = tracing::info_span!("receive message", %connection_id, %address, type_name,
+ let span = tracing::info_span!("receive message",
+ %connection_id,
+ %address,
+ type_name,
+ concurrent_handlers,
user_id=field::Empty,
login=field::Empty,
impersonator=field::Empty,
+ // todo(lsp) remove after Zed Stable hits v0.204.x
+ multi_lsp_query_request=field::Empty,
+ lsp_query_request=field::Empty,
+ release_channel=field::Empty,
+ { TOTAL_DURATION_MS }=field::Empty,
+ { PROCESSING_DURATION_MS }=field::Empty,
+ { QUEUE_DURATION_MS }=field::Empty,
+ { HOST_WAITING_MS }=field::Empty
);
principal.update_span(&span);
let span_enter = span.enter();
if let Some(handler) = this.handlers.get(&message.payload_type_id()) {
let is_background = message.is_background();
- let handle_message = (handler)(message, session.clone());
+ let handle_message = (handler)(message, session.clone(), span.clone());
drop(span_enter);
let handle_message = async move {
@@ -885,12 +949,13 @@ impl Server {
}
drop(foreground_message_handlers);
- tracing::info!("signing out");
+ let concurrent_handlers = get_concurrent_handlers();
+ tracing::info!(concurrent_handlers, "signing out");
if let Err(error) = connection_lost(session, teardown, executor).await {
tracing::error!(?error, "error signing out");
}
-
- }.instrument(span)
+ }
+ .instrument(span)
}
async fn send_initial_client_update(
@@ -921,8 +986,6 @@ impl Server {
.await?;
}
- update_user_plan(session).await?;
-
let contacts = self.app_state.db.get_contacts(user.id).await?;
{
@@ -956,87 +1019,52 @@ impl Server {
inviter_id: UserId,
invitee_id: UserId,
) -> Result<()> {
- if let Some(user) = self.app_state.db.get_user_by_id(inviter_id).await? {
- if let Some(code) = &user.invite_code {
- let pool = self.connection_pool.lock();
- let invitee_contact = contact_for_user(invitee_id, false, &pool);
- for connection_id in pool.user_connection_ids(inviter_id) {
- self.peer.send(
- connection_id,
- proto::UpdateContacts {
- contacts: vec![invitee_contact.clone()],
- ..Default::default()
- },
- )?;
- self.peer.send(
- connection_id,
- proto::UpdateInviteInfo {
- url: format!("{}{}", self.app_state.config.invite_link_prefix, &code),
- count: user.invite_count as u32,
- },
- )?;
- }
+ if let Some(user) = self.app_state.db.get_user_by_id(inviter_id).await?
+ && let Some(code) = &user.invite_code
+ {
+ let pool = self.connection_pool.lock();
+ let invitee_contact = contact_for_user(invitee_id, false, &pool);
+ for connection_id in pool.user_connection_ids(inviter_id) {
+ self.peer.send(
+ connection_id,
+ proto::UpdateContacts {
+ contacts: vec![invitee_contact.clone()],
+ ..Default::default()
+ },
+ )?;
+ self.peer.send(
+ connection_id,
+ proto::UpdateInviteInfo {
+ url: format!("{}{}", self.app_state.config.invite_link_prefix, &code),
+ count: user.invite_count as u32,
+ },
+ )?;
}
}
Ok(())
}
pub async fn invite_count_updated(self: &Arc<Self>, user_id: UserId) -> Result<()> {
- if let Some(user) = self.app_state.db.get_user_by_id(user_id).await? {
- if let Some(invite_code) = &user.invite_code {
- let pool = self.connection_pool.lock();
- for connection_id in pool.user_connection_ids(user_id) {
- self.peer.send(
- connection_id,
- proto::UpdateInviteInfo {
- url: format!(
- "{}{}",
- self.app_state.config.invite_link_prefix, invite_code
- ),
- count: user.invite_count as u32,
- },
- )?;
- }
+ if let Some(user) = self.app_state.db.get_user_by_id(user_id).await?
+ && let Some(invite_code) = &user.invite_code
+ {
+ let pool = self.connection_pool.lock();
+ for connection_id in pool.user_connection_ids(user_id) {
+ self.peer.send(
+ connection_id,
+ proto::UpdateInviteInfo {
+ url: format!(
+ "{}{}",
+ self.app_state.config.invite_link_prefix, invite_code
+ ),
+ count: user.invite_count as u32,
+ },
+ )?;
}
}
Ok(())
}
- pub async fn update_plan_for_user(self: &Arc<Self>, user_id: UserId) -> Result<()> {
- let user = self
- .app_state
- .db
- .get_user_by_id(user_id)
- .await?
- .context("user not found")?;
-
- let update_user_plan = make_update_user_plan_message(
- &user,
- user.admin,
- &self.app_state.db,
- self.app_state.llm_db.clone(),
- )
- .await?;
-
- let pool = self.connection_pool.lock();
- for connection_id in pool.user_connection_ids(user_id) {
- self.peer
- .send(connection_id, update_user_plan.clone())
- .trace_err();
- }
-
- Ok(())
- }
-
- pub async fn refresh_llm_tokens_for_user(self: &Arc<Self>, user_id: UserId) {
- let pool = self.connection_pool.lock();
- for connection_id in pool.user_connection_ids(user_id) {
- self.peer
- .send(connection_id, proto::RefreshLlmToken {})
- .trace_err();
- }
- }
-
pub async fn snapshot(self: &Arc<Self>) -> ServerSnapshot<'_> {
ServerSnapshot {
connection_pool: ConnectionPoolGuard {
@@ -1077,10 +1105,10 @@ fn broadcast<F>(
F: FnMut(ConnectionId) -> anyhow::Result<()>,
{
for receiver_id in receiver_ids {
- if Some(receiver_id) != sender_id {
- if let Err(error) = f(receiver_id) {
- tracing::error!("failed to send to {:?} {}", receiver_id, error);
- }
+ if Some(receiver_id) != sender_id
+ && let Err(error) = f(receiver_id)
+ {
+ tracing::error!("failed to send to {:?} {}", receiver_id, error);
}
}
}
@@ -1140,6 +1168,35 @@ impl Header for AppVersionHeader {
}
}
+#[derive(Debug)]
+pub struct ReleaseChannelHeader(String);
+
+impl Header for ReleaseChannelHeader {
+ fn name() -> &'static HeaderName {
+ static ZED_RELEASE_CHANNEL: OnceLock<HeaderName> = OnceLock::new();
+ ZED_RELEASE_CHANNEL.get_or_init(|| HeaderName::from_static("x-zed-release-channel"))
+ }
+
+ fn decode<'i, I>(values: &mut I) -> Result<Self, axum::headers::Error>
+ where
+ Self: Sized,
+ I: Iterator<Item = &'i axum::http::HeaderValue>,
+ {
+ Ok(Self(
+ values
+ .next()
+ .ok_or_else(axum::headers::Error::invalid)?
+ .to_str()
+ .map_err(|_| axum::headers::Error::invalid())?
+ .to_owned(),
+ ))
+ }
+
+ fn encode<E: Extend<axum::http::HeaderValue>>(&self, values: &mut E) {
+ values.extend([self.0.parse().unwrap()]);
+ }
+}
+
pub fn routes(server: Arc<Server>) -> Router<(), Body> {
Router::new()
.route("/rpc", get(handle_websocket_request))
@@ -1155,9 +1212,11 @@ pub fn routes(server: Arc<Server>) -> Router<(), Body> {
pub async fn handle_websocket_request(
TypedHeader(ProtocolVersion(protocol_version)): TypedHeader<ProtocolVersion>,
app_version_header: Option<TypedHeader<AppVersionHeader>>,
+ release_channel_header: Option<TypedHeader<ReleaseChannelHeader>>,
ConnectInfo(socket_address): ConnectInfo<SocketAddr>,
Extension(server): Extension<Arc<Server>>,
Extension(principal): Extension<Principal>,
+ user_agent: Option<TypedHeader<UserAgent>>,
country_code_header: Option<TypedHeader<CloudflareIpCountryHeader>>,
system_id_header: Option<TypedHeader<SystemIdHeader>>,
ws: WebSocketUpgrade,
@@ -1178,6 +1237,8 @@ pub async fn handle_websocket_request(
.into_response();
};
+ let release_channel = release_channel_header.map(|header| header.0.0);
+
if !version.can_collaborate() {
return (
StatusCode::UPGRADE_REQUIRED,
@@ -1213,6 +1274,8 @@ pub async fn handle_websocket_request(
socket_address,
principal,
version,
+ release_channel,
+ user_agent.map(|header| header.to_string()),
country_code_header.map(|header| header.to_string()),
system_id_header.map(|header| header.to_string()),
None,
@@ -1305,7 +1368,11 @@ async fn connection_lost(
}
/// Acknowledges a ping from a client, used to keep the connection alive.
-async fn ping(_: proto::Ping, response: Response<proto::Ping>, _session: Session) -> Result<()> {
+async fn ping(
+ _: proto::Ping,
+ response: Response<proto::Ping>,
+ _session: MessageContext,
+) -> Result<()> {
response.send(proto::Ack {})?;
Ok(())
}
@@ -1314,7 +1381,7 @@ async fn ping(_: proto::Ping, response: Response<proto::Ping>, _session: Session
async fn create_room(
_request: proto::CreateRoom,
response: Response<proto::CreateRoom>,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let livekit_room = nanoid::nanoid!(30);
@@ -1323,9 +1390,7 @@ async fn create_room(
let live_kit = live_kit?;
let user_id = session.user_id().to_string();
- let token = live_kit
- .room_token(&livekit_room, &user_id.to_string())
- .trace_err()?;
+ let token = live_kit.room_token(&livekit_room, &user_id).trace_err()?;
Some(proto::LiveKitConnectionInfo {
server_url: live_kit.url().into(),
@@ -1354,7 +1419,7 @@ async fn create_room(
async fn join_room(
request: proto::JoinRoom,
response: Response<proto::JoinRoom>,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let room_id = RoomId::from_proto(request.id);
@@ -1421,7 +1486,7 @@ async fn join_room(
async fn rejoin_room(
request: proto::RejoinRoom,
response: Response<proto::RejoinRoom>,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let room;
let channel;
@@ -1549,15 +1614,15 @@ fn notify_rejoined_projects(
}
// Stream this worktree's diagnostics.
- for summary in worktree.diagnostic_summaries {
- session.peer.send(
- session.connection_id,
- proto::UpdateDiagnosticSummary {
- project_id: project.id.to_proto(),
- worktree_id: worktree.id,
- summary: Some(summary),
- },
- )?;
+ let mut worktree_diagnostics = worktree.diagnostic_summaries.into_iter();
+ if let Some(summary) = worktree_diagnostics.next() {
+ let message = proto::UpdateDiagnosticSummary {
+ project_id: project.id.to_proto(),
+ worktree_id: worktree.id,
+ summary: Some(summary),
+ more_summaries: worktree_diagnostics.collect(),
+ };
+ session.peer.send(session.connection_id, message)?;
}
for settings_file in worktree.settings_files {
@@ -1598,7 +1663,7 @@ fn notify_rejoined_projects(
async fn leave_room(
_: proto::LeaveRoom,
response: Response<proto::LeaveRoom>,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
leave_room_for_session(&session, session.connection_id).await?;
response.send(proto::Ack {})?;
@@ -1609,7 +1674,7 @@ async fn leave_room(
async fn set_room_participant_role(
request: proto::SetRoomParticipantRole,
response: Response<proto::SetRoomParticipantRole>,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let user_id = UserId::from_proto(request.user_id);
let role = ChannelRole::from(request.role());
@@ -1657,7 +1722,7 @@ async fn set_room_participant_role(
async fn call(
request: proto::Call,
response: Response<proto::Call>,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let room_id = RoomId::from_proto(request.room_id);
let calling_user_id = session.user_id();
@@ -1726,7 +1791,7 @@ async fn call(
async fn cancel_call(
request: proto::CancelCall,
response: Response<proto::CancelCall>,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let called_user_id = UserId::from_proto(request.called_user_id);
let room_id = RoomId::from_proto(request.room_id);
@@ -1761,7 +1826,7 @@ async fn cancel_call(
}
/// Decline an incoming call.
-async fn decline_call(message: proto::DeclineCall, session: Session) -> Result<()> {
+async fn decline_call(message: proto::DeclineCall, session: MessageContext) -> Result<()> {
let room_id = RoomId::from_proto(message.room_id);
{
let room = session
@@ -1796,7 +1861,7 @@ async fn decline_call(message: proto::DeclineCall, session: Session) -> Result<(
async fn update_participant_location(
request: proto::UpdateParticipantLocation,
response: Response<proto::UpdateParticipantLocation>,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let room_id = RoomId::from_proto(request.room_id);
let location = request.location.context("invalid location")?;
@@ -1815,7 +1880,7 @@ async fn update_participant_location(
async fn share_project(
request: proto::ShareProject,
response: Response<proto::ShareProject>,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let (project_id, room) = &*session
.db()
@@ -1836,7 +1901,7 @@ async fn share_project(
}
/// Unshare a project from the room.
-async fn unshare_project(message: proto::UnshareProject, session: Session) -> Result<()> {
+async fn unshare_project(message: proto::UnshareProject, session: MessageContext) -> Result<()> {
let project_id = ProjectId::from_proto(message.project_id);
unshare_project_internal(project_id, session.connection_id, &session).await
}
@@ -1883,7 +1948,7 @@ async fn unshare_project_internal(
async fn join_project(
request: proto::JoinProject,
response: Response<proto::JoinProject>,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let project_id = ProjectId::from_proto(request.project_id);
@@ -1944,12 +2009,19 @@ async fn join_project(
}
// First, we send the metadata associated with each worktree.
+ let (language_servers, language_server_capabilities) = project
+ .language_servers
+ .clone()
+ .into_iter()
+ .map(|server| (server.server, server.capabilities))
+ .unzip();
response.send(proto::JoinProjectResponse {
project_id: project.id.0 as u64,
- worktrees: worktrees.clone(),
+ worktrees,
replica_id: replica_id.0 as u32,
- collaborators: collaborators.clone(),
- language_servers: project.language_servers.clone(),
+ collaborators,
+ language_servers,
+ language_server_capabilities,
role: project.role.into(),
})?;
@@ -1972,15 +2044,15 @@ async fn join_project(
}
// Stream this worktree's diagnostics.
- for summary in worktree.diagnostic_summaries {
- session.peer.send(
- session.connection_id,
- proto::UpdateDiagnosticSummary {
- project_id: project_id.to_proto(),
- worktree_id: worktree.id,
- summary: Some(summary),
- },
- )?;
+ let mut worktree_diagnostics = worktree.diagnostic_summaries.into_iter();
+ if let Some(summary) = worktree_diagnostics.next() {
+ let message = proto::UpdateDiagnosticSummary {
+ project_id: project.id.to_proto(),
+ worktree_id: worktree.id,
+ summary: Some(summary),
+ more_summaries: worktree_diagnostics.collect(),
+ };
+ session.peer.send(session.connection_id, message)?;
}
for settings_file in worktree.settings_files {
@@ -2008,8 +2080,8 @@ async fn join_project(
session.connection_id,
proto::UpdateLanguageServer {
project_id: project_id.to_proto(),
- server_name: Some(language_server.name.clone()),
- language_server_id: language_server.id,
+ server_name: Some(language_server.server.name.clone()),
+ language_server_id: language_server.server.id,
variant: Some(
proto::update_language_server::Variant::DiskBasedDiagnosticsUpdated(
proto::LspDiskBasedDiagnosticsUpdated {},
@@ -2023,7 +2095,7 @@ async fn join_project(
}
/// Leave someone elses shared project.
-async fn leave_project(request: proto::LeaveProject, session: Session) -> Result<()> {
+async fn leave_project(request: proto::LeaveProject, session: MessageContext) -> Result<()> {
let sender_id = session.connection_id;
let project_id = ProjectId::from_proto(request.project_id);
let db = session.db().await;
@@ -2046,7 +2118,7 @@ async fn leave_project(request: proto::LeaveProject, session: Session) -> Result
async fn update_project(
request: proto::UpdateProject,
response: Response<proto::UpdateProject>,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let project_id = ProjectId::from_proto(request.project_id);
let (room, guest_connection_ids) = &*session
@@ -2075,7 +2147,7 @@ async fn update_project(
async fn update_worktree(
request: proto::UpdateWorktree,
response: Response<proto::UpdateWorktree>,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let guest_connection_ids = session
.db()
@@ -2099,7 +2171,7 @@ async fn update_worktree(
async fn update_repository(
request: proto::UpdateRepository,
response: Response<proto::UpdateRepository>,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let guest_connection_ids = session
.db()
@@ -2123,7 +2195,7 @@ async fn update_repository(
async fn remove_repository(
request: proto::RemoveRepository,
response: Response<proto::RemoveRepository>,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let guest_connection_ids = session
.db()
@@ -2147,7 +2219,7 @@ async fn remove_repository(
/// Updates other participants with changes to the diagnostics
async fn update_diagnostic_summary(
message: proto::UpdateDiagnosticSummary,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let guest_connection_ids = session
.db()
@@ -2171,7 +2243,7 @@ async fn update_diagnostic_summary(
/// Updates other participants with changes to the worktree settings
async fn update_worktree_settings(
message: proto::UpdateWorktreeSettings,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let guest_connection_ids = session
.db()
@@ -2195,7 +2267,7 @@ async fn update_worktree_settings(
/// Notify other participants that a language server has started.
async fn start_language_server(
request: proto::StartLanguageServer,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let guest_connection_ids = session
.db()
@@ -2218,12 +2290,19 @@ async fn start_language_server(
/// Notify other participants that a language server has changed.
async fn update_language_server(
request: proto::UpdateLanguageServer,
- session: Session,
+ session: MessageContext,
) -> Result<()> {
let project_id = ProjectId::from_proto(request.project_id);
- let project_connection_ids = session
- .db()
- .await
+ let db = session.db().await;
+
+ if let Some(proto::update_language_server::Variant::MetadataUpdated(update)) = &request.variant
+ && let Some(capabilities) = update.capabilities.clone()
+ {
+ db.update_server_capabilities(project_id, request.language_server_id, capabilities)
+ .await?;
+ }
+
+ let project_connection_ids = db
.project_connection_ids(project_id, session.connection_id, true)
.await?;
broadcast(
@@ -2243,7 +2322,7 @@ async fn update_language_server(
async fn forward_read_only_project_request<T>(
request: T,
response: Response<T>,
- session: Session,
+ session: MessageContext,
) -> Result<()>
where
T: EntityMessage + RequestMessage,
@@ -2254,29 +2333,7 @@ where
.await
.host_for_read_only_project_request(project_id, session.connection_id)
.await?;
- let payload = session
- .peer
- .forward_request(session.connection_id, host_connection_id, request)
- .await?;
- response.send(payload)?;
- Ok(())
-}
-
-async fn forward_find_search_candidates_request(
- request: proto::FindSearchCandidates,
- response: Response<proto::FindSearchCandidates>,
- session: Session,
-) -> Result<()> {
- let project_id = ProjectId::from_proto(request.remote_entity_id());
- let host_connection_id = session
- .db()
- .await
- .host_for_read_only_project_request(project_id, session.connection_id)
- .await?;
- let payload = session
- .peer
- .forward_request(session.connection_id, host_connection_id, request)
- .await?;
+ let payload = session.forward_request(host_connection_id, request).await?;
response.send(payload)?;
Ok(())
}
@@ -2286,7 +2343,7 @@ async fn forward_find_search_candidates_request(
async fn forward_mutating_project_request<T>(
request: T,
response: Response<T>,
- session: Session,
+ session: MessageContext,
) -> Result<()>
where
T: EntityMessage + RequestMessage,
@@ -30,7 +30,19 @@ impl fmt::Display for ZedVersion {
impl ZedVersion {
pub fn can_collaborate(&self) -> bool {
- self.0 >= SemanticVersion::new(0, 157, 0)
+ // v0.198.4 is the first version where we no longer connect to Collab automatically.
+ // We reject any clients older than that to prevent them from connecting to Collab just for authentication.
+ if self.0 < SemanticVersion::new(0, 198, 4) {
+ return false;
+ }
+
+ // Since we hotfixed the changes to no longer connect to Collab automatically to Preview, we also need to reject
+ // versions in the range [v0.199.0, v0.199.1].
+ if self.0 >= SemanticVersion::new(0, 199, 0) && self.0 < SemanticVersion::new(0, 199, 2) {
+ return false;
+ }
+
+ true
}
}
@@ -1,364 +0,0 @@
-use std::sync::Arc;
-
-use anyhow::{Context as _, anyhow};
-use chrono::Utc;
-use collections::HashMap;
-use stripe::SubscriptionStatus;
-use tokio::sync::RwLock;
-use uuid::Uuid;
-
-use crate::Result;
-use crate::db::billing_subscription::SubscriptionKind;
-use crate::llm::AGENT_EXTENDED_TRIAL_FEATURE_FLAG;
-use crate::stripe_client::{
- RealStripeClient, StripeBillingAddressCollection, StripeCheckoutSessionMode,
- StripeCheckoutSessionPaymentMethodCollection, StripeClient,
- StripeCreateCheckoutSessionLineItems, StripeCreateCheckoutSessionParams,
- StripeCreateCheckoutSessionSubscriptionData, StripeCreateMeterEventParams,
- StripeCreateMeterEventPayload, StripeCreateSubscriptionItems, StripeCreateSubscriptionParams,
- StripeCustomerId, StripeCustomerUpdate, StripeCustomerUpdateAddress, StripeCustomerUpdateName,
- StripeMeter, StripePrice, StripePriceId, StripeSubscription, StripeSubscriptionId,
- StripeSubscriptionTrialSettings, StripeSubscriptionTrialSettingsEndBehavior,
- StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, StripeTaxIdCollection,
- UpdateSubscriptionItems, UpdateSubscriptionParams,
-};
-
-pub struct StripeBilling {
- state: RwLock<StripeBillingState>,
- client: Arc<dyn StripeClient>,
-}
-
-#[derive(Default)]
-struct StripeBillingState {
- meters_by_event_name: HashMap<String, StripeMeter>,
- price_ids_by_meter_id: HashMap<String, StripePriceId>,
- prices_by_lookup_key: HashMap<String, StripePrice>,
-}
-
-impl StripeBilling {
- pub fn new(client: Arc<stripe::Client>) -> Self {
- Self {
- client: Arc::new(RealStripeClient::new(client.clone())),
- state: RwLock::default(),
- }
- }
-
- #[cfg(test)]
- pub fn test(client: Arc<crate::stripe_client::FakeStripeClient>) -> Self {
- Self {
- client,
- state: RwLock::default(),
- }
- }
-
- pub fn client(&self) -> &Arc<dyn StripeClient> {
- &self.client
- }
-
- pub async fn initialize(&self) -> Result<()> {
- log::info!("StripeBilling: initializing");
-
- let mut state = self.state.write().await;
-
- let (meters, prices) =
- futures::try_join!(self.client.list_meters(), self.client.list_prices())?;
-
- for meter in meters {
- state
- .meters_by_event_name
- .insert(meter.event_name.clone(), meter);
- }
-
- for price in prices {
- if let Some(lookup_key) = price.lookup_key.clone() {
- state.prices_by_lookup_key.insert(lookup_key, price.clone());
- }
-
- if let Some(recurring) = price.recurring {
- if let Some(meter) = recurring.meter {
- state.price_ids_by_meter_id.insert(meter, price.id);
- }
- }
- }
-
- log::info!("StripeBilling: initialized");
-
- Ok(())
- }
-
- pub async fn zed_pro_price_id(&self) -> Result<StripePriceId> {
- self.find_price_id_by_lookup_key("zed-pro").await
- }
-
- pub async fn zed_free_price_id(&self) -> Result<StripePriceId> {
- self.find_price_id_by_lookup_key("zed-free").await
- }
-
- pub async fn find_price_id_by_lookup_key(&self, lookup_key: &str) -> Result<StripePriceId> {
- self.state
- .read()
- .await
- .prices_by_lookup_key
- .get(lookup_key)
- .map(|price| price.id.clone())
- .ok_or_else(|| crate::Error::Internal(anyhow!("no price ID found for {lookup_key:?}")))
- }
-
- pub async fn find_price_by_lookup_key(&self, lookup_key: &str) -> Result<StripePrice> {
- self.state
- .read()
- .await
- .prices_by_lookup_key
- .get(lookup_key)
- .cloned()
- .ok_or_else(|| crate::Error::Internal(anyhow!("no price found for {lookup_key:?}")))
- }
-
- pub async fn determine_subscription_kind(
- &self,
- subscription: &StripeSubscription,
- ) -> Option<SubscriptionKind> {
- let zed_pro_price_id = self.zed_pro_price_id().await.ok()?;
- let zed_free_price_id = self.zed_free_price_id().await.ok()?;
-
- subscription.items.iter().find_map(|item| {
- let price = item.price.as_ref()?;
-
- if price.id == zed_pro_price_id {
- Some(if subscription.status == SubscriptionStatus::Trialing {
- SubscriptionKind::ZedProTrial
- } else {
- SubscriptionKind::ZedPro
- })
- } else if price.id == zed_free_price_id {
- Some(SubscriptionKind::ZedFree)
- } else {
- None
- }
- })
- }
-
- /// Returns the Stripe customer associated with the provided email address, or creates a new customer, if one does
- /// not already exist.
- ///
- /// Always returns a new Stripe customer if the email address is `None`.
- pub async fn find_or_create_customer_by_email(
- &self,
- email_address: Option<&str>,
- ) -> Result<StripeCustomerId> {
- let existing_customer = if let Some(email) = email_address {
- let customers = self.client.list_customers_by_email(email).await?;
-
- customers.first().cloned()
- } else {
- None
- };
-
- let customer_id = if let Some(existing_customer) = existing_customer {
- existing_customer.id
- } else {
- let customer = self
- .client
- .create_customer(crate::stripe_client::CreateCustomerParams {
- email: email_address,
- })
- .await?;
-
- customer.id
- };
-
- Ok(customer_id)
- }
-
- pub async fn subscribe_to_price(
- &self,
- subscription_id: &StripeSubscriptionId,
- price: &StripePrice,
- ) -> Result<()> {
- let subscription = self.client.get_subscription(subscription_id).await?;
-
- if subscription_contains_price(&subscription, &price.id) {
- return Ok(());
- }
-
- const BILLING_THRESHOLD_IN_CENTS: i64 = 20 * 100;
-
- let price_per_unit = price.unit_amount.unwrap_or_default();
- let _units_for_billing_threshold = BILLING_THRESHOLD_IN_CENTS / price_per_unit;
-
- self.client
- .update_subscription(
- subscription_id,
- UpdateSubscriptionParams {
- items: Some(vec![UpdateSubscriptionItems {
- price: Some(price.id.clone()),
- }]),
- trial_settings: Some(StripeSubscriptionTrialSettings {
- end_behavior: StripeSubscriptionTrialSettingsEndBehavior {
- missing_payment_method: StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel
- },
- }),
- },
- )
- .await?;
-
- Ok(())
- }
-
- pub async fn bill_model_request_usage(
- &self,
- customer_id: &StripeCustomerId,
- event_name: &str,
- requests: i32,
- ) -> Result<()> {
- let timestamp = Utc::now().timestamp();
- let idempotency_key = Uuid::new_v4();
-
- self.client
- .create_meter_event(StripeCreateMeterEventParams {
- identifier: &format!("model_requests/{}", idempotency_key),
- event_name,
- payload: StripeCreateMeterEventPayload {
- value: requests as u64,
- stripe_customer_id: customer_id,
- },
- timestamp: Some(timestamp),
- })
- .await?;
-
- Ok(())
- }
-
- pub async fn checkout_with_zed_pro(
- &self,
- customer_id: &StripeCustomerId,
- github_login: &str,
- success_url: &str,
- ) -> Result<String> {
- let zed_pro_price_id = self.zed_pro_price_id().await?;
-
- let mut params = StripeCreateCheckoutSessionParams::default();
- params.mode = Some(StripeCheckoutSessionMode::Subscription);
- params.customer = Some(customer_id);
- params.client_reference_id = Some(github_login);
- params.line_items = Some(vec![StripeCreateCheckoutSessionLineItems {
- price: Some(zed_pro_price_id.to_string()),
- quantity: Some(1),
- }]);
- params.success_url = Some(success_url);
- params.billing_address_collection = Some(StripeBillingAddressCollection::Required);
- params.customer_update = Some(StripeCustomerUpdate {
- address: Some(StripeCustomerUpdateAddress::Auto),
- name: Some(StripeCustomerUpdateName::Auto),
- shipping: None,
- });
- params.tax_id_collection = Some(StripeTaxIdCollection { enabled: true });
-
- let session = self.client.create_checkout_session(params).await?;
- Ok(session.url.context("no checkout session URL")?)
- }
-
- pub async fn checkout_with_zed_pro_trial(
- &self,
- customer_id: &StripeCustomerId,
- github_login: &str,
- feature_flags: Vec<String>,
- success_url: &str,
- ) -> Result<String> {
- let zed_pro_price_id = self.zed_pro_price_id().await?;
-
- let eligible_for_extended_trial = feature_flags
- .iter()
- .any(|flag| flag == AGENT_EXTENDED_TRIAL_FEATURE_FLAG);
-
- let trial_period_days = if eligible_for_extended_trial { 60 } else { 14 };
-
- let mut subscription_metadata = std::collections::HashMap::new();
- if eligible_for_extended_trial {
- subscription_metadata.insert(
- "promo_feature_flag".to_string(),
- AGENT_EXTENDED_TRIAL_FEATURE_FLAG.to_string(),
- );
- }
-
- let mut params = StripeCreateCheckoutSessionParams::default();
- params.subscription_data = Some(StripeCreateCheckoutSessionSubscriptionData {
- trial_period_days: Some(trial_period_days),
- trial_settings: Some(StripeSubscriptionTrialSettings {
- end_behavior: StripeSubscriptionTrialSettingsEndBehavior {
- missing_payment_method:
- StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel,
- },
- }),
- metadata: if !subscription_metadata.is_empty() {
- Some(subscription_metadata)
- } else {
- None
- },
- });
- params.mode = Some(StripeCheckoutSessionMode::Subscription);
- params.payment_method_collection =
- Some(StripeCheckoutSessionPaymentMethodCollection::IfRequired);
- params.customer = Some(customer_id);
- params.client_reference_id = Some(github_login);
- params.line_items = Some(vec![StripeCreateCheckoutSessionLineItems {
- price: Some(zed_pro_price_id.to_string()),
- quantity: Some(1),
- }]);
- params.success_url = Some(success_url);
- params.billing_address_collection = Some(StripeBillingAddressCollection::Required);
- params.customer_update = Some(StripeCustomerUpdate {
- address: Some(StripeCustomerUpdateAddress::Auto),
- name: Some(StripeCustomerUpdateName::Auto),
- shipping: None,
- });
- params.tax_id_collection = Some(StripeTaxIdCollection { enabled: true });
-
- let session = self.client.create_checkout_session(params).await?;
- Ok(session.url.context("no checkout session URL")?)
- }
-
- pub async fn subscribe_to_zed_free(
- &self,
- customer_id: StripeCustomerId,
- ) -> Result<StripeSubscription> {
- let zed_free_price_id = self.zed_free_price_id().await?;
-
- let existing_subscriptions = self
- .client
- .list_subscriptions_for_customer(&customer_id)
- .await?;
-
- let existing_active_subscription =
- existing_subscriptions.into_iter().find(|subscription| {
- subscription.status == SubscriptionStatus::Active
- || subscription.status == SubscriptionStatus::Trialing
- });
- if let Some(subscription) = existing_active_subscription {
- return Ok(subscription);
- }
-
- let params = StripeCreateSubscriptionParams {
- customer: customer_id,
- items: vec![StripeCreateSubscriptionItems {
- price: Some(zed_free_price_id),
- quantity: Some(1),
- }],
- };
-
- let subscription = self.client.create_subscription(params).await?;
-
- Ok(subscription)
- }
-}
-
-fn subscription_contains_price(
- subscription: &StripeSubscription,
- price_id: &StripePriceId,
-) -> bool {
- subscription.items.iter().any(|item| {
- item.price
- .as_ref()
- .map_or(false, |price| price.id == *price_id)
- })
-}
@@ -1,279 +0,0 @@
-#[cfg(test)]
-mod fake_stripe_client;
-mod real_stripe_client;
-
-use std::collections::HashMap;
-use std::sync::Arc;
-
-use anyhow::Result;
-use async_trait::async_trait;
-
-#[cfg(test)]
-pub use fake_stripe_client::*;
-pub use real_stripe_client::*;
-use serde::{Deserialize, Serialize};
-
-#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display, Serialize)]
-pub struct StripeCustomerId(pub Arc<str>);
-
-#[derive(Debug, Clone)]
-pub struct StripeCustomer {
- pub id: StripeCustomerId,
- pub email: Option<String>,
-}
-
-#[derive(Debug)]
-pub struct CreateCustomerParams<'a> {
- pub email: Option<&'a str>,
-}
-
-#[derive(Debug)]
-pub struct UpdateCustomerParams<'a> {
- pub email: Option<&'a str>,
-}
-
-#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display)]
-pub struct StripeSubscriptionId(pub Arc<str>);
-
-#[derive(Debug, PartialEq, Clone)]
-pub struct StripeSubscription {
- pub id: StripeSubscriptionId,
- pub customer: StripeCustomerId,
- // TODO: Create our own version of this enum.
- pub status: stripe::SubscriptionStatus,
- pub current_period_end: i64,
- pub current_period_start: i64,
- pub items: Vec<StripeSubscriptionItem>,
- pub cancel_at: Option<i64>,
- pub cancellation_details: Option<StripeCancellationDetails>,
-}
-
-#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display)]
-pub struct StripeSubscriptionItemId(pub Arc<str>);
-
-#[derive(Debug, PartialEq, Clone)]
-pub struct StripeSubscriptionItem {
- pub id: StripeSubscriptionItemId,
- pub price: Option<StripePrice>,
-}
-
-#[derive(Debug, Clone, PartialEq)]
-pub struct StripeCancellationDetails {
- pub reason: Option<StripeCancellationDetailsReason>,
-}
-
-#[derive(Debug, PartialEq, Eq, Clone, Copy)]
-pub enum StripeCancellationDetailsReason {
- CancellationRequested,
- PaymentDisputed,
- PaymentFailed,
-}
-
-#[derive(Debug)]
-pub struct StripeCreateSubscriptionParams {
- pub customer: StripeCustomerId,
- pub items: Vec<StripeCreateSubscriptionItems>,
-}
-
-#[derive(Debug)]
-pub struct StripeCreateSubscriptionItems {
- pub price: Option<StripePriceId>,
- pub quantity: Option<u64>,
-}
-
-#[derive(Debug, Clone)]
-pub struct UpdateSubscriptionParams {
- pub items: Option<Vec<UpdateSubscriptionItems>>,
- pub trial_settings: Option<StripeSubscriptionTrialSettings>,
-}
-
-#[derive(Debug, PartialEq, Clone)]
-pub struct UpdateSubscriptionItems {
- pub price: Option<StripePriceId>,
-}
-
-#[derive(Debug, PartialEq, Clone)]
-pub struct StripeSubscriptionTrialSettings {
- pub end_behavior: StripeSubscriptionTrialSettingsEndBehavior,
-}
-
-#[derive(Debug, PartialEq, Clone)]
-pub struct StripeSubscriptionTrialSettingsEndBehavior {
- pub missing_payment_method: StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod,
-}
-
-#[derive(Debug, PartialEq, Eq, Clone, Copy)]
-pub enum StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod {
- Cancel,
- CreateInvoice,
- Pause,
-}
-
-#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display)]
-pub struct StripePriceId(pub Arc<str>);
-
-#[derive(Debug, PartialEq, Clone)]
-pub struct StripePrice {
- pub id: StripePriceId,
- pub unit_amount: Option<i64>,
- pub lookup_key: Option<String>,
- pub recurring: Option<StripePriceRecurring>,
-}
-
-#[derive(Debug, PartialEq, Clone)]
-pub struct StripePriceRecurring {
- pub meter: Option<String>,
-}
-
-#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display, Deserialize)]
-pub struct StripeMeterId(pub Arc<str>);
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct StripeMeter {
- pub id: StripeMeterId,
- pub event_name: String,
-}
-
-#[derive(Debug, Serialize)]
-pub struct StripeCreateMeterEventParams<'a> {
- pub identifier: &'a str,
- pub event_name: &'a str,
- pub payload: StripeCreateMeterEventPayload<'a>,
- pub timestamp: Option<i64>,
-}
-
-#[derive(Debug, Serialize)]
-pub struct StripeCreateMeterEventPayload<'a> {
- pub value: u64,
- pub stripe_customer_id: &'a StripeCustomerId,
-}
-
-#[derive(Debug, PartialEq, Eq, Clone, Copy)]
-pub enum StripeBillingAddressCollection {
- Auto,
- Required,
-}
-
-#[derive(Debug, PartialEq, Clone)]
-pub struct StripeCustomerUpdate {
- pub address: Option<StripeCustomerUpdateAddress>,
- pub name: Option<StripeCustomerUpdateName>,
- pub shipping: Option<StripeCustomerUpdateShipping>,
-}
-
-#[derive(Debug, PartialEq, Eq, Clone, Copy)]
-pub enum StripeCustomerUpdateAddress {
- Auto,
- Never,
-}
-
-#[derive(Debug, PartialEq, Eq, Clone, Copy)]
-pub enum StripeCustomerUpdateName {
- Auto,
- Never,
-}
-
-#[derive(Debug, PartialEq, Eq, Clone, Copy)]
-pub enum StripeCustomerUpdateShipping {
- Auto,
- Never,
-}
-
-#[derive(Debug, Default)]
-pub struct StripeCreateCheckoutSessionParams<'a> {
- pub customer: Option<&'a StripeCustomerId>,
- pub client_reference_id: Option<&'a str>,
- pub mode: Option<StripeCheckoutSessionMode>,
- pub line_items: Option<Vec<StripeCreateCheckoutSessionLineItems>>,
- pub payment_method_collection: Option<StripeCheckoutSessionPaymentMethodCollection>,
- pub subscription_data: Option<StripeCreateCheckoutSessionSubscriptionData>,
- pub success_url: Option<&'a str>,
- pub billing_address_collection: Option<StripeBillingAddressCollection>,
- pub customer_update: Option<StripeCustomerUpdate>,
- pub tax_id_collection: Option<StripeTaxIdCollection>,
-}
-
-#[derive(Debug, PartialEq, Eq, Clone, Copy)]
-pub enum StripeCheckoutSessionMode {
- Payment,
- Setup,
- Subscription,
-}
-
-#[derive(Debug, PartialEq, Clone)]
-pub struct StripeCreateCheckoutSessionLineItems {
- pub price: Option<String>,
- pub quantity: Option<u64>,
-}
-
-#[derive(Debug, PartialEq, Eq, Clone, Copy)]
-pub enum StripeCheckoutSessionPaymentMethodCollection {
- Always,
- IfRequired,
-}
-
-#[derive(Debug, PartialEq, Clone)]
-pub struct StripeCreateCheckoutSessionSubscriptionData {
- pub metadata: Option<HashMap<String, String>>,
- pub trial_period_days: Option<u32>,
- pub trial_settings: Option<StripeSubscriptionTrialSettings>,
-}
-
-#[derive(Debug, PartialEq, Clone)]
-pub struct StripeTaxIdCollection {
- pub enabled: bool,
-}
-
-#[derive(Debug)]
-pub struct StripeCheckoutSession {
- pub url: Option<String>,
-}
-
-#[async_trait]
-pub trait StripeClient: Send + Sync {
- async fn list_customers_by_email(&self, email: &str) -> Result<Vec<StripeCustomer>>;
-
- async fn get_customer(&self, customer_id: &StripeCustomerId) -> Result<StripeCustomer>;
-
- async fn create_customer(&self, params: CreateCustomerParams<'_>) -> Result<StripeCustomer>;
-
- async fn update_customer(
- &self,
- customer_id: &StripeCustomerId,
- params: UpdateCustomerParams<'_>,
- ) -> Result<StripeCustomer>;
-
- async fn list_subscriptions_for_customer(
- &self,
- customer_id: &StripeCustomerId,
- ) -> Result<Vec<StripeSubscription>>;
-
- async fn get_subscription(
- &self,
- subscription_id: &StripeSubscriptionId,
- ) -> Result<StripeSubscription>;
-
- async fn create_subscription(
- &self,
- params: StripeCreateSubscriptionParams,
- ) -> Result<StripeSubscription>;
-
- async fn update_subscription(
- &self,
- subscription_id: &StripeSubscriptionId,
- params: UpdateSubscriptionParams,
- ) -> Result<()>;
-
- async fn cancel_subscription(&self, subscription_id: &StripeSubscriptionId) -> Result<()>;
-
- async fn list_prices(&self) -> Result<Vec<StripePrice>>;
-
- async fn list_meters(&self) -> Result<Vec<StripeMeter>>;
-
- async fn create_meter_event(&self, params: StripeCreateMeterEventParams<'_>) -> Result<()>;
-
- async fn create_checkout_session(
- &self,
- params: StripeCreateCheckoutSessionParams<'_>,
- ) -> Result<StripeCheckoutSession>;
-}
@@ -1,247 +0,0 @@
-use std::sync::Arc;
-
-use anyhow::{Result, anyhow};
-use async_trait::async_trait;
-use chrono::{Duration, Utc};
-use collections::HashMap;
-use parking_lot::Mutex;
-use uuid::Uuid;
-
-use crate::stripe_client::{
- CreateCustomerParams, StripeBillingAddressCollection, StripeCheckoutSession,
- StripeCheckoutSessionMode, StripeCheckoutSessionPaymentMethodCollection, StripeClient,
- StripeCreateCheckoutSessionLineItems, StripeCreateCheckoutSessionParams,
- StripeCreateCheckoutSessionSubscriptionData, StripeCreateMeterEventParams,
- StripeCreateSubscriptionParams, StripeCustomer, StripeCustomerId, StripeCustomerUpdate,
- StripeMeter, StripeMeterId, StripePrice, StripePriceId, StripeSubscription,
- StripeSubscriptionId, StripeSubscriptionItem, StripeSubscriptionItemId, StripeTaxIdCollection,
- UpdateCustomerParams, UpdateSubscriptionParams,
-};
-
-#[derive(Debug, Clone)]
-pub struct StripeCreateMeterEventCall {
- pub identifier: Arc<str>,
- pub event_name: Arc<str>,
- pub value: u64,
- pub stripe_customer_id: StripeCustomerId,
- pub timestamp: Option<i64>,
-}
-
-#[derive(Debug, Clone)]
-pub struct StripeCreateCheckoutSessionCall {
- pub customer: Option<StripeCustomerId>,
- pub client_reference_id: Option<String>,
- pub mode: Option<StripeCheckoutSessionMode>,
- pub line_items: Option<Vec<StripeCreateCheckoutSessionLineItems>>,
- pub payment_method_collection: Option<StripeCheckoutSessionPaymentMethodCollection>,
- pub subscription_data: Option<StripeCreateCheckoutSessionSubscriptionData>,
- pub success_url: Option<String>,
- pub billing_address_collection: Option<StripeBillingAddressCollection>,
- pub customer_update: Option<StripeCustomerUpdate>,
- pub tax_id_collection: Option<StripeTaxIdCollection>,
-}
-
-pub struct FakeStripeClient {
- pub customers: Arc<Mutex<HashMap<StripeCustomerId, StripeCustomer>>>,
- pub subscriptions: Arc<Mutex<HashMap<StripeSubscriptionId, StripeSubscription>>>,
- pub update_subscription_calls:
- Arc<Mutex<Vec<(StripeSubscriptionId, UpdateSubscriptionParams)>>>,
- pub prices: Arc<Mutex<HashMap<StripePriceId, StripePrice>>>,
- pub meters: Arc<Mutex<HashMap<StripeMeterId, StripeMeter>>>,
- pub create_meter_event_calls: Arc<Mutex<Vec<StripeCreateMeterEventCall>>>,
- pub create_checkout_session_calls: Arc<Mutex<Vec<StripeCreateCheckoutSessionCall>>>,
-}
-
-impl FakeStripeClient {
- pub fn new() -> Self {
- Self {
- customers: Arc::new(Mutex::new(HashMap::default())),
- subscriptions: Arc::new(Mutex::new(HashMap::default())),
- update_subscription_calls: Arc::new(Mutex::new(Vec::new())),
- prices: Arc::new(Mutex::new(HashMap::default())),
- meters: Arc::new(Mutex::new(HashMap::default())),
- create_meter_event_calls: Arc::new(Mutex::new(Vec::new())),
- create_checkout_session_calls: Arc::new(Mutex::new(Vec::new())),
- }
- }
-}
-
-#[async_trait]
-impl StripeClient for FakeStripeClient {
- async fn list_customers_by_email(&self, email: &str) -> Result<Vec<StripeCustomer>> {
- Ok(self
- .customers
- .lock()
- .values()
- .filter(|customer| customer.email.as_deref() == Some(email))
- .cloned()
- .collect())
- }
-
- async fn get_customer(&self, customer_id: &StripeCustomerId) -> Result<StripeCustomer> {
- self.customers
- .lock()
- .get(customer_id)
- .cloned()
- .ok_or_else(|| anyhow!("no customer found for {customer_id:?}"))
- }
-
- async fn create_customer(&self, params: CreateCustomerParams<'_>) -> Result<StripeCustomer> {
- let customer = StripeCustomer {
- id: StripeCustomerId(format!("cus_{}", Uuid::new_v4()).into()),
- email: params.email.map(|email| email.to_string()),
- };
-
- self.customers
- .lock()
- .insert(customer.id.clone(), customer.clone());
-
- Ok(customer)
- }
-
- async fn update_customer(
- &self,
- customer_id: &StripeCustomerId,
- params: UpdateCustomerParams<'_>,
- ) -> Result<StripeCustomer> {
- let mut customers = self.customers.lock();
- if let Some(customer) = customers.get_mut(customer_id) {
- if let Some(email) = params.email {
- customer.email = Some(email.to_string());
- }
- Ok(customer.clone())
- } else {
- Err(anyhow!("no customer found for {customer_id:?}"))
- }
- }
-
- async fn list_subscriptions_for_customer(
- &self,
- customer_id: &StripeCustomerId,
- ) -> Result<Vec<StripeSubscription>> {
- let subscriptions = self
- .subscriptions
- .lock()
- .values()
- .filter(|subscription| subscription.customer == *customer_id)
- .cloned()
- .collect();
-
- Ok(subscriptions)
- }
-
- async fn get_subscription(
- &self,
- subscription_id: &StripeSubscriptionId,
- ) -> Result<StripeSubscription> {
- self.subscriptions
- .lock()
- .get(subscription_id)
- .cloned()
- .ok_or_else(|| anyhow!("no subscription found for {subscription_id:?}"))
- }
-
- async fn create_subscription(
- &self,
- params: StripeCreateSubscriptionParams,
- ) -> Result<StripeSubscription> {
- let now = Utc::now();
-
- let subscription = StripeSubscription {
- id: StripeSubscriptionId(format!("sub_{}", Uuid::new_v4()).into()),
- customer: params.customer,
- status: stripe::SubscriptionStatus::Active,
- current_period_start: now.timestamp(),
- current_period_end: (now + Duration::days(30)).timestamp(),
- items: params
- .items
- .into_iter()
- .map(|item| StripeSubscriptionItem {
- id: StripeSubscriptionItemId(format!("si_{}", Uuid::new_v4()).into()),
- price: item
- .price
- .and_then(|price_id| self.prices.lock().get(&price_id).cloned()),
- })
- .collect(),
- cancel_at: None,
- cancellation_details: None,
- };
-
- self.subscriptions
- .lock()
- .insert(subscription.id.clone(), subscription.clone());
-
- Ok(subscription)
- }
-
- async fn update_subscription(
- &self,
- subscription_id: &StripeSubscriptionId,
- params: UpdateSubscriptionParams,
- ) -> Result<()> {
- let subscription = self.get_subscription(subscription_id).await?;
-
- self.update_subscription_calls
- .lock()
- .push((subscription.id, params));
-
- Ok(())
- }
-
- async fn cancel_subscription(&self, subscription_id: &StripeSubscriptionId) -> Result<()> {
- // TODO: Implement fake subscription cancellation.
- let _ = subscription_id;
-
- Ok(())
- }
-
- async fn list_prices(&self) -> Result<Vec<StripePrice>> {
- let prices = self.prices.lock().values().cloned().collect();
-
- Ok(prices)
- }
-
- async fn list_meters(&self) -> Result<Vec<StripeMeter>> {
- let meters = self.meters.lock().values().cloned().collect();
-
- Ok(meters)
- }
-
- async fn create_meter_event(&self, params: StripeCreateMeterEventParams<'_>) -> Result<()> {
- self.create_meter_event_calls
- .lock()
- .push(StripeCreateMeterEventCall {
- identifier: params.identifier.into(),
- event_name: params.event_name.into(),
- value: params.payload.value,
- stripe_customer_id: params.payload.stripe_customer_id.clone(),
- timestamp: params.timestamp,
- });
-
- Ok(())
- }
-
- async fn create_checkout_session(
- &self,
- params: StripeCreateCheckoutSessionParams<'_>,
- ) -> Result<StripeCheckoutSession> {
- self.create_checkout_session_calls
- .lock()
- .push(StripeCreateCheckoutSessionCall {
- customer: params.customer.cloned(),
- client_reference_id: params.client_reference_id.map(|id| id.to_string()),
- mode: params.mode,
- line_items: params.line_items,
- payment_method_collection: params.payment_method_collection,
- subscription_data: params.subscription_data,
- success_url: params.success_url.map(|url| url.to_string()),
- billing_address_collection: params.billing_address_collection,
- customer_update: params.customer_update,
- tax_id_collection: params.tax_id_collection,
- });
-
- Ok(StripeCheckoutSession {
- url: Some("https://checkout.stripe.com/c/pay/cs_test_1".to_string()),
- })
- }
-}
@@ -1,601 +0,0 @@
-use std::str::FromStr as _;
-use std::sync::Arc;
-
-use anyhow::{Context as _, Result, anyhow};
-use async_trait::async_trait;
-use serde::{Deserialize, Serialize};
-use stripe::{
- CancellationDetails, CancellationDetailsReason, CheckoutSession, CheckoutSessionMode,
- CheckoutSessionPaymentMethodCollection, CreateCheckoutSession, CreateCheckoutSessionLineItems,
- CreateCheckoutSessionSubscriptionData, CreateCheckoutSessionSubscriptionDataTrialSettings,
- CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehavior,
- CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehaviorMissingPaymentMethod,
- CreateCustomer, Customer, CustomerId, ListCustomers, Price, PriceId, Recurring, Subscription,
- SubscriptionId, SubscriptionItem, SubscriptionItemId, UpdateCustomer, UpdateSubscriptionItems,
- UpdateSubscriptionTrialSettings, UpdateSubscriptionTrialSettingsEndBehavior,
- UpdateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod,
-};
-
-use crate::stripe_client::{
- CreateCustomerParams, StripeBillingAddressCollection, StripeCancellationDetails,
- StripeCancellationDetailsReason, StripeCheckoutSession, StripeCheckoutSessionMode,
- StripeCheckoutSessionPaymentMethodCollection, StripeClient,
- StripeCreateCheckoutSessionLineItems, StripeCreateCheckoutSessionParams,
- StripeCreateCheckoutSessionSubscriptionData, StripeCreateMeterEventParams,
- StripeCreateSubscriptionParams, StripeCustomer, StripeCustomerId, StripeCustomerUpdate,
- StripeCustomerUpdateAddress, StripeCustomerUpdateName, StripeCustomerUpdateShipping,
- StripeMeter, StripePrice, StripePriceId, StripePriceRecurring, StripeSubscription,
- StripeSubscriptionId, StripeSubscriptionItem, StripeSubscriptionItemId,
- StripeSubscriptionTrialSettings, StripeSubscriptionTrialSettingsEndBehavior,
- StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, StripeTaxIdCollection,
- UpdateCustomerParams, UpdateSubscriptionParams,
-};
-
-pub struct RealStripeClient {
- client: Arc<stripe::Client>,
-}
-
-impl RealStripeClient {
- pub fn new(client: Arc<stripe::Client>) -> Self {
- Self { client }
- }
-}
-
-#[async_trait]
-impl StripeClient for RealStripeClient {
- async fn list_customers_by_email(&self, email: &str) -> Result<Vec<StripeCustomer>> {
- let response = Customer::list(
- &self.client,
- &ListCustomers {
- email: Some(email),
- ..Default::default()
- },
- )
- .await?;
-
- Ok(response
- .data
- .into_iter()
- .map(StripeCustomer::from)
- .collect())
- }
-
- async fn get_customer(&self, customer_id: &StripeCustomerId) -> Result<StripeCustomer> {
- let customer_id = customer_id.try_into()?;
-
- let customer = Customer::retrieve(&self.client, &customer_id, &[]).await?;
-
- Ok(StripeCustomer::from(customer))
- }
-
- async fn create_customer(&self, params: CreateCustomerParams<'_>) -> Result<StripeCustomer> {
- let customer = Customer::create(
- &self.client,
- CreateCustomer {
- email: params.email,
- ..Default::default()
- },
- )
- .await?;
-
- Ok(StripeCustomer::from(customer))
- }
-
- async fn update_customer(
- &self,
- customer_id: &StripeCustomerId,
- params: UpdateCustomerParams<'_>,
- ) -> Result<StripeCustomer> {
- let customer = Customer::update(
- &self.client,
- &customer_id.try_into()?,
- UpdateCustomer {
- email: params.email,
- ..Default::default()
- },
- )
- .await?;
-
- Ok(StripeCustomer::from(customer))
- }
-
- async fn list_subscriptions_for_customer(
- &self,
- customer_id: &StripeCustomerId,
- ) -> Result<Vec<StripeSubscription>> {
- let customer_id = customer_id.try_into()?;
-
- let subscriptions = stripe::Subscription::list(
- &self.client,
- &stripe::ListSubscriptions {
- customer: Some(customer_id),
- status: None,
- ..Default::default()
- },
- )
- .await?;
-
- Ok(subscriptions
- .data
- .into_iter()
- .map(StripeSubscription::from)
- .collect())
- }
-
- async fn get_subscription(
- &self,
- subscription_id: &StripeSubscriptionId,
- ) -> Result<StripeSubscription> {
- let subscription_id = subscription_id.try_into()?;
-
- let subscription = Subscription::retrieve(&self.client, &subscription_id, &[]).await?;
-
- Ok(StripeSubscription::from(subscription))
- }
-
- async fn create_subscription(
- &self,
- params: StripeCreateSubscriptionParams,
- ) -> Result<StripeSubscription> {
- let customer_id = params.customer.try_into()?;
-
- let mut create_subscription = stripe::CreateSubscription::new(customer_id);
- create_subscription.items = Some(
- params
- .items
- .into_iter()
- .map(|item| stripe::CreateSubscriptionItems {
- price: item.price.map(|price| price.to_string()),
- quantity: item.quantity,
- ..Default::default()
- })
- .collect(),
- );
-
- let subscription = Subscription::create(&self.client, create_subscription).await?;
-
- Ok(StripeSubscription::from(subscription))
- }
-
- async fn update_subscription(
- &self,
- subscription_id: &StripeSubscriptionId,
- params: UpdateSubscriptionParams,
- ) -> Result<()> {
- let subscription_id = subscription_id.try_into()?;
-
- stripe::Subscription::update(
- &self.client,
- &subscription_id,
- stripe::UpdateSubscription {
- items: params.items.map(|items| {
- items
- .into_iter()
- .map(|item| UpdateSubscriptionItems {
- price: item.price.map(|price| price.to_string()),
- ..Default::default()
- })
- .collect()
- }),
- trial_settings: params.trial_settings.map(Into::into),
- ..Default::default()
- },
- )
- .await?;
-
- Ok(())
- }
-
- async fn cancel_subscription(&self, subscription_id: &StripeSubscriptionId) -> Result<()> {
- let subscription_id = subscription_id.try_into()?;
-
- Subscription::cancel(
- &self.client,
- &subscription_id,
- stripe::CancelSubscription {
- invoice_now: None,
- ..Default::default()
- },
- )
- .await?;
-
- Ok(())
- }
-
- async fn list_prices(&self) -> Result<Vec<StripePrice>> {
- let response = stripe::Price::list(
- &self.client,
- &stripe::ListPrices {
- limit: Some(100),
- ..Default::default()
- },
- )
- .await?;
-
- Ok(response.data.into_iter().map(StripePrice::from).collect())
- }
-
- async fn list_meters(&self) -> Result<Vec<StripeMeter>> {
- #[derive(Serialize)]
- struct Params {
- #[serde(skip_serializing_if = "Option::is_none")]
- limit: Option<u64>,
- }
-
- let response = self
- .client
- .get_query::<stripe::List<StripeMeter>, _>(
- "/billing/meters",
- Params { limit: Some(100) },
- )
- .await?;
-
- Ok(response.data)
- }
-
- async fn create_meter_event(&self, params: StripeCreateMeterEventParams<'_>) -> Result<()> {
- #[derive(Deserialize)]
- struct StripeMeterEvent {
- pub identifier: String,
- }
-
- let identifier = params.identifier;
- match self
- .client
- .post_form::<StripeMeterEvent, _>("/billing/meter_events", params)
- .await
- {
- Ok(_event) => Ok(()),
- Err(stripe::StripeError::Stripe(error)) => {
- if error.http_status == 400
- && error
- .message
- .as_ref()
- .map_or(false, |message| message.contains(identifier))
- {
- Ok(())
- } else {
- Err(anyhow!(stripe::StripeError::Stripe(error)))
- }
- }
- Err(error) => Err(anyhow!("failed to create meter event: {error:?}")),
- }
- }
-
- async fn create_checkout_session(
- &self,
- params: StripeCreateCheckoutSessionParams<'_>,
- ) -> Result<StripeCheckoutSession> {
- let params = params.try_into()?;
- let session = CheckoutSession::create(&self.client, params).await?;
-
- Ok(session.into())
- }
-}
-
-impl From<CustomerId> for StripeCustomerId {
- fn from(value: CustomerId) -> Self {
- Self(value.as_str().into())
- }
-}
-
-impl TryFrom<StripeCustomerId> for CustomerId {
- type Error = anyhow::Error;
-
- fn try_from(value: StripeCustomerId) -> Result<Self, Self::Error> {
- Self::from_str(value.0.as_ref()).context("failed to parse Stripe customer ID")
- }
-}
-
-impl TryFrom<&StripeCustomerId> for CustomerId {
- type Error = anyhow::Error;
-
- fn try_from(value: &StripeCustomerId) -> Result<Self, Self::Error> {
- Self::from_str(value.0.as_ref()).context("failed to parse Stripe customer ID")
- }
-}
-
-impl From<Customer> for StripeCustomer {
- fn from(value: Customer) -> Self {
- StripeCustomer {
- id: value.id.into(),
- email: value.email,
- }
- }
-}
-
-impl From<SubscriptionId> for StripeSubscriptionId {
- fn from(value: SubscriptionId) -> Self {
- Self(value.as_str().into())
- }
-}
-
-impl TryFrom<&StripeSubscriptionId> for SubscriptionId {
- type Error = anyhow::Error;
-
- fn try_from(value: &StripeSubscriptionId) -> Result<Self, Self::Error> {
- Self::from_str(value.0.as_ref()).context("failed to parse Stripe subscription ID")
- }
-}
-
-impl From<Subscription> for StripeSubscription {
- fn from(value: Subscription) -> Self {
- Self {
- id: value.id.into(),
- customer: value.customer.id().into(),
- status: value.status,
- current_period_start: value.current_period_start,
- current_period_end: value.current_period_end,
- items: value.items.data.into_iter().map(Into::into).collect(),
- cancel_at: value.cancel_at,
- cancellation_details: value.cancellation_details.map(Into::into),
- }
- }
-}
-
-impl From<CancellationDetails> for StripeCancellationDetails {
- fn from(value: CancellationDetails) -> Self {
- Self {
- reason: value.reason.map(Into::into),
- }
- }
-}
-
-impl From<CancellationDetailsReason> for StripeCancellationDetailsReason {
- fn from(value: CancellationDetailsReason) -> Self {
- match value {
- CancellationDetailsReason::CancellationRequested => Self::CancellationRequested,
- CancellationDetailsReason::PaymentDisputed => Self::PaymentDisputed,
- CancellationDetailsReason::PaymentFailed => Self::PaymentFailed,
- }
- }
-}
-
-impl From<SubscriptionItemId> for StripeSubscriptionItemId {
- fn from(value: SubscriptionItemId) -> Self {
- Self(value.as_str().into())
- }
-}
-
-impl From<SubscriptionItem> for StripeSubscriptionItem {
- fn from(value: SubscriptionItem) -> Self {
- Self {
- id: value.id.into(),
- price: value.price.map(Into::into),
- }
- }
-}
-
-impl From<StripeSubscriptionTrialSettings> for UpdateSubscriptionTrialSettings {
- fn from(value: StripeSubscriptionTrialSettings) -> Self {
- Self {
- end_behavior: value.end_behavior.into(),
- }
- }
-}
-
-impl From<StripeSubscriptionTrialSettingsEndBehavior>
- for UpdateSubscriptionTrialSettingsEndBehavior
-{
- fn from(value: StripeSubscriptionTrialSettingsEndBehavior) -> Self {
- Self {
- missing_payment_method: value.missing_payment_method.into(),
- }
- }
-}
-
-impl From<StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod>
- for UpdateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod
-{
- fn from(value: StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod) -> Self {
- match value {
- StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel => Self::Cancel,
- StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::CreateInvoice => {
- Self::CreateInvoice
- }
- StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Pause => Self::Pause,
- }
- }
-}
-
-impl From<PriceId> for StripePriceId {
- fn from(value: PriceId) -> Self {
- Self(value.as_str().into())
- }
-}
-
-impl TryFrom<StripePriceId> for PriceId {
- type Error = anyhow::Error;
-
- fn try_from(value: StripePriceId) -> Result<Self, Self::Error> {
- Self::from_str(value.0.as_ref()).context("failed to parse Stripe price ID")
- }
-}
-
-impl From<Price> for StripePrice {
- fn from(value: Price) -> Self {
- Self {
- id: value.id.into(),
- unit_amount: value.unit_amount,
- lookup_key: value.lookup_key,
- recurring: value.recurring.map(StripePriceRecurring::from),
- }
- }
-}
-
-impl From<Recurring> for StripePriceRecurring {
- fn from(value: Recurring) -> Self {
- Self { meter: value.meter }
- }
-}
-
-impl<'a> TryFrom<StripeCreateCheckoutSessionParams<'a>> for CreateCheckoutSession<'a> {
- type Error = anyhow::Error;
-
- fn try_from(value: StripeCreateCheckoutSessionParams<'a>) -> Result<Self, Self::Error> {
- Ok(Self {
- customer: value
- .customer
- .map(|customer_id| customer_id.try_into())
- .transpose()?,
- client_reference_id: value.client_reference_id,
- mode: value.mode.map(Into::into),
- line_items: value
- .line_items
- .map(|line_items| line_items.into_iter().map(Into::into).collect()),
- payment_method_collection: value.payment_method_collection.map(Into::into),
- subscription_data: value.subscription_data.map(Into::into),
- success_url: value.success_url,
- billing_address_collection: value.billing_address_collection.map(Into::into),
- customer_update: value.customer_update.map(Into::into),
- tax_id_collection: value.tax_id_collection.map(Into::into),
- ..Default::default()
- })
- }
-}
-
-impl From<StripeCheckoutSessionMode> for CheckoutSessionMode {
- fn from(value: StripeCheckoutSessionMode) -> Self {
- match value {
- StripeCheckoutSessionMode::Payment => Self::Payment,
- StripeCheckoutSessionMode::Setup => Self::Setup,
- StripeCheckoutSessionMode::Subscription => Self::Subscription,
- }
- }
-}
-
-impl From<StripeCreateCheckoutSessionLineItems> for CreateCheckoutSessionLineItems {
- fn from(value: StripeCreateCheckoutSessionLineItems) -> Self {
- Self {
- price: value.price,
- quantity: value.quantity,
- ..Default::default()
- }
- }
-}
-
-impl From<StripeCheckoutSessionPaymentMethodCollection> for CheckoutSessionPaymentMethodCollection {
- fn from(value: StripeCheckoutSessionPaymentMethodCollection) -> Self {
- match value {
- StripeCheckoutSessionPaymentMethodCollection::Always => Self::Always,
- StripeCheckoutSessionPaymentMethodCollection::IfRequired => Self::IfRequired,
- }
- }
-}
-
-impl From<StripeCreateCheckoutSessionSubscriptionData> for CreateCheckoutSessionSubscriptionData {
- fn from(value: StripeCreateCheckoutSessionSubscriptionData) -> Self {
- Self {
- trial_period_days: value.trial_period_days,
- trial_settings: value.trial_settings.map(Into::into),
- metadata: value.metadata,
- ..Default::default()
- }
- }
-}
-
-impl From<StripeSubscriptionTrialSettings> for CreateCheckoutSessionSubscriptionDataTrialSettings {
- fn from(value: StripeSubscriptionTrialSettings) -> Self {
- Self {
- end_behavior: value.end_behavior.into(),
- }
- }
-}
-
-impl From<StripeSubscriptionTrialSettingsEndBehavior>
- for CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehavior
-{
- fn from(value: StripeSubscriptionTrialSettingsEndBehavior) -> Self {
- Self {
- missing_payment_method: value.missing_payment_method.into(),
- }
- }
-}
-
-impl From<StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod>
- for CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehaviorMissingPaymentMethod
-{
- fn from(value: StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod) -> Self {
- match value {
- StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel => Self::Cancel,
- StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::CreateInvoice => {
- Self::CreateInvoice
- }
- StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Pause => Self::Pause,
- }
- }
-}
-
-impl From<CheckoutSession> for StripeCheckoutSession {
- fn from(value: CheckoutSession) -> Self {
- Self { url: value.url }
- }
-}
-
-impl From<StripeBillingAddressCollection> for stripe::CheckoutSessionBillingAddressCollection {
- fn from(value: StripeBillingAddressCollection) -> Self {
- match value {
- StripeBillingAddressCollection::Auto => {
- stripe::CheckoutSessionBillingAddressCollection::Auto
- }
- StripeBillingAddressCollection::Required => {
- stripe::CheckoutSessionBillingAddressCollection::Required
- }
- }
- }
-}
-
-impl From<StripeCustomerUpdateAddress> for stripe::CreateCheckoutSessionCustomerUpdateAddress {
- fn from(value: StripeCustomerUpdateAddress) -> Self {
- match value {
- StripeCustomerUpdateAddress::Auto => {
- stripe::CreateCheckoutSessionCustomerUpdateAddress::Auto
- }
- StripeCustomerUpdateAddress::Never => {
- stripe::CreateCheckoutSessionCustomerUpdateAddress::Never
- }
- }
- }
-}
-
-impl From<StripeCustomerUpdateName> for stripe::CreateCheckoutSessionCustomerUpdateName {
- fn from(value: StripeCustomerUpdateName) -> Self {
- match value {
- StripeCustomerUpdateName::Auto => stripe::CreateCheckoutSessionCustomerUpdateName::Auto,
- StripeCustomerUpdateName::Never => {
- stripe::CreateCheckoutSessionCustomerUpdateName::Never
- }
- }
- }
-}
-
-impl From<StripeCustomerUpdateShipping> for stripe::CreateCheckoutSessionCustomerUpdateShipping {
- fn from(value: StripeCustomerUpdateShipping) -> Self {
- match value {
- StripeCustomerUpdateShipping::Auto => {
- stripe::CreateCheckoutSessionCustomerUpdateShipping::Auto
- }
- StripeCustomerUpdateShipping::Never => {
- stripe::CreateCheckoutSessionCustomerUpdateShipping::Never
- }
- }
- }
-}
-
-impl From<StripeCustomerUpdate> for stripe::CreateCheckoutSessionCustomerUpdate {
- fn from(value: StripeCustomerUpdate) -> Self {
- stripe::CreateCheckoutSessionCustomerUpdate {
- address: value.address.map(Into::into),
- name: value.name.map(Into::into),
- shipping: value.shipping.map(Into::into),
- }
- }
-}
-
-impl From<StripeTaxIdCollection> for stripe::CreateCheckoutSessionTaxIdCollection {
- fn from(value: StripeTaxIdCollection) -> Self {
- stripe::CreateCheckoutSessionTaxIdCollection {
- enabled: value.enabled,
- }
- }
-}
@@ -8,7 +8,6 @@ mod channel_buffer_tests;
mod channel_guest_tests;
mod channel_message_tests;
mod channel_tests;
-// mod debug_panel_tests;
mod editor_tests;
mod following_tests;
mod git_tests;
@@ -18,7 +17,6 @@ mod random_channel_buffer_tests;
mod random_project_collaboration_tests;
mod randomized_test_helpers;
mod remote_editing_collaboration_tests;
-mod stripe_billing_tests;
mod test_server;
use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
@@ -38,12 +36,12 @@ fn room_participants(room: &Entity<Room>, cx: &mut TestAppContext) -> RoomPartic
let mut remote = room
.remote_participants()
.values()
- .map(|participant| participant.user.github_login.clone())
+ .map(|participant| participant.user.github_login.clone().to_string())
.collect::<Vec<_>>();
let mut pending = room
.pending_participants()
.iter()
- .map(|user| user.github_login.clone())
+ .map(|user| user.github_login.clone().to_string())
.collect::<Vec<_>>();
remote.sort();
pending.sort();
@@ -15,19 +15,17 @@ use editor::{
},
};
use fs::Fs;
-use futures::{StreamExt, lock::Mutex};
+use futures::{SinkExt, StreamExt, channel::mpsc, lock::Mutex};
use gpui::{App, Rgba, TestAppContext, UpdateGlobal, VisualContext, VisualTestContext};
use indoc::indoc;
use language::{
FakeLspAdapter,
language_settings::{AllLanguageSettings, InlayHintSettings},
};
+use lsp::LSP_REQUEST_TIMEOUT;
use project::{
ProjectPath, SERVER_PROGRESS_THROTTLE_TIMEOUT,
- lsp_store::{
- lsp_ext_command::{ExpandedMacro, LspExtExpandMacro},
- rust_analyzer_ext::RUST_ANALYZER_NAME,
- },
+ lsp_store::lsp_ext_command::{ExpandedMacro, LspExtExpandMacro},
project_settings::{InlineBlameSettings, ProjectSettings},
};
use recent_projects::disconnected_overlay::DisconnectedOverlay;
@@ -296,19 +294,28 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu
.await;
let active_call_a = cx_a.read(ActiveCall::global);
+ let capabilities = lsp::ServerCapabilities {
+ completion_provider: Some(lsp::CompletionOptions {
+ trigger_characters: Some(vec![".".to_string()]),
+ resolve_provider: Some(true),
+ ..lsp::CompletionOptions::default()
+ }),
+ ..lsp::ServerCapabilities::default()
+ };
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
- capabilities: lsp::ServerCapabilities {
- completion_provider: Some(lsp::CompletionOptions {
- trigger_characters: Some(vec![".".to_string()]),
- resolve_provider: Some(true),
- ..Default::default()
- }),
- ..Default::default()
- },
- ..Default::default()
+ capabilities: capabilities.clone(),
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ capabilities,
+ ..FakeLspAdapter::default()
},
);
@@ -566,11 +573,14 @@ async fn test_collaborating_with_code_actions(
cx_b.update(editor::init);
- // Set up a fake language server.
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a
.language_registry()
.register_fake_lsp("Rust", FakeLspAdapter::default());
+ client_b.language_registry().add(rust_lang());
+ client_b
+ .language_registry()
+ .register_fake_lsp("Rust", FakeLspAdapter::default());
client_a
.fs()
@@ -775,19 +785,27 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T
cx_b.update(editor::init);
- // Set up a fake language server.
+ let capabilities = lsp::ServerCapabilities {
+ rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions {
+ prepare_provider: Some(true),
+ work_done_progress_options: Default::default(),
+ })),
+ ..lsp::ServerCapabilities::default()
+ };
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
- capabilities: lsp::ServerCapabilities {
- rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions {
- prepare_provider: Some(true),
- work_done_progress_options: Default::default(),
- })),
- ..Default::default()
- },
- ..Default::default()
+ capabilities: capabilities.clone(),
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ capabilities,
+ ..FakeLspAdapter::default()
},
);
@@ -818,6 +836,8 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T
.downcast::<Editor>()
.unwrap();
let fake_language_server = fake_language_servers.next().await.unwrap();
+ cx_a.run_until_parked();
+ cx_b.run_until_parked();
// Move cursor to a location that can be renamed.
let prepare_rename = editor_b.update_in(cx_b, |editor, window, cx| {
@@ -998,6 +1018,211 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T
})
}
+#[gpui::test]
+async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
+ let mut server = TestServer::start(cx_a.executor()).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);
+ cx_b.update(editor::init);
+
+ let command_name = "test_command";
+ let capabilities = lsp::ServerCapabilities {
+ code_lens_provider: Some(lsp::CodeLensOptions {
+ resolve_provider: None,
+ }),
+ execute_command_provider: Some(lsp::ExecuteCommandOptions {
+ commands: vec![command_name.to_string()],
+ ..lsp::ExecuteCommandOptions::default()
+ }),
+ ..lsp::ServerCapabilities::default()
+ };
+ client_a.language_registry().add(rust_lang());
+ let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
+ "Rust",
+ FakeLspAdapter {
+ capabilities: capabilities.clone(),
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ capabilities,
+ ..FakeLspAdapter::default()
+ },
+ );
+
+ client_a
+ .fs()
+ .insert_tree(
+ path!("/dir"),
+ json!({
+ "one.rs": "const ONE: usize = 1;"
+ }),
+ )
+ .await;
+ let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await;
+ let project_id = active_call_a
+ .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+ .await
+ .unwrap();
+ let project_b = client_b.join_remote_project(project_id, cx_b).await;
+
+ let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
+ let editor_b = workspace_b
+ .update_in(cx_b, |workspace, window, cx| {
+ workspace.open_path((worktree_id, "one.rs"), None, true, window, cx)
+ })
+ .await
+ .unwrap()
+ .downcast::<Editor>()
+ .unwrap();
+ let (lsp_store_b, buffer_b) = editor_b.update(cx_b, |editor, cx| {
+ let lsp_store = editor.project().unwrap().read(cx).lsp_store();
+ let buffer = editor.buffer().read(cx).as_singleton().unwrap();
+ (lsp_store, buffer)
+ });
+ let fake_language_server = fake_language_servers.next().await.unwrap();
+ cx_a.run_until_parked();
+ cx_b.run_until_parked();
+
+ let long_request_time = LSP_REQUEST_TIMEOUT / 2;
+ let (request_started_tx, mut request_started_rx) = mpsc::unbounded();
+ let requests_started = Arc::new(AtomicUsize::new(0));
+ let requests_completed = Arc::new(AtomicUsize::new(0));
+ let _lens_requests = fake_language_server
+ .set_request_handler::<lsp::request::CodeLensRequest, _, _>({
+ let request_started_tx = request_started_tx.clone();
+ let requests_started = requests_started.clone();
+ let requests_completed = requests_completed.clone();
+ move |params, cx| {
+ let mut request_started_tx = request_started_tx.clone();
+ let requests_started = requests_started.clone();
+ let requests_completed = requests_completed.clone();
+ async move {
+ assert_eq!(
+ params.text_document.uri.as_str(),
+ uri!("file:///dir/one.rs")
+ );
+ requests_started.fetch_add(1, atomic::Ordering::Release);
+ request_started_tx.send(()).await.unwrap();
+ cx.background_executor().timer(long_request_time).await;
+ let i = requests_completed.fetch_add(1, atomic::Ordering::Release) + 1;
+ Ok(Some(vec![lsp::CodeLens {
+ range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 9)),
+ command: Some(lsp::Command {
+ title: format!("LSP Command {i}"),
+ command: command_name.to_string(),
+ arguments: None,
+ }),
+ data: None,
+ }]))
+ }
+ }
+ });
+
+ // Move cursor to a location, this should trigger the code lens call.
+ editor_b.update_in(cx_b, |editor, window, cx| {
+ editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+ s.select_ranges([7..7])
+ });
+ });
+ let () = request_started_rx.next().await.unwrap();
+ assert_eq!(
+ requests_started.load(atomic::Ordering::Acquire),
+ 1,
+ "Selection change should have initiated the first request"
+ );
+ assert_eq!(
+ requests_completed.load(atomic::Ordering::Acquire),
+ 0,
+ "Slow requests should be running still"
+ );
+ let _first_task = lsp_store_b.update(cx_b, |lsp_store, cx| {
+ lsp_store
+ .forget_code_lens_task(buffer_b.read(cx).remote_id())
+ .expect("Should have the fetch task started")
+ });
+
+ editor_b.update_in(cx_b, |editor, window, cx| {
+ editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+ s.select_ranges([1..1])
+ });
+ });
+ let () = request_started_rx.next().await.unwrap();
+ assert_eq!(
+ requests_started.load(atomic::Ordering::Acquire),
+ 2,
+ "Selection change should have initiated the second request"
+ );
+ assert_eq!(
+ requests_completed.load(atomic::Ordering::Acquire),
+ 0,
+ "Slow requests should be running still"
+ );
+ let _second_task = lsp_store_b.update(cx_b, |lsp_store, cx| {
+ lsp_store
+ .forget_code_lens_task(buffer_b.read(cx).remote_id())
+ .expect("Should have the fetch task started for the 2nd time")
+ });
+
+ editor_b.update_in(cx_b, |editor, window, cx| {
+ editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+ s.select_ranges([2..2])
+ });
+ });
+ let () = request_started_rx.next().await.unwrap();
+ assert_eq!(
+ requests_started.load(atomic::Ordering::Acquire),
+ 3,
+ "Selection change should have initiated the third request"
+ );
+ assert_eq!(
+ requests_completed.load(atomic::Ordering::Acquire),
+ 0,
+ "Slow requests should be running still"
+ );
+
+ _first_task.await.unwrap();
+ _second_task.await.unwrap();
+ cx_b.run_until_parked();
+ assert_eq!(
+ requests_started.load(atomic::Ordering::Acquire),
+ 3,
+ "No selection changes should trigger no more code lens requests"
+ );
+ assert_eq!(
+ requests_completed.load(atomic::Ordering::Acquire),
+ 3,
+ "After enough time, all 3 LSP requests should have been served by the language server"
+ );
+ let resulting_lens_actions = editor_b
+ .update(cx_b, |editor, cx| {
+ let lsp_store = editor.project().unwrap().read(cx).lsp_store();
+ lsp_store.update(cx, |lsp_store, cx| {
+ lsp_store.code_lens_actions(&buffer_b, cx)
+ })
+ })
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ resulting_lens_actions.len(),
+ 1,
+ "Should have fetched one code lens action, but got: {resulting_lens_actions:?}"
+ );
+ assert_eq!(
+ resulting_lens_actions.first().unwrap().lsp_action.title(),
+ "LSP Command 3",
+ "Only the final code lens action should be in the data"
+ )
+}
+
#[gpui::test(iterations = 10)]
async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
@@ -1055,7 +1280,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes
project_a.read_with(cx_a, |project, cx| {
let status = project.language_server_statuses(cx).next().unwrap().1;
- assert_eq!(status.name, "the-language-server");
+ assert_eq!(status.name.0, "the-language-server");
assert_eq!(status.pending_work.len(), 1);
assert_eq!(
status.pending_work["the-token"].message.as_ref().unwrap(),
@@ -1072,7 +1297,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes
project_b.read_with(cx_b, |project, cx| {
let status = project.language_server_statuses(cx).next().unwrap().1;
- assert_eq!(status.name, "the-language-server");
+ assert_eq!(status.name.0, "the-language-server");
});
executor.advance_clock(SERVER_PROGRESS_THROTTLE_TIMEOUT);
@@ -1089,7 +1314,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes
project_a.read_with(cx_a, |project, cx| {
let status = project.language_server_statuses(cx).next().unwrap().1;
- assert_eq!(status.name, "the-language-server");
+ assert_eq!(status.name.0, "the-language-server");
assert_eq!(status.pending_work.len(), 1);
assert_eq!(
status.pending_work["the-token"].message.as_ref().unwrap(),
@@ -1099,7 +1324,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes
project_b.read_with(cx_b, |project, cx| {
let status = project.language_server_statuses(cx).next().unwrap().1;
- assert_eq!(status.name, "the-language-server");
+ assert_eq!(status.name.0, "the-language-server");
assert_eq!(status.pending_work.len(), 1);
assert_eq!(
status.pending_work["the-token"].message.as_ref().unwrap(),
@@ -1422,18 +1647,27 @@ async fn test_on_input_format_from_guest_to_host(
.await;
let active_call_a = cx_a.read(ActiveCall::global);
+ let capabilities = lsp::ServerCapabilities {
+ document_on_type_formatting_provider: Some(lsp::DocumentOnTypeFormattingOptions {
+ first_trigger_character: ":".to_string(),
+ more_trigger_character: Some(vec![">".to_string()]),
+ }),
+ ..lsp::ServerCapabilities::default()
+ };
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
- capabilities: lsp::ServerCapabilities {
- document_on_type_formatting_provider: Some(lsp::DocumentOnTypeFormattingOptions {
- first_trigger_character: ":".to_string(),
- more_trigger_character: Some(vec![">".to_string()]),
- }),
- ..Default::default()
- },
- ..Default::default()
+ capabilities: capabilities.clone(),
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ capabilities,
+ ..FakeLspAdapter::default()
},
);
@@ -1588,16 +1822,24 @@ async fn test_mutual_editor_inlay_hint_cache_update(
});
});
+ let capabilities = lsp::ServerCapabilities {
+ inlay_hint_provider: Some(lsp::OneOf::Left(true)),
+ ..lsp::ServerCapabilities::default()
+ };
client_a.language_registry().add(rust_lang());
- client_b.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
- capabilities: lsp::ServerCapabilities {
- inlay_hint_provider: Some(lsp::OneOf::Left(true)),
- ..Default::default()
- },
- ..Default::default()
+ capabilities: capabilities.clone(),
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ capabilities,
+ ..FakeLspAdapter::default()
},
);
@@ -1830,16 +2072,24 @@ async fn test_inlay_hint_refresh_is_forwarded(
});
});
+ let capabilities = lsp::ServerCapabilities {
+ inlay_hint_provider: Some(lsp::OneOf::Left(true)),
+ ..lsp::ServerCapabilities::default()
+ };
client_a.language_registry().add(rust_lang());
- client_b.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
- capabilities: lsp::ServerCapabilities {
- inlay_hint_provider: Some(lsp::OneOf::Left(true)),
- ..Default::default()
- },
- ..Default::default()
+ capabilities: capabilities.clone(),
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ capabilities,
+ ..FakeLspAdapter::default()
},
);
@@ -2004,15 +2254,23 @@ async fn test_lsp_document_color(cx_a: &mut TestAppContext, cx_b: &mut TestAppCo
});
});
+ let capabilities = lsp::ServerCapabilities {
+ color_provider: Some(lsp::ColorProviderCapability::Simple(true)),
+ ..lsp::ServerCapabilities::default()
+ };
client_a.language_registry().add(rust_lang());
- client_b.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
- capabilities: lsp::ServerCapabilities {
- color_provider: Some(lsp::ColorProviderCapability::Simple(true)),
- ..lsp::ServerCapabilities::default()
- },
+ capabilities: capabilities.clone(),
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ capabilities,
..FakeLspAdapter::default()
},
);
@@ -2063,6 +2321,8 @@ async fn test_lsp_document_color(cx_a: &mut TestAppContext, cx_b: &mut TestAppCo
.unwrap();
let fake_language_server = fake_language_servers.next().await.unwrap();
+ cx_a.run_until_parked();
+ cx_b.run_until_parked();
let requests_made = Arc::new(AtomicUsize::new(0));
let closure_requests_made = Arc::clone(&requests_made);
@@ -2246,8 +2506,11 @@ async fn test_lsp_document_color(cx_a: &mut TestAppContext, cx_b: &mut TestAppCo
});
}
-#[gpui::test(iterations = 10)]
-async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
+async fn test_lsp_pull_diagnostics(
+ should_stream_workspace_diagnostic: bool,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
@@ -2261,24 +2524,32 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
cx_a.update(editor::init);
cx_b.update(editor::init);
+ let capabilities = lsp::ServerCapabilities {
+ diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options(
+ lsp::DiagnosticOptions {
+ identifier: Some("test-pulls".to_string()),
+ inter_file_dependencies: true,
+ workspace_diagnostics: true,
+ work_done_progress_options: lsp::WorkDoneProgressOptions {
+ work_done_progress: None,
+ },
+ },
+ )),
+ ..lsp::ServerCapabilities::default()
+ };
client_a.language_registry().add(rust_lang());
- client_b.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
- capabilities: lsp::ServerCapabilities {
- diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options(
- lsp::DiagnosticOptions {
- identifier: Some("test-pulls".to_string()),
- inter_file_dependencies: true,
- workspace_diagnostics: true,
- work_done_progress_options: lsp::WorkDoneProgressOptions {
- work_done_progress: None,
- },
- },
- )),
- ..lsp::ServerCapabilities::default()
- },
+ capabilities: capabilities.clone(),
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ capabilities,
..FakeLspAdapter::default()
},
);
@@ -2331,6 +2602,8 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
.unwrap();
let fake_language_server = fake_language_servers.next().await.unwrap();
+ cx_a.run_until_parked();
+ cx_b.run_until_parked();
let expected_push_diagnostic_main_message = "pushed main diagnostic";
let expected_push_diagnostic_lib_message = "pushed lib diagnostic";
let expected_pull_diagnostic_main_message = "pulled main diagnostic";
@@ -2396,12 +2669,25 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
let closure_workspace_diagnostics_pulls_made = workspace_diagnostics_pulls_made.clone();
let closure_workspace_diagnostics_pulls_result_ids =
workspace_diagnostics_pulls_result_ids.clone();
+ let (workspace_diagnostic_cancel_tx, closure_workspace_diagnostic_cancel_rx) =
+ smol::channel::bounded::<()>(1);
+ let (closure_workspace_diagnostic_received_tx, workspace_diagnostic_received_rx) =
+ smol::channel::bounded::<()>(1);
+ let expected_workspace_diagnostic_token = lsp::ProgressToken::String(format!(
+ "workspace/diagnostic-{}-1",
+ fake_language_server.server.server_id()
+ ));
+ let closure_expected_workspace_diagnostic_token = expected_workspace_diagnostic_token.clone();
let mut workspace_diagnostics_pulls_handle = fake_language_server
.set_request_handler::<lsp::request::WorkspaceDiagnosticRequest, _, _>(
move |params, _| {
let workspace_requests_made = closure_workspace_diagnostics_pulls_made.clone();
let workspace_diagnostics_pulls_result_ids =
closure_workspace_diagnostics_pulls_result_ids.clone();
+ let workspace_diagnostic_cancel_rx = closure_workspace_diagnostic_cancel_rx.clone();
+ let workspace_diagnostic_received_tx = closure_workspace_diagnostic_received_tx.clone();
+ let expected_workspace_diagnostic_token =
+ closure_expected_workspace_diagnostic_token.clone();
async move {
let workspace_request_count =
workspace_requests_made.fetch_add(1, atomic::Ordering::Release) + 1;
@@ -2411,6 +2697,21 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
.await
.extend(params.previous_result_ids.into_iter().map(|id| id.value));
}
+ if should_stream_workspace_diagnostic && !workspace_diagnostic_cancel_rx.is_closed()
+ {
+ assert_eq!(
+ params.partial_result_params.partial_result_token,
+ Some(expected_workspace_diagnostic_token)
+ );
+ workspace_diagnostic_received_tx.send(()).await.unwrap();
+ workspace_diagnostic_cancel_rx.recv().await.unwrap();
+ workspace_diagnostic_cancel_rx.close();
+ // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#partialResults
+ // > The final response has to be empty in terms of result values.
+ return Ok(lsp::WorkspaceDiagnosticReportResult::Report(
+ lsp::WorkspaceDiagnosticReport { items: Vec::new() },
+ ));
+ }
Ok(lsp::WorkspaceDiagnosticReportResult::Report(
lsp::WorkspaceDiagnosticReport {
items: vec![
@@ -2479,7 +2780,11 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
},
);
- workspace_diagnostics_pulls_handle.next().await.unwrap();
+ if should_stream_workspace_diagnostic {
+ workspace_diagnostic_received_rx.recv().await.unwrap();
+ } else {
+ workspace_diagnostics_pulls_handle.next().await.unwrap();
+ }
assert_eq!(
1,
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
@@ -2503,10 +2808,10 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
"Expected single diagnostic, but got: {all_diagnostics:?}"
);
let diagnostic = &all_diagnostics[0];
- let expected_messages = [
- expected_workspace_pull_diagnostics_main_message,
- expected_pull_diagnostic_main_message,
- ];
+ let mut expected_messages = vec![expected_pull_diagnostic_main_message];
+ if !should_stream_workspace_diagnostic {
+ expected_messages.push(expected_workspace_pull_diagnostics_main_message);
+ }
assert!(
expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
"Expected {expected_messages:?} on the host, but got: {}",
@@ -2556,6 +2861,70 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
version: None,
},
);
+
+ if should_stream_workspace_diagnostic {
+ fake_language_server.notify::<lsp::notification::Progress>(&lsp::ProgressParams {
+ token: expected_workspace_diagnostic_token.clone(),
+ value: lsp::ProgressParamsValue::WorkspaceDiagnostic(
+ lsp::WorkspaceDiagnosticReportResult::Report(lsp::WorkspaceDiagnosticReport {
+ items: vec![
+ lsp::WorkspaceDocumentDiagnosticReport::Full(
+ lsp::WorkspaceFullDocumentDiagnosticReport {
+ uri: lsp::Url::from_file_path(path!("/a/main.rs")).unwrap(),
+ version: None,
+ full_document_diagnostic_report:
+ lsp::FullDocumentDiagnosticReport {
+ result_id: Some(format!(
+ "workspace_{}",
+ workspace_diagnostics_pulls_made
+ .fetch_add(1, atomic::Ordering::Release)
+ + 1
+ )),
+ items: vec![lsp::Diagnostic {
+ range: lsp::Range {
+ start: lsp::Position {
+ line: 0,
+ character: 1,
+ },
+ end: lsp::Position {
+ line: 0,
+ character: 2,
+ },
+ },
+ severity: Some(lsp::DiagnosticSeverity::ERROR),
+ message:
+ expected_workspace_pull_diagnostics_main_message
+ .to_string(),
+ ..lsp::Diagnostic::default()
+ }],
+ },
+ },
+ ),
+ lsp::WorkspaceDocumentDiagnosticReport::Full(
+ lsp::WorkspaceFullDocumentDiagnosticReport {
+ uri: lsp::Url::from_file_path(path!("/a/lib.rs")).unwrap(),
+ version: None,
+ full_document_diagnostic_report:
+ lsp::FullDocumentDiagnosticReport {
+ result_id: Some(format!(
+ "workspace_{}",
+ workspace_diagnostics_pulls_made
+ .fetch_add(1, atomic::Ordering::Release)
+ + 1
+ )),
+ items: Vec::new(),
+ },
+ },
+ ),
+ ],
+ }),
+ ),
+ });
+ };
+
+ let mut workspace_diagnostic_start_count =
+ workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire);
+
executor.run_until_parked();
editor_a_main.update(cx_a, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
@@ -2590,6 +2959,7 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
.unwrap()
.downcast::<Editor>()
.unwrap();
+ cx_b.run_until_parked();
pull_diagnostics_handle.next().await.unwrap();
assert_eq!(
@@ -2599,7 +2969,7 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
);
executor.run_until_parked();
assert_eq!(
- 1,
+ workspace_diagnostic_start_count,
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"Workspace diagnostics should not be changed as the remote client does not initialize the workspace diagnostics pull"
);
@@ -2646,7 +3016,7 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
);
executor.run_until_parked();
assert_eq!(
- 1,
+ workspace_diagnostic_start_count,
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"The remote client still did not anything to trigger the workspace diagnostics pull"
);
@@ -2673,9 +3043,78 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
);
}
});
+
+ if should_stream_workspace_diagnostic {
+ fake_language_server.notify::<lsp::notification::Progress>(&lsp::ProgressParams {
+ token: expected_workspace_diagnostic_token.clone(),
+ value: lsp::ProgressParamsValue::WorkspaceDiagnostic(
+ lsp::WorkspaceDiagnosticReportResult::Report(lsp::WorkspaceDiagnosticReport {
+ items: vec![lsp::WorkspaceDocumentDiagnosticReport::Full(
+ lsp::WorkspaceFullDocumentDiagnosticReport {
+ uri: lsp::Url::from_file_path(path!("/a/lib.rs")).unwrap(),
+ version: None,
+ full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport {
+ result_id: Some(format!(
+ "workspace_{}",
+ workspace_diagnostics_pulls_made
+ .fetch_add(1, atomic::Ordering::Release)
+ + 1
+ )),
+ items: vec![lsp::Diagnostic {
+ range: lsp::Range {
+ start: lsp::Position {
+ line: 0,
+ character: 1,
+ },
+ end: lsp::Position {
+ line: 0,
+ character: 2,
+ },
+ },
+ severity: Some(lsp::DiagnosticSeverity::ERROR),
+ message: expected_workspace_pull_diagnostics_lib_message
+ .to_string(),
+ ..lsp::Diagnostic::default()
+ }],
+ },
+ },
+ )],
+ }),
+ ),
+ });
+ workspace_diagnostic_start_count =
+ workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire);
+ workspace_diagnostic_cancel_tx.send(()).await.unwrap();
+ workspace_diagnostics_pulls_handle.next().await.unwrap();
+ executor.run_until_parked();
+ editor_b_lib.update(cx_b, |editor, cx| {
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ let all_diagnostics = snapshot
+ .diagnostics_in_range(0..snapshot.len())
+ .collect::<Vec<_>>();
+ let expected_messages = [
+ expected_workspace_pull_diagnostics_lib_message,
+ // TODO bug: the pushed diagnostics are not being sent to the client when they open the corresponding buffer.
+ // expected_push_diagnostic_lib_message,
+ ];
+ assert_eq!(
+ all_diagnostics.len(),
+ 1,
+ "Expected pull diagnostics, but got: {all_diagnostics:?}"
+ );
+ for diagnostic in all_diagnostics {
+ assert!(
+ expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
+ "The client should get both push and pull messages: {expected_messages:?}, but got: {}",
+ diagnostic.diagnostic.message
+ );
+ }
+ });
+ };
+
{
assert!(
- diagnostics_pulls_result_ids.lock().await.len() > 0,
+ !diagnostics_pulls_result_ids.lock().await.is_empty(),
"Initial diagnostics pulls should report None at least"
);
assert_eq!(
@@ -2701,7 +3140,7 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
);
workspace_diagnostics_pulls_handle.next().await.unwrap();
assert_eq!(
- 2,
+ workspace_diagnostic_start_count + 1,
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"After client lib.rs edits, the workspace diagnostics request should follow"
);
@@ -2720,7 +3159,7 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
);
workspace_diagnostics_pulls_handle.next().await.unwrap();
assert_eq!(
- 3,
+ workspace_diagnostic_start_count + 2,
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"After client main.rs edits, the workspace diagnostics pull should follow"
);
@@ -2739,7 +3178,7 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
);
workspace_diagnostics_pulls_handle.next().await.unwrap();
assert_eq!(
- 4,
+ workspace_diagnostic_start_count + 3,
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"After host main.rs edits, the workspace diagnostics pull should follow"
);
@@ -2769,7 +3208,7 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
);
workspace_diagnostics_pulls_handle.next().await.unwrap();
assert_eq!(
- 5,
+ workspace_diagnostic_start_count + 4,
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"Another workspace diagnostics pull should happen after the diagnostics refresh server request"
);
@@ -2840,6 +3279,19 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp
});
}
+#[gpui::test(iterations = 10)]
+async fn test_non_streamed_lsp_pull_diagnostics(
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ test_lsp_pull_diagnostics(false, cx_a, cx_b).await;
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_streamed_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
+ test_lsp_pull_diagnostics(true, cx_a, cx_b).await;
+}
+
#[gpui::test(iterations = 10)]
async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
@@ -2855,9 +3307,7 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
// Turn inline-blame-off by default so no state is transferred without us explicitly doing so
let inline_blame_off_settings = Some(InlineBlameSettings {
enabled: false,
- delay_ms: None,
- min_column: None,
- show_commit_summary: false,
+ ..Default::default()
});
cx_a.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
@@ -3349,7 +3799,7 @@ async fn test_add_breakpoints(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte
let abs_path = project_a.read_with(cx_a, |project, cx| {
project
.absolute_path(&project_path, cx)
- .map(|path_buf| Arc::from(path_buf.to_owned()))
+ .map(Arc::from)
.unwrap()
});
@@ -3403,20 +3853,16 @@ async fn test_add_breakpoints(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte
let breakpoints_a = editor_a.update(cx_a, |editor, cx| {
editor
.breakpoint_store()
- .clone()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
- .clone()
});
let breakpoints_b = editor_b.update(cx_b, |editor, cx| {
editor
.breakpoint_store()
- .clone()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
- .clone()
});
assert_eq!(1, breakpoints_a.len());
@@ -3436,20 +3882,16 @@ async fn test_add_breakpoints(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte
let breakpoints_a = editor_a.update(cx_a, |editor, cx| {
editor
.breakpoint_store()
- .clone()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
- .clone()
});
let breakpoints_b = editor_b.update(cx_b, |editor, cx| {
editor
.breakpoint_store()
- .clone()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
- .clone()
});
assert_eq!(1, breakpoints_a.len());
@@ -3469,20 +3911,16 @@ async fn test_add_breakpoints(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte
let breakpoints_a = editor_a.update(cx_a, |editor, cx| {
editor
.breakpoint_store()
- .clone()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
- .clone()
});
let breakpoints_b = editor_b.update(cx_b, |editor, cx| {
editor
.breakpoint_store()
- .clone()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
- .clone()
});
assert_eq!(1, breakpoints_a.len());
@@ -3502,20 +3940,16 @@ async fn test_add_breakpoints(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte
let breakpoints_a = editor_a.update(cx_a, |editor, cx| {
editor
.breakpoint_store()
- .clone()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
- .clone()
});
let breakpoints_b = editor_b.update(cx_b, |editor, cx| {
editor
.breakpoint_store()
- .clone()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
- .clone()
});
assert_eq!(0, breakpoints_a.len());
@@ -3537,11 +3971,18 @@ async fn test_client_can_query_lsp_ext(cx_a: &mut TestAppContext, cx_b: &mut Tes
cx_b.update(editor::init);
client_a.language_registry().add(rust_lang());
- client_b.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
- name: RUST_ANALYZER_NAME,
+ name: "rust-analyzer",
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ name: "rust-analyzer",
..FakeLspAdapter::default()
},
);
@@ -439,7 +439,7 @@ async fn test_basic_following(
editor_a1.item_id()
);
- #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
+ // #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
{
use crate::rpc::RECONNECT_TIMEOUT;
use gpui::TestScreenCaptureSource;
@@ -456,11 +456,19 @@ async fn test_basic_following(
.await
.unwrap();
cx_b.set_screen_capture_sources(vec![display]);
+ let source = cx_b
+ .read(|cx| cx.screen_capture_sources())
+ .await
+ .unwrap()
+ .unwrap()
+ .into_iter()
+ .next()
+ .unwrap();
active_call_b
.update(cx_b, |call, cx| {
call.room()
.unwrap()
- .update(cx, |room, cx| room.share_screen(cx))
+ .update(cx, |room, cx| room.share_screen(source, cx))
})
.await
.unwrap();
@@ -962,7 +970,7 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T
// the follow.
workspace_b.update_in(cx_b, |workspace, window, cx| {
workspace.active_pane().update(cx, |pane, cx| {
- pane.activate_prev_item(true, window, cx);
+ pane.activate_previous_item(&Default::default(), window, cx);
});
});
executor.run_until_parked();
@@ -1013,7 +1021,7 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T
// and some of which were originally opened by client B.
workspace_b.update_in(cx_b, |workspace, window, cx| {
workspace.active_pane().update(cx, |pane, cx| {
- pane.close_inactive_items(&Default::default(), None, window, cx)
+ pane.close_other_items(&Default::default(), None, window, cx)
.detach();
});
});
@@ -1065,7 +1073,7 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T
// Client A cycles through some tabs.
workspace_a.update_in(cx_a, |workspace, window, cx| {
workspace.active_pane().update(cx, |pane, cx| {
- pane.activate_prev_item(true, window, cx);
+ pane.activate_previous_item(&Default::default(), window, cx);
});
});
executor.run_until_parked();
@@ -1109,7 +1117,7 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T
workspace_a.update_in(cx_a, |workspace, window, cx| {
workspace.active_pane().update(cx, |pane, cx| {
- pane.activate_prev_item(true, window, cx);
+ pane.activate_previous_item(&Default::default(), window, cx);
});
});
executor.run_until_parked();
@@ -1156,7 +1164,7 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T
workspace_a.update_in(cx_a, |workspace, window, cx| {
workspace.active_pane().update(cx, |pane, cx| {
- pane.activate_prev_item(true, window, cx);
+ pane.activate_previous_item(&Default::default(), window, cx);
});
});
executor.run_until_parked();
@@ -277,11 +277,19 @@ async fn test_basic_calls(
let events_b = active_call_events(cx_b);
let events_c = active_call_events(cx_c);
cx_a.set_screen_capture_sources(vec![display]);
+ let screen_a = cx_a
+ .update(|cx| cx.screen_capture_sources())
+ .await
+ .unwrap()
+ .unwrap()
+ .into_iter()
+ .next()
+ .unwrap();
active_call_a
.update(cx_a, |call, cx| {
call.room()
.unwrap()
- .update(cx, |room, cx| room.share_screen(cx))
+ .update(cx, |room, cx| room.share_screen(screen_a, cx))
})
.await
.unwrap();
@@ -834,7 +842,7 @@ async fn test_client_disconnecting_from_room(
// Allow user A to reconnect to the server.
server.allow_connections();
- executor.advance_clock(RECEIVE_TIMEOUT);
+ executor.advance_clock(RECONNECT_TIMEOUT);
// Call user B again from client A.
active_call_a
@@ -1278,7 +1286,7 @@ async fn test_calls_on_multiple_connections(
client_b1.disconnect(&cx_b1.to_async());
executor.advance_clock(RECEIVE_TIMEOUT);
client_b1
- .authenticate_and_connect(false, &cx_b1.to_async())
+ .connect(false, &cx_b1.to_async())
.await
.into_response()
.unwrap();
@@ -1350,7 +1358,7 @@ async fn test_calls_on_multiple_connections(
// User A reconnects automatically, then calls user B again.
server.allow_connections();
- executor.advance_clock(RECEIVE_TIMEOUT);
+ executor.advance_clock(RECONNECT_TIMEOUT);
active_call_a
.update(cx_a, |call, cx| {
call.invite(client_b1.user_id().unwrap(), None, cx)
@@ -1659,7 +1667,7 @@ async fn test_project_reconnect(
// Client A reconnects. Their project is re-shared, and client B re-joins it.
server.allow_connections();
client_a
- .authenticate_and_connect(false, &cx_a.to_async())
+ .connect(false, &cx_a.to_async())
.await
.into_response()
.unwrap();
@@ -1788,7 +1796,7 @@ async fn test_project_reconnect(
// Client B reconnects. They re-join the room and the remaining shared project.
server.allow_connections();
client_b
- .authenticate_and_connect(false, &cx_b.to_async())
+ .connect(false, &cx_b.to_async())
.await
.into_response()
.unwrap();
@@ -1873,7 +1881,7 @@ async fn test_active_call_events(
vec![room::Event::RemoteProjectShared {
owner: Arc::new(User {
id: client_a.user_id().unwrap(),
- github_login: "user_a".to_string(),
+ github_login: "user_a".into(),
avatar_uri: "avatar_a".into(),
name: None,
}),
@@ -1892,7 +1900,7 @@ async fn test_active_call_events(
vec![room::Event::RemoteProjectShared {
owner: Arc::new(User {
id: client_b.user_id().unwrap(),
- github_login: "user_b".to_string(),
+ github_login: "user_b".into(),
avatar_uri: "avatar_b".into(),
name: None,
}),
@@ -3200,7 +3208,7 @@ async fn test_fs_operations(
})
.await
.unwrap()
- .to_included()
+ .into_included()
.unwrap();
worktree_a.read_with(cx_a, |worktree, _| {
@@ -3229,7 +3237,7 @@ async fn test_fs_operations(
})
.await
.unwrap()
- .to_included()
+ .into_included()
.unwrap();
worktree_a.read_with(cx_a, |worktree, _| {
@@ -3258,7 +3266,7 @@ async fn test_fs_operations(
})
.await
.unwrap()
- .to_included()
+ .into_included()
.unwrap();
worktree_a.read_with(cx_a, |worktree, _| {
@@ -3287,7 +3295,7 @@ async fn test_fs_operations(
})
.await
.unwrap()
- .to_included()
+ .into_included()
.unwrap();
project_b
@@ -3296,7 +3304,7 @@ async fn test_fs_operations(
})
.await
.unwrap()
- .to_included()
+ .into_included()
.unwrap();
project_b
@@ -3305,7 +3313,7 @@ async fn test_fs_operations(
})
.await
.unwrap()
- .to_included()
+ .into_included()
.unwrap();
worktree_a.read_with(cx_a, |worktree, _| {
@@ -4770,10 +4778,27 @@ async fn test_definition(
.await;
let active_call_a = cx_a.read(ActiveCall::global);
- let mut fake_language_servers = client_a
- .language_registry()
- .register_fake_lsp("Rust", Default::default());
+ let capabilities = lsp::ServerCapabilities {
+ definition_provider: Some(OneOf::Left(true)),
+ type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
+ ..lsp::ServerCapabilities::default()
+ };
client_a.language_registry().add(rust_lang());
+ let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
+ "Rust",
+ FakeLspAdapter {
+ capabilities: capabilities.clone(),
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ capabilities,
+ ..FakeLspAdapter::default()
+ },
+ );
client_a
.fs()
@@ -4819,13 +4844,20 @@ async fn test_definition(
)))
},
);
+ cx_a.run_until_parked();
+ cx_b.run_until_parked();
let definitions_1 = project_b
.update(cx_b, |p, cx| p.definitions(&buffer_b, 23, cx))
.await
+ .unwrap()
.unwrap();
cx_b.read(|cx| {
- assert_eq!(definitions_1.len(), 1);
+ assert_eq!(
+ definitions_1.len(),
+ 1,
+ "Unexpected definitions: {definitions_1:?}"
+ );
assert_eq!(project_b.read(cx).worktrees(cx).count(), 2);
let target_buffer = definitions_1[0].target.buffer.read(cx);
assert_eq!(
@@ -4854,6 +4886,7 @@ async fn test_definition(
let definitions_2 = project_b
.update(cx_b, |p, cx| p.definitions(&buffer_b, 33, cx))
.await
+ .unwrap()
.unwrap();
cx_b.read(|cx| {
assert_eq!(definitions_2.len(), 1);
@@ -4891,9 +4924,14 @@ async fn test_definition(
let type_definitions = project_b
.update(cx_b, |p, cx| p.type_definitions(&buffer_b, 7, cx))
.await
+ .unwrap()
.unwrap();
cx_b.read(|cx| {
- assert_eq!(type_definitions.len(), 1);
+ assert_eq!(
+ type_definitions.len(),
+ 1,
+ "Unexpected type definitions: {type_definitions:?}"
+ );
let target_buffer = type_definitions[0].target.buffer.read(cx);
assert_eq!(target_buffer.text(), "type T2 = usize;");
assert_eq!(
@@ -4917,16 +4955,26 @@ async fn test_references(
.await;
let active_call_a = cx_a.read(ActiveCall::global);
+ let capabilities = lsp::ServerCapabilities {
+ references_provider: Some(lsp::OneOf::Left(true)),
+ ..lsp::ServerCapabilities::default()
+ };
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
name: "my-fake-lsp-adapter",
- capabilities: lsp::ServerCapabilities {
- references_provider: Some(lsp::OneOf::Left(true)),
- ..Default::default()
- },
- ..Default::default()
+ capabilities: capabilities.clone(),
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ name: "my-fake-lsp-adapter",
+ capabilities,
+ ..FakeLspAdapter::default()
},
);
@@ -4981,6 +5029,8 @@ async fn test_references(
}
}
});
+ cx_a.run_until_parked();
+ cx_b.run_until_parked();
let references = project_b.update(cx_b, |p, cx| p.references(&buffer_b, 7, cx));
@@ -4988,7 +5038,7 @@ async fn test_references(
executor.run_until_parked();
project_b.read_with(cx_b, |project, cx| {
let status = project.language_server_statuses(cx).next().unwrap().1;
- assert_eq!(status.name, "my-fake-lsp-adapter");
+ assert_eq!(status.name.0, "my-fake-lsp-adapter");
assert_eq!(
status.pending_work.values().next().unwrap().message,
Some("Finding references...".into())
@@ -5013,7 +5063,7 @@ async fn test_references(
])))
.unwrap();
- let references = references.await.unwrap();
+ let references = references.await.unwrap().unwrap();
executor.run_until_parked();
project_b.read_with(cx_b, |project, cx| {
// User is informed that a request is no longer pending.
@@ -5046,7 +5096,7 @@ async fn test_references(
executor.run_until_parked();
project_b.read_with(cx_b, |project, cx| {
let status = project.language_server_statuses(cx).next().unwrap().1;
- assert_eq!(status.name, "my-fake-lsp-adapter");
+ assert_eq!(status.name.0, "my-fake-lsp-adapter");
assert_eq!(
status.pending_work.values().next().unwrap().message,
Some("Finding references...".into())
@@ -5057,7 +5107,7 @@ async fn test_references(
lsp_response_tx
.unbounded_send(Err(anyhow!("can't find references")))
.unwrap();
- assert_eq!(references.await.unwrap(), []);
+ assert_eq!(references.await.unwrap().unwrap(), []);
// User is informed that the request is no longer pending.
executor.run_until_parked();
@@ -5196,10 +5246,26 @@ async fn test_document_highlights(
)
.await;
- let mut fake_language_servers = client_a
- .language_registry()
- .register_fake_lsp("Rust", Default::default());
client_a.language_registry().add(rust_lang());
+ let capabilities = lsp::ServerCapabilities {
+ document_highlight_provider: Some(lsp::OneOf::Left(true)),
+ ..lsp::ServerCapabilities::default()
+ };
+ let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
+ "Rust",
+ FakeLspAdapter {
+ capabilities: capabilities.clone(),
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ capabilities,
+ ..FakeLspAdapter::default()
+ },
+ );
let (project_a, worktree_id) = client_a.build_local_project(path!("/root-1"), cx_a).await;
let project_id = active_call_a
@@ -5248,6 +5314,8 @@ async fn test_document_highlights(
]))
},
);
+ cx_a.run_until_parked();
+ cx_b.run_until_parked();
let highlights = project_b
.update(cx_b, |p, cx| p.document_highlights(&buffer_b, 34, cx))
@@ -5298,30 +5366,49 @@ async fn test_lsp_hover(
client_a.language_registry().add(rust_lang());
let language_server_names = ["rust-analyzer", "CrabLang-ls"];
+ let capabilities_1 = lsp::ServerCapabilities {
+ hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
+ ..lsp::ServerCapabilities::default()
+ };
+ let capabilities_2 = lsp::ServerCapabilities {
+ hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
+ ..lsp::ServerCapabilities::default()
+ };
let mut language_servers = [
client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
- name: "rust-analyzer",
- capabilities: lsp::ServerCapabilities {
- hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
- ..lsp::ServerCapabilities::default()
- },
+ name: language_server_names[0],
+ capabilities: capabilities_1.clone(),
..FakeLspAdapter::default()
},
),
client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
- name: "CrabLang-ls",
- capabilities: lsp::ServerCapabilities {
- hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
- ..lsp::ServerCapabilities::default()
- },
+ name: language_server_names[1],
+ capabilities: capabilities_2.clone(),
..FakeLspAdapter::default()
},
),
];
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ name: language_server_names[0],
+ capabilities: capabilities_1,
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ name: language_server_names[1],
+ capabilities: capabilities_2,
+ ..FakeLspAdapter::default()
+ },
+ );
let (project_a, worktree_id) = client_a.build_local_project(path!("/root-1"), cx_a).await;
let project_id = active_call_a
@@ -5415,11 +5502,14 @@ async fn test_lsp_hover(
unexpected => panic!("Unexpected server name: {unexpected}"),
}
}
+ cx_a.run_until_parked();
+ cx_b.run_until_parked();
// Request hover information as the guest.
let mut hovers = project_b
.update(cx_b, |p, cx| p.hover(&buffer_b, 22, cx))
- .await;
+ .await
+ .unwrap();
assert_eq!(
hovers.len(),
2,
@@ -5597,10 +5687,26 @@ async fn test_open_buffer_while_getting_definition_pointing_to_it(
.await;
let active_call_a = cx_a.read(ActiveCall::global);
+ let capabilities = lsp::ServerCapabilities {
+ definition_provider: Some(OneOf::Left(true)),
+ ..lsp::ServerCapabilities::default()
+ };
client_a.language_registry().add(rust_lang());
- let mut fake_language_servers = client_a
- .language_registry()
- .register_fake_lsp("Rust", Default::default());
+ let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
+ "Rust",
+ FakeLspAdapter {
+ capabilities: capabilities.clone(),
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ capabilities,
+ ..FakeLspAdapter::default()
+ },
+ );
client_a
.fs()
@@ -5641,6 +5747,8 @@ async fn test_open_buffer_while_getting_definition_pointing_to_it(
let definitions;
let buffer_b2;
if rng.r#gen() {
+ cx_a.run_until_parked();
+ cx_b.run_until_parked();
definitions = project_b.update(cx_b, |p, cx| p.definitions(&buffer_b1, 23, cx));
(buffer_b2, _) = project_b
.update(cx_b, |p, cx| {
@@ -5655,11 +5763,17 @@ async fn test_open_buffer_while_getting_definition_pointing_to_it(
})
.await
.unwrap();
+ cx_a.run_until_parked();
+ cx_b.run_until_parked();
definitions = project_b.update(cx_b, |p, cx| p.definitions(&buffer_b1, 23, cx));
}
- let definitions = definitions.await.unwrap();
- assert_eq!(definitions.len(), 1);
+ let definitions = definitions.await.unwrap().unwrap();
+ assert_eq!(
+ definitions.len(),
+ 1,
+ "Unexpected definitions: {definitions:?}"
+ );
assert_eq!(definitions[0].target.buffer, buffer_b2);
}
@@ -5730,7 +5844,7 @@ async fn test_contacts(
server.allow_connections();
client_c
- .authenticate_and_connect(false, &cx_c.to_async())
+ .connect(false, &cx_c.to_async())
.await
.into_response()
.unwrap();
@@ -6071,7 +6185,7 @@ async fn test_contacts(
.iter()
.map(|contact| {
(
- contact.user.github_login.clone(),
+ contact.user.github_login.clone().to_string(),
if contact.online { "online" } else { "offline" },
if contact.busy { "busy" } else { "free" },
)
@@ -6261,7 +6375,7 @@ async fn test_contact_requests(
client.disconnect(&cx.to_async());
client.clear_contacts(cx).await;
client
- .authenticate_and_connect(false, &cx.to_async())
+ .connect(false, &cx.to_async())
.await
.into_response()
.unwrap();
@@ -6312,11 +6426,20 @@ async fn test_join_call_after_screen_was_shared(
// User A shares their screen
let display = gpui::TestScreenCaptureSource::new();
cx_a.set_screen_capture_sources(vec![display]);
+ let screen_a = cx_a
+ .update(|cx| cx.screen_capture_sources())
+ .await
+ .unwrap()
+ .unwrap()
+ .into_iter()
+ .next()
+ .unwrap();
+
active_call_a
.update(cx_a, |call, cx| {
call.room()
.unwrap()
- .update(cx, |room, cx| room.share_screen(cx))
+ .update(cx, |room, cx| room.share_screen(screen_a, cx))
})
.await
.unwrap();
@@ -3,6 +3,7 @@ use std::sync::Arc;
use gpui::{BackgroundExecutor, TestAppContext};
use notifications::NotificationEvent;
use parking_lot::Mutex;
+use pretty_assertions::assert_eq;
use rpc::{Notification, proto};
use crate::tests::TestServer;
@@ -17,6 +18,9 @@ async fn test_notifications(
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
+ // Wait for authentication/connection to Collab to be established.
+ executor.run_until_parked();
+
let notification_events_a = Arc::new(Mutex::new(Vec::new()));
let notification_events_b = Arc::new(Mutex::new(Vec::new()));
client_a.notification_store().update(cx_a, |_, cx| {
@@ -266,7 +266,7 @@ impl RandomizedTest for RandomChannelBufferTest {
"client {user_id} has different text than client {prev_user_id} for channel {channel_name}",
);
} else {
- prev_text = Some((user_id, text.clone()));
+ prev_text = Some((user_id, text));
}
// Assert that all clients and the server agree about who is present in the
@@ -304,7 +304,7 @@ impl RandomizedTest for ProjectCollaborationTest {
let worktree = worktree.read(cx);
worktree.is_visible()
&& worktree.entries(false, 0).any(|e| e.is_file())
- && worktree.root_entry().map_or(false, |e| e.is_dir())
+ && worktree.root_entry().is_some_and(|e| e.is_dir())
})
.choose(rng)
});
@@ -643,7 +643,7 @@ impl RandomizedTest for ProjectCollaborationTest {
);
let project = project.await?;
- client.dev_server_projects_mut().push(project.clone());
+ client.dev_server_projects_mut().push(project);
}
ClientOperation::CreateWorktreeEntry {
@@ -1162,8 +1162,8 @@ impl RandomizedTest for ProjectCollaborationTest {
Some((project, cx))
});
- if !guest_project.is_disconnected(cx) {
- if let Some((host_project, host_cx)) = host_project {
+ if !guest_project.is_disconnected(cx)
+ && let Some((host_project, host_cx)) = host_project {
let host_worktree_snapshots =
host_project.read_with(host_cx, |host_project, cx| {
host_project
@@ -1235,7 +1235,6 @@ impl RandomizedTest for ProjectCollaborationTest {
);
}
}
- }
for buffer in guest_project.opened_buffers(cx) {
let buffer = buffer.read(cx);
@@ -198,11 +198,11 @@ pub async fn run_randomized_test<T: RandomizedTest>(
}
pub fn save_randomized_test_plan() {
- if let Some(serialize_plan) = LAST_PLAN.lock().take() {
- if let Some(path) = plan_save_path() {
- eprintln!("saved test plan to path {:?}", path);
- std::fs::write(path, serialize_plan()).unwrap();
- }
+ if let Some(serialize_plan) = LAST_PLAN.lock().take()
+ && let Some(path) = plan_save_path()
+ {
+ eprintln!("saved test plan to path {:?}", path);
+ std::fs::write(path, serialize_plan()).unwrap();
}
}
@@ -290,10 +290,9 @@ impl<T: RandomizedTest> TestPlan<T> {
if let StoredOperation::Client {
user_id, batch_id, ..
} = operation
+ && batch_id == current_batch_id
{
- if batch_id == current_batch_id {
- return Some(user_id);
- }
+ return Some(user_id);
}
None
}));
@@ -366,10 +365,9 @@ impl<T: RandomizedTest> TestPlan<T> {
},
applied,
) = stored_operation
+ && user_id == ¤t_user_id
{
- if user_id == ¤t_user_id {
- return Some((operation.clone(), applied.clone()));
- }
+ return Some((operation.clone(), applied.clone()));
}
}
None
@@ -550,11 +548,11 @@ impl<T: RandomizedTest> TestPlan<T> {
.unwrap();
let pool = server.connection_pool.lock();
for contact in contacts {
- if let db::Contact::Accepted { user_id, busy, .. } = contact {
- if user_id == removed_user_id {
- assert!(!pool.is_user_online(user_id));
- assert!(!busy);
- }
+ if let db::Contact::Accepted { user_id, busy, .. } = contact
+ && user_id == removed_user_id
+ {
+ assert!(!pool.is_user_online(user_id));
+ assert!(!busy);
}
}
}
@@ -1,603 +0,0 @@
-use std::sync::Arc;
-
-use chrono::{Duration, Utc};
-use pretty_assertions::assert_eq;
-
-use crate::llm::AGENT_EXTENDED_TRIAL_FEATURE_FLAG;
-use crate::stripe_billing::StripeBilling;
-use crate::stripe_client::{
- FakeStripeClient, StripeBillingAddressCollection, StripeCheckoutSessionMode,
- StripeCheckoutSessionPaymentMethodCollection, StripeCreateCheckoutSessionLineItems,
- StripeCreateCheckoutSessionSubscriptionData, StripeCustomerId, StripeCustomerUpdate,
- StripeCustomerUpdateAddress, StripeCustomerUpdateName, StripeMeter, StripeMeterId, StripePrice,
- StripePriceId, StripePriceRecurring, StripeSubscription, StripeSubscriptionId,
- StripeSubscriptionItem, StripeSubscriptionItemId, StripeSubscriptionTrialSettings,
- StripeSubscriptionTrialSettingsEndBehavior,
- StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, UpdateSubscriptionItems,
-};
-
-fn make_stripe_billing() -> (StripeBilling, Arc<FakeStripeClient>) {
- let stripe_client = Arc::new(FakeStripeClient::new());
- let stripe_billing = StripeBilling::test(stripe_client.clone());
-
- (stripe_billing, stripe_client)
-}
-
-#[gpui::test]
-async fn test_initialize() {
- let (stripe_billing, stripe_client) = make_stripe_billing();
-
- // Add test meters
- let meter1 = StripeMeter {
- id: StripeMeterId("meter_1".into()),
- event_name: "event_1".to_string(),
- };
- let meter2 = StripeMeter {
- id: StripeMeterId("meter_2".into()),
- event_name: "event_2".to_string(),
- };
- stripe_client
- .meters
- .lock()
- .insert(meter1.id.clone(), meter1);
- stripe_client
- .meters
- .lock()
- .insert(meter2.id.clone(), meter2);
-
- // Add test prices
- let price1 = StripePrice {
- id: StripePriceId("price_1".into()),
- unit_amount: Some(1_000),
- lookup_key: Some("zed-pro".to_string()),
- recurring: None,
- };
- let price2 = StripePrice {
- id: StripePriceId("price_2".into()),
- unit_amount: Some(0),
- lookup_key: Some("zed-free".to_string()),
- recurring: None,
- };
- let price3 = StripePrice {
- id: StripePriceId("price_3".into()),
- unit_amount: Some(500),
- lookup_key: None,
- recurring: Some(StripePriceRecurring {
- meter: Some("meter_1".to_string()),
- }),
- };
- stripe_client
- .prices
- .lock()
- .insert(price1.id.clone(), price1);
- stripe_client
- .prices
- .lock()
- .insert(price2.id.clone(), price2);
- stripe_client
- .prices
- .lock()
- .insert(price3.id.clone(), price3);
-
- // Initialize the billing system
- stripe_billing.initialize().await.unwrap();
-
- // Verify that prices can be found by lookup key
- let zed_pro_price_id = stripe_billing.zed_pro_price_id().await.unwrap();
- assert_eq!(zed_pro_price_id.to_string(), "price_1");
-
- let zed_free_price_id = stripe_billing.zed_free_price_id().await.unwrap();
- assert_eq!(zed_free_price_id.to_string(), "price_2");
-
- // Verify that a price can be found by lookup key
- let zed_pro_price = stripe_billing
- .find_price_by_lookup_key("zed-pro")
- .await
- .unwrap();
- assert_eq!(zed_pro_price.id.to_string(), "price_1");
- assert_eq!(zed_pro_price.unit_amount, Some(1_000));
-
- // Verify that finding a non-existent lookup key returns an error
- let result = stripe_billing
- .find_price_by_lookup_key("non-existent")
- .await;
- assert!(result.is_err());
-}
-
-#[gpui::test]
-async fn test_find_or_create_customer_by_email() {
- let (stripe_billing, stripe_client) = make_stripe_billing();
-
- // Create a customer with an email that doesn't yet correspond to a customer.
- {
- let email = "user@example.com";
-
- let customer_id = stripe_billing
- .find_or_create_customer_by_email(Some(email))
- .await
- .unwrap();
-
- let customer = stripe_client
- .customers
- .lock()
- .get(&customer_id)
- .unwrap()
- .clone();
- assert_eq!(customer.email.as_deref(), Some(email));
- }
-
- // Create a customer with an email that corresponds to an existing customer.
- {
- let email = "user2@example.com";
-
- let existing_customer_id = stripe_billing
- .find_or_create_customer_by_email(Some(email))
- .await
- .unwrap();
-
- let customer_id = stripe_billing
- .find_or_create_customer_by_email(Some(email))
- .await
- .unwrap();
- assert_eq!(customer_id, existing_customer_id);
-
- let customer = stripe_client
- .customers
- .lock()
- .get(&customer_id)
- .unwrap()
- .clone();
- assert_eq!(customer.email.as_deref(), Some(email));
- }
-}
-
-#[gpui::test]
-async fn test_subscribe_to_price() {
- let (stripe_billing, stripe_client) = make_stripe_billing();
-
- let price = StripePrice {
- id: StripePriceId("price_test".into()),
- unit_amount: Some(2000),
- lookup_key: Some("test-price".to_string()),
- recurring: None,
- };
- stripe_client
- .prices
- .lock()
- .insert(price.id.clone(), price.clone());
-
- let now = Utc::now();
- let subscription = StripeSubscription {
- id: StripeSubscriptionId("sub_test".into()),
- customer: StripeCustomerId("cus_test".into()),
- status: stripe::SubscriptionStatus::Active,
- current_period_start: now.timestamp(),
- current_period_end: (now + Duration::days(30)).timestamp(),
- items: vec![],
- cancel_at: None,
- cancellation_details: None,
- };
- stripe_client
- .subscriptions
- .lock()
- .insert(subscription.id.clone(), subscription.clone());
-
- stripe_billing
- .subscribe_to_price(&subscription.id, &price)
- .await
- .unwrap();
-
- let update_subscription_calls = stripe_client
- .update_subscription_calls
- .lock()
- .iter()
- .map(|(id, params)| (id.clone(), params.clone()))
- .collect::<Vec<_>>();
- assert_eq!(update_subscription_calls.len(), 1);
- assert_eq!(update_subscription_calls[0].0, subscription.id);
- assert_eq!(
- update_subscription_calls[0].1.items,
- Some(vec![UpdateSubscriptionItems {
- price: Some(price.id.clone())
- }])
- );
-
- // Subscribing to a price that is already on the subscription is a no-op.
- {
- let now = Utc::now();
- let subscription = StripeSubscription {
- id: StripeSubscriptionId("sub_test".into()),
- customer: StripeCustomerId("cus_test".into()),
- status: stripe::SubscriptionStatus::Active,
- current_period_start: now.timestamp(),
- current_period_end: (now + Duration::days(30)).timestamp(),
- items: vec![StripeSubscriptionItem {
- id: StripeSubscriptionItemId("si_test".into()),
- price: Some(price.clone()),
- }],
- cancel_at: None,
- cancellation_details: None,
- };
- stripe_client
- .subscriptions
- .lock()
- .insert(subscription.id.clone(), subscription.clone());
-
- stripe_billing
- .subscribe_to_price(&subscription.id, &price)
- .await
- .unwrap();
-
- assert_eq!(stripe_client.update_subscription_calls.lock().len(), 1);
- }
-}
-
-#[gpui::test]
-async fn test_subscribe_to_zed_free() {
- let (stripe_billing, stripe_client) = make_stripe_billing();
-
- let zed_pro_price = StripePrice {
- id: StripePriceId("price_1".into()),
- unit_amount: Some(0),
- lookup_key: Some("zed-pro".to_string()),
- recurring: None,
- };
- stripe_client
- .prices
- .lock()
- .insert(zed_pro_price.id.clone(), zed_pro_price.clone());
- let zed_free_price = StripePrice {
- id: StripePriceId("price_2".into()),
- unit_amount: Some(0),
- lookup_key: Some("zed-free".to_string()),
- recurring: None,
- };
- stripe_client
- .prices
- .lock()
- .insert(zed_free_price.id.clone(), zed_free_price.clone());
-
- stripe_billing.initialize().await.unwrap();
-
- // Customer is subscribed to Zed Free when not already subscribed to a plan.
- {
- let customer_id = StripeCustomerId("cus_no_plan".into());
-
- let subscription = stripe_billing
- .subscribe_to_zed_free(customer_id)
- .await
- .unwrap();
-
- assert_eq!(subscription.items[0].price.as_ref(), Some(&zed_free_price));
- }
-
- // Customer is not subscribed to Zed Free when they already have an active subscription.
- {
- let customer_id = StripeCustomerId("cus_active_subscription".into());
-
- let now = Utc::now();
- let existing_subscription = StripeSubscription {
- id: StripeSubscriptionId("sub_existing_active".into()),
- customer: customer_id.clone(),
- status: stripe::SubscriptionStatus::Active,
- current_period_start: now.timestamp(),
- current_period_end: (now + Duration::days(30)).timestamp(),
- items: vec![StripeSubscriptionItem {
- id: StripeSubscriptionItemId("si_test".into()),
- price: Some(zed_pro_price.clone()),
- }],
- cancel_at: None,
- cancellation_details: None,
- };
- stripe_client.subscriptions.lock().insert(
- existing_subscription.id.clone(),
- existing_subscription.clone(),
- );
-
- let subscription = stripe_billing
- .subscribe_to_zed_free(customer_id)
- .await
- .unwrap();
-
- assert_eq!(subscription, existing_subscription);
- }
-
- // Customer is not subscribed to Zed Free when they already have a trial subscription.
- {
- let customer_id = StripeCustomerId("cus_trial_subscription".into());
-
- let now = Utc::now();
- let existing_subscription = StripeSubscription {
- id: StripeSubscriptionId("sub_existing_trial".into()),
- customer: customer_id.clone(),
- status: stripe::SubscriptionStatus::Trialing,
- current_period_start: now.timestamp(),
- current_period_end: (now + Duration::days(14)).timestamp(),
- items: vec![StripeSubscriptionItem {
- id: StripeSubscriptionItemId("si_test".into()),
- price: Some(zed_pro_price.clone()),
- }],
- cancel_at: None,
- cancellation_details: None,
- };
- stripe_client.subscriptions.lock().insert(
- existing_subscription.id.clone(),
- existing_subscription.clone(),
- );
-
- let subscription = stripe_billing
- .subscribe_to_zed_free(customer_id)
- .await
- .unwrap();
-
- assert_eq!(subscription, existing_subscription);
- }
-}
-
-#[gpui::test]
-async fn test_bill_model_request_usage() {
- let (stripe_billing, stripe_client) = make_stripe_billing();
-
- let customer_id = StripeCustomerId("cus_test".into());
-
- stripe_billing
- .bill_model_request_usage(&customer_id, "some_model/requests", 73)
- .await
- .unwrap();
-
- let create_meter_event_calls = stripe_client
- .create_meter_event_calls
- .lock()
- .iter()
- .cloned()
- .collect::<Vec<_>>();
- assert_eq!(create_meter_event_calls.len(), 1);
- assert!(
- create_meter_event_calls[0]
- .identifier
- .starts_with("model_requests/")
- );
- assert_eq!(create_meter_event_calls[0].stripe_customer_id, customer_id);
- assert_eq!(
- create_meter_event_calls[0].event_name.as_ref(),
- "some_model/requests"
- );
- assert_eq!(create_meter_event_calls[0].value, 73);
-}
-
-#[gpui::test]
-async fn test_checkout_with_zed_pro() {
- let (stripe_billing, stripe_client) = make_stripe_billing();
-
- let customer_id = StripeCustomerId("cus_test".into());
- let github_login = "zeduser1";
- let success_url = "https://example.com/success";
-
- // It returns an error when the Zed Pro price doesn't exist.
- {
- let result = stripe_billing
- .checkout_with_zed_pro(&customer_id, github_login, success_url)
- .await;
-
- assert!(result.is_err());
- assert_eq!(
- result.err().unwrap().to_string(),
- r#"no price ID found for "zed-pro""#
- );
- }
-
- // Successful checkout.
- {
- let price = StripePrice {
- id: StripePriceId("price_1".into()),
- unit_amount: Some(2000),
- lookup_key: Some("zed-pro".to_string()),
- recurring: None,
- };
- stripe_client
- .prices
- .lock()
- .insert(price.id.clone(), price.clone());
-
- stripe_billing.initialize().await.unwrap();
-
- let checkout_url = stripe_billing
- .checkout_with_zed_pro(&customer_id, github_login, success_url)
- .await
- .unwrap();
-
- assert!(checkout_url.starts_with("https://checkout.stripe.com/c/pay"));
-
- let create_checkout_session_calls = stripe_client
- .create_checkout_session_calls
- .lock()
- .drain(..)
- .collect::<Vec<_>>();
- assert_eq!(create_checkout_session_calls.len(), 1);
- let call = create_checkout_session_calls.into_iter().next().unwrap();
- assert_eq!(call.customer, Some(customer_id));
- assert_eq!(call.client_reference_id.as_deref(), Some(github_login));
- assert_eq!(call.mode, Some(StripeCheckoutSessionMode::Subscription));
- assert_eq!(
- call.line_items,
- Some(vec![StripeCreateCheckoutSessionLineItems {
- price: Some(price.id.to_string()),
- quantity: Some(1)
- }])
- );
- assert_eq!(call.payment_method_collection, None);
- assert_eq!(call.subscription_data, None);
- assert_eq!(call.success_url.as_deref(), Some(success_url));
- assert_eq!(
- call.billing_address_collection,
- Some(StripeBillingAddressCollection::Required)
- );
- assert_eq!(
- call.customer_update,
- Some(StripeCustomerUpdate {
- address: Some(StripeCustomerUpdateAddress::Auto),
- name: Some(StripeCustomerUpdateName::Auto),
- shipping: None,
- })
- );
- }
-}
-
-#[gpui::test]
-async fn test_checkout_with_zed_pro_trial() {
- let (stripe_billing, stripe_client) = make_stripe_billing();
-
- let customer_id = StripeCustomerId("cus_test".into());
- let github_login = "zeduser1";
- let success_url = "https://example.com/success";
-
- // It returns an error when the Zed Pro price doesn't exist.
- {
- let result = stripe_billing
- .checkout_with_zed_pro_trial(&customer_id, github_login, Vec::new(), success_url)
- .await;
-
- assert!(result.is_err());
- assert_eq!(
- result.err().unwrap().to_string(),
- r#"no price ID found for "zed-pro""#
- );
- }
-
- let price = StripePrice {
- id: StripePriceId("price_1".into()),
- unit_amount: Some(2000),
- lookup_key: Some("zed-pro".to_string()),
- recurring: None,
- };
- stripe_client
- .prices
- .lock()
- .insert(price.id.clone(), price.clone());
-
- stripe_billing.initialize().await.unwrap();
-
- // Successful checkout.
- {
- let checkout_url = stripe_billing
- .checkout_with_zed_pro_trial(&customer_id, github_login, Vec::new(), success_url)
- .await
- .unwrap();
-
- assert!(checkout_url.starts_with("https://checkout.stripe.com/c/pay"));
-
- let create_checkout_session_calls = stripe_client
- .create_checkout_session_calls
- .lock()
- .drain(..)
- .collect::<Vec<_>>();
- assert_eq!(create_checkout_session_calls.len(), 1);
- let call = create_checkout_session_calls.into_iter().next().unwrap();
- assert_eq!(call.customer.as_ref(), Some(&customer_id));
- assert_eq!(call.client_reference_id.as_deref(), Some(github_login));
- assert_eq!(call.mode, Some(StripeCheckoutSessionMode::Subscription));
- assert_eq!(
- call.line_items,
- Some(vec![StripeCreateCheckoutSessionLineItems {
- price: Some(price.id.to_string()),
- quantity: Some(1)
- }])
- );
- assert_eq!(
- call.payment_method_collection,
- Some(StripeCheckoutSessionPaymentMethodCollection::IfRequired)
- );
- assert_eq!(
- call.subscription_data,
- Some(StripeCreateCheckoutSessionSubscriptionData {
- trial_period_days: Some(14),
- trial_settings: Some(StripeSubscriptionTrialSettings {
- end_behavior: StripeSubscriptionTrialSettingsEndBehavior {
- missing_payment_method:
- StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel,
- },
- }),
- metadata: None,
- })
- );
- assert_eq!(call.success_url.as_deref(), Some(success_url));
- assert_eq!(
- call.billing_address_collection,
- Some(StripeBillingAddressCollection::Required)
- );
- assert_eq!(
- call.customer_update,
- Some(StripeCustomerUpdate {
- address: Some(StripeCustomerUpdateAddress::Auto),
- name: Some(StripeCustomerUpdateName::Auto),
- shipping: None,
- })
- );
- }
-
- // Successful checkout with extended trial.
- {
- let checkout_url = stripe_billing
- .checkout_with_zed_pro_trial(
- &customer_id,
- github_login,
- vec![AGENT_EXTENDED_TRIAL_FEATURE_FLAG.to_string()],
- success_url,
- )
- .await
- .unwrap();
-
- assert!(checkout_url.starts_with("https://checkout.stripe.com/c/pay"));
-
- let create_checkout_session_calls = stripe_client
- .create_checkout_session_calls
- .lock()
- .drain(..)
- .collect::<Vec<_>>();
- assert_eq!(create_checkout_session_calls.len(), 1);
- let call = create_checkout_session_calls.into_iter().next().unwrap();
- assert_eq!(call.customer, Some(customer_id));
- assert_eq!(call.client_reference_id.as_deref(), Some(github_login));
- assert_eq!(call.mode, Some(StripeCheckoutSessionMode::Subscription));
- assert_eq!(
- call.line_items,
- Some(vec![StripeCreateCheckoutSessionLineItems {
- price: Some(price.id.to_string()),
- quantity: Some(1)
- }])
- );
- assert_eq!(
- call.payment_method_collection,
- Some(StripeCheckoutSessionPaymentMethodCollection::IfRequired)
- );
- assert_eq!(
- call.subscription_data,
- Some(StripeCreateCheckoutSessionSubscriptionData {
- trial_period_days: Some(60),
- trial_settings: Some(StripeSubscriptionTrialSettings {
- end_behavior: StripeSubscriptionTrialSettingsEndBehavior {
- missing_payment_method:
- StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel,
- },
- }),
- metadata: Some(std::collections::HashMap::from_iter([(
- "promo_feature_flag".into(),
- AGENT_EXTENDED_TRIAL_FEATURE_FLAG.into()
- )])),
- })
- );
- assert_eq!(call.success_url.as_deref(), Some(success_url));
- assert_eq!(
- call.billing_address_collection,
- Some(StripeBillingAddressCollection::Required)
- );
- assert_eq!(
- call.customer_update,
- Some(StripeCustomerUpdate {
- address: Some(StripeCustomerUpdateAddress::Auto),
- name: Some(StripeCustomerUpdateName::Auto),
- shipping: None,
- })
- );
- }
-}
@@ -1,4 +1,3 @@
-use crate::stripe_client::FakeStripeClient;
use crate::{
AppState, Config,
db::{NewUserParams, UserId, tests::TestDb},
@@ -8,6 +7,7 @@ use crate::{
use anyhow::anyhow;
use call::ActiveCall;
use channel::{ChannelBuffer, ChannelStore};
+use client::test::{make_get_authenticated_user_response, parse_authorization_header};
use client::{
self, ChannelId, Client, Connection, Credentials, EstablishConnectionError, UserStore,
proto::PeerId,
@@ -20,7 +20,7 @@ use fs::FakeFs;
use futures::{StreamExt as _, channel::oneshot};
use git::GitHostingProviderRegistry;
use gpui::{AppContext as _, BackgroundExecutor, Entity, Task, TestAppContext, VisualTestContext};
-use http_client::FakeHttpClient;
+use http_client::{FakeHttpClient, Method};
use language::LanguageRegistry;
use node_runtime::NodeRuntime;
use notifications::NotificationStore;
@@ -161,6 +161,8 @@ impl TestServer {
}
pub async fn create_client(&mut self, cx: &mut TestAppContext, name: &str) -> TestClient {
+ const ACCESS_TOKEN: &str = "the-token";
+
let fs = FakeFs::new(cx.executor());
cx.update(|cx| {
@@ -175,7 +177,7 @@ impl TestServer {
});
let clock = Arc::new(FakeSystemClock::new());
- let http = FakeHttpClient::with_404_response();
+
let user_id = if let Ok(Some(user)) = self.app_state.db.get_user_by_github_login(name).await
{
user.id
@@ -197,6 +199,47 @@ impl TestServer {
.expect("creating user failed")
.user_id
};
+
+ let http = FakeHttpClient::create({
+ let name = name.to_string();
+ move |req| {
+ let name = name.clone();
+ async move {
+ match (req.method(), req.uri().path()) {
+ (&Method::GET, "/client/users/me") => {
+ let credentials = parse_authorization_header(&req);
+ if credentials
+ != Some(Credentials {
+ user_id: user_id.to_proto(),
+ access_token: ACCESS_TOKEN.into(),
+ })
+ {
+ return Ok(http_client::Response::builder()
+ .status(401)
+ .body("Unauthorized".into())
+ .unwrap());
+ }
+
+ Ok(http_client::Response::builder()
+ .status(200)
+ .body(
+ serde_json::to_string(&make_get_authenticated_user_response(
+ user_id.0, name,
+ ))
+ .unwrap()
+ .into(),
+ )
+ .unwrap())
+ }
+ _ => Ok(http_client::Response::builder()
+ .status(404)
+ .body("Not Found".into())
+ .unwrap()),
+ }
+ }
+ }
+ });
+
let client_name = name.to_string();
let mut client = cx.update(|cx| Client::new(clock, http.clone(), cx));
let server = self.server.clone();
@@ -208,11 +251,10 @@ impl TestServer {
.unwrap()
.set_id(user_id.to_proto())
.override_authenticate(move |cx| {
- let access_token = "the-token".to_string();
cx.spawn(async move |_| {
Ok(Credentials {
user_id: user_id.to_proto(),
- access_token,
+ access_token: ACCESS_TOKEN.into(),
})
})
})
@@ -221,7 +263,7 @@ impl TestServer {
credentials,
&Credentials {
user_id: user_id.0 as u64,
- access_token: "the-token".into()
+ access_token: ACCESS_TOKEN.into(),
}
);
@@ -254,6 +296,8 @@ impl TestServer {
client_name,
Principal::User(user),
ZedVersion(SemanticVersion::new(1, 0, 0)),
+ Some("test".to_string()),
+ None,
None,
None,
Some(connection_id_tx),
@@ -318,7 +362,7 @@ impl TestServer {
});
client
- .authenticate_and_connect(false, &cx.to_async())
+ .connect(false, &cx.to_async())
.await
.into_response()
.unwrap();
@@ -326,8 +370,8 @@ impl TestServer {
let client = TestClient {
app_state,
username: name.to_string(),
- channel_store: cx.read(ChannelStore::global).clone(),
- notification_store: cx.read(NotificationStore::global).clone(),
+ channel_store: cx.read(ChannelStore::global),
+ notification_store: cx.read(NotificationStore::global),
state: Default::default(),
};
client.wait_for_current_user(cx).await;
@@ -521,12 +565,8 @@ impl TestServer {
) -> Arc<AppState> {
Arc::new(AppState {
db: test_db.db().clone(),
- llm_db: None,
livekit_client: Some(Arc::new(livekit_test_server.create_api_client())),
blob_store_client: None,
- real_stripe_client: None,
- stripe_client: Some(Arc::new(FakeStripeClient::new())),
- stripe_billing: None,
executor,
kinesis_client: None,
config: Config {
@@ -563,7 +603,6 @@ impl TestServer {
auto_join_channel_id: None,
migrations_path: None,
seed_path: None,
- stripe_api_key: None,
supermaven_admin_api_key: None,
user_backfiller_github_access_token: None,
kinesis_region: None,
@@ -691,17 +730,17 @@ impl TestClient {
current: store
.contacts()
.iter()
- .map(|contact| contact.user.github_login.clone())
+ .map(|contact| contact.user.github_login.clone().to_string())
.collect(),
outgoing_requests: store
.outgoing_contact_requests()
.iter()
- .map(|user| user.github_login.clone())
+ .map(|user| user.github_login.clone().to_string())
.collect(),
incoming_requests: store
.incoming_contact_requests()
.iter()
- .map(|user| user.github_login.clone())
+ .map(|user| user.github_login.clone().to_string())
.collect(),
})
}
@@ -858,7 +897,7 @@ impl TestClient {
let window = cx.update(|cx| cx.active_window().unwrap().downcast::<Workspace>().unwrap());
let entity = window.root(cx).unwrap();
- let cx = VisualTestContext::from_window(*window.deref(), cx).as_mut();
+ let cx = VisualTestContext::from_window(*window.deref(), cx).into_mut();
// it might be nice to try and cleanup these at the end of each test.
(entity, cx)
}
@@ -130,17 +130,17 @@ impl UserBackfiller {
.and_then(|value| value.parse::<i64>().ok())
.and_then(|value| DateTime::from_timestamp(value, 0));
- if rate_limit_remaining == Some(0) {
- if let Some(reset_at) = rate_limit_reset {
- let now = Utc::now();
- if reset_at > now {
- let sleep_duration = reset_at - now;
- log::info!(
- "rate limit reached. Sleeping for {} seconds",
- sleep_duration.num_seconds()
- );
- self.executor.sleep(sleep_duration.to_std().unwrap()).await;
- }
+ if rate_limit_remaining == Some(0)
+ && let Some(reset_at) = rate_limit_reset
+ {
+ let now = Utc::now();
+ if reset_at > now {
+ let sleep_duration = reset_at - now;
+ log::info!(
+ "rate limit reached. Sleeping for {} seconds",
+ sleep_duration.num_seconds()
+ );
+ self.executor.sleep(sleep_duration.to_std().unwrap()).await;
}
}
@@ -66,7 +66,7 @@ impl ChannelView {
channel_id,
link_position,
pane.clone(),
- workspace.clone(),
+ workspace,
window,
cx,
);
@@ -107,43 +107,32 @@ impl ChannelView {
.find(|view| view.read(cx).channel_buffer.read(cx).remote_id(cx) == buffer_id);
// If this channel buffer is already open in this pane, just return it.
- if let Some(existing_view) = existing_view.clone() {
- if existing_view.read(cx).channel_buffer == channel_view.read(cx).channel_buffer
- {
- if let Some(link_position) = link_position {
- existing_view.update(cx, |channel_view, cx| {
- channel_view.focus_position_from_link(
- link_position,
- true,
- window,
- cx,
- )
- });
- }
- return existing_view;
+ if let Some(existing_view) = existing_view.clone()
+ && existing_view.read(cx).channel_buffer == channel_view.read(cx).channel_buffer
+ {
+ if let Some(link_position) = link_position {
+ existing_view.update(cx, |channel_view, cx| {
+ channel_view.focus_position_from_link(link_position, true, window, cx)
+ });
}
+ return existing_view;
}
// If the pane contained a disconnected view for this channel buffer,
// replace that.
- if let Some(existing_item) = existing_view {
- if let Some(ix) = pane.index_for_item(&existing_item) {
- pane.close_item_by_id(
- existing_item.entity_id(),
- SaveIntent::Skip,
- window,
- cx,
- )
+ if let Some(existing_item) = existing_view
+ && let Some(ix) = pane.index_for_item(&existing_item)
+ {
+ pane.close_item_by_id(existing_item.entity_id(), SaveIntent::Skip, window, cx)
.detach();
- pane.add_item(
- Box::new(channel_view.clone()),
- true,
- true,
- Some(ix),
- window,
- cx,
- );
- }
+ pane.add_item(
+ Box::new(channel_view.clone()),
+ true,
+ true,
+ Some(ix),
+ window,
+ cx,
+ );
}
if let Some(link_position) = link_position {
@@ -259,26 +248,21 @@ impl ChannelView {
.editor
.update(cx, |editor, cx| editor.snapshot(window, cx));
- if let Some(outline) = snapshot.buffer_snapshot.outline(None) {
- if let Some(item) = outline
+ if let Some(outline) = snapshot.buffer_snapshot.outline(None)
+ && let Some(item) = outline
.items
.iter()
.find(|item| &Channel::slug(&item.text).to_lowercase() == &position)
- {
- self.editor.update(cx, |editor, cx| {
- editor.change_selections(
- SelectionEffects::scroll(Autoscroll::focused()),
- window,
- cx,
- |s| {
- s.replace_cursors_with(|map| {
- vec![item.range.start.to_display_point(map)]
- })
- },
- )
- });
- return;
- }
+ {
+ self.editor.update(cx, |editor, cx| {
+ editor.change_selections(
+ SelectionEffects::scroll(Autoscroll::focused()),
+ window,
+ cx,
+ |s| s.replace_cursors_with(|map| vec![item.range.start.to_display_point(map)]),
+ )
+ });
+ return;
}
if !first_attempt {
@@ -103,28 +103,16 @@ impl ChatPanel {
});
cx.new(|cx| {
- let entity = cx.entity().downgrade();
- let message_list = ListState::new(
- 0,
- gpui::ListAlignment::Bottom,
- px(1000.),
- move |ix, window, cx| {
- if let Some(entity) = entity.upgrade() {
- entity.update(cx, |this: &mut Self, cx| {
- this.render_message(ix, window, cx).into_any_element()
- })
- } else {
- div().into_any()
+ let message_list = ListState::new(0, gpui::ListAlignment::Bottom, px(1000.));
+
+ message_list.set_scroll_handler(cx.listener(
+ |this: &mut Self, event: &ListScrollEvent, _, cx| {
+ if event.visible_range.start < MESSAGE_LOADING_THRESHOLD {
+ this.load_more_messages(cx);
}
+ this.is_scrolled_to_bottom = !event.is_scrolled;
},
- );
-
- message_list.set_scroll_handler(cx.listener(|this, event: &ListScrollEvent, _, cx| {
- if event.visible_range.start < MESSAGE_LOADING_THRESHOLD {
- this.load_more_messages(cx);
- }
- this.is_scrolled_to_bottom = !event.is_scrolled;
- }));
+ ));
let local_offset = chrono::Local::now().offset().local_minus_utc();
let mut this = Self {
@@ -299,19 +287,20 @@ impl ChatPanel {
}
fn acknowledge_last_message(&mut self, cx: &mut Context<Self>) {
- if self.active && self.is_scrolled_to_bottom {
- if let Some((chat, _)) = &self.active_chat {
- if let Some(channel_id) = self.channel_id(cx) {
- self.last_acknowledged_message_id = self
- .channel_store
- .read(cx)
- .last_acknowledge_message_id(channel_id);
- }
-
- chat.update(cx, |chat, cx| {
- chat.acknowledge_last_message(cx);
- });
+ if self.active
+ && self.is_scrolled_to_bottom
+ && let Some((chat, _)) = &self.active_chat
+ {
+ if let Some(channel_id) = self.channel_id(cx) {
+ self.last_acknowledged_message_id = self
+ .channel_store
+ .read(cx)
+ .last_acknowledge_message_id(channel_id);
}
+
+ chat.update(cx, |chat, cx| {
+ chat.acknowledge_last_message(cx);
+ });
}
}
@@ -399,7 +388,7 @@ impl ChatPanel {
ix: usize,
window: &mut Window,
cx: &mut Context<Self>,
- ) -> impl IntoElement {
+ ) -> AnyElement {
let active_chat = &self.active_chat.as_ref().unwrap().0;
let (message, is_continuation_from_previous, is_admin) =
active_chat.update(cx, |active_chat, cx| {
@@ -417,14 +406,13 @@ impl ChatPanel {
&& last_message.id != this_message.id
&& duration_since_last_message < Duration::from_secs(5 * 60);
- if let ChannelMessageId::Saved(id) = this_message.id {
- if this_message
+ if let ChannelMessageId::Saved(id) = this_message.id
+ && this_message
.mentions
.iter()
.any(|(_, user_id)| Some(*user_id) == self.client.user_id())
- {
- active_chat.acknowledge_message(id);
- }
+ {
+ active_chat.acknowledge_message(id);
}
(this_message, is_continuation_from_previous, is_admin)
@@ -582,6 +570,7 @@ impl ChatPanel {
self.render_popover_buttons(message_id, can_delete_message, can_edit_message, cx)
.mt_neg_2p5(),
)
+ .into_any_element()
}
fn has_open_menu(&self, message_id: Option<u64>) -> bool {
@@ -685,7 +674,7 @@ impl ChatPanel {
})
})
.when_some(message_id, |el, message_id| {
- let this = cx.entity().clone();
+ let this = cx.entity();
el.child(
self.render_popover_button(
@@ -882,34 +871,33 @@ impl ChatPanel {
scroll_to_message_id.or(this.last_acknowledged_message_id)
})?;
- if let Some(message_id) = scroll_to_message_id {
- if let Some(item_ix) =
+ if let Some(message_id) = scroll_to_message_id
+ && let Some(item_ix) =
ChannelChat::load_history_since_message(chat.clone(), message_id, cx.clone())
.await
- {
- this.update(cx, |this, cx| {
- if let Some(highlight_message_id) = highlight_message_id {
- let task = cx.spawn(async move |this, cx| {
- cx.background_executor().timer(Duration::from_secs(2)).await;
- this.update(cx, |this, cx| {
- this.highlighted_message.take();
- cx.notify();
- })
- .ok();
- });
-
- this.highlighted_message = Some((highlight_message_id, task));
- }
+ {
+ this.update(cx, |this, cx| {
+ if let Some(highlight_message_id) = highlight_message_id {
+ let task = cx.spawn(async move |this, cx| {
+ cx.background_executor().timer(Duration::from_secs(2)).await;
+ this.update(cx, |this, cx| {
+ this.highlighted_message.take();
+ cx.notify();
+ })
+ .ok();
+ });
- if this.active_chat.as_ref().map_or(false, |(c, _)| *c == chat) {
- this.message_list.scroll_to(ListOffset {
- item_ix,
- offset_in_item: px(0.0),
- });
- cx.notify();
- }
- })?;
- }
+ this.highlighted_message = Some((highlight_message_id, task));
+ }
+
+ if this.active_chat.as_ref().is_some_and(|(c, _)| *c == chat) {
+ this.message_list.scroll_to(ListOffset {
+ item_ix,
+ offset_in_item: px(0.0),
+ });
+ cx.notify();
+ }
+ })?;
}
Ok(())
@@ -979,7 +967,13 @@ impl Render for ChatPanel {
)
.child(div().flex_grow().px_2().map(|this| {
if self.active_chat.is_some() {
- this.child(list(self.message_list.clone()).size_full())
+ this.child(
+ list(
+ self.message_list.clone(),
+ cx.processor(Self::render_message),
+ )
+ .size_full(),
+ )
} else {
this.child(
div()
@@ -1044,7 +1038,7 @@ impl Render for ChatPanel {
.cloned();
el.when_some(reply_message, |el, reply_message| {
- let user_being_replied_to = reply_message.sender.clone();
+ let user_being_replied_to = reply_message.sender;
el.child(
h_flex()
@@ -1162,7 +1156,7 @@ impl Panel for ChatPanel {
}
fn icon(&self, _window: &Window, cx: &App) -> Option<ui::IconName> {
- self.enabled(cx).then(|| ui::IconName::MessageBubbles)
+ self.enabled(cx).then(|| ui::IconName::Chat)
}
fn icon_tooltip(&self, _: &Window, _: &App) -> Option<&'static str> {
@@ -1192,7 +1186,7 @@ impl Panel for ChatPanel {
let is_in_call = ActiveCall::global(cx)
.read(cx)
.room()
- .map_or(false, |room| room.read(cx).contains_guests());
+ .is_some_and(|room| room.read(cx).contains_guests());
self.active || is_in_call
}
@@ -241,38 +241,36 @@ impl MessageEditor {
) -> Task<Result<Vec<CompletionResponse>>> {
if let Some((start_anchor, query, candidates)) =
self.collect_mention_candidates(buffer, end_anchor, cx)
+ && !candidates.is_empty()
{
- if !candidates.is_empty() {
- return cx.spawn(async move |_, cx| {
- let completion_response = Self::completions_for_candidates(
- &cx,
- query.as_str(),
- &candidates,
- start_anchor..end_anchor,
- Self::completion_for_mention,
- )
- .await;
- Ok(vec![completion_response])
- });
- }
+ return cx.spawn(async move |_, cx| {
+ let completion_response = Self::completions_for_candidates(
+ cx,
+ query.as_str(),
+ &candidates,
+ start_anchor..end_anchor,
+ Self::completion_for_mention,
+ )
+ .await;
+ Ok(vec![completion_response])
+ });
}
if let Some((start_anchor, query, candidates)) =
self.collect_emoji_candidates(buffer, end_anchor, cx)
+ && !candidates.is_empty()
{
- if !candidates.is_empty() {
- return cx.spawn(async move |_, cx| {
- let completion_response = Self::completions_for_candidates(
- &cx,
- query.as_str(),
- candidates,
- start_anchor..end_anchor,
- Self::completion_for_emoji,
- )
- .await;
- Ok(vec![completion_response])
- });
- }
+ return cx.spawn(async move |_, cx| {
+ let completion_response = Self::completions_for_candidates(
+ cx,
+ query.as_str(),
+ candidates,
+ start_anchor..end_anchor,
+ Self::completion_for_emoji,
+ )
+ .await;
+ Ok(vec![completion_response])
+ });
}
Task::ready(Ok(vec![CompletionResponse {
@@ -399,11 +397,10 @@ impl MessageEditor {
) -> Option<(Anchor, String, &'static [StringMatchCandidate])> {
static EMOJI_FUZZY_MATCH_CANDIDATES: LazyLock<Vec<StringMatchCandidate>> =
LazyLock::new(|| {
- let emojis = emojis::iter()
+ emojis::iter()
.flat_map(|s| s.shortcodes())
.map(|emoji| StringMatchCandidate::new(0, emoji))
- .collect::<Vec<_>>();
- emojis
+ .collect::<Vec<_>>()
});
let end_offset = end_anchor.to_offset(buffer.read(cx));
@@ -474,18 +471,17 @@ impl MessageEditor {
for range in ranges {
text.clear();
text.extend(buffer.text_for_range(range.clone()));
- if let Some(username) = text.strip_prefix('@') {
- if let Some(user) = this
+ if let Some(username) = text.strip_prefix('@')
+ && let Some(user) = this
.user_store
.read(cx)
.cached_user_by_github_login(username)
- {
- let start = multi_buffer.anchor_after(range.start);
- let end = multi_buffer.anchor_after(range.end);
+ {
+ let start = multi_buffer.anchor_after(range.start);
+ let end = multi_buffer.anchor_after(range.end);
- mentioned_user_ids.push(user.id);
- anchor_ranges.push(start..end);
- }
+ mentioned_user_ids.push(user.id);
+ anchor_ranges.push(start..end);
}
}
@@ -95,7 +95,7 @@ pub fn init(cx: &mut App) {
.and_then(|room| room.read(cx).channel_id());
if let Some(channel_id) = channel_id {
- let workspace = cx.entity().clone();
+ let workspace = cx.entity();
window.defer(cx, move |window, cx| {
ChannelView::open(channel_id, None, workspace, window, cx)
.detach_and_log_err(cx)
@@ -144,10 +144,22 @@ pub fn init(cx: &mut App) {
if let Some(room) = room {
window.defer(cx, move |_window, cx| {
room.update(cx, |room, cx| {
- if room.is_screen_sharing() {
- room.unshare_screen(cx).ok();
+ if room.is_sharing_screen() {
+ room.unshare_screen(true, cx).ok();
} else {
- room.share_screen(cx).detach_and_log_err(cx);
+ let sources = cx.screen_capture_sources();
+
+ cx.spawn(async move |room, cx| {
+ let sources = sources.await??;
+ let first = sources.into_iter().next();
+ if let Some(first) = first {
+ room.update(cx, |room, cx| room.share_screen(first, cx))?
+ .await
+ } else {
+ Ok(())
+ }
+ })
+ .detach_and_log_err(cx);
};
});
});
@@ -299,10 +311,10 @@ impl CollabPanel {
window,
|this: &mut Self, _, event, window, cx| {
if let editor::EditorEvent::Blurred = event {
- if let Some(state) = &this.channel_editing_state {
- if state.pending_name().is_some() {
- return;
- }
+ if let Some(state) = &this.channel_editing_state
+ && state.pending_name().is_some()
+ {
+ return;
}
this.take_editing_state(window, cx);
this.update_entries(false, cx);
@@ -312,20 +324,6 @@ impl CollabPanel {
)
.detach();
- let entity = cx.entity().downgrade();
- let list_state = ListState::new(
- 0,
- gpui::ListAlignment::Top,
- px(1000.),
- move |ix, window, cx| {
- if let Some(entity) = entity.upgrade() {
- entity.update(cx, |this, cx| this.render_list_entry(ix, window, cx))
- } else {
- div().into_any()
- }
- },
- );
-
let mut this = Self {
width: None,
focus_handle: cx.focus_handle(),
@@ -333,7 +331,7 @@ impl CollabPanel {
fs: workspace.app_state().fs.clone(),
pending_serialization: Task::ready(None),
context_menu: None,
- list_state,
+ list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
channel_name_editor,
filter_editor,
entries: Vec::default(),
@@ -493,11 +491,11 @@ impl CollabPanel {
if !self.collapsed_sections.contains(&Section::ActiveCall) {
let room = room.read(cx);
- if query.is_empty() {
- if let Some(channel_id) = room.channel_id() {
- self.entries.push(ListEntry::ChannelNotes { channel_id });
- self.entries.push(ListEntry::ChannelChat { channel_id });
- }
+ if query.is_empty()
+ && let Some(channel_id) = room.channel_id()
+ {
+ self.entries.push(ListEntry::ChannelNotes { channel_id });
+ self.entries.push(ListEntry::ChannelChat { channel_id });
}
// Populate the active user.
@@ -528,10 +526,10 @@ impl CollabPanel {
project_id: project.id,
worktree_root_names: project.worktree_root_names.clone(),
host_user_id: user_id,
- is_last: projects.peek().is_none() && !room.is_screen_sharing(),
+ is_last: projects.peek().is_none() && !room.is_sharing_screen(),
});
}
- if room.is_screen_sharing() {
+ if room.is_sharing_screen() {
self.entries.push(ListEntry::ParticipantScreen {
peer_id: None,
is_last: true,
@@ -641,10 +639,10 @@ impl CollabPanel {
&Default::default(),
executor.clone(),
));
- if let Some(state) = &self.channel_editing_state {
- if matches!(state, ChannelEditingState::Create { location: None, .. }) {
- self.entries.push(ListEntry::ChannelEditor { depth: 0 });
- }
+ if let Some(state) = &self.channel_editing_state
+ && matches!(state, ChannelEditingState::Create { location: None, .. })
+ {
+ self.entries.push(ListEntry::ChannelEditor { depth: 0 });
}
let mut collapse_depth = None;
for mat in matches {
@@ -666,9 +664,7 @@ impl CollabPanel {
let has_children = channel_store
.channel_at_index(mat.candidate_id + 1)
- .map_or(false, |next_channel| {
- next_channel.parent_path.ends_with(&[channel.id])
- });
+ .is_some_and(|next_channel| next_channel.parent_path.ends_with(&[channel.id]));
match &self.channel_editing_state {
Some(ChannelEditingState::Create {
@@ -928,7 +924,7 @@ impl CollabPanel {
room.read(cx).local_participant().role == proto::ChannelRole::Admin
});
- ListItem::new(SharedString::from(user.github_login.clone()))
+ ListItem::new(user.github_login.clone())
.start_slot(Avatar::new(user.avatar_uri.clone()))
.child(Label::new(user.github_login.clone()))
.toggle_state(is_selected)
@@ -1112,7 +1108,7 @@ impl CollabPanel {
.relative()
.gap_1()
.child(render_tree_branch(false, false, window, cx))
- .child(IconButton::new(0, IconName::MessageBubbles))
+ .child(IconButton::new(0, IconName::Chat))
.children(has_messages_notification.then(|| {
div()
.w_1p5()
@@ -1127,7 +1123,7 @@ impl CollabPanel {
}
fn has_subchannels(&self, ix: usize) -> bool {
- self.entries.get(ix).map_or(false, |entry| {
+ self.entries.get(ix).is_some_and(|entry| {
if let ListEntry::Channel { has_children, .. } = entry {
*has_children
} else {
@@ -1144,7 +1140,7 @@ impl CollabPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let this = cx.entity().clone();
+ let this = cx.entity();
if !(role == proto::ChannelRole::Guest
|| role == proto::ChannelRole::Talker
|| role == proto::ChannelRole::Member)
@@ -1274,7 +1270,7 @@ impl CollabPanel {
.channel_for_id(clipboard.channel_id)
.map(|channel| channel.name.clone())
});
- let this = cx.entity().clone();
+ let this = cx.entity();
let context_menu = ContextMenu::build(window, cx, |mut context_menu, window, cx| {
if self.has_subchannels(ix) {
@@ -1441,7 +1437,7 @@ impl CollabPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let this = cx.entity().clone();
+ let this = cx.entity();
let in_room = ActiveCall::global(cx).read(cx).room().is_some();
let context_menu = ContextMenu::build(window, cx, |mut context_menu, _, _| {
@@ -1554,98 +1550,93 @@ impl CollabPanel {
return;
}
- if let Some(selection) = self.selection {
- if let Some(entry) = self.entries.get(selection) {
- match entry {
- ListEntry::Header(section) => match section {
- Section::ActiveCall => Self::leave_call(window, cx),
- Section::Channels => self.new_root_channel(window, cx),
- Section::Contacts => self.toggle_contact_finder(window, cx),
- Section::ContactRequests
- | Section::Online
- | Section::Offline
- | Section::ChannelInvites => {
- self.toggle_section_expanded(*section, cx);
- }
- },
- ListEntry::Contact { contact, calling } => {
- if contact.online && !contact.busy && !calling {
- self.call(contact.user.id, window, cx);
- }
+ if let Some(selection) = self.selection
+ && let Some(entry) = self.entries.get(selection)
+ {
+ match entry {
+ ListEntry::Header(section) => match section {
+ Section::ActiveCall => Self::leave_call(window, cx),
+ Section::Channels => self.new_root_channel(window, cx),
+ Section::Contacts => self.toggle_contact_finder(window, cx),
+ Section::ContactRequests
+ | Section::Online
+ | Section::Offline
+ | Section::ChannelInvites => {
+ self.toggle_section_expanded(*section, cx);
}
- ListEntry::ParticipantProject {
- project_id,
- host_user_id,
- ..
- } => {
- if let Some(workspace) = self.workspace.upgrade() {
- let app_state = workspace.read(cx).app_state().clone();
- workspace::join_in_room_project(
- *project_id,
- *host_user_id,
- app_state,
- cx,
- )
+ },
+ ListEntry::Contact { contact, calling } => {
+ if contact.online && !contact.busy && !calling {
+ self.call(contact.user.id, window, cx);
+ }
+ }
+ ListEntry::ParticipantProject {
+ project_id,
+ host_user_id,
+ ..
+ } => {
+ if let Some(workspace) = self.workspace.upgrade() {
+ let app_state = workspace.read(cx).app_state().clone();
+ workspace::join_in_room_project(*project_id, *host_user_id, app_state, cx)
.detach_and_prompt_err(
"Failed to join project",
window,
cx,
|_, _, _| None,
);
- }
- }
- ListEntry::ParticipantScreen { peer_id, .. } => {
- let Some(peer_id) = peer_id else {
- return;
- };
- if let Some(workspace) = self.workspace.upgrade() {
- workspace.update(cx, |workspace, cx| {
- workspace.open_shared_screen(*peer_id, window, cx)
- });
- }
- }
- ListEntry::Channel { channel, .. } => {
- let is_active = maybe!({
- let call_channel = ActiveCall::global(cx)
- .read(cx)
- .room()?
- .read(cx)
- .channel_id()?;
-
- Some(call_channel == channel.id)
- })
- .unwrap_or(false);
- if is_active {
- self.open_channel_notes(channel.id, window, cx)
- } else {
- self.join_channel(channel.id, window, cx)
- }
}
- ListEntry::ContactPlaceholder => self.toggle_contact_finder(window, cx),
- ListEntry::CallParticipant { user, peer_id, .. } => {
- if Some(user) == self.user_store.read(cx).current_user().as_ref() {
- Self::leave_call(window, cx);
- } else if let Some(peer_id) = peer_id {
- self.workspace
- .update(cx, |workspace, cx| workspace.follow(*peer_id, window, cx))
- .ok();
- }
- }
- ListEntry::IncomingRequest(user) => {
- self.respond_to_contact_request(user.id, true, window, cx)
- }
- ListEntry::ChannelInvite(channel) => {
- self.respond_to_channel_invite(channel.id, true, cx)
+ }
+ ListEntry::ParticipantScreen { peer_id, .. } => {
+ let Some(peer_id) = peer_id else {
+ return;
+ };
+ if let Some(workspace) = self.workspace.upgrade() {
+ workspace.update(cx, |workspace, cx| {
+ workspace.open_shared_screen(*peer_id, window, cx)
+ });
}
- ListEntry::ChannelNotes { channel_id } => {
- self.open_channel_notes(*channel_id, window, cx)
+ }
+ ListEntry::Channel { channel, .. } => {
+ let is_active = maybe!({
+ let call_channel = ActiveCall::global(cx)
+ .read(cx)
+ .room()?
+ .read(cx)
+ .channel_id()?;
+
+ Some(call_channel == channel.id)
+ })
+ .unwrap_or(false);
+ if is_active {
+ self.open_channel_notes(channel.id, window, cx)
+ } else {
+ self.join_channel(channel.id, window, cx)
}
- ListEntry::ChannelChat { channel_id } => {
- self.join_channel_chat(*channel_id, window, cx)
+ }
+ ListEntry::ContactPlaceholder => self.toggle_contact_finder(window, cx),
+ ListEntry::CallParticipant { user, peer_id, .. } => {
+ if Some(user) == self.user_store.read(cx).current_user().as_ref() {
+ Self::leave_call(window, cx);
+ } else if let Some(peer_id) = peer_id {
+ self.workspace
+ .update(cx, |workspace, cx| workspace.follow(*peer_id, window, cx))
+ .ok();
}
- ListEntry::OutgoingRequest(_) => {}
- ListEntry::ChannelEditor { .. } => {}
}
+ ListEntry::IncomingRequest(user) => {
+ self.respond_to_contact_request(user.id, true, window, cx)
+ }
+ ListEntry::ChannelInvite(channel) => {
+ self.respond_to_channel_invite(channel.id, true, cx)
+ }
+ ListEntry::ChannelNotes { channel_id } => {
+ self.open_channel_notes(*channel_id, window, cx)
+ }
+ ListEntry::ChannelChat { channel_id } => {
+ self.join_channel_chat(*channel_id, window, cx)
+ }
+ ListEntry::OutgoingRequest(_) => {}
+ ListEntry::ChannelEditor { .. } => {}
}
}
}
@@ -1830,10 +1821,10 @@ impl CollabPanel {
}
fn select_channel_editor(&mut self) {
- self.selection = self.entries.iter().position(|entry| match entry {
- ListEntry::ChannelEditor { .. } => true,
- _ => false,
- });
+ self.selection = self
+ .entries
+ .iter()
+ .position(|entry| matches!(entry, ListEntry::ChannelEditor { .. }));
}
fn new_subchannel(
@@ -2319,7 +2310,7 @@ impl CollabPanel {
let client = this.client.clone();
cx.spawn_in(window, async move |_, cx| {
client
- .authenticate_and_connect(true, &cx)
+ .connect(true, cx)
.await
.into_response()
.notify_async_err(cx);
@@ -2419,7 +2410,13 @@ impl CollabPanel {
});
v_flex()
.size_full()
- .child(list(self.list_state.clone()).size_full())
+ .child(
+ list(
+ self.list_state.clone(),
+ cx.processor(Self::render_list_entry),
+ )
+ .size_full(),
+ )
.child(
v_flex()
.child(div().mx_2().border_primary(cx).border_t_1())
@@ -2510,7 +2507,7 @@ impl CollabPanel {
let button = match section {
Section::ActiveCall => channel_link.map(|channel_link| {
- let channel_link_copy = channel_link.clone();
+ let channel_link_copy = channel_link;
IconButton::new("channel-link", IconName::Copy)
.icon_size(IconSize::Small)
.size(ButtonSize::None)
@@ -2571,7 +2568,7 @@ impl CollabPanel {
) -> impl IntoElement {
let online = contact.online;
let busy = contact.busy || calling;
- let github_login = SharedString::from(contact.user.github_login.clone());
+ let github_login = contact.user.github_login.clone();
let item = ListItem::new(github_login.clone())
.indent_level(1)
.indent_step_size(px(20.))
@@ -2593,7 +2590,7 @@ impl CollabPanel {
let contact = contact.clone();
move |this, event: &ClickEvent, window, cx| {
this.deploy_contact_context_menu(
- event.down.position,
+ event.position(),
contact.clone(),
window,
cx,
@@ -2650,7 +2647,7 @@ impl CollabPanel {
is_selected: bool,
cx: &mut Context<Self>,
) -> impl IntoElement {
- let github_login = SharedString::from(user.github_login.clone());
+ let github_login = user.github_login.clone();
let user_id = user.id;
let is_response_pending = self.user_store.read(cx).is_contact_request_pending(user);
let color = if is_response_pending {
@@ -2694,7 +2691,7 @@ impl CollabPanel {
h_flex()
.w_full()
.justify_between()
- .child(Label::new(github_login.clone()))
+ .child(Label::new(github_login))
.child(h_flex().children(controls)),
)
.start_slot(Avatar::new(user.avatar_uri.clone()))
@@ -2908,10 +2905,12 @@ impl CollabPanel {
h_flex().absolute().right(rems(0.)).h_full().child(
h_flex()
.h_full()
+ .bg(cx.theme().colors().background)
+ .rounded_l_sm()
.gap_1()
.px_1()
.child(
- IconButton::new("channel_chat", IconName::MessageBubbles)
+ IconButton::new("channel_chat", IconName::Chat)
.style(ButtonStyle::Filled)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::Small)
@@ -2923,11 +2922,10 @@ impl CollabPanel {
.on_click(cx.listener(move |this, _, window, cx| {
this.join_channel_chat(channel_id, window, cx)
}))
- .tooltip(Tooltip::text("Open channel chat"))
- .visible_on_hover(""),
+ .tooltip(Tooltip::text("Open channel chat")),
)
.child(
- IconButton::new("channel_notes", IconName::File)
+ IconButton::new("channel_notes", IconName::Reader)
.style(ButtonStyle::Filled)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::Small)
@@ -2939,9 +2937,9 @@ impl CollabPanel {
.on_click(cx.listener(move |this, _, window, cx| {
this.open_channel_notes(channel_id, window, cx)
}))
- .tooltip(Tooltip::text("Open channel notes"))
- .visible_on_hover(""),
- ),
+ .tooltip(Tooltip::text("Open channel notes")),
+ )
+ .visible_on_hover(""),
),
)
.tooltip({
@@ -3049,7 +3047,7 @@ impl Render for CollabPanel {
.on_action(cx.listener(CollabPanel::move_channel_down))
.track_focus(&self.focus_handle)
.size_full()
- .child(if self.user_store.read(cx).current_user().is_none() {
+ .child(if !self.client.status().borrow().is_connected() {
self.render_signed_out(cx)
} else {
self.render_signed_in(window, cx)
@@ -3128,7 +3126,7 @@ impl Panel for CollabPanel {
impl Focusable for CollabPanel {
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
- self.filter_editor.focus_handle(cx).clone()
+ self.filter_editor.focus_handle(cx)
}
}
@@ -586,7 +586,7 @@ impl ChannelModalDelegate {
return;
};
let user_id = membership.user.id;
- let picker = cx.entity().clone();
+ let picker = cx.entity();
let context_menu = ContextMenu::build(window, cx, |mut menu, _window, _cx| {
let role = membership.role;
@@ -118,25 +118,15 @@ impl NotificationPanel {
})
.detach();
- let entity = cx.entity().downgrade();
- let notification_list =
- ListState::new(0, ListAlignment::Top, px(1000.), move |ix, window, cx| {
- entity
- .upgrade()
- .and_then(|entity| {
- entity.update(cx, |this, cx| this.render_notification(ix, window, cx))
- })
- .unwrap_or_else(|| div().into_any())
- });
+ let notification_list = ListState::new(0, ListAlignment::Top, px(1000.));
notification_list.set_scroll_handler(cx.listener(
|this, event: &ListScrollEvent, _, cx| {
- if event.count.saturating_sub(event.visible_range.end) < LOADING_THRESHOLD {
- if let Some(task) = this
+ if event.count.saturating_sub(event.visible_range.end) < LOADING_THRESHOLD
+ && let Some(task) = this
.notification_store
.update(cx, |store, cx| store.load_more_notifications(false, cx))
- {
- task.detach();
- }
+ {
+ task.detach();
}
},
));
@@ -299,7 +289,7 @@ impl NotificationPanel {
.gap_1()
.size_full()
.overflow_hidden()
- .child(Label::new(text.clone()))
+ .child(Label::new(text))
.child(
h_flex()
.child(
@@ -330,7 +320,7 @@ impl NotificationPanel {
.justify_end()
.child(Button::new("decline", "Decline").on_click({
let notification = notification.clone();
- let entity = cx.entity().clone();
+ let entity = cx.entity();
move |_, _, cx| {
entity.update(cx, |this, cx| {
this.respond_to_notification(
@@ -343,7 +333,7 @@ impl NotificationPanel {
}))
.child(Button::new("accept", "Accept").on_click({
let notification = notification.clone();
- let entity = cx.entity().clone();
+ let entity = cx.entity();
move |_, _, cx| {
entity.update(cx, |this, cx| {
this.respond_to_notification(
@@ -478,20 +468,19 @@ impl NotificationPanel {
channel_id,
..
} = notification.clone()
+ && let Some(workspace) = self.workspace.upgrade()
{
- if let Some(workspace) = self.workspace.upgrade() {
- window.defer(cx, move |window, cx| {
- workspace.update(cx, |workspace, cx| {
- if let Some(panel) = workspace.focus_panel::<ChatPanel>(window, cx) {
- panel.update(cx, |panel, cx| {
- panel
- .select_channel(ChannelId(channel_id), Some(message_id), cx)
- .detach_and_log_err(cx);
- });
- }
- });
+ window.defer(cx, move |window, cx| {
+ workspace.update(cx, |workspace, cx| {
+ if let Some(panel) = workspace.focus_panel::<ChatPanel>(window, cx) {
+ panel.update(cx, |panel, cx| {
+ panel
+ .select_channel(ChannelId(channel_id), Some(message_id), cx)
+ .detach_and_log_err(cx);
+ });
+ }
});
- }
+ });
}
}
@@ -500,18 +489,18 @@ impl NotificationPanel {
return false;
}
- if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification {
- if let Some(workspace) = self.workspace.upgrade() {
- return if let Some(panel) = workspace.read(cx).panel::<ChatPanel>(cx) {
- let panel = panel.read(cx);
- panel.is_scrolled_to_bottom()
- && panel
- .active_chat()
- .map_or(false, |chat| chat.read(cx).channel_id.0 == *channel_id)
- } else {
- false
- };
- }
+ if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification
+ && let Some(workspace) = self.workspace.upgrade()
+ {
+ return if let Some(panel) = workspace.read(cx).panel::<ChatPanel>(cx) {
+ let panel = panel.read(cx);
+ panel.is_scrolled_to_bottom()
+ && panel
+ .active_chat()
+ .is_some_and(|chat| chat.read(cx).channel_id.0 == *channel_id)
+ } else {
+ false
+ };
}
false
@@ -591,16 +580,16 @@ impl NotificationPanel {
}
fn remove_toast(&mut self, notification_id: u64, cx: &mut Context<Self>) {
- if let Some((current_id, _)) = &self.current_notification_toast {
- if *current_id == notification_id {
- self.current_notification_toast.take();
- self.workspace
- .update(cx, |workspace, cx| {
- let id = NotificationId::unique::<NotificationToast>();
- workspace.dismiss_notification(&id, cx)
- })
- .ok();
- }
+ if let Some((current_id, _)) = &self.current_notification_toast
+ && *current_id == notification_id
+ {
+ self.current_notification_toast.take();
+ self.workspace
+ .update(cx, |workspace, cx| {
+ let id = NotificationId::unique::<NotificationToast>();
+ workspace.dismiss_notification(&id, cx)
+ })
+ .ok();
}
}
@@ -634,13 +623,13 @@ impl Render for NotificationPanel {
.child(Icon::new(IconName::Envelope)),
)
.map(|this| {
- if self.client.user_id().is_none() {
+ if !self.client.status().borrow().is_connected() {
this.child(
v_flex()
.gap_2()
.p_4()
.child(
- Button::new("sign_in_prompt_button", "Sign in")
+ Button::new("connect_prompt_button", "Connect")
.icon_color(Color::Muted)
.icon(IconName::Github)
.icon_position(IconPosition::Start)
@@ -652,10 +641,7 @@ impl Render for NotificationPanel {
let client = client.clone();
window
.spawn(cx, async move |cx| {
- match client
- .authenticate_and_connect(true, &cx)
- .await
- {
+ match client.connect(true, cx).await {
util::ConnectionResult::Timeout => {
log::error!("Connection timeout");
}
@@ -673,7 +659,7 @@ impl Render for NotificationPanel {
)
.child(
div().flex().w_full().items_center().child(
- Label::new("Sign in to view notifications.")
+ Label::new("Connect to view notifications.")
.color(Color::Muted)
.size(LabelSize::Small),
),
@@ -690,7 +676,16 @@ impl Render for NotificationPanel {
),
)
} else {
- this.child(list(self.notification_list.clone()).size_full())
+ this.child(
+ list(
+ self.notification_list.clone(),
+ cx.processor(|this, ix, window, cx| {
+ this.render_notification(ix, window, cx)
+ .unwrap_or_else(|| div().into_any())
+ }),
+ )
+ .size_full(),
+ )
}
})
}
@@ -136,7 +136,10 @@ impl Focusable for CommandPalette {
impl Render for CommandPalette {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
- v_flex().w(rems(34.)).child(self.picker.clone())
+ v_flex()
+ .key_context("CommandPalette")
+ .w(rems(34.))
+ .child(self.picker.clone())
}
}
@@ -203,7 +206,7 @@ impl CommandPaletteDelegate {
if parse_zed_link(&query, cx).is_some() {
intercept_results = vec![CommandInterceptResult {
action: OpenZedUrl { url: query.clone() }.boxed_clone(),
- string: query.clone(),
+ string: query,
positions: vec![],
}]
}
@@ -242,7 +245,7 @@ impl CommandPaletteDelegate {
self.selected_ix = cmp::min(self.selected_ix, self.matches.len() - 1);
}
}
- ///
+
/// Hit count for each command in the palette.
/// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
/// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
@@ -318,8 +318,10 @@ pub enum ComponentScope {
Notification,
#[strum(serialize = "Overlays & Layering")]
Overlays,
+ Onboarding,
Status,
Typography,
+ Utilities,
#[strum(serialize = "Version Control")]
VersionControl,
}
@@ -42,26 +42,26 @@ impl RenderOnce for ComponentExample {
div()
.text_size(rems(0.875))
.text_color(cx.theme().colors().text_muted)
- .child(description.clone()),
+ .child(description),
)
}),
)
.child(
div()
- .flex()
- .w_full()
- .rounded_xl()
.min_h(px(100.))
- .justify_center()
+ .w_full()
.p_8()
+ .flex()
+ .items_center()
+ .justify_center()
+ .rounded_xl()
.border_1()
.border_color(cx.theme().colors().border.opacity(0.5))
.bg(pattern_slash(
- cx.theme().colors().surface_background.opacity(0.5),
+ cx.theme().colors().surface_background.opacity(0.25),
12.0,
12.0,
))
- .shadow_xs()
.child(self.element),
)
.into_any_element()
@@ -118,8 +118,8 @@ impl RenderOnce for ComponentExampleGroup {
.flex()
.items_center()
.gap_3()
- .pb_1()
- .child(div().h_px().w_4().bg(cx.theme().colors().border))
+ .mt_4()
+ .mb_1()
.child(
div()
.flex_none()
@@ -21,12 +21,14 @@ collections.workspace = true
futures.workspace = true
gpui.workspace = true
log.workspace = true
+net.workspace = true
parking_lot.workspace = true
postage.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
smol.workspace = true
+tempfile.workspace = true
url = { workspace = true, features = ["serde"] }
util.workspace = true
workspace-hack.workspace = true
@@ -1,6 +1,6 @@
use anyhow::{Context as _, Result, anyhow};
use collections::HashMap;
-use futures::{FutureExt, StreamExt, channel::oneshot, select};
+use futures::{FutureExt, StreamExt, channel::oneshot, future, select};
use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, Task};
use parking_lot::Mutex;
use postage::barrier;
@@ -10,15 +10,19 @@ use smol::channel;
use std::{
fmt,
path::PathBuf,
+ pin::pin,
sync::{
Arc,
atomic::{AtomicI32, Ordering::SeqCst},
},
time::{Duration, Instant},
};
-use util::TryFutureExt;
+use util::{ResultExt, TryFutureExt};
-use crate::transport::{StdioTransport, Transport};
+use crate::{
+ transport::{StdioTransport, Transport},
+ types::{CancelledParams, ClientNotification, Notification as _, notifications::Cancelled},
+};
const JSON_RPC_VERSION: &str = "2.0";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
@@ -32,6 +36,7 @@ pub const INTERNAL_ERROR: i32 = -32603;
type ResponseHandler = Box<dyn Send + FnOnce(Result<String, Error>)>;
type NotificationHandler = Box<dyn Send + FnMut(Value, AsyncApp)>;
+type RequestHandler = Box<dyn Send + FnMut(RequestId, &RawValue, AsyncApp)>;
#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(untagged)]
@@ -62,20 +67,25 @@ pub(crate) struct Client {
pub(crate) struct ContextServerId(pub Arc<str>);
fn is_null_value<T: Serialize>(value: &T) -> bool {
- if let Ok(Value::Null) = serde_json::to_value(value) {
- true
- } else {
- false
- }
+ matches!(serde_json::to_value(value), Ok(Value::Null))
}
#[derive(Serialize, Deserialize)]
-struct Request<'a, T> {
- jsonrpc: &'static str,
- id: RequestId,
- method: &'a str,
+pub struct Request<'a, T> {
+ pub jsonrpc: &'static str,
+ pub id: RequestId,
+ pub method: &'a str,
#[serde(skip_serializing_if = "is_null_value")]
- params: T,
+ pub params: T,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct AnyRequest<'a> {
+ pub jsonrpc: &'a str,
+ pub id: RequestId,
+ pub method: &'a str,
+ #[serde(skip_serializing_if = "is_null_value")]
+ pub params: Option<&'a RawValue>,
}
#[derive(Serialize, Deserialize)]
@@ -88,18 +98,18 @@ struct AnyResponse<'a> {
result: Option<&'a RawValue>,
}
-#[derive(Deserialize)]
+#[derive(Serialize, Deserialize)]
#[allow(dead_code)]
-struct Response<T> {
- jsonrpc: &'static str,
- id: RequestId,
+pub(crate) struct Response<T> {
+ pub jsonrpc: &'static str,
+ pub id: RequestId,
#[serde(flatten)]
- value: CspResult<T>,
+ pub value: CspResult<T>,
}
-#[derive(Deserialize)]
+#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
-enum CspResult<T> {
+pub(crate) enum CspResult<T> {
#[serde(rename = "result")]
Ok(Option<T>),
#[allow(dead_code)]
@@ -123,8 +133,9 @@ struct AnyNotification<'a> {
}
#[derive(Debug, Serialize, Deserialize)]
-struct Error {
- message: String,
+pub(crate) struct Error {
+ pub message: String,
+ pub code: i32,
}
#[derive(Debug, Clone, Deserialize)]
@@ -143,9 +154,10 @@ impl Client {
pub fn stdio(
server_id: ContextServerId,
binary: ModelContextServerBinary,
+ working_directory: &Option<PathBuf>,
cx: AsyncApp,
) -> Result<Self> {
- log::info!(
+ log::debug!(
"starting context server (executable={:?}, args={:?})",
binary.executable,
&binary.args
@@ -157,7 +169,7 @@ impl Client {
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(String::new);
- let transport = Arc::new(StdioTransport::new(binary, &cx)?);
+ let transport = Arc::new(StdioTransport::new(binary, working_directory, &cx)?);
Self::new(server_id, server_name.into(), transport, cx)
}
@@ -175,15 +187,23 @@ impl Client {
Arc::new(Mutex::new(HashMap::<_, NotificationHandler>::default()));
let response_handlers =
Arc::new(Mutex::new(Some(HashMap::<_, ResponseHandler>::default())));
+ let request_handlers = Arc::new(Mutex::new(HashMap::<_, RequestHandler>::default()));
let receive_input_task = cx.spawn({
let notification_handlers = notification_handlers.clone();
let response_handlers = response_handlers.clone();
+ let request_handlers = request_handlers.clone();
let transport = transport.clone();
async move |cx| {
- Self::handle_input(transport, notification_handlers, response_handlers, cx)
- .log_err()
- .await
+ Self::handle_input(
+ transport,
+ notification_handlers,
+ request_handlers,
+ response_handlers,
+ cx,
+ )
+ .log_err()
+ .await
}
});
let receive_err_task = cx.spawn({
@@ -229,23 +249,36 @@ impl Client {
async fn handle_input(
transport: Arc<dyn Transport>,
notification_handlers: Arc<Mutex<HashMap<&'static str, NotificationHandler>>>,
+ request_handlers: Arc<Mutex<HashMap<&'static str, RequestHandler>>>,
response_handlers: Arc<Mutex<Option<HashMap<RequestId, ResponseHandler>>>>,
cx: &mut AsyncApp,
) -> anyhow::Result<()> {
let mut receiver = transport.receive();
while let Some(message) = receiver.next().await {
- if let Ok(response) = serde_json::from_str::<AnyResponse>(&message) {
- if let Some(handlers) = response_handlers.lock().as_mut() {
- if let Some(handler) = handlers.remove(&response.id) {
- handler(Ok(message.to_string()));
- }
+ log::trace!("recv: {}", &message);
+ if let Ok(request) = serde_json::from_str::<AnyRequest>(&message) {
+ let mut request_handlers = request_handlers.lock();
+ if let Some(handler) = request_handlers.get_mut(request.method) {
+ handler(
+ request.id,
+ request.params.unwrap_or(RawValue::NULL),
+ cx.clone(),
+ );
+ }
+ } else if let Ok(response) = serde_json::from_str::<AnyResponse>(&message) {
+ if let Some(handlers) = response_handlers.lock().as_mut()
+ && let Some(handler) = handlers.remove(&response.id)
+ {
+ handler(Ok(message.to_string()));
}
} else if let Ok(notification) = serde_json::from_str::<AnyNotification>(&message) {
let mut notification_handlers = notification_handlers.lock();
if let Some(handler) = notification_handlers.get_mut(notification.method.as_str()) {
handler(notification.params.unwrap_or(Value::Null), cx.clone());
}
+ } else {
+ log::error!("Unhandled JSON from context_server: {}", message);
}
}
@@ -258,7 +291,7 @@ impl Client {
/// Continuously reads and logs any error messages from the server.
async fn handle_err(transport: Arc<dyn Transport>) -> anyhow::Result<()> {
while let Some(err) = transport.receive_err().next().await {
- log::warn!("context server stderr: {}", err.trim());
+ log::debug!("context server stderr: {}", err.trim());
}
Ok(())
@@ -293,6 +326,17 @@ impl Client {
&self,
method: &str,
params: impl Serialize,
+ ) -> Result<T> {
+ self.request_with(method, params, None, Some(REQUEST_TIMEOUT))
+ .await
+ }
+
+ pub async fn request_with<T: DeserializeOwned>(
+ &self,
+ method: &str,
+ params: impl Serialize,
+ cancel_rx: Option<oneshot::Receiver<()>>,
+ timeout: Option<Duration>,
) -> Result<T> {
let id = self.next_id.fetch_add(1, SeqCst);
let request = serde_json::to_string(&Request {
@@ -328,7 +372,23 @@ impl Client {
handle_response?;
send?;
- let mut timeout = executor.timer(REQUEST_TIMEOUT).fuse();
+ let mut timeout_fut = pin!(
+ match timeout {
+ Some(timeout) => future::Either::Left(executor.timer(timeout)),
+ None => future::Either::Right(future::pending()),
+ }
+ .fuse()
+ );
+ let mut cancel_fut = pin!(
+ match cancel_rx {
+ Some(rx) => future::Either::Left(async {
+ rx.await.log_err();
+ }),
+ None => future::Either::Right(future::pending()),
+ }
+ .fuse()
+ );
+
select! {
response = rx.fuse() => {
let elapsed = started.elapsed();
@@ -347,8 +407,18 @@ impl Client {
Err(_) => anyhow::bail!("cancelled")
}
}
- _ = timeout => {
- log::error!("cancelled csp request task for {method:?} id {id} which took over {:?}", REQUEST_TIMEOUT);
+ _ = cancel_fut => {
+ self.notify(
+ Cancelled::METHOD,
+ ClientNotification::Cancelled(CancelledParams {
+ request_id: RequestId::Int(id),
+ reason: None
+ })
+ ).log_err();
+ anyhow::bail!(RequestCanceled)
+ }
+ _ = timeout_fut => {
+ log::error!("cancelled csp request task for {method:?} id {id} which took over {:?}", timeout.unwrap());
anyhow::bail!("Context server request timeout");
}
}
@@ -367,14 +437,23 @@ impl Client {
Ok(())
}
- #[allow(unused)]
- pub fn on_notification<F>(&self, method: &'static str, f: F)
- where
- F: 'static + Send + FnMut(Value, AsyncApp),
- {
- self.notification_handlers
- .lock()
- .insert(method, Box::new(f));
+ pub fn on_notification(
+ &self,
+ method: &'static str,
+ f: Box<dyn 'static + Send + FnMut(Value, AsyncApp)>,
+ ) {
+ self.notification_handlers.lock().insert(method, f);
+ }
+}
+
+#[derive(Debug)]
+pub struct RequestCanceled;
+
+impl std::error::Error for RequestCanceled {}
+
+impl std::fmt::Display for RequestCanceled {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str("Context server request was canceled")
}
}
@@ -1,13 +1,14 @@
pub mod client;
+pub mod listener;
pub mod protocol;
#[cfg(any(test, feature = "test-support"))]
pub mod test;
pub mod transport;
pub mod types;
-use std::fmt::Display;
use std::path::Path;
use std::sync::Arc;
+use std::{fmt::Display, path::PathBuf};
use anyhow::Result;
use client::Client;
@@ -30,7 +31,7 @@ impl Display for ContextServerId {
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
pub struct ContextServerCommand {
#[serde(rename = "command")]
- pub path: String,
+ pub path: PathBuf,
pub args: Vec<String>,
pub env: Option<HashMap<String, String>>,
}
@@ -52,7 +53,7 @@ impl std::fmt::Debug for ContextServerCommand {
}
enum ContextServerTransport {
- Stdio(ContextServerCommand),
+ Stdio(ContextServerCommand, Option<PathBuf>),
Custom(Arc<dyn crate::transport::Transport>),
}
@@ -63,11 +64,18 @@ pub struct ContextServer {
}
impl ContextServer {
- pub fn stdio(id: ContextServerId, command: ContextServerCommand) -> Self {
+ pub fn stdio(
+ id: ContextServerId,
+ command: ContextServerCommand,
+ working_directory: Option<Arc<Path>>,
+ ) -> Self {
Self {
id,
client: RwLock::new(None),
- configuration: ContextServerTransport::Stdio(command),
+ configuration: ContextServerTransport::Stdio(
+ command,
+ working_directory.map(|directory| directory.to_path_buf()),
+ ),
}
}
@@ -87,15 +95,36 @@ impl ContextServer {
self.client.read().clone()
}
- pub async fn start(self: Arc<Self>, cx: &AsyncApp) -> Result<()> {
- let client = match &self.configuration {
- ContextServerTransport::Stdio(command) => Client::stdio(
+ pub async fn start(&self, cx: &AsyncApp) -> Result<()> {
+ self.initialize(self.new_client(cx)?).await
+ }
+
+ /// Starts the context server, making sure handlers are registered before initialization happens
+ pub async fn start_with_handlers(
+ &self,
+ notification_handlers: Vec<(
+ &'static str,
+ Box<dyn 'static + Send + FnMut(serde_json::Value, AsyncApp)>,
+ )>,
+ cx: &AsyncApp,
+ ) -> Result<()> {
+ let client = self.new_client(cx)?;
+ for (method, handler) in notification_handlers {
+ client.on_notification(method, handler);
+ }
+ self.initialize(client).await
+ }
+
+ fn new_client(&self, cx: &AsyncApp) -> Result<Client> {
+ Ok(match &self.configuration {
+ ContextServerTransport::Stdio(command, working_directory) => Client::stdio(
client::ContextServerId(self.id.0.clone()),
client::ModelContextServerBinary {
executable: Path::new(&command.path).to_path_buf(),
args: command.args.clone(),
env: command.env.clone(),
},
+ working_directory,
cx.clone(),
)?,
ContextServerTransport::Custom(transport) => Client::new(
@@ -104,12 +133,11 @@ impl ContextServer {
transport.clone(),
cx.clone(),
)?,
- };
- self.initialize(client).await
+ })
}
async fn initialize(&self, client: Client) -> Result<()> {
- log::info!("starting context server {}", self.id);
+ log::debug!("starting context server {}", self.id);
let protocol = crate::protocol::ModelContextProtocol::new(client);
let client_info = types::Implementation {
name: "Zed".to_string(),
@@ -0,0 +1,450 @@
+use ::serde::{Deserialize, Serialize};
+use anyhow::{Context as _, Result};
+use collections::HashMap;
+use futures::{
+ AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, FutureExt,
+ channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded},
+ io::BufReader,
+ select_biased,
+};
+use gpui::{App, AppContext, AsyncApp, Task};
+use net::async_net::{UnixListener, UnixStream};
+use schemars::JsonSchema;
+use serde::de::DeserializeOwned;
+use serde_json::{json, value::RawValue};
+use smol::stream::StreamExt;
+use std::{
+ any::TypeId,
+ cell::RefCell,
+ path::{Path, PathBuf},
+ rc::Rc,
+};
+use util::ResultExt;
+
+use crate::{
+ client::{CspResult, RequestId, Response},
+ types::{
+ CallToolParams, CallToolResponse, ListToolsResponse, Request, Tool, ToolAnnotations,
+ ToolResponseContent,
+ requests::{CallTool, ListTools},
+ },
+};
+
+pub struct McpServer {
+ socket_path: PathBuf,
+ tools: Rc<RefCell<HashMap<&'static str, RegisteredTool>>>,
+ handlers: Rc<RefCell<HashMap<&'static str, RequestHandler>>>,
+ _server_task: Task<()>,
+}
+
+struct RegisteredTool {
+ tool: Tool,
+ handler: ToolHandler,
+}
+
+type ToolHandler = Box<
+ dyn Fn(
+ Option<serde_json::Value>,
+ &mut AsyncApp,
+ ) -> Task<Result<ToolResponse<serde_json::Value>>>,
+>;
+type RequestHandler = Box<dyn Fn(RequestId, Option<Box<RawValue>>, &App) -> Task<String>>;
+
+impl McpServer {
+ pub fn new(cx: &AsyncApp) -> Task<Result<Self>> {
+ let task = cx.background_spawn(async move {
+ let temp_dir = tempfile::Builder::new().prefix("zed-mcp").tempdir()?;
+ let socket_path = temp_dir.path().join("mcp.sock");
+ let listener = UnixListener::bind(&socket_path).context("creating mcp socket")?;
+
+ anyhow::Ok((temp_dir, socket_path, listener))
+ });
+
+ cx.spawn(async move |cx| {
+ let (temp_dir, socket_path, listener) = task.await?;
+ let tools = Rc::new(RefCell::new(HashMap::default()));
+ let handlers = Rc::new(RefCell::new(HashMap::default()));
+ let server_task = cx.spawn({
+ let tools = tools.clone();
+ let handlers = handlers.clone();
+ async move |cx| {
+ while let Ok((stream, _)) = listener.accept().await {
+ Self::serve_connection(stream, tools.clone(), handlers.clone(), cx);
+ }
+ drop(temp_dir)
+ }
+ });
+ Ok(Self {
+ socket_path,
+ _server_task: server_task,
+ tools,
+ handlers,
+ })
+ })
+ }
+
+ pub fn add_tool<T: McpServerTool + Clone + 'static>(&mut self, tool: T) {
+ let mut settings = schemars::generate::SchemaSettings::draft07();
+ settings.inline_subschemas = true;
+ let mut generator = settings.into_generator();
+
+ let input_schema = generator.root_schema_for::<T::Input>();
+
+ let description = input_schema
+ .get("description")
+ .and_then(|desc| desc.as_str())
+ .map(|desc| desc.to_string());
+ debug_assert!(
+ description.is_some(),
+ "Input schema struct must include a doc comment for the tool description"
+ );
+
+ let registered_tool = RegisteredTool {
+ tool: Tool {
+ name: T::NAME.into(),
+ description,
+ input_schema: input_schema.into(),
+ output_schema: if TypeId::of::<T::Output>() == TypeId::of::<()>() {
+ None
+ } else {
+ Some(generator.root_schema_for::<T::Output>().into())
+ },
+ annotations: Some(tool.annotations()),
+ },
+ handler: Box::new({
+ move |input_value, cx| {
+ let input = match input_value {
+ Some(input) => serde_json::from_value(input),
+ None => serde_json::from_value(serde_json::Value::Null),
+ };
+
+ let tool = tool.clone();
+ match input {
+ Ok(input) => cx.spawn(async move |cx| {
+ let output = tool.run(input, cx).await?;
+
+ Ok(ToolResponse {
+ content: output.content,
+ structured_content: serde_json::to_value(output.structured_content)
+ .unwrap_or_default(),
+ })
+ }),
+ Err(err) => Task::ready(Err(err.into())),
+ }
+ }
+ }),
+ };
+
+ self.tools.borrow_mut().insert(T::NAME, registered_tool);
+ }
+
+ pub fn handle_request<R: Request>(
+ &mut self,
+ f: impl Fn(R::Params, &App) -> Task<Result<R::Response>> + 'static,
+ ) {
+ let f = Box::new(f);
+ self.handlers.borrow_mut().insert(
+ R::METHOD,
+ Box::new(move |req_id, opt_params, cx| {
+ let result = match opt_params {
+ Some(params) => serde_json::from_str(params.get()),
+ None => serde_json::from_value(serde_json::Value::Null),
+ };
+
+ let params: R::Params = match result {
+ Ok(params) => params,
+ Err(e) => {
+ return Task::ready(
+ serde_json::to_string(&Response::<R::Response> {
+ jsonrpc: "2.0",
+ id: req_id,
+ value: CspResult::Error(Some(crate::client::Error {
+ message: format!("{e}"),
+ code: -32700,
+ })),
+ })
+ .unwrap(),
+ );
+ }
+ };
+ let task = f(params, cx);
+ cx.background_spawn(async move {
+ match task.await {
+ Ok(result) => serde_json::to_string(&Response {
+ jsonrpc: "2.0",
+ id: req_id,
+ value: CspResult::Ok(Some(result)),
+ })
+ .unwrap(),
+ Err(e) => serde_json::to_string(&Response {
+ jsonrpc: "2.0",
+ id: req_id,
+ value: CspResult::Error::<R::Response>(Some(crate::client::Error {
+ message: format!("{e}"),
+ code: -32603,
+ })),
+ })
+ .unwrap(),
+ }
+ })
+ }),
+ );
+ }
+
+ pub fn socket_path(&self) -> &Path {
+ &self.socket_path
+ }
+
+ fn serve_connection(
+ stream: UnixStream,
+ tools: Rc<RefCell<HashMap<&'static str, RegisteredTool>>>,
+ handlers: Rc<RefCell<HashMap<&'static str, RequestHandler>>>,
+ cx: &mut AsyncApp,
+ ) {
+ let (read, write) = smol::io::split(stream);
+ let (incoming_tx, mut incoming_rx) = unbounded();
+ let (outgoing_tx, outgoing_rx) = unbounded();
+
+ cx.background_spawn(Self::handle_io(outgoing_rx, incoming_tx, write, read))
+ .detach();
+
+ cx.spawn(async move |cx| {
+ while let Some(request) = incoming_rx.next().await {
+ let Some(request_id) = request.id.clone() else {
+ continue;
+ };
+
+ if request.method == CallTool::METHOD {
+ Self::handle_call_tool(request_id, request.params, &tools, &outgoing_tx, cx)
+ .await;
+ } else if request.method == ListTools::METHOD {
+ Self::handle_list_tools(request.id.unwrap(), &tools, &outgoing_tx);
+ } else if let Some(handler) = handlers.borrow().get(&request.method.as_ref()) {
+ let outgoing_tx = outgoing_tx.clone();
+
+ if let Some(task) = cx
+ .update(|cx| handler(request_id, request.params, cx))
+ .log_err()
+ {
+ cx.spawn(async move |_| {
+ let response = task.await;
+ outgoing_tx.unbounded_send(response).ok();
+ })
+ .detach();
+ }
+ } else {
+ Self::send_err(
+ request_id,
+ format!("unhandled method {}", request.method),
+ &outgoing_tx,
+ );
+ }
+ }
+ })
+ .detach();
+ }
+
+ fn handle_list_tools(
+ request_id: RequestId,
+ tools: &Rc<RefCell<HashMap<&'static str, RegisteredTool>>>,
+ outgoing_tx: &UnboundedSender<String>,
+ ) {
+ let response = ListToolsResponse {
+ tools: tools.borrow().values().map(|t| t.tool.clone()).collect(),
+ next_cursor: None,
+ meta: None,
+ };
+
+ outgoing_tx
+ .unbounded_send(
+ serde_json::to_string(&Response {
+ jsonrpc: "2.0",
+ id: request_id,
+ value: CspResult::Ok(Some(response)),
+ })
+ .unwrap_or_default(),
+ )
+ .ok();
+ }
+
+ async fn handle_call_tool(
+ request_id: RequestId,
+ params: Option<Box<RawValue>>,
+ tools: &Rc<RefCell<HashMap<&'static str, RegisteredTool>>>,
+ outgoing_tx: &UnboundedSender<String>,
+ cx: &mut AsyncApp,
+ ) {
+ let result: Result<CallToolParams, serde_json::Error> = match params.as_ref() {
+ Some(params) => serde_json::from_str(params.get()),
+ None => serde_json::from_value(serde_json::Value::Null),
+ };
+
+ match result {
+ Ok(params) => {
+ if let Some(tool) = tools.borrow().get(¶ms.name.as_ref()) {
+ let outgoing_tx = outgoing_tx.clone();
+
+ let task = (tool.handler)(params.arguments, cx);
+ cx.spawn(async move |_| {
+ let response = match task.await {
+ Ok(result) => CallToolResponse {
+ content: result.content,
+ is_error: Some(false),
+ meta: None,
+ structured_content: if result.structured_content.is_null() {
+ None
+ } else {
+ Some(result.structured_content)
+ },
+ },
+ Err(err) => CallToolResponse {
+ content: vec![ToolResponseContent::Text {
+ text: err.to_string(),
+ }],
+ is_error: Some(true),
+ meta: None,
+ structured_content: None,
+ },
+ };
+
+ outgoing_tx
+ .unbounded_send(
+ serde_json::to_string(&Response {
+ jsonrpc: "2.0",
+ id: request_id,
+ value: CspResult::Ok(Some(response)),
+ })
+ .unwrap_or_default(),
+ )
+ .ok();
+ })
+ .detach();
+ } else {
+ Self::send_err(
+ request_id,
+ format!("Tool not found: {}", params.name),
+ outgoing_tx,
+ );
+ }
+ }
+ Err(err) => {
+ Self::send_err(request_id, err.to_string(), outgoing_tx);
+ }
+ }
+ }
+
+ fn send_err(
+ request_id: RequestId,
+ message: impl Into<String>,
+ outgoing_tx: &UnboundedSender<String>,
+ ) {
+ outgoing_tx
+ .unbounded_send(
+ serde_json::to_string(&Response::<()> {
+ jsonrpc: "2.0",
+ id: request_id,
+ value: CspResult::Error(Some(crate::client::Error {
+ message: message.into(),
+ code: -32601,
+ })),
+ })
+ .unwrap(),
+ )
+ .ok();
+ }
+
+ async fn handle_io(
+ mut outgoing_rx: UnboundedReceiver<String>,
+ incoming_tx: UnboundedSender<RawRequest>,
+ mut outgoing_bytes: impl Unpin + AsyncWrite,
+ incoming_bytes: impl Unpin + AsyncRead,
+ ) -> Result<()> {
+ let mut output_reader = BufReader::new(incoming_bytes);
+ let mut incoming_line = String::new();
+ loop {
+ select_biased! {
+ message = outgoing_rx.next().fuse() => {
+ if let Some(message) = message {
+ log::trace!("send: {}", &message);
+ outgoing_bytes.write_all(message.as_bytes()).await?;
+ outgoing_bytes.write_all(&[b'\n']).await?;
+ } else {
+ break;
+ }
+ }
+ bytes_read = output_reader.read_line(&mut incoming_line).fuse() => {
+ if bytes_read? == 0 {
+ break
+ }
+ log::trace!("recv: {}", &incoming_line);
+ match serde_json::from_str(&incoming_line) {
+ Ok(message) => {
+ incoming_tx.unbounded_send(message).log_err();
+ }
+ Err(error) => {
+ outgoing_bytes.write_all(serde_json::to_string(&json!({
+ "jsonrpc": "2.0",
+ "error": json!({
+ "code": -32603,
+ "message": format!("Failed to parse: {error}"),
+ }),
+ }))?.as_bytes()).await?;
+ outgoing_bytes.write_all(&[b'\n']).await?;
+ log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}");
+ }
+ }
+ incoming_line.clear();
+ }
+ }
+ }
+ Ok(())
+ }
+}
+
+pub trait McpServerTool {
+ type Input: DeserializeOwned + JsonSchema;
+ type Output: Serialize + JsonSchema;
+
+ const NAME: &'static str;
+
+ fn annotations(&self) -> ToolAnnotations {
+ ToolAnnotations {
+ title: None,
+ read_only_hint: None,
+ destructive_hint: None,
+ idempotent_hint: None,
+ open_world_hint: None,
+ }
+ }
+
+ fn run(
+ &self,
+ input: Self::Input,
+ cx: &mut AsyncApp,
+ ) -> impl Future<Output = Result<ToolResponse<Self::Output>>>;
+}
+
+#[derive(Debug)]
+pub struct ToolResponse<T> {
+ pub content: Vec<ToolResponseContent>,
+ pub structured_content: T,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+struct RawRequest {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ id: Option<RequestId>,
+ method: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ params: Option<Box<serde_json::value::RawValue>>,
+}
+
+#[derive(Serialize, Deserialize)]
+struct RawResponse {
+ jsonrpc: &'static str,
+ id: RequestId,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ error: Option<crate::client::Error>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ result: Option<Box<serde_json::value::RawValue>>,
+}
@@ -5,7 +5,12 @@
//! read/write messages and the types from types.rs for serialization/deserialization
//! of messages.
+use std::time::Duration;
+
use anyhow::Result;
+use futures::channel::oneshot;
+use gpui::AsyncApp;
+use serde_json::Value;
use crate::client::Client;
use crate::types::{self, Notification, Request};
@@ -95,7 +100,26 @@ impl InitializedContextServerProtocol {
self.inner.request(T::METHOD, params).await
}
+ pub async fn request_with<T: Request>(
+ &self,
+ params: T::Params,
+ cancel_rx: Option<oneshot::Receiver<()>>,
+ timeout: Option<Duration>,
+ ) -> Result<T::Response> {
+ self.inner
+ .request_with(T::METHOD, params, cancel_rx, timeout)
+ .await
+ }
+
pub fn notify<T: Notification>(&self, params: T::Params) -> Result<()> {
self.inner.notify(T::METHOD, params)
}
+
+ pub fn on_notification(
+ &self,
+ method: &'static str,
+ f: Box<dyn 'static + Send + FnMut(Value, AsyncApp)>,
+ ) {
+ self.inner.on_notification(method, f);
+ }
}
@@ -1,6 +1,6 @@
use anyhow::Context as _;
use collections::HashMap;
-use futures::{Stream, StreamExt as _, lock::Mutex};
+use futures::{FutureExt, Stream, StreamExt as _, future::BoxFuture, lock::Mutex};
use gpui::BackgroundExecutor;
use std::{pin::Pin, sync::Arc};
@@ -14,9 +14,12 @@ pub fn create_fake_transport(
executor: BackgroundExecutor,
) -> FakeTransport {
let name = name.into();
- FakeTransport::new(executor).on_request::<crate::types::requests::Initialize>(move |_params| {
- create_initialize_response(name.clone())
- })
+ FakeTransport::new(executor).on_request::<crate::types::requests::Initialize, _>(
+ move |_params| {
+ let name = name.clone();
+ async move { create_initialize_response(name.clone()) }
+ },
+ )
}
fn create_initialize_response(server_name: String) -> InitializeResponse {
@@ -32,8 +35,10 @@ fn create_initialize_response(server_name: String) -> InitializeResponse {
}
pub struct FakeTransport {
- request_handlers:
- HashMap<&'static str, Arc<dyn Fn(serde_json::Value) -> serde_json::Value + Send + Sync>>,
+ request_handlers: HashMap<
+ &'static str,
+ Arc<dyn Send + Sync + Fn(serde_json::Value) -> BoxFuture<'static, serde_json::Value>>,
+ >,
tx: futures::channel::mpsc::UnboundedSender<String>,
rx: Arc<Mutex<futures::channel::mpsc::UnboundedReceiver<String>>>,
executor: BackgroundExecutor,
@@ -50,18 +55,25 @@ impl FakeTransport {
}
}
- pub fn on_request<T: crate::types::Request>(
+ pub fn on_request<T, Fut>(
mut self,
- handler: impl Fn(T::Params) -> T::Response + Send + Sync + 'static,
- ) -> Self {
+ handler: impl 'static + Send + Sync + Fn(T::Params) -> Fut,
+ ) -> Self
+ where
+ T: crate::types::Request,
+ Fut: 'static + Send + Future<Output = T::Response>,
+ {
self.request_handlers.insert(
T::METHOD,
Arc::new(move |value| {
- let params = value.get("params").expect("Missing parameters").clone();
+ let params = value
+ .get("params")
+ .cloned()
+ .unwrap_or(serde_json::Value::Null);
let params: T::Params =
serde_json::from_value(params).expect("Invalid parameters received");
let response = handler(params);
- serde_json::to_value(response).unwrap()
+ async move { serde_json::to_value(response.await).unwrap() }.boxed()
}),
);
self
@@ -77,7 +89,7 @@ impl Transport for FakeTransport {
if let Some(method) = msg.get("method") {
let method = method.as_str().expect("Invalid method received");
if let Some(handler) = self.request_handlers.get(method) {
- let payload = handler(msg);
+ let payload = handler(msg).await;
let response = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
@@ -1,3 +1,4 @@
+use std::path::PathBuf;
use std::pin::Pin;
use anyhow::{Context as _, Result};
@@ -22,7 +23,11 @@ pub struct StdioTransport {
}
impl StdioTransport {
- pub fn new(binary: ModelContextServerBinary, cx: &AsyncApp) -> Result<Self> {
+ pub fn new(
+ binary: ModelContextServerBinary,
+ working_directory: &Option<PathBuf>,
+ cx: &AsyncApp,
+ ) -> Result<Self> {
let mut command = util::command::new_smol_command(&binary.executable);
command
.args(&binary.args)
@@ -32,6 +37,10 @@ impl StdioTransport {
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
+ if let Some(working_directory) = working_directory {
+ command.current_dir(working_directory);
+ }
+
let mut server = command.spawn().with_context(|| {
format!(
"failed to spawn command. (path={:?}, args={:?})",
@@ -3,6 +3,8 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use url::Url;
+use crate::client::RequestId;
+
pub const LATEST_PROTOCOL_VERSION: &str = "2025-03-26";
pub const VERSION_2024_11_05: &str = "2024-11-05";
@@ -100,6 +102,7 @@ pub mod notifications {
notification!("notifications/initialized", Initialized, ());
notification!("notifications/progress", Progress, ProgressParams);
notification!("notifications/message", Message, MessageParams);
+ notification!("notifications/cancelled", Cancelled, CancelledParams);
notification!(
"notifications/resources/updated",
ResourcesUpdated,
@@ -153,7 +156,7 @@ pub struct InitializeParams {
pub struct CallToolParams {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
- pub arguments: Option<HashMap<String, serde_json::Value>>,
+ pub arguments: Option<serde_json::Value>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<HashMap<String, serde_json::Value>>,
}
@@ -492,18 +495,20 @@ pub struct RootsCapabilities {
pub list_changed: Option<bool>,
}
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Tool {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub input_schema: serde_json::Value,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub output_schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<ToolAnnotations>,
}
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolAnnotations {
/// A human-readable title for the tool.
@@ -617,11 +622,15 @@ pub enum ClientNotification {
Initialized,
Progress(ProgressParams),
RootsListChanged,
- Cancelled {
- request_id: String,
- #[serde(skip_serializing_if = "Option::is_none")]
- reason: Option<String>,
- },
+ Cancelled(CancelledParams),
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CancelledParams {
+ pub request_id: RequestId,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -673,6 +682,20 @@ pub struct CallToolResponse {
pub is_error: Option<bool>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<HashMap<String, serde_json::Value>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub structured_content: Option<serde_json::Value>,
+}
+
+impl CallToolResponse {
+ pub fn text_contents(&self) -> String {
+ let mut text = String::new();
+ for chunk in &self.content {
+ if let ToolResponseContent::Text { text: chunk } = chunk {
+ text.push_str(chunk)
+ };
+ }
+ text
+ }
}
#[derive(Debug, Serialize, Deserialize)]
@@ -688,6 +711,16 @@ pub enum ToolResponseContent {
Resource { resource: ResourceContents },
}
+impl ToolResponseContent {
+ pub fn text(&self) -> Option<&str> {
+ if let ToolResponseContent::Text { text } = self {
+ Some(text)
+ } else {
+ None
+ }
+ }
+}
+
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListToolsResponse {
@@ -34,7 +34,7 @@ fs.workspace = true
futures.workspace = true
gpui.workspace = true
http_client.workspace = true
-inline_completion.workspace = true
+edit_prediction.workspace = true
language.workspace = true
log.workspace = true
lsp.workspace = true
@@ -46,6 +46,7 @@ project.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
+sum_tree.workspace = true
task.workspace = true
ui.workspace = true
util.workspace = true
@@ -21,10 +21,12 @@ use language::{
point_from_lsp, point_to_lsp,
};
use lsp::{LanguageServer, LanguageServerBinary, LanguageServerId, LanguageServerName};
-use node_runtime::NodeRuntime;
+use node_runtime::{NodeRuntime, VersionStrategy};
use parking_lot::Mutex;
+use project::DisableAiSettings;
use request::StatusNotification;
use serde_json::json;
+use settings::Settings;
use settings::SettingsStore;
use sign_in::{reinstall_and_sign_in_within_workspace, sign_out_within_workspace};
use std::collections::hash_map::Entry;
@@ -37,6 +39,7 @@ use std::{
path::{Path, PathBuf},
sync::Arc,
};
+use sum_tree::Dimensions;
use util::{ResultExt, fs::remove_matching};
use workspace::Workspace;
@@ -78,42 +81,15 @@ pub fn init(
};
copilot_chat::init(fs.clone(), http.clone(), configuration, cx);
- let copilot = cx.new({
- let node_runtime = node_runtime.clone();
- move |cx| Copilot::start(new_server_id, fs, node_runtime, cx)
- });
+ let copilot = cx.new(move |cx| Copilot::start(new_server_id, fs, node_runtime, cx));
Copilot::set_global(copilot.clone(), cx);
- cx.observe(&copilot, |handle, cx| {
- let copilot_action_types = [
- TypeId::of::<Suggest>(),
- TypeId::of::<NextSuggestion>(),
- TypeId::of::<PreviousSuggestion>(),
- TypeId::of::<Reinstall>(),
- ];
- let copilot_auth_action_types = [TypeId::of::<SignOut>()];
- let copilot_no_auth_action_types = [TypeId::of::<SignIn>()];
- let status = handle.read(cx).status();
- let filter = CommandPaletteFilter::global_mut(cx);
-
- match status {
- Status::Disabled => {
- filter.hide_action_types(&copilot_action_types);
- filter.hide_action_types(&copilot_auth_action_types);
- filter.hide_action_types(&copilot_no_auth_action_types);
- }
- Status::Authorized => {
- filter.hide_action_types(&copilot_no_auth_action_types);
- filter.show_action_types(
- copilot_action_types
- .iter()
- .chain(&copilot_auth_action_types),
- );
- }
- _ => {
- filter.hide_action_types(&copilot_action_types);
- filter.hide_action_types(&copilot_auth_action_types);
- filter.show_action_types(copilot_no_auth_action_types.iter());
- }
+ cx.observe(&copilot, |copilot, cx| {
+ copilot.update(cx, |copilot, cx| copilot.update_action_visibilities(cx));
+ })
+ .detach();
+ cx.observe_global::<SettingsStore>(|cx| {
+ if let Some(copilot) = Copilot::global(cx) {
+ copilot.update(cx, |copilot, cx| copilot.update_action_visibilities(cx));
}
})
.detach();
@@ -150,7 +126,7 @@ impl CopilotServer {
fn as_authenticated(&mut self) -> Result<&mut RunningCopilotServer> {
let server = self.as_running()?;
anyhow::ensure!(
- matches!(server.sign_in_status, SignInStatus::Authorized { .. }),
+ matches!(server.sign_in_status, SignInStatus::Authorized),
"must sign in before using copilot"
);
Ok(server)
@@ -209,8 +185,14 @@ impl Status {
matches!(self, Status::Authorized)
}
- pub fn is_disabled(&self) -> bool {
- matches!(self, Status::Disabled)
+ pub fn is_configured(&self) -> bool {
+ matches!(
+ self,
+ Status::Starting { .. }
+ | Status::Error(_)
+ | Status::SigningIn { .. }
+ | Status::Authorized
+ )
}
}
@@ -255,7 +237,7 @@ impl RegisteredBuffer {
let new_snapshot = new_snapshot.clone();
async move {
new_snapshot
- .edits_since::<(PointUtf16, usize)>(&old_version)
+ .edits_since::<Dimensions<PointUtf16, usize>>(&old_version)
.map(|edit| {
let edit_start = edit.new.start.0;
let edit_end = edit_start + (edit.old.end.0 - edit.old.start.0);
@@ -364,7 +346,11 @@ impl Copilot {
this.start_copilot(true, false, cx);
cx.observe_global::<SettingsStore>(move |this, cx| {
this.start_copilot(true, false, cx);
- this.send_configuration_update(cx);
+ if let Ok(server) = this.server.as_running() {
+ notify_did_change_config_to_server(&server.lsp, cx)
+ .context("copilot setting change: did change configuration")
+ .log_err();
+ }
})
.detach();
this
@@ -453,43 +439,6 @@ impl Copilot {
if env.is_empty() { None } else { Some(env) }
}
- fn send_configuration_update(&mut self, cx: &mut Context<Self>) {
- let copilot_settings = all_language_settings(None, cx)
- .edit_predictions
- .copilot
- .clone();
-
- let settings = json!({
- "http": {
- "proxy": copilot_settings.proxy,
- "proxyStrictSSL": !copilot_settings.proxy_no_verify.unwrap_or(false)
- },
- "github-enterprise": {
- "uri": copilot_settings.enterprise_uri
- }
- });
-
- if let Some(copilot_chat) = copilot_chat::CopilotChat::global(cx) {
- copilot_chat.update(cx, |chat, cx| {
- chat.set_configuration(
- copilot_chat::CopilotChatConfiguration {
- enterprise_uri: copilot_settings.enterprise_uri.clone(),
- },
- cx,
- );
- });
- }
-
- if let Ok(server) = self.server.as_running() {
- server
- .lsp
- .notify::<lsp::notification::DidChangeConfiguration>(
- &lsp::DidChangeConfigurationParams { settings },
- )
- .log_err();
- }
- }
-
#[cfg(any(test, feature = "test-support"))]
pub fn fake(cx: &mut gpui::TestAppContext) -> (Entity<Self>, lsp::FakeLanguageServer) {
use fs::FakeFs;
@@ -588,6 +537,9 @@ impl Copilot {
})?
.await?;
+ this.update(cx, |_, cx| notify_did_change_config_to_server(&server, cx))?
+ .context("copilot: did change configuration")?;
+
let status = server
.request::<request::CheckStatus>(request::CheckStatusParams {
local_checks_only: false,
@@ -613,8 +565,6 @@ impl Copilot {
});
cx.emit(Event::CopilotLanguageServerStarted);
this.update_sign_in_status(status, cx);
- // Send configuration now that the LSP is fully started
- this.send_configuration_update(cx);
}
Err(error) => {
this.server = CopilotServer::Error(error.to_string().into());
@@ -628,12 +578,12 @@ impl Copilot {
pub(crate) fn sign_in(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
if let CopilotServer::Running(server) = &mut self.server {
let task = match &server.sign_in_status {
- SignInStatus::Authorized { .. } => Task::ready(Ok(())).shared(),
+ SignInStatus::Authorized => Task::ready(Ok(())).shared(),
SignInStatus::SigningIn { task, .. } => {
cx.notify();
task.clone()
}
- SignInStatus::SignedOut { .. } | SignInStatus::Unauthorized { .. } => {
+ SignInStatus::SignedOut { .. } | SignInStatus::Unauthorized => {
let lsp = server.lsp.clone();
let task = cx
.spawn(async move |this, cx| {
@@ -655,15 +605,13 @@ impl Copilot {
sign_in_status: status,
..
}) = &mut this.server
- {
- if let SignInStatus::SigningIn {
+ && let SignInStatus::SigningIn {
prompt: prompt_flow,
..
} = status
- {
- *prompt_flow = Some(flow.clone());
- cx.notify();
- }
+ {
+ *prompt_flow = Some(flow.clone());
+ cx.notify();
}
})?;
let response = lsp
@@ -779,7 +727,7 @@ impl Copilot {
..
}) = &mut self.server
{
- if !matches!(status, SignInStatus::Authorized { .. }) {
+ if !matches!(status, SignInStatus::Authorized) {
return;
}
@@ -829,59 +777,58 @@ impl Copilot {
event: &language::BufferEvent,
cx: &mut Context<Self>,
) -> Result<()> {
- if let Ok(server) = self.server.as_running() {
- if let Some(registered_buffer) = server.registered_buffers.get_mut(&buffer.entity_id())
- {
- match event {
- language::BufferEvent::Edited => {
- drop(registered_buffer.report_changes(&buffer, cx));
- }
- language::BufferEvent::Saved => {
+ if let Ok(server) = self.server.as_running()
+ && let Some(registered_buffer) = server.registered_buffers.get_mut(&buffer.entity_id())
+ {
+ match event {
+ language::BufferEvent::Edited => {
+ drop(registered_buffer.report_changes(&buffer, cx));
+ }
+ language::BufferEvent::Saved => {
+ server
+ .lsp
+ .notify::<lsp::notification::DidSaveTextDocument>(
+ &lsp::DidSaveTextDocumentParams {
+ text_document: lsp::TextDocumentIdentifier::new(
+ registered_buffer.uri.clone(),
+ ),
+ text: None,
+ },
+ )?;
+ }
+ language::BufferEvent::FileHandleChanged
+ | language::BufferEvent::LanguageChanged => {
+ let new_language_id = id_for_language(buffer.read(cx).language());
+ let Ok(new_uri) = uri_for_buffer(&buffer, cx) else {
+ return Ok(());
+ };
+ if new_uri != registered_buffer.uri
+ || new_language_id != registered_buffer.language_id
+ {
+ let old_uri = mem::replace(&mut registered_buffer.uri, new_uri);
+ registered_buffer.language_id = new_language_id;
+ server
+ .lsp
+ .notify::<lsp::notification::DidCloseTextDocument>(
+ &lsp::DidCloseTextDocumentParams {
+ text_document: lsp::TextDocumentIdentifier::new(old_uri),
+ },
+ )?;
server
.lsp
- .notify::<lsp::notification::DidSaveTextDocument>(
- &lsp::DidSaveTextDocumentParams {
- text_document: lsp::TextDocumentIdentifier::new(
+ .notify::<lsp::notification::DidOpenTextDocument>(
+ &lsp::DidOpenTextDocumentParams {
+ text_document: lsp::TextDocumentItem::new(
registered_buffer.uri.clone(),
+ registered_buffer.language_id.clone(),
+ registered_buffer.snapshot_version,
+ registered_buffer.snapshot.text(),
),
- text: None,
},
)?;
}
- language::BufferEvent::FileHandleChanged
- | language::BufferEvent::LanguageChanged => {
- let new_language_id = id_for_language(buffer.read(cx).language());
- let Ok(new_uri) = uri_for_buffer(&buffer, cx) else {
- return Ok(());
- };
- if new_uri != registered_buffer.uri
- || new_language_id != registered_buffer.language_id
- {
- let old_uri = mem::replace(&mut registered_buffer.uri, new_uri);
- registered_buffer.language_id = new_language_id;
- server
- .lsp
- .notify::<lsp::notification::DidCloseTextDocument>(
- &lsp::DidCloseTextDocumentParams {
- text_document: lsp::TextDocumentIdentifier::new(old_uri),
- },
- )?;
- server
- .lsp
- .notify::<lsp::notification::DidOpenTextDocument>(
- &lsp::DidOpenTextDocumentParams {
- text_document: lsp::TextDocumentItem::new(
- registered_buffer.uri.clone(),
- registered_buffer.language_id.clone(),
- registered_buffer.snapshot_version,
- registered_buffer.snapshot.text(),
- ),
- },
- )?;
- }
- }
- _ => {}
}
+ _ => {}
}
}
@@ -889,17 +836,17 @@ impl Copilot {
}
fn unregister_buffer(&mut self, buffer: &WeakEntity<Buffer>) {
- if let Ok(server) = self.server.as_running() {
- if let Some(buffer) = server.registered_buffers.remove(&buffer.entity_id()) {
- server
- .lsp
- .notify::<lsp::notification::DidCloseTextDocument>(
- &lsp::DidCloseTextDocumentParams {
- text_document: lsp::TextDocumentIdentifier::new(buffer.uri),
- },
- )
- .ok();
- }
+ if let Ok(server) = self.server.as_running()
+ && let Some(buffer) = server.registered_buffers.remove(&buffer.entity_id())
+ {
+ server
+ .lsp
+ .notify::<lsp::notification::DidCloseTextDocument>(
+ &lsp::DidCloseTextDocumentParams {
+ text_document: lsp::TextDocumentIdentifier::new(buffer.uri),
+ },
+ )
+ .ok();
}
}
@@ -1062,8 +1009,8 @@ impl Copilot {
CopilotServer::Error(error) => Status::Error(error.clone()),
CopilotServer::Running(RunningCopilotServer { sign_in_status, .. }) => {
match sign_in_status {
- SignInStatus::Authorized { .. } => Status::Authorized,
- SignInStatus::Unauthorized { .. } => Status::Unauthorized,
+ SignInStatus::Authorized => Status::Authorized,
+ SignInStatus::Unauthorized => Status::Unauthorized,
SignInStatus::SigningIn { prompt, .. } => Status::SigningIn {
prompt: prompt.clone(),
},
@@ -1115,6 +1062,44 @@ impl Copilot {
cx.notify();
}
}
+
+ fn update_action_visibilities(&self, cx: &mut App) {
+ let signed_in_actions = [
+ TypeId::of::<Suggest>(),
+ TypeId::of::<NextSuggestion>(),
+ TypeId::of::<PreviousSuggestion>(),
+ TypeId::of::<Reinstall>(),
+ ];
+ let auth_actions = [TypeId::of::<SignOut>()];
+ let no_auth_actions = [TypeId::of::<SignIn>()];
+ let status = self.status();
+
+ let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
+ let filter = CommandPaletteFilter::global_mut(cx);
+
+ if is_ai_disabled {
+ filter.hide_action_types(&signed_in_actions);
+ filter.hide_action_types(&auth_actions);
+ filter.hide_action_types(&no_auth_actions);
+ } else {
+ match status {
+ Status::Disabled => {
+ filter.hide_action_types(&signed_in_actions);
+ filter.hide_action_types(&auth_actions);
+ filter.hide_action_types(&no_auth_actions);
+ }
+ Status::Authorized => {
+ filter.hide_action_types(&no_auth_actions);
+ filter.show_action_types(signed_in_actions.iter().chain(&auth_actions));
+ }
+ _ => {
+ filter.hide_action_types(&signed_in_actions);
+ filter.hide_action_types(&auth_actions);
+ filter.show_action_types(no_auth_actions.iter());
+ }
+ }
+ }
+ }
}
fn id_for_language(language: Option<&Arc<Language>>) -> String {
@@ -1133,6 +1118,41 @@ fn uri_for_buffer(buffer: &Entity<Buffer>, cx: &App) -> Result<lsp::Url, ()> {
}
}
+fn notify_did_change_config_to_server(
+ server: &Arc<LanguageServer>,
+ cx: &mut Context<Copilot>,
+) -> std::result::Result<(), anyhow::Error> {
+ let copilot_settings = all_language_settings(None, cx)
+ .edit_predictions
+ .copilot
+ .clone();
+
+ if let Some(copilot_chat) = copilot_chat::CopilotChat::global(cx) {
+ copilot_chat.update(cx, |chat, cx| {
+ chat.set_configuration(
+ copilot_chat::CopilotChatConfiguration {
+ enterprise_uri: copilot_settings.enterprise_uri.clone(),
+ },
+ cx,
+ );
+ });
+ }
+
+ let settings = json!({
+ "http": {
+ "proxy": copilot_settings.proxy,
+ "proxyStrictSSL": !copilot_settings.proxy_no_verify.unwrap_or(false)
+ },
+ "github-enterprise": {
+ "uri": copilot_settings.enterprise_uri
+ }
+ });
+
+ server.notify::<lsp::notification::DidChangeConfiguration>(&lsp::DidChangeConfigurationParams {
+ settings,
+ })
+}
+
async fn clear_copilot_dir() {
remove_matching(paths::copilot_dir(), |_| true).await
}
@@ -1158,7 +1178,7 @@ async fn get_copilot_lsp(fs: Arc<dyn Fs>, node_runtime: NodeRuntime) -> anyhow::
PACKAGE_NAME,
&server_path,
paths::copilot_dir(),
- &latest_version,
+ VersionStrategy::Latest(&latest_version),
)
.await;
if should_install {
@@ -484,7 +484,7 @@ impl CopilotChat {
};
if this.oauth_token.is_some() {
- cx.spawn(async move |this, mut cx| Self::update_models(&this, &mut cx).await)
+ cx.spawn(async move |this, cx| Self::update_models(&this, cx).await)
.detach_and_log_err(cx);
}
@@ -863,7 +863,7 @@ mod tests {
"object": "list"
}"#;
- let schema: ModelSchema = serde_json::from_str(&json).unwrap();
+ let schema: ModelSchema = serde_json::from_str(json).unwrap();
assert_eq!(schema.data.len(), 2);
assert_eq!(schema.data[0].id, "gpt-4");
@@ -1,7 +1,7 @@
use crate::{Completion, Copilot};
use anyhow::Result;
+use edit_prediction::{Direction, EditPrediction, EditPredictionProvider};
use gpui::{App, Context, Entity, EntityId, Task};
-use inline_completion::{Direction, EditPredictionProvider, InlineCompletion};
use language::{Buffer, OffsetRangeExt, ToOffset, language_settings::AllLanguageSettings};
use project::Project;
use settings::Settings;
@@ -58,11 +58,19 @@ impl EditPredictionProvider for CopilotCompletionProvider {
}
fn show_completions_in_menu() -> bool {
+ true
+ }
+
+ fn show_tab_accept_marker() -> bool {
+ true
+ }
+
+ fn supports_jump_to_edit() -> bool {
false
}
fn is_refreshing(&self) -> bool {
- self.pending_refresh.is_some()
+ self.pending_refresh.is_some() && self.completions.is_empty()
}
fn is_enabled(
@@ -210,7 +218,7 @@ impl EditPredictionProvider for CopilotCompletionProvider {
buffer: &Entity<Buffer>,
cursor_position: language::Anchor,
cx: &mut Context<Self>,
- ) -> Option<InlineCompletion> {
+ ) -> Option<EditPrediction> {
let buffer_id = buffer.entity_id();
let buffer = buffer.read(cx);
let completion = self.active_completion()?;
@@ -241,7 +249,7 @@ impl EditPredictionProvider for CopilotCompletionProvider {
None
} else {
let position = cursor_position.bias_right(buffer);
- Some(InlineCompletion {
+ Some(EditPrediction {
id: None,
edits: vec![(position..position, completion_text.into())],
edit_preview: None,
@@ -293,6 +301,7 @@ mod tests {
init_test(cx, |settings| {
settings.defaults.completions = Some(CompletionSettings {
words: WordsCompletionMode::Disabled,
+ words_min_length: 0,
lsp: true,
lsp_fetch_timeout_ms: 0,
lsp_insert_mode: LspInsertMode::Insert,
@@ -343,8 +352,8 @@ mod tests {
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
assert!(editor.context_menu_visible());
- assert!(!editor.has_active_inline_completion());
- // Since we have both, the copilot suggestion is not shown inline
+ assert!(editor.has_active_edit_prediction());
+ // Since we have both, the copilot suggestion is existing but does not show up as ghost text
assert_eq!(editor.text(cx), "one.\ntwo\nthree\n");
assert_eq!(editor.display_text(cx), "one.\ntwo\nthree\n");
@@ -355,7 +364,7 @@ mod tests {
.unwrap()
.detach();
assert!(!editor.context_menu_visible());
- assert!(!editor.has_active_inline_completion());
+ assert!(!editor.has_active_edit_prediction());
assert_eq!(editor.text(cx), "one.completion_a\ntwo\nthree\n");
assert_eq!(editor.display_text(cx), "one.completion_a\ntwo\nthree\n");
});
@@ -389,7 +398,7 @@ mod tests {
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, _, cx| {
assert!(!editor.context_menu_visible());
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
// Since only the copilot is available, it's shown inline
assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.\ntwo\nthree\n");
@@ -400,7 +409,7 @@ mod tests {
executor.run_until_parked();
cx.update_editor(|editor, _, cx| {
assert!(!editor.context_menu_visible());
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n");
});
@@ -418,25 +427,25 @@ mod tests {
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
assert!(!editor.context_menu_visible());
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n");
// Canceling should remove the active Copilot suggestion.
editor.cancel(&Default::default(), window, cx);
- assert!(!editor.has_active_inline_completion());
+ assert!(!editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one.c\ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n");
// After canceling, tabbing shouldn't insert the previously shown suggestion.
editor.tab(&Default::default(), window, cx);
- assert!(!editor.has_active_inline_completion());
+ assert!(!editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one.c \ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.c \ntwo\nthree\n");
// When undoing the previously active suggestion is shown again.
editor.undo(&Default::default(), window, cx);
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n");
});
@@ -444,25 +453,25 @@ mod tests {
// If an edit occurs outside of this editor, the suggestion is still correctly interpolated.
cx.update_buffer(|buffer, cx| buffer.edit([(5..5, "o")], None, cx));
cx.update_editor(|editor, window, cx| {
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n");
// AcceptEditPrediction when there is an active suggestion inserts it.
editor.accept_edit_prediction(&Default::default(), window, cx);
- assert!(!editor.has_active_inline_completion());
+ assert!(!editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.copilot2\ntwo\nthree\n");
// When undoing the previously active suggestion is shown again.
editor.undo(&Default::default(), window, cx);
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n");
// Hide suggestion.
editor.cancel(&Default::default(), window, cx);
- assert!(!editor.has_active_inline_completion());
+ assert!(!editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one.co\ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n");
});
@@ -471,7 +480,7 @@ mod tests {
// we won't make it visible.
cx.update_buffer(|buffer, cx| buffer.edit([(6..6, "p")], None, cx));
cx.update_editor(|editor, _, cx| {
- assert!(!editor.has_active_inline_completion());
+ assert!(!editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one.cop\ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.cop\ntwo\nthree\n");
});
@@ -498,19 +507,19 @@ mod tests {
});
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}");
assert_eq!(editor.text(cx), "fn foo() {\n \n}");
// Tabbing inside of leading whitespace inserts indentation without accepting the suggestion.
editor.tab(&Default::default(), window, cx);
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.text(cx), "fn foo() {\n \n}");
assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}");
// Using AcceptEditPrediction again accepts the suggestion.
editor.accept_edit_prediction(&Default::default(), window, cx);
- assert!(!editor.has_active_inline_completion());
+ assert!(!editor.has_active_edit_prediction());
assert_eq!(editor.text(cx), "fn foo() {\n let x = 4;\n}");
assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}");
});
@@ -525,6 +534,7 @@ mod tests {
init_test(cx, |settings| {
settings.defaults.completions = Some(CompletionSettings {
words: WordsCompletionMode::Disabled,
+ words_min_length: 0,
lsp: true,
lsp_fetch_timeout_ms: 0,
lsp_insert_mode: LspInsertMode::Insert,
@@ -575,17 +585,17 @@ mod tests {
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
// Accepting the first word of the suggestion should only accept the first word and still show the rest.
- editor.accept_partial_inline_completion(&Default::default(), window, cx);
- assert!(editor.has_active_inline_completion());
+ editor.accept_partial_edit_prediction(&Default::default(), window, cx);
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.text(cx), "one.copilot\ntwo\nthree\n");
assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n");
// Accepting next word should accept the non-word and copilot suggestion should be gone
- editor.accept_partial_inline_completion(&Default::default(), window, cx);
- assert!(!editor.has_active_inline_completion());
+ editor.accept_partial_edit_prediction(&Default::default(), window, cx);
+ assert!(!editor.has_active_edit_prediction());
assert_eq!(editor.text(cx), "one.copilot1\ntwo\nthree\n");
assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n");
});
@@ -617,11 +627,11 @@ mod tests {
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
// Accepting the first word (non-word) of the suggestion should only accept the first word and still show the rest.
- editor.accept_partial_inline_completion(&Default::default(), window, cx);
- assert!(editor.has_active_inline_completion());
+ editor.accept_partial_edit_prediction(&Default::default(), window, cx);
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.text(cx), "one.123. \ntwo\nthree\n");
assert_eq!(
editor.display_text(cx),
@@ -629,8 +639,8 @@ mod tests {
);
// Accepting next word should accept the next word and copilot suggestion should still exist
- editor.accept_partial_inline_completion(&Default::default(), window, cx);
- assert!(editor.has_active_inline_completion());
+ editor.accept_partial_edit_prediction(&Default::default(), window, cx);
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.text(cx), "one.123. copilot\ntwo\nthree\n");
assert_eq!(
editor.display_text(cx),
@@ -638,8 +648,8 @@ mod tests {
);
// Accepting the whitespace should accept the non-word/whitespaces with newline and copilot suggestion should be gone
- editor.accept_partial_inline_completion(&Default::default(), window, cx);
- assert!(!editor.has_active_inline_completion());
+ editor.accept_partial_edit_prediction(&Default::default(), window, cx);
+ assert!(!editor.has_active_edit_prediction());
assert_eq!(editor.text(cx), "one.123. copilot\n 456\ntwo\nthree\n");
assert_eq!(
editor.display_text(cx),
@@ -692,29 +702,29 @@ mod tests {
});
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
assert_eq!(editor.text(cx), "one\ntw\nthree\n");
editor.backspace(&Default::default(), window, cx);
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
assert_eq!(editor.text(cx), "one\nt\nthree\n");
editor.backspace(&Default::default(), window, cx);
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
assert_eq!(editor.text(cx), "one\n\nthree\n");
// Deleting across the original suggestion range invalidates it.
editor.backspace(&Default::default(), window, cx);
- assert!(!editor.has_active_inline_completion());
+ assert!(!editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one\nthree\n");
assert_eq!(editor.text(cx), "one\nthree\n");
// Undoing the deletion restores the suggestion.
editor.undo(&Default::default(), window, cx);
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
assert_eq!(editor.text(cx), "one\n\nthree\n");
});
@@ -775,7 +785,7 @@ mod tests {
});
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
_ = editor.update(cx, |editor, _, cx| {
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(
editor.display_text(cx),
"\n\na = 1\nb = 2 + a\n\n\n\nc = 3\nd = 4\n"
@@ -797,7 +807,7 @@ mod tests {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([Point::new(4, 5)..Point::new(4, 5)])
});
- assert!(!editor.has_active_inline_completion());
+ assert!(!editor.has_active_edit_prediction());
assert_eq!(
editor.display_text(cx),
"\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4\n"
@@ -806,7 +816,7 @@ mod tests {
// Type a character, ensuring we don't even try to interpolate the previous suggestion.
editor.handle_input(" ", window, cx);
- assert!(!editor.has_active_inline_completion());
+ assert!(!editor.has_active_edit_prediction());
assert_eq!(
editor.display_text(cx),
"\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4 \n"
@@ -817,7 +827,7 @@ mod tests {
// Ensure the new suggestion is displayed when the debounce timeout expires.
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
_ = editor.update(cx, |editor, _, cx| {
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(
editor.display_text(cx),
"\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4 + c\n"
@@ -880,7 +890,7 @@ mod tests {
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, _, cx| {
assert!(!editor.context_menu_visible());
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
assert_eq!(editor.text(cx), "one\ntw\nthree\n");
});
@@ -907,7 +917,7 @@ mod tests {
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, _, cx| {
assert!(!editor.context_menu_visible());
- assert!(editor.has_active_inline_completion());
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
assert_eq!(editor.text(cx), "one\ntwo\nthree\n");
});
@@ -934,8 +944,9 @@ mod tests {
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, _, cx| {
assert!(editor.context_menu_visible());
- assert!(!editor.has_active_inline_completion(),);
+ assert!(editor.has_active_edit_prediction());
assert_eq!(editor.text(cx), "one\ntwo.\nthree\n");
+ assert_eq!(editor.display_text(cx), "one\ntwo.\nthree\n");
});
}
@@ -1023,7 +1034,7 @@ mod tests {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
selections.select_ranges([Point::new(0, 0)..Point::new(0, 0)])
});
- editor.refresh_inline_completion(true, false, window, cx);
+ editor.refresh_edit_prediction(true, false, window, cx);
});
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
@@ -1033,7 +1044,7 @@ mod tests {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([Point::new(5, 0)..Point::new(5, 0)])
});
- editor.refresh_inline_completion(true, false, window, cx);
+ editor.refresh_edit_prediction(true, false, window, cx);
});
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
@@ -1074,11 +1085,9 @@ mod tests {
let replace_range_marker: TextRangeMarker = ('<', '>').into();
let (_, mut marked_ranges) = marked_text_ranges_by(
marked_string,
- vec![complete_from_marker.clone(), replace_range_marker.clone()],
+ vec![complete_from_marker, replace_range_marker.clone()],
);
- let complete_from_position =
- cx.to_lsp(marked_ranges.remove(&complete_from_marker).unwrap()[0].start);
let replace_range =
cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone());
@@ -1087,10 +1096,6 @@ mod tests {
let completions = completions.clone();
async move {
assert_eq!(params.text_document_position.text_document.uri, url.clone());
- assert_eq!(
- params.text_document_position.position,
- complete_from_position
- );
Ok(Some(lsp::CompletionResponse::Array(
completions
.iter()
@@ -0,0 +1,28 @@
+[package]
+name = "crashes"
+version = "0.1.0"
+publish.workspace = true
+edition.workspace = true
+license = "GPL-3.0-or-later"
+
+[dependencies]
+bincode.workspace = true
+crash-handler.workspace = true
+log.workspace = true
+minidumper.workspace = true
+paths.workspace = true
+release_channel.workspace = true
+smol.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+system_specs.workspace = true
+workspace-hack.workspace = true
+
+[target.'cfg(target_os = "macos")'.dependencies]
+mach2.workspace = true
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/crashes.rs"
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -0,0 +1,319 @@
+use crash_handler::CrashHandler;
+use log::info;
+use minidumper::{Client, LoopAction, MinidumpBinary};
+use release_channel::{RELEASE_CHANNEL, ReleaseChannel};
+use serde::{Deserialize, Serialize};
+
+#[cfg(target_os = "macos")]
+use std::sync::atomic::AtomicU32;
+use std::{
+ env,
+ fs::{self, File},
+ io,
+ panic::Location,
+ path::{Path, PathBuf},
+ process::{self, Command},
+ sync::{
+ Arc, OnceLock,
+ atomic::{AtomicBool, Ordering},
+ },
+ thread,
+ time::Duration,
+};
+
+// set once the crash handler has initialized and the client has connected to it
+pub static CRASH_HANDLER: OnceLock<Arc<Client>> = OnceLock::new();
+// set when the first minidump request is made to avoid generating duplicate crash reports
+pub static REQUESTED_MINIDUMP: AtomicBool = AtomicBool::new(false);
+const CRASH_HANDLER_PING_TIMEOUT: Duration = Duration::from_secs(60);
+const CRASH_HANDLER_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
+
+#[cfg(target_os = "macos")]
+static PANIC_THREAD_ID: AtomicU32 = AtomicU32::new(0);
+
+pub async fn init(crash_init: InitCrashHandler) {
+ if *RELEASE_CHANNEL == ReleaseChannel::Dev && env::var("ZED_GENERATE_MINIDUMPS").is_err() {
+ return;
+ }
+
+ let exe = env::current_exe().expect("unable to find ourselves");
+ let zed_pid = process::id();
+ // TODO: we should be able to get away with using 1 crash-handler process per machine,
+ // but for now we append the PID of the current process which makes it unique per remote
+ // server or interactive zed instance. This solves an issue where occasionally the socket
+ // used by the crash handler isn't destroyed correctly which causes it to stay on the file
+ // system and block further attempts to initialize crash handlers with that socket path.
+ let socket_name = paths::temp_dir().join(format!("zed-crash-handler-{zed_pid}"));
+ #[allow(unused)]
+ let server_pid = Command::new(exe)
+ .arg("--crash-handler")
+ .arg(&socket_name)
+ .spawn()
+ .expect("unable to spawn server process")
+ .id();
+ info!("spawning crash handler process");
+
+ let mut elapsed = Duration::ZERO;
+ let retry_frequency = Duration::from_millis(100);
+ let mut maybe_client = None;
+ while maybe_client.is_none() {
+ if let Ok(client) = Client::with_name(socket_name.as_path()) {
+ maybe_client = Some(client);
+ info!("connected to crash handler process after {elapsed:?}");
+ break;
+ }
+ elapsed += retry_frequency;
+ smol::Timer::after(retry_frequency).await;
+ }
+ let client = maybe_client.unwrap();
+ client
+ .send_message(1, serde_json::to_vec(&crash_init).unwrap())
+ .unwrap();
+
+ let client = Arc::new(client);
+ let handler = crash_handler::CrashHandler::attach(unsafe {
+ let client = client.clone();
+ crash_handler::make_crash_event(move |crash_context: &crash_handler::CrashContext| {
+ // only request a minidump once
+ let res = if REQUESTED_MINIDUMP
+ .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
+ .is_ok()
+ {
+ #[cfg(target_os = "macos")]
+ suspend_all_other_threads();
+
+ client.ping().unwrap();
+ client.request_dump(crash_context).is_ok()
+ } else {
+ true
+ };
+ crash_handler::CrashEventResult::Handled(res)
+ })
+ })
+ .expect("failed to attach signal handler");
+
+ #[cfg(target_os = "linux")]
+ {
+ handler.set_ptracer(Some(server_pid));
+ }
+ CRASH_HANDLER.set(client.clone()).ok();
+ std::mem::forget(handler);
+ info!("crash handler registered");
+
+ loop {
+ client.ping().ok();
+ smol::Timer::after(Duration::from_secs(10)).await;
+ }
+}
+
+#[cfg(target_os = "macos")]
+unsafe fn suspend_all_other_threads() {
+ let task = unsafe { mach2::traps::current_task() };
+ let mut threads: mach2::mach_types::thread_act_array_t = std::ptr::null_mut();
+ let mut count = 0;
+ unsafe {
+ mach2::task::task_threads(task, &raw mut threads, &raw mut count);
+ }
+ let current = unsafe { mach2::mach_init::mach_thread_self() };
+ let panic_thread = PANIC_THREAD_ID.load(Ordering::SeqCst);
+ for i in 0..count {
+ let t = unsafe { *threads.add(i as usize) };
+ if t != current && t != panic_thread {
+ unsafe { mach2::thread_act::thread_suspend(t) };
+ }
+ }
+}
+
+pub struct CrashServer {
+ initialization_params: OnceLock<InitCrashHandler>,
+ panic_info: OnceLock<CrashPanic>,
+ active_gpu: OnceLock<system_specs::GpuSpecs>,
+ has_connection: Arc<AtomicBool>,
+}
+
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct CrashInfo {
+ pub init: InitCrashHandler,
+ pub panic: Option<CrashPanic>,
+ pub minidump_error: Option<String>,
+ pub gpus: Vec<system_specs::GpuInfo>,
+ pub active_gpu: Option<system_specs::GpuSpecs>,
+}
+
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct InitCrashHandler {
+ pub session_id: String,
+ pub zed_version: String,
+ pub release_channel: String,
+ pub commit_sha: String,
+}
+
+#[derive(Deserialize, Serialize, Debug, Clone)]
+pub struct CrashPanic {
+ pub message: String,
+ pub span: String,
+}
+
+impl minidumper::ServerHandler for CrashServer {
+ fn create_minidump_file(&self) -> Result<(File, PathBuf), io::Error> {
+ let err_message = "Missing initialization data";
+ let dump_path = paths::logs_dir()
+ .join(
+ &self
+ .initialization_params
+ .get()
+ .expect(err_message)
+ .session_id,
+ )
+ .with_extension("dmp");
+ let file = File::create(&dump_path)?;
+ Ok((file, dump_path))
+ }
+
+ fn on_minidump_created(&self, result: Result<MinidumpBinary, minidumper::Error>) -> LoopAction {
+ let minidump_error = match result {
+ Ok(mut md_bin) => {
+ use io::Write;
+ let _ = md_bin.file.flush();
+ None
+ }
+ Err(e) => Some(format!("{e:?}")),
+ };
+
+ #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
+ let gpus = vec![];
+
+ #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+ let gpus = match system_specs::read_gpu_info_from_sys_class_drm() {
+ Ok(gpus) => gpus,
+ Err(err) => {
+ log::warn!("Failed to collect GPU information for crash report: {err}");
+ vec![]
+ }
+ };
+
+ let crash_info = CrashInfo {
+ init: self
+ .initialization_params
+ .get()
+ .expect("not initialized")
+ .clone(),
+ panic: self.panic_info.get().cloned(),
+ minidump_error,
+ active_gpu: self.active_gpu.get().cloned(),
+ gpus,
+ };
+
+ let crash_data_path = paths::logs_dir()
+ .join(&crash_info.init.session_id)
+ .with_extension("json");
+
+ fs::write(crash_data_path, serde_json::to_vec(&crash_info).unwrap()).ok();
+
+ LoopAction::Exit
+ }
+
+ fn on_message(&self, kind: u32, buffer: Vec<u8>) {
+ match kind {
+ 1 => {
+ let init_data =
+ serde_json::from_slice::<InitCrashHandler>(&buffer).expect("invalid init data");
+ self.initialization_params
+ .set(init_data)
+ .expect("already initialized");
+ }
+ 2 => {
+ let panic_data =
+ serde_json::from_slice::<CrashPanic>(&buffer).expect("invalid panic data");
+ self.panic_info.set(panic_data).expect("already panicked");
+ }
+ 3 => {
+ let gpu_specs: system_specs::GpuSpecs =
+ bincode::deserialize(&buffer).expect("gpu specs");
+ self.active_gpu
+ .set(gpu_specs)
+ .expect("already set active gpu");
+ }
+ _ => {
+ panic!("invalid message kind");
+ }
+ }
+ }
+
+ fn on_client_disconnected(&self, _clients: usize) -> LoopAction {
+ LoopAction::Exit
+ }
+
+ fn on_client_connected(&self, _clients: usize) -> LoopAction {
+ self.has_connection.store(true, Ordering::SeqCst);
+ LoopAction::Continue
+ }
+}
+
+pub fn handle_panic(message: String, span: Option<&Location>) {
+ let span = span
+ .map(|loc| format!("{}:{}", loc.file(), loc.line()))
+ .unwrap_or_default();
+
+ // wait 500ms for the crash handler process to start up
+ // if it's still not there just write panic info and no minidump
+ let retry_frequency = Duration::from_millis(100);
+ for _ in 0..5 {
+ if let Some(client) = CRASH_HANDLER.get() {
+ client
+ .send_message(
+ 2,
+ serde_json::to_vec(&CrashPanic { message, span }).unwrap(),
+ )
+ .ok();
+ log::error!("triggering a crash to generate a minidump...");
+
+ #[cfg(target_os = "macos")]
+ PANIC_THREAD_ID.store(
+ unsafe { mach2::mach_init::mach_thread_self() },
+ Ordering::SeqCst,
+ );
+
+ #[cfg(target_os = "linux")]
+ CrashHandler.simulate_signal(crash_handler::Signal::Trap as u32);
+ #[cfg(not(target_os = "linux"))]
+ CrashHandler.simulate_exception(None);
+ break;
+ }
+ thread::sleep(retry_frequency);
+ }
+}
+
+pub fn crash_server(socket: &Path) {
+ let Ok(mut server) = minidumper::Server::with_name(socket) else {
+ log::info!("Couldn't create socket, there may already be a running crash server");
+ return;
+ };
+
+ let shutdown = Arc::new(AtomicBool::new(false));
+ let has_connection = Arc::new(AtomicBool::new(false));
+
+ std::thread::spawn({
+ let shutdown = shutdown.clone();
+ let has_connection = has_connection.clone();
+ move || {
+ std::thread::sleep(CRASH_HANDLER_CONNECT_TIMEOUT);
+ if !has_connection.load(Ordering::SeqCst) {
+ shutdown.store(true, Ordering::SeqCst);
+ }
+ }
+ });
+
+ server
+ .run(
+ Box::new(CrashServer {
+ initialization_params: OnceLock::new(),
+ panic_info: OnceLock::new(),
+ has_connection,
+ active_gpu: OnceLock::new(),
+ }),
+ &shutdown,
+ Some(CRASH_HANDLER_PING_TIMEOUT),
+ )
+ .expect("failed to run server");
+}
@@ -19,7 +19,7 @@ use release_channel::ReleaseChannel;
/// Only works in development. Setting this environment variable in other
/// release channels is a no-op.
static ZED_DEVELOPMENT_USE_KEYCHAIN: LazyLock<bool> = LazyLock::new(|| {
- std::env::var("ZED_DEVELOPMENT_USE_KEYCHAIN").map_or(false, |value| !value.is_empty())
+ std::env::var("ZED_DEVELOPMENT_USE_KEYCHAIN").is_ok_and(|value| !value.is_empty())
});
/// A provider for credentials.
@@ -74,6 +74,12 @@ impl Borrow<str> for DebugAdapterName {
}
}
+impl Borrow<SharedString> for DebugAdapterName {
+ fn borrow(&self) -> &SharedString {
+ &self.0
+ }
+}
+
impl std::fmt::Display for DebugAdapterName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
@@ -279,7 +285,7 @@ pub async fn download_adapter_from_github(
}
if !adapter_path.exists() {
- fs.create_dir(&adapter_path.as_path())
+ fs.create_dir(adapter_path.as_path())
.await
.context("Failed creating adapter path")?;
}
@@ -23,7 +23,7 @@ impl SessionId {
Self(client_id as u32)
}
- pub fn to_proto(&self) -> u64 {
+ pub fn to_proto(self) -> u64 {
self.0 as u64
}
}
@@ -295,7 +295,7 @@ mod tests {
request: dap_types::StartDebuggingRequestArgumentsRequest::Launch,
},
},
- Box::new(|_| panic!("Did not expect to hit this code path")),
+ Box::new(|_| {}),
&mut cx.to_async(),
)
.await
@@ -46,6 +46,7 @@ impl DapRegistry {
let name = adapter.name();
let _previous_value = self.0.write().adapters.insert(name, adapter);
}
+
pub fn add_locator(&self, locator: Arc<dyn DapLocator>) {
self.0.write().locators.insert(locator.name(), locator);
}
@@ -86,7 +87,7 @@ impl DapRegistry {
self.0.read().adapters.get(name).cloned()
}
- pub fn enumerate_adapters(&self) -> Vec<DebugAdapterName> {
+ pub fn enumerate_adapters<B: FromIterator<DebugAdapterName>>(&self) -> B {
self.0.read().adapters.keys().cloned().collect()
}
}
@@ -883,6 +883,7 @@ impl FakeTransport {
break Err(anyhow!("exit in response to request"));
}
};
+ let success = response.success;
let message =
serde_json::to_string(&Message::Response(response)).unwrap();
@@ -893,6 +894,25 @@ impl FakeTransport {
)
.await
.unwrap();
+
+ if request.command == dap_types::requests::Initialize::COMMAND
+ && success
+ {
+ let message = serde_json::to_string(&Message::Event(Box::new(
+ dap_types::messages::Events::Initialized(Some(
+ Default::default(),
+ )),
+ )))
+ .unwrap();
+ writer
+ .write_all(
+ TransportDelegate::build_rpc_message(message)
+ .as_bytes(),
+ )
+ .await
+ .unwrap();
+ }
+
writer.flush().await.unwrap();
}
}
@@ -36,6 +36,7 @@ paths.workspace = true
serde.workspace = true
serde_json.workspace = true
shlex.workspace = true
+smol.workspace = true
task.workspace = true
util.workspace = true
workspace-hack.workspace = true
@@ -338,8 +338,8 @@ impl DebugAdapter for CodeLldbDebugAdapter {
if command.is_none() {
delegate.output_to_console(format!("Checking latest version of {}...", self.name()));
let adapter_path = paths::debug_adapters_dir().join(&Self::ADAPTER_NAME);
- let version_path =
- if let Ok(version) = self.fetch_latest_adapter_version(delegate).await {
+ let version_path = match self.fetch_latest_adapter_version(delegate).await {
+ Ok(version) => {
adapters::download_adapter_from_github(
self.name(),
version.clone(),
@@ -351,10 +351,26 @@ impl DebugAdapter for CodeLldbDebugAdapter {
adapter_path.join(format!("{}_{}", Self::ADAPTER_NAME, version.tag_name));
remove_matching(&adapter_path, |entry| entry != version_path).await;
version_path
- } else {
- let mut paths = delegate.fs().read_dir(&adapter_path).await?;
- paths.next().await.context("No adapter found")??
- };
+ }
+ Err(e) => {
+ delegate.output_to_console("Unable to fetch latest version".to_string());
+ log::error!("Error fetching latest version of {}: {}", self.name(), e);
+ delegate.output_to_console(format!(
+ "Searching for adapters in: {}",
+ adapter_path.display()
+ ));
+ let mut paths = delegate
+ .fs()
+ .read_dir(&adapter_path)
+ .await
+ .context("No cached adapter directory")?;
+ paths
+ .next()
+ .await
+ .context("No cached adapter found")?
+ .context("No cached adapter found")?
+ }
+ };
let adapter_dir = version_path.join("extension").join("adapter");
let path = adapter_dir.join("codelldb").to_string_lossy().to_string();
self.path_to_codelldb.set(path.clone()).ok();
@@ -369,7 +385,7 @@ impl DebugAdapter for CodeLldbDebugAdapter {
&& let Some(source_languages) = config.get("sourceLanguages").filter(|value| {
value
.as_array()
- .map_or(false, |array| array.iter().all(Value::is_string))
+ .is_some_and(|array| array.iter().all(Value::is_string))
})
{
let ret = vec![
@@ -13,7 +13,6 @@ use dap::{
DapRegistry,
adapters::{
self, AdapterVersion, DapDelegate, DebugAdapter, DebugAdapterBinary, DebugAdapterName,
- GithubRepo,
},
configure_tcp_connection,
};
@@ -36,7 +36,7 @@ impl GoDebugAdapter {
delegate: &Arc<dyn DapDelegate>,
) -> Result<AdapterVersion> {
let release = latest_github_release(
- &"zed-industries/delve-shim-dap",
+ "zed-industries/delve-shim-dap",
true,
false,
delegate.http_client(),
@@ -54,20 +54,6 @@ impl JsDebugAdapter {
user_args: Option<Vec<String>>,
_: &mut AsyncApp,
) -> Result<DebugAdapterBinary> {
- let adapter_path = if let Some(user_installed_path) = user_installed_path {
- user_installed_path
- } else {
- let adapter_path = paths::debug_adapters_dir().join(self.name().as_ref());
-
- let file_name_prefix = format!("{}_", self.name());
-
- util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
- file_name.starts_with(&file_name_prefix)
- })
- .await
- .context("Couldn't find JavaScript dap directory")?
- };
-
let tcp_connection = task_definition.tcp_connection.clone().unwrap_or_default();
let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
@@ -113,10 +99,10 @@ impl JsDebugAdapter {
}
}
- if let Some(env) = configuration.get("env").cloned() {
- if let Ok(env) = serde_json::from_value(env) {
- envs = env;
- }
+ if let Some(env) = configuration.get("env").cloned()
+ && let Ok(env) = serde_json::from_value(env)
+ {
+ envs = env;
}
configuration
@@ -136,21 +122,27 @@ impl JsDebugAdapter {
.or_insert(true.into());
}
+ let adapter_path = if let Some(user_installed_path) = user_installed_path {
+ user_installed_path
+ } else {
+ let adapter_path = paths::debug_adapters_dir().join(self.name().as_ref());
+
+ let file_name_prefix = format!("{}_", self.name());
+
+ util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
+ file_name.starts_with(&file_name_prefix)
+ })
+ .await
+ .context("Couldn't find JavaScript dap directory")?
+ .join(Self::ADAPTER_PATH)
+ };
+
let arguments = if let Some(mut args) = user_args {
- args.insert(
- 0,
- adapter_path
- .join(Self::ADAPTER_PATH)
- .to_string_lossy()
- .to_string(),
- );
+ args.insert(0, adapter_path.to_string_lossy().to_string());
args
} else {
vec![
- adapter_path
- .join(Self::ADAPTER_PATH)
- .to_string_lossy()
- .to_string(),
+ adapter_path.to_string_lossy().to_string(),
port.to_string(),
host.to_string(),
]
@@ -522,7 +514,7 @@ impl DebugAdapter for JsDebugAdapter {
}
}
- self.get_installed_binary(delegate, &config, user_installed_path, user_args, cx)
+ self.get_installed_binary(delegate, config, user_installed_path, user_args, cx)
.await
}
@@ -1,31 +1,38 @@
use crate::*;
use anyhow::Context as _;
-use dap::adapters::latest_github_release;
use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
-use gpui::{AppContext, AsyncApp, SharedString};
+use fs::RemoveOptions;
+use futures::{StreamExt, TryStreamExt};
+use gpui::http_client::AsyncBody;
+use gpui::{AsyncApp, SharedString};
use json_dotpath::DotPaths;
-use language::{LanguageName, Toolchain};
+use language::LanguageName;
+use paths::debug_adapters_dir;
use serde_json::Value;
+use smol::fs::File;
+use smol::io::AsyncReadExt;
+use smol::lock::OnceCell;
+use std::ffi::OsString;
use std::net::Ipv4Addr;
+use std::str::FromStr;
use std::{
collections::HashMap,
ffi::OsStr,
path::{Path, PathBuf},
- sync::OnceLock,
};
-use util::ResultExt;
+use util::{ResultExt, maybe};
#[derive(Default)]
pub(crate) struct PythonDebugAdapter {
- checked: OnceLock<()>,
+ base_venv_path: OnceCell<Result<Arc<Path>, String>>,
+ debugpy_whl_base_path: OnceCell<Result<Arc<Path>, String>>,
}
impl PythonDebugAdapter {
const ADAPTER_NAME: &'static str = "Debugpy";
const DEBUG_ADAPTER_NAME: DebugAdapterName =
DebugAdapterName(SharedString::new_static(Self::ADAPTER_NAME));
- const ADAPTER_PACKAGE_NAME: &'static str = "debugpy";
- const ADAPTER_PATH: &'static str = "src/debugpy/adapter";
+
const LANGUAGE_NAME: &'static str = "Python";
async fn generate_debugpy_arguments(
@@ -33,43 +40,22 @@ impl PythonDebugAdapter {
port: u16,
user_installed_path: Option<&Path>,
user_args: Option<Vec<String>>,
- installed_in_venv: bool,
) -> Result<Vec<String>> {
let mut args = if let Some(user_installed_path) = user_installed_path {
log::debug!(
"Using user-installed debugpy adapter from: {}",
user_installed_path.display()
);
- vec![
- user_installed_path
- .join(Self::ADAPTER_PATH)
- .to_string_lossy()
- .to_string(),
- ]
- } else if installed_in_venv {
- log::debug!("Using venv-installed debugpy");
- vec!["-m".to_string(), "debugpy.adapter".to_string()]
+ vec![user_installed_path.to_string_lossy().to_string()]
} else {
let adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
- let file_name_prefix = format!("{}_", Self::ADAPTER_NAME);
-
- let debugpy_dir =
- util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
- file_name.starts_with(&file_name_prefix)
- })
- .await
- .context("Debugpy directory not found")?;
-
- log::debug!(
- "Using GitHub-downloaded debugpy adapter from: {}",
- debugpy_dir.display()
- );
- vec![
- debugpy_dir
- .join(Self::ADAPTER_PATH)
- .to_string_lossy()
- .to_string(),
- ]
+ let path = adapter_path
+ .join("debugpy")
+ .join("adapter")
+ .to_string_lossy()
+ .into_owned();
+ log::debug!("Using pip debugpy adapter from: {path}");
+ vec![path]
};
args.extend(if let Some(args) = user_args {
@@ -105,44 +91,184 @@ impl PythonDebugAdapter {
request,
})
}
- async fn fetch_latest_adapter_version(
+
+ async fn fetch_wheel(&self, delegate: &Arc<dyn DapDelegate>) -> Result<Arc<Path>, String> {
+ let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME).join("wheels");
+ std::fs::create_dir_all(&download_dir).map_err(|e| e.to_string())?;
+ let system_python = self.base_venv_path(delegate).await?;
+
+ let installation_succeeded = util::command::new_smol_command(system_python.as_ref())
+ .args([
+ "-m",
+ "pip",
+ "download",
+ "debugpy",
+ "--only-binary=:all:",
+ "-d",
+ download_dir.to_string_lossy().as_ref(),
+ ])
+ .output()
+ .await
+ .map_err(|e| format!("{e}"))?
+ .status
+ .success();
+ if !installation_succeeded {
+ return Err("debugpy installation failed (could not fetch Debugpy's wheel)".into());
+ }
+
+ let wheel_path = std::fs::read_dir(&download_dir)
+ .map_err(|e| e.to_string())?
+ .find_map(|entry| {
+ entry.ok().filter(|e| {
+ e.file_type().is_ok_and(|typ| typ.is_file())
+ && Path::new(&e.file_name()).extension() == Some("whl".as_ref())
+ })
+ })
+ .ok_or_else(|| String::from("Did not find a .whl in {download_dir}"))?;
+
+ util::archive::extract_zip(
+ &debug_adapters_dir().join(Self::ADAPTER_NAME),
+ File::open(&wheel_path.path())
+ .await
+ .map_err(|e| e.to_string())?,
+ )
+ .await
+ .map_err(|e| e.to_string())?;
+
+ Ok(Arc::from(wheel_path.path()))
+ }
+
+ async fn maybe_fetch_new_wheel(&self, delegate: &Arc<dyn DapDelegate>) {
+ let latest_release = delegate
+ .http_client()
+ .get(
+ "https://pypi.org/pypi/debugpy/json",
+ AsyncBody::empty(),
+ false,
+ )
+ .await
+ .log_err();
+ maybe!(async move {
+ let response = latest_release.filter(|response| response.status().is_success())?;
+
+ let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME);
+ std::fs::create_dir_all(&download_dir).ok()?;
+
+ let mut output = String::new();
+ response
+ .into_body()
+ .read_to_string(&mut output)
+ .await
+ .ok()?;
+ let as_json = serde_json::Value::from_str(&output).ok()?;
+ let latest_version = as_json.get("info").and_then(|info| {
+ info.get("version")
+ .and_then(|version| version.as_str())
+ .map(ToOwned::to_owned)
+ })?;
+ let dist_info_dirname: OsString = format!("debugpy-{latest_version}.dist-info").into();
+ let is_up_to_date = delegate
+ .fs()
+ .read_dir(&debug_adapters_dir().join(Self::ADAPTER_NAME))
+ .await
+ .ok()?
+ .into_stream()
+ .any(async |entry| {
+ entry.is_ok_and(|e| e.file_name().is_some_and(|name| name == dist_info_dirname))
+ })
+ .await;
+
+ if !is_up_to_date {
+ delegate
+ .fs()
+ .remove_dir(
+ &debug_adapters_dir().join(Self::ADAPTER_NAME),
+ RemoveOptions {
+ recursive: true,
+ ignore_if_not_exists: true,
+ },
+ )
+ .await
+ .ok()?;
+ self.fetch_wheel(delegate).await.ok()?;
+ }
+ Some(())
+ })
+ .await;
+ }
+
+ async fn fetch_debugpy_whl(
&self,
delegate: &Arc<dyn DapDelegate>,
- ) -> Result<AdapterVersion> {
- let github_repo = GithubRepo {
- repo_name: Self::ADAPTER_PACKAGE_NAME.into(),
- repo_owner: "microsoft".into(),
- };
-
- fetch_latest_adapter_version_from_github(github_repo, delegate.as_ref()).await
+ ) -> Result<Arc<Path>, String> {
+ self.debugpy_whl_base_path
+ .get_or_init(|| async move {
+ self.maybe_fetch_new_wheel(delegate).await;
+ Ok(Arc::from(
+ debug_adapters_dir()
+ .join(Self::ADAPTER_NAME)
+ .join("debugpy")
+ .join("adapter")
+ .as_ref(),
+ ))
+ })
+ .await
+ .clone()
}
- async fn install_binary(
- adapter_name: DebugAdapterName,
- version: AdapterVersion,
- delegate: Arc<dyn DapDelegate>,
- ) -> Result<()> {
- let version_path = adapters::download_adapter_from_github(
- adapter_name,
- version,
- adapters::DownloadedFileType::GzipTar,
- delegate.as_ref(),
- )
- .await?;
- // only needed when you install the latest version for the first time
- if let Some(debugpy_dir) =
- util::fs::find_file_name_in_dir(version_path.as_path(), |file_name| {
- file_name.starts_with("microsoft-debugpy-")
+ async fn base_venv_path(&self, delegate: &Arc<dyn DapDelegate>) -> Result<Arc<Path>, String> {
+ self.base_venv_path
+ .get_or_init(|| async {
+ let base_python = Self::system_python_name(delegate)
+ .await
+ .ok_or_else(|| String::from("Could not find a Python installation"))?;
+
+ let did_succeed = util::command::new_smol_command(base_python)
+ .args(["-m", "venv", "zed_base_venv"])
+ .current_dir(
+ paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref()),
+ )
+ .spawn()
+ .map_err(|e| format!("{e:#?}"))?
+ .status()
+ .await
+ .map_err(|e| format!("{e:#?}"))?
+ .success();
+ if !did_succeed {
+ return Err("Failed to create base virtual environment".into());
+ }
+
+ const DIR: &str = if cfg!(target_os = "windows") {
+ "Scripts"
+ } else {
+ "bin"
+ };
+ Ok(Arc::from(
+ paths::debug_adapters_dir()
+ .join(Self::DEBUG_ADAPTER_NAME.as_ref())
+ .join("zed_base_venv")
+ .join(DIR)
+ .join("python3")
+ .as_ref(),
+ ))
})
.await
- {
- // TODO Debugger: Rename folder instead of moving all files to another folder
- // We're doing unnecessary IO work right now
- util::fs::move_folder_files_to_folder(debugpy_dir.as_path(), version_path.as_path())
- .await?;
- }
+ .clone()
+ }
+ async fn system_python_name(delegate: &Arc<dyn DapDelegate>) -> Option<String> {
+ const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
+ let mut name = None;
- Ok(())
+ for cmd in BINARY_NAMES {
+ name = delegate
+ .which(OsStr::new(cmd))
+ .await
+ .map(|path| path.to_string_lossy().to_string());
+ if name.is_some() {
+ break;
+ }
+ }
+ name
}
async fn get_installed_binary(
@@ -151,28 +277,15 @@ impl PythonDebugAdapter {
config: &DebugTaskDefinition,
user_installed_path: Option<PathBuf>,
user_args: Option<Vec<String>>,
- toolchain: Option<Toolchain>,
- installed_in_venv: bool,
+ python_from_toolchain: Option<String>,
) -> Result<DebugAdapterBinary> {
- const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
- let python_path = if let Some(toolchain) = toolchain {
- Some(toolchain.path.to_string())
+ let python_path = if let Some(toolchain) = python_from_toolchain {
+ Some(toolchain)
} else {
- let mut name = None;
-
- for cmd in BINARY_NAMES {
- name = delegate
- .which(OsStr::new(cmd))
- .await
- .map(|path| path.to_string_lossy().to_string());
- if name.is_some() {
- break;
- }
- }
- name
+ Self::system_python_name(delegate).await
};
let python_command = python_path.context("failed to find binary path for Python")?;
@@ -183,7 +296,6 @@ impl PythonDebugAdapter {
port,
user_installed_path.as_deref(),
user_args,
- installed_in_venv,
)
.await?;
@@ -605,59 +717,52 @@ impl DebugAdapter for PythonDebugAdapter {
local_path.display()
);
return self
- .get_installed_binary(
- delegate,
- &config,
- Some(local_path.clone()),
- user_args,
- None,
- false,
- )
+ .get_installed_binary(delegate, config, Some(local_path.clone()), user_args, None)
.await;
}
+ let base_path = config
+ .config
+ .get("cwd")
+ .and_then(|cwd| {
+ cwd.as_str()
+ .map(Path::new)?
+ .strip_prefix(delegate.worktree_root_path())
+ .ok()
+ })
+ .unwrap_or_else(|| "".as_ref())
+ .into();
let toolchain = delegate
.toolchain_store()
.active_toolchain(
delegate.worktree_id(),
- Arc::from("".as_ref()),
+ base_path,
language::LanguageName::new(Self::LANGUAGE_NAME),
cx,
)
.await;
+ let debugpy_path = self
+ .fetch_debugpy_whl(delegate)
+ .await
+ .map_err(|e| anyhow::anyhow!("{e}"))?;
if let Some(toolchain) = &toolchain {
- if let Some(path) = Path::new(&toolchain.path.to_string()).parent() {
- let debugpy_path = path.join("debugpy");
- if delegate.fs().is_file(&debugpy_path).await {
- log::debug!(
- "Found debugpy in toolchain environment: {}",
- debugpy_path.display()
- );
- return self
- .get_installed_binary(
- delegate,
- &config,
- None,
- user_args,
- Some(toolchain.clone()),
- true,
- )
- .await;
- }
- }
- }
-
- if self.checked.set(()).is_ok() {
- delegate.output_to_console(format!("Checking latest version of {}...", self.name()));
- if let Some(version) = self.fetch_latest_adapter_version(delegate).await.log_err() {
- cx.background_spawn(Self::install_binary(self.name(), version, delegate.clone()))
- .await
- .context("Failed to install debugpy")?;
- }
+ log::debug!(
+ "Found debugpy in toolchain environment: {}",
+ debugpy_path.display()
+ );
+ return self
+ .get_installed_binary(
+ delegate,
+ config,
+ None,
+ user_args,
+ Some(toolchain.path.to_string()),
+ )
+ .await;
}
- self.get_installed_binary(delegate, &config, None, user_args, toolchain, false)
+ self.get_installed_binary(delegate, config, None, user_args, None)
.await
}
@@ -671,26 +776,10 @@ impl DebugAdapter for PythonDebugAdapter {
}
}
-async fn fetch_latest_adapter_version_from_github(
- github_repo: GithubRepo,
- delegate: &dyn DapDelegate,
-) -> Result<AdapterVersion> {
- let release = latest_github_release(
- &format!("{}/{}", github_repo.repo_owner, github_repo.repo_name),
- false,
- false,
- delegate.http_client(),
- )
- .await?;
-
- Ok(AdapterVersion {
- tag_name: release.tag_name,
- url: release.tarball_url,
- })
-}
-
#[cfg(test)]
mod tests {
+ use util::path;
+
use super::*;
use std::{net::Ipv4Addr, path::PathBuf};
@@ -700,31 +789,25 @@ mod tests {
let port = 5678;
// Case 1: User-defined debugpy path (highest precedence)
- let user_path = PathBuf::from("/custom/path/to/debugpy");
- let user_args = PythonDebugAdapter::generate_debugpy_arguments(
- &host,
- port,
- Some(&user_path),
- None,
- false,
- )
- .await
- .unwrap();
-
- // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
- let venv_args =
- PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, None, true)
+ let user_path = PathBuf::from("/custom/path/to/debugpy/src/debugpy/adapter");
+ let user_args =
+ PythonDebugAdapter::generate_debugpy_arguments(&host, port, Some(&user_path), None)
.await
.unwrap();
- assert!(user_args[0].ends_with("src/debugpy/adapter"));
+ // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
+ let venv_args = PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, None)
+ .await
+ .unwrap();
+
+ assert_eq!(user_args[0], "/custom/path/to/debugpy/src/debugpy/adapter");
assert_eq!(user_args[1], "--host=127.0.0.1");
assert_eq!(user_args[2], "--port=5678");
- assert_eq!(venv_args[0], "-m");
- assert_eq!(venv_args[1], "debugpy.adapter");
- assert_eq!(venv_args[2], "--host=127.0.0.1");
- assert_eq!(venv_args[3], "--port=5678");
+ let expected_suffix = path!("debug_adapters/Debugpy/debugpy/adapter");
+ assert!(venv_args[0].ends_with(expected_suffix));
+ assert_eq!(venv_args[1], "--host=127.0.0.1");
+ assert_eq!(venv_args[2], "--port=5678");
// The same cases, with arguments overridden by the user
let user_args = PythonDebugAdapter::generate_debugpy_arguments(
@@ -732,7 +815,6 @@ mod tests {
port,
Some(&user_path),
Some(vec!["foo".into()]),
- false,
)
.await
.unwrap();
@@ -741,7 +823,6 @@ mod tests {
port,
None,
Some(vec!["foo".into()]),
- true,
)
.await
.unwrap();
@@ -749,9 +830,8 @@ mod tests {
assert!(user_args[0].ends_with("src/debugpy/adapter"));
assert_eq!(user_args[1], "foo");
- assert_eq!(venv_args[0], "-m");
- assert_eq!(venv_args[1], "debugpy.adapter");
- assert_eq!(venv_args[2], "foo");
+ assert!(venv_args[0].ends_with(expected_suffix));
+ assert_eq!(venv_args[1], "foo");
// Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
}
@@ -37,7 +37,7 @@ const FALLBACK_DB_NAME: &str = "FALLBACK_MEMORY_DB";
const DB_FILE_NAME: &str = "db.sqlite";
pub static ZED_STATELESS: LazyLock<bool> =
- LazyLock::new(|| env::var("ZED_STATELESS").map_or(false, |v| !v.is_empty()));
+ LazyLock::new(|| env::var("ZED_STATELESS").is_ok_and(|v| !v.is_empty()));
pub static ALL_FILE_DB_FAILED: LazyLock<AtomicBool> = LazyLock::new(|| AtomicBool::new(false));
@@ -74,7 +74,7 @@ pub async fn open_db<M: Migrator + 'static>(db_dir: &Path, scope: &str) -> Threa
}
async fn open_main_db<M: Migrator>(db_path: &Path) -> Option<ThreadSafeConnection> {
- log::info!("Opening database {}", db_path.display());
+ log::trace!("Opening database {}", db_path.display());
ThreadSafeConnection::builder::<M>(db_path.to_string_lossy().as_ref(), true)
.with_db_initialization_query(DB_INITIALIZE_QUERY)
.with_connection_initialize_query(CONNECTION_INITIALIZE_QUERY)
@@ -238,7 +238,7 @@ mod tests {
.unwrap();
let _bad_db = open_db::<BadDB>(
tempdir.path(),
- &release_channel::ReleaseChannel::Dev.dev_name(),
+ release_channel::ReleaseChannel::Dev.dev_name(),
)
.await;
}
@@ -279,7 +279,7 @@ mod tests {
{
let corrupt_db = open_db::<CorruptedDB>(
tempdir.path(),
- &release_channel::ReleaseChannel::Dev.dev_name(),
+ release_channel::ReleaseChannel::Dev.dev_name(),
)
.await;
assert!(corrupt_db.persistent());
@@ -287,7 +287,7 @@ mod tests {
let good_db = open_db::<GoodDB>(
tempdir.path(),
- &release_channel::ReleaseChannel::Dev.dev_name(),
+ release_channel::ReleaseChannel::Dev.dev_name(),
)
.await;
assert!(
@@ -334,7 +334,7 @@ mod tests {
// Setup the bad database
let corrupt_db = open_db::<CorruptedDB>(
tempdir.path(),
- &release_channel::ReleaseChannel::Dev.dev_name(),
+ release_channel::ReleaseChannel::Dev.dev_name(),
)
.await;
assert!(corrupt_db.persistent());
@@ -347,7 +347,7 @@ mod tests {
let guard = thread::spawn(move || {
let good_db = smol::block_on(open_db::<GoodDB>(
tmp_path.as_path(),
- &release_channel::ReleaseChannel::Dev.dev_name(),
+ release_channel::ReleaseChannel::Dev.dev_name(),
));
assert!(
good_db.select_row::<usize>("SELECT * FROM test2").unwrap()()
@@ -20,7 +20,7 @@ pub trait Dismissable {
KEY_VALUE_STORE
.read_kvp(Self::KEY)
.log_err()
- .map_or(false, |s| s.is_some())
+ .is_some_and(|s| s.is_some())
}
fn set_dismissed(is_dismissed: bool, cx: &mut App) {
@@ -392,7 +392,7 @@ impl LogStore {
session.label(),
session
.adapter_client()
- .map_or(false, |client| client.has_adapter_logs()),
+ .is_some_and(|client| client.has_adapter_logs()),
)
});
@@ -485,7 +485,7 @@ impl LogStore {
&mut self,
id: &LogStoreEntryIdentifier<'_>,
) -> Option<&Vec<SharedString>> {
- self.get_debug_adapter_state(&id)
+ self.get_debug_adapter_state(id)
.map(|state| &state.rpc_messages.initialization_sequence)
}
}
@@ -536,11 +536,11 @@ impl Render for DapLogToolbarItemView {
})
.unwrap_or_else(|| "No adapter selected".into()),
))
- .menu(move |mut window, cx| {
+ .menu(move |window, cx| {
let log_view = log_view.clone();
let menu_rows = menu_rows.clone();
let project = project.clone();
- ContextMenu::build(&mut window, cx, move |mut menu, window, _cx| {
+ ContextMenu::build(window, cx, move |mut menu, window, _cx| {
for row in menu_rows.into_iter() {
menu = menu.custom_row(move |_window, _cx| {
div()
@@ -661,11 +661,11 @@ impl ToolbarItemView for DapLogToolbarItemView {
_window: &mut Window,
cx: &mut Context<Self>,
) -> workspace::ToolbarItemLocation {
- if let Some(item) = active_pane_item {
- if let Some(log_view) = item.downcast::<DapLogView>() {
- self.log_view = Some(log_view.clone());
- return workspace::ToolbarItemLocation::PrimaryLeft;
- }
+ if let Some(item) = active_pane_item
+ && let Some(log_view) = item.downcast::<DapLogView>()
+ {
+ self.log_view = Some(log_view);
+ return workspace::ToolbarItemLocation::PrimaryLeft;
}
self.log_view = None;
@@ -1131,7 +1131,7 @@ impl LogStore {
project: &WeakEntity<Project>,
session_id: SessionId,
) -> Vec<SharedString> {
- self.projects.get(&project).map_or(vec![], |state| {
+ self.projects.get(project).map_or(vec![], |state| {
state
.debug_sessions
.get(&session_id)
@@ -35,6 +35,7 @@ command_palette_hooks.workspace = true
dap.workspace = true
dap_adapters = { workspace = true, optional = true }
db.workspace = true
+debugger_tools.workspace = true
editor.workspace = true
file_icons.workspace = true
futures.workspace = true
@@ -54,6 +55,7 @@ picker.workspace = true
pretty_assertions.workspace = true
project.workspace = true
rpc.workspace = true
+schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_json_lenient.workspace = true
@@ -66,14 +68,13 @@ telemetry.workspace = true
terminal_view.workspace = true
text.workspace = true
theme.workspace = true
-tree-sitter.workspace = true
tree-sitter-json.workspace = true
+tree-sitter.workspace = true
ui.workspace = true
+unindent = { workspace = true, optional = true }
util.workspace = true
-workspace.workspace = true
workspace-hack.workspace = true
-debugger_tools.workspace = true
-unindent = { workspace = true, optional = true }
+workspace.workspace = true
zed_actions.workspace = true
[dev-dependencies]
@@ -83,8 +84,8 @@ debugger_tools = { workspace = true, features = ["test-support"] }
editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
+tree-sitter-go.workspace = true
unindent.workspace = true
util = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }
zlog.workspace = true
-tree-sitter-go.workspace = true
@@ -36,7 +36,7 @@ use settings::Settings;
use std::sync::{Arc, LazyLock};
use task::{DebugScenario, TaskContext};
use tree_sitter::{Query, StreamingIterator as _};
-use ui::{ContextMenu, Divider, PopoverMenuHandle, Tooltip, prelude::*};
+use ui::{ContextMenu, Divider, PopoverMenuHandle, Tab, Tooltip, prelude::*};
use util::{ResultExt, debug_panic, maybe};
use workspace::SplitDirection;
use workspace::item::SaveOptions;
@@ -257,7 +257,7 @@ impl DebugPanel {
.as_ref()
.map(|entity| entity.downgrade()),
task_context: task_context.clone(),
- worktree_id: worktree_id,
+ worktree_id,
});
};
running.resolve_scenario(
@@ -300,7 +300,7 @@ impl DebugPanel {
});
session.update(cx, |session, _| match &mut session.mode {
- SessionState::Building(state_task) => {
+ SessionState::Booting(state_task) => {
*state_task = Some(boot_task);
}
SessionState::Running(_) => {
@@ -386,10 +386,10 @@ impl DebugPanel {
return;
};
- let dap_store_handle = self.project.read(cx).dap_store().clone();
+ let dap_store_handle = self.project.read(cx).dap_store();
let label = curr_session.read(cx).label();
let quirks = curr_session.read(cx).quirks();
- let adapter = curr_session.read(cx).adapter().clone();
+ let adapter = curr_session.read(cx).adapter();
let binary = curr_session.read(cx).binary().cloned().unwrap();
let task_context = curr_session.read(cx).task_context().clone();
@@ -447,9 +447,9 @@ impl DebugPanel {
return;
};
- let dap_store_handle = self.project.read(cx).dap_store().clone();
+ let dap_store_handle = self.project.read(cx).dap_store();
let label = self.label_for_child_session(&parent_session, request, cx);
- let adapter = parent_session.read(cx).adapter().clone();
+ let adapter = parent_session.read(cx).adapter();
let quirks = parent_session.read(cx).quirks();
let Some(mut binary) = parent_session.read(cx).binary().cloned() else {
log::error!("Attempted to start a child-session without a binary");
@@ -530,10 +530,9 @@ impl DebugPanel {
.active_session
.as_ref()
.map(|session| session.entity_id())
+ && active_session_id == entity_id
{
- if active_session_id == entity_id {
- this.active_session = this.sessions_with_children.keys().next().cloned();
- }
+ this.active_session = this.sessions_with_children.keys().next().cloned();
}
cx.notify()
})
@@ -642,14 +641,16 @@ impl DebugPanel {
}
})
};
+
let documentation_button = || {
IconButton::new("debug-open-documentation", IconName::CircleHelp)
.icon_size(IconSize::Small)
.on_click(move |_, _, cx| cx.open_url("https://zed.dev/docs/debugger"))
.tooltip(Tooltip::text("Open Documentation"))
};
+
let logs_button = || {
- IconButton::new("debug-open-logs", IconName::ScrollText)
+ IconButton::new("debug-open-logs", IconName::Notepad)
.icon_size(IconSize::Small)
.on_click(move |_, window, cx| {
window.dispatch_action(debugger_tools::OpenDebugAdapterLogs.boxed_clone(), cx)
@@ -658,16 +659,18 @@ impl DebugPanel {
};
Some(
- div.border_b_1()
- .border_color(cx.theme().colors().border)
- .p_1()
+ div.w_full()
+ .py_1()
+ .px_1p5()
.justify_between()
- .w_full()
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
.when(is_side, |this| this.gap_1())
.child(
h_flex()
+ .justify_between()
.child(
- h_flex().gap_2().w_full().when_some(
+ h_flex().gap_1().w_full().when_some(
active_session
.as_ref()
.map(|session| session.read(cx).running_state()),
@@ -679,6 +682,7 @@ impl DebugPanel {
let capabilities = running_state.read(cx).capabilities(cx);
let supports_detach =
running_state.read(cx).session().read(cx).is_attached();
+
this.map(|this| {
if thread_status == ThreadStatus::Running {
this.child(
@@ -686,10 +690,9 @@ impl DebugPanel {
"debug-pause",
IconName::DebugPause,
)
- .icon_size(IconSize::XSmall)
- .shape(ui::IconButtonShape::Square)
+ .icon_size(IconSize::Small)
.on_click(window.listener_for(
- &running_state,
+ running_state,
|this, _, _window, cx| {
this.pause_thread(cx);
},
@@ -698,7 +701,7 @@ impl DebugPanel {
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
- "Pause program",
+ "Pause Program",
&Pause,
&focus_handle,
window,
@@ -713,10 +716,9 @@ impl DebugPanel {
"debug-continue",
IconName::DebugContinue,
)
- .icon_size(IconSize::XSmall)
- .shape(ui::IconButtonShape::Square)
+ .icon_size(IconSize::Small)
.on_click(window.listener_for(
- &running_state,
+ running_state,
|this, _, _window, cx| this.continue_thread(cx),
))
.disabled(thread_status != ThreadStatus::Stopped)
@@ -724,7 +726,7 @@ impl DebugPanel {
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
- "Continue program",
+ "Continue Program",
&Continue,
&focus_handle,
window,
@@ -737,10 +739,9 @@ impl DebugPanel {
})
.child(
IconButton::new("debug-step-over", IconName::ArrowRight)
- .icon_size(IconSize::XSmall)
- .shape(ui::IconButtonShape::Square)
+ .icon_size(IconSize::Small)
.on_click(window.listener_for(
- &running_state,
+ running_state,
|this, _, _window, cx| {
this.step_over(cx);
},
@@ -750,7 +751,7 @@ impl DebugPanel {
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
- "Step over",
+ "Step Over",
&StepOver,
&focus_handle,
window,
@@ -764,10 +765,9 @@ impl DebugPanel {
"debug-step-into",
IconName::ArrowDownRight,
)
- .icon_size(IconSize::XSmall)
- .shape(ui::IconButtonShape::Square)
+ .icon_size(IconSize::Small)
.on_click(window.listener_for(
- &running_state,
+ running_state,
|this, _, _window, cx| {
this.step_in(cx);
},
@@ -777,7 +777,7 @@ impl DebugPanel {
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
- "Step in",
+ "Step In",
&StepInto,
&focus_handle,
window,
@@ -788,10 +788,9 @@ impl DebugPanel {
)
.child(
IconButton::new("debug-step-out", IconName::ArrowUpRight)
- .icon_size(IconSize::XSmall)
- .shape(ui::IconButtonShape::Square)
+ .icon_size(IconSize::Small)
.on_click(window.listener_for(
- &running_state,
+ running_state,
|this, _, _window, cx| {
this.step_out(cx);
},
@@ -801,7 +800,7 @@ impl DebugPanel {
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
- "Step out",
+ "Step Out",
&StepOut,
&focus_handle,
window,
@@ -812,10 +811,10 @@ impl DebugPanel {
)
.child(Divider::vertical())
.child(
- IconButton::new("debug-restart", IconName::DebugRestart)
- .icon_size(IconSize::XSmall)
+ IconButton::new("debug-restart", IconName::RotateCcw)
+ .icon_size(IconSize::Small)
.on_click(window.listener_for(
- &running_state,
+ running_state,
|this, _, window, cx| {
this.rerun_session(window, cx);
},
@@ -835,9 +834,9 @@ impl DebugPanel {
)
.child(
IconButton::new("debug-stop", IconName::Power)
- .icon_size(IconSize::XSmall)
+ .icon_size(IconSize::Small)
.on_click(window.listener_for(
- &running_state,
+ running_state,
|this, _, _window, cx| {
if this.session().read(cx).is_building() {
this.session().update(cx, |session, cx| {
@@ -890,9 +889,9 @@ impl DebugPanel {
thread_status != ThreadStatus::Stopped
&& thread_status != ThreadStatus::Running,
)
- .icon_size(IconSize::XSmall)
+ .icon_size(IconSize::Small)
.on_click(window.listener_for(
- &running_state,
+ running_state,
|this, _, _, cx| {
this.detach_client(cx);
},
@@ -915,7 +914,6 @@ impl DebugPanel {
},
),
)
- .justify_around()
.when(is_side, |this| {
this.child(new_session_button())
.child(logs_button())
@@ -924,7 +922,7 @@ impl DebugPanel {
)
.child(
h_flex()
- .gap_2()
+ .gap_0p5()
.when(is_side, |this| this.justify_between())
.child(
h_flex().when_some(
@@ -934,7 +932,6 @@ impl DebugPanel {
.cloned(),
|this, running_state| {
this.children({
- let running_state = running_state.clone();
let threads =
running_state.update(cx, |running_state, cx| {
let session = running_state.session();
@@ -954,12 +951,15 @@ impl DebugPanel {
)
})
})
- .when(!is_side, |this| this.gap_2().child(Divider::vertical()))
+ .when(!is_side, |this| {
+ this.gap_0p5().child(Divider::vertical())
+ })
},
),
)
.child(
h_flex()
+ .gap_0p5()
.children(self.render_session_menu(
self.active_session(),
self.running_state(cx),
@@ -1158,7 +1158,7 @@ impl DebugPanel {
workspace
.project()
.read(cx)
- .project_path_for_absolute_path(&path, cx)
+ .project_path_for_absolute_path(path, cx)
.context(
"Couldn't get project path for .zed/debug.json in active worktree",
)
@@ -1300,10 +1300,10 @@ impl DebugPanel {
cx: &mut Context<'_, Self>,
) -> Option<SharedString> {
let adapter = parent_session.read(cx).adapter();
- if let Some(adapter) = DapRegistry::global(cx).adapter(&adapter) {
- if let Some(label) = adapter.label_for_child_session(request) {
- return Some(label.into());
- }
+ if let Some(adapter) = DapRegistry::global(cx).adapter(&adapter)
+ && let Some(label) = adapter.label_for_child_session(request)
+ {
+ return Some(label.into());
}
None
}
@@ -1644,7 +1644,6 @@ impl Render for DebugPanel {
}
})
.on_action({
- let this = this.clone();
move |_: &ToggleSessionPicker, window, cx| {
this.update(cx, |this, cx| {
this.toggle_session_picker(window, cx);
@@ -1702,6 +1701,7 @@ impl Render for DebugPanel {
this.child(active_session)
} else {
let docked_to_bottom = self.position(window, cx) == DockPosition::Bottom;
+
let welcome_experience = v_flex()
.when_else(
docked_to_bottom,
@@ -1760,60 +1760,65 @@ impl Render for DebugPanel {
category_filter: Some(
zed_actions::ExtensionCategoryFilter::DebugAdapters,
),
+ id: None,
}
.boxed_clone(),
cx,
);
}),
);
- let breakpoint_list =
- v_flex()
- .group("base-breakpoint-list")
- .items_start()
- .when_else(
- docked_to_bottom,
- |this| this.min_w_1_3().h_full(),
- |this| this.w_full().h_2_3(),
- )
- .p_1()
- .child(
- h_flex()
- .pl_1()
- .w_full()
- .justify_between()
- .child(Label::new("Breakpoints").size(LabelSize::Small))
- .child(h_flex().visible_on_hover("base-breakpoint-list").child(
+
+ let breakpoint_list = v_flex()
+ .group("base-breakpoint-list")
+ .when_else(
+ docked_to_bottom,
+ |this| this.min_w_1_3().h_full(),
+ |this| this.size_full().h_2_3(),
+ )
+ .child(
+ h_flex()
+ .track_focus(&self.breakpoint_list.focus_handle(cx))
+ .h(Tab::container_height(cx))
+ .p_1p5()
+ .w_full()
+ .justify_between()
+ .border_b_1()
+ .border_color(cx.theme().colors().border_variant)
+ .child(Label::new("Breakpoints").size(LabelSize::Small))
+ .child(
+ h_flex().visible_on_hover("base-breakpoint-list").child(
self.breakpoint_list.read(cx).render_control_strip(),
- ))
- .track_focus(&self.breakpoint_list.focus_handle(cx)),
- )
- .child(Divider::horizontal())
- .child(self.breakpoint_list.clone());
+ ),
+ ),
+ )
+ .child(self.breakpoint_list.clone());
+
this.child(
v_flex()
- .h_full()
+ .size_full()
.gap_1()
.items_center()
.justify_center()
- .child(
- div()
- .when_else(docked_to_bottom, Div::h_flex, Div::v_flex)
- .size_full()
- .map(|this| {
- if docked_to_bottom {
- this.items_start()
- .child(breakpoint_list)
- .child(Divider::vertical())
- .child(welcome_experience)
- .child(Divider::vertical())
- } else {
- this.items_end()
- .child(welcome_experience)
- .child(Divider::horizontal())
- .child(breakpoint_list)
- }
- }),
- ),
+ .map(|this| {
+ if docked_to_bottom {
+ this.child(
+ h_flex()
+ .size_full()
+ .child(breakpoint_list)
+ .child(Divider::vertical())
+ .child(welcome_experience)
+ .child(Divider::vertical()),
+ )
+ } else {
+ this.child(
+ v_flex()
+ .size_full()
+ .child(welcome_experience)
+ .child(Divider::horizontal())
+ .child(breakpoint_list),
+ )
+ }
+ }),
)
}
})
@@ -3,10 +3,12 @@ use std::any::TypeId;
use dap::debugger_settings::DebuggerSettings;
use debugger_panel::DebugPanel;
use editor::Editor;
-use gpui::{App, DispatchPhase, EntityInputHandler, actions};
+use gpui::{Action, App, DispatchPhase, EntityInputHandler, actions};
use new_process_modal::{NewProcessModal, NewProcessMode};
use onboarding_modal::DebuggerOnboardingModal;
use project::debugger::{self, breakpoint_store::SourceBreakpoint, session::ThreadStatus};
+use schemars::JsonSchema;
+use serde::Deserialize;
use session::DebugSession;
use settings::Settings;
use stack_trace_view::StackTraceView;
@@ -83,11 +85,23 @@ actions!(
Rerun,
/// Toggles expansion of the selected item in the debugger UI.
ToggleExpandItem,
- /// Set a data breakpoint on the selected variable or memory region.
- ToggleDataBreakpoint,
]
);
+/// Extends selection down by a specified number of lines.
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = debugger)]
+#[serde(deny_unknown_fields)]
+/// Set a data breakpoint on the selected variable or memory region.
+pub struct ToggleDataBreakpoint {
+ /// The type of data breakpoint
+ /// Read & Write
+ /// Read
+ /// Write
+ #[serde(default)]
+ pub access_type: Option<dap::DataBreakpointAccessType>,
+}
+
actions!(
dev,
[
@@ -258,7 +272,6 @@ pub fn init(cx: &mut App) {
}
})
.on_action({
- let active_item = active_item.clone();
move |_: &ToggleIgnoreBreakpoints, _, cx| {
active_item
.update(cx, |item, cx| item.toggle_ignore_breakpoints(cx))
@@ -279,65 +292,81 @@ pub fn init(cx: &mut App) {
let Some(debug_panel) = workspace.read(cx).panel::<DebugPanel>(cx) else {
return;
};
- let Some(active_session) = debug_panel
- .clone()
- .update(cx, |panel, _| panel.active_session())
+ let Some(active_session) =
+ debug_panel.update(cx, |panel, _| panel.active_session())
else {
return;
};
+
+ let session = active_session
+ .read(cx)
+ .running_state
+ .read(cx)
+ .session()
+ .read(cx);
+
+ if session.is_terminated() {
+ return;
+ }
+
let editor = cx.entity().downgrade();
- window.on_action(TypeId::of::<editor::actions::RunToCursor>(), {
- let editor = editor.clone();
- let active_session = active_session.clone();
- move |_, phase, _, cx| {
- if phase != DispatchPhase::Bubble {
- return;
- }
- maybe!({
- let (buffer, position, _) = editor
- .update(cx, |editor, cx| {
- let cursor_point: language::Point =
- editor.selections.newest(cx).head();
- editor
- .buffer()
- .read(cx)
- .point_to_buffer_point(cursor_point, cx)
- })
- .ok()??;
+ window.on_action_when(
+ session.any_stopped_thread(),
+ TypeId::of::<editor::actions::RunToCursor>(),
+ {
+ let editor = editor.clone();
+ let active_session = active_session.clone();
+ move |_, phase, _, cx| {
+ if phase != DispatchPhase::Bubble {
+ return;
+ }
+ maybe!({
+ let (buffer, position, _) = editor
+ .update(cx, |editor, cx| {
+ let cursor_point: language::Point =
+ editor.selections.newest(cx).head();
+
+ editor
+ .buffer()
+ .read(cx)
+ .point_to_buffer_point(cursor_point, cx)
+ })
+ .ok()??;
- let path =
+ let path =
debugger::breakpoint_store::BreakpointStore::abs_path_from_buffer(
&buffer, cx,
)?;
- let source_breakpoint = SourceBreakpoint {
- row: position.row,
- path,
- message: None,
- condition: None,
- hit_condition: None,
- state: debugger::breakpoint_store::BreakpointState::Enabled,
- };
+ let source_breakpoint = SourceBreakpoint {
+ row: position.row,
+ path,
+ message: None,
+ condition: None,
+ hit_condition: None,
+ state: debugger::breakpoint_store::BreakpointState::Enabled,
+ };
- active_session.update(cx, |session, cx| {
- session.running_state().update(cx, |state, cx| {
- if let Some(thread_id) = state.selected_thread_id() {
- state.session().update(cx, |session, cx| {
- session.run_to_position(
- source_breakpoint,
- thread_id,
- cx,
- );
- })
- }
+ active_session.update(cx, |session, cx| {
+ session.running_state().update(cx, |state, cx| {
+ if let Some(thread_id) = state.selected_thread_id() {
+ state.session().update(cx, |session, cx| {
+ session.run_to_position(
+ source_breakpoint,
+ thread_id,
+ cx,
+ );
+ })
+ }
+ });
});
- });
- Some(())
- });
- }
- });
+ Some(())
+ });
+ }
+ },
+ );
window.on_action(
TypeId::of::<editor::actions::EvaluateSelectedText>(),
@@ -272,10 +272,9 @@ impl DebugPanel {
.child(session_entry.label_element(self_depth, cx))
.child(
IconButton::new("close-debug-session", IconName::Close)
- .visible_on_hover(id.clone())
+ .visible_on_hover(id)
.icon_size(IconSize::Small)
.on_click({
- let weak = weak.clone();
move |_, window, cx| {
weak.update(cx, |panel, cx| {
panel.close_session(session_entity_id, window, cx);
@@ -1,5 +1,5 @@
use anyhow::{Context as _, bail};
-use collections::{FxHashMap, HashMap};
+use collections::{FxHashMap, HashMap, HashSet};
use language::LanguageRegistry;
use std::{
borrow::Cow,
@@ -343,10 +343,10 @@ impl NewProcessModal {
return;
}
- if let NewProcessMode::Launch = &self.mode {
- if self.configure_mode.read(cx).save_to_debug_json.selected() {
- self.save_debug_scenario(window, cx);
- }
+ if let NewProcessMode::Launch = &self.mode
+ && self.configure_mode.read(cx).save_to_debug_json.selected()
+ {
+ self.save_debug_scenario(window, cx);
}
let Some(debugger) = self.debugger.clone() else {
@@ -413,7 +413,7 @@ impl NewProcessModal {
let Some(adapter) = self.debugger.as_ref() else {
return;
};
- let scenario = self.debug_scenario(&adapter, cx);
+ let scenario = self.debug_scenario(adapter, cx);
cx.spawn_in(window, async move |this, cx| {
let scenario = scenario.await.context("no scenario to save")?;
let worktree_id = task_contexts
@@ -450,7 +450,7 @@ impl NewProcessModal {
.and_then(|buffer| buffer.read(cx).language())
.cloned();
- let mut available_adapters = workspace
+ let mut available_adapters: Vec<_> = workspace
.update(cx, |_, cx| DapRegistry::global(cx).enumerate_adapters())
.unwrap_or_default();
if let Some(language) = active_buffer_language {
@@ -659,12 +659,7 @@ impl Render for NewProcessModal {
this.mode = NewProcessMode::Attach;
if let Some(debugger) = this.debugger.as_ref() {
- Self::update_attach_picker(
- &this.attach_mode,
- &debugger,
- window,
- cx,
- );
+ Self::update_attach_picker(&this.attach_mode, debugger, window, cx);
}
this.mode_focus_handle(cx).focus(window);
cx.notify();
@@ -766,14 +761,7 @@ impl Render for NewProcessModal {
))
.child(
h_flex()
- .child(div().child(self.adapter_drop_down_menu(window, cx)))
- .child(
- Button::new("debugger-spawn", "Start")
- .on_click(cx.listener(|this, _, window, cx| {
- this.start_new_session(window, cx)
- }))
- .disabled(disabled),
- ),
+ .child(div().child(self.adapter_drop_down_menu(window, cx))),
)
}),
NewProcessMode::Debug => el,
@@ -797,7 +785,7 @@ impl RenderOnce for AttachMode {
v_flex()
.w_full()
.track_focus(&self.attach_picker.focus_handle(cx))
- .child(self.attach_picker.clone())
+ .child(self.attach_picker)
}
}
@@ -1022,15 +1010,13 @@ impl DebugDelegate {
let language_names = languages.language_names();
let language = dap_registry
.adapter_language(&scenario.adapter)
- .map(|language| TaskSourceKind::Language {
- name: language.into(),
- });
+ .map(|language| TaskSourceKind::Language { name: language.0 });
let language = language.or_else(|| {
scenario.label.split_whitespace().find_map(|word| {
language_names
.iter()
- .find(|name| name.eq_ignore_ascii_case(word))
+ .find(|name| name.as_ref().eq_ignore_ascii_case(word))
.map(|name| TaskSourceKind::Language {
name: name.to_owned().into(),
})
@@ -1063,6 +1049,9 @@ impl DebugDelegate {
})
})
});
+
+ let valid_adapters: HashSet<_> = cx.global::<DapRegistry>().enumerate_adapters();
+
cx.spawn(async move |this, cx| {
let (recent, scenarios) = if let Some(task) = task {
task.await
@@ -1089,7 +1078,7 @@ impl DebugDelegate {
.into_iter()
.map(|(scenario, context)| {
let (kind, scenario) =
- Self::get_scenario_kind(&languages, &dap_registry, scenario);
+ Self::get_scenario_kind(&languages, dap_registry, scenario);
(kind, scenario, Some(context))
})
.chain(
@@ -1103,9 +1092,10 @@ impl DebugDelegate {
} => !(hide_vscode && dir.ends_with(".vscode")),
_ => true,
})
+ .filter(|(_, scenario)| valid_adapters.contains(&scenario.adapter))
.map(|(kind, scenario)| {
let (language, scenario) =
- Self::get_scenario_kind(&languages, &dap_registry, scenario);
+ Self::get_scenario_kind(&languages, dap_registry, scenario);
(language.or(Some(kind)), scenario, None)
}),
)
@@ -131,7 +131,7 @@ impl Render for DebuggerOnboardingModal {
.child(Headline::new("Zed's Debugger").size(HeadlineSize::Large)),
)
.child(h_flex().absolute().top_2().right_2().child(
- IconButton::new("cancel", IconName::X).on_click(cx.listener(
+ IconButton::new("cancel", IconName::Close).on_click(cx.listener(
|_, _: &ClickEvent, _window, cx| {
debugger_onboarding_event!("Cancelled", trigger = "X click");
cx.emit(DismissEvent);
@@ -256,7 +256,7 @@ pub(crate) fn deserialize_pane_layout(
Some(Member::Axis(PaneAxis::load(
if should_invert { axis.invert() } else { axis },
members,
- flexes.clone(),
+ flexes,
)))
}
SerializedPaneLayout::Pane(serialized_pane) => {
@@ -341,7 +341,7 @@ impl SerializedPaneLayout {
pub(crate) fn in_order(&self) -> Vec<SerializedPaneLayout> {
let mut panes = vec![];
- Self::inner_in_order(&self, &mut panes);
+ Self::inner_in_order(self, &mut panes);
panes
}
@@ -87,7 +87,7 @@ impl DebugSession {
self.stack_trace_view.get_or_init(|| {
let stackframe_list = running_state.read(cx).stack_frame_list().clone();
- let stack_frame_view = cx.new(|cx| {
+ cx.new(|cx| {
StackTraceView::new(
workspace.clone(),
project.clone(),
@@ -95,9 +95,7 @@ impl DebugSession {
window,
cx,
)
- });
-
- stack_frame_view
+ })
})
}
@@ -48,10 +48,8 @@ use task::{
};
use terminal_view::TerminalView;
use ui::{
- ActiveTheme, AnyElement, App, ButtonCommon as _, Clickable as _, Context, FluentBuilder,
- IconButton, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon as _,
- ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Tab, Tooltip,
- VisibleOnHover, VisualContext, Window, div, h_flex, v_flex,
+ FluentBuilder, IntoElement, Render, StatefulInteractiveElement, Tab, Tooltip, VisibleOnHover,
+ VisualContext, prelude::*,
};
use util::ResultExt;
use variable_list::VariableList;
@@ -104,7 +102,7 @@ impl Render for RunningState {
.find(|pane| pane.read(cx).is_zoomed());
let active = self.panes.panes().into_iter().next();
- let pane = if let Some(ref zoomed_pane) = zoomed_pane {
+ let pane = if let Some(zoomed_pane) = zoomed_pane {
zoomed_pane.update(cx, |pane, cx| pane.render(window, cx).into_any_element())
} else if let Some(active) = active {
self.panes
@@ -182,7 +180,7 @@ impl SubView {
let weak_list = list.downgrade();
let focus_handle = list.focus_handle(cx);
let this = Self::new(
- focus_handle.clone(),
+ focus_handle,
list.into(),
DebuggerPaneItem::BreakpointList,
cx,
@@ -293,7 +291,7 @@ pub(crate) fn new_debugger_pane(
let Some(project) = project.upgrade() else {
return ControlFlow::Break(());
};
- let this_pane = cx.entity().clone();
+ let this_pane = cx.entity();
let item = if tab.pane == this_pane {
pane.item_for_index(tab.ix)
} else {
@@ -360,7 +358,7 @@ pub(crate) fn new_debugger_pane(
}
};
- let ret = cx.new(move |cx| {
+ cx.new(move |cx| {
let mut pane = Pane::new(
workspace.clone(),
project.clone(),
@@ -416,19 +414,19 @@ pub(crate) fn new_debugger_pane(
.and_then(|item| item.downcast::<SubView>());
let is_hovered = as_subview
.as_ref()
- .map_or(false, |item| item.read(cx).hovered);
+ .is_some_and(|item| item.read(cx).hovered);
h_flex()
+ .track_focus(&focus_handle)
.group(pane_group_id.clone())
+ .pl_1p5()
+ .pr_1()
.justify_between()
- .bg(cx.theme().colors().tab_bar_background)
.border_b_1()
- .px_2()
.border_color(cx.theme().colors().border)
- .track_focus(&focus_handle)
+ .bg(cx.theme().colors().tab_bar_background)
.on_action(|_: &menu::Cancel, window, cx| {
if cx.stop_active_drag(window) {
- return;
} else {
cx.propagate();
}
@@ -450,7 +448,7 @@ pub(crate) fn new_debugger_pane(
.children(pane.items().enumerate().map(|(ix, item)| {
let selected = active_pane_item
.as_ref()
- .map_or(false, |active| active.item_id() == item.item_id());
+ .is_some_and(|active| active.item_id() == item.item_id());
let deemphasized = !pane.has_focus(window, cx);
let item_ = item.boxed_clone();
div()
@@ -503,7 +501,7 @@ pub(crate) fn new_debugger_pane(
.on_drag(
DraggedTab {
item: item.boxed_clone(),
- pane: cx.entity().clone(),
+ pane: cx.entity(),
detail: 0,
is_active: selected,
ix,
@@ -514,6 +512,7 @@ pub(crate) fn new_debugger_pane(
)
.child({
let zoomed = pane.is_zoomed();
+
h_flex()
.visible_on_hover(pane_group_id)
.when(is_hovered, |this| this.visible())
@@ -537,7 +536,7 @@ pub(crate) fn new_debugger_pane(
IconName::Maximize
},
)
- .icon_size(IconSize::XSmall)
+ .icon_size(IconSize::Small)
.on_click(cx.listener(move |pane, _, _, cx| {
let is_zoomed = pane.is_zoomed();
pane.set_zoomed(!is_zoomed, cx);
@@ -563,9 +562,7 @@ pub(crate) fn new_debugger_pane(
}
});
pane
- });
-
- ret
+ })
}
pub struct DebugTerminal {
@@ -592,10 +589,11 @@ impl DebugTerminal {
}
impl gpui::Render for DebugTerminal {
- fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
- .size_full()
.track_focus(&self.focus_handle)
+ .size_full()
+ .bg(cx.theme().colors().editor_background)
.children(self.terminal.clone())
}
}
@@ -626,7 +624,7 @@ impl RunningState {
if s.starts_with("\"$ZED_") && s.ends_with('"') {
*s = s[1..s.len() - 1].to_string();
}
- if let Some(substituted) = substitute_variables_in_str(&s, context) {
+ if let Some(substituted) = substitute_variables_in_str(s, context) {
*s = substituted;
}
}
@@ -656,7 +654,7 @@ impl RunningState {
}
resolve_path(s);
- if let Some(substituted) = substitute_variables_in_str(&s, context) {
+ if let Some(substituted) = substitute_variables_in_str(s, context) {
*s = substituted;
}
}
@@ -918,7 +916,10 @@ impl RunningState {
let task_store = project.read(cx).task_store().downgrade();
let weak_project = project.downgrade();
let weak_workspace = workspace.downgrade();
- let is_local = project.read(cx).is_local();
+ let ssh_info = project
+ .read(cx)
+ .ssh_client()
+ .and_then(|it| it.read(cx).ssh_info());
cx.spawn_in(window, async move |this, cx| {
let DebugScenario {
@@ -953,7 +954,7 @@ impl RunningState {
inventory.read(cx).task_template_by_label(
buffer,
worktree_id,
- &label,
+ label,
cx,
)
})
@@ -1002,7 +1003,7 @@ impl RunningState {
None
};
- let builder = ShellBuilder::new(is_local, &task.resolved.shell);
+ let builder = ShellBuilder::new(ssh_info.as_ref().map(|info| &*info.shell), &task.resolved.shell);
let command_label = builder.command_label(&task.resolved.command_label);
let (command, args) =
builder.build(task.resolved.command.clone(), &task.resolved.args);
@@ -1014,10 +1015,9 @@ impl RunningState {
..task.resolved.clone()
};
let terminal = project
- .update_in(cx, |project, window, cx| {
+ .update(cx, |project, cx| {
project.create_terminal(
TerminalKind::Task(task_with_shell.clone()),
- window.window_handle(),
cx,
)
})?
@@ -1116,9 +1116,8 @@ impl RunningState {
};
let session = self.session.read(cx);
- let cwd = Some(&request.cwd)
- .filter(|cwd| cwd.len() > 0)
- .map(PathBuf::from)
+ let cwd = (!request.cwd.is_empty())
+ .then(|| PathBuf::from(&request.cwd))
.or_else(|| session.binary().unwrap().cwd.clone());
let mut envs: HashMap<String, String> =
@@ -1153,7 +1152,7 @@ impl RunningState {
} else {
None
}
- } else if args.len() > 0 {
+ } else if !args.is_empty() {
Some(args.remove(0))
} else {
None
@@ -1170,9 +1169,9 @@ impl RunningState {
id: task::TaskId("debug".to_string()),
full_label: title.clone(),
label: title.clone(),
- command: command.clone(),
+ command,
args,
- command_label: title.clone(),
+ command_label: title,
cwd,
env: envs,
use_new_terminal: true,
@@ -1189,9 +1188,7 @@ impl RunningState {
let workspace = self.workspace.clone();
let weak_project = project.downgrade();
- let terminal_task = project.update(cx, |project, cx| {
- project.create_terminal(kind, window.window_handle(), cx)
- });
+ let terminal_task = project.update(cx, |project, cx| project.create_terminal(kind, cx));
let terminal_task = cx.spawn_in(window, async move |_, cx| {
let terminal = terminal_task.await?;
@@ -1312,7 +1309,7 @@ impl RunningState {
let mut pane_item_status = IndexMap::from_iter(
DebuggerPaneItem::all()
.iter()
- .filter(|kind| kind.is_supported(&caps))
+ .filter(|kind| kind.is_supported(caps))
.map(|kind| (*kind, false)),
);
self.panes.panes().iter().for_each(|pane| {
@@ -1373,7 +1370,7 @@ impl RunningState {
this.serialize_layout(window, cx);
match event {
Event::Remove { .. } => {
- let _did_find_pane = this.panes.remove(&source_pane).is_ok();
+ let _did_find_pane = this.panes.remove(source_pane).is_ok();
debug_assert!(_did_find_pane);
cx.notify();
}
@@ -1651,7 +1648,7 @@ impl RunningState {
let is_building = self.session.update(cx, |session, cx| {
session.shutdown(cx).detach();
- matches!(session.mode, session::SessionState::Building(_))
+ matches!(session.mode, session::SessionState::Booting(_))
});
if is_building {
@@ -1761,7 +1758,7 @@ impl RunningState {
this.activate_item(0, false, false, window, cx);
});
- let rightmost_pane = new_debugger_pane(workspace.clone(), project.clone(), window, cx);
+ let rightmost_pane = new_debugger_pane(workspace.clone(), project, window, cx);
rightmost_pane.update(cx, |this, cx| {
this.add_item(
Box::new(SubView::new(
@@ -23,13 +23,9 @@ use project::{
worktree_store::WorktreeStore,
};
use ui::{
- ActiveTheme, AnyElement, App, ButtonCommon, Clickable, Color, Context, Disableable, Div,
- Divider, FluentBuilder as _, Icon, IconButton, IconName, IconSize, InteractiveElement,
- IntoElement, Label, LabelCommon, LabelSize, ListItem, ParentElement, Render, RenderOnce,
- Scrollbar, ScrollbarState, SharedString, StatefulInteractiveElement, Styled, Toggleable,
- Tooltip, Window, div, h_flex, px, v_flex,
+ Divider, DividerColor, FluentBuilder as _, Indicator, IntoElement, ListItem, Render, Scrollbar,
+ ScrollbarState, StatefulInteractiveElement, Tooltip, prelude::*,
};
-use util::ResultExt;
use workspace::Workspace;
use zed_actions::{ToggleEnableBreakpoint, UnsetBreakpoint};
@@ -56,8 +52,6 @@ pub(crate) struct BreakpointList {
scrollbar_state: ScrollbarState,
breakpoints: Vec<BreakpointEntry>,
session: Option<Entity<Session>>,
- hide_scrollbar_task: Option<Task<()>>,
- show_scrollbar: bool,
focus_handle: FocusHandle,
scroll_handle: UniformListScrollHandle,
selected_ix: Option<usize>,
@@ -103,8 +97,6 @@ impl BreakpointList {
worktree_store,
scrollbar_state,
breakpoints: Default::default(),
- hide_scrollbar_task: None,
- show_scrollbar: false,
workspace,
session,
focus_handle,
@@ -247,14 +239,12 @@ impl BreakpointList {
}
fn select_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
- if self.strip_mode.is_some() {
- if self.input.focus_handle(cx).contains_focused(window, cx) {
- cx.propagate();
- return;
- }
+ if self.strip_mode.is_some() && self.input.focus_handle(cx).contains_focused(window, cx) {
+ cx.propagate();
+ return;
}
let ix = match self.selected_ix {
- _ if self.breakpoints.len() == 0 => None,
+ _ if self.breakpoints.is_empty() => None,
None => Some(0),
Some(ix) => {
if ix == self.breakpoints.len() - 1 {
@@ -273,14 +263,12 @@ impl BreakpointList {
window: &mut Window,
cx: &mut Context<Self>,
) {
- if self.strip_mode.is_some() {
- if self.input.focus_handle(cx).contains_focused(window, cx) {
- cx.propagate();
- return;
- }
+ if self.strip_mode.is_some() && self.input.focus_handle(cx).contains_focused(window, cx) {
+ cx.propagate();
+ return;
}
let ix = match self.selected_ix {
- _ if self.breakpoints.len() == 0 => None,
+ _ if self.breakpoints.is_empty() => None,
None => Some(self.breakpoints.len() - 1),
Some(ix) => {
if ix == 0 {
@@ -294,13 +282,11 @@ impl BreakpointList {
}
fn select_first(&mut self, _: &menu::SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
- if self.strip_mode.is_some() {
- if self.input.focus_handle(cx).contains_focused(window, cx) {
- cx.propagate();
- return;
- }
+ if self.strip_mode.is_some() && self.input.focus_handle(cx).contains_focused(window, cx) {
+ cx.propagate();
+ return;
}
- let ix = if self.breakpoints.len() > 0 {
+ let ix = if !self.breakpoints.is_empty() {
Some(0)
} else {
None
@@ -309,13 +295,11 @@ impl BreakpointList {
}
fn select_last(&mut self, _: &menu::SelectLast, window: &mut Window, cx: &mut Context<Self>) {
- if self.strip_mode.is_some() {
- if self.input.focus_handle(cx).contains_focused(window, cx) {
- cx.propagate();
- return;
- }
+ if self.strip_mode.is_some() && self.input.focus_handle(cx).contains_focused(window, cx) {
+ cx.propagate();
+ return;
}
- let ix = if self.breakpoints.len() > 0 {
+ let ix = if !self.breakpoints.is_empty() {
Some(self.breakpoints.len() - 1)
} else {
None
@@ -345,8 +329,8 @@ impl BreakpointList {
let text = self.input.read(cx).text(cx);
match mode {
- ActiveBreakpointStripMode::Log => match &entry.kind {
- BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
+ ActiveBreakpointStripMode::Log => {
+ if let BreakpointEntryKind::LineBreakpoint(line_breakpoint) = &entry.kind {
Self::edit_line_breakpoint_inner(
&self.breakpoint_store,
line_breakpoint.breakpoint.path.clone(),
@@ -355,10 +339,9 @@ impl BreakpointList {
cx,
);
}
- _ => {}
- },
- ActiveBreakpointStripMode::Condition => match &entry.kind {
- BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
+ }
+ ActiveBreakpointStripMode::Condition => {
+ if let BreakpointEntryKind::LineBreakpoint(line_breakpoint) = &entry.kind {
Self::edit_line_breakpoint_inner(
&self.breakpoint_store,
line_breakpoint.breakpoint.path.clone(),
@@ -367,10 +350,9 @@ impl BreakpointList {
cx,
);
}
- _ => {}
- },
- ActiveBreakpointStripMode::HitCondition => match &entry.kind {
- BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
+ }
+ ActiveBreakpointStripMode::HitCondition => {
+ if let BreakpointEntryKind::LineBreakpoint(line_breakpoint) = &entry.kind {
Self::edit_line_breakpoint_inner(
&self.breakpoint_store,
line_breakpoint.breakpoint.path.clone(),
@@ -379,8 +361,7 @@ impl BreakpointList {
cx,
);
}
- _ => {}
- },
+ }
}
self.focus_handle.focus(window);
} else {
@@ -409,11 +390,9 @@ impl BreakpointList {
let Some(entry) = self.selected_ix.and_then(|ix| self.breakpoints.get_mut(ix)) else {
return;
};
- if self.strip_mode.is_some() {
- if self.input.focus_handle(cx).contains_focused(window, cx) {
- cx.propagate();
- return;
- }
+ if self.strip_mode.is_some() && self.input.focus_handle(cx).contains_focused(window, cx) {
+ cx.propagate();
+ return;
}
match &mut entry.kind {
@@ -444,13 +423,10 @@ impl BreakpointList {
return;
};
- match &mut entry.kind {
- BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
- let path = line_breakpoint.breakpoint.path.clone();
- let row = line_breakpoint.breakpoint.row;
- self.edit_line_breakpoint(path, row, BreakpointEditAction::Toggle, cx);
- }
- _ => {}
+ if let BreakpointEntryKind::LineBreakpoint(line_breakpoint) = &mut entry.kind {
+ let path = line_breakpoint.breakpoint.path.clone();
+ let row = line_breakpoint.breakpoint.row;
+ self.edit_line_breakpoint(path, row, BreakpointEditAction::Toggle, cx);
}
cx.notify();
}
@@ -502,7 +478,7 @@ impl BreakpointList {
fn toggle_data_breakpoint(&mut self, id: &str, cx: &mut Context<Self>) {
if let Some(session) = &self.session {
session.update(cx, |this, cx| {
- this.toggle_data_breakpoint(&id, cx);
+ this.toggle_data_breakpoint(id, cx);
});
}
}
@@ -510,7 +486,7 @@ impl BreakpointList {
fn toggle_exception_breakpoint(&mut self, id: &str, cx: &mut Context<Self>) {
if let Some(session) = &self.session {
session.update(cx, |this, cx| {
- this.toggle_exception_breakpoint(&id, cx);
+ this.toggle_exception_breakpoint(id, cx);
});
cx.notify();
const EXCEPTION_SERIALIZATION_INTERVAL: Duration = Duration::from_secs(1);
@@ -546,7 +522,7 @@ impl BreakpointList {
cx.background_executor()
.spawn(async move { KEY_VALUE_STORE.write_kvp(key, value?).await })
} else {
- return Task::ready(Result::Ok(()));
+ Task::ready(Result::Ok(()))
}
}
@@ -565,21 +541,6 @@ impl BreakpointList {
Ok(())
}
- fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
- self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| {
- cx.background_executor()
- .timer(SCROLLBAR_SHOW_INTERVAL)
- .await;
- panel
- .update(cx, |panel, cx| {
- panel.show_scrollbar = false;
- cx.notify();
- })
- .log_err();
- }))
- }
-
fn render_list(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let selected_ix = self.selected_ix;
let focus_handle = self.focus_handle.clone();
@@ -589,6 +550,7 @@ impl BreakpointList {
.map(|session| SupportedBreakpointProperties::from(session.read(cx).capabilities()))
.unwrap_or_else(SupportedBreakpointProperties::empty);
let strip_mode = self.strip_mode;
+
uniform_list(
"breakpoint-list",
self.breakpoints.len(),
@@ -611,49 +573,46 @@ impl BreakpointList {
}),
)
.track_scroll(self.scroll_handle.clone())
- .flex_grow()
+ .flex_1()
}
- fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
- if !(self.show_scrollbar || self.scrollbar_state.is_dragging()) {
- return None;
- }
- Some(
- div()
- .occlude()
- .id("breakpoint-list-vertical-scrollbar")
- .on_mouse_move(cx.listener(|_, _, _, cx| {
- cx.notify();
- cx.stop_propagation()
- }))
- .on_hover(|_, _, cx| {
- cx.stop_propagation();
- })
- .on_any_mouse_down(|_, _, cx| {
+ fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
+ div()
+ .occlude()
+ .id("breakpoint-list-vertical-scrollbar")
+ .on_mouse_move(cx.listener(|_, _, _, cx| {
+ cx.notify();
+ cx.stop_propagation()
+ }))
+ .on_hover(|_, _, cx| {
+ cx.stop_propagation();
+ })
+ .on_any_mouse_down(|_, _, cx| {
+ cx.stop_propagation();
+ })
+ .on_mouse_up(
+ MouseButton::Left,
+ cx.listener(|_, _, _, cx| {
cx.stop_propagation();
- })
- .on_mouse_up(
- MouseButton::Left,
- cx.listener(|_, _, _, cx| {
- cx.stop_propagation();
- }),
- )
- .on_scroll_wheel(cx.listener(|_, _, _, cx| {
- cx.notify();
- }))
- .h_full()
- .absolute()
- .right_1()
- .top_1()
- .bottom_0()
- .w(px(12.))
- .cursor_default()
- .children(Scrollbar::vertical(self.scrollbar_state.clone())),
- )
+ }),
+ )
+ .on_scroll_wheel(cx.listener(|_, _, _, cx| {
+ cx.notify();
+ }))
+ .h_full()
+ .absolute()
+ .right_1()
+ .top_1()
+ .bottom_0()
+ .w(px(12.))
+ .cursor_default()
+ .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
}
+
pub(crate) fn render_control_strip(&self) -> AnyElement {
let selection_kind = self.selection_kind();
let focus_handle = self.focus_handle.clone();
+
let remove_breakpoint_tooltip = selection_kind.map(|(kind, _)| match kind {
SelectedBreakpointKind::Source => "Remove breakpoint from a breakpoint list",
SelectedBreakpointKind::Exception => {
@@ -661,6 +620,7 @@ impl BreakpointList {
}
SelectedBreakpointKind::Data => "Remove data breakpoint from a breakpoint list",
});
+
let toggle_label = selection_kind.map(|(_, is_enabled)| {
if is_enabled {
(
@@ -673,13 +633,12 @@ impl BreakpointList {
});
h_flex()
- .gap_2()
.child(
IconButton::new(
"disable-breakpoint-breakpoint-list",
IconName::DebugDisabledBreakpoint,
)
- .icon_size(IconSize::XSmall)
+ .icon_size(IconSize::Small)
.when_some(toggle_label, |this, (label, meta)| {
this.tooltip({
let focus_handle = focus_handle.clone();
@@ -705,9 +664,8 @@ impl BreakpointList {
}),
)
.child(
- IconButton::new("remove-breakpoint-breakpoint-list", IconName::X)
- .icon_size(IconSize::XSmall)
- .icon_color(ui::Color::Error)
+ IconButton::new("remove-breakpoint-breakpoint-list", IconName::Trash)
+ .icon_size(IconSize::Small)
.when_some(remove_breakpoint_tooltip, |this, tooltip| {
this.tooltip({
let focus_handle = focus_handle.clone();
@@ -727,14 +685,12 @@ impl BreakpointList {
selection_kind.map(|kind| kind.0) != Some(SelectedBreakpointKind::Source),
)
.on_click({
- let focus_handle = focus_handle.clone();
move |_, window, cx| {
focus_handle.focus(window);
window.dispatch_action(UnsetBreakpoint.boxed_clone(), cx)
}
}),
)
- .mr_2()
.into_any_element()
}
}
@@ -815,19 +771,11 @@ impl Render for BreakpointList {
.chain(data_breakpoints)
.chain(exception_breakpoints),
);
+
v_flex()
.id("breakpoint-list")
.key_context("BreakpointList")
.track_focus(&self.focus_handle)
- .on_hover(cx.listener(|this, hovered, window, cx| {
- if *hovered {
- this.show_scrollbar = true;
- this.hide_scrollbar_task.take();
- cx.notify();
- } else if !this.focus_handle.contains_focused(window, cx) {
- this.hide_scrollbar(window, cx);
- }
- }))
.on_action(cx.listener(Self::select_next))
.on_action(cx.listener(Self::select_previous))
.on_action(cx.listener(Self::select_first))
@@ -839,35 +787,33 @@ impl Render for BreakpointList {
.on_action(cx.listener(Self::next_breakpoint_property))
.on_action(cx.listener(Self::previous_breakpoint_property))
.size_full()
- .m_0p5()
- .child(
- v_flex()
- .size_full()
- .child(self.render_list(cx))
- .children(self.render_vertical_scrollbar(cx)),
- )
+ .pt_1()
+ .child(self.render_list(cx))
+ .child(self.render_vertical_scrollbar(cx))
.when_some(self.strip_mode, |this, _| {
- this.child(Divider::horizontal()).child(
- h_flex()
- // .w_full()
- .m_0p5()
- .p_0p5()
- .border_1()
- .rounded_sm()
- .when(
- self.input.focus_handle(cx).contains_focused(window, cx),
- |this| {
- let colors = cx.theme().colors();
- let border = if self.input.read(cx).read_only(cx) {
- colors.border_disabled
- } else {
- colors.border_focused
- };
- this.border_color(border)
- },
- )
- .child(self.input.clone()),
- )
+ this.child(Divider::horizontal().color(DividerColor::Border))
+ .child(
+ h_flex()
+ .p_1()
+ .rounded_sm()
+ .bg(cx.theme().colors().editor_background)
+ .border_1()
+ .when(
+ self.input.focus_handle(cx).contains_focused(window, cx),
+ |this| {
+ let colors = cx.theme().colors();
+
+ let border_color = if self.input.read(cx).read_only(cx) {
+ colors.border_disabled
+ } else {
+ colors.border_transparent
+ };
+
+ this.border_color(border_color)
+ },
+ )
+ .child(self.input.clone()),
+ )
})
}
}
@@ -898,12 +844,17 @@ impl LineBreakpoint {
let path = self.breakpoint.path.clone();
let row = self.breakpoint.row;
let is_enabled = self.breakpoint.state.is_enabled();
+
let indicator = div()
.id(SharedString::from(format!(
"breakpoint-ui-toggle-{:?}/{}:{}",
self.dir, self.name, self.line
)))
- .cursor_pointer()
+ .child(
+ Icon::new(icon_name)
+ .color(Color::Debugger)
+ .size(IconSize::XSmall),
+ )
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
@@ -935,17 +886,14 @@ impl LineBreakpoint {
.ok();
}
})
- .child(
- Icon::new(icon_name)
- .color(Color::Debugger)
- .size(IconSize::XSmall),
- )
.on_mouse_down(MouseButton::Left, move |_, _, _| {});
ListItem::new(SharedString::from(format!(
"breakpoint-ui-item-{:?}/{}:{}",
self.dir, self.name, self.line
)))
+ .toggle_state(is_selected)
+ .inset(true)
.on_click({
let weak = weak.clone();
move |_, window, cx| {
@@ -955,23 +903,20 @@ impl LineBreakpoint {
.ok();
}
})
- .start_slot(indicator)
- .rounded()
.on_secondary_mouse_down(|_, _, cx| {
cx.stop_propagation();
})
+ .start_slot(indicator)
.child(
h_flex()
- .w_full()
- .mr_4()
- .py_0p5()
- .gap_1()
- .min_h(px(26.))
- .justify_between()
.id(SharedString::from(format!(
"breakpoint-ui-on-click-go-to-line-{:?}/{}:{}",
self.dir, self.name, self.line
)))
+ .w_full()
+ .gap_1()
+ .min_h(rems_from_px(26.))
+ .justify_between()
.on_click({
let weak = weak.clone();
move |_, window, cx| {
@@ -982,9 +927,9 @@ impl LineBreakpoint {
.ok();
}
})
- .cursor_pointer()
.child(
h_flex()
+ .id("label-container")
.gap_0p5()
.child(
Label::new(format!("{}:{}", self.name, self.line))
@@ -1004,16 +949,18 @@ impl LineBreakpoint {
.line_height_style(ui::LineHeightStyle::UiLabel)
.truncate(),
)
- })),
+ }))
+ .when_some(self.dir.as_ref(), |this, parent_dir| {
+ this.tooltip(Tooltip::text(format!(
+ "Worktree parent path: {parent_dir}"
+ )))
+ }),
)
- .when_some(self.dir.as_ref(), |this, parent_dir| {
- this.tooltip(Tooltip::text(format!("Worktree parent path: {parent_dir}")))
- })
.child(BreakpointOptionsStrip {
props,
breakpoint: BreakpointEntry {
kind: BreakpointEntryKind::LineBreakpoint(self.clone()),
- weak: weak,
+ weak,
},
is_selected,
focus_handle,
@@ -1021,15 +968,16 @@ impl LineBreakpoint {
index: ix,
}),
)
- .toggle_state(is_selected)
}
}
+
#[derive(Clone, Debug)]
struct ExceptionBreakpoint {
id: String,
data: ExceptionBreakpointsFilter,
is_enabled: bool,
}
+
#[derive(Clone, Debug)]
struct DataBreakpoint(project::debugger::session::DataBreakpointState);
@@ -1050,17 +998,24 @@ impl DataBreakpoint {
};
let is_enabled = self.0.is_enabled;
let id = self.0.dap.data_id.clone();
+
ListItem::new(SharedString::from(format!(
"data-breakpoint-ui-item-{}",
self.0.dap.data_id
)))
- .rounded()
+ .toggle_state(is_selected)
+ .inset(true)
.start_slot(
div()
.id(SharedString::from(format!(
"data-breakpoint-ui-item-{}-click-handler",
self.0.dap.data_id
)))
+ .child(
+ Icon::new(IconName::Binary)
+ .color(color)
+ .size(IconSize::Small),
+ )
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
@@ -1085,25 +1040,18 @@ impl DataBreakpoint {
})
.ok();
}
- })
- .cursor_pointer()
- .child(
- Icon::new(IconName::Binary)
- .color(color)
- .size(IconSize::Small),
- ),
+ }),
)
.child(
h_flex()
.w_full()
- .mr_4()
- .py_0p5()
+ .gap_1()
+ .min_h(rems_from_px(26.))
.justify_between()
.child(
v_flex()
.py_1()
.gap_1()
- .min_h(px(26.))
.justify_center()
.id(("data-breakpoint-label", ix))
.child(
@@ -1124,7 +1072,6 @@ impl DataBreakpoint {
index: ix,
}),
)
- .toggle_state(is_selected)
}
}
@@ -1146,10 +1093,13 @@ impl ExceptionBreakpoint {
let id = SharedString::from(&self.id);
let is_enabled = self.is_enabled;
let weak = list.clone();
+
ListItem::new(SharedString::from(format!(
"exception-breakpoint-ui-item-{}",
self.id
)))
+ .toggle_state(is_selected)
+ .inset(true)
.on_click({
let list = list.clone();
move |_, window, cx| {
@@ -1157,7 +1107,6 @@ impl ExceptionBreakpoint {
.ok();
}
})
- .rounded()
.on_secondary_mouse_down(|_, _, cx| {
cx.stop_propagation();
})
@@ -1167,6 +1116,11 @@ impl ExceptionBreakpoint {
"exception-breakpoint-ui-item-{}-click-handler",
self.id
)))
+ .child(
+ Icon::new(IconName::Flame)
+ .color(color)
+ .size(IconSize::Small),
+ )
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
@@ -1184,32 +1138,24 @@ impl ExceptionBreakpoint {
}
})
.on_click({
- let list = list.clone();
move |_, _, cx| {
list.update(cx, |this, cx| {
this.toggle_exception_breakpoint(&id, cx);
})
.ok();
}
- })
- .cursor_pointer()
- .child(
- Icon::new(IconName::Flame)
- .color(color)
- .size(IconSize::Small),
- ),
+ }),
)
.child(
h_flex()
.w_full()
- .mr_4()
- .py_0p5()
+ .gap_1()
+ .min_h(rems_from_px(26.))
.justify_between()
.child(
v_flex()
.py_1()
.gap_1()
- .min_h(px(26.))
.justify_center()
.id(("exception-breakpoint-label", ix))
.child(
@@ -1225,7 +1171,7 @@ impl ExceptionBreakpoint {
props,
breakpoint: BreakpointEntry {
kind: BreakpointEntryKind::ExceptionBreakpoint(self.clone()),
- weak: weak,
+ weak,
},
is_selected,
focus_handle,
@@ -1233,7 +1179,6 @@ impl ExceptionBreakpoint {
index: ix,
}),
)
- .toggle_state(is_selected)
}
}
#[derive(Clone, Debug)]
@@ -1335,6 +1280,7 @@ impl BreakpointEntry {
}
}
}
+
bitflags::bitflags! {
#[derive(Clone, Copy)]
pub struct SupportedBreakpointProperties: u32 {
@@ -1393,6 +1339,7 @@ impl BreakpointOptionsStrip {
fn is_toggled(&self, expected_mode: ActiveBreakpointStripMode) -> bool {
self.is_selected && self.strip_mode == Some(expected_mode)
}
+
fn on_click_callback(
&self,
mode: ActiveBreakpointStripMode,
@@ -1412,7 +1359,8 @@ impl BreakpointOptionsStrip {
.ok();
}
}
- fn add_border(
+
+ fn add_focus_styles(
&self,
kind: ActiveBreakpointStripMode,
available: bool,
@@ -1421,22 +1369,25 @@ impl BreakpointOptionsStrip {
) -> impl Fn(Div) -> Div {
move |this: Div| {
// Avoid layout shifts in case there's no colored border
- let this = this.border_2().rounded_sm();
+ let this = this.border_1().rounded_sm();
+ let color = cx.theme().colors();
+
if self.is_selected && self.strip_mode == Some(kind) {
- let theme = cx.theme().colors();
if self.focus_handle.is_focused(window) {
- this.border_color(theme.border_selected)
+ this.bg(color.editor_background)
+ .border_color(color.border_focused)
} else {
- this.border_color(theme.border_disabled)
+ this.border_color(color.border)
}
} else if !available {
- this.border_color(cx.theme().colors().border_disabled)
+ this.border_color(color.border_transparent)
} else {
this
}
}
}
}
+
impl RenderOnce for BreakpointOptionsStrip {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.breakpoint.id();
@@ -1459,73 +1410,117 @@ impl RenderOnce for BreakpointOptionsStrip {
};
let color_for_toggle = |is_enabled| {
if is_enabled {
- ui::Color::Default
+ Color::Default
} else {
- ui::Color::Muted
+ Color::Muted
}
};
h_flex()
- .gap_1()
+ .gap_px()
+ .mr_3() // Space to avoid overlapping with the scrollbar
.child(
- div().map(self.add_border(ActiveBreakpointStripMode::Log, supports_logs, window, cx))
+ div()
+ .map(self.add_focus_styles(
+ ActiveBreakpointStripMode::Log,
+ supports_logs,
+ window,
+ cx,
+ ))
.child(
IconButton::new(
SharedString::from(format!("{id}-log-toggle")),
- IconName::ScrollText,
+ IconName::Notepad,
)
- .icon_size(IconSize::XSmall)
+ .shape(ui::IconButtonShape::Square)
.style(style_for_toggle(ActiveBreakpointStripMode::Log, has_logs))
+ .icon_size(IconSize::Small)
.icon_color(color_for_toggle(has_logs))
+ .when(has_logs, |this| this.indicator(Indicator::dot().color(Color::Info)))
.disabled(!supports_logs)
.toggle_state(self.is_toggled(ActiveBreakpointStripMode::Log))
- .on_click(self.on_click_callback(ActiveBreakpointStripMode::Log)).tooltip(|window, cx| Tooltip::with_meta("Set Log Message", None, "Set log message to display (instead of stopping) when a breakpoint is hit", window, cx))
+ .on_click(self.on_click_callback(ActiveBreakpointStripMode::Log))
+ .tooltip(|window, cx| {
+ Tooltip::with_meta(
+ "Set Log Message",
+ None,
+ "Set log message to display (instead of stopping) when a breakpoint is hit.",
+ window,
+ cx,
+ )
+ }),
)
.when(!has_logs && !self.is_selected, |this| this.invisible()),
)
.child(
- div().map(self.add_border(
- ActiveBreakpointStripMode::Condition,
- supports_condition,
- window, cx
- ))
+ div()
+ .map(self.add_focus_styles(
+ ActiveBreakpointStripMode::Condition,
+ supports_condition,
+ window,
+ cx,
+ ))
.child(
IconButton::new(
SharedString::from(format!("{id}-condition-toggle")),
IconName::SplitAlt,
)
- .icon_size(IconSize::XSmall)
+ .shape(ui::IconButtonShape::Square)
.style(style_for_toggle(
ActiveBreakpointStripMode::Condition,
- has_condition
+ has_condition,
))
+ .icon_size(IconSize::Small)
.icon_color(color_for_toggle(has_condition))
+ .when(has_condition, |this| this.indicator(Indicator::dot().color(Color::Info)))
.disabled(!supports_condition)
.toggle_state(self.is_toggled(ActiveBreakpointStripMode::Condition))
.on_click(self.on_click_callback(ActiveBreakpointStripMode::Condition))
- .tooltip(|window, cx| Tooltip::with_meta("Set Condition", None, "Set condition to evaluate when a breakpoint is hit. Program execution will stop only when the condition is met", window, cx))
+ .tooltip(|window, cx| {
+ Tooltip::with_meta(
+ "Set Condition",
+ None,
+ "Set condition to evaluate when a breakpoint is hit. Program execution will stop only when the condition is met.",
+ window,
+ cx,
+ )
+ }),
)
.when(!has_condition && !self.is_selected, |this| this.invisible()),
)
.child(
- div().map(self.add_border(
- ActiveBreakpointStripMode::HitCondition,
- supports_hit_condition,window, cx
- ))
+ div()
+ .map(self.add_focus_styles(
+ ActiveBreakpointStripMode::HitCondition,
+ supports_hit_condition,
+ window,
+ cx,
+ ))
.child(
IconButton::new(
SharedString::from(format!("{id}-hit-condition-toggle")),
IconName::ArrowDown10,
)
- .icon_size(IconSize::XSmall)
.style(style_for_toggle(
ActiveBreakpointStripMode::HitCondition,
has_hit_condition,
))
+ .shape(ui::IconButtonShape::Square)
+ .icon_size(IconSize::Small)
.icon_color(color_for_toggle(has_hit_condition))
+ .when(has_hit_condition, |this| this.indicator(Indicator::dot().color(Color::Info)))
.disabled(!supports_hit_condition)
.toggle_state(self.is_toggled(ActiveBreakpointStripMode::HitCondition))
- .on_click(self.on_click_callback(ActiveBreakpointStripMode::HitCondition)).tooltip(|window, cx| Tooltip::with_meta("Set Hit Condition", None, "Set expression that controls how many hits of the breakpoint are ignored.", window, cx))
+ .on_click(self.on_click_callback(ActiveBreakpointStripMode::HitCondition))
+ .tooltip(|window, cx| {
+ Tooltip::with_meta(
+ "Set Hit Condition",
+ None,
+ "Set expression that controls how many hits of the breakpoint are ignored.",
+ window,
+ cx,
+ )
+ }),
)
.when(!has_hit_condition && !self.is_selected, |this| {
this.invisible()
@@ -352,7 +352,7 @@ impl Console {
.child(
div()
.px_1()
- .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
+ .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
),
)
.when(
@@ -365,9 +365,9 @@ impl Console {
Some(ContextMenu::build(window, cx, |context_menu, _, _| {
context_menu
.when_some(keybinding_target.clone(), |el, keybinding_target| {
- el.context(keybinding_target.clone())
+ el.context(keybinding_target)
})
- .action("Watch expression", WatchExpression.boxed_clone())
+ .action("Watch Expression", WatchExpression.boxed_clone())
}))
})
},
@@ -452,18 +452,22 @@ impl Render for Console {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let query_focus_handle = self.query_bar.focus_handle(cx);
self.update_output(window, cx);
+
v_flex()
.track_focus(&self.focus_handle)
.key_context("DebugConsole")
.on_action(cx.listener(Self::evaluate))
.on_action(cx.listener(Self::watch_expression))
.size_full()
+ .border_2()
+ .bg(cx.theme().colors().editor_background)
.child(self.render_console(cx))
.when(self.is_running(cx), |this| {
this.child(Divider::horizontal()).child(
h_flex()
.on_action(cx.listener(Self::previous_query))
.on_action(cx.listener(Self::next_query))
+ .p_1()
.gap_1()
.bg(cx.theme().colors().editor_background)
.child(self.render_query_bar(cx))
@@ -474,6 +478,9 @@ impl Render for Console {
.on_click(move |_, window, cx| {
window.dispatch_action(Box::new(Confirm), cx)
})
+ .layer(ui::ElevationIndex::ModalSurface)
+ .size(ui::ButtonSize::Compact)
+ .child(Label::new("Evaluate"))
.tooltip({
let query_focus_handle = query_focus_handle.clone();
@@ -486,10 +493,7 @@ impl Render for Console {
cx,
)
}
- })
- .layer(ui::ElevationIndex::ModalSurface)
- .size(ui::ButtonSize::Compact)
- .child(Label::new("Evaluate")),
+ }),
self.render_submit_menu(
ElementId::Name("split-button-right-confirm-button".into()),
Some(query_focus_handle.clone()),
@@ -499,7 +503,6 @@ impl Render for Console {
)),
)
})
- .border_2()
}
}
@@ -608,17 +611,16 @@ impl ConsoleQueryBarCompletionProvider {
for variable in console.variable_list.update(cx, |variable_list, cx| {
variable_list.completion_variables(cx)
}) {
- if let Some(evaluate_name) = &variable.evaluate_name {
- if variables
+ if let Some(evaluate_name) = &variable.evaluate_name
+ && variables
.insert(evaluate_name.clone(), variable.value.clone())
.is_none()
- {
- string_matches.push(StringMatchCandidate {
- id: 0,
- string: evaluate_name.clone(),
- char_bag: evaluate_name.chars().collect(),
- });
- }
+ {
+ string_matches.push(StringMatchCandidate {
+ id: 0,
+ string: evaluate_name.clone(),
+ char_bag: evaluate_name.chars().collect(),
+ });
}
if variables
@@ -694,7 +696,7 @@ impl ConsoleQueryBarCompletionProvider {
new_bytes: &[u8],
snapshot: &TextBufferSnapshot,
) -> Range<Anchor> {
- let buffer_offset = buffer_position.to_offset(&snapshot);
+ let buffer_offset = buffer_position.to_offset(snapshot);
let buffer_bytes = &buffer_text.as_bytes()[0..buffer_offset];
let mut prefix_len = 0;
@@ -974,7 +976,7 @@ mod tests {
&cx.buffer_text(),
snapshot.anchor_before(buffer_position),
replacement.as_bytes(),
- &snapshot,
+ snapshot,
);
cx.update_editor(|editor, _, cx| {
@@ -13,22 +13,8 @@ pub(crate) struct LoadedSourceList {
impl LoadedSourceList {
pub fn new(session: Entity<Session>, cx: &mut Context<Self>) -> Self {
- let weak_entity = cx.weak_entity();
let focus_handle = cx.focus_handle();
-
- let list = ListState::new(
- 0,
- gpui::ListAlignment::Top,
- px(1000.),
- move |ix, _window, cx| {
- weak_entity
- .upgrade()
- .map(|loaded_sources| {
- loaded_sources.update(cx, |this, cx| this.render_entry(ix, cx))
- })
- .unwrap_or(div().into_any())
- },
- );
+ let list = ListState::new(0, gpui::ListAlignment::Top, px(1000.));
let _subscription = cx.subscribe(&session, |this, _, event, cx| match event {
SessionEvent::Stopped(_) | SessionEvent::LoadedSources => {
@@ -71,7 +57,7 @@ impl LoadedSourceList {
h_flex()
.text_ui_xs(cx)
.text_color(cx.theme().colors().text_muted)
- .when_some(source.path.clone(), |this, path| this.child(path)),
+ .when_some(source.path, |this, path| this.child(path)),
)
.into_any()
}
@@ -98,6 +84,12 @@ impl Render for LoadedSourceList {
.track_focus(&self.focus_handle)
.size_full()
.p_1()
- .child(list(self.list.clone()).size_full())
+ .child(
+ list(
+ self.list.clone(),
+ cx.processor(|this, ix, _window, cx| this.render_entry(ix, cx)),
+ )
+ .size_full(),
+ )
}
}
@@ -8,22 +8,19 @@ use std::{
use editor::{Editor, EditorElement, EditorStyle};
use gpui::{
- Action, AppContext, DismissEvent, Empty, Entity, FocusHandle, Focusable, MouseButton,
- MouseMoveEvent, Point, ScrollStrategy, ScrollWheelEvent, Stateful, Subscription, Task,
- TextStyle, UniformList, UniformListScrollHandle, WeakEntity, actions, anchored, bounds,
- deferred, point, size, uniform_list,
+ Action, AppContext, DismissEvent, DragMoveEvent, Empty, Entity, FocusHandle, Focusable,
+ MouseButton, Point, ScrollStrategy, ScrollWheelEvent, Stateful, Subscription, Task, TextStyle,
+ UniformList, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point,
+ uniform_list,
};
use notifications::status_toast::{StatusToast, ToastIcon};
use project::debugger::{MemoryCell, dap_command::DataBreakpointContext, session::Session};
use settings::Settings;
use theme::ThemeSettings;
use ui::{
- ActiveTheme, AnyElement, App, Color, Context, ContextMenu, Div, Divider, DropdownMenu, Element,
- FluentBuilder, Icon, IconName, InteractiveElement, IntoElement, Label, LabelCommon,
- ParentElement, Pixels, PopoverMenuHandle, Render, Scrollbar, ScrollbarState, SharedString,
- StatefulInteractiveElement, Styled, TextSize, Tooltip, Window, div, h_flex, px, v_flex,
+ ContextMenu, Divider, DropdownMenu, FluentBuilder, IntoElement, PopoverMenuHandle, Render,
+ Scrollbar, ScrollbarState, StatefulInteractiveElement, Tooltip, prelude::*,
};
-use util::ResultExt;
use workspace::Workspace;
use crate::{ToggleDataBreakpoint, session::running::stack_frame_list::StackFrameList};
@@ -34,9 +31,7 @@ pub(crate) struct MemoryView {
workspace: WeakEntity<Workspace>,
scroll_handle: UniformListScrollHandle,
scroll_state: ScrollbarState,
- show_scrollbar: bool,
stack_frame_list: WeakEntity<StackFrameList>,
- hide_scrollbar_task: Option<Task<()>>,
focus_handle: FocusHandle,
view_state: ViewState,
query_editor: Entity<Editor>,
@@ -126,6 +121,8 @@ impl ViewState {
}
}
+struct ScrollbarDragging;
+
static HEX_BYTES_MEMOIZED: LazyLock<[SharedString; 256]> =
LazyLock::new(|| std::array::from_fn(|byte| SharedString::from(format!("{byte:02X}"))));
static UNKNOWN_BYTE: SharedString = SharedString::new_static("??");
@@ -148,8 +145,6 @@ impl MemoryView {
scroll_state,
scroll_handle,
stack_frame_list,
- show_scrollbar: false,
- hide_scrollbar_task: None,
focus_handle: cx.focus_handle(),
view_state,
query_editor,
@@ -159,60 +154,49 @@ impl MemoryView {
open_context_menu: None,
};
this.change_query_bar_mode(false, window, cx);
+ cx.on_focus_out(&this.focus_handle, window, |this, _, window, cx| {
+ this.change_query_bar_mode(false, window, cx);
+ cx.notify();
+ })
+ .detach();
this
}
- fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
- self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| {
- cx.background_executor()
- .timer(SCROLLBAR_SHOW_INTERVAL)
- .await;
- panel
- .update(cx, |panel, cx| {
- panel.show_scrollbar = false;
- cx.notify();
- })
- .log_err();
- }))
- }
- fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
- if !(self.show_scrollbar || self.scroll_state.is_dragging()) {
- return None;
- }
- Some(
- div()
- .occlude()
- .id("memory-view-vertical-scrollbar")
- .on_mouse_move(cx.listener(|this, evt, _, cx| {
- this.handle_drag(evt);
- cx.notify();
+ fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
+ div()
+ .occlude()
+ .id("memory-view-vertical-scrollbar")
+ .on_drag_move(cx.listener(|this, evt, _, cx| {
+ let did_handle = this.handle_scroll_drag(evt);
+ cx.notify();
+ if did_handle {
cx.stop_propagation()
- }))
- .on_hover(|_, _, cx| {
- cx.stop_propagation();
- })
- .on_any_mouse_down(|_, _, cx| {
+ }
+ }))
+ .on_drag(ScrollbarDragging, |_, _, _, cx| cx.new(|_| Empty))
+ .on_hover(|_, _, cx| {
+ cx.stop_propagation();
+ })
+ .on_any_mouse_down(|_, _, cx| {
+ cx.stop_propagation();
+ })
+ .on_mouse_up(
+ MouseButton::Left,
+ cx.listener(|_, _, _, cx| {
cx.stop_propagation();
- })
- .on_mouse_up(
- MouseButton::Left,
- cx.listener(|_, _, _, cx| {
- cx.stop_propagation();
- }),
- )
- .on_scroll_wheel(cx.listener(|_, _, _, cx| {
- cx.notify();
- }))
- .h_full()
- .absolute()
- .right_1()
- .top_1()
- .bottom_0()
- .w(px(12.))
- .cursor_default()
- .children(Scrollbar::vertical(self.scroll_state.clone())),
- )
+ }),
+ )
+ .on_scroll_wheel(cx.listener(|_, _, _, cx| {
+ cx.notify();
+ }))
+ .h_full()
+ .absolute()
+ .right_1()
+ .top_1()
+ .bottom_0()
+ .w(px(12.))
+ .cursor_default()
+ .children(Scrollbar::vertical(self.scroll_state.clone()).map(|s| s.auto_hide(cx)))
}
fn render_memory(&self, cx: &mut Context<Self>) -> UniformList {
@@ -278,7 +262,7 @@ impl MemoryView {
cx: &mut Context<Self>,
) {
use parse_int::parse;
- let Ok(as_address) = parse::<u64>(&memory_reference) else {
+ let Ok(as_address) = parse::<u64>(memory_reference) else {
return;
};
let access_size = evaluate_name
@@ -302,16 +286,12 @@ impl MemoryView {
.detach();
}
- fn handle_drag(&mut self, evt: &MouseMoveEvent) {
- if !evt.dragging() {
- return;
- }
- if !self.scroll_state.is_dragging()
- && !self
- .view_state
- .selection
- .as_ref()
- .is_some_and(|selection| selection.is_dragging())
+ fn handle_memory_drag(&mut self, evt: &DragMoveEvent<Drag>) {
+ if !self
+ .view_state
+ .selection
+ .as_ref()
+ .is_some_and(|selection| selection.is_dragging())
{
return;
}
@@ -319,22 +299,31 @@ impl MemoryView {
debug_assert!(row_count > 1);
let scroll_handle = self.scroll_state.scroll_handle();
let viewport = scroll_handle.viewport();
- let (top_area, bottom_area) = {
- let size = size(viewport.size.width, viewport.size.height / 10.);
- (
- bounds(viewport.origin, size),
- bounds(
- point(viewport.origin.x, viewport.origin.y + size.height * 2.),
- size,
- ),
- )
- };
- if bottom_area.contains(&evt.position) {
- //ix == row_count - 1 {
+ if viewport.bottom() < evt.event.position.y {
+ self.view_state.schedule_scroll_down();
+ } else if viewport.top() > evt.event.position.y {
+ self.view_state.schedule_scroll_up();
+ }
+ }
+
+ fn handle_scroll_drag(&mut self, evt: &DragMoveEvent<ScrollbarDragging>) -> bool {
+ if !self.scroll_state.is_dragging() {
+ return false;
+ }
+ let row_count = self.view_state.row_count();
+ debug_assert!(row_count > 1);
+ let scroll_handle = self.scroll_state.scroll_handle();
+ let viewport = scroll_handle.viewport();
+
+ if viewport.bottom() < evt.event.position.y {
self.view_state.schedule_scroll_down();
- } else if top_area.contains(&evt.position) {
+ true
+ } else if viewport.top() > evt.event.position.y {
self.view_state.schedule_scroll_up();
+ true
+ } else {
+ false
}
}
@@ -472,7 +461,7 @@ impl MemoryView {
let data_breakpoint_info = this.data_breakpoint_info(context.clone(), None, cx);
cx.spawn(async move |this, cx| {
if let Some(info) = data_breakpoint_info.await {
- let Some(data_id) = info.data_id.clone() else {
+ let Some(data_id) = info.data_id else {
return;
};
_ = this.update(cx, |this, cx| {
@@ -583,16 +572,22 @@ impl MemoryView {
else {
return;
};
+ let expr = format!("?${{{expr}}}");
let reference = self.session.update(cx, |this, cx| {
this.memory_reference_of_expr(selected_frame, expr, cx)
});
cx.spawn(async move |this, cx| {
- if let Some(reference) = reference.await {
+ if let Some((reference, typ)) = reference.await {
_ = this.update(cx, |this, cx| {
- let Ok(address) = parse_int::parse::<u64>(&reference) else {
- return;
+ let sizeof_expr = if typ.as_ref().is_some_and(|t| {
+ t.chars()
+ .all(|c| c.is_whitespace() || c.is_alphabetic() || c == '*')
+ }) {
+ typ.as_deref()
+ } else {
+ None
};
- this.jump_to_address(address, cx);
+ this.go_to_memory_reference(&reference, sizeof_expr, selected_frame, cx);
});
}
})
@@ -667,7 +662,7 @@ impl MemoryView {
menu = menu.action_disabled_when(
*memory_unreadable,
"Set Data Breakpoint",
- ToggleDataBreakpoint.boxed_clone(),
+ ToggleDataBreakpoint { access_type: None }.boxed_clone(),
);
}
menu.context(self.focus_handle.clone())
@@ -763,7 +758,7 @@ fn render_single_memory_view_line(
this.when(selection.contains(base_address + cell_ix as u64), |this| {
let weak = weak.clone();
- this.bg(Color::Accent.color(cx)).when(
+ this.bg(Color::Selected.color(cx).opacity(0.2)).when(
!selection.is_dragging(),
|this| {
let selection = selection.drag().memory_range();
@@ -860,7 +855,7 @@ fn render_single_memory_view_line(
.px_0p5()
.when_some(view_state.selection.as_ref(), |this, selection| {
this.when(selection.contains(base_address + ix as u64), |this| {
- this.bg(Color::Accent.color(cx))
+ this.bg(Color::Selected.color(cx).opacity(0.2))
})
})
.child(
@@ -899,15 +894,6 @@ impl Render for MemoryView {
.on_action(cx.listener(Self::page_up))
.size_full()
.track_focus(&self.focus_handle)
- .on_hover(cx.listener(|this, hovered, window, cx| {
- if *hovered {
- this.show_scrollbar = true;
- this.hide_scrollbar_task.take();
- cx.notify();
- } else if !this.focus_handle.contains_focused(window, cx) {
- this.hide_scrollbar(window, cx);
- }
- }))
.child(
h_flex()
.w_full()
@@ -944,8 +930,8 @@ impl Render for MemoryView {
.child(
v_flex()
.size_full()
- .on_mouse_move(cx.listener(|this, evt: &MouseMoveEvent, _, _| {
- this.handle_drag(evt);
+ .on_drag_move(cx.listener(|this, evt, _, _| {
+ this.handle_memory_drag(evt);
}))
.child(self.render_memory(cx).size_full())
.children(self.open_context_menu.as_ref().map(|(menu, position, _)| {
@@ -957,7 +943,7 @@ impl Render for MemoryView {
)
.with_priority(1)
}))
- .children(self.render_vertical_scrollbar(cx)),
+ .child(self.render_vertical_scrollbar(cx)),
)
}
}
@@ -157,7 +157,7 @@ impl ModuleList {
h_flex()
.text_ui_xs(cx)
.text_color(cx.theme().colors().text_muted)
- .when_some(module.path.clone(), |this, path| this.child(path)),
+ .when_some(module.path, |this, path| this.child(path)),
)
.into_any()
}
@@ -223,7 +223,7 @@ impl ModuleList {
fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
let ix = match self.selected_ix {
- _ if self.entries.len() == 0 => None,
+ _ if self.entries.is_empty() => None,
None => Some(0),
Some(ix) => {
if ix == self.entries.len() - 1 {
@@ -243,7 +243,7 @@ impl ModuleList {
cx: &mut Context<Self>,
) {
let ix = match self.selected_ix {
- _ if self.entries.len() == 0 => None,
+ _ if self.entries.is_empty() => None,
None => Some(self.entries.len() - 1),
Some(ix) => {
if ix == 0 {
@@ -262,7 +262,7 @@ impl ModuleList {
_window: &mut Window,
cx: &mut Context<Self>,
) {
- let ix = if self.entries.len() > 0 {
+ let ix = if !self.entries.is_empty() {
Some(0)
} else {
None
@@ -271,7 +271,7 @@ impl ModuleList {
}
fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
- let ix = if self.entries.len() > 0 {
+ let ix = if !self.entries.is_empty() {
Some(self.entries.len() - 1)
} else {
None
@@ -70,13 +70,7 @@ impl StackFrameList {
_ => {}
});
- let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.), {
- let this = cx.weak_entity();
- move |ix, _window, cx| {
- this.update(cx, |this, cx| this.render_entry(ix, cx))
- .unwrap_or(div().into_any())
- }
- });
+ let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.));
let scrollbar_state = ScrollbarState::new(list_state.clone());
let mut this = Self {
@@ -132,7 +126,7 @@ impl StackFrameList {
self.stack_frames(cx)
.unwrap_or_default()
.into_iter()
- .map(|stack_frame| stack_frame.dap.clone())
+ .map(|stack_frame| stack_frame.dap)
.collect()
}
@@ -230,7 +224,7 @@ impl StackFrameList {
let collapsed_entries = std::mem::take(&mut collapsed_entries);
if !collapsed_entries.is_empty() {
- entries.push(StackFrameEntry::Collapsed(collapsed_entries.clone()));
+ entries.push(StackFrameEntry::Collapsed(collapsed_entries));
}
self.entries = entries;
@@ -424,7 +418,7 @@ impl StackFrameList {
let source = stack_frame.source.clone();
let is_selected_frame = Some(ix) == self.selected_ix;
- let path = source.clone().and_then(|s| s.path.or(s.name));
+ let path = source.and_then(|s| s.path.or(s.name));
let formatted_path = path.map(|path| format!("{}:{}", path, stack_frame.line,));
let formatted_path = formatted_path.map(|path| {
Label::new(path)
@@ -499,7 +493,7 @@ impl StackFrameList {
.child(
IconButton::new(
("restart-stack-frame", stack_frame.id),
- IconName::DebugRestart,
+ IconName::RotateCcw,
)
.icon_size(IconSize::Small)
.on_click(cx.listener({
@@ -627,7 +621,7 @@ impl StackFrameList {
fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
let ix = match self.selected_ix {
- _ if self.entries.len() == 0 => None,
+ _ if self.entries.is_empty() => None,
None => Some(0),
Some(ix) => {
if ix == self.entries.len() - 1 {
@@ -647,7 +641,7 @@ impl StackFrameList {
cx: &mut Context<Self>,
) {
let ix = match self.selected_ix {
- _ if self.entries.len() == 0 => None,
+ _ if self.entries.is_empty() => None,
None => Some(self.entries.len() - 1),
Some(ix) => {
if ix == 0 {
@@ -666,7 +660,7 @@ impl StackFrameList {
_window: &mut Window,
cx: &mut Context<Self>,
) {
- let ix = if self.entries.len() > 0 {
+ let ix = if !self.entries.is_empty() {
Some(0)
} else {
None
@@ -675,7 +669,7 @@ impl StackFrameList {
}
fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
- let ix = if self.entries.len() > 0 {
+ let ix = if !self.entries.is_empty() {
Some(self.entries.len() - 1)
} else {
None
@@ -708,11 +702,14 @@ impl StackFrameList {
self.activate_selected_entry(window, cx);
}
- fn render_list(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
- div()
- .p_1()
- .size_full()
- .child(list(self.list_state.clone()).size_full())
+ fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ div().p_1().size_full().child(
+ list(
+ self.list_state.clone(),
+ cx.processor(|this, ix, _window, cx| this.render_entry(ix, cx)),
+ )
+ .size_full(),
+ )
}
}
@@ -272,7 +272,7 @@ impl VariableList {
let mut entries = vec![];
let scopes: Vec<_> = self.session.update(cx, |session, cx| {
- session.scopes(stack_frame_id, cx).iter().cloned().collect()
+ session.scopes(stack_frame_id, cx).to_vec()
});
let mut contains_local_scope = false;
@@ -291,7 +291,7 @@ impl VariableList {
}
self.session.update(cx, |session, cx| {
- session.variables(scope.variables_reference, cx).len() > 0
+ !session.variables(scope.variables_reference, cx).is_empty()
})
})
.map(|scope| {
@@ -313,7 +313,7 @@ impl VariableList {
watcher.variables_reference,
watcher.variables_reference,
EntryPath::for_watcher(watcher.expression.clone()),
- DapEntry::Watcher(watcher.clone()),
+ DapEntry::Watcher(watcher),
)
})
.collect::<Vec<_>>(),
@@ -670,9 +670,9 @@ impl VariableList {
let focus_handle = self.focus_handle.clone();
cx.spawn_in(window, async move |this, cx| {
let can_toggle_data_breakpoint = if let Some(task) = can_toggle_data_breakpoint {
- task.await.is_some()
+ task.await
} else {
- true
+ None
};
cx.update(|window, cx| {
let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
@@ -686,11 +686,35 @@ impl VariableList {
menu.action("Go To Memory", GoToMemory.boxed_clone())
})
.action("Watch Variable", AddWatch.boxed_clone())
- .when(can_toggle_data_breakpoint, |menu| {
- menu.action(
- "Toggle Data Breakpoint",
- crate::ToggleDataBreakpoint.boxed_clone(),
- )
+ .when_some(can_toggle_data_breakpoint, |mut menu, data_info| {
+ menu = menu.separator();
+ if let Some(access_types) = data_info.access_types {
+ for access in access_types {
+ menu = menu.action(
+ format!(
+ "Toggle {} Data Breakpoint",
+ match access {
+ dap::DataBreakpointAccessType::Read => "Read",
+ dap::DataBreakpointAccessType::Write => "Write",
+ dap::DataBreakpointAccessType::ReadWrite =>
+ "Read/Write",
+ }
+ ),
+ crate::ToggleDataBreakpoint {
+ access_type: Some(access),
+ }
+ .boxed_clone(),
+ );
+ }
+
+ menu
+ } else {
+ menu.action(
+ "Toggle Data Breakpoint",
+ crate::ToggleDataBreakpoint { access_type: None }
+ .boxed_clone(),
+ )
+ }
})
})
.when(entry.as_watcher().is_some(), |menu| {
@@ -729,7 +753,7 @@ impl VariableList {
fn toggle_data_breakpoint(
&mut self,
- _: &crate::ToggleDataBreakpoint,
+ data_info: &crate::ToggleDataBreakpoint,
_window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -759,17 +783,34 @@ impl VariableList {
});
let session = self.session.downgrade();
+ let access_type = data_info.access_type;
cx.spawn(async move |_, cx| {
- let Some(data_id) = data_breakpoint.await.and_then(|info| info.data_id) else {
+ let Some((data_id, access_types)) = data_breakpoint
+ .await
+ .and_then(|info| Some((info.data_id?, info.access_types)))
+ else {
return;
};
+
+ // Because user's can manually add this action to the keymap
+ // we check if access type is supported
+ let access_type = match access_types {
+ None => None,
+ Some(access_types) => {
+ if access_type.is_some_and(|access_type| access_types.contains(&access_type)) {
+ access_type
+ } else {
+ None
+ }
+ }
+ };
_ = session.update(cx, |session, cx| {
session.create_data_breakpoint(
context,
data_id.clone(),
dap::DataBreakpoint {
data_id,
- access_type: None,
+ access_type,
condition: None,
hit_condition: None,
},
@@ -906,7 +947,7 @@ impl VariableList {
#[track_caller]
#[cfg(test)]
pub(crate) fn assert_visual_entries(&self, expected: Vec<&str>) {
- const INDENT: &'static str = " ";
+ const INDENT: &str = " ";
let entries = &self.entries;
let mut visual_entries = Vec::with_capacity(entries.len());
@@ -956,7 +997,7 @@ impl VariableList {
DapEntry::Watcher { .. } => continue,
DapEntry::Variable(dap) => scopes[idx].1.push(dap.clone()),
DapEntry::Scope(scope) => {
- if scopes.len() > 0 {
+ if !scopes.is_empty() {
idx += 1;
}
@@ -1066,7 +1107,7 @@ impl VariableList {
let variable_value = value.clone();
this.on_click(cx.listener(
move |this, click: &ClickEvent, window, cx| {
- if click.down.click_count < 2 {
+ if click.click_count() < 2 {
return;
}
let editor = Self::create_variable_editor(
@@ -1248,7 +1289,7 @@ impl VariableList {
}),
)
.child(self.render_variable_value(
- &entry,
+ entry,
&variable_color,
watcher.value.to_string(),
cx,
@@ -1260,8 +1301,6 @@ impl VariableList {
IconName::Close,
)
.on_click({
- let weak = weak.clone();
- let path = path.clone();
move |_, window, cx| {
weak.update(cx, |variable_list, cx| {
variable_list.selection = Some(path.clone());
@@ -1429,7 +1468,6 @@ impl VariableList {
}))
})
.on_secondary_mouse_down(cx.listener({
- let path = path.clone();
let entry = variable.clone();
move |this, event: &MouseDownEvent, window, cx| {
this.selection = Some(path.clone());
@@ -1453,7 +1491,7 @@ impl VariableList {
}),
)
.child(self.render_variable_value(
- &variable,
+ variable,
&variable_color,
dap.value.clone(),
cx,
@@ -139,7 +139,7 @@ async fn test_show_attach_modal_and_select_process(
workspace
.update(cx, |_, window, cx| {
let names =
- attach_modal.update(cx, |modal, cx| attach_modal::_process_names(&modal, cx));
+ attach_modal.update(cx, |modal, cx| attach_modal::_process_names(modal, cx));
// Initially all processes are visible.
assert_eq!(3, names.len());
attach_modal.update(cx, |this, cx| {
@@ -154,7 +154,7 @@ async fn test_show_attach_modal_and_select_process(
workspace
.update(cx, |_, _, cx| {
let names =
- attach_modal.update(cx, |modal, cx| attach_modal::_process_names(&modal, cx));
+ attach_modal.update(cx, |modal, cx| attach_modal::_process_names(modal, cx));
// Initially all processes are visible.
assert_eq!(2, names.len());
})
@@ -918,7 +918,7 @@ async fn test_debug_panel_item_thread_status_reset_on_failure(
.unwrap();
let client = session.update(cx, |session, _| session.adapter_client().unwrap());
- const THREAD_ID_NUM: u64 = 1;
+ const THREAD_ID_NUM: i64 = 1;
client.on_request::<dap::requests::Threads, _>(move |_, _| {
Ok(dap::ThreadsResponse {
@@ -1330,7 +1330,6 @@ async fn test_unsetting_breakpoints_on_clear_breakpoint_action(
let called_set_breakpoints = Arc::new(AtomicBool::new(false));
client.on_request::<SetBreakpoints, _>({
- let called_set_breakpoints = called_set_breakpoints.clone();
move |_, args| {
assert!(
args.breakpoints.is_none_or(|bps| bps.is_empty()),
@@ -1445,7 +1444,6 @@ async fn test_we_send_arguments_from_user_config(
let launch_handler_called = Arc::new(AtomicBool::new(false));
start_debug_session_with(&workspace, cx, debug_definition.clone(), {
- let debug_definition = debug_definition.clone();
let launch_handler_called = launch_handler_called.clone();
move |client| {
@@ -1783,9 +1781,8 @@ async fn test_debug_adapters_shutdown_on_app_quit(
let disconnect_request_received = Arc::new(AtomicBool::new(false));
let disconnect_clone = disconnect_request_received.clone();
- let disconnect_clone_for_handler = disconnect_clone.clone();
client.on_request::<Disconnect, _>(move |_, _| {
- disconnect_clone_for_handler.store(true, Ordering::SeqCst);
+ disconnect_clone.store(true, Ordering::SeqCst);
Ok(())
});
@@ -106,9 +106,7 @@ async fn test_debug_session_substitutes_variables_and_relativizes_paths(
);
let expected_other_field = if input_path.contains("$ZED_WORKTREE_ROOT") {
- input_path
- .replace("$ZED_WORKTREE_ROOT", &path!("/test/worktree/path"))
- .to_owned()
+ input_path.replace("$ZED_WORKTREE_ROOT", path!("/test/worktree/path"))
} else {
input_path.to_string()
};
@@ -298,7 +296,7 @@ async fn test_dap_adapter_config_conversion_and_validation(cx: &mut TestAppConte
let adapter_names = cx.update(|cx| {
let registry = DapRegistry::global(cx);
- registry.enumerate_adapters()
+ registry.enumerate_adapters::<Vec<_>>()
});
let zed_config = ZedDebugConfig {
@@ -1445,11 +1445,8 @@ async fn test_variable_list_only_sends_requests_when_rendering(
cx.run_until_parked();
- let running_state = active_debug_session_panel(workspace, cx).update_in(cx, |item, _, _| {
- let state = item.running_state().clone();
-
- state
- });
+ let running_state = active_debug_session_panel(workspace, cx)
+ .update_in(cx, |item, _, _| item.running_state().clone());
client
.fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
@@ -18,7 +18,6 @@ collections.workspace = true
component.workspace = true
ctor.workspace = true
editor.workspace = true
-futures.workspace = true
gpui.workspace = true
indoc.workspace = true
language.workspace = true
@@ -46,7 +46,7 @@ impl DiagnosticRenderer {
markdown.push_str(" (");
}
if let Some(source) = diagnostic.source.as_ref() {
- markdown.push_str(&Markdown::escape(&source));
+ markdown.push_str(&Markdown::escape(source));
}
if diagnostic.source.is_some() && diagnostic.code.is_some() {
markdown.push(' ');
@@ -287,15 +287,13 @@ impl DiagnosticBlock {
}
}
}
- } else {
- if let Some(diagnostic) = editor
- .snapshot(window, cx)
- .buffer_snapshot
- .diagnostic_group(buffer_id, group_id)
- .nth(ix)
- {
- Self::jump_to(editor, diagnostic.range, window, cx)
- }
+ } else if let Some(diagnostic) = editor
+ .snapshot(window, cx)
+ .buffer_snapshot
+ .diagnostic_group(buffer_id, group_id)
+ .nth(ix)
+ {
+ Self::jump_to(editor, diagnostic.range, window, cx)
};
}
@@ -306,7 +304,7 @@ impl DiagnosticBlock {
cx: &mut Context<Editor>,
) {
let snapshot = &editor.buffer().read(cx).snapshot(cx);
- let range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot);
+ let range = range.start.to_offset(snapshot)..range.end.to_offset(snapshot);
editor.unfold_ranges(&[range.start..range.end], true, false, cx);
editor.change_selections(Default::default(), window, cx, |s| {
@@ -13,7 +13,6 @@ use editor::{
DEFAULT_MULTIBUFFER_CONTEXT, Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey,
display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
};
-use futures::future::join_all;
use gpui::{
AnyElement, AnyView, App, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable,
Global, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled,
@@ -24,7 +23,6 @@ use language::{
};
use project::{
DiagnosticSummary, Project, ProjectPath,
- lsp_store::rust_analyzer_ext::{cancel_flycheck, run_flycheck},
project_settings::{DiagnosticSeverity, ProjectSettings},
};
use settings::Settings;
@@ -79,16 +77,10 @@ pub(crate) struct ProjectDiagnosticsEditor {
paths_to_update: BTreeSet<ProjectPath>,
include_warnings: bool,
update_excerpts_task: Option<Task<Result<()>>>,
- cargo_diagnostics_fetch: CargoDiagnosticsFetchState,
+ diagnostic_summary_update: Task<()>,
_subscription: Subscription,
}
-struct CargoDiagnosticsFetchState {
- fetch_task: Option<Task<()>>,
- cancel_task: Option<Task<()>>,
- diagnostic_sources: Arc<Vec<ProjectPath>>,
-}
-
impl EventEmitter<EditorEvent> for ProjectDiagnosticsEditor {}
const DIAGNOSTICS_UPDATE_DELAY: Duration = Duration::from_millis(50);
@@ -176,16 +168,25 @@ impl ProjectDiagnosticsEditor {
}
project::Event::DiagnosticsUpdated {
language_server_id,
- path,
+ paths,
} => {
- this.paths_to_update.insert(path.clone());
- this.summary = project.read(cx).diagnostic_summary(false, cx);
+ this.paths_to_update.extend(paths.clone());
+ let project = project.clone();
+ this.diagnostic_summary_update = cx.spawn(async move |this, cx| {
+ cx.background_executor()
+ .timer(Duration::from_millis(30))
+ .await;
+ this.update(cx, |this, cx| {
+ this.summary = project.read(cx).diagnostic_summary(false, cx);
+ })
+ .log_err();
+ });
cx.emit(EditorEvent::TitleChanged);
if this.editor.focus_handle(cx).contains_focused(window, cx) || this.focus_handle.contains_focused(window, cx) {
- log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. recording change");
+ log::debug!("diagnostics updated for server {language_server_id}, paths {paths:?}. recording change");
} else {
- log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. updating excerpts");
+ log::debug!("diagnostics updated for server {language_server_id}, paths {paths:?}. updating excerpts");
this.update_stale_excerpts(window, cx);
}
}
@@ -250,11 +251,7 @@ impl ProjectDiagnosticsEditor {
)
});
this.diagnostics.clear();
- this.update_all_diagnostics(false, window, cx);
- })
- .detach();
- cx.observe_release(&cx.entity(), |editor, _, cx| {
- editor.stop_cargo_diagnostics_fetch(cx);
+ this.update_all_excerpts(window, cx);
})
.detach();
@@ -271,14 +268,10 @@ impl ProjectDiagnosticsEditor {
editor,
paths_to_update: Default::default(),
update_excerpts_task: None,
- cargo_diagnostics_fetch: CargoDiagnosticsFetchState {
- fetch_task: None,
- cancel_task: None,
- diagnostic_sources: Arc::new(Vec::new()),
- },
+ diagnostic_summary_update: Task::ready(()),
_subscription: project_event_subscription,
};
- this.update_all_diagnostics(true, window, cx);
+ this.update_all_excerpts(window, cx);
this
}
@@ -362,22 +355,10 @@ impl ProjectDiagnosticsEditor {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let fetch_cargo_diagnostics = ProjectSettings::get_global(cx)
- .diagnostics
- .fetch_cargo_diagnostics();
-
- if fetch_cargo_diagnostics {
- if self.cargo_diagnostics_fetch.fetch_task.is_some() {
- self.stop_cargo_diagnostics_fetch(cx);
- } else {
- self.update_all_diagnostics(false, window, cx);
- }
+ if self.update_excerpts_task.is_some() {
+ self.update_excerpts_task = None;
} else {
- if self.update_excerpts_task.is_some() {
- self.update_excerpts_task = None;
- } else {
- self.update_all_diagnostics(false, window, cx);
- }
+ self.update_all_excerpts(window, cx);
}
cx.notify();
}
@@ -395,73 +376,6 @@ impl ProjectDiagnosticsEditor {
}
}
- fn update_all_diagnostics(
- &mut self,
- first_launch: bool,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- let cargo_diagnostics_sources = self.cargo_diagnostics_sources(cx);
- if cargo_diagnostics_sources.is_empty() {
- self.update_all_excerpts(window, cx);
- } else if first_launch && !self.summary.is_empty() {
- self.update_all_excerpts(window, cx);
- } else {
- self.fetch_cargo_diagnostics(Arc::new(cargo_diagnostics_sources), cx);
- }
- }
-
- fn fetch_cargo_diagnostics(
- &mut self,
- diagnostics_sources: Arc<Vec<ProjectPath>>,
- cx: &mut Context<Self>,
- ) {
- let project = self.project.clone();
- self.cargo_diagnostics_fetch.cancel_task = None;
- self.cargo_diagnostics_fetch.fetch_task = None;
- self.cargo_diagnostics_fetch.diagnostic_sources = diagnostics_sources.clone();
- if self.cargo_diagnostics_fetch.diagnostic_sources.is_empty() {
- return;
- }
-
- self.cargo_diagnostics_fetch.fetch_task = Some(cx.spawn(async move |editor, cx| {
- let mut fetch_tasks = Vec::new();
- for buffer_path in diagnostics_sources.iter().cloned() {
- if cx
- .update(|cx| {
- fetch_tasks.push(run_flycheck(project.clone(), buffer_path, cx));
- })
- .is_err()
- {
- break;
- }
- }
-
- let _ = join_all(fetch_tasks).await;
- editor
- .update(cx, |editor, _| {
- editor.cargo_diagnostics_fetch.fetch_task = None;
- })
- .ok();
- }));
- }
-
- fn stop_cargo_diagnostics_fetch(&mut self, cx: &mut App) {
- self.cargo_diagnostics_fetch.fetch_task = None;
- let mut cancel_gasks = Vec::new();
- for buffer_path in std::mem::take(&mut self.cargo_diagnostics_fetch.diagnostic_sources)
- .iter()
- .cloned()
- {
- cancel_gasks.push(cancel_flycheck(self.project.clone(), buffer_path, cx));
- }
-
- self.cargo_diagnostics_fetch.cancel_task = Some(cx.background_spawn(async move {
- let _ = join_all(cancel_gasks).await;
- log::info!("Finished fetching cargo diagnostics");
- }));
- }
-
/// Enqueue an update of all excerpts. Updates all paths that either
/// currently have diagnostics or are currently present in this view.
fn update_all_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -517,7 +431,7 @@ impl ProjectDiagnosticsEditor {
lsp::DiagnosticSeverity::ERROR
};
- cx.spawn_in(window, async move |this, mut cx| {
+ cx.spawn_in(window, async move |this, cx| {
let diagnostics = buffer_snapshot
.diagnostics_in_range::<_, text::Anchor>(
Point::zero()..buffer_snapshot.max_point(),
@@ -531,7 +445,7 @@ impl ProjectDiagnosticsEditor {
return true;
}
this.diagnostics.insert(buffer_id, diagnostics.clone());
- return false;
+ false
})?;
if unchanged {
return Ok(());
@@ -584,7 +498,7 @@ impl ProjectDiagnosticsEditor {
b.initial_range.clone(),
DEFAULT_MULTIBUFFER_CONTEXT,
buffer_snapshot.clone(),
- &mut cx,
+ cx,
)
.await;
let i = excerpt_ranges
@@ -628,17 +542,15 @@ impl ProjectDiagnosticsEditor {
#[cfg(test)]
let cloned_blocks = blocks.clone();
- if was_empty {
- if let Some(anchor_range) = anchor_ranges.first() {
- let range_to_select = anchor_range.start..anchor_range.start;
- this.editor.update(cx, |editor, cx| {
- editor.change_selections(Default::default(), window, cx, |s| {
- s.select_anchor_ranges([range_to_select]);
- })
- });
- if this.focus_handle.is_focused(window) {
- this.editor.read(cx).focus_handle(cx).focus(window);
- }
+ if was_empty && let Some(anchor_range) = anchor_ranges.first() {
+ let range_to_select = anchor_range.start..anchor_range.start;
+ this.editor.update(cx, |editor, cx| {
+ editor.change_selections(Default::default(), window, cx, |s| {
+ s.select_anchor_ranges([range_to_select]);
+ })
+ });
+ if this.focus_handle.is_focused(window) {
+ this.editor.read(cx).focus_handle(cx).focus(window);
}
}
@@ -688,30 +600,6 @@ impl ProjectDiagnosticsEditor {
})
})
}
-
- pub fn cargo_diagnostics_sources(&self, cx: &App) -> Vec<ProjectPath> {
- let fetch_cargo_diagnostics = ProjectSettings::get_global(cx)
- .diagnostics
- .fetch_cargo_diagnostics();
- if !fetch_cargo_diagnostics {
- return Vec::new();
- }
- self.project
- .read(cx)
- .worktrees(cx)
- .filter_map(|worktree| {
- let _cargo_toml_entry = worktree.read(cx).entry_for_path("Cargo.toml")?;
- let rust_file_entry = worktree.read(cx).entries(false, 0).find(|entry| {
- entry
- .path
- .extension()
- .and_then(|extension| extension.to_str())
- == Some("rs")
- })?;
- self.project.read(cx).path_for_entry(rust_file_entry.id, cx)
- })
- .collect()
- }
}
impl Focusable for ProjectDiagnosticsEditor {
@@ -969,18 +857,16 @@ async fn heuristic_syntactic_expand(
// Remove blank lines from start and end
if let Some(start_row) = (outline_range.start.row..outline_range.end.row)
.find(|row| !snapshot.line_indent_for_row(*row).is_line_blank())
- {
- if let Some(end_row) = (outline_range.start.row..outline_range.end.row + 1)
+ && let Some(end_row) = (outline_range.start.row..outline_range.end.row + 1)
.rev()
.find(|row| !snapshot.line_indent_for_row(*row).is_line_blank())
- {
- let row_count = end_row.saturating_sub(start_row);
- if row_count <= max_row_count {
- return Some(RangeInclusive::new(
- outline_range.start.row,
- outline_range.end.row,
- ));
- }
+ {
+ let row_count = end_row.saturating_sub(start_row);
+ if row_count <= max_row_count {
+ return Some(RangeInclusive::new(
+ outline_range.start.row,
+ outline_range.end.row,
+ ));
}
}
}
@@ -862,7 +862,7 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S
21..=50 => mutated_diagnostics.update_in(cx, |diagnostics, window, cx| {
diagnostics.editor.update(cx, |editor, cx| {
let snapshot = editor.snapshot(window, cx);
- if snapshot.buffer_snapshot.len() > 0 {
+ if !snapshot.buffer_snapshot.is_empty() {
let position = rng.gen_range(0..snapshot.buffer_snapshot.len());
let position = snapshot.buffer_snapshot.clip_offset(position, Bias::Left);
log::info!(
@@ -873,10 +873,10 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S
editor.splice_inlays(
&[],
- vec![Inlay::inline_completion(
+ vec![Inlay::edit_prediction(
post_inc(&mut next_inlay_id),
snapshot.buffer_snapshot.anchor_before(position),
- format!("Test inlay {next_inlay_id}"),
+ Rope::from_iter(["Test inlay ", "next_inlay_id"]),
)],
cx,
);
@@ -971,7 +971,7 @@ async fn active_diagnostics_dismiss_after_invalidation(cx: &mut TestAppContext)
let mut cx = EditorTestContext::new(cx).await;
let lsp_store =
- cx.update_editor(|editor, _, cx| editor.project.as_ref().unwrap().read(cx).lsp_store());
+ cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).lsp_store());
cx.set_state(indoc! {"
ˇfn func(abc def: i32) -> u32 {
@@ -1065,7 +1065,7 @@ async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) {
let mut cx = EditorTestContext::new(cx).await;
let lsp_store =
- cx.update_editor(|editor, _, cx| editor.project.as_ref().unwrap().read(cx).lsp_store());
+ cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).lsp_store());
cx.set_state(indoc! {"
ˇfn func(abc def: i32) -> u32 {
@@ -1239,7 +1239,7 @@ async fn test_diagnostics_with_links(cx: &mut TestAppContext) {
}
"});
let lsp_store =
- cx.update_editor(|editor, _, cx| editor.project.as_ref().unwrap().read(cx).lsp_store());
+ cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).lsp_store());
cx.update(|_, cx| {
lsp_store.update(cx, |lsp_store, cx| {
@@ -1293,7 +1293,7 @@ async fn test_hover_diagnostic_and_info_popovers(cx: &mut gpui::TestAppContext)
fn «test»() { println!(); }
"});
let lsp_store =
- cx.update_editor(|editor, _, cx| editor.project.as_ref().unwrap().read(cx).lsp_store());
+ cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).lsp_store());
cx.update(|_, cx| {
lsp_store.update(cx, |lsp_store, cx| {
lsp_store.update_diagnostics(
@@ -1450,7 +1450,7 @@ async fn go_to_diagnostic_with_severity(cx: &mut TestAppContext) {
let mut cx = EditorTestContext::new(cx).await;
let lsp_store =
- cx.update_editor(|editor, _, cx| editor.project.as_ref().unwrap().read(cx).lsp_store());
+ cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).lsp_store());
cx.set_state(indoc! {"error warning info hiˇnt"});
@@ -9,6 +9,7 @@ use language::Diagnostic;
use project::project_settings::{GoToDiagnosticSeverityFilter, ProjectSettings};
use settings::Settings;
use ui::{Button, ButtonLike, Color, Icon, IconName, Label, Tooltip, h_flex, prelude::*};
+use util::ResultExt;
use workspace::{StatusItemView, ToolbarItemEvent, Workspace, item::ItemHandle};
use crate::{Deploy, IncludeWarnings, ProjectDiagnosticsEditor};
@@ -20,6 +21,7 @@ pub struct DiagnosticIndicator {
current_diagnostic: Option<Diagnostic>,
_observe_active_editor: Option<Subscription>,
diagnostics_update: Task<()>,
+ diagnostic_summary_update: Task<()>,
}
impl Render for DiagnosticIndicator {
@@ -135,8 +137,16 @@ impl DiagnosticIndicator {
}
project::Event::DiagnosticsUpdated { .. } => {
- this.summary = project.read(cx).diagnostic_summary(false, cx);
- cx.notify();
+ this.diagnostic_summary_update = cx.spawn(async move |this, cx| {
+ cx.background_executor()
+ .timer(Duration::from_millis(30))
+ .await;
+ this.update(cx, |this, cx| {
+ this.summary = project.read(cx).diagnostic_summary(false, cx);
+ cx.notify();
+ })
+ .log_err();
+ });
}
_ => {}
@@ -150,6 +160,7 @@ impl DiagnosticIndicator {
current_diagnostic: None,
_observe_active_editor: None,
diagnostics_update: Task::ready(()),
+ diagnostic_summary_update: Task::ready(()),
}
}
@@ -1,5 +1,3 @@
-use std::sync::Arc;
-
use crate::{ProjectDiagnosticsEditor, ToggleDiagnosticsRefresh};
use gpui::{Context, Entity, EventEmitter, ParentElement, Render, WeakEntity, Window};
use ui::prelude::*;
@@ -15,26 +13,18 @@ impl Render for ToolbarControls {
let mut include_warnings = false;
let mut has_stale_excerpts = false;
let mut is_updating = false;
- let cargo_diagnostics_sources = Arc::new(self.diagnostics().map_or(Vec::new(), |editor| {
- editor.read(cx).cargo_diagnostics_sources(cx)
- }));
- let fetch_cargo_diagnostics = !cargo_diagnostics_sources.is_empty();
if let Some(editor) = self.diagnostics() {
let diagnostics = editor.read(cx);
include_warnings = diagnostics.include_warnings;
has_stale_excerpts = !diagnostics.paths_to_update.is_empty();
- is_updating = if fetch_cargo_diagnostics {
- diagnostics.cargo_diagnostics_fetch.fetch_task.is_some()
- } else {
- diagnostics.update_excerpts_task.is_some()
- || diagnostics
- .project
- .read(cx)
- .language_servers_running_disk_based_diagnostics(cx)
- .next()
- .is_some()
- };
+ is_updating = diagnostics.update_excerpts_task.is_some()
+ || diagnostics
+ .project
+ .read(cx)
+ .language_servers_running_disk_based_diagnostics(cx)
+ .next()
+ .is_some();
}
let tooltip = if include_warnings {
@@ -54,7 +44,7 @@ impl Render for ToolbarControls {
.map(|div| {
if is_updating {
div.child(
- IconButton::new("stop-updating", IconName::StopFilled)
+ IconButton::new("stop-updating", IconName::Stop)
.icon_color(Color::Info)
.shape(IconButtonShape::Square)
.tooltip(Tooltip::for_action_title(
@@ -64,7 +54,6 @@ impl Render for ToolbarControls {
.on_click(cx.listener(move |toolbar_controls, _, _, cx| {
if let Some(diagnostics) = toolbar_controls.diagnostics() {
diagnostics.update(cx, |diagnostics, cx| {
- diagnostics.stop_cargo_diagnostics_fetch(cx);
diagnostics.update_excerpts_task = None;
cx.notify();
});
@@ -73,10 +62,10 @@ impl Render for ToolbarControls {
)
} else {
div.child(
- IconButton::new("refresh-diagnostics", IconName::Update)
+ IconButton::new("refresh-diagnostics", IconName::ArrowCircle)
.icon_color(Color::Info)
.shape(IconButtonShape::Square)
- .disabled(!has_stale_excerpts && !fetch_cargo_diagnostics)
+ .disabled(!has_stale_excerpts)
.tooltip(Tooltip::for_action_title(
"Refresh diagnostics",
&ToggleDiagnosticsRefresh,
@@ -84,17 +73,8 @@ impl Render for ToolbarControls {
.on_click(cx.listener({
move |toolbar_controls, _, window, cx| {
if let Some(diagnostics) = toolbar_controls.diagnostics() {
- let cargo_diagnostics_sources =
- Arc::clone(&cargo_diagnostics_sources);
diagnostics.update(cx, move |diagnostics, cx| {
- if fetch_cargo_diagnostics {
- diagnostics.fetch_cargo_diagnostics(
- cargo_diagnostics_sources,
- cx,
- );
- } else {
- diagnostics.update_all_excerpts(window, cx);
- }
+ diagnostics.update_all_excerpts(window, cx);
});
}
}
@@ -7,17 +7,19 @@ license = "GPL-3.0-or-later"
[dependencies]
anyhow.workspace = true
-clap.workspace = true
-mdbook = "0.4.40"
+command_palette.workspace = true
+gpui.workspace = true
+# We are specifically pinning this version of mdbook, as later versions introduce issues with double-nested subdirectories.
+# Ask @maxdeviant about this before bumping.
+mdbook = "= 0.4.40"
+regex.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
-regex.workspace = true
util.workspace = true
workspace-hack.workspace = true
zed.workspace = true
-gpui.workspace = true
-command_palette.workspace = true
+zlog.workspace = true
[lints]
workspace = true
@@ -1,14 +1,15 @@
-use anyhow::Result;
-use clap::{Arg, ArgMatches, Command};
+use anyhow::{Context, Result};
use mdbook::BookItem;
use mdbook::book::{Book, Chapter};
use mdbook::preprocess::CmdPreprocessor;
use regex::Regex;
use settings::KeymapFile;
-use std::collections::HashSet;
+use std::borrow::Cow;
+use std::collections::{HashMap, HashSet};
use std::io::{self, Read};
use std::process;
-use std::sync::LazyLock;
+use std::sync::{LazyLock, OnceLock};
+use util::paths::PathExt;
static KEYMAP_MACOS: LazyLock<KeymapFile> = LazyLock::new(|| {
load_keymap("keymaps/default-macos.json").expect("Failed to load MacOS keymap")
@@ -20,60 +21,66 @@ static KEYMAP_LINUX: LazyLock<KeymapFile> = LazyLock::new(|| {
static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(dump_all_gpui_actions);
-pub fn make_app() -> Command {
- Command::new("zed-docs-preprocessor")
- .about("Preprocesses Zed Docs content to provide rich action & keybinding support and more")
- .subcommand(
- Command::new("supports")
- .arg(Arg::new("renderer").required(true))
- .about("Check whether a renderer is supported by this preprocessor"),
- )
-}
+const FRONT_MATTER_COMMENT: &str = "<!-- ZED_META {} -->";
fn main() -> Result<()> {
- let matches = make_app().get_matches();
+ zlog::init();
+ zlog::init_output_stderr();
// call a zed:: function so everything in `zed` crate is linked and
// all actions in the actual app are registered
zed::stdout_is_a_pty();
-
- if let Some(sub_args) = matches.subcommand_matches("supports") {
- handle_supports(sub_args);
- } else {
- handle_preprocessing()?;
+ let args = std::env::args().skip(1).collect::<Vec<_>>();
+
+ match args.get(0).map(String::as_str) {
+ Some("supports") => {
+ let renderer = args.get(1).expect("Required argument");
+ let supported = renderer != "not-supported";
+ if supported {
+ process::exit(0);
+ } else {
+ process::exit(1);
+ }
+ }
+ Some("postprocess") => handle_postprocessing()?,
+ _ => handle_preprocessing()?,
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-enum Error {
+enum PreprocessorError {
ActionNotFound { action_name: String },
DeprecatedActionUsed { used: String, should_be: String },
+ InvalidFrontmatterLine(String),
}
-impl Error {
+impl PreprocessorError {
fn new_for_not_found_action(action_name: String) -> Self {
for action in &*ALL_ACTIONS {
for alias in action.deprecated_aliases {
if alias == &action_name {
- return Error::DeprecatedActionUsed {
- used: action_name.clone(),
+ return PreprocessorError::DeprecatedActionUsed {
+ used: action_name,
should_be: action.name.to_string(),
};
}
}
}
- Error::ActionNotFound {
- action_name: action_name.to_string(),
- }
+ PreprocessorError::ActionNotFound { action_name }
}
}
-impl std::fmt::Display for Error {
+impl std::fmt::Display for PreprocessorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
- Error::ActionNotFound { action_name } => write!(f, "Action not found: {}", action_name),
- Error::DeprecatedActionUsed { used, should_be } => write!(
+ PreprocessorError::InvalidFrontmatterLine(line) => {
+ write!(f, "Invalid frontmatter line: {}", line)
+ }
+ PreprocessorError::ActionNotFound { action_name } => {
+ write!(f, "Action not found: {}", action_name)
+ }
+ PreprocessorError::DeprecatedActionUsed { used, should_be } => write!(
f,
"Deprecated action used: {} should be {}",
used, should_be
@@ -89,14 +96,16 @@ fn handle_preprocessing() -> Result<()> {
let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?;
- let mut errors = HashSet::<Error>::new();
+ let mut errors = HashSet::<PreprocessorError>::new();
+ handle_frontmatter(&mut book, &mut errors);
+ template_big_table_of_actions(&mut book);
template_and_validate_keybindings(&mut book, &mut errors);
template_and_validate_actions(&mut book, &mut errors);
if !errors.is_empty() {
- const ANSI_RED: &'static str = "\x1b[31m";
- const ANSI_RESET: &'static str = "\x1b[0m";
+ const ANSI_RED: &str = "\x1b[31m";
+ const ANSI_RESET: &str = "\x1b[0m";
for error in &errors {
eprintln!("{ANSI_RED}ERROR{ANSI_RESET}: {}", error);
}
@@ -108,19 +117,50 @@ fn handle_preprocessing() -> Result<()> {
Ok(())
}
-fn handle_supports(sub_args: &ArgMatches) -> ! {
- let renderer = sub_args
- .get_one::<String>("renderer")
- .expect("Required argument");
- let supported = renderer != "not-supported";
- if supported {
- process::exit(0);
- } else {
- process::exit(1);
- }
+fn handle_frontmatter(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
+ let frontmatter_regex = Regex::new(r"(?s)^\s*---(.*?)---").unwrap();
+ for_each_chapter_mut(book, |chapter| {
+ let new_content = frontmatter_regex.replace(&chapter.content, |caps: ®ex::Captures| {
+ let frontmatter = caps[1].trim();
+ let frontmatter = frontmatter.trim_matches(&[' ', '-', '\n']);
+ let mut metadata = HashMap::<String, String>::default();
+ for line in frontmatter.lines() {
+ let Some((name, value)) = line.split_once(':') else {
+ errors.insert(PreprocessorError::InvalidFrontmatterLine(format!(
+ "{}: {}",
+ chapter_breadcrumbs(chapter),
+ line
+ )));
+ continue;
+ };
+ let name = name.trim();
+ let value = value.trim();
+ metadata.insert(name.to_string(), value.to_string());
+ }
+ FRONT_MATTER_COMMENT.replace(
+ "{}",
+ &serde_json::to_string(&metadata).expect("Failed to serialize metadata"),
+ )
+ });
+ if let Cow::Owned(content) = new_content {
+ chapter.content = content;
+ }
+ });
}
-fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Error>) {
+fn template_big_table_of_actions(book: &mut Book) {
+ for_each_chapter_mut(book, |chapter| {
+ let needle = "{#ACTIONS_TABLE#}";
+ if let Some(start) = chapter.content.rfind(needle) {
+ chapter.content.replace_range(
+ start..start + needle.len(),
+ &generate_big_table_of_actions(),
+ );
+ }
+ });
+}
+
+fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
let regex = Regex::new(r"\{#kb (.*?)\}").unwrap();
for_each_chapter_mut(book, |chapter| {
@@ -128,7 +168,9 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Error
.replace_all(&chapter.content, |caps: ®ex::Captures| {
let action = caps[1].trim();
if find_action_by_name(action).is_none() {
- errors.insert(Error::new_for_not_found_action(action.to_string()));
+ errors.insert(PreprocessorError::new_for_not_found_action(
+ action.to_string(),
+ ));
return String::new();
}
let macos_binding = find_binding("macos", action).unwrap_or_default();
@@ -144,7 +186,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Error
});
}
-fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Error>) {
+fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
for_each_chapter_mut(book, |chapter| {
@@ -152,7 +194,9 @@ fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Error>) {
.replace_all(&chapter.content, |caps: ®ex::Captures| {
let name = caps[1].trim();
let Some(action) = find_action_by_name(name) else {
- errors.insert(Error::new_for_not_found_action(name.to_string()));
+ errors.insert(PreprocessorError::new_for_not_found_action(
+ name.to_string(),
+ ));
return String::new();
};
format!("<code class=\"hljs\">{}</code>", &action.human_name)
@@ -217,6 +261,13 @@ fn name_for_action(action_as_str: String) -> String {
.unwrap_or(action_as_str)
}
+fn chapter_breadcrumbs(chapter: &Chapter) -> String {
+ let mut breadcrumbs = Vec::with_capacity(chapter.parent_names.len() + 1);
+ breadcrumbs.extend(chapter.parent_names.iter().map(String::as_str));
+ breadcrumbs.push(chapter.name.as_str());
+ format!("[{:?}] {}", chapter.source_path, breadcrumbs.join(" > "))
+}
+
fn load_keymap(asset_path: &str) -> Result<KeymapFile> {
let content = util::asset_str::<settings::SettingsAssets>(asset_path);
KeymapFile::parse(content.as_ref())
@@ -239,6 +290,7 @@ struct ActionDef {
name: &'static str,
human_name: String,
deprecated_aliases: &'static [&'static str],
+ docs: Option<&'static str>,
}
fn dump_all_gpui_actions() -> Vec<ActionDef> {
@@ -247,10 +299,188 @@ fn dump_all_gpui_actions() -> Vec<ActionDef> {
name: action.name,
human_name: command_palette::humanize_action_name(action.name),
deprecated_aliases: action.deprecated_aliases,
+ docs: action.documentation,
})
.collect::<Vec<ActionDef>>();
actions.sort_by_key(|a| a.name);
- return actions;
+ actions
+}
+
+fn handle_postprocessing() -> Result<()> {
+ let logger = zlog::scoped!("render");
+ let mut ctx = mdbook::renderer::RenderContext::from_json(io::stdin())?;
+ let output = ctx
+ .config
+ .get_mut("output")
+ .expect("has output")
+ .as_table_mut()
+ .expect("output is table");
+ let zed_html = output.remove("zed-html").expect("zed-html output defined");
+ let default_description = zed_html
+ .get("default-description")
+ .expect("Default description not found")
+ .as_str()
+ .expect("Default description not a string")
+ .to_string();
+ let default_title = zed_html
+ .get("default-title")
+ .expect("Default title not found")
+ .as_str()
+ .expect("Default title not a string")
+ .to_string();
+
+ output.insert("html".to_string(), zed_html);
+ mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?;
+ let ignore_list = ["toc.html"];
+
+ let root_dir = ctx.destination.clone();
+ let mut files = Vec::with_capacity(128);
+ let mut queue = Vec::with_capacity(64);
+ queue.push(root_dir.clone());
+ while let Some(dir) = queue.pop() {
+ for entry in std::fs::read_dir(&dir).context(dir.to_sanitized_string())? {
+ let Ok(entry) = entry else {
+ continue;
+ };
+ let file_type = entry.file_type().context("Failed to determine file type")?;
+ if file_type.is_dir() {
+ queue.push(entry.path());
+ }
+ if file_type.is_file()
+ && matches!(
+ entry.path().extension().and_then(std::ffi::OsStr::to_str),
+ Some("html")
+ )
+ {
+ if ignore_list.contains(&&*entry.file_name().to_string_lossy()) {
+ zlog::info!(logger => "Ignoring {}", entry.path().to_string_lossy());
+ } else {
+ files.push(entry.path());
+ }
+ }
+ }
+ }
+
+ zlog::info!(logger => "Processing {} `.html` files", files.len());
+ let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
+ for file in files {
+ let contents = std::fs::read_to_string(&file)?;
+ let mut meta_description = None;
+ let mut meta_title = None;
+ let contents = meta_regex.replace(&contents, |caps: ®ex::Captures| {
+ let metadata: HashMap<String, String> = serde_json::from_str(&caps[1]).with_context(|| format!("JSON Metadata: {:?}", &caps[1])).expect("Failed to deserialize metadata");
+ for (kind, content) in metadata {
+ match kind.as_str() {
+ "description" => {
+ meta_description = Some(content);
+ }
+ "title" => {
+ meta_title = Some(content);
+ }
+ _ => {
+ zlog::warn!(logger => "Unrecognized frontmatter key: {} in {:?}", kind, pretty_path(&file, &root_dir));
+ }
+ }
+ }
+ String::new()
+ });
+ let meta_description = meta_description.as_ref().unwrap_or_else(|| {
+ zlog::warn!(logger => "No meta description found for {:?}", pretty_path(&file, &root_dir));
+ &default_description
+ });
+ let page_title = extract_title_from_page(&contents, pretty_path(&file, &root_dir));
+ let meta_title = meta_title.as_ref().unwrap_or_else(|| {
+ zlog::debug!(logger => "No meta title found for {:?}", pretty_path(&file, &root_dir));
+ &default_title
+ });
+ let meta_title = format!("{} | {}", page_title, meta_title);
+ zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir));
+ let contents = contents.replace("#description#", meta_description);
+ let contents = title_regex()
+ .replace(&contents, |_: ®ex::Captures| {
+ format!("<title>{}</title>", meta_title)
+ })
+ .to_string();
+ // let contents = contents.replace("#title#", &meta_title);
+ std::fs::write(file, contents)?;
+ }
+ return Ok(());
+
+ fn pretty_path<'a>(
+ path: &'a std::path::PathBuf,
+ root: &'a std::path::PathBuf,
+ ) -> &'a std::path::Path {
+ path.strip_prefix(&root).unwrap_or(path)
+ }
+ fn extract_title_from_page(contents: &str, pretty_path: &std::path::Path) -> String {
+ let title_tag_contents = &title_regex()
+ .captures(contents)
+ .with_context(|| format!("Failed to find title in {:?}", pretty_path))
+ .expect("Page has <title> element")[1];
+
+ title_tag_contents
+ .trim()
+ .strip_suffix("- Zed")
+ .unwrap_or(title_tag_contents)
+ .trim()
+ .to_string()
+ }
+}
+
+fn title_regex() -> &'static Regex {
+ static TITLE_REGEX: OnceLock<Regex> = OnceLock::new();
+ TITLE_REGEX.get_or_init(|| Regex::new(r"<title>\s*(.*?)\s*</title>").unwrap())
+}
+
+fn generate_big_table_of_actions() -> String {
+ let actions = &*ALL_ACTIONS;
+ let mut output = String::new();
+
+ let mut actions_sorted = actions.iter().collect::<Vec<_>>();
+ actions_sorted.sort_by_key(|a| a.name);
+
+ // Start the definition list with custom styling for better spacing
+ output.push_str("<dl style=\"line-height: 1.8;\">\n");
+
+ for action in actions_sorted.into_iter() {
+ // Add the humanized action name as the term with margin
+ output.push_str(
+ "<dt style=\"margin-top: 1.5em; margin-bottom: 0.5em; font-weight: bold;\"><code>",
+ );
+ output.push_str(&action.human_name);
+ output.push_str("</code></dt>\n");
+
+ // Add the definition with keymap name and description
+ output.push_str("<dd style=\"margin-left: 2em; margin-bottom: 1em;\">\n");
+
+ // Add the description, escaping HTML if needed
+ if let Some(description) = action.docs {
+ output.push_str(
+ &description
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">"),
+ );
+ output.push_str("<br>\n");
+ }
+ output.push_str("Keymap Name: <code>");
+ output.push_str(action.name);
+ output.push_str("</code><br>\n");
+ if !action.deprecated_aliases.is_empty() {
+ output.push_str("Deprecated Aliases:");
+ for alias in action.deprecated_aliases.iter() {
+ output.push_str("<code>");
+ output.push_str(alias);
+ output.push_str("</code>, ");
+ }
+ }
+ output.push_str("\n</dd>\n");
+ }
+
+ // Close the definition list
+ output.push_str("</dl>\n");
+
+ output
}
@@ -1,5 +1,5 @@
[package]
-name = "inline_completion"
+name = "edit_prediction"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
@@ -9,7 +9,7 @@ license = "GPL-3.0-or-later"
workspace = true
[lib]
-path = "src/inline_completion.rs"
+path = "src/edit_prediction.rs"
[dependencies]
client.workspace = true
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -7,7 +7,7 @@ use project::Project;
// TODO: Find a better home for `Direction`.
//
-// This should live in an ancestor crate of `editor` and `inline_completion`,
+// This should live in an ancestor crate of `editor` and `edit_prediction`,
// but at time of writing there isn't an obvious spot.
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum Direction {
@@ -16,7 +16,7 @@ pub enum Direction {
}
#[derive(Clone)]
-pub struct InlineCompletion {
+pub struct EditPrediction {
/// The ID of the completion, if it has one.
pub id: Option<SharedString>,
pub edits: Vec<(Range<language::Anchor>, String)>,
@@ -34,7 +34,7 @@ pub enum DataCollectionState {
impl DataCollectionState {
pub fn is_supported(&self) -> bool {
- !matches!(self, DataCollectionState::Unsupported { .. })
+ !matches!(self, DataCollectionState::Unsupported)
}
pub fn is_enabled(&self) -> bool {
@@ -61,6 +61,10 @@ pub trait EditPredictionProvider: 'static + Sized {
fn show_tab_accept_marker() -> bool {
false
}
+ fn supports_jump_to_edit() -> bool {
+ true
+ }
+
fn data_collection_state(&self, _cx: &App) -> DataCollectionState {
DataCollectionState::Unsupported
}
@@ -85,9 +89,6 @@ pub trait EditPredictionProvider: 'static + Sized {
debounce: bool,
cx: &mut Context<Self>,
);
- fn needs_terms_acceptance(&self, _cx: &App) -> bool {
- false
- }
fn cycle(
&mut self,
buffer: Entity<Buffer>,
@@ -102,10 +103,10 @@ pub trait EditPredictionProvider: 'static + Sized {
buffer: &Entity<Buffer>,
cursor_position: language::Anchor,
cx: &mut Context<Self>,
- ) -> Option<InlineCompletion>;
+ ) -> Option<EditPrediction>;
}
-pub trait InlineCompletionProviderHandle {
+pub trait EditPredictionProviderHandle {
fn name(&self) -> &'static str;
fn display_name(&self) -> &'static str;
fn is_enabled(
@@ -116,10 +117,10 @@ pub trait InlineCompletionProviderHandle {
) -> bool;
fn show_completions_in_menu(&self) -> bool;
fn show_tab_accept_marker(&self) -> bool;
+ fn supports_jump_to_edit(&self) -> bool;
fn data_collection_state(&self, cx: &App) -> DataCollectionState;
fn usage(&self, cx: &App) -> Option<EditPredictionUsage>;
fn toggle_data_collection(&self, cx: &mut App);
- fn needs_terms_acceptance(&self, cx: &App) -> bool;
fn is_refreshing(&self, cx: &App) -> bool;
fn refresh(
&self,
@@ -143,10 +144,10 @@ pub trait InlineCompletionProviderHandle {
buffer: &Entity<Buffer>,
cursor_position: language::Anchor,
cx: &mut App,
- ) -> Option<InlineCompletion>;
+ ) -> Option<EditPrediction>;
}
-impl<T> InlineCompletionProviderHandle for Entity<T>
+impl<T> EditPredictionProviderHandle for Entity<T>
where
T: EditPredictionProvider,
{
@@ -166,6 +167,10 @@ where
T::show_tab_accept_marker()
}
+ fn supports_jump_to_edit(&self) -> bool {
+ T::supports_jump_to_edit()
+ }
+
fn data_collection_state(&self, cx: &App) -> DataCollectionState {
self.read(cx).data_collection_state(cx)
}
@@ -187,10 +192,6 @@ where
self.read(cx).is_enabled(buffer, cursor_position, cx)
}
- fn needs_terms_acceptance(&self, cx: &App) -> bool {
- self.read(cx).needs_terms_acceptance(cx)
- }
-
fn is_refreshing(&self, cx: &App) -> bool {
self.read(cx).is_refreshing()
}
@@ -233,7 +234,7 @@ where
buffer: &Entity<Buffer>,
cursor_position: language::Anchor,
cx: &mut App,
- ) -> Option<InlineCompletion> {
+ ) -> Option<EditPrediction> {
self.update(cx, |this, cx| this.suggest(buffer, cursor_position, cx))
}
}
@@ -1,5 +1,5 @@
[package]
-name = "inline_completion_button"
+name = "edit_prediction_button"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
@@ -9,21 +9,23 @@ license = "GPL-3.0-or-later"
workspace = true
[lib]
-path = "src/inline_completion_button.rs"
+path = "src/edit_prediction_button.rs"
doctest = false
[dependencies]
anyhow.workspace = true
client.workspace = true
+cloud_llm_client.workspace = true
copilot.workspace = true
editor.workspace = true
feature_flags.workspace = true
fs.workspace = true
gpui.workspace = true
indoc.workspace = true
-inline_completion.workspace = true
+edit_prediction.workspace = true
language.workspace = true
paths.workspace = true
+project.workspace = true
regex.workspace = true
settings.workspace = true
supermaven.workspace = true
@@ -32,7 +34,6 @@ ui.workspace = true
workspace-hack.workspace = true
workspace.workspace = true
zed_actions.workspace = true
-zed_llm_client.workspace = true
zeta.workspace = true
[dev-dependencies]
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -1,11 +1,8 @@
use anyhow::Result;
use client::{UserStore, zed_urls};
+use cloud_llm_client::UsageLimit;
use copilot::{Copilot, Status};
-use editor::{
- Editor, SelectionEffects,
- actions::{ShowEditPrediction, ToggleEditPrediction},
- scroll::Autoscroll,
-};
+use editor::{Editor, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll};
use feature_flags::{FeatureFlagAppExt, PredictEditsRateCompletionsFeatureFlag};
use fs::Fs;
use gpui::{
@@ -18,6 +15,7 @@ use language::{
EditPredictionsMode, File, Language,
language_settings::{self, AllLanguageSettings, EditPredictionProvider, all_language_settings},
};
+use project::DisableAiSettings;
use regex::Regex;
use settings::{Settings, SettingsStore, update_settings_file};
use std::{
@@ -34,29 +32,29 @@ use workspace::{
notifications::NotificationId,
};
use zed_actions::OpenBrowser;
-use zed_llm_client::UsageLimit;
use zeta::RateCompletions;
actions!(
edit_prediction,
[
- /// Toggles the inline completion menu.
+ /// Toggles the edit prediction menu.
ToggleMenu
]
);
const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
+const PRIVACY_DOCS: &str = "https://zed.dev/docs/ai/privacy-and-security";
struct CopilotErrorToast;
-pub struct InlineCompletionButton {
+pub struct EditPredictionButton {
editor_subscription: Option<(Subscription, usize)>,
editor_enabled: Option<bool>,
editor_show_predictions: bool,
editor_focus_handle: Option<FocusHandle>,
language: Option<Arc<Language>>,
file: Option<Arc<dyn File>>,
- edit_prediction_provider: Option<Arc<dyn inline_completion::InlineCompletionProviderHandle>>,
+ edit_prediction_provider: Option<Arc<dyn edit_prediction::EditPredictionProviderHandle>>,
fs: Arc<dyn Fs>,
user_store: Entity<UserStore>,
popover_menu_handle: PopoverMenuHandle<ContextMenu>,
@@ -69,8 +67,13 @@ enum SupermavenButtonStatus {
Initializing,
}
-impl Render for InlineCompletionButton {
+impl Render for EditPredictionButton {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ // Return empty div if AI is disabled
+ if DisableAiSettings::get_global(cx).disable_ai {
+ return div();
+ }
+
let all_language_settings = all_language_settings(None, cx);
match all_language_settings.edit_predictions.provider {
@@ -124,7 +127,7 @@ impl Render for InlineCompletionButton {
}),
);
}
- let this = cx.entity().clone();
+ let this = cx.entity();
div().child(
PopoverMenu::new("copilot")
@@ -165,7 +168,7 @@ impl Render for InlineCompletionButton {
let account_status = agent.account_status.clone();
match account_status {
AccountStatus::NeedsActivation { activate_url } => {
- SupermavenButtonStatus::NeedsActivation(activate_url.clone())
+ SupermavenButtonStatus::NeedsActivation(activate_url)
}
AccountStatus::Unknown => SupermavenButtonStatus::Initializing,
AccountStatus::Ready => SupermavenButtonStatus::Ready,
@@ -179,10 +182,10 @@ impl Render for InlineCompletionButton {
let icon = status.to_icon();
let tooltip_text = status.to_tooltip();
let has_menu = status.has_menu();
- let this = cx.entity().clone();
+ let this = cx.entity();
let fs = self.fs.clone();
- return div().child(
+ div().child(
PopoverMenu::new("supermaven")
.menu(move |window, cx| match &status {
SupermavenButtonStatus::NeedsActivation(activate_url) => {
@@ -193,13 +196,13 @@ impl Render for InlineCompletionButton {
cx.open_url(activate_url.as_str())
})
.entry(
- "Use Copilot",
+ "Use Zed AI",
None,
move |_, cx| {
set_completion_provider(
fs.clone(),
cx,
- EditPredictionProvider::Copilot,
+ EditPredictionProvider::Zed,
)
},
)
@@ -227,7 +230,7 @@ impl Render for InlineCompletionButton {
},
)
.with_handle(self.popover_menu_handle.clone()),
- );
+ )
}
EditPredictionProvider::Zed => {
@@ -239,21 +242,11 @@ impl Render for InlineCompletionButton {
IconName::ZedPredictDisabled
};
- let current_user_terms_accepted =
- self.user_store.read(cx).current_user_has_accepted_terms();
- let has_subscription = self.user_store.read(cx).current_plan().is_some()
- && self.user_store.read(cx).subscription_period().is_some();
-
- if !has_subscription || !current_user_terms_accepted.unwrap_or(false) {
- let signed_in = current_user_terms_accepted.is_some();
- let tooltip_meta = if signed_in {
- if has_subscription {
- "Read Terms of Service"
- } else {
- "Choose a Plan"
- }
+ if zeta::should_show_upsell_modal() {
+ let tooltip_meta = if self.user_store.read(cx).current_user().is_some() {
+ "Choose a Plan"
} else {
- "Sign in to use"
+ "Sign In"
};
return div().child(
@@ -334,7 +327,7 @@ impl Render for InlineCompletionButton {
})
});
- let this = cx.entity().clone();
+ let this = cx.entity();
let mut popover_menu = PopoverMenu::new("zeta")
.menu(move |window, cx| {
@@ -346,7 +339,7 @@ impl Render for InlineCompletionButton {
let is_refreshing = self
.edit_prediction_provider
.as_ref()
- .map_or(false, |provider| provider.is_refreshing(cx));
+ .is_some_and(|provider| provider.is_refreshing(cx));
if is_refreshing {
popover_menu = popover_menu.trigger(
@@ -368,7 +361,7 @@ impl Render for InlineCompletionButton {
}
}
-impl InlineCompletionButton {
+impl EditPredictionButton {
pub fn new(
fs: Arc<dyn Fs>,
user_store: Entity<UserStore>,
@@ -390,9 +383,9 @@ impl InlineCompletionButton {
language: None,
file: None,
edit_prediction_provider: None,
+ user_store,
popover_menu_handle,
fs,
- user_store,
}
}
@@ -403,15 +396,16 @@ impl InlineCompletionButton {
) -> Entity<ContextMenu> {
let fs = self.fs.clone();
ContextMenu::build(window, cx, |menu, _, _| {
- menu.entry("Sign In", None, copilot::initiate_sign_in)
+ menu.entry("Sign In to Copilot", None, copilot::initiate_sign_in)
.entry("Disable Copilot", None, {
let fs = fs.clone();
move |_window, cx| hide_copilot(fs.clone(), cx)
})
- .entry("Use Supermaven", None, {
+ .separator()
+ .entry("Use Zed AI", None, {
let fs = fs.clone();
move |_window, cx| {
- set_completion_provider(fs.clone(), cx, EditPredictionProvider::Supermaven)
+ set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed)
}
})
})
@@ -439,9 +433,13 @@ impl InlineCompletionButton {
if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
let entry = ContextMenuEntry::new("This Buffer")
.toggleable(IconPosition::Start, self.editor_show_predictions)
- .action(Box::new(ToggleEditPrediction))
+ .action(Box::new(editor::actions::ToggleEditPrediction))
.handler(move |window, cx| {
- editor_focus_handle.dispatch_action(&ToggleEditPrediction, window, cx);
+ editor_focus_handle.dispatch_action(
+ &editor::actions::ToggleEditPrediction,
+ window,
+ cx,
+ );
});
match language_state.clone() {
@@ -468,7 +466,7 @@ impl InlineCompletionButton {
IconPosition::Start,
None,
move |_, cx| {
- toggle_show_inline_completions_for_language(language.clone(), fs.clone(), cx)
+ toggle_show_edit_predictions_for_language(language.clone(), fs.clone(), cx)
},
);
}
@@ -476,17 +474,25 @@ impl InlineCompletionButton {
let settings = AllLanguageSettings::get_global(cx);
let globally_enabled = settings.show_edit_predictions(None, cx);
- menu = menu.toggleable_entry("All Files", globally_enabled, IconPosition::Start, None, {
- let fs = fs.clone();
- move |_, cx| toggle_inline_completions_globally(fs.clone(), cx)
- });
+ let entry = ContextMenuEntry::new("All Files")
+ .toggleable(IconPosition::Start, globally_enabled)
+ .action(workspace::ToggleEditPrediction.boxed_clone())
+ .handler(|window, cx| {
+ window.dispatch_action(workspace::ToggleEditPrediction.boxed_clone(), cx)
+ });
+ menu = menu.item(entry);
let provider = settings.edit_predictions.provider;
let current_mode = settings.edit_predictions_mode();
let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle);
let eager_mode = matches!(current_mode, EditPredictionsMode::Eager);
- if matches!(provider, EditPredictionProvider::Zed) {
+ if matches!(
+ provider,
+ EditPredictionProvider::Zed
+ | EditPredictionProvider::Copilot
+ | EditPredictionProvider::Supermaven
+ ) {
menu = menu
.separator()
.header("Display Modes")
@@ -518,7 +524,7 @@ impl InlineCompletionButton {
);
}
- menu = menu.separator().header("Privacy Settings");
+ menu = menu.separator().header("Privacy");
if let Some(provider) = &self.edit_prediction_provider {
let data_collection = provider.data_collection_state(cx);
if data_collection.is_supported() {
@@ -569,13 +575,15 @@ impl InlineCompletionButton {
.child(
Label::new(indoc!{
"Help us improve our open dataset model by sharing data from open source repositories. \
- Zed must detect a license file in your repo for this setting to take effect."
+ Zed must detect a license file in your repo for this setting to take effect. \
+ Files with sensitive data and secrets are excluded by default."
})
)
.child(
h_flex()
.items_start()
.pt_2()
+ .pr_1()
.flex_1()
.gap_1p5()
.border_t_1()
@@ -635,6 +643,13 @@ impl InlineCompletionButton {
.detach_and_log_err(cx);
}
}),
+ ).item(
+ ContextMenuEntry::new("View Documentation")
+ .icon(IconName::FileGeneric)
+ .icon_color(Color::Muted)
+ .handler(move |_, cx| {
+ cx.open_url(PRIVACY_DOCS);
+ })
);
if !self.editor_enabled.unwrap_or(true) {
@@ -672,6 +687,13 @@ impl InlineCompletionButton {
) -> Entity<ContextMenu> {
ContextMenu::build(window, cx, |menu, window, cx| {
self.build_language_settings_menu(menu, window, cx)
+ .separator()
+ .entry("Use Zed AI instead", None, {
+ let fs = self.fs.clone();
+ move |_window, cx| {
+ set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed)
+ }
+ })
.separator()
.link(
"Go to Copilot Settings",
@@ -750,44 +772,24 @@ impl InlineCompletionButton {
menu = menu
.custom_entry(
|_window, _cx| {
- h_flex()
- .gap_1()
- .child(
- Icon::new(IconName::Warning)
- .size(IconSize::Small)
- .color(Color::Warning),
- )
- .child(
- Label::new("Your GitHub account is less than 30 days old")
- .size(LabelSize::Small)
- .color(Color::Warning),
- )
+ Label::new("Your GitHub account is less than 30 days old.")
+ .size(LabelSize::Small)
+ .color(Color::Warning)
.into_any_element()
},
|_window, cx| cx.open_url(&zed_urls::account_url(cx)),
)
- .entry(
- "You need to upgrade to Zed Pro or contact us.",
- None,
- |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
- )
+ .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
+ cx.open_url(&zed_urls::account_url(cx))
+ })
.separator();
} else if self.user_store.read(cx).has_overdue_invoices() {
menu = menu
.custom_entry(
|_window, _cx| {
- h_flex()
- .gap_1()
- .child(
- Icon::new(IconName::Warning)
- .size(IconSize::Small)
- .color(Color::Warning),
- )
- .child(
- Label::new("You have an outstanding invoice")
- .size(LabelSize::Small)
- .color(Color::Warning),
- )
+ Label::new("You have an outstanding invoice")
+ .size(LabelSize::Small)
+ .color(Color::Warning)
.into_any_element()
},
|_window, cx| {
@@ -837,7 +839,7 @@ impl InlineCompletionButton {
}
}
-impl StatusItemView for InlineCompletionButton {
+impl StatusItemView for EditPredictionButton {
fn set_active_pane_item(
&mut self,
item: Option<&dyn ItemHandle>,
@@ -907,7 +909,7 @@ async fn open_disabled_globs_setting_in_editor(
let settings = cx.global::<SettingsStore>();
- // Ensure that we always have "inline_completions { "disabled_globs": [] }"
+ // Ensure that we always have "edit_predictions { "disabled_globs": [] }"
let edits = settings.edits_for_update::<AllLanguageSettings>(&text, |file| {
file.edit_predictions
.get_or_insert_with(Default::default)
@@ -945,13 +947,6 @@ async fn open_disabled_globs_setting_in_editor(
anyhow::Ok(())
}
-fn toggle_inline_completions_globally(fs: Arc<dyn Fs>, cx: &mut App) {
- let show_edit_predictions = all_language_settings(None, cx).show_edit_predictions(None, cx);
- update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
- file.defaults.show_edit_predictions = Some(!show_edit_predictions)
- });
-}
-
fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: EditPredictionProvider) {
update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
file.features
@@ -960,7 +955,7 @@ fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: EditPredicti
});
}
-fn toggle_show_inline_completions_for_language(
+fn toggle_show_edit_predictions_for_language(
language: Arc<Language>,
fs: Arc<dyn Fs>,
cx: &mut App,
@@ -22,6 +22,7 @@ test-support = [
"theme/test-support",
"util/test-support",
"workspace/test-support",
+ "tree-sitter-c",
"tree-sitter-rust",
"tree-sitter-typescript",
"tree-sitter-html",
@@ -47,7 +48,7 @@ fs.workspace = true
git.workspace = true
gpui.workspace = true
indoc.workspace = true
-inline_completion.workspace = true
+edit_prediction.workspace = true
itertools.workspace = true
language.workspace = true
linkify.workspace = true
@@ -76,6 +77,7 @@ telemetry.workspace = true
text.workspace = true
time.workspace = true
theme.workspace = true
+tree-sitter-c = { workspace = true, optional = true }
tree-sitter-html = { workspace = true, optional = true }
tree-sitter-rust = { workspace = true, optional = true }
tree-sitter-typescript = { workspace = true, optional = true }
@@ -106,10 +108,12 @@ settings = { workspace = true, features = ["test-support"] }
tempfile.workspace = true
text = { workspace = true, features = ["test-support"] }
theme = { workspace = true, features = ["test-support"] }
+tree-sitter-c.workspace = true
tree-sitter-html.workspace = true
tree-sitter-rust.workspace = true
tree-sitter-typescript.workspace = true
tree-sitter-yaml.workspace = true
+tree-sitter-bash.workspace = true
unindent.workspace = true
util = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }
@@ -259,6 +259,13 @@ pub struct SpawnNearestTask {
pub reveal: task::RevealStrategy,
}
+#[derive(Clone, PartialEq, Action)]
+#[action(no_json, no_register)]
+pub struct DiffClipboardWithSelectionData {
+ pub clipboard_text: String,
+ pub editor: Entity<Editor>,
+}
+
#[derive(Debug, PartialEq, Eq, Clone, Copy, Deserialize, Default)]
pub enum UuidVersion {
#[default]
@@ -266,6 +273,16 @@ pub enum UuidVersion {
V7,
}
+/// Splits selection into individual lines.
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
+#[serde(deny_unknown_fields)]
+pub struct SplitSelectionIntoLines {
+ /// Keep the text selected after splitting instead of collapsing to cursors.
+ #[serde(default)]
+ pub keep_selections: bool,
+}
+
/// Goes to the next diagnostic in the file.
#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
#[action(namespace = editor)]
@@ -308,9 +325,8 @@ actions!(
[
/// Accepts the full edit prediction.
AcceptEditPrediction,
- /// Accepts a partial Copilot suggestion.
- AcceptPartialCopilotSuggestion,
/// Accepts a partial edit prediction.
+ #[action(deprecated_aliases = ["editor::AcceptPartialCopilotSuggestion"])]
AcceptPartialEditPrediction,
/// Adds a cursor above the current selection.
AddSelectionAbove,
@@ -322,6 +338,8 @@ actions!(
ApplyDiffHunk,
/// Deletes the character before the cursor.
Backspace,
+ /// Shows git blame information for the current line.
+ BlameHover,
/// Cancels the current operation.
Cancel,
/// Cancels the running flycheck operation.
@@ -356,6 +374,8 @@ actions!(
ConvertToLowerCase,
/// Toggles the case of selected text.
ConvertToOppositeCase,
+ /// Converts selected text to sentence case.
+ ConvertToSentenceCase,
/// Converts selected text to snake_case.
ConvertToSnakeCase,
/// Converts selected text to Title Case.
@@ -396,6 +416,8 @@ actions!(
DeleteToNextSubwordEnd,
/// Deletes to the start of the previous subword.
DeleteToPreviousSubwordStart,
+ /// Diffs the text stored in the clipboard against the current selection.
+ DiffClipboardWithSelection,
/// Displays names of all active cursors.
DisplayCursorNames,
/// Duplicates the current line below.
@@ -425,6 +447,8 @@ actions!(
FoldRecursive,
/// Folds the selected ranges.
FoldSelectedRanges,
+ /// Toggles focus back to the last active buffer.
+ ToggleFocus,
/// Toggles folding at the current position.
ToggleFold,
/// Toggles recursive folding at the current position.
@@ -658,8 +682,6 @@ actions!(
SortLinesCaseInsensitive,
/// Sorts selected lines case-sensitively.
SortLinesCaseSensitive,
- /// Splits selection into individual lines.
- SplitSelectionIntoLines,
/// Stops the language server for the current file.
StopLanguageServer,
/// Switches between source and header files.
@@ -731,5 +753,6 @@ actions!(
UniqueLinesCaseInsensitive,
/// Removes duplicate lines (case-sensitive).
UniqueLinesCaseSensitive,
+ UnwrapSyntaxNode
]
);
@@ -13,7 +13,7 @@ use crate::{Editor, SwitchSourceHeader, element::register_action};
use project::lsp_store::clangd_ext::CLANGD_SERVER_NAME;
fn is_c_language(language: &Language) -> bool {
- return language.name() == "C++".into() || language.name() == "C".into();
+ language.name() == "C++".into() || language.name() == "C".into()
}
pub fn switch_source_header(
@@ -29,16 +29,14 @@ pub fn switch_source_header(
return;
};
- let server_lookup =
- find_specific_language_server_in_selection(editor, cx, is_c_language, CLANGD_SERVER_NAME);
+ let Some((_, _, server_to_query, buffer)) =
+ find_specific_language_server_in_selection(editor, cx, is_c_language, CLANGD_SERVER_NAME)
+ else {
+ return;
+ };
let project = project.clone();
let upstream_client = project.read(cx).lsp_store().read(cx).upstream_client();
cx.spawn_in(window, async move |_editor, cx| {
- let Some((_, _, server_to_query, buffer)) =
- server_lookup.await
- else {
- return Ok(());
- };
let source_file = buffer.read_with(cx, |buffer, _| {
buffer.file().map(|file| file.path()).map(|path| path.to_string_lossy().to_string()).unwrap_or_else(|| "Unknown".to_string())
})?;
@@ -106,6 +104,6 @@ pub fn apply_related_actions(editor: &Entity<Editor>, window: &mut Window, cx: &
.filter_map(|buffer| buffer.read(cx).language())
.any(|language| is_c_language(language))
{
- register_action(&editor, window, switch_source_header);
+ register_action(editor, window, switch_source_header);
}
}
@@ -94,7 +94,7 @@ async fn test_fuzzy_score(cx: &mut TestAppContext) {
filter_and_sort_matches("set_text", &completions, SnippetSortOrder::Top, cx).await;
assert_eq!(matches[0].string, "set_text");
assert_eq!(matches[1].string, "set_text_style_refinement");
- assert_eq!(matches[2].string, "set_context_menu_options");
+ assert_eq!(matches[2].string, "set_placeholder_text");
}
// fuzzy filter text over label, sort_text and sort_kind
@@ -216,6 +216,28 @@ async fn test_sort_positions(cx: &mut TestAppContext) {
assert_eq!(matches[0].string, "rounded-full");
}
+#[gpui::test]
+async fn test_fuzzy_over_sort_positions(cx: &mut TestAppContext) {
+ let completions = vec![
+ CompletionBuilder::variable("lsp_document_colors", None, "7fffffff"), // 0.29 fuzzy score
+ CompletionBuilder::function(
+ "language_servers_running_disk_based_diagnostics",
+ None,
+ "7fffffff",
+ ), // 0.168 fuzzy score
+ CompletionBuilder::function("code_lens", None, "7fffffff"), // 3.2 fuzzy score
+ CompletionBuilder::variable("lsp_code_lens", None, "7fffffff"), // 3.2 fuzzy score
+ CompletionBuilder::function("fetch_code_lens", None, "7fffffff"), // 3.2 fuzzy score
+ ];
+
+ let matches =
+ filter_and_sort_matches("lens", &completions, SnippetSortOrder::default(), cx).await;
+
+ assert_eq!(matches[0].string, "code_lens");
+ assert_eq!(matches[1].string, "lsp_code_lens");
+ assert_eq!(matches[2].string, "fetch_code_lens");
+}
+
async fn test_for_each_prefix<F>(
target: &str,
completions: &Vec<Completion>,
@@ -295,7 +317,7 @@ async fn filter_and_sort_matches(
let candidates: Arc<[StringMatchCandidate]> = completions
.iter()
.enumerate()
- .map(|(id, completion)| StringMatchCandidate::new(id, &completion.label.filter_text()))
+ .map(|(id, completion)| StringMatchCandidate::new(id, completion.label.filter_text()))
.collect();
let cancel_flag = Arc::new(AtomicBool::new(false));
let background_executor = cx.executor();
@@ -309,5 +331,5 @@ async fn filter_and_sort_matches(
background_executor,
)
.await;
- CompletionsMenu::sort_string_matches(matches, Some(query), snippet_sort_order, &completions)
+ CompletionsMenu::sort_string_matches(matches, Some(query), snippet_sort_order, completions)
}
@@ -321,7 +321,7 @@ impl CompletionsMenu {
let match_candidates = choices
.iter()
.enumerate()
- .map(|(id, completion)| StringMatchCandidate::new(id, &completion))
+ .map(|(id, completion)| StringMatchCandidate::new(id, completion))
.collect();
let entries = choices
.iter()
@@ -514,7 +514,7 @@ impl CompletionsMenu {
// Expand the range to resolve more completions than are predicted to be visible, to reduce
// jank on navigation.
let entry_indices = util::expanded_and_wrapped_usize_range(
- entry_range.clone(),
+ entry_range,
RESOLVE_BEFORE_ITEMS,
RESOLVE_AFTER_ITEMS,
entries.len(),
@@ -1057,9 +1057,9 @@ impl CompletionsMenu {
enum MatchTier<'a> {
WordStartMatch {
sort_exact: Reverse<i32>,
- sort_positions: Vec<usize>,
sort_snippet: Reverse<i32>,
sort_score: Reverse<OrderedFloat<f64>>,
+ sort_positions: Vec<usize>,
sort_text: Option<&'a str>,
sort_kind: usize,
sort_label: &'a str,
@@ -1074,6 +1074,20 @@ impl CompletionsMenu {
.and_then(|q| q.chars().next())
.and_then(|c| c.to_lowercase().next());
+ if snippet_sort_order == SnippetSortOrder::None {
+ matches.retain(|string_match| {
+ let completion = &completions[string_match.candidate_id];
+
+ let is_snippet = matches!(
+ &completion.source,
+ CompletionSource::Lsp { lsp_completion, .. }
+ if lsp_completion.kind == Some(CompletionItemKind::SNIPPET)
+ );
+
+ !is_snippet
+ });
+ }
+
matches.sort_unstable_by_key(|string_match| {
let completion = &completions[string_match.candidate_id];
@@ -1097,10 +1111,8 @@ impl CompletionsMenu {
let query_start_doesnt_match_split_words = query_start_lower
.map(|query_char| {
!split_words(&string_match.string).any(|word| {
- word.chars()
- .next()
- .and_then(|c| c.to_lowercase().next())
- .map_or(false, |word_char| word_char == query_char)
+ word.chars().next().and_then(|c| c.to_lowercase().next())
+ == Some(query_char)
})
})
.unwrap_or(false);
@@ -1112,6 +1124,7 @@ impl CompletionsMenu {
SnippetSortOrder::Top => Reverse(if is_snippet { 1 } else { 0 }),
SnippetSortOrder::Bottom => Reverse(if is_snippet { 0 } else { 1 }),
SnippetSortOrder::Inline => Reverse(0),
+ SnippetSortOrder::None => Reverse(0),
};
let sort_positions = string_match.positions.clone();
let sort_exact = Reverse(if Some(completion.label.filter_text()) == query {
@@ -1122,9 +1135,9 @@ impl CompletionsMenu {
MatchTier::WordStartMatch {
sort_exact,
- sort_positions,
sort_snippet,
sort_score,
+ sort_positions,
sort_text,
sort_kind,
sort_label,
@@ -1369,7 +1382,7 @@ impl CodeActionsMenu {
}
}
- fn visible(&self) -> bool {
+ pub fn visible(&self) -> bool {
!self.actions.is_empty()
}
@@ -635,7 +635,7 @@ pub(crate) struct Highlights<'a> {
}
#[derive(Clone, Copy, Debug)]
-pub struct InlineCompletionStyles {
+pub struct EditPredictionStyles {
pub insertion: HighlightStyle,
pub whitespace: HighlightStyle,
}
@@ -643,7 +643,7 @@ pub struct InlineCompletionStyles {
#[derive(Default, Debug, Clone, Copy)]
pub struct HighlightStyles {
pub inlay_hint: Option<HighlightStyle>,
- pub inline_completion: Option<InlineCompletionStyles>,
+ pub edit_prediction: Option<EditPredictionStyles>,
}
#[derive(Clone)]
@@ -958,7 +958,7 @@ impl DisplaySnapshot {
language_aware,
HighlightStyles {
inlay_hint: Some(editor_style.inlay_hints_style),
- inline_completion: Some(editor_style.inline_completion_styles),
+ edit_prediction: Some(editor_style.edit_prediction_styles),
},
)
.flat_map(|chunk| {
@@ -969,13 +969,13 @@ impl DisplaySnapshot {
if let Some(chunk_highlight) = chunk.highlight_style {
// For color inlays, blend the color with the editor background
let mut processed_highlight = chunk_highlight;
- if chunk.is_inlay {
- if let Some(inlay_color) = chunk_highlight.color {
- // Only blend if the color has transparency (alpha < 1.0)
- if inlay_color.a < 1.0 {
- let blended_color = editor_style.background.blend(inlay_color);
- processed_highlight.color = Some(blended_color);
- }
+ if chunk.is_inlay
+ && let Some(inlay_color) = chunk_highlight.color
+ {
+ // Only blend if the color has transparency (alpha < 1.0)
+ if inlay_color.a < 1.0 {
+ let blended_color = editor_style.background.blend(inlay_color);
+ processed_highlight.color = Some(blended_color);
}
}
@@ -991,7 +991,7 @@ impl DisplaySnapshot {
if let Some(severity) = chunk.diagnostic_severity.filter(|severity| {
self.diagnostics_max_severity
.into_lsp()
- .map_or(false, |max_severity| severity <= &max_severity)
+ .is_some_and(|max_severity| severity <= &max_severity)
}) {
if chunk.is_unnecessary {
diagnostic_highlight.fade_out = Some(editor_style.unnecessary_code_fade);
@@ -2036,7 +2036,7 @@ pub mod tests {
map.update(cx, |map, cx| {
map.splice_inlays(
&[],
- vec![Inlay::inline_completion(
+ vec![Inlay::edit_prediction(
0,
buffer_snapshot.anchor_after(0),
"\n",
@@ -2351,11 +2351,12 @@ pub mod tests {
.highlight_style
.and_then(|style| style.color)
.map_or(black, |color| color.to_rgb());
- if let Some((last_chunk, last_severity, last_color)) = chunks.last_mut() {
- if *last_severity == chunk.diagnostic_severity && *last_color == color {
- last_chunk.push_str(chunk.text);
- continue;
- }
+ if let Some((last_chunk, last_severity, last_color)) = chunks.last_mut()
+ && *last_severity == chunk.diagnostic_severity
+ && *last_color == color
+ {
+ last_chunk.push_str(chunk.text);
+ continue;
}
chunks.push((chunk.text.to_string(), chunk.diagnostic_severity, color));
@@ -2901,11 +2902,12 @@ pub mod tests {
.syntax_highlight_id
.and_then(|id| id.style(theme)?.color);
let highlight_color = chunk.highlight_style.and_then(|style| style.color);
- if let Some((last_chunk, last_syntax_color, last_highlight_color)) = chunks.last_mut() {
- if syntax_color == *last_syntax_color && highlight_color == *last_highlight_color {
- last_chunk.push_str(chunk.text);
- continue;
- }
+ if let Some((last_chunk, last_syntax_color, last_highlight_color)) = chunks.last_mut()
+ && syntax_color == *last_syntax_color
+ && highlight_color == *last_highlight_color
+ {
+ last_chunk.push_str(chunk.text);
+ continue;
}
chunks.push((chunk.text.to_string(), syntax_color, highlight_color));
}
@@ -22,7 +22,7 @@ use std::{
atomic::{AtomicUsize, Ordering::SeqCst},
},
};
-use sum_tree::{Bias, SumTree, Summary, TreeMap};
+use sum_tree::{Bias, Dimensions, SumTree, Summary, TreeMap};
use text::{BufferId, Edit};
use ui::ElementId;
@@ -290,7 +290,10 @@ pub enum Block {
ExcerptBoundary {
excerpt: ExcerptInfo,
height: u32,
- starts_new_buffer: bool,
+ },
+ BufferHeader {
+ excerpt: ExcerptInfo,
+ height: u32,
},
}
@@ -303,27 +306,37 @@ impl Block {
..
} => BlockId::ExcerptBoundary(next_excerpt.id),
Block::FoldedBuffer { first_excerpt, .. } => BlockId::FoldedBuffer(first_excerpt.id),
+ Block::BufferHeader {
+ excerpt: next_excerpt,
+ ..
+ } => BlockId::ExcerptBoundary(next_excerpt.id),
}
}
pub fn has_height(&self) -> bool {
match self {
Block::Custom(block) => block.height.is_some(),
- Block::ExcerptBoundary { .. } | Block::FoldedBuffer { .. } => true,
+ Block::ExcerptBoundary { .. }
+ | Block::FoldedBuffer { .. }
+ | Block::BufferHeader { .. } => true,
}
}
pub fn height(&self) -> u32 {
match self {
Block::Custom(block) => block.height.unwrap_or(0),
- Block::ExcerptBoundary { height, .. } | Block::FoldedBuffer { height, .. } => *height,
+ Block::ExcerptBoundary { height, .. }
+ | Block::FoldedBuffer { height, .. }
+ | Block::BufferHeader { height, .. } => *height,
}
}
pub fn style(&self) -> BlockStyle {
match self {
Block::Custom(block) => block.style,
- Block::ExcerptBoundary { .. } | Block::FoldedBuffer { .. } => BlockStyle::Sticky,
+ Block::ExcerptBoundary { .. }
+ | Block::FoldedBuffer { .. }
+ | Block::BufferHeader { .. } => BlockStyle::Sticky,
}
}
@@ -332,6 +345,7 @@ impl Block {
Block::Custom(block) => matches!(block.placement, BlockPlacement::Above(_)),
Block::FoldedBuffer { .. } => false,
Block::ExcerptBoundary { .. } => true,
+ Block::BufferHeader { .. } => true,
}
}
@@ -340,6 +354,7 @@ impl Block {
Block::Custom(block) => matches!(block.placement, BlockPlacement::Near(_)),
Block::FoldedBuffer { .. } => false,
Block::ExcerptBoundary { .. } => false,
+ Block::BufferHeader { .. } => false,
}
}
@@ -351,6 +366,7 @@ impl Block {
),
Block::FoldedBuffer { .. } => false,
Block::ExcerptBoundary { .. } => false,
+ Block::BufferHeader { .. } => false,
}
}
@@ -359,6 +375,7 @@ impl Block {
Block::Custom(block) => matches!(block.placement, BlockPlacement::Replace(_)),
Block::FoldedBuffer { .. } => true,
Block::ExcerptBoundary { .. } => false,
+ Block::BufferHeader { .. } => false,
}
}
@@ -367,6 +384,7 @@ impl Block {
Block::Custom(_) => false,
Block::FoldedBuffer { .. } => true,
Block::ExcerptBoundary { .. } => true,
+ Block::BufferHeader { .. } => true,
}
}
@@ -374,9 +392,8 @@ impl Block {
match self {
Block::Custom(_) => false,
Block::FoldedBuffer { .. } => true,
- Block::ExcerptBoundary {
- starts_new_buffer, ..
- } => *starts_new_buffer,
+ Block::ExcerptBoundary { .. } => false,
+ Block::BufferHeader { .. } => true,
}
}
}
@@ -393,14 +410,14 @@ impl Debug for Block {
.field("first_excerpt", &first_excerpt)
.field("height", height)
.finish(),
- Self::ExcerptBoundary {
- starts_new_buffer,
- excerpt,
- height,
- } => f
+ Self::ExcerptBoundary { excerpt, height } => f
.debug_struct("ExcerptBoundary")
.field("excerpt", excerpt)
- .field("starts_new_buffer", starts_new_buffer)
+ .field("height", height)
+ .finish(),
+ Self::BufferHeader { excerpt, height } => f
+ .debug_struct("BufferHeader")
+ .field("excerpt", excerpt)
.field("height", height)
.finish(),
}
@@ -416,7 +433,7 @@ struct TransformSummary {
}
pub struct BlockChunks<'a> {
- transforms: sum_tree::Cursor<'a, Transform, (BlockRow, WrapRow)>,
+ transforms: sum_tree::Cursor<'a, Transform, Dimensions<BlockRow, WrapRow>>,
input_chunks: wrap_map::WrapChunks<'a>,
input_chunk: Chunk<'a>,
output_row: u32,
@@ -426,7 +443,7 @@ pub struct BlockChunks<'a> {
#[derive(Clone)]
pub struct BlockRows<'a> {
- transforms: sum_tree::Cursor<'a, Transform, (BlockRow, WrapRow)>,
+ transforms: sum_tree::Cursor<'a, Transform, Dimensions<BlockRow, WrapRow>>,
input_rows: wrap_map::WrapRows<'a>,
output_row: BlockRow,
started: bool,
@@ -524,27 +541,23 @@ impl BlockMap {
// * Isomorphic transforms that end *at* the start of the edit
// * Below blocks that end at the start of the edit
// However, if we hit a replace block that ends at the start of the edit we want to reconstruct it.
- new_transforms.append(cursor.slice(&old_start, Bias::Left, &()), &());
- if let Some(transform) = cursor.item() {
- if transform.summary.input_rows > 0
- && cursor.end(&()) == old_start
- && transform
- .block
- .as_ref()
- .map_or(true, |b| !b.is_replacement())
- {
- // Preserve the transform (push and next)
- new_transforms.push(transform.clone(), &());
- cursor.next(&());
-
- // Preserve below blocks at end of edit
- while let Some(transform) = cursor.item() {
- if transform.block.as_ref().map_or(false, |b| b.place_below()) {
- new_transforms.push(transform.clone(), &());
- cursor.next(&());
- } else {
- break;
- }
+ new_transforms.append(cursor.slice(&old_start, Bias::Left), &());
+ if let Some(transform) = cursor.item()
+ && transform.summary.input_rows > 0
+ && cursor.end() == old_start
+ && transform.block.as_ref().is_none_or(|b| !b.is_replacement())
+ {
+ // Preserve the transform (push and next)
+ new_transforms.push(transform.clone(), &());
+ cursor.next();
+
+ // Preserve below blocks at end of edit
+ while let Some(transform) = cursor.item() {
+ if transform.block.as_ref().is_some_and(|b| b.place_below()) {
+ new_transforms.push(transform.clone(), &());
+ cursor.next();
+ } else {
+ break;
}
}
}
@@ -579,8 +592,8 @@ impl BlockMap {
let mut new_end = WrapRow(edit.new.end);
loop {
// Seek to the transform starting at or after the end of the edit
- cursor.seek(&old_end, Bias::Left, &());
- cursor.next(&());
+ cursor.seek(&old_end, Bias::Left);
+ cursor.next();
// Extend edit to the end of the discarded transform so it is reconstructed in full
let transform_rows_after_edit = cursor.start().0 - old_end.0;
@@ -592,8 +605,8 @@ impl BlockMap {
if next_edit.old.start <= cursor.start().0 {
old_end = WrapRow(next_edit.old.end);
new_end = WrapRow(next_edit.new.end);
- cursor.seek(&old_end, Bias::Left, &());
- cursor.next(&());
+ cursor.seek(&old_end, Bias::Left);
+ cursor.next();
edits.next();
} else {
break;
@@ -607,8 +620,8 @@ impl BlockMap {
// Discard below blocks at the end of the edit. They'll be reconstructed.
while let Some(transform) = cursor.item() {
- if transform.block.as_ref().map_or(false, |b| b.place_below()) {
- cursor.next(&());
+ if transform.block.as_ref().is_some_and(|b| b.place_below()) {
+ cursor.next();
} else {
break;
}
@@ -657,22 +670,20 @@ impl BlockMap {
.iter()
.filter_map(|block| {
let placement = block.placement.to_wrap_row(wrap_snapshot)?;
- if let BlockPlacement::Above(row) = placement {
- if row < new_start {
- return None;
- }
+ if let BlockPlacement::Above(row) = placement
+ && row < new_start
+ {
+ return None;
}
Some((placement, Block::Custom(block.clone())))
}),
);
- if buffer.show_headers() {
- blocks_in_edit.extend(self.header_and_footer_blocks(
- buffer,
- (start_bound, end_bound),
- wrap_snapshot,
- ));
- }
+ blocks_in_edit.extend(self.header_and_footer_blocks(
+ buffer,
+ (start_bound, end_bound),
+ wrap_snapshot,
+ ));
BlockMap::sort_blocks(&mut blocks_in_edit);
@@ -720,7 +731,7 @@ impl BlockMap {
push_isomorphic(&mut new_transforms, rows_after_last_block, wrap_snapshot);
}
- new_transforms.append(cursor.suffix(&()), &());
+ new_transforms.append(cursor.suffix(), &());
debug_assert_eq!(
new_transforms.summary().input_rows,
wrap_snapshot.max_point().row() + 1
@@ -775,7 +786,7 @@ impl BlockMap {
if self.buffers_with_disabled_headers.contains(&new_buffer_id) {
continue;
}
- if self.folded_buffers.contains(&new_buffer_id) {
+ if self.folded_buffers.contains(&new_buffer_id) && buffer.show_headers() {
let mut last_excerpt_end_row = first_excerpt.end_row;
while let Some(next_boundary) = boundaries.peek() {
@@ -808,20 +819,24 @@ impl BlockMap {
}
}
- if new_buffer_id.is_some() {
+ let starts_new_buffer = new_buffer_id.is_some();
+ let block = if starts_new_buffer && buffer.show_headers() {
height += self.buffer_header_height;
- } else {
+ Block::BufferHeader {
+ excerpt: excerpt_boundary.next,
+ height,
+ }
+ } else if excerpt_boundary.prev.is_some() {
height += self.excerpt_header_height;
- }
-
- return Some((
- BlockPlacement::Above(WrapRow(wrap_row)),
Block::ExcerptBoundary {
excerpt: excerpt_boundary.next,
height,
- starts_new_buffer: new_buffer_id.is_some(),
- },
- ));
+ }
+ } else {
+ continue;
+ };
+
+ return Some((BlockPlacement::Above(WrapRow(wrap_row)), block));
}
})
}
@@ -846,13 +861,25 @@ impl BlockMap {
(
Block::ExcerptBoundary {
excerpt: excerpt_a, ..
+ }
+ | Block::BufferHeader {
+ excerpt: excerpt_a, ..
},
Block::ExcerptBoundary {
excerpt: excerpt_b, ..
+ }
+ | Block::BufferHeader {
+ excerpt: excerpt_b, ..
},
) => Some(excerpt_a.id).cmp(&Some(excerpt_b.id)),
- (Block::ExcerptBoundary { .. }, Block::Custom(_)) => Ordering::Less,
- (Block::Custom(_), Block::ExcerptBoundary { .. }) => Ordering::Greater,
+ (
+ Block::ExcerptBoundary { .. } | Block::BufferHeader { .. },
+ Block::Custom(_),
+ ) => Ordering::Less,
+ (
+ Block::Custom(_),
+ Block::ExcerptBoundary { .. } | Block::BufferHeader { .. },
+ ) => Ordering::Greater,
(Block::Custom(block_a), Block::Custom(block_b)) => block_a
.priority
.cmp(&block_b.priority)
@@ -970,19 +997,19 @@ impl BlockMapReader<'_> {
.unwrap_or(self.wrap_snapshot.max_point().row() + 1),
);
- let mut cursor = self.transforms.cursor::<(WrapRow, BlockRow)>(&());
- cursor.seek(&start_wrap_row, Bias::Left, &());
+ let mut cursor = self.transforms.cursor::<Dimensions<WrapRow, BlockRow>>(&());
+ cursor.seek(&start_wrap_row, Bias::Left);
while let Some(transform) = cursor.item() {
if cursor.start().0 > end_wrap_row {
break;
}
- if let Some(BlockId::Custom(id)) = transform.block.as_ref().map(|block| block.id()) {
- if id == block_id {
- return Some(cursor.start().1);
- }
+ if let Some(BlockId::Custom(id)) = transform.block.as_ref().map(|block| block.id())
+ && id == block_id
+ {
+ return Some(cursor.start().1);
}
- cursor.next(&());
+ cursor.next();
}
None
@@ -1292,21 +1319,21 @@ impl BlockSnapshot {
) -> BlockChunks<'a> {
let max_output_row = cmp::min(rows.end, self.transforms.summary().output_rows);
- let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
- cursor.seek(&BlockRow(rows.start), Bias::Right, &());
+ let mut cursor = self.transforms.cursor::<Dimensions<BlockRow, WrapRow>>(&());
+ cursor.seek(&BlockRow(rows.start), Bias::Right);
let transform_output_start = cursor.start().0.0;
let transform_input_start = cursor.start().1.0;
let mut input_start = transform_input_start;
let mut input_end = transform_input_start;
- if let Some(transform) = cursor.item() {
- if transform.block.is_none() {
- input_start += rows.start - transform_output_start;
- input_end += cmp::min(
- rows.end - transform_output_start,
- transform.summary.input_rows,
- );
- }
+ if let Some(transform) = cursor.item()
+ && transform.block.is_none()
+ {
+ input_start += rows.start - transform_output_start;
+ input_end += cmp::min(
+ rows.end - transform_output_start,
+ transform.summary.input_rows,
+ );
}
BlockChunks {
@@ -1324,12 +1351,12 @@ impl BlockSnapshot {
}
pub(super) fn row_infos(&self, start_row: BlockRow) -> BlockRows<'_> {
- let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
- cursor.seek(&start_row, Bias::Right, &());
- let (output_start, input_start) = cursor.start();
+ let mut cursor = self.transforms.cursor::<Dimensions<BlockRow, WrapRow>>(&());
+ cursor.seek(&start_row, Bias::Right);
+ let Dimensions(output_start, input_start, _) = cursor.start();
let overshoot = if cursor
.item()
- .map_or(false, |transform| transform.block.is_none())
+ .is_some_and(|transform| transform.block.is_none())
{
start_row.0 - output_start.0
} else {
@@ -1346,9 +1373,9 @@ impl BlockSnapshot {
pub fn blocks_in_range(&self, rows: Range<u32>) -> impl Iterator<Item = (u32, &Block)> {
let mut cursor = self.transforms.cursor::<BlockRow>(&());
- cursor.seek(&BlockRow(rows.start), Bias::Left, &());
- while cursor.start().0 < rows.start && cursor.end(&()).0 <= rows.start {
- cursor.next(&());
+ cursor.seek(&BlockRow(rows.start), Bias::Left);
+ while cursor.start().0 < rows.start && cursor.end().0 <= rows.start {
+ cursor.next();
}
std::iter::from_fn(move || {
@@ -1359,15 +1386,15 @@ impl BlockSnapshot {
&& transform
.block
.as_ref()
- .map_or(false, |block| block.height() > 0))
+ .is_some_and(|block| block.height() > 0))
{
break;
}
if let Some(block) = &transform.block {
- cursor.next(&());
+ cursor.next();
return Some((start_row, block));
} else {
- cursor.next(&());
+ cursor.next();
}
}
None
@@ -1377,16 +1404,18 @@ impl BlockSnapshot {
pub fn sticky_header_excerpt(&self, position: f32) -> Option<StickyHeaderExcerpt<'_>> {
let top_row = position as u32;
let mut cursor = self.transforms.cursor::<BlockRow>(&());
- cursor.seek(&BlockRow(top_row), Bias::Right, &());
+ cursor.seek(&BlockRow(top_row), Bias::Right);
while let Some(transform) = cursor.item() {
match &transform.block {
- Some(Block::ExcerptBoundary { excerpt, .. }) => {
+ Some(
+ Block::ExcerptBoundary { excerpt, .. } | Block::BufferHeader { excerpt, .. },
+ ) => {
return Some(StickyHeaderExcerpt { excerpt });
}
Some(block) if block.is_buffer_header() => return None,
_ => {
- cursor.prev(&());
+ cursor.prev();
continue;
}
}
@@ -1414,7 +1443,7 @@ impl BlockSnapshot {
let wrap_row = WrapRow(wrap_point.row());
let mut cursor = self.transforms.cursor::<WrapRow>(&());
- cursor.seek(&wrap_row, Bias::Left, &());
+ cursor.seek(&wrap_row, Bias::Left);
while let Some(transform) = cursor.item() {
if let Some(block) = transform.block.as_ref() {
@@ -1425,7 +1454,7 @@ impl BlockSnapshot {
break;
}
- cursor.next(&());
+ cursor.next();
}
None
@@ -1441,19 +1470,19 @@ impl BlockSnapshot {
}
pub fn longest_row_in_range(&self, range: Range<BlockRow>) -> BlockRow {
- let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
- cursor.seek(&range.start, Bias::Right, &());
+ let mut cursor = self.transforms.cursor::<Dimensions<BlockRow, WrapRow>>(&());
+ cursor.seek(&range.start, Bias::Right);
let mut longest_row = range.start;
let mut longest_row_chars = 0;
if let Some(transform) = cursor.item() {
if transform.block.is_none() {
- let (output_start, input_start) = cursor.start();
+ let Dimensions(output_start, input_start, _) = cursor.start();
let overshoot = range.start.0 - output_start.0;
let wrap_start_row = input_start.0 + overshoot;
let wrap_end_row = cmp::min(
input_start.0 + (range.end.0 - output_start.0),
- cursor.end(&()).1.0,
+ cursor.end().1.0,
);
let summary = self
.wrap_snapshot
@@ -1461,29 +1490,29 @@ impl BlockSnapshot {
longest_row = BlockRow(range.start.0 + summary.longest_row);
longest_row_chars = summary.longest_row_chars;
}
- cursor.next(&());
+ cursor.next();
}
let cursor_start_row = cursor.start().0;
if range.end > cursor_start_row {
- let summary = cursor.summary::<_, TransformSummary>(&range.end, Bias::Right, &());
+ let summary = cursor.summary::<_, TransformSummary>(&range.end, Bias::Right);
if summary.longest_row_chars > longest_row_chars {
longest_row = BlockRow(cursor_start_row.0 + summary.longest_row);
longest_row_chars = summary.longest_row_chars;
}
- if let Some(transform) = cursor.item() {
- if transform.block.is_none() {
- let (output_start, input_start) = cursor.start();
- let overshoot = range.end.0 - output_start.0;
- let wrap_start_row = input_start.0;
- let wrap_end_row = input_start.0 + overshoot;
- let summary = self
- .wrap_snapshot
- .text_summary_for_range(wrap_start_row..wrap_end_row);
- if summary.longest_row_chars > longest_row_chars {
- longest_row = BlockRow(output_start.0 + summary.longest_row);
- }
+ if let Some(transform) = cursor.item()
+ && transform.block.is_none()
+ {
+ let Dimensions(output_start, input_start, _) = cursor.start();
+ let overshoot = range.end.0 - output_start.0;
+ let wrap_start_row = input_start.0;
+ let wrap_end_row = input_start.0 + overshoot;
+ let summary = self
+ .wrap_snapshot
+ .text_summary_for_range(wrap_start_row..wrap_end_row);
+ if summary.longest_row_chars > longest_row_chars {
+ longest_row = BlockRow(output_start.0 + summary.longest_row);
}
}
}
@@ -1492,10 +1521,10 @@ impl BlockSnapshot {
}
pub(super) fn line_len(&self, row: BlockRow) -> u32 {
- let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
- cursor.seek(&BlockRow(row.0), Bias::Right, &());
+ let mut cursor = self.transforms.cursor::<Dimensions<BlockRow, WrapRow>>(&());
+ cursor.seek(&BlockRow(row.0), Bias::Right);
if let Some(transform) = cursor.item() {
- let (output_start, input_start) = cursor.start();
+ let Dimensions(output_start, input_start, _) = cursor.start();
let overshoot = row.0 - output_start.0;
if transform.block.is_some() {
0
@@ -1510,14 +1539,14 @@ impl BlockSnapshot {
}
pub(super) fn is_block_line(&self, row: BlockRow) -> bool {
- let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
- cursor.seek(&row, Bias::Right, &());
- cursor.item().map_or(false, |t| t.block.is_some())
+ let mut cursor = self.transforms.cursor::<Dimensions<BlockRow, WrapRow>>(&());
+ cursor.seek(&row, Bias::Right);
+ cursor.item().is_some_and(|t| t.block.is_some())
}
pub(super) fn is_folded_buffer_header(&self, row: BlockRow) -> bool {
- let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
- cursor.seek(&row, Bias::Right, &());
+ let mut cursor = self.transforms.cursor::<Dimensions<BlockRow, WrapRow>>(&());
+ cursor.seek(&row, Bias::Right);
let Some(transform) = cursor.item() else {
return false;
};
@@ -1528,41 +1557,40 @@ impl BlockSnapshot {
let wrap_point = self
.wrap_snapshot
.make_wrap_point(Point::new(row.0, 0), Bias::Left);
- let mut cursor = self.transforms.cursor::<(WrapRow, BlockRow)>(&());
- cursor.seek(&WrapRow(wrap_point.row()), Bias::Right, &());
- cursor.item().map_or(false, |transform| {
+ let mut cursor = self.transforms.cursor::<Dimensions<WrapRow, BlockRow>>(&());
+ cursor.seek(&WrapRow(wrap_point.row()), Bias::Right);
+ cursor.item().is_some_and(|transform| {
transform
.block
.as_ref()
- .map_or(false, |block| block.is_replacement())
+ .is_some_and(|block| block.is_replacement())
})
}
pub fn clip_point(&self, point: BlockPoint, bias: Bias) -> BlockPoint {
- let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
- cursor.seek(&BlockRow(point.row), Bias::Right, &());
+ let mut cursor = self.transforms.cursor::<Dimensions<BlockRow, WrapRow>>(&());
+ cursor.seek(&BlockRow(point.row), Bias::Right);
let max_input_row = WrapRow(self.transforms.summary().input_rows);
let mut search_left =
- (bias == Bias::Left && cursor.start().1.0 > 0) || cursor.end(&()).1 == max_input_row;
+ (bias == Bias::Left && cursor.start().1.0 > 0) || cursor.end().1 == max_input_row;
let mut reversed = false;
loop {
if let Some(transform) = cursor.item() {
- let (output_start_row, input_start_row) = cursor.start();
- let (output_end_row, input_end_row) = cursor.end(&());
+ let Dimensions(output_start_row, input_start_row, _) = cursor.start();
+ let Dimensions(output_end_row, input_end_row, _) = cursor.end();
let output_start = Point::new(output_start_row.0, 0);
let input_start = Point::new(input_start_row.0, 0);
let input_end = Point::new(input_end_row.0, 0);
match transform.block.as_ref() {
Some(block) => {
- if block.is_replacement() {
- if ((bias == Bias::Left || search_left) && output_start <= point.0)
- || (!search_left && output_start >= point.0)
- {
- return BlockPoint(output_start);
- }
+ if block.is_replacement()
+ && (((bias == Bias::Left || search_left) && output_start <= point.0)
+ || (!search_left && output_start >= point.0))
+ {
+ return BlockPoint(output_start);
}
}
None => {
@@ -1584,28 +1612,28 @@ impl BlockSnapshot {
}
if search_left {
- cursor.prev(&());
+ cursor.prev();
} else {
- cursor.next(&());
+ cursor.next();
}
} else if reversed {
return self.max_point();
} else {
reversed = true;
search_left = !search_left;
- cursor.seek(&BlockRow(point.row), Bias::Right, &());
+ cursor.seek(&BlockRow(point.row), Bias::Right);
}
}
}
pub fn to_block_point(&self, wrap_point: WrapPoint) -> BlockPoint {
- let mut cursor = self.transforms.cursor::<(WrapRow, BlockRow)>(&());
- cursor.seek(&WrapRow(wrap_point.row()), Bias::Right, &());
+ let mut cursor = self.transforms.cursor::<Dimensions<WrapRow, BlockRow>>(&());
+ cursor.seek(&WrapRow(wrap_point.row()), Bias::Right);
if let Some(transform) = cursor.item() {
if transform.block.is_some() {
BlockPoint::new(cursor.start().1.0, 0)
} else {
- let (input_start_row, output_start_row) = cursor.start();
+ let Dimensions(input_start_row, output_start_row, _) = cursor.start();
let input_start = Point::new(input_start_row.0, 0);
let output_start = Point::new(output_start_row.0, 0);
let input_overshoot = wrap_point.0 - input_start;
@@ -1617,8 +1645,8 @@ impl BlockSnapshot {
}
pub fn to_wrap_point(&self, block_point: BlockPoint, bias: Bias) -> WrapPoint {
- let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
- cursor.seek(&BlockRow(block_point.row), Bias::Right, &());
+ let mut cursor = self.transforms.cursor::<Dimensions<BlockRow, WrapRow>>(&());
+ cursor.seek(&BlockRow(block_point.row), Bias::Right);
if let Some(transform) = cursor.item() {
match transform.block.as_ref() {
Some(block) => {
@@ -1630,7 +1658,7 @@ impl BlockSnapshot {
} else if bias == Bias::Left {
WrapPoint::new(cursor.start().1.0, 0)
} else {
- let wrap_row = cursor.end(&()).1.0 - 1;
+ let wrap_row = cursor.end().1.0 - 1;
WrapPoint::new(wrap_row, self.wrap_snapshot.line_len(wrap_row))
}
}
@@ -1650,14 +1678,14 @@ impl BlockChunks<'_> {
/// Go to the next transform
fn advance(&mut self) {
self.input_chunk = Chunk::default();
- self.transforms.next(&());
+ self.transforms.next();
while let Some(transform) = self.transforms.item() {
if transform
.block
.as_ref()
- .map_or(false, |block| block.height() == 0)
+ .is_some_and(|block| block.height() == 0)
{
- self.transforms.next(&());
+ self.transforms.next();
} else {
break;
}
@@ -1666,13 +1694,13 @@ impl BlockChunks<'_> {
if self
.transforms
.item()
- .map_or(false, |transform| transform.block.is_none())
+ .is_some_and(|transform| transform.block.is_none())
{
let start_input_row = self.transforms.start().1.0;
let start_output_row = self.transforms.start().0.0;
if start_output_row < self.max_output_row {
let end_input_row = cmp::min(
- self.transforms.end(&()).1.0,
+ self.transforms.end().1.0,
start_input_row + (self.max_output_row - start_output_row),
);
self.input_chunks.seek(start_input_row..end_input_row);
@@ -1696,7 +1724,7 @@ impl<'a> Iterator for BlockChunks<'a> {
let transform = self.transforms.item()?;
if transform.block.is_some() {
let block_start = self.transforms.start().0.0;
- let mut block_end = self.transforms.end(&()).0.0;
+ let mut block_end = self.transforms.end().0.0;
self.advance();
if self.transforms.item().is_none() {
block_end -= 1;
@@ -1731,7 +1759,7 @@ impl<'a> Iterator for BlockChunks<'a> {
}
}
- let transform_end = self.transforms.end(&()).0.0;
+ let transform_end = self.transforms.end().0.0;
let (prefix_rows, prefix_bytes) =
offset_for_row(self.input_chunk.text, transform_end - self.output_row);
self.output_row += prefix_rows;
@@ -1770,15 +1798,15 @@ impl Iterator for BlockRows<'_> {
self.started = true;
}
- if self.output_row.0 >= self.transforms.end(&()).0.0 {
- self.transforms.next(&());
+ if self.output_row.0 >= self.transforms.end().0.0 {
+ self.transforms.next();
while let Some(transform) = self.transforms.item() {
if transform
.block
.as_ref()
- .map_or(false, |block| block.height() == 0)
+ .is_some_and(|block| block.height() == 0)
{
- self.transforms.next(&());
+ self.transforms.next();
} else {
break;
}
@@ -1788,7 +1816,7 @@ impl Iterator for BlockRows<'_> {
if transform
.block
.as_ref()
- .map_or(true, |block| block.is_replacement())
+ .is_none_or(|block| block.is_replacement())
{
self.input_rows.seek(self.transforms.start().1.0);
}
@@ -2161,7 +2189,7 @@ mod tests {
}
let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
- let (_, inlay_snapshot) = InlayMap::new(multi_buffer_snapshot.clone());
+ let (_, inlay_snapshot) = InlayMap::new(multi_buffer_snapshot);
let (_, fold_snapshot) = FoldMap::new(inlay_snapshot);
let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap());
let (_, wraps_snapshot) = WrapMap::new(tab_snapshot, font, font_size, Some(wrap_width), cx);
@@ -2280,7 +2308,7 @@ mod tests {
new_heights.insert(block_ids[0], 3);
block_map_writer.resize(new_heights);
- let snapshot = block_map.read(wraps_snapshot.clone(), Default::default());
+ let snapshot = block_map.read(wraps_snapshot, Default::default());
// Same height as before, should remain the same
assert_eq!(snapshot.text(), "aaa\n\n\n\n\n\nbbb\nccc\nddd\n\n\n");
}
@@ -2290,8 +2318,6 @@ mod tests {
fn test_blocks_on_wrapped_lines(cx: &mut gpui::TestAppContext) {
cx.update(init_test);
- let _font_id = cx.text_system().font_id(&font("Helvetica")).unwrap();
-
let text = "one two three\nfour five six\nseven eight";
let buffer = cx.update(|cx| MultiBuffer::build_simple(text, cx));
@@ -2367,16 +2393,14 @@ mod tests {
buffer.edit([(Point::new(2, 0)..Point::new(3, 0), "")], None, cx);
buffer.snapshot(cx)
});
- let (inlay_snapshot, inlay_edits) = inlay_map.sync(
- buffer_snapshot.clone(),
- buffer_subscription.consume().into_inner(),
- );
+ let (inlay_snapshot, inlay_edits) =
+ inlay_map.sync(buffer_snapshot, buffer_subscription.consume().into_inner());
let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits);
let (tab_snapshot, tab_edits) = tab_map.sync(fold_snapshot, fold_edits, tab_size);
let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| {
wrap_map.sync(tab_snapshot, tab_edits, cx)
});
- let blocks_snapshot = block_map.read(wraps_snapshot.clone(), wrap_edits);
+ let blocks_snapshot = block_map.read(wraps_snapshot, wrap_edits);
assert_eq!(blocks_snapshot.text(), "line1\n\n\n\n\nline5");
let buffer_snapshot = buffer.update(cx, |buffer, cx| {
@@ -2461,7 +2485,7 @@ mod tests {
// Removing the replace block shows all the hidden blocks again.
let mut writer = block_map.write(wraps_snapshot.clone(), Default::default());
writer.remove(HashSet::from_iter([replace_block_id]));
- let blocks_snapshot = block_map.read(wraps_snapshot.clone(), Default::default());
+ let blocks_snapshot = block_map.read(wraps_snapshot, Default::default());
assert_eq!(
blocks_snapshot.text(),
"\nline1\n\nline2\n\n\nline 2.1\nline2.2\nline 2.3\nline 2.4\n\nline4\n\nline5"
@@ -2800,7 +2824,7 @@ mod tests {
buffer.read_with(cx, |buffer, cx| {
writer.fold_buffers([buffer_id_3], buffer, cx);
});
- let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default());
+ let blocks_snapshot = block_map.read(wrap_snapshot, Patch::default());
let blocks = blocks_snapshot
.blocks_in_range(0..u32::MAX)
.collect::<Vec<_>>();
@@ -2853,7 +2877,7 @@ mod tests {
assert_eq!(buffer_ids.len(), 1);
let buffer_id = buffer_ids[0];
- let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone());
+ let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot);
let (_, fold_snapshot) = FoldMap::new(inlay_snapshot);
let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap());
let (_, wrap_snapshot) =
@@ -2867,7 +2891,7 @@ mod tests {
buffer.read_with(cx, |buffer, cx| {
writer.fold_buffers([buffer_id], buffer, cx);
});
- let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default());
+ let blocks_snapshot = block_map.read(wrap_snapshot, Patch::default());
let blocks = blocks_snapshot
.blocks_in_range(0..u32::MAX)
.collect::<Vec<_>>();
@@ -2875,12 +2899,7 @@ mod tests {
1,
blocks
.iter()
- .filter(|(_, block)| {
- match block {
- Block::FoldedBuffer { .. } => true,
- _ => false,
- }
- })
+ .filter(|(_, block)| { matches!(block, Block::FoldedBuffer { .. }) })
.count(),
"Should have one folded block, producing a header of the second buffer"
);
@@ -3197,9 +3216,9 @@ mod tests {
// so we special case row 0 to assume a leading '\n'.
//
// Linehood is the birthright of strings.
- let mut input_text_lines = input_text.split('\n').enumerate().peekable();
+ let input_text_lines = input_text.split('\n').enumerate().peekable();
let mut block_row = 0;
- while let Some((wrap_row, input_line)) = input_text_lines.next() {
+ for (wrap_row, input_line) in input_text_lines {
let wrap_row = wrap_row as u32;
let multibuffer_row = wraps_snapshot
.to_point(WrapPoint::new(wrap_row, 0), Bias::Left)
@@ -3230,34 +3249,32 @@ mod tests {
let mut is_in_replace_block = false;
if let Some((BlockPlacement::Replace(replace_range), block)) =
sorted_blocks_iter.peek()
+ && wrap_row >= replace_range.start().0
{
- if wrap_row >= replace_range.start().0 {
- is_in_replace_block = true;
+ is_in_replace_block = true;
- if wrap_row == replace_range.start().0 {
- if matches!(block, Block::FoldedBuffer { .. }) {
- expected_buffer_rows.push(None);
- } else {
- expected_buffer_rows
- .push(input_buffer_rows[multibuffer_row as usize]);
- }
+ if wrap_row == replace_range.start().0 {
+ if matches!(block, Block::FoldedBuffer { .. }) {
+ expected_buffer_rows.push(None);
+ } else {
+ expected_buffer_rows.push(input_buffer_rows[multibuffer_row as usize]);
}
+ }
- if wrap_row == replace_range.end().0 {
- expected_block_positions.push((block_row, block.id()));
- let text = "\n".repeat((block.height() - 1) as usize);
- if block_row > 0 {
- expected_text.push('\n');
- }
- expected_text.push_str(&text);
-
- for _ in 1..block.height() {
- expected_buffer_rows.push(None);
- }
- block_row += block.height();
+ if wrap_row == replace_range.end().0 {
+ expected_block_positions.push((block_row, block.id()));
+ let text = "\n".repeat((block.height() - 1) as usize);
+ if block_row > 0 {
+ expected_text.push('\n');
+ }
+ expected_text.push_str(&text);
- sorted_blocks_iter.next();
+ for _ in 1..block.height() {
+ expected_buffer_rows.push(None);
}
+ block_row += block.height();
+
+ sorted_blocks_iter.next();
}
}
@@ -3541,7 +3558,7 @@ mod tests {
..buffer_snapshot.anchor_after(Point::new(1, 0))],
false,
);
- let blocks_snapshot = block_map.read(wraps_snapshot.clone(), Default::default());
+ let blocks_snapshot = block_map.read(wraps_snapshot, Default::default());
assert_eq!(blocks_snapshot.text(), "abc\n\ndef\nghi\njkl\nmno");
}
@@ -52,15 +52,15 @@ impl CreaseSnapshot {
) -> Option<&'a Crease<Anchor>> {
let start = snapshot.anchor_before(Point::new(row.0, 0));
let mut cursor = self.creases.cursor::<ItemSummary>(snapshot);
- cursor.seek(&start, Bias::Left, snapshot);
+ cursor.seek(&start, Bias::Left);
while let Some(item) = cursor.item() {
match Ord::cmp(&item.crease.range().start.to_point(snapshot).row, &row.0) {
- Ordering::Less => cursor.next(snapshot),
+ Ordering::Less => cursor.next(),
Ordering::Equal => {
if item.crease.range().start.is_valid(snapshot) {
return Some(&item.crease);
} else {
- cursor.next(snapshot);
+ cursor.next();
}
}
Ordering::Greater => break,
@@ -76,11 +76,11 @@ impl CreaseSnapshot {
) -> impl 'a + Iterator<Item = &'a Crease<Anchor>> {
let start = snapshot.anchor_before(Point::new(range.start.0, 0));
let mut cursor = self.creases.cursor::<ItemSummary>(snapshot);
- cursor.seek(&start, Bias::Left, snapshot);
+ cursor.seek(&start, Bias::Left);
std::iter::from_fn(move || {
while let Some(item) = cursor.item() {
- cursor.next(snapshot);
+ cursor.next();
let crease_range = item.crease.range();
let crease_start = crease_range.start.to_point(snapshot);
let crease_end = crease_range.end.to_point(snapshot);
@@ -102,13 +102,13 @@ impl CreaseSnapshot {
let mut cursor = self.creases.cursor::<ItemSummary>(snapshot);
let mut results = Vec::new();
- cursor.next(snapshot);
+ cursor.next();
while let Some(item) = cursor.item() {
let crease_range = item.crease.range();
let start_point = crease_range.start.to_point(snapshot);
let end_point = crease_range.end.to_point(snapshot);
results.push((item.id, start_point..end_point));
- cursor.next(snapshot);
+ cursor.next();
}
results
@@ -298,7 +298,7 @@ impl CreaseMap {
let mut cursor = self.snapshot.creases.cursor::<ItemSummary>(snapshot);
for crease in creases {
let crease_range = crease.range().clone();
- new_creases.append(cursor.slice(&crease_range, Bias::Left, snapshot), snapshot);
+ new_creases.append(cursor.slice(&crease_range, Bias::Left), snapshot);
let id = self.next_id;
self.next_id.0 += 1;
@@ -306,7 +306,7 @@ impl CreaseMap {
new_creases.push(CreaseItem { crease, id }, snapshot);
new_ids.push(id);
}
- new_creases.append(cursor.suffix(snapshot), snapshot);
+ new_creases.append(cursor.suffix(), snapshot);
new_creases
};
new_ids
@@ -332,9 +332,9 @@ impl CreaseMap {
let mut cursor = self.snapshot.creases.cursor::<ItemSummary>(snapshot);
for (id, range) in &removals {
- new_creases.append(cursor.slice(range, Bias::Left, snapshot), snapshot);
+ new_creases.append(cursor.slice(range, Bias::Left), snapshot);
while let Some(item) = cursor.item() {
- cursor.next(snapshot);
+ cursor.next();
if item.id == *id {
break;
} else {
@@ -343,7 +343,7 @@ impl CreaseMap {
}
}
- new_creases.append(cursor.suffix(snapshot), snapshot);
+ new_creases.append(cursor.suffix(), snapshot);
new_creases
};
@@ -77,7 +77,7 @@ fn create_highlight_endpoints(
let ranges = &text_highlights.1;
let start_ix = match ranges.binary_search_by(|probe| {
- let cmp = probe.end.cmp(&start, &buffer);
+ let cmp = probe.end.cmp(&start, buffer);
if cmp.is_gt() {
cmp::Ordering::Greater
} else {
@@ -88,18 +88,18 @@ fn create_highlight_endpoints(
};
for range in &ranges[start_ix..] {
- if range.start.cmp(&end, &buffer).is_ge() {
+ if range.start.cmp(&end, buffer).is_ge() {
break;
}
highlight_endpoints.push(HighlightEndpoint {
- offset: range.start.to_offset(&buffer),
+ offset: range.start.to_offset(buffer),
is_start: true,
tag,
style,
});
highlight_endpoints.push(HighlightEndpoint {
- offset: range.end.to_offset(&buffer),
+ offset: range.end.to_offset(buffer),
is_start: false,
tag,
style,
@@ -17,7 +17,7 @@ use std::{
sync::Arc,
usize,
};
-use sum_tree::{Bias, Cursor, FilterCursor, SumTree, Summary, TreeMap};
+use sum_tree::{Bias, Cursor, Dimensions, FilterCursor, SumTree, Summary, TreeMap};
use ui::IntoElement as _;
use util::post_inc;
@@ -98,8 +98,10 @@ impl FoldPoint {
}
pub fn to_inlay_point(self, snapshot: &FoldSnapshot) -> InlayPoint {
- let mut cursor = snapshot.transforms.cursor::<(FoldPoint, InlayPoint)>(&());
- cursor.seek(&self, Bias::Right, &());
+ let mut cursor = snapshot
+ .transforms
+ .cursor::<Dimensions<FoldPoint, InlayPoint>>(&());
+ cursor.seek(&self, Bias::Right);
let overshoot = self.0 - cursor.start().0.0;
InlayPoint(cursor.start().1.0 + overshoot)
}
@@ -107,8 +109,8 @@ impl FoldPoint {
pub fn to_offset(self, snapshot: &FoldSnapshot) -> FoldOffset {
let mut cursor = snapshot
.transforms
- .cursor::<(FoldPoint, TransformSummary)>(&());
- cursor.seek(&self, Bias::Right, &());
+ .cursor::<Dimensions<FoldPoint, TransformSummary>>(&());
+ cursor.seek(&self, Bias::Right);
let overshoot = self.0 - cursor.start().1.output.lines;
let mut offset = cursor.start().1.output.len;
if !overshoot.is_zero() {
@@ -187,10 +189,10 @@ impl FoldMapWriter<'_> {
width: None,
},
);
- new_tree.append(cursor.slice(&fold.range, Bias::Right, buffer), buffer);
+ new_tree.append(cursor.slice(&fold.range, Bias::Right), buffer);
new_tree.push(fold, buffer);
}
- new_tree.append(cursor.suffix(buffer), buffer);
+ new_tree.append(cursor.suffix(), buffer);
new_tree
};
@@ -252,7 +254,7 @@ impl FoldMapWriter<'_> {
fold_ixs_to_delete.push(*folds_cursor.start());
self.0.snapshot.fold_metadata_by_id.remove(&fold.id);
}
- folds_cursor.next(buffer);
+ folds_cursor.next();
}
}
@@ -263,10 +265,10 @@ impl FoldMapWriter<'_> {
let mut cursor = self.0.snapshot.folds.cursor::<usize>(buffer);
let mut folds = SumTree::new(buffer);
for fold_ix in fold_ixs_to_delete {
- folds.append(cursor.slice(&fold_ix, Bias::Right, buffer), buffer);
- cursor.next(buffer);
+ folds.append(cursor.slice(&fold_ix, Bias::Right), buffer);
+ cursor.next();
}
- folds.append(cursor.suffix(buffer), buffer);
+ folds.append(cursor.suffix(), buffer);
folds
};
@@ -287,25 +289,25 @@ impl FoldMapWriter<'_> {
let ChunkRendererId::Fold(id) = id else {
continue;
};
- if let Some(metadata) = self.0.snapshot.fold_metadata_by_id.get(&id).cloned() {
- if Some(new_width) != metadata.width {
- let buffer_start = metadata.range.start.to_offset(buffer);
- let buffer_end = metadata.range.end.to_offset(buffer);
- let inlay_range = inlay_snapshot.to_inlay_offset(buffer_start)
- ..inlay_snapshot.to_inlay_offset(buffer_end);
- edits.push(InlayEdit {
- old: inlay_range.clone(),
- new: inlay_range.clone(),
- });
+ if let Some(metadata) = self.0.snapshot.fold_metadata_by_id.get(&id).cloned()
+ && Some(new_width) != metadata.width
+ {
+ let buffer_start = metadata.range.start.to_offset(buffer);
+ let buffer_end = metadata.range.end.to_offset(buffer);
+ let inlay_range = inlay_snapshot.to_inlay_offset(buffer_start)
+ ..inlay_snapshot.to_inlay_offset(buffer_end);
+ edits.push(InlayEdit {
+ old: inlay_range.clone(),
+ new: inlay_range.clone(),
+ });
- self.0.snapshot.fold_metadata_by_id.insert(
- id,
- FoldMetadata {
- range: metadata.range,
- width: Some(new_width),
- },
- );
- }
+ self.0.snapshot.fold_metadata_by_id.insert(
+ id,
+ FoldMetadata {
+ range: metadata.range,
+ width: Some(new_width),
+ },
+ );
}
}
@@ -412,28 +414,28 @@ impl FoldMap {
let mut new_transforms = SumTree::<Transform>::default();
let mut cursor = self.snapshot.transforms.cursor::<InlayOffset>(&());
- cursor.seek(&InlayOffset(0), Bias::Right, &());
+ cursor.seek(&InlayOffset(0), Bias::Right);
while let Some(mut edit) = inlay_edits_iter.next() {
- if let Some(item) = cursor.item() {
- if !item.is_fold() {
- new_transforms.update_last(
- |transform| {
- if !transform.is_fold() {
- transform.summary.add_summary(&item.summary, &());
- cursor.next(&());
- }
- },
- &(),
- );
- }
+ if let Some(item) = cursor.item()
+ && !item.is_fold()
+ {
+ new_transforms.update_last(
+ |transform| {
+ if !transform.is_fold() {
+ transform.summary.add_summary(&item.summary, &());
+ cursor.next();
+ }
+ },
+ &(),
+ );
}
- new_transforms.append(cursor.slice(&edit.old.start, Bias::Left, &()), &());
+ new_transforms.append(cursor.slice(&edit.old.start, Bias::Left), &());
edit.new.start -= edit.old.start - *cursor.start();
edit.old.start = *cursor.start();
- cursor.seek(&edit.old.end, Bias::Right, &());
- cursor.next(&());
+ cursor.seek(&edit.old.end, Bias::Right);
+ cursor.next();
let mut delta = edit.new_len().0 as isize - edit.old_len().0 as isize;
loop {
@@ -449,8 +451,8 @@ impl FoldMap {
if next_edit.old.end >= edit.old.end {
edit.old.end = next_edit.old.end;
- cursor.seek(&edit.old.end, Bias::Right, &());
- cursor.next(&());
+ cursor.seek(&edit.old.end, Bias::Right);
+ cursor.next();
}
} else {
break;
@@ -467,11 +469,7 @@ impl FoldMap {
.snapshot
.folds
.cursor::<FoldRange>(&inlay_snapshot.buffer);
- folds_cursor.seek(
- &FoldRange(anchor..Anchor::max()),
- Bias::Left,
- &inlay_snapshot.buffer,
- );
+ folds_cursor.seek(&FoldRange(anchor..Anchor::max()), Bias::Left);
let mut folds = iter::from_fn({
let inlay_snapshot = &inlay_snapshot;
@@ -485,7 +483,7 @@ impl FoldMap {
..inlay_snapshot.to_inlay_offset(buffer_end),
)
});
- folds_cursor.next(&inlay_snapshot.buffer);
+ folds_cursor.next();
item
}
})
@@ -493,14 +491,14 @@ impl FoldMap {
while folds
.peek()
- .map_or(false, |(_, fold_range)| fold_range.start < edit.new.end)
+ .is_some_and(|(_, fold_range)| fold_range.start < edit.new.end)
{
let (fold, mut fold_range) = folds.next().unwrap();
let sum = new_transforms.summary();
assert!(fold_range.start.0 >= sum.input.len);
- while folds.peek().map_or(false, |(next_fold, next_fold_range)| {
+ while folds.peek().is_some_and(|(next_fold, next_fold_range)| {
next_fold_range.start < fold_range.end
|| (next_fold_range.start == fold_range.end
&& fold.placeholder.merge_adjacent
@@ -558,7 +556,7 @@ impl FoldMap {
}
}
- new_transforms.append(cursor.suffix(&()), &());
+ new_transforms.append(cursor.suffix(), &());
if new_transforms.is_empty() {
let text_summary = inlay_snapshot.text_summary();
push_isomorphic(&mut new_transforms, text_summary);
@@ -571,35 +569,36 @@ impl FoldMap {
let mut old_transforms = self
.snapshot
.transforms
- .cursor::<(InlayOffset, FoldOffset)>(&());
- let mut new_transforms = new_transforms.cursor::<(InlayOffset, FoldOffset)>(&());
+ .cursor::<Dimensions<InlayOffset, FoldOffset>>(&());
+ let mut new_transforms =
+ new_transforms.cursor::<Dimensions<InlayOffset, FoldOffset>>(&());
for mut edit in inlay_edits {
- old_transforms.seek(&edit.old.start, Bias::Left, &());
- if old_transforms.item().map_or(false, |t| t.is_fold()) {
+ old_transforms.seek(&edit.old.start, Bias::Left);
+ if old_transforms.item().is_some_and(|t| t.is_fold()) {
edit.old.start = old_transforms.start().0;
}
let old_start =
old_transforms.start().1.0 + (edit.old.start - old_transforms.start().0).0;
- old_transforms.seek_forward(&edit.old.end, Bias::Right, &());
- if old_transforms.item().map_or(false, |t| t.is_fold()) {
- old_transforms.next(&());
+ old_transforms.seek_forward(&edit.old.end, Bias::Right);
+ if old_transforms.item().is_some_and(|t| t.is_fold()) {
+ old_transforms.next();
edit.old.end = old_transforms.start().0;
}
let old_end =
old_transforms.start().1.0 + (edit.old.end - old_transforms.start().0).0;
- new_transforms.seek(&edit.new.start, Bias::Left, &());
- if new_transforms.item().map_or(false, |t| t.is_fold()) {
+ new_transforms.seek(&edit.new.start, Bias::Left);
+ if new_transforms.item().is_some_and(|t| t.is_fold()) {
edit.new.start = new_transforms.start().0;
}
let new_start =
new_transforms.start().1.0 + (edit.new.start - new_transforms.start().0).0;
- new_transforms.seek_forward(&edit.new.end, Bias::Right, &());
- if new_transforms.item().map_or(false, |t| t.is_fold()) {
- new_transforms.next(&());
+ new_transforms.seek_forward(&edit.new.end, Bias::Right);
+ if new_transforms.item().is_some_and(|t| t.is_fold()) {
+ new_transforms.next();
edit.new.end = new_transforms.start().0;
}
let new_end =
@@ -655,11 +654,13 @@ impl FoldSnapshot {
pub fn text_summary_for_range(&self, range: Range<FoldPoint>) -> TextSummary {
let mut summary = TextSummary::default();
- let mut cursor = self.transforms.cursor::<(FoldPoint, InlayPoint)>(&());
- cursor.seek(&range.start, Bias::Right, &());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<FoldPoint, InlayPoint>>(&());
+ cursor.seek(&range.start, Bias::Right);
if let Some(transform) = cursor.item() {
let start_in_transform = range.start.0 - cursor.start().0.0;
- let end_in_transform = cmp::min(range.end, cursor.end(&()).0).0 - cursor.start().0.0;
+ let end_in_transform = cmp::min(range.end, cursor.end().0).0 - cursor.start().0.0;
if let Some(placeholder) = transform.placeholder.as_ref() {
summary = TextSummary::from(
&placeholder.text
@@ -678,10 +679,10 @@ impl FoldSnapshot {
}
}
- if range.end > cursor.end(&()).0 {
- cursor.next(&());
+ if range.end > cursor.end().0 {
+ cursor.next();
summary += &cursor
- .summary::<_, TransformSummary>(&range.end, Bias::Right, &())
+ .summary::<_, TransformSummary>(&range.end, Bias::Right)
.output;
if let Some(transform) = cursor.item() {
let end_in_transform = range.end.0 - cursor.start().0.0;
@@ -704,20 +705,19 @@ impl FoldSnapshot {
}
pub fn to_fold_point(&self, point: InlayPoint, bias: Bias) -> FoldPoint {
- let mut cursor = self.transforms.cursor::<(InlayPoint, FoldPoint)>(&());
- cursor.seek(&point, Bias::Right, &());
- if cursor.item().map_or(false, |t| t.is_fold()) {
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<InlayPoint, FoldPoint>>(&());
+ cursor.seek(&point, Bias::Right);
+ if cursor.item().is_some_and(|t| t.is_fold()) {
if bias == Bias::Left || point == cursor.start().0 {
cursor.start().1
} else {
- cursor.end(&()).1
+ cursor.end().1
}
} else {
let overshoot = point.0 - cursor.start().0.0;
- FoldPoint(cmp::min(
- cursor.start().1.0 + overshoot,
- cursor.end(&()).1.0,
- ))
+ FoldPoint(cmp::min(cursor.start().1.0 + overshoot, cursor.end().1.0))
}
}
@@ -741,8 +741,10 @@ impl FoldSnapshot {
}
let fold_point = FoldPoint::new(start_row, 0);
- let mut cursor = self.transforms.cursor::<(FoldPoint, InlayPoint)>(&());
- cursor.seek(&fold_point, Bias::Left, &());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<FoldPoint, InlayPoint>>(&());
+ cursor.seek(&fold_point, Bias::Left);
let overshoot = fold_point.0 - cursor.start().0.0;
let inlay_point = InlayPoint(cursor.start().1.0 + overshoot);
@@ -773,7 +775,7 @@ impl FoldSnapshot {
let mut folds = intersecting_folds(&self.inlay_snapshot, &self.folds, range, false);
iter::from_fn(move || {
let item = folds.item();
- folds.next(&self.inlay_snapshot.buffer);
+ folds.next();
item
})
}
@@ -785,8 +787,8 @@ impl FoldSnapshot {
let buffer_offset = offset.to_offset(&self.inlay_snapshot.buffer);
let inlay_offset = self.inlay_snapshot.to_inlay_offset(buffer_offset);
let mut cursor = self.transforms.cursor::<InlayOffset>(&());
- cursor.seek(&inlay_offset, Bias::Right, &());
- cursor.item().map_or(false, |t| t.placeholder.is_some())
+ cursor.seek(&inlay_offset, Bias::Right);
+ cursor.item().is_some_and(|t| t.placeholder.is_some())
}
pub fn is_line_folded(&self, buffer_row: MultiBufferRow) -> bool {
@@ -794,7 +796,7 @@ impl FoldSnapshot {
.inlay_snapshot
.to_inlay_point(Point::new(buffer_row.0, 0));
let mut cursor = self.transforms.cursor::<InlayPoint>(&());
- cursor.seek(&inlay_point, Bias::Right, &());
+ cursor.seek(&inlay_point, Bias::Right);
loop {
match cursor.item() {
Some(transform) => {
@@ -808,11 +810,11 @@ impl FoldSnapshot {
None => return false,
}
- if cursor.end(&()).row() == inlay_point.row() {
- cursor.next(&());
+ if cursor.end().row() == inlay_point.row() {
+ cursor.next();
} else {
inlay_point.0 += Point::new(1, 0);
- cursor.seek(&inlay_point, Bias::Right, &());
+ cursor.seek(&inlay_point, Bias::Right);
}
}
}
@@ -823,19 +825,21 @@ impl FoldSnapshot {
language_aware: bool,
highlights: Highlights<'a>,
) -> FoldChunks<'a> {
- let mut transform_cursor = self.transforms.cursor::<(FoldOffset, InlayOffset)>(&());
- transform_cursor.seek(&range.start, Bias::Right, &());
+ let mut transform_cursor = self
+ .transforms
+ .cursor::<Dimensions<FoldOffset, InlayOffset>>(&());
+ transform_cursor.seek(&range.start, Bias::Right);
let inlay_start = {
let overshoot = range.start.0 - transform_cursor.start().0.0;
transform_cursor.start().1 + InlayOffset(overshoot)
};
- let transform_end = transform_cursor.end(&());
+ let transform_end = transform_cursor.end();
let inlay_end = if transform_cursor
.item()
- .map_or(true, |transform| transform.is_fold())
+ .is_none_or(|transform| transform.is_fold())
{
inlay_start
} else if range.end < transform_end.0 {
@@ -878,15 +882,17 @@ impl FoldSnapshot {
}
pub fn clip_point(&self, point: FoldPoint, bias: Bias) -> FoldPoint {
- let mut cursor = self.transforms.cursor::<(FoldPoint, InlayPoint)>(&());
- cursor.seek(&point, Bias::Right, &());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<FoldPoint, InlayPoint>>(&());
+ cursor.seek(&point, Bias::Right);
if let Some(transform) = cursor.item() {
let transform_start = cursor.start().0.0;
if transform.placeholder.is_some() {
if point.0 == transform_start || matches!(bias, Bias::Left) {
FoldPoint(transform_start)
} else {
- FoldPoint(cursor.end(&()).0.0)
+ FoldPoint(cursor.end().0.0)
}
} else {
let overshoot = InlayPoint(point.0 - transform_start);
@@ -945,7 +951,7 @@ fn intersecting_folds<'a>(
start_cmp == Ordering::Less && end_cmp == Ordering::Greater
}
});
- cursor.next(buffer);
+ cursor.next();
cursor
}
@@ -1203,7 +1209,7 @@ impl<'a> sum_tree::Dimension<'a, FoldSummary> for usize {
#[derive(Clone)]
pub struct FoldRows<'a> {
- cursor: Cursor<'a, Transform, (FoldPoint, InlayPoint)>,
+ cursor: Cursor<'a, Transform, Dimensions<FoldPoint, InlayPoint>>,
input_rows: InlayBufferRows<'a>,
fold_point: FoldPoint,
}
@@ -1211,7 +1217,7 @@ pub struct FoldRows<'a> {
impl FoldRows<'_> {
pub(crate) fn seek(&mut self, row: u32) {
let fold_point = FoldPoint::new(row, 0);
- self.cursor.seek(&fold_point, Bias::Left, &());
+ self.cursor.seek(&fold_point, Bias::Left);
let overshoot = fold_point.0 - self.cursor.start().0.0;
let inlay_point = InlayPoint(self.cursor.start().1.0 + overshoot);
self.input_rows.seek(inlay_point.row());
@@ -1224,8 +1230,8 @@ impl Iterator for FoldRows<'_> {
fn next(&mut self) -> Option<Self::Item> {
let mut traversed_fold = false;
- while self.fold_point > self.cursor.end(&()).0 {
- self.cursor.next(&());
+ while self.fold_point > self.cursor.end().0 {
+ self.cursor.next();
traversed_fold = true;
if self.cursor.item().is_none() {
break;
@@ -1320,7 +1326,7 @@ impl DerefMut for ChunkRendererContext<'_, '_> {
}
pub struct FoldChunks<'a> {
- transform_cursor: Cursor<'a, Transform, (FoldOffset, InlayOffset)>,
+ transform_cursor: Cursor<'a, Transform, Dimensions<FoldOffset, InlayOffset>>,
inlay_chunks: InlayChunks<'a>,
inlay_chunk: Option<(InlayOffset, InlayChunk<'a>)>,
inlay_offset: InlayOffset,
@@ -1330,19 +1336,19 @@ pub struct FoldChunks<'a> {
impl FoldChunks<'_> {
pub(crate) fn seek(&mut self, range: Range<FoldOffset>) {
- self.transform_cursor.seek(&range.start, Bias::Right, &());
+ self.transform_cursor.seek(&range.start, Bias::Right);
let inlay_start = {
let overshoot = range.start.0 - self.transform_cursor.start().0.0;
self.transform_cursor.start().1 + InlayOffset(overshoot)
};
- let transform_end = self.transform_cursor.end(&());
+ let transform_end = self.transform_cursor.end();
let inlay_end = if self
.transform_cursor
.item()
- .map_or(true, |transform| transform.is_fold())
+ .is_none_or(|transform| transform.is_fold())
{
inlay_start
} else if range.end < transform_end.0 {
@@ -1376,10 +1382,10 @@ impl<'a> Iterator for FoldChunks<'a> {
self.inlay_chunk.take();
self.inlay_offset += InlayOffset(transform.summary.input.len);
- while self.inlay_offset >= self.transform_cursor.end(&()).1
+ while self.inlay_offset >= self.transform_cursor.end().1
&& self.transform_cursor.item().is_some()
{
- self.transform_cursor.next(&());
+ self.transform_cursor.next();
}
self.output_offset.0 += placeholder.text.len();
@@ -1396,7 +1402,7 @@ impl<'a> Iterator for FoldChunks<'a> {
&& self.inlay_chunks.offset() != self.inlay_offset
{
let transform_start = self.transform_cursor.start();
- let transform_end = self.transform_cursor.end(&());
+ let transform_end = self.transform_cursor.end();
let inlay_end = if self.max_output_offset < transform_end.0 {
let overshoot = self.max_output_offset.0 - transform_start.0.0;
transform_start.1 + InlayOffset(overshoot)
@@ -1417,14 +1423,14 @@ impl<'a> Iterator for FoldChunks<'a> {
if let Some((buffer_chunk_start, mut inlay_chunk)) = self.inlay_chunk.clone() {
let chunk = &mut inlay_chunk.chunk;
let buffer_chunk_end = buffer_chunk_start + InlayOffset(chunk.text.len());
- let transform_end = self.transform_cursor.end(&()).1;
+ let transform_end = self.transform_cursor.end().1;
let chunk_end = buffer_chunk_end.min(transform_end);
chunk.text = &chunk.text
[(self.inlay_offset - buffer_chunk_start).0..(chunk_end - buffer_chunk_start).0];
if chunk_end == transform_end {
- self.transform_cursor.next(&());
+ self.transform_cursor.next();
} else if chunk_end == buffer_chunk_end {
self.inlay_chunk.take();
}
@@ -1455,9 +1461,9 @@ impl FoldOffset {
pub fn to_point(self, snapshot: &FoldSnapshot) -> FoldPoint {
let mut cursor = snapshot
.transforms
- .cursor::<(FoldOffset, TransformSummary)>(&());
- cursor.seek(&self, Bias::Right, &());
- let overshoot = if cursor.item().map_or(true, |t| t.is_fold()) {
+ .cursor::<Dimensions<FoldOffset, TransformSummary>>(&());
+ cursor.seek(&self, Bias::Right);
+ let overshoot = if cursor.item().is_none_or(|t| t.is_fold()) {
Point::new(0, (self.0 - cursor.start().0.0) as u32)
} else {
let inlay_offset = cursor.start().1.input.len + self.0 - cursor.start().0.0;
@@ -1469,8 +1475,10 @@ impl FoldOffset {
#[cfg(test)]
pub fn to_inlay_offset(self, snapshot: &FoldSnapshot) -> InlayOffset {
- let mut cursor = snapshot.transforms.cursor::<(FoldOffset, InlayOffset)>(&());
- cursor.seek(&self, Bias::Right, &());
+ let mut cursor = snapshot
+ .transforms
+ .cursor::<Dimensions<FoldOffset, InlayOffset>>(&());
+ cursor.seek(&self, Bias::Right);
let overshoot = self.0 - cursor.start().0.0;
InlayOffset(cursor.start().1.0 + overshoot)
}
@@ -1549,7 +1557,7 @@ mod tests {
let buffer = MultiBuffer::build_simple(&sample_text(5, 6, 'a'), cx);
let subscription = buffer.update(cx, |buffer, _| buffer.subscribe());
let buffer_snapshot = buffer.read(cx).snapshot(cx);
- let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone());
+ let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot);
let mut map = FoldMap::new(inlay_snapshot.clone()).0;
let (mut writer, _, _) = map.write(inlay_snapshot, vec![]);
@@ -1628,7 +1636,7 @@ mod tests {
let buffer = MultiBuffer::build_simple("abcdefghijkl", cx);
let subscription = buffer.update(cx, |buffer, _| buffer.subscribe());
let buffer_snapshot = buffer.read(cx).snapshot(cx);
- let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone());
+ let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot);
{
let mut map = FoldMap::new(inlay_snapshot.clone()).0;
@@ -1704,7 +1712,7 @@ mod tests {
let buffer = MultiBuffer::build_simple(&sample_text(5, 6, 'a'), cx);
let subscription = buffer.update(cx, |buffer, _| buffer.subscribe());
let buffer_snapshot = buffer.read(cx).snapshot(cx);
- let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone());
+ let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot);
let mut map = FoldMap::new(inlay_snapshot.clone()).0;
let (mut writer, _, _) = map.write(inlay_snapshot.clone(), vec![]);
@@ -1712,7 +1720,7 @@ mod tests {
(Point::new(0, 2)..Point::new(2, 2), FoldPlaceholder::test()),
(Point::new(3, 1)..Point::new(4, 1), FoldPlaceholder::test()),
]);
- let (snapshot, _) = map.read(inlay_snapshot.clone(), vec![]);
+ let (snapshot, _) = map.read(inlay_snapshot, vec![]);
assert_eq!(snapshot.text(), "aa⋯cccc\nd⋯eeeee");
let buffer_snapshot = buffer.update(cx, |buffer, cx| {
@@ -1739,7 +1747,7 @@ mod tests {
(Point::new(1, 2)..Point::new(3, 2), FoldPlaceholder::test()),
(Point::new(3, 1)..Point::new(4, 1), FoldPlaceholder::test()),
]);
- let (snapshot, _) = map.read(inlay_snapshot.clone(), vec![]);
+ let (snapshot, _) = map.read(inlay_snapshot, vec![]);
let fold_ranges = snapshot
.folds_in_range(Point::new(1, 0)..Point::new(1, 3))
.map(|fold| {
@@ -1774,7 +1782,7 @@ mod tests {
let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone());
let mut map = FoldMap::new(inlay_snapshot.clone()).0;
- let (mut initial_snapshot, _) = map.read(inlay_snapshot.clone(), vec![]);
+ let (mut initial_snapshot, _) = map.read(inlay_snapshot, vec![]);
let mut snapshot_edits = Vec::new();
let mut next_inlay_id = 0;
@@ -10,7 +10,7 @@ use std::{
ops::{Add, AddAssign, Range, Sub, SubAssign},
sync::Arc,
};
-use sum_tree::{Bias, Cursor, SumTree};
+use sum_tree::{Bias, Cursor, Dimensions, SumTree};
use text::{Patch, Rope};
use ui::{ActiveTheme, IntoElement as _, ParentElement as _, Styled as _, div};
@@ -48,16 +48,16 @@ pub struct Inlay {
impl Inlay {
pub fn hint(id: usize, position: Anchor, hint: &project::InlayHint) -> Self {
let mut text = hint.text();
- if hint.padding_right && !text.ends_with(' ') {
- text.push(' ');
+ if hint.padding_right && text.reversed_chars_at(text.len()).next() != Some(' ') {
+ text.push(" ");
}
- if hint.padding_left && !text.starts_with(' ') {
- text.insert(0, ' ');
+ if hint.padding_left && text.chars_at(0).next() != Some(' ') {
+ text.push_front(" ");
}
Self {
id: InlayId::Hint(id),
position,
- text: text.into(),
+ text,
color: None,
}
}
@@ -81,9 +81,9 @@ impl Inlay {
}
}
- pub fn inline_completion<T: Into<Rope>>(id: usize, position: Anchor, text: T) -> Self {
+ pub fn edit_prediction<T: Into<Rope>>(id: usize, position: Anchor, text: T) -> Self {
Self {
- id: InlayId::InlineCompletion(id),
+ id: InlayId::EditPrediction(id),
position,
text: text.into(),
color: None,
@@ -235,14 +235,14 @@ impl<'a> sum_tree::Dimension<'a, TransformSummary> for Point {
#[derive(Clone)]
pub struct InlayBufferRows<'a> {
- transforms: Cursor<'a, Transform, (InlayPoint, Point)>,
+ transforms: Cursor<'a, Transform, Dimensions<InlayPoint, Point>>,
buffer_rows: MultiBufferRows<'a>,
inlay_row: u32,
max_buffer_row: MultiBufferRow,
}
pub struct InlayChunks<'a> {
- transforms: Cursor<'a, Transform, (InlayOffset, usize)>,
+ transforms: Cursor<'a, Transform, Dimensions<InlayOffset, usize>>,
buffer_chunks: CustomHighlightsChunks<'a>,
buffer_chunk: Option<Chunk<'a>>,
inlay_chunks: Option<text::Chunks<'a>>,
@@ -263,7 +263,7 @@ pub struct InlayChunk<'a> {
impl InlayChunks<'_> {
pub fn seek(&mut self, new_range: Range<InlayOffset>) {
- self.transforms.seek(&new_range.start, Bias::Right, &());
+ self.transforms.seek(&new_range.start, Bias::Right);
let buffer_range = self.snapshot.to_buffer_offset(new_range.start)
..self.snapshot.to_buffer_offset(new_range.end);
@@ -296,12 +296,12 @@ impl<'a> Iterator for InlayChunks<'a> {
*chunk = self.buffer_chunks.next().unwrap();
}
- let desired_bytes = self.transforms.end(&()).0.0 - self.output_offset.0;
+ let desired_bytes = self.transforms.end().0.0 - self.output_offset.0;
// If we're already at the transform boundary, skip to the next transform
if desired_bytes == 0 {
self.inlay_chunks = None;
- self.transforms.next(&());
+ self.transforms.next();
return self.next();
}
@@ -340,15 +340,13 @@ impl<'a> Iterator for InlayChunks<'a> {
let mut renderer = None;
let mut highlight_style = match inlay.id {
- InlayId::InlineCompletion(_) => {
- self.highlight_styles.inline_completion.map(|s| {
- if inlay.text.chars().all(|c| c.is_whitespace()) {
- s.whitespace
- } else {
- s.insertion
- }
- })
- }
+ InlayId::EditPrediction(_) => self.highlight_styles.edit_prediction.map(|s| {
+ if inlay.text.chars().all(|c| c.is_whitespace()) {
+ s.whitespace
+ } else {
+ s.insertion
+ }
+ }),
InlayId::Hint(_) => self.highlight_styles.inlay_hint,
InlayId::DebuggerValue(_) => self.highlight_styles.inlay_hint,
InlayId::Color(_) => {
@@ -397,7 +395,7 @@ impl<'a> Iterator for InlayChunks<'a> {
let inlay_chunks = self.inlay_chunks.get_or_insert_with(|| {
let start = offset_in_inlay;
- let end = cmp::min(self.max_output_offset, self.transforms.end(&()).0)
+ let end = cmp::min(self.max_output_offset, self.transforms.end().0)
- self.transforms.start().0;
inlay.text.chunks_in_range(start.0..end.0)
});
@@ -441,9 +439,9 @@ impl<'a> Iterator for InlayChunks<'a> {
}
};
- if self.output_offset >= self.transforms.end(&()).0 {
+ if self.output_offset >= self.transforms.end().0 {
self.inlay_chunks = None;
- self.transforms.next(&());
+ self.transforms.next();
}
Some(chunk)
@@ -453,7 +451,7 @@ impl<'a> Iterator for InlayChunks<'a> {
impl InlayBufferRows<'_> {
pub fn seek(&mut self, row: u32) {
let inlay_point = InlayPoint::new(row, 0);
- self.transforms.seek(&inlay_point, Bias::Left, &());
+ self.transforms.seek(&inlay_point, Bias::Left);
let mut buffer_point = self.transforms.start().1;
let buffer_row = MultiBufferRow(if row == 0 {
@@ -487,7 +485,7 @@ impl Iterator for InlayBufferRows<'_> {
self.inlay_row += 1;
self.transforms
- .seek_forward(&InlayPoint::new(self.inlay_row, 0), Bias::Left, &());
+ .seek_forward(&InlayPoint::new(self.inlay_row, 0), Bias::Left);
Some(buffer_row)
}
@@ -553,21 +551,23 @@ impl InlayMap {
} else {
let mut inlay_edits = Patch::default();
let mut new_transforms = SumTree::default();
- let mut cursor = snapshot.transforms.cursor::<(usize, InlayOffset)>(&());
+ let mut cursor = snapshot
+ .transforms
+ .cursor::<Dimensions<usize, InlayOffset>>(&());
let mut buffer_edits_iter = buffer_edits.iter().peekable();
while let Some(buffer_edit) = buffer_edits_iter.next() {
- new_transforms.append(cursor.slice(&buffer_edit.old.start, Bias::Left, &()), &());
- if let Some(Transform::Isomorphic(transform)) = cursor.item() {
- if cursor.end(&()).0 == buffer_edit.old.start {
- push_isomorphic(&mut new_transforms, *transform);
- cursor.next(&());
- }
+ new_transforms.append(cursor.slice(&buffer_edit.old.start, Bias::Left), &());
+ if let Some(Transform::Isomorphic(transform)) = cursor.item()
+ && cursor.end().0 == buffer_edit.old.start
+ {
+ push_isomorphic(&mut new_transforms, *transform);
+ cursor.next();
}
// Remove all the inlays and transforms contained by the edit.
let old_start =
cursor.start().1 + InlayOffset(buffer_edit.old.start - cursor.start().0);
- cursor.seek(&buffer_edit.old.end, Bias::Right, &());
+ cursor.seek(&buffer_edit.old.end, Bias::Right);
let old_end =
cursor.start().1 + InlayOffset(buffer_edit.old.end - cursor.start().0);
@@ -625,20 +625,20 @@ impl InlayMap {
// we can push its remainder.
if buffer_edits_iter
.peek()
- .map_or(true, |edit| edit.old.start >= cursor.end(&()).0)
+ .is_none_or(|edit| edit.old.start >= cursor.end().0)
{
let transform_start = new_transforms.summary().input.len;
let transform_end =
- buffer_edit.new.end + (cursor.end(&()).0 - buffer_edit.old.end);
+ buffer_edit.new.end + (cursor.end().0 - buffer_edit.old.end);
push_isomorphic(
&mut new_transforms,
buffer_snapshot.text_summary_for_range(transform_start..transform_end),
);
- cursor.next(&());
+ cursor.next();
}
}
- new_transforms.append(cursor.suffix(&()), &());
+ new_transforms.append(cursor.suffix(), &());
if new_transforms.is_empty() {
new_transforms.push(Transform::Isomorphic(Default::default()), &());
}
@@ -737,13 +737,13 @@ impl InlayMap {
Inlay::mock_hint(
post_inc(next_inlay_id),
snapshot.buffer.anchor_at(position, bias),
- text.clone(),
+ &text,
)
} else {
- Inlay::inline_completion(
+ Inlay::edit_prediction(
post_inc(next_inlay_id),
snapshot.buffer.anchor_at(position, bias),
- text.clone(),
+ &text,
)
};
let inlay_id = next_inlay.id;
@@ -772,20 +772,20 @@ impl InlaySnapshot {
pub fn to_point(&self, offset: InlayOffset) -> InlayPoint {
let mut cursor = self
.transforms
- .cursor::<(InlayOffset, (InlayPoint, usize))>(&());
- cursor.seek(&offset, Bias::Right, &());
+ .cursor::<Dimensions<InlayOffset, InlayPoint, usize>>(&());
+ cursor.seek(&offset, Bias::Right);
let overshoot = offset.0 - cursor.start().0.0;
match cursor.item() {
Some(Transform::Isomorphic(_)) => {
- let buffer_offset_start = cursor.start().1.1;
+ let buffer_offset_start = cursor.start().2;
let buffer_offset_end = buffer_offset_start + overshoot;
let buffer_start = self.buffer.offset_to_point(buffer_offset_start);
let buffer_end = self.buffer.offset_to_point(buffer_offset_end);
- InlayPoint(cursor.start().1.0.0 + (buffer_end - buffer_start))
+ InlayPoint(cursor.start().1.0 + (buffer_end - buffer_start))
}
Some(Transform::Inlay(inlay)) => {
let overshoot = inlay.text.offset_to_point(overshoot);
- InlayPoint(cursor.start().1.0.0 + overshoot)
+ InlayPoint(cursor.start().1.0 + overshoot)
}
None => self.max_point(),
}
@@ -802,27 +802,27 @@ impl InlaySnapshot {
pub fn to_offset(&self, point: InlayPoint) -> InlayOffset {
let mut cursor = self
.transforms
- .cursor::<(InlayPoint, (InlayOffset, Point))>(&());
- cursor.seek(&point, Bias::Right, &());
+ .cursor::<Dimensions<InlayPoint, InlayOffset, Point>>(&());
+ cursor.seek(&point, Bias::Right);
let overshoot = point.0 - cursor.start().0.0;
match cursor.item() {
Some(Transform::Isomorphic(_)) => {
- let buffer_point_start = cursor.start().1.1;
+ let buffer_point_start = cursor.start().2;
let buffer_point_end = buffer_point_start + overshoot;
let buffer_offset_start = self.buffer.point_to_offset(buffer_point_start);
let buffer_offset_end = self.buffer.point_to_offset(buffer_point_end);
- InlayOffset(cursor.start().1.0.0 + (buffer_offset_end - buffer_offset_start))
+ InlayOffset(cursor.start().1.0 + (buffer_offset_end - buffer_offset_start))
}
Some(Transform::Inlay(inlay)) => {
let overshoot = inlay.text.point_to_offset(overshoot);
- InlayOffset(cursor.start().1.0.0 + overshoot)
+ InlayOffset(cursor.start().1.0 + overshoot)
}
None => self.len(),
}
}
pub fn to_buffer_point(&self, point: InlayPoint) -> Point {
- let mut cursor = self.transforms.cursor::<(InlayPoint, Point)>(&());
- cursor.seek(&point, Bias::Right, &());
+ let mut cursor = self.transforms.cursor::<Dimensions<InlayPoint, Point>>(&());
+ cursor.seek(&point, Bias::Right);
match cursor.item() {
Some(Transform::Isomorphic(_)) => {
let overshoot = point.0 - cursor.start().0.0;
@@ -833,8 +833,10 @@ impl InlaySnapshot {
}
}
pub fn to_buffer_offset(&self, offset: InlayOffset) -> usize {
- let mut cursor = self.transforms.cursor::<(InlayOffset, usize)>(&());
- cursor.seek(&offset, Bias::Right, &());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<InlayOffset, usize>>(&());
+ cursor.seek(&offset, Bias::Right);
match cursor.item() {
Some(Transform::Isomorphic(_)) => {
let overshoot = offset - cursor.start().0;
@@ -846,20 +848,22 @@ impl InlaySnapshot {
}
pub fn to_inlay_offset(&self, offset: usize) -> InlayOffset {
- let mut cursor = self.transforms.cursor::<(usize, InlayOffset)>(&());
- cursor.seek(&offset, Bias::Left, &());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<usize, InlayOffset>>(&());
+ cursor.seek(&offset, Bias::Left);
loop {
match cursor.item() {
Some(Transform::Isomorphic(_)) => {
- if offset == cursor.end(&()).0 {
+ if offset == cursor.end().0 {
while let Some(Transform::Inlay(inlay)) = cursor.next_item() {
if inlay.position.bias() == Bias::Right {
break;
} else {
- cursor.next(&());
+ cursor.next();
}
}
- return cursor.end(&()).1;
+ return cursor.end().1;
} else {
let overshoot = offset - cursor.start().0;
return InlayOffset(cursor.start().1.0 + overshoot);
@@ -867,7 +871,7 @@ impl InlaySnapshot {
}
Some(Transform::Inlay(inlay)) => {
if inlay.position.bias() == Bias::Left {
- cursor.next(&());
+ cursor.next();
} else {
return cursor.start().1;
}
@@ -879,20 +883,20 @@ impl InlaySnapshot {
}
}
pub fn to_inlay_point(&self, point: Point) -> InlayPoint {
- let mut cursor = self.transforms.cursor::<(Point, InlayPoint)>(&());
- cursor.seek(&point, Bias::Left, &());
+ let mut cursor = self.transforms.cursor::<Dimensions<Point, InlayPoint>>(&());
+ cursor.seek(&point, Bias::Left);
loop {
match cursor.item() {
Some(Transform::Isomorphic(_)) => {
- if point == cursor.end(&()).0 {
+ if point == cursor.end().0 {
while let Some(Transform::Inlay(inlay)) = cursor.next_item() {
if inlay.position.bias() == Bias::Right {
break;
} else {
- cursor.next(&());
+ cursor.next();
}
}
- return cursor.end(&()).1;
+ return cursor.end().1;
} else {
let overshoot = point - cursor.start().0;
return InlayPoint(cursor.start().1.0 + overshoot);
@@ -900,7 +904,7 @@ impl InlaySnapshot {
}
Some(Transform::Inlay(inlay)) => {
if inlay.position.bias() == Bias::Left {
- cursor.next(&());
+ cursor.next();
} else {
return cursor.start().1;
}
@@ -913,8 +917,8 @@ impl InlaySnapshot {
}
pub fn clip_point(&self, mut point: InlayPoint, mut bias: Bias) -> InlayPoint {
- let mut cursor = self.transforms.cursor::<(InlayPoint, Point)>(&());
- cursor.seek(&point, Bias::Left, &());
+ let mut cursor = self.transforms.cursor::<Dimensions<InlayPoint, Point>>(&());
+ cursor.seek(&point, Bias::Left);
loop {
match cursor.item() {
Some(Transform::Isomorphic(transform)) => {
@@ -923,7 +927,7 @@ impl InlaySnapshot {
if inlay.position.bias() == Bias::Left {
return point;
} else if bias == Bias::Left {
- cursor.prev(&());
+ cursor.prev();
} else if transform.first_line_chars == 0 {
point.0 += Point::new(1, 0);
} else {
@@ -932,12 +936,12 @@ impl InlaySnapshot {
} else {
return point;
}
- } else if cursor.end(&()).0 == point {
+ } else if cursor.end().0 == point {
if let Some(Transform::Inlay(inlay)) = cursor.next_item() {
if inlay.position.bias() == Bias::Right {
return point;
} else if bias == Bias::Right {
- cursor.next(&());
+ cursor.next();
} else if point.0.column == 0 {
point.0.row -= 1;
point.0.column = self.line_len(point.0.row);
@@ -970,7 +974,7 @@ impl InlaySnapshot {
}
_ => return point,
}
- } else if point == cursor.end(&()).0 && inlay.position.bias() == Bias::Left {
+ } else if point == cursor.end().0 && inlay.position.bias() == Bias::Left {
match cursor.next_item() {
Some(Transform::Inlay(inlay)) => {
if inlay.position.bias() == Bias::Right {
@@ -983,9 +987,9 @@ impl InlaySnapshot {
if bias == Bias::Left {
point = cursor.start().0;
- cursor.prev(&());
+ cursor.prev();
} else {
- cursor.next(&());
+ cursor.next();
point = cursor.start().0;
}
}
@@ -993,9 +997,9 @@ impl InlaySnapshot {
bias = bias.invert();
if bias == Bias::Left {
point = cursor.start().0;
- cursor.prev(&());
+ cursor.prev();
} else {
- cursor.next(&());
+ cursor.next();
point = cursor.start().0;
}
}
@@ -1010,8 +1014,10 @@ impl InlaySnapshot {
pub fn text_summary_for_range(&self, range: Range<InlayOffset>) -> TextSummary {
let mut summary = TextSummary::default();
- let mut cursor = self.transforms.cursor::<(InlayOffset, usize)>(&());
- cursor.seek(&range.start, Bias::Right, &());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<InlayOffset, usize>>(&());
+ cursor.seek(&range.start, Bias::Right);
let overshoot = range.start.0 - cursor.start().0.0;
match cursor.item() {
@@ -1019,22 +1025,22 @@ impl InlaySnapshot {
let buffer_start = cursor.start().1;
let suffix_start = buffer_start + overshoot;
let suffix_end =
- buffer_start + (cmp::min(cursor.end(&()).0, range.end).0 - cursor.start().0.0);
+ buffer_start + (cmp::min(cursor.end().0, range.end).0 - cursor.start().0.0);
summary = self.buffer.text_summary_for_range(suffix_start..suffix_end);
- cursor.next(&());
+ cursor.next();
}
Some(Transform::Inlay(inlay)) => {
let suffix_start = overshoot;
- let suffix_end = cmp::min(cursor.end(&()).0, range.end).0 - cursor.start().0.0;
+ let suffix_end = cmp::min(cursor.end().0, range.end).0 - cursor.start().0.0;
summary = inlay.text.cursor(suffix_start).summary(suffix_end);
- cursor.next(&());
+ cursor.next();
}
None => {}
}
if range.end > cursor.start().0 {
summary += cursor
- .summary::<_, TransformSummary>(&range.end, Bias::Right, &())
+ .summary::<_, TransformSummary>(&range.end, Bias::Right)
.output;
let overshoot = range.end.0 - cursor.start().0.0;
@@ -1058,9 +1064,9 @@ impl InlaySnapshot {
}
pub fn row_infos(&self, row: u32) -> InlayBufferRows<'_> {
- let mut cursor = self.transforms.cursor::<(InlayPoint, Point)>(&());
+ let mut cursor = self.transforms.cursor::<Dimensions<InlayPoint, Point>>(&());
let inlay_point = InlayPoint::new(row, 0);
- cursor.seek(&inlay_point, Bias::Left, &());
+ cursor.seek(&inlay_point, Bias::Left);
let max_buffer_row = self.buffer.max_row();
let mut buffer_point = cursor.start().1;
@@ -1100,8 +1106,10 @@ impl InlaySnapshot {
language_aware: bool,
highlights: Highlights<'a>,
) -> InlayChunks<'a> {
- let mut cursor = self.transforms.cursor::<(InlayOffset, usize)>(&());
- cursor.seek(&range.start, Bias::Right, &());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<InlayOffset, usize>>(&());
+ cursor.seek(&range.start, Bias::Right);
let buffer_range = self.to_buffer_offset(range.start)..self.to_buffer_offset(range.end);
let buffer_chunks = CustomHighlightsChunks::new(
@@ -1297,6 +1305,29 @@ mod tests {
);
}
+ #[gpui::test]
+ fn test_inlay_hint_padding_with_multibyte_chars() {
+ assert_eq!(
+ Inlay::hint(
+ 0,
+ Anchor::min(),
+ &InlayHint {
+ label: InlayHintLabel::String("🎨".to_string()),
+ position: text::Anchor::default(),
+ padding_left: true,
+ padding_right: true,
+ tooltip: None,
+ kind: None,
+ resolve_state: ResolveState::Resolved,
+ },
+ )
+ .text
+ .to_string(),
+ " 🎨 ",
+ "Should pad single emoji correctly"
+ );
+ }
+
#[gpui::test]
fn test_basic_inlays(cx: &mut App) {
let buffer = MultiBuffer::build_simple("abcdefghi", cx);
@@ -1389,7 +1420,7 @@ mod tests {
buffer.read(cx).snapshot(cx).anchor_before(3),
"|123|",
),
- Inlay::inline_completion(
+ Inlay::edit_prediction(
post_inc(&mut next_inlay_id),
buffer.read(cx).snapshot(cx).anchor_after(3),
"|456|",
@@ -1609,7 +1640,7 @@ mod tests {
buffer.read(cx).snapshot(cx).anchor_before(4),
"|456|",
),
- Inlay::inline_completion(
+ Inlay::edit_prediction(
post_inc(&mut next_inlay_id),
buffer.read(cx).snapshot(cx).anchor_before(7),
"\n|567|\n",
@@ -1686,7 +1717,7 @@ mod tests {
(offset, inlay.clone())
})
.collect::<Vec<_>>();
- let mut expected_text = Rope::from(buffer_snapshot.text());
+ let mut expected_text = Rope::from(&buffer_snapshot.text());
for (offset, inlay) in inlays.iter().rev() {
expected_text.replace(*offset..*offset, &inlay.text.to_string());
}
@@ -36,8 +36,8 @@ pub fn is_invisible(c: char) -> bool {
} else if c >= '\u{7f}' {
c <= '\u{9f}'
|| (c.is_whitespace() && c != IDEOGRAPHIC_SPACE)
- || contains(c, &FORMAT)
- || contains(c, &OTHER)
+ || contains(c, FORMAT)
+ || contains(c, OTHER)
} else {
false
}
@@ -50,7 +50,7 @@ pub fn replacement(c: char) -> Option<&'static str> {
Some(C0_SYMBOLS[c as usize])
} else if c == '\x7f' {
Some(DEL)
- } else if contains(c, &PRESERVE) {
+ } else if contains(c, PRESERVE) {
None
} else {
Some("\u{2007}") // fixed width space
@@ -61,14 +61,14 @@ pub fn replacement(c: char) -> Option<&'static str> {
// but could if we tracked state in the classifier.
const IDEOGRAPHIC_SPACE: char = '\u{3000}';
-const C0_SYMBOLS: &'static [&'static str] = &[
+const C0_SYMBOLS: &[&str] = &[
"␀", "␁", "␂", "␃", "␄", "␅", "␆", "␇", "␈", "␉", "␊", "␋", "␌", "␍", "␎", "␏", "␐", "␑", "␒",
"␓", "␔", "␕", "␖", "␗", "␘", "␙", "␚", "␛", "␜", "␝", "␞", "␟",
];
-const DEL: &'static str = "␡";
+const DEL: &str = "␡";
// generated using ucd-generate: ucd-generate general-category --include Format --chars ucd-16.0.0
-pub const FORMAT: &'static [(char, char)] = &[
+pub const FORMAT: &[(char, char)] = &[
('\u{ad}', '\u{ad}'),
('\u{600}', '\u{605}'),
('\u{61c}', '\u{61c}'),
@@ -93,7 +93,7 @@ pub const FORMAT: &'static [(char, char)] = &[
];
// hand-made base on https://invisible-characters.com (Excluding Cf)
-pub const OTHER: &'static [(char, char)] = &[
+pub const OTHER: &[(char, char)] = &[
('\u{034f}', '\u{034f}'),
('\u{115F}', '\u{1160}'),
('\u{17b4}', '\u{17b5}'),
@@ -107,7 +107,7 @@ pub const OTHER: &'static [(char, char)] = &[
];
// a subset of FORMAT/OTHER that may appear within glyphs
-const PRESERVE: &'static [(char, char)] = &[
+const PRESERVE: &[(char, char)] = &[
('\u{034f}', '\u{034f}'),
('\u{200d}', '\u{200d}'),
('\u{17b4}', '\u{17b5}'),
@@ -116,7 +116,7 @@ impl TabMap {
state.new.end = edit.new.end;
Some(None) // Skip this edit, it's merged
} else {
- let new_state = edit.clone();
+ let new_state = edit;
let result = Some(Some(state.clone())); // Yield the previous edit
**state = new_state;
result
@@ -611,7 +611,7 @@ mod tests {
fn test_expand_tabs(cx: &mut gpui::App) {
let buffer = MultiBuffer::build_simple("", cx);
let buffer_snapshot = buffer.read(cx).snapshot(cx);
- let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone());
+ let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot);
let (_, fold_snapshot) = FoldMap::new(inlay_snapshot);
let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap());
@@ -628,7 +628,7 @@ mod tests {
let buffer = MultiBuffer::build_simple(input, cx);
let buffer_snapshot = buffer.read(cx).snapshot(cx);
- let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone());
+ let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot);
let (_, fold_snapshot) = FoldMap::new(inlay_snapshot);
let (_, mut tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap());
@@ -675,7 +675,7 @@ mod tests {
let buffer = MultiBuffer::build_simple(input, cx);
let buffer_snapshot = buffer.read(cx).snapshot(cx);
- let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone());
+ let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot);
let (_, fold_snapshot) = FoldMap::new(inlay_snapshot);
let (_, mut tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap());
@@ -689,7 +689,7 @@ mod tests {
let buffer = MultiBuffer::build_simple(input, cx);
let buffer_snapshot = buffer.read(cx).snapshot(cx);
- let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone());
+ let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot);
let (_, fold_snapshot) = FoldMap::new(inlay_snapshot);
let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap());
@@ -749,7 +749,7 @@ mod tests {
let buffer_snapshot = buffer.read(cx).snapshot(cx);
log::info!("Buffer text: {:?}", buffer_snapshot.text());
- let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone());
+ let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot);
log::info!("InlayMap text: {:?}", inlay_snapshot.text());
let (mut fold_map, _) = FoldMap::new(inlay_snapshot.clone());
fold_map.randomly_mutate(&mut rng);
@@ -758,7 +758,7 @@ mod tests {
let (inlay_snapshot, _) = inlay_map.randomly_mutate(&mut 0, &mut rng);
log::info!("InlayMap text: {:?}", inlay_snapshot.text());
- let (mut tab_map, _) = TabMap::new(fold_snapshot.clone(), tab_size);
+ let (mut tab_map, _) = TabMap::new(fold_snapshot, tab_size);
let tabs_snapshot = tab_map.set_max_expansion_column(32);
let text = text::Rope::from(tabs_snapshot.text().as_str());
@@ -9,7 +9,7 @@ use multi_buffer::{MultiBufferSnapshot, RowInfo};
use smol::future::yield_now;
use std::sync::LazyLock;
use std::{cmp, collections::VecDeque, mem, ops::Range, time::Duration};
-use sum_tree::{Bias, Cursor, SumTree};
+use sum_tree::{Bias, Cursor, Dimensions, SumTree};
use text::Patch;
pub use super::tab_map::TextSummary;
@@ -55,7 +55,7 @@ pub struct WrapChunks<'a> {
input_chunk: Chunk<'a>,
output_position: WrapPoint,
max_output_row: u32,
- transforms: Cursor<'a, Transform, (WrapPoint, TabPoint)>,
+ transforms: Cursor<'a, Transform, Dimensions<WrapPoint, TabPoint>>,
snapshot: &'a WrapSnapshot,
}
@@ -66,18 +66,18 @@ pub struct WrapRows<'a> {
output_row: u32,
soft_wrapped: bool,
max_output_row: u32,
- transforms: Cursor<'a, Transform, (WrapPoint, TabPoint)>,
+ transforms: Cursor<'a, Transform, Dimensions<WrapPoint, TabPoint>>,
}
impl WrapRows<'_> {
pub(crate) fn seek(&mut self, start_row: u32) {
self.transforms
- .seek(&WrapPoint::new(start_row, 0), Bias::Left, &());
+ .seek(&WrapPoint::new(start_row, 0), Bias::Left);
let mut input_row = self.transforms.start().1.row();
- if self.transforms.item().map_or(false, |t| t.is_isomorphic()) {
+ if self.transforms.item().is_some_and(|t| t.is_isomorphic()) {
input_row += start_row - self.transforms.start().0.row();
}
- self.soft_wrapped = self.transforms.item().map_or(false, |t| !t.is_isomorphic());
+ self.soft_wrapped = self.transforms.item().is_some_and(|t| !t.is_isomorphic());
self.input_buffer_rows.seek(input_row);
self.input_buffer_row = self.input_buffer_rows.next().unwrap();
self.output_row = start_row;
@@ -249,48 +249,48 @@ impl WrapMap {
return;
}
- if let Some(wrap_width) = self.wrap_width {
- if self.background_task.is_none() {
- let pending_edits = self.pending_edits.clone();
- let mut snapshot = self.snapshot.clone();
- let text_system = cx.text_system().clone();
- let (font, font_size) = self.font_with_size.clone();
- let update_task = cx.background_spawn(async move {
- let mut edits = Patch::default();
- let mut line_wrapper = text_system.line_wrapper(font, font_size);
- for (tab_snapshot, tab_edits) in pending_edits {
- let wrap_edits = snapshot
- .update(tab_snapshot, &tab_edits, wrap_width, &mut line_wrapper)
- .await;
- edits = edits.compose(&wrap_edits);
- }
- (snapshot, edits)
- });
+ if let Some(wrap_width) = self.wrap_width
+ && self.background_task.is_none()
+ {
+ let pending_edits = self.pending_edits.clone();
+ let mut snapshot = self.snapshot.clone();
+ let text_system = cx.text_system().clone();
+ let (font, font_size) = self.font_with_size.clone();
+ let update_task = cx.background_spawn(async move {
+ let mut edits = Patch::default();
+ let mut line_wrapper = text_system.line_wrapper(font, font_size);
+ for (tab_snapshot, tab_edits) in pending_edits {
+ let wrap_edits = snapshot
+ .update(tab_snapshot, &tab_edits, wrap_width, &mut line_wrapper)
+ .await;
+ edits = edits.compose(&wrap_edits);
+ }
+ (snapshot, edits)
+ });
- match cx
- .background_executor()
- .block_with_timeout(Duration::from_millis(1), update_task)
- {
- Ok((snapshot, output_edits)) => {
- self.snapshot = snapshot;
- self.edits_since_sync = self.edits_since_sync.compose(&output_edits);
- }
- Err(update_task) => {
- self.background_task = Some(cx.spawn(async move |this, cx| {
- let (snapshot, edits) = update_task.await;
- this.update(cx, |this, cx| {
- this.snapshot = snapshot;
- this.edits_since_sync = this
- .edits_since_sync
- .compose(mem::take(&mut this.interpolated_edits).invert())
- .compose(&edits);
- this.background_task = None;
- this.flush_edits(cx);
- cx.notify();
- })
- .ok();
- }));
- }
+ match cx
+ .background_executor()
+ .block_with_timeout(Duration::from_millis(1), update_task)
+ {
+ Ok((snapshot, output_edits)) => {
+ self.snapshot = snapshot;
+ self.edits_since_sync = self.edits_since_sync.compose(&output_edits);
+ }
+ Err(update_task) => {
+ self.background_task = Some(cx.spawn(async move |this, cx| {
+ let (snapshot, edits) = update_task.await;
+ this.update(cx, |this, cx| {
+ this.snapshot = snapshot;
+ this.edits_since_sync = this
+ .edits_since_sync
+ .compose(mem::take(&mut this.interpolated_edits).invert())
+ .compose(&edits);
+ this.background_task = None;
+ this.flush_edits(cx);
+ cx.notify();
+ })
+ .ok();
+ }));
}
}
}
@@ -340,7 +340,7 @@ impl WrapSnapshot {
let mut tab_edits_iter = tab_edits.iter().peekable();
new_transforms =
- old_cursor.slice(&tab_edits_iter.peek().unwrap().old.start, Bias::Right, &());
+ old_cursor.slice(&tab_edits_iter.peek().unwrap().old.start, Bias::Right);
while let Some(edit) = tab_edits_iter.next() {
if edit.new.start > TabPoint::from(new_transforms.summary().input.lines) {
@@ -356,31 +356,29 @@ impl WrapSnapshot {
));
}
- old_cursor.seek_forward(&edit.old.end, Bias::Right, &());
+ old_cursor.seek_forward(&edit.old.end, Bias::Right);
if let Some(next_edit) = tab_edits_iter.peek() {
- if next_edit.old.start > old_cursor.end(&()) {
- if old_cursor.end(&()) > edit.old.end {
+ if next_edit.old.start > old_cursor.end() {
+ if old_cursor.end() > edit.old.end {
let summary = self
.tab_snapshot
- .text_summary_for_range(edit.old.end..old_cursor.end(&()));
+ .text_summary_for_range(edit.old.end..old_cursor.end());
new_transforms.push_or_extend(Transform::isomorphic(summary));
}
- old_cursor.next(&());
- new_transforms.append(
- old_cursor.slice(&next_edit.old.start, Bias::Right, &()),
- &(),
- );
+ old_cursor.next();
+ new_transforms
+ .append(old_cursor.slice(&next_edit.old.start, Bias::Right), &());
}
} else {
- if old_cursor.end(&()) > edit.old.end {
+ if old_cursor.end() > edit.old.end {
let summary = self
.tab_snapshot
- .text_summary_for_range(edit.old.end..old_cursor.end(&()));
+ .text_summary_for_range(edit.old.end..old_cursor.end());
new_transforms.push_or_extend(Transform::isomorphic(summary));
}
- old_cursor.next(&());
- new_transforms.append(old_cursor.suffix(&()), &());
+ old_cursor.next();
+ new_transforms.append(old_cursor.suffix(), &());
}
}
}
@@ -441,7 +439,6 @@ impl WrapSnapshot {
new_transforms = old_cursor.slice(
&TabPoint::new(row_edits.peek().unwrap().old_rows.start, 0),
Bias::Right,
- &(),
);
while let Some(edit) = row_edits.next() {
@@ -516,34 +513,31 @@ impl WrapSnapshot {
}
new_transforms.extend(edit_transforms, &());
- old_cursor.seek_forward(&TabPoint::new(edit.old_rows.end, 0), Bias::Right, &());
+ old_cursor.seek_forward(&TabPoint::new(edit.old_rows.end, 0), Bias::Right);
if let Some(next_edit) = row_edits.peek() {
- if next_edit.old_rows.start > old_cursor.end(&()).row() {
- if old_cursor.end(&()) > TabPoint::new(edit.old_rows.end, 0) {
+ if next_edit.old_rows.start > old_cursor.end().row() {
+ if old_cursor.end() > TabPoint::new(edit.old_rows.end, 0) {
let summary = self.tab_snapshot.text_summary_for_range(
- TabPoint::new(edit.old_rows.end, 0)..old_cursor.end(&()),
+ TabPoint::new(edit.old_rows.end, 0)..old_cursor.end(),
);
new_transforms.push_or_extend(Transform::isomorphic(summary));
}
- old_cursor.next(&());
+ old_cursor.next();
new_transforms.append(
- old_cursor.slice(
- &TabPoint::new(next_edit.old_rows.start, 0),
- Bias::Right,
- &(),
- ),
+ old_cursor
+ .slice(&TabPoint::new(next_edit.old_rows.start, 0), Bias::Right),
&(),
);
}
} else {
- if old_cursor.end(&()) > TabPoint::new(edit.old_rows.end, 0) {
+ if old_cursor.end() > TabPoint::new(edit.old_rows.end, 0) {
let summary = self.tab_snapshot.text_summary_for_range(
- TabPoint::new(edit.old_rows.end, 0)..old_cursor.end(&()),
+ TabPoint::new(edit.old_rows.end, 0)..old_cursor.end(),
);
new_transforms.push_or_extend(Transform::isomorphic(summary));
}
- old_cursor.next(&());
- new_transforms.append(old_cursor.suffix(&()), &());
+ old_cursor.next();
+ new_transforms.append(old_cursor.suffix(), &());
}
}
}
@@ -570,19 +564,19 @@ impl WrapSnapshot {
tab_edit.new.start.0.column = 0;
tab_edit.new.end.0 += Point::new(1, 0);
- old_cursor.seek(&tab_edit.old.start, Bias::Right, &());
+ old_cursor.seek(&tab_edit.old.start, Bias::Right);
let mut old_start = old_cursor.start().output.lines;
old_start += tab_edit.old.start.0 - old_cursor.start().input.lines;
- old_cursor.seek(&tab_edit.old.end, Bias::Right, &());
+ old_cursor.seek(&tab_edit.old.end, Bias::Right);
let mut old_end = old_cursor.start().output.lines;
old_end += tab_edit.old.end.0 - old_cursor.start().input.lines;
- new_cursor.seek(&tab_edit.new.start, Bias::Right, &());
+ new_cursor.seek(&tab_edit.new.start, Bias::Right);
let mut new_start = new_cursor.start().output.lines;
new_start += tab_edit.new.start.0 - new_cursor.start().input.lines;
- new_cursor.seek(&tab_edit.new.end, Bias::Right, &());
+ new_cursor.seek(&tab_edit.new.end, Bias::Right);
let mut new_end = new_cursor.start().output.lines;
new_end += tab_edit.new.end.0 - new_cursor.start().input.lines;
@@ -604,10 +598,12 @@ impl WrapSnapshot {
) -> WrapChunks<'a> {
let output_start = WrapPoint::new(rows.start, 0);
let output_end = WrapPoint::new(rows.end, 0);
- let mut transforms = self.transforms.cursor::<(WrapPoint, TabPoint)>(&());
- transforms.seek(&output_start, Bias::Right, &());
+ let mut transforms = self
+ .transforms
+ .cursor::<Dimensions<WrapPoint, TabPoint>>(&());
+ transforms.seek(&output_start, Bias::Right);
let mut input_start = TabPoint(transforms.start().1.0);
- if transforms.item().map_or(false, |t| t.is_isomorphic()) {
+ if transforms.item().is_some_and(|t| t.is_isomorphic()) {
input_start.0 += output_start.0 - transforms.start().0.0;
}
let input_end = self
@@ -632,11 +628,13 @@ impl WrapSnapshot {
}
pub fn line_len(&self, row: u32) -> u32 {
- let mut cursor = self.transforms.cursor::<(WrapPoint, TabPoint)>(&());
- cursor.seek(&WrapPoint::new(row + 1, 0), Bias::Left, &());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<WrapPoint, TabPoint>>(&());
+ cursor.seek(&WrapPoint::new(row + 1, 0), Bias::Left);
if cursor
.item()
- .map_or(false, |transform| transform.is_isomorphic())
+ .is_some_and(|transform| transform.is_isomorphic())
{
let overshoot = row - cursor.start().0.row();
let tab_row = cursor.start().1.row() + overshoot;
@@ -657,11 +655,13 @@ impl WrapSnapshot {
let start = WrapPoint::new(rows.start, 0);
let end = WrapPoint::new(rows.end, 0);
- let mut cursor = self.transforms.cursor::<(WrapPoint, TabPoint)>(&());
- cursor.seek(&start, Bias::Right, &());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<WrapPoint, TabPoint>>(&());
+ cursor.seek(&start, Bias::Right);
if let Some(transform) = cursor.item() {
let start_in_transform = start.0 - cursor.start().0.0;
- let end_in_transform = cmp::min(end, cursor.end(&()).0).0 - cursor.start().0.0;
+ let end_in_transform = cmp::min(end, cursor.end().0).0 - cursor.start().0.0;
if transform.is_isomorphic() {
let tab_start = TabPoint(cursor.start().1.0 + start_in_transform);
let tab_end = TabPoint(cursor.start().1.0 + end_in_transform);
@@ -678,12 +678,12 @@ impl WrapSnapshot {
};
}
- cursor.next(&());
+ cursor.next();
}
if rows.end > cursor.start().0.row() {
summary += &cursor
- .summary::<_, TransformSummary>(&WrapPoint::new(rows.end, 0), Bias::Right, &())
+ .summary::<_, TransformSummary>(&WrapPoint::new(rows.end, 0), Bias::Right)
.output;
if let Some(transform) = cursor.item() {
@@ -712,7 +712,7 @@ impl WrapSnapshot {
pub fn soft_wrap_indent(&self, row: u32) -> Option<u32> {
let mut cursor = self.transforms.cursor::<WrapPoint>(&());
- cursor.seek(&WrapPoint::new(row + 1, 0), Bias::Right, &());
+ cursor.seek(&WrapPoint::new(row + 1, 0), Bias::Right);
cursor.item().and_then(|transform| {
if transform.is_isomorphic() {
None
@@ -727,13 +727,15 @@ impl WrapSnapshot {
}
pub fn row_infos(&self, start_row: u32) -> WrapRows<'_> {
- let mut transforms = self.transforms.cursor::<(WrapPoint, TabPoint)>(&());
- transforms.seek(&WrapPoint::new(start_row, 0), Bias::Left, &());
+ let mut transforms = self
+ .transforms
+ .cursor::<Dimensions<WrapPoint, TabPoint>>(&());
+ transforms.seek(&WrapPoint::new(start_row, 0), Bias::Left);
let mut input_row = transforms.start().1.row();
- if transforms.item().map_or(false, |t| t.is_isomorphic()) {
+ if transforms.item().is_some_and(|t| t.is_isomorphic()) {
input_row += start_row - transforms.start().0.row();
}
- let soft_wrapped = transforms.item().map_or(false, |t| !t.is_isomorphic());
+ let soft_wrapped = transforms.item().is_some_and(|t| !t.is_isomorphic());
let mut input_buffer_rows = self.tab_snapshot.rows(input_row);
let input_buffer_row = input_buffer_rows.next().unwrap();
WrapRows {
@@ -747,10 +749,12 @@ impl WrapSnapshot {
}
pub fn to_tab_point(&self, point: WrapPoint) -> TabPoint {
- let mut cursor = self.transforms.cursor::<(WrapPoint, TabPoint)>(&());
- cursor.seek(&point, Bias::Right, &());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<WrapPoint, TabPoint>>(&());
+ cursor.seek(&point, Bias::Right);
let mut tab_point = cursor.start().1.0;
- if cursor.item().map_or(false, |t| t.is_isomorphic()) {
+ if cursor.item().is_some_and(|t| t.is_isomorphic()) {
tab_point += point.0 - cursor.start().0.0;
}
TabPoint(tab_point)
@@ -765,16 +769,18 @@ impl WrapSnapshot {
}
pub fn tab_point_to_wrap_point(&self, point: TabPoint) -> WrapPoint {
- let mut cursor = self.transforms.cursor::<(TabPoint, WrapPoint)>(&());
- cursor.seek(&point, Bias::Right, &());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<TabPoint, WrapPoint>>(&());
+ cursor.seek(&point, Bias::Right);
WrapPoint(cursor.start().1.0 + (point.0 - cursor.start().0.0))
}
pub fn clip_point(&self, mut point: WrapPoint, bias: Bias) -> WrapPoint {
if bias == Bias::Left {
let mut cursor = self.transforms.cursor::<WrapPoint>(&());
- cursor.seek(&point, Bias::Right, &());
- if cursor.item().map_or(false, |t| !t.is_isomorphic()) {
+ cursor.seek(&point, Bias::Right);
+ if cursor.item().is_some_and(|t| !t.is_isomorphic()) {
point = *cursor.start();
*point.column_mut() -= 1;
}
@@ -790,17 +796,19 @@ impl WrapSnapshot {
*point.column_mut() = 0;
- let mut cursor = self.transforms.cursor::<(WrapPoint, TabPoint)>(&());
- cursor.seek(&point, Bias::Right, &());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<WrapPoint, TabPoint>>(&());
+ cursor.seek(&point, Bias::Right);
if cursor.item().is_none() {
- cursor.prev(&());
+ cursor.prev();
}
while let Some(transform) = cursor.item() {
if transform.is_isomorphic() && cursor.start().1.column() == 0 {
- return cmp::min(cursor.end(&()).0.row(), point.row());
+ return cmp::min(cursor.end().0.row(), point.row());
} else {
- cursor.prev(&());
+ cursor.prev();
}
}
@@ -810,13 +818,15 @@ impl WrapSnapshot {
pub fn next_row_boundary(&self, mut point: WrapPoint) -> Option<u32> {
point.0 += Point::new(1, 0);
- let mut cursor = self.transforms.cursor::<(WrapPoint, TabPoint)>(&());
- cursor.seek(&point, Bias::Right, &());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<WrapPoint, TabPoint>>(&());
+ cursor.seek(&point, Bias::Right);
while let Some(transform) = cursor.item() {
if transform.is_isomorphic() && cursor.start().1.column() == 0 {
return Some(cmp::max(cursor.start().0.row(), point.row()));
} else {
- cursor.next(&());
+ cursor.next();
}
}
@@ -889,9 +899,9 @@ impl WrapChunks<'_> {
pub(crate) fn seek(&mut self, rows: Range<u32>) {
let output_start = WrapPoint::new(rows.start, 0);
let output_end = WrapPoint::new(rows.end, 0);
- self.transforms.seek(&output_start, Bias::Right, &());
+ self.transforms.seek(&output_start, Bias::Right);
let mut input_start = TabPoint(self.transforms.start().1.0);
- if self.transforms.item().map_or(false, |t| t.is_isomorphic()) {
+ if self.transforms.item().is_some_and(|t| t.is_isomorphic()) {
input_start.0 += output_start.0 - self.transforms.start().0.0;
}
let input_end = self
@@ -930,7 +940,7 @@ impl<'a> Iterator for WrapChunks<'a> {
}
self.output_position.0 += summary;
- self.transforms.next(&());
+ self.transforms.next();
return Some(Chunk {
text: &display_text[start_ix..end_ix],
..Default::default()
@@ -942,7 +952,7 @@ impl<'a> Iterator for WrapChunks<'a> {
}
let mut input_len = 0;
- let transform_end = self.transforms.end(&()).0;
+ let transform_end = self.transforms.end().0;
for c in self.input_chunk.text.chars() {
let char_len = c.len_utf8();
input_len += char_len;
@@ -954,7 +964,7 @@ impl<'a> Iterator for WrapChunks<'a> {
}
if self.output_position >= transform_end {
- self.transforms.next(&());
+ self.transforms.next();
break;
}
}
@@ -982,8 +992,8 @@ impl Iterator for WrapRows<'_> {
self.output_row += 1;
self.transforms
- .seek_forward(&WrapPoint::new(self.output_row, 0), Bias::Left, &());
- if self.transforms.item().map_or(false, |t| t.is_isomorphic()) {
+ .seek_forward(&WrapPoint::new(self.output_row, 0), Bias::Left);
+ if self.transforms.item().is_some_and(|t| t.is_isomorphic()) {
self.input_buffer_row = self.input_buffer_rows.next().unwrap();
self.soft_wrapped = false;
} else {
@@ -1055,12 +1065,12 @@ impl sum_tree::Item for Transform {
}
fn push_isomorphic(transforms: &mut Vec<Transform>, summary: TextSummary) {
- if let Some(last_transform) = transforms.last_mut() {
- if last_transform.is_isomorphic() {
- last_transform.summary.input += &summary;
- last_transform.summary.output += &summary;
- return;
- }
+ if let Some(last_transform) = transforms.last_mut()
+ && last_transform.is_isomorphic()
+ {
+ last_transform.summary.input += &summary;
+ last_transform.summary.output += &summary;
+ return;
}
transforms.push(Transform::isomorphic(summary));
}
@@ -1213,7 +1223,7 @@ mod tests {
let tab_size = NonZeroU32::new(rng.gen_range(1..=4)).unwrap();
let font = test_font();
- let _font_id = text_system.font_id(&font);
+ let _font_id = text_system.resolve_font(&font);
let font_size = px(14.0);
log::info!("Tab size: {}", tab_size);
@@ -1451,7 +1461,7 @@ mod tests {
}
let mut prev_ix = 0;
- for boundary in line_wrapper.wrap_line(&[LineFragment::text(&line)], wrap_width) {
+ for boundary in line_wrapper.wrap_line(&[LineFragment::text(line)], wrap_width) {
wrapped_text.push_str(&line[prev_ix..boundary.ix]);
wrapped_text.push('\n');
wrapped_text.push_str(&" ".repeat(boundary.next_indent as usize));
@@ -1,26 +1,28 @@
+use edit_prediction::EditPredictionProvider;
use gpui::{Entity, prelude::*};
use indoc::indoc;
-use inline_completion::EditPredictionProvider;
use multi_buffer::{Anchor, MultiBufferSnapshot, ToPoint};
use project::Project;
use std::ops::Range;
use text::{Point, ToOffset};
use crate::{
- InlineCompletion, editor_tests::init_test, test::editor_test_context::EditorTestContext,
+ EditPrediction,
+ editor_tests::{init_test, update_test_language_settings},
+ test::editor_test_context::EditorTestContext,
};
#[gpui::test]
-async fn test_inline_completion_insert(cx: &mut gpui::TestAppContext) {
+async fn test_edit_prediction_insert(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
- let provider = cx.new(|_| FakeInlineCompletionProvider::default());
+ let provider = cx.new(|_| FakeEditPredictionProvider::default());
assign_editor_completion_provider(provider.clone(), &mut cx);
cx.set_state("let absolute_zero_celsius = ˇ;");
propose_edits(&provider, vec![(28..28, "-273.15")], &mut cx);
- cx.update_editor(|editor, window, cx| editor.update_visible_inline_completion(window, cx));
+ cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx));
assert_editor_active_edit_completion(&mut cx, |_, edits| {
assert_eq!(edits.len(), 1);
@@ -33,16 +35,16 @@ async fn test_inline_completion_insert(cx: &mut gpui::TestAppContext) {
}
#[gpui::test]
-async fn test_inline_completion_modification(cx: &mut gpui::TestAppContext) {
+async fn test_edit_prediction_modification(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
- let provider = cx.new(|_| FakeInlineCompletionProvider::default());
+ let provider = cx.new(|_| FakeEditPredictionProvider::default());
assign_editor_completion_provider(provider.clone(), &mut cx);
cx.set_state("let pi = ˇ\"foo\";");
propose_edits(&provider, vec![(9..14, "3.14159")], &mut cx);
- cx.update_editor(|editor, window, cx| editor.update_visible_inline_completion(window, cx));
+ cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx));
assert_editor_active_edit_completion(&mut cx, |_, edits| {
assert_eq!(edits.len(), 1);
@@ -55,11 +57,11 @@ async fn test_inline_completion_modification(cx: &mut gpui::TestAppContext) {
}
#[gpui::test]
-async fn test_inline_completion_jump_button(cx: &mut gpui::TestAppContext) {
+async fn test_edit_prediction_jump_button(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
- let provider = cx.new(|_| FakeInlineCompletionProvider::default());
+ let provider = cx.new(|_| FakeEditPredictionProvider::default());
assign_editor_completion_provider(provider.clone(), &mut cx);
// Cursor is 2+ lines above the proposed edit
@@ -77,7 +79,7 @@ async fn test_inline_completion_jump_button(cx: &mut gpui::TestAppContext) {
&mut cx,
);
- cx.update_editor(|editor, window, cx| editor.update_visible_inline_completion(window, cx));
+ cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx));
assert_editor_active_move_completion(&mut cx, |snapshot, move_target| {
assert_eq!(move_target.to_point(&snapshot), Point::new(4, 3));
});
@@ -107,7 +109,7 @@ async fn test_inline_completion_jump_button(cx: &mut gpui::TestAppContext) {
&mut cx,
);
- cx.update_editor(|editor, window, cx| editor.update_visible_inline_completion(window, cx));
+ cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx));
assert_editor_active_move_completion(&mut cx, |snapshot, move_target| {
assert_eq!(move_target.to_point(&snapshot), Point::new(1, 3));
});
@@ -124,11 +126,11 @@ async fn test_inline_completion_jump_button(cx: &mut gpui::TestAppContext) {
}
#[gpui::test]
-async fn test_inline_completion_invalidation_range(cx: &mut gpui::TestAppContext) {
+async fn test_edit_prediction_invalidation_range(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
- let provider = cx.new(|_| FakeInlineCompletionProvider::default());
+ let provider = cx.new(|_| FakeEditPredictionProvider::default());
assign_editor_completion_provider(provider.clone(), &mut cx);
// Cursor is 3+ lines above the proposed edit
@@ -148,7 +150,7 @@ async fn test_inline_completion_invalidation_range(cx: &mut gpui::TestAppContext
&mut cx,
);
- cx.update_editor(|editor, window, cx| editor.update_visible_inline_completion(window, cx));
+ cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx));
assert_editor_active_move_completion(&mut cx, |snapshot, move_target| {
assert_eq!(move_target.to_point(&snapshot), edit_location);
});
@@ -176,7 +178,7 @@ async fn test_inline_completion_invalidation_range(cx: &mut gpui::TestAppContext
line
"});
cx.editor(|editor, _, _| {
- assert!(editor.active_inline_completion.is_none());
+ assert!(editor.active_edit_prediction.is_none());
});
// Cursor is 3+ lines below the proposed edit
@@ -196,7 +198,7 @@ async fn test_inline_completion_invalidation_range(cx: &mut gpui::TestAppContext
&mut cx,
);
- cx.update_editor(|editor, window, cx| editor.update_visible_inline_completion(window, cx));
+ cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx));
assert_editor_active_move_completion(&mut cx, |snapshot, move_target| {
assert_eq!(move_target.to_point(&snapshot), edit_location);
});
@@ -224,7 +226,88 @@ async fn test_inline_completion_invalidation_range(cx: &mut gpui::TestAppContext
line ˇ5
"});
cx.editor(|editor, _, _| {
- assert!(editor.active_inline_completion.is_none());
+ assert!(editor.active_edit_prediction.is_none());
+ });
+}
+
+#[gpui::test]
+async fn test_edit_prediction_jump_disabled_for_non_zed_providers(cx: &mut gpui::TestAppContext) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorTestContext::new(cx).await;
+ let provider = cx.new(|_| FakeNonZedEditPredictionProvider::default());
+ assign_editor_completion_provider_non_zed(provider.clone(), &mut cx);
+
+ // Cursor is 2+ lines above the proposed edit
+ cx.set_state(indoc! {"
+ line 0
+ line ˇ1
+ line 2
+ line 3
+ line
+ "});
+
+ propose_edits_non_zed(
+ &provider,
+ vec![(Point::new(4, 3)..Point::new(4, 3), " 4")],
+ &mut cx,
+ );
+
+ cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx));
+
+ // For non-Zed providers, there should be no move completion (jump functionality disabled)
+ cx.editor(|editor, _, _| {
+ if let Some(completion_state) = &editor.active_edit_prediction {
+ // Should be an Edit prediction, not a Move prediction
+ match &completion_state.completion {
+ EditPrediction::Edit { .. } => {
+ // This is expected for non-Zed providers
+ }
+ EditPrediction::Move { .. } => {
+ panic!(
+ "Non-Zed providers should not show Move predictions (jump functionality)"
+ );
+ }
+ }
+ }
+ });
+}
+
+#[gpui::test]
+async fn test_edit_predictions_disabled_in_scope(cx: &mut gpui::TestAppContext) {
+ init_test(cx, |_| {});
+
+ update_test_language_settings(cx, |settings| {
+ settings.defaults.edit_predictions_disabled_in = Some(vec!["string".to_string()]);
+ });
+
+ let mut cx = EditorTestContext::new(cx).await;
+ let provider = cx.new(|_| FakeEditPredictionProvider::default());
+ assign_editor_completion_provider(provider.clone(), &mut cx);
+
+ let language = languages::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
+ cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+
+ // Test disabled inside of string
+ cx.set_state("const x = \"hello ˇworld\";");
+ propose_edits(&provider, vec![(17..17, "beautiful ")], &mut cx);
+ cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx));
+ cx.editor(|editor, _, _| {
+ assert!(
+ editor.active_edit_prediction.is_none(),
+ "Edit predictions should be disabled in string scopes when configured in edit_predictions_disabled_in"
+ );
+ });
+
+ // Test enabled outside of string
+ cx.set_state("const x = \"hello world\"; ˇ");
+ propose_edits(&provider, vec![(24..24, "// comment")], &mut cx);
+ cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx));
+ cx.editor(|editor, _, _| {
+ assert!(
+ editor.active_edit_prediction.is_some(),
+ "Edit predictions should work outside of disabled scopes"
+ );
});
}
@@ -234,11 +317,11 @@ fn assert_editor_active_edit_completion(
) {
cx.editor(|editor, _, cx| {
let completion_state = editor
- .active_inline_completion
+ .active_edit_prediction
.as_ref()
.expect("editor has no active completion");
- if let InlineCompletion::Edit { edits, .. } = &completion_state.completion {
+ if let EditPrediction::Edit { edits, .. } = &completion_state.completion {
assert(editor.buffer().read(cx).snapshot(cx), edits);
} else {
panic!("expected edit completion");
@@ -252,11 +335,11 @@ fn assert_editor_active_move_completion(
) {
cx.editor(|editor, _, cx| {
let completion_state = editor
- .active_inline_completion
+ .active_edit_prediction
.as_ref()
.expect("editor has no active completion");
- if let InlineCompletion::Move { target, .. } = &completion_state.completion {
+ if let EditPrediction::Move { target, .. } = &completion_state.completion {
assert(editor.buffer().read(cx).snapshot(cx), *target);
} else {
panic!("expected move completion");
@@ -271,7 +354,7 @@ fn accept_completion(cx: &mut EditorTestContext) {
}
fn propose_edits<T: ToOffset>(
- provider: &Entity<FakeInlineCompletionProvider>,
+ provider: &Entity<FakeEditPredictionProvider>,
edits: Vec<(Range<T>, &str)>,
cx: &mut EditorTestContext,
) {
@@ -283,7 +366,7 @@ fn propose_edits<T: ToOffset>(
cx.update(|_, cx| {
provider.update(cx, |provider, _| {
- provider.set_inline_completion(Some(inline_completion::InlineCompletion {
+ provider.set_edit_prediction(Some(edit_prediction::EditPrediction {
id: None,
edits: edits.collect(),
edit_preview: None,
@@ -293,7 +376,38 @@ fn propose_edits<T: ToOffset>(
}
fn assign_editor_completion_provider(
- provider: Entity<FakeInlineCompletionProvider>,
+ provider: Entity<FakeEditPredictionProvider>,
+ cx: &mut EditorTestContext,
+) {
+ cx.update_editor(|editor, window, cx| {
+ editor.set_edit_prediction_provider(Some(provider), window, cx);
+ })
+}
+
+fn propose_edits_non_zed<T: ToOffset>(
+ provider: &Entity<FakeNonZedEditPredictionProvider>,
+ edits: Vec<(Range<T>, &str)>,
+ cx: &mut EditorTestContext,
+) {
+ let snapshot = cx.buffer_snapshot();
+ let edits = edits.into_iter().map(|(range, text)| {
+ let range = snapshot.anchor_after(range.start)..snapshot.anchor_before(range.end);
+ (range, text.into())
+ });
+
+ cx.update(|_, cx| {
+ provider.update(cx, |provider, _| {
+ provider.set_edit_prediction(Some(edit_prediction::EditPrediction {
+ id: None,
+ edits: edits.collect(),
+ edit_preview: None,
+ }))
+ })
+ });
+}
+
+fn assign_editor_completion_provider_non_zed(
+ provider: Entity<FakeNonZedEditPredictionProvider>,
cx: &mut EditorTestContext,
) {
cx.update_editor(|editor, window, cx| {
@@ -302,20 +416,17 @@ fn assign_editor_completion_provider(
}
#[derive(Default, Clone)]
-pub struct FakeInlineCompletionProvider {
- pub completion: Option<inline_completion::InlineCompletion>,
+pub struct FakeEditPredictionProvider {
+ pub completion: Option<edit_prediction::EditPrediction>,
}
-impl FakeInlineCompletionProvider {
- pub fn set_inline_completion(
- &mut self,
- completion: Option<inline_completion::InlineCompletion>,
- ) {
+impl FakeEditPredictionProvider {
+ pub fn set_edit_prediction(&mut self, completion: Option<edit_prediction::EditPrediction>) {
self.completion = completion;
}
}
-impl EditPredictionProvider for FakeInlineCompletionProvider {
+impl EditPredictionProvider for FakeEditPredictionProvider {
fn name() -> &'static str {
"fake-completion-provider"
}
@@ -328,6 +439,84 @@ impl EditPredictionProvider for FakeInlineCompletionProvider {
false
}
+ fn supports_jump_to_edit() -> bool {
+ true
+ }
+
+ fn is_enabled(
+ &self,
+ _buffer: &gpui::Entity<language::Buffer>,
+ _cursor_position: language::Anchor,
+ _cx: &gpui::App,
+ ) -> bool {
+ true
+ }
+
+ fn is_refreshing(&self) -> bool {
+ false
+ }
+
+ fn refresh(
+ &mut self,
+ _project: Option<Entity<Project>>,
+ _buffer: gpui::Entity<language::Buffer>,
+ _cursor_position: language::Anchor,
+ _debounce: bool,
+ _cx: &mut gpui::Context<Self>,
+ ) {
+ }
+
+ fn cycle(
+ &mut self,
+ _buffer: gpui::Entity<language::Buffer>,
+ _cursor_position: language::Anchor,
+ _direction: edit_prediction::Direction,
+ _cx: &mut gpui::Context<Self>,
+ ) {
+ }
+
+ fn accept(&mut self, _cx: &mut gpui::Context<Self>) {}
+
+ fn discard(&mut self, _cx: &mut gpui::Context<Self>) {}
+
+ fn suggest<'a>(
+ &mut self,
+ _buffer: &gpui::Entity<language::Buffer>,
+ _cursor_position: language::Anchor,
+ _cx: &mut gpui::Context<Self>,
+ ) -> Option<edit_prediction::EditPrediction> {
+ self.completion.clone()
+ }
+}
+
+#[derive(Default, Clone)]
+pub struct FakeNonZedEditPredictionProvider {
+ pub completion: Option<edit_prediction::EditPrediction>,
+}
+
+impl FakeNonZedEditPredictionProvider {
+ pub fn set_edit_prediction(&mut self, completion: Option<edit_prediction::EditPrediction>) {
+ self.completion = completion;
+ }
+}
+
+impl EditPredictionProvider for FakeNonZedEditPredictionProvider {
+ fn name() -> &'static str {
+ "fake-non-zed-provider"
+ }
+
+ fn display_name() -> &'static str {
+ "Fake Non-Zed Provider"
+ }
+
+ fn show_completions_in_menu() -> bool {
+ false
+ }
+
+ fn supports_jump_to_edit() -> bool {
+ false
+ }
+
fn is_enabled(
&self,
_buffer: &gpui::Entity<language::Buffer>,
@@ -355,7 +544,7 @@ impl EditPredictionProvider for FakeInlineCompletionProvider {
&mut self,
_buffer: gpui::Entity<language::Buffer>,
_cursor_position: language::Anchor,
- _direction: inline_completion::Direction,
+ _direction: edit_prediction::Direction,
_cx: &mut gpui::Context<Self>,
) {
}
@@ -369,7 +558,7 @@ impl EditPredictionProvider for FakeInlineCompletionProvider {
_buffer: &gpui::Entity<language::Buffer>,
_cursor_position: language::Anchor,
_cx: &mut gpui::Context<Self>,
- ) -> Option<inline_completion::InlineCompletion> {
+ ) -> Option<edit_prediction::EditPrediction> {
self.completion.clone()
}
}
@@ -43,50 +43,65 @@ pub mod tasks;
#[cfg(test)]
mod code_completion_tests;
#[cfg(test)]
-mod editor_tests;
+mod edit_prediction_tests;
#[cfg(test)]
-mod inline_completion_tests;
+mod editor_tests;
mod signature_help;
#[cfg(any(test, feature = "test-support"))]
pub mod test;
pub(crate) use actions::*;
-pub use actions::{AcceptEditPrediction, OpenExcerpts, OpenExcerptsSplit};
+pub use display_map::{ChunkRenderer, ChunkRendererContext, DisplayPoint, FoldPlaceholder};
+pub use edit_prediction::Direction;
+pub use editor_settings::{
+ CurrentLineHighlight, DocumentColorsRenderMode, EditorSettings, HideMouseMode,
+ ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, ShowMinimap, ShowScrollbar,
+};
+pub use editor_settings_controls::*;
+pub use element::{
+ CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition,
+};
+pub use git::blame::BlameRenderer;
+pub use hover_popover::hover_markdown_style;
+pub use items::MAX_TAB_TITLE_LEN;
+pub use lsp::CompletionContext;
+pub use lsp_ext::lsp_tasks;
+pub use multi_buffer::{
+ Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, PathKey,
+ RowInfo, ToOffset, ToPoint,
+};
+pub use proposed_changes_editor::{
+ ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
+};
+pub use text::Bias;
+
+use ::git::{
+ Restore,
+ blame::{BlameEntry, ParsedCommitMessage},
+};
use aho_corasick::AhoCorasick;
use anyhow::{Context as _, Result, anyhow};
use blink_manager::BlinkManager;
use buffer_diff::DiffHunkStatus;
use client::{Collaborator, ParticipantIndex};
use clock::{AGENT_REPLICA_ID, ReplicaId};
+use code_context_menus::{
+ AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu,
+ CompletionsMenu, ContextMenuOrigin,
+};
use collections::{BTreeMap, HashMap, HashSet, VecDeque};
use convert_case::{Case, Casing};
use dap::TelemetrySpawnLocation;
use display_map::*;
-pub use display_map::{ChunkRenderer, ChunkRendererContext, DisplayPoint, FoldPlaceholder};
-pub use editor_settings::{
- CurrentLineHighlight, DocumentColorsRenderMode, EditorSettings, HideMouseMode,
- ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, ShowScrollbar,
-};
+use edit_prediction::{EditPredictionProvider, EditPredictionProviderHandle};
use editor_settings::{GoToDefinitionFallback, Minimap as MinimapSettings};
-pub use editor_settings_controls::*;
use element::{AcceptEditPredictionBinding, LineWithInvisibles, PositionMap, layout_line};
-pub use element::{
- CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition,
-};
use futures::{
FutureExt, StreamExt as _,
future::{self, Shared, join},
stream::FuturesUnordered,
};
use fuzzy::{StringMatch, StringMatchCandidate};
-use lsp_colors::LspColorData;
-
-use ::git::blame::BlameEntry;
-use ::git::{Restore, blame::ParsedCommitMessage};
-use code_context_menus::{
- AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu,
- CompletionsMenu, ContextMenuOrigin,
-};
use git::blame::{GitBlame, GlobalBlameRenderer};
use gpui::{
Action, Animation, AnimationExt, AnyElement, App, AppContext, AsyncWindowContext,
@@ -100,32 +115,42 @@ use gpui::{
};
use highlight_matching_bracket::refresh_matching_bracket_highlights;
use hover_links::{HoverLink, HoveredLinkState, InlayHighlight, find_file};
-pub use hover_popover::hover_markdown_style;
use hover_popover::{HoverState, hide_hover};
use indent_guides::ActiveIndentGuidesState;
use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy};
-pub use inline_completion::Direction;
-use inline_completion::{EditPredictionProvider, InlineCompletionProviderHandle};
-pub use items::MAX_TAB_TITLE_LEN;
-use itertools::Itertools;
+use itertools::{Either, Itertools};
use language::{
- AutoindentMode, BracketMatch, BracketPair, Buffer, Capability, CharKind, CodeLabel,
- CursorShape, DiagnosticEntry, DiffOptions, DocumentationConfig, EditPredictionsMode,
- EditPreview, HighlightedText, IndentKind, IndentSize, Language, OffsetRangeExt, Point,
- Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery,
+ AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, BufferRow,
+ BufferSnapshot, Capability, CharClassifier, CharKind, CodeLabel, CursorShape, DiagnosticEntry,
+ DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind, IndentSize,
+ Language, OffsetRangeExt, Point, Runnable, RunnableRange, Selection, SelectionGoal, TextObject,
+ TransactionId, TreeSitterOptions, WordsQuery,
language_settings::{
self, InlayHintSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode,
all_language_settings, language_settings,
},
- point_from_lsp, text_diff_with_options,
+ point_from_lsp, point_to_lsp, text_diff_with_options,
};
-use language::{BufferRow, CharClassifier, Runnable, RunnableRange, point_to_lsp};
use linked_editing_ranges::refresh_linked_ranges;
+use lsp::{
+ CodeActionKind, CompletionItemKind, CompletionTriggerKind, InsertTextFormat, InsertTextMode,
+ LanguageServerId,
+};
+use lsp_colors::LspColorData;
use markdown::Markdown;
use mouse_context_menu::MouseContextMenu;
+use movement::TextLayoutDetails;
+use multi_buffer::{
+ ExcerptInfo, ExpandExcerptDirection, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferRow,
+ MultiOrSingleBufferOffsetRange, ToOffsetUtf16,
+};
+use parking_lot::Mutex;
use persistence::DB;
use project::{
- BreakpointWithPosition, CompletionResponse, ProjectPath,
+ BreakpointWithPosition, CodeAction, Completion, CompletionIntent, CompletionResponse,
+ CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, Location, LocationLink,
+ PrepareRenameResponse, Project, ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind,
+ debugger::breakpoint_store::Breakpoint,
debugger::{
breakpoint_store::{
BreakpointEditAction, BreakpointSessionState, BreakpointState, BreakpointStore,
@@ -134,44 +159,12 @@ use project::{
session::{Session, SessionEvent},
},
git_store::{GitStoreEvent, RepositoryEvent},
- project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter},
-};
-
-pub use git::blame::BlameRenderer;
-pub use proposed_changes_editor::{
- ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
-};
-use std::{cell::OnceCell, iter::Peekable, ops::Not};
-use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables};
-
-pub use lsp::CompletionContext;
-use lsp::{
- CodeActionKind, CompletionItemKind, CompletionTriggerKind, InsertTextFormat, InsertTextMode,
- LanguageServerId, LanguageServerName,
-};
-
-use language::BufferSnapshot;
-pub use lsp_ext::lsp_tasks;
-use movement::TextLayoutDetails;
-pub use multi_buffer::{
- Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, PathKey,
- RowInfo, ToOffset, ToPoint,
-};
-use multi_buffer::{
- ExcerptInfo, ExpandExcerptDirection, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferRow,
- MultiOrSingleBufferOffsetRange, ToOffsetUtf16,
-};
-use parking_lot::Mutex;
-use project::{
- CodeAction, Completion, CompletionIntent, CompletionSource, DocumentHighlight, InlayHint,
- Location, LocationLink, PrepareRenameResponse, Project, ProjectItem, ProjectTransaction,
- TaskSourceKind,
- debugger::breakpoint_store::Breakpoint,
lsp_store::{CompletionDocumentation, FormatTrigger, LspFormatTarget, OpenLspBufferHandle},
+ project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter},
project_settings::{GitGutterSetting, ProjectSettings},
};
-use rand::prelude::*;
-use rpc::{ErrorExt, proto::*};
+use rand::{seq::SliceRandom, thread_rng};
+use rpc::{ErrorCode, ErrorExt, proto::PeerId};
use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager, ScrollbarAutoHide};
use selections_collection::{
MutableSelectionsCollection, SelectionsCollection, resolve_selections,
@@ -180,21 +173,24 @@ use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsLocation, SettingsStore, update_settings_file};
use smallvec::{SmallVec, smallvec};
use snippet::Snippet;
-use std::sync::Arc;
use std::{
any::TypeId,
borrow::Cow,
+ cell::OnceCell,
cell::RefCell,
cmp::{self, Ordering, Reverse},
+ iter::Peekable,
mem,
num::NonZeroU32,
+ ops::Not,
ops::{ControlFlow, Deref, DerefMut, Range, RangeInclusive},
path::{Path, PathBuf},
rc::Rc,
+ sync::Arc,
time::{Duration, Instant},
};
-pub use sum_tree::Bias;
use sum_tree::TreeMap;
+use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables};
use text::{BufferId, FromAnchor, OffsetUtf16, Rope};
use theme::{
ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, ThemeSettings,
@@ -216,10 +212,8 @@ use workspace::{
use crate::{
code_context_menus::CompletionsMenuSource,
- hover_links::{find_url, find_url_from_range},
-};
-use crate::{
editor_settings::MultiCursorModifier,
+ hover_links::{find_url, find_url_from_range},
signature_help::{SignatureHelpHiddenBy, SignatureHelpState},
};
@@ -256,6 +250,22 @@ pub type RenderDiffHunkControlsFn = Arc<
) -> AnyElement,
>;
+enum ReportEditorEvent {
+ Saved { auto_saved: bool },
+ EditorOpened,
+ Closed,
+}
+
+impl ReportEditorEvent {
+ pub fn event_type(&self) -> &'static str {
+ match self {
+ Self::Saved { .. } => "Editor Saved",
+ Self::EditorOpened => "Editor Opened",
+ Self::Closed => "Editor Closed",
+ }
+ }
+}
+
struct InlineValueCache {
enabled: bool,
inlays: Vec<InlayId>,
@@ -274,7 +284,7 @@ impl InlineValueCache {
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum InlayId {
- InlineCompletion(usize),
+ EditPrediction(usize),
DebuggerValue(usize),
// LSP
Hint(usize),
@@ -284,7 +294,7 @@ pub enum InlayId {
impl InlayId {
fn id(&self) -> usize {
match self {
- Self::InlineCompletion(id) => *id,
+ Self::EditPrediction(id) => *id,
Self::DebuggerValue(id) => *id,
Self::Hint(id) => *id,
Self::Color(id) => *id,
@@ -356,6 +366,7 @@ pub fn init(cx: &mut App) {
workspace.register_action(Editor::new_file_vertical);
workspace.register_action(Editor::new_file_horizontal);
workspace.register_action(Editor::cancel_language_server_work);
+ workspace.register_action(Editor::toggle_focus);
},
)
.detach();
@@ -482,9 +493,7 @@ pub enum SelectMode {
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum EditorMode {
- SingleLine {
- auto_width: bool,
- },
+ SingleLine,
AutoHeight {
min_lines: usize,
max_lines: Option<usize>,
@@ -554,7 +563,7 @@ pub struct EditorStyle {
pub syntax: Arc<SyntaxTheme>,
pub status: StatusColors,
pub inlay_hints_style: HighlightStyle,
- pub inline_completion_styles: InlineCompletionStyles,
+ pub edit_prediction_styles: EditPredictionStyles,
pub unnecessary_code_fade: f32,
pub show_underlines: bool,
}
@@ -573,7 +582,7 @@ impl Default for EditorStyle {
// style and retrieve them directly from the theme.
status: StatusColors::dark(),
inlay_hints_style: HighlightStyle::default(),
- inline_completion_styles: InlineCompletionStyles {
+ edit_prediction_styles: EditPredictionStyles {
insertion: HighlightStyle::default(),
whitespace: HighlightStyle::default(),
},
@@ -595,8 +604,8 @@ pub fn make_inlay_hints_style(cx: &mut App) -> HighlightStyle {
}
}
-pub fn make_suggestion_styles(cx: &mut App) -> InlineCompletionStyles {
- InlineCompletionStyles {
+pub fn make_suggestion_styles(cx: &mut App) -> EditPredictionStyles {
+ EditPredictionStyles {
insertion: HighlightStyle {
color: Some(cx.theme().status().predictive),
..HighlightStyle::default()
@@ -616,7 +625,7 @@ pub(crate) enum EditDisplayMode {
Inline,
}
-enum InlineCompletion {
+enum EditPrediction {
Edit {
edits: Vec<(Range<Anchor>, String)>,
edit_preview: Option<EditPreview>,
@@ -629,9 +638,9 @@ enum InlineCompletion {
},
}
-struct InlineCompletionState {
+struct EditPredictionState {
inlay_ids: Vec<InlayId>,
- completion: InlineCompletion,
+ completion: EditPrediction,
completion_id: Option<SharedString>,
invalidation_range: Range<Anchor>,
}
@@ -644,7 +653,7 @@ enum EditPredictionSettings {
},
}
-enum InlineCompletionHighlight {}
+enum EditPredictionHighlight {}
#[derive(Debug, Clone)]
struct InlineDiagnostic {
@@ -655,7 +664,7 @@ struct InlineDiagnostic {
severity: lsp::DiagnosticSeverity,
}
-pub enum MenuInlineCompletionsPolicy {
+pub enum MenuEditPredictionsPolicy {
Never,
ByProvider,
}
@@ -771,10 +780,7 @@ impl MinimapVisibility {
}
fn disabled(&self) -> bool {
- match *self {
- Self::Disabled => true,
- _ => false,
- }
+ matches!(*self, Self::Disabled)
}
fn settings_visibility(&self) -> bool {
@@ -931,10 +937,10 @@ impl ChangeList {
}
pub fn invert_last_group(&mut self) {
- if let Some(last) = self.changes.last_mut() {
- if let Some(current) = last.current.as_mut() {
- mem::swap(&mut last.original, current);
- }
+ if let Some(last) = self.changes.last_mut()
+ && let Some(current) = last.current.as_mut()
+ {
+ mem::swap(&mut last.original, current);
}
}
}
@@ -951,6 +957,7 @@ struct InlineBlamePopover {
hide_task: Option<Task<()>>,
popover_bounds: Option<Bounds<Pixels>>,
popover_state: InlineBlamePopoverState,
+ keyboard_grace: bool,
}
enum SelectionDragState {
@@ -1027,9 +1034,7 @@ pub struct Editor {
inline_diagnostics: Vec<(Anchor, InlineDiagnostic)>,
soft_wrap_mode_override: Option<language_settings::SoftWrap>,
hard_wrap: Option<usize>,
-
- // TODO: make this a access method
- pub project: Option<Entity<Project>>,
+ project: Option<Entity<Project>>,
semantics_provider: Option<Rc<dyn SemanticsProvider>>,
completion_provider: Option<Rc<dyn CompletionProvider>>,
collaboration_hub: Option<Box<dyn CollaborationHub>>,
@@ -1093,15 +1098,15 @@ pub struct Editor {
pending_mouse_down: Option<Rc<RefCell<Option<MouseDownEvent>>>>,
gutter_hovered: bool,
hovered_link_state: Option<HoveredLinkState>,
- edit_prediction_provider: Option<RegisteredInlineCompletionProvider>,
+ edit_prediction_provider: Option<RegisteredEditPredictionProvider>,
code_action_providers: Vec<Rc<dyn CodeActionProvider>>,
- active_inline_completion: Option<InlineCompletionState>,
+ active_edit_prediction: Option<EditPredictionState>,
/// Used to prevent flickering as the user types while the menu is open
- stale_inline_completion_in_menu: Option<InlineCompletionState>,
+ stale_edit_prediction_in_menu: Option<EditPredictionState>,
edit_prediction_settings: EditPredictionSettings,
- inline_completions_hidden_for_vim_mode: bool,
- show_inline_completions_override: Option<bool>,
- menu_inline_completions_policy: MenuInlineCompletionsPolicy,
+ edit_predictions_hidden_for_vim_mode: bool,
+ show_edit_predictions_override: Option<bool>,
+ menu_edit_predictions_policy: MenuEditPredictionsPolicy,
edit_prediction_preview: EditPredictionPreview,
edit_prediction_indent_conflict: bool,
edit_prediction_requires_modifier_in_indent_conflict: bool,
@@ -1304,6 +1309,7 @@ impl Default for SelectionHistoryMode {
///
/// Similarly, you might want to disable scrolling if you don't want the viewport to
/// move.
+#[derive(Clone)]
pub struct SelectionEffects {
nav_history: Option<bool>,
completions: bool,
@@ -1418,7 +1424,7 @@ impl SelectionHistory {
if self
.undo_stack
.back()
- .map_or(true, |e| e.selections != entry.selections)
+ .is_none_or(|e| e.selections != entry.selections)
{
self.undo_stack.push_back(entry);
if self.undo_stack.len() > MAX_SELECTION_HISTORY_LEN {
@@ -1431,7 +1437,7 @@ impl SelectionHistory {
if self
.redo_stack
.back()
- .map_or(true, |e| e.selections != entry.selections)
+ .is_none_or(|e| e.selections != entry.selections)
{
self.redo_stack.push_back(entry);
if self.redo_stack.len() > MAX_SELECTION_HISTORY_LEN {
@@ -1515,8 +1521,8 @@ pub struct RenameState {
struct InvalidationStack<T>(Vec<T>);
-struct RegisteredInlineCompletionProvider {
- provider: Arc<dyn InlineCompletionProviderHandle>,
+struct RegisteredEditPredictionProvider {
+ provider: Arc<dyn EditPredictionProviderHandle>,
_subscription: Subscription,
}
@@ -1662,13 +1668,7 @@ impl Editor {
pub fn single_line(window: &mut Window, cx: &mut Context<Self>) -> Self {
let buffer = cx.new(|cx| Buffer::local("", cx));
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
- Self::new(
- EditorMode::SingleLine { auto_width: false },
- buffer,
- None,
- window,
- cx,
- )
+ Self::new(EditorMode::SingleLine, buffer, None, window, cx)
}
pub fn multi_line(window: &mut Window, cx: &mut Context<Self>) -> Self {
@@ -1677,18 +1677,6 @@ impl Editor {
Self::new(EditorMode::full(), buffer, None, window, cx)
}
- pub fn auto_width(window: &mut Window, cx: &mut Context<Self>) -> Self {
- let buffer = cx.new(|cx| Buffer::local("", cx));
- let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
- Self::new(
- EditorMode::SingleLine { auto_width: true },
- buffer,
- None,
- window,
- cx,
- )
- }
-
pub fn auto_height(
min_lines: usize,
max_lines: usize,
@@ -1791,7 +1779,7 @@ impl Editor {
) -> Self {
debug_assert!(
display_map.is_none() || mode.is_minimap(),
- "Providing a display map for a new editor is only intended for the minimap and might have unindended side effects otherwise!"
+ "Providing a display map for a new editor is only intended for the minimap and might have unintended side effects otherwise!"
);
let full_mode = mode.is_full();
@@ -1864,114 +1852,166 @@ impl Editor {
blink_manager
});
- let soft_wrap_mode_override = matches!(mode, EditorMode::SingleLine { .. })
- .then(|| language_settings::SoftWrap::None);
+ let soft_wrap_mode_override =
+ matches!(mode, EditorMode::SingleLine).then(|| language_settings::SoftWrap::None);
let mut project_subscriptions = Vec::new();
- if full_mode {
- if let Some(project) = project.as_ref() {
- project_subscriptions.push(cx.subscribe_in(
- project,
- window,
- |editor, _, event, window, cx| match event {
- project::Event::RefreshCodeLens => {
- // we always query lens with actions, without storing them, always refreshing them
- }
- project::Event::RefreshInlayHints => {
- editor
- .refresh_inlay_hints(InlayHintRefreshReason::RefreshRequested, cx);
+ if full_mode && let Some(project) = project.as_ref() {
+ project_subscriptions.push(cx.subscribe_in(
+ project,
+ window,
+ |editor, _, event, window, cx| match event {
+ project::Event::RefreshCodeLens => {
+ // we always query lens with actions, without storing them, always refreshing them
+ }
+ project::Event::RefreshInlayHints => {
+ editor.refresh_inlay_hints(InlayHintRefreshReason::RefreshRequested, cx);
+ }
+ project::Event::LanguageServerAdded(..)
+ | project::Event::LanguageServerRemoved(..) => {
+ if editor.tasks_update_task.is_none() {
+ editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
}
- project::Event::LanguageServerAdded(..)
- | project::Event::LanguageServerRemoved(..) => {
- if editor.tasks_update_task.is_none() {
- editor.tasks_update_task =
- Some(editor.refresh_runnables(window, cx));
+ }
+ project::Event::SnippetEdit(id, snippet_edits) => {
+ if let Some(buffer) = editor.buffer.read(cx).buffer(*id) {
+ let focus_handle = editor.focus_handle(cx);
+ if focus_handle.is_focused(window) {
+ let snapshot = buffer.read(cx).snapshot();
+ for (range, snippet) in snippet_edits {
+ let editor_range =
+ language::range_from_lsp(*range).to_offset(&snapshot);
+ editor
+ .insert_snippet(
+ &[editor_range],
+ snippet.clone(),
+ window,
+ cx,
+ )
+ .ok();
+ }
}
- editor.update_lsp_data(true, None, window, cx);
}
- project::Event::SnippetEdit(id, snippet_edits) => {
- if let Some(buffer) = editor.buffer.read(cx).buffer(*id) {
- let focus_handle = editor.focus_handle(cx);
- if focus_handle.is_focused(window) {
- let snapshot = buffer.read(cx).snapshot();
- for (range, snippet) in snippet_edits {
- let editor_range =
- language::range_from_lsp(*range).to_offset(&snapshot);
- editor
- .insert_snippet(
- &[editor_range],
- snippet.clone(),
- window,
- cx,
+ }
+ project::Event::LanguageServerBufferRegistered { buffer_id, .. } => {
+ if editor.buffer().read(cx).buffer(*buffer_id).is_some() {
+ editor.update_lsp_data(false, Some(*buffer_id), window, cx);
+ }
+ }
+
+ project::Event::EntryRenamed(transaction) => {
+ let Some(workspace) = editor.workspace() else {
+ return;
+ };
+ let Some(active_editor) = workspace.read(cx).active_item_as::<Self>(cx)
+ else {
+ return;
+ };
+ if active_editor.entity_id() == cx.entity_id() {
+ let edited_buffers_already_open = {
+ let other_editors: Vec<Entity<Editor>> = workspace
+ .read(cx)
+ .panes()
+ .iter()
+ .flat_map(|pane| pane.read(cx).items_of_type::<Editor>())
+ .filter(|editor| editor.entity_id() != cx.entity_id())
+ .collect();
+
+ transaction.0.keys().all(|buffer| {
+ other_editors.iter().any(|editor| {
+ let multi_buffer = editor.read(cx).buffer();
+ multi_buffer.read(cx).is_singleton()
+ && multi_buffer.read(cx).as_singleton().map_or(
+ false,
+ |singleton| {
+ singleton.entity_id() == buffer.entity_id()
+ },
)
- .ok();
- }
- }
+ })
+ })
+ };
+
+ if !edited_buffers_already_open {
+ let workspace = workspace.downgrade();
+ let transaction = transaction.clone();
+ cx.defer_in(window, move |_, window, cx| {
+ cx.spawn_in(window, async move |editor, cx| {
+ Self::open_project_transaction(
+ &editor,
+ workspace,
+ transaction,
+ "Rename".to_string(),
+ cx,
+ )
+ .await
+ .ok()
+ })
+ .detach();
+ });
}
}
- _ => {}
- },
- ));
- if let Some(task_inventory) = project
- .read(cx)
- .task_store()
- .read(cx)
- .task_inventory()
- .cloned()
- {
- project_subscriptions.push(cx.observe_in(
- &task_inventory,
- window,
- |editor, _, window, cx| {
- editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
- },
- ));
- };
+ }
- project_subscriptions.push(cx.subscribe_in(
- &project.read(cx).breakpoint_store(),
+ _ => {}
+ },
+ ));
+ if let Some(task_inventory) = project
+ .read(cx)
+ .task_store()
+ .read(cx)
+ .task_inventory()
+ .cloned()
+ {
+ project_subscriptions.push(cx.observe_in(
+ &task_inventory,
window,
- |editor, _, event, window, cx| match event {
- BreakpointStoreEvent::ClearDebugLines => {
- editor.clear_row_highlights::<ActiveDebugLine>();
- editor.refresh_inline_values(cx);
- }
- BreakpointStoreEvent::SetDebugLine => {
- if editor.go_to_active_debug_line(window, cx) {
- cx.stop_propagation();
- }
-
- editor.refresh_inline_values(cx);
- }
- _ => {}
+ |editor, _, window, cx| {
+ editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
},
));
- let git_store = project.read(cx).git_store().clone();
- let project = project.clone();
- project_subscriptions.push(cx.subscribe(&git_store, move |this, _, event, cx| {
- match event {
- GitStoreEvent::RepositoryUpdated(
- _,
- RepositoryEvent::Updated {
- new_instance: true, ..
- },
- _,
- ) => {
- this.load_diff_task = Some(
- update_uncommitted_diff_for_buffer(
- cx.entity(),
- &project,
- this.buffer.read(cx).all_buffers(),
- this.buffer.clone(),
- cx,
- )
- .shared(),
- );
+ };
+
+ project_subscriptions.push(cx.subscribe_in(
+ &project.read(cx).breakpoint_store(),
+ window,
+ |editor, _, event, window, cx| match event {
+ BreakpointStoreEvent::ClearDebugLines => {
+ editor.clear_row_highlights::<ActiveDebugLine>();
+ editor.refresh_inline_values(cx);
+ }
+ BreakpointStoreEvent::SetDebugLine => {
+ if editor.go_to_active_debug_line(window, cx) {
+ cx.stop_propagation();
}
- _ => {}
+
+ editor.refresh_inline_values(cx);
}
- }));
- }
+ _ => {}
+ },
+ ));
+ let git_store = project.read(cx).git_store().clone();
+ let project = project.clone();
+ project_subscriptions.push(cx.subscribe(&git_store, move |this, _, event, cx| {
+ if let GitStoreEvent::RepositoryUpdated(
+ _,
+ RepositoryEvent::Updated {
+ new_instance: true, ..
+ },
+ _,
+ ) = event
+ {
+ this.load_diff_task = Some(
+ update_uncommitted_diff_for_buffer(
+ cx.entity(),
+ &project,
+ this.buffer.read(cx).all_buffers(),
+ this.buffer.clone(),
+ cx,
+ )
+ .shared(),
+ );
+ }
+ }));
}
let buffer_snapshot = buffer.read(cx).snapshot(cx);
@@ -1992,14 +2032,12 @@ impl Editor {
.detach();
}
- let show_indent_guides = if matches!(
- mode,
- EditorMode::SingleLine { .. } | EditorMode::Minimap { .. }
- ) {
- Some(false)
- } else {
- None
- };
+ let show_indent_guides =
+ if matches!(mode, EditorMode::SingleLine | EditorMode::Minimap { .. }) {
+ Some(false)
+ } else {
+ None
+ };
let breakpoint_store = match (&mode, project.as_ref()) {
(EditorMode::Full { .. }, Some(project)) => Some(project.read(cx).breakpoint_store()),
@@ -2059,7 +2097,7 @@ impl Editor {
vertical: full_mode,
},
minimap_visibility: MinimapVisibility::for_mode(&mode, cx),
- offset_content: !matches!(mode, EditorMode::SingleLine { .. }),
+ offset_content: !matches!(mode, EditorMode::SingleLine),
show_breadcrumbs: EditorSettings::get_global(cx).toolbar.breadcrumbs,
show_gutter: full_mode,
show_line_numbers: (!full_mode).then_some(false),
@@ -2119,8 +2157,8 @@ impl Editor {
pending_mouse_down: None,
hovered_link_state: None,
edit_prediction_provider: None,
- active_inline_completion: None,
- stale_inline_completion_in_menu: None,
+ active_edit_prediction: None,
+ stale_edit_prediction_in_menu: None,
edit_prediction_preview: EditPredictionPreview::Inactive {
released_too_fast: false,
},
@@ -2139,9 +2177,9 @@ impl Editor {
hovered_cursors: HashMap::default(),
next_editor_action_id: EditorActionId::default(),
editor_actions: Rc::default(),
- inline_completions_hidden_for_vim_mode: false,
- show_inline_completions_override: None,
- menu_inline_completions_policy: MenuInlineCompletionsPolicy::ByProvider,
+ edit_predictions_hidden_for_vim_mode: false,
+ show_edit_predictions_override: None,
+ menu_edit_predictions_policy: MenuEditPredictionsPolicy::ByProvider,
edit_prediction_settings: EditPredictionSettings::Disabled,
edit_prediction_indent_conflict: false,
edit_prediction_requires_modifier_in_indent_conflict: true,
@@ -2326,15 +2364,15 @@ impl Editor {
editor.go_to_active_debug_line(window, cx);
- if let Some(buffer) = buffer.read(cx).as_singleton() {
- if let Some(project) = editor.project.as_ref() {
- let handle = project.update(cx, |project, cx| {
- project.register_buffer_with_language_servers(&buffer, cx)
- });
- editor
- .registered_buffers
- .insert(buffer.read(cx).remote_id(), handle);
- }
+ if let Some(buffer) = buffer.read(cx).as_singleton()
+ && let Some(project) = editor.project()
+ {
+ let handle = project.update(cx, |project, cx| {
+ project.register_buffer_with_language_servers(&buffer, cx)
+ });
+ editor
+ .registered_buffers
+ .insert(buffer.read(cx).remote_id(), handle);
}
editor.minimap =
@@ -2344,7 +2382,7 @@ impl Editor {
}
if editor.mode.is_full() {
- editor.report_editor_event("Editor Opened", None, cx);
+ editor.report_editor_event(ReportEditorEvent::EditorOpened, None, cx);
}
editor
@@ -2372,8 +2410,36 @@ impl Editor {
.is_some_and(|menu| menu.context_menu.focus_handle(cx).is_focused(window))
}
+ pub fn is_range_selected(&mut self, range: &Range<Anchor>, cx: &mut Context<Self>) -> bool {
+ if self
+ .selections
+ .pending
+ .as_ref()
+ .is_some_and(|pending_selection| {
+ let snapshot = self.buffer().read(cx).snapshot(cx);
+ pending_selection
+ .selection
+ .range()
+ .includes(range, &snapshot)
+ })
+ {
+ return true;
+ }
+
+ self.selections
+ .disjoint_in_range::<usize>(range.clone(), cx)
+ .into_iter()
+ .any(|selection| {
+ // This is needed to cover a corner case, if we just check for an existing
+ // selection in the fold range, having a cursor at the start of the fold
+ // marks it as selected. Non-empty selections don't cause this.
+ let length = selection.end - selection.start;
+ length > 0
+ })
+ }
+
pub fn key_context(&self, window: &Window, cx: &App) -> KeyContext {
- self.key_context_internal(self.has_active_inline_completion(), window, cx)
+ self.key_context_internal(self.has_active_edit_prediction(), window, cx)
}
fn key_context_internal(
@@ -2385,7 +2451,7 @@ impl Editor {
let mut key_context = KeyContext::new_with_defaults();
key_context.add("Editor");
let mode = match self.mode {
- EditorMode::SingleLine { .. } => "single_line",
+ EditorMode::SingleLine => "single_line",
EditorMode::AutoHeight { .. } => "auto_height",
EditorMode::Minimap { .. } => "minimap",
EditorMode::Full { .. } => "full",
@@ -2401,13 +2467,17 @@ impl Editor {
}
match self.context_menu.borrow().as_ref() {
- Some(CodeContextMenu::Completions(_)) => {
- key_context.add("menu");
- key_context.add("showing_completions");
+ Some(CodeContextMenu::Completions(menu)) => {
+ if menu.visible() {
+ key_context.add("menu");
+ key_context.add("showing_completions");
+ }
}
- Some(CodeContextMenu::CodeActions(_)) => {
- key_context.add("menu");
- key_context.add("showing_code_actions")
+ Some(CodeContextMenu::CodeActions(menu)) => {
+ if menu.visible() {
+ key_context.add("menu");
+ key_context.add("showing_code_actions")
+ }
}
None => {}
}
@@ -2487,9 +2557,7 @@ impl Editor {
.context_menu
.borrow()
.as_ref()
- .map_or(false, |context| {
- matches!(context, CodeContextMenu::Completions(_))
- });
+ .is_some_and(|context| matches!(context, CodeContextMenu::Completions(_)));
showing_completions
|| self.edit_prediction_requires_modifier()
@@ -2520,7 +2588,7 @@ impl Editor {
|| binding
.keystrokes()
.first()
- .map_or(false, |keystroke| keystroke.modifiers.modified())
+ .is_some_and(|keystroke| keystroke.modifiers.modified())
}))
}
@@ -2623,6 +2691,10 @@ impl Editor {
&self.buffer
}
+ pub fn project(&self) -> Option<&Entity<Project>> {
+ self.project.as_ref()
+ }
+
pub fn workspace(&self) -> Option<Entity<Workspace>> {
self.workspace.as_ref()?.0.upgrade()
}
@@ -2720,6 +2792,11 @@ impl Editor {
self.completion_provider = provider;
}
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn completion_provider(&self) -> Option<Rc<dyn CompletionProvider>> {
+ self.completion_provider.clone()
+ }
+
pub fn semantics_provider(&self) -> Option<Rc<dyn SemanticsProvider>> {
self.semantics_provider.clone()
}
@@ -2736,17 +2813,16 @@ impl Editor {
) where
T: EditPredictionProvider,
{
- self.edit_prediction_provider =
- provider.map(|provider| RegisteredInlineCompletionProvider {
- _subscription: cx.observe_in(&provider, window, |this, _, window, cx| {
- if this.focus_handle.is_focused(window) {
- this.update_visible_inline_completion(window, cx);
- }
- }),
- provider: Arc::new(provider),
- });
+ self.edit_prediction_provider = provider.map(|provider| RegisteredEditPredictionProvider {
+ _subscription: cx.observe_in(&provider, window, |this, _, window, cx| {
+ if this.focus_handle.is_focused(window) {
+ this.update_visible_edit_prediction(window, cx);
+ }
+ }),
+ provider: Arc::new(provider),
+ });
self.update_edit_prediction_settings(cx);
- self.refresh_inline_completion(false, false, window, cx);
+ self.refresh_edit_prediction(false, false, window, cx);
}
pub fn placeholder_text(&self) -> Option<&str> {
@@ -2817,24 +2893,24 @@ impl Editor {
self.input_enabled = input_enabled;
}
- pub fn set_inline_completions_hidden_for_vim_mode(
+ pub fn set_edit_predictions_hidden_for_vim_mode(
&mut self,
hidden: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
- if hidden != self.inline_completions_hidden_for_vim_mode {
- self.inline_completions_hidden_for_vim_mode = hidden;
+ if hidden != self.edit_predictions_hidden_for_vim_mode {
+ self.edit_predictions_hidden_for_vim_mode = hidden;
if hidden {
- self.update_visible_inline_completion(window, cx);
+ self.update_visible_edit_prediction(window, cx);
} else {
- self.refresh_inline_completion(true, false, window, cx);
+ self.refresh_edit_prediction(true, false, window, cx);
}
}
}
- pub fn set_menu_inline_completions_policy(&mut self, value: MenuInlineCompletionsPolicy) {
- self.menu_inline_completions_policy = value;
+ pub fn set_menu_edit_predictions_policy(&mut self, value: MenuEditPredictionsPolicy) {
+ self.menu_edit_predictions_policy = value;
}
pub fn set_autoindent(&mut self, autoindent: bool) {
@@ -20,6 +20,7 @@ pub struct EditorSettings {
pub lsp_highlight_debounce: u64,
pub hover_popover_enabled: bool,
pub hover_popover_delay: u64,
+ pub status_bar: StatusBar,
pub toolbar: Toolbar,
pub scrollbar: Scrollbar,
pub minimap: Minimap,
@@ -125,6 +126,18 @@ pub struct JupyterContent {
pub enabled: Option<bool>,
}
+#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+pub struct StatusBar {
+ /// Whether to display the active language button in the status bar.
+ ///
+ /// Default: true
+ pub active_language_button: bool,
+ /// Whether to show the cursor position button in the status bar.
+ ///
+ /// Default: true
+ pub cursor_position_button: bool,
+}
+
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct Toolbar {
pub breadcrumbs: bool,
@@ -395,6 +408,8 @@ pub enum SnippetSortOrder {
Inline,
/// Place snippets at the bottom of the completion list
Bottom,
+ /// Do not show snippets in the completion list
+ None,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
@@ -438,6 +453,8 @@ pub struct EditorSettingsContent {
///
/// Default: 300
pub hover_popover_delay: Option<u64>,
+ /// Status bar related settings
+ pub status_bar: Option<StatusBarContent>,
/// Toolbar related settings
pub toolbar: Option<ToolbarContent>,
/// Scrollbar related settings
@@ -565,6 +582,19 @@ pub struct EditorSettingsContent {
pub lsp_document_colors: Option<DocumentColorsRenderMode>,
}
+// Status bar related settings
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+pub struct StatusBarContent {
+ /// Whether to display the active language button in the status bar.
+ ///
+ /// Default: true
+ pub active_language_button: Option<bool>,
+ /// Whether to show the cursor position button in the status bar.
+ ///
+ /// Default: true
+ pub cursor_position_button: Option<bool>,
+}
+
// Toolbar related settings
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ToolbarContent {
@@ -780,10 +810,8 @@ impl Settings for EditorSettings {
if gutter.line_numbers.is_some() {
old_gutter.line_numbers = gutter.line_numbers
}
- } else {
- if gutter != GutterContent::default() {
- current.gutter = Some(gutter)
- }
+ } else if gutter != GutterContent::default() {
+ current.gutter = Some(gutter)
}
if let Some(b) = vscode.read_bool("editor.scrollBeyondLastLine") {
current.scroll_beyond_last_line = Some(if b {
@@ -88,7 +88,7 @@ impl RenderOnce for BufferFontFamilyControl {
.child(Icon::new(IconName::Font))
.child(DropdownMenu::new(
"buffer-font-family",
- value.clone(),
+ value,
ContextMenu::build(window, cx, |mut menu, _, cx| {
let font_family_cache = FontFamilyCache::global(cx);
@@ -2,7 +2,7 @@ use super::*;
use crate::{
JoinLines,
code_context_menus::CodeContextMenu,
- inline_completion_tests::FakeInlineCompletionProvider,
+ edit_prediction_tests::FakeEditPredictionProvider,
linked_editing_ranges::LinkedEditingRanges,
scroll::scroll_amount::ScrollAmount,
test::{
@@ -55,9 +55,11 @@ use util::{
uri,
};
use workspace::{
- CloseActiveItem, CloseAllItems, CloseInactiveItems, MoveItemToPaneInDirection, NavigationEntry,
+ CloseActiveItem, CloseAllItems, CloseOtherItems, MoveItemToPaneInDirection, NavigationEntry,
OpenOptions, ViewId,
+ invalid_buffer_view::InvalidBufferView,
item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions},
+ register_project_item,
};
#[gpui::test]
@@ -74,7 +76,7 @@ fn test_edit_events(cx: &mut TestAppContext) {
let editor1 = cx.add_window({
let events = events.clone();
|window, cx| {
- let entity = cx.entity().clone();
+ let entity = cx.entity();
cx.subscribe_in(
&entity,
window,
@@ -95,7 +97,7 @@ fn test_edit_events(cx: &mut TestAppContext) {
let events = events.clone();
|window, cx| {
cx.subscribe_in(
- &cx.entity().clone(),
+ &cx.entity(),
window,
move |_, _, event: &EditorEvent, _, _| match event {
EditorEvent::Edited { .. } => events.borrow_mut().push(("editor2", "edited")),
@@ -708,7 +710,7 @@ async fn test_navigation_history(cx: &mut TestAppContext) {
_ = workspace.update(cx, |_v, window, cx| {
cx.new(|cx| {
let buffer = MultiBuffer::build_simple(&sample_text(300, 5, 'a'), cx);
- let mut editor = build_editor(buffer.clone(), window, cx);
+ let mut editor = build_editor(buffer, window, cx);
let handle = cx.entity();
editor.set_nav_history(Some(pane.read(cx).nav_history_for_item(&handle)));
@@ -898,7 +900,7 @@ fn test_fold_action(cx: &mut TestAppContext) {
.unindent(),
cx,
);
- build_editor(buffer.clone(), window, cx)
+ build_editor(buffer, window, cx)
});
_ = editor.update(cx, |editor, window, cx| {
@@ -989,7 +991,7 @@ fn test_fold_action_whitespace_sensitive_language(cx: &mut TestAppContext) {
.unindent(),
cx,
);
- build_editor(buffer.clone(), window, cx)
+ build_editor(buffer, window, cx)
});
_ = editor.update(cx, |editor, window, cx| {
@@ -1074,7 +1076,7 @@ fn test_fold_action_multiple_line_breaks(cx: &mut TestAppContext) {
.unindent(),
cx,
);
- build_editor(buffer.clone(), window, cx)
+ build_editor(buffer, window, cx)
});
_ = editor.update(cx, |editor, window, cx| {
@@ -1173,7 +1175,7 @@ fn test_fold_at_level(cx: &mut TestAppContext) {
.unindent(),
cx,
);
- build_editor(buffer.clone(), window, cx)
+ build_editor(buffer, window, cx)
});
_ = editor.update(cx, |editor, window, cx| {
@@ -1335,7 +1337,7 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) {
let editor = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple("🟥🟧🟨🟩🟦🟪\nabcde\nαβγδε", cx);
- build_editor(buffer.clone(), window, cx)
+ build_editor(buffer, window, cx)
});
assert_eq!('🟥'.len_utf8(), 4);
@@ -1452,7 +1454,7 @@ fn test_move_cursor_different_line_lengths(cx: &mut TestAppContext) {
let editor = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcd\nαβγ\nabcd\nⓐⓑⓒⓓⓔ\n", cx);
- build_editor(buffer.clone(), window, cx)
+ build_editor(buffer, window, cx)
});
_ = editor.update(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
@@ -1901,6 +1903,51 @@ fn test_beginning_of_line_stop_at_indent(cx: &mut TestAppContext) {
});
}
+#[gpui::test]
+fn test_beginning_of_line_with_cursor_between_line_start_and_indent(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let move_to_beg = MoveToBeginningOfLine {
+ stop_at_soft_wraps: true,
+ stop_at_indent: true,
+ };
+
+ let editor = cx.add_window(|window, cx| {
+ let buffer = MultiBuffer::build_simple(" hello\nworld", cx);
+ build_editor(buffer, window, cx)
+ });
+
+ _ = editor.update(cx, |editor, window, cx| {
+ // test cursor between line_start and indent_start
+ editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+ s.select_display_ranges([
+ DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3)
+ ]);
+ });
+
+ // cursor should move to line_start
+ editor.move_to_beginning_of_line(&move_to_beg, window, cx);
+ assert_eq!(
+ editor.selections.display_ranges(cx),
+ &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)]
+ );
+
+ // cursor should move to indent_start
+ editor.move_to_beginning_of_line(&move_to_beg, window, cx);
+ assert_eq!(
+ editor.selections.display_ranges(cx),
+ &[DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 4)]
+ );
+
+ // cursor should move to back to line_start
+ editor.move_to_beginning_of_line(&move_to_beg, window, cx);
+ assert_eq!(
+ editor.selections.display_ranges(cx),
+ &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)]
+ );
+ });
+}
+
#[gpui::test]
fn test_prev_next_word_boundary(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -2434,7 +2481,7 @@ fn test_delete_to_word_boundary(cx: &mut TestAppContext) {
let editor = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple("one two three four", cx);
- build_editor(buffer.clone(), window, cx)
+ build_editor(buffer, window, cx)
});
_ = editor.update(cx, |editor, window, cx| {
@@ -2482,7 +2529,7 @@ fn test_delete_to_previous_word_start_or_newline(cx: &mut TestAppContext) {
let editor = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple("one\n2\nthree\n4", cx);
- build_editor(buffer.clone(), window, cx)
+ build_editor(buffer, window, cx)
});
let del_to_prev_word_start = DeleteToPreviousWordStart {
ignore_newlines: false,
@@ -2518,7 +2565,7 @@ fn test_delete_to_next_word_end_or_newline(cx: &mut TestAppContext) {
let editor = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple("\none\n two\nthree\n four", cx);
- build_editor(buffer.clone(), window, cx)
+ build_editor(buffer, window, cx)
});
let del_to_next_word_end = DeleteToNextWordEnd {
ignore_newlines: false,
@@ -2563,7 +2610,7 @@ fn test_newline(cx: &mut TestAppContext) {
let editor = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple("aaaa\n bbbb\n", cx);
- build_editor(buffer.clone(), window, cx)
+ build_editor(buffer, window, cx)
});
_ = editor.update(cx, |editor, window, cx| {
@@ -2599,7 +2646,7 @@ fn test_newline_with_old_selections(cx: &mut TestAppContext) {
.as_str(),
cx,
);
- let mut editor = build_editor(buffer.clone(), window, cx);
+ let mut editor = build_editor(buffer, window, cx);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([
Point::new(2, 4)..Point::new(2, 5),
@@ -2875,11 +2922,11 @@ async fn test_newline_documentation_comments(cx: &mut TestAppContext) {
let language = Arc::new(
Language::new(
LanguageConfig {
- documentation: Some(language::DocumentationConfig {
+ documentation_comment: Some(language::BlockCommentConfig {
start: "/**".into(),
end: "*/".into(),
prefix: "* ".into(),
- tab_size: NonZeroU32::new(1).unwrap(),
+ tab_size: 1,
}),
..LanguageConfig::default()
@@ -3089,7 +3136,12 @@ async fn test_newline_comments_with_block_comment(cx: &mut TestAppContext) {
let lua_language = Arc::new(Language::new(
LanguageConfig {
line_comments: vec!["--".into()],
- block_comment: Some(("--[[".into(), "]]".into())),
+ block_comment: Some(language::BlockCommentConfig {
+ start: "--[[".into(),
+ prefix: "".into(),
+ end: "]]".into(),
+ tab_size: 0,
+ }),
..LanguageConfig::default()
},
None,
@@ -3125,7 +3177,7 @@ fn test_insert_with_old_selections(cx: &mut TestAppContext) {
let editor = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple("a( X ), b( Y ), c( Z )", cx);
- let mut editor = build_editor(buffer.clone(), window, cx);
+ let mut editor = build_editor(buffer, window, cx);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([3..4, 11..12, 19..20])
});
@@ -4719,6 +4771,23 @@ async fn test_toggle_case(cx: &mut TestAppContext) {
"});
}
+#[gpui::test]
+async fn test_convert_to_sentence_case(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorTestContext::new(cx).await;
+
+ cx.set_state(indoc! {"
+ «implement-windows-supportˇ»
+ "});
+ cx.update_editor(|e, window, cx| {
+ e.convert_to_sentence_case(&ConvertToSentenceCase, window, cx)
+ });
+ cx.assert_editor_state(indoc! {"
+ «Implement windows supportˇ»
+ "});
+}
+
#[gpui::test]
async fn test_manipulate_text(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -5064,6 +5133,33 @@ fn test_move_line_up_down(cx: &mut TestAppContext) {
});
}
+#[gpui::test]
+fn test_move_line_up_selection_at_end_of_fold(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+ let editor = cx.add_window(|window, cx| {
+ let buffer = MultiBuffer::build_simple("\n\n\n\n\n\naaaa\nbbbb\ncccc", cx);
+ build_editor(buffer, window, cx)
+ });
+ _ = editor.update(cx, |editor, window, cx| {
+ editor.fold_creases(
+ vec![Crease::simple(
+ Point::new(6, 4)..Point::new(7, 4),
+ FoldPlaceholder::test(),
+ )],
+ true,
+ window,
+ cx,
+ );
+ editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+ s.select_ranges([Point::new(7, 4)..Point::new(7, 4)])
+ });
+ assert_eq!(editor.display_text(cx), "\n\n\n\n\n\naaaa⋯\ncccc");
+ editor.move_line_up(&MoveLineUp, window, cx);
+ let buffer_text = editor.buffer.read(cx).snapshot(cx).text();
+ assert_eq!(buffer_text, "\n\n\n\n\naaaa\nbbbb\n\ncccc");
+ });
+}
+
#[gpui::test]
fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -5468,7 +5564,7 @@ async fn test_rewrap(cx: &mut TestAppContext) {
# ˇThis is a long comment using a pound
# sign.
"},
- python_language.clone(),
+ python_language,
&mut cx,
);
@@ -5575,7 +5671,7 @@ async fn test_rewrap(cx: &mut TestAppContext) {
also very long and should not merge
with the numbered item.ˇ»
"},
- markdown_language.clone(),
+ markdown_language,
&mut cx,
);
@@ -5606,7 +5702,7 @@ async fn test_rewrap(cx: &mut TestAppContext) {
// This is the second long comment block
// to be wrapped.ˇ»
"},
- rust_language.clone(),
+ rust_language,
&mut cx,
);
@@ -5629,7 +5725,7 @@ async fn test_rewrap(cx: &mut TestAppContext) {
«\tThis is a very long indented line
\tthat will be wrapped.ˇ»
"},
- plaintext_language.clone(),
+ plaintext_language,
&mut cx,
);
@@ -6307,7 +6403,7 @@ async fn test_split_selection_into_lines(cx: &mut TestAppContext) {
fn test(cx: &mut EditorTestContext, initial_state: &'static str, expected_state: &'static str) {
cx.set_state(initial_state);
cx.update_editor(|e, window, cx| {
- e.split_selection_into_lines(&SplitSelectionIntoLines, window, cx)
+ e.split_selection_into_lines(&Default::default(), window, cx)
});
cx.assert_editor_state(expected_state);
}
@@ -6395,7 +6491,7 @@ async fn test_split_selection_into_lines_interacting_with_creases(cx: &mut TestA
DisplayPoint::new(DisplayRow(4), 4)..DisplayPoint::new(DisplayRow(4), 4),
])
});
- editor.split_selection_into_lines(&SplitSelectionIntoLines, window, cx);
+ editor.split_selection_into_lines(&Default::default(), window, cx);
assert_eq!(
editor.display_text(cx),
"aaaaa\nbbbbb\nccc⋯eeee\nfffff\nggggg\n⋯i"
@@ -6411,7 +6507,7 @@ async fn test_split_selection_into_lines_interacting_with_creases(cx: &mut TestA
DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(0), 1)
])
});
- editor.split_selection_into_lines(&SplitSelectionIntoLines, window, cx);
+ editor.split_selection_into_lines(&Default::default(), window, cx);
assert_eq!(
editor.display_text(cx),
"aaaaa\nbbbbb\nccccc\nddddd\neeeee\nfffff\nggggg\nhhhhh\niiiii"
@@ -7202,12 +7298,12 @@ async fn test_undo_format_scrolls_to_last_edit_pos(cx: &mut TestAppContext) {
}
#[gpui::test]
-async fn test_undo_inline_completion_scrolls_to_edit_pos(cx: &mut TestAppContext) {
+async fn test_undo_edit_prediction_scrolls_to_edit_pos(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
- let provider = cx.new(|_| FakeInlineCompletionProvider::default());
+ let provider = cx.new(|_| FakeEditPredictionProvider::default());
cx.update_editor(|editor, window, cx| {
editor.set_edit_prediction_provider(Some(provider.clone()), window, cx);
});
@@ -7230,7 +7326,7 @@ async fn test_undo_inline_completion_scrolls_to_edit_pos(cx: &mut TestAppContext
cx.update(|_, cx| {
provider.update(cx, |provider, _| {
- provider.set_inline_completion(Some(inline_completion::InlineCompletion {
+ provider.set_edit_prediction(Some(edit_prediction::EditPrediction {
id: None,
edits: vec![(edit_position..edit_position, "X".into())],
edit_preview: None,
@@ -7238,7 +7334,7 @@ async fn test_undo_inline_completion_scrolls_to_edit_pos(cx: &mut TestAppContext
})
});
- cx.update_editor(|editor, window, cx| editor.update_visible_inline_completion(window, cx));
+ cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx));
cx.update_editor(|editor, window, cx| {
editor.accept_edit_prediction(&crate::AcceptEditPrediction, window, cx)
});
@@ -7920,6 +8016,29 @@ async fn test_select_larger_smaller_syntax_node_for_string(cx: &mut TestAppConte
});
}
+#[gpui::test]
+async fn test_unwrap_syntax_nodes(cx: &mut gpui::TestAppContext) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorTestContext::new(cx).await;
+
+ let language = Arc::new(Language::new(
+ LanguageConfig::default(),
+ Some(tree_sitter_rust::LANGUAGE.into()),
+ ));
+
+ cx.update_buffer(|buffer, cx| {
+ buffer.set_language(Some(language), cx);
+ });
+
+ cx.set_state(indoc! { r#"use mod1::{mod2::{«mod3ˇ», mod4}, mod5::{mod6, «mod7ˇ»}};"# });
+ cx.update_editor(|editor, window, cx| {
+ editor.unwrap_syntax_node(&UnwrapSyntaxNode, window, cx);
+ });
+
+ cx.assert_editor_state(indoc! { r#"use mod1::{mod2::«mod3ˇ», mod5::«mod7ˇ»};"# });
+}
+
#[gpui::test]
async fn test_fold_function_bodies(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -8089,106 +8208,316 @@ async fn test_autoindent(cx: &mut TestAppContext) {
}
#[gpui::test]
-async fn test_autoindent_selections(cx: &mut TestAppContext) {
- init_test(cx, |_| {});
-
- {
- let mut cx = EditorLspTestContext::new_rust(Default::default(), cx).await;
- cx.set_state(indoc! {"
- impl A {
-
- fn b() {}
-
- «fn c() {
-
- }ˇ»
- }
- "});
-
- cx.update_editor(|editor, window, cx| {
- editor.autoindent(&Default::default(), window, cx);
- });
-
- cx.assert_editor_state(indoc! {"
- impl A {
-
- fn b() {}
+async fn test_autoindent_disabled(cx: &mut TestAppContext) {
+ init_test(cx, |settings| settings.defaults.auto_indent = Some(false));
- «fn c() {
-
- }ˇ»
- }
- "});
- }
+ let language = Arc::new(
+ Language::new(
+ LanguageConfig {
+ brackets: BracketPairConfig {
+ pairs: vec![
+ BracketPair {
+ start: "{".to_string(),
+ end: "}".to_string(),
+ close: false,
+ surround: false,
+ newline: true,
+ },
+ BracketPair {
+ start: "(".to_string(),
+ end: ")".to_string(),
+ close: false,
+ surround: false,
+ newline: true,
+ },
+ ],
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ Some(tree_sitter_rust::LANGUAGE.into()),
+ )
+ .with_indents_query(
+ r#"
+ (_ "(" ")" @end) @indent
+ (_ "{" "}" @end) @indent
+ "#,
+ )
+ .unwrap(),
+ );
- {
- let mut cx = EditorTestContext::new_multibuffer(
- cx,
- [indoc! { "
- impl A {
- «
- // a
- fn b(){}
- »
- «
- }
- fn c(){}
- »
- "}],
- );
+ let text = "fn a() {}";
- let buffer = cx.update_editor(|editor, _, cx| {
- let buffer = editor.buffer().update(cx, |buffer, _| {
- buffer.all_buffers().iter().next().unwrap().clone()
- });
- buffer.update(cx, |buffer, cx| buffer.set_language(Some(rust_lang()), cx));
- buffer
- });
+ let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx));
+ let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+ let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx));
+ editor
+ .condition::<crate::EditorEvent>(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx))
+ .await;
- cx.run_until_parked();
- cx.update_editor(|editor, window, cx| {
- editor.select_all(&Default::default(), window, cx);
- editor.autoindent(&Default::default(), window, cx)
+ editor.update_in(cx, |editor, window, cx| {
+ editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+ s.select_ranges([5..5, 8..8, 9..9])
});
- cx.run_until_parked();
-
- cx.update(|_, cx| {
- assert_eq!(
- buffer.read(cx).text(),
- indoc! { "
- impl A {
-
- // a
- fn b(){}
-
+ editor.newline(&Newline, window, cx);
+ assert_eq!(
+ editor.text(cx),
+ indoc!(
+ "
+ fn a(
- }
- fn c(){}
+ ) {
- " }
+ }
+ "
)
- });
- }
+ );
+ assert_eq!(
+ editor.selections.ranges(cx),
+ &[
+ Point::new(1, 0)..Point::new(1, 0),
+ Point::new(3, 0)..Point::new(3, 0),
+ Point::new(5, 0)..Point::new(5, 0)
+ ]
+ );
+ });
}
#[gpui::test]
-async fn test_autoclose_and_auto_surround_pairs(cx: &mut TestAppContext) {
- init_test(cx, |_| {});
+async fn test_autoindent_disabled_with_nested_language(cx: &mut TestAppContext) {
+ init_test(cx, |settings| {
+ settings.defaults.auto_indent = Some(true);
+ settings.languages.0.insert(
+ "python".into(),
+ LanguageSettingsContent {
+ auto_indent: Some(false),
+ ..Default::default()
+ },
+ );
+ });
let mut cx = EditorTestContext::new(cx).await;
- let language = Arc::new(Language::new(
- LanguageConfig {
- brackets: BracketPairConfig {
- pairs: vec![
- BracketPair {
- start: "{".to_string(),
- end: "}".to_string(),
- close: true,
- surround: true,
- newline: true,
- },
- BracketPair {
+ let injected_language = Arc::new(
+ Language::new(
+ LanguageConfig {
+ brackets: BracketPairConfig {
+ pairs: vec![
+ BracketPair {
+ start: "{".to_string(),
+ end: "}".to_string(),
+ close: false,
+ surround: false,
+ newline: true,
+ },
+ BracketPair {
+ start: "(".to_string(),
+ end: ")".to_string(),
+ close: true,
+ surround: false,
+ newline: true,
+ },
+ ],
+ ..Default::default()
+ },
+ name: "python".into(),
+ ..Default::default()
+ },
+ Some(tree_sitter_python::LANGUAGE.into()),
+ )
+ .with_indents_query(
+ r#"
+ (_ "(" ")" @end) @indent
+ (_ "{" "}" @end) @indent
+ "#,
+ )
+ .unwrap(),
+ );
+
+ let language = Arc::new(
+ Language::new(
+ LanguageConfig {
+ brackets: BracketPairConfig {
+ pairs: vec![
+ BracketPair {
+ start: "{".to_string(),
+ end: "}".to_string(),
+ close: false,
+ surround: false,
+ newline: true,
+ },
+ BracketPair {
+ start: "(".to_string(),
+ end: ")".to_string(),
+ close: true,
+ surround: false,
+ newline: true,
+ },
+ ],
+ ..Default::default()
+ },
+ name: LanguageName::new("rust"),
+ ..Default::default()
+ },
+ Some(tree_sitter_rust::LANGUAGE.into()),
+ )
+ .with_indents_query(
+ r#"
+ (_ "(" ")" @end) @indent
+ (_ "{" "}" @end) @indent
+ "#,
+ )
+ .unwrap()
+ .with_injection_query(
+ r#"
+ (macro_invocation
+ macro: (identifier) @_macro_name
+ (token_tree) @injection.content
+ (#set! injection.language "python"))
+ "#,
+ )
+ .unwrap(),
+ );
+
+ cx.language_registry().add(injected_language);
+ cx.language_registry().add(language.clone());
+
+ cx.update_buffer(|buffer, cx| {
+ buffer.set_language(Some(language), cx);
+ });
+
+ cx.set_state(r#"struct A {ˇ}"#);
+
+ cx.update_editor(|editor, window, cx| {
+ editor.newline(&Default::default(), window, cx);
+ });
+
+ cx.assert_editor_state(indoc!(
+ "struct A {
+ ˇ
+ }"
+ ));
+
+ cx.set_state(r#"select_biased!(ˇ)"#);
+
+ cx.update_editor(|editor, window, cx| {
+ editor.newline(&Default::default(), window, cx);
+ editor.handle_input("def ", window, cx);
+ editor.handle_input("(", window, cx);
+ editor.newline(&Default::default(), window, cx);
+ editor.handle_input("a", window, cx);
+ });
+
+ cx.assert_editor_state(indoc!(
+ "select_biased!(
+ def (
+ aˇ
+ )
+ )"
+ ));
+}
+
+#[gpui::test]
+async fn test_autoindent_selections(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ {
+ let mut cx = EditorLspTestContext::new_rust(Default::default(), cx).await;
+ cx.set_state(indoc! {"
+ impl A {
+
+ fn b() {}
+
+ «fn c() {
+
+ }ˇ»
+ }
+ "});
+
+ cx.update_editor(|editor, window, cx| {
+ editor.autoindent(&Default::default(), window, cx);
+ });
+
+ cx.assert_editor_state(indoc! {"
+ impl A {
+
+ fn b() {}
+
+ «fn c() {
+
+ }ˇ»
+ }
+ "});
+ }
+
+ {
+ let mut cx = EditorTestContext::new_multibuffer(
+ cx,
+ [indoc! { "
+ impl A {
+ «
+ // a
+ fn b(){}
+ »
+ «
+ }
+ fn c(){}
+ »
+ "}],
+ );
+
+ let buffer = cx.update_editor(|editor, _, cx| {
+ let buffer = editor.buffer().update(cx, |buffer, _| {
+ buffer.all_buffers().iter().next().unwrap().clone()
+ });
+ buffer.update(cx, |buffer, cx| buffer.set_language(Some(rust_lang()), cx));
+ buffer
+ });
+
+ cx.run_until_parked();
+ cx.update_editor(|editor, window, cx| {
+ editor.select_all(&Default::default(), window, cx);
+ editor.autoindent(&Default::default(), window, cx)
+ });
+ cx.run_until_parked();
+
+ cx.update(|_, cx| {
+ assert_eq!(
+ buffer.read(cx).text(),
+ indoc! { "
+ impl A {
+
+ // a
+ fn b(){}
+
+
+ }
+ fn c(){}
+
+ " }
+ )
+ });
+ }
+}
+
+#[gpui::test]
+async fn test_autoclose_and_auto_surround_pairs(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorTestContext::new(cx).await;
+
+ let language = Arc::new(Language::new(
+ LanguageConfig {
+ brackets: BracketPairConfig {
+ pairs: vec![
+ BracketPair {
+ start: "{".to_string(),
+ end: "}".to_string(),
+ close: true,
+ surround: true,
+ newline: true,
+ },
+ BracketPair {
start: "(".to_string(),
end: ")".to_string(),
close: true,
@@ -8562,7 +8891,8 @@ async fn test_autoclose_with_embedded_language(cx: &mut TestAppContext) {
));
cx.language_registry().add(html_language.clone());
- cx.language_registry().add(javascript_language.clone());
+ cx.language_registry().add(javascript_language);
+ cx.executor().run_until_parked();
cx.update_buffer(|buffer, cx| {
buffer.set_language(Some(html_language), cx);
@@ -9305,7 +9635,7 @@ async fn test_snippets(cx: &mut TestAppContext) {
.selections
.all(cx)
.iter()
- .map(|s| s.range().clone())
+ .map(|s| s.range())
.collect::<Vec<_>>();
editor
.insert_snippet(&insertion_ranges, snippet, window, cx)
@@ -9385,7 +9715,7 @@ async fn test_snippet_indentation(cx: &mut TestAppContext) {
.selections
.all(cx)
.iter()
- .map(|s| s.range().clone())
+ .map(|s| s.range())
.collect::<Vec<_>>();
editor
.insert_snippet(&insertion_ranges, snippet, window, cx)
@@ -9570,6 +9900,74 @@ async fn test_document_format_during_save(cx: &mut TestAppContext) {
}
}
+#[gpui::test]
+async fn test_redo_after_noop_format(cx: &mut TestAppContext) {
+ init_test(cx, |settings| {
+ settings.defaults.ensure_final_newline_on_save = Some(false);
+ });
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_file(path!("/file.txt"), "foo".into()).await;
+
+ let project = Project::test(fs, [path!("/file.txt").as_ref()], cx).await;
+
+ let buffer = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(path!("/file.txt"), cx)
+ })
+ .await
+ .unwrap();
+
+ let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+ let (editor, cx) = cx.add_window_view(|window, cx| {
+ build_editor_with_project(project.clone(), buffer, window, cx)
+ });
+ editor.update_in(cx, |editor, window, cx| {
+ editor.change_selections(SelectionEffects::default(), window, cx, |s| {
+ s.select_ranges([0..0])
+ });
+ });
+ assert!(!cx.read(|cx| editor.is_dirty(cx)));
+
+ editor.update_in(cx, |editor, window, cx| {
+ editor.handle_input("\n", window, cx)
+ });
+ cx.run_until_parked();
+ save(&editor, &project, cx).await;
+ assert_eq!("\nfoo", editor.read_with(cx, |editor, cx| editor.text(cx)));
+
+ editor.update_in(cx, |editor, window, cx| {
+ editor.undo(&Default::default(), window, cx);
+ });
+ save(&editor, &project, cx).await;
+ assert_eq!("foo", editor.read_with(cx, |editor, cx| editor.text(cx)));
+
+ editor.update_in(cx, |editor, window, cx| {
+ editor.redo(&Default::default(), window, cx);
+ });
+ cx.run_until_parked();
+ assert_eq!("\nfoo", editor.read_with(cx, |editor, cx| editor.text(cx)));
+
+ async fn save(editor: &Entity<Editor>, project: &Entity<Project>, cx: &mut VisualTestContext) {
+ let save = editor
+ .update_in(cx, |editor, window, cx| {
+ editor.save(
+ SaveOptions {
+ format: true,
+ autosave: false,
+ },
+ project.clone(),
+ window,
+ cx,
+ )
+ })
+ .unwrap();
+ cx.executor().start_waiting();
+ save.await;
+ assert!(!cx.read(|cx| editor.is_dirty(cx)));
+ }
+}
+
#[gpui::test]
async fn test_multibuffer_format_during_save(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -9955,8 +10353,14 @@ async fn test_autosave_with_dirty_buffers(cx: &mut TestAppContext) {
);
}
-#[gpui::test]
-async fn test_range_format_during_save(cx: &mut TestAppContext) {
+async fn setup_range_format_test(
+ cx: &mut TestAppContext,
+) -> (
+ Entity<Project>,
+ Entity<Editor>,
+ &mut gpui::VisualTestContext,
+ lsp::FakeLanguageServer,
+) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
@@ -9971,9 +10375,9 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
FakeLspAdapter {
capabilities: lsp::ServerCapabilities {
document_range_formatting_provider: Some(lsp::OneOf::Left(true)),
- ..Default::default()
+ ..lsp::ServerCapabilities::default()
},
- ..Default::default()
+ ..FakeLspAdapter::default()
},
);
@@ -9988,14 +10392,22 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
let (editor, cx) = cx.add_window_view(|window, cx| {
build_editor_with_project(project.clone(), buffer, window, cx)
});
+
+ cx.executor().start_waiting();
+ let fake_server = fake_servers.next().await.unwrap();
+
+ (project, editor, cx, fake_server)
+}
+
+#[gpui::test]
+async fn test_range_format_on_save_success(cx: &mut TestAppContext) {
+ let (project, editor, cx, fake_server) = setup_range_format_test(cx).await;
+
editor.update_in(cx, |editor, window, cx| {
editor.set_text("one\ntwo\nthree\n", window, cx)
});
assert!(cx.read(|cx| editor.is_dirty(cx)));
- cx.executor().start_waiting();
- let fake_server = fake_servers.next().await.unwrap();
-
let save = editor
.update_in(cx, |editor, window, cx| {
editor.save(
@@ -10030,13 +10442,18 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
"one, two\nthree\n"
);
assert!(!cx.read(|cx| editor.is_dirty(cx)));
+}
+
+#[gpui::test]
+async fn test_range_format_on_save_timeout(cx: &mut TestAppContext) {
+ let (project, editor, cx, fake_server) = setup_range_format_test(cx).await;
editor.update_in(cx, |editor, window, cx| {
editor.set_text("one\ntwo\nthree\n", window, cx)
});
assert!(cx.read(|cx| editor.is_dirty(cx)));
- // Ensure we can still save even if formatting hangs.
+ // Test that save still works when formatting hangs
fake_server.set_request_handler::<lsp::request::RangeFormatting, _, _>(
move |params, _| async move {
assert_eq!(
@@ -3,11 +3,11 @@ use crate::{
CodeActionSource, ColumnarMode, ConflictsOurs, ConflictsOursMarker, ConflictsOuter,
ConflictsTheirs, ConflictsTheirsMarker, ContextMenuPlacement, CursorShape, CustomBlockId,
DisplayDiffHunk, DisplayPoint, DisplayRow, DocumentHighlightRead, DocumentHighlightWrite,
- EditDisplayMode, Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle,
- FILE_HEADER_HEIGHT, FocusedBlock, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput,
- HoveredCursor, InlayHintRefreshReason, InlineCompletion, JumpData, LineDown, LineHighlight,
- LineUp, MAX_LINE_LEN, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts,
- PageDown, PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt, SelectPhase,
+ EditDisplayMode, EditPrediction, Editor, EditorMode, EditorSettings, EditorSnapshot,
+ EditorStyle, FILE_HEADER_HEIGHT, FocusedBlock, GutterDimensions, HalfPageDown, HalfPageUp,
+ HandleInput, HoveredCursor, InlayHintRefreshReason, JumpData, LineDown, LineHighlight, LineUp,
+ MAX_LINE_LEN, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, PageDown,
+ PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt, SelectPhase,
SelectedTextHighlight, Selection, SelectionDragState, SoftWrap, StickyHeaderExcerpt, ToPoint,
ToggleFold, ToggleFoldAll,
code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP},
@@ -40,14 +40,15 @@ use git::{
};
use gpui::{
Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
- Bounds, ClickEvent, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges,
- Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox,
- HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, Keystroke, Length,
- ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
- ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
- Size, StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, WeakEntity,
- Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px,
- quad, relative, size, solid_background, transparent_black,
+ Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle,
+ DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId,
+ GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero,
+ Keystroke, Length, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent,
+ MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollHandle,
+ ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled,
+ TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill,
+ linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background,
+ transparent_black,
};
use itertools::Itertools;
use language::language_settings::{
@@ -60,7 +61,7 @@ use multi_buffer::{
};
use project::{
- ProjectPath,
+ Entry, ProjectPath,
debugger::breakpoint_store::{Breakpoint, BreakpointSessionState},
project_settings::{GitGutterSetting, GitHunkStyleSetting, ProjectSettings},
};
@@ -73,6 +74,7 @@ use std::{
fmt::{self, Write},
iter, mem,
ops::{Deref, Range},
+ path::{self, Path},
rc::Rc,
sync::Arc,
time::{Duration, Instant},
@@ -80,13 +82,17 @@ use std::{
use sum_tree::Bias;
use text::{BufferId, SelectionGoal};
use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor};
-use ui::{ButtonLike, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*};
+use ui::{
+ ButtonLike, ContextMenu, Indicator, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*,
+ right_click_menu,
+};
use unicode_segmentation::UnicodeSegmentation;
use util::post_inc;
use util::{RangeExt, ResultExt, debug_panic};
-use workspace::{CollaboratorId, Workspace, item::Item, notifications::NotifyTaskExt};
-
-const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 7.;
+use workspace::{
+ CollaboratorId, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel, Workspace,
+ item::Item, notifications::NotifyTaskExt,
+};
/// Determines what kinds of highlights should be applied to a lines background.
#[derive(Clone, Copy, Default)]
@@ -216,6 +222,7 @@ impl EditorElement {
register_action(editor, window, Editor::newline_above);
register_action(editor, window, Editor::newline_below);
register_action(editor, window, Editor::backspace);
+ register_action(editor, window, Editor::blame_hover);
register_action(editor, window, Editor::delete);
register_action(editor, window, Editor::tab);
register_action(editor, window, Editor::backtab);
@@ -229,7 +236,6 @@ impl EditorElement {
register_action(editor, window, Editor::sort_lines_case_insensitive);
register_action(editor, window, Editor::reverse_lines);
register_action(editor, window, Editor::shuffle_lines);
- register_action(editor, window, Editor::toggle_case);
register_action(editor, window, Editor::convert_indentation_to_spaces);
register_action(editor, window, Editor::convert_indentation_to_tabs);
register_action(editor, window, Editor::convert_to_upper_case);
@@ -240,6 +246,8 @@ impl EditorElement {
register_action(editor, window, Editor::convert_to_upper_camel_case);
register_action(editor, window, Editor::convert_to_lower_camel_case);
register_action(editor, window, Editor::convert_to_opposite_case);
+ register_action(editor, window, Editor::convert_to_sentence_case);
+ register_action(editor, window, Editor::toggle_case);
register_action(editor, window, Editor::convert_to_rot13);
register_action(editor, window, Editor::convert_to_rot47);
register_action(editor, window, Editor::delete_to_previous_word_start);
@@ -261,6 +269,7 @@ impl EditorElement {
register_action(editor, window, Editor::kill_ring_yank);
register_action(editor, window, Editor::copy);
register_action(editor, window, Editor::copy_and_trim);
+ register_action(editor, window, Editor::diff_clipboard_with_selection);
register_action(editor, window, Editor::paste);
register_action(editor, window, Editor::undo);
register_action(editor, window, Editor::redo);
@@ -354,6 +363,7 @@ impl EditorElement {
register_action(editor, window, Editor::toggle_comments);
register_action(editor, window, Editor::select_larger_syntax_node);
register_action(editor, window, Editor::select_smaller_syntax_node);
+ register_action(editor, window, Editor::unwrap_syntax_node);
register_action(editor, window, Editor::select_enclosing_symbol);
register_action(editor, window, Editor::move_to_enclosing_bracket);
register_action(editor, window, Editor::undo_selection);
@@ -551,7 +561,7 @@ impl EditorElement {
register_action(editor, window, Editor::signature_help_next);
register_action(editor, window, Editor::next_edit_prediction);
register_action(editor, window, Editor::previous_edit_prediction);
- register_action(editor, window, Editor::show_inline_completion);
+ register_action(editor, window, Editor::show_edit_prediction);
register_action(editor, window, Editor::context_menu_first);
register_action(editor, window, Editor::context_menu_prev);
register_action(editor, window, Editor::context_menu_next);
@@ -559,7 +569,7 @@ impl EditorElement {
register_action(editor, window, Editor::display_cursor_names);
register_action(editor, window, Editor::unique_lines_case_insensitive);
register_action(editor, window, Editor::unique_lines_case_sensitive);
- register_action(editor, window, Editor::accept_partial_inline_completion);
+ register_action(editor, window, Editor::accept_partial_edit_prediction);
register_action(editor, window, Editor::accept_edit_prediction);
register_action(editor, window, Editor::restore_file);
register_action(editor, window, Editor::git_restore);
@@ -715,7 +725,7 @@ impl EditorElement {
ColumnarMode::FromMouse => true,
ColumnarMode::FromSelection => false,
},
- mode: mode,
+ mode,
goal_column: point_for_position.exact_unclipped.column(),
},
window,
@@ -908,6 +918,11 @@ impl EditorElement {
} else if cfg!(any(target_os = "linux", target_os = "freebsd"))
&& event.button == MouseButton::Middle
{
+ #[allow(
+ clippy::collapsible_if,
+ clippy::needless_return,
+ reason = "The cfg-block below makes this a false positive"
+ )]
if !text_hitbox.is_hovered(window) || editor.read_only(cx) {
return;
}
@@ -946,9 +961,14 @@ impl EditorElement {
let hovered_link_modifier = Editor::multi_cursor_modifier(false, &event.modifiers(), cx);
- if !pending_nonempty_selections && hovered_link_modifier && text_hitbox.is_hovered(window) {
- let point = position_map.point_for_position(event.up.position);
+ if let Some(mouse_position) = event.mouse_position()
+ && !pending_nonempty_selections
+ && hovered_link_modifier
+ && text_hitbox.is_hovered(window)
+ {
+ let point = position_map.point_for_position(mouse_position);
editor.handle_click_hovered_link(point, event.modifiers(), window, cx);
+ editor.selection_drag_state = SelectionDragState::None;
cx.stop_propagation();
}
@@ -1108,26 +1128,24 @@ impl EditorElement {
let hovered_diff_hunk_row = if let Some(control_row) = hovered_diff_control {
Some(control_row)
- } else {
- if text_hovered {
- let current_row = valid_point.row();
- position_map.display_hunks.iter().find_map(|(hunk, _)| {
- if let DisplayDiffHunk::Unfolded {
- display_row_range, ..
- } = hunk
- {
- if display_row_range.contains(¤t_row) {
- Some(display_row_range.start)
- } else {
- None
- }
+ } else if text_hovered {
+ let current_row = valid_point.row();
+ position_map.display_hunks.iter().find_map(|(hunk, _)| {
+ if let DisplayDiffHunk::Unfolded {
+ display_row_range, ..
+ } = hunk
+ {
+ if display_row_range.contains(¤t_row) {
+ Some(display_row_range.start)
} else {
None
}
- })
- } else {
- None
- }
+ } else {
+ None
+ }
+ })
+ } else {
+ None
};
if hovered_diff_hunk_row != editor.hovered_diff_hunk_row {
@@ -1141,11 +1159,15 @@ impl EditorElement {
.inline_blame_popover
.as_ref()
.and_then(|state| state.popover_bounds)
- .map_or(false, |bounds| bounds.contains(&event.position));
+ .is_some_and(|bounds| bounds.contains(&event.position));
+ let keyboard_grace = editor
+ .inline_blame_popover
+ .as_ref()
+ .is_some_and(|state| state.keyboard_grace);
if mouse_over_inline_blame || mouse_over_popover {
- editor.show_blame_popover(&blame_entry, event.position, cx);
- } else {
+ editor.show_blame_popover(blame_entry, event.position, false, cx);
+ } else if !keyboard_grace {
editor.hide_blame_popover(cx);
}
} else {
@@ -1168,10 +1190,10 @@ impl EditorElement {
let is_visible = editor
.gutter_breakpoint_indicator
.0
- .map_or(false, |indicator| indicator.is_active);
+ .is_some_and(|indicator| indicator.is_active);
let has_existing_breakpoint =
- editor.breakpoint_store.as_ref().map_or(false, |store| {
+ editor.breakpoint_store.as_ref().is_some_and(|store| {
let Some(project) = &editor.project else {
return false;
};
@@ -1369,29 +1391,27 @@ impl EditorElement {
ref drop_cursor,
ref hide_drop_cursor,
} = editor.selection_drag_state
+ && !hide_drop_cursor
+ && (drop_cursor
+ .start
+ .cmp(&selection.start, &snapshot.buffer_snapshot)
+ .eq(&Ordering::Less)
+ || drop_cursor
+ .end
+ .cmp(&selection.end, &snapshot.buffer_snapshot)
+ .eq(&Ordering::Greater))
{
- if !hide_drop_cursor
- && (drop_cursor
- .start
- .cmp(&selection.start, &snapshot.buffer_snapshot)
- .eq(&Ordering::Less)
- || drop_cursor
- .end
- .cmp(&selection.end, &snapshot.buffer_snapshot)
- .eq(&Ordering::Greater))
- {
- let drag_cursor_layout = SelectionLayout::new(
- drop_cursor.clone(),
- false,
- CursorShape::Bar,
- &snapshot.display_snapshot,
- false,
- false,
- None,
- );
- let absent_color = cx.theme().players().absent();
- selections.push((absent_color, vec![drag_cursor_layout]));
- }
+ let drag_cursor_layout = SelectionLayout::new(
+ drop_cursor.clone(),
+ false,
+ CursorShape::Bar,
+ &snapshot.display_snapshot,
+ false,
+ false,
+ None,
+ );
+ let absent_color = cx.theme().players().absent();
+ selections.push((absent_color, vec![drag_cursor_layout]));
}
}
@@ -1402,19 +1422,15 @@ impl EditorElement {
CollaboratorId::PeerId(peer_id) => {
if let Some(collaborator) =
collaboration_hub.collaborators(cx).get(&peer_id)
- {
- if let Some(participant_index) = collaboration_hub
+ && let Some(participant_index) = collaboration_hub
.user_participant_indices(cx)
.get(&collaborator.user_id)
- {
- if let Some((local_selection_style, _)) = selections.first_mut()
- {
- *local_selection_style = cx
- .theme()
- .players()
- .color_for_participant(participant_index.0);
- }
- }
+ && let Some((local_selection_style, _)) = selections.first_mut()
+ {
+ *local_selection_style = cx
+ .theme()
+ .players()
+ .color_for_participant(participant_index.0);
}
}
CollaboratorId::Agent => {
@@ -2085,7 +2101,7 @@ impl EditorElement {
row_block_types: &HashMap<DisplayRow, bool>,
content_origin: gpui::Point<Pixels>,
scroll_pixel_position: gpui::Point<Pixels>,
- inline_completion_popover_origin: Option<gpui::Point<Pixels>>,
+ edit_prediction_popover_origin: Option<gpui::Point<Pixels>>,
start_row: DisplayRow,
end_row: DisplayRow,
line_height: Pixels,
@@ -2157,11 +2173,13 @@ impl EditorElement {
};
let padding = ProjectSettings::get_global(cx).diagnostics.inline.padding as f32 * em_width;
- let min_x = ProjectSettings::get_global(cx)
- .diagnostics
- .inline
- .min_column as f32
- * em_width;
+ let min_x = self.column_pixels(
+ ProjectSettings::get_global(cx)
+ .diagnostics
+ .inline
+ .min_column as usize,
+ window,
+ );
let mut elements = HashMap::default();
for (row, mut diagnostics) in diagnostics_by_rows {
@@ -2202,12 +2220,12 @@ impl EditorElement {
cmp::max(padded_line, min_start)
};
- let behind_inline_completion_popover = inline_completion_popover_origin
+ let behind_edit_prediction_popover = edit_prediction_popover_origin
.as_ref()
- .map_or(false, |inline_completion_popover_origin| {
- (pos_y..pos_y + line_height).contains(&inline_completion_popover_origin.y)
+ .is_some_and(|edit_prediction_popover_origin| {
+ (pos_y..pos_y + line_height).contains(&edit_prediction_popover_origin.y)
});
- let opacity = if behind_inline_completion_popover {
+ let opacity = if behind_edit_prediction_popover {
0.5
} else {
1.0
@@ -2272,9 +2290,7 @@ impl EditorElement {
None
}
})
- .map_or(false, |source| {
- matches!(source, CodeActionSource::Indicator(..))
- });
+ .is_some_and(|source| matches!(source, CodeActionSource::Indicator(..)));
Some(editor.render_inline_code_actions(icon_size, display_point.row(), active, cx))
})?;
@@ -2414,19 +2430,21 @@ impl EditorElement {
let editor = self.editor.read(cx);
let blame = editor.blame.clone()?;
let padding = {
- const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 6.;
const INLINE_ACCEPT_SUGGESTION_EM_WIDTHS: f32 = 14.;
- let mut padding = INLINE_BLAME_PADDING_EM_WIDTHS;
+ let mut padding = ProjectSettings::get_global(cx)
+ .git
+ .inline_blame
+ .unwrap_or_default()
+ .padding as f32;
- if let Some(inline_completion) = editor.active_inline_completion.as_ref() {
- match &inline_completion.completion {
- InlineCompletion::Edit {
- display_mode: EditDisplayMode::TabAccept,
- ..
- } => padding += INLINE_ACCEPT_SUGGESTION_EM_WIDTHS,
- _ => {}
- }
+ if let Some(edit_prediction) = editor.active_edit_prediction.as_ref()
+ && let EditPrediction::Edit {
+ display_mode: EditDisplayMode::TabAccept,
+ ..
+ } = &edit_prediction.completion
+ {
+ padding += INLINE_ACCEPT_SUGGESTION_EM_WIDTHS
}
padding * em_width
@@ -2455,7 +2473,7 @@ impl EditorElement {
let min_column_in_pixels = ProjectSettings::get_global(cx)
.git
.inline_blame
- .and_then(|settings| settings.min_column)
+ .map(|settings| settings.min_column)
.map(|col| self.column_pixels(col as usize, window))
.unwrap_or(px(0.));
let min_start = content_origin.x - scroll_pixel_position.x + min_column_in_pixels;
@@ -2732,7 +2750,10 @@ impl EditorElement {
let mut block_offset = 0;
let mut found_excerpt_header = false;
for (_, block) in snapshot.blocks_in_range(prev_line..row_range.start) {
- if matches!(block, Block::ExcerptBoundary { .. }) {
+ if matches!(
+ block,
+ Block::ExcerptBoundary { .. } | Block::BufferHeader { .. }
+ ) {
found_excerpt_header = true;
break;
}
@@ -2749,7 +2770,10 @@ impl EditorElement {
let mut block_height = 0;
let mut found_excerpt_header = false;
for (_, block) in snapshot.blocks_in_range(row_range.end..cons_line) {
- if matches!(block, Block::ExcerptBoundary { .. }) {
+ if matches!(
+ block,
+ Block::ExcerptBoundary { .. } | Block::BufferHeader { .. }
+ ) {
found_excerpt_header = true;
}
block_height += block.height();
@@ -2796,7 +2820,7 @@ impl EditorElement {
}
let row =
- MultiBufferRow(DisplayPoint::new(display_row, 0).to_point(&snapshot).row);
+ MultiBufferRow(DisplayPoint::new(display_row, 0).to_point(snapshot).row);
if snapshot.is_line_folded(row) {
return None;
}
@@ -2887,7 +2911,7 @@ impl EditorElement {
if multibuffer_row
.0
.checked_sub(1)
- .map_or(false, |previous_row| {
+ .is_some_and(|previous_row| {
snapshot.is_line_folded(MultiBufferRow(previous_row))
})
{
@@ -2960,8 +2984,8 @@ impl EditorElement {
.ilog10()
+ 1;
- let elements = buffer_rows
- .into_iter()
+ buffer_rows
+ .iter()
.enumerate()
.map(|(ix, row_info)| {
let ExpandInfo {
@@ -2996,7 +3020,7 @@ impl EditorElement {
.icon_color(Color::Custom(cx.theme().colors().editor_line_number))
.selected_icon_color(Color::Custom(cx.theme().colors().editor_foreground))
.icon_size(IconSize::Custom(rems(editor_font_size / window.rem_size())))
- .width(width.into())
+ .width(width)
.on_click(move |_, window, cx| {
editor.update(cx, |editor, cx| {
editor.expand_excerpt(excerpt_id, direction, window, cx);
@@ -3016,9 +3040,7 @@ impl EditorElement {
Some((toggle, origin))
})
- .collect();
-
- elements
+ .collect()
}
fn calculate_relative_line_numbers(
@@ -3118,7 +3140,7 @@ impl EditorElement {
let relative_rows = self.calculate_relative_line_numbers(snapshot, &rows, relative_to);
let mut line_number = String::new();
let line_numbers = buffer_rows
- .into_iter()
+ .iter()
.enumerate()
.flat_map(|(ix, row_info)| {
let display_row = DisplayRow(rows.start.0 + ix as u32);
@@ -3195,7 +3217,7 @@ impl EditorElement {
&& self.editor.read(cx).is_singleton(cx);
if include_fold_statuses {
row_infos
- .into_iter()
+ .iter()
.enumerate()
.map(|(ix, info)| {
if info.expand_info.is_some() {
@@ -3290,7 +3312,7 @@ impl EditorElement {
let chunks = snapshot.highlighted_chunks(rows.clone(), true, style);
LineWithInvisibles::from_chunks(
chunks,
- &style,
+ style,
MAX_LINE_LEN,
rows.len(),
&snapshot.mode,
@@ -3371,7 +3393,7 @@ impl EditorElement {
let line_ix = align_to.row().0.checked_sub(rows.start.0);
x_position =
if let Some(layout) = line_ix.and_then(|ix| line_layouts.get(ix as usize)) {
- x_and_width(&layout)
+ x_and_width(layout)
} else {
x_and_width(&layout_line(
align_to.row(),
@@ -3437,42 +3459,41 @@ impl EditorElement {
.into_any_element()
}
- Block::ExcerptBoundary {
- excerpt,
- height,
- starts_new_buffer,
- ..
- } => {
+ Block::ExcerptBoundary { .. } => {
let color = cx.theme().colors().clone();
let mut result = v_flex().id(block_id).w_full();
+ result = result.child(
+ h_flex().relative().child(
+ div()
+ .top(line_height / 2.)
+ .absolute()
+ .w_full()
+ .h_px()
+ .bg(color.border_variant),
+ ),
+ );
+
+ result.into_any()
+ }
+
+ Block::BufferHeader { excerpt, height } => {
+ let mut result = v_flex().id(block_id).w_full();
+
let jump_data = header_jump_data(snapshot, block_row_start, *height, excerpt);
- if *starts_new_buffer {
- if sticky_header_excerpt_id != Some(excerpt.id) {
- let selected = selected_buffer_ids.contains(&excerpt.buffer_id);
+ if sticky_header_excerpt_id != Some(excerpt.id) {
+ let selected = selected_buffer_ids.contains(&excerpt.buffer_id);
- result = result.child(div().pr(editor_margins.right).child(
- self.render_buffer_header(
- excerpt, false, selected, false, jump_data, window, cx,
- ),
- ));
- } else {
- result =
- result.child(div().h(FILE_HEADER_HEIGHT as f32 * window.line_height()));
- }
- } else {
- result = result.child(
- h_flex().relative().child(
- div()
- .top(line_height / 2.)
- .absolute()
- .w_full()
- .h_px()
- .bg(color.border_variant),
+ result = result.child(div().pr(editor_margins.right).child(
+ self.render_buffer_header(
+ excerpt, false, selected, false, jump_data, window, cx,
),
- );
- };
+ ));
+ } else {
+ result =
+ result.child(div().h(FILE_HEADER_HEIGHT as f32 * window.line_height()));
+ }
result.into_any()
}
@@ -3496,33 +3517,33 @@ impl EditorElement {
let mut x_offset = px(0.);
let mut is_block = true;
- if let BlockId::Custom(custom_block_id) = block_id {
- if block.has_height() {
- if block.place_near() {
- if let Some((x_target, line_width)) = x_position {
- let margin = em_width * 2;
- if line_width + final_size.width + margin
- < editor_width + editor_margins.gutter.full_width()
- && !row_block_types.contains_key(&(row - 1))
- && element_height_in_lines == 1
- {
- x_offset = line_width + margin;
- row = row - 1;
- is_block = false;
- element_height_in_lines = 0;
- row_block_types.insert(row, is_block);
- } else {
- let max_offset = editor_width + editor_margins.gutter.full_width()
- - final_size.width;
- let min_offset = (x_target + em_width - final_size.width)
- .max(editor_margins.gutter.full_width());
- x_offset = x_target.min(max_offset).max(min_offset);
- }
- }
- };
- if element_height_in_lines != block.height() {
- resized_blocks.insert(custom_block_id, element_height_in_lines);
+ if let BlockId::Custom(custom_block_id) = block_id
+ && block.has_height()
+ {
+ if block.place_near()
+ && let Some((x_target, line_width)) = x_position
+ {
+ let margin = em_width * 2;
+ if line_width + final_size.width + margin
+ < editor_width + editor_margins.gutter.full_width()
+ && !row_block_types.contains_key(&(row - 1))
+ && element_height_in_lines == 1
+ {
+ x_offset = line_width + margin;
+ row = row - 1;
+ is_block = false;
+ element_height_in_lines = 0;
+ row_block_types.insert(row, is_block);
+ } else {
+ let max_offset =
+ editor_width + editor_margins.gutter.full_width() - final_size.width;
+ let min_offset = (x_target + em_width - final_size.width)
+ .max(editor_margins.gutter.full_width());
+ x_offset = x_target.min(max_offset).max(min_offset);
}
+ };
+ if element_height_in_lines != block.height() {
+ resized_blocks.insert(custom_block_id, element_height_in_lines);
}
}
for i in 0..element_height_in_lines {
@@ -3541,11 +3562,10 @@ impl EditorElement {
jump_data: JumpData,
window: &mut Window,
cx: &mut App,
- ) -> Div {
+ ) -> impl IntoElement {
let editor = self.editor.read(cx);
- let file_status = editor
- .buffer
- .read(cx)
+ let multi_buffer = editor.buffer.read(cx);
+ let file_status = multi_buffer
.all_diff_hunks_expanded()
.then(|| {
editor
@@ -3555,6 +3575,17 @@ impl EditorElement {
.status_for_buffer_id(for_excerpt.buffer_id, cx)
})
.flatten();
+ let indicator = multi_buffer
+ .buffer(for_excerpt.buffer_id)
+ .and_then(|buffer| {
+ let buffer = buffer.read(cx);
+ let indicator_color = match (buffer.has_conflict(), buffer.is_dirty()) {
+ (true, _) => Some(Color::Warning),
+ (_, true) => Some(Color::Accent),
+ (false, false) => None,
+ };
+ indicator_color.map(|indicator_color| Indicator::dot().color(indicator_color))
+ });
let include_root = editor
.project
@@ -3562,17 +3593,17 @@ impl EditorElement {
.map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
.unwrap_or_default();
let can_open_excerpts = Editor::can_open_excerpts_in_file(for_excerpt.buffer.file());
- let path = for_excerpt.buffer.resolve_file_path(cx, include_root);
- let filename = path
+ let relative_path = for_excerpt.buffer.resolve_file_path(cx, include_root);
+ let filename = relative_path
.as_ref()
.and_then(|path| Some(path.file_name()?.to_string_lossy().to_string()));
- let parent_path = path.as_ref().and_then(|path| {
+ let parent_path = relative_path.as_ref().and_then(|path| {
Some(path.parent()?.to_string_lossy().to_string() + std::path::MAIN_SEPARATOR_STR)
});
let focus_handle = editor.focus_handle(cx);
let colors = cx.theme().colors();
- div()
+ let header = div()
.p_1()
.w_full()
.h(FILE_HEADER_HEIGHT as f32 * window.line_height())
@@ -3612,7 +3643,7 @@ impl EditorElement {
ButtonLike::new("toggle-buffer-fold")
.style(ui::ButtonStyle::Transparent)
.height(px(28.).into())
- .width(px(28.).into())
+ .width(px(28.))
.children(toggle_chevron_icon)
.tooltip({
let focus_handle = focus_handle.clone();
@@ -3662,38 +3693,54 @@ impl EditorElement {
})
.take(1),
)
+ .child(
+ h_flex()
+ .size(Pixels(12.0))
+ .justify_center()
+ .children(indicator),
+ )
.child(
h_flex()
.cursor_pointer()
.id("path header block")
.size_full()
.justify_between()
+ .overflow_hidden()
.child(
h_flex()
.gap_2()
- .child(
- Label::new(
- filename
- .map(SharedString::from)
- .unwrap_or_else(|| "untitled".into()),
- )
- .single_line()
- .when_some(
- file_status,
- |el, status| {
- el.color(if status.is_conflicted() {
- Color::Conflict
- } else if status.is_modified() {
- Color::Modified
- } else if status.is_deleted() {
- Color::Disabled
- } else {
- Color::Created
- })
- .when(status.is_deleted(), |el| el.strikethrough())
- },
- ),
- )
+ .map(|path_header| {
+ let filename = filename
+ .map(SharedString::from)
+ .unwrap_or_else(|| "untitled".into());
+
+ path_header
+ .when(ItemSettings::get_global(cx).file_icons, |el| {
+ let path = path::Path::new(filename.as_str());
+ let icon = FileIcons::get_icon(path, cx)
+ .unwrap_or_default();
+ let icon =
+ Icon::from_path(icon).color(Color::Muted);
+ el.child(icon)
+ })
+ .child(Label::new(filename).single_line().when_some(
+ file_status,
+ |el, status| {
+ el.color(if status.is_conflicted() {
+ Color::Conflict
+ } else if status.is_modified() {
+ Color::Modified
+ } else if status.is_deleted() {
+ Color::Disabled
+ } else {
+ Color::Created
+ })
+ .when(status.is_deleted(), |el| {
+ el.strikethrough()
+ })
+ },
+ ))
+ })
.when_some(parent_path, |then, path| {
then.child(div().child(path).text_color(
if file_status.is_some_and(FileStatus::is_deleted) {
@@ -3704,36 +3751,139 @@ impl EditorElement {
))
}),
)
- .when(can_open_excerpts && is_selected && path.is_some(), |el| {
- el.child(
- h_flex()
- .id("jump-to-file-button")
- .gap_2p5()
- .child(Label::new("Jump To File"))
- .children(
- KeyBinding::for_action_in(
- &OpenExcerpts,
- &focus_handle,
- window,
- cx,
- )
- .map(|binding| binding.into_any_element()),
- ),
- )
- })
+ .when(
+ can_open_excerpts && is_selected && relative_path.is_some(),
+ |el| {
+ el.child(
+ h_flex()
+ .id("jump-to-file-button")
+ .gap_2p5()
+ .child(Label::new("Jump To File"))
+ .children(
+ KeyBinding::for_action_in(
+ &OpenExcerpts,
+ &focus_handle,
+ window,
+ cx,
+ )
+ .map(|binding| binding.into_any_element()),
+ ),
+ )
+ },
+ )
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_click(window.listener_for(&self.editor, {
move |editor, e: &ClickEvent, window, cx| {
editor.open_excerpts_common(
Some(jump_data.clone()),
- e.down.modifiers.secondary(),
+ e.modifiers().secondary(),
window,
cx,
);
}
})),
),
- )
+ );
+
+ let file = for_excerpt.buffer.file().cloned();
+ let editor = self.editor.clone();
+ right_click_menu("buffer-header-context-menu")
+ .trigger(move |_, _, _| header)
+ .menu(move |window, cx| {
+ let menu_context = focus_handle.clone();
+ let editor = editor.clone();
+ let file = file.clone();
+ ContextMenu::build(window, cx, move |mut menu, window, cx| {
+ if let Some(file) = file
+ && let Some(project) = editor.read(cx).project()
+ && let Some(worktree) =
+ project.read(cx).worktree_for_id(file.worktree_id(cx), cx)
+ {
+ let worktree = worktree.read(cx);
+ let relative_path = file.path();
+ let entry_for_path = worktree.entry_for_path(relative_path);
+ let abs_path = entry_for_path.map(|e| {
+ e.canonical_path.as_deref().map_or_else(
+ || worktree.abs_path().join(relative_path),
+ Path::to_path_buf,
+ )
+ });
+ let has_relative_path = worktree.root_entry().is_some_and(Entry::is_dir);
+
+ let parent_abs_path = abs_path
+ .as_ref()
+ .and_then(|abs_path| Some(abs_path.parent()?.to_path_buf()));
+ let relative_path = has_relative_path
+ .then_some(relative_path)
+ .map(ToOwned::to_owned);
+
+ let visible_in_project_panel =
+ relative_path.is_some() && worktree.is_visible();
+ let reveal_in_project_panel = entry_for_path
+ .filter(|_| visible_in_project_panel)
+ .map(|entry| entry.id);
+ menu = menu
+ .when_some(abs_path, |menu, abs_path| {
+ menu.entry(
+ "Copy Path",
+ Some(Box::new(zed_actions::workspace::CopyPath)),
+ window.handler_for(&editor, move |_, _, cx| {
+ cx.write_to_clipboard(ClipboardItem::new_string(
+ abs_path.to_string_lossy().to_string(),
+ ));
+ }),
+ )
+ })
+ .when_some(relative_path, |menu, relative_path| {
+ menu.entry(
+ "Copy Relative Path",
+ Some(Box::new(zed_actions::workspace::CopyRelativePath)),
+ window.handler_for(&editor, move |_, _, cx| {
+ cx.write_to_clipboard(ClipboardItem::new_string(
+ relative_path.to_string_lossy().to_string(),
+ ));
+ }),
+ )
+ })
+ .when(
+ reveal_in_project_panel.is_some() || parent_abs_path.is_some(),
+ |menu| menu.separator(),
+ )
+ .when_some(reveal_in_project_panel, |menu, entry_id| {
+ menu.entry(
+ "Reveal In Project Panel",
+ Some(Box::new(RevealInProjectPanel::default())),
+ window.handler_for(&editor, move |editor, _, cx| {
+ if let Some(project) = &mut editor.project {
+ project.update(cx, |_, cx| {
+ cx.emit(project::Event::RevealInProjectPanel(
+ entry_id,
+ ))
+ });
+ }
+ }),
+ )
+ })
+ .when_some(parent_abs_path, |menu, parent_abs_path| {
+ menu.entry(
+ "Open in Terminal",
+ Some(Box::new(OpenInTerminal)),
+ window.handler_for(&editor, move |_, window, cx| {
+ window.dispatch_action(
+ OpenTerminal {
+ working_directory: parent_abs_path.clone(),
+ }
+ .boxed_clone(),
+ cx,
+ );
+ }),
+ )
+ });
+ }
+
+ menu.context(menu_context)
+ })
+ })
}
fn render_blocks(
@@ -3771,7 +3921,7 @@ impl EditorElement {
for (row, block) in fixed_blocks {
let block_id = block.id();
- if focused_block.as_ref().map_or(false, |b| b.id == block_id) {
+ if focused_block.as_ref().is_some_and(|b| b.id == block_id) {
focused_block = None;
}
@@ -3828,7 +3978,7 @@ impl EditorElement {
};
let block_id = block.id();
- if focused_block.as_ref().map_or(false, |b| b.id == block_id) {
+ if focused_block.as_ref().is_some_and(|b| b.id == block_id) {
focused_block = None;
}
@@ -3869,60 +4019,58 @@ impl EditorElement {
}
}
- if let Some(focused_block) = focused_block {
- if let Some(focus_handle) = focused_block.focus_handle.upgrade() {
- if focus_handle.is_focused(window) {
- if let Some(block) = snapshot.block_for_id(focused_block.id) {
- let style = block.style();
- let width = match style {
- BlockStyle::Fixed => AvailableSpace::MinContent,
- BlockStyle::Flex => AvailableSpace::Definite(
- hitbox
- .size
- .width
- .max(fixed_block_max_width)
- .max(editor_margins.gutter.width + *scroll_width),
- ),
- BlockStyle::Sticky => AvailableSpace::Definite(hitbox.size.width),
- };
+ if let Some(focused_block) = focused_block
+ && let Some(focus_handle) = focused_block.focus_handle.upgrade()
+ && focus_handle.is_focused(window)
+ && let Some(block) = snapshot.block_for_id(focused_block.id)
+ {
+ let style = block.style();
+ let width = match style {
+ BlockStyle::Fixed => AvailableSpace::MinContent,
+ BlockStyle::Flex => AvailableSpace::Definite(
+ hitbox
+ .size
+ .width
+ .max(fixed_block_max_width)
+ .max(editor_margins.gutter.width + *scroll_width),
+ ),
+ BlockStyle::Sticky => AvailableSpace::Definite(hitbox.size.width),
+ };
- if let Some((element, element_size, _, x_offset)) = self.render_block(
- &block,
- width,
- focused_block.id,
- rows.end,
- snapshot,
- text_x,
- &rows,
- line_layouts,
- editor_margins,
- line_height,
- em_width,
- text_hitbox,
- editor_width,
- scroll_width,
- &mut resized_blocks,
- &mut row_block_types,
- selections,
- selected_buffer_ids,
- is_row_soft_wrapped,
- sticky_header_excerpt_id,
- window,
- cx,
- ) {
- blocks.push(BlockLayout {
- id: block.id(),
- x_offset,
- row: None,
- element,
- available_space: size(width, element_size.height.into()),
- style,
- overlaps_gutter: true,
- is_buffer_header: block.is_buffer_header(),
- });
- }
- }
- }
+ if let Some((element, element_size, _, x_offset)) = self.render_block(
+ &block,
+ width,
+ focused_block.id,
+ rows.end,
+ snapshot,
+ text_x,
+ &rows,
+ line_layouts,
+ editor_margins,
+ line_height,
+ em_width,
+ text_hitbox,
+ editor_width,
+ scroll_width,
+ &mut resized_blocks,
+ &mut row_block_types,
+ selections,
+ selected_buffer_ids,
+ is_row_soft_wrapped,
+ sticky_header_excerpt_id,
+ window,
+ cx,
+ ) {
+ blocks.push(BlockLayout {
+ id: block.id(),
+ x_offset,
+ row: None,
+ element,
+ available_space: size(width, element_size.height.into()),
+ style,
+ overlaps_gutter: true,
+ is_buffer_header: block.is_buffer_header(),
+ });
}
}
@@ -213,8 +213,8 @@ impl GitBlame {
let project_subscription = cx.subscribe(&project, {
let buffer = buffer.clone();
- move |this, _, event, cx| match event {
- project::Event::WorktreeUpdatedEntries(_, updated) => {
+ move |this, _, event, cx| {
+ if let project::Event::WorktreeUpdatedEntries(_, updated) = event {
let project_entry_id = buffer.read(cx).entry_id(cx);
if updated
.iter()
@@ -224,7 +224,6 @@ impl GitBlame {
this.generate(cx);
}
}
- _ => {}
}
});
@@ -292,11 +291,11 @@ impl GitBlame {
let buffer_id = self.buffer_snapshot.remote_id();
let mut cursor = self.entries.cursor::<u32>(&());
- rows.into_iter().map(move |info| {
+ rows.iter().map(move |info| {
let row = info
.buffer_row
.filter(|_| info.buffer_id == Some(buffer_id))?;
- cursor.seek_forward(&row, Bias::Right, &());
+ cursor.seek_forward(&row, Bias::Right);
cursor.item()?.blame.clone()
})
}
@@ -312,10 +311,10 @@ impl GitBlame {
.as_ref()
.and_then(|entry| entry.author.as_ref())
.map(|author| author.len());
- if let Some(author_len) = author_len {
- if author_len > max_author_length {
- max_author_length = author_len;
- }
+ if let Some(author_len) = author_len
+ && author_len > max_author_length
+ {
+ max_author_length = author_len;
}
}
@@ -389,7 +388,7 @@ impl GitBlame {
}
}
- new_entries.append(cursor.slice(&edit.old.start, Bias::Right, &()), &());
+ new_entries.append(cursor.slice(&edit.old.start, Bias::Right), &());
if edit.new.start > new_entries.summary().rows {
new_entries.push(
@@ -401,7 +400,7 @@ impl GitBlame {
);
}
- cursor.seek(&edit.old.end, Bias::Right, &());
+ cursor.seek(&edit.old.end, Bias::Right);
if !edit.new.is_empty() {
new_entries.push(
GitBlameEntry {
@@ -412,27 +411,26 @@ impl GitBlame {
);
}
- let old_end = cursor.end(&());
+ let old_end = cursor.end();
if row_edits
.peek()
- .map_or(true, |next_edit| next_edit.old.start >= old_end)
+ .is_none_or(|next_edit| next_edit.old.start >= old_end)
+ && let Some(entry) = cursor.item()
{
- if let Some(entry) = cursor.item() {
- if old_end > edit.old.end {
- new_entries.push(
- GitBlameEntry {
- rows: cursor.end(&()) - edit.old.end,
- blame: entry.blame.clone(),
- },
- &(),
- );
- }
-
- cursor.next(&());
+ if old_end > edit.old.end {
+ new_entries.push(
+ GitBlameEntry {
+ rows: cursor.end() - edit.old.end,
+ blame: entry.blame.clone(),
+ },
+ &(),
+ );
}
+
+ cursor.next();
}
}
- new_entries.append(cursor.suffix(&()), &());
+ new_entries.append(cursor.suffix(), &());
drop(cursor);
self.buffer_snapshot = new_snapshot;
@@ -1,6 +1,7 @@
use crate::{Editor, RangeToAnchorExt};
-use gpui::{Context, Window};
+use gpui::{Context, HighlightStyle, Window};
use language::CursorShape;
+use theme::ActiveTheme;
enum MatchingBracketHighlight {}
@@ -9,7 +10,7 @@ pub fn refresh_matching_bracket_highlights(
window: &mut Window,
cx: &mut Context<Editor>,
) {
- editor.clear_background_highlights::<MatchingBracketHighlight>(cx);
+ editor.clear_highlights::<MatchingBracketHighlight>(cx);
let newest_selection = editor.selections.newest::<usize>(cx);
// Don't highlight brackets if the selection isn't empty
@@ -35,12 +36,19 @@ pub fn refresh_matching_bracket_highlights(
.buffer_snapshot
.innermost_enclosing_bracket_ranges(head..tail, None)
{
- editor.highlight_background::<MatchingBracketHighlight>(
- &[
+ editor.highlight_text::<MatchingBracketHighlight>(
+ vec![
opening_range.to_anchors(&snapshot.buffer_snapshot),
closing_range.to_anchors(&snapshot.buffer_snapshot),
],
- |theme| theme.colors().editor_document_highlight_bracket_background,
+ HighlightStyle {
+ background_color: Some(
+ cx.theme()
+ .colors()
+ .editor_document_highlight_bracket_background,
+ ),
+ ..Default::default()
+ },
cx,
)
}
@@ -104,7 +112,7 @@ mod tests {
another_test(1, 2, 3);
}
"#});
- cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
+ cx.assert_editor_text_highlights::<MatchingBracketHighlight>(indoc! {r#"
pub fn test«(»"Test argument"«)» {
another_test(1, 2, 3);
}
@@ -115,7 +123,7 @@ mod tests {
another_test(1, ˇ2, 3);
}
"#});
- cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
+ cx.assert_editor_text_highlights::<MatchingBracketHighlight>(indoc! {r#"
pub fn test("Test argument") {
another_test«(»1, 2, 3«)»;
}
@@ -126,7 +134,7 @@ mod tests {
anotherˇ_test(1, 2, 3);
}
"#});
- cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
+ cx.assert_editor_text_highlights::<MatchingBracketHighlight>(indoc! {r#"
pub fn test("Test argument") «{»
another_test(1, 2, 3);
«}»
@@ -138,7 +146,7 @@ mod tests {
another_test(1, 2, 3);
}
"#});
- cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
+ cx.assert_editor_text_highlights::<MatchingBracketHighlight>(indoc! {r#"
pub fn test("Test argument") {
another_test(1, 2, 3);
}
@@ -150,8 +158,8 @@ mod tests {
another_test(1, 2, 3);
}
"#});
- cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
- pub fn test("Test argument") {
+ cx.assert_editor_text_highlights::<MatchingBracketHighlight>(indoc! {r#"
+ pub fn test«("Test argument") {
another_test(1, 2, 3);
}
"#});
@@ -274,7 +274,7 @@ impl Editor {
Task::ready(Ok(Navigated::No))
};
self.select(SelectPhase::End, window, cx);
- return navigate_task;
+ navigate_task
}
}
@@ -320,8 +320,10 @@ pub fn update_inlay_link_and_hover_points(
// Check if we should process this hint for hover
let should_process_hint = match cached_hint.resolve_state {
ResolveState::CanResolve(_, _) => {
- // For unresolved hints, spawn resolution
- if let Some(buffer_id) = hovered_inlay.position.buffer_id {
+ if let Some(buffer_id) = snapshot
+ .buffer_snapshot
+ .buffer_id_for_anchor(hovered_inlay.position)
+ {
inlay_hint_cache.spawn_hint_resolve(
buffer_id,
excerpt_id,
@@ -421,8 +423,11 @@ pub fn update_inlay_link_and_hover_points(
cx,
);
hover_updated = true;
- } else if let Some((_language_server_id, location)) =
+ }
+ if let Some((language_server_id, location)) =
part.location.clone()
+ && secondary_held
+ && !editor.has_pending_nonempty_selection()
{
// When there's no tooltip but we have a location, perform a "Go to Definition" style operation
let filename = location
@@ -720,7 +725,7 @@ pub fn show_link_definition(
provider.definitions(&buffer, buffer_position, preferred_kind, cx)
})?;
if let Some(task) = task {
- task.await.ok().map(|definition_result| {
+ task.await.ok().flatten().map(|definition_result| {
(
definition_result.iter().find_map(|link| {
link.origin.as_ref().and_then(|origin| {
@@ -816,11 +821,11 @@ pub fn show_link_definition(
pub(crate) fn find_url(
buffer: &Entity<language::Buffer>,
position: text::Anchor,
- mut cx: AsyncWindowContext,
+ cx: AsyncWindowContext,
) -> Option<(Range<text::Anchor>, String)> {
const LIMIT: usize = 2048;
- let Ok(snapshot) = buffer.read_with(&mut cx, |buffer, _| buffer.snapshot()) else {
+ let Ok(snapshot) = buffer.read_with(&cx, |buffer, _| buffer.snapshot()) else {
return None;
};
@@ -878,11 +883,11 @@ pub(crate) fn find_url(
pub(crate) fn find_url_from_range(
buffer: &Entity<language::Buffer>,
range: Range<text::Anchor>,
- mut cx: AsyncWindowContext,
+ cx: AsyncWindowContext,
) -> Option<String> {
const LIMIT: usize = 2048;
- let Ok(snapshot) = buffer.read_with(&mut cx, |buffer, _| buffer.snapshot()) else {
+ let Ok(snapshot) = buffer.read_with(&cx, |buffer, _| buffer.snapshot()) else {
return None;
};
@@ -925,10 +930,11 @@ pub(crate) fn find_url_from_range(
let mut finder = LinkFinder::new();
finder.kinds(&[LinkKind::Url]);
- if let Some(link) = finder.links(&text).next() {
- if link.start() == 0 && link.end() == text.len() {
- return Some(link.as_str().to_string());
- }
+ if let Some(link) = finder.links(&text).next()
+ && link.start() == 0
+ && link.end() == text.len()
+ {
+ return Some(link.as_str().to_string());
}
None
@@ -953,7 +959,7 @@ pub(crate) async fn find_file(
) -> Option<ResolvedPath> {
project
.update(cx, |project, cx| {
- project.resolve_path_in_buffer(&candidate_file_path, buffer, cx)
+ project.resolve_path_in_buffer(candidate_file_path, buffer, cx)
})
.ok()?
.await
@@ -1031,7 +1037,7 @@ fn surrounding_filename(
.peekable();
while let Some(ch) = forwards.next() {
// Skip escaped whitespace
- if ch == '\\' && forwards.peek().map_or(false, |ch| ch.is_whitespace()) {
+ if ch == '\\' && forwards.peek().is_some_and(|ch| ch.is_whitespace()) {
token_end += ch.len_utf8();
let whitespace = forwards.next().unwrap();
token_end += whitespace.len_utf8();
@@ -142,11 +142,11 @@ pub fn hover_at_inlay(
.info_popovers
.iter()
.any(|InfoPopover { symbol_range, .. }| {
- if let RangeInEditor::Inlay(range) = symbol_range {
- if range == &inlay_hover.range {
- // Hover triggered from same location as last time. Don't show again.
- return true;
- }
+ if let RangeInEditor::Inlay(range) = symbol_range
+ && range == &inlay_hover.range
+ {
+ // Hover triggered from same location as last time. Don't show again.
+ return true;
}
false
})
@@ -167,17 +167,16 @@ pub fn hover_at_inlay(
let language_registry = project.read_with(cx, |p, _| p.languages().clone())?;
let blocks = vec![inlay_hover.tooltip];
- let parsed_content = parse_blocks(&blocks, &language_registry, None, cx).await;
+ let parsed_content =
+ parse_blocks(&blocks, Some(&language_registry), None, cx).await;
let scroll_handle = ScrollHandle::new();
let subscription = this
.update(cx, |_, cx| {
- if let Some(parsed_content) = &parsed_content {
- Some(cx.observe(parsed_content, |_, _, cx| cx.notify()))
- } else {
- None
- }
+ parsed_content.as_ref().map(|parsed_content| {
+ cx.observe(parsed_content, |_, _, cx| cx.notify())
+ })
})
.ok()
.flatten();
@@ -251,7 +250,9 @@ fn show_hover(
let (excerpt_id, _, _) = editor.buffer().read(cx).excerpt_containing(anchor, cx)?;
- let language_registry = editor.project.as_ref()?.read(cx).languages().clone();
+ let language_registry = editor
+ .project()
+ .map(|project| project.read(cx).languages().clone());
let provider = editor.semantics_provider.clone()?;
if !ignore_timeout {
@@ -267,13 +268,12 @@ 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 {
- if triggered_from
+ if let Some(triggered_from) = &editor.hover_state.triggered_from
+ && triggered_from
.cmp(&anchor, &snapshot.buffer_snapshot)
.is_eq()
- {
- return None;
- }
+ {
+ return None;
}
let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay;
@@ -428,7 +428,7 @@ fn show_hover(
};
let hovers_response = if let Some(hover_request) = hover_request {
- hover_request.await
+ hover_request.await.unwrap_or_default()
} else {
Vec::new()
};
@@ -443,15 +443,14 @@ fn show_hover(
text: format!("Unicode character U+{:02X}", invisible as u32),
kind: HoverBlockKind::PlainText,
}];
- let parsed_content = parse_blocks(&blocks, &language_registry, None, cx).await;
+ let parsed_content =
+ parse_blocks(&blocks, language_registry.as_ref(), None, cx).await;
let scroll_handle = ScrollHandle::new();
let subscription = this
.update(cx, |_, cx| {
- if let Some(parsed_content) = &parsed_content {
- Some(cx.observe(parsed_content, |_, _, cx| cx.notify()))
- } else {
- None
- }
+ parsed_content.as_ref().map(|parsed_content| {
+ cx.observe(parsed_content, |_, _, cx| cx.notify())
+ })
})
.ok()
.flatten();
@@ -493,16 +492,15 @@ fn show_hover(
let blocks = hover_result.contents;
let language = hover_result.language;
- let parsed_content = parse_blocks(&blocks, &language_registry, language, cx).await;
+ let parsed_content =
+ parse_blocks(&blocks, language_registry.as_ref(), language, cx).await;
let scroll_handle = ScrollHandle::new();
hover_highlights.push(range.clone());
let subscription = this
.update(cx, |_, cx| {
- if let Some(parsed_content) = &parsed_content {
- Some(cx.observe(parsed_content, |_, _, cx| cx.notify()))
- } else {
- None
- }
+ parsed_content.as_ref().map(|parsed_content| {
+ cx.observe(parsed_content, |_, _, cx| cx.notify())
+ })
})
.ok()
.flatten();
@@ -583,7 +581,7 @@ fn same_diagnostic_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anc
async fn parse_blocks(
blocks: &[HoverBlock],
- language_registry: &Arc<LanguageRegistry>,
+ language_registry: Option<&Arc<LanguageRegistry>>,
language: Option<Arc<Language>>,
cx: &mut AsyncWindowContext,
) -> Option<Entity<Markdown>> {
@@ -599,18 +597,15 @@ async fn parse_blocks(
})
.join("\n\n");
- let rendered_block = cx
- .new_window_entity(|_window, cx| {
- Markdown::new(
- combined_text.into(),
- Some(language_registry.clone()),
- language.map(|language| language.name()),
- cx,
- )
- })
- .ok();
-
- rendered_block
+ cx.new_window_entity(|_window, cx| {
+ Markdown::new(
+ combined_text.into(),
+ language_registry.cloned(),
+ language.map(|language| language.name()),
+ cx,
+ )
+ })
+ .ok()
}
pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
@@ -622,7 +617,7 @@ pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
let mut base_text_style = window.text_style();
base_text_style.refine(&TextStyleRefinement {
- font_family: Some(ui_font_family.clone()),
+ font_family: Some(ui_font_family),
font_fallbacks: ui_font_fallbacks,
color: Some(cx.theme().colors().editor_foreground),
..Default::default()
@@ -671,7 +666,7 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
let mut base_text_style = window.text_style();
base_text_style.refine(&TextStyleRefinement {
- font_family: Some(ui_font_family.clone()),
+ font_family: Some(ui_font_family),
font_fallbacks: ui_font_fallbacks,
color: Some(cx.theme().colors().editor_foreground),
..Default::default()
@@ -712,59 +707,54 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
}
pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App) {
- if let Ok(uri) = Url::parse(&link) {
- if uri.scheme() == "file" {
- if let Some(workspace) = window.root::<Workspace>().flatten() {
- workspace.update(cx, |workspace, cx| {
- let task = workspace.open_abs_path(
- PathBuf::from(uri.path()),
- OpenOptions {
- visible: Some(OpenVisible::None),
- ..Default::default()
- },
- window,
- cx,
- );
+ if let Ok(uri) = Url::parse(&link)
+ && uri.scheme() == "file"
+ && let Some(workspace) = window.root::<Workspace>().flatten()
+ {
+ workspace.update(cx, |workspace, cx| {
+ let task = workspace.open_abs_path(
+ PathBuf::from(uri.path()),
+ OpenOptions {
+ visible: Some(OpenVisible::None),
+ ..Default::default()
+ },
+ window,
+ cx,
+ );
- cx.spawn_in(window, async move |_, cx| {
- let item = task.await?;
- // Ruby LSP uses URLs with #L1,1-4,4
- // we'll just take the first number and assume it's a line number
- let Some(fragment) = uri.fragment() else {
- return anyhow::Ok(());
- };
- let mut accum = 0u32;
- for c in fragment.chars() {
- if c >= '0' && c <= '9' && accum < u32::MAX / 2 {
- accum *= 10;
- accum += c as u32 - '0' as u32;
- } else if accum > 0 {
- break;
- }
- }
- if accum == 0 {
- return Ok(());
- }
- let Some(editor) = cx.update(|_, cx| item.act_as::<Editor>(cx))? else {
- return Ok(());
- };
- editor.update_in(cx, |editor, window, cx| {
- editor.change_selections(
- Default::default(),
- window,
- cx,
- |selections| {
- selections.select_ranges([text::Point::new(accum - 1, 0)
- ..text::Point::new(accum - 1, 0)]);
- },
- );
- })
- })
- .detach_and_log_err(cx);
- });
- return;
- }
- }
+ cx.spawn_in(window, async move |_, cx| {
+ let item = task.await?;
+ // Ruby LSP uses URLs with #L1,1-4,4
+ // we'll just take the first number and assume it's a line number
+ let Some(fragment) = uri.fragment() else {
+ return anyhow::Ok(());
+ };
+ let mut accum = 0u32;
+ for c in fragment.chars() {
+ if c >= '0' && c <= '9' && accum < u32::MAX / 2 {
+ accum *= 10;
+ accum += c as u32 - '0' as u32;
+ } else if accum > 0 {
+ break;
+ }
+ }
+ if accum == 0 {
+ return Ok(());
+ }
+ let Some(editor) = cx.update(|_, cx| item.act_as::<Editor>(cx))? else {
+ return Ok(());
+ };
+ editor.update_in(cx, |editor, window, cx| {
+ editor.change_selections(Default::default(), window, cx, |selections| {
+ selections.select_ranges([
+ text::Point::new(accum - 1, 0)..text::Point::new(accum - 1, 0)
+ ]);
+ });
+ })
+ })
+ .detach_and_log_err(cx);
+ });
+ return;
}
cx.open_url(&link);
}
@@ -834,20 +824,19 @@ impl HoverState {
pub fn focused(&self, window: &mut Window, cx: &mut Context<Editor>) -> bool {
let mut hover_popover_is_focused = false;
for info_popover in &self.info_popovers {
- if let Some(markdown_view) = &info_popover.parsed_content {
- if markdown_view.focus_handle(cx).is_focused(window) {
- hover_popover_is_focused = true;
- }
+ if let Some(markdown_view) = &info_popover.parsed_content
+ && markdown_view.focus_handle(cx).is_focused(window)
+ {
+ hover_popover_is_focused = true;
}
}
- if let Some(diagnostic_popover) = &self.diagnostic_popover {
- if diagnostic_popover
+ if let Some(diagnostic_popover) = &self.diagnostic_popover
+ && diagnostic_popover
.markdown
.focus_handle(cx)
.is_focused(window)
- {
- hover_popover_is_focused = true;
- }
+ {
+ hover_popover_is_focused = true;
}
hover_popover_is_focused
}
@@ -164,15 +164,15 @@ pub fn indent_guides_in_range(
let end_anchor = snapshot.buffer_snapshot.anchor_after(end_offset);
let mut fold_ranges = Vec::<Range<Point>>::new();
- let mut folds = snapshot.folds_in_range(start_offset..end_offset).peekable();
- while let Some(fold) = folds.next() {
+ let folds = snapshot.folds_in_range(start_offset..end_offset).peekable();
+ for fold in folds {
let start = fold.range.start.to_point(&snapshot.buffer_snapshot);
let end = fold.range.end.to_point(&snapshot.buffer_snapshot);
- if let Some(last_range) = fold_ranges.last_mut() {
- if last_range.end >= start {
- last_range.end = last_range.end.max(end);
- continue;
- }
+ if let Some(last_range) = fold_ranges.last_mut()
+ && last_range.end >= start
+ {
+ last_range.end = last_range.end.max(end);
+ continue;
}
fold_ranges.push(start..end);
}
@@ -475,10 +475,7 @@ impl InlayHintCache {
let excerpt_cached_hints = excerpt_cached_hints.read();
let mut excerpt_cache = excerpt_cached_hints.ordered_hints.iter().fuse().peekable();
shown_excerpt_hints_to_remove.retain(|(shown_anchor, shown_hint_id)| {
- let Some(buffer) = shown_anchor
- .buffer_id
- .and_then(|buffer_id| multi_buffer.buffer(buffer_id))
- else {
+ let Some(buffer) = multi_buffer.buffer_for_anchor(*shown_anchor, cx) else {
return false;
};
let buffer_snapshot = buffer.read(cx).snapshot();
@@ -498,16 +495,14 @@ impl InlayHintCache {
cmp::Ordering::Less | cmp::Ordering::Equal => {
if !old_kinds.contains(&cached_hint.kind)
&& new_kinds.contains(&cached_hint.kind)
- {
- if let Some(anchor) = multi_buffer_snapshot
+ && let Some(anchor) = multi_buffer_snapshot
.anchor_in_excerpt(*excerpt_id, cached_hint.position)
- {
- to_insert.push(Inlay::hint(
- cached_hint_id.id(),
- anchor,
- cached_hint,
- ));
- }
+ {
+ to_insert.push(Inlay::hint(
+ cached_hint_id.id(),
+ anchor,
+ cached_hint,
+ ));
}
excerpt_cache.next();
}
@@ -522,16 +517,16 @@ impl InlayHintCache {
for cached_hint_id in excerpt_cache {
let maybe_missed_cached_hint = &excerpt_cached_hints.hints_by_id[cached_hint_id];
let cached_hint_kind = maybe_missed_cached_hint.kind;
- if !old_kinds.contains(&cached_hint_kind) && new_kinds.contains(&cached_hint_kind) {
- if let Some(anchor) = multi_buffer_snapshot
+ if !old_kinds.contains(&cached_hint_kind)
+ && new_kinds.contains(&cached_hint_kind)
+ && let Some(anchor) = multi_buffer_snapshot
.anchor_in_excerpt(*excerpt_id, maybe_missed_cached_hint.position)
- {
- to_insert.push(Inlay::hint(
- cached_hint_id.id(),
- anchor,
- maybe_missed_cached_hint,
- ));
- }
+ {
+ to_insert.push(Inlay::hint(
+ cached_hint_id.id(),
+ anchor,
+ maybe_missed_cached_hint,
+ ));
}
}
}
@@ -620,44 +615,44 @@ impl InlayHintCache {
) {
if let Some(excerpt_hints) = self.hints.get(&excerpt_id) {
let mut guard = excerpt_hints.write();
- if let Some(cached_hint) = guard.hints_by_id.get_mut(&id) {
- if let ResolveState::CanResolve(server_id, _) = &cached_hint.resolve_state {
- let hint_to_resolve = cached_hint.clone();
- let server_id = *server_id;
- cached_hint.resolve_state = ResolveState::Resolving;
- drop(guard);
- cx.spawn_in(window, async move |editor, cx| {
- let resolved_hint_task = editor.update(cx, |editor, cx| {
- let buffer = editor.buffer().read(cx).buffer(buffer_id)?;
- editor.semantics_provider.as_ref()?.resolve_inlay_hint(
- hint_to_resolve,
- buffer,
- server_id,
- cx,
- )
- })?;
- if let Some(resolved_hint_task) = resolved_hint_task {
- let mut resolved_hint =
- resolved_hint_task.await.context("hint resolve task")?;
- editor.read_with(cx, |editor, _| {
- if let Some(excerpt_hints) =
- editor.inlay_hint_cache.hints.get(&excerpt_id)
+ if let Some(cached_hint) = guard.hints_by_id.get_mut(&id)
+ && let ResolveState::CanResolve(server_id, _) = &cached_hint.resolve_state
+ {
+ let hint_to_resolve = cached_hint.clone();
+ let server_id = *server_id;
+ cached_hint.resolve_state = ResolveState::Resolving;
+ drop(guard);
+ cx.spawn_in(window, async move |editor, cx| {
+ let resolved_hint_task = editor.update(cx, |editor, cx| {
+ let buffer = editor.buffer().read(cx).buffer(buffer_id)?;
+ editor.semantics_provider.as_ref()?.resolve_inlay_hint(
+ hint_to_resolve,
+ buffer,
+ server_id,
+ cx,
+ )
+ })?;
+ if let Some(resolved_hint_task) = resolved_hint_task {
+ let mut resolved_hint =
+ resolved_hint_task.await.context("hint resolve task")?;
+ editor.read_with(cx, |editor, _| {
+ if let Some(excerpt_hints) =
+ editor.inlay_hint_cache.hints.get(&excerpt_id)
+ {
+ let mut guard = excerpt_hints.write();
+ if let Some(cached_hint) = guard.hints_by_id.get_mut(&id)
+ && cached_hint.resolve_state == ResolveState::Resolving
{
- let mut guard = excerpt_hints.write();
- if let Some(cached_hint) = guard.hints_by_id.get_mut(&id) {
- if cached_hint.resolve_state == ResolveState::Resolving {
- resolved_hint.resolve_state = ResolveState::Resolved;
- *cached_hint = resolved_hint;
- }
- }
+ resolved_hint.resolve_state = ResolveState::Resolved;
+ *cached_hint = resolved_hint;
}
- })?;
- }
+ }
+ })?;
+ }
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
- }
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
}
}
}
@@ -990,8 +985,8 @@ fn fetch_and_update_hints(
let buffer = editor.buffer().read(cx).buffer(query.buffer_id)?;
- if !editor.registered_buffers.contains_key(&query.buffer_id) {
- if let Some(project) = editor.project.as_ref() {
+ if !editor.registered_buffers.contains_key(&query.buffer_id)
+ && let Some(project) = editor.project.as_ref() {
project.update(cx, |project, cx| {
editor.registered_buffers.insert(
query.buffer_id,
@@ -999,7 +994,6 @@ fn fetch_and_update_hints(
);
})
}
- }
editor
.semantics_provider
@@ -1240,14 +1234,12 @@ fn apply_hint_update(
.inlay_hint_cache
.allowed_hint_kinds
.contains(&new_hint.kind)
- {
- if let Some(new_hint_position) =
+ && let Some(new_hint_position) =
multi_buffer_snapshot.anchor_in_excerpt(query.excerpt_id, new_hint.position)
- {
- splice
- .to_insert
- .push(Inlay::hint(new_inlay_id, new_hint_position, &new_hint));
- }
+ {
+ splice
+ .to_insert
+ .push(Inlay::hint(new_inlay_id, new_hint_position, &new_hint));
}
let new_id = InlayId::Hint(new_inlay_id);
cached_excerpt_hints.hints_by_id.insert(new_id, new_hint);
@@ -3546,7 +3538,7 @@ pub mod tests {
let excerpt_hints = excerpt_hints.read();
for id in &excerpt_hints.ordered_hints {
let hint = &excerpt_hints.hints_by_id[id];
- let mut label = hint.text();
+ let mut label = hint.text().to_string();
if hint.padding_left {
label.insert(0, ' ');
}
@@ -1,7 +1,7 @@
use crate::{
Anchor, Autoscroll, Editor, EditorEvent, EditorSettings, ExcerptId, ExcerptRange, FormatTarget,
- MultiBuffer, MultiBufferSnapshot, NavigationData, SearchWithinRange, SelectionEffects,
- ToPoint as _,
+ MultiBuffer, MultiBufferSnapshot, NavigationData, ReportEditorEvent, SearchWithinRange,
+ SelectionEffects, ToPoint as _,
display_map::HighlightKey,
editor_settings::SeedQuerySetting,
persistence::{DB, SerializedEditor},
@@ -42,6 +42,7 @@ use ui::{IconDecorationKind, prelude::*};
use util::{ResultExt, TryFutureExt, paths::PathExt};
use workspace::{
CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId,
+ invalid_buffer_view::InvalidBufferView,
item::{FollowableItem, Item, ItemEvent, ProjectItem, SaveOptions},
searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
};
@@ -103,9 +104,9 @@ impl FollowableItem for Editor {
multibuffer = MultiBuffer::new(project.read(cx).capability());
let mut sorted_excerpts = state.excerpts.clone();
sorted_excerpts.sort_by_key(|e| e.id);
- let mut sorted_excerpts = sorted_excerpts.into_iter().peekable();
+ let sorted_excerpts = sorted_excerpts.into_iter().peekable();
- while let Some(excerpt) = sorted_excerpts.next() {
+ for excerpt in sorted_excerpts {
let Ok(buffer_id) = BufferId::new(excerpt.buffer_id) else {
continue;
};
@@ -201,7 +202,7 @@ impl FollowableItem for Editor {
if buffer
.as_singleton()
.and_then(|buffer| buffer.read(cx).file())
- .map_or(false, |file| file.is_private())
+ .is_some_and(|file| file.is_private())
{
return None;
}
@@ -293,7 +294,7 @@ impl FollowableItem for Editor {
EditorEvent::ExcerptsRemoved { ids, .. } => {
update
.deleted_excerpts
- .extend(ids.iter().map(ExcerptId::to_proto));
+ .extend(ids.iter().copied().map(ExcerptId::to_proto));
true
}
EditorEvent::ScrollPositionChanged { autoscroll, .. } if !autoscroll => {
@@ -524,8 +525,8 @@ fn serialize_selection(
) -> proto::Selection {
proto::Selection {
id: selection.id as u64,
- start: Some(serialize_anchor(&selection.start, &buffer)),
- end: Some(serialize_anchor(&selection.end, &buffer)),
+ start: Some(serialize_anchor(&selection.start, buffer)),
+ end: Some(serialize_anchor(&selection.end, buffer)),
reversed: selection.reversed,
}
}
@@ -654,6 +655,10 @@ impl Item for Editor {
}
}
+ fn suggested_filename(&self, cx: &App) -> SharedString {
+ self.buffer.read(cx).title(cx).to_string().into()
+ }
+
fn tab_icon(&self, _: &Window, cx: &App) -> Option<Icon> {
ItemSettings::get_global(cx)
.file_icons
@@ -674,7 +679,7 @@ impl Item for Editor {
let buffer = buffer.read(cx);
let path = buffer.project_path(cx)?;
let buffer_id = buffer.remote_id();
- let project = self.project.as_ref()?.read(cx);
+ let project = self.project()?.read(cx);
let entry = project.entry_for_path(&path, cx)?;
let (repo, repo_path) = project
.git_store()
@@ -711,7 +716,7 @@ impl Item for Editor {
.read(cx)
.as_singleton()
.and_then(|buffer| buffer.read(cx).file())
- .map_or(false, |file| file.disk_state() == DiskState::Deleted);
+ .is_some_and(|file| file.disk_state() == DiskState::Deleted);
h_flex()
.gap_2()
@@ -776,6 +781,10 @@ impl Item for Editor {
}
}
+ fn on_removed(&self, cx: &App) {
+ self.report_editor_event(ReportEditorEvent::Closed, None, cx);
+ }
+
fn deactivated(&mut self, _: &mut Window, cx: &mut Context<Self>) {
let selection = self.selections.newest_anchor();
self.push_to_nav_history(selection.head(), None, true, false, cx);
@@ -815,9 +824,9 @@ impl Item for Editor {
) -> Task<Result<()>> {
// Add meta data tracking # of auto saves
if options.autosave {
- self.report_editor_event("Editor Autosaved", None, cx);
+ self.report_editor_event(ReportEditorEvent::Saved { auto_saved: true }, None, cx);
} else {
- self.report_editor_event("Editor Saved", None, cx);
+ self.report_editor_event(ReportEditorEvent::Saved { auto_saved: false }, None, cx);
}
let buffers = self.buffer().clone().read(cx).all_buffers();
@@ -896,7 +905,11 @@ impl Item for Editor {
.path
.extension()
.map(|a| a.to_string_lossy().to_string());
- self.report_editor_event("Editor Saved", file_extension, cx);
+ self.report_editor_event(
+ ReportEditorEvent::Saved { auto_saved: false },
+ file_extension,
+ cx,
+ );
project.update(cx, |project, cx| project.save_buffer_as(buffer, path, cx))
}
@@ -918,10 +931,10 @@ impl Item for Editor {
})?;
buffer
.update(cx, |buffer, cx| {
- if let Some(transaction) = transaction {
- if !buffer.is_singleton() {
- buffer.push_transaction(&transaction.0, cx);
- }
+ if let Some(transaction) = transaction
+ && !buffer.is_singleton()
+ {
+ buffer.push_transaction(&transaction.0, cx);
}
})
.ok();
@@ -997,8 +1010,8 @@ impl Item for Editor {
) {
self.workspace = Some((workspace.weak_handle(), workspace.database_id()));
if let Some(workspace) = &workspace.weak_handle().upgrade() {
- cx.subscribe(&workspace, |editor, _, event: &workspace::Event, _cx| {
- if matches!(event, workspace::Event::ModalOpened) {
+ cx.subscribe(workspace, |editor, _, event: &workspace::Event, _cx| {
+ if let workspace::Event::ModalOpened = event {
editor.mouse_context_menu.take();
editor.inline_blame_popover.take();
}
@@ -1024,6 +1037,10 @@ impl Item for Editor {
f(ItemEvent::UpdateBreadcrumbs);
}
+ EditorEvent::BreadcrumbsChanged => {
+ f(ItemEvent::UpdateBreadcrumbs);
+ }
+
EditorEvent::DirtyChanged => {
f(ItemEvent::UpdateTab);
}
@@ -1276,7 +1293,7 @@ impl SerializableItem for Editor {
project
.read(cx)
.worktree_for_id(worktree_id, cx)
- .and_then(|worktree| worktree.read(cx).absolutize(&file.path()).ok())
+ .and_then(|worktree| worktree.read(cx).absolutize(file.path()).ok())
.or_else(|| {
let full_path = file.full_path(cx);
let project_path = project.read(cx).find_project_path(&full_path, cx)?;
@@ -1354,40 +1371,47 @@ impl ProjectItem for Editor {
let mut editor = Self::for_buffer(buffer.clone(), Some(project), window, cx);
if let Some((excerpt_id, buffer_id, snapshot)) =
editor.buffer().read(cx).snapshot(cx).as_singleton()
+ && WorkspaceSettings::get(None, cx).restore_on_file_reopen
+ && let Some(restoration_data) = Self::project_item_kind()
+ .and_then(|kind| pane.as_ref()?.project_item_restoration_data.get(&kind))
+ .and_then(|data| data.downcast_ref::<EditorRestorationData>())
+ .and_then(|data| {
+ let file = project::File::from_dyn(buffer.read(cx).file())?;
+ data.entries.get(&file.abs_path(cx))
+ })
{
- if WorkspaceSettings::get(None, cx).restore_on_file_reopen {
- if let Some(restoration_data) = Self::project_item_kind()
- .and_then(|kind| pane.as_ref()?.project_item_restoration_data.get(&kind))
- .and_then(|data| data.downcast_ref::<EditorRestorationData>())
- .and_then(|data| {
- let file = project::File::from_dyn(buffer.read(cx).file())?;
- data.entries.get(&file.abs_path(cx))
- })
- {
- editor.fold_ranges(
- clip_ranges(&restoration_data.folds, &snapshot),
- false,
- window,
- cx,
- );
- if !restoration_data.selections.is_empty() {
- editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
- s.select_ranges(clip_ranges(&restoration_data.selections, &snapshot));
- });
- }
- let (top_row, offset) = restoration_data.scroll_position;
- let anchor = Anchor::in_buffer(
- *excerpt_id,
- buffer_id,
- snapshot.anchor_before(Point::new(top_row, 0)),
- );
- editor.set_scroll_anchor(ScrollAnchor { anchor, offset }, window, cx);
- }
+ editor.fold_ranges(
+ clip_ranges(&restoration_data.folds, snapshot),
+ false,
+ window,
+ cx,
+ );
+ if !restoration_data.selections.is_empty() {
+ editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+ s.select_ranges(clip_ranges(&restoration_data.selections, snapshot));
+ });
}
+ let (top_row, offset) = restoration_data.scroll_position;
+ let anchor = Anchor::in_buffer(
+ *excerpt_id,
+ buffer_id,
+ snapshot.anchor_before(Point::new(top_row, 0)),
+ );
+ editor.set_scroll_anchor(ScrollAnchor { anchor, offset }, window, cx);
}
editor
}
+
+ fn for_broken_project_item(
+ abs_path: PathBuf,
+ is_local: bool,
+ e: &anyhow::Error,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> Option<InvalidBufferView> {
+ Some(InvalidBufferView::new(abs_path, is_local, e, window, cx))
+ }
}
fn clip_ranges<'a>(
@@ -1825,7 +1849,7 @@ pub fn entry_diagnostic_aware_icon_name_and_color(
diagnostic_severity: Option<DiagnosticSeverity>,
) -> Option<(IconName, Color)> {
match diagnostic_severity {
- Some(DiagnosticSeverity::ERROR) => Some((IconName::X, Color::Error)),
+ Some(DiagnosticSeverity::ERROR) => Some((IconName::Close, Color::Error)),
Some(DiagnosticSeverity::WARNING) => Some((IconName::Triangle, Color::Warning)),
_ => None,
}
@@ -37,7 +37,7 @@ pub(crate) fn should_auto_close(
let text = buffer
.text_for_range(edited_range.clone())
.collect::<String>();
- let edited_range = edited_range.to_offset(&buffer);
+ let edited_range = edited_range.to_offset(buffer);
if !text.ends_with(">") {
continue;
}
@@ -51,12 +51,11 @@ pub(crate) fn should_auto_close(
continue;
};
let mut jsx_open_tag_node = node;
- if node.grammar_name() != config.open_tag_node_name {
- if let Some(parent) = node.parent() {
- if parent.grammar_name() == config.open_tag_node_name {
- jsx_open_tag_node = parent;
- }
- }
+ if node.grammar_name() != config.open_tag_node_name
+ && let Some(parent) = node.parent()
+ && parent.grammar_name() == config.open_tag_node_name
+ {
+ jsx_open_tag_node = parent;
}
if jsx_open_tag_node.grammar_name() != config.open_tag_node_name {
continue;
@@ -87,9 +86,9 @@ pub(crate) fn should_auto_close(
});
}
if to_auto_edit.is_empty() {
- return None;
+ None
} else {
- return Some(to_auto_edit);
+ Some(to_auto_edit)
}
}
@@ -182,12 +181,12 @@ pub(crate) fn generate_auto_close_edits(
*/
{
let tag_node_name_equals = |node: &Node, name: &str| {
- let is_empty = name.len() == 0;
+ let is_empty = name.is_empty();
if let Some(node_name) = node.named_child(TS_NODE_TAG_NAME_CHILD_INDEX) {
let range = node_name.byte_range();
return buffer.text_for_range(range).equals_str(name);
}
- return is_empty;
+ is_empty
};
let tree_root_node = {
@@ -208,7 +207,7 @@ pub(crate) fn generate_auto_close_edits(
cur = descendant;
}
- assert!(ancestors.len() > 0);
+ assert!(!ancestors.is_empty());
let mut tree_root_node = open_tag;
@@ -228,7 +227,7 @@ pub(crate) fn generate_auto_close_edits(
let has_open_tag_with_same_tag_name = ancestor
.named_child(0)
.filter(|n| n.kind() == config.open_tag_node_name)
- .map_or(false, |element_open_tag_node| {
+ .is_some_and(|element_open_tag_node| {
tag_node_name_equals(&element_open_tag_node, &tag_name)
});
if has_open_tag_with_same_tag_name {
@@ -264,8 +263,7 @@ pub(crate) fn generate_auto_close_edits(
}
let is_after_open_tag = |node: &Node| {
- return node.start_byte() < open_tag.start_byte()
- && node.end_byte() < open_tag.start_byte();
+ node.start_byte() < open_tag.start_byte() && node.end_byte() < open_tag.start_byte()
};
// perf: use cursor for more efficient traversal
@@ -284,10 +282,8 @@ pub(crate) fn generate_auto_close_edits(
unclosed_open_tag_count -= 1;
}
} else if has_erroneous_close_tag && kind == erroneous_close_tag_node_name {
- if tag_node_name_equals(&node, &tag_name) {
- if !is_after_open_tag(&node) {
- unclosed_open_tag_count -= 1;
- }
+ if tag_node_name_equals(&node, &tag_name) && !is_after_open_tag(&node) {
+ unclosed_open_tag_count -= 1;
}
} else if kind == config.jsx_element_node_name {
// perf: filter only open,close,element,erroneous nodes
@@ -304,7 +300,7 @@ pub(crate) fn generate_auto_close_edits(
let edit_range = edit_anchor..edit_anchor;
edits.push((edit_range, format!("</{}>", tag_name)));
}
- return Ok(edits);
+ Ok(edits)
}
pub(crate) fn refresh_enabled_in_any_buffer(
@@ -370,7 +366,7 @@ pub(crate) fn construct_initial_buffer_versions_map<
initial_buffer_versions.insert(buffer_id, buffer_version);
}
}
- return initial_buffer_versions;
+ initial_buffer_versions
}
pub(crate) fn handle_from(
@@ -458,12 +454,9 @@ pub(crate) fn handle_from(
let ensure_no_edits_since_start = || -> Option<()> {
let has_edits_since_start = this
.read_with(cx, |this, cx| {
- this.buffer
- .read(cx)
- .buffer(buffer_id)
- .map_or(true, |buffer| {
- buffer.read(cx).has_edits_since(&buffer_version_initial)
- })
+ this.buffer.read(cx).buffer(buffer_id).is_none_or(|buffer| {
+ buffer.read(cx).has_edits_since(&buffer_version_initial)
+ })
})
.ok()?;
@@ -514,7 +507,7 @@ pub(crate) fn handle_from(
{
let selections = this
- .read_with(cx, |this, _| this.selections.disjoint_anchors().clone())
+ .read_with(cx, |this, _| this.selections.disjoint_anchors())
.ok()?;
for selection in selections.iter() {
let Some(selection_buffer_offset_head) =
@@ -815,10 +808,7 @@ mod jsx_tag_autoclose_tests {
);
buf
});
- let buffer_c = cx.new(|cx| {
- let buf = language::Buffer::local("<span", cx);
- buf
- });
+ let buffer_c = cx.new(|cx| language::Buffer::local("<span", cx));
let buffer = cx.new(|cx| {
let mut buf = MultiBuffer::new(language::Capability::ReadWrite);
buf.push_excerpts(
@@ -51,7 +51,7 @@ pub(super) fn refresh_linked_ranges(
if editor.pending_rename.is_some() {
return None;
}
- let project = editor.project.as_ref()?.downgrade();
+ let project = editor.project()?.downgrade();
editor.linked_editing_range_task = Some(cx.spawn_in(window, async move |editor, cx| {
cx.background_executor().timer(UPDATE_DEBOUNCE).await;
@@ -72,7 +72,7 @@ pub(super) fn refresh_linked_ranges(
// Throw away selections spanning multiple buffers.
continue;
}
- if let Some(buffer) = end_position.buffer_id.and_then(|id| buffer.buffer(id)) {
+ if let Some(buffer) = buffer.buffer_for_anchor(end_position, cx) {
applicable_selections.push((
buffer,
start_position.text_anchor,
@@ -95,7 +95,7 @@ pub(super) fn refresh_linked_ranges(
let snapshot = buffer.read(cx).snapshot();
let buffer_id = buffer.read(cx).remote_id();
- let linked_edits_task = project.linked_edit(buffer, *start, cx);
+ let linked_edits_task = project.linked_edits(buffer, *start, cx);
let highlights = move || async move {
let edits = linked_edits_task.await.log_err()?;
// Find the range containing our current selection.
@@ -6,7 +6,7 @@ use gpui::{Hsla, Rgba};
use itertools::Itertools;
use language::point_from_lsp;
use multi_buffer::Anchor;
-use project::{DocumentColor, lsp_store::ColorFetchStrategy};
+use project::{DocumentColor, lsp_store::LspFetchStrategy};
use settings::Settings as _;
use text::{Bias, BufferId, OffsetRangeExt as _};
use ui::{App, Context, Window};
@@ -180,9 +180,9 @@ impl Editor {
.filter_map(|buffer| {
let buffer_id = buffer.read(cx).remote_id();
let fetch_strategy = if ignore_cache {
- ColorFetchStrategy::IgnoreCache
+ LspFetchStrategy::IgnoreCache
} else {
- ColorFetchStrategy::UseCache {
+ LspFetchStrategy::UseCache {
known_cache_version: self.colors.as_ref().and_then(|colors| {
Some(colors.buffer_colors.get(&buffer_id)?.cache_version_used)
}),
@@ -207,7 +207,7 @@ impl Editor {
.entry(buffer_snapshot.remote_id())
.or_insert_with(Vec::new);
let excerpt_point_range =
- excerpt_range.context.to_point_utf16(&buffer_snapshot);
+ excerpt_range.context.to_point_utf16(buffer_snapshot);
excerpt_data.push((
excerpt_id,
buffer_snapshot.clone(),
@@ -3,9 +3,8 @@ use std::time::Duration;
use crate::Editor;
use collections::HashMap;
-use futures::stream::FuturesUnordered;
use gpui::AsyncApp;
-use gpui::{App, AppContext as _, Entity, Task};
+use gpui::{App, Entity, Task};
use itertools::Itertools;
use language::Buffer;
use language::Language;
@@ -18,7 +17,6 @@ use project::Project;
use project::TaskSourceKind;
use project::lsp_store::lsp_ext_command::GetLspRunnables;
use smol::future::FutureExt as _;
-use smol::stream::StreamExt;
use task::ResolvedTask;
use task::TaskContext;
use text::BufferId;
@@ -29,52 +27,32 @@ pub(crate) fn find_specific_language_server_in_selection<F>(
editor: &Editor,
cx: &mut App,
filter_language: F,
- language_server_name: &str,
-) -> Task<Option<(Anchor, Arc<Language>, LanguageServerId, Entity<Buffer>)>>
+ language_server_name: LanguageServerName,
+) -> Option<(Anchor, Arc<Language>, LanguageServerId, Entity<Buffer>)>
where
F: Fn(&Language) -> bool,
{
- let Some(project) = &editor.project else {
- return Task::ready(None);
- };
-
- let applicable_buffers = editor
+ let project = editor.project.clone()?;
+ editor
.selections
.disjoint_anchors()
.iter()
.filter_map(|selection| Some((selection.head(), selection.head().buffer_id?)))
.unique_by(|(_, buffer_id)| *buffer_id)
- .filter_map(|(trigger_anchor, buffer_id)| {
+ .find_map(|(trigger_anchor, buffer_id)| {
let buffer = editor.buffer().read(cx).buffer(buffer_id)?;
let language = buffer.read(cx).language_at(trigger_anchor.text_anchor)?;
if filter_language(&language) {
- Some((trigger_anchor, buffer, language))
+ let server_id = buffer.update(cx, |buffer, cx| {
+ project
+ .read(cx)
+ .language_server_id_for_name(buffer, &language_server_name, cx)
+ })?;
+ Some((trigger_anchor, language, server_id, buffer))
} else {
None
}
})
- .collect::<Vec<_>>();
-
- let applicable_buffer_tasks = applicable_buffers
- .into_iter()
- .map(|(trigger_anchor, buffer, language)| {
- let task = buffer.update(cx, |buffer, cx| {
- project.update(cx, |project, cx| {
- project.language_server_id_for_name(buffer, language_server_name, cx)
- })
- });
- (trigger_anchor, buffer, language, task)
- })
- .collect::<Vec<_>>();
- cx.background_spawn(async move {
- for (trigger_anchor, buffer, language, task) in applicable_buffer_tasks {
- if let Some(server_id) = task.await {
- return Some((trigger_anchor, language, server_id, buffer));
- }
- }
-
- None
- })
}
async fn lsp_task_context(
@@ -98,7 +76,7 @@ async fn lsp_task_context(
let project_env = project
.update(cx, |project, cx| {
- project.buffer_environment(&buffer, &worktree_store, cx)
+ project.buffer_environment(buffer, &worktree_store, cx)
})
.ok()?
.await;
@@ -116,9 +94,9 @@ pub fn lsp_tasks(
for_position: Option<text::Anchor>,
cx: &mut App,
) -> Task<Vec<(TaskSourceKind, Vec<(Option<LocationLink>, ResolvedTask)>)>> {
- let mut lsp_task_sources = task_sources
+ let lsp_task_sources = task_sources
.iter()
- .map(|(name, buffer_ids)| {
+ .filter_map(|(name, buffer_ids)| {
let buffers = buffer_ids
.iter()
.filter(|&&buffer_id| match for_position {
@@ -127,61 +105,62 @@ pub fn lsp_tasks(
})
.filter_map(|&buffer_id| project.read(cx).buffer_for_id(buffer_id, cx))
.collect::<Vec<_>>();
- language_server_for_buffers(project.clone(), name.clone(), buffers, cx)
+
+ let server_id = buffers.iter().find_map(|buffer| {
+ project.read_with(cx, |project, cx| {
+ project.language_server_id_for_name(buffer.read(cx), name, cx)
+ })
+ });
+ server_id.zip(Some(buffers))
})
- .collect::<FuturesUnordered<_>>();
+ .collect::<Vec<_>>();
cx.spawn(async move |cx| {
cx.spawn(async move |cx| {
let mut lsp_tasks = HashMap::default();
- while let Some(server_to_query) = lsp_task_sources.next().await {
- if let Some((server_id, buffers)) = server_to_query {
- let mut new_lsp_tasks = Vec::new();
- for buffer in buffers {
- let source_kind = match buffer.update(cx, |buffer, _| {
- buffer.language().map(|language| language.name())
- }) {
- Ok(Some(language_name)) => TaskSourceKind::Lsp {
- server: server_id,
- language_name: SharedString::from(language_name),
- },
- Ok(None) => continue,
- Err(_) => return Vec::new(),
- };
- let id_base = source_kind.to_id_base();
- let lsp_buffer_context = lsp_task_context(&project, &buffer, cx)
- .await
- .unwrap_or_default();
+ for (server_id, buffers) in lsp_task_sources {
+ let mut new_lsp_tasks = Vec::new();
+ for buffer in buffers {
+ let source_kind = match buffer.update(cx, |buffer, _| {
+ buffer.language().map(|language| language.name())
+ }) {
+ Ok(Some(language_name)) => TaskSourceKind::Lsp {
+ server: server_id,
+ language_name: SharedString::from(language_name),
+ },
+ Ok(None) => continue,
+ Err(_) => return Vec::new(),
+ };
+ let id_base = source_kind.to_id_base();
+ let lsp_buffer_context = lsp_task_context(&project, &buffer, cx)
+ .await
+ .unwrap_or_default();
- if let Ok(runnables_task) = project.update(cx, |project, cx| {
- let buffer_id = buffer.read(cx).remote_id();
- project.request_lsp(
- buffer,
- LanguageServerToQuery::Other(server_id),
- GetLspRunnables {
- buffer_id,
- position: for_position,
- },
- cx,
- )
- }) {
- if let Some(new_runnables) = runnables_task.await.log_err() {
- new_lsp_tasks.extend(
- new_runnables.runnables.into_iter().filter_map(
- |(location, runnable)| {
- let resolved_task = runnable
- .resolve_task(&id_base, &lsp_buffer_context)?;
- Some((location, resolved_task))
- },
- ),
- );
- }
- }
- lsp_tasks
- .entry(source_kind)
- .or_insert_with(Vec::new)
- .append(&mut new_lsp_tasks);
+ if let Ok(runnables_task) = project.update(cx, |project, cx| {
+ let buffer_id = buffer.read(cx).remote_id();
+ project.request_lsp(
+ buffer,
+ LanguageServerToQuery::Other(server_id),
+ GetLspRunnables {
+ buffer_id,
+ position: for_position,
+ },
+ cx,
+ )
+ }) && let Some(new_runnables) = runnables_task.await.log_err()
+ {
+ new_lsp_tasks.extend(new_runnables.runnables.into_iter().filter_map(
+ |(location, runnable)| {
+ let resolved_task =
+ runnable.resolve_task(&id_base, &lsp_buffer_context)?;
+ Some((location, resolved_task))
+ },
+ ));
}
+ lsp_tasks
+ .entry(source_kind)
+ .or_insert_with(Vec::new)
+ .append(&mut new_lsp_tasks);
}
}
lsp_tasks.into_iter().collect()
@@ -198,27 +177,3 @@ pub fn lsp_tasks(
.await
})
}
-
-fn language_server_for_buffers(
- project: Entity<Project>,
- name: LanguageServerName,
- candidates: Vec<Entity<Buffer>>,
- cx: &mut App,
-) -> Task<Option<(LanguageServerId, Vec<Entity<Buffer>>)>> {
- cx.spawn(async move |cx| {
- for buffer in &candidates {
- let server_id = buffer
- .update(cx, |buffer, cx| {
- project.update(cx, |project, cx| {
- project.language_server_id_for_name(buffer, &name.0, cx)
- })
- })
- .ok()?
- .await;
- if let Some(server_id) = server_id {
- return Some((server_id, candidates));
- }
- }
- None
- })
-}
@@ -1,8 +1,8 @@
use crate::{
Copy, CopyAndTrim, CopyPermalinkToLine, Cut, DisplayPoint, DisplaySnapshot, Editor,
EvaluateSelectedText, FindAllReferences, GoToDeclaration, GoToDefinition, GoToImplementation,
- GoToTypeDefinition, Paste, Rename, RevealInFileManager, SelectMode, SelectionEffects,
- SelectionExt, ToDisplayPoint, ToggleCodeActions,
+ GoToTypeDefinition, Paste, Rename, RevealInFileManager, RunToCursor, SelectMode,
+ SelectionEffects, SelectionExt, ToDisplayPoint, ToggleCodeActions,
actions::{Format, FormatSelections},
selections_collection::SelectionsCollection,
};
@@ -61,13 +61,13 @@ impl MouseContextMenu {
source,
offset: position - (source_position + content_origin),
};
- return Some(MouseContextMenu::new(
+ Some(MouseContextMenu::new(
editor,
menu_position,
context_menu,
window,
cx,
- ));
+ ))
}
pub(crate) fn new(
@@ -102,11 +102,11 @@ impl MouseContextMenu {
let display_snapshot = &editor
.display_map
.update(cx, |display_map, cx| display_map.snapshot(cx));
- let selection_init_range = selection_init.display_range(&display_snapshot);
+ let selection_init_range = selection_init.display_range(display_snapshot);
let selection_now_range = editor
.selections
.newest_anchor()
- .display_range(&display_snapshot);
+ .display_range(display_snapshot);
if selection_now_range == selection_init_range {
return;
}
@@ -190,25 +190,33 @@ pub fn deploy_context_menu(
.all::<PointUtf16>(cx)
.into_iter()
.any(|s| !s.is_empty());
- let has_git_repo = anchor.buffer_id.is_some_and(|buffer_id| {
- project
- .read(cx)
- .git_store()
- .read(cx)
- .repository_and_path_for_buffer_id(buffer_id, cx)
- .is_some()
- });
+ let has_git_repo = buffer
+ .buffer_id_for_anchor(anchor)
+ .is_some_and(|buffer_id| {
+ project
+ .read(cx)
+ .git_store()
+ .read(cx)
+ .repository_and_path_for_buffer_id(buffer_id, cx)
+ .is_some()
+ });
let evaluate_selection = window.is_action_available(&EvaluateSelectedText, cx);
+ let run_to_cursor = window.is_action_available(&RunToCursor, cx);
ui::ContextMenu::build(window, cx, |menu, _window, _cx| {
let builder = menu
.on_blur_subscription(Subscription::new(|| {}))
+ .when(run_to_cursor, |builder| {
+ builder.action("Run to Cursor", Box::new(RunToCursor))
+ })
.when(evaluate_selection && has_selections, |builder| {
- builder
- .action("Evaluate Selection", Box::new(EvaluateSelectedText))
- .separator()
+ builder.action("Evaluate Selection", Box::new(EvaluateSelectedText))
})
+ .when(
+ run_to_cursor || (evaluate_selection && has_selections),
+ |builder| builder.separator(),
+ )
.action("Go to Definition", Box::new(GoToDefinition))
.action("Go to Declaration", Box::new(GoToDeclaration))
.action("Go to Type Definition", Box::new(GoToTypeDefinition))
@@ -230,7 +230,7 @@ pub fn indented_line_beginning(
if stop_at_soft_boundaries && soft_line_start > indent_start && display_point != soft_line_start
{
soft_line_start
- } else if stop_at_indent && display_point != indent_start {
+ } else if stop_at_indent && (display_point > indent_start || display_point == line_start) {
indent_start
} else {
line_start
@@ -439,17 +439,17 @@ pub fn start_of_excerpt(
};
match direction {
Direction::Prev => {
- let mut start = excerpt.start_anchor().to_display_point(&map);
+ let mut start = excerpt.start_anchor().to_display_point(map);
if start >= display_point && start.row() > DisplayRow(0) {
let Some(excerpt) = map.buffer_snapshot.excerpt_before(excerpt.id()) else {
return display_point;
};
- start = excerpt.start_anchor().to_display_point(&map);
+ start = excerpt.start_anchor().to_display_point(map);
}
start
}
Direction::Next => {
- let mut end = excerpt.end_anchor().to_display_point(&map);
+ let mut end = excerpt.end_anchor().to_display_point(map);
*end.row_mut() += 1;
map.clip_point(end, Bias::Right)
}
@@ -467,7 +467,7 @@ pub fn end_of_excerpt(
};
match direction {
Direction::Prev => {
- let mut start = excerpt.start_anchor().to_display_point(&map);
+ let mut start = excerpt.start_anchor().to_display_point(map);
if start.row() > DisplayRow(0) {
*start.row_mut() -= 1;
}
@@ -476,7 +476,7 @@ pub fn end_of_excerpt(
start
}
Direction::Next => {
- let mut end = excerpt.end_anchor().to_display_point(&map);
+ let mut end = excerpt.end_anchor().to_display_point(map);
*end.column_mut() = 0;
if end <= display_point {
*end.row_mut() += 1;
@@ -485,7 +485,7 @@ pub fn end_of_excerpt(
else {
return display_point;
};
- end = excerpt.end_anchor().to_display_point(&map);
+ end = excerpt.end_anchor().to_display_point(map);
*end.column_mut() = 0;
}
end
@@ -510,10 +510,10 @@ pub fn find_preceding_boundary_point(
if find_range == FindRange::SingleLine && ch == '\n' {
break;
}
- if let Some(prev_ch) = prev_ch {
- if is_boundary(ch, prev_ch) {
- break;
- }
+ if let Some(prev_ch) = prev_ch
+ && is_boundary(ch, prev_ch)
+ {
+ break;
}
offset -= ch.len_utf8();
@@ -562,13 +562,13 @@ pub fn find_boundary_point(
if find_range == FindRange::SingleLine && ch == '\n' {
break;
}
- if let Some(prev_ch) = prev_ch {
- if is_boundary(prev_ch, ch) {
- if return_point_before_boundary {
- return map.clip_point(prev_offset.to_display_point(map), Bias::Right);
- } else {
- break;
- }
+ if let Some(prev_ch) = prev_ch
+ && is_boundary(prev_ch, ch)
+ {
+ if return_point_before_boundary {
+ return map.clip_point(prev_offset.to_display_point(map), Bias::Right);
+ } else {
+ break;
}
}
prev_offset = offset;
@@ -603,13 +603,13 @@ pub fn find_preceding_boundary_trail(
// Find the boundary
let start_offset = offset;
for ch in forward {
- if let Some(prev_ch) = prev_ch {
- if is_boundary(prev_ch, ch) {
- if start_offset == offset {
- trail_offset = Some(offset);
- } else {
- break;
- }
+ if let Some(prev_ch) = prev_ch
+ && is_boundary(prev_ch, ch)
+ {
+ if start_offset == offset {
+ trail_offset = Some(offset);
+ } else {
+ break;
}
}
offset -= ch.len_utf8();
@@ -651,13 +651,13 @@ pub fn find_boundary_trail(
// Find the boundary
let start_offset = offset;
for ch in forward {
- if let Some(prev_ch) = prev_ch {
- if is_boundary(prev_ch, ch) {
- if start_offset == offset {
- trail_offset = Some(offset);
- } else {
- break;
- }
+ if let Some(prev_ch) = prev_ch
+ && is_boundary(prev_ch, ch)
+ {
+ if start_offset == offset {
+ trail_offset = Some(offset);
+ } else {
+ break;
}
}
offset += ch.len_utf8();
@@ -907,12 +907,12 @@ mod tests {
let inlays = (0..buffer_snapshot.len())
.flat_map(|offset| {
[
- Inlay::inline_completion(
+ Inlay::edit_prediction(
post_inc(&mut id),
buffer_snapshot.anchor_at(offset, Bias::Left),
"test",
),
- Inlay::inline_completion(
+ Inlay::edit_prediction(
post_inc(&mut id),
buffer_snapshot.anchor_at(offset, Bias::Right),
"test",
@@ -241,24 +241,13 @@ impl ProposedChangesEditor {
event: &BufferEvent,
_cx: &mut Context<Self>,
) {
- match event {
- BufferEvent::Operation { .. } => {
- self.recalculate_diffs_tx
- .unbounded_send(RecalculateDiff {
- buffer,
- debounce: true,
- })
- .ok();
- }
- // BufferEvent::DiffBaseChanged => {
- // self.recalculate_diffs_tx
- // .unbounded_send(RecalculateDiff {
- // buffer,
- // debounce: false,
- // })
- // .ok();
- // }
- _ => (),
+ if let BufferEvent::Operation { .. } = event {
+ self.recalculate_diffs_tx
+ .unbounded_send(RecalculateDiff {
+ buffer,
+ debounce: true,
+ })
+ .ok();
}
}
}
@@ -442,7 +431,7 @@ impl SemanticsProvider for BranchBufferSemanticsProvider {
buffer: &Entity<Buffer>,
position: text::Anchor,
cx: &mut App,
- ) -> Option<Task<Vec<project::Hover>>> {
+ ) -> Option<Task<Option<Vec<project::Hover>>>> {
let buffer = self.to_base(buffer, &[position], cx)?;
self.0.hover(&buffer, position, cx)
}
@@ -478,7 +467,7 @@ impl SemanticsProvider for BranchBufferSemanticsProvider {
}
fn supports_inlay_hints(&self, buffer: &Entity<Buffer>, cx: &mut App) -> bool {
- if let Some(buffer) = self.to_base(&buffer, &[], cx) {
+ if let Some(buffer) = self.to_base(buffer, &[], cx) {
self.0.supports_inlay_hints(&buffer, cx)
} else {
false
@@ -491,7 +480,7 @@ impl SemanticsProvider for BranchBufferSemanticsProvider {
position: text::Anchor,
cx: &mut App,
) -> Option<Task<anyhow::Result<Vec<project::DocumentHighlight>>>> {
- let buffer = self.to_base(&buffer, &[position], cx)?;
+ let buffer = self.to_base(buffer, &[position], cx)?;
self.0.document_highlights(&buffer, position, cx)
}
@@ -501,8 +490,8 @@ impl SemanticsProvider for BranchBufferSemanticsProvider {
position: text::Anchor,
kind: crate::GotoDefinitionKind,
cx: &mut App,
- ) -> Option<Task<anyhow::Result<Vec<project::LocationLink>>>> {
- let buffer = self.to_base(&buffer, &[position], cx)?;
+ ) -> Option<Task<anyhow::Result<Option<Vec<project::LocationLink>>>>> {
+ let buffer = self.to_base(buffer, &[position], cx)?;
self.0.definitions(&buffer, position, kind, cx)
}
@@ -26,6 +26,17 @@ fn is_rust_language(language: &Language) -> bool {
}
pub fn apply_related_actions(editor: &Entity<Editor>, window: &mut Window, cx: &mut App) {
+ if editor.read(cx).project().is_some_and(|project| {
+ project
+ .read(cx)
+ .language_server_statuses(cx)
+ .any(|(_, status)| status.name == RUST_ANALYZER_NAME)
+ }) {
+ register_action(editor, window, cancel_flycheck_action);
+ register_action(editor, window, run_flycheck_action);
+ register_action(editor, window, clear_flycheck_action);
+ }
+
if editor
.read(cx)
.buffer()
@@ -35,12 +46,9 @@ pub fn apply_related_actions(editor: &Entity<Editor>, window: &mut Window, cx: &
.filter_map(|buffer| buffer.read(cx).language())
.any(|language| is_rust_language(language))
{
- register_action(&editor, window, go_to_parent_module);
- register_action(&editor, window, expand_macro_recursively);
- register_action(&editor, window, open_docs);
- register_action(&editor, window, cancel_flycheck_action);
- register_action(&editor, window, run_flycheck_action);
- register_action(&editor, window, clear_flycheck_action);
+ register_action(editor, window, go_to_parent_module);
+ register_action(editor, window, expand_macro_recursively);
+ register_action(editor, window, open_docs);
}
}
@@ -57,21 +65,21 @@ pub fn go_to_parent_module(
return;
};
- let server_lookup = find_specific_language_server_in_selection(
- editor,
- cx,
- is_rust_language,
- RUST_ANALYZER_NAME,
- );
+ let Some((trigger_anchor, _, server_to_query, buffer)) =
+ find_specific_language_server_in_selection(
+ editor,
+ cx,
+ is_rust_language,
+ RUST_ANALYZER_NAME,
+ )
+ else {
+ return;
+ };
let project = project.clone();
let lsp_store = project.read(cx).lsp_store();
let upstream_client = lsp_store.read(cx).upstream_client();
cx.spawn_in(window, async move |editor, cx| {
- let Some((trigger_anchor, _, server_to_query, buffer)) = server_lookup.await else {
- return anyhow::Ok(());
- };
-
let location_links = if let Some((client, project_id)) = upstream_client {
let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id())?;
@@ -121,7 +129,7 @@ pub fn go_to_parent_module(
)
})?
.await?;
- Ok(())
+ anyhow::Ok(())
})
.detach_and_log_err(cx);
}
@@ -139,21 +147,19 @@ pub fn expand_macro_recursively(
return;
};
- let server_lookup = find_specific_language_server_in_selection(
- editor,
- cx,
- is_rust_language,
- RUST_ANALYZER_NAME,
- );
-
+ let Some((trigger_anchor, rust_language, server_to_query, buffer)) =
+ find_specific_language_server_in_selection(
+ editor,
+ cx,
+ is_rust_language,
+ RUST_ANALYZER_NAME,
+ )
+ else {
+ return;
+ };
let project = project.clone();
let upstream_client = project.read(cx).lsp_store().read(cx).upstream_client();
cx.spawn_in(window, async move |_editor, cx| {
- let Some((trigger_anchor, rust_language, server_to_query, buffer)) = server_lookup.await
- else {
- return Ok(());
- };
-
let macro_expansion = if let Some((client, project_id)) = upstream_client {
let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id())?;
let request = proto::LspExtExpandMacro {
@@ -231,20 +237,20 @@ pub fn open_docs(editor: &mut Editor, _: &OpenDocs, window: &mut Window, cx: &mu
return;
};
- let server_lookup = find_specific_language_server_in_selection(
- editor,
- cx,
- is_rust_language,
- RUST_ANALYZER_NAME,
- );
+ let Some((trigger_anchor, _, server_to_query, buffer)) =
+ find_specific_language_server_in_selection(
+ editor,
+ cx,
+ is_rust_language,
+ RUST_ANALYZER_NAME,
+ )
+ else {
+ return;
+ };
let project = project.clone();
let upstream_client = project.read(cx).lsp_store().read(cx).upstream_client();
cx.spawn_in(window, async move |_editor, cx| {
- let Some((trigger_anchor, _, server_to_query, buffer)) = server_lookup.await else {
- return Ok(());
- };
-
let docs_urls = if let Some((client, project_id)) = upstream_client {
let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id())?;
let request = proto::LspExtOpenDocs {
@@ -287,11 +293,11 @@ pub fn open_docs(editor: &mut Editor, _: &OpenDocs, window: &mut Window, cx: &mu
workspace.update(cx, |_workspace, cx| {
// Check if the local document exists, otherwise fallback to the online document.
// Open with the default browser.
- if let Some(local_url) = docs_urls.local {
- if fs::metadata(Path::new(&local_url[8..])).is_ok() {
- cx.open_url(&local_url);
- return;
- }
+ if let Some(local_url) = docs_urls.local
+ && fs::metadata(Path::new(&local_url[8..])).is_ok()
+ {
+ cx.open_url(&local_url);
+ return;
}
if let Some(web_url) = docs_urls.web {
@@ -311,7 +317,7 @@ fn cancel_flycheck_action(
let Some(project) = &editor.project else {
return;
};
- let Some(buffer_id) = editor
+ let buffer_id = editor
.selections
.disjoint_anchors()
.iter()
@@ -323,10 +329,7 @@ fn cancel_flycheck_action(
.read(cx)
.entry_id(cx)?;
project.path_for_entry(entry_id, cx)
- })
- else {
- return;
- };
+ });
cancel_flycheck(project.clone(), buffer_id, cx).detach_and_log_err(cx);
}
@@ -339,7 +342,7 @@ fn run_flycheck_action(
let Some(project) = &editor.project else {
return;
};
- let Some(buffer_id) = editor
+ let buffer_id = editor
.selections
.disjoint_anchors()
.iter()
@@ -351,10 +354,7 @@ fn run_flycheck_action(
.read(cx)
.entry_id(cx)?;
project.path_for_entry(entry_id, cx)
- })
- else {
- return;
- };
+ });
run_flycheck(project.clone(), buffer_id, cx).detach_and_log_err(cx);
}
@@ -367,7 +367,7 @@ fn clear_flycheck_action(
let Some(project) = &editor.project else {
return;
};
- let Some(buffer_id) = editor
+ let buffer_id = editor
.selections
.disjoint_anchors()
.iter()
@@ -379,9 +379,6 @@ fn clear_flycheck_action(
.read(cx)
.entry_id(cx)?;
project.path_for_entry(entry_id, cx)
- })
- else {
- return;
- };
+ });
clear_flycheck(project.clone(), buffer_id, cx).detach_and_log_err(cx);
}
@@ -12,7 +12,7 @@ use crate::{
};
pub use autoscroll::{Autoscroll, AutoscrollStrategy};
use core::fmt::Debug;
-use gpui::{App, Axis, Context, Global, Pixels, Task, Window, point, px};
+use gpui::{Along, App, Axis, Context, Global, Pixels, Task, Window, point, px};
use language::language_settings::{AllLanguageSettings, SoftWrap};
use language::{Bias, Point};
pub use scroll_amount::ScrollAmount;
@@ -49,14 +49,14 @@ impl ScrollAnchor {
}
pub fn scroll_position(&self, snapshot: &DisplaySnapshot) -> gpui::Point<f32> {
- let mut scroll_position = self.offset;
- if self.anchor == Anchor::min() {
- scroll_position.y = 0.;
- } else {
- let scroll_top = self.anchor.to_display_point(snapshot).row().as_f32();
- scroll_position.y += scroll_top;
- }
- scroll_position
+ self.offset.apply_along(Axis::Vertical, |offset| {
+ if self.anchor == Anchor::min() {
+ 0.
+ } else {
+ let scroll_top = self.anchor.to_display_point(snapshot).row().as_f32();
+ (offset + scroll_top).max(0.)
+ }
+ })
}
pub fn top_row(&self, buffer: &MultiBufferSnapshot) -> u32 {
@@ -348,8 +348,8 @@ impl ScrollManager {
self.show_scrollbars
}
- pub fn autoscroll_request(&self) -> Option<Autoscroll> {
- self.autoscroll_request.map(|(autoscroll, _)| autoscroll)
+ pub fn take_autoscroll_request(&mut self) -> Option<(Autoscroll, bool)> {
+ self.autoscroll_request.take()
}
pub fn active_scrollbar_state(&self) -> Option<&ActiveScrollbarState> {
@@ -675,7 +675,7 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) {
- if matches!(self.mode, EditorMode::SingleLine { .. }) {
+ if matches!(self.mode, EditorMode::SingleLine) {
cx.propagate();
return;
}
@@ -703,20 +703,20 @@ impl Editor {
if matches!(
settings.defaults.soft_wrap,
SoftWrap::PreferredLineLength | SoftWrap::Bounded
- ) {
- if (settings.defaults.preferred_line_length as f32) < visible_column_count {
- visible_column_count = settings.defaults.preferred_line_length as f32;
- }
+ ) && (settings.defaults.preferred_line_length as f32) < visible_column_count
+ {
+ visible_column_count = settings.defaults.preferred_line_length as f32;
}
// If the scroll position is currently at the left edge of the document
// (x == 0.0) and the intent is to scroll right, the gutter's margin
// should first be added to the current position, otherwise the cursor
// will end at the column position minus the margin, which looks off.
- if current_position.x == 0.0 && amount.columns(visible_column_count) > 0. {
- if let Some(last_position_map) = &self.last_position_map {
- current_position.x += self.gutter_dimensions.margin / last_position_map.em_advance;
- }
+ if current_position.x == 0.0
+ && amount.columns(visible_column_count) > 0.
+ && let Some(last_position_map) = &self.last_position_map
+ {
+ current_position.x += self.gutter_dimensions.margin / last_position_map.em_advance;
}
let new_position = current_position
+ point(
@@ -749,12 +749,10 @@ impl Editor {
if let (Some(visible_lines), Some(visible_columns)) =
(self.visible_line_count(), self.visible_column_count())
+ && newest_head.row() <= DisplayRow(screen_top.row().0 + visible_lines as u32)
+ && newest_head.column() <= screen_top.column() + visible_columns as u32
{
- if newest_head.row() <= DisplayRow(screen_top.row().0 + visible_lines as u32)
- && newest_head.column() <= screen_top.column() + visible_columns as u32
- {
- return Ordering::Equal;
- }
+ return Ordering::Equal;
}
Ordering::Greater
@@ -16,7 +16,7 @@ impl Editor {
return;
}
- if matches!(self.mode, EditorMode::SingleLine { .. }) {
+ if matches!(self.mode, EditorMode::SingleLine) {
cx.propagate();
return;
}
@@ -102,15 +102,12 @@ impl AutoscrollStrategy {
pub(crate) struct NeedsHorizontalAutoscroll(pub(crate) bool);
impl Editor {
- pub fn autoscroll_request(&self) -> Option<Autoscroll> {
- self.scroll_manager.autoscroll_request()
- }
-
pub(crate) fn autoscroll_vertically(
&mut self,
bounds: Bounds<Pixels>,
line_height: Pixels,
max_scroll_top: f32,
+ autoscroll_request: Option<(Autoscroll, bool)>,
window: &mut Window,
cx: &mut Context<Editor>,
) -> (NeedsHorizontalAutoscroll, WasScrolled) {
@@ -119,12 +116,12 @@ impl Editor {
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let mut scroll_position = self.scroll_manager.scroll_position(&display_map);
let original_y = scroll_position.y;
- if let Some(last_bounds) = self.expect_bounds_change.take() {
- if scroll_position.y != 0. {
- scroll_position.y += (bounds.top() - last_bounds.top()) / line_height;
- if scroll_position.y < 0. {
- scroll_position.y = 0.;
- }
+ if let Some(last_bounds) = self.expect_bounds_change.take()
+ && scroll_position.y != 0.
+ {
+ scroll_position.y += (bounds.top() - last_bounds.top()) / line_height;
+ if scroll_position.y < 0. {
+ scroll_position.y = 0.;
}
}
if scroll_position.y > max_scroll_top {
@@ -137,7 +134,7 @@ impl Editor {
WasScrolled(false)
};
- let Some((autoscroll, local)) = self.scroll_manager.autoscroll_request.take() else {
+ let Some((autoscroll, local)) = autoscroll_request else {
return (NeedsHorizontalAutoscroll(false), editor_was_scrolled);
};
@@ -284,9 +281,12 @@ impl Editor {
scroll_width: Pixels,
em_advance: Pixels,
layouts: &[LineWithInvisibles],
+ autoscroll_request: Option<(Autoscroll, bool)>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<gpui::Point<f32>> {
+ let (_, local) = autoscroll_request?;
+
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let selections = self.selections.all::<Point>(cx);
let mut scroll_position = self.scroll_manager.scroll_position(&display_map);
@@ -335,10 +335,10 @@ impl Editor {
let was_scrolled = if target_left < scroll_left {
scroll_position.x = target_left / em_advance;
- self.set_scroll_position_internal(scroll_position, true, true, window, cx)
+ self.set_scroll_position_internal(scroll_position, local, true, window, cx)
} else if target_right > scroll_right {
scroll_position.x = (target_right - viewport_width) / em_advance;
- self.set_scroll_position_internal(scroll_position, true, true, window, cx)
+ self.set_scroll_position_internal(scroll_position, local, true, window, cx)
} else {
WasScrolled(false)
};
@@ -67,10 +67,7 @@ impl ScrollAmount {
}
pub fn is_full_page(&self) -> bool {
- match self {
- ScrollAmount::Page(count) if count.abs() == 1.0 => true,
- _ => false,
- }
+ matches!(self, ScrollAmount::Page(count) if count.abs() == 1.0)
}
pub fn direction(&self) -> ScrollDirection {
@@ -119,8 +119,8 @@ impl SelectionsCollection {
cx: &mut App,
) -> Option<Selection<D>> {
let map = self.display_map(cx);
- let selection = resolve_selections(self.pending_anchor().as_ref(), &map).next();
- selection
+
+ resolve_selections(self.pending_anchor().as_ref(), &map).next()
}
pub(crate) fn pending_mode(&self) -> Option<SelectMode> {
@@ -276,18 +276,18 @@ impl SelectionsCollection {
cx: &mut App,
) -> Selection<D> {
let map = self.display_map(cx);
- let selection = resolve_selections([self.newest_anchor()], &map)
+
+ resolve_selections([self.newest_anchor()], &map)
.next()
- .unwrap();
- selection
+ .unwrap()
}
pub fn newest_display(&self, cx: &mut App) -> Selection<DisplayPoint> {
let map = self.display_map(cx);
- let selection = resolve_selections_display([self.newest_anchor()], &map)
+
+ resolve_selections_display([self.newest_anchor()], &map)
.next()
- .unwrap();
- selection
+ .unwrap()
}
pub fn oldest_anchor(&self) -> &Selection<Anchor> {
@@ -303,10 +303,10 @@ impl SelectionsCollection {
cx: &mut App,
) -> Selection<D> {
let map = self.display_map(cx);
- let selection = resolve_selections([self.oldest_anchor()], &map)
+
+ resolve_selections([self.oldest_anchor()], &map)
.next()
- .unwrap();
- selection
+ .unwrap()
}
pub fn first_anchor(&self) -> Selection<Anchor> {
@@ -169,7 +169,7 @@ impl Editor {
else {
return;
};
- let Some(lsp_store) = self.project.as_ref().map(|p| p.read(cx).lsp_store()) else {
+ let Some(lsp_store) = self.project().map(|p| p.read(cx).lsp_store()) else {
return;
};
let task = lsp_store.update(cx, |lsp_store, cx| {
@@ -182,7 +182,9 @@ impl Editor {
let signature_help = task.await;
editor
.update(cx, |editor, cx| {
- let Some(mut signature_help) = signature_help.into_iter().next() else {
+ let Some(mut signature_help) =
+ signature_help.unwrap_or_default().into_iter().next()
+ else {
editor
.signature_help_state
.hide(SignatureHelpHiddenBy::AutoClose);
@@ -191,12 +193,12 @@ impl Editor {
if let Some(language) = language {
for signature in &mut signature_help.signatures {
- let text = Rope::from(signature.label.to_string());
+ let text = Rope::from(signature.label.as_ref());
let highlights = language
.highlight_text(&text, 0..signature.label.len())
.into_iter()
.flat_map(|(range, highlight_id)| {
- Some((range, highlight_id.style(&cx.theme().syntax())?))
+ Some((range, highlight_id.style(cx.theme().syntax())?))
});
signature.highlights =
combine_highlights(signature.highlights.clone(), highlights)
@@ -89,7 +89,7 @@ impl Editor {
.lsp_task_source()?;
if lsp_settings
.get(&lsp_tasks_source)
- .map_or(true, |s| s.enable_lsp_tasks)
+ .is_none_or(|s| s.enable_lsp_tasks)
{
let buffer_id = buffer.read(cx).remote_id();
Some((lsp_tasks_source, buffer_id))
@@ -53,7 +53,7 @@ pub fn marked_display_snapshot(
let (unmarked_text, markers) = marked_text_offsets(text);
let font = Font {
- family: "Zed Plex Mono".into(),
+ family: ".ZedMono".into(),
features: FontFeatures::default(),
fallbacks: None,
weight: FontWeight::default(),
@@ -184,12 +184,12 @@ pub fn editor_content_with_blocks(editor: &Entity<Editor>, cx: &mut VisualTestCo
for (row, block) in blocks {
match block {
Block::Custom(custom_block) => {
- if let BlockPlacement::Near(x) = &custom_block.placement {
- if snapshot.intersects_fold(x.to_point(&snapshot.buffer_snapshot)) {
- continue;
- }
+ if let BlockPlacement::Near(x) = &custom_block.placement
+ && snapshot.intersects_fold(x.to_point(&snapshot.buffer_snapshot))
+ {
+ continue;
};
- let content = block_content_for_tests(&editor, custom_block.id, cx)
+ let content = block_content_for_tests(editor, custom_block.id, cx)
.expect("block content not found");
// 2: "related info 1 for diagnostic 0"
if let Some(height) = custom_block.height {
@@ -230,26 +230,23 @@ pub fn editor_content_with_blocks(editor: &Entity<Editor>, cx: &mut VisualTestCo
lines[row as usize].push_str("§ -----");
}
}
- Block::ExcerptBoundary {
- excerpt,
- height,
- starts_new_buffer,
- } => {
- if starts_new_buffer {
- lines[row.0 as usize].push_str(&cx.update(|_, cx| {
- format!(
- "§ {}",
- excerpt
- .buffer
- .file()
- .unwrap()
- .file_name(cx)
- .to_string_lossy()
- )
- }));
- } else {
- lines[row.0 as usize].push_str("§ -----")
+ Block::ExcerptBoundary { height, .. } => {
+ for row in row.0..row.0 + height {
+ lines[row as usize].push_str("§ -----");
}
+ }
+ Block::BufferHeader { excerpt, height } => {
+ lines[row.0 as usize].push_str(&cx.update(|_, cx| {
+ format!(
+ "§ {}",
+ excerpt
+ .buffer
+ .file()
+ .unwrap()
+ .file_name(cx)
+ .to_string_lossy()
+ )
+ }));
for row in row.0 + 1..row.0 + height {
lines[row as usize].push_str("§ -----");
}
@@ -14,7 +14,8 @@ use futures::Future;
use gpui::{Context, Entity, Focusable as _, VisualTestContext, Window};
use indoc::indoc;
use language::{
- FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageQueries, point_to_lsp,
+ BlockCommentConfig, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageQueries,
+ point_to_lsp,
};
use lsp::{notification, request};
use multi_buffer::ToPointUtf16;
@@ -269,7 +270,12 @@ impl EditorLspTestContext {
path_suffixes: vec!["html".into()],
..Default::default()
},
- block_comment: Some(("<!-- ".into(), " -->".into())),
+ block_comment: Some(BlockCommentConfig {
+ start: "<!--".into(),
+ prefix: "".into(),
+ end: "-->".into(),
+ tab_size: 0,
+ }),
completion_query_characters: ['-'].into_iter().collect(),
..Default::default()
},
@@ -294,6 +300,7 @@ impl EditorLspTestContext {
self.to_lsp_range(ranges[0].clone())
}
+ #[expect(clippy::wrong_self_convention, reason = "This is test code")]
pub fn to_lsp_range(&mut self, range: Range<usize>) -> lsp::Range {
let snapshot = self.update_editor(|editor, window, cx| editor.snapshot(window, cx));
let start_point = range.start.to_point(&snapshot.buffer_snapshot);
@@ -320,6 +327,7 @@ impl EditorLspTestContext {
})
}
+ #[expect(clippy::wrong_self_convention, reason = "This is test code")]
pub fn to_lsp(&mut self, offset: usize) -> lsp::Position {
let snapshot = self.update_editor(|editor, window, cx| editor.snapshot(window, cx));
let point = offset.to_point(&snapshot.buffer_snapshot);
@@ -119,13 +119,7 @@ impl EditorTestContext {
for excerpt in excerpts.into_iter() {
let (text, ranges) = marked_text_ranges(excerpt, false);
let buffer = cx.new(|cx| Buffer::local(text, cx));
- multibuffer.push_excerpts(
- buffer,
- ranges
- .into_iter()
- .map(|range| ExcerptRange::new(range.clone())),
- cx,
- );
+ multibuffer.push_excerpts(buffer, ranges.into_iter().map(ExcerptRange::new), cx);
}
multibuffer
});
@@ -297,9 +291,8 @@ impl EditorTestContext {
pub fn set_head_text(&mut self, diff_base: &str) {
self.cx.run_until_parked();
- let fs = self.update_editor(|editor, _, cx| {
- editor.project.as_ref().unwrap().read(cx).fs().as_fake()
- });
+ let fs =
+ self.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).fs().as_fake());
let path = self.update_buffer(|buffer, _| buffer.file().unwrap().path().clone());
fs.set_head_for_repo(
&Self::root_path().join(".git"),
@@ -311,18 +304,16 @@ impl EditorTestContext {
pub fn clear_index_text(&mut self) {
self.cx.run_until_parked();
- let fs = self.update_editor(|editor, _, cx| {
- editor.project.as_ref().unwrap().read(cx).fs().as_fake()
- });
+ let fs =
+ self.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).fs().as_fake());
fs.set_index_for_repo(&Self::root_path().join(".git"), &[]);
self.cx.run_until_parked();
}
pub fn set_index_text(&mut self, diff_base: &str) {
self.cx.run_until_parked();
- let fs = self.update_editor(|editor, _, cx| {
- editor.project.as_ref().unwrap().read(cx).fs().as_fake()
- });
+ let fs =
+ self.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).fs().as_fake());
let path = self.update_buffer(|buffer, _| buffer.file().unwrap().path().clone());
fs.set_index_for_repo(
&Self::root_path().join(".git"),
@@ -333,9 +324,8 @@ impl EditorTestContext {
#[track_caller]
pub fn assert_index_text(&mut self, expected: Option<&str>) {
- let fs = self.update_editor(|editor, _, cx| {
- editor.project.as_ref().unwrap().read(cx).fs().as_fake()
- });
+ let fs =
+ self.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).fs().as_fake());
let path = self.update_buffer(|buffer, _| buffer.file().unwrap().path().clone());
let mut found = None;
fs.with_git_state(&Self::root_path().join(".git"), false, |git_state| {
@@ -430,7 +420,7 @@ impl EditorTestContext {
if expected_text == "[FOLDED]\n" {
assert!(is_folded, "excerpt {} should be folded", ix);
let is_selected = selections.iter().any(|s| s.head().excerpt_id == excerpt_id);
- if expected_selections.len() > 0 {
+ if !expected_selections.is_empty() {
assert!(
is_selected,
"excerpt {ix} should be selected. got {:?}",
@@ -19,8 +19,8 @@ path = "src/explorer.rs"
[dependencies]
agent.workspace = true
-agent_ui.workspace = true
agent_settings.workspace = true
+agent_ui.workspace = true
anyhow.workspace = true
assistant_tool.workspace = true
assistant_tools.workspace = true
@@ -29,6 +29,7 @@ buffer_diff.workspace = true
chrono.workspace = true
clap.workspace = true
client.workspace = true
+cloud_llm_client.workspace = true
collections.workspace = true
debug_adapter_extension.workspace = true
dirs.workspace = true
@@ -68,4 +69,3 @@ util.workspace = true
uuid.workspace = true
watch.workspace = true
workspace-hack.workspace = true
-zed_llm_client.workspace = true
@@ -0,0 +1,14 @@
+fn main() {
+ let cargo_toml =
+ std::fs::read_to_string("../zed/Cargo.toml").expect("Failed to read crates/zed/Cargo.toml");
+ let version = cargo_toml
+ .lines()
+ .find(|line| line.starts_with("version = "))
+ .expect("Version not found in crates/zed/Cargo.toml")
+ .split('=')
+ .nth(1)
+ .expect("Invalid version format")
+ .trim()
+ .trim_matches('"');
+ println!("cargo:rustc-env=ZED_PKG_VERSION={}", version);
+}
@@ -54,7 +54,7 @@ impl AssertionsReport {
pub fn passed_count(&self) -> usize {
self.ran
.iter()
- .filter(|a| a.result.as_ref().map_or(false, |result| result.passed))
+ .filter(|a| a.result.as_ref().is_ok_and(|result| result.passed))
.count()
}
@@ -18,7 +18,7 @@ use collections::{HashMap, HashSet};
use extension::ExtensionHostProxy;
use futures::future;
use gpui::http_client::read_proxy_from_env;
-use gpui::{App, AppContext, Application, AsyncApp, Entity, SemanticVersion, UpdateGlobal};
+use gpui::{App, AppContext, Application, AsyncApp, Entity, UpdateGlobal};
use gpui_tokio::Tokio;
use language::LanguageRegistry;
use language_model::{ConfiguredModel, LanguageModel, LanguageModelRegistry, SelectedModel};
@@ -103,7 +103,7 @@ fn main() {
let languages: HashSet<String> = args.languages.into_iter().collect();
let http_client = Arc::new(ReqwestClient::new());
- let app = Application::headless().with_http_client(http_client.clone());
+ let app = Application::headless().with_http_client(http_client);
let all_threads = examples::all(&examples_dir);
app.run(move |cx| {
@@ -112,7 +112,7 @@ fn main() {
let telemetry = app_state.client.telemetry();
telemetry.start(system_id, installation_id, session_id, cx);
- let enable_telemetry = env::var("ZED_EVAL_TELEMETRY").map_or(false, |value| value == "1")
+ let enable_telemetry = env::var("ZED_EVAL_TELEMETRY").is_ok_and(|value| value == "1")
&& telemetry.has_checksum_seed();
if enable_telemetry {
println!("Telemetry enabled");
@@ -167,15 +167,14 @@ fn main() {
continue;
}
- if let Some(language) = meta.language_server {
- if !languages.contains(&language.file_extension) {
+ if let Some(language) = meta.language_server
+ && !languages.contains(&language.file_extension) {
panic!(
"Eval for {:?} could not be run because no language server was found for extension {:?}",
meta.name,
language.file_extension
);
}
- }
// TODO: This creates a worktree per repetition. Ideally these examples should
// either be run sequentially on the same worktree, or reuse worktrees when there
@@ -337,7 +336,8 @@ pub struct AgentAppState {
}
pub fn init(cx: &mut App) -> Arc<AgentAppState> {
- release_channel::init(SemanticVersion::default(), cx);
+ let app_version = AppVersion::load(env!("ZED_PKG_VERSION"));
+ release_channel::init(app_version, cx);
gpui_tokio::init(cx);
let mut settings_store = SettingsStore::new(cx);
@@ -349,8 +349,8 @@ pub fn init(cx: &mut App) -> Arc<AgentAppState> {
// Set User-Agent so we can download language servers from GitHub
let user_agent = format!(
- "Zed/{} ({}; {})",
- AppVersion::global(cx),
+ "Zed Agent Eval/{} ({}; {})",
+ app_version,
std::env::consts::OS,
std::env::consts::ARCH
);
@@ -416,11 +416,7 @@ pub fn init(cx: &mut App) -> Arc<AgentAppState> {
language::init(cx);
debug_adapter_extension::init(extension_host_proxy.clone(), cx);
- language_extension::init(
- LspAccess::Noop,
- extension_host_proxy.clone(),
- languages.clone(),
- );
+ language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone());
language_model::init(client.clone(), cx);
language_models::init(user_store.clone(), client.clone(), cx);
languages::init(languages.clone(), node_runtime.clone(), cx);
@@ -519,7 +515,7 @@ async fn judge_example(
enable_telemetry: bool,
cx: &AsyncApp,
) -> JudgeOutput {
- let judge_output = example.judge(model.clone(), &run_output, cx).await;
+ let judge_output = example.judge(model.clone(), run_output, cx).await;
if enable_telemetry {
telemetry::event!(
@@ -530,7 +526,7 @@ async fn judge_example(
example_name = example.name.clone(),
example_repetition = example.repetition,
diff_evaluation = judge_output.diff.clone(),
- thread_evaluation = judge_output.thread.clone(),
+ thread_evaluation = judge_output.thread,
tool_metrics = run_output.tool_metrics,
response_count = run_output.response_count,
token_usage = run_output.token_usage,
@@ -710,7 +706,7 @@ fn print_report(
println!("Average thread score: {average_thread_score}%");
}
- println!("");
+ println!();
print_h2("CUMULATIVE TOOL METRICS");
println!("{}", cumulative_tool_metrics);
@@ -15,11 +15,11 @@ use agent_settings::AgentProfileId;
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use buffer_diff::DiffHunkStatus;
+use cloud_llm_client::CompletionIntent;
use collections::HashMap;
use futures::{FutureExt as _, StreamExt, channel::mpsc, select_biased};
use gpui::{App, AppContext, AsyncApp, Entity};
use language_model::{LanguageModel, Role, StopReason};
-use zed_llm_client::CompletionIntent;
pub const THREAD_EVENT_TIMEOUT: Duration = Duration::from_secs(60 * 2);
@@ -64,7 +64,7 @@ impl ExampleMetadata {
self.url
.split('/')
.next_back()
- .unwrap_or(&"")
+ .unwrap_or("")
.trim_end_matches(".git")
.into()
}
@@ -221,9 +221,6 @@ impl ExampleContext {
ThreadEvent::ShowError(thread_error) => {
tx.try_send(Err(anyhow!(thread_error.clone()))).ok();
}
- ThreadEvent::RetriesFailed { .. } => {
- // Ignore retries failed events
- }
ThreadEvent::Stopped(reason) => match reason {
Ok(StopReason::EndTurn) => {
tx.close_channel();
@@ -258,7 +255,7 @@ impl ExampleContext {
thread.update(cx, |thread, _cx| {
if let Some(tool_use) = pending_tool_use {
let mut tool_metrics = tool_metrics.lock().unwrap();
- if let Some(tool_result) = thread.tool_result(&tool_use_id) {
+ if let Some(tool_result) = thread.tool_result(tool_use_id) {
let message = if tool_result.is_error {
format!("✖︎ {}", tool_use.name)
} else {
@@ -338,7 +335,7 @@ impl ExampleContext {
for message in thread.messages().skip(message_count_before) {
messages.push(Message {
_role: message.role,
- text: message.to_string(),
+ text: message.to_message_content(),
tool_use: thread
.tool_uses_for_message(message.id, cx)
.into_iter()
@@ -425,6 +422,13 @@ impl AppContext for ExampleContext {
self.app.update_entity(handle, update)
}
+ fn as_mut<'a, T>(&'a mut self, handle: &Entity<T>) -> Self::Result<gpui::GpuiBorrow<'a, T>>
+ where
+ T: 'static,
+ {
+ self.app.as_mut(handle)
+ }
+
fn read_entity<T, R>(
&self,
handle: &Entity<T>,
@@ -70,10 +70,10 @@ impl Example for AddArgToTraitMethod {
let path_str = format!("crates/assistant_tools/src/{}.rs", tool_name);
let edits = edits.get(Path::new(&path_str));
- let ignored = edits.map_or(false, |edits| {
+ let ignored = edits.is_some_and(|edits| {
edits.has_added_line(" _window: Option<gpui::AnyWindowHandle>,\n")
});
- let uningored = edits.map_or(false, |edits| {
+ let uningored = edits.is_some_and(|edits| {
edits.has_added_line(" window: Option<gpui::AnyWindowHandle>,\n")
});
@@ -89,7 +89,7 @@ impl Example for AddArgToTraitMethod {
let batch_tool_edits = edits.get(Path::new("crates/assistant_tools/src/batch_tool.rs"));
cx.assert(
- batch_tool_edits.map_or(false, |edits| {
+ batch_tool_edits.is_some_and(|edits| {
edits.has_added_line(" window: Option<gpui::AnyWindowHandle>,\n")
}),
"Argument: batch_tool",
@@ -46,27 +46,25 @@ fn find_target_files_recursive(
max_depth,
found_files,
)?;
- } else if path.is_file() {
- if let Some(filename_osstr) = path.file_name() {
- if let Some(filename_str) = filename_osstr.to_str() {
- if filename_str == target_filename {
- found_files.push(path);
- }
- }
- }
+ } else if path.is_file()
+ && let Some(filename_osstr) = path.file_name()
+ && let Some(filename_str) = filename_osstr.to_str()
+ && filename_str == target_filename
+ {
+ found_files.push(path);
}
}
Ok(())
}
pub fn generate_explorer_html(input_paths: &[PathBuf], output_path: &PathBuf) -> Result<String> {
- if let Some(parent) = output_path.parent() {
- if !parent.exists() {
- fs::create_dir_all(parent).context(format!(
- "Failed to create output directory: {}",
- parent.display()
- ))?;
- }
+ if let Some(parent) = output_path.parent()
+ && !parent.exists()
+ {
+ fs::create_dir_all(parent).context(format!(
+ "Failed to create output directory: {}",
+ parent.display()
+ ))?;
}
let template_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/explorer.html");
@@ -90,11 +90,8 @@ impl ExampleInstance {
worktrees_dir: &Path,
repetition: usize,
) -> Self {
- let name = thread.meta().name.to_string();
- let run_directory = run_dir
- .join(&name)
- .join(repetition.to_string())
- .to_path_buf();
+ let name = thread.meta().name;
+ let run_directory = run_dir.join(&name).join(repetition.to_string());
let repo_path = repo_path_for_url(repos_dir, &thread.meta().url);
@@ -376,11 +373,10 @@ impl ExampleInstance {
);
let result = this.thread.conversation(&mut example_cx).await;
- if let Err(err) = result {
- if !err.is::<FailedAssertion>() {
+ if let Err(err) = result
+ && !err.is::<FailedAssertion>() {
return Err(err);
}
- }
println!("{}Stopped", this.log_prefix);
@@ -459,8 +455,8 @@ impl ExampleInstance {
let mut output_file =
File::create(self.run_directory.join("judge.md")).expect("failed to create judge.md");
- let diff_task = self.judge_diff(model.clone(), &run_output, cx);
- let thread_task = self.judge_thread(model.clone(), &run_output, cx);
+ let diff_task = self.judge_diff(model.clone(), run_output, cx);
+ let thread_task = self.judge_thread(model.clone(), run_output, cx);
let (diff_result, thread_result) = futures::join!(diff_task, thread_task);
@@ -661,7 +657,7 @@ pub fn wait_for_lang_server(
.update(cx, |buffer, cx| {
lsp_store.update(cx, |lsp_store, cx| {
lsp_store
- .language_servers_for_local_buffer(&buffer, cx)
+ .language_servers_for_local_buffer(buffer, cx)
.next()
.is_some()
})
@@ -679,8 +675,8 @@ pub fn wait_for_lang_server(
[
cx.subscribe(&lsp_store, {
let log_prefix = log_prefix.clone();
- move |_, event, _| match event {
- project::LspStoreEvent::LanguageServerUpdate {
+ move |_, event, _| {
+ if let project::LspStoreEvent::LanguageServerUpdate {
message:
client::proto::update_language_server::Variant::WorkProgress(
LspWorkProgress {
@@ -689,11 +685,13 @@ pub fn wait_for_lang_server(
},
),
..
- } => println!("{}⟲ {message}", log_prefix),
- _ => {}
+ } = event
+ {
+ println!("{}⟲ {message}", log_prefix)
+ }
}
}),
- cx.subscribe(&project, {
+ cx.subscribe(project, {
let buffer = buffer.clone();
move |project, event, cx| match event {
project::Event::LanguageServerAdded(_, _, _) => {
@@ -771,7 +769,7 @@ pub async fn query_lsp_diagnostics(
}
fn parse_assertion_result(response: &str) -> Result<RanAssertionResult> {
- let analysis = get_tag("analysis", response)?.to_string();
+ let analysis = get_tag("analysis", response)?;
let passed = match get_tag("passed", response)?.to_lowercase().as_str() {
"true" => true,
"false" => false,
@@ -838,7 +836,7 @@ fn messages_to_markdown<'a>(message_iter: impl IntoIterator<Item = &'a Message>)
for segment in &message.segments {
match segment {
MessageSegment::Text(text) => {
- messages.push_str(&text);
+ messages.push_str(text);
messages.push_str("\n\n");
}
MessageSegment::Thinking { text, signature } => {
@@ -846,7 +844,7 @@ fn messages_to_markdown<'a>(message_iter: impl IntoIterator<Item = &'a Message>)
if let Some(sig) = signature {
messages.push_str(&format!("Signature: {}\n\n", sig));
}
- messages.push_str(&text);
+ messages.push_str(text);
messages.push_str("\n");
}
MessageSegment::RedactedThinking(items) => {
@@ -878,7 +876,7 @@ pub async fn send_language_model_request(
request: LanguageModelRequest,
cx: &AsyncApp,
) -> anyhow::Result<String> {
- match model.stream_completion_text(request, &cx).await {
+ match model.stream_completion_text(request, cx).await {
Ok(mut stream) => {
let mut full_response = String::new();
while let Some(chunk_result) = stream.stream.next().await {
@@ -915,9 +913,9 @@ impl RequestMarkdown {
for tool in &request.tools {
write!(&mut tools, "# {}\n\n", tool.name).unwrap();
write!(&mut tools, "{}\n\n", tool.description).unwrap();
- write!(
+ writeln!(
&mut tools,
- "{}\n",
+ "{}",
MarkdownCodeBlock {
tag: "json",
text: &format!("{:#}", tool.input_schema)
@@ -1191,7 +1189,7 @@ mod test {
output.analysis,
Some("The model did a good job but there were still compilations errors.".into())
);
- assert_eq!(output.passed, true);
+ assert!(output.passed);
let response = r#"
Text around ignored
@@ -1211,6 +1209,6 @@ mod test {
output.analysis,
Some("Failed to compile:\n- Error 1\n- Error 2".into())
);
- assert_eq!(output.passed, false);
+ assert!(!output.passed);
}
}
@@ -32,7 +32,11 @@ serde.workspace = true
serde_json.workspace = true
task.workspace = true
toml.workspace = true
+url.workspace = true
util.workspace = true
wasm-encoder.workspace = true
wasmparser.workspace = true
workspace-hack.workspace = true
+
+[dev-dependencies]
+pretty_assertions.workspace = true
@@ -0,0 +1,20 @@
+mod download_file_capability;
+mod npm_install_package_capability;
+mod process_exec_capability;
+
+pub use download_file_capability::*;
+pub use npm_install_package_capability::*;
+pub use process_exec_capability::*;
+
+use serde::{Deserialize, Serialize};
+
+/// A capability for an extension.
+#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum ExtensionCapability {
+ #[serde(rename = "process:exec")]
+ ProcessExec(ProcessExecCapability),
+ DownloadFile(DownloadFileCapability),
+ #[serde(rename = "npm:install")]
+ NpmInstallPackage(NpmInstallPackageCapability),
+}
@@ -0,0 +1,121 @@
+use serde::{Deserialize, Serialize};
+use url::Url;
+
+#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct DownloadFileCapability {
+ pub host: String,
+ pub path: Vec<String>,
+}
+
+impl DownloadFileCapability {
+ /// Returns whether the capability allows downloading a file from the given URL.
+ pub fn allows(&self, url: &Url) -> bool {
+ let Some(desired_host) = url.host_str() else {
+ return false;
+ };
+
+ let Some(desired_path) = url.path_segments() else {
+ return false;
+ };
+ let desired_path = desired_path.collect::<Vec<_>>();
+
+ if self.host != desired_host && self.host != "*" {
+ return false;
+ }
+
+ for (ix, path_segment) in self.path.iter().enumerate() {
+ if path_segment == "**" {
+ return true;
+ }
+
+ if ix >= desired_path.len() {
+ return false;
+ }
+
+ if path_segment != "*" && path_segment != desired_path[ix] {
+ return false;
+ }
+ }
+
+ if self.path.len() < desired_path.len() {
+ return false;
+ }
+
+ true
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use pretty_assertions::assert_eq;
+
+ use super::*;
+
+ #[test]
+ fn test_allows() {
+ let capability = DownloadFileCapability {
+ host: "*".to_string(),
+ path: vec!["**".to_string()],
+ };
+ assert_eq!(
+ capability.allows(&"https://example.com/some/path".parse().unwrap()),
+ true
+ );
+
+ let capability = DownloadFileCapability {
+ host: "github.com".to_string(),
+ path: vec!["**".to_string()],
+ };
+ assert_eq!(
+ capability.allows(&"https://github.com/some-owner/some-repo".parse().unwrap()),
+ true
+ );
+ assert_eq!(
+ capability.allows(
+ &"https://fake-github.com/some-owner/some-repo"
+ .parse()
+ .unwrap()
+ ),
+ false
+ );
+
+ let capability = DownloadFileCapability {
+ host: "github.com".to_string(),
+ path: vec!["specific-owner".to_string(), "*".to_string()],
+ };
+ assert_eq!(
+ capability.allows(&"https://github.com/some-owner/some-repo".parse().unwrap()),
+ false
+ );
+ assert_eq!(
+ capability.allows(
+ &"https://github.com/specific-owner/some-repo"
+ .parse()
+ .unwrap()
+ ),
+ true
+ );
+
+ let capability = DownloadFileCapability {
+ host: "github.com".to_string(),
+ path: vec!["specific-owner".to_string(), "*".to_string()],
+ };
+ assert_eq!(
+ capability.allows(
+ &"https://github.com/some-owner/some-repo/extra"
+ .parse()
+ .unwrap()
+ ),
+ false
+ );
+ assert_eq!(
+ capability.allows(
+ &"https://github.com/specific-owner/some-repo/extra"
+ .parse()
+ .unwrap()
+ ),
+ false
+ );
+ }
+}
@@ -0,0 +1,39 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct NpmInstallPackageCapability {
+ pub package: String,
+}
+
+impl NpmInstallPackageCapability {
+ /// Returns whether the capability allows installing the given NPM package.
+ pub fn allows(&self, package: &str) -> bool {
+ self.package == "*" || self.package == package
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use pretty_assertions::assert_eq;
+
+ use super::*;
+
+ #[test]
+ fn test_allows() {
+ let capability = NpmInstallPackageCapability {
+ package: "*".to_string(),
+ };
+ assert_eq!(capability.allows("package"), true);
+
+ let capability = NpmInstallPackageCapability {
+ package: "react".to_string(),
+ };
+ assert_eq!(capability.allows("react"), true);
+
+ let capability = NpmInstallPackageCapability {
+ package: "react".to_string(),
+ };
+ assert_eq!(capability.allows("malicious-package"), false);
+ }
+}
@@ -0,0 +1,116 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct ProcessExecCapability {
+ /// The command to execute.
+ pub command: String,
+ /// The arguments to pass to the command. Use `*` for a single wildcard argument.
+ /// If the last element is `**`, then any trailing arguments are allowed.
+ pub args: Vec<String>,
+}
+
+impl ProcessExecCapability {
+ /// Returns whether the capability allows the given command and arguments.
+ pub fn allows(
+ &self,
+ desired_command: &str,
+ desired_args: &[impl AsRef<str> + std::fmt::Debug],
+ ) -> bool {
+ if self.command != desired_command && self.command != "*" {
+ return false;
+ }
+
+ for (ix, arg) in self.args.iter().enumerate() {
+ if arg == "**" {
+ return true;
+ }
+
+ if ix >= desired_args.len() {
+ return false;
+ }
+
+ if arg != "*" && arg != desired_args[ix].as_ref() {
+ return false;
+ }
+ }
+
+ if self.args.len() < desired_args.len() {
+ return false;
+ }
+
+ true
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use pretty_assertions::assert_eq;
+
+ use super::*;
+
+ #[test]
+ fn test_allows_with_exact_match() {
+ let capability = ProcessExecCapability {
+ command: "ls".to_string(),
+ args: vec!["-la".to_string()],
+ };
+
+ assert_eq!(capability.allows("ls", &["-la"]), true);
+ assert_eq!(capability.allows("ls", &["-l"]), false);
+ assert_eq!(capability.allows("pwd", &[] as &[&str]), false);
+ }
+
+ #[test]
+ fn test_allows_with_wildcard_arg() {
+ let capability = ProcessExecCapability {
+ command: "git".to_string(),
+ args: vec!["*".to_string()],
+ };
+
+ assert_eq!(capability.allows("git", &["status"]), true);
+ assert_eq!(capability.allows("git", &["commit"]), true);
+ // Too many args.
+ assert_eq!(capability.allows("git", &["status", "-s"]), false);
+ // Wrong command.
+ assert_eq!(capability.allows("npm", &["install"]), false);
+ }
+
+ #[test]
+ fn test_allows_with_double_wildcard() {
+ let capability = ProcessExecCapability {
+ command: "cargo".to_string(),
+ args: vec!["test".to_string(), "**".to_string()],
+ };
+
+ assert_eq!(capability.allows("cargo", &["test"]), true);
+ assert_eq!(capability.allows("cargo", &["test", "--all"]), true);
+ assert_eq!(
+ capability.allows("cargo", &["test", "--all", "--no-fail-fast"]),
+ true
+ );
+ // Wrong first arg.
+ assert_eq!(capability.allows("cargo", &["build"]), false);
+ }
+
+ #[test]
+ fn test_allows_with_mixed_wildcards() {
+ let capability = ProcessExecCapability {
+ command: "docker".to_string(),
+ args: vec!["run".to_string(), "*".to_string(), "**".to_string()],
+ };
+
+ assert_eq!(capability.allows("docker", &["run", "nginx"]), true);
+ assert_eq!(capability.allows("docker", &["run"]), false);
+ assert_eq!(
+ capability.allows("docker", &["run", "ubuntu", "bash"]),
+ true
+ );
+ assert_eq!(
+ capability.allows("docker", &["run", "alpine", "sh", "-c", "echo hello"]),
+ true
+ );
+ // Wrong first arg.
+ assert_eq!(capability.allows("docker", &["ps"]), false);
+ }
+}
@@ -1,3 +1,4 @@
+mod capabilities;
pub mod extension_builder;
mod extension_events;
mod extension_host_proxy;
@@ -16,6 +17,7 @@ use language::LanguageName;
use semantic_version::SemanticVersion;
use task::{SpawnInTerminal, ZedDebugConfig};
+pub use crate::capabilities::*;
pub use crate::extension_events::*;
pub use crate::extension_host_proxy::*;
pub use crate::extension_manifest::*;
@@ -176,16 +178,15 @@ pub fn parse_wasm_extension_version(
for part in wasmparser::Parser::new(0).parse_all(wasm_bytes) {
if let wasmparser::Payload::CustomSection(s) =
part.context("error parsing wasm extension")?
+ && s.name() == "zed:api-version"
{
- if s.name() == "zed:api-version" {
- version = parse_wasm_extension_version_custom_section(s.data());
- if version.is_none() {
- bail!(
- "extension {} has invalid zed:api-version section: {:?}",
- extension_id,
- s.data()
- );
- }
+ version = parse_wasm_extension_version_custom_section(s.data());
+ if version.is_none() {
+ bail!(
+ "extension {} has invalid zed:api-version section: {:?}",
+ extension_id,
+ s.data()
+ );
}
}
}
@@ -401,7 +401,7 @@ impl ExtensionBuilder {
let mut clang_path = wasi_sdk_dir.clone();
clang_path.extend(["bin", &format!("clang{}", env::consts::EXE_SUFFIX)]);
- if fs::metadata(&clang_path).map_or(false, |metadata| metadata.is_file()) {
+ if fs::metadata(&clang_path).is_ok_and(|metadata| metadata.is_file()) {
return Ok(clang_path);
}
@@ -452,7 +452,7 @@ impl ExtensionBuilder {
let mut output = Vec::new();
let mut stack = Vec::new();
- for payload in Parser::new(0).parse_all(&input) {
+ for payload in Parser::new(0).parse_all(input) {
let payload = payload?;
// Track nesting depth, so that we don't mess with inner producer sections:
@@ -484,14 +484,10 @@ impl ExtensionBuilder {
_ => {}
}
- match &payload {
- CustomSection(c) => {
- if strip_custom_section(c.name()) {
- continue;
- }
- }
-
- _ => {}
+ if let CustomSection(c) = &payload
+ && strip_custom_section(c.name())
+ {
+ continue;
}
if let Some((id, range)) = payload.as_section() {
RawSection {
@@ -19,9 +19,8 @@ pub struct ExtensionEvents;
impl ExtensionEvents {
/// Returns the global [`ExtensionEvents`].
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
- return cx
- .try_global::<GlobalExtensionEvents>()
- .map(|g| g.0.clone());
+ cx.try_global::<GlobalExtensionEvents>()
+ .map(|g| g.0.clone())
}
fn new(_cx: &mut Context<Self>) -> Self {
@@ -28,7 +28,6 @@ pub struct ExtensionHostProxy {
snippet_proxy: RwLock<Option<Arc<dyn ExtensionSnippetProxy>>>,
slash_command_proxy: RwLock<Option<Arc<dyn ExtensionSlashCommandProxy>>>,
context_server_proxy: RwLock<Option<Arc<dyn ExtensionContextServerProxy>>>,
- indexed_docs_provider_proxy: RwLock<Option<Arc<dyn ExtensionIndexedDocsProviderProxy>>>,
debug_adapter_provider_proxy: RwLock<Option<Arc<dyn ExtensionDebugAdapterProviderProxy>>>,
}
@@ -54,7 +53,6 @@ impl ExtensionHostProxy {
snippet_proxy: RwLock::default(),
slash_command_proxy: RwLock::default(),
context_server_proxy: RwLock::default(),
- indexed_docs_provider_proxy: RwLock::default(),
debug_adapter_provider_proxy: RwLock::default(),
}
}
@@ -87,14 +85,6 @@ impl ExtensionHostProxy {
self.context_server_proxy.write().replace(Arc::new(proxy));
}
- pub fn register_indexed_docs_provider_proxy(
- &self,
- proxy: impl ExtensionIndexedDocsProviderProxy,
- ) {
- self.indexed_docs_provider_proxy
- .write()
- .replace(Arc::new(proxy));
- }
pub fn register_debug_adapter_proxy(&self, proxy: impl ExtensionDebugAdapterProviderProxy) {
self.debug_adapter_provider_proxy
.write()
@@ -408,30 +398,6 @@ impl ExtensionContextServerProxy for ExtensionHostProxy {
}
}
-pub trait ExtensionIndexedDocsProviderProxy: Send + Sync + 'static {
- fn register_indexed_docs_provider(&self, extension: Arc<dyn Extension>, provider_id: Arc<str>);
-
- fn unregister_indexed_docs_provider(&self, provider_id: Arc<str>);
-}
-
-impl ExtensionIndexedDocsProviderProxy for ExtensionHostProxy {
- fn register_indexed_docs_provider(&self, extension: Arc<dyn Extension>, provider_id: Arc<str>) {
- let Some(proxy) = self.indexed_docs_provider_proxy.read().clone() else {
- return;
- };
-
- proxy.register_indexed_docs_provider(extension, provider_id)
- }
-
- fn unregister_indexed_docs_provider(&self, provider_id: Arc<str>) {
- let Some(proxy) = self.indexed_docs_provider_proxy.read().clone() else {
- return;
- };
-
- proxy.unregister_indexed_docs_provider(provider_id)
- }
-}
-
pub trait ExtensionDebugAdapterProviderProxy: Send + Sync + 'static {
fn register_debug_adapter(
&self,
@@ -12,6 +12,8 @@ use std::{
sync::Arc,
};
+use crate::ExtensionCapability;
+
/// This is the old version of the extension manifest, from when it was `extension.json`.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct OldExtensionManifest {
@@ -82,8 +84,6 @@ pub struct ExtensionManifest {
#[serde(default)]
pub slash_commands: BTreeMap<Arc<str>, SlashCommandManifestEntry>,
#[serde(default)]
- pub indexed_docs_providers: BTreeMap<Arc<str>, IndexedDocsProviderEntry>,
- #[serde(default)]
pub snippets: Option<PathBuf>,
#[serde(default)]
pub capabilities: Vec<ExtensionCapability>,
@@ -100,24 +100,8 @@ impl ExtensionManifest {
desired_args: &[impl AsRef<str> + std::fmt::Debug],
) -> Result<()> {
let is_allowed = self.capabilities.iter().any(|capability| match capability {
- ExtensionCapability::ProcessExec { command, args } if command == desired_command => {
- for (ix, arg) in args.iter().enumerate() {
- if arg == "**" {
- return true;
- }
-
- if ix >= desired_args.len() {
- return false;
- }
-
- if arg != "*" && arg != desired_args[ix].as_ref() {
- return false;
- }
- }
- if args.len() < desired_args.len() {
- return false;
- }
- true
+ ExtensionCapability::ProcessExec(capability) => {
+ capability.allows(desired_command, desired_args)
}
_ => false,
});
@@ -148,20 +132,6 @@ pub fn build_debug_adapter_schema_path(
})
}
-/// A capability for an extension.
-#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
-#[serde(tag = "kind")]
-pub enum ExtensionCapability {
- #[serde(rename = "process:exec")]
- ProcessExec {
- /// The command to execute.
- command: String,
- /// The arguments to pass to the command. Use `*` for a single wildcard argument.
- /// If the last element is `**`, then any trailing arguments are allowed.
- args: Vec<String>,
- },
-}
-
#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct LibManifestEntry {
pub kind: Option<ExtensionLibraryKind>,
@@ -191,7 +161,7 @@ pub struct LanguageServerManifestEntry {
#[serde(default)]
languages: Vec<LanguageName>,
#[serde(default)]
- pub language_ids: HashMap<String, String>,
+ pub language_ids: HashMap<LanguageName, String>,
#[serde(default)]
pub code_action_kinds: Option<Vec<lsp::CodeActionKind>>,
}
@@ -223,9 +193,6 @@ pub struct SlashCommandManifestEntry {
pub requires_argument: bool,
}
-#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
-pub struct IndexedDocsProviderEntry {}
-
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct DebugAdapterManifestEntry {
pub schema_path: Option<PathBuf>,
@@ -299,7 +266,6 @@ fn manifest_from_old_manifest(
language_servers: Default::default(),
context_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(),
- indexed_docs_providers: BTreeMap::default(),
snippets: None,
capabilities: Vec::new(),
debug_adapters: Default::default(),
@@ -309,6 +275,10 @@ fn manifest_from_old_manifest(
#[cfg(test)]
mod tests {
+ use pretty_assertions::assert_eq;
+
+ use crate::ProcessExecCapability;
+
use super::*;
fn extension_manifest() -> ExtensionManifest {
@@ -328,7 +298,6 @@ mod tests {
language_servers: BTreeMap::default(),
context_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(),
- indexed_docs_providers: BTreeMap::default(),
snippets: None,
capabilities: vec![],
debug_adapters: Default::default(),
@@ -360,12 +329,12 @@ mod tests {
}
#[test]
- fn test_allow_exact_match() {
+ fn test_allow_exec_exact_match() {
let manifest = ExtensionManifest {
- capabilities: vec![ExtensionCapability::ProcessExec {
+ capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "ls".to_string(),
args: vec!["-la".to_string()],
- }],
+ })],
..extension_manifest()
};
@@ -375,12 +344,12 @@ mod tests {
}
#[test]
- fn test_allow_wildcard_arg() {
+ fn test_allow_exec_wildcard_arg() {
let manifest = ExtensionManifest {
- capabilities: vec![ExtensionCapability::ProcessExec {
+ capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "git".to_string(),
args: vec!["*".to_string()],
- }],
+ })],
..extension_manifest()
};
@@ -391,12 +360,12 @@ mod tests {
}
#[test]
- fn test_allow_double_wildcard() {
+ fn test_allow_exec_double_wildcard() {
let manifest = ExtensionManifest {
- capabilities: vec![ExtensionCapability::ProcessExec {
+ capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "cargo".to_string(),
args: vec!["test".to_string(), "**".to_string()],
- }],
+ })],
..extension_manifest()
};
@@ -411,12 +380,12 @@ mod tests {
}
#[test]
- fn test_allow_mixed_wildcards() {
+ fn test_allow_exec_mixed_wildcards() {
let manifest = ExtensionManifest {
- capabilities: vec![ExtensionCapability::ProcessExec {
+ capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "docker".to_string(),
args: vec!["run".to_string(), "*".to_string(), "**".to_string()],
- }],
+ })],
..extension_manifest()
};
@@ -3,7 +3,7 @@ mod dap;
mod lsp;
mod slash_command;
-use std::ops::Range;
+use std::{ops::Range, path::PathBuf};
use util::redact::should_redact;
@@ -18,7 +18,7 @@ pub type EnvVars = Vec<(String, String)>;
/// A command.
pub struct Command {
/// The command to execute.
- pub command: String,
+ pub command: PathBuf,
/// The arguments to pass to the command.
pub args: Vec<String>,
/// The environment variables to set for the command.
@@ -232,10 +232,10 @@ pub trait Extension: Send + Sync {
///
/// To work through a real-world example, take a `cargo run` task and a hypothetical `cargo` locator:
/// 1. We may need to modify the task; in this case, it is problematic that `cargo run` spawns a binary. We should turn `cargo run` into a debug scenario with
- /// `cargo build` task. This is the decision we make at `dap_locator_create_scenario` scope.
+ /// `cargo build` task. This is the decision we make at `dap_locator_create_scenario` scope.
/// 2. Then, after the build task finishes, we will run `run_dap_locator` of the locator that produced the build task to find the program to be debugged. This function
- /// should give us a debugger-agnostic configuration for launching a debug target (that we end up resolving with [`Extension::dap_config_to_scenario`]). It's almost as if the user
- /// found the artifact path by themselves.
+ /// should give us a debugger-agnostic configuration for launching a debug target (that we end up resolving with [`Extension::dap_config_to_scenario`]). It's almost as if the user
+ /// found the artifact path by themselves.
///
/// Note that you're not obliged to use build tasks with locators. Specifically, it is sufficient to provide a debug configuration directly in the return value of
/// `dap_locator_create_scenario` if you're able to do that. Make sure to not fill out `build` field in that case, as that will prevent Zed from running second phase of resolution in such case.
@@ -144,10 +144,6 @@ fn extension_provides(manifest: &ExtensionManifest) -> BTreeSet<ExtensionProvide
provides.insert(ExtensionProvides::ContextServers);
}
- if !manifest.indexed_docs_providers.is_empty() {
- provides.insert(ExtensionProvides::IndexedDocsProviders);
- }
-
if manifest.snippets.is_some() {
provides.insert(ExtensionProvides::Snippets);
}
@@ -132,12 +132,13 @@ fn manifest() -> ExtensionManifest {
.collect(),
context_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(),
- indexed_docs_providers: BTreeMap::default(),
snippets: None,
- capabilities: vec![ExtensionCapability::ProcessExec {
- command: "echo".into(),
- args: vec!["hello!".into()],
- }],
+ capabilities: vec![ExtensionCapability::ProcessExec(
+ extension::ProcessExecCapability {
+ command: "echo".into(),
+ args: vec!["hello!".into()],
+ },
+ )],
debug_adapters: Default::default(),
debug_locators: Default::default(),
}
@@ -0,0 +1,152 @@
+use std::sync::Arc;
+
+use anyhow::{Result, bail};
+use extension::{ExtensionCapability, ExtensionManifest};
+use url::Url;
+
+pub struct CapabilityGranter {
+ granted_capabilities: Vec<ExtensionCapability>,
+ manifest: Arc<ExtensionManifest>,
+}
+
+impl CapabilityGranter {
+ pub fn new(
+ granted_capabilities: Vec<ExtensionCapability>,
+ manifest: Arc<ExtensionManifest>,
+ ) -> Self {
+ Self {
+ granted_capabilities,
+ manifest,
+ }
+ }
+
+ pub fn grant_exec(
+ &self,
+ desired_command: &str,
+ desired_args: &[impl AsRef<str> + std::fmt::Debug],
+ ) -> Result<()> {
+ self.manifest.allow_exec(desired_command, desired_args)?;
+
+ let is_allowed = self
+ .granted_capabilities
+ .iter()
+ .any(|capability| match capability {
+ ExtensionCapability::ProcessExec(capability) => {
+ capability.allows(desired_command, desired_args)
+ }
+ _ => false,
+ });
+
+ if !is_allowed {
+ bail!(
+ "capability for process:exec {desired_command} {desired_args:?} is not granted by the extension host",
+ );
+ }
+
+ Ok(())
+ }
+
+ pub fn grant_download_file(&self, desired_url: &Url) -> Result<()> {
+ let is_allowed = self
+ .granted_capabilities
+ .iter()
+ .any(|capability| match capability {
+ ExtensionCapability::DownloadFile(capability) => capability.allows(desired_url),
+ _ => false,
+ });
+
+ if !is_allowed {
+ bail!(
+ "capability for download_file {desired_url} is not granted by the extension host",
+ );
+ }
+
+ Ok(())
+ }
+
+ pub fn grant_npm_install_package(&self, package_name: &str) -> Result<()> {
+ let is_allowed = self
+ .granted_capabilities
+ .iter()
+ .any(|capability| match capability {
+ ExtensionCapability::NpmInstallPackage(capability) => {
+ capability.allows(package_name)
+ }
+ _ => false,
+ });
+
+ if !is_allowed {
+ bail!("capability for npm:install {package_name} is not granted by the extension host",);
+ }
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::BTreeMap;
+
+ use extension::{ProcessExecCapability, SchemaVersion};
+
+ use super::*;
+
+ fn extension_manifest() -> ExtensionManifest {
+ ExtensionManifest {
+ id: "test".into(),
+ name: "Test".to_string(),
+ version: "1.0.0".into(),
+ schema_version: SchemaVersion::ZERO,
+ description: None,
+ repository: None,
+ authors: vec![],
+ lib: Default::default(),
+ themes: vec![],
+ icon_themes: vec![],
+ languages: vec![],
+ grammars: BTreeMap::default(),
+ language_servers: BTreeMap::default(),
+ context_servers: BTreeMap::default(),
+ slash_commands: BTreeMap::default(),
+ snippets: None,
+ capabilities: vec![],
+ debug_adapters: Default::default(),
+ debug_locators: Default::default(),
+ }
+ }
+
+ #[test]
+ fn test_grant_exec() {
+ let manifest = Arc::new(ExtensionManifest {
+ capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
+ command: "ls".to_string(),
+ args: vec!["-la".to_string()],
+ })],
+ ..extension_manifest()
+ });
+
+ // It returns an error when the extension host has no granted capabilities.
+ let granter = CapabilityGranter::new(Vec::new(), manifest.clone());
+ assert!(granter.grant_exec("ls", &["-la"]).is_err());
+
+ // It succeeds when the extension host has the exact capability.
+ let granter = CapabilityGranter::new(
+ vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
+ command: "ls".to_string(),
+ args: vec!["-la".to_string()],
+ })],
+ manifest.clone(),
+ );
+ assert!(granter.grant_exec("ls", &["-la"]).is_ok());
+
+ // It succeeds when the extension host has a wildcard capability.
+ let granter = CapabilityGranter::new(
+ vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
+ command: "*".to_string(),
+ args: vec!["**".to_string()],
+ })],
+ manifest,
+ );
+ assert!(granter.grant_exec("ls", &["-la"]).is_ok());
+ }
+}
@@ -1,3 +1,4 @@
+mod capability_granter;
pub mod extension_settings;
pub mod headless_host;
pub mod wasm_host;
@@ -15,9 +16,9 @@ pub use extension::ExtensionManifest;
use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder};
use extension::{
ExtensionContextServerProxy, ExtensionDebugAdapterProviderProxy, ExtensionEvents,
- ExtensionGrammarProxy, ExtensionHostProxy, ExtensionIndexedDocsProviderProxy,
- ExtensionLanguageProxy, ExtensionLanguageServerProxy, ExtensionSlashCommandProxy,
- ExtensionSnippetProxy, ExtensionThemeProxy,
+ ExtensionGrammarProxy, ExtensionHostProxy, ExtensionLanguageProxy,
+ ExtensionLanguageServerProxy, ExtensionSlashCommandProxy, ExtensionSnippetProxy,
+ ExtensionThemeProxy,
};
use fs::{Fs, RemoveOptions};
use futures::future::join_all;
@@ -92,10 +93,9 @@ pub fn is_version_compatible(
.wasm_api_version
.as_ref()
.and_then(|wasm_api_version| SemanticVersion::from_str(wasm_api_version).ok())
+ && !is_supported_wasm_api_version(release_channel, wasm_api_version)
{
- if !is_supported_wasm_api_version(release_channel, wasm_api_version) {
- return false;
- }
+ return false;
}
true
@@ -291,19 +291,17 @@ impl ExtensionStore {
// it must be asynchronously rebuilt.
let mut extension_index = ExtensionIndex::default();
let mut extension_index_needs_rebuild = true;
- if let Ok(index_content) = index_content {
- if let Some(index) = serde_json::from_str(&index_content).log_err() {
- extension_index = index;
- if let (Ok(Some(index_metadata)), Ok(Some(extensions_metadata))) =
- (index_metadata, extensions_metadata)
- {
- if index_metadata
- .mtime
- .bad_is_greater_than(extensions_metadata.mtime)
- {
- extension_index_needs_rebuild = false;
- }
- }
+ if let Ok(index_content) = index_content
+ && let Some(index) = serde_json::from_str(&index_content).log_err()
+ {
+ extension_index = index;
+ if let (Ok(Some(index_metadata)), Ok(Some(extensions_metadata))) =
+ (index_metadata, extensions_metadata)
+ && index_metadata
+ .mtime
+ .bad_is_greater_than(extensions_metadata.mtime)
+ {
+ extension_index_needs_rebuild = false;
}
}
@@ -391,10 +389,9 @@ impl ExtensionStore {
if let Some(path::Component::Normal(extension_dir_name)) =
event_path.components().next()
+ && let Some(extension_id) = extension_dir_name.to_str()
{
- if let Some(extension_id) = extension_dir_name.to_str() {
- reload_tx.unbounded_send(Some(extension_id.into())).ok();
- }
+ reload_tx.unbounded_send(Some(extension_id.into())).ok();
}
}
}
@@ -565,12 +562,12 @@ impl ExtensionStore {
extensions
.into_iter()
.filter(|extension| {
- this.extension_index.extensions.get(&extension.id).map_or(
- true,
- |installed_extension| {
+ this.extension_index
+ .extensions
+ .get(&extension.id)
+ .is_none_or(|installed_extension| {
installed_extension.manifest.version != extension.manifest.version
- },
- )
+ })
})
.collect()
})
@@ -762,8 +759,8 @@ impl ExtensionStore {
if let ExtensionOperation::Install = operation {
this.update( cx, |this, cx| {
cx.emit(Event::ExtensionInstalled(extension_id.clone()));
- if let Some(events) = ExtensionEvents::try_global(cx) {
- if let Some(manifest) = this.extension_manifest_for_id(&extension_id) {
+ if let Some(events) = ExtensionEvents::try_global(cx)
+ && let Some(manifest) = this.extension_manifest_for_id(&extension_id) {
events.update(cx, |this, cx| {
this.emit(
extension::Event::ExtensionInstalled(manifest.clone()),
@@ -771,7 +768,6 @@ impl ExtensionStore {
)
});
}
- }
})
.ok();
}
@@ -911,12 +907,12 @@ impl ExtensionStore {
extension_store.update(cx, |_, cx| {
cx.emit(Event::ExtensionUninstalled(extension_id.clone()));
- if let Some(events) = ExtensionEvents::try_global(cx) {
- if let Some(manifest) = extension_manifest {
- events.update(cx, |this, cx| {
- this.emit(extension::Event::ExtensionUninstalled(manifest.clone()), cx)
- });
- }
+ if let Some(events) = ExtensionEvents::try_global(cx)
+ && let Some(manifest) = extension_manifest
+ {
+ events.update(cx, |this, cx| {
+ this.emit(extension::Event::ExtensionUninstalled(manifest.clone()), cx)
+ });
}
})?;
@@ -996,12 +992,12 @@ impl ExtensionStore {
this.update(cx, |this, cx| this.reload(None, cx))?.await;
this.update(cx, |this, cx| {
cx.emit(Event::ExtensionInstalled(extension_id.clone()));
- if let Some(events) = ExtensionEvents::try_global(cx) {
- if let Some(manifest) = this.extension_manifest_for_id(&extension_id) {
- events.update(cx, |this, cx| {
- this.emit(extension::Event::ExtensionInstalled(manifest.clone()), cx)
- });
- }
+ if let Some(events) = ExtensionEvents::try_global(cx)
+ && let Some(manifest) = this.extension_manifest_for_id(&extension_id)
+ {
+ events.update(cx, |this, cx| {
+ this.emit(extension::Event::ExtensionInstalled(manifest.clone()), cx)
+ });
}
})?;
@@ -1117,15 +1113,17 @@ impl ExtensionStore {
extensions_to_unload.len() - reload_count
);
- for extension_id in &extensions_to_load {
- if let Some(extension) = new_index.extensions.get(extension_id) {
- telemetry::event!(
- "Extension Loaded",
- extension_id,
- version = extension.manifest.version
- );
- }
- }
+ let extension_ids = extensions_to_load
+ .iter()
+ .filter_map(|id| {
+ Some((
+ id.clone(),
+ new_index.extensions.get(id)?.manifest.version.clone(),
+ ))
+ })
+ .collect::<Vec<_>>();
+
+ telemetry::event!("Extensions Loaded", id_and_versions = extension_ids);
let themes_to_remove = old_index
.themes
@@ -1177,22 +1175,18 @@ impl ExtensionStore {
}
}
- for (server_id, _) in &extension.manifest.context_servers {
+ for server_id in extension.manifest.context_servers.keys() {
self.proxy.unregister_context_server(server_id.clone(), cx);
}
- for (adapter, _) in &extension.manifest.debug_adapters {
+ for adapter in extension.manifest.debug_adapters.keys() {
self.proxy.unregister_debug_adapter(adapter.clone());
}
- for (locator, _) in &extension.manifest.debug_locators {
+ for locator in extension.manifest.debug_locators.keys() {
self.proxy.unregister_debug_locator(locator.clone());
}
- for (command_name, _) in &extension.manifest.slash_commands {
+ for command_name in extension.manifest.slash_commands.keys() {
self.proxy.unregister_slash_command(command_name.clone());
}
- for (provider_id, _) in &extension.manifest.indexed_docs_providers {
- self.proxy
- .unregister_indexed_docs_provider(provider_id.clone());
- }
}
self.wasm_extensions
@@ -1276,6 +1270,7 @@ impl ExtensionStore {
queries,
context_provider,
toolchain_provider: None,
+ manifest_name: None,
})
}),
);
@@ -1313,10 +1308,17 @@ impl ExtensionStore {
}
for snippets_path in &snippets_to_add {
- if let Some(snippets_contents) = fs.load(snippets_path).await.log_err() {
- proxy
- .register_snippet(snippets_path, &snippets_contents)
- .log_err();
+ match fs
+ .load(snippets_path)
+ .await
+ .with_context(|| format!("Loading snippets from {snippets_path:?}"))
+ {
+ Ok(snippets_contents) => {
+ proxy
+ .register_snippet(snippets_path, &snippets_contents)
+ .log_err();
+ }
+ Err(e) => log::error!("Cannot load snippets: {e:#}"),
}
}
}
@@ -1331,20 +1333,25 @@ impl ExtensionStore {
let extension_path = root_dir.join(extension.manifest.id.as_ref());
let wasm_extension = WasmExtension::load(
- extension_path,
+ &extension_path,
&extension.manifest,
wasm_host.clone(),
- &cx,
+ cx,
)
- .await;
+ .await
+ .with_context(|| format!("Loading extension from {extension_path:?}"));
- if let Some(wasm_extension) = wasm_extension.log_err() {
- wasm_extensions.push((extension.manifest.clone(), wasm_extension));
- } else {
- this.update(cx, |_, cx| {
- cx.emit(Event::ExtensionFailedToLoad(extension.manifest.id.clone()))
- })
- .ok();
+ match wasm_extension {
+ Ok(wasm_extension) => {
+ wasm_extensions.push((extension.manifest.clone(), wasm_extension))
+ }
+ Err(e) => {
+ log::error!("Failed to load extension: {e:#}");
+ this.update(cx, |_, cx| {
+ cx.emit(Event::ExtensionFailedToLoad(extension.manifest.id.clone()))
+ })
+ .ok();
+ }
}
}
@@ -1379,16 +1386,11 @@ impl ExtensionStore {
);
}
- for (id, _context_server_entry) in &manifest.context_servers {
+ for id in manifest.context_servers.keys() {
this.proxy
.register_context_server(extension.clone(), id.clone(), cx);
}
- for (provider_id, _provider) in &manifest.indexed_docs_providers {
- this.proxy
- .register_indexed_docs_provider(extension.clone(), provider_id.clone());
- }
-
for (debug_adapter, meta) in &manifest.debug_adapters {
let mut path = root_dir.clone();
path.push(Path::new(manifest.id.as_ref()));
@@ -1449,7 +1451,7 @@ impl ExtensionStore {
if extension_dir
.file_name()
- .map_or(false, |file_name| file_name == ".DS_Store")
+ .is_some_and(|file_name| file_name == ".DS_Store")
{
continue;
}
@@ -1673,9 +1675,8 @@ impl ExtensionStore {
let schema_path = &extension::build_debug_adapter_schema_path(adapter_name, meta);
if fs.is_file(&src_dir.join(schema_path)).await {
- match schema_path.parent() {
- Some(parent) => fs.create_dir(&tmp_dir.join(parent)).await?,
- None => {}
+ if let Some(parent) = schema_path.parent() {
+ fs.create_dir(&tmp_dir.join(parent)).await?
}
fs.copy_file(
&src_dir.join(schema_path),
@@ -1769,7 +1770,7 @@ impl ExtensionStore {
})?;
for client in clients {
- Self::sync_extensions_over_ssh(&this, client, cx)
+ Self::sync_extensions_over_ssh(this, client, cx)
.await
.log_err();
}
@@ -1781,10 +1782,10 @@ impl ExtensionStore {
let connection_options = client.read(cx).connection_options();
let ssh_url = connection_options.ssh_url();
- if let Some(existing_client) = self.ssh_clients.get(&ssh_url) {
- if existing_client.upgrade().is_some() {
- return;
- }
+ if let Some(existing_client) = self.ssh_clients.get(&ssh_url)
+ && existing_client.upgrade().is_some()
+ {
+ return;
}
self.ssh_clients.insert(ssh_url, client.downgrade());
@@ -10,7 +10,7 @@ use fs::{FakeFs, Fs, RealFs};
use futures::{AsyncReadExt, StreamExt, io::BufReader};
use gpui::{AppContext as _, SemanticVersion, TestAppContext};
use http_client::{FakeHttpClient, Response};
-use language::{BinaryStatus, LanguageMatcher, LanguageRegistry};
+use language::{BinaryStatus, LanguageMatcher, LanguageName, LanguageRegistry};
use language_extension::LspAccess;
use lsp::LanguageServerName;
use node_runtime::NodeRuntime;
@@ -160,7 +160,6 @@ async fn test_extension_store(cx: &mut TestAppContext) {
language_servers: BTreeMap::default(),
context_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(),
- indexed_docs_providers: BTreeMap::default(),
snippets: None,
capabilities: Vec::new(),
debug_adapters: Default::default(),
@@ -191,7 +190,6 @@ async fn test_extension_store(cx: &mut TestAppContext) {
language_servers: BTreeMap::default(),
context_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(),
- indexed_docs_providers: BTreeMap::default(),
snippets: None,
capabilities: Vec::new(),
debug_adapters: Default::default(),
@@ -306,7 +304,11 @@ async fn test_extension_store(cx: &mut TestAppContext) {
assert_eq!(
language_registry.language_names(),
- ["ERB", "Plain Text", "Ruby"]
+ [
+ LanguageName::new("ERB"),
+ LanguageName::new("Plain Text"),
+ LanguageName::new("Ruby"),
+ ]
);
assert_eq!(
theme_registry.list_names(),
@@ -367,7 +369,6 @@ async fn test_extension_store(cx: &mut TestAppContext) {
language_servers: BTreeMap::default(),
context_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(),
- indexed_docs_providers: BTreeMap::default(),
snippets: None,
capabilities: Vec::new(),
debug_adapters: Default::default(),
@@ -458,7 +459,11 @@ async fn test_extension_store(cx: &mut TestAppContext) {
assert_eq!(
language_registry.language_names(),
- ["ERB", "Plain Text", "Ruby"]
+ [
+ LanguageName::new("ERB"),
+ LanguageName::new("Plain Text"),
+ LanguageName::new("Ruby"),
+ ]
);
assert_eq!(
language_registry.grammar_names(),
@@ -513,7 +518,10 @@ async fn test_extension_store(cx: &mut TestAppContext) {
assert_eq!(actual_language.hidden, expected_language.hidden);
}
- assert_eq!(language_registry.language_names(), ["Plain Text"]);
+ assert_eq!(
+ language_registry.language_names(),
+ [LanguageName::new("Plain Text")]
+ );
assert_eq!(language_registry.grammar_names(), []);
});
}
@@ -163,6 +163,7 @@ impl HeadlessExtensionStore {
queries: LanguageQueries::default(),
context_provider: None,
toolchain_provider: None,
+ manifest_name: None,
})
}),
);
@@ -173,9 +174,8 @@ impl HeadlessExtensionStore {
return Ok(());
}
- let wasm_extension: Arc<dyn Extension> = Arc::new(
- WasmExtension::load(extension_dir.clone(), &manifest, wasm_host.clone(), &cx).await?,
- );
+ let wasm_extension: Arc<dyn Extension> =
+ Arc::new(WasmExtension::load(&extension_dir, &manifest, wasm_host.clone(), cx).await?);
for (language_server_id, language_server_config) in &manifest.language_servers {
for language in language_server_config.languages() {
@@ -1,13 +1,15 @@
pub mod wit;
use crate::ExtensionManifest;
+use crate::capability_granter::CapabilityGranter;
use anyhow::{Context as _, Result, anyhow, bail};
use async_trait::async_trait;
use dap::{DebugRequest, StartDebuggingRequestArgumentsRequest};
use extension::{
CodeLabel, Command, Completion, ContextServerConfiguration, DebugAdapterBinary,
- DebugTaskDefinition, ExtensionHostProxy, KeyValueStoreDelegate, ProjectDelegate, SlashCommand,
- SlashCommandArgumentCompletion, SlashCommandOutput, Symbol, WorktreeDelegate,
+ DebugTaskDefinition, DownloadFileCapability, ExtensionCapability, ExtensionHostProxy,
+ KeyValueStoreDelegate, NpmInstallPackageCapability, ProcessExecCapability, ProjectDelegate,
+ SlashCommand, SlashCommandArgumentCompletion, SlashCommandOutput, Symbol, WorktreeDelegate,
};
use fs::{Fs, normalize_path};
use futures::future::LocalBoxFuture;
@@ -50,6 +52,8 @@ pub struct WasmHost {
pub(crate) proxy: Arc<ExtensionHostProxy>,
fs: Arc<dyn Fs>,
pub work_dir: PathBuf,
+ /// The capabilities granted to extensions running on the host.
+ pub(crate) granted_capabilities: Vec<ExtensionCapability>,
_main_thread_message_task: Task<()>,
main_thread_message_tx: mpsc::UnboundedSender<MainThreadCall>,
}
@@ -102,7 +106,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn language_server_initialization_options(
@@ -127,7 +131,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn language_server_workspace_configuration(
@@ -150,7 +154,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn language_server_additional_initialization_options(
@@ -175,7 +179,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn language_server_additional_workspace_configuration(
@@ -200,7 +204,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn labels_for_completions(
@@ -226,7 +230,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn labels_for_symbols(
@@ -252,7 +256,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn complete_slash_command_argument(
@@ -271,7 +275,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn run_slash_command(
@@ -297,7 +301,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn context_server_command(
@@ -316,7 +320,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn context_server_configuration(
@@ -343,7 +347,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn suggest_docs_packages(&self, provider: Arc<str>) -> Result<Vec<String>> {
@@ -358,7 +362,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn index_docs(
@@ -384,7 +388,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn get_dap_binary(
@@ -406,7 +410,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn dap_request_kind(
&self,
@@ -423,7 +427,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn dap_config_to_scenario(&self, config: ZedDebugConfig) -> Result<DebugScenario> {
@@ -437,7 +441,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn dap_locator_create_scenario(
@@ -461,7 +465,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
async fn run_dap_locator(
&self,
@@ -477,7 +481,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
- .await
+ .await?
}
}
@@ -486,6 +490,7 @@ pub struct WasmState {
pub table: ResourceTable,
ctx: wasi::WasiCtx,
pub host: Arc<WasmHost>,
+ pub(crate) capability_granter: CapabilityGranter,
}
type MainThreadCall = Box<dyn Send + for<'a> FnOnce(&'a mut AsyncApp) -> LocalBoxFuture<'a, ()>>;
@@ -527,7 +532,7 @@ fn wasm_engine(executor: &BackgroundExecutor) -> wasmtime::Engine {
// `Future::poll`.
const EPOCH_INTERVAL: Duration = Duration::from_millis(100);
let mut timer = Timer::interval(EPOCH_INTERVAL);
- while let Some(_) = timer.next().await {
+ while (timer.next().await).is_some() {
// Exit the loop and thread once the engine is dropped.
let Some(engine) = engine_ref.upgrade() else {
break;
@@ -571,6 +576,19 @@ impl WasmHost {
node_runtime,
proxy,
release_channel: ReleaseChannel::global(cx),
+ granted_capabilities: vec![
+ ExtensionCapability::ProcessExec(ProcessExecCapability {
+ command: "*".to_string(),
+ args: vec!["**".to_string()],
+ }),
+ ExtensionCapability::DownloadFile(DownloadFileCapability {
+ host: "*".to_string(),
+ path: vec!["**".to_string()],
+ }),
+ ExtensionCapability::NpmInstallPackage(NpmInstallPackageCapability {
+ package: "*".to_string(),
+ }),
+ ],
_main_thread_message_task: task,
main_thread_message_tx: tx,
})
@@ -597,6 +615,10 @@ impl WasmHost {
manifest: manifest.clone(),
table: ResourceTable::new(),
host: this.clone(),
+ capability_granter: CapabilityGranter::new(
+ this.granted_capabilities.clone(),
+ manifest.clone(),
+ ),
},
);
// Store will yield after 1 tick, and get a new deadline of 1 tick after each yield.
@@ -679,16 +701,15 @@ pub fn parse_wasm_extension_version(
for part in wasmparser::Parser::new(0).parse_all(wasm_bytes) {
if let wasmparser::Payload::CustomSection(s) =
part.context("error parsing wasm extension")?
+ && s.name() == "zed:api-version"
{
- if s.name() == "zed:api-version" {
- version = parse_wasm_extension_version_custom_section(s.data());
- if version.is_none() {
- bail!(
- "extension {} has invalid zed:api-version section: {:?}",
- extension_id,
- s.data()
- );
- }
+ version = parse_wasm_extension_version_custom_section(s.data());
+ if version.is_none() {
+ bail!(
+ "extension {} has invalid zed:api-version section: {:?}",
+ extension_id,
+ s.data()
+ );
}
}
}
@@ -715,7 +736,7 @@ fn parse_wasm_extension_version_custom_section(data: &[u8]) -> Option<SemanticVe
impl WasmExtension {
pub async fn load(
- extension_dir: PathBuf,
+ extension_dir: &Path,
manifest: &Arc<ExtensionManifest>,
wasm_host: Arc<WasmHost>,
cx: &AsyncApp,
@@ -739,7 +760,7 @@ impl WasmExtension {
.with_context(|| format!("failed to load wasm extension {}", manifest.id))
}
- pub async fn call<T, Fn>(&self, f: Fn) -> T
+ pub async fn call<T, Fn>(&self, f: Fn) -> Result<T>
where
T: 'static + Send,
Fn: 'static
@@ -755,8 +776,19 @@ impl WasmExtension {
}
.boxed()
}))
- .expect("wasm extension channel should not be closed yet");
- return_rx.await.expect("wasm extension channel")
+ .map_err(|_| {
+ anyhow!(
+ "wasm extension channel should not be closed yet, extension {} (id {})",
+ self.manifest.name,
+ self.manifest.id,
+ )
+ })?;
+ return_rx.await.with_context(|| {
+ format!(
+ "wasm extension channel, extension {} (id {})",
+ self.manifest.name, self.manifest.id,
+ )
+ })
}
}
@@ -777,8 +809,19 @@ impl WasmState {
}
.boxed_local()
}))
- .expect("main thread message channel should not be closed yet");
- async move { return_rx.await.expect("main thread message channel") }
+ .unwrap_or_else(|_| {
+ panic!(
+ "main thread message channel should not be closed yet, extension {} (id {})",
+ self.manifest.name, self.manifest.id,
+ )
+ });
+ let name = self.manifest.name.clone();
+ let id = self.manifest.id.clone();
+ async move {
+ return_rx.await.unwrap_or_else(|_| {
+ panic!("main thread message channel, extension {name} (id {id})")
+ })
+ }
}
fn work_dir(&self) -> PathBuf {
@@ -30,6 +30,7 @@ use std::{
sync::{Arc, OnceLock},
};
use task::{SpawnInTerminal, ZedDebugConfig};
+use url::Url;
use util::{archive::extract_zip, fs::make_file_executable, maybe};
use wasmtime::component::{Linker, Resource};
@@ -75,7 +76,7 @@ impl From<Range> for std::ops::Range<usize> {
impl From<Command> for extension::Command {
fn from(value: Command) -> Self {
Self {
- command: value.command,
+ command: value.command.into(),
args: value.args,
env: value.env,
}
@@ -744,6 +745,9 @@ impl nodejs::Host for WasmState {
package_name: String,
version: String,
) -> wasmtime::Result<Result<(), String>> {
+ self.capability_granter
+ .grant_npm_install_package(&package_name)?;
+
self.host
.node_runtime
.npm_install_packages(&self.work_dir(), &[(&package_name, &version)])
@@ -847,7 +851,8 @@ impl process::Host for WasmState {
command: process::Command,
) -> wasmtime::Result<Result<process::Output, String>> {
maybe!(async {
- self.manifest.allow_exec(&command.command, &command.args)?;
+ self.capability_granter
+ .grant_exec(&command.command, &command.args)?;
let output = util::command::new_smol_command(command.command.as_str())
.args(&command.args)
@@ -933,7 +938,7 @@ impl ExtensionImports for WasmState {
binary: settings.binary.map(|binary| settings::CommandSettings {
path: binary.path,
arguments: binary.arguments,
- env: binary.env,
+ env: binary.env.map(|env| env.into_iter().collect()),
}),
settings: settings.settings,
initialization_options: settings.initialization_options,
@@ -958,7 +963,7 @@ impl ExtensionImports for WasmState {
command,
} => Ok(serde_json::to_string(&settings::ContextServerSettings {
command: Some(settings::CommandSettings {
- path: Some(command.path),
+ path: command.path.to_str().map(|path| path.to_string()),
arguments: Some(command.args),
env: command.env.map(|env| env.into_iter().collect()),
}),
@@ -1010,6 +1015,9 @@ impl ExtensionImports for WasmState {
file_type: DownloadedFileType,
) -> wasmtime::Result<Result<(), String>> {
maybe!(async {
+ let parsed_url = Url::parse(&url)?;
+ self.capability_granter.grant_download_file(&parsed_url)?;
+
let path = PathBuf::from(path);
let extension_work_dir = self.host.work_dir.join(self.manifest.id.as_ref());
@@ -58,10 +58,9 @@ impl RenderOnce for FeatureUpsell {
el.child(
Button::new("open_docs", "View Documentation")
.icon(IconName::ArrowUpRight)
- .icon_size(IconSize::XSmall)
+ .icon_size(IconSize::Small)
.icon_position(IconPosition::End)
.on_click({
- let docs_url = docs_url.clone();
move |_event, _window, cx| {
telemetry::event!(
"Documentation Viewed",
@@ -6,6 +6,7 @@ use std::sync::OnceLock;
use std::time::Duration;
use std::{ops::Range, sync::Arc};
+use anyhow::Context as _;
use client::{ExtensionMetadata, ExtensionProvides};
use collections::{BTreeMap, BTreeSet};
use editor::{Editor, EditorElement, EditorStyle};
@@ -23,7 +24,7 @@ use settings::Settings;
use strum::IntoEnumIterator as _;
use theme::ThemeSettings;
use ui::{
- CheckboxWithLabel, ContextMenu, PopoverMenu, ScrollableHandle, Scrollbar, ScrollbarState,
+ CheckboxWithLabel, Chip, ContextMenu, PopoverMenu, ScrollableHandle, Scrollbar, ScrollbarState,
ToggleButton, Tooltip, prelude::*,
};
use vim_mode_setting::VimModeSetting;
@@ -80,16 +81,24 @@ pub fn init(cx: &mut App) {
.find_map(|item| item.downcast::<ExtensionsPage>());
if let Some(existing) = existing {
- if provides_filter.is_some() {
- existing.update(cx, |extensions_page, cx| {
+ existing.update(cx, |extensions_page, cx| {
+ if provides_filter.is_some() {
extensions_page.change_provides_filter(provides_filter, cx);
- });
- }
+ }
+ if let Some(id) = action.id.as_ref() {
+ extensions_page.focus_extension(id, window, cx);
+ }
+ });
workspace.activate_item(&existing, true, true, window, cx);
} else {
- let extensions_page =
- ExtensionsPage::new(workspace, provides_filter, window, cx);
+ let extensions_page = ExtensionsPage::new(
+ workspace,
+ provides_filter,
+ action.id.as_deref(),
+ window,
+ cx,
+ );
workspace.add_item_to_active_pane(
Box::new(extensions_page),
None,
@@ -107,6 +116,7 @@ pub fn init(cx: &mut App) {
files: false,
directories: true,
multiple: false,
+ prompt: None,
},
DirectoryLister::Local(
workspace.project().clone(),
@@ -287,6 +297,7 @@ impl ExtensionsPage {
pub fn new(
workspace: &Workspace,
provides_filter: Option<ExtensionProvides>,
+ focus_extension_id: Option<&str>,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Entity<Self> {
@@ -317,6 +328,9 @@ impl ExtensionsPage {
let query_editor = cx.new(|cx| {
let mut input = Editor::single_line(window, cx);
input.set_placeholder_text("Search extensions...", cx);
+ if let Some(id) = focus_extension_id {
+ input.set_text(format!("id:{id}"), window, cx);
+ }
input
});
cx.subscribe(&query_editor, Self::on_query_change).detach();
@@ -340,7 +354,7 @@ impl ExtensionsPage {
scrollbar_state: ScrollbarState::new(scroll_handle),
};
this.fetch_extensions(
- None,
+ this.search_query(cx),
Some(BTreeSet::from_iter(this.provides_filter)),
None,
cx,
@@ -464,9 +478,23 @@ impl ExtensionsPage {
.cloned()
.collect::<Vec<_>>();
- let remote_extensions = extension_store.update(cx, |store, cx| {
- store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx)
- });
+ let remote_extensions =
+ if let Some(id) = search.as_ref().and_then(|s| s.strip_prefix("id:")) {
+ let versions =
+ extension_store.update(cx, |store, cx| store.fetch_extension_versions(id, cx));
+ cx.foreground_executor().spawn(async move {
+ let versions = versions.await?;
+ let latest = versions
+ .into_iter()
+ .max_by_key(|v| v.published_at)
+ .context("no extension found")?;
+ Ok(vec![latest])
+ })
+ } else {
+ extension_store.update(cx, |store, cx| {
+ store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx)
+ })
+ };
cx.spawn(async move |this, cx| {
let dev_extensions = if let Some(search) = search {
@@ -666,7 +694,7 @@ impl ExtensionsPage {
cx.open_url(&repository_url);
}
}))
- .tooltip(Tooltip::text(repository_url.clone()))
+ .tooltip(Tooltip::text(repository_url))
})),
)
}
@@ -676,7 +704,7 @@ impl ExtensionsPage {
extension: &ExtensionMetadata,
cx: &mut Context<Self>,
) -> ExtensionCard {
- let this = cx.entity().clone();
+ let this = cx.entity();
let status = Self::extension_status(&extension.id, cx);
let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
@@ -732,20 +760,7 @@ impl ExtensionsPage {
_ => {}
}
- Some(
- div()
- .px_1()
- .border_1()
- .rounded_sm()
- .border_color(cx.theme().colors().border)
- .bg(cx.theme().colors().element_background)
- .child(
- Label::new(extension_provides_label(
- *provides,
- ))
- .size(LabelSize::XSmall),
- ),
- )
+ Some(Chip::new(extension_provides_label(*provides)))
})
.collect::<Vec<_>>(),
),
@@ -812,7 +827,7 @@ impl ExtensionsPage {
cx.open_url(&repository_url);
}
}))
- .tooltip(Tooltip::text(repository_url.clone())),
+ .tooltip(Tooltip::text(repository_url)),
)
.child(
PopoverMenu::new(SharedString::from(format!(
@@ -848,7 +863,7 @@ impl ExtensionsPage {
window: &mut Window,
cx: &mut App,
) -> Entity<ContextMenu> {
- let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| {
+ ContextMenu::build(window, cx, |context_menu, window, _| {
context_menu
.entry(
"Install Another Version...",
@@ -872,9 +887,7 @@ impl ExtensionsPage {
cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
}
})
- });
-
- context_menu
+ })
}
fn show_extension_version_list(
@@ -1016,15 +1029,14 @@ impl ExtensionsPage {
.read(cx)
.extension_manifest_for_id(&extension_id)
.cloned()
+ && let Some(events) = extension::ExtensionEvents::try_global(cx)
{
- if let Some(events) = extension::ExtensionEvents::try_global(cx) {
- events.update(cx, |this, cx| {
- this.emit(
- extension::Event::ConfigureExtensionRequested(manifest),
- cx,
- )
- });
- }
+ events.update(cx, |this, cx| {
+ this.emit(
+ extension::Event::ConfigureExtensionRequested(manifest),
+ cx,
+ )
+ });
}
}
})
@@ -1165,6 +1177,13 @@ impl ExtensionsPage {
self.refresh_feature_upsells(cx);
}
+ pub fn focus_extension(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
+ self.query_editor.update(cx, |editor, cx| {
+ editor.set_text(format!("id:{id}"), window, cx)
+ });
+ self.refresh_search(cx);
+ }
+
pub fn change_provides_filter(
&mut self,
provides_filter: Option<ExtensionProvides>,
@@ -14,7 +14,7 @@ struct FeatureFlags {
}
pub static ZED_DISABLE_STAFF: LazyLock<bool> = LazyLock::new(|| {
- std::env::var("ZED_DISABLE_STAFF").map_or(false, |value| !value.is_empty() && value != "0")
+ std::env::var("ZED_DISABLE_STAFF").is_ok_and(|value| !value.is_empty() && value != "0")
});
impl FeatureFlags {
@@ -77,13 +77,10 @@ impl FeatureFlag for NotebookFeatureFlag {
const NAME: &'static str = "notebooks";
}
-pub struct ThreadAutoCaptureFeatureFlag {}
-impl FeatureFlag for ThreadAutoCaptureFeatureFlag {
- const NAME: &'static str = "thread-auto-capture";
+pub struct PanicFeatureFlag;
- fn enabled_for_staff() -> bool {
- false
- }
+impl FeatureFlag for PanicFeatureFlag {
+ const NAME: &'static str = "panic";
}
pub struct JjUiFeatureFlag {}
@@ -92,10 +89,21 @@ impl FeatureFlag for JjUiFeatureFlag {
const NAME: &'static str = "jj-ui";
}
-pub struct AcpFeatureFlag;
+pub struct GeminiAndNativeFeatureFlag;
-impl FeatureFlag for AcpFeatureFlag {
- const NAME: &'static str = "acp";
+impl FeatureFlag for GeminiAndNativeFeatureFlag {
+ // This was previously called "acp".
+ //
+ // We renamed it because existing builds used it to enable the Claude Code
+ // integration too, and we'd like to turn Gemini/Native on in new builds
+ // without enabling Claude Code in old builds.
+ const NAME: &'static str = "gemini-and-native";
+}
+
+pub struct ClaudeCodeFeatureFlag;
+
+impl FeatureFlag for ClaudeCodeFeatureFlag {
+ const NAME: &'static str = "claude-code";
}
pub trait FeatureFlagViewExt<V: 'static> {
@@ -153,6 +161,11 @@ where
}
}
+#[derive(Debug)]
+pub struct OnFlagsReady {
+ pub is_staff: bool,
+}
+
pub trait FeatureFlagAppExt {
fn wait_for_flag<T: FeatureFlag>(&mut self) -> WaitForFlag;
@@ -164,6 +177,10 @@ pub trait FeatureFlagAppExt {
fn has_flag<T: FeatureFlag>(&self) -> bool;
fn is_staff(&self) -> bool;
+ fn on_flags_ready<F>(&mut self, callback: F) -> Subscription
+ where
+ F: FnMut(OnFlagsReady, &mut App) + 'static;
+
fn observe_flag<T: FeatureFlag, F>(&mut self, callback: F) -> Subscription
where
F: FnMut(bool, &mut App) + 'static;
@@ -193,6 +210,21 @@ impl FeatureFlagAppExt for App {
.unwrap_or(false)
}
+ fn on_flags_ready<F>(&mut self, mut callback: F) -> Subscription
+ where
+ F: FnMut(OnFlagsReady, &mut App) + 'static,
+ {
+ self.observe_global::<FeatureFlags>(move |cx| {
+ let feature_flags = cx.global::<FeatureFlags>();
+ callback(
+ OnFlagsReady {
+ is_staff: feature_flags.staff,
+ },
+ cx,
+ );
+ })
+ }
+
fn observe_flag<T: FeatureFlag, F>(&mut self, mut callback: F) -> Subscription
where
F: FnMut(bool, &mut App) + 'static,
@@ -15,13 +15,9 @@ path = "src/feedback.rs"
test-support = []
[dependencies]
-client.workspace = true
gpui.workspace = true
-human_bytes = "0.4.1"
menu.workspace = true
-release_channel.workspace = true
-serde.workspace = true
-sysinfo.workspace = true
+system_specs.workspace = true
ui.workspace = true
urlencoding.workspace = true
util.workspace = true
@@ -1,18 +1,14 @@
use gpui::{App, ClipboardItem, PromptLevel, actions};
-use system_specs::SystemSpecs;
+use system_specs::{CopySystemSpecsIntoClipboard, SystemSpecs};
use util::ResultExt;
use workspace::Workspace;
use zed_actions::feedback::FileBugReport;
pub mod feedback_modal;
-pub mod system_specs;
-
actions!(
zed,
[
- /// Copies system specifications to the clipboard for bug reports.
- CopySystemSpecsIntoClipboard,
/// Opens email client to send feedback to Zed support.
EmailZed,
/// Opens the Zed repository on GitHub.
@@ -17,7 +17,7 @@ use fuzzy::{CharBag, PathMatch, PathMatchCandidate};
use gpui::{
Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
- Window, actions,
+ Window, actions, rems,
};
use open_path_prompt::OpenPathPrompt;
use picker::{Picker, PickerDelegate};
@@ -209,11 +209,11 @@ impl FileFinder {
let Some(init_modifiers) = self.init_modifiers.take() else {
return;
};
- if self.picker.read(cx).delegate.has_changed_selected_index {
- if !event.modified() || !init_modifiers.is_subset_of(&event) {
- self.init_modifiers = None;
- window.dispatch_action(menu::Confirm.boxed_clone(), cx);
- }
+ if self.picker.read(cx).delegate.has_changed_selected_index
+ && (!event.modified() || !init_modifiers.is_subset_of(event))
+ {
+ self.init_modifiers = None;
+ window.dispatch_action(menu::Confirm.boxed_clone(), cx);
}
}
@@ -267,10 +267,9 @@ impl FileFinder {
) {
self.picker.update(cx, |picker, cx| {
picker.delegate.include_ignored = match picker.delegate.include_ignored {
- Some(true) => match FileFinderSettings::get_global(cx).include_ignored {
- Some(_) => Some(false),
- None => None,
- },
+ Some(true) => FileFinderSettings::get_global(cx)
+ .include_ignored
+ .map(|_| false),
Some(false) => Some(true),
None => Some(true),
};
@@ -323,34 +322,34 @@ impl FileFinder {
) {
self.picker.update(cx, |picker, cx| {
let delegate = &mut picker.delegate;
- if let Some(workspace) = delegate.workspace.upgrade() {
- if let Some(m) = delegate.matches.get(delegate.selected_index()) {
- let path = match &m {
- Match::History { path, .. } => {
- let worktree_id = path.project.worktree_id;
- ProjectPath {
- worktree_id,
- path: Arc::clone(&path.project.path),
- }
+ if let Some(workspace) = delegate.workspace.upgrade()
+ && let Some(m) = delegate.matches.get(delegate.selected_index())
+ {
+ let path = match &m {
+ Match::History { path, .. } => {
+ let worktree_id = path.project.worktree_id;
+ ProjectPath {
+ worktree_id,
+ path: Arc::clone(&path.project.path),
}
- Match::Search(m) => ProjectPath {
- worktree_id: WorktreeId::from_usize(m.0.worktree_id),
- path: m.0.path.clone(),
- },
- Match::CreateNew(p) => p.clone(),
- };
- let open_task = workspace.update(cx, move |workspace, cx| {
- workspace.split_path_preview(path, false, Some(split_direction), window, cx)
- });
- open_task.detach_and_log_err(cx);
- }
+ }
+ Match::Search(m) => ProjectPath {
+ worktree_id: WorktreeId::from_usize(m.0.worktree_id),
+ path: m.0.path.clone(),
+ },
+ Match::CreateNew(p) => p.clone(),
+ };
+ let open_task = workspace.update(cx, move |workspace, cx| {
+ workspace.split_path_preview(path, false, Some(split_direction), window, cx)
+ });
+ open_task.detach_and_log_err(cx);
}
})
}
pub fn modal_max_width(width_setting: Option<FileFinderWidth>, window: &mut Window) -> Pixels {
let window_width = window.viewport_size().width;
- let small_width = Pixels(545.);
+ let small_width = rems(34.).to_pixels(window.rem_size());
match width_setting {
None | Some(FileFinderWidth::Small) => small_width,
@@ -497,7 +496,7 @@ impl Match {
fn panel_match(&self) -> Option<&ProjectPanelOrdMatch> {
match self {
Match::History { panel_match, .. } => panel_match.as_ref(),
- Match::Search(panel_match) => Some(&panel_match),
+ Match::Search(panel_match) => Some(panel_match),
Match::CreateNew(_) => None,
}
}
@@ -537,7 +536,7 @@ impl Matches {
self.matches.binary_search_by(|m| {
// `reverse()` since if cmp_matches(a, b) == Ordering::Greater, then a is better than b.
// And we want the better entries go first.
- Self::cmp_matches(self.separate_history, currently_opened, &m, &entry).reverse()
+ Self::cmp_matches(self.separate_history, currently_opened, m, entry).reverse()
})
}
}
@@ -675,17 +674,17 @@ impl Matches {
let path_str = panel_match.0.path.to_string_lossy();
let filename_str = filename.to_string_lossy();
- if let Some(filename_pos) = path_str.rfind(&*filename_str) {
- if panel_match.0.positions[0] >= filename_pos {
- let mut prev_position = panel_match.0.positions[0];
- for p in &panel_match.0.positions[1..] {
- if *p != prev_position + 1 {
- return false;
- }
- prev_position = *p;
+ if let Some(filename_pos) = path_str.rfind(&*filename_str)
+ && panel_match.0.positions[0] >= filename_pos
+ {
+ let mut prev_position = panel_match.0.positions[0];
+ for p in &panel_match.0.positions[1..] {
+ if *p != prev_position + 1 {
+ return false;
}
- return true;
+ prev_position = *p;
}
+ return true;
}
}
@@ -878,9 +877,7 @@ impl FileFinderDelegate {
PathMatchCandidateSet {
snapshot: worktree.snapshot(),
include_ignored: self.include_ignored.unwrap_or_else(|| {
- worktree
- .root_entry()
- .map_or(false, |entry| entry.is_ignored)
+ worktree.root_entry().is_some_and(|entry| entry.is_ignored)
}),
include_root_name,
candidates: project::Candidates::Files,
@@ -1045,10 +1042,10 @@ impl FileFinderDelegate {
)
} else {
let mut path = Arc::clone(project_relative_path);
- if project_relative_path.as_ref() == Path::new("") {
- if let Some(absolute_path) = &entry_path.absolute {
- path = Arc::from(absolute_path.as_path());
- }
+ if project_relative_path.as_ref() == Path::new("")
+ && let Some(absolute_path) = &entry_path.absolute
+ {
+ path = Arc::from(absolute_path.as_path());
}
let mut path_match = PathMatch {
@@ -1078,23 +1075,21 @@ impl FileFinderDelegate {
),
};
- if file_name_positions.is_empty() {
- if let Some(user_home_path) = std::env::var("HOME").ok() {
- let user_home_path = user_home_path.trim();
- if !user_home_path.is_empty() {
- if (&full_path).starts_with(user_home_path) {
- full_path.replace_range(0..user_home_path.len(), "~");
- full_path_positions.retain_mut(|pos| {
- if *pos >= user_home_path.len() {
- *pos -= user_home_path.len();
- *pos += 1;
- true
- } else {
- false
- }
- })
+ if file_name_positions.is_empty()
+ && let Some(user_home_path) = std::env::var("HOME").ok()
+ {
+ let user_home_path = user_home_path.trim();
+ if !user_home_path.is_empty() && full_path.starts_with(user_home_path) {
+ full_path.replace_range(0..user_home_path.len(), "~");
+ full_path_positions.retain_mut(|pos| {
+ if *pos >= user_home_path.len() {
+ *pos -= user_home_path.len();
+ *pos += 1;
+ true
+ } else {
+ false
}
- }
+ })
}
}
@@ -1242,14 +1237,13 @@ impl FileFinderDelegate {
/// Skips first history match (that is displayed topmost) if it's currently opened.
fn calculate_selected_index(&self, cx: &mut Context<Picker<Self>>) -> usize {
- if FileFinderSettings::get_global(cx).skip_focus_for_active_in_search {
- if let Some(Match::History { path, .. }) = self.matches.get(0) {
- if Some(path) == self.currently_opened_path.as_ref() {
- let elements_after_first = self.matches.len() - 1;
- if elements_after_first > 0 {
- return 1;
- }
- }
+ if FileFinderSettings::get_global(cx).skip_focus_for_active_in_search
+ && let Some(Match::History { path, .. }) = self.matches.get(0)
+ && Some(path) == self.currently_opened_path.as_ref()
+ {
+ let elements_after_first = self.matches.len() - 1;
+ if elements_after_first > 0 {
+ return 1;
}
}
@@ -1310,10 +1304,10 @@ impl PickerDelegate for FileFinderDelegate {
.enumerate()
.find(|(_, m)| !matches!(m, Match::History { .. }))
.map(|(i, _)| i);
- if let Some(first_non_history_index) = first_non_history_index {
- if first_non_history_index > 0 {
- return vec![first_non_history_index - 1];
- }
+ if let Some(first_non_history_index) = first_non_history_index
+ && first_non_history_index > 0
+ {
+ return vec![first_non_history_index - 1];
}
}
Vec::new()
@@ -1402,16 +1396,26 @@ impl PickerDelegate for FileFinderDelegate {
cx.notify();
Task::ready(())
} else {
- let path_position = PathWithPosition::parse_str(&raw_query);
+ let path_position = PathWithPosition::parse_str(raw_query);
+
+ #[cfg(windows)]
+ let raw_query = raw_query.trim().to_owned().replace("/", "\\");
+ #[cfg(not(windows))]
+ let raw_query = raw_query.trim();
+
+ let raw_query = raw_query.trim_end_matches(':').to_owned();
+ let path = path_position.path.to_str();
+ let path_trimmed = path.unwrap_or(&raw_query).trim_end_matches(':');
+ let file_query_end = if path_trimmed == raw_query {
+ None
+ } else {
+ // Safe to unwrap as we won't get here when the unwrap in if fails
+ Some(path.unwrap().len())
+ };
let query = FileSearchQuery {
- raw_query: raw_query.trim().to_owned(),
- file_query_end: if path_position.path.to_str().unwrap_or(raw_query) == raw_query {
- None
- } else {
- // Safe to unwrap as we won't get here when the unwrap in if fails
- Some(path_position.path.to_str().unwrap().len())
- },
+ raw_query,
+ file_query_end,
path_position,
};
@@ -1429,69 +1433,101 @@ impl PickerDelegate for FileFinderDelegate {
window: &mut Window,
cx: &mut Context<Picker<FileFinderDelegate>>,
) {
- if let Some(m) = self.matches.get(self.selected_index()) {
- if let Some(workspace) = self.workspace.upgrade() {
- let open_task = workspace.update(cx, |workspace, cx| {
- let split_or_open =
- |workspace: &mut Workspace,
- project_path,
- window: &mut Window,
- cx: &mut Context<Workspace>| {
- let allow_preview =
- PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder;
- if secondary {
- workspace.split_path_preview(
- project_path,
- allow_preview,
- None,
- window,
- cx,
- )
- } else {
- workspace.open_path_preview(
- project_path,
- None,
- true,
- allow_preview,
- true,
- window,
- cx,
- )
- }
- };
- match &m {
- Match::CreateNew(project_path) => {
- // Create a new file with the given filename
- if secondary {
- workspace.split_path_preview(
- project_path.clone(),
- false,
- None,
- window,
- cx,
- )
- } else {
- workspace.open_path_preview(
- project_path.clone(),
- None,
- true,
- false,
- true,
- window,
- cx,
- )
- }
+ if let Some(m) = self.matches.get(self.selected_index())
+ && let Some(workspace) = self.workspace.upgrade()
+ {
+ let open_task = workspace.update(cx, |workspace, cx| {
+ let split_or_open =
+ |workspace: &mut Workspace,
+ project_path,
+ window: &mut Window,
+ cx: &mut Context<Workspace>| {
+ let allow_preview =
+ PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder;
+ if secondary {
+ workspace.split_path_preview(
+ project_path,
+ allow_preview,
+ None,
+ window,
+ cx,
+ )
+ } else {
+ workspace.open_path_preview(
+ project_path,
+ None,
+ true,
+ allow_preview,
+ true,
+ window,
+ cx,
+ )
}
+ };
+ match &m {
+ Match::CreateNew(project_path) => {
+ // Create a new file with the given filename
+ if secondary {
+ workspace.split_path_preview(
+ project_path.clone(),
+ false,
+ None,
+ window,
+ cx,
+ )
+ } else {
+ workspace.open_path_preview(
+ project_path.clone(),
+ None,
+ true,
+ false,
+ true,
+ window,
+ cx,
+ )
+ }
+ }
- Match::History { path, .. } => {
- let worktree_id = path.project.worktree_id;
- if workspace
- .project()
- .read(cx)
- .worktree_for_id(worktree_id, cx)
- .is_some()
- {
- split_or_open(
+ Match::History { path, .. } => {
+ let worktree_id = path.project.worktree_id;
+ if workspace
+ .project()
+ .read(cx)
+ .worktree_for_id(worktree_id, cx)
+ .is_some()
+ {
+ split_or_open(
+ workspace,
+ ProjectPath {
+ worktree_id,
+ path: Arc::clone(&path.project.path),
+ },
+ window,
+ cx,
+ )
+ } else {
+ match path.absolute.as_ref() {
+ Some(abs_path) => {
+ if secondary {
+ workspace.split_abs_path(
+ abs_path.to_path_buf(),
+ false,
+ window,
+ cx,
+ )
+ } else {
+ workspace.open_abs_path(
+ abs_path.to_path_buf(),
+ OpenOptions {
+ visible: Some(OpenVisible::None),
+ ..Default::default()
+ },
+ window,
+ cx,
+ )
+ }
+ }
+ None => split_or_open(
workspace,
ProjectPath {
worktree_id,
@@ -1499,88 +1535,52 @@ impl PickerDelegate for FileFinderDelegate {
},
window,
cx,
- )
- } else {
- match path.absolute.as_ref() {
- Some(abs_path) => {
- if secondary {
- workspace.split_abs_path(
- abs_path.to_path_buf(),
- false,
- window,
- cx,
- )
- } else {
- workspace.open_abs_path(
- abs_path.to_path_buf(),
- OpenOptions {
- visible: Some(OpenVisible::None),
- ..Default::default()
- },
- window,
- cx,
- )
- }
- }
- None => split_or_open(
- workspace,
- ProjectPath {
- worktree_id,
- path: Arc::clone(&path.project.path),
- },
- window,
- cx,
- ),
- }
+ ),
}
}
- Match::Search(m) => split_or_open(
- workspace,
- ProjectPath {
- worktree_id: WorktreeId::from_usize(m.0.worktree_id),
- path: m.0.path.clone(),
- },
- window,
- cx,
- ),
}
- });
+ Match::Search(m) => split_or_open(
+ workspace,
+ ProjectPath {
+ worktree_id: WorktreeId::from_usize(m.0.worktree_id),
+ path: m.0.path.clone(),
+ },
+ window,
+ cx,
+ ),
+ }
+ });
- let row = self
- .latest_search_query
- .as_ref()
- .and_then(|query| query.path_position.row)
- .map(|row| row.saturating_sub(1));
- let col = self
- .latest_search_query
- .as_ref()
- .and_then(|query| query.path_position.column)
- .unwrap_or(0)
- .saturating_sub(1);
- let finder = self.file_finder.clone();
-
- cx.spawn_in(window, async move |_, cx| {
- let item = open_task.await.notify_async_err(cx)?;
- if let Some(row) = row {
- if let Some(active_editor) = item.downcast::<Editor>() {
- active_editor
- .downgrade()
- .update_in(cx, |editor, window, cx| {
- editor.go_to_singleton_buffer_point(
- Point::new(row, col),
- window,
- cx,
- );
- })
- .log_err();
- }
- }
- finder.update(cx, |_, cx| cx.emit(DismissEvent)).ok()?;
+ let row = self
+ .latest_search_query
+ .as_ref()
+ .and_then(|query| query.path_position.row)
+ .map(|row| row.saturating_sub(1));
+ let col = self
+ .latest_search_query
+ .as_ref()
+ .and_then(|query| query.path_position.column)
+ .unwrap_or(0)
+ .saturating_sub(1);
+ let finder = self.file_finder.clone();
+
+ cx.spawn_in(window, async move |_, cx| {
+ let item = open_task.await.notify_async_err(cx)?;
+ if let Some(row) = row
+ && let Some(active_editor) = item.downcast::<Editor>()
+ {
+ active_editor
+ .downgrade()
+ .update_in(cx, |editor, window, cx| {
+ editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx);
+ })
+ .log_err();
+ }
+ finder.update(cx, |_, cx| cx.emit(DismissEvent)).ok()?;
- Some(())
- })
- .detach();
- }
+ Some(())
+ })
+ .detach();
}
}
@@ -1752,7 +1752,7 @@ impl PickerDelegate for FileFinderDelegate {
Some(ContextMenu::build(window, cx, {
let focus_handle = focus_handle.clone();
move |menu, _, _| {
- menu.context(focus_handle.clone())
+ menu.context(focus_handle)
.action(
"Split Left",
pane::SplitLeft.boxed_clone(),
@@ -218,6 +218,7 @@ async fn test_matching_paths(cx: &mut TestAppContext) {
" ndan ",
" band ",
"a bandana",
+ "bandana:",
] {
picker
.update_in(cx, |picker, window, cx| {
@@ -252,6 +253,53 @@ async fn test_matching_paths(cx: &mut TestAppContext) {
}
}
+#[gpui::test]
+async fn test_matching_paths_with_colon(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ path!("/root"),
+ json!({
+ "a": {
+ "foo:bar.rs": "",
+ "foo.rs": "",
+ }
+ }),
+ )
+ .await;
+
+ let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
+
+ let (picker, _, cx) = build_find_picker(project, cx);
+
+ // 'foo:' matches both files
+ cx.simulate_input("foo:");
+ picker.update(cx, |picker, _| {
+ assert_eq!(picker.delegate.matches.len(), 3);
+ assert_match_at_position(picker, 0, "foo.rs");
+ assert_match_at_position(picker, 1, "foo:bar.rs");
+ });
+
+ // 'foo:b' matches one of the files
+ cx.simulate_input("b");
+ picker.update(cx, |picker, _| {
+ assert_eq!(picker.delegate.matches.len(), 2);
+ assert_match_at_position(picker, 0, "foo:bar.rs");
+ });
+
+ cx.dispatch_action(editor::actions::Backspace);
+
+ // 'foo:1' matches both files, specifying which row to jump to
+ cx.simulate_input("1");
+ picker.update(cx, |picker, _| {
+ assert_eq!(picker.delegate.matches.len(), 3);
+ assert_match_at_position(picker, 0, "foo.rs");
+ assert_match_at_position(picker, 1, "foo:bar.rs");
+ });
+}
+
#[gpui::test]
async fn test_unicode_paths(cx: &mut TestAppContext) {
let app_state = init_test(cx);
@@ -1614,7 +1662,7 @@ async fn test_select_current_open_file_when_no_history(cx: &mut gpui::TestAppCon
let picker = open_file_picker(&workspace, cx);
picker.update(cx, |finder, _| {
- assert_match_selection(&finder, 0, "1_qw");
+ assert_match_selection(finder, 0, "1_qw");
});
}
@@ -2623,7 +2671,7 @@ async fn open_queried_buffer(
workspace: &Entity<Workspace>,
cx: &mut gpui::VisualTestContext,
) -> Vec<FoundPath> {
- let picker = open_file_picker(&workspace, cx);
+ let picker = open_file_picker(workspace, cx);
cx.simulate_input(input);
let history_items = picker.update(cx, |finder, _| {
@@ -75,16 +75,16 @@ impl OpenPathDelegate {
..
} => {
let mut i = selected_match_index;
- if let Some(user_input) = user_input {
- if !user_input.exists || !user_input.is_dir {
- if i == 0 {
- return Some(CandidateInfo {
- path: user_input.file.clone(),
- is_dir: false,
- });
- } else {
- i -= 1;
- }
+ if let Some(user_input) = user_input
+ && (!user_input.exists || !user_input.is_dir)
+ {
+ if i == 0 {
+ return Some(CandidateInfo {
+ path: user_input.file.clone(),
+ is_dir: false,
+ });
+ } else {
+ i -= 1;
}
}
let id = self.string_matches.get(i)?.candidate_id;
@@ -112,7 +112,7 @@ impl OpenPathDelegate {
entries,
..
} => user_input
- .into_iter()
+ .iter()
.filter(|user_input| !user_input.exists || !user_input.is_dir)
.map(|user_input| user_input.file.string.clone())
.chain(self.string_matches.iter().filter_map(|string_match| {
@@ -637,7 +637,7 @@ impl PickerDelegate for OpenPathDelegate {
FileIcons::get_folder_icon(false, cx)?
} else {
let path = path::Path::new(&candidate.path.string);
- FileIcons::get_icon(&path, cx)?
+ FileIcons::get_icon(path, cx)?
};
Some(Icon::from_path(icon).color(Color::Muted))
});
@@ -653,7 +653,7 @@ impl PickerDelegate for OpenPathDelegate {
if parent_path == &self.prompt_root {
format!("{}{}", self.prompt_root, candidate.path.string)
} else {
- candidate.path.string.clone()
+ candidate.path.string
},
match_positions,
)),
@@ -684,7 +684,7 @@ impl PickerDelegate for OpenPathDelegate {
};
StyledText::new(label)
.with_default_highlights(
- &window.text_style().clone(),
+ &window.text_style(),
vec![(
delta..delta + label_len,
HighlightStyle::color(Color::Conflict.color(cx)),
@@ -694,7 +694,7 @@ impl PickerDelegate for OpenPathDelegate {
} else {
StyledText::new(format!("{label} (create)"))
.with_default_highlights(
- &window.text_style().clone(),
+ &window.text_style(),
vec![(
delta..delta + label_len,
HighlightStyle::color(Color::Created.color(cx)),
@@ -728,7 +728,7 @@ impl PickerDelegate for OpenPathDelegate {
.child(LabelLike::new().child(label_with_highlights)),
)
}
- DirectoryState::None { .. } => return None,
+ DirectoryState::None { .. } => None,
}
}
@@ -33,13 +33,23 @@ impl FileIcons {
// TODO: Associate a type with the languages and have the file's language
// override these associations
- // check if file name is in suffixes
- // e.g. catch file named `eslint.config.js` instead of `.eslint.config.js`
- if let Some(typ) = path.file_name().and_then(|typ| typ.to_str()) {
+ if let Some(mut typ) = path.file_name().and_then(|typ| typ.to_str()) {
+ // check if file name is in suffixes
+ // e.g. catch file named `eslint.config.js` instead of `.eslint.config.js`
let maybe_path = get_icon_from_suffix(typ);
if maybe_path.is_some() {
return maybe_path;
}
+
+ // check if suffix based on first dot is in suffixes
+ // e.g. consider `module.js` as suffix to angular's module file named `auth.module.js`
+ while let Some((_, suffix)) = typ.split_once('.') {
+ let maybe_path = get_icon_from_suffix(suffix);
+ if maybe_path.is_some() {
+ return maybe_path;
+ }
+ typ = suffix;
+ }
}
// primary case: check if the files extension or the hidden file name
@@ -62,7 +72,7 @@ impl FileIcons {
return maybe_path;
}
}
- return this.get_icon_for_type("default", cx);
+ this.get_icon_for_type("default", cx)
}
fn default_icon_theme(cx: &App) -> Option<Arc<IconTheme>> {
@@ -51,6 +51,7 @@ ashpd.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
+git = { workspace = true, features = ["test-support"] }
[features]
test-support = ["gpui/test-support", "git/test-support"]
@@ -1,8 +1,9 @@
-use crate::{FakeFs, Fs};
+use crate::{FakeFs, FakeFsEntry, Fs};
use anyhow::{Context as _, Result};
use collections::{HashMap, HashSet};
use futures::future::{self, BoxFuture, join_all};
use git::{
+ Oid,
blame::Blame,
repository::{
AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository,
@@ -10,8 +11,9 @@ use git::{
},
status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus},
};
-use gpui::{AsyncApp, BackgroundExecutor};
+use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task};
use ignore::gitignore::GitignoreBuilder;
+use parking_lot::Mutex;
use rope::Rope;
use smol::future::FutureExt as _;
use std::{path::PathBuf, sync::Arc};
@@ -19,6 +21,7 @@ use std::{path::PathBuf, sync::Arc};
#[derive(Clone)]
pub struct FakeGitRepository {
pub(crate) fs: Arc<FakeFs>,
+ pub(crate) checkpoints: Arc<Mutex<HashMap<Oid, FakeFsEntry>>>,
pub(crate) executor: BackgroundExecutor,
pub(crate) dot_git_path: PathBuf,
pub(crate) repository_dir_path: PathBuf,
@@ -183,7 +186,7 @@ impl GitRepository for FakeGitRepository {
async move { None }.boxed()
}
- fn status(&self, path_prefixes: &[RepoPath]) -> BoxFuture<'_, Result<GitStatus>> {
+ fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
let workdir_path = self.dot_git_path.parent().unwrap();
// Load gitignores
@@ -311,7 +314,10 @@ impl GitRepository for FakeGitRepository {
entries: entries.into(),
})
});
- async move { result? }.boxed()
+ Task::ready(match result {
+ Ok(result) => result,
+ Err(e) => Err(e),
+ })
}
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
@@ -339,7 +345,7 @@ impl GitRepository for FakeGitRepository {
fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
self.with_state_async(true, move |state| {
- state.branches.insert(name.to_owned());
+ state.branches.insert(name);
Ok(())
})
}
@@ -398,6 +404,18 @@ impl GitRepository for FakeGitRepository {
})
}
+ fn stash_paths(
+ &self,
+ _paths: Vec<RepoPath>,
+ _env: Arc<HashMap<String, String>>,
+ ) -> BoxFuture<'_, Result<()>> {
+ unimplemented!()
+ }
+
+ fn stash_pop(&self, _env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>> {
+ unimplemented!()
+ }
+
fn commit(
&self,
_message: gpui::SharedString,
@@ -454,22 +472,57 @@ impl GitRepository for FakeGitRepository {
}
fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
- unimplemented!()
+ let executor = self.executor.clone();
+ let fs = self.fs.clone();
+ let checkpoints = self.checkpoints.clone();
+ let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
+ async move {
+ executor.simulate_random_delay().await;
+ let oid = Oid::random(&mut executor.rng());
+ let entry = fs.entry(&repository_dir_path)?;
+ checkpoints.lock().insert(oid, entry);
+ Ok(GitRepositoryCheckpoint { commit_sha: oid })
+ }
+ .boxed()
}
- fn restore_checkpoint(
- &self,
- _checkpoint: GitRepositoryCheckpoint,
- ) -> BoxFuture<'_, Result<()>> {
- unimplemented!()
+ fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
+ let executor = self.executor.clone();
+ let fs = self.fs.clone();
+ let checkpoints = self.checkpoints.clone();
+ let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
+ async move {
+ executor.simulate_random_delay().await;
+ let checkpoints = checkpoints.lock();
+ let entry = checkpoints
+ .get(&checkpoint.commit_sha)
+ .context(format!("invalid checkpoint: {}", checkpoint.commit_sha))?;
+ fs.insert_entry(&repository_dir_path, entry.clone())?;
+ Ok(())
+ }
+ .boxed()
}
fn compare_checkpoints(
&self,
- _left: GitRepositoryCheckpoint,
- _right: GitRepositoryCheckpoint,
+ left: GitRepositoryCheckpoint,
+ right: GitRepositoryCheckpoint,
) -> BoxFuture<'_, Result<bool>> {
- unimplemented!()
+ let executor = self.executor.clone();
+ let checkpoints = self.checkpoints.clone();
+ async move {
+ executor.simulate_random_delay().await;
+ let checkpoints = checkpoints.lock();
+ let left = checkpoints
+ .get(&left.commit_sha)
+ .context(format!("invalid left checkpoint: {}", left.commit_sha))?;
+ let right = checkpoints
+ .get(&right.commit_sha)
+ .context(format!("invalid right checkpoint: {}", right.commit_sha))?;
+
+ Ok(left == right)
+ }
+ .boxed()
}
fn diff_checkpoints(
@@ -479,4 +532,68 @@ impl GitRepository for FakeGitRepository {
) -> BoxFuture<'_, Result<String>> {
unimplemented!()
}
+
+ fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
+ unimplemented!()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::{FakeFs, Fs};
+ use gpui::BackgroundExecutor;
+ use serde_json::json;
+ use std::path::Path;
+ use util::path;
+
+ #[gpui::test]
+ async fn test_checkpoints(executor: BackgroundExecutor) {
+ let fs = FakeFs::new(executor);
+ fs.insert_tree(
+ path!("/"),
+ json!({
+ "bar": {
+ "baz": "qux"
+ },
+ "foo": {
+ ".git": {},
+ "a": "lorem",
+ "b": "ipsum",
+ },
+ }),
+ )
+ .await;
+ fs.with_git_state(Path::new("/foo/.git"), true, |_git| {})
+ .unwrap();
+ let repository = fs.open_repo(Path::new("/foo/.git")).unwrap();
+
+ let checkpoint_1 = repository.checkpoint().await.unwrap();
+ fs.write(Path::new("/foo/b"), b"IPSUM").await.unwrap();
+ fs.write(Path::new("/foo/c"), b"dolor").await.unwrap();
+ let checkpoint_2 = repository.checkpoint().await.unwrap();
+ let checkpoint_3 = repository.checkpoint().await.unwrap();
+
+ assert!(
+ repository
+ .compare_checkpoints(checkpoint_2.clone(), checkpoint_3.clone())
+ .await
+ .unwrap()
+ );
+ assert!(
+ !repository
+ .compare_checkpoints(checkpoint_1.clone(), checkpoint_2.clone())
+ .await
+ .unwrap()
+ );
+
+ repository.restore_checkpoint(checkpoint_1).await.unwrap();
+ assert_eq!(
+ fs.files_with_contents(Path::new("")),
+ [
+ (Path::new(path!("/bar/baz")).into(), b"qux".into()),
+ (Path::new(path!("/foo/a")).into(), b"lorem".into()),
+ (Path::new(path!("/foo/b")).into(), b"ipsum".into())
+ ]
+ );
+ }
}
@@ -12,7 +12,7 @@ use gpui::BackgroundExecutor;
use gpui::Global;
use gpui::ReadGlobal as _;
use std::borrow::Cow;
-use util::command::new_std_command;
+use util::command::{new_smol_command, new_std_command};
#[cfg(unix)]
use std::os::fd::{AsFd, AsRawFd};
@@ -134,6 +134,7 @@ pub trait Fs: Send + Sync {
fn home_dir(&self) -> Option<PathBuf>;
fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<dyn GitRepository>>;
fn git_init(&self, abs_work_directory: &Path, fallback_branch_name: String) -> Result<()>;
+ async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()>;
fn is_fake(&self) -> bool;
async fn is_case_sensitive(&self) -> Result<bool>;
@@ -419,18 +420,19 @@ impl Fs for RealFs {
async fn remove_file(&self, path: &Path, options: RemoveOptions) -> Result<()> {
#[cfg(windows)]
- if let Ok(Some(metadata)) = self.metadata(path).await {
- if metadata.is_symlink && metadata.is_dir {
- self.remove_dir(
- path,
- RemoveOptions {
- recursive: false,
- ignore_if_not_exists: true,
- },
- )
- .await?;
- return Ok(());
- }
+ if let Ok(Some(metadata)) = self.metadata(path).await
+ && metadata.is_symlink
+ && metadata.is_dir
+ {
+ self.remove_dir(
+ path,
+ RemoveOptions {
+ recursive: false,
+ ignore_if_not_exists: true,
+ },
+ )
+ .await?;
+ return Ok(());
}
match smol::fs::remove_file(path).await {
@@ -466,11 +468,11 @@ impl Fs for RealFs {
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
async fn trash_file(&self, path: &Path, _options: RemoveOptions) -> Result<()> {
- if let Ok(Some(metadata)) = self.metadata(path).await {
- if metadata.is_symlink {
- // TODO: trash_file does not support trashing symlinks yet - https://github.com/bilelmoussaoui/ashpd/issues/255
- return self.remove_file(path, RemoveOptions::default()).await;
- }
+ if let Ok(Some(metadata)) = self.metadata(path).await
+ && metadata.is_symlink
+ {
+ // TODO: trash_file does not support trashing symlinks yet - https://github.com/bilelmoussaoui/ashpd/issues/255
+ return self.remove_file(path, RemoveOptions::default()).await;
}
let file = smol::fs::File::open(path).await?;
match trash::trash_file(&file.as_fd()).await {
@@ -623,13 +625,13 @@ impl Fs for RealFs {
async fn is_file(&self, path: &Path) -> bool {
smol::fs::metadata(path)
.await
- .map_or(false, |metadata| metadata.is_file())
+ .is_ok_and(|metadata| metadata.is_file())
}
async fn is_dir(&self, path: &Path) -> bool {
smol::fs::metadata(path)
.await
- .map_or(false, |metadata| metadata.is_dir())
+ .is_ok_and(|metadata| metadata.is_dir())
}
async fn metadata(&self, path: &Path) -> Result<Option<Metadata>> {
@@ -765,24 +767,23 @@ impl Fs for RealFs {
let pending_paths: Arc<Mutex<Vec<PathEvent>>> = Default::default();
let watcher = Arc::new(fs_watcher::FsWatcher::new(tx, pending_paths.clone()));
- if watcher.add(path).is_err() {
- // If the path doesn't exist yet (e.g. settings.json), watch the parent dir to learn when it's created.
- if let Some(parent) = path.parent() {
- if let Err(e) = watcher.add(parent) {
- log::warn!("Failed to watch: {e}");
- }
- }
+ // If the path doesn't exist yet (e.g. settings.json), watch the parent dir to learn when it's created.
+ if watcher.add(path).is_err()
+ && let Some(parent) = path.parent()
+ && let Err(e) = watcher.add(parent)
+ {
+ log::warn!("Failed to watch: {e}");
}
// Check if path is a symlink and follow the target parent
- if let Some(mut target) = self.read_link(&path).await.ok() {
+ if let Some(mut target) = self.read_link(path).await.ok() {
// Check if symlink target is relative path, if so make it absolute
- if target.is_relative() {
- if let Some(parent) = path.parent() {
- target = parent.join(target);
- if let Ok(canonical) = self.canonicalize(&target).await {
- target = SanitizedPath::from(canonical).as_path().to_path_buf();
- }
+ if target.is_relative()
+ && let Some(parent) = path.parent()
+ {
+ target = parent.join(target);
+ if let Ok(canonical) = self.canonicalize(&target).await {
+ target = SanitizedPath::from(canonical).as_path().to_path_buf();
}
}
watcher.add(&target).ok();
@@ -839,6 +840,23 @@ impl Fs for RealFs {
Ok(())
}
+ async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()> {
+ let output = new_smol_command("git")
+ .current_dir(abs_work_directory)
+ .args(&["clone", repo_url])
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ anyhow::bail!(
+ "git clone failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+
+ Ok(())
+ }
+
fn is_fake(&self) -> bool {
false
}
@@ -906,7 +924,7 @@ pub struct FakeFs {
#[cfg(any(test, feature = "test-support"))]
struct FakeFsState {
- root: Arc<Mutex<FakeFsEntry>>,
+ root: FakeFsEntry,
next_inode: u64,
next_mtime: SystemTime,
git_event_tx: smol::channel::Sender<PathBuf>,
@@ -921,7 +939,7 @@ struct FakeFsState {
}
#[cfg(any(test, feature = "test-support"))]
-#[derive(Debug)]
+#[derive(Clone, Debug)]
enum FakeFsEntry {
File {
inode: u64,
@@ -935,7 +953,7 @@ enum FakeFsEntry {
inode: u64,
mtime: MTime,
len: u64,
- entries: BTreeMap<String, Arc<Mutex<FakeFsEntry>>>,
+ entries: BTreeMap<String, FakeFsEntry>,
git_repo_state: Option<Arc<Mutex<FakeGitRepositoryState>>>,
},
Symlink {
@@ -943,6 +961,67 @@ enum FakeFsEntry {
},
}
+#[cfg(any(test, feature = "test-support"))]
+impl PartialEq for FakeFsEntry {
+ fn eq(&self, other: &Self) -> bool {
+ match (self, other) {
+ (
+ Self::File {
+ inode: l_inode,
+ mtime: l_mtime,
+ len: l_len,
+ content: l_content,
+ git_dir_path: l_git_dir_path,
+ },
+ Self::File {
+ inode: r_inode,
+ mtime: r_mtime,
+ len: r_len,
+ content: r_content,
+ git_dir_path: r_git_dir_path,
+ },
+ ) => {
+ l_inode == r_inode
+ && l_mtime == r_mtime
+ && l_len == r_len
+ && l_content == r_content
+ && l_git_dir_path == r_git_dir_path
+ }
+ (
+ Self::Dir {
+ inode: l_inode,
+ mtime: l_mtime,
+ len: l_len,
+ entries: l_entries,
+ git_repo_state: l_git_repo_state,
+ },
+ Self::Dir {
+ inode: r_inode,
+ mtime: r_mtime,
+ len: r_len,
+ entries: r_entries,
+ git_repo_state: r_git_repo_state,
+ },
+ ) => {
+ let same_repo_state = match (l_git_repo_state.as_ref(), r_git_repo_state.as_ref()) {
+ (Some(l), Some(r)) => Arc::ptr_eq(l, r),
+ (None, None) => true,
+ _ => false,
+ };
+ l_inode == r_inode
+ && l_mtime == r_mtime
+ && l_len == r_len
+ && l_entries == r_entries
+ && same_repo_state
+ }
+ (Self::Symlink { target: l_target }, Self::Symlink { target: r_target }) => {
+ l_target == r_target
+ }
+ _ => false,
+ }
+ }
+}
+
#[cfg(any(test, feature = "test-support"))]
impl FakeFsState {
fn get_and_increment_mtime(&mut self) -> MTime {
@@ -957,25 +1036,9 @@ impl FakeFsState {
inode
}
- fn read_path(&self, target: &Path) -> Result<Arc<Mutex<FakeFsEntry>>> {
- Ok(self
- .try_read_path(target, true)
- .ok_or_else(|| {
- anyhow!(io::Error::new(
- io::ErrorKind::NotFound,
- format!("not found: {target:?}")
- ))
- })?
- .0)
- }
-
- fn try_read_path(
- &self,
- target: &Path,
- follow_symlink: bool,
- ) -> Option<(Arc<Mutex<FakeFsEntry>>, PathBuf)> {
- let mut path = target.to_path_buf();
+ fn canonicalize(&self, target: &Path, follow_symlink: bool) -> Option<PathBuf> {
let mut canonical_path = PathBuf::new();
+ let mut path = target.to_path_buf();
let mut entry_stack = Vec::new();
'outer: loop {
let mut path_components = path.components().peekable();
@@ -985,7 +1048,7 @@ impl FakeFsState {
Component::Prefix(prefix_component) => prefix = Some(prefix_component),
Component::RootDir => {
entry_stack.clear();
- entry_stack.push(self.root.clone());
+ entry_stack.push(&self.root);
canonical_path.clear();
match prefix {
Some(prefix_component) => {
@@ -1002,20 +1065,18 @@ impl FakeFsState {
canonical_path.pop();
}
Component::Normal(name) => {
- let current_entry = entry_stack.last().cloned()?;
- let current_entry = current_entry.lock();
- if let FakeFsEntry::Dir { entries, .. } = &*current_entry {
- let entry = entries.get(name.to_str().unwrap()).cloned()?;
- if path_components.peek().is_some() || follow_symlink {
- let entry = entry.lock();
- if let FakeFsEntry::Symlink { target, .. } = &*entry {
- let mut target = target.clone();
- target.extend(path_components);
- path = target;
- continue 'outer;
- }
+ let current_entry = *entry_stack.last()?;
+ if let FakeFsEntry::Dir { entries, .. } = current_entry {
+ let entry = entries.get(name.to_str().unwrap())?;
+ if (path_components.peek().is_some() || follow_symlink)
+ && let FakeFsEntry::Symlink { target, .. } = entry
+ {
+ let mut target = target.clone();
+ target.extend(path_components);
+ path = target;
+ continue 'outer;
}
- entry_stack.push(entry.clone());
+ entry_stack.push(entry);
canonical_path = canonical_path.join(name);
} else {
return None;
@@ -1025,19 +1086,74 @@ impl FakeFsState {
}
break;
}
- Some((entry_stack.pop()?, canonical_path))
+
+ if entry_stack.is_empty() {
+ None
+ } else {
+ Some(canonical_path)
+ }
+ }
+
+ fn try_entry(
+ &mut self,
+ target: &Path,
+ follow_symlink: bool,
+ ) -> Option<(&mut FakeFsEntry, PathBuf)> {
+ let canonical_path = self.canonicalize(target, follow_symlink)?;
+
+ let mut components = canonical_path
+ .components()
+ .skip_while(|component| matches!(component, Component::Prefix(_)));
+ let Some(Component::RootDir) = components.next() else {
+ panic!(
+ "the path {:?} was not canonicalized properly {:?}",
+ target, canonical_path
+ )
+ };
+
+ let mut entry = &mut self.root;
+ for component in components {
+ match component {
+ Component::Normal(name) => {
+ if let FakeFsEntry::Dir { entries, .. } = entry {
+ entry = entries.get_mut(name.to_str().unwrap())?;
+ } else {
+ return None;
+ }
+ }
+ _ => {
+ panic!(
+ "the path {:?} was not canonicalized properly {:?}",
+ target, canonical_path
+ )
+ }
+ }
+ }
+
+ Some((entry, canonical_path))
+ }
+
+ fn entry(&mut self, target: &Path) -> Result<&mut FakeFsEntry> {
+ Ok(self
+ .try_entry(target, true)
+ .ok_or_else(|| {
+ anyhow!(io::Error::new(
+ io::ErrorKind::NotFound,
+ format!("not found: {target:?}")
+ ))
+ })?
+ .0)
}
- fn write_path<Fn, T>(&self, path: &Path, callback: Fn) -> Result<T>
+ fn write_path<Fn, T>(&mut self, path: &Path, callback: Fn) -> Result<T>
where
- Fn: FnOnce(btree_map::Entry<String, Arc<Mutex<FakeFsEntry>>>) -> Result<T>,
+ Fn: FnOnce(btree_map::Entry<String, FakeFsEntry>) -> Result<T>,
{
let path = normalize_path(path);
let filename = path.file_name().context("cannot overwrite the root")?;
let parent_path = path.parent().unwrap();
- let parent = self.read_path(parent_path)?;
- let mut parent = parent.lock();
+ let parent = self.entry(parent_path)?;
let new_entry = parent
.dir_entries(parent_path)?
.entry(filename.to_str().unwrap().into());
@@ -1087,13 +1203,13 @@ impl FakeFs {
this: this.clone(),
executor: executor.clone(),
state: Arc::new(Mutex::new(FakeFsState {
- root: Arc::new(Mutex::new(FakeFsEntry::Dir {
+ root: FakeFsEntry::Dir {
inode: 0,
mtime: MTime(UNIX_EPOCH),
len: 0,
entries: Default::default(),
git_repo_state: None,
- })),
+ },
git_event_tx: tx,
next_mtime: UNIX_EPOCH + Self::SYSTEMTIME_INTERVAL,
next_inode: 1,
@@ -1143,15 +1259,15 @@ impl FakeFs {
.write_path(path, move |entry| {
match entry {
btree_map::Entry::Vacant(e) => {
- e.insert(Arc::new(Mutex::new(FakeFsEntry::File {
+ e.insert(FakeFsEntry::File {
inode: new_inode,
mtime: new_mtime,
content: Vec::new(),
len: 0,
git_dir_path: None,
- })));
+ });
}
- btree_map::Entry::Occupied(mut e) => match &mut *e.get_mut().lock() {
+ btree_map::Entry::Occupied(mut e) => match &mut *e.get_mut() {
FakeFsEntry::File { mtime, .. } => *mtime = new_mtime,
FakeFsEntry::Dir { mtime, .. } => *mtime = new_mtime,
FakeFsEntry::Symlink { .. } => {}
@@ -1170,7 +1286,7 @@ impl FakeFs {
pub async fn insert_symlink(&self, path: impl AsRef<Path>, target: PathBuf) {
let mut state = self.state.lock();
let path = path.as_ref();
- let file = Arc::new(Mutex::new(FakeFsEntry::Symlink { target }));
+ let file = FakeFsEntry::Symlink { target };
state
.write_path(path.as_ref(), move |e| match e {
btree_map::Entry::Vacant(e) => {
@@ -1203,13 +1319,13 @@ impl FakeFs {
match entry {
btree_map::Entry::Vacant(e) => {
kind = Some(PathEventKind::Created);
- e.insert(Arc::new(Mutex::new(FakeFsEntry::File {
+ e.insert(FakeFsEntry::File {
inode: new_inode,
mtime: new_mtime,
len: new_len,
content: new_content,
git_dir_path: None,
- })));
+ });
}
btree_map::Entry::Occupied(mut e) => {
kind = Some(PathEventKind::Changed);
@@ -1219,7 +1335,7 @@ impl FakeFs {
len,
content,
..
- } = &mut *e.get_mut().lock()
+ } = e.get_mut()
{
*mtime = new_mtime;
*content = new_content;
@@ -1241,9 +1357,8 @@ impl FakeFs {
pub fn read_file_sync(&self, path: impl AsRef<Path>) -> Result<Vec<u8>> {
let path = path.as_ref();
let path = normalize_path(path);
- let state = self.state.lock();
- let entry = state.read_path(&path)?;
- let entry = entry.lock();
+ let mut state = self.state.lock();
+ let entry = state.entry(&path)?;
entry.file_content(&path).cloned()
}
@@ -1251,9 +1366,8 @@ impl FakeFs {
let path = path.as_ref();
let path = normalize_path(path);
self.simulate_random_delay().await;
- let state = self.state.lock();
- let entry = state.read_path(&path)?;
- let entry = entry.lock();
+ let mut state = self.state.lock();
+ let entry = state.entry(&path)?;
entry.file_content(&path).cloned()
}
@@ -1274,6 +1388,25 @@ impl FakeFs {
self.state.lock().flush_events(count);
}
+ pub(crate) fn entry(&self, target: &Path) -> Result<FakeFsEntry> {
+ self.state.lock().entry(target).cloned()
+ }
+
+ pub(crate) fn insert_entry(&self, target: &Path, new_entry: FakeFsEntry) -> Result<()> {
+ let mut state = self.state.lock();
+ state.write_path(target, |entry| {
+ match entry {
+ btree_map::Entry::Vacant(vacant_entry) => {
+ vacant_entry.insert(new_entry);
+ }
+ btree_map::Entry::Occupied(mut occupied_entry) => {
+ occupied_entry.insert(new_entry);
+ }
+ }
+ Ok(())
+ })
+ }
+
#[must_use]
pub fn insert_tree<'a>(
&'a self,
@@ -1343,20 +1476,19 @@ impl FakeFs {
F: FnOnce(&mut FakeGitRepositoryState, &Path, &Path) -> T,
{
let mut state = self.state.lock();
- let entry = state.read_path(dot_git).context("open .git")?;
- let mut entry = entry.lock();
+ let git_event_tx = state.git_event_tx.clone();
+ let entry = state.entry(dot_git).context("open .git")?;
- if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry {
+ if let FakeFsEntry::Dir { git_repo_state, .. } = entry {
let repo_state = git_repo_state.get_or_insert_with(|| {
log::debug!("insert git state for {dot_git:?}");
- Arc::new(Mutex::new(FakeGitRepositoryState::new(
- state.git_event_tx.clone(),
- )))
+ Arc::new(Mutex::new(FakeGitRepositoryState::new(git_event_tx)))
});
let mut repo_state = repo_state.lock();
let result = f(&mut repo_state, dot_git, dot_git);
+ drop(repo_state);
if emit_git_event {
state.emit_event([(dot_git, None)]);
}
@@ -1380,21 +1512,20 @@ impl FakeFs {
}
}
.clone();
- drop(entry);
- let Some((git_dir_entry, canonical_path)) = state.try_read_path(&path, true) else {
+ let Some((git_dir_entry, canonical_path)) = state.try_entry(&path, true) else {
anyhow::bail!("pointed-to git dir {path:?} not found")
};
let FakeFsEntry::Dir {
git_repo_state,
entries,
..
- } = &mut *git_dir_entry.lock()
+ } = git_dir_entry
else {
anyhow::bail!("gitfile points to a non-directory")
};
let common_dir = if let Some(child) = entries.get("commondir") {
Path::new(
- std::str::from_utf8(child.lock().file_content("commondir".as_ref())?)
+ std::str::from_utf8(child.file_content("commondir".as_ref())?)
.context("commondir content")?,
)
.to_owned()
@@ -1402,15 +1533,14 @@ impl FakeFs {
canonical_path.clone()
};
let repo_state = git_repo_state.get_or_insert_with(|| {
- Arc::new(Mutex::new(FakeGitRepositoryState::new(
- state.git_event_tx.clone(),
- )))
+ Arc::new(Mutex::new(FakeGitRepositoryState::new(git_event_tx)))
});
let mut repo_state = repo_state.lock();
let result = f(&mut repo_state, &canonical_path, &common_dir);
if emit_git_event {
+ drop(repo_state);
state.emit_event([(canonical_path, None)]);
}
@@ -1438,10 +1568,10 @@ impl FakeFs {
pub fn insert_branches(&self, dot_git: &Path, branches: &[&str]) {
self.with_git_state(dot_git, true, |state| {
- if let Some(first) = branches.first() {
- if state.current_branch_name.is_none() {
- state.current_branch_name = Some(first.to_string())
- }
+ if let Some(first) = branches.first()
+ && state.current_branch_name.is_none()
+ {
+ state.current_branch_name = Some(first.to_string())
}
state
.branches
@@ -1549,7 +1679,7 @@ impl FakeFs {
/// by mutating the head, index, and unmerged state.
pub fn set_status_for_repo(&self, dot_git: &Path, statuses: &[(&Path, FileStatus)]) {
let workdir_path = dot_git.parent().unwrap();
- let workdir_contents = self.files_with_contents(&workdir_path);
+ let workdir_contents = self.files_with_contents(workdir_path);
self.with_git_state(dot_git, true, |state| {
state.index_contents.clear();
state.head_contents.clear();
@@ -1637,14 +1767,12 @@ impl FakeFs {
pub fn paths(&self, include_dot_git: bool) -> Vec<PathBuf> {
let mut result = Vec::new();
let mut queue = collections::VecDeque::new();
- queue.push_back((
- PathBuf::from(util::path!("/")),
- self.state.lock().root.clone(),
- ));
+ let state = &*self.state.lock();
+ queue.push_back((PathBuf::from(util::path!("/")), &state.root));
while let Some((path, entry)) = queue.pop_front() {
- if let FakeFsEntry::Dir { entries, .. } = &*entry.lock() {
+ if let FakeFsEntry::Dir { entries, .. } = entry {
for (name, entry) in entries {
- queue.push_back((path.join(name), entry.clone()));
+ queue.push_back((path.join(name), entry));
}
}
if include_dot_git
@@ -1661,14 +1789,12 @@ impl FakeFs {
pub fn directories(&self, include_dot_git: bool) -> Vec<PathBuf> {
let mut result = Vec::new();
let mut queue = collections::VecDeque::new();
- queue.push_back((
- PathBuf::from(util::path!("/")),
- self.state.lock().root.clone(),
- ));
+ let state = &*self.state.lock();
+ queue.push_back((PathBuf::from(util::path!("/")), &state.root));
while let Some((path, entry)) = queue.pop_front() {
- if let FakeFsEntry::Dir { entries, .. } = &*entry.lock() {
+ if let FakeFsEntry::Dir { entries, .. } = entry {
for (name, entry) in entries {
- queue.push_back((path.join(name), entry.clone()));
+ queue.push_back((path.join(name), entry));
}
if include_dot_git
|| !path
@@ -1685,17 +1811,14 @@ impl FakeFs {
pub fn files(&self) -> Vec<PathBuf> {
let mut result = Vec::new();
let mut queue = collections::VecDeque::new();
- queue.push_back((
- PathBuf::from(util::path!("/")),
- self.state.lock().root.clone(),
- ));
+ let state = &*self.state.lock();
+ queue.push_back((PathBuf::from(util::path!("/")), &state.root));
while let Some((path, entry)) = queue.pop_front() {
- let e = entry.lock();
- match &*e {
+ match entry {
FakeFsEntry::File { .. } => result.push(path),
FakeFsEntry::Dir { entries, .. } => {
for (name, entry) in entries {
- queue.push_back((path.join(name), entry.clone()));
+ queue.push_back((path.join(name), entry));
}
}
FakeFsEntry::Symlink { .. } => {}
@@ -1707,13 +1830,10 @@ impl FakeFs {
pub fn files_with_contents(&self, prefix: &Path) -> Vec<(PathBuf, Vec<u8>)> {
let mut result = Vec::new();
let mut queue = collections::VecDeque::new();
- queue.push_back((
- PathBuf::from(util::path!("/")),
- self.state.lock().root.clone(),
- ));
+ let state = &*self.state.lock();
+ queue.push_back((PathBuf::from(util::path!("/")), &state.root));
while let Some((path, entry)) = queue.pop_front() {
- let e = entry.lock();
- match &*e {
+ match entry {
FakeFsEntry::File { content, .. } => {
if path.starts_with(prefix) {
result.push((path, content.clone()));
@@ -1721,7 +1841,7 @@ impl FakeFs {
}
FakeFsEntry::Dir { entries, .. } => {
for (name, entry) in entries {
- queue.push_back((path.join(name), entry.clone()));
+ queue.push_back((path.join(name), entry));
}
}
FakeFsEntry::Symlink { .. } => {}
@@ -1787,10 +1907,7 @@ impl FakeFsEntry {
}
}
- fn dir_entries(
- &mut self,
- path: &Path,
- ) -> Result<&mut BTreeMap<String, Arc<Mutex<FakeFsEntry>>>> {
+ fn dir_entries(&mut self, path: &Path) -> Result<&mut BTreeMap<String, FakeFsEntry>> {
if let Self::Dir { entries, .. } = self {
Ok(entries)
} else {
@@ -1837,13 +1954,13 @@ struct FakeHandle {
impl FileHandle for FakeHandle {
fn current_path(&self, fs: &Arc<dyn Fs>) -> Result<PathBuf> {
let fs = fs.as_fake();
- let state = fs.state.lock();
- let Some(target) = state.moves.get(&self.inode) else {
+ let mut state = fs.state.lock();
+ let Some(target) = state.moves.get(&self.inode).cloned() else {
anyhow::bail!("fake fd not moved")
};
- if state.try_read_path(&target, false).is_some() {
- return Ok(target.clone());
+ if state.try_entry(&target, false).is_some() {
+ return Ok(target);
}
anyhow::bail!("fake fd target not found")
}
@@ -1870,13 +1987,13 @@ impl Fs for FakeFs {
state.write_path(&cur_path, |entry| {
entry.or_insert_with(|| {
created_dirs.push((cur_path.clone(), Some(PathEventKind::Created)));
- Arc::new(Mutex::new(FakeFsEntry::Dir {
+ FakeFsEntry::Dir {
inode,
mtime,
len: 0,
entries: Default::default(),
git_repo_state: None,
- }))
+ }
});
Ok(())
})?
@@ -1891,13 +2008,13 @@ impl Fs for FakeFs {
let mut state = self.state.lock();
let inode = state.get_and_increment_inode();
let mtime = state.get_and_increment_mtime();
- let file = Arc::new(Mutex::new(FakeFsEntry::File {
+ let file = FakeFsEntry::File {
inode,
mtime,
len: 0,
content: Vec::new(),
git_dir_path: None,
- }));
+ };
let mut kind = Some(PathEventKind::Created);
state.write_path(path, |entry| {
match entry {
@@ -1921,7 +2038,7 @@ impl Fs for FakeFs {
async fn create_symlink(&self, path: &Path, target: PathBuf) -> Result<()> {
let mut state = self.state.lock();
- let file = Arc::new(Mutex::new(FakeFsEntry::Symlink { target }));
+ let file = FakeFsEntry::Symlink { target };
state
.write_path(path.as_ref(), move |e| match e {
btree_map::Entry::Vacant(e) => {
@@ -1984,7 +2101,7 @@ impl Fs for FakeFs {
}
})?;
- let inode = match *moved_entry.lock() {
+ let inode = match moved_entry {
FakeFsEntry::File { inode, .. } => inode,
FakeFsEntry::Dir { inode, .. } => inode,
_ => 0,
@@ -2033,8 +2150,8 @@ impl Fs for FakeFs {
let mut state = self.state.lock();
let mtime = state.get_and_increment_mtime();
let inode = state.get_and_increment_inode();
- let source_entry = state.read_path(&source)?;
- let content = source_entry.lock().file_content(&source)?.clone();
+ let source_entry = state.entry(&source)?;
+ let content = source_entry.file_content(&source)?.clone();
let mut kind = Some(PathEventKind::Created);
state.write_path(&target, |e| match e {
btree_map::Entry::Occupied(e) => {
@@ -2048,13 +2165,13 @@ impl Fs for FakeFs {
}
}
btree_map::Entry::Vacant(e) => Ok(Some(
- e.insert(Arc::new(Mutex::new(FakeFsEntry::File {
+ e.insert(FakeFsEntry::File {
inode,
mtime,
len: content.len() as u64,
content,
git_dir_path: None,
- })))
+ })
.clone(),
)),
})?;
@@ -2070,8 +2187,7 @@ impl Fs for FakeFs {
let base_name = path.file_name().context("cannot remove the root")?;
let mut state = self.state.lock();
- let parent_entry = state.read_path(parent_path)?;
- let mut parent_entry = parent_entry.lock();
+ let parent_entry = state.entry(parent_path)?;
let entry = parent_entry
.dir_entries(parent_path)?
.entry(base_name.to_str().unwrap().into());
@@ -2082,15 +2198,14 @@ impl Fs for FakeFs {
anyhow::bail!("{path:?} does not exist");
}
}
- btree_map::Entry::Occupied(e) => {
+ btree_map::Entry::Occupied(mut entry) => {
{
- let mut entry = e.get().lock();
- let children = entry.dir_entries(&path)?;
+ let children = entry.get_mut().dir_entries(&path)?;
if !options.recursive && !children.is_empty() {
anyhow::bail!("{path:?} is not empty");
}
}
- e.remove();
+ entry.remove();
}
}
state.emit_event([(path, Some(PathEventKind::Removed))]);
@@ -2104,8 +2219,7 @@ impl Fs for FakeFs {
let parent_path = path.parent().context("cannot remove the root")?;
let base_name = path.file_name().unwrap();
let mut state = self.state.lock();
- let parent_entry = state.read_path(parent_path)?;
- let mut parent_entry = parent_entry.lock();
+ let parent_entry = state.entry(parent_path)?;
let entry = parent_entry
.dir_entries(parent_path)?
.entry(base_name.to_str().unwrap().into());
@@ -2115,9 +2229,9 @@ impl Fs for FakeFs {
anyhow::bail!("{path:?} does not exist");
}
}
- btree_map::Entry::Occupied(e) => {
- e.get().lock().file_content(&path)?;
- e.remove();
+ btree_map::Entry::Occupied(mut entry) => {
+ entry.get_mut().file_content(&path)?;
+ entry.remove();
}
}
state.emit_event([(path, Some(PathEventKind::Removed))]);
@@ -2131,12 +2245,10 @@ impl Fs for FakeFs {
async fn open_handle(&self, path: &Path) -> Result<Arc<dyn FileHandle>> {
self.simulate_random_delay().await;
- let state = self.state.lock();
- let entry = state.read_path(&path)?;
- let entry = entry.lock();
- let inode = match *entry {
- FakeFsEntry::File { inode, .. } => inode,
- FakeFsEntry::Dir { inode, .. } => inode,
+ let mut state = self.state.lock();
+ let inode = match state.entry(path)? {
+ FakeFsEntry::File { inode, .. } => *inode,
+ FakeFsEntry::Dir { inode, .. } => *inode,
_ => unreachable!(),
};
Ok(Arc::new(FakeHandle { inode }))
@@ -2144,7 +2256,7 @@ impl Fs for FakeFs {
async fn load(&self, path: &Path) -> Result<String> {
let content = self.load_internal(path).await?;
- Ok(String::from_utf8(content.clone())?)
+ Ok(String::from_utf8(content)?)
}
async fn load_bytes(&self, path: &Path) -> Result<Vec<u8>> {
@@ -2154,6 +2266,9 @@ impl Fs for FakeFs {
async fn atomic_write(&self, path: PathBuf, data: String) -> Result<()> {
self.simulate_random_delay().await;
let path = normalize_path(path.as_path());
+ if let Some(path) = path.parent() {
+ self.create_dir(path).await?;
+ }
self.write_file_internal(path, data.into_bytes(), true)?;
Ok(())
}
@@ -2183,8 +2298,8 @@ impl Fs for FakeFs {
let path = normalize_path(path);
self.simulate_random_delay().await;
let state = self.state.lock();
- let (_, canonical_path) = state
- .try_read_path(&path, true)
+ let canonical_path = state
+ .canonicalize(&path, true)
.with_context(|| format!("path does not exist: {path:?}"))?;
Ok(canonical_path)
}
@@ -2192,9 +2307,9 @@ impl Fs for FakeFs {
async fn is_file(&self, path: &Path) -> bool {
let path = normalize_path(path);
self.simulate_random_delay().await;
- let state = self.state.lock();
- if let Some((entry, _)) = state.try_read_path(&path, true) {
- entry.lock().is_file()
+ let mut state = self.state.lock();
+ if let Some((entry, _)) = state.try_entry(&path, true) {
+ entry.is_file()
} else {
false
}
@@ -2211,17 +2326,16 @@ impl Fs for FakeFs {
let path = normalize_path(path);
let mut state = self.state.lock();
state.metadata_call_count += 1;
- if let Some((mut entry, _)) = state.try_read_path(&path, false) {
- let is_symlink = entry.lock().is_symlink();
+ if let Some((mut entry, _)) = state.try_entry(&path, false) {
+ let is_symlink = entry.is_symlink();
if is_symlink {
- if let Some(e) = state.try_read_path(&path, true).map(|e| e.0) {
+ if let Some(e) = state.try_entry(&path, true).map(|e| e.0) {
entry = e;
} else {
return Ok(None);
}
}
- let entry = entry.lock();
Ok(Some(match &*entry {
FakeFsEntry::File {
inode, mtime, len, ..
@@ -2253,12 +2367,11 @@ impl Fs for FakeFs {
async fn read_link(&self, path: &Path) -> Result<PathBuf> {
self.simulate_random_delay().await;
let path = normalize_path(path);
- let state = self.state.lock();
+ let mut state = self.state.lock();
let (entry, _) = state
- .try_read_path(&path, false)
+ .try_entry(&path, false)
.with_context(|| format!("path does not exist: {path:?}"))?;
- let entry = entry.lock();
- if let FakeFsEntry::Symlink { target } = &*entry {
+ if let FakeFsEntry::Symlink { target } = entry {
Ok(target.clone())
} else {
anyhow::bail!("not a symlink: {path:?}")
@@ -2273,8 +2386,7 @@ impl Fs for FakeFs {
let path = normalize_path(path);
let mut state = self.state.lock();
state.read_dir_call_count += 1;
- let entry = state.read_path(&path)?;
- let mut entry = entry.lock();
+ let entry = state.entry(&path)?;
let children = entry.dir_entries(&path)?;
let paths = children
.keys()
@@ -2300,19 +2412,18 @@ impl Fs for FakeFs {
tx,
original_path: path.to_owned(),
fs_state: self.state.clone(),
- prefixes: Mutex::new(vec![path.to_owned()]),
+ prefixes: Mutex::new(vec![path]),
});
(
Box::pin(futures::StreamExt::filter(rx, {
let watcher = watcher.clone();
move |events| {
let result = events.iter().any(|evt_path| {
- let result = watcher
+ watcher
.prefixes
.lock()
.iter()
- .any(|prefix| evt_path.path.starts_with(prefix));
- result
+ .any(|prefix| evt_path.path.starts_with(prefix))
});
let executor = executor.clone();
async move {
@@ -2338,6 +2449,7 @@ impl Fs for FakeFs {
dot_git_path: abs_dot_git.to_path_buf(),
repository_dir_path: repository_dir_path.to_owned(),
common_dir_path: common_dir_path.to_owned(),
+ checkpoints: Arc::default(),
}) as _
},
)
@@ -2352,6 +2464,10 @@ impl Fs for FakeFs {
smol::block_on(self.create_dir(&abs_work_directory_path.join(".git")))
}
+ async fn git_clone(&self, _repo_url: &str, _abs_work_directory: &Path) -> Result<()> {
+ anyhow::bail!("Git clone is not supported in fake Fs")
+ }
+
fn is_fake(&self) -> bool {
true
}
@@ -1,6 +1,9 @@
use notify::EventKind;
use parking_lot::Mutex;
-use std::sync::{Arc, OnceLock};
+use std::{
+ collections::HashMap,
+ sync::{Arc, OnceLock},
+};
use util::{ResultExt, paths::SanitizedPath};
use crate::{PathEvent, PathEventKind, Watcher};
@@ -8,6 +11,7 @@ use crate::{PathEvent, PathEventKind, Watcher};
pub struct FsWatcher {
tx: smol::channel::Sender<()>,
pending_path_events: Arc<Mutex<Vec<PathEvent>>>,
+ registrations: Mutex<HashMap<Arc<std::path::Path>, WatcherRegistrationId>>,
}
impl FsWatcher {
@@ -18,10 +22,24 @@ impl FsWatcher {
Self {
tx,
pending_path_events,
+ registrations: Default::default(),
}
}
}
+impl Drop for FsWatcher {
+ fn drop(&mut self) {
+ let mut registrations = self.registrations.lock();
+ let registrations = registrations.drain();
+
+ let _ = global(|g| {
+ for (_, registration) in registrations {
+ g.remove(registration);
+ }
+ });
+ }
+}
+
impl Watcher for FsWatcher {
fn add(&self, path: &std::path::Path) -> anyhow::Result<()> {
let root_path = SanitizedPath::from(path);
@@ -29,75 +47,143 @@ impl Watcher for FsWatcher {
let tx = self.tx.clone();
let pending_paths = self.pending_path_events.clone();
- use notify::Watcher;
+ let path: Arc<std::path::Path> = path.into();
+
+ if self.registrations.lock().contains_key(&path) {
+ return Ok(());
+ }
- global({
+ let registration_id = global({
+ let path = path.clone();
|g| {
- g.add(move |event: ¬ify::Event| {
- let kind = match event.kind {
- EventKind::Create(_) => Some(PathEventKind::Created),
- EventKind::Modify(_) => Some(PathEventKind::Changed),
- EventKind::Remove(_) => Some(PathEventKind::Removed),
- _ => None,
- };
- let mut path_events = event
- .paths
- .iter()
- .filter_map(|event_path| {
- let event_path = SanitizedPath::from(event_path);
- event_path.starts_with(&root_path).then(|| PathEvent {
- path: event_path.as_path().to_path_buf(),
- kind,
+ g.add(
+ path,
+ notify::RecursiveMode::NonRecursive,
+ move |event: ¬ify::Event| {
+ let kind = match event.kind {
+ EventKind::Create(_) => Some(PathEventKind::Created),
+ EventKind::Modify(_) => Some(PathEventKind::Changed),
+ EventKind::Remove(_) => Some(PathEventKind::Removed),
+ _ => None,
+ };
+ let mut path_events = event
+ .paths
+ .iter()
+ .filter_map(|event_path| {
+ let event_path = SanitizedPath::from(event_path);
+ event_path.starts_with(&root_path).then(|| PathEvent {
+ path: event_path.as_path().to_path_buf(),
+ kind,
+ })
})
- })
- .collect::<Vec<_>>();
-
- if !path_events.is_empty() {
- path_events.sort();
- let mut pending_paths = pending_paths.lock();
- if pending_paths.is_empty() {
- tx.try_send(()).ok();
+ .collect::<Vec<_>>();
+
+ if !path_events.is_empty() {
+ path_events.sort();
+ let mut pending_paths = pending_paths.lock();
+ if pending_paths.is_empty() {
+ tx.try_send(()).ok();
+ }
+ util::extend_sorted(
+ &mut *pending_paths,
+ path_events,
+ usize::MAX,
+ |a, b| a.path.cmp(&b.path),
+ );
}
- util::extend_sorted(
- &mut *pending_paths,
- path_events,
- usize::MAX,
- |a, b| a.path.cmp(&b.path),
- );
- }
- })
+ },
+ )
}
- })?;
-
- global(|g| {
- g.watcher
- .lock()
- .watch(path, notify::RecursiveMode::NonRecursive)
})??;
+ self.registrations.lock().insert(path, registration_id);
+
Ok(())
}
fn remove(&self, path: &std::path::Path) -> anyhow::Result<()> {
- use notify::Watcher;
- Ok(global(|w| w.watcher.lock().unwatch(path))??)
+ let Some(registration) = self.registrations.lock().remove(path) else {
+ return Ok(());
+ };
+
+ global(|w| w.remove(registration))
}
}
+#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash)]
+pub struct WatcherRegistrationId(u32);
+
+struct WatcherRegistrationState {
+ callback: Arc<dyn Fn(¬ify::Event) + Send + Sync>,
+ path: Arc<std::path::Path>,
+}
+
+struct WatcherState {
+ watchers: HashMap<WatcherRegistrationId, WatcherRegistrationState>,
+ path_registrations: HashMap<Arc<std::path::Path>, u32>,
+ last_registration: WatcherRegistrationId,
+}
+
pub struct GlobalWatcher {
+ state: Mutex<WatcherState>,
+
+ // DANGER: never keep the state lock while holding the watcher lock
// two mutexes because calling watcher.add triggers an watcher.event, which needs watchers.
#[cfg(target_os = "linux")]
- pub(super) watcher: Mutex<notify::INotifyWatcher>,
+ watcher: Mutex<notify::INotifyWatcher>,
#[cfg(target_os = "freebsd")]
- pub(super) watcher: Mutex<notify::KqueueWatcher>,
+ watcher: Mutex<notify::KqueueWatcher>,
#[cfg(target_os = "windows")]
- pub(super) watcher: Mutex<notify::ReadDirectoryChangesWatcher>,
- pub(super) watchers: Mutex<Vec<Box<dyn Fn(¬ify::Event) + Send + Sync>>>,
+ watcher: Mutex<notify::ReadDirectoryChangesWatcher>,
}
impl GlobalWatcher {
- pub(super) fn add(&self, cb: impl Fn(¬ify::Event) + Send + Sync + 'static) {
- self.watchers.lock().push(Box::new(cb))
+ #[must_use]
+ fn add(
+ &self,
+ path: Arc<std::path::Path>,
+ mode: notify::RecursiveMode,
+ cb: impl Fn(¬ify::Event) + Send + Sync + 'static,
+ ) -> anyhow::Result<WatcherRegistrationId> {
+ use notify::Watcher;
+
+ self.watcher.lock().watch(&path, mode)?;
+
+ let mut state = self.state.lock();
+
+ let id = state.last_registration;
+ state.last_registration = WatcherRegistrationId(id.0 + 1);
+
+ let registration_state = WatcherRegistrationState {
+ callback: Arc::new(cb),
+ path: path.clone(),
+ };
+ state.watchers.insert(id, registration_state);
+ *state.path_registrations.entry(path).or_insert(0) += 1;
+
+ Ok(id)
+ }
+
+ pub fn remove(&self, id: WatcherRegistrationId) {
+ use notify::Watcher;
+ let mut state = self.state.lock();
+ let Some(registration_state) = state.watchers.remove(&id) else {
+ return;
+ };
+
+ let Some(count) = state.path_registrations.get_mut(®istration_state.path) else {
+ return;
+ };
+ *count -= 1;
+ if *count == 0 {
+ state.path_registrations.remove(®istration_state.path);
+
+ drop(state);
+ self.watcher
+ .lock()
+ .unwatch(®istration_state.path)
+ .log_err();
+ }
}
}
@@ -114,8 +200,16 @@ fn handle_event(event: Result<notify::Event, notify::Error>) {
return;
};
global::<()>(move |watcher| {
- for f in watcher.watchers.lock().iter() {
- f(&event)
+ let callbacks = {
+ let state = watcher.state.lock();
+ state
+ .watchers
+ .values()
+ .map(|r| r.callback.clone())
+ .collect::<Vec<_>>()
+ };
+ for callback in callbacks {
+ callback(&event);
}
})
.log_err();
@@ -124,8 +218,12 @@ fn handle_event(event: Result<notify::Event, notify::Error>) {
pub fn global<T>(f: impl FnOnce(&GlobalWatcher) -> T) -> anyhow::Result<T> {
let result = FS_WATCHER_INSTANCE.get_or_init(|| {
notify::recommended_watcher(handle_event).map(|file_watcher| GlobalWatcher {
+ state: Mutex::new(WatcherState {
+ watchers: Default::default(),
+ path_registrations: Default::default(),
+ last_registration: Default::default(),
+ }),
watcher: Mutex::new(file_watcher),
- watchers: Default::default(),
})
});
match result {
@@ -41,10 +41,9 @@ impl Watcher for MacWatcher {
if let Some((watched_path, _)) = handles
.range::<Path, _>((Bound::Unbounded, Bound::Included(path)))
.next_back()
+ && path.starts_with(watched_path)
{
- if path.starts_with(watched_path) {
- return Ok(());
- }
+ return Ok(());
}
let (stream, handle) = EventStream::new(&[path], self.latency);
@@ -178,40 +178,39 @@ impl EventStream {
flags.contains(StreamFlags::USER_DROPPED)
|| flags.contains(StreamFlags::KERNEL_DROPPED)
})
+ && let Some(last_valid_event_id) = state.last_valid_event_id.take()
{
- if let Some(last_valid_event_id) = state.last_valid_event_id.take() {
- fs::FSEventStreamStop(state.stream);
- fs::FSEventStreamInvalidate(state.stream);
- fs::FSEventStreamRelease(state.stream);
-
- let stream_context = fs::FSEventStreamContext {
- version: 0,
- info,
- retain: None,
- release: None,
- copy_description: None,
- };
- let stream = fs::FSEventStreamCreate(
- cf::kCFAllocatorDefault,
- Self::trampoline,
- &stream_context,
- state.paths,
- last_valid_event_id,
- state.latency.as_secs_f64(),
- fs::kFSEventStreamCreateFlagFileEvents
- | fs::kFSEventStreamCreateFlagNoDefer
- | fs::kFSEventStreamCreateFlagWatchRoot,
- );
-
- state.stream = stream;
- fs::FSEventStreamScheduleWithRunLoop(
- state.stream,
- cf::CFRunLoopGetCurrent(),
- cf::kCFRunLoopDefaultMode,
- );
- fs::FSEventStreamStart(state.stream);
- stream_restarted = true;
- }
+ fs::FSEventStreamStop(state.stream);
+ fs::FSEventStreamInvalidate(state.stream);
+ fs::FSEventStreamRelease(state.stream);
+
+ let stream_context = fs::FSEventStreamContext {
+ version: 0,
+ info,
+ retain: None,
+ release: None,
+ copy_description: None,
+ };
+ let stream = fs::FSEventStreamCreate(
+ cf::kCFAllocatorDefault,
+ Self::trampoline,
+ &stream_context,
+ state.paths,
+ last_valid_event_id,
+ state.latency.as_secs_f64(),
+ fs::kFSEventStreamCreateFlagFileEvents
+ | fs::kFSEventStreamCreateFlagNoDefer
+ | fs::kFSEventStreamCreateFlagWatchRoot,
+ );
+
+ state.stream = stream;
+ fs::FSEventStreamScheduleWithRunLoop(
+ state.stream,
+ cf::CFRunLoopGetCurrent(),
+ cf::kCFRunLoopDefaultMode,
+ );
+ fs::FSEventStreamStart(state.stream);
+ stream_restarted = true;
}
if !stream_restarted {
@@ -208,8 +208,15 @@ impl<'a> Matcher<'a> {
return 1.0;
}
- let path_len = prefix.len() + path.len();
+ let limit = self.last_positions[query_idx];
+ let max_valid_index = (prefix.len() + path_lowercased.len()).saturating_sub(1);
+ let safe_limit = limit.min(max_valid_index);
+
+ if path_idx > safe_limit {
+ return 0.0;
+ }
+ let path_len = prefix.len() + path.len();
if let Some(memoized) = self.score_matrix[query_idx * path_len + path_idx] {
return memoized;
}
@@ -218,16 +225,13 @@ impl<'a> Matcher<'a> {
let mut best_position = 0;
let query_char = self.lowercase_query[query_idx];
- let limit = self.last_positions[query_idx];
-
- let max_valid_index = (prefix.len() + path_lowercased.len()).saturating_sub(1);
- let safe_limit = limit.min(max_valid_index);
let mut last_slash = 0;
+
for j in path_idx..=safe_limit {
let extra_lowercase_chars_count = extra_lowercase_chars
.iter()
- .take_while(|(i, _)| i < &&j)
+ .take_while(|&(&i, _)| i < j)
.map(|(_, increment)| increment)
.sum::<usize>();
let j_regular = j - extra_lowercase_chars_count;
@@ -236,10 +240,9 @@ impl<'a> Matcher<'a> {
lowercase_prefix[j]
} else {
let path_index = j - prefix.len();
- if path_index < path_lowercased.len() {
- path_lowercased[path_index]
- } else {
- continue;
+ match path_lowercased.get(path_index) {
+ Some(&char) => char,
+ None => continue,
}
};
let is_path_sep = path_char == MAIN_SEPARATOR;
@@ -255,18 +258,16 @@ impl<'a> Matcher<'a> {
#[cfg(target_os = "windows")]
let need_to_score = query_char == path_char || (is_path_sep && query_char == '_');
if need_to_score {
- let curr = if j_regular < prefix.len() {
- prefix[j_regular]
- } else {
- path[j_regular - prefix.len()]
+ let curr = match prefix.get(j_regular) {
+ Some(&curr) => curr,
+ None => path[j_regular - prefix.len()],
};
let mut char_score = 1.0;
if j > path_idx {
- let last = if j_regular - 1 < prefix.len() {
- prefix[j_regular - 1]
- } else {
- path[j_regular - 1 - prefix.len()]
+ let last = match prefix.get(j_regular - 1) {
+ Some(&last) => last,
+ None => path[j_regular - 1 - prefix.len()],
};
if last == MAIN_SEPARATOR {
@@ -12,7 +12,7 @@ workspace = true
path = "src/git.rs"
[features]
-test-support = []
+test-support = ["rand"]
[dependencies]
anyhow.workspace = true
@@ -26,6 +26,7 @@ http_client.workspace = true
log.workspace = true
parking_lot.workspace = true
regex.workspace = true
+rand = { workspace = true, optional = true }
rope.workspace = true
schemars.workspace = true
serde.workspace = true
@@ -47,3 +48,4 @@ text = { workspace = true, features = ["test-support"] }
unindent.workspace = true
gpui = { workspace = true, features = ["test-support"] }
tempfile.workspace = true
+rand.workspace = true
@@ -73,6 +73,7 @@ async fn run_git_blame(
.current_dir(working_directory)
.arg("blame")
.arg("--incremental")
+ .arg("-w")
.arg("--contents")
.arg("-")
.arg(path.as_os_str())
@@ -288,14 +289,12 @@ fn parse_git_blame(output: &str) -> Result<Vec<BlameEntry>> {
}
};
- if done {
- if let Some(entry) = current_entry.take() {
- index.insert(entry.sha, entries.len());
+ 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);
- }
+ // We only want annotations that have a commit.
+ if !entry.sha.is_zero() {
+ entries.push(entry);
}
}
}
@@ -55,6 +55,10 @@ actions!(
StageAll,
/// Unstages all changes in the repository.
UnstageAll,
+ /// Stashes all changes in the repository, including untracked files.
+ StashAll,
+ /// Pops the most recent stash.
+ StashPop,
/// Restores all tracked files to their last committed state.
RestoreTrackedFiles,
/// Moves all untracked files to trash.
@@ -77,6 +81,8 @@ actions!(
Commit,
/// Amends the last commit with staged changes.
Amend,
+ /// Enable the --signoff option.
+ Signoff,
/// Cancels the current git operation.
Cancel,
/// Expands the commit message editor.
@@ -87,6 +93,8 @@ actions!(
Init,
/// Opens all modified files in the editor.
OpenModifiedFiles,
+ /// Clones a repository.
+ Clone,
]
);
@@ -111,6 +119,13 @@ impl Oid {
Ok(Self(oid))
}
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn random(rng: &mut impl rand::Rng) -> Self {
+ let mut bytes = [0; 20];
+ rng.fill(&mut bytes);
+ Self::from_bytes(&bytes).unwrap()
+ }
+
pub fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
@@ -6,7 +6,7 @@ use collections::HashMap;
use futures::future::BoxFuture;
use futures::{AsyncWriteExt, FutureExt as _, select_biased};
use git2::BranchType;
-use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString};
+use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
use parking_lot::Mutex;
use rope::Rope;
use schemars::JsonSchema;
@@ -96,6 +96,7 @@ impl Upstream {
#[derive(Clone, Copy, Default)]
pub struct CommitOptions {
pub amend: bool,
+ pub signoff: bool,
}
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
@@ -268,10 +269,8 @@ impl GitExcludeOverride {
pub async fn restore_original(&mut self) -> Result<()> {
if let Some(ref original) = self.original_excludes {
smol::fs::write(&self.git_exclude_path, original).await?;
- } else {
- if self.git_exclude_path.exists() {
- smol::fs::remove_file(&self.git_exclude_path).await?;
- }
+ } else if self.git_exclude_path.exists() {
+ smol::fs::remove_file(&self.git_exclude_path).await?;
}
self.added_excludes = None;
@@ -337,7 +336,7 @@ pub trait GitRepository: Send + Sync {
fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
- fn status(&self, path_prefixes: &[RepoPath]) -> BoxFuture<'_, Result<GitStatus>>;
+ fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
@@ -394,6 +393,14 @@ pub trait GitRepository: Send + Sync {
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>>;
+ fn stash_paths(
+ &self,
+ paths: Vec<RepoPath>,
+ env: Arc<HashMap<String, String>>,
+ ) -> BoxFuture<'_, Result<()>>;
+
+ fn stash_pop(&self, env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>>;
+
fn push(
&self,
branch_name: String,
@@ -454,6 +461,8 @@ pub trait GitRepository: Send + Sync {
base_checkpoint: GitRepositoryCheckpoint,
target_checkpoint: GitRepositoryCheckpoint,
) -> BoxFuture<'_, Result<String>>;
+
+ fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>>;
}
pub enum DiffType {
@@ -835,21 +844,19 @@ impl GitRepository for RealGitRepository {
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
- child
- .stdin
- .take()
- .unwrap()
- .write_all(content.as_bytes())
- .await?;
+ let mut stdin = child.stdin.take().unwrap();
+ stdin.write_all(content.as_bytes()).await?;
+ stdin.flush().await?;
+ drop(stdin);
let output = child.output().await?.stdout;
- let sha = String::from_utf8(output)?;
+ let sha = str::from_utf8(&output)?.trim();
log::debug!("indexing SHA: {sha}, path {path:?}");
let output = new_smol_command(&git_binary_path)
.current_dir(&working_directory)
.envs(env.iter())
- .args(["update-index", "--add", "--cacheinfo", "100644", &sha])
+ .args(["update-index", "--add", "--cacheinfo", "100644", sha])
.arg(path.to_unix_style())
.output()
.await?;
@@ -860,6 +867,7 @@ impl GitRepository for RealGitRepository {
String::from_utf8_lossy(&output.stderr)
);
} else {
+ log::debug!("removing path {path:?} from the index");
let output = new_smol_command(&git_binary_path)
.current_dir(&working_directory)
.envs(env.iter())
@@ -908,8 +916,9 @@ impl GitRepository for RealGitRepository {
.context("no stdin for git cat-file subprocess")?;
let mut stdin = BufWriter::new(stdin);
for rev in &revs {
- write!(&mut stdin, "{rev}\n")?;
+ writeln!(&mut stdin, "{rev}")?;
}
+ stdin.flush()?;
drop(stdin);
let output = process.wait_with_output()?;
@@ -942,25 +951,27 @@ impl GitRepository for RealGitRepository {
.boxed()
}
- fn status(&self, path_prefixes: &[RepoPath]) -> BoxFuture<'_, Result<GitStatus>> {
+ fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
let git_binary_path = self.git_binary_path.clone();
- let working_directory = self.working_directory();
- let path_prefixes = path_prefixes.to_owned();
- self.executor
- .spawn(async move {
- let output = new_std_command(&git_binary_path)
- .current_dir(working_directory?)
- .args(git_status_args(&path_prefixes))
- .output()?;
- if output.status.success() {
- let stdout = String::from_utf8_lossy(&output.stdout);
- stdout.parse()
- } else {
- let stderr = String::from_utf8_lossy(&output.stderr);
- anyhow::bail!("git status failed: {stderr}");
- }
- })
- .boxed()
+ let working_directory = match self.working_directory() {
+ Ok(working_directory) => working_directory,
+ Err(e) => return Task::ready(Err(e)),
+ };
+ let args = git_status_args(path_prefixes);
+ log::debug!("Checking for git status in {path_prefixes:?}");
+ self.executor.spawn(async move {
+ let output = new_std_command(&git_binary_path)
+ .current_dir(working_directory)
+ .args(args)
+ .output()?;
+ if output.status.success() {
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ stdout.parse()
+ } else {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ anyhow::bail!("git status failed: {stderr}");
+ }
+ })
}
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
@@ -1043,7 +1054,7 @@ impl GitRepository for RealGitRepository {
let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
let revision = revision.get();
let branch_commit = revision.peel_to_commit()?;
- let mut branch = repo.branch(&branch_name, &branch_commit, false)?;
+ let mut branch = repo.branch(branch_name, &branch_commit, false)?;
branch.set_upstream(Some(&name))?;
branch
} else {
@@ -1188,6 +1199,55 @@ impl GitRepository for RealGitRepository {
.boxed()
}
+ fn stash_paths(
+ &self,
+ paths: Vec<RepoPath>,
+ env: Arc<HashMap<String, String>>,
+ ) -> BoxFuture<'_, Result<()>> {
+ let working_directory = self.working_directory();
+ self.executor
+ .spawn(async move {
+ let mut cmd = new_smol_command("git");
+ cmd.current_dir(&working_directory?)
+ .envs(env.iter())
+ .args(["stash", "push", "--quiet"])
+ .arg("--include-untracked");
+
+ cmd.args(paths.iter().map(|p| p.as_ref()));
+
+ let output = cmd.output().await?;
+
+ anyhow::ensure!(
+ output.status.success(),
+ "Failed to stash:\n{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ Ok(())
+ })
+ .boxed()
+ }
+
+ fn stash_pop(&self, env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>> {
+ let working_directory = self.working_directory();
+ self.executor
+ .spawn(async move {
+ let mut cmd = new_smol_command("git");
+ cmd.current_dir(&working_directory?)
+ .envs(env.iter())
+ .args(["stash", "pop"]);
+
+ let output = cmd.output().await?;
+
+ anyhow::ensure!(
+ output.status.success(),
+ "Failed to stash pop:\n{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ Ok(())
+ })
+ .boxed()
+ }
+
fn commit(
&self,
message: SharedString,
@@ -1209,6 +1269,10 @@ impl GitRepository for RealGitRepository {
cmd.arg("--amend");
}
+ if options.signoff {
+ cmd.arg("--signoff");
+ }
+
if let Some((name, email)) = name_and_email {
cmd.arg("--author").arg(&format!("{name} <{email}>"));
}
@@ -1381,12 +1445,11 @@ impl GitRepository for RealGitRepository {
let mut remote_branches = vec![];
let mut add_if_matching = async |remote_head: &str| {
- if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await {
- if merge_base.trim() == head {
- if let Some(s) = remote_head.strip_prefix("refs/remotes/") {
- remote_branches.push(s.to_owned().into());
- }
- }
+ if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await
+ && merge_base.trim() == head
+ && let Some(s) = remote_head.strip_prefix("refs/remotes/")
+ {
+ remote_branches.push(s.to_owned().into());
}
};
@@ -1508,10 +1571,9 @@ impl GitRepository for RealGitRepository {
Err(error) => {
if let Some(GitBinaryCommandError { status, .. }) =
error.downcast_ref::<GitBinaryCommandError>()
+ && status.code() == Some(1)
{
- if status.code() == Some(1) {
- return Ok(false);
- }
+ return Ok(false);
}
Err(error)
@@ -1545,6 +1607,37 @@ impl GitRepository for RealGitRepository {
})
.boxed()
}
+
+ fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
+ let working_directory = self.working_directory();
+ let git_binary_path = self.git_binary_path.clone();
+
+ let executor = self.executor.clone();
+ self.executor
+ .spawn(async move {
+ let working_directory = working_directory?;
+ let git = GitBinary::new(git_binary_path, working_directory, executor);
+
+ if let Ok(output) = git
+ .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
+ .await
+ {
+ let output = output
+ .strip_prefix("refs/remotes/upstream/")
+ .map(|s| SharedString::from(s.to_owned()));
+ return Ok(output);
+ }
+
+ let output = git
+ .run(&["symbolic-ref", "refs/remotes/origin/HEAD"])
+ .await?;
+
+ Ok(output
+ .strip_prefix("refs/remotes/origin/")
+ .map(|s| SharedString::from(s.to_owned())))
+ })
+ .boxed()
+ }
}
fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
@@ -1935,7 +2028,7 @@ fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
branches.push(Branch {
is_head: is_current_branch,
- ref_name: ref_name,
+ ref_name,
most_recent_commit: Some(CommitSummary {
sha: head_sha,
subject,
@@ -1957,7 +2050,7 @@ fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
}
fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
- if upstream_track == "" {
+ if upstream_track.is_empty() {
return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
ahead: 0,
behind: 0,
@@ -2252,7 +2345,7 @@ mod tests {
#[allow(clippy::octal_escapes)]
let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
assert_eq!(
- parse_branch_input(&input).unwrap(),
+ parse_branch_input(input).unwrap(),
vec![Branch {
is_head: true,
ref_name: "refs/heads/zed-patches".into(),
@@ -153,17 +153,11 @@ impl FileStatus {
}
pub fn is_conflicted(self) -> bool {
- match self {
- FileStatus::Unmerged { .. } => true,
- _ => false,
- }
+ matches!(self, FileStatus::Unmerged { .. })
}
pub fn is_ignored(self) -> bool {
- match self {
- FileStatus::Ignored => true,
- _ => false,
- }
+ matches!(self, FileStatus::Ignored)
}
pub fn has_changes(&self) -> bool {
@@ -176,40 +170,31 @@ impl FileStatus {
pub fn is_modified(self) -> bool {
match self {
- FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
- (StatusCode::Modified, _) | (_, StatusCode::Modified) => true,
- _ => false,
- },
+ FileStatus::Tracked(tracked) => matches!(
+ (tracked.index_status, tracked.worktree_status),
+ (StatusCode::Modified, _) | (_, StatusCode::Modified)
+ ),
_ => false,
}
}
pub fn is_created(self) -> bool {
match self {
- FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
- (StatusCode::Added, _) | (_, StatusCode::Added) => true,
- _ => false,
- },
+ FileStatus::Tracked(tracked) => matches!(
+ (tracked.index_status, tracked.worktree_status),
+ (StatusCode::Added, _) | (_, StatusCode::Added)
+ ),
FileStatus::Untracked => true,
_ => false,
}
}
pub fn is_deleted(self) -> bool {
- match self {
- FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
- (StatusCode::Deleted, _) | (_, StatusCode::Deleted) => true,
- _ => false,
- },
- _ => false,
- }
+ matches!(self, FileStatus::Tracked(tracked) if matches!((tracked.index_status, tracked.worktree_status), (StatusCode::Deleted, _) | (_, StatusCode::Deleted)))
}
pub fn is_untracked(self) -> bool {
- match self {
- FileStatus::Untracked => true,
- _ => false,
- }
+ matches!(self, FileStatus::Untracked)
}
pub fn summary(self) -> GitSummary {
@@ -468,7 +453,7 @@ impl FromStr for GitStatus {
Some((path, status))
})
.collect::<Vec<_>>();
- entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
+ entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
// When a file exists in HEAD, is deleted in the index, and exists again in the working copy,
// git produces two lines for it, one reading `D ` (deleted in index, unmodified in working copy)
// and the other reading `??` (untracked). Merge these two into the equivalent of `DA`.
@@ -49,13 +49,13 @@ pub fn register_additional_providers(
pub fn get_host_from_git_remote_url(remote_url: &str) -> Result<String> {
maybe!({
- if let Some(remote_url) = remote_url.strip_prefix("git@") {
- if let Some((host, _)) = remote_url.trim_start_matches("git@").split_once(':') {
- return Some(host.to_string());
- }
+ if let Some(remote_url) = remote_url.strip_prefix("git@")
+ && let Some((host, _)) = remote_url.trim_start_matches("git@").split_once(':')
+ {
+ return Some(host.to_string());
}
- Url::parse(&remote_url)
+ Url::parse(remote_url)
.ok()
.and_then(|remote_url| remote_url.host_str().map(|host| host.to_string()))
})
@@ -1,12 +1,22 @@
use std::str::FromStr;
+use std::sync::LazyLock;
+use regex::Regex;
use url::Url;
use git::{
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
- RemoteUrl,
+ PullRequest, RemoteUrl,
};
+fn pull_request_regex() -> &'static Regex {
+ static PULL_REQUEST_REGEX: LazyLock<Regex> = LazyLock::new(|| {
+ // This matches Bitbucket PR reference pattern: (pull request #xxx)
+ Regex::new(r"\(pull request #(\d+)\)").unwrap()
+ });
+ &PULL_REQUEST_REGEX
+}
+
pub struct Bitbucket {
name: String,
base_url: Url,
@@ -96,6 +106,22 @@ impl GitHostingProvider for Bitbucket {
);
permalink
}
+
+ fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
+ // Check first line of commit message for PR references
+ let first_line = message.lines().next()?;
+
+ // Try to match against our PR patterns
+ let capture = pull_request_regex().captures(first_line)?;
+ let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
+
+ // Construct the PR URL in Bitbucket format
+ let mut url = self.base_url();
+ let path = format!("/{}/{}/pull-requests/{}", remote.owner, remote.repo, number);
+ url.set_path(&path);
+
+ Some(PullRequest { number, url })
+ }
}
#[cfg(test)]
@@ -203,4 +229,34 @@ mod tests {
"https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-24:48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
+
+ #[test]
+ fn test_bitbucket_pull_requests() {
+ use indoc::indoc;
+
+ let remote = ParsedGitRemote {
+ owner: "zed-industries".into(),
+ repo: "zed".into(),
+ };
+
+ let bitbucket = Bitbucket::public_instance();
+
+ // Test message without PR reference
+ let message = "This does not contain a pull request";
+ assert!(bitbucket.extract_pull_request(&remote, message).is_none());
+
+ // Pull request number at end of first line
+ let message = indoc! {r#"
+ Merged in feature-branch (pull request #123)
+
+ Some detailed description of the changes.
+ "#};
+
+ let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
+ assert_eq!(pr.number, 123);
+ assert_eq!(
+ pr.url.as_str(),
+ "https://bitbucket.org/zed-industries/zed/pull-requests/123"
+ );
+ }
}
@@ -292,7 +292,7 @@ mod tests {
assert_eq!(
Chromium
- .extract_pull_request(&remote, &message)
+ .extract_pull_request(&remote, message)
.unwrap()
.url
.as_str(),
@@ -159,7 +159,11 @@ impl GitHostingProvider for Github {
}
let mut path_segments = url.path_segments()?;
- let owner = path_segments.next()?;
+ let mut owner = path_segments.next()?;
+ if owner.is_empty() {
+ owner = path_segments.next()?;
+ }
+
let repo = path_segments.next()?.trim_end_matches(".git");
Some(ParsedGitRemote {
@@ -244,6 +248,22 @@ mod tests {
use super::*;
+ #[test]
+ fn test_remote_url_with_root_slash() {
+ let remote_url = "git@github.com:/zed-industries/zed";
+ let parsed_remote = Github::public_instance()
+ .parse_remote_url(remote_url)
+ .unwrap();
+
+ assert_eq!(
+ parsed_remote,
+ ParsedGitRemote {
+ owner: "zed-industries".into(),
+ repo: "zed".into(),
+ }
+ );
+ }
+
#[test]
fn test_invalid_self_hosted_remote_url() {
let remote_url = "git@github.com:zed-industries/zed.git";
@@ -454,7 +474,7 @@ mod tests {
assert_eq!(
github
- .extract_pull_request(&remote, &message)
+ .extract_pull_request(&remote, message)
.unwrap()
.url
.as_str(),
@@ -468,6 +488,6 @@ mod tests {
See the original PR, this is a fix.
"#
};
- assert_eq!(github.extract_pull_request(&remote, &message), None);
+ assert_eq!(github.extract_pull_request(&remote, message), None);
}
}
@@ -23,6 +23,7 @@ askpass.workspace = true
buffer_diff.workspace = true
call.workspace = true
chrono.workspace = true
+cloud_llm_client.workspace = true
collections.workspace = true
command_palette_hooks.workspace = true
component.workspace = true
@@ -61,7 +62,6 @@ watch.workspace = true
workspace-hack.workspace = true
workspace.workspace = true
zed_actions.workspace = true
-zed_llm_client.workspace = true
[target.'cfg(windows)'.dependencies]
windows.workspace = true
@@ -70,6 +70,7 @@ windows.workspace = true
ctor.workspace = true
editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
+indoc.workspace = true
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
@@ -172,7 +172,7 @@ impl BlameRenderer for GitBlameRenderer {
.clone()
.unwrap_or("<no name>".to_string())
.into(),
- author_email: blame.author_mail.clone().unwrap_or("".to_string()).into(),
+ author_email: blame.author_mail.unwrap_or("".to_string()).into(),
message: details,
};
@@ -186,7 +186,7 @@ impl BlameRenderer for GitBlameRenderer {
.get(0..8)
.map(|sha| sha.to_string().into())
.unwrap_or_else(|| commit_details.sha.clone());
- let full_sha = commit_details.sha.to_string().clone();
+ let full_sha = commit_details.sha.to_string();
let absolute_timestamp = format_local_timestamp(
commit_details.commit_time,
OffsetDateTime::now_utc(),
@@ -377,7 +377,7 @@ impl BlameRenderer for GitBlameRenderer {
has_parent: true,
},
repository.downgrade(),
- workspace.clone(),
+ workspace,
window,
cx,
)
@@ -13,7 +13,7 @@ use project::git_store::Repository;
use std::sync::Arc;
use time::OffsetDateTime;
use time_format::format_local_timestamp;
-use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*};
+use ui::{HighlightedLabel, ListItem, ListItemSpacing, Tooltip, prelude::*};
use util::ResultExt;
use workspace::notifications::DetachAndPromptErr;
use workspace::{ModalView, Workspace};
@@ -48,7 +48,7 @@ pub fn open(
window: &mut Window,
cx: &mut Context<Workspace>,
) {
- let repository = workspace.project().read(cx).active_repository(cx).clone();
+ let repository = workspace.project().read(cx).active_repository(cx);
let style = BranchListStyle::Modal;
workspace.toggle_modal(window, cx, |window, cx| {
BranchList::new(repository, style, rems(34.), window, cx)
@@ -90,11 +90,21 @@ impl BranchList {
let all_branches_request = repository
.clone()
.map(|repository| repository.update(cx, |repository, _| repository.branches()));
+ let default_branch_request = repository
+ .clone()
+ .map(|repository| repository.update(cx, |repository, _| repository.default_branch()));
cx.spawn_in(window, async move |this, cx| {
let mut all_branches = all_branches_request
.context("No active repository")?
.await??;
+ let default_branch = default_branch_request
+ .context("No active repository")?
+ .await
+ .map(Result::ok)
+ .ok()
+ .flatten()
+ .flatten();
let all_branches = cx
.background_spawn(async move {
@@ -124,6 +134,7 @@ impl BranchList {
this.update_in(cx, |this, window, cx| {
this.picker.update(cx, |picker, cx| {
+ picker.delegate.default_branch = default_branch;
picker.delegate.all_branches = Some(all_branches);
picker.refresh(window, cx);
})
@@ -133,7 +144,7 @@ impl BranchList {
})
.detach_and_log_err(cx);
- let delegate = BranchListDelegate::new(repository.clone(), style);
+ let delegate = BranchListDelegate::new(repository, style);
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
@@ -169,6 +180,7 @@ impl Focusable for BranchList {
impl Render for BranchList {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
+ .key_context("GitBranchSelector")
.w(self.width)
.on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
.child(self.picker.clone())
@@ -192,6 +204,7 @@ struct BranchEntry {
pub struct BranchListDelegate {
matches: Vec<BranchEntry>,
all_branches: Option<Vec<Branch>>,
+ default_branch: Option<SharedString>,
repo: Option<Entity<Repository>>,
style: BranchListStyle,
selected_index: usize,
@@ -206,6 +219,7 @@ impl BranchListDelegate {
repo,
style,
all_branches: None,
+ default_branch: None,
selected_index: 0,
last_query: Default::default(),
modifiers: Default::default(),
@@ -214,6 +228,7 @@ impl BranchListDelegate {
fn create_branch(
&self,
+ from_branch: Option<SharedString>,
new_branch_name: SharedString,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
@@ -223,6 +238,11 @@ impl BranchListDelegate {
};
let new_branch_name = new_branch_name.to_string().replace(' ', "-");
cx.spawn(async move |_, cx| {
+ if let Some(based_branch) = from_branch {
+ repo.update(cx, |repo, _| repo.change_branch(based_branch.to_string()))?
+ .await??;
+ }
+
repo.update(cx, |repo, _| {
repo.create_branch(new_branch_name.to_string())
})?
@@ -353,12 +373,22 @@ impl PickerDelegate for BranchListDelegate {
})
}
- fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+ fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(entry) = self.matches.get(self.selected_index()) else {
return;
};
if entry.is_new {
- self.create_branch(entry.branch.name().to_owned().into(), window, cx);
+ let from_branch = if secondary {
+ self.default_branch.clone()
+ } else {
+ None
+ };
+ self.create_branch(
+ from_branch,
+ entry.branch.name().to_owned().into(),
+ window,
+ cx,
+ );
return;
}
@@ -439,6 +469,28 @@ impl PickerDelegate for BranchListDelegate {
})
.unwrap_or_else(|| (None, None));
+ let icon = if let Some(default_branch) = self.default_branch.clone()
+ && entry.is_new
+ {
+ Some(
+ IconButton::new("branch-from-default", IconName::GitBranchAlt)
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.delegate.set_selected_index(ix, window, cx);
+ this.delegate.confirm(true, window, cx);
+ }))
+ .tooltip(move |window, cx| {
+ Tooltip::for_action(
+ format!("Create branch based off default: {default_branch}"),
+ &menu::SecondaryConfirm,
+ window,
+ cx,
+ )
+ }),
+ )
+ } else {
+ None
+ };
+
let branch_name = if entry.is_new {
h_flex()
.gap_1()
@@ -504,7 +556,8 @@ impl PickerDelegate for BranchListDelegate {
.color(Color::Muted)
}))
}),
- ),
+ )
+ .end_slot::<IconButton>(icon),
)
}
@@ -1,8 +1,10 @@
use crate::branch_picker::{self, BranchList};
use crate::git_panel::{GitPanel, commit_message_editor};
use git::repository::CommitOptions;
-use git::{Amend, Commit, GenerateCommitMessage};
-use panel::{panel_button, panel_editor_style, panel_filled_button};
+use git::{Amend, Commit, GenerateCommitMessage, Signoff};
+use panel::{panel_button, panel_editor_style};
+use project::DisableAiSettings;
+use settings::Settings;
use ui::{
ContextMenu, KeybindingHint, PopoverMenu, PopoverMenuHandle, SplitButton, Tooltip, prelude::*,
};
@@ -33,7 +35,7 @@ impl ModalContainerProperties {
// Calculate width based on character width
let mut modal_width = 460.0;
- let style = window.text_style().clone();
+ let style = window.text_style();
let font_id = window.text_system().resolve_font(&style.font());
let font_size = style.font_size.to_pixels(window.rem_size());
@@ -133,11 +135,10 @@ impl CommitModal {
.as_ref()
.and_then(|repo| repo.read(cx).head_commit.as_ref())
.is_some()
+ && !git_panel.amend_pending()
{
- if !git_panel.amend_pending() {
- git_panel.set_amend_pending(true, cx);
- git_panel.load_last_commit_message_if_empty(cx);
- }
+ git_panel.set_amend_pending(true, cx);
+ git_panel.load_last_commit_message_if_empty(cx);
}
}
ForceMode::Commit => {
@@ -178,7 +179,7 @@ impl CommitModal {
let commit_editor = git_panel.update(cx, |git_panel, cx| {
git_panel.set_modal_open(true, cx);
- let buffer = git_panel.commit_message_buffer(cx).clone();
+ let buffer = git_panel.commit_message_buffer(cx);
let panel_editor = git_panel.commit_editor.clone();
let project = git_panel.project.clone();
@@ -193,12 +194,12 @@ impl CommitModal {
let commit_message = commit_editor.read(cx).text(cx);
- if let Some(suggested_commit_message) = suggested_commit_message {
- if commit_message.is_empty() {
- commit_editor.update(cx, |editor, cx| {
- editor.set_placeholder_text(suggested_commit_message, cx);
- });
- }
+ if let Some(suggested_commit_message) = suggested_commit_message
+ && commit_message.is_empty()
+ {
+ commit_editor.update(cx, |editor, cx| {
+ editor.set_placeholder_text(suggested_commit_message, cx);
+ });
}
let focus_handle = commit_editor.focus_handle(cx);
@@ -270,17 +271,56 @@ impl CommitModal {
.child(
div()
.px_1()
- .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
+ .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
),
)
- .menu(move |window, cx| {
- Some(ContextMenu::build(window, cx, |context_menu, _, _| {
- context_menu
- .when_some(keybinding_target.clone(), |el, keybinding_target| {
- el.context(keybinding_target.clone())
- })
- .action("Amend", Amend.boxed_clone())
- }))
+ .menu({
+ let git_panel_entity = self.git_panel.clone();
+ move |window, cx| {
+ let git_panel = git_panel_entity.read(cx);
+ let amend_enabled = git_panel.amend_pending();
+ let signoff_enabled = git_panel.signoff_enabled();
+ let has_previous_commit = git_panel.head_commit(cx).is_some();
+
+ Some(ContextMenu::build(window, cx, |context_menu, _, _| {
+ context_menu
+ .when_some(keybinding_target.clone(), |el, keybinding_target| {
+ el.context(keybinding_target)
+ })
+ .when(has_previous_commit, |this| {
+ this.toggleable_entry(
+ "Amend",
+ amend_enabled,
+ IconPosition::Start,
+ Some(Box::new(Amend)),
+ {
+ let git_panel = git_panel_entity.downgrade();
+ move |_, cx| {
+ git_panel
+ .update(cx, |git_panel, cx| {
+ git_panel.toggle_amend_pending(cx);
+ })
+ .ok();
+ }
+ },
+ )
+ })
+ .toggleable_entry(
+ "Signoff",
+ signoff_enabled,
+ IconPosition::Start,
+ Some(Box::new(Signoff)),
+ {
+ let git_panel = git_panel_entity.clone();
+ move |window, cx| {
+ git_panel.update(cx, |git_panel, cx| {
+ git_panel.toggle_signoff_enabled(&Signoff, window, cx);
+ })
+ }
+ },
+ )
+ }))
+ }
})
.with_handle(self.commit_menu_handle.clone())
.anchor(Corner::TopRight)
@@ -295,7 +335,7 @@ impl CommitModal {
generate_commit_message,
active_repo,
is_amend_pending,
- has_previous_commit,
+ is_signoff_enabled,
) = self.git_panel.update(cx, |git_panel, cx| {
let (can_commit, tooltip) = git_panel.configure_commit_button(cx);
let title = git_panel.commit_button_title();
@@ -303,10 +343,7 @@ impl CommitModal {
let generate_commit_message = git_panel.render_generate_commit_message_button(cx);
let active_repo = git_panel.active_repository.clone();
let is_amend_pending = git_panel.amend_pending();
- let has_previous_commit = active_repo
- .as_ref()
- .and_then(|repo| repo.read(cx).head_commit.as_ref())
- .is_some();
+ let is_signoff_enabled = git_panel.signoff_enabled();
(
can_commit,
tooltip,
@@ -315,7 +352,7 @@ impl CommitModal {
generate_commit_message,
active_repo,
is_amend_pending,
- has_previous_commit,
+ is_signoff_enabled,
)
});
@@ -354,15 +391,9 @@ impl CommitModal {
});
let focus_handle = self.focus_handle(cx);
- let close_kb_hint =
- if let Some(close_kb) = ui::KeyBinding::for_action(&menu::Cancel, window, cx) {
- Some(
- KeybindingHint::new(close_kb, cx.theme().colors().editor_background)
- .suffix("Cancel"),
- )
- } else {
- None
- };
+ let close_kb_hint = ui::KeyBinding::for_action(&menu::Cancel, window, cx).map(|close_kb| {
+ KeybindingHint::new(close_kb, cx.theme().colors().editor_background).suffix("Cancel")
+ });
h_flex()
.group("commit_editor_footer")
@@ -396,126 +427,59 @@ impl CommitModal {
.px_1()
.gap_4()
.children(close_kb_hint)
- .when(is_amend_pending, |this| {
- let focus_handle = focus_handle.clone();
- this.child(
- panel_filled_button(commit_label)
- .tooltip(move |window, cx| {
- if can_commit {
- Tooltip::for_action_in(
- tooltip,
- &Amend,
- &focus_handle,
- window,
- cx,
- )
- } else {
- Tooltip::simple(tooltip, cx)
- }
- })
- .disabled(!can_commit)
- .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
- telemetry::event!("Git Amended", source = "Git Modal");
- this.git_panel.update(cx, |git_panel, cx| {
- git_panel.set_amend_pending(false, cx);
- git_panel.commit_changes(
- CommitOptions { amend: true },
- window,
- cx,
- );
- });
- cx.emit(DismissEvent);
- })),
+ .child(SplitButton::new(
+ ui::ButtonLike::new_rounded_left(ElementId::Name(
+ format!("split-button-left-{}", commit_label).into(),
+ ))
+ .layer(ui::ElevationIndex::ModalSurface)
+ .size(ui::ButtonSize::Compact)
+ .child(
+ div()
+ .child(Label::new(commit_label).size(LabelSize::Small))
+ .mr_0p5(),
)
- })
- .when(!is_amend_pending, |this| {
- this.when(has_previous_commit, |this| {
- this.child(SplitButton::new(
- ui::ButtonLike::new_rounded_left(ElementId::Name(
- format!("split-button-left-{}", commit_label).into(),
- ))
- .layer(ui::ElevationIndex::ModalSurface)
- .size(ui::ButtonSize::Compact)
- .child(
- div()
- .child(Label::new(commit_label).size(LabelSize::Small))
- .mr_0p5(),
+ .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
+ telemetry::event!("Git Committed", source = "Git Modal");
+ this.git_panel.update(cx, |git_panel, cx| {
+ git_panel.commit_changes(
+ CommitOptions {
+ amend: is_amend_pending,
+ signoff: is_signoff_enabled,
+ },
+ window,
+ cx,
)
- .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
- telemetry::event!("Git Committed", source = "Git Modal");
- this.git_panel.update(cx, |git_panel, cx| {
- git_panel.commit_changes(
- CommitOptions { amend: false },
- window,
- cx,
- )
- });
- cx.emit(DismissEvent);
- }))
- .disabled(!can_commit)
- .tooltip({
- let focus_handle = focus_handle.clone();
- move |window, cx| {
- if can_commit {
- Tooltip::with_meta_in(
- tooltip,
- Some(&git::Commit),
- "git commit",
- &focus_handle.clone(),
- window,
- cx,
- )
- } else {
- Tooltip::simple(tooltip, cx)
- }
- }
- }),
- self.render_git_commit_menu(
- ElementId::Name(
- format!("split-button-right-{}", commit_label).into(),
- ),
- Some(focus_handle.clone()),
- )
- .into_any_element(),
- ))
- })
- .when(!has_previous_commit, |this| {
- this.child(
- panel_filled_button(commit_label)
- .tooltip(move |window, cx| {
- if can_commit {
- Tooltip::with_meta_in(
- tooltip,
- Some(&git::Commit),
- "git commit",
- &focus_handle,
- window,
- cx,
- )
- } else {
- Tooltip::simple(tooltip, cx)
- }
- })
- .disabled(!can_commit)
- .on_click(cx.listener(
- move |this, _: &ClickEvent, window, cx| {
- telemetry::event!(
- "Git Committed",
- source = "Git Modal"
- );
- this.git_panel.update(cx, |git_panel, cx| {
- git_panel.commit_changes(
- CommitOptions { amend: false },
- window,
- cx,
- )
- });
- cx.emit(DismissEvent);
- },
- )),
- )
- })
- }),
+ });
+ cx.emit(DismissEvent);
+ }))
+ .disabled(!can_commit)
+ .tooltip({
+ let focus_handle = focus_handle.clone();
+ move |window, cx| {
+ if can_commit {
+ Tooltip::with_meta_in(
+ tooltip,
+ Some(&git::Commit),
+ format!(
+ "git commit{}{}",
+ if is_amend_pending { " --amend" } else { "" },
+ if is_signoff_enabled { " --signoff" } else { "" }
+ ),
+ &focus_handle.clone(),
+ window,
+ cx,
+ )
+ } else {
+ Tooltip::simple(tooltip, cx)
+ }
+ }
+ }),
+ self.render_git_commit_menu(
+ ElementId::Name(format!("split-button-right-{}", commit_label).into()),
+ Some(focus_handle),
+ )
+ .into_any_element(),
+ )),
)
}
@@ -534,7 +498,14 @@ impl CommitModal {
}
telemetry::event!("Git Committed", source = "Git Modal");
self.git_panel.update(cx, |git_panel, cx| {
- git_panel.commit_changes(CommitOptions { amend: false }, window, cx)
+ git_panel.commit_changes(
+ CommitOptions {
+ amend: false,
+ signoff: git_panel.signoff_enabled(),
+ },
+ window,
+ cx,
+ )
});
cx.emit(DismissEvent);
}
@@ -559,7 +530,14 @@ impl CommitModal {
telemetry::event!("Git Amended", source = "Git Modal");
self.git_panel.update(cx, |git_panel, cx| {
git_panel.set_amend_pending(false, cx);
- git_panel.commit_changes(CommitOptions { amend: true }, window, cx);
+ git_panel.commit_changes(
+ CommitOptions {
+ amend: true,
+ signoff: git_panel.signoff_enabled(),
+ },
+ window,
+ cx,
+ );
});
cx.emit(DismissEvent);
}
@@ -588,11 +566,13 @@ impl Render for CommitModal {
.on_action(cx.listener(Self::dismiss))
.on_action(cx.listener(Self::commit))
.on_action(cx.listener(Self::amend))
- .on_action(cx.listener(|this, _: &GenerateCommitMessage, _, cx| {
- this.git_panel.update(cx, |panel, cx| {
- panel.generate_commit_message(cx);
- })
- }))
+ .when(!DisableAiSettings::get_global(cx).disable_ai, |this| {
+ this.on_action(cx.listener(|this, _: &GenerateCommitMessage, _, cx| {
+ this.git_panel.update(cx, |panel, cx| {
+ panel.generate_commit_message(cx);
+ })
+ }))
+ })
.on_action(
cx.listener(|this, _: &zed_actions::git::Branch, window, cx| {
this.toggle_branch_selector(window, cx);
@@ -181,7 +181,7 @@ impl Render for CommitTooltip {
.get(0..8)
.map(|sha| sha.to_string().into())
.unwrap_or_else(|| self.commit.sha.clone());
- let full_sha = self.commit.sha.to_string().clone();
+ let full_sha = self.commit.sha.to_string();
let absolute_timestamp = format_local_timestamp(
self.commit.commit_time,
OffsetDateTime::now_utc(),
@@ -88,11 +88,10 @@ impl CommitView {
let ix = pane.items().position(|item| {
let commit_view = item.downcast::<CommitView>();
commit_view
- .map_or(false, |view| view.read(cx).commit.sha == commit.sha)
+ .is_some_and(|view| view.read(cx).commit.sha == commit.sha)
});
if let Some(ix) = ix {
pane.activate_item(ix, true, true, window, cx);
- return;
} else {
pane.add_item(Box::new(commit_view), true, true, None, window, cx);
}
@@ -160,7 +159,7 @@ impl CommitView {
});
}
- cx.spawn(async move |this, mut cx| {
+ cx.spawn(async move |this, cx| {
for file in commit_diff.files {
let is_deleted = file.new_text.is_none();
let new_text = file.new_text.unwrap_or_default();
@@ -179,9 +178,9 @@ impl CommitView {
worktree_id,
}) as Arc<dyn language::File>;
- let buffer = build_buffer(new_text, file, &language_registry, &mut cx).await?;
+ let buffer = build_buffer(new_text, file, &language_registry, cx).await?;
let buffer_diff =
- build_buffer_diff(old_text, &buffer, &language_registry, &mut cx).await?;
+ build_buffer_diff(old_text, &buffer, &language_registry, cx).await?;
this.update(cx, |this, cx| {
this.multibuffer.update(cx, |multibuffer, cx| {
@@ -55,7 +55,7 @@ pub fn register_editor(editor: &mut Editor, buffer: Entity<MultiBuffer>, cx: &mu
buffers: Default::default(),
});
- let buffers = buffer.read(cx).all_buffers().clone();
+ let buffers = buffer.read(cx).all_buffers();
for buffer in buffers {
buffer_added(editor, buffer, cx);
}
@@ -112,7 +112,7 @@ fn excerpt_for_buffer_updated(
}
fn buffer_added(editor: &mut Editor, buffer: Entity<Buffer>, cx: &mut Context<Editor>) {
- let Some(project) = &editor.project else {
+ let Some(project) = editor.project() else {
return;
};
let git_store = project.read(cx).git_store().clone();
@@ -129,7 +129,7 @@ fn buffer_added(editor: &mut Editor, buffer: Entity<Buffer>, cx: &mut Context<Ed
let subscription = cx.subscribe(&conflict_set, conflicts_updated);
BufferConflicts {
block_ids: Vec::new(),
- conflict_set: conflict_set.clone(),
+ conflict_set,
_subscription: subscription,
}
});
@@ -156,7 +156,7 @@ fn buffers_removed(editor: &mut Editor, removed_buffer_ids: &[BufferId], cx: &mu
.unwrap()
.buffers
.retain(|buffer_id, buffer| {
- if removed_buffer_ids.contains(&buffer_id) {
+ if removed_buffer_ids.contains(buffer_id) {
removed_block_ids.extend(buffer.block_ids.iter().map(|(_, block_id)| *block_id));
false
} else {
@@ -222,12 +222,12 @@ fn conflicts_updated(
let precedes_start = range
.context
.start
- .cmp(&conflict_range.start, &buffer_snapshot)
+ .cmp(&conflict_range.start, buffer_snapshot)
.is_le();
let follows_end = range
.context
.end
- .cmp(&conflict_range.start, &buffer_snapshot)
+ .cmp(&conflict_range.start, buffer_snapshot)
.is_ge();
precedes_start && follows_end
}) else {
@@ -268,12 +268,12 @@ fn conflicts_updated(
let precedes_start = range
.context
.start
- .cmp(&conflict.range.start, &buffer_snapshot)
+ .cmp(&conflict.range.start, buffer_snapshot)
.is_le();
let follows_end = range
.context
.end
- .cmp(&conflict.range.start, &buffer_snapshot)
+ .cmp(&conflict.range.start, buffer_snapshot)
.is_ge();
precedes_start && follows_end
}) else {
@@ -437,7 +437,6 @@ fn render_conflict_buttons(
Button::new("both", "Use Both")
.label_size(LabelSize::Small)
.on_click({
- let editor = editor.clone();
let conflict = conflict.clone();
let ours = conflict.ours.clone();
let theirs = conflict.theirs.clone();
@@ -469,7 +468,7 @@ pub(crate) fn resolve_conflict(
let Some((workspace, project, multibuffer, buffer)) = editor
.update(cx, |editor, cx| {
let workspace = editor.workspace()?;
- let project = editor.project.clone()?;
+ 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)?;
@@ -1,4 +1,4 @@
-//! DiffView provides a UI for displaying differences between two buffers.
+//! FileDiffView provides a UI for displaying differences between two buffers.
use anyhow::Result;
use buffer_diff::{BufferDiff, BufferDiffSnapshot};
@@ -25,7 +25,7 @@ use workspace::{
searchable::SearchableItemHandle,
};
-pub struct DiffView {
+pub struct FileDiffView {
editor: Entity<Editor>,
old_buffer: Entity<Buffer>,
new_buffer: Entity<Buffer>,
@@ -35,7 +35,7 @@ pub struct DiffView {
const RECALCULATE_DIFF_DEBOUNCE: Duration = Duration::from_millis(250);
-impl DiffView {
+impl FileDiffView {
pub fn open(
old_path: PathBuf,
new_path: PathBuf,
@@ -57,7 +57,7 @@ impl DiffView {
workspace.update_in(cx, |workspace, window, cx| {
let diff_view = cx.new(|cx| {
- DiffView::new(
+ FileDiffView::new(
old_buffer,
new_buffer,
buffer_diff,
@@ -123,7 +123,7 @@ impl DiffView {
old_buffer,
new_buffer,
_recalculate_diff_task: cx.spawn(async move |this, cx| {
- while let Ok(_) = buffer_changes_rx.recv().await {
+ while buffer_changes_rx.recv().await.is_ok() {
loop {
let mut timer = cx
.background_executor()
@@ -190,15 +190,15 @@ async fn build_buffer_diff(
})
}
-impl EventEmitter<EditorEvent> for DiffView {}
+impl EventEmitter<EditorEvent> for FileDiffView {}
-impl Focusable for DiffView {
+impl Focusable for FileDiffView {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.editor.focus_handle(cx)
}
}
-impl Item for DiffView {
+impl Item for FileDiffView {
type Event = EditorEvent;
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
@@ -216,48 +216,37 @@ impl Item for DiffView {
}
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
- let old_filename = self
- .old_buffer
- .read(cx)
- .file()
- .and_then(|file| {
- Some(
- file.full_path(cx)
- .file_name()?
- .to_string_lossy()
- .to_string(),
- )
- })
- .unwrap_or_else(|| "untitled".into());
- let new_filename = self
- .new_buffer
- .read(cx)
- .file()
- .and_then(|file| {
- Some(
- file.full_path(cx)
- .file_name()?
- .to_string_lossy()
- .to_string(),
- )
- })
- .unwrap_or_else(|| "untitled".into());
+ let title_text = |buffer: &Entity<Buffer>| {
+ buffer
+ .read(cx)
+ .file()
+ .and_then(|file| {
+ Some(
+ file.full_path(cx)
+ .file_name()?
+ .to_string_lossy()
+ .to_string(),
+ )
+ })
+ .unwrap_or_else(|| "untitled".into())
+ };
+ let old_filename = title_text(&self.old_buffer);
+ let new_filename = title_text(&self.new_buffer);
+
format!("{old_filename} ↔ {new_filename}").into()
}
fn tab_tooltip_text(&self, cx: &App) -> Option<ui::SharedString> {
- let old_path = self
- .old_buffer
- .read(cx)
- .file()
- .map(|file| file.full_path(cx).compact().to_string_lossy().to_string())
- .unwrap_or_else(|| "untitled".into());
- let new_path = self
- .new_buffer
- .read(cx)
- .file()
- .map(|file| file.full_path(cx).compact().to_string_lossy().to_string())
- .unwrap_or_else(|| "untitled".into());
+ let path = |buffer: &Entity<Buffer>| {
+ buffer
+ .read(cx)
+ .file()
+ .map(|file| file.full_path(cx).compact().to_string_lossy().to_string())
+ .unwrap_or_else(|| "untitled".into())
+ };
+ let old_path = path(&self.old_buffer);
+ let new_path = path(&self.new_buffer);
+
Some(format!("{old_path} ↔ {new_path}").into())
}
@@ -363,7 +352,7 @@ impl Item for DiffView {
}
}
-impl Render for DiffView {
+impl Render for FileDiffView {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
self.editor.clone()
}
@@ -407,16 +396,16 @@ mod tests {
)
.await;
- let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
+ let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
- let (workspace, mut cx) =
+ let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let diff_view = workspace
.update_in(cx, |workspace, window, cx| {
- DiffView::open(
- PathBuf::from(path!("/test/old_file.txt")),
- PathBuf::from(path!("/test/new_file.txt")),
+ FileDiffView::open(
+ path!("/test/old_file.txt").into(),
+ path!("/test/new_file.txt").into(),
workspace,
window,
cx,
@@ -428,7 +417,7 @@ mod tests {
// Verify initial diff
assert_state_with_diff(
&diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
- &mut cx,
+ cx,
&unindent(
"
- old line 1
@@ -463,7 +452,7 @@ mod tests {
cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE);
assert_state_with_diff(
&diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
- &mut cx,
+ cx,
&unindent(
"
- old line 1
@@ -498,7 +487,7 @@ mod tests {
cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE);
assert_state_with_diff(
&diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
- &mut cx,
+ cx,
&unindent(
"
ˇnew line 1
@@ -510,6 +499,21 @@ mod tests {
",
),
);
+
+ diff_view.read_with(cx, |diff_view, cx| {
+ assert_eq!(
+ diff_view.tab_content_text(0, cx),
+ "old_file.txt ↔ new_file.txt"
+ );
+ assert_eq!(
+ diff_view.tab_tooltip_text(cx).unwrap(),
+ format!(
+ "{} ↔ {}",
+ path!("test/old_file.txt"),
+ path!("test/new_file.txt")
+ )
+ );
+ })
}
#[gpui::test]
@@ -533,7 +537,7 @@ mod tests {
let diff_view = workspace
.update_in(cx, |workspace, window, cx| {
- DiffView::open(
+ FileDiffView::open(
PathBuf::from(path!("/test/old_file.txt")),
PathBuf::from(path!("/test/new_file.txt")),
workspace,
@@ -25,8 +25,11 @@ use git::repository::{
UpstreamTrackingStatus, get_git_committer,
};
use git::status::StageStatus;
-use git::{Amend, ToggleStaged, repository::RepoPath, status::FileStatus};
-use git::{ExpandCommitEditor, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
+use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
+use git::{
+ ExpandCommitEditor, RestoreTrackedFiles, StageAll, StashAll, StashPop, TrashUntrackedFiles,
+ UnstageAll,
+};
use gpui::{
Action, Animation, AnimationExt as _, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner,
DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext,
@@ -47,13 +50,12 @@ use panel::{
PanelHeader, panel_button, panel_editor_container, panel_editor_style, panel_filled_button,
panel_icon_button,
};
-use project::git_store::{RepositoryEvent, RepositoryId};
use project::{
- Fs, Project, ProjectPath,
- git_store::{GitStoreEvent, Repository},
+ DisableAiSettings, Fs, Project, ProjectPath,
+ git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId},
};
use serde::{Deserialize, Serialize};
-use settings::{Settings as _, SettingsStore};
+use settings::{Settings, SettingsStore};
use std::future::Future;
use std::ops::Range;
use std::path::{Path, PathBuf};
@@ -61,17 +63,18 @@ use std::{collections::HashSet, sync::Arc, time::Duration, usize};
use strum::{IntoEnumIterator, VariantNames};
use time::OffsetDateTime;
use ui::{
- Checkbox, ContextMenu, ElevationIndex, PopoverMenu, Scrollbar, ScrollbarState, SplitButton,
- Tooltip, prelude::*,
+ Checkbox, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize, PopoverMenu, Scrollbar,
+ ScrollbarState, SplitButton, Tooltip, prelude::*,
};
use util::{ResultExt, TryFutureExt, maybe};
+use workspace::SERIALIZATION_THROTTLE_TIME;
+use cloud_llm_client::CompletionIntent;
use workspace::{
Workspace,
dock::{DockPosition, Panel, PanelEvent},
notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId},
};
-use zed_llm_client::CompletionIntent;
actions!(
git_panel,
@@ -100,7 +103,7 @@ fn prompt<T>(
where
T: IntoEnumIterator + VariantNames + 'static,
{
- let rx = window.prompt(PromptLevel::Info, msg, detail, &T::VARIANTS, cx);
+ let rx = window.prompt(PromptLevel::Info, msg, detail, T::VARIANTS, cx);
cx.spawn(async move |_| Ok(T::iter().nth(rx.await?).unwrap()))
}
@@ -138,6 +141,13 @@ fn git_panel_context_menu(
UnstageAll.boxed_clone(),
)
.separator()
+ .action_disabled_when(
+ !(state.has_new_changes || state.has_tracked_changes),
+ "Stash All",
+ StashAll.boxed_clone(),
+ )
+ .action("Stash Pop", StashPop.boxed_clone())
+ .separator()
.action("Open Diff", project_diff::Diff.boxed_clone())
.separator()
.action_disabled_when(
@@ -174,6 +184,10 @@ pub enum Event {
#[derive(Serialize, Deserialize)]
struct SerializedGitPanel {
width: Option<Pixels>,
+ #[serde(default)]
+ amend_pending: bool,
+ #[serde(default)]
+ signoff_enabled: bool,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
@@ -337,7 +351,8 @@ pub struct GitPanel {
pending: Vec<PendingOperation>,
pending_commit: Option<Task<()>>,
amend_pending: bool,
- pending_serialization: Task<Option<()>>,
+ signoff_enabled: bool,
+ pending_serialization: Task<()>,
pub(crate) project: Entity<Project>,
scroll_handle: UniformListScrollHandle,
max_width_item_index: Option<usize>,
@@ -373,6 +388,9 @@ pub(crate) fn commit_message_editor(
window: &mut Window,
cx: &mut Context<Editor>,
) -> Editor {
+ project.update(cx, |this, cx| {
+ this.mark_buffer_as_non_searchable(commit_message_buffer.read(cx).remote_id(), cx);
+ });
let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
let max_lines = if in_panel { MAX_PANEL_EDITOR_LINES } else { 18 };
let mut commit_editor = Editor::new(
@@ -408,7 +426,7 @@ impl GitPanel {
let git_store = project.read(cx).git_store().clone();
let active_repository = project.read(cx).active_repository(cx);
- let git_panel = cx.new(|cx| {
+ cx.new(|cx| {
let focus_handle = cx.focus_handle();
cx.on_focus(&focus_handle, window, Self::focus_in).detach();
cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
@@ -458,9 +476,14 @@ impl GitPanel {
};
let mut assistant_enabled = AgentSettings::get_global(cx).enabled;
+ let mut was_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
let _settings_subscription = cx.observe_global::<SettingsStore>(move |_, cx| {
- if assistant_enabled != AgentSettings::get_global(cx).enabled {
+ let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
+ if assistant_enabled != AgentSettings::get_global(cx).enabled
+ || was_ai_disabled != is_ai_disabled
+ {
assistant_enabled = AgentSettings::get_global(cx).enabled;
+ was_ai_disabled = is_ai_disabled;
cx.notify();
}
});
@@ -512,7 +535,8 @@ impl GitPanel {
pending: Vec::new(),
pending_commit: None,
amend_pending: false,
- pending_serialization: Task::ready(None),
+ signoff_enabled: false,
+ pending_serialization: Task::ready(()),
single_staged_entry: None,
single_tracked_entry: None,
project,
@@ -539,9 +563,7 @@ impl GitPanel {
this.schedule_update(false, window, cx);
this
- });
-
- git_panel
+ })
}
fn hide_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -628,14 +650,14 @@ impl GitPanel {
if GitPanelSettings::get_global(cx).sort_by_path {
return self
.entries
- .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path))
+ .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
.ok();
}
if self.conflicted_count > 0 {
let conflicted_start = 1;
if let Ok(ix) = self.entries[conflicted_start..conflicted_start + self.conflicted_count]
- .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path))
+ .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
{
return Some(conflicted_start + ix);
}
@@ -647,7 +669,7 @@ impl GitPanel {
0
} + 1;
if let Ok(ix) = self.entries[tracked_start..tracked_start + self.tracked_count]
- .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path))
+ .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
{
return Some(tracked_start + ix);
}
@@ -663,7 +685,7 @@ impl GitPanel {
0
} + 1;
if let Ok(ix) = self.entries[untracked_start..untracked_start + self.new_count]
- .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path))
+ .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
{
return Some(untracked_start + ix);
}
@@ -690,20 +712,54 @@ impl GitPanel {
cx.notify();
}
+ fn serialization_key(workspace: &Workspace) -> Option<String> {
+ workspace
+ .database_id()
+ .map(|id| i64::from(id).to_string())
+ .or(workspace.session_id())
+ .map(|id| format!("{}-{:?}", GIT_PANEL_KEY, id))
+ }
+
fn serialize(&mut self, cx: &mut Context<Self>) {
let width = self.width;
- self.pending_serialization = cx.background_spawn(
- async move {
- KEY_VALUE_STORE
- .write_kvp(
- GIT_PANEL_KEY.into(),
- serde_json::to_string(&SerializedGitPanel { width })?,
- )
- .await?;
- anyhow::Ok(())
- }
- .log_err(),
- );
+ let amend_pending = self.amend_pending;
+ let signoff_enabled = self.signoff_enabled;
+
+ self.pending_serialization = cx.spawn(async move |git_panel, cx| {
+ cx.background_executor()
+ .timer(SERIALIZATION_THROTTLE_TIME)
+ .await;
+ let Some(serialization_key) = git_panel
+ .update(cx, |git_panel, cx| {
+ git_panel
+ .workspace
+ .read_with(cx, |workspace, _| Self::serialization_key(workspace))
+ .ok()
+ .flatten()
+ })
+ .ok()
+ .flatten()
+ else {
+ return;
+ };
+ cx.background_spawn(
+ async move {
+ KEY_VALUE_STORE
+ .write_kvp(
+ serialization_key,
+ serde_json::to_string(&SerializedGitPanel {
+ width,
+ amend_pending,
+ signoff_enabled,
+ })?,
+ )
+ .await?;
+ anyhow::Ok(())
+ }
+ .log_err(),
+ )
+ .await;
+ });
}
pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
@@ -717,7 +773,7 @@ impl GitPanel {
if window
.focused(cx)
- .map_or(false, |focused| self.focus_handle == focused)
+ .is_some_and(|focused| self.focus_handle == focused)
{
dispatch_context.add("menu");
dispatch_context.add("ChangesList");
@@ -836,9 +892,7 @@ impl GitPanel {
let have_entries = self
.active_repository
.as_ref()
- .map_or(false, |active_repository| {
- active_repository.read(cx).status_summary().count > 0
- });
+ .is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0);
if have_entries && self.selected_entry.is_none() {
self.selected_entry = Some(1);
self.scroll_to_selected_entry(cx);
@@ -868,19 +922,17 @@ impl GitPanel {
let workspace = self.workspace.upgrade()?;
let git_repo = self.active_repository.as_ref()?;
- if let Some(project_diff) = workspace.read(cx).active_item_as::<ProjectDiff>(cx) {
- if let Some(project_path) = project_diff.read(cx).active_path(cx) {
- if Some(&entry.repo_path)
- == git_repo
- .read(cx)
- .project_path_to_repo_path(&project_path, cx)
- .as_ref()
- {
- project_diff.focus_handle(cx).focus(window);
- project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx));
- return None;
- }
- }
+ if let Some(project_diff) = workspace.read(cx).active_item_as::<ProjectDiff>(cx)
+ && let Some(project_path) = project_diff.read(cx).active_path(cx)
+ && Some(&entry.repo_path)
+ == git_repo
+ .read(cx)
+ .project_path_to_repo_path(&project_path, cx)
+ .as_ref()
+ {
+ project_diff.focus_handle(cx).focus(window);
+ project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx));
+ return None;
};
self.workspace
@@ -1144,16 +1196,13 @@ impl GitPanel {
window,
cx,
);
- cx.spawn(async move |this, cx| match prompt.await {
- Ok(RestoreCancel::RestoreTrackedFiles) => {
+ cx.spawn(async move |this, cx| {
+ if let Ok(RestoreCancel::RestoreTrackedFiles) = prompt.await {
this.update(cx, |this, cx| {
this.perform_checkout(entries, cx);
})
.ok();
}
- _ => {
- return;
- }
})
.detach();
}
@@ -1283,10 +1332,10 @@ impl GitPanel {
.iter()
.filter_map(|entry| entry.status_entry())
.filter(|status_entry| {
- section.contains(&status_entry, repository)
+ section.contains(status_entry, repository)
&& status_entry.staging.as_bool() != Some(goal_staged_state)
})
- .map(|status_entry| status_entry.clone())
+ .cloned()
.collect::<Vec<_>>();
(goal_staged_state, entries)
@@ -1365,6 +1414,52 @@ impl GitPanel {
self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count
}
+ pub fn stash_pop(&mut self, _: &StashPop, _window: &mut Window, cx: &mut Context<Self>) {
+ let Some(active_repository) = self.active_repository.clone() else {
+ return;
+ };
+
+ cx.spawn({
+ async move |this, cx| {
+ let stash_task = active_repository
+ .update(cx, |repo, cx| repo.stash_pop(cx))?
+ .await;
+ this.update(cx, |this, cx| {
+ stash_task
+ .map_err(|e| {
+ this.show_error_toast("stash pop", e, cx);
+ })
+ .ok();
+ cx.notify();
+ })
+ }
+ })
+ .detach();
+ }
+
+ pub fn stash_all(&mut self, _: &StashAll, _window: &mut Window, cx: &mut Context<Self>) {
+ let Some(active_repository) = self.active_repository.clone() else {
+ return;
+ };
+
+ cx.spawn({
+ async move |this, cx| {
+ let stash_task = active_repository
+ .update(cx, |repo, cx| repo.stash_all(cx))?
+ .await;
+ this.update(cx, |this, cx| {
+ stash_task
+ .map_err(|e| {
+ this.show_error_toast("stash", e, cx);
+ })
+ .ok();
+ cx.notify();
+ })
+ }
+ })
+ .detach();
+ }
+
pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
self.commit_editor
.read(cx)
@@ -1372,7 +1467,6 @@ impl GitPanel {
.read(cx)
.as_singleton()
.unwrap()
- .clone()
}
fn toggle_staged_for_selected(
@@ -1432,7 +1526,14 @@ impl GitPanel {
.contains_focused(window, cx)
{
telemetry::event!("Git Committed", source = "Git Panel");
- self.commit_changes(CommitOptions { amend: false }, window, cx)
+ self.commit_changes(
+ CommitOptions {
+ amend: false,
+ signoff: self.signoff_enabled,
+ },
+ window,
+ cx,
+ )
} else {
cx.propagate();
}
@@ -1444,19 +1545,21 @@ impl GitPanel {
.focus_handle(cx)
.contains_focused(window, cx)
{
- if self
- .active_repository
- .as_ref()
- .and_then(|repo| repo.read(cx).head_commit.as_ref())
- .is_some()
- {
+ if self.head_commit(cx).is_some() {
if !self.amend_pending {
self.set_amend_pending(true, cx);
self.load_last_commit_message_if_empty(cx);
} else {
telemetry::event!("Git Amended", source = "Git Panel");
self.set_amend_pending(false, cx);
- self.commit_changes(CommitOptions { amend: true }, window, cx);
+ self.commit_changes(
+ CommitOptions {
+ amend: true,
+ signoff: self.signoff_enabled,
+ },
+ window,
+ cx,
+ );
}
}
} else {
@@ -1464,21 +1567,21 @@ impl GitPanel {
}
}
+ pub fn head_commit(&self, cx: &App) -> Option<CommitDetails> {
+ self.active_repository
+ .as_ref()
+ .and_then(|repo| repo.read(cx).head_commit.as_ref())
+ .cloned()
+ }
+
pub fn load_last_commit_message_if_empty(&mut self, cx: &mut Context<Self>) {
if !self.commit_editor.read(cx).is_empty(cx) {
return;
}
- let Some(active_repository) = self.active_repository.as_ref() else {
- return;
- };
- let Some(recent_sha) = active_repository
- .read(cx)
- .head_commit
- .as_ref()
- .map(|commit| commit.sha.to_string())
- else {
+ let Some(head_commit) = self.head_commit(cx) else {
return;
};
+ let recent_sha = head_commit.sha.to_string();
let detail_task = self.load_commit_details(recent_sha, cx);
cx.spawn(async move |this, cx| {
if let Ok(message) = detail_task.await.map(|detail| detail.message) {
@@ -1495,12 +1598,6 @@ impl GitPanel {
.detach();
}
- fn cancel(&mut self, _: &git::Cancel, _: &mut Window, cx: &mut Context<Self>) {
- if self.amend_pending {
- self.set_amend_pending(false, cx);
- }
- }
-
fn custom_or_suggested_commit_message(
&self,
window: &mut Window,
@@ -1535,13 +1632,12 @@ impl GitPanel {
fn has_commit_message(&self, cx: &mut Context<Self>) -> bool {
let text = self.commit_editor.read(cx).text(cx);
if !text.trim().is_empty() {
- return true;
+ true
} else if text.is_empty() {
- return self
- .suggest_commit_message(cx)
- .is_some_and(|text| !text.trim().is_empty());
+ self.suggest_commit_message(cx)
+ .is_some_and(|text| !text.trim().is_empty())
} else {
- return false;
+ false
}
}
@@ -1726,7 +1822,9 @@ impl GitPanel {
let git_status_entry = if let Some(staged_entry) = &self.single_staged_entry {
Some(staged_entry)
- } else if let Some(single_tracked_entry) = &self.single_tracked_entry {
+ } else if self.total_staged_count() == 0
+ && let Some(single_tracked_entry) = &self.single_tracked_entry
+ {
Some(single_tracked_entry)
} else {
None
@@ -1762,7 +1860,7 @@ impl GitPanel {
/// Generates a commit message using an LLM.
pub fn generate_commit_message(&mut self, cx: &mut Context<Self>) {
- if !self.can_commit() {
+ if !self.can_commit() || DisableAiSettings::get_global(cx).disable_ai {
return;
}
@@ -1843,7 +1941,7 @@ impl GitPanel {
thinking_allowed: false,
};
- let stream = model.stream_completion_text(request, &cx);
+ let stream = model.stream_completion_text(request, cx);
match stream.await {
Ok(mut messages) => {
if !text_empty {
@@ -1974,6 +2072,100 @@ impl GitPanel {
.detach_and_log_err(cx);
}
+ pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context<Self>) {
+ let path = cx.prompt_for_paths(gpui::PathPromptOptions {
+ files: false,
+ directories: true,
+ multiple: false,
+ prompt: Some("Select as Repository Destination".into()),
+ });
+
+ let workspace = self.workspace.clone();
+
+ cx.spawn_in(window, async move |this, cx| {
+ let mut paths = path.await.ok()?.ok()??;
+ let mut path = paths.pop()?;
+ let repo_name = repo
+ .split(std::path::MAIN_SEPARATOR_STR)
+ .last()?
+ .strip_suffix(".git")?
+ .to_owned();
+
+ let fs = this.read_with(cx, |this, _| this.fs.clone()).ok()?;
+
+ let prompt_answer = match fs.git_clone(&repo, path.as_path()).await {
+ Ok(_) => cx.update(|window, cx| {
+ window.prompt(
+ PromptLevel::Info,
+ &format!("Git Clone: {}", repo_name),
+ None,
+ &["Add repo to project", "Open repo in new project"],
+ cx,
+ )
+ }),
+ Err(e) => {
+ this.update(cx, |this: &mut GitPanel, cx| {
+ let toast = StatusToast::new(e.to_string(), cx, |this, _| {
+ this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
+ .dismiss_button(true)
+ });
+
+ this.workspace
+ .update(cx, |workspace, cx| {
+ workspace.toggle_status_toast(toast, cx);
+ })
+ .ok();
+ })
+ .ok()?;
+
+ return None;
+ }
+ }
+ .ok()?;
+
+ path.push(repo_name);
+ match prompt_answer.await.ok()? {
+ 0 => {
+ workspace
+ .update(cx, |workspace, cx| {
+ workspace
+ .project()
+ .update(cx, |project, cx| {
+ project.create_worktree(path.as_path(), true, cx)
+ })
+ .detach();
+ })
+ .ok();
+ }
+ 1 => {
+ workspace
+ .update(cx, move |workspace, cx| {
+ workspace::open_new(
+ Default::default(),
+ workspace.app_state().clone(),
+ cx,
+ move |workspace, _, cx| {
+ cx.activate(true);
+ workspace
+ .project()
+ .update(cx, |project, cx| {
+ project.create_worktree(&path, true, cx)
+ })
+ .detach();
+ },
+ )
+ .detach();
+ })
+ .ok();
+ }
+ _ => {}
+ }
+
+ Some(())
+ })
+ .detach();
+ }
+
pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let worktrees = self
.project
@@ -1983,7 +2175,7 @@ impl GitPanel {
let worktree = if worktrees.len() == 1 {
Task::ready(Some(worktrees.first().unwrap().clone()))
- } else if worktrees.len() == 0 {
+ } else if worktrees.is_empty() {
let result = window.prompt(
PromptLevel::Warning,
"Unable to initialize a git repository",
@@ -2307,14 +2499,15 @@ impl GitPanel {
.committer_name
.clone()
.or_else(|| participant.user.name.clone())
- .unwrap_or_else(|| participant.user.github_login.clone());
+ .unwrap_or_else(|| participant.user.github_login.clone().to_string());
new_co_authors.push((name.clone(), email.clone()))
}
}
- if !project.is_local() && !project.is_read_only(cx) {
- if let Some(local_committer) = self.local_committer(room, cx) {
- new_co_authors.push(local_committer);
- }
+ if !project.is_local()
+ && !project.is_read_only(cx)
+ && let Some(local_committer) = self.local_committer(room, cx)
+ {
+ new_co_authors.push(local_committer);
}
new_co_authors
}
@@ -2327,7 +2520,7 @@ impl GitPanel {
.name
.clone()
.or_else(|| user.name.clone())
- .unwrap_or_else(|| user.github_login.clone());
+ .unwrap_or_else(|| user.github_login.clone().to_string());
Some((name, email))
}
@@ -2553,35 +2746,34 @@ impl GitPanel {
for pending in self.pending.iter() {
if pending.target_status == TargetStatus::Staged {
pending_staged_count += pending.entries.len();
- last_pending_staged = pending.entries.iter().next().cloned();
+ last_pending_staged = pending.entries.first().cloned();
}
- if let Some(single_staged) = &single_staged_entry {
- if pending
+ if let Some(single_staged) = &single_staged_entry
+ && pending
.entries
.iter()
.any(|entry| entry.repo_path == single_staged.repo_path)
- {
- pending_status_for_single_staged = Some(pending.target_status);
- }
+ {
+ pending_status_for_single_staged = Some(pending.target_status);
}
}
- if conflict_entries.len() == 0 && staged_count == 1 && pending_staged_count == 0 {
+ if conflict_entries.is_empty() && staged_count == 1 && pending_staged_count == 0 {
match pending_status_for_single_staged {
Some(TargetStatus::Staged) | None => {
self.single_staged_entry = single_staged_entry;
}
_ => {}
}
- } else if conflict_entries.len() == 0 && pending_staged_count == 1 {
+ } else if conflict_entries.is_empty() && pending_staged_count == 1 {
self.single_staged_entry = last_pending_staged;
}
- if conflict_entries.len() == 0 && changed_entries.len() == 1 {
+ if conflict_entries.is_empty() && changed_entries.len() == 1 {
self.single_tracked_entry = changed_entries.first().cloned();
}
- if conflict_entries.len() > 0 {
+ if !conflict_entries.is_empty() {
self.entries.push(GitListEntry::Header(GitHeaderEntry {
header: Section::Conflict,
}));
@@ -2589,7 +2781,7 @@ impl GitPanel {
.extend(conflict_entries.into_iter().map(GitListEntry::Status));
}
- if changed_entries.len() > 0 {
+ if !changed_entries.is_empty() {
if !sort_by_path {
self.entries.push(GitListEntry::Header(GitHeaderEntry {
header: Section::Tracked,
@@ -2598,7 +2790,7 @@ impl GitPanel {
self.entries
.extend(changed_entries.into_iter().map(GitListEntry::Status));
}
- if new_entries.len() > 0 {
+ if !new_entries.is_empty() {
self.entries.push(GitListEntry::Header(GitHeaderEntry {
header: Section::New,
}));
@@ -2737,8 +2929,7 @@ impl GitPanel {
.matches(git::repository::REMOTE_CANCELLED_BY_USER)
.next()
.is_some()
- {
- return; // Hide the cancelled by user message
+ { // Hide the cancelled by user message
} else {
workspace.update(cx, |workspace, cx| {
let workspace_weak = cx.weak_entity();
@@ -2792,9 +2983,9 @@ impl GitPanel {
let status_toast = StatusToast::new(message, cx, move |this, _cx| {
use remote_output::SuccessStyle::*;
match style {
- Toast { .. } => this,
+ Toast => this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)),
ToastWithLog { output } => this
- .icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
+ .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
.action("View Log", move |window, cx| {
let output = output.clone();
let output =
@@ -2805,9 +2996,9 @@ impl GitPanel {
})
.ok();
}),
- PushPrLink { link } => this
- .icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
- .action("Open Pull Request", move |_, cx| cx.open_url(&link)),
+ PushPrLink { text, link } => this
+ .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
+ .action(text, move |_, cx| cx.open_url(&link)),
}
});
workspace.toggle_status_toast(status_toast, cx)
@@ -3000,17 +3191,48 @@ impl GitPanel {
.justify_center()
.border_l_1()
.border_color(cx.theme().colors().border)
- .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
+ .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
),
)
- .menu(move |window, cx| {
- Some(ContextMenu::build(window, cx, |context_menu, _, _| {
- context_menu
- .when_some(keybinding_target.clone(), |el, keybinding_target| {
- el.context(keybinding_target.clone())
- })
- .action("Amend", Amend.boxed_clone())
- }))
+ .menu({
+ let git_panel = cx.entity();
+ let has_previous_commit = self.head_commit(cx).is_some();
+ let amend = self.amend_pending();
+ let signoff = self.signoff_enabled;
+
+ move |window, cx| {
+ Some(ContextMenu::build(window, cx, |context_menu, _, _| {
+ context_menu
+ .when_some(keybinding_target.clone(), |el, keybinding_target| {
+ el.context(keybinding_target)
+ })
+ .when(has_previous_commit, |this| {
+ this.toggleable_entry(
+ "Amend",
+ amend,
+ IconPosition::Start,
+ Some(Box::new(Amend)),
+ {
+ let git_panel = git_panel.downgrade();
+ move |_, cx| {
+ git_panel
+ .update(cx, |git_panel, cx| {
+ git_panel.toggle_amend_pending(cx);
+ })
+ .ok();
+ }
+ },
+ )
+ })
+ .toggleable_entry(
+ "Signoff",
+ signoff,
+ IconPosition::Start,
+ Some(Box::new(Signoff)),
+ move |window, cx| window.dispatch_action(Box::new(Signoff), cx),
+ )
+ }))
+ }
})
.anchor(Corner::TopRight)
}
@@ -3038,12 +3260,10 @@ impl GitPanel {
} else {
"Amend Tracked"
}
+ } else if self.has_staged_changes() {
+ "Commit"
} else {
- if self.has_staged_changes() {
- "Commit"
- } else {
- "Commit Tracked"
- }
+ "Commit Tracked"
}
}
@@ -3164,7 +3384,7 @@ impl GitPanel {
let enable_coauthors = self.render_co_authors(cx);
let editor_focus_handle = self.commit_editor.focus_handle(cx);
- let expand_tooltip_focus_handle = editor_focus_handle.clone();
+ let expand_tooltip_focus_handle = editor_focus_handle;
let branch = active_repository.read(cx).branch.clone();
let head_commit = active_repository.read(cx).head_commit.clone();
@@ -3177,7 +3397,7 @@ impl GitPanel {
* MAX_PANEL_EDITOR_LINES
+ gap;
- let git_panel = cx.entity().clone();
+ let git_panel = cx.entity();
let display_name = SharedString::from(Arc::from(
active_repository
.read(cx)
@@ -3187,14 +3407,13 @@ impl GitPanel {
let editor_is_long = self.commit_editor.update(cx, |editor, cx| {
editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32
});
- let has_previous_commit = head_commit.is_some();
let footer = v_flex()
.child(PanelRepoFooter::new(
display_name,
branch,
head_commit,
- Some(git_panel.clone()),
+ Some(git_panel),
))
.child(
panel_editor_container(window, cx)
@@ -3231,7 +3450,7 @@ impl GitPanel {
h_flex()
.gap_0p5()
.children(enable_coauthors)
- .child(self.render_commit_button(has_previous_commit, cx)),
+ .child(self.render_commit_button(cx)),
),
)
.child(
@@ -3280,14 +3499,12 @@ impl GitPanel {
Some(footer)
}
- fn render_commit_button(
- &self,
- has_previous_commit: bool,
- cx: &mut Context<Self>,
- ) -> impl IntoElement {
+ fn render_commit_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
let (can_commit, tooltip) = self.configure_commit_button(cx);
let title = self.commit_button_title();
let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx);
+ let amend = self.amend_pending();
+ let signoff = self.signoff_enabled;
div()
.id("commit-wrapper")
@@ -3296,164 +3513,87 @@ impl GitPanel {
*hovered && !this.has_staged_changes() && !this.has_unstaged_conflicts();
cx.notify()
}))
- .when(self.amend_pending, {
- |this| {
- this.h_flex()
- .gap_1()
- .child(
- panel_filled_button("Cancel")
- .tooltip({
- let handle = commit_tooltip_focus_handle.clone();
- move |window, cx| {
- Tooltip::for_action_in(
- "Cancel amend",
- &git::Cancel,
- &handle,
- window,
- cx,
- )
- }
- })
- .on_click(move |_, window, cx| {
- window.dispatch_action(Box::new(git::Cancel), cx);
- }),
- )
- .child(
- panel_filled_button(title)
- .tooltip({
- let handle = commit_tooltip_focus_handle.clone();
- move |window, cx| {
- if can_commit {
- Tooltip::for_action_in(
- tooltip, &Amend, &handle, window, cx,
- )
- } else {
- Tooltip::simple(tooltip, cx)
- }
- }
- })
- .disabled(!can_commit || self.modal_open)
- .on_click({
- let git_panel = cx.weak_entity();
- move |_, window, cx| {
- telemetry::event!("Git Amended", source = "Git Panel");
- git_panel
- .update(cx, |git_panel, cx| {
- git_panel.set_amend_pending(false, cx);
- git_panel.commit_changes(
- CommitOptions { amend: true },
- window,
- cx,
- );
- })
- .ok();
- }
- }),
- )
- }
- })
- .when(!self.amend_pending, |this| {
- this.when(has_previous_commit, |this| {
- this.child(SplitButton::new(
- ui::ButtonLike::new_rounded_left(ElementId::Name(
- format!("split-button-left-{}", title).into(),
- ))
- .layer(ui::ElevationIndex::ModalSurface)
- .size(ui::ButtonSize::Compact)
- .child(
- div()
- .child(Label::new(title).size(LabelSize::Small))
- .mr_0p5(),
- )
- .on_click({
- let git_panel = cx.weak_entity();
- move |_, window, cx| {
- telemetry::event!("Git Committed", source = "Git Panel");
- git_panel
- .update(cx, |git_panel, cx| {
- git_panel.commit_changes(
- CommitOptions { amend: false },
- window,
- cx,
- );
- })
- .ok();
- }
- })
- .disabled(!can_commit || self.modal_open)
- .tooltip({
- let handle = commit_tooltip_focus_handle.clone();
- move |window, cx| {
- if can_commit {
- Tooltip::with_meta_in(
- tooltip,
- Some(&git::Commit),
- "git commit",
- &handle.clone(),
- window,
- cx,
- )
- } else {
- Tooltip::simple(tooltip, cx)
- }
- }
- }),
- self.render_git_commit_menu(
- ElementId::Name(format!("split-button-right-{}", title).into()),
- Some(commit_tooltip_focus_handle.clone()),
- cx,
- )
- .into_any_element(),
- ))
- })
- .when(!has_previous_commit, |this| {
- this.child(
- panel_filled_button(title)
- .tooltip(move |window, cx| {
- if can_commit {
- Tooltip::with_meta_in(
- tooltip,
- Some(&git::Commit),
- "git commit",
- &commit_tooltip_focus_handle,
- window,
- cx,
- )
- } else {
- Tooltip::simple(tooltip, cx)
- }
+ .child(SplitButton::new(
+ ui::ButtonLike::new_rounded_left(ElementId::Name(
+ format!("split-button-left-{}", title).into(),
+ ))
+ .layer(ui::ElevationIndex::ModalSurface)
+ .size(ui::ButtonSize::Compact)
+ .child(
+ div()
+ .child(Label::new(title).size(LabelSize::Small))
+ .mr_0p5(),
+ )
+ .on_click({
+ let git_panel = cx.weak_entity();
+ move |_, window, cx| {
+ telemetry::event!("Git Committed", source = "Git Panel");
+ git_panel
+ .update(cx, |git_panel, cx| {
+ git_panel.set_amend_pending(false, cx);
+ git_panel.commit_changes(
+ CommitOptions { amend, signoff },
+ window,
+ cx,
+ );
})
- .disabled(!can_commit || self.modal_open)
- .on_click({
- let git_panel = cx.weak_entity();
- move |_, window, cx| {
- telemetry::event!("Git Committed", source = "Git Panel");
- git_panel
- .update(cx, |git_panel, cx| {
- git_panel.commit_changes(
- CommitOptions { amend: false },
- window,
- cx,
- );
- })
- .ok();
- }
- }),
- )
+ .ok();
+ }
})
- })
+ .disabled(!can_commit || self.modal_open)
+ .tooltip({
+ let handle = commit_tooltip_focus_handle.clone();
+ move |window, cx| {
+ if can_commit {
+ Tooltip::with_meta_in(
+ tooltip,
+ Some(&git::Commit),
+ format!(
+ "git commit{}{}",
+ if amend { " --amend" } else { "" },
+ if signoff { " --signoff" } else { "" }
+ ),
+ &handle.clone(),
+ window,
+ cx,
+ )
+ } else {
+ Tooltip::simple(tooltip, cx)
+ }
+ }
+ }),
+ self.render_git_commit_menu(
+ ElementId::Name(format!("split-button-right-{}", title).into()),
+ Some(commit_tooltip_focus_handle),
+ cx,
+ )
+ .into_any_element(),
+ ))
}
fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
- div()
- .p_2()
+ h_flex()
+ .py_1p5()
+ .px_2()
+ .gap_1p5()
+ .justify_between()
.border_t_1()
- .border_color(cx.theme().colors().border)
+ .border_color(cx.theme().colors().border.opacity(0.8))
.child(
- Label::new(
- "This will update your most recent commit. Cancel to make a new one instead.",
- )
- .size(LabelSize::Small),
+ div()
+ .flex_grow()
+ .overflow_hidden()
+ .max_w(relative(0.85))
+ .child(
+ Label::new("This will update your most recent commit.")
+ .size(LabelSize::Small)
+ .truncate(),
+ ),
+ )
+ .child(
+ panel_button("Cancel")
+ .size(ButtonSize::Default)
+ .on_click(cx.listener(|this, _, _, cx| this.set_amend_pending(false, cx))),
)
}
@@ -3,18 +3,24 @@ use std::any::Any;
use ::settings::Settings;
use command_palette_hooks::CommandPaletteFilter;
use commit_modal::CommitModal;
-use editor::Editor;
+use editor::{Editor, actions::DiffClipboardWithSelectionData};
mod blame_ui;
use git::{
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
};
use git_panel_settings::GitPanelSettings;
-use gpui::{Action, App, Context, FocusHandle, Window, actions};
+use gpui::{
+ Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window,
+ actions,
+};
use onboarding::GitOnboardingModal;
use project_diff::ProjectDiff;
use ui::prelude::*;
-use workspace::Workspace;
+use workspace::{ModalView, Workspace};
+use zed_actions;
+
+use crate::{git_panel::GitPanel, text_diff_view::TextDiffView};
mod askpass_modal;
pub mod branch_picker;
@@ -22,7 +28,7 @@ mod commit_modal;
pub mod commit_tooltip;
mod commit_view;
mod conflict_view;
-pub mod diff_view;
+pub mod file_diff_view;
pub mod git_panel;
mod git_panel_settings;
pub mod onboarding;
@@ -30,6 +36,7 @@ pub mod picker_prompt;
pub mod project_diff;
pub(crate) mod remote_output;
pub mod repository_selector;
+pub mod text_diff_view;
actions!(
git,
@@ -110,6 +117,22 @@ pub fn init(cx: &mut App) {
});
});
}
+ workspace.register_action(|workspace, action: &git::StashAll, window, cx| {
+ let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+ return;
+ };
+ panel.update(cx, |panel, cx| {
+ panel.stash_all(action, window, cx);
+ });
+ });
+ workspace.register_action(|workspace, action: &git::StashPop, window, cx| {
+ let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+ return;
+ };
+ panel.update(cx, |panel, cx| {
+ panel.stash_pop(action, window, cx);
+ });
+ });
workspace.register_action(|workspace, action: &git::StageAll, window, cx| {
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
return;
@@ -149,9 +172,25 @@ pub fn init(cx: &mut App) {
panel.git_init(window, cx);
});
});
+ workspace.register_action(|workspace, _action: &git::Clone, window, cx| {
+ let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+ return;
+ };
+
+ workspace.toggle_modal(window, cx, |window, cx| {
+ GitCloneModal::show(panel, window, cx)
+ });
+ });
workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
open_modified_files(workspace, window, cx);
});
+ workspace.register_action(
+ |workspace, action: &DiffClipboardWithSelectionData, window, cx| {
+ if let Some(task) = TextDiffView::open(action, workspace, window, cx) {
+ task.detach();
+ };
+ },
+ );
})
.detach();
}
@@ -206,12 +245,12 @@ fn render_remote_button(
}
(0, 0) => None,
(ahead, 0) => Some(remote_button::render_push_button(
- keybinding_target.clone(),
+ keybinding_target,
id,
ahead,
)),
(ahead, behind) => Some(remote_button::render_pull_button(
- keybinding_target.clone(),
+ keybinding_target,
id,
ahead,
behind,
@@ -329,7 +368,7 @@ mod remote_button {
"Publish",
0,
0,
- Some(IconName::ArrowUpFromLine),
+ Some(IconName::ExpandUp),
keybinding_target.clone(),
move |_, window, cx| {
window.dispatch_action(Box::new(git::Push), cx);
@@ -356,7 +395,7 @@ mod remote_button {
"Republish",
0,
0,
- Some(IconName::ArrowUpFromLine),
+ Some(IconName::ExpandUp),
keybinding_target.clone(),
move |_, window, cx| {
window.dispatch_action(Box::new(git::Push), cx);
@@ -386,16 +425,9 @@ mod remote_button {
let command = command.into();
if let Some(handle) = focus_handle {
- Tooltip::with_meta_in(
- label.clone(),
- Some(action),
- command.clone(),
- &handle,
- window,
- cx,
- )
+ Tooltip::with_meta_in(label, Some(action), command, &handle, window, cx)
} else {
- Tooltip::with_meta(label.clone(), Some(action), command.clone(), window, cx)
+ Tooltip::with_meta(label, Some(action), command, window, cx)
}
}
@@ -411,14 +443,14 @@ mod remote_button {
.child(
div()
.px_1()
- .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
+ .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
),
)
.menu(move |window, cx| {
Some(ContextMenu::build(window, cx, |context_menu, _, _| {
context_menu
.when_some(keybinding_target.clone(), |el, keybinding_target| {
- el.context(keybinding_target.clone())
+ el.context(keybinding_target)
})
.action("Fetch", git::Fetch.boxed_clone())
.action("Fetch From", git::FetchFrom.boxed_clone())
@@ -501,7 +533,7 @@ mod remote_button {
)
.into_any_element();
- SplitButton { left, right }
+ SplitButton::new(left, right)
}
}
@@ -586,3 +618,88 @@ impl Component for GitStatusIcon {
)
}
}
+
+struct GitCloneModal {
+ panel: Entity<GitPanel>,
+ repo_input: Entity<Editor>,
+ focus_handle: FocusHandle,
+}
+
+impl GitCloneModal {
+ pub fn show(panel: Entity<GitPanel>, window: &mut Window, cx: &mut Context<Self>) -> Self {
+ let repo_input = cx.new(|cx| {
+ let mut editor = Editor::single_line(window, cx);
+ editor.set_placeholder_text("Enter repository URL…", cx);
+ editor
+ });
+ let focus_handle = repo_input.focus_handle(cx);
+
+ window.focus(&focus_handle);
+
+ Self {
+ panel,
+ repo_input,
+ focus_handle,
+ }
+ }
+}
+
+impl Focusable for GitCloneModal {
+ fn focus_handle(&self, _: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl Render for GitCloneModal {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ div()
+ .elevation_3(cx)
+ .w(rems(34.))
+ .flex_1()
+ .overflow_hidden()
+ .child(
+ div()
+ .w_full()
+ .p_2()
+ .border_b_1()
+ .border_color(cx.theme().colors().border_variant)
+ .child(self.repo_input.clone()),
+ )
+ .child(
+ h_flex()
+ .w_full()
+ .p_2()
+ .gap_0p5()
+ .rounded_b_sm()
+ .bg(cx.theme().colors().editor_background)
+ .child(
+ Label::new("Clone a repository from GitHub or other sources.")
+ .color(Color::Muted)
+ .size(LabelSize::Small),
+ )
+ .child(
+ Button::new("learn-more", "Learn More")
+ .label_size(LabelSize::Small)
+ .icon(IconName::ArrowUpRight)
+ .icon_size(IconSize::XSmall)
+ .on_click(|_, _, cx| {
+ cx.open_url("https://github.com/git-guides/git-clone");
+ }),
+ ),
+ )
+ .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
+ cx.emit(DismissEvent);
+ }))
+ .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
+ let repo = this.repo_input.read(cx).text(cx);
+ this.panel.update(cx, |panel, cx| {
+ panel.git_clone(repo, window, cx);
+ });
+ cx.emit(DismissEvent);
+ }))
+ }
+}
+
+impl EventEmitter<DismissEvent> for GitCloneModal {}
+
+impl ModalView for GitCloneModal {}
@@ -110,7 +110,7 @@ impl Render for GitOnboardingModal {
.child(Headline::new("Native Git Support").size(HeadlineSize::Large)),
)
.child(h_flex().absolute().top_2().right_2().child(
- IconButton::new("cancel", IconName::X).on_click(cx.listener(
+ IconButton::new("cancel", IconName::Close).on_click(cx.listener(
|_, _: &ClickEvent, _window, cx| {
git_onboarding_event!("Cancelled", trigger = "X click");
cx.emit(DismissEvent);
@@ -152,7 +152,7 @@ impl PickerDelegate for PickerPromptDelegate {
.all_options
.iter()
.enumerate()
- .map(|(ix, option)| StringMatchCandidate::new(ix, &option))
+ .map(|(ix, option)| StringMatchCandidate::new(ix, option))
.collect::<Vec<StringMatchCandidate>>()
});
let Some(candidates) = candidates.log_err() else {
@@ -242,7 +242,7 @@ impl ProjectDiff {
TRACKED_NAMESPACE
};
- let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
+ let path_key = PathKey::namespaced(namespace, entry.repo_path.0);
self.move_to_path(path_key, window, cx)
}
@@ -280,7 +280,7 @@ impl ProjectDiff {
fn button_states(&self, cx: &App) -> ButtonStates {
let editor = self.editor.read(cx);
let snapshot = self.multibuffer.read(cx).snapshot(cx);
- let prev_next = snapshot.diff_hunks().skip(1).next().is_some();
+ let prev_next = snapshot.diff_hunks().nth(1).is_some();
let mut selection = true;
let mut ranges = editor
@@ -329,14 +329,14 @@ impl ProjectDiff {
})
.ok();
- return ButtonStates {
+ ButtonStates {
stage: has_unstaged_hunks,
unstage: has_staged_hunks,
prev_next,
selection,
stage_all,
unstage_all,
- };
+ }
}
fn handle_editor_event(
@@ -346,27 +346,24 @@ impl ProjectDiff {
window: &mut Window,
cx: &mut Context<Self>,
) {
- match event {
- EditorEvent::SelectionsChanged { local: true } => {
- let Some(project_path) = self.active_path(cx) else {
- return;
- };
- self.workspace
- .update(cx, |workspace, cx| {
- if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
- git_panel.update(cx, |git_panel, cx| {
- git_panel.select_entry_by_path(project_path, window, cx)
- })
- }
- })
- .ok();
- }
- _ => {}
+ if let EditorEvent::SelectionsChanged { local: true } = event {
+ let Some(project_path) = self.active_path(cx) else {
+ return;
+ };
+ self.workspace
+ .update(cx, |workspace, cx| {
+ if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
+ git_panel.update(cx, |git_panel, cx| {
+ git_panel.select_entry_by_path(project_path, window, cx)
+ })
+ }
+ })
+ .ok();
}
- if editor.focus_handle(cx).contains_focused(window, cx) {
- if self.multibuffer.read(cx).is_empty() {
- self.focus_handle.focus(window)
- }
+ if editor.focus_handle(cx).contains_focused(window, cx)
+ && self.multibuffer.read(cx).is_empty()
+ {
+ self.focus_handle.focus(window)
}
}
@@ -451,10 +448,10 @@ impl ProjectDiff {
let diff = diff.read(cx);
let diff_hunk_ranges = diff
.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
- .map(|diff_hunk| diff_hunk.buffer_range.clone());
+ .map(|diff_hunk| diff_hunk.buffer_range);
let conflicts = conflict_addon
.conflict_set(snapshot.remote_id())
- .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts.clone())
+ .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts)
.unwrap_or_default();
let conflicts = conflicts.iter().map(|conflict| conflict.range.clone());
@@ -513,7 +510,7 @@ impl ProjectDiff {
mut recv: postage::watch::Receiver<()>,
cx: &mut AsyncWindowContext,
) -> Result<()> {
- while let Some(_) = recv.next().await {
+ while (recv.next().await).is_some() {
let buffers_to_load = this.update(cx, |this, cx| this.load_buffers(cx))?;
for buffer_to_load in buffers_to_load {
if let Some(buffer) = buffer_to_load.await.log_err() {
@@ -740,7 +737,7 @@ impl Render for ProjectDiff {
} else {
None
};
- let keybinding_focus_handle = self.focus_handle(cx).clone();
+ let keybinding_focus_handle = self.focus_handle(cx);
el.child(
v_flex()
.gap_1()
@@ -1073,8 +1070,7 @@ pub struct ProjectDiffEmptyState {
impl RenderOnce for ProjectDiffEmptyState {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let status_against_remote = |ahead_by: usize, behind_by: usize| -> bool {
- match self.current_branch {
- Some(Branch {
+ matches!(self.current_branch, Some(Branch {
upstream:
Some(Upstream {
tracking:
@@ -1084,9 +1080,7 @@ impl RenderOnce for ProjectDiffEmptyState {
..
}),
..
- }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0) => true,
- _ => false,
- }
+ }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0))
};
let change_count = |current_branch: &Branch| -> (usize, usize) {
@@ -1173,7 +1167,7 @@ impl RenderOnce for ProjectDiffEmptyState {
.child(Label::new("No Changes").color(Color::Muted))
} else {
this.when_some(self.current_branch.as_ref(), |this, branch| {
- this.child(has_branch_container(&branch))
+ this.child(has_branch_container(branch))
})
}
}),
@@ -1332,14 +1326,14 @@ fn merge_anchor_ranges<'a>(
loop {
if let Some(left_range) = left
.peek()
- .filter(|range| range.start.cmp(&next_range.end, &snapshot).is_le())
+ .filter(|range| range.start.cmp(&next_range.end, snapshot).is_le())
.cloned()
{
left.next();
next_range.end = left_range.end;
} else if let Some(right_range) = right
.peek()
- .filter(|range| range.start.cmp(&next_range.end, &snapshot).is_le())
+ .filter(|range| range.start.cmp(&next_range.end, snapshot).is_le())
.cloned()
{
right.next();
@@ -24,7 +24,7 @@ impl RemoteAction {
pub enum SuccessStyle {
Toast,
ToastWithLog { output: RemoteCommandOutput },
- PushPrLink { link: String },
+ PushPrLink { text: String, link: String },
}
pub struct SuccessMessage {
@@ -37,7 +37,7 @@ pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> Succ
RemoteAction::Fetch(remote) => {
if output.stderr.is_empty() {
SuccessMessage {
- message: "Already up to date".into(),
+ message: "Fetch: Already up to date".into(),
style: SuccessStyle::Toast,
}
} else {
@@ -68,10 +68,9 @@ pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> Succ
Ok(files_changed)
};
-
- if output.stderr.starts_with("Everything up to date") {
+ if output.stdout.ends_with("Already up to date.\n") {
SuccessMessage {
- message: output.stderr.trim().to_owned(),
+ message: "Pull: Already up to date".into(),
style: SuccessStyle::Toast,
}
} else if output.stdout.starts_with("Updating") {
@@ -119,48 +118,42 @@ pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> Succ
}
}
RemoteAction::Push(branch_name, remote_ref) => {
- if output.stderr.contains("* [new branch]") {
+ let message = if output.stderr.ends_with("Everything up-to-date\n") {
+ "Push: Everything is up-to-date".to_string()
+ } else {
+ format!("Pushed {} to {}", branch_name, remote_ref.name)
+ };
+
+ let style = if output.stderr.ends_with("Everything up-to-date\n") {
+ Some(SuccessStyle::Toast)
+ } else if output.stderr.contains("\nremote: ") {
let pr_hints = [
- // GitHub
- "Create a pull request",
- // Bitbucket
- "Create pull request",
- // GitLab
- "create a merge request",
+ ("Create a pull request", "Create Pull Request"), // GitHub
+ ("Create pull request", "Create Pull Request"), // Bitbucket
+ ("create a merge request", "Create Merge Request"), // GitLab
+ ("View merge request", "View Merge Request"), // GitLab
];
- let style = if pr_hints
+ pr_hints
.iter()
- .any(|indicator| output.stderr.contains(indicator))
- {
- let finder = LinkFinder::new();
- let first_link = finder
- .links(&output.stderr)
- .filter(|link| *link.kind() == LinkKind::Url)
- .map(|link| link.start()..link.end())
- .next();
- if let Some(link) = first_link {
- let link = output.stderr[link].to_string();
- SuccessStyle::PushPrLink { link }
- } else {
- SuccessStyle::ToastWithLog { output }
- }
- } else {
- SuccessStyle::ToastWithLog { output }
- };
- SuccessMessage {
- message: format!("Published {} to {}", branch_name, remote_ref.name),
- style,
- }
- } else if output.stderr.starts_with("Everything up to date") {
- SuccessMessage {
- message: output.stderr.trim().to_owned(),
- style: SuccessStyle::Toast,
- }
+ .find(|(indicator, _)| output.stderr.contains(indicator))
+ .and_then(|(_, mapped)| {
+ let finder = LinkFinder::new();
+ finder
+ .links(&output.stderr)
+ .filter(|link| *link.kind() == LinkKind::Url)
+ .map(|link| link.start()..link.end())
+ .next()
+ .map(|link| SuccessStyle::PushPrLink {
+ text: mapped.to_string(),
+ link: output.stderr[link].to_string(),
+ })
+ })
} else {
- SuccessMessage {
- message: format!("Pushed {} to {}", branch_name, remote_ref.name),
- style: SuccessStyle::ToastWithLog { output },
- }
+ None
+ };
+ SuccessMessage {
+ message,
+ style: style.unwrap_or(SuccessStyle::ToastWithLog { output }),
}
}
}
@@ -169,6 +162,7 @@ pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> Succ
#[cfg(test)]
mod tests {
use super::*;
+ use indoc::indoc;
#[test]
fn test_push_new_branch_pull_request() {
@@ -181,8 +175,7 @@ mod tests {
let output = RemoteCommandOutput {
stdout: String::new(),
- stderr: String::from(
- "
+ stderr: indoc! { "
Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote:
remote: Create a pull request for 'test' on GitHub by visiting:
@@ -190,13 +183,14 @@ mod tests {
remote:
To example.com:test/test.git
* [new branch] test -> test
- ",
- ),
+ "}
+ .to_string(),
};
let msg = format_output(&action, output);
- if let SuccessStyle::PushPrLink { link } = &msg.style {
+ if let SuccessStyle::PushPrLink { text: hint, link } = &msg.style {
+ assert_eq!(hint, "Create Pull Request");
assert_eq!(link, "https://example.com/test/test/pull/new/test");
} else {
panic!("Expected PushPrLink variant");
@@ -214,7 +208,7 @@ mod tests {
let output = RemoteCommandOutput {
stdout: String::new(),
- stderr: String::from("
+ stderr: indoc! {"
Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote:
remote: To create a merge request for test, visit:
@@ -222,12 +216,14 @@ mod tests {
remote:
To example.com:test/test.git
* [new branch] test -> test
- "),
- };
+ "}
+ .to_string()
+ };
let msg = format_output(&action, output);
- if let SuccessStyle::PushPrLink { link } = &msg.style {
+ if let SuccessStyle::PushPrLink { text, link } = &msg.style {
+ assert_eq!(text, "Create Merge Request");
assert_eq!(
link,
"https://example.com/test/test/-/merge_requests/new?merge_request%5Bsource_branch%5D=test"
@@ -237,6 +233,39 @@ mod tests {
}
}
+ #[test]
+ fn test_push_branch_existing_merge_request() {
+ let action = RemoteAction::Push(
+ SharedString::new("test_branch"),
+ Remote {
+ name: SharedString::new("test_remote"),
+ },
+ );
+
+ let output = RemoteCommandOutput {
+ stdout: String::new(),
+ stderr: indoc! {"
+ Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
+ remote:
+ remote: View merge request for test:
+ remote: https://example.com/test/test/-/merge_requests/99999
+ remote:
+ To example.com:test/test.git
+ + 80bd3c83be...e03d499d2e test -> test
+ "}
+ .to_string(),
+ };
+
+ let msg = format_output(&action, output);
+
+ if let SuccessStyle::PushPrLink { text, link } = &msg.style {
+ assert_eq!(text, "View Merge Request");
+ assert_eq!(link, "https://example.com/test/test/-/merge_requests/99999");
+ } else {
+ panic!("Expected PushPrLink variant");
+ }
+ }
+
#[test]
fn test_push_new_branch_no_link() {
let action = RemoteAction::Push(
@@ -248,12 +277,12 @@ mod tests {
let output = RemoteCommandOutput {
stdout: String::new(),
- stderr: String::from(
- "
+ stderr: indoc! { "
To http://example.com/test/test.git
* [new branch] test -> test
",
- ),
+ }
+ .to_string(),
};
let msg = format_output(&action, output);
@@ -261,10 +290,7 @@ mod tests {
if let SuccessStyle::ToastWithLog { output } = &msg.style {
assert_eq!(
output.stderr,
- "
- To http://example.com/test/test.git
- * [new branch] test -> test
- "
+ "To http://example.com/test/test.git\n * [new branch] test -> test\n"
);
} else {
panic!("Expected ToastWithLog variant");
@@ -109,7 +109,10 @@ impl Focusable for RepositorySelector {
impl Render for RepositorySelector {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
- div().w(self.width).child(self.picker.clone())
+ div()
+ .key_context("GitRepositorySelector")
+ .w(self.width)
+ .child(self.picker.clone())
}
}
@@ -0,0 +1,740 @@
+//! TextDiffView currently provides a UI for displaying differences between the clipboard and selected text.
+
+use anyhow::Result;
+use buffer_diff::{BufferDiff, BufferDiffSnapshot};
+use editor::{Editor, EditorEvent, MultiBuffer, ToPoint, actions::DiffClipboardWithSelectionData};
+use futures::{FutureExt, select_biased};
+use gpui::{
+ AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter,
+ FocusHandle, Focusable, IntoElement, Render, Task, Window,
+};
+use language::{self, Buffer, Point};
+use project::Project;
+use std::{
+ any::{Any, TypeId},
+ cmp,
+ ops::Range,
+ pin::pin,
+ sync::Arc,
+ time::Duration,
+};
+use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
+use util::paths::PathExt;
+
+use workspace::{
+ Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
+ item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams},
+ searchable::SearchableItemHandle,
+};
+
+pub struct TextDiffView {
+ diff_editor: Entity<Editor>,
+ title: SharedString,
+ path: Option<SharedString>,
+ buffer_changes_tx: watch::Sender<()>,
+ _recalculate_diff_task: Task<Result<()>>,
+}
+
+const RECALCULATE_DIFF_DEBOUNCE: Duration = Duration::from_millis(250);
+
+impl TextDiffView {
+ pub fn open(
+ diff_data: &DiffClipboardWithSelectionData,
+ workspace: &Workspace,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> Option<Task<Result<Entity<Self>>>> {
+ let source_editor = diff_data.editor.clone();
+
+ let selection_data = source_editor.update(cx, |editor, cx| {
+ let multibuffer = editor.buffer().read(cx);
+ let source_buffer = multibuffer.as_singleton()?;
+ let selections = editor.selections.all::<Point>(cx);
+ let buffer_snapshot = source_buffer.read(cx);
+ let first_selection = selections.first()?;
+ let max_point = buffer_snapshot.max_point();
+
+ if first_selection.is_empty() {
+ let full_range = Point::new(0, 0)..max_point;
+ return Some((source_buffer, full_range));
+ }
+
+ let start = first_selection.start;
+ let end = first_selection.end;
+ let expanded_start = Point::new(start.row, 0);
+
+ let expanded_end = if end.column > 0 {
+ let next_row = end.row + 1;
+ cmp::min(max_point, Point::new(next_row, 0))
+ } else {
+ end
+ };
+ Some((source_buffer, expanded_start..expanded_end))
+ });
+
+ let Some((source_buffer, expanded_selection_range)) = selection_data else {
+ log::warn!("There should always be at least one selection in Zed. This is a bug.");
+ return None;
+ };
+
+ source_editor.update(cx, |source_editor, cx| {
+ source_editor.change_selections(Default::default(), window, cx, |s| {
+ s.select_ranges(vec![
+ expanded_selection_range.start..expanded_selection_range.end,
+ ]);
+ })
+ });
+
+ let source_buffer_snapshot = source_buffer.read(cx).snapshot();
+ let mut clipboard_text = diff_data.clipboard_text.clone();
+
+ if !clipboard_text.ends_with("\n") {
+ clipboard_text.push_str("\n");
+ }
+
+ 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 task = window.spawn(cx, async move |cx| {
+ let project = workspace.update(cx, |workspace, _| workspace.project().clone())?;
+
+ update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
+
+ workspace.update_in(cx, |workspace, window, cx| {
+ let diff_view = cx.new(|cx| {
+ TextDiffView::new(
+ clipboard_buffer,
+ source_editor,
+ source_buffer,
+ expanded_selection_range,
+ diff_buffer,
+ project,
+ window,
+ cx,
+ )
+ });
+
+ let pane = workspace.active_pane();
+ pane.update(cx, |pane, cx| {
+ pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
+ });
+
+ diff_view
+ })
+ });
+
+ Some(task)
+ }
+
+ pub fn new(
+ clipboard_buffer: Entity<Buffer>,
+ source_editor: Entity<Editor>,
+ source_buffer: Entity<Buffer>,
+ source_range: Range<Point>,
+ diff_buffer: Entity<BufferDiff>,
+ project: Entity<Project>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let multibuffer = cx.new(|cx| {
+ let mut multibuffer = MultiBuffer::new(language::Capability::ReadWrite);
+
+ multibuffer.push_excerpts(
+ source_buffer.clone(),
+ [editor::ExcerptRange::new(source_range)],
+ cx,
+ );
+
+ multibuffer.add_diff(diff_buffer.clone(), cx);
+ multibuffer
+ });
+ let diff_editor = cx.new(|cx| {
+ let mut editor = Editor::for_multibuffer(multibuffer, Some(project), window, cx);
+ editor.start_temporary_diff_override();
+ editor.disable_diagnostics(cx);
+ editor.set_expand_all_diff_hunks(cx);
+ editor.set_render_diff_hunk_controls(
+ Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
+ cx,
+ );
+ editor
+ });
+
+ let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
+
+ cx.subscribe(&source_buffer, move |this, _, event, _| match event {
+ language::BufferEvent::Edited
+ | language::BufferEvent::LanguageChanged
+ | language::BufferEvent::Reparsed => {
+ this.buffer_changes_tx.send(()).ok();
+ }
+ _ => {}
+ })
+ .detach();
+
+ let editor = source_editor.read(cx);
+ let title = editor.buffer().read(cx).title(cx).to_string();
+ let selection_location_text = selection_location_text(editor, cx);
+ let selection_location_title = selection_location_text
+ .as_ref()
+ .map(|text| format!("{} @ {}", title, text))
+ .unwrap_or(title);
+
+ let path = editor
+ .buffer()
+ .read(cx)
+ .as_singleton()
+ .and_then(|b| {
+ b.read(cx)
+ .file()
+ .map(|f| f.full_path(cx).compact().to_string_lossy().to_string())
+ })
+ .unwrap_or("untitled".into());
+
+ let selection_location_path = selection_location_text
+ .map(|text| format!("{} @ {}", path, text))
+ .unwrap_or(path);
+
+ Self {
+ diff_editor,
+ title: format!("Clipboard ↔ {selection_location_title}").into(),
+ path: Some(format!("Clipboard ↔ {selection_location_path}").into()),
+ buffer_changes_tx,
+ _recalculate_diff_task: cx.spawn(async move |_, cx| {
+ while buffer_changes_rx.recv().await.is_ok() {
+ loop {
+ let mut timer = cx
+ .background_executor()
+ .timer(RECALCULATE_DIFF_DEBOUNCE)
+ .fuse();
+ let mut recv = pin!(buffer_changes_rx.recv().fuse());
+ select_biased! {
+ _ = timer => break,
+ _ = recv => continue,
+ }
+ }
+
+ log::trace!("start recalculating");
+ update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
+ log::trace!("finish recalculating");
+ }
+ Ok(())
+ }),
+ }
+ }
+}
+
+fn build_clipboard_buffer(
+ text: String,
+ source_buffer: &Entity<Buffer>,
+ replacement_range: Range<Point>,
+ cx: &mut App,
+) -> Entity<Buffer> {
+ let source_buffer_snapshot = source_buffer.read(cx).snapshot();
+ cx.new(|cx| {
+ let mut buffer = language::Buffer::local(source_buffer_snapshot.text(), cx);
+ let language = source_buffer.read(cx).language().cloned();
+ 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
+ })
+}
+
+async fn update_diff_buffer(
+ diff: &Entity<BufferDiff>,
+ source_buffer: &Entity<Buffer>,
+ clipboard_buffer: &Entity<Buffer>,
+ cx: &mut AsyncApp,
+) -> Result<()> {
+ let source_buffer_snapshot = source_buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
+
+ let base_buffer_snapshot = clipboard_buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
+ let base_text = base_buffer_snapshot.text();
+
+ let diff_snapshot = cx
+ .update(|cx| {
+ BufferDiffSnapshot::new_with_base_buffer(
+ source_buffer_snapshot.text.clone(),
+ Some(Arc::new(base_text)),
+ base_buffer_snapshot,
+ cx,
+ )
+ })?
+ .await;
+
+ diff.update(cx, |diff, cx| {
+ diff.set_snapshot(diff_snapshot, &source_buffer_snapshot.text, cx);
+ })?;
+ Ok(())
+}
+
+impl EventEmitter<EditorEvent> for TextDiffView {}
+
+impl Focusable for TextDiffView {
+ fn focus_handle(&self, cx: &App) -> FocusHandle {
+ self.diff_editor.focus_handle(cx)
+ }
+}
+
+impl Item for TextDiffView {
+ type Event = EditorEvent;
+
+ fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
+ Some(Icon::new(IconName::Diff).color(Color::Muted))
+ }
+
+ fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
+ Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
+ .color(if params.selected {
+ Color::Default
+ } else {
+ Color::Muted
+ })
+ .into_any_element()
+ }
+
+ fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
+ self.title.clone()
+ }
+
+ fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
+ self.path.clone()
+ }
+
+ fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
+ Editor::to_item_events(event, f)
+ }
+
+ fn telemetry_event_text(&self) -> Option<&'static str> {
+ Some("Selection Diff View Opened")
+ }
+
+ fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ self.diff_editor
+ .update(cx, |editor, cx| editor.deactivated(window, cx));
+ }
+
+ fn is_singleton(&self, _: &App) -> bool {
+ false
+ }
+
+ fn act_as_type<'a>(
+ &'a self,
+ type_id: TypeId,
+ self_handle: &'a Entity<Self>,
+ _: &'a App,
+ ) -> Option<AnyView> {
+ if type_id == TypeId::of::<Self>() {
+ Some(self_handle.to_any())
+ } else if type_id == TypeId::of::<Editor>() {
+ Some(self.diff_editor.to_any())
+ } else {
+ None
+ }
+ }
+
+ fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ Some(Box::new(self.diff_editor.clone()))
+ }
+
+ fn for_each_project_item(
+ &self,
+ cx: &App,
+ f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
+ ) {
+ self.diff_editor.for_each_project_item(cx, f)
+ }
+
+ fn set_nav_history(
+ &mut self,
+ nav_history: ItemNavHistory,
+ _: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.diff_editor.update(cx, |editor, _| {
+ editor.set_nav_history(Some(nav_history));
+ });
+ }
+
+ fn navigate(
+ &mut self,
+ data: Box<dyn Any>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> bool {
+ self.diff_editor
+ .update(cx, |editor, cx| editor.navigate(data, window, cx))
+ }
+
+ fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
+ ToolbarItemLocation::PrimaryLeft
+ }
+
+ fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
+ self.diff_editor.breadcrumbs(theme, cx)
+ }
+
+ fn added_to_workspace(
+ &mut self,
+ workspace: &mut Workspace,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.diff_editor.update(cx, |editor, cx| {
+ editor.added_to_workspace(workspace, window, cx)
+ });
+ }
+
+ fn can_save(&self, cx: &App) -> bool {
+ // The editor handles the new buffer, so delegate to it
+ self.diff_editor.read(cx).can_save(cx)
+ }
+
+ fn save(
+ &mut self,
+ options: SaveOptions,
+ project: Entity<Project>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ // Delegate saving to the editor, which manages the new buffer
+ self.diff_editor
+ .update(cx, |editor, cx| editor.save(options, project, window, cx))
+ }
+}
+
+pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
+ let buffer = editor.buffer().read(cx);
+ let buffer_snapshot = buffer.snapshot(cx);
+ let first_selection = editor.selections.disjoint.first()?;
+
+ let selection_start = first_selection.start.to_point(&buffer_snapshot);
+ let selection_end = first_selection.end.to_point(&buffer_snapshot);
+
+ let start_row = selection_start.row;
+ let start_column = selection_start.column;
+ let end_row = selection_end.row;
+ let end_column = selection_end.column;
+
+ let range_text = if start_row == end_row {
+ format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
+ } else {
+ format!(
+ "L{}:{}-L{}:{}",
+ start_row + 1,
+ start_column + 1,
+ end_row + 1,
+ end_column + 1
+ )
+ };
+
+ Some(range_text)
+}
+
+impl Render for TextDiffView {
+ fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+ self.diff_editor.clone()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use editor::test::editor_test_context::assert_state_with_diff;
+ use gpui::{TestAppContext, VisualContext};
+ use project::{FakeFs, Project};
+ use serde_json::json;
+ use settings::{Settings, SettingsStore};
+ use unindent::unindent;
+ use util::{path, test::marked_text_ranges};
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ language::init(cx);
+ Project::init_settings(cx);
+ workspace::init_settings(cx);
+ editor::init_settings(cx);
+ theme::ThemeSettings::register(cx)
+ });
+ }
+
+ #[gpui::test]
+ async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection(
+ cx: &mut TestAppContext,
+ ) {
+ base_test(
+ path!("/test"),
+ path!("/test/text.txt"),
+ "def process_incoming_inventory(items, warehouse_id):\n pass\n",
+ "def process_outgoing_inventory(items, warehouse_id):\n passˇ\n",
+ &unindent(
+ "
+ - def process_incoming_inventory(items, warehouse_id):
+ + ˇdef process_outgoing_inventory(items, warehouse_id):
+ pass
+ ",
+ ),
+ "Clipboard ↔ text.txt @ L1:1-L3:1",
+ &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
+ cx,
+ )
+ .await;
+ }
+
+ #[gpui::test]
+ async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines(
+ cx: &mut TestAppContext,
+ ) {
+ base_test(
+ path!("/test"),
+ path!("/test/text.txt"),
+ "def process_incoming_inventory(items, warehouse_id):\n pass\n",
+ "«def process_outgoing_inventory(items, warehouse_id):\n passˇ»\n",
+ &unindent(
+ "
+ - def process_incoming_inventory(items, warehouse_id):
+ + ˇdef process_outgoing_inventory(items, warehouse_id):
+ pass
+ ",
+ ),
+ "Clipboard ↔ text.txt @ L1:1-L3:1",
+ &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
+ cx,
+ )
+ .await;
+ }
+
+ #[gpui::test]
+ async fn test_diffing_clipboard_against_single_line_selection(cx: &mut TestAppContext) {
+ base_test(
+ path!("/test"),
+ path!("/test/text.txt"),
+ "a",
+ "«bbˇ»",
+ &unindent(
+ "
+ - a
+ + ˇbb",
+ ),
+ "Clipboard ↔ text.txt @ L1:1-3",
+ &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
+ cx,
+ )
+ .await;
+ }
+
+ #[gpui::test]
+ async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) {
+ base_test(
+ path!("/test"),
+ path!("/test/text.txt"),
+ " a",
+ "«bbˇ»",
+ &unindent(
+ "
+ - a
+ + ˇbb",
+ ),
+ "Clipboard ↔ text.txt @ L1:1-3",
+ &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
+ cx,
+ )
+ .await;
+ }
+
+ #[gpui::test]
+ async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) {
+ base_test(
+ path!("/test"),
+ path!("/test/text.txt"),
+ "a",
+ " «bbˇ»",
+ &unindent(
+ "
+ - a
+ + ˇ bb",
+ ),
+ "Clipboard ↔ text.txt @ L1:1-7",
+ &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
+ cx,
+ )
+ .await;
+ }
+
+ #[gpui::test]
+ async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection(
+ cx: &mut TestAppContext,
+ ) {
+ base_test(
+ path!("/test"),
+ path!("/test/text.txt"),
+ "a",
+ "« bbˇ»",
+ &unindent(
+ "
+ - a
+ + ˇ bb",
+ ),
+ "Clipboard ↔ text.txt @ L1:1-7",
+ &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
+ cx,
+ )
+ .await;
+ }
+
+ #[gpui::test]
+ async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace(
+ cx: &mut TestAppContext,
+ ) {
+ base_test(
+ path!("/test"),
+ path!("/test/text.txt"),
+ " a",
+ " «bbˇ»",
+ &unindent(
+ "
+ - a
+ + ˇ bb",
+ ),
+ "Clipboard ↔ text.txt @ L1:1-7",
+ &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
+ cx,
+ )
+ .await;
+ }
+
+ #[gpui::test]
+ async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection(
+ cx: &mut TestAppContext,
+ ) {
+ base_test(
+ path!("/test"),
+ path!("/test/text.txt"),
+ " a",
+ "« bbˇ»",
+ &unindent(
+ "
+ - a
+ + ˇ bb",
+ ),
+ "Clipboard ↔ text.txt @ L1:1-7",
+ &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
+ cx,
+ )
+ .await;
+ }
+
+ #[gpui::test]
+ async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters(
+ cx: &mut TestAppContext,
+ ) {
+ base_test(
+ path!("/test"),
+ path!("/test/text.txt"),
+ "a",
+ "«bˇ»b",
+ &unindent(
+ "
+ - a
+ + ˇbb",
+ ),
+ "Clipboard ↔ text.txt @ L1:1-3",
+ &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
+ cx,
+ )
+ .await;
+ }
+
+ async fn base_test(
+ project_root: &str,
+ file_path: &str,
+ clipboard_text: &str,
+ editor_text: &str,
+ expected_diff: &str,
+ expected_tab_title: &str,
+ expected_tab_tooltip: &str,
+ cx: &mut TestAppContext,
+ ) {
+ init_test(cx);
+
+ let file_name = std::path::Path::new(file_path)
+ .file_name()
+ .unwrap()
+ .to_str()
+ .unwrap();
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ project_root,
+ json!({
+ file_name: editor_text
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs, [project_root.as_ref()], cx).await;
+
+ let (workspace, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+ let buffer = project
+ .update(cx, |project, cx| project.open_local_buffer(file_path, cx))
+ .await
+ .unwrap();
+
+ let editor = cx.new_window_entity(|window, cx| {
+ let mut editor = Editor::for_buffer(buffer, None, window, cx);
+ let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false);
+ editor.set_text(unmarked_text, window, cx);
+ editor.change_selections(Default::default(), window, cx, |s| {
+ s.select_ranges(selection_ranges)
+ });
+
+ editor
+ });
+
+ let diff_view = workspace
+ .update_in(cx, |workspace, window, cx| {
+ TextDiffView::open(
+ &DiffClipboardWithSelectionData {
+ clipboard_text: clipboard_text.to_string(),
+ editor,
+ },
+ workspace,
+ window,
+ cx,
+ )
+ })
+ .unwrap()
+ .await
+ .unwrap();
+
+ cx.executor().run_until_parked();
+
+ assert_state_with_diff(
+ &diff_view.read_with(cx, |diff_view, _| diff_view.diff_editor.clone()),
+ cx,
+ expected_diff,
+ );
+
+ diff_view.read_with(cx, |diff_view, cx| {
+ assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title);
+ assert_eq!(
+ diff_view.tab_tooltip_text(cx).unwrap(),
+ expected_tab_tooltip
+ );
+ });
+ }
+}
@@ -1,4 +1,4 @@
-use editor::{Editor, MultiBufferSnapshot};
+use editor::{Editor, EditorSettings, MultiBufferSnapshot};
use gpui::{App, Entity, FocusHandle, Focusable, Subscription, Task, WeakEntity};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -95,10 +95,8 @@ impl CursorPosition {
.ok()
.unwrap_or(true);
- if !is_singleton {
- if let Some(debounce) = debounce {
- cx.background_executor().timer(debounce).await;
- }
+ if !is_singleton && let Some(debounce) = debounce {
+ cx.background_executor().timer(debounce).await;
}
editor
@@ -108,7 +106,7 @@ impl CursorPosition {
cursor_position.selected_count.selections = editor.selections.count();
match editor.mode() {
editor::EditorMode::AutoHeight { .. }
- | editor::EditorMode::SingleLine { .. }
+ | editor::EditorMode::SingleLine
| editor::EditorMode::Minimap { .. } => {
cursor_position.position = None;
cursor_position.context = None;
@@ -131,7 +129,7 @@ impl CursorPosition {
cursor_position.selected_count.lines += 1;
}
}
- if last_selection.as_ref().map_or(true, |last_selection| {
+ if last_selection.as_ref().is_none_or(|last_selection| {
selection.id > last_selection.id
}) {
last_selection = Some(selection);
@@ -209,6 +207,13 @@ impl CursorPosition {
impl Render for CursorPosition {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ if !EditorSettings::get_global(cx)
+ .status_bar
+ .cursor_position_button
+ {
+ return div();
+ }
+
div().when_some(self.position, |el, position| {
let mut text = format!(
"{}{FILE_ROW_COLUMN_DELIMITER}{}",
@@ -227,13 +232,11 @@ impl Render for CursorPosition {
if let Some(editor) = workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(cx))
+ && let Some((_, buffer, _)) = editor.read(cx).active_excerpt(cx)
{
- if let Some((_, buffer, _)) = editor.read(cx).active_excerpt(cx)
- {
- workspace.toggle_modal(window, cx, |window, cx| {
- crate::GoToLine::new(editor, buffer, window, cx)
- })
- }
+ workspace.toggle_modal(window, cx, |window, cx| {
+ crate::GoToLine::new(editor, buffer, window, cx)
+ })
}
});
}
@@ -308,10 +311,14 @@ impl Settings for LineIndicatorFormat {
type FileContent = Option<LineIndicatorFormatContent>;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> anyhow::Result<Self> {
- let format = [sources.release_channel, sources.user]
- .into_iter()
- .find_map(|value| value.copied().flatten())
- .unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
+ let format = [
+ sources.release_channel,
+ sources.operating_system,
+ sources.user,
+ ]
+ .into_iter()
+ .find_map(|value| value.copied().flatten())
+ .unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
Ok(format.0)
}
@@ -103,11 +103,11 @@ impl GoToLine {
return;
};
editor.update(cx, |editor, cx| {
- if let Some(placeholder_text) = editor.placeholder_text() {
- if editor.text(cx).is_empty() {
- let placeholder_text = placeholder_text.to_string();
- editor.set_text(placeholder_text, window, cx);
- }
+ if let Some(placeholder_text) = editor.placeholder_text()
+ && editor.text(cx).is_empty()
+ {
+ let placeholder_text = placeholder_text.to_string();
+ editor.set_text(placeholder_text, window, cx);
}
});
}
@@ -157,7 +157,7 @@ impl GoToLine {
self.prev_scroll_position.take();
cx.emit(DismissEvent)
}
- editor::EditorEvent::BufferEdited { .. } => self.highlight_current_line(cx),
+ editor::EditorEvent::BufferEdited => self.highlight_current_line(cx),
_ => {}
}
}
@@ -712,7 +712,7 @@ mod tests {
) -> Entity<GoToLine> {
cx.dispatch_action(editor::actions::ToggleGoToLine);
workspace.update(cx, |workspace, cx| {
- workspace.active_modal::<GoToLine>(cx).unwrap().clone()
+ workspace.active_modal::<GoToLine>(cx).unwrap()
})
}
@@ -106,10 +106,9 @@ pub fn validate_generate_content_request(request: &GenerateContentRequest) -> Re
.contents
.iter()
.find(|content| content.role == Role::User)
+ && user_content.parts.is_empty()
{
- if user_content.parts.is_empty() {
- bail!("User content must contain at least one part");
- }
+ bail!("User content must contain at least one part");
}
Ok(())
@@ -267,7 +266,7 @@ pub struct CitationMetadata {
pub struct PromptFeedback {
#[serde(skip_serializing_if = "Option::is_none")]
pub block_reason: Option<String>,
- pub safety_ratings: Vec<SafetyRating>,
+ pub safety_ratings: Option<Vec<SafetyRating>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub block_reason_message: Option<String>,
}
@@ -478,10 +477,10 @@ impl<'de> Deserialize<'de> for ModelName {
model_id: id.to_string(),
})
} else {
- return Err(serde::de::Error::custom(format!(
+ Err(serde::de::Error::custom(format!(
"Expected model name to begin with {}, got: {}",
MODEL_NAME_PREFIX, string
- )));
+ )))
}
}
}
@@ -119,9 +119,10 @@ serde_json.workspace = true
slotmap = "1.0.6"
smallvec.workspace = true
smol.workspace = true
+stacksafe.workspace = true
strum.workspace = true
sum_tree.workspace = true
-taffy = "0.4.3"
+taffy = "=0.9.0"
thiserror.workspace = true
util.workspace = true
uuid.workspace = true
@@ -209,17 +210,13 @@ xkbcommon = { version = "0.8.0", features = [
"wayland",
"x11",
], optional = true }
-xim = { git = "https://github.com/XDeme1/xim-rs", rev = "d50d461764c2213655cd9cf65a0ea94c70d3c4fd", features = [
+xim = { git = "https://github.com/zed-industries/xim-rs", rev = "c0a70c1bd2ce197364216e5e818a2cb3adb99a8d" , features = [
"x11rb-xcb",
"x11rb-client",
], optional = true }
x11-clipboard = { version = "0.9.3", optional = true }
[target.'cfg(target_os = "windows")'.dependencies]
-blade-util.workspace = true
-bytemuck = "1"
-blade-graphics.workspace = true
-blade-macros.workspace = true
flume = "0.11"
rand.workspace = true
windows.workspace = true
@@ -240,7 +237,6 @@ util = { workspace = true, features = ["test-support"] }
[target.'cfg(target_os = "windows")'.build-dependencies]
embed-resource = "3.0"
-naga.workspace = true
[target.'cfg(target_os = "macos")'.build-dependencies]
bindgen = "0.71"
@@ -287,6 +283,10 @@ path = "examples/shadow.rs"
name = "svg"
path = "examples/svg/svg.rs"
+[[example]]
+name = "tab_stop"
+path = "examples/tab_stop.rs"
+
[[example]]
name = "text"
path = "examples/text.rs"
@@ -295,6 +295,10 @@ path = "examples/text.rs"
name = "text_wrapper"
path = "examples/text_wrapper.rs"
+[[example]]
+name = "tree"
+path = "examples/tree.rs"
+
[[example]]
name = "uniform_list"
path = "examples/uniform_list.rs"
@@ -302,3 +306,7 @@ path = "examples/uniform_list.rs"
[[example]]
name = "window_shadow"
path = "examples/window_shadow.rs"
+
+[[example]]
+name = "grid_layout"
+path = "examples/grid_layout.rs"
@@ -9,7 +9,10 @@ fn main() {
let target = env::var("CARGO_CFG_TARGET_OS");
println!("cargo::rustc-check-cfg=cfg(gles)");
- #[cfg(any(not(target_os = "macos"), feature = "macos-blade"))]
+ #[cfg(any(
+ not(any(target_os = "macos", target_os = "windows")),
+ all(target_os = "macos", feature = "macos-blade")
+ ))]
check_wgsl_shaders();
match target.as_deref() {
@@ -17,21 +20,18 @@ fn main() {
#[cfg(target_os = "macos")]
macos::build();
}
- #[cfg(all(target_os = "windows", feature = "windows-manifest"))]
Ok("windows") => {
- let manifest = std::path::Path::new("resources/windows/gpui.manifest.xml");
- let rc_file = std::path::Path::new("resources/windows/gpui.rc");
- println!("cargo:rerun-if-changed={}", manifest.display());
- println!("cargo:rerun-if-changed={}", rc_file.display());
- embed_resource::compile(rc_file, embed_resource::NONE)
- .manifest_required()
- .unwrap();
+ #[cfg(target_os = "windows")]
+ windows::build();
}
_ => (),
};
}
-#[allow(dead_code)]
+#[cfg(any(
+ not(any(target_os = "macos", target_os = "windows")),
+ all(target_os = "macos", feature = "macos-blade")
+))]
fn check_wgsl_shaders() {
use std::path::PathBuf;
use std::process;
@@ -126,8 +126,9 @@ mod macos {
"ContentMask".into(),
"Uniforms".into(),
"AtlasTile".into(),
- "PathInputIndex".into(),
+ "PathRasterizationInputIndex".into(),
"PathVertex_ScaledPixels".into(),
+ "PathRasterizationVertex".into(),
"ShadowInputIndex".into(),
"Shadow".into(),
"QuadInputIndex".into(),
@@ -242,3 +243,214 @@ mod macos {
}
}
}
+
+#[cfg(target_os = "windows")]
+mod windows {
+ use std::{
+ fs,
+ io::Write,
+ path::{Path, PathBuf},
+ process::{self, Command},
+ };
+
+ pub(super) fn build() {
+ // Compile HLSL shaders
+ #[cfg(not(debug_assertions))]
+ compile_shaders();
+
+ // Embed the Windows manifest and resource file
+ #[cfg(feature = "windows-manifest")]
+ embed_resource();
+ }
+
+ #[cfg(feature = "windows-manifest")]
+ fn embed_resource() {
+ let manifest = std::path::Path::new("resources/windows/gpui.manifest.xml");
+ let rc_file = std::path::Path::new("resources/windows/gpui.rc");
+ println!("cargo:rerun-if-changed={}", manifest.display());
+ println!("cargo:rerun-if-changed={}", rc_file.display());
+ embed_resource::compile(rc_file, embed_resource::NONE)
+ .manifest_required()
+ .unwrap();
+ }
+
+ /// You can set the `GPUI_FXC_PATH` environment variable to specify the path to the fxc.exe compiler.
+ fn compile_shaders() {
+ let shader_path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
+ .join("src/platform/windows/shaders.hlsl");
+ let out_dir = std::env::var("OUT_DIR").unwrap();
+
+ println!("cargo:rerun-if-changed={}", shader_path.display());
+
+ // Check if fxc.exe is available
+ let fxc_path = find_fxc_compiler();
+
+ // Define all modules
+ let modules = [
+ "quad",
+ "shadow",
+ "path_rasterization",
+ "path_sprite",
+ "underline",
+ "monochrome_sprite",
+ "polychrome_sprite",
+ ];
+
+ let rust_binding_path = format!("{}/shaders_bytes.rs", out_dir);
+ if Path::new(&rust_binding_path).exists() {
+ fs::remove_file(&rust_binding_path)
+ .expect("Failed to remove existing Rust binding file");
+ }
+ for module in modules {
+ compile_shader_for_module(
+ module,
+ &out_dir,
+ &fxc_path,
+ shader_path.to_str().unwrap(),
+ &rust_binding_path,
+ );
+ }
+
+ {
+ let shader_path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
+ .join("src/platform/windows/color_text_raster.hlsl");
+ compile_shader_for_module(
+ "emoji_rasterization",
+ &out_dir,
+ &fxc_path,
+ shader_path.to_str().unwrap(),
+ &rust_binding_path,
+ );
+ }
+ }
+
+ /// You can set the `GPUI_FXC_PATH` environment variable to specify the path to the fxc.exe compiler.
+ fn find_fxc_compiler() -> String {
+ // Check environment variable
+ if let Ok(path) = std::env::var("GPUI_FXC_PATH")
+ && Path::new(&path).exists()
+ {
+ return path;
+ }
+
+ // Try to find in PATH
+ // NOTE: This has to be `where.exe` on Windows, not `where`, it must be ended with `.exe`
+ if let Ok(output) = std::process::Command::new("where.exe")
+ .arg("fxc.exe")
+ .output()
+ && output.status.success()
+ {
+ let path = String::from_utf8_lossy(&output.stdout);
+ return path.trim().to_string();
+ }
+
+ // Check the default path
+ if Path::new(r"C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\fxc.exe")
+ .exists()
+ {
+ return r"C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\fxc.exe"
+ .to_string();
+ }
+
+ panic!("Failed to find fxc.exe");
+ }
+
+ fn compile_shader_for_module(
+ module: &str,
+ out_dir: &str,
+ fxc_path: &str,
+ shader_path: &str,
+ rust_binding_path: &str,
+ ) {
+ // Compile vertex shader
+ let output_file = format!("{}/{}_vs.h", out_dir, module);
+ let const_name = format!("{}_VERTEX_BYTES", module.to_uppercase());
+ compile_shader_impl(
+ fxc_path,
+ &format!("{module}_vertex"),
+ &output_file,
+ &const_name,
+ shader_path,
+ "vs_4_1",
+ );
+ generate_rust_binding(&const_name, &output_file, rust_binding_path);
+
+ // Compile fragment shader
+ let output_file = format!("{}/{}_ps.h", out_dir, module);
+ let const_name = format!("{}_FRAGMENT_BYTES", module.to_uppercase());
+ compile_shader_impl(
+ fxc_path,
+ &format!("{module}_fragment"),
+ &output_file,
+ &const_name,
+ shader_path,
+ "ps_4_1",
+ );
+ generate_rust_binding(&const_name, &output_file, rust_binding_path);
+ }
+
+ fn compile_shader_impl(
+ fxc_path: &str,
+ entry_point: &str,
+ output_path: &str,
+ var_name: &str,
+ shader_path: &str,
+ target: &str,
+ ) {
+ let output = Command::new(fxc_path)
+ .args([
+ "/T",
+ target,
+ "/E",
+ entry_point,
+ "/Fh",
+ output_path,
+ "/Vn",
+ var_name,
+ "/O3",
+ shader_path,
+ ])
+ .output();
+
+ match output {
+ Ok(result) => {
+ if result.status.success() {
+ return;
+ }
+ eprintln!(
+ "Shader compilation failed for {}:\n{}",
+ entry_point,
+ String::from_utf8_lossy(&result.stderr)
+ );
+ process::exit(1);
+ }
+ Err(e) => {
+ eprintln!("Failed to run fxc for {}: {}", entry_point, e);
+ process::exit(1);
+ }
+ }
+ }
+
+ fn generate_rust_binding(const_name: &str, head_file: &str, output_path: &str) {
+ let header_content = fs::read_to_string(head_file).expect("Failed to read header file");
+ let const_definition = {
+ let global_var_start = header_content.find("const BYTE").unwrap();
+ let global_var = &header_content[global_var_start..];
+ let equal = global_var.find('=').unwrap();
+ global_var[equal + 1..].trim()
+ };
+ let rust_binding = format!(
+ "const {}: &[u8] = &{}\n",
+ const_name,
+ const_definition.replace('{', "[").replace('}', "]")
+ );
+ let mut options = fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(output_path)
+ .expect("Failed to open Rust binding file");
+ options
+ .write_all(rust_binding.as_bytes())
+ .expect("Failed to write Rust binding file");
+ }
+}
@@ -0,0 +1,80 @@
+use gpui::{
+ App, Application, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, div, prelude::*,
+ px, rgb, size,
+};
+
+// https://en.wikipedia.org/wiki/Holy_grail_(web_design)
+struct HolyGrailExample {}
+
+impl Render for HolyGrailExample {
+ fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+ let block = |color: Hsla| {
+ div()
+ .size_full()
+ .bg(color)
+ .border_1()
+ .border_dashed()
+ .rounded_md()
+ .border_color(gpui::white())
+ .items_center()
+ };
+
+ div()
+ .gap_1()
+ .grid()
+ .bg(rgb(0x505050))
+ .size(px(500.0))
+ .shadow_lg()
+ .border_1()
+ .size_full()
+ .grid_cols(5)
+ .grid_rows(5)
+ .child(
+ block(gpui::white())
+ .row_span(1)
+ .col_span_full()
+ .child("Header"),
+ )
+ .child(
+ block(gpui::red())
+ .col_span(1)
+ .h_56()
+ .child("Table of contents"),
+ )
+ .child(
+ block(gpui::green())
+ .col_span(3)
+ .row_span(3)
+ .child("Content"),
+ )
+ .child(
+ block(gpui::blue())
+ .col_span(1)
+ .row_span(3)
+ .child("AD :(")
+ .text_color(gpui::white()),
+ )
+ .child(
+ block(gpui::black())
+ .row_span(1)
+ .col_span_full()
+ .text_color(gpui::white())
+ .child("Footer"),
+ )
+ }
+}
+
+fn main() {
+ Application::new().run(|cx: &mut App| {
+ let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx);
+ cx.open_window(
+ WindowOptions {
+ window_bounds: Some(WindowBounds::Windowed(bounds)),
+ ..Default::default()
+ },
+ |_, cx| cx.new(|_| HolyGrailExample {}),
+ )
+ .unwrap();
+ cx.activate(true);
+ });
+}
@@ -137,14 +137,14 @@ impl TextInput {
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
if !self.selected_range.is_empty() {
cx.write_to_clipboard(ClipboardItem::new_string(
- (&self.content[self.selected_range.clone()]).to_string(),
+ self.content[self.selected_range.clone()].to_string(),
));
}
}
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
if !self.selected_range.is_empty() {
cx.write_to_clipboard(ClipboardItem::new_string(
- (&self.content[self.selected_range.clone()]).to_string(),
+ self.content[self.selected_range.clone()].to_string(),
));
self.replace_text_in_range(None, "", window, cx)
}
@@ -446,7 +446,7 @@ impl Element for TextElement {
let (display_text, text_color) = if content.is_empty() {
(input.placeholder.clone(), hsla(0., 0., 0., 0.2))
} else {
- (content.clone(), style.color)
+ (content, style.color)
};
let run = TextRun {
@@ -474,7 +474,7 @@ impl Element for TextElement {
},
TextRun {
len: display_text.len() - marked_range.end,
- ..run.clone()
+ ..run
},
]
.into_iter()
@@ -549,10 +549,10 @@ impl Element for TextElement {
line.paint(bounds.origin, window.line_height(), window, cx)
.unwrap();
- if focus_handle.is_focused(window) {
- if let Some(cursor) = prepaint.cursor.take() {
- window.paint_quad(cursor);
- }
+ if focus_handle.is_focused(window)
+ && let Some(cursor) = prepaint.cursor.take()
+ {
+ window.paint_quad(cursor);
}
self.input.update(cx, |input, _cx| {
@@ -595,9 +595,7 @@ impl Render for TextInput {
.w_full()
.p(px(4.))
.bg(white())
- .child(TextElement {
- input: cx.entity().clone(),
- }),
+ .child(TextElement { input: cx.entity() }),
)
}
}
@@ -1,15 +1,12 @@
use gpui::{
Application, Background, Bounds, ColorSpace, Context, MouseDownEvent, Path, PathBuilder,
- PathStyle, Pixels, Point, Render, SharedString, StrokeOptions, Window, WindowBounds,
- WindowOptions, canvas, div, linear_color_stop, linear_gradient, point, prelude::*, px, rgb,
- size,
+ PathStyle, Pixels, Point, Render, SharedString, StrokeOptions, Window, WindowOptions, canvas,
+ div, linear_color_stop, linear_gradient, point, prelude::*, px, quad, rgb, size,
};
-const DEFAULT_WINDOW_WIDTH: Pixels = px(1024.0);
-const DEFAULT_WINDOW_HEIGHT: Pixels = px(768.0);
-
struct PaintingViewer {
default_lines: Vec<(Path<Pixels>, Background)>,
+ background_quads: Vec<(Bounds<Pixels>, Background)>,
lines: Vec<Vec<Point<Pixels>>>,
start: Point<Pixels>,
dashed: bool,
@@ -20,12 +17,148 @@ impl PaintingViewer {
fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
let mut lines = vec![];
+ // Black squares beneath transparent paths.
+ let background_quads = vec![
+ (
+ Bounds {
+ origin: point(px(70.), px(70.)),
+ size: size(px(40.), px(40.)),
+ },
+ gpui::black().into(),
+ ),
+ (
+ Bounds {
+ origin: point(px(170.), px(70.)),
+ size: size(px(40.), px(40.)),
+ },
+ gpui::black().into(),
+ ),
+ (
+ Bounds {
+ origin: point(px(270.), px(70.)),
+ size: size(px(40.), px(40.)),
+ },
+ gpui::black().into(),
+ ),
+ (
+ Bounds {
+ origin: point(px(370.), px(70.)),
+ size: size(px(40.), px(40.)),
+ },
+ gpui::black().into(),
+ ),
+ (
+ Bounds {
+ origin: point(px(450.), px(50.)),
+ size: size(px(80.), px(80.)),
+ },
+ gpui::black().into(),
+ ),
+ ];
+
+ // 50% opaque red path that extends across black quad.
+ let mut builder = PathBuilder::fill();
+ builder.move_to(point(px(50.), px(50.)));
+ builder.line_to(point(px(130.), px(50.)));
+ builder.line_to(point(px(130.), px(130.)));
+ builder.line_to(point(px(50.), px(130.)));
+ builder.close();
+ let path = builder.build().unwrap();
+ let mut red = rgb(0xFF0000);
+ red.a = 0.5;
+ lines.push((path, red.into()));
+
+ // 50% opaque blue path that extends across black quad.
+ let mut builder = PathBuilder::fill();
+ builder.move_to(point(px(150.), px(50.)));
+ builder.line_to(point(px(230.), px(50.)));
+ builder.line_to(point(px(230.), px(130.)));
+ builder.line_to(point(px(150.), px(130.)));
+ builder.close();
+ let path = builder.build().unwrap();
+ let mut blue = rgb(0x0000FF);
+ blue.a = 0.5;
+ lines.push((path, blue.into()));
+
+ // 50% opaque green path that extends across black quad.
+ let mut builder = PathBuilder::fill();
+ builder.move_to(point(px(250.), px(50.)));
+ builder.line_to(point(px(330.), px(50.)));
+ builder.line_to(point(px(330.), px(130.)));
+ builder.line_to(point(px(250.), px(130.)));
+ builder.close();
+ let path = builder.build().unwrap();
+ let mut green = rgb(0x00FF00);
+ green.a = 0.5;
+ lines.push((path, green.into()));
+
+ // 50% opaque black path that extends across black quad.
+ let mut builder = PathBuilder::fill();
+ builder.move_to(point(px(350.), px(50.)));
+ builder.line_to(point(px(430.), px(50.)));
+ builder.line_to(point(px(430.), px(130.)));
+ builder.line_to(point(px(350.), px(130.)));
+ builder.close();
+ let path = builder.build().unwrap();
+ let mut black = rgb(0x000000);
+ black.a = 0.5;
+ lines.push((path, black.into()));
+
+ // Two 50% opaque red circles overlapping - center should be darker red
+ let mut builder = PathBuilder::fill();
+ let center = point(px(530.), px(85.));
+ let radius = px(30.);
+ builder.move_to(point(center.x + radius, center.y));
+ builder.arc_to(
+ point(radius, radius),
+ px(0.),
+ false,
+ false,
+ point(center.x - radius, center.y),
+ );
+ builder.arc_to(
+ point(radius, radius),
+ px(0.),
+ false,
+ false,
+ point(center.x + radius, center.y),
+ );
+ builder.close();
+ let path = builder.build().unwrap();
+ let mut red1 = rgb(0xFF0000);
+ red1.a = 0.5;
+ lines.push((path, red1.into()));
+
+ let mut builder = PathBuilder::fill();
+ let center = point(px(570.), px(85.));
+ let radius = px(30.);
+ builder.move_to(point(center.x + radius, center.y));
+ builder.arc_to(
+ point(radius, radius),
+ px(0.),
+ false,
+ false,
+ point(center.x - radius, center.y),
+ );
+ builder.arc_to(
+ point(radius, radius),
+ px(0.),
+ false,
+ false,
+ point(center.x + radius, center.y),
+ );
+ builder.close();
+ let path = builder.build().unwrap();
+ let mut red2 = rgb(0xFF0000);
+ red2.a = 0.5;
+ lines.push((path, red2.into()));
+
// draw a Rust logo
let mut builder = lyon::path::Path::svg_builder();
lyon::extra::rust_logo::build_logo_path(&mut builder);
// move down the Path
let mut builder: PathBuilder = builder.into();
- builder.translate(point(px(10.), px(100.)));
+ builder.translate(point(px(10.), px(200.)));
builder.scale(0.9);
let path = builder.build().unwrap();
lines.push((path, gpui::black().into()));
@@ -34,10 +167,10 @@ impl PaintingViewer {
let mut builder = PathBuilder::fill();
builder.add_polygon(
&[
- point(px(150.), px(200.)),
- point(px(200.), px(125.)),
- point(px(200.), px(175.)),
- point(px(250.), px(100.)),
+ point(px(150.), px(300.)),
+ point(px(200.), px(225.)),
+ point(px(200.), px(275.)),
+ point(px(250.), px(200.)),
],
false,
);
@@ -46,17 +179,17 @@ impl PaintingViewer {
// draw a ⭐
let mut builder = PathBuilder::fill();
- builder.move_to(point(px(350.), px(100.)));
- builder.line_to(point(px(370.), px(160.)));
- builder.line_to(point(px(430.), px(160.)));
- builder.line_to(point(px(380.), px(200.)));
- builder.line_to(point(px(400.), px(260.)));
- builder.line_to(point(px(350.), px(220.)));
- builder.line_to(point(px(300.), px(260.)));
- builder.line_to(point(px(320.), px(200.)));
- builder.line_to(point(px(270.), px(160.)));
- builder.line_to(point(px(330.), px(160.)));
- builder.line_to(point(px(350.), px(100.)));
+ builder.move_to(point(px(350.), px(200.)));
+ builder.line_to(point(px(370.), px(260.)));
+ builder.line_to(point(px(430.), px(260.)));
+ builder.line_to(point(px(380.), px(300.)));
+ builder.line_to(point(px(400.), px(360.)));
+ builder.line_to(point(px(350.), px(320.)));
+ builder.line_to(point(px(300.), px(360.)));
+ builder.line_to(point(px(320.), px(300.)));
+ builder.line_to(point(px(270.), px(260.)));
+ builder.line_to(point(px(330.), px(260.)));
+ builder.line_to(point(px(350.), px(200.)));
let path = builder.build().unwrap();
lines.push((
path,
@@ -70,7 +203,7 @@ impl PaintingViewer {
// draw linear gradient
let square_bounds = Bounds {
- origin: point(px(450.), px(100.)),
+ origin: point(px(450.), px(200.)),
size: size(px(200.), px(80.)),
};
let height = square_bounds.size.height;
@@ -100,31 +233,31 @@ impl PaintingViewer {
// draw a pie chart
let center = point(px(96.), px(96.));
- let pie_center = point(px(775.), px(155.));
+ let pie_center = point(px(775.), px(255.));
let segments = [
(
- point(px(871.), px(155.)),
- point(px(747.), px(63.)),
+ point(px(871.), px(255.)),
+ point(px(747.), px(163.)),
rgb(0x1374e9),
),
(
- point(px(747.), px(63.)),
- point(px(679.), px(163.)),
+ point(px(747.), px(163.)),
+ point(px(679.), px(263.)),
rgb(0xe13527),
),
(
- point(px(679.), px(163.)),
- point(px(754.), px(249.)),
+ point(px(679.), px(263.)),
+ point(px(754.), px(349.)),
rgb(0x0751ce),
),
(
- point(px(754.), px(249.)),
- point(px(854.), px(210.)),
+ point(px(754.), px(349.)),
+ point(px(854.), px(310.)),
rgb(0x209742),
),
(
- point(px(854.), px(210.)),
- point(px(871.), px(155.)),
+ point(px(854.), px(310.)),
+ point(px(871.), px(255.)),
rgb(0xfbc10a),
),
];
@@ -144,16 +277,19 @@ impl PaintingViewer {
.with_line_width(1.)
.with_line_join(lyon::path::LineJoin::Bevel);
let mut builder = PathBuilder::stroke(px(1.)).with_style(PathStyle::Stroke(options));
- builder.move_to(point(px(40.), px(320.)));
+ builder.move_to(point(px(40.), px(420.)));
for i in 1..50 {
builder.line_to(point(
px(40.0 + i as f32 * 10.0),
- px(320.0 + (i as f32 * 10.0).sin() * 40.0),
+ px(420.0 + (i as f32 * 10.0).sin() * 40.0),
));
}
+ let path = builder.build().unwrap();
+ lines.push((path, gpui::green().into()));
Self {
default_lines: lines.clone(),
+ background_quads,
lines: vec![],
start: point(px(0.), px(0.)),
dashed: false,
@@ -185,13 +321,10 @@ fn button(
}
impl Render for PaintingViewer {
- fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- window.request_animation_frame();
-
+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let default_lines = self.default_lines.clone();
+ let background_quads = self.background_quads.clone();
let lines = self.lines.clone();
- let window_size = window.bounds().size;
- let scale = window_size.width / DEFAULT_WINDOW_WIDTH;
let dashed = self.dashed;
div()
@@ -227,8 +360,21 @@ impl Render for PaintingViewer {
canvas(
move |_, _, _| {},
move |_, _, window, _| {
+ // First draw background quads
+ for (bounds, color) in background_quads.iter() {
+ window.paint_quad(quad(
+ *bounds,
+ px(0.),
+ *color,
+ px(0.),
+ gpui::transparent_black(),
+ Default::default(),
+ ));
+ }
+
+ // Then draw the default paths on top
for (path, color) in default_lines {
- window.paint_path(path.clone().scale(scale), color);
+ window.paint_path(path, color);
}
for points in lines {
@@ -304,16 +450,15 @@ fn main() {
cx.open_window(
WindowOptions {
focus: true,
- window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
- None,
- size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT),
- cx,
- ))),
..Default::default()
},
|window, cx| cx.new(|cx| PaintingViewer::new(window, cx)),
)
.unwrap();
+ cx.on_window_closed(|cx| {
+ cx.quit();
+ })
+ .detach();
cx.activate(true);
});
}
@@ -0,0 +1,92 @@
+use gpui::{
+ Application, Background, Bounds, ColorSpace, Context, Path, PathBuilder, Pixels, Render,
+ TitlebarOptions, Window, WindowBounds, WindowOptions, canvas, div, linear_color_stop,
+ linear_gradient, point, prelude::*, px, rgb, size,
+};
+
+const DEFAULT_WINDOW_WIDTH: Pixels = px(1024.0);
+const DEFAULT_WINDOW_HEIGHT: Pixels = px(768.0);
+
+struct PaintingViewer {
+ default_lines: Vec<(Path<Pixels>, Background)>,
+ _painting: bool,
+}
+
+impl PaintingViewer {
+ fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
+ let mut lines = vec![];
+
+ // draw a lightening bolt ⚡
+ for _ in 0..2000 {
+ // draw a ⭐
+ let mut builder = PathBuilder::fill();
+ builder.move_to(point(px(350.), px(100.)));
+ builder.line_to(point(px(370.), px(160.)));
+ builder.line_to(point(px(430.), px(160.)));
+ builder.line_to(point(px(380.), px(200.)));
+ builder.line_to(point(px(400.), px(260.)));
+ builder.line_to(point(px(350.), px(220.)));
+ builder.line_to(point(px(300.), px(260.)));
+ builder.line_to(point(px(320.), px(200.)));
+ builder.line_to(point(px(270.), px(160.)));
+ builder.line_to(point(px(330.), px(160.)));
+ builder.line_to(point(px(350.), px(100.)));
+ let path = builder.build().unwrap();
+ lines.push((
+ path,
+ linear_gradient(
+ 180.,
+ linear_color_stop(rgb(0xFACC15), 0.7),
+ linear_color_stop(rgb(0xD56D0C), 1.),
+ )
+ .color_space(ColorSpace::Oklab),
+ ));
+ }
+
+ Self {
+ default_lines: lines,
+ _painting: false,
+ }
+ }
+}
+
+impl Render for PaintingViewer {
+ fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+ window.request_animation_frame();
+ let lines = self.default_lines.clone();
+ div().size_full().child(
+ canvas(
+ move |_, _, _| {},
+ move |_, _, window, _| {
+ for (path, color) in lines {
+ window.paint_path(path, color);
+ }
+ },
+ )
+ .size_full(),
+ )
+ }
+}
+
+fn main() {
+ Application::new().run(|cx| {
+ cx.open_window(
+ WindowOptions {
+ titlebar: Some(TitlebarOptions {
+ title: Some("Vulkan".into()),
+ ..Default::default()
+ }),
+ focus: true,
+ window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
+ None,
+ size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT),
+ cx,
+ ))),
+ ..Default::default()
+ },
+ |window, cx| cx.new(|cx| PaintingViewer::new(window, cx)),
+ )
+ .unwrap();
+ cx.activate(true);
+ });
+}
@@ -1,5 +1,6 @@
use gpui::{
- App, Application, Context, Menu, MenuItem, Window, WindowOptions, actions, div, prelude::*, rgb,
+ App, Application, Context, Menu, MenuItem, SystemMenuType, Window, WindowOptions, actions, div,
+ prelude::*, rgb,
};
struct SetMenus;
@@ -27,14 +28,18 @@ fn main() {
// Add menu items
cx.set_menus(vec![Menu {
name: "set_menus".into(),
- items: vec![MenuItem::action("Quit", Quit)],
+ items: vec![
+ MenuItem::os_submenu("Services", SystemMenuType::Services),
+ MenuItem::separator(),
+ MenuItem::action("Quit", Quit),
+ ],
}]);
cx.open_window(WindowOptions::default(), |_, cx| cx.new(|_| SetMenus {}))
.unwrap();
});
}
-// Associate actions using the `actions!` macro (or `impl_actions!` macro)
+// Associate actions using the `actions!` macro (or `Action` derive macro)
actions!(set_menus, [Quit]);
// Define the quit function that is registered with the App
@@ -0,0 +1,155 @@
+use gpui::{
+ App, Application, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString,
+ Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size,
+};
+
+actions!(example, [Tab, TabPrev]);
+
+struct Example {
+ focus_handle: FocusHandle,
+ items: Vec<FocusHandle>,
+ message: SharedString,
+}
+
+impl Example {
+ fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
+ let items = vec![
+ cx.focus_handle().tab_index(1).tab_stop(true),
+ cx.focus_handle().tab_index(2).tab_stop(true),
+ cx.focus_handle().tab_index(3).tab_stop(true),
+ cx.focus_handle(),
+ cx.focus_handle().tab_index(2).tab_stop(true),
+ ];
+
+ let focus_handle = cx.focus_handle();
+ window.focus(&focus_handle);
+
+ Self {
+ focus_handle,
+ items,
+ message: SharedString::from("Press `Tab`, `Shift-Tab` to switch focus."),
+ }
+ }
+
+ fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context<Self>) {
+ window.focus_next();
+ self.message = SharedString::from("You have pressed `Tab`.");
+ }
+
+ fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context<Self>) {
+ window.focus_prev();
+ self.message = SharedString::from("You have pressed `Shift-Tab`.");
+ }
+}
+
+impl Render for Example {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ fn tab_stop_style<T: Styled>(this: T) -> T {
+ this.border_3().border_color(gpui::blue())
+ }
+
+ fn button(id: impl Into<ElementId>) -> Stateful<Div> {
+ div()
+ .id(id)
+ .h_10()
+ .flex_1()
+ .flex()
+ .justify_center()
+ .items_center()
+ .border_1()
+ .border_color(gpui::black())
+ .bg(gpui::black())
+ .text_color(gpui::white())
+ .focus(tab_stop_style)
+ .shadow_sm()
+ }
+
+ div()
+ .id("app")
+ .track_focus(&self.focus_handle)
+ .on_action(cx.listener(Self::on_tab))
+ .on_action(cx.listener(Self::on_tab_prev))
+ .size_full()
+ .flex()
+ .flex_col()
+ .p_4()
+ .gap_3()
+ .bg(gpui::white())
+ .text_color(gpui::black())
+ .child(self.message.clone())
+ .children(
+ self.items
+ .clone()
+ .into_iter()
+ .enumerate()
+ .map(|(ix, item_handle)| {
+ div()
+ .id(("item", ix))
+ .track_focus(&item_handle)
+ .h_10()
+ .w_full()
+ .flex()
+ .justify_center()
+ .items_center()
+ .border_1()
+ .border_color(gpui::black())
+ .when(
+ item_handle.tab_stop && item_handle.is_focused(window),
+ tab_stop_style,
+ )
+ .map(|this| match item_handle.tab_stop {
+ true => this
+ .hover(|this| this.bg(gpui::black().opacity(0.1)))
+ .child(format!("tab_index: {}", item_handle.tab_index)),
+ false => this.opacity(0.4).child("tab_stop: false"),
+ })
+ }),
+ )
+ .child(
+ div()
+ .flex()
+ .flex_row()
+ .gap_3()
+ .items_center()
+ .child(
+ button("el1")
+ .tab_index(4)
+ .child("Button 1")
+ .on_click(cx.listener(|this, _, _, cx| {
+ this.message = "You have clicked Button 1.".into();
+ cx.notify();
+ })),
+ )
+ .child(
+ button("el2")
+ .tab_index(5)
+ .child("Button 2")
+ .on_click(cx.listener(|this, _, _, cx| {
+ this.message = "You have clicked Button 2.".into();
+ cx.notify();
+ })),
+ ),
+ )
+ }
+}
+
+fn main() {
+ Application::new().run(|cx: &mut App| {
+ cx.bind_keys([
+ KeyBinding::new("tab", Tab, None),
+ KeyBinding::new("shift-tab", TabPrev, None),
+ ]);
+
+ let bounds = Bounds::centered(None, size(px(800.), px(600.0)), cx);
+ cx.open_window(
+ WindowOptions {
+ window_bounds: Some(WindowBounds::Windowed(bounds)),
+ ..Default::default()
+ },
+ |window, cx| cx.new(|cx| Example::new(window, cx)),
+ )
+ .unwrap();
+
+ cx.activate(true);
+ });
+}
@@ -155,7 +155,7 @@ impl RenderOnce for Specimen {
.text_size(px(font_size * scale))
.line_height(relative(line_height))
.p(px(10.0))
- .child(self.string.clone())
+ .child(self.string)
}
}
@@ -198,7 +198,7 @@ impl RenderOnce for CharacterGrid {
"χ", "ψ", "∂", "а", "в", "Ж", "ж", "З", "з", "К", "к", "л", "м", "Н", "н", "Р", "р",
"У", "у", "ф", "ч", "ь", "ы", "Э", "э", "Я", "я", "ij", "öẋ", ".,", "⣝⣑", "~", "*",
"_", "^", "`", "'", "(", "{", "«", "#", "&", "@", "$", "¢", "%", "|", "?", "¶", "µ",
- "❮", "<=", "!=", "==", "--", "++", "=>", "->",
+ "❮", "<=", "!=", "==", "--", "++", "=>", "->", "🏀", "🎊", "😍", "❤️", "👍", "👎",
];
let columns = 11;
@@ -0,0 +1,46 @@
+//! Renders a div with deep children hierarchy. This example is useful to exemplify that Zed can
+//! handle deep hierarchies (even though it cannot just yet!).
+use std::sync::LazyLock;
+
+use gpui::{
+ App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px,
+ size,
+};
+
+struct Tree {}
+
+static DEPTH: LazyLock<u64> = LazyLock::new(|| {
+ std::env::var("GPUI_TREE_DEPTH")
+ .ok()
+ .and_then(|depth| depth.parse().ok())
+ .unwrap_or_else(|| 50)
+});
+
+impl Render for Tree {
+ fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+ let mut depth = *DEPTH;
+ static COLORS: [gpui::Hsla; 4] = [gpui::red(), gpui::blue(), gpui::green(), gpui::yellow()];
+ let mut colors = COLORS.iter().cycle().copied();
+ let mut next_div = || div().p_0p5().bg(colors.next().unwrap());
+ let mut innermost_node = next_div();
+ while depth > 0 {
+ innermost_node = next_div().child(innermost_node);
+ depth -= 1;
+ }
+ innermost_node
+ }
+}
+
+fn main() {
+ Application::new().run(|cx: &mut App| {
+ let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx);
+ cx.open_window(
+ WindowOptions {
+ window_bounds: Some(WindowBounds::Windowed(bounds)),
+ ..Default::default()
+ },
+ |_, cx| cx.new(|_| Tree {}),
+ )
+ .unwrap();
+ });
+}
@@ -165,8 +165,8 @@ impl Render for WindowShadow {
},
)
.on_click(|e, window, _| {
- if e.down.button == MouseButton::Right {
- window.show_window_menu(e.up.position);
+ if e.is_right_click() {
+ window.show_window_menu(e.position());
}
})
.text_color(black())
@@ -73,18 +73,18 @@ macro_rules! actions {
/// - `name = "ActionName"` overrides the action's name. This must not contain `::`.
///
/// - `no_json` causes the `build` method to always error and `action_json_schema` to return `None`,
-/// and allows actions not implement `serde::Serialize` and `schemars::JsonSchema`.
+/// and allows actions not implement `serde::Serialize` and `schemars::JsonSchema`.
///
/// - `no_register` skips registering the action. This is useful for implementing the `Action` trait
-/// while not supporting invocation by name or JSON deserialization.
+/// while not supporting invocation by name or JSON deserialization.
///
/// - `deprecated_aliases = ["editor::SomeAction"]` specifies deprecated old names for the action.
-/// These action names should *not* correspond to any actions that are registered. These old names
-/// can then still be used to refer to invoke this action. In Zed, the keymap JSON schema will
-/// accept these old names and provide warnings.
+/// These action names should *not* correspond to any actions that are registered. These old names
+/// can then still be used to refer to invoke this action. In Zed, the keymap JSON schema will
+/// accept these old names and provide warnings.
///
/// - `deprecated = "Message about why this action is deprecation"` specifies a deprecation message.
-/// In Zed, the keymap JSON schema will cause this to be displayed as a warning.
+/// In Zed, the keymap JSON schema will cause this to be displayed as a warning.
///
/// # Manual Implementation
///
@@ -277,6 +277,8 @@ pub struct App {
pub(crate) release_listeners: SubscriberSet<EntityId, ReleaseListener>,
pub(crate) global_observers: SubscriberSet<TypeId, Handler>,
pub(crate) quit_observers: SubscriberSet<(), QuitHandler>,
+ pub(crate) restart_observers: SubscriberSet<(), Handler>,
+ pub(crate) restart_path: Option<PathBuf>,
pub(crate) window_closed_observers: SubscriberSet<(), WindowClosedHandler>,
pub(crate) layout_id_buffer: Vec<LayoutId>, // We recycle this memory across layout requests.
pub(crate) propagate_event: bool,
@@ -349,6 +351,8 @@ impl App {
keyboard_layout_observers: SubscriberSet::new(),
global_observers: SubscriberSet::new(),
quit_observers: SubscriberSet::new(),
+ restart_observers: SubscriberSet::new(),
+ restart_path: None,
window_closed_observers: SubscriberSet::new(),
layout_id_buffer: Default::default(),
propagate_event: true,
@@ -364,7 +368,7 @@ impl App {
}),
});
- init_app_menus(platform.as_ref(), &mut app.borrow_mut());
+ init_app_menus(platform.as_ref(), &app.borrow());
platform.on_keyboard_layout_change(Box::new({
let app = Rc::downgrade(&app);
@@ -448,15 +452,23 @@ impl App {
}
pub(crate) fn update<R>(&mut self, update: impl FnOnce(&mut Self) -> R) -> R {
- self.pending_updates += 1;
+ self.start_update();
let result = update(self);
+ self.finish_update();
+ result
+ }
+
+ pub(crate) fn start_update(&mut self) {
+ self.pending_updates += 1;
+ }
+
+ pub(crate) fn finish_update(&mut self) {
if !self.flushing_effects && self.pending_updates == 1 {
self.flushing_effects = true;
self.flush_effects();
self.flushing_effects = false;
}
self.pending_updates -= 1;
- result
}
/// Arrange a callback to be invoked when the given entity calls `notify` on its respective context.
@@ -688,7 +700,7 @@ impl App {
/// Returns a list of available screen capture sources.
pub fn screen_capture_sources(
&self,
- ) -> oneshot::Receiver<Result<Vec<Box<dyn ScreenCaptureSource>>>> {
+ ) -> oneshot::Receiver<Result<Vec<Rc<dyn ScreenCaptureSource>>>> {
self.platform.screen_capture_sources()
}
@@ -804,8 +816,9 @@ impl App {
pub fn prompt_for_new_path(
&self,
directory: &Path,
+ suggested_name: Option<&str>,
) -> oneshot::Receiver<Result<Option<PathBuf>>> {
- self.platform.prompt_for_new_path(directory)
+ self.platform.prompt_for_new_path(directory, suggested_name)
}
/// Reveals the specified path at the platform level, such as in Finder on macOS.
@@ -824,8 +837,16 @@ impl App {
}
/// Restarts the application.
- pub fn restart(&self, binary_path: Option<PathBuf>) {
- self.platform.restart(binary_path)
+ pub fn restart(&mut self) {
+ self.restart_observers
+ .clone()
+ .retain(&(), |observer| observer(self));
+ self.platform.restart(self.restart_path.take())
+ }
+
+ /// Sets the path to use when restarting the application.
+ pub fn set_restart_path(&mut self, path: PathBuf) {
+ self.restart_path = Some(path);
}
/// Returns the HTTP client for the application.
@@ -868,7 +889,6 @@ impl App {
loop {
self.release_dropped_entities();
self.release_dropped_focus_handles();
-
if let Some(effect) = self.pending_effects.pop_front() {
match effect {
Effect::Notify { emitter } => {
@@ -947,8 +967,8 @@ impl App {
self.focus_handles
.clone()
.write()
- .retain(|handle_id, count| {
- if count.load(SeqCst) == 0 {
+ .retain(|handle_id, focus| {
+ if focus.ref_count.load(SeqCst) == 0 {
for window_handle in self.windows() {
window_handle
.update(self, |_, window, _| {
@@ -1290,7 +1310,7 @@ impl App {
T: 'static,
{
let window_handle = window.handle;
- self.observe_release(&handle, move |entity, cx| {
+ self.observe_release(handle, move |entity, cx| {
let _ = window_handle.update(cx, |_, window, cx| on_release(entity, window, cx));
})
}
@@ -1312,7 +1332,7 @@ impl App {
}
inner(
- &mut self.keystroke_observers,
+ &self.keystroke_observers,
Box::new(move |event, window, cx| {
f(event, window, cx);
true
@@ -1338,7 +1358,7 @@ impl App {
}
inner(
- &mut self.keystroke_interceptors,
+ &self.keystroke_interceptors,
Box::new(move |event, window, cx| {
f(event, window, cx);
true
@@ -1363,7 +1383,9 @@ impl App {
self.keymap.clone()
}
- /// Register a global listener for actions invoked via the keyboard.
+ /// Register a global handler for actions invoked via the keyboard. These handlers are run at
+ /// the end of the bubble phase for actions, and so will only be invoked if there are no other
+ /// handlers or if they called `cx.propagate()`.
pub fn on_action<A: Action>(&mut self, listener: impl Fn(&A, &mut Self) + 'static) {
self.global_action_listeners
.entry(TypeId::of::<A>())
@@ -1457,6 +1479,21 @@ impl App {
subscription
}
+ /// Register a callback to be invoked when the application is about to restart.
+ ///
+ /// These callbacks are called before any `on_app_quit` callbacks.
+ pub fn on_app_restart(&self, mut on_restart: impl 'static + FnMut(&mut App)) -> Subscription {
+ let (subscription, activate) = self.restart_observers.insert(
+ (),
+ Box::new(move |cx| {
+ on_restart(cx);
+ true
+ }),
+ );
+ activate();
+ subscription
+ }
+
/// Register a callback to be invoked when a window is closed
/// The window is no longer accessible at the point this callback is invoked.
pub fn on_window_closed(&self, mut on_closed: impl FnMut(&mut App) + 'static) -> Subscription {
@@ -1479,12 +1516,11 @@ impl App {
/// the bindings in the element tree, and any global action listeners.
pub fn is_action_available(&mut self, action: &dyn Action) -> bool {
let mut action_available = false;
- if let Some(window) = self.active_window() {
- if let Ok(window_action_available) =
+ if let Some(window) = self.active_window()
+ && let Ok(window_action_available) =
window.update(self, |_, window, cx| window.is_action_available(action, cx))
- {
- action_available = window_action_available;
- }
+ {
+ action_available = window_action_available;
}
action_available
@@ -1569,27 +1605,26 @@ impl App {
.insert(action.as_any().type_id(), global_listeners);
}
- if self.propagate_event {
- if let Some(mut global_listeners) = self
+ if self.propagate_event
+ && let Some(mut global_listeners) = self
.global_action_listeners
.remove(&action.as_any().type_id())
- {
- for listener in global_listeners.iter().rev() {
- listener(action.as_any(), DispatchPhase::Bubble, self);
- if !self.propagate_event {
- break;
- }
+ {
+ for listener in global_listeners.iter().rev() {
+ listener(action.as_any(), DispatchPhase::Bubble, self);
+ if !self.propagate_event {
+ break;
}
+ }
- global_listeners.extend(
- self.global_action_listeners
- .remove(&action.as_any().type_id())
- .unwrap_or_default(),
- );
-
+ global_listeners.extend(
self.global_action_listeners
- .insert(action.as_any().type_id(), global_listeners);
- }
+ .remove(&action.as_any().type_id())
+ .unwrap_or_default(),
+ );
+
+ self.global_action_listeners
+ .insert(action.as_any().type_id(), global_listeners);
}
}
@@ -1672,8 +1707,8 @@ impl App {
.unwrap_or_else(|| {
is_first = true;
let future = A::load(source.clone(), self);
- let task = self.background_executor().spawn(future).shared();
- task
+
+ self.background_executor().spawn(future).shared()
});
self.loading_assets.insert(asset_id, Box::new(task.clone()));
@@ -1819,6 +1854,13 @@ impl AppContext for App {
})
}
+ fn as_mut<'a, T>(&'a mut self, handle: &Entity<T>) -> GpuiBorrow<'a, T>
+ where
+ T: 'static,
+ {
+ GpuiBorrow::new(handle.clone(), self)
+ }
+
fn read_entity<T, R>(
&self,
handle: &Entity<T>,
@@ -1873,7 +1915,7 @@ impl AppContext for App {
G: Global,
{
let mut g = self.global::<G>();
- callback(&g, self)
+ callback(g, self)
}
}
@@ -2007,6 +2049,10 @@ impl HttpClient for NullHttpClient {
.boxed()
}
+ fn user_agent(&self) -> Option<&http_client::http::HeaderValue> {
+ None
+ }
+
fn proxy(&self) -> Option<&Url> {
None
}
@@ -2015,3 +2061,79 @@ impl HttpClient for NullHttpClient {
type_name::<Self>()
}
}
+
+/// A mutable reference to an entity owned by GPUI
+pub struct GpuiBorrow<'a, T> {
+ inner: Option<Lease<T>>,
+ app: &'a mut App,
+}
+
+impl<'a, T: 'static> GpuiBorrow<'a, T> {
+ fn new(inner: Entity<T>, app: &'a mut App) -> Self {
+ app.start_update();
+ let lease = app.entities.lease(&inner);
+ Self {
+ inner: Some(lease),
+ app,
+ }
+ }
+}
+
+impl<'a, T: 'static> std::borrow::Borrow<T> for GpuiBorrow<'a, T> {
+ fn borrow(&self) -> &T {
+ self.inner.as_ref().unwrap().borrow()
+ }
+}
+
+impl<'a, T: 'static> std::borrow::BorrowMut<T> for GpuiBorrow<'a, T> {
+ fn borrow_mut(&mut self) -> &mut T {
+ self.inner.as_mut().unwrap().borrow_mut()
+ }
+}
+
+impl<'a, T> Drop for GpuiBorrow<'a, T> {
+ fn drop(&mut self) {
+ let lease = self.inner.take().unwrap();
+ self.app.notify(lease.id);
+ self.app.entities.end_lease(lease);
+ self.app.finish_update();
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use std::{cell::RefCell, rc::Rc};
+
+ use crate::{AppContext, TestAppContext};
+
+ #[test]
+ fn test_gpui_borrow() {
+ let cx = TestAppContext::single();
+ let observation_count = Rc::new(RefCell::new(0));
+
+ let state = cx.update(|cx| {
+ let state = cx.new(|_| false);
+ cx.observe(&state, {
+ let observation_count = observation_count.clone();
+ move |_, _| {
+ let mut count = observation_count.borrow_mut();
+ *count += 1;
+ }
+ })
+ .detach();
+
+ state
+ });
+
+ cx.update(|cx| {
+ // Calling this like this so that we don't clobber the borrow_mut above
+ *std::borrow::BorrowMut::borrow_mut(&mut state.as_mut(cx)) = true;
+ });
+
+ cx.update(|cx| {
+ state.write(cx, false);
+ });
+
+ assert_eq!(*observation_count.borrow(), 2);
+ }
+}
@@ -3,7 +3,7 @@ use crate::{
Entity, EventEmitter, Focusable, ForegroundExecutor, Global, PromptButton, PromptLevel, Render,
Reservation, Result, Subscription, Task, VisualContext, Window, WindowHandle,
};
-use anyhow::Context as _;
+use anyhow::{Context as _, anyhow};
use derive_more::{Deref, DerefMut};
use futures::channel::oneshot;
use std::{future::Future, rc::Weak};
@@ -58,6 +58,15 @@ impl AppContext for AsyncApp {
Ok(app.update_entity(handle, update))
}
+ fn as_mut<'a, T>(&'a mut self, _handle: &Entity<T>) -> Self::Result<super::GpuiBorrow<'a, T>>
+ where
+ T: 'static,
+ {
+ Err(anyhow!(
+ "Cannot as_mut with an async context. Try calling update() first"
+ ))
+ }
+
fn read_entity<T, R>(
&self,
handle: &Entity<T>,
@@ -364,6 +373,15 @@ impl AppContext for AsyncWindowContext {
.update(self, |_, _, cx| cx.update_entity(handle, update))
}
+ fn as_mut<'a, T>(&'a mut self, _: &Entity<T>) -> Self::Result<super::GpuiBorrow<'a, T>>
+ where
+ T: 'static,
+ {
+ Err(anyhow!(
+ "Cannot use as_mut() from an async context, call `update`"
+ ))
+ }
+
fn read_entity<T, R>(
&self,
handle: &Entity<T>,
@@ -447,7 +465,7 @@ impl VisualContext for AsyncWindowContext {
V: Focusable,
{
self.window.update(self, |_, window, cx| {
- view.read(cx).focus_handle(cx).clone().focus(window);
+ view.read(cx).focus_handle(cx).focus(window);
})
}
}
@@ -164,6 +164,20 @@ impl<'a, T: 'static> Context<'a, T> {
subscription
}
+ /// Register a callback to be invoked when the application is about to restart.
+ pub fn on_app_restart(
+ &self,
+ mut on_restart: impl FnMut(&mut T, &mut App) + 'static,
+ ) -> Subscription
+ where
+ T: 'static,
+ {
+ let handle = self.weak_entity();
+ self.app.on_app_restart(move |cx| {
+ handle.update(cx, |entity, cx| on_restart(entity, cx)).ok();
+ })
+ }
+
/// Arrange for the given function to be invoked whenever the application is quit.
/// The future returned from this callback will be polled for up to [crate::SHUTDOWN_TIMEOUT] until the app fully quits.
pub fn on_app_quit<Fut>(
@@ -175,20 +189,15 @@ impl<'a, T: 'static> Context<'a, T> {
T: 'static,
{
let handle = self.weak_entity();
- let (subscription, activate) = self.app.quit_observers.insert(
- (),
- Box::new(move |cx| {
- let future = handle.update(cx, |entity, cx| on_quit(entity, cx)).ok();
- async move {
- if let Some(future) = future {
- future.await;
- }
+ self.app.on_app_quit(move |cx| {
+ let future = handle.update(cx, |entity, cx| on_quit(entity, cx)).ok();
+ async move {
+ if let Some(future) = future {
+ future.await;
}
- .boxed_local()
- }),
- );
- activate();
- subscription
+ }
+ .boxed_local()
+ })
}
/// Tell GPUI that this entity has changed and observers of it should be notified.
@@ -463,7 +472,7 @@ impl<'a, T: 'static> Context<'a, T> {
let view = self.weak_entity();
inner(
- &mut self.keystroke_observers,
+ &self.keystroke_observers,
Box::new(move |event, window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |view, cx| f(view, event, window, cx));
@@ -601,16 +610,16 @@ impl<'a, T: 'static> Context<'a, T> {
let (subscription, activate) =
window.new_focus_listener(Box::new(move |event, window, cx| {
view.update(cx, |view, cx| {
- if let Some(blurred_id) = event.previous_focus_path.last().copied() {
- if event.is_focus_out(focus_id) {
- let event = FocusOutEvent {
- blurred: WeakFocusHandle {
- id: blurred_id,
- handles: Arc::downgrade(&cx.focus_handles),
- },
- };
- listener(view, event, window, cx)
- }
+ if let Some(blurred_id) = event.previous_focus_path.last().copied()
+ && event.is_focus_out(focus_id)
+ {
+ let event = FocusOutEvent {
+ blurred: WeakFocusHandle {
+ id: blurred_id,
+ handles: Arc::downgrade(&cx.focus_handles),
+ },
+ };
+ listener(view, event, window, cx)
}
})
.is_ok()
@@ -726,6 +735,13 @@ impl<T> AppContext for Context<'_, T> {
self.app.update_entity(handle, update)
}
+ fn as_mut<'a, E>(&'a mut self, handle: &Entity<E>) -> Self::Result<super::GpuiBorrow<'a, E>>
+ where
+ E: 'static,
+ {
+ self.app.as_mut(handle)
+ }
+
fn read_entity<U, R>(
&self,
handle: &Entity<U>,
@@ -1,4 +1,4 @@
-use crate::{App, AppContext, VisualContext, Window, seal::Sealed};
+use crate::{App, AppContext, GpuiBorrow, VisualContext, Window, seal::Sealed};
use anyhow::{Context as _, Result};
use collections::FxHashSet;
use derive_more::{Deref, DerefMut};
@@ -105,7 +105,7 @@ impl EntityMap {
/// Move an entity to the stack.
#[track_caller]
- pub fn lease<'a, T>(&mut self, pointer: &'a Entity<T>) -> Lease<'a, T> {
+ pub fn lease<T>(&mut self, pointer: &Entity<T>) -> Lease<T> {
self.assert_valid_context(pointer);
let mut accessed_entities = self.accessed_entities.borrow_mut();
accessed_entities.insert(pointer.entity_id);
@@ -117,15 +117,14 @@ impl EntityMap {
);
Lease {
entity,
- pointer,
+ id: pointer.entity_id,
entity_type: PhantomData,
}
}
/// Returns an entity after moving it to the stack.
pub fn end_lease<T>(&mut self, mut lease: Lease<T>) {
- self.entities
- .insert(lease.pointer.entity_id, lease.entity.take().unwrap());
+ self.entities.insert(lease.id, lease.entity.take().unwrap());
}
pub fn read<T: 'static>(&self, entity: &Entity<T>) -> &T {
@@ -187,13 +186,13 @@ fn double_lease_panic<T>(operation: &str) -> ! {
)
}
-pub(crate) struct Lease<'a, T> {
+pub(crate) struct Lease<T> {
entity: Option<Box<dyn Any>>,
- pub pointer: &'a Entity<T>,
+ pub id: EntityId,
entity_type: PhantomData<T>,
}
-impl<T: 'static> core::ops::Deref for Lease<'_, T> {
+impl<T: 'static> core::ops::Deref for Lease<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
@@ -201,13 +200,13 @@ impl<T: 'static> core::ops::Deref for Lease<'_, T> {
}
}
-impl<T: 'static> core::ops::DerefMut for Lease<'_, T> {
+impl<T: 'static> core::ops::DerefMut for Lease<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.entity.as_mut().unwrap().downcast_mut().unwrap()
}
}
-impl<T> Drop for Lease<'_, T> {
+impl<T> Drop for Lease<T> {
fn drop(&mut self) {
if self.entity.is_some() && !panicking() {
panic!("Leases must be ended with EntityMap::end_lease")
@@ -232,14 +231,15 @@ impl AnyEntity {
Self {
entity_id: id,
entity_type,
- entity_map: entity_map.clone(),
#[cfg(any(test, feature = "leak-detection"))]
handle_id: entity_map
+ .clone()
.upgrade()
.unwrap()
.write()
.leak_detector
.handle_created(id),
+ entity_map,
}
}
@@ -371,7 +371,7 @@ impl std::fmt::Debug for AnyEntity {
}
}
-/// A strong, well typed reference to a struct which is managed
+/// A strong, well-typed reference to a struct which is managed
/// by GPUI
#[derive(Deref, DerefMut)]
pub struct Entity<T> {
@@ -437,6 +437,19 @@ impl<T: 'static> Entity<T> {
cx.update_entity(self, update)
}
+ /// Updates the entity referenced by this handle with the given function.
+ pub fn as_mut<'a, C: AppContext>(&self, cx: &'a mut C) -> C::Result<GpuiBorrow<'a, T>> {
+ cx.as_mut(self)
+ }
+
+ /// Updates the entity referenced by this handle with the given function.
+ pub fn write<C: AppContext>(&self, cx: &mut C, value: T) -> C::Result<()> {
+ self.update(cx, |entity, cx| {
+ *entity = value;
+ cx.notify();
+ })
+ }
+
/// Updates the entity referenced by this handle with the given function if
/// the referenced entity still exists, within a visual context that has a window.
/// Returns an error if the entity has been released.
@@ -649,7 +662,7 @@ pub struct WeakEntity<T> {
impl<T> std::fmt::Debug for WeakEntity<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- f.debug_struct(&type_name::<Self>())
+ f.debug_struct(type_name::<Self>())
.field("entity_id", &self.any_entity.entity_id)
.field("entity_type", &type_name::<T>())
.finish()
@@ -774,7 +787,7 @@ impl<T: 'static> PartialOrd for WeakEntity<T> {
#[cfg(any(test, feature = "leak-detection"))]
static LEAK_BACKTRACE: std::sync::LazyLock<bool> =
- std::sync::LazyLock::new(|| std::env::var("LEAK_BACKTRACE").map_or(false, |b| !b.is_empty()));
+ std::sync::LazyLock::new(|| std::env::var("LEAK_BACKTRACE").is_ok_and(|b| !b.is_empty()));
#[cfg(any(test, feature = "leak-detection"))]
#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)]
@@ -9,6 +9,7 @@ use crate::{
};
use anyhow::{anyhow, bail};
use futures::{Stream, StreamExt, channel::oneshot};
+use rand::{SeedableRng, rngs::StdRng};
use std::{cell::RefCell, future::Future, ops::Deref, rc::Rc, sync::Arc, time::Duration};
/// A TestAppContext is provided to tests created with `#[gpui::test]`, it provides
@@ -63,6 +64,13 @@ impl AppContext for TestAppContext {
app.update_entity(handle, update)
}
+ fn as_mut<'a, T>(&'a mut self, _: &Entity<T>) -> Self::Result<super::GpuiBorrow<'a, T>>
+ where
+ T: 'static,
+ {
+ panic!("Cannot use as_mut with a test app context. Try calling update() first")
+ }
+
fn read_entity<T, R>(
&self,
handle: &Entity<T>,
@@ -126,7 +134,7 @@ impl TestAppContext {
app: App::new_app(platform.clone(), asset_source, http_client),
background_executor,
foreground_executor,
- dispatcher: dispatcher.clone(),
+ dispatcher,
test_platform: platform,
text_system,
fn_name,
@@ -134,6 +142,12 @@ impl TestAppContext {
}
}
+ /// Create a single TestAppContext, for non-multi-client tests
+ pub fn single() -> Self {
+ let dispatcher = TestDispatcher::new(StdRng::from_entropy());
+ Self::build(dispatcher, None)
+ }
+
/// The name of the test function that created this `TestAppContext`
pub fn test_function_name(&self) -> Option<&'static str> {
self.fn_name
@@ -178,6 +192,7 @@ impl TestAppContext {
&self.foreground_executor
}
+ #[expect(clippy::wrong_self_convention)]
fn new<T: 'static>(&mut self, build_entity: impl FnOnce(&mut Context<T>) -> T) -> Entity<T> {
let mut cx = self.app.borrow_mut();
cx.new(build_entity)
@@ -205,7 +220,7 @@ impl TestAppContext {
let mut cx = self.app.borrow_mut();
// Some tests rely on the window size matching the bounds of the test display
- let bounds = Bounds::maximized(None, &mut cx);
+ let bounds = Bounds::maximized(None, &cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
@@ -219,7 +234,7 @@ impl TestAppContext {
/// Adds a new window with no content.
pub fn add_empty_window(&mut self) -> &mut VisualTestContext {
let mut cx = self.app.borrow_mut();
- let bounds = Bounds::maximized(None, &mut cx);
+ let bounds = Bounds::maximized(None, &cx);
let window = cx
.open_window(
WindowOptions {
@@ -230,7 +245,7 @@ impl TestAppContext {
)
.unwrap();
drop(cx);
- let cx = VisualTestContext::from_window(*window.deref(), self).as_mut();
+ let cx = VisualTestContext::from_window(*window.deref(), self).into_mut();
cx.run_until_parked();
cx
}
@@ -247,7 +262,7 @@ impl TestAppContext {
V: 'static + Render,
{
let mut cx = self.app.borrow_mut();
- let bounds = Bounds::maximized(None, &mut cx);
+ let bounds = Bounds::maximized(None, &cx);
let window = cx
.open_window(
WindowOptions {
@@ -259,7 +274,7 @@ impl TestAppContext {
.unwrap();
drop(cx);
let view = window.root(self).unwrap();
- let cx = VisualTestContext::from_window(*window.deref(), self).as_mut();
+ let cx = VisualTestContext::from_window(*window.deref(), self).into_mut();
cx.run_until_parked();
// it might be nice to try and cleanup these at the end of each test.
@@ -324,7 +339,7 @@ impl TestAppContext {
/// Returns all windows open in the test.
pub fn windows(&self) -> Vec<AnyWindowHandle> {
- self.app.borrow().windows().clone()
+ self.app.borrow().windows()
}
/// Run the given task on the main thread.
@@ -571,7 +586,7 @@ impl<V: 'static> Entity<V> {
cx.executor().advance_clock(advance_clock_by);
async move {
- let notification = crate::util::timeout(duration, rx.recv())
+ let notification = crate::util::smol_timeout(duration, rx.recv())
.await
.expect("next notification timed out");
drop(subscription);
@@ -604,7 +619,7 @@ impl<V> Entity<V> {
}
}),
cx.subscribe(self, {
- let mut tx = tx.clone();
+ let mut tx = tx;
move |_, _: &Evt, _| {
tx.blocking_send(()).ok();
}
@@ -615,7 +630,7 @@ impl<V> Entity<V> {
let handle = self.downgrade();
async move {
- crate::util::timeout(Duration::from_secs(1), async move {
+ crate::util::smol_timeout(Duration::from_secs(1), async move {
loop {
{
let cx = cx.borrow();
@@ -868,7 +883,7 @@ impl VisualTestContext {
/// Get an &mut VisualTestContext (which is mostly what you need to pass to other methods).
/// This method internally retains the VisualTestContext until the end of the test.
- pub fn as_mut(self) -> &'static mut Self {
+ pub fn into_mut(self) -> &'static mut Self {
let ptr = Box::into_raw(Box::new(self));
// safety: on_quit will be called after the test has finished.
// the executor will ensure that all tasks related to the test have stopped.
@@ -914,6 +929,13 @@ impl AppContext for VisualTestContext {
self.cx.update_entity(handle, update)
}
+ fn as_mut<'a, T>(&'a mut self, handle: &Entity<T>) -> Self::Result<super::GpuiBorrow<'a, T>>
+ where
+ T: 'static,
+ {
+ self.cx.as_mut(handle)
+ }
+
fn read_entity<T, R>(
&self,
handle: &Entity<T>,
@@ -1004,7 +1026,7 @@ impl VisualContext for VisualTestContext {
fn focus<V: crate::Focusable>(&mut self, view: &Entity<V>) -> Self::Result<()> {
self.window
.update(&mut self.cx, |_, window, cx| {
- view.read(cx).focus_handle(cx).clone().focus(window)
+ view.read(cx).focus_handle(cx).focus(window)
})
.unwrap()
}
@@ -142,7 +142,7 @@ impl Arena {
if self.current_chunk_index >= self.chunks.len() {
self.chunks.push(Chunk::new(self.chunk_size));
assert_eq!(self.current_chunk_index, self.chunks.len() - 1);
- log::info!(
+ log::trace!(
"increased element arena capacity to {}kb",
self.capacity() / 1024,
);
@@ -35,6 +35,7 @@ pub(crate) fn swap_rgba_pa_to_bgra(color: &mut [u8]) {
/// An RGBA color
#[derive(PartialEq, Clone, Copy, Default)]
+#[repr(C)]
pub struct Rgba {
/// The red component of the color, in the range 0.0 to 1.0
pub r: f32,
@@ -904,9 +905,9 @@ mod tests {
assert_eq!(background.solid, color);
assert_eq!(background.opacity(0.5).solid, color.opacity(0.5));
- assert_eq!(background.is_transparent(), false);
+ assert!(!background.is_transparent());
background.solid = hsla(0.0, 0.0, 0.0, 0.0);
- assert_eq!(background.is_transparent(), true);
+ assert!(background.is_transparent());
}
#[test]
@@ -920,7 +921,7 @@ mod tests {
assert_eq!(background.opacity(0.5).colors[0], from.opacity(0.5));
assert_eq!(background.opacity(0.5).colors[1], to.opacity(0.5));
- assert_eq!(background.is_transparent(), false);
- assert_eq!(background.opacity(0.0).is_transparent(), true);
+ assert!(!background.is_transparent());
+ assert!(background.opacity(0.0).is_transparent());
}
}
@@ -39,7 +39,7 @@ use crate::{
use derive_more::{Deref, DerefMut};
pub(crate) use smallvec::SmallVec;
use std::{
- any::Any,
+ any::{Any, type_name},
fmt::{self, Debug, Display},
mem, panic,
};
@@ -220,14 +220,17 @@ impl<C: RenderOnce> Element for Component<C> {
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
- let mut element = self
- .component
- .take()
- .unwrap()
- .render(window, cx)
- .into_any_element();
- let layout_id = element.request_layout(window, cx);
- (layout_id, element)
+ window.with_global_id(ElementId::Name(type_name::<C>().into()), |_, window| {
+ let mut element = self
+ .component
+ .take()
+ .unwrap()
+ .render(window, cx)
+ .into_any_element();
+
+ let layout_id = element.request_layout(window, cx);
+ (layout_id, element)
+ })
}
fn prepaint(
@@ -239,7 +242,9 @@ impl<C: RenderOnce> Element for Component<C> {
window: &mut Window,
cx: &mut App,
) {
- element.prepaint(window, cx);
+ window.with_global_id(ElementId::Name(type_name::<C>().into()), |_, window| {
+ element.prepaint(window, cx);
+ })
}
fn paint(
@@ -252,7 +257,9 @@ impl<C: RenderOnce> Element for Component<C> {
window: &mut Window,
cx: &mut App,
) {
- element.paint(window, cx);
+ window.with_global_id(ElementId::Name(type_name::<C>().into()), |_, window| {
+ element.paint(window, cx);
+ })
}
}
@@ -596,10 +603,8 @@ impl AnyElement {
self.0.prepaint(window, cx);
- if !focus_assigned {
- if let Some(focus_id) = window.next_frame.focus {
- return FocusHandle::for_id(focus_id, &cx.focus_handles);
- }
+ if !focus_assigned && let Some(focus_id) = window.next_frame.focus {
+ return FocusHandle::for_id(focus_id, &cx.focus_handles);
}
None
@@ -1,4 +1,7 @@
-use std::time::{Duration, Instant};
+use std::{
+ rc::Rc,
+ time::{Duration, Instant},
+};
use crate::{
AnyElement, App, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, Window,
@@ -8,6 +11,7 @@ pub use easing::*;
use smallvec::SmallVec;
/// An animation that can be applied to an element.
+#[derive(Clone)]
pub struct Animation {
/// The amount of time for which this animation should run
pub duration: Duration,
@@ -15,7 +19,7 @@ pub struct Animation {
pub oneshot: bool,
/// A function that takes a delta between 0 and 1 and returns a new delta
/// between 0 and 1 based on the given easing function.
- pub easing: Box<dyn Fn(f32) -> f32>,
+ pub easing: Rc<dyn Fn(f32) -> f32>,
}
impl Animation {
@@ -25,7 +29,7 @@ impl Animation {
Self {
duration,
oneshot: true,
- easing: Box::new(linear),
+ easing: Rc::new(linear),
}
}
@@ -39,7 +43,7 @@ impl Animation {
/// The easing function will take a time delta between 0 and 1 and return a new delta
/// between 0 and 1
pub fn with_easing(mut self, easing: impl Fn(f32) -> f32 + 'static) -> Self {
- self.easing = Box::new(easing);
+ self.easing = Rc::new(easing);
self
}
}
@@ -19,14 +19,15 @@ use crate::{
Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, DispatchPhase,
Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior,
HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent,
- LayoutId, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
- Overflow, ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style,
- StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px,
- size,
+ KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton,
+ MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Overflow, ParentElement, Pixels,
+ Point, Render, ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task,
+ TooltipId, Visibility, Window, WindowControlArea, point, px, size,
};
use collections::HashMap;
use refineable::Refineable;
use smallvec::SmallVec;
+use stacksafe::{StackSafe, stacksafe};
use std::{
any::{Any, TypeId},
cell::RefCell,
@@ -285,21 +286,20 @@ impl Interactivity {
{
self.mouse_move_listeners
.push(Box::new(move |event, phase, hitbox, window, cx| {
- if phase == DispatchPhase::Capture {
- if let Some(drag) = &cx.active_drag {
- if drag.value.as_ref().type_id() == TypeId::of::<T>() {
- (listener)(
- &DragMoveEvent {
- event: event.clone(),
- bounds: hitbox.bounds,
- drag: PhantomData,
- dragged_item: Arc::clone(&drag.value),
- },
- window,
- cx,
- );
- }
- }
+ if phase == DispatchPhase::Capture
+ && let Some(drag) = &cx.active_drag
+ && drag.value.as_ref().type_id() == TypeId::of::<T>()
+ {
+ (listener)(
+ &DragMoveEvent {
+ event: event.clone(),
+ bounds: hitbox.bounds,
+ drag: PhantomData,
+ dragged_item: Arc::clone(&drag.value),
+ },
+ window,
+ cx,
+ );
}
}));
}
@@ -484,10 +484,9 @@ impl Interactivity {
where
Self: Sized,
{
- self.click_listeners
- .push(Box::new(move |event, window, cx| {
- listener(event, window, cx)
- }));
+ self.click_listeners.push(Rc::new(move |event, window, cx| {
+ listener(event, window, cx)
+ }));
}
/// On drag initiation, this callback will be used to create a new view to render the dragged value for a
@@ -619,6 +618,13 @@ pub trait InteractiveElement: Sized {
self
}
+ /// Set index of the tab stop order.
+ fn tab_index(mut self, index: isize) -> Self {
+ self.interactivity().focusable = true;
+ self.interactivity().tab_index = Some(index);
+ self
+ }
+
/// Set the keymap context for this element. This will be used to determine
/// which action to dispatch from the keymap.
fn key_context<C, E>(mut self, key_context: C) -> Self
@@ -1149,7 +1155,7 @@ pub(crate) type MouseMoveListener =
pub(crate) type ScrollWheelListener =
Box<dyn Fn(&ScrollWheelEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
-pub(crate) type ClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
+pub(crate) type ClickListener = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
pub(crate) type DragListener =
Box<dyn Fn(&dyn Any, Point<Pixels>, &mut Window, &mut App) -> AnyView + 'static>;
@@ -1189,7 +1195,7 @@ pub fn div() -> Div {
/// A [`Div`] element, the all-in-one element for building complex UIs in GPUI
pub struct Div {
interactivity: Interactivity,
- children: SmallVec<[AnyElement; 2]>,
+ children: SmallVec<[StackSafe<AnyElement>; 2]>,
prepaint_listener: Option<Box<dyn Fn(Vec<Bounds<Pixels>>, &mut Window, &mut App) + 'static>>,
image_cache: Option<Box<dyn ImageCacheProvider>>,
}
@@ -1250,7 +1256,8 @@ impl InteractiveElement for Div {
impl ParentElement for Div {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
- self.children.extend(elements)
+ self.children
+ .extend(elements.into_iter().map(StackSafe::new))
}
}
@@ -1266,6 +1273,7 @@ impl Element for Div {
self.interactivity.source_location()
}
+ #[stacksafe]
fn request_layout(
&mut self,
global_id: Option<&GlobalElementId>,
@@ -1301,6 +1309,7 @@ impl Element for Div {
(layout_id, DivFrameState { child_layout_ids })
}
+ #[stacksafe]
fn prepaint(
&mut self,
global_id: Option<&GlobalElementId>,
@@ -1327,7 +1336,6 @@ impl Element for Div {
} else if let Some(scroll_handle) = self.interactivity.tracked_scroll_handle.as_ref() {
let mut state = scroll_handle.0.borrow_mut();
state.child_bounds = Vec::with_capacity(request_layout.child_layout_ids.len());
- state.bounds = bounds;
for child_layout_id in &request_layout.child_layout_ids {
let child_bounds = window.layout_bounds(*child_layout_id);
child_min = child_min.min(&child_bounds.origin);
@@ -1371,6 +1379,7 @@ impl Element for Div {
)
}
+ #[stacksafe]
fn paint(
&mut self,
global_id: Option<&GlobalElementId>,
@@ -1462,6 +1471,7 @@ pub struct Interactivity {
pub(crate) tooltip_builder: Option<TooltipBuilder>,
pub(crate) window_control: Option<WindowControlArea>,
pub(crate) hitbox_behavior: HitboxBehavior,
+ pub(crate) tab_index: Option<isize>,
#[cfg(any(feature = "inspector", debug_assertions))]
pub(crate) source_location: Option<&'static core::panic::Location<'static>>,
@@ -1503,15 +1513,14 @@ impl Interactivity {
let mut element_state =
element_state.map(|element_state| element_state.unwrap_or_default());
- if let Some(element_state) = element_state.as_ref() {
- if cx.has_active_drag() {
- if let Some(pending_mouse_down) = element_state.pending_mouse_down.as_ref()
- {
- *pending_mouse_down.borrow_mut() = None;
- }
- if let Some(clicked_state) = element_state.clicked_state.as_ref() {
- *clicked_state.borrow_mut() = ElementClickedState::default();
- }
+ if let Some(element_state) = element_state.as_ref()
+ && cx.has_active_drag()
+ {
+ if let Some(pending_mouse_down) = element_state.pending_mouse_down.as_ref() {
+ *pending_mouse_down.borrow_mut() = None;
+ }
+ if let Some(clicked_state) = element_state.clicked_state.as_ref() {
+ *clicked_state.borrow_mut() = ElementClickedState::default();
}
}
@@ -1519,30 +1528,35 @@ impl Interactivity {
// If there's an explicit focus handle we're tracking, use that. Otherwise
// create a new handle and store it in the element state, which lives for as
// as frames contain an element with this id.
- if self.focusable && self.tracked_focus_handle.is_none() {
- if let Some(element_state) = element_state.as_mut() {
- self.tracked_focus_handle = Some(
- element_state
- .focus_handle
- .get_or_insert_with(|| cx.focus_handle())
- .clone(),
- );
+ if self.focusable
+ && self.tracked_focus_handle.is_none()
+ && let Some(element_state) = element_state.as_mut()
+ {
+ let mut handle = element_state
+ .focus_handle
+ .get_or_insert_with(|| cx.focus_handle())
+ .clone()
+ .tab_stop(false);
+
+ if let Some(index) = self.tab_index {
+ handle = handle.tab_index(index).tab_stop(true);
}
+
+ self.tracked_focus_handle = Some(handle);
}
if let Some(scroll_handle) = self.tracked_scroll_handle.as_ref() {
self.scroll_offset = Some(scroll_handle.0.borrow().offset.clone());
- } else if self.base_style.overflow.x == Some(Overflow::Scroll)
- || self.base_style.overflow.y == Some(Overflow::Scroll)
+ } else if (self.base_style.overflow.x == Some(Overflow::Scroll)
+ || self.base_style.overflow.y == Some(Overflow::Scroll))
+ && let Some(element_state) = element_state.as_mut()
{
- if let Some(element_state) = element_state.as_mut() {
- self.scroll_offset = Some(
- element_state
- .scroll_offset
- .get_or_insert_with(Rc::default)
- .clone(),
- );
- }
+ self.scroll_offset = Some(
+ element_state
+ .scroll_offset
+ .get_or_insert_with(Rc::default)
+ .clone(),
+ );
}
let style = self.compute_style_internal(None, element_state.as_mut(), window, cx);
@@ -1651,6 +1665,11 @@ impl Interactivity {
window: &mut Window,
_cx: &mut App,
) -> Point<Pixels> {
+ fn round_to_two_decimals(pixels: Pixels) -> Pixels {
+ const ROUNDING_FACTOR: f32 = 100.0;
+ (pixels * ROUNDING_FACTOR).round() / ROUNDING_FACTOR
+ }
+
if let Some(scroll_offset) = self.scroll_offset.as_ref() {
let mut scroll_to_bottom = false;
let mut tracked_scroll_handle = self
@@ -1665,8 +1684,16 @@ impl Interactivity {
let rem_size = window.rem_size();
let padding = style.padding.to_pixels(bounds.size.into(), rem_size);
let padding_size = size(padding.left + padding.right, padding.top + padding.bottom);
+ // The floating point values produced by Taffy and ours often vary
+ // slightly after ~5 decimal places. This can lead to cases where after
+ // subtracting these, the container becomes scrollable for less than
+ // 0.00000x pixels. As we generally don't benefit from a precision that
+ // high for the maximum scroll, we round the scroll max to 2 decimal
+ // places here.
let padded_content_size = self.content_size + padding_size;
- let scroll_max = (padded_content_size - bounds.size).max(&Size::default());
+ let scroll_max = (padded_content_size - bounds.size)
+ .map(round_to_two_decimals)
+ .max(&Default::default());
// Clamp scroll offset in case scroll max is smaller now (e.g., if children
// were removed or the bounds became larger).
let mut scroll_offset = scroll_offset.borrow_mut();
@@ -1679,7 +1706,8 @@ impl Interactivity {
}
if let Some(mut scroll_handle_state) = tracked_scroll_handle {
- scroll_handle_state.padded_content_size = padded_content_size;
+ scroll_handle_state.max_offset = scroll_max;
+ scroll_handle_state.bounds = bounds;
}
*scroll_offset
@@ -1729,6 +1757,10 @@ impl Interactivity {
return ((), element_state);
}
+ if let Some(focus_handle) = &self.tracked_focus_handle {
+ window.next_frame.tab_handles.insert(focus_handle);
+ }
+
window.with_element_opacity(style.opacity, |window| {
style.paint(bounds, window, cx, |window: &mut Window, cx: &mut App| {
window.with_text_style(style.text_style().cloned(), |window| {
@@ -1920,6 +1952,12 @@ impl Interactivity {
window: &mut Window,
cx: &mut App,
) {
+ let is_focused = self
+ .tracked_focus_handle
+ .as_ref()
+ .map(|handle| handle.is_focused(window))
+ .unwrap_or(false);
+
// If this element can be focused, register a mouse down listener
// that will automatically transfer focus when hitting the element.
// This behavior can be suppressed by using `cx.prevent_default()`.
@@ -1991,26 +2029,27 @@ impl Interactivity {
let hitbox = hitbox.clone();
window.on_mouse_event({
move |_: &MouseUpEvent, phase, window, cx| {
- if let Some(drag) = &cx.active_drag {
- if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) {
- let drag_state_type = drag.value.as_ref().type_id();
- for (drop_state_type, listener) in &drop_listeners {
- if *drop_state_type == drag_state_type {
- let drag = cx
- .active_drag
- .take()
- .expect("checked for type drag state type above");
-
- let mut can_drop = true;
- if let Some(predicate) = &can_drop_predicate {
- can_drop = predicate(drag.value.as_ref(), window, cx);
- }
+ if let Some(drag) = &cx.active_drag
+ && phase == DispatchPhase::Bubble
+ && hitbox.is_hovered(window)
+ {
+ let drag_state_type = drag.value.as_ref().type_id();
+ for (drop_state_type, listener) in &drop_listeners {
+ if *drop_state_type == drag_state_type {
+ let drag = cx
+ .active_drag
+ .take()
+ .expect("checked for type drag state type above");
+
+ let mut can_drop = true;
+ if let Some(predicate) = &can_drop_predicate {
+ can_drop = predicate(drag.value.as_ref(), window, cx);
+ }
- if can_drop {
- listener(drag.value.as_ref(), window, cx);
- window.refresh();
- cx.stop_propagation();
- }
+ if can_drop {
+ listener(drag.value.as_ref(), window, cx);
+ window.refresh();
+ cx.stop_propagation();
}
}
}
@@ -2054,34 +2093,60 @@ impl Interactivity {
}
let mut pending_mouse_down = pending_mouse_down.borrow_mut();
- if let Some(mouse_down) = pending_mouse_down.clone() {
- if !cx.has_active_drag()
- && (event.position - mouse_down.position).magnitude()
- > DRAG_THRESHOLD
- {
- if let Some((drag_value, drag_listener)) = drag_listener.take() {
- *clicked_state.borrow_mut() = ElementClickedState::default();
- let cursor_offset = event.position - hitbox.origin;
- let drag = (drag_listener)(
- drag_value.as_ref(),
- cursor_offset,
- window,
- cx,
- );
- cx.active_drag = Some(AnyDrag {
- view: drag,
- value: drag_value,
- cursor_offset,
- cursor_style: drag_cursor_style,
+ if let Some(mouse_down) = pending_mouse_down.clone()
+ && !cx.has_active_drag()
+ && (event.position - mouse_down.position).magnitude() > DRAG_THRESHOLD
+ && let Some((drag_value, drag_listener)) = drag_listener.take()
+ {
+ *clicked_state.borrow_mut() = ElementClickedState::default();
+ let cursor_offset = event.position - hitbox.origin;
+ let drag =
+ (drag_listener)(drag_value.as_ref(), cursor_offset, window, cx);
+ cx.active_drag = Some(AnyDrag {
+ view: drag,
+ value: drag_value,
+ cursor_offset,
+ cursor_style: drag_cursor_style,
+ });
+ pending_mouse_down.take();
+ window.refresh();
+ cx.stop_propagation();
+ }
+ }
+ });
+
+ if is_focused {
+ // Press enter, space to trigger click, when the element is focused.
+ window.on_key_event({
+ let click_listeners = click_listeners.clone();
+ let hitbox = hitbox.clone();
+ move |event: &KeyUpEvent, phase, window, cx| {
+ if phase.bubble() && !window.default_prevented() {
+ let stroke = &event.keystroke;
+ let keyboard_button = if stroke.key.eq("enter") {
+ Some(KeyboardButton::Enter)
+ } else if stroke.key.eq("space") {
+ Some(KeyboardButton::Space)
+ } else {
+ None
+ };
+
+ if let Some(button) = keyboard_button
+ && !stroke.modifiers.modified()
+ {
+ let click_event = ClickEvent::Keyboard(KeyboardClickEvent {
+ button,
+ bounds: hitbox.bounds,
});
- pending_mouse_down.take();
- window.refresh();
- cx.stop_propagation();
+
+ for listener in &click_listeners {
+ listener(&click_event, window, cx);
+ }
}
}
}
- }
- });
+ });
+ }
window.on_mouse_event({
let mut captured_mouse_down = None;
@@ -2108,10 +2173,10 @@ impl Interactivity {
// Fire click handlers during the bubble phase.
DispatchPhase::Bubble => {
if let Some(mouse_down) = captured_mouse_down.take() {
- let mouse_click = ClickEvent {
+ let mouse_click = ClickEvent::Mouse(MouseClickEvent {
down: mouse_down,
up: event.clone(),
- };
+ });
for listener in &click_listeners {
listener(&mouse_click, window, cx);
}
@@ -2209,7 +2274,7 @@ impl Interactivity {
window.on_mouse_event(move |_: &MouseDownEvent, phase, window, _cx| {
if phase == DispatchPhase::Bubble && !window.default_prevented() {
let group_hovered = active_group_hitbox
- .map_or(false, |group_hitbox_id| group_hitbox_id.is_hovered(window));
+ .is_some_and(|group_hitbox_id| group_hitbox_id.is_hovered(window));
let element_hovered = hitbox.is_hovered(window);
if group_hovered || element_hovered {
*active_state.borrow_mut() = ElementClickedState {
@@ -2355,33 +2420,32 @@ impl Interactivity {
style.refine(&self.base_style);
if let Some(focus_handle) = self.tracked_focus_handle.as_ref() {
- if let Some(in_focus_style) = self.in_focus_style.as_ref() {
- if focus_handle.within_focused(window, cx) {
- style.refine(in_focus_style);
- }
+ if let Some(in_focus_style) = self.in_focus_style.as_ref()
+ && focus_handle.within_focused(window, cx)
+ {
+ style.refine(in_focus_style);
}
- if let Some(focus_style) = self.focus_style.as_ref() {
- if focus_handle.is_focused(window) {
- style.refine(focus_style);
- }
+ if let Some(focus_style) = self.focus_style.as_ref()
+ && focus_handle.is_focused(window)
+ {
+ style.refine(focus_style);
}
}
if let Some(hitbox) = hitbox {
if !cx.has_active_drag() {
- if let Some(group_hover) = self.group_hover_style.as_ref() {
- if let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) {
- if group_hitbox_id.is_hovered(window) {
- style.refine(&group_hover.style);
- }
- }
+ if let Some(group_hover) = self.group_hover_style.as_ref()
+ && let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx)
+ && group_hitbox_id.is_hovered(window)
+ {
+ style.refine(&group_hover.style);
}
- if let Some(hover_style) = self.hover_style.as_ref() {
- if hitbox.is_hovered(window) {
- style.refine(hover_style);
- }
+ if let Some(hover_style) = self.hover_style.as_ref()
+ && hitbox.is_hovered(window)
+ {
+ style.refine(hover_style);
}
}
@@ -2395,12 +2459,10 @@ impl Interactivity {
for (state_type, group_drag_style) in &self.group_drag_over_styles {
if let Some(group_hitbox_id) =
GroupHitboxes::get(&group_drag_style.group, cx)
+ && *state_type == drag.value.as_ref().type_id()
+ && group_hitbox_id.is_hovered(window)
{
- if *state_type == drag.value.as_ref().type_id()
- && group_hitbox_id.is_hovered(window)
- {
- style.refine(&group_drag_style.style);
- }
+ style.refine(&group_drag_style.style);
}
}
@@ -2422,16 +2484,16 @@ impl Interactivity {
.clicked_state
.get_or_insert_with(Default::default)
.borrow();
- if clicked_state.group {
- if let Some(group) = self.group_active_style.as_ref() {
- style.refine(&group.style)
- }
+ if clicked_state.group
+ && let Some(group) = self.group_active_style.as_ref()
+ {
+ style.refine(&group.style)
}
- if let Some(active_style) = self.active_style.as_ref() {
- if clicked_state.element {
- style.refine(active_style)
- }
+ if let Some(active_style) = self.active_style.as_ref()
+ && clicked_state.element
+ {
+ style.refine(active_style)
}
}
@@ -2552,7 +2614,7 @@ pub(crate) fn register_tooltip_mouse_handlers(
window.on_mouse_event({
let active_tooltip = active_tooltip.clone();
move |_: &MouseDownEvent, _phase, window: &mut Window, _cx| {
- if !tooltip_id.map_or(false, |tooltip_id| tooltip_id.is_hovered(window)) {
+ if !tooltip_id.is_some_and(|tooltip_id| tooltip_id.is_hovered(window)) {
clear_active_tooltip_if_not_hoverable(&active_tooltip, window);
}
}
@@ -2561,7 +2623,7 @@ pub(crate) fn register_tooltip_mouse_handlers(
window.on_mouse_event({
let active_tooltip = active_tooltip.clone();
move |_: &ScrollWheelEvent, _phase, window: &mut Window, _cx| {
- if !tooltip_id.map_or(false, |tooltip_id| tooltip_id.is_hovered(window)) {
+ if !tooltip_id.is_some_and(|tooltip_id| tooltip_id.is_hovered(window)) {
clear_active_tooltip_if_not_hoverable(&active_tooltip, window);
}
}
@@ -2717,7 +2779,7 @@ fn handle_tooltip_check_visible_and_update(
match action {
Action::None => {}
- Action::Hide => clear_active_tooltip(&active_tooltip, window),
+ Action::Hide => clear_active_tooltip(active_tooltip, window),
Action::ScheduleHide(tooltip) => {
let delayed_hide_task = window.spawn(cx, {
let active_tooltip = active_tooltip.clone();
@@ -2919,7 +2981,7 @@ impl ScrollAnchor {
struct ScrollHandleState {
offset: Rc<RefCell<Point<Pixels>>>,
bounds: Bounds<Pixels>,
- padded_content_size: Size<Pixels>,
+ max_offset: Size<Pixels>,
child_bounds: Vec<Bounds<Pixels>>,
scroll_to_bottom: bool,
overflow: Point<Overflow>,
@@ -2948,6 +3010,11 @@ impl ScrollHandle {
*self.0.borrow().offset.borrow()
}
+ /// Get the maximum scroll offset.
+ pub fn max_offset(&self) -> Size<Pixels> {
+ self.0.borrow().max_offset
+ }
+
/// Get the top child that's scrolled into view.
pub fn top_item(&self) -> usize {
let state = self.0.borrow();
@@ -2972,21 +3039,11 @@ impl ScrollHandle {
self.0.borrow().bounds
}
- /// Set the bounds into which this child is painted
- pub(super) fn set_bounds(&self, bounds: Bounds<Pixels>) {
- self.0.borrow_mut().bounds = bounds;
- }
-
/// Get the bounds for a specific child.
pub fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
self.0.borrow().child_bounds.get(ix).cloned()
}
- /// Get the size of the content with padding of the container.
- pub fn padded_content_size(&self) -> Size<Pixels> {
- self.0.borrow().padded_content_size
- }
-
/// scroll_to_item scrolls the minimal amount to ensure that the child is
/// fully visible
pub fn scroll_to_item(&self, ix: usize) {
@@ -64,7 +64,7 @@ mod any_image_cache {
cx: &mut App,
) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
let image_cache = image_cache.clone().downcast::<I>().unwrap();
- return image_cache.update(cx, |image_cache, cx| image_cache.load(resource, window, cx));
+ image_cache.update(cx, |image_cache, cx| image_cache.load(resource, window, cx))
}
}
@@ -297,10 +297,10 @@ impl RetainAllImageCache {
/// Remove the image from the cache by the given source.
pub fn remove(&mut self, source: &Resource, window: &mut Window, cx: &mut App) {
let hash = hash(source);
- if let Some(mut item) = self.0.remove(&hash) {
- if let Some(Ok(image)) = item.get() {
- cx.drop_image(image, Some(window));
- }
+ if let Some(mut item) = self.0.remove(&hash)
+ && let Some(Ok(image)) = item.get()
+ {
+ cx.drop_image(image, Some(window));
}
}
@@ -379,13 +379,12 @@ impl Element for Img {
None => {
if let Some(state) = &mut state {
if let Some((started_loading, _)) = state.started_loading {
- if started_loading.elapsed() > LOADING_DELAY {
- if let Some(loading) = self.style.loading.as_ref() {
- let mut element = loading();
- replacement_id =
- Some(element.request_layout(window, cx));
- layout_state.replacement = Some(element);
- }
+ if started_loading.elapsed() > LOADING_DELAY
+ && let Some(loading) = self.style.loading.as_ref()
+ {
+ let mut element = loading();
+ replacement_id = Some(element.request_layout(window, cx));
+ layout_state.replacement = Some(element);
}
} else {
let current_view = window.current_view();
@@ -476,7 +475,7 @@ impl Element for Img {
.paint_image(
new_bounds,
corner_radii,
- data.clone(),
+ data,
layout_state.frame_index,
self.style.grayscale,
)
@@ -16,12 +16,18 @@ use crate::{
use collections::VecDeque;
use refineable::Refineable as _;
use std::{cell::RefCell, ops::Range, rc::Rc};
-use sum_tree::{Bias, SumTree};
+use sum_tree::{Bias, Dimensions, SumTree};
+
+type RenderItemFn = dyn FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static;
/// Construct a new list element
-pub fn list(state: ListState) -> List {
+pub fn list(
+ state: ListState,
+ render_item: impl FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static,
+) -> List {
List {
state,
+ render_item: Box::new(render_item),
style: StyleRefinement::default(),
sizing_behavior: ListSizingBehavior::default(),
}
@@ -30,6 +36,7 @@ pub fn list(state: ListState) -> List {
/// A list element
pub struct List {
state: ListState,
+ render_item: Box<RenderItemFn>,
style: StyleRefinement,
sizing_behavior: ListSizingBehavior,
}
@@ -55,7 +62,6 @@ impl std::fmt::Debug for ListState {
struct StateInner {
last_layout_bounds: Option<Bounds<Pixels>>,
last_padding: Option<Edges<Pixels>>,
- render_item: Box<dyn FnMut(usize, &mut Window, &mut App) -> AnyElement>,
items: SumTree<ListItem>,
logical_scroll_top: Option<ListOffset>,
alignment: ListAlignment,
@@ -186,19 +192,10 @@ impl ListState {
/// above and below the visible area. Elements within this area will
/// be measured even though they are not visible. This can help ensure
/// that the list doesn't flicker or pop in when scrolling.
- pub fn new<R>(
- item_count: usize,
- alignment: ListAlignment,
- overdraw: Pixels,
- render_item: R,
- ) -> Self
- where
- R: 'static + FnMut(usize, &mut Window, &mut App) -> AnyElement,
- {
+ pub fn new(item_count: usize, alignment: ListAlignment, overdraw: Pixels) -> Self {
let this = Self(Rc::new(RefCell::new(StateInner {
last_layout_bounds: None,
last_padding: None,
- render_item: Box::new(render_item),
items: SumTree::default(),
logical_scroll_top: None,
alignment,
@@ -249,8 +246,8 @@ impl ListState {
let state = &mut *self.0.borrow_mut();
let mut old_items = state.items.cursor::<Count>(&());
- let mut new_items = old_items.slice(&Count(old_range.start), Bias::Right, &());
- old_items.seek_forward(&Count(old_range.end), Bias::Right, &());
+ let mut new_items = old_items.slice(&Count(old_range.start), Bias::Right);
+ old_items.seek_forward(&Count(old_range.end), Bias::Right);
let mut spliced_count = 0;
new_items.extend(
@@ -260,7 +257,7 @@ impl ListState {
}),
&(),
);
- new_items.append(old_items.suffix(&()), &());
+ new_items.append(old_items.suffix(), &());
drop(old_items);
state.items = new_items;
@@ -300,14 +297,14 @@ impl ListState {
let current_offset = self.logical_scroll_top();
let state = &mut *self.0.borrow_mut();
let mut cursor = state.items.cursor::<ListItemSummary>(&());
- cursor.seek(&Count(current_offset.item_ix), Bias::Right, &());
+ cursor.seek(&Count(current_offset.item_ix), Bias::Right);
let start_pixel_offset = cursor.start().height + current_offset.offset_in_item;
let new_pixel_offset = (start_pixel_offset + distance).max(px(0.));
if new_pixel_offset > start_pixel_offset {
- cursor.seek_forward(&Height(new_pixel_offset), Bias::Right, &());
+ cursor.seek_forward(&Height(new_pixel_offset), Bias::Right);
} else {
- cursor.seek(&Height(new_pixel_offset), Bias::Right, &());
+ cursor.seek(&Height(new_pixel_offset), Bias::Right);
}
state.logical_scroll_top = Some(ListOffset {
@@ -343,11 +340,11 @@ impl ListState {
scroll_top.offset_in_item = px(0.);
} else {
let mut cursor = state.items.cursor::<ListItemSummary>(&());
- cursor.seek(&Count(ix + 1), Bias::Right, &());
+ cursor.seek(&Count(ix + 1), Bias::Right);
let bottom = cursor.start().height + padding.top;
let goal_top = px(0.).max(bottom - height + padding.bottom);
- cursor.seek(&Height(goal_top), Bias::Left, &());
+ cursor.seek(&Height(goal_top), Bias::Left);
let start_ix = cursor.start().count;
let start_item_top = cursor.start().height;
@@ -371,14 +368,14 @@ impl ListState {
return None;
}
- let mut cursor = state.items.cursor::<(Count, Height)>(&());
- cursor.seek(&Count(scroll_top.item_ix), Bias::Right, &());
+ let mut cursor = state.items.cursor::<Dimensions<Count, Height>>(&());
+ cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
let scroll_top = cursor.start().1.0 + scroll_top.offset_in_item;
- cursor.seek_forward(&Count(ix), Bias::Right, &());
+ cursor.seek_forward(&Count(ix), Bias::Right);
if let Some(&ListItem::Measured { size, .. }) = cursor.item() {
- let &(Count(count), Height(top)) = cursor.start();
+ let &Dimensions(Count(count), Height(top), _) = cursor.start();
if count == ix {
let top = bounds.top() + top - scroll_top;
return Some(Bounds::from_corners(
@@ -411,9 +408,9 @@ impl ListState {
self.0.borrow_mut().set_offset_from_scrollbar(point);
}
- /// Returns the size of items we have measured.
+ /// Returns the maximum scroll offset according to the items we have measured.
/// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly.
- pub fn content_size_for_scrollbar(&self) -> Size<Pixels> {
+ pub fn max_offset_for_scrollbar(&self) -> Size<Pixels> {
let state = self.0.borrow();
let bounds = state.last_layout_bounds.unwrap_or_default();
@@ -421,7 +418,7 @@ impl ListState {
.scrollbar_drag_start_height
.unwrap_or_else(|| state.items.summary().height);
- Size::new(bounds.size.width, height)
+ Size::new(Pixels::ZERO, Pixels::ZERO.max(height - bounds.size.height))
}
/// Returns the current scroll offset adjusted for the scrollbar
@@ -431,7 +428,7 @@ impl ListState {
let mut cursor = state.items.cursor::<ListItemSummary>(&());
let summary: ListItemSummary =
- cursor.summary(&Count(logical_scroll_top.item_ix), Bias::Right, &());
+ cursor.summary(&Count(logical_scroll_top.item_ix), Bias::Right);
let content_height = state.items.summary().height;
let drag_offset =
// if dragging the scrollbar, we want to offset the point if the height changed
@@ -450,9 +447,9 @@ impl ListState {
impl StateInner {
fn visible_range(&self, height: Pixels, scroll_top: &ListOffset) -> Range<usize> {
let mut cursor = self.items.cursor::<ListItemSummary>(&());
- cursor.seek(&Count(scroll_top.item_ix), Bias::Right, &());
+ cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
let start_y = cursor.start().height + scroll_top.offset_in_item;
- cursor.seek_forward(&Height(start_y + height), Bias::Left, &());
+ cursor.seek_forward(&Height(start_y + height), Bias::Left);
scroll_top.item_ix..cursor.start().count + 1
}
@@ -482,7 +479,7 @@ impl StateInner {
self.logical_scroll_top = None;
} else {
let mut cursor = self.items.cursor::<ListItemSummary>(&());
- cursor.seek(&Height(new_scroll_top), Bias::Right, &());
+ cursor.seek(&Height(new_scroll_top), Bias::Right);
let item_ix = cursor.start().count;
let offset_in_item = new_scroll_top - cursor.start().height;
self.logical_scroll_top = Some(ListOffset {
@@ -523,7 +520,7 @@ impl StateInner {
fn scroll_top(&self, logical_scroll_top: &ListOffset) -> Pixels {
let mut cursor = self.items.cursor::<ListItemSummary>(&());
- cursor.seek(&Count(logical_scroll_top.item_ix), Bias::Right, &());
+ cursor.seek(&Count(logical_scroll_top.item_ix), Bias::Right);
cursor.start().height + logical_scroll_top.offset_in_item
}
@@ -532,6 +529,7 @@ impl StateInner {
available_width: Option<Pixels>,
available_height: Pixels,
padding: &Edges<Pixels>,
+ render_item: &mut RenderItemFn,
window: &mut Window,
cx: &mut App,
) -> LayoutItemsResponse {
@@ -553,7 +551,7 @@ impl StateInner {
let mut cursor = old_items.cursor::<Count>(&());
// Render items after the scroll top, including those in the trailing overdraw
- cursor.seek(&Count(scroll_top.item_ix), Bias::Right, &());
+ cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
for (ix, item) in cursor.by_ref().enumerate() {
let visible_height = rendered_height - scroll_top.offset_in_item;
if visible_height >= available_height + self.overdraw {
@@ -566,7 +564,7 @@ impl StateInner {
// If we're within the visible area or the height wasn't cached, render and measure the item's element
if visible_height < available_height || size.is_none() {
let item_index = scroll_top.item_ix + ix;
- let mut element = (self.render_item)(item_index, window, cx);
+ let mut element = render_item(item_index, window, cx);
let element_size = element.layout_as_root(available_item_space, window, cx);
size = Some(element_size);
if visible_height < available_height {
@@ -592,16 +590,16 @@ impl StateInner {
rendered_height += padding.bottom;
// Prepare to start walking upward from the item at the scroll top.
- cursor.seek(&Count(scroll_top.item_ix), Bias::Right, &());
+ cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
// If the rendered items do not fill the visible region, then adjust
// the scroll top upward.
if rendered_height - scroll_top.offset_in_item < available_height {
while rendered_height < available_height {
- cursor.prev(&());
+ cursor.prev();
if let Some(item) = cursor.item() {
let item_index = cursor.start().0;
- let mut element = (self.render_item)(item_index, window, cx);
+ let mut element = render_item(item_index, window, cx);
let element_size = element.layout_as_root(available_item_space, window, cx);
let focus_handle = item.focus_handle();
rendered_height += element_size.height;
@@ -645,12 +643,12 @@ impl StateInner {
// Measure items in the leading overdraw
let mut leading_overdraw = scroll_top.offset_in_item;
while leading_overdraw < self.overdraw {
- cursor.prev(&());
+ cursor.prev();
if let Some(item) = cursor.item() {
let size = if let ListItem::Measured { size, .. } = item {
*size
} else {
- let mut element = (self.render_item)(cursor.start().0, window, cx);
+ let mut element = render_item(cursor.start().0, window, cx);
element.layout_as_root(available_item_space, window, cx)
};
@@ -666,10 +664,10 @@ impl StateInner {
let measured_range = cursor.start().0..(cursor.start().0 + measured_items.len());
let mut cursor = old_items.cursor::<Count>(&());
- let mut new_items = cursor.slice(&Count(measured_range.start), Bias::Right, &());
+ let mut new_items = cursor.slice(&Count(measured_range.start), Bias::Right);
new_items.extend(measured_items, &());
- cursor.seek(&Count(measured_range.end), Bias::Right, &());
- new_items.append(cursor.suffix(&()), &());
+ cursor.seek(&Count(measured_range.end), Bias::Right);
+ new_items.append(cursor.suffix(), &());
self.items = new_items;
// If none of the visible items are focused, check if an off-screen item is focused
@@ -679,11 +677,11 @@ impl StateInner {
let mut cursor = self
.items
.filter::<_, Count>(&(), |summary| summary.has_focus_handles);
- cursor.next(&());
+ cursor.next();
while let Some(item) = cursor.item() {
if item.contains_focused(window, cx) {
let item_index = cursor.start().0;
- let mut element = (self.render_item)(cursor.start().0, window, cx);
+ let mut element = render_item(cursor.start().0, window, cx);
let size = element.layout_as_root(available_item_space, window, cx);
item_layouts.push_back(ItemLayout {
index: item_index,
@@ -692,7 +690,7 @@ impl StateInner {
});
break;
}
- cursor.next(&());
+ cursor.next();
}
}
@@ -708,6 +706,7 @@ impl StateInner {
bounds: Bounds<Pixels>,
padding: Edges<Pixels>,
autoscroll: bool,
+ render_item: &mut RenderItemFn,
window: &mut Window,
cx: &mut App,
) -> Result<LayoutItemsResponse, ListOffset> {
@@ -716,6 +715,7 @@ impl StateInner {
Some(bounds.size.width),
bounds.size.height,
&padding,
+ render_item,
window,
cx,
);
@@ -732,47 +732,44 @@ impl StateInner {
item.element.prepaint_at(item_origin, window, cx);
});
- if let Some(autoscroll_bounds) = window.take_autoscroll() {
- if autoscroll {
- if autoscroll_bounds.top() < bounds.top() {
- return Err(ListOffset {
- item_ix: item.index,
- offset_in_item: autoscroll_bounds.top() - item_origin.y,
- });
- } else if autoscroll_bounds.bottom() > bounds.bottom() {
- let mut cursor = self.items.cursor::<Count>(&());
- cursor.seek(&Count(item.index), Bias::Right, &());
- let mut height = bounds.size.height - padding.top - padding.bottom;
-
- // Account for the height of the element down until the autoscroll bottom.
- height -= autoscroll_bounds.bottom() - item_origin.y;
-
- // Keep decreasing the scroll top until we fill all the available space.
- while height > Pixels::ZERO {
- cursor.prev(&());
- let Some(item) = cursor.item() else { break };
-
- let size = item.size().unwrap_or_else(|| {
- let mut item =
- (self.render_item)(cursor.start().0, window, cx);
- let item_available_size = size(
- bounds.size.width.into(),
- AvailableSpace::MinContent,
- );
- item.layout_as_root(item_available_size, window, cx)
- });
- height -= size.height;
- }
-
- return Err(ListOffset {
- item_ix: cursor.start().0,
- offset_in_item: if height < Pixels::ZERO {
- -height
- } else {
- Pixels::ZERO
- },
+ if let Some(autoscroll_bounds) = window.take_autoscroll()
+ && autoscroll
+ {
+ if autoscroll_bounds.top() < bounds.top() {
+ return Err(ListOffset {
+ item_ix: item.index,
+ offset_in_item: autoscroll_bounds.top() - item_origin.y,
+ });
+ } else if autoscroll_bounds.bottom() > bounds.bottom() {
+ let mut cursor = self.items.cursor::<Count>(&());
+ cursor.seek(&Count(item.index), Bias::Right);
+ let mut height = bounds.size.height - padding.top - padding.bottom;
+
+ // Account for the height of the element down until the autoscroll bottom.
+ height -= autoscroll_bounds.bottom() - item_origin.y;
+
+ // Keep decreasing the scroll top until we fill all the available space.
+ while height > Pixels::ZERO {
+ cursor.prev();
+ let Some(item) = cursor.item() else { break };
+
+ let size = item.size().unwrap_or_else(|| {
+ let mut item = render_item(cursor.start().0, window, cx);
+ let item_available_size =
+ size(bounds.size.width.into(), AvailableSpace::MinContent);
+ item.layout_as_root(item_available_size, window, cx)
});
+ height -= size.height;
}
+
+ return Err(ListOffset {
+ item_ix: cursor.start().0,
+ offset_in_item: if height < Pixels::ZERO {
+ -height
+ } else {
+ Pixels::ZERO
+ },
+ });
}
}
@@ -806,7 +803,7 @@ impl StateInner {
self.logical_scroll_top = None;
} else {
let mut cursor = self.items.cursor::<ListItemSummary>(&());
- cursor.seek(&Height(new_scroll_top), Bias::Right, &());
+ cursor.seek(&Height(new_scroll_top), Bias::Right);
let item_ix = cursor.start().count;
let offset_in_item = new_scroll_top - cursor.start().height;
@@ -876,8 +873,14 @@ impl Element for List {
window.rem_size(),
);
- let layout_response =
- state.layout_items(None, available_height, &padding, window, cx);
+ let layout_response = state.layout_items(
+ None,
+ available_height,
+ &padding,
+ &mut self.render_item,
+ window,
+ cx,
+ );
let max_element_width = layout_response.max_item_width;
let summary = state.items.summary();
@@ -935,9 +938,10 @@ impl Element for List {
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
// If the width of the list has changed, invalidate all cached item heights
- if state.last_layout_bounds.map_or(true, |last_bounds| {
- last_bounds.size.width != bounds.size.width
- }) {
+ if state
+ .last_layout_bounds
+ .is_none_or(|last_bounds| last_bounds.size.width != bounds.size.width)
+ {
let new_items = SumTree::from_iter(
state.items.iter().map(|item| ListItem::Unmeasured {
focus_handle: item.focus_handle(),
@@ -951,15 +955,16 @@ impl Element for List {
let padding = style
.padding
.to_pixels(bounds.size.into(), window.rem_size());
- let layout = match state.prepaint_items(bounds, padding, true, window, cx) {
- Ok(layout) => layout,
- Err(autoscroll_request) => {
- state.logical_scroll_top = Some(autoscroll_request);
- state
- .prepaint_items(bounds, padding, false, window, cx)
- .unwrap()
- }
- };
+ let layout =
+ match state.prepaint_items(bounds, padding, true, &mut self.render_item, window, cx) {
+ Ok(layout) => layout,
+ Err(autoscroll_request) => {
+ state.logical_scroll_top = Some(autoscroll_request);
+ state
+ .prepaint_items(bounds, padding, false, &mut self.render_item, window, cx)
+ .unwrap()
+ }
+ };
state.last_layout_bounds = Some(bounds);
state.last_padding = Some(padding);
@@ -1108,9 +1113,7 @@ mod test {
let cx = cx.add_empty_window();
- let state = ListState::new(5, crate::ListAlignment::Top, px(10.), |_, _, _| {
- div().h(px(10.)).w_full().into_any()
- });
+ let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
// Ensure that the list is scrolled to the top
state.scroll_to(gpui::ListOffset {
@@ -1121,7 +1124,11 @@ mod test {
struct TestView(ListState);
impl Render for TestView {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
- list(self.0.clone()).w_full().h_full()
+ list(self.0.clone(), |_, _, _| {
+ div().h(px(10.)).w_full().into_any()
+ })
+ .w_full()
+ .h_full()
}
}
@@ -1154,14 +1161,16 @@ mod test {
let cx = cx.add_empty_window();
- let state = ListState::new(5, crate::ListAlignment::Top, px(10.), |_, _, _| {
- div().h(px(20.)).w_full().into_any()
- });
+ let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
struct TestView(ListState);
impl Render for TestView {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
- list(self.0.clone()).w_full().h_full()
+ list(self.0.clone(), |_, _, _| {
+ div().h(px(20.)).w_full().into_any()
+ })
+ .w_full()
+ .h_full()
}
}
@@ -326,7 +326,7 @@ impl TextLayout {
vec![text_style.to_run(text.len())]
};
- let layout_id = window.request_measured_layout(Default::default(), {
+ window.request_measured_layout(Default::default(), {
let element_state = self.clone();
move |known_dimensions, available_space, window, cx| {
@@ -356,12 +356,11 @@ impl TextLayout {
(None, "".into())
};
- if let Some(text_layout) = element_state.0.borrow().as_ref() {
- if text_layout.size.is_some()
- && (wrap_width.is_none() || wrap_width == text_layout.wrap_width)
- {
- return text_layout.size.unwrap();
- }
+ if let Some(text_layout) = element_state.0.borrow().as_ref()
+ && text_layout.size.is_some()
+ && (wrap_width.is_none() || wrap_width == text_layout.wrap_width)
+ {
+ return text_layout.size.unwrap();
}
let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size);
@@ -417,9 +416,7 @@ impl TextLayout {
size
}
- });
-
- layout_id
+ })
}
fn prepaint(&self, bounds: Bounds<Pixels>, text: &str) {
@@ -763,14 +760,13 @@ impl Element for InteractiveText {
let mut interactive_state = interactive_state.unwrap_or_default();
if let Some(click_listener) = self.click_listener.take() {
let mouse_position = window.mouse_position();
- if let Ok(ix) = text_layout.index_for_position(mouse_position) {
- if self
+ if let Ok(ix) = text_layout.index_for_position(mouse_position)
+ && self
.clickable_ranges
.iter()
.any(|range| range.contains(&ix))
- {
- window.set_cursor_style(crate::CursorStyle::PointingHand, hitbox)
- }
+ {
+ window.set_cursor_style(crate::CursorStyle::PointingHand, hitbox)
}
let text_layout = text_layout.clone();
@@ -803,13 +799,13 @@ impl Element for InteractiveText {
} else {
let hitbox = hitbox.clone();
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, _| {
- if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) {
- if let Ok(mouse_down_index) =
+ if phase == DispatchPhase::Bubble
+ && hitbox.is_hovered(window)
+ && let Ok(mouse_down_index) =
text_layout.index_for_position(event.position)
- {
- mouse_down.set(Some(mouse_down_index));
- window.refresh();
- }
+ {
+ mouse_down.set(Some(mouse_down_index));
+ window.refresh();
}
});
}
@@ -88,15 +88,24 @@ pub enum ScrollStrategy {
/// May not be possible if there's not enough list items above the item scrolled to:
/// in this case, the element will be placed at the closest possible position.
Center,
- /// Scrolls the element to be at the given item index from the top of the viewport.
- ToPosition(usize),
+}
+
+#[derive(Clone, Copy, Debug)]
+#[allow(missing_docs)]
+pub struct DeferredScrollToItem {
+ /// The item index to scroll to
+ pub item_index: usize,
+ /// The scroll strategy to use
+ pub strategy: ScrollStrategy,
+ /// The offset in number of items
+ pub offset: usize,
}
#[derive(Clone, Debug, Default)]
#[allow(missing_docs)]
pub struct UniformListScrollState {
pub base_handle: ScrollHandle,
- pub deferred_scroll_to_item: Option<(usize, ScrollStrategy)>,
+ pub deferred_scroll_to_item: Option<DeferredScrollToItem>,
/// Size of the item, captured during last layout.
pub last_item_size: Option<ItemSize>,
/// Whether the list was vertically flipped during last layout.
@@ -126,7 +135,24 @@ impl UniformListScrollHandle {
/// Scroll the list to the given item index.
pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) {
- self.0.borrow_mut().deferred_scroll_to_item = Some((ix, strategy));
+ self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem {
+ item_index: ix,
+ strategy,
+ offset: 0,
+ });
+ }
+
+ /// Scroll the list to the given item index with an offset.
+ ///
+ /// For ScrollStrategy::Top, the item will be placed at the offset position from the top.
+ ///
+ /// For ScrollStrategy::Center, the item will be centered between offset and the last visible item.
+ pub fn scroll_to_item_with_offset(&self, ix: usize, strategy: ScrollStrategy, offset: usize) {
+ self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem {
+ item_index: ix,
+ strategy,
+ offset,
+ });
}
/// Check if the list is flipped vertically.
@@ -139,7 +165,8 @@ impl UniformListScrollHandle {
pub fn logical_scroll_top_index(&self) -> usize {
let this = self.0.borrow();
this.deferred_scroll_to_item
- .map(|(ix, _)| ix)
+ .as_ref()
+ .map(|deferred| deferred.item_index)
.unwrap_or_else(|| this.base_handle.logical_scroll_top().0)
}
@@ -295,9 +322,8 @@ impl Element for UniformList {
bounds.bottom_right() - point(border.right + padding.right, border.bottom),
);
- let y_flipped = if let Some(scroll_handle) = self.scroll_handle.as_mut() {
- let mut scroll_state = scroll_handle.0.borrow_mut();
- scroll_state.base_handle.set_bounds(bounds);
+ let y_flipped = if let Some(scroll_handle) = &self.scroll_handle {
+ let scroll_state = scroll_handle.0.borrow();
scroll_state.y_flipped
} else {
false
@@ -321,7 +347,8 @@ impl Element for UniformList {
scroll_offset.x = Pixels::ZERO;
}
- if let Some((mut ix, scroll_strategy)) = shared_scroll_to_item {
+ if let Some(deferred_scroll) = shared_scroll_to_item {
+ let mut ix = deferred_scroll.item_index;
if y_flipped {
ix = self.item_count.saturating_sub(ix + 1);
}
@@ -330,23 +357,28 @@ impl Element for UniformList {
let item_top = item_height * ix + padding.top;
let item_bottom = item_top + item_height;
let scroll_top = -updated_scroll_offset.y;
+ let offset_pixels = item_height * deferred_scroll.offset;
let mut scrolled_to_top = false;
- if item_top < scroll_top + padding.top {
+
+ if item_top < scroll_top + padding.top + offset_pixels {
scrolled_to_top = true;
- updated_scroll_offset.y = -(item_top) + padding.top;
+ updated_scroll_offset.y = -(item_top) + padding.top + offset_pixels;
} else if item_bottom > scroll_top + list_height - padding.bottom {
scrolled_to_top = true;
updated_scroll_offset.y = -(item_bottom - list_height) - padding.bottom;
}
- match scroll_strategy {
+ match deferred_scroll.strategy {
ScrollStrategy::Top => {}
ScrollStrategy::Center => {
if scrolled_to_top {
let item_center = item_top + item_height / 2.0;
- let target_scroll_top = item_center - list_height / 2.0;
- if item_top < scroll_top
+ let viewport_height = list_height - offset_pixels;
+ let viewport_center = offset_pixels + viewport_height / 2.0;
+ let target_scroll_top = item_center - viewport_center;
+
+ if item_top < scroll_top + offset_pixels
|| item_bottom > scroll_top + list_height
{
updated_scroll_offset.y = -target_scroll_top
@@ -356,15 +388,6 @@ impl Element for UniformList {
}
}
}
- ScrollStrategy::ToPosition(sticky_index) => {
- let target_y_in_viewport = item_height * sticky_index;
- let target_scroll_top = item_top - target_y_in_viewport;
- let max_scroll_top =
- (content_height - list_height).max(Pixels::ZERO);
- let new_scroll_top =
- target_scroll_top.clamp(Pixels::ZERO, max_scroll_top);
- updated_scroll_offset.y = -new_scroll_top;
- }
}
scroll_offset = *updated_scroll_offset
}
@@ -9,12 +9,14 @@ use refineable::Refineable;
use schemars::{JsonSchema, json_schema};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use std::borrow::Cow;
+use std::ops::Range;
use std::{
cmp::{self, PartialOrd},
fmt::{self, Display},
hash::Hash,
ops::{Add, Div, Mul, MulAssign, Neg, Sub},
};
+use taffy::prelude::{TaffyGridLine, TaffyGridSpan};
use crate::{App, DisplayId};
@@ -1044,7 +1046,7 @@ where
size: self.size.clone()
+ size(
amount.left.clone() + amount.right.clone(),
- amount.top.clone() + amount.bottom.clone(),
+ amount.top.clone() + amount.bottom,
),
}
}
@@ -1157,10 +1159,10 @@ where
/// Computes the space available within outer bounds.
pub fn space_within(&self, outer: &Self) -> Edges<T> {
Edges {
- top: self.top().clone() - outer.top().clone(),
- right: outer.right().clone() - self.right().clone(),
- bottom: outer.bottom().clone() - self.bottom().clone(),
- left: self.left().clone() - outer.left().clone(),
+ top: self.top() - outer.top(),
+ right: outer.right() - self.right(),
+ bottom: outer.bottom() - self.bottom(),
+ left: self.left() - outer.left(),
}
}
}
@@ -1639,7 +1641,7 @@ impl Bounds<Pixels> {
}
/// Convert the bounds from logical pixels to physical pixels
- pub fn to_device_pixels(&self, factor: f32) -> Bounds<DevicePixels> {
+ pub fn to_device_pixels(self, factor: f32) -> Bounds<DevicePixels> {
Bounds {
origin: point(
DevicePixels((self.origin.x.0 * factor).round() as i32),
@@ -1710,7 +1712,7 @@ where
top: self.top.clone() * rhs.top,
right: self.right.clone() * rhs.right,
bottom: self.bottom.clone() * rhs.bottom,
- left: self.left.clone() * rhs.left,
+ left: self.left * rhs.left,
}
}
}
@@ -1955,7 +1957,7 @@ impl Edges<DefiniteLength> {
/// assert_eq!(edges_in_pixels.bottom, px(32.0)); // 2 rems
/// assert_eq!(edges_in_pixels.left, px(50.0)); // 25% of parent width
/// ```
- pub fn to_pixels(&self, parent_size: Size<AbsoluteLength>, rem_size: Pixels) -> Edges<Pixels> {
+ pub fn to_pixels(self, parent_size: Size<AbsoluteLength>, rem_size: Pixels) -> Edges<Pixels> {
Edges {
top: self.top.to_pixels(parent_size.height, rem_size),
right: self.right.to_pixels(parent_size.width, rem_size),
@@ -2025,7 +2027,7 @@ impl Edges<AbsoluteLength> {
/// assert_eq!(edges_in_pixels.bottom, px(20.0)); // Already in pixels
/// assert_eq!(edges_in_pixels.left, px(32.0)); // 2 rems converted to pixels
/// ```
- pub fn to_pixels(&self, rem_size: Pixels) -> Edges<Pixels> {
+ pub fn to_pixels(self, rem_size: Pixels) -> Edges<Pixels> {
Edges {
top: self.top.to_pixels(rem_size),
right: self.right.to_pixels(rem_size),
@@ -2270,7 +2272,7 @@ impl Corners<AbsoluteLength> {
/// assert_eq!(corners_in_pixels.bottom_right, Pixels(30.0));
/// assert_eq!(corners_in_pixels.bottom_left, Pixels(32.0)); // 2 rems converted to pixels
/// ```
- pub fn to_pixels(&self, rem_size: Pixels) -> Corners<Pixels> {
+ pub fn to_pixels(self, rem_size: Pixels) -> Corners<Pixels> {
Corners {
top_left: self.top_left.to_pixels(rem_size),
top_right: self.top_right.to_pixels(rem_size),
@@ -2409,7 +2411,7 @@ where
top_left: self.top_left.clone() * rhs.top_left,
top_right: self.top_right.clone() * rhs.top_right,
bottom_right: self.bottom_right.clone() * rhs.bottom_right,
- bottom_left: self.bottom_left.clone() * rhs.bottom_left,
+ bottom_left: self.bottom_left * rhs.bottom_left,
}
}
}
@@ -2856,7 +2858,7 @@ impl DevicePixels {
/// let total_bytes = pixels.to_bytes(bytes_per_pixel);
/// assert_eq!(total_bytes, 40); // 10 pixels * 4 bytes/pixel = 40 bytes
/// ```
- pub fn to_bytes(&self, bytes_per_pixel: u8) -> u32 {
+ pub fn to_bytes(self, bytes_per_pixel: u8) -> u32 {
self.0 as u32 * bytes_per_pixel as u32
}
}
@@ -3071,8 +3073,8 @@ pub struct Rems(pub f32);
impl Rems {
/// Convert this Rem value to pixels.
- pub fn to_pixels(&self, rem_size: Pixels) -> Pixels {
- *self * rem_size
+ pub fn to_pixels(self, rem_size: Pixels) -> Pixels {
+ self * rem_size
}
}
@@ -3166,9 +3168,9 @@ impl AbsoluteLength {
/// assert_eq!(length_in_pixels.to_pixels(rem_size), Pixels(42.0));
/// assert_eq!(length_in_rems.to_pixels(rem_size), Pixels(32.0));
/// ```
- pub fn to_pixels(&self, rem_size: Pixels) -> Pixels {
+ pub fn to_pixels(self, rem_size: Pixels) -> Pixels {
match self {
- AbsoluteLength::Pixels(pixels) => *pixels,
+ AbsoluteLength::Pixels(pixels) => pixels,
AbsoluteLength::Rems(rems) => rems.to_pixels(rem_size),
}
}
@@ -3182,10 +3184,10 @@ impl AbsoluteLength {
/// # Returns
///
/// Returns the `AbsoluteLength` as `Pixels`.
- pub fn to_rems(&self, rem_size: Pixels) -> Rems {
+ pub fn to_rems(self, rem_size: Pixels) -> Rems {
match self {
AbsoluteLength::Pixels(pixels) => Rems(pixels.0 / rem_size.0),
- AbsoluteLength::Rems(rems) => *rems,
+ AbsoluteLength::Rems(rems) => rems,
}
}
}
@@ -3313,12 +3315,12 @@ impl DefiniteLength {
/// assert_eq!(length_in_rems.to_pixels(base_size, rem_size), Pixels(32.0));
/// assert_eq!(length_as_fraction.to_pixels(base_size, rem_size), Pixels(50.0));
/// ```
- pub fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Pixels {
+ pub fn to_pixels(self, base_size: AbsoluteLength, rem_size: Pixels) -> Pixels {
match self {
DefiniteLength::Absolute(size) => size.to_pixels(rem_size),
DefiniteLength::Fraction(fraction) => match base_size {
- AbsoluteLength::Pixels(px) => px * *fraction,
- AbsoluteLength::Rems(rems) => rems * rem_size * *fraction,
+ AbsoluteLength::Pixels(px) => px * fraction,
+ AbsoluteLength::Rems(rems) => rems * rem_size * fraction,
},
}
}
@@ -3522,7 +3524,7 @@ impl Serialize for Length {
/// # Returns
///
/// A `DefiniteLength` representing the relative length as a fraction of the parent's size.
-pub fn relative(fraction: f32) -> DefiniteLength {
+pub const fn relative(fraction: f32) -> DefiniteLength {
DefiniteLength::Fraction(fraction)
}
@@ -3608,6 +3610,37 @@ impl From<()> for Length {
}
}
+/// A location in a grid layout.
+#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, JsonSchema, Default)]
+pub struct GridLocation {
+ /// The rows this item uses within the grid.
+ pub row: Range<GridPlacement>,
+ /// The columns this item uses within the grid.
+ pub column: Range<GridPlacement>,
+}
+
+/// The placement of an item within a grid layout's column or row.
+#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize, JsonSchema, Default)]
+pub enum GridPlacement {
+ /// The grid line index to place this item.
+ Line(i16),
+ /// The number of grid lines to span.
+ Span(u16),
+ /// Automatically determine the placement, equivalent to Span(1)
+ #[default]
+ Auto,
+}
+
+impl From<GridPlacement> for taffy::GridPlacement {
+ fn from(placement: GridPlacement) -> Self {
+ match placement {
+ GridPlacement::Line(index) => taffy::GridPlacement::from_line_index(index),
+ GridPlacement::Span(span) => taffy::GridPlacement::from_span(span),
+ GridPlacement::Auto => taffy::GridPlacement::Auto,
+ }
+ }
+}
+
/// Provides a trait for types that can calculate half of their value.
///
/// The `Half` trait is used for types that can be evenly divided, returning a new instance of the same type
@@ -95,6 +95,7 @@ mod style;
mod styled;
mod subscription;
mod svg_renderer;
+mod tab_stop;
mod taffy;
#[cfg(any(test, feature = "test-support"))]
pub mod test;
@@ -151,11 +152,12 @@ pub use style::*;
pub use styled::*;
pub use subscription::*;
use svg_renderer::*;
+pub(crate) use tab_stop::*;
pub use taffy::{AvailableSpace, LayoutId};
#[cfg(any(test, feature = "test-support"))]
pub use test::*;
pub use text_system::*;
-pub use util::arc_cow::ArcCow;
+pub use util::{FutureExt, Timeout, arc_cow::ArcCow};
pub use view::*;
pub use window::*;
@@ -170,6 +172,10 @@ pub trait AppContext {
type Result<T>;
/// Create a new entity in the app context.
+ #[expect(
+ clippy::wrong_self_convention,
+ reason = "`App::new` is an ubiquitous function for creating entities"
+ )]
fn new<T: 'static>(
&mut self,
build_entity: impl FnOnce(&mut Context<T>) -> T,
@@ -197,6 +203,11 @@ pub trait AppContext {
where
T: 'static;
+ /// Update a entity in the app context.
+ fn as_mut<'a, T>(&'a mut self, handle: &Entity<T>) -> Self::Result<GpuiBorrow<'a, T>>
+ where
+ T: 'static;
+
/// Read a entity from the app context.
fn read_entity<T, R>(
&self,
@@ -341,7 +352,7 @@ impl<T> Flatten<T> for Result<T> {
}
/// Information about the GPU GPUI is running on.
-#[derive(Default, Debug)]
+#[derive(Default, Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct GpuSpecs {
/// Whether the GPU is really a fake (like `llvmpipe`) running on the CPU.
pub is_software_emulated: bool,
@@ -164,7 +164,7 @@ mod conditional {
if let Some(render_inspector) = cx
.inspector_element_registry
.renderers_by_type_id
- .remove(&type_id)
+ .remove(type_id)
{
let mut element = (render_inspector)(
active_element.id.clone(),
@@ -1,6 +1,6 @@
use crate::{
- Capslock, Context, Empty, IntoElement, Keystroke, Modifiers, Pixels, Point, Render, Window,
- point, seal::Sealed,
+ Bounds, Capslock, Context, Empty, IntoElement, Keystroke, Modifiers, Pixels, Point, Render,
+ Window, point, seal::Sealed,
};
use smallvec::SmallVec;
use std::{any::Any, fmt::Debug, ops::Deref, path::PathBuf};
@@ -141,7 +141,7 @@ impl MouseEvent for MouseUpEvent {}
/// A click event, generated when a mouse button is pressed and released.
#[derive(Clone, Debug, Default)]
-pub struct ClickEvent {
+pub struct MouseClickEvent {
/// The mouse event when the button was pressed.
pub down: MouseDownEvent,
@@ -149,18 +149,126 @@ pub struct ClickEvent {
pub up: MouseUpEvent,
}
+/// A click event that was generated by a keyboard button being pressed and released.
+#[derive(Clone, Debug, Default)]
+pub struct KeyboardClickEvent {
+ /// The keyboard button that was pressed to trigger the click.
+ pub button: KeyboardButton,
+
+ /// The bounds of the element that was clicked.
+ pub bounds: Bounds<Pixels>,
+}
+
+/// A click event, generated when a mouse button or keyboard button is pressed and released.
+#[derive(Clone, Debug)]
+pub enum ClickEvent {
+ /// A click event trigger by a mouse button being pressed and released.
+ Mouse(MouseClickEvent),
+ /// A click event trigger by a keyboard button being pressed and released.
+ Keyboard(KeyboardClickEvent),
+}
+
+impl Default for ClickEvent {
+ fn default() -> Self {
+ ClickEvent::Keyboard(KeyboardClickEvent::default())
+ }
+}
+
impl ClickEvent {
- /// Returns the modifiers that were held down during both the
- /// mouse down and mouse up events
+ /// Returns the modifiers that were held during the click event
+ ///
+ /// `Keyboard`: The keyboard click events never have modifiers.
+ /// `Mouse`: Modifiers that were held during the mouse key up event.
pub fn modifiers(&self) -> Modifiers {
- Modifiers {
- control: self.up.modifiers.control && self.down.modifiers.control,
- alt: self.up.modifiers.alt && self.down.modifiers.alt,
- shift: self.up.modifiers.shift && self.down.modifiers.shift,
- platform: self.up.modifiers.platform && self.down.modifiers.platform,
- function: self.up.modifiers.function && self.down.modifiers.function,
+ match self {
+ // Click events are only generated from keyboard events _without any modifiers_, so we know the modifiers are always Default
+ ClickEvent::Keyboard(_) => Modifiers::default(),
+ // Click events on the web only reflect the modifiers for the keyup event,
+ // tested via observing the behavior of the `ClickEvent.shiftKey` field in Chrome 138
+ // under various combinations of modifiers and keyUp / keyDown events.
+ ClickEvent::Mouse(event) => event.up.modifiers,
+ }
+ }
+
+ /// Returns the position of the click event
+ ///
+ /// `Keyboard`: The bottom left corner of the clicked hitbox
+ /// `Mouse`: The position of the mouse when the button was released.
+ pub fn position(&self) -> Point<Pixels> {
+ match self {
+ ClickEvent::Keyboard(event) => event.bounds.bottom_left(),
+ ClickEvent::Mouse(event) => event.up.position,
}
}
+
+ /// Returns the mouse position of the click event
+ ///
+ /// `Keyboard`: None
+ /// `Mouse`: The position of the mouse when the button was released.
+ pub fn mouse_position(&self) -> Option<Point<Pixels>> {
+ match self {
+ ClickEvent::Keyboard(_) => None,
+ ClickEvent::Mouse(event) => Some(event.up.position),
+ }
+ }
+
+ /// Returns if this was a right click
+ ///
+ /// `Keyboard`: false
+ /// `Mouse`: Whether the right button was pressed and released
+ pub fn is_right_click(&self) -> bool {
+ match self {
+ ClickEvent::Keyboard(_) => false,
+ ClickEvent::Mouse(event) => {
+ event.down.button == MouseButton::Right && event.up.button == MouseButton::Right
+ }
+ }
+ }
+
+ /// Returns whether the click was a standard click
+ ///
+ /// `Keyboard`: Always true
+ /// `Mouse`: Left button pressed and released
+ pub fn standard_click(&self) -> bool {
+ match self {
+ ClickEvent::Keyboard(_) => true,
+ ClickEvent::Mouse(event) => {
+ event.down.button == MouseButton::Left && event.up.button == MouseButton::Left
+ }
+ }
+ }
+
+ /// Returns whether the click focused the element
+ ///
+ /// `Keyboard`: false, keyboard clicks only work if an element is already focused
+ /// `Mouse`: Whether this was the first focusing click
+ pub fn first_focus(&self) -> bool {
+ match self {
+ ClickEvent::Keyboard(_) => false,
+ ClickEvent::Mouse(event) => event.down.first_mouse,
+ }
+ }
+
+ /// Returns the click count of the click event
+ ///
+ /// `Keyboard`: Always 1
+ /// `Mouse`: Count of clicks from MouseUpEvent
+ pub fn click_count(&self) -> usize {
+ match self {
+ ClickEvent::Keyboard(_) => 1,
+ ClickEvent::Mouse(event) => event.up.click_count,
+ }
+ }
+}
+
+/// An enum representing the keyboard button that was pressed for a click event.
+#[derive(Hash, PartialEq, Eq, Copy, Clone, Debug, Default)]
+pub enum KeyboardButton {
+ /// Enter key was clicked
+ #[default]
+ Enter,
+ /// Space key was clicked
+ Space,
}
/// An enum representing the mouse button that was pressed.
@@ -50,8 +50,8 @@
/// KeyBinding::new("cmd-k left", pane::SplitLeft, Some("Pane"))
///
use crate::{
- Action, ActionRegistry, App, BindingIndex, DispatchPhase, EntityId, FocusId, KeyBinding,
- KeyContext, Keymap, Keystroke, ModifiersChangedEvent, Window,
+ Action, ActionRegistry, App, DispatchPhase, EntityId, FocusId, KeyBinding, KeyContext, Keymap,
+ Keystroke, ModifiersChangedEvent, Window,
};
use collections::FxHashMap;
use smallvec::SmallVec;
@@ -406,16 +406,11 @@ impl DispatchTree {
// methods, but this can't be done very cleanly since keymap must be borrowed.
let keymap = self.keymap.borrow();
keymap
- .bindings_for_action_with_indices(action)
- .filter(|(binding_index, binding)| {
- Self::binding_matches_predicate_and_not_shadowed(
- &keymap,
- *binding_index,
- &binding.keystrokes,
- context_stack,
- )
+ .bindings_for_action(action)
+ .filter(|binding| {
+ Self::binding_matches_predicate_and_not_shadowed(&keymap, binding, context_stack)
})
- .map(|(_, binding)| binding.clone())
+ .cloned()
.collect()
}
@@ -428,28 +423,22 @@ impl DispatchTree {
) -> Option<KeyBinding> {
let keymap = self.keymap.borrow();
keymap
- .bindings_for_action_with_indices(action)
+ .bindings_for_action(action)
.rev()
- .find_map(|(binding_index, binding)| {
- let found = Self::binding_matches_predicate_and_not_shadowed(
- &keymap,
- binding_index,
- &binding.keystrokes,
- context_stack,
- );
- if found { Some(binding.clone()) } else { None }
+ .find(|binding| {
+ Self::binding_matches_predicate_and_not_shadowed(&keymap, binding, context_stack)
})
+ .cloned()
}
fn binding_matches_predicate_and_not_shadowed(
keymap: &Keymap,
- binding_index: BindingIndex,
- keystrokes: &[Keystroke],
+ binding: &KeyBinding,
context_stack: &[KeyContext],
) -> bool {
- let (bindings, _) = keymap.bindings_for_input_with_indices(&keystrokes, context_stack);
- if let Some((highest_precedence_index, _)) = bindings.iter().next() {
- binding_index == *highest_precedence_index
+ let (bindings, _) = keymap.bindings_for_input(&binding.keystrokes, context_stack);
+ if let Some(found) = bindings.iter().next() {
+ found.action.partial_eq(binding.action.as_ref())
} else {
false
}
@@ -469,7 +458,7 @@ impl DispatchTree {
.keymap
.borrow()
.bindings_for_input(input, &context_stack);
- return (bindings, partial, context_stack);
+ (bindings, partial, context_stack)
}
/// dispatch_key processes the keystroke
@@ -622,9 +611,17 @@ impl DispatchTree {
#[cfg(test)]
mod tests {
- use std::{cell::RefCell, rc::Rc};
-
- use crate::{Action, ActionRegistry, DispatchTree, KeyBinding, KeyContext, Keymap};
+ use crate::{
+ self as gpui, Element, ElementId, GlobalElementId, InspectorElementId, LayoutId, Style,
+ };
+ use core::panic;
+ use std::{cell::RefCell, ops::Range, rc::Rc};
+
+ use crate::{
+ Action, ActionRegistry, App, Bounds, Context, DispatchTree, FocusHandle, InputHandler,
+ IntoElement, KeyBinding, KeyContext, Keymap, Pixels, Point, Render, TestAppContext,
+ UTF16Selection, Window,
+ };
#[derive(PartialEq, Eq)]
struct TestAction;
@@ -642,10 +639,7 @@ mod tests {
}
fn partial_eq(&self, action: &dyn Action) -> bool {
- action
- .as_any()
- .downcast_ref::<Self>()
- .map_or(false, |a| self == a)
+ action.as_any().downcast_ref::<Self>() == Some(self)
}
fn boxed_clone(&self) -> std::boxed::Box<dyn Action> {
@@ -685,4 +679,165 @@ mod tests {
assert!(keybinding[0].action.partial_eq(&TestAction))
}
+
+ #[crate::test]
+ fn test_input_handler_pending(cx: &mut TestAppContext) {
+ #[derive(Clone)]
+ struct CustomElement {
+ focus_handle: FocusHandle,
+ text: Rc<RefCell<String>>,
+ }
+ impl CustomElement {
+ fn new(cx: &mut Context<Self>) -> Self {
+ Self {
+ focus_handle: cx.focus_handle(),
+ text: Rc::default(),
+ }
+ }
+ }
+ impl Element for CustomElement {
+ type RequestLayoutState = ();
+
+ type PrepaintState = ();
+
+ fn id(&self) -> Option<ElementId> {
+ Some("custom".into())
+ }
+ fn source_location(&self) -> Option<&'static panic::Location<'static>> {
+ None
+ }
+ fn request_layout(
+ &mut self,
+ _: Option<&GlobalElementId>,
+ _: Option<&InspectorElementId>,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> (LayoutId, Self::RequestLayoutState) {
+ (window.request_layout(Style::default(), [], cx), ())
+ }
+ fn prepaint(
+ &mut self,
+ _: Option<&GlobalElementId>,
+ _: Option<&InspectorElementId>,
+ _: Bounds<Pixels>,
+ _: &mut Self::RequestLayoutState,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> Self::PrepaintState {
+ window.set_focus_handle(&self.focus_handle, cx);
+ }
+ fn paint(
+ &mut self,
+ _: Option<&GlobalElementId>,
+ _: Option<&InspectorElementId>,
+ _: Bounds<Pixels>,
+ _: &mut Self::RequestLayoutState,
+ _: &mut Self::PrepaintState,
+ window: &mut Window,
+ cx: &mut App,
+ ) {
+ let mut key_context = KeyContext::default();
+ key_context.add("Terminal");
+ window.set_key_context(key_context);
+ window.handle_input(&self.focus_handle, self.clone(), cx);
+ window.on_action(std::any::TypeId::of::<TestAction>(), |_, _, _, _| {});
+ }
+ }
+ impl IntoElement for CustomElement {
+ type Element = Self;
+
+ fn into_element(self) -> Self::Element {
+ self
+ }
+ }
+
+ impl InputHandler for CustomElement {
+ fn selected_text_range(
+ &mut self,
+ _: bool,
+ _: &mut Window,
+ _: &mut App,
+ ) -> Option<UTF16Selection> {
+ None
+ }
+
+ fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option<Range<usize>> {
+ None
+ }
+
+ fn text_for_range(
+ &mut self,
+ _: Range<usize>,
+ _: &mut Option<Range<usize>>,
+ _: &mut Window,
+ _: &mut App,
+ ) -> Option<String> {
+ None
+ }
+
+ fn replace_text_in_range(
+ &mut self,
+ replacement_range: Option<Range<usize>>,
+ text: &str,
+ _: &mut Window,
+ _: &mut App,
+ ) {
+ if replacement_range.is_some() {
+ unimplemented!()
+ }
+ self.text.borrow_mut().push_str(text)
+ }
+
+ fn replace_and_mark_text_in_range(
+ &mut self,
+ replacement_range: Option<Range<usize>>,
+ new_text: &str,
+ _: Option<Range<usize>>,
+ _: &mut Window,
+ _: &mut App,
+ ) {
+ if replacement_range.is_some() {
+ unimplemented!()
+ }
+ self.text.borrow_mut().push_str(new_text)
+ }
+
+ fn unmark_text(&mut self, _: &mut Window, _: &mut App) {}
+
+ fn bounds_for_range(
+ &mut self,
+ _: Range<usize>,
+ _: &mut Window,
+ _: &mut App,
+ ) -> Option<Bounds<Pixels>> {
+ None
+ }
+
+ fn character_index_for_point(
+ &mut self,
+ _: Point<Pixels>,
+ _: &mut Window,
+ _: &mut App,
+ ) -> Option<usize> {
+ None
+ }
+ }
+ impl Render for CustomElement {
+ fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+ self.clone()
+ }
+ }
+
+ cx.update(|cx| {
+ cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]);
+ cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]);
+ });
+ let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx));
+ cx.update(|window, cx| {
+ window.focus(&test.read(cx).focus_handle);
+ window.activate_window();
+ });
+ cx.simulate_keystrokes("ctrl-b [");
+ test.update(cx, |test, _| assert_eq!(test.text.borrow().as_str(), "["))
+ }
}
@@ -5,7 +5,7 @@ pub use binding::*;
pub use context::*;
use crate::{Action, Keystroke, is_no_action};
-use collections::HashMap;
+use collections::{HashMap, HashSet};
use smallvec::SmallVec;
use std::any::TypeId;
@@ -24,7 +24,7 @@ pub struct Keymap {
}
/// Index of a binding within a keymap.
-#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
pub struct BindingIndex(usize);
impl Keymap {
@@ -77,15 +77,6 @@ impl Keymap {
&'a self,
action: &'a dyn Action,
) -> impl 'a + DoubleEndedIterator<Item = &'a KeyBinding> {
- self.bindings_for_action_with_indices(action)
- .map(|(_, binding)| binding)
- }
-
- /// Like `bindings_for_action_with_indices`, but also returns the binding indices.
- pub fn bindings_for_action_with_indices<'a>(
- &'a self,
- action: &'a dyn Action,
- ) -> impl 'a + DoubleEndedIterator<Item = (BindingIndex, &'a KeyBinding)> {
let action_id = action.type_id();
let binding_indices = self
.binding_indices_by_action_id
@@ -118,7 +109,7 @@ impl Keymap {
}
}
- Some((BindingIndex(*ix), binding))
+ Some(binding)
})
}
@@ -153,107 +144,63 @@ impl Keymap {
input: &[Keystroke],
context_stack: &[KeyContext],
) -> (SmallVec<[KeyBinding; 1]>, bool) {
- let (bindings, pending) = self.bindings_for_input_with_indices(input, context_stack);
- let bindings = bindings
- .into_iter()
- .map(|(_, binding)| binding)
- .collect::<SmallVec<[KeyBinding; 1]>>();
- (bindings, pending)
- }
+ let mut matched_bindings = SmallVec::<[(usize, BindingIndex, &KeyBinding); 1]>::new();
+ let mut pending_bindings = SmallVec::<[(BindingIndex, &KeyBinding); 1]>::new();
+
+ for (ix, binding) in self.bindings().enumerate().rev() {
+ let Some(depth) = self.binding_enabled(binding, context_stack) else {
+ continue;
+ };
+ let Some(pending) = binding.match_keystrokes(input) else {
+ continue;
+ };
+
+ if !pending {
+ matched_bindings.push((depth, BindingIndex(ix), binding));
+ } else {
+ pending_bindings.push((BindingIndex(ix), binding));
+ }
+ }
- /// Like `bindings_for_input`, but also returns the binding indices.
- pub fn bindings_for_input_with_indices(
- &self,
- input: &[Keystroke],
- context_stack: &[KeyContext],
- ) -> (SmallVec<[(BindingIndex, KeyBinding); 1]>, bool) {
- let possibilities = self
- .bindings()
- .enumerate()
- .rev()
- .filter_map(|(ix, binding)| {
- binding
- .match_keystrokes(input)
- .map(|pending| (BindingIndex(ix), binding, pending))
- });
-
- let mut bindings: SmallVec<[(BindingIndex, KeyBinding, usize); 1]> = SmallVec::new();
-
- // (pending, is_no_action, depth, keystrokes)
- let mut pending_info_opt: Option<(bool, bool, usize, &[Keystroke])> = None;
-
- 'outer: for (binding_index, binding, pending) in possibilities {
- for depth in (0..=context_stack.len()).rev() {
- if self.binding_enabled(binding, &context_stack[0..depth]) {
- let is_no_action = is_no_action(&*binding.action);
- // We only want to consider a binding pending if it has an action
- // This, however, means that if we have both a NoAction binding and a binding
- // with an action at the same depth, we should still set is_pending to true.
- if let Some(pending_info) = pending_info_opt.as_mut() {
- let (
- already_pending,
- pending_is_no_action,
- pending_depth,
- pending_keystrokes,
- ) = *pending_info;
-
- // We only want to change the pending status if it's not already pending AND if
- // the existing pending status was set by a NoAction binding. This avoids a NoAction
- // binding erroneously setting the pending status to true when a binding with an action
- // already set it to false
- //
- // We also want to change the pending status if the keystrokes don't match,
- // meaning it's different keystrokes than the NoAction that set pending to false
- if pending
- && !already_pending
- && pending_is_no_action
- && (pending_depth == depth
- || pending_keystrokes != binding.keystrokes())
- {
- pending_info.0 = !is_no_action;
- }
- } else {
- pending_info_opt = Some((
- pending && !is_no_action,
- is_no_action,
- depth,
- binding.keystrokes(),
- ));
- }
+ matched_bindings.sort_by(|(depth_a, ix_a, _), (depth_b, ix_b, _)| {
+ depth_b.cmp(depth_a).then(ix_b.cmp(ix_a))
+ });
- if !pending {
- bindings.push((binding_index, binding.clone(), depth));
- continue 'outer;
- }
- }
+ let mut bindings: SmallVec<[_; 1]> = SmallVec::new();
+ let mut first_binding_index = None;
+ for (_, ix, binding) in matched_bindings {
+ if is_no_action(&*binding.action) {
+ break;
}
+ bindings.push(binding.clone());
+ first_binding_index.get_or_insert(ix);
+ }
+
+ let mut pending = HashSet::default();
+ for (ix, binding) in pending_bindings.into_iter().rev() {
+ if let Some(binding_ix) = first_binding_index
+ && binding_ix > ix
+ {
+ continue;
+ }
+ if is_no_action(&*binding.action) {
+ pending.remove(&&binding.keystrokes);
+ continue;
+ }
+ pending.insert(&binding.keystrokes);
}
- // sort by descending depth
- bindings.sort_by(|a, b| a.2.cmp(&b.2).reverse());
- let bindings = bindings
- .into_iter()
- .map_while(|(binding_index, binding, _)| {
- if is_no_action(&*binding.action) {
- None
- } else {
- Some((binding_index, binding))
- }
- })
- .collect();
- (bindings, pending_info_opt.unwrap_or_default().0)
+ (bindings, !pending.is_empty())
}
/// Check if the given binding is enabled, given a certain key context.
- fn binding_enabled(&self, binding: &KeyBinding, context: &[KeyContext]) -> bool {
- // If binding has a context predicate, it must match the current context,
+ /// Returns the deepest depth at which the binding matches, or None if it doesn't match.
+ fn binding_enabled(&self, binding: &KeyBinding, contexts: &[KeyContext]) -> Option<usize> {
if let Some(predicate) = &binding.context_predicate {
- if !predicate.eval(context) {
- return false;
- }
+ predicate.depth_of(contexts)
+ } else {
+ Some(contexts.len())
}
-
- true
}
}
@@ -280,18 +227,57 @@ mod tests {
keymap.add_bindings(bindings.clone());
// global bindings are enabled in all contexts
- assert!(keymap.binding_enabled(&bindings[0], &[]));
- assert!(keymap.binding_enabled(&bindings[0], &[KeyContext::parse("terminal").unwrap()]));
+ assert_eq!(keymap.binding_enabled(&bindings[0], &[]), Some(0));
+ assert_eq!(
+ keymap.binding_enabled(&bindings[0], &[KeyContext::parse("terminal").unwrap()]),
+ Some(1)
+ );
// contextual bindings are enabled in contexts that match their predicate
- assert!(!keymap.binding_enabled(&bindings[1], &[KeyContext::parse("barf x=y").unwrap()]));
- assert!(keymap.binding_enabled(&bindings[1], &[KeyContext::parse("pane x=y").unwrap()]));
-
- assert!(!keymap.binding_enabled(&bindings[2], &[KeyContext::parse("editor").unwrap()]));
- assert!(keymap.binding_enabled(
- &bindings[2],
- &[KeyContext::parse("editor mode=full").unwrap()]
- ));
+ assert_eq!(
+ keymap.binding_enabled(&bindings[1], &[KeyContext::parse("barf x=y").unwrap()]),
+ None
+ );
+ assert_eq!(
+ keymap.binding_enabled(&bindings[1], &[KeyContext::parse("pane x=y").unwrap()]),
+ Some(1)
+ );
+
+ assert_eq!(
+ keymap.binding_enabled(&bindings[2], &[KeyContext::parse("editor").unwrap()]),
+ None
+ );
+ assert_eq!(
+ keymap.binding_enabled(
+ &bindings[2],
+ &[KeyContext::parse("editor mode=full").unwrap()]
+ ),
+ Some(1)
+ );
+ }
+
+ #[test]
+ fn test_depth_precedence() {
+ let bindings = [
+ KeyBinding::new("ctrl-a", ActionBeta {}, Some("pane")),
+ KeyBinding::new("ctrl-a", ActionGamma {}, Some("editor")),
+ ];
+
+ let mut keymap = Keymap::default();
+ keymap.add_bindings(bindings);
+
+ let (result, pending) = keymap.bindings_for_input(
+ &[Keystroke::parse("ctrl-a").unwrap()],
+ &[
+ KeyContext::parse("pane").unwrap(),
+ KeyContext::parse("editor").unwrap(),
+ ],
+ );
+
+ assert!(!pending);
+ assert_eq!(result.len(), 2);
+ assert!(result[0].action.partial_eq(&ActionGamma {}));
+ assert!(result[1].action.partial_eq(&ActionBeta {}));
}
#[test]
@@ -304,7 +290,7 @@ mod tests {
];
let mut keymap = Keymap::default();
- keymap.add_bindings(bindings.clone());
+ keymap.add_bindings(bindings);
// binding is only enabled in a specific context
assert!(
@@ -358,7 +344,7 @@ mod tests {
];
let mut keymap = Keymap::default();
- keymap.add_bindings(bindings.clone());
+ keymap.add_bindings(bindings);
let space = || Keystroke::parse("space").unwrap();
let w = || Keystroke::parse("w").unwrap();
@@ -378,29 +364,29 @@ mod tests {
// Ensure `space` results in pending input on the workspace, but not editor
let space_workspace = keymap.bindings_for_input(&[space()], &workspace_context());
assert!(space_workspace.0.is_empty());
- assert_eq!(space_workspace.1, true);
+ assert!(space_workspace.1);
let space_editor = keymap.bindings_for_input(&[space()], &editor_workspace_context());
assert!(space_editor.0.is_empty());
- assert_eq!(space_editor.1, false);
+ assert!(!space_editor.1);
// Ensure `space w` results in pending input on the workspace, but not editor
let space_w_workspace = keymap.bindings_for_input(&space_w, &workspace_context());
assert!(space_w_workspace.0.is_empty());
- assert_eq!(space_w_workspace.1, true);
+ assert!(space_w_workspace.1);
let space_w_editor = keymap.bindings_for_input(&space_w, &editor_workspace_context());
assert!(space_w_editor.0.is_empty());
- assert_eq!(space_w_editor.1, false);
+ assert!(!space_w_editor.1);
// Ensure `space w w` results in the binding in the workspace, but not in the editor
let space_w_w_workspace = keymap.bindings_for_input(&space_w_w, &workspace_context());
assert!(!space_w_w_workspace.0.is_empty());
- assert_eq!(space_w_w_workspace.1, false);
+ assert!(!space_w_w_workspace.1);
let space_w_w_editor = keymap.bindings_for_input(&space_w_w, &editor_workspace_context());
assert!(space_w_w_editor.0.is_empty());
- assert_eq!(space_w_w_editor.1, false);
+ assert!(!space_w_w_editor.1);
// Now test what happens if we have another binding defined AFTER the NoAction
// that should result in pending
@@ -410,11 +396,11 @@ mod tests {
KeyBinding::new("space w x", ActionAlpha {}, Some("editor")),
];
let mut keymap = Keymap::default();
- keymap.add_bindings(bindings.clone());
+ keymap.add_bindings(bindings);
let space_editor = keymap.bindings_for_input(&[space()], &editor_workspace_context());
assert!(space_editor.0.is_empty());
- assert_eq!(space_editor.1, true);
+ assert!(space_editor.1);
// Now test what happens if we have another binding defined BEFORE the NoAction
// that should result in pending
@@ -424,11 +410,11 @@ mod tests {
KeyBinding::new("space w w", NoAction {}, Some("editor")),
];
let mut keymap = Keymap::default();
- keymap.add_bindings(bindings.clone());
+ keymap.add_bindings(bindings);
let space_editor = keymap.bindings_for_input(&[space()], &editor_workspace_context());
assert!(space_editor.0.is_empty());
- assert_eq!(space_editor.1, true);
+ assert!(space_editor.1);
// Now test what happens if we have another binding defined at a higher context
// that should result in pending
@@ -438,11 +424,198 @@ mod tests {
KeyBinding::new("space w w", NoAction {}, Some("editor")),
];
let mut keymap = Keymap::default();
- keymap.add_bindings(bindings.clone());
+ keymap.add_bindings(bindings);
let space_editor = keymap.bindings_for_input(&[space()], &editor_workspace_context());
assert!(space_editor.0.is_empty());
- assert_eq!(space_editor.1, true);
+ assert!(space_editor.1);
+ }
+
+ #[test]
+ fn test_override_multikey() {
+ let bindings = [
+ KeyBinding::new("ctrl-w left", ActionAlpha {}, Some("editor")),
+ KeyBinding::new("ctrl-w", NoAction {}, Some("editor")),
+ ];
+
+ let mut keymap = Keymap::default();
+ keymap.add_bindings(bindings);
+
+ // Ensure `space` results in pending input on the workspace, but not editor
+ let (result, pending) = keymap.bindings_for_input(
+ &[Keystroke::parse("ctrl-w").unwrap()],
+ &[KeyContext::parse("editor").unwrap()],
+ );
+ assert!(result.is_empty());
+ assert!(pending);
+
+ let bindings = [
+ KeyBinding::new("ctrl-w left", ActionAlpha {}, Some("editor")),
+ KeyBinding::new("ctrl-w", ActionBeta {}, Some("editor")),
+ ];
+
+ let mut keymap = Keymap::default();
+ keymap.add_bindings(bindings);
+
+ // Ensure `space` results in pending input on the workspace, but not editor
+ let (result, pending) = keymap.bindings_for_input(
+ &[Keystroke::parse("ctrl-w").unwrap()],
+ &[KeyContext::parse("editor").unwrap()],
+ );
+ assert_eq!(result.len(), 1);
+ assert!(!pending);
+ }
+
+ #[test]
+ fn test_simple_disable() {
+ let bindings = [
+ KeyBinding::new("ctrl-x", ActionAlpha {}, Some("editor")),
+ KeyBinding::new("ctrl-x", NoAction {}, Some("editor")),
+ ];
+
+ let mut keymap = Keymap::default();
+ keymap.add_bindings(bindings);
+
+ // Ensure `space` results in pending input on the workspace, but not editor
+ let (result, pending) = keymap.bindings_for_input(
+ &[Keystroke::parse("ctrl-x").unwrap()],
+ &[KeyContext::parse("editor").unwrap()],
+ );
+ assert!(result.is_empty());
+ assert!(!pending);
+ }
+
+ #[test]
+ fn test_fail_to_disable() {
+ // disabled at the wrong level
+ let bindings = [
+ KeyBinding::new("ctrl-x", ActionAlpha {}, Some("editor")),
+ KeyBinding::new("ctrl-x", NoAction {}, Some("workspace")),
+ ];
+
+ let mut keymap = Keymap::default();
+ keymap.add_bindings(bindings);
+
+ // Ensure `space` results in pending input on the workspace, but not editor
+ let (result, pending) = keymap.bindings_for_input(
+ &[Keystroke::parse("ctrl-x").unwrap()],
+ &[
+ KeyContext::parse("workspace").unwrap(),
+ KeyContext::parse("editor").unwrap(),
+ ],
+ );
+ assert_eq!(result.len(), 1);
+ assert!(!pending);
+ }
+
+ #[test]
+ fn test_disable_deeper() {
+ let bindings = [
+ KeyBinding::new("ctrl-x", ActionAlpha {}, Some("workspace")),
+ KeyBinding::new("ctrl-x", NoAction {}, Some("editor")),
+ ];
+
+ let mut keymap = Keymap::default();
+ keymap.add_bindings(bindings);
+
+ // Ensure `space` results in pending input on the workspace, but not editor
+ let (result, pending) = keymap.bindings_for_input(
+ &[Keystroke::parse("ctrl-x").unwrap()],
+ &[
+ KeyContext::parse("workspace").unwrap(),
+ KeyContext::parse("editor").unwrap(),
+ ],
+ );
+ assert_eq!(result.len(), 0);
+ assert!(!pending);
+ }
+
+ #[test]
+ fn test_pending_match_enabled() {
+ let bindings = [
+ KeyBinding::new("ctrl-x", ActionBeta, Some("vim_mode == normal")),
+ KeyBinding::new("ctrl-x 0", ActionAlpha, Some("Workspace")),
+ ];
+ let mut keymap = Keymap::default();
+ keymap.add_bindings(bindings);
+
+ let matched = keymap.bindings_for_input(
+ &[Keystroke::parse("ctrl-x")].map(Result::unwrap),
+ &[
+ KeyContext::parse("Workspace"),
+ KeyContext::parse("Pane"),
+ KeyContext::parse("Editor vim_mode=normal"),
+ ]
+ .map(Result::unwrap),
+ );
+ assert_eq!(matched.0.len(), 1);
+ assert!(matched.0[0].action.partial_eq(&ActionBeta));
+ assert!(matched.1);
+ }
+
+ #[test]
+ fn test_pending_match_enabled_extended() {
+ let bindings = [
+ KeyBinding::new("ctrl-x", ActionBeta, Some("vim_mode == normal")),
+ KeyBinding::new("ctrl-x 0", NoAction, Some("Workspace")),
+ ];
+ let mut keymap = Keymap::default();
+ keymap.add_bindings(bindings);
+
+ let matched = keymap.bindings_for_input(
+ &[Keystroke::parse("ctrl-x")].map(Result::unwrap),
+ &[
+ KeyContext::parse("Workspace"),
+ KeyContext::parse("Pane"),
+ KeyContext::parse("Editor vim_mode=normal"),
+ ]
+ .map(Result::unwrap),
+ );
+ assert_eq!(matched.0.len(), 1);
+ assert!(matched.0[0].action.partial_eq(&ActionBeta));
+ assert!(!matched.1);
+ let bindings = [
+ KeyBinding::new("ctrl-x", ActionBeta, Some("Workspace")),
+ KeyBinding::new("ctrl-x 0", NoAction, Some("vim_mode == normal")),
+ ];
+ let mut keymap = Keymap::default();
+ keymap.add_bindings(bindings);
+
+ let matched = keymap.bindings_for_input(
+ &[Keystroke::parse("ctrl-x")].map(Result::unwrap),
+ &[
+ KeyContext::parse("Workspace"),
+ KeyContext::parse("Pane"),
+ KeyContext::parse("Editor vim_mode=normal"),
+ ]
+ .map(Result::unwrap),
+ );
+ assert_eq!(matched.0.len(), 1);
+ assert!(matched.0[0].action.partial_eq(&ActionBeta));
+ assert!(!matched.1);
+ }
+
+ #[test]
+ fn test_overriding_prefix() {
+ let bindings = [
+ KeyBinding::new("ctrl-x 0", ActionAlpha, Some("Workspace")),
+ KeyBinding::new("ctrl-x", ActionBeta, Some("vim_mode == normal")),
+ ];
+ let mut keymap = Keymap::default();
+ keymap.add_bindings(bindings);
+
+ let matched = keymap.bindings_for_input(
+ &[Keystroke::parse("ctrl-x")].map(Result::unwrap),
+ &[
+ KeyContext::parse("Workspace"),
+ KeyContext::parse("Pane"),
+ KeyContext::parse("Editor vim_mode=normal"),
+ ]
+ .map(Result::unwrap),
+ );
+ assert_eq!(matched.0.len(), 1);
+ assert!(matched.0[0].action.partial_eq(&ActionBeta));
+ assert!(!matched.1);
}
#[test]
@@ -456,7 +629,7 @@ mod tests {
];
let mut keymap = Keymap::default();
- keymap.add_bindings(bindings.clone());
+ keymap.add_bindings(bindings);
assert_bindings(&keymap, &ActionAlpha {}, &["ctrl-a"]);
assert_bindings(&keymap, &ActionBeta {}, &[]);
@@ -30,11 +30,8 @@ impl Clone for KeyBinding {
impl KeyBinding {
/// Construct a new keybinding from the given data. Panics on parse error.
pub fn new<A: Action>(keystrokes: &str, action: A, context: Option<&str>) -> Self {
- let context_predicate = if let Some(context) = context {
- Some(KeyBindingContextPredicate::parse(context).unwrap().into())
- } else {
- None
- };
+ let context_predicate =
+ context.map(|context| KeyBindingContextPredicate::parse(context).unwrap().into());
Self::load(keystrokes, Box::new(action), context_predicate, None, None).unwrap()
}
@@ -53,10 +50,10 @@ impl KeyBinding {
if let Some(equivalents) = key_equivalents {
for keystroke in keystrokes.iter_mut() {
- if keystroke.key.chars().count() == 1 {
- if let Some(key) = equivalents.get(&keystroke.key.chars().next().unwrap()) {
- keystroke.key = key.to_string();
- }
+ if keystroke.key.chars().count() == 1
+ && let Some(key) = equivalents.get(&keystroke.key.chars().next().unwrap())
+ {
+ keystroke.key = key.to_string();
}
}
}
@@ -178,7 +178,7 @@ pub enum KeyBindingContextPredicate {
NotEqual(SharedString, SharedString),
/// A predicate that will match a given predicate appearing below another predicate.
/// in the element tree
- Child(
+ Descendant(
Box<KeyBindingContextPredicate>,
Box<KeyBindingContextPredicate>,
),
@@ -203,7 +203,7 @@ impl fmt::Display for KeyBindingContextPredicate {
Self::Equal(left, right) => write!(f, "{} == {}", left, right),
Self::NotEqual(left, right) => write!(f, "{} != {}", left, right),
Self::Not(pred) => write!(f, "!{}", pred),
- Self::Child(parent, child) => write!(f, "{} > {}", parent, child),
+ Self::Descendant(parent, child) => write!(f, "{} > {}", parent, child),
Self::And(left, right) => write!(f, "({} && {})", left, right),
Self::Or(left, right) => write!(f, "({} || {})", left, right),
}
@@ -249,8 +249,25 @@ impl KeyBindingContextPredicate {
}
}
+ /// Find the deepest depth at which the predicate matches.
+ pub fn depth_of(&self, contexts: &[KeyContext]) -> Option<usize> {
+ for depth in (0..=contexts.len()).rev() {
+ let context_slice = &contexts[0..depth];
+ if self.eval_inner(context_slice, contexts) {
+ return Some(depth);
+ }
+ }
+ None
+ }
+
+ /// Eval a predicate against a set of contexts, arranged from lowest to highest.
+ #[allow(unused)]
+ pub(crate) fn eval(&self, contexts: &[KeyContext]) -> bool {
+ self.eval_inner(contexts, contexts)
+ }
+
/// Eval a predicate against a set of contexts, arranged from lowest to highest.
- pub fn eval(&self, contexts: &[KeyContext]) -> bool {
+ pub fn eval_inner(&self, contexts: &[KeyContext], all_contexts: &[KeyContext]) -> bool {
let Some(context) = contexts.last() else {
return false;
};
@@ -264,12 +281,38 @@ impl KeyBindingContextPredicate {
.get(left)
.map(|value| value != right)
.unwrap_or(true),
- Self::Not(pred) => !pred.eval(contexts),
- Self::Child(parent, child) => {
- parent.eval(&contexts[..contexts.len() - 1]) && child.eval(contexts)
+ Self::Not(pred) => {
+ for i in 0..all_contexts.len() {
+ if pred.eval_inner(&all_contexts[..=i], all_contexts) {
+ return false;
+ }
+ }
+ true
+ }
+ // Workspace > Pane > Editor
+ //
+ // Pane > (Pane > Editor) // should match?
+ // (Pane > Pane) > Editor // should not match?
+ // Pane > !Workspace <-- should match?
+ // !Workspace <-- shouldn't match?
+ Self::Descendant(parent, child) => {
+ for i in 0..contexts.len() - 1 {
+ // [Workspace > Pane], [Editor]
+ if parent.eval_inner(&contexts[..=i], all_contexts) {
+ if !child.eval_inner(&contexts[i + 1..], &contexts[i + 1..]) {
+ return false;
+ }
+ return true;
+ }
+ }
+ false
+ }
+ Self::And(left, right) => {
+ left.eval_inner(contexts, all_contexts) && right.eval_inner(contexts, all_contexts)
+ }
+ Self::Or(left, right) => {
+ left.eval_inner(contexts, all_contexts) || right.eval_inner(contexts, all_contexts)
}
- Self::And(left, right) => left.eval(contexts) && right.eval(contexts),
- Self::Or(left, right) => left.eval(contexts) || right.eval(contexts),
}
}
@@ -285,7 +328,7 @@ impl KeyBindingContextPredicate {
}
match other {
- KeyBindingContextPredicate::Child(_, child) => self.is_superset(child),
+ KeyBindingContextPredicate::Descendant(_, child) => self.is_superset(child),
KeyBindingContextPredicate::And(left, right) => {
self.is_superset(left) || self.is_superset(right)
}
@@ -375,7 +418,7 @@ impl KeyBindingContextPredicate {
}
fn new_child(self, other: Self) -> Result<Self> {
- Ok(Self::Child(Box::new(self), Box::new(other)))
+ Ok(Self::Descendant(Box::new(self), Box::new(other)))
}
fn new_eq(self, other: Self) -> Result<Self> {
@@ -418,6 +461,8 @@ fn skip_whitespace(source: &str) -> &str {
#[cfg(test)]
mod tests {
+ use core::slice;
+
use super::*;
use crate as gpui;
use KeyBindingContextPredicate::*;
@@ -598,4 +643,118 @@ mod tests {
assert_eq!(a.is_superset(&b), result, "({a:?}).is_superset({b:?})");
}
}
+
+ #[test]
+ fn test_child_operator() {
+ let predicate = KeyBindingContextPredicate::parse("parent > child").unwrap();
+
+ let parent_context = KeyContext::try_from("parent").unwrap();
+ let child_context = KeyContext::try_from("child").unwrap();
+
+ let contexts = vec![parent_context.clone(), child_context.clone()];
+ assert!(predicate.eval(&contexts));
+
+ let grandparent_context = KeyContext::try_from("grandparent").unwrap();
+
+ let contexts = vec![
+ grandparent_context,
+ parent_context.clone(),
+ child_context.clone(),
+ ];
+ assert!(predicate.eval(&contexts));
+
+ let other_context = KeyContext::try_from("other").unwrap();
+
+ let contexts = vec![other_context.clone(), child_context.clone()];
+ assert!(!predicate.eval(&contexts));
+
+ let contexts = vec![parent_context.clone(), other_context, child_context.clone()];
+ assert!(predicate.eval(&contexts));
+
+ assert!(!predicate.eval(&[]));
+ assert!(!predicate.eval(slice::from_ref(&child_context)));
+ assert!(!predicate.eval(&[parent_context]));
+
+ let zany_predicate = KeyBindingContextPredicate::parse("child > child").unwrap();
+ assert!(!zany_predicate.eval(slice::from_ref(&child_context)));
+ assert!(zany_predicate.eval(&[child_context.clone(), child_context]));
+ }
+
+ #[test]
+ fn test_not_operator() {
+ let not_predicate = KeyBindingContextPredicate::parse("!editor").unwrap();
+ let editor_context = KeyContext::try_from("editor").unwrap();
+ let workspace_context = KeyContext::try_from("workspace").unwrap();
+ let parent_context = KeyContext::try_from("parent").unwrap();
+ let child_context = KeyContext::try_from("child").unwrap();
+
+ assert!(not_predicate.eval(slice::from_ref(&workspace_context)));
+ assert!(!not_predicate.eval(slice::from_ref(&editor_context)));
+ assert!(!not_predicate.eval(&[editor_context.clone(), workspace_context.clone()]));
+ assert!(!not_predicate.eval(&[workspace_context.clone(), editor_context.clone()]));
+
+ let complex_not = KeyBindingContextPredicate::parse("!editor && workspace").unwrap();
+ assert!(complex_not.eval(slice::from_ref(&workspace_context)));
+ assert!(!complex_not.eval(&[editor_context.clone(), workspace_context.clone()]));
+
+ let not_mode_predicate = KeyBindingContextPredicate::parse("!(mode == full)").unwrap();
+ let mut mode_context = KeyContext::default();
+ mode_context.set("mode", "full");
+ assert!(!not_mode_predicate.eval(&[mode_context.clone()]));
+
+ let mut other_mode_context = KeyContext::default();
+ other_mode_context.set("mode", "partial");
+ assert!(not_mode_predicate.eval(&[other_mode_context]));
+
+ let not_descendant = KeyBindingContextPredicate::parse("!(parent > child)").unwrap();
+ assert!(not_descendant.eval(slice::from_ref(&parent_context)));
+ assert!(not_descendant.eval(slice::from_ref(&child_context)));
+ assert!(!not_descendant.eval(&[parent_context.clone(), child_context.clone()]));
+
+ let not_descendant = KeyBindingContextPredicate::parse("parent > !child").unwrap();
+ assert!(!not_descendant.eval(slice::from_ref(&parent_context)));
+ assert!(!not_descendant.eval(slice::from_ref(&child_context)));
+ assert!(!not_descendant.eval(&[parent_context, child_context]));
+
+ let double_not = KeyBindingContextPredicate::parse("!!editor").unwrap();
+ assert!(double_not.eval(slice::from_ref(&editor_context)));
+ assert!(!double_not.eval(slice::from_ref(&workspace_context)));
+
+ // Test complex descendant cases
+ let workspace_context = KeyContext::try_from("Workspace").unwrap();
+ let pane_context = KeyContext::try_from("Pane").unwrap();
+ let editor_context = KeyContext::try_from("Editor").unwrap();
+
+ // Workspace > Pane > Editor
+ let workspace_pane_editor = vec![
+ workspace_context.clone(),
+ pane_context.clone(),
+ editor_context.clone(),
+ ];
+
+ // Pane > (Pane > Editor) - should not match
+ let pane_pane_editor = KeyBindingContextPredicate::parse("Pane > (Pane > Editor)").unwrap();
+ assert!(!pane_pane_editor.eval(&workspace_pane_editor));
+
+ let workspace_pane_editor_predicate =
+ KeyBindingContextPredicate::parse("Workspace > Pane > Editor").unwrap();
+ assert!(workspace_pane_editor_predicate.eval(&workspace_pane_editor));
+
+ // (Pane > Pane) > Editor - should not match
+ let pane_pane_then_editor =
+ KeyBindingContextPredicate::parse("(Pane > Pane) > Editor").unwrap();
+ assert!(!pane_pane_then_editor.eval(&workspace_pane_editor));
+
+ // Pane > !Workspace - should match
+ let pane_not_workspace = KeyBindingContextPredicate::parse("Pane > !Workspace").unwrap();
+ assert!(pane_not_workspace.eval(&[pane_context.clone(), editor_context.clone()]));
+ assert!(!pane_not_workspace.eval(&[pane_context.clone(), workspace_context.clone()]));
+
+ // !Workspace - shouldn't match when Workspace is in the context
+ let not_workspace = KeyBindingContextPredicate::parse("!Workspace").unwrap();
+ assert!(!not_workspace.eval(slice::from_ref(&workspace_context)));
+ assert!(not_workspace.eval(slice::from_ref(&pane_context)));
+ assert!(not_workspace.eval(slice::from_ref(&editor_context)));
+ assert!(!not_workspace.eval(&workspace_pane_editor));
+ }
}
@@ -278,7 +278,7 @@ impl PathBuilder {
options: &StrokeOptions,
) -> Result<Path<Pixels>, Error> {
let path = if let Some(dash_array) = dash_array {
- let measurements = lyon::algorithms::measure::PathMeasurements::from_path(&path, 0.01);
+ let measurements = lyon::algorithms::measure::PathMeasurements::from_path(path, 0.01);
let mut sampler = measurements
.create_sampler(path, lyon::algorithms::measure::SampleType::Normalized);
let mut builder = lyon::path::Path::builder();
@@ -336,7 +336,10 @@ impl PathBuilder {
let v1 = buf.vertices[i1];
let v2 = buf.vertices[i2];
- path.push_triangle((v0.into(), v1.into(), v2.into()));
+ path.push_triangle(
+ (v0.into(), v1.into(), v2.into()),
+ (point(0., 1.), point(0., 1.), point(0., 1.)),
+ );
}
path
@@ -13,8 +13,7 @@ mod mac;
any(target_os = "linux", target_os = "freebsd"),
any(feature = "x11", feature = "wayland")
),
- target_os = "windows",
- feature = "macos-blade"
+ all(target_os = "macos", feature = "macos-blade")
))]
mod blade;
@@ -85,7 +84,7 @@ pub(crate) use test::*;
pub(crate) use windows::*;
#[cfg(any(test, feature = "test-support"))]
-pub use test::{TestDispatcher, TestScreenCaptureSource};
+pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream};
/// Returns a background executor for the current platform.
pub fn background_executor() -> BackgroundExecutor {
@@ -189,13 +188,12 @@ pub(crate) trait Platform: 'static {
false
}
#[cfg(feature = "screen-capture")]
- fn screen_capture_sources(
- &self,
- ) -> oneshot::Receiver<Result<Vec<Box<dyn ScreenCaptureSource>>>>;
+ fn screen_capture_sources(&self)
+ -> oneshot::Receiver<Result<Vec<Rc<dyn ScreenCaptureSource>>>>;
#[cfg(not(feature = "screen-capture"))]
fn screen_capture_sources(
&self,
- ) -> oneshot::Receiver<anyhow::Result<Vec<Box<dyn ScreenCaptureSource>>>> {
+ ) -> oneshot::Receiver<anyhow::Result<Vec<Rc<dyn ScreenCaptureSource>>>> {
let (sources_tx, sources_rx) = oneshot::channel();
sources_tx
.send(Err(anyhow::anyhow!(
@@ -222,7 +220,11 @@ pub(crate) trait Platform: 'static {
&self,
options: PathPromptOptions,
) -> oneshot::Receiver<Result<Option<Vec<PathBuf>>>>;
- fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver<Result<Option<PathBuf>>>;
+ fn prompt_for_new_path(
+ &self,
+ directory: &Path,
+ suggested_name: Option<&str>,
+ ) -> oneshot::Receiver<Result<Option<PathBuf>>>;
fn can_select_mixed_files_and_dirs(&self) -> bool;
fn reveal_path(&self, path: &Path);
fn open_with_system(&self, path: &Path);
@@ -293,10 +295,23 @@ pub trait PlatformDisplay: Send + Sync + Debug {
}
}
+/// Metadata for a given [ScreenCaptureSource]
+#[derive(Clone)]
+pub struct SourceMetadata {
+ /// Opaque identifier of this screen.
+ pub id: u64,
+ /// Human-readable label for this source.
+ pub label: Option<SharedString>,
+ /// Whether this source is the main display.
+ pub is_main: Option<bool>,
+ /// Video resolution of this source.
+ pub resolution: Size<DevicePixels>,
+}
+
/// A source of on-screen video content that can be captured.
pub trait ScreenCaptureSource {
- /// Returns the video resolution of this source.
- fn resolution(&self) -> Result<Size<DevicePixels>>;
+ /// Returns metadata for this source.
+ fn metadata(&self) -> Result<SourceMetadata>;
/// Start capture video from this source, invoking the given callback
/// with each frame.
@@ -308,7 +323,10 @@ pub trait ScreenCaptureSource {
}
/// A video stream captured from a screen.
-pub trait ScreenCaptureStream {}
+pub trait ScreenCaptureStream {
+ /// Returns metadata for this source.
+ fn metadata(&self) -> Result<SourceMetadata>;
+}
/// A frame of video captured from a screen.
pub struct ScreenCaptureFrame(pub PlatformScreenCaptureFrame);
@@ -433,6 +451,8 @@ impl Tiling {
#[derive(Debug, Copy, Clone, Eq, PartialEq, Default)]
pub(crate) struct RequestFrameOptions {
pub(crate) require_presentation: bool,
+ /// Force refresh of all rendering states when true
+ pub(crate) force_render: bool,
}
pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
@@ -572,7 +592,7 @@ impl PlatformTextSystem for NoopTextSystem {
}
fn font_id(&self, _descriptor: &Font) -> Result<FontId> {
- return Ok(FontId(1));
+ Ok(FontId(1))
}
fn font_metrics(&self, _font_id: FontId) -> FontMetrics {
@@ -653,7 +673,7 @@ impl PlatformTextSystem for NoopTextSystem {
}
}
let mut runs = Vec::default();
- if glyphs.len() > 0 {
+ if !glyphs.is_empty() {
runs.push(ShapedRun {
font_id: FontId(0),
glyphs,
@@ -1258,7 +1278,7 @@ pub enum WindowBackgroundAppearance {
}
/// The options that can be configured for a file dialog prompt
-#[derive(Copy, Clone, Debug)]
+#[derive(Clone, Debug)]
pub struct PathPromptOptions {
/// Should the prompt allow files to be selected?
pub files: bool,
@@ -1266,6 +1286,8 @@ pub struct PathPromptOptions {
pub directories: bool,
/// Should the prompt allow multiple files to be selected?
pub multiple: bool,
+ /// The prompt to show to a user when selecting a path
+ pub prompt: Option<SharedString>,
}
/// What kind of prompt styling to show
@@ -1486,7 +1508,7 @@ impl ClipboardItem {
for entry in self.entries.iter() {
if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry {
- answer.push_str(&text);
+ answer.push_str(text);
any_entries = true;
}
}
@@ -20,6 +20,34 @@ impl Menu {
}
}
+/// OS menus are menus that are recognized by the operating system
+/// This allows the operating system to provide specialized items for
+/// these menus
+pub struct OsMenu {
+ /// The name of the menu
+ pub name: SharedString,
+
+ /// The type of menu
+ pub menu_type: SystemMenuType,
+}
+
+impl OsMenu {
+ /// Create an OwnedOsMenu from this OsMenu
+ pub fn owned(self) -> OwnedOsMenu {
+ OwnedOsMenu {
+ name: self.name.to_string().into(),
+ menu_type: self.menu_type,
+ }
+ }
+}
+
+/// The type of system menu
+#[derive(Copy, Clone, Eq, PartialEq)]
+pub enum SystemMenuType {
+ /// The 'Services' menu in the Application menu on macOS
+ Services,
+}
+
/// The different kinds of items that can be in a menu
pub enum MenuItem {
/// A separator between items
@@ -28,6 +56,9 @@ pub enum MenuItem {
/// A submenu
Submenu(Menu),
+ /// A menu, managed by the system (for example, the Services menu on macOS)
+ SystemMenu(OsMenu),
+
/// An action that can be performed
Action {
/// The name of this menu item
@@ -53,6 +84,14 @@ impl MenuItem {
Self::Submenu(menu)
}
+ /// Creates a new submenu that is populated by the OS
+ pub fn os_submenu(name: impl Into<SharedString>, menu_type: SystemMenuType) -> Self {
+ Self::SystemMenu(OsMenu {
+ name: name.into(),
+ menu_type,
+ })
+ }
+
/// Creates a new menu item that invokes an action
pub fn action(name: impl Into<SharedString>, action: impl Action) -> Self {
Self::Action {
@@ -89,10 +128,23 @@ impl MenuItem {
action,
os_action,
},
+ MenuItem::SystemMenu(os_menu) => OwnedMenuItem::SystemMenu(os_menu.owned()),
}
}
}
+/// OS menus are menus that are recognized by the operating system
+/// This allows the operating system to provide specialized items for
+/// these menus
+#[derive(Clone)]
+pub struct OwnedOsMenu {
+ /// The name of the menu
+ pub name: SharedString,
+
+ /// The type of menu
+ pub menu_type: SystemMenuType,
+}
+
/// A menu of the application, either a main menu or a submenu
#[derive(Clone)]
pub struct OwnedMenu {
@@ -111,6 +163,9 @@ pub enum OwnedMenuItem {
/// A submenu
Submenu(OwnedMenu),
+ /// A menu, managed by the system (for example, the Services menu on macOS)
+ SystemMenu(OwnedOsMenu),
+
/// An action that can be performed
Action {
/// The name of this menu item
@@ -139,6 +194,7 @@ impl Clone for OwnedMenuItem {
action: action.boxed_clone(),
os_action: *os_action,
},
+ OwnedMenuItem::SystemMenu(os_menu) => OwnedMenuItem::SystemMenu(os_menu.clone()),
}
}
}
@@ -38,8 +38,6 @@ impl BladeAtlasState {
}
pub struct BladeTextureInfo {
- #[allow(dead_code)]
- pub size: gpu::Extent,
pub raw_view: gpu::TextureView,
}
@@ -63,15 +61,6 @@ impl BladeAtlas {
self.0.lock().destroy();
}
- #[allow(dead_code)]
- pub(crate) fn clear_textures(&self, texture_kind: AtlasTextureKind) {
- let mut lock = self.0.lock();
- let textures = &mut lock.storage[texture_kind];
- for texture in textures.iter_mut() {
- texture.clear();
- }
- }
-
pub fn before_frame(&self, gpu_encoder: &mut gpu::CommandEncoder) {
let mut lock = self.0.lock();
lock.flush(gpu_encoder);
@@ -85,13 +74,7 @@ impl BladeAtlas {
pub fn get_texture_info(&self, id: AtlasTextureId) -> BladeTextureInfo {
let lock = self.0.lock();
let texture = &lock.storage[id];
- let size = texture.allocator.size();
BladeTextureInfo {
- size: gpu::Extent {
- width: size.width as u32,
- height: size.height as u32,
- depth: 1,
- },
raw_view: texture.raw_view,
}
}
@@ -334,10 +317,6 @@ struct BladeAtlasTexture {
}
impl BladeAtlasTexture {
- fn clear(&mut self) {
- self.allocator.clear();
- }
-
fn allocate(&mut self, size: Size<DevicePixels>) -> Option<AtlasTile> {
let allocation = self.allocator.allocate(size.into())?;
let tile = AtlasTile {
@@ -49,7 +49,7 @@ fn parse_pci_id(id: &str) -> anyhow::Result<u32> {
"Expected a 4 digit PCI ID in hexadecimal format"
);
- return u32::from_str_radix(id, 16).context("parsing PCI ID as hex");
+ u32::from_str_radix(id, 16).context("parsing PCI ID as hex")
}
#[cfg(test)]
@@ -3,15 +3,15 @@
use super::{BladeAtlas, BladeContext};
use crate::{
- Background, Bounds, ContentMask, DevicePixels, GpuSpecs, MonochromeSprite, PathVertex,
- PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, Underline,
+ Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, Path, Point, PolychromeSprite,
+ PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, Underline,
};
-use blade_graphics::{self as gpu};
+use blade_graphics as gpu;
use blade_util::{BufferBelt, BufferBeltDescriptor};
use bytemuck::{Pod, Zeroable};
#[cfg(target_os = "macos")]
use media::core_video::CVMetalTextureCache;
-use std::{mem, sync::Arc};
+use std::sync::Arc;
const MAX_FRAME_TIME_MS: u32 = 10000;
@@ -61,9 +61,16 @@ struct ShaderShadowsData {
}
#[derive(blade_macros::ShaderData)]
-struct ShaderPathsData {
+struct ShaderPathRasterizationData {
globals: GlobalParams,
b_path_vertices: gpu::BufferPiece,
+}
+
+#[derive(blade_macros::ShaderData)]
+struct ShaderPathsData {
+ globals: GlobalParams,
+ t_sprite: gpu::TextureView,
+ s_sprite: gpu::Sampler,
b_path_sprites: gpu::BufferPiece,
}
@@ -102,28 +109,21 @@ struct ShaderSurfacesData {
#[repr(C)]
struct PathSprite {
bounds: Bounds<ScaledPixels>,
- color: Background,
}
-/// Argument buffer layout for `draw_indirect` commands.
+#[derive(Clone, Debug)]
#[repr(C)]
-#[derive(Copy, Clone, Debug, Default, Pod, Zeroable)]
-pub struct DrawIndirectArgs {
- /// The number of vertices to draw.
- pub vertex_count: u32,
- /// The number of instances to draw.
- pub instance_count: u32,
- /// The Index of the first vertex to draw.
- pub first_vertex: u32,
- /// The instance ID of the first instance to draw.
- ///
- /// Has to be 0, unless [`Features::INDIRECT_FIRST_INSTANCE`](crate::Features::INDIRECT_FIRST_INSTANCE) is enabled.
- pub first_instance: u32,
+struct PathRasterizationVertex {
+ xy_position: Point<ScaledPixels>,
+ st_position: Point<f32>,
+ color: Background,
+ bounds: Bounds<ScaledPixels>,
}
struct BladePipelines {
quads: gpu::RenderPipeline,
shadows: gpu::RenderPipeline,
+ path_rasterization: gpu::RenderPipeline,
paths: gpu::RenderPipeline,
underlines: gpu::RenderPipeline,
mono_sprites: gpu::RenderPipeline,
@@ -132,7 +132,7 @@ struct BladePipelines {
}
impl BladePipelines {
- fn new(gpu: &gpu::Context, surface_info: gpu::SurfaceInfo, sample_count: u32) -> Self {
+ fn new(gpu: &gpu::Context, surface_info: gpu::SurfaceInfo, path_sample_count: u32) -> Self {
use gpu::ShaderData as _;
log::info!(
@@ -146,10 +146,7 @@ impl BladePipelines {
shader.check_struct_size::<SurfaceParams>();
shader.check_struct_size::<Quad>();
shader.check_struct_size::<Shadow>();
- assert_eq!(
- mem::size_of::<PathVertex<ScaledPixels>>(),
- shader.get_struct_size("PathVertex") as usize,
- );
+ shader.check_struct_size::<PathRasterizationVertex>();
shader.check_struct_size::<PathSprite>();
shader.check_struct_size::<Underline>();
shader.check_struct_size::<MonochromeSprite>();
@@ -180,10 +177,7 @@ impl BladePipelines {
depth_stencil: None,
fragment: Some(shader.at("fs_quad")),
color_targets,
- multisample_state: gpu::MultisampleState {
- sample_count,
- ..Default::default()
- },
+ multisample_state: gpu::MultisampleState::default(),
}),
shadows: gpu.create_render_pipeline(gpu::RenderPipelineDesc {
name: "shadows",
@@ -197,8 +191,33 @@ impl BladePipelines {
depth_stencil: None,
fragment: Some(shader.at("fs_shadow")),
color_targets,
+ multisample_state: gpu::MultisampleState::default(),
+ }),
+ path_rasterization: gpu.create_render_pipeline(gpu::RenderPipelineDesc {
+ name: "path_rasterization",
+ data_layouts: &[&ShaderPathRasterizationData::layout()],
+ vertex: shader.at("vs_path_rasterization"),
+ vertex_fetches: &[],
+ primitive: gpu::PrimitiveState {
+ topology: gpu::PrimitiveTopology::TriangleList,
+ ..Default::default()
+ },
+ depth_stencil: None,
+ fragment: Some(shader.at("fs_path_rasterization")),
+ // The original implementation was using ADDITIVE blende mode,
+ // I don't know why
+ // color_targets: &[gpu::ColorTargetState {
+ // format: PATH_TEXTURE_FORMAT,
+ // blend: Some(gpu::BlendState::ADDITIVE),
+ // write_mask: gpu::ColorWrites::default(),
+ // }],
+ color_targets: &[gpu::ColorTargetState {
+ format: surface_info.format,
+ blend: Some(gpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
+ write_mask: gpu::ColorWrites::default(),
+ }],
multisample_state: gpu::MultisampleState {
- sample_count,
+ sample_count: path_sample_count,
..Default::default()
},
}),
@@ -208,16 +227,20 @@ impl BladePipelines {
vertex: shader.at("vs_path"),
vertex_fetches: &[],
primitive: gpu::PrimitiveState {
- topology: gpu::PrimitiveTopology::TriangleList,
+ topology: gpu::PrimitiveTopology::TriangleStrip,
..Default::default()
},
depth_stencil: None,
fragment: Some(shader.at("fs_path")),
- color_targets,
- multisample_state: gpu::MultisampleState {
- sample_count,
- ..Default::default()
- },
+ color_targets: &[gpu::ColorTargetState {
+ format: surface_info.format,
+ blend: Some(gpu::BlendState {
+ color: gpu::BlendComponent::OVER,
+ alpha: gpu::BlendComponent::ADDITIVE,
+ }),
+ write_mask: gpu::ColorWrites::default(),
+ }],
+ multisample_state: gpu::MultisampleState::default(),
}),
underlines: gpu.create_render_pipeline(gpu::RenderPipelineDesc {
name: "underlines",
@@ -231,10 +254,7 @@ impl BladePipelines {
depth_stencil: None,
fragment: Some(shader.at("fs_underline")),
color_targets,
- multisample_state: gpu::MultisampleState {
- sample_count,
- ..Default::default()
- },
+ multisample_state: gpu::MultisampleState::default(),
}),
mono_sprites: gpu.create_render_pipeline(gpu::RenderPipelineDesc {
name: "mono-sprites",
@@ -248,10 +268,7 @@ impl BladePipelines {
depth_stencil: None,
fragment: Some(shader.at("fs_mono_sprite")),
color_targets,
- multisample_state: gpu::MultisampleState {
- sample_count,
- ..Default::default()
- },
+ multisample_state: gpu::MultisampleState::default(),
}),
poly_sprites: gpu.create_render_pipeline(gpu::RenderPipelineDesc {
name: "poly-sprites",
@@ -265,10 +282,7 @@ impl BladePipelines {
depth_stencil: None,
fragment: Some(shader.at("fs_poly_sprite")),
color_targets,
- multisample_state: gpu::MultisampleState {
- sample_count,
- ..Default::default()
- },
+ multisample_state: gpu::MultisampleState::default(),
}),
surfaces: gpu.create_render_pipeline(gpu::RenderPipelineDesc {
name: "surfaces",
@@ -282,10 +296,7 @@ impl BladePipelines {
depth_stencil: None,
fragment: Some(shader.at("fs_surface")),
color_targets,
- multisample_state: gpu::MultisampleState {
- sample_count,
- ..Default::default()
- },
+ multisample_state: gpu::MultisampleState::default(),
}),
}
}
@@ -293,6 +304,7 @@ impl BladePipelines {
fn destroy(&mut self, gpu: &gpu::Context) {
gpu.destroy_render_pipeline(&mut self.quads);
gpu.destroy_render_pipeline(&mut self.shadows);
+ gpu.destroy_render_pipeline(&mut self.path_rasterization);
gpu.destroy_render_pipeline(&mut self.paths);
gpu.destroy_render_pipeline(&mut self.underlines);
gpu.destroy_render_pipeline(&mut self.mono_sprites);
@@ -322,9 +334,11 @@ pub struct BladeRenderer {
atlas_sampler: gpu::Sampler,
#[cfg(target_os = "macos")]
core_video_texture_cache: CVMetalTextureCache,
- sample_count: u32,
- texture_msaa: Option<gpu::Texture>,
- texture_view_msaa: Option<gpu::TextureView>,
+ path_sample_count: u32,
+ path_intermediate_texture: gpu::Texture,
+ path_intermediate_texture_view: gpu::TextureView,
+ path_intermediate_msaa_texture: Option<gpu::Texture>,
+ path_intermediate_msaa_texture_view: Option<gpu::TextureView>,
}
impl BladeRenderer {
@@ -333,18 +347,6 @@ impl BladeRenderer {
window: &I,
config: BladeSurfaceConfig,
) -> anyhow::Result<Self> {
- // workaround for https://github.com/zed-industries/zed/issues/26143
- let sample_count = std::env::var("ZED_SAMPLE_COUNT")
- .ok()
- .or_else(|| std::env::var("ZED_PATH_SAMPLE_COUNT").ok())
- .and_then(|v| v.parse().ok())
- .or_else(|| {
- [4, 2, 1]
- .into_iter()
- .find(|count| context.gpu.supports_texture_sample_count(*count))
- })
- .unwrap_or(1);
-
let surface_config = gpu::SurfaceConfig {
size: config.size,
usage: gpu::TextureUsage::TARGET,
@@ -358,21 +360,21 @@ impl BladeRenderer {
.create_surface_configured(window, surface_config)
.map_err(|err| anyhow::anyhow!("Failed to create surface: {err:?}"))?;
- let (texture_msaa, texture_view_msaa) = create_msaa_texture_if_needed(
- &context.gpu,
- surface.info().format,
- config.size.width,
- config.size.height,
- sample_count,
- )
- .unzip();
-
let command_encoder = context.gpu.create_command_encoder(gpu::CommandEncoderDesc {
name: "main",
buffer_count: 2,
});
-
- let pipelines = BladePipelines::new(&context.gpu, surface.info(), sample_count);
+ // workaround for https://github.com/zed-industries/zed/issues/26143
+ let path_sample_count = std::env::var("ZED_PATH_SAMPLE_COUNT")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .or_else(|| {
+ [4, 2, 1]
+ .into_iter()
+ .find(|count| context.gpu.supports_texture_sample_count(*count))
+ })
+ .unwrap_or(1);
+ let pipelines = BladePipelines::new(&context.gpu, surface.info(), path_sample_count);
let instance_belt = BufferBelt::new(BufferBeltDescriptor {
memory: gpu::Memory::Shared,
min_chunk_size: 0x1000,
@@ -380,12 +382,29 @@ impl BladeRenderer {
});
let atlas = Arc::new(BladeAtlas::new(&context.gpu));
let atlas_sampler = context.gpu.create_sampler(gpu::SamplerDesc {
- name: "atlas",
+ name: "path rasterization sampler",
mag_filter: gpu::FilterMode::Linear,
min_filter: gpu::FilterMode::Linear,
..Default::default()
});
+ let (path_intermediate_texture, path_intermediate_texture_view) =
+ create_path_intermediate_texture(
+ &context.gpu,
+ surface.info().format,
+ config.size.width,
+ config.size.height,
+ );
+ let (path_intermediate_msaa_texture, path_intermediate_msaa_texture_view) =
+ create_msaa_texture_if_needed(
+ &context.gpu,
+ surface.info().format,
+ config.size.width,
+ config.size.height,
+ path_sample_count,
+ )
+ .unzip();
+
#[cfg(target_os = "macos")]
let core_video_texture_cache = unsafe {
CVMetalTextureCache::new(
@@ -406,31 +425,33 @@ impl BladeRenderer {
atlas_sampler,
#[cfg(target_os = "macos")]
core_video_texture_cache,
- sample_count,
- texture_msaa,
- texture_view_msaa,
+ path_sample_count,
+ path_intermediate_texture,
+ path_intermediate_texture_view,
+ path_intermediate_msaa_texture,
+ path_intermediate_msaa_texture_view,
})
}
fn wait_for_gpu(&mut self) {
- if let Some(last_sp) = self.last_sync_point.take() {
- if !self.gpu.wait_for(&last_sp, MAX_FRAME_TIME_MS) {
- log::error!("GPU hung");
- #[cfg(target_os = "linux")]
- if self.gpu.device_information().driver_name == "radv" {
- log::error!(
- "there's a known bug with amdgpu/radv, try setting ZED_PATH_SAMPLE_COUNT=0 as a workaround"
- );
- log::error!(
- "if that helps you're running into https://github.com/zed-industries/zed/issues/26143"
- );
- }
+ if let Some(last_sp) = self.last_sync_point.take()
+ && !self.gpu.wait_for(&last_sp, MAX_FRAME_TIME_MS)
+ {
+ log::error!("GPU hung");
+ #[cfg(target_os = "linux")]
+ if self.gpu.device_information().driver_name == "radv" {
log::error!(
- "your device information is: {:?}",
- self.gpu.device_information()
+ "there's a known bug with amdgpu/radv, try setting ZED_PATH_SAMPLE_COUNT=0 as a workaround"
+ );
+ log::error!(
+ "if that helps you're running into https://github.com/zed-industries/zed/issues/26143"
);
- while !self.gpu.wait_for(&last_sp, MAX_FRAME_TIME_MS) {}
}
+ log::error!(
+ "your device information is: {:?}",
+ self.gpu.device_information()
+ );
+ while !self.gpu.wait_for(&last_sp, MAX_FRAME_TIME_MS) {}
}
}
@@ -461,24 +482,35 @@ impl BladeRenderer {
self.surface_config.size = gpu_size;
self.gpu
.reconfigure_surface(&mut self.surface, self.surface_config);
-
- if let Some(texture_msaa) = self.texture_msaa {
- self.gpu.destroy_texture(texture_msaa);
+ self.gpu.destroy_texture(self.path_intermediate_texture);
+ self.gpu
+ .destroy_texture_view(self.path_intermediate_texture_view);
+ if let Some(msaa_texture) = self.path_intermediate_msaa_texture {
+ self.gpu.destroy_texture(msaa_texture);
}
- if let Some(texture_view_msaa) = self.texture_view_msaa {
- self.gpu.destroy_texture_view(texture_view_msaa);
+ if let Some(msaa_view) = self.path_intermediate_msaa_texture_view {
+ self.gpu.destroy_texture_view(msaa_view);
}
-
- let (texture_msaa, texture_view_msaa) = create_msaa_texture_if_needed(
- &self.gpu,
- self.surface.info().format,
- gpu_size.width,
- gpu_size.height,
- self.sample_count,
- )
- .unzip();
- self.texture_msaa = texture_msaa;
- self.texture_view_msaa = texture_view_msaa;
+ let (path_intermediate_texture, path_intermediate_texture_view) =
+ create_path_intermediate_texture(
+ &self.gpu,
+ self.surface.info().format,
+ gpu_size.width,
+ gpu_size.height,
+ );
+ self.path_intermediate_texture = path_intermediate_texture;
+ self.path_intermediate_texture_view = path_intermediate_texture_view;
+ let (path_intermediate_msaa_texture, path_intermediate_msaa_texture_view) =
+ create_msaa_texture_if_needed(
+ &self.gpu,
+ self.surface.info().format,
+ gpu_size.width,
+ gpu_size.height,
+ self.path_sample_count,
+ )
+ .unzip();
+ self.path_intermediate_msaa_texture = path_intermediate_msaa_texture;
+ self.path_intermediate_msaa_texture_view = path_intermediate_msaa_texture_view;
}
}
@@ -489,7 +521,8 @@ impl BladeRenderer {
self.gpu
.reconfigure_surface(&mut self.surface, self.surface_config);
self.pipelines.destroy(&self.gpu);
- self.pipelines = BladePipelines::new(&self.gpu, self.surface.info(), self.sample_count);
+ self.pipelines =
+ BladePipelines::new(&self.gpu, self.surface.info(), self.path_sample_count);
}
}
@@ -527,6 +560,67 @@ impl BladeRenderer {
objc2::rc::Retained::as_ptr(&self.surface.metal_layer()) as *mut _
}
+ #[profiling::function]
+ fn draw_paths_to_intermediate(
+ &mut self,
+ paths: &[Path<ScaledPixels>],
+ width: f32,
+ height: f32,
+ ) {
+ self.command_encoder
+ .init_texture(self.path_intermediate_texture);
+ if let Some(msaa_texture) = self.path_intermediate_msaa_texture {
+ self.command_encoder.init_texture(msaa_texture);
+ }
+
+ let target = if let Some(msaa_view) = self.path_intermediate_msaa_texture_view {
+ gpu::RenderTarget {
+ view: msaa_view,
+ init_op: gpu::InitOp::Clear(gpu::TextureColor::TransparentBlack),
+ finish_op: gpu::FinishOp::ResolveTo(self.path_intermediate_texture_view),
+ }
+ } else {
+ gpu::RenderTarget {
+ view: self.path_intermediate_texture_view,
+ init_op: gpu::InitOp::Clear(gpu::TextureColor::TransparentBlack),
+ finish_op: gpu::FinishOp::Store,
+ }
+ };
+ if let mut pass = self.command_encoder.render(
+ "rasterize paths",
+ gpu::RenderTargetSet {
+ colors: &[target],
+ depth_stencil: None,
+ },
+ ) {
+ let globals = GlobalParams {
+ viewport_size: [width, height],
+ premultiplied_alpha: 0,
+ pad: 0,
+ };
+ let mut encoder = pass.with(&self.pipelines.path_rasterization);
+
+ let mut vertices = Vec::new();
+ for path in paths {
+ vertices.extend(path.vertices.iter().map(|v| PathRasterizationVertex {
+ xy_position: v.xy_position,
+ st_position: v.st_position,
+ color: path.color,
+ bounds: path.clipped_bounds(),
+ }));
+ }
+ let vertex_buf = unsafe { self.instance_belt.alloc_typed(&vertices, &self.gpu) };
+ encoder.bind(
+ 0,
+ &ShaderPathRasterizationData {
+ globals,
+ b_path_vertices: vertex_buf,
+ },
+ );
+ encoder.draw(0, vertices.len() as u32, 0, 1);
+ }
+ }
+
pub fn destroy(&mut self) {
self.wait_for_gpu();
self.atlas.destroy();
@@ -535,11 +629,14 @@ impl BladeRenderer {
self.gpu.destroy_command_encoder(&mut self.command_encoder);
self.pipelines.destroy(&self.gpu);
self.gpu.destroy_surface(&mut self.surface);
- if let Some(texture_msaa) = self.texture_msaa {
- self.gpu.destroy_texture(texture_msaa);
+ self.gpu.destroy_texture(self.path_intermediate_texture);
+ self.gpu
+ .destroy_texture_view(self.path_intermediate_texture_view);
+ if let Some(msaa_texture) = self.path_intermediate_msaa_texture {
+ self.gpu.destroy_texture(msaa_texture);
}
- if let Some(texture_view_msaa) = self.texture_view_msaa {
- self.gpu.destroy_texture_view(texture_view_msaa);
+ if let Some(msaa_view) = self.path_intermediate_msaa_texture_view {
+ self.gpu.destroy_texture_view(msaa_view);
}
}
@@ -551,10 +648,6 @@ impl BladeRenderer {
profiling::scope!("acquire frame");
self.surface.acquire_frame()
};
- let frame_view = frame.texture_view();
- if let Some(texture_msaa) = self.texture_msaa {
- self.command_encoder.init_texture(texture_msaa);
- }
self.command_encoder.init_texture(frame.texture());
let globals = GlobalParams {
@@ -569,253 +662,245 @@ impl BladeRenderer {
pad: 0,
};
- let target = if let Some(texture_view_msaa) = self.texture_view_msaa {
- gpu::RenderTarget {
- view: texture_view_msaa,
- init_op: gpu::InitOp::Clear(gpu::TextureColor::TransparentBlack),
- finish_op: gpu::FinishOp::ResolveTo(frame_view),
- }
- } else {
- gpu::RenderTarget {
- view: frame_view,
- init_op: gpu::InitOp::Clear(gpu::TextureColor::TransparentBlack),
- finish_op: gpu::FinishOp::Store,
- }
- };
-
- // draw to the target texture
- if let mut pass = self.command_encoder.render(
+ let mut pass = self.command_encoder.render(
"main",
gpu::RenderTargetSet {
- colors: &[target],
+ colors: &[gpu::RenderTarget {
+ view: frame.texture_view(),
+ init_op: gpu::InitOp::Clear(gpu::TextureColor::TransparentBlack),
+ finish_op: gpu::FinishOp::Store,
+ }],
depth_stencil: None,
},
- ) {
- profiling::scope!("render pass");
- for batch in scene.batches() {
- match batch {
- PrimitiveBatch::Quads(quads) => {
- let instance_buf =
- unsafe { self.instance_belt.alloc_typed(quads, &self.gpu) };
- let mut encoder = pass.with(&self.pipelines.quads);
- encoder.bind(
- 0,
- &ShaderQuadsData {
- globals,
- b_quads: instance_buf,
- },
- );
- encoder.draw(0, 4, 0, quads.len() as u32);
- }
- PrimitiveBatch::Shadows(shadows) => {
- let instance_buf =
- unsafe { self.instance_belt.alloc_typed(shadows, &self.gpu) };
- let mut encoder = pass.with(&self.pipelines.shadows);
- encoder.bind(
- 0,
- &ShaderShadowsData {
- globals,
- b_shadows: instance_buf,
- },
- );
- encoder.draw(0, 4, 0, shadows.len() as u32);
- }
- PrimitiveBatch::Paths(paths) => {
- let mut encoder = pass.with(&self.pipelines.paths);
-
- let mut vertices = Vec::new();
- let mut sprites = Vec::with_capacity(paths.len());
- let mut draw_indirect_commands = Vec::with_capacity(paths.len());
- let mut first_vertex = 0;
-
- for (i, path) in paths.iter().enumerate() {
- draw_indirect_commands.push(DrawIndirectArgs {
- vertex_count: path.vertices.len() as u32,
- instance_count: 1,
- first_vertex,
- first_instance: i as u32,
- });
- first_vertex += path.vertices.len() as u32;
-
- vertices.extend(path.vertices.iter().map(|v| PathVertex {
- xy_position: v.xy_position,
- content_mask: ContentMask {
- bounds: path.content_mask.bounds,
- },
- }));
+ );
- sprites.push(PathSprite {
- bounds: path.bounds,
- color: path.color,
- });
+ profiling::scope!("render pass");
+ for batch in scene.batches() {
+ match batch {
+ PrimitiveBatch::Quads(quads) => {
+ let instance_buf = unsafe { self.instance_belt.alloc_typed(quads, &self.gpu) };
+ let mut encoder = pass.with(&self.pipelines.quads);
+ encoder.bind(
+ 0,
+ &ShaderQuadsData {
+ globals,
+ b_quads: instance_buf,
+ },
+ );
+ encoder.draw(0, 4, 0, quads.len() as u32);
+ }
+ PrimitiveBatch::Shadows(shadows) => {
+ let instance_buf =
+ unsafe { self.instance_belt.alloc_typed(shadows, &self.gpu) };
+ let mut encoder = pass.with(&self.pipelines.shadows);
+ encoder.bind(
+ 0,
+ &ShaderShadowsData {
+ globals,
+ b_shadows: instance_buf,
+ },
+ );
+ encoder.draw(0, 4, 0, shadows.len() as u32);
+ }
+ PrimitiveBatch::Paths(paths) => {
+ let Some(first_path) = paths.first() else {
+ continue;
+ };
+ drop(pass);
+ self.draw_paths_to_intermediate(
+ paths,
+ self.surface_config.size.width as f32,
+ self.surface_config.size.height as f32,
+ );
+ pass = self.command_encoder.render(
+ "main",
+ gpu::RenderTargetSet {
+ colors: &[gpu::RenderTarget {
+ view: frame.texture_view(),
+ init_op: gpu::InitOp::Load,
+ finish_op: gpu::FinishOp::Store,
+ }],
+ depth_stencil: None,
+ },
+ );
+ let mut encoder = pass.with(&self.pipelines.paths);
+ // When copying paths from the intermediate texture to the drawable,
+ // each pixel must only be copied once, in case of transparent paths.
+ //
+ // If all paths have the same draw order, then their bounds are all
+ // disjoint, so we can copy each path's bounds individually. If this
+ // batch combines different draw orders, we perform a single copy
+ // for a minimal spanning rect.
+ let sprites = if paths.last().unwrap().order == first_path.order {
+ paths
+ .iter()
+ .map(|path| PathSprite {
+ bounds: path.clipped_bounds(),
+ })
+ .collect()
+ } else {
+ let mut bounds = first_path.clipped_bounds();
+ for path in paths.iter().skip(1) {
+ bounds = bounds.union(&path.clipped_bounds());
}
-
- let b_path_vertices =
- unsafe { self.instance_belt.alloc_typed(&vertices, &self.gpu) };
- let instance_buf =
- unsafe { self.instance_belt.alloc_typed(&sprites, &self.gpu) };
- let indirect_buf = unsafe {
- self.instance_belt
- .alloc_typed(&draw_indirect_commands, &self.gpu)
+ vec![PathSprite { bounds }]
+ };
+ let instance_buf =
+ unsafe { self.instance_belt.alloc_typed(&sprites, &self.gpu) };
+ encoder.bind(
+ 0,
+ &ShaderPathsData {
+ globals,
+ t_sprite: self.path_intermediate_texture_view,
+ s_sprite: self.atlas_sampler,
+ b_path_sprites: instance_buf,
+ },
+ );
+ encoder.draw(0, 4, 0, sprites.len() as u32);
+ }
+ PrimitiveBatch::Underlines(underlines) => {
+ let instance_buf =
+ unsafe { self.instance_belt.alloc_typed(underlines, &self.gpu) };
+ let mut encoder = pass.with(&self.pipelines.underlines);
+ encoder.bind(
+ 0,
+ &ShaderUnderlinesData {
+ globals,
+ b_underlines: instance_buf,
+ },
+ );
+ encoder.draw(0, 4, 0, underlines.len() as u32);
+ }
+ PrimitiveBatch::MonochromeSprites {
+ texture_id,
+ sprites,
+ } => {
+ let tex_info = self.atlas.get_texture_info(texture_id);
+ let instance_buf =
+ unsafe { self.instance_belt.alloc_typed(sprites, &self.gpu) };
+ let mut encoder = pass.with(&self.pipelines.mono_sprites);
+ encoder.bind(
+ 0,
+ &ShaderMonoSpritesData {
+ globals,
+ t_sprite: tex_info.raw_view,
+ s_sprite: self.atlas_sampler,
+ b_mono_sprites: instance_buf,
+ },
+ );
+ encoder.draw(0, 4, 0, sprites.len() as u32);
+ }
+ PrimitiveBatch::PolychromeSprites {
+ texture_id,
+ sprites,
+ } => {
+ let tex_info = self.atlas.get_texture_info(texture_id);
+ let instance_buf =
+ unsafe { self.instance_belt.alloc_typed(sprites, &self.gpu) };
+ let mut encoder = pass.with(&self.pipelines.poly_sprites);
+ encoder.bind(
+ 0,
+ &ShaderPolySpritesData {
+ globals,
+ t_sprite: tex_info.raw_view,
+ s_sprite: self.atlas_sampler,
+ b_poly_sprites: instance_buf,
+ },
+ );
+ encoder.draw(0, 4, 0, sprites.len() as u32);
+ }
+ PrimitiveBatch::Surfaces(surfaces) => {
+ let mut _encoder = pass.with(&self.pipelines.surfaces);
+
+ for surface in surfaces {
+ #[cfg(not(target_os = "macos"))]
+ {
+ let _ = surface;
+ continue;
};
- encoder.bind(
- 0,
- &ShaderPathsData {
- globals,
- b_path_vertices,
- b_path_sprites: instance_buf,
- },
- );
-
- for i in 0..paths.len() {
- encoder.draw_indirect(indirect_buf.buffer.at(indirect_buf.offset
- + (i * mem::size_of::<DrawIndirectArgs>()) as u64));
- }
- }
- PrimitiveBatch::Underlines(underlines) => {
- let instance_buf =
- unsafe { self.instance_belt.alloc_typed(underlines, &self.gpu) };
- let mut encoder = pass.with(&self.pipelines.underlines);
- encoder.bind(
- 0,
- &ShaderUnderlinesData {
- globals,
- b_underlines: instance_buf,
- },
- );
- encoder.draw(0, 4, 0, underlines.len() as u32);
- }
- PrimitiveBatch::MonochromeSprites {
- texture_id,
- sprites,
- } => {
- let tex_info = self.atlas.get_texture_info(texture_id);
- let instance_buf =
- unsafe { self.instance_belt.alloc_typed(sprites, &self.gpu) };
- let mut encoder = pass.with(&self.pipelines.mono_sprites);
- encoder.bind(
- 0,
- &ShaderMonoSpritesData {
- globals,
- t_sprite: tex_info.raw_view,
- s_sprite: self.atlas_sampler,
- b_mono_sprites: instance_buf,
- },
- );
- encoder.draw(0, 4, 0, sprites.len() as u32);
- }
- PrimitiveBatch::PolychromeSprites {
- texture_id,
- sprites,
- } => {
- let tex_info = self.atlas.get_texture_info(texture_id);
- let instance_buf =
- unsafe { self.instance_belt.alloc_typed(sprites, &self.gpu) };
- let mut encoder = pass.with(&self.pipelines.poly_sprites);
- encoder.bind(
- 0,
- &ShaderPolySpritesData {
- globals,
- t_sprite: tex_info.raw_view,
- s_sprite: self.atlas_sampler,
- b_poly_sprites: instance_buf,
- },
- );
- encoder.draw(0, 4, 0, sprites.len() as u32);
- }
- PrimitiveBatch::Surfaces(surfaces) => {
- let mut _encoder = pass.with(&self.pipelines.surfaces);
-
- for surface in surfaces {
- #[cfg(not(target_os = "macos"))]
- {
- let _ = surface;
- continue;
- };
-
- #[cfg(target_os = "macos")]
- {
- let (t_y, t_cb_cr) = unsafe {
- use core_foundation::base::TCFType as _;
- use std::ptr;
+ #[cfg(target_os = "macos")]
+ {
+ let (t_y, t_cb_cr) = unsafe {
+ use core_foundation::base::TCFType as _;
+ use std::ptr;
- assert_eq!(
+ assert_eq!(
surface.image_buffer.get_pixel_format(),
core_video::pixel_buffer::kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
);
- let y_texture = self
- .core_video_texture_cache
- .create_texture_from_image(
- surface.image_buffer.as_concrete_TypeRef(),
- ptr::null(),
- metal::MTLPixelFormat::R8Unorm,
- surface.image_buffer.get_width_of_plane(0),
- surface.image_buffer.get_height_of_plane(0),
- 0,
- )
- .unwrap();
- let cb_cr_texture = self
- .core_video_texture_cache
- .create_texture_from_image(
- surface.image_buffer.as_concrete_TypeRef(),
- ptr::null(),
- metal::MTLPixelFormat::RG8Unorm,
- surface.image_buffer.get_width_of_plane(1),
- surface.image_buffer.get_height_of_plane(1),
- 1,
- )
- .unwrap();
- (
- gpu::TextureView::from_metal_texture(
- &objc2::rc::Retained::retain(
- foreign_types::ForeignTypeRef::as_ptr(
- y_texture.as_texture_ref(),
- )
- as *mut objc2::runtime::ProtocolObject<
- dyn objc2_metal::MTLTexture,
- >,
+ let y_texture = self
+ .core_video_texture_cache
+ .create_texture_from_image(
+ surface.image_buffer.as_concrete_TypeRef(),
+ ptr::null(),
+ metal::MTLPixelFormat::R8Unorm,
+ surface.image_buffer.get_width_of_plane(0),
+ surface.image_buffer.get_height_of_plane(0),
+ 0,
+ )
+ .unwrap();
+ let cb_cr_texture = self
+ .core_video_texture_cache
+ .create_texture_from_image(
+ surface.image_buffer.as_concrete_TypeRef(),
+ ptr::null(),
+ metal::MTLPixelFormat::RG8Unorm,
+ surface.image_buffer.get_width_of_plane(1),
+ surface.image_buffer.get_height_of_plane(1),
+ 1,
+ )
+ .unwrap();
+ (
+ gpu::TextureView::from_metal_texture(
+ &objc2::rc::Retained::retain(
+ foreign_types::ForeignTypeRef::as_ptr(
+ y_texture.as_texture_ref(),
)
- .unwrap(),
- gpu::TexelAspects::COLOR,
- ),
- gpu::TextureView::from_metal_texture(
- &objc2::rc::Retained::retain(
- foreign_types::ForeignTypeRef::as_ptr(
- cb_cr_texture.as_texture_ref(),
- )
- as *mut objc2::runtime::ProtocolObject<
- dyn objc2_metal::MTLTexture,
- >,
+ as *mut objc2::runtime::ProtocolObject<
+ dyn objc2_metal::MTLTexture,
+ >,
+ )
+ .unwrap(),
+ gpu::TexelAspects::COLOR,
+ ),
+ gpu::TextureView::from_metal_texture(
+ &objc2::rc::Retained::retain(
+ foreign_types::ForeignTypeRef::as_ptr(
+ cb_cr_texture.as_texture_ref(),
)
- .unwrap(),
- gpu::TexelAspects::COLOR,
- ),
- )
- };
-
- _encoder.bind(
- 0,
- &ShaderSurfacesData {
- globals,
- surface_locals: SurfaceParams {
- bounds: surface.bounds.into(),
- content_mask: surface.content_mask.bounds.into(),
- },
- t_y,
- t_cb_cr,
- s_surface: self.atlas_sampler,
+ as *mut objc2::runtime::ProtocolObject<
+ dyn objc2_metal::MTLTexture,
+ >,
+ )
+ .unwrap(),
+ gpu::TexelAspects::COLOR,
+ ),
+ )
+ };
+
+ _encoder.bind(
+ 0,
+ &ShaderSurfacesData {
+ globals,
+ surface_locals: SurfaceParams {
+ bounds: surface.bounds.into(),
+ content_mask: surface.content_mask.bounds.into(),
},
- );
+ t_y,
+ t_cb_cr,
+ s_surface: self.atlas_sampler,
+ },
+ );
- _encoder.draw(0, 4, 0, 1);
- }
+ _encoder.draw(0, 4, 0, 1);
}
}
}
}
}
+ drop(pass);
self.command_encoder.present(frame);
let sync_point = self.gpu.submit(&mut self.command_encoder);
@@ -922,62 +922,103 @@ fn fs_shadow(input: ShadowVarying) -> @location(0) vec4<f32> {
return blend_color(input.color, alpha);
}
-// --- paths --- //
+// --- path rasterization --- //
-struct PathVertex {
+struct PathRasterizationVertex {
xy_position: vec2<f32>,
- content_mask: Bounds,
+ st_position: vec2<f32>,
+ color: Background,
+ bounds: Bounds,
+}
+
+var<storage, read> b_path_vertices: array<PathRasterizationVertex>;
+
+struct PathRasterizationVarying {
+ @builtin(position) position: vec4<f32>,
+ @location(0) st_position: vec2<f32>,
+ @location(1) vertex_id: u32,
+ //TODO: use `clip_distance` once Naga supports it
+ @location(3) clip_distances: vec4<f32>,
+}
+
+@vertex
+fn vs_path_rasterization(@builtin(vertex_index) vertex_id: u32) -> PathRasterizationVarying {
+ let v = b_path_vertices[vertex_id];
+
+ var out = PathRasterizationVarying();
+ out.position = to_device_position_impl(v.xy_position);
+ out.st_position = v.st_position;
+ out.vertex_id = vertex_id;
+ out.clip_distances = distance_from_clip_rect_impl(v.xy_position, v.bounds);
+ return out;
+}
+
+@fragment
+fn fs_path_rasterization(input: PathRasterizationVarying) -> @location(0) vec4<f32> {
+ let dx = dpdx(input.st_position);
+ let dy = dpdy(input.st_position);
+ if (any(input.clip_distances < vec4<f32>(0.0))) {
+ return vec4<f32>(0.0);
+ }
+
+ let v = b_path_vertices[input.vertex_id];
+ let background = v.color;
+ let bounds = v.bounds;
+
+ var alpha: f32;
+ if (length(vec2<f32>(dx.x, dy.x)) < 0.001) {
+ // If the gradient is too small, return a solid color.
+ alpha = 1.0;
+ } else {
+ let gradient = 2.0 * input.st_position.xx * vec2<f32>(dx.x, dy.x) - vec2<f32>(dx.y, dy.y);
+ let f = input.st_position.x * input.st_position.x - input.st_position.y;
+ let distance = f / length(gradient);
+ alpha = saturate(0.5 - distance);
+ }
+ let gradient_color = prepare_gradient_color(
+ background.tag,
+ background.color_space,
+ background.solid,
+ background.colors,
+ );
+ let color = gradient_color(background, input.position.xy, bounds,
+ gradient_color.solid, gradient_color.color0, gradient_color.color1);
+ return vec4<f32>(color.rgb * color.a * alpha, color.a * alpha);
}
+// --- paths --- //
+
struct PathSprite {
bounds: Bounds,
- color: Background,
}
-var<storage, read> b_path_vertices: array<PathVertex>;
var<storage, read> b_path_sprites: array<PathSprite>;
struct PathVarying {
@builtin(position) position: vec4<f32>,
- @location(0) clip_distances: vec4<f32>,
- @location(1) @interpolate(flat) instance_id: u32,
- @location(2) @interpolate(flat) color_solid: vec4<f32>,
- @location(3) @interpolate(flat) color0: vec4<f32>,
- @location(4) @interpolate(flat) color1: vec4<f32>,
+ @location(0) texture_coords: vec2<f32>,
}
@vertex
fn vs_path(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> PathVarying {
- let v = b_path_vertices[vertex_id];
+ let unit_vertex = vec2<f32>(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u));
let sprite = b_path_sprites[instance_id];
+ // Don't apply content mask because it was already accounted for when rasterizing the path.
+ let device_position = to_device_position(unit_vertex, sprite.bounds);
+ // For screen-space intermediate texture, convert screen position to texture coordinates
+ let screen_position = sprite.bounds.origin + unit_vertex * sprite.bounds.size;
+ let texture_coords = screen_position / globals.viewport_size;
var out = PathVarying();
- out.position = to_device_position_impl(v.xy_position);
- out.clip_distances = distance_from_clip_rect_impl(v.xy_position, v.content_mask);
- out.instance_id = instance_id;
+ out.position = device_position;
+ out.texture_coords = texture_coords;
- let gradient = prepare_gradient_color(
- sprite.color.tag,
- sprite.color.color_space,
- sprite.color.solid,
- sprite.color.colors
- );
- out.color_solid = gradient.solid;
- out.color0 = gradient.color0;
- out.color1 = gradient.color1;
return out;
}
@fragment
fn fs_path(input: PathVarying) -> @location(0) vec4<f32> {
- if any(input.clip_distances < vec4<f32>(0.0)) {
- return vec4<f32>(0.0);
- }
-
- let sprite = b_path_sprites[input.instance_id];
- let background = sprite.color;
- let color = gradient_color(background, input.position.xy, sprite.bounds,
- input.color_solid, input.color0, input.color1);
- return blend_color(color, 1.0);
+ let sample = textureSample(t_sprite, s_sprite, input.texture_coords);
+ return sample;
}
// --- underlines --- //
@@ -1016,6 +1057,9 @@ fn vs_underline(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index)
@fragment
fn fs_underline(input: UnderlineVarying) -> @location(0) vec4<f32> {
+ const WAVE_FREQUENCY: f32 = 2.0;
+ const WAVE_HEIGHT_RATIO: f32 = 0.8;
+
// Alpha clip first, since we don't have `clip_distance`.
if (any(input.clip_distances < vec4<f32>(0.0))) {
return vec4<f32>(0.0);
@@ -1028,9 +1072,11 @@ fn fs_underline(input: UnderlineVarying) -> @location(0) vec4<f32> {
}
let half_thickness = underline.thickness * 0.5;
+
let st = (input.position.xy - underline.bounds.origin) / underline.bounds.size.y - vec2<f32>(0.0, 0.5);
- let frequency = M_PI_F * 3.0 * underline.thickness / 3.0;
- let amplitude = 1.0 / (4.0 * underline.thickness);
+ let frequency = M_PI_F * WAVE_FREQUENCY * underline.thickness / underline.bounds.size.y;
+ let amplitude = (underline.thickness * WAVE_HEIGHT_RATIO) / underline.bounds.size.y;
+
let sine = sin(st.x * frequency) * amplitude;
let dSine = cos(st.x * frequency) * amplitude * frequency;
let distance = (st.y - sine) / sqrt(1.0 + dSine * dSine);
@@ -534,11 +534,62 @@ impl Modifiers {
/// Checks if this [`Modifiers`] is a subset of another [`Modifiers`].
pub fn is_subset_of(&self, other: &Modifiers) -> bool {
- (other.control || !self.control)
- && (other.alt || !self.alt)
- && (other.shift || !self.shift)
- && (other.platform || !self.platform)
- && (other.function || !self.function)
+ (*other & *self) == *self
+ }
+}
+
+impl std::ops::BitOr for Modifiers {
+ type Output = Self;
+
+ fn bitor(mut self, other: Self) -> Self::Output {
+ self |= other;
+ self
+ }
+}
+
+impl std::ops::BitOrAssign for Modifiers {
+ fn bitor_assign(&mut self, other: Self) {
+ self.control |= other.control;
+ self.alt |= other.alt;
+ self.shift |= other.shift;
+ self.platform |= other.platform;
+ self.function |= other.function;
+ }
+}
+
+impl std::ops::BitXor for Modifiers {
+ type Output = Self;
+ fn bitxor(mut self, rhs: Self) -> Self::Output {
+ self ^= rhs;
+ self
+ }
+}
+
+impl std::ops::BitXorAssign for Modifiers {
+ fn bitxor_assign(&mut self, other: Self) {
+ self.control ^= other.control;
+ self.alt ^= other.alt;
+ self.shift ^= other.shift;
+ self.platform ^= other.platform;
+ self.function ^= other.function;
+ }
+}
+
+impl std::ops::BitAnd for Modifiers {
+ type Output = Self;
+ fn bitand(mut self, rhs: Self) -> Self::Output {
+ self &= rhs;
+ self
+ }
+}
+
+impl std::ops::BitAndAssign for Modifiers {
+ fn bitand_assign(&mut self, other: Self) {
+ self.control &= other.control;
+ self.alt &= other.alt;
+ self.shift &= other.shift;
+ self.platform &= other.platform;
+ self.function &= other.function;
}
}
@@ -73,7 +73,7 @@ impl LinuxClient for HeadlessClient {
#[cfg(feature = "screen-capture")]
fn screen_capture_sources(
&self,
- ) -> futures::channel::oneshot::Receiver<anyhow::Result<Vec<Box<dyn crate::ScreenCaptureSource>>>>
+ ) -> futures::channel::oneshot::Receiver<anyhow::Result<Vec<Rc<dyn crate::ScreenCaptureSource>>>>
{
let (mut tx, rx) = futures::channel::oneshot::channel();
tx.send(Err(anyhow::anyhow!(
@@ -56,7 +56,7 @@ pub trait LinuxClient {
#[cfg(feature = "screen-capture")]
fn screen_capture_sources(
&self,
- ) -> oneshot::Receiver<Result<Vec<Box<dyn crate::ScreenCaptureSource>>>>;
+ ) -> oneshot::Receiver<Result<Vec<Rc<dyn crate::ScreenCaptureSource>>>>;
fn open_window(
&self,
@@ -108,13 +108,13 @@ impl LinuxCommon {
let callbacks = PlatformHandlers::default();
- let dispatcher = Arc::new(LinuxDispatcher::new(main_sender.clone()));
+ let dispatcher = Arc::new(LinuxDispatcher::new(main_sender));
let background_executor = BackgroundExecutor::new(dispatcher.clone());
let common = LinuxCommon {
background_executor,
- foreground_executor: ForegroundExecutor::new(dispatcher.clone()),
+ foreground_executor: ForegroundExecutor::new(dispatcher),
text_system,
appearance: WindowAppearance::Light,
auto_hide_scrollbars: false,
@@ -245,7 +245,7 @@ impl<P: LinuxClient + 'static> Platform for P {
#[cfg(feature = "screen-capture")]
fn screen_capture_sources(
&self,
- ) -> oneshot::Receiver<Result<Vec<Box<dyn crate::ScreenCaptureSource>>>> {
+ ) -> oneshot::Receiver<Result<Vec<Rc<dyn crate::ScreenCaptureSource>>>> {
self.screen_capture_sources()
}
@@ -294,6 +294,7 @@ impl<P: LinuxClient + 'static> Platform for P {
let request = match ashpd::desktop::file_chooser::OpenFileRequest::default()
.modal(true)
.title(title)
+ .accept_label(options.prompt.as_ref().map(crate::SharedString::as_str))
.multiple(options.multiple)
.directory(options.directories)
.send()
@@ -327,26 +328,35 @@ impl<P: LinuxClient + 'static> Platform for P {
done_rx
}
- fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver<Result<Option<PathBuf>>> {
+ fn prompt_for_new_path(
+ &self,
+ directory: &Path,
+ suggested_name: Option<&str>,
+ ) -> oneshot::Receiver<Result<Option<PathBuf>>> {
let (done_tx, done_rx) = oneshot::channel();
#[cfg(not(any(feature = "wayland", feature = "x11")))]
- let _ = (done_tx.send(Ok(None)), directory);
+ let _ = (done_tx.send(Ok(None)), directory, suggested_name);
#[cfg(any(feature = "wayland", feature = "x11"))]
self.foreground_executor()
.spawn({
let directory = directory.to_owned();
+ let suggested_name = suggested_name.map(|s| s.to_owned());
async move {
- let request = match ashpd::desktop::file_chooser::SaveFileRequest::default()
- .modal(true)
- .title("Save File")
- .current_folder(directory)
- .expect("pathbuf should not be nul terminated")
- .send()
- .await
- {
+ let mut request_builder =
+ ashpd::desktop::file_chooser::SaveFileRequest::default()
+ .modal(true)
+ .title("Save File")
+ .current_folder(directory)
+ .expect("pathbuf should not be nul terminated");
+
+ if let Some(suggested_name) = suggested_name {
+ request_builder = request_builder.current_name(suggested_name.as_str());
+ }
+
+ let request = match request_builder.send().await {
Ok(request) => request,
Err(err) => {
let result = match err {
@@ -431,7 +441,7 @@ impl<P: LinuxClient + 'static> Platform for P {
fn app_path(&self) -> Result<PathBuf> {
// get the path of the executable of the current process
let app_path = env::current_exe()?;
- return Ok(app_path);
+ Ok(app_path)
}
fn set_menus(&self, menus: Vec<Menu>, _keymap: &Keymap) {
@@ -632,7 +642,7 @@ pub(super) fn get_xkb_compose_state(cx: &xkb::Context) -> Option<xkb::compose::S
let mut state: Option<xkb::compose::State> = None;
for locale in locales {
if let Ok(table) =
- xkb::compose::Table::new_from_locale(&cx, &locale, xkb::compose::COMPILE_NO_FLAGS)
+ xkb::compose::Table::new_from_locale(cx, &locale, xkb::compose::COMPILE_NO_FLAGS)
{
state = Some(xkb::compose::State::new(
&table,
@@ -657,7 +667,7 @@ pub(super) const DEFAULT_CURSOR_ICON_NAME: &str = "left_ptr";
impl CursorStyle {
#[cfg(any(feature = "wayland", feature = "x11"))]
- pub(super) fn to_icon_names(&self) -> &'static [&'static str] {
+ pub(super) fn to_icon_names(self) -> &'static [&'static str] {
// Based on cursor names from chromium:
// https://github.com/chromium/chromium/blob/d3069cf9c973dc3627fa75f64085c6a86c8f41bf/ui/base/cursor/cursor_factory.cc#L113
match self {
@@ -822,11 +832,41 @@ impl crate::Keystroke {
Keysym::underscore => "_".to_owned(),
Keysym::equal => "=".to_owned(),
Keysym::plus => "+".to_owned(),
+ Keysym::space => "space".to_owned(),
+ Keysym::BackSpace => "backspace".to_owned(),
+ Keysym::Tab => "tab".to_owned(),
+ Keysym::Delete => "delete".to_owned(),
+ Keysym::Escape => "escape".to_owned(),
+
+ Keysym::Left => "left".to_owned(),
+ Keysym::Right => "right".to_owned(),
+ Keysym::Up => "up".to_owned(),
+ Keysym::Down => "down".to_owned(),
+ Keysym::Home => "home".to_owned(),
+ Keysym::End => "end".to_owned(),
_ => {
let name = xkb::keysym_get_name(key_sym).to_lowercase();
if key_sym.is_keypad_key() {
name.replace("kp_", "")
+ } else if let Some(key) = key_utf8.chars().next()
+ && key_utf8.len() == 1
+ && key.is_ascii()
+ {
+ if key.is_ascii_graphic() {
+ key_utf8.to_lowercase()
+ // map ctrl-a to `a`
+ // ctrl-0..9 may emit control codes like ctrl-[, but
+ // we don't want to map them to `[`
+ } else if key_utf32 <= 0x1f
+ && !name.chars().next().is_some_and(|c| c.is_ascii_digit())
+ {
+ ((key_utf32 as u8 + 0x40) as char)
+ .to_ascii_lowercase()
+ .to_string()
+ } else {
+ name
+ }
} else if let Some(key_en) = guess_ascii(keycode, modifiers.shift) {
String::from(key_en)
} else {
@@ -950,21 +990,18 @@ mod tests {
#[test]
fn test_is_within_click_distance() {
let zero = Point::new(px(0.0), px(0.0));
- assert_eq!(
- is_within_click_distance(zero, Point::new(px(5.0), px(5.0))),
- true
- );
- assert_eq!(
- is_within_click_distance(zero, Point::new(px(-4.9), px(5.0))),
- true
- );
- assert_eq!(
- is_within_click_distance(Point::new(px(3.0), px(2.0)), Point::new(px(-2.0), px(-2.0))),
- true
- );
- assert_eq!(
- is_within_click_distance(zero, Point::new(px(5.0), px(5.1))),
- false
- );
+ assert!(is_within_click_distance(zero, Point::new(px(5.0), px(5.0))));
+ assert!(is_within_click_distance(
+ zero,
+ Point::new(px(-4.9), px(5.0))
+ ));
+ assert!(is_within_click_distance(
+ Point::new(px(3.0), px(2.0)),
+ Point::new(px(-2.0), px(-2.0))
+ ));
+ assert!(!is_within_click_distance(
+ zero,
+ Point::new(px(5.0), px(5.1))
+ ),);
}
}
@@ -213,11 +213,7 @@ impl CosmicTextSystemState {
features: &FontFeatures,
) -> Result<SmallVec<[FontId; 4]>> {
// TODO: Determine the proper system UI font.
- let name = if name == ".SystemUIFont" {
- "Zed Plex Sans"
- } else {
- name
- };
+ let name = crate::text_system::font_name_with_fallbacks(name, "IBM Plex Sans");
let families = self
.font_system
@@ -12,7 +12,7 @@ use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::
use crate::CursorStyle;
impl CursorStyle {
- pub(super) fn to_shape(&self) -> Shape {
+ pub(super) fn to_shape(self) -> Shape {
match self {
CursorStyle::Arrow => Shape::Default,
CursorStyle::IBeam => Shape::Text,
@@ -359,13 +359,13 @@ impl WaylandClientStatePtr {
}
changed
};
- if changed {
- if let Some(mut callback) = state.common.callbacks.keyboard_layout_change.take() {
- drop(state);
- callback();
- state = client.borrow_mut();
- state.common.callbacks.keyboard_layout_change = Some(callback);
- }
+
+ if changed && let Some(mut callback) = state.common.callbacks.keyboard_layout_change.take()
+ {
+ drop(state);
+ callback();
+ state = client.borrow_mut();
+ state.common.callbacks.keyboard_layout_change = Some(callback);
}
}
@@ -373,15 +373,15 @@ impl WaylandClientStatePtr {
let mut client = self.get_client();
let mut state = client.borrow_mut();
let closed_window = state.windows.remove(surface_id).unwrap();
- if let Some(window) = state.mouse_focused_window.take() {
- if !window.ptr_eq(&closed_window) {
- state.mouse_focused_window = Some(window);
- }
+ if let Some(window) = state.mouse_focused_window.take()
+ && !window.ptr_eq(&closed_window)
+ {
+ state.mouse_focused_window = Some(window);
}
- if let Some(window) = state.keyboard_focused_window.take() {
- if !window.ptr_eq(&closed_window) {
- state.keyboard_focused_window = Some(window);
- }
+ if let Some(window) = state.keyboard_focused_window.take()
+ && !window.ptr_eq(&closed_window)
+ {
+ state.keyboard_focused_window = Some(window);
}
if state.windows.is_empty() {
state.common.signal.stop();
@@ -528,7 +528,7 @@ impl WaylandClient {
client.common.appearance = appearance;
- for (_, window) in &mut client.windows {
+ for window in client.windows.values_mut() {
window.set_appearance(appearance);
}
}
@@ -672,7 +672,7 @@ impl LinuxClient for WaylandClient {
#[cfg(feature = "screen-capture")]
fn screen_capture_sources(
&self,
- ) -> futures::channel::oneshot::Receiver<anyhow::Result<Vec<Box<dyn crate::ScreenCaptureSource>>>>
+ ) -> futures::channel::oneshot::Receiver<anyhow::Result<Vec<Rc<dyn crate::ScreenCaptureSource>>>>
{
// TODO: Get screen capture working on wayland. Be sure to try window resizing as that may
// be tricky.
@@ -710,9 +710,7 @@ impl LinuxClient for WaylandClient {
fn set_cursor_style(&self, style: CursorStyle) {
let mut state = self.0.borrow_mut();
- let need_update = state
- .cursor_style
- .map_or(true, |current_style| current_style != style);
+ let need_update = state.cursor_style != Some(style);
if need_update {
let serial = state.serial_tracker.get(SerialKind::MouseEnter);
@@ -951,11 +949,8 @@ impl Dispatch<WlCallback, ObjectId> for WaylandClientStatePtr {
};
drop(state);
- match event {
- wl_callback::Event::Done { .. } => {
- window.frame();
- }
- _ => {}
+ if let wl_callback::Event::Done { .. } = event {
+ window.frame();
}
}
}
@@ -1145,7 +1140,7 @@ impl Dispatch<wl_seat::WlSeat, ()> for WaylandClientStatePtr {
.globals
.text_input_manager
.as_ref()
- .map(|text_input_manager| text_input_manager.get_text_input(&seat, qh, ()));
+ .map(|text_input_manager| text_input_manager.get_text_input(seat, qh, ()));
if let Some(wl_keyboard) = &state.wl_keyboard {
wl_keyboard.release();
@@ -1285,7 +1280,6 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
let Some(focused_window) = focused_window else {
return;
};
- let focused_window = focused_window.clone();
let keymap_state = state.keymap_state.as_ref().unwrap();
let keycode = Keycode::from(key + MIN_KEYCODE);
@@ -1294,7 +1288,7 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
match key_state {
wl_keyboard::KeyState::Pressed if !keysym.is_modifier_key() => {
let mut keystroke =
- Keystroke::from_xkb(&keymap_state, state.modifiers, keycode);
+ Keystroke::from_xkb(keymap_state, state.modifiers, keycode);
if let Some(mut compose) = state.compose_state.take() {
compose.feed(keysym);
match compose.status() {
@@ -1538,12 +1532,9 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
cursor_shape_device.set_shape(serial, style.to_shape());
} else {
let scale = window.primary_output_scale();
- state.cursor.set_icon(
- &wl_pointer,
- serial,
- style.to_icon_names(),
- scale,
- );
+ state
+ .cursor
+ .set_icon(wl_pointer, serial, style.to_icon_names(), scale);
}
}
drop(state);
@@ -1580,7 +1571,7 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
if state
.keyboard_focused_window
.as_ref()
- .map_or(false, |keyboard_window| window.ptr_eq(&keyboard_window))
+ .is_some_and(|keyboard_window| window.ptr_eq(keyboard_window))
{
state.enter_token = None;
}
@@ -1787,17 +1778,17 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
drop(state);
window.handle_input(input);
}
- } else if let Some(discrete) = discrete {
- if let Some(window) = state.mouse_focused_window.clone() {
- let input = PlatformInput::ScrollWheel(ScrollWheelEvent {
- position: state.mouse_location.unwrap(),
- delta: ScrollDelta::Lines(discrete),
- modifiers: state.modifiers,
- touch_phase: TouchPhase::Moved,
- });
- drop(state);
- window.handle_input(input);
- }
+ } else if let Some(discrete) = discrete
+ && let Some(window) = state.mouse_focused_window.clone()
+ {
+ let input = PlatformInput::ScrollWheel(ScrollWheelEvent {
+ position: state.mouse_location.unwrap(),
+ delta: ScrollDelta::Lines(discrete),
+ modifiers: state.modifiers,
+ touch_phase: TouchPhase::Moved,
+ });
+ drop(state);
+ window.handle_input(input);
}
}
}
@@ -2019,25 +2010,22 @@ impl Dispatch<wl_data_offer::WlDataOffer, ()> for WaylandClientStatePtr {
let client = this.get_client();
let mut state = client.borrow_mut();
- match event {
- wl_data_offer::Event::Offer { mime_type } => {
- // Drag and drop
- if mime_type == FILE_LIST_MIME_TYPE {
- let serial = state.serial_tracker.get(SerialKind::DataDevice);
- let mime_type = mime_type.clone();
- data_offer.accept(serial, Some(mime_type));
- }
+ if let wl_data_offer::Event::Offer { mime_type } = event {
+ // Drag and drop
+ if mime_type == FILE_LIST_MIME_TYPE {
+ let serial = state.serial_tracker.get(SerialKind::DataDevice);
+ let mime_type = mime_type.clone();
+ data_offer.accept(serial, Some(mime_type));
+ }
- // Clipboard
- if let Some(offer) = state
- .data_offers
- .iter_mut()
- .find(|wrapper| wrapper.inner.id() == data_offer.id())
- {
- offer.add_mime_type(mime_type);
- }
+ // Clipboard
+ if let Some(offer) = state
+ .data_offers
+ .iter_mut()
+ .find(|wrapper| wrapper.inner.id() == data_offer.id())
+ {
+ offer.add_mime_type(mime_type);
}
- _ => {}
}
}
}
@@ -2118,13 +2106,10 @@ impl Dispatch<zwp_primary_selection_offer_v1::ZwpPrimarySelectionOfferV1, ()>
let client = this.get_client();
let mut state = client.borrow_mut();
- match event {
- zwp_primary_selection_offer_v1::Event::Offer { mime_type } => {
- if let Some(offer) = state.primary_data_offer.as_mut() {
- offer.add_mime_type(mime_type);
- }
- }
- _ => {}
+ if let zwp_primary_selection_offer_v1::Event::Offer { mime_type } = event
+ && let Some(offer) = state.primary_data_offer.as_mut()
+ {
+ offer.add_mime_type(mime_type);
}
}
}
@@ -45,10 +45,11 @@ impl Cursor {
}
fn set_theme_internal(&mut self, theme_name: Option<String>) {
- if let Some(loaded_theme) = self.loaded_theme.as_ref() {
- if loaded_theme.name == theme_name && loaded_theme.scaled_size == self.scaled_size {
- return;
- }
+ if let Some(loaded_theme) = self.loaded_theme.as_ref()
+ && loaded_theme.name == theme_name
+ && loaded_theme.scaled_size == self.scaled_size
+ {
+ return;
}
let result = if let Some(theme_name) = theme_name.as_ref() {
CursorTheme::load_from_name(
@@ -66,7 +67,7 @@ impl Cursor {
{
self.loaded_theme = Some(LoadedTheme {
theme,
- name: theme_name.map(|name| name.to_string()),
+ name: theme_name,
scaled_size: self.scaled_size,
});
}
@@ -144,7 +145,7 @@ impl Cursor {
hot_y as i32 / scale,
);
- self.surface.attach(Some(&buffer), 0, 0);
+ self.surface.attach(Some(buffer), 0, 0);
self.surface.damage(0, 0, width as i32, height as i32);
self.surface.commit();
}
@@ -76,6 +76,7 @@ struct InProgressConfigure {
size: Option<Size<Pixels>>,
fullscreen: bool,
maximized: bool,
+ resizing: bool,
tiling: Tiling,
}
@@ -107,9 +108,10 @@ pub struct WaylandWindowState {
active: bool,
hovered: bool,
in_progress_configure: Option<InProgressConfigure>,
+ resize_throttle: bool,
in_progress_window_controls: Option<WindowControls>,
window_controls: WindowControls,
- inset: Option<Pixels>,
+ client_inset: Option<Pixels>,
}
#[derive(Clone)]
@@ -176,6 +178,7 @@ impl WaylandWindowState {
tiling: Tiling::default(),
window_bounds: options.bounds,
in_progress_configure: None,
+ resize_throttle: false,
client,
appearance,
handle,
@@ -183,7 +186,7 @@ impl WaylandWindowState {
hovered: false,
in_progress_window_controls: None,
window_controls: WindowControls::default(),
- inset: None,
+ client_inset: None,
})
}
@@ -208,6 +211,13 @@ impl WaylandWindowState {
self.display = current_output;
scale
}
+
+ pub fn inset(&self) -> Pixels {
+ match self.decorations {
+ WindowDecorations::Server => px(0.0),
+ WindowDecorations::Client => self.client_inset.unwrap_or(px(0.0)),
+ }
+ }
}
pub(crate) struct WaylandWindow(pub WaylandWindowStatePtr);
@@ -335,6 +345,7 @@ impl WaylandWindowStatePtr {
pub fn frame(&self) {
let mut state = self.state.borrow_mut();
state.surface.frame(&state.globals.qh, state.surface.id());
+ state.resize_throttle = false;
drop(state);
let mut cb = self.callbacks.borrow_mut();
@@ -344,79 +355,82 @@ impl WaylandWindowStatePtr {
}
pub fn handle_xdg_surface_event(&self, event: xdg_surface::Event) {
- match event {
- xdg_surface::Event::Configure { serial } => {
- {
- let mut state = self.state.borrow_mut();
- if let Some(window_controls) = state.in_progress_window_controls.take() {
- state.window_controls = window_controls;
-
- drop(state);
- let mut callbacks = self.callbacks.borrow_mut();
- if let Some(appearance_changed) = callbacks.appearance_changed.as_mut() {
- appearance_changed();
- }
+ if let xdg_surface::Event::Configure { serial } = event {
+ {
+ let mut state = self.state.borrow_mut();
+ if let Some(window_controls) = state.in_progress_window_controls.take() {
+ state.window_controls = window_controls;
+
+ drop(state);
+ let mut callbacks = self.callbacks.borrow_mut();
+ if let Some(appearance_changed) = callbacks.appearance_changed.as_mut() {
+ appearance_changed();
}
}
- {
- let mut state = self.state.borrow_mut();
-
- if let Some(mut configure) = state.in_progress_configure.take() {
- let got_unmaximized = state.maximized && !configure.maximized;
- state.fullscreen = configure.fullscreen;
- state.maximized = configure.maximized;
- state.tiling = configure.tiling;
- if !configure.fullscreen && !configure.maximized {
- configure.size = if got_unmaximized {
- Some(state.window_bounds.size)
- } else {
- compute_outer_size(state.inset, configure.size, state.tiling)
- };
- if let Some(size) = configure.size {
- state.window_bounds = Bounds {
- origin: Point::default(),
- size,
- };
- }
- }
- drop(state);
+ }
+ {
+ let mut state = self.state.borrow_mut();
+
+ if let Some(mut configure) = state.in_progress_configure.take() {
+ let got_unmaximized = state.maximized && !configure.maximized;
+ state.fullscreen = configure.fullscreen;
+ state.maximized = configure.maximized;
+ state.tiling = configure.tiling;
+ // Limit interactive resizes to once per vblank
+ if configure.resizing && state.resize_throttle {
+ return;
+ } else if configure.resizing {
+ state.resize_throttle = true;
+ }
+ if !configure.fullscreen && !configure.maximized {
+ configure.size = if got_unmaximized {
+ Some(state.window_bounds.size)
+ } else {
+ compute_outer_size(state.inset(), configure.size, state.tiling)
+ };
if let Some(size) = configure.size {
- self.resize(size);
+ state.window_bounds = Bounds {
+ origin: Point::default(),
+ size,
+ };
}
}
- }
- let mut state = self.state.borrow_mut();
- state.xdg_surface.ack_configure(serial);
-
- let window_geometry = inset_by_tiling(
- state.bounds.map_origin(|_| px(0.0)),
- state.inset.unwrap_or(px(0.0)),
- state.tiling,
- )
- .map(|v| v.0 as i32)
- .map_size(|v| if v <= 0 { 1 } else { v });
-
- state.xdg_surface.set_window_geometry(
- window_geometry.origin.x,
- window_geometry.origin.y,
- window_geometry.size.width,
- window_geometry.size.height,
- );
-
- let request_frame_callback = !state.acknowledged_first_configure;
- if request_frame_callback {
- state.acknowledged_first_configure = true;
drop(state);
- self.frame();
+ if let Some(size) = configure.size {
+ self.resize(size);
+ }
}
}
- _ => {}
+ let mut state = self.state.borrow_mut();
+ state.xdg_surface.ack_configure(serial);
+
+ let window_geometry = inset_by_tiling(
+ state.bounds.map_origin(|_| px(0.0)),
+ state.inset(),
+ state.tiling,
+ )
+ .map(|v| v.0 as i32)
+ .map_size(|v| if v <= 0 { 1 } else { v });
+
+ state.xdg_surface.set_window_geometry(
+ window_geometry.origin.x,
+ window_geometry.origin.y,
+ window_geometry.size.width,
+ window_geometry.size.height,
+ );
+
+ let request_frame_callback = !state.acknowledged_first_configure;
+ if request_frame_callback {
+ state.acknowledged_first_configure = true;
+ drop(state);
+ self.frame();
+ }
}
}
pub fn handle_toplevel_decoration_event(&self, event: zxdg_toplevel_decoration_v1::Event) {
- match event {
- zxdg_toplevel_decoration_v1::Event::Configure { mode } => match mode {
+ if let zxdg_toplevel_decoration_v1::Event::Configure { mode } = event {
+ match mode {
WEnum::Value(zxdg_toplevel_decoration_v1::Mode::ServerSide) => {
self.state.borrow_mut().decorations = WindowDecorations::Server;
if let Some(mut appearance_changed) =
@@ -440,17 +454,13 @@ impl WaylandWindowStatePtr {
WEnum::Unknown(v) => {
log::warn!("Unknown decoration mode: {}", v);
}
- },
- _ => {}
+ }
}
}
pub fn handle_fractional_scale_event(&self, event: wp_fractional_scale_v1::Event) {
- match event {
- wp_fractional_scale_v1::Event::PreferredScale { scale } => {
- self.rescale(scale as f32 / 120.0);
- }
- _ => {}
+ if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event {
+ self.rescale(scale as f32 / 120.0);
}
}
@@ -472,6 +482,7 @@ impl WaylandWindowStatePtr {
let mut tiling = Tiling::default();
let mut fullscreen = false;
let mut maximized = false;
+ let mut resizing = false;
for state in states {
match state {
@@ -481,6 +492,7 @@ impl WaylandWindowStatePtr {
xdg_toplevel::State::Fullscreen => {
fullscreen = true;
}
+ xdg_toplevel::State::Resizing => resizing = true,
xdg_toplevel::State::TiledTop => {
tiling.top = true;
}
@@ -508,6 +520,7 @@ impl WaylandWindowStatePtr {
size,
fullscreen,
maximized,
+ resizing,
tiling,
});
@@ -649,8 +662,8 @@ impl WaylandWindowStatePtr {
pub fn set_size_and_scale(&self, size: Option<Size<Pixels>>, scale: Option<f32>) {
let (size, scale) = {
let mut state = self.state.borrow_mut();
- if size.map_or(true, |size| size == state.bounds.size)
- && scale.map_or(true, |scale| scale == state.scale)
+ if size.is_none_or(|size| size == state.bounds.size)
+ && scale.is_none_or(|scale| scale == state.scale)
{
return;
}
@@ -693,21 +706,20 @@ impl WaylandWindowStatePtr {
}
pub fn handle_input(&self, input: PlatformInput) {
- if let Some(ref mut fun) = self.callbacks.borrow_mut().input {
- if !fun(input.clone()).propagate {
- return;
- }
+ if let Some(ref mut fun) = self.callbacks.borrow_mut().input
+ && !fun(input.clone()).propagate
+ {
+ return;
}
- if let PlatformInput::KeyDown(event) = input {
- if event.keystroke.modifiers.is_subset_of(&Modifiers::shift()) {
- if let Some(key_char) = &event.keystroke.key_char {
- let mut state = self.state.borrow_mut();
- if let Some(mut input_handler) = state.input_handler.take() {
- drop(state);
- input_handler.replace_text_in_range(None, key_char);
- self.state.borrow_mut().input_handler = Some(input_handler);
- }
- }
+ if let PlatformInput::KeyDown(event) = input
+ && event.keystroke.modifiers.is_subset_of(&Modifiers::shift())
+ && let Some(key_char) = &event.keystroke.key_char
+ {
+ let mut state = self.state.borrow_mut();
+ if let Some(mut input_handler) = state.input_handler.take() {
+ drop(state);
+ input_handler.replace_text_in_range(None, key_char);
+ self.state.borrow_mut().input_handler = Some(input_handler);
}
}
}
@@ -805,7 +817,7 @@ impl PlatformWindow for WaylandWindow {
} else if state.maximized {
WindowBounds::Maximized(state.window_bounds)
} else {
- let inset = state.inset.unwrap_or(px(0.));
+ let inset = state.inset();
drop(state);
WindowBounds::Windowed(self.bounds().inset(inset))
}
@@ -1060,8 +1072,8 @@ impl PlatformWindow for WaylandWindow {
fn set_client_inset(&self, inset: Pixels) {
let mut state = self.borrow_mut();
- if Some(inset) != state.inset {
- state.inset = Some(inset);
+ if Some(inset) != state.client_inset {
+ state.client_inset = Some(inset);
update_window(state);
}
}
@@ -1081,9 +1093,7 @@ fn update_window(mut state: RefMut<WaylandWindowState>) {
state.renderer.update_transparency(!opaque);
let mut opaque_area = state.window_bounds.map(|v| v.0 as i32);
- if let Some(inset) = state.inset {
- opaque_area.inset(inset.0 as i32);
- }
+ opaque_area.inset(state.inset().0 as i32);
let region = state
.globals
@@ -1129,7 +1139,7 @@ fn update_window(mut state: RefMut<WaylandWindowState>) {
}
impl WindowDecorations {
- fn to_xdg(&self) -> zxdg_toplevel_decoration_v1::Mode {
+ fn to_xdg(self) -> zxdg_toplevel_decoration_v1::Mode {
match self {
WindowDecorations::Client => zxdg_toplevel_decoration_v1::Mode::ClientSide,
WindowDecorations::Server => zxdg_toplevel_decoration_v1::Mode::ServerSide,
@@ -1138,7 +1148,7 @@ impl WindowDecorations {
}
impl ResizeEdge {
- fn to_xdg(&self) -> xdg_toplevel::ResizeEdge {
+ fn to_xdg(self) -> xdg_toplevel::ResizeEdge {
match self {
ResizeEdge::Top => xdg_toplevel::ResizeEdge::Top,
ResizeEdge::TopRight => xdg_toplevel::ResizeEdge::TopRight,
@@ -1156,12 +1166,10 @@ impl ResizeEdge {
/// updating to account for the client decorations. But that's not the area we want to render
/// to, due to our intrusize CSD. So, here we calculate the 'actual' size, by adding back in the insets
fn compute_outer_size(
- inset: Option<Pixels>,
+ inset: Pixels,
new_size: Option<Size<Pixels>>,
tiling: Tiling,
) -> Option<Size<Pixels>> {
- let Some(inset) = inset else { return new_size };
-
new_size.map(|mut new_size| {
if !tiling.top {
new_size.height += inset;
@@ -1,23 +1,22 @@
use crate::{Capslock, xcb_flush};
-use core::str;
-use std::{
- cell::RefCell,
- collections::{BTreeMap, HashSet},
- ops::Deref,
- path::PathBuf,
- rc::{Rc, Weak},
- time::{Duration, Instant},
-};
-
use anyhow::{Context as _, anyhow};
use calloop::{
EventLoop, LoopHandle, RegistrationToken,
generic::{FdWrapper, Generic},
};
use collections::HashMap;
+use core::str;
use http_client::Url;
use log::Level;
use smallvec::SmallVec;
+use std::{
+ cell::RefCell,
+ collections::{BTreeMap, HashSet},
+ ops::Deref,
+ path::PathBuf,
+ rc::{Rc, Weak},
+ time::{Duration, Instant},
+};
use util::ResultExt;
use x11rb::{
@@ -38,7 +37,7 @@ use x11rb::{
};
use xim::{AttributeName, Client, InputStyle, x11rb::X11rbClient};
use xkbc::x11::ffi::{XKB_X11_MIN_MAJOR_XKB_VERSION, XKB_X11_MIN_MINOR_XKB_VERSION};
-use xkbcommon::xkb::{self as xkbc, LayoutIndex, ModMask, STATE_LAYOUT_EFFECTIVE};
+use xkbcommon::xkb::{self as xkbc, STATE_LAYOUT_EFFECTIVE};
use super::{
ButtonOrScroll, ScrollDirection, X11Display, X11WindowStatePtr, XcbAtoms, XimCallbackEvent,
@@ -141,13 +140,6 @@ impl From<xim::ClientError> for EventHandlerError {
}
}
-#[derive(Debug, Default, Clone)]
-struct XKBStateNotiy {
- depressed_layout: LayoutIndex,
- latched_layout: LayoutIndex,
- locked_layout: LayoutIndex,
-}
-
#[derive(Debug, Default)]
pub struct Xdnd {
other_window: xproto::Window,
@@ -200,7 +192,6 @@ pub struct X11ClientState {
pub(crate) mouse_focused_window: Option<xproto::Window>,
pub(crate) keyboard_focused_window: Option<xproto::Window>,
pub(crate) xkb: xkbc::State,
- previous_xkb_state: XKBStateNotiy,
keyboard_layout: LinuxKeyboardLayout,
pub(crate) ximc: Option<X11rbClient<Rc<XCBConnection>>>,
pub(crate) xim_handler: Option<XimHandler>,
@@ -241,15 +232,12 @@ impl X11ClientStatePtr {
};
let mut state = client.0.borrow_mut();
- if let Some(window_ref) = state.windows.remove(&x_window) {
- match window_ref.refresh_state {
- Some(RefreshState::PeriodicRefresh {
- event_loop_token, ..
- }) => {
- state.loop_handle.remove(event_loop_token);
- }
- _ => {}
- }
+ if let Some(window_ref) = state.windows.remove(&x_window)
+ && let Some(RefreshState::PeriodicRefresh {
+ event_loop_token, ..
+ }) = window_ref.refresh_state
+ {
+ state.loop_handle.remove(event_loop_token);
}
if state.mouse_focused_window == Some(x_window) {
state.mouse_focused_window = None;
@@ -468,7 +456,7 @@ impl X11Client {
move |event, _, client| match event {
XDPEvent::WindowAppearance(appearance) => {
client.with_common(|common| common.appearance = appearance);
- for (_, window) in &mut client.0.borrow_mut().windows {
+ for window in client.0.borrow_mut().windows.values_mut() {
window.window.set_appearance(appearance);
}
}
@@ -507,7 +495,6 @@ impl X11Client {
mouse_focused_window: None,
keyboard_focused_window: None,
xkb: xkb_state,
- previous_xkb_state: XKBStateNotiy::default(),
keyboard_layout,
ximc,
xim_handler,
@@ -575,10 +562,10 @@ impl X11Client {
events.push(last_keymap_change_event);
}
- if let Some(last_press) = last_key_press.as_ref() {
- if last_press.detail == key_press.detail {
- continue;
- }
+ if let Some(last_press) = last_key_press.as_ref()
+ && last_press.detail == key_press.detail
+ {
+ continue;
}
if let Some(Event::KeyRelease(key_release)) =
@@ -652,13 +639,7 @@ impl X11Client {
let xim_connected = xim_handler.connected;
drop(state);
- let xim_filtered = match ximc.filter_event(&event, &mut xim_handler) {
- Ok(handled) => handled,
- Err(err) => {
- log::error!("XIMClientError: {}", err);
- false
- }
- };
+ let xim_filtered = ximc.filter_event(&event, &mut xim_handler);
let xim_callback_event = xim_handler.last_callback_event.take();
let mut state = self.0.borrow_mut();
@@ -669,14 +650,28 @@ impl X11Client {
self.handle_xim_callback_event(event);
}
- if xim_filtered {
- continue;
- }
-
- if xim_connected {
- self.xim_handle_event(event);
- } else {
- self.handle_event(event);
+ match xim_filtered {
+ Ok(handled) => {
+ if handled {
+ continue;
+ }
+ if xim_connected {
+ self.xim_handle_event(event);
+ } else {
+ self.handle_event(event);
+ }
+ }
+ Err(err) => {
+ // this might happen when xim server crashes on one of the events
+ // we do lose 1-2 keys when crash happens since there is no reliable way to get that info
+ // luckily, x11 sends us window not found error when xim server crashes upon further key press
+ // hence we fall back to handle_event
+ log::error!("XIMClientError: {}", err);
+ let mut state = self.0.borrow_mut();
+ state.take_xim();
+ drop(state);
+ self.handle_event(event);
+ }
}
}
}
@@ -878,22 +873,19 @@ impl X11Client {
let Some(reply) = reply else {
return Some(());
};
- match str::from_utf8(&reply.value) {
- Ok(file_list) => {
- let paths: SmallVec<[_; 2]> = file_list
- .lines()
- .filter_map(|path| Url::parse(path).log_err())
- .filter_map(|url| url.to_file_path().log_err())
- .collect();
- let input = PlatformInput::FileDrop(FileDropEvent::Entered {
- position: state.xdnd_state.position,
- paths: crate::ExternalPaths(paths),
- });
- drop(state);
- window.handle_input(input);
- self.0.borrow_mut().xdnd_state.retrieved = true;
- }
- Err(_) => {}
+ if let Ok(file_list) = str::from_utf8(&reply.value) {
+ let paths: SmallVec<[_; 2]> = file_list
+ .lines()
+ .filter_map(|path| Url::parse(path).log_err())
+ .filter_map(|url| url.to_file_path().log_err())
+ .collect();
+ let input = PlatformInput::FileDrop(FileDropEvent::Entered {
+ position: state.xdnd_state.position,
+ paths: crate::ExternalPaths(paths),
+ });
+ drop(state);
+ window.handle_input(input);
+ self.0.borrow_mut().xdnd_state.retrieved = true;
}
}
Event::ConfigureNotify(event) => {
@@ -959,14 +951,6 @@ impl X11Client {
state.xkb_device_id,
)
};
- let depressed_layout = xkb_state.serialize_layout(xkbc::STATE_LAYOUT_DEPRESSED);
- let latched_layout = xkb_state.serialize_layout(xkbc::STATE_LAYOUT_LATCHED);
- let locked_layout = xkb_state.serialize_layout(xkbc::ffi::XKB_STATE_LAYOUT_LOCKED);
- state.previous_xkb_state = XKBStateNotiy {
- depressed_layout,
- latched_layout,
- locked_layout,
- };
state.xkb = xkb_state;
drop(state);
self.handle_keyboard_layout_change();
@@ -983,12 +967,6 @@ impl X11Client {
event.latched_group as u32,
event.locked_group.into(),
);
- state.previous_xkb_state = XKBStateNotiy {
- depressed_layout: event.base_group as u32,
- latched_layout: event.latched_group as u32,
- locked_layout: event.locked_group.into(),
- };
-
let modifiers = Modifiers::from_xkb(&state.xkb);
let capslock = Capslock::from_xkb(&state.xkb);
if state.last_modifiers_changed_event == modifiers
@@ -1025,20 +1003,16 @@ impl X11Client {
state.pre_key_char_down.take();
let keystroke = {
let code = event.detail.into();
- let xkb_state = state.previous_xkb_state.clone();
- state.xkb.update_mask(
- event.state.bits() as ModMask,
- 0,
- 0,
- xkb_state.depressed_layout,
- xkb_state.latched_layout,
- xkb_state.locked_layout,
- );
let mut keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code);
let keysym = state.xkb.key_get_one_sym(code);
+
if keysym.is_modifier_key() {
return Some(());
}
+
+ // should be called after key_get_one_sym
+ state.xkb.update_key(code, xkbc::KeyDirection::Down);
+
if let Some(mut compose_state) = state.compose_state.take() {
compose_state.feed(keysym);
match compose_state.status() {
@@ -1093,20 +1067,16 @@ impl X11Client {
let keystroke = {
let code = event.detail.into();
- let xkb_state = state.previous_xkb_state.clone();
- state.xkb.update_mask(
- event.state.bits() as ModMask,
- 0,
- 0,
- xkb_state.depressed_layout,
- xkb_state.latched_layout,
- xkb_state.locked_layout,
- );
let keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code);
let keysym = state.xkb.key_get_one_sym(code);
+
if keysym.is_modifier_key() {
return Some(());
}
+
+ // should be called after key_get_one_sym
+ state.xkb.update_key(code, xkbc::KeyDirection::Up);
+
keystroke
};
drop(state);
@@ -1236,7 +1206,7 @@ impl X11Client {
state = self.0.borrow_mut();
if let Some(mut pointer) = state.pointer_device_states.get_mut(&event.sourceid) {
- let scroll_delta = get_scroll_delta_and_update_state(&mut pointer, &event);
+ let scroll_delta = get_scroll_delta_and_update_state(pointer, &event);
drop(state);
if let Some(scroll_delta) = scroll_delta {
window.handle_input(PlatformInput::ScrollWheel(make_scroll_wheel_event(
@@ -1295,7 +1265,7 @@ impl X11Client {
Event::XinputDeviceChanged(event) => {
let mut state = self.0.borrow_mut();
if let Some(mut pointer) = state.pointer_device_states.get_mut(&event.sourceid) {
- reset_pointer_device_scroll_positions(&mut pointer);
+ reset_pointer_device_scroll_positions(pointer);
}
}
_ => {}
@@ -1359,7 +1329,7 @@ impl X11Client {
state.composing = false;
drop(state);
if let Some(mut keystroke) = keystroke {
- keystroke.key_char = Some(text.clone());
+ keystroke.key_char = Some(text);
window.handle_input(PlatformInput::KeyDown(crate::KeyDownEvent {
keystroke,
is_held: false,
@@ -1482,7 +1452,7 @@ impl LinuxClient for X11Client {
#[cfg(feature = "screen-capture")]
fn screen_capture_sources(
&self,
- ) -> futures::channel::oneshot::Receiver<anyhow::Result<Vec<Box<dyn crate::ScreenCaptureSource>>>>
+ ) -> futures::channel::oneshot::Receiver<anyhow::Result<Vec<Rc<dyn crate::ScreenCaptureSource>>>>
{
crate::platform::scap_screen_capture::scap_screen_sources(
&self.0.borrow().common.foreground_executor,
@@ -1610,11 +1580,11 @@ impl LinuxClient for X11Client {
fn read_from_primary(&self) -> Option<crate::ClipboardItem> {
let state = self.0.borrow_mut();
- return state
+ state
.clipboard
.get_any(clipboard::ClipboardKind::Primary)
.context("X11: Failed to read from clipboard (primary)")
- .log_with_level(log::Level::Debug);
+ .log_with_level(log::Level::Debug)
}
fn read_from_clipboard(&self) -> Option<crate::ClipboardItem> {
@@ -1627,11 +1597,11 @@ impl LinuxClient for X11Client {
{
return state.clipboard_item.clone();
}
- return state
+ state
.clipboard
.get_any(clipboard::ClipboardKind::Clipboard)
.context("X11: Failed to read from clipboard (clipboard)")
- .log_with_level(log::Level::Debug);
+ .log_with_level(log::Level::Debug)
}
fn run(&self) {
@@ -1827,6 +1797,7 @@ impl X11ClientState {
drop(state);
window.refresh(RequestFrameOptions {
require_presentation: expose_event_received,
+ force_render: false,
});
}
xcb_connection
@@ -2033,12 +2004,12 @@ fn check_gtk_frame_extents_supported(
}
fn xdnd_is_atom_supported(atom: u32, atoms: &XcbAtoms) -> bool {
- return atom == atoms.TEXT
+ atom == atoms.TEXT
|| atom == atoms.STRING
|| atom == atoms.UTF8_STRING
|| atom == atoms.TEXT_PLAIN
|| atom == atoms.TEXT_PLAIN_UTF8
- || atom == atoms.TextUriList;
+ || atom == atoms.TextUriList
}
fn xdnd_get_supported_atom(
@@ -2058,16 +2029,15 @@ fn xdnd_get_supported_atom(
),
)
.log_with_level(Level::Warn)
+ && let Some(atoms) = reply.value32()
{
- if let Some(atoms) = reply.value32() {
- for atom in atoms {
- if xdnd_is_atom_supported(atom, &supported_atoms) {
- return atom;
- }
+ for atom in atoms {
+ if xdnd_is_atom_supported(atom, supported_atoms) {
+ return atom;
}
}
}
- return 0;
+ 0
}
fn xdnd_send_finished(
@@ -2138,7 +2108,7 @@ fn current_pointer_device_states(
.classes
.iter()
.filter_map(|class| class.data.as_scroll())
- .map(|class| *class)
+ .copied()
.rev()
.collect::<Vec<_>>();
let old_state = scroll_values_to_preserve.get(&info.deviceid);
@@ -2168,7 +2138,7 @@ fn current_pointer_device_states(
if pointer_device_states.is_empty() {
log::error!("Found no xinput mouse pointers.");
}
- return Some(pointer_device_states);
+ Some(pointer_device_states)
}
/// Returns true if the device is a pointer device. Does not include pointer device groups.
@@ -2434,11 +2404,13 @@ fn legacy_get_randr_scale_factor(connection: &XCBConnection, root: u32) -> Optio
let mut crtc_infos: HashMap<randr::Crtc, randr::GetCrtcInfoReply> = HashMap::default();
let mut valid_outputs: HashSet<randr::Output> = HashSet::new();
for (crtc, cookie) in crtc_cookies {
- if let Ok(reply) = cookie.reply() {
- if reply.width > 0 && reply.height > 0 && !reply.outputs.is_empty() {
- crtc_infos.insert(crtc, reply.clone());
- valid_outputs.extend(&reply.outputs);
- }
+ if let Ok(reply) = cookie.reply()
+ && reply.width > 0
+ && reply.height > 0
+ && !reply.outputs.is_empty()
+ {
+ crtc_infos.insert(crtc, reply.clone());
+ valid_outputs.extend(&reply.outputs);
}
}
@@ -1078,11 +1078,11 @@ impl Clipboard {
} else {
String::from_utf8(result.bytes).map_err(|_| Error::ConversionFailure)?
};
- return Ok(ClipboardItem::new_string(text));
+ Ok(ClipboardItem::new_string(text))
}
pub fn is_owner(&self, selection: ClipboardKind) -> bool {
- return self.inner.is_owner(selection).unwrap_or(false);
+ self.inner.is_owner(selection).unwrap_or(false)
}
}
@@ -1120,25 +1120,25 @@ impl Drop for Clipboard {
log::error!("Failed to flush the clipboard window. Error: {}", e);
return;
}
- if let Some(global_cb) = global_cb {
- if let Err(e) = global_cb.server_handle.join() {
- // Let's try extracting the error message
- let message;
- if let Some(msg) = e.downcast_ref::<&'static str>() {
- message = Some((*msg).to_string());
- } else if let Some(msg) = e.downcast_ref::<String>() {
- message = Some(msg.clone());
- } else {
- message = None;
- }
- if let Some(message) = message {
- log::error!(
- "The clipboard server thread panicked. Panic message: '{}'",
- message,
- );
- } else {
- log::error!("The clipboard server thread panicked.");
- }
+ if let Some(global_cb) = global_cb
+ && let Err(e) = global_cb.server_handle.join()
+ {
+ // Let's try extracting the error message
+ let message;
+ if let Some(msg) = e.downcast_ref::<&'static str>() {
+ message = Some((*msg).to_string());
+ } else if let Some(msg) = e.downcast_ref::<String>() {
+ message = Some(msg.clone());
+ } else {
+ message = None;
+ }
+ if let Some(message) = message {
+ log::error!(
+ "The clipboard server thread panicked. Panic message: '{}'",
+ message,
+ );
+ } else {
+ log::error!("The clipboard server thread panicked.");
}
}
}
@@ -73,8 +73,8 @@ pub(crate) fn get_valuator_axis_index(
// valuator present in this event's axisvalues. Axisvalues is ordered from
// lowest valuator number to highest, so counting bits before the 1 bit for
// this valuator yields the index in axisvalues.
- if bit_is_set_in_vec(&valuator_mask, valuator_number) {
- Some(popcount_upto_bit_index(&valuator_mask, valuator_number) as usize)
+ if bit_is_set_in_vec(valuator_mask, valuator_number) {
+ Some(popcount_upto_bit_index(valuator_mask, valuator_number) as usize)
} else {
None
}
@@ -104,7 +104,7 @@ fn bit_is_set_in_vec(bit_vec: &Vec<u32>, bit_index: u16) -> bool {
let array_index = bit_index as usize / 32;
bit_vec
.get(array_index)
- .map_or(false, |bits| bit_is_set(*bits, bit_index % 32))
+ .is_some_and(|bits| bit_is_set(*bits, bit_index % 32))
}
fn bit_is_set(bits: u32, bit_index: u16) -> bool {
@@ -95,7 +95,7 @@ fn query_render_extent(
}
impl ResizeEdge {
- fn to_moveresize(&self) -> u32 {
+ fn to_moveresize(self) -> u32 {
match self {
ResizeEdge::TopLeft => 0,
ResizeEdge::Top => 1,
@@ -397,7 +397,7 @@ impl X11WindowState {
.display_id
.map_or(x_main_screen_index, |did| did.0 as usize);
- let visual_set = find_visuals(&xcb, x_screen_index);
+ let visual_set = find_visuals(xcb, x_screen_index);
let visual = match visual_set.transparent {
Some(visual) => visual,
@@ -515,19 +515,19 @@ impl X11WindowState {
xcb.configure_window(x_window, &xproto::ConfigureWindowAux::new().x(x).y(y)),
)?;
}
- if let Some(titlebar) = params.titlebar {
- if let Some(title) = titlebar.title {
- check_reply(
- || "X11 ChangeProperty8 on window title failed.",
- xcb.change_property8(
- xproto::PropMode::REPLACE,
- x_window,
- xproto::AtomEnum::WM_NAME,
- xproto::AtomEnum::STRING,
- title.as_bytes(),
- ),
- )?;
- }
+ if let Some(titlebar) = params.titlebar
+ && let Some(title) = titlebar.title
+ {
+ check_reply(
+ || "X11 ChangeProperty8 on window title failed.",
+ xcb.change_property8(
+ xproto::PropMode::REPLACE,
+ x_window,
+ xproto::AtomEnum::WM_NAME,
+ xproto::AtomEnum::STRING,
+ title.as_bytes(),
+ ),
+ )?;
}
if params.kind == WindowKind::PopUp {
check_reply(
@@ -604,7 +604,7 @@ impl X11WindowState {
),
)?;
- xcb_flush(&xcb);
+ xcb_flush(xcb);
let renderer = {
let raw_window = RawWindow {
@@ -664,7 +664,7 @@ impl X11WindowState {
|| "X11 DestroyWindow failed while cleaning it up after setup failure.",
xcb.destroy_window(x_window),
)?;
- xcb_flush(&xcb);
+ xcb_flush(xcb);
}
setup_result
@@ -956,10 +956,10 @@ impl X11WindowStatePtr {
}
pub fn handle_input(&self, input: PlatformInput) {
- if let Some(ref mut fun) = self.callbacks.borrow_mut().input {
- if !fun(input.clone()).propagate {
- return;
- }
+ if let Some(ref mut fun) = self.callbacks.borrow_mut().input
+ && !fun(input.clone()).propagate
+ {
+ return;
}
if let PlatformInput::KeyDown(event) = input {
// only allow shift modifier when inserting text
@@ -1068,15 +1068,14 @@ impl X11WindowStatePtr {
}
let mut callbacks = self.callbacks.borrow_mut();
- if let Some((content_size, scale_factor)) = resize_args {
- if let Some(ref mut fun) = callbacks.resize {
- fun(content_size, scale_factor)
- }
+ if let Some((content_size, scale_factor)) = resize_args
+ && let Some(ref mut fun) = callbacks.resize
+ {
+ fun(content_size, scale_factor)
}
- if !is_resize {
- if let Some(ref mut fun) = callbacks.moved {
- fun();
- }
+
+ if !is_resize && let Some(ref mut fun) = callbacks.moved {
+ fun();
}
Ok(())
@@ -311,9 +311,8 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke {
let mut shift = modifiers.contains(NSEventModifierFlags::NSShiftKeyMask);
let command = modifiers.contains(NSEventModifierFlags::NSCommandKeyMask);
let function = modifiers.contains(NSEventModifierFlags::NSFunctionKeyMask)
- && first_char.map_or(true, |ch| {
- !(NSUpArrowFunctionKey..=NSModeSwitchFunctionKey).contains(&ch)
- });
+ && first_char
+ .is_none_or(|ch| !(NSUpArrowFunctionKey..=NSModeSwitchFunctionKey).contains(&ch));
#[allow(non_upper_case_globals)]
let key = match first_char {
@@ -427,7 +426,7 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke {
key_char = Some(chars_for_modified_key(native_event.keyCode(), mods));
}
- let mut key = if shift
+ if shift
&& chars_ignoring_modifiers
.chars()
.all(|c| c.is_ascii_lowercase())
@@ -438,9 +437,7 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke {
chars_with_shift
} else {
chars_ignoring_modifiers
- };
-
- key
+ }
}
};
@@ -25,27 +25,6 @@ impl MetalAtlas {
pub(crate) fn metal_texture(&self, id: AtlasTextureId) -> metal::Texture {
self.0.lock().texture(id).metal_texture.clone()
}
-
- #[allow(dead_code)]
- pub(crate) fn allocate(
- &self,
- size: Size<DevicePixels>,
- texture_kind: AtlasTextureKind,
- ) -> Option<AtlasTile> {
- self.0.lock().allocate(size, texture_kind)
- }
-
- #[allow(dead_code)]
- pub(crate) fn clear_textures(&self, texture_kind: AtlasTextureKind) {
- let mut lock = self.0.lock();
- let textures = match texture_kind {
- AtlasTextureKind::Monochrome => &mut lock.monochrome_textures,
- AtlasTextureKind::Polychrome => &mut lock.polychrome_textures,
- };
- for texture in textures.iter_mut() {
- texture.clear();
- }
- }
}
struct MetalAtlasState {
@@ -212,10 +191,6 @@ struct MetalAtlasTexture {
}
impl MetalAtlasTexture {
- fn clear(&mut self) {
- self.allocator.clear();
- }
-
fn allocate(&mut self, size: Size<DevicePixels>) -> Option<AtlasTile> {
let allocation = self.allocator.allocate(size.into())?;
let tile = AtlasTile {
@@ -1,7 +1,7 @@
use super::metal_atlas::MetalAtlas;
use crate::{
AtlasTextureId, Background, Bounds, ContentMask, DevicePixels, MonochromeSprite, PaintSurface,
- Path, PathVertex, PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size,
+ Path, Point, PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size,
Surface, Underline, point, size,
};
use anyhow::Result;
@@ -11,6 +11,7 @@ use cocoa::{
foundation::{NSSize, NSUInteger},
quartzcore::AutoresizingMask,
};
+
use core_foundation::base::TCFType;
use core_video::{
metal_texture::CVMetalTextureGetTexture, metal_texture_cache::CVMetalTextureCache,
@@ -18,11 +19,12 @@ use core_video::{
};
use foreign_types::{ForeignType, ForeignTypeRef};
use metal::{
- CAMetalLayer, CommandQueue, MTLDrawPrimitivesIndirectArguments, MTLPixelFormat,
- MTLResourceOptions, NSRange,
+ CAMetalLayer, CommandQueue, MTLPixelFormat, MTLResourceOptions, NSRange,
+ RenderPassColorAttachmentDescriptorRef,
};
use objc::{self, msg_send, sel, sel_impl};
use parking_lot::Mutex;
+
use std::{cell::Cell, ffi::c_void, mem, ptr, sync::Arc};
// Exported to metal
@@ -32,6 +34,9 @@ pub(crate) type PointF = crate::Point<f32>;
const SHADERS_METALLIB: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/shaders.metallib"));
#[cfg(feature = "runtime_shaders")]
const SHADERS_SOURCE_FILE: &str = include_str!(concat!(env!("OUT_DIR"), "/stitched_shaders.metal"));
+// Use 4x MSAA, all devices support it.
+// https://developer.apple.com/documentation/metal/mtldevice/1433355-supportstexturesamplecount
+const PATH_SAMPLE_COUNT: u32 = 4;
pub type Context = Arc<Mutex<InstanceBufferPool>>;
pub type Renderer = MetalRenderer;
@@ -96,7 +101,8 @@ pub(crate) struct MetalRenderer {
layer: metal::MetalLayer,
presents_with_transaction: bool,
command_queue: CommandQueue,
- path_pipeline_state: metal::RenderPipelineState,
+ paths_rasterization_pipeline_state: metal::RenderPipelineState,
+ path_sprites_pipeline_state: metal::RenderPipelineState,
shadows_pipeline_state: metal::RenderPipelineState,
quads_pipeline_state: metal::RenderPipelineState,
underlines_pipeline_state: metal::RenderPipelineState,
@@ -108,8 +114,17 @@ pub(crate) struct MetalRenderer {
instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>,
sprite_atlas: Arc<MetalAtlas>,
core_video_texture_cache: core_video::metal_texture_cache::CVMetalTextureCache,
- sample_count: u64,
- msaa_texture: Option<metal::Texture>,
+ path_intermediate_texture: Option<metal::Texture>,
+ path_intermediate_msaa_texture: Option<metal::Texture>,
+ path_sample_count: u32,
+}
+
+#[repr(C)]
+pub struct PathRasterizationVertex {
+ pub xy_position: Point<ScaledPixels>,
+ pub st_position: Point<f32>,
+ pub color: Background,
+ pub bounds: Bounds<ScaledPixels>,
}
impl MetalRenderer {
@@ -168,19 +183,22 @@ impl MetalRenderer {
MTLResourceOptions::StorageModeManaged,
);
- let sample_count = [4, 2, 1]
- .into_iter()
- .find(|count| device.supports_texture_sample_count(*count))
- .unwrap_or(1);
-
- let path_pipeline_state = build_pipeline_state(
+ let paths_rasterization_pipeline_state = build_path_rasterization_pipeline_state(
&device,
&library,
- "paths",
- "path_vertex",
- "path_fragment",
+ "paths_rasterization",
+ "path_rasterization_vertex",
+ "path_rasterization_fragment",
+ MTLPixelFormat::BGRA8Unorm,
+ PATH_SAMPLE_COUNT,
+ );
+ let path_sprites_pipeline_state = build_path_sprite_pipeline_state(
+ &device,
+ &library,
+ "path_sprites",
+ "path_sprite_vertex",
+ "path_sprite_fragment",
MTLPixelFormat::BGRA8Unorm,
- sample_count,
);
let shadows_pipeline_state = build_pipeline_state(
&device,
@@ -189,7 +207,6 @@ impl MetalRenderer {
"shadow_vertex",
"shadow_fragment",
MTLPixelFormat::BGRA8Unorm,
- sample_count,
);
let quads_pipeline_state = build_pipeline_state(
&device,
@@ -198,7 +215,6 @@ impl MetalRenderer {
"quad_vertex",
"quad_fragment",
MTLPixelFormat::BGRA8Unorm,
- sample_count,
);
let underlines_pipeline_state = build_pipeline_state(
&device,
@@ -207,7 +223,6 @@ impl MetalRenderer {
"underline_vertex",
"underline_fragment",
MTLPixelFormat::BGRA8Unorm,
- sample_count,
);
let monochrome_sprites_pipeline_state = build_pipeline_state(
&device,
@@ -216,7 +231,6 @@ impl MetalRenderer {
"monochrome_sprite_vertex",
"monochrome_sprite_fragment",
MTLPixelFormat::BGRA8Unorm,
- sample_count,
);
let polychrome_sprites_pipeline_state = build_pipeline_state(
&device,
@@ -225,7 +239,6 @@ impl MetalRenderer {
"polychrome_sprite_vertex",
"polychrome_sprite_fragment",
MTLPixelFormat::BGRA8Unorm,
- sample_count,
);
let surfaces_pipeline_state = build_pipeline_state(
&device,
@@ -234,21 +247,20 @@ impl MetalRenderer {
"surface_vertex",
"surface_fragment",
MTLPixelFormat::BGRA8Unorm,
- sample_count,
);
let command_queue = device.new_command_queue();
let sprite_atlas = Arc::new(MetalAtlas::new(device.clone()));
let core_video_texture_cache =
CVMetalTextureCache::new(None, device.clone(), None).unwrap();
- let msaa_texture = create_msaa_texture(&device, &layer, sample_count);
Self {
device,
layer,
presents_with_transaction: false,
command_queue,
- path_pipeline_state,
+ paths_rasterization_pipeline_state,
+ path_sprites_pipeline_state,
shadows_pipeline_state,
quads_pipeline_state,
underlines_pipeline_state,
@@ -259,8 +271,9 @@ impl MetalRenderer {
instance_buffer_pool,
sprite_atlas,
core_video_texture_cache,
- sample_count,
- msaa_texture,
+ path_intermediate_texture: None,
+ path_intermediate_msaa_texture: None,
+ path_sample_count: PATH_SAMPLE_COUNT,
}
}
@@ -293,8 +306,40 @@ impl MetalRenderer {
setDrawableSize: size
];
}
+ let device_pixels_size = Size {
+ width: DevicePixels(size.width as i32),
+ height: DevicePixels(size.height as i32),
+ };
+ self.update_path_intermediate_textures(device_pixels_size);
+ }
- self.msaa_texture = create_msaa_texture(&self.device, &self.layer, self.sample_count);
+ fn update_path_intermediate_textures(&mut self, size: Size<DevicePixels>) {
+ // We are uncertain when this happens, but sometimes size can be 0 here. Most likely before
+ // the layout pass on window creation. Zero-sized texture creation causes SIGABRT.
+ // https://github.com/zed-industries/zed/issues/36229
+ if size.width.0 <= 0 || size.height.0 <= 0 {
+ self.path_intermediate_texture = None;
+ self.path_intermediate_msaa_texture = None;
+ return;
+ }
+
+ 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(metal::MTLPixelFormat::BGRA8Unorm);
+ texture_descriptor
+ .set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead);
+ self.path_intermediate_texture = Some(self.device.new_texture(&texture_descriptor));
+
+ if self.path_sample_count > 1 {
+ let mut msaa_descriptor = texture_descriptor;
+ msaa_descriptor.set_texture_type(metal::MTLTextureType::D2Multisample);
+ msaa_descriptor.set_storage_mode(metal::MTLStorageMode::Private);
+ msaa_descriptor.set_sample_count(self.path_sample_count as _);
+ self.path_intermediate_msaa_texture = Some(self.device.new_texture(&msaa_descriptor));
+ } else {
+ self.path_intermediate_msaa_texture = None;
+ }
}
pub fn update_transparency(&self, _transparent: bool) {
@@ -380,36 +425,18 @@ impl MetalRenderer {
) -> Result<metal::CommandBuffer> {
let command_queue = self.command_queue.clone();
let command_buffer = command_queue.new_command_buffer();
- let mut instance_offset = 0;
- let render_pass_descriptor = metal::RenderPassDescriptor::new();
- let color_attachment = render_pass_descriptor
- .color_attachments()
- .object_at(0)
- .unwrap();
-
- if let Some(msaa_texture_ref) = self.msaa_texture.as_deref() {
- color_attachment.set_texture(Some(msaa_texture_ref));
- color_attachment.set_load_action(metal::MTLLoadAction::Clear);
- color_attachment.set_store_action(metal::MTLStoreAction::MultisampleResolve);
- color_attachment.set_resolve_texture(Some(drawable.texture()));
- } else {
- color_attachment.set_load_action(metal::MTLLoadAction::Clear);
- color_attachment.set_texture(Some(drawable.texture()));
- color_attachment.set_store_action(metal::MTLStoreAction::Store);
- }
-
let alpha = if self.layer.is_opaque() { 1. } else { 0. };
- color_attachment.set_clear_color(metal::MTLClearColor::new(0., 0., 0., alpha));
- let command_encoder = command_buffer.new_render_command_encoder(render_pass_descriptor);
+ let mut instance_offset = 0;
- command_encoder.set_viewport(metal::MTLViewport {
- originX: 0.0,
- originY: 0.0,
- width: i32::from(viewport_size.width) as f64,
- height: i32::from(viewport_size.height) as f64,
- znear: 0.0,
- zfar: 1.0,
- });
+ let mut command_encoder = new_command_encoder(
+ command_buffer,
+ drawable,
+ viewport_size,
+ |color_attachment| {
+ color_attachment.set_load_action(metal::MTLLoadAction::Clear);
+ color_attachment.set_clear_color(metal::MTLClearColor::new(0., 0., 0., alpha));
+ },
+ );
for batch in scene.batches() {
let ok = match batch {
@@ -427,13 +454,38 @@ impl MetalRenderer {
viewport_size,
command_encoder,
),
- PrimitiveBatch::Paths(paths) => self.draw_paths(
- paths,
- instance_buffer,
- &mut instance_offset,
- viewport_size,
- command_encoder,
- ),
+ PrimitiveBatch::Paths(paths) => {
+ command_encoder.end_encoding();
+
+ let did_draw = self.draw_paths_to_intermediate(
+ paths,
+ instance_buffer,
+ &mut instance_offset,
+ viewport_size,
+ command_buffer,
+ );
+
+ command_encoder = new_command_encoder(
+ command_buffer,
+ drawable,
+ viewport_size,
+ |color_attachment| {
+ color_attachment.set_load_action(metal::MTLLoadAction::Load);
+ },
+ );
+
+ if did_draw {
+ self.draw_paths_from_intermediate(
+ paths,
+ instance_buffer,
+ &mut instance_offset,
+ viewport_size,
+ command_encoder,
+ )
+ } else {
+ false
+ }
+ }
PrimitiveBatch::Underlines(underlines) => self.draw_underlines(
underlines,
instance_buffer,
@@ -471,7 +523,6 @@ impl MetalRenderer {
command_encoder,
),
};
-
if !ok {
command_encoder.end_encoding();
anyhow::bail!(
@@ -496,6 +547,92 @@ impl MetalRenderer {
Ok(command_buffer.to_owned())
}
+ fn draw_paths_to_intermediate(
+ &self,
+ paths: &[Path<ScaledPixels>],
+ instance_buffer: &mut InstanceBuffer,
+ instance_offset: &mut usize,
+ viewport_size: Size<DevicePixels>,
+ command_buffer: &metal::CommandBufferRef,
+ ) -> bool {
+ if paths.is_empty() {
+ return true;
+ }
+ let Some(intermediate_texture) = &self.path_intermediate_texture else {
+ return false;
+ };
+
+ let render_pass_descriptor = metal::RenderPassDescriptor::new();
+ let color_attachment = render_pass_descriptor
+ .color_attachments()
+ .object_at(0)
+ .unwrap();
+ color_attachment.set_load_action(metal::MTLLoadAction::Clear);
+ color_attachment.set_clear_color(metal::MTLClearColor::new(0., 0., 0., 0.));
+
+ if let Some(msaa_texture) = &self.path_intermediate_msaa_texture {
+ color_attachment.set_texture(Some(msaa_texture));
+ color_attachment.set_resolve_texture(Some(intermediate_texture));
+ color_attachment.set_store_action(metal::MTLStoreAction::MultisampleResolve);
+ } else {
+ color_attachment.set_texture(Some(intermediate_texture));
+ color_attachment.set_store_action(metal::MTLStoreAction::Store);
+ }
+
+ let command_encoder = command_buffer.new_render_command_encoder(render_pass_descriptor);
+ command_encoder.set_render_pipeline_state(&self.paths_rasterization_pipeline_state);
+
+ align_offset(instance_offset);
+ let mut vertices = Vec::new();
+ for path in paths {
+ vertices.extend(path.vertices.iter().map(|v| PathRasterizationVertex {
+ xy_position: v.xy_position,
+ st_position: v.st_position,
+ color: path.color,
+ bounds: path.bounds.intersect(&path.content_mask.bounds),
+ }));
+ }
+ let vertices_bytes_len = mem::size_of_val(vertices.as_slice());
+ let next_offset = *instance_offset + vertices_bytes_len;
+ if next_offset > instance_buffer.size {
+ command_encoder.end_encoding();
+ return false;
+ }
+ command_encoder.set_vertex_buffer(
+ PathRasterizationInputIndex::Vertices as u64,
+ Some(&instance_buffer.metal_buffer),
+ *instance_offset as u64,
+ );
+ command_encoder.set_vertex_bytes(
+ PathRasterizationInputIndex::ViewportSize as u64,
+ mem::size_of_val(&viewport_size) as u64,
+ &viewport_size as *const Size<DevicePixels> as *const _,
+ );
+ command_encoder.set_fragment_buffer(
+ PathRasterizationInputIndex::Vertices as u64,
+ Some(&instance_buffer.metal_buffer),
+ *instance_offset as u64,
+ );
+ let buffer_contents =
+ unsafe { (instance_buffer.metal_buffer.contents() as *mut u8).add(*instance_offset) };
+ unsafe {
+ ptr::copy_nonoverlapping(
+ vertices.as_ptr() as *const u8,
+ buffer_contents,
+ vertices_bytes_len,
+ );
+ }
+ command_encoder.draw_primitives(
+ metal::MTLPrimitiveType::Triangle,
+ 0,
+ vertices.len() as u64,
+ );
+ *instance_offset = next_offset;
+
+ command_encoder.end_encoding();
+ true
+ }
+
fn draw_shadows(
&self,
shadows: &[Shadow],
@@ -618,7 +755,7 @@ impl MetalRenderer {
true
}
- fn draw_paths(
+ fn draw_paths_from_intermediate(
&self,
paths: &[Path<ScaledPixels>],
instance_buffer: &mut InstanceBuffer,
@@ -626,112 +763,85 @@ impl MetalRenderer {
viewport_size: Size<DevicePixels>,
command_encoder: &metal::RenderCommandEncoderRef,
) -> bool {
- if paths.is_empty() {
+ let Some(first_path) = paths.first() else {
return true;
- }
-
- command_encoder.set_render_pipeline_state(&self.path_pipeline_state);
-
- unsafe {
- let base_addr = instance_buffer.metal_buffer.contents();
- let mut p = (base_addr as *mut u8).add(*instance_offset);
- let mut draw_indirect_commands = Vec::with_capacity(paths.len());
-
- // copy vertices
- let vertices_offset = (p as usize) - (base_addr as usize);
- let mut first_vertex = 0;
- for (i, path) in paths.iter().enumerate() {
- if (p as usize) - (base_addr as usize)
- + (mem::size_of::<PathVertex<ScaledPixels>>() * path.vertices.len())
- > instance_buffer.size
- {
- return false;
- }
+ };
- for v in &path.vertices {
- *(p as *mut PathVertex<ScaledPixels>) = PathVertex {
- xy_position: v.xy_position,
- content_mask: ContentMask {
- bounds: path.content_mask.bounds,
- },
- };
- p = p.add(mem::size_of::<PathVertex<ScaledPixels>>());
- }
+ let Some(ref intermediate_texture) = self.path_intermediate_texture else {
+ return false;
+ };
- draw_indirect_commands.push(MTLDrawPrimitivesIndirectArguments {
- vertexCount: path.vertices.len() as u32,
- instanceCount: 1,
- vertexStart: first_vertex,
- baseInstance: i as u32,
- });
- first_vertex += path.vertices.len() as u32;
- }
+ command_encoder.set_render_pipeline_state(&self.path_sprites_pipeline_state);
+ command_encoder.set_vertex_buffer(
+ SpriteInputIndex::Vertices as u64,
+ Some(&self.unit_vertices),
+ 0,
+ );
+ command_encoder.set_vertex_bytes(
+ SpriteInputIndex::ViewportSize as u64,
+ mem::size_of_val(&viewport_size) as u64,
+ &viewport_size as *const Size<DevicePixels> as *const _,
+ );
- // copy sprites
- let sprites_offset = (p as u64) - (base_addr as u64);
- if (p as usize) - (base_addr as usize) + (mem::size_of::<PathSprite>() * paths.len())
- > instance_buffer.size
- {
- return false;
- }
- for path in paths {
- *(p as *mut PathSprite) = PathSprite {
- bounds: path.bounds,
- color: path.color,
- };
- p = p.add(mem::size_of::<PathSprite>());
- }
+ command_encoder.set_fragment_texture(
+ SpriteInputIndex::AtlasTexture as u64,
+ Some(intermediate_texture),
+ );
- // copy indirect commands
- let icb_bytes_len = mem::size_of_val(draw_indirect_commands.as_slice());
- let icb_offset = (p as u64) - (base_addr as u64);
- if (p as usize) - (base_addr as usize) + icb_bytes_len > instance_buffer.size {
- return false;
+ // When copying paths from the intermediate texture to the drawable,
+ // each pixel must only be copied once, in case of transparent paths.
+ //
+ // If all paths have the same draw order, then their bounds are all
+ // disjoint, so we can copy each path's bounds individually. If this
+ // batch combines different draw orders, we perform a single copy
+ // for a minimal spanning rect.
+ let sprites;
+ if paths.last().unwrap().order == first_path.order {
+ sprites = paths
+ .iter()
+ .map(|path| PathSprite {
+ bounds: path.clipped_bounds(),
+ })
+ .collect();
+ } else {
+ let mut bounds = first_path.clipped_bounds();
+ for path in paths.iter().skip(1) {
+ bounds = bounds.union(&path.clipped_bounds());
}
- ptr::copy_nonoverlapping(
- draw_indirect_commands.as_ptr() as *const u8,
- p,
- icb_bytes_len,
- );
- p = p.add(icb_bytes_len);
-
- // draw path
- command_encoder.set_vertex_buffer(
- PathInputIndex::Vertices as u64,
- Some(&instance_buffer.metal_buffer),
- vertices_offset as u64,
- );
+ sprites = vec![PathSprite { bounds }];
+ }
- command_encoder.set_vertex_bytes(
- PathInputIndex::ViewportSize as u64,
- mem::size_of_val(&viewport_size) as u64,
- &viewport_size as *const Size<DevicePixels> as *const _,
- );
+ align_offset(instance_offset);
+ let sprite_bytes_len = mem::size_of_val(sprites.as_slice());
+ let next_offset = *instance_offset + sprite_bytes_len;
+ if next_offset > instance_buffer.size {
+ return false;
+ }
- command_encoder.set_vertex_buffer(
- PathInputIndex::Sprites as u64,
- Some(&instance_buffer.metal_buffer),
- sprites_offset,
- );
+ command_encoder.set_vertex_buffer(
+ SpriteInputIndex::Sprites as u64,
+ Some(&instance_buffer.metal_buffer),
+ *instance_offset as u64,
+ );
- command_encoder.set_fragment_buffer(
- PathInputIndex::Sprites as u64,
- Some(&instance_buffer.metal_buffer),
- sprites_offset,
+ let buffer_contents =
+ unsafe { (instance_buffer.metal_buffer.contents() as *mut u8).add(*instance_offset) };
+ unsafe {
+ ptr::copy_nonoverlapping(
+ sprites.as_ptr() as *const u8,
+ buffer_contents,
+ sprite_bytes_len,
);
-
- for i in 0..paths.len() {
- command_encoder.draw_primitives_indirect(
- metal::MTLPrimitiveType::Triangle,
- &instance_buffer.metal_buffer,
- icb_offset
- + (i * std::mem::size_of::<MTLDrawPrimitivesIndirectArguments>()) as u64,
- );
- }
-
- *instance_offset = (p as usize) - (base_addr as usize);
}
+ command_encoder.draw_primitives_instanced(
+ metal::MTLPrimitiveType::Triangle,
+ 0,
+ 6,
+ sprites.len() as u64,
+ );
+ *instance_offset = next_offset;
+
true
}
@@ -1046,6 +1156,33 @@ impl MetalRenderer {
}
}
+fn new_command_encoder<'a>(
+ command_buffer: &'a metal::CommandBufferRef,
+ drawable: &'a metal::MetalDrawableRef,
+ viewport_size: Size<DevicePixels>,
+ configure_color_attachment: impl Fn(&RenderPassColorAttachmentDescriptorRef),
+) -> &'a metal::RenderCommandEncoderRef {
+ let render_pass_descriptor = metal::RenderPassDescriptor::new();
+ let color_attachment = render_pass_descriptor
+ .color_attachments()
+ .object_at(0)
+ .unwrap();
+ color_attachment.set_texture(Some(drawable.texture()));
+ color_attachment.set_store_action(metal::MTLStoreAction::Store);
+ configure_color_attachment(color_attachment);
+
+ let command_encoder = command_buffer.new_render_command_encoder(render_pass_descriptor);
+ command_encoder.set_viewport(metal::MTLViewport {
+ originX: 0.0,
+ originY: 0.0,
+ width: i32::from(viewport_size.width) as f64,
+ height: i32::from(viewport_size.height) as f64,
+ znear: 0.0,
+ zfar: 1.0,
+ });
+ command_encoder
+}
+
fn build_pipeline_state(
device: &metal::DeviceRef,
library: &metal::LibraryRef,
@@ -1053,7 +1190,6 @@ fn build_pipeline_state(
vertex_fn_name: &str,
fragment_fn_name: &str,
pixel_format: metal::MTLPixelFormat,
- sample_count: u64,
) -> metal::RenderPipelineState {
let vertex_fn = library
.get_function(vertex_fn_name, None)
@@ -1066,7 +1202,6 @@ fn build_pipeline_state(
descriptor.set_label(label);
descriptor.set_vertex_function(Some(vertex_fn.as_ref()));
descriptor.set_fragment_function(Some(fragment_fn.as_ref()));
- descriptor.set_sample_count(sample_count);
let color_attachment = descriptor.color_attachments().object_at(0).unwrap();
color_attachment.set_pixel_format(pixel_format);
color_attachment.set_blending_enabled(true);
@@ -1082,43 +1217,82 @@ fn build_pipeline_state(
.expect("could not create render pipeline state")
}
-// Align to multiples of 256 make Metal happy.
-fn align_offset(offset: &mut usize) {
- *offset = (*offset).div_ceil(256) * 256;
-}
+fn build_path_sprite_pipeline_state(
+ device: &metal::DeviceRef,
+ library: &metal::LibraryRef,
+ label: &str,
+ vertex_fn_name: &str,
+ fragment_fn_name: &str,
+ pixel_format: metal::MTLPixelFormat,
+) -> metal::RenderPipelineState {
+ let vertex_fn = library
+ .get_function(vertex_fn_name, None)
+ .expect("error locating vertex function");
+ let fragment_fn = library
+ .get_function(fragment_fn_name, None)
+ .expect("error locating fragment function");
-fn create_msaa_texture(
- device: &metal::Device,
- layer: &metal::MetalLayer,
- sample_count: u64,
-) -> Option<metal::Texture> {
- let viewport_size = layer.drawable_size();
- let width = viewport_size.width.ceil() as u64;
- let height = viewport_size.height.ceil() as u64;
-
- if width == 0 || height == 0 {
- return None;
- }
+ let descriptor = metal::RenderPipelineDescriptor::new();
+ descriptor.set_label(label);
+ descriptor.set_vertex_function(Some(vertex_fn.as_ref()));
+ descriptor.set_fragment_function(Some(fragment_fn.as_ref()));
+ let color_attachment = descriptor.color_attachments().object_at(0).unwrap();
+ color_attachment.set_pixel_format(pixel_format);
+ color_attachment.set_blending_enabled(true);
+ color_attachment.set_rgb_blend_operation(metal::MTLBlendOperation::Add);
+ color_attachment.set_alpha_blend_operation(metal::MTLBlendOperation::Add);
+ color_attachment.set_source_rgb_blend_factor(metal::MTLBlendFactor::One);
+ color_attachment.set_source_alpha_blend_factor(metal::MTLBlendFactor::One);
+ color_attachment.set_destination_rgb_blend_factor(metal::MTLBlendFactor::OneMinusSourceAlpha);
+ color_attachment.set_destination_alpha_blend_factor(metal::MTLBlendFactor::One);
- if sample_count <= 1 {
- return None;
- }
+ device
+ .new_render_pipeline_state(&descriptor)
+ .expect("could not create render pipeline state")
+}
- let texture_descriptor = metal::TextureDescriptor::new();
- texture_descriptor.set_texture_type(metal::MTLTextureType::D2Multisample);
+fn build_path_rasterization_pipeline_state(
+ device: &metal::DeviceRef,
+ library: &metal::LibraryRef,
+ label: &str,
+ vertex_fn_name: &str,
+ fragment_fn_name: &str,
+ pixel_format: metal::MTLPixelFormat,
+ path_sample_count: u32,
+) -> metal::RenderPipelineState {
+ let vertex_fn = library
+ .get_function(vertex_fn_name, None)
+ .expect("error locating vertex function");
+ let fragment_fn = library
+ .get_function(fragment_fn_name, None)
+ .expect("error locating fragment function");
- // MTLStorageMode default is `shared` only for Apple silicon GPUs. Use `private` for Apple and Intel GPUs both.
- // Reference: https://developer.apple.com/documentation/metal/choosing-a-resource-storage-mode-for-apple-gpus
- texture_descriptor.set_storage_mode(metal::MTLStorageMode::Private);
+ let descriptor = metal::RenderPipelineDescriptor::new();
+ descriptor.set_label(label);
+ descriptor.set_vertex_function(Some(vertex_fn.as_ref()));
+ descriptor.set_fragment_function(Some(fragment_fn.as_ref()));
+ if path_sample_count > 1 {
+ descriptor.set_raster_sample_count(path_sample_count as _);
+ descriptor.set_alpha_to_coverage_enabled(false);
+ }
+ let color_attachment = descriptor.color_attachments().object_at(0).unwrap();
+ color_attachment.set_pixel_format(pixel_format);
+ color_attachment.set_blending_enabled(true);
+ color_attachment.set_rgb_blend_operation(metal::MTLBlendOperation::Add);
+ color_attachment.set_alpha_blend_operation(metal::MTLBlendOperation::Add);
+ color_attachment.set_source_rgb_blend_factor(metal::MTLBlendFactor::One);
+ color_attachment.set_source_alpha_blend_factor(metal::MTLBlendFactor::One);
+ color_attachment.set_destination_rgb_blend_factor(metal::MTLBlendFactor::OneMinusSourceAlpha);
+ color_attachment.set_destination_alpha_blend_factor(metal::MTLBlendFactor::OneMinusSourceAlpha);
- texture_descriptor.set_width(width);
- texture_descriptor.set_height(height);
- texture_descriptor.set_pixel_format(layer.pixel_format());
- texture_descriptor.set_usage(metal::MTLTextureUsage::RenderTarget);
- texture_descriptor.set_sample_count(sample_count);
+ device
+ .new_render_pipeline_state(&descriptor)
+ .expect("could not create render pipeline state")
+}
- let metal_texture = device.new_texture(&texture_descriptor);
- Some(metal_texture)
+// Align to multiples of 256 make Metal happy.
+fn align_offset(offset: &mut usize) {
+ *offset = (*offset).div_ceil(256) * 256;
}
#[repr(C)]
@@ -1162,17 +1336,15 @@ enum SurfaceInputIndex {
}
#[repr(C)]
-enum PathInputIndex {
+enum PathRasterizationInputIndex {
Vertices = 0,
ViewportSize = 1,
- Sprites = 2,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[repr(C)]
pub struct PathSprite {
pub bounds: Bounds<ScaledPixels>,
- pub color: Background,
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -35,14 +35,14 @@ pub fn apply_features_and_fallbacks(
unsafe {
let mut keys = vec![kCTFontFeatureSettingsAttribute];
let mut values = vec![generate_feature_array(features)];
- if let Some(fallbacks) = fallbacks {
- if !fallbacks.fallback_list().is_empty() {
- keys.push(kCTFontCascadeListAttribute);
- values.push(generate_fallback_array(
- fallbacks,
- font.native_font().as_concrete_TypeRef(),
- ));
- }
+ if let Some(fallbacks) = fallbacks
+ && !fallbacks.fallback_list().is_empty()
+ {
+ keys.push(kCTFontCascadeListAttribute);
+ values.push(generate_fallback_array(
+ fallbacks,
+ font.native_font().as_concrete_TypeRef(),
+ ));
}
let attrs = CFDictionaryCreate(
kCFAllocatorDefault,
@@ -7,9 +7,9 @@ use super::{
use crate::{
Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString,
CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher,
- MacDisplay, MacWindow, Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay,
- PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow, Result, SemanticVersion, Task,
- WindowAppearance, WindowParams, hash,
+ MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform,
+ PlatformDisplay, PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow, Result,
+ SemanticVersion, SystemMenuType, Task, WindowAppearance, WindowParams, hash,
};
use anyhow::{Context as _, anyhow};
use block::ConcreteBlock;
@@ -47,7 +47,7 @@ use objc::{
use parking_lot::Mutex;
use ptr::null_mut;
use std::{
- cell::{Cell, LazyCell},
+ cell::Cell,
convert::TryInto,
ffi::{CStr, OsStr, c_void},
os::{raw::c_char, unix::ffi::OsStrExt},
@@ -56,7 +56,7 @@ use std::{
ptr,
rc::Rc,
slice, str,
- sync::Arc,
+ sync::{Arc, OnceLock},
};
use strum::IntoEnumIterator;
use util::ResultExt;
@@ -296,18 +296,7 @@ impl MacPlatform {
actions: &mut Vec<Box<dyn Action>>,
keymap: &Keymap,
) -> id {
- const DEFAULT_CONTEXT: LazyCell<Vec<KeyContext>> = LazyCell::new(|| {
- let mut workspace_context = KeyContext::new_with_defaults();
- workspace_context.add("Workspace");
- let mut pane_context = KeyContext::new_with_defaults();
- pane_context.add("Pane");
- let mut editor_context = KeyContext::new_with_defaults();
- editor_context.add("Editor");
-
- pane_context.extend(&editor_context);
- workspace_context.extend(&pane_context);
- vec![workspace_context]
- });
+ static DEFAULT_CONTEXT: OnceLock<Vec<KeyContext>> = OnceLock::new();
unsafe {
match item {
@@ -323,9 +312,20 @@ impl MacPlatform {
let keystrokes = keymap
.bindings_for_action(action.as_ref())
.find_or_first(|binding| {
- binding
- .predicate()
- .is_none_or(|predicate| predicate.eval(&DEFAULT_CONTEXT))
+ binding.predicate().is_none_or(|predicate| {
+ predicate.eval(DEFAULT_CONTEXT.get_or_init(|| {
+ let mut workspace_context = KeyContext::new_with_defaults();
+ workspace_context.add("Workspace");
+ let mut pane_context = KeyContext::new_with_defaults();
+ pane_context.add("Pane");
+ let mut editor_context = KeyContext::new_with_defaults();
+ editor_context.add("Editor");
+
+ pane_context.extend(&editor_context);
+ workspace_context.extend(&pane_context);
+ vec![workspace_context]
+ }))
+ })
})
.map(|binding| binding.keystrokes());
@@ -371,7 +371,7 @@ impl MacPlatform {
item = NSMenuItem::alloc(nil)
.initWithTitle_action_keyEquivalent_(
- ns_string(&name),
+ ns_string(name),
selector,
ns_string(key_to_native(&keystroke.key).as_ref()),
)
@@ -383,7 +383,7 @@ impl MacPlatform {
} else {
item = NSMenuItem::alloc(nil)
.initWithTitle_action_keyEquivalent_(
- ns_string(&name),
+ ns_string(name),
selector,
ns_string(""),
)
@@ -392,7 +392,7 @@ impl MacPlatform {
} else {
item = NSMenuItem::alloc(nil)
.initWithTitle_action_keyEquivalent_(
- ns_string(&name),
+ ns_string(name),
selector,
ns_string(""),
)
@@ -412,10 +412,21 @@ impl MacPlatform {
submenu.addItem_(Self::create_menu_item(item, delegate, actions, keymap));
}
item.setSubmenu_(submenu);
- item.setTitle_(ns_string(&name));
- if name == "Services" {
- let app: id = msg_send![APP_CLASS, sharedApplication];
- app.setServicesMenu_(item);
+ item.setTitle_(ns_string(name));
+ item
+ }
+ MenuItem::SystemMenu(OsMenu { name, menu_type }) => {
+ let item = NSMenuItem::new(nil).autorelease();
+ let submenu = NSMenu::new(nil).autorelease();
+ submenu.setDelegate_(delegate);
+ item.setSubmenu_(submenu);
+ item.setTitle_(ns_string(name));
+
+ match menu_type {
+ SystemMenuType::Services => {
+ let app: id = msg_send![APP_CLASS, sharedApplication];
+ app.setServicesMenu_(item);
+ }
}
item
@@ -583,7 +594,7 @@ impl Platform for MacPlatform {
#[cfg(feature = "screen-capture")]
fn screen_capture_sources(
&self,
- ) -> oneshot::Receiver<Result<Vec<Box<dyn crate::ScreenCaptureSource>>>> {
+ ) -> oneshot::Receiver<Result<Vec<Rc<dyn crate::ScreenCaptureSource>>>> {
super::screen_capture::get_sources()
}
@@ -694,6 +705,7 @@ impl Platform for MacPlatform {
panel.setCanChooseDirectories_(options.directories.to_objc());
panel.setCanChooseFiles_(options.files.to_objc());
panel.setAllowsMultipleSelection_(options.multiple.to_objc());
+
panel.setCanCreateDirectories(true.to_objc());
panel.setResolvesAliases_(false.to_objc());
let done_tx = Cell::new(Some(done_tx));
@@ -703,10 +715,10 @@ impl Platform for MacPlatform {
let urls = panel.URLs();
for i in 0..urls.count() {
let url = urls.objectAtIndex(i);
- if url.isFileURL() == YES {
- if let Ok(path) = ns_url_to_path(url) {
- result.push(path)
- }
+ if url.isFileURL() == YES
+ && let Ok(path) = ns_url_to_path(url)
+ {
+ result.push(path)
}
}
Some(result)
@@ -719,6 +731,11 @@ impl Platform for MacPlatform {
}
});
let block = block.copy();
+
+ if let Some(prompt) = options.prompt {
+ let _: () = msg_send![panel, setPrompt: ns_string(&prompt)];
+ }
+
let _: () = msg_send![panel, beginWithCompletionHandler: block];
}
})
@@ -726,8 +743,13 @@ impl Platform for MacPlatform {
done_rx
}
- fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver<Result<Option<PathBuf>>> {
+ fn prompt_for_new_path(
+ &self,
+ directory: &Path,
+ suggested_name: Option<&str>,
+ ) -> oneshot::Receiver<Result<Option<PathBuf>>> {
let directory = directory.to_owned();
+ let suggested_name = suggested_name.map(|s| s.to_owned());
let (done_tx, done_rx) = oneshot::channel();
self.foreground_executor()
.spawn(async move {
@@ -737,6 +759,11 @@ impl Platform for MacPlatform {
let url = NSURL::fileURLWithPath_isDirectory_(nil, path, true.to_objc());
panel.setDirectoryURL(url);
+ if let Some(suggested_name) = suggested_name {
+ let name_string = ns_string(&suggested_name);
+ let _: () = msg_send![panel, setNameFieldStringValue: name_string];
+ }
+
let done_tx = Cell::new(Some(done_tx));
let block = ConcreteBlock::new(move |response: NSModalResponse| {
let mut result = None;
@@ -759,17 +786,18 @@ impl Platform for MacPlatform {
// This is conditional on OS version because I'd like to get rid of it, so that
// you can manually create a file called `a.sql.s`. That said it seems better
// to break that use-case than breaking `a.sql`.
- if chunks.len() == 3 && chunks[1].starts_with(chunks[2]) {
- if Self::os_version() >= SemanticVersion::new(15, 0, 0) {
- let new_filename = OsStr::from_bytes(
- &filename.as_bytes()
- [..chunks[0].len() + 1 + chunks[1].len()],
- )
- .to_owned();
- result.set_file_name(&new_filename);
- }
+ if chunks.len() == 3
+ && chunks[1].starts_with(chunks[2])
+ && Self::os_version() >= SemanticVersion::new(15, 0, 0)
+ {
+ let new_filename = OsStr::from_bytes(
+ &filename.as_bytes()
+ [..chunks[0].len() + 1 + chunks[1].len()],
+ )
+ .to_owned();
+ result.set_file_name(&new_filename);
}
- return result;
+ result
})
}
}
@@ -1,5 +1,5 @@
use crate::{
- DevicePixels, ForegroundExecutor, Size,
+ DevicePixels, ForegroundExecutor, SharedString, SourceMetadata,
platform::{ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream},
size,
};
@@ -7,8 +7,9 @@ use anyhow::{Result, anyhow};
use block::ConcreteBlock;
use cocoa::{
base::{YES, id, nil},
- foundation::NSArray,
+ foundation::{NSArray, NSString},
};
+use collections::HashMap;
use core_foundation::base::TCFType;
use core_graphics::display::{
CGDirectDisplayID, CGDisplayCopyDisplayMode, CGDisplayModeGetPixelHeight,
@@ -32,11 +33,13 @@ use super::NSStringExt;
#[derive(Clone)]
pub struct MacScreenCaptureSource {
sc_display: id,
+ meta: Option<ScreenMeta>,
}
pub struct MacScreenCaptureStream {
sc_stream: id,
sc_stream_output: id,
+ meta: SourceMetadata,
}
static mut DELEGATE_CLASS: *const Class = ptr::null();
@@ -47,19 +50,31 @@ const FRAME_CALLBACK_IVAR: &str = "frame_callback";
const SCStreamOutputTypeScreen: NSInteger = 0;
impl ScreenCaptureSource for MacScreenCaptureSource {
- fn resolution(&self) -> Result<Size<DevicePixels>> {
- unsafe {
+ fn metadata(&self) -> Result<SourceMetadata> {
+ let (display_id, size) = unsafe {
let display_id: CGDirectDisplayID = msg_send![self.sc_display, displayID];
let display_mode_ref = CGDisplayCopyDisplayMode(display_id);
let width = CGDisplayModeGetPixelWidth(display_mode_ref);
let height = CGDisplayModeGetPixelHeight(display_mode_ref);
CGDisplayModeRelease(display_mode_ref);
- Ok(size(
- DevicePixels(width as i32),
- DevicePixels(height as i32),
- ))
- }
+ (
+ display_id,
+ size(DevicePixels(width as i32), DevicePixels(height as i32)),
+ )
+ };
+ let (label, is_main) = self
+ .meta
+ .clone()
+ .map(|meta| (meta.label, meta.is_main))
+ .unzip();
+
+ Ok(SourceMetadata {
+ id: display_id as u64,
+ label,
+ is_main,
+ resolution: size,
+ })
}
fn stream(
@@ -89,9 +104,9 @@ impl ScreenCaptureSource for MacScreenCaptureSource {
Box::into_raw(Box::new(frame_callback)) as *mut c_void,
);
- let resolution = self.resolution().unwrap();
- let _: id = msg_send![configuration, setWidth: resolution.width.0 as i64];
- let _: id = msg_send![configuration, setHeight: resolution.height.0 as i64];
+ let meta = self.metadata().unwrap();
+ let _: id = msg_send![configuration, setWidth: meta.resolution.width.0 as i64];
+ let _: id = msg_send![configuration, setHeight: meta.resolution.height.0 as i64];
let stream: id = msg_send![stream, initWithFilter:filter configuration:configuration delegate:delegate];
let (mut tx, rx) = oneshot::channel();
@@ -110,6 +125,7 @@ impl ScreenCaptureSource for MacScreenCaptureSource {
move |error: id| {
let result = if error == nil {
let stream = MacScreenCaptureStream {
+ meta: meta.clone(),
sc_stream: stream,
sc_stream_output: output,
};
@@ -138,7 +154,11 @@ impl Drop for MacScreenCaptureSource {
}
}
-impl ScreenCaptureStream for MacScreenCaptureStream {}
+impl ScreenCaptureStream for MacScreenCaptureStream {
+ fn metadata(&self) -> Result<SourceMetadata> {
+ Ok(self.meta.clone())
+ }
+}
impl Drop for MacScreenCaptureStream {
fn drop(&mut self) {
@@ -164,24 +184,74 @@ impl Drop for MacScreenCaptureStream {
}
}
-pub(crate) fn get_sources() -> oneshot::Receiver<Result<Vec<Box<dyn ScreenCaptureSource>>>> {
+#[derive(Clone)]
+struct ScreenMeta {
+ label: SharedString,
+ // Is this the screen with menu bar?
+ is_main: bool,
+}
+
+unsafe fn screen_id_to_human_label() -> HashMap<CGDirectDisplayID, ScreenMeta> {
+ let screens: id = msg_send![class!(NSScreen), screens];
+ let count: usize = msg_send![screens, count];
+ let mut map = HashMap::default();
+ let screen_number_key = unsafe { NSString::alloc(nil).init_str("NSScreenNumber") };
+ for i in 0..count {
+ let screen: id = msg_send![screens, objectAtIndex: i];
+ let device_desc: id = msg_send![screen, deviceDescription];
+ if device_desc == nil {
+ continue;
+ }
+
+ let nsnumber: id = msg_send![device_desc, objectForKey: screen_number_key];
+ if nsnumber == nil {
+ continue;
+ }
+
+ let screen_id: u32 = msg_send![nsnumber, unsignedIntValue];
+
+ let name: id = msg_send![screen, localizedName];
+ if name != nil {
+ let cstr: *const std::os::raw::c_char = msg_send![name, UTF8String];
+ let rust_str = unsafe {
+ std::ffi::CStr::from_ptr(cstr)
+ .to_string_lossy()
+ .into_owned()
+ };
+ map.insert(
+ screen_id,
+ ScreenMeta {
+ label: rust_str.into(),
+ is_main: i == 0,
+ },
+ );
+ }
+ }
+ map
+}
+
+pub(crate) fn get_sources() -> oneshot::Receiver<Result<Vec<Rc<dyn ScreenCaptureSource>>>> {
unsafe {
let (mut tx, rx) = oneshot::channel();
let tx = Rc::new(RefCell::new(Some(tx)));
-
+ let screen_id_to_label = screen_id_to_human_label();
let block = ConcreteBlock::new(move |shareable_content: id, error: id| {
let Some(mut tx) = tx.borrow_mut().take() else {
return;
};
+
let result = if error == nil {
let displays: id = msg_send![shareable_content, displays];
let mut result = Vec::new();
for i in 0..displays.count() {
let display = displays.objectAtIndex(i);
+ let id: CGDirectDisplayID = msg_send![display, displayID];
+ let meta = screen_id_to_label.get(&id).cloned();
let source = MacScreenCaptureSource {
sc_display: msg_send![display, retain],
+ meta,
};
- result.push(Box::new(source) as Box<dyn ScreenCaptureSource>);
+ result.push(Rc::new(source) as Rc<dyn ScreenCaptureSource>);
}
Ok(result)
} else {
@@ -567,15 +567,20 @@ vertex UnderlineVertexOutput underline_vertex(
fragment float4 underline_fragment(UnderlineFragmentInput input [[stage_in]],
constant Underline *underlines
[[buffer(UnderlineInputIndex_Underlines)]]) {
+ const float WAVE_FREQUENCY = 2.0;
+ const float WAVE_HEIGHT_RATIO = 0.8;
+
Underline underline = underlines[input.underline_id];
if (underline.wavy) {
float half_thickness = underline.thickness * 0.5;
float2 origin =
float2(underline.bounds.origin.x, underline.bounds.origin.y);
+
float2 st = ((input.position.xy - origin) / underline.bounds.size.height) -
float2(0., 0.5);
- float frequency = (M_PI_F * (3. * underline.thickness)) / 8.;
- float amplitude = 1. / (2. * underline.thickness);
+ float frequency = (M_PI_F * WAVE_FREQUENCY * underline.thickness) / underline.bounds.size.height;
+ float amplitude = (underline.thickness * WAVE_HEIGHT_RATIO) / underline.bounds.size.height;
+
float sine = sin(st.x * frequency) * amplitude;
float dSine = cos(st.x * frequency) * amplitude * frequency;
float distance = (st.y - sine) / sqrt(1. + dSine * dSine);
@@ -698,63 +703,120 @@ fragment float4 polychrome_sprite_fragment(
return color;
}
-struct PathVertexOutput {
+struct PathRasterizationVertexOutput {
float4 position [[position]];
- uint sprite_id [[flat]];
- float4 solid_color [[flat]];
- float4 color0 [[flat]];
- float4 color1 [[flat]];
- float4 clip_distance;
+ float2 st_position;
+ uint vertex_id [[flat]];
+ float clip_rect_distance [[clip_distance]][4];
};
-vertex PathVertexOutput path_vertex(
- uint vertex_id [[vertex_id]],
- constant PathVertex_ScaledPixels *vertices [[buffer(PathInputIndex_Vertices)]],
- uint sprite_id [[instance_id]],
- constant PathSprite *sprites [[buffer(PathInputIndex_Sprites)]],
- constant Size_DevicePixels *input_viewport_size [[buffer(PathInputIndex_ViewportSize)]]) {
- PathVertex_ScaledPixels v = vertices[vertex_id];
+struct PathRasterizationFragmentInput {
+ float4 position [[position]];
+ float2 st_position;
+ uint vertex_id [[flat]];
+};
+
+vertex PathRasterizationVertexOutput path_rasterization_vertex(
+ uint vertex_id [[vertex_id]],
+ constant PathRasterizationVertex *vertices [[buffer(PathRasterizationInputIndex_Vertices)]],
+ constant Size_DevicePixels *atlas_size [[buffer(PathRasterizationInputIndex_ViewportSize)]]
+) {
+ PathRasterizationVertex v = vertices[vertex_id];
float2 vertex_position = float2(v.xy_position.x, v.xy_position.y);
- float2 viewport_size = float2((float)input_viewport_size->width,
- (float)input_viewport_size->height);
- PathSprite sprite = sprites[sprite_id];
- float4 device_position = float4(vertex_position / viewport_size * float2(2., -2.) + float2(-1., 1.), 0., 1.);
+ float4 position = float4(
+ vertex_position * float2(2. / atlas_size->width, -2. / atlas_size->height) + float2(-1., 1.),
+ 0.,
+ 1.
+ );
+ return PathRasterizationVertexOutput{
+ position,
+ float2(v.st_position.x, v.st_position.y),
+ vertex_id,
+ {
+ v.xy_position.x - v.bounds.origin.x,
+ v.bounds.origin.x + v.bounds.size.width - v.xy_position.x,
+ v.xy_position.y - v.bounds.origin.y,
+ v.bounds.origin.y + v.bounds.size.height - v.xy_position.y
+ }
+ };
+}
- GradientColor gradient = prepare_fill_color(
- sprite.color.tag,
- sprite.color.color_space,
- sprite.color.solid,
- sprite.color.colors[0].color,
- sprite.color.colors[1].color
+fragment float4 path_rasterization_fragment(
+ PathRasterizationFragmentInput input [[stage_in]],
+ constant PathRasterizationVertex *vertices [[buffer(PathRasterizationInputIndex_Vertices)]]
+) {
+ float2 dx = dfdx(input.st_position);
+ float2 dy = dfdy(input.st_position);
+
+ PathRasterizationVertex v = vertices[input.vertex_id];
+ Background background = v.color;
+ Bounds_ScaledPixels path_bounds = v.bounds;
+ float alpha;
+ if (length(float2(dx.x, dy.x)) < 0.001) {
+ alpha = 1.0;
+ } else {
+ float2 gradient = float2(
+ (2. * input.st_position.x) * dx.x - dx.y,
+ (2. * input.st_position.x) * dy.x - dy.y
+ );
+ float f = (input.st_position.x * input.st_position.x) - input.st_position.y;
+ float distance = f / length(gradient);
+ alpha = saturate(0.5 - distance);
+ }
+
+ GradientColor gradient_color = prepare_fill_color(
+ background.tag,
+ background.color_space,
+ background.solid,
+ background.colors[0].color,
+ background.colors[1].color
+ );
+
+ float4 color = fill_color(
+ background,
+ input.position.xy,
+ path_bounds,
+ gradient_color.solid,
+ gradient_color.color0,
+ gradient_color.color1
);
+ return float4(color.rgb * color.a * alpha, alpha * color.a);
+}
+
+struct PathSpriteVertexOutput {
+ float4 position [[position]];
+ float2 texture_coords;
+};
- return PathVertexOutput{
+vertex PathSpriteVertexOutput path_sprite_vertex(
+ uint unit_vertex_id [[vertex_id]],
+ uint sprite_id [[instance_id]],
+ constant float2 *unit_vertices [[buffer(SpriteInputIndex_Vertices)]],
+ constant PathSprite *sprites [[buffer(SpriteInputIndex_Sprites)]],
+ constant Size_DevicePixels *viewport_size [[buffer(SpriteInputIndex_ViewportSize)]]
+) {
+ float2 unit_vertex = unit_vertices[unit_vertex_id];
+ PathSprite sprite = sprites[sprite_id];
+ // Don't apply content mask because it was already accounted for when
+ // rasterizing the path.
+ float4 device_position =
+ to_device_position(unit_vertex, sprite.bounds, viewport_size);
+
+ float2 screen_position = float2(sprite.bounds.origin.x, sprite.bounds.origin.y) + unit_vertex * float2(sprite.bounds.size.width, sprite.bounds.size.height);
+ float2 texture_coords = screen_position / float2(viewport_size->width, viewport_size->height);
+
+ return PathSpriteVertexOutput{
device_position,
- sprite_id,
- gradient.solid,
- gradient.color0,
- gradient.color1,
- {v.xy_position.x - v.content_mask.bounds.origin.x,
- v.content_mask.bounds.origin.x + v.content_mask.bounds.size.width -
- v.xy_position.x,
- v.xy_position.y - v.content_mask.bounds.origin.y,
- v.content_mask.bounds.origin.y + v.content_mask.bounds.size.height -
- v.xy_position.y}
+ texture_coords
};
}
-fragment float4 path_fragment(
- PathVertexOutput input [[stage_in]],
- constant PathSprite *sprites [[buffer(PathInputIndex_Sprites)]]) {
- if (any(input.clip_distance < float4(0.0))) {
- return float4(0.0);
- }
-
- PathSprite sprite = sprites[input.sprite_id];
- Background background = sprite.color;
- float4 color = fill_color(background, input.position.xy, sprite.bounds,
- input.solid_color, input.color0, input.color1);
- return color;
+fragment float4 path_sprite_fragment(
+ PathSpriteVertexOutput input [[stage_in]],
+ texture2d<float> intermediate_texture [[texture(SpriteInputIndex_AtlasTexture)]]
+) {
+ constexpr sampler intermediate_texture_sampler(mag_filter::linear, min_filter::linear);
+ return intermediate_texture.sample(intermediate_texture_sampler, input.texture_coords);
}
struct SurfaceVertexOutput {
@@ -211,11 +211,7 @@ impl MacTextSystemState {
features: &FontFeatures,
fallbacks: Option<&FontFallbacks>,
) -> Result<SmallVec<[FontId; 4]>> {
- let name = if name == ".SystemUIFont" {
- ".AppleSystemUIFont"
- } else {
- name
- };
+ let name = crate::text_system::font_name_with_fallbacks(name, ".AppleSystemUIFont");
let mut font_ids = SmallVec::new();
let family = self
@@ -323,7 +319,7 @@ impl MacTextSystemState {
fn is_emoji(&self, font_id: FontId) -> bool {
self.postscript_names_by_font_id
.get(&font_id)
- .map_or(false, |postscript_name| {
+ .is_some_and(|postscript_name| {
postscript_name == "AppleColorEmoji" || postscript_name == ".AppleColorEmojiUI"
})
}
@@ -653,7 +653,7 @@ impl MacWindow {
.and_then(|titlebar| titlebar.traffic_light_position),
transparent_titlebar: titlebar
.as_ref()
- .map_or(true, |titlebar| titlebar.appears_transparent),
+ .is_none_or(|titlebar| titlebar.appears_transparent),
previous_modifiers_changed_event: None,
keystroke_for_do_command: None,
do_command_handled: None,
@@ -688,7 +688,7 @@ impl MacWindow {
});
}
- if titlebar.map_or(true, |titlebar| titlebar.appears_transparent) {
+ if titlebar.is_none_or(|titlebar| titlebar.appears_transparent) {
native_window.setTitlebarAppearsTransparent_(YES);
native_window.setTitleVisibility_(NSWindowTitleVisibility::NSWindowTitleHidden);
}
@@ -1090,7 +1090,7 @@ impl PlatformWindow for MacWindow {
NSView::removeFromSuperview(blur_view);
this.blurred_view = None;
}
- } else if this.blurred_view == None {
+ } else if this.blurred_view.is_none() {
let content_view = this.native_window.contentView();
let frame = NSView::bounds(content_view);
let mut blur_view: id = msg_send![BLURRED_VIEW_CLASS, alloc];
@@ -1478,18 +1478,18 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent:
return YES;
}
- if key_down_event.is_held {
- if let Some(key_char) = key_down_event.keystroke.key_char.as_ref() {
- let handled = with_input_handler(&this, |input_handler| {
- if !input_handler.apple_press_and_hold_enabled() {
- input_handler.replace_text_in_range(None, &key_char);
- return YES;
- }
- NO
- });
- if handled == Some(YES) {
+ if key_down_event.is_held
+ && let Some(key_char) = key_down_event.keystroke.key_char.as_ref()
+ {
+ let handled = with_input_handler(this, |input_handler| {
+ if !input_handler.apple_press_and_hold_enabled() {
+ input_handler.replace_text_in_range(None, key_char);
return YES;
}
+ NO
+ });
+ if handled == Some(YES) {
+ return YES;
}
}
@@ -1624,10 +1624,10 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) {
modifiers: prev_modifiers,
capslock: prev_capslock,
})) = &lock.previous_modifiers_changed_event
+ && prev_modifiers == modifiers
+ && prev_capslock == capslock
{
- if prev_modifiers == modifiers && prev_capslock == capslock {
- return;
- }
+ return;
}
lock.previous_modifiers_changed_event = Some(event.clone());
@@ -1949,7 +1949,7 @@ extern "C" fn insert_text(this: &Object, _: Sel, text: id, replacement_range: NS
let text = text.to_str();
let replacement_range = replacement_range.to_range();
with_input_handler(this, |input_handler| {
- input_handler.replace_text_in_range(replacement_range, &text)
+ input_handler.replace_text_in_range(replacement_range, text)
});
}
}
@@ -1973,7 +1973,7 @@ extern "C" fn set_marked_text(
let replacement_range = replacement_range.to_range();
let text = text.to_str();
with_input_handler(this, |input_handler| {
- input_handler.replace_and_mark_text_in_range(replacement_range, &text, selected_range)
+ input_handler.replace_and_mark_text_in_range(replacement_range, text, selected_range)
});
}
}
@@ -1995,10 +1995,10 @@ extern "C" fn attributed_substring_for_proposed_range(
let mut adjusted: Option<Range<usize>> = None;
let selected_text = input_handler.text_for_range(range.clone(), &mut adjusted)?;
- if let Some(adjusted) = adjusted {
- if adjusted != range {
- unsafe { (actual_range as *mut NSRange).write(NSRange::from(adjusted)) };
- }
+ if let Some(adjusted) = adjusted
+ && adjusted != range
+ {
+ unsafe { (actual_range as *mut NSRange).write(NSRange::from(adjusted)) };
}
unsafe {
let string: id = msg_send![class!(NSAttributedString), alloc];
@@ -2063,8 +2063,8 @@ fn screen_point_to_gpui_point(this: &Object, position: NSPoint) -> Point<Pixels>
let frame = get_frame(this);
let window_x = position.x - frame.origin.x;
let window_y = frame.size.height - (position.y - frame.origin.y);
- let position = point(px(window_x as f32), px(window_y as f32));
- position
+
+ point(px(window_x as f32), px(window_y as f32))
}
extern "C" fn dragging_entered(this: &Object, _: Sel, dragging_info: id) -> NSDragOperation {
@@ -2073,11 +2073,10 @@ extern "C" fn dragging_entered(this: &Object, _: Sel, dragging_info: id) -> NSDr
let paths = external_paths_from_event(dragging_info);
if let Some(event) =
paths.map(|paths| PlatformInput::FileDrop(FileDropEvent::Entered { position, paths }))
+ && send_new_event(&window_state, event)
{
- if send_new_event(&window_state, event) {
- window_state.lock().external_files_dragged = true;
- return NSDragOperationCopy;
- }
+ window_state.lock().external_files_dragged = true;
+ return NSDragOperationCopy;
}
NSDragOperationNone
}
@@ -1,10 +1,12 @@
//! Screen capture for Linux and Windows
use crate::{
DevicePixels, ForegroundExecutor, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream,
- Size, size,
+ Size, SourceMetadata, size,
};
use anyhow::{Context as _, Result, anyhow};
use futures::channel::oneshot;
+use scap::Target;
+use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{self, AtomicBool};
@@ -15,7 +17,7 @@ use std::sync::atomic::{self, AtomicBool};
#[allow(dead_code)]
pub(crate) fn scap_screen_sources(
foreground_executor: &ForegroundExecutor,
-) -> oneshot::Receiver<Result<Vec<Box<dyn ScreenCaptureSource>>>> {
+) -> oneshot::Receiver<Result<Vec<Rc<dyn ScreenCaptureSource>>>> {
let (sources_tx, sources_rx) = oneshot::channel();
get_screen_targets(sources_tx);
to_dyn_screen_capture_sources(sources_rx, foreground_executor)
@@ -29,14 +31,14 @@ pub(crate) fn scap_screen_sources(
#[allow(dead_code)]
pub(crate) fn start_scap_default_target_source(
foreground_executor: &ForegroundExecutor,
-) -> oneshot::Receiver<Result<Vec<Box<dyn ScreenCaptureSource>>>> {
+) -> oneshot::Receiver<Result<Vec<Rc<dyn ScreenCaptureSource>>>> {
let (sources_tx, sources_rx) = oneshot::channel();
start_default_target_screen_capture(sources_tx);
to_dyn_screen_capture_sources(sources_rx, foreground_executor)
}
struct ScapCaptureSource {
- target: scap::Target,
+ target: scap::Display,
size: Size<DevicePixels>,
}
@@ -52,7 +54,7 @@ fn get_screen_targets(sources_tx: oneshot::Sender<Result<Vec<ScapCaptureSource>>
}
};
let sources = targets
- .iter()
+ .into_iter()
.filter_map(|target| match target {
scap::Target::Display(display) => {
let size = Size {
@@ -60,7 +62,7 @@ fn get_screen_targets(sources_tx: oneshot::Sender<Result<Vec<ScapCaptureSource>>
height: DevicePixels(display.height as i32),
};
Some(ScapCaptureSource {
- target: target.clone(),
+ target: display,
size,
})
}
@@ -72,8 +74,13 @@ fn get_screen_targets(sources_tx: oneshot::Sender<Result<Vec<ScapCaptureSource>>
}
impl ScreenCaptureSource for ScapCaptureSource {
- fn resolution(&self) -> Result<Size<DevicePixels>> {
- Ok(self.size)
+ fn metadata(&self) -> Result<SourceMetadata> {
+ Ok(SourceMetadata {
+ resolution: self.size,
+ label: Some(self.target.title.clone().into()),
+ is_main: None,
+ id: self.target.id as u64,
+ })
}
fn stream(
@@ -85,13 +92,15 @@ impl ScreenCaptureSource for ScapCaptureSource {
let target = self.target.clone();
// Due to use of blocking APIs, a dedicated thread is used.
- std::thread::spawn(move || match new_scap_capturer(Some(target)) {
- Ok(mut capturer) => {
- capturer.start_capture();
- run_capture(capturer, frame_callback, stream_tx);
- }
- Err(e) => {
- stream_tx.send(Err(e)).ok();
+ std::thread::spawn(move || {
+ match new_scap_capturer(Some(scap::Target::Display(target.clone()))) {
+ Ok(mut capturer) => {
+ capturer.start_capture();
+ run_capture(capturer, target.clone(), frame_callback, stream_tx);
+ }
+ Err(e) => {
+ stream_tx.send(Err(e)).ok();
+ }
}
});
@@ -107,6 +116,7 @@ struct ScapDefaultTargetCaptureSource {
// Callback for frames.
Box<dyn Fn(ScreenCaptureFrame) + Send>,
)>,
+ target: scap::Display,
size: Size<DevicePixels>,
}
@@ -123,33 +133,48 @@ fn start_default_target_screen_capture(
.get_next_frame()
.context("Failed to get first frame of screenshare to get the size.")?;
let size = frame_size(&first_frame);
- Ok((capturer, size))
+ let target = capturer
+ .target()
+ .context("Unable to determine the target display.")?;
+ let target = target.clone();
+ Ok((capturer, size, target))
});
match start_result {
- Err(e) => {
- sources_tx.send(Err(e)).ok();
- }
- Ok((capturer, size)) => {
+ Ok((capturer, size, Target::Display(display))) => {
let (stream_call_tx, stream_rx) = std::sync::mpsc::sync_channel(1);
sources_tx
.send(Ok(vec![ScapDefaultTargetCaptureSource {
stream_call_tx,
size,
+ target: display.clone(),
}]))
.ok();
let Ok((stream_tx, frame_callback)) = stream_rx.recv() else {
return;
};
- run_capture(capturer, frame_callback, stream_tx);
+ run_capture(capturer, display, frame_callback, stream_tx);
+ }
+ Err(e) => {
+ sources_tx.send(Err(e)).ok();
+ }
+ _ => {
+ sources_tx
+ .send(Err(anyhow!("The screen capture source is not a display")))
+ .ok();
}
}
});
}
impl ScreenCaptureSource for ScapDefaultTargetCaptureSource {
- fn resolution(&self) -> Result<Size<DevicePixels>> {
- Ok(self.size)
+ fn metadata(&self) -> Result<SourceMetadata> {
+ Ok(SourceMetadata {
+ resolution: self.size,
+ label: None,
+ is_main: None,
+ id: self.target.id as u64,
+ })
}
fn stream(
@@ -189,14 +214,21 @@ fn new_scap_capturer(target: Option<scap::Target>) -> Result<scap::capturer::Cap
fn run_capture(
mut capturer: scap::capturer::Capturer,
+ display: scap::Display,
frame_callback: Box<dyn Fn(ScreenCaptureFrame) + Send>,
stream_tx: oneshot::Sender<Result<ScapStream>>,
) {
let cancel_stream = Arc::new(AtomicBool::new(false));
+ let size = Size {
+ width: DevicePixels(display.width as i32),
+ height: DevicePixels(display.height as i32),
+ };
let stream_send_result = stream_tx.send(Ok(ScapStream {
cancel_stream: cancel_stream.clone(),
+ display,
+ size,
}));
- if let Err(_) = stream_send_result {
+ if stream_send_result.is_err() {
return;
}
while !cancel_stream.load(std::sync::atomic::Ordering::SeqCst) {
@@ -213,9 +245,20 @@ fn run_capture(
struct ScapStream {
cancel_stream: Arc<AtomicBool>,
+ display: scap::Display,
+ size: Size<DevicePixels>,
}
-impl ScreenCaptureStream for ScapStream {}
+impl ScreenCaptureStream for ScapStream {
+ fn metadata(&self) -> Result<SourceMetadata> {
+ Ok(SourceMetadata {
+ resolution: self.size,
+ label: Some(self.display.title.clone().into()),
+ is_main: None,
+ id: self.display.id as u64,
+ })
+ }
+}
impl Drop for ScapStream {
fn drop(&mut self) {
@@ -237,12 +280,12 @@ fn frame_size(frame: &scap::frame::Frame) -> Size<DevicePixels> {
}
/// This is used by `get_screen_targets` and `start_default_target_screen_capture` to turn their
-/// results into `Box<dyn ScreenCaptureSource>`. They need to `Send` their capture source, and so
-/// the capture source structs are used as `Box<dyn ScreenCaptureSource>` is not `Send`.
+/// results into `Rc<dyn ScreenCaptureSource>`. They need to `Send` their capture source, and so
+/// the capture source structs are used as `Rc<dyn ScreenCaptureSource>` is not `Send`.
fn to_dyn_screen_capture_sources<T: ScreenCaptureSource + 'static>(
sources_rx: oneshot::Receiver<Result<Vec<T>>>,
foreground_executor: &ForegroundExecutor,
-) -> oneshot::Receiver<Result<Vec<Box<dyn ScreenCaptureSource>>>> {
+) -> oneshot::Receiver<Result<Vec<Rc<dyn ScreenCaptureSource>>>> {
let (dyn_sources_tx, dyn_sources_rx) = oneshot::channel();
foreground_executor
.spawn(async move {
@@ -250,7 +293,7 @@ fn to_dyn_screen_capture_sources<T: ScreenCaptureSource + 'static>(
Ok(Ok(results)) => dyn_sources_tx
.send(Ok(results
.into_iter()
- .map(|source| Box::new(source) as Box<dyn ScreenCaptureSource>)
+ .map(|source| Rc::new(source) as Rc<dyn ScreenCaptureSource>)
.collect::<Vec<_>>()))
.ok(),
Ok(Err(err)) => dyn_sources_tx.send(Err(err)).ok(),
@@ -8,4 +8,4 @@ pub(crate) use display::*;
pub(crate) use platform::*;
pub(crate) use window::*;
-pub use platform::TestScreenCaptureSource;
+pub use platform::{TestScreenCaptureSource, TestScreenCaptureStream};
@@ -78,11 +78,11 @@ impl TestDispatcher {
let state = self.state.lock();
let next_due_time = state.delayed.first().map(|(time, _)| *time);
drop(state);
- if let Some(due_time) = next_due_time {
- if due_time <= new_now {
- self.state.lock().time = due_time;
- continue;
- }
+ if let Some(due_time) = next_due_time
+ && due_time <= new_now
+ {
+ self.state.lock().time = due_time;
+ continue;
}
break;
}
@@ -270,9 +270,7 @@ impl PlatformDispatcher for TestDispatcher {
fn dispatch(&self, runnable: Runnable, label: Option<TaskLabel>) {
{
let mut state = self.state.lock();
- if label.map_or(false, |label| {
- state.deprioritized_task_labels.contains(&label)
- }) {
+ if label.is_some_and(|label| state.deprioritized_task_labels.contains(&label)) {
state.deprioritized_background.push(runnable);
} else {
state.background.push(runnable);
@@ -2,7 +2,7 @@ use crate::{
AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels,
ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, PlatformKeyboardLayout,
PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream,
- Size, Task, TestDisplay, TestWindow, WindowAppearance, WindowParams, size,
+ SourceMetadata, Task, TestDisplay, TestWindow, WindowAppearance, WindowParams, size,
};
use anyhow::Result;
use collections::VecDeque;
@@ -44,11 +44,17 @@ pub(crate) struct TestPlatform {
/// A fake screen capture source, used for testing.
pub struct TestScreenCaptureSource {}
+/// A fake screen capture stream, used for testing.
pub struct TestScreenCaptureStream {}
impl ScreenCaptureSource for TestScreenCaptureSource {
- fn resolution(&self) -> Result<Size<DevicePixels>> {
- Ok(size(DevicePixels(1), DevicePixels(1)))
+ fn metadata(&self) -> Result<SourceMetadata> {
+ Ok(SourceMetadata {
+ id: 0,
+ is_main: None,
+ label: None,
+ resolution: size(DevicePixels(1), DevicePixels(1)),
+ })
}
fn stream(
@@ -64,7 +70,11 @@ impl ScreenCaptureSource for TestScreenCaptureSource {
}
}
-impl ScreenCaptureStream for TestScreenCaptureStream {}
+impl ScreenCaptureStream for TestScreenCaptureStream {
+ fn metadata(&self) -> Result<SourceMetadata> {
+ TestScreenCaptureSource {}.metadata()
+ }
+}
struct TestPrompt {
msg: String,
@@ -177,24 +187,24 @@ impl TestPlatform {
.push_back(TestPrompt {
msg: msg.to_string(),
detail: detail.map(|s| s.to_string()),
- answers: answers.clone(),
+ answers,
tx,
});
rx
}
pub(crate) fn set_active_window(&self, window: Option<TestWindow>) {
- let executor = self.foreground_executor().clone();
+ let executor = self.foreground_executor();
let previous_window = self.active_window.borrow_mut().take();
self.active_window.borrow_mut().clone_from(&window);
executor
.spawn(async move {
if let Some(previous_window) = previous_window {
- if let Some(window) = window.as_ref() {
- if Rc::ptr_eq(&previous_window.0, &window.0) {
- return;
- }
+ if let Some(window) = window.as_ref()
+ && Rc::ptr_eq(&previous_window.0, &window.0)
+ {
+ return;
}
previous_window.simulate_active_status_change(false);
}
@@ -271,13 +281,13 @@ impl Platform for TestPlatform {
#[cfg(feature = "screen-capture")]
fn screen_capture_sources(
&self,
- ) -> oneshot::Receiver<Result<Vec<Box<dyn ScreenCaptureSource>>>> {
+ ) -> oneshot::Receiver<Result<Vec<Rc<dyn ScreenCaptureSource>>>> {
let (mut tx, rx) = oneshot::channel();
tx.send(Ok(self
.screen_capture_sources
.borrow()
.iter()
- .map(|source| Box::new(source.clone()) as Box<dyn ScreenCaptureSource>)
+ .map(|source| Rc::new(source.clone()) as Rc<dyn ScreenCaptureSource>)
.collect()))
.ok();
rx
@@ -326,6 +336,7 @@ impl Platform for TestPlatform {
fn prompt_for_new_path(
&self,
directory: &std::path::Path,
+ _suggested_name: Option<&str>,
) -> oneshot::Receiver<Result<Option<std::path::PathBuf>>> {
let (tx, rx) = oneshot::channel();
self.background_executor()
@@ -341,7 +341,7 @@ impl PlatformAtlas for TestAtlas {
crate::AtlasTile {
texture_id: AtlasTextureId {
index: texture_id,
- kind: crate::AtlasTextureKind::Polychrome,
+ kind: crate::AtlasTextureKind::Monochrome,
},
tile_id: TileId(tile_id),
padding: 0,
@@ -1,6 +1,8 @@
mod clipboard;
mod destination_list;
mod direct_write;
+mod directx_atlas;
+mod directx_renderer;
mod dispatcher;
mod display;
mod events;
@@ -8,12 +10,15 @@ mod keyboard;
mod platform;
mod system_settings;
mod util;
+mod vsync;
mod window;
mod wrapper;
pub(crate) use clipboard::*;
pub(crate) use destination_list::*;
pub(crate) use direct_write::*;
+pub(crate) use directx_atlas::*;
+pub(crate) use directx_renderer::*;
pub(crate) use dispatcher::*;
pub(crate) use display::*;
pub(crate) use events::*;
@@ -21,6 +26,7 @@ pub(crate) use keyboard::*;
pub(crate) use platform::*;
pub(crate) use system_settings::*;
pub(crate) use util::*;
+pub(crate) use vsync::*;
pub(crate) use window::*;
pub(crate) use wrapper::*;
@@ -0,0 +1,39 @@
+struct RasterVertexOutput {
+ float4 position : SV_Position;
+ float2 texcoord : TEXCOORD0;
+};
+
+RasterVertexOutput emoji_rasterization_vertex(uint vertexID : SV_VERTEXID)
+{
+ RasterVertexOutput output;
+ output.texcoord = float2((vertexID << 1) & 2, vertexID & 2);
+ output.position = float4(output.texcoord * 2.0f - 1.0f, 0.0f, 1.0f);
+ output.position.y = -output.position.y;
+
+ return output;
+}
+
+struct PixelInput {
+ float4 position: SV_Position;
+ float2 texcoord : TEXCOORD0;
+};
+
+struct Bounds {
+ int2 origin;
+ int2 size;
+};
+
+Texture2D<float4> t_layer : register(t0);
+SamplerState s_layer : register(s0);
+
+cbuffer GlyphLayerTextureParams : register(b0) {
+ Bounds bounds;
+ float4 run_color;
+};
+
+float4 emoji_rasterization_fragment(PixelInput input): SV_Target {
+ float3 sampled = t_layer.Sample(s_layer, input.texcoord.xy).rgb;
+ float alpha = (sampled.r + sampled.g + sampled.b) / 3;
+
+ return float4(run_color.rgb, alpha);
+}
@@ -10,10 +10,11 @@ use windows::{
Foundation::*,
Globalization::GetUserDefaultLocaleName,
Graphics::{
- Direct2D::{Common::*, *},
+ Direct3D::D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP,
+ Direct3D11::*,
DirectWrite::*,
Dxgi::Common::*,
- Gdi::LOGFONTW,
+ Gdi::{IsRectEmpty, LOGFONTW},
Imaging::*,
},
System::SystemServices::LOCALE_NAME_MAX_LENGTH,
@@ -40,16 +41,21 @@ struct DirectWriteComponent {
locale: String,
factory: IDWriteFactory5,
bitmap_factory: AgileReference<IWICImagingFactory>,
- d2d1_factory: ID2D1Factory,
in_memory_loader: IDWriteInMemoryFontFileLoader,
builder: IDWriteFontSetBuilder1,
text_renderer: Arc<TextRendererWrapper>,
- render_context: GlyphRenderContext,
+
+ render_params: IDWriteRenderingParams3,
+ gpu_state: GPUState,
}
-struct GlyphRenderContext {
- params: IDWriteRenderingParams3,
- dc_target: ID2D1DeviceContext4,
+struct GPUState {
+ device: ID3D11Device,
+ device_context: ID3D11DeviceContext,
+ sampler: [Option<ID3D11SamplerState>; 1],
+ blend_state: ID3D11BlendState,
+ vertex_shader: ID3D11VertexShader,
+ pixel_shader: ID3D11PixelShader,
}
struct DirectWriteState {
@@ -70,12 +76,11 @@ struct FontIdentifier {
}
impl DirectWriteComponent {
- pub fn new(bitmap_factory: &IWICImagingFactory) -> Result<Self> {
+ pub fn new(bitmap_factory: &IWICImagingFactory, gpu_context: &DirectXDevices) -> Result<Self> {
+ // todo: ideally this would not be a large unsafe block but smaller isolated ones for easier auditing
unsafe {
let factory: IDWriteFactory5 = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)?;
let bitmap_factory = AgileReference::new(bitmap_factory)?;
- let d2d1_factory: ID2D1Factory =
- D2D1CreateFactory(D2D1_FACTORY_TYPE_MULTI_THREADED, None)?;
// The `IDWriteInMemoryFontFileLoader` here is supported starting from
// Windows 10 Creators Update, which consequently requires the entire
// `DirectWriteTextSystem` to run on `win10 1703`+.
@@ -86,60 +91,132 @@ impl DirectWriteComponent {
GetUserDefaultLocaleName(&mut locale_vec);
let locale = String::from_utf16_lossy(&locale_vec);
let text_renderer = Arc::new(TextRendererWrapper::new(&locale));
- let render_context = GlyphRenderContext::new(&factory, &d2d1_factory)?;
+
+ let render_params = {
+ let default_params: IDWriteRenderingParams3 =
+ factory.CreateRenderingParams()?.cast()?;
+ let gamma = default_params.GetGamma();
+ let enhanced_contrast = default_params.GetEnhancedContrast();
+ let gray_contrast = default_params.GetGrayscaleEnhancedContrast();
+ let cleartype_level = default_params.GetClearTypeLevel();
+ let grid_fit_mode = default_params.GetGridFitMode();
+
+ factory.CreateCustomRenderingParams(
+ gamma,
+ enhanced_contrast,
+ gray_contrast,
+ cleartype_level,
+ DWRITE_PIXEL_GEOMETRY_RGB,
+ DWRITE_RENDERING_MODE1_NATURAL_SYMMETRIC,
+ grid_fit_mode,
+ )?
+ };
+
+ let gpu_state = GPUState::new(gpu_context)?;
Ok(DirectWriteComponent {
locale,
factory,
bitmap_factory,
- d2d1_factory,
in_memory_loader,
builder,
text_renderer,
- render_context,
+ render_params,
+ gpu_state,
})
}
}
}
-impl GlyphRenderContext {
- pub fn new(factory: &IDWriteFactory5, d2d1_factory: &ID2D1Factory) -> Result<Self> {
- unsafe {
- let default_params: IDWriteRenderingParams3 =
- factory.CreateRenderingParams()?.cast()?;
- let gamma = default_params.GetGamma();
- let enhanced_contrast = default_params.GetEnhancedContrast();
- let gray_contrast = default_params.GetGrayscaleEnhancedContrast();
- let cleartype_level = default_params.GetClearTypeLevel();
- let grid_fit_mode = default_params.GetGridFitMode();
-
- let params = factory.CreateCustomRenderingParams(
- gamma,
- enhanced_contrast,
- gray_contrast,
- cleartype_level,
- DWRITE_PIXEL_GEOMETRY_RGB,
- DWRITE_RENDERING_MODE1_NATURAL_SYMMETRIC,
- grid_fit_mode,
- )?;
- let dc_target = {
- let target = d2d1_factory.CreateDCRenderTarget(&get_render_target_property(
- DXGI_FORMAT_B8G8R8A8_UNORM,
- D2D1_ALPHA_MODE_PREMULTIPLIED,
- ))?;
- let target = target.cast::<ID2D1DeviceContext4>()?;
- target.SetTextRenderingParams(¶ms);
- target
+impl GPUState {
+ fn new(gpu_context: &DirectXDevices) -> Result<Self> {
+ let device = gpu_context.device.clone();
+ let device_context = gpu_context.device_context.clone();
+
+ let blend_state = {
+ let mut blend_state = None;
+ let desc = D3D11_BLEND_DESC {
+ AlphaToCoverageEnable: false.into(),
+ IndependentBlendEnable: false.into(),
+ RenderTarget: [
+ D3D11_RENDER_TARGET_BLEND_DESC {
+ BlendEnable: true.into(),
+ SrcBlend: D3D11_BLEND_SRC_ALPHA,
+ DestBlend: D3D11_BLEND_INV_SRC_ALPHA,
+ BlendOp: D3D11_BLEND_OP_ADD,
+ SrcBlendAlpha: D3D11_BLEND_SRC_ALPHA,
+ DestBlendAlpha: D3D11_BLEND_INV_SRC_ALPHA,
+ BlendOpAlpha: D3D11_BLEND_OP_ADD,
+ RenderTargetWriteMask: D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8,
+ },
+ Default::default(),
+ Default::default(),
+ Default::default(),
+ Default::default(),
+ Default::default(),
+ Default::default(),
+ Default::default(),
+ ],
};
+ unsafe { device.CreateBlendState(&desc, Some(&mut blend_state)) }?;
+ blend_state.unwrap()
+ };
- Ok(Self { params, dc_target })
- }
+ let sampler = {
+ let mut sampler = None;
+ let desc = D3D11_SAMPLER_DESC {
+ Filter: D3D11_FILTER_MIN_MAG_MIP_POINT,
+ AddressU: D3D11_TEXTURE_ADDRESS_BORDER,
+ AddressV: D3D11_TEXTURE_ADDRESS_BORDER,
+ AddressW: D3D11_TEXTURE_ADDRESS_BORDER,
+ MipLODBias: 0.0,
+ MaxAnisotropy: 1,
+ ComparisonFunc: D3D11_COMPARISON_ALWAYS,
+ BorderColor: [0.0, 0.0, 0.0, 0.0],
+ MinLOD: 0.0,
+ MaxLOD: 0.0,
+ };
+ unsafe { device.CreateSamplerState(&desc, Some(&mut sampler)) }?;
+ [sampler]
+ };
+
+ let vertex_shader = {
+ let source = shader_resources::RawShaderBytes::new(
+ shader_resources::ShaderModule::EmojiRasterization,
+ shader_resources::ShaderTarget::Vertex,
+ )?;
+ let mut shader = None;
+ unsafe { device.CreateVertexShader(source.as_bytes(), None, Some(&mut shader)) }?;
+ shader.unwrap()
+ };
+
+ let pixel_shader = {
+ let source = shader_resources::RawShaderBytes::new(
+ shader_resources::ShaderModule::EmojiRasterization,
+ shader_resources::ShaderTarget::Fragment,
+ )?;
+ let mut shader = None;
+ unsafe { device.CreatePixelShader(source.as_bytes(), None, Some(&mut shader)) }?;
+ shader.unwrap()
+ };
+
+ Ok(Self {
+ device,
+ device_context,
+ sampler,
+ blend_state,
+ vertex_shader,
+ pixel_shader,
+ })
}
}
impl DirectWriteTextSystem {
- pub(crate) fn new(bitmap_factory: &IWICImagingFactory) -> Result<Self> {
- let components = DirectWriteComponent::new(bitmap_factory)?;
+ pub(crate) fn new(
+ gpu_context: &DirectXDevices,
+ bitmap_factory: &IWICImagingFactory,
+ ) -> Result<Self> {
+ let components = DirectWriteComponent::new(bitmap_factory, gpu_context)?;
let system_font_collection = unsafe {
let mut result = std::mem::zeroed();
components
@@ -421,8 +498,9 @@ impl DirectWriteState {
)
.unwrap()
} else {
+ let family = self.system_ui_font_name.clone();
self.find_font_id(
- target_font.family.as_ref(),
+ font_name_with_fallbacks(target_font.family.as_ref(), family.as_ref()),
target_font.weight,
target_font.style,
&target_font.features,
@@ -435,7 +513,6 @@ impl DirectWriteState {
}
#[cfg(not(any(test, feature = "test-support")))]
{
- let family = self.system_ui_font_name.clone();
log::error!("{} not found, use {} instead.", target_font.family, family);
self.get_font_id_from_font_collection(
family.as_ref(),
@@ -648,15 +725,13 @@ impl DirectWriteState {
}
}
- fn raster_bounds(&self, params: &RenderGlyphParams) -> Result<Bounds<DevicePixels>> {
- let render_target = &self.components.render_context.dc_target;
- unsafe {
- render_target.SetUnitMode(D2D1_UNIT_MODE_DIPS);
- render_target.SetDpi(96.0 * params.scale_factor, 96.0 * params.scale_factor);
- }
+ fn create_glyph_run_analysis(
+ &self,
+ params: &RenderGlyphParams,
+ ) -> Result<IDWriteGlyphRunAnalysis> {
let font = &self.fonts[params.font_id.0];
let glyph_id = [params.glyph_id.0 as u16];
- let advance = [0.0f32];
+ let advance = [0.0];
let offset = [DWRITE_GLYPH_OFFSET::default()];
let glyph_run = DWRITE_GLYPH_RUN {
fontFace: unsafe { std::mem::transmute_copy(&font.font_face) },
@@ -668,44 +743,87 @@ impl DirectWriteState {
isSideways: BOOL(0),
bidiLevel: 0,
};
- let bounds = unsafe {
- render_target.GetGlyphRunWorldBounds(
- Vector2 { X: 0.0, Y: 0.0 },
- &glyph_run,
- DWRITE_MEASURING_MODE_NATURAL,
- )?
+ let transform = DWRITE_MATRIX {
+ m11: params.scale_factor,
+ m12: 0.0,
+ m21: 0.0,
+ m22: params.scale_factor,
+ dx: 0.0,
+ dy: 0.0,
};
- // todo(windows)
- // This is a walkaround, deleted when figured out.
- let y_offset;
- let extra_height;
- if params.is_emoji {
- y_offset = 0;
- extra_height = 0;
- } else {
- // make some room for scaler.
- y_offset = -1;
- extra_height = 2;
+ let subpixel_shift = params
+ .subpixel_variant
+ .map(|v| v as f32 / SUBPIXEL_VARIANTS as f32);
+ let baseline_origin_x = subpixel_shift.x / params.scale_factor;
+ let baseline_origin_y = subpixel_shift.y / params.scale_factor;
+
+ let mut rendering_mode = DWRITE_RENDERING_MODE1::default();
+ let mut grid_fit_mode = DWRITE_GRID_FIT_MODE::default();
+ unsafe {
+ font.font_face.GetRecommendedRenderingMode(
+ params.font_size.0,
+ // The dpi here seems that it has the same effect with `Some(&transform)`
+ 1.0,
+ 1.0,
+ Some(&transform),
+ false,
+ DWRITE_OUTLINE_THRESHOLD_ANTIALIASED,
+ DWRITE_MEASURING_MODE_NATURAL,
+ &self.components.render_params,
+ &mut rendering_mode,
+ &mut grid_fit_mode,
+ )?;
}
- if bounds.right < bounds.left {
- Ok(Bounds {
- origin: point(0.into(), 0.into()),
- size: size(0.into(), 0.into()),
- })
- } else {
+ let glyph_analysis = unsafe {
+ self.components.factory.CreateGlyphRunAnalysis(
+ &glyph_run,
+ Some(&transform),
+ rendering_mode,
+ DWRITE_MEASURING_MODE_NATURAL,
+ grid_fit_mode,
+ // We're using cleartype not grayscale for monochrome is because it provides better quality
+ DWRITE_TEXT_ANTIALIAS_MODE_CLEARTYPE,
+ baseline_origin_x,
+ baseline_origin_y,
+ )
+ }?;
+ Ok(glyph_analysis)
+ }
+
+ fn raster_bounds(&self, params: &RenderGlyphParams) -> Result<Bounds<DevicePixels>> {
+ let glyph_analysis = self.create_glyph_run_analysis(params)?;
+
+ let bounds = unsafe { glyph_analysis.GetAlphaTextureBounds(DWRITE_TEXTURE_CLEARTYPE_3x1)? };
+ // Some glyphs cannot be drawn with ClearType, such as bitmap fonts. In that case
+ // GetAlphaTextureBounds() supposedly returns an empty RECT, but I haven't tested that yet.
+ if !unsafe { IsRectEmpty(&bounds) }.as_bool() {
Ok(Bounds {
- origin: point(
- ((bounds.left * params.scale_factor).ceil() as i32).into(),
- ((bounds.top * params.scale_factor).ceil() as i32 + y_offset).into(),
- ),
+ origin: point(bounds.left.into(), bounds.top.into()),
size: size(
- (((bounds.right - bounds.left) * params.scale_factor).ceil() as i32).into(),
- (((bounds.bottom - bounds.top) * params.scale_factor).ceil() as i32
- + extra_height)
- .into(),
+ (bounds.right - bounds.left).into(),
+ (bounds.bottom - bounds.top).into(),
),
})
+ } else {
+ // If it's empty, retry with grayscale AA.
+ let bounds =
+ unsafe { glyph_analysis.GetAlphaTextureBounds(DWRITE_TEXTURE_ALIASED_1x1)? };
+
+ if bounds.right < bounds.left {
+ Ok(Bounds {
+ origin: point(0.into(), 0.into()),
+ size: size(0.into(), 0.into()),
+ })
+ } else {
+ Ok(Bounds {
+ origin: point(bounds.left.into(), bounds.top.into()),
+ size: size(
+ (bounds.right - bounds.left).into(),
+ (bounds.bottom - bounds.top).into(),
+ ),
+ })
+ }
}
}
@@ -731,7 +849,95 @@ impl DirectWriteState {
anyhow::bail!("glyph bounds are empty");
}
- let font_info = &self.fonts[params.font_id.0];
+ let bitmap_data = if params.is_emoji {
+ if let Ok(color) = self.rasterize_color(params, glyph_bounds) {
+ color
+ } else {
+ let monochrome = self.rasterize_monochrome(params, glyph_bounds)?;
+ monochrome
+ .into_iter()
+ .flat_map(|pixel| [0, 0, 0, pixel])
+ .collect::<Vec<_>>()
+ }
+ } else {
+ self.rasterize_monochrome(params, glyph_bounds)?
+ };
+
+ Ok((glyph_bounds.size, bitmap_data))
+ }
+
+ fn rasterize_monochrome(
+ &self,
+ params: &RenderGlyphParams,
+ glyph_bounds: Bounds<DevicePixels>,
+ ) -> Result<Vec<u8>> {
+ let mut bitmap_data =
+ vec![0u8; glyph_bounds.size.width.0 as usize * glyph_bounds.size.height.0 as usize * 3];
+
+ let glyph_analysis = self.create_glyph_run_analysis(params)?;
+ unsafe {
+ glyph_analysis.CreateAlphaTexture(
+ // We're using cleartype not grayscale for monochrome is because it provides better quality
+ DWRITE_TEXTURE_CLEARTYPE_3x1,
+ &RECT {
+ left: glyph_bounds.origin.x.0,
+ top: glyph_bounds.origin.y.0,
+ right: glyph_bounds.size.width.0 + glyph_bounds.origin.x.0,
+ bottom: glyph_bounds.size.height.0 + glyph_bounds.origin.y.0,
+ },
+ &mut bitmap_data,
+ )?;
+ }
+
+ let bitmap_factory = self.components.bitmap_factory.resolve()?;
+ let bitmap = unsafe {
+ bitmap_factory.CreateBitmapFromMemory(
+ glyph_bounds.size.width.0 as u32,
+ glyph_bounds.size.height.0 as u32,
+ &GUID_WICPixelFormat24bppRGB,
+ glyph_bounds.size.width.0 as u32 * 3,
+ &bitmap_data,
+ )
+ }?;
+
+ let grayscale_bitmap =
+ unsafe { WICConvertBitmapSource(&GUID_WICPixelFormat8bppGray, &bitmap) }?;
+
+ let mut bitmap_data =
+ vec![0u8; glyph_bounds.size.width.0 as usize * glyph_bounds.size.height.0 as usize];
+ unsafe {
+ grayscale_bitmap.CopyPixels(
+ std::ptr::null() as _,
+ glyph_bounds.size.width.0 as u32,
+ &mut bitmap_data,
+ )
+ }?;
+
+ Ok(bitmap_data)
+ }
+
+ fn rasterize_color(
+ &self,
+ params: &RenderGlyphParams,
+ glyph_bounds: Bounds<DevicePixels>,
+ ) -> Result<Vec<u8>> {
+ let bitmap_size = glyph_bounds.size;
+ let subpixel_shift = params
+ .subpixel_variant
+ .map(|v| v as f32 / SUBPIXEL_VARIANTS as f32);
+ let baseline_origin_x = subpixel_shift.x / params.scale_factor;
+ let baseline_origin_y = subpixel_shift.y / params.scale_factor;
+
+ let transform = DWRITE_MATRIX {
+ m11: params.scale_factor,
+ m12: 0.0,
+ m21: 0.0,
+ m22: params.scale_factor,
+ dx: 0.0,
+ dy: 0.0,
+ };
+
+ let font = &self.fonts[params.font_id.0];
let glyph_id = [params.glyph_id.0 as u16];
let advance = [glyph_bounds.size.width.0 as f32];
let offset = [DWRITE_GLYPH_OFFSET {
@@ -739,7 +945,7 @@ impl DirectWriteState {
ascenderOffset: glyph_bounds.origin.y.0 as f32 / params.scale_factor,
}];
let glyph_run = DWRITE_GLYPH_RUN {
- fontFace: unsafe { std::mem::transmute_copy(&font_info.font_face) },
+ fontFace: unsafe { std::mem::transmute_copy(&font.font_face) },
fontEmSize: params.font_size.0,
glyphCount: 1,
glyphIndices: glyph_id.as_ptr(),
@@ -749,160 +955,254 @@ impl DirectWriteState {
bidiLevel: 0,
};
- // Add an extra pixel when the subpixel variant isn't zero to make room for anti-aliasing.
- let mut bitmap_size = glyph_bounds.size;
- if params.subpixel_variant.x > 0 {
- bitmap_size.width += DevicePixels(1);
- }
- if params.subpixel_variant.y > 0 {
- bitmap_size.height += DevicePixels(1);
- }
- let bitmap_size = bitmap_size;
-
- let total_bytes;
- let bitmap_format;
- let render_target_property;
- let bitmap_width;
- let bitmap_height;
- let bitmap_stride;
- let bitmap_dpi;
- if params.is_emoji {
- total_bytes = bitmap_size.height.0 as usize * bitmap_size.width.0 as usize * 4;
- bitmap_format = &GUID_WICPixelFormat32bppPBGRA;
- render_target_property = get_render_target_property(
- DXGI_FORMAT_B8G8R8A8_UNORM,
- D2D1_ALPHA_MODE_PREMULTIPLIED,
- );
- bitmap_width = bitmap_size.width.0 as u32;
- bitmap_height = bitmap_size.height.0 as u32;
- bitmap_stride = bitmap_size.width.0 as u32 * 4;
- bitmap_dpi = 96.0;
- } else {
- total_bytes = bitmap_size.height.0 as usize * bitmap_size.width.0 as usize;
- bitmap_format = &GUID_WICPixelFormat8bppAlpha;
- render_target_property =
- get_render_target_property(DXGI_FORMAT_A8_UNORM, D2D1_ALPHA_MODE_STRAIGHT);
- bitmap_width = bitmap_size.width.0 as u32 * 2;
- bitmap_height = bitmap_size.height.0 as u32 * 2;
- bitmap_stride = bitmap_size.width.0 as u32;
- bitmap_dpi = 192.0;
- }
+ // todo: support formats other than COLR
+ let color_enumerator = unsafe {
+ self.components.factory.TranslateColorGlyphRun(
+ Vector2::new(baseline_origin_x, baseline_origin_y),
+ &glyph_run,
+ None,
+ DWRITE_GLYPH_IMAGE_FORMATS_COLR,
+ DWRITE_MEASURING_MODE_NATURAL,
+ Some(&transform),
+ 0,
+ )
+ }?;
+
+ let mut glyph_layers = Vec::new();
+ loop {
+ let color_run = unsafe { color_enumerator.GetCurrentRun() }?;
+ let color_run = unsafe { &*color_run };
+ let image_format = color_run.glyphImageFormat & !DWRITE_GLYPH_IMAGE_FORMATS_TRUETYPE;
+ if image_format == DWRITE_GLYPH_IMAGE_FORMATS_COLR {
+ let color_analysis = unsafe {
+ self.components.factory.CreateGlyphRunAnalysis(
+ &color_run.Base.glyphRun as *const _,
+ Some(&transform),
+ DWRITE_RENDERING_MODE1_NATURAL_SYMMETRIC,
+ DWRITE_MEASURING_MODE_NATURAL,
+ DWRITE_GRID_FIT_MODE_DEFAULT,
+ DWRITE_TEXT_ANTIALIAS_MODE_CLEARTYPE,
+ baseline_origin_x,
+ baseline_origin_y,
+ )
+ }?;
- let bitmap_factory = self.components.bitmap_factory.resolve()?;
- unsafe {
- let bitmap = bitmap_factory.CreateBitmap(
- bitmap_width,
- bitmap_height,
- bitmap_format,
- WICBitmapCacheOnLoad,
- )?;
- let render_target = self
- .components
- .d2d1_factory
- .CreateWicBitmapRenderTarget(&bitmap, &render_target_property)?;
- let brush = render_target.CreateSolidColorBrush(&BRUSH_COLOR, None)?;
- let subpixel_shift = params
- .subpixel_variant
- .map(|v| v as f32 / SUBPIXEL_VARIANTS as f32);
- let baseline_origin = Vector2 {
- X: subpixel_shift.x / params.scale_factor,
- Y: subpixel_shift.y / params.scale_factor,
- };
+ let color_bounds =
+ unsafe { color_analysis.GetAlphaTextureBounds(DWRITE_TEXTURE_CLEARTYPE_3x1) }?;
- // This `cast()` action here should never fail since we are running on Win10+, and
- // ID2D1DeviceContext4 requires Win8+
- let render_target = render_target.cast::<ID2D1DeviceContext4>().unwrap();
- render_target.SetUnitMode(D2D1_UNIT_MODE_DIPS);
- render_target.SetDpi(
- bitmap_dpi * params.scale_factor,
- bitmap_dpi * params.scale_factor,
- );
- render_target.SetTextRenderingParams(&self.components.render_context.params);
- render_target.BeginDraw();
-
- if params.is_emoji {
- // WARN: only DWRITE_GLYPH_IMAGE_FORMATS_COLR has been tested
- let enumerator = self.components.factory.TranslateColorGlyphRun(
- baseline_origin,
- &glyph_run as _,
- None,
- DWRITE_GLYPH_IMAGE_FORMATS_COLR
- | DWRITE_GLYPH_IMAGE_FORMATS_SVG
- | DWRITE_GLYPH_IMAGE_FORMATS_PNG
- | DWRITE_GLYPH_IMAGE_FORMATS_JPEG
- | DWRITE_GLYPH_IMAGE_FORMATS_PREMULTIPLIED_B8G8R8A8,
- DWRITE_MEASURING_MODE_NATURAL,
- None,
- 0,
- )?;
- while enumerator.MoveNext().is_ok() {
- let Ok(color_glyph) = enumerator.GetCurrentRun() else {
- break;
+ let color_size = size(
+ color_bounds.right - color_bounds.left,
+ color_bounds.bottom - color_bounds.top,
+ );
+ if color_size.width > 0 && color_size.height > 0 {
+ let mut alpha_data =
+ vec![0u8; (color_size.width * color_size.height * 3) as usize];
+ unsafe {
+ color_analysis.CreateAlphaTexture(
+ DWRITE_TEXTURE_CLEARTYPE_3x1,
+ &color_bounds,
+ &mut alpha_data,
+ )
+ }?;
+
+ let run_color = {
+ let run_color = color_run.Base.runColor;
+ Rgba {
+ r: run_color.r,
+ g: run_color.g,
+ b: run_color.b,
+ a: run_color.a,
+ }
};
- let color_glyph = &*color_glyph;
- let brush_color = translate_color(&color_glyph.Base.runColor);
- brush.SetColor(&brush_color);
- match color_glyph.glyphImageFormat {
- DWRITE_GLYPH_IMAGE_FORMATS_PNG
- | DWRITE_GLYPH_IMAGE_FORMATS_JPEG
- | DWRITE_GLYPH_IMAGE_FORMATS_PREMULTIPLIED_B8G8R8A8 => render_target
- .DrawColorBitmapGlyphRun(
- color_glyph.glyphImageFormat,
- baseline_origin,
- &color_glyph.Base.glyphRun,
- color_glyph.measuringMode,
- D2D1_COLOR_BITMAP_GLYPH_SNAP_OPTION_DEFAULT,
- ),
- DWRITE_GLYPH_IMAGE_FORMATS_SVG => render_target.DrawSvgGlyphRun(
- baseline_origin,
- &color_glyph.Base.glyphRun,
- &brush,
- None,
- color_glyph.Base.paletteIndex as u32,
- color_glyph.measuringMode,
- ),
- _ => render_target.DrawGlyphRun(
- baseline_origin,
- &color_glyph.Base.glyphRun,
- Some(color_glyph.Base.glyphRunDescription as *const _),
- &brush,
- color_glyph.measuringMode,
- ),
- }
+ let bounds = bounds(point(color_bounds.left, color_bounds.top), color_size);
+ let alpha_data = alpha_data
+ .chunks_exact(3)
+ .flat_map(|chunk| [chunk[0], chunk[1], chunk[2], 255])
+ .collect::<Vec<_>>();
+ glyph_layers.push(GlyphLayerTexture::new(
+ &self.components.gpu_state,
+ run_color,
+ bounds,
+ &alpha_data,
+ )?);
}
- } else {
- render_target.DrawGlyphRun(
- baseline_origin,
- &glyph_run,
- None,
- &brush,
- DWRITE_MEASURING_MODE_NATURAL,
- );
}
- render_target.EndDraw(None, None)?;
-
- let mut raw_data = vec![0u8; total_bytes];
- if params.is_emoji {
- bitmap.CopyPixels(std::ptr::null() as _, bitmap_stride, &mut raw_data)?;
- // Convert from BGRA with premultiplied alpha to BGRA with straight alpha.
- for pixel in raw_data.chunks_exact_mut(4) {
- let a = pixel[3] as f32 / 255.;
- pixel[0] = (pixel[0] as f32 / a) as u8;
- pixel[1] = (pixel[1] as f32 / a) as u8;
- pixel[2] = (pixel[2] as f32 / a) as u8;
- }
- } else {
- let scaler = bitmap_factory.CreateBitmapScaler()?;
- scaler.Initialize(
- &bitmap,
- bitmap_size.width.0 as u32,
- bitmap_size.height.0 as u32,
- WICBitmapInterpolationModeHighQualityCubic,
- )?;
- scaler.CopyPixels(std::ptr::null() as _, bitmap_stride, &mut raw_data)?;
+
+ let has_next = unsafe { color_enumerator.MoveNext() }
+ .map(|e| e.as_bool())
+ .unwrap_or(false);
+ if !has_next {
+ break;
}
- Ok((bitmap_size, raw_data))
}
+
+ let gpu_state = &self.components.gpu_state;
+ let params_buffer = {
+ let desc = D3D11_BUFFER_DESC {
+ ByteWidth: std::mem::size_of::<GlyphLayerTextureParams>() as u32,
+ Usage: D3D11_USAGE_DYNAMIC,
+ BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
+ CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
+ MiscFlags: 0,
+ StructureByteStride: 0,
+ };
+
+ let mut buffer = None;
+ unsafe {
+ gpu_state
+ .device
+ .CreateBuffer(&desc, None, Some(&mut buffer))
+ }?;
+ [buffer]
+ };
+
+ let render_target_texture = {
+ let mut texture = None;
+ let desc = D3D11_TEXTURE2D_DESC {
+ Width: bitmap_size.width.0 as u32,
+ Height: bitmap_size.height.0 as u32,
+ MipLevels: 1,
+ ArraySize: 1,
+ Format: DXGI_FORMAT_B8G8R8A8_UNORM,
+ SampleDesc: DXGI_SAMPLE_DESC {
+ Count: 1,
+ Quality: 0,
+ },
+ Usage: D3D11_USAGE_DEFAULT,
+ BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
+ CPUAccessFlags: 0,
+ MiscFlags: 0,
+ };
+ unsafe {
+ gpu_state
+ .device
+ .CreateTexture2D(&desc, None, Some(&mut texture))
+ }?;
+ texture.unwrap()
+ };
+
+ let render_target_view = {
+ let desc = D3D11_RENDER_TARGET_VIEW_DESC {
+ Format: DXGI_FORMAT_B8G8R8A8_UNORM,
+ ViewDimension: D3D11_RTV_DIMENSION_TEXTURE2D,
+ Anonymous: D3D11_RENDER_TARGET_VIEW_DESC_0 {
+ Texture2D: D3D11_TEX2D_RTV { MipSlice: 0 },
+ },
+ };
+ let mut rtv = None;
+ unsafe {
+ gpu_state.device.CreateRenderTargetView(
+ &render_target_texture,
+ Some(&desc),
+ Some(&mut rtv),
+ )
+ }?;
+ [rtv]
+ };
+
+ let staging_texture = {
+ let mut texture = None;
+ let desc = D3D11_TEXTURE2D_DESC {
+ Width: bitmap_size.width.0 as u32,
+ Height: bitmap_size.height.0 as u32,
+ MipLevels: 1,
+ ArraySize: 1,
+ Format: DXGI_FORMAT_B8G8R8A8_UNORM,
+ SampleDesc: DXGI_SAMPLE_DESC {
+ Count: 1,
+ Quality: 0,
+ },
+ Usage: D3D11_USAGE_STAGING,
+ BindFlags: 0,
+ CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
+ MiscFlags: 0,
+ };
+ unsafe {
+ gpu_state
+ .device
+ .CreateTexture2D(&desc, None, Some(&mut texture))
+ }?;
+ texture.unwrap()
+ };
+
+ let device_context = &gpu_state.device_context;
+ unsafe { device_context.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP) };
+ unsafe { device_context.VSSetShader(&gpu_state.vertex_shader, None) };
+ unsafe { device_context.PSSetShader(&gpu_state.pixel_shader, None) };
+ unsafe { device_context.VSSetConstantBuffers(0, Some(¶ms_buffer)) };
+ unsafe { device_context.PSSetConstantBuffers(0, Some(¶ms_buffer)) };
+ unsafe { device_context.OMSetRenderTargets(Some(&render_target_view), None) };
+ unsafe { device_context.PSSetSamplers(0, Some(&gpu_state.sampler)) };
+ unsafe { device_context.OMSetBlendState(&gpu_state.blend_state, None, 0xffffffff) };
+
+ for layer in glyph_layers {
+ let params = GlyphLayerTextureParams {
+ run_color: layer.run_color,
+ bounds: layer.bounds,
+ };
+ unsafe {
+ let mut dest = std::mem::zeroed();
+ gpu_state.device_context.Map(
+ params_buffer[0].as_ref().unwrap(),
+ 0,
+ D3D11_MAP_WRITE_DISCARD,
+ 0,
+ Some(&mut dest),
+ )?;
+ std::ptr::copy_nonoverlapping(¶ms as *const _, dest.pData as *mut _, 1);
+ gpu_state
+ .device_context
+ .Unmap(params_buffer[0].as_ref().unwrap(), 0);
+ };
+
+ let texture = [Some(layer.texture_view)];
+ unsafe { device_context.PSSetShaderResources(0, Some(&texture)) };
+
+ let viewport = [D3D11_VIEWPORT {
+ TopLeftX: layer.bounds.origin.x as f32,
+ TopLeftY: layer.bounds.origin.y as f32,
+ Width: layer.bounds.size.width as f32,
+ Height: layer.bounds.size.height as f32,
+ MinDepth: 0.0,
+ MaxDepth: 1.0,
+ }];
+ unsafe { device_context.RSSetViewports(Some(&viewport)) };
+
+ unsafe { device_context.Draw(4, 0) };
+ }
+
+ unsafe { device_context.CopyResource(&staging_texture, &render_target_texture) };
+
+ let mapped_data = {
+ let mut mapped_data = D3D11_MAPPED_SUBRESOURCE::default();
+ unsafe {
+ device_context.Map(
+ &staging_texture,
+ 0,
+ D3D11_MAP_READ,
+ 0,
+ Some(&mut mapped_data),
+ )
+ }?;
+ mapped_data
+ };
+ let mut rasterized =
+ vec![0u8; (bitmap_size.width.0 as u32 * bitmap_size.height.0 as u32 * 4) as usize];
+
+ for y in 0..bitmap_size.height.0 as usize {
+ let width = bitmap_size.width.0 as usize;
+ unsafe {
+ std::ptr::copy_nonoverlapping::<u8>(
+ (mapped_data.pData as *const u8).byte_add(mapped_data.RowPitch as usize * y),
+ rasterized
+ .as_mut_ptr()
+ .byte_add(width * y * std::mem::size_of::<u32>()),
+ width * std::mem::size_of::<u32>(),
+ )
+ };
+ }
+
+ Ok(rasterized)
}
fn get_typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result<Bounds<f32>> {
@@ -976,6 +1276,84 @@ impl Drop for DirectWriteState {
}
}
+struct GlyphLayerTexture {
+ run_color: Rgba,
+ bounds: Bounds<i32>,
+ texture_view: ID3D11ShaderResourceView,
+ // holding on to the texture to not RAII drop it
+ _texture: ID3D11Texture2D,
+}
+
+impl GlyphLayerTexture {
+ pub fn new(
+ gpu_state: &GPUState,
+ run_color: Rgba,
+ bounds: Bounds<i32>,
+ alpha_data: &[u8],
+ ) -> Result<Self> {
+ let texture_size = bounds.size;
+
+ let desc = D3D11_TEXTURE2D_DESC {
+ Width: texture_size.width as u32,
+ Height: texture_size.height as u32,
+ MipLevels: 1,
+ ArraySize: 1,
+ Format: DXGI_FORMAT_R8G8B8A8_UNORM,
+ SampleDesc: DXGI_SAMPLE_DESC {
+ Count: 1,
+ Quality: 0,
+ },
+ Usage: D3D11_USAGE_DEFAULT,
+ BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
+ CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
+ MiscFlags: 0,
+ };
+
+ let texture = {
+ let mut texture: Option<ID3D11Texture2D> = None;
+ unsafe {
+ gpu_state
+ .device
+ .CreateTexture2D(&desc, None, Some(&mut texture))?
+ };
+ texture.unwrap()
+ };
+ let texture_view = {
+ let mut view: Option<ID3D11ShaderResourceView> = None;
+ unsafe {
+ gpu_state
+ .device
+ .CreateShaderResourceView(&texture, None, Some(&mut view))?
+ };
+ view.unwrap()
+ };
+
+ unsafe {
+ gpu_state.device_context.UpdateSubresource(
+ &texture,
+ 0,
+ None,
+ alpha_data.as_ptr() as _,
+ (texture_size.width * 4) as u32,
+ 0,
+ )
+ };
+
+ Ok(GlyphLayerTexture {
+ run_color,
+ bounds,
+ texture_view,
+ _texture: texture,
+ })
+ }
+}
+
+#[repr(C)]
+struct GlyphLayerTextureParams {
+ bounds: Bounds<i32>,
+ run_color: Rgba,
+}
+
struct TextRendererWrapper(pub IDWriteTextRenderer);
impl TextRendererWrapper {
@@ -1406,7 +1784,7 @@ fn apply_font_features(
}
unsafe {
- direct_write_features.AddFontFeature(make_direct_write_feature(&tag, *value))?;
+ direct_write_features.AddFontFeature(make_direct_write_feature(tag, *value))?;
}
}
unsafe {
@@ -1470,16 +1848,6 @@ fn get_name(string: IDWriteLocalizedStrings, locale: &str) -> Result<String> {
Ok(String::from_utf16_lossy(&name_vec[..name_length]))
}
-#[inline]
-fn translate_color(color: &DWRITE_COLOR_F) -> D2D1_COLOR_F {
- D2D1_COLOR_F {
- r: color.r,
- g: color.g,
- b: color.b,
- a: color.a,
- }
-}
-
fn get_system_ui_font_name() -> SharedString {
unsafe {
let mut info: LOGFONTW = std::mem::zeroed();
@@ -0,0 +1,309 @@
+use collections::FxHashMap;
+use etagere::BucketedAtlasAllocator;
+use parking_lot::Mutex;
+use windows::Win32::Graphics::{
+ Direct3D11::{
+ D3D11_BIND_SHADER_RESOURCE, D3D11_BOX, D3D11_CPU_ACCESS_WRITE, D3D11_TEXTURE2D_DESC,
+ D3D11_USAGE_DEFAULT, ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView,
+ ID3D11Texture2D,
+ },
+ Dxgi::Common::*,
+};
+
+use crate::{
+ AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTile, Bounds, DevicePixels, PlatformAtlas,
+ Point, Size, platform::AtlasTextureList,
+};
+
+pub(crate) struct DirectXAtlas(Mutex<DirectXAtlasState>);
+
+struct DirectXAtlasState {
+ device: ID3D11Device,
+ device_context: ID3D11DeviceContext,
+ monochrome_textures: AtlasTextureList<DirectXAtlasTexture>,
+ polychrome_textures: AtlasTextureList<DirectXAtlasTexture>,
+ tiles_by_key: FxHashMap<AtlasKey, AtlasTile>,
+}
+
+struct DirectXAtlasTexture {
+ id: AtlasTextureId,
+ bytes_per_pixel: u32,
+ allocator: BucketedAtlasAllocator,
+ texture: ID3D11Texture2D,
+ view: [Option<ID3D11ShaderResourceView>; 1],
+ live_atlas_keys: u32,
+}
+
+impl DirectXAtlas {
+ pub(crate) fn new(device: &ID3D11Device, device_context: &ID3D11DeviceContext) -> Self {
+ DirectXAtlas(Mutex::new(DirectXAtlasState {
+ device: device.clone(),
+ device_context: device_context.clone(),
+ monochrome_textures: Default::default(),
+ polychrome_textures: Default::default(),
+ tiles_by_key: Default::default(),
+ }))
+ }
+
+ pub(crate) fn get_texture_view(
+ &self,
+ id: AtlasTextureId,
+ ) -> [Option<ID3D11ShaderResourceView>; 1] {
+ let lock = self.0.lock();
+ let tex = lock.texture(id);
+ tex.view.clone()
+ }
+
+ pub(crate) fn handle_device_lost(
+ &self,
+ device: &ID3D11Device,
+ device_context: &ID3D11DeviceContext,
+ ) {
+ let mut lock = self.0.lock();
+ lock.device = device.clone();
+ lock.device_context = device_context.clone();
+ lock.monochrome_textures = AtlasTextureList::default();
+ lock.polychrome_textures = AtlasTextureList::default();
+ lock.tiles_by_key.clear();
+ }
+}
+
+impl PlatformAtlas for DirectXAtlas {
+ fn get_or_insert_with<'a>(
+ &self,
+ key: &AtlasKey,
+ build: &mut dyn FnMut() -> anyhow::Result<
+ Option<(Size<DevicePixels>, std::borrow::Cow<'a, [u8]>)>,
+ >,
+ ) -> anyhow::Result<Option<AtlasTile>> {
+ let mut lock = self.0.lock();
+ if let Some(tile) = lock.tiles_by_key.get(key) {
+ Ok(Some(tile.clone()))
+ } else {
+ let Some((size, bytes)) = build()? else {
+ return Ok(None);
+ };
+ let tile = lock
+ .allocate(size, key.texture_kind())
+ .ok_or_else(|| anyhow::anyhow!("failed to allocate"))?;
+ let texture = lock.texture(tile.texture_id);
+ texture.upload(&lock.device_context, tile.bounds, &bytes);
+ lock.tiles_by_key.insert(key.clone(), tile.clone());
+ Ok(Some(tile))
+ }
+ }
+
+ 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 {
+ return;
+ };
+
+ let textures = match id.kind {
+ AtlasTextureKind::Monochrome => &mut lock.monochrome_textures,
+ AtlasTextureKind::Polychrome => &mut lock.polychrome_textures,
+ };
+
+ let Some(texture_slot) = textures.textures.get_mut(id.index as usize) else {
+ return;
+ };
+
+ if let Some(mut texture) = texture_slot.take() {
+ texture.decrement_ref_count();
+ if texture.is_unreferenced() {
+ textures.free_list.push(texture.id.index as usize);
+ lock.tiles_by_key.remove(key);
+ } else {
+ *texture_slot = Some(texture);
+ }
+ }
+ }
+}
+
+impl DirectXAtlasState {
+ fn allocate(
+ &mut self,
+ size: Size<DevicePixels>,
+ texture_kind: AtlasTextureKind,
+ ) -> Option<AtlasTile> {
+ {
+ let textures = match texture_kind {
+ AtlasTextureKind::Monochrome => &mut self.monochrome_textures,
+ AtlasTextureKind::Polychrome => &mut self.polychrome_textures,
+ };
+
+ if let Some(tile) = textures
+ .iter_mut()
+ .rev()
+ .find_map(|texture| texture.allocate(size))
+ {
+ return Some(tile);
+ }
+ }
+
+ let texture = self.push_texture(size, texture_kind)?;
+ texture.allocate(size)
+ }
+
+ fn push_texture(
+ &mut self,
+ min_size: Size<DevicePixels>,
+ kind: AtlasTextureKind,
+ ) -> Option<&mut DirectXAtlasTexture> {
+ const DEFAULT_ATLAS_SIZE: Size<DevicePixels> = Size {
+ width: DevicePixels(1024),
+ height: DevicePixels(1024),
+ };
+ // Max texture size for DirectX. See:
+ // https://learn.microsoft.com/en-us/windows/win32/direct3d11/overviews-direct3d-11-resources-limits
+ const MAX_ATLAS_SIZE: Size<DevicePixels> = Size {
+ width: DevicePixels(16384),
+ height: DevicePixels(16384),
+ };
+ let size = min_size.min(&MAX_ATLAS_SIZE).max(&DEFAULT_ATLAS_SIZE);
+ let pixel_format;
+ let bind_flag;
+ let bytes_per_pixel;
+ match kind {
+ AtlasTextureKind::Monochrome => {
+ pixel_format = DXGI_FORMAT_R8_UNORM;
+ bind_flag = D3D11_BIND_SHADER_RESOURCE;
+ bytes_per_pixel = 1;
+ }
+ AtlasTextureKind::Polychrome => {
+ pixel_format = DXGI_FORMAT_B8G8R8A8_UNORM;
+ bind_flag = D3D11_BIND_SHADER_RESOURCE;
+ bytes_per_pixel = 4;
+ }
+ }
+ let texture_desc = D3D11_TEXTURE2D_DESC {
+ Width: size.width.0 as u32,
+ Height: size.height.0 as u32,
+ MipLevels: 1,
+ ArraySize: 1,
+ Format: pixel_format,
+ SampleDesc: DXGI_SAMPLE_DESC {
+ Count: 1,
+ Quality: 0,
+ },
+ Usage: D3D11_USAGE_DEFAULT,
+ BindFlags: bind_flag.0 as u32,
+ CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
+ MiscFlags: 0,
+ };
+ let mut texture: Option<ID3D11Texture2D> = None;
+ unsafe {
+ // This only returns None if the device is lost, which we will recreate later.
+ // So it's ok to return None here.
+ self.device
+ .CreateTexture2D(&texture_desc, None, Some(&mut texture))
+ .ok()?;
+ }
+ let texture = texture.unwrap();
+
+ let texture_list = match kind {
+ AtlasTextureKind::Monochrome => &mut self.monochrome_textures,
+ AtlasTextureKind::Polychrome => &mut self.polychrome_textures,
+ };
+ let index = texture_list.free_list.pop();
+ let view = unsafe {
+ let mut view = None;
+ self.device
+ .CreateShaderResourceView(&texture, None, Some(&mut view))
+ .ok()?;
+ [view]
+ };
+ let atlas_texture = DirectXAtlasTexture {
+ id: AtlasTextureId {
+ index: index.unwrap_or(texture_list.textures.len()) as u32,
+ kind,
+ },
+ bytes_per_pixel,
+ allocator: etagere::BucketedAtlasAllocator::new(size.into()),
+ texture,
+ view,
+ live_atlas_keys: 0,
+ };
+ if let Some(ix) = index {
+ texture_list.textures[ix] = Some(atlas_texture);
+ texture_list.textures.get_mut(ix).unwrap().as_mut()
+ } else {
+ texture_list.textures.push(Some(atlas_texture));
+ texture_list.textures.last_mut().unwrap().as_mut()
+ }
+ }
+
+ fn texture(&self, id: AtlasTextureId) -> &DirectXAtlasTexture {
+ let textures = match id.kind {
+ crate::AtlasTextureKind::Monochrome => &self.monochrome_textures,
+ crate::AtlasTextureKind::Polychrome => &self.polychrome_textures,
+ };
+ textures[id.index as usize].as_ref().unwrap()
+ }
+}
+
+impl DirectXAtlasTexture {
+ fn allocate(&mut self, size: Size<DevicePixels>) -> Option<AtlasTile> {
+ let allocation = self.allocator.allocate(size.into())?;
+ let tile = AtlasTile {
+ texture_id: self.id,
+ tile_id: allocation.id.into(),
+ bounds: Bounds {
+ origin: allocation.rectangle.min.into(),
+ size,
+ },
+ padding: 0,
+ };
+ self.live_atlas_keys += 1;
+ Some(tile)
+ }
+
+ fn upload(
+ &self,
+ device_context: &ID3D11DeviceContext,
+ bounds: Bounds<DevicePixels>,
+ bytes: &[u8],
+ ) {
+ unsafe {
+ device_context.UpdateSubresource(
+ &self.texture,
+ 0,
+ Some(&D3D11_BOX {
+ left: bounds.left().0 as u32,
+ top: bounds.top().0 as u32,
+ front: 0,
+ right: bounds.right().0 as u32,
+ bottom: bounds.bottom().0 as u32,
+ back: 1,
+ }),
+ bytes.as_ptr() as _,
+ bounds.size.width.to_bytes(self.bytes_per_pixel as u8),
+ 0,
+ );
+ }
+ }
+
+ fn decrement_ref_count(&mut self) {
+ self.live_atlas_keys -= 1;
+ }
+
+ fn is_unreferenced(&mut self) -> bool {
+ self.live_atlas_keys == 0
+ }
+}
+
+impl From<Size<DevicePixels>> for etagere::Size {
+ fn from(size: Size<DevicePixels>) -> Self {
+ etagere::Size::new(size.width.into(), size.height.into())
+ }
+}
+
+impl From<etagere::Point> for Point<DevicePixels> {
+ fn from(value: etagere::Point) -> Self {
+ Point {
+ x: DevicePixels::from(value.x),
+ y: DevicePixels::from(value.y),
+ }
+ }
+}