diff --git a/.cargo/ci-config.toml b/.cargo/ci-config.toml deleted file mode 100644 index 664419837b3559..00000000000000 --- a/.cargo/ci-config.toml +++ /dev/null @@ -1,15 +0,0 @@ -# This config is different from config.toml in this directory, as the latter is recognized by Cargo. -# This file is placed in $HOME/.cargo/config.toml on CI runs. Cargo then merges Zeds .cargo/config.toml with $HOME/.cargo/config.toml -# with preference for settings from Zeds config.toml. -# TL;DR: If a value is set in both ci-config.toml and config.toml, config.toml value takes precedence. -# Arrays are merged together though. See: https://doc.rust-lang.org/cargo/reference/config.html#hierarchical-structure -# The intent for this file is to configure CI build process with a divergance from Zed developers experience; for example, in this config file -# we use `-D warnings` for rustflags (which makes compilation fail in presence of warnings during build process). Placing that in developers `config.toml` -# would be incovenient. -# We *could* override things like RUSTFLAGS manually by setting them as environment variables, but that is less DRY; worse yet, if you forget to set proper environment variables -# in one spot, that's going to trigger a rebuild of all of the artifacts. Using ci-config.toml we can define these overrides for CI in one spot and not worry about it. -[build] -rustflags = ["-D", "warnings"] - -[alias] -xtask = "run --package xtask --" diff --git a/.cargo/collab-config.toml b/.cargo/collab-config.toml new file mode 100644 index 00000000000000..74603802d8e5d6 --- /dev/null +++ b/.cargo/collab-config.toml @@ -0,0 +1,5 @@ +# This file is used to build collab in a Docker image. +# In particular, we don't use clang. +[build] +# v0 mangling scheme provides more detailed backtraces around closures +rustflags = ["-C", "symbol-mangling-version=v0", "--cfg", "tokio_unstable"] diff --git a/.cargo/config.toml b/.cargo/config.toml index d73dead142ffd7..39fabdb5b8519a 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -4,3 +4,11 @@ rustflags = ["-C", "symbol-mangling-version=v0", "--cfg", "tokio_unstable"] [alias] xtask = "run --package xtask --" + +[target.x86_64-unknown-linux-gnu] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=mold"] + +[target.aarch64-unknown-linux-gnu] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=mold"] diff --git a/.dockerignore b/.dockerignore index a6e6c35ee50fe0..337b4d42623c48 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,13 @@ +.git +.github +**/.gitignore +**/.gitkeep +.gitattributes +.mailmap **/target zed.xcworkspace .DS_Store +compose.yml plugins/bin script/node_modules styles/node_modules diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000000000..726accb0ae45f0 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,28 @@ +# .git-blame-ignore-revs +# +# This file consists of a list of commits that should be ignored for +# `git blame` purposes. This is useful for ignoring commits that only +# changed whitespace / indentation / formatting, but did not change +# the underlying syntax tree. +# +# GitHub will pick this up automatically for blame views: +# https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view +# To use this file locally, run: +# git blame --ignore-revs-file .git-blame-ignore-revs +# To always use this file by default, run: +# git config --local blame.ignoreRevsFile .git-blame-ignore-revs +# To disable this functionality, run: +# git config --local blame.ignoreRevsFile "" +# Comments are optional, but may provide helpful context. + +# 2023-04-20 Set default tab_size for JSON to 2 and apply new formatting +# https://github.com/zed-industries/zed/pull/2394 +eca93c124a488b4e538946cd2d313bd571aa2b86 + +# 2024-02-25 Format JSON files in assets/ +# https://github.com/zed-industries/zed/pull/8405 +ffdda588b41f7d9d270ffe76cab116f828ad545e + +# 2024-07-05 Improved formatting of default keymaps (single line per bind) +# https://github.com/zed-industries/zed/pull/13887 +813cc3f5e537372fc86720b5e71b6e1c815440ab diff --git a/.github/ISSUE_TEMPLATE/0_feature_request.yml b/.github/ISSUE_TEMPLATE/0_feature_request.yml index c5e1fa9237e147..d8dc7950f68643 100644 --- a/.github/ISSUE_TEMPLATE/0_feature_request.yml +++ b/.github/ISSUE_TEMPLATE/0_feature_request.yml @@ -2,23 +2,23 @@ name: Feature Request description: "Tip: open this issue template from within Zed with the `request feature` command palette action" labels: ["admin read", "triage", "enhancement"] body: - - type: checkboxes - attributes: - label: Check for existing issues - description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it. - options: - - label: Completed - required: true - - type: textarea - attributes: - label: Describe the feature - description: A clear and concise description of what you want to happen. - validations: + - type: checkboxes + attributes: + label: Check for existing issues + description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it. + options: + - label: Completed required: true - - type: textarea - attributes: - label: | - If applicable, add mockups / screenshots to help present your vision of the feature - description: Drag images into the text input below - validations: - required: false + - type: textarea + attributes: + label: Describe the feature + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: textarea + attributes: + label: | + If applicable, add mockups / screenshots to help present your vision of the feature + description: Drag images into the text input below + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/1_bug_report.yml b/.github/ISSUE_TEMPLATE/1_bug_report.yml index ccdd084533ae1d..a4c1e5aa6ee8c5 100644 --- a/.github/ISSUE_TEMPLATE/1_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1_bug_report.yml @@ -1,40 +1,46 @@ name: Bug Report description: | - Use this template for **non-crash-related** bug reports. - Tip: open this issue template from within Zed with the `file bug report` command palette action. + Use this template for **non-crash-related** bug reports. + Tip: open this issue template from within Zed with the `file bug report` command palette action. labels: ["admin read", "triage", "defect"] body: - - type: checkboxes - attributes: - label: Check for existing issues - description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it. - options: - - label: Completed - required: true - - type: textarea - attributes: - label: Describe the bug / provide steps to reproduce it - description: A clear and concise description of what the bug is. - validations: + - type: checkboxes + attributes: + label: Check for existing issues + description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it. + options: + - label: Completed required: true - - type: textarea - id: environment - attributes: - label: Environment - description: Run the `copy system specs into clipboard` command palette action and paste the output in the field below. - validations: - required: true - - type: textarea - attributes: - label: If applicable, add mockups / screenshots to help explain present your vision of the feature - description: Drag issues into the text input below - validations: - required: false - - type: textarea - attributes: - label: If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue. - description: | - Drag Zed.log into the text input below. - If you only need the most recent lines, you can run the `zed: open log` command palette action to see the last 1000. - validations: - required: false + - type: textarea + attributes: + label: Describe the bug / provide steps to reproduce it + description: A clear and concise description of what the bug is. + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Run the `copy system specs into clipboard` command palette action and paste the output in the field below. + validations: + required: true + - type: textarea + attributes: + label: If applicable, add mockups / screenshots to help explain present your vision of the feature + description: Drag issues into the text input below + validations: + required: false + - type: textarea + attributes: + label: If applicable, attach your Zed.log file to this issue. + description: | + macOS: `~/Library/Logs/Zed/Zed.log` + Linux: `~/.local/share/zed/logs/Zed.log` or $XDG_DATA_HOME + If you only need the most recent lines, you can run the `zed: open log` command palette action to see the last 1000. + value: | +
Zed.log
+        
+
+        
+ validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2_crash_report.yml b/.github/ISSUE_TEMPLATE/2_crash_report.yml index c877ff1f4d4c9b..b170315d1cd497 100644 --- a/.github/ISSUE_TEMPLATE/2_crash_report.yml +++ b/.github/ISSUE_TEMPLATE/2_crash_report.yml @@ -1,33 +1,39 @@ name: Crash Report description: | - Use this template for crash reports. + Use this template for crash reports. labels: ["admin read", "triage", "defect", "panic / crash"] body: - - type: checkboxes - attributes: - label: Check for existing issues - description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it. - options: - - label: Completed - required: true - - type: textarea - attributes: - label: Describe the bug / provide steps to reproduce it - description: A clear and concise description of what the bug is. - validations: + - type: checkboxes + attributes: + label: Check for existing issues + description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it. + options: + - label: Completed required: true - - type: textarea - id: environment - attributes: - label: Environment - description: Run the `copy system specs into clipboard` command palette action and paste the output in the field below. - validations: - required: true - - type: textarea - attributes: - label: If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue. - description: | - Drag Zed.log into the text input below. - If you only need the most recent lines, you can run the `zed: open log` command palette action to see the last 1000. - validations: - required: false + - type: textarea + attributes: + label: Describe the bug / provide steps to reproduce it + description: A clear and concise description of what the bug is. + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Run the `copy system specs into clipboard` command palette action and paste the output in the field below. + validations: + required: true + - type: textarea + attributes: + label: If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue. + description: | + macOS: `~/Library/Logs/Zed/Zed.log` + Linux: `~/.local/share/zed/logs/Zed.log` or $XDG_DATA_HOME + If you only need the most recent lines, you can run the `zed: open log` command palette action to see the last 1000. + value: | +
Zed.log
+        
+
+        
+ validations: + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b4f6090ab258fa..ecfc7a9ab81e87 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,7 +2,7 @@ Release Notes: -- Added/Fixed/Improved ... ([#](https://github.com/zed-industries/zed/issues/)). +- Added/Fixed/Improved ... ([#NNNNN](https://github.com/zed-industries/zed/issues/NNNNN)). Optionally, include screenshots / media showcasing your addition that can be included in the release notes. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35d764afb9044d..9c834ec25c1f46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ env: jobs: style: + timeout-minutes: 60 name: Check formatting and spelling runs-on: - self-hosted @@ -37,9 +38,6 @@ jobs: - name: Remove untracked files run: git clean -df - - name: Set up default .cargo/config.toml - run: cp ./.cargo/ci-config.toml ~/.cargo/config.toml - - name: Check spelling run: | if ! which typos > /dev/null; then @@ -53,6 +51,9 @@ jobs: - name: Check unused dependencies uses: bnjbvr/cargo-machete@main + - name: Check licenses are present + run: script/check-licenses + - name: Check license generation run: script/generate-licenses /tmp/zed_licenses_output @@ -73,10 +74,11 @@ jobs: version: v1.29.0 - uses: bufbuild/buf-breaking-action@v1 with: - input: "crates/rpc/proto/" - against: "https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/rpc/proto/" + input: "crates/proto/proto/" + against: "https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/proto/proto/" macos_tests: + timeout-minutes: 60 name: (macOS) Run Clippy and tests runs-on: - self-hosted @@ -88,7 +90,7 @@ jobs: clean: false - name: cargo clippy - run: cargo xtask clippy + run: ./script/clippy - name: Run tests uses: ./.github/actions/run_tests @@ -99,8 +101,8 @@ jobs: - name: Build other binaries and features run: cargo build --workspace --bins --all-features; cargo check -p gpui --features "macos-blade" - # todo(linux): Actually run the tests linux_tests: + timeout-minutes: 60 name: (Linux) Run Clippy and tests runs-on: - self-hosted @@ -115,13 +117,17 @@ jobs: clean: false - name: cargo clippy - run: cargo xtask clippy + run: ./script/clippy + + - name: Run tests + uses: ./.github/actions/run_tests - name: Build Zed run: cargo build -p zed # todo(windows): Actually run the tests windows_tests: + timeout-minutes: 60 name: (Windows) Run Clippy and tests runs-on: hosted-windows-1 steps: @@ -136,12 +142,13 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} - name: cargo clippy - run: cargo xtask clippy + run: ./script/clippy - name: Build Zed run: cargo build -p zed bundle-mac: + timeout-minutes: 60 name: Create a macOS bundle runs-on: - self-hosted @@ -247,11 +254,12 @@ jobs: target/aarch64-apple-darwin/release/Zed-aarch64.dmg target/x86_64-apple-darwin/release/Zed-x86_64.dmg target/release/Zed.dmg - body_file: target/release-notes.md + body_path: target/release-notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} bundle-linux: + timeout-minutes: 60 name: Create a Linux bundle runs-on: - self-hosted @@ -299,10 +307,7 @@ jobs: exit 1 fi - - name: Generate license file - run: script/generate-licenses - - - name: Create and upload Linux .tar.gz bundle + - name: Create Linux .tar.gz bundle run: script/bundle-linux - name: Upload Linux bundle to workflow run if main branch or specific label @@ -310,11 +315,10 @@ jobs: if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }} with: name: zed-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-linux-gnu.tar.gz - path: zed-*.tar.gz + path: target/release/zed-*.tar.gz - name: Upload app bundle to release uses: softprops/action-gh-release@v1 - if: ${{ env.RELEASE_CHANNEL == 'preview' }} with: draft: true prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} @@ -322,3 +326,86 @@ jobs: body: "" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + bundle-linux-aarch64: + timeout-minutes: 60 + name: Create arm64 Linux bundle + runs-on: + - hosted-linux-arm-1 + if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') }} + needs: [linux_tests] + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + clean: false + - name: "Setup jq" + uses: dcarbone/install-jq-action@v2 + + - name: Set up Clang + run: | + sudo apt-get update + sudo apt-get install -y llvm-10 clang-10 build-essential cmake pkg-config libasound2-dev libfontconfig-dev libwayland-dev libxkbcommon-x11-dev libssl-dev libsqlite3-dev libzstd-dev libvulkan1 libgit2-dev + echo "/usr/lib/llvm-10/bin" >> $GITHUB_PATH + + - uses: rui314/setup-mold@v1 + with: + mold-version: 2.32.0 + + - name: rustup + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Limit target directory size + run: script/clear-target-dir-if-larger-than 100 + + - name: Determine version and release channel + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + set -eu + + version=$(script/get-crate-version zed) + channel=$(cat crates/zed/RELEASE_CHANNEL) + echo "Publishing version: ${version} on release channel ${channel}" + echo "RELEASE_CHANNEL=${channel}" >> $GITHUB_ENV + + expected_tag_name="" + case ${channel} in + stable) + expected_tag_name="v${version}";; + preview) + expected_tag_name="v${version}-pre";; + nightly) + expected_tag_name="v${version}-nightly";; + *) + echo "can't publish a release on channel ${channel}" + exit 1;; + esac + if [[ $GITHUB_REF_NAME != $expected_tag_name ]]; then + echo "invalid release tag ${GITHUB_REF_NAME}. expected ${expected_tag_name}" + exit 1 + fi + + - name: Create and upload Linux .tar.gz bundle + run: script/bundle-linux + + - name: Upload Linux bundle to workflow run if main branch or specific label + uses: actions/upload-artifact@v4 + if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }} + with: + name: zed-${{ github.event.pull_request.head.sha || github.sha }}-aarch64-unknown-linux-gnu.tar.gz + path: target/release/zed-*.tar.gz + + - name: Upload app bundle to release + uses: softprops/action-gh-release@v1 + if: ${{ env.RELEASE_CHANNEL == 'preview' || env.RELEASE_CHANNEL == 'stable' }} + with: + draft: true + prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} + files: target/release/zed-linux-aarch64.tar.gz + body: "" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index a6044dc4f464c3..b14358dbe790c0 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -18,7 +18,7 @@ jobs: - uses: pnpm/action-setup@v3 with: - version: 8 + version: 9 - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index e6f741bcb16a4d..654532e2aecdd7 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -27,7 +27,7 @@ jobs: uses: ./.github/actions/check_style - name: Run clippy - run: cargo xtask clippy + run: ./script/clippy tests: name: Run tests @@ -75,6 +75,9 @@ jobs: with: clean: false + - name: Set up default .cargo/config.toml + run: cp ./.cargo/collab-config.toml ./.cargo/config.toml + - name: Build docker image run: docker build . --build-arg GITHUB_SHA=$GITHUB_SHA --tag registry.digitalocean.com/zed/collab:$GITHUB_SHA diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index cd31885ca0db8c..33827b574cb974 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -15,6 +15,7 @@ env: jobs: style: + timeout-minutes: 60 name: Check formatting and Clippy lints if: github.repository_owner == 'zed-industries' runs-on: @@ -31,8 +32,9 @@ jobs: uses: ./.github/actions/check_style - name: Run clippy - run: cargo xtask clippy + run: ./script/clippy tests: + timeout-minutes: 60 name: Run tests if: github.repository_owner == 'zed-industries' runs-on: @@ -49,6 +51,7 @@ jobs: uses: ./.github/actions/run_tests bundle-mac: + timeout-minutes: 60 name: Create a macOS bundle if: github.repository_owner == 'zed-industries' runs-on: @@ -90,8 +93,9 @@ jobs: - name: Upload Zed Nightly run: script/upload-nightly macos - bundle-deb: - name: Create a Linux *.tar.gz bundle + bundle-linux-x86: + timeout-minutes: 60 + name: Create a Linux *.tar.gz bundle for x86 if: github.repository_owner == 'zed-industries' runs-on: - self-hosted @@ -117,8 +121,57 @@ jobs: echo "Publishing version: ${version} on release channel nightly" echo "nightly" > crates/zed/RELEASE_CHANNEL - - name: Generate license file - run: script/generate-licenses + - name: Create Linux .tar.gz bundle + run: script/bundle-linux + + - name: Upload Zed Nightly + run: script/upload-nightly linux-targz + + bundle-linux-arm: + timeout-minutes: 60 + name: Create a Linux *.tar.gz bundle for ARM + if: github.repository_owner == 'zed-industries' + runs-on: + - self-hosted + - hosted-linux-arm-1 + needs: tests + env: + DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} + DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + clean: false + + - name: "Setup jq" + uses: dcarbone/install-jq-action@v2 + + - name: Set up Clang + run: | + sudo apt-get update + sudo apt-get install -y llvm-10 clang-10 build-essential cmake pkg-config libasound2-dev libfontconfig-dev libwayland-dev libxkbcommon-x11-dev libssl-dev libsqlite3-dev libzstd-dev libvulkan1 libgit2-dev + echo "/usr/lib/llvm-10/bin" >> $GITHUB_PATH + + - uses: rui314/setup-mold@v1 + with: + mold-version: 2.32.0 + + - name: rustup + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Limit target directory size + run: script/clear-target-dir-if-larger-than 100 + + - name: Set release channel to nightly + run: | + set -euo pipefail + version=$(git rev-parse --short HEAD) + echo "Publishing version: ${version} on release channel nightly" + echo "nightly" > crates/zed/RELEASE_CHANNEL - name: Create Linux .tar.gz bundle run: script/bundle-linux diff --git a/.gitignore b/.gitignore index 48e329d820b334..de15d0abc360e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.direnv .idea **/target **/cargo-target @@ -7,6 +8,8 @@ /script/node_modules /crates/theme/schemas/theme.json /crates/collab/seed.json +/crates/zed/resources/flatpak/flatpak-cargo-sources.json +/dev.zed.Zed*.json /assets/*licenses.md **/venv .build @@ -25,3 +28,4 @@ DerivedData/ .blob_store .vscode .wrangler +.flatpak-builder diff --git a/.mailmap b/.mailmap index ef4cd1c4c2ff2d..ee29b0f328675c 100644 --- a/.mailmap +++ b/.mailmap @@ -7,14 +7,24 @@ # Reference: https://git-scm.com/docs/gitmailmap # Keep these entries sorted alphabetically. -# In Zed: `editor: sort lines case sensitive` +# In Zed: `editor: sort lines case insensitive` +Alex Viscreanu +Alex Viscreanu +amtoaer +amtoaer Antonio Scandurra Antonio Scandurra +Bennet Bo Fenner +Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com> +Bennet Bo Fenner Christian Bergschneider Christian Bergschneider Conrad Irwin Conrad Irwin +Danilo Leal +Danilo Leal <67129314+danilo-leal@users.noreply.github.com> +Evren Sen <146845123+evrsen@users.noreply.github.com> Fernando Tagawa Fernando Tagawa Greg Morenz @@ -48,12 +58,27 @@ Nate Butler Nathan Sobo Nathan Sobo Nathan Sobo +Nigel Jose +Nigel Jose +Peter Tripp +Peter Tripp Petros Amoiridis Petros Amoiridis Piotr Osiewicz Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> +Pocæus +Pocæus +Rashid Almheiri +Rashid Almheiri <69181766+huwaireb@users.noreply.github.com> +Richard Feldman +Richard Feldman Robert Clover Robert Clover +Sergey Onufrienko Thorsten Ball Thorsten Ball Thorsten Ball +Vitaly Slobodin +Vitaly Slobodin +WindSoilder +张小白 <364772080@qq.com> diff --git a/.zed/settings.json b/.zed/settings.json index eedf2f37534ca8..e966ceb5d60927 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -14,11 +14,24 @@ }, "JSON": { "tab_size": 2, + "preferred_line_length": 100, + "formatter": "prettier" + }, + "JSONC": { + "tab_size": 2, + "preferred_line_length": 100, "formatter": "prettier" }, "JavaScript": { "tab_size": 2, "formatter": "prettier" + }, + "Rust": { + "tasks": { + "variables": { + "RUST_DEFAULT_PACKAGE_RUN": "zed" + } + } } }, "formatter": "auto", diff --git a/.zed/tasks.json b/.zed/tasks.json index 80465969e2b3b1..259ab07f3e0656 100644 --- a/.zed/tasks.json +++ b/.zed/tasks.json @@ -1,7 +1,7 @@ [ { "label": "clippy", - "command": "cargo", - "args": ["xtask", "clippy"] + "command": "./script/clippy", + "args": [] } ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b9719e90faf108..85e3aec944a717 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ We plan to set aside time each week to pair program with contributors on promisi Zed is made up of several smaller crates - let's go over those you're most likely to interact with: -- [`gpui`](/crates/gpui) is a GPU-accelerated UI framework which provides all of the building blocks for Zed. **We recommend familiarizing yourself with the root level GPUI documentation** +- [`gpui`](/crates/gpui) is a GPU-accelerated UI framework which provides all of the building blocks for Zed. **We recommend familiarizing yourself with the root level GPUI documentation.** - [`editor`](/crates/editor) contains the core `Editor` type that drives both the code editor and all various input fields within Zed. It also handles a display layer for LSP features such as Inlay Hints or code completions. - [`project`](/crates/project) manages files and navigation within the filetree. It is also Zed's side of communication with LSP. - [`workspace`](/crates/workspace) handles local state serialization and groups projects together. diff --git a/Cargo.toml b/Cargo.toml index 92cddee4b94383..2a409cc6558a03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,6 @@ members = [ "crates/anthropic", "crates/assets", "crates/assistant", - "crates/assistant2", "crates/assistant_slash_command", "crates/assistant_tooling", "crates/audio", @@ -22,6 +21,7 @@ members = [ "crates/command_palette_hooks", "crates/copilot", "crates/db", + "crates/dev_server_projects", "crates/diagnostics", "crates/editor", "crates/extension", @@ -42,8 +42,10 @@ members = [ "crates/gpui", "crates/gpui_macros", "crates/headless", + "crates/html_to_markdown", "crates/http", "crates/image_viewer", + "crates/indexed_docs", "crates/inline_completion_button", "crates/install_cli", "crates/journal", @@ -60,48 +62,55 @@ members = [ "crates/menu", "crates/multi_buffer", "crates/node_runtime", + "crates/notebook", "crates/notifications", + "crates/ollama", "crates/open_ai", "crates/outline", + "crates/outline_panel", + "crates/paths", "crates/picker", "crates/prettier", "crates/project", "crates/project_panel", "crates/project_symbols", + "crates/proto", "crates/quick_action_bar", "crates/recent_projects", "crates/refineable", "crates/refineable/derive_refineable", "crates/release_channel", - "crates/dev_server_projects", + "crates/repl", "crates/rich_text", "crates/rope", "crates/rpc", - "crates/task", - "crates/tasks_ui", "crates/search", "crates/semantic_index", "crates/semantic_version", "crates/settings", "crates/snippet", + "crates/snippet_provider", "crates/sqlez", "crates/sqlez_macros", "crates/story", "crates/storybook", "crates/sum_tree", - "crates/tab_switcher", "crates/supermaven", "crates/supermaven_api", + "crates/tab_switcher", + "crates/task", + "crates/tasks_ui", + "crates/telemetry_events", "crates/terminal", "crates/terminal_view", "crates/text", "crates/theme", "crates/theme_importer", "crates/theme_selector", - "crates/telemetry_events", "crates/time_format", + "crates/title_bar", "crates/ui", - "crates/ui_text_field", + "crates/ui_input", "crates/util", "crates/vcs_menu", "crates/vim", @@ -130,8 +139,10 @@ members = [ "extensions/prisma", "extensions/purescript", "extensions/ruby", + "extensions/snippets", "extensions/svelte", "extensions/terraform", + "extensions/test-extension", "extensions/toml", "extensions/uiua", "extensions/vue", @@ -148,12 +159,10 @@ ai = { path = "crates/ai" } anthropic = { path = "crates/anthropic" } assets = { path = "crates/assets" } assistant = { path = "crates/assistant" } -assistant2 = { path = "crates/assistant2" } assistant_slash_command = { path = "crates/assistant_slash_command" } assistant_tooling = { path = "crates/assistant_tooling" } audio = { path = "crates/audio" } auto_update = { path = "crates/auto_update" } -base64 = "0.13" breadcrumbs = { path = "crates/breadcrumbs" } call = { path = "crates/call" } channel = { path = "crates/channel" } @@ -163,11 +172,11 @@ clock = { path = "crates/clock" } collab = { path = "crates/collab" } collab_ui = { path = "crates/collab_ui" } collections = { path = "crates/collections" } -color = { path = "crates/color" } command_palette = { path = "crates/command_palette" } command_palette_hooks = { path = "crates/command_palette_hooks" } copilot = { path = "crates/copilot" } db = { path = "crates/db" } +dev_server_projects = { path = "crates/dev_server_projects" } diagnostics = { path = "crates/diagnostics" } editor = { path = "crates/editor" } extension = { path = "crates/extension" } @@ -186,11 +195,14 @@ google_ai = { path = "crates/google_ai" } gpui = { path = "crates/gpui" } gpui_macros = { path = "crates/gpui_macros" } headless = { path = "crates/headless" } +html_to_markdown = { path = "crates/html_to_markdown" } http = { path = "crates/http" } -install_cli = { path = "crates/install_cli" } image_viewer = { path = "crates/image_viewer" } +indexed_docs = { path = "crates/indexed_docs" } inline_completion_button = { path = "crates/inline_completion_button" } +install_cli = { path = "crates/install_cli" } journal = { path = "crates/journal" } +notebook = { path = "crates/notebook" } language = { path = "crates/language" } language_selector = { path = "crates/language_selector" } language_tools = { path = "crates/language_tools" } @@ -205,77 +217,91 @@ menu = { path = "crates/menu" } multi_buffer = { path = "crates/multi_buffer" } node_runtime = { path = "crates/node_runtime" } notifications = { path = "crates/notifications" } +ollama = { path = "crates/ollama" } open_ai = { path = "crates/open_ai" } outline = { path = "crates/outline" } +outline_panel = { path = "crates/outline_panel" } +paths = { path = "crates/paths" } picker = { path = "crates/picker" } plugin = { path = "crates/plugin" } plugin_macros = { path = "crates/plugin_macros" } prettier = { path = "crates/prettier" } project = { path = "crates/project" } -worktree = { path = "crates/worktree" } project_panel = { path = "crates/project_panel" } project_symbols = { path = "crates/project_symbols" } +proto = { path = "crates/proto" } quick_action_bar = { path = "crates/quick_action_bar" } recent_projects = { path = "crates/recent_projects" } release_channel = { path = "crates/release_channel" } -dev_server_projects = { path = "crates/dev_server_projects" } +repl = { path = "crates/repl" } rich_text = { path = "crates/rich_text" } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } -task = { path = "crates/task" } -tasks_ui = { path = "crates/tasks_ui" } search = { path = "crates/search" } semantic_index = { path = "crates/semantic_index" } semantic_version = { path = "crates/semantic_version" } settings = { path = "crates/settings" } snippet = { path = "crates/snippet" } +snippet_provider = { path = "crates/snippet_provider" } sqlez = { path = "crates/sqlez" } sqlez_macros = { path = "crates/sqlez_macros" } -supermaven = { path = "crates/supermaven" } -supermaven_api = { path = "crates/supermaven_api" } story = { path = "crates/story" } storybook = { path = "crates/storybook" } sum_tree = { path = "crates/sum_tree" } +supermaven = { path = "crates/supermaven" } +supermaven_api = { path = "crates/supermaven_api" } tab_switcher = { path = "crates/tab_switcher" } +task = { path = "crates/task" } +tasks_ui = { path = "crates/tasks_ui" } +telemetry_events = { path = "crates/telemetry_events" } terminal = { path = "crates/terminal" } terminal_view = { path = "crates/terminal_view" } text = { path = "crates/text" } theme = { path = "crates/theme" } theme_importer = { path = "crates/theme_importer" } theme_selector = { path = "crates/theme_selector" } -telemetry_events = { path = "crates/telemetry_events" } time_format = { path = "crates/time_format" } +title_bar = { path = "crates/title_bar" } ui = { path = "crates/ui" } -ui_text_field = { path = "crates/ui_text_field" } +ui_input = { path = "crates/ui_input" } util = { path = "crates/util" } vcs_menu = { path = "crates/vcs_menu" } vim = { path = "crates/vim" } welcome = { path = "crates/welcome" } workspace = { path = "crates/workspace" } +worktree = { path = "crates/worktree" } zed = { path = "crates/zed" } zed_actions = { path = "crates/zed_actions" } -anyhow = "1.0.57" +alacritty_terminal = "0.23" any_vec = "0.13" +anyhow = "1.0.57" +ashpd = "0.9.1" async-compression = { version = "0.4", features = ["gzip", "futures-io"] } +async-dispatcher = { version = "0.1" } async-fs = "1.6" async-recursion = "1.0.0" async-tar = "0.4.2" async-trait = "0.1" +async-watch = "0.3.1" async_zip = { version = "0.0.17", features = ["deflate", "deflate64"] } -bitflags = "2.4.2" -blade-graphics = { git = "https://github.com/kvark/blade", rev = "e35b2d41f221a48b75f7cf2e78a81e7ecb7a383c" } -blade-macros = { git = "https://github.com/kvark/blade", rev = "e35b2d41f221a48b75f7cf2e78a81e7ecb7a383c" } +base64 = "0.13" +bitflags = "2.6.0" +blade-graphics = { git = "https://github.com/zed-industries/blade", rev = "a477c2008db27db0b9f745715e119b3ee7ab7818" } +blade-macros = { git = "https://github.com/zed-industries/blade", rev = "a477c2008db27db0b9f745715e119b3ee7ab7818" } +blade-util = { git = "https://github.com/zed-industries/blade", rev = "a477c2008db27db0b9f745715e119b3ee7ab7818" } cap-std = "3.0" cargo_toml = "0.20" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.4", features = ["derive"] } clickhouse = { version = "0.11.6" } -ctor = "0.2.6" -signal-hook = "0.3.17" +cocoa = "0.25" core-foundation = { version = "0.9.3" } core-foundation-sys = "0.8.6" +ctor = "0.2.6" +dashmap = "5.5.3" derive_more = "0.99.17" +dirs = "4.0" emojis = "0.6.1" env_logger = "0.9" exec = "0.3.1" @@ -283,17 +309,17 @@ fork = "0.1.23" futures = "0.3" futures-batch = "0.6.1" futures-lite = "1.13" -git2 = { version = "0.18", default-features = false } +git2 = { version = "0.19", default-features = false } globset = "0.4" -heed = { git = "https://github.com/meilisearch/heed", rev = "036ac23f73a021894974b9adc815bc95b3e0482a", features = [ - "read-txn-no-tls", -] } +heed = { version = "0.20.1", features = ["read-txn-no-tls"] } hex = "0.4.3" +html5ever = "0.27.0" ignore = "0.4.22" +image = "0.25.1" +indexmap = { version = "1.6.2", features = ["serde"] } indoc = "1" # We explicitly disable http2 support in isahc. isahc = { version = "1.7.2", default-features = false, features = [ - "static-curl", "text-decoding", ] } itertools = "0.11.0" @@ -301,8 +327,10 @@ lazy_static = "1.4.0" libc = "0.2" linkify = "0.10.0" log = { version = "0.4.16", features = ["kv_unstable_serde"] } +markup5ever_rcdom = "0.3.0" nanoid = "0.4" nix = "0.28" +num-format = "0.4.4" once_cell = "1.19.0" ordered-float = "2.1.1" palette = { version = "0.7.5", default-features = false, features = ["std"] } @@ -319,6 +347,9 @@ rand = "0.8.5" refineable = { path = "./crates/refineable" } regex = "1.5" repair_json = "0.1.0" +runtimelib = { version = "0.12", default-features = false, features = [ + "async-dispatcher-runtime", +] } rusqlite = { version = "0.29.0", features = ["blob", "array", "modern_sqlite"] } rust-embed = { version = "8.4", features = ["include-exclude"] } schemars = "0.8" @@ -334,6 +365,9 @@ serde_repr = "0.1" sha2 = "0.10" shellexpand = "2.1.0" shlex = "1.3.0" +signal-hook = "0.3.17" +similar = "1.3" +simplelog = "0.12.2" smallvec = { version = "1.6", features = ["union"] } smol = "1.2" strum = { version = "0.25.0", features = ["derive"] } @@ -353,28 +387,28 @@ toml = "0.8" tokio = { version = "1", features = ["full"] } tower-http = "0.4.4" tree-sitter = { version = "0.20", features = ["wasm"] } -tree-sitter-bash = { git = "https://github.com/tree-sitter/tree-sitter-bash", rev = "7331995b19b8f8aba2d5e26deb51d2195c18bc94" } +tree-sitter-bash = "0.20.5" tree-sitter-c = "0.20.1" -tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "f44509141e7e483323d2ec178f2d2e6c0fc041c1" } -tree-sitter-css = { git = "https://github.com/tree-sitter/tree-sitter-css", rev = "769203d0f9abe1a9a691ac2b9fe4bb4397a73c51" } -tree-sitter-elixir = { git = "https://github.com/elixir-lang/tree-sitter-elixir", rev = "a2861e88a730287a60c11ea9299c033c7d076e30" } +tree-sitter-cpp = "0.20.5" +tree-sitter-css = "0.20" +tree-sitter-elixir = "0.1.1" tree-sitter-embedded-template = "0.20.0" tree-sitter-go = { git = "https://github.com/tree-sitter/tree-sitter-go", rev = "b82ab803d887002a0af11f6ce63d72884580bf33" } -tree-sitter-gomod = { git = "https://github.com/camdencheek/tree-sitter-go-mod" } +tree-sitter-gomod = "1.0.1" tree-sitter-gowork = { git = "https://github.com/d1y/tree-sitter-go-work" } rustc-demangle = "0.1.23" tree-sitter-heex = { git = "https://github.com/phoenixframework/tree-sitter-heex", rev = "2e1348c3cf2c9323e87c2744796cf3f3868aa82a" } tree-sitter-html = "0.19.0" tree-sitter-jsdoc = { git = "https://github.com/tree-sitter/tree-sitter-jsdoc", rev = "6a6cf9e7341af32d8e2b2e24a37fbfebefc3dc55" } -tree-sitter-json = { git = "https://github.com/tree-sitter/tree-sitter-json", rev = "40a81c01a40ac48744e0c8ccabbaba1920441199" } +tree-sitter-json = "0.20.2" tree-sitter-markdown = { git = "https://github.com/MDeiml/tree-sitter-markdown", rev = "330ecab87a3e3a7211ac69bbadc19eabecdb1cca" } tree-sitter-proto = { git = "https://github.com/rewinfrey/tree-sitter-proto", rev = "36d54f288aee112f13a67b550ad32634d0c2cb52" } tree-sitter-python = "0.20.2" tree-sitter-regex = "0.20.0" tree-sitter-ruby = "0.20.0" tree-sitter-rust = "0.20.3" -tree-sitter-typescript = { git = "https://github.com/tree-sitter/tree-sitter-typescript", rev = "5d20856f34315b068c41edaee2ac8a100081d259" } -tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "f545a41f57502e1b5ddf2a6668896c1b0620f930" } +tree-sitter-typescript = "0.20.5" +tree-sitter-yaml = "0.0.1" unindent = "0.1.7" unicase = "2.6" unicode-segmentation = "1.10" @@ -395,12 +429,13 @@ wit-component = "0.201" sys-locale = "0.3.1" [workspace.dependencies.windows] -version = "0.56.0" +version = "0.57" features = [ "implement", "Foundation_Numerics", "System", "System_Threading", + "UI_ViewManagement", "Wdk_System_SystemServices", "Win32_Globalization", "Win32_Graphics_Direct2D", @@ -419,11 +454,11 @@ features = [ "Win32_System_Com_StructuredStorage", "Win32_System_DataExchange", "Win32_System_LibraryLoader", + "Win32_System_Memory", "Win32_System_Ole", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", - "Win32_System_Time", "Win32_System_WinRT", "Win32_UI_Controls", "Win32_UI_HiDpi", @@ -436,11 +471,12 @@ features = [ [patch.crates-io] tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "7b4894ba2ae81b988846676f54c0988d4027ef4f" } # Workaround for a broken nightly build of gpui: See #7644 and revisit once 0.5.3 is released. -pathfinder_simd = { git = "https://github.com/servo/pathfinder.git", rev = "30419d07660dc11a21e42ef4a7fa329600cff152" } +pathfinder_simd = { git = "https://github.com/servo/pathfinder.git", rev = "4968e819c0d9b015437ffc694511e175801a17c7" } [profile.dev] split-debuginfo = "unpacked" debug = "limited" +codegen-units = 16 [profile.dev.package] taffy = { opt-level = 3 } @@ -459,6 +495,11 @@ codegen-units = 1 [profile.release.package] zed = { codegen-units = 16 } +[profile.release-fast] +inherits = "release" +lto = false +codegen-units = 16 + [workspace.lints.clippy] dbg_macro = "deny" todo = "deny" @@ -480,7 +521,7 @@ single_range_in_vec_init = "allow" # There are a bunch of rules currently failing in the `style` group, so # allow all of those, for now. -style = "allow" +style = { level = "allow", priority = -1 } # Individual rules that have violations in the codebase: almost_complete_range = "allow" diff --git a/Dockerfile b/Dockerfile index 0614ae589e2fb1..6c8d3dab3b644b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.2 -FROM rust:1.78-bookworm as builder +FROM rust:1.79-bookworm as builder WORKDIR app COPY . . diff --git a/Makefile b/Makefile new file mode 100644 index 00000000000000..9860b52bb58f54 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +SHELL = /bin/zsh + +.PHONY: notebook-py-deps notebook + +PYTHON_VERSION := 3.12 +POETRY_DIR := crates/notebook +RUST_LOG = error + +notebook-py-deps: + poetry install --directory $(POETRY_DIR) --no-root + +notebook: notebook-py-deps + VENV=$(shell poetry --directory $(POETRY_DIR) env info --path) \ + PYTHONPATH=$(shell poetry --directory $(POETRY_DIR) env info --path)/lib/python$(PYTHON_VERSION)/site-packages \ + PYO3_PYTHON=$(shell poetry --directory $(POETRY_DIR) env info --path)/bin/python$(PYTHON_VERSION) \ + PYTHONEXECUTABLE=$(shell poetry --directory $(POETRY_DIR) env info --path)/bin/python$(PYTHON_VERSION) \ + RUST_LOG=$(RUST_LOG) \ + cargo run diff --git a/README.md b/README.md index a5ae33d0db6783..a3b7aeb84f594c 100644 --- a/README.md +++ b/README.md @@ -4,42 +4,36 @@ Welcome to Zed, a high-performance, multiplayer code editor from the creators of [Atom](https://github.com/atom/atom) and [Tree-sitter](https://github.com/tree-sitter/tree-sitter). -## Installation +-------- -You can [download](https://zed.dev/download) Zed today for macOS (v10.15+). +### Installation -Support for additional platforms is on our [roadmap](https://zed.dev/roadmap): -- Linux ([tracking issue](https://github.com/zed-industries/zed/issues/7015)) -- Windows ([tracking issue](https://github.com/zed-industries/zed/issues/5394)) -- Web ([tracking issue](https://github.com/zed-industries/zed/issues/5396)) - -For macOS users, you can also install Zed using [Homebrew](https://brew.sh/): + + Packaging status + -```sh -brew install --cask zed -``` +On macOS and Linux you can [download Zed directly](https://zed.dev/download) or [install Zed via your local package manager](https://zed.dev/docs/linux#installing-via-a-package-manager). -Alternatively, to install the Preview release: +Other platforms are not yet available: -```sh -brew install --cask zed@preview -``` +- Windows ([tracking issue](https://github.com/zed-industries/zed/issues/5394)) +- Web ([tracking issue](https://github.com/zed-industries/zed/issues/5396)) -## Developing Zed +### Developing Zed - [Building Zed for macOS](./docs/src/development/macos.md) - [Building Zed for Linux](./docs/src/development/linux.md) - [Building Zed for Windows](./docs/src/development/windows.md) - [Running Collaboration Locally](./docs/src/development/local-collaboration.md) -## Contributing +### Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md) for ways you can contribute to Zed. Also... we're hiring! Check out our [jobs](https://zed.dev/jobs) page for open roles. -## Licensing +### Licensing License information for third party dependencies must be correctly provided for CI to pass. diff --git a/assets/fonts/plex-mono/ZedPlexMono-Bold.ttf b/assets/fonts/plex-mono/ZedPlexMono-Bold.ttf new file mode 100644 index 00000000000000..d5f4b5e2855fe9 Binary files /dev/null and b/assets/fonts/plex-mono/ZedPlexMono-Bold.ttf differ diff --git a/assets/fonts/plex-mono/ZedPlexMono-BoldItalic.ttf b/assets/fonts/plex-mono/ZedPlexMono-BoldItalic.ttf new file mode 100644 index 00000000000000..05eaf7cccde1c6 Binary files /dev/null and b/assets/fonts/plex-mono/ZedPlexMono-BoldItalic.ttf differ diff --git a/assets/fonts/plex-mono/ZedPlexMono-Italic.ttf b/assets/fonts/plex-mono/ZedPlexMono-Italic.ttf new file mode 100644 index 00000000000000..3b078217578da8 Binary files /dev/null and b/assets/fonts/plex-mono/ZedPlexMono-Italic.ttf differ diff --git a/assets/fonts/plex-mono/ZedPlexMono-Regular.ttf b/assets/fonts/plex-mono/ZedPlexMono-Regular.ttf new file mode 100644 index 00000000000000..61dbb583619e20 Binary files /dev/null and b/assets/fonts/plex-mono/ZedPlexMono-Regular.ttf differ diff --git a/assets/fonts/plex-mono/license.txt b/assets/fonts/plex-mono/license.txt new file mode 100644 index 00000000000000..f72f76504cd73c --- /dev/null +++ b/assets/fonts/plex-mono/license.txt @@ -0,0 +1,92 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/assets/fonts/plex-sans/ZedPlexSans-Bold.ttf b/assets/fonts/plex-sans/ZedPlexSans-Bold.ttf new file mode 100644 index 00000000000000..f1e66392f7c5a3 Binary files /dev/null and b/assets/fonts/plex-sans/ZedPlexSans-Bold.ttf differ diff --git a/assets/fonts/plex-sans/ZedPlexSans-BoldItalic.ttf b/assets/fonts/plex-sans/ZedPlexSans-BoldItalic.ttf new file mode 100644 index 00000000000000..7612dc516742f8 Binary files /dev/null and b/assets/fonts/plex-sans/ZedPlexSans-BoldItalic.ttf differ diff --git a/assets/fonts/plex-sans/ZedPlexSans-Italic.ttf b/assets/fonts/plex-sans/ZedPlexSans-Italic.ttf new file mode 100644 index 00000000000000..8769c232ee6923 Binary files /dev/null and b/assets/fonts/plex-sans/ZedPlexSans-Italic.ttf differ diff --git a/assets/fonts/plex-sans/ZedPlexSans-Regular.ttf b/assets/fonts/plex-sans/ZedPlexSans-Regular.ttf new file mode 100644 index 00000000000000..3ea293d59a31d2 Binary files /dev/null and b/assets/fonts/plex-sans/ZedPlexSans-Regular.ttf differ diff --git a/assets/fonts/plex-sans/license.txt b/assets/fonts/plex-sans/license.txt new file mode 100644 index 00000000000000..f72f76504cd73c --- /dev/null +++ b/assets/fonts/plex-sans/license.txt @@ -0,0 +1,92 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/assets/fonts/zed-mono/zed-mono-extended.ttf b/assets/fonts/zed-mono/zed-mono-extended.ttf deleted file mode 100644 index 05b8c7085e51d4..00000000000000 Binary files a/assets/fonts/zed-mono/zed-mono-extended.ttf and /dev/null differ diff --git a/assets/fonts/zed-mono/zed-mono-extendedbold.ttf b/assets/fonts/zed-mono/zed-mono-extendedbold.ttf deleted file mode 100644 index d5dde1bb14d580..00000000000000 Binary files a/assets/fonts/zed-mono/zed-mono-extendedbold.ttf and /dev/null differ diff --git a/assets/fonts/zed-mono/zed-mono-extendedbolditalic.ttf b/assets/fonts/zed-mono/zed-mono-extendedbolditalic.ttf deleted file mode 100644 index bcd8c7e618e0b6..00000000000000 Binary files a/assets/fonts/zed-mono/zed-mono-extendedbolditalic.ttf and /dev/null differ diff --git a/assets/fonts/zed-mono/zed-mono-extendeditalic.ttf b/assets/fonts/zed-mono/zed-mono-extendeditalic.ttf deleted file mode 100644 index 023c5a87cea6c1..00000000000000 Binary files a/assets/fonts/zed-mono/zed-mono-extendeditalic.ttf and /dev/null differ diff --git a/assets/fonts/zed-sans/zed-sans-extended.ttf b/assets/fonts/zed-sans/zed-sans-extended.ttf deleted file mode 100644 index 07a96856803998..00000000000000 Binary files a/assets/fonts/zed-sans/zed-sans-extended.ttf and /dev/null differ diff --git a/assets/fonts/zed-sans/zed-sans-extendedbold.ttf b/assets/fonts/zed-sans/zed-sans-extendedbold.ttf deleted file mode 100644 index 696c3cdd5b8ecd..00000000000000 Binary files a/assets/fonts/zed-sans/zed-sans-extendedbold.ttf and /dev/null differ diff --git a/assets/fonts/zed-sans/zed-sans-extendedbolditalic.ttf b/assets/fonts/zed-sans/zed-sans-extendedbolditalic.ttf deleted file mode 100644 index 74cb8f7a3c6dd6..00000000000000 Binary files a/assets/fonts/zed-sans/zed-sans-extendedbolditalic.ttf and /dev/null differ diff --git a/assets/fonts/zed-sans/zed-sans-extendeditalic.ttf b/assets/fonts/zed-sans/zed-sans-extendeditalic.ttf deleted file mode 100644 index 9460e5a7dd690f..00000000000000 Binary files a/assets/fonts/zed-sans/zed-sans-extendeditalic.ttf and /dev/null differ diff --git a/assets/icons/arrow_down_from_line.svg b/assets/icons/arrow_down_from_line.svg new file mode 100644 index 00000000000000..89316973a00ab0 --- /dev/null +++ b/assets/icons/arrow_down_from_line.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/arrow_up_from_line.svg b/assets/icons/arrow_up_from_line.svg new file mode 100644 index 00000000000000..50a075e42bd4e6 --- /dev/null +++ b/assets/icons/arrow_up_from_line.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/book.svg b/assets/icons/book.svg new file mode 100644 index 00000000000000..d30f81f32ea777 --- /dev/null +++ b/assets/icons/book.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/book_copy.svg b/assets/icons/book_copy.svg new file mode 100644 index 00000000000000..b055d47b5fe45c --- /dev/null +++ b/assets/icons/book_copy.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/book_plus.svg b/assets/icons/book_plus.svg new file mode 100644 index 00000000000000..2868f07cd098d7 --- /dev/null +++ b/assets/icons/book_plus.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/chevron_down_small.svg b/assets/icons/chevron_down_small.svg new file mode 100644 index 00000000000000..8f8a99d4b97d16 --- /dev/null +++ b/assets/icons/chevron_down_small.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/chevron_up_down.svg b/assets/icons/chevron_up_down.svg new file mode 100644 index 00000000000000..a7414ec8a0706a --- /dev/null +++ b/assets/icons/chevron_up_down.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/context.svg b/assets/icons/context.svg new file mode 100644 index 00000000000000..837b3aadd938a7 --- /dev/null +++ b/assets/icons/context.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/file_icons/file_types.json b/assets/icons/file_icons/file_types.json index 5c2e13068856c6..fd0dc448c4e54f 100644 --- a/assets/icons/file_icons/file_types.json +++ b/assets/icons/file_icons/file_types.json @@ -1,15 +1,15 @@ { "stems": { + "Dockerfile": "docker", "Podfile": "ruby", - "Procfile": "heroku", - "Dockerfile": "docker" + "Procfile": "heroku" }, "suffixes": { - "astro": "astro", "Emakefile": "erlang", "aac": "audio", "accdb": "storage", "app.src": "erlang", + "astro": "astro", "avi": "video", "avif": "image", "bak": "backup", @@ -22,12 +22,12 @@ "c": "c", "cc": "cpp", "cjs": "javascript", + "coffee": "coffeescript", "conf": "settings", "cpp": "cpp", "css": "css", "csv": "storage", "cts": "typescript", - "coffee": "coffeescript", "dart": "dart", "dat": "storage", "db": "storage", @@ -61,12 +61,12 @@ "graphql": "graphql", "graphqls": "graphql", "h": "c", - "hpp": "cpp", "handlebars": "code", "hbs": "template", "heex": "elixir", - "heif": "image", "heic": "image", + "heif": "image", + "hpp": "cpp", "hrl": "erlang", "hs": "haskell", "htm": "template", @@ -74,6 +74,7 @@ "ib": "storage", "ico": "image", "ini": "settings", + "inl": "cpp", "j2k": "image", "java": "java", "jfif": "image", @@ -81,9 +82,9 @@ "jpeg": "image", "jpg": "image", "js": "javascript", - "jsx": "react", "json": "storage", "jsonc": "storage", + "jsx": "react", "jxl": "image", "kt": "kotlin", "ldf": "storage", @@ -93,14 +94,15 @@ "lua": "lua", "m4a": "audio", "m4v": "video", + "markdown": "document", "md": "document", "mdb": "storage", "mdf": "storage", "mdx": "document", "metadata": "code", - "mkv": "video", "mjs": "javascript", "mka": "audio", + "mkv": "video", "ml": "ocaml", "mli": "ocaml", "mov": "video", @@ -109,8 +111,8 @@ "mts": "typescript", "myd": "storage", "myi": "storage", - "nu": "terminal", "nim": "nim", + "nu": "terminal", "odp": "document", "ods": "document", "odt": "document", @@ -132,33 +134,33 @@ "psd": "image", "py": "python", "qoi": "image", + "r": "r", "rb": "ruby", "rebar.config": "erlang", "rkt": "code", "rs": "rust", - "r": "r", "rtf": "document", "sav": "storage", + "sc": "scala", + "scala": "scala", "scm": "code", "sdf": "storage", "sh": "terminal", + "sql": "storage", "sqlite": "storage", "svelte": "template", "svg": "image", - "sc": "scala", - "scala": "scala", - "sql": "storage", "swift": "swift", + "tcl": "tcl", "tf": "terraform", "tfvars": "terraform", "tiff": "image", "toml": "toml", "ts": "typescript", "tsv": "storage", - "ttf": "font", "tsx": "react", + "ttf": "font", "txt": "document", - "tcl": "tcl", "vue": "vue", "wav": "audio", "webm": "video", @@ -190,27 +192,30 @@ "audio": { "icon": "icons/file_icons/audio.svg" }, + "bun": { + "icon": "icons/file_icons/bun.svg" + }, + "c": { + "icon": "icons/file_icons/c.svg" + }, "code": { "icon": "icons/file_icons/code.svg" }, + "coffeescript": { + "icon": "icons/file_icons/coffeescript.svg" + }, "collapsed_chevron": { "icon": "icons/file_icons/chevron_right.svg" }, "collapsed_folder": { "icon": "icons/file_icons/folder.svg" }, - "c": { - "icon": "icons/file_icons/c.svg" - }, "cpp": { "icon": "icons/file_icons/cpp.svg" }, "css": { "icon": "icons/file_icons/css.svg" }, - "coffeescript": { - "icon": "icons/file_icons/coffeescript.svg" - }, "dart": { "icon": "icons/file_icons/dart.svg" }, @@ -247,18 +252,18 @@ "fsharp": { "icon": "icons/file_icons/fsharp.svg" }, - "haskell": { - "icon": "icons/file_icons/haskell.svg" - }, - "heroku": { - "icon": "icons/file_icons/heroku.svg" - }, "go": { "icon": "icons/file_icons/go.svg" }, "graphql": { "icon": "icons/file_icons/graphql.svg" }, + "haskell": { + "icon": "icons/file_icons/haskell.svg" + }, + "heroku": { + "icon": "icons/file_icons/heroku.svg" + }, "image": { "icon": "icons/file_icons/image.svg" }, @@ -274,21 +279,18 @@ "lock": { "icon": "icons/file_icons/lock.svg" }, - "bun": { - "icon": "icons/file_icons/bun.svg" - }, "log": { "icon": "icons/file_icons/info.svg" }, "lua": { "icon": "icons/file_icons/lua.svg" }, - "ocaml": { - "icon": "icons/file_icons/ocaml.svg" - }, "nim": { "icon": "icons/file_icons/nim.svg" }, + "ocaml": { + "icon": "icons/file_icons/ocaml.svg" + }, "phoenix": { "icon": "icons/file_icons/phoenix.svg" }, @@ -316,36 +318,36 @@ "rust": { "icon": "icons/file_icons/rust.svg" }, + "scala": { + "icon": "icons/file_icons/scala.svg" + }, "settings": { "icon": "icons/file_icons/settings.svg" }, "storage": { "icon": "icons/file_icons/database.svg" }, - "scala": { - "icon": "icons/file_icons/scala.svg" - }, "swift": { "icon": "icons/file_icons/swift.svg" }, + "tcl": { + "icon": "icons/file_icons/tcl.svg" + }, "template": { "icon": "icons/file_icons/html.svg" }, - "terraform": { - "icon": "icons/file_icons/terraform.svg" - }, "terminal": { "icon": "icons/file_icons/terminal.svg" }, + "terraform": { + "icon": "icons/file_icons/terraform.svg" + }, "toml": { "icon": "icons/file_icons/toml.svg" }, "typescript": { "icon": "icons/file_icons/typescript.svg" }, - "tcl": { - "icon": "icons/file_icons/tcl.svg" - }, "vcs": { "icon": "icons/file_icons/git.svg" }, diff --git a/assets/icons/font.svg b/assets/icons/font.svg new file mode 100644 index 00000000000000..861ab1a41540b3 --- /dev/null +++ b/assets/icons/font.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/font_size.svg b/assets/icons/font_size.svg new file mode 100644 index 00000000000000..cfba2deb6c0297 --- /dev/null +++ b/assets/icons/font_size.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/font_weight.svg b/assets/icons/font_weight.svg new file mode 100644 index 00000000000000..3ebbfa77bc77e6 --- /dev/null +++ b/assets/icons/font_weight.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/generic_close.svg b/assets/icons/generic_close.svg new file mode 100644 index 00000000000000..0fd213daf9c81f --- /dev/null +++ b/assets/icons/generic_close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/generic_maximize.svg b/assets/icons/generic_maximize.svg new file mode 100644 index 00000000000000..e44abd8f06a8dd --- /dev/null +++ b/assets/icons/generic_maximize.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/generic_minimize.svg b/assets/icons/generic_minimize.svg new file mode 100644 index 00000000000000..4b43cde2743e26 --- /dev/null +++ b/assets/icons/generic_minimize.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/generic_restore.svg b/assets/icons/generic_restore.svg new file mode 100644 index 00000000000000..3bf581f2cd6e9c --- /dev/null +++ b/assets/icons/generic_restore.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/line_height.svg b/assets/icons/line_height.svg new file mode 100644 index 00000000000000..904cfad8a8638c --- /dev/null +++ b/assets/icons/line_height.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/list_tree.svg b/assets/icons/list_tree.svg new file mode 100644 index 00000000000000..8cf157ec135d13 --- /dev/null +++ b/assets/icons/list_tree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/repl_neutral.svg b/assets/icons/repl_neutral.svg new file mode 100644 index 00000000000000..db647fe40b24a3 --- /dev/null +++ b/assets/icons/repl_neutral.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/icons/repl_off.svg b/assets/icons/repl_off.svg new file mode 100644 index 00000000000000..51ada0db4621c3 --- /dev/null +++ b/assets/icons/repl_off.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/repl_pause.svg b/assets/icons/repl_pause.svg new file mode 100644 index 00000000000000..2ac327df3b0d88 --- /dev/null +++ b/assets/icons/repl_pause.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/icons/repl_play.svg b/assets/icons/repl_play.svg new file mode 100644 index 00000000000000..d23b8991126371 --- /dev/null +++ b/assets/icons/repl_play.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/icons/rerun.svg b/assets/icons/rerun.svg new file mode 100644 index 00000000000000..4d22f924f5d7e7 --- /dev/null +++ b/assets/icons/rerun.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/reveal.svg b/assets/icons/reveal.svg new file mode 100644 index 00000000000000..ff5444d8f84c31 --- /dev/null +++ b/assets/icons/reveal.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/rotate_ccw.svg b/assets/icons/rotate_ccw.svg new file mode 100644 index 00000000000000..4eff13b94b698d --- /dev/null +++ b/assets/icons/rotate_ccw.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/rotate_cw.svg b/assets/icons/rotate_cw.svg new file mode 100644 index 00000000000000..019367745fa9ba --- /dev/null +++ b/assets/icons/rotate_cw.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/save.svg b/assets/icons/save.svg new file mode 100644 index 00000000000000..f83d035331c2ba --- /dev/null +++ b/assets/icons/save.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/search_selection.svg b/assets/icons/search_selection.svg new file mode 100644 index 00000000000000..b970db14300b18 --- /dev/null +++ b/assets/icons/search_selection.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/sparkle.svg b/assets/icons/sparkle.svg new file mode 100644 index 00000000000000..f420f527f138cc --- /dev/null +++ b/assets/icons/sparkle.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/sparkle_filled.svg b/assets/icons/sparkle_filled.svg new file mode 100644 index 00000000000000..96837f618ddd56 --- /dev/null +++ b/assets/icons/sparkle_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/star.svg b/assets/icons/star.svg new file mode 100644 index 00000000000000..71d4f3f7cc29b1 --- /dev/null +++ b/assets/icons/star.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/star_filled.svg b/assets/icons/star_filled.svg new file mode 100644 index 00000000000000..4aaad4b7fd61c9 --- /dev/null +++ b/assets/icons/star_filled.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/stop.svg b/assets/icons/stop.svg new file mode 100644 index 00000000000000..3beabd53947bfd --- /dev/null +++ b/assets/icons/stop.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/text-cursor.svg b/assets/icons/text-cursor.svg new file mode 100644 index 00000000000000..2e7b95b2039455 --- /dev/null +++ b/assets/icons/text-cursor.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/triangle_right.svg b/assets/icons/triangle_right.svg new file mode 100644 index 00000000000000..2c78a316f7cd82 --- /dev/null +++ b/assets/icons/triangle_right.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/visible.svg b/assets/icons/visible.svg new file mode 100644 index 00000000000000..0a7e65d60d7ee8 --- /dev/null +++ b/assets/icons/visible.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/zed_assistant_filled.svg b/assets/icons/zed_assistant_filled.svg new file mode 100644 index 00000000000000..8d16fd98499b23 --- /dev/null +++ b/assets/icons/zed_assistant_filled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 2d7e693b3c75b5..8be6d123900a95 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1,13 +1,16 @@ [ - // todo(linux): Review the editor bindings // Standard Linux bindings { "bindings": { "up": "menu::SelectPrev", + "shift-tab": "menu::SelectPrev", + "home": "menu::SelectFirst", "pageup": "menu::SelectFirst", "shift-pageup": "menu::SelectFirst", "ctrl-p": "menu::SelectPrev", "down": "menu::SelectNext", + "tab": "menu::SelectNext", + "end": "menu::SelectLast", "pagedown": "menu::SelectLast", "shift-pagedown": "menu::SelectFirst", "ctrl-n": "menu::SelectNext", @@ -16,7 +19,6 @@ "escape": "menu::Cancel", "ctrl-escape": "menu::Cancel", "ctrl-c": "menu::Cancel", - "shift-enter": "picker::UseSelectedQuery", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], "ctrl-alt-enter": ["picker::ConfirmInput", { "secondary": true }], "ctrl-shift-w": "workspace::CloseWindow", @@ -28,7 +30,6 @@ "ctrl-0": "zed::ResetBufferFontSize", "ctrl-,": "zed::OpenSettings", "ctrl-q": "zed::Quit", - "alt-f9": "zed::Hide", "f11": "zed::ToggleFullScreen" } }, @@ -43,106 +44,61 @@ "tab": "editor::Tab", "shift-tab": "editor::TabPrev", "ctrl-k": "editor::CutToEndOfLine", - "ctrl-t": "editor::Transpose", - // "ctrl-backspace": "editor::DeleteToBeginningOfLine", - // "ctrl-delete": "editor::DeleteToEndOfLine", + // "ctrl-t": "editor::Transpose", "ctrl-backspace": "editor::DeleteToPreviousWordStart", - // "ctrl-w": "editor::DeleteToPreviousWordStart", "ctrl-delete": "editor::DeleteToNextWordEnd", - // "alt-h": "editor::DeleteToPreviousWordStart", - // "alt-d": "editor::DeleteToNextWordEnd", + "shift-delete": "editor::Cut", "ctrl-x": "editor::Cut", - "ctrl-c": "editor::Copy", "ctrl-insert": "editor::Copy", - "ctrl-v": "editor::Paste", + "ctrl-c": "editor::Copy", "shift-insert": "editor::Paste", + "ctrl-v": "editor::Paste", + "ctrl-y": "editor::Redo", "ctrl-z": "editor::Undo", "ctrl-shift-z": "editor::Redo", "up": "editor::MoveUp", - // "ctrl-up": "editor::MoveToStartOfParagraph", todo(linux) Should be "scroll down by 1 line" - "pageup": "editor::PageUp", - // "shift-pageup": "editor::MovePageUp", todo(linux) should be 'select page up' + "ctrl-up": "editor::LineUp", + "ctrl-down": "editor::LineDown", + "pageup": "editor::MovePageUp", + "alt-pageup": "editor::PageUp", + "shift-pageup": "editor::SelectPageUp", "home": "editor::MoveToBeginningOfLine", "down": "editor::MoveDown", - // "ctrl-down": "editor::MoveToEndOfParagraph", todo(linux) should be "scroll up by 1 line" - "pagedown": "editor::PageDown", - // "shift-pagedown": "editor::MovePageDown", todo(linux) should be 'select page down' + "pagedown": "editor::MovePageDown", + "alt-pagedown": "editor::PageDown", + "shift-pagedown": "editor::SelectPageDown", "end": "editor::MoveToEndOfLine", "left": "editor::MoveLeft", "right": "editor::MoveRight", "ctrl-left": "editor::MoveToPreviousWordStart", - // "alt-b": "editor::MoveToPreviousWordStart", "ctrl-right": "editor::MoveToNextWordEnd", - // "alt-f": "editor::MoveToNextWordEnd", - // "cmd-left": "editor::MoveToBeginningOfLine", - // "ctrl-a": "editor::MoveToBeginningOfLine", - // "cmd-right": "editor::MoveToEndOfLine", - // "ctrl-e": "editor::MoveToEndOfLine", "ctrl-home": "editor::MoveToBeginning", "ctrl-end": "editor::MoveToEnd", "shift-up": "editor::SelectUp", "shift-down": "editor::SelectDown", "shift-left": "editor::SelectLeft", "shift-right": "editor::SelectRight", - "ctrl-shift-left": "editor::SelectToPreviousWordStart", - "ctrl-shift-right": "editor::SelectToNextWordEnd", - "ctrl-shift-up": "editor::AddSelectionAbove", - "ctrl-shift-down": "editor::AddSelectionBelow", - // "ctrl-shift-up": "editor::SelectToStartOfParagraph", - // "ctrl-shift-down": "editor::SelectToEndOfParagraph", + "ctrl-shift-left": "editor::SelectToPreviousWordStart", // cursorWordLeftSelect + "ctrl-shift-right": "editor::SelectToNextWordEnd", // cursorWordRightSelect "ctrl-shift-home": "editor::SelectToBeginning", "ctrl-shift-end": "editor::SelectToEnd", "ctrl-a": "editor::SelectAll", "ctrl-l": "editor::SelectLine", "ctrl-shift-i": "editor::Format", - // "cmd-shift-left": [ - // "editor::SelectToBeginningOfLine", - // { - // "stop_at_soft_wraps": true - // } - // ], - "shift-home": [ - "editor::SelectToBeginningOfLine", - { - "stop_at_soft_wraps": true - } - ], - // "ctrl-shift-a": [ - // "editor::SelectToBeginningOfLine", - // { - // "stop_at_soft_wraps": true - // } - // ], - // "cmd-shift-right": [ - // "editor::SelectToEndOfLine", - // { - // "stop_at_soft_wraps": true - // } - // ], - "shift-end": [ - "editor::SelectToEndOfLine", - { - "stop_at_soft_wraps": true - } - ], - // "ctrl-shift-e": [ - // "editor::SelectToEndOfLine", - // { - // "stop_at_soft_wraps": true - // } - // ], - // "alt-v": [ - // "editor::MovePageUp", - // { - // "center_cursor": true - // } - // ], + // "cmd-shift-left": ["editor::SelectToBeginningOfLine", {"stop_at_soft_wraps": true }], + "shift-home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": true }], + // "ctrl-shift-a": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": true }], + // "cmd-shift-right": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }], + "shift-end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }], + // "ctrl-shift-e": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }], + // "alt-v": ["editor::MovePageUp", { "center_cursor": true }], "ctrl-alt-space": "editor::ShowCharacterPalette", "ctrl-;": "editor::ToggleLineNumbers", "ctrl-k ctrl-r": "editor::RevertSelectedHunks", "ctrl-'": "editor::ToggleHunkDiff", "ctrl-\"": "editor::ExpandAllHunkDiffs", - "ctrl-alt-g b": "editor::ToggleGitBlame" + "ctrl-i": "editor::ShowSignatureHelp", + "alt-g b": "editor::ToggleGitBlame" } }, { @@ -154,19 +110,11 @@ "ctrl-enter": "editor::NewlineAbove", "alt-z": "editor::ToggleSoftWrap", "ctrl-f": "buffer_search::Deploy", - "ctrl-h": [ - "buffer_search::Deploy", - { - "replace_enabled": true - } - ], - // "cmd-e": [ - // "buffer_search::Deploy", - // { - // "focus": false - // } - // ], - "ctrl->": "assistant::QuoteSelection" + "ctrl-h": ["buffer_search::Deploy", { "replace_enabled": true }], + // "cmd-e": ["buffer_search::Deploy", { "focus": false }], + "ctrl->": "assistant::QuoteSelection", + "ctrl-<": "assistant::InsertIntoEditor", + "ctrl-alt-e": "editor::SelectEnclosingSymbol" } }, { @@ -201,17 +149,15 @@ "context": "AssistantPanel", "bindings": { "ctrl-g": "search::SelectNextMatch", - "ctrl-shift-g": "search::SelectPrevMatch" + "ctrl-shift-g": "search::SelectPrevMatch", + "alt-m": "assistant::ToggleModelSelector" } }, { - "context": "ConversationEditor > Editor", + "context": "PromptLibrary", "bindings": { - "ctrl-enter": "assistant::Assist", - "ctrl-s": "workspace::Save", - "ctrl->": "assistant::QuoteSelection", - "shift-enter": "assistant::Split", - "ctrl-r": "assistant::CycleMessageRole" + "ctrl-n": "prompt_library::NewPrompt", + "ctrl-shift-s": "prompt_library::ToggleDefaultPrompt" } }, { @@ -223,7 +169,8 @@ "shift-enter": "search::SelectPrevMatch", "alt-enter": "search::SelectAllMatches", "ctrl-f": "search::FocusSearch", - "ctrl-h": "search::ToggleReplace" + "ctrl-h": "search::ToggleReplace", + "ctrl-l": "search::ToggleSelection" } }, { @@ -258,7 +205,7 @@ } }, { - "context": "ProjectSearchBar && in_replace", + "context": "ProjectSearchBar && in_replace > Editor", "bindings": { "enter": "search::ReplaceNext", "ctrl-alt-enter": "search::ReplaceAll" @@ -279,6 +226,7 @@ "ctrl-pageup": "pane::ActivatePrevItem", "ctrl-pagedown": "pane::ActivateNextItem", "ctrl-w": "pane::CloseActiveItem", + "ctrl-f4": "pane::CloseActiveItem", "alt-ctrl-t": "pane::CloseInactiveItems", "alt-ctrl-shift-w": "workspace::CloseInactiveTabsAndPanes", "ctrl-k u": "pane::CloseCleanItems", @@ -287,6 +235,7 @@ "ctrl-alt-g": "search::SelectNextMatch", "ctrl-alt-shift-g": "search::SelectPrevMatch", "ctrl-alt-shift-h": "search::ToggleReplace", + "ctrl-alt-shift-l": "search::ToggleSelection", "alt-enter": "search::SelectAllMatches", "alt-c": "search::ToggleCaseSensitive", "alt-w": "search::ToggleWholeWord", @@ -296,55 +245,36 @@ "ctrl-alt-shift-x": "search::ToggleRegex" } }, + { + "context": "Terminal", + "bindings": { + "ctrl-w": ["terminal::SendKeystroke", "ctrl-w"], + "ctrl-e": ["terminal::SendKeystroke", "ctrl-e"] + } + }, // Bindings from VS Code { "context": "Editor", "bindings": { "ctrl-[": "editor::Outdent", "ctrl-]": "editor::Indent", - "shift-alt-up": "editor::AddSelectionAbove", - "shift-alt-down": "editor::AddSelectionBelow", + "shift-alt-up": "editor::AddSelectionAbove", // Insert Cursor Above + "shift-alt-down": "editor::AddSelectionBelow", // Insert Cursor Below "ctrl-shift-k": "editor::DeleteLine", "alt-up": "editor::MoveLineUp", "alt-down": "editor::MoveLineDown", "ctrl-alt-shift-up": "editor::DuplicateLineUp", "ctrl-alt-shift-down": "editor::DuplicateLineDown", - "ctrl-shift-left": "editor::SelectToPreviousWordStart", - "ctrl-shift-right": "editor::SelectToNextWordEnd", - "ctrl-shift-up": "editor::SelectLargerSyntaxNode", //todo(linux) tmp keybinding - "ctrl-shift-down": "editor::SelectSmallerSyntaxNode", //todo(linux) tmp keybinding - "ctrl-d": [ - "editor::SelectNext", - { - "replace_newest": false - } - ], - "ctrl-shift-l": "editor::SelectAllMatches", - "ctrl-shift-d": [ - "editor::SelectPrevious", - { - "replace_newest": false - } - ], - "ctrl-k ctrl-d": [ - "editor::SelectNext", - { - "replace_newest": true - } - ], - "ctrl-k ctrl-shift-d": [ - "editor::SelectPrevious", - { - "replace_newest": true - } - ], + "alt-shift-right": "editor::SelectLargerSyntaxNode", // Expand Selection + "alt-shift-left": "editor::SelectSmallerSyntaxNode", // Shrink Selection + "ctrl-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection + "ctrl-f2": "editor::SelectAllMatches", // Select all occurrences of current word + "ctrl-shift-down": ["editor::SelectNext", { "replace_newest": false }], // Add selection to Next Find Match + "ctrl-shift-up": ["editor::SelectPrevious", { "replace_newest": false }], + "ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], + "ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], "ctrl-k ctrl-i": "editor::Hover", - "ctrl-/": [ - "editor::ToggleComments", - { - "advance_downwards": false - } - ], + "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], "ctrl-u": "editor::UndoSelection", "ctrl-shift-u": "editor::RedoSelection", "f8": "editor::GoToDiagnostic", @@ -352,16 +282,23 @@ "f2": "editor::Rename", "f12": "editor::GoToDefinition", "alt-f12": "editor::GoToDefinitionSplit", + "ctrl-shift-f10": "editor::GoToDefinitionSplit", "ctrl-f12": "editor::GoToTypeDefinition", "shift-f12": "editor::GoToImplementation", "alt-ctrl-f12": "editor::GoToTypeDefinitionSplit", "alt-shift-f12": "editor::FindAllReferences", "ctrl-m": "editor::MoveToEnclosingBracket", + "ctrl-shift-\\": "editor::MoveToEnclosingBracket", "ctrl-shift-[": "editor::Fold", "ctrl-shift-]": "editor::UnfoldLines", "ctrl-space": "editor::ShowCompletions", "ctrl-.": "editor::ToggleCodeActions", - "alt-ctrl-r": "editor::RevealInFinder", + "alt-ctrl-r": "editor::RevealInFileManager", + "ctrl-k r": "editor::RevealInFileManager", + "ctrl-k p": "editor::CopyPath", + "ctrl-\\": "pane::SplitRight", + "ctrl-k v": "markdown::OpenPreviewToTheSide", + "ctrl-shift-v": "markdown::OpenPreview", "ctrl-alt-shift-c": "editor::DisplayCursorNames" } }, @@ -388,6 +325,8 @@ "ctrl-alt--": "pane::GoBack", "ctrl-alt-_": "pane::GoForward", "ctrl-shift-t": "pane::ReopenClosedItem", + "f3": "search::SelectNextMatch", + "shift-f3": "search::SelectPrevMatch", "ctrl-shift-f": "project_search::ToggleFocus" } }, @@ -395,12 +334,7 @@ "context": "Workspace", "bindings": { // Change the default action on `menu::Confirm` by setting the parameter - // "alt-cmd-o": [ - // "projects::OpenRecent", - // { - // "create_new_window": true - // } - // ] + // "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": true }], "alt-ctrl-o": "projects::OpenRecent", "alt-ctrl-shift-b": "branches::OpenRecent", "ctrl-~": "workspace::NewTerminal", @@ -419,27 +353,24 @@ "alt-7": ["workspace::ActivatePane", 6], "alt-8": ["workspace::ActivatePane", 7], "alt-9": ["workspace::ActivatePane", 8], - "ctrl-alt-b": "workspace::ToggleLeftDock", - "ctrl-b": "workspace::ToggleRightDock", + "ctrl-alt-b": "workspace::ToggleRightDock", + "ctrl-b": "workspace::ToggleLeftDock", "ctrl-j": "workspace::ToggleBottomDock", "ctrl-alt-y": "workspace::CloseAllDocks", "ctrl-shift-f": "pane::DeploySearch", - "ctrl-shift-h": [ - "pane::DeploySearch", - { - "replace_enabled": true - } - ], + "ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], "ctrl-k ctrl-s": "zed::OpenKeymap", "ctrl-k ctrl-t": "theme_selector::Toggle", - "ctrl-shift-t": "project_symbols::Toggle", + "ctrl-t": "project_symbols::Toggle", "ctrl-p": "file_finder::Toggle", "ctrl-tab": "tab_switcher::Toggle", "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], "ctrl-e": "file_finder::Toggle", "ctrl-shift-p": "command_palette::Toggle", + "f1": "command_palette::Toggle", "ctrl-shift-m": "diagnostics::Deploy", "ctrl-shift-e": "project_panel::ToggleFocus", + "ctrl-shift-b": "outline_panel::ToggleFocus", "ctrl-?": "assistant::ToggleFocus", "ctrl-alt-s": "workspace::SaveAll", "ctrl-k m": "language_selector::Toggle", @@ -452,6 +383,7 @@ "ctrl-k shift-right": ["workspace::SwapPaneInDirection", "Right"], "ctrl-k shift-up": ["workspace::SwapPaneInDirection", "Up"], "ctrl-k shift-down": ["workspace::SwapPaneInDirection", "Down"], + "ctrl-shift-x": "zed::Extensions", "alt-t": "task::Rerun", "alt-shift-t": "task::Spawn" } @@ -462,13 +394,13 @@ "bindings": { "ctrl-shift-k": "editor::DeleteLine", "ctrl-shift-d": "editor::DuplicateLineDown", - "ctrl-j": "editor::JoinLines", + "ctrl-shift-j": "editor::JoinLines", "ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart", "ctrl-alt-h": "editor::DeleteToPreviousSubwordStart", "ctrl-alt-delete": "editor::DeleteToNextSubwordEnd", "ctrl-alt-d": "editor::DeleteToNextSubwordEnd", "ctrl-alt-left": "editor::MoveToPreviousSubwordStart", - "ctrl-alt-b": "editor::MoveToPreviousSubwordStart", + // "ctrl-alt-b": "editor::MoveToPreviousSubwordStart", "ctrl-alt-right": "editor::MoveToNextSubwordEnd", "ctrl-alt-f": "editor::MoveToNextSubwordEnd", "ctrl-alt-shift-left": "editor::SelectToPreviousSubwordStart", @@ -545,12 +477,39 @@ "ctrl-enter": "assistant::InlineAssist" } }, + { + "context": "ContextEditor > Editor", + "bindings": { + "ctrl-enter": "assistant::Assist", + "ctrl-s": "workspace::Save", + "ctrl->": "assistant::QuoteSelection", + "ctrl-<": "assistant::InsertIntoEditor", + "shift-enter": "assistant::Split", + "ctrl-r": "assistant::CycleMessageRole", + "enter": "assistant::ConfirmCommand", + "alt-enter": "editor::Newline" + } + }, { "context": "ProjectSearchBar && !in_replace", "bindings": { "ctrl-enter": "project_search::SearchInNew" } }, + { + "context": "OutlinePanel", + "bindings": { + "escape": "menu::Cancel", + "left": "outline_panel::CollapseSelectedEntry", + "right": "outline_panel::ExpandSelectedEntry", + "ctrl-alt-c": "outline_panel::CopyPath", + "alt-ctrl-shift-c": "outline_panel::CopyRelativePath", + "alt-ctrl-r": "outline_panel::RevealInFileManager", + "space": "outline_panel::Open", + "shift-down": "menu::SelectNext", + "shift-up": "menu::SelectPrev" + } + }, { "context": "ProjectPanel", "bindings": { @@ -567,12 +526,16 @@ "alt-ctrl-shift-c": "project_panel::CopyRelativePath", "f2": "project_panel::Rename", "enter": "project_panel::Rename", - "backspace": "project_panel::Trash", - "delete": "project_panel::Trash", + "backspace": ["project_panel::Trash", { "skip_prompt": false }], + "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], + "delete": ["project_panel::Trash", { "skip_prompt": false }], "ctrl-backspace": ["project_panel::Delete", { "skip_prompt": false }], "ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }], - "alt-ctrl-r": "project_panel::RevealInFinder", - "alt-shift-f": "project_panel::NewSearchInDirectory" + "alt-ctrl-r": "project_panel::RevealInFileManager", + "alt-shift-f": "project_panel::NewSearchInDirectory", + "shift-down": "menu::SelectNext", + "shift-up": "menu::SelectPrev", + "escape": "menu::Cancel" } }, { @@ -600,6 +563,13 @@ "tab": "channel_modal::ToggleMode" } }, + { + "context": "Picker > Editor", + "bindings": { + "tab": "picker::ConfirmCompletion", + "alt-enter": ["picker::ConfirmInput", { "secondary": false }] + } + }, { "context": "ChannelModal > Picker > Editor", "bindings": { @@ -627,6 +597,7 @@ "ctrl-insert": "terminal::Copy", "shift-ctrl-v": "terminal::Paste", "shift-insert": "terminal::Paste", + "ctrl-enter": "assistant::InlineAssist", "up": ["terminal::SendKeystroke", "up"], "pageup": ["terminal::SendKeystroke", "pageup"], "down": ["terminal::SendKeystroke", "down"], @@ -634,11 +605,12 @@ "escape": ["terminal::SendKeystroke", "escape"], "enter": ["terminal::SendKeystroke", "enter"], "ctrl-c": ["terminal::SendKeystroke", "ctrl-c"], - - // Some nice conveniences - "ctrl-backspace": ["terminal::SendText", "\u0015"], - "ctrl-right": ["terminal::SendText", "\u0005"], - "ctrl-left": ["terminal::SendText", "\u0001"] + "shift-pageup": "terminal::ScrollPageUp", + "shift-pagedown": "terminal::ScrollPageDown", + "shift-up": "terminal::ScrollLineUp", + "shift-down": "terminal::ScrollLineDown", + "shift-home": "terminal::ScrollToTop", + "shift-end": "terminal::ScrollToBottom" } } ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 09464c3d604ceb..1527e3e9433bbc 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -3,10 +3,14 @@ { "bindings": { "up": "menu::SelectPrev", + "shift-tab": "menu::SelectPrev", + "home": "menu::SelectFirst", "pageup": "menu::SelectFirst", "shift-pageup": "menu::SelectFirst", "ctrl-p": "menu::SelectPrev", "down": "menu::SelectNext", + "tab": "menu::SelectNext", + "end": "menu::SelectLast", "pagedown": "menu::SelectLast", "shift-pagedown": "menu::SelectFirst", "ctrl-n": "menu::SelectNext", @@ -61,13 +65,17 @@ "cmd-shift-z": "editor::Redo", "up": "editor::MoveUp", "ctrl-up": "editor::MoveToStartOfParagraph", - "pageup": "editor::PageUp", - "shift-pageup": "editor::MovePageUp", + "pageup": "editor::MovePageUp", + "shift-pageup": "editor::SelectPageUp", + "cmd-pageup": "editor::PageUp", + "ctrl-pageup": "editor::LineUp", "home": "editor::MoveToBeginningOfLine", "down": "editor::MoveDown", "ctrl-down": "editor::MoveToEndOfParagraph", - "pagedown": "editor::PageDown", - "shift-pagedown": "editor::MovePageDown", + "pagedown": "editor::MovePageDown", + "shift-pagedown": "editor::SelectPageDown", + "cmd-pagedown": "editor::PageDown", + "ctrl-pagedown": "editor::LineDown", "end": "editor::MoveToEndOfLine", "left": "editor::MoveLeft", "right": "editor::MoveRight", @@ -75,7 +83,7 @@ "ctrl-n": "editor::MoveDown", "ctrl-b": "editor::MoveLeft", "ctrl-f": "editor::MoveRight", - "ctrl-l": "editor::NextScreen", + "ctrl-l": "editor::ScrollCursorCenter", "alt-left": "editor::MoveToPreviousWordStart", "alt-b": "editor::MoveToPreviousWordStart", "alt-right": "editor::MoveToNextWordEnd", @@ -94,9 +102,9 @@ "ctrl-shift-b": "editor::SelectLeft", "shift-right": "editor::SelectRight", "ctrl-shift-f": "editor::SelectRight", - "alt-shift-left": "editor::SelectToPreviousWordStart", + "alt-shift-left": "editor::SelectToPreviousWordStart", // cursorWordLeftSelect "alt-shift-b": "editor::SelectToPreviousWordStart", - "alt-shift-right": "editor::SelectToNextWordEnd", + "alt-shift-right": "editor::SelectToNextWordEnd", // cursorWordRightSelect "alt-shift-f": "editor::SelectToNextWordEnd", "ctrl-shift-up": "editor::SelectToStartOfParagraph", "ctrl-shift-down": "editor::SelectToEndOfParagraph", @@ -105,60 +113,21 @@ "cmd-a": "editor::SelectAll", "cmd-l": "editor::SelectLine", "cmd-shift-i": "editor::Format", - "cmd-shift-left": [ - "editor::SelectToBeginningOfLine", - { - "stop_at_soft_wraps": true - } - ], - "shift-home": [ - "editor::SelectToBeginningOfLine", - { - "stop_at_soft_wraps": true - } - ], - "ctrl-shift-a": [ - "editor::SelectToBeginningOfLine", - { - "stop_at_soft_wraps": true - } - ], - "cmd-shift-right": [ - "editor::SelectToEndOfLine", - { - "stop_at_soft_wraps": true - } - ], - "shift-end": [ - "editor::SelectToEndOfLine", - { - "stop_at_soft_wraps": true - } - ], - "ctrl-shift-e": [ - "editor::SelectToEndOfLine", - { - "stop_at_soft_wraps": true - } - ], - "ctrl-v": [ - "editor::MovePageDown", - { - "center_cursor": true - } - ], - "alt-v": [ - "editor::MovePageUp", - { - "center_cursor": true - } - ], + "cmd-shift-left": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": true }], + "shift-home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": true }], + "ctrl-shift-a": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": true }], + "cmd-shift-right": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }], + "shift-end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }], + "ctrl-shift-e": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }], + "ctrl-v": ["editor::MovePageDown", { "center_cursor": true }], + "alt-v": ["editor::MovePageUp", { "center_cursor": true }], "ctrl-cmd-space": "editor::ShowCharacterPalette", "cmd-;": "editor::ToggleLineNumbers", "cmd-alt-z": "editor::RevertSelectedHunks", "cmd-'": "editor::ToggleHunkDiff", "cmd-\"": "editor::ExpandAllHunkDiffs", - "cmd-alt-g b": "editor::ToggleGitBlame" + "cmd-alt-g b": "editor::ToggleGitBlame", + "cmd-i": "editor::ShowSignatureHelp" } }, { @@ -167,22 +136,20 @@ "enter": "editor::Newline", "shift-enter": "editor::Newline", "cmd-shift-enter": "editor::NewlineAbove", - "cmd-enter": "editor::NewlineBelow", "alt-z": "editor::ToggleSoftWrap", "cmd-f": "buffer_search::Deploy", - "cmd-alt-f": [ - "buffer_search::Deploy", - { - "replace_enabled": true - } - ], - "cmd-e": [ - "buffer_search::Deploy", - { - "focus": false - } - ], - "cmd->": "assistant::QuoteSelection" + "cmd-alt-f": ["buffer_search::Deploy", { "replace_enabled": true }], + "cmd-alt-l": ["buffer_search::Deploy", { "selection_search_enabled": true }], + "cmd-e": ["buffer_search::Deploy", { "focus": false }], + "cmd->": "assistant::QuoteSelection", + "cmd-<": "assistant::InsertIntoEditor", + "cmd-alt-e": "editor::SelectEnclosingSymbol" + } + }, + { + "context": "Editor && mode == full && !jupyter", + "bindings": { + "cmd-enter": "editor::NewlineBelow" } }, { @@ -214,20 +181,32 @@ } }, { - "context": "AssistantPanel", // Used in the assistant crate, which we're replacing + "context": "AssistantPanel", "bindings": { "cmd-g": "search::SelectNextMatch", - "cmd-shift-g": "search::SelectPrevMatch" + "cmd-shift-g": "search::SelectPrevMatch", + "alt-m": "assistant::ToggleModelSelector" } }, { - "context": "ConversationEditor > Editor", + "context": "ContextEditor > Editor", "bindings": { "cmd-enter": "assistant::Assist", "cmd-s": "workspace::Save", "cmd->": "assistant::QuoteSelection", + "cmd-<": "assistant::InsertIntoEditor", "shift-enter": "assistant::Split", - "ctrl-r": "assistant::CycleMessageRole" + "ctrl-r": "assistant::CycleMessageRole", + "enter": "assistant::ConfirmCommand", + "alt-enter": "editor::Newline" + } + }, + { + "context": "PromptLibrary", + "bindings": { + "cmd-n": "prompt_library::NewPrompt", + "cmd-shift-s": "prompt_library::ToggleDefaultPrompt", + "cmd-w": "workspace::CloseWindow" } }, { @@ -239,7 +218,8 @@ "shift-enter": "search::SelectPrevMatch", "alt-enter": "search::SelectAllMatches", "cmd-f": "search::FocusSearch", - "cmd-alt-f": "search::ToggleReplace" + "cmd-alt-f": "search::ToggleReplace", + "cmd-alt-l": "search::ToggleSelection" } }, { @@ -260,6 +240,7 @@ "context": "ProjectSearchBar", "bindings": { "escape": "project_search::ToggleFocus", + "cmd-shift-j": "project_search::ToggleFilters", "cmd-shift-f": "search::FocusSearch", "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", @@ -274,7 +255,7 @@ } }, { - "context": "ProjectSearchBar && in_replace", + "context": "ProjectSearchBar && in_replace > Editor", "bindings": { "enter": "search::ReplaceNext", "cmd-enter": "search::ReplaceAll" @@ -284,6 +265,7 @@ "context": "ProjectSearchView", "bindings": { "escape": "project_search::ToggleFocus", + "cmd-shift-j": "project_search::ToggleFilters", "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", "alt-cmd-x": "search::ToggleRegex" @@ -305,6 +287,7 @@ "cmd-g": "search::SelectNextMatch", "cmd-shift-g": "search::SelectPrevMatch", "cmd-shift-h": "search::ToggleReplace", + "cmd-alt-l": "search::ToggleSelection", "alt-enter": "search::SelectAllMatches", "alt-cmd-c": "search::ToggleCaseSensitive", "alt-cmd-w": "search::ToggleWholeWord", @@ -319,49 +302,25 @@ "bindings": { "cmd-[": "editor::Outdent", "cmd-]": "editor::Indent", - "cmd-alt-up": "editor::AddSelectionAbove", + "cmd-alt-up": "editor::AddSelectionAbove", // Insert cursor above "cmd-ctrl-p": "editor::AddSelectionAbove", - "cmd-alt-down": "editor::AddSelectionBelow", + "cmd-alt-down": "editor::AddSelectionBelow", // Insert cursor below "cmd-ctrl-n": "editor::AddSelectionBelow", "cmd-shift-k": "editor::DeleteLine", "alt-up": "editor::MoveLineUp", "alt-down": "editor::MoveLineDown", "alt-shift-up": "editor::DuplicateLineUp", "alt-shift-down": "editor::DuplicateLineDown", - "ctrl-shift-right": "editor::SelectLargerSyntaxNode", - "ctrl-shift-left": "editor::SelectSmallerSyntaxNode", - "cmd-d": [ - "editor::SelectNext", - { - "replace_newest": false - } - ], - "cmd-shift-l": "editor::SelectAllMatches", - "ctrl-cmd-d": [ - "editor::SelectPrevious", - { - "replace_newest": false - } - ], - "cmd-k cmd-d": [ - "editor::SelectNext", - { - "replace_newest": true - } - ], - "cmd-k ctrl-cmd-d": [ - "editor::SelectPrevious", - { - "replace_newest": true - } - ], + "ctrl-shift-right": "editor::SelectLargerSyntaxNode", // Expand Selection + "ctrl-shift-left": "editor::SelectSmallerSyntaxNode", // Shrink Selection + "cmd-d": ["editor::SelectNext", { "replace_newest": false }], // Add selection to Next Find Match + "cmd-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection + "cmd-f2": "editor::SelectAllMatches", // Select all occurrences of current word + "ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": false }], + "cmd-k cmd-d": ["editor::SelectNext", { "replace_newest": true }], + "cmd-k ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": true }], "cmd-k cmd-i": "editor::Hover", - "cmd-/": [ - "editor::ToggleComments", - { - "advance_downwards": false - } - ], + "cmd-/": ["editor::ToggleComments", { "advance_downwards": false }], "cmd-u": "editor::UndoSelection", "cmd-shift-u": "editor::RedoSelection", "f8": "editor::GoToDiagnostic", @@ -374,11 +333,17 @@ "alt-cmd-f12": "editor::GoToTypeDefinitionSplit", "alt-shift-f12": "editor::FindAllReferences", "ctrl-m": "editor::MoveToEnclosingBracket", + "cmd-shift-\\": "editor::MoveToEnclosingBracket", "alt-cmd-[": "editor::Fold", "alt-cmd-]": "editor::UnfoldLines", "ctrl-space": "editor::ShowCompletions", "cmd-.": "editor::ToggleCodeActions", - "alt-cmd-r": "editor::RevealInFinder", + "alt-cmd-r": "editor::RevealInFileManager", + "cmd-k r": "editor::RevealInFileManager", + "cmd-k p": "editor::CopyPath", + "cmd-\\": "pane::SplitRight", + "cmd-k v": "markdown::OpenPreviewToTheSide", + "cmd-shift-v": "markdown::OpenPreview", "ctrl-cmd-c": "editor::DisplayCursorNames" } }, @@ -403,7 +368,7 @@ "ctrl-9": ["pane::ActivateItem", 8], "ctrl-0": "pane::ActivateLastItem", "ctrl--": "pane::GoBack", - "ctrl-_": "pane::GoForward", + "ctrl-shift--": "pane::GoForward", "cmd-shift-t": "pane::ReopenClosedItem", "cmd-shift-f": "project_search::ToggleFocus" } @@ -412,12 +377,7 @@ "context": "Workspace", "bindings": { // Change the default action on `menu::Confirm` by setting the parameter - // "alt-cmd-o": [ - // "projects::OpenRecent", - // { - // "create_new_window": true - // } - // ] + // "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }], "alt-cmd-o": "projects::OpenRecent", "alt-cmd-b": "branches::OpenRecent", "ctrl-~": "workspace::NewTerminal", @@ -441,12 +401,7 @@ "cmd-j": "workspace::ToggleBottomDock", "alt-cmd-y": "workspace::CloseAllDocks", "cmd-shift-f": "pane::DeploySearch", - "cmd-shift-h": [ - "pane::DeploySearch", - { - "replace_enabled": true - } - ], + "cmd-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], "cmd-k cmd-s": "zed::OpenKeymap", "cmd-k cmd-t": "theme_selector::Toggle", "cmd-t": "project_symbols::Toggle", @@ -456,6 +411,7 @@ "cmd-shift-p": "command_palette::Toggle", "cmd-shift-m": "diagnostics::Deploy", "cmd-shift-e": "project_panel::ToggleFocus", + "cmd-shift-b": "outline_panel::ToggleFocus", "cmd-?": "assistant::ToggleFocus", "cmd-alt-s": "workspace::SaveAll", "cmd-k m": "language_selector::Toggle", @@ -468,6 +424,7 @@ "cmd-k shift-right": ["workspace::SwapPaneInDirection", "Right"], "cmd-k shift-up": ["workspace::SwapPaneInDirection", "Up"], "cmd-k shift-down": ["workspace::SwapPaneInDirection", "Down"], + "cmd-shift-x": "zed::Extensions", "alt-t": "task::Rerun", "alt-shift-t": "task::Spawn" } @@ -565,12 +522,27 @@ "cmd-enter": "project_search::SearchInNew" } }, + { + "context": "OutlinePanel", + "bindings": { + "escape": "menu::Cancel", + "left": "outline_panel::CollapseSelectedEntry", + "right": "outline_panel::ExpandSelectedEntry", + "cmd-alt-c": "outline_panel::CopyPath", + "alt-cmd-shift-c": "outline_panel::CopyRelativePath", + "alt-cmd-r": "outline_panel::RevealInFileManager", + "space": "outline_panel::Open", + "shift-down": "menu::SelectNext", + "shift-up": "menu::SelectPrev" + } + }, { "context": "ProjectPanel", "bindings": { "left": "project_panel::CollapseSelectedEntry", "right": "project_panel::ExpandSelectedEntry", "cmd-n": "project_panel::NewFile", + "cmd-d": "project_panel::Duplicate", "alt-cmd-n": "project_panel::NewDirectory", "cmd-x": "project_panel::Cut", "cmd-c": "project_panel::Copy", @@ -578,12 +550,18 @@ "cmd-alt-c": "project_panel::CopyPath", "alt-cmd-shift-c": "project_panel::CopyRelativePath", "enter": "project_panel::Rename", + "f2": "project_panel::Rename", "backspace": ["project_panel::Trash", { "skip_prompt": false }], "delete": ["project_panel::Trash", { "skip_prompt": false }], - "cmd-backspace": ["project_panel::Delete", { "skip_prompt": false }], + "cmd-backspace": ["project_panel::Trash", { "skip_prompt": true }], "cmd-delete": ["project_panel::Delete", { "skip_prompt": false }], - "alt-cmd-r": "project_panel::RevealInFinder", - "alt-shift-f": "project_panel::NewSearchInDirectory" + "alt-cmd-r": "project_panel::RevealInFileManager", + "cmd-alt-backspace": ["project_panel::Delete", { "skip_prompt": false }], + + "alt-shift-f": "project_panel::NewSearchInDirectory", + "shift-down": "menu::SelectNext", + "shift-up": "menu::SelectPrev", + "escape": "menu::Cancel" } }, { @@ -592,6 +570,12 @@ "space": "project_panel::Open" } }, + { + "context": "Editor && jupyter && !ContextEditor", + "bindings": { + "cmd-enter": "repl::Run" + } + }, { "context": "CollabPanel && not_editing", "bindings": { @@ -611,6 +595,14 @@ "tab": "channel_modal::ToggleMode" } }, + { + "context": "Picker > Editor", + "bindings": { + "tab": "picker::ConfirmCompletion", + "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }] + } + }, { "context": "ChannelModal > Picker > Editor", "bindings": { @@ -630,14 +622,6 @@ "ctrl-backspace": "tab_switcher::CloseSelectedItem" } }, - { - "context": "Picker", - "bindings": { - "alt-e": "picker::UseSelectedQuery", - "alt-enter": ["picker::ConfirmInput", { "secondary": false }], - "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }] - } - }, { "context": "Terminal", "bindings": { @@ -645,6 +629,7 @@ "cmd-c": "terminal::Copy", "cmd-v": "terminal::Paste", "cmd-k": "terminal::Clear", + "ctrl-enter": "assistant::InlineAssist", // Some nice conveniences "cmd-backspace": ["terminal::SendText", "\u0015"], "cmd-right": ["terminal::SendText", "\u0005"], @@ -660,7 +645,17 @@ "pagedown": ["terminal::SendKeystroke", "pagedown"], "escape": ["terminal::SendKeystroke", "escape"], "enter": ["terminal::SendKeystroke", "enter"], - "ctrl-c": ["terminal::SendKeystroke", "ctrl-c"] + "ctrl-c": ["terminal::SendKeystroke", "ctrl-c"], + "cmd-up": "terminal::ScrollPageUp", + "cmd-down": "terminal::ScrollPageDown", + "shift-pageup": "terminal::ScrollPageUp", + "shift-pagedown": "terminal::ScrollPageDown", + "shift-up": "terminal::ScrollLineUp", + "shift-down": "terminal::ScrollLineDown", + "cmd-home": "terminal::ScrollToTop", + "cmd-end": "terminal::ScrollToBottom", + "shift-home": "terminal::ScrollToTop", + "shift-end": "terminal::ScrollToBottom" } } ] diff --git a/assets/keymaps/initial.json b/assets/keymaps/initial.json new file mode 100644 index 00000000000000..07af2894dee8af --- /dev/null +++ b/assets/keymaps/initial.json @@ -0,0 +1,21 @@ +// Zed keymap +// +// For information on binding keys, see the Zed +// documentation: https://zed.dev/docs/key-bindings +// +// To see the default key bindings run `zed: Open Default Keymap` +// from the command palette. +[ + { + "context": "Workspace", + "bindings": { + // "shift shift": "file_finder::Toggle" + } + }, + { + "context": "Editor", + "bindings": { + // "j k": ["workspace::SendKeystrokes", "escape"] + } + } +] diff --git a/assets/keymaps/linux/atom.json b/assets/keymaps/linux/atom.json new file mode 100644 index 00000000000000..05caf32bcb9bff --- /dev/null +++ b/assets/keymaps/linux/atom.json @@ -0,0 +1,94 @@ +// Default Keymap (Atom) for Zed on Linux +[ + { + "bindings": { + "ctrl-shift-f5": "workspace::Reload", // window:reload + "ctrl-k ctrl-n": "workspace::ActivatePreviousPane", // window:focus-next-pane + "ctrl-k ctrl-p": "workspace::ActivateNextPane" // window:focus-previous-pane + } + }, + { + "context": "Editor", + "bindings": { + "ctrl-shift-l": "language_selector::Toggle", // grammar-selector:show + "ctrl-|": "pane::RevealInProjectPanel", // tree-view:reveal-active-file + "ctrl-b": "editor::GoToDefinition", // fuzzy-finder:toggle-buffer-finder + "ctrl-alt-b": "editor::GoToDefinitionSplit", // N/A: From JetBrains + "ctrl-<": "editor::ScrollCursorCenter", // editor:scroll-to-cursor + "f3": ["editor::SelectNext", { "replace_newest": true }], // find-and-replace:find-next + "shift-f3": ["editor::SelectPrevious", { "replace_newest": true }], //find-and-replace:find-previous + "alt-shift-down": "editor::AddSelectionBelow", // editor:add-selection-below + "alt-shift-up": "editor::AddSelectionAbove", // editor:add-selection-above + "ctrl-k ctrl-u": "editor::ConvertToUpperCase", // editor:upper-case + "ctrl-k ctrl-l": "editor::ConvertToLowerCase", // editor:lower-case + "ctrl-j": "editor::JoinLines", // editor:join-lines + "ctrl-shift-d": "editor::DuplicateLineDown", // editor:duplicate-lines + "ctrl-up": "editor::MoveLineUp", // editor:move-line-up + "ctrl-down": "editor::MoveLineDown", // editor:move-line-down + "ctrl-\\": "workspace::ToggleLeftDock", // tree-view:toggle + "ctrl-shift-m": "markdown::OpenPreviewToTheSide" // markdown-preview:toggle + } + }, + { + "context": "Editor && mode == full", + "bindings": { + "ctrl-r": "outline::Toggle" // symbols-view:toggle-project-symbols + } + }, + { + "context": "BufferSearchBar", + "bindings": { + "ctrl-f3": "search::SelectNextMatch", // find-and-replace:find-next-selected + "ctrl-shift-f3": "search::SelectPrevMatch" // find-and-replace:find-previous-selected + } + }, + { + "context": "Workspace", + "bindings": { + "ctrl-\\": "workspace::ToggleLeftDock", // tree-view:toggle + "ctrl-k ctrl-b": "workspace::ToggleLeftDock", // tree-view:toggle + "ctrl-t": "file_finder::Toggle", // fuzzy-finder:toggle-file-finder + "ctrl-r": "project_symbols::Toggle" // symbols-view:toggle-project-symbols + } + }, + { + "context": "Pane", + "bindings": { + // "ctrl-0": "project_panel::ToggleFocus", // tree-view:toggle-focus + "ctrl-1": ["pane::ActivateItem", 0], // tree-view:open-selected-entry-in-pane-1 + "ctrl-2": ["pane::ActivateItem", 1], // tree-view:open-selected-entry-in-pane-2 + "ctrl-3": ["pane::ActivateItem", 2], // tree-view:open-selected-entry-in-pane-3 + "ctrl-4": ["pane::ActivateItem", 3], // tree-view:open-selected-entry-in-pane-4 + "ctrl-5": ["pane::ActivateItem", 4], // tree-view:open-selected-entry-in-pane-5 + "ctrl-6": ["pane::ActivateItem", 5], // tree-view:open-selected-entry-in-pane-6 + "ctrl-7": ["pane::ActivateItem", 6], // tree-view:open-selected-entry-in-pane-7 + "ctrl-8": ["pane::ActivateItem", 7], // tree-view:open-selected-entry-in-pane-8 + "ctrl-9": ["pane::ActivateItem", 8] // tree-view:open-selected-entry-in-pane-9 + } + }, + { + "context": "ProjectPanel", + "bindings": { + "f2": "project_panel::Rename", // tree-view:rename + "backspace": ["project_panel::Trash", { "skip_prompt": false }], + "ctrl-x": "project_panel::Cut", // tree-view:cut + "ctrl-c": "project_panel::Copy", // tree-view:copy + "ctrl-v": "project_panel::Paste" // tree-view:paste + } + }, + { + "context": "ProjectPanel && not_editing", + "bindings": { + "ctrl-shift-c": "project_panel::CopyPath", // tree-view:copy-full-path + "ctrl-[": "project_panel::CollapseSelectedEntry", // tree-view:collapse-directory + "ctrl-b": "project_panel::CollapseSelectedEntry", // tree-view:collapse-directory + "ctrl-]": "project_panel::ExpandSelectedEntry", // tree-view:expand-item + "ctrl-f": "project_panel::ExpandSelectedEntry", // tree-view:expand-item + "a": "project_panel::NewFile", // tree-view:add-file + "d": "project_panel::Duplicate", // tree-view:duplicate + "home": "menu::SelectFirst", // core:move-to-top + "end": "menu::SelectLast", // core:move-to-bottom + "shift-a": "project_panel::NewDirectory" // tree-view:add-folder + } + } +] diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json new file mode 100644 index 00000000000000..55ba63d8f3aa23 --- /dev/null +++ b/assets/keymaps/linux/jetbrains.json @@ -0,0 +1,91 @@ +[ + { + "bindings": { + "ctrl-shift-[": "pane::ActivatePrevItem", + "ctrl-shift-]": "pane::ActivateNextItem" + } + }, + { + "context": "Editor", + "bindings": { + "ctrl->": "zed::IncreaseBufferFontSize", + "ctrl-<": "zed::DecreaseBufferFontSize", + "ctrl-shift-j": "editor::JoinLines", + "ctrl-d": "editor::DuplicateLineDown", + "ctrl-y": "editor::DeleteLine", + "ctrl-m": "editor::ScrollCursorCenter", + "ctrl-pagedown": "editor::MovePageDown", + "ctrl-pageup": "editor::MovePageUp", + // "ctrl-alt-shift-b": "editor::SelectToPreviousWordStart", + "ctrl-alt-enter": "editor::NewlineAbove", + "shift-enter": "editor::NewlineBelow", + // "ctrl--": "editor::Fold", // TODO: `ctrl-numpad--` (numpad not implemented) + // "ctrl-+": "editor::UnfoldLines", // TODO: `ctrl-numpad+` (numpad not implemented) + "alt-shift-g": "editor::SplitSelectionIntoLines", + "alt-j": ["editor::SelectNext", { "replace_newest": false }], + "alt-shift-j": ["editor::SelectPrevious", { "replace_newest": false }], + "ctrl-/": ["editor::ToggleComments", { "advance_downwards": true }], + "alt-up": "editor::SelectLargerSyntaxNode", + "alt-down": "editor::SelectSmallerSyntaxNode", + "shift-alt-up": "editor::MoveLineUp", + "shift-alt-down": "editor::MoveLineDown", + "ctrl-alt-l": "editor::Format", + "shift-f6": "editor::Rename", + "ctrl-alt-left": "pane::GoBack", + "ctrl-alt-right": "pane::GoForward", + "alt-f7": "editor::FindAllReferences", + "ctrl-alt-f7": "editor::FindAllReferences", + // "ctrl-b": "editor::GoToDefinition", // Conflicts with workspace::ToggleLeftDock + // "ctrl-alt-b": "editor::GoToDefinitionSplit", // Conflicts with workspace::ToggleLeftDock + "ctrl-shift-b": "editor::GoToTypeDefinition", + "ctrl-alt-shift-b": "editor::GoToTypeDefinitionSplit", + "f2": "editor::GoToDiagnostic", + "shift-f2": "editor::GoToPrevDiagnostic", + "ctrl-alt-shift-down": "editor::GoToHunk", + "ctrl-alt-shift-up": "editor::GoToPrevHunk", + "ctrl-home": "editor::MoveToBeginning", + "ctrl-end": "editor::MoveToEnd", + "ctrl-shift-home": "editor::SelectToBeginning", + "ctrl-shift-end": "editor::SelectToEnd" + } + }, + { + "context": "Editor && mode == full", + "bindings": { + "ctrl-f12": "outline::Toggle", + "alt-7": "outline::Toggle", + "ctrl-shift-n": "file_finder::Toggle", + "ctrl-g": "go_to_line::Toggle", + "alt-enter": "editor::ToggleCodeActions" + } + }, + { + "context": "Workspace", + "bindings": { + "ctrl-shift-n": "file_finder::Toggle", + "ctrl-shift-a": "command_palette::Toggle", + "shift shift": "command_palette::Toggle", + "ctrl-alt-shift-n": "project_symbols::Toggle", + "alt-1": "workspace::ToggleLeftDock", + "ctrl-e": "tab_switcher::Toggle", + "alt-6": "diagnostics::Deploy" + } + }, + { + "context": "Pane", + "bindings": { + "ctrl-alt-left": "pane::GoBack", + "ctrl-alt-right": "pane::GoForward" + } + }, + { + "context": "ProjectPanel", + "bindings": { + "enter": "project_panel::Open", + "backspace": ["project_panel::Trash", { "skip_prompt": false }], + "delete": ["project_panel::Trash", { "skip_prompt": false }], + "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], + "shift-f6": "project_panel::Rename" + } + } +] diff --git a/assets/keymaps/linux/sublime_text.json b/assets/keymaps/linux/sublime_text.json new file mode 100644 index 00000000000000..7e9b41615aa100 --- /dev/null +++ b/assets/keymaps/linux/sublime_text.json @@ -0,0 +1,55 @@ +[ + { + "bindings": { + "ctrl-shift-[": "pane::ActivatePrevItem", + "ctrl-shift-]": "pane::ActivateNextItem", + "ctrl-pageup": "pane::ActivatePrevItem", + "ctrl-pagedown": "pane::ActivateNextItem", + "ctrl-tab": "pane::ActivateNextItem", + "ctrl-shift-tab": "pane::ActivatePrevItem" + } + }, + { + "context": "Editor", + "bindings": { + "ctrl-shift-up": "editor::AddSelectionAbove", + "ctrl-shift-down": "editor::AddSelectionBelow", + "ctrl-shift-m": "editor::SelectLargerSyntaxNode", + "ctrl-shift-l": "editor::SplitSelectionIntoLines", + "ctrl-shift-a": "editor::SelectLargerSyntaxNode", + "ctrl-shift-d": "editor::DuplicateLineDown", + "f12": "editor::GoToDefinition", + "ctrl-f12": "editor::GoToDefinitionSplit", + "shift-f12": "editor::FindAllReferences", + "ctrl-shift-f12": "editor::FindAllReferences", + "ctrl-.": "editor::GoToHunk", + "ctrl-,": "editor::GoToPrevHunk", + "ctrl-k ctrl-u": "editor::ConvertToUpperCase", + "ctrl-k ctrl-l": "editor::ConvertToLowerCase", + "shift-alt-m": "markdown::OpenPreviewToTheSide", + "ctrl-backspace": "editor::DeleteToPreviousWordStart", + "ctrl-delete": "editor::DeleteToNextWordEnd" + } + }, + { + "context": "Editor && mode == full", + "bindings": { + "ctrl-r": "outline::Toggle" + } + }, + { + "context": "Pane", + "bindings": { + "f4": "search::SelectNextMatch", + "shift-f4": "search::SelectPrevMatch" + } + }, + { + "context": "Workspace", + "bindings": { + "ctrl-k ctrl-b": "workspace::ToggleLeftDock", + // "ctrl-0": "project_panel::ToggleFocus", // normally resets zoom + "shift-ctrl-r": "project_symbols::Toggle" + } + } +] diff --git a/assets/keymaps/atom.json b/assets/keymaps/macos/atom.json similarity index 57% rename from assets/keymaps/atom.json rename to assets/keymaps/macos/atom.json index 91844295b1c919..50e99927db361e 100644 --- a/assets/keymaps/atom.json +++ b/assets/keymaps/macos/atom.json @@ -1,6 +1,8 @@ +// Default Keymap (Atom) for Zed on MacOS [ { "bindings": { + "ctrl-alt-cmd-l": "workspace::Reload", "cmd-k cmd-p": "workspace::ActivatePreviousPane", "cmd-k cmd-n": "workspace::ActivateNextPane" } @@ -8,24 +10,24 @@ { "context": "Editor", "bindings": { + "ctrl-shift-l": "language_selector::Toggle", + "cmd-|": "pane::RevealInProjectPanel", "cmd-b": "editor::GoToDefinition", "alt-cmd-b": "editor::GoToDefinitionSplit", "cmd-<": "editor::ScrollCursorCenter", - "cmd-g": [ - "editor::SelectNext", - { - "replace_newest": true - } - ], - "ctrl-cmd-g": [ - "editor::SelectPrevious", - { - "replace_newest": true - } - ], + "cmd-g": ["editor::SelectNext", { "replace_newest": true }], + "cmd-shift-g": ["editor::SelectPrevious", { "replace_newest": true }], "ctrl-shift-down": "editor::AddSelectionBelow", "ctrl-shift-up": "editor::AddSelectionAbove", - "cmd-shift-backspace": "editor::DeleteToBeginningOfLine" + "cmd-shift-backspace": "editor::DeleteToBeginningOfLine", + "cmd-k cmd-u": "editor::ConvertToUpperCase", + "cmd-k cmd-l": "editor::ConvertToLowerCase", + "alt-enter": "editor::Newline", + "cmd-shift-d": "editor::DuplicateLineDown", + "ctrl-cmd-up": "editor::MoveLineUp", + "ctrl-cmd-down": "editor::MoveLineDown", + "cmd-\\": "workspace::ToggleLeftDock", + "ctrl-shift-m": "markdown::OpenPreviewToTheSide" } }, { @@ -69,12 +71,26 @@ { "context": "ProjectPanel", "bindings": { + "f2": "project_panel::Rename", + "backspace": ["project_panel::Trash", { "skip_prompt": false }], + "cmd-x": "project_panel::Cut", + "cmd-c": "project_panel::Copy", + "cmd-v": "project_panel::Paste" + } + }, + { + "context": "ProjectPanel && not_editing", + "bindings": { + "ctrl-shift-c": "project_panel::CopyPath", "ctrl-[": "project_panel::CollapseSelectedEntry", "ctrl-b": "project_panel::CollapseSelectedEntry", - "alt-b": "project_panel::CollapseSelectedEntry", "ctrl-]": "project_panel::ExpandSelectedEntry", "ctrl-f": "project_panel::ExpandSelectedEntry", - "ctrl-shift-c": "project_panel::CopyPath" + "a": "project_panel::NewFile", + "d": "project_panel::Duplicate", + "home": "menu::SelectFirst", + "end": "menu::SelectLast", + "shift-a": "project_panel::NewDirectory" } } ] diff --git a/assets/keymaps/jetbrains.json b/assets/keymaps/macos/jetbrains.json similarity index 80% rename from assets/keymaps/jetbrains.json rename to assets/keymaps/macos/jetbrains.json index c099d60578827e..1d5f1181f48a6b 100644 --- a/assets/keymaps/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -21,24 +21,9 @@ "cmd--": "editor::Fold", "cmd-+": "editor::UnfoldLines", "alt-shift-g": "editor::SplitSelectionIntoLines", - "ctrl-g": [ - "editor::SelectNext", - { - "replace_newest": false - } - ], - "ctrl-cmd-g": [ - "editor::SelectPrevious", - { - "replace_newest": false - } - ], - "cmd-/": [ - "editor::ToggleComments", - { - "advance_downwards": true - } - ], + "ctrl-g": ["editor::SelectNext", { "replace_newest": false }], + "ctrl-cmd-g": ["editor::SelectPrevious", { "replace_newest": false }], + "cmd-/": ["editor::ToggleComments", { "advance_downwards": true }], "alt-up": "editor::SelectLargerSyntaxNode", "alt-down": "editor::SelectSmallerSyntaxNode", "shift-alt-up": "editor::MoveLineUp", @@ -54,7 +39,7 @@ "cmd-shift-b": "editor::GoToTypeDefinition", "cmd-alt-shift-b": "editor::GoToTypeDefinitionSplit", "f2": "editor::GoToDiagnostic", - "cmd-f2": "editor::GoToPrevDiagnostic", + "shift-f2": "editor::GoToPrevDiagnostic", "ctrl-alt-shift-down": "editor::GoToHunk", "ctrl-alt-shift-up": "editor::GoToPrevHunk", "cmd-home": "editor::MoveToBeginning", @@ -78,6 +63,7 @@ "bindings": { "cmd-shift-o": "file_finder::Toggle", "cmd-shift-a": "command_palette::Toggle", + "shift shift": "command_palette::Toggle", "cmd-alt-o": "project_symbols::Toggle", "cmd-1": "workspace::ToggleLeftDock", "cmd-6": "diagnostics::Deploy" @@ -94,6 +80,10 @@ "context": "ProjectPanel", "bindings": { "enter": "project_panel::Open", + "cmd-backspace": ["project_panel::Trash", { "skip_prompt": false }], + "backspace": ["project_panel::Trash", { "skip_prompt": false }], + "delete": ["project_panel::Trash", { "skip_prompt": false }], + "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], "shift-f6": "project_panel::Rename" } } diff --git a/assets/keymaps/sublime_text.json b/assets/keymaps/macos/sublime_text.json similarity index 77% rename from assets/keymaps/sublime_text.json rename to assets/keymaps/macos/sublime_text.json index dc1fc1c3ef0286..06de3bfd1eba90 100644 --- a/assets/keymaps/sublime_text.json +++ b/assets/keymaps/macos/sublime_text.json @@ -3,11 +3,10 @@ "bindings": { "cmd-shift-[": "pane::ActivatePrevItem", "cmd-shift-]": "pane::ActivateNextItem", - "ctrl-pagedown": "pane::ActivatePrevItem", - "ctrl-pageup": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivateNextItem", - "ctrl-tab": "pane::ActivatePrevItem", - "cmd-+": "zed::IncreaseBufferFontSize" + "ctrl-pageup": "pane::ActivatePrevItem", + "ctrl-pagedown": "pane::ActivateNextItem", + "ctrl-tab": "pane::ActivateNextItem", + "ctrl-shift-tab": "pane::ActivatePrevItem" } }, { @@ -19,12 +18,17 @@ "ctrl-shift-m": "editor::SelectLargerSyntaxNode", "cmd-shift-l": "editor::SplitSelectionIntoLines", "cmd-shift-a": "editor::SelectLargerSyntaxNode", + "cmd-shift-d": "editor::DuplicateLineDown", "shift-f12": "editor::FindAllReferences", "alt-cmd-down": "editor::GoToDefinition", "ctrl-alt-cmd-down": "editor::GoToDefinitionSplit", "alt-shift-cmd-down": "editor::FindAllReferences", "ctrl-.": "editor::GoToHunk", "ctrl-,": "editor::GoToPrevHunk", + "cmd-k cmd-u": "editor::ConvertToUpperCase", + "cmd-k cmd-l": "editor::ConvertToLowerCase", + "cmd-shift-j": "editor::JoinLines", + "shift-alt-m": "markdown::OpenPreviewToTheSide", "ctrl-backspace": "editor::DeleteToPreviousWordStart", "ctrl-delete": "editor::DeleteToNextWordEnd" } diff --git a/assets/keymaps/textmate.json b/assets/keymaps/macos/textmate.json similarity index 75% rename from assets/keymaps/textmate.json rename to assets/keymaps/macos/textmate.json index c3947dc3ce3c94..5395e9d940599f 100644 --- a/assets/keymaps/textmate.json +++ b/assets/keymaps/macos/textmate.json @@ -22,34 +22,14 @@ "alt-shift-delete": "editor::DeleteToNextWordEnd", "ctrl-backspace": "editor::DeleteToPreviousSubwordStart", "ctrl-delete": "editor::DeleteToNextSubwordEnd", - "alt-left": [ - "editor::MoveToPreviousWordStart", - { - "stop_at_soft_wraps": true - } - ], - "alt-right": [ - "editor::MoveToNextWordEnd", - { - "stop_at_soft_wraps": true - } - ], + "alt-left": ["editor::MoveToPreviousWordStart", { "stop_at_soft_wraps": true }], + "alt-right": ["editor::MoveToNextWordEnd", { "stop_at_soft_wraps": true }], "ctrl-left": "editor::MoveToPreviousSubwordStart", "ctrl-right": "editor::MoveToNextSubwordEnd", "cmd-shift-left": "editor::SelectToBeginningOfLine", "cmd-shift-right": "editor::SelectToEndOfLine", - "alt-shift-left": [ - "editor::SelectToPreviousWordStart", - { - "stop_at_soft_wraps": true - } - ], - "alt-shift-right": [ - "editor::SelectToNextWordEnd", - { - "stop_at_soft_wraps": true - } - ], + "alt-shift-left": ["editor::SelectToPreviousWordStart", { "stop_at_soft_wraps": true }], + "alt-shift-right": ["editor::SelectToNextWordEnd", { "stop_at_soft_wraps": true }], "ctrl-shift-left": "editor::SelectToPreviousSubwordStart", "ctrl-shift-right": "editor::SelectToNextSubwordEnd", "ctrl-w": "editor::SelectNext", @@ -87,7 +67,15 @@ }, { "context": "ProjectPanel", - "bindings": {} + "bindings": { + "cmd-backspace": ["project_panel::Trash", { "skip_prompt": true }], + "cmd-d": "project_panel::Duplicate", + "cmd-n": "project_panel::NewFolder", + "return": "project_panel::Rename", + "cmd-c": "project_panel::Copy", + "cmd-v": "project_panel::Paste", + "cmd-alt-c": "project_panel::CopyPath" + } }, { "context": "Dock", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 045beffeced4a1..d6e61893b7abff 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -1,36 +1,20 @@ [ { - "context": "ProjectPanel || Editor", + "context": "VimControl && !menu", "bindings": { - "ctrl-6": "pane::AlternateFile" - } - }, - { - "context": "Editor && VimControl && !VimWaiting && !menu", - "bindings": { - "i": [ - "vim::PushOperator", - { - "Object": { - "around": false - } - } - ], - "a": [ - "vim::PushOperator", - { - "Object": { - "around": true - } - } - ], + "i": ["vim::PushOperator", { "Object": { "around": false } }], + "a": ["vim::PushOperator", { "Object": { "around": true } }], ":": "command_palette::Toggle", "h": "vim::Left", "left": "vim::Left", "backspace": "vim::Backspace", "j": "vim::Down", "down": "vim::Down", + "ctrl-j": "vim::Down", "enter": "vim::NextLineStart", + "ctrl-m": "vim::NextLineStart", + "+": "vim::NextLineStart", + "-": "vim::PreviousLineStart", "tab": "vim::Tab", "shift-tab": "vim::Tab", "k": "vim::Up", @@ -39,6 +23,7 @@ "right": "vim::Right", "space": "vim::Space", "$": "vim::EndOfLine", + "end": "vim::EndOfLine", "^": "vim::FirstNonWhitespace", "_": "vim::StartOfLineDownward", "g _": "vim::EndOfLineDownward", @@ -46,83 +31,32 @@ "{": "vim::StartOfParagraph", "}": "vim::EndOfParagraph", "|": "vim::GoToColumn", - // Word motions "w": "vim::NextWordStart", "e": "vim::NextWordEnd", "b": "vim::PreviousWordStart", "g e": "vim::PreviousWordEnd", - // Subword motions // "w": "vim::NextSubwordStart", // "b": "vim::PreviousSubwordStart", // "e": "vim::NextSubwordEnd", // "g e": "vim::PreviousSubwordEnd", - - "shift-w": [ - "vim::NextWordStart", - { - "ignorePunctuation": true - } - ], - "shift-e": [ - "vim::NextWordEnd", - { - "ignorePunctuation": true - } - ], - "shift-b": [ - "vim::PreviousWordStart", - { - "ignorePunctuation": true - } - ], + "shift-w": ["vim::NextWordStart", { "ignorePunctuation": true }], + "shift-e": ["vim::NextWordEnd", { "ignorePunctuation": true }], + "shift-b": ["vim::PreviousWordStart", { "ignorePunctuation": true }], "g shift-e": ["vim::PreviousWordEnd", { "ignorePunctuation": true }], - "/": "vim::Search", - "?": [ - "vim::Search", - { - "backwards": true - } - ], + "g /": "pane::DeploySearch", + "?": ["vim::Search", { "backwards": true }], "*": "vim::MoveToNext", "#": "vim::MoveToPrev", "n": "vim::MoveToNextMatch", "shift-n": "vim::MoveToPrevMatch", "%": "vim::Matching", - "f": [ - "vim::PushOperator", - { - "FindForward": { - "before": false - } - } - ], - "t": [ - "vim::PushOperator", - { - "FindForward": { - "before": true - } - } - ], - "shift-f": [ - "vim::PushOperator", - { - "FindBackward": { - "after": false - } - } - ], - "shift-t": [ - "vim::PushOperator", - { - "FindBackward": { - "after": true - } - } - ], + "f": ["vim::PushOperator", { "FindForward": { "before": false } }], + "t": ["vim::PushOperator", { "FindForward": { "before": true } }], + "shift-f": ["vim::PushOperator", { "FindBackward": { "after": false } }], + "shift-t": ["vim::PushOperator", { "FindBackward": { "after": true } }], "m": ["vim::PushOperator", "Mark"], "'": ["vim::PushOperator", { "Jump": { "line": true } }], "`": ["vim::PushOperator", { "Jump": { "line": false } }], @@ -139,7 +73,8 @@ "ctrl-q": "vim::ToggleVisualBlock", "shift-k": "editor::Hover", "shift-r": "vim::ToggleReplace", - "0": "vim::StartOfLine", // When no number operator present, use start of line motion + "0": "vim::StartOfLine", + "home": "vim::StartOfLine", "ctrl-f": "vim::PageDown", "pagedown": "vim::PageDown", "ctrl-b": "vim::PageUp", @@ -161,93 +96,29 @@ "g shift-n": "vim::SelectPreviousMatch", "g l": "vim::SelectNext", "g shift-l": "vim::SelectPrevious", - "g >": [ - "editor::SelectNext", - { - "replace_newest": true - } - ], - "g <": [ - "editor::SelectPrevious", - { - "replace_newest": true - } - ], + "g >": ["editor::SelectNext", { "replace_newest": true }], + "g <": ["editor::SelectPrevious", { "replace_newest": true }], "g a": "editor::SelectAllMatches", "g s": "outline::Toggle", "g shift-s": "project_symbols::Toggle", "g .": "editor::ToggleCodeActions", // zed specific "g shift-a": "editor::FindAllReferences", // zed specific "g space": "editor::OpenExcerpts", // zed specific - "g *": [ - "vim::MoveToNext", - { - "partialWord": true - } - ], - "g #": [ - "vim::MoveToPrev", - { - "partialWord": true - } - ], - "g j": [ - "vim::Down", - { - "displayLines": true - } - ], - "g down": [ - "vim::Down", - { - "displayLines": true - } - ], - "g k": [ - "vim::Up", - { - "displayLines": true - } - ], - "g up": [ - "vim::Up", - { - "displayLines": true - } - ], - "g $": [ - "vim::EndOfLine", - { - "displayLines": true - } - ], - "g end": [ - "vim::EndOfLine", - { - "displayLines": true - } - ], - "g 0": [ - "vim::StartOfLine", - { - "displayLines": true - } - ], - "g home": [ - "vim::StartOfLine", - { - "displayLines": true - } - ], - "g ^": [ - "vim::FirstNonWhitespace", - { - "displayLines": true - } - ], + "g *": ["vim::MoveToNext", { "partialWord": true }], + "g #": ["vim::MoveToPrev", { "partialWord": true }], + "g j": ["vim::Down", { "displayLines": true }], + "g down": ["vim::Down", { "displayLines": true }], + "g k": ["vim::Up", { "displayLines": true }], + "g up": ["vim::Up", { "displayLines": true }], + "g $": ["vim::EndOfLine", { "displayLines": true }], + "g end": ["vim::EndOfLine", { "displayLines": true }], + "g 0": ["vim::StartOfLine", { "displayLines": true }], + "g home": ["vim::StartOfLine", { "displayLines": true }], + "g ^": ["vim::FirstNonWhitespace", { "displayLines": true }], + "g v": "vim::RestoreVisualSelection", "g ]": "editor::GoToDiagnostic", "g [": "editor::GoToPrevDiagnostic", - "g i": ["workspace::SendKeystrokes", "` ^ i"], + "g i": "vim::InsertAtPrevious", "g ,": "vim::ChangeListNewer", "g ;": "vim::ChangeListOlder", "shift-h": "vim::WindowTop", @@ -261,18 +132,8 @@ "z c": "editor::Fold", "z o": "editor::UnfoldLines", "z f": "editor::FoldSelectedRanges", - "shift-z shift-q": [ - "pane::CloseActiveItem", - { - "saveIntent": "skip" - } - ], - "shift-z shift-z": [ - "pane::CloseActiveItem", - { - "saveIntent": "saveAll" - } - ], + "shift-z shift-q": ["pane::CloseActiveItem", { "saveIntent": "skip" }], + "shift-z shift-z": ["pane::CloseActiveItem", { "saveIntent": "saveAll" }], // Count support "1": ["vim::Number", 1], "2": ["vim::Number", 2], @@ -284,6 +145,7 @@ "8": ["vim::Number", 8], "9": ["vim::Number", 9], // window related commands (ctrl-w X) + "ctrl-w": null, "ctrl-w left": ["workspace::ActivatePaneInDirection", "Left"], "ctrl-w right": ["workspace::ActivatePaneInDirection", "Right"], "ctrl-w up": ["workspace::ActivatePaneInDirection", "Up"], @@ -327,27 +189,26 @@ "ctrl-w ctrl-o": "workspace::CloseInactiveTabsAndPanes", "ctrl-w n": ["workspace::NewFileInDirection", "Up"], "ctrl-w ctrl-n": ["workspace::NewFileInDirection", "Up"], - "ctrl-w d": "editor::GoToDefinitionSplit", "ctrl-w g d": "editor::GoToDefinitionSplit", "ctrl-w shift-d": "editor::GoToTypeDefinitionSplit", "ctrl-w g shift-d": "editor::GoToTypeDefinitionSplit", "ctrl-w space": "editor::OpenExcerptsSplit", "ctrl-w g space": "editor::OpenExcerptsSplit", - "-": "pane::RevealInProjectPanel" + "ctrl-6": "pane::AlternateFile" } }, { - // escape is in its own section so that it cancels a pending count. - "context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting", + "context": "VimControl && VimCount", "bindings": { - "escape": "editor::Cancel", - "ctrl-[": "editor::Cancel" + "0": ["vim::Number", 0] } }, { - "context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting", + "context": "vim_mode == normal", "bindings": { + "escape": "editor::Cancel", + "ctrl-[": "editor::Cancel", ".": "vim::Repeat", "c": ["vim::PushOperator", "Change"], "shift-c": "vim::ChangeToEndOfLine", @@ -355,7 +216,7 @@ "shift-d": "vim::DeleteToEndOfLine", "shift-j": "vim::JoinLines", "y": ["vim::PushOperator", "Yank"], - "shift-y": "vim::YankLine", + "shift-y": "vim::YankToEndOfLine", "i": "vim::InsertBefore", "shift-i": "vim::InsertFirstNonWhitespace", "a": "vim::InsertAfter", @@ -368,19 +229,21 @@ "ctrl-a": "vim::Increment", "ctrl-x": "vim::Decrement", "p": "vim::Paste", - "shift-p": [ - "vim::Paste", - { - "before": true - } - ], - "u": "editor::Undo", - "ctrl-r": "editor::Redo", + "shift-p": ["vim::Paste", { "before": true }], + "u": "vim::Undo", + "ctrl-r": "vim::Redo", "r": ["vim::PushOperator", "Replace"], "s": "vim::Substitute", "shift-s": "vim::SubstituteLine", - "> >": "vim::Indent", - "< <": "vim::Outdent", + ">": ["vim::PushOperator", "Indent"], + "<": ["vim::PushOperator", "Outdent"], + "g u": ["vim::PushOperator", "Lowercase"], + "g shift-u": ["vim::PushOperator", "Uppercase"], + "g ~": ["vim::PushOperator", "OppositeCase"], + "\"": ["vim::PushOperator", "Register"], + "q": "vim::ToggleRecord", + "shift-q": "vim::ReplayLastRecording", + "@": ["vim::PushOperator", "ReplayRegister"], "ctrl-pagedown": "pane::ActivateNextItem", "ctrl-pageup": "pane::ActivatePrevItem", // tree-sitter related commands @@ -389,86 +252,107 @@ "] d": "editor::GoToDiagnostic", "[ d": "editor::GoToPrevDiagnostic", "] c": "editor::GoToHunk", - "[ c": "editor::GoToPrevHunk" + "[ c": "editor::GoToPrevHunk", + "g c c": "vim::ToggleComments" } }, { - "context": "Editor && vim_mode == visual && vim_operator == none && !VimWaiting", + "context": "vim_mode == visual", "bindings": { + "u": "vim::ConvertToLowerCase", + "U": "vim::ConvertToUpperCase", + "o": "vim::OtherEnd", + "shift-o": "vim::OtherEnd", + "d": "vim::VisualDelete", + "x": "vim::VisualDelete", + "shift-d": "vim::VisualDeleteLine", + "shift-x": "vim::VisualDeleteLine", + "y": "vim::VisualYank", + "shift-y": "vim::VisualYank", + "p": "vim::Paste", + "shift-p": ["vim::Paste", { "preserveClipboard": true }], + "s": "vim::Substitute", + "shift-s": "vim::SubstituteLine", + "shift-r": "vim::SubstituteLine", + "c": "vim::Substitute", + "~": "vim::ChangeCase", + "*": ["vim::MoveToNext", { "partialWord": true }], + "#": ["vim::MoveToPrev", { "partialWord": true }], + "ctrl-a": "vim::Increment", + "ctrl-x": "vim::Decrement", + "g ctrl-a": ["vim::Increment", { "step": true }], + "g ctrl-x": ["vim::Decrement", { "step": true }], + "shift-i": "vim::InsertBefore", + "shift-a": "vim::InsertAfter", + "shift-j": "vim::JoinLines", + "r": ["vim::PushOperator", "Replace"], + "ctrl-c": ["vim::SwitchMode", "Normal"], + "escape": ["vim::SwitchMode", "Normal"], + "ctrl-[": ["vim::SwitchMode", "Normal"], + ">": "vim::Indent", + "<": "vim::Outdent", + "i": ["vim::PushOperator", { "Object": { "around": false } }], + "a": ["vim::PushOperator", { "Object": { "around": true } }], + "g c": "vim::ToggleComments", + "\"": ["vim::PushOperator", "Register"], // tree-sitter related commands "[ x": "editor::SelectLargerSyntaxNode", "] x": "editor::SelectSmallerSyntaxNode" } }, { - "context": "Editor && VimCount", + "context": "vim_mode == insert", "bindings": { - "0": ["vim::Number", 0] - } - }, - { - "context": "Editor && vim_operator == c", - "bindings": { - "c": "vim::CurrentLine", - "d": "editor::Rename" // zed specific - } - }, - { - "context": "Editor && vim_mode == normal && vim_operator == c", - "bindings": { - "s": [ - "vim::PushOperator", - { - "ChangeSurrounds": {} - } - ] - } - }, - { - "context": "Editor && vim_operator == d", - "bindings": { - "d": "vim::CurrentLine" - } - }, - { - "context": "Editor && vim_mode == normal && vim_operator == d", - "bindings": { - "s": ["vim::PushOperator", "DeleteSurrounds"] + "escape": "vim::NormalBefore", + "ctrl-c": "vim::NormalBefore", + "ctrl-[": "vim::NormalBefore", + "ctrl-x": null, + "ctrl-x ctrl-o": "editor::ShowCompletions", + "ctrl-x ctrl-a": "assistant::InlineAssist", // zed specific + "ctrl-x ctrl-c": "editor::ShowInlineCompletion", // zed specific + "ctrl-x ctrl-l": "editor::ToggleCodeActions", // zed specific + "ctrl-x ctrl-z": "editor::Cancel", + "ctrl-w": "editor::DeleteToPreviousWordStart", + "ctrl-u": "editor::DeleteToBeginningOfLine", + "ctrl-t": "vim::Indent", + "ctrl-d": "vim::Outdent", + "ctrl-r": ["vim::PushOperator", "Register"] } }, { - "context": "Editor && vim_operator == y", + "context": "vim_mode == replace", "bindings": { - "y": "vim::CurrentLine" + "escape": "vim::NormalBefore", + "ctrl-c": "vim::NormalBefore", + "ctrl-[": "vim::NormalBefore", + "backspace": "vim::UndoReplace", + "tab": "vim::Tab", + "enter": "vim::Enter" } }, { - "context": "Editor && vim_mode == normal && vim_operator == y", + "context": "vim_mode == waiting", "bindings": { - "s": [ - "vim::PushOperator", - { - "AddSurrounds": {} - } - ] + "tab": "vim::Tab", + "enter": "vim::Enter", + "escape": "vim::ClearOperators", + "ctrl-c": "vim::ClearOperators", + "ctrl-[": "vim::ClearOperators" } }, { - "context": "Editor && vim_operator == ys", + "context": "vim_mode == operator", "bindings": { - "s": "vim::CurrentLine" + "escape": "vim::ClearOperators", + "ctrl-c": "vim::ClearOperators", + "ctrl-[": "vim::ClearOperators" } }, { - "context": "Editor && VimObject", + "context": "vim_operator == a || vim_operator == i || vim_operator == cs", "bindings": { "w": "vim::Word", - "shift-w": [ - "vim::Word", - { - "ignorePunctuation": true - } - ], + "shift-w": ["vim::Word", { "ignorePunctuation": true }], "t": "vim::Tag", "s": "vim::Sentence", "p": "vim::Paragraph", @@ -490,130 +374,64 @@ } }, { - "context": "Editor && vim_mode == visual && !VimWaiting && !VimObject", + "context": "vim_operator == c", "bindings": { - "u": "vim::ConvertToLowerCase", - "U": "vim::ConvertToUpperCase", - "o": "vim::OtherEnd", - "shift-o": "vim::OtherEnd", - "d": "vim::VisualDelete", - "x": "vim::VisualDelete", - "shift-d": "vim::VisualDeleteLine", - "shift-x": "vim::VisualDeleteLine", - "y": "vim::VisualYank", - "shift-y": "vim::VisualYank", - "p": "vim::Paste", - "shift-p": [ - "vim::Paste", - { - "preserveClipboard": true - } - ], - "s": "vim::Substitute", - "shift-s": "vim::SubstituteLine", - "shift-r": "vim::SubstituteLine", - "c": "vim::Substitute", - "~": "vim::ChangeCase", - "*": [ - "vim::MoveToNext", - { - "partialWord": true - } - ], - "#": [ - "vim::MoveToPrev", - { - "partialWord": true - } - ], - "ctrl-a": "vim::Increment", - "ctrl-x": "vim::Decrement", - "g ctrl-a": [ - "vim::Increment", - { - "step": true - } - ], - "g ctrl-x": [ - "vim::Decrement", - { - "step": true - } - ], - "shift-i": "vim::InsertBefore", - "shift-a": "vim::InsertAfter", - "shift-j": "vim::JoinLines", - "r": ["vim::PushOperator", "Replace"], - "ctrl-c": ["vim::SwitchMode", "Normal"], - "escape": ["vim::SwitchMode", "Normal"], - "ctrl-[": ["vim::SwitchMode", "Normal"], - ">": "vim::Indent", - "<": "vim::Outdent", - "i": [ - "vim::PushOperator", - { - "Object": { - "around": false - } - } - ], - "a": [ - "vim::PushOperator", - { - "Object": { - "around": true - } - } - ] + "c": "vim::CurrentLine", + "d": "editor::Rename", // zed specific + "s": ["vim::PushOperator", { "ChangeSurrounds": {} }] } }, { - "context": "Editor && vim_mode == normal", + "context": "vim_operator == d", "bindings": { - "g c c": "editor::ToggleComments" + "d": "vim::CurrentLine", + "s": ["vim::PushOperator", "DeleteSurrounds"] } }, { - "context": "Editor && vim_mode == visual", + "context": "vim_operator == gu", "bindings": { - "g c": "editor::ToggleComments" + "g u": "vim::CurrentLine", + "u": "vim::CurrentLine" } }, { - "context": "Editor && vim_mode == insert", + "context": "vim_operator == gU", "bindings": { - "escape": "vim::NormalBefore", - "ctrl-c": "vim::NormalBefore", - "ctrl-[": "vim::NormalBefore", - "ctrl-x ctrl-o": "editor::ShowCompletions", - "ctrl-x ctrl-a": "assistant::InlineAssist", // zed specific - "ctrl-x ctrl-c": "editor::ShowInlineCompletion", // zed specific - "ctrl-x ctrl-l": "editor::ToggleCodeActions", // zed specific - "ctrl-x ctrl-z": "editor::Cancel", - "ctrl-w": "editor::DeleteToPreviousWordStart", - "ctrl-u": "editor::DeleteToBeginningOfLine", - "ctrl-t": "vim::Indent", - "ctrl-d": "vim::Outdent", - "ctrl-r \"": "editor::Paste", - "ctrl-r +": "editor::Paste" + "g shift-u": "vim::CurrentLine", + "shift-u": "vim::CurrentLine" } }, { - "context": "Editor && vim_mode == replace", + "context": "vim_operator == g~", "bindings": { - "escape": "vim::NormalBefore", - "ctrl-c": "vim::NormalBefore", - "ctrl-[": "vim::NormalBefore", - "backspace": "vim::UndoReplace" + "g ~": "vim::CurrentLine", + "~": "vim::CurrentLine" } }, { - "context": "Editor && VimWaiting", + "context": "vim_operator == y", "bindings": { - "tab": "vim::Tab", - "enter": "vim::Enter", - "escape": ["vim::SwitchMode", "Normal"], - "ctrl-[": ["vim::SwitchMode", "Normal"] + "y": "vim::CurrentLine", + "s": ["vim::PushOperator", { "AddSurrounds": {} }] + } + }, + { + "context": "vim_operator == ys", + "bindings": { + "s": "vim::CurrentLine" + } + }, + { + "context": "vim_operator == >", + "bindings": { + ">": "vim::CurrentLine" + } + }, + { + "context": "vim_operator == <", + "bindings": { + "<": "vim::CurrentLine" } }, { @@ -626,7 +444,8 @@ { "context": "EmptyPane || SharedScreen", "bindings": { - ":": "command_palette::Toggle" + ":": "command_palette::Toggle", + "g /": "pane::DeploySearch" } }, { @@ -649,10 +468,20 @@ "t": "project_panel::OpenPermanent", "v": "project_panel::OpenPermanent", "p": "project_panel::Open", - "x": "project_panel::RevealInFinder", + "x": "project_panel::RevealInFileManager", "shift-g": "menu::SelectLast", "g g": "menu::SelectFirst", - "-": "project_panel::SelectParent" + "-": "project_panel::SelectParent", + "ctrl-6": "pane::AlternateFile" + } + }, + { + "context": "OutlinePanel", + "bindings": { + "j": "menu::SelectNext", + "k": "menu::SelectPrev", + "shift-g": "menu::SelectLast", + "g g": "menu::SelectFirst" } } ] diff --git a/assets/prompts/operations.md b/assets/prompts/operations.md new file mode 100644 index 00000000000000..b77cfedf12048e --- /dev/null +++ b/assets/prompts/operations.md @@ -0,0 +1,241 @@ +Your task is to map a step from the conversation above to operations on symbols inside the provided source files. + +Guidelines: +- There's no need to describe *what* to do, just *where* to do it. +- If creating a file, assume any subsequent updates are included at the time of creation. +- Don't create and then update a file. +- We'll create it in one shot. +- Prefer updating symbols lower in the syntax tree if possible. +- Never include operations on a parent symbol and one of its children in the same block. +- Never nest an operation with another operation or include CDATA or other content. All operations are leaf nodes. +- Include a description attribute for each operation with a brief, one-line description of the change to perform. +- Descriptions are required for all operations except delete. +- When generating multiple operations, ensure the descriptions are specific to each individual operation. +- Avoid referring to the location in the description. Focus on the change to be made, not the location where it's made. That's implicit with the symbol you provide. +- Don't generate multiple operations at the same location. Instead, combine them together in a single operation with a succinct combined description. + +The available operation types are: + +1. : Modify an existing symbol in a file. +2. : Create a new file. +3. : Add a new symbol as sibling after an existing symbol in a file. +4. : Add a new symbol as the last child of an existing symbol in a file. +5. : Add a new symbol as the first child of an existing symbol in a file. +6. : Remove an existing symbol from a file. The `description` attribute is invalid for delete, but required for other ops. + +All operations *require* a path. +Operations that *require* a symbol: , , +Operations that don't allow a symbol: +Operations that have an *optional* symbol: , + +Example 1: + +User: + ```rs src/rectangle.rs + struct Rectangle { + width: f64, + height: f64, + } + + impl Rectangle { + fn new(width: f64, height: f64) -> Self { + Rectangle { width, height } + } + } + ``` + + Symbols for src/rectangle.rs: + - struct Rectangle + - impl Rectangle + - impl Rectangle fn new + + Add new methods 'calculate_area' and 'calculate_perimeter' to the Rectangle struct + Implement the 'Display' trait for the Rectangle struct + + What are the operations for the step: Add a new method 'calculate_area' to the Rectangle struct + +Assistant (wrong): + + + + + +This demonstrates what NOT to do. NEVER append multiple children at the same location. + +Assistant (corrected): + + + + +User: +What are the operations for the step: Implement the 'Display' trait for the Rectangle struct + +Assistant: + + + + +Example 2: + +User: +```rs src/user.rs +struct User { + pub name: String, + age: u32, + email: String, +} + +impl User { + fn new(name: String, age: u32, email: String) -> Self { + User { name, age, email } + } + + pub fn print_info(&self) { + println!("Name: {}, Age: {}, Email: {}", self.name, self.age, self.email); + } +} +``` + +Symbols for src/user.rs: +- struct User +- struct User pub name +- struct User age +- struct User email +- impl User +- impl User fn new +- impl User pub fn print_info + +Update the 'print_info' method to use formatted output +Remove the 'email' field from the User struct + +What are the operations for the step: Update the 'print_info' method to use formatted output + +Assistant: + + + + +User: +What are the operations for the step: Remove the 'email' field from the User struct + +Assistant: + + + + +Example 3: + +User: +```rs src/vehicle.rs +struct Vehicle { + make: String, + model: String, + year: u32, +} + +impl Vehicle { + fn new(make: String, model: String, year: u32) -> Self { + Vehicle { make, model, year } + } + + fn print_year(&self) { + println!("Year: {}", self.year); + } +} +``` + +Symbols for src/vehicle.rs: +- struct Vehicle +- struct Vehicle make +- struct Vehicle model +- struct Vehicle year +- impl Vehicle +- impl Vehicle fn new +- impl Vehicle fn print_year + +Add a 'use std::fmt;' statement at the beginning of the file +Add a new method 'start_engine' in the Vehicle impl block + +What are the operations for the step: Add a 'use std::fmt;' statement at the beginning of the file + +Assistant: + + + + +User: +What are the operations for the step: Add a new method 'start_engine' in the Vehicle impl block + +Assistant: + + + + +Example 4: + +User: +```rs src/employee.rs +struct Employee { + name: String, + position: String, + salary: u32, + department: String, +} + +impl Employee { + fn new(name: String, position: String, salary: u32, department: String) -> Self { + Employee { name, position, salary, department } + } + + fn print_details(&self) { + println!("Name: {}, Position: {}, Salary: {}, Department: {}", + self.name, self.position, self.salary, self.department); + } + + fn give_raise(&mut self, amount: u32) { + self.salary += amount; + } +} +``` + +Symbols for src/employee.rs: +- struct Employee +- struct Employee name +- struct Employee position +- struct Employee salary +- struct Employee department +- impl Employee +- impl Employee fn new +- impl Employee fn print_details +- impl Employee fn give_raise + +Make salary an f32 + +What are the operations for the step: Make salary an f32 + +A (wrong): + + + + + +This example demonstrates what not to do. `struct Employee salary` is a child of `struct Employee`. + +A (corrected): + + + + +User: + What are the correct operations for the step: Remove the 'department' field and update the 'print_details' method + +A: + + + + + +Now generate the operations for the following step. +Output only valid XML containing valid operations with their required attributes. +NEVER output code or any other text inside tags. If you do, you will replaced with another model. +Your response *MUST* begin with and end with : diff --git a/assets/settings/default.json b/assets/settings/default.json index a1cc720e8c94fd..7dafecfa1de1d4 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1,19 +1,15 @@ { // The name of the Zed theme to use for the UI. // - // The theme can also be set to follow system preferences: - // - // "theme": { - // "mode": "system", - // "light": "One Light", - // "dark": "One Dark" - // } - // - // Where `mode` is one of: + // `mode` is one of: // - "system": Use the theme that corresponds to the system's appearance // - "light": Use the theme indicated by the "light" field // - "dark": Use the theme indicated by the "dark" field - "theme": "One Dark", + "theme": { + "mode": "system", + "light": "One Light", + "dark": "One Dark" + }, // The name of a base set of key bindings to use. // This setting can take four values, each named after another // text editor: @@ -29,7 +25,7 @@ "inline_completion_provider": "copilot" }, // The name of a font to use for rendering text in the editor - "buffer_font_family": "Zed Mono", + "buffer_font_family": "Zed Plex Mono", // The OpenType features to enable for text in the editor. "buffer_font_features": { // Disable ligatures: @@ -37,24 +33,29 @@ }, // The default font size for text in the editor "buffer_font_size": 15, + // The weight of the editor font in standard CSS units from 100 to 900. + "buffer_font_weight": 400, // Set the buffer's line height. // May take 3 values: // 1. Use a line height that's comfortable for reading (1.618) - // "line_height": "comfortable" + // "buffer_line_height": "comfortable" // 2. Use a standard line height, (1.3) - // "line_height": "standard", + // "buffer_line_height": "standard", // 3. Use a custom line height - // "line_height": { + // "buffer_line_height": { // "custom": 2 // }, "buffer_line_height": "comfortable", // The name of a font to use for rendering text in the UI - "ui_font_family": ".SystemUIFont", + // (On macOS) You can set this to ".SystemUIFont" to use the system font + "ui_font_family": "Zed Plex Sans", // The OpenType features to enable for text in the UI "ui_font_features": { // Disable ligatures: "calt": false }, + // The weight of the UI font in standard CSS units from 100 to 900. + "ui_font_weight": 400, // The default font size for text in the UI "ui_font_size": 16, // The factor to grow the active pane by. Defaults to 1.0 @@ -93,6 +94,9 @@ // 3. Never close the window // "when_closing_with_no_tabs": "keep_window_open", "when_closing_with_no_tabs": "platform_default", + // Whether to use the system provided dialogs for Open and Save As. + // When set to false, Zed will use the built-in keyboard-first pickers. + "use_system_path_prompts": true, // Whether the cursor blinks in the editor. "cursor_blink": true, // How to highlight the current line in the editor. @@ -115,24 +119,24 @@ // The debounce delay before re-querying the language server for completion // documentation when not included in original completion list. "completion_documentation_secondary_query_debounce": 300, - // Whether to show wrap guides in the editor. Setting this to true will - // show a guide at the 'preferred_line_length' value if 'soft_wrap' is set to - // 'preferred_line_length', and will show any additional guides as specified - // by the 'wrap_guides' setting. + // Show method signatures in the editor, when inside parentheses. + "auto_signature_help": false, + /// Whether to show the signature help after completion or a bracket pair inserted. + /// If `auto_signature_help` is enabled, this setting will be treated as enabled also. + "show_signature_help_after_edits": true, + // Whether to show wrap guides (vertical rulers) in the editor. + // Setting this to true will show a guide at the 'preferred_line_length' value + // if softwrap is set to 'preferred_line_length', and will show any + // additional guides as specified by the 'wrap_guides' setting. "show_wrap_guides": true, // Character counts at which to show wrap guides in the editor. "wrap_guides": [], // Hide the values of in variables from visual display in private files "redact_private_values": false, + // The default number of lines to expand excerpts in the multibuffer by. + "expand_excerpt_lines": 3, // Globs to match against file paths to determine if a file is private. - "private_files": [ - "**/.env*", - "**/*.pem", - "**/*.key", - "**/*.cert", - "**/*.crt", - "**/secrets.yml" - ], + "private_files": ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/secrets.yml"], // Whether to use additional LSP queries to format (and amend) the code after // every "trigger" symbol input, defined by LSP server capabilities. "use_on_type_format": true, @@ -140,38 +144,50 @@ // opening parenthesis, bracket, brace, single or double quote characters. // For example, when you type (, Zed will add a closing ) at the correct position. "use_autoclose": true, + // Whether to automatically surround selected text when typing opening parenthesis, + // bracket, brace, single or double quote characters. + // For example, when you select text and type (, Zed will surround the text with (). + "use_auto_surround": true, // Controls how the editor handles the autoclosed characters. // When set to `false`(default), skipping over and auto-removing of the closing characters // happen only for auto-inserted characters. // Otherwise(when `true`), the closing characters are always skipped over and auto-removed // no matter how they were inserted. "always_treat_brackets_as_autoclosed": false, - // Controls whether copilot provides suggestion immediately - // or waits for a `copilot::Toggle` - "show_copilot_suggestions": true, + // Controls whether inline completions are shown immediately (true) + // or manually by triggering `editor::ShowInlineCompletion` (false). + "show_inline_completions": true, // Whether to show tabs and spaces in the editor. // This setting can take three values: // // 1. Draw tabs and spaces only for the selected text (default): // "selection" // 2. Do not draw any tabs or spaces: - // "none" + // "none" // 3. Draw all invisible symbols: - // "all" + // "all" + // 4. Draw whitespaces at boundaries only: + // "boundary" + // For a whitespace to be on a boundary, any of the following conditions need to be met: + // - It is a tab + // - It is adjacent to an edge (start or end) + // - It is adjacent to a whitespace (left or right) "show_whitespaces": "selection", // Settings related to calls in Zed "calls": { // Join calls with the microphone live by default "mute_on_join": false, // Share your project when you are the first to join a channel - "share_on_join": true + "share_on_join": false }, // Toolbar related settings "toolbar": { // Whether to show breadcrumbs. "breadcrumbs": true, // Whether to show quick action buttons. - "quick_actions": true + "quick_actions": true, + // Whether to show the Selections menu in the editor toolbar + "selections_menu": true }, // Scrollbar related settings "scrollbar": { @@ -213,6 +229,8 @@ "line_numbers": true, // Whether to show code action buttons in the gutter. "code_actions": true, + // Whether to show runnables buttons in the gutter. + "runnables": true, // Whether to show fold buttons in the gutter. "folds": true }, @@ -221,6 +239,8 @@ "enabled": true, /// The width of the indent guides in pixels, between 1 and 10. "line_width": 1, + /// The width of the active indent guide in pixels, between 1 and 10. + "active_line_width": 1, /// Determines how indent guides are colored. /// This setting can take the following three values: /// @@ -235,12 +255,16 @@ /// 2. "indent_aware" "background_coloring": "disabled" }, + // Whether the editor will scroll beyond the last line. + "scroll_beyond_last_line": "one_page", // The number of lines to keep above/below the cursor when scrolling. "vertical_scroll_margin": 3, // Scroll sensitivity multiplier. This multiplier is applied // to both the horizontal and vertical delta values while scrolling. "scroll_sensitivity": 1.0, "relative_line_numbers": false, + // If 'search_wrap' is disabled, search result do not wrap around the end of the file. + "search_wrap": true, // When to populate a new search's query based on the text under the cursor. // This setting can take the following three values: // @@ -286,9 +310,39 @@ // when a corresponding project entry becomes active. // Gitignored entries are never auto revealed. "auto_reveal_entries": true, + // Whether to fold directories automatically and show compact folders + // (e.g. "a/b/c" ) when a directory has only one subdirectory inside. + "auto_fold_dirs": false, + /// Scrollbar-related settings + "scrollbar": { + /// When to show the scrollbar in the project panel. + /// + /// Default: always + "show": "always" + } + }, + "outline_panel": { + // Whether to show the outline panel button in the status bar + "button": true, + // Default width of the outline panel. + "default_width": 300, + // Where to dock the outline panel. Can be 'left' or 'right'. + "dock": "left", + // Whether to show file icons in the outline panel. + "file_icons": true, + // Whether to show folder icons or chevrons for directories in the outline panel. + "folder_icons": true, + // Whether to show the git status in the outline panel. + "git_status": true, + // Amount of indentation for nested items. + "indent_size": 20, + // Whether to reveal it in the outline panel automatically, + // when a corresponding outline entry becomes active. + // Gitignored entries are never auto revealed. + "auto_reveal_entries": true, /// Whether to fold directories automatically /// when a directory has only one directory inside. - "auto_fold_dirs": false + "auto_fold_dirs": true }, "collaboration_panel": { // Whether to show the collaboration panel button in the status bar. @@ -342,6 +396,7 @@ // 2. "gpt-4" // 3. "gpt-4-turbo-preview" // 4. "gpt-4o" + // 5. "gpt-4o-mini" "default_model": "gpt-4o" } }, @@ -349,6 +404,9 @@ "show_call_status_icon": true, // Whether to use language servers to provide code intelligence. "enable_language_server": true, + // Whether to perform linked edits of associated ranges, if the language server supports it. + // For example, when editing opening tag, the contents of the closing tag will be edited as well. + "linked_edits": true, // The list of language servers to use (or disable) for all languages. // // This is typically customized on a per-language basis. @@ -377,7 +435,9 @@ // Show git status colors in the editor tabs. "git_status": false, // Position of the close button on the editor tabs. - "close_position": "right" + "close_position": "right", + // Whether to show the file icon for a tab. + "file_icons": false }, // Settings related to preview tabs. "preview_tabs": { @@ -420,16 +480,16 @@ // or falling back to formatting via language server: // "formatter": "auto" "formatter": "auto", - // How to soft-wrap long lines of text. This setting can take - // three values: + // How to soft-wrap long lines of text. + // Possible values: // // 1. Do not soft wrap. // "soft_wrap": "none", // 2. Prefer a single line generally, unless an overly long line is encountered. // "soft_wrap": "prefer_line", - // 3. Soft wrap lines that overflow the editor: + // 3. Soft wrap lines that overflow the editor. // "soft_wrap": "editor_width", - // 4. Soft wrap lines at the preferred line length + // 4. Soft wrap lines at the preferred line length. // "soft_wrap": "preferred_line_length", "soft_wrap": "prefer_line", // The column at which to soft-wrap lines, for buffers where soft-wrap @@ -447,7 +507,8 @@ // Send anonymized usage data like what languages you're using Zed with. "metrics": true }, - // Automatically update Zed + // Automatically update Zed. This setting may be ignored on Linux if + // installed through a package manager. "auto_update": true, // Diagnostics configuration. "diagnostics": { @@ -484,9 +545,16 @@ // "delay_ms": 600 } }, - "copilot": { - // The set of glob patterns for which copilot should be disabled - // in any matching file. + // Configuration for how direnv configuration should be loaded. May take 2 values: + // 1. Load direnv configuration through the shell hook, works for POSIX shells and fish. + // "load_direnv": "shell_hook" + // 2. Load direnv configuration using `direnv export json` directly. + // This can help with some shells that otherwise would not detect + // the direnv environment, such as nushell or elvish. + // "load_direnv": "direct" + "load_direnv": "shell_hook", + "inline_completions": { + // A list of globs representing files that inline completions should be disabled for. "disabled_globs": [".env"] }, // Settings specific to journaling @@ -612,13 +680,17 @@ // "font_size": 15, // Set the terminal's font family. If this option is not included, // the terminal will default to matching the buffer's font family. - // "font_family": "Zed Mono", + // "font_family": "Zed Plex Mono", // Sets the maximum number of lines in the terminal's scrollback buffer. // Default: 10_000, maximum: 100_000 (all bigger values set will be treated as 100_000), 0 disables the scrolling. // Existing terminals will not pick up this change until they are recreated. // "max_scroll_history_lines": 10000, }, "code_actions_on_format": {}, + /// Settings related to running tasks. + "tasks": { + "variables": {} + }, // An object whose keys are language names, and whose values // are arrays of filenames or extensions of files that should // use those languages. @@ -631,7 +703,10 @@ // "TOML": ["Embargo.lock"] // } // - "file_types": {}, + "file_types": { + "JSON": ["flake.lock"], + "JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json"] + }, // The extensions that Zed should automatically install on startup. // // If you don't want any of these extensions, add this field to your settings @@ -653,10 +728,12 @@ } }, "C": { - "format_on_save": "off" + "format_on_save": "off", + "use_on_type_format": false }, "C++": { - "format_on_save": "off" + "format_on_save": "off", + "use_on_type_format": false }, "CSS": { "prettier": { @@ -666,9 +743,6 @@ "Elixir": { "language_servers": ["elixir-ls", "!next-ls", "!lexical", "..."] }, - "Gleam": { - "tab_size": 2 - }, "Go": { "code_actions_on_format": { "source.organizeImports": true @@ -694,6 +768,7 @@ } }, "JavaScript": { + "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { "allowed": true } @@ -703,26 +778,28 @@ "allowed": true } }, - "Make": { - "hard_tabs": true + "JSONC": { + "prettier": { + "allowed": true + } }, "Markdown": { "format_on_save": "off", + "use_on_type_format": false, "prettier": { "allowed": true } }, "PHP": { + "language_servers": ["phpactor", "!intelephense", "..."], "prettier": { "allowed": true, - "plugins": ["@prettier/plugin-php"] + "plugins": ["@prettier/plugin-php"], + "parser": "php" } }, - "Prisma": { - "tab_size": 2 - }, "Ruby": { - "language_servers": ["solargraph", "!ruby-lsp", "..."] + "language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "..."] }, "SCSS": { "prettier": { @@ -742,6 +819,7 @@ } }, "TSX": { + "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { "allowed": true } @@ -752,6 +830,7 @@ } }, "TypeScript": { + "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { "allowed": true } diff --git a/assets/settings/initial_user_settings.json b/assets/settings/initial_user_settings.json index 75d4a02626336c..705dc30428ca71 100644 --- a/assets/settings/initial_user_settings.json +++ b/assets/settings/initial_user_settings.json @@ -4,9 +4,14 @@ // documentation: https://zed.dev/docs/configuring-zed // // To see all of Zed's default settings without changing your -// custom settings, run the `open default settings` command -// from the command palette or from `Zed` application menu. +// custom settings, run the `zed: Open Default Settings` command +// from the command palette { "ui_font_size": 16, - "buffer_font_size": 16 + "buffer_font_size": 16, + "theme": { + "mode": "system", + "light": "One Light", + "dark": "One Dark" + } } diff --git a/assets/themes/andromeda/andromeda.json b/assets/themes/andromeda/andromeda.json index 389b0269a17768..8fe6d03a2563e4 100644 --- a/assets/themes/andromeda/andromeda.json +++ b/assets/themes/andromeda/andromeda.json @@ -38,6 +38,7 @@ "icon.accent": "#10a793ff", "status_bar.background": "#262933ff", "title_bar.background": "#262933ff", + "title_bar.inactive_background": "#21242bff", "toolbar.background": "#1e2025ff", "tab_bar.background": "#21242bff", "tab.inactive_background": "#21242bff", diff --git a/assets/themes/atelier/atelier.json b/assets/themes/atelier/atelier.json index d8fae1d30d5d0e..b374cbeca42c12 100644 --- a/assets/themes/atelier/atelier.json +++ b/assets/themes/atelier/atelier.json @@ -38,6 +38,7 @@ "icon.accent": "#566ddaff", "status_bar.background": "#3a353fff", "title_bar.background": "#3a353fff", + "title_bar.inactive_background": "#221f26ff", "toolbar.background": "#19171cff", "tab_bar.background": "#221f26ff", "tab.inactive_background": "#221f26ff", @@ -422,6 +423,7 @@ "icon.accent": "#586cdaff", "status_bar.background": "#bfbcc5ff", "title_bar.background": "#bfbcc5ff", + "title_bar.inactive_background": "#e6e3ebff", "toolbar.background": "#efecf4ff", "tab_bar.background": "#e6e3ebff", "tab.inactive_background": "#e6e3ebff", @@ -806,6 +808,7 @@ "icon.accent": "#6684e0ff", "status_bar.background": "#45433bff", "title_bar.background": "#45433bff", + "title_bar.inactive_background": "#262622ff", "toolbar.background": "#20201dff", "tab_bar.background": "#262622ff", "tab.inactive_background": "#262622ff", @@ -1190,6 +1193,7 @@ "icon.accent": "#6684dfff", "status_bar.background": "#cecab4ff", "title_bar.background": "#cecab4ff", + "title_bar.inactive_background": "#eeebd7ff", "toolbar.background": "#fefbecff", "tab_bar.background": "#eeebd7ff", "tab.inactive_background": "#eeebd7ff", @@ -1574,6 +1578,7 @@ "icon.accent": "#36a165ff", "status_bar.background": "#424136ff", "title_bar.background": "#424136ff", + "title_bar.inactive_background": "#2c2b23ff", "toolbar.background": "#22221bff", "tab_bar.background": "#2c2b23ff", "tab.inactive_background": "#2c2b23ff", @@ -1958,6 +1963,7 @@ "icon.accent": "#37a165ff", "status_bar.background": "#c5c4b9ff", "title_bar.background": "#c5c4b9ff", + "title_bar.inactive_background": "#ebeae3ff", "toolbar.background": "#f4f3ecff", "tab_bar.background": "#ebeae3ff", "tab.inactive_background": "#ebeae3ff", @@ -2342,6 +2348,7 @@ "icon.accent": "#407ee6ff", "status_bar.background": "#443c39ff", "title_bar.background": "#443c39ff", + "title_bar.inactive_background": "#27211eff", "toolbar.background": "#1b1918ff", "tab_bar.background": "#27211eff", "tab.inactive_background": "#27211eff", @@ -2726,6 +2733,7 @@ "icon.accent": "#407ee6ff", "status_bar.background": "#ccc7c5ff", "title_bar.background": "#ccc7c5ff", + "title_bar.inactive_background": "#e9e6e4ff", "toolbar.background": "#f0eeedff", "tab_bar.background": "#e9e6e4ff", "tab.inactive_background": "#e9e6e4ff", @@ -3110,6 +3118,7 @@ "icon.accent": "#5169ebff", "status_bar.background": "#433a43ff", "title_bar.background": "#433a43ff", + "title_bar.inactive_background": "#252025ff", "toolbar.background": "#1b181bff", "tab_bar.background": "#252025ff", "tab.inactive_background": "#252025ff", @@ -3494,6 +3503,7 @@ "icon.accent": "#5169ebff", "status_bar.background": "#c6b8c6ff", "title_bar.background": "#c6b8c6ff", + "title_bar.inactive_background": "#e0d5e0ff", "toolbar.background": "#f7f3f7ff", "tab_bar.background": "#e0d5e0ff", "tab.inactive_background": "#e0d5e0ff", @@ -3878,6 +3888,7 @@ "icon.accent": "#267eadff", "status_bar.background": "#33444dff", "title_bar.background": "#33444dff", + "title_bar.inactive_background": "#1c2529ff", "toolbar.background": "#161b1dff", "tab_bar.background": "#1c2529ff", "tab.inactive_background": "#1c2529ff", @@ -4262,6 +4273,7 @@ "icon.accent": "#267eadff", "status_bar.background": "#a6cadcff", "title_bar.background": "#a6cadcff", + "title_bar.inactive_background": "#cdeaf9ff", "toolbar.background": "#ebf8ffff", "tab_bar.background": "#cdeaf9ff", "tab.inactive_background": "#cdeaf9ff", @@ -4646,6 +4658,7 @@ "icon.accent": "#7272caff", "status_bar.background": "#3b3535ff", "title_bar.background": "#3b3535ff", + "title_bar.inactive_background": "#252020ff", "toolbar.background": "#1b1818ff", "tab_bar.background": "#252020ff", "tab.inactive_background": "#252020ff", @@ -5030,6 +5043,7 @@ "icon.accent": "#7272caff", "status_bar.background": "#c1bbbbff", "title_bar.background": "#c1bbbbff", + "title_bar.inactive_background": "#ebe3e3ff", "toolbar.background": "#f4ececff", "tab_bar.background": "#ebe3e3ff", "tab.inactive_background": "#ebe3e3ff", @@ -5414,6 +5428,7 @@ "icon.accent": "#468b8fff", "status_bar.background": "#353f39ff", "title_bar.background": "#353f39ff", + "title_bar.inactive_background": "#1f2621ff", "toolbar.background": "#171c19ff", "tab_bar.background": "#1f2621ff", "tab.inactive_background": "#1f2621ff", @@ -5798,6 +5813,7 @@ "icon.accent": "#488b90ff", "status_bar.background": "#bcc5bfff", "title_bar.background": "#bcc5bfff", + "title_bar.inactive_background": "#e3ebe6ff", "toolbar.background": "#ecf4eeff", "tab_bar.background": "#e3ebe6ff", "tab.inactive_background": "#e3ebe6ff", @@ -6182,6 +6198,7 @@ "icon.accent": "#3e62f4ff", "status_bar.background": "#3b453bff", "title_bar.background": "#3b453bff", + "title_bar.inactive_background": "#1f231fff", "toolbar.background": "#131513ff", "tab_bar.background": "#1f231fff", "tab.inactive_background": "#1f231fff", @@ -6566,6 +6583,7 @@ "icon.accent": "#3e61f4ff", "status_bar.background": "#b4ceb4ff", "title_bar.background": "#b4ceb4ff", + "title_bar.inactive_background": "#daeedaff", "toolbar.background": "#f3faf3ff", "tab_bar.background": "#daeedaff", "tab.inactive_background": "#daeedaff", @@ -6950,6 +6968,7 @@ "icon.accent": "#3e8ed0ff", "status_bar.background": "#3e4769ff", "title_bar.background": "#3e4769ff", + "title_bar.inactive_background": "#262f51ff", "toolbar.background": "#202646ff", "tab_bar.background": "#262f51ff", "tab.inactive_background": "#262f51ff", @@ -7334,6 +7353,7 @@ "icon.accent": "#3e8fd0ff", "status_bar.background": "#c1c5d8ff", "title_bar.background": "#c1c5d8ff", + "title_bar.inactive_background": "#e5e8f5ff", "toolbar.background": "#f5f7ffff", "tab_bar.background": "#e5e8f5ff", "tab.inactive_background": "#e5e8f5ff", diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index e83d35f2ede364..79ffacdc666cfa 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -38,6 +38,7 @@ "icon.accent": "#5ac1feff", "status_bar.background": "#313337ff", "title_bar.background": "#313337ff", + "title_bar.inactive_background": "#1f2127ff", "toolbar.background": "#0d1016ff", "tab_bar.background": "#1f2127ff", "tab.inactive_background": "#1f2127ff", @@ -407,6 +408,7 @@ "icon.accent": "#3b9ee5ff", "status_bar.background": "#dcdddeff", "title_bar.background": "#dcdddeff", + "title_bar.inactive_background": "#ececedff", "toolbar.background": "#fcfcfcff", "tab_bar.background": "#ececedff", "tab.inactive_background": "#ececedff", @@ -776,6 +778,7 @@ "icon.accent": "#72cffeff", "status_bar.background": "#464a52ff", "title_bar.background": "#464a52ff", + "title_bar.inactive_background": "#353944ff", "toolbar.background": "#242835ff", "tab_bar.background": "#353944ff", "tab.inactive_background": "#353944ff", diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index d0dd3964574328..d899ff39dfca62 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -47,6 +47,7 @@ "icon.accent": "#83a598ff", "status_bar.background": "#4c4642ff", "title_bar.background": "#4c4642ff", + "title_bar.inactive_background": "#3a3735ff", "toolbar.background": "#282828ff", "tab_bar.background": "#3a3735ff", "tab.inactive_background": "#3a3735ff", @@ -430,6 +431,7 @@ "icon.accent": "#83a598ff", "status_bar.background": "#4c4642ff", "title_bar.background": "#4c4642ff", + "title_bar.inactive_background": "#393634ff", "toolbar.background": "#1d2021ff", "tab_bar.background": "#393634ff", "tab.inactive_background": "#393634ff", @@ -813,6 +815,7 @@ "icon.accent": "#83a598ff", "status_bar.background": "#4c4642ff", "title_bar.background": "#4c4642ff", + "title_bar.inactive_background": "#3b3735ff", "toolbar.background": "#32302fff", "tab_bar.background": "#3b3735ff", "tab.inactive_background": "#3b3735ff", @@ -1196,6 +1199,7 @@ "icon.accent": "#0b6678ff", "status_bar.background": "#d9c8a4ff", "title_bar.background": "#d9c8a4ff", + "title_bar.inactive_background": "#ecddb4ff", "toolbar.background": "#fbf1c7ff", "tab_bar.background": "#ecddb4ff", "tab.inactive_background": "#ecddb4ff", @@ -1579,6 +1583,7 @@ "icon.accent": "#0b6678ff", "status_bar.background": "#d9c8a4ff", "title_bar.background": "#d9c8a4ff", + "title_bar.inactive_background": "#ecddb5ff", "toolbar.background": "#f9f5d7ff", "tab_bar.background": "#ecddb5ff", "tab.inactive_background": "#ecddb5ff", @@ -1962,6 +1967,7 @@ "icon.accent": "#0b6678ff", "status_bar.background": "#d9c8a4ff", "title_bar.background": "#d9c8a4ff", + "title_bar.inactive_background": "#ecdcb3ff", "toolbar.background": "#f2e5bcff", "tab_bar.background": "#ecdcb3ff", "tab.inactive_background": "#ecdcb3ff", diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index 6c22473fdd1787..f98b21acac2bea 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -15,29 +15,30 @@ "elevated_surface.background": "#2f343eff", "surface.background": "#2f343eff", "background": "#3b414dff", - "element.background": "#2f343eff", + "element.background": "#2e343eff", "element.hover": "#363c46ff", "element.active": "#454a56ff", "element.selected": "#454a56ff", - "element.disabled": "#2f343eff", + "element.disabled": "#2e343eff", "drop_target.background": "#83899480", "ghost_element.background": "#00000000", "ghost_element.hover": "#363c46ff", "ghost_element.active": "#454a56ff", "ghost_element.selected": "#454a56ff", - "ghost_element.disabled": "#2f343eff", + "ghost_element.disabled": "#2e343eff", "text": "#c8ccd4ff", "text.muted": "#838994ff", - "text.placeholder": "#555a63ff", - "text.disabled": "#555a63ff", + "text.placeholder": "#696B77ff", + "text.disabled": "#696B77ff", "text.accent": "#74ade8ff", "icon": "#c8ccd4ff", "icon.muted": "#838994ff", - "icon.disabled": "#555a63ff", + "icon.disabled": "#696B77ff", "icon.placeholder": "#838994ff", "icon.accent": "#74ade8ff", "status_bar.background": "#3b414dff", "title_bar.background": "#3b414dff", + "title_bar.inactive_background": "#2e343eff", "toolbar.background": "#282c33ff", "tab_bar.background": "#2f343eff", "tab.inactive_background": "#2f343eff", @@ -59,7 +60,7 @@ "editor.highlighted_line.background": "#2f343eff", "editor.line_number": "#c8ccd459", "editor.active_line_number": "#c8ccd4ff", - "editor.invisible": "#555a63ff", + "editor.invisible": "#696B77ff", "editor.wrap_guide": "#c8ccd40d", "editor.active_wrap_guide": "#c8ccd41a", "editor.document_highlight.read_background": "#74ade81a", @@ -94,46 +95,46 @@ "terminal.ansi.dim_white": "#575d65ff", "link_text.hover": "#74ade8ff", "conflict": "#dec184ff", - "conflict.background": "#41321dff", + "conflict.background": "#dec1841a", "conflict.border": "#5d4c2fff", "created": "#a1c181ff", - "created.background": "#222e1dff", + "created.background": "#a1c1811a", "created.border": "#38482fff", "deleted": "#d07277ff", - "deleted.background": "#301b1bff", + "deleted.background": "#d072771a", "deleted.border": "#4c2b2cff", "error": "#d07277ff", - "error.background": "#301b1bff", + "error.background": "#d072771a", "error.border": "#4c2b2cff", - "hidden": "#555a63ff", - "hidden.background": "#3b414dff", + "hidden": "#696B77ff", + "hidden.background": "#696B771a", "hidden.border": "#414754ff", "hint": "#5a6f89ff", - "hint.background": "#18243dff", + "hint.background": "#5a6f891a", "hint.border": "#293b5bff", - "ignored": "#555a63ff", - "ignored.background": "#3b414dff", + "ignored": "#696B77ff", + "ignored.background": "#696B771a", "ignored.border": "#464b57ff", "info": "#74ade8ff", - "info.background": "#18243dff", + "info.background": "#74ade81a", "info.border": "#293b5bff", "modified": "#dec184ff", - "modified.background": "#41321dff", + "modified.background": "#dec1841a", "modified.border": "#5d4c2fff", "predictive": "#5a6a87ff", - "predictive.background": "#222e1dff", + "predictive.background": "#5a6a871a", "predictive.border": "#38482fff", "renamed": "#74ade8ff", - "renamed.background": "#18243dff", + "renamed.background": "#74ade81a", "renamed.border": "#293b5bff", "success": "#a1c181ff", - "success.background": "#222e1dff", + "success.background": "#a1c1811a", "success.border": "#38482fff", "unreachable": "#838994ff", - "unreachable.background": "#3b414dff", + "unreachable.background": "#8389941a", "unreachable.border": "#464b57ff", "warning": "#dec184ff", - "warning.background": "#41321dff", + "warning.background": "#dec1841a", "warning.border": "#5d4c2fff", "players": [ { @@ -412,6 +413,7 @@ "icon.accent": "#5c78e2ff", "status_bar.background": "#dcdcddff", "title_bar.background": "#dcdcddff", + "title_bar.inactive_background": "#ebebecff", "toolbar.background": "#fafafaff", "tab_bar.background": "#ebebecff", "tab.inactive_background": "#ebebecff", @@ -491,7 +493,7 @@ "info": "#5c78e2ff", "info.background": "#e2e2faff", "info.border": "#cbcdf6ff", - "modified": "#dec184ff", + "modified": "#a47a23ff", "modified.background": "#faf2e6ff", "modified.border": "#f4e7d1ff", "predictive": "#9b9ec6ff", diff --git a/assets/themes/rose_pine/rose_pine.json b/assets/themes/rose_pine/rose_pine.json index 7d5865fdbec040..99778348ba038d 100644 --- a/assets/themes/rose_pine/rose_pine.json +++ b/assets/themes/rose_pine/rose_pine.json @@ -38,6 +38,7 @@ "icon.accent": "#9bced6ff", "status_bar.background": "#292738ff", "title_bar.background": "#292738ff", + "title_bar.inactive_background": "#1c1b2aff", "toolbar.background": "#191724ff", "tab_bar.background": "#1c1b2aff", "tab.inactive_background": "#1c1b2aff", @@ -417,6 +418,7 @@ "icon.accent": "#57949fff", "status_bar.background": "#dcd8d8ff", "title_bar.background": "#dcd8d8ff", + "title_bar.inactive_background": "#fef9f2ff", "toolbar.background": "#faf4edff", "tab_bar.background": "#fef9f2ff", "tab.inactive_background": "#fef9f2ff", @@ -796,6 +798,7 @@ "icon.accent": "#9bced6ff", "status_bar.background": "#38354eff", "title_bar.background": "#38354eff", + "title_bar.inactive_background": "#28253cff", "toolbar.background": "#232136ff", "tab_bar.background": "#28253cff", "tab.inactive_background": "#28253cff", diff --git a/assets/themes/sandcastle/sandcastle.json b/assets/themes/sandcastle/sandcastle.json index 83f8ef68ddf9ab..12e1e2d2297926 100644 --- a/assets/themes/sandcastle/sandcastle.json +++ b/assets/themes/sandcastle/sandcastle.json @@ -38,6 +38,7 @@ "icon.accent": "#518b8bff", "status_bar.background": "#333944ff", "title_bar.background": "#333944ff", + "title_bar.inactive_background": "#2b3038ff", "toolbar.background": "#282c33ff", "tab_bar.background": "#2b3038ff", "tab.inactive_background": "#2b3038ff", diff --git a/assets/themes/solarized/solarized.json b/assets/themes/solarized/solarized.json index 53a4441c9febee..aa4e793e2a79ab 100644 --- a/assets/themes/solarized/solarized.json +++ b/assets/themes/solarized/solarized.json @@ -38,6 +38,7 @@ "icon.accent": "#278ad1ff", "status_bar.background": "#073743ff", "title_bar.background": "#073743ff", + "title_bar.inactive_background": "#04313bff", "toolbar.background": "#002a35ff", "tab_bar.background": "#04313bff", "tab.inactive_background": "#04313bff", @@ -407,6 +408,7 @@ "icon.accent": "#288bd1ff", "status_bar.background": "#cfd0c4ff", "title_bar.background": "#cfd0c4ff", + "title_bar.inactive_background": "#f3eddaff", "toolbar.background": "#fdf6e3ff", "tab_bar.background": "#f3eddaff", "tab.inactive_background": "#f3eddaff", diff --git a/assets/themes/summercamp/summercamp.json b/assets/themes/summercamp/summercamp.json index 35b101ea383b44..51e9e2d61db6f0 100644 --- a/assets/themes/summercamp/summercamp.json +++ b/assets/themes/summercamp/summercamp.json @@ -38,6 +38,7 @@ "icon.accent": "#499befff", "status_bar.background": "#2a261cff", "title_bar.background": "#2a261cff", + "title_bar.inactive_background": "#231f16ff", "toolbar.background": "#1b1810ff", "tab_bar.background": "#231f16ff", "tab.inactive_background": "#231f16ff", diff --git a/docker-compose.yml b/compose.yml similarity index 98% rename from docker-compose.yml rename to compose.yml index af25ed3e6e7576..28f091fb6f04d4 100644 --- a/docker-compose.yml +++ b/compose.yml @@ -1,5 +1,3 @@ -version: "3.7" - services: postgres: image: postgres:15 diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index b0ff7632786452..d39cb4af479c96 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -3,22 +3,22 @@ use editor::Editor; use extension::ExtensionStore; use futures::StreamExt; use gpui::{ - actions, svg, AppContext, CursorStyle, EventEmitter, InteractiveElement as _, Model, - ParentElement as _, Render, SharedString, StatefulInteractiveElement, Styled, View, - ViewContext, VisualContext as _, + actions, anchored, deferred, percentage, Animation, AnimationExt as _, AppContext, CursorStyle, + DismissEvent, EventEmitter, InteractiveElement as _, Model, ParentElement as _, Render, + SharedString, StatefulInteractiveElement, Styled, Transformation, View, ViewContext, + VisualContext as _, +}; +use language::{ + LanguageRegistry, LanguageServerBinaryStatus, LanguageServerId, LanguageServerName, }; -use language::{LanguageRegistry, LanguageServerBinaryStatus, LanguageServerName}; use project::{LanguageServerProgress, Project}; use smallvec::SmallVec; -use std::{cmp::Reverse, fmt::Write, sync::Arc}; -use ui::prelude::*; +use std::{cmp::Reverse, fmt::Write, sync::Arc, time::Duration}; +use ui::{prelude::*, ContextMenu}; use workspace::{item::ItemHandle, StatusItemView, Workspace}; actions!(activity_indicator, [ShowErrorMessage]); -const DOWNLOAD_ICON: &str = "icons/download.svg"; -const WARNING_ICON: &str = "icons/warning.svg"; - pub enum Event { ShowError { lsp_name: Arc, error: String }, } @@ -27,6 +27,7 @@ pub struct ActivityIndicator { statuses: Vec, project: Model, auto_updater: Option>, + context_menu: Option>, } struct LspStatus { @@ -35,14 +36,14 @@ struct LspStatus { } struct PendingWork<'a> { - language_server_name: &'a str, + language_server_id: LanguageServerId, progress_token: &'a str, progress: &'a LanguageServerProgress, } #[derive(Default)] struct Content { - icon: Option<&'static str>, + icon: Option, message: String, on_click: Option)>>, } @@ -78,6 +79,7 @@ impl ActivityIndicator { statuses: Default::default(), project: project.clone(), auto_updater, + context_menu: None, } }); @@ -105,6 +107,7 @@ impl ActivityIndicator { Editor::for_buffer(buffer, Some(project.clone()), cx) })), None, + true, cx, ); })?; @@ -151,7 +154,7 @@ impl ActivityIndicator { .read(cx) .language_server_statuses() .rev() - .filter_map(|status| { + .filter_map(|(server_id, status)| { if status.pending_work.is_empty() { None } else { @@ -159,7 +162,7 @@ impl ActivityIndicator { .pending_work .iter() .map(|(token, progress)| PendingWork { - language_server_name: status.name.as_str(), + language_server_id: server_id, progress_token: token.as_str(), progress, }) @@ -175,33 +178,44 @@ impl ActivityIndicator { // Show any language server has pending activity. let mut pending_work = self.pending_language_server_work(cx); if let Some(PendingWork { - language_server_name, progress_token, progress, + .. }) = pending_work.next() { - let mut message = language_server_name.to_string(); - - message.push_str(": "); - if let Some(progress_message) = progress.message.as_ref() { - message.push_str(progress_message); - } else { - message.push_str(progress_token); - } + let mut message = progress + .title + .as_deref() + .unwrap_or(progress_token) + .to_string(); if let Some(percentage) = progress.percentage { write!(&mut message, " ({}%)", percentage).unwrap(); } + if let Some(progress_message) = progress.message.as_ref() { + message.push_str(": "); + message.push_str(progress_message); + } + let additional_work_count = pending_work.count(); if additional_work_count > 0 { write!(&mut message, " + {} more", additional_work_count).unwrap(); } return Content { - icon: None, + icon: Some( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), + ) + .into_any_element(), + ), message, - on_click: None, + on_click: Some(Arc::new(Self::toggle_language_server_work_context_menu)), }; } @@ -222,7 +236,11 @@ impl ActivityIndicator { if !downloading.is_empty() { return Content { - icon: Some(DOWNLOAD_ICON), + icon: Some( + Icon::new(IconName::Download) + .size(IconSize::Small) + .into_any_element(), + ), message: format!("Downloading {}...", downloading.join(", "),), on_click: None, }; @@ -230,7 +248,11 @@ impl ActivityIndicator { if !checking_for_update.is_empty() { return Content { - icon: Some(DOWNLOAD_ICON), + icon: Some( + Icon::new(IconName::Download) + .size(IconSize::Small) + .into_any_element(), + ), message: format!( "Checking for updates to {}...", checking_for_update.join(", "), @@ -241,7 +263,11 @@ impl ActivityIndicator { if !failed.is_empty() { return Content { - icon: Some(WARNING_ICON), + icon: Some( + Icon::new(IconName::ExclamationTriangle) + .size(IconSize::Small) + .into_any_element(), + ), message: format!( "Failed to download {}. Click to show error.", failed.join(", "), @@ -255,7 +281,11 @@ impl ActivityIndicator { // Show any formatting failure if let Some(failure) = self.project.read(cx).last_formatting_failure() { return Content { - icon: Some(WARNING_ICON), + icon: Some( + Icon::new(IconName::ExclamationTriangle) + .size(IconSize::Small) + .into_any_element(), + ), message: format!("Formatting failed: {}. Click to see logs.", failure), on_click: Some(Arc::new(|_, cx| { cx.dispatch_action(Box::new(workspace::OpenLog)); @@ -267,17 +297,29 @@ impl ActivityIndicator { if let Some(updater) = &self.auto_updater { return match &updater.read(cx).status() { AutoUpdateStatus::Checking => Content { - icon: Some(DOWNLOAD_ICON), + icon: Some( + Icon::new(IconName::Download) + .size(IconSize::Small) + .into_any_element(), + ), message: "Checking for Zed updates…".to_string(), on_click: None, }, AutoUpdateStatus::Downloading => Content { - icon: Some(DOWNLOAD_ICON), + icon: Some( + Icon::new(IconName::Download) + .size(IconSize::Small) + .into_any_element(), + ), message: "Downloading Zed update…".to_string(), on_click: None, }, AutoUpdateStatus::Installing => Content { - icon: Some(DOWNLOAD_ICON), + icon: Some( + Icon::new(IconName::Download) + .size(IconSize::Small) + .into_any_element(), + ), message: "Installing Zed update…".to_string(), on_click: None, }, @@ -285,14 +327,18 @@ impl ActivityIndicator { icon: None, message: "Click to restart and update Zed".to_string(), on_click: Some(Arc::new({ - let restart = workspace::Restart { + let reload = workspace::Reload { binary_path: Some(binary_path.clone()), }; - move |_, cx| workspace::restart(&restart, cx) + move |_, cx| workspace::reload(&reload, cx) })), }, AutoUpdateStatus::Errored => Content { - icon: Some(WARNING_ICON), + icon: Some( + Icon::new(IconName::ExclamationTriangle) + .size(IconSize::Small) + .into_any_element(), + ), message: "Auto update failed".to_string(), on_click: Some(Arc::new(|this, cx| { this.dismiss_error_message(&Default::default(), cx) @@ -307,7 +353,11 @@ impl ActivityIndicator { { if let Some(extension_id) = extension_store.outstanding_operations().keys().next() { return Content { - icon: Some(DOWNLOAD_ICON), + icon: Some( + Icon::new(IconName::Download) + .size(IconSize::Small) + .into_any_element(), + ), message: format!("Updating {extension_id} extension…"), on_click: None, }; @@ -316,6 +366,75 @@ impl ActivityIndicator { Default::default() } + + fn toggle_language_server_work_context_menu(&mut self, cx: &mut ViewContext) { + if self.context_menu.take().is_some() { + return; + } + + self.build_lsp_work_context_menu(cx); + cx.notify(); + } + + fn build_lsp_work_context_menu(&mut self, cx: &mut ViewContext) { + let mut has_work = false; + let this = cx.view().downgrade(); + let context_menu = ContextMenu::build(cx, |mut menu, cx| { + for work in self.pending_language_server_work(cx) { + has_work = true; + + let this = this.clone(); + let title = SharedString::from( + work.progress + .title + .as_deref() + .unwrap_or(work.progress_token) + .to_string(), + ); + if work.progress.is_cancellable { + let language_server_id = work.language_server_id; + let token = work.progress_token.to_string(); + menu = menu.custom_entry( + move |_| { + h_flex() + .w_full() + .justify_between() + .child(Label::new(title.clone())) + .child(Icon::new(IconName::XCircle)) + .into_any_element() + }, + move |cx| { + this.update(cx, |this, cx| { + this.project.update(cx, |project, cx| { + project.cancel_language_server_work( + language_server_id, + Some(token.clone()), + cx, + ); + }); + this.context_menu.take(); + }) + .ok(); + }, + ); + } else { + menu = menu.label(title.clone()); + } + } + menu + }); + + if has_work { + cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| { + this.context_menu.take(); + cx.notify(); + }) + .detach(); + cx.focus_view(&context_menu); + self.context_menu = Some(context_menu); + cx.notify(); + } + } } impl EventEmitter for ActivityIndicator {} @@ -338,8 +457,17 @@ impl Render for ActivityIndicator { } result - .children(content.icon.map(|icon| svg().path(icon))) + .gap_2() + .children(content.icon) .child(Label::new(SharedString::from(content.message)).size(LabelSize::Small)) + .children(self.context_menu.as_ref().map(|menu| { + deferred( + anchored() + .anchor(gpui::AnchorCorner::BottomLeft) + .child(menu.clone()), + ) + .with_priority(1) + })) } } diff --git a/crates/anthropic/Cargo.toml b/crates/anthropic/Cargo.toml index 484a9b3e10f0ed..0ea24d5c0770bf 100644 --- a/crates/anthropic/Cargo.toml +++ b/crates/anthropic/Cargo.toml @@ -23,6 +23,7 @@ isahc.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true +strum.workspace = true [dev-dependencies] tokio.workspace = true diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 65df4e74dc65bc..c36a7f37fd25cd 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -4,13 +4,16 @@ use http::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use isahc::config::Configurable; use serde::{Deserialize, Serialize}; use std::{convert::TryFrom, time::Duration}; +use strum::EnumIter; pub const ANTHROPIC_API_URL: &'static str = "https://api.anthropic.com"; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] pub enum Model { #[default] + #[serde(alias = "claude-3-5-sonnet", rename = "claude-3-5-sonnet-20240620")] + Claude3_5Sonnet, #[serde(alias = "claude-3-opus", rename = "claude-3-opus-20240229")] Claude3Opus, #[serde(alias = "claude-3-sonnet", rename = "claude-3-sonnet-20240229")] @@ -21,7 +24,9 @@ pub enum Model { impl Model { pub fn from_id(id: &str) -> Result { - if id.starts_with("claude-3-opus") { + if id.starts_with("claude-3-5-sonnet") { + Ok(Self::Claude3_5Sonnet) + } else if id.starts_with("claude-3-opus") { Ok(Self::Claude3Opus) } else if id.starts_with("claude-3-sonnet") { Ok(Self::Claude3Sonnet) @@ -34,6 +39,7 @@ impl Model { pub fn id(&self) -> &'static str { match self { + Model::Claude3_5Sonnet => "claude-3-5-sonnet-20240620", Model::Claude3Opus => "claude-3-opus-20240229", Model::Claude3Sonnet => "claude-3-sonnet-20240229", Model::Claude3Haiku => "claude-3-opus-20240307", @@ -42,6 +48,7 @@ impl Model { pub fn display_name(&self) -> &'static str { match self { + Self::Claude3_5Sonnet => "Claude 3.5 Sonnet", Self::Claude3Opus => "Claude 3 Opus", Self::Claude3Sonnet => "Claude 3 Sonnet", Self::Claude3Haiku => "Claude 3 Haiku", diff --git a/crates/assets/src/assets.rs b/crates/assets/src/assets.rs index b0a32a9d9cfe5d..f9284bca86b3b9 100644 --- a/crates/assets/src/assets.rs +++ b/crates/assets/src/assets.rs @@ -16,9 +16,9 @@ use rust_embed::RustEmbed; pub struct Assets; impl AssetSource for Assets { - fn load(&self, path: &str) -> Result> { + fn load(&self, path: &str) -> Result>> { Self::get(path) - .map(|f| f.data) + .map(|f| Some(f.data)) .ok_or_else(|| anyhow!("could not find asset at path \"{}\"", path)) } @@ -42,11 +42,23 @@ impl Assets { let mut embedded_fonts = Vec::new(); for font_path in font_paths { if font_path.ends_with(".ttf") { - let font_bytes = cx.asset_source().load(&font_path)?; + let font_bytes = cx + .asset_source() + .load(&font_path)? + .expect("Assets should never return None"); embedded_fonts.push(font_bytes); } } cx.text_system().add_fonts(embedded_fonts) } + + pub fn load_test_fonts(&self, cx: &AppContext) { + cx.text_system() + .add_fonts(vec![self + .load("fonts/plex-mono/ZedPlexMono-Regular.ttf") + .unwrap() + .unwrap()]) + .unwrap() + } } diff --git a/crates/assistant/Cargo.toml b/crates/assistant/Cargo.toml index 1023296255e54a..e3ddd4e2c7454c 100644 --- a/crates/assistant/Cargo.toml +++ b/crates/assistant/Cargo.toml @@ -12,41 +12,62 @@ workspace = true path = "src/assistant.rs" doctest = false +[features] +test-support = [ + "editor/test-support", + "language/test-support", + "project/test-support", + "text/test-support", +] + [dependencies] -anyhow.workspace = true anthropic = { workspace = true, features = ["schemars"] } +anyhow.workspace = true +assets.workspace = true assistant_slash_command.workspace = true +async-watch.workspace = true +breadcrumbs.workspace = true cargo_toml.workspace = true chrono.workspace = true client.workspace = true +clock.workspace = true collections.workspace = true command_palette_hooks.workspace = true editor.workspace = true -file_icons.workspace = true +feature_flags.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true +heed.workspace = true +html_to_markdown.workspace = true http.workspace = true +indexed_docs.workspace = true indoc.workspace = true language.workspace = true log.workspace = true menu.workspace = true multi_buffer.workspace = true +ollama = { workspace = true, features = ["schemars"] } open_ai = { workspace = true, features = ["schemars"] } ordered-float.workspace = true parking_lot.workspace = true +paths.workspace = true project.workspace = true regex.workspace = true rope.workspace = true schemars.workspace = true search.workspace = true +semantic_index.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true +similar.workspace = true smol.workspace = true -strsim = "0.11" +strum.workspace = true telemetry_events.workspace = true +terminal.workspace = true +terminal_view.workspace = true theme.workspace = true tiktoken-rs.workspace = true toml.workspace = true @@ -55,13 +76,15 @@ util.workspace = true uuid.workspace = true workspace.workspace = true picker.workspace = true -gray_matter = "0.2.7" +roxmltree = "0.20.0" [dev-dependencies] ctor.workspace = true editor = { workspace = true, features = ["test-support"] } env_logger.workspace = true +language = { workspace = true, features = ["test-support"] } log.workspace = true project = { workspace = true, features = ["test-support"] } rand.workspace = true +text = { workspace = true, features = ["test-support"] } unindent.workspace = true diff --git a/crates/assistant/src/ambient_context.rs b/crates/assistant/src/ambient_context.rs deleted file mode 100644 index cbb63b6044d487..00000000000000 --- a/crates/assistant/src/ambient_context.rs +++ /dev/null @@ -1,30 +0,0 @@ -mod current_project; -mod recent_buffers; - -pub use current_project::*; -pub use recent_buffers::*; - -#[derive(Default)] -pub struct AmbientContext { - pub recent_buffers: RecentBuffersContext, - pub current_project: CurrentProjectContext, -} - -impl AmbientContext { - pub fn snapshot(&self) -> AmbientContextSnapshot { - AmbientContextSnapshot { - recent_buffers: self.recent_buffers.snapshot.clone(), - } - } -} - -#[derive(Clone, Default, Debug)] -pub struct AmbientContextSnapshot { - pub recent_buffers: RecentBuffersSnapshot, -} - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] -pub enum ContextUpdated { - Updating, - Disabled, -} diff --git a/crates/assistant/src/ambient_context/current_project.rs b/crates/assistant/src/ambient_context/current_project.rs deleted file mode 100644 index f89a2a88562f5c..00000000000000 --- a/crates/assistant/src/ambient_context/current_project.rs +++ /dev/null @@ -1,180 +0,0 @@ -use std::fmt::Write; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{anyhow, Result}; -use fs::Fs; -use gpui::{AsyncAppContext, ModelContext, Task, WeakModel}; -use project::{Project, ProjectPath}; -use util::ResultExt; - -use crate::ambient_context::ContextUpdated; -use crate::assistant_panel::Conversation; -use crate::{LanguageModelRequestMessage, Role}; - -/// Ambient context about the current project. -pub struct CurrentProjectContext { - pub enabled: bool, - pub message: String, - pub pending_message: Option>, -} - -#[allow(clippy::derivable_impls)] -impl Default for CurrentProjectContext { - fn default() -> Self { - Self { - enabled: false, - message: String::new(), - pending_message: None, - } - } -} - -impl CurrentProjectContext { - /// Returns the [`CurrentProjectContext`] as a message to the language model. - pub fn to_message(&self) -> Option { - self.enabled - .then(|| LanguageModelRequestMessage { - role: Role::System, - content: self.message.clone(), - }) - .filter(|message| !message.content.is_empty()) - } - - /// Updates the [`CurrentProjectContext`] for the given [`Project`]. - pub fn update( - &mut self, - fs: Arc, - project: WeakModel, - cx: &mut ModelContext, - ) -> ContextUpdated { - if !self.enabled { - self.message.clear(); - self.pending_message = None; - cx.notify(); - return ContextUpdated::Disabled; - } - - self.pending_message = Some(cx.spawn(|conversation, mut cx| async move { - const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100); - cx.background_executor().timer(DEBOUNCE_TIMEOUT).await; - - let Some(path_to_cargo_toml) = Self::path_to_cargo_toml(project, &mut cx).log_err() - else { - return; - }; - - let Some(path_to_cargo_toml) = path_to_cargo_toml - .ok_or_else(|| anyhow!("no Cargo.toml")) - .log_err() - else { - return; - }; - - let message_task = cx - .background_executor() - .spawn(async move { Self::build_message(fs, &path_to_cargo_toml).await }); - - if let Some(message) = message_task.await.log_err() { - conversation - .update(&mut cx, |conversation, cx| { - conversation.ambient_context.current_project.message = message; - conversation.count_remaining_tokens(cx); - cx.notify(); - }) - .log_err(); - } - })); - - ContextUpdated::Updating - } - - async fn build_message(fs: Arc, path_to_cargo_toml: &Path) -> Result { - let buffer = fs.load(path_to_cargo_toml).await?; - let cargo_toml: cargo_toml::Manifest = toml::from_str(&buffer)?; - - let mut message = String::new(); - writeln!(message, "You are in a Rust project.")?; - - if let Some(workspace) = cargo_toml.workspace { - writeln!( - message, - "The project is a Cargo workspace with the following members:" - )?; - for member in workspace.members { - writeln!(message, "- {member}")?; - } - - if !workspace.default_members.is_empty() { - writeln!(message, "The default members are:")?; - for member in workspace.default_members { - writeln!(message, "- {member}")?; - } - } - - if !workspace.dependencies.is_empty() { - writeln!( - message, - "The following workspace dependencies are installed:" - )?; - for dependency in workspace.dependencies.keys() { - writeln!(message, "- {dependency}")?; - } - } - } else if let Some(package) = cargo_toml.package { - writeln!( - message, - "The project name is \"{name}\".", - name = package.name - )?; - - let description = package - .description - .as_ref() - .and_then(|description| description.get().ok().cloned()); - if let Some(description) = description.as_ref() { - writeln!(message, "It describes itself as \"{description}\".")?; - } - - if !cargo_toml.dependencies.is_empty() { - writeln!(message, "The following dependencies are installed:")?; - for dependency in cargo_toml.dependencies.keys() { - writeln!(message, "- {dependency}")?; - } - } - } - - Ok(message) - } - - fn path_to_cargo_toml( - project: WeakModel, - cx: &mut AsyncAppContext, - ) -> Result> { - cx.update(|cx| { - let worktree = project.update(cx, |project, _cx| { - project - .worktrees() - .next() - .ok_or_else(|| anyhow!("no worktree")) - })??; - - let path_to_cargo_toml = worktree.update(cx, |worktree, _cx| { - let cargo_toml = worktree.entry_for_path("Cargo.toml")?; - Some(ProjectPath { - worktree_id: worktree.id(), - path: cargo_toml.path.clone(), - }) - }); - let path_to_cargo_toml = path_to_cargo_toml.and_then(|path| { - project - .update(cx, |project, cx| project.absolute_path(&path, cx)) - .ok() - .flatten() - }); - - Ok(path_to_cargo_toml) - })? - } -} diff --git a/crates/assistant/src/ambient_context/recent_buffers.rs b/crates/assistant/src/ambient_context/recent_buffers.rs deleted file mode 100644 index 056fbd11834d27..00000000000000 --- a/crates/assistant/src/ambient_context/recent_buffers.rs +++ /dev/null @@ -1,147 +0,0 @@ -use crate::{assistant_panel::Conversation, LanguageModelRequestMessage, Role}; -use gpui::{ModelContext, Subscription, Task, WeakModel}; -use language::{Buffer, BufferSnapshot, Rope}; -use std::{fmt::Write, path::PathBuf, time::Duration}; - -use super::ContextUpdated; - -pub struct RecentBuffersContext { - pub enabled: bool, - pub buffers: Vec, - pub snapshot: RecentBuffersSnapshot, - pub pending_message: Option>, -} - -pub struct RecentBuffer { - pub buffer: WeakModel, - pub _subscription: Subscription, -} - -impl Default for RecentBuffersContext { - fn default() -> Self { - Self { - enabled: true, - buffers: Vec::new(), - snapshot: RecentBuffersSnapshot::default(), - pending_message: None, - } - } -} - -impl RecentBuffersContext { - pub fn update(&mut self, cx: &mut ModelContext) -> ContextUpdated { - let source_buffers = self - .buffers - .iter() - .filter_map(|recent| { - let (full_path, snapshot) = recent - .buffer - .read_with(cx, |buffer, cx| { - ( - buffer.file().map(|file| file.full_path(cx)), - buffer.snapshot(), - ) - }) - .ok()?; - Some(SourceBufferSnapshot { - full_path, - model: recent.buffer.clone(), - snapshot, - }) - }) - .collect::>(); - - if !self.enabled || source_buffers.is_empty() { - self.snapshot.message = Default::default(); - self.snapshot.source_buffers.clear(); - self.pending_message = None; - cx.notify(); - ContextUpdated::Disabled - } else { - self.pending_message = Some(cx.spawn(|this, mut cx| async move { - const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100); - cx.background_executor().timer(DEBOUNCE_TIMEOUT).await; - - let message = if source_buffers.is_empty() { - Rope::new() - } else { - cx.background_executor() - .spawn({ - let source_buffers = source_buffers.clone(); - async move { message_for_recent_buffers(source_buffers) } - }) - .await - }; - this.update(&mut cx, |this, cx| { - this.ambient_context.recent_buffers.snapshot.source_buffers = source_buffers; - this.ambient_context.recent_buffers.snapshot.message = message; - this.count_remaining_tokens(cx); - cx.notify(); - }) - .ok(); - })); - - ContextUpdated::Updating - } - } - - /// Returns the [`RecentBuffersContext`] as a message to the language model. - pub fn to_message(&self) -> Option { - self.enabled - .then(|| LanguageModelRequestMessage { - role: Role::System, - content: self.snapshot.message.to_string(), - }) - .filter(|message| !message.content.is_empty()) - } -} - -#[derive(Clone, Default, Debug)] -pub struct RecentBuffersSnapshot { - pub message: Rope, - pub source_buffers: Vec, -} - -#[derive(Clone)] -pub struct SourceBufferSnapshot { - pub full_path: Option, - pub model: WeakModel, - pub snapshot: BufferSnapshot, -} - -impl std::fmt::Debug for SourceBufferSnapshot { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SourceBufferSnapshot") - .field("full_path", &self.full_path) - .field("model (entity id)", &self.model.entity_id()) - .field("snapshot (text)", &self.snapshot.text()) - .finish() - } -} - -fn message_for_recent_buffers(buffers: Vec) -> Rope { - let mut message = String::new(); - writeln!( - message, - "The following is a list of recent buffers that the user has opened." - ) - .unwrap(); - - for buffer in buffers { - if let Some(path) = buffer.full_path { - writeln!(message, "```{}", path.display()).unwrap(); - } else { - writeln!(message, "```untitled").unwrap(); - } - - for chunk in buffer.snapshot.chunks(0..buffer.snapshot.len(), false) { - message.push_str(chunk.text); - } - if !message.ends_with('\n') { - message.push('\n'); - } - message.push_str("```\n"); - } - - Rope::from(message.as_str()) -} diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index da1be612c5455c..cf3726485ff490 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -1,29 +1,42 @@ -mod ambient_context; pub mod assistant_panel; pub mod assistant_settings; -mod codegen; mod completion_provider; -mod omit_ranges; +mod context; +pub mod context_store; +mod inline_assistant; +mod model_selector; +mod prompt_library; mod prompts; -mod saved_conversation; -mod search; mod slash_command; mod streaming_diff; +mod terminal_inline_assistant; -use ambient_context::AmbientContextSnapshot; -pub use assistant_panel::AssistantPanel; -use assistant_settings::{AnthropicModel, AssistantSettings, OpenAiModel, ZedDotDevModel}; +pub use assistant_panel::{AssistantPanel, AssistantPanelEvent}; +use assistant_settings::{AnthropicModel, AssistantSettings, CloudModel, OllamaModel, OpenAiModel}; +use assistant_slash_command::SlashCommandRegistry; use client::{proto, Client}; use command_palette_hooks::CommandPaletteFilter; -pub(crate) use completion_provider::*; -use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal}; -pub(crate) use saved_conversation::*; +pub use completion_provider::*; +pub use context::*; +pub use context_store::*; +use fs::Fs; +use gpui::{actions, impl_actions, AppContext, Global, SharedString, UpdateGlobal}; +use indexed_docs::IndexedDocsRegistry; +pub(crate) use inline_assistant::*; +pub(crate) use model_selector::*; +use semantic_index::{CloudEmbeddingProvider, SemanticIndex}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; +use slash_command::{ + active_command, default_command, diagnostics_command, docs_command, fetch_command, + file_command, now_command, project_command, prompt_command, search_command, symbols_command, + tabs_command, term_command, +}; use std::{ fmt::{self, Display}, sync::Arc, }; +pub(crate) use streaming_diff::*; actions!( assistant, @@ -32,20 +45,33 @@ actions!( Split, CycleMessageRole, QuoteSelection, + InsertIntoEditor, ToggleFocus, ResetKey, - InlineAssist, InsertActivePrompt, - ToggleIncludeConversation, - ToggleHistory, - ApplyEdit + DeployHistory, + DeployPromptLibrary, + ConfirmCommand, + ToggleModelSelector, + DebugEditSteps ] ); -#[derive( - Copy, Clone, Debug, Default, Eq, PartialEq, PartialOrd, Ord, Hash, Serialize, Deserialize, -)] -struct MessageId(usize); +#[derive(Clone, Default, Deserialize, PartialEq)] +pub struct InlineAssist { + prompt: Option, +} + +impl_actions!(assistant, [InlineAssist]); + +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct MessageId(clock::Lamport); + +impl MessageId { + pub fn as_u64(self) -> u64 { + self.0.as_u64() + } +} #[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] #[serde(rename_all = "lowercase")] @@ -56,8 +82,26 @@ pub enum Role { } impl Role { - pub fn cycle(&mut self) { - *self = match self { + pub fn from_proto(role: i32) -> Role { + match proto::LanguageModelRole::from_i32(role) { + Some(proto::LanguageModelRole::LanguageModelUser) => Role::User, + Some(proto::LanguageModelRole::LanguageModelAssistant) => Role::Assistant, + Some(proto::LanguageModelRole::LanguageModelSystem) => Role::System, + Some(proto::LanguageModelRole::LanguageModelTool) => Role::System, + None => Role::User, + } + } + + pub fn to_proto(&self) -> proto::LanguageModelRole { + match self { + Role::User => proto::LanguageModelRole::LanguageModelUser, + Role::Assistant => proto::LanguageModelRole::LanguageModelAssistant, + Role::System => proto::LanguageModelRole::LanguageModelSystem, + } + } + + pub fn cycle(self) -> Role { + match self { Role::User => Role::Assistant, Role::Assistant => Role::System, Role::System => Role::User, @@ -77,14 +121,15 @@ impl Display for Role { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub enum LanguageModel { - ZedDotDev(ZedDotDevModel), + Cloud(CloudModel), OpenAi(OpenAiModel), Anthropic(AnthropicModel), + Ollama(OllamaModel), } impl Default for LanguageModel { fn default() -> Self { - LanguageModel::ZedDotDev(ZedDotDevModel::default()) + LanguageModel::Cloud(CloudModel::default()) } } @@ -93,7 +138,8 @@ impl LanguageModel { match self { LanguageModel::OpenAi(model) => format!("openai/{}", model.id()), LanguageModel::Anthropic(model) => format!("anthropic/{}", model.id()), - LanguageModel::ZedDotDev(model) => format!("zed.dev/{}", model.id()), + LanguageModel::Cloud(model) => format!("zed.dev/{}", model.id()), + LanguageModel::Ollama(model) => format!("ollama/{}", model.id()), } } @@ -101,7 +147,8 @@ impl LanguageModel { match self { LanguageModel::OpenAi(model) => model.display_name().into(), LanguageModel::Anthropic(model) => model.display_name().into(), - LanguageModel::ZedDotDev(model) => model.display_name().into(), + LanguageModel::Cloud(model) => model.display_name().into(), + LanguageModel::Ollama(model) => model.display_name().into(), } } @@ -109,7 +156,8 @@ impl LanguageModel { match self { LanguageModel::OpenAi(model) => model.max_token_count(), LanguageModel::Anthropic(model) => model.max_token_count(), - LanguageModel::ZedDotDev(model) => model.max_token_count(), + LanguageModel::Cloud(model) => model.max_token_count(), + LanguageModel::Ollama(model) => model.max_token_count(), } } @@ -117,7 +165,8 @@ impl LanguageModel { match self { LanguageModel::OpenAi(model) => model.id(), LanguageModel::Anthropic(model) => model.id(), - LanguageModel::ZedDotDev(model) => model.id(), + LanguageModel::Cloud(model) => model.id(), + LanguageModel::Ollama(model) => model.id(), } } } @@ -131,11 +180,7 @@ pub struct LanguageModelRequestMessage { impl LanguageModelRequestMessage { pub fn to_proto(&self) -> proto::LanguageModelRequestMessage { proto::LanguageModelRequestMessage { - role: match self.role { - Role::User => proto::LanguageModelRole::LanguageModelUser, - Role::Assistant => proto::LanguageModelRole::LanguageModelAssistant, - Role::System => proto::LanguageModelRole::LanguageModelSystem, - } as i32, + role: self.role.to_proto() as i32, content: self.content.clone(), tool_calls: Vec::new(), tool_call_id: None, @@ -143,7 +188,7 @@ impl LanguageModelRequestMessage { } } -#[derive(Debug, Default, Serialize)] +#[derive(Debug, Default, Serialize, Deserialize)] pub struct LanguageModelRequest { pub model: LanguageModel, pub messages: Vec, @@ -162,6 +207,24 @@ impl LanguageModelRequest { tools: Vec::new(), } } + + /// Before we send the request to the server, we can perform fixups on it appropriate to the model. + pub fn preprocess(&mut self) { + match &self.model { + LanguageModel::OpenAi(_) => {} + LanguageModel::Anthropic(_) => {} + LanguageModel::Ollama(_) => {} + LanguageModel::Cloud(model) => match model { + CloudModel::Claude3Opus + | CloudModel::Claude3Sonnet + | CloudModel::Claude3Haiku + | CloudModel::Claude3_5Sonnet => { + preprocess_anthropic_request(self); + } + _ => {} + }, + } + } } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] @@ -184,22 +247,48 @@ pub struct LanguageModelChoiceDelta { pub finish_reason: Option, } -#[derive(Clone, Debug, Serialize, Deserialize)] -struct MessageMetadata { - role: Role, - status: MessageStatus, - // TODO: Delete this - #[serde(skip)] - ambient_context: AmbientContextSnapshot, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -enum MessageStatus { +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum MessageStatus { Pending, Done, Error(SharedString), } +impl MessageStatus { + pub fn from_proto(status: proto::ContextMessageStatus) -> MessageStatus { + match status.variant { + Some(proto::context_message_status::Variant::Pending(_)) => MessageStatus::Pending, + Some(proto::context_message_status::Variant::Done(_)) => MessageStatus::Done, + Some(proto::context_message_status::Variant::Error(error)) => { + MessageStatus::Error(error.message.into()) + } + None => MessageStatus::Pending, + } + } + + pub fn to_proto(&self) -> proto::ContextMessageStatus { + match self { + MessageStatus::Pending => proto::ContextMessageStatus { + variant: Some(proto::context_message_status::Variant::Pending( + proto::context_message_status::Pending {}, + )), + }, + MessageStatus::Done => proto::ContextMessageStatus { + variant: Some(proto::context_message_status::Variant::Done( + proto::context_message_status::Done {}, + )), + }, + MessageStatus::Error(message) => proto::ContextMessageStatus { + variant: Some(proto::context_message_status::Variant::Error( + proto::context_message_status::Error { + message: message.to_string(), + }, + )), + }, + } + } +} + /// The state pertaining to the Assistant. #[derive(Default)] struct Assistant { @@ -233,12 +322,34 @@ impl Assistant { } } -pub fn init(client: Arc, cx: &mut AppContext) { +pub fn init(fs: Arc, client: Arc, cx: &mut AppContext) { cx.set_global(Assistant::default()); AssistantSettings::register(cx); - completion_provider::init(client, cx); + + cx.spawn(|mut cx| { + let client = client.clone(); + async move { + let embedding_provider = CloudEmbeddingProvider::new(client.clone()); + let semantic_index = SemanticIndex::new( + paths::embeddings_dir().join("semantic-index-db.0.mdb"), + Arc::new(embedding_provider), + &mut cx, + ) + .await?; + cx.update(|cx| cx.set_global(semantic_index)) + } + }) + .detach(); + + context_store::init(&client); + prompt_library::init(cx); + completion_provider::init(client.clone(), cx); assistant_slash_command::init(cx); + register_slash_commands(cx); assistant_panel::init(cx); + inline_assistant::init(fs.clone(), client.telemetry().clone(), cx); + terminal_inline_assistant::init(fs.clone(), client.telemetry().clone(), cx); + IndexedDocsRegistry::init_global(cx); CommandPaletteFilter::update_global(cx, |filter, _cx| { filter.hide_namespace(Assistant::NAMESPACE); @@ -251,13 +362,47 @@ pub fn init(client: Arc, cx: &mut AppContext) { cx.observe_global::(|cx| { Assistant::update_global(cx, |assistant, cx| { let settings = AssistantSettings::get_global(cx); - assistant.set_enabled(settings.enabled, cx); }); }) .detach(); } +fn register_slash_commands(cx: &mut AppContext) { + let slash_command_registry = SlashCommandRegistry::global(cx); + slash_command_registry.register_command(file_command::FileSlashCommand, true); + slash_command_registry.register_command(active_command::ActiveSlashCommand, true); + slash_command_registry.register_command(symbols_command::OutlineSlashCommand, true); + slash_command_registry.register_command(tabs_command::TabsSlashCommand, true); + slash_command_registry.register_command(project_command::ProjectSlashCommand, true); + slash_command_registry.register_command(search_command::SearchSlashCommand, true); + slash_command_registry.register_command(prompt_command::PromptSlashCommand, true); + slash_command_registry.register_command(default_command::DefaultSlashCommand, true); + slash_command_registry.register_command(term_command::TermSlashCommand, true); + slash_command_registry.register_command(now_command::NowSlashCommand, true); + slash_command_registry.register_command(diagnostics_command::DiagnosticsSlashCommand, true); + slash_command_registry.register_command(docs_command::DocsSlashCommand, true); + slash_command_registry.register_command(fetch_command::FetchSlashCommand, false); +} + +pub fn humanize_token_count(count: usize) -> String { + match count { + 0..=999 => count.to_string(), + 1000..=9999 => { + let thousands = count / 1000; + let hundreds = (count % 1000 + 50) / 100; + if hundreds == 0 { + format!("{}k", thousands) + } else if hundreds == 10 { + format!("{}k", thousands + 1) + } else { + format!("{}.{}k", thousands, hundreds) + } + } + _ => format!("{}k", (count + 500) / 1000), + } +} + #[cfg(test)] #[ctor::ctor] fn init_logger() { diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 41fc176f28b11a..92bd4b9cbe9832 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1,86 +1,82 @@ -use crate::ambient_context::{AmbientContext, ContextUpdated, RecentBuffer}; -use crate::prompts::prompt_library::PromptLibrary; -use crate::prompts::prompt_manager::PromptManager; use crate::{ - ambient_context::*, - assistant_settings::{AssistantDockPosition, AssistantSettings, ZedDotDevModel}, - codegen::{self, Codegen, CodegenKind}, - omit_ranges::text_in_range_omitting_ranges, - prompts::prompt::generate_content_prompt, - search::*, + assistant_settings::{AssistantDockPosition, AssistantSettings}, + humanize_token_count, + prompt_library::open_prompt_library, slash_command::{ - current_file_command, file_command, prompt_command, SlashCommandCleanup, - SlashCommandCompletionProvider, SlashCommandLine, SlashCommandRegistry, + default_command::DefaultSlashCommand, + docs_command::{DocsSlashCommand, DocsSlashCommandArgs}, + SlashCommandCompletionProvider, SlashCommandRegistry, }, - ApplyEdit, Assist, CompletionProvider, CycleMessageRole, InlineAssist, LanguageModel, - LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata, MessageStatus, - QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata, SavedMessage, - Split, ToggleFocus, ToggleHistory, ToggleIncludeConversation, + terminal_inline_assistant::TerminalInlineAssistant, + Assist, CompletionProvider, ConfirmCommand, Context, ContextEvent, ContextId, ContextStore, + CycleMessageRole, DebugEditSteps, DeployHistory, DeployPromptLibrary, EditStep, + EditStepOperations, EditSuggestionGroup, InlineAssist, InlineAssistId, InlineAssistant, + InsertIntoEditor, MessageStatus, ModelSelector, PendingSlashCommand, PendingSlashCommandStatus, + QuoteSelection, RemoteContextMetadata, ResetKey, Role, SavedContextMetadata, Split, + ToggleFocus, ToggleModelSelector, }; use anyhow::{anyhow, Result}; -use client::telemetry::Telemetry; -use collections::{hash_map, HashMap, HashSet, VecDeque}; -use editor::FoldPlaceholder; +use assistant_slash_command::{SlashCommand, SlashCommandOutputSection}; +use breadcrumbs::Breadcrumbs; +use client::proto; +use collections::{BTreeSet, HashMap, HashSet}; use editor::{ - actions::{FoldAt, MoveDown, MoveUp}, + actions::{FoldAt, MoveToEndOfLine, Newline, ShowCompletions, UnfoldAt}, display_map::{ - BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, Flap, FlapId, + BlockDisposition, BlockProperties, BlockStyle, Crease, CustomBlockId, RenderBlock, ToDisplayPoint, }, - scroll::{Autoscroll, AutoscrollStrategy}, - Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBufferSnapshot, RowExt, - ToOffset as _, ToPoint, + scroll::{Autoscroll, AutoscrollStrategy, ScrollAnchor}, + Anchor, Editor, EditorEvent, ExcerptRange, MultiBuffer, RowExt, ToOffset as _, ToPoint, }; -use file_icons::FileIcons; +use editor::{display_map::CreaseId, FoldPlaceholder}; use fs::Fs; -use futures::StreamExt; use gpui::{ - canvas, div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext, - AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Empty, Entity, - EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, HighlightStyle, - InteractiveElement, IntoElement, Model, ModelContext, ParentElement, Pixels, Render, - SharedString, StatefulInteractiveElement, Styled, Subscription, Task, TextStyle, - UniformListScrollHandle, View, ViewContext, VisualContext, WeakModel, WeakView, WhiteSpace, - WindowContext, + div, percentage, point, Action, Animation, AnimationExt, AnyElement, AnyView, AppContext, + AsyncWindowContext, ClipboardItem, Context as _, DismissEvent, Empty, Entity, EventEmitter, + FocusHandle, FocusableView, InteractiveElement, IntoElement, Model, ParentElement, Pixels, + Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Transformation, + UpdateGlobal, View, ViewContext, VisualContext, WeakView, WindowContext, }; -use language::LspAdapterDelegate; +use indexed_docs::IndexedDocsStore; use language::{ - language_settings::SoftWrap, AutoindentMode, Buffer, BufferSnapshot, LanguageRegistry, - OffsetRangeExt as _, Point, ToOffset as _, ToPoint as _, + language_settings::SoftWrap, Buffer, Capability, LanguageRegistry, LspAdapterDelegate, Point, + ToOffset, }; use multi_buffer::MultiBufferRow; -use parking_lot::Mutex; -use project::{Project, ProjectLspAdapterDelegate, ProjectTransaction}; +use picker::{Picker, PickerDelegate}; +use project::{Project, ProjectLspAdapterDelegate}; use search::{buffer_search::DivRegistrar, BufferSearchBar}; use settings::Settings; use std::{ cmp::{self, Ordering}, fmt::Write, - iter, mem, ops::Range, path::PathBuf, sync::Arc, - time::{Duration, Instant}, + time::Duration, }; -use telemetry_events::AssistantKind; +use terminal_view::{terminal_panel::TerminalPanel, TerminalView}; use theme::ThemeSettings; use ui::{ - popover_menu, prelude::*, ButtonLike, ContextMenu, ElevationIndex, KeyBinding, Tab, TabBar, - Tooltip, + prelude::*, + utils::{format_distance_from_now, DateTimeType}, + Avatar, AvatarShape, ButtonLike, ContextMenu, Disclosure, ElevationIndex, KeyBinding, ListItem, + ListItemSpacing, PopoverMenu, PopoverMenuHandle, Tooltip, }; -use util::{paths::CONVERSATIONS_DIR, post_inc, ResultExt, TryFutureExt}; -use uuid::Uuid; +use util::ResultExt; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, - searchable::Direction, - Event as WorkspaceEvent, Save, Toast, ToggleZoom, Toolbar, Workspace, + item::{self, BreadcrumbText, FollowableItem, Item, ItemHandle}, + notifications::NotifyTaskExt, + pane::{self, SaveIntent}, + searchable::{SearchEvent, SearchableItem}, + Pane, Save, ToggleZoom, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, }; -use workspace::{notifications::NotificationId, NewFile}; - -const MAX_RECENT_BUFFERS: usize = 3; -const SLASH_COMMAND_DEBOUNCE: Duration = Duration::from_millis(200); +use workspace::{searchable::SearchableItemHandle, NewFile}; pub fn init(cx: &mut AppContext) { + workspace::FollowableViewRegistry::register::(cx); cx.observe_new_views( |workspace: &mut Workspace, _cx: &mut ViewContext| { workspace @@ -93,180 +89,336 @@ pub fn init(cx: &mut AppContext) { workspace.toggle_panel_focus::(cx); }) .register_action(AssistantPanel::inline_assist) - .register_action(AssistantPanel::cancel_last_inline_assist) - // .register_action(ConversationEditor::insert_active_prompt) - .register_action(ConversationEditor::quote_selection); + .register_action(ContextEditor::quote_selection) + .register_action(ContextEditor::insert_selection); }, ) .detach(); } +pub enum AssistantPanelEvent { + ContextEdited, +} + pub struct AssistantPanel { + pane: View, workspace: WeakView, width: Option, height: Option, - active_conversation_editor: Option, - show_saved_conversations: bool, - saved_conversations: Vec, - saved_conversations_scroll_handle: UniformListScrollHandle, - zoomed: bool, - focus_handle: FocusHandle, - toolbar: View, + project: Model, + context_store: Model, languages: Arc, - slash_commands: Arc, - prompt_library: Arc, fs: Arc, - telemetry: Arc, - _subscriptions: Vec, - next_inline_assist_id: usize, - pending_inline_assists: HashMap, - pending_inline_assist_ids_by_editor: HashMap, Vec>, - include_conversation_in_next_inline_assist: bool, - inline_prompt_history: VecDeque, - _watch_saved_conversations: Task>, - model: LanguageModel, + subscriptions: Vec, authentication_prompt: Option, + model_selector_menu_handle: PopoverMenuHandle, } -struct ActiveConversationEditor { - editor: View, - _subscriptions: Vec, +#[derive(Clone)] +enum ContextMetadata { + Remote(RemoteContextMetadata), + Saved(SavedContextMetadata), } -impl AssistantPanel { - const INLINE_PROMPT_HISTORY_MAX_LEN: usize = 20; +struct SavedContextPickerDelegate { + store: Model, + project: Model, + matches: Vec, + selected_index: usize, +} - pub fn load( - workspace: WeakView, - cx: AsyncWindowContext, - ) -> Task>> { - cx.spawn(|mut cx| async move { - let fs = workspace.update(&mut cx, |workspace, _| workspace.app_state().fs.clone())?; - let saved_conversations = SavedConversationMetadata::list(fs.clone()) - .await - .log_err() - .unwrap_or_default(); - - let prompt_library = Arc::new( - PromptLibrary::load(fs.clone()) - .await - .log_err() - .unwrap_or_default(), - ); +enum SavedContextPickerEvent { + Confirmed(ContextMetadata), +} - // TODO: deserialize state. - let workspace_handle = workspace.clone(); - workspace.update(&mut cx, |workspace, cx| { - cx.new_view::(|cx| { - const CONVERSATION_WATCH_DURATION: Duration = Duration::from_millis(100); - let _watch_saved_conversations = cx.spawn(move |this, mut cx| async move { - let mut events = fs - .watch(&CONVERSATIONS_DIR, CONVERSATION_WATCH_DURATION) - .await; - while events.next().await.is_some() { - let saved_conversations = SavedConversationMetadata::list(fs.clone()) - .await - .log_err() - .unwrap_or_default(); - this.update(&mut cx, |this, cx| { - this.saved_conversations = saved_conversations; - cx.notify(); - }) - .ok(); - } +enum InlineAssistTarget { + Editor(View, bool), + Terminal(View), +} - anyhow::Ok(()) - }); +impl EventEmitter for Picker {} - let toolbar = cx.new_view(|cx| { - let mut toolbar = Toolbar::new(); - toolbar.set_can_navigate(false, cx); - toolbar.add_item(cx.new_view(BufferSearchBar::new), cx); - toolbar - }); +impl SavedContextPickerDelegate { + fn new(project: Model, store: Model) -> Self { + Self { + project, + store, + matches: Vec::new(), + selected_index: 0, + } + } +} - let focus_handle = cx.focus_handle(); - let subscriptions = vec![ - cx.on_focus_in(&focus_handle, Self::focus_in), - cx.on_focus_out(&focus_handle, Self::focus_out), - cx.observe_global::({ - let mut prev_settings_version = - CompletionProvider::global(cx).settings_version(); - move |this, cx| { - this.completion_provider_changed(prev_settings_version, cx); - prev_settings_version = - CompletionProvider::global(cx).settings_version(); - } - }), - ]; - let model = CompletionProvider::global(cx).default_model(); +impl PickerDelegate for SavedContextPickerDelegate { + type ListItem = ListItem; - cx.observe_global::(|_, cx| { - cx.notify(); - }) - .detach(); + fn match_count(&self) -> usize { + self.matches.len() + } - let slash_command_registry = SlashCommandRegistry::global(cx); - let window = cx.window_handle().downcast::(); + fn selected_index(&self) -> usize { + self.selected_index + } - slash_command_registry.register_command(file_command::FileSlashCommand::new( - workspace.project().clone(), - )); - slash_command_registry.register_command( - prompt_command::PromptSlashCommand::new(prompt_library.clone()), - ); - if let Some(window) = window { - slash_command_registry.register_command( - current_file_command::CurrentFileSlashCommand::new(window), - ); - } + fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext>) { + self.selected_index = ix; + } - Self { - workspace: workspace_handle, - active_conversation_editor: None, - show_saved_conversations: false, - saved_conversations, - saved_conversations_scroll_handle: Default::default(), - zoomed: false, - focus_handle, - toolbar, - languages: workspace.app_state().languages.clone(), - slash_commands: slash_command_registry, - prompt_library, - fs: workspace.app_state().fs.clone(), - telemetry: workspace.client().telemetry().clone(), - width: None, - height: None, - _subscriptions: subscriptions, - next_inline_assist_id: 0, - pending_inline_assists: Default::default(), - pending_inline_assist_ids_by_editor: Default::default(), - include_conversation_in_next_inline_assist: false, - inline_prompt_history: Default::default(), - _watch_saved_conversations, - model, - authentication_prompt: None, - } - }) + fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { + "Search...".into() + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { + let search = self.store.read(cx).search(query, cx); + cx.spawn(|this, mut cx| async move { + let matches = search.await; + this.update(&mut cx, |this, cx| { + let host_contexts = this.delegate.store.read(cx).host_contexts(); + this.delegate.matches = host_contexts + .iter() + .cloned() + .map(ContextMetadata::Remote) + .chain(matches.into_iter().map(ContextMetadata::Saved)) + .collect(); + this.delegate.selected_index = 0; + cx.notify(); }) + .ok(); }) } - fn focus_in(&mut self, cx: &mut ViewContext) { - self.toolbar - .update(cx, |toolbar, cx| toolbar.focus_changed(true, cx)); - cx.notify(); - if self.focus_handle.is_focused(cx) { - if let Some(editor) = self.active_conversation_editor() { - cx.focus_view(editor); + fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { + if let Some(metadata) = self.matches.get(self.selected_index) { + cx.emit(SavedContextPickerEvent::Confirmed(metadata.clone())); + } + } + + fn dismissed(&mut self, _cx: &mut ViewContext>) {} + + fn render_match( + &self, + ix: usize, + selected: bool, + cx: &mut ViewContext>, + ) -> Option { + let context = self.matches.get(ix)?; + let item = match context { + ContextMetadata::Remote(context) => { + let host_user = self.project.read(cx).host().and_then(|collaborator| { + self.project + .read(cx) + .user_store() + .read(cx) + .get_cached_user(collaborator.user_id) + }); + div() + .flex() + .w_full() + .justify_between() + .gap_2() + .child( + h_flex().flex_1().overflow_x_hidden().child( + Label::new(context.summary.clone().unwrap_or("New Context".into())) + .size(LabelSize::Small), + ), + ) + .child( + h_flex() + .gap_2() + .children(if let Some(host_user) = host_user { + vec![ + Avatar::new(host_user.avatar_uri.clone()) + .shape(AvatarShape::Circle) + .into_any_element(), + Label::new(format!("Shared by @{}", host_user.github_login)) + .color(Color::Muted) + .size(LabelSize::Small) + .into_any_element(), + ] + } else { + vec![Label::new("Shared by host") + .color(Color::Muted) + .size(LabelSize::Small) + .into_any_element()] + }), + ) } + ContextMetadata::Saved(context) => div() + .flex() + .w_full() + .justify_between() + .gap_2() + .child( + h_flex() + .flex_1() + .child(Label::new(context.title.clone()).size(LabelSize::Small)) + .overflow_x_hidden(), + ) + .child( + Label::new(format_distance_from_now( + DateTimeType::Local(context.mtime), + false, + true, + true, + )) + .color(Color::Muted) + .size(LabelSize::Small), + ), + }; + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .selected(selected) + .child(item), + ) + } +} + +impl AssistantPanel { + pub fn load( + workspace: WeakView, + cx: AsyncWindowContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + let context_store = workspace + .update(&mut cx, |workspace, cx| { + ContextStore::new(workspace.project().clone(), cx) + })? + .await?; + workspace.update(&mut cx, |workspace, cx| { + // TODO: deserialize state. + cx.new_view(|cx| Self::new(workspace, context_store, cx)) + }) + }) + } + + fn new( + workspace: &Workspace, + context_store: Model, + cx: &mut ViewContext, + ) -> Self { + let model_selector_menu_handle = PopoverMenuHandle::default(); + let pane = cx.new_view(|cx| { + let mut pane = Pane::new( + workspace.weak_handle(), + workspace.project().clone(), + Default::default(), + None, + NewFile.boxed_clone(), + cx, + ); + pane.set_can_split(false, cx); + pane.set_can_navigate(true, cx); + pane.display_nav_history_buttons(None); + pane.set_should_display_tab_bar(|_| true); + pane.set_render_tab_bar_buttons(cx, move |pane, cx| { + h_flex() + .gap(Spacing::Small.rems(cx)) + .child( + IconButton::new("menu", IconName::Menu) + .icon_size(IconSize::Small) + .on_click(cx.listener(|pane, _, cx| { + let zoom_label = if pane.is_zoomed() { + "Zoom Out" + } else { + "Zoom In" + }; + let menu = ContextMenu::build(cx, |menu, cx| { + menu.context(pane.focus_handle(cx)) + .action("New Context", Box::new(NewFile)) + .action("History", Box::new(DeployHistory)) + .action("Prompt Library", Box::new(DeployPromptLibrary)) + .action(zoom_label, Box::new(ToggleZoom)) + }); + cx.subscribe(&menu, |pane, _, _: &DismissEvent, _| { + pane.new_item_menu = None; + }) + .detach(); + pane.new_item_menu = Some(menu); + })), + ) + .when_some(pane.new_item_menu.as_ref(), |el, new_item_menu| { + el.child(Pane::render_menu_overlay(new_item_menu)) + }) + .into_any_element() + }); + pane.toolbar().update(cx, |toolbar, cx| { + toolbar.add_item(cx.new_view(|_| Breadcrumbs::new()), cx); + toolbar.add_item( + cx.new_view(|_| { + ContextEditorToolbarItem::new(workspace, model_selector_menu_handle.clone()) + }), + cx, + ); + toolbar.add_item(cx.new_view(BufferSearchBar::new), cx) + }); + pane + }); + + let subscriptions = vec![ + cx.observe(&pane, |_, _, cx| cx.notify()), + cx.subscribe(&pane, Self::handle_pane_event), + cx.observe_global::({ + let mut prev_settings_version = CompletionProvider::global(cx).settings_version(); + move |this, cx| { + this.completion_provider_changed(prev_settings_version, cx); + prev_settings_version = CompletionProvider::global(cx).settings_version(); + } + }), + ]; + + Self { + pane, + workspace: workspace.weak_handle(), + width: None, + height: None, + project: workspace.project().clone(), + context_store, + languages: workspace.app_state().languages.clone(), + fs: workspace.app_state().fs.clone(), + subscriptions, + authentication_prompt: None, + model_selector_menu_handle, } } - fn focus_out(&mut self, cx: &mut ViewContext) { - self.toolbar - .update(cx, |toolbar, cx| toolbar.focus_changed(false, cx)); - cx.notify(); + fn handle_pane_event( + &mut self, + pane: View, + event: &pane::Event, + cx: &mut ViewContext, + ) { + match event { + pane::Event::Remove => cx.emit(PanelEvent::Close), + pane::Event::ZoomIn => cx.emit(PanelEvent::ZoomIn), + pane::Event::ZoomOut => cx.emit(PanelEvent::ZoomOut), + + pane::Event::AddItem { item } => { + self.workspace + .update(cx, |workspace, cx| { + item.added_to_pane(workspace, self.pane.clone(), cx) + }) + .ok(); + } + + pane::Event::ActivateItem { local } => { + if *local { + self.workspace + .update(cx, |workspace, cx| { + workspace.unfollow_in_pane(&pane, cx); + }) + .ok(); + } + cx.emit(AssistantPanelEvent::ContextEdited); + } + + pane::Event::RemoveItem { .. } => { + cx.emit(AssistantPanelEvent::ContextEdited); + } + + _ => {} + } } fn completion_provider_changed( @@ -277,12 +429,18 @@ impl AssistantPanel { if self.is_authenticated(cx) { self.authentication_prompt = None; - let model = CompletionProvider::global(cx).default_model(); - self.set_model(model, cx); + if let Some(editor) = self.active_context_editor(cx) { + editor.update(cx, |active_context, cx| { + active_context + .context + .update(cx, |context, cx| context.completion_provider_changed(cx)) + }) + } - if self.active_conversation_editor().is_none() { - self.new_conversation(cx); + if self.active_context_editor(cx).is_none() { + self.new_context(cx); } + cx.notify(); } else if self.authentication_prompt.is_none() || prev_settings_version != CompletionProvider::global(cx).settings_version() { @@ -290,12 +448,13 @@ impl AssistantPanel { Some(cx.update_global::(|provider, cx| { provider.authentication_prompt(cx) })); + cx.notify(); } } pub fn inline_assist( workspace: &mut Workspace, - _: &InlineAssist, + action: &InlineAssist, cx: &mut ViewContext, ) { let settings = AssistantSettings::get_global(cx); @@ -303,58 +462,78 @@ impl AssistantPanel { return; } - let Some(assistant) = workspace.panel::(cx) else { + let Some(assistant_panel) = workspace.panel::(cx) else { return; }; - let conversation_editor = - assistant - .read(cx) - .active_conversation_editor() - .and_then(|editor| { - let editor = &editor.read(cx).editor; - if editor.read(cx).is_focused(cx) { - Some(editor.clone()) - } else { - None - } - }); - - let show_include_conversation; - let active_editor; - if let Some(conversation_editor) = conversation_editor { - active_editor = conversation_editor; - show_include_conversation = false; - } else if let Some(workspace_editor) = workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - { - active_editor = workspace_editor; - show_include_conversation = true; - } else { + let Some(inline_assist_target) = + Self::resolve_inline_assist_target(workspace, &assistant_panel, cx) + else { return; }; - let project = workspace.project().clone(); - if assistant.update(cx, |assistant, cx| assistant.is_authenticated(cx)) { - assistant.update(cx, |assistant, cx| { - assistant.new_inline_assist(&active_editor, &project, show_include_conversation, cx) - }); + let initial_prompt = action.prompt.clone(); + if assistant_panel.update(cx, |assistant, cx| assistant.is_authenticated(cx)) { + match inline_assist_target { + InlineAssistTarget::Editor(active_editor, include_context) => { + InlineAssistant::update_global(cx, |assistant, cx| { + assistant.assist( + &active_editor, + Some(cx.view().downgrade()), + include_context.then_some(&assistant_panel), + initial_prompt, + cx, + ) + }) + } + InlineAssistTarget::Terminal(active_terminal) => { + TerminalInlineAssistant::update_global(cx, |assistant, cx| { + assistant.assist( + &active_terminal, + Some(cx.view().downgrade()), + Some(&assistant_panel), + initial_prompt, + cx, + ) + }) + } + } } else { - let assistant = assistant.downgrade(); + let assistant_panel = assistant_panel.downgrade(); cx.spawn(|workspace, mut cx| async move { - assistant + assistant_panel .update(&mut cx, |assistant, cx| assistant.authenticate(cx))? .await?; - if assistant.update(&mut cx, |assistant, cx| assistant.is_authenticated(cx))? { - assistant.update(&mut cx, |assistant, cx| { - assistant.new_inline_assist( - &active_editor, - &project, - show_include_conversation, - cx, - ) - })?; + if assistant_panel.update(&mut cx, |panel, cx| panel.is_authenticated(cx))? { + cx.update(|cx| match inline_assist_target { + InlineAssistTarget::Editor(active_editor, include_context) => { + let assistant_panel = if include_context { + assistant_panel.upgrade() + } else { + None + }; + InlineAssistant::update_global(cx, |assistant, cx| { + assistant.assist( + &active_editor, + Some(workspace), + assistant_panel.as_ref(), + initial_prompt, + cx, + ) + }) + } + InlineAssistTarget::Terminal(active_terminal) => { + TerminalInlineAssistant::update_global(cx, |assistant, cx| { + assistant.assist( + &active_terminal, + Some(workspace), + assistant_panel.upgrade().as_ref(), + initial_prompt, + cx, + ) + }) + } + })? } else { workspace.update(&mut cx, |workspace, cx| { workspace.focus_panel::(cx) @@ -367,2357 +546,427 @@ impl AssistantPanel { } } - fn new_inline_assist( - &mut self, - editor: &View, - project: &Model, - show_include_conversation: bool, - cx: &mut ViewContext, - ) { - let selection = editor.read(cx).selections.newest_anchor().clone(); - if selection.start.excerpt_id != selection.end.excerpt_id { - return; - } - let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); - - // Extend the selection to the start and the end of the line. - let mut point_selection = selection.map(|selection| selection.to_point(&snapshot)); - if point_selection.end > point_selection.start { - point_selection.start.column = 0; - // If the selection ends at the start of the line, we don't want to include it. - if point_selection.end.column == 0 { - point_selection.end.row -= 1; + fn resolve_inline_assist_target( + workspace: &mut Workspace, + assistant_panel: &View, + cx: &mut WindowContext, + ) -> Option { + if let Some(terminal_panel) = workspace.panel::(cx) { + if terminal_panel + .read(cx) + .focus_handle(cx) + .contains_focused(cx) + { + use feature_flags::FeatureFlagAppExt; + if !cx.has_flag::() { + return None; + } + + if let Some(terminal_view) = terminal_panel + .read(cx) + .pane() + .read(cx) + .active_item() + .and_then(|t| t.downcast::()) + { + return Some(InlineAssistTarget::Terminal(terminal_view)); + } } - point_selection.end.column = snapshot.line_len(MultiBufferRow(point_selection.end.row)); } + let context_editor = + assistant_panel + .read(cx) + .active_context_editor(cx) + .and_then(|editor| { + let editor = &editor.read(cx).editor; + if editor.read(cx).is_focused(cx) { + Some(editor.clone()) + } else { + None + } + }); - let codegen_kind = if point_selection.start == point_selection.end { - CodegenKind::Generate { - position: snapshot.anchor_after(point_selection.start), - } + if let Some(context_editor) = context_editor { + Some(InlineAssistTarget::Editor(context_editor, false)) + } else if let Some(workspace_editor) = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + { + Some(InlineAssistTarget::Editor(workspace_editor, true)) } else { - CodegenKind::Transform { - range: snapshot.anchor_before(point_selection.start) - ..snapshot.anchor_after(point_selection.end), - } - }; - - let inline_assist_id = post_inc(&mut self.next_inline_assist_id); - let telemetry = self.telemetry.clone(); + None + } + } - let codegen = cx.new_model(|cx| { - Codegen::new( - editor.read(cx).buffer().clone(), - codegen_kind, - Some(telemetry), - cx, - ) + fn new_context(&mut self, cx: &mut ViewContext) -> Option> { + let context = self.context_store.update(cx, |store, cx| store.create(cx)); + let workspace = self.workspace.upgrade()?; + let lsp_adapter_delegate = workspace.update(cx, |workspace, cx| { + make_lsp_adapter_delegate(workspace.project(), cx).log_err() }); - let measurements = Arc::new(Mutex::new(BlockMeasurements::default())); - let inline_assistant = cx.new_view(|cx| { - InlineAssistant::new( - inline_assist_id, - measurements.clone(), - show_include_conversation, - show_include_conversation && self.include_conversation_in_next_inline_assist, - self.inline_prompt_history.clone(), - codegen.clone(), - cx, - ) - }); - let block_id = editor.update(cx, |editor, cx| { - editor.change_selections(None, cx, |selections| { - selections.select_anchor_ranges([selection.head()..selection.head()]) - }); - editor.insert_blocks( - [BlockProperties { - style: BlockStyle::Flex, - position: snapshot.anchor_before(Point::new(point_selection.head().row, 0)), - height: 2, - render: Box::new({ - let inline_assistant = inline_assistant.clone(); - move |cx: &mut BlockContext| { - *measurements.lock() = BlockMeasurements { - anchor_x: cx.anchor_x, - gutter_width: cx.gutter_dimensions.width, - }; - inline_assistant.clone().into_any_element() - } - }), - disposition: if selection.reversed { - BlockDisposition::Above - } else { - BlockDisposition::Below - }, - }], - Some(Autoscroll::Strategy(AutoscrollStrategy::Newest)), + let assistant_panel = cx.view().downgrade(); + let editor = cx.new_view(|cx| { + let mut editor = ContextEditor::for_context( + context, + self.fs.clone(), + workspace.clone(), + self.project.clone(), + lsp_adapter_delegate, + assistant_panel, cx, - )[0] + ); + editor.insert_default_prompt(cx); + editor }); - self.pending_inline_assists.insert( - inline_assist_id, - PendingInlineAssist { - editor: editor.downgrade(), - inline_assistant: Some((block_id, inline_assistant.clone())), - codegen: codegen.clone(), - project: project.downgrade(), - _subscriptions: vec![ - cx.subscribe(&inline_assistant, Self::handle_inline_assistant_event), - cx.subscribe(editor, { - let inline_assistant = inline_assistant.downgrade(); - move |_, editor, event, cx| { - if let Some(inline_assistant) = inline_assistant.upgrade() { - if let EditorEvent::SelectionsChanged { local } = event { - if *local - && inline_assistant.focus_handle(cx).contains_focused(cx) - { - cx.focus_view(&editor); - } - } - } - } - }), - cx.observe(&codegen, { - let editor = editor.downgrade(); - move |this, _, cx| { - if let Some(editor) = editor.upgrade() { - this.update_highlights_for_editor(&editor, cx); - } - } - }), - cx.subscribe(&codegen, move |this, codegen, event, cx| match event { - codegen::Event::Undone => { - this.finish_inline_assist(inline_assist_id, false, cx) - } - codegen::Event::Finished => { - let pending_assist = if let Some(pending_assist) = - this.pending_inline_assists.get(&inline_assist_id) - { - pending_assist - } else { - return; - }; + self.show_context(editor.clone(), cx); + Some(editor) + } - let error = codegen - .read(cx) - .error() - .map(|error| format!("Inline assistant error: {}", error)); - if let Some(error) = error { - if pending_assist.inline_assistant.is_none() { - if let Some(workspace) = this.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - struct InlineAssistantError; - - let id = - NotificationId::identified::( - inline_assist_id, - ); - - workspace.show_toast(Toast::new(id, error), cx); - }) - } + fn show_context(&mut self, context_editor: View, cx: &mut ViewContext) { + let focus = self.focus_handle(cx).contains_focused(cx); + let prev_len = self.pane.read(cx).items_len(); + self.pane.update(cx, |pane, cx| { + pane.add_item(Box::new(context_editor.clone()), focus, focus, None, cx) + }); - this.finish_inline_assist(inline_assist_id, false, cx); - } - } else { - this.finish_inline_assist(inline_assist_id, false, cx); - } - } - }), - ], - }, - ); - self.pending_inline_assist_ids_by_editor - .entry(editor.downgrade()) - .or_default() - .push(inline_assist_id); - self.update_highlights_for_editor(editor, cx); + if prev_len != self.pane.read(cx).items_len() { + self.subscriptions + .push(cx.subscribe(&context_editor, Self::handle_context_editor_event)); + } + + cx.emit(AssistantPanelEvent::ContextEdited); + cx.notify(); } - fn handle_inline_assistant_event( + fn handle_context_editor_event( &mut self, - inline_assistant: View, - event: &InlineAssistantEvent, + _: View, + event: &EditorEvent, cx: &mut ViewContext, ) { - let assist_id = inline_assistant.read(cx).id; match event { - InlineAssistantEvent::Confirmed { - prompt, - include_conversation, - } => { - self.confirm_inline_assist(assist_id, prompt, *include_conversation, cx); - } - InlineAssistantEvent::Canceled => { - self.finish_inline_assist(assist_id, true, cx); - } - InlineAssistantEvent::Dismissed => { - self.hide_inline_assist(assist_id, cx); - } - InlineAssistantEvent::IncludeConversationToggled { - include_conversation, - } => { - self.include_conversation_in_next_inline_assist = *include_conversation; - } + EditorEvent::TitleChanged { .. } => cx.notify(), + EditorEvent::Edited { .. } => cx.emit(AssistantPanelEvent::ContextEdited), + _ => {} } } - fn cancel_last_inline_assist( - workspace: &mut Workspace, - _: &editor::actions::Cancel, - cx: &mut ViewContext, - ) { - if let Some(panel) = workspace.panel::(cx) { - if let Some(editor) = workspace - .active_item(cx) - .and_then(|item| item.downcast::()) - { - let handled = panel.update(cx, |panel, cx| { - if let Some(assist_id) = panel - .pending_inline_assist_ids_by_editor - .get(&editor.downgrade()) - .and_then(|assist_ids| assist_ids.last().copied()) - { - panel.finish_inline_assist(assist_id, true, cx); - true - } else { - false - } - }); - if handled { - return; - } - } - } + fn deploy_history(&mut self, _: &DeployHistory, cx: &mut ViewContext) { + let history_item_ix = self + .pane + .read(cx) + .items() + .position(|item| item.downcast::().is_some()); - cx.propagate(); + if let Some(history_item_ix) = history_item_ix { + self.pane.update(cx, |pane, cx| { + pane.activate_item(history_item_ix, true, true, cx); + }); + } else { + let assistant_panel = cx.view().downgrade(); + let history = cx.new_view(|cx| { + ContextHistory::new( + self.project.clone(), + self.context_store.clone(), + assistant_panel, + cx, + ) + }); + self.pane.update(cx, |pane, cx| { + pane.add_item(Box::new(history), true, true, None, cx); + }); + } } - fn finish_inline_assist(&mut self, assist_id: usize, undo: bool, cx: &mut ViewContext) { - self.hide_inline_assist(assist_id, cx); + fn deploy_prompt_library(&mut self, _: &DeployPromptLibrary, cx: &mut ViewContext) { + open_prompt_library(self.languages.clone(), cx).detach_and_log_err(cx); + } - if let Some(pending_assist) = self.pending_inline_assists.remove(&assist_id) { - if let hash_map::Entry::Occupied(mut entry) = self - .pending_inline_assist_ids_by_editor - .entry(pending_assist.editor.clone()) - { - entry.get_mut().retain(|id| *id != assist_id); - if entry.get().is_empty() { - entry.remove(); - } - } + fn reset_credentials(&mut self, _: &ResetKey, cx: &mut ViewContext) { + CompletionProvider::global(cx) + .reset_credentials(cx) + .detach_and_log_err(cx); + } - if let Some(editor) = pending_assist.editor.upgrade() { - self.update_highlights_for_editor(&editor, cx); + fn toggle_model_selector(&mut self, _: &ToggleModelSelector, cx: &mut ViewContext) { + self.model_selector_menu_handle.toggle(cx); + } - if undo { - pending_assist - .codegen - .update(cx, |codegen, cx| codegen.undo(cx)); - } - } - } + fn active_context_editor(&self, cx: &AppContext) -> Option> { + self.pane + .read(cx) + .active_item()? + .downcast::() } - fn hide_inline_assist(&mut self, assist_id: usize, cx: &mut ViewContext) { - if let Some(pending_assist) = self.pending_inline_assists.get_mut(&assist_id) { - if let Some(editor) = pending_assist.editor.upgrade() { - if let Some((block_id, inline_assistant)) = pending_assist.inline_assistant.take() { - editor.update(cx, |editor, cx| { - editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); - if inline_assistant.focus_handle(cx).contains_focused(cx) { - editor.focus(cx); - } - }); - } - } - } + pub fn active_context(&self, cx: &AppContext) -> Option> { + Some(self.active_context_editor(cx)?.read(cx).context.clone()) } - fn confirm_inline_assist( + fn open_saved_context( &mut self, - inline_assist_id: usize, - user_prompt: &str, - include_conversation: bool, + path: PathBuf, cx: &mut ViewContext, - ) { - let conversation = if include_conversation { - self.active_conversation_editor() - .map(|editor| editor.read(cx).conversation.clone()) - } else { - None - }; - - let pending_assist = - if let Some(pending_assist) = self.pending_inline_assists.get_mut(&inline_assist_id) { - pending_assist - } else { - return; - }; - - let editor = if let Some(editor) = pending_assist.editor.upgrade() { - editor - } else { - return; - }; - - let project = pending_assist.project.clone(); - - let project_name = project.upgrade().map(|project| { - project - .read(cx) - .worktree_root_names(cx) - .collect::>() - .join("/") + ) -> Task> { + let existing_context = self.pane.read(cx).items().find_map(|item| { + item.downcast::() + .filter(|editor| editor.read(cx).context.read(cx).path() == Some(&path)) }); - - self.inline_prompt_history - .retain(|prompt| prompt != user_prompt); - self.inline_prompt_history.push_back(user_prompt.into()); - if self.inline_prompt_history.len() > Self::INLINE_PROMPT_HISTORY_MAX_LEN { - self.inline_prompt_history.pop_front(); + if let Some(existing_context) = existing_context { + return cx.spawn(|this, mut cx| async move { + this.update(&mut cx, |this, cx| this.show_context(existing_context, cx)) + }); } - let codegen = pending_assist.codegen.clone(); - let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); - let range = codegen.read(cx).range(); - let start = snapshot.point_to_buffer_offset(range.start); - let end = snapshot.point_to_buffer_offset(range.end); - let (buffer, range) = if let Some((start, end)) = start.zip(end) { - let (start_buffer, start_buffer_offset) = start; - let (end_buffer, end_buffer_offset) = end; - if start_buffer.remote_id() == end_buffer.remote_id() { - (start_buffer.clone(), start_buffer_offset..end_buffer_offset) - } else { - self.finish_inline_assist(inline_assist_id, false, cx); - return; - } - } else { - self.finish_inline_assist(inline_assist_id, false, cx); - return; - }; - - let language = buffer.language_at(range.start); - let language_name = if let Some(language) = language.as_ref() { - if Arc::ptr_eq(language, &language::PLAIN_TEXT) { - None - } else { - Some(language.name()) - } - } else { - None - }; + let context = self + .context_store + .update(cx, |store, cx| store.open_local_context(path.clone(), cx)); + let fs = self.fs.clone(); + let project = self.project.clone(); + let workspace = self.workspace.clone(); - // Higher Temperature increases the randomness of model outputs. - // If Markdown or No Language is Known, increase the randomness for more creative output - // If Code, decrease temperature to get more deterministic outputs - let temperature = if let Some(language) = language_name.clone() { - if language.as_ref() == "Markdown" { - 1.0 - } else { - 0.5 - } - } else { - 1.0 - }; + let lsp_adapter_delegate = workspace + .update(cx, |workspace, cx| { + make_lsp_adapter_delegate(workspace.project(), cx).log_err() + }) + .log_err() + .flatten(); - let user_prompt = user_prompt.to_string(); + cx.spawn(|this, mut cx| async move { + let context = context.await?; + let assistant_panel = this.clone(); + this.update(&mut cx, |this, cx| { + let workspace = workspace + .upgrade() + .ok_or_else(|| anyhow!("workspace dropped"))?; + let editor = cx.new_view(|cx| { + ContextEditor::for_context( + context, + fs, + workspace, + project, + lsp_adapter_delegate, + assistant_panel, + cx, + ) + }); + this.show_context(editor, cx); + anyhow::Ok(()) + })??; + Ok(()) + }) + } - let prompt = cx.background_executor().spawn(async move { - let language_name = language_name.as_deref(); - generate_content_prompt(user_prompt, language_name, buffer, range, project_name) + fn open_remote_context( + &mut self, + id: ContextId, + cx: &mut ViewContext, + ) -> Task>> { + let existing_context = self.pane.read(cx).items().find_map(|item| { + item.downcast::() + .filter(|editor| *editor.read(cx).context.read(cx).id() == id) }); - - let mut messages = Vec::new(); - if let Some(conversation) = conversation { - let conversation = conversation.read(cx); - let buffer = conversation.buffer.read(cx); - messages.extend( - conversation - .messages(cx) - .map(|message| message.to_request_message(buffer)), - ); + if let Some(existing_context) = existing_context { + return cx.spawn(|this, mut cx| async move { + this.update(&mut cx, |this, cx| { + this.show_context(existing_context.clone(), cx) + })?; + Ok(existing_context) + }); } - let model = self.model.clone(); - - cx.spawn(|_, mut cx| async move { - // I Don't know if we want to return a ? here. - let prompt = prompt.await?; - messages.push(LanguageModelRequestMessage { - role: Role::User, - content: prompt, - }); + let context = self + .context_store + .update(cx, |store, cx| store.open_remote_context(id, cx)); + let fs = self.fs.clone(); + let workspace = self.workspace.clone(); - let request = LanguageModelRequest { - model, - messages, - stop: vec!["|END|>".to_string()], - temperature, - }; + let lsp_adapter_delegate = workspace + .update(cx, |workspace, cx| { + make_lsp_adapter_delegate(workspace.project(), cx).log_err() + }) + .log_err() + .flatten(); - codegen.update(&mut cx, |codegen, cx| codegen.start(request, cx))?; - anyhow::Ok(()) + cx.spawn(|this, mut cx| async move { + let context = context.await?; + let assistant_panel = this.clone(); + this.update(&mut cx, |this, cx| { + let workspace = workspace + .upgrade() + .ok_or_else(|| anyhow!("workspace dropped"))?; + let editor = cx.new_view(|cx| { + ContextEditor::for_context( + context, + fs, + workspace, + this.project.clone(), + lsp_adapter_delegate, + assistant_panel, + cx, + ) + }); + this.show_context(editor.clone(), cx); + anyhow::Ok(editor) + })? }) - .detach(); } - fn update_highlights_for_editor(&self, editor: &View, cx: &mut ViewContext) { - let mut background_ranges = Vec::new(); - let mut foreground_ranges = Vec::new(); - let empty_inline_assist_ids = Vec::new(); - let inline_assist_ids = self - .pending_inline_assist_ids_by_editor - .get(&editor.downgrade()) - .unwrap_or(&empty_inline_assist_ids); - - for inline_assist_id in inline_assist_ids { - if let Some(pending_assist) = self.pending_inline_assists.get(inline_assist_id) { - let codegen = pending_assist.codegen.read(cx); - background_ranges.push(codegen.range()); - foreground_ranges.extend(codegen.last_equal_ranges().iter().cloned()); - } - } - - let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); - merge_ranges(&mut background_ranges, &snapshot); - merge_ranges(&mut foreground_ranges, &snapshot); - editor.update(cx, |editor, cx| { - if background_ranges.is_empty() { - editor.clear_background_highlights::(cx); - } else { - editor.highlight_background::( - &background_ranges, - |theme| theme.editor_active_line_background, // TODO use the appropriate color - cx, - ); - } - - if foreground_ranges.is_empty() { - editor.clear_highlights::(cx); - } else { - editor.highlight_text::( - foreground_ranges, - HighlightStyle { - fade_out: Some(0.6), - ..Default::default() - }, - cx, - ); - } - }); + fn is_authenticated(&mut self, cx: &mut ViewContext) -> bool { + CompletionProvider::global(cx).is_authenticated() } - fn new_conversation(&mut self, cx: &mut ViewContext) -> Option> { - let workspace = self.workspace.upgrade()?; - - let editor = cx.new_view(|cx| { - ConversationEditor::new( - self.model.clone(), - self.languages.clone(), - self.slash_commands.clone(), - self.fs.clone(), - workspace, - cx, - ) - }); - - self.show_conversation(editor.clone(), cx); - Some(editor) + fn authenticate(&mut self, cx: &mut ViewContext) -> Task> { + cx.update_global::(|provider, cx| provider.authenticate(cx)) } - fn show_conversation( - &mut self, - conversation_editor: View, - cx: &mut ViewContext, - ) { - let mut subscriptions = Vec::new(); - subscriptions - .push(cx.subscribe(&conversation_editor, Self::handle_conversation_editor_event)); + fn render_signed_in(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let mut registrar = DivRegistrar::new( + |panel, cx| { + panel + .pane + .read(cx) + .toolbar() + .read(cx) + .item_of_type::() + }, + cx, + ); + BufferSearchBar::register(&mut registrar); + let registrar = registrar.into_div(); - let conversation = conversation_editor.read(cx).conversation.clone(); - subscriptions.push(cx.observe(&conversation, |_, _, cx| cx.notify())); + v_flex() + .key_context("AssistantPanel") + .size_full() + .on_action(cx.listener(|this, _: &workspace::NewFile, cx| { + this.new_context(cx); + })) + .on_action(cx.listener(AssistantPanel::deploy_history)) + .on_action(cx.listener(AssistantPanel::deploy_prompt_library)) + .on_action(cx.listener(AssistantPanel::reset_credentials)) + .on_action(cx.listener(AssistantPanel::toggle_model_selector)) + .child(registrar.size_full().child(self.pane.clone())) + } +} - let editor = conversation_editor.read(cx).editor.clone(); - self.toolbar.update(cx, |toolbar, cx| { - toolbar.set_active_item(Some(&editor), cx); - }); - if self.focus_handle.contains_focused(cx) { - cx.focus_view(&editor); +impl Render for AssistantPanel { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + if let Some(authentication_prompt) = self.authentication_prompt.as_ref() { + authentication_prompt.clone().into_any() + } else { + self.render_signed_in(cx).into_any_element() } - self.active_conversation_editor = Some(ActiveConversationEditor { - editor: conversation_editor, - _subscriptions: subscriptions, - }); - self.show_saved_conversations = false; + } +} - cx.notify(); +impl Panel for AssistantPanel { + fn persistent_name() -> &'static str { + "AssistantPanel" } - fn cycle_model(&mut self, cx: &mut ViewContext) { - let next_model = match &self.model { - LanguageModel::OpenAi(model) => LanguageModel::OpenAi(match &model { - open_ai::Model::ThreePointFiveTurbo => open_ai::Model::Four, - open_ai::Model::Four => open_ai::Model::FourTurbo, - open_ai::Model::FourTurbo => open_ai::Model::FourOmni, - open_ai::Model::FourOmni => open_ai::Model::ThreePointFiveTurbo, - }), - LanguageModel::Anthropic(model) => LanguageModel::Anthropic(match &model { - anthropic::Model::Claude3Opus => anthropic::Model::Claude3Sonnet, - anthropic::Model::Claude3Sonnet => anthropic::Model::Claude3Haiku, - anthropic::Model::Claude3Haiku => anthropic::Model::Claude3Opus, - }), - LanguageModel::ZedDotDev(model) => LanguageModel::ZedDotDev(match &model { - ZedDotDevModel::Gpt3Point5Turbo => ZedDotDevModel::Gpt4, - ZedDotDevModel::Gpt4 => ZedDotDevModel::Gpt4Turbo, - ZedDotDevModel::Gpt4Turbo => ZedDotDevModel::Gpt4Omni, - ZedDotDevModel::Gpt4Omni => ZedDotDevModel::Claude3Opus, - ZedDotDevModel::Claude3Opus => ZedDotDevModel::Claude3Sonnet, - ZedDotDevModel::Claude3Sonnet => ZedDotDevModel::Claude3Haiku, - ZedDotDevModel::Claude3Haiku => { - match CompletionProvider::global(cx).default_model() { - LanguageModel::ZedDotDev(custom @ ZedDotDevModel::Custom(_)) => custom, - _ => ZedDotDevModel::Gpt3Point5Turbo, - } - } - ZedDotDevModel::Custom(_) => ZedDotDevModel::Gpt3Point5Turbo, - }), - }; + fn position(&self, cx: &WindowContext) -> DockPosition { + match AssistantSettings::get_global(cx).dock { + AssistantDockPosition::Left => DockPosition::Left, + AssistantDockPosition::Bottom => DockPosition::Bottom, + AssistantDockPosition::Right => DockPosition::Right, + } + } - self.set_model(next_model, cx); + fn position_is_valid(&self, _: DockPosition) -> bool { + true } - fn set_model(&mut self, model: LanguageModel, cx: &mut ViewContext) { - self.model = model.clone(); - if let Some(editor) = self.active_conversation_editor() { - editor.update(cx, |active_conversation, cx| { - active_conversation - .conversation - .update(cx, |conversation, cx| { - conversation.set_model(model, cx); - }) - }) - } - cx.notify(); + fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext) { + settings::update_settings_file::(self.fs.clone(), cx, move |settings| { + let dock = match position { + DockPosition::Left => AssistantDockPosition::Left, + DockPosition::Bottom => AssistantDockPosition::Bottom, + DockPosition::Right => AssistantDockPosition::Right, + }; + settings.set_dock(dock); + }); } - fn handle_conversation_editor_event( - &mut self, - _: View, - event: &ConversationEditorEvent, - cx: &mut ViewContext, - ) { - match event { - ConversationEditorEvent::TabContentChanged => cx.notify(), + fn size(&self, cx: &WindowContext) -> Pixels { + let settings = AssistantSettings::get_global(cx); + match self.position(cx) { + DockPosition::Left | DockPosition::Right => { + self.width.unwrap_or(settings.default_width) + } + DockPosition::Bottom => self.height.unwrap_or(settings.default_height), } } - fn toggle_zoom(&mut self, _: &workspace::ToggleZoom, cx: &mut ViewContext) { - if self.zoomed { - cx.emit(PanelEvent::ZoomOut) - } else { - cx.emit(PanelEvent::ZoomIn) + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { + match self.position(cx) { + DockPosition::Left | DockPosition::Right => self.width = size, + DockPosition::Bottom => self.height = size, } + cx.notify(); } - fn toggle_history(&mut self, _: &ToggleHistory, cx: &mut ViewContext) { - self.show_saved_conversations = !self.show_saved_conversations; - cx.notify(); + fn is_zoomed(&self, cx: &WindowContext) -> bool { + self.pane.read(cx).is_zoomed() } - fn show_history(&mut self, cx: &mut ViewContext) { - if !self.show_saved_conversations { - self.show_saved_conversations = true; - cx.notify(); - } + fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext) { + self.pane.update(cx, |pane, cx| pane.set_zoomed(zoomed, cx)); } - fn deploy(&mut self, action: &search::buffer_search::Deploy, cx: &mut ViewContext) { - let mut propagate = true; - if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { - search_bar.update(cx, |search_bar, cx| { - if search_bar.show(cx) { - search_bar.search_suggested(cx); - if action.focus { - let focus_handle = search_bar.focus_handle(cx); - search_bar.select_query(cx); - cx.focus(&focus_handle); + fn set_active(&mut self, active: bool, cx: &mut ViewContext) { + if active { + let load_credentials = self.authenticate(cx); + cx.spawn(|this, mut cx| async move { + load_credentials.await?; + this.update(&mut cx, |this, cx| { + if this.is_authenticated(cx) && this.active_context_editor(cx).is_none() { + this.new_context(cx); } - propagate = false - } - }); - } - if propagate { - cx.propagate(); + }) + }) + .detach_and_log_err(cx); } } - fn handle_editor_cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext) { - if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { - if !search_bar.read(cx).is_dismissed() { - search_bar.update(cx, |search_bar, cx| { - search_bar.dismiss(&Default::default(), cx) - }); - return; - } - } - cx.propagate(); + fn pane(&self) -> Option> { + Some(self.pane.clone()) } - fn select_next_match(&mut self, _: &search::SelectNextMatch, cx: &mut ViewContext) { - if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { - search_bar.update(cx, |bar, cx| bar.select_match(Direction::Next, 1, cx)); - } + fn remote_id() -> Option { + Some(proto::PanelId::AssistantPanel) } - fn select_prev_match(&mut self, _: &search::SelectPrevMatch, cx: &mut ViewContext) { - if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { - search_bar.update(cx, |bar, cx| bar.select_match(Direction::Prev, 1, cx)); + fn icon(&self, cx: &WindowContext) -> Option { + let settings = AssistantSettings::get_global(cx); + if !settings.enabled || !settings.button { + return None; } - } - fn reset_credentials(&mut self, _: &ResetKey, cx: &mut ViewContext) { - CompletionProvider::global(cx) - .reset_credentials(cx) - .detach_and_log_err(cx); + Some(IconName::ZedAssistant) } - fn active_conversation_editor(&self) -> Option<&View> { - Some(&self.active_conversation_editor.as_ref()?.editor) - } - - fn render_popover_button(&self, cx: &mut ViewContext) -> impl IntoElement { - let assistant = cx.view().clone(); - let zoomed = self.zoomed; - popover_menu("assistant-popover") - .trigger(IconButton::new("trigger", IconName::Menu)) - .menu(move |cx| { - let assistant = assistant.clone(); - ContextMenu::build(cx, |menu, _cx| { - menu.entry( - if zoomed { "Zoom Out" } else { "Zoom In" }, - Some(Box::new(ToggleZoom)), - { - let assistant = assistant.clone(); - move |cx| { - assistant.focus_handle(cx).dispatch_action(&ToggleZoom, cx); - } - }, - ) - .entry("New Context", Some(Box::new(NewFile)), { - let assistant = assistant.clone(); - move |cx| { - assistant.focus_handle(cx).dispatch_action(&NewFile, cx); - } - }) - .entry("History", Some(Box::new(ToggleHistory)), { - let assistant = assistant.clone(); - move |cx| assistant.update(cx, |assistant, cx| assistant.show_history(cx)) - }) - }) - .into() - }) - } - - fn render_inject_context_menu(&self, _cx: &mut ViewContext) -> impl Element { - let workspace = self.workspace.clone(); - - popover_menu("inject-context-menu") - .trigger(IconButton::new("trigger", IconName::Quote).tooltip(|cx| { - // Tooltip::with_meta("Insert Context", None, "Type # to insert via keyboard", cx) - Tooltip::text("Insert Context", cx) - })) - .menu(move |cx| { - ContextMenu::build(cx, |menu, _cx| { - // menu.entry("Insert Search", None, { - // let assistant = assistant.clone(); - // move |_cx| {} - // }) - // .entry("Insert Docs", None, { - // let assistant = assistant.clone(); - // move |cx| {} - // }) - menu.entry("Quote Selection", None, { - let workspace = workspace.clone(); - move |cx| { - workspace - .update(cx, |workspace, cx| { - ConversationEditor::quote_selection( - workspace, - &Default::default(), - cx, - ) - }) - .ok(); - } - }) - // .entry("Insert Active Prompt", None, { - // let workspace = workspace.clone(); - // move |cx| { - // workspace - // .update(cx, |workspace, cx| { - // ConversationEditor::insert_active_prompt( - // workspace, - // &Default::default(), - // cx, - // ) - // }) - // .ok(); - // } - // }) - }) - .into() - }) - } - - fn render_send_button(&self, cx: &mut ViewContext) -> Option { - self.active_conversation_editor - .as_ref() - .map(|conversation| { - let focus_handle = conversation.editor.focus_handle(cx); - ButtonLike::new("send_button") - .style(ButtonStyle::Filled) - .layer(ElevationIndex::ModalSurface) - .children( - KeyBinding::for_action_in(&Assist, &focus_handle, cx) - .map(|binding| binding.into_any_element()), - ) - .child(Label::new("Send")) - .on_click(cx.listener(|this, _event, cx| { - if let Some(active_editor) = this.active_conversation_editor() { - active_editor.update(cx, |editor, cx| editor.assist(&Assist, cx)); - } - })) - }) - } - - fn render_saved_conversation( - &mut self, - index: usize, - cx: &mut ViewContext, - ) -> impl IntoElement { - let conversation = &self.saved_conversations[index]; - let path = conversation.path.clone(); - - ButtonLike::new(index) - .on_click(cx.listener(move |this, _, cx| { - this.open_conversation(path.clone(), cx) - .detach_and_log_err(cx) - })) - .full_width() - .child( - div() - .flex() - .w_full() - .gap_2() - .child( - Label::new(conversation.mtime.format("%F %I:%M%p").to_string()) - .color(Color::Muted) - .size(LabelSize::Small), - ) - .child(Label::new(conversation.title.clone()).size(LabelSize::Small)), - ) - } - - fn open_conversation(&mut self, path: PathBuf, cx: &mut ViewContext) -> Task> { - cx.focus(&self.focus_handle); - - let fs = self.fs.clone(); - let workspace = self.workspace.clone(); - let slash_commands = self.slash_commands.clone(); - let languages = self.languages.clone(); - let telemetry = self.telemetry.clone(); - - let lsp_adapter_delegate = workspace - .update(cx, |workspace, cx| { - make_lsp_adapter_delegate(workspace.project(), cx) - }) - .log_err(); - - cx.spawn(|this, mut cx| async move { - let saved_conversation = SavedConversation::load(&path, fs.as_ref()).await?; - let model = this.update(&mut cx, |this, _| this.model.clone())?; - let conversation = Conversation::deserialize( - saved_conversation, - model, - path.clone(), - languages, - slash_commands, - Some(telemetry), - lsp_adapter_delegate, - &mut cx, - ) - .await?; - - this.update(&mut cx, |this, cx| { - let workspace = workspace - .upgrade() - .ok_or_else(|| anyhow!("workspace dropped"))?; - let editor = cx.new_view(|cx| { - ConversationEditor::for_conversation(conversation, fs, workspace, cx) - }); - this.show_conversation(editor, cx); - anyhow::Ok(()) - })??; - Ok(()) - }) - } - - fn show_prompt_manager(&mut self, cx: &mut ViewContext) { - if let Some(workspace) = self.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.toggle_modal(cx, |cx| { - PromptManager::new( - self.prompt_library.clone(), - self.languages.clone(), - self.fs.clone(), - cx, - ) - }) - }) - } - } - - fn is_authenticated(&mut self, cx: &mut ViewContext) -> bool { - CompletionProvider::global(cx).is_authenticated() - } - - fn authenticate(&mut self, cx: &mut ViewContext) -> Task> { - cx.update_global::(|provider, cx| provider.authenticate(cx)) - } - - fn render_signed_in(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let header = - TabBar::new("assistant_header") - .start_child(h_flex().gap_1().child(self.render_popover_button(cx))) - .children(self.active_conversation_editor().map(|editor| { - h_flex() - .h(rems(Tab::CONTAINER_HEIGHT_IN_REMS)) - .flex_1() - .px_2() - .child(Label::new(editor.read(cx).title(cx)).into_element()) - })) - .end_child( - h_flex() - .gap_2() - .when_some(self.active_conversation_editor(), |this, editor| { - let conversation = editor.read(cx).conversation.clone(); - this.child( - h_flex() - .gap_1() - .child(self.render_model(&conversation, cx)) - .children(self.render_remaining_tokens(&conversation, cx)), - ) - .child( - ui::Divider::vertical() - .inset() - .color(ui::DividerColor::Border), - ) - }) - .child( - h_flex() - .gap_1() - .child(self.render_inject_context_menu(cx)) - .child( - IconButton::new("show_prompt_manager", IconName::Library) - .icon_size(IconSize::Small) - .on_click(cx.listener(|this, _event, cx| { - this.show_prompt_manager(cx) - })) - .tooltip(|cx| Tooltip::text("Prompt Library…", cx)), - ), - ), - ); - - let contents = if self.active_conversation_editor().is_some() { - let mut registrar = DivRegistrar::new( - |panel, cx| panel.toolbar.read(cx).item_of_type::(), - cx, - ); - BufferSearchBar::register(&mut registrar); - registrar.into_div() - } else { - div() - }; - - v_flex() - .key_context("AssistantPanel") - .size_full() - .on_action(cx.listener(|this, _: &workspace::NewFile, cx| { - this.new_conversation(cx); - })) - .on_action(cx.listener(AssistantPanel::toggle_zoom)) - .on_action(cx.listener(AssistantPanel::toggle_history)) - .on_action(cx.listener(AssistantPanel::deploy)) - .on_action(cx.listener(AssistantPanel::select_next_match)) - .on_action(cx.listener(AssistantPanel::select_prev_match)) - .on_action(cx.listener(AssistantPanel::handle_editor_cancel)) - .on_action(cx.listener(AssistantPanel::reset_credentials)) - .track_focus(&self.focus_handle) - .child(header) - .children(if self.toolbar.read(cx).hidden() { - None - } else { - Some(self.toolbar.clone()) - }) - .child(contents.flex_1().child( - if self.show_saved_conversations || self.active_conversation_editor().is_none() { - let view = cx.view().clone(); - let scroll_handle = self.saved_conversations_scroll_handle.clone(); - let conversation_count = self.saved_conversations.len(); - canvas( - move |bounds, cx| { - let mut saved_conversations = uniform_list( - view, - "saved_conversations", - conversation_count, - |this, range, cx| { - range - .map(|ix| this.render_saved_conversation(ix, cx)) - .collect() - }, - ) - .track_scroll(scroll_handle) - .into_any_element(); - saved_conversations.prepaint_as_root( - bounds.origin, - bounds.size.map(AvailableSpace::Definite), - cx, - ); - saved_conversations - }, - |_bounds, mut saved_conversations, cx| saved_conversations.paint(cx), - ) - .size_full() - .into_any_element() - } else if let Some(editor) = self.active_conversation_editor() { - let editor = editor.clone(); - div() - .size_full() - .child(editor.clone()) - .child( - h_flex() - .w_full() - .absolute() - .bottom_0() - .p_4() - .justify_end() - .children(self.render_send_button(cx)), - ) - .into_any_element() - } else { - div().into_any_element() - }, - )) - } - - fn render_model( - &self, - conversation: &Model, - cx: &mut ViewContext, - ) -> impl IntoElement { - Button::new("current_model", conversation.read(cx).model.display_name()) - .style(ButtonStyle::Filled) - .tooltip(move |cx| Tooltip::text("Change Model", cx)) - .on_click(cx.listener(|this, _, cx| this.cycle_model(cx))) - } - - fn render_remaining_tokens( - &self, - conversation: &Model, - cx: &mut ViewContext, - ) -> Option { - let remaining_tokens = conversation.read(cx).remaining_tokens()?; - let remaining_tokens_color = if remaining_tokens <= 0 { - Color::Error - } else if remaining_tokens <= 500 { - Color::Warning - } else { - Color::Muted - }; - Some( - Label::new(remaining_tokens.to_string()) - .size(LabelSize::Small) - .color(remaining_tokens_color), - ) - } -} - -impl Render for AssistantPanel { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - if let Some(authentication_prompt) = self.authentication_prompt.as_ref() { - authentication_prompt.clone().into_any() - } else { - self.render_signed_in(cx).into_any_element() - } - } -} - -impl Panel for AssistantPanel { - fn persistent_name() -> &'static str { - "AssistantPanel" - } - - fn position(&self, cx: &WindowContext) -> DockPosition { - match AssistantSettings::get_global(cx).dock { - AssistantDockPosition::Left => DockPosition::Left, - AssistantDockPosition::Bottom => DockPosition::Bottom, - AssistantDockPosition::Right => DockPosition::Right, - } - } - - fn position_is_valid(&self, _: DockPosition) -> bool { - true - } - - fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext) { - settings::update_settings_file::(self.fs.clone(), cx, move |settings| { - let dock = match position { - DockPosition::Left => AssistantDockPosition::Left, - DockPosition::Bottom => AssistantDockPosition::Bottom, - DockPosition::Right => AssistantDockPosition::Right, - }; - settings.set_dock(dock); - }); - } - - fn size(&self, cx: &WindowContext) -> Pixels { - let settings = AssistantSettings::get_global(cx); - match self.position(cx) { - DockPosition::Left | DockPosition::Right => { - self.width.unwrap_or(settings.default_width) - } - DockPosition::Bottom => self.height.unwrap_or(settings.default_height), - } - } - - fn set_size(&mut self, size: Option, cx: &mut ViewContext) { - match self.position(cx) { - DockPosition::Left | DockPosition::Right => self.width = size, - DockPosition::Bottom => self.height = size, - } - cx.notify(); - } - - fn is_zoomed(&self, _: &WindowContext) -> bool { - self.zoomed - } - - fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext) { - self.zoomed = zoomed; - cx.notify(); - } - - fn set_active(&mut self, active: bool, cx: &mut ViewContext) { - if active { - let load_credentials = self.authenticate(cx); - cx.spawn(|this, mut cx| async move { - load_credentials.await?; - this.update(&mut cx, |this, cx| { - if this.is_authenticated(cx) && this.active_conversation_editor().is_none() { - this.new_conversation(cx); - } - }) - }) - .detach_and_log_err(cx); - } - } - - fn icon(&self, cx: &WindowContext) -> Option { - let settings = AssistantSettings::get_global(cx); - if !settings.enabled || !settings.button { - return None; - } - - Some(IconName::Ai) - } - - fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { - Some("Assistant Panel") - } - - fn toggle_action(&self) -> Box { - Box::new(ToggleFocus) - } -} - -impl EventEmitter for AssistantPanel {} - -impl FocusableView for AssistantPanel { - fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { - self.focus_handle.clone() - } -} - -#[derive(Clone)] -enum ConversationEvent { - MessagesEdited, - SummaryChanged, - EditSuggestionsChanged, - StreamedCompletion, - SlashCommandsChanged, - SlashCommandOutputAdded(Range), - SlashCommandOutputRemoved(Range), -} - -#[derive(Default)] -struct Summary { - text: String, - done: bool, -} - -pub struct Conversation { - id: Option, - buffer: Model, - pub(crate) ambient_context: AmbientContext, - edit_suggestions: Vec, - slash_command_calls: Vec, - message_anchors: Vec, - messages_metadata: HashMap, - next_message_id: MessageId, - summary: Option, - pending_summary: Task>, - completion_count: usize, - pending_completions: Vec, - model: LanguageModel, - token_count: Option, - pending_token_count: Task>, - pending_edit_suggestion_parse: Option>, - pending_command_invocation_parse: Option>, - pending_save: Task>, - path: Option, - _subscriptions: Vec, - telemetry: Option>, - slash_command_registry: Arc, - language_registry: Arc, - lsp_adapter_delegate: Option>, -} - -impl EventEmitter for Conversation {} - -impl Conversation { - fn new( - model: LanguageModel, - language_registry: Arc, - slash_command_registry: Arc, - telemetry: Option>, - lsp_adapter_delegate: Option>, - cx: &mut ModelContext, - ) -> Self { - let buffer = cx.new_model(|cx| { - let mut buffer = Buffer::local("", cx); - buffer.set_language_registry(language_registry.clone()); - buffer - }); - - let mut this = Self { - id: Some(Uuid::new_v4().to_string()), - message_anchors: Default::default(), - messages_metadata: Default::default(), - next_message_id: Default::default(), - ambient_context: AmbientContext::default(), - edit_suggestions: Vec::new(), - slash_command_calls: Vec::new(), - summary: None, - pending_summary: Task::ready(None), - completion_count: Default::default(), - pending_completions: Default::default(), - token_count: None, - pending_token_count: Task::ready(None), - pending_edit_suggestion_parse: None, - pending_command_invocation_parse: None, - model, - _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)], - pending_save: Task::ready(Ok(())), - path: None, - buffer, - telemetry, - slash_command_registry, - language_registry, - lsp_adapter_delegate, - }; - - let message = MessageAnchor { - id: MessageId(post_inc(&mut this.next_message_id.0)), - start: language::Anchor::MIN, - }; - this.message_anchors.push(message.clone()); - this.messages_metadata.insert( - message.id, - MessageMetadata { - role: Role::User, - status: MessageStatus::Done, - ambient_context: AmbientContextSnapshot::default(), - }, - ); - - this.set_language(cx); - this.count_remaining_tokens(cx); - this - } - - fn serialize(&self, cx: &AppContext) -> SavedConversation { - SavedConversation { - id: self.id.clone(), - zed: "conversation".into(), - version: SavedConversation::VERSION.into(), - text: self.buffer.read(cx).text(), - message_metadata: self.messages_metadata.clone(), - messages: self - .messages(cx) - .map(|message| SavedMessage { - id: message.id, - start: message.offset_range.start, - }) - .collect(), - summary: self - .summary - .as_ref() - .map(|summary| summary.text.clone()) - .unwrap_or_default(), - } - } - - #[allow(clippy::too_many_arguments)] - async fn deserialize( - saved_conversation: SavedConversation, - model: LanguageModel, - path: PathBuf, - language_registry: Arc, - slash_command_registry: Arc, - telemetry: Option>, - lsp_adapter_delegate: Option>, - cx: &mut AsyncAppContext, - ) -> Result> { - let id = match saved_conversation.id { - Some(id) => Some(id), - None => Some(Uuid::new_v4().to_string()), - }; - - let markdown = language_registry.language_for_name("Markdown"); - let mut message_anchors = Vec::new(); - let mut next_message_id = MessageId(0); - let buffer = cx.new_model(|cx| { - let mut buffer = Buffer::local(saved_conversation.text, cx); - for message in saved_conversation.messages { - message_anchors.push(MessageAnchor { - id: message.id, - start: buffer.anchor_before(message.start), - }); - next_message_id = cmp::max(next_message_id, MessageId(message.id.0 + 1)); - } - buffer.set_language_registry(language_registry.clone()); - cx.spawn(|buffer, mut cx| async move { - let markdown = markdown.await?; - buffer.update(&mut cx, |buffer: &mut Buffer, cx| { - buffer.set_language(Some(markdown), cx) - })?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - buffer - })?; - - cx.new_model(move |cx| { - let mut this = Self { - id, - message_anchors, - messages_metadata: saved_conversation.message_metadata, - next_message_id, - ambient_context: AmbientContext::default(), - edit_suggestions: Vec::new(), - slash_command_calls: Vec::new(), - summary: Some(Summary { - text: saved_conversation.summary, - done: true, - }), - pending_summary: Task::ready(None), - completion_count: Default::default(), - pending_completions: Default::default(), - token_count: None, - pending_edit_suggestion_parse: None, - pending_command_invocation_parse: None, - pending_token_count: Task::ready(None), - model, - _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)], - pending_save: Task::ready(Ok(())), - path: Some(path), - buffer, - telemetry, - language_registry, - slash_command_registry, - lsp_adapter_delegate, - }; - this.set_language(cx); - this.reparse_edit_suggestions(cx); - this.count_remaining_tokens(cx); - this - }) - } - - fn set_language(&mut self, cx: &mut ModelContext) { - let markdown = self.language_registry.language_for_name("Markdown"); - cx.spawn(|this, mut cx| async move { - let markdown = markdown.await?; - this.update(&mut cx, |this, cx| { - this.buffer - .update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx)); - }) - }) - .detach_and_log_err(cx); - } - - fn toggle_recent_buffers(&mut self, cx: &mut ModelContext) { - self.ambient_context.recent_buffers.enabled = !self.ambient_context.recent_buffers.enabled; - match self.ambient_context.recent_buffers.update(cx) { - ContextUpdated::Updating => {} - ContextUpdated::Disabled => { - self.count_remaining_tokens(cx); - } - } - } - - fn toggle_current_project_context( - &mut self, - fs: Arc, - project: WeakModel, - cx: &mut ModelContext, - ) { - self.ambient_context.current_project.enabled = - !self.ambient_context.current_project.enabled; - match self.ambient_context.current_project.update(fs, project, cx) { - ContextUpdated::Updating => {} - ContextUpdated::Disabled => { - self.count_remaining_tokens(cx); - } - } - } - - fn set_recent_buffers( - &mut self, - buffers: impl IntoIterator>, - cx: &mut ModelContext, - ) { - self.ambient_context.recent_buffers.buffers.clear(); - self.ambient_context - .recent_buffers - .buffers - .extend(buffers.into_iter().map(|buffer| RecentBuffer { - buffer: buffer.downgrade(), - _subscription: cx.observe(&buffer, |this, _, cx| { - match this.ambient_context.recent_buffers.update(cx) { - ContextUpdated::Updating => {} - ContextUpdated::Disabled => { - this.count_remaining_tokens(cx); - } - } - }), - })); - match self.ambient_context.recent_buffers.update(cx) { - ContextUpdated::Updating => {} - ContextUpdated::Disabled => { - self.count_remaining_tokens(cx); - } - } - } - - fn handle_buffer_event( - &mut self, - _: Model, - event: &language::Event, - cx: &mut ModelContext, - ) { - if *event == language::Event::Edited { - self.count_remaining_tokens(cx); - self.reparse_edit_suggestions(cx); - self.reparse_slash_command_calls(cx); - cx.emit(ConversationEvent::MessagesEdited); - } - } - - pub(crate) fn count_remaining_tokens(&mut self, cx: &mut ModelContext) { - let request = self.to_completion_request(cx); - self.pending_token_count = cx.spawn(|this, mut cx| { - async move { - cx.background_executor() - .timer(Duration::from_millis(200)) - .await; - - let token_count = cx - .update(|cx| CompletionProvider::global(cx).count_tokens(request, cx))? - .await?; - - this.update(&mut cx, |this, cx| { - this.token_count = Some(token_count); - cx.notify() - })?; - anyhow::Ok(()) - } - .log_err() - }); - } - - fn reparse_edit_suggestions(&mut self, cx: &mut ModelContext) { - self.pending_edit_suggestion_parse = Some(cx.spawn(|this, mut cx| async move { - cx.background_executor() - .timer(Duration::from_millis(200)) - .await; - - this.update(&mut cx, |this, cx| { - this.reparse_edit_suggestions_in_range(0..this.buffer.read(cx).len(), cx); - }) - .ok(); - })); - } - - fn reparse_edit_suggestions_in_range( - &mut self, - range: Range, - cx: &mut ModelContext, - ) { - self.buffer.update(cx, |buffer, _| { - let range_start = buffer.anchor_before(range.start); - let range_end = buffer.anchor_after(range.end); - let start_ix = self - .edit_suggestions - .binary_search_by(|probe| { - probe - .source_range - .end - .cmp(&range_start, buffer) - .then(Ordering::Greater) - }) - .unwrap_err(); - let end_ix = self - .edit_suggestions - .binary_search_by(|probe| { - probe - .source_range - .start - .cmp(&range_end, buffer) - .then(Ordering::Less) - }) - .unwrap_err(); - - let mut new_edit_suggestions = Vec::new(); - let mut message_lines = buffer.as_rope().chunks_in_range(range).lines(); - while let Some(suggestion) = parse_next_edit_suggestion(&mut message_lines) { - let start_anchor = buffer.anchor_after(suggestion.outer_range.start); - let end_anchor = buffer.anchor_before(suggestion.outer_range.end); - new_edit_suggestions.push(EditSuggestion { - source_range: start_anchor..end_anchor, - full_path: suggestion.path, - }); - } - self.edit_suggestions - .splice(start_ix..end_ix, new_edit_suggestions); - }); - cx.emit(ConversationEvent::EditSuggestionsChanged); - cx.notify(); - } - - fn reparse_slash_command_calls(&mut self, cx: &mut ModelContext) { - self.pending_command_invocation_parse = Some(cx.spawn(|this, mut cx| async move { - cx.background_executor().timer(SLASH_COMMAND_DEBOUNCE).await; - - this.update(&mut cx, |this, cx| { - let buffer = this.buffer.read(cx).snapshot(); - - let mut changed = false; - let mut new_calls = Vec::new(); - let mut old_calls = mem::take(&mut this.slash_command_calls) - .into_iter() - .peekable(); - let mut lines = buffer.as_rope().chunks().lines(); - let mut offset = 0; - while let Some(line) = lines.next() { - let line_end_offset = offset + line.len(); - if let Some(call) = SlashCommandLine::parse(line) { - let mut unchanged_call = None; - while let Some(old_call) = old_calls.peek() { - match old_call.source_range.start.to_offset(&buffer).cmp(&offset) { - Ordering::Greater => break, - Ordering::Equal - if this.slash_command_is_unchanged( - old_call, &call, line, &buffer, - ) => - { - unchanged_call = old_calls.next(); - } - _ => { - changed = true; - let old_call = old_calls.next().unwrap(); - this.slash_command_call_removed(old_call, cx); - } - } - } - - let name = &line[call.name]; - if let Some(call) = unchanged_call { - new_calls.push(call); - } else if let Some(command) = this.slash_command_registry.command(name) { - changed = true; - let name = name.to_string(); - let source_range = - buffer.anchor_after(offset)..buffer.anchor_before(line_end_offset); - - let argument = call.argument.map(|range| &line[range]); - let invocation = command.run( - argument, - this.lsp_adapter_delegate - .clone() - .expect("no LspAdapterDelegate present when invoking command"), - cx, - ); - - new_calls.push(SlashCommandCall { - name, - argument: argument.map(|s| s.to_string()), - source_range: source_range.clone(), - output_range: None, - should_rerun: false, - _invalidate: cx.spawn(|this, mut cx| { - let source_range = source_range.clone(); - let invalidated = invocation.invalidated; - async move { - if invalidated.await.is_ok() { - _ = this.update(&mut cx, |this, cx| { - let buffer = this.buffer.read(cx); - let call_ix = this - .slash_command_calls - .binary_search_by(|probe| { - probe - .source_range - .start - .cmp(&source_range.start, buffer) - }); - if let Ok(call_ix) = call_ix { - this.slash_command_calls[call_ix] - .should_rerun = true; - this.reparse_slash_command_calls(cx); - } - }); - } - } - }), - _command_cleanup: invocation.cleanup, - }); - - cx.spawn(|this, mut cx| async move { - let output = invocation.output.await; - this.update(&mut cx, |this, cx| { - let output_range = this.buffer.update(cx, |buffer, cx| { - let call_ix = this - .slash_command_calls - .binary_search_by(|probe| { - probe - .source_range - .start - .cmp(&source_range.start, buffer) - }) - .ok()?; - - let mut output = output.log_err()?; - output.truncate(output.trim_end().len()); - - let source_end = source_range.end.to_offset(buffer); - let output_start = source_end + '\n'.len_utf8(); - let output_end = output_start + output.len(); - - if buffer - .chars_at(source_end) - .next() - .map_or(false, |c| c != '\n') - { - output.push('\n'); - } - - buffer.edit( - [ - (source_end..source_end, "\n".to_string()), - (source_end..source_end, output), - ], - None, - cx, - ); - - let output_start = buffer.anchor_after(output_start); - let output_end = buffer.anchor_before(output_end); - this.slash_command_calls[call_ix].output_range = - Some(output_start..output_end); - Some(source_range.end..output_end) - }); - if let Some(output_range) = output_range { - cx.emit(ConversationEvent::SlashCommandOutputAdded( - output_range, - )); - cx.emit(ConversationEvent::SlashCommandsChanged); - } - }) - .ok(); - }) - .detach(); - } - } - offset = lines.offset(); - } - - for old_call in old_calls { - changed = true; - this.slash_command_call_removed(old_call, cx); - } - - if changed { - cx.emit(ConversationEvent::SlashCommandsChanged); - } - - this.slash_command_calls = new_calls; - }) - .ok(); - })); - } - - fn slash_command_is_unchanged( - &self, - old_call: &SlashCommandCall, - new_call: &SlashCommandLine, - new_text: &str, - buffer: &BufferSnapshot, - ) -> bool { - if old_call.name != new_text[new_call.name.clone()] { - return false; - } - - if old_call.argument.as_deref() != new_call.argument.clone().map(|range| &new_text[range]) { - return false; - } - - if old_call.should_rerun { - return false; - } - - if let Some(output_range) = &old_call.output_range { - let source_range = old_call.source_range.to_point(buffer); - let output_start = output_range.start.to_point(buffer); - if source_range.start.column != 0 { - return false; - } - if source_range.end.column != new_text.len() as u32 { - return false; - } - if output_start != Point::new(source_range.end.row + 1, 0) { - return false; - } - if let Some(next_char) = buffer.chars_at(output_range.end).next() { - if next_char != '\n' { - return false; - } - } - } - true - } - - fn slash_command_call_removed( - &self, - old_call: SlashCommandCall, - cx: &mut ModelContext, - ) { - if let Some(output_range) = old_call.output_range { - self.buffer.update(cx, |buffer, cx| { - buffer.edit( - [(old_call.source_range.end..output_range.end, "")], - None, - cx, - ); - }); - cx.emit(ConversationEvent::SlashCommandOutputRemoved( - old_call.source_range.end..output_range.end, - )) - } - } - - fn remaining_tokens(&self) -> Option { - Some(self.model.max_token_count() as isize - self.token_count? as isize) - } - - fn set_model(&mut self, model: LanguageModel, cx: &mut ModelContext) { - self.model = model; - self.count_remaining_tokens(cx); - } - - fn assist( - &mut self, - selected_messages: HashSet, - cx: &mut ModelContext, - ) -> Vec { - let mut user_messages = Vec::new(); - - let last_message_id = if let Some(last_message_id) = - self.message_anchors.iter().rev().find_map(|message| { - message - .start - .is_valid(self.buffer.read(cx)) - .then_some(message.id) - }) { - last_message_id - } else { - return Default::default(); - }; - - let mut should_assist = false; - for selected_message_id in selected_messages { - let selected_message_role = - if let Some(metadata) = self.messages_metadata.get(&selected_message_id) { - metadata.role - } else { - continue; - }; - - if selected_message_role == Role::Assistant { - if let Some(user_message) = self.insert_message_after( - selected_message_id, - Role::User, - MessageStatus::Done, - cx, - ) { - user_messages.push(user_message); - } - } else { - should_assist = true; - } - } - - if should_assist { - if !CompletionProvider::global(cx).is_authenticated() { - log::info!("completion provider has no credentials"); - return Default::default(); - } - - let request = self.to_completion_request(cx); - let stream = CompletionProvider::global(cx).complete(request); - let assistant_message = self - .insert_message_after(last_message_id, Role::Assistant, MessageStatus::Pending, cx) - .unwrap(); - - // Queue up the user's next reply. - let user_message = self - .insert_message_after(assistant_message.id, Role::User, MessageStatus::Done, cx) - .unwrap(); - user_messages.push(user_message); - - let task = cx.spawn({ - |this, mut cx| async move { - let assistant_message_id = assistant_message.id; - let mut response_latency = None; - let stream_completion = async { - let request_start = Instant::now(); - let mut messages = stream.await?; - - while let Some(message) = messages.next().await { - if response_latency.is_none() { - response_latency = Some(request_start.elapsed()); - } - let text = message?; - - this.update(&mut cx, |this, cx| { - let message_ix = this - .message_anchors - .iter() - .position(|message| message.id == assistant_message_id)?; - let message_range = this.buffer.update(cx, |buffer, cx| { - let message_start_offset = - this.message_anchors[message_ix].start.to_offset(buffer); - let message_old_end_offset = this.message_anchors - [message_ix + 1..] - .iter() - .find(|message| message.start.is_valid(buffer)) - .map_or(buffer.len(), |message| { - message.start.to_offset(buffer).saturating_sub(1) - }); - let message_new_end_offset = - message_old_end_offset + text.len(); - buffer.edit( - [(message_old_end_offset..message_old_end_offset, text)], - None, - cx, - ); - message_start_offset..message_new_end_offset - }); - this.reparse_edit_suggestions_in_range(message_range, cx); - cx.emit(ConversationEvent::StreamedCompletion); - - Some(()) - })?; - smol::future::yield_now().await; - } - - this.update(&mut cx, |this, cx| { - this.pending_completions - .retain(|completion| completion.id != this.completion_count); - this.summarize(cx); - })?; - - anyhow::Ok(()) - }; - - let result = stream_completion.await; - - this.update(&mut cx, |this, cx| { - if let Some(metadata) = - this.messages_metadata.get_mut(&assistant_message.id) - { - let error_message = result - .err() - .map(|error| error.to_string().trim().to_string()); - if let Some(error_message) = error_message.as_ref() { - metadata.status = - MessageStatus::Error(SharedString::from(error_message.clone())); - } else { - metadata.status = MessageStatus::Done; - } - - if let Some(telemetry) = this.telemetry.as_ref() { - telemetry.report_assistant_event( - this.id.clone(), - AssistantKind::Panel, - this.model.telemetry_id(), - response_latency, - error_message, - ); - } - - cx.emit(ConversationEvent::MessagesEdited); - } - }) - .ok(); - } - }); - - self.pending_completions.push(PendingCompletion { - id: post_inc(&mut self.completion_count), - _task: task, - }); - } - - user_messages - } - - fn to_completion_request(&self, cx: &mut ModelContext) -> LanguageModelRequest { - let edits_system_prompt = LanguageModelRequestMessage { - role: Role::System, - content: include_str!("./system_prompts/edits.md").to_string(), - }; - - let recent_buffers_context = self.ambient_context.recent_buffers.to_message(); - let current_project_context = self.ambient_context.current_project.to_message(); - - let messages = Some(edits_system_prompt) - .into_iter() - .chain(recent_buffers_context) - .chain(current_project_context) - .chain( - self.messages(cx) - .filter(|message| matches!(message.status, MessageStatus::Done)) - .map(|message| message.to_request_message(self.buffer.read(cx))), - ); - - LanguageModelRequest { - model: self.model.clone(), - messages: messages.collect(), - stop: vec![], - temperature: 1.0, - } - } - - fn cancel_last_assist(&mut self) -> bool { - self.pending_completions.pop().is_some() - } - - fn cycle_message_roles(&mut self, ids: HashSet, cx: &mut ModelContext) { - for id in ids { - if let Some(metadata) = self.messages_metadata.get_mut(&id) { - metadata.role.cycle(); - cx.emit(ConversationEvent::MessagesEdited); - cx.notify(); - } - } - } - - fn insert_message_after( - &mut self, - message_id: MessageId, - role: Role, - status: MessageStatus, - cx: &mut ModelContext, - ) -> Option { - if let Some(prev_message_ix) = self - .message_anchors - .iter() - .position(|message| message.id == message_id) - { - // Find the next valid message after the one we were given. - let mut next_message_ix = prev_message_ix + 1; - while let Some(next_message) = self.message_anchors.get(next_message_ix) { - if next_message.start.is_valid(self.buffer.read(cx)) { - break; - } - next_message_ix += 1; - } - - let start = self.buffer.update(cx, |buffer, cx| { - let offset = self - .message_anchors - .get(next_message_ix) - .map_or(buffer.len(), |message| message.start.to_offset(buffer) - 1); - buffer.edit([(offset..offset, "\n")], None, cx); - buffer.anchor_before(offset + 1) - }); - let message = MessageAnchor { - id: MessageId(post_inc(&mut self.next_message_id.0)), - start, - }; - self.message_anchors - .insert(next_message_ix, message.clone()); - self.messages_metadata.insert( - message.id, - MessageMetadata { - role, - status, - ambient_context: self.ambient_context.snapshot(), - }, - ); - cx.emit(ConversationEvent::MessagesEdited); - Some(message) - } else { - None - } - } - - fn split_message( - &mut self, - range: Range, - cx: &mut ModelContext, - ) -> (Option, Option) { - let start_message = self.message_for_offset(range.start, cx); - let end_message = self.message_for_offset(range.end, cx); - if let Some((start_message, end_message)) = start_message.zip(end_message) { - // Prevent splitting when range spans multiple messages. - if start_message.id != end_message.id { - return (None, None); - } - - let message = start_message; - let role = message.role; - let mut edited_buffer = false; - - let mut suffix_start = None; - if range.start > message.offset_range.start && range.end < message.offset_range.end - 1 - { - if self.buffer.read(cx).chars_at(range.end).next() == Some('\n') { - suffix_start = Some(range.end + 1); - } else if self.buffer.read(cx).reversed_chars_at(range.end).next() == Some('\n') { - suffix_start = Some(range.end); - } - } - - let suffix = if let Some(suffix_start) = suffix_start { - MessageAnchor { - id: MessageId(post_inc(&mut self.next_message_id.0)), - start: self.buffer.read(cx).anchor_before(suffix_start), - } - } else { - self.buffer.update(cx, |buffer, cx| { - buffer.edit([(range.end..range.end, "\n")], None, cx); - }); - edited_buffer = true; - MessageAnchor { - id: MessageId(post_inc(&mut self.next_message_id.0)), - start: self.buffer.read(cx).anchor_before(range.end + 1), - } - }; - - self.message_anchors - .insert(message.index_range.end + 1, suffix.clone()); - self.messages_metadata.insert( - suffix.id, - MessageMetadata { - role, - status: MessageStatus::Done, - ambient_context: message.ambient_context.clone(), - }, - ); - - let new_messages = - if range.start == range.end || range.start == message.offset_range.start { - (None, Some(suffix)) - } else { - let mut prefix_end = None; - if range.start > message.offset_range.start - && range.end < message.offset_range.end - 1 - { - if self.buffer.read(cx).chars_at(range.start).next() == Some('\n') { - prefix_end = Some(range.start + 1); - } else if self.buffer.read(cx).reversed_chars_at(range.start).next() - == Some('\n') - { - prefix_end = Some(range.start); - } - } - - let selection = if let Some(prefix_end) = prefix_end { - cx.emit(ConversationEvent::MessagesEdited); - MessageAnchor { - id: MessageId(post_inc(&mut self.next_message_id.0)), - start: self.buffer.read(cx).anchor_before(prefix_end), - } - } else { - self.buffer.update(cx, |buffer, cx| { - buffer.edit([(range.start..range.start, "\n")], None, cx) - }); - edited_buffer = true; - MessageAnchor { - id: MessageId(post_inc(&mut self.next_message_id.0)), - start: self.buffer.read(cx).anchor_before(range.end + 1), - } - }; - - self.message_anchors - .insert(message.index_range.end + 1, selection.clone()); - self.messages_metadata.insert( - selection.id, - MessageMetadata { - role, - status: MessageStatus::Done, - ambient_context: message.ambient_context, - }, - ); - (Some(selection), Some(suffix)) - }; - - if !edited_buffer { - cx.emit(ConversationEvent::MessagesEdited); - } - new_messages - } else { - (None, None) - } - } - - fn summarize(&mut self, cx: &mut ModelContext) { - if self.message_anchors.len() >= 2 && self.summary.is_none() { - if !CompletionProvider::global(cx).is_authenticated() { - return; - } - - let messages = self - .messages(cx) - .take(2) - .map(|message| message.to_request_message(self.buffer.read(cx))) - .chain(Some(LanguageModelRequestMessage { - role: Role::User, - content: "Summarize the conversation into a short title without punctuation" - .into(), - })); - let request = LanguageModelRequest { - model: self.model.clone(), - messages: messages.collect(), - stop: vec![], - temperature: 1.0, - }; - - let stream = CompletionProvider::global(cx).complete(request); - self.pending_summary = cx.spawn(|this, mut cx| { - async move { - let mut messages = stream.await?; - - while let Some(message) = messages.next().await { - let text = message?; - this.update(&mut cx, |this, cx| { - this.summary - .get_or_insert(Default::default()) - .text - .push_str(&text); - cx.emit(ConversationEvent::SummaryChanged); - })?; - } - - this.update(&mut cx, |this, cx| { - if let Some(summary) = this.summary.as_mut() { - summary.done = true; - cx.emit(ConversationEvent::SummaryChanged); - } - })?; - - anyhow::Ok(()) - } - .log_err() - }); - } - } - - fn message_for_offset(&self, offset: usize, cx: &AppContext) -> Option { - self.messages_for_offsets([offset], cx).pop() - } - - fn messages_for_offsets( - &self, - offsets: impl IntoIterator, - cx: &AppContext, - ) -> Vec { - let mut result = Vec::new(); - - let mut messages = self.messages(cx).peekable(); - let mut offsets = offsets.into_iter().peekable(); - let mut current_message = messages.next(); - while let Some(offset) = offsets.next() { - // Locate the message that contains the offset. - while current_message.as_ref().map_or(false, |message| { - !message.offset_range.contains(&offset) && messages.peek().is_some() - }) { - current_message = messages.next(); - } - let Some(message) = current_message.as_ref() else { - break; - }; - - // Skip offsets that are in the same message. - while offsets.peek().map_or(false, |offset| { - message.offset_range.contains(offset) || messages.peek().is_none() - }) { - offsets.next(); - } - - result.push(message.clone()); - } - result - } - - fn messages<'a>(&'a self, cx: &'a AppContext) -> impl 'a + Iterator { - let buffer = self.buffer.read(cx); - let mut slash_command_calls = self - .slash_command_calls - .iter() - .map(|call| { - if let Some(output) = &call.output_range { - call.source_range.start.to_offset(buffer)..output.start.to_offset(buffer) - } else { - call.source_range.to_offset(buffer) - } - }) - .peekable(); - let mut message_anchors = self.message_anchors.iter().enumerate().peekable(); - iter::from_fn(move || { - if let Some((start_ix, message_anchor)) = message_anchors.next() { - let metadata = self.messages_metadata.get(&message_anchor.id)?; - let message_start = message_anchor.start.to_offset(buffer); - let mut message_end = None; - let mut end_ix = start_ix; - while let Some((_, next_message)) = message_anchors.peek() { - if next_message.start.is_valid(buffer) { - message_end = Some(next_message.start); - break; - } else { - end_ix += 1; - message_anchors.next(); - } - } - let message_end = message_end - .unwrap_or(language::Anchor::MAX) - .to_offset(buffer); - - let mut slash_command_ranges = Vec::new(); - while let Some(call_range) = slash_command_calls.peek() { - if call_range.end <= message_end { - slash_command_ranges.push(slash_command_calls.next().unwrap()); - } else { - break; - } - } - - return Some(Message { - index_range: start_ix..end_ix, - offset_range: message_start..message_end, - id: message_anchor.id, - anchor: message_anchor.start, - role: metadata.role, - status: metadata.status.clone(), - slash_command_ranges, - ambient_context: metadata.ambient_context.clone(), - }); - } - None - }) + fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { + Some("Assistant Panel") } - fn save( - &mut self, - debounce: Option, - fs: Arc, - cx: &mut ModelContext, - ) { - self.pending_save = cx.spawn(|this, mut cx| async move { - if let Some(debounce) = debounce { - cx.background_executor().timer(debounce).await; - } - - let (old_path, summary) = this.read_with(&cx, |this, _| { - let path = this.path.clone(); - let summary = if let Some(summary) = this.summary.as_ref() { - if summary.done { - Some(summary.text.clone()) - } else { - None - } - } else { - None - }; - (path, summary) - })?; - - if let Some(summary) = summary { - let conversation = this.read_with(&cx, |this, cx| this.serialize(cx))?; - let path = if let Some(old_path) = old_path { - old_path - } else { - let mut discriminant = 1; - let mut new_path; - loop { - new_path = CONVERSATIONS_DIR.join(&format!( - "{} - {}.zed.json", - summary.trim(), - discriminant - )); - if fs.is_file(&new_path).await { - discriminant += 1; - } else { - break; - } - } - new_path - }; - - fs.create_dir(CONVERSATIONS_DIR.as_ref()).await?; - fs.atomic_write(path.clone(), serde_json::to_string(&conversation).unwrap()) - .await?; - this.update(&mut cx, |this, _| this.path = Some(path))?; - } - - Ok(()) - }); + fn toggle_action(&self) -> Box { + Box::new(ToggleFocus) } } -#[derive(Debug)] -enum EditParsingState { - None, - InOldText { - path: PathBuf, - start_offset: usize, - old_text_start_offset: usize, - }, - InNewText { - path: PathBuf, - start_offset: usize, - old_text_range: Range, - new_text_start_offset: usize, - }, -} - -#[derive(Clone, Debug, PartialEq)] -struct EditSuggestion { - source_range: Range, - full_path: PathBuf, -} - -struct ParsedEditSuggestion { - path: PathBuf, - outer_range: Range, - old_text_range: Range, - new_text_range: Range, -} +impl EventEmitter for AssistantPanel {} +impl EventEmitter for AssistantPanel {} -fn parse_next_edit_suggestion(lines: &mut rope::Lines) -> Option { - let mut state = EditParsingState::None; - loop { - let offset = lines.offset(); - let message_line = lines.next()?; - match state { - EditParsingState::None => { - if let Some(rest) = message_line.strip_prefix("```edit ") { - let path = rest.trim(); - if !path.is_empty() { - state = EditParsingState::InOldText { - path: PathBuf::from(path), - start_offset: offset, - old_text_start_offset: lines.offset(), - }; - } - } - } - EditParsingState::InOldText { - path, - start_offset, - old_text_start_offset, - } => { - if message_line == "---" { - state = EditParsingState::InNewText { - path, - start_offset, - old_text_range: old_text_start_offset..offset, - new_text_start_offset: lines.offset(), - }; - } else { - state = EditParsingState::InOldText { - path, - start_offset, - old_text_start_offset, - }; - } - } - EditParsingState::InNewText { - path, - start_offset, - old_text_range, - new_text_start_offset, - } => { - if message_line == "```" { - return Some(ParsedEditSuggestion { - path, - outer_range: start_offset..offset + "```".len(), - old_text_range, - new_text_range: new_text_start_offset..offset, - }); - } else { - state = EditParsingState::InNewText { - path, - start_offset, - old_text_range, - new_text_start_offset, - }; - } - } - } +impl FocusableView for AssistantPanel { + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + self.pane.focus_handle(cx) } } -struct SlashCommandCall { - source_range: Range, - output_range: Option>, - name: String, - argument: Option, - should_rerun: bool, - _invalidate: Task<()>, - _command_cleanup: SlashCommandCleanup, -} - -struct PendingCompletion { - id: usize, - _task: Task<()>, -} - -enum ConversationEditorEvent { +pub enum ContextEditorEvent { + Edited, TabContentChanged, } @@ -2727,112 +976,147 @@ struct ScrollPosition { cursor: Anchor, } -struct ConversationEditor { - conversation: Model, +struct ActiveEditStep { + start: language::Anchor, + assist_ids: Vec, + editor: Option>, + _open_editor: Task>, +} + +pub struct ContextEditor { + context: Model, fs: Arc, workspace: WeakView, + project: Model, + lsp_adapter_delegate: Option>, editor: View, - flap_ids: HashMap, FlapId>, - blocks: HashSet, + blocks: HashSet, scroll_position: Option, + remote_id: Option, + pending_slash_command_creases: HashMap, CreaseId>, + pending_slash_command_blocks: HashMap, CustomBlockId>, _subscriptions: Vec, + active_edit_step: Option, + assistant_panel: WeakView, } -impl ConversationEditor { - fn new( - model: LanguageModel, - language_registry: Arc, - slash_command_registry: Arc, - fs: Arc, - workspace: View, - cx: &mut ViewContext, - ) -> Self { - let telemetry = workspace.read(cx).client().telemetry().clone(); - let project = workspace.read(cx).project().clone(); - let lsp_adapter_delegate = make_lsp_adapter_delegate(&project, cx); - - let conversation = cx.new_model(|cx| { - Conversation::new( - model, - language_registry, - slash_command_registry, - Some(telemetry), - Some(lsp_adapter_delegate), - cx, - ) - }); - Self::for_conversation(conversation, fs, workspace, cx) - } +impl ContextEditor { + const MAX_TAB_TITLE_LEN: usize = 16; - fn for_conversation( - conversation: Model, + fn for_context( + context: Model, fs: Arc, workspace: View, + project: Model, + lsp_adapter_delegate: Option>, + assistant_panel: WeakView, cx: &mut ViewContext, ) -> Self { - let command_registry = conversation.read(cx).slash_command_registry.clone(); - let completion_provider = SlashCommandCompletionProvider::new(command_registry); + let completion_provider = SlashCommandCompletionProvider::new( + Some(cx.view().downgrade()), + Some(workspace.downgrade()), + ); let editor = cx.new_view(|cx| { - let mut editor = Editor::for_buffer(conversation.read(cx).buffer.clone(), None, cx); + let mut editor = Editor::for_buffer(context.read(cx).buffer().clone(), None, cx); editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); editor.set_show_line_numbers(false, cx); editor.set_show_git_diff_gutter(false, cx); editor.set_show_code_actions(false, cx); + editor.set_show_runnables(false, cx); editor.set_show_wrap_guides(false, cx); editor.set_show_indent_guides(false, cx); editor.set_completion_provider(Box::new(completion_provider)); + editor.set_collaboration_hub(Box::new(project.clone())); editor }); let _subscriptions = vec![ - cx.observe(&conversation, |_, _, cx| cx.notify()), - cx.subscribe(&conversation, Self::handle_conversation_event), + cx.observe(&context, |_, _, cx| cx.notify()), + cx.subscribe(&context, Self::handle_context_event), cx.subscribe(&editor, Self::handle_editor_event), - cx.subscribe(&workspace, Self::handle_workspace_event), + cx.subscribe(&editor, Self::handle_editor_search_event), ]; + let sections = context.read(cx).slash_command_output_sections().to_vec(); let mut this = Self { - conversation, + context, editor, + lsp_adapter_delegate, blocks: Default::default(), scroll_position: None, - flap_ids: Default::default(), + remote_id: None, fs, workspace: workspace.downgrade(), + project, + pending_slash_command_creases: HashMap::default(), + pending_slash_command_blocks: HashMap::default(), _subscriptions, + active_edit_step: None, + assistant_panel, }; - this.update_recent_editors(cx); this.update_message_headers(cx); + this.insert_slash_command_output_sections(sections, cx); this } + fn insert_default_prompt(&mut self, cx: &mut ViewContext) { + let command_name = DefaultSlashCommand.name(); + self.editor.update(cx, |editor, cx| { + editor.insert(&format!("/{command_name}"), cx) + }); + self.split(&Split, cx); + let command = self.context.update(cx, |context, cx| { + let first_message_id = context.messages(cx).next().unwrap().id; + context.update_metadata(first_message_id, cx, |metadata| { + metadata.role = Role::System; + }); + context.reparse_slash_commands(cx); + context.pending_slash_commands()[0].clone() + }); + + self.run_command( + command.source_range, + &command.name, + command.argument.as_deref(), + false, + self.workspace.clone(), + cx, + ); + } + fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { - let cursors = self.cursors(cx); + if !self.apply_edit_step(cx) { + self.send_to_model(cx); + } + } - let user_messages = self.conversation.update(cx, |conversation, cx| { - let selected_messages = conversation - .messages_for_offsets(cursors, cx) - .into_iter() - .map(|message| message.id) - .collect(); - conversation.assist(selected_messages, cx) - }); - let new_selections = user_messages - .iter() - .map(|message| { - let cursor = message + fn apply_edit_step(&mut self, cx: &mut ViewContext) -> bool { + if let Some(step) = self.active_edit_step.as_ref() { + InlineAssistant::update_global(cx, |assistant, cx| { + for assist_id in &step.assist_ids { + assistant.start_assist(*assist_id, cx); + } + !step.assist_ids.is_empty() + }) + } else { + false + } + } + + fn send_to_model(&mut self, cx: &mut ViewContext) { + if let Some(user_message) = self.context.update(cx, |context, cx| context.assist(cx)) { + let new_selection = { + let cursor = user_message .start - .to_offset(self.conversation.read(cx).buffer.read(cx)); + .to_offset(self.context.read(cx).buffer().read(cx)); cursor..cursor - }) - .collect::>(); - if !new_selections.is_empty() { + }; self.editor.update(cx, |editor, cx| { editor.change_selections( Some(Autoscroll::Strategy(AutoscrollStrategy::Fit)), cx, - |selections| selections.select_ranges(new_selections), + |selections| selections.select_ranges([new_selection]), ); }); // Avoid scrolling to the new cursor position so the assistant's output is stable. @@ -2842,87 +1126,199 @@ impl ConversationEditor { fn cancel_last_assist(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext) { if !self - .conversation - .update(cx, |conversation, _| conversation.cancel_last_assist()) + .context + .update(cx, |context, _| context.cancel_last_assist()) { cx.propagate(); } } + fn debug_edit_steps(&mut self, _: &DebugEditSteps, cx: &mut ViewContext) { + let mut output = String::new(); + for (i, step) in self.context.read(cx).edit_steps().iter().enumerate() { + output.push_str(&format!("Step {}:\n", i + 1)); + output.push_str(&format!( + "Content: {}\n", + self.context + .read(cx) + .buffer() + .read(cx) + .text_for_range(step.source_range.clone()) + .collect::() + )); + match &step.operations { + Some(EditStepOperations::Parsed { + operations, + raw_output, + }) => { + output.push_str(&format!("Raw Output:\n{raw_output}\n")); + output.push_str("Parsed Operations:\n"); + for op in operations { + output.push_str(&format!(" {:?}\n", op)); + } + } + Some(EditStepOperations::Pending(_)) => { + output.push_str("Operations: Pending\n"); + } + None => { + output.push_str("Operations: None\n"); + } + } + output.push('\n'); + } + + let editor = self + .workspace + .update(cx, |workspace, cx| Editor::new_in_workspace(workspace, cx)); + + if let Ok(editor) = editor { + cx.spawn(|_, mut cx| async move { + let editor = editor.await?; + editor.update(&mut cx, |editor, cx| editor.set_text(output, cx)) + }) + .detach_and_notify_err(cx); + } + } + fn cycle_message_role(&mut self, _: &CycleMessageRole, cx: &mut ViewContext) { let cursors = self.cursors(cx); - self.conversation.update(cx, |conversation, cx| { - let messages = conversation + self.context.update(cx, |context, cx| { + let messages = context .messages_for_offsets(cursors, cx) .into_iter() .map(|message| message.id) .collect(); - conversation.cycle_message_roles(messages, cx) + context.cycle_message_roles(messages, cx) + }); + } + + fn cursors(&self, cx: &AppContext) -> Vec { + let selections = self.editor.read(cx).selections.all::(cx); + selections + .into_iter() + .map(|selection| selection.head()) + .collect() + } + + fn insert_command(&mut self, name: &str, cx: &mut ViewContext) { + if let Some(command) = SlashCommandRegistry::global(cx).command(name) { + self.editor.update(cx, |editor, cx| { + editor.transact(cx, |editor, cx| { + editor.change_selections(Some(Autoscroll::fit()), cx, |s| s.try_cancel()); + let snapshot = editor.buffer().read(cx).snapshot(cx); + let newest_cursor = editor.selections.newest::(cx).head(); + if newest_cursor.column > 0 + || snapshot + .chars_at(newest_cursor) + .next() + .map_or(false, |ch| ch != '\n') + { + editor.move_to_end_of_line( + &MoveToEndOfLine { + stop_at_soft_wraps: false, + }, + cx, + ); + editor.newline(&Newline, cx); + } + + editor.insert(&format!("/{name}"), cx); + if command.requires_argument() { + editor.insert(" ", cx); + editor.show_completions(&ShowCompletions::default(), cx); + } + }); + }); + if !command.requires_argument() { + self.confirm_command(&ConfirmCommand, cx); + } + } + } + + pub fn confirm_command(&mut self, _: &ConfirmCommand, cx: &mut ViewContext) { + let selections = self.editor.read(cx).selections.disjoint_anchors(); + let mut commands_by_range = HashMap::default(); + let workspace = self.workspace.clone(); + self.context.update(cx, |context, cx| { + context.reparse_slash_commands(cx); + for selection in selections.iter() { + if let Some(command) = + context.pending_command_for_position(selection.head().text_anchor, cx) + { + commands_by_range + .entry(command.source_range.clone()) + .or_insert_with(|| command.clone()); + } + } }); + + if commands_by_range.is_empty() { + cx.propagate(); + } else { + for command in commands_by_range.into_values() { + self.run_command( + command.source_range, + &command.name, + command.argument.as_deref(), + true, + workspace.clone(), + cx, + ); + } + cx.stop_propagation(); + } } - fn cursors(&self, cx: &AppContext) -> Vec { - let selections = self.editor.read(cx).selections.all::(cx); - selections - .into_iter() - .map(|selection| selection.head()) - .collect() + pub fn run_command( + &mut self, + command_range: Range, + name: &str, + argument: Option<&str>, + insert_trailing_newline: bool, + workspace: WeakView, + cx: &mut ViewContext, + ) { + if let Some(command) = SlashCommandRegistry::global(cx).command(name) { + if let Some(lsp_adapter_delegate) = self.lsp_adapter_delegate.clone() { + let argument = argument.map(ToString::to_string); + let output = command.run(argument.as_deref(), workspace, lsp_adapter_delegate, cx); + self.context.update(cx, |context, cx| { + context.insert_command_output( + command_range, + output, + insert_trailing_newline, + cx, + ) + }); + } + } } - fn handle_conversation_event( + fn handle_context_event( &mut self, - _: Model, - event: &ConversationEvent, + _: Model, + event: &ContextEvent, cx: &mut ViewContext, ) { + let context_editor = cx.view().downgrade(); + match event { - ConversationEvent::MessagesEdited => { + ContextEvent::MessagesEdited => { self.update_message_headers(cx); - self.conversation.update(cx, |conversation, cx| { - conversation.save(Some(Duration::from_millis(500)), self.fs.clone(), cx); + self.context.update(cx, |context, cx| { + context.save(Some(Duration::from_millis(500)), self.fs.clone(), cx); }); } - ConversationEvent::EditSuggestionsChanged => { - self.editor.update(cx, |editor, cx| { - let buffer = editor.buffer().read(cx).snapshot(cx); - let excerpt_id = *buffer.as_singleton().unwrap().0; - let conversation = self.conversation.read(cx); - let highlighted_rows = conversation - .edit_suggestions - .iter() - .map(|suggestion| { - let start = buffer - .anchor_in_excerpt(excerpt_id, suggestion.source_range.start) - .unwrap(); - let end = buffer - .anchor_in_excerpt(excerpt_id, suggestion.source_range.end) - .unwrap(); - start..=end - }) - .collect::>(); - - editor.clear_row_highlights::(); - for range in highlighted_rows { - editor.highlight_rows::( - range, - Some( - cx.theme() - .colors() - .editor_document_highlight_read_background, - ), - false, - cx, - ); - } - }); + ContextEvent::EditStepsChanged => { + cx.notify(); } - ConversationEvent::SummaryChanged => { - cx.emit(ConversationEditorEvent::TabContentChanged); - self.conversation.update(cx, |conversation, cx| { - conversation.save(None, self.fs.clone(), cx); + ContextEvent::SummaryChanged => { + cx.emit(EditorEvent::TitleChanged); + self.context.update(cx, |context, cx| { + context.save(None, self.fs.clone(), cx); }); } - ConversationEvent::StreamedCompletion => { + ContextEvent::StreamedCompletion => { self.editor.update(cx, |editor, cx| { if let Some(scroll_position) = self.scroll_position { let snapshot = editor.snapshot(cx); @@ -2936,75 +1332,228 @@ impl ConversationEditor { } }); } - ConversationEvent::SlashCommandsChanged => { + ContextEvent::PendingSlashCommandsUpdated { removed, updated } => { self.editor.update(cx, |editor, cx| { let buffer = editor.buffer().read(cx).snapshot(cx); - let excerpt_id = *buffer.as_singleton().unwrap().0; - let conversation = self.conversation.read(cx); - let colors = cx.theme().colors(); - let highlighted_rows = conversation - .slash_command_calls - .iter() - .map(|call| { - let start = call.source_range.start; - let end = if let Some(output) = &call.output_range { - output.end - } else { - call.source_range.end + let (excerpt_id, buffer_id, _) = buffer.as_singleton().unwrap(); + let excerpt_id = *excerpt_id; + + editor.remove_creases( + removed + .iter() + .filter_map(|range| self.pending_slash_command_creases.remove(range)), + cx, + ); + + editor.remove_blocks( + HashSet::from_iter( + removed.iter().filter_map(|range| { + self.pending_slash_command_blocks.remove(range) + }), + ), + None, + cx, + ); + + let crease_ids = editor.insert_creases( + updated.iter().map(|command| { + let workspace = self.workspace.clone(); + let confirm_command = Arc::new({ + let context_editor = context_editor.clone(); + let command = command.clone(); + move |cx: &mut WindowContext| { + context_editor + .update(cx, |context_editor, cx| { + context_editor.run_command( + command.source_range.clone(), + &command.name, + command.argument.as_deref(), + false, + workspace.clone(), + cx, + ); + }) + .ok(); + } + }); + let placeholder = FoldPlaceholder { + render: Arc::new(move |_, _, _| Empty.into_any()), + constrain_width: false, + merge_adjacent: false, }; - let start = buffer.anchor_in_excerpt(excerpt_id, start).unwrap(); - let end = buffer.anchor_in_excerpt(excerpt_id, end).unwrap(); - ( - start..=end, - Some(colors.editor_document_highlight_read_background), - ) - }) - .collect::>(); + let render_toggle = { + let confirm_command = confirm_command.clone(); + let command = command.clone(); + move |row, _, _, _cx: &mut WindowContext| { + render_pending_slash_command_gutter_decoration( + row, + &command.status, + confirm_command.clone(), + ) + } + }; + let render_trailer = { + let command = command.clone(); + move |row, _unfold, cx: &mut WindowContext| { + // TODO: In the future we should investigate how we can expose + // this as a hook on the `SlashCommand` trait so that we don't + // need to special-case it here. + if command.name == DocsSlashCommand::NAME { + return render_docs_slash_command_trailer( + row, + command.clone(), + cx, + ); + } - editor.clear_row_highlights::(); - for (range, color) in highlighted_rows { - editor.highlight_rows::(range, color, false, cx); - } - }); - } - ConversationEvent::SlashCommandOutputAdded(range) => { - self.editor.update(cx, |editor, cx| { - let buffer = editor.buffer().read(cx).snapshot(cx); - let excerpt_id = *buffer.as_singleton().unwrap().0; - let start = buffer.anchor_in_excerpt(excerpt_id, range.start).unwrap(); - let end = buffer.anchor_in_excerpt(excerpt_id, range.end).unwrap(); - let buffer_row = MultiBufferRow(start.to_point(&buffer).row); - - let flap_id = editor - .insert_flaps( - [Flap::new( - start..end, - FoldPlaceholder { - render: Arc::new(|_, _, _| Empty.into_any()), - constrain_width: false, + Empty.into_any() + } + }; + + let start = buffer + .anchor_in_excerpt(excerpt_id, command.source_range.start) + .unwrap(); + let end = buffer + .anchor_in_excerpt(excerpt_id, command.source_range.end) + .unwrap(); + Crease::new(start..end, placeholder, render_toggle, render_trailer) + }), + cx, + ); + + let block_ids = editor.insert_blocks( + updated + .iter() + .filter_map(|command| match &command.status { + PendingSlashCommandStatus::Error(error) => { + Some((command, error.clone())) + } + _ => None, + }) + .map(|(command, error_message)| BlockProperties { + style: BlockStyle::Fixed, + position: Anchor { + buffer_id: Some(buffer_id), + excerpt_id, + text_anchor: command.source_range.start, }, - render_slash_command_output_toggle, - render_slash_command_output_trailer, - )], - cx, - ) - .into_iter() - .next() - .unwrap(); - self.flap_ids.insert(range.clone(), flap_id); - editor.fold_at(&FoldAt { buffer_row }, cx); - }); + height: 1, + disposition: BlockDisposition::Below, + render: slash_command_error_block_renderer(error_message), + }), + None, + cx, + ); + + self.pending_slash_command_creases.extend( + updated + .iter() + .map(|command| command.source_range.clone()) + .zip(crease_ids), + ); + + self.pending_slash_command_blocks.extend( + updated + .iter() + .map(|command| command.source_range.clone()) + .zip(block_ids), + ); + }) } - ConversationEvent::SlashCommandOutputRemoved(range) => { - if let Some(flap_id) = self.flap_ids.remove(range) { - self.editor.update(cx, |editor, cx| { - editor.remove_flaps([flap_id], cx); + ContextEvent::SlashCommandFinished { + output_range, + sections, + run_commands_in_output, + } => { + self.insert_slash_command_output_sections(sections.iter().cloned(), cx); + + if *run_commands_in_output { + let commands = self.context.update(cx, |context, cx| { + context.reparse_slash_commands(cx); + context + .pending_commands_for_range(output_range.clone(), cx) + .to_vec() }); + + for command in commands { + self.run_command( + command.source_range, + &command.name, + command.argument.as_deref(), + false, + self.workspace.clone(), + cx, + ); + } } } + ContextEvent::Operation(_) => {} } } + fn insert_slash_command_output_sections( + &mut self, + sections: impl IntoIterator>, + cx: &mut ViewContext, + ) { + self.editor.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx).snapshot(cx); + let excerpt_id = *buffer.as_singleton().unwrap().0; + let mut buffer_rows_to_fold = BTreeSet::new(); + let mut creases = Vec::new(); + for section in sections { + let start = buffer + .anchor_in_excerpt(excerpt_id, section.range.start) + .unwrap(); + let end = buffer + .anchor_in_excerpt(excerpt_id, section.range.end) + .unwrap(); + let buffer_row = MultiBufferRow(start.to_point(&buffer).row); + buffer_rows_to_fold.insert(buffer_row); + creases.push(Crease::new( + start..end, + FoldPlaceholder { + render: Arc::new({ + let editor = cx.view().downgrade(); + let icon = section.icon; + let label = section.label.clone(); + move |fold_id, fold_range, _cx| { + let editor = editor.clone(); + ButtonLike::new(fold_id) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ElevatedSurface) + .child(Icon::new(icon)) + .child(Label::new(label.clone()).single_line()) + .on_click(move |_, cx| { + editor + .update(cx, |editor, cx| { + let buffer_start = fold_range + .start + .to_point(&editor.buffer().read(cx).read(cx)); + let buffer_row = MultiBufferRow(buffer_start.row); + editor.unfold_at(&UnfoldAt { buffer_row }, cx); + }) + .ok(); + }) + .into_any_element() + } + }), + constrain_width: false, + merge_adjacent: false, + }, + render_slash_command_output_toggle, + |_, _, _| Empty.into_any_element(), + )); + } + + editor.insert_creases(creases, cx); + + for buffer_row in buffer_rows_to_fold.into_iter().rev() { + editor.fold_at(&FoldAt { buffer_row }, cx); + } + }); + } + fn handle_editor_event( &mut self, _: View, @@ -3022,65 +1571,207 @@ impl ConversationEditor { } EditorEvent::SelectionsChanged { .. } => { self.scroll_position = self.cursor_scroll_position(cx); + if self + .edit_step_for_cursor(cx) + .map(|step| step.source_range.start) + != self.active_edit_step.as_ref().map(|step| step.start) + { + if let Some(old_active_edit_step) = self.active_edit_step.take() { + if let Some(editor) = old_active_edit_step + .editor + .and_then(|editor| editor.upgrade()) + { + self.workspace + .update(cx, |workspace, cx| { + if let Some(pane) = workspace.pane_for(&editor) { + pane.update(cx, |pane, cx| { + let item_id = editor.entity_id(); + if pane.is_active_preview_item(item_id) { + pane.close_item_by_id( + item_id, + SaveIntent::Skip, + cx, + ) + .detach_and_log_err(cx); + } + }); + } + }) + .ok(); + } + } + + if let Some(new_active_step) = self.edit_step_for_cursor(cx) { + let suggestions = new_active_step.edit_suggestions(&self.project, cx); + self.active_edit_step = Some(ActiveEditStep { + start: new_active_step.source_range.start, + assist_ids: Vec::new(), + editor: None, + _open_editor: self.open_editor_for_edit_suggestions(suggestions, cx), + }); + } + } } _ => {} } + cx.emit(event.clone()); } - fn handle_workspace_event( + fn open_editor_for_edit_suggestions( &mut self, - _: View, - event: &WorkspaceEvent, + edit_suggestions: Task, Vec>>, cx: &mut ViewContext, - ) { - match event { - WorkspaceEvent::ActiveItemChanged - | WorkspaceEvent::ItemAdded - | WorkspaceEvent::ItemRemoved - | WorkspaceEvent::PaneAdded(_) - | WorkspaceEvent::PaneRemoved => self.update_recent_editors(cx), - _ => {} - } - } + ) -> Task> { + let workspace = self.workspace.clone(); + let project = self.project.clone(); + let assistant_panel = self.assistant_panel.clone(); + cx.spawn(|this, mut cx| async move { + let edit_suggestions = edit_suggestions.await; - fn update_recent_editors(&mut self, cx: &mut ViewContext) { - let Some(workspace) = self.workspace.upgrade() else { - return; - }; + let mut assist_ids = Vec::new(); + let editor = if edit_suggestions.is_empty() { + return Ok(()); + } else if edit_suggestions.len() == 1 + && edit_suggestions.values().next().unwrap().len() == 1 + { + // If there's only one buffer and one suggestion group, open it directly + let (buffer, suggestion_groups) = edit_suggestions.into_iter().next().unwrap(); + let suggestion_group = suggestion_groups.into_iter().next().unwrap(); + let editor = workspace.update(&mut cx, |workspace, cx| { + let active_pane = workspace.active_pane().clone(); + workspace.open_project_item::(active_pane, buffer, false, false, cx) + })?; - let mut timestamps_by_entity_id = HashMap::default(); - for pane in workspace.read(cx).panes() { - let pane = pane.read(cx); - for entry in pane.activation_history() { - timestamps_by_entity_id.insert(entry.entity_id, entry.timestamp); - } - } + cx.update(|cx| { + for suggestion in suggestion_group.suggestions { + let description = suggestion.description.unwrap_or_else(|| "Delete".into()); + let range = { + let buffer = editor.read(cx).buffer().read(cx).read(cx); + let (&excerpt_id, _, _) = buffer.as_singleton().unwrap(); + buffer + .anchor_in_excerpt(excerpt_id, suggestion.range.start) + .unwrap() + ..buffer + .anchor_in_excerpt(excerpt_id, suggestion.range.end) + .unwrap() + }; + let initial_text = suggestion.prepend_newline.then(|| "\n".into()); + InlineAssistant::update_global(cx, |assistant, cx| { + assist_ids.push(assistant.suggest_assist( + &editor, + range, + description, + initial_text, + Some(workspace.clone()), + assistant_panel.upgrade().as_ref(), + cx, + )); + }); + } - let mut timestamps_by_buffer = HashMap::default(); - for editor in workspace.read(cx).items_of_type::(cx) { - let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else { - continue; - }; + // Scroll the editor to the suggested assist + editor.update(cx, |editor, cx| { + let anchor = { + let buffer = editor.buffer().read(cx).read(cx); + let (&excerpt_id, _, _) = buffer.as_singleton().unwrap(); + buffer + .anchor_in_excerpt(excerpt_id, suggestion_group.context_range.start) + .unwrap() + }; - let new_timestamp = timestamps_by_entity_id - .get(&editor.entity_id()) - .copied() - .unwrap_or_default(); - let timestamp = timestamps_by_buffer.entry(buffer).or_insert(new_timestamp); - *timestamp = cmp::max(*timestamp, new_timestamp); - } + editor.set_scroll_anchor( + ScrollAnchor { + offset: gpui::Point::default(), + anchor, + }, + cx, + ); + }); + })?; - let mut recent_buffers = timestamps_by_buffer.into_iter().collect::>(); - recent_buffers.sort_unstable_by_key(|(_, timestamp)| *timestamp); - if recent_buffers.len() > MAX_RECENT_BUFFERS { - let excess = recent_buffers.len() - MAX_RECENT_BUFFERS; - recent_buffers.drain(..excess); - } + editor + } else { + // If there are multiple buffers or suggestion groups, create a multibuffer + let mut inline_assist_suggestions = Vec::new(); + let multibuffer = cx.new_model(|cx| { + let replica_id = project.read(cx).replica_id(); + let mut multibuffer = MultiBuffer::new(replica_id, Capability::ReadWrite); + for (buffer, suggestion_groups) in edit_suggestions { + let excerpt_ids = multibuffer.push_excerpts( + buffer, + suggestion_groups + .iter() + .map(|suggestion_group| ExcerptRange { + context: suggestion_group.context_range.clone(), + primary: None, + }), + cx, + ); - self.conversation.update(cx, |conversation, cx| { - conversation - .set_recent_buffers(recent_buffers.into_iter().map(|(buffer, _)| buffer), cx); - }); + for (excerpt_id, suggestion_group) in + excerpt_ids.into_iter().zip(suggestion_groups) + { + for suggestion in suggestion_group.suggestions { + let description = + suggestion.description.unwrap_or_else(|| "Delete".into()); + let range = { + let multibuffer = multibuffer.read(cx); + multibuffer + .anchor_in_excerpt(excerpt_id, suggestion.range.start) + .unwrap() + ..multibuffer + .anchor_in_excerpt(excerpt_id, suggestion.range.end) + .unwrap() + }; + let initial_text = + suggestion.prepend_newline.then(|| "\n".to_string()); + inline_assist_suggestions.push((range, description, initial_text)); + } + } + } + multibuffer + })?; + + let editor = cx + .new_view(|cx| Editor::for_multibuffer(multibuffer, Some(project), true, cx))?; + cx.update(|cx| { + InlineAssistant::update_global(cx, |assistant, cx| { + for (range, description, initial_text) in inline_assist_suggestions { + assist_ids.push(assistant.suggest_assist( + &editor, + range, + description, + initial_text, + Some(workspace.clone()), + assistant_panel.upgrade().as_ref(), + cx, + )); + } + }) + })?; + workspace.update(&mut cx, |workspace, cx| { + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, false, cx) + })?; + + editor + }; + + this.update(&mut cx, |this, _cx| { + if let Some(step) = this.active_edit_step.as_mut() { + step.assist_ids = assist_ids; + step.editor = Some(editor.downgrade()); + } + }) + }) + } + + fn handle_editor_search_event( + &mut self, + _: View, + event: &SearchEvent, + cx: &mut ViewContext, + ) { + cx.emit(event.clone()); } fn cursor_scroll_position(&self, cx: &mut ViewContext) -> Option { @@ -3109,30 +1800,22 @@ impl ConversationEditor { } fn update_message_headers(&mut self, cx: &mut ViewContext) { - let project = self - .workspace - .update(cx, |workspace, _cx| workspace.project().downgrade()) - .unwrap(); - self.editor.update(cx, |editor, cx| { let buffer = editor.buffer().read(cx).snapshot(cx); let excerpt_id = *buffer.as_singleton().unwrap().0; let old_blocks = std::mem::take(&mut self.blocks); let new_blocks = self - .conversation + .context .read(cx) .messages(cx) - .enumerate() - .map(|(ix, message)| BlockProperties { + .map(|message| BlockProperties { position: buffer .anchor_in_excerpt(excerpt_id, message.anchor) .unwrap(), height: 2, style: BlockStyle::Sticky, render: Box::new({ - let fs = self.fs.clone(); - let project = project.clone(); - let conversation = self.conversation.clone(); + let context = self.context.clone(); move |cx| { let message_id = message.id; let sender = ButtonLike::new("role") @@ -3151,10 +1834,10 @@ impl ConversationEditor { ) }) .on_click({ - let conversation = conversation.clone(); + let context = context.clone(); move |_, cx| { - conversation.update(cx, |conversation, cx| { - conversation.cycle_message_roles( + context.update(cx, |context, cx| { + context.cycle_message_roles( HashSet::from_iter(Some(message_id)), cx, ) @@ -3163,8 +1846,8 @@ impl ConversationEditor { }); h_flex() - .id(("message_header", message_id.0)) - .pl(cx.gutter_dimensions.width) + .id(("message_header", message_id.as_u64())) + .pl(cx.gutter_dimensions.full_width()) .h_11() .w_full() .relative() @@ -3182,74 +1865,6 @@ impl ConversationEditor { None }, ) - .children((ix == 0).then(|| { - div() - .h_flex() - .flex_1() - .justify_end() - .pr_4() - .gap_1() - .child( - IconButton::new("include_file", IconName::File) - .icon_size(IconSize::Small) - .selected( - conversation - .read(cx) - .ambient_context - .recent_buffers - .enabled, - ) - .on_click({ - let conversation = conversation.downgrade(); - move |_, cx| { - conversation - .update(cx, |conversation, cx| { - conversation - .toggle_recent_buffers(cx); - }) - .ok(); - } - }) - .tooltip(|cx| { - Tooltip::text("Include Open Files", cx) - }), - ) - .child( - IconButton::new( - "include_current_project", - IconName::FileTree, - ) - .icon_size(IconSize::Small) - .selected( - conversation - .read(cx) - .ambient_context - .current_project - .enabled, - ) - .on_click({ - let fs = fs.clone(); - let project = project.clone(); - let conversation = conversation.downgrade(); - move |_, cx| { - let fs = fs.clone(); - let project = project.clone(); - conversation - .update(cx, |conversation, cx| { - conversation - .toggle_current_project_context( - fs, project, cx, - ); - }) - .ok(); - } - }) - .tooltip( - |cx| Tooltip::text("Include Current Project", cx), - ), - ) - .into_any() - })) .into_any_element() } }), @@ -3263,6 +1878,42 @@ impl ConversationEditor { }); } + fn insert_selection( + workspace: &mut Workspace, + _: &InsertIntoEditor, + cx: &mut ViewContext, + ) { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + let Some(context_editor_view) = panel.read(cx).active_context_editor(cx) else { + return; + }; + let Some(active_editor_view) = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + else { + return; + }; + + let context_editor = context_editor_view.read(cx).editor.read(cx); + let anchor = context_editor.selections.newest_anchor(); + let text = context_editor + .buffer() + .read(cx) + .read(cx) + .text_for_range(anchor.range()) + .collect::(); + + // If nothing is selected, don't delete the current selection; instead, be a no-op. + if !text.is_empty() { + active_editor_view.update(cx, |editor, cx| { + editor.insert(&text, cx); + editor.focus(cx); + }) + } + } + fn quote_selection( workspace: &mut Workspace, _: &QuoteSelection, @@ -3278,9 +1929,11 @@ impl ConversationEditor { return; }; + let selection = editor.update(cx, |editor, cx| editor.selections.newest_adjusted(cx)); let editor = editor.read(cx); - let range = editor.selections.newest::(cx).range(); let buffer = editor.buffer().read(cx).snapshot(cx); + let range = editor::ToOffset::to_offset(&selection.start, &buffer) + ..editor::ToOffset::to_offset(&selection.end, &buffer); let start_language = buffer.language_at(range.start); let end_language = buffer.language_at(range.end); let language_name = if start_language == end_language { @@ -3312,16 +1965,15 @@ impl ConversationEditor { if let Some(text) = text { panel.update(cx, |_, cx| { - // Wait to create a new conversation until the workspace is no longer + // Wait to create a new context until the workspace is no longer // being updated. cx.defer(move |panel, cx| { - if let Some(conversation) = panel - .active_conversation_editor() - .cloned() - .or_else(|| panel.new_conversation(cx)) + if let Some(context) = panel + .active_context_editor(cx) + .or_else(|| panel.new_context(cx)) { - conversation.update(cx, |conversation, cx| { - conversation + context.update(cx, |context, cx| { + context .editor .update(cx, |editor, cx| editor.insert(&text, cx)) }); @@ -3333,551 +1985,659 @@ impl ConversationEditor { fn copy(&mut self, _: &editor::actions::Copy, cx: &mut ViewContext) { let editor = self.editor.read(cx); - let conversation = self.conversation.read(cx); + let context = self.context.read(cx); if editor.selections.count() == 1 { let selection = editor.selections.newest::(cx); let mut copied_text = String::new(); let mut spanned_messages = 0; - for message in conversation.messages(cx) { + for message in context.messages(cx) { if message.offset_range.start >= selection.range().end { break; - } else if message.offset_range.end >= selection.range().start { - let range = cmp::max(message.offset_range.start, selection.range().start) - ..cmp::min(message.offset_range.end, selection.range().end); - if !range.is_empty() { - spanned_messages += 1; - write!(&mut copied_text, "## {}\n\n", message.role).unwrap(); - for chunk in conversation.buffer.read(cx).text_for_range(range) { - copied_text.push_str(chunk); - } - copied_text.push('\n'); - } - } - } - - if spanned_messages > 1 { - cx.write_to_clipboard(ClipboardItem::new(copied_text)); - return; - } - } - - cx.propagate(); - } - - fn split(&mut self, _: &Split, cx: &mut ViewContext) { - self.conversation.update(cx, |conversation, cx| { - let selections = self.editor.read(cx).selections.disjoint_anchors(); - for selection in selections.as_ref() { - let buffer = self.editor.read(cx).buffer().read(cx).snapshot(cx); - let range = selection - .map(|endpoint| endpoint.to_offset(&buffer)) - .range(); - conversation.split_message(range, cx); - } - }); - } - - fn apply_edit(&mut self, _: &ApplyEdit, cx: &mut ViewContext) { - struct Edit { - old_text: String, - new_text: String, - } - - let conversation = self.conversation.read(cx); - let conversation_buffer = conversation.buffer.read(cx); - let conversation_buffer_snapshot = conversation_buffer.snapshot(); - - let selections = self.editor.read(cx).selections.disjoint_anchors(); - let mut selections = selections.iter().peekable(); - let selected_suggestions = conversation.edit_suggestions.iter().filter(|suggestion| { - while let Some(selection) = selections.peek() { - if selection - .end - .text_anchor - .cmp(&suggestion.source_range.start, conversation_buffer) - .is_lt() - { - selections.next(); - continue; - } - if selection - .start - .text_anchor - .cmp(&suggestion.source_range.end, conversation_buffer) - .is_gt() - { - break; - } - return true; - } - false - }); - - let mut suggestions_by_buffer = - HashMap::, (BufferSnapshot, Vec)>::default(); - for suggestion in selected_suggestions { - let offset = suggestion.source_range.start.to_offset(conversation_buffer); - if let Some(message) = conversation.message_for_offset(offset, cx) { - if let Some(buffer) = message - .ambient_context - .recent_buffers - .source_buffers - .iter() - .find(|source_buffer| { - source_buffer.full_path.as_ref() == Some(&suggestion.full_path) - }) - { - if let Some(buffer) = buffer.model.upgrade() { - let (_, edits) = suggestions_by_buffer - .entry(buffer.clone()) - .or_insert_with(|| (buffer.read(cx).snapshot(), Vec::new())); - - let mut lines = conversation_buffer_snapshot - .as_rope() - .chunks_in_range( - suggestion - .source_range - .to_offset(&conversation_buffer_snapshot), - ) - .lines(); - if let Some(suggestion) = parse_next_edit_suggestion(&mut lines) { - let old_text = conversation_buffer_snapshot - .text_for_range(suggestion.old_text_range) - .collect(); - let new_text = conversation_buffer_snapshot - .text_for_range(suggestion.new_text_range) - .collect(); - edits.push(Edit { old_text, new_text }); + } else if message.offset_range.end >= selection.range().start { + let range = cmp::max(message.offset_range.start, selection.range().start) + ..cmp::min(message.offset_range.end, selection.range().end); + if !range.is_empty() { + spanned_messages += 1; + write!(&mut copied_text, "## {}\n\n", message.role).unwrap(); + for chunk in context.buffer().read(cx).text_for_range(range) { + copied_text.push_str(chunk); } + copied_text.push('\n'); } } } - } - cx.spawn(|this, mut cx| async move { - let edits_by_buffer = cx - .background_executor() - .spawn(async move { - let mut result = HashMap::default(); - for (buffer, (snapshot, suggestions)) in suggestions_by_buffer { - let edits = - result - .entry(buffer) - .or_insert(Vec::<(Range, _)>::new()); - for suggestion in suggestions { - if let Some(range) = - fuzzy_search_lines(snapshot.as_rope(), &suggestion.old_text) - { - let edit_start = snapshot.anchor_after(range.start); - let edit_end = snapshot.anchor_before(range.end); - if let Err(ix) = edits.binary_search_by(|(range, _)| { - range.start.cmp(&edit_start, &snapshot) - }) { - edits.insert( - ix, - (edit_start..edit_end, suggestion.new_text.clone()), - ); - } - } else { - log::info!( - "assistant edit did not match any text in buffer {:?}", - &suggestion.old_text - ); - } - } - } - result - }) - .await; - - let mut project_transaction = ProjectTransaction::default(); - let (editor, workspace, title) = this.update(&mut cx, |this, cx| { - for (buffer_handle, edits) in edits_by_buffer { - buffer_handle.update(cx, |buffer, cx| { - buffer.start_transaction(); - buffer.edit( - edits, - Some(AutoindentMode::Block { - original_indent_columns: Vec::new(), - }), - cx, - ); - buffer.end_transaction(cx); - if let Some(transaction) = buffer.finalize_last_transaction() { - project_transaction - .0 - .insert(buffer_handle.clone(), transaction.clone()); - } - }); - } + if spanned_messages > 1 { + cx.write_to_clipboard(ClipboardItem::new(copied_text)); + return; + } + } - ( - this.editor.downgrade(), - this.workspace.clone(), - this.title(cx), - ) - })?; + cx.propagate(); + } - Editor::open_project_transaction( - &editor, - workspace, - project_transaction, - format!("Edits from {}", title), - cx, - ) - .await - }) - .detach_and_log_err(cx); + fn split(&mut self, _: &Split, cx: &mut ViewContext) { + self.context.update(cx, |context, cx| { + let selections = self.editor.read(cx).selections.disjoint_anchors(); + for selection in selections.as_ref() { + let buffer = self.editor.read(cx).buffer().read(cx).snapshot(cx); + let range = selection + .map(|endpoint| endpoint.to_offset(&buffer)) + .range(); + context.split_message(range, cx); + } + }); } fn save(&mut self, _: &Save, cx: &mut ViewContext) { - self.conversation.update(cx, |conversation, cx| { - conversation.save(None, self.fs.clone(), cx) - }); + self.context + .update(cx, |context, cx| context.save(None, self.fs.clone(), cx)); } fn title(&self, cx: &AppContext) -> String { - self.conversation + self.context .read(cx) - .summary - .as_ref() + .summary() .map(|summary| summary.text.clone()) .unwrap_or_else(|| "New Context".into()) } + + fn render_send_button(&self, cx: &mut ViewContext) -> impl IntoElement { + let focus_handle = self.focus_handle(cx).clone(); + let button_text = match self.edit_step_for_cursor(cx) { + Some(edit_step) => match &edit_step.operations { + Some(EditStepOperations::Pending(_)) => "Computing Changes...", + Some(EditStepOperations::Parsed { .. }) => "Apply Changes", + None => "Send", + }, + None => "Send", + }; + ButtonLike::new("send_button") + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ModalSurface) + .children( + KeyBinding::for_action_in(&Assist, &focus_handle, cx) + .map(|binding| binding.into_any_element()), + ) + .child(Label::new(button_text)) + .on_click(move |_event, cx| { + focus_handle.dispatch_action(&Assist, cx); + }) + } + + fn edit_step_for_cursor<'a>(&'a self, cx: &'a AppContext) -> Option<&'a EditStep> { + let newest_cursor = self + .editor + .read(cx) + .selections + .newest_anchor() + .head() + .text_anchor; + let context = self.context.read(cx); + let buffer = context.buffer().read(cx); + + let edit_steps = context.edit_steps(); + edit_steps + .binary_search_by(|step| { + let step_range = step.source_range.clone(); + if newest_cursor.cmp(&step_range.start, buffer).is_lt() { + Ordering::Greater + } else if newest_cursor.cmp(&step_range.end, buffer).is_gt() { + Ordering::Less + } else { + Ordering::Equal + } + }) + .ok() + .map(|index| &edit_steps[index]) + } } -impl EventEmitter for ConversationEditor {} +impl EventEmitter for ContextEditor {} +impl EventEmitter for ContextEditor {} -impl Render for ConversationEditor { +impl Render for ContextEditor { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { div() - .key_context("ConversationEditor") - .capture_action(cx.listener(ConversationEditor::cancel_last_assist)) - .capture_action(cx.listener(ConversationEditor::save)) - .capture_action(cx.listener(ConversationEditor::copy)) - .capture_action(cx.listener(ConversationEditor::cycle_message_role)) - .on_action(cx.listener(ConversationEditor::assist)) - .on_action(cx.listener(ConversationEditor::split)) - .on_action(cx.listener(ConversationEditor::apply_edit)) + .key_context("ContextEditor") + .capture_action(cx.listener(ContextEditor::cancel_last_assist)) + .capture_action(cx.listener(ContextEditor::save)) + .capture_action(cx.listener(ContextEditor::copy)) + .capture_action(cx.listener(ContextEditor::cycle_message_role)) + .capture_action(cx.listener(ContextEditor::confirm_command)) + .on_action(cx.listener(ContextEditor::assist)) + .on_action(cx.listener(ContextEditor::split)) + .on_action(cx.listener(ContextEditor::debug_edit_steps)) .size_full() .v_flex() .child( div() .flex_grow() .bg(cx.theme().colors().editor_background) - .child(self.editor.clone()), + .child(self.editor.clone()) + .child( + h_flex() + .w_full() + .absolute() + .bottom_0() + .p_4() + .justify_end() + .child(self.render_send_button(cx)), + ), ) } } -impl FocusableView for ConversationEditor { +impl FocusableView for ContextEditor { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { self.editor.focus_handle(cx) } } -#[derive(Clone, Debug)] -struct MessageAnchor { - id: MessageId, - start: language::Anchor, -} +impl Item for ContextEditor { + type Event = editor::EditorEvent; -#[derive(Clone, Debug)] -pub struct Message { - offset_range: Range, - index_range: Range, - id: MessageId, - anchor: language::Anchor, - role: Role, - status: MessageStatus, - slash_command_ranges: Vec>, - ambient_context: AmbientContextSnapshot, -} + fn tab_content_text(&self, cx: &WindowContext) -> Option { + Some(util::truncate_and_trailoff(&self.title(cx), Self::MAX_TAB_TITLE_LEN).into()) + } -impl Message { - fn to_request_message(&self, buffer: &Buffer) -> LanguageModelRequestMessage { - let mut content = text_in_range_omitting_ranges( - buffer.as_rope(), - self.offset_range.clone(), - &self.slash_command_ranges, - ); - content.truncate(content.trim_end().len()); - LanguageModelRequestMessage { - role: self.role, - content, + fn to_item_events(event: &Self::Event, mut f: impl FnMut(item::ItemEvent)) { + match event { + EditorEvent::Edited { .. } => { + f(item::ItemEvent::Edit); + f(item::ItemEvent::UpdateBreadcrumbs); + } + EditorEvent::TitleChanged => { + f(item::ItemEvent::UpdateTab); + } + _ => {} } } -} -enum InlineAssistantEvent { - Confirmed { - prompt: String, - include_conversation: bool, - }, - Canceled, - Dismissed, - IncludeConversationToggled { - include_conversation: bool, - }, + fn tab_tooltip_text(&self, cx: &AppContext) -> Option { + Some(self.title(cx).into()) + } + + fn as_searchable(&self, handle: &View) -> Option> { + Some(Box::new(handle.clone())) + } + + fn breadcrumbs( + &self, + theme: &theme::Theme, + cx: &AppContext, + ) -> Option> { + let editor = self.editor.read(cx); + let cursor = editor.selections.newest_anchor().head(); + let multibuffer = &editor.buffer().read(cx); + let (_, symbols) = multibuffer.symbols_containing(cursor, Some(&theme.syntax()), cx)?; + + let settings = ThemeSettings::get_global(cx); + + let mut breadcrumbs = Vec::new(); + + let title = self.title(cx); + if title.chars().count() > Self::MAX_TAB_TITLE_LEN { + breadcrumbs.push(BreadcrumbText { + text: title, + highlights: None, + font: Some(settings.buffer_font.clone()), + }); + } + + breadcrumbs.extend(symbols.into_iter().map(|symbol| BreadcrumbText { + text: symbol.text, + highlights: Some(symbol.highlight_ranges), + font: Some(settings.buffer_font.clone()), + })); + Some(breadcrumbs) + } + + fn breadcrumb_location(&self) -> ToolbarItemLocation { + ToolbarItemLocation::PrimaryLeft + } + + fn set_nav_history(&mut self, nav_history: pane::ItemNavHistory, cx: &mut ViewContext) { + self.editor.update(cx, |editor, cx| { + Item::set_nav_history(editor, nav_history, cx) + }) + } + + fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { + self.editor + .update(cx, |editor, cx| Item::navigate(editor, data, cx)) + } + + fn deactivated(&mut self, cx: &mut ViewContext) { + self.editor + .update(cx, |editor, cx| Item::deactivated(editor, cx)) + } } -struct InlineAssistant { - id: usize, - prompt_editor: View, - confirmed: bool, - show_include_conversation: bool, - include_conversation: bool, - measurements: Arc>, - prompt_history: VecDeque, - prompt_history_ix: Option, - pending_prompt: String, - codegen: Model, - _subscriptions: Vec, +impl SearchableItem for ContextEditor { + type Match = ::Match; + + fn clear_matches(&mut self, cx: &mut ViewContext) { + self.editor.update(cx, |editor, cx| { + editor.clear_matches(cx); + }); + } + + fn update_matches(&mut self, matches: &[Self::Match], cx: &mut ViewContext) { + self.editor + .update(cx, |editor, cx| editor.update_matches(matches, cx)); + } + + fn query_suggestion(&mut self, cx: &mut ViewContext) -> String { + self.editor + .update(cx, |editor, cx| editor.query_suggestion(cx)) + } + + fn activate_match( + &mut self, + index: usize, + matches: &[Self::Match], + cx: &mut ViewContext, + ) { + self.editor.update(cx, |editor, cx| { + editor.activate_match(index, matches, cx); + }); + } + + fn select_matches(&mut self, matches: &[Self::Match], cx: &mut ViewContext) { + self.editor + .update(cx, |editor, cx| editor.select_matches(matches, cx)); + } + + fn replace( + &mut self, + identifier: &Self::Match, + query: &project::search::SearchQuery, + cx: &mut ViewContext, + ) { + self.editor + .update(cx, |editor, cx| editor.replace(identifier, query, cx)); + } + + fn find_matches( + &mut self, + query: Arc, + cx: &mut ViewContext, + ) -> Task> { + self.editor + .update(cx, |editor, cx| editor.find_matches(query, cx)) + } + + fn active_match_index( + &mut self, + matches: &[Self::Match], + cx: &mut ViewContext, + ) -> Option { + self.editor + .update(cx, |editor, cx| editor.active_match_index(matches, cx)) + } } -impl EventEmitter for InlineAssistant {} +impl FollowableItem for ContextEditor { + fn remote_id(&self) -> Option { + self.remote_id + } + + fn to_state_proto(&self, cx: &WindowContext) -> Option { + let context = self.context.read(cx); + Some(proto::view::Variant::ContextEditor( + proto::view::ContextEditor { + context_id: context.id().to_proto(), + editor: if let Some(proto::view::Variant::Editor(proto)) = + self.editor.read(cx).to_state_proto(cx) + { + Some(proto) + } else { + None + }, + }, + )) + } -impl Render for InlineAssistant { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let measurements = *self.measurements.lock(); - h_flex() - .w_full() - .py_2() - .border_y_1() - .border_color(cx.theme().colors().border) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::toggle_include_conversation)) - .on_action(cx.listener(Self::move_up)) - .on_action(cx.listener(Self::move_down)) - .child( - h_flex() - .justify_center() - .w(measurements.gutter_width) - .children(self.show_include_conversation.then(|| { - IconButton::new("include_conversation", IconName::Ai) - .on_click(cx.listener(|this, _, cx| { - this.toggle_include_conversation(&ToggleIncludeConversation, cx) - })) - .selected(self.include_conversation) - .tooltip(|cx| { - Tooltip::for_action( - "Include Conversation", - &ToggleIncludeConversation, - cx, - ) - }) - })) - .children(if let Some(error) = self.codegen.read(cx).error() { - let error_message = SharedString::from(error.to_string()); - Some( - div() - .id("error") - .tooltip(move |cx| Tooltip::text(error_message.clone(), cx)) - .child(Icon::new(IconName::XCircle).color(Color::Error)), + fn from_state_proto( + workspace: View, + id: workspace::ViewId, + state: &mut Option, + cx: &mut WindowContext, + ) -> Option>>> { + let proto::view::Variant::ContextEditor(_) = state.as_ref()? else { + return None; + }; + let Some(proto::view::Variant::ContextEditor(state)) = state.take() else { + unreachable!() + }; + + let context_id = ContextId::from_proto(state.context_id); + let editor_state = state.editor?; + + let (project, panel) = workspace.update(cx, |workspace, cx| { + Some(( + workspace.project().clone(), + workspace.panel::(cx)?, + )) + })?; + + let context_editor = + panel.update(cx, |panel, cx| panel.open_remote_context(context_id, cx)); + + Some(cx.spawn(|mut cx| async move { + let context_editor = context_editor.await?; + context_editor + .update(&mut cx, |context_editor, cx| { + context_editor.remote_id = Some(id); + context_editor.editor.update(cx, |editor, cx| { + editor.apply_update_proto( + &project, + proto::update_view::Variant::Editor(proto::update_view::Editor { + selections: editor_state.selections, + pending_selection: editor_state.pending_selection, + scroll_top_anchor: editor_state.scroll_top_anchor, + scroll_x: editor_state.scroll_y, + scroll_y: editor_state.scroll_y, + ..Default::default() + }), + cx, ) - } else { - None - }), - ) - .child( - h_flex() - .w_full() - .ml(measurements.anchor_x - measurements.gutter_width) - .child(self.render_prompt_editor(cx)), - ) + }) + })? + .await?; + Ok(context_editor) + })) } -} -impl FocusableView for InlineAssistant { - fn focus_handle(&self, cx: &AppContext) -> FocusHandle { - self.prompt_editor.focus_handle(cx) + fn to_follow_event(event: &Self::Event) -> Option { + Editor::to_follow_event(event) } -} -impl InlineAssistant { - #[allow(clippy::too_many_arguments)] - fn new( - id: usize, - measurements: Arc>, - show_include_conversation: bool, - include_conversation: bool, - prompt_history: VecDeque, - codegen: Model, + fn add_event_to_update_proto( + &self, + event: &Self::Event, + update: &mut Option, + cx: &WindowContext, + ) -> bool { + self.editor + .read(cx) + .add_event_to_update_proto(event, update, cx) + } + + fn apply_update_proto( + &mut self, + project: &Model, + message: proto::update_view::Variant, cx: &mut ViewContext, - ) -> Self { - let prompt_editor = cx.new_view(|cx| { - let mut editor = Editor::single_line(cx); - let placeholder = match codegen.read(cx).kind() { - CodegenKind::Transform { .. } => "Enter transformation prompt…", - CodegenKind::Generate { .. } => "Enter generation prompt…", - }; - editor.set_placeholder_text(placeholder, cx); - editor - }); - cx.focus_view(&prompt_editor); + ) -> Task> { + self.editor.update(cx, |editor, cx| { + editor.apply_update_proto(project, message, cx) + }) + } - let subscriptions = vec![ - cx.observe(&codegen, Self::handle_codegen_changed), - cx.subscribe(&prompt_editor, Self::handle_prompt_editor_events), - ]; + fn is_project_item(&self, _cx: &WindowContext) -> bool { + true + } + + fn set_leader_peer_id( + &mut self, + leader_peer_id: Option, + cx: &mut ViewContext, + ) { + self.editor.update(cx, |editor, cx| { + editor.set_leader_peer_id(leader_peer_id, cx) + }) + } + + fn dedup(&self, existing: &Self, cx: &WindowContext) -> Option { + if existing.context.read(cx).id() == self.context.read(cx).id() { + Some(item::Dedup::KeepExisting) + } else { + None + } + } +} + +pub struct ContextEditorToolbarItem { + fs: Arc, + workspace: WeakView, + active_context_editor: Option>, + model_selector_menu_handle: PopoverMenuHandle, +} +impl ContextEditorToolbarItem { + pub fn new( + workspace: &Workspace, + model_selector_menu_handle: PopoverMenuHandle, + ) -> Self { Self { - id, - prompt_editor, - confirmed: false, - show_include_conversation, - include_conversation, - measurements, - prompt_history, - prompt_history_ix: None, - pending_prompt: String::new(), - codegen, - _subscriptions: subscriptions, + fs: workspace.app_state().fs.clone(), + workspace: workspace.weak_handle(), + active_context_editor: None, + model_selector_menu_handle, } } - fn handle_prompt_editor_events( + fn render_inject_context_menu(&self, cx: &mut ViewContext) -> impl Element { + let commands = SlashCommandRegistry::global(cx); + let active_editor_focus_handle = self.workspace.upgrade().and_then(|workspace| { + Some( + workspace + .read(cx) + .active_item_as::(cx)? + .focus_handle(cx), + ) + }); + let active_context_editor = self.active_context_editor.clone(); + + PopoverMenu::new("inject-context-menu") + .trigger(IconButton::new("trigger", IconName::Quote).tooltip(|cx| { + Tooltip::with_meta("Insert Context", None, "Type / to insert via keyboard", cx) + })) + .menu(move |cx| { + let active_context_editor = active_context_editor.clone()?; + ContextMenu::build(cx, |mut menu, _cx| { + for command_name in commands.featured_command_names() { + if let Some(command) = commands.command(&command_name) { + let menu_text = SharedString::from(Arc::from(command.menu_text())); + menu = menu.custom_entry( + { + let command_name = command_name.clone(); + move |_cx| { + h_flex() + .gap_4() + .w_full() + .justify_between() + .child(Label::new(menu_text.clone())) + .child( + Label::new(format!("/{command_name}")) + .color(Color::Muted), + ) + .into_any() + } + }, + { + let active_context_editor = active_context_editor.clone(); + move |cx| { + active_context_editor + .update(cx, |context_editor, cx| { + context_editor.insert_command(&command_name, cx) + }) + .ok(); + } + }, + ) + } + } + + if let Some(active_editor_focus_handle) = active_editor_focus_handle.clone() { + menu = menu + .context(active_editor_focus_handle) + .action("Quote Selection", Box::new(QuoteSelection)); + } + + menu + }) + .into() + }) + } + + fn render_remaining_tokens(&self, cx: &mut ViewContext) -> Option { + let model = CompletionProvider::global(cx).model(); + let context = &self + .active_context_editor + .as_ref()? + .upgrade()? + .read(cx) + .context; + let token_count = context.read(cx).token_count()?; + let max_token_count = model.max_token_count(); + + let remaining_tokens = max_token_count as isize - token_count as isize; + let token_count_color = if remaining_tokens <= 0 { + Color::Error + } else if token_count as f32 / max_token_count as f32 >= 0.8 { + Color::Warning + } else { + Color::Muted + }; + + Some( + h_flex() + .gap_0p5() + .child( + Label::new(humanize_token_count(token_count)) + .size(LabelSize::Small) + .color(token_count_color), + ) + .child(Label::new("/").size(LabelSize::Small).color(Color::Muted)) + .child( + Label::new(humanize_token_count(max_token_count)) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + } +} + +impl Render for ContextEditorToolbarItem { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + h_flex() + .gap_2() + .child(ModelSelector::new( + self.model_selector_menu_handle.clone(), + self.fs.clone(), + )) + .children(self.render_remaining_tokens(cx)) + .child(self.render_inject_context_menu(cx)) + } +} + +impl ToolbarItemView for ContextEditorToolbarItem { + fn set_active_pane_item( &mut self, - _: View, - event: &EditorEvent, - cx: &mut ViewContext, - ) { - if let EditorEvent::Edited = event { - self.pending_prompt = self.prompt_editor.read(cx).text(cx); - cx.notify(); + active_pane_item: Option<&dyn ItemHandle>, + cx: &mut ViewContext, + ) -> ToolbarItemLocation { + self.active_context_editor = active_pane_item + .and_then(|item| item.act_as::(cx)) + .map(|editor| editor.downgrade()); + cx.notify(); + if self.active_context_editor.is_none() { + ToolbarItemLocation::Hidden + } else { + ToolbarItemLocation::PrimaryRight } } - fn handle_codegen_changed(&mut self, _: Model, cx: &mut ViewContext) { - let is_read_only = !self.codegen.read(cx).idle(); - self.prompt_editor.update(cx, |editor, cx| { - let was_read_only = editor.read_only(cx); - if was_read_only != is_read_only { - if is_read_only { - editor.set_read_only(true); - } else { - self.confirmed = false; - editor.set_read_only(false); - } - } - }); + fn pane_focus_update(&mut self, _pane_focused: bool, cx: &mut ViewContext) { cx.notify(); } +} - fn cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext) { - cx.emit(InlineAssistantEvent::Canceled); - } +impl EventEmitter for ContextEditorToolbarItem {} - fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { - if self.confirmed { - cx.emit(InlineAssistantEvent::Dismissed); - } else { - let prompt = self.prompt_editor.read(cx).text(cx); - self.prompt_editor - .update(cx, |editor, _cx| editor.set_read_only(true)); - cx.emit(InlineAssistantEvent::Confirmed { - prompt, - include_conversation: self.include_conversation, - }); - self.confirmed = true; - cx.notify(); - } - } +pub struct ContextHistory { + picker: View>, + _subscriptions: Vec, + assistant_panel: WeakView, +} - fn toggle_include_conversation( - &mut self, - _: &ToggleIncludeConversation, +impl ContextHistory { + fn new( + project: Model, + context_store: Model, + assistant_panel: WeakView, cx: &mut ViewContext, - ) { - self.include_conversation = !self.include_conversation; - cx.emit(InlineAssistantEvent::IncludeConversationToggled { - include_conversation: self.include_conversation, + ) -> Self { + let picker = cx.new_view(|cx| { + Picker::uniform_list( + SavedContextPickerDelegate::new(project, context_store.clone()), + cx, + ) + .modal(false) + .max_height(None) }); - cx.notify(); - } - fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext) { - if let Some(ix) = self.prompt_history_ix { - if ix > 0 { - self.prompt_history_ix = Some(ix - 1); - let prompt = self.prompt_history[ix - 1].clone(); - self.set_prompt(&prompt, cx); - } - } else if !self.prompt_history.is_empty() { - self.prompt_history_ix = Some(self.prompt_history.len() - 1); - let prompt = self.prompt_history[self.prompt_history.len() - 1].clone(); - self.set_prompt(&prompt, cx); - } - } + let _subscriptions = vec![ + cx.observe(&context_store, |this, _, cx| { + this.picker.update(cx, |picker, cx| picker.refresh(cx)); + }), + cx.subscribe(&picker, Self::handle_picker_event), + ]; - fn move_down(&mut self, _: &MoveDown, cx: &mut ViewContext) { - if let Some(ix) = self.prompt_history_ix { - if ix < self.prompt_history.len() - 1 { - self.prompt_history_ix = Some(ix + 1); - let prompt = self.prompt_history[ix + 1].clone(); - self.set_prompt(&prompt, cx); - } else { - self.prompt_history_ix = None; - let pending_prompt = self.pending_prompt.clone(); - self.set_prompt(&pending_prompt, cx); - } + Self { + picker, + _subscriptions, + assistant_panel, } } - fn set_prompt(&mut self, prompt: &str, cx: &mut ViewContext) { - self.prompt_editor.update(cx, |editor, cx| { - editor.buffer().update(cx, |buffer, cx| { - let len = buffer.len(cx); - buffer.edit([(0..len, prompt)], None, cx); - }); - }); + fn handle_picker_event( + &mut self, + _: View>, + event: &SavedContextPickerEvent, + cx: &mut ViewContext, + ) { + let SavedContextPickerEvent::Confirmed(context) = event; + self.assistant_panel + .update(cx, |assistant_panel, cx| match context { + ContextMetadata::Remote(metadata) => { + assistant_panel + .open_remote_context(metadata.id.clone(), cx) + .detach_and_log_err(cx); + } + ContextMetadata::Saved(metadata) => { + assistant_panel + .open_saved_context(metadata.path.clone(), cx) + .detach_and_log_err(cx); + } + }) + .ok(); } +} - fn render_prompt_editor(&self, cx: &mut ViewContext) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: if self.prompt_editor.read(cx).read_only(cx) { - cx.theme().colors().text_disabled - } else { - cx.theme().colors().text - }, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_size: rems(0.875).into(), - font_weight: FontWeight::NORMAL, - font_style: FontStyle::Normal, - line_height: relative(1.3), - background_color: None, - underline: None, - strikethrough: None, - white_space: WhiteSpace::Normal, - }; - EditorElement::new( - &self.prompt_editor, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) +impl Render for ContextHistory { + fn render(&mut self, _: &mut ViewContext) -> impl IntoElement { + div().size_full().child(self.picker.clone()) } } -// This wouldn't need to exist if we could pass parameters when rendering child views. -#[derive(Copy, Clone, Default)] -struct BlockMeasurements { - anchor_x: Pixels, - gutter_width: Pixels, +impl FocusableView for ContextHistory { + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + self.picker.focus_handle(cx) + } } -struct PendingInlineAssist { - editor: WeakView, - inline_assistant: Option<(BlockId, View)>, - codegen: Model, - _subscriptions: Vec, - project: WeakModel, +impl EventEmitter<()> for ContextHistory {} + +impl Item for ContextHistory { + type Event = (); + + fn tab_content_text(&self, _cx: &WindowContext) -> Option { + Some("History".into()) + } } type ToggleFold = Arc; @@ -3888,841 +2648,125 @@ fn render_slash_command_output_toggle( fold: ToggleFold, _cx: &mut WindowContext, ) -> AnyElement { - IconButton::new( - ("slash-command-output-fold-indicator", row.0), - ui::IconName::ChevronDown, + Disclosure::new( + ("slash-command-output-fold-indicator", row.0 as u64), + !is_folded, ) - .on_click(move |_e, cx| fold(!is_folded, cx)) - .icon_color(ui::Color::Muted) - .icon_size(ui::IconSize::Small) .selected(is_folded) - .selected_icon(ui::IconName::ChevronRight) - .size(ui::ButtonSize::None) + .on_click(move |_e, cx| fold(!is_folded, cx)) .into_any_element() } -fn render_slash_command_output_trailer( - _row: MultiBufferRow, - _is_folded: bool, - _cx: &mut WindowContext, +fn render_pending_slash_command_gutter_decoration( + row: MultiBufferRow, + status: &PendingSlashCommandStatus, + confirm_command: Arc, ) -> AnyElement { - div().into_any_element() -} + let mut icon = IconButton::new( + ("slash-command-gutter-decoration", row.0), + ui::IconName::TriangleRight, + ) + .on_click(move |_e, cx| confirm_command(cx)) + .icon_size(ui::IconSize::Small) + .size(ui::ButtonSize::None); -fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { - ranges.sort_unstable_by(|a, b| { - a.start - .cmp(&b.start, buffer) - .then_with(|| b.end.cmp(&a.end, buffer)) - }); - - let mut ix = 0; - while ix + 1 < ranges.len() { - let b = ranges[ix + 1].clone(); - let a = &mut ranges[ix]; - if a.end.cmp(&b.start, buffer).is_gt() { - if a.end.cmp(&b.end, buffer).is_lt() { - a.end = b.end; - } - ranges.remove(ix + 1); - } else { - ix += 1; + match status { + PendingSlashCommandStatus::Idle => { + icon = icon.icon_color(Color::Muted); } + PendingSlashCommandStatus::Running { .. } => { + icon = icon.selected(true); + } + PendingSlashCommandStatus::Error(_) => icon = icon.icon_color(Color::Error), } -} -fn make_lsp_adapter_delegate( - project: &Model, - cx: &mut AppContext, -) -> Arc { - project.update(cx, |project, cx| { - // TODO: Find the right worktree. - let worktree = project - .worktrees() - .next() - .expect("expected at least one worktree"); - ProjectLspAdapterDelegate::new(project, &worktree, cx) - }) + icon.into_any_element() } -#[cfg(test)] -mod tests { - use std::{cell::RefCell, path::Path, rc::Rc}; - - use super::*; - use crate::{FakeCompletionProvider, MessageId}; - use fs::FakeFs; - use gpui::{AppContext, TestAppContext}; - use rope::Rope; - use serde_json::json; - use settings::SettingsStore; - use unindent::Unindent; - use util::test::marked_text_ranges; - - #[gpui::test] - fn test_inserting_and_removing_messages(cx: &mut AppContext) { - let settings_store = SettingsStore::test(cx); - cx.set_global(CompletionProvider::Fake(FakeCompletionProvider::default())); - cx.set_global(settings_store); - init(cx); - let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - - let conversation = cx.new_model(|cx| { - Conversation::new( - LanguageModel::default(), - registry, - Default::default(), - None, - None, - cx, - ) - }); - let buffer = conversation.read(cx).buffer.clone(); - - let message_1 = conversation.read(cx).message_anchors[0].clone(); - assert_eq!( - messages(&conversation, cx), - vec![(message_1.id, Role::User, 0..0)] - ); - - let message_2 = conversation.update(cx, |conversation, cx| { - conversation - .insert_message_after(message_1.id, Role::Assistant, MessageStatus::Done, cx) - .unwrap() - }); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..1), - (message_2.id, Role::Assistant, 1..1) - ] - ); - - buffer.update(cx, |buffer, cx| { - buffer.edit([(0..0, "1"), (1..1, "2")], None, cx) - }); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..2), - (message_2.id, Role::Assistant, 2..3) - ] - ); - - let message_3 = conversation.update(cx, |conversation, cx| { - conversation - .insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) - .unwrap() - }); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..2), - (message_2.id, Role::Assistant, 2..4), - (message_3.id, Role::User, 4..4) - ] - ); - - let message_4 = conversation.update(cx, |conversation, cx| { - conversation - .insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) - .unwrap() - }); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..2), - (message_2.id, Role::Assistant, 2..4), - (message_4.id, Role::User, 4..5), - (message_3.id, Role::User, 5..5), - ] - ); - - buffer.update(cx, |buffer, cx| { - buffer.edit([(4..4, "C"), (5..5, "D")], None, cx) - }); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..2), - (message_2.id, Role::Assistant, 2..4), - (message_4.id, Role::User, 4..6), - (message_3.id, Role::User, 6..7), - ] - ); - - // Deleting across message boundaries merges the messages. - buffer.update(cx, |buffer, cx| buffer.edit([(1..4, "")], None, cx)); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..3), - (message_3.id, Role::User, 3..4), - ] - ); - - // Undoing the deletion should also undo the merge. - buffer.update(cx, |buffer, cx| buffer.undo(cx)); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..2), - (message_2.id, Role::Assistant, 2..4), - (message_4.id, Role::User, 4..6), - (message_3.id, Role::User, 6..7), - ] - ); - - // Redoing the deletion should also redo the merge. - buffer.update(cx, |buffer, cx| buffer.redo(cx)); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..3), - (message_3.id, Role::User, 3..4), - ] - ); - - // Ensure we can still insert after a merged message. - let message_5 = conversation.update(cx, |conversation, cx| { - conversation - .insert_message_after(message_1.id, Role::System, MessageStatus::Done, cx) - .unwrap() - }); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..3), - (message_5.id, Role::System, 3..4), - (message_3.id, Role::User, 4..5) - ] - ); - } - - #[gpui::test] - fn test_message_splitting(cx: &mut AppContext) { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - cx.set_global(CompletionProvider::Fake(FakeCompletionProvider::default())); - init(cx); - let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - - let conversation = cx.new_model(|cx| { - Conversation::new( - LanguageModel::default(), - registry, - Default::default(), - None, - None, - cx, - ) - }); - let buffer = conversation.read(cx).buffer.clone(); - - let message_1 = conversation.read(cx).message_anchors[0].clone(); - assert_eq!( - messages(&conversation, cx), - vec![(message_1.id, Role::User, 0..0)] - ); - - buffer.update(cx, |buffer, cx| { - buffer.edit([(0..0, "aaa\nbbb\nccc\nddd\n")], None, cx) - }); - - let (_, message_2) = - conversation.update(cx, |conversation, cx| conversation.split_message(3..3, cx)); - let message_2 = message_2.unwrap(); - - // We recycle newlines in the middle of a split message - assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\nddd\n"); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..4), - (message_2.id, Role::User, 4..16), - ] - ); - - let (_, message_3) = - conversation.update(cx, |conversation, cx| conversation.split_message(3..3, cx)); - let message_3 = message_3.unwrap(); - - // We don't recycle newlines at the end of a split message - assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\nccc\nddd\n"); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..4), - (message_3.id, Role::User, 4..5), - (message_2.id, Role::User, 5..17), - ] - ); - - let (_, message_4) = - conversation.update(cx, |conversation, cx| conversation.split_message(9..9, cx)); - let message_4 = message_4.unwrap(); - assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\nccc\nddd\n"); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..4), - (message_3.id, Role::User, 4..5), - (message_2.id, Role::User, 5..9), - (message_4.id, Role::User, 9..17), - ] - ); - - let (_, message_5) = - conversation.update(cx, |conversation, cx| conversation.split_message(9..9, cx)); - let message_5 = message_5.unwrap(); - assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\n\nccc\nddd\n"); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..4), - (message_3.id, Role::User, 4..5), - (message_2.id, Role::User, 5..9), - (message_4.id, Role::User, 9..10), - (message_5.id, Role::User, 10..18), - ] - ); - - let (message_6, message_7) = conversation.update(cx, |conversation, cx| { - conversation.split_message(14..16, cx) - }); - let message_6 = message_6.unwrap(); - let message_7 = message_7.unwrap(); - assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\n\nccc\ndd\nd\n"); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..4), - (message_3.id, Role::User, 4..5), - (message_2.id, Role::User, 5..9), - (message_4.id, Role::User, 9..10), - (message_5.id, Role::User, 10..14), - (message_6.id, Role::User, 14..17), - (message_7.id, Role::User, 17..19), - ] - ); - } - - #[gpui::test] - fn test_messages_for_offsets(cx: &mut AppContext) { - let settings_store = SettingsStore::test(cx); - cx.set_global(CompletionProvider::Fake(FakeCompletionProvider::default())); - cx.set_global(settings_store); - init(cx); - let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - let conversation = cx.new_model(|cx| { - Conversation::new( - LanguageModel::default(), - registry, - Default::default(), - None, - None, - cx, - ) - }); - let buffer = conversation.read(cx).buffer.clone(); +fn render_docs_slash_command_trailer( + row: MultiBufferRow, + command: PendingSlashCommand, + cx: &mut WindowContext, +) -> AnyElement { + let Some(argument) = command.argument else { + return Empty.into_any(); + }; - let message_1 = conversation.read(cx).message_anchors[0].clone(); - assert_eq!( - messages(&conversation, cx), - vec![(message_1.id, Role::User, 0..0)] - ); + let args = DocsSlashCommandArgs::parse(&argument); - buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "aaa")], None, cx)); - let message_2 = conversation - .update(cx, |conversation, cx| { - conversation.insert_message_after(message_1.id, Role::User, MessageStatus::Done, cx) - }) - .unwrap(); - buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "bbb")], None, cx)); + let Some(store) = args + .provider() + .and_then(|provider| IndexedDocsStore::try_global(provider, cx).ok()) + else { + return Empty.into_any(); + }; - let message_3 = conversation - .update(cx, |conversation, cx| { - conversation.insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) - }) - .unwrap(); - buffer.update(cx, |buffer, cx| buffer.edit([(8..8, "ccc")], None, cx)); - - assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc"); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..4), - (message_2.id, Role::User, 4..8), - (message_3.id, Role::User, 8..11) - ] - ); + let Some(package) = args.package() else { + return Empty.into_any(); + }; - assert_eq!( - message_ids_for_offsets(&conversation, &[0, 4, 9], cx), - [message_1.id, message_2.id, message_3.id] - ); - assert_eq!( - message_ids_for_offsets(&conversation, &[0, 1, 11], cx), - [message_1.id, message_3.id] - ); + let mut children = Vec::new(); - let message_4 = conversation - .update(cx, |conversation, cx| { - conversation.insert_message_after(message_3.id, Role::User, MessageStatus::Done, cx) - }) - .unwrap(); - assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\n"); - assert_eq!( - messages(&conversation, cx), - vec![ - (message_1.id, Role::User, 0..4), - (message_2.id, Role::User, 4..8), - (message_3.id, Role::User, 8..12), - (message_4.id, Role::User, 12..12) - ] - ); - assert_eq!( - message_ids_for_offsets(&conversation, &[0, 4, 8, 12], cx), - [message_1.id, message_2.id, message_3.id, message_4.id] + if store.is_indexing(&package) { + children.push( + div() + .id(("crates-being-indexed", row.0)) + .child(Icon::new(IconName::ArrowCircle).with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(4)).repeat(), + |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), + )) + .tooltip({ + let package = package.clone(); + move |cx| Tooltip::text(format!("Indexing {package}…"), cx) + }) + .into_any_element(), ); - - fn message_ids_for_offsets( - conversation: &Model, - offsets: &[usize], - cx: &AppContext, - ) -> Vec { - conversation - .read(cx) - .messages_for_offsets(offsets.iter().copied(), cx) - .into_iter() - .map(|message| message.id) - .collect() - } } - #[gpui::test] - async fn test_slash_commands(cx: &mut TestAppContext) { - let settings_store = cx.update(SettingsStore::test); - cx.set_global(settings_store); - cx.set_global(CompletionProvider::Fake(FakeCompletionProvider::default())); - cx.update(Project::init_settings); - cx.update(init); - let fs = FakeFs::new(cx.background_executor.clone()); - - fs.insert_tree( - "/test", - json!({ - "src": { - "lib.rs": "fn one() -> usize { 1 }", - "main.rs": " - use crate::one; - fn main() { one(); } - ".unindent(), - } - }), + if let Some(latest_error) = store.latest_error_for_package(&package) { + children.push( + div() + .id(("latest-error", row.0)) + .child(Icon::new(IconName::ExclamationTriangle).color(Color::Warning)) + .tooltip(move |cx| Tooltip::text(format!("failed to index: {latest_error}"), cx)) + .into_any_element(), ) - .await; - - let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; - let prompt_library = Arc::new(PromptLibrary::default()); - let slash_command_registry = SlashCommandRegistry::new(); - - slash_command_registry - .register_command(file_command::FileSlashCommand::new(project.clone())); - slash_command_registry.register_command(prompt_command::PromptSlashCommand::new( - prompt_library.clone(), - )); - - let lsp_adapter_delegate = project.update(cx, |project, cx| { - // TODO: Find the right worktree. - let worktree = project - .worktrees() - .next() - .expect("expected at least one worktree"); - ProjectLspAdapterDelegate::new(project, &worktree, cx) - }); - - let registry = Arc::new(LanguageRegistry::test(cx.executor())); - let conversation = cx.new_model(|cx| { - Conversation::new( - LanguageModel::default(), - registry.clone(), - slash_command_registry, - None, - Some(lsp_adapter_delegate), - cx, - ) - }); - - let output_ranges = Rc::new(RefCell::new(HashSet::default())); - conversation.update(cx, |_, cx| { - cx.subscribe(&conversation, { - let ranges = output_ranges.clone(); - move |_, _, event, _| match event { - ConversationEvent::SlashCommandOutputAdded(range) => { - ranges.borrow_mut().insert(range.clone()); - } - ConversationEvent::SlashCommandOutputRemoved(range) => { - ranges.borrow_mut().remove(range); - } - _ => {} - } - }) - .detach(); - }); - - let buffer = conversation.read_with(cx, |conversation, _| conversation.buffer.clone()); - - // Insert a slash command - buffer.update(cx, |buffer, cx| { - buffer.edit([(0..0, "/file src/lib.rs")], None, cx); - }); - assert_text_and_output_ranges( - &buffer, - &output_ranges.borrow(), - " - /file src/lib.rs - " - .unindent() - .trim_end(), - cx, - ); - - // The slash command runs - cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE); - assert_text_and_output_ranges( - &buffer, - &output_ranges.borrow(), - &" - /file src/lib.rs« - ```src/lib.rs - fn one() -> usize { 1 } - ```»" - .unindent(), - cx, - ); - - // Edit the slash command - buffer.update(cx, |buffer, cx| { - let edit_offset = buffer.text().find("lib.rs").unwrap(); - buffer.edit([(edit_offset..edit_offset + "lib".len(), "main")], None, cx); - }); - assert_text_and_output_ranges( - &buffer, - &output_ranges.borrow(), - &" - /file src/main.rs« - ```src/lib.rs - fn one() -> usize { 1 } - ```»" - .unindent(), - cx, - ); - - cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE); - assert_text_and_output_ranges( - &buffer, - &output_ranges.borrow(), - &" - /file src/main.rs« - ```src/main.rs - use crate::one; - fn main() { one(); } - ```»" - .unindent(), - cx, - ); - - // Insert newlines between the slash command and its output - buffer.update(cx, |buffer, cx| { - let edit_offset = buffer.text().find("\n```src/main.rs").unwrap(); - buffer.edit([(edit_offset..edit_offset, "\n")], None, cx); - }); - assert_text_and_output_ranges( - &buffer, - &output_ranges.borrow(), - &" - /file src/main.rs« - - ```src/main.rs - use crate::one; - fn main() { one(); } - ```»" - .unindent(), - cx, - ); - - cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE); - assert_text_and_output_ranges( - &buffer, - &output_ranges.borrow(), - &" - /file src/main.rs« - ```src/main.rs - use crate::one; - fn main() { one(); } - ```»" - .unindent(), - cx, - ); - - // Insert text at the beginning of the output - buffer.update(cx, |buffer, cx| { - let edit_offset = buffer.text().find("```src/main.rs").unwrap(); - buffer.edit([(edit_offset..edit_offset, "!")], None, cx); - }); - assert_text_and_output_ranges( - &buffer, - &output_ranges.borrow(), - &" - /file src/main.rs« - !```src/main.rs - use crate::one; - fn main() { one(); } - ```»" - .unindent(), - cx, - ); - - cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE); - assert_text_and_output_ranges( - &buffer, - &output_ranges.borrow(), - &" - /file src/main.rs« - ```src/main.rs - use crate::one; - fn main() { one(); } - ```»" - .unindent(), - cx, - ); - - // Slash commands are omitted from completion requests. Only their - // output is included. - let request = conversation.update(cx, |conversation, cx| { - conversation.to_completion_request(cx) - }); - assert_eq!( - &request.messages[1..], - &[LanguageModelRequestMessage { - role: Role::User, - content: " - ```src/main.rs - use crate::one; - fn main() { one(); } - ```" - .unindent() - }] - ); - - buffer.update(cx, |buffer, cx| { - buffer.edit([(0..0, "hello\n")], None, cx); - }); - buffer.update(cx, |buffer, cx| { - buffer.edit( - [(buffer.len()..buffer.len(), "\ngoodbye\nfarewell\n")], - None, - cx, - ); - }); - let request = conversation.update(cx, |conversation, cx| { - conversation.to_completion_request(cx) - }); - assert_eq!( - &request.messages[1..], - &[LanguageModelRequestMessage { - role: Role::User, - content: " - hello - ```src/main.rs - use crate::one; - fn main() { one(); } - ``` - goodbye - farewell" - .unindent() - }] - ); - - #[track_caller] - fn assert_text_and_output_ranges( - buffer: &Model, - ranges: &HashSet>, - expected_marked_text: &str, - cx: &mut TestAppContext, - ) { - let (expected_text, expected_ranges) = marked_text_ranges(expected_marked_text, false); - let (actual_text, actual_ranges) = buffer.update(cx, |buffer, _| { - let mut ranges = ranges - .iter() - .map(|range| range.to_offset(buffer)) - .collect::>(); - ranges.sort_by_key(|a| a.start); - (buffer.text(), ranges) - }); - - assert_eq!(actual_text, expected_text); - assert_eq!(actual_ranges, expected_ranges); - } } - #[test] - fn test_parse_next_edit_suggestion() { - let text = " - some output: - - ```edit src/foo.rs - let a = 1; - let b = 2; - --- - let w = 1; - let x = 2; - let y = 3; - let z = 4; - ``` - - some more output: - - ```edit src/foo.rs - let c = 1; - --- - ``` - - and the conclusion. - " - .unindent(); - - let rope = Rope::from(text.as_str()); - let mut lines = rope.chunks().lines(); - let mut suggestions = vec![]; - while let Some(suggestion) = parse_next_edit_suggestion(&mut lines) { - suggestions.push(( - suggestion.path.clone(), - text[suggestion.old_text_range].to_string(), - text[suggestion.new_text_range].to_string(), - )); - } + let is_indexing = store.is_indexing(&package); + let latest_error = store.latest_error_for_package(&package); - assert_eq!( - suggestions, - vec![ - ( - Path::new("src/foo.rs").into(), - [ - " let a = 1;", // - " let b = 2;", - "", - ] - .join("\n"), - [ - " let w = 1;", - " let x = 2;", - " let y = 3;", - " let z = 4;", - "", - ] - .join("\n"), - ), - ( - Path::new("src/foo.rs").into(), - [ - " let c = 1;", // - "", - ] - .join("\n"), - String::new(), - ) - ] - ); + if !is_indexing && latest_error.is_none() { + return Empty.into_any(); } - #[gpui::test] - async fn test_serialization(cx: &mut TestAppContext) { - let settings_store = cx.update(SettingsStore::test); - cx.set_global(settings_store); - cx.set_global(CompletionProvider::Fake(FakeCompletionProvider::default())); - cx.update(init); - let registry = Arc::new(LanguageRegistry::test(cx.executor())); - let conversation = cx.new_model(|cx| { - Conversation::new( - LanguageModel::default(), - registry.clone(), - Default::default(), - None, - None, - cx, - ) - }); - let buffer = conversation.read_with(cx, |conversation, _| conversation.buffer.clone()); - let message_0 = - conversation.read_with(cx, |conversation, _| conversation.message_anchors[0].id); - let message_1 = conversation.update(cx, |conversation, cx| { - conversation - .insert_message_after(message_0, Role::Assistant, MessageStatus::Done, cx) - .unwrap() - }); - let message_2 = conversation.update(cx, |conversation, cx| { - conversation - .insert_message_after(message_1.id, Role::System, MessageStatus::Done, cx) - .unwrap() - }); - buffer.update(cx, |buffer, cx| { - buffer.edit([(0..0, "a"), (1..1, "b\nc")], None, cx); - buffer.finalize_last_transaction(); - }); - let _message_3 = conversation.update(cx, |conversation, cx| { - conversation - .insert_message_after(message_2.id, Role::System, MessageStatus::Done, cx) - .unwrap() - }); - buffer.update(cx, |buffer, cx| buffer.undo(cx)); - assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "a\nb\nc\n"); - assert_eq!( - cx.read(|cx| messages(&conversation, cx)), - [ - (message_0, Role::User, 0..2), - (message_1.id, Role::Assistant, 2..6), - (message_2.id, Role::System, 6..6), - ] - ); + h_flex().gap_2().children(children).into_any_element() +} - let deserialized_conversation = Conversation::deserialize( - conversation.read_with(cx, |conversation, cx| conversation.serialize(cx)), - LanguageModel::default(), - Default::default(), - registry.clone(), - Default::default(), - None, - None, - &mut cx.to_async(), - ) - .await - .unwrap(); - let deserialized_buffer = - deserialized_conversation.read_with(cx, |conversation, _| conversation.buffer.clone()); - assert_eq!( - deserialized_buffer.read_with(cx, |buffer, _| buffer.text()), - "a\nb\nc\n" - ); - assert_eq!( - cx.read(|cx| messages(&deserialized_conversation, cx)), - [ - (message_0, Role::User, 0..2), - (message_1.id, Role::Assistant, 2..6), - (message_2.id, Role::System, 6..6), - ] - ); - } +fn make_lsp_adapter_delegate( + project: &Model, + cx: &mut AppContext, +) -> Result> { + project.update(cx, |project, cx| { + // TODO: Find the right worktree. + let worktree = project + .worktrees() + .next() + .ok_or_else(|| anyhow!("no worktrees when constructing ProjectLspAdapterDelegate"))?; + Ok(ProjectLspAdapterDelegate::new(project, &worktree, cx) as Arc) + }) +} - fn messages( - conversation: &Model, - cx: &AppContext, - ) -> Vec<(MessageId, Role, Range)> { - conversation - .read(cx) - .messages(cx) - .map(|message| (message.id, message.role, message.offset_range)) - .collect() - } +fn slash_command_error_block_renderer(message: String) -> RenderBlock { + Box::new(move |_| { + div() + .pl_6() + .child( + Label::new(format!("error: {}", message)) + .single_line() + .color(Color::Error), + ) + .into_any() + }) } diff --git a/crates/assistant/src/assistant_settings.rs b/crates/assistant/src/assistant_settings.rs index d822db8f7028ea..d341973326d623 100644 --- a/crates/assistant/src/assistant_settings.rs +++ b/crates/assistant/src/assistant_settings.rs @@ -1,7 +1,9 @@ use std::fmt; +use crate::{preprocess_anthropic_request, LanguageModel, LanguageModelRequest}; pub use anthropic::Model as AnthropicModel; use gpui::Pixels; +pub use ollama::Model as OllamaModel; pub use open_ai::Model as OpenAiModel; use schemars::{ schema::{InstanceType, Metadata, Schema, SchemaObject}, @@ -12,21 +14,26 @@ use serde::{ Deserialize, Deserializer, Serialize, Serializer, }; use settings::{Settings, SettingsSources}; +use strum::{EnumIter, IntoEnumIterator}; -#[derive(Clone, Debug, Default, PartialEq)] -pub enum ZedDotDevModel { +#[derive(Clone, Debug, Default, PartialEq, EnumIter)] +pub enum CloudModel { Gpt3Point5Turbo, Gpt4, Gpt4Turbo, #[default] Gpt4Omni, + Gpt4OmniMini, + Claude3_5Sonnet, Claude3Opus, Claude3Sonnet, Claude3Haiku, + Gemini15Pro, + Gemini15Flash, Custom(String), } -impl Serialize for ZedDotDevModel { +impl Serialize for CloudModel { fn serialize(&self, serializer: S) -> Result where S: Serializer, @@ -35,7 +42,7 @@ impl Serialize for ZedDotDevModel { } } -impl<'de> Deserialize<'de> for ZedDotDevModel { +impl<'de> Deserialize<'de> for CloudModel { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, @@ -43,7 +50,7 @@ impl<'de> Deserialize<'de> for ZedDotDevModel { struct ZedDotDevModelVisitor; impl<'de> Visitor<'de> for ZedDotDevModelVisitor { - type Value = ZedDotDevModel; + type Value = CloudModel; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("a string for a ZedDotDevModel variant or a custom model") @@ -53,13 +60,10 @@ impl<'de> Deserialize<'de> for ZedDotDevModel { where E: de::Error, { - match value { - "gpt-3.5-turbo" => Ok(ZedDotDevModel::Gpt3Point5Turbo), - "gpt-4" => Ok(ZedDotDevModel::Gpt4), - "gpt-4-turbo-preview" => Ok(ZedDotDevModel::Gpt4Turbo), - "gpt-4o" => Ok(ZedDotDevModel::Gpt4Omni), - _ => Ok(ZedDotDevModel::Custom(value.to_owned())), - } + let model = CloudModel::iter() + .find(|model| model.id() == value) + .unwrap_or_else(|| CloudModel::Custom(value.to_string())); + Ok(model) } } @@ -67,30 +71,29 @@ impl<'de> Deserialize<'de> for ZedDotDevModel { } } -impl JsonSchema for ZedDotDevModel { +impl JsonSchema for CloudModel { fn schema_name() -> String { "ZedDotDevModel".to_owned() } fn json_schema(_generator: &mut schemars::gen::SchemaGenerator) -> Schema { - let variants = vec![ - "gpt-3.5-turbo".to_owned(), - "gpt-4".to_owned(), - "gpt-4-turbo-preview".to_owned(), - "gpt-4o".to_owned(), - ]; + let variants = CloudModel::iter() + .filter_map(|model| { + let id = model.id(); + if id.is_empty() { + None + } else { + Some(id.to_string()) + } + }) + .collect::>(); Schema::Object(SchemaObject { instance_type: Some(InstanceType::String.into()), - enum_values: Some(variants.into_iter().map(|s| s.into()).collect()), + enum_values: Some(variants.iter().map(|s| s.clone().into()).collect()), metadata: Some(Box::new(Metadata { title: Some("ZedDotDevModel".to_owned()), - default: Some(serde_json::json!("gpt-4-turbo-preview")), - examples: vec![ - serde_json::json!("gpt-3.5-turbo"), - serde_json::json!("gpt-4"), - serde_json::json!("gpt-4-turbo-preview"), - serde_json::json!("custom-model-name"), - ], + default: Some(CloudModel::default().id().into()), + examples: variants.into_iter().map(Into::into).collect(), ..Default::default() })), ..Default::default() @@ -98,16 +101,20 @@ impl JsonSchema for ZedDotDevModel { } } -impl ZedDotDevModel { +impl CloudModel { pub fn id(&self) -> &str { match self { Self::Gpt3Point5Turbo => "gpt-3.5-turbo", Self::Gpt4 => "gpt-4", Self::Gpt4Turbo => "gpt-4-turbo-preview", Self::Gpt4Omni => "gpt-4o", + Self::Gpt4OmniMini => "gpt-4o-mini", + Self::Claude3_5Sonnet => "claude-3-5-sonnet", Self::Claude3Opus => "claude-3-opus", Self::Claude3Sonnet => "claude-3-sonnet", Self::Claude3Haiku => "claude-3-haiku", + Self::Gemini15Pro => "gemini-1.5-pro", + Self::Gemini15Flash => "gemini-1.5-flash", Self::Custom(id) => id, } } @@ -118,9 +125,13 @@ impl ZedDotDevModel { Self::Gpt4 => "GPT 4", Self::Gpt4Turbo => "GPT 4 Turbo", Self::Gpt4Omni => "GPT 4 Omni", + Self::Gpt4OmniMini => "GPT 4 Omni Mini", + Self::Claude3_5Sonnet => "Claude 3.5 Sonnet", Self::Claude3Opus => "Claude 3 Opus", Self::Claude3Sonnet => "Claude 3 Sonnet", Self::Claude3Haiku => "Claude 3 Haiku", + Self::Gemini15Pro => "Gemini 1.5 Pro", + Self::Gemini15Flash => "Gemini 1.5 Flash", Self::Custom(id) => id.as_str(), } } @@ -130,10 +141,25 @@ impl ZedDotDevModel { Self::Gpt3Point5Turbo => 2048, Self::Gpt4 => 4096, Self::Gpt4Turbo | Self::Gpt4Omni => 128000, - Self::Claude3Opus | Self::Claude3Sonnet | Self::Claude3Haiku => 200000, + Self::Gpt4OmniMini => 128000, + Self::Claude3_5Sonnet + | Self::Claude3Opus + | Self::Claude3Sonnet + | Self::Claude3Haiku => 200000, + Self::Gemini15Pro => 128000, + Self::Gemini15Flash => 32000, Self::Custom(_) => 4096, // TODO: Make this configurable } } + + pub fn preprocess_request(&self, request: &mut LanguageModelRequest) { + match self { + Self::Claude3Opus | Self::Claude3Sonnet | Self::Claude3Haiku => { + preprocess_anthropic_request(request) + } + _ => {} + } + } } #[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema)] @@ -145,51 +171,67 @@ pub enum AssistantDockPosition { Bottom, } -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] -#[serde(tag = "name", rename_all = "snake_case")] +#[derive(Debug, PartialEq)] pub enum AssistantProvider { - #[serde(rename = "zed.dev")] ZedDotDev { - #[serde(default)] - default_model: ZedDotDevModel, + model: CloudModel, }, - #[serde(rename = "openai")] OpenAi { - #[serde(default)] - default_model: OpenAiModel, - #[serde(default = "open_ai_url")] + model: OpenAiModel, api_url: String, - #[serde(default)] low_speed_timeout_in_seconds: Option, + available_models: Vec, }, - #[serde(rename = "anthropic")] Anthropic { - #[serde(default)] - default_model: AnthropicModel, - #[serde(default = "anthropic_api_url")] + model: AnthropicModel, + api_url: String, + low_speed_timeout_in_seconds: Option, + }, + Ollama { + model: OllamaModel, api_url: String, - #[serde(default)] low_speed_timeout_in_seconds: Option, }, } impl Default for AssistantProvider { fn default() -> Self { - Self::ZedDotDev { - default_model: ZedDotDevModel::default(), + Self::OpenAi { + model: OpenAiModel::default(), + api_url: open_ai::OPEN_AI_API_URL.into(), + low_speed_timeout_in_seconds: None, + available_models: Default::default(), } } } -fn open_ai_url() -> String { - open_ai::OPEN_AI_API_URL.to_string() -} - -fn anthropic_api_url() -> String { - anthropic::ANTHROPIC_API_URL.to_string() +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(tag = "name", rename_all = "snake_case")] +pub enum AssistantProviderContent { + #[serde(rename = "zed.dev")] + ZedDotDev { default_model: Option }, + #[serde(rename = "openai")] + OpenAi { + default_model: Option, + api_url: Option, + low_speed_timeout_in_seconds: Option, + available_models: Option>, + }, + #[serde(rename = "anthropic")] + Anthropic { + default_model: Option, + api_url: Option, + low_speed_timeout_in_seconds: Option, + }, + #[serde(rename = "ollama")] + Ollama { + default_model: Option, + api_url: Option, + low_speed_timeout_in_seconds: Option, + }, } -#[derive(Default, Debug, Deserialize, Serialize)] +#[derive(Debug, Default)] pub struct AssistantSettings { pub enabled: bool, pub button: bool, @@ -240,17 +282,19 @@ impl AssistantSettingsContent { default_width: settings.default_width, default_height: settings.default_height, provider: if let Some(open_ai_api_url) = settings.openai_api_url.as_ref() { - Some(AssistantProvider::OpenAi { - default_model: settings.default_open_ai_model.clone().unwrap_or_default(), - api_url: open_ai_api_url.clone(), + Some(AssistantProviderContent::OpenAi { + default_model: settings.default_open_ai_model.clone(), + api_url: Some(open_ai_api_url.clone()), low_speed_timeout_in_seconds: None, + available_models: Some(Default::default()), }) } else { settings.default_open_ai_model.clone().map(|open_ai_model| { - AssistantProvider::OpenAi { - default_model: open_ai_model, - api_url: open_ai_url(), + AssistantProviderContent::OpenAi { + default_model: Some(open_ai_model), + api_url: None, low_speed_timeout_in_seconds: None, + available_models: Some(Default::default()), } }) }, @@ -270,6 +314,80 @@ impl AssistantSettingsContent { } } } + + pub fn set_model(&mut self, new_model: LanguageModel) { + match self { + AssistantSettingsContent::Versioned(settings) => match settings { + VersionedAssistantSettingsContent::V1(settings) => match &mut settings.provider { + Some(AssistantProviderContent::ZedDotDev { + default_model: model, + }) => { + if let LanguageModel::Cloud(new_model) = new_model { + *model = Some(new_model); + } + } + Some(AssistantProviderContent::OpenAi { + default_model: model, + .. + }) => { + if let LanguageModel::OpenAi(new_model) = new_model { + *model = Some(new_model); + } + } + Some(AssistantProviderContent::Anthropic { + default_model: model, + .. + }) => { + if let LanguageModel::Anthropic(new_model) = new_model { + *model = Some(new_model); + } + } + Some(AssistantProviderContent::Ollama { + default_model: model, + .. + }) => { + if let LanguageModel::Ollama(new_model) = new_model { + *model = Some(new_model); + } + } + provider => match new_model { + LanguageModel::Cloud(model) => { + *provider = Some(AssistantProviderContent::ZedDotDev { + default_model: Some(model), + }) + } + LanguageModel::OpenAi(model) => { + *provider = Some(AssistantProviderContent::OpenAi { + default_model: Some(model), + api_url: None, + low_speed_timeout_in_seconds: None, + available_models: Some(Default::default()), + }) + } + LanguageModel::Anthropic(model) => { + *provider = Some(AssistantProviderContent::Anthropic { + default_model: Some(model), + api_url: None, + low_speed_timeout_in_seconds: None, + }) + } + LanguageModel::Ollama(model) => { + *provider = Some(AssistantProviderContent::Ollama { + default_model: Some(model), + api_url: None, + low_speed_timeout_in_seconds: None, + }) + } + }, + }, + }, + AssistantSettingsContent::Legacy(settings) => { + if let LanguageModel::OpenAi(model) = new_model { + settings.default_open_ai_model = Some(model); + } + } + } + } } #[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] @@ -318,7 +436,7 @@ pub struct AssistantSettingsContentV1 { /// /// This can either be the internal `zed.dev` service or an external `openai` service, /// each with their respective default models and configurations. - provider: Option, + provider: Option, } #[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] @@ -376,31 +494,117 @@ impl Settings for AssistantSettings { if let Some(provider) = value.provider.clone() { match (&mut settings.provider, provider) { ( - AssistantProvider::ZedDotDev { default_model }, - AssistantProvider::ZedDotDev { - default_model: default_model_override, + AssistantProvider::ZedDotDev { model }, + AssistantProviderContent::ZedDotDev { + default_model: model_override, }, ) => { - *default_model = default_model_override; + merge(model, model_override); } ( AssistantProvider::OpenAi { - default_model, + model, api_url, low_speed_timeout_in_seconds, + available_models, }, - AssistantProvider::OpenAi { - default_model: default_model_override, + AssistantProviderContent::OpenAi { + default_model: model_override, + api_url: api_url_override, + low_speed_timeout_in_seconds: low_speed_timeout_in_seconds_override, + available_models: available_models_override, + }, + ) => { + merge(model, model_override); + merge(api_url, api_url_override); + merge(available_models, available_models_override); + if let Some(low_speed_timeout_in_seconds_override) = + low_speed_timeout_in_seconds_override + { + *low_speed_timeout_in_seconds = + Some(low_speed_timeout_in_seconds_override); + } + } + ( + AssistantProvider::Ollama { + model, + api_url, + low_speed_timeout_in_seconds, + }, + AssistantProviderContent::Ollama { + default_model: model_override, api_url: api_url_override, low_speed_timeout_in_seconds: low_speed_timeout_in_seconds_override, }, ) => { - *default_model = default_model_override; - *api_url = api_url_override; - *low_speed_timeout_in_seconds = low_speed_timeout_in_seconds_override; + merge(model, model_override); + merge(api_url, api_url_override); + if let Some(low_speed_timeout_in_seconds_override) = + low_speed_timeout_in_seconds_override + { + *low_speed_timeout_in_seconds = + Some(low_speed_timeout_in_seconds_override); + } + } + ( + AssistantProvider::Anthropic { + model, + api_url, + low_speed_timeout_in_seconds, + }, + AssistantProviderContent::Anthropic { + default_model: model_override, + api_url: api_url_override, + low_speed_timeout_in_seconds: low_speed_timeout_in_seconds_override, + }, + ) => { + merge(model, model_override); + merge(api_url, api_url_override); + if let Some(low_speed_timeout_in_seconds_override) = + low_speed_timeout_in_seconds_override + { + *low_speed_timeout_in_seconds = + Some(low_speed_timeout_in_seconds_override); + } } - (merged, provider_override) => { - *merged = provider_override; + (provider, provider_override) => { + *provider = match provider_override { + AssistantProviderContent::ZedDotDev { + default_model: model, + } => AssistantProvider::ZedDotDev { + model: model.unwrap_or_default(), + }, + AssistantProviderContent::OpenAi { + default_model: model, + api_url, + low_speed_timeout_in_seconds, + available_models, + } => AssistantProvider::OpenAi { + model: model.unwrap_or_default(), + api_url: api_url.unwrap_or_else(|| open_ai::OPEN_AI_API_URL.into()), + low_speed_timeout_in_seconds, + available_models: available_models.unwrap_or_default(), + }, + AssistantProviderContent::Anthropic { + default_model: model, + api_url, + low_speed_timeout_in_seconds, + } => AssistantProvider::Anthropic { + model: model.unwrap_or_default(), + api_url: api_url + .unwrap_or_else(|| anthropic::ANTHROPIC_API_URL.into()), + low_speed_timeout_in_seconds, + }, + AssistantProviderContent::Ollama { + default_model: model, + api_url, + low_speed_timeout_in_seconds, + } => AssistantProvider::Ollama { + model: model.unwrap_or_default(), + api_url: api_url.unwrap_or_else(|| ollama::OLLAMA_API_URL.into()), + low_speed_timeout_in_seconds, + }, + }; } } } @@ -410,7 +614,7 @@ impl Settings for AssistantSettings { } } -fn merge(target: &mut T, value: Option) { +fn merge(target: &mut T, value: Option) { if let Some(value) = value { *target = value; } @@ -433,9 +637,10 @@ mod tests { assert_eq!( AssistantSettings::get_global(cx).provider, AssistantProvider::OpenAi { - default_model: OpenAiModel::FourOmni, - api_url: open_ai_url(), + model: OpenAiModel::FourOmni, + api_url: open_ai::OPEN_AI_API_URL.into(), low_speed_timeout_in_seconds: None, + available_models: Default::default(), } ); @@ -455,9 +660,10 @@ mod tests { assert_eq!( AssistantSettings::get_global(cx).provider, AssistantProvider::OpenAi { - default_model: OpenAiModel::FourOmni, + model: OpenAiModel::FourOmni, api_url: "test-url".into(), low_speed_timeout_in_seconds: None, + available_models: Default::default(), } ); SettingsStore::update_global(cx, |store, cx| { @@ -475,9 +681,10 @@ mod tests { assert_eq!( AssistantSettings::get_global(cx).provider, AssistantProvider::OpenAi { - default_model: OpenAiModel::Four, - api_url: open_ai_url(), + model: OpenAiModel::Four, + api_url: open_ai::OPEN_AI_API_URL.into(), low_speed_timeout_in_seconds: None, + available_models: Default::default(), } ); @@ -501,7 +708,7 @@ mod tests { assert_eq!( AssistantSettings::get_global(cx).provider, AssistantProvider::ZedDotDev { - default_model: ZedDotDevModel::Custom("custom".into()) + model: CloudModel::Custom("custom".into()) } ); } diff --git a/crates/assistant/src/codegen.rs b/crates/assistant/src/codegen.rs deleted file mode 100644 index 8483a2ae14e64d..00000000000000 --- a/crates/assistant/src/codegen.rs +++ /dev/null @@ -1,696 +0,0 @@ -use crate::{ - streaming_diff::{Hunk, StreamingDiff}, - CompletionProvider, LanguageModelRequest, -}; -use anyhow::Result; -use client::telemetry::Telemetry; -use editor::{Anchor, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint}; -use futures::{channel::mpsc, SinkExt, Stream, StreamExt}; -use gpui::{EventEmitter, Model, ModelContext, Task}; -use language::{Rope, TransactionId}; -use multi_buffer::MultiBufferRow; -use std::{cmp, future, ops::Range, sync::Arc, time::Instant}; - -pub enum Event { - Finished, - Undone, -} - -#[derive(Clone)] -pub enum CodegenKind { - Transform { range: Range }, - Generate { position: Anchor }, -} - -pub struct Codegen { - buffer: Model, - snapshot: MultiBufferSnapshot, - kind: CodegenKind, - last_equal_ranges: Vec>, - transaction_id: Option, - error: Option, - generation: Task<()>, - idle: bool, - telemetry: Option>, - _subscription: gpui::Subscription, -} - -impl EventEmitter for Codegen {} - -impl Codegen { - pub fn new( - buffer: Model, - kind: CodegenKind, - telemetry: Option>, - cx: &mut ModelContext, - ) -> Self { - let snapshot = buffer.read(cx).snapshot(cx); - Self { - buffer: buffer.clone(), - snapshot, - kind, - last_equal_ranges: Default::default(), - transaction_id: Default::default(), - error: Default::default(), - idle: true, - generation: Task::ready(()), - telemetry, - _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), - } - } - - fn handle_buffer_event( - &mut self, - _buffer: Model, - event: &multi_buffer::Event, - cx: &mut ModelContext, - ) { - if let multi_buffer::Event::TransactionUndone { transaction_id } = event { - if self.transaction_id == Some(*transaction_id) { - self.transaction_id = None; - self.generation = Task::ready(()); - cx.emit(Event::Undone); - } - } - } - - pub fn range(&self) -> Range { - match &self.kind { - CodegenKind::Transform { range } => range.clone(), - CodegenKind::Generate { position } => position.bias_left(&self.snapshot)..*position, - } - } - - pub fn kind(&self) -> &CodegenKind { - &self.kind - } - - pub fn last_equal_ranges(&self) -> &[Range] { - &self.last_equal_ranges - } - - pub fn idle(&self) -> bool { - self.idle - } - - pub fn error(&self) -> Option<&anyhow::Error> { - self.error.as_ref() - } - - pub fn start(&mut self, prompt: LanguageModelRequest, cx: &mut ModelContext) { - let range = self.range(); - let snapshot = self.snapshot.clone(); - let selected_text = snapshot - .text_for_range(range.start..range.end) - .collect::(); - - let selection_start = range.start.to_point(&snapshot); - let suggested_line_indent = snapshot - .suggested_indents(selection_start.row..selection_start.row + 1, cx) - .into_values() - .next() - .unwrap_or_else(|| snapshot.indent_size_for_line(MultiBufferRow(selection_start.row))); - - let model_telemetry_id = prompt.model.telemetry_id(); - let response = CompletionProvider::global(cx).complete(prompt); - let telemetry = self.telemetry.clone(); - self.generation = cx.spawn(|this, mut cx| { - async move { - let generate = async { - let mut edit_start = range.start.to_offset(&snapshot); - - let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); - let diff = cx.background_executor().spawn(async move { - let mut response_latency = None; - let request_start = Instant::now(); - let diff = async { - let chunks = strip_invalid_spans_from_codeblock(response.await?); - futures::pin_mut!(chunks); - let mut diff = StreamingDiff::new(selected_text.to_string()); - - let mut new_text = String::new(); - let mut base_indent = None; - let mut line_indent = None; - let mut first_line = true; - - while let Some(chunk) = chunks.next().await { - if response_latency.is_none() { - response_latency = Some(request_start.elapsed()); - } - let chunk = chunk?; - - let mut lines = chunk.split('\n').peekable(); - while let Some(line) = lines.next() { - new_text.push_str(line); - if line_indent.is_none() { - if let Some(non_whitespace_ch_ix) = - new_text.find(|ch: char| !ch.is_whitespace()) - { - line_indent = Some(non_whitespace_ch_ix); - base_indent = base_indent.or(line_indent); - - let line_indent = line_indent.unwrap(); - let base_indent = base_indent.unwrap(); - let indent_delta = - line_indent as i32 - base_indent as i32; - let mut corrected_indent_len = cmp::max( - 0, - suggested_line_indent.len as i32 + indent_delta, - ) - as usize; - if first_line { - corrected_indent_len = corrected_indent_len - .saturating_sub( - selection_start.column as usize, - ); - } - - let indent_char = suggested_line_indent.char(); - let mut indent_buffer = [0; 4]; - let indent_str = - indent_char.encode_utf8(&mut indent_buffer); - new_text.replace_range( - ..line_indent, - &indent_str.repeat(corrected_indent_len), - ); - } - } - - if line_indent.is_some() { - hunks_tx.send(diff.push_new(&new_text)).await?; - new_text.clear(); - } - - if lines.peek().is_some() { - hunks_tx.send(diff.push_new("\n")).await?; - line_indent = None; - first_line = false; - } - } - } - hunks_tx.send(diff.push_new(&new_text)).await?; - hunks_tx.send(diff.finish()).await?; - - anyhow::Ok(()) - }; - - let error_message = diff.await.err().map(|error| error.to_string()); - if let Some(telemetry) = telemetry { - telemetry.report_assistant_event( - None, - telemetry_events::AssistantKind::Inline, - model_telemetry_id, - response_latency, - error_message, - ); - } - }); - - while let Some(hunks) = hunks_rx.next().await { - this.update(&mut cx, |this, cx| { - this.last_equal_ranges.clear(); - - let transaction = this.buffer.update(cx, |buffer, cx| { - // Avoid grouping assistant edits with user edits. - buffer.finalize_last_transaction(cx); - - buffer.start_transaction(cx); - buffer.edit( - hunks.into_iter().filter_map(|hunk| match hunk { - Hunk::Insert { text } => { - let edit_start = snapshot.anchor_after(edit_start); - Some((edit_start..edit_start, text)) - } - Hunk::Remove { len } => { - let edit_end = edit_start + len; - let edit_range = snapshot.anchor_after(edit_start) - ..snapshot.anchor_before(edit_end); - edit_start = edit_end; - Some((edit_range, String::new())) - } - Hunk::Keep { len } => { - let edit_end = edit_start + len; - let edit_range = snapshot.anchor_after(edit_start) - ..snapshot.anchor_before(edit_end); - edit_start = edit_end; - this.last_equal_ranges.push(edit_range); - None - } - }), - None, - cx, - ); - - buffer.end_transaction(cx) - }); - - if let Some(transaction) = transaction { - if let Some(first_transaction) = this.transaction_id { - // Group all assistant edits into the first transaction. - this.buffer.update(cx, |buffer, cx| { - buffer.merge_transactions( - transaction, - first_transaction, - cx, - ) - }); - } else { - this.transaction_id = Some(transaction); - this.buffer.update(cx, |buffer, cx| { - buffer.finalize_last_transaction(cx) - }); - } - } - - cx.notify(); - })?; - } - - diff.await; - - anyhow::Ok(()) - }; - - let result = generate.await; - this.update(&mut cx, |this, cx| { - this.last_equal_ranges.clear(); - this.idle = true; - if let Err(error) = result { - this.error = Some(error); - } - cx.emit(Event::Finished); - cx.notify(); - }) - .ok(); - } - }); - self.error.take(); - self.idle = false; - cx.notify(); - } - - pub fn undo(&mut self, cx: &mut ModelContext) { - if let Some(transaction_id) = self.transaction_id { - self.buffer - .update(cx, |buffer, cx| buffer.undo_transaction(transaction_id, cx)); - } - } -} - -fn strip_invalid_spans_from_codeblock( - stream: impl Stream>, -) -> impl Stream> { - let mut first_line = true; - let mut buffer = String::new(); - let mut starts_with_markdown_codeblock = false; - let mut includes_start_or_end_span = false; - stream.filter_map(move |chunk| { - let chunk = match chunk { - Ok(chunk) => chunk, - Err(err) => return future::ready(Some(Err(err))), - }; - buffer.push_str(&chunk); - - if buffer.len() > "<|S|".len() && buffer.starts_with("<|S|") { - includes_start_or_end_span = true; - - buffer = buffer - .strip_prefix("<|S|>") - .or_else(|| buffer.strip_prefix("<|S|")) - .unwrap_or(&buffer) - .to_string(); - } else if buffer.ends_with("|E|>") { - includes_start_or_end_span = true; - } else if buffer.starts_with("<|") - || buffer.starts_with("<|S") - || buffer.starts_with("<|S|") - || buffer.ends_with('|') - || buffer.ends_with("|E") - || buffer.ends_with("|E|") - { - return future::ready(None); - } - - if first_line { - if buffer.is_empty() || buffer == "`" || buffer == "``" { - return future::ready(None); - } else if buffer.starts_with("```") { - starts_with_markdown_codeblock = true; - if let Some(newline_ix) = buffer.find('\n') { - buffer.replace_range(..newline_ix + 1, ""); - first_line = false; - } else { - return future::ready(None); - } - } - } - - let mut text = buffer.to_string(); - if starts_with_markdown_codeblock { - text = text - .strip_suffix("\n```\n") - .or_else(|| text.strip_suffix("\n```")) - .or_else(|| text.strip_suffix("\n``")) - .or_else(|| text.strip_suffix("\n`")) - .or_else(|| text.strip_suffix('\n')) - .unwrap_or(&text) - .to_string(); - } - - if includes_start_or_end_span { - text = text - .strip_suffix("|E|>") - .or_else(|| text.strip_suffix("E|>")) - .or_else(|| text.strip_prefix("|>")) - .or_else(|| text.strip_prefix('>')) - .unwrap_or(&text) - .to_string(); - }; - - if text.contains('\n') { - first_line = false; - } - - let remainder = buffer.split_off(text.len()); - let result = if buffer.is_empty() { - None - } else { - Some(Ok(buffer.clone())) - }; - - buffer = remainder; - future::ready(result) - }) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use crate::FakeCompletionProvider; - - use super::*; - use futures::stream::{self}; - use gpui::{Context, TestAppContext}; - use indoc::indoc; - use language::{ - language_settings, tree_sitter_rust, Buffer, Language, LanguageConfig, LanguageMatcher, - Point, - }; - use rand::prelude::*; - use serde::Serialize; - use settings::SettingsStore; - - #[derive(Serialize)] - pub struct DummyCompletionRequest { - pub name: String, - } - - #[gpui::test(iterations = 10)] - async fn test_transform_autoindent(cx: &mut TestAppContext, mut rng: StdRng) { - let provider = FakeCompletionProvider::default(); - cx.set_global(cx.update(SettingsStore::test)); - cx.set_global(CompletionProvider::Fake(provider.clone())); - cx.update(language_settings::init); - - let text = indoc! {" - fn main() { - let x = 0; - for _ in 0..10 { - x += 1; - } - } - "}; - let buffer = - cx.new_model(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); - let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx)); - let range = buffer.read_with(cx, |buffer, cx| { - let snapshot = buffer.snapshot(cx); - snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(4, 5)) - }); - let codegen = cx.new_model(|cx| { - Codegen::new(buffer.clone(), CodegenKind::Transform { range }, None, cx) - }); - - let request = LanguageModelRequest::default(); - codegen.update(cx, |codegen, cx| codegen.start(request, cx)); - - let mut new_text = concat!( - " let mut x = 0;\n", - " while x < 10 {\n", - " x += 1;\n", - " }", - ); - while !new_text.is_empty() { - let max_len = cmp::min(new_text.len(), 10); - let len = rng.gen_range(1..=max_len); - let (chunk, suffix) = new_text.split_at(len); - provider.send_completion(chunk.into()); - new_text = suffix; - cx.background_executor.run_until_parked(); - } - provider.finish_completion(); - cx.background_executor.run_until_parked(); - - assert_eq!( - buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), - indoc! {" - fn main() { - let mut x = 0; - while x < 10 { - x += 1; - } - } - "} - ); - } - - #[gpui::test(iterations = 10)] - async fn test_autoindent_when_generating_past_indentation( - cx: &mut TestAppContext, - mut rng: StdRng, - ) { - let provider = FakeCompletionProvider::default(); - cx.set_global(CompletionProvider::Fake(provider.clone())); - cx.set_global(cx.update(SettingsStore::test)); - cx.update(language_settings::init); - - let text = indoc! {" - fn main() { - le - } - "}; - let buffer = - cx.new_model(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); - let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx)); - let position = buffer.read_with(cx, |buffer, cx| { - let snapshot = buffer.snapshot(cx); - snapshot.anchor_before(Point::new(1, 6)) - }); - let codegen = cx.new_model(|cx| { - Codegen::new(buffer.clone(), CodegenKind::Generate { position }, None, cx) - }); - - let request = LanguageModelRequest::default(); - codegen.update(cx, |codegen, cx| codegen.start(request, cx)); - - let mut new_text = concat!( - "t mut x = 0;\n", - "while x < 10 {\n", - " x += 1;\n", - "}", // - ); - while !new_text.is_empty() { - let max_len = cmp::min(new_text.len(), 10); - let len = rng.gen_range(1..=max_len); - let (chunk, suffix) = new_text.split_at(len); - provider.send_completion(chunk.into()); - new_text = suffix; - cx.background_executor.run_until_parked(); - } - provider.finish_completion(); - cx.background_executor.run_until_parked(); - - assert_eq!( - buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), - indoc! {" - fn main() { - let mut x = 0; - while x < 10 { - x += 1; - } - } - "} - ); - } - - #[gpui::test(iterations = 10)] - async fn test_autoindent_when_generating_before_indentation( - cx: &mut TestAppContext, - mut rng: StdRng, - ) { - let provider = FakeCompletionProvider::default(); - cx.set_global(CompletionProvider::Fake(provider.clone())); - cx.set_global(cx.update(SettingsStore::test)); - cx.update(language_settings::init); - - let text = concat!( - "fn main() {\n", - " \n", - "}\n" // - ); - let buffer = - cx.new_model(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); - let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx)); - let position = buffer.read_with(cx, |buffer, cx| { - let snapshot = buffer.snapshot(cx); - snapshot.anchor_before(Point::new(1, 2)) - }); - let codegen = cx.new_model(|cx| { - Codegen::new(buffer.clone(), CodegenKind::Generate { position }, None, cx) - }); - - let request = LanguageModelRequest::default(); - codegen.update(cx, |codegen, cx| codegen.start(request, cx)); - - let mut new_text = concat!( - "let mut x = 0;\n", - "while x < 10 {\n", - " x += 1;\n", - "}", // - ); - while !new_text.is_empty() { - let max_len = cmp::min(new_text.len(), 10); - let len = rng.gen_range(1..=max_len); - let (chunk, suffix) = new_text.split_at(len); - provider.send_completion(chunk.into()); - new_text = suffix; - cx.background_executor.run_until_parked(); - } - provider.finish_completion(); - cx.background_executor.run_until_parked(); - - assert_eq!( - buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), - indoc! {" - fn main() { - let mut x = 0; - while x < 10 { - x += 1; - } - } - "} - ); - } - - #[gpui::test] - async fn test_strip_invalid_spans_from_codeblock() { - assert_eq!( - strip_invalid_spans_from_codeblock(chunks("Lorem ipsum dolor", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "Lorem ipsum dolor" - ); - assert_eq!( - strip_invalid_spans_from_codeblock(chunks("```\nLorem ipsum dolor", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "Lorem ipsum dolor" - ); - assert_eq!( - strip_invalid_spans_from_codeblock(chunks("```\nLorem ipsum dolor\n```", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "Lorem ipsum dolor" - ); - assert_eq!( - strip_invalid_spans_from_codeblock(chunks("```\nLorem ipsum dolor\n```\n", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "Lorem ipsum dolor" - ); - assert_eq!( - strip_invalid_spans_from_codeblock(chunks( - "```html\n```js\nLorem ipsum dolor\n```\n```", - 2 - )) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "```js\nLorem ipsum dolor\n```" - ); - assert_eq!( - strip_invalid_spans_from_codeblock(chunks("``\nLorem ipsum dolor\n```", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "``\nLorem ipsum dolor\n```" - ); - assert_eq!( - strip_invalid_spans_from_codeblock(chunks("<|S|Lorem ipsum|E|>", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "Lorem ipsum" - ); - - assert_eq!( - strip_invalid_spans_from_codeblock(chunks("<|S|>Lorem ipsum", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "Lorem ipsum" - ); - - assert_eq!( - strip_invalid_spans_from_codeblock(chunks("```\n<|S|>Lorem ipsum\n```", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "Lorem ipsum" - ); - assert_eq!( - strip_invalid_spans_from_codeblock(chunks("```\n<|S|Lorem ipsum|E|>\n```", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "Lorem ipsum" - ); - fn chunks(text: &str, size: usize) -> impl Stream> { - stream::iter( - text.chars() - .collect::>() - .chunks(size) - .map(|chunk| Ok(chunk.iter().collect::())) - .collect::>(), - ) - } - } - - 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()), - ) - .with_indents_query( - r#" - (call_expression) @indent - (field_expression) @indent - (_ "(" ")" @end) @indent - (_ "{" "}" @end) @indent - "#, - ) - .unwrap() - } -} diff --git a/crates/assistant/src/completion_provider.rs b/crates/assistant/src/completion_provider.rs index 666dab5dfce784..13f91f70e32b19 100644 --- a/crates/assistant/src/completion_provider.rs +++ b/crates/assistant/src/completion_provider.rs @@ -1,14 +1,18 @@ mod anthropic; -#[cfg(test)] +mod cloud; +#[cfg(any(test, feature = "test-support"))] mod fake; +mod ollama; mod open_ai; -mod zed; pub use anthropic::*; -#[cfg(test)] +pub use cloud::*; +#[cfg(any(test, feature = "test-support"))] pub use fake::*; +pub use ollama::*; pub use open_ai::*; -pub use zed::*; +use parking_lot::RwLock; +use smol::lock::{Semaphore, SemaphoreGuardArc}; use crate::{ assistant_settings::{AssistantProvider, AssistantSettings}, @@ -16,211 +20,122 @@ use crate::{ }; use anyhow::Result; use client::Client; -use futures::{future::BoxFuture, stream::BoxStream}; +use futures::{future::BoxFuture, stream::BoxStream, StreamExt}; use gpui::{AnyView, AppContext, BorrowAppContext, Task, WindowContext}; use settings::{Settings, SettingsStore}; -use std::sync::Arc; -use std::time::Duration; +use std::{any::Any, pin::Pin, sync::Arc, task::Poll, time::Duration}; + +/// Choose which model to use for openai provider. +/// If the model is not available, try to use the first available model, or fallback to the original model. +fn choose_openai_model( + model: &::open_ai::Model, + available_models: &[::open_ai::Model], +) -> ::open_ai::Model { + available_models + .iter() + .find(|&m| m == model) + .or_else(|| available_models.first()) + .unwrap_or_else(|| model) + .clone() +} pub fn init(client: Arc, cx: &mut AppContext) { - let mut settings_version = 0; - let provider = match &AssistantSettings::get_global(cx).provider { - AssistantProvider::ZedDotDev { default_model } => { - CompletionProvider::ZedDotDev(ZedDotDevCompletionProvider::new( - default_model.clone(), - client.clone(), - settings_version, - cx, - )) - } - AssistantProvider::OpenAi { - default_model, - api_url, - low_speed_timeout_in_seconds, - } => CompletionProvider::OpenAi(OpenAiCompletionProvider::new( - default_model.clone(), - api_url.clone(), - client.http_client(), - low_speed_timeout_in_seconds.map(Duration::from_secs), - settings_version, - )), - AssistantProvider::Anthropic { - default_model, - api_url, - low_speed_timeout_in_seconds, - } => CompletionProvider::Anthropic(AnthropicCompletionProvider::new( - default_model.clone(), - api_url.clone(), - client.http_client(), - low_speed_timeout_in_seconds.map(Duration::from_secs), - settings_version, - )), - }; - cx.set_global(provider); + let provider = create_provider_from_settings(client.clone(), 0, cx); + cx.set_global(CompletionProvider::new(provider, Some(client))); + let mut settings_version = 0; cx.observe_global::(move |cx| { settings_version += 1; cx.update_global::(|provider, cx| { - match (&mut *provider, &AssistantSettings::get_global(cx).provider) { - ( - CompletionProvider::OpenAi(provider), - AssistantProvider::OpenAi { - default_model, - api_url, - low_speed_timeout_in_seconds, - }, - ) => { - provider.update( - default_model.clone(), - api_url.clone(), - low_speed_timeout_in_seconds.map(Duration::from_secs), - settings_version, - ); - } - ( - CompletionProvider::Anthropic(provider), - AssistantProvider::Anthropic { - default_model, - api_url, - low_speed_timeout_in_seconds, - }, - ) => { - provider.update( - default_model.clone(), - api_url.clone(), - low_speed_timeout_in_seconds.map(Duration::from_secs), - settings_version, - ); - } - ( - CompletionProvider::ZedDotDev(provider), - AssistantProvider::ZedDotDev { default_model }, - ) => { - provider.update(default_model.clone(), settings_version); - } - (_, AssistantProvider::ZedDotDev { default_model }) => { - *provider = CompletionProvider::ZedDotDev(ZedDotDevCompletionProvider::new( - default_model.clone(), - client.clone(), - settings_version, - cx, - )); - } - ( - _, - AssistantProvider::OpenAi { - default_model, - api_url, - low_speed_timeout_in_seconds, - }, - ) => { - *provider = CompletionProvider::OpenAi(OpenAiCompletionProvider::new( - default_model.clone(), - api_url.clone(), - client.http_client(), - low_speed_timeout_in_seconds.map(Duration::from_secs), - settings_version, - )); - } - ( - _, - AssistantProvider::Anthropic { - default_model, - api_url, - low_speed_timeout_in_seconds, - }, - ) => { - *provider = CompletionProvider::Anthropic(AnthropicCompletionProvider::new( - default_model.clone(), - api_url.clone(), - client.http_client(), - low_speed_timeout_in_seconds.map(Duration::from_secs), - settings_version, - )); - } - } + provider.update_settings(settings_version, cx); }) }) .detach(); } -pub enum CompletionProvider { - OpenAi(OpenAiCompletionProvider), - Anthropic(AnthropicCompletionProvider), - ZedDotDev(ZedDotDevCompletionProvider), - #[cfg(test)] - Fake(FakeCompletionProvider), +pub struct CompletionResponse { + inner: BoxStream<'static, Result>, + _lock: SemaphoreGuardArc, } -impl gpui::Global for CompletionProvider {} +impl futures::Stream for CompletionResponse { + type Item = Result; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_next(cx) + } +} + +pub trait LanguageModelCompletionProvider: Send + Sync { + fn available_models(&self, cx: &AppContext) -> Vec; + fn settings_version(&self) -> usize; + fn is_authenticated(&self) -> bool; + fn authenticate(&self, cx: &AppContext) -> Task>; + fn authentication_prompt(&self, cx: &mut WindowContext) -> AnyView; + fn reset_credentials(&self, cx: &AppContext) -> Task>; + fn model(&self) -> LanguageModel; + fn count_tokens( + &self, + request: LanguageModelRequest, + cx: &AppContext, + ) -> BoxFuture<'static, Result>; + fn stream_completion( + &self, + request: LanguageModelRequest, + ) -> BoxFuture<'static, Result>>>; + + fn as_any_mut(&mut self) -> &mut dyn Any; +} + +const MAX_CONCURRENT_COMPLETION_REQUESTS: usize = 4; + +pub struct CompletionProvider { + provider: Arc>, + client: Option>, + request_limiter: Arc, +} impl CompletionProvider { - pub fn global(cx: &AppContext) -> &Self { - cx.global::() + pub fn new( + provider: Arc>, + client: Option>, + ) -> Self { + Self { + provider, + client, + request_limiter: Arc::new(Semaphore::new(MAX_CONCURRENT_COMPLETION_REQUESTS)), + } + } + + pub fn available_models(&self, cx: &AppContext) -> Vec { + self.provider.read().available_models(cx) } pub fn settings_version(&self) -> usize { - match self { - CompletionProvider::OpenAi(provider) => provider.settings_version(), - CompletionProvider::Anthropic(provider) => provider.settings_version(), - CompletionProvider::ZedDotDev(provider) => provider.settings_version(), - #[cfg(test)] - CompletionProvider::Fake(_) => unimplemented!(), - } + self.provider.read().settings_version() } pub fn is_authenticated(&self) -> bool { - match self { - CompletionProvider::OpenAi(provider) => provider.is_authenticated(), - CompletionProvider::Anthropic(provider) => provider.is_authenticated(), - CompletionProvider::ZedDotDev(provider) => provider.is_authenticated(), - #[cfg(test)] - CompletionProvider::Fake(_) => true, - } + self.provider.read().is_authenticated() } pub fn authenticate(&self, cx: &AppContext) -> Task> { - match self { - CompletionProvider::OpenAi(provider) => provider.authenticate(cx), - CompletionProvider::Anthropic(provider) => provider.authenticate(cx), - CompletionProvider::ZedDotDev(provider) => provider.authenticate(cx), - #[cfg(test)] - CompletionProvider::Fake(_) => Task::ready(Ok(())), - } + self.provider.read().authenticate(cx) } pub fn authentication_prompt(&self, cx: &mut WindowContext) -> AnyView { - match self { - CompletionProvider::OpenAi(provider) => provider.authentication_prompt(cx), - CompletionProvider::Anthropic(provider) => provider.authentication_prompt(cx), - CompletionProvider::ZedDotDev(provider) => provider.authentication_prompt(cx), - #[cfg(test)] - CompletionProvider::Fake(_) => unimplemented!(), - } + self.provider.read().authentication_prompt(cx) } pub fn reset_credentials(&self, cx: &AppContext) -> Task> { - match self { - CompletionProvider::OpenAi(provider) => provider.reset_credentials(cx), - CompletionProvider::Anthropic(provider) => provider.reset_credentials(cx), - CompletionProvider::ZedDotDev(_) => Task::ready(Ok(())), - #[cfg(test)] - CompletionProvider::Fake(_) => Task::ready(Ok(())), - } + self.provider.read().reset_credentials(cx) } - pub fn default_model(&self) -> LanguageModel { - match self { - CompletionProvider::OpenAi(provider) => LanguageModel::OpenAi(provider.default_model()), - CompletionProvider::Anthropic(provider) => { - LanguageModel::Anthropic(provider.default_model()) - } - CompletionProvider::ZedDotDev(provider) => { - LanguageModel::ZedDotDev(provider.default_model()) - } - #[cfg(test)] - CompletionProvider::Fake(_) => unimplemented!(), - } + pub fn model(&self) -> LanguageModel { + self.provider.read().model() } pub fn count_tokens( @@ -228,25 +143,254 @@ impl CompletionProvider { request: LanguageModelRequest, cx: &AppContext, ) -> BoxFuture<'static, Result> { - match self { - CompletionProvider::OpenAi(provider) => provider.count_tokens(request, cx), - CompletionProvider::Anthropic(provider) => provider.count_tokens(request, cx), - CompletionProvider::ZedDotDev(provider) => provider.count_tokens(request, cx), - #[cfg(test)] - CompletionProvider::Fake(_) => futures::FutureExt::boxed(futures::future::ready(Ok(0))), - } + self.provider.read().count_tokens(request, cx) } - pub fn complete( + pub fn stream_completion( &self, request: LanguageModelRequest, - ) -> BoxFuture<'static, Result>>> { - match self { - CompletionProvider::OpenAi(provider) => provider.complete(request), - CompletionProvider::Anthropic(provider) => provider.complete(request), - CompletionProvider::ZedDotDev(provider) => provider.complete(request), - #[cfg(test)] - CompletionProvider::Fake(provider) => provider.complete(), + cx: &AppContext, + ) -> Task> { + let rate_limiter = self.request_limiter.clone(); + let provider = self.provider.clone(); + cx.foreground_executor().spawn(async move { + let lock = rate_limiter.acquire_arc().await; + let response = provider.read().stream_completion(request); + let response = response.await?; + Ok(CompletionResponse { + inner: response, + _lock: lock, + }) + }) + } + + pub fn complete(&self, request: LanguageModelRequest, cx: &AppContext) -> Task> { + let response = self.stream_completion(request, cx); + cx.foreground_executor().spawn(async move { + let mut chunks = response.await?; + let mut completion = String::new(); + while let Some(chunk) = chunks.next().await { + let chunk = chunk?; + completion.push_str(&chunk); + } + Ok(completion) + }) + } +} + +impl gpui::Global for CompletionProvider {} + +impl CompletionProvider { + pub fn global(cx: &AppContext) -> &Self { + cx.global::() + } + + pub fn update_current_as( + &mut self, + update: impl FnOnce(&mut T) -> R, + ) -> Option { + let mut provider = self.provider.write(); + if let Some(provider) = provider.as_any_mut().downcast_mut::() { + Some(update(provider)) + } else { + None } } + + pub fn update_settings(&mut self, version: usize, cx: &mut AppContext) { + let updated = match &AssistantSettings::get_global(cx).provider { + AssistantProvider::ZedDotDev { model } => self + .update_current_as::<_, CloudCompletionProvider>(|provider| { + provider.update(model.clone(), version); + }), + AssistantProvider::OpenAi { + model, + api_url, + low_speed_timeout_in_seconds, + available_models, + } => self.update_current_as::<_, OpenAiCompletionProvider>(|provider| { + provider.update( + choose_openai_model(&model, &available_models), + api_url.clone(), + low_speed_timeout_in_seconds.map(Duration::from_secs), + version, + ); + }), + AssistantProvider::Anthropic { + model, + api_url, + low_speed_timeout_in_seconds, + } => self.update_current_as::<_, AnthropicCompletionProvider>(|provider| { + provider.update( + model.clone(), + api_url.clone(), + low_speed_timeout_in_seconds.map(Duration::from_secs), + version, + ); + }), + AssistantProvider::Ollama { + model, + api_url, + low_speed_timeout_in_seconds, + } => self.update_current_as::<_, OllamaCompletionProvider>(|provider| { + provider.update( + model.clone(), + api_url.clone(), + low_speed_timeout_in_seconds.map(Duration::from_secs), + version, + cx, + ); + }), + }; + + // Previously configured provider was changed to another one + if updated.is_none() { + if let Some(client) = self.client.clone() { + self.provider = create_provider_from_settings(client, version, cx); + } else { + log::warn!("completion provider cannot be created because client is not set"); + } + } + } +} + +fn create_provider_from_settings( + client: Arc, + settings_version: usize, + cx: &mut AppContext, +) -> Arc> { + match &AssistantSettings::get_global(cx).provider { + AssistantProvider::ZedDotDev { model } => Arc::new(RwLock::new( + CloudCompletionProvider::new(model.clone(), client.clone(), settings_version, cx), + )), + AssistantProvider::OpenAi { + model, + api_url, + low_speed_timeout_in_seconds, + available_models, + } => Arc::new(RwLock::new(OpenAiCompletionProvider::new( + choose_openai_model(&model, &available_models), + api_url.clone(), + client.http_client(), + low_speed_timeout_in_seconds.map(Duration::from_secs), + settings_version, + ))), + AssistantProvider::Anthropic { + model, + api_url, + low_speed_timeout_in_seconds, + } => Arc::new(RwLock::new(AnthropicCompletionProvider::new( + model.clone(), + api_url.clone(), + client.http_client(), + low_speed_timeout_in_seconds.map(Duration::from_secs), + settings_version, + ))), + AssistantProvider::Ollama { + model, + api_url, + low_speed_timeout_in_seconds, + } => Arc::new(RwLock::new(OllamaCompletionProvider::new( + model.clone(), + api_url.clone(), + client.http_client(), + low_speed_timeout_in_seconds.map(Duration::from_secs), + settings_version, + cx, + ))), + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use gpui::AppContext; + use parking_lot::RwLock; + use settings::SettingsStore; + use smol::stream::StreamExt; + + use crate::{ + completion_provider::MAX_CONCURRENT_COMPLETION_REQUESTS, CompletionProvider, + FakeCompletionProvider, LanguageModelRequest, + }; + + #[gpui::test] + fn test_rate_limiting(cx: &mut AppContext) { + SettingsStore::test(cx); + let fake_provider = FakeCompletionProvider::setup_test(cx); + + let provider = CompletionProvider::new(Arc::new(RwLock::new(fake_provider.clone())), None); + + // Enqueue some requests + for i in 0..MAX_CONCURRENT_COMPLETION_REQUESTS * 2 { + let response = provider.stream_completion( + LanguageModelRequest { + temperature: i as f32 / 10.0, + ..Default::default() + }, + cx, + ); + cx.background_executor() + .spawn(async move { + let mut stream = response.await.unwrap(); + while let Some(message) = stream.next().await { + message.unwrap(); + } + }) + .detach(); + } + cx.background_executor().run_until_parked(); + + assert_eq!( + fake_provider.completion_count(), + MAX_CONCURRENT_COMPLETION_REQUESTS + ); + + // Get the first completion request that is in flight and mark it as completed. + let completion = fake_provider + .pending_completions() + .into_iter() + .next() + .unwrap(); + fake_provider.finish_completion(&completion); + + // Ensure that the number of in-flight completion requests is reduced. + assert_eq!( + fake_provider.completion_count(), + MAX_CONCURRENT_COMPLETION_REQUESTS - 1 + ); + + cx.background_executor().run_until_parked(); + + // Ensure that another completion request was allowed to acquire the lock. + assert_eq!( + fake_provider.completion_count(), + MAX_CONCURRENT_COMPLETION_REQUESTS + ); + + // Mark all completion requests as finished that are in flight. + for request in fake_provider.pending_completions() { + fake_provider.finish_completion(&request); + } + + assert_eq!(fake_provider.completion_count(), 0); + + // Wait until the background tasks acquire the lock again. + cx.background_executor().run_until_parked(); + + assert_eq!( + fake_provider.completion_count(), + MAX_CONCURRENT_COMPLETION_REQUESTS - 1 + ); + + // Finish all remaining completion requests. + for request in fake_provider.pending_completions() { + fake_provider.finish_completion(&request); + } + + cx.background_executor().run_until_parked(); + + assert_eq!(fake_provider.completion_count(), 0); + } } diff --git a/crates/assistant/src/completion_provider/anthropic.rs b/crates/assistant/src/completion_provider/anthropic.rs index a203949f542341..48d2020cbee159 100644 --- a/crates/assistant/src/completion_provider/anthropic.rs +++ b/crates/assistant/src/completion_provider/anthropic.rs @@ -1,17 +1,18 @@ -use crate::count_open_ai_tokens; use crate::{ assistant_settings::AnthropicModel, CompletionProvider, LanguageModel, LanguageModelRequest, Role, }; -use anthropic::{stream_completion, Request, RequestMessage, Role as AnthropicRole}; +use crate::{count_open_ai_tokens, LanguageModelCompletionProvider, LanguageModelRequestMessage}; +use anthropic::{stream_completion, Request, RequestMessage}; use anyhow::{anyhow, Result}; use editor::{Editor, EditorElement, EditorStyle}; use futures::{future::BoxFuture, stream::BoxStream, FutureExt, StreamExt}; -use gpui::{AnyView, AppContext, FontStyle, FontWeight, Task, TextStyle, View, WhiteSpace}; +use gpui::{AnyView, AppContext, FontStyle, Task, TextStyle, View, WhiteSpace}; use http::HttpClient; use settings::Settings; use std::time::Duration; use std::{env, sync::Arc}; +use strum::IntoEnumIterator; use theme::ThemeSettings; use ui::prelude::*; use util::ResultExt; @@ -19,52 +20,28 @@ use util::ResultExt; pub struct AnthropicCompletionProvider { api_key: Option, api_url: String, - default_model: AnthropicModel, + model: AnthropicModel, http_client: Arc, low_speed_timeout: Option, settings_version: usize, } -impl AnthropicCompletionProvider { - pub fn new( - default_model: AnthropicModel, - api_url: String, - http_client: Arc, - low_speed_timeout: Option, - settings_version: usize, - ) -> Self { - Self { - api_key: None, - api_url, - default_model, - http_client, - low_speed_timeout, - settings_version, - } - } - - pub fn update( - &mut self, - default_model: AnthropicModel, - api_url: String, - low_speed_timeout: Option, - settings_version: usize, - ) { - self.default_model = default_model; - self.api_url = api_url; - self.low_speed_timeout = low_speed_timeout; - self.settings_version = settings_version; +impl LanguageModelCompletionProvider for AnthropicCompletionProvider { + fn available_models(&self, _cx: &AppContext) -> Vec { + AnthropicModel::iter() + .map(LanguageModel::Anthropic) + .collect() } - pub fn settings_version(&self) -> usize { + fn settings_version(&self) -> usize { self.settings_version } - pub fn is_authenticated(&self) -> bool { + fn is_authenticated(&self) -> bool { self.api_key.is_some() } - pub fn authenticate(&self, cx: &AppContext) -> Task> { + fn authenticate(&self, cx: &AppContext) -> Task> { if self.is_authenticated() { Task::ready(Ok(())) } else { @@ -80,36 +57,36 @@ impl AnthropicCompletionProvider { String::from_utf8(api_key)? }; cx.update_global::(|provider, _cx| { - if let CompletionProvider::Anthropic(provider) = provider { + provider.update_current_as::<_, AnthropicCompletionProvider>(|provider| { provider.api_key = Some(api_key); - } + }); }) }) } } - pub fn reset_credentials(&self, cx: &AppContext) -> Task> { + fn reset_credentials(&self, cx: &AppContext) -> Task> { let delete_credentials = cx.delete_credentials(&self.api_url); cx.spawn(|mut cx| async move { delete_credentials.await.log_err(); cx.update_global::(|provider, _cx| { - if let CompletionProvider::Anthropic(provider) = provider { + provider.update_current_as::<_, AnthropicCompletionProvider>(|provider| { provider.api_key = None; - } + }); }) }) } - pub fn authentication_prompt(&self, cx: &mut WindowContext) -> AnyView { + fn authentication_prompt(&self, cx: &mut WindowContext) -> AnyView { cx.new_view(|cx| AuthenticationPrompt::new(self.api_url.clone(), cx)) .into() } - pub fn default_model(&self) -> AnthropicModel { - self.default_model.clone() + fn model(&self) -> LanguageModel { + LanguageModel::Anthropic(self.model.clone()) } - pub fn count_tokens( + fn count_tokens( &self, request: LanguageModelRequest, cx: &AppContext, @@ -117,7 +94,7 @@ impl AnthropicCompletionProvider { count_open_ai_tokens(request, cx.background_executor()) } - pub fn complete( + fn stream_completion( &self, request: LanguageModelRequest, ) -> BoxFuture<'static, Result>>> { @@ -162,58 +139,121 @@ impl AnthropicCompletionProvider { .boxed() } - fn to_anthropic_request(&self, request: LanguageModelRequest) -> Request { + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } +} + +impl AnthropicCompletionProvider { + pub fn new( + model: AnthropicModel, + api_url: String, + http_client: Arc, + low_speed_timeout: Option, + settings_version: usize, + ) -> Self { + Self { + api_key: None, + api_url, + model, + http_client, + low_speed_timeout, + settings_version, + } + } + + pub fn update( + &mut self, + model: AnthropicModel, + api_url: String, + low_speed_timeout: Option, + settings_version: usize, + ) { + self.model = model; + self.api_url = api_url; + self.low_speed_timeout = low_speed_timeout; + self.settings_version = settings_version; + } + + fn to_anthropic_request(&self, mut request: LanguageModelRequest) -> Request { + preprocess_anthropic_request(&mut request); + let model = match request.model { LanguageModel::Anthropic(model) => model, - _ => self.default_model(), + _ => self.model.clone(), }; let mut system_message = String::new(); + if request + .messages + .first() + .map_or(false, |message| message.role == Role::System) + { + system_message = request.messages.remove(0).content; + } - let mut messages: Vec = Vec::new(); - for message in request.messages { - if message.content.is_empty() { - continue; - } + Request { + model, + messages: request + .messages + .iter() + .map(|msg| RequestMessage { + role: match msg.role { + Role::User => anthropic::Role::User, + Role::Assistant => anthropic::Role::Assistant, + Role::System => unreachable!("filtered out by preprocess_request"), + }, + content: msg.content.clone(), + }) + .collect(), + stream: true, + system: system_message, + max_tokens: 4092, + } + } +} - match message.role { - Role::User | Role::Assistant => { - let role = match message.role { - Role::User => AnthropicRole::User, - Role::Assistant => AnthropicRole::Assistant, - _ => unreachable!(), - }; +pub fn preprocess_anthropic_request(request: &mut LanguageModelRequest) { + let mut new_messages: Vec = Vec::new(); + let mut system_message = String::new(); - if let Some(last_message) = messages.last_mut() { - if last_message.role == role { - last_message.content.push_str("\n\n"); - last_message.content.push_str(&message.content); - continue; - } - } + for message in request.messages.drain(..) { + if message.content.is_empty() { + continue; + } - messages.push(RequestMessage { - role, - content: message.content, - }); - } - Role::System => { - if !system_message.is_empty() { - system_message.push_str("\n\n"); + match message.role { + Role::User | Role::Assistant => { + if let Some(last_message) = new_messages.last_mut() { + if last_message.role == message.role { + last_message.content.push_str("\n\n"); + last_message.content.push_str(&message.content); + continue; } - system_message.push_str(&message.content); } + + new_messages.push(message); + } + Role::System => { + if !system_message.is_empty() { + system_message.push_str("\n\n"); + } + system_message.push_str(&message.content); } } + } - Request { - model, - messages, - stream: true, - system: system_message, - max_tokens: 4092, - } + if !system_message.is_empty() { + new_messages.insert( + 0, + LanguageModelRequestMessage { + role: Role::System, + content: system_message, + }, + ); } + + request.messages = new_messages; } struct AuthenticationPrompt { @@ -246,9 +286,9 @@ impl AuthenticationPrompt { cx.spawn(|_, mut cx| async move { write_credentials.await?; cx.update_global::(|provider, _cx| { - if let CompletionProvider::Anthropic(provider) = provider { + provider.update_current_as::<_, AnthropicCompletionProvider>(|provider| { provider.api_key = Some(api_key); - } + }); }) }) .detach_and_log_err(cx); @@ -261,7 +301,7 @@ impl AuthenticationPrompt { font_family: settings.ui_font.family.clone(), font_features: settings.ui_font.features.clone(), font_size: rems(0.875).into(), - font_weight: FontWeight::NORMAL, + font_weight: settings.ui_font.weight, font_style: FontStyle::Normal, line_height: relative(1.3), background_color: None, @@ -317,7 +357,7 @@ impl Render for AuthenticationPrompt { h_flex() .gap_2() .child(Label::new("Click on").size(LabelSize::Small)) - .child(Icon::new(IconName::Ai).size(IconSize::XSmall)) + .child(Icon::new(IconName::ZedAssistant).size(IconSize::XSmall)) .child( Label::new("in the status bar to close this panel.").size(LabelSize::Small), ), diff --git a/crates/assistant/src/completion_provider/zed.rs b/crates/assistant/src/completion_provider/cloud.rs similarity index 67% rename from crates/assistant/src/completion_provider/zed.rs rename to crates/assistant/src/completion_provider/cloud.rs index 8fa149807204d0..32b8587116c17f 100644 --- a/crates/assistant/src/completion_provider/zed.rs +++ b/crates/assistant/src/completion_provider/cloud.rs @@ -1,25 +1,26 @@ use crate::{ - assistant_settings::ZedDotDevModel, count_open_ai_tokens, CompletionProvider, LanguageModel, - LanguageModelRequest, + assistant_settings::CloudModel, count_open_ai_tokens, CompletionProvider, LanguageModel, + LanguageModelCompletionProvider, LanguageModelRequest, }; use anyhow::{anyhow, Result}; use client::{proto, Client}; use futures::{future::BoxFuture, stream::BoxStream, FutureExt, StreamExt, TryFutureExt}; use gpui::{AnyView, AppContext, Task}; use std::{future, sync::Arc}; +use strum::IntoEnumIterator; use ui::prelude::*; -pub struct ZedDotDevCompletionProvider { +pub struct CloudCompletionProvider { client: Arc, - default_model: ZedDotDevModel, + model: CloudModel, settings_version: usize, status: client::Status, _maintain_client_status: Task<()>, } -impl ZedDotDevCompletionProvider { +impl CloudCompletionProvider { pub fn new( - default_model: ZedDotDevModel, + model: CloudModel, client: Arc, settings_version: usize, cx: &mut AppContext, @@ -29,70 +30,93 @@ impl ZedDotDevCompletionProvider { let maintain_client_status = cx.spawn(|mut cx| async move { while let Some(status) = status_rx.next().await { let _ = cx.update_global::(|provider, _cx| { - if let CompletionProvider::ZedDotDev(provider) = provider { + provider.update_current_as::<_, Self>(|provider| { provider.status = status; - } else { - unreachable!() - } + }); }); } }); Self { client, - default_model, + model, settings_version, status, _maintain_client_status: maintain_client_status, } } - pub fn update(&mut self, default_model: ZedDotDevModel, settings_version: usize) { - self.default_model = default_model; + pub fn update(&mut self, model: CloudModel, settings_version: usize) { + self.model = model; self.settings_version = settings_version; } +} - pub fn settings_version(&self) -> usize { - self.settings_version +impl LanguageModelCompletionProvider for CloudCompletionProvider { + fn available_models(&self, _cx: &AppContext) -> Vec { + let mut custom_model = if let CloudModel::Custom(custom_model) = self.model.clone() { + Some(custom_model) + } else { + None + }; + CloudModel::iter() + .filter_map(move |model| { + if let CloudModel::Custom(_) = model { + Some(CloudModel::Custom(custom_model.take()?)) + } else { + Some(model) + } + }) + .map(LanguageModel::Cloud) + .collect() } - pub fn default_model(&self) -> ZedDotDevModel { - self.default_model.clone() + fn settings_version(&self) -> usize { + self.settings_version } - pub fn is_authenticated(&self) -> bool { + fn is_authenticated(&self) -> bool { self.status.is_connected() } - pub fn authenticate(&self, cx: &AppContext) -> Task> { + fn authenticate(&self, cx: &AppContext) -> Task> { let client = self.client.clone(); cx.spawn(move |cx| async move { client.authenticate_and_connect(true, &cx).await }) } - pub fn authentication_prompt(&self, cx: &mut WindowContext) -> AnyView { + fn authentication_prompt(&self, cx: &mut WindowContext) -> AnyView { cx.new_view(|_cx| AuthenticationPrompt).into() } - pub fn count_tokens( + fn reset_credentials(&self, _cx: &AppContext) -> Task> { + Task::ready(Ok(())) + } + + fn model(&self) -> LanguageModel { + LanguageModel::Cloud(self.model.clone()) + } + + fn count_tokens( &self, request: LanguageModelRequest, cx: &AppContext, ) -> BoxFuture<'static, Result> { match request.model { - LanguageModel::ZedDotDev(ZedDotDevModel::Gpt4) - | LanguageModel::ZedDotDev(ZedDotDevModel::Gpt4Turbo) - | LanguageModel::ZedDotDev(ZedDotDevModel::Gpt4Omni) - | LanguageModel::ZedDotDev(ZedDotDevModel::Gpt3Point5Turbo) => { + LanguageModel::Cloud(CloudModel::Gpt4) + | LanguageModel::Cloud(CloudModel::Gpt4Turbo) + | LanguageModel::Cloud(CloudModel::Gpt4Omni) + | LanguageModel::Cloud(CloudModel::Gpt3Point5Turbo) => { count_open_ai_tokens(request, cx.background_executor()) } - LanguageModel::ZedDotDev( - ZedDotDevModel::Claude3Opus - | ZedDotDevModel::Claude3Sonnet - | ZedDotDevModel::Claude3Haiku, + LanguageModel::Cloud( + CloudModel::Claude3_5Sonnet + | CloudModel::Claude3Opus + | CloudModel::Claude3Sonnet + | CloudModel::Claude3Haiku, ) => { // Can't find a tokenizer for Claude 3, so for now just use the same as OpenAI's as an approximation. count_open_ai_tokens(request, cx.background_executor()) } - LanguageModel::ZedDotDev(ZedDotDevModel::Custom(model)) => { + LanguageModel::Cloud(CloudModel::Custom(model)) => { let request = self.client.request(proto::CountTokensWithLanguageModel { model, messages: request @@ -111,10 +135,12 @@ impl ZedDotDevCompletionProvider { } } - pub fn complete( + fn stream_completion( &self, - request: LanguageModelRequest, + mut request: LanguageModelRequest, ) -> BoxFuture<'static, Result>>> { + request.preprocess(); + let request = proto::CompleteWithLanguageModel { model: request.model.id().to_string(), messages: request @@ -142,6 +168,10 @@ impl ZedDotDevCompletionProvider { }) .boxed() } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } } struct AuthenticationPrompt; diff --git a/crates/assistant/src/completion_provider/fake.rs b/crates/assistant/src/completion_provider/fake.rs index 9c06796a376c74..e9ad8d9a0faa5c 100644 --- a/crates/assistant/src/completion_provider/fake.rs +++ b/crates/assistant/src/completion_provider/fake.rs @@ -1,29 +1,115 @@ use anyhow::Result; +use collections::HashMap; use futures::{channel::mpsc, future::BoxFuture, stream::BoxStream, FutureExt, StreamExt}; +use gpui::{AnyView, AppContext, Task}; use std::sync::Arc; +use ui::WindowContext; + +use crate::{LanguageModel, LanguageModelCompletionProvider, LanguageModelRequest}; #[derive(Clone, Default)] pub struct FakeCompletionProvider { - current_completion_tx: Arc>>>, + current_completion_txs: Arc>>>, } impl FakeCompletionProvider { - pub fn complete(&self) -> BoxFuture<'static, Result>>> { - let (tx, rx) = mpsc::unbounded(); - *self.current_completion_tx.lock() = Some(tx); - async move { Ok(rx.map(Ok).boxed()) }.boxed() + pub fn setup_test(cx: &mut AppContext) -> Self { + use crate::CompletionProvider; + use parking_lot::RwLock; + + let this = Self::default(); + let provider = CompletionProvider::new(Arc::new(RwLock::new(this.clone())), None); + cx.set_global(provider); + this + } + + pub fn pending_completions(&self) -> Vec { + self.current_completion_txs + .lock() + .keys() + .map(|k| serde_json::from_str(k).unwrap()) + .collect() } - pub fn send_completion(&self, chunk: String) { - self.current_completion_tx + pub fn completion_count(&self) -> usize { + self.current_completion_txs.lock().len() + } + + pub fn send_completion_chunk(&self, request: &LanguageModelRequest, chunk: String) { + let json = serde_json::to_string(request).unwrap(); + self.current_completion_txs .lock() - .as_ref() + .get(&json) .unwrap() .unbounded_send(chunk) .unwrap(); } - pub fn finish_completion(&self) { - self.current_completion_tx.lock().take(); + pub fn send_last_completion_chunk(&self, chunk: String) { + self.send_completion_chunk(self.pending_completions().last().unwrap(), chunk); + } + + pub fn finish_completion(&self, request: &LanguageModelRequest) { + self.current_completion_txs + .lock() + .remove(&serde_json::to_string(request).unwrap()) + .unwrap(); + } + + pub fn finish_last_completion(&self) { + self.finish_completion(self.pending_completions().last().unwrap()); + } +} + +impl LanguageModelCompletionProvider for FakeCompletionProvider { + fn available_models(&self, _cx: &AppContext) -> Vec { + vec![LanguageModel::default()] + } + + fn settings_version(&self) -> usize { + 0 + } + + fn is_authenticated(&self) -> bool { + true + } + + fn authenticate(&self, _cx: &AppContext) -> Task> { + Task::ready(Ok(())) + } + + fn authentication_prompt(&self, _cx: &mut WindowContext) -> AnyView { + unimplemented!() + } + + fn reset_credentials(&self, _cx: &AppContext) -> Task> { + Task::ready(Ok(())) + } + + fn model(&self) -> LanguageModel { + LanguageModel::default() + } + + fn count_tokens( + &self, + _request: LanguageModelRequest, + _cx: &AppContext, + ) -> BoxFuture<'static, Result> { + futures::future::ready(Ok(0)).boxed() + } + + fn stream_completion( + &self, + _request: LanguageModelRequest, + ) -> BoxFuture<'static, Result>>> { + let (tx, rx) = mpsc::unbounded(); + self.current_completion_txs + .lock() + .insert(serde_json::to_string(&_request).unwrap(), tx); + async move { Ok(rx.map(Ok).boxed()) }.boxed() + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self } } diff --git a/crates/assistant/src/completion_provider/ollama.rs b/crates/assistant/src/completion_provider/ollama.rs new file mode 100644 index 00000000000000..59d79e3ae7d511 --- /dev/null +++ b/crates/assistant/src/completion_provider/ollama.rs @@ -0,0 +1,358 @@ +use crate::LanguageModelCompletionProvider; +use crate::{ + assistant_settings::OllamaModel, CompletionProvider, LanguageModel, LanguageModelRequest, Role, +}; +use anyhow::Result; +use futures::StreamExt as _; +use futures::{future::BoxFuture, stream::BoxStream, FutureExt}; +use gpui::{AnyView, AppContext, Task}; +use http::HttpClient; +use ollama::{ + get_models, preload_model, stream_chat_completion, ChatMessage, ChatOptions, ChatRequest, + Role as OllamaRole, +}; +use std::sync::Arc; +use std::time::Duration; +use ui::{prelude::*, ButtonLike, ElevationIndex}; + +const OLLAMA_DOWNLOAD_URL: &str = "https://ollama.com/download"; +const OLLAMA_LIBRARY_URL: &str = "https://ollama.com/library"; + +pub struct OllamaCompletionProvider { + api_url: String, + model: OllamaModel, + http_client: Arc, + low_speed_timeout: Option, + settings_version: usize, + available_models: Vec, +} + +impl LanguageModelCompletionProvider for OllamaCompletionProvider { + fn available_models(&self, _cx: &AppContext) -> Vec { + self.available_models + .iter() + .map(|m| LanguageModel::Ollama(m.clone())) + .collect() + } + + fn settings_version(&self) -> usize { + self.settings_version + } + + fn is_authenticated(&self) -> bool { + !self.available_models.is_empty() + } + + fn authenticate(&self, cx: &AppContext) -> Task> { + if self.is_authenticated() { + Task::ready(Ok(())) + } else { + self.fetch_models(cx) + } + } + + fn authentication_prompt(&self, cx: &mut WindowContext) -> AnyView { + let fetch_models = Box::new(move |cx: &mut WindowContext| { + cx.update_global::(|provider, cx| { + provider + .update_current_as::<_, OllamaCompletionProvider>(|provider| { + provider.fetch_models(cx) + }) + .unwrap_or_else(|| Task::ready(Ok(()))) + }) + }); + + cx.new_view(|cx| DownloadOllamaMessage::new(fetch_models, cx)) + .into() + } + + fn reset_credentials(&self, cx: &AppContext) -> Task> { + self.fetch_models(cx) + } + + fn model(&self) -> LanguageModel { + LanguageModel::Ollama(self.model.clone()) + } + + fn count_tokens( + &self, + request: LanguageModelRequest, + _cx: &AppContext, + ) -> BoxFuture<'static, Result> { + // There is no endpoint for this _yet_ in Ollama + // see: https://github.com/ollama/ollama/issues/1716 and https://github.com/ollama/ollama/issues/3582 + let token_count = request + .messages + .iter() + .map(|msg| msg.content.chars().count()) + .sum::() + / 4; + + async move { Ok(token_count) }.boxed() + } + + fn stream_completion( + &self, + request: LanguageModelRequest, + ) -> BoxFuture<'static, Result>>> { + let request = self.to_ollama_request(request); + + let http_client = self.http_client.clone(); + let api_url = self.api_url.clone(); + let low_speed_timeout = self.low_speed_timeout; + async move { + let request = + stream_chat_completion(http_client.as_ref(), &api_url, request, low_speed_timeout); + let response = request.await?; + let stream = response + .filter_map(|response| async move { + match response { + Ok(delta) => { + let content = match delta.message { + ChatMessage::User { content } => content, + ChatMessage::Assistant { content } => content, + ChatMessage::System { content } => content, + }; + Some(Ok(content)) + } + Err(error) => Some(Err(error)), + } + }) + .boxed(); + Ok(stream) + } + .boxed() + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } +} + +impl OllamaCompletionProvider { + pub fn new( + model: OllamaModel, + api_url: String, + http_client: Arc, + low_speed_timeout: Option, + settings_version: usize, + cx: &AppContext, + ) -> Self { + cx.spawn({ + let api_url = api_url.clone(); + let client = http_client.clone(); + let model = model.name.clone(); + + |_| async move { + if model.is_empty() { + return Ok(()); + } + preload_model(client.as_ref(), &api_url, &model).await + } + }) + .detach_and_log_err(cx); + + Self { + api_url, + model, + http_client, + low_speed_timeout, + settings_version, + available_models: Default::default(), + } + } + + pub fn update( + &mut self, + model: OllamaModel, + api_url: String, + low_speed_timeout: Option, + settings_version: usize, + cx: &AppContext, + ) { + cx.spawn({ + let api_url = api_url.clone(); + let client = self.http_client.clone(); + let model = model.name.clone(); + + |_| async move { preload_model(client.as_ref(), &api_url, &model).await } + }) + .detach_and_log_err(cx); + + if model.name.is_empty() { + self.select_first_available_model() + } else { + self.model = model; + } + + self.api_url = api_url; + self.low_speed_timeout = low_speed_timeout; + self.settings_version = settings_version; + } + + pub fn select_first_available_model(&mut self) { + if let Some(model) = self.available_models.first() { + self.model = model.clone(); + } + } + + pub fn fetch_models(&self, cx: &AppContext) -> Task> { + let http_client = self.http_client.clone(); + let api_url = self.api_url.clone(); + + // As a proxy for the server being "authenticated", we'll check if its up by fetching the models + cx.spawn(|mut cx| async move { + let models = get_models(http_client.as_ref(), &api_url, None).await?; + + let mut models: Vec = models + .into_iter() + // Since there is no metadata from the Ollama API + // indicating which models are embedding models, + // simply filter out models with "-embed" in their name + .filter(|model| !model.name.contains("-embed")) + .map(|model| OllamaModel::new(&model.name)) + .collect(); + + models.sort_by(|a, b| a.name.cmp(&b.name)); + + cx.update_global::(|provider, _cx| { + provider.update_current_as::<_, OllamaCompletionProvider>(|provider| { + provider.available_models = models; + + if !provider.available_models.is_empty() && provider.model.name.is_empty() { + provider.select_first_available_model() + } + }); + }) + }) + } + + fn to_ollama_request(&self, request: LanguageModelRequest) -> ChatRequest { + let model = match request.model { + LanguageModel::Ollama(model) => model, + _ => self.model.clone(), + }; + + ChatRequest { + model: model.name, + messages: request + .messages + .into_iter() + .map(|msg| match msg.role { + Role::User => ChatMessage::User { + content: msg.content, + }, + Role::Assistant => ChatMessage::Assistant { + content: msg.content, + }, + Role::System => ChatMessage::System { + content: msg.content, + }, + }) + .collect(), + keep_alive: model.keep_alive.unwrap_or_default(), + stream: true, + options: Some(ChatOptions { + num_ctx: Some(model.max_tokens), + stop: Some(request.stop), + temperature: Some(request.temperature), + ..Default::default() + }), + } + } +} + +impl From for ollama::Role { + fn from(val: Role) -> Self { + match val { + Role::User => OllamaRole::User, + Role::Assistant => OllamaRole::Assistant, + Role::System => OllamaRole::System, + } + } +} + +struct DownloadOllamaMessage { + retry_connection: Box Task>>, +} + +impl DownloadOllamaMessage { + pub fn new( + retry_connection: Box Task>>, + _cx: &mut ViewContext, + ) -> Self { + Self { retry_connection } + } + + fn render_download_button(&self, _cx: &mut ViewContext) -> impl IntoElement { + ButtonLike::new("download_ollama_button") + .style(ButtonStyle::Filled) + .size(ButtonSize::Large) + .layer(ElevationIndex::ModalSurface) + .child(Label::new("Get Ollama")) + .on_click(move |_, cx| cx.open_url(OLLAMA_DOWNLOAD_URL)) + } + + fn render_retry_button(&self, cx: &mut ViewContext) -> impl IntoElement { + ButtonLike::new("retry_ollama_models") + .style(ButtonStyle::Filled) + .size(ButtonSize::Large) + .layer(ElevationIndex::ModalSurface) + .child(Label::new("Retry")) + .on_click(cx.listener(move |this, _, cx| { + let connected = (this.retry_connection)(cx); + + cx.spawn(|_this, _cx| async move { + connected.await?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx) + })) + } + + fn render_next_steps(&self, _cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .p_4() + .size_full() + .gap_2() + .child( + Label::new("Once Ollama is on your machine, make sure to download a model or two.") + .size(LabelSize::Large), + ) + .child( + h_flex().w_full().p_4().justify_center().gap_2().child( + ButtonLike::new("view-models") + .style(ButtonStyle::Filled) + .size(ButtonSize::Large) + .layer(ElevationIndex::ModalSurface) + .child(Label::new("View Available Models")) + .on_click(move |_, cx| cx.open_url(OLLAMA_LIBRARY_URL)), + ), + ) + } +} + +impl Render for DownloadOllamaMessage { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .p_4() + .size_full() + .gap_2() + .child(Label::new("To use Ollama models via the assistant, Ollama must be running on your machine with at least one model downloaded.").size(LabelSize::Large)) + .child( + h_flex() + .w_full() + .p_4() + .justify_center() + .gap_2() + .child( + self.render_download_button(cx) + ) + .child( + self.render_retry_button(cx) + ) + ) + .child(self.render_next_steps(cx)) + .into_any() + } +} diff --git a/crates/assistant/src/completion_provider/open_ai.rs b/crates/assistant/src/completion_provider/open_ai.rs index 536ce88837341a..fd65d1afe51343 100644 --- a/crates/assistant/src/completion_provider/open_ai.rs +++ b/crates/assistant/src/completion_provider/open_ai.rs @@ -1,16 +1,19 @@ -use crate::assistant_settings::ZedDotDevModel; +use crate::assistant_settings::CloudModel; +use crate::assistant_settings::{AssistantProvider, AssistantSettings}; +use crate::LanguageModelCompletionProvider; use crate::{ assistant_settings::OpenAiModel, CompletionProvider, LanguageModel, LanguageModelRequest, Role, }; use anyhow::{anyhow, Result}; use editor::{Editor, EditorElement, EditorStyle}; use futures::{future::BoxFuture, stream::BoxStream, FutureExt, StreamExt}; -use gpui::{AnyView, AppContext, FontStyle, FontWeight, Task, TextStyle, View, WhiteSpace}; +use gpui::{AnyView, AppContext, FontStyle, Task, TextStyle, View, WhiteSpace}; use http::HttpClient; use open_ai::{stream_completion, Request, RequestMessage, Role as OpenAiRole}; use settings::Settings; use std::time::Duration; use std::{env, sync::Arc}; +use strum::IntoEnumIterator; use theme::ThemeSettings; use ui::prelude::*; use util::ResultExt; @@ -18,7 +21,7 @@ use util::ResultExt; pub struct OpenAiCompletionProvider { api_key: Option, api_url: String, - default_model: OpenAiModel, + model: OpenAiModel, http_client: Arc, low_speed_timeout: Option, settings_version: usize, @@ -26,7 +29,7 @@ pub struct OpenAiCompletionProvider { impl OpenAiCompletionProvider { pub fn new( - default_model: OpenAiModel, + model: OpenAiModel, api_url: String, http_client: Arc, low_speed_timeout: Option, @@ -35,7 +38,7 @@ impl OpenAiCompletionProvider { Self { api_key: None, api_url, - default_model, + model, http_client, low_speed_timeout, settings_version, @@ -44,26 +47,86 @@ impl OpenAiCompletionProvider { pub fn update( &mut self, - default_model: OpenAiModel, + model: OpenAiModel, api_url: String, low_speed_timeout: Option, settings_version: usize, ) { - self.default_model = default_model; + self.model = model; self.api_url = api_url; self.low_speed_timeout = low_speed_timeout; self.settings_version = settings_version; } - pub fn settings_version(&self) -> usize { + fn to_open_ai_request(&self, request: LanguageModelRequest) -> Request { + let model = match request.model { + LanguageModel::OpenAi(model) => model, + _ => self.model.clone(), + }; + + Request { + model, + messages: request + .messages + .into_iter() + .map(|msg| match msg.role { + Role::User => RequestMessage::User { + content: msg.content, + }, + Role::Assistant => RequestMessage::Assistant { + content: Some(msg.content), + tool_calls: Vec::new(), + }, + Role::System => RequestMessage::System { + content: msg.content, + }, + }) + .collect(), + stream: true, + stop: request.stop, + temperature: request.temperature, + tools: Vec::new(), + tool_choice: None, + } + } +} + +impl LanguageModelCompletionProvider for OpenAiCompletionProvider { + fn available_models(&self, cx: &AppContext) -> Vec { + if let AssistantProvider::OpenAi { + available_models, .. + } = &AssistantSettings::get_global(cx).provider + { + if !available_models.is_empty() { + return available_models + .iter() + .cloned() + .map(LanguageModel::OpenAi) + .collect(); + } + } + let available_models = if matches!(self.model, OpenAiModel::Custom { .. }) { + vec![self.model.clone()] + } else { + OpenAiModel::iter() + .filter(|model| !matches!(model, OpenAiModel::Custom { .. })) + .collect() + }; + available_models + .into_iter() + .map(LanguageModel::OpenAi) + .collect() + } + + fn settings_version(&self) -> usize { self.settings_version } - pub fn is_authenticated(&self) -> bool { + fn is_authenticated(&self) -> bool { self.api_key.is_some() } - pub fn authenticate(&self, cx: &AppContext) -> Task> { + fn authenticate(&self, cx: &AppContext) -> Task> { if self.is_authenticated() { Task::ready(Ok(())) } else { @@ -79,36 +142,36 @@ impl OpenAiCompletionProvider { String::from_utf8(api_key)? }; cx.update_global::(|provider, _cx| { - if let CompletionProvider::OpenAi(provider) = provider { + provider.update_current_as::<_, Self>(|provider| { provider.api_key = Some(api_key); - } + }); }) }) } } - pub fn reset_credentials(&self, cx: &AppContext) -> Task> { + fn reset_credentials(&self, cx: &AppContext) -> Task> { let delete_credentials = cx.delete_credentials(&self.api_url); cx.spawn(|mut cx| async move { delete_credentials.await.log_err(); cx.update_global::(|provider, _cx| { - if let CompletionProvider::OpenAi(provider) = provider { + provider.update_current_as::<_, Self>(|provider| { provider.api_key = None; - } + }); }) }) } - pub fn authentication_prompt(&self, cx: &mut WindowContext) -> AnyView { + fn authentication_prompt(&self, cx: &mut WindowContext) -> AnyView { cx.new_view(|cx| AuthenticationPrompt::new(self.api_url.clone(), cx)) .into() } - pub fn default_model(&self) -> OpenAiModel { - self.default_model.clone() + fn model(&self) -> LanguageModel { + LanguageModel::OpenAi(self.model.clone()) } - pub fn count_tokens( + fn count_tokens( &self, request: LanguageModelRequest, cx: &AppContext, @@ -116,7 +179,7 @@ impl OpenAiCompletionProvider { count_open_ai_tokens(request, cx.background_executor()) } - pub fn complete( + fn stream_completion( &self, request: LanguageModelRequest, ) -> BoxFuture<'static, Result>>> { @@ -149,36 +212,8 @@ impl OpenAiCompletionProvider { .boxed() } - fn to_open_ai_request(&self, request: LanguageModelRequest) -> Request { - let model = match request.model { - LanguageModel::OpenAi(model) => model, - _ => self.default_model(), - }; - - Request { - model, - messages: request - .messages - .into_iter() - .map(|msg| match msg.role { - Role::User => RequestMessage::User { - content: msg.content, - }, - Role::Assistant => RequestMessage::Assistant { - content: Some(msg.content), - tool_calls: Vec::new(), - }, - Role::System => RequestMessage::System { - content: msg.content, - }, - }) - .collect(), - stream: true, - stop: request.stop, - temperature: request.temperature, - tools: Vec::new(), - tool_choice: None, - } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self } } @@ -205,9 +240,11 @@ pub fn count_open_ai_tokens( match request.model { LanguageModel::Anthropic(_) - | LanguageModel::ZedDotDev(ZedDotDevModel::Claude3Opus) - | LanguageModel::ZedDotDev(ZedDotDevModel::Claude3Sonnet) - | LanguageModel::ZedDotDev(ZedDotDevModel::Claude3Haiku) => { + | LanguageModel::Cloud(CloudModel::Claude3_5Sonnet) + | LanguageModel::Cloud(CloudModel::Claude3Opus) + | LanguageModel::Cloud(CloudModel::Claude3Sonnet) + | LanguageModel::Cloud(CloudModel::Claude3Haiku) + | LanguageModel::OpenAi(OpenAiModel::Custom { .. }) => { // Tiktoken doesn't yet support these models, so we manually use the // same tokenizer as GPT-4. tiktoken_rs::num_tokens_from_messages("gpt-4", &messages) @@ -258,9 +295,9 @@ impl AuthenticationPrompt { cx.spawn(|_, mut cx| async move { write_credentials.await?; cx.update_global::(|provider, _cx| { - if let CompletionProvider::OpenAi(provider) = provider { + provider.update_current_as::<_, OpenAiCompletionProvider>(|provider| { provider.api_key = Some(api_key); - } + }); }) }) .detach_and_log_err(cx); @@ -273,7 +310,7 @@ impl AuthenticationPrompt { font_family: settings.ui_font.family.clone(), font_features: settings.ui_font.features.clone(), font_size: rems(0.875).into(), - font_weight: FontWeight::NORMAL, + font_weight: settings.ui_font.weight, font_style: FontStyle::Normal, line_height: relative(1.3), background_color: None, @@ -331,7 +368,7 @@ impl Render for AuthenticationPrompt { h_flex() .gap_2() .child(Label::new("Click on").size(LabelSize::Small)) - .child(Icon::new(IconName::Ai).size(IconSize::XSmall)) + .child(Icon::new(IconName::ZedAssistant).size(IconSize::XSmall)) .child( Label::new("in the status bar to close this panel.").size(LabelSize::Small), ), diff --git a/crates/assistant/src/context.rs b/crates/assistant/src/context.rs new file mode 100644 index 00000000000000..25f24753a1bd75 --- /dev/null +++ b/crates/assistant/src/context.rs @@ -0,0 +1,3466 @@ +use crate::{ + prompt_library::PromptStore, slash_command::SlashCommandLine, CompletionProvider, + LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageStatus, Role, +}; +use anyhow::{anyhow, Context as _, Result}; +use assistant_slash_command::{ + SlashCommandOutput, SlashCommandOutputSection, SlashCommandRegistry, +}; +use client::{proto, telemetry::Telemetry}; +use clock::ReplicaId; +use collections::{HashMap, HashSet}; +use fs::Fs; +use futures::{ + future::{self, Shared}, + FutureExt, StreamExt, +}; +use gpui::{AppContext, Context as _, EventEmitter, Model, ModelContext, Subscription, Task}; +use language::{ + AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, ParseStatus, Point, ToOffset, +}; +use open_ai::Model as OpenAiModel; +use paths::contexts_dir; +use project::Project; +use serde::{Deserialize, Serialize}; +use std::{ + cmp, + fmt::Debug, + iter, mem, + ops::Range, + path::{Path, PathBuf}, + sync::Arc, + time::{Duration, Instant}, +}; +use telemetry_events::AssistantKind; +use ui::SharedString; +use util::{post_inc, ResultExt, TryFutureExt}; +use uuid::Uuid; + +#[derive(Clone, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct ContextId(String); + +impl ContextId { + pub fn new() -> Self { + Self(Uuid::new_v4().to_string()) + } + + pub fn from_proto(id: String) -> Self { + Self(id) + } + + pub fn to_proto(&self) -> String { + self.0.clone() + } +} + +#[derive(Clone, Debug)] +pub enum ContextOperation { + InsertMessage { + anchor: MessageAnchor, + metadata: MessageMetadata, + version: clock::Global, + }, + UpdateMessage { + message_id: MessageId, + metadata: MessageMetadata, + version: clock::Global, + }, + UpdateSummary { + summary: ContextSummary, + version: clock::Global, + }, + SlashCommandFinished { + id: SlashCommandId, + output_range: Range, + sections: Vec>, + version: clock::Global, + }, + BufferOperation(language::Operation), +} + +impl ContextOperation { + pub fn from_proto(op: proto::ContextOperation) -> Result { + match op.variant.context("invalid variant")? { + proto::context_operation::Variant::InsertMessage(insert) => { + let message = insert.message.context("invalid message")?; + let id = MessageId(language::proto::deserialize_timestamp( + message.id.context("invalid id")?, + )); + Ok(Self::InsertMessage { + anchor: MessageAnchor { + id, + start: language::proto::deserialize_anchor( + message.start.context("invalid anchor")?, + ) + .context("invalid anchor")?, + }, + metadata: MessageMetadata { + role: Role::from_proto(message.role), + status: MessageStatus::from_proto( + message.status.context("invalid status")?, + ), + timestamp: id.0, + }, + version: language::proto::deserialize_version(&insert.version), + }) + } + proto::context_operation::Variant::UpdateMessage(update) => Ok(Self::UpdateMessage { + message_id: MessageId(language::proto::deserialize_timestamp( + update.message_id.context("invalid message id")?, + )), + metadata: MessageMetadata { + role: Role::from_proto(update.role), + status: MessageStatus::from_proto(update.status.context("invalid status")?), + timestamp: language::proto::deserialize_timestamp( + update.timestamp.context("invalid timestamp")?, + ), + }, + version: language::proto::deserialize_version(&update.version), + }), + proto::context_operation::Variant::UpdateSummary(update) => Ok(Self::UpdateSummary { + summary: ContextSummary { + text: update.summary, + done: update.done, + timestamp: language::proto::deserialize_timestamp( + update.timestamp.context("invalid timestamp")?, + ), + }, + version: language::proto::deserialize_version(&update.version), + }), + proto::context_operation::Variant::SlashCommandFinished(finished) => { + Ok(Self::SlashCommandFinished { + id: SlashCommandId(language::proto::deserialize_timestamp( + finished.id.context("invalid id")?, + )), + output_range: language::proto::deserialize_anchor_range( + finished.output_range.context("invalid range")?, + )?, + sections: finished + .sections + .into_iter() + .map(|section| { + Ok(SlashCommandOutputSection { + range: language::proto::deserialize_anchor_range( + section.range.context("invalid range")?, + )?, + icon: section.icon_name.parse()?, + label: section.label.into(), + }) + }) + .collect::>>()?, + version: language::proto::deserialize_version(&finished.version), + }) + } + proto::context_operation::Variant::BufferOperation(op) => Ok(Self::BufferOperation( + language::proto::deserialize_operation( + op.operation.context("invalid buffer operation")?, + )?, + )), + } + } + + pub fn to_proto(&self) -> proto::ContextOperation { + match self { + Self::InsertMessage { + anchor, + metadata, + version, + } => proto::ContextOperation { + variant: Some(proto::context_operation::Variant::InsertMessage( + proto::context_operation::InsertMessage { + message: Some(proto::ContextMessage { + id: Some(language::proto::serialize_timestamp(anchor.id.0)), + start: Some(language::proto::serialize_anchor(&anchor.start)), + role: metadata.role.to_proto() as i32, + status: Some(metadata.status.to_proto()), + }), + version: language::proto::serialize_version(version), + }, + )), + }, + Self::UpdateMessage { + message_id, + metadata, + version, + } => proto::ContextOperation { + variant: Some(proto::context_operation::Variant::UpdateMessage( + proto::context_operation::UpdateMessage { + message_id: Some(language::proto::serialize_timestamp(message_id.0)), + role: metadata.role.to_proto() as i32, + status: Some(metadata.status.to_proto()), + timestamp: Some(language::proto::serialize_timestamp(metadata.timestamp)), + version: language::proto::serialize_version(version), + }, + )), + }, + Self::UpdateSummary { summary, version } => proto::ContextOperation { + variant: Some(proto::context_operation::Variant::UpdateSummary( + proto::context_operation::UpdateSummary { + summary: summary.text.clone(), + done: summary.done, + timestamp: Some(language::proto::serialize_timestamp(summary.timestamp)), + version: language::proto::serialize_version(version), + }, + )), + }, + Self::SlashCommandFinished { + id, + output_range, + sections, + version, + } => proto::ContextOperation { + variant: Some(proto::context_operation::Variant::SlashCommandFinished( + proto::context_operation::SlashCommandFinished { + id: Some(language::proto::serialize_timestamp(id.0)), + output_range: Some(language::proto::serialize_anchor_range( + output_range.clone(), + )), + sections: sections + .iter() + .map(|section| { + let icon_name: &'static str = section.icon.into(); + proto::SlashCommandOutputSection { + range: Some(language::proto::serialize_anchor_range( + section.range.clone(), + )), + icon_name: icon_name.to_string(), + label: section.label.to_string(), + } + }) + .collect(), + version: language::proto::serialize_version(version), + }, + )), + }, + Self::BufferOperation(operation) => proto::ContextOperation { + variant: Some(proto::context_operation::Variant::BufferOperation( + proto::context_operation::BufferOperation { + operation: Some(language::proto::serialize_operation(operation)), + }, + )), + }, + } + } + + fn timestamp(&self) -> clock::Lamport { + match self { + Self::InsertMessage { anchor, .. } => anchor.id.0, + Self::UpdateMessage { metadata, .. } => metadata.timestamp, + Self::UpdateSummary { summary, .. } => summary.timestamp, + Self::SlashCommandFinished { id, .. } => id.0, + Self::BufferOperation(_) => { + panic!("reading the timestamp of a buffer operation is not supported") + } + } + } + + /// Returns the current version of the context operation. + pub fn version(&self) -> &clock::Global { + match self { + Self::InsertMessage { version, .. } + | Self::UpdateMessage { version, .. } + | Self::UpdateSummary { version, .. } + | Self::SlashCommandFinished { version, .. } => version, + Self::BufferOperation(_) => { + panic!("reading the version of a buffer operation is not supported") + } + } + } +} + +#[derive(Clone)] +pub enum ContextEvent { + MessagesEdited, + SummaryChanged, + EditStepsChanged, + StreamedCompletion, + PendingSlashCommandsUpdated { + removed: Vec>, + updated: Vec, + }, + SlashCommandFinished { + output_range: Range, + sections: Vec>, + run_commands_in_output: bool, + }, + Operation(ContextOperation), +} + +#[derive(Clone, Default, Debug)] +pub struct ContextSummary { + pub text: String, + done: bool, + timestamp: clock::Lamport, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MessageAnchor { + pub id: MessageId, + pub start: language::Anchor, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct MessageMetadata { + pub role: Role, + status: MessageStatus, + timestamp: clock::Lamport, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Message { + pub offset_range: Range, + pub index_range: Range, + pub id: MessageId, + pub anchor: language::Anchor, + pub role: Role, + pub status: MessageStatus, +} + +impl Message { + fn to_request_message(&self, buffer: &Buffer) -> LanguageModelRequestMessage { + LanguageModelRequestMessage { + role: self.role, + content: buffer.text_for_range(self.offset_range.clone()).collect(), + } + } +} + +struct PendingCompletion { + id: usize, + _task: Task<()>, +} + +#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)] +pub struct SlashCommandId(clock::Lamport); + +#[derive(Debug)] +pub struct EditStep { + pub source_range: Range, + pub operations: Option, +} + +#[derive(Debug)] +pub struct EditSuggestionGroup { + pub context_range: Range, + pub suggestions: Vec, +} + +#[derive(Debug)] +pub struct EditSuggestion { + pub range: Range, + /// If None, assume this is a suggestion to delete the range rather than transform it. + pub description: Option, + pub prepend_newline: bool, +} + +impl EditStep { + pub fn edit_suggestions( + &self, + project: &Model, + cx: &AppContext, + ) -> Task, Vec>> { + let Some(EditStepOperations::Parsed { operations, .. }) = &self.operations else { + return Task::ready(HashMap::default()); + }; + + let suggestion_tasks: Vec<_> = operations + .iter() + .map(|operation| operation.edit_suggestion(project.clone(), cx)) + .collect(); + + cx.spawn(|mut cx| async move { + let suggestions = future::join_all(suggestion_tasks) + .await + .into_iter() + .filter_map(|task| task.log_err()) + .collect::>(); + + let mut suggestions_by_buffer = HashMap::default(); + for (buffer, suggestion) in suggestions { + suggestions_by_buffer + .entry(buffer) + .or_insert_with(Vec::new) + .push(suggestion); + } + + let mut suggestion_groups_by_buffer = HashMap::default(); + for (buffer, mut suggestions) in suggestions_by_buffer { + let mut suggestion_groups = Vec::::new(); + buffer + .update(&mut cx, |buffer, _cx| { + // Sort suggestions by their range + suggestions.sort_by(|a, b| a.range.cmp(&b.range, buffer)); + + // Dedup overlapping suggestions + suggestions.dedup_by(|a, b| { + let a_range = a.range.to_offset(buffer); + let b_range = b.range.to_offset(buffer); + if a_range.start <= b_range.end && b_range.start <= a_range.end { + if b_range.start < a_range.start { + a.range.start = b.range.start; + } + if b_range.end > a_range.end { + a.range.end = b.range.end; + } + + if let (Some(a_desc), Some(b_desc)) = + (a.description.as_mut(), b.description.as_mut()) + { + b_desc.push('\n'); + b_desc.push_str(a_desc); + } else if a.description.is_some() { + b.description = a.description.take(); + } + + true + } else { + false + } + }); + + // Create context ranges for each suggestion + for suggestion in suggestions { + let context_range = { + let suggestion_point_range = suggestion.range.to_point(buffer); + let start_row = suggestion_point_range.start.row.saturating_sub(5); + let end_row = cmp::min( + suggestion_point_range.end.row + 5, + buffer.max_point().row, + ); + let start = buffer.anchor_before(Point::new(start_row, 0)); + let end = buffer + .anchor_after(Point::new(end_row, buffer.line_len(end_row))); + start..end + }; + + if let Some(last_group) = suggestion_groups.last_mut() { + if last_group + .context_range + .end + .cmp(&context_range.start, buffer) + .is_ge() + { + // Merge with the previous group if context ranges overlap + last_group.context_range.end = context_range.end; + last_group.suggestions.push(suggestion); + } else { + // Create a new group + suggestion_groups.push(EditSuggestionGroup { + context_range, + suggestions: vec![suggestion], + }); + } + } else { + // Create the first group + suggestion_groups.push(EditSuggestionGroup { + context_range, + suggestions: vec![suggestion], + }); + } + } + }) + .ok(); + suggestion_groups_by_buffer.insert(buffer, suggestion_groups); + } + + suggestion_groups_by_buffer + }) + } +} + +pub enum EditStepOperations { + Pending(Task>), + Parsed { + operations: Vec, + raw_output: String, + }, +} + +impl Debug for EditStepOperations { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EditStepOperations::Pending(_) => write!(f, "EditStepOperations::Pending"), + EditStepOperations::Parsed { + operations, + raw_output, + } => f + .debug_struct("EditStepOperations::Parsed") + .field("operations", operations) + .field("raw_output", raw_output) + .finish(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditOperation { + pub path: String, + pub kind: EditOperationKind, +} + +impl EditOperation { + fn edit_suggestion( + &self, + project: Model, + cx: &AppContext, + ) -> Task, EditSuggestion)>> { + let path = self.path.clone(); + let kind = self.kind.clone(); + cx.spawn(move |mut cx| async move { + let buffer = project + .update(&mut cx, |project, cx| { + let project_path = project + .project_path_for_full_path(Path::new(&path), cx) + .with_context(|| format!("worktree not found for {:?}", path))?; + anyhow::Ok(project.open_buffer(project_path, cx)) + })?? + .await?; + + let mut parse_status = buffer.read_with(&cx, |buffer, _cx| buffer.parse_status())?; + while *parse_status.borrow() != ParseStatus::Idle { + parse_status.changed().await?; + } + + let prepend_newline = kind.prepend_newline(); + let suggestion_range = if let Some(symbol) = kind.symbol() { + let outline = buffer + .update(&mut cx, |buffer, _| buffer.snapshot().outline(None))? + .context("no outline for buffer")?; + let candidate = outline + .path_candidates + .iter() + .find(|item| item.string == symbol) + .context("symbol not found")?; + buffer.update(&mut cx, |buffer, _| { + let outline_item = &outline.items[candidate.id]; + let symbol_range = outline_item.range.to_point(buffer); + let body_range = outline_item + .body_range + .as_ref() + .map(|range| range.to_point(buffer)) + .unwrap_or(symbol_range.clone()); + + match kind { + EditOperationKind::PrependChild { .. } => { + let position = buffer.anchor_after(body_range.start); + position..position + } + EditOperationKind::AppendChild { .. } => { + let position = buffer.anchor_before(body_range.end); + position..position + } + EditOperationKind::InsertSiblingBefore { .. } => { + let position = buffer.anchor_before(symbol_range.start); + position..position + } + EditOperationKind::InsertSiblingAfter { .. } => { + let position = buffer.anchor_after(symbol_range.end); + position..position + } + EditOperationKind::Update { .. } | EditOperationKind::Delete { .. } => { + let start = Point::new(symbol_range.start.row, 0); + let end = Point::new( + symbol_range.end.row, + buffer.line_len(symbol_range.end.row), + ); + buffer.anchor_before(start)..buffer.anchor_after(end) + } + EditOperationKind::Create { .. } => unreachable!(), + } + })? + } else { + match kind { + EditOperationKind::PrependChild { .. } => { + language::Anchor::MIN..language::Anchor::MIN + } + EditOperationKind::AppendChild { .. } | EditOperationKind::Create { .. } => { + language::Anchor::MAX..language::Anchor::MAX + } + _ => unreachable!("All other operations should have a symbol"), + } + }; + + Ok(( + buffer, + EditSuggestion { + range: suggestion_range, + description: kind.description().map(ToString::to_string), + prepend_newline, + }, + )) + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EditOperationKind { + Update { + symbol: String, + description: String, + }, + Create { + description: String, + }, + InsertSiblingBefore { + symbol: String, + description: String, + }, + InsertSiblingAfter { + symbol: String, + description: String, + }, + PrependChild { + symbol: Option, + description: String, + }, + AppendChild { + symbol: Option, + description: String, + }, + Delete { + symbol: String, + }, +} + +impl EditOperationKind { + pub fn symbol(&self) -> Option<&str> { + match self { + Self::Update { symbol, .. } => Some(symbol), + Self::InsertSiblingBefore { symbol, .. } => Some(symbol), + Self::InsertSiblingAfter { symbol, .. } => Some(symbol), + Self::PrependChild { symbol, .. } => symbol.as_deref(), + Self::AppendChild { symbol, .. } => symbol.as_deref(), + Self::Delete { symbol } => Some(symbol), + Self::Create { .. } => None, + } + } + + pub fn description(&self) -> Option<&str> { + match self { + Self::Update { description, .. } => Some(description), + Self::Create { description } => Some(description), + Self::InsertSiblingBefore { description, .. } => Some(description), + Self::InsertSiblingAfter { description, .. } => Some(description), + Self::PrependChild { description, .. } => Some(description), + Self::AppendChild { description, .. } => Some(description), + Self::Delete { .. } => None, + } + } + + pub fn prepend_newline(&self) -> bool { + match self { + Self::PrependChild { .. } + | Self::AppendChild { .. } + | Self::InsertSiblingAfter { .. } + | Self::InsertSiblingBefore { .. } => true, + _ => false, + } + } +} + +pub struct Context { + id: ContextId, + timestamp: clock::Lamport, + version: clock::Global, + pending_ops: Vec, + operations: Vec, + buffer: Model, + pending_slash_commands: Vec, + edits_since_last_slash_command_parse: language::Subscription, + finished_slash_commands: HashSet, + slash_command_output_sections: Vec>, + message_anchors: Vec, + messages_metadata: HashMap, + summary: Option, + pending_summary: Task>, + completion_count: usize, + pending_completions: Vec, + token_count: Option, + pending_token_count: Task>, + pending_save: Task>, + path: Option, + _subscriptions: Vec, + telemetry: Option>, + language_registry: Arc, + edit_steps: Vec, +} + +impl EventEmitter for Context {} + +impl Context { + pub fn local( + language_registry: Arc, + telemetry: Option>, + cx: &mut ModelContext, + ) -> Self { + Self::new( + ContextId::new(), + ReplicaId::default(), + language::Capability::ReadWrite, + language_registry, + telemetry, + cx, + ) + } + + pub fn new( + id: ContextId, + replica_id: ReplicaId, + capability: language::Capability, + language_registry: Arc, + telemetry: Option>, + cx: &mut ModelContext, + ) -> Self { + let buffer = cx.new_model(|_cx| { + let mut buffer = Buffer::remote( + language::BufferId::new(1).unwrap(), + replica_id, + capability, + "", + ); + buffer.set_language_registry(language_registry.clone()); + buffer + }); + let edits_since_last_slash_command_parse = + buffer.update(cx, |buffer, _| buffer.subscribe()); + let mut this = Self { + id, + timestamp: clock::Lamport::new(replica_id), + version: clock::Global::new(), + pending_ops: Vec::new(), + operations: Vec::new(), + message_anchors: Default::default(), + messages_metadata: Default::default(), + pending_slash_commands: Vec::new(), + finished_slash_commands: HashSet::default(), + slash_command_output_sections: Vec::new(), + edits_since_last_slash_command_parse, + summary: None, + pending_summary: Task::ready(None), + completion_count: Default::default(), + pending_completions: Default::default(), + token_count: None, + pending_token_count: Task::ready(None), + _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)], + pending_save: Task::ready(Ok(())), + path: None, + buffer, + telemetry, + language_registry, + edit_steps: Vec::new(), + }; + + let first_message_id = MessageId(clock::Lamport { + replica_id: 0, + value: 0, + }); + let message = MessageAnchor { + id: first_message_id, + start: language::Anchor::MIN, + }; + this.messages_metadata.insert( + first_message_id, + MessageMetadata { + role: Role::User, + status: MessageStatus::Done, + timestamp: first_message_id.0, + }, + ); + this.message_anchors.push(message); + + this.set_language(cx); + this.count_remaining_tokens(cx); + this + } + + fn serialize(&self, cx: &AppContext) -> SavedContext { + let buffer = self.buffer.read(cx); + SavedContext { + id: Some(self.id.clone()), + zed: "context".into(), + version: SavedContext::VERSION.into(), + text: buffer.text(), + messages: self + .messages(cx) + .map(|message| SavedMessage { + id: message.id, + start: message.offset_range.start, + metadata: self.messages_metadata[&message.id].clone(), + }) + .collect(), + summary: self + .summary + .as_ref() + .map(|summary| summary.text.clone()) + .unwrap_or_default(), + slash_command_output_sections: self + .slash_command_output_sections + .iter() + .filter_map(|section| { + let range = section.range.to_offset(buffer); + if section.range.start.is_valid(buffer) && !range.is_empty() { + Some(assistant_slash_command::SlashCommandOutputSection { + range, + icon: section.icon, + label: section.label.clone(), + }) + } else { + None + } + }) + .collect(), + } + } + + #[allow(clippy::too_many_arguments)] + pub fn deserialize( + saved_context: SavedContext, + path: PathBuf, + language_registry: Arc, + telemetry: Option>, + cx: &mut ModelContext, + ) -> Self { + let id = saved_context.id.clone().unwrap_or_else(|| ContextId::new()); + let mut this = Self::new( + id, + ReplicaId::default(), + language::Capability::ReadWrite, + language_registry, + telemetry, + cx, + ); + this.path = Some(path); + this.buffer.update(cx, |buffer, cx| { + buffer.set_text(saved_context.text.as_str(), cx) + }); + let operations = saved_context.into_ops(&this.buffer, cx); + this.apply_ops(operations, cx).unwrap(); + this + } + + pub fn id(&self) -> &ContextId { + &self.id + } + + pub fn replica_id(&self) -> ReplicaId { + self.timestamp.replica_id + } + + pub fn version(&self, cx: &AppContext) -> ContextVersion { + ContextVersion { + context: self.version.clone(), + buffer: self.buffer.read(cx).version(), + } + } + + pub fn set_capability( + &mut self, + capability: language::Capability, + cx: &mut ModelContext, + ) { + self.buffer + .update(cx, |buffer, cx| buffer.set_capability(capability, cx)); + } + + fn next_timestamp(&mut self) -> clock::Lamport { + let timestamp = self.timestamp.tick(); + self.version.observe(timestamp); + timestamp + } + + pub fn serialize_ops( + &self, + since: &ContextVersion, + cx: &AppContext, + ) -> Task> { + let buffer_ops = self + .buffer + .read(cx) + .serialize_ops(Some(since.buffer.clone()), cx); + + let mut context_ops = self + .operations + .iter() + .filter(|op| !since.context.observed(op.timestamp())) + .cloned() + .collect::>(); + context_ops.extend(self.pending_ops.iter().cloned()); + + cx.background_executor().spawn(async move { + let buffer_ops = buffer_ops.await; + context_ops.sort_unstable_by_key(|op| op.timestamp()); + buffer_ops + .into_iter() + .map(|op| proto::ContextOperation { + variant: Some(proto::context_operation::Variant::BufferOperation( + proto::context_operation::BufferOperation { + operation: Some(op), + }, + )), + }) + .chain(context_ops.into_iter().map(|op| op.to_proto())) + .collect() + }) + } + + pub fn apply_ops( + &mut self, + ops: impl IntoIterator, + cx: &mut ModelContext, + ) -> Result<()> { + let mut buffer_ops = Vec::new(); + for op in ops { + match op { + ContextOperation::BufferOperation(buffer_op) => buffer_ops.push(buffer_op), + op @ _ => self.pending_ops.push(op), + } + } + self.buffer + .update(cx, |buffer, cx| buffer.apply_ops(buffer_ops, cx))?; + self.flush_ops(cx); + + Ok(()) + } + + fn flush_ops(&mut self, cx: &mut ModelContext) { + let mut messages_changed = false; + let mut summary_changed = false; + + self.pending_ops.sort_unstable_by_key(|op| op.timestamp()); + for op in mem::take(&mut self.pending_ops) { + if !self.can_apply_op(&op, cx) { + self.pending_ops.push(op); + continue; + } + + let timestamp = op.timestamp(); + match op.clone() { + ContextOperation::InsertMessage { + anchor, metadata, .. + } => { + if self.messages_metadata.contains_key(&anchor.id) { + // We already applied this operation. + } else { + self.insert_message(anchor, metadata, cx); + messages_changed = true; + } + } + ContextOperation::UpdateMessage { + message_id, + metadata: new_metadata, + .. + } => { + let metadata = self.messages_metadata.get_mut(&message_id).unwrap(); + if new_metadata.timestamp > metadata.timestamp { + *metadata = new_metadata; + messages_changed = true; + } + } + ContextOperation::UpdateSummary { + summary: new_summary, + .. + } => { + if self + .summary + .as_ref() + .map_or(true, |summary| new_summary.timestamp > summary.timestamp) + { + self.summary = Some(new_summary); + summary_changed = true; + } + } + ContextOperation::SlashCommandFinished { + id, + output_range, + sections, + .. + } => { + if self.finished_slash_commands.insert(id) { + let buffer = self.buffer.read(cx); + self.slash_command_output_sections + .extend(sections.iter().cloned()); + self.slash_command_output_sections + .sort_by(|a, b| a.range.cmp(&b.range, buffer)); + cx.emit(ContextEvent::SlashCommandFinished { + output_range, + sections, + run_commands_in_output: false, + }); + } + } + ContextOperation::BufferOperation(_) => unreachable!(), + } + + self.version.observe(timestamp); + self.timestamp.observe(timestamp); + self.operations.push(op); + } + + if messages_changed { + cx.emit(ContextEvent::MessagesEdited); + cx.notify(); + } + + if summary_changed { + cx.emit(ContextEvent::SummaryChanged); + cx.notify(); + } + } + + fn can_apply_op(&self, op: &ContextOperation, cx: &AppContext) -> bool { + if !self.version.observed_all(op.version()) { + return false; + } + + match op { + ContextOperation::InsertMessage { anchor, .. } => self + .buffer + .read(cx) + .version + .observed(anchor.start.timestamp), + ContextOperation::UpdateMessage { message_id, .. } => { + self.messages_metadata.contains_key(message_id) + } + ContextOperation::UpdateSummary { .. } => true, + ContextOperation::SlashCommandFinished { + output_range, + sections, + .. + } => { + let version = &self.buffer.read(cx).version; + sections + .iter() + .map(|section| §ion.range) + .chain([output_range]) + .all(|range| { + let observed_start = range.start == language::Anchor::MIN + || range.start == language::Anchor::MAX + || version.observed(range.start.timestamp); + let observed_end = range.end == language::Anchor::MIN + || range.end == language::Anchor::MAX + || version.observed(range.end.timestamp); + observed_start && observed_end + }) + } + ContextOperation::BufferOperation(_) => { + panic!("buffer operations should always be applied") + } + } + } + + fn push_op(&mut self, op: ContextOperation, cx: &mut ModelContext) { + self.operations.push(op.clone()); + cx.emit(ContextEvent::Operation(op)); + } + + pub fn buffer(&self) -> &Model { + &self.buffer + } + + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } + + pub fn summary(&self) -> Option<&ContextSummary> { + self.summary.as_ref() + } + + pub fn edit_steps(&self) -> &[EditStep] { + &self.edit_steps + } + + pub fn pending_slash_commands(&self) -> &[PendingSlashCommand] { + &self.pending_slash_commands + } + + pub fn slash_command_output_sections(&self) -> &[SlashCommandOutputSection] { + &self.slash_command_output_sections + } + + fn set_language(&mut self, cx: &mut ModelContext) { + let markdown = self.language_registry.language_for_name("Markdown"); + cx.spawn(|this, mut cx| async move { + let markdown = markdown.await?; + this.update(&mut cx, |this, cx| { + this.buffer + .update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx)); + }) + }) + .detach_and_log_err(cx); + } + + fn handle_buffer_event( + &mut self, + _: Model, + event: &language::Event, + cx: &mut ModelContext, + ) { + match event { + language::Event::Operation(operation) => cx.emit(ContextEvent::Operation( + ContextOperation::BufferOperation(operation.clone()), + )), + language::Event::Edited => { + self.count_remaining_tokens(cx); + self.reparse_slash_commands(cx); + self.prune_invalid_edit_steps(cx); + cx.emit(ContextEvent::MessagesEdited); + } + _ => {} + } + } + + pub(crate) fn token_count(&self) -> Option { + self.token_count + } + + pub(crate) fn count_remaining_tokens(&mut self, cx: &mut ModelContext) { + let request = self.to_completion_request(cx); + self.pending_token_count = cx.spawn(|this, mut cx| { + async move { + cx.background_executor() + .timer(Duration::from_millis(200)) + .await; + + let token_count = cx + .update(|cx| CompletionProvider::global(cx).count_tokens(request, cx))? + .await?; + + this.update(&mut cx, |this, cx| { + this.token_count = Some(token_count); + cx.notify() + })?; + anyhow::Ok(()) + } + .log_err() + }); + } + + pub fn reparse_slash_commands(&mut self, cx: &mut ModelContext) { + let buffer = self.buffer.read(cx); + let mut row_ranges = self + .edits_since_last_slash_command_parse + .consume() + .into_iter() + .map(|edit| { + let start_row = buffer.offset_to_point(edit.new.start).row; + let end_row = buffer.offset_to_point(edit.new.end).row + 1; + start_row..end_row + }) + .peekable(); + + let mut removed = Vec::new(); + let mut updated = Vec::new(); + while let Some(mut row_range) = row_ranges.next() { + while let Some(next_row_range) = row_ranges.peek() { + if row_range.end >= next_row_range.start { + row_range.end = next_row_range.end; + row_ranges.next(); + } else { + break; + } + } + + let start = buffer.anchor_before(Point::new(row_range.start, 0)); + let end = buffer.anchor_after(Point::new( + row_range.end - 1, + buffer.line_len(row_range.end - 1), + )); + + let old_range = self.pending_command_indices_for_range(start..end, cx); + + let mut new_commands = Vec::new(); + let mut lines = buffer.text_for_range(start..end).lines(); + let mut offset = lines.offset(); + while let Some(line) = lines.next() { + if let Some(command_line) = SlashCommandLine::parse(line) { + let name = &line[command_line.name.clone()]; + let argument = command_line.argument.as_ref().and_then(|argument| { + (!argument.is_empty()).then_some(&line[argument.clone()]) + }); + if let Some(command) = SlashCommandRegistry::global(cx).command(name) { + if !command.requires_argument() || argument.is_some() { + let start_ix = offset + command_line.name.start - 1; + let end_ix = offset + + command_line + .argument + .map_or(command_line.name.end, |argument| argument.end); + let source_range = + buffer.anchor_after(start_ix)..buffer.anchor_after(end_ix); + let pending_command = PendingSlashCommand { + name: name.to_string(), + argument: argument.map(ToString::to_string), + source_range, + status: PendingSlashCommandStatus::Idle, + }; + updated.push(pending_command.clone()); + new_commands.push(pending_command); + } + } + } + + offset = lines.offset(); + } + + let removed_commands = self.pending_slash_commands.splice(old_range, new_commands); + removed.extend(removed_commands.map(|command| command.source_range)); + } + + if !updated.is_empty() || !removed.is_empty() { + cx.emit(ContextEvent::PendingSlashCommandsUpdated { removed, updated }); + } + } + + fn prune_invalid_edit_steps(&mut self, cx: &mut ModelContext) { + let buffer = self.buffer.read(cx); + let prev_len = self.edit_steps.len(); + self.edit_steps.retain(|step| { + step.source_range.start.is_valid(buffer) && step.source_range.end.is_valid(buffer) + }); + if self.edit_steps.len() != prev_len { + cx.emit(ContextEvent::EditStepsChanged); + cx.notify(); + } + } + + fn parse_edit_steps_in_range(&mut self, range: Range, cx: &mut ModelContext) { + let mut new_edit_steps = Vec::new(); + + self.buffer.update(cx, |buffer, _cx| { + let mut message_lines = buffer.as_rope().chunks_in_range(range).lines(); + let mut in_step = false; + let mut step_start = 0; + let mut line_start_offset = message_lines.offset(); + + while let Some(line) = message_lines.next() { + if let Some(step_start_index) = line.find("") { + if !in_step { + in_step = true; + step_start = line_start_offset + step_start_index; + } + } + + if let Some(step_end_index) = line.find("") { + if in_step { + let start_anchor = buffer.anchor_after(step_start); + let end_anchor = buffer + .anchor_before(line_start_offset + step_end_index + "".len()); + let source_range = start_anchor..end_anchor; + + // Check if a step with the same range already exists + let existing_step_index = self.edit_steps.binary_search_by(|probe| { + probe.source_range.cmp(&source_range, buffer) + }); + + if let Err(ix) = existing_step_index { + // Step doesn't exist, so add it + new_edit_steps.push(( + ix, + EditStep { + source_range, + operations: None, + }, + )); + } + + in_step = false; + } + } + + line_start_offset = message_lines.offset(); + } + }); + + // Insert new steps and generate their corresponding tasks + for (index, mut step) in new_edit_steps.into_iter().rev() { + let task = self.generate_edit_step_operations(&step, cx); + step.operations = Some(EditStepOperations::Pending(task)); + self.edit_steps.insert(index, step); + } + + cx.emit(ContextEvent::EditStepsChanged); + cx.notify(); + } + + fn generate_edit_step_operations( + &self, + edit_step: &EditStep, + cx: &mut ModelContext, + ) -> Task> { + let mut request = self.to_completion_request(cx); + let edit_step_range = edit_step.source_range.clone(); + let step_text = self + .buffer + .read(cx) + .text_for_range(edit_step_range.clone()) + .collect::(); + + cx.spawn(|this, mut cx| async move { + let prompt_store = cx.update(|cx| PromptStore::global(cx))?.await?; + + let mut prompt = prompt_store.operations_prompt(); + prompt.push_str(&step_text); + + request.messages.push(LanguageModelRequestMessage { + role: Role::User, + content: prompt, + }); + + let raw_output = cx + .update(|cx| CompletionProvider::global(cx).complete(request, cx))? + .await?; + + let operations = Self::parse_edit_operations(&raw_output); + this.update(&mut cx, |this, cx| { + let step_index = this + .edit_steps + .binary_search_by(|step| { + step.source_range + .cmp(&edit_step_range, this.buffer.read(cx)) + }) + .map_err(|_| anyhow!("edit step not found"))?; + if let Some(edit_step) = this.edit_steps.get_mut(step_index) { + edit_step.operations = Some(EditStepOperations::Parsed { + operations, + raw_output, + }); + cx.emit(ContextEvent::EditStepsChanged); + } + anyhow::Ok(()) + })? + }) + } + + fn parse_edit_operations(xml: &str) -> Vec { + let Some(start_ix) = xml.find("") else { + return Vec::new(); + }; + let Some(end_ix) = xml[start_ix..].find("") else { + return Vec::new(); + }; + let end_ix = end_ix + start_ix + "".len(); + + let doc = roxmltree::Document::parse(&xml[start_ix..end_ix]).log_err(); + doc.map_or(Vec::new(), |doc| { + doc.root_element() + .children() + .map(|node| { + let tag_name = node.tag_name().name(); + let path = node + .attribute("path") + .with_context(|| { + format!("invalid node {node:?}, missing attribute 'path'") + })? + .to_string(); + let kind = match tag_name { + "update" => EditOperationKind::Update { + symbol: node + .attribute("symbol") + .with_context(|| { + format!("invalid node {node:?}, missing attribute 'symbol'") + })? + .to_string(), + description: node + .attribute("description") + .with_context(|| { + format!( + "invalid node {node:?}, missing attribute 'description'" + ) + })? + .to_string(), + }, + "create" => EditOperationKind::Create { + description: node + .attribute("description") + .with_context(|| { + format!( + "invalid node {node:?}, missing attribute 'description'" + ) + })? + .to_string(), + }, + "insert_sibling_after" => EditOperationKind::InsertSiblingAfter { + symbol: node + .attribute("symbol") + .with_context(|| { + format!("invalid node {node:?}, missing attribute 'symbol'") + })? + .to_string(), + description: node + .attribute("description") + .with_context(|| { + format!( + "invalid node {node:?}, missing attribute 'description'" + ) + })? + .to_string(), + }, + "insert_sibling_before" => EditOperationKind::InsertSiblingBefore { + symbol: node + .attribute("symbol") + .with_context(|| { + format!("invalid node {node:?}, missing attribute 'symbol'") + })? + .to_string(), + description: node + .attribute("description") + .with_context(|| { + format!( + "invalid node {node:?}, missing attribute 'description'" + ) + })? + .to_string(), + }, + "prepend_child" => EditOperationKind::PrependChild { + symbol: node.attribute("symbol").map(String::from), + description: node + .attribute("description") + .with_context(|| { + format!( + "invalid node {node:?}, missing attribute 'description'" + ) + })? + .to_string(), + }, + "append_child" => EditOperationKind::AppendChild { + symbol: node.attribute("symbol").map(String::from), + description: node + .attribute("description") + .with_context(|| { + format!( + "invalid node {node:?}, missing attribute 'description'" + ) + })? + .to_string(), + }, + "delete" => EditOperationKind::Delete { + symbol: node + .attribute("symbol") + .with_context(|| { + format!("invalid node {node:?}, missing attribute 'symbol'") + })? + .to_string(), + }, + _ => return Err(anyhow!("invalid node {node:?}")), + }; + anyhow::Ok(EditOperation { path, kind }) + }) + .filter_map(|op| op.log_err()) + .collect() + }) + } + + pub fn pending_command_for_position( + &mut self, + position: language::Anchor, + cx: &mut ModelContext, + ) -> Option<&mut PendingSlashCommand> { + let buffer = self.buffer.read(cx); + match self + .pending_slash_commands + .binary_search_by(|probe| probe.source_range.end.cmp(&position, buffer)) + { + Ok(ix) => Some(&mut self.pending_slash_commands[ix]), + Err(ix) => { + let cmd = self.pending_slash_commands.get_mut(ix)?; + if position.cmp(&cmd.source_range.start, buffer).is_ge() + && position.cmp(&cmd.source_range.end, buffer).is_le() + { + Some(cmd) + } else { + None + } + } + } + } + + pub fn pending_commands_for_range( + &self, + range: Range, + cx: &AppContext, + ) -> &[PendingSlashCommand] { + let range = self.pending_command_indices_for_range(range, cx); + &self.pending_slash_commands[range] + } + + fn pending_command_indices_for_range( + &self, + range: Range, + cx: &AppContext, + ) -> Range { + let buffer = self.buffer.read(cx); + let start_ix = match self + .pending_slash_commands + .binary_search_by(|probe| probe.source_range.end.cmp(&range.start, &buffer)) + { + Ok(ix) | Err(ix) => ix, + }; + let end_ix = match self + .pending_slash_commands + .binary_search_by(|probe| probe.source_range.start.cmp(&range.end, &buffer)) + { + Ok(ix) => ix + 1, + Err(ix) => ix, + }; + start_ix..end_ix + } + + pub fn insert_command_output( + &mut self, + command_range: Range, + output: Task>, + insert_trailing_newline: bool, + cx: &mut ModelContext, + ) { + self.reparse_slash_commands(cx); + + let insert_output_task = cx.spawn(|this, mut cx| { + let command_range = command_range.clone(); + async move { + let output = output.await; + this.update(&mut cx, |this, cx| match output { + Ok(mut output) => { + if insert_trailing_newline { + output.text.push('\n'); + } + + let version = this.version.clone(); + let command_id = SlashCommandId(this.next_timestamp()); + let (operation, event) = this.buffer.update(cx, |buffer, cx| { + let start = command_range.start.to_offset(buffer); + let old_end = command_range.end.to_offset(buffer); + let new_end = start + output.text.len(); + buffer.edit([(start..old_end, output.text)], None, cx); + + let mut sections = output + .sections + .into_iter() + .map(|section| SlashCommandOutputSection { + range: buffer.anchor_after(start + section.range.start) + ..buffer.anchor_before(start + section.range.end), + icon: section.icon, + label: section.label, + }) + .collect::>(); + sections.sort_by(|a, b| a.range.cmp(&b.range, buffer)); + + this.slash_command_output_sections + .extend(sections.iter().cloned()); + this.slash_command_output_sections + .sort_by(|a, b| a.range.cmp(&b.range, buffer)); + + let output_range = + buffer.anchor_after(start)..buffer.anchor_before(new_end); + this.finished_slash_commands.insert(command_id); + + ( + ContextOperation::SlashCommandFinished { + id: command_id, + output_range: output_range.clone(), + sections: sections.clone(), + version, + }, + ContextEvent::SlashCommandFinished { + output_range, + sections, + run_commands_in_output: output.run_commands_in_text, + }, + ) + }); + + this.push_op(operation, cx); + cx.emit(event); + } + Err(error) => { + if let Some(pending_command) = + this.pending_command_for_position(command_range.start, cx) + { + pending_command.status = + PendingSlashCommandStatus::Error(error.to_string()); + cx.emit(ContextEvent::PendingSlashCommandsUpdated { + removed: vec![pending_command.source_range.clone()], + updated: vec![pending_command.clone()], + }); + } + } + }) + .ok(); + } + }); + + if let Some(pending_command) = self.pending_command_for_position(command_range.start, cx) { + pending_command.status = PendingSlashCommandStatus::Running { + _task: insert_output_task.shared(), + }; + cx.emit(ContextEvent::PendingSlashCommandsUpdated { + removed: vec![pending_command.source_range.clone()], + updated: vec![pending_command.clone()], + }); + } + } + + pub fn completion_provider_changed(&mut self, cx: &mut ModelContext) { + self.count_remaining_tokens(cx); + } + + pub fn assist(&mut self, cx: &mut ModelContext) -> Option { + let last_message_id = self.message_anchors.iter().rev().find_map(|message| { + message + .start + .is_valid(self.buffer.read(cx)) + .then_some(message.id) + })?; + + if !CompletionProvider::global(cx).is_authenticated() { + log::info!("completion provider has no credentials"); + return None; + } + + let request = self.to_completion_request(cx); + let stream = CompletionProvider::global(cx).stream_completion(request, cx); + let assistant_message = self + .insert_message_after(last_message_id, Role::Assistant, MessageStatus::Pending, cx) + .unwrap(); + + // Queue up the user's next reply. + let user_message = self + .insert_message_after(assistant_message.id, Role::User, MessageStatus::Done, cx) + .unwrap(); + + let task = cx.spawn({ + |this, mut cx| async move { + let assistant_message_id = assistant_message.id; + let mut response_latency = None; + let stream_completion = async { + let request_start = Instant::now(); + let mut chunks = stream.await?; + + while let Some(chunk) = chunks.next().await { + if response_latency.is_none() { + response_latency = Some(request_start.elapsed()); + } + let chunk = chunk?; + + this.update(&mut cx, |this, cx| { + let message_ix = this + .message_anchors + .iter() + .position(|message| message.id == assistant_message_id)?; + let message_range = this.buffer.update(cx, |buffer, cx| { + let message_start_offset = + this.message_anchors[message_ix].start.to_offset(buffer); + let message_old_end_offset = this.message_anchors[message_ix + 1..] + .iter() + .find(|message| message.start.is_valid(buffer)) + .map_or(buffer.len(), |message| { + message.start.to_offset(buffer).saturating_sub(1) + }); + let message_new_end_offset = message_old_end_offset + chunk.len(); + buffer.edit( + [(message_old_end_offset..message_old_end_offset, chunk)], + None, + cx, + ); + message_start_offset..message_new_end_offset + }); + this.parse_edit_steps_in_range(message_range, cx); + cx.emit(ContextEvent::StreamedCompletion); + + Some(()) + })?; + smol::future::yield_now().await; + } + + this.update(&mut cx, |this, cx| { + this.pending_completions + .retain(|completion| completion.id != this.completion_count); + this.summarize(cx); + })?; + + anyhow::Ok(()) + }; + + let result = stream_completion.await; + + this.update(&mut cx, |this, cx| { + let error_message = result + .err() + .map(|error| error.to_string().trim().to_string()); + + this.update_metadata(assistant_message_id, cx, |metadata| { + if let Some(error_message) = error_message.as_ref() { + metadata.status = + MessageStatus::Error(SharedString::from(error_message.clone())); + } else { + metadata.status = MessageStatus::Done; + } + }); + + if let Some(telemetry) = this.telemetry.as_ref() { + let model = CompletionProvider::global(cx).model(); + telemetry.report_assistant_event( + Some(this.id.0.clone()), + AssistantKind::Panel, + model.telemetry_id(), + response_latency, + error_message, + ); + } + }) + .ok(); + } + }); + + self.pending_completions.push(PendingCompletion { + id: post_inc(&mut self.completion_count), + _task: task, + }); + + Some(user_message) + } + + pub fn to_completion_request(&self, cx: &AppContext) -> LanguageModelRequest { + let messages = self + .messages(cx) + .filter(|message| matches!(message.status, MessageStatus::Done)) + .map(|message| message.to_request_message(self.buffer.read(cx))); + + LanguageModelRequest { + model: CompletionProvider::global(cx).model(), + messages: messages.collect(), + stop: vec![], + temperature: 1.0, + } + } + + pub fn cancel_last_assist(&mut self) -> bool { + self.pending_completions.pop().is_some() + } + + pub fn cycle_message_roles(&mut self, ids: HashSet, cx: &mut ModelContext) { + for id in ids { + if let Some(metadata) = self.messages_metadata.get(&id) { + let role = metadata.role.cycle(); + self.update_metadata(id, cx, |metadata| metadata.role = role); + } + } + } + + pub fn update_metadata( + &mut self, + id: MessageId, + cx: &mut ModelContext, + f: impl FnOnce(&mut MessageMetadata), + ) { + let version = self.version.clone(); + let timestamp = self.next_timestamp(); + if let Some(metadata) = self.messages_metadata.get_mut(&id) { + f(metadata); + metadata.timestamp = timestamp; + let operation = ContextOperation::UpdateMessage { + message_id: id, + metadata: metadata.clone(), + version, + }; + self.push_op(operation, cx); + cx.emit(ContextEvent::MessagesEdited); + cx.notify(); + } + } + + fn insert_message_after( + &mut self, + message_id: MessageId, + role: Role, + status: MessageStatus, + cx: &mut ModelContext, + ) -> Option { + if let Some(prev_message_ix) = self + .message_anchors + .iter() + .position(|message| message.id == message_id) + { + // Find the next valid message after the one we were given. + let mut next_message_ix = prev_message_ix + 1; + while let Some(next_message) = self.message_anchors.get(next_message_ix) { + if next_message.start.is_valid(self.buffer.read(cx)) { + break; + } + next_message_ix += 1; + } + + let start = self.buffer.update(cx, |buffer, cx| { + let offset = self + .message_anchors + .get(next_message_ix) + .map_or(buffer.len(), |message| { + buffer.clip_offset(message.start.to_offset(buffer) - 1, Bias::Left) + }); + buffer.edit([(offset..offset, "\n")], None, cx); + buffer.anchor_before(offset + 1) + }); + + let version = self.version.clone(); + let anchor = MessageAnchor { + id: MessageId(self.next_timestamp()), + start, + }; + let metadata = MessageMetadata { + role, + status, + timestamp: anchor.id.0, + }; + self.insert_message(anchor.clone(), metadata.clone(), cx); + self.push_op( + ContextOperation::InsertMessage { + anchor: anchor.clone(), + metadata, + version, + }, + cx, + ); + Some(anchor) + } else { + None + } + } + + pub fn split_message( + &mut self, + range: Range, + cx: &mut ModelContext, + ) -> (Option, Option) { + let start_message = self.message_for_offset(range.start, cx); + let end_message = self.message_for_offset(range.end, cx); + if let Some((start_message, end_message)) = start_message.zip(end_message) { + // Prevent splitting when range spans multiple messages. + if start_message.id != end_message.id { + return (None, None); + } + + let message = start_message; + let role = message.role; + let mut edited_buffer = false; + + let mut suffix_start = None; + if range.start > message.offset_range.start && range.end < message.offset_range.end - 1 + { + if self.buffer.read(cx).chars_at(range.end).next() == Some('\n') { + suffix_start = Some(range.end + 1); + } else if self.buffer.read(cx).reversed_chars_at(range.end).next() == Some('\n') { + suffix_start = Some(range.end); + } + } + + let version = self.version.clone(); + let suffix = if let Some(suffix_start) = suffix_start { + MessageAnchor { + id: MessageId(self.next_timestamp()), + start: self.buffer.read(cx).anchor_before(suffix_start), + } + } else { + self.buffer.update(cx, |buffer, cx| { + buffer.edit([(range.end..range.end, "\n")], None, cx); + }); + edited_buffer = true; + MessageAnchor { + id: MessageId(self.next_timestamp()), + start: self.buffer.read(cx).anchor_before(range.end + 1), + } + }; + + let suffix_metadata = MessageMetadata { + role, + status: MessageStatus::Done, + timestamp: suffix.id.0, + }; + self.insert_message(suffix.clone(), suffix_metadata.clone(), cx); + self.push_op( + ContextOperation::InsertMessage { + anchor: suffix.clone(), + metadata: suffix_metadata, + version, + }, + cx, + ); + + let new_messages = + if range.start == range.end || range.start == message.offset_range.start { + (None, Some(suffix)) + } else { + let mut prefix_end = None; + if range.start > message.offset_range.start + && range.end < message.offset_range.end - 1 + { + if self.buffer.read(cx).chars_at(range.start).next() == Some('\n') { + prefix_end = Some(range.start + 1); + } else if self.buffer.read(cx).reversed_chars_at(range.start).next() + == Some('\n') + { + prefix_end = Some(range.start); + } + } + + let version = self.version.clone(); + let selection = if let Some(prefix_end) = prefix_end { + MessageAnchor { + id: MessageId(self.next_timestamp()), + start: self.buffer.read(cx).anchor_before(prefix_end), + } + } else { + self.buffer.update(cx, |buffer, cx| { + buffer.edit([(range.start..range.start, "\n")], None, cx) + }); + edited_buffer = true; + MessageAnchor { + id: MessageId(self.next_timestamp()), + start: self.buffer.read(cx).anchor_before(range.end + 1), + } + }; + + let selection_metadata = MessageMetadata { + role, + status: MessageStatus::Done, + timestamp: selection.id.0, + }; + self.insert_message(selection.clone(), selection_metadata.clone(), cx); + self.push_op( + ContextOperation::InsertMessage { + anchor: selection.clone(), + metadata: selection_metadata, + version, + }, + cx, + ); + + (Some(selection), Some(suffix)) + }; + + if !edited_buffer { + cx.emit(ContextEvent::MessagesEdited); + } + new_messages + } else { + (None, None) + } + } + + fn insert_message( + &mut self, + new_anchor: MessageAnchor, + new_metadata: MessageMetadata, + cx: &mut ModelContext, + ) { + cx.emit(ContextEvent::MessagesEdited); + + self.messages_metadata.insert(new_anchor.id, new_metadata); + + let buffer = self.buffer.read(cx); + let insertion_ix = self + .message_anchors + .iter() + .position(|anchor| { + let comparison = new_anchor.start.cmp(&anchor.start, buffer); + comparison.is_lt() || (comparison.is_eq() && new_anchor.id > anchor.id) + }) + .unwrap_or(self.message_anchors.len()); + self.message_anchors.insert(insertion_ix, new_anchor); + } + + fn summarize(&mut self, cx: &mut ModelContext) { + if self.message_anchors.len() >= 2 && self.summary.is_none() { + if !CompletionProvider::global(cx).is_authenticated() { + return; + } + + let messages = self + .messages(cx) + .map(|message| message.to_request_message(self.buffer.read(cx))) + .chain(Some(LanguageModelRequestMessage { + role: Role::User, + content: "Summarize the context into a short title without punctuation.".into(), + })); + let request = LanguageModelRequest { + model: CompletionProvider::global(cx).model(), + messages: messages.collect(), + stop: vec![], + temperature: 1.0, + }; + + let stream = CompletionProvider::global(cx).stream_completion(request, cx); + self.pending_summary = cx.spawn(|this, mut cx| { + async move { + let mut messages = stream.await?; + + while let Some(message) = messages.next().await { + let text = message?; + let mut lines = text.lines(); + this.update(&mut cx, |this, cx| { + let version = this.version.clone(); + let timestamp = this.next_timestamp(); + let summary = this.summary.get_or_insert(Default::default()); + summary.text.extend(lines.next()); + summary.timestamp = timestamp; + let operation = ContextOperation::UpdateSummary { + summary: summary.clone(), + version, + }; + this.push_op(operation, cx); + cx.emit(ContextEvent::SummaryChanged); + })?; + + // Stop if the LLM generated multiple lines. + if lines.next().is_some() { + break; + } + } + + this.update(&mut cx, |this, cx| { + let version = this.version.clone(); + let timestamp = this.next_timestamp(); + if let Some(summary) = this.summary.as_mut() { + summary.done = true; + summary.timestamp = timestamp; + let operation = ContextOperation::UpdateSummary { + summary: summary.clone(), + version, + }; + this.push_op(operation, cx); + cx.emit(ContextEvent::SummaryChanged); + } + })?; + + anyhow::Ok(()) + } + .log_err() + }); + } + } + + fn message_for_offset(&self, offset: usize, cx: &AppContext) -> Option { + self.messages_for_offsets([offset], cx).pop() + } + + pub fn messages_for_offsets( + &self, + offsets: impl IntoIterator, + cx: &AppContext, + ) -> Vec { + let mut result = Vec::new(); + + let mut messages = self.messages(cx).peekable(); + let mut offsets = offsets.into_iter().peekable(); + let mut current_message = messages.next(); + while let Some(offset) = offsets.next() { + // Locate the message that contains the offset. + while current_message.as_ref().map_or(false, |message| { + !message.offset_range.contains(&offset) && messages.peek().is_some() + }) { + current_message = messages.next(); + } + let Some(message) = current_message.as_ref() else { + break; + }; + + // Skip offsets that are in the same message. + while offsets.peek().map_or(false, |offset| { + message.offset_range.contains(offset) || messages.peek().is_none() + }) { + offsets.next(); + } + + result.push(message.clone()); + } + result + } + + pub fn messages<'a>(&'a self, cx: &'a AppContext) -> impl 'a + Iterator { + let buffer = self.buffer.read(cx); + let mut message_anchors = self.message_anchors.iter().enumerate().peekable(); + iter::from_fn(move || { + if let Some((start_ix, message_anchor)) = message_anchors.next() { + let metadata = self.messages_metadata.get(&message_anchor.id)?; + let message_start = message_anchor.start.to_offset(buffer); + let mut message_end = None; + let mut end_ix = start_ix; + while let Some((_, next_message)) = message_anchors.peek() { + if next_message.start.is_valid(buffer) { + message_end = Some(next_message.start); + break; + } else { + end_ix += 1; + message_anchors.next(); + } + } + let message_end = message_end + .unwrap_or(language::Anchor::MAX) + .to_offset(buffer); + + return Some(Message { + index_range: start_ix..end_ix, + offset_range: message_start..message_end, + id: message_anchor.id, + anchor: message_anchor.start, + role: metadata.role, + status: metadata.status.clone(), + }); + } + None + }) + } + + pub fn save( + &mut self, + debounce: Option, + fs: Arc, + cx: &mut ModelContext, + ) { + if self.replica_id() != ReplicaId::default() { + // Prevent saving a remote context for now. + return; + } + + self.pending_save = cx.spawn(|this, mut cx| async move { + if let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; + } + + let (old_path, summary) = this.read_with(&cx, |this, _| { + let path = this.path.clone(); + let summary = if let Some(summary) = this.summary.as_ref() { + if summary.done { + Some(summary.text.clone()) + } else { + None + } + } else { + None + }; + (path, summary) + })?; + + if let Some(summary) = summary { + let context = this.read_with(&cx, |this, cx| this.serialize(cx))?; + let path = if let Some(old_path) = old_path { + old_path + } else { + let mut discriminant = 1; + let mut new_path; + loop { + new_path = contexts_dir().join(&format!( + "{} - {}.zed.json", + summary.trim(), + discriminant + )); + if fs.is_file(&new_path).await { + discriminant += 1; + } else { + break; + } + } + new_path + }; + + fs.create_dir(contexts_dir().as_ref()).await?; + fs.atomic_write(path.clone(), serde_json::to_string(&context).unwrap()) + .await?; + this.update(&mut cx, |this, _| this.path = Some(path))?; + } + + Ok(()) + }); + } +} + +#[derive(Debug, Default)] +pub struct ContextVersion { + context: clock::Global, + buffer: clock::Global, +} + +impl ContextVersion { + pub fn from_proto(proto: &proto::ContextVersion) -> Self { + Self { + context: language::proto::deserialize_version(&proto.context_version), + buffer: language::proto::deserialize_version(&proto.buffer_version), + } + } + + pub fn to_proto(&self, context_id: ContextId) -> proto::ContextVersion { + proto::ContextVersion { + context_id: context_id.to_proto(), + context_version: language::proto::serialize_version(&self.context), + buffer_version: language::proto::serialize_version(&self.buffer), + } + } +} + +#[derive(Clone)] +pub struct PendingSlashCommand { + pub name: String, + pub argument: Option, + pub status: PendingSlashCommandStatus, + pub source_range: Range, +} + +#[derive(Clone)] +pub enum PendingSlashCommandStatus { + Idle, + Running { _task: Shared> }, + Error(String), +} + +#[derive(Serialize, Deserialize)] +pub struct SavedMessage { + pub id: MessageId, + pub start: usize, + pub metadata: MessageMetadata, +} + +#[derive(Serialize, Deserialize)] +pub struct SavedContext { + pub id: Option, + pub zed: String, + pub version: String, + pub text: String, + pub messages: Vec, + pub summary: String, + pub slash_command_output_sections: + Vec>, +} + +impl SavedContext { + pub const VERSION: &'static str = "0.4.0"; + + pub fn from_json(json: &str) -> Result { + let saved_context_json = serde_json::from_str::(json)?; + match saved_context_json + .get("version") + .ok_or_else(|| anyhow!("version not found"))? + { + serde_json::Value::String(version) => match version.as_str() { + SavedContext::VERSION => { + Ok(serde_json::from_value::(saved_context_json)?) + } + SavedContextV0_3_0::VERSION => { + let saved_context = + serde_json::from_value::(saved_context_json)?; + Ok(saved_context.upgrade()) + } + SavedContextV0_2_0::VERSION => { + let saved_context = + serde_json::from_value::(saved_context_json)?; + Ok(saved_context.upgrade()) + } + SavedContextV0_1_0::VERSION => { + let saved_context = + serde_json::from_value::(saved_context_json)?; + Ok(saved_context.upgrade()) + } + _ => Err(anyhow!("unrecognized saved context version: {}", version)), + }, + _ => Err(anyhow!("version not found on saved context")), + } + } + + fn into_ops( + self, + buffer: &Model, + cx: &mut ModelContext, + ) -> Vec { + let mut operations = Vec::new(); + let mut version = clock::Global::new(); + let mut next_timestamp = clock::Lamport::new(ReplicaId::default()); + + let mut first_message_metadata = None; + for message in self.messages { + if message.id == MessageId(clock::Lamport::default()) { + first_message_metadata = Some(message.metadata); + } else { + operations.push(ContextOperation::InsertMessage { + anchor: MessageAnchor { + id: message.id, + start: buffer.read(cx).anchor_before(message.start), + }, + metadata: MessageMetadata { + role: message.metadata.role, + status: message.metadata.status, + timestamp: message.metadata.timestamp, + }, + version: version.clone(), + }); + version.observe(message.id.0); + next_timestamp.observe(message.id.0); + } + } + + if let Some(metadata) = first_message_metadata { + let timestamp = next_timestamp.tick(); + operations.push(ContextOperation::UpdateMessage { + message_id: MessageId(clock::Lamport::default()), + metadata: MessageMetadata { + role: metadata.role, + status: metadata.status, + timestamp, + }, + version: version.clone(), + }); + version.observe(timestamp); + } + + let timestamp = next_timestamp.tick(); + operations.push(ContextOperation::SlashCommandFinished { + id: SlashCommandId(timestamp), + output_range: language::Anchor::MIN..language::Anchor::MAX, + sections: self + .slash_command_output_sections + .into_iter() + .map(|section| { + let buffer = buffer.read(cx); + SlashCommandOutputSection { + range: buffer.anchor_after(section.range.start) + ..buffer.anchor_before(section.range.end), + icon: section.icon, + label: section.label, + } + }) + .collect(), + version: version.clone(), + }); + version.observe(timestamp); + + let timestamp = next_timestamp.tick(); + operations.push(ContextOperation::UpdateSummary { + summary: ContextSummary { + text: self.summary, + done: true, + timestamp, + }, + version: version.clone(), + }); + version.observe(timestamp); + + operations + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +struct SavedMessageIdPreV0_4_0(usize); + +#[derive(Serialize, Deserialize)] +struct SavedMessagePreV0_4_0 { + id: SavedMessageIdPreV0_4_0, + start: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +struct SavedMessageMetadataPreV0_4_0 { + role: Role, + status: MessageStatus, +} + +#[derive(Serialize, Deserialize)] +struct SavedContextV0_3_0 { + id: Option, + zed: String, + version: String, + text: String, + messages: Vec, + message_metadata: HashMap, + summary: String, + slash_command_output_sections: Vec>, +} + +impl SavedContextV0_3_0 { + const VERSION: &'static str = "0.3.0"; + + fn upgrade(self) -> SavedContext { + SavedContext { + id: self.id, + zed: self.zed, + version: SavedContext::VERSION.into(), + text: self.text, + messages: self + .messages + .into_iter() + .filter_map(|message| { + let metadata = self.message_metadata.get(&message.id)?; + let timestamp = clock::Lamport { + replica_id: ReplicaId::default(), + value: message.id.0 as u32, + }; + Some(SavedMessage { + id: MessageId(timestamp), + start: message.start, + metadata: MessageMetadata { + role: metadata.role, + status: metadata.status.clone(), + timestamp, + }, + }) + }) + .collect(), + summary: self.summary, + slash_command_output_sections: self.slash_command_output_sections, + } + } +} + +#[derive(Serialize, Deserialize)] +struct SavedContextV0_2_0 { + id: Option, + zed: String, + version: String, + text: String, + messages: Vec, + message_metadata: HashMap, + summary: String, +} + +impl SavedContextV0_2_0 { + const VERSION: &'static str = "0.2.0"; + + fn upgrade(self) -> SavedContext { + SavedContextV0_3_0 { + id: self.id, + zed: self.zed, + version: SavedContextV0_3_0::VERSION.to_string(), + text: self.text, + messages: self.messages, + message_metadata: self.message_metadata, + summary: self.summary, + slash_command_output_sections: Vec::new(), + } + .upgrade() + } +} + +#[derive(Serialize, Deserialize)] +struct SavedContextV0_1_0 { + id: Option, + zed: String, + version: String, + text: String, + messages: Vec, + message_metadata: HashMap, + summary: String, + api_url: Option, + model: OpenAiModel, +} + +impl SavedContextV0_1_0 { + const VERSION: &'static str = "0.1.0"; + + fn upgrade(self) -> SavedContext { + SavedContextV0_2_0 { + id: self.id, + zed: self.zed, + version: SavedContextV0_2_0::VERSION.to_string(), + text: self.text, + messages: self.messages, + message_metadata: self.message_metadata, + summary: self.summary, + } + .upgrade() + } +} + +#[derive(Clone)] +pub struct SavedContextMetadata { + pub title: String, + pub path: PathBuf, + pub mtime: chrono::DateTime, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + assistant_panel, prompt_library, + slash_command::{active_command, file_command}, + FakeCompletionProvider, MessageId, + }; + use assistant_slash_command::{ArgumentCompletion, SlashCommand}; + use fs::FakeFs; + use gpui::{AppContext, TestAppContext, WeakView}; + use indoc::indoc; + use language::LspAdapterDelegate; + use parking_lot::Mutex; + use project::Project; + use rand::prelude::*; + use serde_json::json; + use settings::SettingsStore; + use std::{cell::RefCell, env, rc::Rc, sync::atomic::AtomicBool}; + use text::{network::Network, ToPoint}; + use ui::WindowContext; + use unindent::Unindent; + use util::{test::marked_text_ranges, RandomCharIter}; + use workspace::Workspace; + + #[gpui::test] + fn test_inserting_and_removing_messages(cx: &mut AppContext) { + let settings_store = SettingsStore::test(cx); + FakeCompletionProvider::setup_test(cx); + cx.set_global(settings_store); + assistant_panel::init(cx); + let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + + let context = cx.new_model(|cx| Context::local(registry, None, cx)); + let buffer = context.read(cx).buffer.clone(); + + let message_1 = context.read(cx).message_anchors[0].clone(); + assert_eq!( + messages(&context, cx), + vec![(message_1.id, Role::User, 0..0)] + ); + + let message_2 = context.update(cx, |context, cx| { + context + .insert_message_after(message_1.id, Role::Assistant, MessageStatus::Done, cx) + .unwrap() + }); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..1), + (message_2.id, Role::Assistant, 1..1) + ] + ); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "1"), (1..1, "2")], None, cx) + }); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..3) + ] + ); + + let message_3 = context.update(cx, |context, cx| { + context + .insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) + .unwrap() + }); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..4), + (message_3.id, Role::User, 4..4) + ] + ); + + let message_4 = context.update(cx, |context, cx| { + context + .insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) + .unwrap() + }); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..4), + (message_4.id, Role::User, 4..5), + (message_3.id, Role::User, 5..5), + ] + ); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(4..4, "C"), (5..5, "D")], None, cx) + }); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..4), + (message_4.id, Role::User, 4..6), + (message_3.id, Role::User, 6..7), + ] + ); + + // Deleting across message boundaries merges the messages. + buffer.update(cx, |buffer, cx| buffer.edit([(1..4, "")], None, cx)); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..3), + (message_3.id, Role::User, 3..4), + ] + ); + + // Undoing the deletion should also undo the merge. + buffer.update(cx, |buffer, cx| buffer.undo(cx)); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..4), + (message_4.id, Role::User, 4..6), + (message_3.id, Role::User, 6..7), + ] + ); + + // Redoing the deletion should also redo the merge. + buffer.update(cx, |buffer, cx| buffer.redo(cx)); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..3), + (message_3.id, Role::User, 3..4), + ] + ); + + // Ensure we can still insert after a merged message. + let message_5 = context.update(cx, |context, cx| { + context + .insert_message_after(message_1.id, Role::System, MessageStatus::Done, cx) + .unwrap() + }); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..3), + (message_5.id, Role::System, 3..4), + (message_3.id, Role::User, 4..5) + ] + ); + } + + #[gpui::test] + fn test_message_splitting(cx: &mut AppContext) { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + FakeCompletionProvider::setup_test(cx); + assistant_panel::init(cx); + let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + + let context = cx.new_model(|cx| Context::local(registry, None, cx)); + let buffer = context.read(cx).buffer.clone(); + + let message_1 = context.read(cx).message_anchors[0].clone(); + assert_eq!( + messages(&context, cx), + vec![(message_1.id, Role::User, 0..0)] + ); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "aaa\nbbb\nccc\nddd\n")], None, cx) + }); + + let (_, message_2) = context.update(cx, |context, cx| context.split_message(3..3, cx)); + let message_2 = message_2.unwrap(); + + // We recycle newlines in the middle of a split message + assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\nddd\n"); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_2.id, Role::User, 4..16), + ] + ); + + let (_, message_3) = context.update(cx, |context, cx| context.split_message(3..3, cx)); + let message_3 = message_3.unwrap(); + + // We don't recycle newlines at the end of a split message + assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\nccc\nddd\n"); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_3.id, Role::User, 4..5), + (message_2.id, Role::User, 5..17), + ] + ); + + let (_, message_4) = context.update(cx, |context, cx| context.split_message(9..9, cx)); + let message_4 = message_4.unwrap(); + assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\nccc\nddd\n"); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_3.id, Role::User, 4..5), + (message_2.id, Role::User, 5..9), + (message_4.id, Role::User, 9..17), + ] + ); + + let (_, message_5) = context.update(cx, |context, cx| context.split_message(9..9, cx)); + let message_5 = message_5.unwrap(); + assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\n\nccc\nddd\n"); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_3.id, Role::User, 4..5), + (message_2.id, Role::User, 5..9), + (message_4.id, Role::User, 9..10), + (message_5.id, Role::User, 10..18), + ] + ); + + let (message_6, message_7) = + context.update(cx, |context, cx| context.split_message(14..16, cx)); + let message_6 = message_6.unwrap(); + let message_7 = message_7.unwrap(); + assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\n\nccc\ndd\nd\n"); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_3.id, Role::User, 4..5), + (message_2.id, Role::User, 5..9), + (message_4.id, Role::User, 9..10), + (message_5.id, Role::User, 10..14), + (message_6.id, Role::User, 14..17), + (message_7.id, Role::User, 17..19), + ] + ); + } + + #[gpui::test] + fn test_messages_for_offsets(cx: &mut AppContext) { + let settings_store = SettingsStore::test(cx); + FakeCompletionProvider::setup_test(cx); + cx.set_global(settings_store); + assistant_panel::init(cx); + let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + let context = cx.new_model(|cx| Context::local(registry, None, cx)); + let buffer = context.read(cx).buffer.clone(); + + let message_1 = context.read(cx).message_anchors[0].clone(); + assert_eq!( + messages(&context, cx), + vec![(message_1.id, Role::User, 0..0)] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "aaa")], None, cx)); + let message_2 = context + .update(cx, |context, cx| { + context.insert_message_after(message_1.id, Role::User, MessageStatus::Done, cx) + }) + .unwrap(); + buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "bbb")], None, cx)); + + let message_3 = context + .update(cx, |context, cx| { + context.insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) + }) + .unwrap(); + buffer.update(cx, |buffer, cx| buffer.edit([(8..8, "ccc")], None, cx)); + + assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc"); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_2.id, Role::User, 4..8), + (message_3.id, Role::User, 8..11) + ] + ); + + assert_eq!( + message_ids_for_offsets(&context, &[0, 4, 9], cx), + [message_1.id, message_2.id, message_3.id] + ); + assert_eq!( + message_ids_for_offsets(&context, &[0, 1, 11], cx), + [message_1.id, message_3.id] + ); + + let message_4 = context + .update(cx, |context, cx| { + context.insert_message_after(message_3.id, Role::User, MessageStatus::Done, cx) + }) + .unwrap(); + assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\n"); + assert_eq!( + messages(&context, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_2.id, Role::User, 4..8), + (message_3.id, Role::User, 8..12), + (message_4.id, Role::User, 12..12) + ] + ); + assert_eq!( + message_ids_for_offsets(&context, &[0, 4, 8, 12], cx), + [message_1.id, message_2.id, message_3.id, message_4.id] + ); + + fn message_ids_for_offsets( + context: &Model, + offsets: &[usize], + cx: &AppContext, + ) -> Vec { + context + .read(cx) + .messages_for_offsets(offsets.iter().copied(), cx) + .into_iter() + .map(|message| message.id) + .collect() + } + } + + #[gpui::test] + async fn test_slash_commands(cx: &mut TestAppContext) { + let settings_store = cx.update(SettingsStore::test); + cx.set_global(settings_store); + cx.update(FakeCompletionProvider::setup_test); + cx.update(Project::init_settings); + cx.update(assistant_panel::init); + let fs = FakeFs::new(cx.background_executor.clone()); + + fs.insert_tree( + "/test", + json!({ + "src": { + "lib.rs": "fn one() -> usize { 1 }", + "main.rs": " + use crate::one; + fn main() { one(); } + ".unindent(), + } + }), + ) + .await; + + let slash_command_registry = cx.update(SlashCommandRegistry::default_global); + slash_command_registry.register_command(file_command::FileSlashCommand, false); + slash_command_registry.register_command(active_command::ActiveSlashCommand, false); + + let registry = Arc::new(LanguageRegistry::test(cx.executor())); + let context = cx.new_model(|cx| Context::local(registry.clone(), None, cx)); + + let output_ranges = Rc::new(RefCell::new(HashSet::default())); + context.update(cx, |_, cx| { + cx.subscribe(&context, { + let ranges = output_ranges.clone(); + move |_, _, event, _| match event { + ContextEvent::PendingSlashCommandsUpdated { removed, updated } => { + for range in removed { + ranges.borrow_mut().remove(range); + } + for command in updated { + ranges.borrow_mut().insert(command.source_range.clone()); + } + } + _ => {} + } + }) + .detach(); + }); + + let buffer = context.read_with(cx, |context, _| context.buffer.clone()); + + // Insert a slash command + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "/file src/lib.rs")], None, cx); + }); + assert_text_and_output_ranges( + &buffer, + &output_ranges.borrow(), + " + «/file src/lib.rs» + " + .unindent() + .trim_end(), + cx, + ); + + // Edit the argument of the slash command. + buffer.update(cx, |buffer, cx| { + let edit_offset = buffer.text().find("lib.rs").unwrap(); + buffer.edit([(edit_offset..edit_offset + "lib".len(), "main")], None, cx); + }); + assert_text_and_output_ranges( + &buffer, + &output_ranges.borrow(), + " + «/file src/main.rs» + " + .unindent() + .trim_end(), + cx, + ); + + // Edit the name of the slash command, using one that doesn't exist. + buffer.update(cx, |buffer, cx| { + let edit_offset = buffer.text().find("/file").unwrap(); + buffer.edit( + [(edit_offset..edit_offset + "/file".len(), "/unknown")], + None, + cx, + ); + }); + assert_text_and_output_ranges( + &buffer, + &output_ranges.borrow(), + " + /unknown src/main.rs + " + .unindent() + .trim_end(), + cx, + ); + + #[track_caller] + fn assert_text_and_output_ranges( + buffer: &Model, + ranges: &HashSet>, + expected_marked_text: &str, + cx: &mut TestAppContext, + ) { + let (expected_text, expected_ranges) = marked_text_ranges(expected_marked_text, false); + let (actual_text, actual_ranges) = buffer.update(cx, |buffer, _| { + let mut ranges = ranges + .iter() + .map(|range| range.to_offset(buffer)) + .collect::>(); + ranges.sort_by_key(|a| a.start); + (buffer.text(), ranges) + }); + + assert_eq!(actual_text, expected_text); + assert_eq!(actual_ranges, expected_ranges); + } + } + + #[gpui::test] + async fn test_edit_step_parsing(cx: &mut TestAppContext) { + cx.update(prompt_library::init); + let settings_store = cx.update(SettingsStore::test); + cx.set_global(settings_store); + let fake_provider = cx.update(FakeCompletionProvider::setup_test); + cx.update(assistant_panel::init); + let registry = Arc::new(LanguageRegistry::test(cx.executor())); + + // Create a new context + let context = cx.new_model(|cx| Context::local(registry.clone(), None, cx)); + let buffer = context.read_with(cx, |context, _| context.buffer.clone()); + + // Simulate user input + let user_message = indoc! {r#" + Please refactor this code: + + fn main() { + println!("Hello, World!"); + } + "#}; + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, user_message)], None, cx); + }); + + // Simulate LLM response with edit steps + let llm_response = indoc! {r#" + Sure, I can help you refactor that code. Here's a step-by-step process: + + + First, let's extract the greeting into a separate function: + + ```rust + fn greet() { + println!("Hello, World!"); + } + + fn main() { + greet(); + } + ``` + + + + Now, let's make the greeting customizable: + + ```rust + fn greet(name: &str) { + println!("Hello, {}!", name); + } + + fn main() { + greet("World"); + } + ``` + + + These changes make the code more modular and flexible. + "#}; + + // Simulate the assist method to trigger the LLM response + context.update(cx, |context, cx| context.assist(cx)); + cx.run_until_parked(); + + // Retrieve the assistant response message's start from the context + let response_start_row = context.read_with(cx, |context, cx| { + let buffer = context.buffer.read(cx); + context.message_anchors[1].start.to_point(buffer).row + }); + + // Simulate the LLM completion + fake_provider.send_last_completion_chunk(llm_response.to_string()); + fake_provider.finish_last_completion(); + + // Wait for the completion to be processed + cx.run_until_parked(); + + // Verify that the edit steps were parsed correctly + context.read_with(cx, |context, cx| { + assert_eq!( + edit_steps(context, cx), + vec![ + Point::new(response_start_row + 2, 0)..Point::new(response_start_row + 14, 7), + Point::new(response_start_row + 16, 0)..Point::new(response_start_row + 28, 7), + ] + ); + }); + + fn edit_steps(context: &Context, cx: &AppContext) -> Vec> { + context + .edit_steps + .iter() + .map(|step| { + let buffer = context.buffer.read(cx); + step.source_range.to_point(buffer) + }) + .collect() + } + } + + #[test] + fn test_parse_edit_operations() { + let operations = indoc! {r#" + Here are the operations to make all fields of the Canvas struct private: + + + + + + + + "#}; + + let parsed_operations = Context::parse_edit_operations(operations); + assert_eq!( + parsed_operations, + vec![ + EditOperation { + path: "font-kit/src/canvas.rs".to_string(), + kind: EditOperationKind::Update { + symbol: "pub struct Canvas pub pixels".to_string(), + description: "Remove pub keyword from pixels field".to_string(), + }, + }, + EditOperation { + path: "font-kit/src/canvas.rs".to_string(), + kind: EditOperationKind::Update { + symbol: "pub struct Canvas pub size".to_string(), + description: "Remove pub keyword from size field".to_string(), + }, + }, + EditOperation { + path: "font-kit/src/canvas.rs".to_string(), + kind: EditOperationKind::Update { + symbol: "pub struct Canvas pub stride".to_string(), + description: "Remove pub keyword from stride field".to_string(), + }, + }, + EditOperation { + path: "font-kit/src/canvas.rs".to_string(), + kind: EditOperationKind::Update { + symbol: "pub struct Canvas pub format".to_string(), + description: "Remove pub keyword from format field".to_string(), + }, + }, + ] + ); + } + + #[gpui::test] + async fn test_serialization(cx: &mut TestAppContext) { + let settings_store = cx.update(SettingsStore::test); + cx.set_global(settings_store); + cx.update(FakeCompletionProvider::setup_test); + cx.update(assistant_panel::init); + let registry = Arc::new(LanguageRegistry::test(cx.executor())); + let context = cx.new_model(|cx| Context::local(registry.clone(), None, cx)); + let buffer = context.read_with(cx, |context, _| context.buffer.clone()); + let message_0 = context.read_with(cx, |context, _| context.message_anchors[0].id); + let message_1 = context.update(cx, |context, cx| { + context + .insert_message_after(message_0, Role::Assistant, MessageStatus::Done, cx) + .unwrap() + }); + let message_2 = context.update(cx, |context, cx| { + context + .insert_message_after(message_1.id, Role::System, MessageStatus::Done, cx) + .unwrap() + }); + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "a"), (1..1, "b\nc")], None, cx); + buffer.finalize_last_transaction(); + }); + let _message_3 = context.update(cx, |context, cx| { + context + .insert_message_after(message_2.id, Role::System, MessageStatus::Done, cx) + .unwrap() + }); + buffer.update(cx, |buffer, cx| buffer.undo(cx)); + assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "a\nb\nc\n"); + assert_eq!( + cx.read(|cx| messages(&context, cx)), + [ + (message_0, Role::User, 0..2), + (message_1.id, Role::Assistant, 2..6), + (message_2.id, Role::System, 6..6), + ] + ); + + let serialized_context = context.read_with(cx, |context, cx| context.serialize(cx)); + let deserialized_context = cx.new_model(|cx| { + Context::deserialize( + serialized_context, + Default::default(), + registry.clone(), + None, + cx, + ) + }); + let deserialized_buffer = + deserialized_context.read_with(cx, |context, _| context.buffer.clone()); + assert_eq!( + deserialized_buffer.read_with(cx, |buffer, _| buffer.text()), + "a\nb\nc\n" + ); + assert_eq!( + cx.read(|cx| messages(&deserialized_context, cx)), + [ + (message_0, Role::User, 0..2), + (message_1.id, Role::Assistant, 2..6), + (message_2.id, Role::System, 6..6), + ] + ); + } + + #[gpui::test(iterations = 100)] + async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: StdRng) { + let min_peers = env::var("MIN_PEERS") + .map(|i| i.parse().expect("invalid `MIN_PEERS` variable")) + .unwrap_or(2); + let max_peers = env::var("MAX_PEERS") + .map(|i| i.parse().expect("invalid `MAX_PEERS` variable")) + .unwrap_or(5); + let operations = env::var("OPERATIONS") + .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) + .unwrap_or(50); + + let settings_store = cx.update(SettingsStore::test); + cx.set_global(settings_store); + cx.update(FakeCompletionProvider::setup_test); + cx.update(assistant_panel::init); + let slash_commands = cx.update(SlashCommandRegistry::default_global); + slash_commands.register_command(FakeSlashCommand("cmd-1".into()), false); + slash_commands.register_command(FakeSlashCommand("cmd-2".into()), false); + slash_commands.register_command(FakeSlashCommand("cmd-3".into()), false); + + let registry = Arc::new(LanguageRegistry::test(cx.background_executor.clone())); + let network = Arc::new(Mutex::new(Network::new(rng.clone()))); + let mut contexts = Vec::new(); + + let num_peers = rng.gen_range(min_peers..=max_peers); + let context_id = ContextId::new(); + for i in 0..num_peers { + let context = cx.new_model(|cx| { + Context::new( + context_id.clone(), + i as ReplicaId, + language::Capability::ReadWrite, + registry.clone(), + None, + cx, + ) + }); + + cx.update(|cx| { + cx.subscribe(&context, { + let network = network.clone(); + move |_, event, _| { + if let ContextEvent::Operation(op) = event { + network + .lock() + .broadcast(i as ReplicaId, vec![op.to_proto()]); + } + } + }) + .detach(); + }); + + contexts.push(context); + network.lock().add_peer(i as ReplicaId); + } + + let mut mutation_count = operations; + + while mutation_count > 0 + || !network.lock().is_idle() + || network.lock().contains_disconnected_peers() + { + let context_index = rng.gen_range(0..contexts.len()); + let context = &contexts[context_index]; + + match rng.gen_range(0..100) { + 0..=29 if mutation_count > 0 => { + log::info!("Context {}: edit buffer", context_index); + context.update(cx, |context, cx| { + context + .buffer + .update(cx, |buffer, cx| buffer.randomly_edit(&mut rng, 1, cx)); + }); + mutation_count -= 1; + } + 30..=44 if mutation_count > 0 => { + context.update(cx, |context, cx| { + let range = context.buffer.read(cx).random_byte_range(0, &mut rng); + log::info!("Context {}: split message at {:?}", context_index, range); + context.split_message(range, cx); + }); + mutation_count -= 1; + } + 45..=59 if mutation_count > 0 => { + context.update(cx, |context, cx| { + if let Some(message) = context.messages(cx).choose(&mut rng) { + let role = *[Role::User, Role::Assistant, Role::System] + .choose(&mut rng) + .unwrap(); + log::info!( + "Context {}: insert message after {:?} with {:?}", + context_index, + message.id, + role + ); + context.insert_message_after(message.id, role, MessageStatus::Done, cx); + } + }); + mutation_count -= 1; + } + 60..=74 if mutation_count > 0 => { + context.update(cx, |context, cx| { + let command_text = "/".to_string() + + slash_commands + .command_names() + .choose(&mut rng) + .unwrap() + .clone() + .as_ref(); + + let command_range = context.buffer.update(cx, |buffer, cx| { + let offset = buffer.random_byte_range(0, &mut rng).start; + buffer.edit( + [(offset..offset, format!("\n{}\n", command_text))], + None, + cx, + ); + offset + 1..offset + 1 + command_text.len() + }); + + let output_len = rng.gen_range(1..=10); + let output_text = RandomCharIter::new(&mut rng) + .filter(|c| *c != '\r') + .take(output_len) + .collect::(); + + let num_sections = rng.gen_range(0..=3); + let mut sections = Vec::with_capacity(num_sections); + for _ in 0..num_sections { + let section_start = rng.gen_range(0..output_len); + let section_end = rng.gen_range(section_start..=output_len); + sections.push(SlashCommandOutputSection { + range: section_start..section_end, + icon: ui::IconName::Ai, + label: "section".into(), + }); + } + + log::info!( + "Context {}: insert slash command output at {:?} with {:?}", + context_index, + command_range, + sections + ); + + let command_range = + context.buffer.read(cx).anchor_after(command_range.start) + ..context.buffer.read(cx).anchor_after(command_range.end); + context.insert_command_output( + command_range, + Task::ready(Ok(SlashCommandOutput { + text: output_text, + sections, + run_commands_in_text: false, + })), + true, + cx, + ); + }); + cx.run_until_parked(); + mutation_count -= 1; + } + 75..=84 if mutation_count > 0 => { + context.update(cx, |context, cx| { + if let Some(message) = context.messages(cx).choose(&mut rng) { + let new_status = match rng.gen_range(0..3) { + 0 => MessageStatus::Done, + 1 => MessageStatus::Pending, + _ => MessageStatus::Error(SharedString::from("Random error")), + }; + log::info!( + "Context {}: update message {:?} status to {:?}", + context_index, + message.id, + new_status + ); + context.update_metadata(message.id, cx, |metadata| { + metadata.status = new_status; + }); + } + }); + mutation_count -= 1; + } + _ => { + let replica_id = context_index as ReplicaId; + if network.lock().is_disconnected(replica_id) { + network.lock().reconnect_peer(replica_id, 0); + + let (ops_to_send, ops_to_receive) = cx.read(|cx| { + let host_context = &contexts[0].read(cx); + let guest_context = context.read(cx); + ( + guest_context.serialize_ops(&host_context.version(cx), cx), + host_context.serialize_ops(&guest_context.version(cx), cx), + ) + }); + let ops_to_send = ops_to_send.await; + let ops_to_receive = ops_to_receive + .await + .into_iter() + .map(ContextOperation::from_proto) + .collect::>>() + .unwrap(); + log::info!( + "Context {}: reconnecting. Sent {} operations, received {} operations", + context_index, + ops_to_send.len(), + ops_to_receive.len() + ); + + network.lock().broadcast(replica_id, ops_to_send); + context + .update(cx, |context, cx| context.apply_ops(ops_to_receive, cx)) + .unwrap(); + } else if rng.gen_bool(0.1) && replica_id != 0 { + log::info!("Context {}: disconnecting", context_index); + network.lock().disconnect_peer(replica_id); + } else if network.lock().has_unreceived(replica_id) { + log::info!("Context {}: applying operations", context_index); + let ops = network.lock().receive(replica_id); + let ops = ops + .into_iter() + .map(ContextOperation::from_proto) + .collect::>>() + .unwrap(); + context + .update(cx, |context, cx| context.apply_ops(ops, cx)) + .unwrap(); + } + } + } + } + + cx.read(|cx| { + let first_context = contexts[0].read(cx); + for context in &contexts[1..] { + let context = context.read(cx); + assert!(context.pending_ops.is_empty()); + assert_eq!( + context.buffer.read(cx).text(), + first_context.buffer.read(cx).text(), + "Context {} text != Context 0 text", + context.buffer.read(cx).replica_id() + ); + assert_eq!( + context.message_anchors, + first_context.message_anchors, + "Context {} messages != Context 0 messages", + context.buffer.read(cx).replica_id() + ); + assert_eq!( + context.messages_metadata, + first_context.messages_metadata, + "Context {} message metadata != Context 0 message metadata", + context.buffer.read(cx).replica_id() + ); + assert_eq!( + context.slash_command_output_sections, + first_context.slash_command_output_sections, + "Context {} slash command output sections != Context 0 slash command output sections", + context.buffer.read(cx).replica_id() + ); + } + }); + } + + fn messages(context: &Model, cx: &AppContext) -> Vec<(MessageId, Role, Range)> { + context + .read(cx) + .messages(cx) + .map(|message| (message.id, message.role, message.offset_range)) + .collect() + } + + #[derive(Clone)] + struct FakeSlashCommand(String); + + impl SlashCommand for FakeSlashCommand { + fn name(&self) -> String { + self.0.clone() + } + + fn description(&self) -> String { + format!("Fake slash command: {}", self.0) + } + + fn menu_text(&self) -> String { + format!("Run fake command: {}", self.0) + } + + fn complete_argument( + self: Arc, + _query: String, + _cancel: Arc, + _workspace: Option>, + _cx: &mut AppContext, + ) -> Task>> { + Task::ready(Ok(vec![])) + } + + fn requires_argument(&self) -> bool { + false + } + + fn run( + self: Arc, + _argument: Option<&str>, + _workspace: WeakView, + _delegate: Arc, + _cx: &mut WindowContext, + ) -> Task> { + Task::ready(Ok(SlashCommandOutput { + text: format!("Executed fake command: {}", self.0), + sections: vec![], + run_commands_in_text: false, + })) + } + } +} diff --git a/crates/assistant/src/context_store.rs b/crates/assistant/src/context_store.rs new file mode 100644 index 00000000000000..9cf60fc01432ff --- /dev/null +++ b/crates/assistant/src/context_store.rs @@ -0,0 +1,628 @@ +use crate::{ + Context, ContextEvent, ContextId, ContextOperation, ContextVersion, SavedContext, + SavedContextMetadata, +}; +use anyhow::{anyhow, Context as _, Result}; +use client::{proto, telemetry::Telemetry, Client, TypedEnvelope}; +use clock::ReplicaId; +use fs::Fs; +use futures::StreamExt; +use fuzzy::StringMatchCandidate; +use gpui::{AppContext, AsyncAppContext, Context as _, Model, ModelContext, Task, WeakModel}; +use language::LanguageRegistry; +use paths::contexts_dir; +use project::Project; +use regex::Regex; +use std::{ + cmp::Reverse, + ffi::OsStr, + mem, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; +use util::{ResultExt, TryFutureExt}; + +pub fn init(client: &Arc) { + client.add_model_message_handler(ContextStore::handle_advertise_contexts); + client.add_model_request_handler(ContextStore::handle_open_context); + client.add_model_message_handler(ContextStore::handle_update_context); + client.add_model_request_handler(ContextStore::handle_synchronize_contexts); +} + +#[derive(Clone)] +pub struct RemoteContextMetadata { + pub id: ContextId, + pub summary: Option, +} + +pub struct ContextStore { + contexts: Vec, + contexts_metadata: Vec, + host_contexts: Vec, + fs: Arc, + languages: Arc, + telemetry: Arc, + _watch_updates: Task>, + client: Arc, + project: Model, + project_is_shared: bool, + client_subscription: Option, + _project_subscriptions: Vec, +} + +enum ContextHandle { + Weak(WeakModel), + Strong(Model), +} + +impl ContextHandle { + fn upgrade(&self) -> Option> { + match self { + ContextHandle::Weak(weak) => weak.upgrade(), + ContextHandle::Strong(strong) => Some(strong.clone()), + } + } + + fn downgrade(&self) -> WeakModel { + match self { + ContextHandle::Weak(weak) => weak.clone(), + ContextHandle::Strong(strong) => strong.downgrade(), + } + } +} + +impl ContextStore { + pub fn new(project: Model, cx: &mut AppContext) -> Task>> { + let fs = project.read(cx).fs().clone(); + let languages = project.read(cx).languages().clone(); + let telemetry = project.read(cx).client().telemetry().clone(); + cx.spawn(|mut cx| async move { + const CONTEXT_WATCH_DURATION: Duration = Duration::from_millis(100); + let (mut events, _) = fs.watch(contexts_dir(), CONTEXT_WATCH_DURATION).await; + + let this = cx.new_model(|cx: &mut ModelContext| { + let mut this = Self { + contexts: Vec::new(), + contexts_metadata: Vec::new(), + host_contexts: Vec::new(), + fs, + languages, + telemetry, + _watch_updates: cx.spawn(|this, mut cx| { + async move { + while events.next().await.is_some() { + this.update(&mut cx, |this, cx| this.reload(cx))? + .await + .log_err(); + } + anyhow::Ok(()) + } + .log_err() + }), + client_subscription: None, + _project_subscriptions: vec![ + cx.observe(&project, Self::handle_project_changed), + cx.subscribe(&project, Self::handle_project_event), + ], + project_is_shared: false, + client: project.read(cx).client(), + project: project.clone(), + }; + this.handle_project_changed(project, cx); + this.synchronize_contexts(cx); + this + })?; + this.update(&mut cx, |this, cx| this.reload(cx))? + .await + .log_err(); + Ok(this) + }) + } + + async fn handle_advertise_contexts( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, cx| { + this.host_contexts = envelope + .payload + .contexts + .into_iter() + .map(|context| RemoteContextMetadata { + id: ContextId::from_proto(context.context_id), + summary: context.summary, + }) + .collect(); + cx.notify(); + }) + } + + async fn handle_open_context( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result { + let context_id = ContextId::from_proto(envelope.payload.context_id); + let operations = this.update(&mut cx, |this, cx| { + if this.project.read(cx).is_remote() { + return Err(anyhow!("only the host contexts can be opened")); + } + + let context = this + .loaded_context_for_id(&context_id, cx) + .context("context not found")?; + if context.read(cx).replica_id() != ReplicaId::default() { + return Err(anyhow!("context must be opened via the host")); + } + + anyhow::Ok( + context + .read(cx) + .serialize_ops(&ContextVersion::default(), cx), + ) + })??; + let operations = operations.await; + Ok(proto::OpenContextResponse { + context: Some(proto::Context { operations }), + }) + } + + async fn handle_update_context( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, cx| { + let context_id = ContextId::from_proto(envelope.payload.context_id); + if let Some(context) = this.loaded_context_for_id(&context_id, cx) { + let operation_proto = envelope.payload.operation.context("invalid operation")?; + let operation = ContextOperation::from_proto(operation_proto)?; + context.update(cx, |context, cx| context.apply_ops([operation], cx))?; + } + Ok(()) + })? + } + + async fn handle_synchronize_contexts( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result { + this.update(&mut cx, |this, cx| { + if this.project.read(cx).is_remote() { + return Err(anyhow!("only the host can synchronize contexts")); + } + + let mut local_versions = Vec::new(); + for remote_version_proto in envelope.payload.contexts { + let remote_version = ContextVersion::from_proto(&remote_version_proto); + let context_id = ContextId::from_proto(remote_version_proto.context_id); + if let Some(context) = this.loaded_context_for_id(&context_id, cx) { + let context = context.read(cx); + let operations = context.serialize_ops(&remote_version, cx); + local_versions.push(context.version(cx).to_proto(context_id.clone())); + let client = this.client.clone(); + let project_id = envelope.payload.project_id; + cx.background_executor() + .spawn(async move { + let operations = operations.await; + for operation in operations { + client.send(proto::UpdateContext { + project_id, + context_id: context_id.to_proto(), + operation: Some(operation), + })?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + } + + this.advertise_contexts(cx); + + anyhow::Ok(proto::SynchronizeContextsResponse { + contexts: local_versions, + }) + })? + } + + fn handle_project_changed(&mut self, _: Model, cx: &mut ModelContext) { + let is_shared = self.project.read(cx).is_shared(); + let was_shared = mem::replace(&mut self.project_is_shared, is_shared); + if is_shared == was_shared { + return; + } + + if is_shared { + self.contexts.retain_mut(|context| { + if let Some(strong_context) = context.upgrade() { + *context = ContextHandle::Strong(strong_context); + true + } else { + false + } + }); + let remote_id = self.project.read(cx).remote_id().unwrap(); + self.client_subscription = self + .client + .subscribe_to_entity(remote_id) + .log_err() + .map(|subscription| subscription.set_model(&cx.handle(), &mut cx.to_async())); + self.advertise_contexts(cx); + } else { + self.client_subscription = None; + } + } + + fn handle_project_event( + &mut self, + _: Model, + event: &project::Event, + cx: &mut ModelContext, + ) { + match event { + project::Event::Reshared => { + self.advertise_contexts(cx); + } + project::Event::HostReshared | project::Event::Rejoined => { + self.synchronize_contexts(cx); + } + project::Event::DisconnectedFromHost => { + self.contexts.retain_mut(|context| { + if let Some(strong_context) = context.upgrade() { + *context = ContextHandle::Weak(context.downgrade()); + strong_context.update(cx, |context, cx| { + if context.replica_id() != ReplicaId::default() { + context.set_capability(language::Capability::ReadOnly, cx); + } + }); + true + } else { + false + } + }); + self.host_contexts.clear(); + cx.notify(); + } + _ => {} + } + } + + pub fn create(&mut self, cx: &mut ModelContext) -> Model { + let context = cx.new_model(|cx| { + Context::local(self.languages.clone(), Some(self.telemetry.clone()), cx) + }); + self.register_context(&context, cx); + context + } + + pub fn open_local_context( + &mut self, + path: PathBuf, + cx: &ModelContext, + ) -> Task>> { + if let Some(existing_context) = self.loaded_context_for_path(&path, cx) { + return Task::ready(Ok(existing_context)); + } + + let fs = self.fs.clone(); + let languages = self.languages.clone(); + let telemetry = self.telemetry.clone(); + let load = cx.background_executor().spawn({ + let path = path.clone(); + async move { + let saved_context = fs.load(&path).await?; + SavedContext::from_json(&saved_context) + } + }); + + cx.spawn(|this, mut cx| async move { + let saved_context = load.await?; + let context = cx.new_model(|cx| { + Context::deserialize(saved_context, path.clone(), languages, Some(telemetry), cx) + })?; + this.update(&mut cx, |this, cx| { + if let Some(existing_context) = this.loaded_context_for_path(&path, cx) { + existing_context + } else { + this.register_context(&context, cx); + context + } + }) + }) + } + + fn loaded_context_for_path(&self, path: &Path, cx: &AppContext) -> Option> { + self.contexts.iter().find_map(|context| { + let context = context.upgrade()?; + if context.read(cx).path() == Some(path) { + Some(context) + } else { + None + } + }) + } + + fn loaded_context_for_id(&self, id: &ContextId, cx: &AppContext) -> Option> { + self.contexts.iter().find_map(|context| { + let context = context.upgrade()?; + if context.read(cx).id() == id { + Some(context) + } else { + None + } + }) + } + + pub fn open_remote_context( + &mut self, + context_id: ContextId, + cx: &mut ModelContext, + ) -> Task>> { + let project = self.project.read(cx); + let Some(project_id) = project.remote_id() else { + return Task::ready(Err(anyhow!("project was not remote"))); + }; + if project.is_local() { + return Task::ready(Err(anyhow!("cannot open remote contexts as the host"))); + } + + if let Some(context) = self.loaded_context_for_id(&context_id, cx) { + return Task::ready(Ok(context)); + } + + let replica_id = project.replica_id(); + let capability = project.capability(); + let language_registry = self.languages.clone(); + let telemetry = self.telemetry.clone(); + let request = self.client.request(proto::OpenContext { + project_id, + context_id: context_id.to_proto(), + }); + cx.spawn(|this, mut cx| async move { + let response = request.await?; + let context_proto = response.context.context("invalid context")?; + let context = cx.new_model(|cx| { + Context::new( + context_id.clone(), + replica_id, + capability, + language_registry, + Some(telemetry), + cx, + ) + })?; + let operations = cx + .background_executor() + .spawn(async move { + context_proto + .operations + .into_iter() + .map(|op| ContextOperation::from_proto(op)) + .collect::>>() + }) + .await?; + context.update(&mut cx, |context, cx| context.apply_ops(operations, cx))??; + this.update(&mut cx, |this, cx| { + if let Some(existing_context) = this.loaded_context_for_id(&context_id, cx) { + existing_context + } else { + this.register_context(&context, cx); + this.synchronize_contexts(cx); + context + } + }) + }) + } + + fn register_context(&mut self, context: &Model, cx: &mut ModelContext) { + let handle = if self.project_is_shared { + ContextHandle::Strong(context.clone()) + } else { + ContextHandle::Weak(context.downgrade()) + }; + self.contexts.push(handle); + self.advertise_contexts(cx); + cx.subscribe(context, Self::handle_context_event).detach(); + } + + fn handle_context_event( + &mut self, + context: Model, + event: &ContextEvent, + cx: &mut ModelContext, + ) { + let Some(project_id) = self.project.read(cx).remote_id() else { + return; + }; + + match event { + ContextEvent::SummaryChanged => { + self.advertise_contexts(cx); + } + ContextEvent::Operation(operation) => { + let context_id = context.read(cx).id().to_proto(); + let operation = operation.to_proto(); + self.client + .send(proto::UpdateContext { + project_id, + context_id, + operation: Some(operation), + }) + .log_err(); + } + _ => {} + } + } + + fn advertise_contexts(&self, cx: &AppContext) { + let Some(project_id) = self.project.read(cx).remote_id() else { + return; + }; + + // For now, only the host can advertise their open contexts. + if self.project.read(cx).is_remote() { + return; + } + + let contexts = self + .contexts + .iter() + .rev() + .filter_map(|context| { + let context = context.upgrade()?.read(cx); + if context.replica_id() == ReplicaId::default() { + Some(proto::ContextMetadata { + context_id: context.id().to_proto(), + summary: context.summary().map(|summary| summary.text.clone()), + }) + } else { + None + } + }) + .collect(); + self.client + .send(proto::AdvertiseContexts { + project_id, + contexts, + }) + .ok(); + } + + fn synchronize_contexts(&mut self, cx: &mut ModelContext) { + let Some(project_id) = self.project.read(cx).remote_id() else { + return; + }; + + let contexts = self + .contexts + .iter() + .filter_map(|context| { + let context = context.upgrade()?.read(cx); + if context.replica_id() != ReplicaId::default() { + Some(context.version(cx).to_proto(context.id().clone())) + } else { + None + } + }) + .collect(); + + let client = self.client.clone(); + let request = self.client.request(proto::SynchronizeContexts { + project_id, + contexts, + }); + cx.spawn(|this, cx| async move { + let response = request.await?; + + let mut context_ids = Vec::new(); + let mut operations = Vec::new(); + this.read_with(&cx, |this, cx| { + for context_version_proto in response.contexts { + let context_version = ContextVersion::from_proto(&context_version_proto); + let context_id = ContextId::from_proto(context_version_proto.context_id); + if let Some(context) = this.loaded_context_for_id(&context_id, cx) { + context_ids.push(context_id); + operations.push(context.read(cx).serialize_ops(&context_version, cx)); + } + } + })?; + + let operations = futures::future::join_all(operations).await; + for (context_id, operations) in context_ids.into_iter().zip(operations) { + for operation in operations { + client.send(proto::UpdateContext { + project_id, + context_id: context_id.to_proto(), + operation: Some(operation), + })?; + } + } + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + pub fn search(&self, query: String, cx: &AppContext) -> Task> { + let metadata = self.contexts_metadata.clone(); + let executor = cx.background_executor().clone(); + cx.background_executor().spawn(async move { + if query.is_empty() { + metadata + } else { + let candidates = metadata + .iter() + .enumerate() + .map(|(id, metadata)| StringMatchCandidate::new(id, metadata.title.clone())) + .collect::>(); + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + 100, + &Default::default(), + executor, + ) + .await; + + matches + .into_iter() + .map(|mat| metadata[mat.candidate_id].clone()) + .collect() + } + }) + } + + pub fn host_contexts(&self) -> &[RemoteContextMetadata] { + &self.host_contexts + } + + fn reload(&mut self, cx: &mut ModelContext) -> Task> { + let fs = self.fs.clone(); + cx.spawn(|this, mut cx| async move { + fs.create_dir(contexts_dir()).await?; + + let mut paths = fs.read_dir(contexts_dir()).await?; + let mut contexts = Vec::::new(); + while let Some(path) = paths.next().await { + let path = path?; + if path.extension() != Some(OsStr::new("json")) { + continue; + } + + let pattern = r" - \d+.zed.json$"; + let re = Regex::new(pattern).unwrap(); + + let metadata = fs.metadata(&path).await?; + if let Some((file_name, metadata)) = path + .file_name() + .and_then(|name| name.to_str()) + .zip(metadata) + { + // This is used to filter out contexts saved by the new assistant. + if !re.is_match(file_name) { + continue; + } + + if let Some(title) = re.replace(file_name, "").lines().next() { + contexts.push(SavedContextMetadata { + title: title.to_string(), + path, + mtime: metadata.mtime.into(), + }); + } + } + } + contexts.sort_unstable_by_key(|context| Reverse(context.mtime)); + + this.update(&mut cx, |this, cx| { + this.contexts_metadata = contexts; + cx.notify(); + }) + }) + } +} diff --git a/crates/assistant/src/inline_assistant.rs b/crates/assistant/src/inline_assistant.rs new file mode 100644 index 00000000000000..be14e271e86f0f --- /dev/null +++ b/crates/assistant/src/inline_assistant.rs @@ -0,0 +1,2878 @@ +use crate::{ + assistant_settings::AssistantSettings, humanize_token_count, prompts::generate_content_prompt, + AssistantPanel, AssistantPanelEvent, CompletionProvider, Hunk, LanguageModelRequest, + LanguageModelRequestMessage, Role, StreamingDiff, +}; +use anyhow::{anyhow, Context as _, Result}; +use client::telemetry::Telemetry; +use collections::{hash_map, HashMap, HashSet, VecDeque}; +use editor::{ + actions::{MoveDown, MoveUp, SelectAll}, + display_map::{ + BlockContext, BlockDisposition, BlockProperties, BlockStyle, CustomBlockId, RenderBlock, + ToDisplayPoint, + }, + Anchor, AnchorRangeExt, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, + ExcerptRange, GutterDimensions, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint, +}; +use fs::Fs; +use futures::{ + channel::mpsc, + future::LocalBoxFuture, + stream::{self, BoxStream}, + SinkExt, Stream, StreamExt, +}; +use gpui::{ + point, AppContext, EventEmitter, FocusHandle, FocusableView, FontStyle, Global, HighlightStyle, + Model, ModelContext, Subscription, Task, TextStyle, UpdateGlobal, View, ViewContext, WeakView, + WhiteSpace, WindowContext, +}; +use language::{Buffer, Point, Selection, TransactionId}; +use multi_buffer::MultiBufferRow; +use parking_lot::Mutex; +use rope::Rope; +use settings::{update_settings_file, Settings}; +use similar::TextDiff; +use smol::future::FutureExt; +use std::{ + cmp, + future::Future, + mem, + ops::{Range, RangeInclusive}, + pin::Pin, + sync::Arc, + task::{self, Poll}, + time::{Duration, Instant}, +}; +use theme::ThemeSettings; +use ui::{prelude::*, ContextMenu, PopoverMenu, Tooltip}; +use util::RangeExt; +use workspace::{notifications::NotificationId, Toast, Workspace}; + +pub fn init(fs: Arc, telemetry: Arc, cx: &mut AppContext) { + cx.set_global(InlineAssistant::new(fs, telemetry)); +} + +const PROMPT_HISTORY_MAX_LEN: usize = 20; + +pub struct InlineAssistant { + next_assist_id: InlineAssistId, + next_assist_group_id: InlineAssistGroupId, + assists: HashMap, + assists_by_editor: HashMap, EditorInlineAssists>, + assist_groups: HashMap, + prompt_history: VecDeque, + telemetry: Option>, + fs: Arc, +} + +impl Global for InlineAssistant {} + +impl InlineAssistant { + pub fn new(fs: Arc, telemetry: Arc) -> Self { + Self { + next_assist_id: InlineAssistId::default(), + next_assist_group_id: InlineAssistGroupId::default(), + assists: HashMap::default(), + assists_by_editor: HashMap::default(), + assist_groups: HashMap::default(), + prompt_history: VecDeque::default(), + telemetry: Some(telemetry), + fs, + } + } + + pub fn assist( + &mut self, + editor: &View, + workspace: Option>, + assistant_panel: Option<&View>, + initial_prompt: Option, + cx: &mut WindowContext, + ) { + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); + + let mut selections = Vec::>::new(); + let mut newest_selection = None; + for mut selection in editor.read(cx).selections.all::(cx) { + if selection.end > selection.start { + selection.start.column = 0; + // If the selection ends at the start of the line, we don't want to include it. + if selection.end.column == 0 { + selection.end.row -= 1; + } + selection.end.column = snapshot.line_len(MultiBufferRow(selection.end.row)); + } + + if let Some(prev_selection) = selections.last_mut() { + if selection.start <= prev_selection.end { + prev_selection.end = selection.end; + continue; + } + } + + let latest_selection = newest_selection.get_or_insert_with(|| selection.clone()); + if selection.id > latest_selection.id { + *latest_selection = selection.clone(); + } + selections.push(selection); + } + let newest_selection = newest_selection.unwrap(); + + let mut codegen_ranges = Vec::new(); + for (excerpt_id, buffer, buffer_range) in + snapshot.excerpts_in_ranges(selections.iter().map(|selection| { + snapshot.anchor_before(selection.start)..snapshot.anchor_after(selection.end) + })) + { + let start = Anchor { + buffer_id: Some(buffer.remote_id()), + excerpt_id, + text_anchor: buffer.anchor_before(buffer_range.start), + }; + let end = Anchor { + buffer_id: Some(buffer.remote_id()), + excerpt_id, + text_anchor: buffer.anchor_after(buffer_range.end), + }; + codegen_ranges.push(start..end); + } + + let assist_group_id = self.next_assist_group_id.post_inc(); + let prompt_buffer = + cx.new_model(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx)); + let prompt_buffer = cx.new_model(|cx| MultiBuffer::singleton(prompt_buffer, cx)); + + let mut assists = Vec::new(); + let mut assist_to_focus = None; + for range in codegen_ranges { + let assist_id = self.next_assist_id.post_inc(); + let codegen = cx.new_model(|cx| { + Codegen::new( + editor.read(cx).buffer().clone(), + range.clone(), + None, + self.telemetry.clone(), + cx, + ) + }); + + let gutter_dimensions = Arc::new(Mutex::new(GutterDimensions::default())); + let prompt_editor = cx.new_view(|cx| { + PromptEditor::new( + assist_id, + gutter_dimensions.clone(), + self.prompt_history.clone(), + prompt_buffer.clone(), + codegen.clone(), + editor, + assistant_panel, + workspace.clone(), + self.fs.clone(), + cx, + ) + }); + + if assist_to_focus.is_none() { + let focus_assist = if newest_selection.reversed { + range.start.to_point(&snapshot) == newest_selection.start + } else { + range.end.to_point(&snapshot) == newest_selection.end + }; + if focus_assist { + assist_to_focus = Some(assist_id); + } + } + + let [prompt_block_id, end_block_id] = + self.insert_assist_blocks(editor, &range, &prompt_editor, cx); + + assists.push((assist_id, prompt_editor, prompt_block_id, end_block_id)); + } + + let editor_assists = self + .assists_by_editor + .entry(editor.downgrade()) + .or_insert_with(|| EditorInlineAssists::new(&editor, cx)); + let mut assist_group = InlineAssistGroup::new(); + for (assist_id, prompt_editor, prompt_block_id, end_block_id) in assists { + self.assists.insert( + assist_id, + InlineAssist::new( + assist_id, + assist_group_id, + assistant_panel.is_some(), + editor, + &prompt_editor, + prompt_block_id, + end_block_id, + prompt_editor.read(cx).codegen.clone(), + workspace.clone(), + cx, + ), + ); + assist_group.assist_ids.push(assist_id); + editor_assists.assist_ids.push(assist_id); + } + self.assist_groups.insert(assist_group_id, assist_group); + + if let Some(assist_id) = assist_to_focus { + self.focus_assist(assist_id, cx); + } + } + + #[allow(clippy::too_many_arguments)] + pub fn suggest_assist( + &mut self, + editor: &View, + mut range: Range, + initial_prompt: String, + initial_insertion: Option, + workspace: Option>, + assistant_panel: Option<&View>, + cx: &mut WindowContext, + ) -> InlineAssistId { + let assist_group_id = self.next_assist_group_id.post_inc(); + let prompt_buffer = cx.new_model(|cx| Buffer::local(&initial_prompt, cx)); + let prompt_buffer = cx.new_model(|cx| MultiBuffer::singleton(prompt_buffer, cx)); + + let assist_id = self.next_assist_id.post_inc(); + + let buffer = editor.read(cx).buffer().clone(); + let prepend_transaction_id = initial_insertion.and_then(|initial_insertion| { + buffer.update(cx, |buffer, cx| { + buffer.start_transaction(cx); + buffer.edit([(range.start..range.start, initial_insertion)], None, cx); + buffer.end_transaction(cx) + }) + }); + + range.start = range.start.bias_left(&buffer.read(cx).read(cx)); + range.end = range.end.bias_right(&buffer.read(cx).read(cx)); + + let codegen = cx.new_model(|cx| { + Codegen::new( + editor.read(cx).buffer().clone(), + range.clone(), + prepend_transaction_id, + self.telemetry.clone(), + cx, + ) + }); + + let gutter_dimensions = Arc::new(Mutex::new(GutterDimensions::default())); + let prompt_editor = cx.new_view(|cx| { + PromptEditor::new( + assist_id, + gutter_dimensions.clone(), + self.prompt_history.clone(), + prompt_buffer.clone(), + codegen.clone(), + editor, + assistant_panel, + workspace.clone(), + self.fs.clone(), + cx, + ) + }); + + let [prompt_block_id, end_block_id] = + self.insert_assist_blocks(editor, &range, &prompt_editor, cx); + + let editor_assists = self + .assists_by_editor + .entry(editor.downgrade()) + .or_insert_with(|| EditorInlineAssists::new(&editor, cx)); + + let mut assist_group = InlineAssistGroup::new(); + self.assists.insert( + assist_id, + InlineAssist::new( + assist_id, + assist_group_id, + assistant_panel.is_some(), + editor, + &prompt_editor, + prompt_block_id, + end_block_id, + prompt_editor.read(cx).codegen.clone(), + workspace.clone(), + cx, + ), + ); + assist_group.assist_ids.push(assist_id); + editor_assists.assist_ids.push(assist_id); + self.assist_groups.insert(assist_group_id, assist_group); + assist_id + } + + fn insert_assist_blocks( + &self, + editor: &View, + range: &Range, + prompt_editor: &View, + cx: &mut WindowContext, + ) -> [CustomBlockId; 2] { + let assist_blocks = vec![ + BlockProperties { + style: BlockStyle::Sticky, + position: range.start, + height: prompt_editor.read(cx).height_in_lines, + render: build_assist_editor_renderer(prompt_editor), + disposition: BlockDisposition::Above, + }, + BlockProperties { + style: BlockStyle::Sticky, + position: range.end, + height: 1, + render: Box::new(|cx| { + v_flex() + .h_full() + .w_full() + .border_t_1() + .border_color(cx.theme().status().info_border) + .into_any_element() + }), + disposition: BlockDisposition::Below, + }, + ]; + + editor.update(cx, |editor, cx| { + let block_ids = editor.insert_blocks(assist_blocks, None, cx); + [block_ids[0], block_ids[1]] + }) + } + + fn handle_prompt_editor_focus_in(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) { + let assist = &self.assists[&assist_id]; + let Some(decorations) = assist.decorations.as_ref() else { + return; + }; + let assist_group = self.assist_groups.get_mut(&assist.group_id).unwrap(); + let editor_assists = self.assists_by_editor.get_mut(&assist.editor).unwrap(); + + assist_group.active_assist_id = Some(assist_id); + if assist_group.linked { + for assist_id in &assist_group.assist_ids { + if let Some(decorations) = self.assists[assist_id].decorations.as_ref() { + decorations.prompt_editor.update(cx, |prompt_editor, cx| { + prompt_editor.set_show_cursor_when_unfocused(true, cx) + }); + } + } + } + + assist + .editor + .update(cx, |editor, cx| { + let scroll_top = editor.scroll_position(cx).y; + let scroll_bottom = scroll_top + editor.visible_line_count().unwrap_or(0.); + let prompt_row = editor + .row_for_block(decorations.prompt_block_id, cx) + .unwrap() + .0 as f32; + + if (scroll_top..scroll_bottom).contains(&prompt_row) { + editor_assists.scroll_lock = Some(InlineAssistScrollLock { + assist_id, + distance_from_top: prompt_row - scroll_top, + }); + } else { + editor_assists.scroll_lock = None; + } + }) + .ok(); + } + + fn handle_prompt_editor_focus_out( + &mut self, + assist_id: InlineAssistId, + cx: &mut WindowContext, + ) { + let assist = &self.assists[&assist_id]; + let assist_group = self.assist_groups.get_mut(&assist.group_id).unwrap(); + if assist_group.active_assist_id == Some(assist_id) { + assist_group.active_assist_id = None; + if assist_group.linked { + for assist_id in &assist_group.assist_ids { + if let Some(decorations) = self.assists[assist_id].decorations.as_ref() { + decorations.prompt_editor.update(cx, |prompt_editor, cx| { + prompt_editor.set_show_cursor_when_unfocused(false, cx) + }); + } + } + } + } + } + + fn handle_prompt_editor_event( + &mut self, + prompt_editor: View, + event: &PromptEditorEvent, + cx: &mut WindowContext, + ) { + let assist_id = prompt_editor.read(cx).id; + match event { + PromptEditorEvent::StartRequested => { + self.start_assist(assist_id, cx); + } + PromptEditorEvent::StopRequested => { + self.stop_assist(assist_id, cx); + } + PromptEditorEvent::ConfirmRequested => { + self.finish_assist(assist_id, false, cx); + } + PromptEditorEvent::CancelRequested => { + self.finish_assist(assist_id, true, cx); + } + PromptEditorEvent::DismissRequested => { + self.dismiss_assist(assist_id, cx); + } + PromptEditorEvent::Resized { height_in_lines } => { + self.resize_assist(assist_id, *height_in_lines, cx); + } + } + } + + fn handle_editor_newline(&mut self, editor: View, cx: &mut WindowContext) { + let Some(editor_assists) = self.assists_by_editor.get(&editor.downgrade()) else { + return; + }; + + let editor = editor.read(cx); + if editor.selections.count() == 1 { + let selection = editor.selections.newest::(cx); + let buffer = editor.buffer().read(cx).snapshot(cx); + for assist_id in &editor_assists.assist_ids { + let assist = &self.assists[assist_id]; + let assist_range = assist.codegen.read(cx).range.to_offset(&buffer); + if assist_range.contains(&selection.start) && assist_range.contains(&selection.end) + { + if matches!(assist.codegen.read(cx).status, CodegenStatus::Pending) { + self.dismiss_assist(*assist_id, cx); + } else { + self.finish_assist(*assist_id, false, cx); + } + + return; + } + } + } + + cx.propagate(); + } + + fn handle_editor_cancel(&mut self, editor: View, cx: &mut WindowContext) { + let Some(editor_assists) = self.assists_by_editor.get(&editor.downgrade()) else { + return; + }; + + let editor = editor.read(cx); + if editor.selections.count() == 1 { + let selection = editor.selections.newest::(cx); + let buffer = editor.buffer().read(cx).snapshot(cx); + for assist_id in &editor_assists.assist_ids { + let assist = &self.assists[assist_id]; + let assist_range = assist.codegen.read(cx).range.to_offset(&buffer); + if assist.decorations.is_some() + && assist_range.contains(&selection.start) + && assist_range.contains(&selection.end) + { + self.focus_assist(*assist_id, cx); + return; + } + } + } + + cx.propagate(); + } + + fn handle_editor_release(&mut self, editor: WeakView, cx: &mut WindowContext) { + if let Some(editor_assists) = self.assists_by_editor.get_mut(&editor) { + for assist_id in editor_assists.assist_ids.clone() { + self.finish_assist(assist_id, true, cx); + } + } + } + + fn handle_editor_change(&mut self, editor: View, cx: &mut WindowContext) { + let Some(editor_assists) = self.assists_by_editor.get(&editor.downgrade()) else { + return; + }; + let Some(scroll_lock) = editor_assists.scroll_lock.as_ref() else { + return; + }; + let assist = &self.assists[&scroll_lock.assist_id]; + let Some(decorations) = assist.decorations.as_ref() else { + return; + }; + + editor.update(cx, |editor, cx| { + let scroll_position = editor.scroll_position(cx); + let target_scroll_top = editor + .row_for_block(decorations.prompt_block_id, cx) + .unwrap() + .0 as f32 + - scroll_lock.distance_from_top; + if target_scroll_top != scroll_position.y { + editor.set_scroll_position(point(scroll_position.x, target_scroll_top), cx); + } + }); + } + + fn handle_editor_event( + &mut self, + editor: View, + event: &EditorEvent, + cx: &mut WindowContext, + ) { + let Some(editor_assists) = self.assists_by_editor.get_mut(&editor.downgrade()) else { + return; + }; + + match event { + EditorEvent::Saved => { + for assist_id in editor_assists.assist_ids.clone() { + let assist = &self.assists[&assist_id]; + if let CodegenStatus::Done = &assist.codegen.read(cx).status { + self.finish_assist(assist_id, false, cx) + } + } + } + EditorEvent::Edited { transaction_id } => { + let buffer = editor.read(cx).buffer().read(cx); + let edited_ranges = + buffer.edited_ranges_for_transaction::(*transaction_id, cx); + let snapshot = buffer.snapshot(cx); + + for assist_id in editor_assists.assist_ids.clone() { + let assist = &self.assists[&assist_id]; + if matches!( + assist.codegen.read(cx).status, + CodegenStatus::Error(_) | CodegenStatus::Done + ) { + let assist_range = assist.codegen.read(cx).range.to_offset(&snapshot); + if edited_ranges + .iter() + .any(|range| range.overlaps(&assist_range)) + { + self.finish_assist(assist_id, false, cx); + } + } + } + } + EditorEvent::ScrollPositionChanged { .. } => { + if let Some(scroll_lock) = editor_assists.scroll_lock.as_ref() { + let assist = &self.assists[&scroll_lock.assist_id]; + if let Some(decorations) = assist.decorations.as_ref() { + let distance_from_top = editor.update(cx, |editor, cx| { + let scroll_top = editor.scroll_position(cx).y; + let prompt_row = editor + .row_for_block(decorations.prompt_block_id, cx) + .unwrap() + .0 as f32; + prompt_row - scroll_top + }); + + if distance_from_top != scroll_lock.distance_from_top { + editor_assists.scroll_lock = None; + } + } + } + } + EditorEvent::SelectionsChanged { .. } => { + for assist_id in editor_assists.assist_ids.clone() { + let assist = &self.assists[&assist_id]; + if let Some(decorations) = assist.decorations.as_ref() { + if decorations.prompt_editor.focus_handle(cx).is_focused(cx) { + return; + } + } + } + + editor_assists.scroll_lock = None; + } + _ => {} + } + } + + fn finish_assist(&mut self, assist_id: InlineAssistId, undo: bool, cx: &mut WindowContext) { + if let Some(assist) = self.assists.get(&assist_id) { + let assist_group_id = assist.group_id; + if self.assist_groups[&assist_group_id].linked { + for assist_id in self.unlink_assist_group(assist_group_id, cx) { + self.finish_assist(assist_id, undo, cx); + } + return; + } + } + + self.dismiss_assist(assist_id, cx); + + if let Some(assist) = self.assists.remove(&assist_id) { + if let hash_map::Entry::Occupied(mut entry) = self.assist_groups.entry(assist.group_id) + { + entry.get_mut().assist_ids.retain(|id| *id != assist_id); + if entry.get().assist_ids.is_empty() { + entry.remove(); + } + } + + if let hash_map::Entry::Occupied(mut entry) = + self.assists_by_editor.entry(assist.editor.clone()) + { + entry.get_mut().assist_ids.retain(|id| *id != assist_id); + if entry.get().assist_ids.is_empty() { + entry.remove(); + if let Some(editor) = assist.editor.upgrade() { + self.update_editor_highlights(&editor, cx); + } + } else { + entry.get().highlight_updates.send(()).ok(); + } + } + + if undo { + assist.codegen.update(cx, |codegen, cx| codegen.undo(cx)); + } + } + } + + fn dismiss_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) -> bool { + let Some(assist) = self.assists.get_mut(&assist_id) else { + return false; + }; + let Some(editor) = assist.editor.upgrade() else { + return false; + }; + let Some(decorations) = assist.decorations.take() else { + return false; + }; + + editor.update(cx, |editor, cx| { + let mut to_remove = decorations.removed_line_block_ids; + to_remove.insert(decorations.prompt_block_id); + to_remove.insert(decorations.end_block_id); + editor.remove_blocks(to_remove, None, cx); + }); + + if decorations + .prompt_editor + .focus_handle(cx) + .contains_focused(cx) + { + self.focus_next_assist(assist_id, cx); + } + + if let Some(editor_assists) = self.assists_by_editor.get_mut(&editor.downgrade()) { + if editor_assists + .scroll_lock + .as_ref() + .map_or(false, |lock| lock.assist_id == assist_id) + { + editor_assists.scroll_lock = None; + } + editor_assists.highlight_updates.send(()).ok(); + } + + true + } + + fn focus_next_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) { + let Some(assist) = self.assists.get(&assist_id) else { + return; + }; + + let assist_group = &self.assist_groups[&assist.group_id]; + let assist_ix = assist_group + .assist_ids + .iter() + .position(|id| *id == assist_id) + .unwrap(); + let assist_ids = assist_group + .assist_ids + .iter() + .skip(assist_ix + 1) + .chain(assist_group.assist_ids.iter().take(assist_ix)); + + for assist_id in assist_ids { + let assist = &self.assists[assist_id]; + if assist.decorations.is_some() { + self.focus_assist(*assist_id, cx); + return; + } + } + + assist.editor.update(cx, |editor, cx| editor.focus(cx)).ok(); + } + + fn focus_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) { + let assist = &self.assists[&assist_id]; + let Some(editor) = assist.editor.upgrade() else { + return; + }; + + if let Some(decorations) = assist.decorations.as_ref() { + decorations.prompt_editor.update(cx, |prompt_editor, cx| { + prompt_editor.editor.update(cx, |editor, cx| { + editor.focus(cx); + editor.select_all(&SelectAll, cx); + }) + }); + } + + let position = assist.codegen.read(cx).range.start; + editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |selections| { + selections.select_anchor_ranges([position..position]) + }); + + let mut scroll_target_top; + let mut scroll_target_bottom; + if let Some(decorations) = assist.decorations.as_ref() { + scroll_target_top = editor + .row_for_block(decorations.prompt_block_id, cx) + .unwrap() + .0 as f32; + scroll_target_bottom = editor + .row_for_block(decorations.end_block_id, cx) + .unwrap() + .0 as f32; + } else { + let snapshot = editor.snapshot(cx); + let codegen = assist.codegen.read(cx); + let start_row = codegen + .range + .start + .to_display_point(&snapshot.display_snapshot) + .row(); + scroll_target_top = start_row.0 as f32; + scroll_target_bottom = scroll_target_top + 1.; + } + scroll_target_top -= editor.vertical_scroll_margin() as f32; + scroll_target_bottom += editor.vertical_scroll_margin() as f32; + + let height_in_lines = editor.visible_line_count().unwrap_or(0.); + let scroll_top = editor.scroll_position(cx).y; + let scroll_bottom = scroll_top + height_in_lines; + + if scroll_target_top < scroll_top { + editor.set_scroll_position(point(0., scroll_target_top), cx); + } else if scroll_target_bottom > scroll_bottom { + if (scroll_target_bottom - scroll_target_top) <= height_in_lines { + editor + .set_scroll_position(point(0., scroll_target_bottom - height_in_lines), cx); + } else { + editor.set_scroll_position(point(0., scroll_target_top), cx); + } + } + }); + } + + fn resize_assist( + &mut self, + assist_id: InlineAssistId, + height_in_lines: u8, + cx: &mut WindowContext, + ) { + if let Some(assist) = self.assists.get_mut(&assist_id) { + if let Some(editor) = assist.editor.upgrade() { + if let Some(decorations) = assist.decorations.as_ref() { + let mut new_blocks = HashMap::default(); + new_blocks.insert( + decorations.prompt_block_id, + ( + Some(height_in_lines), + build_assist_editor_renderer(&decorations.prompt_editor), + ), + ); + editor.update(cx, |editor, cx| { + editor + .display_map + .update(cx, |map, cx| map.replace_blocks(new_blocks, cx)) + }); + } + } + } + } + + fn unlink_assist_group( + &mut self, + assist_group_id: InlineAssistGroupId, + cx: &mut WindowContext, + ) -> Vec { + let assist_group = self.assist_groups.get_mut(&assist_group_id).unwrap(); + assist_group.linked = false; + for assist_id in &assist_group.assist_ids { + let assist = self.assists.get_mut(assist_id).unwrap(); + if let Some(editor_decorations) = assist.decorations.as_ref() { + editor_decorations + .prompt_editor + .update(cx, |prompt_editor, cx| prompt_editor.unlink(cx)); + } + } + assist_group.assist_ids.clone() + } + + pub fn start_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) { + let assist = if let Some(assist) = self.assists.get_mut(&assist_id) { + assist + } else { + return; + }; + + let assist_group_id = assist.group_id; + if self.assist_groups[&assist_group_id].linked { + for assist_id in self.unlink_assist_group(assist_group_id, cx) { + self.start_assist(assist_id, cx); + } + return; + } + + let Some(user_prompt) = assist + .decorations + .as_ref() + .map(|decorations| decorations.prompt_editor.read(cx).prompt(cx)) + else { + return; + }; + + self.prompt_history.retain(|prompt| *prompt != user_prompt); + self.prompt_history.push_back(user_prompt.clone()); + if self.prompt_history.len() > PROMPT_HISTORY_MAX_LEN { + self.prompt_history.pop_front(); + } + + let codegen = assist.codegen.clone(); + let telemetry_id = CompletionProvider::global(cx).model().telemetry_id(); + let chunks: LocalBoxFuture>>> = + if user_prompt.trim().to_lowercase() == "delete" { + async { Ok(stream::empty().boxed()) }.boxed_local() + } else { + let request = self.request_for_inline_assist(assist_id, cx); + let mut cx = cx.to_async(); + async move { + let request = request.await?; + let chunks = cx + .update(|cx| CompletionProvider::global(cx).stream_completion(request, cx))? + .await?; + Ok(chunks.boxed()) + } + .boxed_local() + }; + codegen.update(cx, |codegen, cx| { + codegen.start(telemetry_id, chunks, cx); + }); + } + + fn request_for_inline_assist( + &self, + assist_id: InlineAssistId, + cx: &mut WindowContext, + ) -> Task> { + cx.spawn(|mut cx| async move { + let (user_prompt, context_request, project_name, buffer, range, model) = cx + .read_global(|this: &InlineAssistant, cx: &WindowContext| { + let assist = this.assists.get(&assist_id).context("invalid assist")?; + let decorations = assist.decorations.as_ref().context("invalid assist")?; + let editor = assist.editor.upgrade().context("invalid assist")?; + let user_prompt = decorations.prompt_editor.read(cx).prompt(cx); + let context_request = if assist.include_context { + assist.workspace.as_ref().and_then(|workspace| { + let workspace = workspace.upgrade()?.read(cx); + let assistant_panel = workspace.panel::(cx)?; + Some( + assistant_panel + .read(cx) + .active_context(cx)? + .read(cx) + .to_completion_request(cx), + ) + }) + } else { + None + }; + let project_name = assist.workspace.as_ref().and_then(|workspace| { + let workspace = workspace.upgrade()?; + Some( + workspace + .read(cx) + .project() + .read(cx) + .worktree_root_names(cx) + .collect::>() + .join("/"), + ) + }); + let buffer = editor.read(cx).buffer().read(cx).snapshot(cx); + let range = assist.codegen.read(cx).range.clone(); + let model = CompletionProvider::global(cx).model(); + anyhow::Ok(( + user_prompt, + context_request, + project_name, + buffer, + range, + model, + )) + })??; + + let language = buffer.language_at(range.start); + let language_name = if let Some(language) = language.as_ref() { + if Arc::ptr_eq(language, &language::PLAIN_TEXT) { + None + } else { + Some(language.name()) + } + } else { + None + }; + + // Higher Temperature increases the randomness of model outputs. + // If Markdown or No Language is Known, increase the randomness for more creative output + // If Code, decrease temperature to get more deterministic outputs + let temperature = if let Some(language) = language_name.clone() { + if language.as_ref() == "Markdown" { + 1.0 + } else { + 0.5 + } + } else { + 1.0 + }; + + let prompt = cx + .background_executor() + .spawn(async move { + let language_name = language_name.as_deref(); + let start = buffer.point_to_buffer_offset(range.start); + let end = buffer.point_to_buffer_offset(range.end); + let (buffer, range) = if let Some((start, end)) = start.zip(end) { + let (start_buffer, start_buffer_offset) = start; + let (end_buffer, end_buffer_offset) = end; + if start_buffer.remote_id() == end_buffer.remote_id() { + (start_buffer.clone(), start_buffer_offset..end_buffer_offset) + } else { + return Err(anyhow!("invalid transformation range")); + } + } else { + return Err(anyhow!("invalid transformation range")); + }; + generate_content_prompt(user_prompt, language_name, buffer, range, project_name) + }) + .await?; + + let mut messages = Vec::new(); + if let Some(context_request) = context_request { + messages = context_request.messages; + } + + messages.push(LanguageModelRequestMessage { + role: Role::User, + content: prompt, + }); + + Ok(LanguageModelRequest { + model, + messages, + stop: vec!["|END|>".to_string()], + temperature, + }) + }) + } + + pub fn stop_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) { + let assist = if let Some(assist) = self.assists.get_mut(&assist_id) { + assist + } else { + return; + }; + + assist.codegen.update(cx, |codegen, cx| codegen.stop(cx)); + } + + fn update_editor_highlights(&self, editor: &View, cx: &mut WindowContext) { + let mut gutter_pending_ranges = Vec::new(); + let mut gutter_transformed_ranges = Vec::new(); + let mut foreground_ranges = Vec::new(); + let mut inserted_row_ranges = Vec::new(); + let empty_assist_ids = Vec::new(); + let assist_ids = self + .assists_by_editor + .get(&editor.downgrade()) + .map_or(&empty_assist_ids, |editor_assists| { + &editor_assists.assist_ids + }); + + for assist_id in assist_ids { + if let Some(assist) = self.assists.get(assist_id) { + let codegen = assist.codegen.read(cx); + foreground_ranges.extend(codegen.last_equal_ranges().iter().cloned()); + + if codegen.edit_position != codegen.range.end { + gutter_pending_ranges.push(codegen.edit_position..codegen.range.end); + } + + if codegen.range.start != codegen.edit_position { + gutter_transformed_ranges.push(codegen.range.start..codegen.edit_position); + } + + if assist.decorations.is_some() { + inserted_row_ranges.extend(codegen.diff.inserted_row_ranges.iter().cloned()); + } + } + } + + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); + merge_ranges(&mut foreground_ranges, &snapshot); + merge_ranges(&mut gutter_pending_ranges, &snapshot); + merge_ranges(&mut gutter_transformed_ranges, &snapshot); + editor.update(cx, |editor, cx| { + enum GutterPendingRange {} + if gutter_pending_ranges.is_empty() { + editor.clear_gutter_highlights::(cx); + } else { + editor.highlight_gutter::( + &gutter_pending_ranges, + |cx| cx.theme().status().info_background, + cx, + ) + } + + enum GutterTransformedRange {} + if gutter_transformed_ranges.is_empty() { + editor.clear_gutter_highlights::(cx); + } else { + editor.highlight_gutter::( + &gutter_transformed_ranges, + |cx| cx.theme().status().info, + cx, + ) + } + + if foreground_ranges.is_empty() { + editor.clear_highlights::(cx); + } else { + editor.highlight_text::( + foreground_ranges, + HighlightStyle { + fade_out: Some(0.6), + ..Default::default() + }, + cx, + ); + } + + editor.clear_row_highlights::(); + for row_range in inserted_row_ranges { + editor.highlight_rows::( + row_range, + Some(cx.theme().status().info_background), + false, + cx, + ); + } + }); + } + + fn update_editor_blocks( + &mut self, + editor: &View, + assist_id: InlineAssistId, + cx: &mut WindowContext, + ) { + let Some(assist) = self.assists.get_mut(&assist_id) else { + return; + }; + let Some(decorations) = assist.decorations.as_mut() else { + return; + }; + + let codegen = assist.codegen.read(cx); + let old_snapshot = codegen.snapshot.clone(); + let old_buffer = codegen.old_buffer.clone(); + let deleted_row_ranges = codegen.diff.deleted_row_ranges.clone(); + + editor.update(cx, |editor, cx| { + let old_blocks = mem::take(&mut decorations.removed_line_block_ids); + editor.remove_blocks(old_blocks, None, cx); + + let mut new_blocks = Vec::new(); + for (new_row, old_row_range) in deleted_row_ranges { + let (_, buffer_start) = old_snapshot + .point_to_buffer_offset(Point::new(*old_row_range.start(), 0)) + .unwrap(); + let (_, buffer_end) = old_snapshot + .point_to_buffer_offset(Point::new( + *old_row_range.end(), + old_snapshot.line_len(MultiBufferRow(*old_row_range.end())), + )) + .unwrap(); + + let deleted_lines_editor = cx.new_view(|cx| { + let multi_buffer = cx.new_model(|_| { + MultiBuffer::without_headers(0, language::Capability::ReadOnly) + }); + multi_buffer.update(cx, |multi_buffer, cx| { + multi_buffer.push_excerpts( + old_buffer.clone(), + Some(ExcerptRange { + context: buffer_start..buffer_end, + primary: None, + }), + cx, + ); + }); + + enum DeletedLines {} + let mut editor = Editor::for_multibuffer(multi_buffer, None, true, cx); + editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx); + editor.set_show_wrap_guides(false, cx); + editor.set_show_gutter(false, cx); + editor.scroll_manager.set_forbid_vertical_scroll(true); + editor.set_read_only(true); + editor.highlight_rows::( + Anchor::min()..=Anchor::max(), + Some(cx.theme().status().deleted_background), + false, + cx, + ); + editor + }); + + let height = deleted_lines_editor + .update(cx, |editor, cx| editor.max_point(cx).row().0 as u8 + 1); + new_blocks.push(BlockProperties { + position: new_row, + height, + style: BlockStyle::Flex, + render: Box::new(move |cx| { + div() + .bg(cx.theme().status().deleted_background) + .size_full() + .pl(cx.gutter_dimensions.full_width()) + .child(deleted_lines_editor.clone()) + .into_any_element() + }), + disposition: BlockDisposition::Above, + }); + } + + decorations.removed_line_block_ids = editor + .insert_blocks(new_blocks, None, cx) + .into_iter() + .collect(); + }) + } +} + +struct EditorInlineAssists { + assist_ids: Vec, + scroll_lock: Option, + highlight_updates: async_watch::Sender<()>, + _update_highlights: Task>, + _subscriptions: Vec, +} + +struct InlineAssistScrollLock { + assist_id: InlineAssistId, + distance_from_top: f32, +} + +impl EditorInlineAssists { + #[allow(clippy::too_many_arguments)] + fn new(editor: &View, cx: &mut WindowContext) -> Self { + let (highlight_updates_tx, mut highlight_updates_rx) = async_watch::channel(()); + Self { + assist_ids: Vec::new(), + scroll_lock: None, + highlight_updates: highlight_updates_tx, + _update_highlights: cx.spawn(|mut cx| { + let editor = editor.downgrade(); + async move { + while let Ok(()) = highlight_updates_rx.changed().await { + let editor = editor.upgrade().context("editor was dropped")?; + cx.update_global(|assistant: &mut InlineAssistant, cx| { + assistant.update_editor_highlights(&editor, cx); + })?; + } + Ok(()) + } + }), + _subscriptions: vec![ + cx.observe_release(editor, { + let editor = editor.downgrade(); + |_, cx| { + InlineAssistant::update_global(cx, |this, cx| { + this.handle_editor_release(editor, cx); + }) + } + }), + cx.observe(editor, move |editor, cx| { + InlineAssistant::update_global(cx, |this, cx| { + this.handle_editor_change(editor, cx) + }) + }), + cx.subscribe(editor, move |editor, event, cx| { + InlineAssistant::update_global(cx, |this, cx| { + this.handle_editor_event(editor, event, cx) + }) + }), + editor.update(cx, |editor, cx| { + let editor_handle = cx.view().downgrade(); + editor.register_action( + move |_: &editor::actions::Newline, cx: &mut WindowContext| { + InlineAssistant::update_global(cx, |this, cx| { + if let Some(editor) = editor_handle.upgrade() { + this.handle_editor_newline(editor, cx) + } + }) + }, + ) + }), + editor.update(cx, |editor, cx| { + let editor_handle = cx.view().downgrade(); + editor.register_action( + move |_: &editor::actions::Cancel, cx: &mut WindowContext| { + InlineAssistant::update_global(cx, |this, cx| { + if let Some(editor) = editor_handle.upgrade() { + this.handle_editor_cancel(editor, cx) + } + }) + }, + ) + }), + ], + } + } +} + +struct InlineAssistGroup { + assist_ids: Vec, + linked: bool, + active_assist_id: Option, +} + +impl InlineAssistGroup { + fn new() -> Self { + Self { + assist_ids: Vec::new(), + linked: true, + active_assist_id: None, + } + } +} + +fn build_assist_editor_renderer(editor: &View) -> RenderBlock { + let editor = editor.clone(); + Box::new(move |cx: &mut BlockContext| { + *editor.read(cx).gutter_dimensions.lock() = *cx.gutter_dimensions; + editor.clone().into_any_element() + }) +} + +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)] +pub struct InlineAssistId(usize); + +impl InlineAssistId { + fn post_inc(&mut self) -> InlineAssistId { + let id = *self; + self.0 += 1; + id + } +} + +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)] +struct InlineAssistGroupId(usize); + +impl InlineAssistGroupId { + fn post_inc(&mut self) -> InlineAssistGroupId { + let id = *self; + self.0 += 1; + id + } +} + +enum PromptEditorEvent { + StartRequested, + StopRequested, + ConfirmRequested, + CancelRequested, + DismissRequested, + Resized { height_in_lines: u8 }, +} + +struct PromptEditor { + id: InlineAssistId, + fs: Arc, + height_in_lines: u8, + editor: View, + edited_since_done: bool, + gutter_dimensions: Arc>, + prompt_history: VecDeque, + prompt_history_ix: Option, + pending_prompt: String, + codegen: Model, + _codegen_subscription: Subscription, + editor_subscriptions: Vec, + pending_token_count: Task>, + token_count: Option, + _token_count_subscriptions: Vec, + workspace: Option>, +} + +impl EventEmitter for PromptEditor {} + +impl Render for PromptEditor { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let gutter_dimensions = *self.gutter_dimensions.lock(); + let fs = self.fs.clone(); + + let buttons = match &self.codegen.read(cx).status { + CodegenStatus::Idle => { + vec![ + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .tooltip(|cx| Tooltip::for_action("Cancel Assist", &menu::Cancel, cx)) + .on_click( + cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)), + ), + IconButton::new("start", IconName::Sparkle) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .tooltip(|cx| Tooltip::for_action("Transform", &menu::Confirm, cx)) + .on_click( + cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::StartRequested)), + ), + ] + } + CodegenStatus::Pending => { + vec![ + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .tooltip(|cx| Tooltip::text("Cancel Assist", cx)) + .on_click( + cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)), + ), + IconButton::new("stop", IconName::Stop) + .icon_color(Color::Error) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .tooltip(|cx| { + Tooltip::with_meta( + "Interrupt Transformation", + Some(&menu::Cancel), + "Changes won't be discarded", + cx, + ) + }) + .on_click( + cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::StopRequested)), + ), + ] + } + CodegenStatus::Error(_) | CodegenStatus::Done => { + vec![ + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .tooltip(|cx| Tooltip::for_action("Cancel Assist", &menu::Cancel, cx)) + .on_click( + cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)), + ), + if self.edited_since_done { + IconButton::new("restart", IconName::RotateCw) + .icon_color(Color::Info) + .icon_size(IconSize::XSmall) + .size(ButtonSize::None) + .tooltip(|cx| { + Tooltip::with_meta( + "Restart Transformation", + Some(&menu::Confirm), + "Changes will be discarded", + cx, + ) + }) + .on_click(cx.listener(|_, _, cx| { + cx.emit(PromptEditorEvent::StartRequested); + })) + } else { + IconButton::new("confirm", IconName::Check) + .icon_color(Color::Info) + .size(ButtonSize::None) + .tooltip(|cx| Tooltip::for_action("Confirm Assist", &menu::Confirm, cx)) + .on_click(cx.listener(|_, _, cx| { + cx.emit(PromptEditorEvent::ConfirmRequested); + })) + }, + ] + } + }; + + h_flex() + .bg(cx.theme().colors().editor_background) + .border_y_1() + .border_color(cx.theme().status().info_border) + .py_1p5() + .h_full() + .w_full() + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::move_up)) + .on_action(cx.listener(Self::move_down)) + .child( + h_flex() + .w(gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0)) + .justify_center() + .gap_2() + .child( + PopoverMenu::new("model-switcher") + .menu(move |cx| { + ContextMenu::build(cx, |mut menu, cx| { + for model in CompletionProvider::global(cx).available_models(cx) + { + menu = menu.custom_entry( + { + let model = model.clone(); + move |_| { + Label::new(model.display_name()) + .into_any_element() + } + }, + { + let fs = fs.clone(); + let model = model.clone(); + move |cx| { + let model = model.clone(); + update_settings_file::( + fs.clone(), + cx, + move |settings| settings.set_model(model), + ); + } + }, + ); + } + menu + }) + .into() + }) + .trigger( + IconButton::new("context", IconName::Settings) + .size(ButtonSize::None) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(move |cx| { + Tooltip::with_meta( + format!( + "Using {}", + CompletionProvider::global(cx) + .model() + .display_name() + ), + None, + "Click to Change Model", + cx, + ) + }), + ) + .anchor(gpui::AnchorCorner::BottomRight), + ) + .children( + if let CodegenStatus::Error(error) = &self.codegen.read(cx).status { + let error_message = SharedString::from(error.to_string()); + Some( + div() + .id("error") + .tooltip(move |cx| Tooltip::text(error_message.clone(), cx)) + .child( + Icon::new(IconName::XCircle) + .size(IconSize::Small) + .color(Color::Error), + ), + ) + } else { + None + }, + ), + ) + .child(div().flex_1().child(self.render_prompt_editor(cx))) + .child( + h_flex() + .gap_2() + .pr_4() + .children(self.render_token_count(cx)) + .children(buttons), + ) + } +} + +impl FocusableView for PromptEditor { + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl PromptEditor { + const MAX_LINES: u8 = 8; + + #[allow(clippy::too_many_arguments)] + fn new( + id: InlineAssistId, + gutter_dimensions: Arc>, + prompt_history: VecDeque, + prompt_buffer: Model, + codegen: Model, + parent_editor: &View, + assistant_panel: Option<&View>, + workspace: Option>, + fs: Arc, + cx: &mut ViewContext, + ) -> Self { + let prompt_editor = cx.new_view(|cx| { + let mut editor = Editor::new( + EditorMode::AutoHeight { + max_lines: Self::MAX_LINES as usize, + }, + prompt_buffer, + None, + false, + cx, + ); + editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); + // Since the prompt editors for all inline assistants are linked, + // always show the cursor (even when it isn't focused) because + // typing in one will make what you typed appear in all of them. + editor.set_show_cursor_when_unfocused(true, cx); + editor.set_placeholder_text("Add a prompt…", cx); + editor + }); + + let mut token_count_subscriptions = Vec::new(); + token_count_subscriptions + .push(cx.subscribe(parent_editor, Self::handle_parent_editor_event)); + if let Some(assistant_panel) = assistant_panel { + token_count_subscriptions + .push(cx.subscribe(assistant_panel, Self::handle_assistant_panel_event)); + } + + let mut this = Self { + id, + height_in_lines: 1, + editor: prompt_editor, + edited_since_done: false, + gutter_dimensions, + prompt_history, + prompt_history_ix: None, + pending_prompt: String::new(), + _codegen_subscription: cx.observe(&codegen, Self::handle_codegen_changed), + editor_subscriptions: Vec::new(), + codegen, + fs, + pending_token_count: Task::ready(Ok(())), + token_count: None, + _token_count_subscriptions: token_count_subscriptions, + workspace, + }; + this.count_lines(cx); + this.count_tokens(cx); + this.subscribe_to_editor(cx); + this + } + + fn subscribe_to_editor(&mut self, cx: &mut ViewContext) { + self.editor_subscriptions.clear(); + self.editor_subscriptions + .push(cx.observe(&self.editor, Self::handle_prompt_editor_changed)); + self.editor_subscriptions + .push(cx.subscribe(&self.editor, Self::handle_prompt_editor_events)); + } + + fn set_show_cursor_when_unfocused( + &mut self, + show_cursor_when_unfocused: bool, + cx: &mut ViewContext, + ) { + self.editor.update(cx, |editor, cx| { + editor.set_show_cursor_when_unfocused(show_cursor_when_unfocused, cx) + }); + } + + fn unlink(&mut self, cx: &mut ViewContext) { + let prompt = self.prompt(cx); + let focus = self.editor.focus_handle(cx).contains_focused(cx); + self.editor = cx.new_view(|cx| { + let mut editor = Editor::auto_height(Self::MAX_LINES as usize, cx); + editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); + editor.set_placeholder_text("Add a prompt…", cx); + editor.set_text(prompt, cx); + if focus { + editor.focus(cx); + } + editor + }); + self.subscribe_to_editor(cx); + } + + fn prompt(&self, cx: &AppContext) -> String { + self.editor.read(cx).text(cx) + } + + fn count_lines(&mut self, cx: &mut ViewContext) { + let height_in_lines = cmp::max( + 2, // Make the editor at least two lines tall, to account for padding and buttons. + cmp::min( + self.editor + .update(cx, |editor, cx| editor.max_point(cx).row().0 + 1), + Self::MAX_LINES as u32, + ), + ) as u8; + + if height_in_lines != self.height_in_lines { + self.height_in_lines = height_in_lines; + cx.emit(PromptEditorEvent::Resized { height_in_lines }); + } + } + + fn handle_parent_editor_event( + &mut self, + _: View, + event: &EditorEvent, + cx: &mut ViewContext, + ) { + if let EditorEvent::BufferEdited { .. } = event { + self.count_tokens(cx); + } + } + + fn handle_assistant_panel_event( + &mut self, + _: View, + event: &AssistantPanelEvent, + cx: &mut ViewContext, + ) { + let AssistantPanelEvent::ContextEdited { .. } = event; + self.count_tokens(cx); + } + + fn count_tokens(&mut self, cx: &mut ViewContext) { + let assist_id = self.id; + self.pending_token_count = cx.spawn(|this, mut cx| async move { + cx.background_executor().timer(Duration::from_secs(1)).await; + let request = cx + .update_global(|inline_assistant: &mut InlineAssistant, cx| { + inline_assistant.request_for_inline_assist(assist_id, cx) + })? + .await?; + + let token_count = cx + .update(|cx| CompletionProvider::global(cx).count_tokens(request, cx))? + .await?; + this.update(&mut cx, |this, cx| { + this.token_count = Some(token_count); + cx.notify(); + }) + }) + } + + fn handle_prompt_editor_changed(&mut self, _: View, cx: &mut ViewContext) { + self.count_lines(cx); + } + + fn handle_prompt_editor_events( + &mut self, + _: View, + event: &EditorEvent, + cx: &mut ViewContext, + ) { + match event { + EditorEvent::Edited { .. } => { + let prompt = self.editor.read(cx).text(cx); + if self + .prompt_history_ix + .map_or(true, |ix| self.prompt_history[ix] != prompt) + { + self.prompt_history_ix.take(); + self.pending_prompt = prompt; + } + + self.edited_since_done = true; + cx.notify(); + } + EditorEvent::BufferEdited => { + self.count_tokens(cx); + } + _ => {} + } + } + + fn handle_codegen_changed(&mut self, _: Model, cx: &mut ViewContext) { + match &self.codegen.read(cx).status { + CodegenStatus::Idle => { + self.editor + .update(cx, |editor, _| editor.set_read_only(false)); + } + CodegenStatus::Pending => { + self.editor + .update(cx, |editor, _| editor.set_read_only(true)); + } + CodegenStatus::Done | CodegenStatus::Error(_) => { + self.edited_since_done = false; + self.editor + .update(cx, |editor, _| editor.set_read_only(false)); + } + } + } + + fn cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext) { + match &self.codegen.read(cx).status { + CodegenStatus::Idle | CodegenStatus::Done | CodegenStatus::Error(_) => { + cx.emit(PromptEditorEvent::CancelRequested); + } + CodegenStatus::Pending => { + cx.emit(PromptEditorEvent::StopRequested); + } + } + } + + fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + match &self.codegen.read(cx).status { + CodegenStatus::Idle => { + cx.emit(PromptEditorEvent::StartRequested); + } + CodegenStatus::Pending => { + cx.emit(PromptEditorEvent::DismissRequested); + } + CodegenStatus::Done | CodegenStatus::Error(_) => { + if self.edited_since_done { + cx.emit(PromptEditorEvent::StartRequested); + } else { + cx.emit(PromptEditorEvent::ConfirmRequested); + } + } + } + } + + fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext) { + if let Some(ix) = self.prompt_history_ix { + if ix > 0 { + self.prompt_history_ix = Some(ix - 1); + let prompt = self.prompt_history[ix - 1].as_str(); + self.editor.update(cx, |editor, cx| { + editor.set_text(prompt, cx); + editor.move_to_beginning(&Default::default(), cx); + }); + } + } else if !self.prompt_history.is_empty() { + self.prompt_history_ix = Some(self.prompt_history.len() - 1); + let prompt = self.prompt_history[self.prompt_history.len() - 1].as_str(); + self.editor.update(cx, |editor, cx| { + editor.set_text(prompt, cx); + editor.move_to_beginning(&Default::default(), cx); + }); + } + } + + fn move_down(&mut self, _: &MoveDown, cx: &mut ViewContext) { + if let Some(ix) = self.prompt_history_ix { + if ix < self.prompt_history.len() - 1 { + self.prompt_history_ix = Some(ix + 1); + let prompt = self.prompt_history[ix + 1].as_str(); + self.editor.update(cx, |editor, cx| { + editor.set_text(prompt, cx); + editor.move_to_end(&Default::default(), cx) + }); + } else { + self.prompt_history_ix = None; + let prompt = self.pending_prompt.as_str(); + self.editor.update(cx, |editor, cx| { + editor.set_text(prompt, cx); + editor.move_to_end(&Default::default(), cx) + }); + } + } + } + + fn render_token_count(&self, cx: &mut ViewContext) -> Option { + let model = CompletionProvider::global(cx).model(); + let token_count = self.token_count?; + let max_token_count = model.max_token_count(); + + let remaining_tokens = max_token_count as isize - token_count as isize; + let token_count_color = if remaining_tokens <= 0 { + Color::Error + } else if token_count as f32 / max_token_count as f32 >= 0.8 { + Color::Warning + } else { + Color::Muted + }; + + let mut token_count = h_flex() + .id("token_count") + .gap_0p5() + .child( + Label::new(humanize_token_count(token_count)) + .size(LabelSize::Small) + .color(token_count_color), + ) + .child(Label::new("/").size(LabelSize::Small).color(Color::Muted)) + .child( + Label::new(humanize_token_count(max_token_count)) + .size(LabelSize::Small) + .color(Color::Muted), + ); + if let Some(workspace) = self.workspace.clone() { + token_count = token_count + .tooltip(|cx| { + Tooltip::with_meta( + "Tokens Used by Inline Assistant", + None, + "Click to Open Assistant Panel", + cx, + ) + }) + .cursor_pointer() + .on_mouse_down(gpui::MouseButton::Left, |_, cx| cx.stop_propagation()) + .on_click(move |_, cx| { + cx.stop_propagation(); + workspace + .update(cx, |workspace, cx| { + workspace.focus_panel::(cx) + }) + .ok(); + }); + } else { + token_count = token_count + .cursor_default() + .tooltip(|cx| Tooltip::text("Tokens Used by Inline Assistant", cx)); + } + + Some(token_count) + } + + fn render_prompt_editor(&self, cx: &mut ViewContext) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: if self.editor.read(cx).read_only(cx) { + cx.theme().colors().text_disabled + } else { + cx.theme().colors().text + }, + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_size: rems(0.875).into(), + font_weight: settings.ui_font.weight, + font_style: FontStyle::Normal, + line_height: relative(1.3), + background_color: None, + underline: None, + strikethrough: None, + white_space: WhiteSpace::Normal, + }; + EditorElement::new( + &self.editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } +} + +struct InlineAssist { + group_id: InlineAssistGroupId, + editor: WeakView, + decorations: Option, + codegen: Model, + _subscriptions: Vec, + workspace: Option>, + include_context: bool, +} + +impl InlineAssist { + #[allow(clippy::too_many_arguments)] + fn new( + assist_id: InlineAssistId, + group_id: InlineAssistGroupId, + include_context: bool, + editor: &View, + prompt_editor: &View, + prompt_block_id: CustomBlockId, + end_block_id: CustomBlockId, + codegen: Model, + workspace: Option>, + cx: &mut WindowContext, + ) -> Self { + let prompt_editor_focus_handle = prompt_editor.focus_handle(cx); + InlineAssist { + group_id, + include_context, + editor: editor.downgrade(), + decorations: Some(InlineAssistDecorations { + prompt_block_id, + prompt_editor: prompt_editor.clone(), + removed_line_block_ids: HashSet::default(), + end_block_id, + }), + codegen: codegen.clone(), + workspace: workspace.clone(), + _subscriptions: vec![ + cx.on_focus_in(&prompt_editor_focus_handle, move |cx| { + InlineAssistant::update_global(cx, |this, cx| { + this.handle_prompt_editor_focus_in(assist_id, cx) + }) + }), + cx.on_focus_out(&prompt_editor_focus_handle, move |_, cx| { + InlineAssistant::update_global(cx, |this, cx| { + this.handle_prompt_editor_focus_out(assist_id, cx) + }) + }), + cx.subscribe(prompt_editor, |prompt_editor, event, cx| { + InlineAssistant::update_global(cx, |this, cx| { + this.handle_prompt_editor_event(prompt_editor, event, cx) + }) + }), + cx.observe(&codegen, { + let editor = editor.downgrade(); + move |_, cx| { + if let Some(editor) = editor.upgrade() { + InlineAssistant::update_global(cx, |this, cx| { + if let Some(editor_assists) = + this.assists_by_editor.get(&editor.downgrade()) + { + editor_assists.highlight_updates.send(()).ok(); + } + + this.update_editor_blocks(&editor, assist_id, cx); + }) + } + } + }), + cx.subscribe(&codegen, move |codegen, event, cx| { + InlineAssistant::update_global(cx, |this, cx| match event { + CodegenEvent::Undone => this.finish_assist(assist_id, false, cx), + CodegenEvent::Finished => { + let assist = if let Some(assist) = this.assists.get(&assist_id) { + assist + } else { + return; + }; + + if let CodegenStatus::Error(error) = &codegen.read(cx).status { + if assist.decorations.is_none() { + if let Some(workspace) = assist + .workspace + .as_ref() + .and_then(|workspace| workspace.upgrade()) + { + let error = format!("Inline assistant error: {}", error); + workspace.update(cx, |workspace, cx| { + struct InlineAssistantError; + + let id = + NotificationId::identified::( + assist_id.0, + ); + + workspace.show_toast(Toast::new(id, error), cx); + }) + } + } + } + + if assist.decorations.is_none() { + this.finish_assist(assist_id, false, cx); + } + } + }) + }), + ], + } + } +} + +struct InlineAssistDecorations { + prompt_block_id: CustomBlockId, + prompt_editor: View, + removed_line_block_ids: HashSet, + end_block_id: CustomBlockId, +} + +#[derive(Debug)] +pub enum CodegenEvent { + Finished, + Undone, +} + +pub struct Codegen { + buffer: Model, + old_buffer: Model, + snapshot: MultiBufferSnapshot, + range: Range, + edit_position: Anchor, + last_equal_ranges: Vec>, + prepend_transaction_id: Option, + generation_transaction_id: Option, + status: CodegenStatus, + generation: Task<()>, + diff: Diff, + telemetry: Option>, + _subscription: gpui::Subscription, +} + +enum CodegenStatus { + Idle, + Pending, + Done, + Error(anyhow::Error), +} + +#[derive(Default)] +struct Diff { + task: Option>, + should_update: bool, + deleted_row_ranges: Vec<(Anchor, RangeInclusive)>, + inserted_row_ranges: Vec>, +} + +impl EventEmitter for Codegen {} + +impl Codegen { + pub fn new( + buffer: Model, + range: Range, + prepend_transaction_id: Option, + telemetry: Option>, + cx: &mut ModelContext, + ) -> Self { + let snapshot = buffer.read(cx).snapshot(cx); + + let (old_buffer, _, _) = buffer + .read(cx) + .range_to_buffer_ranges(range.clone(), cx) + .pop() + .unwrap(); + let old_buffer = cx.new_model(|cx| { + let old_buffer = old_buffer.read(cx); + let text = old_buffer.as_rope().clone(); + let line_ending = old_buffer.line_ending(); + let language = old_buffer.language().cloned(); + let language_registry = old_buffer.language_registry(); + + let mut buffer = Buffer::local_normalized(text, line_ending, cx); + buffer.set_language(language, cx); + if let Some(language_registry) = language_registry { + buffer.set_language_registry(language_registry) + } + buffer + }); + + Self { + buffer: buffer.clone(), + old_buffer, + edit_position: range.start, + range, + snapshot, + last_equal_ranges: Default::default(), + prepend_transaction_id, + generation_transaction_id: None, + status: CodegenStatus::Idle, + generation: Task::ready(()), + diff: Diff::default(), + telemetry, + _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), + } + } + + fn handle_buffer_event( + &mut self, + _buffer: Model, + event: &multi_buffer::Event, + cx: &mut ModelContext, + ) { + if let multi_buffer::Event::TransactionUndone { transaction_id } = event { + if self.generation_transaction_id == Some(*transaction_id) { + self.generation_transaction_id = None; + self.generation = Task::ready(()); + cx.emit(CodegenEvent::Undone); + } else if self.prepend_transaction_id == Some(*transaction_id) { + self.prepend_transaction_id = None; + self.generation_transaction_id = None; + self.generation = Task::ready(()); + cx.emit(CodegenEvent::Undone); + } + } + } + + pub fn last_equal_ranges(&self) -> &[Range] { + &self.last_equal_ranges + } + + pub fn start( + &mut self, + telemetry_id: String, + stream: impl 'static + Future>>>, + cx: &mut ModelContext, + ) { + let range = self.range.clone(); + let snapshot = self.snapshot.clone(); + let selected_text = snapshot + .text_for_range(range.start..range.end) + .collect::(); + + let selection_start = range.start.to_point(&snapshot); + let suggested_line_indent = snapshot + .suggested_indents(selection_start.row..selection_start.row + 1, cx) + .into_values() + .next() + .unwrap_or_else(|| snapshot.indent_size_for_line(MultiBufferRow(selection_start.row))); + + let telemetry = self.telemetry.clone(); + self.edit_position = range.start; + self.diff = Diff::default(); + self.status = CodegenStatus::Pending; + if let Some(transaction_id) = self.generation_transaction_id.take() { + self.buffer + .update(cx, |buffer, cx| buffer.undo_transaction(transaction_id, cx)); + } + self.generation = cx.spawn(|this, mut cx| { + async move { + let chunks = stream.await; + let generate = async { + let mut edit_start = range.start.to_offset(&snapshot); + + let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); + let diff: Task> = + cx.background_executor().spawn(async move { + let mut response_latency = None; + let request_start = Instant::now(); + let diff = async { + let chunks = StripInvalidSpans::new(chunks?); + futures::pin_mut!(chunks); + let mut diff = StreamingDiff::new(selected_text.to_string()); + + let mut new_text = String::new(); + let mut base_indent = None; + let mut line_indent = None; + let mut first_line = true; + + while let Some(chunk) = chunks.next().await { + if response_latency.is_none() { + response_latency = Some(request_start.elapsed()); + } + let chunk = chunk?; + + let mut lines = chunk.split('\n').peekable(); + while let Some(line) = lines.next() { + new_text.push_str(line); + if line_indent.is_none() { + if let Some(non_whitespace_ch_ix) = + new_text.find(|ch: char| !ch.is_whitespace()) + { + line_indent = Some(non_whitespace_ch_ix); + base_indent = base_indent.or(line_indent); + + let line_indent = line_indent.unwrap(); + let base_indent = base_indent.unwrap(); + let indent_delta = + line_indent as i32 - base_indent as i32; + let mut corrected_indent_len = cmp::max( + 0, + suggested_line_indent.len as i32 + indent_delta, + ) + as usize; + if first_line { + corrected_indent_len = corrected_indent_len + .saturating_sub( + selection_start.column as usize, + ); + } + + let indent_char = suggested_line_indent.char(); + let mut indent_buffer = [0; 4]; + let indent_str = + indent_char.encode_utf8(&mut indent_buffer); + new_text.replace_range( + ..line_indent, + &indent_str.repeat(corrected_indent_len), + ); + } + } + + if line_indent.is_some() { + hunks_tx.send(diff.push_new(&new_text)).await?; + new_text.clear(); + } + + if lines.peek().is_some() { + hunks_tx.send(diff.push_new("\n")).await?; + if line_indent.is_none() { + // Don't write out the leading indentation in empty lines on the next line + // This is the case where the above if statement didn't clear the buffer + new_text.clear(); + } + line_indent = None; + first_line = false; + } + } + } + hunks_tx.send(diff.push_new(&new_text)).await?; + hunks_tx.send(diff.finish()).await?; + + anyhow::Ok(()) + }; + + let result = diff.await; + + let error_message = + result.as_ref().err().map(|error| error.to_string()); + if let Some(telemetry) = telemetry { + telemetry.report_assistant_event( + None, + telemetry_events::AssistantKind::Inline, + telemetry_id, + response_latency, + error_message, + ); + } + + result?; + Ok(()) + }); + + while let Some(hunks) = hunks_rx.next().await { + this.update(&mut cx, |this, cx| { + this.last_equal_ranges.clear(); + + let transaction = this.buffer.update(cx, |buffer, cx| { + // Avoid grouping assistant edits with user edits. + buffer.finalize_last_transaction(cx); + + buffer.start_transaction(cx); + buffer.edit( + hunks.into_iter().filter_map(|hunk| match hunk { + Hunk::Insert { text } => { + let edit_start = snapshot.anchor_after(edit_start); + Some((edit_start..edit_start, text)) + } + Hunk::Remove { len } => { + let edit_end = edit_start + len; + let edit_range = snapshot.anchor_after(edit_start) + ..snapshot.anchor_before(edit_end); + edit_start = edit_end; + Some((edit_range, String::new())) + } + Hunk::Keep { len } => { + let edit_end = edit_start + len; + let edit_range = snapshot.anchor_after(edit_start) + ..snapshot.anchor_before(edit_end); + edit_start = edit_end; + this.last_equal_ranges.push(edit_range); + None + } + }), + None, + cx, + ); + this.edit_position = snapshot.anchor_after(edit_start); + + buffer.end_transaction(cx) + }); + + if let Some(transaction) = transaction { + if let Some(first_transaction) = this.generation_transaction_id { + // Group all assistant edits into the first transaction. + this.buffer.update(cx, |buffer, cx| { + buffer.merge_transactions( + transaction, + first_transaction, + cx, + ) + }); + } else { + this.generation_transaction_id = Some(transaction); + this.buffer.update(cx, |buffer, cx| { + buffer.finalize_last_transaction(cx) + }); + } + } + + this.update_diff(cx); + cx.notify(); + })?; + } + + diff.await?; + + anyhow::Ok(()) + }; + + let result = generate.await; + this.update(&mut cx, |this, cx| { + this.last_equal_ranges.clear(); + if let Err(error) = result { + this.status = CodegenStatus::Error(error); + } else { + this.status = CodegenStatus::Done; + } + cx.emit(CodegenEvent::Finished); + cx.notify(); + }) + .ok(); + } + }); + cx.notify(); + } + + pub fn stop(&mut self, cx: &mut ModelContext) { + self.last_equal_ranges.clear(); + self.status = CodegenStatus::Done; + self.generation = Task::ready(()); + cx.emit(CodegenEvent::Finished); + cx.notify(); + } + + pub fn undo(&mut self, cx: &mut ModelContext) { + if let Some(transaction_id) = self.prepend_transaction_id.take() { + self.buffer + .update(cx, |buffer, cx| buffer.undo_transaction(transaction_id, cx)); + } + + if let Some(transaction_id) = self.generation_transaction_id.take() { + self.buffer + .update(cx, |buffer, cx| buffer.undo_transaction(transaction_id, cx)); + } + } + + fn update_diff(&mut self, cx: &mut ModelContext) { + if self.diff.task.is_some() { + self.diff.should_update = true; + } else { + self.diff.should_update = false; + + let old_snapshot = self.snapshot.clone(); + let old_range = self.range.to_point(&old_snapshot); + let new_snapshot = self.buffer.read(cx).snapshot(cx); + let new_range = self.range.to_point(&new_snapshot); + + self.diff.task = Some(cx.spawn(|this, mut cx| async move { + let (deleted_row_ranges, inserted_row_ranges) = cx + .background_executor() + .spawn(async move { + let old_text = old_snapshot + .text_for_range( + Point::new(old_range.start.row, 0) + ..Point::new( + old_range.end.row, + old_snapshot.line_len(MultiBufferRow(old_range.end.row)), + ), + ) + .collect::(); + let new_text = new_snapshot + .text_for_range( + Point::new(new_range.start.row, 0) + ..Point::new( + new_range.end.row, + new_snapshot.line_len(MultiBufferRow(new_range.end.row)), + ), + ) + .collect::(); + + let mut old_row = old_range.start.row; + let mut new_row = new_range.start.row; + let diff = TextDiff::from_lines(old_text.as_str(), new_text.as_str()); + + let mut deleted_row_ranges: Vec<(Anchor, RangeInclusive)> = Vec::new(); + let mut inserted_row_ranges = Vec::new(); + for change in diff.iter_all_changes() { + let line_count = change.value().lines().count() as u32; + match change.tag() { + similar::ChangeTag::Equal => { + old_row += line_count; + new_row += line_count; + } + similar::ChangeTag::Delete => { + let old_end_row = old_row + line_count - 1; + let new_row = + new_snapshot.anchor_before(Point::new(new_row, 0)); + + if let Some((_, last_deleted_row_range)) = + deleted_row_ranges.last_mut() + { + if *last_deleted_row_range.end() + 1 == old_row { + *last_deleted_row_range = + *last_deleted_row_range.start()..=old_end_row; + } else { + deleted_row_ranges + .push((new_row, old_row..=old_end_row)); + } + } else { + deleted_row_ranges.push((new_row, old_row..=old_end_row)); + } + + old_row += line_count; + } + similar::ChangeTag::Insert => { + let new_end_row = new_row + line_count - 1; + let start = new_snapshot.anchor_before(Point::new(new_row, 0)); + let end = new_snapshot.anchor_before(Point::new( + new_end_row, + new_snapshot.line_len(MultiBufferRow(new_end_row)), + )); + inserted_row_ranges.push(start..=end); + new_row += line_count; + } + } + } + + (deleted_row_ranges, inserted_row_ranges) + }) + .await; + + this.update(&mut cx, |this, cx| { + this.diff.deleted_row_ranges = deleted_row_ranges; + this.diff.inserted_row_ranges = inserted_row_ranges; + this.diff.task = None; + if this.diff.should_update { + this.update_diff(cx); + } + cx.notify(); + }) + .ok(); + })); + } + } +} + +struct StripInvalidSpans { + stream: T, + stream_done: bool, + buffer: String, + first_line: bool, + line_end: bool, + starts_with_code_block: bool, +} + +impl StripInvalidSpans +where + T: Stream>, +{ + fn new(stream: T) -> Self { + Self { + stream, + stream_done: false, + buffer: String::new(), + first_line: true, + line_end: false, + starts_with_code_block: false, + } + } +} + +impl Stream for StripInvalidSpans +where + T: Stream>, +{ + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut task::Context) -> Poll> { + const CODE_BLOCK_DELIMITER: &str = "```"; + const CURSOR_SPAN: &str = "<|CURSOR|>"; + + let this = unsafe { self.get_unchecked_mut() }; + loop { + if !this.stream_done { + let mut stream = unsafe { Pin::new_unchecked(&mut this.stream) }; + match stream.as_mut().poll_next(cx) { + Poll::Ready(Some(Ok(chunk))) => { + this.buffer.push_str(&chunk); + } + Poll::Ready(Some(Err(error))) => return Poll::Ready(Some(Err(error))), + Poll::Ready(None) => { + this.stream_done = true; + } + Poll::Pending => return Poll::Pending, + } + } + + let mut chunk = String::new(); + let mut consumed = 0; + if !this.buffer.is_empty() { + let mut lines = this.buffer.split('\n').enumerate().peekable(); + while let Some((line_ix, line)) = lines.next() { + if line_ix > 0 { + this.first_line = false; + } + + if this.first_line { + let trimmed_line = line.trim(); + if lines.peek().is_some() { + if trimmed_line.starts_with(CODE_BLOCK_DELIMITER) { + consumed += line.len() + 1; + this.starts_with_code_block = true; + continue; + } + } else if trimmed_line.is_empty() + || prefixes(CODE_BLOCK_DELIMITER) + .any(|prefix| trimmed_line.starts_with(prefix)) + { + break; + } + } + + let line_without_cursor = line.replace(CURSOR_SPAN, ""); + if lines.peek().is_some() { + if this.line_end { + chunk.push('\n'); + } + + chunk.push_str(&line_without_cursor); + this.line_end = true; + consumed += line.len() + 1; + } else if this.stream_done { + if !this.starts_with_code_block + || !line_without_cursor.trim().ends_with(CODE_BLOCK_DELIMITER) + { + if this.line_end { + chunk.push('\n'); + } + + chunk.push_str(&line); + } + + consumed += line.len(); + } else { + let trimmed_line = line.trim(); + if trimmed_line.is_empty() + || prefixes(CURSOR_SPAN).any(|prefix| trimmed_line.ends_with(prefix)) + || prefixes(CODE_BLOCK_DELIMITER) + .any(|prefix| trimmed_line.ends_with(prefix)) + { + break; + } else { + if this.line_end { + chunk.push('\n'); + this.line_end = false; + } + + chunk.push_str(&line_without_cursor); + consumed += line.len(); + } + } + } + } + + this.buffer = this.buffer.split_off(consumed); + if !chunk.is_empty() { + return Poll::Ready(Some(Ok(chunk))); + } else if this.stream_done { + return Poll::Ready(None); + } + } + } +} + +fn prefixes(text: &str) -> impl Iterator { + (0..text.len() - 1).map(|ix| &text[..ix + 1]) +} + +fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { + ranges.sort_unstable_by(|a, b| { + a.start + .cmp(&b.start, buffer) + .then_with(|| b.end.cmp(&a.end, buffer)) + }); + + let mut ix = 0; + while ix + 1 < ranges.len() { + let b = ranges[ix + 1].clone(); + let a = &mut ranges[ix]; + if a.end.cmp(&b.start, buffer).is_gt() { + if a.end.cmp(&b.end, buffer).is_lt() { + a.end = b.end; + } + ranges.remove(ix + 1); + } else { + ix += 1; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::FakeCompletionProvider; + use futures::stream::{self}; + use gpui::{Context, TestAppContext}; + use indoc::indoc; + use language::{ + language_settings, tree_sitter_rust, Buffer, Language, LanguageConfig, LanguageMatcher, + Point, + }; + use rand::prelude::*; + use serde::Serialize; + use settings::SettingsStore; + use std::{future, sync::Arc}; + + #[derive(Serialize)] + pub struct DummyCompletionRequest { + pub name: String, + } + + #[gpui::test(iterations = 10)] + async fn test_transform_autoindent(cx: &mut TestAppContext, mut rng: StdRng) { + cx.set_global(cx.update(SettingsStore::test)); + cx.update(|cx| FakeCompletionProvider::setup_test(cx)); + cx.update(language_settings::init); + + let text = indoc! {" + fn main() { + let x = 0; + for _ in 0..10 { + x += 1; + } + } + "}; + let buffer = + cx.new_model(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx)); + let range = buffer.read_with(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(4, 5)) + }); + let codegen = cx.new_model(|cx| Codegen::new(buffer.clone(), range, None, None, cx)); + + let (chunks_tx, chunks_rx) = mpsc::unbounded(); + codegen.update(cx, |codegen, cx| { + codegen.start( + String::new(), + future::ready(Ok(chunks_rx.map(|chunk| Ok(chunk)).boxed())), + cx, + ) + }); + + let mut new_text = concat!( + " let mut x = 0;\n", + " while x < 10 {\n", + " x += 1;\n", + " }", + ); + while !new_text.is_empty() { + let max_len = cmp::min(new_text.len(), 10); + let len = rng.gen_range(1..=max_len); + let (chunk, suffix) = new_text.split_at(len); + chunks_tx.unbounded_send(chunk.to_string()).unwrap(); + new_text = suffix; + cx.background_executor.run_until_parked(); + } + drop(chunks_tx); + cx.background_executor.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), + indoc! {" + fn main() { + let mut x = 0; + while x < 10 { + x += 1; + } + } + "} + ); + } + + #[gpui::test(iterations = 10)] + async fn test_autoindent_when_generating_past_indentation( + cx: &mut TestAppContext, + mut rng: StdRng, + ) { + cx.set_global(cx.update(SettingsStore::test)); + cx.update(language_settings::init); + + let text = indoc! {" + fn main() { + le + } + "}; + let buffer = + cx.new_model(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx)); + let range = buffer.read_with(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + snapshot.anchor_before(Point::new(1, 6))..snapshot.anchor_after(Point::new(1, 6)) + }); + let codegen = cx.new_model(|cx| Codegen::new(buffer.clone(), range, None, None, cx)); + + let (chunks_tx, chunks_rx) = mpsc::unbounded(); + codegen.update(cx, |codegen, cx| { + codegen.start( + String::new(), + future::ready(Ok(chunks_rx.map(|chunk| Ok(chunk)).boxed())), + cx, + ) + }); + + cx.background_executor.run_until_parked(); + + let mut new_text = concat!( + "t mut x = 0;\n", + "while x < 10 {\n", + " x += 1;\n", + "}", // + ); + while !new_text.is_empty() { + let max_len = cmp::min(new_text.len(), 10); + let len = rng.gen_range(1..=max_len); + let (chunk, suffix) = new_text.split_at(len); + chunks_tx.unbounded_send(chunk.to_string()).unwrap(); + new_text = suffix; + cx.background_executor.run_until_parked(); + } + drop(chunks_tx); + cx.background_executor.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), + indoc! {" + fn main() { + let mut x = 0; + while x < 10 { + x += 1; + } + } + "} + ); + } + + #[gpui::test(iterations = 10)] + async fn test_autoindent_when_generating_before_indentation( + cx: &mut TestAppContext, + mut rng: StdRng, + ) { + cx.update(|cx| FakeCompletionProvider::setup_test(cx)); + cx.set_global(cx.update(SettingsStore::test)); + cx.update(language_settings::init); + + let text = concat!( + "fn main() {\n", + " \n", + "}\n" // + ); + let buffer = + cx.new_model(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx)); + let range = buffer.read_with(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + snapshot.anchor_before(Point::new(1, 2))..snapshot.anchor_after(Point::new(1, 2)) + }); + let codegen = cx.new_model(|cx| Codegen::new(buffer.clone(), range, None, None, cx)); + + let (chunks_tx, chunks_rx) = mpsc::unbounded(); + codegen.update(cx, |codegen, cx| { + codegen.start( + String::new(), + future::ready(Ok(chunks_rx.map(|chunk| Ok(chunk)).boxed())), + cx, + ) + }); + + cx.background_executor.run_until_parked(); + + let mut new_text = concat!( + "let mut x = 0;\n", + "while x < 10 {\n", + " x += 1;\n", + "}", // + ); + while !new_text.is_empty() { + let max_len = cmp::min(new_text.len(), 10); + let len = rng.gen_range(1..=max_len); + let (chunk, suffix) = new_text.split_at(len); + chunks_tx.unbounded_send(chunk.to_string()).unwrap(); + new_text = suffix; + cx.background_executor.run_until_parked(); + } + drop(chunks_tx); + cx.background_executor.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), + indoc! {" + fn main() { + let mut x = 0; + while x < 10 { + x += 1; + } + } + "} + ); + } + + #[gpui::test] + async fn test_strip_invalid_spans_from_codeblock() { + assert_chunks("Lorem ipsum dolor", "Lorem ipsum dolor").await; + assert_chunks("```\nLorem ipsum dolor", "Lorem ipsum dolor").await; + assert_chunks("```\nLorem ipsum dolor\n```", "Lorem ipsum dolor").await; + assert_chunks( + "```html\n```js\nLorem ipsum dolor\n```\n```", + "```js\nLorem ipsum dolor\n```", + ) + .await; + assert_chunks("``\nLorem ipsum dolor\n```", "``\nLorem ipsum dolor\n```").await; + assert_chunks("Lorem<|CURSOR|> ipsum", "Lorem ipsum").await; + assert_chunks("Lorem ipsum", "Lorem ipsum").await; + assert_chunks("```\n<|CURSOR|>Lorem ipsum\n```", "Lorem ipsum").await; + + async fn assert_chunks(text: &str, expected_text: &str) { + for chunk_size in 1..=text.len() { + let actual_text = StripInvalidSpans::new(chunks(text, chunk_size)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await; + assert_eq!( + actual_text, expected_text, + "failed to strip invalid spans, chunk size: {}", + chunk_size + ); + } + } + + fn chunks(text: &str, size: usize) -> impl Stream> { + stream::iter( + text.chars() + .collect::>() + .chunks(size) + .map(|chunk| Ok(chunk.iter().collect::())) + .collect::>(), + ) + } + } + + 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()), + ) + .with_indents_query( + r#" + (call_expression) @indent + (field_expression) @indent + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap() + } +} diff --git a/crates/assistant/src/model_selector.rs b/crates/assistant/src/model_selector.rs new file mode 100644 index 00000000000000..a27b2b55655aa9 --- /dev/null +++ b/crates/assistant/src/model_selector.rs @@ -0,0 +1,82 @@ +use std::sync::Arc; + +use crate::{assistant_settings::AssistantSettings, CompletionProvider, ToggleModelSelector}; +use fs::Fs; +use settings::update_settings_file; +use ui::{prelude::*, ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip}; + +#[derive(IntoElement)] +pub struct ModelSelector { + handle: PopoverMenuHandle, + fs: Arc, +} + +impl ModelSelector { + pub fn new(handle: PopoverMenuHandle, fs: Arc) -> Self { + ModelSelector { handle, fs } + } +} + +impl RenderOnce for ModelSelector { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + PopoverMenu::new("model-switcher") + .with_handle(self.handle) + .menu(move |cx| { + ContextMenu::build(cx, |mut menu, cx| { + for model in CompletionProvider::global(cx).available_models(cx) { + menu = menu.custom_entry( + { + let model = model.clone(); + move |_| Label::new(model.display_name()).into_any_element() + }, + { + let fs = self.fs.clone(); + let model = model.clone(); + move |cx| { + let model = model.clone(); + update_settings_file::( + fs.clone(), + cx, + move |settings| settings.set_model(model), + ); + } + }, + ); + } + menu + }) + .into() + }) + .trigger( + ButtonLike::new("active-model") + .style(ButtonStyle::Subtle) + .child( + h_flex() + .w_full() + .gap_0p5() + .child( + div() + .overflow_x_hidden() + .flex_grow() + .whitespace_nowrap() + .child( + Label::new( + CompletionProvider::global(cx).model().display_name(), + ) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + Icon::new(IconName::ChevronDown) + .color(Color::Muted) + .size(IconSize::XSmall), + ), + ) + .tooltip(move |cx| { + Tooltip::for_action("Change Model", &ToggleModelSelector, cx) + }), + ) + .attach(gpui::AnchorCorner::BottomLeft) + } +} diff --git a/crates/assistant/src/omit_ranges.rs b/crates/assistant/src/omit_ranges.rs deleted file mode 100644 index f4a6988e956288..00000000000000 --- a/crates/assistant/src/omit_ranges.rs +++ /dev/null @@ -1,101 +0,0 @@ -use rope::Rope; -use std::{cmp::Ordering, ops::Range}; - -pub(crate) fn text_in_range_omitting_ranges( - rope: &Rope, - range: Range, - omit_ranges: &[Range], -) -> String { - let mut content = String::with_capacity(range.len()); - let mut omit_ranges = omit_ranges - .iter() - .skip_while(|omit_range| omit_range.end <= range.start) - .peekable(); - let mut offset = range.start; - let mut chunks = rope.chunks_in_range(range.clone()); - while let Some(chunk) = chunks.next() { - if let Some(omit_range) = omit_ranges.peek() { - match offset.cmp(&omit_range.start) { - Ordering::Less => { - let max_len = omit_range.start - offset; - if chunk.len() < max_len { - content.push_str(chunk); - offset += chunk.len(); - } else { - content.push_str(&chunk[..max_len]); - chunks.seek(omit_range.end.min(range.end)); - offset = omit_range.end; - omit_ranges.next(); - } - } - Ordering::Equal | Ordering::Greater => { - chunks.seek(omit_range.end.min(range.end)); - offset = omit_range.end; - omit_ranges.next(); - } - } - } else { - content.push_str(chunk); - offset += chunk.len(); - } - } - - content -} - -#[cfg(test)] -mod tests { - use super::*; - use rand::{rngs::StdRng, Rng as _}; - use util::RandomCharIter; - - #[gpui::test(iterations = 100)] - fn test_text_in_range_omitting_ranges(mut rng: StdRng) { - let text = RandomCharIter::new(&mut rng).take(1024).collect::(); - let rope = Rope::from(text.as_str()); - - let mut start = rng.gen_range(0..=text.len() / 2); - let mut end = rng.gen_range(text.len() / 2..=text.len()); - while !text.is_char_boundary(start) { - start -= 1; - } - while !text.is_char_boundary(end) { - end += 1; - } - let range = start..end; - - let mut ix = 0; - let mut omit_ranges = Vec::new(); - for _ in 0..rng.gen_range(0..10) { - let mut start = rng.gen_range(ix..=text.len()); - while !text.is_char_boundary(start) { - start += 1; - } - let mut end = rng.gen_range(start..=text.len()); - while !text.is_char_boundary(end) { - end += 1; - } - omit_ranges.push(start..end); - ix = end; - if ix == text.len() { - break; - } - } - - let mut expected_text = text[range.clone()].to_string(); - for omit_range in omit_ranges.iter().rev() { - let start = omit_range - .start - .saturating_sub(range.start) - .min(range.len()); - let end = omit_range.end.saturating_sub(range.start).min(range.len()); - expected_text.replace_range(start..end, ""); - } - - assert_eq!( - text_in_range_omitting_ranges(&rope, range.clone(), &omit_ranges), - expected_text, - "text: {text:?}\nrange: {range:?}\nomit_ranges: {omit_ranges:?}" - ); - } -} diff --git a/crates/assistant/src/prompt_library.rs b/crates/assistant/src/prompt_library.rs new file mode 100644 index 00000000000000..9d782aedc78cdd --- /dev/null +++ b/crates/assistant/src/prompt_library.rs @@ -0,0 +1,1319 @@ +use crate::{ + slash_command::SlashCommandCompletionProvider, AssistantPanel, CompletionProvider, + InlineAssist, InlineAssistant, LanguageModelRequest, LanguageModelRequestMessage, Role, +}; +use anyhow::{anyhow, Result}; +use assets::Assets; +use chrono::{DateTime, Utc}; +use collections::{HashMap, HashSet}; +use editor::{actions::Tab, CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle}; +use futures::{ + future::{self, BoxFuture, Shared}, + FutureExt, +}; +use fuzzy::StringMatchCandidate; +use gpui::{ + actions, point, size, transparent_black, AppContext, AssetSource, BackgroundExecutor, Bounds, + EventEmitter, Global, HighlightStyle, PromptLevel, ReadGlobal, Subscription, Task, TextStyle, + TitlebarOptions, UpdateGlobal, View, WindowBounds, WindowHandle, WindowOptions, +}; +use heed::{types::SerdeBincode, Database, RoTxn}; +use language::{language_settings::SoftWrap, Buffer, LanguageRegistry}; +use parking_lot::RwLock; +use picker::{Picker, PickerDelegate}; +use rope::Rope; +use serde::{Deserialize, Serialize}; +use settings::Settings; +use std::{ + cmp::Reverse, + future::Future, + path::PathBuf, + sync::{atomic::AtomicBool, Arc}, + time::Duration, +}; +use theme::ThemeSettings; +use ui::{ + div, prelude::*, IconButtonShape, ListItem, ListItemSpacing, ParentElement, Render, + SharedString, Styled, Tooltip, ViewContext, VisualContext, +}; +use util::{ResultExt, TryFutureExt}; +use uuid::Uuid; +use workspace::Workspace; + +actions!( + prompt_library, + [ + NewPrompt, + DeletePrompt, + DuplicatePrompt, + ToggleDefaultPrompt + ] +); + +/// Init starts loading the PromptStore in the background and assigns +/// a shared future to a global. +pub fn init(cx: &mut AppContext) { + let db_path = paths::prompts_dir().join("prompts-library-db.0.mdb"); + let prompt_store_future = PromptStore::new(db_path, cx.background_executor().clone()) + .then(|result| future::ready(result.map(Arc::new).map_err(Arc::new))) + .boxed() + .shared(); + cx.set_global(GlobalPromptStore(prompt_store_future)) +} + +/// This function opens a new prompt library window if one doesn't exist already. +/// If one exists, it brings it to the foreground. +/// +/// Note that, when opening a new window, this waits for the PromptStore to be +/// initialized. If it was initialized successfully, it returns a window handle +/// to a prompt library. +pub fn open_prompt_library( + language_registry: Arc, + cx: &mut AppContext, +) -> Task>> { + let existing_window = cx + .windows() + .into_iter() + .find_map(|window| window.downcast::()); + if let Some(existing_window) = existing_window { + existing_window + .update(cx, |_, cx| cx.activate_window()) + .ok(); + Task::ready(Ok(existing_window)) + } else { + let store = PromptStore::global(cx); + cx.spawn(|cx| async move { + let store = store.await?; + cx.update(|cx| { + let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx); + cx.open_window( + WindowOptions { + titlebar: Some(TitlebarOptions { + title: Some("Prompt Library".into()), + appears_transparent: true, + traffic_light_position: Some(point(px(9.0), px(9.0))), + }), + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |cx| cx.new_view(|cx| PromptLibrary::new(store, language_registry, cx)), + ) + })? + }) + } +} + +pub struct PromptLibrary { + store: Arc, + language_registry: Arc, + prompt_editors: HashMap, + active_prompt_id: Option, + picker: View>, + pending_load: Task<()>, + _subscriptions: Vec, +} + +struct PromptEditor { + title_editor: View, + body_editor: View, + token_count: Option, + pending_token_count: Task>, + next_title_and_body_to_save: Option<(String, Rope)>, + pending_save: Option>>, + _subscriptions: Vec, +} + +struct PromptPickerDelegate { + store: Arc, + selected_index: usize, + matches: Vec, +} + +enum PromptPickerEvent { + Selected { prompt_id: PromptId }, + Confirmed { prompt_id: PromptId }, + Deleted { prompt_id: PromptId }, + ToggledDefault { prompt_id: PromptId }, +} + +impl EventEmitter for Picker {} + +impl PickerDelegate for PromptPickerDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext>) { + self.selected_index = ix; + if let Some(prompt) = self.matches.get(self.selected_index) { + cx.emit(PromptPickerEvent::Selected { + prompt_id: prompt.id, + }); + } + } + + fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { + "Search...".into() + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { + let search = self.store.search(query); + let prev_prompt_id = self.matches.get(self.selected_index).map(|mat| mat.id); + cx.spawn(|this, mut cx| async move { + let (matches, selected_index) = cx + .background_executor() + .spawn(async move { + let matches = search.await; + + let selected_index = prev_prompt_id + .and_then(|prev_prompt_id| { + matches.iter().position(|entry| entry.id == prev_prompt_id) + }) + .unwrap_or(0); + (matches, selected_index) + }) + .await; + + this.update(&mut cx, |this, cx| { + this.delegate.matches = matches; + this.delegate.set_selected_index(selected_index, cx); + cx.notify(); + }) + .ok(); + }) + } + + fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { + if let Some(prompt) = self.matches.get(self.selected_index) { + cx.emit(PromptPickerEvent::Confirmed { + prompt_id: prompt.id, + }); + } + } + + fn dismissed(&mut self, _cx: &mut ViewContext>) {} + + fn render_match( + &self, + ix: usize, + selected: bool, + cx: &mut ViewContext>, + ) -> Option { + let prompt = self.matches.get(ix)?; + let default = prompt.default; + let prompt_id = prompt.id; + let element = ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .selected(selected) + .child(h_flex().h_5().line_height(relative(1.)).child(Label::new( + prompt.title.clone().unwrap_or("Untitled".into()), + ))) + .end_slot::(default.then(|| { + IconButton::new("toggle-default-prompt", IconName::SparkleFilled) + .selected(true) + .icon_color(Color::Accent) + .shape(IconButtonShape::Square) + .tooltip(move |cx| Tooltip::text("Remove from Default Prompt", cx)) + .on_click(cx.listener(move |_, _, cx| { + cx.emit(PromptPickerEvent::ToggledDefault { prompt_id }) + })) + })) + .end_hover_slot( + h_flex() + .gap_2() + .child( + IconButton::new("delete-prompt", IconName::Trash) + .icon_color(Color::Muted) + .shape(IconButtonShape::Square) + .tooltip(move |cx| Tooltip::text("Delete Prompt", cx)) + .on_click(cx.listener(move |_, _, cx| { + cx.emit(PromptPickerEvent::Deleted { prompt_id }) + })), + ) + .child( + IconButton::new("toggle-default-prompt", IconName::Sparkle) + .selected(default) + .selected_icon(IconName::SparkleFilled) + .icon_color(if default { Color::Accent } else { Color::Muted }) + .shape(IconButtonShape::Square) + .tooltip(move |cx| { + Tooltip::text( + if default { + "Remove from Default Prompt" + } else { + "Add to Default Prompt" + }, + cx, + ) + }) + .on_click(cx.listener(move |_, _, cx| { + cx.emit(PromptPickerEvent::ToggledDefault { prompt_id }) + })), + ), + ); + Some(element) + } + + fn render_editor(&self, editor: &View, cx: &mut ViewContext>) -> Div { + h_flex() + .bg(cx.theme().colors().editor_background) + .rounded_md() + .overflow_hidden() + .flex_none() + .py_1() + .px_2() + .mx_1() + .child(editor.clone()) + } +} + +impl PromptLibrary { + fn new( + store: Arc, + language_registry: Arc, + cx: &mut ViewContext, + ) -> Self { + let delegate = PromptPickerDelegate { + store: store.clone(), + selected_index: 0, + matches: Vec::new(), + }; + + let picker = cx.new_view(|cx| { + let picker = Picker::uniform_list(delegate, cx) + .modal(false) + .max_height(None); + picker.focus(cx); + picker + }); + Self { + store: store.clone(), + language_registry, + prompt_editors: HashMap::default(), + active_prompt_id: None, + pending_load: Task::ready(()), + _subscriptions: vec![cx.subscribe(&picker, Self::handle_picker_event)], + picker, + } + } + + fn handle_picker_event( + &mut self, + _: View>, + event: &PromptPickerEvent, + cx: &mut ViewContext, + ) { + match event { + PromptPickerEvent::Selected { prompt_id } => { + self.load_prompt(*prompt_id, false, cx); + } + PromptPickerEvent::Confirmed { prompt_id } => { + self.load_prompt(*prompt_id, true, cx); + } + PromptPickerEvent::ToggledDefault { prompt_id } => { + self.toggle_default_for_prompt(*prompt_id, cx); + } + PromptPickerEvent::Deleted { prompt_id } => { + self.delete_prompt(*prompt_id, cx); + } + } + } + + pub fn new_prompt(&mut self, cx: &mut ViewContext) { + // If we already have an untitled prompt, use that instead + // of creating a new one. + if let Some(metadata) = self.store.first() { + if metadata.title.is_none() { + self.load_prompt(metadata.id, true, cx); + return; + } + } + + let prompt_id = PromptId::new(); + let save = self.store.save(prompt_id, None, false, "".into()); + self.picker.update(cx, |picker, cx| picker.refresh(cx)); + cx.spawn(|this, mut cx| async move { + save.await?; + this.update(&mut cx, |this, cx| this.load_prompt(prompt_id, true, cx)) + }) + .detach_and_log_err(cx); + } + + pub fn save_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext) { + const SAVE_THROTTLE: Duration = Duration::from_millis(500); + + let prompt_metadata = self.store.metadata(prompt_id).unwrap(); + let prompt_editor = self.prompt_editors.get_mut(&prompt_id).unwrap(); + let title = prompt_editor.title_editor.read(cx).text(cx); + let body = prompt_editor.body_editor.update(cx, |editor, cx| { + editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .read(cx) + .as_rope() + .clone() + }); + + let store = self.store.clone(); + let executor = cx.background_executor().clone(); + + prompt_editor.next_title_and_body_to_save = Some((title, body)); + if prompt_editor.pending_save.is_none() { + prompt_editor.pending_save = Some(cx.spawn(|this, mut cx| { + async move { + loop { + let title_and_body = this.update(&mut cx, |this, _| { + this.prompt_editors + .get_mut(&prompt_id)? + .next_title_and_body_to_save + .take() + })?; + + if let Some((title, body)) = title_and_body { + let title = if title.trim().is_empty() { + None + } else { + Some(SharedString::from(title)) + }; + store + .save(prompt_id, title, prompt_metadata.default, body) + .await + .log_err(); + this.update(&mut cx, |this, cx| { + this.picker.update(cx, |picker, cx| picker.refresh(cx)); + cx.notify(); + })?; + + executor.timer(SAVE_THROTTLE).await; + } else { + break; + } + } + + this.update(&mut cx, |this, _cx| { + if let Some(prompt_editor) = this.prompt_editors.get_mut(&prompt_id) { + prompt_editor.pending_save = None; + } + }) + } + .log_err() + })); + } + } + + pub fn delete_active_prompt(&mut self, cx: &mut ViewContext) { + if let Some(active_prompt_id) = self.active_prompt_id { + self.delete_prompt(active_prompt_id, cx); + } + } + + pub fn duplicate_active_prompt(&mut self, cx: &mut ViewContext) { + if let Some(active_prompt_id) = self.active_prompt_id { + self.duplicate_prompt(active_prompt_id, cx); + } + } + + pub fn toggle_default_for_active_prompt(&mut self, cx: &mut ViewContext) { + if let Some(active_prompt_id) = self.active_prompt_id { + self.toggle_default_for_prompt(active_prompt_id, cx); + } + } + + pub fn toggle_default_for_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext) { + if let Some(prompt_metadata) = self.store.metadata(prompt_id) { + self.store + .save_metadata(prompt_id, prompt_metadata.title, !prompt_metadata.default) + .detach_and_log_err(cx); + self.picker.update(cx, |picker, cx| picker.refresh(cx)); + cx.notify(); + } + } + + pub fn load_prompt(&mut self, prompt_id: PromptId, focus: bool, cx: &mut ViewContext) { + if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) { + if focus { + prompt_editor + .body_editor + .update(cx, |editor, cx| editor.focus(cx)); + } + self.set_active_prompt(Some(prompt_id), cx); + } else if let Some(prompt_metadata) = self.store.metadata(prompt_id) { + let language_registry = self.language_registry.clone(); + let prompt = self.store.load(prompt_id); + self.pending_load = cx.spawn(|this, mut cx| async move { + let prompt = prompt.await; + let markdown = language_registry.language_for_name("Markdown").await; + this.update(&mut cx, |this, cx| match prompt { + Ok(prompt) => { + let title_editor = cx.new_view(|cx| { + let mut editor = Editor::auto_width(cx); + editor.set_placeholder_text("Untitled", cx); + editor.set_text(prompt_metadata.title.unwrap_or_default(), cx); + editor + }); + let body_editor = cx.new_view(|cx| { + let buffer = cx.new_model(|cx| { + let mut buffer = Buffer::local(prompt, cx); + buffer.set_language(markdown.log_err(), cx); + buffer.set_language_registry(language_registry); + buffer + }); + + let mut editor = Editor::for_buffer(buffer, None, cx); + editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); + editor.set_show_gutter(false, cx); + editor.set_show_wrap_guides(false, cx); + editor.set_show_indent_guides(false, cx); + editor.set_use_modal_editing(false); + editor.set_current_line_highlight(Some(CurrentLineHighlight::None)); + editor.set_completion_provider(Box::new( + SlashCommandCompletionProvider::new(None, None), + )); + if focus { + editor.focus(cx); + } + editor + }); + let _subscriptions = vec![ + cx.subscribe(&title_editor, move |this, editor, event, cx| { + this.handle_prompt_title_editor_event(prompt_id, editor, event, cx) + }), + cx.subscribe(&body_editor, move |this, editor, event, cx| { + this.handle_prompt_body_editor_event(prompt_id, editor, event, cx) + }), + ]; + this.prompt_editors.insert( + prompt_id, + PromptEditor { + title_editor, + body_editor, + next_title_and_body_to_save: None, + pending_save: None, + token_count: None, + pending_token_count: Task::ready(None), + _subscriptions, + }, + ); + this.set_active_prompt(Some(prompt_id), cx); + this.count_tokens(prompt_id, cx); + } + Err(error) => { + // TODO: we should show the error in the UI. + log::error!("error while loading prompt: {:?}", error); + } + }) + .ok(); + }); + } + } + + fn set_active_prompt(&mut self, prompt_id: Option, cx: &mut ViewContext) { + self.active_prompt_id = prompt_id; + self.picker.update(cx, |picker, cx| { + if let Some(prompt_id) = prompt_id { + if picker + .delegate + .matches + .get(picker.delegate.selected_index()) + .map_or(true, |old_selected_prompt| { + old_selected_prompt.id != prompt_id + }) + { + if let Some(ix) = picker + .delegate + .matches + .iter() + .position(|mat| mat.id == prompt_id) + { + picker.set_selected_index(ix, true, cx); + } + } + } else { + picker.focus(cx); + } + }); + cx.notify(); + } + + pub fn delete_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext) { + if let Some(metadata) = self.store.metadata(prompt_id) { + let confirmation = cx.prompt( + PromptLevel::Warning, + &format!( + "Are you sure you want to delete {}", + metadata.title.unwrap_or("Untitled".into()) + ), + None, + &["Delete", "Cancel"], + ); + + cx.spawn(|this, mut cx| async move { + if confirmation.await.ok() == Some(0) { + this.update(&mut cx, |this, cx| { + if this.active_prompt_id == Some(prompt_id) { + this.set_active_prompt(None, cx); + } + this.prompt_editors.remove(&prompt_id); + this.store.delete(prompt_id).detach_and_log_err(cx); + this.picker.update(cx, |picker, cx| picker.refresh(cx)); + cx.notify(); + })?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + } + + pub fn duplicate_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext) { + if let Some(prompt) = self.prompt_editors.get(&prompt_id) { + const DUPLICATE_SUFFIX: &str = " copy"; + let title_to_duplicate = prompt.title_editor.read(cx).text(cx); + let existing_titles = self + .prompt_editors + .iter() + .filter(|&(&id, _)| id != prompt_id) + .map(|(_, prompt_editor)| prompt_editor.title_editor.read(cx).text(cx)) + .filter(|title| title.starts_with(&title_to_duplicate)) + .collect::>(); + + let title = if existing_titles.is_empty() { + title_to_duplicate + DUPLICATE_SUFFIX + } else { + let mut i = 1; + loop { + let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}"); + if !existing_titles.contains(&new_title) { + break new_title; + } + i += 1; + } + }; + + let new_id = PromptId::new(); + let body = prompt.body_editor.read(cx).text(cx); + let save = self + .store + .save(new_id, Some(title.into()), false, body.into()); + self.picker.update(cx, |picker, cx| picker.refresh(cx)); + cx.spawn(|this, mut cx| async move { + save.await?; + this.update(&mut cx, |prompt_library, cx| { + prompt_library.load_prompt(new_id, true, cx) + }) + }) + .detach_and_log_err(cx); + } + } + + fn focus_active_prompt(&mut self, _: &Tab, cx: &mut ViewContext) { + if let Some(active_prompt) = self.active_prompt_id { + self.prompt_editors[&active_prompt] + .body_editor + .update(cx, |editor, cx| editor.focus(cx)); + cx.stop_propagation(); + } + } + + fn focus_picker(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { + self.picker.update(cx, |picker, cx| picker.focus(cx)); + } + + pub fn inline_assist(&mut self, action: &InlineAssist, cx: &mut ViewContext) { + let Some(active_prompt_id) = self.active_prompt_id else { + cx.propagate(); + return; + }; + + let prompt_editor = &self.prompt_editors[&active_prompt_id].body_editor; + let provider = CompletionProvider::global(cx); + let initial_prompt = action.prompt.clone(); + if provider.is_authenticated() { + InlineAssistant::update_global(cx, |assistant, cx| { + assistant.assist(&prompt_editor, None, None, initial_prompt, cx) + }) + } else { + for window in cx.windows() { + if let Some(workspace) = window.downcast::() { + let panel = workspace + .update(cx, |workspace, cx| { + cx.activate_window(); + workspace.focus_panel::(cx) + }) + .ok() + .flatten(); + if panel.is_some() { + return; + } + } + } + } + } + + fn move_down_from_title(&mut self, _: &editor::actions::MoveDown, cx: &mut ViewContext) { + if let Some(prompt_id) = self.active_prompt_id { + if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) { + cx.focus_view(&prompt_editor.body_editor); + } + } + } + + fn move_up_from_body(&mut self, _: &editor::actions::MoveUp, cx: &mut ViewContext) { + if let Some(prompt_id) = self.active_prompt_id { + if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) { + cx.focus_view(&prompt_editor.title_editor); + } + } + } + + fn handle_prompt_title_editor_event( + &mut self, + prompt_id: PromptId, + title_editor: View, + event: &EditorEvent, + cx: &mut ViewContext, + ) { + match event { + EditorEvent::BufferEdited => { + self.save_prompt(prompt_id, cx); + self.count_tokens(prompt_id, cx); + } + EditorEvent::Blurred => { + title_editor.update(cx, |title_editor, cx| { + title_editor.change_selections(None, cx, |selections| { + let cursor = selections.oldest_anchor().head(); + selections.select_anchor_ranges([cursor..cursor]); + }); + }); + } + _ => {} + } + } + + fn handle_prompt_body_editor_event( + &mut self, + prompt_id: PromptId, + body_editor: View, + event: &EditorEvent, + cx: &mut ViewContext, + ) { + match event { + EditorEvent::BufferEdited => { + self.save_prompt(prompt_id, cx); + self.count_tokens(prompt_id, cx); + } + EditorEvent::Blurred => { + body_editor.update(cx, |body_editor, cx| { + body_editor.change_selections(None, cx, |selections| { + let cursor = selections.oldest_anchor().head(); + selections.select_anchor_ranges([cursor..cursor]); + }); + }); + } + _ => {} + } + } + + fn count_tokens(&mut self, prompt_id: PromptId, cx: &mut ViewContext) { + if let Some(prompt) = self.prompt_editors.get_mut(&prompt_id) { + let editor = &prompt.body_editor.read(cx); + let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx); + let body = buffer.as_rope().clone(); + prompt.pending_token_count = cx.spawn(|this, mut cx| { + async move { + const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1); + + cx.background_executor().timer(DEBOUNCE_TIMEOUT).await; + let token_count = cx + .update(|cx| { + let provider = CompletionProvider::global(cx); + let model = provider.model(); + provider.count_tokens( + LanguageModelRequest { + model, + messages: vec![LanguageModelRequestMessage { + role: Role::System, + content: body.to_string(), + }], + stop: Vec::new(), + temperature: 1., + }, + cx, + ) + })? + .await?; + this.update(&mut cx, |this, cx| { + let prompt_editor = this.prompt_editors.get_mut(&prompt_id).unwrap(); + prompt_editor.token_count = Some(token_count); + cx.notify(); + }) + } + .log_err() + }); + } + } + + fn render_prompt_list(&mut self, cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .id("prompt-list") + .capture_action(cx.listener(Self::focus_active_prompt)) + .bg(cx.theme().colors().panel_background) + .h_full() + .px_1() + .w_1_3() + .overflow_x_hidden() + .child( + h_flex() + .p(Spacing::Small.rems(cx)) + .h_9() + .w_full() + .flex_none() + .justify_end() + .child( + IconButton::new("new-prompt", IconName::Plus) + .style(ButtonStyle::Transparent) + .shape(IconButtonShape::Square) + .tooltip(move |cx| Tooltip::for_action("New Prompt", &NewPrompt, cx)) + .on_click(|_, cx| { + cx.dispatch_action(Box::new(NewPrompt)); + }), + ), + ) + .child(div().flex_grow().child(self.picker.clone())) + } + + fn render_active_prompt(&mut self, cx: &mut ViewContext) -> gpui::Stateful
{ + div() + .w_2_3() + .h_full() + .id("prompt-editor") + .border_l_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) + .flex_none() + .min_w_64() + .children(self.active_prompt_id.and_then(|prompt_id| { + let prompt_metadata = self.store.metadata(prompt_id)?; + let prompt_editor = &self.prompt_editors[&prompt_id]; + let focus_handle = prompt_editor.body_editor.focus_handle(cx); + let current_model = CompletionProvider::global(cx).model(); + let settings = ThemeSettings::get_global(cx); + + Some( + v_flex() + .id("prompt-editor-inner") + .size_full() + .relative() + .overflow_hidden() + .pl(Spacing::XXLarge.rems(cx)) + .pt(Spacing::Large.rems(cx)) + .on_click(cx.listener(move |_, _, cx| { + cx.focus(&focus_handle); + })) + .child( + h_flex() + .group("active-editor-header") + .pr(Spacing::XXLarge.rems(cx)) + .pt(Spacing::XSmall.rems(cx)) + .pb(Spacing::Large.rems(cx)) + .justify_between() + .child( + h_flex().gap_1().child( + div() + .max_w_80() + .on_action(cx.listener(Self::move_down_from_title)) + .border_1() + .border_color(transparent_black()) + .rounded_md() + .group_hover("active-editor-header", |this| { + this.border_color( + cx.theme().colors().border_variant, + ) + }) + .child(EditorElement::new( + &prompt_editor.title_editor, + EditorStyle { + background: cx.theme().system().transparent, + local_player: cx.theme().players().local(), + text: TextStyle { + color: cx + .theme() + .colors() + .editor_foreground, + font_family: settings + .ui_font + .family + .clone(), + font_features: settings + .ui_font + .features + .clone(), + font_size: HeadlineSize::Large + .size() + .into(), + font_weight: settings.ui_font.weight, + line_height: relative( + settings.buffer_line_height.value(), + ), + ..Default::default() + }, + scrollbar_width: Pixels::ZERO, + syntax: cx.theme().syntax().clone(), + status: cx.theme().status().clone(), + inlay_hints_style: HighlightStyle { + color: Some(cx.theme().status().hint), + ..HighlightStyle::default() + }, + suggestions_style: HighlightStyle { + color: Some(cx.theme().status().predictive), + ..HighlightStyle::default() + }, + }, + )), + ), + ) + .child( + h_flex() + .h_full() + .child( + h_flex() + .h_full() + .gap(Spacing::XXLarge.rems(cx)) + .child(div()), + ) + .child( + h_flex() + .h_full() + .gap(Spacing::XXLarge.rems(cx)) + .children(prompt_editor.token_count.map( + |token_count| { + let token_count: SharedString = + token_count.to_string().into(); + let label_token_count: SharedString = + token_count.to_string().into(); + + h_flex() + .id("token_count") + .tooltip(move |cx| { + let token_count = + token_count.clone(); + + Tooltip::with_meta( + format!( + "{} tokens", + token_count.clone() + ), + None, + format!( + "Model: {}", + current_model + .display_name() + ), + cx, + ) + }) + .child( + Label::new(format!( + "{} tokens", + label_token_count.clone() + )) + .color(Color::Muted), + ) + }, + )) + .child( + IconButton::new( + "delete-prompt", + IconName::Trash, + ) + .size(ButtonSize::Large) + .style(ButtonStyle::Transparent) + .shape(IconButtonShape::Square) + .size(ButtonSize::Large) + .tooltip(move |cx| { + Tooltip::for_action( + "Delete Prompt", + &DeletePrompt, + cx, + ) + }) + .on_click(|_, cx| { + cx.dispatch_action(Box::new(DeletePrompt)); + }), + ) + .child( + IconButton::new( + "duplicate-prompt", + IconName::BookCopy, + ) + .size(ButtonSize::Large) + .style(ButtonStyle::Transparent) + .shape(IconButtonShape::Square) + .size(ButtonSize::Large) + .tooltip(move |cx| { + Tooltip::for_action( + "Duplicate Prompt", + &DuplicatePrompt, + cx, + ) + }) + .on_click(|_, cx| { + cx.dispatch_action(Box::new( + DuplicatePrompt, + )); + }), + ) + .child( + IconButton::new( + "toggle-default-prompt", + IconName::Sparkle, + ) + .style(ButtonStyle::Transparent) + .selected(prompt_metadata.default) + .selected_icon(IconName::SparkleFilled) + .icon_color(if prompt_metadata.default { + Color::Accent + } else { + Color::Muted + }) + .shape(IconButtonShape::Square) + .size(ButtonSize::Large) + .tooltip(move |cx| { + Tooltip::text( + if prompt_metadata.default { + "Remove from Default Prompt" + } else { + "Add to Default Prompt" + }, + cx, + ) + }) + .on_click(|_, cx| { + cx.dispatch_action(Box::new( + ToggleDefaultPrompt, + )); + }), + ), + ), + ), + ) + .child( + div() + .on_action(cx.listener(Self::focus_picker)) + .on_action(cx.listener(Self::inline_assist)) + .on_action(cx.listener(Self::move_up_from_body)) + .flex_grow() + .h_full() + .child(prompt_editor.body_editor.clone()), + ), + ) + })) + } +} + +impl Render for PromptLibrary { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let ui_font = theme::setup_ui_font(cx); + let theme = cx.theme().clone(); + + h_flex() + .id("prompt-manager") + .key_context("PromptLibrary") + .on_action(cx.listener(|this, &NewPrompt, cx| this.new_prompt(cx))) + .on_action(cx.listener(|this, &DeletePrompt, cx| this.delete_active_prompt(cx))) + .on_action(cx.listener(|this, &DuplicatePrompt, cx| this.duplicate_active_prompt(cx))) + .on_action(cx.listener(|this, &ToggleDefaultPrompt, cx| { + this.toggle_default_for_active_prompt(cx) + })) + .size_full() + .overflow_hidden() + .font(ui_font) + .text_color(theme.colors().text) + .child(self.render_prompt_list(cx)) + .child(self.render_active_prompt(cx)) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PromptMetadata { + pub id: PromptId, + pub title: Option, + pub default: bool, + pub saved_at: DateTime, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PromptId(Uuid); + +impl PromptId { + pub fn new() -> PromptId { + PromptId(Uuid::new_v4()) + } +} + +pub struct PromptStore { + executor: BackgroundExecutor, + env: heed::Env, + bodies: Database, SerdeBincode>, + metadata: Database, SerdeBincode>, + metadata_cache: RwLock, +} + +#[derive(Default)] +struct MetadataCache { + metadata: Vec, + metadata_by_id: HashMap, +} + +impl MetadataCache { + fn from_db( + db: Database, SerdeBincode>, + txn: &RoTxn, + ) -> Result { + let mut cache = MetadataCache::default(); + for result in db.iter(txn)? { + let (prompt_id, metadata) = result?; + cache.metadata.push(metadata.clone()); + cache.metadata_by_id.insert(prompt_id, metadata); + } + cache.sort(); + Ok(cache) + } + + fn insert(&mut self, metadata: PromptMetadata) { + self.metadata_by_id.insert(metadata.id, metadata.clone()); + if let Some(old_metadata) = self.metadata.iter_mut().find(|m| m.id == metadata.id) { + *old_metadata = metadata; + } else { + self.metadata.push(metadata); + } + self.sort(); + } + + fn remove(&mut self, id: PromptId) { + self.metadata.retain(|metadata| metadata.id != id); + self.metadata_by_id.remove(&id); + } + + fn sort(&mut self) { + self.metadata.sort_unstable_by(|a, b| { + a.title + .cmp(&b.title) + .then_with(|| b.saved_at.cmp(&a.saved_at)) + }); + } +} + +impl PromptStore { + pub fn global(cx: &AppContext) -> impl Future>> { + let store = GlobalPromptStore::global(cx).0.clone(); + async move { store.await.map_err(|err| anyhow!(err)) } + } + + pub fn new(db_path: PathBuf, executor: BackgroundExecutor) -> Task> { + executor.spawn({ + let executor = executor.clone(); + async move { + std::fs::create_dir_all(&db_path)?; + + let db_env = unsafe { + heed::EnvOpenOptions::new() + .map_size(1024 * 1024 * 1024) // 1GB + .max_dbs(2) // bodies and metadata + .open(db_path)? + }; + + let mut txn = db_env.write_txn()?; + let bodies = db_env.create_database(&mut txn, Some("bodies"))?; + let metadata = db_env.create_database(&mut txn, Some("metadata"))?; + let metadata_cache = MetadataCache::from_db(metadata, &txn)?; + txn.commit()?; + + Ok(PromptStore { + executor, + env: db_env, + bodies, + metadata, + metadata_cache: RwLock::new(metadata_cache), + }) + } + }) + } + + pub fn load(&self, id: PromptId) -> Task> { + let env = self.env.clone(); + let bodies = self.bodies; + self.executor.spawn(async move { + let txn = env.read_txn()?; + bodies + .get(&txn, &id)? + .ok_or_else(|| anyhow!("prompt not found")) + }) + } + + pub fn default_prompt_metadata(&self) -> Vec { + return self + .metadata_cache + .read() + .metadata + .iter() + .filter(|metadata| metadata.default) + .cloned() + .collect::>(); + } + + pub fn delete(&self, id: PromptId) -> Task> { + self.metadata_cache.write().remove(id); + + let db_connection = self.env.clone(); + let bodies = self.bodies; + let metadata = self.metadata; + + self.executor.spawn(async move { + let mut txn = db_connection.write_txn()?; + + metadata.delete(&mut txn, &id)?; + bodies.delete(&mut txn, &id)?; + + txn.commit()?; + Ok(()) + }) + } + + fn metadata(&self, id: PromptId) -> Option { + self.metadata_cache.read().metadata_by_id.get(&id).cloned() + } + + pub fn id_for_title(&self, title: &str) -> Option { + let metadata_cache = self.metadata_cache.read(); + let metadata = metadata_cache + .metadata + .iter() + .find(|metadata| metadata.title.as_ref().map(|title| &***title) == Some(title))?; + Some(metadata.id) + } + + pub fn search(&self, query: String) -> Task> { + let cached_metadata = self.metadata_cache.read().metadata.clone(); + let executor = self.executor.clone(); + self.executor.spawn(async move { + let mut matches = if query.is_empty() { + cached_metadata + } else { + let candidates = cached_metadata + .iter() + .enumerate() + .filter_map(|(ix, metadata)| { + Some(StringMatchCandidate::new( + ix, + metadata.title.as_ref()?.to_string(), + )) + }) + .collect::>(); + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + 100, + &AtomicBool::default(), + executor, + ) + .await; + matches + .into_iter() + .map(|mat| cached_metadata[mat.candidate_id].clone()) + .collect() + }; + matches.sort_by_key(|metadata| Reverse(metadata.default)); + matches + }) + } + + fn save( + &self, + id: PromptId, + title: Option, + default: bool, + body: Rope, + ) -> Task> { + let prompt_metadata = PromptMetadata { + id, + title, + default, + saved_at: Utc::now(), + }; + self.metadata_cache.write().insert(prompt_metadata.clone()); + + let db_connection = self.env.clone(); + let bodies = self.bodies; + let metadata = self.metadata; + + self.executor.spawn(async move { + let mut txn = db_connection.write_txn()?; + + metadata.put(&mut txn, &id, &prompt_metadata)?; + bodies.put(&mut txn, &id, &body.to_string())?; + + txn.commit()?; + + Ok(()) + }) + } + + fn save_metadata( + &self, + id: PromptId, + title: Option, + default: bool, + ) -> Task> { + let prompt_metadata = PromptMetadata { + id, + title, + default, + saved_at: Utc::now(), + }; + self.metadata_cache.write().insert(prompt_metadata.clone()); + + let db_connection = self.env.clone(); + let metadata = self.metadata; + + self.executor.spawn(async move { + let mut txn = db_connection.write_txn()?; + metadata.put(&mut txn, &id, &prompt_metadata)?; + txn.commit()?; + + Ok(()) + }) + } + + fn first(&self) -> Option { + self.metadata_cache.read().metadata.first().cloned() + } + + pub fn operations_prompt(&self) -> String { + String::from_utf8( + Assets + .load("prompts/operations.md") + .unwrap() + .unwrap() + .to_vec(), + ) + .unwrap() + } +} + +/// Wraps a shared future to a prompt store so it can be assigned as a context global. +pub struct GlobalPromptStore( + Shared, Arc>>>, +); + +impl Global for GlobalPromptStore {} diff --git a/crates/assistant/src/prompts.rs b/crates/assistant/src/prompts.rs index 9d0e2be23e590d..4c99caca82312d 100644 --- a/crates/assistant/src/prompts.rs +++ b/crates/assistant/src/prompts.rs @@ -1,3 +1,135 @@ -pub mod prompt; -pub mod prompt_library; -pub mod prompt_manager; +use language::BufferSnapshot; +use std::{fmt::Write, ops::Range}; + +pub fn generate_content_prompt( + user_prompt: String, + language_name: Option<&str>, + buffer: BufferSnapshot, + range: Range, + _project_name: Option, +) -> anyhow::Result { + let mut prompt = String::new(); + + let content_type = match language_name { + None | Some("Markdown" | "Plain Text") => { + writeln!( + prompt, + "Here's a file of text that I'm going to ask you to make an edit to." + )?; + "text" + } + Some(language_name) => { + writeln!( + prompt, + "Here's a file of {language_name} that I'm going to ask you to make an edit to." + )?; + "code" + } + }; + + const MAX_CTX: usize = 50000; + let mut is_truncated = false; + if range.is_empty() { + prompt.push_str("The point you'll need to insert at is marked with .\n\n"); + } else { + prompt.push_str("The section you'll need to rewrite is marked with tags.\n\n"); + } + // Include file content. + let before_range = 0..range.start; + let truncated_before = if before_range.len() > MAX_CTX { + is_truncated = true; + range.start - MAX_CTX..range.start + } else { + before_range + }; + let mut non_rewrite_len = truncated_before.len(); + for chunk in buffer.text_for_range(truncated_before) { + prompt.push_str(chunk); + } + if !range.is_empty() { + prompt.push_str("\n"); + for chunk in buffer.text_for_range(range.clone()) { + prompt.push_str(chunk); + } + prompt.push_str("\n"); + } else { + prompt.push_str(""); + } + let after_range = range.end..buffer.len(); + let truncated_after = if after_range.len() > MAX_CTX { + is_truncated = true; + range.end..range.end + MAX_CTX + } else { + after_range + }; + non_rewrite_len += truncated_after.len(); + for chunk in buffer.text_for_range(truncated_after) { + prompt.push_str(chunk); + } + + write!(prompt, "\n\n").unwrap(); + + if is_truncated { + writeln!(prompt, "The context around the relevant section has been truncated (possibly in the middle of a line) for brevity.\n")?; + } + + if range.is_empty() { + writeln!( + prompt, + "You can't replace {content_type}, your answer will be inserted in place of the `` tags. Don't include the insert_here tags in your output.", + ) + .unwrap(); + writeln!( + prompt, + "Generate {content_type} based on the following prompt:\n\n\n{user_prompt}\n", + ) + .unwrap(); + writeln!(prompt, "Match the indentation in the original file in the inserted {content_type}, don't include any indentation on blank lines.\n").unwrap(); + prompt.push_str("Immediately start with the following format with no remarks:\n\n```\n{{INSERTED_CODE}}\n```"); + } else { + writeln!(prompt, "Edit the section of {content_type} in tags based on the following prompt:'").unwrap(); + writeln!(prompt, "\n\n{user_prompt}\n\n").unwrap(); + let rewrite_len = range.end - range.start; + if rewrite_len < 20000 && rewrite_len * 2 < non_rewrite_len { + writeln!(prompt, "And here's the section to rewrite based on that prompt again for reference:\n\n\n").unwrap(); + for chunk in buffer.text_for_range(range.clone()) { + prompt.push_str(chunk); + } + writeln!(prompt, "\n\n").unwrap(); + } + writeln!(prompt, "Only make changes that are necessary to fulfill the prompt, leave everything else as-is. All surrounding {content_type} will be preserved.\n").unwrap(); + write!( + prompt, + "Start at the indentation level in the original file in the rewritten {content_type}. " + ) + .unwrap(); + prompt.push_str("Don't stop until you've rewritten the entire section, even if you have no more changes to make, always write out the whole section with no unnecessary elisions."); + prompt.push_str("\n\nImmediately start with the following format with no remarks:\n\n```\n{{REWRITTEN_CODE}}\n```"); + } + + Ok(prompt) +} + +pub fn generate_terminal_assistant_prompt( + user_prompt: &str, + shell: Option<&str>, + working_directory: Option<&str>, +) -> String { + let mut prompt = String::new(); + writeln!(&mut prompt, "You are an expert terminal user.").unwrap(); + writeln!(&mut prompt, "You will be given a description of a command and you need to respond with a command that matches the description.").unwrap(); + writeln!(&mut prompt, "Do not include markdown blocks or any other text formatting in your response, always respond with a single command that can be executed in the given shell.").unwrap(); + if let Some(shell) = shell { + writeln!(&mut prompt, "Current shell is '{shell}'.").unwrap(); + } + if let Some(working_directory) = working_directory { + writeln!( + &mut prompt, + "Current working directory is '{working_directory}'." + ) + .unwrap(); + } + writeln!(&mut prompt, "Here is the description of the command:").unwrap(); + prompt.push_str(user_prompt); + prompt +} diff --git a/crates/assistant/src/prompts/prompt.rs b/crates/assistant/src/prompts/prompt.rs deleted file mode 100644 index f24bb7c965b5e0..00000000000000 --- a/crates/assistant/src/prompts/prompt.rs +++ /dev/null @@ -1,222 +0,0 @@ -use language::BufferSnapshot; -use std::{fmt::Write, ops::Range}; -use ui::SharedString; - -use gray_matter::{engine::YAML, Matter}; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub struct StaticPromptFrontmatter { - title: String, - version: String, - author: String, - #[serde(default)] - languages: Vec, - #[serde(default)] - dependencies: Vec, -} - -impl Default for StaticPromptFrontmatter { - fn default() -> Self { - Self { - title: "Untitled Prompt".to_string(), - version: "1.0".to_string(), - author: "No Author".to_string(), - languages: vec!["*".to_string()], - dependencies: vec![], - } - } -} - -/// A static prompt that can be loaded into the prompt library -/// from Markdown with a frontmatter header -/// -/// Examples: -/// -/// ### Globally available prompt -/// -/// ```markdown -/// --- -/// title: Foo -/// version: 1.0 -/// author: Jane Kim -/// languages: ["rust"] -/// dependencies: ["gpui"] -/// --- -/// -/// When building a UI with GPUI, ensure you... -/// ``` -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub struct StaticPrompt { - #[serde(skip)] - metadata: StaticPromptFrontmatter, - content: String, - file_name: Option, -} - -impl StaticPrompt { - pub fn new(content: String, file_name: Option) -> Self { - let matter = Matter::::new(); - let result = matter.parse(&content); - - let metadata = result - .data - .map_or_else( - || Err(anyhow::anyhow!("Failed to parse frontmatter")), - |data| { - let front_matter: StaticPromptFrontmatter = data.deserialize()?; - Ok(front_matter) - }, - ) - .unwrap_or_else(|e| { - if let Some(file_name) = &file_name { - log::error!("Failed to parse frontmatter for {}: {}", file_name, e); - } else { - log::error!("Failed to parse frontmatter: {}", e); - } - StaticPromptFrontmatter::default() - }); - - StaticPrompt { - content, - file_name, - metadata, - } - } -} - -impl StaticPrompt { - /// Sets the file name of the prompt - pub fn _file_name(&mut self, file_name: String) -> &mut Self { - self.file_name = Some(file_name); - self - } - - /// Returns the prompt's content - pub fn content(&self) -> &String { - &self.content - } - - /// Returns the prompt's metadata - pub fn _metadata(&self) -> &StaticPromptFrontmatter { - &self.metadata - } - - /// Returns the prompt's title - pub fn title(&self) -> SharedString { - self.metadata.title.clone().into() - } - - pub fn body(&self) -> String { - let matter = Matter::::new(); - let result = matter.parse(self.content.as_str()); - result.content.clone() - } -} - -pub fn generate_content_prompt( - user_prompt: String, - language_name: Option<&str>, - buffer: BufferSnapshot, - range: Range, - project_name: Option, -) -> anyhow::Result { - let mut prompt = String::new(); - - let content_type = match language_name { - None | Some("Markdown" | "Plain Text") => { - writeln!(prompt, "You are an expert engineer.")?; - "Text" - } - Some(language_name) => { - writeln!(prompt, "You are an expert {language_name} engineer.")?; - writeln!( - prompt, - "Your answer MUST always and only be valid {}.", - language_name - )?; - "Code" - } - }; - - if let Some(project_name) = project_name { - writeln!( - prompt, - "You are currently working inside the '{project_name}' project in code editor Zed." - )?; - } - - // Include file content. - for chunk in buffer.text_for_range(0..range.start) { - prompt.push_str(chunk); - } - - if range.is_empty() { - prompt.push_str("<|START|>"); - } else { - prompt.push_str("<|START|"); - } - - for chunk in buffer.text_for_range(range.clone()) { - prompt.push_str(chunk); - } - - if !range.is_empty() { - prompt.push_str("|END|>"); - } - - for chunk in buffer.text_for_range(range.end..buffer.len()) { - prompt.push_str(chunk); - } - - prompt.push('\n'); - - if range.is_empty() { - writeln!( - prompt, - "Assume the cursor is located where the `<|START|>` span is." - ) - .unwrap(); - writeln!( - prompt, - "{content_type} can't be replaced, so assume your answer will be inserted at the cursor.", - ) - .unwrap(); - writeln!( - prompt, - "Generate {content_type} based on the users prompt: {user_prompt}", - ) - .unwrap(); - } else { - writeln!(prompt, "Modify the user's selected {content_type} based upon the users prompt: '{user_prompt}'").unwrap(); - writeln!(prompt, "You must reply with only the adjusted {content_type} (within the '<|START|' and '|END|>' spans) not the entire file.").unwrap(); - writeln!( - prompt, - "Double check that you only return code and not the '<|START|' and '|END|'> spans" - ) - .unwrap(); - } - - writeln!(prompt, "Never make remarks about the output.").unwrap(); - writeln!( - prompt, - "Do not return anything else, except the generated {content_type}." - ) - .unwrap(); - - Ok(prompt) -} diff --git a/crates/assistant/src/prompts/prompt_library.rs b/crates/assistant/src/prompts/prompt_library.rs deleted file mode 100644 index 50fff5b6ee18c1..00000000000000 --- a/crates/assistant/src/prompts/prompt_library.rs +++ /dev/null @@ -1,189 +0,0 @@ -use anyhow::Context; -use collections::HashMap; -use fs::Fs; - -use gray_matter::{engine::YAML, Matter}; -use parking_lot::RwLock; -use serde::{Deserialize, Serialize}; -use smol::stream::StreamExt; -use std::sync::Arc; -use util::paths::PROMPTS_DIR; -use uuid::Uuid; - -use super::prompt::StaticPrompt; - -#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub struct PromptId(pub Uuid); - -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub enum SortOrder { - Alphabetical, -} - -#[allow(unused)] -impl PromptId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } -} - -#[derive(Default, Serialize, Deserialize)] -pub struct PromptLibraryState { - /// A set of prompts that all assistant contexts will start with - default_prompt: Vec, - /// All [Prompt]s loaded into the library - prompts: HashMap, - /// Prompts that have been changed but haven't been - /// saved back to the file system - dirty_prompts: Vec, - version: usize, -} - -pub struct PromptLibrary { - state: RwLock, -} - -impl Default for PromptLibrary { - fn default() -> Self { - Self::new() - } -} - -impl PromptLibrary { - fn new() -> Self { - Self { - state: RwLock::new(PromptLibraryState::default()), - } - } - - pub fn prompts(&self) -> Vec<(PromptId, StaticPrompt)> { - let state = self.state.read(); - state - .prompts - .iter() - .map(|(id, prompt)| (*id, prompt.clone())) - .collect() - } - - pub fn sorted_prompts(&self, sort_order: SortOrder) -> Vec<(PromptId, StaticPrompt)> { - let state = self.state.read(); - - let mut prompts = state - .prompts - .iter() - .map(|(id, prompt)| (*id, prompt.clone())) - .collect::>(); - - match sort_order { - SortOrder::Alphabetical => prompts.sort_by(|(_, a), (_, b)| a.title().cmp(&b.title())), - }; - - prompts - } - - pub fn first_prompt_id(&self) -> Option { - let state = self.state.read(); - state.prompts.keys().next().cloned() - } - - pub fn prompt(&self, id: PromptId) -> Option { - let state = self.state.read(); - state.prompts.get(&id).cloned() - } - - /// Save the current state of the prompt library to the - /// file system as a JSON file - pub async fn save(&self, fs: Arc) -> anyhow::Result<()> { - fs.create_dir(&PROMPTS_DIR).await?; - - let path = PROMPTS_DIR.join("index.json"); - - let json = { - let state = self.state.read(); - serde_json::to_string(&*state)? - }; - - fs.atomic_write(path, json).await?; - - Ok(()) - } - - /// Load the state of the prompt library from the file system - /// or create a new one if it doesn't exist - pub async fn load(fs: Arc) -> anyhow::Result { - let path = PROMPTS_DIR.join("index.json"); - - let state = if fs.is_file(&path).await { - let json = fs.load(&path).await?; - serde_json::from_str(&json)? - } else { - PromptLibraryState::default() - }; - - let mut prompt_library = Self { - state: RwLock::new(state), - }; - - prompt_library.load_prompts(fs).await?; - - Ok(prompt_library) - } - - /// Load all prompts from the file system - /// adding them to the library if they don't already exist - pub async fn load_prompts(&mut self, fs: Arc) -> anyhow::Result<()> { - // let current_prompts = self.all_prompt_contents().clone(); - - // For now, we'll just clear the prompts and reload them all - self.state.get_mut().prompts.clear(); - - let mut prompt_paths = fs.read_dir(&PROMPTS_DIR).await?; - - while let Some(prompt_path) = prompt_paths.next().await { - let prompt_path = prompt_path.with_context(|| "Failed to read prompt path")?; - let file_name_lossy = if prompt_path.file_name().is_some() { - Some( - prompt_path - .file_name() - .unwrap() - .to_string_lossy() - .to_string(), - ) - } else { - None - }; - - if !fs.is_file(&prompt_path).await - || prompt_path.extension().and_then(|ext| ext.to_str()) != Some("md") - { - continue; - } - - let json = fs - .load(&prompt_path) - .await - .with_context(|| format!("Failed to load prompt {:?}", prompt_path))?; - - // Check that the prompt is valid - let matter = Matter::::new(); - let result = matter.parse(&json); - if result.data.is_none() { - log::warn!("Invalid prompt: {:?}", prompt_path); - continue; - } - - let static_prompt = StaticPrompt::new(json, file_name_lossy.clone()); - - let state = self.state.get_mut(); - - let id = Uuid::new_v4(); - state.prompts.insert(PromptId(id), static_prompt); - state.version += 1; - } - - // Write any changes back to the file system - self.save(fs.clone()).await?; - - Ok(()) - } -} diff --git a/crates/assistant/src/prompts/prompt_manager.rs b/crates/assistant/src/prompts/prompt_manager.rs deleted file mode 100644 index c16c9140fb8ab4..00000000000000 --- a/crates/assistant/src/prompts/prompt_manager.rs +++ /dev/null @@ -1,327 +0,0 @@ -use collections::HashMap; -use editor::Editor; -use fs::Fs; -use gpui::{prelude::FluentBuilder, *}; -use language::{language_settings, Buffer, LanguageRegistry}; -use picker::{Picker, PickerDelegate}; -use std::sync::Arc; -use ui::{prelude::*, IconButtonShape, ListItem, ListItemSpacing}; -use util::{ResultExt, TryFutureExt}; -use workspace::ModalView; - -use super::prompt_library::{PromptId, PromptLibrary, SortOrder}; -use crate::prompts::prompt::StaticPrompt; - -pub struct PromptManager { - focus_handle: FocusHandle, - prompt_library: Arc, - language_registry: Arc, - #[allow(dead_code)] - fs: Arc, - picker: View>, - prompt_editors: HashMap>, - active_prompt_id: Option, -} - -impl PromptManager { - pub fn new( - prompt_library: Arc, - language_registry: Arc, - fs: Arc, - cx: &mut ViewContext, - ) -> Self { - let prompt_manager = cx.view().downgrade(); - let picker = cx.new_view(|cx| { - Picker::uniform_list( - PromptManagerDelegate { - prompt_manager, - matching_prompts: vec![], - matching_prompt_ids: vec![], - prompt_library: prompt_library.clone(), - selected_index: 0, - }, - cx, - ) - .max_height(rems(35.75)) - .modal(false) - }); - - let focus_handle = picker.focus_handle(cx); - - let mut manager = Self { - focus_handle, - prompt_library, - language_registry, - fs, - picker, - prompt_editors: HashMap::default(), - active_prompt_id: None, - }; - - manager.active_prompt_id = manager.prompt_library.first_prompt_id(); - - manager - } - - pub fn set_active_prompt(&mut self, prompt_id: Option, cx: &mut ViewContext) { - self.active_prompt_id = prompt_id; - cx.notify(); - } - - pub fn focus_active_editor(&self, cx: &mut ViewContext) { - if let Some(active_prompt_id) = self.active_prompt_id { - if let Some(editor) = self.prompt_editors.get(&active_prompt_id) { - let focus_handle = editor.focus_handle(cx); - - cx.focus(&focus_handle) - } - } - } - - fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { - cx.emit(DismissEvent); - } - - fn render_prompt_list(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let picker = self.picker.clone(); - - v_flex() - .id("prompt-list") - .bg(cx.theme().colors().surface_background) - .h_full() - .w_2_5() - .child( - h_flex() - .bg(cx.theme().colors().background) - .p(Spacing::Small.rems(cx)) - .border_b_1() - .border_color(cx.theme().colors().border) - .h(rems(1.75)) - .w_full() - .flex_none() - .justify_between() - .child(Label::new("Prompt Library").size(LabelSize::Small)) - .child(IconButton::new("new-prompt", IconName::Plus).disabled(true)), - ) - .child( - v_flex() - .h(rems(38.25)) - .flex_grow() - .justify_start() - .child(picker), - ) - } - - fn set_editor_for_prompt( - &mut self, - prompt_id: PromptId, - cx: &mut ViewContext, - ) -> impl IntoElement { - let prompt_library = self.prompt_library.clone(); - - let editor_for_prompt = self.prompt_editors.entry(prompt_id).or_insert_with(|| { - cx.new_view(|cx| { - let text = if let Some(prompt) = prompt_library.prompt(prompt_id) { - prompt.content().to_owned() - } else { - "".to_string() - }; - - let buffer = cx.new_model(|cx| { - let mut buffer = Buffer::local(text, cx); - let markdown = self.language_registry.language_for_name("Markdown"); - cx.spawn(|buffer, mut cx| async move { - if let Some(markdown) = markdown.await.log_err() { - _ = buffer.update(&mut cx, |buffer, cx| { - buffer.set_language(Some(markdown), cx); - }); - } - }) - .detach(); - buffer.set_language_registry(self.language_registry.clone()); - buffer - }); - let mut editor = Editor::for_buffer(buffer, None, cx); - editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); - editor.set_show_gutter(false, cx); - editor - }) - }); - editor_for_prompt.clone() - } -} - -impl Render for PromptManager { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - h_flex() - .key_context("PromptManager") - .track_focus(&self.focus_handle) - .on_action(cx.listener(Self::dismiss)) - // .on_action(cx.listener(Self::save_active_prompt)) - .elevation_3(cx) - .size_full() - .flex_none() - .w(rems(64.)) - .h(rems(40.)) - .overflow_hidden() - .child(self.render_prompt_list(cx)) - .child( - div().w_3_5().h_full().child( - v_flex() - .id("prompt-editor") - .border_l_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .size_full() - .flex_none() - .min_w_64() - .h_full() - .child( - h_flex() - .bg(cx.theme().colors().background) - .p(Spacing::Small.rems(cx)) - .border_b_1() - .border_color(cx.theme().colors().border) - .h_7() - .w_full() - .justify_between() - .child(div()) - .child( - IconButton::new("dismiss", IconName::Close) - .shape(IconButtonShape::Square) - .on_click(|_, cx| { - cx.dispatch_action(menu::Cancel.boxed_clone()); - }), - ), - ) - .when_some(self.active_prompt_id, |this, active_prompt_id| { - this.child( - h_flex() - .flex_1() - .w_full() - .py(Spacing::Large.rems(cx)) - .px(Spacing::XLarge.rems(cx)) - .child(self.set_editor_for_prompt(active_prompt_id, cx)), - ) - }), - ), - ) - } -} - -impl EventEmitter for PromptManager {} -impl ModalView for PromptManager {} - -impl FocusableView for PromptManager { - fn focus_handle(&self, _cx: &AppContext) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -pub struct PromptManagerDelegate { - prompt_manager: WeakView, - matching_prompts: Vec>, - matching_prompt_ids: Vec, - prompt_library: Arc, - selected_index: usize, -} - -impl PickerDelegate for PromptManagerDelegate { - type ListItem = ListItem; - - fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { - "Find a prompt…".into() - } - - fn match_count(&self) -> usize { - self.matching_prompt_ids.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext>) { - self.selected_index = ix; - } - - fn selected_index_changed( - &self, - ix: usize, - _cx: &mut ViewContext>, - ) -> Option> { - let prompt_id = self.matching_prompt_ids.get(ix).copied()?; - let prompt_manager = self.prompt_manager.upgrade()?; - - Some(Box::new(move |cx| { - prompt_manager.update(cx, |manager, cx| { - manager.set_active_prompt(Some(prompt_id), cx); - }) - })) - } - - fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { - let prompt_library = self.prompt_library.clone(); - cx.spawn(|picker, mut cx| async move { - async { - let prompts = prompt_library.sorted_prompts(SortOrder::Alphabetical); - let matching_prompts = prompts - .into_iter() - .filter(|(_, prompt)| { - prompt - .content() - .to_lowercase() - .contains(&query.to_lowercase()) - }) - .collect::>(); - picker.update(&mut cx, |picker, cx| { - picker.delegate.matching_prompt_ids = - matching_prompts.iter().map(|(id, _)| *id).collect(); - picker.delegate.matching_prompts = matching_prompts - .into_iter() - .map(|(_, prompt)| Arc::new(prompt)) - .collect(); - cx.notify(); - })?; - anyhow::Ok(()) - } - .log_err() - .await; - }) - } - - fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { - let prompt_manager = self.prompt_manager.upgrade().unwrap(); - prompt_manager.update(cx, move |manager, cx| manager.focus_active_editor(cx)); - } - - fn should_dismiss(&self) -> bool { - false - } - - fn dismissed(&mut self, cx: &mut ViewContext>) { - self.prompt_manager - .update(cx, |_, cx| { - cx.emit(DismissEvent); - }) - .ok(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _cx: &mut ViewContext>, - ) -> Option { - let matching_prompt = self.matching_prompts.get(ix)?; - let prompt = matching_prompt.clone(); - - Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .selected(selected) - .child(Label::new(prompt.title())), - ) - } -} diff --git a/crates/assistant/src/saved_conversation.rs b/crates/assistant/src/saved_conversation.rs deleted file mode 100644 index ac6c925a43e122..00000000000000 --- a/crates/assistant/src/saved_conversation.rs +++ /dev/null @@ -1,126 +0,0 @@ -use crate::{assistant_settings::OpenAiModel, MessageId, MessageMetadata}; -use anyhow::{anyhow, Result}; -use collections::HashMap; -use fs::Fs; -use futures::StreamExt; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use std::{ - cmp::Reverse, - ffi::OsStr, - path::{Path, PathBuf}, - sync::Arc, -}; -use util::paths::CONVERSATIONS_DIR; - -#[derive(Serialize, Deserialize)] -pub struct SavedMessage { - pub id: MessageId, - pub start: usize, -} - -#[derive(Serialize, Deserialize)] -pub struct SavedConversation { - pub id: Option, - pub zed: String, - pub version: String, - pub text: String, - pub messages: Vec, - pub message_metadata: HashMap, - pub summary: String, -} - -impl SavedConversation { - pub const VERSION: &'static str = "0.2.0"; - - pub async fn load(path: &Path, fs: &dyn Fs) -> Result { - let saved_conversation = fs.load(path).await?; - let saved_conversation_json = - serde_json::from_str::(&saved_conversation)?; - match saved_conversation_json - .get("version") - .ok_or_else(|| anyhow!("version not found"))? - { - serde_json::Value::String(version) => match version.as_str() { - Self::VERSION => Ok(serde_json::from_value::(saved_conversation_json)?), - "0.1.0" => { - let saved_conversation = - serde_json::from_value::(saved_conversation_json)?; - Ok(Self { - id: saved_conversation.id, - zed: saved_conversation.zed, - version: saved_conversation.version, - text: saved_conversation.text, - messages: saved_conversation.messages, - message_metadata: saved_conversation.message_metadata, - summary: saved_conversation.summary, - }) - } - _ => Err(anyhow!( - "unrecognized saved conversation version: {}", - version - )), - }, - _ => Err(anyhow!("version not found on saved conversation")), - } - } -} - -#[derive(Serialize, Deserialize)] -struct SavedConversationV0_1_0 { - id: Option, - zed: String, - version: String, - text: String, - messages: Vec, - message_metadata: HashMap, - summary: String, - api_url: Option, - model: OpenAiModel, -} - -pub struct SavedConversationMetadata { - pub title: String, - pub path: PathBuf, - pub mtime: chrono::DateTime, -} - -impl SavedConversationMetadata { - pub async fn list(fs: Arc) -> Result> { - fs.create_dir(&CONVERSATIONS_DIR).await?; - - let mut paths = fs.read_dir(&CONVERSATIONS_DIR).await?; - let mut conversations = Vec::::new(); - while let Some(path) = paths.next().await { - let path = path?; - if path.extension() != Some(OsStr::new("json")) { - continue; - } - - let pattern = r" - \d+.zed.json$"; - let re = Regex::new(pattern).unwrap(); - - let metadata = fs.metadata(&path).await?; - if let Some((file_name, metadata)) = path - .file_name() - .and_then(|name| name.to_str()) - .zip(metadata) - { - // This is used to filter out conversations saved by the new assistant. - if !re.is_match(file_name) { - continue; - } - - let title = re.replace(file_name, ""); - conversations.push(Self { - title: title.into_owned(), - path, - mtime: metadata.mtime.into(), - }); - } - } - conversations.sort_unstable_by_key(|conversation| Reverse(conversation.mtime)); - - Ok(conversations) - } -} diff --git a/crates/assistant/src/search.rs b/crates/assistant/src/search.rs deleted file mode 100644 index 7e8b18ae5056ba..00000000000000 --- a/crates/assistant/src/search.rs +++ /dev/null @@ -1,171 +0,0 @@ -use language::Rope; -use std::ops::Range; - -/// Search the given buffer for the given substring, ignoring any differences -/// in line indentation between the query and the buffer. -/// -/// Returns a vector of ranges of byte offsets in the buffer corresponding -/// to the entire lines of the buffer. -pub fn fuzzy_search_lines(haystack: &Rope, needle: &str) -> Option> { - const SIMILARITY_THRESHOLD: f64 = 0.8; - - let mut best_match: Option<(Range, f64)> = None; // (range, score) - let mut haystack_lines = haystack.chunks().lines(); - let mut haystack_line_start = 0; - while let Some(mut haystack_line) = haystack_lines.next() { - let next_haystack_line_start = haystack_line_start + haystack_line.len() + 1; - let mut advanced_to_next_haystack_line = false; - - let mut matched = true; - let match_start = haystack_line_start; - let mut match_end = next_haystack_line_start; - let mut match_score = 0.0; - let mut needle_lines = needle.lines().peekable(); - while let Some(needle_line) = needle_lines.next() { - let similarity = line_similarity(haystack_line, needle_line); - if similarity >= SIMILARITY_THRESHOLD { - match_end = haystack_lines.offset(); - match_score += similarity; - - if needle_lines.peek().is_some() { - if let Some(next_haystack_line) = haystack_lines.next() { - advanced_to_next_haystack_line = true; - haystack_line = next_haystack_line; - } else { - matched = false; - break; - } - } else { - break; - } - } else { - matched = false; - break; - } - } - - if matched - && best_match - .as_ref() - .map(|(_, best_score)| match_score > *best_score) - .unwrap_or(true) - { - best_match = Some((match_start..match_end, match_score)); - } - - if advanced_to_next_haystack_line { - haystack_lines.seek(next_haystack_line_start); - } - haystack_line_start = next_haystack_line_start; - } - - best_match.map(|(range, _)| range) -} - -/// Calculates the similarity between two lines, ignoring leading and trailing whitespace, -/// using the Jaro-Winkler distance. -/// -/// Returns a value between 0.0 and 1.0, where 1.0 indicates an exact match. -fn line_similarity(line1: &str, line2: &str) -> f64 { - strsim::jaro_winkler(line1.trim(), line2.trim()) -} - -#[cfg(test)] -mod test { - use super::*; - use gpui::{AppContext, Context as _}; - use language::Buffer; - use unindent::Unindent as _; - use util::test::marked_text_ranges; - - #[gpui::test] - fn test_fuzzy_search_lines(cx: &mut AppContext) { - let (text, expected_ranges) = marked_text_ranges( - &r#" - fn main() { - if a() { - assert_eq!( - 1 + 2, - does_not_match, - ); - } - - println!("hi"); - - assert_eq!( - 1 + 2, - 3, - ); // this last line does not match - - « assert_eq!( - 1 + 2, - 3, - ); - » - - « assert_eq!( - "something", - "else", - ); - » - } - "# - .unindent(), - false, - ); - - let buffer = cx.new_model(|cx| Buffer::local(&text, cx)); - let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - - let actual_range = fuzzy_search_lines( - snapshot.as_rope(), - &" - assert_eq!( - 1 + 2, - 3, - ); - " - .unindent(), - ) - .unwrap(); - assert_eq!(actual_range, expected_ranges[0]); - - let actual_range = fuzzy_search_lines( - snapshot.as_rope(), - &" - assert_eq!( - 1 + 2, - 3, - ); - " - .unindent(), - ) - .unwrap(); - assert_eq!(actual_range, expected_ranges[0]); - - let actual_range = fuzzy_search_lines( - snapshot.as_rope(), - &" - asst_eq!( - \"something\", - \"els\" - ) - " - .unindent(), - ) - .unwrap(); - assert_eq!(actual_range, expected_ranges[1]); - - let actual_range = fuzzy_search_lines( - snapshot.as_rope(), - &" - assert_eq!( - 2 + 1, - 3, - ); - " - .unindent(), - ); - assert_eq!(actual_range, None); - } -} diff --git a/crates/assistant/src/slash_command.rs b/crates/assistant/src/slash_command.rs index a9d5f61d026958..c64820cbd8f6e9 100644 --- a/crates/assistant/src/slash_command.rs +++ b/crates/assistant/src/slash_command.rs @@ -1,8 +1,10 @@ +use crate::assistant_panel::ContextEditor; use anyhow::Result; +pub use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandRegistry}; use editor::{CompletionProvider, Editor}; use fuzzy::{match_strings, StringMatchCandidate}; -use gpui::{AppContext, Model, Task, ViewContext}; -use language::{Anchor, Buffer, CodeLabel, Documentation, LanguageServerId, ToPoint}; +use gpui::{AppContext, Model, Task, ViewContext, WeakView, WindowContext}; +use language::{Anchor, Buffer, CodeLabel, Documentation, HighlightId, LanguageServerId, ToPoint}; use parking_lot::{Mutex, RwLock}; use rope::Point; use std::{ @@ -12,18 +14,27 @@ use std::{ Arc, }, }; +use ui::ActiveTheme; +use workspace::Workspace; -pub use assistant_slash_command::{ - SlashCommand, SlashCommandCleanup, SlashCommandInvocation, SlashCommandRegistry, -}; - -pub mod current_file_command; +pub mod active_command; +pub mod default_command; +pub mod diagnostics_command; +pub mod docs_command; +pub mod fetch_command; pub mod file_command; +pub mod now_command; +pub mod project_command; pub mod prompt_command; +pub mod search_command; +pub mod symbols_command; +pub mod tabs_command; +pub mod term_command; pub(crate) struct SlashCommandCompletionProvider { - commands: Arc, cancel_flag: Mutex>, + editor: Option>, + workspace: Option>, } pub(crate) struct SlashCommandLine { @@ -34,21 +45,26 @@ pub(crate) struct SlashCommandLine { } impl SlashCommandCompletionProvider { - pub fn new(commands: Arc) -> Self { + pub fn new( + editor: Option>, + workspace: Option>, + ) -> Self { Self { cancel_flag: Mutex::new(Arc::new(AtomicBool::new(false))), - commands, + editor, + workspace, } } fn complete_command_name( &self, command_name: &str, - range: Range, - cx: &mut AppContext, + command_range: Range, + name_range: Range, + cx: &mut WindowContext, ) -> Task>> { - let candidates = self - .commands + let commands = SlashCommandRegistry::global(cx); + let candidates = commands .command_names() .into_iter() .enumerate() @@ -58,39 +74,68 @@ impl SlashCommandCompletionProvider { char_bag: def.as_ref().into(), }) .collect::>(); - let commands = self.commands.clone(); let command_name = command_name.to_string(); - let executor = cx.background_executor().clone(); - executor.clone().spawn(async move { + let editor = self.editor.clone(); + let workspace = self.workspace.clone(); + cx.spawn(|mut cx| async move { let matches = match_strings( &candidates, &command_name, true, usize::MAX, &Default::default(), - executor, + cx.background_executor().clone(), ) .await; - Ok(matches - .into_iter() - .filter_map(|mat| { - let command = commands.command(&mat.string)?; - let mut new_text = mat.string.clone(); - if command.requires_argument() { - new_text.push(' '); - } + cx.update(|cx| { + matches + .into_iter() + .filter_map(|mat| { + let command = commands.command(&mat.string)?; + let mut new_text = mat.string.clone(); + let requires_argument = command.requires_argument(); + if requires_argument { + new_text.push(' '); + } - Some(project::Completion { - old_range: range.clone(), - documentation: Some(Documentation::SingleLine(command.description())), - new_text, - label: CodeLabel::plain(mat.string, None), - server_id: LanguageServerId(0), - lsp_completion: Default::default(), + let confirm = editor.clone().zip(workspace.clone()).and_then( + |(editor, workspace)| { + (!requires_argument).then(|| { + let command_name = mat.string.clone(); + let command_range = command_range.clone(); + let editor = editor.clone(); + let workspace = workspace.clone(); + Arc::new(move |cx: &mut WindowContext| { + editor + .update(cx, |editor, cx| { + editor.run_command( + command_range.clone(), + &command_name, + None, + true, + workspace.clone(), + cx, + ); + }) + .ok(); + }) as Arc<_> + }) + }, + ); + Some(project::Completion { + old_range: name_range.clone(), + documentation: Some(Documentation::SingleLine(command.description())), + new_text, + label: command.label(cx), + server_id: LanguageServerId(0), + lsp_completion: Default::default(), + show_new_completions_on_confirm: requires_argument, + confirm, + }) }) - }) - .collect()) + .collect() + }) }) } @@ -98,27 +143,75 @@ impl SlashCommandCompletionProvider { &self, command_name: &str, argument: String, - range: Range, - cx: &mut AppContext, + command_range: Range, + argument_range: Range, + cx: &mut WindowContext, ) -> Task>> { let new_cancel_flag = Arc::new(AtomicBool::new(false)); let mut flag = self.cancel_flag.lock(); flag.store(true, SeqCst); *flag = new_cancel_flag.clone(); - if let Some(command) = self.commands.command(command_name) { - let completions = command.complete_argument(argument, new_cancel_flag.clone(), cx); + let commands = SlashCommandRegistry::global(cx); + if let Some(command) = commands.command(command_name) { + let completions = command.complete_argument( + argument, + new_cancel_flag.clone(), + self.workspace.clone(), + cx, + ); + let command_name: Arc = command_name.into(); + let editor = self.editor.clone(); + let workspace = self.workspace.clone(); cx.background_executor().spawn(async move { Ok(completions .await? .into_iter() - .map(|arg| project::Completion { - old_range: range.clone(), - label: CodeLabel::plain(arg.clone(), None), - new_text: arg.clone(), - documentation: None, - server_id: LanguageServerId(0), - lsp_completion: Default::default(), + .map(|command_argument| { + let confirm = if command_argument.run_command { + editor + .clone() + .zip(workspace.clone()) + .map(|(editor, workspace)| { + Arc::new({ + let command_range = command_range.clone(); + let command_name = command_name.clone(); + let command_argument = command_argument.new_text.clone(); + move |cx: &mut WindowContext| { + editor + .update(cx, |editor, cx| { + editor.run_command( + command_range.clone(), + &command_name, + Some(&command_argument), + true, + workspace.clone(), + cx, + ); + }) + .ok(); + } + }) as Arc<_> + }) + } else { + None + }; + + let mut new_text = command_argument.new_text.clone(); + if !command_argument.run_command { + new_text.push(' '); + } + + project::Completion { + old_range: argument_range.clone(), + label: CodeLabel::plain(command_argument.label, None), + new_text, + documentation: None, + server_id: LanguageServerId(0), + lsp_completion: Default::default(), + show_new_completions_on_confirm: !command_argument.run_command, + confirm, + } }) .collect()) }) @@ -134,27 +227,47 @@ impl CompletionProvider for SlashCommandCompletionProvider { &self, buffer: &Model, buffer_position: Anchor, + _: editor::CompletionContext, cx: &mut ViewContext, ) -> Task>> { - let task = buffer.update(cx, |buffer, cx| { - let position = buffer_position.to_point(buffer); - let line_start = Point::new(position.row, 0); - let mut lines = buffer.text_for_range(line_start..position).lines(); - let line = lines.next()?; - let call = SlashCommandLine::parse(line)?; - - let name = &line[call.name.clone()]; - if let Some(argument) = call.argument { - let start = buffer.anchor_after(Point::new(position.row, argument.start as u32)); - let argument = line[argument.clone()].to_string(); - Some(self.complete_command_argument(name, argument, start..buffer_position, cx)) - } else { - let start = buffer.anchor_after(Point::new(position.row, call.name.start as u32)); - Some(self.complete_command_name(name, start..buffer_position, cx)) - } - }); + let Some((name, argument, command_range, argument_range)) = + buffer.update(cx, |buffer, _cx| { + let position = buffer_position.to_point(buffer); + let line_start = Point::new(position.row, 0); + let mut lines = buffer.text_for_range(line_start..position).lines(); + let line = lines.next()?; + let call = SlashCommandLine::parse(line)?; + + let command_range_start = Point::new(position.row, call.name.start as u32 - 1); + let command_range_end = Point::new( + position.row, + call.argument.as_ref().map_or(call.name.end, |arg| arg.end) as u32, + ); + let command_range = buffer.anchor_after(command_range_start) + ..buffer.anchor_after(command_range_end); - task.unwrap_or_else(|| Task::ready(Ok(Vec::new()))) + let name = line[call.name.clone()].to_string(); + + Some(if let Some(argument) = call.argument { + let start = + buffer.anchor_after(Point::new(position.row, argument.start as u32)); + let argument = line[argument.clone()].to_string(); + (name, Some(argument), command_range, start..buffer_position) + } else { + let start = + buffer.anchor_after(Point::new(position.row, call.name.start as u32)); + (name, None, command_range, start..buffer_position) + }) + }) + else { + return Task::ready(Ok(Vec::new())); + }; + + if let Some(argument) = argument { + self.complete_command_argument(&name, argument, command_range, argument_range, cx) + } else { + self.complete_command_name(&name, command_range, argument_range, cx) + } } fn resolve_completions( @@ -243,3 +356,19 @@ impl SlashCommandLine { call } } + +pub fn create_label_for_command( + command_name: &str, + arguments: &[&str], + cx: &AppContext, +) -> CodeLabel { + let mut label = CodeLabel::default(); + label.push_str(command_name, None); + label.push_str(" ", None); + label.push_str( + &arguments.join(" "), + cx.theme().syntax().highlight_id("comment").map(HighlightId), + ); + label.filter_range = 0..command_name.len(); + label +} diff --git a/crates/assistant/src/slash_command/active_command.rs b/crates/assistant/src/slash_command/active_command.rs new file mode 100644 index 00000000000000..0f46937560f930 --- /dev/null +++ b/crates/assistant/src/slash_command/active_command.rs @@ -0,0 +1,102 @@ +use super::{ + diagnostics_command::write_single_file_diagnostics, + file_command::{build_entry_output_section, codeblock_fence_for_path}, + SlashCommand, SlashCommandOutput, +}; +use anyhow::{anyhow, Result}; +use assistant_slash_command::ArgumentCompletion; +use editor::Editor; +use gpui::{AppContext, Task, WeakView}; +use language::LspAdapterDelegate; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use ui::WindowContext; +use workspace::Workspace; + +pub(crate) struct ActiveSlashCommand; + +impl SlashCommand for ActiveSlashCommand { + fn name(&self) -> String { + "active".into() + } + + fn description(&self) -> String { + "insert active tab".into() + } + + fn menu_text(&self) -> String { + "Insert Active Tab".into() + } + + fn complete_argument( + self: Arc, + _query: String, + _cancel: Arc, + _workspace: Option>, + _cx: &mut AppContext, + ) -> Task>> { + Task::ready(Err(anyhow!("this command does not require argument"))) + } + + fn requires_argument(&self) -> bool { + false + } + + fn run( + self: Arc, + _argument: Option<&str>, + workspace: WeakView, + _delegate: Arc, + cx: &mut WindowContext, + ) -> Task> { + let output = workspace.update(cx, |workspace, cx| { + let Some(active_item) = workspace.active_item(cx) else { + return Task::ready(Err(anyhow!("no active tab"))); + }; + let Some(buffer) = active_item + .downcast::() + .and_then(|editor| editor.read(cx).buffer().read(cx).as_singleton()) + else { + return Task::ready(Err(anyhow!("active tab is not an editor"))); + }; + + let snapshot = buffer.read(cx).snapshot(); + let path = snapshot.resolve_file_path(cx, true); + let task = cx.background_executor().spawn({ + let path = path.clone(); + async move { + let mut output = String::new(); + output.push_str(&codeblock_fence_for_path(path.as_deref(), None)); + for chunk in snapshot.as_rope().chunks() { + output.push_str(chunk); + } + if !output.ends_with('\n') { + output.push('\n'); + } + output.push_str("```\n"); + let has_diagnostics = + write_single_file_diagnostics(&mut output, path.as_deref(), &snapshot); + if output.ends_with('\n') { + output.pop(); + } + (output, has_diagnostics) + } + }); + cx.foreground_executor().spawn(async move { + let (text, has_diagnostics) = task.await; + let range = 0..text.len(); + Ok(SlashCommandOutput { + text, + sections: vec![build_entry_output_section( + range, + path.as_deref(), + false, + None, + )], + run_commands_in_text: has_diagnostics, + }) + }) + }); + output.unwrap_or_else(|error| Task::ready(Err(error))) + } +} diff --git a/crates/assistant/src/slash_command/current_file_command.rs b/crates/assistant/src/slash_command/current_file_command.rs deleted file mode 100644 index 5f55253557a30f..00000000000000 --- a/crates/assistant/src/slash_command/current_file_command.rs +++ /dev/null @@ -1,142 +0,0 @@ -use std::sync::Arc; -use std::{borrow::Cow, cell::Cell, rc::Rc}; - -use anyhow::{anyhow, Result}; -use collections::HashMap; -use editor::Editor; -use futures::channel::oneshot; -use gpui::{AppContext, Entity, Subscription, Task, WindowHandle}; -use language::LspAdapterDelegate; -use workspace::{Event as WorkspaceEvent, Workspace}; - -use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation}; - -pub(crate) struct CurrentFileSlashCommand { - workspace: WindowHandle, -} - -impl CurrentFileSlashCommand { - pub fn new(workspace: WindowHandle) -> Self { - Self { workspace } - } -} - -impl SlashCommand for CurrentFileSlashCommand { - fn name(&self) -> String { - "current_file".into() - } - - fn description(&self) -> String { - "insert the current file".into() - } - - fn complete_argument( - &self, - _query: String, - _cancel: std::sync::Arc, - _cx: &mut AppContext, - ) -> Task>> { - Task::ready(Err(anyhow!("this command does not require argument"))) - } - - fn requires_argument(&self) -> bool { - false - } - - fn run( - self: Arc, - _argument: Option<&str>, - _delegate: Arc, - cx: &mut AppContext, - ) -> SlashCommandInvocation { - let (invalidate_tx, invalidate_rx) = oneshot::channel(); - let invalidate_tx = Rc::new(Cell::new(Some(invalidate_tx))); - let mut subscriptions: Vec = Vec::new(); - let output = self.workspace.update(cx, |workspace, cx| { - let mut timestamps_by_entity_id = HashMap::default(); - for pane in workspace.panes() { - let pane = pane.read(cx); - for entry in pane.activation_history() { - timestamps_by_entity_id.insert(entry.entity_id, entry.timestamp); - } - } - - let mut most_recent_buffer = None; - for editor in workspace.items_of_type::(cx) { - let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else { - continue; - }; - - let timestamp = timestamps_by_entity_id - .get(&editor.entity_id()) - .copied() - .unwrap_or_default(); - if most_recent_buffer - .as_ref() - .map_or(true, |(_, prev_timestamp)| timestamp > *prev_timestamp) - { - most_recent_buffer = Some((buffer, timestamp)); - } - } - - subscriptions.push({ - let workspace_view = cx.view().clone(); - let invalidate_tx = invalidate_tx.clone(); - cx.window_context() - .subscribe(&workspace_view, move |_workspace, event, _cx| match event { - WorkspaceEvent::ActiveItemChanged - | WorkspaceEvent::ItemAdded - | WorkspaceEvent::ItemRemoved - | WorkspaceEvent::PaneAdded(_) - | WorkspaceEvent::PaneRemoved => { - if let Some(invalidate_tx) = invalidate_tx.take() { - _ = invalidate_tx.send(()); - } - } - _ => {} - }) - }); - - if let Some((buffer, _)) = most_recent_buffer { - subscriptions.push({ - let invalidate_tx = invalidate_tx.clone(); - cx.window_context().observe(&buffer, move |_buffer, _cx| { - if let Some(invalidate_tx) = invalidate_tx.take() { - _ = invalidate_tx.send(()); - } - }) - }); - - let snapshot = buffer.read(cx).snapshot(); - let path = snapshot.resolve_file_path(cx, true); - cx.background_executor().spawn(async move { - let path = path - .as_ref() - .map(|path| path.to_string_lossy()) - .unwrap_or_else(|| Cow::Borrowed("untitled")); - - let mut output = String::with_capacity(path.len() + snapshot.len() + 9); - output.push_str("```"); - output.push_str(&path); - output.push('\n'); - for chunk in snapshot.as_rope().chunks() { - output.push_str(chunk); - } - if !output.ends_with('\n') { - output.push('\n'); - } - output.push_str("```"); - Ok(output) - }) - } else { - Task::ready(Err(anyhow!("no recent buffer found"))) - } - }); - - SlashCommandInvocation { - output: output.unwrap_or_else(|error| Task::ready(Err(error))), - invalidated: invalidate_rx, - cleanup: SlashCommandCleanup::new(move || drop(subscriptions)), - } - } -} diff --git a/crates/assistant/src/slash_command/default_command.rs b/crates/assistant/src/slash_command/default_command.rs new file mode 100644 index 00000000000000..ccc9d1fbdb0953 --- /dev/null +++ b/crates/assistant/src/slash_command/default_command.rs @@ -0,0 +1,79 @@ +use super::{SlashCommand, SlashCommandOutput}; +use crate::prompt_library::PromptStore; +use anyhow::{anyhow, Result}; +use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection}; +use gpui::{AppContext, Task, WeakView}; +use language::LspAdapterDelegate; +use std::{ + fmt::Write, + sync::{atomic::AtomicBool, Arc}, +}; +use ui::prelude::*; +use workspace::Workspace; + +pub(crate) struct DefaultSlashCommand; + +impl SlashCommand for DefaultSlashCommand { + fn name(&self) -> String { + "default".into() + } + + fn description(&self) -> String { + "insert default prompt".into() + } + + fn menu_text(&self) -> String { + "Insert Default Prompt".into() + } + + fn requires_argument(&self) -> bool { + false + } + + fn complete_argument( + self: Arc, + _query: String, + _cancellation_flag: Arc, + _workspace: Option>, + _cx: &mut AppContext, + ) -> Task>> { + Task::ready(Err(anyhow!("this command does not require argument"))) + } + + fn run( + self: Arc, + _argument: Option<&str>, + _workspace: WeakView, + _delegate: Arc, + cx: &mut WindowContext, + ) -> Task> { + let store = PromptStore::global(cx); + cx.background_executor().spawn(async move { + let store = store.await?; + let prompts = store.default_prompt_metadata(); + + let mut text = String::new(); + text.push('\n'); + for prompt in prompts { + if let Some(title) = prompt.title { + writeln!(text, "/prompt {}", title).unwrap(); + } + } + text.pop(); + + if text.is_empty() { + text.push('\n'); + } + + Ok(SlashCommandOutput { + sections: vec![SlashCommandOutputSection { + range: 0..text.len(), + icon: IconName::Library, + label: "Default".into(), + }], + text, + run_commands_in_text: true, + }) + }) + } +} diff --git a/crates/assistant/src/slash_command/diagnostics_command.rs b/crates/assistant/src/slash_command/diagnostics_command.rs new file mode 100644 index 00000000000000..20e712803b1af8 --- /dev/null +++ b/crates/assistant/src/slash_command/diagnostics_command.rs @@ -0,0 +1,497 @@ +use super::{create_label_for_command, SlashCommand, SlashCommandOutput}; +use anyhow::{anyhow, Result}; +use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection}; +use fuzzy::{PathMatch, StringMatchCandidate}; +use gpui::{AppContext, Model, Task, View, WeakView}; +use language::{ + Anchor, BufferSnapshot, DiagnosticEntry, DiagnosticSeverity, LspAdapterDelegate, + OffsetRangeExt, ToOffset, +}; +use project::{DiagnosticSummary, PathMatchCandidateSet, Project}; +use rope::Point; +use std::fmt::Write; +use std::path::{Path, PathBuf}; +use std::{ + ops::Range, + sync::{atomic::AtomicBool, Arc}, +}; +use ui::prelude::*; +use util::paths::PathMatcher; +use util::ResultExt; +use workspace::Workspace; + +pub(crate) struct DiagnosticsSlashCommand; + +impl DiagnosticsSlashCommand { + fn search_paths( + &self, + query: String, + cancellation_flag: Arc, + workspace: &View, + cx: &mut AppContext, + ) -> Task> { + if query.is_empty() { + let workspace = workspace.read(cx); + let entries = workspace.recent_navigation_history(Some(10), cx); + let path_prefix: Arc = "".into(); + Task::ready( + entries + .into_iter() + .map(|(entry, _)| PathMatch { + score: 0., + positions: Vec::new(), + worktree_id: entry.worktree_id.to_usize(), + path: entry.path.clone(), + path_prefix: path_prefix.clone(), + distance_to_relative_ancestor: 0, + }) + .collect(), + ) + } else { + let worktrees = workspace.read(cx).visible_worktrees(cx).collect::>(); + let candidate_sets = worktrees + .into_iter() + .map(|worktree| { + let worktree = worktree.read(cx); + PathMatchCandidateSet { + snapshot: worktree.snapshot(), + include_ignored: worktree + .root_entry() + .map_or(false, |entry| entry.is_ignored), + include_root_name: true, + candidates: project::Candidates::Entries, + } + }) + .collect::>(); + + let executor = cx.background_executor().clone(); + cx.foreground_executor().spawn(async move { + fuzzy::match_path_sets( + candidate_sets.as_slice(), + query.as_str(), + None, + false, + 100, + &cancellation_flag, + executor, + ) + .await + }) + } + } +} + +impl SlashCommand for DiagnosticsSlashCommand { + fn name(&self) -> String { + "diagnostics".into() + } + + fn label(&self, cx: &AppContext) -> language::CodeLabel { + create_label_for_command("diagnostics", &[INCLUDE_WARNINGS_ARGUMENT], cx) + } + + fn description(&self) -> String { + "Insert diagnostics".into() + } + + fn menu_text(&self) -> String { + "Insert Diagnostics".into() + } + + fn requires_argument(&self) -> bool { + false + } + + fn complete_argument( + self: Arc, + query: String, + cancellation_flag: Arc, + workspace: Option>, + cx: &mut AppContext, + ) -> Task>> { + let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else { + return Task::ready(Err(anyhow!("workspace was dropped"))); + }; + let query = query.split_whitespace().last().unwrap_or("").to_string(); + + let paths = self.search_paths(query.clone(), cancellation_flag.clone(), &workspace, cx); + let executor = cx.background_executor().clone(); + cx.background_executor().spawn(async move { + let mut matches: Vec = paths + .await + .into_iter() + .map(|path_match| { + format!( + "{}{}", + path_match.path_prefix, + path_match.path.to_string_lossy() + ) + }) + .collect(); + + matches.extend( + fuzzy::match_strings( + &Options::match_candidates_for_args(), + &query, + false, + 10, + &cancellation_flag, + executor, + ) + .await + .into_iter() + .map(|candidate| candidate.string), + ); + + Ok(matches + .into_iter() + .map(|completion| ArgumentCompletion { + label: completion.clone(), + new_text: completion, + run_command: true, + }) + .collect()) + }) + } + + fn run( + self: Arc, + argument: Option<&str>, + workspace: WeakView, + _delegate: Arc, + cx: &mut WindowContext, + ) -> Task> { + let Some(workspace) = workspace.upgrade() else { + return Task::ready(Err(anyhow!("workspace was dropped"))); + }; + + let options = Options::parse(argument); + + let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx); + cx.spawn(move |_| async move { + let Some((text, sections)) = task.await? else { + return Ok(SlashCommandOutput::default()); + }; + + Ok(SlashCommandOutput { + text, + sections: sections + .into_iter() + .map(|(range, placeholder_type)| SlashCommandOutputSection { + range, + icon: match placeholder_type { + PlaceholderType::Root(_, _) => IconName::ExclamationTriangle, + PlaceholderType::File(_) => IconName::File, + PlaceholderType::Diagnostic(DiagnosticType::Error, _) => { + IconName::XCircle + } + PlaceholderType::Diagnostic(DiagnosticType::Warning, _) => { + IconName::ExclamationTriangle + } + }, + label: match placeholder_type { + PlaceholderType::Root(summary, source) => { + let mut label = String::new(); + label.push_str("Diagnostics"); + if let Some(source) = source { + write!(label, " ({})", source).unwrap(); + } + + if summary.error_count > 0 || summary.warning_count > 0 { + label.push(':'); + + if summary.error_count > 0 { + write!(label, " {} errors", summary.error_count).unwrap(); + if summary.warning_count > 0 { + label.push_str(","); + } + } + + if summary.warning_count > 0 { + write!(label, " {} warnings", summary.warning_count) + .unwrap(); + } + } + + label.into() + } + PlaceholderType::File(file_path) => file_path.into(), + PlaceholderType::Diagnostic(_, message) => message.into(), + }, + }) + .collect(), + run_commands_in_text: false, + }) + }) + } +} + +#[derive(Default)] +struct Options { + include_warnings: bool, + path_matcher: Option, +} + +const INCLUDE_WARNINGS_ARGUMENT: &str = "--include-warnings"; + +impl Options { + fn parse(arguments_line: Option<&str>) -> Self { + arguments_line + .map(|arguments_line| { + let args = arguments_line.split_whitespace().collect::>(); + let mut include_warnings = false; + let mut path_matcher = None; + for arg in args { + if arg == INCLUDE_WARNINGS_ARGUMENT { + include_warnings = true; + } else { + path_matcher = PathMatcher::new(&[arg.to_owned()]).log_err(); + } + } + Self { + include_warnings, + path_matcher, + } + }) + .unwrap_or_default() + } + + fn match_candidates_for_args() -> [StringMatchCandidate; 1] { + [StringMatchCandidate::new( + 0, + INCLUDE_WARNINGS_ARGUMENT.to_string(), + )] + } +} + +fn collect_diagnostics( + project: Model, + options: Options, + cx: &mut AppContext, +) -> Task, PlaceholderType)>)>>> { + let error_source = if let Some(path_matcher) = &options.path_matcher { + debug_assert_eq!(path_matcher.sources().len(), 1); + Some(path_matcher.sources().first().cloned().unwrap_or_default()) + } else { + None + }; + + let glob_is_exact_file_match = if let Some(path) = options + .path_matcher + .as_ref() + .and_then(|pm| pm.sources().first()) + { + PathBuf::try_from(path) + .ok() + .and_then(|path| { + project.read(cx).worktrees().find_map(|worktree| { + let worktree = worktree.read(cx); + let worktree_root_path = Path::new(worktree.root_name()); + let relative_path = path.strip_prefix(worktree_root_path).ok()?; + worktree.absolutize(&relative_path).ok() + }) + }) + .is_some() + } else { + false + }; + + let project_handle = project.downgrade(); + let diagnostic_summaries: Vec<_> = project + .read(cx) + .diagnostic_summaries(false, cx) + .flat_map(|(path, _, summary)| { + let worktree = project.read(cx).worktree_for_id(path.worktree_id, cx)?; + let mut path_buf = PathBuf::from(worktree.read(cx).root_name()); + path_buf.push(&path.path); + Some((path, path_buf, summary)) + }) + .collect(); + + cx.spawn(|mut cx| async move { + let mut text = String::new(); + if let Some(error_source) = error_source.as_ref() { + writeln!(text, "diagnostics: {}", error_source).unwrap(); + } else { + writeln!(text, "diagnostics").unwrap(); + } + let mut sections: Vec<(Range, PlaceholderType)> = Vec::new(); + + let mut project_summary = DiagnosticSummary::default(); + for (project_path, path, summary) in diagnostic_summaries { + if let Some(path_matcher) = &options.path_matcher { + if !path_matcher.is_match(&path) { + continue; + } + } + + project_summary.error_count += summary.error_count; + if options.include_warnings { + project_summary.warning_count += summary.warning_count; + } else if summary.error_count == 0 { + continue; + } + + let last_end = text.len(); + let file_path = path.to_string_lossy().to_string(); + if !glob_is_exact_file_match { + writeln!(&mut text, "{file_path}").unwrap(); + } + + if let Some(buffer) = project_handle + .update(&mut cx, |project, cx| project.open_buffer(project_path, cx))? + .await + .log_err() + { + collect_buffer_diagnostics( + &mut text, + &mut sections, + cx.read_model(&buffer, |buffer, _| buffer.snapshot())?, + options.include_warnings, + ); + } + + if !glob_is_exact_file_match { + sections.push(( + last_end..text.len().saturating_sub(1), + PlaceholderType::File(file_path), + )) + } + } + + // No diagnostics found + if sections.is_empty() { + return Ok(None); + } + + sections.push(( + 0..text.len(), + PlaceholderType::Root(project_summary, error_source), + )); + Ok(Some((text, sections))) + }) +} + +pub fn buffer_has_error_diagnostics(snapshot: &BufferSnapshot) -> bool { + for (_, group) in snapshot.diagnostic_groups(None) { + let entry = &group.entries[group.primary_ix]; + if entry.diagnostic.severity == DiagnosticSeverity::ERROR { + return true; + } + } + false +} + +pub fn write_single_file_diagnostics( + output: &mut String, + path: Option<&Path>, + snapshot: &BufferSnapshot, +) -> bool { + if let Some(path) = path { + if buffer_has_error_diagnostics(&snapshot) { + output.push_str("/diagnostics "); + output.push_str(&path.to_string_lossy()); + return true; + } + } + false +} + +fn collect_buffer_diagnostics( + text: &mut String, + sections: &mut Vec<(Range, PlaceholderType)>, + snapshot: BufferSnapshot, + include_warnings: bool, +) { + for (_, group) in snapshot.diagnostic_groups(None) { + let entry = &group.entries[group.primary_ix]; + collect_diagnostic(text, sections, entry, &snapshot, include_warnings) + } +} + +fn collect_diagnostic( + text: &mut String, + sections: &mut Vec<(Range, PlaceholderType)>, + entry: &DiagnosticEntry, + snapshot: &BufferSnapshot, + include_warnings: bool, +) { + const EXCERPT_EXPANSION_SIZE: u32 = 2; + const MAX_MESSAGE_LENGTH: usize = 2000; + + let ty = match entry.diagnostic.severity { + DiagnosticSeverity::WARNING => { + if !include_warnings { + return; + } + DiagnosticType::Warning + } + DiagnosticSeverity::ERROR => DiagnosticType::Error, + _ => return, + }; + let prev_len = text.len(); + + let range = entry.range.to_point(snapshot); + let diagnostic_row_number = range.start.row + 1; + + let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE); + let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1; + let excerpt_range = + Point::new(start_row, 0).to_offset(&snapshot)..Point::new(end_row, 0).to_offset(&snapshot); + + text.push_str("```"); + if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) { + text.push_str(&language_name); + } + text.push('\n'); + + let mut buffer_text = String::new(); + for chunk in snapshot.text_for_range(excerpt_range) { + buffer_text.push_str(chunk); + } + + for (i, line) in buffer_text.lines().enumerate() { + let line_number = start_row + i as u32 + 1; + writeln!(text, "{}", line).unwrap(); + + if line_number == diagnostic_row_number { + text.push_str("//"); + let prev_len = text.len(); + write!(text, " {}: ", ty.as_str()).unwrap(); + let padding = text.len() - prev_len; + + let message = util::truncate(&entry.diagnostic.message, MAX_MESSAGE_LENGTH) + .replace('\n', format!("\n//{:padding$}", "").as_str()); + + writeln!(text, "{message}").unwrap(); + } + } + + writeln!(text, "```").unwrap(); + sections.push(( + prev_len..text.len().saturating_sub(1), + PlaceholderType::Diagnostic(ty, entry.diagnostic.message.clone()), + )) +} + +#[derive(Clone)] +pub enum PlaceholderType { + Root(DiagnosticSummary, Option), + File(String), + Diagnostic(DiagnosticType, String), +} + +#[derive(Copy, Clone)] +pub enum DiagnosticType { + Warning, + Error, +} + +impl DiagnosticType { + pub fn as_str(&self) -> &'static str { + match self { + DiagnosticType::Warning => "warning", + DiagnosticType::Error => "error", + } + } +} diff --git a/crates/assistant/src/slash_command/docs_command.rs b/crates/assistant/src/slash_command/docs_command.rs new file mode 100644 index 00000000000000..6271bdc32e6b41 --- /dev/null +++ b/crates/assistant/src/slash_command/docs_command.rs @@ -0,0 +1,478 @@ +use std::path::Path; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, bail, Result}; +use assistant_slash_command::{ + ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection, +}; +use gpui::{AppContext, BackgroundExecutor, Model, Task, WeakView}; +use indexed_docs::{ + DocsDotRsProvider, IndexedDocsRegistry, IndexedDocsStore, LocalRustdocProvider, PackageName, + ProviderId, +}; +use language::LspAdapterDelegate; +use project::{Project, ProjectPath}; +use ui::prelude::*; +use util::{maybe, ResultExt}; +use workspace::Workspace; + +pub(crate) struct DocsSlashCommand; + +impl DocsSlashCommand { + pub const NAME: &'static str = "docs"; + + fn path_to_cargo_toml(project: Model, cx: &mut AppContext) -> Option> { + let worktree = project.read(cx).worktrees().next()?; + let worktree = worktree.read(cx); + let entry = worktree.entry_for_path("Cargo.toml")?; + let path = ProjectPath { + worktree_id: worktree.id(), + path: entry.path.clone(), + }; + Some(Arc::from( + project.read(cx).absolute_path(&path, cx)?.as_path(), + )) + } + + /// Ensures that the indexed doc providers for Rust are registered. + /// + /// Ideally we would do this sooner, but we need to wait until we're able to + /// access the workspace so we can read the project. + fn ensure_rust_doc_providers_are_registered( + &self, + workspace: Option>, + cx: &mut AppContext, + ) { + let indexed_docs_registry = IndexedDocsRegistry::global(cx); + if indexed_docs_registry + .get_provider_store(LocalRustdocProvider::id()) + .is_none() + { + let index_provider_deps = maybe!({ + let workspace = workspace.clone().ok_or_else(|| anyhow!("no workspace"))?; + let workspace = workspace + .upgrade() + .ok_or_else(|| anyhow!("workspace was dropped"))?; + let project = workspace.read(cx).project().clone(); + let fs = project.read(cx).fs().clone(); + let cargo_workspace_root = Self::path_to_cargo_toml(project, cx) + .and_then(|path| path.parent().map(|path| path.to_path_buf())) + .ok_or_else(|| anyhow!("no Cargo workspace root found"))?; + + anyhow::Ok((fs, cargo_workspace_root)) + }); + + if let Some((fs, cargo_workspace_root)) = index_provider_deps.log_err() { + indexed_docs_registry.register_provider(Box::new(LocalRustdocProvider::new( + fs, + cargo_workspace_root, + ))); + } + } + + if indexed_docs_registry + .get_provider_store(DocsDotRsProvider::id()) + .is_none() + { + let http_client = maybe!({ + let workspace = workspace.ok_or_else(|| anyhow!("no workspace"))?; + let workspace = workspace + .upgrade() + .ok_or_else(|| anyhow!("workspace was dropped"))?; + let project = workspace.read(cx).project().clone(); + anyhow::Ok(project.read(cx).client().http_client().clone()) + }); + + if let Some(http_client) = http_client.log_err() { + indexed_docs_registry + .register_provider(Box::new(DocsDotRsProvider::new(http_client))); + } + } + } + + /// Runs just-in-time indexing for a given package, in case the slash command + /// is run without any entries existing in the index. + fn run_just_in_time_indexing( + store: Arc, + key: String, + package: PackageName, + executor: BackgroundExecutor, + ) -> Task<()> { + executor.clone().spawn(async move { + let (prefix, needs_full_index) = if let Some((prefix, _)) = key.split_once('*') { + // If we have a wildcard in the search, we want to wait until + // we've completely finished indexing so we get a full set of + // results for the wildcard. + (prefix.to_string(), true) + } else { + (key, false) + }; + + // If we already have some entries, we assume that we've indexed the package before + // and don't need to do it again. + let has_any_entries = store + .any_with_prefix(prefix.clone()) + .await + .unwrap_or_default(); + if has_any_entries { + return (); + }; + + let index_task = store.clone().index(package.clone()); + + if needs_full_index { + _ = index_task.await; + } else { + loop { + executor.timer(Duration::from_millis(200)).await; + + if store + .any_with_prefix(prefix.clone()) + .await + .unwrap_or_default() + || !store.is_indexing(&package) + { + break; + } + } + } + }) + } +} + +impl SlashCommand for DocsSlashCommand { + fn name(&self) -> String { + Self::NAME.into() + } + + fn description(&self) -> String { + "insert docs".into() + } + + fn menu_text(&self) -> String { + "Insert Documentation".into() + } + + fn requires_argument(&self) -> bool { + true + } + + fn complete_argument( + self: Arc, + query: String, + _cancel: Arc, + workspace: Option>, + cx: &mut AppContext, + ) -> Task>> { + self.ensure_rust_doc_providers_are_registered(workspace, cx); + + let indexed_docs_registry = IndexedDocsRegistry::global(cx); + let args = DocsSlashCommandArgs::parse(&query); + let store = args + .provider() + .ok_or_else(|| anyhow!("no docs provider specified")) + .and_then(|provider| IndexedDocsStore::try_global(provider, cx)); + cx.background_executor().spawn(async move { + fn build_completions( + provider: ProviderId, + items: Vec, + ) -> Vec { + items + .into_iter() + .map(|item| ArgumentCompletion { + label: item.clone(), + new_text: format!("{provider} {item}"), + run_command: true, + }) + .collect() + } + + match args { + DocsSlashCommandArgs::NoProvider => { + let providers = indexed_docs_registry.list_providers(); + if providers.is_empty() { + return Ok(vec![ArgumentCompletion { + label: "No available docs providers.".to_string(), + new_text: String::new(), + run_command: false, + }]); + } + + Ok(providers + .into_iter() + .map(|provider| ArgumentCompletion { + label: provider.to_string(), + new_text: provider.to_string(), + run_command: false, + }) + .collect()) + } + DocsSlashCommandArgs::SearchPackageDocs { + provider, + package, + index, + } => { + let store = store?; + + if index { + // We don't need to hold onto this task, as the `IndexedDocsStore` will hold it + // until it completes. + let _ = store.clone().index(package.as_str().into()); + } + + let items = store.search(package).await; + Ok(build_completions(provider, items)) + } + DocsSlashCommandArgs::SearchItemDocs { + provider, + item_path, + .. + } => { + let store = store?; + let items = store.search(item_path).await; + Ok(build_completions(provider, items)) + } + } + }) + } + + fn run( + self: Arc, + argument: Option<&str>, + _workspace: WeakView, + _delegate: Arc, + cx: &mut WindowContext, + ) -> Task> { + let Some(argument) = argument else { + return Task::ready(Err(anyhow!("missing argument"))); + }; + + let args = DocsSlashCommandArgs::parse(argument); + let executor = cx.background_executor().clone(); + let task = cx.background_executor().spawn({ + let store = args + .provider() + .ok_or_else(|| anyhow!("no docs provider specified")) + .and_then(|provider| IndexedDocsStore::try_global(provider, cx)); + async move { + let (provider, key) = match args.clone() { + DocsSlashCommandArgs::NoProvider => bail!("no docs provider specified"), + DocsSlashCommandArgs::SearchPackageDocs { + provider, package, .. + } => (provider, package), + DocsSlashCommandArgs::SearchItemDocs { + provider, + item_path, + .. + } => (provider, item_path), + }; + + let store = store?; + + if let Some(package) = args.package() { + Self::run_just_in_time_indexing(store.clone(), key.clone(), package, executor) + .await; + } + + let (text, ranges) = if let Some((prefix, _)) = key.split_once('*') { + let docs = store.load_many_by_prefix(prefix.to_string()).await?; + + let mut text = String::new(); + let mut ranges = Vec::new(); + + for (key, docs) in docs { + let prev_len = text.len(); + + text.push_str(&docs.0); + text.push_str("\n"); + ranges.push((key, prev_len..text.len())); + text.push_str("\n"); + } + + (text, ranges) + } else { + let item_docs = store.load(key.clone()).await?; + let text = item_docs.to_string(); + let range = 0..text.len(); + + (text, vec![(key, range)]) + }; + + anyhow::Ok((provider, text, ranges)) + } + }); + + cx.foreground_executor().spawn(async move { + let (provider, text, ranges) = task.await?; + Ok(SlashCommandOutput { + text, + sections: ranges + .into_iter() + .map(|(key, range)| SlashCommandOutputSection { + range, + icon: IconName::FileDoc, + label: format!("docs ({provider}): {key}",).into(), + }) + .collect(), + run_commands_in_text: false, + }) + }) + } +} + +fn is_item_path_delimiter(char: char) -> bool { + !char.is_alphanumeric() && char != '-' && char != '_' +} + +#[derive(Debug, PartialEq, Clone)] +pub(crate) enum DocsSlashCommandArgs { + NoProvider, + SearchPackageDocs { + provider: ProviderId, + package: String, + index: bool, + }, + SearchItemDocs { + provider: ProviderId, + package: String, + item_path: String, + }, +} + +impl DocsSlashCommandArgs { + pub fn parse(argument: &str) -> Self { + let Some((provider, argument)) = argument.split_once(' ') else { + return Self::NoProvider; + }; + + let provider = ProviderId(provider.into()); + + if let Some((package, rest)) = argument.split_once(is_item_path_delimiter) { + if rest.trim().is_empty() { + Self::SearchPackageDocs { + provider, + package: package.to_owned(), + index: true, + } + } else { + Self::SearchItemDocs { + provider, + package: package.to_owned(), + item_path: argument.to_owned(), + } + } + } else { + Self::SearchPackageDocs { + provider, + package: argument.to_owned(), + index: false, + } + } + } + + pub fn provider(&self) -> Option { + match self { + Self::NoProvider => None, + Self::SearchPackageDocs { provider, .. } | Self::SearchItemDocs { provider, .. } => { + Some(provider.clone()) + } + } + } + + pub fn package(&self) -> Option { + match self { + Self::NoProvider => None, + Self::SearchPackageDocs { package, .. } | Self::SearchItemDocs { package, .. } => { + Some(package.as_str().into()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_docs_slash_command_args() { + assert_eq!( + DocsSlashCommandArgs::parse(""), + DocsSlashCommandArgs::NoProvider + ); + assert_eq!( + DocsSlashCommandArgs::parse("rustdoc"), + DocsSlashCommandArgs::NoProvider + ); + + assert_eq!( + DocsSlashCommandArgs::parse("rustdoc "), + DocsSlashCommandArgs::SearchPackageDocs { + provider: ProviderId("rustdoc".into()), + package: "".into(), + index: false + } + ); + assert_eq!( + DocsSlashCommandArgs::parse("gleam "), + DocsSlashCommandArgs::SearchPackageDocs { + provider: ProviderId("gleam".into()), + package: "".into(), + index: false + } + ); + + assert_eq!( + DocsSlashCommandArgs::parse("rustdoc gpui"), + DocsSlashCommandArgs::SearchPackageDocs { + provider: ProviderId("rustdoc".into()), + package: "gpui".into(), + index: false, + } + ); + assert_eq!( + DocsSlashCommandArgs::parse("gleam gleam_stdlib"), + DocsSlashCommandArgs::SearchPackageDocs { + provider: ProviderId("gleam".into()), + package: "gleam_stdlib".into(), + index: false + } + ); + + // Adding an item path delimiter indicates we can start indexing. + assert_eq!( + DocsSlashCommandArgs::parse("rustdoc gpui:"), + DocsSlashCommandArgs::SearchPackageDocs { + provider: ProviderId("rustdoc".into()), + package: "gpui".into(), + index: true, + } + ); + assert_eq!( + DocsSlashCommandArgs::parse("gleam gleam_stdlib/"), + DocsSlashCommandArgs::SearchPackageDocs { + provider: ProviderId("gleam".into()), + package: "gleam_stdlib".into(), + index: true + } + ); + + assert_eq!( + DocsSlashCommandArgs::parse("rustdoc gpui::foo::bar::Baz"), + DocsSlashCommandArgs::SearchItemDocs { + provider: ProviderId("rustdoc".into()), + package: "gpui".into(), + item_path: "gpui::foo::bar::Baz".into() + } + ); + assert_eq!( + DocsSlashCommandArgs::parse("gleam gleam_stdlib/gleam/int"), + DocsSlashCommandArgs::SearchItemDocs { + provider: ProviderId("gleam".into()), + package: "gleam_stdlib".into(), + item_path: "gleam_stdlib/gleam/int".into() + } + ); + } +} diff --git a/crates/assistant/src/slash_command/fetch_command.rs b/crates/assistant/src/slash_command/fetch_command.rs new file mode 100644 index 00000000000000..c99bf438fbc206 --- /dev/null +++ b/crates/assistant/src/slash_command/fetch_command.rs @@ -0,0 +1,165 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use anyhow::{anyhow, bail, Context, Result}; +use assistant_slash_command::{ + ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection, +}; +use futures::AsyncReadExt; +use gpui::{AppContext, Task, WeakView}; +use html_to_markdown::{convert_html_to_markdown, markdown, TagHandler}; +use http::{AsyncBody, HttpClient, HttpClientWithUrl}; +use language::LspAdapterDelegate; +use ui::prelude::*; +use workspace::Workspace; + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +enum ContentType { + Html, + Plaintext, + Json, +} + +pub(crate) struct FetchSlashCommand; + +impl FetchSlashCommand { + async fn build_message(http_client: Arc, url: &str) -> Result { + let mut url = url.to_owned(); + if !url.starts_with("https://") && !url.starts_with("http://") { + url = format!("https://{url}"); + } + + let mut response = http_client.get(&url, AsyncBody::default(), true).await?; + + let mut body = Vec::new(); + response + .body_mut() + .read_to_end(&mut body) + .await + .context("error reading response body")?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let Some(content_type) = response.headers().get("content-type") else { + bail!("missing Content-Type header"); + }; + let content_type = content_type + .to_str() + .context("invalid Content-Type header")?; + let content_type = match content_type { + "text/html" => ContentType::Html, + "text/plain" => ContentType::Plaintext, + "application/json" => ContentType::Json, + _ => ContentType::Html, + }; + + match content_type { + ContentType::Html => { + let mut handlers: Vec = vec![ + Rc::new(RefCell::new(markdown::WebpageChromeRemover)), + Rc::new(RefCell::new(markdown::ParagraphHandler)), + Rc::new(RefCell::new(markdown::HeadingHandler)), + Rc::new(RefCell::new(markdown::ListHandler)), + Rc::new(RefCell::new(markdown::TableHandler::new())), + Rc::new(RefCell::new(markdown::StyledTextHandler)), + ]; + if url.contains("wikipedia.org") { + use html_to_markdown::structure::wikipedia; + + handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover))); + handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler))); + handlers.push(Rc::new( + RefCell::new(wikipedia::WikipediaCodeHandler::new()), + )); + } else { + handlers.push(Rc::new(RefCell::new(markdown::CodeHandler))); + } + + convert_html_to_markdown(&body[..], &mut handlers) + } + ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()), + ContentType::Json => { + let json: serde_json::Value = serde_json::from_slice(&body)?; + + Ok(format!( + "```json\n{}\n```", + serde_json::to_string_pretty(&json)? + )) + } + } + } +} + +impl SlashCommand for FetchSlashCommand { + fn name(&self) -> String { + "fetch".into() + } + + fn description(&self) -> String { + "insert URL contents".into() + } + + fn menu_text(&self) -> String { + "Insert fetched URL contents".into() + } + + fn requires_argument(&self) -> bool { + true + } + + fn complete_argument( + self: Arc, + _query: String, + _cancel: Arc, + _workspace: Option>, + _cx: &mut AppContext, + ) -> Task>> { + Task::ready(Ok(Vec::new())) + } + + fn run( + self: Arc, + argument: Option<&str>, + workspace: WeakView, + _delegate: Arc, + cx: &mut WindowContext, + ) -> Task> { + let Some(argument) = argument else { + return Task::ready(Err(anyhow!("missing URL"))); + }; + let Some(workspace) = workspace.upgrade() else { + return Task::ready(Err(anyhow!("workspace was dropped"))); + }; + + let http_client = workspace.read(cx).client().http_client(); + let url = argument.to_string(); + + let text = cx.background_executor().spawn({ + let url = url.clone(); + async move { Self::build_message(http_client, &url).await } + }); + + let url = SharedString::from(url); + cx.foreground_executor().spawn(async move { + let text = text.await?; + let range = 0..text.len(); + Ok(SlashCommandOutput { + text, + sections: vec![SlashCommandOutputSection { + range, + icon: IconName::AtSign, + label: format!("fetch {}", url).into(), + }], + run_commands_in_text: false, + }) + }) + } +} diff --git a/crates/assistant/src/slash_command/file_command.rs b/crates/assistant/src/slash_command/file_command.rs index e9b9b4060bf72f..d5d5662914a52f 100644 --- a/crates/assistant/src/slash_command/file_command.rs +++ b/crates/assistant/src/slash_command/file_command.rs @@ -1,64 +1,84 @@ -use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation}; -use anyhow::Result; -use futures::channel::oneshot; +use super::{diagnostics_command::write_single_file_diagnostics, SlashCommand, SlashCommandOutput}; +use anyhow::{anyhow, Result}; +use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection}; use fuzzy::PathMatch; -use gpui::{AppContext, Model, Task}; -use language::LspAdapterDelegate; +use gpui::{AppContext, Model, Task, View, WeakView}; +use language::{BufferSnapshot, LineEnding, LspAdapterDelegate}; use project::{PathMatchCandidateSet, Project}; use std::{ - path::Path, + fmt::Write, + ops::Range, + path::{Path, PathBuf}, sync::{atomic::AtomicBool, Arc}, }; +use ui::prelude::*; +use util::{paths::PathMatcher, ResultExt}; +use workspace::Workspace; -pub(crate) struct FileSlashCommand { - project: Model, -} +pub(crate) struct FileSlashCommand; impl FileSlashCommand { - pub fn new(project: Model) -> Self { - Self { project } - } - fn search_paths( &self, query: String, cancellation_flag: Arc, + workspace: &View, cx: &mut AppContext, ) -> Task> { - let worktrees = self - .project - .read(cx) - .visible_worktrees(cx) - .collect::>(); - let include_root_name = worktrees.len() > 1; - let candidate_sets = worktrees - .into_iter() - .map(|worktree| { - let worktree = worktree.read(cx); - PathMatchCandidateSet { - snapshot: worktree.snapshot(), - include_ignored: worktree - .root_entry() - .map_or(false, |entry| entry.is_ignored), - include_root_name, - directories_only: false, - } - }) - .collect::>(); - - let executor = cx.background_executor().clone(); - cx.foreground_executor().spawn(async move { - fuzzy::match_path_sets( - candidate_sets.as_slice(), - query.as_str(), - None, - false, - 100, - &cancellation_flag, - executor, + if query.is_empty() { + let workspace = workspace.read(cx); + let project = workspace.project().read(cx); + let entries = workspace.recent_navigation_history(Some(10), cx); + let path_prefix: Arc = "".into(); + Task::ready( + entries + .into_iter() + .filter_map(|(entry, _)| { + let worktree = project.worktree_for_id(entry.worktree_id, cx)?; + let mut full_path = PathBuf::from(worktree.read(cx).root_name()); + full_path.push(&entry.path); + Some(PathMatch { + score: 0., + positions: Vec::new(), + worktree_id: entry.worktree_id.to_usize(), + path: full_path.into(), + path_prefix: path_prefix.clone(), + distance_to_relative_ancestor: 0, + }) + }) + .collect(), ) - .await - }) + } else { + let worktrees = workspace.read(cx).visible_worktrees(cx).collect::>(); + let candidate_sets = worktrees + .into_iter() + .map(|worktree| { + let worktree = worktree.read(cx); + PathMatchCandidateSet { + snapshot: worktree.snapshot(), + include_ignored: worktree + .root_entry() + .map_or(false, |entry| entry.is_ignored), + include_root_name: true, + candidates: project::Candidates::Entries, + } + }) + .collect::>(); + + let executor = cx.background_executor().clone(); + cx.foreground_executor().spawn(async move { + fuzzy::match_path_sets( + candidate_sets.as_slice(), + query.as_str(), + None, + false, + 100, + &cancellation_flag, + executor, + ) + .await + }) + } } } @@ -68,7 +88,11 @@ impl SlashCommand for FileSlashCommand { } fn description(&self) -> String { - "insert an entire file".into() + "insert file".into() + } + + fn menu_text(&self) -> String { + "Insert File".into() } fn requires_argument(&self) -> bool { @@ -76,22 +100,33 @@ impl SlashCommand for FileSlashCommand { } fn complete_argument( - &self, + self: Arc, query: String, cancellation_flag: Arc, + workspace: Option>, cx: &mut AppContext, - ) -> gpui::Task>> { - let paths = self.search_paths(query, cancellation_flag, cx); + ) -> Task>> { + let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else { + return Task::ready(Err(anyhow!("workspace was dropped"))); + }; + + let paths = self.search_paths(query, cancellation_flag, &workspace, cx); cx.background_executor().spawn(async move { Ok(paths .await .into_iter() .map(|path_match| { - format!( + let text = format!( "{}{}", path_match.path_prefix, path_match.path.to_string_lossy() - ) + ); + + ArgumentCompletion { + label: text.clone(), + new_text: text, + run_command: true, + } }) .collect()) }) @@ -100,52 +135,237 @@ impl SlashCommand for FileSlashCommand { fn run( self: Arc, argument: Option<&str>, + workspace: WeakView, _delegate: Arc, - cx: &mut AppContext, - ) -> SlashCommandInvocation { - let project = self.project.read(cx); - let Some(argument) = argument else { - return SlashCommandInvocation { - output: Task::ready(Err(anyhow::anyhow!("missing path"))), - invalidated: oneshot::channel().1, - cleanup: SlashCommandCleanup::default(), - }; + cx: &mut WindowContext, + ) -> Task> { + let Some(workspace) = workspace.upgrade() else { + return Task::ready(Err(anyhow!("workspace was dropped"))); }; - let path = Path::new(argument); - let abs_path = project.worktrees().find_map(|worktree| { - let worktree = worktree.read(cx); - worktree.entry_for_path(path)?; - worktree.absolutize(path).ok() - }); - - let Some(abs_path) = abs_path else { - return SlashCommandInvocation { - output: Task::ready(Err(anyhow::anyhow!("missing path"))), - invalidated: oneshot::channel().1, - cleanup: SlashCommandCleanup::default(), - }; + let Some(argument) = argument else { + return Task::ready(Err(anyhow!("missing path"))); }; - let fs = project.fs().clone(); - let argument = argument.to_string(); - let output = cx.background_executor().spawn(async move { - let content = fs.load(&abs_path).await?; - let mut output = String::with_capacity(argument.len() + content.len() + 9); - output.push_str("```"); - output.push_str(&argument); - output.push('\n'); - output.push_str(&content); - if !output.ends_with('\n') { - output.push('\n'); + let task = collect_files(workspace.read(cx).project().clone(), argument, cx); + + cx.foreground_executor().spawn(async move { + let (text, ranges) = task.await?; + Ok(SlashCommandOutput { + text, + sections: ranges + .into_iter() + .map(|(range, path, entry_type)| { + build_entry_output_section( + range, + Some(&path), + entry_type == EntryType::Directory, + None, + ) + }) + .collect(), + run_commands_in_text: true, + }) + }) + } +} + +#[derive(Clone, Copy, PartialEq)] +enum EntryType { + File, + Directory, +} + +fn collect_files( + project: Model, + glob_input: &str, + cx: &mut AppContext, +) -> Task, PathBuf, EntryType)>)>> { + let Ok(matcher) = PathMatcher::new(&[glob_input.to_owned()]) else { + return Task::ready(Err(anyhow!("invalid path"))); + }; + + let project_handle = project.downgrade(); + let snapshots = project + .read(cx) + .worktrees() + .map(|worktree| worktree.read(cx).snapshot()) + .collect::>(); + cx.spawn(|mut cx| async move { + let mut text = String::new(); + let mut ranges = Vec::new(); + for snapshot in snapshots { + let worktree_id = snapshot.id(); + let mut directory_stack: Vec<(Arc, String, usize)> = Vec::new(); + let mut folded_directory_names_stack = Vec::new(); + let mut is_top_level_directory = true; + for entry in snapshot.entries(false, 0) { + let mut path_including_worktree_name = PathBuf::new(); + path_including_worktree_name.push(snapshot.root_name()); + path_including_worktree_name.push(&entry.path); + if !matcher.is_match(&path_including_worktree_name) { + continue; + } + + while let Some((dir, _, _)) = directory_stack.last() { + if entry.path.starts_with(dir) { + break; + } + let (_, entry_name, start) = directory_stack.pop().unwrap(); + ranges.push(( + start..text.len().saturating_sub(1), + PathBuf::from(entry_name), + EntryType::Directory, + )); + } + + let filename = entry + .path + .file_name() + .unwrap_or_default() + .to_str() + .unwrap_or_default() + .to_string(); + + if entry.is_dir() { + // Auto-fold directories that contain no files + let mut child_entries = snapshot.child_entries(&entry.path); + if let Some(child) = child_entries.next() { + if child_entries.next().is_none() && child.kind.is_dir() { + if is_top_level_directory { + is_top_level_directory = false; + folded_directory_names_stack.push( + path_including_worktree_name.to_string_lossy().to_string(), + ); + } else { + folded_directory_names_stack.push(filename.to_string()); + } + continue; + } + } else { + // Skip empty directories + folded_directory_names_stack.clear(); + continue; + } + let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/"); + let entry_start = text.len(); + if prefix_paths.is_empty() { + if is_top_level_directory { + text.push_str(&path_including_worktree_name.to_string_lossy()); + is_top_level_directory = false; + } else { + text.push_str(&filename); + } + directory_stack.push((entry.path.clone(), filename, entry_start)); + } else { + let entry_name = format!("{}/{}", prefix_paths, &filename); + text.push_str(&entry_name); + directory_stack.push((entry.path.clone(), entry_name, entry_start)); + } + text.push('\n'); + } else if entry.is_file() { + let Some(open_buffer_task) = project_handle + .update(&mut cx, |project, cx| { + project.open_buffer((worktree_id, &entry.path), cx) + }) + .ok() + else { + continue; + }; + if let Some(buffer) = open_buffer_task.await.log_err() { + let snapshot = cx.read_model(&buffer, |buffer, _| buffer.snapshot())?; + let prev_len = text.len(); + collect_file_content(&mut text, &snapshot, filename.clone()); + text.push('\n'); + if !write_single_file_diagnostics( + &mut text, + Some(&path_including_worktree_name), + &snapshot, + ) { + text.pop(); + } + ranges.push(( + prev_len..text.len(), + PathBuf::from(filename), + EntryType::File, + )); + text.push('\n'); + } + } } - output.push_str("```"); - Ok(output) - }); - SlashCommandInvocation { - output, - invalidated: oneshot::channel().1, - cleanup: SlashCommandCleanup::default(), + + while let Some((dir, _, start)) = directory_stack.pop() { + let mut root_path = PathBuf::new(); + root_path.push(snapshot.root_name()); + root_path.push(&dir); + ranges.push((start..text.len(), root_path, EntryType::Directory)); + } + } + Ok((text, ranges)) + }) +} + +fn collect_file_content(buffer: &mut String, snapshot: &BufferSnapshot, filename: String) { + let mut content = snapshot.text(); + LineEnding::normalize(&mut content); + buffer.reserve(filename.len() + content.len() + 9); + buffer.push_str(&codeblock_fence_for_path( + Some(&PathBuf::from(filename)), + None, + )); + buffer.push_str(&content); + if !buffer.ends_with('\n') { + buffer.push('\n'); + } + buffer.push_str("```"); +} + +pub fn codeblock_fence_for_path(path: Option<&Path>, row_range: Option>) -> String { + let mut text = String::new(); + write!(text, "```").unwrap(); + + if let Some(path) = path { + if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) { + write!(text, "{} ", extension).unwrap(); } + + write!(text, "{}", path.display()).unwrap(); + } else { + write!(text, "untitled").unwrap(); + } + + if let Some(row_range) = row_range { + write!(text, ":{}-{}", row_range.start + 1, row_range.end + 1).unwrap(); + } + + text.push('\n'); + text +} + +pub fn build_entry_output_section( + range: Range, + path: Option<&Path>, + is_directory: bool, + line_range: Option>, +) -> SlashCommandOutputSection { + let mut label = if let Some(path) = path { + path.to_string_lossy().to_string() + } else { + "untitled".to_string() + }; + if let Some(line_range) = line_range { + write!(label, ":{}-{}", line_range.start, line_range.end).unwrap(); + } + + let icon = if is_directory { + IconName::Folder + } else { + IconName::File + }; + + SlashCommandOutputSection { + range, + icon, + label: label.into(), } } diff --git a/crates/assistant/src/slash_command/now_command.rs b/crates/assistant/src/slash_command/now_command.rs new file mode 100644 index 00000000000000..73f4f7b2565f83 --- /dev/null +++ b/crates/assistant/src/slash_command/now_command.rs @@ -0,0 +1,64 @@ +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use anyhow::Result; +use assistant_slash_command::{ + ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection, +}; +use chrono::Local; +use gpui::{AppContext, Task, WeakView}; +use language::LspAdapterDelegate; +use ui::prelude::*; +use workspace::Workspace; + +pub(crate) struct NowSlashCommand; + +impl SlashCommand for NowSlashCommand { + fn name(&self) -> String { + "now".into() + } + + fn description(&self) -> String { + "insert the current date and time".into() + } + + fn menu_text(&self) -> String { + "Insert Current Date and Time".into() + } + + fn requires_argument(&self) -> bool { + false + } + + fn complete_argument( + self: Arc, + _query: String, + _cancel: Arc, + _workspace: Option>, + _cx: &mut AppContext, + ) -> Task>> { + Task::ready(Ok(Vec::new())) + } + + fn run( + self: Arc, + _argument: Option<&str>, + _workspace: WeakView, + _delegate: Arc, + _cx: &mut WindowContext, + ) -> Task> { + let now = Local::now(); + let text = format!("Today is {now}.", now = now.to_rfc2822()); + let range = 0..text.len(); + + Task::ready(Ok(SlashCommandOutput { + text, + sections: vec![SlashCommandOutputSection { + range, + icon: IconName::CountdownTimer, + label: now.to_rfc2822().into(), + }], + run_commands_in_text: false, + })) + } +} diff --git a/crates/assistant/src/slash_command/project_command.rs b/crates/assistant/src/slash_command/project_command.rs new file mode 100644 index 00000000000000..476e60c5d48fe1 --- /dev/null +++ b/crates/assistant/src/slash_command/project_command.rs @@ -0,0 +1,150 @@ +use super::{SlashCommand, SlashCommandOutput}; +use anyhow::{anyhow, Context, Result}; +use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection}; +use fs::Fs; +use gpui::{AppContext, Model, Task, WeakView}; +use language::LspAdapterDelegate; +use project::{Project, ProjectPath}; +use std::{ + fmt::Write, + path::Path, + sync::{atomic::AtomicBool, Arc}, +}; +use ui::prelude::*; +use workspace::Workspace; + +pub(crate) struct ProjectSlashCommand; + +impl ProjectSlashCommand { + async fn build_message(fs: Arc, path_to_cargo_toml: &Path) -> Result { + let buffer = fs.load(path_to_cargo_toml).await?; + let cargo_toml: cargo_toml::Manifest = toml::from_str(&buffer)?; + + let mut message = String::new(); + writeln!(message, "You are in a Rust project.")?; + + if let Some(workspace) = cargo_toml.workspace { + writeln!( + message, + "The project is a Cargo workspace with the following members:" + )?; + for member in workspace.members { + writeln!(message, "- {member}")?; + } + + if !workspace.default_members.is_empty() { + writeln!(message, "The default members are:")?; + for member in workspace.default_members { + writeln!(message, "- {member}")?; + } + } + + if !workspace.dependencies.is_empty() { + writeln!( + message, + "The following workspace dependencies are installed:" + )?; + for dependency in workspace.dependencies.keys() { + writeln!(message, "- {dependency}")?; + } + } + } else if let Some(package) = cargo_toml.package { + writeln!( + message, + "The project name is \"{name}\".", + name = package.name + )?; + + let description = package + .description + .as_ref() + .and_then(|description| description.get().ok().cloned()); + if let Some(description) = description.as_ref() { + writeln!(message, "It describes itself as \"{description}\".")?; + } + + if !cargo_toml.dependencies.is_empty() { + writeln!(message, "The following dependencies are installed:")?; + for dependency in cargo_toml.dependencies.keys() { + writeln!(message, "- {dependency}")?; + } + } + } + + Ok(message) + } + + fn path_to_cargo_toml(project: Model, cx: &mut AppContext) -> Option> { + let worktree = project.read(cx).worktrees().next()?; + let worktree = worktree.read(cx); + let entry = worktree.entry_for_path("Cargo.toml")?; + let path = ProjectPath { + worktree_id: worktree.id(), + path: entry.path.clone(), + }; + Some(Arc::from( + project.read(cx).absolute_path(&path, cx)?.as_path(), + )) + } +} + +impl SlashCommand for ProjectSlashCommand { + fn name(&self) -> String { + "project".into() + } + + fn description(&self) -> String { + "insert project metadata".into() + } + + fn menu_text(&self) -> String { + "Insert Project Metadata".into() + } + + fn complete_argument( + self: Arc, + _query: String, + _cancel: Arc, + _workspace: Option>, + _cx: &mut AppContext, + ) -> Task>> { + Task::ready(Err(anyhow!("this command does not require argument"))) + } + + fn requires_argument(&self) -> bool { + false + } + + fn run( + self: Arc, + _argument: Option<&str>, + workspace: WeakView, + _delegate: Arc, + cx: &mut WindowContext, + ) -> Task> { + let output = workspace.update(cx, |workspace, cx| { + let project = workspace.project().clone(); + let fs = workspace.project().read(cx).fs().clone(); + let path = Self::path_to_cargo_toml(project, cx); + let output = cx.background_executor().spawn(async move { + let path = path.with_context(|| "Cargo.toml not found")?; + Self::build_message(fs, &path).await + }); + + cx.foreground_executor().spawn(async move { + let text = output.await?; + let range = 0..text.len(); + Ok(SlashCommandOutput { + text, + sections: vec![SlashCommandOutputSection { + range, + icon: IconName::FileTree, + label: "Project".into(), + }], + run_commands_in_text: false, + }) + }) + }); + output.unwrap_or_else(|error| Task::ready(Err(error))) + } +} diff --git a/crates/assistant/src/slash_command/prompt_command.rs b/crates/assistant/src/slash_command/prompt_command.rs index 37b73013da637d..1edf2d51df084f 100644 --- a/crates/assistant/src/slash_command/prompt_command.rs +++ b/crates/assistant/src/slash_command/prompt_command.rs @@ -1,21 +1,14 @@ -use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation}; -use crate::prompts::prompt_library::PromptLibrary; +use super::{SlashCommand, SlashCommandOutput}; +use crate::prompt_library::PromptStore; use anyhow::{anyhow, Context, Result}; -use futures::channel::oneshot; -use fuzzy::StringMatchCandidate; -use gpui::{AppContext, Task}; +use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection}; +use gpui::{AppContext, Task, WeakView}; use language::LspAdapterDelegate; use std::sync::{atomic::AtomicBool, Arc}; +use ui::prelude::*; +use workspace::Workspace; -pub(crate) struct PromptSlashCommand { - library: Arc, -} - -impl PromptSlashCommand { - pub fn new(library: Arc) -> Self { - Self { library } - } -} +pub(crate) struct PromptSlashCommand; impl SlashCommand for PromptSlashCommand { fn name(&self) -> String { @@ -23,7 +16,11 @@ impl SlashCommand for PromptSlashCommand { } fn description(&self) -> String { - "insert a prompt from the library".into() + "insert prompt from library".into() + } + + fn menu_text(&self) -> String { + "Insert Prompt from Library".into() } fn requires_argument(&self) -> bool { @@ -31,32 +28,25 @@ impl SlashCommand for PromptSlashCommand { } fn complete_argument( - &self, + self: Arc, query: String, - cancellation_flag: Arc, + _cancellation_flag: Arc, + _workspace: Option>, cx: &mut AppContext, - ) -> Task>> { - let library = self.library.clone(); - let executor = cx.background_executor().clone(); + ) -> Task>> { + let store = PromptStore::global(cx); cx.background_executor().spawn(async move { - let candidates = library - .prompts() - .into_iter() - .enumerate() - .map(|(ix, prompt)| StringMatchCandidate::new(ix, prompt.1.title().to_string())) - .collect::>(); - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - 100, - &cancellation_flag, - executor, - ) - .await; - Ok(matches + let prompts = store.await?.search(query).await; + Ok(prompts .into_iter() - .map(|mat| candidates[mat.candidate_id].string.clone()) + .filter_map(|prompt| { + let prompt_title = prompt.title?.to_string(); + Some(ArgumentCompletion { + label: prompt_title.clone(), + new_text: prompt_title, + run_command: true, + }) + }) .collect()) }) } @@ -64,32 +54,42 @@ impl SlashCommand for PromptSlashCommand { fn run( self: Arc, title: Option<&str>, + _workspace: WeakView, _delegate: Arc, - cx: &mut AppContext, - ) -> SlashCommandInvocation { + cx: &mut WindowContext, + ) -> Task> { let Some(title) = title else { - return SlashCommandInvocation { - output: Task::ready(Err(anyhow!("missing prompt name"))), - invalidated: oneshot::channel().1, - cleanup: SlashCommandCleanup::default(), - }; + return Task::ready(Err(anyhow!("missing prompt name"))); }; - let library = self.library.clone(); - let title = title.to_string(); - let output = cx.background_executor().spawn(async move { - let prompt = library - .prompts() - .into_iter() - .find(|prompt| &prompt.1.title().to_string() == &title) - .with_context(|| format!("no prompt found with title {:?}", title))? - .1; - Ok(prompt.body()) + let store = PromptStore::global(cx); + let title = SharedString::from(title.to_string()); + let prompt = cx.background_executor().spawn({ + let title = title.clone(); + async move { + let store = store.await?; + let prompt_id = store + .id_for_title(&title) + .with_context(|| format!("no prompt found with title {:?}", title))?; + let body = store.load(prompt_id).await?; + anyhow::Ok(body) + } }); - SlashCommandInvocation { - output, - invalidated: oneshot::channel().1, - cleanup: SlashCommandCleanup::default(), - } + cx.foreground_executor().spawn(async move { + let mut prompt = prompt.await?; + if prompt.is_empty() { + prompt.push('\n'); + } + let range = 0..prompt.len(); + Ok(SlashCommandOutput { + text: prompt, + sections: vec![SlashCommandOutputSection { + range, + icon: IconName::Library, + label: title, + }], + run_commands_in_text: true, + }) + }) } } diff --git a/crates/assistant/src/slash_command/search_command.rs b/crates/assistant/src/slash_command/search_command.rs new file mode 100644 index 00000000000000..cdf1da7a9b7c9b --- /dev/null +++ b/crates/assistant/src/slash_command/search_command.rs @@ -0,0 +1,174 @@ +use super::{ + create_label_for_command, + file_command::{build_entry_output_section, codeblock_fence_for_path}, + SlashCommand, SlashCommandOutput, +}; +use anyhow::Result; +use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection}; +use gpui::{AppContext, Task, WeakView}; +use language::{CodeLabel, LineEnding, LspAdapterDelegate}; +use semantic_index::SemanticIndex; +use std::{ + fmt::Write, + path::PathBuf, + sync::{atomic::AtomicBool, Arc}, +}; +use ui::{prelude::*, IconName}; +use util::ResultExt; +use workspace::Workspace; + +pub(crate) struct SearchSlashCommand; + +impl SlashCommand for SearchSlashCommand { + fn name(&self) -> String { + "search".into() + } + + fn label(&self, cx: &AppContext) -> CodeLabel { + create_label_for_command("search", &["--n"], cx) + } + + fn description(&self) -> String { + "semantic search".into() + } + + fn menu_text(&self) -> String { + "Semantic Search".into() + } + + fn requires_argument(&self) -> bool { + true + } + + fn complete_argument( + self: Arc, + _query: String, + _cancel: Arc, + _workspace: Option>, + _cx: &mut AppContext, + ) -> Task>> { + Task::ready(Ok(Vec::new())) + } + + fn run( + self: Arc, + argument: Option<&str>, + workspace: WeakView, + _delegate: Arc, + cx: &mut WindowContext, + ) -> Task> { + let Some(workspace) = workspace.upgrade() else { + return Task::ready(Err(anyhow::anyhow!("workspace was dropped"))); + }; + let Some(argument) = argument else { + return Task::ready(Err(anyhow::anyhow!("missing search query"))); + }; + + let mut limit = None; + let mut query = String::new(); + for part in argument.split(' ') { + if let Some(parameter) = part.strip_prefix("--") { + if let Ok(count) = parameter.parse::() { + limit = Some(count); + continue; + } + } + + query.push_str(part); + query.push(' '); + } + query.pop(); + + if query.is_empty() { + return Task::ready(Err(anyhow::anyhow!("missing search query"))); + } + + let project = workspace.read(cx).project().clone(); + let fs = project.read(cx).fs().clone(); + let project_index = + cx.update_global(|index: &mut SemanticIndex, cx| index.project_index(project, cx)); + + cx.spawn(|cx| async move { + let results = project_index + .read_with(&cx, |project_index, cx| { + project_index.search(query.clone(), limit.unwrap_or(5), cx) + })? + .await?; + + let mut loaded_results = Vec::new(); + for result in results { + let (full_path, file_content) = + result.worktree.read_with(&cx, |worktree, _cx| { + let entry_abs_path = worktree.abs_path().join(&result.path); + let mut entry_full_path = PathBuf::from(worktree.root_name()); + entry_full_path.push(&result.path); + let file_content = async { + let entry_abs_path = entry_abs_path; + fs.load(&entry_abs_path).await + }; + (entry_full_path, file_content) + })?; + if let Some(file_content) = file_content.await.log_err() { + loaded_results.push((result, full_path, file_content)); + } + } + + let output = cx + .background_executor() + .spawn(async move { + let mut text = format!("Search results for {query}:\n"); + let mut sections = Vec::new(); + for (result, full_path, file_content) in loaded_results { + let range_start = result.range.start.min(file_content.len()); + let range_end = result.range.end.min(file_content.len()); + + let start_row = file_content[0..range_start].matches('\n').count() as u32; + let end_row = file_content[0..range_end].matches('\n').count() as u32; + let start_line_byte_offset = file_content[0..range_start] + .rfind('\n') + .map(|pos| pos + 1) + .unwrap_or_default(); + let end_line_byte_offset = file_content[range_end..] + .find('\n') + .map(|pos| range_end + pos) + .unwrap_or_else(|| file_content.len()); + + let section_start_ix = text.len(); + text.push_str(&codeblock_fence_for_path( + Some(&result.path), + Some(start_row..end_row), + )); + + let mut excerpt = + file_content[start_line_byte_offset..end_line_byte_offset].to_string(); + LineEnding::normalize(&mut excerpt); + text.push_str(&excerpt); + writeln!(text, "\n```\n").unwrap(); + let section_end_ix = text.len() - 1; + sections.push(build_entry_output_section( + section_start_ix..section_end_ix, + Some(&full_path), + false, + Some(start_row + 1..end_row + 1), + )); + } + + let query = SharedString::from(query); + sections.push(SlashCommandOutputSection { + range: 0..text.len(), + icon: IconName::MagnifyingGlass, + label: query, + }); + + SlashCommandOutput { + text, + sections, + run_commands_in_text: false, + } + }) + .await; + + Ok(output) + }) + } +} diff --git a/crates/assistant/src/slash_command/symbols_command.rs b/crates/assistant/src/slash_command/symbols_command.rs new file mode 100644 index 00000000000000..11a056f0daf5cb --- /dev/null +++ b/crates/assistant/src/slash_command/symbols_command.rs @@ -0,0 +1,89 @@ +use super::{SlashCommand, SlashCommandOutput}; +use anyhow::{anyhow, Context as _, Result}; +use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection}; +use editor::Editor; +use gpui::{AppContext, Task, WeakView}; +use language::LspAdapterDelegate; +use std::sync::Arc; +use std::{path::Path, sync::atomic::AtomicBool}; +use ui::{IconName, WindowContext}; +use workspace::Workspace; + +pub(crate) struct OutlineSlashCommand; + +impl SlashCommand for OutlineSlashCommand { + fn name(&self) -> String { + "symbols".into() + } + + fn description(&self) -> String { + "insert symbols for active tab".into() + } + + fn menu_text(&self) -> String { + "Insert Symbols for Active Tab".into() + } + + fn complete_argument( + self: Arc, + _query: String, + _cancel: Arc, + _workspace: Option>, + _cx: &mut AppContext, + ) -> Task>> { + Task::ready(Err(anyhow!("this command does not require argument"))) + } + + fn requires_argument(&self) -> bool { + false + } + + fn run( + self: Arc, + _argument: Option<&str>, + workspace: WeakView, + _delegate: Arc, + cx: &mut WindowContext, + ) -> Task> { + let output = workspace.update(cx, |workspace, cx| { + let Some(active_item) = workspace.active_item(cx) else { + return Task::ready(Err(anyhow!("no active tab"))); + }; + let Some(buffer) = active_item + .downcast::() + .and_then(|editor| editor.read(cx).buffer().read(cx).as_singleton()) + else { + return Task::ready(Err(anyhow!("active tab is not an editor"))); + }; + + let snapshot = buffer.read(cx).snapshot(); + let path = snapshot.resolve_file_path(cx, true); + + cx.background_executor().spawn(async move { + let outline = snapshot + .outline(None) + .context("no symbols for active tab")?; + + let path = path.as_deref().unwrap_or(Path::new("untitled")); + let mut outline_text = format!("Symbols for {}:\n", path.display()); + for item in &outline.path_candidates { + outline_text.push_str("- "); + outline_text.push_str(&item.string); + outline_text.push('\n'); + } + + Ok(SlashCommandOutput { + sections: vec![SlashCommandOutputSection { + range: 0..outline_text.len(), + icon: IconName::ListTree, + label: path.to_string_lossy().to_string().into(), + }], + text: outline_text, + run_commands_in_text: false, + }) + }) + }); + + output.unwrap_or_else(|error| Task::ready(Err(error))) + } +} diff --git a/crates/assistant/src/slash_command/tabs_command.rs b/crates/assistant/src/slash_command/tabs_command.rs new file mode 100644 index 00000000000000..78be293bd7c7d1 --- /dev/null +++ b/crates/assistant/src/slash_command/tabs_command.rs @@ -0,0 +1,118 @@ +use super::{ + diagnostics_command::write_single_file_diagnostics, + file_command::{build_entry_output_section, codeblock_fence_for_path}, + SlashCommand, SlashCommandOutput, +}; +use anyhow::{anyhow, Result}; +use assistant_slash_command::ArgumentCompletion; +use collections::HashMap; +use editor::Editor; +use gpui::{AppContext, Entity, Task, WeakView}; +use language::LspAdapterDelegate; +use std::{fmt::Write, sync::Arc}; +use ui::WindowContext; +use workspace::Workspace; + +pub(crate) struct TabsSlashCommand; + +impl SlashCommand for TabsSlashCommand { + fn name(&self) -> String { + "tabs".into() + } + + fn description(&self) -> String { + "insert open tabs".into() + } + + fn menu_text(&self) -> String { + "Insert Open Tabs".into() + } + + fn requires_argument(&self) -> bool { + false + } + + fn complete_argument( + self: Arc, + _query: String, + _cancel: Arc, + _workspace: Option>, + _cx: &mut AppContext, + ) -> Task>> { + Task::ready(Err(anyhow!("this command does not require argument"))) + } + + fn run( + self: Arc, + _argument: Option<&str>, + workspace: WeakView, + _delegate: Arc, + cx: &mut WindowContext, + ) -> Task> { + let open_buffers = workspace.update(cx, |workspace, cx| { + let mut timestamps_by_entity_id = HashMap::default(); + let mut open_buffers = Vec::new(); + + for pane in workspace.panes() { + let pane = pane.read(cx); + for entry in pane.activation_history() { + timestamps_by_entity_id.insert(entry.entity_id, entry.timestamp); + } + } + + for editor in workspace.items_of_type::(cx) { + if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() { + if let Some(timestamp) = timestamps_by_entity_id.get(&editor.entity_id()) { + let snapshot = buffer.read(cx).snapshot(); + let full_path = snapshot.resolve_file_path(cx, true); + open_buffers.push((full_path, snapshot, *timestamp)); + } + } + } + + open_buffers + }); + + match open_buffers { + Ok(mut open_buffers) => cx.background_executor().spawn(async move { + open_buffers.sort_by_key(|(_, _, timestamp)| *timestamp); + + let mut sections = Vec::new(); + let mut text = String::new(); + let mut has_diagnostics = false; + for (full_path, buffer, _) in open_buffers { + let section_start_ix = text.len(); + text.push_str(&codeblock_fence_for_path(full_path.as_deref(), None)); + for chunk in buffer.as_rope().chunks() { + text.push_str(chunk); + } + if !text.ends_with('\n') { + text.push('\n'); + } + writeln!(text, "```").unwrap(); + if write_single_file_diagnostics(&mut text, full_path.as_deref(), &buffer) { + has_diagnostics = true; + } + if !text.ends_with('\n') { + text.push('\n'); + } + + let section_end_ix = text.len() - 1; + sections.push(build_entry_output_section( + section_start_ix..section_end_ix, + full_path.as_deref(), + false, + None, + )); + } + + Ok(SlashCommandOutput { + text, + sections, + run_commands_in_text: has_diagnostics, + }) + }), + Err(error) => Task::ready(Err(error)), + } + } +} diff --git a/crates/assistant/src/slash_command/term_command.rs b/crates/assistant/src/slash_command/term_command.rs new file mode 100644 index 00000000000000..e3ee2f9ece031c --- /dev/null +++ b/crates/assistant/src/slash_command/term_command.rs @@ -0,0 +1,111 @@ +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use anyhow::Result; +use assistant_slash_command::{ + ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection, +}; +use gpui::{AppContext, Task, WeakView}; +use language::{CodeLabel, LspAdapterDelegate}; +use terminal_view::{terminal_panel::TerminalPanel, TerminalView}; +use ui::prelude::*; +use workspace::Workspace; + +use super::create_label_for_command; + +pub(crate) struct TermSlashCommand; + +const LINE_COUNT_ARG: &str = "--line-count"; + +impl SlashCommand for TermSlashCommand { + fn name(&self) -> String { + "term".into() + } + + fn label(&self, cx: &AppContext) -> CodeLabel { + create_label_for_command("term", &[LINE_COUNT_ARG], cx) + } + + fn description(&self) -> String { + "insert terminal output".into() + } + + fn menu_text(&self) -> String { + "Insert Terminal Output".into() + } + + fn requires_argument(&self) -> bool { + false + } + + fn complete_argument( + self: Arc, + _query: String, + _cancel: Arc, + _workspace: Option>, + _cx: &mut AppContext, + ) -> Task>> { + Task::ready(Ok(vec![ArgumentCompletion { + label: LINE_COUNT_ARG.to_string(), + new_text: LINE_COUNT_ARG.to_string(), + run_command: true, + }])) + } + + fn run( + self: Arc, + argument: Option<&str>, + workspace: WeakView, + _delegate: Arc, + cx: &mut WindowContext, + ) -> Task> { + let Some(workspace) = workspace.upgrade() else { + return Task::ready(Err(anyhow::anyhow!("workspace was dropped"))); + }; + let Some(terminal_panel) = workspace.read(cx).panel::(cx) else { + return Task::ready(Err(anyhow::anyhow!("no terminal panel open"))); + }; + let Some(active_terminal) = terminal_panel + .read(cx) + .pane() + .read(cx) + .active_item() + .and_then(|t| t.downcast::()) + else { + return Task::ready(Err(anyhow::anyhow!("no active terminal"))); + }; + + let line_count = argument.and_then(|a| parse_argument(a)).unwrap_or(20); + + let lines = active_terminal + .read(cx) + .model() + .read(cx) + .last_n_non_empty_lines(line_count); + + let mut text = String::new(); + text.push_str("Terminal output:\n"); + text.push_str(&lines.join("\n")); + let range = 0..text.len(); + + Task::ready(Ok(SlashCommandOutput { + text, + sections: vec![SlashCommandOutputSection { + range, + icon: IconName::Terminal, + label: "Terminal".into(), + }], + run_commands_in_text: false, + })) + } +} + +fn parse_argument(argument: &str) -> Option { + let mut args = argument.split(' '); + if args.next() == Some(LINE_COUNT_ARG) { + if let Some(line_count) = args.next().and_then(|s| s.parse::().ok()) { + return Some(line_count); + } + } + None +} diff --git a/crates/assistant/src/terminal_inline_assistant.rs b/crates/assistant/src/terminal_inline_assistant.rs new file mode 100644 index 00000000000000..8f2cd63bac2798 --- /dev/null +++ b/crates/assistant/src/terminal_inline_assistant.rs @@ -0,0 +1,1125 @@ +use crate::{ + assistant_settings::AssistantSettings, humanize_token_count, + prompts::generate_terminal_assistant_prompt, AssistantPanel, AssistantPanelEvent, + CompletionProvider, LanguageModelRequest, LanguageModelRequestMessage, Role, +}; +use anyhow::{Context as _, Result}; +use client::telemetry::Telemetry; +use collections::{HashMap, VecDeque}; +use editor::{ + actions::{MoveDown, MoveUp, SelectAll}, + Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer, +}; +use fs::Fs; +use futures::{channel::mpsc, SinkExt, StreamExt}; +use gpui::{ + AppContext, Context, EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, Global, + Model, ModelContext, Subscription, Task, TextStyle, UpdateGlobal, View, WeakView, WhiteSpace, +}; +use language::Buffer; +use settings::{update_settings_file, Settings}; +use std::{ + cmp, + sync::Arc, + time::{Duration, Instant}, +}; +use terminal::Terminal; +use terminal_view::TerminalView; +use theme::ThemeSettings; +use ui::{prelude::*, ContextMenu, PopoverMenu, Tooltip}; +use util::ResultExt; +use workspace::{notifications::NotificationId, Toast, Workspace}; + +pub fn init(fs: Arc, telemetry: Arc, cx: &mut AppContext) { + cx.set_global(TerminalInlineAssistant::new(fs, telemetry)); +} + +const PROMPT_HISTORY_MAX_LEN: usize = 20; + +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)] +struct TerminalInlineAssistId(usize); + +impl TerminalInlineAssistId { + fn post_inc(&mut self) -> TerminalInlineAssistId { + let id = *self; + self.0 += 1; + id + } +} + +pub struct TerminalInlineAssistant { + next_assist_id: TerminalInlineAssistId, + assists: HashMap, + prompt_history: VecDeque, + telemetry: Option>, + fs: Arc, +} + +impl Global for TerminalInlineAssistant {} + +impl TerminalInlineAssistant { + pub fn new(fs: Arc, telemetry: Arc) -> Self { + Self { + next_assist_id: TerminalInlineAssistId::default(), + assists: HashMap::default(), + prompt_history: VecDeque::default(), + telemetry: Some(telemetry), + fs, + } + } + + pub fn assist( + &mut self, + terminal_view: &View, + workspace: Option>, + assistant_panel: Option<&View>, + initial_prompt: Option, + cx: &mut WindowContext, + ) { + let terminal = terminal_view.read(cx).terminal().clone(); + let assist_id = self.next_assist_id.post_inc(); + let prompt_buffer = + cx.new_model(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx)); + let prompt_buffer = cx.new_model(|cx| MultiBuffer::singleton(prompt_buffer, cx)); + let codegen = cx.new_model(|_| Codegen::new(terminal, self.telemetry.clone())); + + let prompt_editor = cx.new_view(|cx| { + PromptEditor::new( + assist_id, + self.prompt_history.clone(), + prompt_buffer.clone(), + codegen, + assistant_panel, + workspace.clone(), + self.fs.clone(), + cx, + ) + }); + let prompt_editor_render = prompt_editor.clone(); + let block = terminal_view::BlockProperties { + height: 2, + render: Box::new(move |_| prompt_editor_render.clone().into_any_element()), + }; + terminal_view.update(cx, |terminal_view, cx| { + terminal_view.set_block_below_cursor(block, cx); + }); + + let terminal_assistant = TerminalInlineAssist::new( + assist_id, + terminal_view, + assistant_panel.is_some(), + prompt_editor, + workspace.clone(), + cx, + ); + + self.assists.insert(assist_id, terminal_assistant); + + self.focus_assist(assist_id, cx); + } + + fn focus_assist(&mut self, assist_id: TerminalInlineAssistId, cx: &mut WindowContext) { + let assist = &self.assists[&assist_id]; + if let Some(prompt_editor) = assist.prompt_editor.as_ref() { + prompt_editor.update(cx, |this, cx| { + this.editor.update(cx, |editor, cx| { + editor.focus(cx); + editor.select_all(&SelectAll, cx); + }); + }); + } + } + + fn handle_prompt_editor_event( + &mut self, + prompt_editor: View, + event: &PromptEditorEvent, + cx: &mut WindowContext, + ) { + let assist_id = prompt_editor.read(cx).id; + match event { + PromptEditorEvent::StartRequested => { + self.start_assist(assist_id, cx); + } + PromptEditorEvent::StopRequested => { + self.stop_assist(assist_id, cx); + } + PromptEditorEvent::ConfirmRequested => { + self.finish_assist(assist_id, false, cx); + } + PromptEditorEvent::CancelRequested => { + self.finish_assist(assist_id, true, cx); + } + PromptEditorEvent::DismissRequested => { + self.dismiss_assist(assist_id, cx); + } + PromptEditorEvent::Resized { height_in_lines } => { + self.insert_prompt_editor_into_terminal(assist_id, *height_in_lines, cx); + } + } + } + + fn start_assist(&mut self, assist_id: TerminalInlineAssistId, cx: &mut WindowContext) { + let assist = if let Some(assist) = self.assists.get_mut(&assist_id) { + assist + } else { + return; + }; + + let Some(user_prompt) = assist + .prompt_editor + .as_ref() + .map(|editor| editor.read(cx).prompt(cx)) + else { + return; + }; + + self.prompt_history.retain(|prompt| *prompt != user_prompt); + self.prompt_history.push_back(user_prompt.clone()); + if self.prompt_history.len() > PROMPT_HISTORY_MAX_LEN { + self.prompt_history.pop_front(); + } + + assist + .terminal + .update(cx, |terminal, cx| { + terminal + .terminal() + .update(cx, |terminal, _| terminal.input(CLEAR_INPUT.to_string())); + }) + .log_err(); + + let codegen = assist.codegen.clone(); + let Some(request) = self.request_for_inline_assist(assist_id, cx).log_err() else { + return; + }; + + codegen.update(cx, |codegen, cx| codegen.start(request, cx)); + } + + fn stop_assist(&mut self, assist_id: TerminalInlineAssistId, cx: &mut WindowContext) { + let assist = if let Some(assist) = self.assists.get_mut(&assist_id) { + assist + } else { + return; + }; + + assist.codegen.update(cx, |codegen, cx| codegen.stop(cx)); + } + + fn request_for_inline_assist( + &self, + assist_id: TerminalInlineAssistId, + cx: &mut WindowContext, + ) -> Result { + let assist = self.assists.get(&assist_id).context("invalid assist")?; + + let model = CompletionProvider::global(cx).model(); + + let shell = std::env::var("SHELL").ok(); + let working_directory = assist + .terminal + .update(cx, |terminal, cx| { + terminal + .model() + .read(cx) + .working_directory() + .map(|path| path.to_string_lossy().to_string()) + }) + .ok() + .flatten(); + + let context_request = if assist.include_context { + assist.workspace.as_ref().and_then(|workspace| { + let workspace = workspace.upgrade()?.read(cx); + let assistant_panel = workspace.panel::(cx)?; + Some( + assistant_panel + .read(cx) + .active_context(cx)? + .read(cx) + .to_completion_request(cx), + ) + }) + } else { + None + }; + + let prompt = generate_terminal_assistant_prompt( + &assist + .prompt_editor + .clone() + .context("invalid assist")? + .read(cx) + .prompt(cx), + shell.as_deref(), + working_directory.as_deref(), + ); + + let mut messages = Vec::new(); + if let Some(context_request) = context_request { + messages = context_request.messages; + } + + messages.push(LanguageModelRequestMessage { + role: Role::User, + content: prompt, + }); + + Ok(LanguageModelRequest { + model, + messages, + stop: Vec::new(), + temperature: 1.0, + }) + } + + fn finish_assist( + &mut self, + assist_id: TerminalInlineAssistId, + undo: bool, + cx: &mut WindowContext, + ) { + self.dismiss_assist(assist_id, cx); + + if let Some(assist) = self.assists.remove(&assist_id) { + assist + .terminal + .update(cx, |this, cx| { + this.clear_block_below_cursor(cx); + this.focus_handle(cx).focus(cx); + }) + .log_err(); + assist.codegen.update(cx, |codegen, cx| { + if undo { + codegen.undo(cx); + } else { + codegen.complete(cx); + } + }); + } + } + + fn dismiss_assist( + &mut self, + assist_id: TerminalInlineAssistId, + cx: &mut WindowContext, + ) -> bool { + let Some(assist) = self.assists.get_mut(&assist_id) else { + return false; + }; + if assist.prompt_editor.is_none() { + return false; + } + assist.prompt_editor = None; + assist + .terminal + .update(cx, |this, cx| { + this.clear_block_below_cursor(cx); + this.focus_handle(cx).focus(cx); + }) + .is_ok() + } + + fn insert_prompt_editor_into_terminal( + &mut self, + assist_id: TerminalInlineAssistId, + height: u8, + cx: &mut WindowContext, + ) { + if let Some(assist) = self.assists.get_mut(&assist_id) { + if let Some(prompt_editor) = assist.prompt_editor.as_ref().cloned() { + assist + .terminal + .update(cx, |terminal, cx| { + terminal.clear_block_below_cursor(cx); + let block = terminal_view::BlockProperties { + height, + render: Box::new(move |_| prompt_editor.clone().into_any_element()), + }; + terminal.set_block_below_cursor(block, cx); + }) + .log_err(); + } + } + } +} + +struct TerminalInlineAssist { + terminal: WeakView, + prompt_editor: Option>, + codegen: Model, + workspace: Option>, + include_context: bool, + _subscriptions: Vec, +} + +impl TerminalInlineAssist { + pub fn new( + assist_id: TerminalInlineAssistId, + terminal: &View, + include_context: bool, + prompt_editor: View, + workspace: Option>, + cx: &mut WindowContext, + ) -> Self { + let codegen = prompt_editor.read(cx).codegen.clone(); + Self { + terminal: terminal.downgrade(), + prompt_editor: Some(prompt_editor.clone()), + codegen: codegen.clone(), + workspace: workspace.clone(), + include_context, + _subscriptions: vec![ + cx.subscribe(&prompt_editor, |prompt_editor, event, cx| { + TerminalInlineAssistant::update_global(cx, |this, cx| { + this.handle_prompt_editor_event(prompt_editor, event, cx) + }) + }), + cx.subscribe(&codegen, move |codegen, event, cx| { + TerminalInlineAssistant::update_global(cx, |this, cx| match event { + CodegenEvent::Finished => { + let assist = if let Some(assist) = this.assists.get(&assist_id) { + assist + } else { + return; + }; + + if let CodegenStatus::Error(error) = &codegen.read(cx).status { + if assist.prompt_editor.is_none() { + if let Some(workspace) = assist + .workspace + .as_ref() + .and_then(|workspace| workspace.upgrade()) + { + let error = + format!("Terminal inline assistant error: {}", error); + workspace.update(cx, |workspace, cx| { + struct InlineAssistantError; + + let id = + NotificationId::identified::( + assist_id.0, + ); + + workspace.show_toast(Toast::new(id, error), cx); + }) + } + } + } + + if assist.prompt_editor.is_none() { + this.finish_assist(assist_id, false, cx); + } + } + }) + }), + ], + } + } +} + +enum PromptEditorEvent { + StartRequested, + StopRequested, + ConfirmRequested, + CancelRequested, + DismissRequested, + Resized { height_in_lines: u8 }, +} + +struct PromptEditor { + id: TerminalInlineAssistId, + fs: Arc, + height_in_lines: u8, + editor: View, + edited_since_done: bool, + prompt_history: VecDeque, + prompt_history_ix: Option, + pending_prompt: String, + codegen: Model, + _codegen_subscription: Subscription, + editor_subscriptions: Vec, + pending_token_count: Task>, + token_count: Option, + _token_count_subscriptions: Vec, + workspace: Option>, +} + +impl EventEmitter for PromptEditor {} + +impl Render for PromptEditor { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let fs = self.fs.clone(); + + let buttons = match &self.codegen.read(cx).status { + CodegenStatus::Idle => { + vec![ + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .tooltip(|cx| Tooltip::for_action("Cancel Assist", &menu::Cancel, cx)) + .on_click( + cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)), + ), + IconButton::new("start", IconName::Sparkle) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .tooltip(|cx| Tooltip::for_action("Generate", &menu::Confirm, cx)) + .on_click( + cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::StartRequested)), + ), + ] + } + CodegenStatus::Pending => { + vec![ + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .tooltip(|cx| Tooltip::text("Cancel Assist", cx)) + .on_click( + cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)), + ), + IconButton::new("stop", IconName::Stop) + .icon_color(Color::Error) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .tooltip(|cx| { + Tooltip::with_meta( + "Interrupt Generation", + Some(&menu::Cancel), + "Changes won't be discarded", + cx, + ) + }) + .on_click( + cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::StopRequested)), + ), + ] + } + CodegenStatus::Error(_) | CodegenStatus::Done => { + vec![ + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .tooltip(|cx| Tooltip::for_action("Cancel Assist", &menu::Cancel, cx)) + .on_click( + cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)), + ), + if self.edited_since_done { + IconButton::new("restart", IconName::RotateCw) + .icon_color(Color::Info) + .icon_size(IconSize::XSmall) + .size(ButtonSize::None) + .tooltip(|cx| { + Tooltip::with_meta( + "Restart Generation", + Some(&menu::Confirm), + "Changes will be discarded", + cx, + ) + }) + .on_click(cx.listener(|_, _, cx| { + cx.emit(PromptEditorEvent::StartRequested); + })) + } else { + IconButton::new("confirm", IconName::Play) + .icon_color(Color::Info) + .size(ButtonSize::None) + .tooltip(|cx| { + Tooltip::for_action("Execute generated command", &menu::Confirm, cx) + }) + .on_click(cx.listener(|_, _, cx| { + cx.emit(PromptEditorEvent::ConfirmRequested); + })) + }, + ] + } + }; + + h_flex() + .bg(cx.theme().colors().editor_background) + .border_y_1() + .border_color(cx.theme().status().info_border) + .py_1p5() + .h_full() + .w_full() + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::move_up)) + .on_action(cx.listener(Self::move_down)) + .child( + h_flex() + .w_12() + .justify_center() + .gap_2() + .child( + PopoverMenu::new("model-switcher") + .menu(move |cx| { + ContextMenu::build(cx, |mut menu, cx| { + for model in CompletionProvider::global(cx).available_models(cx) + { + menu = menu.custom_entry( + { + let model = model.clone(); + move |_| { + Label::new(model.display_name()) + .into_any_element() + } + }, + { + let fs = fs.clone(); + let model = model.clone(); + move |cx| { + let model = model.clone(); + update_settings_file::( + fs.clone(), + cx, + move |settings| settings.set_model(model), + ); + } + }, + ); + } + menu + }) + .into() + }) + .trigger( + IconButton::new("context", IconName::Settings) + .size(ButtonSize::None) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(move |cx| { + Tooltip::with_meta( + format!( + "Using {}", + CompletionProvider::global(cx) + .model() + .display_name() + ), + None, + "Click to Change Model", + cx, + ) + }), + ) + .anchor(gpui::AnchorCorner::BottomRight), + ) + .children( + if let CodegenStatus::Error(error) = &self.codegen.read(cx).status { + let error_message = SharedString::from(error.to_string()); + Some( + div() + .id("error") + .tooltip(move |cx| Tooltip::text(error_message.clone(), cx)) + .child( + Icon::new(IconName::XCircle) + .size(IconSize::Small) + .color(Color::Error), + ), + ) + } else { + None + }, + ), + ) + .child(div().flex_1().child(self.render_prompt_editor(cx))) + .child( + h_flex() + .gap_2() + .pr_4() + .children(self.render_token_count(cx)) + .children(buttons), + ) + } +} + +impl FocusableView for PromptEditor { + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl PromptEditor { + const MAX_LINES: u8 = 8; + + #[allow(clippy::too_many_arguments)] + fn new( + id: TerminalInlineAssistId, + prompt_history: VecDeque, + prompt_buffer: Model, + codegen: Model, + assistant_panel: Option<&View>, + workspace: Option>, + fs: Arc, + cx: &mut ViewContext, + ) -> Self { + let prompt_editor = cx.new_view(|cx| { + let mut editor = Editor::new( + EditorMode::AutoHeight { + max_lines: Self::MAX_LINES as usize, + }, + prompt_buffer, + None, + false, + cx, + ); + editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); + editor.set_placeholder_text("Add a prompt…", cx); + editor + }); + + let mut token_count_subscriptions = Vec::new(); + if let Some(assistant_panel) = assistant_panel { + token_count_subscriptions + .push(cx.subscribe(assistant_panel, Self::handle_assistant_panel_event)); + } + + let mut this = Self { + id, + height_in_lines: 1, + editor: prompt_editor, + edited_since_done: false, + prompt_history, + prompt_history_ix: None, + pending_prompt: String::new(), + _codegen_subscription: cx.observe(&codegen, Self::handle_codegen_changed), + editor_subscriptions: Vec::new(), + codegen, + fs, + pending_token_count: Task::ready(Ok(())), + token_count: None, + _token_count_subscriptions: token_count_subscriptions, + workspace, + }; + this.count_lines(cx); + this.count_tokens(cx); + this.subscribe_to_editor(cx); + this + } + + fn subscribe_to_editor(&mut self, cx: &mut ViewContext) { + self.editor_subscriptions.clear(); + self.editor_subscriptions + .push(cx.observe(&self.editor, Self::handle_prompt_editor_changed)); + self.editor_subscriptions + .push(cx.subscribe(&self.editor, Self::handle_prompt_editor_events)); + } + + fn prompt(&self, cx: &AppContext) -> String { + self.editor.read(cx).text(cx) + } + + fn count_lines(&mut self, cx: &mut ViewContext) { + let height_in_lines = cmp::max( + 2, // Make the editor at least two lines tall, to account for padding and buttons. + cmp::min( + self.editor + .update(cx, |editor, cx| editor.max_point(cx).row().0 + 1), + Self::MAX_LINES as u32, + ), + ) as u8; + + if height_in_lines != self.height_in_lines { + self.height_in_lines = height_in_lines; + cx.emit(PromptEditorEvent::Resized { height_in_lines }); + } + } + + fn handle_assistant_panel_event( + &mut self, + _: View, + event: &AssistantPanelEvent, + cx: &mut ViewContext, + ) { + let AssistantPanelEvent::ContextEdited { .. } = event; + self.count_tokens(cx); + } + + fn count_tokens(&mut self, cx: &mut ViewContext) { + let assist_id = self.id; + self.pending_token_count = cx.spawn(|this, mut cx| async move { + cx.background_executor().timer(Duration::from_secs(1)).await; + let request = + cx.update_global(|inline_assistant: &mut TerminalInlineAssistant, cx| { + inline_assistant.request_for_inline_assist(assist_id, cx) + })??; + + let token_count = cx + .update(|cx| CompletionProvider::global(cx).count_tokens(request, cx))? + .await?; + this.update(&mut cx, |this, cx| { + this.token_count = Some(token_count); + cx.notify(); + }) + }) + } + + fn handle_prompt_editor_changed(&mut self, _: View, cx: &mut ViewContext) { + self.count_lines(cx); + } + + fn handle_prompt_editor_events( + &mut self, + _: View, + event: &EditorEvent, + cx: &mut ViewContext, + ) { + match event { + EditorEvent::Edited { .. } => { + let prompt = self.editor.read(cx).text(cx); + if self + .prompt_history_ix + .map_or(true, |ix| self.prompt_history[ix] != prompt) + { + self.prompt_history_ix.take(); + self.pending_prompt = prompt; + } + + self.edited_since_done = true; + cx.notify(); + } + EditorEvent::BufferEdited => { + self.count_tokens(cx); + } + _ => {} + } + } + + fn handle_codegen_changed(&mut self, _: Model, cx: &mut ViewContext) { + match &self.codegen.read(cx).status { + CodegenStatus::Idle => { + self.editor + .update(cx, |editor, _| editor.set_read_only(false)); + } + CodegenStatus::Pending => { + self.editor + .update(cx, |editor, _| editor.set_read_only(true)); + } + CodegenStatus::Done | CodegenStatus::Error(_) => { + self.edited_since_done = false; + self.editor + .update(cx, |editor, _| editor.set_read_only(false)); + } + } + } + + fn cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext) { + match &self.codegen.read(cx).status { + CodegenStatus::Idle | CodegenStatus::Done | CodegenStatus::Error(_) => { + cx.emit(PromptEditorEvent::CancelRequested); + } + CodegenStatus::Pending => { + cx.emit(PromptEditorEvent::StopRequested); + } + } + } + + fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + match &self.codegen.read(cx).status { + CodegenStatus::Idle => { + if !self.editor.read(cx).text(cx).trim().is_empty() { + cx.emit(PromptEditorEvent::StartRequested); + } + } + CodegenStatus::Pending => { + cx.emit(PromptEditorEvent::DismissRequested); + } + CodegenStatus::Done | CodegenStatus::Error(_) => { + if self.edited_since_done { + cx.emit(PromptEditorEvent::StartRequested); + } else { + cx.emit(PromptEditorEvent::ConfirmRequested); + } + } + } + } + + fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext) { + if let Some(ix) = self.prompt_history_ix { + if ix > 0 { + self.prompt_history_ix = Some(ix - 1); + let prompt = self.prompt_history[ix - 1].as_str(); + self.editor.update(cx, |editor, cx| { + editor.set_text(prompt, cx); + editor.move_to_beginning(&Default::default(), cx); + }); + } + } else if !self.prompt_history.is_empty() { + self.prompt_history_ix = Some(self.prompt_history.len() - 1); + let prompt = self.prompt_history[self.prompt_history.len() - 1].as_str(); + self.editor.update(cx, |editor, cx| { + editor.set_text(prompt, cx); + editor.move_to_beginning(&Default::default(), cx); + }); + } + } + + fn move_down(&mut self, _: &MoveDown, cx: &mut ViewContext) { + if let Some(ix) = self.prompt_history_ix { + if ix < self.prompt_history.len() - 1 { + self.prompt_history_ix = Some(ix + 1); + let prompt = self.prompt_history[ix + 1].as_str(); + self.editor.update(cx, |editor, cx| { + editor.set_text(prompt, cx); + editor.move_to_end(&Default::default(), cx) + }); + } else { + self.prompt_history_ix = None; + let prompt = self.pending_prompt.as_str(); + self.editor.update(cx, |editor, cx| { + editor.set_text(prompt, cx); + editor.move_to_end(&Default::default(), cx) + }); + } + } + } + + fn render_token_count(&self, cx: &mut ViewContext) -> Option { + let model = CompletionProvider::global(cx).model(); + let token_count = self.token_count?; + let max_token_count = model.max_token_count(); + + let remaining_tokens = max_token_count as isize - token_count as isize; + let token_count_color = if remaining_tokens <= 0 { + Color::Error + } else if token_count as f32 / max_token_count as f32 >= 0.8 { + Color::Warning + } else { + Color::Muted + }; + + let mut token_count = h_flex() + .id("token_count") + .gap_0p5() + .child( + Label::new(humanize_token_count(token_count)) + .size(LabelSize::Small) + .color(token_count_color), + ) + .child(Label::new("/").size(LabelSize::Small).color(Color::Muted)) + .child( + Label::new(humanize_token_count(max_token_count)) + .size(LabelSize::Small) + .color(Color::Muted), + ); + if let Some(workspace) = self.workspace.clone() { + token_count = token_count + .tooltip(|cx| { + Tooltip::with_meta( + "Tokens Used by Inline Assistant", + None, + "Click to Open Assistant Panel", + cx, + ) + }) + .cursor_pointer() + .on_mouse_down(gpui::MouseButton::Left, |_, cx| cx.stop_propagation()) + .on_click(move |_, cx| { + cx.stop_propagation(); + workspace + .update(cx, |workspace, cx| { + workspace.focus_panel::(cx) + }) + .ok(); + }); + } else { + token_count = token_count + .cursor_default() + .tooltip(|cx| Tooltip::text("Tokens Used by Inline Assistant", cx)); + } + + Some(token_count) + } + + fn render_prompt_editor(&self, cx: &mut ViewContext) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: if self.editor.read(cx).read_only(cx) { + cx.theme().colors().text_disabled + } else { + cx.theme().colors().text + }, + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_size: rems(0.875).into(), + font_weight: FontWeight::NORMAL, + font_style: FontStyle::Normal, + line_height: relative(1.3), + background_color: None, + underline: None, + strikethrough: None, + white_space: WhiteSpace::Normal, + }; + EditorElement::new( + &self.editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } +} + +#[derive(Debug)] +pub enum CodegenEvent { + Finished, +} + +impl EventEmitter for Codegen {} + +const CLEAR_INPUT: &str = "\x15"; +const CARRIAGE_RETURN: &str = "\x0d"; + +struct TerminalTransaction { + terminal: Model, +} + +impl TerminalTransaction { + pub fn start(terminal: Model) -> Self { + Self { terminal } + } + + pub fn push(&mut self, hunk: String, cx: &mut AppContext) { + // Ensure that the assistant cannot accidently execute commands that are streamed into the terminal + let input = hunk.replace(CARRIAGE_RETURN, " "); + self.terminal + .update(cx, |terminal, _| terminal.input(input)); + } + + pub fn undo(&self, cx: &mut AppContext) { + self.terminal + .update(cx, |terminal, _| terminal.input(CLEAR_INPUT.to_string())); + } + + pub fn complete(&self, cx: &mut AppContext) { + self.terminal.update(cx, |terminal, _| { + terminal.input(CARRIAGE_RETURN.to_string()) + }); + } +} + +pub struct Codegen { + status: CodegenStatus, + telemetry: Option>, + terminal: Model, + generation: Task<()>, + transaction: Option, +} + +impl Codegen { + pub fn new(terminal: Model, telemetry: Option>) -> Self { + Self { + terminal, + telemetry, + status: CodegenStatus::Idle, + generation: Task::ready(()), + transaction: None, + } + } + + pub fn start(&mut self, prompt: LanguageModelRequest, cx: &mut ModelContext) { + self.status = CodegenStatus::Pending; + self.transaction = Some(TerminalTransaction::start(self.terminal.clone())); + + let telemetry = self.telemetry.clone(); + let model_telemetry_id = prompt.model.telemetry_id(); + let response = CompletionProvider::global(cx).stream_completion(prompt, cx); + + self.generation = cx.spawn(|this, mut cx| async move { + let response = response.await; + let generate = async { + let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); + + let task = cx.background_executor().spawn(async move { + let mut response_latency = None; + let request_start = Instant::now(); + let task = async { + let mut chunks = response?; + while let Some(chunk) = chunks.next().await { + if response_latency.is_none() { + response_latency = Some(request_start.elapsed()); + } + let chunk = chunk?; + hunks_tx.send(chunk).await?; + } + + anyhow::Ok(()) + }; + + let result = task.await; + + let error_message = result.as_ref().err().map(|error| error.to_string()); + if let Some(telemetry) = telemetry { + telemetry.report_assistant_event( + None, + telemetry_events::AssistantKind::Inline, + model_telemetry_id, + response_latency, + error_message, + ); + } + + result?; + anyhow::Ok(()) + }); + + while let Some(hunk) = hunks_rx.next().await { + this.update(&mut cx, |this, cx| { + if let Some(transaction) = &mut this.transaction { + transaction.push(hunk, cx); + cx.notify(); + } + })?; + } + + task.await?; + anyhow::Ok(()) + }; + + let result = generate.await; + + this.update(&mut cx, |this, cx| { + if let Err(error) = result { + this.status = CodegenStatus::Error(error); + } else { + this.status = CodegenStatus::Done; + } + cx.emit(CodegenEvent::Finished); + cx.notify(); + }) + .ok(); + }); + cx.notify(); + } + + pub fn stop(&mut self, cx: &mut ModelContext) { + self.status = CodegenStatus::Done; + self.generation = Task::ready(()); + cx.emit(CodegenEvent::Finished); + cx.notify(); + } + + pub fn complete(&mut self, cx: &mut ModelContext) { + if let Some(transaction) = self.transaction.take() { + transaction.complete(cx); + } + } + + pub fn undo(&mut self, cx: &mut ModelContext) { + if let Some(transaction) = self.transaction.take() { + transaction.undo(cx); + } + } +} + +enum CodegenStatus { + Idle, + Pending, + Done, + Error(anyhow::Error), +} diff --git a/crates/assistant2/evals/list-of-into-element.md b/crates/assistant2/evals/list-of-into-element.md deleted file mode 100644 index fca5e1afebae6c..00000000000000 --- a/crates/assistant2/evals/list-of-into-element.md +++ /dev/null @@ -1 +0,0 @@ -> Give me a comprehensive list of all the elements defined in my project using the following query: `impl Element for {}, impl Element for {}, impl IntoElement for {})` diff --git a/crates/assistant2/evals/new-gpui-element.md b/crates/assistant2/evals/new-gpui-element.md deleted file mode 100644 index 51452cb36e6898..00000000000000 --- a/crates/assistant2/evals/new-gpui-element.md +++ /dev/null @@ -1 +0,0 @@ -> What are all the places we define a new gpui element in my project? (impl Element for {}) diff --git a/crates/assistant2/evals/settings-file.md b/crates/assistant2/evals/settings-file.md deleted file mode 100644 index ff15f7d0030a4b..00000000000000 --- a/crates/assistant2/evals/settings-file.md +++ /dev/null @@ -1,3 +0,0 @@ -Use tools frequently, especially when referring to files and code. The Zed editor we're working in can show me files directly when you add annotations. Be concise in chat, bountiful in tool calling. - -Teach me everything you can about how zed loads settings. Please annotate the code inline. diff --git a/crates/assistant2/evals/what-is-the-assistant2-crate.md b/crates/assistant2/evals/what-is-the-assistant2-crate.md deleted file mode 100644 index 5d39684c5a6c07..00000000000000 --- a/crates/assistant2/evals/what-is-the-assistant2-crate.md +++ /dev/null @@ -1 +0,0 @@ -> Can you tell me what the assistant2 crate is for in my project? Tell me in 100 words or less. diff --git a/crates/assistant2/src/assistant2.rs b/crates/assistant2/src/assistant2.rs deleted file mode 100644 index bb60a6487c8893..00000000000000 --- a/crates/assistant2/src/assistant2.rs +++ /dev/null @@ -1,1183 +0,0 @@ -mod assistant_settings; -mod attachments; -mod completion_provider; -mod saved_conversation; -mod saved_conversations; -mod tools; -pub mod ui; - -use crate::saved_conversation::SavedConversationMetadata; -use crate::ui::UserOrAssistant; -use ::ui::{div, prelude::*, Color, Tooltip, ViewContext}; -use anyhow::{Context, Result}; -use assistant_tooling::{ - AttachmentRegistry, ProjectContext, ToolFunctionCall, ToolRegistry, UserAttachment, -}; -use attachments::ActiveEditorAttachmentTool; -use client::{proto, Client, UserStore}; -use collections::HashMap; -use completion_provider::*; -use editor::Editor; -use feature_flags::FeatureFlagAppExt as _; -use file_icons::FileIcons; -use fs::Fs; -use futures::{future::join_all, StreamExt}; -use gpui::{ - list, AnyElement, AppContext, AsyncWindowContext, ClickEvent, EventEmitter, FocusHandle, - FocusableView, ListAlignment, ListState, Model, ReadGlobal, Render, Task, UpdateGlobal, View, - WeakView, -}; -use language::{language_settings::SoftWrap, LanguageRegistry}; -use markdown::{Markdown, MarkdownStyle}; -use open_ai::{FunctionContent, ToolCall, ToolCallContent}; -use saved_conversation::{SavedAssistantMessagePart, SavedChatMessage, SavedConversation}; -use saved_conversations::SavedConversations; -use semantic_index::{CloudEmbeddingProvider, ProjectIndex, ProjectIndexDebugView, SemanticIndex}; -use serde::{Deserialize, Serialize}; -use settings::Settings; -use std::sync::Arc; -use tools::{AnnotationTool, CreateBufferTool, ProjectIndexTool}; -use ui::{ActiveFileButton, Composer, ProjectIndexButton}; -use util::paths::CONVERSATIONS_DIR; -use util::{maybe, paths::EMBEDDINGS_DIR, ResultExt}; -use workspace::{ - dock::{DockPosition, Panel, PanelEvent}, - Workspace, -}; - -pub use assistant_settings::AssistantSettings; - -const MAX_COMPLETION_CALLS_PER_SUBMISSION: usize = 5; - -#[derive(Eq, PartialEq, Copy, Clone, Deserialize)] -pub struct Submit(SubmitMode); - -/// There are multiple different ways to submit a model request, represented by this enum. -#[derive(Eq, PartialEq, Copy, Clone, Deserialize)] -pub enum SubmitMode { - /// Only include the conversation. - Simple, - /// Send the current file as context. - CurrentFile, - /// Search the codebase and send relevant excerpts. - Codebase, -} - -gpui::actions!(assistant2, [Cancel, ToggleFocus, DebugProjectIndex,]); -gpui::impl_actions!(assistant2, [Submit]); - -pub fn init(client: Arc, cx: &mut AppContext) { - AssistantSettings::register(cx); - - cx.spawn(|mut cx| { - let client = client.clone(); - async move { - let embedding_provider = CloudEmbeddingProvider::new(client.clone()); - let semantic_index = SemanticIndex::new( - EMBEDDINGS_DIR.join("semantic-index-db.0.mdb"), - Arc::new(embedding_provider), - &mut cx, - ) - .await?; - cx.update(|cx| cx.set_global(semantic_index)) - } - }) - .detach(); - - cx.set_global(CompletionProvider::new(CloudCompletionProvider::new( - client, - ))); - - cx.observe_new_views( - |workspace: &mut Workspace, _cx: &mut ViewContext| { - workspace.register_action(|workspace, _: &ToggleFocus, cx| { - workspace.toggle_panel_focus::(cx); - }); - workspace.register_action(|workspace, _: &DebugProjectIndex, cx| { - if let Some(panel) = workspace.panel::(cx) { - let index = panel.read(cx).chat.read(cx).project_index.clone(); - let view = cx.new_view(|cx| ProjectIndexDebugView::new(index, cx)); - workspace.add_item_to_center(Box::new(view), cx); - } - }); - }, - ) - .detach(); -} - -pub fn enabled(cx: &AppContext) -> bool { - cx.is_staff() -} - -pub struct AssistantPanel { - chat: View, - width: Option, -} - -impl AssistantPanel { - pub fn load( - workspace: WeakView, - cx: AsyncWindowContext, - ) -> Task>> { - cx.spawn(|mut cx| async move { - let (app_state, project) = workspace.update(&mut cx, |workspace, _| { - (workspace.app_state().clone(), workspace.project().clone()) - })?; - - cx.new_view(|cx| { - let project_index = SemanticIndex::update_global(cx, |semantic_index, cx| { - semantic_index.project_index(project.clone(), cx) - }); - - // Used in tools to render file icons - cx.observe_global::(|_, cx| { - cx.notify(); - }) - .detach(); - - let mut tool_registry = ToolRegistry::new(); - tool_registry - .register(ProjectIndexTool::new(project_index.clone())) - .unwrap(); - tool_registry - .register(CreateBufferTool::new(workspace.clone(), project.clone())) - .unwrap(); - tool_registry - .register(AnnotationTool::new(workspace.clone(), project.clone())) - .unwrap(); - - let mut attachment_registry = AttachmentRegistry::new(); - attachment_registry - .register(ActiveEditorAttachmentTool::new(workspace.clone(), cx)); - - Self::new( - project.read(cx).fs().clone(), - app_state.languages.clone(), - Arc::new(tool_registry), - Arc::new(attachment_registry), - app_state.user_store.clone(), - project_index, - workspace, - cx, - ) - }) - }) - } - - #[allow(clippy::too_many_arguments)] - pub fn new( - fs: Arc, - language_registry: Arc, - tool_registry: Arc, - attachment_registry: Arc, - user_store: Model, - project_index: Model, - workspace: WeakView, - cx: &mut ViewContext, - ) -> Self { - let chat = cx.new_view(|cx| { - AssistantChat::new( - fs, - language_registry, - tool_registry.clone(), - attachment_registry, - user_store, - project_index, - workspace, - cx, - ) - }); - - Self { width: None, chat } - } -} - -impl Render for AssistantPanel { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - div() - .size_full() - .v_flex() - .bg(cx.theme().colors().panel_background) - .child(self.chat.clone()) - } -} - -impl Panel for AssistantPanel { - fn persistent_name() -> &'static str { - "AssistantPanelv2" - } - - fn position(&self, _cx: &WindowContext) -> workspace::dock::DockPosition { - // todo!("Add a setting / use assistant settings") - DockPosition::Right - } - - fn position_is_valid(&self, position: workspace::dock::DockPosition) -> bool { - matches!(position, DockPosition::Right) - } - - fn set_position(&mut self, _: workspace::dock::DockPosition, _: &mut ViewContext) { - // Do nothing until we have a setting for this - } - - fn size(&self, _cx: &WindowContext) -> Pixels { - self.width.unwrap_or(px(400.)) - } - - fn set_size(&mut self, size: Option, cx: &mut ViewContext) { - self.width = size; - cx.notify(); - } - - fn icon(&self, _cx: &WindowContext) -> Option<::ui::IconName> { - Some(IconName::ZedAssistant) - } - - fn icon_tooltip(&self, _: &WindowContext) -> Option<&'static str> { - Some("Assistant Panel ✨") - } - - fn toggle_action(&self) -> Box { - Box::new(ToggleFocus) - } -} - -impl EventEmitter for AssistantPanel {} - -impl FocusableView for AssistantPanel { - fn focus_handle(&self, cx: &AppContext) -> FocusHandle { - self.chat.read(cx).composer_editor.read(cx).focus_handle(cx) - } -} - -pub struct AssistantChat { - model: String, - messages: Vec, - list_state: ListState, - fs: Arc, - language_registry: Arc, - composer_editor: View, - saved_conversations: View, - saved_conversations_open: bool, - project_index_button: View, - active_file_button: Option>, - user_store: Model, - next_message_id: MessageId, - collapsed_messages: HashMap, - editing_message: Option, - pending_completion: Option>, - tool_registry: Arc, - attachment_registry: Arc, - project_index: Model, - markdown_style: MarkdownStyle, -} - -struct EditingMessage { - id: MessageId, - body: View, -} - -impl AssistantChat { - #[allow(clippy::too_many_arguments)] - fn new( - fs: Arc, - language_registry: Arc, - tool_registry: Arc, - attachment_registry: Arc, - user_store: Model, - project_index: Model, - workspace: WeakView, - cx: &mut ViewContext, - ) -> Self { - let model = CompletionProvider::global(cx).default_model(); - let view = cx.view().downgrade(); - let list_state = ListState::new( - 0, - ListAlignment::Bottom, - px(1024.), - move |ix, cx: &mut WindowContext| { - view.update(cx, |this, cx| this.render_message(ix, cx)) - .unwrap() - }, - ); - - let project_index_button = cx.new_view(|cx| { - ProjectIndexButton::new(project_index.clone(), tool_registry.clone(), cx) - }); - - let active_file_button = match workspace.upgrade() { - Some(workspace) => { - Some(cx.new_view( - |cx| ActiveFileButton::new(attachment_registry.clone(), workspace, cx), // - )) - } - _ => None, - }; - - let saved_conversations = cx.new_view(|cx| SavedConversations::new(cx)); - cx.spawn({ - let fs = fs.clone(); - let saved_conversations = saved_conversations.downgrade(); - |_assistant_chat, mut cx| async move { - let saved_conversation_metadata = SavedConversationMetadata::list(fs).await?; - - cx.update(|cx| { - saved_conversations.update(cx, |this, cx| { - this.init(saved_conversation_metadata, cx); - }) - })??; - - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - - Self { - model, - messages: Vec::new(), - composer_editor: cx.new_view(|cx| { - let mut editor = Editor::auto_height(80, cx); - editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); - editor.set_placeholder_text("Send a message…", cx); - editor - }), - saved_conversations, - saved_conversations_open: false, - list_state, - user_store, - fs, - language_registry, - project_index_button, - active_file_button, - project_index, - next_message_id: MessageId(0), - editing_message: None, - collapsed_messages: HashMap::default(), - pending_completion: None, - attachment_registry, - tool_registry, - markdown_style: MarkdownStyle { - code_block: gpui::TextStyleRefinement { - font_family: Some("Zed Mono".into()), - color: Some(cx.theme().colors().editor_foreground), - background_color: Some(cx.theme().colors().editor_background), - ..Default::default() - }, - inline_code: gpui::TextStyleRefinement { - font_family: Some("Zed Mono".into()), - // @nate: Could we add inline-code specific styles to the theme? - color: Some(cx.theme().colors().editor_foreground), - background_color: Some(cx.theme().colors().editor_background), - ..Default::default() - }, - rule_color: Color::Muted.color(cx), - block_quote_border_color: Color::Muted.color(cx), - block_quote: gpui::TextStyleRefinement { - color: Some(Color::Muted.color(cx)), - ..Default::default() - }, - link: gpui::TextStyleRefinement { - color: Some(Color::Accent.color(cx)), - underline: Some(gpui::UnderlineStyle { - thickness: px(1.), - color: Some(Color::Accent.color(cx)), - wavy: false, - }), - ..Default::default() - }, - syntax: cx.theme().syntax().clone(), - selection_background_color: { - let mut selection = cx.theme().players().local().selection; - selection.fade_out(0.7); - selection - }, - }, - } - } - - fn message_for_id(&self, id: MessageId) -> Option<&ChatMessage> { - self.messages.iter().find(|message| match message { - ChatMessage::User(message) => message.id == id, - ChatMessage::Assistant(message) => message.id == id, - }) - } - - fn toggle_saved_conversations(&mut self) { - self.saved_conversations_open = !self.saved_conversations_open; - } - - fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { - // If we're currently editing a message, cancel the edit. - if self.editing_message.take().is_some() { - cx.notify(); - return; - } - - if self.pending_completion.take().is_some() { - if let Some(ChatMessage::Assistant(grouping)) = self.messages.last() { - if grouping.messages.is_empty() { - self.pop_message(cx); - } - } - return; - } - - cx.propagate(); - } - - fn submit(&mut self, Submit(mode): &Submit, cx: &mut ViewContext) { - if self.composer_editor.focus_handle(cx).is_focused(cx) { - // Don't allow multiple concurrent completions. - if self.pending_completion.is_some() { - cx.propagate(); - return; - } - - let message = self.composer_editor.update(cx, |composer_editor, cx| { - let text = composer_editor.text(cx); - let id = self.next_message_id.post_inc(); - let body = cx.new_view(|cx| { - Markdown::new( - text, - self.markdown_style.clone(), - Some(self.language_registry.clone()), - cx, - ) - }); - composer_editor.clear(cx); - - ChatMessage::User(UserMessage { - id, - body, - attachments: Vec::new(), - }) - }); - self.push_message(message, cx); - } else if let Some(editing_message) = self.editing_message.as_ref() { - let focus_handle = editing_message.body.focus_handle(cx); - if focus_handle.contains_focused(cx) { - if let Some(ChatMessage::User(user_message)) = - self.message_for_id(editing_message.id) - { - user_message.body.update(cx, |body, cx| { - body.reset(editing_message.body.read(cx).text(cx), cx); - }); - } - - self.truncate_messages(editing_message.id, cx); - - self.pending_completion.take(); - self.composer_editor.focus_handle(cx).focus(cx); - self.editing_message.take(); - } else { - log::error!("unexpected state: no user message editor is focused."); - return; - } - } else { - log::error!("unexpected state: no user message editor is focused."); - return; - } - - let mode = *mode; - self.pending_completion = Some(cx.spawn(move |this, mut cx| async move { - let attachments_task = this.update(&mut cx, |this, cx| { - let attachment_registry = this.attachment_registry.clone(); - attachment_registry.call_all_attachment_tools(cx) - }); - - let attachments = maybe!(async { - let attachments_task = attachments_task?; - let attachments = attachments_task.await?; - - anyhow::Ok(attachments) - }) - .await - .log_err() - .unwrap_or_default(); - - // Set the attachments to the _last_ user message - this.update(&mut cx, |this, _cx| { - if let Some(ChatMessage::User(message)) = this.messages.last_mut() { - message.attachments = attachments; - } - }) - .log_err(); - - Self::request_completion( - this.clone(), - mode, - MAX_COMPLETION_CALLS_PER_SUBMISSION, - &mut cx, - ) - .await - .log_err(); - - this.update(&mut cx, |this, _cx| { - this.pending_completion = None; - }) - .context("Failed to push new user message") - .log_err(); - })); - } - - async fn request_completion( - this: WeakView, - mode: SubmitMode, - limit: usize, - cx: &mut AsyncWindowContext, - ) -> Result<()> { - let mut call_count = 0; - loop { - let complete = async { - let (tool_definitions, model_name, messages) = this.update(cx, |this, cx| { - this.push_new_assistant_message(cx); - - let definitions = if call_count < limit - && matches!(mode, SubmitMode::Codebase | SubmitMode::Simple) - { - this.tool_registry.definitions() - } else { - Vec::new() - }; - call_count += 1; - - ( - definitions, - this.model.clone(), - this.completion_messages(cx), - ) - })?; - - let messages = messages.await?; - - let completion = cx.update(|cx| { - CompletionProvider::global(cx).complete( - model_name, - messages, - Vec::new(), - 1.0, - tool_definitions, - ) - }); - - let mut stream = completion?.await?; - while let Some(delta) = stream.next().await { - let delta = delta?; - this.update(cx, |this, cx| { - if let Some(ChatMessage::Assistant(AssistantMessage { messages, .. })) = - this.messages.last_mut() - { - if messages.is_empty() { - messages.push(AssistantMessagePart { - body: cx.new_view(|cx| { - Markdown::new( - "".into(), - this.markdown_style.clone(), - Some(this.language_registry.clone()), - cx, - ) - }), - tool_calls: Vec::new(), - }) - } - - let message = messages.last_mut().unwrap(); - - if let Some(content) = &delta.content { - message - .body - .update(cx, |message, cx| message.append(&content, cx)); - } - - for tool_call_delta in delta.tool_calls { - let index = tool_call_delta.index as usize; - if index >= message.tool_calls.len() { - message.tool_calls.resize_with(index + 1, Default::default); - } - let tool_call = &mut message.tool_calls[index]; - - if let Some(id) = &tool_call_delta.id { - tool_call.id.push_str(id); - } - - match tool_call_delta.variant { - Some(proto::tool_call_delta::Variant::Function( - tool_call_delta, - )) => { - this.tool_registry.update_tool_call( - tool_call, - tool_call_delta.name.as_deref(), - tool_call_delta.arguments.as_deref(), - cx, - ); - } - None => {} - } - } - - cx.notify(); - } else { - unreachable!() - } - })?; - } - - anyhow::Ok(()) - } - .await; - - let mut tool_tasks = Vec::new(); - this.update(cx, |this, cx| { - if let Some(ChatMessage::Assistant(AssistantMessage { - error: message_error, - messages, - .. - })) = this.messages.last_mut() - { - if let Err(error) = complete { - message_error.replace(SharedString::from(error.to_string())); - cx.notify(); - } else { - if let Some(current_message) = messages.last_mut() { - for tool_call in current_message.tool_calls.iter_mut() { - tool_tasks - .extend(this.tool_registry.execute_tool_call(tool_call, cx)); - } - } - } - } - })?; - - // This ends recursion on calling for responses after tools - if tool_tasks.is_empty() { - return Ok(()); - } - - join_all(tool_tasks.into_iter()).await; - } - } - - fn push_new_assistant_message(&mut self, cx: &mut ViewContext) { - // If the last message is a grouped assistant message, add to the grouped message - if let Some(ChatMessage::Assistant(AssistantMessage { messages, .. })) = - self.messages.last_mut() - { - messages.push(AssistantMessagePart { - body: cx.new_view(|cx| { - Markdown::new( - "".into(), - self.markdown_style.clone(), - Some(self.language_registry.clone()), - cx, - ) - }), - tool_calls: Vec::new(), - }); - return; - } - - let message = ChatMessage::Assistant(AssistantMessage { - id: self.next_message_id.post_inc(), - messages: vec![AssistantMessagePart { - body: cx.new_view(|cx| { - Markdown::new( - "".into(), - self.markdown_style.clone(), - Some(self.language_registry.clone()), - cx, - ) - }), - tool_calls: Vec::new(), - }], - error: None, - }); - self.push_message(message, cx); - } - - fn push_message(&mut self, message: ChatMessage, cx: &mut ViewContext) { - let old_len = self.messages.len(); - let focus_handle = Some(message.focus_handle(cx)); - self.messages.push(message); - self.list_state - .splice_focusable(old_len..old_len, focus_handle); - cx.notify(); - } - - fn pop_message(&mut self, cx: &mut ViewContext) { - if self.messages.is_empty() { - return; - } - - self.messages.pop(); - self.list_state - .splice(self.messages.len()..self.messages.len() + 1, 0); - cx.notify(); - } - - fn truncate_messages(&mut self, last_message_id: MessageId, cx: &mut ViewContext) { - if let Some(index) = self.messages.iter().position(|message| match message { - ChatMessage::User(message) => message.id == last_message_id, - ChatMessage::Assistant(message) => message.id == last_message_id, - }) { - self.list_state.splice(index + 1..self.messages.len(), 0); - self.messages.truncate(index + 1); - cx.notify(); - } - } - - fn is_message_collapsed(&self, id: &MessageId) -> bool { - self.collapsed_messages.get(id).copied().unwrap_or_default() - } - - fn toggle_message_collapsed(&mut self, id: MessageId) { - let entry = self.collapsed_messages.entry(id).or_insert(false); - *entry = !*entry; - } - - fn reset(&mut self) { - self.messages.clear(); - self.list_state.reset(0); - self.editing_message.take(); - self.collapsed_messages.clear(); - } - - fn new_conversation(&mut self, cx: &mut ViewContext) { - let messages = std::mem::take(&mut self.messages) - .into_iter() - .map(|message| self.serialize_message(message, cx)) - .collect::>(); - - self.reset(); - - let title = messages - .first() - .map(|message| match message { - SavedChatMessage::User { body, .. } => body.clone(), - SavedChatMessage::Assistant { messages, .. } => messages - .first() - .map(|message| message.body.to_string()) - .unwrap_or_default(), - }) - .unwrap_or_else(|| "A conversation with the assistant.".to_string()); - - let saved_conversation = SavedConversation { - version: "0.3.0".to_string(), - title, - messages, - }; - - let discriminant = 1; - - let path = CONVERSATIONS_DIR.join(&format!( - "{title} - {discriminant}.zed.{version}.json", - title = saved_conversation.title, - version = saved_conversation.version - )); - - cx.spawn({ - let fs = self.fs.clone(); - |_this, _cx| async move { - fs.create_dir(CONVERSATIONS_DIR.as_ref()).await?; - fs.atomic_write(path, serde_json::to_string(&saved_conversation)?) - .await?; - - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - } - - fn render_error( - &self, - error: Option, - _ix: usize, - cx: &mut ViewContext, - ) -> AnyElement { - let theme = cx.theme(); - - if let Some(error) = error { - div() - .py_1() - .px_2() - .mx_neg_1() - .rounded_md() - .border_1() - .border_color(theme.status().error_border) - // .bg(theme.status().error_background) - .text_color(theme.status().error) - .child(error.clone()) - .into_any_element() - } else { - div().into_any_element() - } - } - - fn render_message(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { - let is_first = ix == 0; - let is_last = ix == self.messages.len().saturating_sub(1); - - let padding = Spacing::Large.rems(cx); - - // Whenever there's a run of assistant messages, group as one Assistant UI element - - match &self.messages[ix] { - ChatMessage::User(UserMessage { - id, - body, - attachments, - }) => div() - .id(SharedString::from(format!("message-{}-container", id.0))) - .when(is_first, |this| this.pt(padding)) - .map(|element| { - if let Some(editing_message) = self.editing_message.as_ref() { - if editing_message.id == *id { - return element.child(Composer::new( - editing_message.body.clone(), - self.project_index_button.clone(), - self.active_file_button.clone(), - crate::ui::ModelSelector::new( - cx.view().downgrade(), - self.model.clone(), - ) - .into_any_element(), - )); - } - } - - element - .on_click(cx.listener({ - let id = *id; - let body = body.clone(); - move |assistant_chat, event: &ClickEvent, cx| { - if event.up.click_count == 2 { - let body = cx.new_view(|cx| { - let mut editor = Editor::auto_height(80, cx); - let source = Arc::from(body.read(cx).source()); - editor.set_text(source, cx); - editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); - editor - }); - assistant_chat.editing_message = Some(EditingMessage { - id, - body: body.clone(), - }); - body.focus_handle(cx).focus(cx); - } - } - })) - .child( - crate::ui::ChatMessage::new( - *id, - UserOrAssistant::User(self.user_store.read(cx).current_user()), - vec![ - body.clone().into_any_element(), - h_flex() - .gap_2() - .children( - attachments - .iter() - .map(|attachment| attachment.view.clone()), - ) - .into_any_element(), - ], - self.is_message_collapsed(id), - Box::new(cx.listener({ - let id = *id; - move |assistant_chat, _event, _cx| { - assistant_chat.toggle_message_collapsed(id) - } - })), - ) - // TODO: Wire up selections. - .selected(is_last), - ) - }) - .into_any(), - ChatMessage::Assistant(AssistantMessage { - id, - messages, - error, - .. - }) => { - let mut message_elements = Vec::new(); - - for message in messages { - if !message.body.read(cx).source().is_empty() { - message_elements.push(div().child(message.body.clone()).into_any()) - } - - let tools = message - .tool_calls - .iter() - .filter_map(|tool_call| self.tool_registry.render_tool_call(tool_call, cx)) - .collect::>(); - - if !tools.is_empty() { - message_elements.push(div().children(tools).into_any()) - } - } - - if message_elements.is_empty() { - message_elements.push(::ui::Label::new("Researching...").into_any_element()) - } - - div() - .when(is_first, |this| this.pt(padding)) - .child( - crate::ui::ChatMessage::new( - *id, - UserOrAssistant::Assistant, - message_elements, - self.is_message_collapsed(id), - Box::new(cx.listener({ - let id = *id; - move |assistant_chat, _event, _cx| { - assistant_chat.toggle_message_collapsed(id) - } - })), - ) - // TODO: Wire up selections. - .selected(is_last), - ) - .child(self.render_error(error.clone(), ix, cx)) - .into_any() - } - } - } - - fn completion_messages(&self, cx: &mut WindowContext) -> Task>> { - let project_index = self.project_index.read(cx); - let project = project_index.project(); - let fs = project_index.fs(); - - let mut project_context = ProjectContext::new(project, fs); - let mut completion_messages = Vec::new(); - - for message in &self.messages { - match message { - ChatMessage::User(UserMessage { - body, attachments, .. - }) => { - for attachment in attachments { - if let Some(content) = attachment.generate(&mut project_context, cx) { - completion_messages.push(CompletionMessage::System { content }); - } - } - - // Show user's message last so that the assistant is grounded in the user's request - completion_messages.push(CompletionMessage::User { - content: body.read(cx).source().to_string(), - }); - } - ChatMessage::Assistant(AssistantMessage { messages, .. }) => { - for message in messages { - let body = message.body.clone(); - - if body.read(cx).source().is_empty() && message.tool_calls.is_empty() { - continue; - } - - let tool_calls_from_assistant = message - .tool_calls - .iter() - .map(|tool_call| ToolCall { - content: ToolCallContent::Function { - function: FunctionContent { - name: tool_call.name.clone(), - arguments: tool_call.arguments.clone(), - }, - }, - id: tool_call.id.clone(), - }) - .collect(); - - completion_messages.push(CompletionMessage::Assistant { - content: Some(body.read(cx).source().to_string()), - tool_calls: tool_calls_from_assistant, - }); - - for tool_call in &message.tool_calls { - // Every tool call _must_ have a result by ID, otherwise OpenAI will error. - let content = self.tool_registry.content_for_tool_call( - tool_call, - &mut project_context, - cx, - ); - completion_messages.push(CompletionMessage::Tool { - content, - tool_call_id: tool_call.id.clone(), - }); - } - } - } - } - } - - let system_message = project_context.generate_system_message(cx); - - cx.background_executor().spawn(async move { - let content = system_message.await?; - completion_messages.insert(0, CompletionMessage::System { content }); - Ok(completion_messages) - }) - } - - fn serialize_message( - &self, - message: ChatMessage, - cx: &mut ViewContext, - ) -> SavedChatMessage { - match message { - ChatMessage::User(message) => SavedChatMessage::User { - id: message.id, - body: message.body.read(cx).source().into(), - attachments: message - .attachments - .iter() - .map(|attachment| { - self.attachment_registry - .serialize_user_attachment(attachment) - }) - .collect(), - }, - ChatMessage::Assistant(message) => SavedChatMessage::Assistant { - id: message.id, - error: message.error, - messages: message - .messages - .iter() - .map(|message| SavedAssistantMessagePart { - body: message.body.read(cx).source().to_string().into(), - tool_calls: message - .tool_calls - .iter() - .filter_map(|tool_call| { - self.tool_registry - .serialize_tool_call(tool_call, cx) - .log_err() - }) - .collect(), - }) - .collect(), - }, - } - } -} - -impl Render for AssistantChat { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let header_height = Spacing::Small.rems(cx) * 2.0 + ButtonSize::Default.rems(); - - div() - .relative() - .flex_1() - .v_flex() - .key_context("AssistantChat") - .on_action(cx.listener(Self::submit)) - .on_action(cx.listener(Self::cancel)) - .text_color(Color::Default.color(cx)) - .child(list(self.list_state.clone()).flex_1().pt(header_height)) - .child( - h_flex() - .absolute() - .top_0() - .justify_between() - .w_full() - .h(header_height) - .p(Spacing::Small.rems(cx)) - .child( - IconButton::new( - "toggle-saved-conversations", - if self.saved_conversations_open { - IconName::ChevronRight - } else { - IconName::ChevronLeft - }, - ) - .on_click(cx.listener(|this, _event, _cx| { - this.toggle_saved_conversations(); - })) - .tooltip(move |cx| Tooltip::text("Switch Conversations", cx)), - ) - .child( - h_flex() - .gap(Spacing::Large.rems(cx)) - .child( - IconButton::new("new-conversation", IconName::Plus) - .on_click(cx.listener(move |this, _event, cx| { - this.new_conversation(cx); - })) - .tooltip(move |cx| Tooltip::text("New Context", cx)), - ) - .child( - IconButton::new("assistant-menu", IconName::Menu) - .disabled(true) - .tooltip(move |cx| { - Tooltip::text( - "Coming soon – Assistant settings & controls", - cx, - ) - }), - ), - ), - ) - .when(self.saved_conversations_open, |element| { - element.child( - h_flex() - .absolute() - .top(header_height) - .w_full() - .child(self.saved_conversations.clone()), - ) - }) - .child(Composer::new( - self.composer_editor.clone(), - self.project_index_button.clone(), - self.active_file_button.clone(), - crate::ui::ModelSelector::new(cx.view().downgrade(), self.model.clone()) - .into_any_element(), - )) - } -} - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] -pub struct MessageId(usize); - -impl MessageId { - fn post_inc(&mut self) -> Self { - let id = *self; - self.0 += 1; - id - } -} - -enum ChatMessage { - User(UserMessage), - Assistant(AssistantMessage), -} - -impl ChatMessage { - fn focus_handle(&self, cx: &AppContext) -> Option { - match self { - ChatMessage::User(message) => Some(message.body.focus_handle(cx)), - ChatMessage::Assistant(_) => None, - } - } -} - -struct UserMessage { - pub id: MessageId, - pub body: View, - pub attachments: Vec, -} - -struct AssistantMessagePart { - pub body: View, - pub tool_calls: Vec, -} - -struct AssistantMessage { - pub id: MessageId, - pub messages: Vec, - pub error: Option, -} diff --git a/crates/assistant2/src/assistant_settings.rs b/crates/assistant2/src/assistant_settings.rs deleted file mode 100644 index 7d532faaeba6b0..00000000000000 --- a/crates/assistant2/src/assistant_settings.rs +++ /dev/null @@ -1,26 +0,0 @@ -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; - -#[derive(Default, Debug, Deserialize, Serialize, Clone)] -pub struct AssistantSettings { - pub enabled: bool, -} - -#[derive(Default, Debug, Deserialize, Serialize, Clone, JsonSchema)] -pub struct AssistantSettingsContent { - pub enabled: Option, -} - -impl Settings for AssistantSettings { - const KEY: Option<&'static str> = Some("assistant_v2"); - - type FileContent = AssistantSettingsContent; - - fn load( - sources: SettingsSources, - _: &mut gpui::AppContext, - ) -> anyhow::Result { - Ok(sources.json_merge().unwrap_or_else(|_| Default::default())) - } -} diff --git a/crates/assistant2/src/attachments.rs b/crates/assistant2/src/attachments.rs deleted file mode 100644 index 2187f855a447e0..00000000000000 --- a/crates/assistant2/src/attachments.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod active_file; - -pub use active_file::*; diff --git a/crates/assistant2/src/attachments/active_file.rs b/crates/assistant2/src/attachments/active_file.rs deleted file mode 100644 index 744d92689f289d..00000000000000 --- a/crates/assistant2/src/attachments/active_file.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::{path::PathBuf, sync::Arc}; - -use anyhow::{anyhow, Result}; -use assistant_tooling::{AttachmentOutput, LanguageModelAttachment, ProjectContext}; -use editor::Editor; -use gpui::{Render, Task, View, WeakModel, WeakView}; -use language::Buffer; -use project::ProjectPath; -use serde::{Deserialize, Serialize}; -use ui::{prelude::*, ButtonLike, Tooltip, WindowContext}; -use util::maybe; -use workspace::Workspace; - -#[derive(Serialize, Deserialize)] -pub struct ActiveEditorAttachment { - #[serde(skip)] - buffer: Option>, - path: Option, -} - -pub struct FileAttachmentView { - project_path: Option, - buffer: Option>, - error: Option, -} - -impl Render for FileAttachmentView { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - if let Some(error) = &self.error { - return div().child(error.to_string()).into_any_element(); - } - - let filename: SharedString = self - .project_path - .as_ref() - .and_then(|p| p.path.file_name()?.to_str()) - .unwrap_or("Untitled") - .to_string() - .into(); - - ButtonLike::new("file-attachment") - .child( - h_flex() - .gap_1() - .bg(cx.theme().colors().editor_background) - .rounded_md() - .child(ui::Icon::new(IconName::File)) - .child(filename.clone()), - ) - .tooltip(move |cx| Tooltip::with_meta("File Attached", None, filename.clone(), cx)) - .into_any_element() - } -} - -impl AttachmentOutput for FileAttachmentView { - fn generate(&self, project: &mut ProjectContext, cx: &mut WindowContext) -> String { - if let Some(path) = &self.project_path { - project.add_file(path.clone()); - return format!("current file: {}", path.path.display()); - } - - if let Some(buffer) = self.buffer.as_ref().and_then(|buffer| buffer.upgrade()) { - return format!("current untitled buffer text:\n{}", buffer.read(cx).text()); - } - - String::new() - } -} - -pub struct ActiveEditorAttachmentTool { - workspace: WeakView, -} - -impl ActiveEditorAttachmentTool { - pub fn new(workspace: WeakView, _cx: &mut WindowContext) -> Self { - Self { workspace } - } -} - -impl LanguageModelAttachment for ActiveEditorAttachmentTool { - type Output = ActiveEditorAttachment; - type View = FileAttachmentView; - - fn name(&self) -> Arc { - "active-editor-attachment".into() - } - - fn run(&self, cx: &mut WindowContext) -> Task> { - Task::ready(maybe!({ - let active_buffer = self - .workspace - .update(cx, |workspace, cx| { - workspace - .active_item(cx) - .and_then(|item| Some(item.act_as::(cx)?.read(cx).buffer().clone())) - })? - .ok_or_else(|| anyhow!("no active buffer"))?; - - let buffer = active_buffer.read(cx); - - if let Some(buffer) = buffer.as_singleton() { - let path = project::File::from_dyn(buffer.read(cx).file()) - .and_then(|file| file.worktree.read(cx).absolutize(&file.path).ok()); - return Ok(ActiveEditorAttachment { - buffer: Some(buffer.downgrade()), - path, - }); - } else { - Err(anyhow!("no active buffer")) - } - })) - } - - fn view( - &self, - output: Result, - cx: &mut WindowContext, - ) -> View { - let error; - let project_path; - let buffer; - match output { - Ok(output) => { - error = None; - let workspace = self.workspace.upgrade().unwrap(); - let project = workspace.read(cx).project(); - project_path = output - .path - .and_then(|path| project.read(cx).project_path_for_absolute_path(&path, cx)); - buffer = output.buffer; - } - Err(err) => { - error = Some(err); - buffer = None; - project_path = None; - } - } - cx.new_view(|_cx| FileAttachmentView { - project_path, - buffer, - error, - }) - } -} diff --git a/crates/assistant2/src/completion_provider.rs b/crates/assistant2/src/completion_provider.rs deleted file mode 100644 index deb87de8681fc4..00000000000000 --- a/crates/assistant2/src/completion_provider.rs +++ /dev/null @@ -1,179 +0,0 @@ -use anyhow::Result; -use assistant_tooling::ToolFunctionDefinition; -use client::{proto, Client}; -use futures::{future::BoxFuture, stream::BoxStream, FutureExt, StreamExt}; -use gpui::Global; -use std::sync::Arc; - -pub use open_ai::RequestMessage as CompletionMessage; - -#[derive(Clone)] -pub struct CompletionProvider(Arc); - -impl CompletionProvider { - pub fn new(backend: impl CompletionProviderBackend) -> Self { - Self(Arc::new(backend)) - } - - pub fn default_model(&self) -> String { - self.0.default_model() - } - - pub fn available_models(&self) -> Vec { - self.0.available_models() - } - - pub fn complete( - &self, - model: String, - messages: Vec, - stop: Vec, - temperature: f32, - tools: Vec, - ) -> BoxFuture<'static, Result>>> - { - self.0.complete(model, messages, stop, temperature, tools) - } -} - -impl Global for CompletionProvider {} - -pub trait CompletionProviderBackend: 'static { - fn default_model(&self) -> String; - fn available_models(&self) -> Vec; - fn complete( - &self, - model: String, - messages: Vec, - stop: Vec, - temperature: f32, - tools: Vec, - ) -> BoxFuture<'static, Result>>>; -} - -pub struct CloudCompletionProvider { - client: Arc, -} - -impl CloudCompletionProvider { - pub fn new(client: Arc) -> Self { - Self { client } - } -} - -impl CompletionProviderBackend for CloudCompletionProvider { - fn default_model(&self) -> String { - "gpt-4-turbo".into() - } - - fn available_models(&self) -> Vec { - vec!["gpt-4-turbo".into(), "gpt-4".into(), "gpt-3.5-turbo".into()] - } - - fn complete( - &self, - model: String, - messages: Vec, - stop: Vec, - temperature: f32, - tools: Vec, - ) -> BoxFuture<'static, Result>>> - { - let client = self.client.clone(); - let tools: Vec = tools - .iter() - .filter_map(|tool| { - Some(proto::ChatCompletionTool { - variant: Some(proto::chat_completion_tool::Variant::Function( - proto::chat_completion_tool::FunctionObject { - name: tool.name.clone(), - description: Some(tool.description.clone()), - parameters: Some(serde_json::to_string(&tool.parameters).ok()?), - }, - )), - }) - }) - .collect(); - - let tool_choice = match tools.is_empty() { - true => None, - false => Some("auto".into()), - }; - - async move { - let stream = client - .request_stream(proto::CompleteWithLanguageModel { - model, - messages: messages - .into_iter() - .map(|message| match message { - CompletionMessage::Assistant { - content, - tool_calls, - } => proto::LanguageModelRequestMessage { - role: proto::LanguageModelRole::LanguageModelAssistant as i32, - content: content.unwrap_or_default(), - tool_call_id: None, - tool_calls: tool_calls - .into_iter() - .map(|tool_call| match tool_call.content { - open_ai::ToolCallContent::Function { function } => { - proto::ToolCall { - id: tool_call.id, - variant: Some(proto::tool_call::Variant::Function( - proto::tool_call::FunctionCall { - name: function.name, - arguments: function.arguments, - }, - )), - } - } - }) - .collect(), - }, - CompletionMessage::User { content } => { - proto::LanguageModelRequestMessage { - role: proto::LanguageModelRole::LanguageModelUser as i32, - content, - tool_call_id: None, - tool_calls: Vec::new(), - } - } - CompletionMessage::System { content } => { - proto::LanguageModelRequestMessage { - role: proto::LanguageModelRole::LanguageModelSystem as i32, - content, - tool_calls: Vec::new(), - tool_call_id: None, - } - } - CompletionMessage::Tool { - content, - tool_call_id, - } => proto::LanguageModelRequestMessage { - role: proto::LanguageModelRole::LanguageModelTool as i32, - content, - tool_call_id: Some(tool_call_id), - tool_calls: Vec::new(), - }, - }) - .collect(), - stop, - temperature, - tool_choice, - tools, - }) - .await?; - - Ok(stream - .filter_map(|response| async move { - match response { - Ok(mut response) => Some(Ok(response.choices.pop()?.delta?)), - Err(error) => Some(Err(error)), - } - }) - .boxed()) - } - .boxed() - } -} diff --git a/crates/assistant2/src/saved_conversation.rs b/crates/assistant2/src/saved_conversation.rs deleted file mode 100644 index a46f8a54c9cbc0..00000000000000 --- a/crates/assistant2/src/saved_conversation.rs +++ /dev/null @@ -1,90 +0,0 @@ -use std::cmp::Reverse; -use std::ffi::OsStr; -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::Result; -use assistant_tooling::{SavedToolFunctionCall, SavedUserAttachment}; -use fs::Fs; -use futures::StreamExt; -use gpui::SharedString; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use util::paths::CONVERSATIONS_DIR; - -use crate::MessageId; - -#[derive(Serialize, Deserialize)] -pub struct SavedConversation { - /// The schema version of the conversation. - pub version: String, - /// The title of the conversation, generated by the Assistant. - pub title: String, - pub messages: Vec, -} - -#[derive(Serialize, Deserialize)] -pub enum SavedChatMessage { - User { - id: MessageId, - body: String, - attachments: Vec, - }, - Assistant { - id: MessageId, - messages: Vec, - error: Option, - }, -} - -#[derive(Serialize, Deserialize)] -pub struct SavedAssistantMessagePart { - pub body: SharedString, - pub tool_calls: Vec, -} - -pub struct SavedConversationMetadata { - pub title: String, - pub path: PathBuf, - pub mtime: chrono::DateTime, -} - -impl SavedConversationMetadata { - pub async fn list(fs: Arc) -> Result> { - fs.create_dir(&CONVERSATIONS_DIR).await?; - - let mut paths = fs.read_dir(&CONVERSATIONS_DIR).await?; - let mut conversations = Vec::new(); - while let Some(path) = paths.next().await { - let path = path?; - if path.extension() != Some(OsStr::new("json")) { - continue; - } - - let pattern = r" - \d+.zed.\d.\d.\d.json$"; - let re = Regex::new(pattern).unwrap(); - - let metadata = fs.metadata(&path).await?; - if let Some((file_name, metadata)) = path - .file_name() - .and_then(|name| name.to_str()) - .zip(metadata) - { - // This is used to filter out conversations saved by the old assistant. - if !re.is_match(file_name) { - continue; - } - - let title = re.replace(file_name, ""); - conversations.push(Self { - title: title.into_owned(), - path, - mtime: metadata.mtime.into(), - }); - } - } - conversations.sort_unstable_by_key(|conversation| Reverse(conversation.mtime)); - - Ok(conversations) - } -} diff --git a/crates/assistant2/src/saved_conversations.rs b/crates/assistant2/src/saved_conversations.rs deleted file mode 100644 index 4ddb90d7e4a839..00000000000000 --- a/crates/assistant2/src/saved_conversations.rs +++ /dev/null @@ -1,196 +0,0 @@ -use std::sync::Arc; - -use fuzzy::{match_strings, StringMatch, StringMatchCandidate}; -use gpui::{AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, View, WeakView}; -use picker::{Picker, PickerDelegate}; -use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing}; -use util::ResultExt; - -use crate::saved_conversation::SavedConversationMetadata; - -pub struct SavedConversations { - focus_handle: FocusHandle, - picker: Option>>, -} - -impl EventEmitter for SavedConversations {} - -impl FocusableView for SavedConversations { - fn focus_handle(&self, cx: &AppContext) -> FocusHandle { - if let Some(picker) = self.picker.as_ref() { - picker.focus_handle(cx) - } else { - self.focus_handle.clone() - } - } -} - -impl SavedConversations { - pub fn new(cx: &mut ViewContext) -> Self { - Self { - focus_handle: cx.focus_handle(), - picker: None, - } - } - - pub fn init( - &mut self, - saved_conversations: Vec, - cx: &mut ViewContext, - ) { - let delegate = - SavedConversationPickerDelegate::new(cx.view().downgrade(), saved_conversations); - self.picker = Some(cx.new_view(|cx| Picker::uniform_list(delegate, cx).modal(false))); - } -} - -impl Render for SavedConversations { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - v_flex() - .w_full() - .bg(cx.theme().colors().panel_background) - .children(self.picker.clone()) - } -} - -pub struct SavedConversationPickerDelegate { - view: WeakView, - saved_conversations: Vec, - selected_index: usize, - matches: Vec, -} - -impl SavedConversationPickerDelegate { - pub fn new( - weak_view: WeakView, - saved_conversations: Vec, - ) -> Self { - let matches = saved_conversations - .iter() - .map(|conversation| StringMatch { - candidate_id: 0, - score: 0.0, - positions: Default::default(), - string: conversation.title.clone(), - }) - .collect(); - - Self { - view: weak_view, - saved_conversations, - selected_index: 0, - matches, - } - } -} - -impl PickerDelegate for SavedConversationPickerDelegate { - type ListItem = ui::ListItem; - - fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { - "Select saved conversation...".into() - } - - fn match_count(&self) -> usize { - self.matches.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext>) { - self.selected_index = ix; - } - - fn update_matches( - &mut self, - query: String, - cx: &mut ViewContext>, - ) -> gpui::Task<()> { - let background_executor = cx.background_executor().clone(); - let candidates = self - .saved_conversations - .iter() - .enumerate() - .map(|(id, conversation)| { - let text = conversation.title.clone(); - - StringMatchCandidate { - id, - char_bag: text.as_str().into(), - string: text, - } - }) - .collect::>(); - - cx.spawn(move |this, mut cx| async move { - let matches = if query.is_empty() { - candidates - .into_iter() - .enumerate() - .map(|(index, candidate)| StringMatch { - candidate_id: index, - string: candidate.string, - positions: Vec::new(), - score: 0.0, - }) - .collect() - } else { - match_strings( - &candidates, - &query, - false, - 100, - &Default::default(), - background_executor, - ) - .await - }; - - this.update(&mut cx, |this, _cx| { - this.delegate.matches = matches; - this.delegate.selected_index = this - .delegate - .selected_index - .min(this.delegate.matches.len().saturating_sub(1)); - }) - .log_err(); - }) - } - - fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { - if self.matches.is_empty() { - self.dismissed(cx); - return; - } - - // TODO: Implement selecting a saved conversation. - } - - fn dismissed(&mut self, cx: &mut ui::prelude::ViewContext>) { - self.view - .update(cx, |_, cx| cx.emit(DismissEvent)) - .log_err(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _cx: &mut ViewContext>, - ) -> Option { - let conversation_match = &self.matches[ix]; - let _conversation = &self.saved_conversations[conversation_match.candidate_id]; - - Some( - ListItem::new(ix) - .spacing(ListItemSpacing::Sparse) - .selected(selected) - .child(HighlightedLabel::new( - conversation_match.string.clone(), - conversation_match.positions.clone(), - )), - ) - } -} diff --git a/crates/assistant2/src/tools.rs b/crates/assistant2/src/tools.rs deleted file mode 100644 index f60f41c5862edd..00000000000000 --- a/crates/assistant2/src/tools.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod annotate_code; -mod create_buffer; -mod project_index; - -pub use annotate_code::*; -pub use create_buffer::*; -pub use project_index::*; diff --git a/crates/assistant2/src/tools/annotate_code.rs b/crates/assistant2/src/tools/annotate_code.rs deleted file mode 100644 index afee701054ae78..00000000000000 --- a/crates/assistant2/src/tools/annotate_code.rs +++ /dev/null @@ -1,304 +0,0 @@ -use anyhow::Result; -use assistant_tooling::{LanguageModelTool, ProjectContext, ToolView}; -use editor::{ - display_map::{BlockContext, BlockDisposition, BlockProperties, BlockStyle}, - Editor, MultiBuffer, -}; -use futures::{channel::mpsc::UnboundedSender, StreamExt as _}; -use gpui::{prelude::*, AnyElement, AsyncWindowContext, Model, Task, View, WeakView}; -use language::ToPoint; -use project::{search::SearchQuery, Project, ProjectPath}; -use schemars::JsonSchema; -use serde::Deserialize; -use std::path::Path; -use ui::prelude::*; -use util::ResultExt; -use workspace::Workspace; - -pub struct AnnotationTool { - workspace: WeakView, - project: Model, -} - -impl AnnotationTool { - pub fn new(workspace: WeakView, project: Model) -> Self { - Self { workspace, project } - } -} - -#[derive(Default, Debug, Deserialize, JsonSchema, Clone)] -pub struct AnnotationInput { - /// Name for this set of annotations - #[serde(default = "default_title")] - title: String, - /// Excerpts from the file to show to the user. - excerpts: Vec, -} - -fn default_title() -> String { - "Untitled".to_string() -} - -#[derive(Debug, Deserialize, JsonSchema, Clone)] -struct Excerpt { - /// Path to the file - path: String, - /// A short, distinctive string that appears in the file, used to define a location in the file. - text_passage: String, - /// Text to display above the code excerpt - annotation: String, -} - -impl LanguageModelTool for AnnotationTool { - type View = AnnotationResultView; - - fn name(&self) -> String { - "annotate_code".to_string() - } - - fn description(&self) -> String { - "Dynamically annotate symbols in the current codebase. Opens a buffer in a panel in their editor, to the side of the conversation. The annotations are shown in the editor as a block decoration.".to_string() - } - - fn view(&self, cx: &mut WindowContext) -> View { - cx.new_view(|cx| { - let (tx, mut rx) = futures::channel::mpsc::unbounded(); - cx.spawn(|view, mut cx| async move { - while let Some(excerpt) = rx.next().await { - AnnotationResultView::add_excerpt(view.clone(), excerpt, &mut cx).await?; - } - anyhow::Ok(()) - }) - .detach(); - - AnnotationResultView { - project: self.project.clone(), - workspace: self.workspace.clone(), - tx, - pending_excerpt: None, - added_editor_to_workspace: false, - editor: None, - error: None, - rendered_excerpt_count: 0, - } - }) - } -} - -pub struct AnnotationResultView { - workspace: WeakView, - project: Model, - pending_excerpt: Option, - added_editor_to_workspace: bool, - editor: Option>, - tx: UnboundedSender, - error: Option, - rendered_excerpt_count: usize, -} - -impl AnnotationResultView { - async fn add_excerpt( - this: WeakView, - excerpt: Excerpt, - cx: &mut AsyncWindowContext, - ) -> Result<()> { - let project = this.update(cx, |this, _cx| this.project.clone())?; - - let worktree_id = project.update(cx, |project, cx| { - let worktree = project.worktrees().next()?; - let worktree_id = worktree.read(cx).id(); - Some(worktree_id) - })?; - - let worktree_id = if let Some(worktree_id) = worktree_id { - worktree_id - } else { - return Err(anyhow::anyhow!("No worktree found")); - }; - - let buffer_task = project.update(cx, |project, cx| { - project.open_buffer( - ProjectPath { - worktree_id, - path: Path::new(&excerpt.path).into(), - }, - cx, - ) - })?; - - let buffer = match buffer_task.await { - Ok(buffer) => buffer, - Err(error) => { - return this.update(cx, |this, cx| { - this.error = Some(error); - cx.notify(); - }) - } - }; - - let snapshot = buffer.update(cx, |buffer, _cx| buffer.snapshot())?; - let query = SearchQuery::text(&excerpt.text_passage, false, false, false, vec![], vec![])?; - let matches = query.search(&snapshot, None).await; - let Some(first_match) = matches.first() else { - log::warn!( - "text {:?} does not appear in '{}'", - excerpt.text_passage, - excerpt.path - ); - return Ok(()); - }; - - this.update(cx, |this, cx| { - let mut start = first_match.start.to_point(&snapshot); - start.column = 0; - - if let Some(editor) = &this.editor { - editor.update(cx, |editor, cx| { - let ranges = editor.buffer().update(cx, |multibuffer, cx| { - multibuffer.push_excerpts_with_context_lines( - buffer.clone(), - vec![start..start], - 5, - cx, - ) - }); - - let annotation = SharedString::from(excerpt.annotation); - editor.insert_blocks( - [BlockProperties { - position: ranges[0].start, - height: annotation.split('\n').count() as u8 + 1, - style: BlockStyle::Fixed, - render: Box::new(move |cx| Self::render_note_block(&annotation, cx)), - disposition: BlockDisposition::Above, - }], - None, - cx, - ); - }); - - if !this.added_editor_to_workspace { - this.added_editor_to_workspace = true; - this.workspace - .update(cx, |workspace, cx| { - workspace.add_item_to_active_pane(Box::new(editor.clone()), None, cx); - }) - .log_err(); - } - } - })?; - - Ok(()) - } - - fn render_note_block(explanation: &SharedString, cx: &mut BlockContext) -> AnyElement { - let anchor_x = cx.anchor_x; - let gutter_width = cx.gutter_dimensions.width; - - h_flex() - .w_full() - .py_2() - .border_y_1() - .border_color(cx.theme().colors().border) - .child( - h_flex() - .justify_center() - .w(gutter_width) - .child(Icon::new(IconName::Ai).color(Color::Hint)), - ) - .child( - h_flex() - .w_full() - .ml(anchor_x - gutter_width) - .child(explanation.clone()), - ) - .into_any_element() - } -} - -impl Render for AnnotationResultView { - fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - if let Some(error) = &self.error { - ui::Label::new(error.to_string()).into_any_element() - } else { - ui::Label::new(SharedString::from(format!( - "Opened a buffer with {} excerpts", - self.rendered_excerpt_count - ))) - .into_any_element() - } - } -} - -impl ToolView for AnnotationResultView { - type Input = AnnotationInput; - type SerializedState = Option; - - fn generate(&self, _: &mut ProjectContext, _: &mut ViewContext) -> String { - if let Some(error) = &self.error { - format!("Failed to create buffer: {error:?}") - } else { - format!( - "opened {} excerpts in a buffer", - self.rendered_excerpt_count - ) - } - } - - fn set_input(&mut self, mut input: Self::Input, cx: &mut ViewContext) { - let editor = if let Some(editor) = &self.editor { - editor.clone() - } else { - let multibuffer = cx.new_model(|_cx| { - MultiBuffer::new(0, language::Capability::ReadWrite).with_title(String::new()) - }); - let editor = cx.new_view(|cx| { - Editor::for_multibuffer(multibuffer.clone(), Some(self.project.clone()), cx) - }); - - self.editor = Some(editor.clone()); - editor - }; - - editor.update(cx, |editor, cx| { - editor.buffer().update(cx, |multibuffer, cx| { - if multibuffer.title(cx) != input.title { - multibuffer.set_title(input.title.clone(), cx); - } - }); - - self.pending_excerpt = input.excerpts.pop(); - for excerpt in input.excerpts.iter().skip(self.rendered_excerpt_count) { - self.tx.unbounded_send(excerpt.clone()).ok(); - } - self.rendered_excerpt_count = input.excerpts.len(); - }); - - cx.notify(); - } - - fn execute(&mut self, _cx: &mut ViewContext) -> Task> { - if let Some(excerpt) = self.pending_excerpt.take() { - self.rendered_excerpt_count += 1; - self.tx.unbounded_send(excerpt.clone()).ok(); - } - - self.tx.close_channel(); - Task::ready(Ok(())) - } - - fn serialize(&self, _cx: &mut ViewContext) -> Self::SerializedState { - self.error.as_ref().map(|error| error.to_string()) - } - - fn deserialize( - &mut self, - output: Self::SerializedState, - _cx: &mut ViewContext, - ) -> Result<()> { - if let Some(error_message) = output { - self.error = Some(anyhow::anyhow!("{}", error_message)); - } - Ok(()) - } -} diff --git a/crates/assistant2/src/tools/create_buffer.rs b/crates/assistant2/src/tools/create_buffer.rs deleted file mode 100644 index 894ee75d554ab3..00000000000000 --- a/crates/assistant2/src/tools/create_buffer.rs +++ /dev/null @@ -1,145 +0,0 @@ -use anyhow::{anyhow, Result}; -use assistant_tooling::{LanguageModelTool, ProjectContext, ToolView}; -use editor::Editor; -use gpui::{prelude::*, Model, Task, View, WeakView}; -use project::Project; -use schemars::JsonSchema; -use serde::Deserialize; -use ui::prelude::*; -use util::ResultExt; -use workspace::Workspace; - -pub struct CreateBufferTool { - workspace: WeakView, - project: Model, -} - -impl CreateBufferTool { - pub fn new(workspace: WeakView, project: Model) -> Self { - Self { workspace, project } - } -} - -#[derive(Debug, Clone, Deserialize, JsonSchema)] -pub struct CreateBufferInput { - /// The contents of the buffer. - text: String, - - /// The name of the language to use for the buffer. - /// - /// This should be a human-readable name, like "Rust", "JavaScript", or "Python". - language: String, -} - -impl LanguageModelTool for CreateBufferTool { - type View = CreateBufferView; - - fn name(&self) -> String { - "create_file".to_string() - } - - fn description(&self) -> String { - "Create a new untitled file in the current codebase. Side effect: opens it in a new pane/tab for the user to edit.".to_string() - } - - fn view(&self, cx: &mut WindowContext) -> View { - cx.new_view(|_cx| CreateBufferView { - workspace: self.workspace.clone(), - project: self.project.clone(), - input: None, - error: None, - }) - } -} - -pub struct CreateBufferView { - workspace: WeakView, - project: Model, - input: Option, - error: Option, -} - -impl Render for CreateBufferView { - fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - ui::Label::new("Opening a buffer") - } -} - -impl ToolView for CreateBufferView { - type Input = CreateBufferInput; - - type SerializedState = (); - - fn generate(&self, _project: &mut ProjectContext, _cx: &mut ViewContext) -> String { - let Some(input) = self.input.as_ref() else { - return "No input".to_string(); - }; - - match &self.error { - None => format!("Created a new {} buffer", input.language), - Some(err) => format!("Failed to create buffer: {err:?}"), - } - } - - fn set_input(&mut self, input: Self::Input, cx: &mut ViewContext) { - self.input = Some(input); - cx.notify(); - } - - fn execute(&mut self, cx: &mut ViewContext) -> Task> { - cx.spawn({ - let workspace = self.workspace.clone(); - let project = self.project.clone(); - let input = self.input.clone(); - |_this, mut cx| async move { - let input = input.ok_or_else(|| anyhow!("no input"))?; - - let text = input.text.clone(); - let language_name = input.language.clone(); - let language = cx - .update(|cx| { - project - .read(cx) - .languages() - .language_for_name(&language_name) - })? - .await?; - - let buffer = cx - .update(|cx| project.update(cx, |project, cx| project.create_buffer(cx)))? - .await?; - - buffer.update(&mut cx, |buffer, cx| { - buffer.edit([(0..0, text)], None, cx); - buffer.set_language(Some(language), cx) - })?; - - workspace - .update(&mut cx, |workspace, cx| { - workspace.add_item_to_active_pane( - Box::new( - cx.new_view(|cx| Editor::for_buffer(buffer, Some(project), cx)), - ), - None, - cx, - ); - }) - .log_err(); - - Ok(()) - } - }) - } - - fn serialize(&self, _cx: &mut ViewContext) -> Self::SerializedState { - () - } - - fn deserialize( - &mut self, - _output: Self::SerializedState, - _cx: &mut ViewContext, - ) -> Result<()> { - Ok(()) - } -} diff --git a/crates/assistant2/src/tools/project_index.rs b/crates/assistant2/src/tools/project_index.rs deleted file mode 100644 index 5d28d470f3191f..00000000000000 --- a/crates/assistant2/src/tools/project_index.rs +++ /dev/null @@ -1,428 +0,0 @@ -use anyhow::Result; -use assistant_tooling::{LanguageModelTool, ToolView}; -use collections::BTreeMap; -use file_icons::FileIcons; -use gpui::{prelude::*, AnyElement, Model, Task}; -use project::ProjectPath; -use schemars::JsonSchema; -use semantic_index::{ProjectIndex, Status}; -use serde::{Deserialize, Serialize}; -use std::{ - fmt::Write as _, - ops::Range, - path::{Path, PathBuf}, - str::FromStr as _, - sync::Arc, -}; -use ui::{prelude::*, CollapsibleContainer, Color, Icon, IconName, Label, WindowContext}; - -const DEFAULT_SEARCH_LIMIT: usize = 20; - -pub struct ProjectIndexTool { - project_index: Model, -} - -#[derive(Default)] -enum ProjectIndexToolState { - #[default] - CollectingQuery, - Searching, - Error(anyhow::Error), - Finished { - excerpts: BTreeMap>>, - index_status: Status, - }, -} - -pub struct ProjectIndexView { - project_index: Model, - input: CodebaseQuery, - expanded_header: bool, - state: ProjectIndexToolState, -} - -#[derive(Default, Deserialize, JsonSchema)] -pub struct CodebaseQuery { - /// Semantic search query - query: String, - /// Criteria to include results - includes: Option, - /// Criteria to exclude results - excludes: Option, -} - -#[derive(Deserialize, JsonSchema, Clone, Default)] -pub struct SearchFilter { - /// Filter by file path prefix - prefix_path: Option, - /// Filter by file extension - extension: Option, - // Note: we possibly can't do content filtering very easily given the project context handling - // the final results, so we're leaving out direct string matches for now -} - -fn project_starts_with(prefix_path: Option, project_path: ProjectPath) -> bool { - if let Some(path) = &prefix_path { - if let Some(path) = PathBuf::from_str(path).ok() { - return project_path.path.starts_with(path); - } - } - - return false; -} - -impl SearchFilter { - fn matches(&self, project_path: &ProjectPath) -> bool { - let path_match = project_starts_with(self.prefix_path.clone(), project_path.clone()); - - path_match - && (if let Some(extension) = &self.extension { - project_path - .path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext == extension) - .unwrap_or(false) - } else { - true - }) - } -} - -#[derive(Serialize, Deserialize)] -pub struct SerializedState { - index_status: Status, - error_message: Option, - worktrees: BTreeMap, WorktreeIndexOutput>, -} - -#[derive(Default, Serialize, Deserialize)] -struct WorktreeIndexOutput { - excerpts: BTreeMap, Vec>>, -} - -impl ProjectIndexView { - fn toggle_header(&mut self, cx: &mut ViewContext) { - self.expanded_header = !self.expanded_header; - cx.notify(); - } - - fn render_filter_section( - &mut self, - heading: &str, - filter: Option, - cx: &mut ViewContext, - ) -> Option { - let filter = match filter { - Some(filter) => filter, - None => return None, - }; - - // Any of the filter fields can be empty. We'll show nothing if they're all empty. - let path = filter.prefix_path.as_ref().map(|path| { - let icon_path = FileIcons::get_icon(Path::new(path), cx) - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from("icons/file_icons/file.svg")); - - h_flex() - .gap_1() - .child("Paths: ") - .child(Icon::from_path(icon_path)) - .child(ui::Label::new(path.clone()).color(Color::Muted)) - }); - - let extension = filter.extension.as_ref().map(|extension| { - let icon_path = FileIcons::get_icon(Path::new(extension), cx) - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from("icons/file_icons/file.svg")); - - h_flex() - .gap_1() - .child("Extensions: ") - .child(Icon::from_path(icon_path)) - .child(ui::Label::new(extension.clone()).color(Color::Muted)) - }); - - if path.is_none() && extension.is_none() { - return None; - } - - Some( - v_flex() - .child(ui::Label::new(heading.to_string())) - .gap_1() - .children(path) - .children(extension) - .into_any_element(), - ) - } -} - -impl Render for ProjectIndexView { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let query = self.input.query.clone(); - - let (header_text, content) = match &self.state { - ProjectIndexToolState::Error(error) => { - return format!("failed to search: {error:?}").into_any_element() - } - ProjectIndexToolState::CollectingQuery | ProjectIndexToolState::Searching => { - ("Searching...".to_string(), div()) - } - ProjectIndexToolState::Finished { excerpts, .. } => { - let file_count = excerpts.len(); - - if excerpts.is_empty() { - ("No results found".to_string(), div()) - } else { - let header_text = format!( - "Read {} {}", - file_count, - if file_count == 1 { "file" } else { "files" } - ); - - let el = v_flex().gap_2().children(excerpts.keys().map(|path| { - h_flex().gap_2().child(Icon::new(IconName::File)).child( - Label::new(path.path.to_string_lossy().to_string()).color(Color::Muted), - ) - })); - - (header_text, el) - } - } - }; - - let header = h_flex() - .gap_2() - .child(Icon::new(IconName::File)) - .child(header_text); - - v_flex() - .gap_3() - .child( - CollapsibleContainer::new("collapsible-container", self.expanded_header) - .start_slot(header) - .on_click(cx.listener(move |this, _, cx| { - this.toggle_header(cx); - })) - .child( - v_flex() - .gap_3() - .p_3() - .child( - h_flex() - .gap_2() - .child(Icon::new(IconName::MagnifyingGlass)) - .child(Label::new(format!("`{}`", query)).color(Color::Muted)), - ) - .children(self.render_filter_section( - "Includes", - self.input.includes.clone(), - cx, - )) - .children(self.render_filter_section( - "Excludes", - self.input.excludes.clone(), - cx, - )) - .child(content), - ), - ) - .into_any_element() - } -} - -impl ToolView for ProjectIndexView { - type Input = CodebaseQuery; - type SerializedState = SerializedState; - - fn generate( - &self, - context: &mut assistant_tooling::ProjectContext, - _: &mut ViewContext, - ) -> String { - match &self.state { - ProjectIndexToolState::CollectingQuery => String::new(), - ProjectIndexToolState::Searching => String::new(), - ProjectIndexToolState::Error(error) => format!("failed to search: {error:?}"), - ProjectIndexToolState::Finished { - excerpts, - index_status, - } => { - let mut body = "found results in the following paths:\n".to_string(); - - for (project_path, ranges) in excerpts { - context.add_excerpts(project_path.clone(), ranges); - writeln!(&mut body, "* {}", &project_path.path.display()).unwrap(); - } - - if *index_status != Status::Idle { - body.push_str("Still indexing. Results may be incomplete.\n"); - } - - body - } - } - } - - fn set_input(&mut self, input: Self::Input, cx: &mut ViewContext) { - self.input = input; - cx.notify(); - } - - fn execute(&mut self, cx: &mut ViewContext) -> Task> { - self.state = ProjectIndexToolState::Searching; - cx.notify(); - - let project_index = self.project_index.read(cx); - let index_status = project_index.status(); - - // TODO: wire the filters into the search here instead of processing after. - // Otherwise we'll get zero results sometimes. - let search = project_index.search(self.input.query.clone(), DEFAULT_SEARCH_LIMIT, cx); - - let includes = self.input.includes.clone(); - let excludes = self.input.excludes.clone(); - - cx.spawn(|this, mut cx| async move { - let search_result = search.await; - this.update(&mut cx, |this, cx| { - match search_result { - Ok(search_results) => { - let mut excerpts = BTreeMap::>>::new(); - for search_result in search_results { - let project_path = ProjectPath { - worktree_id: search_result.worktree.read(cx).id(), - path: search_result.path, - }; - - if let Some(includes) = &includes { - if !includes.matches(&project_path) { - continue; - } - } else if let Some(excludes) = &excludes { - if excludes.matches(&project_path) { - continue; - } - } - - excerpts - .entry(project_path) - .or_default() - .push(search_result.range); - } - this.state = ProjectIndexToolState::Finished { - excerpts, - index_status, - }; - } - Err(error) => { - this.state = ProjectIndexToolState::Error(error); - } - } - cx.notify(); - }) - }) - } - - fn serialize(&self, cx: &mut ViewContext) -> Self::SerializedState { - let mut serialized = SerializedState { - error_message: None, - index_status: Status::Idle, - worktrees: Default::default(), - }; - match &self.state { - ProjectIndexToolState::Error(err) => serialized.error_message = Some(err.to_string()), - ProjectIndexToolState::Finished { - excerpts, - index_status, - } => { - serialized.index_status = *index_status; - if let Some(project) = self.project_index.read(cx).project().upgrade() { - let project = project.read(cx); - for (project_path, excerpts) in excerpts { - if let Some(worktree) = - project.worktree_for_id(project_path.worktree_id, cx) - { - let worktree_path = worktree.read(cx).abs_path(); - serialized - .worktrees - .entry(worktree_path) - .or_default() - .excerpts - .insert(project_path.path.clone(), excerpts.clone()); - } - } - } - } - _ => {} - } - serialized - } - - fn deserialize( - &mut self, - serialized: Self::SerializedState, - cx: &mut ViewContext, - ) -> Result<()> { - if !serialized.worktrees.is_empty() { - let mut excerpts = BTreeMap::>>::new(); - if let Some(project) = self.project_index.read(cx).project().upgrade() { - let project = project.read(cx); - for (worktree_path, worktree_state) in serialized.worktrees { - if let Some(worktree) = project - .worktrees() - .find(|worktree| worktree.read(cx).abs_path() == worktree_path) - { - let worktree_id = worktree.read(cx).id(); - for (path, serialized_excerpts) in worktree_state.excerpts { - excerpts.insert(ProjectPath { worktree_id, path }, serialized_excerpts); - } - } - } - } - self.state = ProjectIndexToolState::Finished { - excerpts, - index_status: serialized.index_status, - }; - } - cx.notify(); - Ok(()) - } -} - -impl ProjectIndexTool { - pub fn new(project_index: Model) -> Self { - Self { project_index } - } -} - -impl LanguageModelTool for ProjectIndexTool { - type View = ProjectIndexView; - - fn name(&self) -> String { - "semantic_search_codebase".to_string() - } - - fn description(&self) -> String { - unindent::unindent( - r#"This search tool uses a semantic index to perform search queries across your codebase, identifying and returning excerpts of text and code possibly related to the query. - - Ideal for: - - Discovering implementations of similar logic within the project - - Finding usage examples of functions, classes/structures, libraries, and other code elements - - Developing understanding of the codebase's architecture and design - - Note: The search's effectiveness is directly related to the current state of the codebase and the specificity of your query. It is recommended that you use snippets of code that are similar to the code you wish to find."#, - ) - } - - fn view(&self, cx: &mut WindowContext) -> gpui::View { - cx.new_view(|_| ProjectIndexView { - state: ProjectIndexToolState::CollectingQuery, - input: Default::default(), - expanded_header: false, - project_index: self.project_index.clone(), - }) - } -} diff --git a/crates/assistant2/src/ui.rs b/crates/assistant2/src/ui.rs deleted file mode 100644 index 3333620a472c42..00000000000000 --- a/crates/assistant2/src/ui.rs +++ /dev/null @@ -1,17 +0,0 @@ -mod active_file_button; -mod chat_message; -mod chat_notice; -mod composer; -mod project_index_button; - -#[cfg(feature = "stories")] -mod stories; - -pub use active_file_button::*; -pub use chat_message::*; -pub use chat_notice::*; -pub use composer::*; -pub use project_index_button::*; - -#[cfg(feature = "stories")] -pub use stories::*; diff --git a/crates/assistant2/src/ui/active_file_button.rs b/crates/assistant2/src/ui/active_file_button.rs deleted file mode 100644 index 10415785686f46..00000000000000 --- a/crates/assistant2/src/ui/active_file_button.rs +++ /dev/null @@ -1,134 +0,0 @@ -use crate::attachments::ActiveEditorAttachmentTool; -use assistant_tooling::AttachmentRegistry; -use editor::Editor; -use gpui::{prelude::*, Subscription, View}; -use std::sync::Arc; -use ui::{prelude::*, ButtonLike, Color, Icon, IconName, Tooltip}; -use workspace::Workspace; - -#[derive(Clone)] -enum Status { - ActiveFile(String), - #[allow(dead_code)] - NoFile, -} - -pub struct ActiveFileButton { - attachment_registry: Arc, - status: Status, - #[allow(dead_code)] - workspace_subscription: Subscription, -} - -impl ActiveFileButton { - pub fn new( - attachment_registry: Arc, - workspace: View, - cx: &mut ViewContext, - ) -> Self { - let workspace_subscription = cx.subscribe(&workspace, Self::handle_workspace_event); - - cx.defer(move |this, cx| this.update_active_buffer(workspace.clone(), cx)); - - Self { - attachment_registry, - status: Status::NoFile, - workspace_subscription, - } - } - - pub fn set_enabled(&mut self, enabled: bool) { - self.attachment_registry - .set_attachment_tool_enabled::(enabled); - } - - pub fn update_active_buffer(&mut self, workspace: View, cx: &mut ViewContext) { - let active_buffer = workspace - .read(cx) - .active_item(cx) - .and_then(|item| Some(item.act_as::(cx)?.read(cx).buffer().clone())); - - if let Some(buffer) = active_buffer { - let buffer = buffer.read(cx); - - if let Some(singleton) = buffer.as_singleton() { - let singleton = singleton.read(cx); - - let filename: String = singleton - .file() - .map(|file| file.path().to_string_lossy()) - .unwrap_or("Untitled".into()) - .into(); - - self.status = Status::ActiveFile(filename); - } - } - } - - fn handle_workspace_event( - &mut self, - workspace: View, - event: &workspace::Event, - cx: &mut ViewContext, - ) { - if let workspace::Event::ActiveItemChanged = event { - self.update_active_buffer(workspace, cx); - } - } -} - -impl Render for ActiveFileButton { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let is_enabled = self - .attachment_registry - .is_attachment_tool_enabled::(); - - let icon = if is_enabled { - Icon::new(IconName::File) - .size(IconSize::XSmall) - .color(Color::Default) - } else { - Icon::new(IconName::File) - .size(IconSize::XSmall) - .color(Color::Disabled) - }; - - let indicator = None; - - let status = self.status.clone(); - - ButtonLike::new("active-file-button") - .child( - ui::IconWithIndicator::new(icon, indicator) - .indicator_border_color(Some(gpui::transparent_black())), - ) - .tooltip({ - move |cx| { - let status = status.clone(); - let (tooltip, meta) = match (is_enabled, status) { - (false, _) => ( - "Active file disabled".to_string(), - Some("Click to enable".to_string()), - ), - (true, Status::ActiveFile(filename)) => ( - format!("Active file {filename} enabled"), - Some("Click to disable".to_string()), - ), - (true, Status::NoFile) => { - ("No file active for conversation".to_string(), None) - } - }; - - if let Some(meta) = meta { - Tooltip::with_meta(tooltip, None, meta, cx) - } else { - Tooltip::text(tooltip, cx) - } - } - }) - .on_click(cx.listener(move |this, _, cx| { - this.set_enabled(!is_enabled); - cx.notify(); - })) - } -} diff --git a/crates/assistant2/src/ui/chat_message.rs b/crates/assistant2/src/ui/chat_message.rs deleted file mode 100644 index fb07e55b5c141d..00000000000000 --- a/crates/assistant2/src/ui/chat_message.rs +++ /dev/null @@ -1,140 +0,0 @@ -use std::sync::Arc; - -use client::User; -use gpui::{hsla, AnyElement, ClickEvent}; -use ui::{prelude::*, Avatar, Tooltip}; - -use crate::MessageId; - -pub enum UserOrAssistant { - User(Option>), - Assistant, -} - -#[derive(IntoElement)] -pub struct ChatMessage { - id: MessageId, - player: UserOrAssistant, - messages: Vec, - selected: bool, - collapsed: bool, - on_collapse_handle_click: Box, -} - -impl ChatMessage { - pub fn new( - id: MessageId, - player: UserOrAssistant, - messages: Vec, - collapsed: bool, - on_collapse_handle_click: Box, - ) -> Self { - Self { - id, - player, - messages, - selected: false, - collapsed, - on_collapse_handle_click, - } - } -} - -impl Selectable for ChatMessage { - fn selected(mut self, selected: bool) -> Self { - self.selected = selected; - self - } -} - -impl RenderOnce for ChatMessage { - fn render(self, cx: &mut WindowContext) -> impl IntoElement { - let message_group = SharedString::from(format!("{}_group", self.id.0)); - - let collapse_handle_id = SharedString::from(format!("{}_collapse_handle", self.id.0)); - - let content_padding = Spacing::Small.rems(cx); - // Clamp the message height to exactly 1.5 lines when collapsed. - let collapsed_height = content_padding.to_pixels(cx.rem_size()) + cx.line_height() * 1.5; - - let background_color = if let UserOrAssistant::User(_) = &self.player { - Some(cx.theme().colors().surface_background) - } else { - None - }; - - let (username, avatar_uri) = match self.player { - UserOrAssistant::Assistant => ( - "Assistant".into(), - Some("https://zed.dev/assistant_avatar.png".into()), - ), - UserOrAssistant::User(Some(user)) => { - (user.github_login.clone(), Some(user.avatar_uri.clone())) - } - UserOrAssistant::User(None) => ("You".into(), None), - }; - - v_flex() - .group(message_group.clone()) - .gap(Spacing::XSmall.rems(cx)) - .p(Spacing::XSmall.rems(cx)) - .when(self.selected, |element| { - element.bg(hsla(0.6, 0.67, 0.46, 0.12)) - }) - .rounded_lg() - .child( - h_flex() - .justify_between() - .px(content_padding) - .child( - h_flex() - .gap_2() - .map(|this| { - let avatar_size = rems_from_px(20.); - if let Some(avatar_uri) = avatar_uri { - this.child(Avatar::new(avatar_uri).size(avatar_size)) - } else { - this.child(div().size(avatar_size)) - } - }) - .child(Label::new(username).color(Color::Muted)), - ) - .child( - h_flex().visible_on_hover(message_group).child( - // temp icons - IconButton::new( - collapse_handle_id.clone(), - if self.collapsed { - IconName::ArrowUp - } else { - IconName::ArrowDown - }, - ) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .on_click(self.on_collapse_handle_click) - .tooltip(|cx| Tooltip::text("Collapse Message", cx)), - ), - ), - ) - .when(self.messages.len() > 0, |el| { - el.child( - h_flex().w_full().child( - v_flex() - .relative() - .overflow_hidden() - .w_full() - .p(content_padding) - .gap_3() - .text_ui(cx) - .rounded_lg() - .when_some(background_color, |this, background_color| { - this.bg(background_color) - }) - .when(self.collapsed, |this| this.h(collapsed_height)) - .children(self.messages), - ), - ) - }) - } -} diff --git a/crates/assistant2/src/ui/chat_notice.rs b/crates/assistant2/src/ui/chat_notice.rs deleted file mode 100644 index 5001d2d23e8a00..00000000000000 --- a/crates/assistant2/src/ui/chat_notice.rs +++ /dev/null @@ -1,71 +0,0 @@ -use ui::{prelude::*, Avatar, IconButtonShape}; - -#[derive(IntoElement)] -pub struct ChatNotice { - message: SharedString, - meta: Option, -} - -impl ChatNotice { - pub fn new(message: impl Into) -> Self { - Self { - message: message.into(), - meta: None, - } - } - - pub fn meta(mut self, meta: impl Into) -> Self { - self.meta = Some(meta.into()); - self - } -} - -impl RenderOnce for ChatNotice { - fn render(self, _cx: &mut WindowContext) -> impl IntoElement { - h_flex() - .w_full() - .items_start() - .mt_4() - .gap_3() - .child( - // TODO: Replace with question mark. - Avatar::new("https://zed.dev/assistant_avatar.png").size(rems_from_px(20.)), - ) - .child( - v_flex() - .size_full() - .gap_1() - .pr_4() - .overflow_hidden() - .child( - h_flex() - .justify_between() - .overflow_hidden() - .child( - h_flex() - .flex_none() - .overflow_hidden() - .child(Label::new(self.message)), - ) - .child( - h_flex() - .flex_shrink_0() - .gap_1() - .child(Button::new("allow", "Allow")) - .child( - IconButton::new("deny", IconName::Close) - .shape(IconButtonShape::Square) - .icon_color(Color::Muted) - .size(ButtonSize::None) - .icon_size(IconSize::XSmall), - ), - ), - ) - .children( - self.meta.map(|meta| { - Label::new(meta).size(LabelSize::Small).color(Color::Muted) - }), - ), - ) - } -} diff --git a/crates/assistant2/src/ui/composer.rs b/crates/assistant2/src/ui/composer.rs deleted file mode 100644 index 742ef15c18c607..00000000000000 --- a/crates/assistant2/src/ui/composer.rs +++ /dev/null @@ -1,193 +0,0 @@ -use crate::{ - ui::{ActiveFileButton, ProjectIndexButton}, - AssistantChat, CompletionProvider, -}; -use editor::{Editor, EditorElement, EditorStyle}; -use gpui::{AnyElement, FontStyle, FontWeight, ReadGlobal, TextStyle, View, WeakView, WhiteSpace}; -use settings::Settings; -use theme::ThemeSettings; -use ui::{popover_menu, prelude::*, ButtonLike, ContextMenu, Divider, TextSize, Tooltip}; - -#[derive(IntoElement)] -pub struct Composer { - editor: View, - project_index_button: View, - active_file_button: Option>, - model_selector: AnyElement, -} - -impl Composer { - pub fn new( - editor: View, - project_index_button: View, - active_file_button: Option>, - model_selector: AnyElement, - ) -> Self { - Self { - editor, - project_index_button, - active_file_button, - model_selector, - } - } - - fn render_tools(&mut self, _cx: &mut WindowContext) -> impl IntoElement { - h_flex().child(self.project_index_button.clone()) - } - - fn render_attachment_tools(&mut self, _cx: &mut WindowContext) -> impl IntoElement { - h_flex().children( - self.active_file_button - .clone() - .map(|view| view.into_any_element()), - ) - } -} - -impl RenderOnce for Composer { - fn render(mut self, cx: &mut WindowContext) -> impl IntoElement { - let font_size = TextSize::Default.rems(cx); - let line_height = font_size.to_pixels(cx.rem_size()) * 1.3; - let mut editor_border = cx.theme().colors().text; - editor_border.fade_out(0.90); - - // Remove the extra 1px added by the border - let padding = Spacing::XLarge.rems(cx) - rems_from_px(1.); - - h_flex() - .p(Spacing::Small.rems(cx)) - .w_full() - .items_start() - .child( - v_flex() - .w_full() - .rounded_lg() - .p(padding) - .border_1() - .border_color(editor_border) - .bg(cx.theme().colors().editor_background) - .child( - v_flex() - .justify_between() - .w_full() - .gap_2() - .child({ - let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: cx.theme().colors().editor_foreground, - font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features.clone(), - font_size: font_size.into(), - font_weight: FontWeight::NORMAL, - font_style: FontStyle::Normal, - line_height: line_height.into(), - background_color: None, - underline: None, - strikethrough: None, - white_space: WhiteSpace::Normal, - }; - - EditorElement::new( - &self.editor, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) - }) - .child( - h_flex() - .flex_none() - .gap_2() - .justify_between() - .w_full() - .child( - h_flex().gap_1().child( - h_flex() - .gap_2() - .child(self.render_tools(cx)) - .child(Divider::vertical()) - .child(self.render_attachment_tools(cx)), - ), - ) - .child(h_flex().gap_1().child(self.model_selector)), - ), - ), - ) - } -} - -#[derive(IntoElement)] -pub struct ModelSelector { - assistant_chat: WeakView, - model: String, -} - -impl ModelSelector { - pub fn new(assistant_chat: WeakView, model: String) -> Self { - Self { - assistant_chat, - model, - } - } -} - -impl RenderOnce for ModelSelector { - fn render(self, _cx: &mut WindowContext) -> impl IntoElement { - popover_menu("model-switcher") - .menu(move |cx| { - ContextMenu::build(cx, |mut menu, cx| { - for model in CompletionProvider::global(cx).available_models() { - menu = menu.custom_entry( - { - let model = model.clone(); - move |_| Label::new(model.clone()).into_any_element() - }, - { - let assistant_chat = self.assistant_chat.clone(); - move |cx| { - _ = assistant_chat.update(cx, |assistant_chat, cx| { - assistant_chat.model.clone_from(&model); - cx.notify(); - }); - } - }, - ); - } - menu - }) - .into() - }) - .trigger( - ButtonLike::new("active-model") - .child( - h_flex() - .w_full() - .gap_0p5() - .child( - div() - .overflow_x_hidden() - .flex_grow() - .whitespace_nowrap() - .child( - Label::new(self.model) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - div().child( - Icon::new(IconName::ChevronDown) - .color(Color::Muted) - .size(IconSize::XSmall), - ), - ), - ) - .style(ButtonStyle::Subtle) - .tooltip(move |cx| Tooltip::text("Change Model", cx)), - ) - .anchor(gpui::AnchorCorner::BottomRight) - } -} diff --git a/crates/assistant2/src/ui/project_index_button.rs b/crates/assistant2/src/ui/project_index_button.rs deleted file mode 100644 index 6d7cb081877a05..00000000000000 --- a/crates/assistant2/src/ui/project_index_button.rs +++ /dev/null @@ -1,112 +0,0 @@ -use assistant_tooling::ToolRegistry; -use gpui::{percentage, prelude::*, Animation, AnimationExt, Model, Transformation}; -use semantic_index::{ProjectIndex, Status}; -use std::{sync::Arc, time::Duration}; -use ui::{prelude::*, ButtonLike, Color, Icon, IconName, Indicator, Tooltip}; - -use crate::tools::ProjectIndexTool; - -pub struct ProjectIndexButton { - project_index: Model, - tool_registry: Arc, -} - -impl ProjectIndexButton { - pub fn new( - project_index: Model, - tool_registry: Arc, - cx: &mut ViewContext, - ) -> Self { - cx.subscribe(&project_index, |_this, _, _status: &Status, cx| { - cx.notify(); - }) - .detach(); - Self { - project_index, - tool_registry, - } - } - - pub fn set_enabled(&mut self, enabled: bool) { - self.tool_registry - .set_tool_enabled::(enabled); - } -} - -impl Render for ProjectIndexButton { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let status = self.project_index.read(cx).status(); - let is_enabled = self.tool_registry.is_tool_enabled::(); - - let icon = if is_enabled { - match status { - Status::Idle => Icon::new(IconName::Code) - .size(IconSize::XSmall) - .color(Color::Default), - Status::Loading => Icon::new(IconName::Code) - .size(IconSize::XSmall) - .color(Color::Muted), - Status::Scanning { .. } => Icon::new(IconName::Code) - .size(IconSize::XSmall) - .color(Color::Muted), - } - } else { - Icon::new(IconName::Code) - .size(IconSize::XSmall) - .color(Color::Disabled) - }; - - let indicator = if is_enabled { - match status { - Status::Idle => Some(Indicator::dot().color(Color::Success)), - Status::Scanning { .. } => Some(Indicator::dot().color(Color::Warning)), - Status::Loading => Some(Indicator::icon( - Icon::new(IconName::Spinner) - .color(Color::Accent) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), - ), - )), - } - } else { - None - }; - - ButtonLike::new("project-index") - .child( - ui::IconWithIndicator::new(icon, indicator) - .indicator_border_color(Some(gpui::transparent_black())), - ) - .tooltip({ - move |cx| { - let (tooltip, meta) = match (is_enabled, status) { - (false, _) => ( - "Project index disabled".to_string(), - Some("Click to enable".to_string()), - ), - (_, Status::Idle) => ( - "Project index ready".to_string(), - Some("Click to disable".to_string()), - ), - (_, Status::Loading) => ("Project index loading...".to_string(), None), - (_, Status::Scanning { remaining_count }) => ( - "Project index scanning...".to_string(), - Some(format!("{} remaining...", remaining_count)), - ), - }; - - if let Some(meta) = meta { - Tooltip::with_meta(tooltip, None, meta, cx) - } else { - Tooltip::text(tooltip, cx) - } - } - }) - .on_click(cx.listener(move |this, _, cx| { - this.set_enabled(!is_enabled); - cx.notify(); - })) - } -} diff --git a/crates/assistant2/src/ui/stories.rs b/crates/assistant2/src/ui/stories.rs deleted file mode 100644 index 8bc2b30d662471..00000000000000 --- a/crates/assistant2/src/ui/stories.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod chat_message; -mod chat_notice; - -pub use chat_message::*; -pub use chat_notice::*; diff --git a/crates/assistant2/src/ui/stories/chat_message.rs b/crates/assistant2/src/ui/stories/chat_message.rs deleted file mode 100644 index 1d63ae78c4de5a..00000000000000 --- a/crates/assistant2/src/ui/stories/chat_message.rs +++ /dev/null @@ -1,101 +0,0 @@ -use std::sync::Arc; - -use client::User; -use story::{StoryContainer, StoryItem, StorySection}; -use ui::prelude::*; - -use crate::ui::{ChatMessage, UserOrAssistant}; -use crate::MessageId; - -pub struct ChatMessageStory; - -impl Render for ChatMessageStory { - fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - let user_1 = Arc::new(User { - id: 12345, - github_login: "iamnbutler".into(), - avatar_uri: "https://avatars.githubusercontent.com/u/1714999?v=4".into(), - }); - - StoryContainer::new( - "ChatMessage Story", - "crates/assistant2/src/ui/stories/chat_message.rs", - ) - .child( - StorySection::new() - .child(StoryItem::new( - "User chat message", - ChatMessage::new( - MessageId(0), - UserOrAssistant::User(Some(user_1.clone())), - vec![div().child("What can I do here?").into_any_element()], - false, - Box::new(|_, _| {}), - ), - )) - .child(StoryItem::new( - "User chat message (collapsed)", - ChatMessage::new( - MessageId(0), - UserOrAssistant::User(Some(user_1.clone())), - vec![div().child("What can I do here?").into_any_element()], - true, - Box::new(|_, _| {}), - ), - )), - ) - .child( - StorySection::new() - .child(StoryItem::new( - "Assistant chat message", - ChatMessage::new( - MessageId(0), - UserOrAssistant::Assistant, - vec![div().child("You can talk to me!").into_any_element()], - false, - Box::new(|_, _| {}), - ), - )) - .child(StoryItem::new( - "Assistant chat message (collapsed)", - ChatMessage::new( - MessageId(0), - UserOrAssistant::Assistant, - vec![div().child(MULTI_LINE_MESSAGE).into_any_element()], - true, - Box::new(|_, _| {}), - ), - )), - ) - .child( - StorySection::new().child(StoryItem::new( - "Conversation between user and assistant", - v_flex() - .gap_2() - .child(ChatMessage::new( - MessageId(0), - UserOrAssistant::User(Some(user_1.clone())), - vec![div().child("What is Rust??").into_any_element()], - false, - Box::new(|_, _| {}), - )) - .child(ChatMessage::new( - MessageId(0), - UserOrAssistant::Assistant, - vec![div().child("Rust is a multi-paradigm programming language focused on performance and safety").into_any_element()], - false, - Box::new(|_, _| {}), - )) - .child(ChatMessage::new( - MessageId(0), - UserOrAssistant::User(Some(user_1)), - vec![div().child("Sounds pretty cool!").into_any_element()], - false, - Box::new(|_, _| {}), - )), - )), - ) - } -} - -const MULTI_LINE_MESSAGE: &str = "In 2010, the movies nominated for the 82nd Academy Awards, for films released in 2009, were as follows. Note that 2010 nominees were announced for the ceremony happening in that year, but they honor movies from the previous year"; diff --git a/crates/assistant2/src/ui/stories/chat_notice.rs b/crates/assistant2/src/ui/stories/chat_notice.rs deleted file mode 100644 index ad8eef92c73cbc..00000000000000 --- a/crates/assistant2/src/ui/stories/chat_notice.rs +++ /dev/null @@ -1,22 +0,0 @@ -use story::{StoryContainer, StoryItem, StorySection}; -use ui::prelude::*; - -use crate::ui::ChatNotice; - -pub struct ChatNoticeStory; - -impl Render for ChatNoticeStory { - fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - StoryContainer::new( - "ChatNotice Story", - "crates/assistant2/src/ui/stories/chat_notice.rs", - ) - .child( - StorySection::new().child(StoryItem::new( - "Project index request", - ChatNotice::new("Allow assistant to index your project?") - .meta("Enabling will allow responses more relevant to this project."), - )), - ) - } -} diff --git a/crates/assistant_slash_command/Cargo.toml b/crates/assistant_slash_command/Cargo.toml index a30e9ba0a0c103..3d764bb0be9d82 100644 --- a/crates/assistant_slash_command/Cargo.toml +++ b/crates/assistant_slash_command/Cargo.toml @@ -15,7 +15,8 @@ path = "src/assistant_slash_command.rs" anyhow.workspace = true collections.workspace = true derive_more.workspace = true -futures.workspace = true gpui.workspace = true language.workspace = true parking_lot.workspace = true +serde.workspace = true +workspace.workspace = true diff --git a/crates/assistant_slash_command/src/assistant_slash_command.rs b/crates/assistant_slash_command/src/assistant_slash_command.rs index dcdf446673475c..5f917363a23073 100644 --- a/crates/assistant_slash_command/src/assistant_slash_command.rs +++ b/crates/assistant_slash_command/src/assistant_slash_command.rs @@ -1,61 +1,75 @@ mod slash_command_registry; -use std::sync::atomic::AtomicBool; -use std::sync::Arc; - use anyhow::Result; -use futures::channel::oneshot; -use gpui::{AppContext, Task}; -use language::LspAdapterDelegate; - +use gpui::{AnyElement, AppContext, ElementId, SharedString, Task, WeakView, WindowContext}; +use language::{CodeLabel, LspAdapterDelegate}; +use serde::{Deserialize, Serialize}; pub use slash_command_registry::*; +use std::{ + ops::Range, + sync::{atomic::AtomicBool, Arc}, +}; +use workspace::{ui::IconName, Workspace}; pub fn init(cx: &mut AppContext) { SlashCommandRegistry::default_global(cx); } +#[derive(Debug)] +pub struct ArgumentCompletion { + /// The label to display for this completion. + pub label: String, + /// The new text that should be inserted into the command when this completion is accepted. + pub new_text: String, + /// Whether the command should be run when accepting this completion. + pub run_command: bool, +} + pub trait SlashCommand: 'static + Send + Sync { fn name(&self) -> String; + fn label(&self, _cx: &AppContext) -> CodeLabel { + CodeLabel::plain(self.name(), None) + } fn description(&self) -> String; + fn menu_text(&self) -> String; fn complete_argument( - &self, + self: Arc, query: String, cancel: Arc, + workspace: Option>, cx: &mut AppContext, - ) -> Task>>; + ) -> Task>>; fn requires_argument(&self) -> bool; fn run( self: Arc, argument: Option<&str>, + workspace: WeakView, // TODO: We're just using the `LspAdapterDelegate` here because that is // what the extension API is already expecting. // // It may be that `LspAdapterDelegate` needs a more general name, or // perhaps another kind of delegate is needed here. delegate: Arc, - cx: &mut AppContext, - ) -> SlashCommandInvocation; + cx: &mut WindowContext, + ) -> Task>; } -pub struct SlashCommandInvocation { - pub output: Task>, - pub invalidated: oneshot::Receiver<()>, - pub cleanup: SlashCommandCleanup, -} +pub type RenderFoldPlaceholder = Arc< + dyn Send + + Sync + + Fn(ElementId, Arc, &mut WindowContext) -> AnyElement, +>; #[derive(Default)] -pub struct SlashCommandCleanup(Option>); - -impl SlashCommandCleanup { - pub fn new(cleanup: impl FnOnce() + 'static) -> Self { - Self(Some(Box::new(cleanup))) - } +pub struct SlashCommandOutput { + pub text: String, + pub sections: Vec>, + pub run_commands_in_text: bool, } -impl Drop for SlashCommandCleanup { - fn drop(&mut self) { - if let Some(cleanup) = self.0.take() { - cleanup(); - } - } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SlashCommandOutputSection { + pub range: Range, + pub icon: IconName, + pub label: SharedString, } diff --git a/crates/assistant_slash_command/src/slash_command_registry.rs b/crates/assistant_slash_command/src/slash_command_registry.rs index 68619dc1e1105d..070e60bc6b9f74 100644 --- a/crates/assistant_slash_command/src/slash_command_registry.rs +++ b/crates/assistant_slash_command/src/slash_command_registry.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use collections::HashMap; +use collections::{BTreeSet, HashMap}; use derive_more::{Deref, DerefMut}; use gpui::Global; use gpui::{AppContext, ReadGlobal}; @@ -16,6 +16,7 @@ impl Global for GlobalSlashCommandRegistry {} #[derive(Default)] struct SlashCommandRegistryState { commands: HashMap, Arc>, + featured_commands: BTreeSet>, } #[derive(Default)] @@ -40,16 +41,19 @@ impl SlashCommandRegistry { Arc::new(Self { state: RwLock::new(SlashCommandRegistryState { commands: HashMap::default(), + featured_commands: BTreeSet::default(), }), }) } /// Registers the provided [`SlashCommand`]. - pub fn register_command(&self, command: impl SlashCommand) { - self.state - .write() - .commands - .insert(command.name().into(), Arc::new(command)); + pub fn register_command(&self, command: impl SlashCommand, is_featured: bool) { + let mut state = self.state.write(); + let command_name: Arc = command.name().into(); + if is_featured { + state.featured_commands.insert(command_name.clone()); + } + state.commands.insert(command_name, Arc::new(command)); } /// Returns the names of registered [`SlashCommand`]s. @@ -57,6 +61,16 @@ impl SlashCommandRegistry { self.state.read().commands.keys().cloned().collect() } + /// Returns the names of registered, featured [`SlashCommand`]s. + pub fn featured_command_names(&self) -> Vec> { + self.state + .read() + .featured_commands + .iter() + .cloned() + .collect() + } + /// Returns the [`SlashCommand`] with the given name. pub fn command(&self, name: &str) -> Option> { self.state.read().commands.get(name).cloned() diff --git a/crates/audio/src/assets.rs b/crates/audio/src/assets.rs index 387990cb720c78..591f9ac9d64be9 100644 --- a/crates/audio/src/assets.rs +++ b/crates/audio/src/assets.rs @@ -41,7 +41,12 @@ impl SoundRegistry { } let path = format!("sounds/{}.wav", name); - let bytes = self.assets.load(&path)?.into_owned(); + let bytes = self + .assets + .load(&path)? + .map(|asset| Ok(asset)) + .unwrap_or_else(|| Err(anyhow::anyhow!("No such asset available")))? + .into_owned(); let cursor = Cursor::new(bytes); let source = Decoder::new(cursor)?.convert_samples::().buffered(); diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index e5b314d9fe821a..ee78ea3556a656 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -1,7 +1,7 @@ mod update_notification; use anyhow::{anyhow, Context, Result}; -use client::{Client, TelemetrySettings, ZED_APP_PATH}; +use client::{Client, TelemetrySettings}; use db::kvp::KEY_VALUE_STORE; use db::RELEASE_CHANNEL; use editor::{Editor, MultiBuffer}; @@ -23,7 +23,10 @@ use smol::{fs::File, process::Command}; use http::{HttpClient, HttpClientWithUrl}; use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; use std::{ - env::consts::{ARCH, OS}, + env::{ + self, + consts::{ARCH, OS}, + }, ffi::OsString, path::PathBuf, sync::Arc, @@ -138,20 +141,29 @@ pub fn init(http_client: Arc, cx: &mut AppContext) { let auto_updater = cx.new_model(|cx| { let updater = AutoUpdater::new(version, http_client); - let mut update_subscription = AutoUpdateSetting::get_global(cx) - .0 - .then(|| updater.start_polling(cx)); - - cx.observe_global::(move |updater, cx| { - if AutoUpdateSetting::get_global(cx).0 { - if update_subscription.is_none() { - update_subscription = Some(updater.start_polling(cx)) + let poll_for_updates = ReleaseChannel::try_global(cx) + .map(|channel| channel.poll_for_updates()) + .unwrap_or(false); + + if option_env!("ZED_UPDATE_EXPLANATION").is_none() + && env::var("ZED_UPDATE_EXPLANATION").is_err() + && poll_for_updates + { + let mut update_subscription = AutoUpdateSetting::get_global(cx) + .0 + .then(|| updater.start_polling(cx)); + + cx.observe_global::(move |updater, cx| { + if AutoUpdateSetting::get_global(cx).0 { + if update_subscription.is_none() { + update_subscription = Some(updater.start_polling(cx)) + } + } else { + update_subscription.take(); } - } else { - update_subscription.take(); - } - }) - .detach(); + }) + .detach(); + } updater }); @@ -159,6 +171,33 @@ pub fn init(http_client: Arc, cx: &mut AppContext) { } pub fn check(_: &Check, cx: &mut WindowContext) { + if let Some(message) = option_env!("ZED_UPDATE_EXPLANATION") { + drop(cx.prompt( + gpui::PromptLevel::Info, + "Zed was installed via a package manager.", + Some(message), + &["Ok"], + )); + return; + } + + if let Some(message) = env::var("ZED_UPDATE_EXPLANATION").ok() { + drop(cx.prompt( + gpui::PromptLevel::Info, + "Zed was installed via a package manager.", + Some(&message), + &["Ok"], + )); + return; + } + + if !ReleaseChannel::try_global(cx) + .map(|channel| channel.poll_for_updates()) + .unwrap_or(false) + { + return; + } + if let Some(updater) = AutoUpdater::get(cx) { updater.update(cx, |updater, cx| updater.poll(cx)); } else { @@ -237,8 +276,9 @@ fn view_release_notes_locally(workspace: &mut Workspace, cx: &mut ViewContext = MarkdownPreviewView::new( MarkdownPreviewMode::Default, @@ -248,7 +288,12 @@ fn view_release_notes_locally(workspace: &mut Workspace, cx: &mut ViewContext install_release_macos(&temp_dir, downloaded_asset, &cx).await, "linux" => install_release_linux(&temp_dir, downloaded_asset, &cx).await, _ => Err(anyhow!("not supported: {:?}", OS)), @@ -496,9 +536,10 @@ async fn install_release_linux( temp_dir: &tempfile::TempDir, downloaded_tar_gz: PathBuf, cx: &AsyncAppContext, -) -> Result<()> { +) -> Result { let channel = cx.update(|cx| ReleaseChannel::global(cx).dev_name())?; - let home_dir = PathBuf::from(std::env::var("HOME").context("no HOME env var set")?); + let home_dir = PathBuf::from(env::var("HOME").context("no HOME env var set")?); + let running_app_path = cx.update(|cx| cx.app_path())??; let extracted = temp_dir.path().join("zed"); fs::create_dir_all(&extracted) @@ -529,7 +570,16 @@ async fn install_release_linux( let app_folder_name = format!("zed{}.app", suffix); let from = extracted.join(&app_folder_name); - let to = home_dir.join(".local"); + let mut to = home_dir.join(".local"); + + let expected_suffix = format!("{}/libexec/zed-editor", app_folder_name); + + if let Some(prefix) = running_app_path + .to_str() + .and_then(|str| str.strip_suffix(&expected_suffix)) + { + to = PathBuf::from(prefix); + } let output = Command::new("rsync") .args(&["-av", "--delete"]) @@ -546,17 +596,15 @@ async fn install_release_linux( String::from_utf8_lossy(&output.stderr) ); - Ok(()) + Ok(to.join(expected_suffix)) } async fn install_release_macos( temp_dir: &tempfile::TempDir, downloaded_dmg: PathBuf, cx: &AsyncAppContext, -) -> Result<()> { - let running_app_path = ZED_APP_PATH - .clone() - .map_or_else(|| cx.update(|cx| cx.app_path())?, Ok)?; +) -> Result { + let running_app_path = cx.update(|cx| cx.app_path())??; let running_app_filename = running_app_path .file_name() .ok_or_else(|| anyhow!("invalid running app path"))?; @@ -604,5 +652,5 @@ async fn install_release_macos( String::from_utf8_lossy(&output.stderr) ); - Ok(()) + Ok(running_app_path) } diff --git a/crates/breadcrumbs/src/breadcrumbs.rs b/crates/breadcrumbs/src/breadcrumbs.rs index d70b1cb2278904..ae8469ed41ebe0 100644 --- a/crates/breadcrumbs/src/breadcrumbs.rs +++ b/crates/breadcrumbs/src/breadcrumbs.rs @@ -72,7 +72,7 @@ impl Render for Breadcrumbs { .into_any() }); let breadcrumbs = Itertools::intersperse_with(highlighted_segments, || { - Label::new("›").color(Color::Muted).into_any_element() + Label::new("›").color(Color::Placeholder).into_any_element() }); let breadcrumbs_stack = h_flex().gap_1().children(breadcrumbs); @@ -83,13 +83,19 @@ impl Render for Breadcrumbs { Some(editor) => element.child( ButtonLike::new("toggle outline view") .child(breadcrumbs_stack) - .style(ButtonStyle::Subtle) + .style(ButtonStyle::Transparent) .on_click(move |_, cx| { if let Some(editor) = editor.upgrade() { - outline::toggle(editor, &outline::Toggle, cx) + outline::toggle(editor, &editor::actions::ToggleOutline, cx) } }) - .tooltip(|cx| Tooltip::for_action("Show symbol outline", &outline::Toggle, cx)), + .tooltip(|cx| { + Tooltip::for_action( + "Show symbol outline", + &editor::actions::ToggleOutline, + cx, + ) + }), ), None => element // Match the height of the `ButtonLike` in the other arm. @@ -107,29 +113,30 @@ impl ToolbarItemView for Breadcrumbs { ) -> ToolbarItemLocation { cx.notify(); self.active_item = None; - if let Some(item) = active_pane_item { - let this = cx.view().downgrade(); - self.subscription = Some(item.subscribe_to_item_events( - cx, - Box::new(move |event, cx| { - if let ItemEvent::UpdateBreadcrumbs = event { - this.update(cx, |this, cx| { - cx.notify(); - if let Some(active_item) = this.active_item.as_ref() { - cx.emit(ToolbarItemEvent::ChangeLocation( - active_item.breadcrumb_location(cx), - )) - } - }) - .ok(); - } - }), - )); - self.active_item = Some(item.boxed_clone()); - item.breadcrumb_location(cx) - } else { - ToolbarItemLocation::Hidden - } + + let Some(item) = active_pane_item else { + return ToolbarItemLocation::Hidden; + }; + + let this = cx.view().downgrade(); + self.subscription = Some(item.subscribe_to_item_events( + cx, + Box::new(move |event, cx| { + if let ItemEvent::UpdateBreadcrumbs = event { + this.update(cx, |this, cx| { + cx.notify(); + if let Some(active_item) = this.active_item.as_ref() { + cx.emit(ToolbarItemEvent::ChangeLocation( + active_item.breadcrumb_location(cx), + )) + } + }) + .ok(); + } + }), + )); + self.active_item = Some(item.boxed_clone()); + item.breadcrumb_location(cx) } fn pane_focus_update(&mut self, pane_focused: bool, _: &mut ViewContext) { diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml index 0377c4f1f42cd0..70249eb533d571 100644 --- a/crates/call/Cargo.toml +++ b/crates/call/Cargo.toml @@ -13,6 +13,7 @@ path = "src/call.rs" doctest = false [features] +no-webrtc = ["live_kit_client/no-webrtc"] test-support = [ "client/test-support", "collections/test-support", diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index 66187e08c51490..353c2342828ff2 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -114,7 +114,6 @@ impl ActiveCall { async fn handle_incoming_call( this: Model, envelope: TypedEnvelope, - _: Arc, mut cx: AsyncAppContext, ) -> Result { let user_store = this.update(&mut cx, |this, _| this.user_store.clone())?; @@ -142,7 +141,6 @@ impl ActiveCall { async fn handle_call_canceled( this: Model, envelope: TypedEnvelope, - _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { this.update(&mut cx, |this, _| { diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 61fb694024461e..f51221908fdeb2 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -267,7 +267,7 @@ impl Room { .await { Ok(()) => Ok(room), - Err(error) => Err(anyhow!("room creation failed: {:?}", error)), + Err(error) => Err(error.context("room creation failed")), } }) } @@ -697,7 +697,6 @@ impl Room { async fn handle_room_updated( this: Model, envelope: TypedEnvelope, - _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { let room = envelope diff --git a/crates/channel/src/channel_buffer.rs b/crates/channel/src/channel_buffer.rs index c2115a7cab5c7f..7ce291ef4ad7ab 100644 --- a/crates/channel/src/channel_buffer.rs +++ b/crates/channel/src/channel_buffer.rs @@ -138,7 +138,6 @@ impl ChannelBuffer { async fn handle_update_channel_buffer( this: Model, update_channel_buffer: TypedEnvelope, - _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { let ops = update_channel_buffer @@ -160,7 +159,6 @@ impl ChannelBuffer { async fn handle_update_channel_buffer_collaborators( this: Model, message: TypedEnvelope, - _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { this.update(&mut cx, |this, cx| { diff --git a/crates/channel/src/channel_chat.rs b/crates/channel/src/channel_chat.rs index 46074665b84529..8a1250fd692173 100644 --- a/crates/channel/src/channel_chat.rs +++ b/crates/channel/src/channel_chat.rs @@ -528,7 +528,6 @@ impl ChannelChat { async fn handle_message_sent( this: Model, message: TypedEnvelope, - _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { let user_store = this.update(&mut cx, |this, _| this.user_store.clone())?; @@ -553,7 +552,6 @@ impl ChannelChat { async fn handle_message_removed( this: Model, message: TypedEnvelope, - _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { this.update(&mut cx, |this, cx| { @@ -565,7 +563,6 @@ impl ChannelChat { async fn handle_message_updated( this: Model, message: TypedEnvelope, - _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { let user_store = this.update(&mut cx, |this, _| this.user_store.clone())?; diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index 74ff7c731ebb12..cbc605ffdab2fa 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -62,6 +62,7 @@ pub struct ChannelStore { opened_buffers: HashMap>, opened_chats: HashMap>, client: Arc, + did_subscribe: bool, user_store: Model, _rpc_subscriptions: [Subscription; 2], _watch_connection_status: Task>, @@ -243,6 +244,20 @@ impl ChannelStore { .log_err(); }), channel_states: Default::default(), + did_subscribe: false, + } + } + + pub fn initialize(&mut self) { + if !self.did_subscribe { + if self + .client + .send(proto::SubscribeToChannels {}) + .log_err() + .is_some() + { + self.did_subscribe = true; + } } } @@ -873,7 +888,6 @@ impl ChannelStore { async fn handle_update_channels( this: Model, message: TypedEnvelope, - _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { this.update(&mut cx, |this, _| { @@ -887,7 +901,6 @@ impl ChannelStore { async fn handle_update_user_channels( this: Model, message: TypedEnvelope, - _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { this.update(&mut cx, |this, cx| { @@ -1035,7 +1048,7 @@ impl ChannelStore { fn handle_disconnect(&mut self, wait_for_reconnect: bool, cx: &mut ModelContext) { cx.notify(); - + self.did_subscribe = false; self.disconnect_channel_buffers_task.get_or_insert_with(|| { cx.spawn(move |this, mut cx| async move { if wait_for_reconnect { diff --git a/crates/channel/src/channel_store_tests.rs b/crates/channel/src/channel_store_tests.rs index 566a4eb42232be..2dd3c0f81d68e7 100644 --- a/crates/channel/src/channel_store_tests.rs +++ b/crates/channel/src/channel_store_tests.rs @@ -3,7 +3,7 @@ use crate::channel_chat::ChannelChatEvent; use super::*; use client::{test::FakeServer, Client, UserStore}; use clock::FakeSystemClock; -use gpui::{AppContext, Context, Model, TestAppContext}; +use gpui::{AppContext, Context, Model, SemanticVersion, TestAppContext}; use http::FakeHttpClient; use rpc::proto::{self}; use settings::SettingsStore; @@ -340,7 +340,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { fn init_test(cx: &mut AppContext) -> Model { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - release_channel::init("0.0.0", cx); + release_channel::init(SemanticVersion::default(), cx); client::init_settings(cx); let clock = Arc::new(FakeSystemClock::default()); diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 199b6bb62de59e..aa64e8de8f87d1 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -19,9 +19,10 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true clap.workspace = true -libc.workspace = true ipc-channel = "0.18" once_cell.workspace = true +parking_lot.workspace = true +paths.workspace = true release_channel.workspace = true serde.workspace = true util.workspace = true diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 8b5faaf382217a..d6ea61d4d09615 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -11,6 +11,7 @@ pub struct IpcHandshake { pub enum CliRequest { Open { paths: Vec, + urls: Vec, wait: bool, open_new_workspace: Option, dev_server_token: Option, diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index c1752a481f5735..bb4bcd5bebcf95 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -3,10 +3,13 @@ use anyhow::{Context, Result}; use clap::Parser; use cli::{ipc::IpcOneShotServer, CliRequest, CliResponse, IpcHandshake}; +use parking_lot::Mutex; use std::{ + convert::Infallible, env, fs, io, path::{Path, PathBuf}, process::ExitStatus, + sync::Arc, thread::{self, JoinHandle}, }; use util::paths::PathLikeWithPosition; @@ -35,8 +38,7 @@ struct Args { /// /// Use `path:line:row` syntax to open a file at a specific location. /// Non-existing paths and directories will ignore `:line:row` suffix. - #[arg(value_parser = parse_path_with_position)] - paths_with_position: Vec>, + paths_with_position: Vec, /// Print Zed's version and the app path. #[arg(short, long)] version: bool, @@ -51,15 +53,40 @@ struct Args { dev_server_token: Option, } -fn parse_path_with_position( - argument_str: &str, -) -> Result, std::convert::Infallible> { - PathLikeWithPosition::parse_str(argument_str, |path_str| { +fn parse_path_with_position(argument_str: &str) -> Result { + let path_like = PathLikeWithPosition::parse_str::(argument_str, |_, path_str| { Ok(Path::new(path_str).to_path_buf()) }) + .unwrap(); + let curdir = env::current_dir()?; + + let canonicalized = path_like.map_path_like(|path| match fs::canonicalize(&path) { + Ok(path) => Ok(path), + Err(e) => { + if let Some(mut parent) = path.parent() { + if parent == Path::new("") { + parent = &curdir + } + match fs::canonicalize(parent) { + Ok(parent) => Ok(parent.join(path.file_name().unwrap())), + Err(_) => Err(e), + } + } else { + Err(e) + } + } + })?; + Ok(canonicalized.to_string(|path| path.display().to_string())) } fn main() -> Result<()> { + // Exit flatpak sandbox if needed + #[cfg(target_os = "linux")] + { + flatpak::try_restart_to_host(); + flatpak::ld_extra_libs(); + } + // Intercept version designators #[cfg(target_os = "macos")] if let Some(channel) = std::env::args().nth(1).filter(|arg| arg.starts_with("--")) { @@ -72,6 +99,9 @@ fn main() -> Result<()> { } let args = Args::parse(); + #[cfg(target_os = "linux")] + let args = flatpak::set_bin_if_no_escape(args); + let app = Detect::detect(args.zed.as_deref()).context("Bundle detection")?; if args.version { @@ -79,28 +109,6 @@ fn main() -> Result<()> { return Ok(()); } - let curdir = env::current_dir()?; - let mut paths = vec![]; - for path in args.paths_with_position { - let canonicalized = path.map_path_like(|path| match fs::canonicalize(&path) { - Ok(path) => Ok(path), - Err(e) => { - if let Some(mut parent) = path.parent() { - if parent == Path::new("") { - parent = &curdir; - } - match fs::canonicalize(parent) { - Ok(parent) => Ok(parent.join(path.file_name().unwrap())), - Err(_) => Err(e), - } - } else { - Err(e) - } - } - })?; - paths.push(canonicalized.to_string(|path| path.display().to_string())) - } - let (server, server_name) = IpcOneShotServer::::new().context("Handshake before Zed spawn")?; let url = format!("zed-cli://{server_name}"); @@ -113,26 +121,48 @@ fn main() -> Result<()> { None }; - let sender: JoinHandle> = thread::spawn(move || { - let (_, handshake) = server.accept().context("Handshake after Zed spawn")?; - let (tx, rx) = (handshake.requests, handshake.responses); - tx.send(CliRequest::Open { - paths, - wait: args.wait, - open_new_workspace, - dev_server_token: args.dev_server_token, - })?; - - while let Ok(response) = rx.recv() { - match response { - CliResponse::Ping => {} - CliResponse::Stdout { message } => println!("{message}"), - CliResponse::Stderr { message } => eprintln!("{message}"), - CliResponse::Exit { status } => std::process::exit(status), - } + let exit_status = Arc::new(Mutex::new(None)); + let mut paths = vec![]; + let mut urls = vec![]; + for path in args.paths_with_position.iter() { + if path.starts_with("zed://") + || path.starts_with("http://") + || path.starts_with("https://") + || path.starts_with("file://") + { + urls.push(path.to_string()); + } else { + paths.push(parse_path_with_position(path)?) } + } - Ok(()) + let sender: JoinHandle> = thread::spawn({ + let exit_status = exit_status.clone(); + move || { + let (_, handshake) = server.accept().context("Handshake after Zed spawn")?; + let (tx, rx) = (handshake.requests, handshake.responses); + tx.send(CliRequest::Open { + paths, + urls, + wait: args.wait, + open_new_workspace, + dev_server_token: args.dev_server_token, + })?; + + while let Ok(response) = rx.recv() { + match response { + CliResponse::Ping => {} + CliResponse::Stdout { message } => println!("{message}"), + CliResponse::Stderr { message } => eprintln!("{message}"), + CliResponse::Exit { status } => { + exit_status.lock().replace(status); + return Ok(()); + } + } + } + + Ok(()) + } }); if args.foreground { @@ -142,6 +172,9 @@ fn main() -> Result<()> { sender.join().unwrap()?; } + if let Some(exit_status) = exit_status.lock().take() { + std::process::exit(exit_status); + } Ok(()) } @@ -151,10 +184,7 @@ mod linux { env, ffi::OsString, io, - os::{ - linux::net::SocketAddrExt, - unix::net::{SocketAddr, UnixDatagram}, - }, + os::unix::net::{SocketAddr, UnixDatagram}, path::{Path, PathBuf}, process::{self, ExitStatus}, thread, @@ -176,22 +206,24 @@ mod linux { impl Detect { pub fn detect(path: Option<&Path>) -> anyhow::Result { let path = if let Some(path) = path { - path.to_path_buf().canonicalize() + path.to_path_buf().canonicalize()? } else { let cli = env::current_exe()?; let dir = cli .parent() .ok_or_else(|| anyhow!("no parent path for cli"))?; - match dir.join("zed").canonicalize() { - Ok(path) => Ok(path), - // development builds have Zed capitalized - Err(e) => match dir.join("Zed").canonicalize() { - Ok(path) => Ok(path), - Err(_) => Err(e), - }, - } - }?; + // libexec is the standard, lib/zed is for Arch (and other non-libexec distros), + // ./zed is for the target directory in development builds. + let possible_locations = + ["../libexec/zed-editor", "../lib/zed/zed-editor", "./zed"]; + possible_locations + .iter() + .find_map(|p| dir.join(p).canonicalize().ok().filter(|path| path != &cli)) + .ok_or_else(|| { + anyhow!("could not find any of: {}", possible_locations.join(", ")) + })? + }; Ok(App(path)) } @@ -212,12 +244,9 @@ mod linux { } fn launch(&self, ipc_url: String) -> anyhow::Result<()> { - let uid: u32 = unsafe { libc::getuid() }; - let sock_addr = - SocketAddr::from_abstract_name(format!("zed-{}-{}", *RELEASE_CHANNEL, uid))?; - + let sock_path = paths::support_dir().join(format!("zed-{}.sock", *RELEASE_CHANNEL)); let sock = UnixDatagram::unbound()?; - if sock.connect_addr(&sock_addr).is_err() { + if sock.connect(&sock_path).is_err() { self.boot_background(ipc_url)?; } else { sock.send(ipc_url.as_bytes())?; @@ -244,10 +273,8 @@ mod linux { eprintln!("failed to setsid: {}", std::io::Error::last_os_error()); process::exit(1); } - if std::env::var("ZED_KEEP_FD").is_err() { - if let Err(_) = fork::close_fd() { - eprintln!("failed to close_fd: {}", std::io::Error::last_os_error()); - } + if let Err(_) = fork::close_fd() { + eprintln!("failed to close_fd: {}", std::io::Error::last_os_error()); } let error = exec::execvp(path.clone(), &[path.as_os_str(), &OsString::from(ipc_url)]); @@ -275,6 +302,114 @@ mod linux { } } +#[cfg(target_os = "linux")] +mod flatpak { + use std::ffi::OsString; + use std::path::PathBuf; + use std::process::Command; + use std::{env, process}; + + const EXTRA_LIB_ENV_NAME: &'static str = "ZED_FLATPAK_LIB_PATH"; + const NO_ESCAPE_ENV_NAME: &'static str = "ZED_FLATPAK_NO_ESCAPE"; + + /// Adds bundled libraries to LD_LIBRARY_PATH if running under flatpak + pub fn ld_extra_libs() { + let mut paths = if let Ok(paths) = env::var("LD_LIBRARY_PATH") { + env::split_paths(&paths).collect() + } else { + Vec::new() + }; + + if let Ok(extra_path) = env::var(EXTRA_LIB_ENV_NAME) { + paths.push(extra_path.into()); + } + + env::set_var("LD_LIBRARY_PATH", env::join_paths(paths).unwrap()); + } + + /// Restarts outside of the sandbox if currently running within it + pub fn try_restart_to_host() { + if let Some(flatpak_dir) = get_flatpak_dir() { + let mut args = vec!["/usr/bin/flatpak-spawn".into(), "--host".into()]; + args.append(&mut get_xdg_env_args()); + args.push("--env=ZED_UPDATE_EXPLANATION=Please use flatpak to update zed".into()); + args.push( + format!( + "--env={EXTRA_LIB_ENV_NAME}={}", + flatpak_dir.join("lib").to_str().unwrap() + ) + .into(), + ); + args.push(flatpak_dir.join("bin").join("zed").into()); + + let mut is_app_location_set = false; + for arg in &env::args_os().collect::>()[1..] { + args.push(arg.clone()); + is_app_location_set |= arg == "--zed"; + } + + if !is_app_location_set { + args.push("--zed".into()); + args.push(flatpak_dir.join("libexec").join("zed-editor").into()); + } + + let error = exec::execvp("/usr/bin/flatpak-spawn", args); + eprintln!("failed restart cli on host: {:?}", error); + process::exit(1); + } + } + + pub fn set_bin_if_no_escape(mut args: super::Args) -> super::Args { + if env::var(NO_ESCAPE_ENV_NAME).is_ok() + && env::var("FLATPAK_ID").map_or(false, |id| id.starts_with("dev.zed.Zed")) + { + if args.zed.is_none() { + args.zed = Some("/app/libexec/zed-editor".into()); + env::set_var("ZED_UPDATE_EXPLANATION", "Please use flatpak to update zed"); + } + } + args + } + + fn get_flatpak_dir() -> Option { + if env::var(NO_ESCAPE_ENV_NAME).is_ok() { + return None; + } + + if let Ok(flatpak_id) = env::var("FLATPAK_ID") { + if !flatpak_id.starts_with("dev.zed.Zed") { + return None; + } + + let install_dir = Command::new("/usr/bin/flatpak-spawn") + .arg("--host") + .arg("flatpak") + .arg("info") + .arg("--show-location") + .arg(flatpak_id) + .output() + .unwrap(); + let install_dir = PathBuf::from(String::from_utf8(install_dir.stdout).unwrap().trim()); + Some(install_dir.join("files")) + } else { + None + } + } + + fn get_xdg_env_args() -> Vec { + let xdg_keys = [ + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + ]; + env::vars() + .filter(|(key, _)| xdg_keys.contains(&key.as_str())) + .map(|(key, val)| format!("--env=FLATPAK_{}={}", key, val).into()) + .collect() + } +} + // todo("windows") #[cfg(target_os = "windows")] mod windows { diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index b502c2d1ee2c0f..fcc59a25781e0e 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -19,17 +19,18 @@ test-support = ["clock/test-support", "collections/test-support", "gpui/test-sup anyhow.workspace = true async-recursion = "0.3" async-tungstenite = { version = "0.16", features = ["async-std", "async-native-tls"] } -async-native-tls = { version = "0.5.0", features = ["vendored"] } chrono = { workspace = true, features = ["serde"] } clock.workspace = true collections.workspace = true feature_flags.workspace = true +fs.workspace = true futures.workspace = true gpui.workspace = true http.workspace = true lazy_static.workspace = true log.workspace = true once_cell.workspace = true +paths.workspace = true parking_lot.workspace = true postage.workspace = true rand.workspace = true @@ -50,6 +51,7 @@ time.workspace = true tiny_http = "0.8" url.workspace = true util.workspace = true +worktree.workspace = true [dev-dependencies] clock = { workspace = true, features = ["test-support"] } @@ -60,9 +62,10 @@ settings = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } http = { workspace = true, features = ["test-support"] } -[target.'cfg(target_os = "linux")'.dependencies] -async-native-tls = {"version" = "0.5.0", features = ["vendored"]} -# This is an indirect dependency of async-tungstenite that is included -# here so we can vendor libssl with the feature flag. -[package.metadata.cargo-machete] -ignored = ["async-native-tls"] +[target.'cfg(target_os = "windows")'.dependencies] +windows.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +cocoa.workspace = true +isahc = { workspace = true, features = ["static-curl"] } +async-native-tls = { version = "0.5.0", features = ["vendored"] } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index c9e61a64f39a5a..99d05cb14679f0 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -13,8 +13,9 @@ use async_tungstenite::tungstenite::{ use clock::SystemClock; use collections::HashMap; use futures::{ - channel::oneshot, future::LocalBoxFuture, AsyncReadExt, FutureExt, SinkExt, Stream, StreamExt, - TryFutureExt as _, TryStreamExt, + channel::oneshot, + future::{BoxFuture, LocalBoxFuture}, + AsyncReadExt, FutureExt, SinkExt, Stream, StreamExt, TryFutureExt as _, TryStreamExt, }; use gpui::{ actions, AnyModel, AnyWeakModel, AppContext, AsyncAppContext, Global, Model, Task, WeakModel, @@ -23,6 +24,7 @@ use http::{HttpClient, HttpClientWithUrl}; use lazy_static::lazy_static; use parking_lot::RwLock; use postage::watch; +use proto::ProtoClient; use rand::prelude::*; use release_channel::{AppVersion, ReleaseChannel}; use rpc::proto::{AnyTypedEnvelope, EntityMessage, EnvelopedMessage, PeerId, RequestMessage}; @@ -84,7 +86,8 @@ lazy_static! { std::env::var("ZED_ALWAYS_ACTIVE").map_or(false, |e| !e.is_empty()); } -pub const INITIAL_RECONNECTION_DELAY: Duration = Duration::from_millis(100); +pub const INITIAL_RECONNECTION_DELAY: Duration = Duration::from_millis(500); +pub const MAX_RECONNECTION_DELAY: Duration = Duration::from_secs(10); pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(20); actions!(client, [SignIn, SignOut, Reconnect]); @@ -216,6 +219,9 @@ pub struct Client { >, >, >, + + #[cfg(any(test, feature = "test-support"))] + rpc_url: RwLock>, } #[derive(Error, Debug)] @@ -287,7 +293,6 @@ struct ClientState { status: (watch::Sender, watch::Receiver), entity_id_extractors: HashMap u64>, _reconnect_task: Option>, - reconnect_interval: Duration, entities_by_type_and_remote_id: HashMap<(TypeId, u64), WeakSubscriber>, models_by_message_type: HashMap, entity_types_by_message_type: HashMap, @@ -363,7 +368,6 @@ impl Default for ClientState { status: watch::channel_with(Status::SignedOut), entity_id_extractors: Default::default(), _reconnect_task: None, - reconnect_interval: Duration::from_secs(5), models_by_message_type: Default::default(), entities_by_type_and_remote_id: Default::default(), entity_types_by_message_type: Default::default(), @@ -510,7 +514,7 @@ impl Client { let credentials_provider: Arc = if use_zed_development_auth { Arc::new(DevelopmentCredentialsProvider { - path: util::paths::CONFIG_DIR.join("development_auth"), + path: paths::config_dir().join("development_auth"), }) } else { Arc::new(KeychainCredentialsProvider) @@ -528,6 +532,8 @@ impl Client { authenticate: Default::default(), #[cfg(any(test, feature = "test-support"))] establish_connection: Default::default(), + #[cfg(any(test, feature = "test-support"))] + rpc_url: RwLock::default(), }) } @@ -585,6 +591,12 @@ impl Client { self } + #[cfg(any(test, feature = "test-support"))] + pub fn override_rpc_url(&self, url: Url) -> &Self { + *self.rpc_url.write() = Some(url); + self + } + pub fn global(cx: &AppContext) -> Arc { cx.global::().0.clone() } @@ -623,7 +635,6 @@ impl Client { } Status::ConnectionLost => { let this = self.clone(); - let reconnect_interval = state.reconnect_interval; state._reconnect_task = Some(cx.spawn(move |cx| async move { #[cfg(any(test, feature = "test-support"))] let mut rng = StdRng::seed_from_u64(0); @@ -642,8 +653,9 @@ impl Client { ); cx.background_executor().timer(delay).await; delay = delay - .mul_f32(rng.gen_range(1.0..=2.0)) - .min(reconnect_interval); + .mul_f32(rng.gen_range(0.5..=2.5)) + .max(INITIAL_RECONNECTION_DELAY) + .min(MAX_RECONNECTION_DELAY); } else { break; } @@ -690,6 +702,22 @@ impl Client { entity: WeakModel, handler: H, ) -> Subscription + where + M: EnvelopedMessage, + E: 'static, + H: 'static + Sync + Fn(Model, TypedEnvelope, AsyncAppContext) -> F + Send + Sync, + F: 'static + Future>, + { + self.add_message_handler_impl(entity, move |model, message, _, cx| { + handler(model, message, cx) + }) + } + + fn add_message_handler_impl( + self: &Arc, + entity: WeakModel, + handler: H, + ) -> Subscription where M: EnvelopedMessage, E: 'static, @@ -738,19 +766,11 @@ impl Client { where M: RequestMessage, E: 'static, - H: 'static - + Sync - + Fn(Model, TypedEnvelope, Arc, AsyncAppContext) -> F - + Send - + Sync, + H: 'static + Sync + Fn(Model, TypedEnvelope, AsyncAppContext) -> F + Send + Sync, F: 'static + Future>, { - self.add_message_handler(model, move |handle, envelope, this, cx| { - Self::respond_to_request( - envelope.receipt(), - handler(handle, envelope, this.clone(), cx), - this, - ) + self.add_message_handler_impl(model, move |handle, envelope, this, cx| { + Self::respond_to_request(envelope.receipt(), handler(handle, envelope, cx), this) }) } @@ -758,11 +778,11 @@ impl Client { where M: EntityMessage, E: 'static, - H: 'static + Fn(Model, TypedEnvelope, Arc, AsyncAppContext) -> F + Send + Sync, + H: 'static + Fn(Model, TypedEnvelope, AsyncAppContext) -> F + Send + Sync, F: 'static + Future>, { - self.add_entity_message_handler::(move |subscriber, message, client, cx| { - handler(subscriber.downcast::().unwrap(), message, client, cx) + self.add_entity_message_handler::(move |subscriber, message, _, cx| { + handler(subscriber.downcast::().unwrap(), message, cx) }) } @@ -809,13 +829,13 @@ impl Client { where M: EntityMessage + RequestMessage, E: 'static, - H: 'static + Fn(Model, TypedEnvelope, Arc, AsyncAppContext) -> F + Send + Sync, + H: 'static + Fn(Model, TypedEnvelope, AsyncAppContext) -> F + Send + Sync, F: 'static + Future>, { - self.add_model_message_handler(move |entity, envelope, client, cx| { + self.add_entity_message_handler::(move |entity, envelope, client, cx| { Self::respond_to_request::( envelope.receipt(), - handler(entity, envelope, client.clone(), cx), + handler(entity.downcast::().unwrap(), envelope, cx), client, ) }) @@ -1079,38 +1099,50 @@ impl Client { self.establish_websocket_connection(credentials, cx) } - async fn get_rpc_url( + fn rpc_url( + &self, http: Arc, release_channel: Option, - ) -> Result { - if let Some(url) = &*ZED_RPC_URL { - return Url::parse(url).context("invalid rpc url"); - } + ) -> impl Future> { + #[cfg(any(test, feature = "test-support"))] + let url_override = self.rpc_url.read().clone(); - let mut url = http.build_url("/rpc"); - if let Some(preview_param) = - release_channel.and_then(|channel| channel.release_query_param()) - { - url += "?"; - url += preview_param; - } - let response = http.get(&url, Default::default(), false).await?; - let collab_url = if response.status().is_redirection() { - response - .headers() - .get("Location") - .ok_or_else(|| anyhow!("missing location header in /rpc response"))? - .to_str() - .map_err(EstablishConnectionError::other)? - .to_string() - } else { - Err(anyhow!( - "unexpected /rpc response status {}", - response.status() - ))? - }; + async move { + #[cfg(any(test, feature = "test-support"))] + if let Some(url) = url_override { + return Ok(url); + } + + if let Some(url) = &*ZED_RPC_URL { + return Url::parse(url).context("invalid rpc url"); + } + + let mut url = http.build_url("/rpc"); + if let Some(preview_param) = + release_channel.and_then(|channel| channel.release_query_param()) + { + url += "?"; + url += preview_param; + } + + let response = http.get(&url, Default::default(), false).await?; + let collab_url = if response.status().is_redirection() { + response + .headers() + .get("Location") + .ok_or_else(|| anyhow!("missing location header in /rpc response"))? + .to_str() + .map_err(EstablishConnectionError::other)? + .to_string() + } else { + Err(anyhow!( + "unexpected /rpc response status {}", + response.status() + ))? + }; - Url::parse(&collab_url).context("invalid rpc url") + Url::parse(&collab_url).context("invalid rpc url") + } } fn establish_websocket_connection( @@ -1137,8 +1169,9 @@ impl Client { ); let http = self.http.clone(); + let rpc_url = self.rpc_url(http, release_channel); cx.background_executor().spawn(async move { - let mut rpc_url = Self::get_rpc_url(http, release_channel).await?; + let mut rpc_url = rpc_url.await?; let rpc_host = rpc_url .host_str() .zip(rpc_url.port_or_known_default()) @@ -1179,6 +1212,7 @@ impl Client { cx: &AsyncAppContext, ) -> Task> { let http = self.http.clone(); + let this = self.clone(); cx.spawn(|cx| async move { let background = cx.background_executor().clone(); @@ -1208,7 +1242,8 @@ impl Client { { eprintln!("authenticate as admin {login}, {token}"); - return Self::authenticate_as_admin(http, login.clone(), token.clone()) + return this + .authenticate_as_admin(http, login.clone(), token.clone()) .await; } @@ -1296,6 +1331,7 @@ impl Client { } async fn authenticate_as_admin( + self: &Arc, http: Arc, login: String, mut api_token: String, @@ -1312,7 +1348,7 @@ impl Client { // Use the collab server's admin API to retrieve the id // of the impersonated user. - let mut url = Self::get_rpc_url(http.clone(), None).await?; + let mut url = self.rpc_url(http.clone(), None).await?; url.set_path("/user"); url.set_query(Some(&format!("github_login={login}"))); let request = Request::get(url.as_str()) @@ -1374,6 +1410,11 @@ impl Client { self.peer.send(self.connection_id()?, message) } + fn send_dynamic(&self, envelope: proto::Envelope) -> Result<()> { + let connection_id = self.connection_id()?; + self.peer.send_dynamic(connection_id, envelope) + } + pub fn request( &self, request: T, @@ -1430,6 +1471,31 @@ impl Client { } } + pub fn request_dynamic( + &self, + envelope: proto::Envelope, + request_type: &'static str, + ) -> impl Future> { + let client_id = self.id(); + log::debug!( + "rpc request start. client_id:{}. name:{}", + client_id, + request_type + ); + let response = self + .connection_id() + .map(|conn_id| self.peer.request_dynamic(conn_id, envelope, request_type)); + async move { + let response = response?.await; + log::debug!( + "rpc request finish. client_id:{}. name:{}", + client_id, + request_type + ); + Ok(response?.0) + } + } + fn respond(&self, receipt: Receipt, response: T::Response) -> Result<()> { log::debug!("rpc respond. client_id:{}. name:{}", self.id(), T::NAME); self.peer.respond(receipt, response) @@ -1547,6 +1613,20 @@ impl Client { } } +impl ProtoClient for Client { + fn request( + &self, + envelope: proto::Envelope, + request_type: &'static str, + ) -> BoxFuture<'static, Result> { + self.request_dynamic(envelope, request_type).boxed() + } + + fn send(&self, envelope: proto::Envelope) -> Result<()> { + self.send_dynamic(envelope) + } +} + #[derive(Serialize, Deserialize)] struct DevelopmentCredentials { user_id: u64, @@ -1705,6 +1785,7 @@ mod tests { use gpui::{BackgroundExecutor, Context, TestAppContext}; use http::FakeHttpClient; use parking_lot::Mutex; + use proto::TypedEnvelope; use settings::SettingsStore; use std::future; @@ -1887,7 +1968,7 @@ mod tests { let (done_tx1, mut done_rx1) = smol::channel::unbounded(); let (done_tx2, mut done_rx2) = smol::channel::unbounded(); client.add_model_message_handler( - move |model: Model, _: TypedEnvelope, _, mut cx| { + move |model: Model, _: TypedEnvelope, mut cx| { match model.update(&mut cx, |model, _| model.id).unwrap() { 1 => done_tx1.try_send(()).unwrap(), 2 => done_tx2.try_send(()).unwrap(), @@ -1949,7 +2030,7 @@ mod tests { let (done_tx2, mut done_rx2) = smol::channel::unbounded(); let subscription1 = client.add_message_handler( model.downgrade(), - move |_, _: TypedEnvelope, _, _| { + move |_, _: TypedEnvelope, _| { done_tx1.try_send(()).unwrap(); async { Ok(()) } }, @@ -1957,7 +2038,7 @@ mod tests { drop(subscription1); let _subscription2 = client.add_message_handler( model.downgrade(), - move |_, _: TypedEnvelope, _, _| { + move |_, _: TypedEnvelope, _| { done_tx2.try_send(()).unwrap(); async { Ok(()) } }, @@ -1983,7 +2064,7 @@ mod tests { let (done_tx, mut done_rx) = smol::channel::unbounded(); let subscription = client.add_message_handler( model.clone().downgrade(), - move |model: Model, _: TypedEnvelope, _, mut cx| { + move |model: Model, _: TypedEnvelope, mut cx| { model .update(&mut cx, |model, _| model.subscription.take()) .unwrap(); diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index feaa11dffb0213..55f263f42ee3d0 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -3,8 +3,9 @@ mod event_coalescer; use crate::{ChannelId, TelemetrySettings}; use chrono::{DateTime, Utc}; use clock::SystemClock; +use collections::{HashMap, HashSet}; use futures::Future; -use gpui::{AppContext, AppMetadata, BackgroundExecutor, Task}; +use gpui::{AppContext, BackgroundExecutor, Task}; use http::{self, HttpClient, HttpClientWithUrl, Method}; use once_cell::sync::Lazy; use parking_lot::Mutex; @@ -23,6 +24,7 @@ use tempfile::NamedTempFile; #[cfg(not(debug_assertions))] use util::ResultExt; use util::TryFutureExt; +use worktree::{UpdatedEntriesSet, WorktreeId}; use self::event_coalescer::EventCoalescer; @@ -39,7 +41,6 @@ struct TelemetryState { installation_id: Option>, // Per app installation (different for dev, nightly, preview, and stable) session_id: Option, // Per app launch release_channel: Option<&'static str>, - app_metadata: AppMetadata, architecture: &'static str, events_queue: Vec, flush_events_task: Option>, @@ -48,6 +49,29 @@ struct TelemetryState { first_event_date_time: Option>, event_coalescer: EventCoalescer, max_queue_size: usize, + worktree_id_map: WorktreeIdMap, + + os_name: String, + app_version: String, + os_version: Option, +} + +#[derive(Debug)] +struct WorktreeIdMap(HashMap); + +#[derive(Debug)] +struct ProjectCache { + name: String, + worktree_ids_reported: HashSet, +} + +impl ProjectCache { + fn new(name: String) -> Self { + Self { + name, + worktree_ids_reported: HashSet::default(), + } + } } #[cfg(debug_assertions)] @@ -71,6 +95,87 @@ static ZED_CLIENT_CHECKSUM_SEED: Lazy>> = Lazy::new(|| { }) }); +pub fn os_name() -> String { + #[cfg(target_os = "macos")] + { + "macOS".to_string() + } + #[cfg(target_os = "linux")] + { + format!("Linux {}", gpui::guess_compositor()) + } + + #[cfg(target_os = "windows")] + { + "Windows".to_string() + } +} + +/// Note: This might do blocking IO! Only call from background threads +pub fn os_version() -> String { + #[cfg(target_os = "macos")] + { + use cocoa::base::nil; + use cocoa::foundation::NSProcessInfo; + + unsafe { + let process_info = cocoa::foundation::NSProcessInfo::processInfo(nil); + let version = process_info.operatingSystemVersion(); + gpui::SemanticVersion::new( + version.majorVersion as usize, + version.minorVersion as usize, + version.patchVersion as usize, + ) + .to_string() + } + } + #[cfg(target_os = "linux")] + { + use std::path::Path; + + let content = if let Ok(file) = std::fs::read_to_string(&Path::new("/etc/os-release")) { + file + } else if let Ok(file) = std::fs::read_to_string(&Path::new("/usr/lib/os-release")) { + file + } else { + log::error!("Failed to load /etc/os-release, /usr/lib/os-release"); + "".to_string() + }; + let mut name = "unknown".to_string(); + let mut version = "unknown".to_string(); + + for line in content.lines() { + if line.starts_with("ID=") { + name = line.trim_start_matches("ID=").trim_matches('"').to_string(); + } + if line.starts_with("VERSION_ID=") { + version = line + .trim_start_matches("VERSION_ID=") + .trim_matches('"') + .to_string(); + } + } + + format!("{} {}", name, version) + } + + #[cfg(target_os = "windows")] + { + let mut info = unsafe { std::mem::zeroed() }; + let status = unsafe { windows::Wdk::System::SystemServices::RtlGetVersion(&mut info) }; + if status.is_ok() { + gpui::SemanticVersion::new( + info.dwMajorVersion as _, + info.dwMinorVersion as _, + info.dwBuildNumber as _, + ) + .to_string() + } else { + "unknown".to_string() + } + } +} + impl Telemetry { pub fn new( clock: Arc, @@ -84,7 +189,6 @@ impl Telemetry { let state = Arc::new(Mutex::new(TelemetryState { settings: *TelemetrySettings::get_global(cx), - app_metadata: cx.app_metadata(), architecture: env::consts::ARCH, release_channel, installation_id: None, @@ -97,6 +201,24 @@ impl Telemetry { first_event_date_time: None, event_coalescer: EventCoalescer::new(clock.clone()), max_queue_size: MAX_QUEUE_LEN, + worktree_id_map: WorktreeIdMap(HashMap::from_iter([ + ( + "pnpm-lock.yaml".to_string(), + ProjectCache::new("pnpm".to_string()), + ), + ( + "yarn.lock".to_string(), + ProjectCache::new("yarn".to_string()), + ), + ( + "package.json".to_string(), + ProjectCache::new("node".to_string()), + ), + ])), + + os_version: None, + os_name: os_name(), + app_version: release_channel::AppVersion::global(cx).to_string(), })); #[cfg(not(debug_assertions))] @@ -105,7 +227,7 @@ impl Telemetry { let state = state.clone(); async move { if let Some(tempfile) = - NamedTempFile::new_in(util::paths::CONFIG_DIR.as_path()).log_err() + NamedTempFile::new_in(paths::logs_dir().as_path()).log_err() { state.lock().log_file = Some(tempfile); } @@ -168,6 +290,9 @@ impl Telemetry { let mut state = self.state.lock(); state.installation_id = installation_id.map(|id| id.into()); state.session_id = Some(session_id); + state.app_version = release_channel::AppVersion::global(cx).to_string(); + state.os_name = os_name(); + drop(state); let this = self.clone(); @@ -360,6 +485,52 @@ impl Telemetry { self.report_event(event) } + pub fn report_discovered_project_events( + self: &Arc, + worktree_id: WorktreeId, + updated_entries_set: &UpdatedEntriesSet, + ) { + let project_names: Vec = { + let mut state = self.state.lock(); + state + .worktree_id_map + .0 + .iter_mut() + .filter_map(|(project_file_name, project_type_telemetry)| { + if project_type_telemetry + .worktree_ids_reported + .contains(&worktree_id) + { + return None; + } + + let project_file_found = updated_entries_set.iter().any(|(path, _, _)| { + path.as_ref() + .file_name() + .and_then(|name| name.to_str()) + .map(|name_str| name_str == project_file_name) + .unwrap_or(false) + }); + + if !project_file_found { + return None; + } + + project_type_telemetry + .worktree_ids_reported + .insert(worktree_id); + + Some(project_type_telemetry.name.clone()) + }) + .collect() + }; + + // Done on purpose to avoid calling `self.state.lock()` multiple times + for project_name in project_names { + self.report_app_event(format!("open {} project", project_name)); + } + } + fn report_event(self: &Arc, event: Event) { let mut state = self.state.lock(); @@ -423,10 +594,6 @@ impl Telemetry { return; } - if ZED_CLIENT_CHECKSUM_SEED.is_none() { - return; - }; - let this = self.clone(); self.executor .spawn( @@ -445,20 +612,15 @@ impl Telemetry { { let state = this.state.lock(); + let request_body = EventRequestBody { installation_id: state.installation_id.as_deref().map(Into::into), + metrics_id: state.metrics_id.as_deref().map(Into::into), session_id: state.session_id.clone(), is_staff: state.is_staff, - app_version: state - .app_metadata - .app_version - .unwrap_or_default() - .to_string(), - os_name: state.app_metadata.os_name.to_string(), - os_version: state - .app_metadata - .os_version - .map(|version| version.to_string()), + app_version: state.app_version.clone(), + os_name: state.os_name.clone(), + os_version: state.os_version.clone(), architecture: state.architecture.to_string(), release_channel: state.release_channel.map(Into::into), @@ -468,9 +630,7 @@ impl Telemetry { serde_json::to_writer(&mut json_bytes, &request_body)?; } - let Some(checksum) = calculate_json_checksum(&json_bytes) else { - return Ok(()); - }; + let checksum = calculate_json_checksum(&json_bytes).unwrap_or("".to_string()); let request = http::Request::builder() .method(Method::POST) diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index f97f45abe98bda..cd5bed682840e2 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -192,10 +192,13 @@ impl UserStore { cx.update(|cx| { if let Some(info) = info { - cx.update_flags(info.staff, info.flags); + let disable_staff = std::env::var("ZED_DISABLE_STAFF") + .map_or(false, |v| v != "" && v != "0"); + let staff = info.staff && !disable_staff; + cx.update_flags(staff, info.flags); client.telemetry.set_authenticated_user_info( Some(info.metrics_id.clone()), - info.staff, + staff, ) } })?; @@ -239,7 +242,6 @@ impl UserStore { async fn handle_update_invite_info( this: Model, message: TypedEnvelope, - _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { this.update(&mut cx, |this, cx| { @@ -255,7 +257,6 @@ impl UserStore { async fn handle_show_contacts( this: Model, _: TypedEnvelope, - _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { this.update(&mut cx, |_, cx| cx.emit(Event::ShowContacts))?; @@ -269,7 +270,6 @@ impl UserStore { async fn handle_update_contacts( this: Model, message: TypedEnvelope, - _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { this.update(&mut cx, |this, _| { diff --git a/crates/clock/Cargo.toml b/crates/clock/Cargo.toml index d1fb21747b378a..699a50e70d4da5 100644 --- a/crates/clock/Cargo.toml +++ b/crates/clock/Cargo.toml @@ -18,4 +18,5 @@ test-support = ["dep:parking_lot"] [dependencies] chrono.workspace = true parking_lot = { workspace = true, optional = true } +serde.workspace = true smallvec.workspace = true diff --git a/crates/clock/src/clock.rs b/crates/clock/src/clock.rs index 7a1377981a921c..f7d36ed4a87b9d 100644 --- a/crates/clock/src/clock.rs +++ b/crates/clock/src/clock.rs @@ -1,5 +1,6 @@ mod system_clock; +use serde::{Deserialize, Serialize}; use smallvec::SmallVec; use std::{ cmp::{self, Ordering}, @@ -16,7 +17,7 @@ pub type Seq = u32; /// A [Lamport timestamp](https://en.wikipedia.org/wiki/Lamport_timestamp), /// used to determine the ordering of events in the editor. -#[derive(Clone, Copy, Default, Eq, Hash, PartialEq)] +#[derive(Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize)] pub struct Lamport { pub replica_id: ReplicaId, pub value: Seq, @@ -87,51 +88,27 @@ impl Global { } pub fn observed_any(&self, other: &Self) -> bool { - let mut lhs = self.0.iter(); - let mut rhs = other.0.iter(); - loop { - if let Some(left) = lhs.next() { - if let Some(right) = rhs.next() { - if *right > 0 && left >= right { - return true; - } - } else { - return false; - } - } else { - return false; - } - } + self.0 + .iter() + .zip(other.0.iter()) + .any(|(left, right)| *right > 0 && left >= right) } pub fn observed_all(&self, other: &Self) -> bool { - let mut lhs = self.0.iter(); let mut rhs = other.0.iter(); - loop { - if let Some(left) = lhs.next() { - if let Some(right) = rhs.next() { - if left < right { - return false; - } - } else { - return true; - } - } else { - return rhs.next().is_none(); - } - } + self.0.iter().all(|left| match rhs.next() { + Some(right) => left >= right, + None => true, + }) && rhs.next().is_none() } pub fn changed_since(&self, other: &Self) -> bool { - if self.0.len() > other.0.len() { - return true; - } - for (left, right) in self.0.iter().zip(other.0.iter()) { - if left > right { - return true; - } - } - false + self.0.len() > other.0.len() + || self + .0 + .iter() + .zip(other.0.iter()) + .any(|(left, right)| left > right) } pub fn iter(&self) -> impl Iterator + '_ { @@ -185,6 +162,10 @@ impl Lamport { } } + pub fn as_u64(self) -> u64 { + ((self.value as u64) << 32) | (self.replica_id as u64) + } + pub fn tick(&mut self) -> Self { let timestamp = *self; self.value += 1; diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 005d4a27a0a7c7..4f1e1151b69b9d 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -71,6 +71,7 @@ util.workspace = true uuid.workspace = true [dev-dependencies] +assistant = { workspace = true, features = ["test-support"] } async-trait.workspace = true audio.workspace = true call = { workspace = true, features = ["test-support"] } @@ -96,6 +97,7 @@ node_runtime.workspace = true notifications = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } +recent_projects = { workspace = true } release_channel.workspace = true dev_server_projects.workspace = true rpc = { workspace = true, features = ["test-support"] } @@ -107,4 +109,5 @@ theme.workspace = true unindent.workspace = true util.workspace = true workspace = { workspace = true, features = ["test-support"] } +worktree = { workspace = true, features = ["test-support"] } headless.workspace = true diff --git a/crates/collab/k8s/collab.template.yml b/crates/collab/k8s/collab.template.yml index 271b146b0b6bba..77be7222281385 100644 --- a/crates/collab/k8s/collab.template.yml +++ b/crates/collab/k8s/collab.template.yml @@ -122,6 +122,11 @@ spec: secretKeyRef: name: anthropic key: api_key + - name: GOOGLE_AI_API_KEY + valueFrom: + secretKeyRef: + name: google-ai + key: api_key - name: BLOB_STORE_ACCESS_KEY valueFrom: secretKeyRef: @@ -172,6 +177,8 @@ spec: secretKeyRef: name: slack key: panics_webhook + - name: COMPLETE_WITH_LANGUAGE_MODEL_RATE_LIMIT_PER_HOUR + value: "1000" - name: SUPERMAVEN_ADMIN_API_KEY valueFrom: secretKeyRef: diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 45c424ea224130..8e3068645cc34e 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -414,5 +414,5 @@ CREATE TABLE dev_servers ( CREATE TABLE dev_server_projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, dev_server_id INTEGER NOT NULL REFERENCES dev_servers(id), - path TEXT NOT NULL + paths TEXT NOT NULL ); diff --git a/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql b/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql new file mode 100644 index 00000000000000..675df4885bb531 --- /dev/null +++ b/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql @@ -0,0 +1,4 @@ +ALTER TABLE dev_server_projects ADD COLUMN paths JSONB NULL; +UPDATE dev_server_projects SET paths = to_json(ARRAY[path]); +ALTER TABLE dev_server_projects ALTER COLUMN paths SET NOT NULL; +ALTER TABLE dev_server_projects ALTER COLUMN path DROP NOT NULL; diff --git a/crates/collab/src/api/events.rs b/crates/collab/src/api/events.rs index 8c269f5497242f..55d743ba9b27ec 100644 --- a/crates/collab/src/api/events.rs +++ b/crates/collab/src/api/events.rs @@ -308,6 +308,13 @@ pub async fn post_panic( .map_err(|_| Error::Http(StatusCode::BAD_REQUEST, "invalid json".into()))?; let panic = report.panic; + if panic.os_name == "Linux" && panic.os_version == Some("1.0.0".to_string()) { + return Err(Error::Http( + StatusCode::BAD_REQUEST, + "invalid os version".into(), + ))?; + } + tracing::error!( service = "client", version = %panic.app_version, @@ -394,12 +401,7 @@ pub async fn post_events( ))?; }; - if checksum != expected { - return Err(Error::Http( - StatusCode::BAD_REQUEST, - "invalid checksum".into(), - ))?; - } + let checksum_matched = checksum == expected; let request_body: telemetry_events::EventRequestBody = serde_json::from_slice(&body).map_err(|err| { @@ -424,6 +426,7 @@ pub async fn post_events( &request_body, first_event_at, country_code.clone(), + checksum_matched, )), // Needed for clients sending old copilot_event types Event::Copilot(_) => {} @@ -436,6 +439,7 @@ pub async fn post_events( &request_body, first_event_at, country_code.clone(), + checksum_matched, )) } Event::Call(event) => to_upload.call_events.push(CallEventRow::from_event( @@ -443,6 +447,7 @@ pub async fn post_events( &wrapper, &request_body, first_event_at, + checksum_matched, )), Event::Assistant(event) => { to_upload @@ -452,6 +457,7 @@ pub async fn post_events( &wrapper, &request_body, first_event_at, + checksum_matched, )) } Event::Cpu(event) => to_upload.cpu_events.push(CpuEventRow::from_event( @@ -459,36 +465,42 @@ pub async fn post_events( &wrapper, &request_body, first_event_at, + checksum_matched, )), Event::Memory(event) => to_upload.memory_events.push(MemoryEventRow::from_event( event.clone(), &wrapper, &request_body, first_event_at, + checksum_matched, )), Event::App(event) => to_upload.app_events.push(AppEventRow::from_event( event.clone(), &wrapper, &request_body, first_event_at, + checksum_matched, )), Event::Setting(event) => to_upload.setting_events.push(SettingEventRow::from_event( event.clone(), &wrapper, &request_body, first_event_at, + checksum_matched, )), Event::Edit(event) => to_upload.edit_events.push(EditEventRow::from_event( event.clone(), &wrapper, &request_body, first_event_at, + checksum_matched, )), Event::Action(event) => to_upload.action_events.push(ActionEventRow::from_event( event.clone(), &wrapper, &request_body, first_event_at, + checksum_matched, )), Event::Extension(event) => { let metadata = app @@ -503,6 +515,7 @@ pub async fn post_events( &request_body, metadata, first_event_at, + checksum_matched, )) } } @@ -650,29 +663,31 @@ where #[derive(Serialize, Debug, clickhouse::Row)] pub struct EditorEventRow { - pub installation_id: String, - pub operation: String, - pub app_version: String, - pub file_extension: String, - pub os_name: String, - pub os_version: String, - pub release_channel: String, - pub signed_in: bool, - pub vim_mode: bool, + installation_id: String, + metrics_id: String, + operation: String, + app_version: String, + file_extension: String, + os_name: String, + os_version: String, + release_channel: String, + signed_in: bool, + vim_mode: bool, #[serde(serialize_with = "serialize_country_code")] - pub country_code: String, - pub region_code: String, - pub city: String, - pub time: i64, - pub copilot_enabled: bool, - pub copilot_enabled_for_language: bool, - pub historical_event: bool, - pub architecture: String, - pub is_staff: Option, - pub session_id: Option, - pub major: Option, - pub minor: Option, - pub patch: Option, + country_code: String, + region_code: String, + city: String, + time: i64, + copilot_enabled: bool, + copilot_enabled_for_language: bool, + historical_event: bool, + architecture: String, + is_staff: Option, + session_id: Option, + major: Option, + minor: Option, + patch: Option, + checksum_matched: bool, } impl EditorEventRow { @@ -682,6 +697,7 @@ impl EditorEventRow { body: &EventRequestBody, first_event_at: chrono::DateTime, country_code: Option, + checksum_matched: bool, ) -> Self { let semver = body.semver(); let time = @@ -692,11 +708,13 @@ impl EditorEventRow { major: semver.map(|v| v.major() as i32), minor: semver.map(|v| v.minor() as i32), patch: semver.map(|v| v.patch() as i32), + checksum_matched, release_channel: body.release_channel.clone().unwrap_or_default(), os_name: body.os_name.clone(), os_version: body.os_version.clone().unwrap_or_default(), architecture: body.architecture.clone(), installation_id: body.installation_id.clone().unwrap_or_default(), + metrics_id: body.metrics_id.clone().unwrap_or_default(), session_id: body.session_id.clone(), is_staff: body.is_staff, time: time.timestamp_millis(), @@ -716,25 +734,26 @@ impl EditorEventRow { #[derive(Serialize, Debug, clickhouse::Row)] pub struct InlineCompletionEventRow { - pub installation_id: String, - pub provider: String, - pub suggestion_accepted: bool, - pub app_version: String, - pub file_extension: String, - pub os_name: String, - pub os_version: String, - pub release_channel: String, - pub signed_in: bool, + installation_id: String, + provider: String, + suggestion_accepted: bool, + app_version: String, + file_extension: String, + os_name: String, + os_version: String, + release_channel: String, + signed_in: bool, #[serde(serialize_with = "serialize_country_code")] - pub country_code: String, - pub region_code: String, - pub city: String, - pub time: i64, - pub is_staff: Option, - pub session_id: Option, - pub major: Option, - pub minor: Option, - pub patch: Option, + country_code: String, + region_code: String, + city: String, + time: i64, + is_staff: Option, + session_id: Option, + major: Option, + minor: Option, + patch: Option, + checksum_matched: bool, } impl InlineCompletionEventRow { @@ -744,6 +763,7 @@ impl InlineCompletionEventRow { body: &EventRequestBody, first_event_at: chrono::DateTime, country_code: Option, + checksum_matched: bool, ) -> Self { let semver = body.semver(); let time = @@ -754,6 +774,7 @@ impl InlineCompletionEventRow { major: semver.map(|v| v.major() as i32), minor: semver.map(|v| v.minor() as i32), patch: semver.map(|v| v.patch() as i32), + checksum_matched, release_channel: body.release_channel.clone().unwrap_or_default(), os_name: body.os_name.clone(), os_version: body.os_version.clone().unwrap_or_default(), @@ -780,6 +801,9 @@ pub struct CallEventRow { minor: Option, patch: Option, release_channel: String, + os_name: String, + os_version: String, + checksum_matched: bool, // ClientEventBase installation_id: String, @@ -799,6 +823,7 @@ impl CallEventRow { wrapper: &EventWrapper, body: &EventRequestBody, first_event_at: chrono::DateTime, + checksum_matched: bool, ) -> Self { let semver = body.semver(); let time = @@ -809,7 +834,10 @@ impl CallEventRow { major: semver.map(|v| v.major() as i32), minor: semver.map(|v| v.minor() as i32), patch: semver.map(|v| v.patch() as i32), + checksum_matched, release_channel: body.release_channel.clone().unwrap_or_default(), + os_name: body.os_name.clone(), + os_version: body.os_version.clone().unwrap_or_default(), installation_id: body.installation_id.clone().unwrap_or_default(), session_id: body.session_id.clone(), is_staff: body.is_staff, @@ -828,7 +856,10 @@ pub struct AssistantEventRow { major: Option, minor: Option, patch: Option, + checksum_matched: bool, release_channel: String, + os_name: String, + os_version: String, // ClientEventBase installation_id: Option, @@ -850,6 +881,7 @@ impl AssistantEventRow { wrapper: &EventWrapper, body: &EventRequestBody, first_event_at: chrono::DateTime, + checksum_matched: bool, ) -> Self { let semver = body.semver(); let time = @@ -860,7 +892,10 @@ impl AssistantEventRow { major: semver.map(|v| v.major() as i32), minor: semver.map(|v| v.minor() as i32), patch: semver.map(|v| v.patch() as i32), + checksum_matched, release_channel: body.release_channel.clone().unwrap_or_default(), + os_name: body.os_name.clone(), + os_version: body.os_version.clone().unwrap_or_default(), installation_id: body.installation_id.clone(), session_id: body.session_id.clone(), is_staff: body.is_staff, @@ -878,18 +913,21 @@ impl AssistantEventRow { #[derive(Debug, clickhouse::Row, Serialize)] pub struct CpuEventRow { - pub installation_id: Option, - pub is_staff: Option, - pub usage_as_percentage: f32, - pub core_count: u32, - pub app_version: String, - pub release_channel: String, - pub time: i64, - pub session_id: Option, + installation_id: Option, + is_staff: Option, + usage_as_percentage: f32, + core_count: u32, + app_version: String, + release_channel: String, + os_name: String, + os_version: String, + time: i64, + session_id: Option, // pub normalized_cpu_usage: f64, MATERIALIZED - pub major: Option, - pub minor: Option, - pub patch: Option, + major: Option, + minor: Option, + patch: Option, + checksum_matched: bool, } impl CpuEventRow { @@ -898,6 +936,7 @@ impl CpuEventRow { wrapper: &EventWrapper, body: &EventRequestBody, first_event_at: chrono::DateTime, + checksum_matched: bool, ) -> Self { let semver = body.semver(); let time = @@ -908,7 +947,10 @@ impl CpuEventRow { major: semver.map(|v| v.major() as i32), minor: semver.map(|v| v.minor() as i32), patch: semver.map(|v| v.patch() as i32), + checksum_matched, release_channel: body.release_channel.clone().unwrap_or_default(), + os_name: body.os_name.clone(), + os_version: body.os_version.clone().unwrap_or_default(), installation_id: body.installation_id.clone(), session_id: body.session_id.clone(), is_staff: body.is_staff, @@ -926,7 +968,10 @@ pub struct MemoryEventRow { major: Option, minor: Option, patch: Option, + checksum_matched: bool, release_channel: String, + os_name: String, + os_version: String, // ClientEventBase installation_id: Option, @@ -945,6 +990,7 @@ impl MemoryEventRow { wrapper: &EventWrapper, body: &EventRequestBody, first_event_at: chrono::DateTime, + checksum_matched: bool, ) -> Self { let semver = body.semver(); let time = @@ -955,7 +1001,10 @@ impl MemoryEventRow { major: semver.map(|v| v.major() as i32), minor: semver.map(|v| v.minor() as i32), patch: semver.map(|v| v.patch() as i32), + checksum_matched, release_channel: body.release_channel.clone().unwrap_or_default(), + os_name: body.os_name.clone(), + os_version: body.os_version.clone().unwrap_or_default(), installation_id: body.installation_id.clone(), session_id: body.session_id.clone(), is_staff: body.is_staff, @@ -973,7 +1022,10 @@ pub struct AppEventRow { major: Option, minor: Option, patch: Option, + checksum_matched: bool, release_channel: String, + os_name: String, + os_version: String, // ClientEventBase installation_id: Option, @@ -991,6 +1043,7 @@ impl AppEventRow { wrapper: &EventWrapper, body: &EventRequestBody, first_event_at: chrono::DateTime, + checksum_matched: bool, ) -> Self { let semver = body.semver(); let time = @@ -1001,7 +1054,10 @@ impl AppEventRow { major: semver.map(|v| v.major() as i32), minor: semver.map(|v| v.minor() as i32), patch: semver.map(|v| v.patch() as i32), + checksum_matched, release_channel: body.release_channel.clone().unwrap_or_default(), + os_name: body.os_name.clone(), + os_version: body.os_version.clone().unwrap_or_default(), installation_id: body.installation_id.clone(), session_id: body.session_id.clone(), is_staff: body.is_staff, @@ -1018,7 +1074,10 @@ pub struct SettingEventRow { major: Option, minor: Option, patch: Option, + checksum_matched: bool, release_channel: String, + os_name: String, + os_version: String, // ClientEventBase installation_id: Option, @@ -1036,6 +1095,7 @@ impl SettingEventRow { wrapper: &EventWrapper, body: &EventRequestBody, first_event_at: chrono::DateTime, + checksum_matched: bool, ) -> Self { let semver = body.semver(); let time = @@ -1045,8 +1105,11 @@ impl SettingEventRow { app_version: body.app_version.clone(), major: semver.map(|v| v.major() as i32), minor: semver.map(|v| v.minor() as i32), + checksum_matched, patch: semver.map(|v| v.patch() as i32), release_channel: body.release_channel.clone().unwrap_or_default(), + os_name: body.os_name.clone(), + os_version: body.os_version.clone().unwrap_or_default(), installation_id: body.installation_id.clone(), session_id: body.session_id.clone(), is_staff: body.is_staff, @@ -1064,7 +1127,10 @@ pub struct ExtensionEventRow { major: Option, minor: Option, patch: Option, + checksum_matched: bool, release_channel: String, + os_name: String, + os_version: String, // ClientEventBase installation_id: Option, @@ -1087,6 +1153,7 @@ impl ExtensionEventRow { body: &EventRequestBody, extension_metadata: Option, first_event_at: chrono::DateTime, + checksum_matched: bool, ) -> Self { let semver = body.semver(); let time = @@ -1097,7 +1164,10 @@ impl ExtensionEventRow { major: semver.map(|v| v.major() as i32), minor: semver.map(|v| v.minor() as i32), patch: semver.map(|v| v.patch() as i32), + checksum_matched, release_channel: body.release_channel.clone().unwrap_or_default(), + os_name: body.os_name.clone(), + os_version: body.os_version.clone().unwrap_or_default(), installation_id: body.installation_id.clone(), session_id: body.session_id.clone(), is_staff: body.is_staff, @@ -1126,7 +1196,10 @@ pub struct EditEventRow { major: Option, minor: Option, patch: Option, + checksum_matched: bool, release_channel: String, + os_name: String, + os_version: String, // ClientEventBase installation_id: Option, @@ -1148,6 +1221,7 @@ impl EditEventRow { wrapper: &EventWrapper, body: &EventRequestBody, first_event_at: chrono::DateTime, + checksum_matched: bool, ) -> Self { let semver = body.semver(); let time = @@ -1161,7 +1235,10 @@ impl EditEventRow { major: semver.map(|v| v.major() as i32), minor: semver.map(|v| v.minor() as i32), patch: semver.map(|v| v.patch() as i32), + checksum_matched, release_channel: body.release_channel.clone().unwrap_or_default(), + os_name: body.os_name.clone(), + os_version: body.os_version.clone().unwrap_or_default(), installation_id: body.installation_id.clone(), session_id: body.session_id.clone(), is_staff: body.is_staff, @@ -1180,7 +1257,10 @@ pub struct ActionEventRow { major: Option, minor: Option, patch: Option, + checksum_matched: bool, release_channel: String, + os_name: String, + os_version: String, // ClientEventBase installation_id: Option, @@ -1200,6 +1280,7 @@ impl ActionEventRow { wrapper: &EventWrapper, body: &EventRequestBody, first_event_at: chrono::DateTime, + checksum_matched: bool, ) -> Self { let semver = body.semver(); let time = @@ -1210,7 +1291,10 @@ impl ActionEventRow { major: semver.map(|v| v.major() as i32), minor: semver.map(|v| v.minor() as i32), patch: semver.map(|v| v.patch() as i32), + checksum_matched, release_channel: body.release_channel.clone().unwrap_or_default(), + os_name: body.os_name.clone(), + os_version: body.os_version.clone().unwrap_or_default(), installation_id: body.installation_id.clone(), session_id: body.session_id.clone(), is_staff: body.is_staff, diff --git a/crates/collab/src/api/extensions.rs b/crates/collab/src/api/extensions.rs index 20e35ec28d56d1..d0532504ed664c 100644 --- a/crates/collab/src/api/extensions.rs +++ b/crates/collab/src/api/extensions.rs @@ -36,8 +36,6 @@ pub fn router() -> Router { struct GetExtensionsParams { filter: Option, #[serde(default)] - ids: Option, - #[serde(default)] max_schema_version: i32, } @@ -45,19 +43,41 @@ async fn get_extensions( Extension(app): Extension>, Query(params): Query, ) -> Result> { - let extension_ids = params - .ids - .as_ref() - .map(|s| s.split(',').map(|s| s.trim()).collect::>()); - - let extensions = if let Some(extension_ids) = extension_ids { - app.db.get_extensions_by_ids(&extension_ids, None).await? - } else { - app.db - .get_extensions(params.filter.as_deref(), params.max_schema_version, 500) - .await? + let mut extensions = app + .db + .get_extensions(params.filter.as_deref(), params.max_schema_version, 500) + .await?; + + if let Some(filter) = params.filter.as_deref() { + let extension_id = filter.to_lowercase(); + let mut exact_match = None; + extensions.retain(|extension| { + if extension.id.as_ref() == &extension_id { + exact_match = Some(extension.clone()); + false + } else { + true + } + }); + if exact_match.is_none() { + exact_match = app + .db + .get_extensions_by_ids(&[&extension_id], None) + .await? + .first() + .cloned(); + } + + if let Some(exact_match) = exact_match { + extensions.insert(0, exact_match); + } }; + if let Some(query) = params.filter.as_deref() { + let count = extensions.len(); + tracing::info!(query, count, "extension_search") + } + Ok(Json(GetExtensionsResponse { data: extensions })) } @@ -239,61 +259,74 @@ async fn fetch_extensions_from_blob_store( ) -> anyhow::Result<()> { log::info!("fetching extensions from blob store"); - let list = blob_store_client - .list_objects() - .bucket(blob_store_bucket) - .prefix("extensions/") - .send() - .await?; + let mut next_marker = None; + let mut published_versions = HashMap::>::default(); + + loop { + let list = blob_store_client + .list_objects() + .bucket(blob_store_bucket) + .prefix("extensions/") + .set_marker(next_marker.clone()) + .send() + .await?; + let objects = list.contents.unwrap_or_default(); + log::info!("fetched {} object(s) from blob store", objects.len()); + + for object in &objects { + let Some(key) = object.key.as_ref() else { + continue; + }; + let mut parts = key.split('/'); + let Some(_) = parts.next().filter(|part| *part == "extensions") else { + continue; + }; + let Some(extension_id) = parts.next() else { + continue; + }; + let Some(version) = parts.next() else { + continue; + }; + if parts.next() == Some("manifest.json") { + published_versions + .entry(extension_id.to_owned()) + .or_default() + .push(version.to_owned()); + } + } - let objects = list.contents.unwrap_or_default(); - - let mut published_versions = HashMap::<&str, Vec<&str>>::default(); - for object in &objects { - let Some(key) = object.key.as_ref() else { - continue; - }; - let mut parts = key.split('/'); - let Some(_) = parts.next().filter(|part| *part == "extensions") else { - continue; - }; - let Some(extension_id) = parts.next() else { - continue; - }; - let Some(version) = parts.next() else { - continue; - }; - if parts.next() == Some("manifest.json") { - published_versions - .entry(extension_id) - .or_default() - .push(version); + if let (Some(true), Some(last_object)) = (list.is_truncated, objects.last()) { + next_marker.clone_from(&last_object.key); + } else { + break; } } + log::info!("found {} published extensions", published_versions.len()); + let known_versions = app_state.db.get_known_extension_versions().await?; let mut new_versions = HashMap::<&str, Vec>::default(); let empty = Vec::new(); - for (extension_id, published_versions) in published_versions { + for (extension_id, published_versions) in &published_versions { let known_versions = known_versions.get(extension_id).unwrap_or(&empty); for published_version in published_versions { if known_versions - .binary_search_by_key(&published_version, String::as_str) + .binary_search_by_key(&published_version, |known_version| known_version) .is_err() { if let Some(extension) = fetch_extension_manifest( blob_store_client, blob_store_bucket, - extension_id, - published_version, + &extension_id, + &published_version, ) .await .log_err() { new_versions - .entry(extension_id) + .entry(&extension_id) .or_default() .push(extension); } diff --git a/crates/collab/src/auth.rs b/crates/collab/src/auth.rs index 915563d6b44738..14c07742d4eaba 100644 --- a/crates/collab/src/auth.rs +++ b/crates/collab/src/auth.rs @@ -277,7 +277,7 @@ mod test { #[gpui::test] async fn test_verify_access_token(cx: &mut gpui::TestAppContext) { - let test_db = crate::db::TestDb::postgres(cx.executor().clone()); + let test_db = crate::db::TestDb::sqlite(cx.executor().clone()); let db = test_db.db(); let user = db diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 02b182aca7bf4d..d4ed9ea5e7fba7 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -654,6 +654,7 @@ pub struct ChannelsForUser { pub channel_memberships: Vec, pub channel_participants: HashMap>, pub hosted_projects: Vec, + pub invited_channels: Vec, pub observed_buffer_versions: Vec, pub observed_channel_messages: Vec, diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 502fcd59c56900..ff7a95cf7649a6 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -416,7 +416,9 @@ impl Database { user_id: UserId, tx: &DatabaseTransaction, ) -> Result { - let new_channels = self.get_user_channels(user_id, Some(channel), tx).await?; + let new_channels = self + .get_user_channels(user_id, Some(channel), false, tx) + .await?; let removed_channels = self .get_channel_descendants_excluding_self([channel], tx) .await? @@ -481,44 +483,10 @@ impl Database { .await } - /// Returns all channel invites for the user with the given ID. - pub async fn get_channel_invites_for_user(&self, user_id: UserId) -> Result> { - self.transaction(|tx| async move { - let mut role_for_channel: HashMap = HashMap::default(); - - let channel_invites = channel_member::Entity::find() - .filter( - channel_member::Column::UserId - .eq(user_id) - .and(channel_member::Column::Accepted.eq(false)), - ) - .all(&*tx) - .await?; - - for invite in channel_invites { - role_for_channel.insert(invite.channel_id, invite.role); - } - - let channels = channel::Entity::find() - .filter(channel::Column::Id.is_in(role_for_channel.keys().copied())) - .all(&*tx) - .await?; - - let channels = channels.into_iter().map(Channel::from_model).collect(); - - Ok(channels) - }) - .await - } - /// Returns all channels for the user with the given ID. pub async fn get_channels_for_user(&self, user_id: UserId) -> Result { - self.transaction(|tx| async move { - let tx = tx; - - self.get_user_channels(user_id, None, &tx).await - }) - .await + self.transaction(|tx| async move { self.get_user_channels(user_id, None, true, &tx).await }) + .await } /// Returns all channels for the user with the given ID that are descendants @@ -527,25 +495,37 @@ impl Database { &self, user_id: UserId, ancestor_channel: Option<&channel::Model>, + include_invites: bool, tx: &DatabaseTransaction, ) -> Result { - let mut filter = channel_member::Column::UserId - .eq(user_id) - .and(channel_member::Column::Accepted.eq(true)); - + let mut filter = channel_member::Column::UserId.eq(user_id); + if !include_invites { + filter = filter.and(channel_member::Column::Accepted.eq(true)) + } if let Some(ancestor) = ancestor_channel { filter = filter.and(channel_member::Column::ChannelId.eq(ancestor.root_id())); } - let channel_memberships = channel_member::Entity::find() + let mut channels = Vec::::new(); + let mut invited_channels = Vec::::new(); + let mut channel_memberships = Vec::::new(); + let mut rows = channel_member::Entity::find() .filter(filter) - .all(tx) - .await?; - - let channels = channel::Entity::find() - .filter(channel::Column::Id.is_in(channel_memberships.iter().map(|m| m.channel_id))) - .all(tx) + .inner_join(channel::Entity) + .select_also(channel::Entity) + .stream(tx) .await?; + while let Some(row) = rows.next().await { + if let (membership, Some(channel)) = row? { + if membership.accepted { + channel_memberships.push(membership); + channels.push(channel); + } else { + invited_channels.push(Channel::from_model(channel)); + } + } + } + drop(rows); let mut descendants = self .get_channel_descendants_excluding_self(channels.iter(), tx) @@ -643,6 +623,7 @@ impl Database { Ok(ChannelsForUser { channel_memberships, channels, + invited_channels, hosted_projects, channel_participants, latest_buffer_versions, @@ -713,7 +694,7 @@ impl Database { .find_also_related(user::Entity) .filter(channel_member::Column::ChannelId.eq(channel.root_id())); - if cfg!(any(test, sqlite)) && self.pool.get_database_backend() == DbBackend::Sqlite { + if cfg!(any(test, feature = "sqlite")) && self.pool.get_database_backend() == DbBackend::Sqlite { query = query.filter(Expr::cust_with_values( "UPPER(github_login) LIKE ?", [Self::fuzzy_like_string(&filter.to_uppercase())], diff --git a/crates/collab/src/db/queries/dev_server_projects.rs b/crates/collab/src/db/queries/dev_server_projects.rs index 3c71693a530b97..9312811335a126 100644 --- a/crates/collab/src/db/queries/dev_server_projects.rs +++ b/crates/collab/src/db/queries/dev_server_projects.rs @@ -5,7 +5,7 @@ use rpc::{ }; use sea_orm::{ ActiveModelTrait, ActiveValue, ColumnTrait, Condition, DatabaseTransaction, EntityTrait, - ModelTrait, QueryFilter, + IntoActiveModel, ModelTrait, QueryFilter, }; use crate::db::ProjectId; @@ -56,12 +56,7 @@ impl Database { .await?; Ok(servers .into_iter() - .map(|(dev_server_project, project)| proto::DevServerProject { - id: dev_server_project.id.to_proto(), - project_id: project.map(|p| p.id.to_proto()), - dev_server_id: dev_server_project.dev_server_id.to_proto(), - path: dev_server_project.path, - }) + .map(|(dev_server_project, project)| dev_server_project.to_proto(project)) .collect()) } @@ -134,7 +129,7 @@ impl Database { let project = dev_server_project::Entity::insert(dev_server_project::ActiveModel { id: ActiveValue::NotSet, dev_server_id: ActiveValue::Set(dev_server_id), - path: ActiveValue::Set(path.to_string()), + paths: ActiveValue::Set(dev_server_project::JSONPaths(vec![path.to_string()])), }) .exec_with_returning(&*tx) .await?; @@ -148,6 +143,38 @@ impl Database { .await } + pub async fn update_dev_server_project( + &self, + id: DevServerProjectId, + paths: &Vec, + user_id: UserId, + ) -> crate::Result<(dev_server_project::Model, proto::DevServerProjectsUpdate)> { + self.transaction(move |tx| async move { + let paths = paths.clone(); + let Some((project, Some(dev_server))) = dev_server_project::Entity::find_by_id(id) + .find_also_related(dev_server::Entity) + .one(&*tx) + .await? + else { + return Err(anyhow!("no such dev server project"))?; + }; + + if dev_server.user_id != user_id { + return Err(anyhow!("not your dev server"))?; + } + let mut project = project.into_active_model(); + project.paths = ActiveValue::Set(dev_server_project::JSONPaths(paths)); + let project = project.update(&*tx).await?; + + let status = self + .dev_server_projects_update_internal(user_id, &tx) + .await?; + + Ok((project, status)) + }) + .await + } + pub async fn delete_dev_server_project( &self, dev_server_project_id: DevServerProjectId, @@ -258,7 +285,6 @@ impl Database { dev_server_id: DevServerId, connection: ConnectionId, ) -> crate::Result> { - // todo!() project_transaction? (maybe we can make the lock per-dev-server instead of per-project?) self.transaction(|tx| async move { let mut ret = Vec::new(); for reshared_project in reshared_projects { @@ -322,7 +348,6 @@ impl Database { user_id: UserId, connection_id: ConnectionId, ) -> crate::Result> { - // todo!() project_transaction? (maybe we can make the lock per-dev-server instead of per-project?) self.transaction(|tx| async move { let mut ret = Vec::new(); for rejoined_project in rejoined_projects { diff --git a/crates/collab/src/db/queries/dev_servers.rs b/crates/collab/src/db/queries/dev_servers.rs index fd5bcc8c478102..16cbfedee33e50 100644 --- a/crates/collab/src/db/queries/dev_servers.rs +++ b/crates/collab/src/db/queries/dev_servers.rs @@ -19,6 +19,28 @@ impl Database { .await } + pub async fn get_dev_server_for_user( + &self, + dev_server_id: DevServerId, + user_id: UserId, + ) -> crate::Result { + self.transaction(|tx| async move { + let server = dev_server::Entity::find_by_id(dev_server_id) + .one(&*tx) + .await? + .ok_or_else(|| anyhow::anyhow!("no dev server with id {}", dev_server_id))?; + if server.user_id != user_id { + return Err(anyhow::anyhow!( + "dev server {} is not owned by user {}", + dev_server_id, + user_id + ))?; + } + Ok(server) + }) + .await + } + pub async fn get_dev_servers(&self, user_id: UserId) -> crate::Result> { self.transaction(|tx| async move { Ok(dev_server::Entity::find() diff --git a/crates/collab/src/db/queries/extensions.rs b/crates/collab/src/db/queries/extensions.rs index d6938fd776e2f8..93604868fae0e3 100644 --- a/crates/collab/src/db/queries/extensions.rs +++ b/crates/collab/src/db/queries/extensions.rs @@ -379,6 +379,7 @@ fn metadata_from_extension_and_version( pub fn convert_time_to_chrono(time: time::PrimitiveDateTime) -> chrono::DateTime { chrono::DateTime::from_naive_utc_and_offset( + #[allow(deprecated)] chrono::NaiveDateTime::from_timestamp_opt(time.assume_utc().unix_timestamp(), 0).unwrap(), Utc, ) diff --git a/crates/collab/src/db/queries/remote_projects.rs b/crates/collab/src/db/queries/remote_projects.rs deleted file mode 100644 index 8b137891791fe9..00000000000000 --- a/crates/collab/src/db/queries/remote_projects.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/collab/src/db/tables/dev_server_project.rs b/crates/collab/src/db/tables/dev_server_project.rs index bf90d7092d5879..d3c2da63491fb5 100644 --- a/crates/collab/src/db/tables/dev_server_project.rs +++ b/crates/collab/src/db/tables/dev_server_project.rs @@ -1,7 +1,8 @@ use super::project; use crate::db::{DevServerId, DevServerProjectId}; use rpc::proto; -use sea_orm::entity::prelude::*; +use sea_orm::{entity::prelude::*, FromJsonQueryResult}; +use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "dev_server_projects")] @@ -9,9 +10,12 @@ pub struct Model { #[sea_orm(primary_key)] pub id: DevServerProjectId, pub dev_server_id: DevServerId, - pub path: String, + pub paths: JSONPaths, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)] +pub struct JSONPaths(pub Vec); + impl ActiveModelBehavior for ActiveModel {} #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -44,7 +48,12 @@ impl Model { id: self.id.to_proto(), project_id: project.map(|p| p.id.to_proto()), dev_server_id: self.dev_server_id.to_proto(), - path: self.path.clone(), + path: self.paths().get(0).cloned().unwrap_or_default(), + paths: self.paths().clone(), } } + + pub fn paths(&self) -> &Vec { + &self.paths.0 + } } diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index e3ce834295818e..b8460c41e82c92 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -2,6 +2,8 @@ mod buffer_tests; mod channel_tests; mod contributor_tests; mod db_tests; +// we only run postgres tests on macos right now +#[cfg(target_os = "macos")] mod embedding_tests; mod extension_tests; mod feature_flag_tests; @@ -108,6 +110,7 @@ impl TestDb { #[macro_export] macro_rules! test_both_dbs { ($test_name:ident, $postgres_test_name:ident, $sqlite_test_name:ident) => { + #[cfg(target_os = "macos")] #[gpui::test] async fn $postgres_test_name(cx: &mut gpui::TestAppContext) { let test_db = $crate::db::TestDb::postgres(cx.executor().clone()); diff --git a/crates/collab/src/db/tests/channel_tests.rs b/crates/collab/src/db/tests/channel_tests.rs index 4482549e91d51b..d409867447e533 100644 --- a/crates/collab/src/db/tests/channel_tests.rs +++ b/crates/collab/src/db/tests/channel_tests.rs @@ -176,23 +176,23 @@ async fn test_channel_invites(db: &Arc) { .unwrap(); let user_2_invites = db - .get_channel_invites_for_user(user_2) // -> [channel_1_1, channel_1_2] + .get_channels_for_user(user_2) .await .unwrap() + .invited_channels .into_iter() .map(|channel| channel.id) .collect::>(); - assert_eq!(user_2_invites, &[channel_1_1, channel_1_2]); let user_3_invites = db - .get_channel_invites_for_user(user_3) // -> [channel_1_1] + .get_channels_for_user(user_3) .await .unwrap() + .invited_channels .into_iter() .map(|channel| channel.id) .collect::>(); - assert_eq!(user_3_invites, &[channel_1_1]); let (mut members, _) = db diff --git a/crates/collab/src/db/tests/db_tests.rs b/crates/collab/src/db/tests/db_tests.rs index c78ba9ec917512..4f9bb035fbf454 100644 --- a/crates/collab/src/db/tests/db_tests.rs +++ b/crates/collab/src/db/tests/db_tests.rs @@ -1,9 +1,7 @@ use super::*; use crate::test_both_dbs; -use gpui::TestAppContext; use pretty_assertions::{assert_eq, assert_ne}; use std::sync::Arc; -use tests::TestDb; test_both_dbs!( test_get_users, @@ -564,9 +562,10 @@ fn test_fuzzy_like_string() { assert_eq!(Database::fuzzy_like_string(" z "), "%z%"); } +#[cfg(target_os = "macos")] #[gpui::test] -async fn test_fuzzy_search_users(cx: &mut TestAppContext) { - let test_db = TestDb::postgres(cx.executor()); +async fn test_fuzzy_search_users(cx: &mut gpui::TestAppContext) { + let test_db = tests::TestDb::postgres(cx.executor()); let db = test_db.db(); for (i, github_login) in [ "California", diff --git a/crates/collab/src/rate_limiter.rs b/crates/collab/src/rate_limiter.rs index e6f1fcbaebdcc6..844a3af949481a 100644 --- a/crates/collab/src/rate_limiter.rs +++ b/crates/collab/src/rate_limiter.rs @@ -62,7 +62,7 @@ impl RateLimiter { let mut bucket = self .buckets .entry(bucket_key.clone()) - .or_insert_with(|| RateBucket::new(T::capacity(), T::refill_duration(), now)); + .or_insert_with(|| RateBucket::new::(now)); if bucket.value_mut().allow(now) { self.dirty_buckets.insert(bucket_key); @@ -72,19 +72,19 @@ impl RateLimiter { } } - async fn load_bucket( + async fn load_bucket( &self, user_id: UserId, ) -> Result, Error> { Ok(self .db - .get_rate_bucket(user_id, K::db_name()) + .get_rate_bucket(user_id, T::db_name()) .await? - .map(|saved_bucket| RateBucket { - capacity: K::capacity(), - refill_time_per_token: K::refill_duration(), - token_count: saved_bucket.token_count as usize, - last_refill: DateTime::from_naive_utc_and_offset(saved_bucket.last_refill, Utc), + .map(|saved_bucket| { + RateBucket::from_db::( + saved_bucket.token_count as usize, + DateTime::from_naive_utc_and_offset(saved_bucket.last_refill, Utc), + ) })) } @@ -124,15 +124,24 @@ struct RateBucket { } impl RateBucket { - fn new(capacity: usize, refill_duration: Duration, now: DateTimeUtc) -> Self { - RateBucket { - capacity, - token_count: capacity, - refill_time_per_token: refill_duration / capacity as i32, + fn new(now: DateTimeUtc) -> Self { + Self { + capacity: T::capacity(), + token_count: T::capacity(), + refill_time_per_token: T::refill_duration() / T::capacity() as i32, last_refill: now, } } + fn from_db(token_count: usize, last_refill: DateTimeUtc) -> Self { + Self { + capacity: T::capacity(), + token_count, + refill_time_per_token: T::refill_duration() / T::capacity() as i32, + last_refill, + } + } + fn allow(&mut self, now: DateTimeUtc) -> bool { self.refill(now); if self.token_count > 0 { @@ -148,9 +157,12 @@ impl RateBucket { if elapsed >= self.refill_time_per_token { let new_tokens = elapsed.num_milliseconds() / self.refill_time_per_token.num_milliseconds(); - self.token_count = (self.token_count + new_tokens as usize).min(self.capacity); - self.last_refill = now; + + let unused_refill_time = Duration::milliseconds( + elapsed.num_milliseconds() % self.refill_time_per_token.num_milliseconds(), + ); + self.last_refill = now - unused_refill_time; } } } @@ -218,8 +230,19 @@ mod tests { .await .unwrap(); - // After one second, user 1 can make another request before being rate-limited again. - now += Duration::seconds(1); + // After 1.5s, user 1 can make another request before being rate-limited again. + now += Duration::milliseconds(1500); + rate_limiter + .check_internal::(user_1, now) + .await + .unwrap(); + rate_limiter + .check_internal::(user_1, now) + .await + .unwrap_err(); + + // After 500ms, user 1 can make another request before being rate-limited again. + now += Duration::milliseconds(500); rate_limiter .check_internal::(user_1, now) .await @@ -238,6 +261,17 @@ mod tests { .check_internal::(user_1, now) .await .unwrap_err(); + + // After 1s, user 1 can make another request before being rate-limited again. + now += Duration::seconds(1); + rate_limiter + .check_internal::(user_1, now) + .await + .unwrap(); + rate_limiter + .check_internal::(user_1, now) + .await + .unwrap_err(); } struct RateLimitA; diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 7798a1492af17d..d9113898084a26 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -431,11 +431,13 @@ impl Server { .add_request_handler(user_handler(join_hosted_project)) .add_request_handler(user_handler(rejoin_dev_server_projects)) .add_request_handler(user_handler(create_dev_server_project)) + .add_request_handler(user_handler(update_dev_server_project)) .add_request_handler(user_handler(delete_dev_server_project)) .add_request_handler(user_handler(create_dev_server)) .add_request_handler(user_handler(regenerate_dev_server_token)) .add_request_handler(user_handler(rename_dev_server)) .add_request_handler(user_handler(delete_dev_server)) + .add_request_handler(user_handler(list_remote_directory)) .add_request_handler(dev_server_handler(share_dev_server_project)) .add_request_handler(dev_server_handler(shutdown_dev_server)) .add_request_handler(dev_server_handler(reconnect_dev_server)) @@ -545,6 +547,12 @@ impl Server { .add_request_handler(user_handler( forward_mutating_project_request::, )) + .add_request_handler(user_handler( + forward_mutating_project_request::, + )) + .add_request_handler(user_handler( + forward_mutating_project_request::, + )) .add_message_handler(create_buffer_for_peer) .add_request_handler(update_buffer) .add_message_handler(broadcast_project_message_from_host::) @@ -557,6 +565,7 @@ impl Server { .add_request_handler(user_handler(request_contact)) .add_request_handler(user_handler(remove_contact)) .add_request_handler(user_handler(respond_to_contact_request)) + .add_message_handler(subscribe_to_channels) .add_request_handler(user_handler(create_channel)) .add_request_handler(user_handler(delete_channel)) .add_request_handler(user_handler(invite_channel_member)) @@ -588,6 +597,14 @@ impl Server { .add_message_handler(user_message_handler(acknowledge_channel_message)) .add_message_handler(user_message_handler(acknowledge_buffer_version)) .add_request_handler(user_handler(get_supermaven_api_key)) + .add_request_handler(user_handler( + forward_mutating_project_request::, + )) + .add_request_handler(user_handler( + forward_mutating_project_request::, + )) + .add_message_handler(broadcast_project_message_from_host::) + .add_message_handler(update_context) .add_streaming_request_handler({ let app_state = app_state.clone(); move |request, response, session| { @@ -1105,34 +1122,25 @@ impl Server { .await?; } - let (contacts, channels_for_user, channel_invites, dev_server_projects) = - future::try_join4( - self.app_state.db.get_contacts(user.id), - self.app_state.db.get_channels_for_user(user.id), - self.app_state.db.get_channel_invites_for_user(user.id), - self.app_state.db.dev_server_projects_update(user.id), - ) - .await?; + let (contacts, dev_server_projects) = future::try_join( + self.app_state.db.get_contacts(user.id), + self.app_state.db.dev_server_projects_update(user.id), + ) + .await?; { let mut pool = self.connection_pool.lock(); pool.add_connection(connection_id, user.id, user.admin, zed_version); - for membership in &channels_for_user.channel_memberships { - pool.subscribe_to_channel(user.id, membership.channel_id, membership.role) - } self.peer.send( connection_id, build_initial_contacts_update(contacts, &pool), )?; - self.peer.send( - connection_id, - build_update_user_channels(&channels_for_user), - )?; - self.peer.send( - connection_id, - build_channels_update(channels_for_user, channel_invites), - )?; } + + if should_auto_subscribe_to_channels(zed_version) { + subscribe_user_to_channels(user.id, session).await?; + } + send_dev_server_projects_update(user.id, dev_server_projects, session).await; if let Some(incoming_call) = @@ -2307,6 +2315,69 @@ async fn join_hosted_project( join_project_internal(response, session, &mut project, &replica_id) } +async fn list_remote_directory( + request: proto::ListRemoteDirectory, + response: Response, + session: UserSession, +) -> Result<()> { + let dev_server_id = DevServerId(request.dev_server_id as i32); + let dev_server_connection_id = session + .connection_pool() + .await + .dev_server_connection_id_supporting(dev_server_id, ZedVersion::with_list_directory())?; + + session + .db() + .await + .get_dev_server_for_user(dev_server_id, session.user_id()) + .await?; + + response.send( + session + .peer + .forward_request(session.connection_id, dev_server_connection_id, request) + .await?, + )?; + Ok(()) +} + +async fn update_dev_server_project( + request: proto::UpdateDevServerProject, + response: Response, + session: UserSession, +) -> Result<()> { + let dev_server_project_id = DevServerProjectId(request.dev_server_project_id as i32); + + let (dev_server_project, update) = session + .db() + .await + .update_dev_server_project(dev_server_project_id, &request.paths, session.user_id()) + .await?; + + let projects = session + .db() + .await + .get_projects_for_dev_server(dev_server_project.dev_server_id) + .await?; + + let dev_server_connection_id = session + .connection_pool() + .await + .dev_server_connection_id_supporting( + dev_server_project.dev_server_id, + ZedVersion::with_list_directory(), + )?; + + session.peer.send( + dev_server_connection_id, + proto::DevServerInstructions { projects }, + )?; + + send_dev_server_projects_update(session.user_id(), update, &session).await; + + response.send(proto::Ack {}) +} + async fn create_dev_server_project( request: proto::CreateDevServerProject, response: Response, @@ -2585,14 +2656,13 @@ async fn rejoin_dev_server_projects( ) .await? }; - notify_rejoined_projects(&mut rejoined_projects, &session)?; - response.send(proto::RejoinRemoteProjectsResponse { rejoined_projects: rejoined_projects - .into_iter() + .iter() .map(|project| project.to_proto()) .collect(), - }) + })?; + notify_rejoined_projects(&mut rejoined_projects, &session) } async fn reconnect_dev_server( @@ -3059,6 +3129,53 @@ async fn update_buffer( Ok(()) } +async fn update_context(message: proto::UpdateContext, session: Session) -> Result<()> { + let project_id = ProjectId::from_proto(message.project_id); + + let operation = message.operation.as_ref().context("invalid operation")?; + let capability = match operation.variant.as_ref() { + Some(proto::context_operation::Variant::BufferOperation(buffer_op)) => { + if let Some(buffer_op) = buffer_op.operation.as_ref() { + match buffer_op.variant { + None | Some(proto::operation::Variant::UpdateSelections(_)) => { + Capability::ReadOnly + } + _ => Capability::ReadWrite, + } + } else { + Capability::ReadWrite + } + } + Some(_) => Capability::ReadWrite, + None => Capability::ReadOnly, + }; + + let guard = session + .db() + .await + .connections_for_buffer_update( + project_id, + session.principal_id(), + session.connection_id, + capability, + ) + .await?; + + let (host, guests) = &*guard; + + broadcast( + Some(session.connection_id), + guests.iter().chain([host]).copied(), + |connection_id| { + session + .peer + .forward_send(session.connection_id, connection_id, message.clone()) + }, + ); + + Ok(()) +} + /// Notify other participants that a project has been updated. async fn broadcast_project_message_from_host>( request: T, @@ -3399,6 +3516,36 @@ async fn remove_contact( Ok(()) } +fn should_auto_subscribe_to_channels(version: ZedVersion) -> bool { + version.0.minor() < 139 +} + +async fn subscribe_to_channels(_: proto::SubscribeToChannels, session: Session) -> Result<()> { + subscribe_user_to_channels( + session.user_id().ok_or_else(|| anyhow!("must be a user"))?, + &session, + ) + .await?; + Ok(()) +} + +async fn subscribe_user_to_channels(user_id: UserId, session: &Session) -> Result<(), Error> { + let channels_for_user = session.db().await.get_channels_for_user(user_id).await?; + let mut pool = session.connection_pool().await; + for membership in &channels_for_user.channel_memberships { + pool.subscribe_to_channel(user_id, membership.channel_id, membership.role) + } + session.peer.send( + session.connection_id, + build_update_user_channels(&channels_for_user), + )?; + session.peer.send( + session.connection_id, + build_channels_update(channels_for_user), + )?; + Ok(()) +} + /// Creates a new channel. async fn create_channel( request: proto::CreateChannel, @@ -4435,6 +4582,7 @@ async fn complete_with_open_ai( tool_calls: choice .delta .tool_calls + .unwrap_or_default() .into_iter() .map(|delta| proto::ToolCallDelta { index: delta.index as u32, @@ -4475,6 +4623,7 @@ async fn complete_with_google_ai( session.http_client.clone(), google_ai::API_URL, api_key.as_ref(), + &request.model.clone(), crate::ai::language_model_request_to_google_ai(request)?, ) .await @@ -5034,7 +5183,7 @@ fn notify_membership_updated( ..Default::default() }; - let mut update = build_channels_update(result.new_channels, vec![]); + let mut update = build_channels_update(result.new_channels); update.delete_channels = result .removed_channels .into_iter() @@ -5064,10 +5213,7 @@ fn build_update_user_channels(channels: &ChannelsForUser) -> proto::UpdateUserCh } } -fn build_channels_update( - channels: ChannelsForUser, - channel_invites: Vec, -) -> proto::UpdateChannels { +fn build_channels_update(channels: ChannelsForUser) -> proto::UpdateChannels { let mut update = proto::UpdateChannels::default(); for channel in channels.channels { @@ -5086,7 +5232,7 @@ fn build_channels_update( }); } - for channel in channel_invites { + for channel in channels.invited_channels { update.channel_invitations.push(channel.to_proto()); } diff --git a/crates/collab/src/rpc/connection_pool.rs b/crates/collab/src/rpc/connection_pool.rs index 197e82af98f773..6474b95f55e36a 100644 --- a/crates/collab/src/rpc/connection_pool.rs +++ b/crates/collab/src/rpc/connection_pool.rs @@ -38,6 +38,10 @@ impl ZedVersion { pub fn with_save_as() -> ZedVersion { ZedVersion(SemanticVersion::new(0, 134, 0)) } + + pub fn with_list_directory() -> ZedVersion { + ZedVersion(SemanticVersion::new(0, 145, 0)) + } } pub trait VersionedMessage { @@ -73,6 +77,7 @@ impl ConnectionPool { pub fn reset(&mut self) { self.connections.clear(); self.connected_users.clear(); + self.connected_dev_servers.clear(); self.channels.clear(); } @@ -186,6 +191,18 @@ impl ConnectionPool { self.connected_dev_servers.get(&dev_server_id).copied() } + pub fn dev_server_connection_id_supporting( + &self, + dev_server_id: DevServerId, + required: ZedVersion, + ) -> Result { + match self.connected_dev_servers.get(&dev_server_id) { + Some(cid) if self.connections[cid].zed_version >= required => Ok(*cid), + Some(_) => Err(anyhow!(proto::ErrorCode::RemoteUpgradeRequired)), + None => Err(anyhow!(proto::ErrorCode::DevServerOffline)), + } + } + pub fn channel_user_ids( &self, channel_id: ChannelId, diff --git a/crates/collab/src/tests/dev_server_tests.rs b/crates/collab/src/tests/dev_server_tests.rs index c759cbc3dbdece..d7576442de5430 100644 --- a/crates/collab/src/tests/dev_server_tests.rs +++ b/crates/collab/src/tests/dev_server_tests.rs @@ -66,8 +66,9 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC .update(cx, |store, cx| { let projects = store.dev_server_projects(); assert_eq!(projects.len(), 1); - assert_eq!(projects[0].path, "/remote"); + assert_eq!(projects[0].paths, vec!["/remote"]); workspace::join_dev_server_project( + projects[0].id, projects[0].project_id.unwrap(), client.app_state.clone(), None, @@ -205,8 +206,9 @@ async fn create_dev_server_project( .update(cx, |store, cx| { let projects = store.dev_server_projects(); assert_eq!(projects.len(), 1); - assert_eq!(projects[0].path, "/remote"); + assert_eq!(projects[0].paths, vec!["/remote"]); workspace::join_dev_server_project( + projects[0].id, projects[0].project_id.unwrap(), client_app_state, None, @@ -491,6 +493,7 @@ async fn test_dev_server_reconnect( .update(cx2, |store, cx| { let projects = store.dev_server_projects(); workspace::join_dev_server_project( + projects[0].id, projects[0].project_id.unwrap(), client2.app_state.clone(), None, @@ -501,6 +504,29 @@ async fn test_dev_server_reconnect( .unwrap(); } +#[gpui::test] +async fn test_dev_server_restart(cx1: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppContext) { + let (server, client1) = TestServer::start1(cx1).await; + + let (_dev_server, remote_workspace) = + create_dev_server_project(&server, client1.app_state.clone(), cx1, cx2).await; + let cx = VisualTestContext::from_window(remote_workspace.into(), cx1).as_mut(); + + server.reset().await; + cx.run_until_parked(); + + cx.simulate_keystrokes("cmd-p 1 enter"); + remote_workspace + .update(cx, |ws, cx| { + ws.active_item_as::(cx) + .unwrap() + .update(cx, |ed, cx| { + assert_eq!(ed.text(cx).to_string(), "remote\nremote\nremote"); + }) + }) + .unwrap(); +} + #[gpui::test] async fn test_create_dev_server_project_path_validation( cx1: &mut gpui::TestAppContext, @@ -572,7 +598,8 @@ async fn test_save_as_remote(cx1: &mut gpui::TestAppContext, cx2: &mut gpui::Tes let title = remote_workspace .update(&mut cx, |ws, cx| { - ws.active_item(cx).unwrap().tab_description(0, &cx).unwrap() + let active_item = ws.active_item(cx).unwrap(); + active_item.tab_description(0, &cx).unwrap() }) .unwrap(); diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index 88146bae168a69..74cb699e088f35 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -28,8 +28,9 @@ use language::{ use multi_buffer::MultiBufferRow; use project::{ project_settings::{InlineBlameSettings, ProjectSettings}, - SERVER_PROGRESS_DEBOUNCE_TIMEOUT, + SERVER_PROGRESS_THROTTLE_TIMEOUT, }; +use recent_projects::disconnected_overlay::DisconnectedOverlay; use rpc::RECEIVE_TIMEOUT; use serde_json::json; use settings::SettingsStore; @@ -42,7 +43,7 @@ use std::{ }, }; use text::Point; -use workspace::{Workspace, WorkspaceId}; +use workspace::Workspace; #[gpui::test(iterations = 10)] async fn test_host_disconnect( @@ -59,6 +60,7 @@ async fn test_host_disconnect( .await; cx_b.update(editor::init); + cx_b.update(recent_projects::init); client_a .fs() @@ -83,16 +85,10 @@ async fn test_host_disconnect( let project_b = client_b.build_dev_server_project(project_id, cx_b).await; cx_a.background_executor.run_until_parked(); - assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); + assert!(worktree_a.read_with(cx_a, |tree, _| tree.has_update_observer())); - let workspace_b = cx_b.add_window(|cx| { - Workspace::new( - WorkspaceId::default(), - project_b.clone(), - client_b.app_state.clone(), - cx, - ) - }); + let workspace_b = cx_b + .add_window(|cx| Workspace::new(None, project_b.clone(), client_b.app_state.clone(), cx)); let cx_b = &mut VisualTestContext::from_window(*workspace_b, cx_b); let workspace_b_view = workspace_b.root_view(cx_b).unwrap(); @@ -126,14 +122,13 @@ async fn test_host_disconnect( project_b.read_with(cx_b, |project, _| project.is_read_only()); - assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared())); + assert!(worktree_a.read_with(cx_a, |tree, _| !tree.has_update_observer())); // Ensure client B's edited state is reset and that the whole window is blurred. - workspace_b .update(cx_b, |workspace, cx| { - assert_eq!(cx.focused(), None); - assert!(!workspace.is_edited()) + assert!(workspace.active_modal::(cx).is_some()); + assert!(!workspace.is_edited()); }) .unwrap(); @@ -1011,6 +1006,8 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes let fake_language_server = fake_language_servers.next().await.unwrap(); fake_language_server.start_progress("the-token").await; + + executor.advance_clock(SERVER_PROGRESS_THROTTLE_TIMEOUT); fake_language_server.notify::(lsp::ProgressParams { token: lsp::NumberOrString::String("the-token".to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Report( @@ -1020,11 +1017,10 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes }, )), }); - executor.advance_clock(SERVER_PROGRESS_DEBOUNCE_TIMEOUT); executor.run_until_parked(); project_a.read_with(cx_a, |project, _| { - let status = project.language_server_statuses().next().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert_eq!(status.name, "the-language-server"); assert_eq!(status.pending_work.len(), 1); assert_eq!( @@ -1041,10 +1037,11 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes let project_b = client_b.build_dev_server_project(project_id, cx_b).await; project_b.read_with(cx_b, |project, _| { - let status = project.language_server_statuses().next().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert_eq!(status.name, "the-language-server"); }); + executor.advance_clock(SERVER_PROGRESS_THROTTLE_TIMEOUT); fake_language_server.notify::(lsp::ProgressParams { token: lsp::NumberOrString::String("the-token".to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Report( @@ -1054,11 +1051,10 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes }, )), }); - executor.advance_clock(SERVER_PROGRESS_DEBOUNCE_TIMEOUT); executor.run_until_parked(); project_a.read_with(cx_a, |project, _| { - let status = project.language_server_statuses().next().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert_eq!(status.name, "the-language-server"); assert_eq!(status.pending_work.len(), 1); assert_eq!( @@ -1068,7 +1064,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes }); project_b.read_with(cx_b, |project, _| { - let status = project.language_server_statuses().next().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert_eq!(status.name, "the-language-server"); assert_eq!(status.pending_work.len(), 1); assert_eq!( @@ -1208,7 +1204,7 @@ async fn test_share_project( buffer_a.read_with(cx_a, |buffer, _| { buffer .snapshot() - .remote_selections_in_range(text::Anchor::MIN..text::Anchor::MAX) + .selections_in_range(text::Anchor::MIN..text::Anchor::MAX, false) .count() == 1 }); @@ -1249,7 +1245,7 @@ async fn test_share_project( buffer_a.read_with(cx_a, |buffer, _| { buffer .snapshot() - .remote_selections_in_range(text::Anchor::MIN..text::Anchor::MAX) + .selections_in_range(text::Anchor::MIN..text::Anchor::MAX, false) .count() == 0 }); diff --git a/crates/collab/src/tests/following_tests.rs b/crates/collab/src/tests/following_tests.rs index e6904edfb12712..8c27fb401a9bd1 100644 --- a/crates/collab/src/tests/following_tests.rs +++ b/crates/collab/src/tests/following_tests.rs @@ -135,7 +135,7 @@ async fn test_basic_following( assert_eq!(editor.selections.ranges(cx), vec![2..1]); }); - // When client B starts following client A, all visible view states are replicated to client B. + // When client B starts following client A, only the active view state is replicated to client B. workspace_b.update(cx_b, |workspace, cx| workspace.follow(peer_id_a, cx)); cx_c.executor().run_until_parked(); @@ -156,7 +156,7 @@ async fn test_basic_following( ); assert_eq!( editor_b1.update(cx_b, |editor, cx| editor.selections.ranges(cx)), - vec![3..2] + vec![3..3] ); executor.run_until_parked(); @@ -194,7 +194,7 @@ async fn test_basic_following( // Client C unfollows client A. workspace_c.update(cx_c, |workspace, cx| { - workspace.unfollow(&workspace.active_pane().clone(), cx); + workspace.unfollow(peer_id_a, cx).unwrap(); }); // All clients see that clients B is following client A. @@ -266,7 +266,7 @@ async fn test_basic_following( // When client A activates a different editor, client B does so as well. workspace_a.update(cx_a, |workspace, cx| { - workspace.activate_item(&editor_a1, cx) + workspace.activate_item(&editor_a1, true, true, cx) }); executor.run_until_parked(); workspace_b.update(cx_b, |workspace, cx| { @@ -308,9 +308,10 @@ async fn test_basic_following( result }); let multibuffer_editor_a = workspace_a.update(cx_a, |workspace, cx| { - let editor = - cx.new_view(|cx| Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), cx)); - workspace.add_item_to_active_pane(Box::new(editor.clone()), None, cx); + let editor = cx.new_view(|cx| { + Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), true, cx) + }); + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, cx); editor }); executor.run_until_parked(); @@ -397,10 +398,10 @@ async fn test_basic_following( // After unfollowing, client B stops receiving updates from client A. workspace_b.update(cx_b, |workspace, cx| { - workspace.unfollow(&workspace.active_pane().clone(), cx) + workspace.unfollow(peer_id_a, cx).unwrap() }); workspace_a.update(cx_a, |workspace, cx| { - workspace.activate_item(&editor_a2, cx) + workspace.activate_item(&editor_a2, true, true, cx) }); executor.run_until_parked(); assert_eq!( @@ -465,7 +466,7 @@ async fn test_basic_following( // Client B activates a multibuffer that was created by following client A. Client A returns to that multibuffer. workspace_b.update(cx_b, |workspace, cx| { - workspace.activate_item(&multibuffer_editor_b, cx) + workspace.activate_item(&multibuffer_editor_b, true, true, cx) }); executor.run_until_parked(); workspace_a.update(cx_a, |workspace, cx| { diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 003c0c6ae3ae5d..f98cb5de0e1adc 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -6,6 +6,7 @@ use crate::{ }, }; use anyhow::{anyhow, Result}; +use assistant::ContextStore; use call::{room, ActiveCall, ParticipantLocation, Room}; use client::{User, RECEIVE_TIMEOUT}; use collections::{HashMap, HashSet}; @@ -1378,7 +1379,7 @@ async fn test_unshare_project( let project_b = client_b.build_dev_server_project(project_id, cx_b).await; executor.run_until_parked(); - assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); + assert!(worktree_a.read_with(cx_a, |tree, _| tree.has_update_observer())); project_b .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) @@ -1403,7 +1404,7 @@ async fn test_unshare_project( .unwrap(); executor.run_until_parked(); - assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared())); + assert!(worktree_a.read_with(cx_a, |tree, _| !tree.has_update_observer())); assert!(project_c.read_with(cx_c, |project, _| project.is_disconnected())); @@ -1415,7 +1416,7 @@ async fn test_unshare_project( let project_c2 = client_c.build_dev_server_project(project_id, cx_c).await; executor.run_until_parked(); - assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); + assert!(worktree_a.read_with(cx_a, |tree, _| tree.has_update_observer())); project_c2 .update(cx_c, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) .await @@ -1522,19 +1523,19 @@ async fn test_project_reconnect( executor.run_until_parked(); let worktree1_id = worktree_a1.read_with(cx_a, |worktree, _| { - assert!(worktree.as_local().unwrap().is_shared()); + assert!(worktree.has_update_observer()); worktree.id() }); let (worktree_a2, _) = project_a1 .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root-1/dir2", true, cx) + p.find_or_create_worktree("/root-1/dir2", true, cx) }) .await .unwrap(); executor.run_until_parked(); let worktree2_id = worktree_a2.read_with(cx_a, |tree, _| { - assert!(tree.as_local().unwrap().is_shared()); + assert!(tree.has_update_observer()); tree.id() }); executor.run_until_parked(); @@ -1567,9 +1568,7 @@ async fn test_project_reconnect( assert_eq!(project.collaborators().len(), 1); }); - worktree_a1.read_with(cx_a, |tree, _| { - assert!(tree.as_local().unwrap().is_shared()) - }); + worktree_a1.read_with(cx_a, |tree, _| assert!(tree.has_update_observer())); // While client A is disconnected, add and remove files from client A's project. client_a @@ -1602,7 +1601,7 @@ async fn test_project_reconnect( }); let (worktree_a3, _) = project_a1 .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root-1/dir3", true, cx) + p.find_or_create_worktree("/root-1/dir3", true, cx) }) .await .unwrap(); @@ -1611,7 +1610,7 @@ async fn test_project_reconnect( .await; let worktree3_id = worktree_a3.read_with(cx_a, |tree, _| { - assert!(!tree.as_local().unwrap().is_shared()); + assert!(!tree.has_update_observer()); tree.id() }); executor.run_until_parked(); @@ -1634,7 +1633,7 @@ async fn test_project_reconnect( project_a1.read_with(cx_a, |project, cx| { assert!(project.is_shared()); - assert!(worktree_a1.read(cx).as_local().unwrap().is_shared()); + assert!(worktree_a1.read(cx).has_update_observer()); assert_eq!( worktree_a1 .read(cx) @@ -1652,7 +1651,7 @@ async fn test_project_reconnect( "subdir2/i.txt" ] ); - assert!(worktree_a3.read(cx).as_local().unwrap().is_shared()); + assert!(worktree_a3.read(cx).has_update_observer()); assert_eq!( worktree_a3 .read(cx) @@ -1726,14 +1725,14 @@ async fn test_project_reconnect( // While client B is disconnected, add and remove worktrees from client A's project. let (worktree_a4, _) = project_a1 .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root-1/dir4", true, cx) + p.find_or_create_worktree("/root-1/dir4", true, cx) }) .await .unwrap(); executor.run_until_parked(); let worktree4_id = worktree_a4.read_with(cx_a, |tree, _| { - assert!(tree.as_local().unwrap().is_shared()); + assert!(tree.has_update_observer()); tree.id() }); project_a1.update(cx_a, |project, cx| { @@ -3022,7 +3021,6 @@ async fn test_fs_operations( let project_b = client_b.build_dev_server_project(project_id, cx_b).await; let worktree_a = project_a.read_with(cx_a, |project, _| project.worktrees().next().unwrap()); - let worktree_b = project_b.read_with(cx_b, |project, _| project.worktrees().next().unwrap()); let entry = project_b @@ -3031,6 +3029,7 @@ async fn test_fs_operations( }) .await .unwrap() + .to_included() .unwrap(); worktree_a.read_with(cx_a, |worktree, _| { @@ -3059,6 +3058,7 @@ async fn test_fs_operations( }) .await .unwrap() + .to_included() .unwrap(); worktree_a.read_with(cx_a, |worktree, _| { @@ -3087,6 +3087,7 @@ async fn test_fs_operations( }) .await .unwrap() + .to_included() .unwrap(); worktree_a.read_with(cx_a, |worktree, _| { @@ -3115,20 +3116,25 @@ async fn test_fs_operations( }) .await .unwrap() + .to_included() .unwrap(); + project_b .update(cx_b, |project, cx| { project.create_entry((worktree_id, "DIR/SUBDIR"), true, cx) }) .await .unwrap() + .to_included() .unwrap(); + project_b .update(cx_b, |project, cx| { project.create_entry((worktree_id, "DIR/SUBDIR/f.txt"), false, cx) }) .await .unwrap() + .to_included() .unwrap(); worktree_a.read_with(cx_a, |worktree, _| { @@ -3321,7 +3327,7 @@ async fn test_local_settings( let store = cx.global::(); assert_eq!( store - .local_settings(worktree_b.read(cx).id().to_usize()) + .local_settings(worktree_b.entity_id().as_u64() as _) .collect::>(), &[ (Path::new("").into(), r#"{"tab_size":2}"#.to_string()), @@ -3340,7 +3346,7 @@ async fn test_local_settings( let store = cx.global::(); assert_eq!( store - .local_settings(worktree_b.read(cx).id().to_usize()) + .local_settings(worktree_b.entity_id().as_u64() as _) .collect::>(), &[ (Path::new("").into(), r#"{}"#.to_string()), @@ -3369,7 +3375,7 @@ async fn test_local_settings( let store = cx.global::(); assert_eq!( store - .local_settings(worktree_b.read(cx).id().to_usize()) + .local_settings(worktree_b.entity_id().as_u64() as _) .collect::>(), &[ (Path::new("a").into(), r#"{"tab_size":8}"#.to_string()), @@ -3401,7 +3407,7 @@ async fn test_local_settings( let store = cx.global::(); assert_eq!( store - .local_settings(worktree_b.read(cx).id().to_usize()) + .local_settings(worktree_b.entity_id().as_u64() as _) .collect::>(), &[(Path::new("a").into(), r#"{"hard_tabs":true}"#.to_string()),] ) @@ -4767,7 +4773,7 @@ async fn test_references( // User is informed that a request is pending. executor.run_until_parked(); project_b.read_with(cx_b, |project, _| { - let status = project.language_server_statuses().next().cloned().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert_eq!(status.name, "my-fake-lsp-adapter"); assert_eq!( status.pending_work.values().next().unwrap().message, @@ -4797,7 +4803,7 @@ async fn test_references( executor.run_until_parked(); project_b.read_with(cx_b, |project, cx| { // User is informed that a request is no longer pending. - let status = project.language_server_statuses().next().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert!(status.pending_work.is_empty()); assert_eq!(references.len(), 3); @@ -4825,7 +4831,7 @@ async fn test_references( // User is informed that a request is pending. executor.run_until_parked(); project_b.read_with(cx_b, |project, _| { - let status = project.language_server_statuses().next().cloned().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert_eq!(status.name, "my-fake-lsp-adapter"); assert_eq!( status.pending_work.values().next().unwrap().message, @@ -4842,7 +4848,7 @@ async fn test_references( // User is informed that the request is no longer pending. executor.run_until_parked(); project_b.read_with(cx_b, |project, _| { - let status = project.language_server_statuses().next().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert!(status.pending_work.is_empty()); }); } @@ -4881,7 +4887,7 @@ async fn test_project_search( let (project_a, _) = client_a.build_local_project("/root/dir-1", cx_a).await; let (worktree_2, _) = project_a .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root/dir-2", true, cx) + p.find_or_create_worktree("/root/dir-2", true, cx) }) .await .unwrap(); @@ -4899,7 +4905,15 @@ async fn test_project_search( let mut results = HashMap::default(); let mut search_rx = project_b.update(cx_b, |project, cx| { project.search( - SearchQuery::text("world", false, false, false, Vec::new(), Vec::new()).unwrap(), + SearchQuery::text( + "world", + false, + false, + false, + Default::default(), + Default::default(), + ) + .unwrap(), cx, ) }); @@ -6436,3 +6450,123 @@ async fn test_preview_tabs(cx: &mut TestAppContext) { assert!(!pane.can_navigate_forward()); }); } + +#[gpui::test(iterations = 10)] +async fn test_context_collaboration_with_reconnect( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + client_a.fs().insert_tree("/a", Default::default()).await; + let (project_a, _) = client_a.build_local_project("/a", cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_dev_server_project(project_id, cx_b).await; + + // Client A sees that a guest has joined. + executor.run_until_parked(); + + project_a.read_with(cx_a, |project, _| { + assert_eq!(project.collaborators().len(), 1); + }); + project_b.read_with(cx_b, |project, _| { + assert_eq!(project.collaborators().len(), 1); + }); + + let context_store_a = cx_a + .update(|cx| ContextStore::new(project_a.clone(), cx)) + .await + .unwrap(); + let context_store_b = cx_b + .update(|cx| ContextStore::new(project_b.clone(), cx)) + .await + .unwrap(); + + // Client A creates a new context. + let context_a = context_store_a.update(cx_a, |store, cx| store.create(cx)); + executor.run_until_parked(); + + // Client B retrieves host's contexts and joins one. + let context_b = context_store_b + .update(cx_b, |store, cx| { + let host_contexts = store.host_contexts().to_vec(); + assert_eq!(host_contexts.len(), 1); + store.open_remote_context(host_contexts[0].id.clone(), cx) + }) + .await + .unwrap(); + + // Host and guest make changes + context_a.update(cx_a, |context, cx| { + context.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "Host change\n")], None, cx) + }) + }); + context_b.update(cx_b, |context, cx| { + context.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "Guest change\n")], None, cx) + }) + }); + executor.run_until_parked(); + assert_eq!( + context_a.read_with(cx_a, |context, cx| context.buffer().read(cx).text()), + "Guest change\nHost change\n" + ); + assert_eq!( + context_b.read_with(cx_b, |context, cx| context.buffer().read(cx).text()), + "Guest change\nHost change\n" + ); + + // Disconnect client A and make some changes while disconnected. + server.disconnect_client(client_a.peer_id().unwrap()); + server.forbid_connections(); + context_a.update(cx_a, |context, cx| { + context.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "Host offline change\n")], None, cx) + }) + }); + context_b.update(cx_b, |context, cx| { + context.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "Guest offline change\n")], None, cx) + }) + }); + executor.run_until_parked(); + assert_eq!( + context_a.read_with(cx_a, |context, cx| context.buffer().read(cx).text()), + "Host offline change\nGuest change\nHost change\n" + ); + assert_eq!( + context_b.read_with(cx_b, |context, cx| context.buffer().read(cx).text()), + "Guest offline change\nGuest change\nHost change\n" + ); + + // Allow client A to reconnect and verify that contexts converge. + server.allow_connections(); + executor.advance_clock(RECEIVE_TIMEOUT); + assert_eq!( + context_a.read_with(cx_a, |context, cx| context.buffer().read(cx).text()), + "Guest offline change\nHost offline change\nGuest change\nHost change\n" + ); + assert_eq!( + context_b.read_with(cx_b, |context, cx| context.buffer().read(cx).text()), + "Guest offline change\nHost offline change\nGuest change\nHost change\n" + ); + + // Client A disconnects without being able to reconnect. Context B becomes readonly. + server.forbid_connections(); + server.disconnect_client(client_a.peer_id().unwrap()); + executor.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT); + context_b.read_with(cx_b, |context, cx| { + assert!(context.buffer().read(cx).read_only()); + }); +} diff --git a/crates/collab/src/tests/random_project_collaboration_tests.rs b/crates/collab/src/tests/random_project_collaboration_tests.rs index 03f3f92d51d346..70721dea69d5df 100644 --- a/crates/collab/src/tests/random_project_collaboration_tests.rs +++ b/crates/collab/src/tests/random_project_collaboration_tests.rs @@ -14,7 +14,9 @@ use language::{ }; use lsp::FakeLanguageServer; use pretty_assertions::assert_eq; -use project::{search::SearchQuery, Project, ProjectPath, SearchResult}; +use project::{ + search::SearchQuery, Project, ProjectPath, SearchResult, DEFAULT_COMPLETION_CONTEXT, +}; use rand::{ distributions::{Alphanumeric, DistString}, prelude::*, @@ -303,7 +305,7 @@ impl RandomizedTest for ProjectCollaborationTest { .filter(|worktree| { let worktree = worktree.read(cx); worktree.is_visible() - && worktree.entries(false).any(|e| e.is_file()) + && worktree.entries(false, 0).any(|e| e.is_file()) && worktree.root_entry().map_or(false, |e| e.is_dir()) }) .choose(rng) @@ -425,14 +427,14 @@ impl RandomizedTest for ProjectCollaborationTest { .filter(|worktree| { let worktree = worktree.read(cx); worktree.is_visible() - && worktree.entries(false).any(|e| e.is_file()) + && worktree.entries(false, 0).any(|e| e.is_file()) }) .choose(rng) }); let Some(worktree) = worktree else { continue }; let full_path = worktree.read_with(cx, |worktree, _| { let entry = worktree - .entries(false) + .entries(false, 0) .filter(|e| e.is_file()) .choose(rng) .unwrap(); @@ -579,7 +581,7 @@ impl RandomizedTest for ProjectCollaborationTest { } project .update(cx, |project, cx| { - project.find_or_create_local_worktree(&new_root_path, true, cx) + project.find_or_create_worktree(&new_root_path, true, cx) }) .await .unwrap(); @@ -829,7 +831,7 @@ impl RandomizedTest for ProjectCollaborationTest { .map_ok(|_| ()) .boxed(), LspRequestKind::Completion => project - .completions(&buffer, offset, cx) + .completions(&buffer, offset, DEFAULT_COMPLETION_CONTEXT, cx) .map_ok(|_| ()) .boxed(), LspRequestKind::CodeAction => project @@ -873,8 +875,15 @@ impl RandomizedTest for ProjectCollaborationTest { let mut search = project.update(cx, |project, cx| { project.search( - SearchQuery::text(query, false, false, false, Vec::new(), Vec::new()) - .unwrap(), + SearchQuery::text( + query, + false, + false, + false, + Default::default(), + Default::default(), + ) + .unwrap(), cx, ) }); @@ -1204,8 +1213,8 @@ impl RandomizedTest for ProjectCollaborationTest { guest_project.remote_id(), ); assert_eq!( - guest_snapshot.entries(false).collect::>(), - host_snapshot.entries(false).collect::>(), + guest_snapshot.entries(false, 0).collect::>(), + host_snapshot.entries(false, 0).collect::>(), "{} has different snapshot than the host for worktree {:?} ({:?}) and project {:?}", client.username, host_snapshot.abs_path(), @@ -1228,7 +1237,7 @@ impl RandomizedTest for ProjectCollaborationTest { } } - for buffer in guest_project.opened_buffers() { + for buffer in guest_project.opened_buffers(cx) { let buffer = buffer.read(cx); assert_eq!( buffer.deferred_ops_len(), @@ -1278,8 +1287,8 @@ impl RandomizedTest for ProjectCollaborationTest { for guest_buffer in guest_buffers { let buffer_id = guest_buffer.read_with(client_cx, |buffer, _| buffer.remote_id()); - let host_buffer = host_project.read_with(host_cx, |project, _| { - project.buffer_for_id(buffer_id).unwrap_or_else(|| { + let host_buffer = host_project.read_with(host_cx, |project, cx| { + project.buffer_for_id(buffer_id, cx).unwrap_or_else(|| { panic!( "host does not have buffer for guest:{}, peer:{:?}, id:{}", client.username, diff --git a/crates/collab/src/tests/randomized_test_helpers.rs b/crates/collab/src/tests/randomized_test_helpers.rs index 80ff9fe5d5bea8..c788dd28e0e526 100644 --- a/crates/collab/src/tests/randomized_test_helpers.rs +++ b/crates/collab/src/tests/randomized_test_helpers.rs @@ -69,7 +69,6 @@ struct TestPlan { pub struct UserTestPlan { pub user_id: UserId, pub username: String, - pub allow_client_reconnection: bool, pub allow_client_disconnection: bool, next_root_id: usize, operation_ix: usize, @@ -237,7 +236,6 @@ impl TestPlan { next_root_id: 0, operation_ix: 0, allow_client_disconnection, - allow_client_reconnection, }); } diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index fc663cbc2d1ffa..3ef511fb04b684 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -42,7 +42,7 @@ use std::{ Arc, }, }; -use workspace::{Workspace, WorkspaceId, WorkspaceStore}; +use workspace::{Workspace, WorkspaceStore}; pub struct TestServer { pub app_state: Arc, @@ -161,7 +161,7 @@ impl TestServer { } let settings = SettingsStore::test(cx); cx.set_global(settings); - release_channel::init("0.0.0", cx); + release_channel::init(SemanticVersion::default(), cx); client::init_settings(cx); }); @@ -277,11 +277,7 @@ impl TestServer { node_runtime: FakeNodeRuntime::new(), }); - let os_keymap = if cfg!(target_os = "linux") { - "keymaps/default-linux.json" - } else { - "keymaps/default-macos.json" - }; + let os_keymap = "keymaps/default-macos.json"; cx.update(|cx| { theme::init(theme::LoadThemes::JustBase, cx); @@ -298,6 +294,8 @@ impl TestServer { menu::init(); dev_server_projects::init(client.clone(), cx); settings::KeymapFile::load_asset(os_keymap, cx).unwrap(); + assistant::FakeCompletionProvider::setup_test(cx); + assistant::context_store::init(&client); }); client @@ -327,7 +325,7 @@ impl TestServer { } let settings = SettingsStore::test(cx); cx.set_global(settings); - release_channel::init("0.0.0", cx); + release_channel::init(SemanticVersion::default(), cx); client::init_settings(cx); }); let (dev_server_id, _) = split_dev_server_token(&access_token).unwrap(); @@ -807,9 +805,7 @@ impl TestClient { ) -> (Model, WorktreeId) { let project = self.build_empty_local_project(cx); let (worktree, _) = project - .update(cx, |p, cx| { - p.find_or_create_local_worktree(root_path, true, cx) - }) + .update(cx, |p, cx| p.find_or_create_worktree(root_path, true, cx)) .await .unwrap(); worktree @@ -906,12 +902,7 @@ impl TestClient { ) -> (View, &'a mut VisualTestContext) { cx.add_window_view(|cx| { cx.activate_window(); - Workspace::new( - WorkspaceId::default(), - project.clone(), - self.app_state.clone(), - cx, - ) + Workspace::new(None, project.clone(), self.app_state.clone(), cx) }) } @@ -922,12 +913,7 @@ impl TestClient { let project = self.build_test_project(cx).await; cx.add_window_view(|cx| { cx.activate_window(); - Workspace::new( - WorkspaceId::default(), - project.clone(), - self.app_state.clone(), - cx, - ) + Workspace::new(None, project.clone(), self.app_state.clone(), cx) }) } diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 01da2ac15b4346..6bfcca832b1f44 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -30,15 +30,14 @@ test-support = [ [dependencies] anyhow.workspace = true -auto_update.workspace = true call.workspace = true channel.workspace = true +chrono.workspace = true client.workspace = true collections.workspace = true db.workspace = true editor.workspace = true emojis.workspace = true -extensions_ui.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true @@ -49,8 +48,6 @@ notifications.workspace = true parking_lot.workspace = true picker.workspace = true project.workspace = true -recent_projects.workspace = true -dev_server_projects.workspace = true release_channel.workspace = true rich_text.workspace = true rpc.workspace = true @@ -62,14 +59,13 @@ settings.workspace = true smallvec.workspace = true story = { workspace = true, optional = true } theme.workspace = true -theme_selector.workspace = true time_format.workspace = true time.workspace = true +title_bar.workspace = true ui.workspace = true util.workspace = true vcs_menu.workspace = true workspace.workspace = true -zed_actions.workspace = true [dev-dependencies] call = { workspace = true, features = ["test-support"] } diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index 59099dd486ea33..c77d4b57826f0d 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -11,21 +11,22 @@ use editor::{ EditorEvent, }; use gpui::{ - actions, AnyElement, AnyView, AppContext, ClipboardItem, Entity as _, EventEmitter, - FocusableView, IntoElement as _, Model, Pixels, Point, Render, Subscription, Task, View, - ViewContext, VisualContext as _, WeakView, WindowContext, + actions, AnyView, AppContext, ClipboardItem, Entity as _, EventEmitter, FocusableView, Model, + Pixels, Point, Render, Subscription, Task, View, ViewContext, VisualContext as _, WeakView, + WindowContext, }; use project::Project; +use rpc::proto::ChannelVisibility; use std::{ any::{Any, TypeId}, sync::Arc, }; -use ui::{prelude::*, Label}; +use ui::prelude::*; use util::ResultExt; -use workspace::notifications::NotificationId; +use workspace::item::TabContentParams; +use workspace::{item::Dedup, notifications::NotificationId}; use workspace::{ - item::{FollowableItem, Item, ItemEvent, ItemHandle, TabContentParams}, - register_followable_item, + item::{FollowableItem, Item, ItemEvent, ItemHandle}, searchable::SearchableItemHandle, ItemNavHistory, Pane, SaveIntent, Toast, ViewId, Workspace, WorkspaceId, }; @@ -33,7 +34,7 @@ use workspace::{ actions!(collab, [CopyLink]); pub fn init(cx: &mut AppContext) { - register_followable_item::(cx) + workspace::FollowableViewRegistry::register::(cx) } pub struct ChannelView { @@ -84,31 +85,12 @@ impl ChannelView { workspace: View, cx: &mut WindowContext, ) -> Task>> { - let weak_workspace = workspace.downgrade(); - let workspace = workspace.read(cx); - let project = workspace.project().to_owned(); - let channel_store = ChannelStore::global(cx); - let language_registry = workspace.app_state().languages.clone(); - let markdown = language_registry.language_for_name("Markdown"); - let channel_buffer = - channel_store.update(cx, |store, cx| store.open_channel_buffer(channel_id, cx)); - + let channel_view = Self::load(channel_id, workspace, cx); cx.spawn(|mut cx| async move { - let channel_buffer = channel_buffer.await?; - let markdown = markdown.await.log_err(); - - channel_buffer.update(&mut cx, |channel_buffer, cx| { - channel_buffer.buffer().update(cx, |buffer, cx| { - buffer.set_language_registry(language_registry); - let Some(markdown) = markdown else { - return; - }; - buffer.set_language(Some(markdown), cx); - }) - })?; + let channel_view = channel_view.await?; pane.update(&mut cx, |pane, cx| { - let buffer_id = channel_buffer.read(cx).remote_id(cx); + let buffer_id = channel_view.read(cx).channel_buffer.read(cx).remote_id(cx); let existing_view = pane .items_of_type::() @@ -116,7 +98,8 @@ impl ChannelView { // If this channel buffer is already open in this pane, just return it. if let Some(existing_view) = existing_view.clone() { - if existing_view.read(cx).channel_buffer == channel_buffer { + if existing_view.read(cx).channel_buffer == channel_view.read(cx).channel_buffer + { if let Some(link_position) = link_position { existing_view.update(cx, |channel_view, cx| { channel_view.focus_position_from_link(link_position, true, cx) @@ -126,30 +109,60 @@ impl ChannelView { } } - let view = cx.new_view(|cx| { - let mut this = - Self::new(project, weak_workspace, channel_store, channel_buffer, cx); - this.acknowledge_buffer_version(cx); - this - }); - // If the pane contained a disconnected view for this channel buffer, // replace that. if let Some(existing_item) = existing_view { if let Some(ix) = pane.index_for_item(&existing_item) { pane.close_item_by_id(existing_item.entity_id(), SaveIntent::Skip, cx) .detach(); - pane.add_item(Box::new(view.clone()), true, true, Some(ix), cx); + pane.add_item(Box::new(channel_view.clone()), true, true, Some(ix), cx); } } if let Some(link_position) = link_position { - view.update(cx, |channel_view, cx| { + channel_view.update(cx, |channel_view, cx| { channel_view.focus_position_from_link(link_position, true, cx) }); } - view + channel_view + }) + }) + } + + pub fn load( + channel_id: ChannelId, + workspace: View, + cx: &mut WindowContext, + ) -> Task>> { + let weak_workspace = workspace.downgrade(); + let workspace = workspace.read(cx); + let project = workspace.project().to_owned(); + let channel_store = ChannelStore::global(cx); + let language_registry = workspace.app_state().languages.clone(); + let markdown = language_registry.language_for_name("Markdown"); + let channel_buffer = + channel_store.update(cx, |store, cx| store.open_channel_buffer(channel_id, cx)); + + cx.spawn(|mut cx| async move { + let channel_buffer = channel_buffer.await?; + let markdown = markdown.await.log_err(); + + channel_buffer.update(&mut cx, |channel_buffer, cx| { + channel_buffer.buffer().update(cx, |buffer, cx| { + buffer.set_language_registry(language_registry); + let Some(markdown) = markdown else { + return; + }; + buffer.set_language(Some(markdown), cx); + }) + })?; + + cx.new_view(|cx| { + let mut this = + Self::new(project, weak_workspace, channel_store, channel_buffer, cx); + this.acknowledge_buffer_version(cx); + this }) }) } @@ -228,11 +241,11 @@ impl ChannelView { &self.editor, move |this, _, e: &EditorEvent, cx| { match e { - EditorEvent::Reparsed => { + EditorEvent::Reparsed(_) => { this.focus_position_from_link(position.clone(), false, cx); this._reparse_subscription.take(); } - EditorEvent::Edited | EditorEvent::SelectionsChanged { local: true } => { + EditorEvent::Edited { .. } | EditorEvent::SelectionsChanged { local: true } => { this._reparse_subscription.take(); } _ => {} @@ -374,24 +387,45 @@ impl Item for ChannelView { } } - fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement { - let label = if let Some(channel) = self.channel(cx) { - match ( + fn tab_icon(&self, cx: &WindowContext) -> Option { + let channel = self.channel(cx)?; + let icon = match channel.visibility { + ChannelVisibility::Public => IconName::Public, + ChannelVisibility::Members => IconName::Hash, + }; + + Some(Icon::new(icon)) + } + + fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> gpui::AnyElement { + let (channel_name, status) = if let Some(channel) = self.channel(cx) { + let status = match ( self.channel_buffer.read(cx).buffer().read(cx).read_only(), self.channel_buffer.read(cx).is_connected(), ) { - (false, true) => format!("#{}", channel.name), - (true, true) => format!("#{} (read-only)", channel.name), - (_, false) => format!("#{} (disconnected)", channel.name), - } + (false, true) => None, + (true, true) => Some("read-only"), + (_, false) => Some("disconnected"), + }; + + (channel.name.clone(), status) } else { - "channel notes (disconnected)".to_string() + ("".into(), Some("disconnected")) }; - Label::new(label) - .color(if params.selected { - Color::Default - } else { - Color::Muted + + h_flex() + .gap_2() + .child( + Label::new(channel_name) + .color(params.text_color()) + .italic(params.preview), + ) + .when_some(status, |element, status| { + element.child( + Label::new(status) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) }) .into_any_element() } @@ -400,7 +434,11 @@ impl Item for ChannelView { None } - fn clone_on_split(&self, _: WorkspaceId, cx: &mut ViewContext) -> Option> { + fn clone_on_split( + &self, + _: Option, + cx: &mut ViewContext, + ) -> Option> { Some(cx.new_view(|cx| { Self::new( self.project.clone(), @@ -474,7 +512,6 @@ impl FollowableItem for ChannelView { } fn from_state_proto( - pane: View, workspace: View, remote_id: workspace::ViewId, state: &mut Option, @@ -487,8 +524,7 @@ impl FollowableItem for ChannelView { unreachable!() }; - let open = - ChannelView::open_in_pane(ChannelId(state.channel_id), None, pane, workspace, cx); + let open = ChannelView::load(ChannelId(state.channel_id), workspace, cx); Some(cx.spawn(|mut cx| async move { let this = open.await?; @@ -559,6 +595,19 @@ impl FollowableItem for ChannelView { fn to_follow_event(event: &Self::Event) -> Option { Editor::to_follow_event(event) } + + fn dedup(&self, existing: &Self, cx: &WindowContext) -> Option { + let existing = existing.channel_buffer.read(cx); + if self.channel_buffer.read(cx).channel_id == existing.channel_id { + if existing.is_connected() { + Some(Dedup::KeepExisting) + } else { + Some(Dedup::ReplaceExisting) + } + } else { + None + } + } } struct ChannelBufferCollaborationHub(Model); diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 2b05b9390da152..67d5047a519321 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -22,7 +22,7 @@ use settings::Settings; use std::{sync::Arc, time::Duration}; use time::{OffsetDateTime, UtcOffset}; use ui::{ - popover_menu, prelude::*, Avatar, Button, ContextMenu, IconButton, IconName, KeyBinding, Label, + prelude::*, Avatar, Button, ContextMenu, IconButton, IconName, KeyBinding, Label, PopoverMenu, TabBar, Tooltip, }; use util::{ResultExt, TryFutureExt}; @@ -111,6 +111,7 @@ impl ChatPanel { this.is_scrolled_to_bottom = !event.is_scrolled; })); + let local_offset = chrono::Local::now().offset().local_minus_utc(); let mut this = Self { fs, client, @@ -120,7 +121,7 @@ impl ChatPanel { active_chat: Default::default(), pending_serialization: Task::ready(None), message_editor: input_editor, - local_timezone: cx.local_timezone(), + local_timezone: UtcOffset::from_whole_seconds(local_offset).unwrap(), subscriptions: Vec::new(), is_scrolled_to_bottom: true, active: false, @@ -355,11 +356,10 @@ impl ChatPanel { .child(Icon::new(IconName::ReplyArrowRight).color(Color::Muted)) .child(Avatar::new(user_being_replied_to.avatar_uri.clone()).size(rems(0.7))) .child( - div().font_weight(FontWeight::SEMIBOLD).child( - Label::new(format!("@{}", user_being_replied_to.github_login)) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), + Label::new(format!("@{}", user_being_replied_to.github_login)) + .size(LabelSize::XSmall) + .weight(FontWeight::SEMIBOLD) + .color(Color::Muted), ) .child( div().overflow_y_hidden().child( @@ -490,22 +490,16 @@ impl ChatPanel { |this| { this.child( h_flex() + .gap_2() .text_ui_sm(cx) .child( - div().absolute().child( - Avatar::new(message.sender.avatar_uri.clone()) - .size(rems(1.)), - ), + Avatar::new(message.sender.avatar_uri.clone()) + .size(rems(1.)), ) .child( - div() - .pl(cx.rem_size() + px(6.0)) - .pr(px(8.0)) - .font_weight(FontWeight::BOLD) - .child( - Label::new(message.sender.github_login.clone()) - .size(LabelSize::Small), - ), + Label::new(message.sender.github_login.clone()) + .size(LabelSize::Small) + .weight(FontWeight::BOLD), ) .child( Label::new(time_format::format_localized_timestamp( @@ -679,7 +673,7 @@ impl ChatPanel { cx, div() .child( - popover_menu(("menu", message_id)) + PopoverMenu::new(("menu", message_id)) .trigger(IconButton::new( ("trigger", message_id), IconName::Ellipsis, @@ -1044,13 +1038,12 @@ impl Render for ChatPanel { .id(("reply-preview", reply_to_message_id)) .child(Label::new("Replying to ").size(LabelSize::Small)) .child( - div().font_weight(FontWeight::BOLD).child( - Label::new(format!( - "@{}", - user_being_replied_to.github_login.clone() - )) - .size(LabelSize::Small), - ), + Label::new(format!( + "@{}", + user_being_replied_to.github_login.clone() + )) + .size(LabelSize::Small) + .weight(FontWeight::BOLD), ) .when_some(channel_id, |this, channel_id| { this.cursor_pointer().on_click(cx.listener( diff --git a/crates/collab_ui/src/chat_panel/message_editor.rs b/crates/collab_ui/src/chat_panel/message_editor.rs index 294f4678430329..91491536760ae5 100644 --- a/crates/collab_ui/src/chat_panel/message_editor.rs +++ b/crates/collab_ui/src/chat_panel/message_editor.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use channel::{ChannelChat, ChannelStore, MessageParams}; use client::{UserId, UserStore}; use collections::HashSet; @@ -25,8 +25,15 @@ use crate::panel_settings::MessageEditorSettings; const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50); lazy_static! { - static ref MENTIONS_SEARCH: SearchQuery = - SearchQuery::regex("@[-_\\w]+", false, false, false, Vec::new(), Vec::new()).unwrap(); + static ref MENTIONS_SEARCH: SearchQuery = SearchQuery::regex( + "@[-_\\w]+", + false, + false, + false, + Default::default(), + Default::default() + ) + .unwrap(); } pub struct MessageEditor { @@ -46,6 +53,7 @@ impl CompletionProvider for MessageEditorCompletionProvider { &self, buffer: &Model, buffer_position: language::Anchor, + _: editor::CompletionContext, cx: &mut ViewContext, ) -> Task>> { let Some(handle) = self.0.upgrade() else { @@ -132,7 +140,7 @@ impl MessageEditor { let markdown = language_registry.language_for_name("Markdown"); cx.spawn(|_, mut cx| async move { - let markdown = markdown.await?; + let markdown = markdown.await.context("failed to load Markdown language")?; buffer.update(&mut cx, |buffer, cx| { buffer.set_language(Some(markdown), cx) }) @@ -305,6 +313,8 @@ impl MessageEditor { documentation: None, server_id: LanguageServerId(0), // TODO: Make this optional or something? lsp_completion: Default::default(), // TODO: Make this optional or something? + confirm: None, + show_new_completions_on_confirm: false, } }) .collect() @@ -524,7 +534,7 @@ impl Render for MessageEditor { font_family: settings.ui_font.family.clone(), font_features: settings.ui_font.features.clone(), font_size: TextSize::Small.rems(cx).into(), - font_weight: FontWeight::NORMAL, + font_weight: settings.ui_font.weight, font_style: FontStyle::Normal, line_height: relative(1.3), background_color: None, diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index a6095039876d1b..e4f03b14f24890 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2,10 +2,7 @@ mod channel_modal; mod contact_finder; use self::channel_modal::ChannelModal; -use crate::{ - channel_view::ChannelView, chat_panel::ChatPanel, face_pile::FacePile, - CollaborationPanelSettings, -}; +use crate::{channel_view::ChannelView, chat_panel::ChatPanel, CollaborationPanelSettings}; use call::ActiveCall; use channel::{Channel, ChannelEvent, ChannelStore}; use client::{ChannelId, Client, Contact, ProjectId, User, UserStore}; @@ -16,10 +13,10 @@ use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ actions, anchored, canvas, deferred, div, fill, list, point, prelude::*, px, AnyElement, AppContext, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, DismissEvent, Div, - EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, InteractiveElement, - IntoElement, ListOffset, ListState, Model, MouseDownEvent, ParentElement, Pixels, Point, - PromptLevel, Render, SharedString, Styled, Subscription, Task, TextStyle, View, ViewContext, - VisualContext, WeakView, WhiteSpace, + EventEmitter, FocusHandle, FocusableView, FontStyle, InteractiveElement, IntoElement, + ListOffset, ListState, Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, + Render, SharedString, Styled, Subscription, Task, TextStyle, View, ViewContext, VisualContext, + WeakView, WhiteSpace, }; use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrev}; use project::{Fs, Project}; @@ -34,7 +31,8 @@ use std::{mem, sync::Arc}; use theme::{ActiveTheme, ThemeSettings}; use ui::{ prelude::*, tooltip_container, Avatar, AvatarAvailabilityIndicator, Button, Color, ContextMenu, - Icon, IconButton, IconName, IconSize, Indicator, Label, ListHeader, ListItem, Tooltip, + Facepile, Icon, IconButton, IconName, IconSize, Indicator, Label, ListHeader, ListItem, + Tooltip, }; use util::{maybe, ResultExt, TryFutureExt}; use workspace::{ @@ -2161,6 +2159,9 @@ impl CollabPanel { } fn render_signed_in(&mut self, cx: &mut ViewContext) -> Div { + self.channel_store.update(cx, |channel_store, _| { + channel_store.initialize(); + }); v_flex() .size_full() .child(list(self.list_state.clone()).size_full()) @@ -2190,7 +2191,7 @@ impl CollabPanel { font_family: settings.ui_font.family.clone(), font_features: settings.ui_font.features.clone(), font_size: rems(0.875).into(), - font_weight: FontWeight::NORMAL, + font_weight: settings.ui_font.weight, font_style: FontStyle::Normal, line_height: relative(1.3), background_color: None, @@ -2539,16 +2540,15 @@ impl CollabPanel { None } else { let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT); - let result = FacePile::new( + let result = Facepile::new( participants .iter() .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element()) .take(FACEPILE_LIMIT) .chain(if extra_count > 0 { Some( - div() + Label::new(format!("+{extra_count}")) .ml_2() - .child(Label::new(format!("+{extra_count}"))) .into_any_element(), ) } else { diff --git a/crates/collab_ui/src/collab_panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs index ff58c833f139e9..27cfb2c01e15fd 100644 --- a/crates/collab_ui/src/collab_panel/contact_finder.rs +++ b/crates/collab_ui/src/collab_panel/contact_finder.rs @@ -5,7 +5,6 @@ use gpui::{ }; use picker::{Picker, PickerDelegate}; use std::sync::Arc; -use theme::ActiveTheme as _; use ui::{prelude::*, Avatar, ListItem, ListItemSpacing}; use util::{ResultExt as _, TryFutureExt}; use workspace::ModalView; diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs deleted file mode 100644 index 2e7bc13c11264d..00000000000000 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ /dev/null @@ -1,780 +0,0 @@ -use crate::face_pile::FacePile; -use auto_update::AutoUpdateStatus; -use call::{ActiveCall, ParticipantLocation, Room}; -use client::{proto::PeerId, Client, User, UserStore}; -use gpui::{ - actions, canvas, div, point, px, Action, AnyElement, AppContext, Element, Hsla, - InteractiveElement, IntoElement, Model, ParentElement, Path, Render, - StatefulInteractiveElement, Styled, Subscription, View, ViewContext, VisualContext, WeakView, -}; -use project::{Project, RepositoryEntry}; -use recent_projects::RecentProjects; -use rpc::proto::{self, DevServerStatus}; -use std::sync::Arc; -use theme::ActiveTheme; -use ui::{ - h_flex, popover_menu, prelude::*, Avatar, AvatarAudioStatusIndicator, Button, ButtonLike, - ButtonStyle, ContextMenu, Icon, IconButton, IconName, Indicator, TintColor, TitleBar, Tooltip, -}; -use util::ResultExt; -use vcs_menu::{build_branch_list, BranchList, OpenRecent as ToggleVcsMenu}; -use workspace::{notifications::NotifyResultExt, Workspace}; - -const MAX_PROJECT_NAME_LENGTH: usize = 40; -const MAX_BRANCH_NAME_LENGTH: usize = 40; - -actions!( - collab, - [ - ShareProject, - UnshareProject, - ToggleUserMenu, - ToggleProjectMenu, - SwitchBranch - ] -); - -pub fn init(cx: &mut AppContext) { - cx.observe_new_views(|workspace: &mut Workspace, cx| { - let titlebar_item = cx.new_view(|cx| CollabTitlebarItem::new(workspace, cx)); - workspace.set_titlebar_item(titlebar_item.into(), cx) - }) - .detach(); -} - -pub struct CollabTitlebarItem { - project: Model, - user_store: Model, - client: Arc, - workspace: WeakView, - _subscriptions: Vec, -} - -impl Render for CollabTitlebarItem { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let room = ActiveCall::global(cx).read(cx).room().cloned(); - let current_user = self.user_store.read(cx).current_user(); - let client = self.client.clone(); - let project_id = self.project.read(cx).remote_id(); - let workspace = self.workspace.upgrade(); - - TitleBar::new("collab-titlebar", Box::new(workspace::CloseWindow)) - // note: on windows titlebar behaviour is handled by the platform implementation - .when(cfg!(not(windows)), |this| { - this.on_click(|event, cx| { - if event.up.click_count == 2 { - cx.zoom_window(); - } - }) - }) - // left side - .child( - h_flex() - .gap_1() - .children(self.render_project_host(cx)) - .child(self.render_project_name(cx)) - .children(self.render_project_branch(cx)) - .on_mouse_move(|_, cx| cx.stop_propagation()), - ) - .child( - h_flex() - .id("collaborator-list") - .w_full() - .gap_1() - .overflow_x_scroll() - .when_some( - current_user.clone().zip(client.peer_id()).zip(room.clone()), - |this, ((current_user, peer_id), room)| { - let player_colors = cx.theme().players(); - let room = room.read(cx); - let mut remote_participants = - room.remote_participants().values().collect::>(); - remote_participants.sort_by_key(|p| p.participant_index.0); - - let current_user_face_pile = self.render_collaborator( - ¤t_user, - peer_id, - true, - room.is_speaking(), - room.is_muted(), - None, - &room, - project_id, - ¤t_user, - cx, - ); - - this.children(current_user_face_pile.map(|face_pile| { - v_flex() - .on_mouse_move(|_, cx| cx.stop_propagation()) - .child(face_pile) - .child(render_color_ribbon(player_colors.local().cursor)) - })) - .children( - remote_participants.iter().filter_map(|collaborator| { - let player_color = player_colors - .color_for_participant(collaborator.participant_index.0); - let is_following = workspace - .as_ref()? - .read(cx) - .is_being_followed(collaborator.peer_id); - let is_present = project_id.map_or(false, |project_id| { - collaborator.location - == ParticipantLocation::SharedProject { project_id } - }); - - let face_pile = self.render_collaborator( - &collaborator.user, - collaborator.peer_id, - is_present, - collaborator.speaking, - collaborator.muted, - is_following.then_some(player_color.selection), - &room, - project_id, - ¤t_user, - cx, - )?; - - Some( - v_flex() - .id(("collaborator", collaborator.user.id)) - .child(face_pile) - .child(render_color_ribbon(player_color.cursor)) - .cursor_pointer() - .on_click({ - let peer_id = collaborator.peer_id; - cx.listener(move |this, _, cx| { - this.workspace - .update(cx, |workspace, cx| { - workspace.follow(peer_id, cx); - }) - .ok(); - }) - }) - .tooltip({ - let login = collaborator.user.github_login.clone(); - move |cx| { - Tooltip::text(format!("Follow {login}"), cx) - } - }), - ) - }), - ) - }, - ), - ) - // right side - .child( - h_flex() - .gap_1() - .pr_1() - .on_mouse_move(|_, cx| cx.stop_propagation()) - .when_some(room, |this, room| { - let room = room.read(cx); - let project = self.project.read(cx); - let is_local = project.is_local(); - let is_dev_server_project = project.dev_server_project_id().is_some(); - let is_shared = (is_local || is_dev_server_project) && project.is_shared(); - let is_muted = room.is_muted(); - let is_deafened = room.is_deafened().unwrap_or(false); - let is_screen_sharing = room.is_screen_sharing(); - let can_use_microphone = room.can_use_microphone(); - let can_share_projects = room.can_share_projects(); - - this.when( - (is_local || is_dev_server_project) && can_share_projects, - |this| { - this.child( - Button::new( - "toggle_sharing", - if is_shared { "Unshare" } else { "Share" }, - ) - .tooltip(move |cx| { - Tooltip::text( - if is_shared { - "Stop sharing project with call participants" - } else { - "Share project with call participants" - }, - cx, - ) - }) - .style(ButtonStyle::Subtle) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .selected(is_shared) - .label_size(LabelSize::Small) - .on_click(cx.listener( - move |this, _, cx| { - if is_shared { - this.unshare_project(&Default::default(), cx); - } else { - this.share_project(&Default::default(), cx); - } - }, - )), - ) - }, - ) - .child( - div() - .child( - IconButton::new("leave-call", ui::IconName::Exit) - .style(ButtonStyle::Subtle) - .tooltip(|cx| Tooltip::text("Leave call", cx)) - .icon_size(IconSize::Small) - .on_click(move |_, cx| { - ActiveCall::global(cx) - .update(cx, |call, cx| call.hang_up(cx)) - .detach_and_log_err(cx); - }), - ) - .pr_2(), - ) - .when(can_use_microphone, |this| { - this.child( - IconButton::new( - "mute-microphone", - if is_muted { - ui::IconName::MicMute - } else { - ui::IconName::Mic - }, - ) - .tooltip(move |cx| { - Tooltip::text( - if is_muted { - "Unmute microphone" - } else { - "Mute microphone" - }, - cx, - ) - }) - .style(ButtonStyle::Subtle) - .icon_size(IconSize::Small) - .selected(is_muted) - .selected_style(ButtonStyle::Tinted(TintColor::Negative)) - .on_click(move |_, cx| crate::toggle_mute(&Default::default(), cx)), - ) - }) - .child( - IconButton::new( - "mute-sound", - if is_deafened { - ui::IconName::AudioOff - } else { - ui::IconName::AudioOn - }, - ) - .style(ButtonStyle::Subtle) - .selected_style(ButtonStyle::Tinted(TintColor::Negative)) - .icon_size(IconSize::Small) - .selected(is_deafened) - .tooltip(move |cx| { - if can_use_microphone { - Tooltip::with_meta( - "Deafen Audio", - None, - "Mic will be muted", - cx, - ) - } else { - Tooltip::text("Deafen Audio", cx) - } - }) - .on_click(move |_, cx| crate::toggle_deafen(&Default::default(), cx)), - ) - .when(can_share_projects, |this| { - this.child( - IconButton::new("screen-share", ui::IconName::Screen) - .style(ButtonStyle::Subtle) - .icon_size(IconSize::Small) - .selected(is_screen_sharing) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .tooltip(move |cx| { - Tooltip::text( - if is_screen_sharing { - "Stop Sharing Screen" - } else { - "Share Screen" - }, - cx, - ) - }) - .on_click(move |_, cx| { - crate::toggle_screen_sharing(&Default::default(), cx) - }), - ) - }) - .child(div().pr_2()) - }) - .map(|el| { - let status = self.client.status(); - let status = &*status.borrow(); - if matches!(status, client::Status::Connected { .. }) { - el.child(self.render_user_menu_button(cx)) - } else { - el.children(self.render_connection_status(status, cx)) - .child(self.render_sign_in_button(cx)) - .child(self.render_user_menu_button(cx)) - } - }), - ) - } -} - -fn render_color_ribbon(color: Hsla) -> impl Element { - canvas( - move |_, _| {}, - move |bounds, _, cx| { - let height = bounds.size.height; - let horizontal_offset = height; - let vertical_offset = px(height.0 / 2.0); - let mut path = Path::new(bounds.lower_left()); - path.curve_to( - bounds.origin + point(horizontal_offset, vertical_offset), - bounds.origin + point(px(0.0), vertical_offset), - ); - path.line_to(bounds.upper_right() + point(-horizontal_offset, vertical_offset)); - path.curve_to( - bounds.lower_right(), - bounds.upper_right() + point(px(0.0), vertical_offset), - ); - path.line_to(bounds.lower_left()); - cx.paint_path(path, color); - }, - ) - .h_1() - .w_full() -} - -impl CollabTitlebarItem { - pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { - let project = workspace.project().clone(); - let user_store = workspace.app_state().user_store.clone(); - let client = workspace.app_state().client.clone(); - let active_call = ActiveCall::global(cx); - let mut subscriptions = Vec::new(); - subscriptions.push( - cx.observe(&workspace.weak_handle().upgrade().unwrap(), |_, _, cx| { - cx.notify() - }), - ); - subscriptions.push(cx.observe(&project, |_, _, cx| cx.notify())); - subscriptions.push(cx.observe(&active_call, |this, _, cx| this.active_call_changed(cx))); - subscriptions.push(cx.observe_window_activation(Self::window_activation_changed)); - subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify())); - - Self { - workspace: workspace.weak_handle(), - project, - user_store, - client, - _subscriptions: subscriptions, - } - } - - // resolve if you are in a room -> render_project_owner - // render_project_owner -> resolve if you are in a room -> Option - - pub fn render_project_host(&self, cx: &mut ViewContext) -> Option { - if let Some(dev_server) = - self.project - .read(cx) - .dev_server_project_id() - .and_then(|dev_server_project_id| { - dev_server_projects::Store::global(cx) - .read(cx) - .dev_server_for_project(dev_server_project_id) - }) - { - return Some( - ButtonLike::new("dev_server_trigger") - .child(Indicator::dot().color( - if dev_server.status == DevServerStatus::Online { - Color::Created - } else { - Color::Disabled - }, - )) - .child( - Label::new(dev_server.name.clone()) - .size(LabelSize::Small) - .line_height_style(LineHeightStyle::UiLabel), - ) - .tooltip(move |cx| Tooltip::text("Project is hosted on a dev server", cx)) - .on_click(cx.listener(|this, _, cx| { - if let Some(workspace) = this.workspace.upgrade() { - recent_projects::DevServerProjects::open(workspace, cx) - } - })) - .into_any_element(), - ); - } - - let host = self.project.read(cx).host()?; - let host_user = self.user_store.read(cx).get_cached_user(host.user_id)?; - let participant_index = self - .user_store - .read(cx) - .participant_indices() - .get(&host_user.id)?; - Some( - Button::new("project_owner_trigger", host_user.github_login.clone()) - .color(Color::Player(participant_index.0)) - .style(ButtonStyle::Subtle) - .label_size(LabelSize::Small) - .tooltip(move |cx| { - Tooltip::text( - format!( - "{} is sharing this project. Click to follow.", - host_user.github_login.clone() - ), - cx, - ) - }) - .on_click({ - let host_peer_id = host.peer_id; - cx.listener(move |this, _, cx| { - this.workspace - .update(cx, |workspace, cx| { - workspace.follow(host_peer_id, cx); - }) - .log_err(); - }) - }) - .into_any_element(), - ) - } - - pub fn render_project_name(&self, cx: &mut ViewContext) -> impl IntoElement { - let name = { - let mut names = self.project.read(cx).visible_worktrees(cx).map(|worktree| { - let worktree = worktree.read(cx); - worktree.root_name() - }); - - names.next() - }; - let is_project_selected = name.is_some(); - let name = if let Some(name) = name { - util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH) - } else { - "Open recent project".to_string() - }; - - let workspace = self.workspace.clone(); - Button::new("project_name_trigger", name) - .when(!is_project_selected, |b| b.color(Color::Muted)) - .style(ButtonStyle::Subtle) - .label_size(LabelSize::Small) - .tooltip(move |cx| { - Tooltip::for_action( - "Recent Projects", - &recent_projects::OpenRecent { - create_new_window: false, - }, - cx, - ) - }) - .on_click(cx.listener(move |_, _, cx| { - if let Some(workspace) = workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - RecentProjects::open(workspace, false, cx); - }) - } - })) - } - - pub fn render_project_branch(&self, cx: &mut ViewContext) -> Option { - let entry = { - let mut names_and_branches = - self.project.read(cx).visible_worktrees(cx).map(|worktree| { - let worktree = worktree.read(cx); - worktree.root_git_entry() - }); - - names_and_branches.next().flatten() - }; - let workspace = self.workspace.upgrade()?; - let branch_name = entry - .as_ref() - .and_then(RepositoryEntry::branch) - .map(|branch| util::truncate_and_trailoff(&branch, MAX_BRANCH_NAME_LENGTH))?; - Some( - popover_menu("project_branch_trigger") - .trigger( - Button::new("project_branch_trigger", branch_name) - .color(Color::Muted) - .style(ButtonStyle::Subtle) - .label_size(LabelSize::Small) - .tooltip(move |cx| { - Tooltip::with_meta( - "Recent Branches", - Some(&ToggleVcsMenu), - "Local branches only", - cx, - ) - }), - ) - .menu(move |cx| Self::render_vcs_popover(workspace.clone(), cx)), - ) - } - - #[allow(clippy::too_many_arguments)] - fn render_collaborator( - &self, - user: &Arc, - peer_id: PeerId, - is_present: bool, - is_speaking: bool, - is_muted: bool, - leader_selection_color: Option, - room: &Room, - project_id: Option, - current_user: &Arc, - cx: &ViewContext, - ) -> Option
{ - if room.role_for_user(user.id) == Some(proto::ChannelRole::Guest) { - return None; - } - - const FACEPILE_LIMIT: usize = 3; - let followers = project_id.map_or(&[] as &[_], |id| room.followers_for(peer_id, id)); - let extra_count = followers.len().saturating_sub(FACEPILE_LIMIT); - - Some( - div() - .m_0p5() - .p_0p5() - // When the collaborator is not followed, still draw this wrapper div, but leave - // it transparent, so that it does not shift the layout when following. - .when_some(leader_selection_color, |div, color| { - div.rounded_md().bg(color) - }) - .child( - FacePile::empty() - .child( - Avatar::new(user.avatar_uri.clone()) - .grayscale(!is_present) - .border_color(if is_speaking { - cx.theme().status().info - } else { - // We draw the border in a transparent color rather to avoid - // the layout shift that would come with adding/removing the border. - gpui::transparent_black() - }) - .when(is_muted, |avatar| { - avatar.indicator( - AvatarAudioStatusIndicator::new(ui::AudioStatus::Muted) - .tooltip({ - let github_login = user.github_login.clone(); - move |cx| { - Tooltip::text( - format!("{} is muted", github_login), - cx, - ) - } - }), - ) - }), - ) - .children(followers.iter().take(FACEPILE_LIMIT).filter_map( - |follower_peer_id| { - let follower = room - .remote_participants() - .values() - .find_map(|p| { - (p.peer_id == *follower_peer_id).then_some(&p.user) - }) - .or_else(|| { - (self.client.peer_id() == Some(*follower_peer_id)) - .then_some(current_user) - })? - .clone(); - - Some(div().mt(-px(4.)).child( - Avatar::new(follower.avatar_uri.clone()).size(rems(0.75)), - )) - }, - )) - .children(if extra_count > 0 { - Some( - div() - .ml_1() - .child(Label::new(format!("+{extra_count}"))) - .into_any_element(), - ) - } else { - None - }), - ), - ) - } - - fn window_activation_changed(&mut self, cx: &mut ViewContext) { - if cx.is_window_active() { - ActiveCall::global(cx) - .update(cx, |call, cx| call.set_location(Some(&self.project), cx)) - .detach_and_log_err(cx); - } else if cx.active_window().is_none() { - ActiveCall::global(cx) - .update(cx, |call, cx| call.set_location(None, cx)) - .detach_and_log_err(cx); - } - self.workspace - .update(cx, |workspace, cx| { - workspace.update_active_view_for_followers(cx); - }) - .ok(); - } - - fn active_call_changed(&mut self, cx: &mut ViewContext) { - cx.notify(); - } - - fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext) { - let active_call = ActiveCall::global(cx); - let project = self.project.clone(); - active_call - .update(cx, |call, cx| call.share_project(project, cx)) - .detach_and_log_err(cx); - } - - fn unshare_project(&mut self, _: &UnshareProject, cx: &mut ViewContext) { - let active_call = ActiveCall::global(cx); - let project = self.project.clone(); - active_call - .update(cx, |call, cx| call.unshare_project(project, cx)) - .log_err(); - } - - pub fn render_vcs_popover( - workspace: View, - cx: &mut WindowContext<'_>, - ) -> Option> { - let view = build_branch_list(workspace, cx).log_err()?; - let focus_handle = view.focus_handle(cx); - cx.focus(&focus_handle); - Some(view) - } - - fn render_connection_status( - &self, - status: &client::Status, - cx: &mut ViewContext, - ) -> Option { - match status { - client::Status::ConnectionError - | client::Status::ConnectionLost - | client::Status::Reauthenticating { .. } - | client::Status::Reconnecting { .. } - | client::Status::ReconnectionError { .. } => Some( - div() - .id("disconnected") - .child(Icon::new(IconName::Disconnected).size(IconSize::Small)) - .tooltip(|cx| Tooltip::text("Disconnected", cx)) - .into_any_element(), - ), - client::Status::UpgradeRequired => { - let auto_updater = auto_update::AutoUpdater::get(cx); - let label = match auto_updater.map(|auto_update| auto_update.read(cx).status()) { - Some(AutoUpdateStatus::Updated { .. }) => "Please restart Zed to Collaborate", - Some(AutoUpdateStatus::Installing) - | Some(AutoUpdateStatus::Downloading) - | Some(AutoUpdateStatus::Checking) => "Updating...", - Some(AutoUpdateStatus::Idle) | Some(AutoUpdateStatus::Errored) | None => { - "Please update Zed to Collaborate" - } - }; - - Some( - Button::new("connection-status", label) - .label_size(LabelSize::Small) - .on_click(|_, cx| { - if let Some(auto_updater) = auto_update::AutoUpdater::get(cx) { - if auto_updater.read(cx).status().is_updated() { - workspace::restart(&Default::default(), cx); - return; - } - } - auto_update::check(&Default::default(), cx); - }) - .into_any_element(), - ) - } - _ => None, - } - } - - pub fn render_sign_in_button(&mut self, _: &mut ViewContext) -> Button { - let client = self.client.clone(); - Button::new("sign_in", "Sign in") - .label_size(LabelSize::Small) - .on_click(move |_, cx| { - let client = client.clone(); - cx.spawn(move |mut cx| async move { - client - .authenticate_and_connect(true, &cx) - .await - .notify_async_err(&mut cx); - }) - .detach(); - }) - } - - pub fn render_user_menu_button(&mut self, cx: &mut ViewContext) -> impl Element { - if let Some(user) = self.user_store.read(cx).current_user() { - popover_menu("user-menu") - .menu(|cx| { - ContextMenu::build(cx, |menu, _| { - menu.action("Settings", zed_actions::OpenSettings.boxed_clone()) - .action("Extensions", extensions_ui::Extensions.boxed_clone()) - .action("Themes…", theme_selector::Toggle::default().boxed_clone()) - .separator() - .action("Sign Out", client::SignOut.boxed_clone()) - }) - .into() - }) - .trigger( - ButtonLike::new("user-menu") - .child( - h_flex() - .gap_0p5() - .child(Avatar::new(user.avatar_uri.clone())) - .child( - Icon::new(IconName::ChevronDown) - .size(IconSize::Small) - .color(Color::Muted), - ), - ) - .style(ButtonStyle::Subtle) - .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)), - ) - .anchor(gpui::AnchorCorner::TopRight) - } else { - popover_menu("user-menu") - .menu(|cx| { - ContextMenu::build(cx, |menu, _| { - menu.action("Settings", zed_actions::OpenSettings.boxed_clone()) - .action("Extensions", extensions_ui::Extensions.boxed_clone()) - .action("Themes…", theme_selector::Toggle::default().boxed_clone()) - }) - .into() - }) - .trigger( - ButtonLike::new("user-menu") - .child( - h_flex().gap_0p5().child( - Icon::new(IconName::ChevronDown) - .size(IconSize::Small) - .color(Color::Muted), - ), - ) - .style(ButtonStyle::Subtle) - .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)), - ) - } - } -} diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index 94f7c7ba32f4bb..8b704b6a05fc7e 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -1,20 +1,16 @@ pub mod channel_view; pub mod chat_panel; pub mod collab_panel; -mod collab_titlebar_item; -mod face_pile; pub mod notification_panel; pub mod notifications; mod panel_settings; use std::{rc::Rc, sync::Arc}; -use call::{report_call_event_for_room, ActiveCall}; pub use collab_panel::CollabPanel; -pub use collab_titlebar_item::CollabTitlebarItem; use gpui::{ - actions, point, AppContext, DevicePixels, Pixels, PlatformDisplay, Size, Task, - WindowBackgroundAppearance, WindowBounds, WindowContext, WindowKind, WindowOptions, + point, AppContext, Pixels, PlatformDisplay, Size, WindowBackgroundAppearance, WindowBounds, + WindowDecorations, WindowKind, WindowOptions, }; use panel_settings::MessageEditorSettings; pub use panel_settings::{ @@ -22,12 +18,8 @@ pub use panel_settings::{ }; use release_channel::ReleaseChannel; use settings::Settings; -use workspace::{notifications::DetachAndPromptErr, AppState}; - -actions!( - collab, - [ToggleScreenSharing, ToggleMute, ToggleDeafen, LeaveCall] -); +use ui::px; +use workspace::AppState; pub fn init(app_state: &Arc, cx: &mut AppContext) { CollaborationPanelSettings::register(cx); @@ -35,83 +27,30 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { NotificationPanelSettings::register(cx); MessageEditorSettings::register(cx); - vcs_menu::init(cx); - collab_titlebar_item::init(cx); - collab_panel::init(cx); channel_view::init(cx); chat_panel::init(cx); + collab_panel::init(cx); notification_panel::init(cx); notifications::init(&app_state, cx); -} - -pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut WindowContext) { - let call = ActiveCall::global(cx).read(cx); - if let Some(room) = call.room().cloned() { - let client = call.client(); - let toggle_screen_sharing = room.update(cx, |room, cx| { - if room.is_screen_sharing() { - report_call_event_for_room( - "disable screen share", - room.id(), - room.channel_id(), - &client, - ); - Task::ready(room.unshare_screen(cx)) - } else { - report_call_event_for_room( - "enable screen share", - room.id(), - room.channel_id(), - &client, - ); - room.share_screen(cx) - } - }); - toggle_screen_sharing.detach_and_prompt_err("Sharing Screen Failed", cx, |e, _| Some(format!("{:?}\n\nPlease check that you have given Zed permissions to record your screen in Settings.", e))); - } -} - -pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) { - let call = ActiveCall::global(cx).read(cx); - if let Some(room) = call.room().cloned() { - let client = call.client(); - room.update(cx, |room, cx| { - let operation = if room.is_muted() { - "enable microphone" - } else { - "disable microphone" - }; - report_call_event_for_room(operation, room.id(), room.channel_id(), &client); - - room.toggle_mute(cx) - }); - } -} - -pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) { - if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { - room.update(cx, |room, cx| room.toggle_deafen(cx)); - } + title_bar::init(cx); + vcs_menu::init(cx); } fn notification_window_options( screen: Rc, - window_size: Size, + size: Size, cx: &AppContext, ) -> WindowOptions { - let notification_margin_width = DevicePixels::from(16); - let notification_margin_height = DevicePixels::from(-0) - DevicePixels::from(48); - - let screen_bounds = screen.bounds(); - let size: Size = window_size.into(); + let notification_margin_width = px(16.); + let notification_margin_height = px(-48.); - let bounds = gpui::Bounds:: { - origin: screen_bounds.upper_right() + let bounds = gpui::Bounds:: { + origin: screen.bounds().upper_right() - point( size.width + notification_margin_width, notification_margin_height, ), - size: window_size.into(), + size, }; let app_id = ReleaseChannel::global(cx).app_id(); @@ -124,7 +63,9 @@ fn notification_window_options( kind: WindowKind::PopUp, is_movable: false, display_id: Some(screen.id()), - window_background: WindowBackgroundAppearance::default(), + window_background: WindowBackgroundAppearance::Transparent, app_id: Some(app_id.to_owned()), + window_min_size: None, + window_decorations: Some(WindowDecorations::Client), } } diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index c8e58b1e85765d..08dee3686d9bc1 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -127,11 +127,12 @@ impl NotificationPanel { }, )); + let local_offset = chrono::Local::now().offset().local_minus_utc(); let mut this = Self { fs, client, user_store, - local_timezone: cx.local_timezone(), + local_timezone: UtcOffset::from_whole_seconds(local_offset).unwrap(), channel_store: ChannelStore::global(cx), notification_store: NotificationStore::global(cx), notification_list, diff --git a/crates/collab_ui/src/notifications/incoming_call_notification.rs b/crates/collab_ui/src/notifications/incoming_call_notification.rs index 4279841f53fcd1..cca67cb5e7cf21 100644 --- a/crates/collab_ui/src/notifications/incoming_call_notification.rs +++ b/crates/collab_ui/src/notifications/incoming_call_notification.rs @@ -3,9 +3,8 @@ use crate::notifications::collab_notification::CollabNotification; use call::{ActiveCall, IncomingCall}; use futures::StreamExt; use gpui::{prelude::*, AppContext, WindowHandle}; -use settings::Settings; + use std::sync::{Arc, Weak}; -use theme::ThemeSettings; use ui::{prelude::*, Button, Label}; use util::ResultExt; use workspace::AppState; @@ -113,18 +112,9 @@ impl IncomingCallNotification { impl Render for IncomingCallNotification { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - // TODO: Is there a better place for us to initialize the font? - let (ui_font, ui_font_size) = { - let theme_settings = ThemeSettings::get_global(cx); - ( - theme_settings.ui_font.family.clone(), - theme_settings.ui_font_size, - ) - }; - - cx.set_rem_size(ui_font_size); + let ui_font = theme::setup_ui_font(cx); - div().size_full().font_family(ui_font).child( + div().size_full().font(ui_font).child( CollabNotification::new( self.state.call.calling_user.avatar_uri.clone(), Button::new("accept", "Accept").on_click({ diff --git a/crates/collab_ui/src/notifications/project_shared_notification.rs b/crates/collab_ui/src/notifications/project_shared_notification.rs index 87f53a56b200f4..2634bf1c6f49d9 100644 --- a/crates/collab_ui/src/notifications/project_shared_notification.rs +++ b/crates/collab_ui/src/notifications/project_shared_notification.rs @@ -4,10 +4,10 @@ use call::{room, ActiveCall}; use client::User; use collections::HashMap; use gpui::{AppContext, Size}; -use settings::Settings; use std::sync::{Arc, Weak}; -use theme::ThemeSettings; + use ui::{prelude::*, Button, Label}; +use util::ResultExt; use workspace::AppState; pub fn init(app_state: &Arc, cx: &mut AppContext) { @@ -27,16 +27,21 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { for screen in cx.displays() { let options = notification_window_options(screen, window_size, cx); - let window = cx.open_window(options, |cx| { - cx.new_view(|_| { - ProjectSharedNotification::new( - owner.clone(), - *project_id, - worktree_root_names.clone(), - app_state.clone(), - ) + let Some(window) = cx + .open_window(options, |cx| { + cx.new_view(|_| { + ProjectSharedNotification::new( + owner.clone(), + *project_id, + worktree_root_names.clone(), + app_state.clone(), + ) + }) }) - }); + .log_err() + else { + continue; + }; notification_windows .entry(*project_id) .or_insert(Vec::new()) @@ -118,18 +123,9 @@ impl ProjectSharedNotification { impl Render for ProjectSharedNotification { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - // TODO: Is there a better place for us to initialize the font? - let (ui_font, ui_font_size) = { - let theme_settings = ThemeSettings::get_global(cx); - ( - theme_settings.ui_font.family.clone(), - theme_settings.ui_font_size, - ) - }; - - cx.set_rem_size(ui_font_size); + let ui_font = theme::setup_ui_font(cx); - div().size_full().font_family(ui_font).child( + div().size_full().font(ui_font).child( CollabNotification::new( self.owner.avatar_uri.clone(), Button::new("open", "Open").on_click(cx.listener(move |this, _event, cx| { diff --git a/crates/color/src/color.rs b/crates/color/src/color.rs deleted file mode 100644 index 49bdc53fd16ef8..00000000000000 --- a/crates/color/src/color.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! # Color -//! -//! The `color` crate provides a set utilities for working with colors. It is a wrapper around the [`palette`](https://docs.rs/palette) crate with some additional functionality. -//! -//! It is used to create a manipulate colors when building themes. -//! -//! === In development note === -//! -//! This crate is meant to sit between gpui and the theme/ui for all the color related stuff. -//! -//! It could be folded into gpui, ui or theme potentially but for now we'll continue -//! to develop it in isolation. -//! -//! Once we have a good idea of the needs of the theme system and color in gpui in general I see 3 paths: -//! 1. Use `palette` (or another color library) directly in gpui and everywhere else, rather than rolling our own color system. -//! 2. Keep this crate as a thin wrapper around `palette` and use it everywhere except gpui, and convert to gpui's color system when needed. -//! 3. Build the needed functionality into gpui and keep using its color system everywhere. -//! -//! I'm leaning towards 2 in the short term and 1 in the long term, but we'll need to discuss it more. -//! -//! === End development note === -use palette::{ - blend::Blend, convert::FromColorUnclamped, encoding, rgb::Rgb, Clamp, Mix, Srgb, WithAlpha, -}; - -/// The types of blend modes supported -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum BlendMode { - /// Multiplies the colors, resulting in a darker color. This mode is useful for creating shadows. - Multiply, - /// Lightens the color by adding the source and destination colors. It results in a lighter color. - Screen, - /// Combines Multiply and Screen blend modes. Parts of the image that are lighter than 50% gray are lightened, and parts that are darker are darkened. - Overlay, - /// Selects the darker of the base or blend color as the resulting color. Useful for darkening images without affecting the overall contrast. - Darken, - /// Selects the lighter of the base or blend color as the resulting color. Useful for lightening images without affecting the overall contrast. - Lighten, - /// Brightens the base color to reflect the blend color. The result is a lightened image. - Dodge, - /// Darkens the base color to reflect the blend color. The result is a darkened image. - Burn, - /// Similar to Overlay, but with a stronger effect. Hard Light can either multiply or screen colors, depending on the blend color. - HardLight, - /// A softer version of Hard Light. Soft Light either darkens or lightens colors, depending on the blend color. - SoftLight, - /// Subtracts the darker of the two constituent colors from the lighter color. Difference mode is useful for creating more vivid colors. - Difference, - /// Similar to Difference, but with a lower contrast. Exclusion mode produces an effect similar to Difference but with less intensity. - Exclusion, -} - -/// Converts a hexadecimal color string to a `palette::Hsla` color. -/// -/// This function supports the following hex formats: -/// `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`. -pub fn hex_to_hsla(s: &str) -> Result { - let hex = s.trim_start_matches('#'); - - // Expand shorthand formats #RGB and #RGBA to #RRGGBB and #RRGGBBAA - let h = hex.as_bytes(); - let arr: [u8; 8] = match h.len() { - // #RGB => #RRGGBBAA - 3 => [h[0], h[0], h[1], h[1], h[2], h[2], b'f', b'f'], - // #RGBA => #RRGGBBAA - 4 => [h[0], h[0], h[1], h[1], h[2], h[2], h[3], h[3]], - // #RRGGBB => #RRGGBBAA - 6 => [h[0], h[1], h[2], h[3], h[4], h[5], b'f', b'f'], - // Already in #RRGGBBAA - 8 => h.try_into().unwrap(), - _ => return Err("Invalid hexadecimal string length".to_string()), - }; - - let hex = - std::str::from_utf8(&arr).map_err(|_| format!("Invalid hexadecimal string: {}", s))?; - let hex_val = - u32::from_str_radix(hex, 16).map_err(|_| format!("Invalid hexadecimal string: {}", s))?; - - Ok(RGBAColor { - r: ((hex_val >> 24) & 0xFF) as f32 / 255.0, - g: ((hex_val >> 16) & 0xFF) as f32 / 255.0, - b: ((hex_val >> 8) & 0xFF) as f32 / 255.0, - a: (hex_val & 0xFF) as f32 / 255.0, - }) -} - -// These derives implement to and from palette's color types. -#[derive(FromColorUnclamped, WithAlpha, Debug, Clone)] -#[palette(skip_derives(Rgb), rgb_standard = "encoding::Srgb")] -pub struct RGBAColor { - r: f32, - g: f32, - b: f32, - // Let Palette know this is our alpha channel. - #[palette(alpha)] - a: f32, -} - -impl FromColorUnclamped for RGBAColor { - fn from_color_unclamped(color: RGBAColor) -> RGBAColor { - color - } -} - -impl FromColorUnclamped> for RGBAColor -where - Srgb: FromColorUnclamped>, -{ - fn from_color_unclamped(color: Rgb) -> RGBAColor { - let srgb = Srgb::from_color_unclamped(color); - RGBAColor { - r: srgb.red, - g: srgb.green, - b: srgb.blue, - a: 1.0, - } - } -} - -impl FromColorUnclamped for Rgb -where - Rgb: FromColorUnclamped, -{ - fn from_color_unclamped(color: RGBAColor) -> Self { - Self::from_color_unclamped(Srgb::new(color.r, color.g, color.b)) - } -} - -impl Clamp for RGBAColor { - fn clamp(self) -> Self { - RGBAColor { - r: self.r.min(1.0).max(0.0), - g: self.g.min(1.0).max(0.0), - b: self.b.min(1.0).max(0.0), - a: self.a.min(1.0).max(0.0), - } - } -} - -impl RGBAColor { - /// Creates a new color from the given RGBA values. - /// - /// This color can be used to convert to any [`palette::Color`] type. - pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self { - RGBAColor { r, g, b, a } - } - - /// Returns a set of states for this color. - pub fn states(self, is_light: bool) -> ColorStates { - states_for_color(self, is_light) - } - - /// Mixes this color with another [`palette::Hsl`] color at the given `mix_ratio`. - pub fn mixed(&self, other: RGBAColor, mix_ratio: f32) -> Self { - let srgb_self = Srgb::new(self.r, self.g, self.b); - let srgb_other = Srgb::new(other.r, other.g, other.b); - - // Directly mix the colors as sRGB values - let mixed = srgb_self.mix(srgb_other, mix_ratio); - RGBAColor::from_color_unclamped(mixed) - } - - pub fn blend(&self, other: RGBAColor, blend_mode: BlendMode) -> Self { - let srgb_self = Srgb::new(self.r, self.g, self.b); - let srgb_other = Srgb::new(other.r, other.g, other.b); - - let blended = match blend_mode { - // replace hsl methods with the respective sRGB methods - BlendMode::Multiply => srgb_self.multiply(srgb_other), - _ => unimplemented!(), - }; - - Self { - r: blended.red, - g: blended.green, - b: blended.blue, - a: self.a, - } - } -} - -/// A set of colors for different states of an element. -#[derive(Debug, Clone)] -pub struct ColorStates { - /// The default color. - pub default: RGBAColor, - /// The color when the mouse is hovering over the element. - pub hover: RGBAColor, - /// The color when the mouse button is held down on the element. - pub active: RGBAColor, - /// The color when the element is focused with the keyboard. - pub focused: RGBAColor, - /// The color when the element is disabled. - pub disabled: RGBAColor, -} - -/// Returns a set of colors for different states of an element. -/// -/// todo("This should take a theme and use appropriate colors from it") -pub fn states_for_color(color: RGBAColor, is_light: bool) -> ColorStates { - let adjustment_factor = if is_light { 0.1 } else { -0.1 }; - let hover_adjustment = 1.0 - adjustment_factor; - let active_adjustment = 1.0 - 2.0 * adjustment_factor; - let focused_adjustment = 1.0 - 3.0 * adjustment_factor; - let disabled_adjustment = 1.0 - 4.0 * adjustment_factor; - - let make_adjustment = |color: RGBAColor, adjustment: f32| -> RGBAColor { - // Adjust lightness for each state - // Note: Adjustment logic may differ; simplify as needed for sRGB - RGBAColor::new( - color.r * adjustment, - color.g * adjustment, - color.b * adjustment, - color.a, - ) - }; - - let color = color.clamp(); - - ColorStates { - default: color.clone(), - hover: make_adjustment(color.clone(), hover_adjustment), - active: make_adjustment(color.clone(), active_adjustment), - focused: make_adjustment(color.clone(), focused_adjustment), - disabled: make_adjustment(color.clone(), disabled_adjustment), - } -} diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index cf62b75afb78c8..ab236ea2076239 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -477,7 +477,7 @@ mod tests { }); workspace.update(cx, |workspace, cx| { - workspace.add_item_to_active_pane(Box::new(editor.clone()), None, cx); + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, cx); editor.update(cx, |editor, cx| editor.focus(cx)) }); diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index f7b9988e48df3e..70915ecab27187 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -38,6 +38,7 @@ lsp.workspace = true menu.workspace = true node_runtime.workspace = true parking_lot.workspace = true +paths.workspace = true project.workspace = true serde.workspace = true settings.workspace = true diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 9a836eb9be7fd7..5d034d150d0f5a 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -33,7 +33,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use util::{fs::remove_matching, maybe, paths, ResultExt}; +use util::{fs::remove_matching, maybe, ResultExt}; pub use copilot_completion_provider::CopilotCompletionProvider; pub use sign_in::CopilotCodeVerification; @@ -968,7 +968,7 @@ fn uri_for_buffer(buffer: &Model, cx: &AppContext) -> lsp::Url { } async fn clear_copilot_dir() { - remove_matching(&paths::COPILOT_DIR, |_| true).await + remove_matching(paths::copilot_dir(), |_| true).await } async fn get_copilot_lsp(http: Arc) -> anyhow::Result { @@ -979,7 +979,7 @@ async fn get_copilot_lsp(http: Arc) -> anyhow::Result { let release = latest_github_release("zed-industries/copilot", true, false, http.clone()).await?; - let version_dir = &*paths::COPILOT_DIR.join(format!("copilot-{}", release.tag_name)); + let version_dir = &paths::copilot_dir().join(format!("copilot-{}", release.tag_name)); fs::create_dir_all(version_dir).await?; let server_path = version_dir.join(SERVER_PATH); @@ -1003,7 +1003,7 @@ async fn get_copilot_lsp(http: Arc) -> anyhow::Result { let archive = Archive::new(decompressed_bytes); archive.unpack(dist_dir).await?; - remove_matching(&paths::COPILOT_DIR, |entry| entry != version_dir).await; + remove_matching(paths::copilot_dir(), |entry| entry != version_dir).await; } Ok(server_path) @@ -1016,7 +1016,7 @@ async fn get_copilot_lsp(http: Arc) -> anyhow::Result { // Fetch a cached binary, if it exists maybe!(async { let mut last_version_dir = None; - let mut entries = fs::read_dir(paths::COPILOT_DIR.as_path()).await?; + let mut entries = fs::read_dir(paths::copilot_dir()).await?; while let Some(entry) = entries.next().await { let entry = entry?; if entry.file_type().await?.is_dir() { @@ -1044,7 +1044,6 @@ async fn get_copilot_lsp(http: Arc) -> anyhow::Result { mod tests { use super::*; use gpui::TestAppContext; - use language::BufferId; #[gpui::test(iterations = 10)] async fn test_buffer_management(cx: &mut TestAppContext) { @@ -1258,16 +1257,5 @@ mod tests { fn load(&self, _: &AppContext) -> Task> { unimplemented!() } - - fn buffer_reloaded( - &self, - _: BufferId, - _: &clock::Global, - _: language::LineEnding, - _: Option, - _: &mut AppContext, - ) { - unimplemented!() - } } } diff --git a/crates/copilot/src/copilot_completion_provider.rs b/crates/copilot/src/copilot_completion_provider.rs index 314ec0ac0845c1..cc5da74cb1c98b 100644 --- a/crates/copilot/src/copilot_completion_provider.rs +++ b/crates/copilot/src/copilot_completion_provider.rs @@ -781,7 +781,7 @@ mod tests { ); multibuffer }); - let editor = cx.add_window(|cx| Editor::for_multibuffer(multibuffer, None, cx)); + let editor = cx.add_window(|cx| Editor::for_multibuffer(multibuffer, None, true, cx)); editor.update(cx, |editor, cx| editor.focus(cx)).unwrap(); let copilot_provider = cx.new_model(|_| CopilotCompletionProvider::new(copilot)); editor @@ -811,7 +811,7 @@ mod tests { assert!(editor.has_active_inline_completion(cx)); assert_eq!( editor.display_text(cx), - "\n\na = 1\nb = 2 + a\n\n\n\nc = 3\nd = 4\n" + "\n\n\na = 1\nb = 2 + a\n\n\n\n\n\nc = 3\nd = 4\n\n" ); assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4\n"); }); @@ -833,7 +833,7 @@ mod tests { assert!(!editor.has_active_inline_completion(cx)); assert_eq!( editor.display_text(cx), - "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4\n" + "\n\n\na = 1\nb = 2\n\n\n\n\n\nc = 3\nd = 4\n\n" ); assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4\n"); @@ -842,7 +842,7 @@ mod tests { assert!(!editor.has_active_inline_completion(cx)); assert_eq!( editor.display_text(cx), - "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4 \n" + "\n\n\na = 1\nb = 2\n\n\n\n\n\nc = 3\nd = 4 \n\n" ); assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4 \n"); }); @@ -853,7 +853,7 @@ mod tests { assert!(editor.has_active_inline_completion(cx)); assert_eq!( editor.display_text(cx), - "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4 + c\n" + "\n\n\na = 1\nb = 2\n\n\n\n\n\nc = 3\nd = 4 + c\n\n" ); assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4 \n"); }); @@ -1032,7 +1032,7 @@ mod tests { ); multibuffer }); - let editor = cx.add_window(|cx| Editor::for_multibuffer(multibuffer, None, cx)); + let editor = cx.add_window(|cx| Editor::for_multibuffer(multibuffer, None, true, cx)); let copilot_provider = cx.new_model(|_| CopilotCompletionProvider::new(copilot)); editor .update(cx, |editor, cx| { diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml index f31609277db132..f36785ea1e5925 100644 --- a/crates/db/Cargo.toml +++ b/crates/db/Cargo.toml @@ -21,6 +21,7 @@ gpui.workspace = true indoc.workspace = true lazy_static.workspace = true log.workspace = true +paths.workspace = true release_channel.workspace = true smol.workspace = true sqlez.workspace = true diff --git a/crates/db/src/db.rs b/crates/db/src/db.rs index 577a1746d51d8e..3d65549fefcc5c 100644 --- a/crates/db/src/db.rs +++ b/crates/db/src/db.rs @@ -7,10 +7,10 @@ use anyhow::Context; use gpui::AppContext; pub use indoc::indoc; pub use lazy_static; +pub use paths::database_dir; pub use smol; pub use sqlez; pub use sqlez_macros; -pub use util::paths::DB_DIR; use release_channel::ReleaseChannel; pub use release_channel::RELEASE_CHANNEL; @@ -145,7 +145,7 @@ macro_rules! define_connection { #[cfg(not(any(test, feature = "test-support")))] $crate::lazy_static::lazy_static! { - pub static ref $id: $t = $t($crate::smol::block_on($crate::open_db(&$crate::DB_DIR, &$crate::RELEASE_CHANNEL))); + pub static ref $id: $t = $t($crate::smol::block_on($crate::open_db($crate::database_dir(), &$crate::RELEASE_CHANNEL))); } }; (pub static ref $id:ident: $t:ident<$($d:ty),+> = $migrations:expr;) => { @@ -176,7 +176,7 @@ macro_rules! define_connection { #[cfg(not(any(test, feature = "test-support")))] $crate::lazy_static::lazy_static! { - pub static ref $id: $t = $t($crate::smol::block_on($crate::open_db(&$crate::DB_DIR, &$crate::RELEASE_CHANNEL))); + pub static ref $id: $t = $t($crate::smol::block_on($crate::open_db($crate::database_dir(), &$crate::RELEASE_CHANNEL))); } }; } diff --git a/crates/dev_server_projects/src/dev_server_projects.rs b/crates/dev_server_projects/src/dev_server_projects.rs index e69c905a14cf18..d5e4b34039b61c 100644 --- a/crates/dev_server_projects/src/dev_server_projects.rs +++ b/crates/dev_server_projects/src/dev_server_projects.rs @@ -20,7 +20,7 @@ pub struct Store { pub struct DevServerProject { pub id: DevServerProjectId, pub project_id: Option, - pub path: SharedString, + pub paths: Vec, pub dev_server_id: DevServerId, } @@ -29,7 +29,7 @@ impl From for DevServerProject { Self { id: DevServerProjectId(project.id), project_id: project.project_id.map(|id| ProjectId(id)), - path: project.path.into(), + paths: project.paths.into_iter().map(|path| path.into()).collect(), dev_server_id: DevServerId(project.dev_server_id), } } @@ -85,7 +85,7 @@ impl Store { .filter(|project| project.dev_server_id == id) .cloned() .collect(); - projects.sort_by_key(|p| (p.path.clone(), p.id)); + projects.sort_by_key(|p| (p.paths.clone(), p.id)); projects } @@ -108,7 +108,7 @@ impl Store { pub fn dev_server_projects(&self) -> Vec { let mut projects: Vec = self.dev_server_projects.values().cloned().collect(); - projects.sort_by_key(|p| (p.path.clone(), p.id)); + projects.sort_by_key(|p| (p.paths.clone(), p.id)); projects } @@ -124,7 +124,6 @@ impl Store { async fn handle_dev_server_projects_update( this: Model, envelope: TypedEnvelope, - _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { this.update(&mut cx, |this, cx| { diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml index 48f05444e4d09d..74641480d269a2 100644 --- a/crates/diagnostics/Cargo.toml +++ b/crates/diagnostics/Cargo.toml @@ -18,11 +18,13 @@ collections.workspace = true ctor.workspace = true editor.workspace = true env_logger.workspace = true +feature_flags.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true log.workspace = true lsp.workspace = true +multi_buffer.workspace = true project.workspace = true rand.workspace = true schemars.workspace = true diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index f0357463c48b7d..e2b64567b718b0 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -4,16 +4,18 @@ mod toolbar_controls; #[cfg(test)] mod diagnostics_tests; +pub(crate) mod grouped_diagnostics; use anyhow::Result; use collections::{BTreeSet, HashSet}; use editor::{ diagnostic_block_renderer, - display_map::{BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock}, + display_map::{BlockDisposition, BlockProperties, BlockStyle, CustomBlockId, RenderBlock}, highlight_diagnostic_message, scroll::Autoscroll, Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, ToOffset, }; +use feature_flags::FeatureFlagAppExt; use futures::{ channel::mpsc::{self, UnboundedSender}, StreamExt as _, @@ -43,7 +45,7 @@ use ui::{h_flex, prelude::*, Icon, IconName, Label}; use util::ResultExt; use workspace::{ item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams}, - ItemNavHistory, Pane, ToolbarItemLocation, Workspace, + ItemNavHistory, ToolbarItemLocation, Workspace, }; actions!(diagnostics, [Deploy, ToggleWarnings]); @@ -52,6 +54,9 @@ pub fn init(cx: &mut AppContext) { ProjectDiagnosticsSettings::register(cx); cx.observe_new_views(ProjectDiagnosticsEditor::register) .detach(); + if !cx.has_flag::() { + grouped_diagnostics::init(cx); + } } struct ProjectDiagnosticsEditor { @@ -80,7 +85,7 @@ struct DiagnosticGroupState { primary_diagnostic: DiagnosticEntry, primary_excerpt_ix: usize, excerpts: Vec, - blocks: HashSet, + blocks: HashSet, block_count: usize, } @@ -102,6 +107,9 @@ impl Render for ProjectDiagnosticsEditor { div() .track_focus(&self.focus_handle) + .when(self.path_states.is_empty(), |el| { + el.key_context("EmptyPane") + }) .size_full() .on_action(cx.listener(Self::toggle_warnings)) .child(child) @@ -137,7 +145,7 @@ impl ProjectDiagnosticsEditor { this.summary = project.read(cx).diagnostic_summary(false, cx); cx.emit(EditorEvent::TitleChanged); - if this.editor.read(cx).is_focused(cx) || this.focus_handle.is_focused(cx) { + if this.editor.focus_handle(cx).contains_focused(cx) || this.focus_handle.contains_focused(cx) { log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. recording change"); } else { log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. updating excerpts"); @@ -150,7 +158,7 @@ impl ProjectDiagnosticsEditor { let focus_handle = cx.focus_handle(); cx.on_focus_in(&focus_handle, |this, cx| this.focus_in(cx)) .detach(); - cx.on_focus_out(&focus_handle, |this, cx| this.focus_out(cx)) + cx.on_focus_out(&focus_handle, |this, _event, cx| this.focus_out(cx)) .detach(); let excerpts = cx.new_model(|cx| { @@ -161,7 +169,7 @@ impl ProjectDiagnosticsEditor { }); let editor = cx.new_view(|cx| { let mut editor = - Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), cx); + Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), false, cx); editor.set_vertical_scroll_margin(5, cx); editor }); @@ -229,13 +237,13 @@ impl ProjectDiagnosticsEditor { fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext) { if let Some(existing) = workspace.item_of_type::(cx) { - workspace.activate_item(&existing, cx); + workspace.activate_item(&existing, true, true, cx); } else { let workspace_handle = cx.view().downgrade(); let diagnostics = cx.new_view(|cx| { ProjectDiagnosticsEditor::new(workspace.project().clone(), workspace_handle, cx) }); - workspace.add_item_to_active_pane(Box::new(diagnostics), None, cx); + workspace.add_item_to_active_pane(Box::new(diagnostics), None, true, cx); } } @@ -463,7 +471,9 @@ impl ProjectDiagnosticsEditor { position: (excerpt_id, entry.range.start), height: diagnostic.message.matches('\n').count() as u8 + 1, style: BlockStyle::Fixed, - render: diagnostic_block_renderer(diagnostic, true), + render: diagnostic_block_renderer( + diagnostic, None, true, true, + ), disposition: BlockDisposition::Below, }); } @@ -639,11 +649,7 @@ impl Item for ProjectDiagnosticsEditor { fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement { if self.summary.error_count == 0 && self.summary.warning_count == 0 { Label::new("No problems") - .color(if params.selected { - Color::Default - } else { - Color::Muted - }) + .color(params.text_color()) .into_any_element() } else { h_flex() @@ -653,13 +659,10 @@ impl Item for ProjectDiagnosticsEditor { h_flex() .gap_1() .child(Icon::new(IconName::XCircle).color(Color::Error)) - .child(Label::new(self.summary.error_count.to_string()).color( - if params.selected { - Color::Default - } else { - Color::Muted - }, - )), + .child( + Label::new(self.summary.error_count.to_string()) + .color(params.text_color()), + ), ) }) .when(self.summary.warning_count > 0, |then| { @@ -667,13 +670,10 @@ impl Item for ProjectDiagnosticsEditor { h_flex() .gap_1() .child(Icon::new(IconName::ExclamationTriangle).color(Color::Warning)) - .child(Label::new(self.summary.warning_count.to_string()).color( - if params.selected { - Color::Default - } else { - Color::Muted - }, - )), + .child( + Label::new(self.summary.warning_count.to_string()) + .color(params.text_color()), + ), ) }) .into_any_element() @@ -704,7 +704,7 @@ impl Item for ProjectDiagnosticsEditor { fn clone_on_split( &self, - _workspace_id: workspace::WorkspaceId, + _workspace_id: Option, cx: &mut ViewContext, ) -> Option> where @@ -776,29 +776,17 @@ impl Item for ProjectDiagnosticsEditor { self.editor .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx)); } - - fn serialized_item_kind() -> Option<&'static str> { - Some("diagnostics") - } - - fn deserialize( - project: Model, - workspace: WeakView, - _workspace_id: workspace::WorkspaceId, - _item_id: workspace::ItemId, - cx: &mut ViewContext, - ) -> Task>> { - Task::ready(Ok(cx.new_view(|cx| Self::new(project, workspace, cx)))) - } } +const DIAGNOSTIC_HEADER: &'static str = "diagnostic header"; + fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock { - let (message, code_ranges) = highlight_diagnostic_message(&diagnostic); + let (message, code_ranges) = highlight_diagnostic_message(&diagnostic, None); let message: SharedString = message; Box::new(move |cx| { let highlight_style: HighlightStyle = cx.theme().colors().text_accent.into(); h_flex() - .id("diagnostic header") + .id(DIAGNOSTIC_HEADER) .py_2() .pl_10() .pr_5() @@ -865,10 +853,12 @@ fn compare_diagnostics( snapshot: &language::BufferSnapshot, ) -> Ordering { use language::ToOffset; - // The old diagnostics may point to a previously open Buffer for this file. - if !old.range.start.is_valid(snapshot) { + + // The diagnostics may point to a previously open Buffer for this file. + if !old.range.start.is_valid(snapshot) || !new.range.start.is_valid(snapshot) { return Ordering::Greater; } + old.range .start .to_offset(snapshot) diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index f456020e848023..32f40edc1c395f 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -1,7 +1,7 @@ use super::*; use collections::HashMap; use editor::{ - display_map::{BlockContext, DisplayRow, TransformBlock}, + display_map::{Block, BlockContext, DisplayRow}, DisplayPoint, GutterDimensions, }; use gpui::{px, AvailableSpace, Stateful, TestAppContext, VisualTestContext}; @@ -158,11 +158,11 @@ async fn test_diagnostics(cx: &mut TestAppContext) { assert_eq!( editor_blocks(&editor, cx), [ - (DisplayRow(0), "path header block".into()), - (DisplayRow(2), "diagnostic header".into()), - (DisplayRow(15), "collapsed context".into()), - (DisplayRow(16), "diagnostic header".into()), - (DisplayRow(25), "collapsed context".into()), + (DisplayRow(0), FILE_HEADER.into()), + (DisplayRow(2), DIAGNOSTIC_HEADER.into()), + (DisplayRow(15), EXCERPT_HEADER.into()), + (DisplayRow(16), DIAGNOSTIC_HEADER.into()), + (DisplayRow(25), EXCERPT_HEADER.into()), ] ); assert_eq!( @@ -243,13 +243,13 @@ async fn test_diagnostics(cx: &mut TestAppContext) { assert_eq!( editor_blocks(&editor, cx), [ - (DisplayRow(0), "path header block".into()), - (DisplayRow(2), "diagnostic header".into()), - (DisplayRow(7), "path header block".into()), - (DisplayRow(9), "diagnostic header".into()), - (DisplayRow(22), "collapsed context".into()), - (DisplayRow(23), "diagnostic header".into()), - (DisplayRow(32), "collapsed context".into()), + (DisplayRow(0), FILE_HEADER.into()), + (DisplayRow(2), DIAGNOSTIC_HEADER.into()), + (DisplayRow(7), FILE_HEADER.into()), + (DisplayRow(9), DIAGNOSTIC_HEADER.into()), + (DisplayRow(22), EXCERPT_HEADER.into()), + (DisplayRow(23), DIAGNOSTIC_HEADER.into()), + (DisplayRow(32), EXCERPT_HEADER.into()), ] ); @@ -355,15 +355,15 @@ async fn test_diagnostics(cx: &mut TestAppContext) { assert_eq!( editor_blocks(&editor, cx), [ - (DisplayRow(0), "path header block".into()), - (DisplayRow(2), "diagnostic header".into()), - (DisplayRow(7), "collapsed context".into()), - (DisplayRow(8), "diagnostic header".into()), - (DisplayRow(13), "path header block".into()), - (DisplayRow(15), "diagnostic header".into()), - (DisplayRow(28), "collapsed context".into()), - (DisplayRow(29), "diagnostic header".into()), - (DisplayRow(38), "collapsed context".into()), + (DisplayRow(0), FILE_HEADER.into()), + (DisplayRow(2), DIAGNOSTIC_HEADER.into()), + (DisplayRow(7), EXCERPT_HEADER.into()), + (DisplayRow(8), DIAGNOSTIC_HEADER.into()), + (DisplayRow(13), FILE_HEADER.into()), + (DisplayRow(15), DIAGNOSTIC_HEADER.into()), + (DisplayRow(28), EXCERPT_HEADER.into()), + (DisplayRow(29), DIAGNOSTIC_HEADER.into()), + (DisplayRow(38), EXCERPT_HEADER.into()), ] ); @@ -493,8 +493,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { assert_eq!( editor_blocks(&editor, cx), [ - (DisplayRow(0), "path header block".into()), - (DisplayRow(2), "diagnostic header".into()), + (DisplayRow(0), FILE_HEADER.into()), + (DisplayRow(2), DIAGNOSTIC_HEADER.into()), ] ); assert_eq!( @@ -539,10 +539,10 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { assert_eq!( editor_blocks(&editor, cx), [ - (DisplayRow(0), "path header block".into()), - (DisplayRow(2), "diagnostic header".into()), - (DisplayRow(6), "collapsed context".into()), - (DisplayRow(7), "diagnostic header".into()), + (DisplayRow(0), FILE_HEADER.into()), + (DisplayRow(2), DIAGNOSTIC_HEADER.into()), + (DisplayRow(6), EXCERPT_HEADER.into()), + (DisplayRow(7), DIAGNOSTIC_HEADER.into()), ] ); assert_eq!( @@ -605,10 +605,10 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { assert_eq!( editor_blocks(&editor, cx), [ - (DisplayRow(0), "path header block".into()), - (DisplayRow(2), "diagnostic header".into()), - (DisplayRow(7), "collapsed context".into()), - (DisplayRow(8), "diagnostic header".into()), + (DisplayRow(0), FILE_HEADER.into()), + (DisplayRow(2), DIAGNOSTIC_HEADER.into()), + (DisplayRow(7), EXCERPT_HEADER.into()), + (DisplayRow(8), DIAGNOSTIC_HEADER.into()), ] ); assert_eq!( @@ -661,10 +661,10 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { assert_eq!( editor_blocks(&editor, cx), [ - (DisplayRow(0), "path header block".into()), - (DisplayRow(2), "diagnostic header".into()), - (DisplayRow(7), "collapsed context".into()), - (DisplayRow(8), "diagnostic header".into()), + (DisplayRow(0), FILE_HEADER.into()), + (DisplayRow(2), DIAGNOSTIC_HEADER.into()), + (DisplayRow(7), EXCERPT_HEADER.into()), + (DisplayRow(8), DIAGNOSTIC_HEADER.into()), ] ); assert_eq!( @@ -958,6 +958,10 @@ fn random_diagnostic( } } +const FILE_HEADER: &'static str = "file header"; +const EXCERPT_HEADER: &'static str = "excerpt header"; +const EXCERPT_FOOTER: &'static str = "excerpt footer"; + fn editor_blocks( editor: &View, cx: &mut VisualTestContext, @@ -969,10 +973,10 @@ fn editor_blocks( blocks.extend( snapshot .blocks_in_range(DisplayRow(0)..snapshot.max_point().row()) - .enumerate() - .filter_map(|(ix, (row, block))| { + .filter_map(|(row, block)| { + let block_id = block.id(); let name: SharedString = match block { - TransformBlock::Custom(block) => { + Block::Custom(block) => { let mut element = block.render(&mut BlockContext { context: cx, anchor_x: px(0.), @@ -980,7 +984,7 @@ fn editor_blocks( line_height: px(0.), em_width: px(0.), max_width: px(0.), - block_id: ix, + block_id, editor_style: &editor::EditorStyle::default(), }); let element = element.downcast_mut::>().unwrap(); @@ -992,15 +996,16 @@ fn editor_blocks( .ok()? } - TransformBlock::ExcerptHeader { + Block::ExcerptHeader { starts_new_buffer, .. } => { if *starts_new_buffer { - "path header block".into() + FILE_HEADER.into() } else { - "collapsed context".into() + EXCERPT_HEADER.into() } } + Block::ExcerptFooter { .. } => EXCERPT_FOOTER.into(), }; Some((row, name)) diff --git a/crates/diagnostics/src/grouped_diagnostics.rs b/crates/diagnostics/src/grouped_diagnostics.rs new file mode 100644 index 00000000000000..8947447392a230 --- /dev/null +++ b/crates/diagnostics/src/grouped_diagnostics.rs @@ -0,0 +1,1405 @@ +use anyhow::Result; +use collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use editor::{ + diagnostic_block_renderer, + display_map::{ + BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, CustomBlockId, + RenderBlock, + }, + scroll::Autoscroll, + Bias, Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, ToPoint, +}; +use futures::{ + channel::mpsc::{self, UnboundedSender}, + StreamExt as _, +}; +use gpui::{ + actions, div, AnyElement, AnyView, AppContext, Context, EventEmitter, FocusHandle, + FocusableView, InteractiveElement, IntoElement, Model, ParentElement, Render, SharedString, + Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext, +}; +use language::{ + Buffer, BufferSnapshot, DiagnosticEntry, DiagnosticSeverity, OffsetRangeExt, ToOffset, + ToPoint as _, +}; +use lsp::LanguageServerId; +use multi_buffer::{build_excerpt_ranges, ExpandExcerptDirection, MultiBufferRow}; +use project::{DiagnosticSummary, Project, ProjectPath}; +use settings::Settings; +use std::{ + any::{Any, TypeId}, + cmp::Ordering, + ops::Range, + sync::{ + atomic::{self, AtomicBool}, + Arc, + }, +}; +use theme::ActiveTheme; +use ui::{h_flex, prelude::*, Icon, IconName, Label}; +use util::{debug_panic, ResultExt}; +use workspace::{ + item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams}, + ItemNavHistory, ToolbarItemLocation, Workspace, +}; + +use crate::project_diagnostics_settings::ProjectDiagnosticsSettings; +actions!(grouped_diagnostics, [Deploy, ToggleWarnings]); + +pub fn init(cx: &mut AppContext) { + cx.observe_new_views(GroupedDiagnosticsEditor::register) + .detach(); +} + +pub struct GroupedDiagnosticsEditor { + pub project: Model, + workspace: WeakView, + focus_handle: FocusHandle, + editor: View, + summary: DiagnosticSummary, + excerpts: Model, + path_states: Vec, + pub paths_to_update: BTreeSet<(ProjectPath, LanguageServerId)>, + pub include_warnings: bool, + context: u32, + pub update_paths_tx: UnboundedSender<(ProjectPath, Option)>, + _update_excerpts_task: Task>, + _subscription: Subscription, +} + +struct PathState { + path: ProjectPath, + first_excerpt_id: Option, + last_excerpt_id: Option, + diagnostics: Vec<(DiagnosticData, CustomBlockId)>, +} + +#[derive(Debug, Clone)] +struct DiagnosticData { + language_server_id: LanguageServerId, + is_primary: bool, + entry: DiagnosticEntry, +} + +impl DiagnosticData { + fn diagnostic_entries_equal(&self, other: &DiagnosticData) -> bool { + self.language_server_id == other.language_server_id + && self.is_primary == other.is_primary + && self.entry.range == other.entry.range + && equal_without_group_ids(&self.entry.diagnostic, &other.entry.diagnostic) + } +} + +// `group_id` can differ between LSP server diagnostics output, +// hence ignore it when checking diagnostics for updates. +fn equal_without_group_ids(a: &language::Diagnostic, b: &language::Diagnostic) -> bool { + a.source == b.source + && a.code == b.code + && a.severity == b.severity + && a.message == b.message + && a.is_primary == b.is_primary + && a.is_disk_based == b.is_disk_based + && a.is_unnecessary == b.is_unnecessary +} + +impl EventEmitter for GroupedDiagnosticsEditor {} + +impl Render for GroupedDiagnosticsEditor { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let child = if self.path_states.is_empty() { + div() + .bg(cx.theme().colors().editor_background) + .flex() + .items_center() + .justify_center() + .size_full() + .child(Label::new("No problems in workspace")) + } else { + div().size_full().child(self.editor.clone()) + }; + + div() + .track_focus(&self.focus_handle) + .when(self.path_states.is_empty(), |el| { + el.key_context("EmptyPane") + }) + .size_full() + .on_action(cx.listener(Self::toggle_warnings)) + .child(child) + } +} + +impl GroupedDiagnosticsEditor { + fn register(workspace: &mut Workspace, _: &mut ViewContext) { + workspace.register_action(Self::deploy); + } + + fn new_with_context( + context: u32, + project_handle: Model, + workspace: WeakView, + cx: &mut ViewContext, + ) -> Self { + let project_event_subscription = + cx.subscribe(&project_handle, |this, project, event, cx| match event { + project::Event::DiskBasedDiagnosticsStarted { .. } => { + cx.notify(); + } + project::Event::DiskBasedDiagnosticsFinished { language_server_id } => { + log::debug!("disk based diagnostics finished for server {language_server_id}"); + this.enqueue_update_stale_excerpts(Some(*language_server_id)); + } + project::Event::DiagnosticsUpdated { + language_server_id, + path, + } => { + this.paths_to_update + .insert((path.clone(), *language_server_id)); + this.summary = project.read(cx).diagnostic_summary(false, cx); + cx.emit(EditorEvent::TitleChanged); + + if this.editor.focus_handle(cx).contains_focused(cx) || this.focus_handle.contains_focused(cx) { + log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. recording change"); + } else { + log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. updating excerpts"); + this.enqueue_update_stale_excerpts(Some(*language_server_id)); + } + } + _ => {} + }); + + let focus_handle = cx.focus_handle(); + cx.on_focus_in(&focus_handle, |this, cx| this.focus_in(cx)) + .detach(); + cx.on_focus_out(&focus_handle, |this, _event, cx| this.focus_out(cx)) + .detach(); + + let excerpts = cx.new_model(|cx| { + MultiBuffer::new( + project_handle.read(cx).replica_id(), + project_handle.read(cx).capability(), + ) + }); + let editor = cx.new_view(|cx| { + let mut editor = + Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), false, cx); + editor.set_vertical_scroll_margin(5, cx); + editor + }); + cx.subscribe(&editor, |this, _editor, event: &EditorEvent, cx| { + cx.emit(event.clone()); + match event { + EditorEvent::Focused => { + if this.path_states.is_empty() { + cx.focus(&this.focus_handle); + } + } + EditorEvent::Blurred => this.enqueue_update_stale_excerpts(None), + _ => {} + } + }) + .detach(); + + let (update_excerpts_tx, mut update_excerpts_rx) = mpsc::unbounded(); + + let project = project_handle.read(cx); + let mut this = Self { + project: project_handle.clone(), + context, + summary: project.diagnostic_summary(false, cx), + workspace, + excerpts, + focus_handle, + editor, + path_states: Vec::new(), + paths_to_update: BTreeSet::new(), + include_warnings: ProjectDiagnosticsSettings::get_global(cx).include_warnings, + update_paths_tx: update_excerpts_tx, + _update_excerpts_task: cx.spawn(move |this, mut cx| async move { + while let Some((path, language_server_id)) = update_excerpts_rx.next().await { + if let Some(buffer) = project_handle + .update(&mut cx, |project, cx| project.open_buffer(path.clone(), cx))? + .await + .log_err() + { + this.update(&mut cx, |this, cx| { + this.update_excerpts(path, language_server_id, buffer, cx); + })?; + } + } + anyhow::Ok(()) + }), + _subscription: project_event_subscription, + }; + this.enqueue_update_all_excerpts(cx); + this + } + + fn new( + project_handle: Model, + workspace: WeakView, + cx: &mut ViewContext, + ) -> Self { + Self::new_with_context( + editor::DEFAULT_MULTIBUFFER_CONTEXT, + project_handle, + workspace, + cx, + ) + } + + fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext) { + if let Some(existing) = workspace.item_of_type::(cx) { + workspace.activate_item(&existing, true, true, cx); + } else { + let workspace_handle = cx.view().downgrade(); + let diagnostics = cx.new_view(|cx| { + GroupedDiagnosticsEditor::new(workspace.project().clone(), workspace_handle, cx) + }); + workspace.add_item_to_active_pane(Box::new(diagnostics), None, true, cx); + } + } + + pub fn toggle_warnings(&mut self, _: &ToggleWarnings, cx: &mut ViewContext) { + self.include_warnings = !self.include_warnings; + self.enqueue_update_all_excerpts(cx); + cx.notify(); + } + + fn focus_in(&mut self, cx: &mut ViewContext) { + if self.focus_handle.is_focused(cx) && !self.path_states.is_empty() { + self.editor.focus_handle(cx).focus(cx) + } + } + + fn focus_out(&mut self, cx: &mut ViewContext) { + if !self.focus_handle.is_focused(cx) && !self.editor.focus_handle(cx).is_focused(cx) { + self.enqueue_update_stale_excerpts(None); + } + } + + /// Enqueue an update of all excerpts. Updates all paths that either + /// currently have diagnostics or are currently present in this view. + fn enqueue_update_all_excerpts(&mut self, cx: &mut ViewContext) { + self.project.update(cx, |project, cx| { + let mut paths = project + .diagnostic_summaries(false, cx) + .map(|(path, _, _)| path) + .collect::>(); + paths.extend(self.path_states.iter().map(|state| state.path.clone())); + for path in paths { + self.update_paths_tx.unbounded_send((path, None)).unwrap(); + } + }); + } + + /// Enqueue an update of the excerpts for any path whose diagnostics are known + /// to have changed. If a language server id is passed, then only the excerpts for + /// that language server's diagnostics will be updated. Otherwise, all stale excerpts + /// will be refreshed. + pub fn enqueue_update_stale_excerpts(&mut self, language_server_id: Option) { + for (path, server_id) in &self.paths_to_update { + if language_server_id.map_or(true, |id| id == *server_id) { + self.update_paths_tx + .unbounded_send((path.clone(), Some(*server_id))) + .unwrap(); + } + } + } + + fn update_excerpts( + &mut self, + path_to_update: ProjectPath, + server_to_update: Option, + buffer: Model, + cx: &mut ViewContext, + ) { + self.paths_to_update.retain(|(path, server_id)| { + *path != path_to_update + || server_to_update.map_or(false, |to_update| *server_id != to_update) + }); + + // TODO change selections as in the old panel, to the next primary diagnostics + // TODO make [shift-]f8 to work, jump to the next block group + let _was_empty = self.path_states.is_empty(); + let path_ix = match self.path_states.binary_search_by(|probe| { + project::compare_paths((&probe.path.path, true), (&path_to_update.path, true)) + }) { + Ok(ix) => ix, + Err(ix) => { + self.path_states.insert( + ix, + PathState { + path: path_to_update.clone(), + diagnostics: Vec::new(), + last_excerpt_id: None, + first_excerpt_id: None, + }, + ); + ix + } + }; + + let max_severity = if self.include_warnings { + DiagnosticSeverity::WARNING + } else { + DiagnosticSeverity::ERROR + }; + + let excerpt_borders = self.excerpt_borders_for_path(path_ix); + let path_state = &mut self.path_states[path_ix]; + let buffer_snapshot = buffer.read(cx).snapshot(); + + let mut path_update = PathUpdate::new( + excerpt_borders, + &buffer_snapshot, + server_to_update, + max_severity, + path_state, + ); + path_update.prepare_excerpt_data( + self.context, + self.excerpts.read(cx).snapshot(cx), + buffer.read(cx).snapshot(), + path_state.diagnostics.iter(), + ); + self.excerpts.update(cx, |multi_buffer, cx| { + path_update.apply_excerpt_changes( + path_state, + self.context, + buffer_snapshot, + multi_buffer, + buffer, + cx, + ); + }); + + let new_multi_buffer_snapshot = self.excerpts.read(cx).snapshot(cx); + let blocks_to_insert = + path_update.prepare_blocks_to_insert(self.editor.clone(), new_multi_buffer_snapshot); + + let new_block_ids = self.editor.update(cx, |editor, cx| { + editor.remove_blocks(std::mem::take(&mut path_update.blocks_to_remove), None, cx); + editor.insert_blocks(blocks_to_insert, Some(Autoscroll::fit()), cx) + }); + path_state.diagnostics = path_update.new_blocks(new_block_ids); + + if self.path_states.is_empty() { + if self.editor.focus_handle(cx).is_focused(cx) { + cx.focus(&self.focus_handle); + } + } else if self.focus_handle.is_focused(cx) { + let focus_handle = self.editor.focus_handle(cx); + cx.focus(&focus_handle); + } + + #[cfg(test)] + self.check_invariants(cx); + + cx.notify(); + } + + fn excerpt_borders_for_path(&self, path_ix: usize) -> (Option, Option) { + let previous_path_state_ix = + Some(path_ix.saturating_sub(1)).filter(|&previous_path_ix| previous_path_ix != path_ix); + let next_path_state_ix = path_ix + 1; + let start = previous_path_state_ix.and_then(|i| { + self.path_states[..=i] + .iter() + .rev() + .find_map(|state| state.last_excerpt_id) + }); + let end = self.path_states[next_path_state_ix..] + .iter() + .find_map(|state| state.first_excerpt_id); + (start, end) + } + + #[cfg(test)] + fn check_invariants(&self, cx: &mut ViewContext) { + let mut excerpts = Vec::new(); + for (id, buffer, _) in self.excerpts.read(cx).snapshot(cx).excerpts() { + if let Some(file) = buffer.file() { + excerpts.push((id, file.path().clone())); + } + } + + let mut prev_path = None; + for (_, path) in &excerpts { + if let Some(prev_path) = prev_path { + if path < prev_path { + panic!("excerpts are not sorted by path {:?}", excerpts); + } + } + prev_path = Some(path); + } + } +} + +impl FocusableView for GroupedDiagnosticsEditor { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Item for GroupedDiagnosticsEditor { + type Event = EditorEvent; + + fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn deactivated(&mut self, cx: &mut ViewContext) { + self.editor.update(cx, |editor, cx| editor.deactivated(cx)); + } + + fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { + self.editor + .update(cx, |editor, cx| editor.navigate(data, cx)) + } + + fn tab_tooltip_text(&self, _: &AppContext) -> Option { + Some("Project Diagnostics".into()) + } + + fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement { + if self.summary.error_count == 0 && self.summary.warning_count == 0 { + Label::new("No problems") + .color(params.text_color()) + .into_any_element() + } else { + h_flex() + .gap_1() + .when(self.summary.error_count > 0, |then| { + then.child( + h_flex() + .gap_1() + .child(Icon::new(IconName::XCircle).color(Color::Error)) + .child( + Label::new(self.summary.error_count.to_string()) + .color(params.text_color()), + ), + ) + }) + .when(self.summary.warning_count > 0, |then| { + then.child( + h_flex() + .gap_1() + .child(Icon::new(IconName::ExclamationTriangle).color(Color::Warning)) + .child( + Label::new(self.summary.warning_count.to_string()) + .color(params.text_color()), + ), + ) + }) + .into_any_element() + } + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("project diagnostics") + } + + fn for_each_project_item( + &self, + cx: &AppContext, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item), + ) { + self.editor.for_each_project_item(cx, f) + } + + fn is_singleton(&self, _: &AppContext) -> bool { + false + } + + fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext) { + self.editor.update(cx, |editor, _| { + editor.set_nav_history(Some(nav_history)); + }); + } + + fn clone_on_split( + &self, + _workspace_id: Option, + cx: &mut ViewContext, + ) -> Option> + where + Self: Sized, + { + Some(cx.new_view(|cx| { + GroupedDiagnosticsEditor::new(self.project.clone(), self.workspace.clone(), cx) + })) + } + + fn is_dirty(&self, cx: &AppContext) -> bool { + self.excerpts.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &AppContext) -> bool { + self.excerpts.read(cx).has_conflict(cx) + } + + fn can_save(&self, _: &AppContext) -> bool { + true + } + + fn save( + &mut self, + format: bool, + project: Model, + cx: &mut ViewContext, + ) -> Task> { + self.editor.save(format, project, cx) + } + + fn save_as( + &mut self, + _: Model, + _: ProjectPath, + _: &mut ViewContext, + ) -> Task> { + unreachable!() + } + + fn reload(&mut self, project: Model, cx: &mut ViewContext) -> Task> { + self.editor.reload(project, cx) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a View, + _: &'a AppContext, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.to_any()) + } else if type_id == TypeId::of::() { + Some(self.editor.to_any()) + } else { + None + } + } + + fn breadcrumb_location(&self) -> ToolbarItemLocation { + ToolbarItemLocation::PrimaryLeft + } + + fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option> { + self.editor.breadcrumbs(theme, cx) + } + + fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { + self.editor + .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx)); + } +} + +fn compare_data_locations( + old: &DiagnosticData, + new: &DiagnosticData, + snapshot: &BufferSnapshot, +) -> Ordering { + compare_diagnostics(&old.entry, &new.entry, snapshot) + .then_with(|| old.language_server_id.cmp(&new.language_server_id)) +} + +fn compare_diagnostics( + old: &DiagnosticEntry, + new: &DiagnosticEntry, + snapshot: &BufferSnapshot, +) -> Ordering { + compare_diagnostic_ranges(&old.range, &new.range, snapshot) + .then_with(|| old.diagnostic.message.cmp(&new.diagnostic.message)) +} + +fn compare_diagnostic_ranges( + old: &Range, + new: &Range, + snapshot: &BufferSnapshot, +) -> Ordering { + // The diagnostics may point to a previously open Buffer for this file. + if !old.start.is_valid(snapshot) || !new.start.is_valid(snapshot) { + return Ordering::Greater; + } + + old.start + .to_offset(snapshot) + .cmp(&new.start.to_offset(snapshot)) + .then_with(|| { + old.end + .to_offset(snapshot) + .cmp(&new.end.to_offset(snapshot)) + }) +} + +// TODO wrong? What to do here instead? +fn compare_diagnostic_range_edges( + old: &Range, + new: &Range, + snapshot: &BufferSnapshot, +) -> (Ordering, Ordering) { + // The diagnostics may point to a previously open Buffer for this file. + let start_cmp = match (old.start.is_valid(snapshot), new.start.is_valid(snapshot)) { + (false, false) => old.start.offset.cmp(&new.start.offset), + (false, true) => Ordering::Greater, + (true, false) => Ordering::Less, + (true, true) => old.start.cmp(&new.start, snapshot), + }; + + let end_cmp = old + .end + .to_offset(snapshot) + .cmp(&new.end.to_offset(snapshot)); + (start_cmp, end_cmp) +} + +#[derive(Debug)] +struct PathUpdate { + path_excerpts_borders: (Option, Option), + latest_excerpt_id: ExcerptId, + new_diagnostics: Vec<(DiagnosticData, Option)>, + diagnostics_by_row_label: BTreeMap)>, + blocks_to_remove: HashSet, + unchanged_blocks: HashMap, + excerpts_with_new_diagnostics: HashSet, + excerpts_to_remove: Vec, + excerpt_expands: HashMap<(ExpandExcerptDirection, u32), Vec>, + excerpts_to_add: HashMap>>, + first_excerpt_id: Option, + last_excerpt_id: Option, +} + +impl PathUpdate { + fn new( + path_excerpts_borders: (Option, Option), + buffer_snapshot: &BufferSnapshot, + server_to_update: Option, + max_severity: DiagnosticSeverity, + path_state: &PathState, + ) -> Self { + let mut blocks_to_remove = HashSet::default(); + let mut removed_groups = HashSet::default(); + let mut new_diagnostics = path_state + .diagnostics + .iter() + .filter(|(diagnostic_data, _)| { + server_to_update.map_or(true, |server_id| { + diagnostic_data.language_server_id != server_id + }) + }) + .filter(|(diagnostic_data, block_id)| { + let diagnostic = &diagnostic_data.entry.diagnostic; + let retain = !diagnostic.is_primary || diagnostic.severity <= max_severity; + if !retain { + removed_groups.insert(diagnostic.group_id); + blocks_to_remove.insert(*block_id); + } + retain + }) + .map(|(diagnostic, block_id)| (diagnostic.clone(), Some(*block_id))) + .collect::>(); + new_diagnostics.retain(|(diagnostic_data, block_id)| { + let retain = !removed_groups.contains(&diagnostic_data.entry.diagnostic.group_id); + if !retain { + if let Some(block_id) = block_id { + blocks_to_remove.insert(*block_id); + } + } + retain + }); + for (server_id, group) in buffer_snapshot + .diagnostic_groups(server_to_update) + .into_iter() + .filter(|(_, group)| { + group.entries[group.primary_ix].diagnostic.severity <= max_severity + }) + { + for (diagnostic_index, diagnostic) in group.entries.iter().enumerate() { + let new_data = DiagnosticData { + language_server_id: server_id, + is_primary: diagnostic_index == group.primary_ix, + entry: diagnostic.clone(), + }; + let (Ok(i) | Err(i)) = new_diagnostics.binary_search_by(|probe| { + compare_data_locations(&probe.0, &new_data, &buffer_snapshot) + }); + new_diagnostics.insert(i, (new_data, None)); + } + } + + let latest_excerpt_id = path_excerpts_borders.0.unwrap_or_else(|| ExcerptId::min()); + Self { + latest_excerpt_id, + path_excerpts_borders, + new_diagnostics, + blocks_to_remove, + diagnostics_by_row_label: BTreeMap::new(), + excerpts_to_remove: Vec::new(), + excerpts_with_new_diagnostics: HashSet::default(), + unchanged_blocks: HashMap::default(), + excerpts_to_add: HashMap::default(), + excerpt_expands: HashMap::default(), + first_excerpt_id: None, + last_excerpt_id: None, + } + } + + fn prepare_excerpt_data<'a>( + &'a mut self, + context: u32, + multi_buffer_snapshot: MultiBufferSnapshot, + buffer_snapshot: BufferSnapshot, + current_diagnostics: impl Iterator + 'a, + ) { + let mut current_diagnostics = current_diagnostics.fuse().peekable(); + let mut excerpts_to_expand = + HashMap::>::default(); + let mut current_excerpts = path_state_excerpts( + self.path_excerpts_borders.0, + self.path_excerpts_borders.1, + &multi_buffer_snapshot, + ) + .fuse() + .peekable(); + + for (diagnostic_index, (new_diagnostic, existing_block)) in + self.new_diagnostics.iter().enumerate() + { + if let Some(existing_block) = existing_block { + self.unchanged_blocks + .insert(diagnostic_index, *existing_block); + } + + loop { + match current_excerpts.peek() { + None => { + let excerpt_ranges = self + .excerpts_to_add + .entry(self.latest_excerpt_id) + .or_default(); + let new_range = new_diagnostic.entry.range.clone(); + let (Ok(i) | Err(i)) = excerpt_ranges.binary_search_by(|probe| { + compare_diagnostic_ranges(probe, &new_range, &buffer_snapshot) + }); + excerpt_ranges.insert(i, new_range); + break; + } + Some((current_excerpt_id, _, current_excerpt_range)) => { + match compare_diagnostic_range_edges( + ¤t_excerpt_range.context, + &new_diagnostic.entry.range, + &buffer_snapshot, + ) { + /* + new_s new_e + ----[---->><<----]-- + cur_s cur_e + */ + ( + Ordering::Less | Ordering::Equal, + Ordering::Greater | Ordering::Equal, + ) => { + self.excerpts_with_new_diagnostics + .insert(*current_excerpt_id); + if self.first_excerpt_id.is_none() { + self.first_excerpt_id = Some(*current_excerpt_id); + } + self.last_excerpt_id = Some(*current_excerpt_id); + break; + } + /* + cur_s cur_e + ---->>>>>[--]<<<<<-- + new_s new_e + */ + ( + Ordering::Greater | Ordering::Equal, + Ordering::Less | Ordering::Equal, + ) => { + let expand_up = current_excerpt_range + .context + .start + .to_point(&buffer_snapshot) + .row + .saturating_sub( + new_diagnostic + .entry + .range + .start + .to_point(&buffer_snapshot) + .row, + ); + let expand_down = new_diagnostic + .entry + .range + .end + .to_point(&buffer_snapshot) + .row + .saturating_sub( + current_excerpt_range + .context + .end + .to_point(&buffer_snapshot) + .row, + ); + let expand_value = excerpts_to_expand + .entry(*current_excerpt_id) + .or_default() + .entry(ExpandExcerptDirection::UpAndDown) + .or_default(); + *expand_value = (*expand_value).max(expand_up).max(expand_down); + self.excerpts_with_new_diagnostics + .insert(*current_excerpt_id); + if self.first_excerpt_id.is_none() { + self.first_excerpt_id = Some(*current_excerpt_id); + } + self.last_excerpt_id = Some(*current_excerpt_id); + break; + } + /* + new_s new_e + > < + ----[---->>>]<<<<<-- + cur_s cur_e + + or + new_s new_e + > < + ----[----]-->>><<<-- + cur_s cur_e + */ + (Ordering::Less, Ordering::Less) => { + if current_excerpt_range + .context + .end + .cmp(&new_diagnostic.entry.range.start, &buffer_snapshot) + .is_ge() + { + let expand_down = new_diagnostic + .entry + .range + .end + .to_point(&buffer_snapshot) + .row + .saturating_sub( + current_excerpt_range + .context + .end + .to_point(&buffer_snapshot) + .row, + ); + let expand_value = excerpts_to_expand + .entry(*current_excerpt_id) + .or_default() + .entry(ExpandExcerptDirection::Down) + .or_default(); + *expand_value = (*expand_value).max(expand_down); + self.excerpts_with_new_diagnostics + .insert(*current_excerpt_id); + if self.first_excerpt_id.is_none() { + self.first_excerpt_id = Some(*current_excerpt_id); + } + self.last_excerpt_id = Some(*current_excerpt_id); + break; + } else if !self + .excerpts_with_new_diagnostics + .contains(current_excerpt_id) + { + self.excerpts_to_remove.push(*current_excerpt_id); + } + } + /* + cur_s cur_e + ---->>>>>[<<<<----]-- + > < + new_s new_e + + or + cur_s cur_e + ---->>><<<--[----]-- + > < + new_s new_e + */ + (Ordering::Greater, Ordering::Greater) => { + if current_excerpt_range + .context + .start + .cmp(&new_diagnostic.entry.range.end, &buffer_snapshot) + .is_le() + { + let expand_up = current_excerpt_range + .context + .start + .to_point(&buffer_snapshot) + .row + .saturating_sub( + new_diagnostic + .entry + .range + .start + .to_point(&buffer_snapshot) + .row, + ); + let expand_value = excerpts_to_expand + .entry(*current_excerpt_id) + .or_default() + .entry(ExpandExcerptDirection::Up) + .or_default(); + *expand_value = (*expand_value).max(expand_up); + self.excerpts_with_new_diagnostics + .insert(*current_excerpt_id); + if self.first_excerpt_id.is_none() { + self.first_excerpt_id = Some(*current_excerpt_id); + } + self.last_excerpt_id = Some(*current_excerpt_id); + break; + } else { + let excerpt_ranges = self + .excerpts_to_add + .entry(self.latest_excerpt_id) + .or_default(); + let new_range = new_diagnostic.entry.range.clone(); + let (Ok(i) | Err(i)) = + excerpt_ranges.binary_search_by(|probe| { + compare_diagnostic_ranges( + probe, + &new_range, + &buffer_snapshot, + ) + }); + excerpt_ranges.insert(i, new_range); + break; + } + } + } + if let Some((next_id, ..)) = current_excerpts.next() { + self.latest_excerpt_id = next_id; + } + } + } + } + + loop { + match current_diagnostics.peek() { + None => break, + Some((current_diagnostic, current_block)) => { + match compare_data_locations( + current_diagnostic, + new_diagnostic, + &buffer_snapshot, + ) { + Ordering::Less => { + self.blocks_to_remove.insert(*current_block); + } + Ordering::Equal => { + if current_diagnostic.diagnostic_entries_equal(&new_diagnostic) { + self.unchanged_blocks + .insert(diagnostic_index, *current_block); + } else { + self.blocks_to_remove.insert(*current_block); + } + let _ = current_diagnostics.next(); + break; + } + Ordering::Greater => break, + } + let _ = current_diagnostics.next(); + } + } + } + } + + self.excerpts_to_remove.retain(|excerpt_id| { + !self.excerpts_with_new_diagnostics.contains(excerpt_id) + && !excerpts_to_expand.contains_key(excerpt_id) + }); + self.excerpts_to_remove.extend( + current_excerpts + .filter(|(excerpt_id, ..)| { + !self.excerpts_with_new_diagnostics.contains(excerpt_id) + && !excerpts_to_expand.contains_key(excerpt_id) + }) + .map(|(excerpt_id, ..)| excerpt_id), + ); + let mut excerpt_expands = HashMap::default(); + for (excerpt_id, directions) in excerpts_to_expand { + let excerpt_expand = if directions.len() > 1 { + Some(( + ExpandExcerptDirection::UpAndDown, + directions + .values() + .max() + .copied() + .unwrap_or_default() + .max(context), + )) + } else { + directions + .into_iter() + .next() + .map(|(direction, expand)| (direction, expand.max(context))) + }; + if let Some(expand) = excerpt_expand { + excerpt_expands + .entry(expand) + .or_insert_with(|| Vec::new()) + .push(excerpt_id); + } + } + self.blocks_to_remove + .extend(current_diagnostics.map(|(_, block_id)| block_id)); + } + + fn apply_excerpt_changes( + &mut self, + path_state: &mut PathState, + context: u32, + buffer_snapshot: BufferSnapshot, + multi_buffer: &mut MultiBuffer, + buffer: Model, + cx: &mut gpui::ModelContext, + ) { + let max_point = buffer_snapshot.max_point(); + for (after_excerpt_id, ranges) in std::mem::take(&mut self.excerpts_to_add) { + let ranges = ranges + .into_iter() + .map(|range| { + let mut extended_point_range = range.to_point(&buffer_snapshot); + extended_point_range.start.row = + extended_point_range.start.row.saturating_sub(context); + extended_point_range.start.column = 0; + extended_point_range.end.row = + (extended_point_range.end.row + context).min(max_point.row); + extended_point_range.end.column = u32::MAX; + let extended_start = + buffer_snapshot.clip_point(extended_point_range.start, Bias::Left); + let extended_end = + buffer_snapshot.clip_point(extended_point_range.end, Bias::Right); + extended_start..extended_end + }) + .collect::>(); + let (joined_ranges, _) = build_excerpt_ranges(&buffer_snapshot, &ranges, context); + let excerpts = multi_buffer.insert_excerpts_after( + after_excerpt_id, + buffer.clone(), + joined_ranges, + cx, + ); + if self.first_excerpt_id.is_none() { + self.first_excerpt_id = excerpts.first().copied(); + } + self.last_excerpt_id = excerpts.last().copied(); + } + for ((direction, line_count), excerpts) in std::mem::take(&mut self.excerpt_expands) { + multi_buffer.expand_excerpts(excerpts, line_count, direction, cx); + } + multi_buffer.remove_excerpts(std::mem::take(&mut self.excerpts_to_remove), cx); + path_state.first_excerpt_id = self.first_excerpt_id; + path_state.last_excerpt_id = self.last_excerpt_id; + } + + fn prepare_blocks_to_insert( + &mut self, + editor: View, + multi_buffer_snapshot: MultiBufferSnapshot, + ) -> Vec> { + let mut updated_excerpts = path_state_excerpts( + self.path_excerpts_borders.0, + self.path_excerpts_borders.1, + &multi_buffer_snapshot, + ) + .fuse() + .peekable(); + let mut used_labels = BTreeMap::new(); + self.diagnostics_by_row_label = self.new_diagnostics.iter().enumerate().fold( + BTreeMap::new(), + |mut diagnostics_by_row_label, (diagnostic_index, (diagnostic, existing_block))| { + let new_diagnostic = &diagnostic.entry; + let block_position = new_diagnostic.range.start; + let excerpt_id = loop { + match updated_excerpts.peek() { + None => break None, + Some((excerpt_id, excerpt_buffer_snapshot, excerpt_range)) => { + let excerpt_range = &excerpt_range.context; + match block_position.cmp(&excerpt_range.start, excerpt_buffer_snapshot) + { + Ordering::Less => break None, + Ordering::Equal | Ordering::Greater => match block_position + .cmp(&excerpt_range.end, excerpt_buffer_snapshot) + { + Ordering::Equal | Ordering::Less => break Some(*excerpt_id), + Ordering::Greater => { + let _ = updated_excerpts.next(); + } + }, + } + } + } + }; + + let Some(position_in_multi_buffer) = excerpt_id.and_then(|excerpt_id| { + multi_buffer_snapshot.anchor_in_excerpt(excerpt_id, block_position) + }) else { + return diagnostics_by_row_label; + }; + + let multi_buffer_row = MultiBufferRow( + position_in_multi_buffer + .to_point(&multi_buffer_snapshot) + .row, + ); + + let grouped_diagnostics = &mut diagnostics_by_row_label + .entry(multi_buffer_row) + .or_insert_with(|| (position_in_multi_buffer, Vec::new())) + .1; + let new_label = used_labels + .entry(multi_buffer_row) + .or_insert_with(|| HashSet::default()) + .insert(( + new_diagnostic.diagnostic.source.as_deref(), + new_diagnostic.diagnostic.message.as_str(), + )); + + if !new_label || !grouped_diagnostics.is_empty() { + if let Some(existing_block) = existing_block { + self.blocks_to_remove.insert(*existing_block); + } + if let Some(block_id) = self.unchanged_blocks.remove(&diagnostic_index) { + self.blocks_to_remove.insert(block_id); + } + } + if new_label { + let (Ok(i) | Err(i)) = grouped_diagnostics.binary_search_by(|&probe| { + let a = &self.new_diagnostics[probe].0.entry.diagnostic; + let b = &self.new_diagnostics[diagnostic_index].0.entry.diagnostic; + a.group_id + .cmp(&b.group_id) + .then_with(|| a.is_primary.cmp(&b.is_primary).reverse()) + .then_with(|| a.severity.cmp(&b.severity)) + }); + grouped_diagnostics.insert(i, diagnostic_index); + } + + diagnostics_by_row_label + }, + ); + + self.diagnostics_by_row_label + .values() + .filter_map(|(earliest_in_row_position, diagnostics_at_line)| { + let earliest_in_row_position = *earliest_in_row_position; + match diagnostics_at_line.len() { + 0 => None, + len => { + if len == 1 { + let i = diagnostics_at_line.first().copied()?; + if self.unchanged_blocks.contains_key(&i) { + return None; + } + } + let lines_in_first_message = diagnostic_text_lines( + &self + .new_diagnostics + .get(diagnostics_at_line.first().copied()?)? + .0 + .entry + .diagnostic, + ); + let folded_block_height = lines_in_first_message.clamp(1, 2); + let diagnostics_to_render = Arc::new( + diagnostics_at_line + .iter() + .filter_map(|&index| self.new_diagnostics.get(index)) + .map(|(diagnostic_data, _)| { + diagnostic_data.entry.diagnostic.clone() + }) + .collect::>(), + ); + Some(BlockProperties { + position: earliest_in_row_position, + height: folded_block_height, + style: BlockStyle::Sticky, + render: render_same_line_diagnostics( + Arc::new(AtomicBool::new(false)), + diagnostics_to_render, + editor.clone(), + folded_block_height, + ), + disposition: BlockDisposition::Above, + }) + } + } + }) + .collect() + } + + fn new_blocks( + mut self, + new_block_ids: Vec, + ) -> Vec<(DiagnosticData, CustomBlockId)> { + let mut new_block_ids = new_block_ids.into_iter().fuse(); + for (_, (_, grouped_diagnostics)) in self.diagnostics_by_row_label { + let mut created_block_id = None; + match grouped_diagnostics.len() { + 0 => { + debug_panic!("Unexpected empty diagnostics group"); + continue; + } + 1 => { + let index = grouped_diagnostics[0]; + if let Some(&block_id) = self.unchanged_blocks.get(&index) { + self.new_diagnostics[index].1 = Some(block_id); + } else { + let Some(block_id) = + created_block_id.get_or_insert_with(|| new_block_ids.next()) + else { + debug_panic!("Expected a new block for each new diagnostic"); + continue; + }; + self.new_diagnostics[index].1 = Some(*block_id); + } + } + _ => { + let Some(block_id) = + created_block_id.get_or_insert_with(|| new_block_ids.next()) + else { + debug_panic!("Expected a new block for each new diagnostic group"); + continue; + }; + for i in grouped_diagnostics { + self.new_diagnostics[i].1 = Some(*block_id); + } + } + } + } + + self.new_diagnostics + .into_iter() + .filter_map(|(diagnostic, block_id)| Some((diagnostic, block_id?))) + .collect() + } +} + +fn render_same_line_diagnostics( + expanded: Arc, + diagnostics: Arc>, + editor_handle: View, + folded_block_height: u8, +) -> RenderBlock { + Box::new(move |cx: &mut BlockContext| { + let block_id = match cx.block_id { + BlockId::Custom(block_id) => block_id, + _ => { + debug_panic!("Expected a block id for the diagnostics block"); + return div().into_any_element(); + } + }; + let Some(first_diagnostic) = diagnostics.first() else { + debug_panic!("Expected at least one diagnostic"); + return div().into_any_element(); + }; + let button_expanded = expanded.clone(); + let expanded = expanded.load(atomic::Ordering::Acquire); + let expand_label = if expanded { '-' } else { '+' }; + let first_diagnostics_height = diagnostic_text_lines(first_diagnostic); + let extra_diagnostics = diagnostics.len() - 1; + let toggle_expand_label = + if folded_block_height == first_diagnostics_height && extra_diagnostics == 0 { + None + } else if extra_diagnostics > 0 { + Some(format!("{expand_label}{extra_diagnostics}")) + } else { + Some(expand_label.to_string()) + }; + + let expanded_block_height = diagnostics + .iter() + .map(|diagnostic| diagnostic_text_lines(diagnostic)) + .sum::(); + let editor_handle = editor_handle.clone(); + let parent = h_flex() + .items_start() + .child(v_flex().size_full().when_some_else( + toggle_expand_label, + |parent, label| { + parent.child(Button::new(cx.block_id, label).on_click({ + let diagnostics = Arc::clone(&diagnostics); + move |_, cx| { + let new_expanded = !expanded; + button_expanded.store(new_expanded, atomic::Ordering::Release); + let new_size = if new_expanded { + expanded_block_height + } else { + folded_block_height + }; + editor_handle.update(cx, |editor, cx| { + editor.replace_blocks( + HashMap::from_iter(Some(( + block_id, + ( + Some(new_size), + render_same_line_diagnostics( + Arc::clone(&button_expanded), + Arc::clone(&diagnostics), + editor_handle.clone(), + folded_block_height, + ), + ), + ))), + None, + cx, + ) + }); + } + })) + }, + |parent| { + parent.child( + h_flex() + .size(IconSize::default().rems()) + .invisible() + .flex_none(), + ) + }, + )); + let max_message_rows = if expanded { + None + } else { + Some(folded_block_height) + }; + let mut renderer = + diagnostic_block_renderer(first_diagnostic.clone(), max_message_rows, false, true); + let mut diagnostics_element = v_flex(); + diagnostics_element = diagnostics_element.child(renderer(cx)); + if expanded { + for diagnostic in diagnostics.iter().skip(1) { + let mut renderer = diagnostic_block_renderer(diagnostic.clone(), None, false, true); + diagnostics_element = diagnostics_element.child(renderer(cx)); + } + } + parent.child(diagnostics_element).into_any_element() + }) +} + +fn diagnostic_text_lines(diagnostic: &language::Diagnostic) -> u8 { + diagnostic.message.matches('\n').count() as u8 + 1 +} + +fn path_state_excerpts( + after_excerpt_id: Option, + before_excerpt_id: Option, + multi_buffer_snapshot: &editor::MultiBufferSnapshot, +) -> impl Iterator)> { + multi_buffer_snapshot + .excerpts() + .skip_while(move |&(excerpt_id, ..)| match after_excerpt_id { + Some(after_excerpt_id) => after_excerpt_id != excerpt_id, + None => false, + }) + .filter(move |&(excerpt_id, ..)| after_excerpt_id != Some(excerpt_id)) + .take_while(move |&(excerpt_id, ..)| match before_excerpt_id { + Some(before_excerpt_id) => before_excerpt_id != excerpt_id, + None => true, + }) +} diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 715da22ef1052f..80b31b999c653b 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -1,9 +1,7 @@ -use std::time::Duration; - use editor::Editor; use gpui::{ - percentage, rems, Animation, AnimationExt, EventEmitter, IntoElement, ParentElement, Render, - Styled, Subscription, Transformation, View, ViewContext, WeakView, + rems, EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription, View, + ViewContext, WeakView, }; use language::Diagnostic; use ui::{h_flex, prelude::*, Button, ButtonLike, Color, Icon, IconName, Label, Tooltip}; @@ -61,42 +59,7 @@ impl Render for DiagnosticIndicator { .child(Label::new(warning_count.to_string()).size(LabelSize::Small)), }; - let has_in_progress_checks = self - .workspace - .upgrade() - .and_then(|workspace| { - workspace - .read(cx) - .project() - .read(cx) - .language_servers_running_disk_based_diagnostics() - .next() - }) - .is_some(); - - let status = if has_in_progress_checks { - Some( - h_flex() - .gap_2() - .child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::Small) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate(percentage(delta))) - }, - ), - ) - .child( - Label::new("Checking…") - .size(LabelSize::Small) - .into_any_element(), - ) - .into_any_element(), - ) - } else if let Some(diagnostic) = &self.current_diagnostic { + let status = if let Some(diagnostic) = &self.current_diagnostic { let message = diagnostic.message.split('\n').next().unwrap().to_string(); Some( Button::new("diagnostic_message", message) diff --git a/crates/diagnostics/src/toolbar_controls.rs b/crates/diagnostics/src/toolbar_controls.rs index 7f4deba73e404a..909eb77c2258cb 100644 --- a/crates/diagnostics/src/toolbar_controls.rs +++ b/crates/diagnostics/src/toolbar_controls.rs @@ -1,11 +1,12 @@ -use crate::ProjectDiagnosticsEditor; -use gpui::{EventEmitter, ParentElement, Render, ViewContext, WeakView}; +use crate::{grouped_diagnostics::GroupedDiagnosticsEditor, ProjectDiagnosticsEditor}; +use futures::future::Either; +use gpui::{EventEmitter, ParentElement, Render, View, ViewContext, WeakView}; use ui::prelude::*; use ui::{IconButton, IconName, Tooltip}; use workspace::{item::ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView}; pub struct ToolbarControls { - editor: Option>, + editor: Option, WeakView>>, } impl Render for ToolbarControls { @@ -14,18 +15,33 @@ impl Render for ToolbarControls { let mut has_stale_excerpts = false; let mut is_updating = false; - if let Some(editor) = self.editor.as_ref().and_then(|editor| editor.upgrade()) { - let editor = editor.read(cx); - - include_warnings = editor.include_warnings; - has_stale_excerpts = !editor.paths_to_update.is_empty(); - is_updating = editor.update_paths_tx.len() > 0 - || editor - .project - .read(cx) - .language_servers_running_disk_based_diagnostics() - .next() - .is_some(); + if let Some(editor) = self.editor() { + match editor { + Either::Left(editor) => { + let editor = editor.read(cx); + include_warnings = editor.include_warnings; + has_stale_excerpts = !editor.paths_to_update.is_empty(); + is_updating = editor.update_paths_tx.len() > 0 + || editor + .project + .read(cx) + .language_servers_running_disk_based_diagnostics() + .next() + .is_some(); + } + Either::Right(editor) => { + let editor = editor.read(cx); + include_warnings = editor.include_warnings; + has_stale_excerpts = !editor.paths_to_update.is_empty(); + is_updating = editor.update_paths_tx.len() > 0 + || editor + .project + .read(cx) + .language_servers_running_disk_based_diagnostics() + .next() + .is_some(); + } + } } let tooltip = if include_warnings { @@ -42,12 +58,19 @@ impl Render for ToolbarControls { .disabled(is_updating) .tooltip(move |cx| Tooltip::text("Update excerpts", cx)) .on_click(cx.listener(|this, _, cx| { - if let Some(editor) = - this.editor.as_ref().and_then(|editor| editor.upgrade()) - { - editor.update(cx, |editor, _| { - editor.enqueue_update_stale_excerpts(None); - }); + if let Some(editor) = this.editor() { + match editor { + Either::Left(editor) => { + editor.update(cx, |editor, _| { + editor.enqueue_update_stale_excerpts(None); + }); + } + Either::Right(editor) => { + editor.update(cx, |editor, _| { + editor.enqueue_update_stale_excerpts(None); + }); + } + } } })), ) @@ -56,12 +79,19 @@ impl Render for ToolbarControls { IconButton::new("toggle-warnings", IconName::ExclamationTriangle) .tooltip(move |cx| Tooltip::text(tooltip, cx)) .on_click(cx.listener(|this, _, cx| { - if let Some(editor) = - this.editor.as_ref().and_then(|editor| editor.upgrade()) - { - editor.update(cx, |editor, cx| { - editor.toggle_warnings(&Default::default(), cx); - }); + if let Some(editor) = this.editor() { + match editor { + Either::Left(editor) => { + editor.update(cx, |editor, cx| { + editor.toggle_warnings(&Default::default(), cx); + }); + } + Either::Right(editor) => { + editor.update(cx, |editor, cx| { + editor.toggle_warnings(&Default::default(), cx); + }); + } + } } })), ) @@ -78,7 +108,10 @@ impl ToolbarItemView for ToolbarControls { ) -> ToolbarItemLocation { if let Some(pane_item) = active_pane_item.as_ref() { if let Some(editor) = pane_item.downcast::() { - self.editor = Some(editor.downgrade()); + self.editor = Some(Either::Left(editor.downgrade())); + ToolbarItemLocation::PrimaryRight + } else if let Some(editor) = pane_item.downcast::() { + self.editor = Some(Either::Right(editor.downgrade())); ToolbarItemLocation::PrimaryRight } else { ToolbarItemLocation::Hidden @@ -93,4 +126,13 @@ impl ToolbarControls { pub fn new() -> Self { ToolbarControls { editor: None } } + + fn editor( + &self, + ) -> Option, View>> { + Some(match self.editor.as_ref()? { + Either::Left(diagnostics) => Either::Left(diagnostics.upgrade()?), + Either::Right(grouped_diagnostics) => Either::Right(grouped_diagnostics.upgrade()?), + }) + } } diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 864567d353956b..dae32ff5825ac0 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -30,12 +30,15 @@ test-support = [ [dependencies] aho-corasick = "1.1" anyhow.workspace = true +assets.workspace = true +chrono.workspace = true client.workspace = true clock.workspace = true collections.workspace = true convert_case = "0.6.0" db.workspace = true emojis.workspace = true +file_icons.workspace = true futures.workspace = true fuzzy.workspace = true git.workspace = true @@ -48,6 +51,7 @@ lazy_static.workspace = true linkify.workspace = true log.workspace = true lsp.workspace = true +markdown.workspace = true multi_buffer.workspace = true ordered-float.workspace = true parking_lot.workspace = true diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 1c44ccc6f31cf2..d6b3403da72fe4 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -1,5 +1,6 @@ //! This module contains all actions supported by [`Editor`]. use super::*; +use gpui::action_as; use util::serde::default_true; #[derive(PartialEq, Clone, Deserialize, Default)] @@ -41,7 +42,7 @@ pub struct MovePageDown { #[derive(PartialEq, Clone, Deserialize, Default)] pub struct MoveToEndOfLine { #[serde(default = "default_true")] - pub(super) stop_at_soft_wraps: bool, + pub stop_at_soft_wraps: bool, } #[derive(PartialEq, Clone, Deserialize, Default)] @@ -114,12 +115,31 @@ pub struct ExpandExcerpts { pub(super) lines: u32, } +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct ExpandExcerptsUp { + #[serde(default)] + pub(super) lines: u32, +} + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct ExpandExcerptsDown { + #[serde(default)] + pub(super) lines: u32, +} +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct ShowCompletions { + #[serde(default)] + pub(super) trigger: Option, +} + impl_actions!( editor, [ ConfirmCodeAction, ConfirmCompletion, ExpandExcerpts, + ExpandExcerptsUp, + ExpandExcerptsDown, FoldAt, MoveDownByLines, MovePageDown, @@ -133,6 +153,7 @@ impl_actions!( SelectToBeginningOfLine, SelectToEndOfLine, SelectUpByLines, + ShowCompletions, ToggleCodeActions, ToggleComments, UnfoldAt, @@ -149,6 +170,7 @@ gpui::actions!( AddSelectionBelow, Backspace, Cancel, + CancelLanguageServerWork, ConfirmRename, ContextMenuFirst, ContextMenuLast, @@ -236,7 +258,7 @@ gpui::actions!( RedoSelection, Rename, RestartLanguageServer, - RevealInFinder, + RevealInFileManager, ReverseLines, RevertSelectedHunks, ScrollCursorBottom, @@ -246,6 +268,7 @@ gpui::actions!( SelectAllMatches, SelectDown, SelectLargerSyntaxNode, + SelectEnclosingSymbol, SelectLeft, SelectLine, SelectRight, @@ -259,22 +282,27 @@ gpui::actions!( SelectToPreviousWordStart, SelectToStartOfParagraph, SelectUp, + SelectPageDown, + SelectPageUp, ShowCharacterPalette, - ShowCompletions, ShowInlineCompletion, + ShowSignatureHelp, ShuffleLines, SortLinesCaseInsensitive, SortLinesCaseSensitive, SplitSelectionIntoLines, Tab, TabPrev, + ToggleAutoSignatureHelp, ToggleGitBlame, ToggleGitBlameInline, + ToggleSelectionMenu, ToggleHunkDiff, ToggleInlayHints, ToggleLineNumbers, ToggleIndentGuides, ToggleSoftWrap, + ToggleTabBar, Transpose, Undo, UndoSelection, @@ -283,3 +311,7 @@ gpui::actions!( UniqueLinesCaseSensitive, ] ); + +action_as!(outline, ToggleOutline as Toggle); + +action_as!(go_to_line, ToggleGoToLine as Toggle); diff --git a/crates/editor/src/blame_entry_tooltip.rs b/crates/editor/src/blame_entry_tooltip.rs index 7864338550d31e..dd1b3a3ea552c5 100644 --- a/crates/editor/src/blame_entry_tooltip.rs +++ b/crates/editor/src/blame_entry_tooltip.rs @@ -8,6 +8,7 @@ use gpui::{ use settings::Settings; use std::hash::Hash; use theme::{ActiveTheme, ThemeSettings}; +use time::UtcOffset; use ui::{ div, h_flex, tooltip_container, v_flex, Avatar, Button, ButtonStyle, Clickable as _, Color, FluentBuilder, Icon, IconName, IconPosition, InteractiveElement as _, IntoElement, @@ -129,7 +130,7 @@ impl Render for BlameEntryTooltip { let author_email = self.blame_entry.author_mail.clone(); let short_commit_id = self.blame_entry.sha.display_short(); - let absolute_timestamp = blame_entry_absolute_timestamp(&self.blame_entry, cx); + let absolute_timestamp = blame_entry_absolute_timestamp(&self.blame_entry); let message = self .details @@ -247,30 +248,25 @@ impl Render for BlameEntryTooltip { } } -fn blame_entry_timestamp( - blame_entry: &BlameEntry, - format: time_format::TimestampFormat, - cx: &WindowContext, -) -> String { +fn blame_entry_timestamp(blame_entry: &BlameEntry, format: time_format::TimestampFormat) -> String { match blame_entry.author_offset_date_time() { - Ok(timestamp) => time_format::format_localized_timestamp( - timestamp, - time::OffsetDateTime::now_utc(), - cx.local_timezone(), - format, - ), + Ok(timestamp) => { + let local = chrono::Local::now().offset().local_minus_utc(); + time_format::format_localized_timestamp( + timestamp, + time::OffsetDateTime::now_utc(), + UtcOffset::from_whole_seconds(local).unwrap(), + format, + ) + } Err(_) => "Error parsing date".to_string(), } } -pub fn blame_entry_relative_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String { - blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative, cx) +pub fn blame_entry_relative_timestamp(blame_entry: &BlameEntry) -> String { + blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative) } -fn blame_entry_absolute_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String { - blame_entry_timestamp( - blame_entry, - time_format::TimestampFormat::MediumAbsolute, - cx, - ) +fn blame_entry_absolute_timestamp(blame_entry: &BlameEntry) -> String { + blame_entry_timestamp(blame_entry, time_format::TimestampFormat::MediumAbsolute) } diff --git a/crates/editor/src/debounced_delay.rs b/crates/editor/src/debounced_delay.rs index b9d8ebf1037aea..0dbf36d49e38aa 100644 --- a/crates/editor/src/debounced_delay.rs +++ b/crates/editor/src/debounced_delay.rs @@ -29,13 +29,9 @@ impl DebouncedDelay { let (sender, mut receiver) = oneshot::channel::<()>(); self.cancel_channel = Some(sender); - let previous_task = self.task.take(); + drop(self.task.take()); self.task = Some(cx.spawn(move |model, mut cx| async move { let mut timer = cx.background_executor().timer(delay).fuse(); - if let Some(previous_task) = previous_task { - previous_task.await; - } - futures::select_biased! { _ = receiver => return, _ = timer => {} diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 8f1355a5c796ea..d8ae49bf90c248 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -18,7 +18,7 @@ //! [EditorElement]: crate::element::EditorElement mod block_map; -mod flap_map; +mod crease_map; mod fold_map; mod inlay_map; mod tab_map; @@ -28,12 +28,12 @@ use crate::{ hover_links::InlayHighlight, movement::TextLayoutDetails, EditorStyle, InlayId, RowExt, }; pub use block_map::{ - BlockBufferRows, BlockChunks as DisplayChunks, BlockContext, BlockDisposition, BlockId, - BlockMap, BlockPoint, BlockProperties, BlockStyle, RenderBlock, TransformBlock, + Block, BlockBufferRows, BlockChunks as DisplayChunks, BlockContext, BlockDisposition, BlockId, + BlockMap, BlockPoint, BlockProperties, BlockStyle, CustomBlockId, RenderBlock, }; use block_map::{BlockRow, BlockSnapshot}; use collections::{HashMap, HashSet}; -pub use flap_map::*; +pub use crease_map::*; pub use fold_map::{Fold, FoldId, FoldPlaceholder, FoldPoint}; use fold_map::{FoldMap, FoldSnapshot}; use gpui::{ @@ -52,8 +52,14 @@ use multi_buffer::{ ToOffset, ToPoint, }; use serde::Deserialize; -use std::ops::Add; -use std::{any::TypeId, borrow::Cow, fmt::Debug, num::NonZeroU32, ops::Range, sync::Arc}; +use std::{ + any::TypeId, + borrow::Cow, + fmt::Debug, + num::NonZeroU32, + ops::{Add, Range, Sub}, + sync::Arc, +}; use sum_tree::{Bias, TreeMap}; use tab_map::{TabMap, TabSnapshot}; use text::LineIndent; @@ -100,7 +106,7 @@ pub struct DisplayMap { /// Regions of inlays that should be highlighted. inlay_highlights: InlayHighlights, /// A container for explicitly foldable ranges, which supersede indentation based fold range suggestions. - flap_map: FlapMap, + crease_map: CreaseMap, fold_placeholder: FoldPlaceholder, pub clip_at_line_ends: bool, } @@ -112,8 +118,10 @@ impl DisplayMap { font: Font, font_size: Pixels, wrap_width: Option, + show_excerpt_controls: bool, buffer_header_height: u8, excerpt_header_height: u8, + excerpt_footer_height: u8, fold_placeholder: FoldPlaceholder, cx: &mut ModelContext, ) -> Self { @@ -124,8 +132,15 @@ impl DisplayMap { let (fold_map, snapshot) = FoldMap::new(snapshot); let (tab_map, snapshot) = TabMap::new(snapshot, tab_size); let (wrap_map, snapshot) = WrapMap::new(snapshot, font, font_size, wrap_width, cx); - let block_map = BlockMap::new(snapshot, buffer_header_height, excerpt_header_height); - let flap_map = FlapMap::default(); + let block_map = BlockMap::new( + snapshot, + show_excerpt_controls, + buffer_header_height, + excerpt_header_height, + excerpt_footer_height, + ); + let crease_map = CreaseMap::default(); + cx.observe(&wrap_map, |_, _, cx| cx.notify()).detach(); DisplayMap { @@ -136,7 +151,7 @@ impl DisplayMap { tab_map, wrap_map, block_map, - flap_map, + crease_map, fold_placeholder, text_highlights: Default::default(), inlay_highlights: Default::default(), @@ -154,7 +169,7 @@ impl DisplayMap { let (wrap_snapshot, edits) = self .wrap_map .update(cx, |map, cx| map.sync(tab_snapshot.clone(), edits, cx)); - let block_snapshot = self.block_map.read(wrap_snapshot.clone(), edits); + let block_snapshot = self.block_map.read(wrap_snapshot.clone(), edits).snapshot; DisplaySnapshot { buffer_snapshot: self.buffer.read(cx).snapshot(cx), @@ -163,7 +178,7 @@ impl DisplayMap { tab_snapshot, wrap_snapshot, block_snapshot, - flap_snapshot: self.flap_map.snapshot(), + crease_snapshot: self.crease_map.snapshot(), text_highlights: self.text_highlights.clone(), inlay_highlights: self.inlay_highlights.clone(), clip_at_line_ends: self.clip_at_line_ends, @@ -232,29 +247,29 @@ impl DisplayMap { self.block_map.read(snapshot, edits); } - pub fn insert_flaps( + pub fn insert_creases( &mut self, - flaps: impl IntoIterator, + creases: impl IntoIterator, cx: &mut ModelContext, - ) -> Vec { + ) -> Vec { let snapshot = self.buffer.read(cx).snapshot(cx); - self.flap_map.insert(flaps, &snapshot) + self.crease_map.insert(creases, &snapshot) } - pub fn remove_flaps( + pub fn remove_creases( &mut self, - flap_ids: impl IntoIterator, + crease_ids: impl IntoIterator, cx: &mut ModelContext, ) { let snapshot = self.buffer.read(cx).snapshot(cx); - self.flap_map.remove(flap_ids, &snapshot) + self.crease_map.remove(crease_ids, &snapshot) } pub fn insert_blocks( &mut self, blocks: impl IntoIterator>, cx: &mut ModelContext, - ) -> Vec { + ) -> Vec { let snapshot = self.buffer.read(cx).snapshot(cx); let edits = self.buffer_subscription.consume().into_inner(); let tab_size = Self::tab_size(&self.buffer, cx); @@ -268,11 +283,58 @@ impl DisplayMap { block_map.insert(blocks) } - pub fn replace_blocks(&mut self, styles: HashMap) { - self.block_map.replace(styles); + pub fn replace_blocks( + &mut self, + heights_and_renderers: HashMap, RenderBlock)>, + cx: &mut ModelContext, + ) { + // + // Note: previous implementation of `replace_blocks` simply called + // `self.block_map.replace(styles)` which just modified the render by replacing + // the `RenderBlock` with the new one. + // + // ```rust + // for block in &self.blocks { + // if let Some(render) = renderers.remove(&block.id) { + // *block.render.lock() = render; + // } + // } + // ``` + // + // If height changes however, we need to update the tree. There's a performance + // cost to this, so we'll split the replace blocks into handling the old behavior + // directly and the new behavior separately. + // + // + let mut only_renderers = HashMap::::default(); + let mut full_replace = HashMap::::default(); + for (id, (height, render)) in heights_and_renderers { + if let Some(height) = height { + full_replace.insert(id, (height, render)); + } else { + only_renderers.insert(id, render); + } + } + self.block_map.replace_renderers(only_renderers); + + if full_replace.is_empty() { + return; + } + + let snapshot = self.buffer.read(cx).snapshot(cx); + let edits = self.buffer_subscription.consume().into_inner(); + let tab_size = Self::tab_size(&self.buffer, cx); + let (snapshot, edits) = self.inlay_map.sync(snapshot, edits); + let (snapshot, edits) = self.fold_map.read(snapshot, edits); + let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size); + let (snapshot, edits) = self + .wrap_map + .update(cx, |map, cx| map.sync(snapshot, edits, cx)); + let mut block_map = self.block_map.write(snapshot, edits); + block_map.replace(full_replace); } - pub fn remove_blocks(&mut self, ids: HashSet, cx: &mut ModelContext) { + pub fn remove_blocks(&mut self, ids: HashSet, cx: &mut ModelContext) { let snapshot = self.buffer.read(cx).snapshot(cx); let edits = self.buffer_subscription.consume().into_inner(); let tab_size = Self::tab_size(&self.buffer, cx); @@ -286,6 +348,25 @@ impl DisplayMap { block_map.remove(ids); } + pub fn row_for_block( + &mut self, + block_id: CustomBlockId, + cx: &mut ModelContext, + ) -> Option { + let snapshot = self.buffer.read(cx).snapshot(cx); + let edits = self.buffer_subscription.consume().into_inner(); + let tab_size = Self::tab_size(&self.buffer, cx); + let (snapshot, edits) = self.inlay_map.sync(snapshot, edits); + let (snapshot, edits) = self.fold_map.read(snapshot, edits); + let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size); + let (snapshot, edits) = self + .wrap_map + .update(cx, |map, cx| map.sync(snapshot, edits, cx)); + let block_map = self.block_map.read(snapshot, edits); + let block_row = block_map.row_for_block(block_id)?; + Some(DisplayRow(block_row.0)) + } + pub fn highlight_text( &mut self, type_id: TypeId, @@ -380,6 +461,10 @@ impl DisplayMap { pub fn is_rewrapping(&self, cx: &gpui::AppContext) -> bool { self.wrap_map.read(cx).is_rewrapping() } + + pub fn show_excerpt_controls(&self) -> bool { + self.block_map.show_excerpt_controls() + } } #[derive(Debug, Default)] @@ -406,7 +491,7 @@ pub struct HighlightedChunk<'a> { pub struct DisplaySnapshot { pub buffer_snapshot: MultiBufferSnapshot, pub fold_snapshot: FoldSnapshot, - pub flap_snapshot: FlapSnapshot, + pub crease_snapshot: CreaseSnapshot, inlay_snapshot: InlaySnapshot, tab_snapshot: TabSnapshot, wrap_snapshot: WrapSnapshot, @@ -635,8 +720,7 @@ impl DisplaySnapshot { if let Some(severity) = chunk.diagnostic_severity { // Omit underlines for HINT/INFO diagnostics on 'unnecessary' code. if severity <= DiagnosticSeverity::WARNING || !chunk.is_unnecessary { - let diagnostic_color = - super::diagnostic_style(severity, true, &editor_style.status); + let diagnostic_color = super::diagnostic_style(severity, &editor_style.status); diagnostic_highlight.underline = Some(UnderlineStyle { color: Some(diagnostic_color), thickness: 1.0.into(), @@ -801,12 +885,16 @@ impl DisplaySnapshot { pub fn blocks_in_range( &self, rows: Range, - ) -> impl Iterator { + ) -> impl Iterator { self.block_snapshot .blocks_in_range(rows.start.0..rows.end.0) .map(|(row, block)| (DisplayRow(row), block)) } + pub fn block_for_id(&self, id: BlockId) -> Option { + self.block_snapshot.block_for_id(id) + } + pub fn intersects_fold(&self, offset: T) -> bool { self.fold_snapshot.intersects_fold(offset) } @@ -872,16 +960,18 @@ impl DisplaySnapshot { return false; } - for next_row in (buffer_row.0 + 1)..=max_row.0 { - let next_line_indent = self.line_indent_for_buffer_row(MultiBufferRow(next_row)); - if next_line_indent.raw_len() > line_indent.raw_len() { - return true; - } else if !next_line_indent.is_line_blank() { - break; - } - } - - false + (buffer_row.0 + 1..=max_row.0) + .find_map(|next_row| { + let next_line_indent = self.line_indent_for_buffer_row(MultiBufferRow(next_row)); + if next_line_indent.raw_len() > line_indent.raw_len() { + Some(true) + } else if !next_line_indent.is_line_blank() { + Some(false) + } else { + None + } + }) + .unwrap_or(false) } pub fn foldable_range( @@ -889,13 +979,13 @@ impl DisplaySnapshot { buffer_row: MultiBufferRow, ) -> Option<(Range, FoldPlaceholder)> { let start = MultiBufferPoint::new(buffer_row.0, self.buffer_snapshot.line_len(buffer_row)); - if let Some(flap) = self - .flap_snapshot + if let Some(crease) = self + .crease_snapshot .query_row(buffer_row, &self.buffer_snapshot) { Some(( - flap.range.to_point(&self.buffer_snapshot), - flap.placeholder.clone(), + crease.range.to_point(&self.buffer_snapshot), + crease.placeholder.clone(), )) } else if self.starts_indent(MultiBufferRow(start.row)) && !self.is_line_folded(MultiBufferRow(start.row)) @@ -917,8 +1007,23 @@ impl DisplaySnapshot { break; } } - let end = end.unwrap_or(max_point); - Some((start..end, self.fold_placeholder.clone())) + + let mut row_before_line_breaks = end.unwrap_or(max_point); + while row_before_line_breaks.row > start.row + && self + .buffer_snapshot + .is_line_blank(MultiBufferRow(row_before_line_breaks.row)) + { + row_before_line_breaks.row -= 1; + } + + row_before_line_breaks = Point::new( + row_before_line_breaks.row, + self.buffer_snapshot + .line_len(MultiBufferRow(row_before_line_breaks.row)), + ); + + Some((start..row_before_line_breaks, self.fold_placeholder.clone())) } else { None } @@ -955,6 +1060,22 @@ impl Debug for DisplayPoint { } } +impl Add for DisplayPoint { + type Output = Self; + + fn add(self, other: Self) -> Self::Output { + DisplayPoint(BlockPoint(self.0 .0 + other.0 .0)) + } +} + +impl Sub for DisplayPoint { + type Output = Self; + + fn sub(self, other: Self) -> Self::Output { + DisplayPoint(BlockPoint(self.0 .0 - other.0 .0)) + } +} + #[derive(Debug, Copy, Clone, Default, Eq, Ord, PartialOrd, PartialEq, Deserialize, Hash)] #[serde(transparent)] pub struct DisplayRow(pub u32); @@ -967,6 +1088,14 @@ impl Add for DisplayRow { } } +impl Sub for DisplayRow { + type Output = Self; + + fn sub(self, other: Self) -> Self::Output { + DisplayRow(self.0 - other.0) + } +} + impl DisplayPoint { pub fn new(row: DisplayRow, column: u32) -> Self { Self(BlockPoint(Point::new(row.0, column))) @@ -1037,14 +1166,11 @@ impl ToDisplayPoint for Anchor { #[cfg(test)] pub mod tests { use super::*; - use crate::{ - movement, - test::{editor_test_context::EditorTestContext, marked_display_snapshot}, - }; + use crate::{movement, test::marked_display_snapshot}; use gpui::{div, font, observe, px, AppContext, BorrowAppContext, Context, Element, Hsla}; use language::{ language_settings::{AllLanguageSettings, AllLanguageSettingsContent}, - Buffer, Language, LanguageConfig, LanguageMatcher, SelectionGoal, + Buffer, Language, LanguageConfig, LanguageMatcher, }; use project::Project; use rand::{prelude::*, Rng}; @@ -1098,8 +1224,10 @@ pub mod tests { font("Helvetica"), font_size, wrap_width, + true, buffer_start_excerpt_header_height, excerpt_header_height, + 0, FoldPlaceholder::test(), cx, ) @@ -1317,6 +1445,7 @@ pub mod tests { } } + #[cfg(target_os = "macos")] #[gpui::test(retries = 5)] async fn test_soft_wraps(cx: &mut gpui::TestAppContext) { cx.background_executor @@ -1325,7 +1454,7 @@ pub mod tests { init_test(cx, |_| {}); }); - let mut cx = EditorTestContext::new(cx).await; + let mut cx = crate::test::editor_test_context::EditorTestContext::new(cx).await; let editor = cx.editor.clone(); let window = cx.window; @@ -1344,8 +1473,10 @@ pub mod tests { font("Helvetica"), font_size, wrap_width, + true, 1, 1, + 0, FoldPlaceholder::test(), cx, ) @@ -1379,39 +1510,39 @@ pub mod tests { movement::up( &snapshot, DisplayPoint::new(DisplayRow(1), 10), - SelectionGoal::None, + language::SelectionGoal::None, false, &text_layout_details, ), ( DisplayPoint::new(DisplayRow(0), 7), - SelectionGoal::HorizontalPosition(x.0) + language::SelectionGoal::HorizontalPosition(x.0) ) ); assert_eq!( movement::down( &snapshot, DisplayPoint::new(DisplayRow(0), 7), - SelectionGoal::HorizontalPosition(x.0), + language::SelectionGoal::HorizontalPosition(x.0), false, &text_layout_details ), ( DisplayPoint::new(DisplayRow(1), 10), - SelectionGoal::HorizontalPosition(x.0) + language::SelectionGoal::HorizontalPosition(x.0) ) ); assert_eq!( movement::down( &snapshot, DisplayPoint::new(DisplayRow(1), 10), - SelectionGoal::HorizontalPosition(x.0), + language::SelectionGoal::HorizontalPosition(x.0), false, &text_layout_details ), ( DisplayPoint::new(DisplayRow(2), 4), - SelectionGoal::HorizontalPosition(x.0) + language::SelectionGoal::HorizontalPosition(x.0) ) ); @@ -1453,8 +1584,10 @@ pub mod tests { font("Helvetica"), font_size, None, + true, 1, 1, + 0, FoldPlaceholder::test(), cx, ) @@ -1549,6 +1682,8 @@ pub mod tests { font("Helvetica"), font_size, None, + true, + 1, 1, 1, FoldPlaceholder::test(), @@ -1597,6 +1732,8 @@ pub mod tests { ); } + // todo(linux) fails due to pixel differences in text rendering + #[cfg(target_os = "macos")] #[gpui::test] async fn test_chunks_with_soft_wrapping(cx: &mut gpui::TestAppContext) { use unindent::Unindent as _; @@ -1650,8 +1787,10 @@ pub mod tests { font("Courier"), font_size, Some(px(40.0)), + true, 1, 1, + 0, FoldPlaceholder::test(), cx, ) @@ -1732,6 +1871,8 @@ pub mod tests { font("Courier"), font_size, None, + true, + 1, 1, 1, FoldPlaceholder::test(), @@ -1844,7 +1985,7 @@ pub mod tests { } #[gpui::test] - fn test_flaps(cx: &mut gpui::AppContext) { + fn test_creases(cx: &mut gpui::AppContext) { init_test(cx, |_| {}); let text = "aaa\nbbb\nccc\nddd\neee\nfff\nggg\nhhh\niii\njjj\nkkk\nlll"; @@ -1856,8 +1997,10 @@ pub mod tests { font("Helvetica"), font_size, None, + true, 1, 1, + 0, FoldPlaceholder::test(), cx, ); @@ -1865,8 +2008,8 @@ pub mod tests { let range = snapshot.anchor_before(Point::new(2, 0))..snapshot.anchor_after(Point::new(3, 3)); - map.flap_map.insert( - [Flap::new( + map.crease_map.insert( + [Crease::new( range, FoldPlaceholder::test(), |_row, _status, _toggle, _cx| div(), @@ -1893,8 +2036,10 @@ pub mod tests { font("Helvetica"), font_size, None, + true, 1, 1, + 0, FoldPlaceholder::test(), cx, ) @@ -1968,8 +2113,10 @@ pub mod tests { font("Helvetica"), font_size, None, + true, 1, 1, + 0, FoldPlaceholder::test(), cx, ) diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 5d534e6017d8e5..630b96d50ca96e 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -4,7 +4,7 @@ use super::{ }; use crate::{EditorStyle, GutterDimensions}; use collections::{Bound, HashMap, HashSet}; -use gpui::{AnyElement, Pixels, WindowContext}; +use gpui::{AnyElement, EntityId, Pixels, WindowContext}; use language::{BufferSnapshot, Chunk, Patch, Point}; use multi_buffer::{Anchor, ExcerptId, ExcerptRange, MultiBufferRow, ToPoint as _}; use parking_lot::Mutex; @@ -12,14 +12,15 @@ use std::{ cell::RefCell, cmp::{self, Ordering}, fmt::Debug, - ops::{Deref, DerefMut, Range}, + ops::{Deref, DerefMut, Range, RangeBounds}, sync::{ atomic::{AtomicUsize, Ordering::SeqCst}, Arc, }, }; -use sum_tree::{Bias, SumTree}; +use sum_tree::{Bias, SumTree, TreeMap}; use text::Edit; +use ui::ElementId; const NEWLINES: &[u8] = &[b'\n'; u8::MAX as usize]; @@ -29,10 +30,18 @@ const NEWLINES: &[u8] = &[b'\n'; u8::MAX as usize]; pub struct BlockMap { next_block_id: AtomicUsize, wrap_snapshot: RefCell, - blocks: Vec>, + custom_blocks: Vec>, + custom_blocks_by_id: TreeMap>, transforms: RefCell>, + show_excerpt_controls: bool, buffer_header_height: u8, excerpt_header_height: u8, + excerpt_footer_height: u8, +} + +pub struct BlockMapReader<'a> { + blocks: &'a Vec>, + pub snapshot: BlockSnapshot, } pub struct BlockMapWriter<'a>(&'a mut BlockMap); @@ -41,10 +50,17 @@ pub struct BlockMapWriter<'a>(&'a mut BlockMap); pub struct BlockSnapshot { wrap_snapshot: WrapSnapshot, transforms: SumTree, + custom_blocks_by_id: TreeMap>, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct BlockId(usize); +pub struct CustomBlockId(usize); + +impl Into for CustomBlockId { + fn into(self) -> ElementId { + ElementId::Integer(self.0) + } +} #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)] pub struct BlockPoint(pub Point); @@ -55,10 +71,10 @@ pub struct BlockRow(pub(super) u32); #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)] struct WrapRow(u32); -pub type RenderBlock = Box AnyElement>; +pub type RenderBlock = Box AnyElement>; -pub struct Block { - id: BlockId, +pub struct CustomBlock { + id: CustomBlockId, position: Anchor, height: u8, style: BlockStyle, @@ -70,11 +86,22 @@ pub struct BlockProperties

{ pub position: P, pub height: u8, pub style: BlockStyle, - pub render: Box AnyElement>, + pub render: RenderBlock, pub disposition: BlockDisposition, } -#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] +impl Debug for BlockProperties

{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BlockProperties") + .field("position", &self.position) + .field("height", &self.height) + .field("style", &self.style) + .field("disposition", &self.disposition) + .finish() + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] pub enum BlockStyle { Fixed, Flex, @@ -88,10 +115,48 @@ pub struct BlockContext<'a, 'b> { pub gutter_dimensions: &'b GutterDimensions, pub em_width: Pixels, pub line_height: Pixels, - pub block_id: usize, + pub block_id: BlockId, pub editor_style: &'b EditorStyle, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BlockId { + Custom(CustomBlockId), + ExcerptHeader(ExcerptId), + ExcerptFooter(ExcerptId), +} + +impl From for EntityId { + fn from(value: BlockId) -> Self { + match value { + BlockId::Custom(CustomBlockId(id)) => EntityId::from(id as u64), + BlockId::ExcerptHeader(id) => id.into(), + BlockId::ExcerptFooter(id) => id.into(), + } + } +} + +impl Into for BlockId { + fn into(self) -> ElementId { + match self { + Self::Custom(CustomBlockId(id)) => ("Block", id).into(), + Self::ExcerptHeader(id) => ("ExcerptHeader", EntityId::from(id)).into(), + Self::ExcerptFooter(id) => ("ExcerptFooter", EntityId::from(id)).into(), + } + } +} + +impl std::fmt::Display for BlockId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Custom(id) => write!(f, "Block({id:?})"), + Self::ExcerptHeader(id) => write!(f, "ExcerptHeader({id:?})"), + Self::ExcerptFooter(id) => write!(f, "ExcerptFooter({id:?})"), + } + } +} + +/// Whether the block should be considered above or below the anchor line #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum BlockDisposition { Above, @@ -101,45 +166,108 @@ pub enum BlockDisposition { #[derive(Clone, Debug)] struct Transform { summary: TransformSummary, - block: Option, + block: Option, +} + +pub(crate) enum BlockType { + Custom(CustomBlockId), + Header, + Footer, +} + +pub(crate) trait BlockLike { + fn block_type(&self) -> BlockType; + fn disposition(&self) -> BlockDisposition; } #[allow(clippy::large_enum_variant)] #[derive(Clone)] -pub enum TransformBlock { - Custom(Arc), +pub enum Block { + Custom(Arc), ExcerptHeader { id: ExcerptId, buffer: BufferSnapshot, range: ExcerptRange, height: u8, starts_new_buffer: bool, + show_excerpt_controls: bool, + }, + ExcerptFooter { + id: ExcerptId, + disposition: BlockDisposition, + height: u8, }, } -impl TransformBlock { +impl BlockLike for Block { + fn block_type(&self) -> BlockType { + match self { + Block::Custom(block) => BlockType::Custom(block.id), + Block::ExcerptHeader { .. } => BlockType::Header, + Block::ExcerptFooter { .. } => BlockType::Footer, + } + } + + fn disposition(&self) -> BlockDisposition { + self.disposition() + } +} + +impl Block { + pub fn id(&self) -> BlockId { + match self { + Block::Custom(block) => BlockId::Custom(block.id), + Block::ExcerptHeader { id, .. } => BlockId::ExcerptHeader(*id), + Block::ExcerptFooter { id, .. } => BlockId::ExcerptFooter(*id), + } + } + fn disposition(&self) -> BlockDisposition { match self { - TransformBlock::Custom(block) => block.disposition, - TransformBlock::ExcerptHeader { .. } => BlockDisposition::Above, + Block::Custom(block) => block.disposition, + Block::ExcerptHeader { .. } => BlockDisposition::Above, + Block::ExcerptFooter { disposition, .. } => *disposition, } } pub fn height(&self) -> u8 { match self { - TransformBlock::Custom(block) => block.height, - TransformBlock::ExcerptHeader { height, .. } => *height, + Block::Custom(block) => block.height, + Block::ExcerptHeader { height, .. } => *height, + Block::ExcerptFooter { height, .. } => *height, + } + } + + pub fn style(&self) -> BlockStyle { + match self { + Block::Custom(block) => block.style, + Block::ExcerptHeader { .. } => BlockStyle::Sticky, + Block::ExcerptFooter { .. } => BlockStyle::Sticky, } } } -impl Debug for TransformBlock { +impl Debug for Block { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Custom(block) => f.debug_struct("Custom").field("block", block).finish(), - Self::ExcerptHeader { buffer, .. } => f + Self::ExcerptHeader { + buffer, + starts_new_buffer, + id, + .. + } => f .debug_struct("ExcerptHeader") + .field("id", &id) .field("path", &buffer.file().map(|f| f.path())) + .field("starts_new_buffer", &starts_new_buffer) + .finish(), + Block::ExcerptFooter { + id, disposition, .. + } => f + .debug_struct("ExcerptFooter") + .field("id", &id) + .field("disposition", &disposition) .finish(), } } @@ -170,17 +298,22 @@ pub struct BlockBufferRows<'a> { impl BlockMap { pub fn new( wrap_snapshot: WrapSnapshot, + show_excerpt_controls: bool, buffer_header_height: u8, excerpt_header_height: u8, + excerpt_footer_height: u8, ) -> Self { let row_count = wrap_snapshot.max_point().row() + 1; let map = Self { next_block_id: AtomicUsize::new(0), - blocks: Vec::new(), + custom_blocks: Vec::new(), + custom_blocks_by_id: TreeMap::default(), transforms: RefCell::new(SumTree::from_item(Transform::isomorphic(row_count), &())), wrap_snapshot: RefCell::new(wrap_snapshot.clone()), + show_excerpt_controls, buffer_header_height, excerpt_header_height, + excerpt_footer_height, }; map.sync( &wrap_snapshot, @@ -192,12 +325,16 @@ impl BlockMap { map } - pub fn read(&self, wrap_snapshot: WrapSnapshot, edits: Patch) -> BlockSnapshot { + pub fn read(&self, wrap_snapshot: WrapSnapshot, edits: Patch) -> BlockMapReader { self.sync(&wrap_snapshot, edits); *self.wrap_snapshot.borrow_mut() = wrap_snapshot.clone(); - BlockSnapshot { - wrap_snapshot, - transforms: self.transforms.borrow().clone(), + BlockMapReader { + blocks: &self.custom_blocks, + snapshot: BlockSnapshot { + wrap_snapshot, + transforms: self.transforms.borrow().clone(), + custom_blocks_by_id: self.custom_blocks_by_id.clone(), + }, } } @@ -318,25 +455,26 @@ impl BlockMap { let new_buffer_start = wrap_snapshot.to_point(WrapPoint::new(new_start.0, 0), Bias::Left); let start_bound = Bound::Included(new_buffer_start); - let start_block_ix = match self.blocks[last_block_ix..].binary_search_by(|probe| { - probe - .position - .to_point(buffer) - .cmp(&new_buffer_start) - .then(Ordering::Greater) - }) { - Ok(ix) | Err(ix) => last_block_ix + ix, - }; + let start_block_ix = + match self.custom_blocks[last_block_ix..].binary_search_by(|probe| { + probe + .position + .to_point(buffer) + .cmp(&new_buffer_start) + .then(Ordering::Greater) + }) { + Ok(ix) | Err(ix) => last_block_ix + ix, + }; let end_bound; let end_block_ix = if new_end.0 > wrap_snapshot.max_point().row() { end_bound = Bound::Unbounded; - self.blocks.len() + self.custom_blocks.len() } else { let new_buffer_end = wrap_snapshot.to_point(WrapPoint::new(new_end.0, 0), Bias::Left); end_bound = Bound::Excluded(new_buffer_end); - match self.blocks[start_block_ix..].binary_search_by(|probe| { + match self.custom_blocks[start_block_ix..].binary_search_by(|probe| { probe .position .to_point(buffer) @@ -349,64 +487,33 @@ impl BlockMap { last_block_ix = end_block_ix; debug_assert!(blocks_in_edit.is_empty()); - blocks_in_edit.extend( - self.blocks[start_block_ix..end_block_ix] - .iter() - .map(|block| { - let mut position = block.position.to_point(buffer); - match block.disposition { - BlockDisposition::Above => position.column = 0, - BlockDisposition::Below => { - position.column = buffer.line_len(MultiBufferRow(position.row)) - } + blocks_in_edit.extend(self.custom_blocks[start_block_ix..end_block_ix].iter().map( + |block| { + let mut position = block.position.to_point(buffer); + match block.disposition { + BlockDisposition::Above => position.column = 0, + BlockDisposition::Below => { + position.column = buffer.line_len(MultiBufferRow(position.row)) } - let position = wrap_snapshot.make_wrap_point(position, Bias::Left); - (position.row(), TransformBlock::Custom(block.clone())) - }), - ); + } + let position = wrap_snapshot.make_wrap_point(position, Bias::Left); + (position.row(), Block::Custom(block.clone())) + }, + )); + if buffer.show_headers() { - blocks_in_edit.extend( - buffer - .excerpt_boundaries_in_range((start_bound, end_bound)) - .map(|excerpt_boundary| { - ( - wrap_snapshot - .make_wrap_point( - Point::new(excerpt_boundary.row.0, 0), - Bias::Left, - ) - .row(), - TransformBlock::ExcerptHeader { - id: excerpt_boundary.id, - buffer: excerpt_boundary.buffer, - range: excerpt_boundary.range, - height: if excerpt_boundary.starts_new_buffer { - self.buffer_header_height - } else { - self.excerpt_header_height - }, - starts_new_buffer: excerpt_boundary.starts_new_buffer, - }, - ) - }), - ); + blocks_in_edit.extend(BlockMap::header_and_footer_blocks( + self.show_excerpt_controls, + self.excerpt_footer_height, + self.buffer_header_height, + self.excerpt_header_height, + buffer, + (start_bound, end_bound), + wrap_snapshot, + )); } - // Place excerpt headers above custom blocks on the same row. - blocks_in_edit.sort_unstable_by(|(row_a, block_a), (row_b, block_b)| { - row_a.cmp(row_b).then_with(|| match (block_a, block_b) { - ( - TransformBlock::ExcerptHeader { .. }, - TransformBlock::ExcerptHeader { .. }, - ) => Ordering::Equal, - (TransformBlock::ExcerptHeader { .. }, _) => Ordering::Less, - (_, TransformBlock::ExcerptHeader { .. }) => Ordering::Greater, - (TransformBlock::Custom(block_a), TransformBlock::Custom(block_b)) => block_a - .disposition - .cmp(&block_b.disposition) - .then_with(|| block_a.id.cmp(&block_b.id)), - }) - }); + BlockMap::sort_blocks(&mut blocks_in_edit); // For each of these blocks, insert a new isomorphic transform preceding the block, // and then insert the block itself. @@ -442,13 +549,114 @@ impl BlockMap { *transforms = new_transforms; } - pub fn replace(&mut self, mut renderers: HashMap) { - for block in &self.blocks { + pub fn replace_renderers(&mut self, mut renderers: HashMap) { + for block in &mut self.custom_blocks { if let Some(render) = renderers.remove(&block.id) { *block.render.lock() = render; } } } + + pub fn show_excerpt_controls(&self) -> bool { + self.show_excerpt_controls + } + + pub fn header_and_footer_blocks<'a, 'b: 'a, 'c: 'a + 'b, R, T>( + show_excerpt_controls: bool, + excerpt_footer_height: u8, + buffer_header_height: u8, + excerpt_header_height: u8, + buffer: &'b multi_buffer::MultiBufferSnapshot, + range: R, + wrap_snapshot: &'c WrapSnapshot, + ) -> impl Iterator + 'b + where + R: RangeBounds, + T: multi_buffer::ToOffset, + { + buffer + .excerpt_boundaries_in_range(range) + .flat_map(move |excerpt_boundary| { + let mut wrap_row = wrap_snapshot + .make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left) + .row(); + + [ + show_excerpt_controls + .then(|| { + let disposition; + if excerpt_boundary.next.is_some() { + disposition = BlockDisposition::Above; + } else { + wrap_row = wrap_snapshot + .make_wrap_point( + Point::new( + excerpt_boundary.row.0, + buffer.line_len(excerpt_boundary.row), + ), + Bias::Left, + ) + .row(); + disposition = BlockDisposition::Below; + } + + excerpt_boundary.prev.as_ref().map(|prev| { + ( + wrap_row, + Block::ExcerptFooter { + id: prev.id, + height: excerpt_footer_height, + disposition, + }, + ) + }) + }) + .flatten(), + excerpt_boundary.next.map(|next| { + let starts_new_buffer = excerpt_boundary + .prev + .map_or(true, |prev| prev.buffer_id != next.buffer_id); + + ( + wrap_row, + Block::ExcerptHeader { + id: next.id, + buffer: next.buffer, + range: next.range, + height: if starts_new_buffer { + buffer_header_height + } else { + excerpt_header_height + }, + starts_new_buffer, + show_excerpt_controls, + }, + ) + }), + ] + }) + .flatten() + } + + pub(crate) fn sort_blocks(blocks: &mut Vec<(u32, B)>) { + // Place excerpt headers and footers above custom blocks on the same row + blocks.sort_unstable_by(|(row_a, block_a), (row_b, block_b)| { + row_a.cmp(row_b).then_with(|| { + block_a + .disposition() + .cmp(&block_b.disposition()) + .then_with(|| match ((block_a.block_type()), (block_b.block_type())) { + (BlockType::Footer, BlockType::Footer) => Ordering::Equal, + (BlockType::Footer, _) => Ordering::Less, + (_, BlockType::Footer) => Ordering::Greater, + (BlockType::Header, BlockType::Header) => Ordering::Equal, + (BlockType::Header, _) => Ordering::Less, + (_, BlockType::Header) => Ordering::Greater, + (BlockType::Custom(a_id), BlockType::Custom(b_id)) => a_id.cmp(&b_id), + }) + }) + }); + } } fn push_isomorphic(tree: &mut SumTree, rows: u32) { @@ -492,18 +700,74 @@ impl std::ops::DerefMut for BlockPoint { } } +impl<'a> Deref for BlockMapReader<'a> { + type Target = BlockSnapshot; + + fn deref(&self) -> &Self::Target { + &self.snapshot + } +} + +impl<'a> DerefMut for BlockMapReader<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.snapshot + } +} + +impl<'a> BlockMapReader<'a> { + pub fn row_for_block(&self, block_id: CustomBlockId) -> Option { + let block = self.blocks.iter().find(|block| block.id == block_id)?; + let buffer_row = block + .position + .to_point(self.wrap_snapshot.buffer_snapshot()) + .row; + let wrap_row = self + .wrap_snapshot + .make_wrap_point(Point::new(buffer_row, 0), Bias::Left) + .row(); + let start_wrap_row = WrapRow( + self.wrap_snapshot + .prev_row_boundary(WrapPoint::new(wrap_row, 0)), + ); + let end_wrap_row = WrapRow( + self.wrap_snapshot + .next_row_boundary(WrapPoint::new(wrap_row, 0)) + .unwrap_or(self.wrap_snapshot.max_point().row() + 1), + ); + + let mut cursor = self.transforms.cursor::<(WrapRow, BlockRow)>(); + cursor.seek(&start_wrap_row, Bias::Left, &()); + while let Some(transform) = cursor.item() { + if cursor.start().0 > end_wrap_row { + break; + } + + if let Some(BlockType::Custom(id)) = + transform.block.as_ref().map(|block| block.block_type()) + { + if id == block_id { + return Some(cursor.start().1); + } + } + cursor.next(&()); + } + + None + } +} + impl<'a> BlockMapWriter<'a> { pub fn insert( &mut self, blocks: impl IntoIterator>, - ) -> Vec { + ) -> Vec { let mut ids = Vec::new(); let mut edits = Patch::default(); let wrap_snapshot = &*self.0.wrap_snapshot.borrow(); let buffer = wrap_snapshot.buffer_snapshot(); for block in blocks { - let id = BlockId(self.0.next_block_id.fetch_add(1, SeqCst)); + let id = CustomBlockId(self.0.next_block_id.fetch_add(1, SeqCst)); ids.push(id); let position = block.position; @@ -518,22 +782,21 @@ impl<'a> BlockMapWriter<'a> { let block_ix = match self .0 - .blocks + .custom_blocks .binary_search_by(|probe| probe.position.cmp(&position, buffer)) { Ok(ix) | Err(ix) => ix, }; - self.0.blocks.insert( - block_ix, - Arc::new(Block { - id, - position, - height: block.height, - render: Mutex::new(block.render), - disposition: block.disposition, - style: block.style, - }), - ); + let new_block = Arc::new(CustomBlock { + id, + position, + height: block.height, + render: Mutex::new(block.render), + disposition: block.disposition, + style: block.style, + }); + self.0.custom_blocks.insert(block_ix, new_block.clone()); + self.0.custom_blocks_by_id.insert(id, new_block); edits = edits.compose([Edit { old: start_row..end_row, @@ -545,12 +808,59 @@ impl<'a> BlockMapWriter<'a> { ids } - pub fn remove(&mut self, block_ids: HashSet) { + pub fn replace( + &mut self, + mut heights_and_renderers: HashMap, + ) { + let wrap_snapshot = &*self.0.wrap_snapshot.borrow(); + let buffer = wrap_snapshot.buffer_snapshot(); + let mut edits = Patch::default(); + let mut last_block_buffer_row = None; + + for block in &mut self.0.custom_blocks { + if let Some((new_height, render)) = heights_and_renderers.remove(&block.id) { + if block.height != new_height { + let new_block = CustomBlock { + id: block.id, + position: block.position, + height: new_height, + style: block.style, + render: Mutex::new(render), + disposition: block.disposition, + }; + let new_block = Arc::new(new_block); + *block = new_block.clone(); + self.0.custom_blocks_by_id.insert(block.id, new_block); + + let buffer_row = block.position.to_point(buffer).row; + if last_block_buffer_row != Some(buffer_row) { + last_block_buffer_row = Some(buffer_row); + let wrap_row = wrap_snapshot + .make_wrap_point(Point::new(buffer_row, 0), Bias::Left) + .row(); + let start_row = + wrap_snapshot.prev_row_boundary(WrapPoint::new(wrap_row, 0)); + let end_row = wrap_snapshot + .next_row_boundary(WrapPoint::new(wrap_row, 0)) + .unwrap_or(wrap_snapshot.max_point().row() + 1); + edits.push(Edit { + old: start_row..end_row, + new: start_row..end_row, + }) + } + } + } + } + + self.0.sync(wrap_snapshot, edits); + } + + pub fn remove(&mut self, block_ids: HashSet) { let wrap_snapshot = &*self.0.wrap_snapshot.borrow(); let buffer = wrap_snapshot.buffer_snapshot(); let mut edits = Patch::default(); let mut last_block_buffer_row = None; - self.0.blocks.retain(|block| { + self.0.custom_blocks.retain(|block| { if block_ids.contains(&block.id) { let buffer_row = block.position.to_point(buffer).row; if last_block_buffer_row != Some(buffer_row) { @@ -567,6 +877,7 @@ impl<'a> BlockMapWriter<'a> { new: start_row..end_row, }) } + self.0.custom_blocks_by_id.remove(&block.id); false } else { true @@ -651,10 +962,7 @@ impl BlockSnapshot { } } - pub fn blocks_in_range( - &self, - rows: Range, - ) -> impl Iterator { + pub fn blocks_in_range(&self, rows: Range) -> impl Iterator { let mut cursor = self.transforms.cursor::(); cursor.seek(&BlockRow(rows.start), Bias::Right, &()); std::iter::from_fn(move || { @@ -674,6 +982,60 @@ impl BlockSnapshot { }) } + pub fn block_for_id(&self, block_id: BlockId) -> Option { + let buffer = self.wrap_snapshot.buffer_snapshot(); + + match block_id { + BlockId::Custom(custom_block_id) => { + let custom_block = self.custom_blocks_by_id.get(&custom_block_id)?; + Some(Block::Custom(custom_block.clone())) + } + BlockId::ExcerptHeader(excerpt_id) => { + let excerpt_range = buffer.range_for_excerpt::(excerpt_id)?; + let wrap_point = self + .wrap_snapshot + .make_wrap_point(excerpt_range.start, Bias::Left); + let mut cursor = self.transforms.cursor::<(WrapRow, BlockRow)>(); + cursor.seek(&WrapRow(wrap_point.row()), Bias::Left, &()); + while let Some(transform) = cursor.item() { + if let Some(block) = transform.block.as_ref() { + if block.id() == block_id { + return Some(block.clone()); + } + } else if cursor.start().0 > WrapRow(wrap_point.row()) { + break; + } + + cursor.next(&()); + } + + None + } + BlockId::ExcerptFooter(excerpt_id) => { + let excerpt_range = buffer.range_for_excerpt::(excerpt_id)?; + let wrap_point = self + .wrap_snapshot + .make_wrap_point(excerpt_range.end, Bias::Left); + + let mut cursor = self.transforms.cursor::<(WrapRow, BlockRow)>(); + cursor.seek(&WrapRow(wrap_point.row()), Bias::Left, &()); + while let Some(transform) = cursor.item() { + if let Some(block) = transform.block.as_ref() { + if block.id() == block_id { + return Some(block.clone()); + } + } else if cursor.start().0 > WrapRow(wrap_point.row()) { + break; + } + + cursor.next(&()); + } + + None + } + } + } + pub fn max_point(&self) -> BlockPoint { let row = self.transforms.summary().output_rows - 1; BlockPoint::new(row, self.line_len(BlockRow(row))) @@ -803,7 +1165,7 @@ impl Transform { } } - fn block(block: TransformBlock) -> Self { + fn block(block: Block) -> Self { Self { summary: TransformSummary { input_rows: 0, @@ -952,7 +1314,7 @@ impl DerefMut for BlockContext<'_, '_> { } } -impl Block { +impl CustomBlock { pub fn render(&self, cx: &mut BlockContext) -> AnyElement { self.render.lock()(cx) } @@ -966,7 +1328,7 @@ impl Block { } } -impl Debug for Block { +impl Debug for CustomBlock { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Block") .field("id", &self.id) @@ -997,9 +1359,11 @@ fn offset_for_row(s: &str, target: u32) -> (u32, usize) { #[cfg(test)] mod tests { use super::*; - use crate::display_map::inlay_map::InlayMap; - use crate::display_map::{fold_map::FoldMap, tab_map::TabMap, wrap_map::WrapMap}; - use gpui::{div, font, px, Element}; + use crate::display_map::{ + fold_map::FoldMap, inlay_map::InlayMap, tab_map::TabMap, wrap_map::WrapMap, + }; + use gpui::{div, font, px, AppContext, Context as _, Element}; + use language::{Buffer, Capability}; use multi_buffer::MultiBuffer; use rand::prelude::*; use settings::SettingsStore; @@ -1034,7 +1398,7 @@ mod tests { let (mut tab_map, tab_snapshot) = TabMap::new(fold_snapshot, 1.try_into().unwrap()); let (wrap_map, wraps_snapshot) = cx.update(|cx| WrapMap::new(tab_snapshot, font("Helvetica"), px(14.0), None, cx)); - let mut block_map = BlockMap::new(wraps_snapshot.clone(), 1, 1); + let mut block_map = BlockMap::new(wraps_snapshot.clone(), true, 1, 1, 1); let mut writer = block_map.write(wraps_snapshot.clone(), Default::default()); let block_ids = writer.insert(vec![ @@ -1190,6 +1554,195 @@ mod tests { assert_eq!(snapshot.text(), "aaa\n\nb!!!\n\n\nbb\nccc\nddd\n\n\n"); } + #[gpui::test] + fn test_multibuffer_headers_and_footers(cx: &mut AppContext) { + init_test(cx); + + let buffer1 = cx.new_model(|cx| Buffer::local("Buffer 1", cx)); + let buffer2 = cx.new_model(|cx| Buffer::local("Buffer 2", cx)); + let buffer3 = cx.new_model(|cx| Buffer::local("Buffer 3", cx)); + + let mut excerpt_ids = Vec::new(); + let multi_buffer = cx.new_model(|cx| { + let mut multi_buffer = MultiBuffer::new(0, Capability::ReadWrite); + excerpt_ids.extend(multi_buffer.push_excerpts( + buffer1.clone(), + [ExcerptRange { + context: 0..buffer1.read(cx).len(), + primary: None, + }], + cx, + )); + excerpt_ids.extend(multi_buffer.push_excerpts( + buffer2.clone(), + [ExcerptRange { + context: 0..buffer2.read(cx).len(), + primary: None, + }], + cx, + )); + excerpt_ids.extend(multi_buffer.push_excerpts( + buffer3.clone(), + [ExcerptRange { + context: 0..buffer3.read(cx).len(), + primary: None, + }], + cx, + )); + + multi_buffer + }); + + let font = font("Helvetica"); + let font_size = px(14.); + let font_id = cx.text_system().resolve_font(&font); + let mut wrap_width = px(0.); + for c in "Buff".chars() { + wrap_width += cx + .text_system() + .advance(font_id, font_size, c) + .unwrap() + .width; + } + + let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(multi_buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + let (_, wraps_snapshot) = WrapMap::new(tab_snapshot, font, font_size, Some(wrap_width), cx); + + let block_map = BlockMap::new(wraps_snapshot.clone(), true, 1, 1, 1); + let snapshot = block_map.read(wraps_snapshot, Default::default()); + + // Each excerpt has a header above and footer below. Excerpts are also *separated* by a newline. + assert_eq!( + snapshot.text(), + "\nBuff\ner 1\n\n\nBuff\ner 2\n\n\nBuff\ner 3\n" + ); + + let blocks: Vec<_> = snapshot + .blocks_in_range(0..u32::MAX) + .map(|(row, block)| (row, block.id())) + .collect(); + assert_eq!( + blocks, + vec![ + (0, BlockId::ExcerptHeader(excerpt_ids[0])), + (3, BlockId::ExcerptFooter(excerpt_ids[0])), + (4, BlockId::ExcerptHeader(excerpt_ids[1])), + (7, BlockId::ExcerptFooter(excerpt_ids[1])), + (8, BlockId::ExcerptHeader(excerpt_ids[2])), + (11, BlockId::ExcerptFooter(excerpt_ids[2])) + ] + ); + } + + #[gpui::test] + fn test_replace_with_heights(cx: &mut gpui::TestAppContext) { + let _update = cx.update(|cx| init_test(cx)); + + let text = "aaa\nbbb\nccc\nddd"; + + let buffer = cx.update(|cx| MultiBuffer::build_simple(text, cx)); + let buffer_snapshot = cx.update(|cx| buffer.read(cx).snapshot(cx)); + let _subscription = buffer.update(cx, |buffer, _| buffer.subscribe()); + let (_inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_tab_map, tab_snapshot) = TabMap::new(fold_snapshot, 1.try_into().unwrap()); + let (_wrap_map, wraps_snapshot) = + cx.update(|cx| WrapMap::new(tab_snapshot, font("Helvetica"), px(14.0), None, cx)); + let mut block_map = BlockMap::new(wraps_snapshot.clone(), false, 1, 1, 0); + + let mut writer = block_map.write(wraps_snapshot.clone(), Default::default()); + let block_ids = writer.insert(vec![ + BlockProperties { + style: BlockStyle::Fixed, + position: buffer_snapshot.anchor_after(Point::new(1, 0)), + height: 1, + disposition: BlockDisposition::Above, + render: Box::new(|_| div().into_any()), + }, + BlockProperties { + style: BlockStyle::Fixed, + position: buffer_snapshot.anchor_after(Point::new(1, 2)), + height: 2, + disposition: BlockDisposition::Above, + render: Box::new(|_| div().into_any()), + }, + BlockProperties { + style: BlockStyle::Fixed, + position: buffer_snapshot.anchor_after(Point::new(3, 3)), + height: 3, + disposition: BlockDisposition::Below, + render: Box::new(|_| div().into_any()), + }, + ]); + + { + let snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); + assert_eq!(snapshot.text(), "aaa\n\n\n\nbbb\nccc\nddd\n\n\n"); + + let mut block_map_writer = block_map.write(wraps_snapshot.clone(), Default::default()); + + let mut hash_map = HashMap::default(); + let render: RenderBlock = Box::new(|_| div().into_any()); + hash_map.insert(block_ids[0], (2_u8, render)); + block_map_writer.replace(hash_map); + let snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); + assert_eq!(snapshot.text(), "aaa\n\n\n\n\nbbb\nccc\nddd\n\n\n"); + } + + { + let mut block_map_writer = block_map.write(wraps_snapshot.clone(), Default::default()); + + let mut hash_map = HashMap::default(); + let render: RenderBlock = Box::new(|_| div().into_any()); + hash_map.insert(block_ids[0], (1_u8, render)); + block_map_writer.replace(hash_map); + + let snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); + assert_eq!(snapshot.text(), "aaa\n\n\n\nbbb\nccc\nddd\n\n\n"); + } + + { + let mut block_map_writer = block_map.write(wraps_snapshot.clone(), Default::default()); + + let mut hash_map = HashMap::default(); + let render: RenderBlock = Box::new(|_| div().into_any()); + hash_map.insert(block_ids[0], (0_u8, render)); + block_map_writer.replace(hash_map); + + let snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); + assert_eq!(snapshot.text(), "aaa\n\n\nbbb\nccc\nddd\n\n\n"); + } + + { + let mut block_map_writer = block_map.write(wraps_snapshot.clone(), Default::default()); + + let mut hash_map = HashMap::default(); + let render: RenderBlock = Box::new(|_| div().into_any()); + hash_map.insert(block_ids[0], (3_u8, render)); + block_map_writer.replace(hash_map); + + let snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); + assert_eq!(snapshot.text(), "aaa\n\n\n\n\n\nbbb\nccc\nddd\n\n\n"); + } + + { + let mut block_map_writer = block_map.write(wraps_snapshot.clone(), Default::default()); + + let mut hash_map = HashMap::default(); + let render: RenderBlock = Box::new(|_| div().into_any()); + hash_map.insert(block_ids[0], (3_u8, render)); + block_map_writer.replace(hash_map); + + let snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); + // Same height as before, should remain the same + assert_eq!(snapshot.text(), "aaa\n\n\n\n\n\nbbb\nccc\nddd\n\n\n"); + } + } + + #[cfg(target_os = "macos")] #[gpui::test] fn test_blocks_on_wrapped_lines(cx: &mut gpui::TestAppContext) { cx.update(|cx| init_test(cx)); @@ -1206,7 +1759,7 @@ mod tests { let (_, wraps_snapshot) = cx.update(|cx| { WrapMap::new(tab_snapshot, font("Helvetica"), px(14.0), Some(px(60.)), cx) }); - let mut block_map = BlockMap::new(wraps_snapshot.clone(), 1, 1); + let mut block_map = BlockMap::new(wraps_snapshot.clone(), true, 1, 1, 0); let mut writer = block_map.write(wraps_snapshot.clone(), Default::default()); writer.insert(vec![ @@ -1252,9 +1805,11 @@ mod tests { let font_size = px(14.0); let buffer_start_header_height = rng.gen_range(1..=5); let excerpt_header_height = rng.gen_range(1..=5); + let excerpt_footer_height = rng.gen_range(1..=5); log::info!("Wrap width: {:?}", wrap_width); log::info!("Excerpt Header Height: {:?}", excerpt_header_height); + log::info!("Excerpt Footer Height: {:?}", excerpt_footer_height); let buffer = if rng.gen() { let len = rng.gen_range(0..10); @@ -1273,8 +1828,10 @@ mod tests { .update(|cx| WrapMap::new(tab_snapshot, font("Helvetica"), font_size, wrap_width, cx)); let mut block_map = BlockMap::new( wraps_snapshot, + true, buffer_start_header_height, excerpt_header_height, + excerpt_footer_height, ); let mut custom_blocks = Vec::new(); @@ -1410,24 +1967,23 @@ mod tests { }, ) })); - expected_blocks.extend(buffer_snapshot.excerpt_boundaries_in_range(0..).map( - |boundary| { - let position = - wraps_snapshot.make_wrap_point(Point::new(boundary.row.0, 0), Bias::Left); - ( - position.row(), - ExpectedBlock::ExcerptHeader { - height: if boundary.starts_new_buffer { - buffer_start_header_height - } else { - excerpt_header_height - }, - starts_new_buffer: boundary.starts_new_buffer, - }, - ) - }, - )); - expected_blocks.sort_unstable(); + + // Note that this needs to be synced with the related section in BlockMap::sync + expected_blocks.extend( + BlockMap::header_and_footer_blocks( + true, + excerpt_footer_height, + buffer_start_header_height, + excerpt_header_height, + &buffer_snapshot, + 0.., + &wraps_snapshot, + ) + .map(|(row, block)| (row, block.into())), + ); + + BlockMap::sort_blocks(&mut expected_blocks); + let mut sorted_blocks_iter = expected_blocks.into_iter().peekable(); let input_buffer_rows = buffer_snapshot @@ -1518,6 +2074,25 @@ mod tests { expected_block_positions ); + for (_, expected_block) in + blocks_snapshot.blocks_in_range(0..(expected_row_count as u32)) + { + let actual_block = blocks_snapshot.block_for_id(expected_block.id()); + assert_eq!( + actual_block.map(|block| block.id()), + Some(expected_block.id()) + ); + } + + for (block_row, block) in expected_block_positions { + if let BlockType::Custom(block_id) = block.block_type() { + assert_eq!( + blocks_snapshot.row_for_block(block_id), + Some(BlockRow(block_row)) + ); + } + } + let mut expected_longest_rows = Vec::new(); let mut longest_line_len = -1_isize; for (row, line) in expected_lines.iter().enumerate() { @@ -1593,24 +2168,43 @@ mod tests { } } - #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] + #[derive(Debug, Eq, PartialEq)] enum ExpectedBlock { ExcerptHeader { height: u8, starts_new_buffer: bool, }, + ExcerptFooter { + height: u8, + disposition: BlockDisposition, + }, Custom { disposition: BlockDisposition, - id: BlockId, + id: CustomBlockId, height: u8, }, } + impl BlockLike for ExpectedBlock { + fn block_type(&self) -> BlockType { + match self { + ExpectedBlock::Custom { id, .. } => BlockType::Custom(*id), + ExpectedBlock::ExcerptHeader { .. } => BlockType::Header, + ExpectedBlock::ExcerptFooter { .. } => BlockType::Footer, + } + } + + fn disposition(&self) -> BlockDisposition { + self.disposition() + } + } + impl ExpectedBlock { fn height(&self) -> u8 { match self { ExpectedBlock::ExcerptHeader { height, .. } => *height, ExpectedBlock::Custom { height, .. } => *height, + ExpectedBlock::ExcerptFooter { height, .. } => *height, } } @@ -1618,19 +2212,20 @@ mod tests { match self { ExpectedBlock::ExcerptHeader { .. } => BlockDisposition::Above, ExpectedBlock::Custom { disposition, .. } => *disposition, + ExpectedBlock::ExcerptFooter { disposition, .. } => *disposition, } } } - impl From for ExpectedBlock { - fn from(block: TransformBlock) -> Self { + impl From for ExpectedBlock { + fn from(block: Block) -> Self { match block { - TransformBlock::Custom(block) => ExpectedBlock::Custom { + Block::Custom(block) => ExpectedBlock::Custom { id: block.id, disposition: block.disposition, height: block.height, }, - TransformBlock::ExcerptHeader { + Block::ExcerptHeader { height, starts_new_buffer, .. @@ -1638,6 +2233,14 @@ mod tests { height, starts_new_buffer, }, + Block::ExcerptFooter { + height, + disposition, + .. + } => ExpectedBlock::ExcerptFooter { + height, + disposition, + }, } } } @@ -1647,13 +2250,15 @@ mod tests { let settings = SettingsStore::test(cx); cx.set_global(settings); theme::init(theme::LoadThemes::JustBase, cx); + assets::Assets.load_test_fonts(cx); } - impl TransformBlock { - fn as_custom(&self) -> Option<&Block> { + impl Block { + fn as_custom(&self) -> Option<&CustomBlock> { match self { - TransformBlock::Custom(block) => Some(block), - TransformBlock::ExcerptHeader { .. } => None, + Block::Custom(block) => Some(block), + Block::ExcerptHeader { .. } => None, + Block::ExcerptFooter { .. } => None, } } } diff --git a/crates/editor/src/display_map/flap_map.rs b/crates/editor/src/display_map/crease_map.rs similarity index 65% rename from crates/editor/src/display_map/flap_map.rs rename to crates/editor/src/display_map/crease_map.rs index 9c33f766c95412..8e2b5241933ea8 100644 --- a/crates/editor/src/display_map/flap_map.rs +++ b/crates/editor/src/display_map/crease_map.rs @@ -9,51 +9,57 @@ use ui::WindowContext; use crate::FoldPlaceholder; #[derive(Copy, Clone, Default, Debug, Eq, PartialEq, PartialOrd, Ord, Hash)] -pub struct FlapId(usize); +pub struct CreaseId(usize); #[derive(Default)] -pub struct FlapMap { - snapshot: FlapSnapshot, - next_id: FlapId, - id_to_range: HashMap>, +pub struct CreaseMap { + snapshot: CreaseSnapshot, + next_id: CreaseId, + id_to_range: HashMap>, } #[derive(Clone, Default)] -pub struct FlapSnapshot { - flaps: SumTree, +pub struct CreaseSnapshot { + creases: SumTree, } -impl FlapSnapshot { - /// Returns the first Flap starting on the specified buffer row. +impl CreaseSnapshot { + /// Returns the first Crease starting on the specified buffer row. pub fn query_row<'a>( &'a self, row: MultiBufferRow, snapshot: &'a MultiBufferSnapshot, - ) -> Option<&'a Flap> { + ) -> Option<&'a Crease> { let start = snapshot.anchor_before(Point::new(row.0, 0)); - let mut cursor = self.flaps.cursor::(); + let mut cursor = self.creases.cursor::(); cursor.seek(&start, Bias::Left, snapshot); while let Some(item) = cursor.item() { - match Ord::cmp(&item.flap.range.start.to_point(snapshot).row, &row.0) { + match Ord::cmp(&item.crease.range.start.to_point(snapshot).row, &row.0) { Ordering::Less => cursor.next(snapshot), - Ordering::Equal => return Some(&item.flap), + Ordering::Equal => { + if item.crease.range.start.is_valid(snapshot) { + return Some(&item.crease); + } else { + cursor.next(snapshot); + } + } Ordering::Greater => break, } } return None; } - pub fn flap_items_with_offsets( + pub fn crease_items_with_offsets( &self, snapshot: &MultiBufferSnapshot, - ) -> Vec<(FlapId, Range)> { - let mut cursor = self.flaps.cursor::(); + ) -> Vec<(CreaseId, Range)> { + let mut cursor = self.creases.cursor::(); let mut results = Vec::new(); cursor.next(snapshot); while let Some(item) = cursor.item() { - let start_point = item.flap.range.start.to_point(snapshot); - let end_point = item.flap.range.end.to_point(snapshot); + let start_point = item.crease.range.start.to_point(snapshot); + let end_point = item.crease.range.end.to_point(snapshot); results.push((item.id, start_point..end_point)); cursor.next(snapshot); } @@ -76,14 +82,14 @@ type RenderTrailerFn = Arc AnyElement>; #[derive(Clone)] -pub struct Flap { +pub struct Crease { pub range: Range, pub placeholder: FoldPlaceholder, pub render_toggle: RenderToggleFn, pub render_trailer: RenderTrailerFn, } -impl Flap { +impl Crease { pub fn new( range: Range, placeholder: FoldPlaceholder, @@ -109,7 +115,7 @@ impl Flap { + 'static, TrailerElement: IntoElement, { - Flap { + Crease { range, placeholder, render_toggle: Arc::new(move |row, folded, toggle, cx| { @@ -122,50 +128,52 @@ impl Flap { } } -impl std::fmt::Debug for Flap { +impl std::fmt::Debug for Crease { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Flap").field("range", &self.range).finish() + f.debug_struct("Crease") + .field("range", &self.range) + .finish() } } #[derive(Clone, Debug)] -struct FlapItem { - id: FlapId, - flap: Flap, +struct CreaseItem { + id: CreaseId, + crease: Crease, } -impl FlapMap { - pub fn snapshot(&self) -> FlapSnapshot { +impl CreaseMap { + pub fn snapshot(&self) -> CreaseSnapshot { self.snapshot.clone() } pub fn insert( &mut self, - flaps: impl IntoIterator, + creases: impl IntoIterator, snapshot: &MultiBufferSnapshot, - ) -> Vec { + ) -> Vec { let mut new_ids = Vec::new(); - self.snapshot.flaps = { - let mut new_flaps = SumTree::new(); - let mut cursor = self.snapshot.flaps.cursor::(); - for flap in flaps { - new_flaps.append(cursor.slice(&flap.range, Bias::Left, snapshot), snapshot); + self.snapshot.creases = { + let mut new_creases = SumTree::new(); + let mut cursor = self.snapshot.creases.cursor::(); + for crease in creases { + new_creases.append(cursor.slice(&crease.range, Bias::Left, snapshot), snapshot); let id = self.next_id; self.next_id.0 += 1; - self.id_to_range.insert(id, flap.range.clone()); - new_flaps.push(FlapItem { flap, id }, snapshot); + self.id_to_range.insert(id, crease.range.clone()); + new_creases.push(CreaseItem { crease, id }, snapshot); new_ids.push(id); } - new_flaps.append(cursor.suffix(snapshot), snapshot); - new_flaps + new_creases.append(cursor.suffix(snapshot), snapshot); + new_creases }; new_ids } pub fn remove( &mut self, - ids: impl IntoIterator, + ids: impl IntoIterator, snapshot: &MultiBufferSnapshot, ) { let mut removals = Vec::new(); @@ -178,24 +186,24 @@ impl FlapMap { AnchorRangeExt::cmp(a_range, b_range, snapshot).then(b_id.cmp(&a_id)) }); - self.snapshot.flaps = { - let mut new_flaps = SumTree::new(); - let mut cursor = self.snapshot.flaps.cursor::(); + self.snapshot.creases = { + let mut new_creases = SumTree::new(); + let mut cursor = self.snapshot.creases.cursor::(); for (id, range) in removals { - new_flaps.append(cursor.slice(&range, Bias::Left, snapshot), snapshot); + new_creases.append(cursor.slice(&range, Bias::Left, snapshot), snapshot); while let Some(item) = cursor.item() { cursor.next(snapshot); if item.id == id { break; } else { - new_flaps.push(item.clone(), snapshot); + new_creases.push(item.clone(), snapshot); } } } - new_flaps.append(cursor.suffix(snapshot), snapshot); - new_flaps + new_creases.append(cursor.suffix(snapshot), snapshot); + new_creases }; } } @@ -221,17 +229,17 @@ impl sum_tree::Summary for ItemSummary { } } -impl sum_tree::Item for FlapItem { +impl sum_tree::Item for CreaseItem { type Summary = ItemSummary; fn summary(&self) -> Self::Summary { ItemSummary { - range: self.flap.range.clone(), + range: self.crease.range.clone(), } } } -/// Implements `SeekTarget` for `Range` to enable seeking within a `SumTree` of `FlapItem`s. +/// Implements `SeekTarget` for `Range` to enable seeking within a `SumTree` of `CreaseItem`s. impl SeekTarget<'_, ItemSummary, ItemSummary> for Range { fn cmp(&self, cursor_location: &ItemSummary, snapshot: &MultiBufferSnapshot) -> Ordering { AnchorRangeExt::cmp(self, &cursor_location.range, snapshot) @@ -251,48 +259,48 @@ mod test { use multi_buffer::MultiBuffer; #[gpui::test] - fn test_insert_and_remove_flaps(cx: &mut AppContext) { + fn test_insert_and_remove_creases(cx: &mut AppContext) { let text = "line1\nline2\nline3\nline4\nline5"; let buffer = MultiBuffer::build_simple(text, cx); let snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); - let mut flap_map = FlapMap::default(); + let mut crease_map = CreaseMap::default(); - // Insert flaps - let flaps = [ - Flap::new( + // Insert creases + let creases = [ + Crease::new( snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(1, 5)), FoldPlaceholder::test(), |_row, _folded, _toggle, _cx| div(), |_row, _folded, _cx| div(), ), - Flap::new( + Crease::new( snapshot.anchor_before(Point::new(3, 0))..snapshot.anchor_after(Point::new(3, 5)), FoldPlaceholder::test(), |_row, _folded, _toggle, _cx| div(), |_row, _folded, _cx| div(), ), ]; - let flap_ids = flap_map.insert(flaps, &snapshot); - assert_eq!(flap_ids.len(), 2); + let crease_ids = crease_map.insert(creases, &snapshot); + assert_eq!(crease_ids.len(), 2); - // Verify flaps are inserted - let flap_snapshot = flap_map.snapshot(); - assert!(flap_snapshot + // Verify creases are inserted + let crease_snapshot = crease_map.snapshot(); + assert!(crease_snapshot .query_row(MultiBufferRow(1), &snapshot) .is_some()); - assert!(flap_snapshot + assert!(crease_snapshot .query_row(MultiBufferRow(3), &snapshot) .is_some()); - // Remove flaps - flap_map.remove(flap_ids, &snapshot); + // Remove creases + crease_map.remove(crease_ids, &snapshot); - // Verify flaps are removed - let flap_snapshot = flap_map.snapshot(); - assert!(flap_snapshot + // Verify creases are removed + let crease_snapshot = crease_map.snapshot(); + assert!(crease_snapshot .query_row(MultiBufferRow(1), &snapshot) .is_none()); - assert!(flap_snapshot + assert!(crease_snapshot .query_row(MultiBufferRow(3), &snapshot) .is_none()); } diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index a117f2058bf233..328aef9b458525 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -20,6 +20,8 @@ pub struct FoldPlaceholder { pub render: Arc, &mut WindowContext) -> AnyElement>, /// If true, the element is constrained to the shaped width of an ellipsis. pub constrain_width: bool, + /// If true, merges the fold with an adjacent one. + pub merge_adjacent: bool, } impl FoldPlaceholder { @@ -30,6 +32,7 @@ impl FoldPlaceholder { Self { render: Arc::new(|_id, _range, _cx| gpui::Empty.into_any_element()), constrain_width: true, + merge_adjacent: true, } } } @@ -161,7 +164,7 @@ impl<'a> FoldMapWriter<'a> { new_tree }; - consolidate_inlay_edits(&mut edits); + let edits = consolidate_inlay_edits(edits); let edits = self.0.sync(snapshot.clone(), edits); (self.0.snapshot.clone(), edits) } @@ -209,7 +212,7 @@ impl<'a> FoldMapWriter<'a> { folds }; - consolidate_inlay_edits(&mut edits); + let edits = consolidate_inlay_edits(edits); let edits = self.0.sync(snapshot.clone(), edits); (self.0.snapshot.clone(), edits) } @@ -374,8 +377,11 @@ impl FoldMap { assert!(fold_range.start.0 >= sum.input.len); - while folds.peek().map_or(false, |(_, next_fold_range)| { - next_fold_range.start <= fold_range.end + while folds.peek().map_or(false, |(next_fold, next_fold_range)| { + next_fold_range.start < fold_range.end + || (next_fold_range.start == fold_range.end + && fold.placeholder.merge_adjacent + && next_fold.placeholder.merge_adjacent) }) { let (_, next_fold_range) = folds.next().unwrap(); if next_fold_range.end > fold_range.end { @@ -507,7 +513,7 @@ impl FoldMap { }); } - consolidate_fold_edits(&mut fold_edits); + fold_edits = consolidate_fold_edits(fold_edits); } self.snapshot.transforms = new_transforms; @@ -803,7 +809,7 @@ where cursor } -fn consolidate_inlay_edits(edits: &mut Vec) { +fn consolidate_inlay_edits(mut edits: Vec) -> Vec { edits.sort_unstable_by(|a, b| { a.old .start @@ -811,42 +817,68 @@ fn consolidate_inlay_edits(edits: &mut Vec) { .then_with(|| b.old.end.cmp(&a.old.end)) }); - let mut i = 1; - while i < edits.len() { - let edit = edits[i].clone(); - let prev_edit = &mut edits[i - 1]; - if prev_edit.old.end >= edit.old.start { - prev_edit.old.end = prev_edit.old.end.max(edit.old.end); - prev_edit.new.start = prev_edit.new.start.min(edit.new.start); - prev_edit.new.end = prev_edit.new.end.max(edit.new.end); - edits.remove(i); - continue; - } - i += 1; - } + let _old_alloc_ptr = edits.as_ptr(); + let mut inlay_edits = edits.into_iter(); + let inlay_edits = if let Some(mut first_edit) = inlay_edits.next() { + // This code relies on reusing allocations from the Vec<_> - at the time of writing .flatten() prevents them. + #[allow(clippy::filter_map_identity)] + let mut v: Vec<_> = inlay_edits + .scan(&mut first_edit, |prev_edit, edit| { + if prev_edit.old.end >= edit.old.start { + prev_edit.old.end = prev_edit.old.end.max(edit.old.end); + prev_edit.new.start = prev_edit.new.start.min(edit.new.start); + prev_edit.new.end = prev_edit.new.end.max(edit.new.end); + Some(None) // Skip this edit, it's merged + } else { + let prev = std::mem::replace(*prev_edit, edit); + Some(Some(prev)) // Yield the previous edit + } + }) + .filter_map(|x| x) + .collect(); + v.push(first_edit.clone()); + debug_assert_eq!(_old_alloc_ptr, v.as_ptr(), "Inlay edits were reallocated"); + v + } else { + vec![] + }; + + inlay_edits } -fn consolidate_fold_edits(edits: &mut Vec) { +fn consolidate_fold_edits(mut edits: Vec) -> Vec { edits.sort_unstable_by(|a, b| { a.old .start .cmp(&b.old.start) .then_with(|| b.old.end.cmp(&a.old.end)) }); - - let mut i = 1; - while i < edits.len() { - let edit = edits[i].clone(); - let prev_edit = &mut edits[i - 1]; - if prev_edit.old.end >= edit.old.start { - prev_edit.old.end = prev_edit.old.end.max(edit.old.end); - prev_edit.new.start = prev_edit.new.start.min(edit.new.start); - prev_edit.new.end = prev_edit.new.end.max(edit.new.end); - edits.remove(i); - continue; - } - i += 1; - } + let _old_alloc_ptr = edits.as_ptr(); + let mut fold_edits = edits.into_iter(); + let fold_edits = if let Some(mut first_edit) = fold_edits.next() { + // This code relies on reusing allocations from the Vec<_> - at the time of writing .flatten() prevents them. + #[allow(clippy::filter_map_identity)] + let mut v: Vec<_> = fold_edits + .scan(&mut first_edit, |prev_edit, edit| { + if prev_edit.old.end >= edit.old.start { + prev_edit.old.end = prev_edit.old.end.max(edit.old.end); + prev_edit.new.start = prev_edit.new.start.min(edit.new.start); + prev_edit.new.end = prev_edit.new.end.max(edit.new.end); + Some(None) // Skip this edit, it's merged + } else { + let prev = std::mem::replace(*prev_edit, edit); + Some(Some(prev)) // Yield the previous edit + } + }) + .filter_map(|x| x) + .collect(); + v.push(first_edit.clone()); + v + } else { + vec![] + }; + + fold_edits } #[derive(Clone, Debug, Default)] diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index ad5cc84ef78b13..82565e56b6cab4 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -462,11 +462,8 @@ impl InlayMap { if buffer_edits.is_empty() { if snapshot.buffer.edit_count() != buffer_snapshot.edit_count() - || snapshot.buffer.parse_count() != buffer_snapshot.parse_count() - || snapshot.buffer.diagnostics_update_count() - != buffer_snapshot.diagnostics_update_count() - || snapshot.buffer.git_diff_update_count() - != buffer_snapshot.git_diff_update_count() + || snapshot.buffer.non_text_state_update_count() + != buffer_snapshot.non_text_state_update_count() || snapshot.buffer.trailing_excerpt_update_count() != buffer_snapshot.trailing_excerpt_update_count() { diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index 87eee6d177d7c2..f497fb1758c782 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -103,20 +103,33 @@ impl TabMap { } } + let _old_alloc_ptr = fold_edits.as_ptr(); // Combine any edits that overlap due to the expansion. - let mut ix = 1; - while ix < fold_edits.len() { - let (prev_edits, next_edits) = fold_edits.split_at_mut(ix); - let prev_edit = prev_edits.last_mut().unwrap(); - let edit = &next_edits[0]; - if prev_edit.old.end >= edit.old.start { - prev_edit.old.end = edit.old.end; - prev_edit.new.end = edit.new.end; - fold_edits.remove(ix); - } else { - ix += 1; - } - } + let mut fold_edits = fold_edits.into_iter(); + let fold_edits = if let Some(mut first_edit) = fold_edits.next() { + // This code relies on reusing allocations from the Vec<_> - at the time of writing .flatten() prevents them. + #[allow(clippy::filter_map_identity)] + let mut v: Vec<_> = fold_edits + .scan(&mut first_edit, |state, edit| { + if state.old.end >= edit.old.start { + state.old.end = edit.old.end; + state.new.end = edit.new.end; + Some(None) // Skip this edit, it's merged + } else { + let new_state = edit.clone(); + let result = Some(Some(state.clone())); // Yield the previous edit + **state = new_state; + result + } + }) + .filter_map(|x| x) + .collect(); + v.push(first_edit); + debug_assert_eq!(v.as_ptr(), _old_alloc_ptr, "Fold edits were reallocated"); + v + } else { + vec![] + }; for fold_edit in fold_edits { let old_start = fold_edit.old.start.to_point(&old_snapshot.fold_snapshot); diff --git a/crates/editor/src/display_map/wrap_map.rs b/crates/editor/src/display_map/wrap_map.rs index 645ff78c2048c1..15f497336d26ae 100644 --- a/crates/editor/src/display_map/wrap_map.rs +++ b/crates/editor/src/display_map/wrap_map.rs @@ -567,7 +567,7 @@ impl WrapSnapshot { }); } - consolidate_wrap_edits(&mut wrap_edits); + wrap_edits = consolidate_wrap_edits(wrap_edits); Patch::new(wrap_edits) } @@ -1008,19 +1008,33 @@ impl<'a> sum_tree::Dimension<'a, TransformSummary> for WrapPoint { } } -fn consolidate_wrap_edits(edits: &mut Vec) { - let mut i = 1; - while i < edits.len() { - let edit = edits[i].clone(); - let prev_edit = &mut edits[i - 1]; - if prev_edit.old.end >= edit.old.start { - prev_edit.old.end = edit.old.end; - prev_edit.new.end = edit.new.end; - edits.remove(i); - continue; - } - i += 1; - } +fn consolidate_wrap_edits(edits: Vec) -> Vec { + let _old_alloc_ptr = edits.as_ptr(); + let mut wrap_edits = edits.into_iter(); + let wrap_edits = if let Some(mut first_edit) = wrap_edits.next() { + // This code relies on reusing allocations from the Vec<_> - at the time of writing .flatten() prevents them. + #[allow(clippy::filter_map_identity)] + let mut v: Vec<_> = wrap_edits + .scan(&mut first_edit, |prev_edit, edit| { + if prev_edit.old.end >= edit.old.start { + prev_edit.old.end = edit.old.end; + prev_edit.new.end = edit.new.end; + Some(None) // Skip this edit, it's merged + } else { + let prev = std::mem::replace(*prev_edit, edit); + Some(Some(prev)) // Yield the previous edit + } + }) + .filter_map(|x| x) + .collect(); + v.push(first_edit.clone()); + debug_assert_eq!(v.as_ptr(), _old_alloc_ptr, "Wrap edits were reallocated"); + v + } else { + vec![] + }; + + wrap_edits } #[cfg(test)] diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d61332d667d992..32d46748f2bf0e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -15,20 +15,20 @@ pub mod actions; mod blame_entry_tooltip; mod blink_manager; +mod debounced_delay; pub mod display_map; mod editor_settings; mod element; -mod hunk_diff; -mod inlay_hint_cache; - -mod debounced_delay; mod git; mod highlight_matching_bracket; mod hover_links; mod hover_popover; +mod hunk_diff; mod indent_guides; +mod inlay_hint_cache; mod inline_completion_provider; pub mod items; +mod linked_editing_ranges; mod mouse_context_menu; pub mod movement; mod persistence; @@ -39,8 +39,10 @@ pub mod tasks; #[cfg(test)] mod editor_tests; +mod signature_help; #[cfg(any(test, feature = "test-support"))] pub mod test; + use ::git::diff::{DiffHunk, DiffHunkStatus}; use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry}; pub(crate) use actions::*; @@ -54,8 +56,7 @@ use convert_case::{Case, Casing}; use debounced_delay::DebouncedDelay; use display_map::*; pub use display_map::{DisplayPoint, FoldPlaceholder}; -use editor_settings::CurrentLineHighlight; -pub use editor_settings::EditorSettings; +pub use editor_settings::{CurrentLineHighlight, EditorSettings}; use element::LineWithInvisibles; pub use element::{ CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition, @@ -67,16 +68,17 @@ use git::diff_hunk_to_display; use gpui::{ div, impl_actions, point, prelude::*, px, relative, size, uniform_list, Action, AnyElement, AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, ClipboardItem, - Context, DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusableView, FontId, FontStyle, - FontWeight, HighlightStyle, Hsla, InteractiveText, KeyContext, Model, MouseButton, PaintQuad, - ParentElement, Pixels, Render, SharedString, Size, StrikethroughStyle, Styled, StyledText, - Subscription, Task, TextStyle, UnderlineStyle, UniformListScrollHandle, View, ViewContext, - ViewInputHandler, VisualContext, WeakView, WhiteSpace, WindowContext, + Context, DispatchPhase, ElementId, EntityId, EventEmitter, FocusHandle, FocusOutEvent, + FocusableView, FontId, FontStyle, FontWeight, HighlightStyle, Hsla, InteractiveText, + KeyContext, ListSizingBehavior, Model, MouseButton, PaintQuad, ParentElement, Pixels, Render, + SharedString, Size, StrikethroughStyle, Styled, StyledText, Subscription, Task, TextStyle, + UnderlineStyle, UniformListScrollHandle, View, ViewContext, ViewInputHandler, VisualContext, + WeakFocusHandle, WeakView, WhiteSpace, WindowContext, }; use highlight_matching_bracket::refresh_matching_bracket_highlights; use hover_popover::{hide_hover, HoverState}; use hunk_diff::ExpandedHunks; -pub(crate) use hunk_diff::HunkToExpand; +pub(crate) use hunk_diff::HoveredHunk; use indent_guides::ActiveIndentGuidesState; use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy}; pub use inline_completion_provider::*; @@ -89,18 +91,23 @@ use language::{ CursorShape, Diagnostic, Documentation, IndentKind, IndentSize, Language, OffsetRangeExt, Point, Selection, SelectionGoal, TransactionId, }; -use language::{BufferRow, Runnable, RunnableRange}; +use language::{point_to_lsp, BufferRow, Runnable, RunnableRange}; +use linked_editing_ranges::refresh_linked_ranges; use task::{ResolvedTask, TaskTemplate, TaskVariables}; use hover_links::{HoverLink, HoveredLinkState, InlayHighlight}; -use lsp::{DiagnosticSeverity, LanguageServerId}; +pub use lsp::CompletionContext; +use lsp::{ + CompletionItemKind, CompletionTriggerKind, DiagnosticSeverity, InsertTextFormat, + LanguageServerId, +}; use mouse_context_menu::MouseContextMenu; use movement::TextLayoutDetails; pub use multi_buffer::{ Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint, }; -use multi_buffer::{MultiBufferPoint, MultiBufferRow, ToOffsetUtf16}; +use multi_buffer::{ExpandExcerptDirection, MultiBufferPoint, MultiBufferRow, ToOffsetUtf16}; use ordered_float::OrderedFloat; use parking_lot::{Mutex, RwLock}; use project::project_settings::{GitGutterSetting, ProjectSettings}; @@ -113,18 +120,19 @@ use rpc::{proto::*, ErrorExt}; use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager, ScrollbarAutoHide}; use selections_collection::{resolve_multiple, MutableSelectionsCollection, SelectionsCollection}; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore}; +use settings::{update_settings_file, Settings, SettingsStore}; use smallvec::SmallVec; use snippet::Snippet; -use std::ops::Not as _; use std::{ any::TypeId, borrow::Cow, + cell::RefCell, cmp::{self, Ordering, Reverse}, mem, num::NonZeroU32, - ops::{ControlFlow, Deref, DerefMut, Range, RangeInclusive}, - path::Path, + ops::{ControlFlow, Deref, DerefMut, Not as _, Range, RangeInclusive}, + path::{Path, PathBuf}, + rc::Rc, sync::Arc, time::{Duration, Instant}, }; @@ -136,8 +144,8 @@ use theme::{ ThemeColors, ThemeSettings, }; use ui::{ - h_flex, prelude::*, ButtonSize, ButtonStyle, IconButton, IconName, IconSize, ListItem, Popover, - Tooltip, + h_flex, prelude::*, ButtonSize, ButtonStyle, Disclosure, IconButton, IconName, IconSize, + ListItem, Popover, Tooltip, }; use util::{defer, maybe, post_inc, RangeExt, ResultExt, TryFutureExt}; use workspace::item::{ItemHandle, PreviewTabsSettings}; @@ -145,10 +153,14 @@ use workspace::notifications::{DetachAndPromptErr, NotificationId}; use workspace::{ searchable::SearchEvent, ItemNavHistory, SplitDirection, ViewId, Workspace, WorkspaceId, }; -use workspace::{OpenInTerminal, OpenTerminal, Toast}; +use workspace::{OpenInTerminal, OpenTerminal, TabBarSettings, Toast}; use crate::hover_links::find_url; +use crate::signature_help::{SignatureHelpHiddenBy, SignatureHelpState}; +pub const FILE_HEADER_HEIGHT: u8 = 1; +pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u8 = 1; +pub const MULTI_BUFFER_EXCERPT_FOOTER_HEIGHT: u8 = 1; pub const DEFAULT_MULTIBUFFER_CONTEXT: u32 = 2; const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); const MAX_LINE_LEN: usize = 1024; @@ -259,8 +271,9 @@ pub fn init(cx: &mut AppContext) { init_settings(cx); workspace::register_project_item::(cx); - workspace::register_followable_item::(cx); - workspace::register_deserializable_item::(cx); + workspace::FollowableViewRegistry::register::(cx); + workspace::register_serializable_item::(cx); + cx.observe_new_views( |workspace: &mut Workspace, _cx: &mut ViewContext| { workspace.register_action(Editor::new_file); @@ -329,7 +342,7 @@ pub enum SelectMode { #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum EditorMode { - SingleLine, + SingleLine { auto_width: bool }, AutoHeight { max_lines: usize }, Full, } @@ -374,10 +387,24 @@ impl Default for EditorStyle { type CompletionId = usize; +#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug, Default)] +struct EditorActionId(usize); + +impl EditorActionId { + pub fn post_inc(&mut self) -> Self { + let answer = self.0; + + *self = Self(answer + 1); + + Self(answer) + } +} + // type GetFieldEditorTheme = dyn Fn(&theme::Theme) -> theme::FieldEditor; // type OverrideTextStyle = dyn Fn(&EditorStyle) -> Option; type BackgroundHighlight = (fn(&ThemeColors) -> Hsla, Arc<[Range]>); +type GutterHighlight = (fn(&AppContext) -> Hsla, Arc<[Range]>); struct ScrollbarMarkerState { scrollbar_size: Size, @@ -429,6 +456,7 @@ struct BufferOffset(usize); /// See the [module level documentation](self) for more information. pub struct Editor { focus_handle: FocusHandle, + last_focused_descendant: Option, /// The text buffer being edited buffer: Model, /// Map of how text in the buffer should be displayed. @@ -436,6 +464,9 @@ pub struct Editor { pub display_map: Model, pub selections: SelectionsCollection, pub scroll_manager: ScrollManager, + /// When inline assist editors are linked, they all render cursors because + /// typing enters text into each of them, even the ones that aren't focused. + pub(crate) show_cursor_when_unfocused: bool, columnar_selection_tail: Option, add_selections_state: Option, select_next_state: Option, @@ -460,31 +491,37 @@ pub struct Editor { show_line_numbers: Option, show_git_diff_gutter: Option, show_code_actions: Option, + show_runnables: Option, show_wrap_guides: Option, show_indent_guides: Option, placeholder_text: Option>, highlight_order: usize, highlighted_rows: HashMap>, background_highlights: TreeMap, + gutter_highlights: TreeMap, scrollbar_marker_state: ScrollbarMarkerState, active_indent_guides_state: ActiveIndentGuidesState, nav_history: Option, context_menu: RwLock>, mouse_context_menu: Option, completion_tasks: Vec<(CompletionId, Task>)>, + signature_help_state: SignatureHelpState, + auto_signature_help: Option, find_all_references_task_sources: Vec, next_completion_id: CompletionId, completion_documentation_pre_resolve_debounce: DebouncedDelay, available_code_actions: Option<(Location, Arc<[CodeAction]>)>, code_actions_task: Option>, document_highlights_task: Option>, + linked_editing_range_task: Option>>, + linked_edit_ranges: linked_editing_ranges::LinkedEditingRanges, pending_rename: Option, searchable: bool, cursor_shape: CursorShape, - current_line_highlight: CurrentLineHighlight, + current_line_highlight: Option, collapse_matches: bool, autoindent_mode: Option, - workspace: Option<(WeakView, WorkspaceId)>, + workspace: Option<(WeakView, Option)>, keymap_context_layers: BTreeMap, input_enabled: bool, use_modal_editing: bool, @@ -505,13 +542,17 @@ pub struct Editor { gutter_dimensions: GutterDimensions, pub vim_replace_map: HashMap, String>, style: Option, - editor_actions: Vec)>>, + next_editor_action_id: EditorActionId, + editor_actions: Rc)>>>>, use_autoclose: bool, + use_auto_surround: bool, auto_replace_emoji_shortcode: bool, show_git_blame_gutter: bool, show_git_blame_inline: bool, show_git_blame_inline_delay_task: Option>, git_blame_inline_enabled: bool, + serialize_dirty_buffers: bool, + show_selection_menu: Option, blame: Option>, blame_subscription: Option, custom_context_menu: Option< @@ -524,6 +565,10 @@ pub struct Editor { expect_bounds_change: Option>, tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>, tasks_update_task: Option>, + previous_search_ranges: Option]>>, + file_header_size: u8, + breadcrumb_header: Option, + focused_block: Option, } #[derive(Clone)] @@ -533,6 +578,7 @@ pub struct EditorSnapshot { show_line_numbers: Option, show_git_diff_gutter: Option, show_code_actions: Option, + show_runnables: Option, render_git_blame_gutter: bool, pub display_snapshot: DisplaySnapshot, pub placeholder_text: Option>, @@ -554,6 +600,20 @@ pub struct GutterDimensions { pub git_blame_entries_width: Option, } +impl GutterDimensions { + /// The full width of the space taken up by the gutter. + pub fn full_width(&self) -> Pixels { + self.margin + self.width + } + + /// The width of the space reserved for the fold indicators, + /// use alongside 'justify_end' and `gutter_width` to + /// right align content with the line numbers + pub fn fold_area_width(&self) -> Pixels { + self.margin + self.right_padding + } +} + impl Default for GutterDimensions { fn default() -> Self { Self { @@ -726,7 +786,7 @@ pub struct RenameState { pub range: Range, pub old_name: Arc, pub editor: View, - block_id: BlockId, + block_id: CustomBlockId, } struct InvalidationStack(Vec); @@ -1077,11 +1137,10 @@ impl CompletionsMenu { None } else { Some( - h_flex().ml_4().child( - Label::new(text.clone()) - .size(LabelSize::Small) - .color(Color::Muted), - ), + Label::new(text.clone()) + .ml_4() + .size(LabelSize::Small) + .color(Color::Muted), ) } } else { @@ -1104,7 +1163,7 @@ impl CompletionsMenu { } })) .child(h_flex().overflow_hidden().child(completion_label)) - .end_slot::

(documentation_label), + .end_slot::