Compare commits
45 Commits
as-e2e-ci
...
agent-stil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbd27148d5 | ||
|
|
d6022dc87c | ||
|
|
0dd480d475 | ||
|
|
34fc2fd9d0 | ||
|
|
00701b5e99 | ||
|
|
cdfb3348ea | ||
|
|
35cd1b9ae1 | ||
|
|
bd402fdc7d | ||
|
|
c7d641ecb8 | ||
|
|
3d662ee282 | ||
|
|
7d4d8b8398 | ||
|
|
6912dc8399 | ||
|
|
952e3713d7 | ||
|
|
913e9adf90 | ||
|
|
50482a6bc2 | ||
|
|
d110459ef8 | ||
|
|
d693f02c63 | ||
|
|
90fa921756 | ||
|
|
11efa32fa7 | ||
|
|
e6dc6faccf | ||
|
|
070f7dbe1a | ||
|
|
106d4cfce9 | ||
|
|
a1080a0411 | ||
|
|
7679db99ac | ||
|
|
9ade399756 | ||
|
|
e8db429d24 | ||
|
|
53b69d29c5 | ||
|
|
e2e147ab0e | ||
|
|
fa2ff3ce1c | ||
|
|
c1d1d1cff6 | ||
|
|
efba2cbfd3 | ||
|
|
2234220618 | ||
|
|
dd840e4b27 | ||
|
|
262365ca24 | ||
|
|
90fa06dd61 | ||
|
|
740686b883 | ||
|
|
a5c25e0366 | ||
|
|
305c653c62 | ||
|
|
e227b5ac30 | ||
|
|
03876d076e | ||
|
|
4dbd24d75f | ||
|
|
c397027ec2 | ||
|
|
f5f837d39a | ||
|
|
5b1b3c51d4 | ||
|
|
b4a441f12f |
32
.github/actionlint.yml
vendored
32
.github/actionlint.yml
vendored
@@ -5,25 +5,25 @@ self-hosted-runner:
|
||||
# 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
|
||||
# Buildjet Ubuntu 20.04 - AMD x86_64
|
||||
- buildjet-2vcpu-ubuntu-2004
|
||||
- buildjet-4vcpu-ubuntu-2004
|
||||
- buildjet-8vcpu-ubuntu-2004
|
||||
- buildjet-16vcpu-ubuntu-2004
|
||||
- buildjet-32vcpu-ubuntu-2004
|
||||
# Buildjet Ubuntu 22.04 - AMD x86_64
|
||||
- buildjet-2vcpu-ubuntu-2204
|
||||
- buildjet-4vcpu-ubuntu-2204
|
||||
- buildjet-8vcpu-ubuntu-2204
|
||||
- buildjet-16vcpu-ubuntu-2204
|
||||
- buildjet-32vcpu-ubuntu-2204
|
||||
# Buildjet Ubuntu 22.04 - Graviton aarch64
|
||||
- buildjet-8vcpu-ubuntu-2204-arm
|
||||
- buildjet-16vcpu-ubuntu-2204-arm
|
||||
- buildjet-32vcpu-ubuntu-2204-arm
|
||||
# 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-2x4-ubuntu-2204
|
||||
- namespace-profile-4x8-ubuntu-2204
|
||||
- namespace-profile-8x16-ubuntu-2204
|
||||
- namespace-profile-16x32-ubuntu-2204
|
||||
- namespace-profile-32x64-ubuntu-2204
|
||||
# Self Hosted Runners
|
||||
- self-mini-macos
|
||||
- self-32vcpu-windows-2022
|
||||
|
||||
2
.github/actions/build_docs/action.yml
vendored
2
.github/actions/build_docs/action.yml
vendored
@@ -13,7 +13,7 @@ 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}
|
||||
|
||||
132
.github/workflows/agent_servers_e2e.yml
vendored
132
.github/workflows/agent_servers_e2e.yml
vendored
@@ -1,132 +0,0 @@
|
||||
name: Agent Servers E2E Tests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * *"
|
||||
|
||||
push:
|
||||
branches:
|
||||
- as-e2e-ci
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
paths:
|
||||
- "crates/agent_servers/**"
|
||||
- "crates/acp_thread/**"
|
||||
- ".github/workflows/agent_servers_e2e.yml"
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
name: Run Agent Servers E2E Tests
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
timeout-minutes: 20
|
||||
runs-on:
|
||||
- buildjet-16vcpu-ubuntu-2204
|
||||
|
||||
steps:
|
||||
- name: Add Rust to the PATH
|
||||
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
clean: false
|
||||
|
||||
# - name: Checkout gemini-cli repo
|
||||
# uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
# with:
|
||||
# repository: zed-industries/gemini-cli
|
||||
# ref: migrate-acp
|
||||
# path: gemini-cli
|
||||
# clean: false
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-provider: "buildjet"
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: ./script/linux
|
||||
|
||||
- name: Configure CI
|
||||
run: |
|
||||
mkdir -p ./../.cargo
|
||||
cp ./.cargo/ci-config.toml ./../.cargo/config.toml
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: |
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
# Verify installation
|
||||
which claude || echo "Claude CLI not found in PATH"
|
||||
# Skip authentication if API key is not set (tests may use mock)
|
||||
if [ -n "$ANTHROPIC_API_KEY" ]; then
|
||||
echo "Anthropic API key is configured"
|
||||
fi
|
||||
|
||||
# - name: Install and setup Gemini CLI
|
||||
# shell: bash -euxo pipefail {0}
|
||||
# run: |
|
||||
# # Also install dependencies for local gemini-cli repo
|
||||
# pushd gemini-cli
|
||||
# npm install
|
||||
# npm run build
|
||||
# popd
|
||||
|
||||
# # Verify installations
|
||||
# which gemini || echo "Gemini CLI not found in PATH"
|
||||
# # Skip authentication if API key is not set (tests may use mock)
|
||||
# if [ -n "$GEMINI_API_KEY" ]; then
|
||||
# echo "Gemini API key is configured"
|
||||
# fi
|
||||
|
||||
- name: Limit target directory size
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: script/clear-target-dir-if-larger-than 100
|
||||
|
||||
- name: Install nextest
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: |
|
||||
cargo install cargo-nextest --locked
|
||||
|
||||
- name: Build Zed
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: |
|
||||
cargo build
|
||||
|
||||
- name: Run E2E tests
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: |
|
||||
cargo nextest run \
|
||||
--package agent_servers \
|
||||
--features e2e \
|
||||
--no-fail-fast \
|
||||
claude
|
||||
|
||||
# Even the Linux runner is not stateful, in theory there is no need to do this cleanup.
|
||||
# But, to avoid potential issues in the future if we choose to use a stateful Linux runner and forget to add code
|
||||
# to clean up the config file, I’ve included the cleanup code here as a precaution.
|
||||
# While it’s not strictly necessary at this moment, I believe it’s better to err on the side of caution.
|
||||
- name: Clean CI config file
|
||||
if: always()
|
||||
run: rm -rf ./../.cargo
|
||||
2
.github/workflows/bump_patch_version.yml
vendored
2
.github/workflows/bump_patch_version.yml
vendored
@@ -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
|
||||
|
||||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -137,7 +137,7 @@ 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
|
||||
@@ -168,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
|
||||
@@ -221,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
|
||||
@@ -328,7 +328,7 @@ 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"
|
||||
@@ -342,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
|
||||
@@ -380,7 +380,7 @@ 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"
|
||||
@@ -394,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
|
||||
@@ -597,7 +597,7 @@ 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')
|
||||
@@ -650,7 +650,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
name: Linux arm64 release bundle
|
||||
runs-on:
|
||||
- buildjet-32vcpu-ubuntu-2204-arm
|
||||
- namespace-profile-32x64-ubuntu-2004-arm # ubuntu 20.04 for minimal glibc
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v')
|
||||
|| contains(github.event.pull_request.labels.*.name, 'run-bundling')
|
||||
|
||||
2
.github/workflows/deploy_cloudflare.yml
vendored
2
.github/workflows/deploy_cloudflare.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/deploy_collab.yml
vendored
6
.github/workflows/deploy_collab.yml
vendored
@@ -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
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
needs:
|
||||
- publish
|
||||
runs-on:
|
||||
- buildjet-16vcpu-ubuntu-2204
|
||||
- namespace-profile-16x32-ubuntu-2204
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
@@ -137,12 +137,14 @@ jobs:
|
||||
|
||||
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}"
|
||||
|
||||
4
.github/workflows/eval.yml
vendored
4
.github/workflows/eval.yml
vendored
@@ -32,7 +32,7 @@ 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"
|
||||
@@ -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
|
||||
|
||||
2
.github/workflows/nix.yml
vendored
2
.github/workflows/nix.yml
vendored
@@ -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]
|
||||
|
||||
2
.github/workflows/randomized_tests.yml
vendored
2
.github/workflows/randomized_tests.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/release_nightly.yml
vendored
4
.github/workflows/release_nightly.yml
vendored
@@ -128,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
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
name: Create a Linux *.tar.gz bundle for ARM
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on:
|
||||
- buildjet-32vcpu-ubuntu-2204-arm
|
||||
- namespace-profile-32x64-ubuntu-2004-arm # ubuntu 20.04 for minimal glibc
|
||||
needs: tests
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
|
||||
4
.github/workflows/unit_evals.yml
vendored
4
.github/workflows/unit_evals.yml
vendored
@@ -23,7 +23,7 @@ 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"
|
||||
@@ -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
|
||||
|
||||
25
Cargo.lock
generated
25
Cargo.lock
generated
@@ -138,9 +138,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
version = "0.0.20"
|
||||
version = "0.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12dbfec3d27680337ed9d3064eecafe97acf0b0f190148bb4e29d96707c9e403"
|
||||
checksum = "3fad72b7b8ee4331b3a4c8d43c107e982a4725564b4ee658ae5c4e79d2b486e8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures 0.3.31",
|
||||
@@ -159,6 +159,7 @@ dependencies = [
|
||||
"agent-client-protocol",
|
||||
"agent_servers",
|
||||
"anyhow",
|
||||
"assistant_tool",
|
||||
"client",
|
||||
"clock",
|
||||
"cloud_llm_client",
|
||||
@@ -171,10 +172,14 @@ dependencies = [
|
||||
"gpui_tokio",
|
||||
"handlebars 4.5.0",
|
||||
"indoc",
|
||||
"itertools 0.14.0",
|
||||
"language",
|
||||
"language_model",
|
||||
"language_models",
|
||||
"log",
|
||||
"pretty_assertions",
|
||||
"project",
|
||||
"prompt_store",
|
||||
"reqwest_client",
|
||||
"rust-embed",
|
||||
"schemars",
|
||||
@@ -185,6 +190,7 @@ dependencies = [
|
||||
"ui",
|
||||
"util",
|
||||
"uuid",
|
||||
"watch",
|
||||
"workspace-hack",
|
||||
"worktree",
|
||||
]
|
||||
@@ -7502,9 +7508,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "grid"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71b01d27060ad58be4663b9e4ac9e2d4806918e8876af8912afbddd1a91d5eaa"
|
||||
checksum = "12101ecc8225ea6d675bc70263074eab6169079621c2186fe0c66590b2df9681"
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
@@ -9123,6 +9129,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"client",
|
||||
"cloud_api_types",
|
||||
"cloud_llm_client",
|
||||
"collections",
|
||||
"futures 0.3.31",
|
||||
@@ -12632,6 +12639,7 @@ dependencies = [
|
||||
"editor",
|
||||
"file_icons",
|
||||
"git",
|
||||
"git_ui",
|
||||
"gpui",
|
||||
"indexmap",
|
||||
"language",
|
||||
@@ -16215,9 +16223,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "taffy"
|
||||
version = "0.8.3"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7aaef0ac998e6527d6d0d5582f7e43953bb17221ac75bb8eb2fcc2db3396db1c"
|
||||
checksum = "a13e5d13f79d558b5d353a98072ca8ca0e99da429467804de959aa8c83c9a004"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"grid",
|
||||
@@ -16618,9 +16626,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tiktoken-rs"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25563eeba904d770acf527e8b370fe9a5547bacd20ff84a0b6c3bc41288e5625"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/zed-industries/tiktoken-rs?rev=30c32a4522751699adeda0d5840c71c3b75ae73d#30c32a4522751699adeda0d5840c71c3b75ae73d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
|
||||
@@ -425,7 +425,7 @@ zlog_settings = { path = "crates/zlog_settings" }
|
||||
#
|
||||
|
||||
agentic-coding-protocol = "0.0.10"
|
||||
agent-client-protocol = "0.0.20"
|
||||
agent-client-protocol = { version = "0.0.23" }
|
||||
aho-corasick = "1.1"
|
||||
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
|
||||
any_vec = "0.14"
|
||||
@@ -601,7 +601,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",
|
||||
|
||||
1
assets/icons/file_icons/puppet.svg
Normal file
1
assets/icons/file_icons/puppet.svg
Normal file
@@ -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>
|
||||
|
After Width: | Height: | Size: 308 B |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 6.4 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 5.3 KiB |
1
assets/images/pro_user_stamp.svg
Normal file
1
assets/images/pro_user_stamp.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.5 KiB |
@@ -848,6 +848,7 @@
|
||||
"ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }],
|
||||
"alt-ctrl-r": "project_panel::RevealInFileManager",
|
||||
"ctrl-shift-enter": "project_panel::OpenWithSystem",
|
||||
"alt-d": "project_panel::CompareMarkedFiles",
|
||||
"shift-find": "project_panel::NewSearchInDirectory",
|
||||
"ctrl-alt-shift-f": "project_panel::NewSearchInDirectory",
|
||||
"shift-down": "menu::SelectNext",
|
||||
@@ -1102,6 +1103,13 @@
|
||||
"ctrl-enter": "menu::Confirm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "OnboardingAiConfigurationModal",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"escape": "menu::Cancel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Diagnostics",
|
||||
"use_key_equivalents": true,
|
||||
@@ -1178,7 +1186,8 @@
|
||||
"ctrl-1": "onboarding::ActivateBasicsPage",
|
||||
"ctrl-2": "onboarding::ActivateEditingPage",
|
||||
"ctrl-3": "onboarding::ActivateAISetupPage",
|
||||
"ctrl-escape": "onboarding::Finish"
|
||||
"ctrl-escape": "onboarding::Finish",
|
||||
"alt-tab": "onboarding::SignIn"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -907,6 +907,7 @@
|
||||
"cmd-delete": ["project_panel::Delete", { "skip_prompt": false }],
|
||||
"alt-cmd-r": "project_panel::RevealInFileManager",
|
||||
"ctrl-shift-enter": "project_panel::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",
|
||||
@@ -1204,6 +1205,13 @@
|
||||
"cmd-enter": "menu::Confirm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "OnboardingAiConfigurationModal",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"escape": "menu::Cancel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Diagnostics",
|
||||
"use_key_equivalents": true,
|
||||
@@ -1280,7 +1288,8 @@
|
||||
"cmd-1": "onboarding::ActivateBasicsPage",
|
||||
"cmd-2": "onboarding::ActivateEditingPage",
|
||||
"cmd-3": "onboarding::ActivateAISetupPage",
|
||||
"cmd-escape": "onboarding::Finish"
|
||||
"cmd-escape": "onboarding::Finish",
|
||||
"alt-tab": "onboarding::SignIn"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -813,6 +813,7 @@
|
||||
"p": "project_panel::Open",
|
||||
"x": "project_panel::RevealInFileManager",
|
||||
"s": "project_panel::OpenWithSystem",
|
||||
"z d": "project_panel::CompareMarkedFiles",
|
||||
"] c": "project_panel::SelectNextGitEntry",
|
||||
"[ c": "project_panel::SelectPrevGitEntry",
|
||||
"] d": "project_panel::SelectNextDiagnostic",
|
||||
|
||||
@@ -1171,6 +1171,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
|
||||
|
||||
@@ -6,7 +6,6 @@ use anyhow::{Context as _, Result};
|
||||
use assistant_tool::ActionLog;
|
||||
use buffer_diff::BufferDiff;
|
||||
use editor::{Bias, MultiBuffer, PathKey};
|
||||
use futures::future::{Fuse, FusedFuture};
|
||||
use futures::{FutureExt, channel::oneshot, future::BoxFuture};
|
||||
use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task};
|
||||
use itertools::Itertools;
|
||||
@@ -167,6 +166,7 @@ pub struct ToolCall {
|
||||
pub status: ToolCallStatus,
|
||||
pub locations: Vec<acp::ToolCallLocation>,
|
||||
pub raw_input: Option<serde_json::Value>,
|
||||
pub raw_output: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ToolCall {
|
||||
@@ -195,6 +195,7 @@ impl ToolCall {
|
||||
locations: tool_call.locations,
|
||||
status,
|
||||
raw_input: tool_call.raw_input,
|
||||
raw_output: tool_call.raw_output,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +212,7 @@ impl ToolCall {
|
||||
content,
|
||||
locations,
|
||||
raw_input,
|
||||
raw_output,
|
||||
} = fields;
|
||||
|
||||
if let Some(kind) = kind {
|
||||
@@ -241,6 +243,10 @@ impl ToolCall {
|
||||
if let Some(raw_input) = raw_input {
|
||||
self.raw_input = Some(raw_input);
|
||||
}
|
||||
|
||||
if let Some(raw_output) = raw_output {
|
||||
self.raw_output = Some(raw_output);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diffs(&self) -> impl Iterator<Item = &Diff> {
|
||||
@@ -573,7 +579,7 @@ pub struct AcpThread {
|
||||
project: Entity<Project>,
|
||||
action_log: Entity<ActionLog>,
|
||||
shared_buffers: HashMap<Entity<Buffer>, BufferSnapshot>,
|
||||
send_task: Option<Fuse<Task<()>>>,
|
||||
send_task: Option<Task<()>>,
|
||||
connection: Rc<dyn AgentConnection>,
|
||||
session_id: acp::SessionId,
|
||||
}
|
||||
@@ -663,11 +669,7 @@ impl AcpThread {
|
||||
}
|
||||
|
||||
pub fn status(&self) -> ThreadStatus {
|
||||
if self
|
||||
.send_task
|
||||
.as_ref()
|
||||
.map_or(false, |t| !t.is_terminated())
|
||||
{
|
||||
if self.send_task.as_ref().map_or(false, |t| !t.is_finished()) {
|
||||
if self.waiting_for_tool_confirmation() {
|
||||
ThreadStatus::WaitingForToolConfirmation
|
||||
} else {
|
||||
@@ -902,7 +904,7 @@ impl AcpThread {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn request_tool_call_permission(
|
||||
pub fn request_tool_call_authorization(
|
||||
&mut self,
|
||||
tool_call: acp::ToolCall,
|
||||
options: Vec<acp::PermissionOption>,
|
||||
@@ -1042,31 +1044,28 @@ impl AcpThread {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let cancel_task = self.cancel(cx);
|
||||
|
||||
self.send_task = Some(
|
||||
cx.spawn(async move |this, cx| {
|
||||
async {
|
||||
cancel_task.await;
|
||||
self.send_task = Some(cx.spawn(async move |this, cx| {
|
||||
async {
|
||||
cancel_task.await;
|
||||
|
||||
let result = this
|
||||
.update(cx, |this, cx| {
|
||||
this.connection.prompt(
|
||||
acp::PromptRequest {
|
||||
prompt: message,
|
||||
session_id: this.session_id.clone(),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await;
|
||||
let result = this
|
||||
.update(cx, |this, cx| {
|
||||
this.connection.prompt(
|
||||
acp::PromptRequest {
|
||||
prompt: message,
|
||||
session_id: this.session_id.clone(),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await;
|
||||
|
||||
tx.send(result).log_err();
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.await
|
||||
.log_err();
|
||||
})
|
||||
.fuse(),
|
||||
);
|
||||
tx.send(result).log_err();
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.await
|
||||
.log_err();
|
||||
}));
|
||||
|
||||
cx.spawn(async move |this, cx| match rx.await {
|
||||
Ok(Err(e)) => {
|
||||
@@ -1547,6 +1546,7 @@ mod tests {
|
||||
content: vec![],
|
||||
locations: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
@@ -1659,6 +1659,7 @@ mod tests {
|
||||
}],
|
||||
locations: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ acp_thread.workspace = true
|
||||
agent-client-protocol.workspace = true
|
||||
agent_servers.workspace = true
|
||||
anyhow.workspace = true
|
||||
assistant_tool.workspace = true
|
||||
cloud_llm_client.workspace = true
|
||||
collections.workspace = true
|
||||
fs.workspace = true
|
||||
@@ -23,10 +24,13 @@ futures.workspace = true
|
||||
gpui.workspace = true
|
||||
handlebars = { workspace = true, features = ["rust-embed"] }
|
||||
indoc.workspace = true
|
||||
itertools.workspace = true
|
||||
language.workspace = true
|
||||
language_model.workspace = true
|
||||
language_models.workspace = true
|
||||
log.workspace = true
|
||||
project.workspace = true
|
||||
prompt_store.workspace = true
|
||||
rust-embed.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
@@ -36,7 +40,7 @@ smol.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
uuid.workspace = true
|
||||
worktree.workspace = true
|
||||
watch.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -47,8 +51,10 @@ env_logger.workspace = true
|
||||
fs = { 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"] }
|
||||
project = { workspace = true, "features" = ["test-support"] }
|
||||
reqwest_client.workspace = true
|
||||
settings = { workspace = true, "features" = ["test-support"] }
|
||||
worktree = { workspace = true, "features" = ["test-support"] }
|
||||
pretty_assertions.workspace = true
|
||||
|
||||
@@ -1,16 +1,39 @@
|
||||
use crate::{templates::Templates, AgentResponseEvent, Thread};
|
||||
use crate::{FindPathTool, ReadFileTool, ThinkingTool, ToolCallAuthorization};
|
||||
use acp_thread::ModelSelector;
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::{anyhow, Result};
|
||||
use futures::StreamExt;
|
||||
use gpui::{App, AppContext, AsyncApp, Entity, Subscription, Task, WeakEntity};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use futures::{future, StreamExt};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
|
||||
};
|
||||
use language_model::{LanguageModel, LanguageModelRegistry};
|
||||
use project::Project;
|
||||
use project::{Project, ProjectItem, ProjectPath, Worktree};
|
||||
use prompt_store::{
|
||||
ProjectContext, PromptId, PromptStore, RulesFileContext, UserRulesContext, WorktreeContext,
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::{templates::Templates, AgentResponseEvent, Thread};
|
||||
const RULES_FILE_NAMES: [&'static 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 {
|
||||
@@ -24,17 +47,247 @@ struct Session {
|
||||
pub struct NativeAgent {
|
||||
/// Session ID -> Session mapping
|
||||
sessions: HashMap<acp::SessionId, Session>,
|
||||
/// Shared project context for all threads
|
||||
project_context: Rc<RefCell<ProjectContext>>,
|
||||
project_context_needs_refresh: watch::Sender<()>,
|
||||
_maintain_project_context: Task<Result<()>>,
|
||||
/// Shared templates for all threads
|
||||
templates: Arc<Templates>,
|
||||
project: Entity<Project>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl NativeAgent {
|
||||
pub fn new(templates: Arc<Templates>) -> Self {
|
||||
pub async fn new(
|
||||
project: Entity<Project>,
|
||||
templates: Arc<Templates>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Entity<NativeAgent>> {
|
||||
log::info!("Creating new NativeAgent");
|
||||
Self {
|
||||
sessions: HashMap::new(),
|
||||
templates,
|
||||
|
||||
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)];
|
||||
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(),
|
||||
project_context: Rc::new(RefCell::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
|
||||
}),
|
||||
templates,
|
||||
project,
|
||||
prompt_store,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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, _| this.project_context.replace(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_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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,8 +373,21 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
log::debug!("Starting thread creation in async context");
|
||||
|
||||
// Generate session ID
|
||||
let session_id = acp::SessionId(uuid::Uuid::new_v4().to_string().into());
|
||||
log::info!("Created session with ID: {}", session_id);
|
||||
|
||||
// Create AcpThread
|
||||
let acp_thread = cx.update(|cx| {
|
||||
cx.new(|cx| {
|
||||
acp_thread::AcpThread::new("agent2", self.clone(), project.clone(), session_id.clone(), cx)
|
||||
})
|
||||
})?;
|
||||
let action_log = cx.update(|cx| acp_thread.read(cx).action_log().clone())?;
|
||||
|
||||
// Create Thread
|
||||
let (session_id, thread) = agent.update(
|
||||
let thread = agent.update(
|
||||
cx,
|
||||
|agent, cx: &mut gpui::Context<NativeAgent>| -> Result<_> {
|
||||
// Fetch default model from registry settings
|
||||
@@ -146,22 +412,18 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
||||
anyhow!("No default model configured. Please configure a default model in settings.")
|
||||
})?;
|
||||
|
||||
let thread = cx.new(|_| Thread::new(project.clone(), agent.templates.clone(), default_model));
|
||||
let thread = cx.new(|_| {
|
||||
let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log.clone(), agent.templates.clone(), default_model);
|
||||
thread.add_tool(ThinkingTool);
|
||||
thread.add_tool(FindPathTool::new(project.clone()));
|
||||
thread.add_tool(ReadFileTool::new(project.clone(), action_log));
|
||||
thread
|
||||
});
|
||||
|
||||
// Generate session ID
|
||||
let session_id = acp::SessionId(uuid::Uuid::new_v4().to_string().into());
|
||||
log::info!("Created session with ID: {}", session_id);
|
||||
Ok((session_id, thread))
|
||||
Ok(thread)
|
||||
},
|
||||
)??;
|
||||
|
||||
// Create AcpThread
|
||||
let acp_thread = cx.update(|cx| {
|
||||
cx.new(|cx| {
|
||||
acp_thread::AcpThread::new("agent2", self.clone(), project, session_id.clone(), cx)
|
||||
})
|
||||
})?;
|
||||
|
||||
// Store the session
|
||||
agent.update(cx, |agent, cx| {
|
||||
agent.sessions.insert(
|
||||
@@ -264,6 +526,28 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
||||
)
|
||||
})??;
|
||||
}
|
||||
AgentResponseEvent::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(option) = recv
|
||||
.await
|
||||
.context("authorization sender was dropped")
|
||||
.log_err()
|
||||
{
|
||||
response
|
||||
.send(option)
|
||||
.map(|_| anyhow!("authorization receiver was dropped"))
|
||||
.log_err();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
AgentResponseEvent::ToolCall(tool_call) => {
|
||||
acp_thread.update(cx, |thread, cx| {
|
||||
thread.handle_session_update(
|
||||
@@ -343,3 +627,77 @@ fn convert_prompt_to_message(blocks: Vec<acp::ContentBlock>) -> String {
|
||||
|
||||
message
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fs::FakeFs;
|
||||
use gpui::TestAppContext;
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
|
||||
#[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 agent = NativeAgent::new(project.clone(), Templates::new(), None, &mut cx.to_async())
|
||||
.await
|
||||
.unwrap();
|
||||
agent.read_with(cx, |agent, _| {
|
||||
assert_eq!(agent.project_context.borrow().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, _| {
|
||||
assert_eq!(
|
||||
agent.project_context.borrow().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.borrow().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()
|
||||
})
|
||||
}]
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
mod agent;
|
||||
mod native_agent_server;
|
||||
mod prompts;
|
||||
mod templates;
|
||||
mod thread;
|
||||
mod tools;
|
||||
@@ -11,3 +10,4 @@ mod tests;
|
||||
pub use agent::*;
|
||||
pub use native_agent_server::NativeAgentServer;
|
||||
pub use thread::*;
|
||||
pub use tools::*;
|
||||
|
||||
@@ -3,8 +3,9 @@ use std::rc::Rc;
|
||||
|
||||
use agent_servers::AgentServer;
|
||||
use anyhow::Result;
|
||||
use gpui::{App, AppContext, Entity, Task};
|
||||
use gpui::{App, Entity, Task};
|
||||
use project::Project;
|
||||
use prompt_store::PromptStore;
|
||||
|
||||
use crate::{templates::Templates, NativeAgent, NativeAgentConnection};
|
||||
|
||||
@@ -32,21 +33,22 @@ impl AgentServer for NativeAgentServer {
|
||||
fn connect(
|
||||
&self,
|
||||
_root_dir: &Path,
|
||||
_project: &Entity<Project>,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> {
|
||||
log::info!(
|
||||
"NativeAgentServer::connect called for path: {:?}",
|
||||
_root_dir
|
||||
);
|
||||
let project = project.clone();
|
||||
let prompt_store = PromptStore::global(cx);
|
||||
cx.spawn(async move |cx| {
|
||||
log::debug!("Creating templates for native agent");
|
||||
// Create templates (you might want to load these from files or resources)
|
||||
let templates = Templates::new();
|
||||
let prompt_store = prompt_store.await?;
|
||||
|
||||
// Create the native agent
|
||||
log::debug!("Creating native agent entity");
|
||||
let agent = cx.update(|cx| cx.new(|_| NativeAgent::new(templates)))?;
|
||||
let agent = NativeAgent::new(project, templates, Some(prompt_store), cx).await?;
|
||||
|
||||
// Create the connection wrapper
|
||||
let connection = NativeAgentConnection(agent);
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
use crate::{
|
||||
templates::{BaseTemplate, Template, Templates, WorktreeData},
|
||||
thread::Prompt,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use gpui::{App, Entity};
|
||||
use project::Project;
|
||||
|
||||
pub struct BasePrompt {
|
||||
project: Entity<Project>,
|
||||
}
|
||||
|
||||
impl BasePrompt {
|
||||
pub fn new(project: Entity<Project>) -> Self {
|
||||
Self { project }
|
||||
}
|
||||
}
|
||||
|
||||
impl Prompt for BasePrompt {
|
||||
fn render(&self, templates: &Templates, cx: &App) -> Result<String> {
|
||||
BaseTemplate {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
shell: util::get_system_shell(),
|
||||
worktrees: self
|
||||
.project
|
||||
.read(cx)
|
||||
.worktrees(cx)
|
||||
.map(|worktree| WorktreeData {
|
||||
root_name: worktree.read(cx).root_name().to_string(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
.render(templates)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
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"]
|
||||
@@ -15,6 +15,8 @@ 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))
|
||||
}
|
||||
@@ -32,26 +34,54 @@ pub trait Template: Sized {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BaseTemplate {
|
||||
pub os: String,
|
||||
pub shell: String,
|
||||
pub worktrees: Vec<WorktreeData>,
|
||||
pub struct SystemPromptTemplate<'a> {
|
||||
#[serde(flatten)]
|
||||
pub project: &'a prompt_store::ProjectContext,
|
||||
pub available_tools: Vec<SharedString>,
|
||||
}
|
||||
|
||||
impl Template for BaseTemplate {
|
||||
const TEMPLATE_NAME: &'static str = "base.hbs";
|
||||
impl Template for SystemPromptTemplate<'_> {
|
||||
const TEMPLATE_NAME: &'static str = "system_prompt.hbs";
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WorktreeData {
|
||||
pub root_name: String,
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GlobTemplate {
|
||||
pub project_roots: String,
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
impl Template for GlobTemplate {
|
||||
const TEMPLATE_NAME: &'static str = "glob.hbs";
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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}}
|
||||
- `{{root_name}}`
|
||||
{{/each}}
|
||||
|
||||
- When providing paths to tools, the path should always begin with a path that starts with a project root directory listed above.
|
||||
- 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.
|
||||
- Bias towards not asking the user for help if you can find the answer yourself.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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. 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}}
|
||||
@@ -1,8 +0,0 @@
|
||||
Find paths on disk with glob patterns.
|
||||
|
||||
Assume that all glob patterns are matched in a project directory with the following entries.
|
||||
|
||||
{{project_roots}}
|
||||
|
||||
When searching with patterns that begin with literal path components, e.g. `foo/bar/**/*.rs`, be
|
||||
sure to anchor them with one of the directories listed above.
|
||||
178
crates/agent2/src/templates/system_prompt.hbs
Normal file
178
crates/agent2/src/templates/system_prompt.hbs
Normal file
@@ -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}}
|
||||
@@ -1,30 +1,34 @@
|
||||
use super::*;
|
||||
use crate::templates::Templates;
|
||||
use acp_thread::AgentConnection;
|
||||
use agent_client_protocol as acp;
|
||||
use agent_client_protocol::{self as acp};
|
||||
use anyhow::Result;
|
||||
use assistant_tool::ActionLog;
|
||||
use client::{Client, UserStore};
|
||||
use fs::FakeFs;
|
||||
use futures::channel::mpsc::UnboundedReceiver;
|
||||
use gpui::{http_client::FakeHttpClient, AppContext, Entity, Task, TestAppContext};
|
||||
use indoc::indoc;
|
||||
use language_model::{
|
||||
fake_provider::FakeLanguageModel, LanguageModel, LanguageModelCompletionError,
|
||||
LanguageModelCompletionEvent, LanguageModelId, LanguageModelRegistry, MessageContent,
|
||||
StopReason,
|
||||
LanguageModelCompletionEvent, LanguageModelId, LanguageModelRegistry, LanguageModelToolResult,
|
||||
LanguageModelToolUse, MessageContent, Role, StopReason,
|
||||
};
|
||||
use project::Project;
|
||||
use prompt_store::ProjectContext;
|
||||
use reqwest_client::ReqwestClient;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use smol::stream::StreamExt;
|
||||
use std::{path::Path, rc::Rc, sync::Arc, time::Duration};
|
||||
use std::{cell::RefCell, path::Path, rc::Rc, sync::Arc, time::Duration};
|
||||
use util::path;
|
||||
|
||||
mod test_tools;
|
||||
use test_tools::*;
|
||||
|
||||
#[gpui::test]
|
||||
#[ignore = "temporarily disabled until it can be run on CI"]
|
||||
#[ignore = "can't run on CI yet"]
|
||||
async fn test_echo(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await;
|
||||
|
||||
@@ -44,7 +48,7 @@ async fn test_echo(cx: &mut TestAppContext) {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[ignore = "temporarily disabled until it can be run on CI"]
|
||||
#[ignore = "can't run on CI yet"]
|
||||
async fn test_thinking(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4Thinking).await;
|
||||
|
||||
@@ -77,7 +81,46 @@ async fn test_thinking(cx: &mut TestAppContext) {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[ignore = "temporarily disabled until it can be run on CI"]
|
||||
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.borrow_mut().shell = "test-shell".into();
|
||||
thread.update(cx, |thread, _| thread.add_tool(EchoTool));
|
||||
thread.update(cx, |thread, cx| thread.send(model.clone(), "abc", cx));
|
||||
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]
|
||||
#[ignore = "can't run on CI yet"]
|
||||
async fn test_basic_tool_calls(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await;
|
||||
|
||||
@@ -127,7 +170,7 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[ignore = "temporarily disabled until it can be run on CI"]
|
||||
#[ignore = "can't run on CI yet"]
|
||||
async fn test_streaming_tool_calls(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await;
|
||||
|
||||
@@ -175,7 +218,161 @@ async fn test_streaming_tool_calls(cx: &mut TestAppContext) {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[ignore = "temporarily disabled until it can be run on CI"]
|
||||
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(model.clone(), "abc", cx)
|
||||
});
|
||||
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![
|
||||
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: None
|
||||
}),
|
||||
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
|
||||
})
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[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(model.clone(), "abc", cx));
|
||||
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(&mut events).await;
|
||||
assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Failed));
|
||||
}
|
||||
|
||||
async fn expect_tool_call(
|
||||
events: &mut UnboundedReceiver<Result<AgentResponseEvent, LanguageModelCompletionError>>,
|
||||
) -> acp::ToolCall {
|
||||
let event = events
|
||||
.next()
|
||||
.await
|
||||
.expect("no tool call authorization event received")
|
||||
.unwrap();
|
||||
match event {
|
||||
AgentResponseEvent::ToolCall(tool_call) => return tool_call,
|
||||
event => {
|
||||
panic!("Unexpected event {event:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn expect_tool_call_update(
|
||||
events: &mut UnboundedReceiver<Result<AgentResponseEvent, LanguageModelCompletionError>>,
|
||||
) -> acp::ToolCallUpdate {
|
||||
let event = events
|
||||
.next()
|
||||
.await
|
||||
.expect("no tool call authorization event received")
|
||||
.unwrap();
|
||||
match event {
|
||||
AgentResponseEvent::ToolCallUpdate(tool_call_update) => return tool_call_update,
|
||||
event => {
|
||||
panic!("Unexpected event {event:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_tool_call_authorization(
|
||||
events: &mut UnboundedReceiver<Result<AgentResponseEvent, LanguageModelCompletionError>>,
|
||||
) -> ToolCallAuthorization {
|
||||
loop {
|
||||
let event = events
|
||||
.next()
|
||||
.await
|
||||
.expect("no tool call authorization event received")
|
||||
.unwrap();
|
||||
if let AgentResponseEvent::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]
|
||||
#[ignore = "can't run on CI yet"]
|
||||
async fn test_concurrent_tool_calls(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await;
|
||||
|
||||
@@ -214,7 +411,7 @@ async fn test_concurrent_tool_calls(cx: &mut TestAppContext) {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[ignore = "temporarily disabled until it can be run on CI"]
|
||||
#[ignore = "can't run on CI yet"]
|
||||
async fn test_cancellation(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await;
|
||||
|
||||
@@ -281,12 +478,10 @@ async fn test_cancellation(cx: &mut TestAppContext) {
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_refusal(cx: &mut TestAppContext) {
|
||||
let fake_model = Arc::new(FakeLanguageModel::default());
|
||||
let ThreadTest { thread, .. } = setup(cx, TestModel::Fake(fake_model.clone())).await;
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
|
||||
let fake_model = model.as_fake();
|
||||
|
||||
let events = thread.update(cx, |thread, cx| {
|
||||
thread.send(fake_model.clone(), "Hello", cx)
|
||||
});
|
||||
let events = thread.update(cx, |thread, cx| thread.send(model.clone(), "Hello", cx));
|
||||
cx.run_until_parked();
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(
|
||||
@@ -343,8 +538,16 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
|
||||
});
|
||||
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, [Path::new("/test")], cx).await;
|
||||
let cwd = Path::new("/test");
|
||||
|
||||
// Create agent and connection
|
||||
let agent = cx.new(|_| NativeAgent::new(templates.clone()));
|
||||
let agent = NativeAgent::new(project.clone(), templates.clone(), None, &mut cx.to_async())
|
||||
.await
|
||||
.unwrap();
|
||||
let connection = NativeAgentConnection(agent.clone());
|
||||
|
||||
// Test model_selector returns Some
|
||||
@@ -366,12 +569,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
|
||||
assert!(!listed_models.is_empty(), "should have at least one model");
|
||||
assert_eq!(listed_models[0].id().0, "fake");
|
||||
|
||||
// Create a project for new_thread
|
||||
let fake_fs = cx.update(|cx| fs::FakeFs::new(cx.background_executor().clone()));
|
||||
let project = Project::test(fake_fs, [Path::new("/test")], cx).await;
|
||||
|
||||
// Create a thread using new_thread
|
||||
let cwd = Path::new("/test");
|
||||
let connection_rc = Rc::new(connection.clone());
|
||||
let acp_thread = cx
|
||||
.update(|cx| {
|
||||
@@ -441,6 +639,77 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
|
||||
);
|
||||
}
|
||||
|
||||
#[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(model.clone(), "Think", cx));
|
||||
cx.run_until_parked();
|
||||
|
||||
let input = json!({ "content": "Thinking hard!" });
|
||||
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: 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!({ "content": "Thinking hard!" })),
|
||||
raw_output: None,
|
||||
}
|
||||
);
|
||||
let update = expect_tool_call_update(&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(&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(&mut events).await;
|
||||
assert_eq!(
|
||||
update,
|
||||
acp::ToolCallUpdate {
|
||||
id: acp::ToolCallId("1".into()),
|
||||
fields: acp::ToolCallUpdateFields {
|
||||
status: Some(acp::ToolCallStatus::Completed),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Filters out the stop events for asserting against in tests
|
||||
fn stop_events(
|
||||
result_events: Vec<Result<AgentResponseEvent, LanguageModelCompletionError>>,
|
||||
@@ -457,12 +726,13 @@ fn stop_events(
|
||||
struct ThreadTest {
|
||||
model: Arc<dyn LanguageModel>,
|
||||
thread: Entity<Thread>,
|
||||
project_context: Rc<RefCell<ProjectContext>>,
|
||||
}
|
||||
|
||||
enum TestModel {
|
||||
Sonnet4,
|
||||
Sonnet4Thinking,
|
||||
Fake(Arc<FakeLanguageModel>),
|
||||
Fake,
|
||||
}
|
||||
|
||||
impl TestModel {
|
||||
@@ -470,7 +740,7 @@ impl TestModel {
|
||||
match self {
|
||||
TestModel::Sonnet4 => LanguageModelId("claude-sonnet-4-latest".into()),
|
||||
TestModel::Sonnet4Thinking => LanguageModelId("claude-sonnet-4-thinking-latest".into()),
|
||||
TestModel::Fake(fake_model) => fake_model.id(),
|
||||
TestModel::Fake => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,8 +769,8 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
|
||||
language_model::init(client.clone(), cx);
|
||||
language_models::init(user_store.clone(), client.clone(), cx);
|
||||
|
||||
if let TestModel::Fake(model) = model {
|
||||
Task::ready(model as Arc<_>)
|
||||
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);
|
||||
@@ -520,9 +790,22 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
|
||||
})
|
||||
.await;
|
||||
|
||||
let thread = cx.new(|_| Thread::new(project, templates, model.clone()));
|
||||
|
||||
ThreadTest { model, thread }
|
||||
let project_context = Rc::new(RefCell::new(ProjectContext::default()));
|
||||
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
||||
let thread = cx.new(|_| {
|
||||
Thread::new(
|
||||
project,
|
||||
project_context.clone(),
|
||||
action_log,
|
||||
templates,
|
||||
model.clone(),
|
||||
)
|
||||
});
|
||||
ThreadTest {
|
||||
model,
|
||||
thread,
|
||||
project_context,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -19,7 +19,20 @@ impl AgentTool for EchoTool {
|
||||
"echo".into()
|
||||
}
|
||||
|
||||
fn run(self: Arc<Self>, input: Self::Input, _cx: &mut App) -> Task<Result<String>> {
|
||||
fn kind(&self) -> acp::ToolKind {
|
||||
acp::ToolKind::Other
|
||||
}
|
||||
|
||||
fn initial_title(&self, _: Self::Input) -> 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))
|
||||
}
|
||||
}
|
||||
@@ -40,7 +53,20 @@ impl AgentTool for DelayTool {
|
||||
"delay".into()
|
||||
}
|
||||
|
||||
fn run(self: Arc<Self>, input: Self::Input, cx: &mut App) -> Task<Result<String>>
|
||||
fn initial_title(&self, input: Self::Input) -> SharedString {
|
||||
format!("Delay {}ms", input.ms).into()
|
||||
}
|
||||
|
||||
fn kind(&self) -> 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,
|
||||
{
|
||||
@@ -51,6 +77,43 @@ impl AgentTool for DelayTool {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(JsonSchema, Serialize, Deserialize)]
|
||||
pub struct ToolRequiringPermissionInput {}
|
||||
|
||||
pub struct ToolRequiringPermission;
|
||||
|
||||
impl AgentTool for ToolRequiringPermission {
|
||||
type Input = ToolRequiringPermissionInput;
|
||||
|
||||
fn name(&self) -> SharedString {
|
||||
"tool_requiring_permission".into()
|
||||
}
|
||||
|
||||
fn kind(&self) -> acp::ToolKind {
|
||||
acp::ToolKind::Other
|
||||
}
|
||||
|
||||
fn initial_title(&self, _input: Self::Input) -> SharedString {
|
||||
"This tool requires permission".into()
|
||||
}
|
||||
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
input: Self::Input,
|
||||
event_stream: ToolCallEventStream,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<String>>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let auth_check = self.authorize(input, event_stream);
|
||||
cx.foreground_executor().spawn(async move {
|
||||
auth_check.await?;
|
||||
Ok("Allowed".to_string())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(JsonSchema, Serialize, Deserialize)]
|
||||
pub struct InfiniteToolInput {}
|
||||
|
||||
@@ -63,7 +126,20 @@ impl AgentTool for InfiniteTool {
|
||||
"infinite".into()
|
||||
}
|
||||
|
||||
fn run(self: Arc<Self>, _input: Self::Input, cx: &mut App) -> Task<Result<String>> {
|
||||
fn kind(&self) -> acp::ToolKind {
|
||||
acp::ToolKind::Other
|
||||
}
|
||||
|
||||
fn initial_title(&self, _input: Self::Input) -> SharedString {
|
||||
"This is the tool that never ends... it just goes on and on my friends!".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!()
|
||||
@@ -100,7 +176,20 @@ impl AgentTool for WordListTool {
|
||||
"word_list".into()
|
||||
}
|
||||
|
||||
fn run(self: Arc<Self>, _input: Self::Input, _cx: &mut App) -> Task<Result<String>> {
|
||||
fn initial_title(&self, _input: Self::Input) -> SharedString {
|
||||
"List of random words".into()
|
||||
}
|
||||
|
||||
fn kind(&self) -> acp::ToolKind {
|
||||
acp::ToolKind::Other
|
||||
}
|
||||
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
_input: Self::Input,
|
||||
_event_stream: ToolCallEventStream,
|
||||
_cx: &mut App,
|
||||
) -> Task<Result<String>> {
|
||||
Task::ready(Ok("ok".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
use crate::{prompts::BasePrompt, templates::Templates};
|
||||
use crate::templates::{SystemPromptTemplate, Template, Templates};
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use assistant_tool::{adapt_schema_to_format, ActionLog};
|
||||
use cloud_llm_client::{CompletionIntent, CompletionMode};
|
||||
use collections::HashMap;
|
||||
use futures::{channel::mpsc, stream::FuturesUnordered};
|
||||
use gpui::{App, Context, Entity, ImageFormat, SharedString, Task};
|
||||
use futures::{
|
||||
channel::{mpsc, oneshot},
|
||||
stream::FuturesUnordered,
|
||||
};
|
||||
use gpui::{App, Context, Entity, SharedString, Task};
|
||||
use language_model::{
|
||||
LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelImage,
|
||||
LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
|
||||
LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool,
|
||||
LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolSchemaFormat,
|
||||
LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, StopReason,
|
||||
};
|
||||
use log;
|
||||
use project::Project;
|
||||
use prompt_store::ProjectContext;
|
||||
use schemars::{JsonSchema, Schema};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smol::stream::StreamExt;
|
||||
use std::{collections::BTreeMap, fmt::Write, sync::Arc};
|
||||
use std::{cell::RefCell, collections::BTreeMap, fmt::Write, future::Future, rc::Rc, sync::Arc};
|
||||
use util::{markdown::MarkdownCodeBlock, ResultExt};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -97,11 +102,15 @@ pub enum AgentResponseEvent {
|
||||
Thinking(String),
|
||||
ToolCall(acp::ToolCall),
|
||||
ToolCallUpdate(acp::ToolCallUpdate),
|
||||
ToolCallAuthorization(ToolCallAuthorization),
|
||||
Stop(acp::StopReason),
|
||||
}
|
||||
|
||||
pub trait Prompt {
|
||||
fn render(&self, prompts: &Templates, cx: &App) -> Result<String>;
|
||||
#[derive(Debug)]
|
||||
pub struct ToolCallAuthorization {
|
||||
pub tool_call: acp::ToolCall,
|
||||
pub options: Vec<acp::PermissionOption>,
|
||||
pub response: oneshot::Sender<acp::PermissionOptionId>,
|
||||
}
|
||||
|
||||
pub struct Thread {
|
||||
@@ -112,28 +121,31 @@ pub struct Thread {
|
||||
/// we run tools, report their results.
|
||||
running_turn: Option<Task<()>>,
|
||||
pending_tool_uses: HashMap<LanguageModelToolUseId, LanguageModelToolUse>,
|
||||
system_prompts: Vec<Arc<dyn Prompt>>,
|
||||
tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
|
||||
project_context: Rc<RefCell<ProjectContext>>,
|
||||
templates: Arc<Templates>,
|
||||
pub selected_model: Arc<dyn LanguageModel>,
|
||||
// action_log: Entity<ActionLog>,
|
||||
action_log: Entity<ActionLog>,
|
||||
}
|
||||
|
||||
impl Thread {
|
||||
pub fn new(
|
||||
project: Entity<Project>,
|
||||
_project: Entity<Project>,
|
||||
project_context: Rc<RefCell<ProjectContext>>,
|
||||
action_log: Entity<ActionLog>,
|
||||
templates: Arc<Templates>,
|
||||
default_model: Arc<dyn LanguageModel>,
|
||||
) -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
completion_mode: CompletionMode::Normal,
|
||||
system_prompts: vec![Arc::new(BasePrompt::new(project))],
|
||||
running_turn: None,
|
||||
pending_tool_uses: HashMap::default(),
|
||||
tools: BTreeMap::default(),
|
||||
project_context,
|
||||
templates,
|
||||
selected_model: default_model,
|
||||
action_log,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +200,7 @@ impl Thread {
|
||||
cx.notify();
|
||||
let (events_tx, events_rx) =
|
||||
mpsc::unbounded::<Result<AgentResponseEvent, LanguageModelCompletionError>>();
|
||||
let event_stream = AgentResponseEventStream(events_tx);
|
||||
|
||||
let user_message_ix = self.messages.len();
|
||||
self.messages.push(AgentMessage {
|
||||
@@ -222,12 +235,7 @@ impl Thread {
|
||||
while let Some(event) = events.next().await {
|
||||
match event {
|
||||
Ok(LanguageModelCompletionEvent::Stop(reason)) => {
|
||||
if let Some(reason) = to_acp_stop_reason(reason) {
|
||||
events_tx
|
||||
.unbounded_send(Ok(AgentResponseEvent::Stop(reason)))
|
||||
.ok();
|
||||
}
|
||||
|
||||
event_stream.send_stop(reason);
|
||||
if reason == StopReason::Refusal {
|
||||
thread.update(cx, |thread, _cx| {
|
||||
thread.messages.truncate(user_message_ix);
|
||||
@@ -240,14 +248,16 @@ impl Thread {
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
tool_uses.extend(thread.handle_streamed_completion_event(
|
||||
event, &events_tx, cx,
|
||||
event,
|
||||
&event_stream,
|
||||
cx,
|
||||
));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("Error in completion stream: {:?}", error);
|
||||
events_tx.unbounded_send(Err(error)).ok();
|
||||
event_stream.send_error(error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -266,11 +276,17 @@ impl Thread {
|
||||
while let Some(tool_result) = tool_uses.next().await {
|
||||
log::info!("Tool finished {:?}", tool_result);
|
||||
|
||||
events_tx
|
||||
.unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate(
|
||||
to_acp_tool_call_update(&tool_result),
|
||||
)))
|
||||
.ok();
|
||||
event_stream.send_tool_call_update(
|
||||
&tool_result.tool_use_id,
|
||||
acp::ToolCallUpdateFields {
|
||||
status: Some(if tool_result.is_error {
|
||||
acp::ToolCallStatus::Failed
|
||||
} else {
|
||||
acp::ToolCallStatus::Completed
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
thread
|
||||
.update(cx, |thread, _cx| {
|
||||
thread.pending_tool_uses.remove(&tool_result.tool_use_id);
|
||||
@@ -291,7 +307,7 @@ impl Thread {
|
||||
|
||||
if let Err(error) = turn_result {
|
||||
log::error!("Turn execution failed: {:?}", error);
|
||||
events_tx.unbounded_send(Err(error)).ok();
|
||||
event_stream.send_error(error);
|
||||
} else {
|
||||
log::info!("Turn execution completed successfully");
|
||||
}
|
||||
@@ -299,24 +315,24 @@ impl Thread {
|
||||
events_rx
|
||||
}
|
||||
|
||||
pub fn build_system_message(&self, cx: &App) -> Option<AgentMessage> {
|
||||
pub fn action_log(&self) -> &Entity<ActionLog> {
|
||||
&self.action_log
|
||||
}
|
||||
|
||||
pub fn build_system_message(&self) -> AgentMessage {
|
||||
log::debug!("Building system message");
|
||||
let mut system_message = AgentMessage {
|
||||
role: Role::System,
|
||||
content: Vec::new(),
|
||||
};
|
||||
|
||||
for prompt in &self.system_prompts {
|
||||
if let Some(rendered_prompt) = prompt.render(&self.templates, cx).log_err() {
|
||||
system_message
|
||||
.content
|
||||
.push(MessageContent::Text(rendered_prompt));
|
||||
}
|
||||
let prompt = SystemPromptTemplate {
|
||||
project: &self.project_context.borrow(),
|
||||
available_tools: self.tools.keys().cloned().collect(),
|
||||
}
|
||||
.render(&self.templates)
|
||||
.context("failed to build system prompt")
|
||||
.expect("Invalid template");
|
||||
log::debug!("System message built");
|
||||
AgentMessage {
|
||||
role: Role::System,
|
||||
content: vec![prompt.into()],
|
||||
}
|
||||
|
||||
let result = (!system_message.content.is_empty()).then_some(system_message);
|
||||
log::debug!("System message built: {}", result.is_some());
|
||||
result
|
||||
}
|
||||
|
||||
/// A helper method that's called on every streamed completion event.
|
||||
@@ -325,7 +341,7 @@ impl Thread {
|
||||
fn handle_streamed_completion_event(
|
||||
&mut self,
|
||||
event: LanguageModelCompletionEvent,
|
||||
events_tx: &mpsc::UnboundedSender<Result<AgentResponseEvent, LanguageModelCompletionError>>,
|
||||
event_stream: &AgentResponseEventStream,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<Task<LanguageModelToolResult>> {
|
||||
log::trace!("Handling streamed completion event: {:?}", event);
|
||||
@@ -338,13 +354,13 @@ impl Thread {
|
||||
content: Vec::new(),
|
||||
});
|
||||
}
|
||||
Text(new_text) => self.handle_text_event(new_text, events_tx, cx),
|
||||
Text(new_text) => self.handle_text_event(new_text, event_stream, cx),
|
||||
Thinking { text, signature } => {
|
||||
self.handle_thinking_event(text, signature, events_tx, cx)
|
||||
self.handle_thinking_event(text, signature, event_stream, cx)
|
||||
}
|
||||
RedactedThinking { data } => self.handle_redacted_thinking_event(data, cx),
|
||||
ToolUse(tool_use) => {
|
||||
return self.handle_tool_use_event(tool_use, events_tx, cx);
|
||||
return self.handle_tool_use_event(tool_use, event_stream, cx);
|
||||
}
|
||||
ToolUseJsonParseError {
|
||||
id,
|
||||
@@ -369,12 +385,10 @@ impl Thread {
|
||||
fn handle_text_event(
|
||||
&mut self,
|
||||
new_text: String,
|
||||
events_tx: &mpsc::UnboundedSender<Result<AgentResponseEvent, LanguageModelCompletionError>>,
|
||||
events_stream: &AgentResponseEventStream,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
events_tx
|
||||
.unbounded_send(Ok(AgentResponseEvent::Text(new_text.clone())))
|
||||
.ok();
|
||||
events_stream.send_text(&new_text);
|
||||
|
||||
let last_message = self.last_assistant_message();
|
||||
if let Some(MessageContent::Text(text)) = last_message.content.last_mut() {
|
||||
@@ -390,12 +404,10 @@ impl Thread {
|
||||
&mut self,
|
||||
new_text: String,
|
||||
new_signature: Option<String>,
|
||||
events_tx: &mpsc::UnboundedSender<Result<AgentResponseEvent, LanguageModelCompletionError>>,
|
||||
event_stream: &AgentResponseEventStream,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
events_tx
|
||||
.unbounded_send(Ok(AgentResponseEvent::Thinking(new_text.clone())))
|
||||
.ok();
|
||||
event_stream.send_thinking(&new_text);
|
||||
|
||||
let last_message = self.last_assistant_message();
|
||||
if let Some(MessageContent::Thinking { text, signature }) = last_message.content.last_mut()
|
||||
@@ -423,11 +435,13 @@ impl Thread {
|
||||
fn handle_tool_use_event(
|
||||
&mut self,
|
||||
tool_use: LanguageModelToolUse,
|
||||
events_tx: &mpsc::UnboundedSender<Result<AgentResponseEvent, LanguageModelCompletionError>>,
|
||||
event_stream: &AgentResponseEventStream,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<Task<LanguageModelToolResult>> {
|
||||
cx.notify();
|
||||
|
||||
let tool = self.tools.get(tool_use.name.as_ref()).cloned();
|
||||
|
||||
self.pending_tool_uses
|
||||
.insert(tool_use.id.clone(), tool_use.clone());
|
||||
let last_message = self.last_assistant_message();
|
||||
@@ -445,82 +459,74 @@ impl Thread {
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
if push_new_tool_use {
|
||||
events_tx
|
||||
.unbounded_send(Ok(AgentResponseEvent::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::Pending,
|
||||
content: vec![],
|
||||
locations: vec![],
|
||||
raw_input: Some(tool_use.input.clone()),
|
||||
})))
|
||||
.ok();
|
||||
event_stream.send_tool_call(tool.as_ref(), &tool_use);
|
||||
last_message
|
||||
.content
|
||||
.push(MessageContent::ToolUse(tool_use.clone()));
|
||||
} else {
|
||||
events_tx
|
||||
.unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate(
|
||||
acp::ToolCallUpdate {
|
||||
id: acp::ToolCallId(tool_use.id.to_string().into()),
|
||||
fields: acp::ToolCallUpdateFields {
|
||||
raw_input: Some(tool_use.input.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
)))
|
||||
.ok();
|
||||
event_stream.send_tool_call_update(
|
||||
&tool_use.id,
|
||||
acp::ToolCallUpdateFields {
|
||||
raw_input: Some(tool_use.input.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if !tool_use.is_input_complete {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(tool) = self.tools.get(tool_use.name.as_ref()) {
|
||||
events_tx
|
||||
.unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate(
|
||||
acp::ToolCallUpdate {
|
||||
id: acp::ToolCallId(tool_use.id.to_string().into()),
|
||||
fields: acp::ToolCallUpdateFields {
|
||||
status: Some(acp::ToolCallStatus::InProgress),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
)))
|
||||
.ok();
|
||||
|
||||
let pending_tool_result = tool.clone().run(tool_use.input, cx);
|
||||
|
||||
Some(cx.foreground_executor().spawn(async move {
|
||||
match pending_tool_result.await {
|
||||
Ok(tool_output) => LanguageModelToolResult {
|
||||
tool_use_id: tool_use.id,
|
||||
tool_name: tool_use.name,
|
||||
is_error: false,
|
||||
content: LanguageModelToolResultContent::Text(Arc::from(tool_output)),
|
||||
output: None,
|
||||
},
|
||||
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,
|
||||
},
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
let Some(tool) = tool else {
|
||||
let content = format!("No tool named {} exists", tool_use.name);
|
||||
Some(Task::ready(LanguageModelToolResult {
|
||||
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 tool_result = self.run_tool(tool, tool_use.clone(), event_stream.clone(), cx);
|
||||
Some(cx.foreground_executor().spawn(async move {
|
||||
match tool_result.await {
|
||||
Ok(tool_output) => LanguageModelToolResult {
|
||||
tool_use_id: tool_use.id,
|
||||
tool_name: tool_use.name,
|
||||
is_error: false,
|
||||
content: LanguageModelToolResultContent::Text(Arc::from(tool_output)),
|
||||
output: None,
|
||||
},
|
||||
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 run_tool(
|
||||
&self,
|
||||
tool: Arc<dyn AnyAgentTool>,
|
||||
tool_use: LanguageModelToolUse,
|
||||
event_stream: AgentResponseEventStream,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<String>> {
|
||||
cx.spawn(async move |_this, cx| {
|
||||
let tool_event_stream = ToolCallEventStream::new(tool_use.id, event_stream);
|
||||
tool_event_stream.send_update(acp::ToolCallUpdateFields {
|
||||
status: Some(acp::ToolCallStatus::InProgress),
|
||||
..Default::default()
|
||||
});
|
||||
cx.update(|cx| tool.run(tool_use.input, tool_event_stream, cx))?
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_tool_use_json_parse_error_event(
|
||||
@@ -575,7 +581,7 @@ impl Thread {
|
||||
log::debug!("Completion intent: {:?}", completion_intent);
|
||||
log::debug!("Completion mode: {:?}", self.completion_mode);
|
||||
|
||||
let messages = self.build_request_messages(cx);
|
||||
let messages = self.build_request_messages();
|
||||
log::info!("Request will include {} messages", messages.len());
|
||||
|
||||
let tools: Vec<LanguageModelRequestTool> = self
|
||||
@@ -588,7 +594,7 @@ impl Thread {
|
||||
name: tool_name,
|
||||
description: tool.description(cx).to_string(),
|
||||
input_schema: tool
|
||||
.input_schema(LanguageModelToolSchemaFormat::JsonSchema)
|
||||
.input_schema(self.selected_model.tool_input_format())
|
||||
.log_err()?,
|
||||
})
|
||||
})
|
||||
@@ -613,14 +619,13 @@ impl Thread {
|
||||
request
|
||||
}
|
||||
|
||||
fn build_request_messages(&self, cx: &App) -> Vec<LanguageModelRequestMessage> {
|
||||
fn build_request_messages(&self) -> Vec<LanguageModelRequestMessage> {
|
||||
log::trace!(
|
||||
"Building request messages from {} thread messages",
|
||||
self.messages.len()
|
||||
);
|
||||
|
||||
let messages = self
|
||||
.build_system_message(cx)
|
||||
let messages = Some(self.build_system_message())
|
||||
.iter()
|
||||
.chain(self.messages.iter())
|
||||
.map(|message| {
|
||||
@@ -656,9 +661,10 @@ pub trait AgentTool
|
||||
where
|
||||
Self: 'static + Sized,
|
||||
{
|
||||
type Input: for<'de> Deserialize<'de> + JsonSchema;
|
||||
type Input: for<'de> Deserialize<'de> + Serialize + JsonSchema;
|
||||
|
||||
fn name(&self) -> SharedString;
|
||||
|
||||
fn description(&self, _cx: &mut App) -> SharedString {
|
||||
let schema = schemars::schema_for!(Self::Input);
|
||||
SharedString::new(
|
||||
@@ -669,13 +675,33 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
fn kind(&self) -> acp::ToolKind;
|
||||
|
||||
/// The initial tool title to display. Can be updated during the tool run.
|
||||
fn initial_title(&self, input: Self::Input) -> SharedString;
|
||||
|
||||
/// Returns the JSON schema that describes the tool's input.
|
||||
fn input_schema(&self, _format: LanguageModelToolSchemaFormat) -> Schema {
|
||||
fn input_schema(&self) -> Schema {
|
||||
schemars::schema_for!(Self::Input)
|
||||
}
|
||||
|
||||
/// Allows the tool to authorize a given tool call with the user if necessary
|
||||
fn authorize(
|
||||
&self,
|
||||
input: Self::Input,
|
||||
event_stream: ToolCallEventStream,
|
||||
) -> impl use<Self> + Future<Output = Result<()>> {
|
||||
let json_input = serde_json::json!(&input);
|
||||
event_stream.authorize(self.initial_title(input).into(), self.kind(), json_input)
|
||||
}
|
||||
|
||||
/// Runs the tool with the provided input.
|
||||
fn run(self: Arc<Self>, input: Self::Input, cx: &mut App) -> Task<Result<String>>;
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
input: Self::Input,
|
||||
event_stream: ToolCallEventStream,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<String>>;
|
||||
|
||||
fn erase(self) -> Arc<dyn AnyAgentTool> {
|
||||
Arc::new(Erased(Arc::new(self)))
|
||||
@@ -687,8 +713,15 @@ pub struct Erased<T>(T);
|
||||
pub trait AnyAgentTool {
|
||||
fn name(&self) -> SharedString;
|
||||
fn description(&self, cx: &mut App) -> SharedString;
|
||||
fn kind(&self) -> acp::ToolKind;
|
||||
fn initial_title(&self, input: serde_json::Value) -> Result<SharedString>;
|
||||
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value>;
|
||||
fn run(self: Arc<Self>, input: serde_json::Value, cx: &mut App) -> Task<Result<String>>;
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
input: serde_json::Value,
|
||||
event_stream: ToolCallEventStream,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<String>>;
|
||||
}
|
||||
|
||||
impl<T> AnyAgentTool for Erased<Arc<T>>
|
||||
@@ -703,52 +736,220 @@ where
|
||||
self.0.description(cx)
|
||||
}
|
||||
|
||||
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
|
||||
Ok(serde_json::to_value(self.0.input_schema(format))?)
|
||||
fn kind(&self) -> agent_client_protocol::ToolKind {
|
||||
self.0.kind()
|
||||
}
|
||||
|
||||
fn run(self: Arc<Self>, input: serde_json::Value, cx: &mut App) -> Task<Result<String>> {
|
||||
fn initial_title(&self, input: serde_json::Value) -> Result<SharedString> {
|
||||
let parsed_input = serde_json::from_value(input)?;
|
||||
Ok(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())?;
|
||||
adapt_schema_to_format(&mut json, format)?;
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
input: serde_json::Value,
|
||||
event_stream: ToolCallEventStream,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<String>> {
|
||||
let parsed_input: Result<T::Input> = serde_json::from_value(input).map_err(Into::into);
|
||||
match parsed_input {
|
||||
Ok(input) => self.0.clone().run(input, cx),
|
||||
Ok(input) => self.0.clone().run(input, event_stream, cx),
|
||||
Err(error) => Task::ready(Err(anyhow!(error))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_acp_stop_reason(reason: StopReason) -> Option<acp::StopReason> {
|
||||
match reason {
|
||||
StopReason::EndTurn => Some(acp::StopReason::EndTurn),
|
||||
StopReason::MaxTokens => Some(acp::StopReason::MaxTokens),
|
||||
StopReason::Refusal => Some(acp::StopReason::Refusal),
|
||||
StopReason::ToolUse => None,
|
||||
#[derive(Clone)]
|
||||
struct AgentResponseEventStream(
|
||||
mpsc::UnboundedSender<Result<AgentResponseEvent, LanguageModelCompletionError>>,
|
||||
);
|
||||
|
||||
impl AgentResponseEventStream {
|
||||
fn send_text(&self, text: &str) {
|
||||
self.0
|
||||
.unbounded_send(Ok(AgentResponseEvent::Text(text.to_string())))
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn send_thinking(&self, text: &str) {
|
||||
self.0
|
||||
.unbounded_send(Ok(AgentResponseEvent::Thinking(text.to_string())))
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn authorize_tool_call(
|
||||
&self,
|
||||
id: &LanguageModelToolUseId,
|
||||
title: String,
|
||||
kind: acp::ToolKind,
|
||||
input: serde_json::Value,
|
||||
) -> impl use<> + Future<Output = Result<()>> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.0
|
||||
.unbounded_send(Ok(AgentResponseEvent::ToolCallAuthorization(
|
||||
ToolCallAuthorization {
|
||||
tool_call: Self::initial_tool_call(id, title, kind, input),
|
||||
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();
|
||||
async move {
|
||||
match response_rx.await?.0.as_ref() {
|
||||
"allow" | "always_allow" => Ok(()),
|
||||
_ => Err(anyhow!("Permission to run tool denied by user")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_tool_call(
|
||||
&self,
|
||||
tool: Option<&Arc<dyn AnyAgentTool>>,
|
||||
tool_use: &LanguageModelToolUse,
|
||||
) {
|
||||
self.0
|
||||
.unbounded_send(Ok(AgentResponseEvent::ToolCall(Self::initial_tool_call(
|
||||
&tool_use.id,
|
||||
tool.and_then(|t| t.initial_title(tool_use.input.clone()).ok())
|
||||
.map(|i| i.into())
|
||||
.unwrap_or_else(|| tool_use.name.to_string()),
|
||||
tool.map(|t| t.kind()).unwrap_or(acp::ToolKind::Other),
|
||||
tool_use.input.clone(),
|
||||
))))
|
||||
.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 send_tool_call_update(
|
||||
&self,
|
||||
tool_use_id: &LanguageModelToolUseId,
|
||||
fields: acp::ToolCallUpdateFields,
|
||||
) {
|
||||
self.0
|
||||
.unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate(
|
||||
acp::ToolCallUpdate {
|
||||
id: acp::ToolCallId(tool_use_id.to_string().into()),
|
||||
fields,
|
||||
},
|
||||
)))
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn send_stop(&self, reason: StopReason) {
|
||||
match reason {
|
||||
StopReason::EndTurn => {
|
||||
self.0
|
||||
.unbounded_send(Ok(AgentResponseEvent::Stop(acp::StopReason::EndTurn)))
|
||||
.ok();
|
||||
}
|
||||
StopReason::MaxTokens => {
|
||||
self.0
|
||||
.unbounded_send(Ok(AgentResponseEvent::Stop(acp::StopReason::MaxTokens)))
|
||||
.ok();
|
||||
}
|
||||
StopReason::Refusal => {
|
||||
self.0
|
||||
.unbounded_send(Ok(AgentResponseEvent::Stop(acp::StopReason::Refusal)))
|
||||
.ok();
|
||||
}
|
||||
StopReason::ToolUse => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_error(&self, error: LanguageModelCompletionError) {
|
||||
self.0.unbounded_send(Err(error)).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn to_acp_tool_call_update(tool_result: &LanguageModelToolResult) -> acp::ToolCallUpdate {
|
||||
let status = if tool_result.is_error {
|
||||
acp::ToolCallStatus::Failed
|
||||
} else {
|
||||
acp::ToolCallStatus::Completed
|
||||
};
|
||||
let content = match &tool_result.content {
|
||||
LanguageModelToolResultContent::Text(text) => text.to_string().into(),
|
||||
LanguageModelToolResultContent::Image(LanguageModelImage { source, .. }) => {
|
||||
acp::ToolCallContent::Content {
|
||||
content: acp::ContentBlock::Image(acp::ImageContent {
|
||||
annotations: None,
|
||||
data: source.to_string(),
|
||||
mime_type: ImageFormat::Png.mime_type().to_string(),
|
||||
}),
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct ToolCallEventStream {
|
||||
tool_use_id: LanguageModelToolUseId,
|
||||
stream: AgentResponseEventStream,
|
||||
}
|
||||
|
||||
impl ToolCallEventStream {
|
||||
fn new(tool_use_id: LanguageModelToolUseId, stream: AgentResponseEventStream) -> Self {
|
||||
Self {
|
||||
tool_use_id,
|
||||
stream,
|
||||
}
|
||||
};
|
||||
acp::ToolCallUpdate {
|
||||
id: acp::ToolCallId(tool_result.tool_use_id.to_string().into()),
|
||||
fields: acp::ToolCallUpdateFields {
|
||||
status: Some(status),
|
||||
content: Some(vec![content]),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
|
||||
pub fn send_update(&self, fields: acp::ToolCallUpdateFields) {
|
||||
self.stream.send_tool_call_update(&self.tool_use_id, fields);
|
||||
}
|
||||
|
||||
pub fn authorize(
|
||||
&self,
|
||||
title: String,
|
||||
kind: acp::ToolKind,
|
||||
input: serde_json::Value,
|
||||
) -> impl use<> + Future<Output = Result<()>> {
|
||||
self.stream
|
||||
.authorize_tool_call(&self.tool_use_id, title, kind, input)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub struct TestToolCallEventStream {
|
||||
stream: ToolCallEventStream,
|
||||
_events_rx: mpsc::UnboundedReceiver<Result<AgentResponseEvent, LanguageModelCompletionError>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl TestToolCallEventStream {
|
||||
pub fn new() -> Self {
|
||||
let (events_tx, events_rx) =
|
||||
mpsc::unbounded::<Result<AgentResponseEvent, LanguageModelCompletionError>>();
|
||||
|
||||
let stream = ToolCallEventStream::new("test".into(), AgentResponseEventStream(events_tx));
|
||||
|
||||
Self {
|
||||
stream,
|
||||
_events_rx: events_rx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stream(&self) -> ToolCallEventStream {
|
||||
self.stream.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
mod glob;
|
||||
mod find_path_tool;
|
||||
mod read_file_tool;
|
||||
mod thinking_tool;
|
||||
|
||||
pub use find_path_tool::*;
|
||||
pub use read_file_tool::*;
|
||||
pub use thinking_tool::*;
|
||||
|
||||
231
crates/agent2/src/tools/find_path_tool.rs
Normal file
231
crates/agent2/src/tools/find_path_tool.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::{anyhow, Result};
|
||||
use gpui::{App, AppContext, Entity, SharedString, Task};
|
||||
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;
|
||||
|
||||
use crate::{AgentTool, ToolCallEventStream};
|
||||
|
||||
/// 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)]
|
||||
struct FindPathToolOutput {
|
||||
paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
fn name(&self) -> SharedString {
|
||||
"find_path".into()
|
||||
}
|
||||
|
||||
fn kind(&self) -> acp::ToolKind {
|
||||
acp::ToolKind::Search
|
||||
}
|
||||
|
||||
fn initial_title(&self, input: Self::Input) -> SharedString {
|
||||
format!("Find paths matching “`{}`”", input.glob).into()
|
||||
}
|
||||
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
input: Self::Input,
|
||||
event_stream: ToolCallEventStream,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<String>> {
|
||||
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.send_update(acp::ToolCallUpdateFields {
|
||||
title: Some(if paginated_matches.len() == 0 {
|
||||
"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(),
|
||||
),
|
||||
raw_output: Some(serde_json::json!({
|
||||
"paths": &matches,
|
||||
})),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if matches.is_empty() {
|
||||
Ok("No matches found".into())
|
||||
} else {
|
||||
let mut message = format!("Found {} total matches.", matches.len());
|
||||
if matches.len() > RESULTS_PER_PAGE {
|
||||
write!(
|
||||
&mut message,
|
||||
"\nShowing results {}-{} (provide 'offset' parameter for more results):",
|
||||
input.offset + 1,
|
||||
input.offset + paginated_matches.len()
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
for mat in matches.iter().skip(input.offset).take(RESULTS_PER_PAGE) {
|
||||
write!(&mut message, "\n{}", mat.display()).unwrap();
|
||||
}
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use gpui::{App, AppContext, Entity, SharedString, Task};
|
||||
use project::Project;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use util::paths::PathMatcher;
|
||||
use worktree::Snapshot as WorktreeSnapshot;
|
||||
|
||||
use crate::{
|
||||
templates::{GlobTemplate, Template, Templates},
|
||||
thread::AgentTool,
|
||||
};
|
||||
|
||||
// Description is dynamic, see `fn description` below
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct GlobInput {
|
||||
/// A POSIX glob pattern
|
||||
glob: SharedString,
|
||||
}
|
||||
|
||||
struct GlobTool {
|
||||
project: Entity<Project>,
|
||||
templates: Arc<Templates>,
|
||||
}
|
||||
|
||||
impl AgentTool for GlobTool {
|
||||
type Input = GlobInput;
|
||||
|
||||
fn name(&self) -> SharedString {
|
||||
"glob".into()
|
||||
}
|
||||
|
||||
fn description(&self, cx: &mut App) -> SharedString {
|
||||
let project_roots = self
|
||||
.project
|
||||
.read(cx)
|
||||
.worktrees(cx)
|
||||
.map(|worktree| worktree.read(cx).root_name().into())
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n");
|
||||
|
||||
GlobTemplate { project_roots }
|
||||
.render(&self.templates)
|
||||
.expect("template failed to render")
|
||||
.into()
|
||||
}
|
||||
|
||||
fn run(self: Arc<Self>, input: Self::Input, cx: &mut App) -> Task<Result<String>> {
|
||||
let path_matcher = match PathMatcher::new([&input.glob]) {
|
||||
Ok(matcher) => matcher,
|
||||
Err(error) => return Task::ready(Err(anyhow!(error))),
|
||||
};
|
||||
|
||||
let snapshots: Vec<WorktreeSnapshot> = self
|
||||
.project
|
||||
.read(cx)
|
||||
.worktrees(cx)
|
||||
.map(|worktree| worktree.read(cx).snapshot())
|
||||
.collect();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let paths = 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))
|
||||
});
|
||||
let output = paths
|
||||
.map(|path| format!("{}\n", path.display()))
|
||||
.collect::<String>();
|
||||
Ok(output)
|
||||
})
|
||||
}
|
||||
}
|
||||
970
crates/agent2/src/tools/read_file_tool.rs
Normal file
970
crates/agent2/src/tools/read_file_tool.rs
Normal file
@@ -0,0 +1,970 @@
|
||||
use agent_client_protocol::{self as acp};
|
||||
use anyhow::{anyhow, Result};
|
||||
use assistant_tool::{outline, ActionLog};
|
||||
use gpui::{Entity, Task};
|
||||
use indoc::formatdoc;
|
||||
use language::{Anchor, Point};
|
||||
use project::{AgentLocation, Project, WorktreeSettings};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::Settings;
|
||||
use std::sync::Arc;
|
||||
use ui::{App, SharedString};
|
||||
|
||||
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;
|
||||
|
||||
fn name(&self) -> SharedString {
|
||||
"read_file".into()
|
||||
}
|
||||
|
||||
fn kind(&self) -> acp::ToolKind {
|
||||
acp::ToolKind::Read
|
||||
}
|
||||
|
||||
fn initial_title(&self, input: Self::Input) -> SharedString {
|
||||
let path = &input.path;
|
||||
match (input.start_line, input.end_line) {
|
||||
(Some(start), Some(end)) => {
|
||||
format!(
|
||||
"[Read file `{}` (lines {}-{})](@selection:{}:({}-{}))",
|
||||
path, start, end, path, start, end
|
||||
)
|
||||
}
|
||||
(Some(start), None) => {
|
||||
format!(
|
||||
"[Read file `{}` (from line {})](@selection:{}:({}-{}))",
|
||||
path, start, path, start, start
|
||||
)
|
||||
}
|
||||
_ => format!("[Read file `{}`](@file:{})", path, path),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
input: Self::Input,
|
||||
event_stream: ToolCallEventStream,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<String>> {
|
||||
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();
|
||||
|
||||
event_stream.send_update(acp::ToolCallUpdateFields {
|
||||
locations: Some(vec![acp::ToolCallLocation {
|
||||
path: project_path.path.to_path_buf(),
|
||||
line: input.start_line,
|
||||
// TODO (tracked): use full range
|
||||
}]),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// TODO (tracked): images
|
||||
// if image_store::is_image_file(&self.project, &project_path, cx) {
|
||||
// let model = &self.thread.read(cx).selected_model;
|
||||
|
||||
// if !model.supports_images() {
|
||||
// return Task::ready(Err(anyhow!(
|
||||
// "Attempted to read an image, but Zed doesn't currently support sending images to {}.",
|
||||
// model.name().0
|
||||
// )))
|
||||
// .into();
|
||||
// }
|
||||
|
||||
// return cx.spawn(async move |cx| -> Result<ToolResultOutput> {
|
||||
// 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(ToolResultOutput {
|
||||
// content: ToolResultContent::Image(language_model_image),
|
||||
// output: None,
|
||||
// })
|
||||
// });
|
||||
// }
|
||||
//
|
||||
|
||||
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, cx))
|
||||
})?
|
||||
.await?;
|
||||
if buffer.read_with(cx, |buffer, _| {
|
||||
buffer
|
||||
.file()
|
||||
.as_ref()
|
||||
.map_or(true, |file| !file.disk_state().exists())
|
||||
})? {
|
||||
anyhow::bail!("{file_path} not found");
|
||||
}
|
||||
|
||||
project.update(cx, |project, cx| {
|
||||
project.set_agent_location(
|
||||
Some(AgentLocation {
|
||||
buffer: buffer.downgrade(),
|
||||
position: Anchor::MIN,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
|
||||
// Check if specific line ranges are provided
|
||||
if input.start_line.is_some() || input.end_line.is_some() {
|
||||
let mut anchor = None;
|
||||
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);
|
||||
})?;
|
||||
|
||||
if let Some(anchor) = anchor {
|
||||
project.update(cx, |project, cx| {
|
||||
project.set_agent_location(
|
||||
Some(AgentLocation {
|
||||
buffer: buffer.downgrade(),
|
||||
position: anchor,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
} 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, cx);
|
||||
})?;
|
||||
|
||||
Ok(result)
|
||||
} else {
|
||||
// File is too big, so return the outline
|
||||
// and a suggestion to read again with line numbers.
|
||||
let outline =
|
||||
outline::file_outline(project, 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."
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::TestToolCallEventStream;
|
||||
|
||||
use super::*;
|
||||
use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
|
||||
use language::{tree_sitter_rust, Language, LanguageConfig, LanguageMatcher};
|
||||
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 = TestToolCallEventStream::new();
|
||||
|
||||
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.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 event_stream = TestToolCallEventStream::new();
|
||||
let result = cx
|
||||
.update(|cx| {
|
||||
let input = ReadFileToolInput {
|
||||
path: "root/small_file.txt".into(),
|
||||
start_line: None,
|
||||
end_line: None,
|
||||
};
|
||||
tool.run(input, event_stream.stream(), cx)
|
||||
})
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), "This is a small file content");
|
||||
}
|
||||
|
||||
#[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 event_stream = TestToolCallEventStream::new();
|
||||
let content = cx
|
||||
.update(|cx| {
|
||||
let input = ReadFileToolInput {
|
||||
path: "root/large_file.rs".into(),
|
||||
start_line: None,
|
||||
end_line: None,
|
||||
};
|
||||
tool.clone().run(input, event_stream.stream(), cx)
|
||||
})
|
||||
.await
|
||||
.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, event_stream.stream(), cx)
|
||||
})
|
||||
.await;
|
||||
let content = result.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 event_stream = TestToolCallEventStream::new();
|
||||
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, event_stream.stream(), cx)
|
||||
})
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4");
|
||||
}
|
||||
|
||||
#[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));
|
||||
let event_stream = TestToolCallEventStream::new();
|
||||
|
||||
// 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, event_stream.stream(), cx)
|
||||
})
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), "Line 1\nLine 2");
|
||||
|
||||
// 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, event_stream.stream(), cx)
|
||||
})
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), "Line 1");
|
||||
|
||||
// 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, event_stream.stream(), cx)
|
||||
})
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), "Line 3");
|
||||
}
|
||||
|
||||
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));
|
||||
let event_stream = TestToolCallEventStream::new();
|
||||
|
||||
// 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, event_stream.stream(), 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, event_stream.stream(), 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, event_stream.stream(), 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, event_stream.stream(), 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, event_stream.stream(), 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, event_stream.stream(), 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, event_stream.stream(), 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, event_stream.stream(), cx)
|
||||
})
|
||||
.await;
|
||||
assert!(result.is_ok(), "Should be able to read normal files");
|
||||
assert_eq!(result.unwrap(), "Normal file content");
|
||||
|
||||
// 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, event_stream.stream(), 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()));
|
||||
let event_stream = TestToolCallEventStream::new();
|
||||
|
||||
// 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, event_stream.stream(), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, "fn main() { println!(\"Hello from worktree1\"); }");
|
||||
|
||||
// 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, event_stream.stream(), 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, event_stream.stream(), 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, event_stream.stream(), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
"export function greet() { return 'Hello from worktree2'; }"
|
||||
);
|
||||
|
||||
// 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, event_stream.stream(), 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, event_stream.stream(), 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, event_stream.stream(), 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
48
crates/agent2/src/tools/thinking_tool.rs
Normal file
48
crates/agent2/src/tools/thinking_tool.rs
Normal file
@@ -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;
|
||||
|
||||
fn name(&self) -> SharedString {
|
||||
"thinking".into()
|
||||
}
|
||||
|
||||
fn kind(&self) -> acp::ToolKind {
|
||||
acp::ToolKind::Think
|
||||
}
|
||||
|
||||
fn initial_title(&self, _input: Self::Input) -> SharedString {
|
||||
"Thinking".into()
|
||||
}
|
||||
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
input: Self::Input,
|
||||
event_stream: ToolCallEventStream,
|
||||
_cx: &mut App,
|
||||
) -> Task<Result<String>> {
|
||||
event_stream.send_update(acp::ToolCallUpdateFields {
|
||||
content: Some(vec![input.content.into()]),
|
||||
..Default::default()
|
||||
});
|
||||
Task::ready(Ok("Finished thinking.".to_string()))
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ impl acp_old::Client for OldAcpClientDelegate {
|
||||
let response = cx
|
||||
.update(|cx| {
|
||||
self.thread.borrow().update(cx, |thread, cx| {
|
||||
thread.request_tool_call_permission(tool_call, acp_options, cx)
|
||||
thread.request_tool_call_authorization(tool_call, acp_options, cx)
|
||||
})
|
||||
})?
|
||||
.context("Failed to update thread")?
|
||||
@@ -280,6 +280,7 @@ fn into_new_tool_call(id: acp::ToolCallId, request: acp_old::PushToolCallParams)
|
||||
.map(into_new_tool_call_location)
|
||||
.collect(),
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ impl acp::Client for ClientDelegate {
|
||||
.context("Failed to get session")?
|
||||
.thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.request_tool_call_permission(arguments.tool_call, arguments.options, cx)
|
||||
thread.request_tool_call_authorization(arguments.tool_call, arguments.options, cx)
|
||||
})?;
|
||||
|
||||
let result = rx.await;
|
||||
|
||||
@@ -893,10 +893,9 @@ pub(crate) mod tests {
|
||||
#[cfg_attr(not(feature = "e2e"), ignore)]
|
||||
async fn test_todo_plan(cx: &mut TestAppContext) {
|
||||
let fs = e2e_tests::init_test(cx).await;
|
||||
let tempdir = tempfile::tempdir().unwrap();
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
let thread =
|
||||
e2e_tests::new_test_thread(ClaudeCode, project.clone(), tempdir.path(), cx).await;
|
||||
e2e_tests::new_test_thread(ClaudeCode, project.clone(), "/private/tmp", cx).await;
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
@@ -950,8 +949,6 @@ pub(crate) mod tests {
|
||||
));
|
||||
assert_eq!(thread.plan().entries.len(), entries_len);
|
||||
});
|
||||
|
||||
drop(tempdir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -153,7 +153,7 @@ impl McpServerTool for PermissionTool {
|
||||
|
||||
let chosen_option = thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.request_tool_call_permission(
|
||||
thread.request_tool_call_authorization(
|
||||
claude_tool.as_acp(tool_call_id),
|
||||
vec![
|
||||
acp::PermissionOption {
|
||||
|
||||
@@ -297,6 +297,7 @@ impl ClaudeTool {
|
||||
content: self.content(),
|
||||
locations: self.locations(),
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@ use gpui::{Entity, TestAppContext};
|
||||
use indoc::indoc;
|
||||
use project::{FakeFs, Project};
|
||||
use settings::{Settings, SettingsStore};
|
||||
use util::path;
|
||||
|
||||
pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
|
||||
let fs = init_test(cx).await;
|
||||
let tempdir = tempfile::tempdir().unwrap();
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx))
|
||||
@@ -40,8 +40,6 @@ pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppCont
|
||||
AgentThreadEntry::AssistantMessage(_)
|
||||
));
|
||||
});
|
||||
|
||||
drop(tempdir);
|
||||
}
|
||||
|
||||
pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
|
||||
@@ -120,7 +118,7 @@ pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestApp
|
||||
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, project.clone(), tempdir.path(), cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
@@ -158,9 +156,8 @@ pub async fn test_tool_call_with_permission(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let fs = init_test(cx).await;
|
||||
let tempdir = tempfile::tempdir().unwrap();
|
||||
let project = Project::test(fs, [tempdir.path()], cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await;
|
||||
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
|
||||
let thread = new_test_thread(server, 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."#,
|
||||
@@ -242,15 +239,13 @@ pub async fn test_tool_call_with_permission(
|
||||
"Expected content to contain 'Hello'"
|
||||
);
|
||||
});
|
||||
|
||||
drop(tempdir);
|
||||
}
|
||||
|
||||
pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
|
||||
let fs = init_test(cx).await;
|
||||
let tempdir = tempfile::tempdir().unwrap();
|
||||
let project = Project::test(fs, [tempdir.path()], cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await;
|
||||
|
||||
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
|
||||
let thread = new_test_thread(server, 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."#,
|
||||
@@ -313,15 +308,12 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon
|
||||
AgentThreadEntry::AssistantMessage(..),
|
||||
))
|
||||
});
|
||||
|
||||
drop(tempdir);
|
||||
}
|
||||
|
||||
pub async fn test_thread_drop(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
|
||||
let fs = init_test(cx).await;
|
||||
let tempdir = tempfile::tempdir().unwrap();
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.send_raw("Hello from test!", cx))
|
||||
@@ -337,8 +329,6 @@ pub async fn test_thread_drop(server: impl AgentServer + 'static, cx: &mut TestA
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
assert!(!weak_thread.is_upgradable());
|
||||
|
||||
drop(tempdir);
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
|
||||
@@ -45,6 +45,11 @@ impl<T> MessageHistory<T> {
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn items(&self) -> &[T] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -31,7 +31,7 @@ use language::{Buffer, Language};
|
||||
use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
|
||||
use parking_lot::Mutex;
|
||||
use project::Project;
|
||||
use settings::Settings as _;
|
||||
use settings::{Settings as _, SettingsStore};
|
||||
use text::{Anchor, BufferSnapshot};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{
|
||||
@@ -80,6 +80,7 @@ pub struct AcpThreadView {
|
||||
editor_expanded: bool,
|
||||
message_history: Rc<RefCell<MessageHistory<Vec<acp::ContentBlock>>>>,
|
||||
_cancel_task: Option<Task<()>>,
|
||||
_subscriptions: [Subscription; 1],
|
||||
}
|
||||
|
||||
enum ThreadState {
|
||||
@@ -178,6 +179,8 @@ impl AcpThreadView {
|
||||
|
||||
let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
|
||||
|
||||
let subscription = cx.observe_global_in::<SettingsStore>(window, Self::settings_changed);
|
||||
|
||||
Self {
|
||||
agent: agent.clone(),
|
||||
workspace: workspace.clone(),
|
||||
@@ -200,6 +203,7 @@ impl AcpThreadView {
|
||||
plan_expanded: false,
|
||||
editor_expanded: false,
|
||||
message_history,
|
||||
_subscriptions: [subscription],
|
||||
_cancel_task: None,
|
||||
}
|
||||
}
|
||||
@@ -377,6 +381,11 @@ impl AcpThreadView {
|
||||
editor.display_map.update(cx, |map, cx| {
|
||||
let snapshot = map.snapshot(cx);
|
||||
for (crease_id, crease) in snapshot.crease_snapshot.creases() {
|
||||
// Skip creases that have been edited out of the message buffer.
|
||||
if !crease.range().start.is_valid(&snapshot.buffer_snapshot) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(project_path) =
|
||||
self.mention_set.lock().path_for_crease_id(crease_id)
|
||||
{
|
||||
@@ -704,15 +713,7 @@ impl AcpThreadView {
|
||||
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(TextStyleRefinement {
|
||||
font_size: Some(
|
||||
TextSize::Small
|
||||
.rems(cx)
|
||||
.to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
});
|
||||
editor.set_text_style_refinement(diff_editor_text_style_refinement(cx));
|
||||
editor
|
||||
});
|
||||
let entity_id = multibuffer.entity_id();
|
||||
@@ -2597,6 +2598,15 @@ impl AcpThreadView {
|
||||
.cursor_default()
|
||||
.children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
|
||||
}
|
||||
|
||||
fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
for diff_editor in self.diff_editors.values() {
|
||||
diff_editor.update(cx, |diff_editor, cx| {
|
||||
diff_editor.set_text_style_refinement(diff_editor_text_style_refinement(cx));
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for AcpThreadView {
|
||||
@@ -2874,6 +2884,18 @@ fn plan_label_markdown_style(
|
||||
}
|
||||
}
|
||||
|
||||
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 agent_client_protocol::SessionId;
|
||||
@@ -2881,8 +2903,12 @@ mod tests {
|
||||
use fs::FakeFs;
|
||||
use futures::future::try_join_all;
|
||||
use gpui::{SemanticVersion, TestAppContext, VisualTestContext};
|
||||
use lsp::{CompletionContext, CompletionTriggerKind};
|
||||
use project::CompletionIntent;
|
||||
use rand::Rng;
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
use util::path;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -2962,6 +2988,7 @@ mod tests {
|
||||
content: vec!["hi".into()],
|
||||
locations: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
};
|
||||
let connection = StubAgentConnection::new(vec![acp::SessionUpdate::ToolCall(tool_call)])
|
||||
.with_permission_requests(HashMap::from_iter([(
|
||||
@@ -2994,6 +3021,109 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_crease_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 agent = StubAgentServer::default();
|
||||
let (workspace, cx) =
|
||||
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
let thread_view = cx.update(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
AcpThreadView::new(
|
||||
Rc::new(agent),
|
||||
workspace.downgrade(),
|
||||
project,
|
||||
Rc::new(RefCell::new(MessageHistory::default())),
|
||||
1,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
|
||||
let excerpt_id = message_editor.update(cx, |editor, cx| {
|
||||
editor
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.excerpt_ids()
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap()
|
||||
});
|
||||
let completions = message_editor.update_in(cx, |editor, window, cx| {
|
||||
editor.set_text("Hello @", window, cx);
|
||||
let buffer = editor.buffer().read(cx).as_singleton().unwrap();
|
||||
let completion_provider = editor.completion_provider().unwrap();
|
||||
completion_provider.completions(
|
||||
excerpt_id,
|
||||
&buffer,
|
||||
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();
|
||||
|
||||
message_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).
|
||||
message_editor.update_in(cx, |editor, window, cx| {
|
||||
editor.backspace(&Default::default(), window, cx);
|
||||
editor.backspace(&Default::default(), window, cx);
|
||||
});
|
||||
|
||||
thread_view.update_in(cx, |thread_view, window, cx| {
|
||||
thread_view.chat(&Chat, window, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let content = thread_view.update_in(cx, |thread_view, _window, _cx| {
|
||||
thread_view
|
||||
.message_history
|
||||
.borrow()
|
||||
.items()
|
||||
.iter()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
// We don't send a resource link for the deleted crease.
|
||||
pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
|
||||
}
|
||||
|
||||
async fn setup_thread_view(
|
||||
agent: impl AgentServer + 'static,
|
||||
cx: &mut TestAppContext,
|
||||
@@ -3147,7 +3277,7 @@ mod tests {
|
||||
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_permission(
|
||||
thread.request_tool_call_authorization(
|
||||
tool_call.clone(),
|
||||
options.clone(),
|
||||
cx,
|
||||
|
||||
@@ -2343,6 +2343,16 @@ impl AgentPanel {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_backdrop(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.bg(cx.theme().colors().panel_background)
|
||||
.opacity(0.8)
|
||||
.block_mouse_except_scroll()
|
||||
}
|
||||
|
||||
fn render_trial_end_upsell(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
@@ -2352,15 +2362,24 @@ impl AgentPanel {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(EndTrialUpsell::new(Arc::new({
|
||||
let this = cx.entity();
|
||||
move |_, cx| {
|
||||
this.update(cx, |_this, cx| {
|
||||
TrialEndUpsell::set_dismissed(true, cx);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})))
|
||||
Some(
|
||||
v_flex()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.size_full()
|
||||
.bg(cx.theme().colors().panel_background)
|
||||
.opacity(0.85)
|
||||
.block_mouse_except_scroll()
|
||||
.child(EndTrialUpsell::new(Arc::new({
|
||||
let this = cx.entity();
|
||||
move |_, cx| {
|
||||
this.update(cx, |_this, cx| {
|
||||
TrialEndUpsell::set_dismissed(true, cx);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}))),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_empty_state_section_header(
|
||||
@@ -3210,9 +3229,10 @@ impl Render for AgentPanel {
|
||||
// - Scrolling in all views works as expected
|
||||
// - Files can be dropped into the panel
|
||||
let content = v_flex()
|
||||
.key_context(self.key_context())
|
||||
.justify_between()
|
||||
.relative()
|
||||
.size_full()
|
||||
.justify_between()
|
||||
.key_context(self.key_context())
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
.on_action(cx.listener(|this, action: &NewThread, window, cx| {
|
||||
this.new_thread(action, window, cx);
|
||||
@@ -3255,14 +3275,12 @@ impl Render for AgentPanel {
|
||||
.on_action(cx.listener(Self::toggle_burn_mode))
|
||||
.child(self.render_toolbar(window, cx))
|
||||
.children(self.render_onboarding(window, cx))
|
||||
.children(self.render_trial_end_upsell(window, cx))
|
||||
.map(|parent| match &self.active_view {
|
||||
ActiveView::Thread {
|
||||
thread,
|
||||
message_editor,
|
||||
..
|
||||
} => parent
|
||||
.relative()
|
||||
.child(
|
||||
if thread.read(cx).is_empty() && !self.should_render_onboarding(cx) {
|
||||
self.render_thread_empty_state(window, cx)
|
||||
@@ -3299,21 +3317,10 @@ impl Render for AgentPanel {
|
||||
})
|
||||
.child(h_flex().relative().child(message_editor.clone()).when(
|
||||
!LanguageModelRegistry::read_global(cx).has_authenticated_provider(cx),
|
||||
|this| {
|
||||
this.child(
|
||||
div()
|
||||
.size_full()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.bg(cx.theme().colors().panel_background)
|
||||
.opacity(0.8)
|
||||
.block_mouse_except_scroll(),
|
||||
)
|
||||
},
|
||||
|this| this.child(self.render_backdrop(cx)),
|
||||
))
|
||||
.child(self.render_drag_target(cx)),
|
||||
ActiveView::ExternalAgentThread { thread_view, .. } => parent
|
||||
.relative()
|
||||
.child(thread_view.clone())
|
||||
.child(self.render_drag_target(cx)),
|
||||
ActiveView::History => parent.child(self.history.clone()),
|
||||
@@ -3352,7 +3359,8 @@ impl Render for AgentPanel {
|
||||
))
|
||||
}
|
||||
ActiveView::Configuration => parent.children(self.configuration.clone()),
|
||||
});
|
||||
})
|
||||
.children(self.render_trial_end_upsell(window, cx));
|
||||
|
||||
match self.active_view.which_font_size_used() {
|
||||
WhichFontSize::AgentFont => {
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use client::{Client, zed_urls};
|
||||
use client::{Client, UserStore, zed_urls};
|
||||
use cloud_llm_client::Plan;
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, App, IntoElement, RenderOnce, Transformation, Window,
|
||||
percentage,
|
||||
Animation, AnimationExt, AnyElement, App, Entity, IntoElement, RenderOnce, Transformation,
|
||||
Window, percentage,
|
||||
};
|
||||
use ui::{Divider, Vector, VectorName, prelude::*};
|
||||
|
||||
use crate::{SignInStatus, plan_definitions::PlanDefinitions};
|
||||
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_plan: Option<Plan>) -> Self {
|
||||
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,
|
||||
@@ -32,6 +39,7 @@ impl AiUpsellCard {
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}),
|
||||
account_too_young: store.account_too_young(),
|
||||
tab_index: None,
|
||||
}
|
||||
}
|
||||
@@ -40,6 +48,7 @@ impl AiUpsellCard {
|
||||
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()
|
||||
@@ -128,7 +137,7 @@ impl RenderOnce for AiUpsellCard {
|
||||
.size(rems_from_px(72.))
|
||||
.child(
|
||||
Vector::new(
|
||||
VectorName::CertifiedUserStamp,
|
||||
VectorName::ProUserStamp,
|
||||
rems_from_px(72.),
|
||||
rems_from_px(72.),
|
||||
)
|
||||
@@ -158,36 +167,70 @@ impl RenderOnce for AiUpsellCard {
|
||||
SignInStatus::SignedIn => match self.user_plan {
|
||||
None | Some(Plan::ZedFree) => 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(
|
||||
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))
|
||||
}),
|
||||
.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(
|
||||
Label::new("No credit card required")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
),
|
||||
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))
|
||||
@@ -255,48 +298,68 @@ impl Component for AiUpsellCard {
|
||||
Some(
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.children(vec![example_group(vec![
|
||||
single_example(
|
||||
"Signed Out State",
|
||||
AiUpsellCard {
|
||||
sign_in_status: SignInStatus::SignedOut,
|
||||
sign_in: Arc::new(|_, _| {}),
|
||||
user_plan: None,
|
||||
tab_index: Some(0),
|
||||
}
|
||||
.into_any_element(),
|
||||
),
|
||||
single_example(
|
||||
"Free Plan",
|
||||
AiUpsellCard {
|
||||
sign_in_status: SignInStatus::SignedIn,
|
||||
sign_in: Arc::new(|_, _| {}),
|
||||
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(|_, _| {}),
|
||||
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(|_, _| {}),
|
||||
user_plan: Some(Plan::ZedPro),
|
||||
tab_index: Some(1),
|
||||
}
|
||||
.into_any_element(),
|
||||
),
|
||||
])])
|
||||
.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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,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;
|
||||
|
||||
@@ -134,10 +134,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))
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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,
|
||||
@@ -192,6 +193,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 {}
|
||||
@@ -205,6 +208,7 @@ pub struct Client {
|
||||
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"))]
|
||||
@@ -554,6 +558,7 @@ impl Client {
|
||||
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(),
|
||||
@@ -960,25 +965,51 @@ impl Client {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Performs a sign-in and also connects to Collab.
|
||||
/// Performs a sign-in and also (optionally) connects to Collab.
|
||||
///
|
||||
/// This is called in places where we *don't* need to connect in the future. We will replace these calls with calls
|
||||
/// to `sign_in` when we're ready to remove auto-connection 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<()> {
|
||||
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();
|
||||
|
||||
let connect_result = match self.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"),
|
||||
};
|
||||
connect_result.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(())
|
||||
}
|
||||
@@ -1651,10 +1682,22 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_message_to_client(self: &Arc<Client>, message: MessageToClient, _cx: &AsyncApp) {
|
||||
match message {
|
||||
MessageToClient::UserUpdated => {}
|
||||
}
|
||||
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> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::{Client, Status, TypedEnvelope, proto};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
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,
|
||||
@@ -181,6 +182,12 @@ impl UserStore {
|
||||
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(),
|
||||
@@ -813,6 +820,32 @@ impl UserStore {
|
||||
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);
|
||||
}
|
||||
|
||||
pub fn watch_current_user(&self) -> watch::Receiver<Option<Arc<User>>> {
|
||||
self.current_user.clone()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -699,7 +699,10 @@ impl Database {
|
||||
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)
|
||||
|
||||
@@ -41,9 +41,11 @@ 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::{MultiLspQuery, split_repository_update};
|
||||
use supermaven_api::{CreateExternalUserRequest, SupermavenAdminApi};
|
||||
use tracing::Span;
|
||||
|
||||
use futures::{
|
||||
FutureExt, SinkExt, StreamExt, TryStreamExt, channel::oneshot, future::BoxFuture,
|
||||
@@ -94,8 +96,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;
|
||||
|
||||
@@ -163,6 +170,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,
|
||||
@@ -646,40 +689,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;
|
||||
|
||||
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,
|
||||
"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()
|
||||
@@ -693,7 +733,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,
|
||||
{
|
||||
@@ -703,7 +743,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,
|
||||
{
|
||||
@@ -761,7 +801,8 @@ impl Server {
|
||||
login=field::Empty,
|
||||
impersonator=field::Empty,
|
||||
user_agent=field::Empty,
|
||||
geoip_country_code=field::Empty
|
||||
geoip_country_code=field::Empty,
|
||||
release_channel=field::Empty,
|
||||
);
|
||||
principal.update_span(&span);
|
||||
if let Some(user_agent) = user_agent {
|
||||
@@ -888,12 +929,16 @@ impl Server {
|
||||
login=field::Empty,
|
||||
impersonator=field::Empty,
|
||||
multi_lsp_query_request=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 {
|
||||
@@ -1385,7 +1430,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(())
|
||||
}
|
||||
@@ -1394,7 +1443,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);
|
||||
|
||||
@@ -1434,7 +1483,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);
|
||||
|
||||
@@ -1501,7 +1550,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;
|
||||
@@ -1629,15 +1678,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 {
|
||||
@@ -1678,7 +1727,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 {})?;
|
||||
@@ -1689,7 +1738,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());
|
||||
@@ -1737,7 +1786,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();
|
||||
@@ -1806,7 +1855,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);
|
||||
@@ -1841,7 +1890,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
|
||||
@@ -1876,7 +1925,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")?;
|
||||
@@ -1895,7 +1944,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()
|
||||
@@ -1916,7 +1965,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
|
||||
}
|
||||
@@ -1963,7 +2012,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);
|
||||
|
||||
@@ -2059,15 +2108,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 {
|
||||
@@ -2110,7 +2159,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;
|
||||
@@ -2133,7 +2182,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
|
||||
@@ -2162,7 +2211,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()
|
||||
@@ -2186,7 +2235,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()
|
||||
@@ -2210,7 +2259,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()
|
||||
@@ -2234,7 +2283,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()
|
||||
@@ -2258,7 +2307,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()
|
||||
@@ -2282,7 +2331,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()
|
||||
@@ -2305,7 +2354,7 @@ 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 db = session.db().await;
|
||||
@@ -2338,7 +2387,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,
|
||||
@@ -2349,10 +2398,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?;
|
||||
let payload = session.forward_request(host_connection_id, request).await?;
|
||||
response.send(payload)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2362,7 +2408,7 @@ where
|
||||
async fn forward_mutating_project_request<T>(
|
||||
request: T,
|
||||
response: Response<T>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()>
|
||||
where
|
||||
T: EntityMessage + RequestMessage,
|
||||
@@ -2374,10 +2420,7 @@ where
|
||||
.await
|
||||
.host_for_mutating_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(())
|
||||
}
|
||||
@@ -2385,7 +2428,7 @@ where
|
||||
async fn multi_lsp_query(
|
||||
request: MultiLspQuery,
|
||||
response: Response<MultiLspQuery>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
tracing::Span::current().record("multi_lsp_query_request", request.request_str());
|
||||
tracing::info!("multi_lsp_query message received");
|
||||
@@ -2395,7 +2438,7 @@ async fn multi_lsp_query(
|
||||
/// Notify other participants that a new buffer has been created
|
||||
async fn create_buffer_for_peer(
|
||||
request: proto::CreateBufferForPeer,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
session
|
||||
.db()
|
||||
@@ -2417,7 +2460,7 @@ async fn create_buffer_for_peer(
|
||||
async fn update_buffer(
|
||||
request: proto::UpdateBuffer,
|
||||
response: Response<proto::UpdateBuffer>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let project_id = ProjectId::from_proto(request.project_id);
|
||||
let mut capability = Capability::ReadOnly;
|
||||
@@ -2452,17 +2495,14 @@ async fn update_buffer(
|
||||
};
|
||||
|
||||
if host != session.connection_id {
|
||||
session
|
||||
.peer
|
||||
.forward_request(session.connection_id, host, request.clone())
|
||||
.await?;
|
||||
session.forward_request(host, request.clone()).await?;
|
||||
}
|
||||
|
||||
response.send(proto::Ack {})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_context(message: proto::UpdateContext, session: Session) -> Result<()> {
|
||||
async fn update_context(message: proto::UpdateContext, session: MessageContext) -> Result<()> {
|
||||
let project_id = ProjectId::from_proto(message.project_id);
|
||||
|
||||
let operation = message.operation.as_ref().context("invalid operation")?;
|
||||
@@ -2507,7 +2547,7 @@ async fn update_context(message: proto::UpdateContext, session: Session) -> Resu
|
||||
/// Notify other participants that a project has been updated.
|
||||
async fn broadcast_project_message_from_host<T: EntityMessage<Entity = ShareProject>>(
|
||||
request: T,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let project_id = ProjectId::from_proto(request.remote_entity_id());
|
||||
let project_connection_ids = session
|
||||
@@ -2532,7 +2572,7 @@ async fn broadcast_project_message_from_host<T: EntityMessage<Entity = ShareProj
|
||||
async fn follow(
|
||||
request: proto::Follow,
|
||||
response: Response<proto::Follow>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let room_id = RoomId::from_proto(request.room_id);
|
||||
let project_id = request.project_id.map(ProjectId::from_proto);
|
||||
@@ -2545,10 +2585,7 @@ async fn follow(
|
||||
.check_room_participants(room_id, leader_id, session.connection_id)
|
||||
.await?;
|
||||
|
||||
let response_payload = session
|
||||
.peer
|
||||
.forward_request(session.connection_id, leader_id, request)
|
||||
.await?;
|
||||
let response_payload = session.forward_request(leader_id, request).await?;
|
||||
response.send(response_payload)?;
|
||||
|
||||
if let Some(project_id) = project_id {
|
||||
@@ -2564,7 +2601,7 @@ async fn follow(
|
||||
}
|
||||
|
||||
/// Stop following another user in a call.
|
||||
async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> {
|
||||
async fn unfollow(request: proto::Unfollow, session: MessageContext) -> Result<()> {
|
||||
let room_id = RoomId::from_proto(request.room_id);
|
||||
let project_id = request.project_id.map(ProjectId::from_proto);
|
||||
let leader_id = request.leader_id.context("invalid leader id")?.into();
|
||||
@@ -2593,7 +2630,7 @@ async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Notify everyone following you of your current location.
|
||||
async fn update_followers(request: proto::UpdateFollowers, session: Session) -> Result<()> {
|
||||
async fn update_followers(request: proto::UpdateFollowers, session: MessageContext) -> Result<()> {
|
||||
let room_id = RoomId::from_proto(request.room_id);
|
||||
let database = session.db.lock().await;
|
||||
|
||||
@@ -2628,7 +2665,7 @@ async fn update_followers(request: proto::UpdateFollowers, session: Session) ->
|
||||
async fn get_users(
|
||||
request: proto::GetUsers,
|
||||
response: Response<proto::GetUsers>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let user_ids = request
|
||||
.user_ids
|
||||
@@ -2656,7 +2693,7 @@ async fn get_users(
|
||||
async fn fuzzy_search_users(
|
||||
request: proto::FuzzySearchUsers,
|
||||
response: Response<proto::FuzzySearchUsers>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let query = request.query;
|
||||
let users = match query.len() {
|
||||
@@ -2688,7 +2725,7 @@ async fn fuzzy_search_users(
|
||||
async fn request_contact(
|
||||
request: proto::RequestContact,
|
||||
response: Response<proto::RequestContact>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let requester_id = session.user_id();
|
||||
let responder_id = UserId::from_proto(request.responder_id);
|
||||
@@ -2735,7 +2772,7 @@ async fn request_contact(
|
||||
async fn respond_to_contact_request(
|
||||
request: proto::RespondToContactRequest,
|
||||
response: Response<proto::RespondToContactRequest>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let responder_id = session.user_id();
|
||||
let requester_id = UserId::from_proto(request.requester_id);
|
||||
@@ -2793,7 +2830,7 @@ async fn respond_to_contact_request(
|
||||
async fn remove_contact(
|
||||
request: proto::RemoveContact,
|
||||
response: Response<proto::RemoveContact>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let requester_id = session.user_id();
|
||||
let responder_id = UserId::from_proto(request.user_id);
|
||||
@@ -3052,7 +3089,10 @@ async fn update_user_plan(session: &Session) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn subscribe_to_channels(_: proto::SubscribeToChannels, session: Session) -> Result<()> {
|
||||
async fn subscribe_to_channels(
|
||||
_: proto::SubscribeToChannels,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
subscribe_user_to_channels(session.user_id(), &session).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -3078,7 +3118,7 @@ async fn subscribe_user_to_channels(user_id: UserId, session: &Session) -> Resul
|
||||
async fn create_channel(
|
||||
request: proto::CreateChannel,
|
||||
response: Response<proto::CreateChannel>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
|
||||
@@ -3133,7 +3173,7 @@ async fn create_channel(
|
||||
async fn delete_channel(
|
||||
request: proto::DeleteChannel,
|
||||
response: Response<proto::DeleteChannel>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
|
||||
@@ -3161,7 +3201,7 @@ async fn delete_channel(
|
||||
async fn invite_channel_member(
|
||||
request: proto::InviteChannelMember,
|
||||
response: Response<proto::InviteChannelMember>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
@@ -3198,7 +3238,7 @@ async fn invite_channel_member(
|
||||
async fn remove_channel_member(
|
||||
request: proto::RemoveChannelMember,
|
||||
response: Response<proto::RemoveChannelMember>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
@@ -3242,7 +3282,7 @@ async fn remove_channel_member(
|
||||
async fn set_channel_visibility(
|
||||
request: proto::SetChannelVisibility,
|
||||
response: Response<proto::SetChannelVisibility>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
@@ -3287,7 +3327,7 @@ async fn set_channel_visibility(
|
||||
async fn set_channel_member_role(
|
||||
request: proto::SetChannelMemberRole,
|
||||
response: Response<proto::SetChannelMemberRole>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
@@ -3335,7 +3375,7 @@ async fn set_channel_member_role(
|
||||
async fn rename_channel(
|
||||
request: proto::RenameChannel,
|
||||
response: Response<proto::RenameChannel>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
@@ -3367,7 +3407,7 @@ async fn rename_channel(
|
||||
async fn move_channel(
|
||||
request: proto::MoveChannel,
|
||||
response: Response<proto::MoveChannel>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let to = ChannelId::from_proto(request.to);
|
||||
@@ -3409,7 +3449,7 @@ async fn move_channel(
|
||||
async fn reorder_channel(
|
||||
request: proto::ReorderChannel,
|
||||
response: Response<proto::ReorderChannel>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let direction = request.direction();
|
||||
@@ -3455,7 +3495,7 @@ async fn reorder_channel(
|
||||
async fn get_channel_members(
|
||||
request: proto::GetChannelMembers,
|
||||
response: Response<proto::GetChannelMembers>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
@@ -3475,7 +3515,7 @@ async fn get_channel_members(
|
||||
async fn respond_to_channel_invite(
|
||||
request: proto::RespondToChannelInvite,
|
||||
response: Response<proto::RespondToChannelInvite>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
@@ -3516,7 +3556,7 @@ async fn respond_to_channel_invite(
|
||||
async fn join_channel(
|
||||
request: proto::JoinChannel,
|
||||
response: Response<proto::JoinChannel>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
join_channel_internal(channel_id, Box::new(response), session).await
|
||||
@@ -3539,7 +3579,7 @@ impl JoinChannelInternalResponse for Response<proto::JoinRoom> {
|
||||
async fn join_channel_internal(
|
||||
channel_id: ChannelId,
|
||||
response: Box<impl JoinChannelInternalResponse>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let joined_room = {
|
||||
let mut db = session.db().await;
|
||||
@@ -3634,7 +3674,7 @@ async fn join_channel_internal(
|
||||
async fn join_channel_buffer(
|
||||
request: proto::JoinChannelBuffer,
|
||||
response: Response<proto::JoinChannelBuffer>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
@@ -3665,7 +3705,7 @@ async fn join_channel_buffer(
|
||||
/// Edit the channel notes
|
||||
async fn update_channel_buffer(
|
||||
request: proto::UpdateChannelBuffer,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
@@ -3717,7 +3757,7 @@ async fn update_channel_buffer(
|
||||
async fn rejoin_channel_buffers(
|
||||
request: proto::RejoinChannelBuffers,
|
||||
response: Response<proto::RejoinChannelBuffers>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
let buffers = db
|
||||
@@ -3752,7 +3792,7 @@ async fn rejoin_channel_buffers(
|
||||
async fn leave_channel_buffer(
|
||||
request: proto::LeaveChannelBuffer,
|
||||
response: Response<proto::LeaveChannelBuffer>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
@@ -3814,7 +3854,7 @@ fn send_notifications(
|
||||
async fn send_channel_message(
|
||||
request: proto::SendChannelMessage,
|
||||
response: Response<proto::SendChannelMessage>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
// Validate the message body.
|
||||
let body = request.body.trim().to_string();
|
||||
@@ -3907,7 +3947,7 @@ async fn send_channel_message(
|
||||
async fn remove_channel_message(
|
||||
request: proto::RemoveChannelMessage,
|
||||
response: Response<proto::RemoveChannelMessage>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let message_id = MessageId::from_proto(request.message_id);
|
||||
@@ -3942,7 +3982,7 @@ async fn remove_channel_message(
|
||||
async fn update_channel_message(
|
||||
request: proto::UpdateChannelMessage,
|
||||
response: Response<proto::UpdateChannelMessage>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let message_id = MessageId::from_proto(request.message_id);
|
||||
@@ -4026,7 +4066,7 @@ async fn update_channel_message(
|
||||
/// Mark a channel message as read
|
||||
async fn acknowledge_channel_message(
|
||||
request: proto::AckChannelMessage,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let message_id = MessageId::from_proto(request.message_id);
|
||||
@@ -4046,7 +4086,7 @@ async fn acknowledge_channel_message(
|
||||
/// Mark a buffer version as synced
|
||||
async fn acknowledge_buffer_version(
|
||||
request: proto::AckBufferOperation,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let buffer_id = BufferId::from_proto(request.buffer_id);
|
||||
session
|
||||
@@ -4066,7 +4106,7 @@ async fn acknowledge_buffer_version(
|
||||
async fn get_supermaven_api_key(
|
||||
_request: proto::GetSupermavenApiKey,
|
||||
response: Response<proto::GetSupermavenApiKey>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let user_id: String = session.user_id().to_string();
|
||||
if !session.is_staff() {
|
||||
@@ -4095,7 +4135,7 @@ async fn get_supermaven_api_key(
|
||||
async fn join_channel_chat(
|
||||
request: proto::JoinChannelChat,
|
||||
response: Response<proto::JoinChannelChat>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
|
||||
@@ -4113,7 +4153,10 @@ async fn join_channel_chat(
|
||||
}
|
||||
|
||||
/// Stop receiving chat updates for a channel
|
||||
async fn leave_channel_chat(request: proto::LeaveChannelChat, session: Session) -> Result<()> {
|
||||
async fn leave_channel_chat(
|
||||
request: proto::LeaveChannelChat,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
session
|
||||
.db()
|
||||
@@ -4127,7 +4170,7 @@ async fn leave_channel_chat(request: proto::LeaveChannelChat, session: Session)
|
||||
async fn get_channel_messages(
|
||||
request: proto::GetChannelMessages,
|
||||
response: Response<proto::GetChannelMessages>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let messages = session
|
||||
@@ -4151,7 +4194,7 @@ async fn get_channel_messages(
|
||||
async fn get_channel_messages_by_id(
|
||||
request: proto::GetChannelMessagesById,
|
||||
response: Response<proto::GetChannelMessagesById>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let message_ids = request
|
||||
.message_ids
|
||||
@@ -4174,7 +4217,7 @@ async fn get_channel_messages_by_id(
|
||||
async fn get_notifications(
|
||||
request: proto::GetNotifications,
|
||||
response: Response<proto::GetNotifications>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let notifications = session
|
||||
.db()
|
||||
@@ -4196,7 +4239,7 @@ async fn get_notifications(
|
||||
async fn mark_notification_as_read(
|
||||
request: proto::MarkNotificationRead,
|
||||
response: Response<proto::MarkNotificationRead>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let database = &session.db().await;
|
||||
let notifications = database
|
||||
@@ -4218,7 +4261,7 @@ async fn mark_notification_as_read(
|
||||
async fn get_private_user_info(
|
||||
_request: proto::GetPrivateUserInfo,
|
||||
response: Response<proto::GetPrivateUserInfo>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
|
||||
@@ -4242,7 +4285,7 @@ async fn get_private_user_info(
|
||||
async fn accept_terms_of_service(
|
||||
_request: proto::AcceptTermsOfService,
|
||||
response: Response<proto::AcceptTermsOfService>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
|
||||
@@ -4266,7 +4309,7 @@ async fn accept_terms_of_service(
|
||||
async fn get_llm_api_token(
|
||||
_request: proto::GetLlmToken,
|
||||
response: Response<proto::GetLlmToken>,
|
||||
session: Session,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let db = session.db().await;
|
||||
|
||||
|
||||
@@ -3101,9 +3101,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| {
|
||||
|
||||
@@ -3053,7 +3053,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)
|
||||
|
||||
@@ -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(
|
||||
@@ -343,8 +351,8 @@ mod tests {
|
||||
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
assert!(editor.context_menu_visible());
|
||||
assert!(!editor.has_active_edit_prediction());
|
||||
// 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");
|
||||
|
||||
@@ -934,8 +942,9 @@ mod tests {
|
||||
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
|
||||
cx.update_editor(|editor, _, cx| {
|
||||
assert!(editor.context_menu_visible());
|
||||
assert!(!editor.has_active_edit_prediction(),);
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1077,8 +1086,6 @@ mod tests {
|
||||
vec![complete_from_marker.clone(), 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 +1094,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()
|
||||
|
||||
@@ -177,9 +177,9 @@ impl ProjectDiagnosticsEditor {
|
||||
}
|
||||
project::Event::DiagnosticsUpdated {
|
||||
language_server_id,
|
||||
path,
|
||||
paths,
|
||||
} => {
|
||||
this.paths_to_update.insert(path.clone());
|
||||
this.paths_to_update.extend(paths.clone());
|
||||
let project = project.clone();
|
||||
this.diagnostic_summary_update = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor()
|
||||
@@ -193,9 +193,9 @@ impl ProjectDiagnosticsEditor {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -876,7 +876,7 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -116,6 +120,7 @@ pub trait EditPredictionProviderHandle {
|
||||
) -> 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);
|
||||
@@ -166,6 +171,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)
|
||||
}
|
||||
|
||||
@@ -491,7 +491,12 @@ impl EditPredictionButton {
|
||||
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")
|
||||
|
||||
@@ -745,5 +745,6 @@ actions!(
|
||||
UniqueLinesCaseInsensitive,
|
||||
/// Removes duplicate lines (case-sensitive).
|
||||
UniqueLinesCaseSensitive,
|
||||
UnwrapSyntaxNode
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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.chars_at(text.len().saturating_sub(1)).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,
|
||||
}
|
||||
}
|
||||
@@ -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::edit_prediction(
|
||||
post_inc(next_inlay_id),
|
||||
snapshot.buffer.anchor_at(position, bias),
|
||||
text.clone(),
|
||||
&text,
|
||||
)
|
||||
};
|
||||
let inlay_id = next_inlay.id;
|
||||
@@ -1694,7 +1694,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());
|
||||
}
|
||||
|
||||
@@ -228,6 +228,49 @@ async fn test_edit_prediction_invalidation_range(cx: &mut gpui::TestAppContext)
|
||||
});
|
||||
}
|
||||
|
||||
#[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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn assert_editor_active_edit_completion(
|
||||
cx: &mut EditorTestContext,
|
||||
assert: impl FnOnce(MultiBufferSnapshot, &Vec<(Range<Anchor>, String)>),
|
||||
@@ -301,6 +344,37 @@ fn assign_editor_completion_provider(
|
||||
})
|
||||
}
|
||||
|
||||
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| {
|
||||
editor.set_edit_prediction_provider(Some(provider), window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct FakeEditPredictionProvider {
|
||||
pub completion: Option<edit_prediction::EditPrediction>,
|
||||
@@ -325,6 +399,84 @@ impl EditPredictionProvider for FakeEditPredictionProvider {
|
||||
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>,
|
||||
|
||||
@@ -2705,6 +2705,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()
|
||||
}
|
||||
@@ -7760,8 +7765,14 @@ impl Editor {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let is_move =
|
||||
move_invalidation_row_range.is_some() || self.edit_predictions_hidden_for_vim_mode;
|
||||
let supports_jump = self
|
||||
.edit_prediction_provider
|
||||
.as_ref()
|
||||
.map(|provider| provider.provider.supports_jump_to_edit())
|
||||
.unwrap_or(true);
|
||||
|
||||
let is_move = supports_jump
|
||||
&& (move_invalidation_row_range.is_some() || self.edit_predictions_hidden_for_vim_mode);
|
||||
let completion = if is_move {
|
||||
invalidation_row_range =
|
||||
move_invalidation_row_range.unwrap_or(edit_start_row..edit_end_row);
|
||||
@@ -8799,8 +8810,12 @@ impl Editor {
|
||||
return None;
|
||||
}
|
||||
|
||||
let highlighted_edits =
|
||||
crate::edit_prediction_edit_text(&snapshot, edits, edit_preview.as_ref()?, false, cx);
|
||||
let highlighted_edits = if let Some(edit_preview) = edit_preview.as_ref() {
|
||||
crate::edit_prediction_edit_text(&snapshot, edits, edit_preview, false, cx)
|
||||
} else {
|
||||
// Fallback for providers without edit_preview
|
||||
crate::edit_prediction_fallback_text(edits, cx)
|
||||
};
|
||||
|
||||
let styled_text = highlighted_edits.to_styled_text(&style.text);
|
||||
let line_count = highlighted_edits.text.lines().count();
|
||||
@@ -9068,6 +9083,18 @@ impl Editor {
|
||||
let editor_bg_color = cx.theme().colors().editor_background;
|
||||
editor_bg_color.blend(accent_color.opacity(0.6))
|
||||
}
|
||||
fn get_prediction_provider_icon_name(
|
||||
provider: &Option<RegisteredEditPredictionProvider>,
|
||||
) -> IconName {
|
||||
match provider {
|
||||
Some(provider) => match provider.provider.name() {
|
||||
"copilot" => IconName::Copilot,
|
||||
"supermaven" => IconName::Supermaven,
|
||||
_ => IconName::ZedPredict,
|
||||
},
|
||||
None => IconName::ZedPredict,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_edit_prediction_cursor_popover(
|
||||
&self,
|
||||
@@ -9080,6 +9107,7 @@ impl Editor {
|
||||
cx: &mut Context<Editor>,
|
||||
) -> Option<AnyElement> {
|
||||
let provider = self.edit_prediction_provider.as_ref()?;
|
||||
let provider_icon = Self::get_prediction_provider_icon_name(&self.edit_prediction_provider);
|
||||
|
||||
if provider.provider.needs_terms_acceptance(cx) {
|
||||
return Some(
|
||||
@@ -9106,7 +9134,7 @@ impl Editor {
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.child(Icon::new(IconName::ZedPredict))
|
||||
.child(Icon::new(provider_icon))
|
||||
.child(Label::new("Accept Terms of Service"))
|
||||
.child(div().w_full())
|
||||
.child(
|
||||
@@ -9122,12 +9150,8 @@ impl Editor {
|
||||
|
||||
let is_refreshing = provider.provider.is_refreshing(cx);
|
||||
|
||||
fn pending_completion_container() -> Div {
|
||||
h_flex()
|
||||
.h_full()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.child(Icon::new(IconName::ZedPredict))
|
||||
fn pending_completion_container(icon: IconName) -> Div {
|
||||
h_flex().h_full().flex_1().gap_2().child(Icon::new(icon))
|
||||
}
|
||||
|
||||
let completion = match &self.active_edit_prediction {
|
||||
@@ -9157,7 +9181,7 @@ impl Editor {
|
||||
Icon::new(IconName::ZedPredictUp)
|
||||
}
|
||||
}
|
||||
EditPrediction::Edit { .. } => Icon::new(IconName::ZedPredict),
|
||||
EditPrediction::Edit { .. } => Icon::new(provider_icon),
|
||||
}))
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -9224,15 +9248,15 @@ impl Editor {
|
||||
cx,
|
||||
)?,
|
||||
|
||||
None => {
|
||||
pending_completion_container().child(Label::new("...").size(LabelSize::Small))
|
||||
}
|
||||
None => pending_completion_container(provider_icon)
|
||||
.child(Label::new("...").size(LabelSize::Small)),
|
||||
},
|
||||
|
||||
None => pending_completion_container().child(Label::new("No Prediction")),
|
||||
None => pending_completion_container(provider_icon)
|
||||
.child(Label::new("...").size(LabelSize::Small)),
|
||||
};
|
||||
|
||||
let completion = if is_refreshing {
|
||||
let completion = if is_refreshing || self.active_edit_prediction.is_none() {
|
||||
completion
|
||||
.with_animation(
|
||||
"loading-completion",
|
||||
@@ -9332,23 +9356,35 @@ impl Editor {
|
||||
.child(Icon::new(arrow).color(Color::Muted).size(IconSize::Small))
|
||||
}
|
||||
|
||||
let supports_jump = self
|
||||
.edit_prediction_provider
|
||||
.as_ref()
|
||||
.map(|provider| provider.provider.supports_jump_to_edit())
|
||||
.unwrap_or(true);
|
||||
|
||||
match &completion.completion {
|
||||
EditPrediction::Move {
|
||||
target, snapshot, ..
|
||||
} => Some(
|
||||
h_flex()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.flex_1()
|
||||
.child(
|
||||
if target.text_anchor.to_point(&snapshot).row > cursor_point.row {
|
||||
Icon::new(IconName::ZedPredictDown)
|
||||
} else {
|
||||
Icon::new(IconName::ZedPredictUp)
|
||||
},
|
||||
)
|
||||
.child(Label::new("Jump to Edit")),
|
||||
),
|
||||
} => {
|
||||
if !supports_jump {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
h_flex()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.flex_1()
|
||||
.child(
|
||||
if target.text_anchor.to_point(&snapshot).row > cursor_point.row {
|
||||
Icon::new(IconName::ZedPredictDown)
|
||||
} else {
|
||||
Icon::new(IconName::ZedPredictUp)
|
||||
},
|
||||
)
|
||||
.child(Label::new("Jump to Edit")),
|
||||
)
|
||||
}
|
||||
|
||||
EditPrediction::Edit {
|
||||
edits,
|
||||
@@ -9358,14 +9394,13 @@ impl Editor {
|
||||
} => {
|
||||
let first_edit_row = edits.first()?.0.start.text_anchor.to_point(&snapshot).row;
|
||||
|
||||
let (highlighted_edits, has_more_lines) = crate::edit_prediction_edit_text(
|
||||
&snapshot,
|
||||
&edits,
|
||||
edit_preview.as_ref()?,
|
||||
true,
|
||||
cx,
|
||||
)
|
||||
.first_line_preview();
|
||||
let (highlighted_edits, has_more_lines) =
|
||||
if let Some(edit_preview) = edit_preview.as_ref() {
|
||||
crate::edit_prediction_edit_text(&snapshot, &edits, edit_preview, true, cx)
|
||||
.first_line_preview()
|
||||
} else {
|
||||
crate::edit_prediction_fallback_text(&edits, cx).first_line_preview()
|
||||
};
|
||||
|
||||
let styled_text = gpui::StyledText::new(highlighted_edits.text)
|
||||
.with_default_highlights(&style.text, highlighted_edits.highlights);
|
||||
@@ -9376,11 +9411,13 @@ impl Editor {
|
||||
.child(styled_text)
|
||||
.when(has_more_lines, |parent| parent.child("…"));
|
||||
|
||||
let left = if first_edit_row != cursor_point.row {
|
||||
let left = if supports_jump && first_edit_row != cursor_point.row {
|
||||
render_relative_row_jump("", cursor_point.row, first_edit_row)
|
||||
.into_any_element()
|
||||
} else {
|
||||
Icon::new(IconName::ZedPredict).into_any_element()
|
||||
let icon_name =
|
||||
Editor::get_prediction_provider_icon_name(&self.edit_prediction_provider);
|
||||
Icon::new(icon_name).into_any_element()
|
||||
};
|
||||
|
||||
Some(
|
||||
@@ -14711,6 +14748,81 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unwrap_syntax_node(
|
||||
&mut self,
|
||||
_: &UnwrapSyntaxNode,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
|
||||
|
||||
let buffer = self.buffer.read(cx).snapshot(cx);
|
||||
let old_selections: Box<[_]> = self.selections.all::<usize>(cx).into();
|
||||
|
||||
let edits = old_selections
|
||||
.iter()
|
||||
// only consider the first selection for now
|
||||
.take(1)
|
||||
.map(|selection| {
|
||||
// Only requires two branches once if-let-chains stabilize (#53667)
|
||||
let selection_range = if !selection.is_empty() {
|
||||
selection.range()
|
||||
} else if let Some((_, ancestor_range)) =
|
||||
buffer.syntax_ancestor(selection.start..selection.end)
|
||||
{
|
||||
match ancestor_range {
|
||||
MultiOrSingleBufferOffsetRange::Single(range) => range,
|
||||
MultiOrSingleBufferOffsetRange::Multi(range) => range,
|
||||
}
|
||||
} else {
|
||||
selection.range()
|
||||
};
|
||||
|
||||
let mut new_range = selection_range.clone();
|
||||
while let Some((_, ancestor_range)) = buffer.syntax_ancestor(new_range.clone()) {
|
||||
new_range = match ancestor_range {
|
||||
MultiOrSingleBufferOffsetRange::Single(range) => range,
|
||||
MultiOrSingleBufferOffsetRange::Multi(range) => range,
|
||||
};
|
||||
if new_range.start < selection_range.start
|
||||
|| new_range.end > selection_range.end
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(selection, selection_range, new_range)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
self.transact(window, cx, |editor, window, cx| {
|
||||
for (_, child, parent) in &edits {
|
||||
let text = buffer.text_for_range(child.clone()).collect::<String>();
|
||||
editor.replace_text_in_range(Some(parent.clone()), &text, window, cx);
|
||||
}
|
||||
|
||||
editor.change_selections(
|
||||
SelectionEffects::scroll(Autoscroll::fit()),
|
||||
window,
|
||||
cx,
|
||||
|s| {
|
||||
s.select(
|
||||
edits
|
||||
.iter()
|
||||
.map(|(s, old, new)| Selection {
|
||||
id: s.id,
|
||||
start: new.start,
|
||||
end: new.start + old.len(),
|
||||
goal: SelectionGoal::None,
|
||||
reversed: s.reversed,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
|
||||
if !EditorSettings::get_global(cx).gutter.runnables {
|
||||
self.clear_tasks();
|
||||
@@ -23195,6 +23307,33 @@ fn edit_prediction_edit_text(
|
||||
edit_preview.highlight_edits(current_snapshot, &edits, include_deletions, cx)
|
||||
}
|
||||
|
||||
fn edit_prediction_fallback_text(edits: &[(Range<Anchor>, String)], cx: &App) -> HighlightedText {
|
||||
// Fallback for providers that don't provide edit_preview (like Copilot/Supermaven)
|
||||
// Just show the raw edit text with basic styling
|
||||
let mut text = String::new();
|
||||
let mut highlights = Vec::new();
|
||||
|
||||
let insertion_highlight_style = HighlightStyle {
|
||||
color: Some(cx.theme().colors().text),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for (_, edit_text) in edits {
|
||||
let start_offset = text.len();
|
||||
text.push_str(edit_text);
|
||||
let end_offset = text.len();
|
||||
|
||||
if start_offset < end_offset {
|
||||
highlights.push((start_offset..end_offset, insertion_highlight_style));
|
||||
}
|
||||
}
|
||||
|
||||
HighlightedText {
|
||||
text: text.into(),
|
||||
highlights,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diagnostic_style(severity: lsp::DiagnosticSeverity, colors: &StatusColors) -> Hsla {
|
||||
match severity {
|
||||
lsp::DiagnosticSeverity::ERROR => colors.error,
|
||||
|
||||
@@ -7969,6 +7969,38 @@ async fn test_select_larger_smaller_syntax_node_for_string(cx: &mut TestAppConte
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_unwrap_syntax_node(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(
|
||||
&r#"
|
||||
use mod1::mod2::{«mod3ˇ», mod4};
|
||||
"#
|
||||
.unindent(),
|
||||
);
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.unwrap_syntax_node(&UnwrapSyntaxNode, window, cx);
|
||||
});
|
||||
cx.assert_editor_state(
|
||||
&r#"
|
||||
use mod1::mod2::«mod3ˇ»;
|
||||
"#
|
||||
.unindent(),
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_fold_function_bodies(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
@@ -86,8 +86,6 @@ 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.;
|
||||
|
||||
/// Determines what kinds of highlights should be applied to a lines background.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct LineHighlightSpec {
|
||||
@@ -357,6 +355,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);
|
||||
@@ -2427,10 +2426,13 @@ 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(edit_prediction) = editor.active_edit_prediction.as_ref() {
|
||||
match &edit_prediction.completion {
|
||||
@@ -2468,7 +2470,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;
|
||||
@@ -3681,6 +3683,7 @@ impl EditorElement {
|
||||
.id("path header block")
|
||||
.size_full()
|
||||
.justify_between()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
@@ -8028,12 +8031,20 @@ impl Element for EditorElement {
|
||||
autoscroll_containing_element,
|
||||
needs_horizontal_autoscroll,
|
||||
) = self.editor.update(cx, |editor, cx| {
|
||||
let autoscroll_request = editor.autoscroll_request();
|
||||
let autoscroll_request = editor.scroll_manager.take_autoscroll_request();
|
||||
|
||||
let autoscroll_containing_element =
|
||||
autoscroll_request.is_some() || editor.has_pending_selection();
|
||||
|
||||
let (needs_horizontal_autoscroll, was_scrolled) = editor
|
||||
.autoscroll_vertically(bounds, line_height, max_scroll_top, window, cx);
|
||||
.autoscroll_vertically(
|
||||
bounds,
|
||||
line_height,
|
||||
max_scroll_top,
|
||||
autoscroll_request,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
if was_scrolled.0 {
|
||||
snapshot = editor.snapshot(window, cx);
|
||||
}
|
||||
@@ -8355,7 +8366,13 @@ impl Element for EditorElement {
|
||||
})
|
||||
.flatten()?;
|
||||
let mut element = render_inline_blame_entry(blame_entry, &style, cx)?;
|
||||
let inline_blame_padding = INLINE_BLAME_PADDING_EM_WIDTHS * em_advance;
|
||||
let inline_blame_padding = ProjectSettings::get_global(cx)
|
||||
.git
|
||||
.inline_blame
|
||||
.unwrap_or_default()
|
||||
.padding
|
||||
as f32
|
||||
* em_advance;
|
||||
Some(
|
||||
element
|
||||
.layout_as_root(AvailableSpace::min_size(), window, cx)
|
||||
@@ -8423,7 +8440,11 @@ impl Element for EditorElement {
|
||||
Ok(blocks) => blocks,
|
||||
Err(resized_blocks) => {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.resize_blocks(resized_blocks, autoscroll_request, cx)
|
||||
editor.resize_blocks(
|
||||
resized_blocks,
|
||||
autoscroll_request.map(|(autoscroll, _)| autoscroll),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
return self.prepaint(None, _inspector_id, bounds, &mut (), window, cx);
|
||||
}
|
||||
@@ -8468,6 +8489,7 @@ impl Element for EditorElement {
|
||||
scroll_width,
|
||||
em_advance,
|
||||
&line_layouts,
|
||||
autoscroll_request,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -3546,7 +3546,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, ' ');
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) {
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -191,7 +191,7 @@ 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()
|
||||
|
||||
@@ -158,6 +158,11 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OnFlagsReady {
|
||||
pub is_staff: bool,
|
||||
}
|
||||
|
||||
pub trait FeatureFlagAppExt {
|
||||
fn wait_for_flag<T: FeatureFlag>(&mut self) -> WaitForFlag;
|
||||
|
||||
@@ -169,6 +174,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;
|
||||
@@ -198,6 +207,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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,10 +308,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)
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
strum.workspace = true
|
||||
sum_tree.workspace = true
|
||||
taffy = "=0.8.3"
|
||||
taffy = "=0.9.0"
|
||||
thiserror.workspace = true
|
||||
util.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -79,6 +79,14 @@ impl<T> Task<T> {
|
||||
Task(TaskState::Spawned(task)) => task.detach(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the task has run to completion.
|
||||
pub fn is_finished(&self) -> bool {
|
||||
match self {
|
||||
Task(TaskState::Ready(_)) => true,
|
||||
Task(TaskState::Spawned(task)) => task.is_finished(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E, T> Task<Result<T, E>>
|
||||
|
||||
@@ -20,6 +20,7 @@ anthropic = { workspace = true, features = ["schemars"] }
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
client.workspace = true
|
||||
cloud_api_types.workspace = true
|
||||
cloud_llm_client.workspace = true
|
||||
collections.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
@@ -3,11 +3,9 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use client::Client;
|
||||
use cloud_api_types::websocket_protocol::MessageToClient;
|
||||
use cloud_llm_client::Plan;
|
||||
use gpui::{
|
||||
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, ReadGlobal as _,
|
||||
};
|
||||
use proto::TypedEnvelope;
|
||||
use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _};
|
||||
use smol::lock::{RwLock, RwLockUpgradableReadGuard, RwLockWriteGuard};
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -82,9 +80,7 @@ impl Global for GlobalRefreshLlmTokenListener {}
|
||||
|
||||
pub struct RefreshLlmTokenEvent;
|
||||
|
||||
pub struct RefreshLlmTokenListener {
|
||||
_llm_token_subscription: client::Subscription,
|
||||
}
|
||||
pub struct RefreshLlmTokenListener;
|
||||
|
||||
impl EventEmitter<RefreshLlmTokenEvent> for RefreshLlmTokenListener {}
|
||||
|
||||
@@ -99,17 +95,21 @@ impl RefreshLlmTokenListener {
|
||||
}
|
||||
|
||||
fn new(client: Arc<Client>, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
_llm_token_subscription: client
|
||||
.add_message_handler(cx.weak_entity(), Self::handle_refresh_llm_token),
|
||||
}
|
||||
client.add_message_to_client_handler({
|
||||
let this = cx.entity();
|
||||
move |message, cx| {
|
||||
Self::handle_refresh_llm_token(this.clone(), message, cx);
|
||||
}
|
||||
});
|
||||
|
||||
Self
|
||||
}
|
||||
|
||||
async fn handle_refresh_llm_token(
|
||||
this: Entity<Self>,
|
||||
_: TypedEnvelope<proto::RefreshLlmToken>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<()> {
|
||||
this.update(&mut cx, |_this, cx| cx.emit(RefreshLlmTokenEvent))
|
||||
fn handle_refresh_llm_token(this: Entity<Self>, message: &MessageToClient, cx: &mut App) {
|
||||
match message {
|
||||
MessageToClient::UserUpdated => {
|
||||
this.update(cx, |_this, cx| cx.emit(RefreshLlmTokenEvent));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,6 +674,10 @@ pub fn count_open_ai_tokens(
|
||||
| Model::O3
|
||||
| Model::O3Mini
|
||||
| Model::O4Mini => tiktoken_rs::num_tokens_from_messages(model.id(), &messages),
|
||||
// GPT-5 models don't have tiktoken support yet; fall back on gpt-4o tokenizer
|
||||
Model::Five | Model::FiveMini | Model::FiveNano => {
|
||||
tiktoken_rs::num_tokens_from_messages("gpt-4o", &messages)
|
||||
}
|
||||
}
|
||||
.map(|tokens| tokens as u64)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name = "C++"
|
||||
grammar = "cpp"
|
||||
path_suffixes = ["cc", "hh", "cpp", "h", "hpp", "cxx", "hxx", "c++", "ipp", "inl", "ixx", "cu", "cuh", "C", "H"]
|
||||
path_suffixes = ["cc", "hh", "cpp", "h", "hpp", "cxx", "hxx", "c++", "ipp", "inl", "ino", "ixx", "cu", "cuh", "C", "H"]
|
||||
line_comments = ["// ", "/// ", "//! "]
|
||||
decrease_indent_patterns = [
|
||||
{ pattern = "^\\s*\\{.*\\}?\\s*$", valid_after = ["if", "for", "while", "do", "switch", "else"] },
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"&&="
|
||||
"||="
|
||||
"??="
|
||||
"..."
|
||||
] @operator
|
||||
|
||||
(regex "/" @string.regex)
|
||||
|
||||
@@ -305,66 +305,63 @@ impl LspAdapter for RustLspAdapter {
|
||||
completion: &lsp::CompletionItem,
|
||||
language: &Arc<Language>,
|
||||
) -> Option<CodeLabel> {
|
||||
let detail = completion
|
||||
// rust-analyzer calls these detail left and detail right in terms of where it expects things to be rendered
|
||||
// this usually contains signatures of the thing to be completed
|
||||
let detail_right = completion
|
||||
.label_details
|
||||
.as_ref()
|
||||
.and_then(|detail| detail.detail.as_ref())
|
||||
.and_then(|detail| detail.description.as_ref())
|
||||
.or(completion.detail.as_ref())
|
||||
.map(|detail| detail.trim());
|
||||
let function_signature = completion
|
||||
// this tends to contain alias and import information
|
||||
let detail_left = completion
|
||||
.label_details
|
||||
.as_ref()
|
||||
.and_then(|detail| detail.description.as_deref())
|
||||
.or(completion.detail.as_deref());
|
||||
match (detail, completion.kind) {
|
||||
(Some(detail), Some(lsp::CompletionItemKind::FIELD)) => {
|
||||
.and_then(|detail| detail.detail.as_deref());
|
||||
let mk_label = |text: String, runs| {
|
||||
let filter_range = completion
|
||||
.filter_text
|
||||
.as_deref()
|
||||
.and_then(|filter| {
|
||||
completion
|
||||
.label
|
||||
.find(filter)
|
||||
.map(|ix| ix..ix + filter.len())
|
||||
})
|
||||
.unwrap_or(0..completion.label.len());
|
||||
|
||||
CodeLabel {
|
||||
text,
|
||||
runs,
|
||||
filter_range,
|
||||
}
|
||||
};
|
||||
let mut label = match (detail_right, completion.kind) {
|
||||
(Some(signature), Some(lsp::CompletionItemKind::FIELD)) => {
|
||||
let name = &completion.label;
|
||||
let text = format!("{name}: {detail}");
|
||||
let text = format!("{name}: {signature}");
|
||||
let prefix = "struct S { ";
|
||||
let source = Rope::from(format!("{prefix}{text} }}"));
|
||||
let source = Rope::from_iter([prefix, &text, " }"]);
|
||||
let runs =
|
||||
language.highlight_text(&source, prefix.len()..prefix.len() + text.len());
|
||||
let filter_range = completion
|
||||
.filter_text
|
||||
.as_deref()
|
||||
.and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
|
||||
.unwrap_or(0..name.len());
|
||||
return Some(CodeLabel {
|
||||
text,
|
||||
runs,
|
||||
filter_range,
|
||||
});
|
||||
mk_label(text, runs)
|
||||
}
|
||||
(
|
||||
Some(detail),
|
||||
Some(signature),
|
||||
Some(lsp::CompletionItemKind::CONSTANT | lsp::CompletionItemKind::VARIABLE),
|
||||
) if completion.insert_text_format != Some(lsp::InsertTextFormat::SNIPPET) => {
|
||||
let name = &completion.label;
|
||||
let text = format!(
|
||||
"{}: {}",
|
||||
name,
|
||||
completion.detail.as_deref().unwrap_or(detail)
|
||||
);
|
||||
let text = format!("{name}: {signature}",);
|
||||
let prefix = "let ";
|
||||
let source = Rope::from(format!("{prefix}{text} = ();"));
|
||||
let source = Rope::from_iter([prefix, &text, " = ();"]);
|
||||
let runs =
|
||||
language.highlight_text(&source, prefix.len()..prefix.len() + text.len());
|
||||
let filter_range = completion
|
||||
.filter_text
|
||||
.as_deref()
|
||||
.and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
|
||||
.unwrap_or(0..name.len());
|
||||
return Some(CodeLabel {
|
||||
text,
|
||||
runs,
|
||||
filter_range,
|
||||
});
|
||||
mk_label(text, runs)
|
||||
}
|
||||
(
|
||||
Some(detail),
|
||||
function_signature,
|
||||
Some(lsp::CompletionItemKind::FUNCTION | lsp::CompletionItemKind::METHOD),
|
||||
) => {
|
||||
static REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new("\\(…?\\)").unwrap());
|
||||
const FUNCTION_PREFIXES: [&str; 6] = [
|
||||
"async fn",
|
||||
"async unsafe fn",
|
||||
@@ -373,34 +370,27 @@ impl LspAdapter for RustLspAdapter {
|
||||
"unsafe fn",
|
||||
"fn",
|
||||
];
|
||||
// Is it function `async`?
|
||||
let fn_keyword = FUNCTION_PREFIXES.iter().find_map(|prefix| {
|
||||
function_signature.as_ref().and_then(|signature| {
|
||||
signature
|
||||
.strip_prefix(*prefix)
|
||||
.map(|suffix| (*prefix, suffix))
|
||||
})
|
||||
let fn_prefixed = FUNCTION_PREFIXES.iter().find_map(|&prefix| {
|
||||
function_signature?
|
||||
.strip_prefix(prefix)
|
||||
.map(|suffix| (prefix, suffix))
|
||||
});
|
||||
// fn keyword should be followed by opening parenthesis.
|
||||
if let Some((prefix, suffix)) = fn_keyword {
|
||||
let mut text = REGEX.replace(&completion.label, suffix).to_string();
|
||||
let source = Rope::from(format!("{prefix} {text} {{}}"));
|
||||
if let Some((prefix, suffix)) = fn_prefixed {
|
||||
let label = if let Some(label) = completion
|
||||
.label
|
||||
.strip_suffix("(…)")
|
||||
.or_else(|| completion.label.strip_suffix("()"))
|
||||
{
|
||||
label
|
||||
} else {
|
||||
&completion.label
|
||||
};
|
||||
let text = format!("{label}{suffix}");
|
||||
let source = Rope::from_iter([prefix, " ", &text, " {}"]);
|
||||
let run_start = prefix.len() + 1;
|
||||
let runs = language.highlight_text(&source, run_start..run_start + text.len());
|
||||
if detail.starts_with("(") {
|
||||
text.push(' ');
|
||||
text.push_str(&detail);
|
||||
}
|
||||
let filter_range = completion
|
||||
.filter_text
|
||||
.as_deref()
|
||||
.and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
|
||||
.unwrap_or(0..completion.label.find('(').unwrap_or(text.len()));
|
||||
return Some(CodeLabel {
|
||||
filter_range,
|
||||
text,
|
||||
runs,
|
||||
});
|
||||
mk_label(text, runs)
|
||||
} else if completion
|
||||
.detail
|
||||
.as_ref()
|
||||
@@ -410,20 +400,13 @@ impl LspAdapter for RustLspAdapter {
|
||||
let len = text.len();
|
||||
let source = Rope::from(text.as_str());
|
||||
let runs = language.highlight_text(&source, 0..len);
|
||||
let filter_range = completion
|
||||
.filter_text
|
||||
.as_deref()
|
||||
.and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
|
||||
.unwrap_or(0..len);
|
||||
return Some(CodeLabel {
|
||||
filter_range,
|
||||
text,
|
||||
runs,
|
||||
});
|
||||
mk_label(text, runs)
|
||||
} else {
|
||||
mk_label(completion.label.clone(), vec![])
|
||||
}
|
||||
}
|
||||
(_, Some(kind)) => {
|
||||
let highlight_name = match kind {
|
||||
(_, kind) => {
|
||||
let highlight_name = kind.and_then(|kind| match kind {
|
||||
lsp::CompletionItemKind::STRUCT
|
||||
| lsp::CompletionItemKind::INTERFACE
|
||||
| lsp::CompletionItemKind::ENUM => Some("type"),
|
||||
@@ -433,27 +416,32 @@ impl LspAdapter for RustLspAdapter {
|
||||
Some("constant")
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
});
|
||||
|
||||
let mut label = completion.label.clone();
|
||||
if let Some(detail) = detail.filter(|detail| detail.starts_with("(")) {
|
||||
label.push(' ');
|
||||
label.push_str(detail);
|
||||
}
|
||||
let mut label = CodeLabel::plain(label, completion.filter_text.as_deref());
|
||||
let label = completion.label.clone();
|
||||
let mut runs = vec![];
|
||||
if let Some(highlight_name) = highlight_name {
|
||||
let highlight_id = language.grammar()?.highlight_id_for_name(highlight_name)?;
|
||||
label.runs.push((
|
||||
0..label.text.rfind('(').unwrap_or(completion.label.len()),
|
||||
runs.push((
|
||||
0..label.rfind('(').unwrap_or(completion.label.len()),
|
||||
highlight_id,
|
||||
));
|
||||
}
|
||||
|
||||
return Some(label);
|
||||
mk_label(label, runs)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(detail_left) = detail_left {
|
||||
label.text.push(' ');
|
||||
if !detail_left.starts_with('(') {
|
||||
label.text.push('(');
|
||||
}
|
||||
label.text.push_str(detail_left);
|
||||
if !detail_left.ends_with(')') {
|
||||
label.text.push(')');
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
Some(label)
|
||||
}
|
||||
|
||||
async fn label_for_symbol(
|
||||
@@ -462,55 +450,22 @@ impl LspAdapter for RustLspAdapter {
|
||||
kind: lsp::SymbolKind,
|
||||
language: &Arc<Language>,
|
||||
) -> Option<CodeLabel> {
|
||||
let (text, filter_range, display_range) = match kind {
|
||||
lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
|
||||
let text = format!("fn {} () {{}}", name);
|
||||
let filter_range = 3..3 + name.len();
|
||||
let display_range = 0..filter_range.end;
|
||||
(text, filter_range, display_range)
|
||||
}
|
||||
lsp::SymbolKind::STRUCT => {
|
||||
let text = format!("struct {} {{}}", name);
|
||||
let filter_range = 7..7 + name.len();
|
||||
let display_range = 0..filter_range.end;
|
||||
(text, filter_range, display_range)
|
||||
}
|
||||
lsp::SymbolKind::ENUM => {
|
||||
let text = format!("enum {} {{}}", name);
|
||||
let filter_range = 5..5 + name.len();
|
||||
let display_range = 0..filter_range.end;
|
||||
(text, filter_range, display_range)
|
||||
}
|
||||
lsp::SymbolKind::INTERFACE => {
|
||||
let text = format!("trait {} {{}}", name);
|
||||
let filter_range = 6..6 + name.len();
|
||||
let display_range = 0..filter_range.end;
|
||||
(text, filter_range, display_range)
|
||||
}
|
||||
lsp::SymbolKind::CONSTANT => {
|
||||
let text = format!("const {}: () = ();", name);
|
||||
let filter_range = 6..6 + name.len();
|
||||
let display_range = 0..filter_range.end;
|
||||
(text, filter_range, display_range)
|
||||
}
|
||||
lsp::SymbolKind::MODULE => {
|
||||
let text = format!("mod {} {{}}", name);
|
||||
let filter_range = 4..4 + name.len();
|
||||
let display_range = 0..filter_range.end;
|
||||
(text, filter_range, display_range)
|
||||
}
|
||||
lsp::SymbolKind::TYPE_PARAMETER => {
|
||||
let text = format!("type {} {{}}", name);
|
||||
let filter_range = 5..5 + name.len();
|
||||
let display_range = 0..filter_range.end;
|
||||
(text, filter_range, display_range)
|
||||
}
|
||||
let (prefix, suffix) = match kind {
|
||||
lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => ("fn ", " () {}"),
|
||||
lsp::SymbolKind::STRUCT => ("struct ", " {}"),
|
||||
lsp::SymbolKind::ENUM => ("enum ", " {}"),
|
||||
lsp::SymbolKind::INTERFACE => ("trait ", " {}"),
|
||||
lsp::SymbolKind::CONSTANT => ("const ", ": () = ();"),
|
||||
lsp::SymbolKind::MODULE => ("mod ", " {}"),
|
||||
lsp::SymbolKind::TYPE_PARAMETER => ("type ", " {}"),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let filter_range = prefix.len()..prefix.len() + name.len();
|
||||
let display_range = 0..filter_range.end;
|
||||
Some(CodeLabel {
|
||||
runs: language.highlight_text(&text.as_str().into(), display_range.clone()),
|
||||
text: text[display_range].to_string(),
|
||||
runs: language.highlight_text(&Rope::from_iter([prefix, name, suffix]), display_range),
|
||||
text: format!("{prefix}{name}"),
|
||||
filter_range,
|
||||
})
|
||||
}
|
||||
@@ -1169,7 +1124,7 @@ mod tests {
|
||||
.await,
|
||||
Some(CodeLabel {
|
||||
text: "hello(&mut Option<T>) -> Vec<T> (use crate::foo)".to_string(),
|
||||
filter_range: 0..5,
|
||||
filter_range: 0..10,
|
||||
runs: vec![
|
||||
(0..5, highlight_function),
|
||||
(7..10, highlight_keyword),
|
||||
@@ -1187,7 +1142,7 @@ mod tests {
|
||||
kind: Some(lsp::CompletionItemKind::FUNCTION),
|
||||
label: "hello(…)".to_string(),
|
||||
label_details: Some(CompletionItemLabelDetails {
|
||||
detail: Some(" (use crate::foo)".into()),
|
||||
detail: Some("(use crate::foo)".into()),
|
||||
description: Some("async fn(&mut Option<T>) -> Vec<T>".to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
@@ -1197,7 +1152,7 @@ mod tests {
|
||||
.await,
|
||||
Some(CodeLabel {
|
||||
text: "hello(&mut Option<T>) -> Vec<T> (use crate::foo)".to_string(),
|
||||
filter_range: 0..5,
|
||||
filter_range: 0..10,
|
||||
runs: vec![
|
||||
(0..5, highlight_function),
|
||||
(7..10, highlight_keyword),
|
||||
@@ -1234,7 +1189,7 @@ mod tests {
|
||||
kind: Some(lsp::CompletionItemKind::FUNCTION),
|
||||
label: "hello(…)".to_string(),
|
||||
label_details: Some(CompletionItemLabelDetails {
|
||||
detail: Some(" (use crate::foo)".to_string()),
|
||||
detail: Some("(use crate::foo)".to_string()),
|
||||
description: Some("fn(&mut Option<T>) -> Vec<T>".to_string()),
|
||||
}),
|
||||
|
||||
@@ -1243,6 +1198,35 @@ mod tests {
|
||||
&language
|
||||
)
|
||||
.await,
|
||||
Some(CodeLabel {
|
||||
text: "hello(&mut Option<T>) -> Vec<T> (use crate::foo)".to_string(),
|
||||
filter_range: 0..10,
|
||||
runs: vec![
|
||||
(0..5, highlight_function),
|
||||
(7..10, highlight_keyword),
|
||||
(11..17, highlight_type),
|
||||
(18..19, highlight_type),
|
||||
(25..28, highlight_type),
|
||||
(29..30, highlight_type),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
adapter
|
||||
.label_for_completion(
|
||||
&lsp::CompletionItem {
|
||||
kind: Some(lsp::CompletionItemKind::FUNCTION),
|
||||
label: "hello".to_string(),
|
||||
label_details: Some(CompletionItemLabelDetails {
|
||||
detail: Some("(use crate::foo)".to_string()),
|
||||
description: Some("fn(&mut Option<T>) -> Vec<T>".to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
&language
|
||||
)
|
||||
.await,
|
||||
Some(CodeLabel {
|
||||
text: "hello(&mut Option<T>) -> Vec<T> (use crate::foo)".to_string(),
|
||||
filter_range: 0..5,
|
||||
@@ -1274,9 +1258,14 @@ mod tests {
|
||||
)
|
||||
.await,
|
||||
Some(CodeLabel {
|
||||
text: "await.as_deref_mut()".to_string(),
|
||||
text: "await.as_deref_mut(&mut self) -> IterMut<'_, T>".to_string(),
|
||||
filter_range: 6..18,
|
||||
runs: vec![],
|
||||
runs: vec![
|
||||
(6..18, HighlightId(2)),
|
||||
(20..23, HighlightId(1)),
|
||||
(33..40, HighlightId(0)),
|
||||
(45..46, HighlightId(0))
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"&&="
|
||||
"||="
|
||||
"??="
|
||||
"..."
|
||||
] @operator
|
||||
|
||||
(regex "/" @string.regex)
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
"&&="
|
||||
"||="
|
||||
"??="
|
||||
"..."
|
||||
] @operator
|
||||
|
||||
(regex "/" @string.regex)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ai_onboarding::{AiUpsellCard, SignInStatus};
|
||||
use client::UserStore;
|
||||
use ai_onboarding::AiUpsellCard;
|
||||
use client::{Client, UserStore};
|
||||
use fs::Fs;
|
||||
use gpui::{
|
||||
Action, AnyView, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity,
|
||||
@@ -12,8 +12,8 @@ use language_model::{LanguageModelProvider, LanguageModelProviderId, LanguageMod
|
||||
use project::DisableAiSettings;
|
||||
use settings::{Settings, update_settings_file};
|
||||
use ui::{
|
||||
Badge, ButtonLike, Divider, Modal, ModalFooter, ModalHeader, Section, SwitchField, ToggleState,
|
||||
prelude::*, tooltip_container,
|
||||
Badge, ButtonLike, Divider, KeyBinding, Modal, ModalFooter, ModalHeader, Section, SwitchField,
|
||||
ToggleState, prelude::*, tooltip_container,
|
||||
};
|
||||
use util::ResultExt;
|
||||
use workspace::{ModalView, Workspace};
|
||||
@@ -88,7 +88,7 @@ fn render_privacy_card(tab_index: &mut isize, disabled: bool, cx: &mut App) -> i
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.child(Label::new("We don't train models using your data"))
|
||||
.child(Label::new("Privacy is the default for Zed"))
|
||||
.child(
|
||||
h_flex().gap_1().child(privacy_badge()).child(
|
||||
Button::new("learn_more", "Learn More")
|
||||
@@ -109,7 +109,7 @@ fn render_privacy_card(tab_index: &mut isize, disabled: bool, cx: &mut App) -> i
|
||||
)
|
||||
.child(
|
||||
Label::new(
|
||||
"Feel confident in the security and privacy of your projects using Zed.",
|
||||
"Any use or storage of your data is with your explicit, single-use, opt-in consent.",
|
||||
)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
@@ -240,6 +240,7 @@ fn render_llm_provider_card(
|
||||
pub(crate) fn render_ai_setup_page(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
user_store: Entity<UserStore>,
|
||||
client: Arc<Client>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> impl IntoElement {
|
||||
@@ -283,14 +284,16 @@ pub(crate) fn render_ai_setup_page(
|
||||
v_flex()
|
||||
.mt_2()
|
||||
.gap_6()
|
||||
.child(AiUpsellCard {
|
||||
sign_in_status: SignInStatus::SignedIn,
|
||||
sign_in: Arc::new(|_, _| {}),
|
||||
user_plan: user_store.read(cx).plan(),
|
||||
tab_index: Some({
|
||||
.child({
|
||||
let mut ai_upsell_card =
|
||||
AiUpsellCard::new(client, &user_store, user_store.read(cx).plan(), cx);
|
||||
|
||||
ai_upsell_card.tab_index = Some({
|
||||
tab_index += 1;
|
||||
tab_index - 1
|
||||
}),
|
||||
});
|
||||
|
||||
ai_upsell_card
|
||||
})
|
||||
.child(render_llm_provider_section(
|
||||
&mut tab_index,
|
||||
@@ -335,6 +338,10 @@ impl AiConfigurationModal {
|
||||
selected_provider,
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context<Self>) {
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
}
|
||||
|
||||
impl ModalView for AiConfigurationModal {}
|
||||
@@ -348,11 +355,15 @@ impl Focusable for AiConfigurationModal {
|
||||
}
|
||||
|
||||
impl Render for AiConfigurationModal {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.key_context("OnboardingAiConfigurationModal")
|
||||
.w(rems(34.))
|
||||
.elevation_3(cx)
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(
|
||||
cx.listener(|this, _: &menu::Cancel, _window, cx| this.cancel(&menu::Cancel, cx)),
|
||||
)
|
||||
.child(
|
||||
Modal::new("onboarding-ai-setup-modal", None)
|
||||
.header(
|
||||
@@ -367,18 +378,19 @@ impl Render for AiConfigurationModal {
|
||||
.section(Section::new().child(self.configuration_view.clone()))
|
||||
.footer(
|
||||
ModalFooter::new().end_slot(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("onboarding-closing-cancel", "Cancel")
|
||||
.on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
|
||||
Button::new("ai-onb-modal-Done", "Done")
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(
|
||||
&menu::Cancel,
|
||||
&self.focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
)
|
||||
.child(Button::new("save-btn", "Done").on_click(cx.listener(
|
||||
|_, _, window, cx| {
|
||||
window.dispatch_action(menu::Confirm.boxed_clone(), cx);
|
||||
cx.emit(DismissEvent);
|
||||
},
|
||||
))),
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.cancel(&menu::Cancel, cx)
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -395,7 +407,7 @@ impl AiPrivacyTooltip {
|
||||
|
||||
impl Render for AiPrivacyTooltip {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const DESCRIPTION: &'static str = "One of Zed's most important principles is transparency. This is why we are and value open-source so much. And it wouldn't be any different with AI.";
|
||||
const DESCRIPTION: &'static str = "We believe in opt-in data sharing as the default for building AI products, rather than opt-out. We'll only use or store your data if you affirmatively send it to us. ";
|
||||
|
||||
tooltip_container(window, cx, move |this, _, _| {
|
||||
this.child(
|
||||
@@ -406,7 +418,7 @@ impl Render for AiPrivacyTooltip {
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(Label::new("Privacy Principle")),
|
||||
.child(Label::new("Privacy First")),
|
||||
)
|
||||
.child(
|
||||
div().max_w_64().child(
|
||||
|
||||
@@ -201,12 +201,15 @@ fn render_telemetry_section(tab_index: &mut isize, cx: &App) -> impl IntoElement
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
|
||||
v_flex()
|
||||
.pt_6()
|
||||
.gap_4()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant.opacity(0.5))
|
||||
.child(Label::new("Telemetry").size(LabelSize::Large))
|
||||
.child(SwitchField::new(
|
||||
"onboarding-telemetry-metrics",
|
||||
"Help Improve Zed",
|
||||
Some("Sending anonymous usage data helps us build the right features and create the best experience.".into()),
|
||||
Some("Anonymous usage data helps us build the right features and improve your experience.".into()),
|
||||
if TelemetrySettings::get_global(cx).metrics {
|
||||
ui::ToggleState::Selected
|
||||
} else {
|
||||
@@ -294,7 +297,7 @@ fn render_base_keymap_section(tab_index: &mut isize, cx: &mut App) -> impl IntoE
|
||||
ToggleButtonWithIcon::new("Emacs", IconName::EditorEmacs, |_, _, cx| {
|
||||
write_keymap_base(BaseKeymap::Emacs, cx);
|
||||
}),
|
||||
ToggleButtonWithIcon::new("Cursor (Beta)", IconName::EditorCursor, |_, _, cx| {
|
||||
ToggleButtonWithIcon::new("Cursor", IconName::EditorCursor, |_, _, cx| {
|
||||
write_keymap_base(BaseKeymap::Cursor, cx);
|
||||
}),
|
||||
],
|
||||
@@ -326,10 +329,7 @@ fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoEleme
|
||||
SwitchField::new(
|
||||
"onboarding-vim-mode",
|
||||
"Vim Mode",
|
||||
Some(
|
||||
"Coming from Neovim? Zed's first-class implementation of Vim Mode has got your back."
|
||||
.into(),
|
||||
),
|
||||
Some("Coming from Neovim? Use our first-class implementation of Vim Mode.".into()),
|
||||
toggle_state,
|
||||
{
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
|
||||
@@ -584,11 +584,15 @@ fn render_popular_settings_section(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> impl IntoElement {
|
||||
const LIGATURE_TOOLTIP: &'static str = "Ligatures are when a font creates a special character out of combining two characters into one. For example, with ligatures turned on, =/= would become ≠.";
|
||||
const LIGATURE_TOOLTIP: &'static str =
|
||||
"Font ligatures combine two characters into one. For example, turning =/= into ≠.";
|
||||
|
||||
v_flex()
|
||||
.gap_5()
|
||||
.child(Label::new("Popular Settings").size(LabelSize::Large).mt_8())
|
||||
.pt_6()
|
||||
.gap_4()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant.opacity(0.5))
|
||||
.child(Label::new("Popular Settings").size(LabelSize::Large))
|
||||
.child(render_font_customization_section(tab_index, window, cx))
|
||||
.child(
|
||||
SwitchField::new(
|
||||
@@ -683,7 +687,10 @@ fn render_popular_settings_section(
|
||||
[
|
||||
ToggleButtonSimple::new("Auto", |_, _, cx| {
|
||||
write_show_mini_map(ShowMinimap::Auto, cx);
|
||||
}),
|
||||
})
|
||||
.tooltip(Tooltip::text(
|
||||
"Show the minimap if the editor's scrollbar is visible.",
|
||||
)),
|
||||
ToggleButtonSimple::new("Always", |_, _, cx| {
|
||||
write_show_mini_map(ShowMinimap::Always, cx);
|
||||
}),
|
||||
@@ -707,7 +714,7 @@ fn render_popular_settings_section(
|
||||
pub(crate) fn render_editing_page(window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let mut tab_index = 0;
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.gap_6()
|
||||
.child(render_import_settings_section(&mut tab_index, cx))
|
||||
.child(render_popular_settings_section(&mut tab_index, window, cx))
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ actions!(
|
||||
ActivateAISetupPage,
|
||||
/// Finish the onboarding process.
|
||||
Finish,
|
||||
/// Sign in while in the onboarding flow.
|
||||
SignIn
|
||||
]
|
||||
);
|
||||
|
||||
@@ -376,6 +378,7 @@ impl Onboarding {
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(12.)));
|
||||
|
||||
if ai_setup_page {
|
||||
this.child(
|
||||
ButtonLike::new("start_building")
|
||||
@@ -387,14 +390,7 @@ impl Onboarding {
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.child(Label::new("Start Building"))
|
||||
.child(keybinding.map_or_else(
|
||||
|| {
|
||||
Icon::new(IconName::Check)
|
||||
.size(IconSize::Small)
|
||||
.into_any_element()
|
||||
},
|
||||
IntoElement::into_any_element,
|
||||
)),
|
||||
.children(keybinding),
|
||||
)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Finish.boxed_clone(), cx);
|
||||
@@ -409,11 +405,10 @@ impl Onboarding {
|
||||
.ml_1()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.child(Label::new("Skip All"))
|
||||
.child(keybinding.map_or_else(
|
||||
|| gpui::Empty.into_any_element(),
|
||||
IntoElement::into_any_element,
|
||||
)),
|
||||
.child(
|
||||
Label::new("Skip All").color(Color::Muted),
|
||||
)
|
||||
.children(keybinding),
|
||||
)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Finish.boxed_clone(), cx);
|
||||
@@ -435,23 +430,39 @@ impl Onboarding {
|
||||
Button::new("sign_in", "Sign In")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Outlined)
|
||||
.size(ButtonSize::Medium)
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(&SignIn, &self.focus_handle, window, cx)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
)
|
||||
.on_click(|_, window, cx| {
|
||||
let client = Client::global(cx);
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
client
|
||||
.sign_in_with_optional_connect(true, &cx)
|
||||
.await
|
||||
.notify_async_err(cx);
|
||||
})
|
||||
.detach();
|
||||
window.dispatch_action(SignIn.boxed_clone(), cx);
|
||||
})
|
||||
.into_any_element()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) {
|
||||
go_to_welcome_page(cx);
|
||||
}
|
||||
|
||||
fn handle_sign_in(_: &SignIn, window: &mut Window, cx: &mut App) {
|
||||
let client = Client::global(cx);
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
client
|
||||
.sign_in_with_optional_connect(true, &cx)
|
||||
.await
|
||||
.notify_async_err(cx);
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let client = Client::global(cx);
|
||||
|
||||
match self.selected_page {
|
||||
SelectedPage::Basics => crate::basics_page::render_basics_page(cx).into_any_element(),
|
||||
SelectedPage::Editing => {
|
||||
@@ -460,16 +471,13 @@ impl Onboarding {
|
||||
SelectedPage::AiSetup => crate::ai_setup_page::render_ai_setup_page(
|
||||
self.workspace.clone(),
|
||||
self.user_store.clone(),
|
||||
client,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) {
|
||||
go_to_welcome_page(cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Onboarding {
|
||||
@@ -486,6 +494,7 @@ impl Render for Onboarding {
|
||||
.size_full()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.on_action(Self::on_finish)
|
||||
.on_action(Self::handle_sign_in)
|
||||
.on_action(cx.listener(|this, _: &ActivateBasicsPage, _, cx| {
|
||||
this.set_page(SelectedPage::Basics, cx);
|
||||
}))
|
||||
|
||||
@@ -74,6 +74,12 @@ pub enum Model {
|
||||
O3,
|
||||
#[serde(rename = "o4-mini")]
|
||||
O4Mini,
|
||||
#[serde(rename = "gpt-5")]
|
||||
Five,
|
||||
#[serde(rename = "gpt-5-mini")]
|
||||
FiveMini,
|
||||
#[serde(rename = "gpt-5-nano")]
|
||||
FiveNano,
|
||||
|
||||
#[serde(rename = "custom")]
|
||||
Custom {
|
||||
@@ -105,6 +111,9 @@ impl Model {
|
||||
"o3-mini" => Ok(Self::O3Mini),
|
||||
"o3" => Ok(Self::O3),
|
||||
"o4-mini" => Ok(Self::O4Mini),
|
||||
"gpt-5" => Ok(Self::Five),
|
||||
"gpt-5-mini" => Ok(Self::FiveMini),
|
||||
"gpt-5-nano" => Ok(Self::FiveNano),
|
||||
invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"),
|
||||
}
|
||||
}
|
||||
@@ -123,6 +132,9 @@ impl Model {
|
||||
Self::O3Mini => "o3-mini",
|
||||
Self::O3 => "o3",
|
||||
Self::O4Mini => "o4-mini",
|
||||
Self::Five => "gpt-5",
|
||||
Self::FiveMini => "gpt-5-mini",
|
||||
Self::FiveNano => "gpt-5-nano",
|
||||
Self::Custom { name, .. } => name,
|
||||
}
|
||||
}
|
||||
@@ -141,6 +153,9 @@ impl Model {
|
||||
Self::O3Mini => "o3-mini",
|
||||
Self::O3 => "o3",
|
||||
Self::O4Mini => "o4-mini",
|
||||
Self::Five => "gpt-5",
|
||||
Self::FiveMini => "gpt-5-mini",
|
||||
Self::FiveNano => "gpt-5-nano",
|
||||
Self::Custom {
|
||||
name, display_name, ..
|
||||
} => display_name.as_ref().unwrap_or(name),
|
||||
@@ -161,6 +176,9 @@ impl Model {
|
||||
Self::O3Mini => 200_000,
|
||||
Self::O3 => 200_000,
|
||||
Self::O4Mini => 200_000,
|
||||
Self::Five => 272_000,
|
||||
Self::FiveMini => 272_000,
|
||||
Self::FiveNano => 272_000,
|
||||
Self::Custom { max_tokens, .. } => *max_tokens,
|
||||
}
|
||||
}
|
||||
@@ -182,6 +200,9 @@ impl Model {
|
||||
Self::O3Mini => Some(100_000),
|
||||
Self::O3 => Some(100_000),
|
||||
Self::O4Mini => Some(100_000),
|
||||
Self::Five => Some(128_000),
|
||||
Self::FiveMini => Some(128_000),
|
||||
Self::FiveNano => Some(128_000),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +218,10 @@ impl Model {
|
||||
| Self::FourOmniMini
|
||||
| Self::FourPointOne
|
||||
| Self::FourPointOneMini
|
||||
| Self::FourPointOneNano => true,
|
||||
| Self::FourPointOneNano
|
||||
| Self::Five
|
||||
| Self::FiveMini
|
||||
| Self::FiveNano => true,
|
||||
Self::O1 | Self::O3 | Self::O3Mini | Self::O4Mini | Model::Custom { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,20 @@ impl FormatTrigger {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DocumentDiagnosticsUpdate<'a, D> {
|
||||
pub diagnostics: D,
|
||||
pub result_id: Option<String>,
|
||||
pub server_id: LanguageServerId,
|
||||
pub disk_based_sources: Cow<'a, [String]>,
|
||||
}
|
||||
|
||||
pub struct DocumentDiagnostics {
|
||||
diagnostics: Vec<DiagnosticEntry<Unclipped<PointUtf16>>>,
|
||||
document_abs_path: PathBuf,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
pub struct LocalLspStore {
|
||||
weak: WeakEntity<LspStore>,
|
||||
worktree_store: Entity<WorktreeStore>,
|
||||
@@ -503,12 +517,16 @@ impl LocalLspStore {
|
||||
adapter.process_diagnostics(&mut params, server_id, buffer);
|
||||
}
|
||||
|
||||
this.merge_diagnostics(
|
||||
server_id,
|
||||
params,
|
||||
None,
|
||||
this.merge_lsp_diagnostics(
|
||||
DiagnosticSourceKind::Pushed,
|
||||
&adapter.disk_based_diagnostic_sources,
|
||||
vec![DocumentDiagnosticsUpdate {
|
||||
server_id,
|
||||
diagnostics: params,
|
||||
result_id: None,
|
||||
disk_based_sources: Cow::Borrowed(
|
||||
&adapter.disk_based_diagnostic_sources,
|
||||
),
|
||||
}],
|
||||
|_, diagnostic, cx| match diagnostic.source_kind {
|
||||
DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => {
|
||||
adapter.retain_old_diagnostic(diagnostic, cx)
|
||||
@@ -3610,8 +3628,8 @@ pub enum LspStoreEvent {
|
||||
RefreshInlayHints,
|
||||
RefreshCodeLens,
|
||||
DiagnosticsUpdated {
|
||||
language_server_id: LanguageServerId,
|
||||
path: ProjectPath,
|
||||
server_id: LanguageServerId,
|
||||
paths: Vec<ProjectPath>,
|
||||
},
|
||||
DiskBasedDiagnosticsStarted {
|
||||
language_server_id: LanguageServerId,
|
||||
@@ -4440,17 +4458,24 @@ impl LspStore {
|
||||
|
||||
pub(crate) fn send_diagnostic_summaries(&self, worktree: &mut Worktree) {
|
||||
if let Some((client, downstream_project_id)) = self.downstream_client.clone() {
|
||||
if let Some(summaries) = self.diagnostic_summaries.get(&worktree.id()) {
|
||||
for (path, summaries) in summaries {
|
||||
for (&server_id, summary) in summaries {
|
||||
client
|
||||
.send(proto::UpdateDiagnosticSummary {
|
||||
project_id: downstream_project_id,
|
||||
worktree_id: worktree.id().to_proto(),
|
||||
summary: Some(summary.to_proto(server_id, path)),
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
if let Some(diangostic_summaries) = self.diagnostic_summaries.get(&worktree.id()) {
|
||||
let mut summaries =
|
||||
diangostic_summaries
|
||||
.into_iter()
|
||||
.flat_map(|(path, summaries)| {
|
||||
summaries
|
||||
.into_iter()
|
||||
.map(|(server_id, summary)| summary.to_proto(*server_id, path))
|
||||
});
|
||||
if let Some(summary) = summaries.next() {
|
||||
client
|
||||
.send(proto::UpdateDiagnosticSummary {
|
||||
project_id: downstream_project_id,
|
||||
worktree_id: worktree.id().to_proto(),
|
||||
summary: Some(summary),
|
||||
more_summaries: summaries.collect(),
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6564,7 +6589,7 @@ impl LspStore {
|
||||
&mut self,
|
||||
buffer: Entity<Buffer>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Vec<LspPullDiagnostics>>> {
|
||||
) -> Task<Result<Option<Vec<LspPullDiagnostics>>>> {
|
||||
let buffer_id = buffer.read(cx).remote_id();
|
||||
|
||||
if let Some((client, upstream_project_id)) = self.upstream_client() {
|
||||
@@ -6575,7 +6600,7 @@ impl LspStore {
|
||||
},
|
||||
cx,
|
||||
) {
|
||||
return Task::ready(Ok(Vec::new()));
|
||||
return Task::ready(Ok(None));
|
||||
}
|
||||
let request_task = client.request(proto::MultiLspQuery {
|
||||
buffer_id: buffer_id.to_proto(),
|
||||
@@ -6593,7 +6618,7 @@ impl LspStore {
|
||||
)),
|
||||
});
|
||||
cx.background_spawn(async move {
|
||||
Ok(request_task
|
||||
let _proto_responses = request_task
|
||||
.await?
|
||||
.responses
|
||||
.into_iter()
|
||||
@@ -6606,8 +6631,11 @@ impl LspStore {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flat_map(GetDocumentDiagnostics::diagnostics_from_proto)
|
||||
.collect())
|
||||
.collect::<Vec<_>>();
|
||||
// Proto requests cause the diagnostics to be pulled from language server(s) on the local side
|
||||
// and then, buffer state updated with the diagnostics received, which will be later propagated to the client.
|
||||
// Do not attempt to further process the dummy responses here.
|
||||
Ok(None)
|
||||
})
|
||||
} else {
|
||||
let server_ids = buffer.update(cx, |buffer, cx| {
|
||||
@@ -6635,7 +6663,7 @@ impl LspStore {
|
||||
for diagnostics in join_all(pull_diagnostics).await {
|
||||
responses.extend(diagnostics?);
|
||||
}
|
||||
Ok(responses)
|
||||
Ok(Some(responses))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6701,75 +6729,93 @@ impl LspStore {
|
||||
buffer: Entity<Buffer>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<anyhow::Result<()>> {
|
||||
let buffer_id = buffer.read(cx).remote_id();
|
||||
let diagnostics = self.pull_diagnostics(buffer, cx);
|
||||
cx.spawn(async move |lsp_store, cx| {
|
||||
let diagnostics = diagnostics.await.context("pulling diagnostics")?;
|
||||
let Some(diagnostics) = diagnostics.await.context("pulling diagnostics")? else {
|
||||
return Ok(());
|
||||
};
|
||||
lsp_store.update(cx, |lsp_store, cx| {
|
||||
if lsp_store.as_local().is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
for diagnostics_set in diagnostics {
|
||||
let LspPullDiagnostics::Response {
|
||||
server_id,
|
||||
uri,
|
||||
diagnostics,
|
||||
} = diagnostics_set
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let adapter = lsp_store.language_server_adapter_for_id(server_id);
|
||||
let disk_based_sources = adapter
|
||||
.as_ref()
|
||||
.map(|adapter| adapter.disk_based_diagnostic_sources.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
match diagnostics {
|
||||
PulledDiagnostics::Unchanged { result_id } => {
|
||||
lsp_store
|
||||
.merge_diagnostics(
|
||||
server_id,
|
||||
lsp::PublishDiagnosticsParams {
|
||||
uri: uri.clone(),
|
||||
diagnostics: Vec::new(),
|
||||
version: None,
|
||||
},
|
||||
Some(result_id),
|
||||
DiagnosticSourceKind::Pulled,
|
||||
disk_based_sources,
|
||||
|_, _, _| true,
|
||||
cx,
|
||||
)
|
||||
.log_err();
|
||||
}
|
||||
PulledDiagnostics::Changed {
|
||||
let mut unchanged_buffers = HashSet::default();
|
||||
let mut changed_buffers = HashSet::default();
|
||||
let server_diagnostics_updates = diagnostics
|
||||
.into_iter()
|
||||
.filter_map(|diagnostics_set| match diagnostics_set {
|
||||
LspPullDiagnostics::Response {
|
||||
server_id,
|
||||
uri,
|
||||
diagnostics,
|
||||
result_id,
|
||||
} => {
|
||||
lsp_store
|
||||
.merge_diagnostics(
|
||||
} => Some((server_id, uri, diagnostics)),
|
||||
LspPullDiagnostics::Default => None,
|
||||
})
|
||||
.fold(
|
||||
HashMap::default(),
|
||||
|mut acc, (server_id, uri, diagnostics)| {
|
||||
let (result_id, diagnostics) = match diagnostics {
|
||||
PulledDiagnostics::Unchanged { result_id } => {
|
||||
unchanged_buffers.insert(uri.clone());
|
||||
(Some(result_id), Vec::new())
|
||||
}
|
||||
PulledDiagnostics::Changed {
|
||||
result_id,
|
||||
diagnostics,
|
||||
} => {
|
||||
changed_buffers.insert(uri.clone());
|
||||
(result_id, diagnostics)
|
||||
}
|
||||
};
|
||||
let disk_based_sources = Cow::Owned(
|
||||
lsp_store
|
||||
.language_server_adapter_for_id(server_id)
|
||||
.as_ref()
|
||||
.map(|adapter| adapter.disk_based_diagnostic_sources.as_slice())
|
||||
.unwrap_or(&[])
|
||||
.to_vec(),
|
||||
);
|
||||
acc.entry(server_id).or_insert_with(Vec::new).push(
|
||||
DocumentDiagnosticsUpdate {
|
||||
server_id,
|
||||
lsp::PublishDiagnosticsParams {
|
||||
uri: uri.clone(),
|
||||
diagnostics: lsp::PublishDiagnosticsParams {
|
||||
uri,
|
||||
diagnostics,
|
||||
version: None,
|
||||
},
|
||||
result_id,
|
||||
DiagnosticSourceKind::Pulled,
|
||||
disk_based_sources,
|
||||
|buffer, old_diagnostic, _| match old_diagnostic.source_kind {
|
||||
DiagnosticSourceKind::Pulled => {
|
||||
buffer.remote_id() != buffer_id
|
||||
}
|
||||
DiagnosticSourceKind::Other
|
||||
| DiagnosticSourceKind::Pushed => true,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
acc
|
||||
},
|
||||
);
|
||||
|
||||
for diagnostic_updates in server_diagnostics_updates.into_values() {
|
||||
lsp_store
|
||||
.merge_lsp_diagnostics(
|
||||
DiagnosticSourceKind::Pulled,
|
||||
diagnostic_updates,
|
||||
|buffer, old_diagnostic, cx| {
|
||||
File::from_dyn(buffer.file())
|
||||
.and_then(|file| {
|
||||
let abs_path = file.as_local()?.abs_path(cx);
|
||||
lsp::Url::from_file_path(abs_path).ok()
|
||||
})
|
||||
.is_none_or(|buffer_uri| {
|
||||
unchanged_buffers.contains(&buffer_uri)
|
||||
|| match old_diagnostic.source_kind {
|
||||
DiagnosticSourceKind::Pulled => {
|
||||
!changed_buffers.contains(&buffer_uri)
|
||||
}
|
||||
DiagnosticSourceKind::Other
|
||||
| DiagnosticSourceKind::Pushed => true,
|
||||
}
|
||||
})
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.log_err();
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -7791,88 +7837,135 @@ impl LspStore {
|
||||
cx: &mut Context<Self>,
|
||||
) -> anyhow::Result<()> {
|
||||
self.merge_diagnostic_entries(
|
||||
server_id,
|
||||
abs_path,
|
||||
result_id,
|
||||
version,
|
||||
diagnostics,
|
||||
vec![DocumentDiagnosticsUpdate {
|
||||
diagnostics: DocumentDiagnostics {
|
||||
diagnostics,
|
||||
document_abs_path: abs_path,
|
||||
version,
|
||||
},
|
||||
result_id,
|
||||
server_id,
|
||||
disk_based_sources: Cow::Borrowed(&[]),
|
||||
}],
|
||||
|_, _, _| false,
|
||||
cx,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn merge_diagnostic_entries(
|
||||
pub fn merge_diagnostic_entries<'a>(
|
||||
&mut self,
|
||||
server_id: LanguageServerId,
|
||||
abs_path: PathBuf,
|
||||
result_id: Option<String>,
|
||||
version: Option<i32>,
|
||||
mut diagnostics: Vec<DiagnosticEntry<Unclipped<PointUtf16>>>,
|
||||
filter: impl Fn(&Buffer, &Diagnostic, &App) -> bool + Clone,
|
||||
diagnostic_updates: Vec<DocumentDiagnosticsUpdate<'a, DocumentDiagnostics>>,
|
||||
merge: impl Fn(&Buffer, &Diagnostic, &App) -> bool + Clone,
|
||||
cx: &mut Context<Self>,
|
||||
) -> anyhow::Result<()> {
|
||||
let Some((worktree, relative_path)) =
|
||||
self.worktree_store.read(cx).find_worktree(&abs_path, cx)
|
||||
else {
|
||||
log::warn!("skipping diagnostics update, no worktree found for path {abs_path:?}");
|
||||
return Ok(());
|
||||
};
|
||||
let mut diagnostics_summary = None::<proto::UpdateDiagnosticSummary>;
|
||||
let mut updated_diagnostics_paths = HashMap::default();
|
||||
for mut update in diagnostic_updates {
|
||||
let abs_path = &update.diagnostics.document_abs_path;
|
||||
let server_id = update.server_id;
|
||||
let Some((worktree, relative_path)) =
|
||||
self.worktree_store.read(cx).find_worktree(abs_path, cx)
|
||||
else {
|
||||
log::warn!("skipping diagnostics update, no worktree found for path {abs_path:?}");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let project_path = ProjectPath {
|
||||
worktree_id: worktree.read(cx).id(),
|
||||
path: relative_path.into(),
|
||||
};
|
||||
let worktree_id = worktree.read(cx).id();
|
||||
let project_path = ProjectPath {
|
||||
worktree_id,
|
||||
path: relative_path.into(),
|
||||
};
|
||||
|
||||
if let Some(buffer_handle) = self.buffer_store.read(cx).get_by_path(&project_path) {
|
||||
let snapshot = buffer_handle.read(cx).snapshot();
|
||||
let buffer = buffer_handle.read(cx);
|
||||
let reused_diagnostics = buffer
|
||||
.get_diagnostics(server_id)
|
||||
.into_iter()
|
||||
.flat_map(|diag| {
|
||||
diag.iter()
|
||||
.filter(|v| filter(buffer, &v.diagnostic, cx))
|
||||
.map(|v| {
|
||||
let start = Unclipped(v.range.start.to_point_utf16(&snapshot));
|
||||
let end = Unclipped(v.range.end.to_point_utf16(&snapshot));
|
||||
DiagnosticEntry {
|
||||
range: start..end,
|
||||
diagnostic: v.diagnostic.clone(),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(buffer_handle) = self.buffer_store.read(cx).get_by_path(&project_path) {
|
||||
let snapshot = buffer_handle.read(cx).snapshot();
|
||||
let buffer = buffer_handle.read(cx);
|
||||
let reused_diagnostics = buffer
|
||||
.get_diagnostics(server_id)
|
||||
.into_iter()
|
||||
.flat_map(|diag| {
|
||||
diag.iter()
|
||||
.filter(|v| merge(buffer, &v.diagnostic, cx))
|
||||
.map(|v| {
|
||||
let start = Unclipped(v.range.start.to_point_utf16(&snapshot));
|
||||
let end = Unclipped(v.range.end.to_point_utf16(&snapshot));
|
||||
DiagnosticEntry {
|
||||
range: start..end,
|
||||
diagnostic: v.diagnostic.clone(),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
self.as_local_mut()
|
||||
.context("cannot merge diagnostics on a remote LspStore")?
|
||||
.update_buffer_diagnostics(
|
||||
&buffer_handle,
|
||||
self.as_local_mut()
|
||||
.context("cannot merge diagnostics on a remote LspStore")?
|
||||
.update_buffer_diagnostics(
|
||||
&buffer_handle,
|
||||
server_id,
|
||||
update.result_id,
|
||||
update.diagnostics.version,
|
||||
update.diagnostics.diagnostics.clone(),
|
||||
reused_diagnostics.clone(),
|
||||
cx,
|
||||
)?;
|
||||
|
||||
update.diagnostics.diagnostics.extend(reused_diagnostics);
|
||||
}
|
||||
|
||||
let updated = worktree.update(cx, |worktree, cx| {
|
||||
self.update_worktree_diagnostics(
|
||||
worktree.id(),
|
||||
server_id,
|
||||
result_id,
|
||||
version,
|
||||
diagnostics.clone(),
|
||||
reused_diagnostics.clone(),
|
||||
project_path.path.clone(),
|
||||
update.diagnostics.diagnostics,
|
||||
cx,
|
||||
)?;
|
||||
|
||||
diagnostics.extend(reused_diagnostics);
|
||||
)
|
||||
})?;
|
||||
match updated {
|
||||
ControlFlow::Continue(new_summary) => {
|
||||
if let Some((project_id, new_summary)) = new_summary {
|
||||
match &mut diagnostics_summary {
|
||||
Some(diagnostics_summary) => {
|
||||
diagnostics_summary
|
||||
.more_summaries
|
||||
.push(proto::DiagnosticSummary {
|
||||
path: project_path.path.as_ref().to_proto(),
|
||||
language_server_id: server_id.0 as u64,
|
||||
error_count: new_summary.error_count,
|
||||
warning_count: new_summary.warning_count,
|
||||
})
|
||||
}
|
||||
None => {
|
||||
diagnostics_summary = Some(proto::UpdateDiagnosticSummary {
|
||||
project_id: project_id,
|
||||
worktree_id: worktree_id.to_proto(),
|
||||
summary: Some(proto::DiagnosticSummary {
|
||||
path: project_path.path.as_ref().to_proto(),
|
||||
language_server_id: server_id.0 as u64,
|
||||
error_count: new_summary.error_count,
|
||||
warning_count: new_summary.warning_count,
|
||||
}),
|
||||
more_summaries: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
updated_diagnostics_paths
|
||||
.entry(server_id)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(project_path);
|
||||
}
|
||||
ControlFlow::Break(()) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let updated = worktree.update(cx, |worktree, cx| {
|
||||
self.update_worktree_diagnostics(
|
||||
worktree.id(),
|
||||
server_id,
|
||||
project_path.path.clone(),
|
||||
diagnostics,
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
if updated {
|
||||
cx.emit(LspStoreEvent::DiagnosticsUpdated {
|
||||
language_server_id: server_id,
|
||||
path: project_path,
|
||||
})
|
||||
if let Some((diagnostics_summary, (downstream_client, _))) =
|
||||
diagnostics_summary.zip(self.downstream_client.as_ref())
|
||||
{
|
||||
downstream_client.send(diagnostics_summary).log_err();
|
||||
}
|
||||
for (server_id, paths) in updated_diagnostics_paths {
|
||||
cx.emit(LspStoreEvent::DiagnosticsUpdated { server_id, paths });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -7881,10 +7974,10 @@ impl LspStore {
|
||||
&mut self,
|
||||
worktree_id: WorktreeId,
|
||||
server_id: LanguageServerId,
|
||||
worktree_path: Arc<Path>,
|
||||
path_in_worktree: Arc<Path>,
|
||||
diagnostics: Vec<DiagnosticEntry<Unclipped<PointUtf16>>>,
|
||||
_: &mut Context<Worktree>,
|
||||
) -> Result<bool> {
|
||||
) -> Result<ControlFlow<(), Option<(u64, proto::DiagnosticSummary)>>> {
|
||||
let local = match &mut self.mode {
|
||||
LspStoreMode::Local(local_lsp_store) => local_lsp_store,
|
||||
_ => anyhow::bail!("update_worktree_diagnostics called on remote"),
|
||||
@@ -7892,7 +7985,9 @@ impl LspStore {
|
||||
|
||||
let summaries_for_tree = self.diagnostic_summaries.entry(worktree_id).or_default();
|
||||
let diagnostics_for_tree = local.diagnostics.entry(worktree_id).or_default();
|
||||
let summaries_by_server_id = summaries_for_tree.entry(worktree_path.clone()).or_default();
|
||||
let summaries_by_server_id = summaries_for_tree
|
||||
.entry(path_in_worktree.clone())
|
||||
.or_default();
|
||||
|
||||
let old_summary = summaries_by_server_id
|
||||
.remove(&server_id)
|
||||
@@ -7900,18 +7995,19 @@ impl LspStore {
|
||||
|
||||
let new_summary = DiagnosticSummary::new(&diagnostics);
|
||||
if new_summary.is_empty() {
|
||||
if let Some(diagnostics_by_server_id) = diagnostics_for_tree.get_mut(&worktree_path) {
|
||||
if let Some(diagnostics_by_server_id) = diagnostics_for_tree.get_mut(&path_in_worktree)
|
||||
{
|
||||
if let Ok(ix) = diagnostics_by_server_id.binary_search_by_key(&server_id, |e| e.0) {
|
||||
diagnostics_by_server_id.remove(ix);
|
||||
}
|
||||
if diagnostics_by_server_id.is_empty() {
|
||||
diagnostics_for_tree.remove(&worktree_path);
|
||||
diagnostics_for_tree.remove(&path_in_worktree);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
summaries_by_server_id.insert(server_id, new_summary);
|
||||
let diagnostics_by_server_id = diagnostics_for_tree
|
||||
.entry(worktree_path.clone())
|
||||
.entry(path_in_worktree.clone())
|
||||
.or_default();
|
||||
match diagnostics_by_server_id.binary_search_by_key(&server_id, |e| e.0) {
|
||||
Ok(ix) => {
|
||||
@@ -7924,23 +8020,22 @@ impl LspStore {
|
||||
}
|
||||
|
||||
if !old_summary.is_empty() || !new_summary.is_empty() {
|
||||
if let Some((downstream_client, project_id)) = &self.downstream_client {
|
||||
downstream_client
|
||||
.send(proto::UpdateDiagnosticSummary {
|
||||
project_id: *project_id,
|
||||
worktree_id: worktree_id.to_proto(),
|
||||
summary: Some(proto::DiagnosticSummary {
|
||||
path: worktree_path.to_proto(),
|
||||
language_server_id: server_id.0 as u64,
|
||||
error_count: new_summary.error_count as u32,
|
||||
warning_count: new_summary.warning_count as u32,
|
||||
}),
|
||||
})
|
||||
.log_err();
|
||||
if let Some((_, project_id)) = &self.downstream_client {
|
||||
Ok(ControlFlow::Continue(Some((
|
||||
*project_id,
|
||||
proto::DiagnosticSummary {
|
||||
path: path_in_worktree.to_proto(),
|
||||
language_server_id: server_id.0 as u64,
|
||||
error_count: new_summary.error_count as u32,
|
||||
warning_count: new_summary.warning_count as u32,
|
||||
},
|
||||
))))
|
||||
} else {
|
||||
Ok(ControlFlow::Continue(None))
|
||||
}
|
||||
} else {
|
||||
Ok(ControlFlow::Break(()))
|
||||
}
|
||||
|
||||
Ok(!old_summary.is_empty() || !new_summary.is_empty())
|
||||
}
|
||||
|
||||
pub fn open_buffer_for_symbol(
|
||||
@@ -8793,23 +8888,30 @@ impl LspStore {
|
||||
envelope: TypedEnvelope<proto::UpdateDiagnosticSummary>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<()> {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.update(&mut cx, |lsp_store, cx| {
|
||||
let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
|
||||
if let Some(message) = envelope.payload.summary {
|
||||
let mut updated_diagnostics_paths = HashMap::default();
|
||||
let mut diagnostics_summary = None::<proto::UpdateDiagnosticSummary>;
|
||||
for message_summary in envelope
|
||||
.payload
|
||||
.summary
|
||||
.into_iter()
|
||||
.chain(envelope.payload.more_summaries)
|
||||
{
|
||||
let project_path = ProjectPath {
|
||||
worktree_id,
|
||||
path: Arc::<Path>::from_proto(message.path),
|
||||
path: Arc::<Path>::from_proto(message_summary.path),
|
||||
};
|
||||
let path = project_path.path.clone();
|
||||
let server_id = LanguageServerId(message.language_server_id as usize);
|
||||
let server_id = LanguageServerId(message_summary.language_server_id as usize);
|
||||
let summary = DiagnosticSummary {
|
||||
error_count: message.error_count as usize,
|
||||
warning_count: message.warning_count as usize,
|
||||
error_count: message_summary.error_count as usize,
|
||||
warning_count: message_summary.warning_count as usize,
|
||||
};
|
||||
|
||||
if summary.is_empty() {
|
||||
if let Some(worktree_summaries) =
|
||||
this.diagnostic_summaries.get_mut(&worktree_id)
|
||||
lsp_store.diagnostic_summaries.get_mut(&worktree_id)
|
||||
{
|
||||
if let Some(summaries) = worktree_summaries.get_mut(&path) {
|
||||
summaries.remove(&server_id);
|
||||
@@ -8819,31 +8921,55 @@ impl LspStore {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.diagnostic_summaries
|
||||
lsp_store
|
||||
.diagnostic_summaries
|
||||
.entry(worktree_id)
|
||||
.or_default()
|
||||
.entry(path)
|
||||
.or_default()
|
||||
.insert(server_id, summary);
|
||||
}
|
||||
if let Some((downstream_client, project_id)) = &this.downstream_client {
|
||||
downstream_client
|
||||
.send(proto::UpdateDiagnosticSummary {
|
||||
project_id: *project_id,
|
||||
worktree_id: worktree_id.to_proto(),
|
||||
summary: Some(proto::DiagnosticSummary {
|
||||
path: project_path.path.as_ref().to_proto(),
|
||||
language_server_id: server_id.0 as u64,
|
||||
error_count: summary.error_count as u32,
|
||||
warning_count: summary.warning_count as u32,
|
||||
}),
|
||||
})
|
||||
.log_err();
|
||||
|
||||
if let Some((_, project_id)) = &lsp_store.downstream_client {
|
||||
match &mut diagnostics_summary {
|
||||
Some(diagnostics_summary) => {
|
||||
diagnostics_summary
|
||||
.more_summaries
|
||||
.push(proto::DiagnosticSummary {
|
||||
path: project_path.path.as_ref().to_proto(),
|
||||
language_server_id: server_id.0 as u64,
|
||||
error_count: summary.error_count as u32,
|
||||
warning_count: summary.warning_count as u32,
|
||||
})
|
||||
}
|
||||
None => {
|
||||
diagnostics_summary = Some(proto::UpdateDiagnosticSummary {
|
||||
project_id: *project_id,
|
||||
worktree_id: worktree_id.to_proto(),
|
||||
summary: Some(proto::DiagnosticSummary {
|
||||
path: project_path.path.as_ref().to_proto(),
|
||||
language_server_id: server_id.0 as u64,
|
||||
error_count: summary.error_count as u32,
|
||||
warning_count: summary.warning_count as u32,
|
||||
}),
|
||||
more_summaries: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.emit(LspStoreEvent::DiagnosticsUpdated {
|
||||
language_server_id: LanguageServerId(message.language_server_id as usize),
|
||||
path: project_path,
|
||||
});
|
||||
updated_diagnostics_paths
|
||||
.entry(server_id)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(project_path);
|
||||
}
|
||||
|
||||
if let Some((diagnostics_summary, (downstream_client, _))) =
|
||||
diagnostics_summary.zip(lsp_store.downstream_client.as_ref())
|
||||
{
|
||||
downstream_client.send(diagnostics_summary).log_err();
|
||||
}
|
||||
for (server_id, paths) in updated_diagnostics_paths {
|
||||
cx.emit(LspStoreEvent::DiagnosticsUpdated { server_id, paths });
|
||||
}
|
||||
Ok(())
|
||||
})?
|
||||
@@ -10361,6 +10487,7 @@ impl LspStore {
|
||||
error_count: 0,
|
||||
warning_count: 0,
|
||||
}),
|
||||
more_summaries: Vec::new(),
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
@@ -10649,52 +10776,80 @@ impl LspStore {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn update_diagnostics(
|
||||
&mut self,
|
||||
language_server_id: LanguageServerId,
|
||||
params: lsp::PublishDiagnosticsParams,
|
||||
server_id: LanguageServerId,
|
||||
diagnostics: lsp::PublishDiagnosticsParams,
|
||||
result_id: Option<String>,
|
||||
source_kind: DiagnosticSourceKind,
|
||||
disk_based_sources: &[String],
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<()> {
|
||||
self.merge_diagnostics(
|
||||
language_server_id,
|
||||
params,
|
||||
result_id,
|
||||
self.merge_lsp_diagnostics(
|
||||
source_kind,
|
||||
disk_based_sources,
|
||||
vec![DocumentDiagnosticsUpdate {
|
||||
diagnostics,
|
||||
result_id,
|
||||
server_id,
|
||||
disk_based_sources: Cow::Borrowed(disk_based_sources),
|
||||
}],
|
||||
|_, _, _| false,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn merge_diagnostics(
|
||||
pub fn merge_lsp_diagnostics(
|
||||
&mut self,
|
||||
language_server_id: LanguageServerId,
|
||||
mut params: lsp::PublishDiagnosticsParams,
|
||||
result_id: Option<String>,
|
||||
source_kind: DiagnosticSourceKind,
|
||||
disk_based_sources: &[String],
|
||||
filter: impl Fn(&Buffer, &Diagnostic, &App) -> bool + Clone,
|
||||
lsp_diagnostics: Vec<DocumentDiagnosticsUpdate<lsp::PublishDiagnosticsParams>>,
|
||||
merge: impl Fn(&Buffer, &Diagnostic, &App) -> bool + Clone,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<()> {
|
||||
anyhow::ensure!(self.mode.is_local(), "called update_diagnostics on remote");
|
||||
let abs_path = params
|
||||
.uri
|
||||
.to_file_path()
|
||||
.map_err(|()| anyhow!("URI is not a file"))?;
|
||||
let updates = lsp_diagnostics
|
||||
.into_iter()
|
||||
.filter_map(|update| {
|
||||
let abs_path = update.diagnostics.uri.to_file_path().ok()?;
|
||||
Some(DocumentDiagnosticsUpdate {
|
||||
diagnostics: self.lsp_to_document_diagnostics(
|
||||
abs_path,
|
||||
source_kind,
|
||||
update.server_id,
|
||||
update.diagnostics,
|
||||
&update.disk_based_sources,
|
||||
),
|
||||
result_id: update.result_id,
|
||||
server_id: update.server_id,
|
||||
disk_based_sources: update.disk_based_sources,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
self.merge_diagnostic_entries(updates, merge, cx)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn lsp_to_document_diagnostics(
|
||||
&mut self,
|
||||
document_abs_path: PathBuf,
|
||||
source_kind: DiagnosticSourceKind,
|
||||
server_id: LanguageServerId,
|
||||
mut lsp_diagnostics: lsp::PublishDiagnosticsParams,
|
||||
disk_based_sources: &[String],
|
||||
) -> DocumentDiagnostics {
|
||||
let mut diagnostics = Vec::default();
|
||||
let mut primary_diagnostic_group_ids = HashMap::default();
|
||||
let mut sources_by_group_id = HashMap::default();
|
||||
let mut supporting_diagnostics = HashMap::default();
|
||||
|
||||
let adapter = self.language_server_adapter_for_id(language_server_id);
|
||||
let adapter = self.language_server_adapter_for_id(server_id);
|
||||
|
||||
// Ensure that primary diagnostics are always the most severe
|
||||
params.diagnostics.sort_by_key(|item| item.severity);
|
||||
lsp_diagnostics
|
||||
.diagnostics
|
||||
.sort_by_key(|item| item.severity);
|
||||
|
||||
for diagnostic in ¶ms.diagnostics {
|
||||
for diagnostic in &lsp_diagnostics.diagnostics {
|
||||
let source = diagnostic.source.as_ref();
|
||||
let range = range_from_lsp(diagnostic.range);
|
||||
let is_supporting = diagnostic
|
||||
@@ -10716,7 +10871,7 @@ impl LspStore {
|
||||
.map_or(false, |tags| tags.contains(&DiagnosticTag::UNNECESSARY));
|
||||
|
||||
let underline = self
|
||||
.language_server_adapter_for_id(language_server_id)
|
||||
.language_server_adapter_for_id(server_id)
|
||||
.map_or(true, |adapter| adapter.underline_diagnostic(diagnostic));
|
||||
|
||||
if is_supporting {
|
||||
@@ -10758,7 +10913,7 @@ impl LspStore {
|
||||
});
|
||||
if let Some(infos) = &diagnostic.related_information {
|
||||
for info in infos {
|
||||
if info.location.uri == params.uri && !info.message.is_empty() {
|
||||
if info.location.uri == lsp_diagnostics.uri && !info.message.is_empty() {
|
||||
let range = range_from_lsp(info.location.range);
|
||||
diagnostics.push(DiagnosticEntry {
|
||||
range,
|
||||
@@ -10806,16 +10961,11 @@ impl LspStore {
|
||||
}
|
||||
}
|
||||
|
||||
self.merge_diagnostic_entries(
|
||||
language_server_id,
|
||||
abs_path,
|
||||
result_id,
|
||||
params.version,
|
||||
DocumentDiagnostics {
|
||||
diagnostics,
|
||||
filter,
|
||||
cx,
|
||||
)?;
|
||||
Ok(())
|
||||
document_abs_path,
|
||||
version: lsp_diagnostics.version,
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_newly_running_language_server(
|
||||
@@ -11571,67 +11721,84 @@ impl LspStore {
|
||||
) {
|
||||
let workspace_diagnostics =
|
||||
GetDocumentDiagnostics::deserialize_workspace_diagnostics_report(report, server_id);
|
||||
for workspace_diagnostics in workspace_diagnostics {
|
||||
let LspPullDiagnostics::Response {
|
||||
server_id,
|
||||
uri,
|
||||
diagnostics,
|
||||
} = workspace_diagnostics.diagnostics
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let adapter = self.language_server_adapter_for_id(server_id);
|
||||
let disk_based_sources = adapter
|
||||
.as_ref()
|
||||
.map(|adapter| adapter.disk_based_diagnostic_sources.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
match diagnostics {
|
||||
PulledDiagnostics::Unchanged { result_id } => {
|
||||
self.merge_diagnostics(
|
||||
let mut unchanged_buffers = HashSet::default();
|
||||
let mut changed_buffers = HashSet::default();
|
||||
let workspace_diagnostics_updates = workspace_diagnostics
|
||||
.into_iter()
|
||||
.filter_map(
|
||||
|workspace_diagnostics| match workspace_diagnostics.diagnostics {
|
||||
LspPullDiagnostics::Response {
|
||||
server_id,
|
||||
lsp::PublishDiagnosticsParams {
|
||||
uri: uri.clone(),
|
||||
diagnostics: Vec::new(),
|
||||
version: None,
|
||||
},
|
||||
Some(result_id),
|
||||
DiagnosticSourceKind::Pulled,
|
||||
disk_based_sources,
|
||||
|_, _, _| true,
|
||||
cx,
|
||||
)
|
||||
.log_err();
|
||||
}
|
||||
PulledDiagnostics::Changed {
|
||||
diagnostics,
|
||||
result_id,
|
||||
} => {
|
||||
self.merge_diagnostics(
|
||||
server_id,
|
||||
lsp::PublishDiagnosticsParams {
|
||||
uri: uri.clone(),
|
||||
uri,
|
||||
diagnostics,
|
||||
} => Some((server_id, uri, diagnostics, workspace_diagnostics.version)),
|
||||
LspPullDiagnostics::Default => None,
|
||||
},
|
||||
)
|
||||
.fold(
|
||||
HashMap::default(),
|
||||
|mut acc, (server_id, uri, diagnostics, version)| {
|
||||
let (result_id, diagnostics) = match diagnostics {
|
||||
PulledDiagnostics::Unchanged { result_id } => {
|
||||
unchanged_buffers.insert(uri.clone());
|
||||
(Some(result_id), Vec::new())
|
||||
}
|
||||
PulledDiagnostics::Changed {
|
||||
result_id,
|
||||
diagnostics,
|
||||
version: workspace_diagnostics.version,
|
||||
},
|
||||
result_id,
|
||||
DiagnosticSourceKind::Pulled,
|
||||
disk_based_sources,
|
||||
|buffer, old_diagnostic, cx| match old_diagnostic.source_kind {
|
||||
DiagnosticSourceKind::Pulled => {
|
||||
let buffer_url = File::from_dyn(buffer.file())
|
||||
.map(|f| f.abs_path(cx))
|
||||
.and_then(|abs_path| file_path_to_lsp_url(&abs_path).ok());
|
||||
buffer_url.is_none_or(|buffer_url| buffer_url != uri)
|
||||
}
|
||||
DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => true,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
} => {
|
||||
changed_buffers.insert(uri.clone());
|
||||
(result_id, diagnostics)
|
||||
}
|
||||
};
|
||||
let disk_based_sources = Cow::Owned(
|
||||
self.language_server_adapter_for_id(server_id)
|
||||
.as_ref()
|
||||
.map(|adapter| adapter.disk_based_diagnostic_sources.as_slice())
|
||||
.unwrap_or(&[])
|
||||
.to_vec(),
|
||||
);
|
||||
acc.entry(server_id)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(DocumentDiagnosticsUpdate {
|
||||
server_id,
|
||||
diagnostics: lsp::PublishDiagnosticsParams {
|
||||
uri,
|
||||
diagnostics,
|
||||
version,
|
||||
},
|
||||
result_id,
|
||||
disk_based_sources,
|
||||
});
|
||||
acc
|
||||
},
|
||||
);
|
||||
|
||||
for diagnostic_updates in workspace_diagnostics_updates.into_values() {
|
||||
self.merge_lsp_diagnostics(
|
||||
DiagnosticSourceKind::Pulled,
|
||||
diagnostic_updates,
|
||||
|buffer, old_diagnostic, cx| {
|
||||
File::from_dyn(buffer.file())
|
||||
.and_then(|file| {
|
||||
let abs_path = file.as_local()?.abs_path(cx);
|
||||
lsp::Url::from_file_path(abs_path).ok()
|
||||
})
|
||||
.is_none_or(|buffer_uri| {
|
||||
unchanged_buffers.contains(&buffer_uri)
|
||||
|| match old_diagnostic.source_kind {
|
||||
DiagnosticSourceKind::Pulled => {
|
||||
!changed_buffers.contains(&buffer_uri)
|
||||
}
|
||||
DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => {
|
||||
true
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::sync::Arc;
|
||||
use std::{borrow::Cow, sync::Arc};
|
||||
|
||||
use ::serde::{Deserialize, Serialize};
|
||||
use gpui::WeakEntity;
|
||||
@@ -6,7 +6,7 @@ use language::{CachedLspAdapter, Diagnostic, DiagnosticSourceKind};
|
||||
use lsp::{LanguageServer, LanguageServerName};
|
||||
use util::ResultExt as _;
|
||||
|
||||
use crate::LspStore;
|
||||
use crate::{LspStore, lsp_store::DocumentDiagnosticsUpdate};
|
||||
|
||||
pub const CLANGD_SERVER_NAME: LanguageServerName = LanguageServerName::new_static("clangd");
|
||||
const INACTIVE_REGION_MESSAGE: &str = "inactive region";
|
||||
@@ -81,12 +81,16 @@ pub fn register_notifications(
|
||||
version: params.text_document.version,
|
||||
diagnostics,
|
||||
};
|
||||
this.merge_diagnostics(
|
||||
server_id,
|
||||
mapped_diagnostics,
|
||||
None,
|
||||
this.merge_lsp_diagnostics(
|
||||
DiagnosticSourceKind::Pushed,
|
||||
&adapter.disk_based_diagnostic_sources,
|
||||
vec![DocumentDiagnosticsUpdate {
|
||||
server_id,
|
||||
diagnostics: mapped_diagnostics,
|
||||
result_id: None,
|
||||
disk_based_sources: Cow::Borrowed(
|
||||
&adapter.disk_based_diagnostic_sources,
|
||||
),
|
||||
}],
|
||||
|_, diag, _| !is_inactive_region(diag),
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -73,11 +73,10 @@ use gpui::{
|
||||
App, AppContext, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, Hsla, SharedString,
|
||||
Task, WeakEntity, Window,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use language::{
|
||||
Buffer, BufferEvent, Capability, CodeLabel, CursorShape, DiagnosticSourceKind, Language,
|
||||
LanguageName, LanguageRegistry, PointUtf16, ToOffset, ToPointUtf16, Toolchain, ToolchainList,
|
||||
Transaction, Unclipped, language_settings::InlayHintKind, proto::split_operations,
|
||||
Buffer, BufferEvent, Capability, CodeLabel, CursorShape, Language, LanguageName,
|
||||
LanguageRegistry, PointUtf16, ToOffset, ToPointUtf16, Toolchain, ToolchainList, Transaction,
|
||||
Unclipped, language_settings::InlayHintKind, proto::split_operations,
|
||||
};
|
||||
use lsp::{
|
||||
CodeActionKind, CompletionContext, CompletionItemKind, DocumentHighlightKind, InsertTextMode,
|
||||
@@ -113,7 +112,7 @@ use std::{
|
||||
|
||||
use task_store::TaskStore;
|
||||
use terminals::Terminals;
|
||||
use text::{Anchor, BufferId, OffsetRangeExt, Point};
|
||||
use text::{Anchor, BufferId, OffsetRangeExt, Point, Rope};
|
||||
use toolchain_store::EmptyToolchainStore;
|
||||
use util::{
|
||||
ResultExt as _,
|
||||
@@ -306,7 +305,7 @@ pub enum Event {
|
||||
language_server_id: LanguageServerId,
|
||||
},
|
||||
DiagnosticsUpdated {
|
||||
path: ProjectPath,
|
||||
paths: Vec<ProjectPath>,
|
||||
language_server_id: LanguageServerId,
|
||||
},
|
||||
RemoteIdChanged(Option<u64>),
|
||||
@@ -668,10 +667,10 @@ pub enum ResolveState {
|
||||
}
|
||||
|
||||
impl InlayHint {
|
||||
pub fn text(&self) -> String {
|
||||
pub fn text(&self) -> Rope {
|
||||
match &self.label {
|
||||
InlayHintLabel::String(s) => s.to_owned(),
|
||||
InlayHintLabel::LabelParts(parts) => parts.iter().map(|part| &part.value).join(""),
|
||||
InlayHintLabel::String(s) => Rope::from(s),
|
||||
InlayHintLabel::LabelParts(parts) => parts.iter().map(|part| &*part.value).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2896,18 +2895,17 @@ impl Project {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match event {
|
||||
LspStoreEvent::DiagnosticsUpdated {
|
||||
language_server_id,
|
||||
path,
|
||||
} => cx.emit(Event::DiagnosticsUpdated {
|
||||
path: path.clone(),
|
||||
language_server_id: *language_server_id,
|
||||
}),
|
||||
LspStoreEvent::LanguageServerAdded(language_server_id, name, worktree_id) => cx.emit(
|
||||
Event::LanguageServerAdded(*language_server_id, name.clone(), *worktree_id),
|
||||
LspStoreEvent::DiagnosticsUpdated { server_id, paths } => {
|
||||
cx.emit(Event::DiagnosticsUpdated {
|
||||
paths: paths.clone(),
|
||||
language_server_id: *server_id,
|
||||
})
|
||||
}
|
||||
LspStoreEvent::LanguageServerAdded(server_id, name, worktree_id) => cx.emit(
|
||||
Event::LanguageServerAdded(*server_id, name.clone(), *worktree_id),
|
||||
),
|
||||
LspStoreEvent::LanguageServerRemoved(language_server_id) => {
|
||||
cx.emit(Event::LanguageServerRemoved(*language_server_id))
|
||||
LspStoreEvent::LanguageServerRemoved(server_id) => {
|
||||
cx.emit(Event::LanguageServerRemoved(*server_id))
|
||||
}
|
||||
LspStoreEvent::LanguageServerLog(server_id, log_type, string) => cx.emit(
|
||||
Event::LanguageServerLog(*server_id, log_type.clone(), string.clone()),
|
||||
@@ -3830,27 +3828,6 @@ impl Project {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_diagnostics(
|
||||
&mut self,
|
||||
language_server_id: LanguageServerId,
|
||||
source_kind: DiagnosticSourceKind,
|
||||
result_id: Option<String>,
|
||||
params: lsp::PublishDiagnosticsParams,
|
||||
disk_based_sources: &[String],
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
self.lsp_store.update(cx, |lsp_store, cx| {
|
||||
lsp_store.update_diagnostics(
|
||||
language_server_id,
|
||||
params,
|
||||
result_id,
|
||||
source_kind,
|
||||
disk_based_sources,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn search(&mut self, query: SearchQuery, cx: &mut Context<Self>) -> Receiver<SearchResult> {
|
||||
let (result_tx, result_rx) = smol::channel::unbounded();
|
||||
|
||||
|
||||
@@ -431,10 +431,9 @@ impl GitSettings {
|
||||
|
||||
pub fn inline_blame_delay(&self) -> Option<Duration> {
|
||||
match self.inline_blame {
|
||||
Some(InlineBlameSettings {
|
||||
delay_ms: Some(delay_ms),
|
||||
..
|
||||
}) if delay_ms > 0 => Some(Duration::from_millis(delay_ms)),
|
||||
Some(InlineBlameSettings { delay_ms, .. }) if delay_ms > 0 => {
|
||||
Some(Duration::from_millis(delay_ms))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -470,7 +469,7 @@ pub enum GitGutterSetting {
|
||||
Hide,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct InlineBlameSettings {
|
||||
/// Whether or not to show git blame data inline in
|
||||
@@ -483,11 +482,19 @@ pub struct InlineBlameSettings {
|
||||
/// after a delay once the cursor stops moving.
|
||||
///
|
||||
/// Default: 0
|
||||
pub delay_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub delay_ms: u64,
|
||||
/// The amount of padding between the end of the source line and the start
|
||||
/// of the inline blame in units of columns.
|
||||
///
|
||||
/// Default: 7
|
||||
#[serde(default = "default_inline_blame_padding")]
|
||||
pub padding: u32,
|
||||
/// The minimum column number to show the inline blame information at
|
||||
///
|
||||
/// Default: 0
|
||||
pub min_column: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub min_column: u32,
|
||||
/// Whether to show commit summary as part of the inline blame.
|
||||
///
|
||||
/// Default: false
|
||||
@@ -495,6 +502,22 @@ pub struct InlineBlameSettings {
|
||||
pub show_commit_summary: bool,
|
||||
}
|
||||
|
||||
fn default_inline_blame_padding() -> u32 {
|
||||
7
|
||||
}
|
||||
|
||||
impl Default for InlineBlameSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
delay_ms: 0,
|
||||
padding: default_inline_blame_padding(),
|
||||
min_column: 0,
|
||||
show_commit_summary: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
pub struct BinarySettings {
|
||||
pub path: Option<String>,
|
||||
|
||||
@@ -18,9 +18,10 @@ use git::{
|
||||
use git2::RepositoryInitOptions;
|
||||
use gpui::{App, BackgroundExecutor, SemanticVersion, UpdateGlobal};
|
||||
use http_client::Url;
|
||||
use itertools::Itertools;
|
||||
use language::{
|
||||
Diagnostic, DiagnosticEntry, DiagnosticSet, DiskState, FakeLspAdapter, LanguageConfig,
|
||||
LanguageMatcher, LanguageName, LineEnding, OffsetRangeExt, Point, ToPoint,
|
||||
Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, DiskState, FakeLspAdapter,
|
||||
LanguageConfig, LanguageMatcher, LanguageName, LineEnding, OffsetRangeExt, Point, ToPoint,
|
||||
language_settings::{AllLanguageSettings, LanguageSettingsContent, language_settings},
|
||||
tree_sitter_rust, tree_sitter_typescript,
|
||||
};
|
||||
@@ -1618,7 +1619,7 @@ async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) {
|
||||
events.next().await.unwrap(),
|
||||
Event::DiagnosticsUpdated {
|
||||
language_server_id: LanguageServerId(0),
|
||||
path: (worktree_id, Path::new("a.rs")).into()
|
||||
paths: vec![(worktree_id, Path::new("a.rs")).into()],
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1666,7 +1667,7 @@ async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) {
|
||||
events.next().await.unwrap(),
|
||||
Event::DiagnosticsUpdated {
|
||||
language_server_id: LanguageServerId(0),
|
||||
path: (worktree_id, Path::new("a.rs")).into()
|
||||
paths: vec![(worktree_id, Path::new("a.rs")).into()],
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ command_palette_hooks.workspace = true
|
||||
db.workspace = true
|
||||
editor.workspace = true
|
||||
file_icons.workspace = true
|
||||
git_ui.workspace = true
|
||||
indexmap.workspace = true
|
||||
git.workspace = true
|
||||
gpui.workspace = true
|
||||
|
||||
@@ -16,6 +16,7 @@ use editor::{
|
||||
};
|
||||
use file_icons::FileIcons;
|
||||
use git::status::GitSummary;
|
||||
use git_ui::file_diff_view::FileDiffView;
|
||||
use gpui::{
|
||||
Action, AnyElement, App, ArcCow, AsyncWindowContext, Bounds, ClipboardItem, Context,
|
||||
CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, ExternalPaths,
|
||||
@@ -93,7 +94,7 @@ pub struct ProjectPanel {
|
||||
unfolded_dir_ids: HashSet<ProjectEntryId>,
|
||||
// Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
|
||||
selection: Option<SelectedEntry>,
|
||||
marked_entries: BTreeSet<SelectedEntry>,
|
||||
marked_entries: Vec<SelectedEntry>,
|
||||
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
|
||||
edit_state: Option<EditState>,
|
||||
filename_editor: Entity<Editor>,
|
||||
@@ -280,6 +281,8 @@ actions!(
|
||||
SelectNextDirectory,
|
||||
/// Selects the previous directory.
|
||||
SelectPrevDirectory,
|
||||
/// Opens a diff view to compare two marked files.
|
||||
CompareMarkedFiles,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -376,7 +379,7 @@ struct DraggedProjectEntryView {
|
||||
selection: SelectedEntry,
|
||||
details: EntryDetails,
|
||||
click_offset: Point<Pixels>,
|
||||
selections: Arc<BTreeSet<SelectedEntry>>,
|
||||
selections: Arc<[SelectedEntry]>,
|
||||
}
|
||||
|
||||
struct ItemColors {
|
||||
@@ -442,7 +445,15 @@ impl ProjectPanel {
|
||||
}
|
||||
}
|
||||
project::Event::ActiveEntryChanged(None) => {
|
||||
this.marked_entries.clear();
|
||||
let is_active_item_file_diff_view = this
|
||||
.workspace
|
||||
.upgrade()
|
||||
.and_then(|ws| ws.read(cx).active_item(cx))
|
||||
.map(|item| item.act_as_type(TypeId::of::<FileDiffView>(), cx).is_some())
|
||||
.unwrap_or(false);
|
||||
if !is_active_item_file_diff_view {
|
||||
this.marked_entries.clear();
|
||||
}
|
||||
}
|
||||
project::Event::RevealInProjectPanel(entry_id) => {
|
||||
if let Some(()) = this
|
||||
@@ -676,7 +687,7 @@ impl ProjectPanel {
|
||||
project_panel.update(cx, |project_panel, _| {
|
||||
let entry = SelectedEntry { worktree_id, entry_id };
|
||||
project_panel.marked_entries.clear();
|
||||
project_panel.marked_entries.insert(entry);
|
||||
project_panel.marked_entries.push(entry);
|
||||
project_panel.selection = Some(entry);
|
||||
});
|
||||
if !focus_opened_item {
|
||||
@@ -887,6 +898,7 @@ impl ProjectPanel {
|
||||
let should_hide_rename = is_root
|
||||
&& (cfg!(target_os = "windows")
|
||||
|| (settings.hide_root && visible_worktrees_count == 1));
|
||||
let should_show_compare = !is_dir && self.file_abs_paths_to_diff(cx).is_some();
|
||||
|
||||
let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
|
||||
menu.context(self.focus_handle.clone()).map(|menu| {
|
||||
@@ -918,6 +930,10 @@ impl ProjectPanel {
|
||||
.when(is_foldable, |menu| {
|
||||
menu.action("Fold Directory", Box::new(FoldDirectory))
|
||||
})
|
||||
.when(should_show_compare, |menu| {
|
||||
menu.separator()
|
||||
.action("Compare marked files", Box::new(CompareMarkedFiles))
|
||||
})
|
||||
.separator()
|
||||
.action("Cut", Box::new(Cut))
|
||||
.action("Copy", Box::new(Copy))
|
||||
@@ -1262,7 +1278,7 @@ impl ProjectPanel {
|
||||
};
|
||||
self.selection = Some(selection);
|
||||
if window.modifiers().shift {
|
||||
self.marked_entries.insert(selection);
|
||||
self.marked_entries.push(selection);
|
||||
}
|
||||
self.autoscroll(cx);
|
||||
cx.notify();
|
||||
@@ -2007,7 +2023,7 @@ impl ProjectPanel {
|
||||
};
|
||||
self.selection = Some(selection);
|
||||
if window.modifiers().shift {
|
||||
self.marked_entries.insert(selection);
|
||||
self.marked_entries.push(selection);
|
||||
}
|
||||
|
||||
self.autoscroll(cx);
|
||||
@@ -2244,7 +2260,7 @@ impl ProjectPanel {
|
||||
};
|
||||
self.selection = Some(selection);
|
||||
if window.modifiers().shift {
|
||||
self.marked_entries.insert(selection);
|
||||
self.marked_entries.push(selection);
|
||||
}
|
||||
self.autoscroll(cx);
|
||||
cx.notify();
|
||||
@@ -2572,6 +2588,43 @@ impl ProjectPanel {
|
||||
}
|
||||
}
|
||||
|
||||
fn file_abs_paths_to_diff(&self, cx: &Context<Self>) -> Option<(PathBuf, PathBuf)> {
|
||||
let mut selections_abs_path = self
|
||||
.marked_entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let project = self.project.read(cx);
|
||||
let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
|
||||
let entry = worktree.read(cx).entry_for_id(entry.entry_id)?;
|
||||
if !entry.is_file() {
|
||||
return None;
|
||||
}
|
||||
worktree.read(cx).absolutize(&entry.path).ok()
|
||||
})
|
||||
.rev();
|
||||
|
||||
let last_path = selections_abs_path.next()?;
|
||||
let previous_to_last = selections_abs_path.next()?;
|
||||
Some((previous_to_last, last_path))
|
||||
}
|
||||
|
||||
fn compare_marked_files(
|
||||
&mut self,
|
||||
_: &CompareMarkedFiles,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let selected_files = self.file_abs_paths_to_diff(cx);
|
||||
if let Some((file_path1, file_path2)) = selected_files {
|
||||
self.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
FileDiffView::open(file_path1, file_path2, workspace, window, cx)
|
||||
.detach_and_log_err(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn open_system(&mut self, _: &OpenWithSystem, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some((worktree, entry)) = self.selected_entry(cx) {
|
||||
let abs_path = worktree.abs_path().join(&entry.path);
|
||||
@@ -3914,11 +3967,9 @@ impl ProjectPanel {
|
||||
|
||||
let depth = details.depth;
|
||||
let worktree_id = details.worktree_id;
|
||||
let selections = Arc::new(self.marked_entries.clone());
|
||||
|
||||
let dragged_selection = DraggedSelection {
|
||||
active_selection: selection,
|
||||
marked_selections: selections,
|
||||
marked_selections: Arc::from(self.marked_entries.clone()),
|
||||
};
|
||||
|
||||
let bg_color = if is_marked {
|
||||
@@ -4089,7 +4140,7 @@ impl ProjectPanel {
|
||||
});
|
||||
if drag_state.items().count() == 1 {
|
||||
this.marked_entries.clear();
|
||||
this.marked_entries.insert(drag_state.active_selection);
|
||||
this.marked_entries.push(drag_state.active_selection);
|
||||
}
|
||||
this.hover_expand_task.take();
|
||||
|
||||
@@ -4156,65 +4207,69 @@ impl ProjectPanel {
|
||||
}),
|
||||
)
|
||||
.on_click(
|
||||
cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
|
||||
cx.listener(move |project_panel, event: &gpui::ClickEvent, window, cx| {
|
||||
if event.is_right_click() || event.first_focus()
|
||||
|| show_editor
|
||||
{
|
||||
return;
|
||||
}
|
||||
if event.standard_click() {
|
||||
this.mouse_down = false;
|
||||
project_panel.mouse_down = false;
|
||||
}
|
||||
cx.stop_propagation();
|
||||
|
||||
if let Some(selection) = this.selection.filter(|_| event.modifiers().shift) {
|
||||
let current_selection = this.index_for_selection(selection);
|
||||
if let Some(selection) = project_panel.selection.filter(|_| event.modifiers().shift) {
|
||||
let current_selection = project_panel.index_for_selection(selection);
|
||||
let clicked_entry = SelectedEntry {
|
||||
entry_id,
|
||||
worktree_id,
|
||||
};
|
||||
let target_selection = this.index_for_selection(clicked_entry);
|
||||
let target_selection = project_panel.index_for_selection(clicked_entry);
|
||||
if let Some(((_, _, source_index), (_, _, target_index))) =
|
||||
current_selection.zip(target_selection)
|
||||
{
|
||||
let range_start = source_index.min(target_index);
|
||||
let range_end = source_index.max(target_index) + 1;
|
||||
let mut new_selections = BTreeSet::new();
|
||||
this.for_each_visible_entry(
|
||||
let mut new_selections = Vec::new();
|
||||
project_panel.for_each_visible_entry(
|
||||
range_start..range_end,
|
||||
window,
|
||||
cx,
|
||||
|entry_id, details, _, _| {
|
||||
new_selections.insert(SelectedEntry {
|
||||
new_selections.push(SelectedEntry {
|
||||
entry_id,
|
||||
worktree_id: details.worktree_id,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
this.marked_entries = this
|
||||
.marked_entries
|
||||
.union(&new_selections)
|
||||
.cloned()
|
||||
.collect();
|
||||
for selection in &new_selections {
|
||||
if !project_panel.marked_entries.contains(selection) {
|
||||
project_panel.marked_entries.push(*selection);
|
||||
}
|
||||
}
|
||||
|
||||
this.selection = Some(clicked_entry);
|
||||
this.marked_entries.insert(clicked_entry);
|
||||
project_panel.selection = Some(clicked_entry);
|
||||
if !project_panel.marked_entries.contains(&clicked_entry) {
|
||||
project_panel.marked_entries.push(clicked_entry);
|
||||
}
|
||||
}
|
||||
} else if event.modifiers().secondary() {
|
||||
if event.click_count() > 1 {
|
||||
this.split_entry(entry_id, cx);
|
||||
project_panel.split_entry(entry_id, cx);
|
||||
} else {
|
||||
this.selection = Some(selection);
|
||||
if !this.marked_entries.insert(selection) {
|
||||
this.marked_entries.remove(&selection);
|
||||
project_panel.selection = Some(selection);
|
||||
if let Some(position) = project_panel.marked_entries.iter().position(|e| *e == selection) {
|
||||
project_panel.marked_entries.remove(position);
|
||||
} else {
|
||||
project_panel.marked_entries.push(selection);
|
||||
}
|
||||
}
|
||||
} else if kind.is_dir() {
|
||||
this.marked_entries.clear();
|
||||
project_panel.marked_entries.clear();
|
||||
if is_sticky {
|
||||
if let Some((_, _, index)) = this.index_for_entry(entry_id, worktree_id) {
|
||||
this.scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, sticky_index.unwrap_or(0));
|
||||
if let Some((_, _, index)) = project_panel.index_for_entry(entry_id, worktree_id) {
|
||||
project_panel.scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, sticky_index.unwrap_or(0));
|
||||
cx.notify();
|
||||
// move down by 1px so that clicked item
|
||||
// don't count as sticky anymore
|
||||
@@ -4230,16 +4285,16 @@ impl ProjectPanel {
|
||||
}
|
||||
}
|
||||
if event.modifiers().alt {
|
||||
this.toggle_expand_all(entry_id, window, cx);
|
||||
project_panel.toggle_expand_all(entry_id, window, cx);
|
||||
} else {
|
||||
this.toggle_expanded(entry_id, window, cx);
|
||||
project_panel.toggle_expanded(entry_id, window, cx);
|
||||
}
|
||||
} else {
|
||||
let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
|
||||
let click_count = event.click_count();
|
||||
let focus_opened_item = !preview_tabs_enabled || click_count > 1;
|
||||
let allow_preview = preview_tabs_enabled && click_count == 1;
|
||||
this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
|
||||
project_panel.open_entry(entry_id, focus_opened_item, allow_preview, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -4810,12 +4865,21 @@ impl ProjectPanel {
|
||||
{
|
||||
anyhow::bail!("can't reveal an ignored entry in the project panel");
|
||||
}
|
||||
let is_active_item_file_diff_view = self
|
||||
.workspace
|
||||
.upgrade()
|
||||
.and_then(|ws| ws.read(cx).active_item(cx))
|
||||
.map(|item| item.act_as_type(TypeId::of::<FileDiffView>(), cx).is_some())
|
||||
.unwrap_or(false);
|
||||
if is_active_item_file_diff_view {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let worktree_id = worktree.id();
|
||||
self.expand_entry(worktree_id, entry_id, cx);
|
||||
self.update_visible_entries(Some((worktree_id, entry_id)), cx);
|
||||
self.marked_entries.clear();
|
||||
self.marked_entries.insert(SelectedEntry {
|
||||
self.marked_entries.push(SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id,
|
||||
});
|
||||
@@ -5170,6 +5234,7 @@ impl Render for ProjectPanel {
|
||||
.on_action(cx.listener(Self::unfold_directory))
|
||||
.on_action(cx.listener(Self::fold_directory))
|
||||
.on_action(cx.listener(Self::remove_from_project))
|
||||
.on_action(cx.listener(Self::compare_marked_files))
|
||||
.when(!project.is_read_only(cx), |el| {
|
||||
el.on_action(cx.listener(Self::new_file))
|
||||
.on_action(cx.listener(Self::new_directory))
|
||||
|
||||
@@ -8,7 +8,7 @@ use settings::SettingsStore;
|
||||
use std::path::{Path, PathBuf};
|
||||
use util::path;
|
||||
use workspace::{
|
||||
AppState, Pane,
|
||||
AppState, ItemHandle, Pane,
|
||||
item::{Item, ProjectItem},
|
||||
register_project_item,
|
||||
};
|
||||
@@ -3068,7 +3068,7 @@ async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
|
||||
panel.update(cx, |this, cx| {
|
||||
let drag = DraggedSelection {
|
||||
active_selection: this.selection.unwrap(),
|
||||
marked_selections: Arc::new(this.marked_entries.clone()),
|
||||
marked_selections: this.marked_entries.clone().into(),
|
||||
};
|
||||
let target_entry = this
|
||||
.project
|
||||
@@ -5562,10 +5562,10 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
|
||||
worktree_id,
|
||||
entry_id: child_file.id,
|
||||
},
|
||||
marked_selections: Arc::new(BTreeSet::from([SelectedEntry {
|
||||
marked_selections: Arc::new([SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: child_file.id,
|
||||
}])),
|
||||
}]),
|
||||
};
|
||||
let result =
|
||||
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
|
||||
@@ -5604,7 +5604,7 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
|
||||
worktree_id,
|
||||
entry_id: child_file.id,
|
||||
},
|
||||
marked_selections: Arc::new(BTreeSet::from([
|
||||
marked_selections: Arc::new([
|
||||
SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: child_file.id,
|
||||
@@ -5613,7 +5613,7 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
|
||||
worktree_id,
|
||||
entry_id: sibling_file.id,
|
||||
},
|
||||
])),
|
||||
]),
|
||||
};
|
||||
let result =
|
||||
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
|
||||
@@ -5821,6 +5821,186 @@ async fn test_hide_root(cx: &mut gpui::TestAppContext) {
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
|
||||
init_test_with_editor(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor().clone());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"file1.txt": "content of file1",
|
||||
"file2.txt": "content of file2",
|
||||
"dir1": {
|
||||
"file3.txt": "content of file3"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
|
||||
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
|
||||
|
||||
let file1_path = path!("root/file1.txt");
|
||||
let file2_path = path!("root/file2.txt");
|
||||
select_path_with_mark(&panel, file1_path, cx);
|
||||
select_path_with_mark(&panel, file2_path, cx);
|
||||
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.compare_marked_files(&CompareMarkedFiles, window, cx);
|
||||
});
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
workspace
|
||||
.update(cx, |workspace, _, cx| {
|
||||
let active_items = workspace
|
||||
.panes()
|
||||
.iter()
|
||||
.filter_map(|pane| pane.read(cx).active_item())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(active_items.len(), 1);
|
||||
let diff_view = active_items
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap()
|
||||
.downcast::<FileDiffView>()
|
||||
.expect("Open item should be an FileDiffView");
|
||||
assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
|
||||
assert_eq!(
|
||||
diff_view.tab_tooltip_text(cx).unwrap(),
|
||||
format!("{} ↔ {}", file1_path, file2_path)
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
|
||||
let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
|
||||
let worktree_id = panel.update(cx, |panel, cx| {
|
||||
panel
|
||||
.project
|
||||
.read(cx)
|
||||
.worktrees(cx)
|
||||
.next()
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.id()
|
||||
});
|
||||
|
||||
let expected_entries = [
|
||||
SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: file1_entry_id,
|
||||
},
|
||||
SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: file2_entry_id,
|
||||
},
|
||||
];
|
||||
panel.update(cx, |panel, _cx| {
|
||||
assert_eq!(
|
||||
&panel.marked_entries, &expected_entries,
|
||||
"Should keep marked entries after comparison"
|
||||
);
|
||||
});
|
||||
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.project.update(cx, |_, cx| {
|
||||
cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
|
||||
})
|
||||
});
|
||||
|
||||
panel.update(cx, |panel, _cx| {
|
||||
assert_eq!(
|
||||
&panel.marked_entries, &expected_entries,
|
||||
"Marked entries should persist after focusing back on the project panel"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
|
||||
init_test_with_editor(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor().clone());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"file1.txt": "content of file1",
|
||||
"file2.txt": "content of file2",
|
||||
"dir1": {},
|
||||
"dir2": {
|
||||
"file3.txt": "content of file3"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
|
||||
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
|
||||
|
||||
// Test 1: When only one file is selected, there should be no compare option
|
||||
select_path(&panel, "root/file1.txt", cx);
|
||||
|
||||
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
|
||||
assert_eq!(
|
||||
selected_files, None,
|
||||
"Should not have compare option when only one file is selected"
|
||||
);
|
||||
|
||||
// Test 2: When multiple files are selected, there should be a compare option
|
||||
select_path_with_mark(&panel, "root/file1.txt", cx);
|
||||
select_path_with_mark(&panel, "root/file2.txt", cx);
|
||||
|
||||
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
|
||||
assert!(
|
||||
selected_files.is_some(),
|
||||
"Should have files selected for comparison"
|
||||
);
|
||||
if let Some((file1, file2)) = selected_files {
|
||||
assert!(
|
||||
file1.to_string_lossy().ends_with("file1.txt")
|
||||
&& file2.to_string_lossy().ends_with("file2.txt"),
|
||||
"Should have file1.txt and file2.txt as the selected files when multi-selecting"
|
||||
);
|
||||
}
|
||||
|
||||
// Test 3: Selecting a directory shouldn't count as a comparable file
|
||||
select_path_with_mark(&panel, "root/dir1", cx);
|
||||
|
||||
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
|
||||
assert!(
|
||||
selected_files.is_some(),
|
||||
"Directory selection should not affect comparable files"
|
||||
);
|
||||
if let Some((file1, file2)) = selected_files {
|
||||
assert!(
|
||||
file1.to_string_lossy().ends_with("file1.txt")
|
||||
&& file2.to_string_lossy().ends_with("file2.txt"),
|
||||
"Selecting a directory should not affect the number of comparable files"
|
||||
);
|
||||
}
|
||||
|
||||
// Test 4: Selecting one more file
|
||||
select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
|
||||
|
||||
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
|
||||
assert!(
|
||||
selected_files.is_some(),
|
||||
"Directory selection should not affect comparable files"
|
||||
);
|
||||
if let Some((file1, file2)) = selected_files {
|
||||
assert!(
|
||||
file1.to_string_lossy().ends_with("file2.txt")
|
||||
&& file2.to_string_lossy().ends_with("file3.txt"),
|
||||
"Selecting a directory should not affect the number of comparable files"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
|
||||
let path = path.as_ref();
|
||||
panel.update(cx, |panel, cx| {
|
||||
@@ -5855,7 +6035,7 @@ fn select_path_with_mark(
|
||||
entry_id,
|
||||
};
|
||||
if !panel.marked_entries.contains(&entry) {
|
||||
panel.marked_entries.insert(entry);
|
||||
panel.marked_entries.push(entry);
|
||||
}
|
||||
panel.selection = Some(entry);
|
||||
return;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user