Compare commits
104 Commits
v0.126.2
...
smaller-oc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8107e6d9db | ||
|
|
91a0923fc4 | ||
|
|
b4ddc83e85 | ||
|
|
3bd9d14420 | ||
|
|
95b311cb90 | ||
|
|
8eea281288 | ||
|
|
373a4e7614 | ||
|
|
f9f9f0670f | ||
|
|
20d5f5e8da | ||
|
|
f066dd268f | ||
|
|
0be20d0817 | ||
|
|
a04932c4eb | ||
|
|
ceadb39c38 | ||
|
|
2244419dfd | ||
|
|
a8fa1f7363 | ||
|
|
eb5e18c66d | ||
|
|
830e107921 | ||
|
|
45c4d35da8 | ||
|
|
298314d526 | ||
|
|
2f6c78b0c0 | ||
|
|
4700d33728 | ||
|
|
9afd78b35e | ||
|
|
9ff3cff6f8 | ||
|
|
d66f8f99bd | ||
|
|
269848775c | ||
|
|
39bd12a557 | ||
|
|
e1f8a1e8b2 | ||
|
|
41dc5fc412 | ||
|
|
f4a86e6fea | ||
|
|
597465b0f5 | ||
|
|
ccc939124f | ||
|
|
a03fecafbb | ||
|
|
ca696fd5f6 | ||
|
|
456efb53ad | ||
|
|
d4ec78f328 | ||
|
|
efe5203a09 | ||
|
|
146971fb02 | ||
|
|
347178039c | ||
|
|
6a7a3b257a | ||
|
|
8a6264d933 | ||
|
|
5abcc1c3c5 | ||
|
|
977af37cfe | ||
|
|
1756c1fc1e | ||
|
|
be953b78ef | ||
|
|
51ebe0eb01 | ||
|
|
a550b9cecf | ||
|
|
bf295eac90 | ||
|
|
fa5dfe19f8 | ||
|
|
7b73e2824b | ||
|
|
1081ba7a62 | ||
|
|
ed8aa6d200 | ||
|
|
af564242e1 | ||
|
|
aa7be4b5d8 | ||
|
|
866d791760 | ||
|
|
f67abd2943 | ||
|
|
d247086b21 | ||
|
|
467a179837 | ||
|
|
b50f86735f | ||
|
|
e85d484952 | ||
|
|
2d83580df4 | ||
|
|
a90a667fd0 | ||
|
|
35c7b5d7dd | ||
|
|
ffebe2e4a6 | ||
|
|
e85f190128 | ||
|
|
284a57d4d1 | ||
|
|
9068911eb4 | ||
|
|
27518f4280 | ||
|
|
86748a09e7 | ||
|
|
b5370cd15a | ||
|
|
85e6bc94e9 | ||
|
|
105e654dce | ||
|
|
6b8984279f | ||
|
|
bc7fb9f253 | ||
|
|
d450fde1ed | ||
|
|
4c9c9df730 | ||
|
|
3a9ec906af | ||
|
|
01fe3eec4d | ||
|
|
0a07746381 | ||
|
|
026cdc617c | ||
|
|
4238793d16 | ||
|
|
1a9387035d | ||
|
|
4f53e6e9a0 | ||
|
|
75a42c27db | ||
|
|
4d2156e2ad | ||
|
|
9481b346e2 | ||
|
|
8a92d28663 | ||
|
|
8be4b4d75d | ||
|
|
c0edb5bd6c | ||
|
|
c8e03ce42a | ||
|
|
74e7611ceb | ||
|
|
0b87be71e6 | ||
|
|
ae5ec9224c | ||
|
|
ca37d39109 | ||
|
|
675ae24964 | ||
|
|
e273198ada | ||
|
|
af87fb98d0 | ||
|
|
effc317a06 | ||
|
|
6bbd09e28e | ||
|
|
06035dadea | ||
|
|
8357039419 | ||
|
|
59faef5800 | ||
|
|
a0fac3866a | ||
|
|
8352f39ff9 | ||
|
|
850ddddcac |
6
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
@@ -32,9 +32,9 @@ body:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: |
|
||||
If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue.
|
||||
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.
|
||||
description: Drag Zed.log into the text input below
|
||||
validations:
|
||||
required: false
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/2_crash_report.yml
vendored
6
.github/ISSUE_TEMPLATE/2_crash_report.yml
vendored
@@ -31,9 +31,9 @@ body:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: |
|
||||
If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue.
|
||||
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.
|
||||
description: Drag Zed.log into the text input below
|
||||
validations:
|
||||
required: false
|
||||
|
||||
98
.github/workflows/ci.yml
vendored
98
.github/workflows/ci.yml
vendored
@@ -86,12 +86,6 @@ jobs:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Install cargo-component
|
||||
run: |
|
||||
if ! which cargo-component > /dev/null; then
|
||||
cargo install cargo-component
|
||||
fi
|
||||
|
||||
- name: cargo clippy
|
||||
run: cargo xtask clippy
|
||||
|
||||
@@ -152,12 +146,12 @@ jobs:
|
||||
- name: Build Zed
|
||||
run: cargo build -p zed
|
||||
|
||||
bundle:
|
||||
name: Bundle macOS app
|
||||
bundle-mac:
|
||||
name: Create a macOS bundle
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- bundle
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
|
||||
needs: [macos_tests]
|
||||
env:
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
@@ -212,12 +206,12 @@ jobs:
|
||||
- name: Generate license file
|
||||
run: script/generate-licenses
|
||||
|
||||
- name: Create app bundle
|
||||
run: script/bundle
|
||||
- name: Create macOS app bundle
|
||||
run: script/bundle-mac
|
||||
|
||||
- name: Upload app bundle to workflow run if main branch or specific label
|
||||
uses: actions/upload-artifact@v3
|
||||
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
|
||||
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 }}.dmg
|
||||
path: target/release/Zed.dmg
|
||||
@@ -232,3 +226,81 @@ jobs:
|
||||
body: ""
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
bundle-deb:
|
||||
name: Create a *.deb Linux bundle
|
||||
runs-on: ubuntu-22.04 # keep the version fixed to avoid libc and dynamic linked library issues
|
||||
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 }}
|
||||
DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
|
||||
DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Configure linux
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: script/linux
|
||||
|
||||
- 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
|
||||
|
||||
# TODO linux : Find a way to add licenses to the final bundle
|
||||
# - name: Generate license file
|
||||
# run: script/generate-licenses
|
||||
|
||||
- name: Create Linux *.deb bundle
|
||||
run: script/bundle-linux
|
||||
|
||||
- name: Upload app 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 }}.deb
|
||||
path: target/release/*.deb
|
||||
|
||||
# TODO linux : make it stable enough to be uploaded as a release
|
||||
# - uses: softprops/action-gh-release@v1
|
||||
# name: Upload app bundle to release
|
||||
# if: ${{ env.RELEASE_CHANNEL == 'preview' || env.RELEASE_CHANNEL == 'stable' }}
|
||||
# with:
|
||||
# draft: true
|
||||
# prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }}
|
||||
# files: target/release/Zed.dmg
|
||||
# body: ""
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
55
.github/workflows/release_nightly.yml
vendored
55
.github/workflows/release_nightly.yml
vendored
@@ -50,8 +50,8 @@ jobs:
|
||||
- name: Run tests
|
||||
uses: ./.github/actions/run_tests
|
||||
|
||||
bundle:
|
||||
name: Bundle app
|
||||
bundle-mac:
|
||||
name: Create a macOS bundle
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
@@ -77,9 +77,6 @@ jobs:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Limit target directory size
|
||||
run: script/clear-target-dir-if-larger-than 100
|
||||
|
||||
- name: Set release channel to nightly
|
||||
run: |
|
||||
set -eu
|
||||
@@ -90,8 +87,50 @@ jobs:
|
||||
- name: Generate license file
|
||||
run: script/generate-licenses
|
||||
|
||||
- name: Create app bundle
|
||||
run: script/bundle
|
||||
- name: Create macOS app bundle
|
||||
run: script/bundle-mac
|
||||
|
||||
- name: Upload Zed Nightly
|
||||
run: script/upload-nightly
|
||||
run: script/upload-nightly macos
|
||||
|
||||
bundle-deb:
|
||||
name: Create a *.deb Linux bundle
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on: ubuntu-22.04 # keep the version fixed to avoid libc and dynamic linked library issues
|
||||
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
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Configure linux
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: script/linux
|
||||
|
||||
- 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
|
||||
|
||||
# TODO linux : find a way to add licenses to the final bundle
|
||||
# - name: Generate license file
|
||||
# run: script/generate-licenses
|
||||
|
||||
- name: Create Linux *.deb bundle
|
||||
run: script/bundle-linux
|
||||
|
||||
- name: Upload Zed Nightly
|
||||
run: script/upload-nightly linux-deb
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -23,4 +23,4 @@ DerivedData/
|
||||
.pytest_cache
|
||||
.venv
|
||||
.blob_store
|
||||
extensions/gleam/grammars
|
||||
.vscode
|
||||
|
||||
299
Cargo.lock
generated
299
Cargo.lock
generated
@@ -113,9 +113,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alacritty_terminal"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7ceabf6fc76511f616ca216b51398a2511f19ba9f71bcbd977999edff1b0d1"
|
||||
version = "0.22.1-dev"
|
||||
source = "git+https://github.com/alacritty/alacritty?rev=992011a4cd9a35f197acc0a0bd430d89a0d01013#992011a4cd9a35f197acc0a0bd430d89a0d01013"
|
||||
dependencies = [
|
||||
"base64 0.21.4",
|
||||
"bitflags 2.4.2",
|
||||
@@ -2223,6 +2222,7 @@ dependencies = [
|
||||
"aws-sdk-s3",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"base64 0.13.1",
|
||||
"call",
|
||||
"channel",
|
||||
"chrono",
|
||||
@@ -2272,6 +2272,7 @@ dependencies = [
|
||||
"settings",
|
||||
"sha2 0.10.7",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
"telemetry_events",
|
||||
"text",
|
||||
"theme",
|
||||
@@ -2301,8 +2302,8 @@ dependencies = [
|
||||
"collections",
|
||||
"db",
|
||||
"editor",
|
||||
"emojis",
|
||||
"extensions_ui",
|
||||
"feedback",
|
||||
"futures 0.3.28",
|
||||
"fuzzy",
|
||||
"gpui",
|
||||
@@ -3009,15 +3010,6 @@ dependencies = [
|
||||
"util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "debugid"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d"
|
||||
dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deflate"
|
||||
version = "0.8.6"
|
||||
@@ -3157,16 +3149,6 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories-next"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"dirs-sys-next",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "3.0.2"
|
||||
@@ -3281,6 +3263,7 @@ dependencies = [
|
||||
"copilot",
|
||||
"ctor",
|
||||
"db",
|
||||
"emojis",
|
||||
"env_logger",
|
||||
"futures 0.3.28",
|
||||
"fuzzy",
|
||||
@@ -3349,6 +3332,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "emojis"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ee61eb945bff65ee7d19d157d39c67c33290ff0742907413fd5eefd29edc979"
|
||||
dependencies = [
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "0.3.6"
|
||||
@@ -3524,7 +3516,10 @@ dependencies = [
|
||||
"async-compression",
|
||||
"async-tar",
|
||||
"async-trait",
|
||||
"cap-std",
|
||||
"collections",
|
||||
"ctor",
|
||||
"env_logger",
|
||||
"fs",
|
||||
"futures 0.3.28",
|
||||
"gpui",
|
||||
@@ -3532,6 +3527,7 @@ dependencies = [
|
||||
"log",
|
||||
"lsp",
|
||||
"node_runtime",
|
||||
"parking_lot 0.11.2",
|
||||
"project",
|
||||
"schemars",
|
||||
"serde",
|
||||
@@ -3540,22 +3536,28 @@ dependencies = [
|
||||
"theme",
|
||||
"toml 0.8.10",
|
||||
"util",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"wit-component 0.20.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extensions_ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"client",
|
||||
"editor",
|
||||
"extension",
|
||||
"fuzzy",
|
||||
"gpui",
|
||||
"settings",
|
||||
"smallvec",
|
||||
"theme",
|
||||
"ui",
|
||||
"util",
|
||||
"workspace",
|
||||
]
|
||||
|
||||
@@ -4144,28 +4146,6 @@ dependencies = [
|
||||
"thread_local",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fxhash"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fxprof-processed-profile"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd"
|
||||
dependencies = [
|
||||
"bitflags 2.4.2",
|
||||
"debugid",
|
||||
"fxhash",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -4307,9 +4287,16 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"editor",
|
||||
"gpui",
|
||||
"indoc",
|
||||
"language",
|
||||
"menu",
|
||||
"project",
|
||||
"rope",
|
||||
"serde_json",
|
||||
"text",
|
||||
"theme",
|
||||
"tree-sitter-rust",
|
||||
"tree-sitter-typescript",
|
||||
"ui",
|
||||
"util",
|
||||
"workspace",
|
||||
@@ -5039,26 +5026,6 @@ version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38"
|
||||
|
||||
[[package]]
|
||||
name = "ittapi"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ittapi-sys",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ittapi-sys"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.19.0"
|
||||
@@ -5337,7 +5304,6 @@ dependencies = [
|
||||
"tree-sitter-elm",
|
||||
"tree-sitter-embedded-template",
|
||||
"tree-sitter-erlang",
|
||||
"tree-sitter-gitcommit",
|
||||
"tree-sitter-gleam",
|
||||
"tree-sitter-glsl",
|
||||
"tree-sitter-go",
|
||||
@@ -5365,7 +5331,6 @@ dependencies = [
|
||||
"tree-sitter-svelte",
|
||||
"tree-sitter-toml",
|
||||
"tree-sitter-typescript",
|
||||
"tree-sitter-uiua",
|
||||
"tree-sitter-vue",
|
||||
"tree-sitter-yaml",
|
||||
"tree-sitter-zig",
|
||||
@@ -5905,9 +5870,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.8"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2"
|
||||
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -6170,7 +6135,7 @@ dependencies = [
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio 0.8.8",
|
||||
"mio 0.8.11",
|
||||
"walkdir",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
@@ -6656,12 +6621,19 @@ dependencies = [
|
||||
"editor",
|
||||
"fuzzy",
|
||||
"gpui",
|
||||
"indoc",
|
||||
"language",
|
||||
"menu",
|
||||
"ordered-float 2.10.0",
|
||||
"picker",
|
||||
"project",
|
||||
"rope",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"smol",
|
||||
"theme",
|
||||
"tree-sitter-rust",
|
||||
"tree-sitter-typescript",
|
||||
"ui",
|
||||
"util",
|
||||
"workspace",
|
||||
@@ -6893,6 +6865,24 @@ dependencies = [
|
||||
"indexmap 2.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b"
|
||||
dependencies = [
|
||||
"siphasher 0.3.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "picker"
|
||||
version = "0.1.0"
|
||||
@@ -8949,6 +8939,12 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b8de496cf83d4ed58b6be86c3a275b8602f6ffe98d3024a869e124147a9a3ac"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.9"
|
||||
@@ -9591,7 +9587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c536faaff1a10837cfe373142583f6e27d81e96beba339147e77b67c9f260ff"
|
||||
dependencies = [
|
||||
"float-cmp",
|
||||
"siphasher",
|
||||
"siphasher 0.2.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10076,7 +10072,7 @@ dependencies = [
|
||||
"backtrace",
|
||||
"bytes 1.5.0",
|
||||
"libc",
|
||||
"mio 0.8.8",
|
||||
"mio 0.8.11",
|
||||
"num_cpus",
|
||||
"parking_lot 0.12.1",
|
||||
"pin-project-lite",
|
||||
@@ -10513,15 +10509,6 @@ dependencies = [
|
||||
"tree-sitter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-gitcommit"
|
||||
version = "0.3.3"
|
||||
source = "git+https://github.com/gbprod/tree-sitter-gitcommit#7c01af8d227b5344f62aade2ff00f19bd0c458ca"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-gleam"
|
||||
version = "0.34.0"
|
||||
@@ -10781,15 +10768,6 @@ dependencies = [
|
||||
"tree-sitter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-uiua"
|
||||
version = "0.10.0"
|
||||
source = "git+https://github.com/shnarazk/tree-sitter-uiua?rev=21dc2db39494585bf29a3f86d5add6e9d11a22ba#21dc2db39494585bf29a3f86d5add6e9d11a22ba"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-vue"
|
||||
version = "0.0.1"
|
||||
@@ -11068,7 +11046,7 @@ dependencies = [
|
||||
"roxmltree 0.14.1",
|
||||
"rustybuzz 0.3.0",
|
||||
"simplecss",
|
||||
"siphasher",
|
||||
"siphasher 0.2.3",
|
||||
"svgtypes",
|
||||
"ttf-parser 0.12.3",
|
||||
"unicode-bidi",
|
||||
@@ -11105,6 +11083,7 @@ dependencies = [
|
||||
"log",
|
||||
"parking_lot 0.11.2",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -11405,15 +11384,6 @@ dependencies = [
|
||||
"leb128",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.201.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9c7d2731df60006819b013f64ccc2019691deccf6e11a1804bc850cd6748f1a"
|
||||
dependencies = [
|
||||
"leb128",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.10.20"
|
||||
@@ -11426,7 +11396,7 @@ dependencies = [
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"spdx",
|
||||
"wasm-encoder 0.41.2",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
@@ -11457,41 +11427,33 @@ version = "18.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c843b8bc4dd4f3a76173ba93405c71111d570af0d90ea5f6299c705d0c2add2"
|
||||
dependencies = [
|
||||
"addr2line",
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bincode",
|
||||
"bumpalo",
|
||||
"cfg-if 1.0.0",
|
||||
"encoding_rs",
|
||||
"fxprof-processed-profile",
|
||||
"gimli",
|
||||
"indexmap 2.0.0",
|
||||
"ittapi",
|
||||
"libc",
|
||||
"log",
|
||||
"object",
|
||||
"once_cell",
|
||||
"paste",
|
||||
"rayon",
|
||||
"rustix 0.38.30",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"target-lexicon",
|
||||
"wasm-encoder 0.41.2",
|
||||
"wasmparser",
|
||||
"wasmtime-cache",
|
||||
"wasmtime-component-macro",
|
||||
"wasmtime-component-util",
|
||||
"wasmtime-cranelift",
|
||||
"wasmtime-environ",
|
||||
"wasmtime-fiber",
|
||||
"wasmtime-jit-debug",
|
||||
"wasmtime-jit-icache-coherence",
|
||||
"wasmtime-runtime",
|
||||
"wasmtime-winch",
|
||||
"wat",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
@@ -11528,26 +11490,6 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-cache"
|
||||
version = "18.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fb4fc2bbf9c790a57875eba65588fa97acf57a7d784dc86d057e648d9a1ed91"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.21.4",
|
||||
"bincode",
|
||||
"directories-next",
|
||||
"log",
|
||||
"rustix 0.38.30",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"sha2 0.10.7",
|
||||
"toml 0.5.11",
|
||||
"windows-sys 0.52.0",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-component-macro"
|
||||
version = "18.0.2"
|
||||
@@ -11629,7 +11571,7 @@ dependencies = [
|
||||
"serde_derive",
|
||||
"target-lexicon",
|
||||
"thiserror",
|
||||
"wasm-encoder 0.41.2",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
"wasmprinter",
|
||||
"wasmtime-component-util",
|
||||
@@ -11651,18 +11593,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-jit-debug"
|
||||
version = "18.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "833dae95bc7a4f9177bf93f9497419763535b74e37eb8c37be53937d3281e287"
|
||||
dependencies = [
|
||||
"object",
|
||||
"once_cell",
|
||||
"rustix 0.38.30",
|
||||
"wasmtime-versioned-export-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-jit-icache-coherence"
|
||||
version = "18.0.2"
|
||||
@@ -11694,11 +11624,10 @@ dependencies = [
|
||||
"psm",
|
||||
"rustix 0.38.30",
|
||||
"sptr",
|
||||
"wasm-encoder 0.41.2",
|
||||
"wasm-encoder",
|
||||
"wasmtime-asm-macros",
|
||||
"wasmtime-environ",
|
||||
"wasmtime-fiber",
|
||||
"wasmtime-jit-debug",
|
||||
"wasmtime-versioned-export-macros",
|
||||
"wasmtime-wmemcheck",
|
||||
"windows-sys 0.52.0",
|
||||
@@ -11805,28 +11734,6 @@ dependencies = [
|
||||
"leb128",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "201.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ef6e1ef34d7da3e2b374fd2b1a9c0227aff6cad596e1b24df9b58d0f6222faa"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"leb128",
|
||||
"memchr",
|
||||
"unicode-width",
|
||||
"wasm-encoder 0.201.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wat"
|
||||
version = "1.201.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "453d5b37a45b98dee4f4cb68015fc73634d7883bbef1c65e6e9c78d454cf3f32"
|
||||
dependencies = [
|
||||
"wast 201.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.3"
|
||||
@@ -12129,6 +12036,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efc5cf48f83140dcaab716eeaea345f9e93d0018fb81162753a3f76c3397b538"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-targets 0.52.4",
|
||||
]
|
||||
|
||||
@@ -12142,6 +12051,28 @@ dependencies = [
|
||||
"windows-targets 0.52.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "942ac266be9249c84ca862f0a164a39533dc2f6f33dc98ec89c8da99b82ea0bd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.48",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da33557140a288fae4e1d5f8873aaf9eb6613a9cf82c3e070223ff177f598b60"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.48",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.0"
|
||||
@@ -12426,7 +12357,7 @@ dependencies = [
|
||||
"heck 0.4.1",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
"wit-component 0.21.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12443,6 +12374,25 @@ dependencies = [
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4436190e87b4e539807bcdcf5b817e79d2e29e16bc5ddb6445413fe3d1f5716"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.4.2",
|
||||
"indexmap 2.0.0",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser 0.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.21.0"
|
||||
@@ -12456,7 +12406,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder 0.41.2",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser 0.14.0",
|
||||
@@ -12506,7 +12456,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"log",
|
||||
"thiserror",
|
||||
"wast 35.0.2",
|
||||
"wast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12824,7 +12774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "0.126.2"
|
||||
version = "0.127.0"
|
||||
dependencies = [
|
||||
"activity_indicator",
|
||||
"anyhow",
|
||||
@@ -12854,13 +12804,11 @@ dependencies = [
|
||||
"feedback",
|
||||
"file_finder",
|
||||
"fs",
|
||||
"fsevent",
|
||||
"futures 0.3.28",
|
||||
"go_to_line",
|
||||
"gpui",
|
||||
"install_cli",
|
||||
"isahc",
|
||||
"itertools 0.11.0",
|
||||
"journal",
|
||||
"language",
|
||||
"language_selector",
|
||||
@@ -12926,6 +12874,13 @@ dependencies = [
|
||||
"zed_extension_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zed_uiua"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"zed_extension_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeno"
|
||||
version = "0.2.3"
|
||||
|
||||
41
Cargo.toml
41
Cargo.toml
@@ -92,7 +92,10 @@ members = [
|
||||
"crates/workspace",
|
||||
"crates/zed",
|
||||
"crates/zed_actions",
|
||||
|
||||
"extensions/gleam",
|
||||
"extensions/uiua",
|
||||
|
||||
"tooling/xtask",
|
||||
]
|
||||
default-members = ["crates/zed"]
|
||||
@@ -105,6 +108,7 @@ assets = { path = "crates/assets" }
|
||||
assistant = { path = "crates/assistant" }
|
||||
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" }
|
||||
@@ -200,6 +204,7 @@ bitflags = "2.4.2"
|
||||
blade-graphics = { git = "https://github.com/kvark/blade", rev = "43721bf42d298b7cbee2195ee66f73a5f1c7b2fc" }
|
||||
blade-macros = { git = "https://github.com/kvark/blade", rev = "43721bf42d298b7cbee2195ee66f73a5f1c7b2fc" }
|
||||
blade-rwh = { package = "raw-window-handle", version = "0.5" }
|
||||
cap-std = "2.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
clap = "4.4"
|
||||
clickhouse = { version = "0.11.6" }
|
||||
@@ -207,6 +212,7 @@ ctor = "0.2.6"
|
||||
core-foundation = { version = "0.9.3" }
|
||||
core-foundation-sys = "0.8.6"
|
||||
derive_more = "0.99.17"
|
||||
emojis = "0.6.1"
|
||||
env_logger = "0.9"
|
||||
futures = "0.3"
|
||||
git2 = { version = "0.15", default-features = false }
|
||||
@@ -215,7 +221,10 @@ hex = "0.4.3"
|
||||
ignore = "0.4.22"
|
||||
indoc = "1"
|
||||
# We explicitly disable http2 support in isahc.
|
||||
isahc = { version = "1.7.2", default-features = false, features = ["static-curl", "text-decoding"] }
|
||||
isahc = { version = "1.7.2", default-features = false, features = [
|
||||
"static-curl",
|
||||
"text-decoding",
|
||||
] }
|
||||
itertools = "0.11.0"
|
||||
lazy_static = "1.4.0"
|
||||
linkify = "0.10.0"
|
||||
@@ -238,18 +247,26 @@ semver = "1.0"
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
serde_derive = { version = "1.0", features = ["deserialize_in_place"] }
|
||||
serde_json = { version = "1.0", features = ["preserve_order", "raw_value"] }
|
||||
serde_json_lenient = { version = "0.1", features = ["preserve_order", "raw_value"] }
|
||||
serde_json_lenient = { version = "0.1", features = [
|
||||
"preserve_order",
|
||||
"raw_value",
|
||||
] }
|
||||
serde_repr = "0.1"
|
||||
sha2 = "0.10"
|
||||
shellexpand = "2.1.0"
|
||||
smallvec = { version = "1.6", features = ["union"] }
|
||||
smol = "1.2"
|
||||
strum = { version = "0.25.0", features = ["derive"] }
|
||||
subtle = "2.5.0"
|
||||
sysinfo = "0.29.10"
|
||||
tempfile = "3.9.0"
|
||||
thiserror = "1.0.29"
|
||||
tiktoken-rs = "0.5.7"
|
||||
time = { version = "0.3", features = ["serde", "serde-well-known", "formatting"] }
|
||||
time = { version = "0.3", features = [
|
||||
"serde",
|
||||
"serde-well-known",
|
||||
"formatting",
|
||||
] }
|
||||
toml = "0.8"
|
||||
tower-http = "0.4.4"
|
||||
tree-sitter = { version = "0.20", features = ["wasm"] }
|
||||
@@ -266,7 +283,6 @@ tree-sitter-elixir = { git = "https://github.com/elixir-lang/tree-sitter-elixir"
|
||||
tree-sitter-elm = { git = "https://github.com/elm-tooling/tree-sitter-elm", rev = "692c50c0b961364c40299e73c1306aecb5d20f40" }
|
||||
tree-sitter-embedded-template = "0.20.0"
|
||||
tree-sitter-erlang = "0.4.0"
|
||||
tree-sitter-gitcommit = { git = "https://github.com/gbprod/tree-sitter-gitcommit" }
|
||||
tree-sitter-gleam = { git = "https://github.com/gleam-lang/tree-sitter-gleam", rev = "58b7cac8fc14c92b0677c542610d8738c373fa81" }
|
||||
tree-sitter-glsl = { git = "https://github.com/theHamsta/tree-sitter-glsl", rev = "2a56fb7bc8bb03a1892b4741279dd0a8758b7fb3" }
|
||||
tree-sitter-go = { git = "https://github.com/tree-sitter/tree-sitter-go", rev = "aeb2f33b366fd78d5789ff104956ce23508b85db" }
|
||||
@@ -295,7 +311,6 @@ tree-sitter-scheme = { git = "https://github.com/6cdh/tree-sitter-scheme", rev =
|
||||
tree-sitter-svelte = { git = "https://github.com/Himujjal/tree-sitter-svelte", rev = "bd60db7d3d06f89b6ec3b287c9a6e9190b5564bd" }
|
||||
tree-sitter-toml = { git = "https://github.com/tree-sitter/tree-sitter-toml", rev = "342d9be207c2dba869b9967124c679b5e6fd0ebe" }
|
||||
tree-sitter-typescript = { git = "https://github.com/tree-sitter/tree-sitter-typescript", rev = "5d20856f34315b068c41edaee2ac8a100081d259" }
|
||||
tree-sitter-uiua = { git = "https://github.com/shnarazk/tree-sitter-uiua", rev = "21dc2db39494585bf29a3f86d5add6e9d11a22ba" }
|
||||
tree-sitter-vue = { git = "https://github.com/zed-industries/tree-sitter-vue", rev = "6608d9d60c386f19d80af7d8132322fa11199c42" }
|
||||
tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "f545a41f57502e1b5ddf2a6668896c1b0620f930" }
|
||||
tree-sitter-zig = { git = "https://github.com/maxxnino/tree-sitter-zig", rev = "0d08703e4c3f426ec61695d7617415fff97029bd" }
|
||||
@@ -304,22 +319,35 @@ unicase = "2.6"
|
||||
url = "2.2"
|
||||
uuid = { version = "1.1.2", features = ["v4"] }
|
||||
wasmparser = "0.121"
|
||||
wasmtime = "18.0"
|
||||
wasm-encoder = "0.41"
|
||||
wasmtime = { version = "18.0", default-features = false, features = ["async", "demangle", "runtime", "cranelift", "component-model"] }
|
||||
wasmtime-wasi = "18.0"
|
||||
which = "6.0.0"
|
||||
wit-component = "0.20"
|
||||
sys-locale = "0.3.1"
|
||||
|
||||
[workspace.dependencies.windows]
|
||||
version = "0.53.0"
|
||||
features = [
|
||||
"implement",
|
||||
"Wdk_System_SystemServices",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Graphics_DirectComposition",
|
||||
"Win32_UI_Controls",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_SystemInformation",
|
||||
"Win32_System_SystemServices",
|
||||
"Win32_System_Time",
|
||||
"Win32_Security",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Com_StructuredStorage",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_DataExchange",
|
||||
"Win32_System_Ole",
|
||||
"Win32_System_Com",
|
||||
]
|
||||
|
||||
[patch.crates-io]
|
||||
@@ -381,7 +409,6 @@ non_canonical_clone_impl = "allow"
|
||||
non_canonical_partial_ord_impl = "allow"
|
||||
reversed_empty_ranges = "allow"
|
||||
single_range_in_vec_init = "allow"
|
||||
suspicious_to_owned = "allow"
|
||||
type_complexity = "allow"
|
||||
|
||||
[workspace.metadata.cargo-machete]
|
||||
|
||||
@@ -22,7 +22,8 @@ RUN --mount=type=cache,target=./target \
|
||||
# Copy collab server binary to the runtime image
|
||||
FROM debian:bookworm-slim as runtime
|
||||
RUN apt-get update; \
|
||||
apt-get install -y --no-install-recommends libcurl4-openssl-dev ca-certificates linux-perf
|
||||
apt-get install -y --no-install-recommends libcurl4-openssl-dev ca-certificates \
|
||||
linux-perf binutils
|
||||
WORKDIR app
|
||||
COPY --from=builder /app/collab /app/collab
|
||||
COPY --from=builder /app/crates/collab/migrations /app/migrations
|
||||
|
||||
@@ -10,7 +10,7 @@ You can [download](https://zed.dev/download) Zed today for macOS (v10.15+).
|
||||
|
||||
Support for additional platforms is on our [roadmap](https://zed.dev/roadmap):
|
||||
|
||||
- Linux ([tracking issue](https://github.com/zed-industries/zed/issues/5395))
|
||||
- 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))
|
||||
|
||||
@@ -23,6 +23,7 @@ brew install zed
|
||||
|
||||
- [Building Zed for macOS](./docs/src/developing_zed__building_zed_macos.md)
|
||||
- [Building Zed for Linux](./docs/src/developing_zed__building_zed_linux.md)
|
||||
- [Building Zed for Windows](./docs/src/developing_zed__building_zed_windows.md)
|
||||
- [Running Collaboration Locally](./docs/src/developing_zed__local_collaboration.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
4
assets/icons/reply_arrow.svg
Normal file
4
assets/icons/reply_arrow.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 17V15.8C20 14.1198 20 13.2798 19.673 12.638C19.3854 12.0735 18.9265 11.6146 18.362 11.327C17.7202 11 16.8802 11 15.2 11H4M4 11L8 7M4 11L8 15" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 468 B |
@@ -118,7 +118,8 @@
|
||||
"stop_at_soft_wraps": true
|
||||
}
|
||||
],
|
||||
"ctrl-;": "editor::ToggleLineNumbers"
|
||||
"ctrl-;": "editor::ToggleLineNumbers",
|
||||
"ctrl-alt-z": "editor::RevertSelectedHunks"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -153,7 +153,8 @@
|
||||
}
|
||||
],
|
||||
"ctrl-cmd-space": "editor::ShowCharacterPalette",
|
||||
"cmd-;": "editor::ToggleLineNumbers"
|
||||
"cmd-;": "editor::ToggleLineNumbers",
|
||||
"cmd-alt-z": "editor::RevertSelectedHunks"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -315,6 +316,18 @@
|
||||
"cmd-ctrl-p": "editor::AddSelectionAbove",
|
||||
"cmd-alt-down": "editor::AddSelectionBelow",
|
||||
"cmd-ctrl-n": "editor::AddSelectionBelow",
|
||||
"cmd-shift-k": "editor::DeleteLine",
|
||||
"alt-up": "editor::MoveLineUp",
|
||||
"alt-down": "editor::MoveLineDown",
|
||||
"alt-shift-up": [
|
||||
"editor::DuplicateLine",
|
||||
{
|
||||
"move_upwards": true
|
||||
}
|
||||
],
|
||||
"alt-shift-down": "editor::DuplicateLine",
|
||||
"ctrl-shift-right": "editor::SelectLargerSyntaxNode",
|
||||
"ctrl-shift-left": "editor::SelectSmallerSyntaxNode",
|
||||
"cmd-d": [
|
||||
"editor::SelectNext",
|
||||
{
|
||||
@@ -347,8 +360,6 @@
|
||||
"advance_downwards": false
|
||||
}
|
||||
],
|
||||
"alt-up": "editor::SelectLargerSyntaxNode",
|
||||
"alt-down": "editor::SelectSmallerSyntaxNode",
|
||||
"cmd-u": "editor::UndoSelection",
|
||||
"cmd-shift-u": "editor::RedoSelection",
|
||||
"f8": "editor::GoToDiagnostic",
|
||||
@@ -454,11 +465,7 @@
|
||||
{
|
||||
"context": "Editor",
|
||||
"bindings": {
|
||||
"ctrl-shift-k": "editor::DeleteLine",
|
||||
"cmd-shift-d": "editor::DuplicateLine",
|
||||
"ctrl-j": "editor::JoinLines",
|
||||
"ctrl-cmd-up": "editor::MoveLineUp",
|
||||
"ctrl-cmd-down": "editor::MoveLineDown",
|
||||
"ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart",
|
||||
"ctrl-alt-h": "editor::DeleteToPreviousSubwordStart",
|
||||
"ctrl-alt-delete": "editor::DeleteToNextSubwordEnd",
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
"advance_downwards": true
|
||||
}
|
||||
],
|
||||
"alt-up": "editor::SelectLargerSyntaxNode",
|
||||
"alt-down": "editor::SelectSmallerSyntaxNode",
|
||||
"shift-alt-up": "editor::MoveLineUp",
|
||||
"shift-alt-down": "editor::MoveLineDown",
|
||||
"cmd-alt-l": "editor::Format",
|
||||
|
||||
@@ -37,30 +37,42 @@
|
||||
"_": "vim::StartOfLineDownward",
|
||||
"g _": "vim::EndOfLineDownward",
|
||||
"shift-g": "vim::EndOfDocument",
|
||||
"w": "vim::NextWordStart",
|
||||
"{": "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
|
||||
}
|
||||
],
|
||||
"e": "vim::NextWordEnd",
|
||||
"shift-e": [
|
||||
"vim::NextWordEnd",
|
||||
{
|
||||
"ignorePunctuation": true
|
||||
}
|
||||
],
|
||||
"b": "vim::PreviousWordStart",
|
||||
"shift-b": [
|
||||
"vim::PreviousWordStart",
|
||||
{
|
||||
"ignorePunctuation": true
|
||||
}
|
||||
],
|
||||
"g shift-e": ["vim::PreviousWordEnd", { "ignorePunctuation": true }],
|
||||
|
||||
"n": "search::SelectNextMatch",
|
||||
"shift-n": "search::SelectPrevMatch",
|
||||
"%": "vim::Matching",
|
||||
@@ -117,8 +129,6 @@
|
||||
"ctrl-e": "vim::LineDown",
|
||||
"ctrl-y": "vim::LineUp",
|
||||
// "g" commands
|
||||
"g e": "vim::PreviousWordEnd",
|
||||
"g shift-e": ["vim::PreviousWordEnd", { "ignorePunctuation": true }],
|
||||
"g g": "vim::StartOfDocument",
|
||||
"g h": "editor::Hover",
|
||||
"g t": "pane::ActivateNextItem",
|
||||
@@ -353,7 +363,9 @@
|
||||
"> >": "vim::Indent",
|
||||
"< <": "vim::Outdent",
|
||||
"ctrl-pagedown": "pane::ActivateNextItem",
|
||||
"ctrl-pageup": "pane::ActivatePrevItem"
|
||||
"ctrl-pageup": "pane::ActivatePrevItem",
|
||||
"[ x": "editor::SelectLargerSyntaxNode",
|
||||
"] x": "editor::SelectSmallerSyntaxNode"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -211,6 +211,11 @@
|
||||
// Default width of the channels panel.
|
||||
"default_width": 240
|
||||
},
|
||||
"message_editor": {
|
||||
// Whether to automatically replace emoji shortcodes with emoji characters.
|
||||
// For example: typing `:wave:` gets replaced with `👋`.
|
||||
"auto_replace_emoji_shortcode": true
|
||||
},
|
||||
"notification_panel": {
|
||||
// Whether to show the collaboration panel button in the status bar.
|
||||
"button": true,
|
||||
@@ -590,7 +595,8 @@
|
||||
// Vim settings
|
||||
"vim": {
|
||||
"use_system_clipboard": "always",
|
||||
"use_multiline_find": false
|
||||
"use_multiline_find": false,
|
||||
"use_smartcase_find": false
|
||||
},
|
||||
// The server to connect to. If the environment variable
|
||||
// ZED_SERVER_URL is set, it will override this setting.
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
[
|
||||
{
|
||||
"label": "Example task",
|
||||
"command": "bash",
|
||||
// rest of the parameters are optional
|
||||
"args": ["-c", "for i in {1..5}; do echo \"Hello $i/5\"; sleep 1; done"],
|
||||
"command": "for i in {1..5}; do echo \"Hello $i/5\"; sleep 1; done",
|
||||
// Env overrides for the command, will be appended to the terminal's environment from the settings.
|
||||
"env": { "foo": "bar" },
|
||||
// Current working directory to spawn the command into, defaults to current project root.
|
||||
|
||||
@@ -31,9 +31,9 @@ use fs::Fs;
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
canvas, div, point, relative, rems, uniform_list, Action, AnyElement, AppContext,
|
||||
AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, EventEmitter,
|
||||
FocusHandle, FocusableView, FontStyle, FontWeight, HighlightStyle, InteractiveElement,
|
||||
IntoElement, Model, ModelContext, ParentElement, Pixels, PromptLevel, Render, SharedString,
|
||||
AsyncAppContext, AsyncWindowContext, ClipboardItem, Context, EventEmitter, FocusHandle,
|
||||
FocusableView, FontStyle, FontWeight, HighlightStyle, InteractiveElement, IntoElement, Model,
|
||||
ModelContext, ParentElement, Pixels, PromptLevel, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Subscription, Task, TextStyle, UniformListScrollHandle,
|
||||
View, ViewContext, VisualContext, WeakModel, WeakView, WhiteSpace, WindowContext,
|
||||
};
|
||||
@@ -1284,25 +1284,25 @@ impl Render for AssistantPanel {
|
||||
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| {
|
||||
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()
|
||||
.draw(
|
||||
bounds.origin,
|
||||
bounds.size.map(AvailableSpace::Definite),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
canvas(
|
||||
move |bounds, cx| {
|
||||
let mut list = 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();
|
||||
list.layout(bounds.origin, bounds.size.into(), cx);
|
||||
list
|
||||
},
|
||||
|_bounds, mut list, cx| list.paint(cx),
|
||||
)
|
||||
.size_full()
|
||||
.into_any_element()
|
||||
}),
|
||||
|
||||
@@ -4,11 +4,10 @@ use gpui::{
|
||||
ViewContext,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use std::cmp;
|
||||
use theme::ActiveTheme;
|
||||
use ui::{prelude::*, ButtonLike, ButtonStyle, Label, Tooltip};
|
||||
use workspace::{
|
||||
item::{BreadcrumbText, ItemEvent, ItemHandle},
|
||||
item::{ItemEvent, ItemHandle},
|
||||
ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
|
||||
};
|
||||
|
||||
@@ -32,30 +31,14 @@ impl EventEmitter<ToolbarItemEvent> for Breadcrumbs {}
|
||||
|
||||
impl Render for Breadcrumbs {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
const MAX_SEGMENTS: usize = 12;
|
||||
let element = h_flex().text_ui();
|
||||
let Some(active_item) = self.active_item.as_ref() else {
|
||||
return element;
|
||||
};
|
||||
let Some(mut segments) = active_item.breadcrumbs(cx.theme(), cx) else {
|
||||
let Some(segments) = active_item.breadcrumbs(cx.theme(), cx) else {
|
||||
return element;
|
||||
};
|
||||
|
||||
let prefix_end_ix = cmp::min(segments.len(), MAX_SEGMENTS / 2);
|
||||
let suffix_start_ix = cmp::max(
|
||||
prefix_end_ix,
|
||||
segments.len().saturating_sub(MAX_SEGMENTS / 2),
|
||||
);
|
||||
if suffix_start_ix > prefix_end_ix {
|
||||
segments.splice(
|
||||
prefix_end_ix..suffix_start_ix,
|
||||
Some(BreadcrumbText {
|
||||
text: "⋯".into(),
|
||||
highlights: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let highlighted_segments = segments.into_iter().map(|segment| {
|
||||
let mut text_style = cx.text_style();
|
||||
text_style.color = Color::Muted.color(cx);
|
||||
|
||||
@@ -3,9 +3,7 @@ mod channel_index;
|
||||
use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat, ChannelMessage};
|
||||
use anyhow::{anyhow, Result};
|
||||
use channel_index::ChannelIndex;
|
||||
use client::{
|
||||
ChannelId, Client, ClientSettings, HostedProjectId, Subscription, User, UserId, UserStore,
|
||||
};
|
||||
use client::{ChannelId, Client, ClientSettings, ProjectId, Subscription, User, UserId, UserStore};
|
||||
use collections::{hash_map, HashMap, HashSet};
|
||||
use futures::{channel::mpsc, future::Shared, Future, FutureExt, StreamExt};
|
||||
use gpui::{
|
||||
@@ -29,7 +27,7 @@ pub fn init(client: &Arc<Client>, user_store: Model<UserStore>, cx: &mut AppCont
|
||||
cx.set_global(GlobalChannelStore(channel_store));
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
struct NotesVersion {
|
||||
epoch: u64,
|
||||
version: clock::Global,
|
||||
@@ -37,7 +35,7 @@ struct NotesVersion {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostedProject {
|
||||
id: HostedProjectId,
|
||||
project_id: ProjectId,
|
||||
channel_id: ChannelId,
|
||||
name: SharedString,
|
||||
_visibility: proto::ChannelVisibility,
|
||||
@@ -46,7 +44,7 @@ pub struct HostedProject {
|
||||
impl From<proto::HostedProject> for HostedProject {
|
||||
fn from(project: proto::HostedProject) -> Self {
|
||||
Self {
|
||||
id: HostedProjectId(project.id),
|
||||
project_id: ProjectId(project.project_id),
|
||||
channel_id: ChannelId(project.channel_id),
|
||||
_visibility: project.visibility(),
|
||||
name: project.name.into(),
|
||||
@@ -59,7 +57,7 @@ pub struct ChannelStore {
|
||||
channel_invitations: Vec<Arc<Channel>>,
|
||||
channel_participants: HashMap<ChannelId, Vec<Arc<User>>>,
|
||||
channel_states: HashMap<ChannelId, ChannelState>,
|
||||
hosted_projects: HashMap<HostedProjectId, HostedProject>,
|
||||
hosted_projects: HashMap<ProjectId, HostedProject>,
|
||||
|
||||
outgoing_invites: HashSet<(ChannelId, UserId)>,
|
||||
update_channels_tx: mpsc::UnboundedSender<proto::UpdateChannels>,
|
||||
@@ -81,14 +79,14 @@ pub struct Channel {
|
||||
pub parent_path: Vec<ChannelId>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ChannelState {
|
||||
latest_chat_message: Option<u64>,
|
||||
latest_notes_versions: Option<NotesVersion>,
|
||||
latest_notes_version: NotesVersion,
|
||||
observed_notes_version: NotesVersion,
|
||||
observed_chat_message: Option<u64>,
|
||||
observed_notes_versions: Option<NotesVersion>,
|
||||
role: Option<ChannelRole>,
|
||||
projects: HashSet<HostedProjectId>,
|
||||
projects: HashSet<ProjectId>,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
@@ -305,8 +303,8 @@ impl ChannelStore {
|
||||
self.channel_index.by_id().get(&channel_id)
|
||||
}
|
||||
|
||||
pub fn projects_for_id(&self, channel_id: ChannelId) -> Vec<(SharedString, HostedProjectId)> {
|
||||
let mut projects: Vec<(SharedString, HostedProjectId)> = self
|
||||
pub fn projects_for_id(&self, channel_id: ChannelId) -> Vec<(SharedString, ProjectId)> {
|
||||
let mut projects: Vec<(SharedString, ProjectId)> = self
|
||||
.channel_states
|
||||
.get(&channel_id)
|
||||
.map(|state| state.projects.clone())
|
||||
@@ -1159,27 +1157,27 @@ impl ChannelStore {
|
||||
let hosted_project: HostedProject = hosted_project.into();
|
||||
if let Some(old_project) = self
|
||||
.hosted_projects
|
||||
.insert(hosted_project.id, hosted_project.clone())
|
||||
.insert(hosted_project.project_id, hosted_project.clone())
|
||||
{
|
||||
self.channel_states
|
||||
.entry(old_project.channel_id)
|
||||
.or_default()
|
||||
.remove_hosted_project(old_project.id);
|
||||
.remove_hosted_project(old_project.project_id);
|
||||
}
|
||||
self.channel_states
|
||||
.entry(hosted_project.channel_id)
|
||||
.or_default()
|
||||
.add_hosted_project(hosted_project.id);
|
||||
.add_hosted_project(hosted_project.project_id);
|
||||
}
|
||||
|
||||
for hosted_project_id in payload.deleted_hosted_projects {
|
||||
let hosted_project_id = HostedProjectId(hosted_project_id);
|
||||
let hosted_project_id = ProjectId(hosted_project_id);
|
||||
|
||||
if let Some(old_project) = self.hosted_projects.remove(&hosted_project_id) {
|
||||
self.channel_states
|
||||
.entry(old_project.channel_id)
|
||||
.or_default()
|
||||
.remove_hosted_project(old_project.id);
|
||||
.remove_hosted_project(old_project.project_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1236,19 +1234,12 @@ impl ChannelState {
|
||||
}
|
||||
|
||||
fn has_channel_buffer_changed(&self) -> bool {
|
||||
if let Some(latest_version) = &self.latest_notes_versions {
|
||||
if let Some(observed_version) = &self.observed_notes_versions {
|
||||
latest_version.epoch > observed_version.epoch
|
||||
|| (latest_version.epoch == observed_version.epoch
|
||||
&& latest_version
|
||||
.version
|
||||
.changed_since(&observed_version.version))
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
self.latest_notes_version.epoch > self.observed_notes_version.epoch
|
||||
|| (self.latest_notes_version.epoch == self.observed_notes_version.epoch
|
||||
&& self
|
||||
.latest_notes_version
|
||||
.version
|
||||
.changed_since(&self.observed_notes_version.version))
|
||||
}
|
||||
|
||||
fn has_new_messages(&self) -> bool {
|
||||
@@ -1275,36 +1266,32 @@ impl ChannelState {
|
||||
}
|
||||
|
||||
fn acknowledge_notes_version(&mut self, epoch: u64, version: &clock::Global) {
|
||||
if let Some(existing) = &mut self.observed_notes_versions {
|
||||
if existing.epoch == epoch {
|
||||
existing.version.join(version);
|
||||
return;
|
||||
}
|
||||
if self.observed_notes_version.epoch == epoch {
|
||||
self.observed_notes_version.version.join(version);
|
||||
} else {
|
||||
self.observed_notes_version = NotesVersion {
|
||||
epoch,
|
||||
version: version.clone(),
|
||||
};
|
||||
}
|
||||
self.observed_notes_versions = Some(NotesVersion {
|
||||
epoch,
|
||||
version: version.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
fn update_latest_notes_version(&mut self, epoch: u64, version: &clock::Global) {
|
||||
if let Some(existing) = &mut self.latest_notes_versions {
|
||||
if existing.epoch == epoch {
|
||||
existing.version.join(version);
|
||||
return;
|
||||
}
|
||||
if self.latest_notes_version.epoch == epoch {
|
||||
self.latest_notes_version.version.join(version);
|
||||
} else {
|
||||
self.latest_notes_version = NotesVersion {
|
||||
epoch,
|
||||
version: version.clone(),
|
||||
};
|
||||
}
|
||||
self.latest_notes_versions = Some(NotesVersion {
|
||||
epoch,
|
||||
version: version.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
fn add_hosted_project(&mut self, project_id: HostedProjectId) {
|
||||
fn add_hosted_project(&mut self, project_id: ProjectId) {
|
||||
self.projects.insert(project_id);
|
||||
}
|
||||
|
||||
fn remove_hosted_project(&mut self, project_id: HostedProjectId) {
|
||||
fn remove_hosted_project(&mut self, project_id: ProjectId) {
|
||||
self.projects.remove(&project_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ impl std::fmt::Display for ChannelId {
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
|
||||
pub struct HostedProjectId(pub u64);
|
||||
pub struct ProjectId(pub u64);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ParticipantIndex(pub u32);
|
||||
|
||||
@@ -23,6 +23,7 @@ aws-config = { version = "1.1.5" }
|
||||
aws-sdk-s3 = { version = "1.15.0" }
|
||||
axum = { version = "0.6", features = ["json", "headers", "ws"] }
|
||||
axum-extra = { version = "0.4", features = ["erased-json"] }
|
||||
base64.workspace = true
|
||||
chrono.workspace = true
|
||||
clock.workspace = true
|
||||
clickhouse.workspace = true
|
||||
@@ -48,6 +49,7 @@ serde_derive.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "json", "time", "uuid", "any"] }
|
||||
subtle.workspace = true
|
||||
rustc-demangle.workspace = true
|
||||
telemetry_events.workspace = true
|
||||
text.workspace = true
|
||||
|
||||
@@ -248,7 +248,10 @@ CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channe
|
||||
CREATE TABLE "buffers" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
|
||||
"epoch" INTEGER NOT NULL DEFAULT 0
|
||||
"epoch" INTEGER NOT NULL DEFAULT 0,
|
||||
"latest_operation_epoch" INTEGER,
|
||||
"latest_operation_replica_id" INTEGER,
|
||||
"latest_operation_lamport_timestamp" INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX "index_buffers_on_channel_id" ON "buffers" ("channel_id");
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Add migration script here
|
||||
|
||||
ALTER TABLE buffers ADD COLUMN latest_operation_epoch INTEGER;
|
||||
ALTER TABLE buffers ADD COLUMN latest_operation_lamport_timestamp INTEGER;
|
||||
ALTER TABLE buffers ADD COLUMN latest_operation_replica_id INTEGER;
|
||||
|
||||
WITH ops AS (
|
||||
SELECT DISTINCT ON (buffer_id) buffer_id, epoch, lamport_timestamp, replica_id
|
||||
FROM buffer_operations
|
||||
ORDER BY buffer_id, epoch DESC, lamport_timestamp DESC, replica_id DESC
|
||||
)
|
||||
UPDATE buffers
|
||||
SET latest_operation_epoch = ops.epoch,
|
||||
latest_operation_lamport_timestamp = ops.lamport_timestamp,
|
||||
latest_operation_replica_id = ops.replica_id
|
||||
FROM ops
|
||||
WHERE buffers.id = ops.buffer_id;
|
||||
@@ -9,14 +9,15 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
};
|
||||
use prometheus::{exponential_buckets, register_histogram, Histogram};
|
||||
use rand::thread_rng;
|
||||
use scrypt::{
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
password_hash::{PasswordHash, PasswordVerifier},
|
||||
Scrypt,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Digest;
|
||||
use std::sync::OnceLock;
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Impersonator(pub Option<db::User>);
|
||||
@@ -115,8 +116,7 @@ pub async fn create_access_token(
|
||||
) -> Result<String> {
|
||||
const VERSION: usize = 1;
|
||||
let access_token = rpc::auth::random_token();
|
||||
let access_token_hash =
|
||||
hash_access_token(&access_token).context("failed to hash access token")?;
|
||||
let access_token_hash = hash_access_token(&access_token);
|
||||
let id = db
|
||||
.create_access_token(
|
||||
user_id,
|
||||
@@ -132,23 +132,15 @@ pub async fn create_access_token(
|
||||
})?)
|
||||
}
|
||||
|
||||
fn hash_access_token(token: &str) -> Result<String> {
|
||||
// Avoid slow hashing in debug mode.
|
||||
let params = if cfg!(debug_assertions) {
|
||||
scrypt::Params::new(1, 1, 1).unwrap()
|
||||
} else {
|
||||
scrypt::Params::new(14, 8, 1).unwrap()
|
||||
};
|
||||
|
||||
Ok(Scrypt
|
||||
.hash_password(
|
||||
token.as_bytes(),
|
||||
None,
|
||||
params,
|
||||
&SaltString::generate(thread_rng()),
|
||||
)
|
||||
.map_err(anyhow::Error::new)?
|
||||
.to_string())
|
||||
/// Hashing prevents anyone with access to the database being able to login.
|
||||
/// As the token is randomly generated, we don't need to worry about scrypt-style
|
||||
/// protection.
|
||||
fn hash_access_token(token: &str) -> String {
|
||||
let digest = sha2::Sha256::digest(token);
|
||||
format!(
|
||||
"$sha256${}",
|
||||
base64::encode_config(digest, base64::URL_SAFE)
|
||||
)
|
||||
}
|
||||
|
||||
/// Encrypts the given access token with the given public key to avoid leaking it on the way
|
||||
@@ -190,15 +182,27 @@ pub async fn verify_access_token(
|
||||
if token_user_id != user_id {
|
||||
return Err(anyhow!("no such access token"))?;
|
||||
}
|
||||
|
||||
let db_hash = PasswordHash::new(&db_token.hash).map_err(anyhow::Error::new)?;
|
||||
let t0 = Instant::now();
|
||||
let is_valid = Scrypt
|
||||
.verify_password(token.token.as_bytes(), &db_hash)
|
||||
.is_ok();
|
||||
|
||||
let is_valid = if db_token.hash.starts_with("$scrypt$") {
|
||||
let db_hash = PasswordHash::new(&db_token.hash).map_err(anyhow::Error::new)?;
|
||||
Scrypt
|
||||
.verify_password(token.token.as_bytes(), &db_hash)
|
||||
.is_ok()
|
||||
} else {
|
||||
let token_hash = hash_access_token(&token.token);
|
||||
db_token.hash.as_bytes().ct_eq(token_hash.as_ref()).into()
|
||||
};
|
||||
|
||||
let duration = t0.elapsed();
|
||||
log::info!("hashed access token in {:?}", duration);
|
||||
metric_access_token_hashing_time.observe(duration.as_millis() as f64);
|
||||
|
||||
if is_valid && db_token.hash.starts_with("$scrypt$") {
|
||||
let new_hash = hash_access_token(&token.token);
|
||||
db.update_access_token_hash(db_token.id, &new_hash).await?;
|
||||
}
|
||||
|
||||
Ok(VerifyAccessTokenResult {
|
||||
is_valid,
|
||||
impersonator_id: if db_token.impersonated_user_id.is_some() {
|
||||
@@ -208,3 +212,145 @@ pub async fn verify_access_token(
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use rand::thread_rng;
|
||||
use scrypt::password_hash::{PasswordHasher, SaltString};
|
||||
use sea_orm::EntityTrait;
|
||||
|
||||
use super::*;
|
||||
use crate::db::{access_token, NewUserParams};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_verify_access_token(cx: &mut gpui::TestAppContext) {
|
||||
let test_db = crate::db::TestDb::postgres(cx.executor().clone());
|
||||
let db = test_db.db();
|
||||
|
||||
let user = db
|
||||
.create_user(
|
||||
"example@example.com",
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: "example".into(),
|
||||
github_user_id: 1,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let token = create_access_token(&db, user.user_id, None).await.unwrap();
|
||||
assert!(matches!(
|
||||
verify_access_token(&token, user.user_id, &db)
|
||||
.await
|
||||
.unwrap(),
|
||||
VerifyAccessTokenResult {
|
||||
is_valid: true,
|
||||
impersonator_id: None,
|
||||
}
|
||||
));
|
||||
|
||||
let old_token = create_previous_access_token(user.user_id, None, &db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let old_token_id = serde_json::from_str::<AccessTokenJson>(&old_token)
|
||||
.unwrap()
|
||||
.id;
|
||||
|
||||
let hash = db
|
||||
.transaction(|tx| async move {
|
||||
Ok(access_token::Entity::find_by_id(old_token_id)
|
||||
.one(&*tx)
|
||||
.await?)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.hash;
|
||||
assert!(hash.starts_with("$scrypt$"));
|
||||
|
||||
assert!(matches!(
|
||||
verify_access_token(&old_token, user.user_id, &db)
|
||||
.await
|
||||
.unwrap(),
|
||||
VerifyAccessTokenResult {
|
||||
is_valid: true,
|
||||
impersonator_id: None,
|
||||
}
|
||||
));
|
||||
|
||||
let hash = db
|
||||
.transaction(|tx| async move {
|
||||
Ok(access_token::Entity::find_by_id(old_token_id)
|
||||
.one(&*tx)
|
||||
.await?)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.hash;
|
||||
assert!(hash.starts_with("$sha256$"));
|
||||
|
||||
assert!(matches!(
|
||||
verify_access_token(&old_token, user.user_id, &db)
|
||||
.await
|
||||
.unwrap(),
|
||||
VerifyAccessTokenResult {
|
||||
is_valid: true,
|
||||
impersonator_id: None,
|
||||
}
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
verify_access_token(&token, user.user_id, &db)
|
||||
.await
|
||||
.unwrap(),
|
||||
VerifyAccessTokenResult {
|
||||
is_valid: true,
|
||||
impersonator_id: None,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
async fn create_previous_access_token(
|
||||
user_id: UserId,
|
||||
impersonated_user_id: Option<UserId>,
|
||||
db: &Database,
|
||||
) -> Result<String> {
|
||||
let access_token = rpc::auth::random_token();
|
||||
let access_token_hash = previous_hash_access_token(&access_token)?;
|
||||
let id = db
|
||||
.create_access_token(
|
||||
user_id,
|
||||
impersonated_user_id,
|
||||
&access_token_hash,
|
||||
MAX_ACCESS_TOKENS_TO_STORE,
|
||||
)
|
||||
.await?;
|
||||
Ok(serde_json::to_string(&AccessTokenJson {
|
||||
version: 1,
|
||||
id,
|
||||
token: access_token,
|
||||
})?)
|
||||
}
|
||||
|
||||
fn previous_hash_access_token(token: &str) -> Result<String> {
|
||||
// Avoid slow hashing in debug mode.
|
||||
let params = if cfg!(debug_assertions) {
|
||||
scrypt::Params::new(1, 1, 1).unwrap()
|
||||
} else {
|
||||
scrypt::Params::new(14, 8, 1).unwrap()
|
||||
};
|
||||
|
||||
Ok(Scrypt
|
||||
.hash_password(
|
||||
token.as_bytes(),
|
||||
None,
|
||||
params,
|
||||
&SaltString::generate(thread_rng()),
|
||||
)
|
||||
.map_err(anyhow::Error::new)?
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,4 +55,22 @@ impl Database {
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Retrieves the access token with the given ID.
|
||||
pub async fn update_access_token_hash(
|
||||
&self,
|
||||
id: AccessTokenId,
|
||||
new_hash: &str,
|
||||
) -> Result<access_token::Model> {
|
||||
self.transaction(|tx| async move {
|
||||
Ok(access_token::Entity::update(access_token::ActiveModel {
|
||||
id: ActiveValue::unchanged(id),
|
||||
hash: ActiveValue::set(new_hash.into()),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,6 +558,17 @@ impl Database {
|
||||
lamport_timestamp: i32,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<()> {
|
||||
buffer::Entity::update(buffer::ActiveModel {
|
||||
id: ActiveValue::Unchanged(buffer_id),
|
||||
epoch: ActiveValue::Unchanged(epoch),
|
||||
latest_operation_epoch: ActiveValue::Set(Some(epoch)),
|
||||
latest_operation_replica_id: ActiveValue::Set(Some(replica_id)),
|
||||
latest_operation_lamport_timestamp: ActiveValue::Set(Some(lamport_timestamp)),
|
||||
channel_id: ActiveValue::NotSet,
|
||||
})
|
||||
.exec(tx)
|
||||
.await?;
|
||||
|
||||
use observed_buffer_edits::Column;
|
||||
observed_buffer_edits::Entity::insert(observed_buffer_edits::ActiveModel {
|
||||
user_id: ActiveValue::Set(user_id),
|
||||
@@ -711,7 +722,10 @@ impl Database {
|
||||
buffer::ActiveModel {
|
||||
id: ActiveValue::Unchanged(buffer.id),
|
||||
epoch: ActiveValue::Set(epoch),
|
||||
..Default::default()
|
||||
latest_operation_epoch: ActiveValue::NotSet,
|
||||
latest_operation_replica_id: ActiveValue::NotSet,
|
||||
latest_operation_lamport_timestamp: ActiveValue::NotSet,
|
||||
channel_id: ActiveValue::NotSet,
|
||||
}
|
||||
.save(tx)
|
||||
.await?;
|
||||
@@ -745,30 +759,6 @@ impl Database {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn latest_channel_buffer_changes(
|
||||
&self,
|
||||
channel_ids_by_buffer_id: &HashMap<BufferId, ChannelId>,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<proto::ChannelBufferVersion>> {
|
||||
let latest_operations = self
|
||||
.get_latest_operations_for_buffers(channel_ids_by_buffer_id.keys().copied(), tx)
|
||||
.await?;
|
||||
|
||||
Ok(latest_operations
|
||||
.iter()
|
||||
.flat_map(|op| {
|
||||
Some(proto::ChannelBufferVersion {
|
||||
channel_id: channel_ids_by_buffer_id.get(&op.buffer_id)?.to_proto(),
|
||||
epoch: op.epoch as u64,
|
||||
version: vec![proto::VectorClockEntry {
|
||||
replica_id: op.replica_id as u32,
|
||||
timestamp: op.lamport_timestamp as u32,
|
||||
}],
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn observed_channel_buffer_changes(
|
||||
&self,
|
||||
channel_ids_by_buffer_id: &HashMap<BufferId, ChannelId>,
|
||||
@@ -798,55 +788,6 @@ impl Database {
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Returns the latest operations for the buffers with the specified IDs.
|
||||
pub async fn get_latest_operations_for_buffers(
|
||||
&self,
|
||||
buffer_ids: impl IntoIterator<Item = BufferId>,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<buffer_operation::Model>> {
|
||||
let mut values = String::new();
|
||||
for id in buffer_ids {
|
||||
if !values.is_empty() {
|
||||
values.push_str(", ");
|
||||
}
|
||||
write!(&mut values, "({})", id).unwrap();
|
||||
}
|
||||
|
||||
if values.is_empty() {
|
||||
return Ok(Vec::default());
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
row_number() OVER (
|
||||
PARTITION BY buffer_id
|
||||
ORDER BY
|
||||
epoch DESC,
|
||||
lamport_timestamp DESC,
|
||||
replica_id DESC
|
||||
) as row_number
|
||||
FROM buffer_operations
|
||||
WHERE
|
||||
buffer_id in ({values})
|
||||
) AS last_operations
|
||||
WHERE
|
||||
row_number = 1
|
||||
"#,
|
||||
);
|
||||
|
||||
let stmt = Statement::from_string(self.pool.get_database_backend(), sql);
|
||||
Ok(buffer_operation::Entity::find()
|
||||
.from_raw_sql(stmt)
|
||||
.all(tx)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
fn operation_to_storage(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::*;
|
||||
use rpc::{proto::channel_member::Kind, ErrorCode, ErrorCodeExt};
|
||||
use rpc::{
|
||||
proto::{channel_member::Kind, ChannelBufferVersion, VectorClockEntry},
|
||||
ErrorCode, ErrorCodeExt,
|
||||
};
|
||||
use sea_orm::TryGetableMany;
|
||||
|
||||
impl Database {
|
||||
@@ -625,6 +628,7 @@ impl Database {
|
||||
let channel_ids = channels.iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
|
||||
let mut channel_ids_by_buffer_id = HashMap::default();
|
||||
let mut latest_buffer_versions: Vec<ChannelBufferVersion> = vec![];
|
||||
let mut rows = buffer::Entity::find()
|
||||
.filter(buffer::Column::ChannelId.is_in(channel_ids.iter().copied()))
|
||||
.stream(tx)
|
||||
@@ -632,13 +636,24 @@ impl Database {
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
channel_ids_by_buffer_id.insert(row.id, row.channel_id);
|
||||
latest_buffer_versions.push(ChannelBufferVersion {
|
||||
channel_id: row.channel_id.0 as u64,
|
||||
epoch: row.latest_operation_epoch.unwrap_or_default() as u64,
|
||||
version: if let Some((latest_lamport_timestamp, latest_replica_id)) = row
|
||||
.latest_operation_lamport_timestamp
|
||||
.zip(row.latest_operation_replica_id)
|
||||
{
|
||||
vec![VectorClockEntry {
|
||||
timestamp: latest_lamport_timestamp as u32,
|
||||
replica_id: latest_replica_id as u32,
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
});
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
let latest_buffer_versions = self
|
||||
.latest_channel_buffer_changes(&channel_ids_by_buffer_id, tx)
|
||||
.await?;
|
||||
|
||||
let latest_channel_messages = self.latest_channel_messages(&channel_ids, tx).await?;
|
||||
|
||||
let observed_buffer_versions = self
|
||||
|
||||
@@ -9,20 +9,21 @@ impl Database {
|
||||
roles: &HashMap<ChannelId, ChannelRole>,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<proto::HostedProject>> {
|
||||
Ok(hosted_project::Entity::find()
|
||||
let projects = hosted_project::Entity::find()
|
||||
.find_also_related(project::Entity)
|
||||
.filter(hosted_project::Column::ChannelId.is_in(channel_ids.iter().map(|id| id.0)))
|
||||
.all(tx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.flat_map(|project| {
|
||||
if project.deleted_at.is_some() {
|
||||
.flat_map(|(hosted_project, project)| {
|
||||
if hosted_project.deleted_at.is_some() {
|
||||
return None;
|
||||
}
|
||||
match project.visibility {
|
||||
match hosted_project.visibility {
|
||||
ChannelVisibility::Public => {}
|
||||
ChannelVisibility::Members => {
|
||||
let is_visible = roles
|
||||
.get(&project.channel_id)
|
||||
.get(&hosted_project.channel_id)
|
||||
.map(|role| role.can_see_all_descendants())
|
||||
.unwrap_or(false);
|
||||
if !is_visible {
|
||||
@@ -31,13 +32,15 @@ impl Database {
|
||||
}
|
||||
};
|
||||
Some(proto::HostedProject {
|
||||
id: project.id.to_proto(),
|
||||
channel_id: project.channel_id.to_proto(),
|
||||
name: project.name.clone(),
|
||||
visibility: project.visibility.into(),
|
||||
project_id: project?.id.to_proto(),
|
||||
channel_id: hosted_project.channel_id.to_proto(),
|
||||
name: hosted_project.name.clone(),
|
||||
visibility: hosted_project.visibility.into(),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect();
|
||||
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
pub async fn get_hosted_project(
|
||||
|
||||
@@ -512,18 +512,30 @@ impl Database {
|
||||
/// Adds the given connection to the specified hosted project
|
||||
pub async fn join_hosted_project(
|
||||
&self,
|
||||
id: HostedProjectId,
|
||||
id: ProjectId,
|
||||
user_id: UserId,
|
||||
connection: ConnectionId,
|
||||
) -> Result<(Project, ReplicaId)> {
|
||||
self.transaction(|tx| async move {
|
||||
let (hosted_project, role) = self.get_hosted_project(id, user_id, &tx).await?;
|
||||
let project = project::Entity::find()
|
||||
.filter(project::Column::HostedProjectId.eq(hosted_project.id))
|
||||
let (project, hosted_project) = project::Entity::find_by_id(id)
|
||||
.find_also_related(hosted_project::Entity)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("hosted project is no longer shared"))?;
|
||||
|
||||
let Some(hosted_project) = hosted_project else {
|
||||
return Err(anyhow!("project is not hosted"))?;
|
||||
};
|
||||
|
||||
let channel = channel::Entity::find_by_id(hosted_project.channel_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such channel"))?;
|
||||
|
||||
let role = self
|
||||
.check_user_is_channel_participant(&channel, user_id, &tx)
|
||||
.await?;
|
||||
|
||||
self.join_project_internal(project, user_id, connection, role, &tx)
|
||||
.await
|
||||
})
|
||||
|
||||
@@ -8,6 +8,9 @@ pub struct Model {
|
||||
pub id: BufferId,
|
||||
pub epoch: i32,
|
||||
pub channel_id: ChannelId,
|
||||
pub latest_operation_epoch: Option<i32>,
|
||||
pub latest_operation_lamport_timestamp: Option<i32>,
|
||||
pub latest_operation_replica_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -15,4 +15,13 @@ pub struct Model {
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_one = "super::project::Entity")]
|
||||
Project,
|
||||
}
|
||||
|
||||
impl Related<super::project::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Project.def()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ pub enum Relation {
|
||||
Collaborators,
|
||||
#[sea_orm(has_many = "super::language_server::Entity")]
|
||||
LanguageServers,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::hosted_project::Entity",
|
||||
from = "Column::HostedProjectId",
|
||||
to = "super::hosted_project::Column::Id"
|
||||
)]
|
||||
HostedProject,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
@@ -82,4 +88,10 @@ impl Related<super::language_server::Entity> for Entity {
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::hosted_project::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::HostedProject.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
@@ -235,19 +235,6 @@ async fn test_channel_buffers_last_operations(db: &Database) {
|
||||
));
|
||||
}
|
||||
|
||||
let operations = db
|
||||
.transaction(|tx| {
|
||||
let buffers = &buffers;
|
||||
async move {
|
||||
db.get_latest_operations_for_buffers([buffers[0].id, buffers[2].id], &tx)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(operations.is_empty());
|
||||
|
||||
update_buffer(
|
||||
buffers[0].channel_id,
|
||||
user_id,
|
||||
@@ -299,57 +286,10 @@ async fn test_channel_buffers_last_operations(db: &Database) {
|
||||
)
|
||||
.await;
|
||||
|
||||
let operations = db
|
||||
.transaction(|tx| {
|
||||
let buffers = &buffers;
|
||||
async move {
|
||||
db.get_latest_operations_for_buffers([buffers[1].id, buffers[2].id], &tx)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_operations(
|
||||
&operations,
|
||||
&[
|
||||
(buffers[1].id, 1, &text_buffers[1]),
|
||||
(buffers[2].id, 0, &text_buffers[2]),
|
||||
],
|
||||
);
|
||||
|
||||
let operations = db
|
||||
.transaction(|tx| {
|
||||
let buffers = &buffers;
|
||||
async move {
|
||||
db.get_latest_operations_for_buffers([buffers[0].id, buffers[1].id], &tx)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_operations(
|
||||
&operations,
|
||||
&[
|
||||
(buffers[0].id, 0, &text_buffers[0]),
|
||||
(buffers[1].id, 1, &text_buffers[1]),
|
||||
],
|
||||
);
|
||||
|
||||
let buffer_changes = db
|
||||
.transaction(|tx| {
|
||||
let buffers = &buffers;
|
||||
let mut hash = HashMap::default();
|
||||
hash.insert(buffers[0].id, buffers[0].channel_id);
|
||||
hash.insert(buffers[1].id, buffers[1].channel_id);
|
||||
hash.insert(buffers[2].id, buffers[2].channel_id);
|
||||
|
||||
async move { db.latest_channel_buffer_changes(&hash, &tx).await }
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let channels_for_user = db.get_channels_for_user(user_id).await.unwrap();
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
buffer_changes,
|
||||
channels_for_user.latest_buffer_versions,
|
||||
[
|
||||
rpc::proto::ChannelBufferVersion {
|
||||
channel_id: buffers[0].channel_id.to_proto(),
|
||||
@@ -361,8 +301,7 @@ async fn test_channel_buffers_last_operations(db: &Database) {
|
||||
epoch: 1,
|
||||
version: serialize_version(&text_buffers[1].version())
|
||||
.into_iter()
|
||||
.filter(|vector| vector.replica_id
|
||||
== buffer_changes[1].version.first().unwrap().replica_id)
|
||||
.filter(|vector| vector.replica_id == text_buffers[1].replica_id() as u32)
|
||||
.collect::<Vec<_>>(),
|
||||
},
|
||||
rpc::proto::ChannelBufferVersion {
|
||||
@@ -388,30 +327,3 @@ async fn update_buffer(
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn assert_operations(
|
||||
operations: &[buffer_operation::Model],
|
||||
expected: &[(BufferId, i32, &text::Buffer)],
|
||||
) {
|
||||
let actual = operations
|
||||
.iter()
|
||||
.map(|op| buffer_operation::Model {
|
||||
buffer_id: op.buffer_id,
|
||||
epoch: op.epoch,
|
||||
lamport_timestamp: op.lamport_timestamp,
|
||||
replica_id: op.replica_id,
|
||||
value: vec![],
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let expected = expected
|
||||
.iter()
|
||||
.map(|(buffer_id, epoch, buffer)| buffer_operation::Model {
|
||||
buffer_id: *buffer_id,
|
||||
epoch: *epoch,
|
||||
lamport_timestamp: buffer.lamport_clock.value as i32 - 1,
|
||||
replica_id: buffer.replica_id() as i32,
|
||||
value: vec![],
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(actual, expected, "unexpected operations")
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ use crate::{
|
||||
auth::{self, Impersonator},
|
||||
db::{
|
||||
self, BufferId, ChannelId, ChannelRole, ChannelsForUser, CreatedChannelMessage, Database,
|
||||
HostedProjectId, InviteMemberResult, MembershipUpdated, MessageId, NotificationId, Project,
|
||||
ProjectId, RemoveChannelMemberResult, ReplicaId, RespondToChannelInvite, RoomId, ServerId,
|
||||
User, UserId,
|
||||
InviteMemberResult, MembershipUpdated, MessageId, NotificationId, Project, ProjectId,
|
||||
RemoveChannelMemberResult, ReplicaId, RespondToChannelInvite, RoomId, ServerId, User,
|
||||
UserId,
|
||||
},
|
||||
executor::Executor,
|
||||
AppState, Error, Result,
|
||||
@@ -1770,7 +1770,7 @@ async fn join_hosted_project(
|
||||
.db()
|
||||
.await
|
||||
.join_hosted_project(
|
||||
HostedProjectId(request.id as i32),
|
||||
ProjectId(request.project_id as i32),
|
||||
session.user_id,
|
||||
session.connection_id,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,8 @@ use crate::{
|
||||
use call::ActiveCall;
|
||||
use editor::{
|
||||
actions::{
|
||||
ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Redo, Rename, ToggleCodeActions, Undo,
|
||||
ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Redo, Rename, RevertSelectedHunks,
|
||||
ToggleCodeActions, Undo,
|
||||
},
|
||||
test::editor_test_context::{AssertionContextManager, EditorTestContext},
|
||||
Editor,
|
||||
@@ -17,6 +18,7 @@ use language::{
|
||||
language_settings::{AllLanguageSettings, InlayHintSettings},
|
||||
FakeLspAdapter,
|
||||
};
|
||||
use project::SERVER_PROGRESS_DEBOUNCE_TIMEOUT;
|
||||
use rpc::RECEIVE_TIMEOUT;
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
@@ -865,6 +867,7 @@ 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, _| {
|
||||
@@ -898,6 +901,7 @@ 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, _| {
|
||||
@@ -1811,6 +1815,171 @@ async fn test_inlay_hint_refresh_is_forwarded(
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_multiple_types_reverts(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
|
||||
let mut server = TestServer::start(cx_a.executor()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
server
|
||||
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
|
||||
.await;
|
||||
let active_call_a = cx_a.read(ActiveCall::global);
|
||||
let active_call_b = cx_b.read(ActiveCall::global);
|
||||
|
||||
cx_a.update(editor::init);
|
||||
cx_b.update(editor::init);
|
||||
|
||||
client_a.language_registry().add(rust_lang());
|
||||
client_b.language_registry().add(rust_lang());
|
||||
|
||||
let base_text = indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
struct Row2;
|
||||
|
||||
struct Row4;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
|
||||
struct Row8;
|
||||
struct Row9;
|
||||
struct Row10;"#};
|
||||
|
||||
client_a
|
||||
.fs()
|
||||
.insert_tree(
|
||||
"/a",
|
||||
json!({
|
||||
"main.rs": base_text,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
|
||||
active_call_a
|
||||
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
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_remote_project(project_id, cx_b).await;
|
||||
active_call_b
|
||||
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
|
||||
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
|
||||
|
||||
let editor_a = workspace_a
|
||||
.update(cx_a, |workspace, cx| {
|
||||
workspace.open_path((worktree_id, "main.rs"), None, true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.downcast::<Editor>()
|
||||
.unwrap();
|
||||
|
||||
let editor_b = workspace_b
|
||||
.update(cx_b, |workspace, cx| {
|
||||
workspace.open_path((worktree_id, "main.rs"), None, true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.downcast::<Editor>()
|
||||
.unwrap();
|
||||
|
||||
let mut editor_cx_a = EditorTestContext {
|
||||
cx: cx_a.clone(),
|
||||
window: cx_a.handle(),
|
||||
editor: editor_a,
|
||||
assertion_cx: AssertionContextManager::new(),
|
||||
};
|
||||
let mut editor_cx_b = EditorTestContext {
|
||||
cx: cx_b.clone(),
|
||||
window: cx_b.handle(),
|
||||
editor: editor_b,
|
||||
assertion_cx: AssertionContextManager::new(),
|
||||
};
|
||||
|
||||
// host edits the file, that differs from the base text, producing diff hunks
|
||||
editor_cx_a.set_state(indoc! {r#"struct Row;
|
||||
struct Row0.1;
|
||||
struct Row0.2;
|
||||
struct Row1;
|
||||
|
||||
struct Row4;
|
||||
struct Row5444;
|
||||
struct Row6;
|
||||
|
||||
struct Row9;
|
||||
struct Row1220;ˇ"#});
|
||||
editor_cx_a.update_editor(|editor, cx| {
|
||||
editor
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.unwrap()
|
||||
.update(cx, |buffer, cx| {
|
||||
buffer.set_diff_base(Some(base_text.to_string()), cx);
|
||||
});
|
||||
});
|
||||
editor_cx_b.update_editor(|editor, cx| {
|
||||
editor
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.unwrap()
|
||||
.update(cx, |buffer, cx| {
|
||||
buffer.set_diff_base(Some(base_text.to_string()), cx);
|
||||
});
|
||||
});
|
||||
cx_a.executor().run_until_parked();
|
||||
cx_b.executor().run_until_parked();
|
||||
|
||||
// client, selects a range in the updated buffer, and reverts it
|
||||
// both host and the client observe the reverted state (with one hunk left, not covered by client's selection)
|
||||
editor_cx_b.set_selections_state(indoc! {r#"«ˇstruct Row;
|
||||
struct Row0.1;
|
||||
struct Row0.2;
|
||||
struct Row1;
|
||||
|
||||
struct Row4;
|
||||
struct Row5444;
|
||||
struct Row6;
|
||||
|
||||
struct R»ow9;
|
||||
struct Row1220;"#});
|
||||
editor_cx_b.update_editor(|editor, cx| {
|
||||
editor.revert_selected_hunks(&RevertSelectedHunks, cx);
|
||||
});
|
||||
cx_a.executor().run_until_parked();
|
||||
cx_b.executor().run_until_parked();
|
||||
editor_cx_a.assert_editor_state(indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
struct Row2;
|
||||
|
||||
struct Row4;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
|
||||
struct Row8;
|
||||
struct Row9;
|
||||
struct Row1220;ˇ"#});
|
||||
editor_cx_b.assert_editor_state(indoc! {r#"«ˇstruct Row;
|
||||
struct Row1;
|
||||
struct Row2;
|
||||
|
||||
struct Row4;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
|
||||
struct Row8;
|
||||
struct R»ow9;
|
||||
struct Row1220;"#});
|
||||
}
|
||||
|
||||
fn extract_hint_labels(editor: &Editor) -> Vec<String> {
|
||||
let mut labels = Vec::new();
|
||||
for hint in editor.inlay_hint_cache().hints() {
|
||||
|
||||
@@ -37,8 +37,8 @@ clock.workspace = true
|
||||
collections.workspace = true
|
||||
db.workspace = true
|
||||
editor.workspace = true
|
||||
emojis.workspace = true
|
||||
extensions_ui.workspace = true
|
||||
feedback.workspace = true
|
||||
futures.workspace = true
|
||||
fuzzy.workspace = true
|
||||
gpui.workspace = true
|
||||
|
||||
@@ -430,7 +430,6 @@ impl ChatPanel {
|
||||
ChannelMessageId::Saved(id) => ("saved-message", id).into(),
|
||||
ChannelMessageId::Pending(id) => ("pending-message", id).into(),
|
||||
};
|
||||
let this = cx.view().clone();
|
||||
|
||||
let mentioning_you = message
|
||||
.mentions
|
||||
@@ -465,15 +464,21 @@ impl ChatPanel {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.relative()
|
||||
.group("")
|
||||
.when(!is_continuation_from_previous, |this| this.pt_2())
|
||||
.child(
|
||||
div()
|
||||
.group("")
|
||||
.bg(background)
|
||||
.rounded_md()
|
||||
.overflow_hidden()
|
||||
.px_1()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.when(!self.has_open_menu(message_id), |this| {
|
||||
this.hover(|style| style.bg(cx.theme().colors().element_hover))
|
||||
})
|
||||
.when(!is_continuation_from_previous, |this| {
|
||||
this.mt_2().child(
|
||||
this.child(
|
||||
h_flex()
|
||||
.text_ui_sm()
|
||||
.child(div().absolute().child(
|
||||
@@ -545,37 +550,11 @@ impl ChatPanel {
|
||||
.w_full()
|
||||
.text_ui_sm()
|
||||
.id(element_id)
|
||||
.group("")
|
||||
.child(text.element("body".into(), cx))
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.z_index(1)
|
||||
.right_0()
|
||||
.w_6()
|
||||
.bg(background)
|
||||
.when(!self.has_open_menu(message_id), |el| {
|
||||
el.visible_on_hover("")
|
||||
})
|
||||
.when_some(message_id, |el, message_id| {
|
||||
el.child(
|
||||
popover_menu(("menu", message_id))
|
||||
.trigger(IconButton::new(
|
||||
("trigger", message_id),
|
||||
IconName::Ellipsis,
|
||||
))
|
||||
.menu(move |cx| {
|
||||
Some(Self::render_message_menu(
|
||||
&this,
|
||||
message_id,
|
||||
can_delete_message,
|
||||
cx,
|
||||
))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
.child(text.element("body".into(), cx)),
|
||||
)
|
||||
.when(self.has_open_menu(message_id), |el| {
|
||||
el.bg(cx.theme().colors().element_selected)
|
||||
})
|
||||
}),
|
||||
)
|
||||
.when(
|
||||
@@ -600,6 +579,10 @@ impl ChatPanel {
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(
|
||||
self.render_popover_buttons(&cx, message_id, can_delete_message)
|
||||
.neg_mt_2p5(),
|
||||
)
|
||||
}
|
||||
|
||||
fn has_open_menu(&self, message_id: Option<u64>) -> bool {
|
||||
@@ -609,6 +592,90 @@ impl ChatPanel {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_popover_buttons(
|
||||
&self,
|
||||
cx: &ViewContext<Self>,
|
||||
message_id: Option<u64>,
|
||||
can_delete_message: bool,
|
||||
) -> Div {
|
||||
div()
|
||||
.absolute()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.right_8()
|
||||
.w_6()
|
||||
.rounded_tl_md()
|
||||
.rounded_bl_md()
|
||||
.border_l_1()
|
||||
.border_t_1()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().element_selected)
|
||||
.bg(cx.theme().colors().element_background)
|
||||
.hover(|style| style.bg(cx.theme().colors().element_hover))
|
||||
.when(!self.has_open_menu(message_id), |el| {
|
||||
el.visible_on_hover("")
|
||||
})
|
||||
.when_some(message_id, |el, message_id| {
|
||||
el.child(
|
||||
div()
|
||||
.id("reply")
|
||||
.child(
|
||||
IconButton::new(("reply", message_id), IconName::ReplyArrow)
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
this.message_editor.update(cx, |editor, cx| {
|
||||
editor.set_reply_to_message_id(message_id);
|
||||
editor.focus_handle(cx).focus(cx);
|
||||
})
|
||||
})),
|
||||
)
|
||||
.tooltip(|cx| Tooltip::text("Reply", cx)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.right_2()
|
||||
.w_6()
|
||||
.rounded_tr_md()
|
||||
.rounded_br_md()
|
||||
.border_r_1()
|
||||
.border_t_1()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().element_selected)
|
||||
.bg(cx.theme().colors().element_background)
|
||||
.hover(|style| style.bg(cx.theme().colors().element_hover))
|
||||
.when(!self.has_open_menu(message_id), |el| {
|
||||
el.visible_on_hover("")
|
||||
})
|
||||
.when_some(message_id, |el, message_id| {
|
||||
let this = cx.view().clone();
|
||||
|
||||
el.child(
|
||||
div()
|
||||
.id("more")
|
||||
.child(
|
||||
popover_menu(("menu", message_id))
|
||||
.trigger(IconButton::new(
|
||||
("trigger", message_id),
|
||||
IconName::Ellipsis,
|
||||
))
|
||||
.menu(move |cx| {
|
||||
Some(Self::render_message_menu(
|
||||
&this,
|
||||
message_id,
|
||||
can_delete_message,
|
||||
cx,
|
||||
))
|
||||
}),
|
||||
)
|
||||
.tooltip(|cx| Tooltip::text("More", cx)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_message_menu(
|
||||
this: &View<Self>,
|
||||
message_id: u64,
|
||||
@@ -785,7 +852,7 @@ impl Render for ChatPanel {
|
||||
.size_full()
|
||||
.on_action(cx.listener(Self::send))
|
||||
.child(
|
||||
h_flex().z_index(1).child(
|
||||
h_flex().child(
|
||||
TabBar::new("chat_header").child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
|
||||
@@ -3,7 +3,7 @@ use channel::{ChannelMembership, ChannelStore, MessageParams};
|
||||
use client::{ChannelId, UserId};
|
||||
use collections::{HashMap, HashSet};
|
||||
use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle};
|
||||
use fuzzy::StringMatchCandidate;
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
use gpui::{
|
||||
AsyncWindowContext, FocusableView, FontStyle, FontWeight, HighlightStyle, IntoElement, Model,
|
||||
Render, SharedString, Task, TextStyle, View, ViewContext, WeakView, WhiteSpace,
|
||||
@@ -16,10 +16,12 @@ use lazy_static::lazy_static;
|
||||
use parking_lot::RwLock;
|
||||
use project::search::SearchQuery;
|
||||
use settings::Settings;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::{ops::Range, sync::Arc, time::Duration};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{prelude::*, UiTextSize};
|
||||
|
||||
use crate::panel_settings::MessageEditorSettings;
|
||||
|
||||
const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
lazy_static! {
|
||||
@@ -86,6 +88,11 @@ impl MessageEditor {
|
||||
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
|
||||
editor.set_use_autoclose(false);
|
||||
editor.set_completion_provider(Box::new(MessageEditorCompletionProvider(this)));
|
||||
editor.set_auto_replace_emoji_shortcode(
|
||||
MessageEditorSettings::get_global(cx)
|
||||
.auto_replace_emoji_shortcode
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
});
|
||||
|
||||
let buffer = editor
|
||||
@@ -96,6 +103,16 @@ impl MessageEditor {
|
||||
.expect("message editor must be singleton");
|
||||
|
||||
cx.subscribe(&buffer, Self::on_buffer_event).detach();
|
||||
cx.observe_global::<settings::SettingsStore>(|view, cx| {
|
||||
view.editor.update(cx, |editor, cx| {
|
||||
editor.set_auto_replace_emoji_shortcode(
|
||||
MessageEditorSettings::get_global(cx)
|
||||
.auto_replace_emoji_shortcode
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
|
||||
let markdown = language_registry.language_for_name("Markdown");
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
@@ -219,6 +236,101 @@ impl MessageEditor {
|
||||
end_anchor: Anchor,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Task<Result<Vec<Completion>>> {
|
||||
if let Some((start_anchor, query, candidates)) =
|
||||
self.collect_mention_candidates(buffer, end_anchor, cx)
|
||||
{
|
||||
if !candidates.is_empty() {
|
||||
return cx.spawn(|_, cx| async move {
|
||||
Ok(Self::resolve_completions_for_candidates(
|
||||
&cx,
|
||||
query.as_str(),
|
||||
&candidates,
|
||||
start_anchor..end_anchor,
|
||||
Self::completion_for_mention,
|
||||
)
|
||||
.await)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((start_anchor, query, candidates)) =
|
||||
self.collect_emoji_candidates(buffer, end_anchor, cx)
|
||||
{
|
||||
if !candidates.is_empty() {
|
||||
return cx.spawn(|_, cx| async move {
|
||||
Ok(Self::resolve_completions_for_candidates(
|
||||
&cx,
|
||||
query.as_str(),
|
||||
candidates,
|
||||
start_anchor..end_anchor,
|
||||
Self::completion_for_emoji,
|
||||
)
|
||||
.await)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Task::ready(Ok(vec![]))
|
||||
}
|
||||
|
||||
async fn resolve_completions_for_candidates(
|
||||
cx: &AsyncWindowContext,
|
||||
query: &str,
|
||||
candidates: &[StringMatchCandidate],
|
||||
range: Range<Anchor>,
|
||||
completion_fn: impl Fn(&StringMatch) -> (String, CodeLabel),
|
||||
) -> Vec<Completion> {
|
||||
let matches = fuzzy::match_strings(
|
||||
&candidates,
|
||||
&query,
|
||||
true,
|
||||
10,
|
||||
&Default::default(),
|
||||
cx.background_executor().clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|mat| {
|
||||
let (new_text, label) = completion_fn(&mat);
|
||||
Completion {
|
||||
old_range: range.clone(),
|
||||
new_text,
|
||||
label,
|
||||
documentation: None,
|
||||
server_id: LanguageServerId(0), // TODO: Make this optional or something?
|
||||
lsp_completion: Default::default(), // TODO: Make this optional or something?
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn completion_for_mention(mat: &StringMatch) -> (String, CodeLabel) {
|
||||
let label = CodeLabel {
|
||||
filter_range: 1..mat.string.len() + 1,
|
||||
text: format!("@{}", mat.string),
|
||||
runs: Vec::new(),
|
||||
};
|
||||
(mat.string.clone(), label)
|
||||
}
|
||||
|
||||
fn completion_for_emoji(mat: &StringMatch) -> (String, CodeLabel) {
|
||||
let emoji = emojis::get_by_shortcode(&mat.string).unwrap();
|
||||
let label = CodeLabel {
|
||||
filter_range: 1..mat.string.len() + 1,
|
||||
text: format!(":{}: {}", mat.string, emoji),
|
||||
runs: Vec::new(),
|
||||
};
|
||||
(emoji.to_string(), label)
|
||||
}
|
||||
|
||||
fn collect_mention_candidates(
|
||||
&mut self,
|
||||
buffer: &Model<Buffer>,
|
||||
end_anchor: Anchor,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Option<(Anchor, String, Vec<StringMatchCandidate>)> {
|
||||
let end_offset = end_anchor.to_offset(buffer.read(cx));
|
||||
|
||||
let Some(query) = buffer.update(cx, |buffer, _| {
|
||||
@@ -232,9 +344,9 @@ impl MessageEditor {
|
||||
}
|
||||
query.push(ch);
|
||||
}
|
||||
return None;
|
||||
None
|
||||
}) else {
|
||||
return Task::ready(Ok(vec![]));
|
||||
return None;
|
||||
};
|
||||
|
||||
let start_offset = end_offset - query.len();
|
||||
@@ -258,33 +370,76 @@ impl MessageEditor {
|
||||
char_bag: user.chars().collect(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
cx.spawn(|_, cx| async move {
|
||||
let matches = fuzzy::match_strings(
|
||||
&candidates,
|
||||
&query,
|
||||
true,
|
||||
10,
|
||||
&Default::default(),
|
||||
cx.background_executor().clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(matches
|
||||
.into_iter()
|
||||
.map(|mat| Completion {
|
||||
old_range: start_anchor..end_anchor,
|
||||
new_text: mat.string.clone(),
|
||||
label: CodeLabel {
|
||||
filter_range: 1..mat.string.len() + 1,
|
||||
text: format!("@{}", mat.string),
|
||||
runs: Vec::new(),
|
||||
},
|
||||
documentation: None,
|
||||
server_id: LanguageServerId(0), // TODO: Make this optional or something?
|
||||
lsp_completion: Default::default(), // TODO: Make this optional or something?
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
Some((start_anchor, query, candidates))
|
||||
}
|
||||
|
||||
fn collect_emoji_candidates(
|
||||
&mut self,
|
||||
buffer: &Model<Buffer>,
|
||||
end_anchor: Anchor,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Option<(Anchor, String, &'static [StringMatchCandidate])> {
|
||||
lazy_static! {
|
||||
static ref EMOJI_FUZZY_MATCH_CANDIDATES: Vec<StringMatchCandidate> = {
|
||||
let emojis = emojis::iter()
|
||||
.flat_map(|s| s.shortcodes())
|
||||
.map(|emoji| StringMatchCandidate {
|
||||
id: 0,
|
||||
string: emoji.to_string(),
|
||||
char_bag: emoji.chars().collect(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
emojis
|
||||
};
|
||||
}
|
||||
|
||||
let end_offset = end_anchor.to_offset(buffer.read(cx));
|
||||
|
||||
let Some(query) = buffer.update(cx, |buffer, _| {
|
||||
let mut query = String::new();
|
||||
for ch in buffer.reversed_chars_at(end_offset).take(100) {
|
||||
if ch == ':' {
|
||||
let next_char = buffer
|
||||
.reversed_chars_at(end_offset - query.len() - 1)
|
||||
.next();
|
||||
// Ensure we are at the start of the message or that the previous character is a whitespace
|
||||
if next_char.is_none() || next_char.unwrap().is_whitespace() {
|
||||
return Some(query.chars().rev().collect::<String>());
|
||||
}
|
||||
|
||||
// If the previous character is not a whitespace, we are in the middle of a word
|
||||
// and we only want to complete the shortcode if the word is made up of other emojis
|
||||
let mut containing_word = String::new();
|
||||
for ch in buffer
|
||||
.reversed_chars_at(end_offset - query.len() - 1)
|
||||
.take(100)
|
||||
{
|
||||
if ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
containing_word.push(ch);
|
||||
}
|
||||
let containing_word = containing_word.chars().rev().collect::<String>();
|
||||
if util::word_consists_of_emojis(containing_word.as_str()) {
|
||||
return Some(query.chars().rev().collect::<String>());
|
||||
}
|
||||
break;
|
||||
}
|
||||
if ch.is_whitespace() || !ch.is_ascii() {
|
||||
break;
|
||||
}
|
||||
query.push(ch);
|
||||
}
|
||||
None
|
||||
}) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let start_offset = end_offset - query.len() - 1;
|
||||
let start_anchor = buffer.read(cx).anchor_before(start_offset);
|
||||
|
||||
Some((start_anchor, query, &EMOJI_FUZZY_MATCH_CANDIDATES))
|
||||
}
|
||||
|
||||
async fn find_mentions(
|
||||
@@ -465,6 +620,8 @@ mod tests {
|
||||
editor::init(cx);
|
||||
client::init(&client, cx);
|
||||
channel::init(&client, user_store, cx);
|
||||
|
||||
MessageEditorSettings::register(cx);
|
||||
});
|
||||
|
||||
let language_registry = Arc::new(LanguageRegistry::test());
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
};
|
||||
use call::ActiveCall;
|
||||
use channel::{Channel, ChannelEvent, ChannelStore};
|
||||
use client::{ChannelId, Client, Contact, HostedProjectId, User, UserStore};
|
||||
use client::{ChannelId, Client, Contact, ProjectId, User, UserStore};
|
||||
use contact_finder::ContactFinder;
|
||||
use db::kvp::KEY_VALUE_STORE;
|
||||
use editor::{Editor, EditorElement, EditorStyle};
|
||||
@@ -185,7 +185,7 @@ enum ListEntry {
|
||||
depth: usize,
|
||||
},
|
||||
HostedProject {
|
||||
id: HostedProjectId,
|
||||
id: ProjectId,
|
||||
name: SharedString,
|
||||
},
|
||||
Contact {
|
||||
@@ -989,7 +989,6 @@ impl CollabPanel {
|
||||
.children(has_channel_buffer_changed.then(|| {
|
||||
div()
|
||||
.w_1p5()
|
||||
.z_index(1)
|
||||
.absolute()
|
||||
.right(px(2.))
|
||||
.top(px(2.))
|
||||
@@ -1022,7 +1021,6 @@ impl CollabPanel {
|
||||
.children(has_messages_notification.then(|| {
|
||||
div()
|
||||
.w_1p5()
|
||||
.z_index(1)
|
||||
.absolute()
|
||||
.right(px(2.))
|
||||
.top(px(4.))
|
||||
@@ -1035,7 +1033,7 @@ impl CollabPanel {
|
||||
|
||||
fn render_channel_project(
|
||||
&self,
|
||||
id: HostedProjectId,
|
||||
id: ProjectId,
|
||||
name: &SharedString,
|
||||
is_selected: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
@@ -2617,7 +2615,6 @@ impl CollabPanel {
|
||||
.children(has_notes_notification.then(|| {
|
||||
div()
|
||||
.w_1p5()
|
||||
.z_index(1)
|
||||
.absolute()
|
||||
.right(px(-1.))
|
||||
.top(px(-1.))
|
||||
@@ -2632,49 +2629,44 @@ impl CollabPanel {
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.absolute()
|
||||
.right(rems(0.))
|
||||
.z_index(1)
|
||||
.h_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.h_full()
|
||||
.gap_1()
|
||||
.px_1()
|
||||
.child(
|
||||
IconButton::new("channel_chat", IconName::MessageBubbles)
|
||||
.style(ButtonStyle::Filled)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(if has_messages_notification {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
this.join_channel_chat(channel_id, cx)
|
||||
}))
|
||||
.tooltip(|cx| Tooltip::text("Open channel chat", cx))
|
||||
.visible_on_hover(""),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("channel_notes", IconName::File)
|
||||
.style(ButtonStyle::Filled)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(if has_notes_notification {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
this.open_channel_notes(channel_id, cx)
|
||||
}))
|
||||
.tooltip(|cx| Tooltip::text("Open channel notes", cx))
|
||||
.visible_on_hover(""),
|
||||
),
|
||||
),
|
||||
h_flex().absolute().right(rems(0.)).h_full().child(
|
||||
h_flex()
|
||||
.h_full()
|
||||
.gap_1()
|
||||
.px_1()
|
||||
.child(
|
||||
IconButton::new("channel_chat", IconName::MessageBubbles)
|
||||
.style(ButtonStyle::Filled)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(if has_messages_notification {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
this.join_channel_chat(channel_id, cx)
|
||||
}))
|
||||
.tooltip(|cx| Tooltip::text("Open channel chat", cx))
|
||||
.visible_on_hover(""),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("channel_notes", IconName::File)
|
||||
.style(ButtonStyle::Filled)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(if has_notes_notification {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
this.open_channel_notes(channel_id, cx)
|
||||
}))
|
||||
.tooltip(|cx| Tooltip::text("Open channel notes", cx))
|
||||
.visible_on_hover(""),
|
||||
),
|
||||
),
|
||||
)
|
||||
.tooltip({
|
||||
let channel_store = self.channel_store.clone();
|
||||
@@ -2720,31 +2712,34 @@ fn render_tree_branch(is_last: bool, overdraw: bool, cx: &mut WindowContext) ->
|
||||
let thickness = px(1.);
|
||||
let color = cx.theme().colors().text;
|
||||
|
||||
canvas(move |bounds, cx| {
|
||||
let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
|
||||
let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
|
||||
let right = bounds.right();
|
||||
let top = bounds.top();
|
||||
canvas(
|
||||
|_, _| {},
|
||||
move |bounds, _, cx| {
|
||||
let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
|
||||
let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
|
||||
let right = bounds.right();
|
||||
let top = bounds.top();
|
||||
|
||||
cx.paint_quad(fill(
|
||||
Bounds::from_corners(
|
||||
point(start_x, top),
|
||||
point(
|
||||
start_x + thickness,
|
||||
if is_last {
|
||||
start_y
|
||||
} else {
|
||||
bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
|
||||
},
|
||||
cx.paint_quad(fill(
|
||||
Bounds::from_corners(
|
||||
point(start_x, top),
|
||||
point(
|
||||
start_x + thickness,
|
||||
if is_last {
|
||||
start_y
|
||||
} else {
|
||||
bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
color,
|
||||
));
|
||||
cx.paint_quad(fill(
|
||||
Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
|
||||
color,
|
||||
));
|
||||
})
|
||||
color,
|
||||
));
|
||||
cx.paint_quad(fill(
|
||||
Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
|
||||
color,
|
||||
));
|
||||
},
|
||||
)
|
||||
.w(width)
|
||||
.h(line_height)
|
||||
}
|
||||
|
||||
@@ -329,24 +329,27 @@ impl Render for CollabTitlebarItem {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_color_ribbon(color: Hsla) -> gpui::Canvas {
|
||||
canvas(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);
|
||||
})
|
||||
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()
|
||||
}
|
||||
@@ -698,9 +701,8 @@ impl CollabTitlebarItem {
|
||||
ContextMenu::build(cx, |menu, _| {
|
||||
menu.action("Settings", zed_actions::OpenSettings.boxed_clone())
|
||||
.action("Extensions", extensions_ui::Extensions.boxed_clone())
|
||||
.action("Theme...", theme_selector::Toggle.boxed_clone())
|
||||
.action("Themes...", theme_selector::Toggle.boxed_clone())
|
||||
.separator()
|
||||
.action("Share Feedback...", feedback::GiveFeedback.boxed_clone())
|
||||
.action("Sign Out", client::SignOut.boxed_clone())
|
||||
})
|
||||
.into()
|
||||
@@ -722,10 +724,8 @@ impl CollabTitlebarItem {
|
||||
.menu(|cx| {
|
||||
ContextMenu::build(cx, |menu, _| {
|
||||
menu.action("Settings", zed_actions::OpenSettings.boxed_clone())
|
||||
.action("Theme...", theme_selector::Toggle.boxed_clone())
|
||||
.action("Extensions", extensions_ui::Extensions.boxed_clone())
|
||||
.separator()
|
||||
.action("Share Feedback...", feedback::GiveFeedback.boxed_clone())
|
||||
.action("Themes...", theme_selector::Toggle.boxed_clone())
|
||||
})
|
||||
.into()
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ use gpui::{
|
||||
actions, point, AppContext, GlobalPixels, Pixels, PlatformDisplay, Size, Task, WindowBounds,
|
||||
WindowContext, WindowKind, WindowOptions,
|
||||
};
|
||||
use panel_settings::MessageEditorSettings;
|
||||
pub use panel_settings::{
|
||||
ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings,
|
||||
};
|
||||
@@ -31,6 +32,7 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
|
||||
CollaborationPanelSettings::register(cx);
|
||||
ChatPanelSettings::register(cx);
|
||||
NotificationPanelSettings::register(cx);
|
||||
MessageEditorSettings::register(cx);
|
||||
|
||||
vcs_menu::init(cx);
|
||||
collab_titlebar_item::init(cx);
|
||||
|
||||
@@ -14,25 +14,25 @@ impl FacePile {
|
||||
}
|
||||
|
||||
pub fn new(faces: SmallVec<[AnyElement; 2]>) -> Self {
|
||||
Self {
|
||||
base: h_flex(),
|
||||
faces,
|
||||
}
|
||||
Self { base: div(), faces }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FacePile {
|
||||
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
|
||||
let player_count = self.faces.len();
|
||||
let player_list = self.faces.into_iter().enumerate().map(|(ix, player)| {
|
||||
let isnt_last = ix < player_count - 1;
|
||||
|
||||
div()
|
||||
.z_index((player_count - ix) as u16)
|
||||
.when(isnt_last, |div| div.neg_mr_1())
|
||||
.child(player)
|
||||
});
|
||||
self.base.children(player_list)
|
||||
// Lay the faces out in reverse so they overlap in the desired order (left to right, front to back)
|
||||
self.base
|
||||
.flex()
|
||||
.flex_row_reverse()
|
||||
.items_center()
|
||||
.justify_start()
|
||||
.children(
|
||||
self.faces
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.map(|(ix, player)| div().when(ix > 0, |div| div.neg_ml_1()).child(player)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,15 @@ pub struct PanelSettingsContent {
|
||||
pub default_width: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
|
||||
pub struct MessageEditorSettings {
|
||||
/// Whether to automatically replace emoji shortcodes with emoji characters.
|
||||
/// For example: typing `:wave:` gets replaced with `👋`.
|
||||
///
|
||||
/// Default: false
|
||||
pub auto_replace_emoji_shortcode: Option<bool>,
|
||||
}
|
||||
|
||||
impl Settings for CollaborationPanelSettings {
|
||||
const KEY: Option<&'static str> = Some("collaboration_panel");
|
||||
type FileContent = PanelSettingsContent;
|
||||
@@ -77,3 +86,15 @@ impl Settings for NotificationPanelSettings {
|
||||
Self::load_via_json_merge(default_value, user_values)
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings for MessageEditorSettings {
|
||||
const KEY: Option<&'static str> = Some("message_editor");
|
||||
type FileContent = MessageEditorSettings;
|
||||
fn load(
|
||||
default_value: &Self::FileContent,
|
||||
user_values: &[&Self::FileContent],
|
||||
_: &mut gpui::AppContext,
|
||||
) -> anyhow::Result<Self> {
|
||||
Self::load_via_json_merge(default_value, user_values)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ impl CopilotButton {
|
||||
pub fn build_copilot_menu(&mut self, cx: &mut ViewContext<Self>) -> View<ContextMenu> {
|
||||
let fs = self.fs.clone();
|
||||
|
||||
return ContextMenu::build(cx, move |mut menu, cx| {
|
||||
ContextMenu::build(cx, move |mut menu, cx| {
|
||||
if let Some(language) = self.language.clone() {
|
||||
let fs = fs.clone();
|
||||
let language_enabled =
|
||||
@@ -216,7 +216,7 @@ impl CopilotButton {
|
||||
.boxed_clone(),
|
||||
)
|
||||
.action("Sign Out", SignOut.boxed_clone())
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_enabled(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
|
||||
|
||||
@@ -894,7 +894,7 @@ mod tests {
|
||||
display_map::{BlockContext, TransformBlock},
|
||||
DisplayPoint, GutterDimensions,
|
||||
};
|
||||
use gpui::{px, TestAppContext, VisualTestContext, WindowContext};
|
||||
use gpui::{px, Stateful, TestAppContext, VisualTestContext, WindowContext};
|
||||
use language::{Diagnostic, DiagnosticEntry, DiagnosticSeverity, PointUtf16, Unclipped};
|
||||
use project::FakeFs;
|
||||
use serde_json::json;
|
||||
@@ -1600,20 +1600,18 @@ mod tests {
|
||||
let name: SharedString = match block {
|
||||
TransformBlock::Custom(block) => cx.with_element_context({
|
||||
|cx| -> Option<SharedString> {
|
||||
block
|
||||
.render(&mut BlockContext {
|
||||
context: cx,
|
||||
anchor_x: px(0.),
|
||||
gutter_dimensions: &GutterDimensions::default(),
|
||||
line_height: px(0.),
|
||||
em_width: px(0.),
|
||||
max_width: px(0.),
|
||||
block_id: ix,
|
||||
editor_style: &editor::EditorStyle::default(),
|
||||
})
|
||||
.inner_id()?
|
||||
.try_into()
|
||||
.ok()
|
||||
let mut element = block.render(&mut BlockContext {
|
||||
context: cx,
|
||||
anchor_x: px(0.),
|
||||
gutter_dimensions: &GutterDimensions::default(),
|
||||
line_height: px(0.),
|
||||
em_width: px(0.),
|
||||
max_width: px(0.),
|
||||
block_id: ix,
|
||||
editor_style: &editor::EditorStyle::default(),
|
||||
});
|
||||
let element = element.downcast_mut::<Stateful<Div>>().unwrap();
|
||||
element.interactivity().element_id.clone()?.try_into().ok()
|
||||
}
|
||||
})?,
|
||||
|
||||
|
||||
@@ -36,11 +36,12 @@ collections.workspace = true
|
||||
convert_case = "0.6.0"
|
||||
copilot.workspace = true
|
||||
db.workspace = true
|
||||
emojis.workspace = true
|
||||
futures.workspace = true
|
||||
fuzzy.workspace = true
|
||||
git.workspace = true
|
||||
gpui.workspace = true
|
||||
indoc = "1.0.4"
|
||||
indoc.workspace = true
|
||||
itertools.workspace = true
|
||||
language.workspace = true
|
||||
lazy_static.workspace = true
|
||||
|
||||
@@ -94,6 +94,12 @@ pub struct SelectDownByLines {
|
||||
pub(super) lines: u32,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Deserialize, Default)]
|
||||
pub struct DuplicateLine {
|
||||
#[serde(default)]
|
||||
pub move_upwards: bool,
|
||||
}
|
||||
|
||||
impl_actions!(
|
||||
editor,
|
||||
[
|
||||
@@ -112,7 +118,8 @@ impl_actions!(
|
||||
MoveUpByLines,
|
||||
MoveDownByLines,
|
||||
SelectUpByLines,
|
||||
SelectDownByLines
|
||||
SelectDownByLines,
|
||||
DuplicateLine
|
||||
]
|
||||
);
|
||||
|
||||
@@ -152,7 +159,6 @@ gpui::actions!(
|
||||
DeleteToPreviousSubwordStart,
|
||||
DeleteToPreviousWordStart,
|
||||
DisplayCursorNames,
|
||||
DuplicateLine,
|
||||
ExpandMacroRecursively,
|
||||
FindAllReferences,
|
||||
Fold,
|
||||
@@ -204,6 +210,7 @@ gpui::actions!(
|
||||
PageDown,
|
||||
PageUp,
|
||||
Paste,
|
||||
RevertSelectedHunks,
|
||||
Redo,
|
||||
RedoSelection,
|
||||
Rename,
|
||||
|
||||
@@ -46,7 +46,7 @@ pub use block_map::{
|
||||
BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock, TransformBlock,
|
||||
};
|
||||
|
||||
pub use self::fold_map::{Fold, FoldPoint};
|
||||
pub use self::fold_map::{Fold, FoldId, FoldPoint};
|
||||
pub use self::inlay_map::{InlayOffset, InlayPoint};
|
||||
pub(crate) use inlay_map::Inlay;
|
||||
|
||||
@@ -339,8 +339,13 @@ impl DisplayMap {
|
||||
pub(crate) struct Highlights<'a> {
|
||||
pub text_highlights: Option<&'a TextHighlights>,
|
||||
pub inlay_highlights: Option<&'a InlayHighlights>,
|
||||
pub inlay_highlight_style: Option<HighlightStyle>,
|
||||
pub suggestion_highlight_style: Option<HighlightStyle>,
|
||||
pub styles: HighlightStyles,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Copy)]
|
||||
pub struct HighlightStyles {
|
||||
pub inlay_hint: Option<HighlightStyle>,
|
||||
pub suggestion: Option<HighlightStyle>,
|
||||
}
|
||||
|
||||
pub struct HighlightedChunk<'a> {
|
||||
@@ -516,8 +521,7 @@ impl DisplaySnapshot {
|
||||
&self,
|
||||
display_rows: Range<u32>,
|
||||
language_aware: bool,
|
||||
inlay_highlight_style: Option<HighlightStyle>,
|
||||
suggestion_highlight_style: Option<HighlightStyle>,
|
||||
highlight_styles: HighlightStyles,
|
||||
) -> DisplayChunks<'_> {
|
||||
self.block_snapshot.chunks(
|
||||
display_rows,
|
||||
@@ -525,8 +529,7 @@ impl DisplaySnapshot {
|
||||
Highlights {
|
||||
text_highlights: Some(&self.text_highlights),
|
||||
inlay_highlights: Some(&self.inlay_highlights),
|
||||
inlay_highlight_style,
|
||||
suggestion_highlight_style,
|
||||
styles: highlight_styles,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -540,8 +543,10 @@ impl DisplaySnapshot {
|
||||
self.chunks(
|
||||
display_rows,
|
||||
language_aware,
|
||||
Some(editor_style.inlays_style),
|
||||
Some(editor_style.suggestions_style),
|
||||
HighlightStyles {
|
||||
inlay_hint: Some(editor_style.inlay_hints_style),
|
||||
suggestion: Some(editor_style.suggestions_style),
|
||||
},
|
||||
)
|
||||
.map(|chunk| {
|
||||
let mut highlight_style = chunk
|
||||
@@ -1846,7 +1851,7 @@ pub mod tests {
|
||||
) -> Vec<(String, Option<Hsla>, Option<Hsla>)> {
|
||||
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
|
||||
let mut chunks: Vec<(String, Option<Hsla>, Option<Hsla>)> = Vec::new();
|
||||
for chunk in snapshot.chunks(rows, true, None, None) {
|
||||
for chunk in snapshot.chunks(rows, true, HighlightStyles::default()) {
|
||||
let syntax_color = chunk
|
||||
.syntax_highlight_id
|
||||
.and_then(|id| id.style(theme)?.color);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::InlayId;
|
||||
use crate::{HighlightStyles, InlayId};
|
||||
use collections::{BTreeMap, BTreeSet};
|
||||
use gpui::HighlightStyle;
|
||||
use language::{Chunk, Edit, Point, TextSummary};
|
||||
@@ -215,8 +215,7 @@ pub struct InlayChunks<'a> {
|
||||
inlay_chunk: Option<&'a str>,
|
||||
output_offset: InlayOffset,
|
||||
max_output_offset: InlayOffset,
|
||||
inlay_highlight_style: Option<HighlightStyle>,
|
||||
suggestion_highlight_style: Option<HighlightStyle>,
|
||||
highlight_styles: HighlightStyles,
|
||||
highlight_endpoints: Peekable<vec::IntoIter<HighlightEndpoint>>,
|
||||
active_highlights: BTreeMap<Option<TypeId>, HighlightStyle>,
|
||||
highlights: Highlights<'a>,
|
||||
@@ -307,8 +306,8 @@ impl<'a> Iterator for InlayChunks<'a> {
|
||||
}
|
||||
|
||||
let mut highlight_style = match inlay.id {
|
||||
InlayId::Suggestion(_) => self.suggestion_highlight_style,
|
||||
InlayId::Hint(_) => self.inlay_highlight_style,
|
||||
InlayId::Suggestion(_) => self.highlight_styles.suggestion,
|
||||
InlayId::Hint(_) => self.highlight_styles.inlay_hint,
|
||||
};
|
||||
let next_inlay_highlight_endpoint;
|
||||
let offset_in_inlay = self.output_offset - self.transforms.start().0;
|
||||
@@ -1052,8 +1051,7 @@ impl InlaySnapshot {
|
||||
buffer_chunk: None,
|
||||
output_offset: range.start,
|
||||
max_output_offset: range.end,
|
||||
inlay_highlight_style: highlights.inlay_highlight_style,
|
||||
suggestion_highlight_style: highlights.suggestion_highlight_style,
|
||||
highlight_styles: highlights.styles,
|
||||
highlight_endpoints: highlight_endpoints.into_iter().peekable(),
|
||||
active_highlights: Default::default(),
|
||||
highlights,
|
||||
|
||||
@@ -36,14 +36,14 @@ mod selections_collection;
|
||||
mod editor_tests;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test;
|
||||
use ::git::diff::DiffHunk;
|
||||
use ::git::diff::{DiffHunk, DiffHunkStatus};
|
||||
pub(crate) use actions::*;
|
||||
use aho_corasick::AhoCorasick;
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use blink_manager::BlinkManager;
|
||||
use client::{Collaborator, ParticipantIndex};
|
||||
use clock::ReplicaId;
|
||||
use collections::{BTreeMap, Bound, HashMap, HashSet, VecDeque};
|
||||
use collections::{hash_map, BTreeMap, Bound, HashMap, HashSet, VecDeque};
|
||||
use convert_case::{Case, Casing};
|
||||
use copilot::Copilot;
|
||||
use debounced_delay::DebouncedDelay;
|
||||
@@ -51,7 +51,9 @@ pub use display_map::DisplayPoint;
|
||||
use display_map::*;
|
||||
pub use editor_settings::EditorSettings;
|
||||
use element::LineWithInvisibles;
|
||||
pub use element::{Cursor, EditorElement, HighlightedRange, HighlightedRangeLine};
|
||||
pub use element::{
|
||||
CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition,
|
||||
};
|
||||
use futures::FutureExt;
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
use git::diff_hunk_to_display;
|
||||
@@ -60,9 +62,9 @@ use gpui::{
|
||||
AnyElement, AppContext, AsyncWindowContext, BackgroundExecutor, Bounds, ClipboardItem, Context,
|
||||
DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusableView, FontId, FontStyle,
|
||||
FontWeight, HighlightStyle, Hsla, InteractiveText, KeyContext, Model, MouseButton,
|
||||
ParentElement, Pixels, Render, SharedString, Styled, StyledText, Subscription, Task, TextStyle,
|
||||
UnderlineStyle, UniformListScrollHandle, View, ViewContext, ViewInputHandler, VisualContext,
|
||||
WeakView, WhiteSpace, WindowContext,
|
||||
ParentElement, Pixels, Render, SharedString, StrikethroughStyle, Styled, StyledText,
|
||||
Subscription, Task, TextStyle, UnderlineStyle, UniformListScrollHandle, View, ViewContext,
|
||||
ViewInputHandler, VisualContext, WeakView, WhiteSpace, WindowContext,
|
||||
};
|
||||
use highlight_matching_bracket::refresh_matching_bracket_highlights;
|
||||
use hover_popover::{hide_hover, HoverState};
|
||||
@@ -111,7 +113,6 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
pub use sum_tree::Bias;
|
||||
use sum_tree::TreeMap;
|
||||
use text::{BufferId, OffsetUtf16, Rope};
|
||||
use theme::{
|
||||
observe_buffer_font_size_adjustment, ActiveTheme, PlayerColor, StatusColors, SyntaxTheme,
|
||||
@@ -323,7 +324,7 @@ pub struct EditorStyle {
|
||||
pub scrollbar_width: Pixels,
|
||||
pub syntax: Arc<SyntaxTheme>,
|
||||
pub status: StatusColors,
|
||||
pub inlays_style: HighlightStyle,
|
||||
pub inlay_hints_style: HighlightStyle,
|
||||
pub suggestions_style: HighlightStyle,
|
||||
}
|
||||
|
||||
@@ -339,7 +340,7 @@ impl Default for EditorStyle {
|
||||
// We should look into removing the status colors from the editor
|
||||
// style and retrieve them directly from the theme.
|
||||
status: StatusColors::dark(),
|
||||
inlays_style: HighlightStyle::default(),
|
||||
inlay_hints_style: HighlightStyle::default(),
|
||||
suggestions_style: HighlightStyle::default(),
|
||||
}
|
||||
}
|
||||
@@ -351,7 +352,6 @@ type CompletionId = usize;
|
||||
// type OverrideTextStyle = dyn Fn(&EditorStyle) -> Option<HighlightStyle>;
|
||||
|
||||
type BackgroundHighlight = (fn(&ThemeColors) -> Hsla, Vec<Range<Anchor>>);
|
||||
type InlayBackgroundHighlight = (fn(&ThemeColors) -> Hsla, Vec<InlayHighlight>);
|
||||
|
||||
/// Zed's primary text input `View`, allowing users to edit a [`MultiBuffer`]
|
||||
///
|
||||
@@ -388,9 +388,9 @@ pub struct Editor {
|
||||
show_gutter: bool,
|
||||
show_wrap_guides: Option<bool>,
|
||||
placeholder_text: Option<Arc<str>>,
|
||||
highlighted_rows: Option<Range<u32>>,
|
||||
highlight_order: usize,
|
||||
highlighted_rows: HashMap<TypeId, Vec<(usize, Range<Anchor>, Hsla)>>,
|
||||
background_highlights: BTreeMap<TypeId, BackgroundHighlight>,
|
||||
inlay_background_highlights: TreeMap<Option<TypeId>, InlayBackgroundHighlight>,
|
||||
nav_history: Option<ItemNavHistory>,
|
||||
context_menu: RwLock<Option<ContextMenu>>,
|
||||
mouse_context_menu: Option<MouseContextMenu>,
|
||||
@@ -425,6 +425,7 @@ pub struct Editor {
|
||||
editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,
|
||||
show_copilot_suggestions: bool,
|
||||
use_autoclose: bool,
|
||||
auto_replace_emoji_shortcode: bool,
|
||||
custom_context_menu: Option<
|
||||
Box<
|
||||
dyn 'static
|
||||
@@ -929,6 +930,15 @@ impl CompletionsMenu {
|
||||
// Ignore font weight for syntax highlighting, as we'll use it
|
||||
// for fuzzy matches.
|
||||
highlight.font_weight = None;
|
||||
|
||||
if completion.lsp_completion.deprecated.unwrap_or(false) {
|
||||
highlight.strikethrough = Some(StrikethroughStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
highlight.color = Some(cx.theme().colors().text_muted);
|
||||
}
|
||||
|
||||
(range, highlight)
|
||||
},
|
||||
),
|
||||
@@ -1186,6 +1196,7 @@ impl CodeActionsMenu {
|
||||
}
|
||||
}),
|
||||
)
|
||||
.whitespace_nowrap()
|
||||
// TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
|
||||
.child(SharedString::from(action.lsp_action.title.clone()))
|
||||
})
|
||||
@@ -1214,6 +1225,7 @@ impl CodeActionsMenu {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CopilotState {
|
||||
excerpt_id: Option<ExcerptId>,
|
||||
pending_refresh: Task<Option<()>>,
|
||||
@@ -1515,9 +1527,9 @@ impl Editor {
|
||||
show_gutter: mode == EditorMode::Full,
|
||||
show_wrap_guides: None,
|
||||
placeholder_text: None,
|
||||
highlighted_rows: None,
|
||||
highlight_order: 0,
|
||||
highlighted_rows: HashMap::default(),
|
||||
background_highlights: Default::default(),
|
||||
inlay_background_highlights: Default::default(),
|
||||
nav_history: None,
|
||||
context_menu: RwLock::new(None),
|
||||
mouse_context_menu: None,
|
||||
@@ -1539,6 +1551,7 @@ impl Editor {
|
||||
use_modal_editing: mode == EditorMode::Full,
|
||||
read_only: false,
|
||||
use_autoclose: true,
|
||||
auto_replace_emoji_shortcode: false,
|
||||
leader_peer_id: None,
|
||||
remote_id: None,
|
||||
hover_state: Default::default(),
|
||||
@@ -1829,6 +1842,10 @@ impl Editor {
|
||||
self.use_autoclose = autoclose;
|
||||
}
|
||||
|
||||
pub fn set_auto_replace_emoji_shortcode(&mut self, auto_replace: bool) {
|
||||
self.auto_replace_emoji_shortcode = auto_replace;
|
||||
}
|
||||
|
||||
pub fn set_show_copilot_suggestions(&mut self, show_copilot_suggestions: bool) {
|
||||
self.show_copilot_suggestions = show_copilot_suggestions;
|
||||
}
|
||||
@@ -2505,6 +2522,47 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
if self.auto_replace_emoji_shortcode
|
||||
&& selection.is_empty()
|
||||
&& text.as_ref().ends_with(':')
|
||||
{
|
||||
if let Some(possible_emoji_short_code) =
|
||||
Self::find_possible_emoji_shortcode_at_position(&snapshot, selection.start)
|
||||
{
|
||||
if !possible_emoji_short_code.is_empty() {
|
||||
if let Some(emoji) = emojis::get_by_shortcode(&possible_emoji_short_code) {
|
||||
let emoji_shortcode_start = Point::new(
|
||||
selection.start.row,
|
||||
selection.start.column - possible_emoji_short_code.len() as u32 - 1,
|
||||
);
|
||||
|
||||
// Remove shortcode from buffer
|
||||
edits.push((
|
||||
emoji_shortcode_start..selection.start,
|
||||
"".to_string().into(),
|
||||
));
|
||||
new_selections.push((
|
||||
Selection {
|
||||
id: selection.id,
|
||||
start: snapshot.anchor_after(emoji_shortcode_start),
|
||||
end: snapshot.anchor_before(selection.start),
|
||||
reversed: selection.reversed,
|
||||
goal: selection.goal,
|
||||
},
|
||||
0,
|
||||
));
|
||||
|
||||
// Insert emoji
|
||||
let selection_start_anchor = snapshot.anchor_after(selection.start);
|
||||
new_selections.push((selection.map(|_| selection_start_anchor), 0));
|
||||
edits.push((selection.start..selection.end, emoji.to_string().into()));
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not handling any auto-close operation, then just replace the selected
|
||||
// text with the given input and move the selection to the end of the
|
||||
// newly inserted text.
|
||||
@@ -2588,6 +2646,53 @@ impl Editor {
|
||||
});
|
||||
}
|
||||
|
||||
fn find_possible_emoji_shortcode_at_position(
|
||||
snapshot: &MultiBufferSnapshot,
|
||||
position: Point,
|
||||
) -> Option<String> {
|
||||
let mut chars = Vec::new();
|
||||
let mut found_colon = false;
|
||||
for char in snapshot.reversed_chars_at(position).take(100) {
|
||||
// Found a possible emoji shortcode in the middle of the buffer
|
||||
if found_colon {
|
||||
if char.is_whitespace() {
|
||||
chars.reverse();
|
||||
return Some(chars.iter().collect());
|
||||
}
|
||||
// If the previous character is not a whitespace, we are in the middle of a word
|
||||
// and we only want to complete the shortcode if the word is made up of other emojis
|
||||
let mut containing_word = String::new();
|
||||
for ch in snapshot
|
||||
.reversed_chars_at(position)
|
||||
.skip(chars.len() + 1)
|
||||
.take(100)
|
||||
{
|
||||
if ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
containing_word.push(ch);
|
||||
}
|
||||
let containing_word = containing_word.chars().rev().collect::<String>();
|
||||
if util::word_consists_of_emojis(containing_word.as_str()) {
|
||||
chars.reverse();
|
||||
return Some(chars.iter().collect());
|
||||
}
|
||||
}
|
||||
|
||||
if char.is_whitespace() || !char.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
if char == ':' {
|
||||
found_colon = true;
|
||||
} else {
|
||||
chars.push(char);
|
||||
}
|
||||
}
|
||||
// Found a possible emoji shortcode at the beginning of the buffer
|
||||
chars.reverse();
|
||||
Some(chars.iter().collect())
|
||||
}
|
||||
|
||||
pub fn newline(&mut self, _: &Newline, cx: &mut ViewContext<Self>) {
|
||||
self.transact(cx, |this, cx| {
|
||||
let (edits, selection_fixup_info): (Vec<_>, Vec<_>) = {
|
||||
@@ -3041,7 +3146,7 @@ impl Editor {
|
||||
(InvalidationStrategy::RefreshRequested, None)
|
||||
} else {
|
||||
self.inlay_hint_cache.clear();
|
||||
self.splice_inlay_hints(
|
||||
self.splice_inlays(
|
||||
self.visible_inlay_hints(cx)
|
||||
.iter()
|
||||
.map(|inlay| inlay.id)
|
||||
@@ -3063,7 +3168,7 @@ impl Editor {
|
||||
to_remove,
|
||||
to_insert,
|
||||
})) => {
|
||||
self.splice_inlay_hints(to_remove, to_insert, cx);
|
||||
self.splice_inlays(to_remove, to_insert, cx);
|
||||
return;
|
||||
}
|
||||
ControlFlow::Break(None) => return,
|
||||
@@ -3076,7 +3181,7 @@ impl Editor {
|
||||
to_insert,
|
||||
}) = self.inlay_hint_cache.remove_excerpts(excerpts_removed)
|
||||
{
|
||||
self.splice_inlay_hints(to_remove, to_insert, cx);
|
||||
self.splice_inlays(to_remove, to_insert, cx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3099,7 +3204,7 @@ impl Editor {
|
||||
ignore_debounce,
|
||||
cx,
|
||||
) {
|
||||
self.splice_inlay_hints(to_remove, to_insert, cx);
|
||||
self.splice_inlays(to_remove, to_insert, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3107,9 +3212,7 @@ impl Editor {
|
||||
self.display_map
|
||||
.read(cx)
|
||||
.current_inlays()
|
||||
.filter(move |inlay| {
|
||||
Some(inlay.id) != self.copilot_state.suggestion.as_ref().map(|h| h.id)
|
||||
})
|
||||
.filter(move |inlay| matches!(inlay.id, InlayId::Hint(_)))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
@@ -3180,7 +3283,7 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
fn splice_inlay_hints(
|
||||
fn splice_inlays(
|
||||
&self,
|
||||
to_remove: Vec<InlayId>,
|
||||
to_insert: Vec<Inlay>,
|
||||
@@ -4088,7 +4191,10 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn clear_copilot_suggestions(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.copilot_state = Default::default();
|
||||
if let Some(old_suggestion) = self.copilot_state.suggestion.take() {
|
||||
self.splice_inlays(vec![old_suggestion.id], Vec::new(), cx);
|
||||
}
|
||||
self.copilot_state = CopilotState::default();
|
||||
self.discard_copilot_suggestion(cx);
|
||||
}
|
||||
|
||||
@@ -4120,14 +4226,14 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub fn render_fold_indicators(
|
||||
&self,
|
||||
&mut self,
|
||||
fold_data: Vec<Option<(FoldStatus, u32, bool)>>,
|
||||
_style: &EditorStyle,
|
||||
gutter_hovered: bool,
|
||||
_line_height: Pixels,
|
||||
_gutter_margin: Pixels,
|
||||
editor_view: View<Editor>,
|
||||
) -> Vec<Option<IconButton>> {
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Vec<Option<AnyElement>> {
|
||||
fold_data
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -4136,24 +4242,20 @@ impl Editor {
|
||||
.map(|(fold_status, buffer_row, active)| {
|
||||
(active || gutter_hovered || fold_status == FoldStatus::Folded).then(|| {
|
||||
IconButton::new(ix, ui::IconName::ChevronDown)
|
||||
.on_click({
|
||||
let view = editor_view.clone();
|
||||
move |_e, cx| {
|
||||
view.update(cx, |editor, cx| match fold_status {
|
||||
FoldStatus::Folded => {
|
||||
editor.unfold_at(&UnfoldAt { buffer_row }, cx);
|
||||
}
|
||||
FoldStatus::Foldable => {
|
||||
editor.fold_at(&FoldAt { buffer_row }, cx);
|
||||
}
|
||||
})
|
||||
.on_click(cx.listener(move |this, _e, cx| match fold_status {
|
||||
FoldStatus::Folded => {
|
||||
this.unfold_at(&UnfoldAt { buffer_row }, cx);
|
||||
}
|
||||
})
|
||||
FoldStatus::Foldable => {
|
||||
this.fold_at(&FoldAt { buffer_row }, cx);
|
||||
}
|
||||
}))
|
||||
.icon_color(ui::Color::Muted)
|
||||
.icon_size(ui::IconSize::Small)
|
||||
.selected(fold_status == FoldStatus::Folded)
|
||||
.selected_icon(ui::IconName::ChevronRight)
|
||||
.size(ui::ButtonSize::None)
|
||||
.into_any_element()
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
@@ -4835,6 +4937,105 @@ impl Editor {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn revert_selected_hunks(&mut self, _: &RevertSelectedHunks, cx: &mut ViewContext<Self>) {
|
||||
let revert_changes = self.gather_revert_changes(&self.selections.disjoint_anchors(), cx);
|
||||
if !revert_changes.is_empty() {
|
||||
self.transact(cx, |editor, cx| {
|
||||
editor.buffer().update(cx, |multi_buffer, cx| {
|
||||
for (buffer_id, buffer_revert_ranges) in revert_changes {
|
||||
if let Some(buffer) = multi_buffer.buffer(buffer_id) {
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit(buffer_revert_ranges, None, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
editor.change_selections(None, cx, |selections| selections.refresh());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn gather_revert_changes(
|
||||
&mut self,
|
||||
selections: &[Selection<Anchor>],
|
||||
cx: &mut ViewContext<'_, Editor>,
|
||||
) -> HashMap<BufferId, Vec<(Range<text::Anchor>, Arc<str>)>> {
|
||||
let mut revert_changes = HashMap::default();
|
||||
self.buffer.update(cx, |multi_buffer, cx| {
|
||||
let multi_buffer_snapshot = multi_buffer.snapshot(cx);
|
||||
let selected_multi_buffer_rows = selections.iter().map(|selection| {
|
||||
let head = selection.head();
|
||||
let tail = selection.tail();
|
||||
let start = tail.to_point(&multi_buffer_snapshot).row;
|
||||
let end = head.to_point(&multi_buffer_snapshot).row;
|
||||
if start > end {
|
||||
end..start
|
||||
} else {
|
||||
start..end
|
||||
}
|
||||
});
|
||||
|
||||
let mut processed_buffer_rows =
|
||||
HashMap::<BufferId, HashSet<Range<text::Anchor>>>::default();
|
||||
for selected_multi_buffer_rows in selected_multi_buffer_rows {
|
||||
let query_rows =
|
||||
selected_multi_buffer_rows.start..selected_multi_buffer_rows.end + 1;
|
||||
for hunk in multi_buffer_snapshot.git_diff_hunks_in_range(query_rows.clone()) {
|
||||
// Deleted hunk is an empty row range, no caret can be placed there and Zed allows to revert it
|
||||
// when the caret is just above or just below the deleted hunk.
|
||||
let allow_adjacent = hunk.status() == DiffHunkStatus::Removed;
|
||||
let related_to_selection = if allow_adjacent {
|
||||
hunk.associated_range.overlaps(&query_rows)
|
||||
|| hunk.associated_range.start == query_rows.end
|
||||
|| hunk.associated_range.end == query_rows.start
|
||||
} else {
|
||||
// `selected_multi_buffer_rows` are inclusive (e.g. [2..2] means 2nd row is selected)
|
||||
// `hunk.associated_range` is exclusive (e.g. [2..3] means 2nd row is selected)
|
||||
hunk.associated_range.overlaps(&selected_multi_buffer_rows)
|
||||
|| selected_multi_buffer_rows.end == hunk.associated_range.start
|
||||
};
|
||||
if related_to_selection {
|
||||
if !processed_buffer_rows
|
||||
.entry(hunk.buffer_id)
|
||||
.or_default()
|
||||
.insert(hunk.buffer_range.start..hunk.buffer_range.end)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Self::prepare_revert_change(&mut revert_changes, &multi_buffer, &hunk, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
revert_changes
|
||||
}
|
||||
|
||||
fn prepare_revert_change(
|
||||
revert_changes: &mut HashMap<BufferId, Vec<(Range<text::Anchor>, Arc<str>)>>,
|
||||
multi_buffer: &MultiBuffer,
|
||||
hunk: &DiffHunk<u32>,
|
||||
cx: &mut AppContext,
|
||||
) -> Option<()> {
|
||||
let buffer = multi_buffer.buffer(hunk.buffer_id)?;
|
||||
let buffer = buffer.read(cx);
|
||||
let original_text = buffer.diff_base()?.get(hunk.diff_base_byte_range.clone())?;
|
||||
let buffer_snapshot = buffer.snapshot();
|
||||
let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default();
|
||||
if let Err(i) = buffer_revert_changes.binary_search_by(|probe| {
|
||||
probe
|
||||
.0
|
||||
.start
|
||||
.cmp(&hunk.buffer_range.start, &buffer_snapshot)
|
||||
.then(probe.0.end.cmp(&hunk.buffer_range.end, &buffer_snapshot))
|
||||
.then(probe.1.as_ref().cmp(original_text))
|
||||
}) {
|
||||
buffer_revert_changes.insert(i, (hunk.buffer_range.clone(), Arc::from(original_text)));
|
||||
Some(())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reverse_lines(&mut self, _: &ReverseLines, cx: &mut ViewContext<Self>) {
|
||||
self.manipulate_lines(cx, |lines| lines.reverse())
|
||||
}
|
||||
@@ -5032,7 +5233,7 @@ impl Editor {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn duplicate_line(&mut self, _: &DuplicateLine, cx: &mut ViewContext<Self>) {
|
||||
pub fn duplicate_line(&mut self, action: &DuplicateLine, cx: &mut ViewContext<Self>) {
|
||||
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
|
||||
let buffer = &display_map.buffer_snapshot;
|
||||
let selections = self.selections.all::<Point>(cx);
|
||||
@@ -5053,14 +5254,20 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the text from the selected row region and splice it at the start of the region.
|
||||
// Copy the text from the selected row region and splice it either at the start
|
||||
// or end of the region.
|
||||
let start = Point::new(rows.start, 0);
|
||||
let end = Point::new(rows.end - 1, buffer.line_len(rows.end - 1));
|
||||
let text = buffer
|
||||
.text_for_range(start..end)
|
||||
.chain(Some("\n"))
|
||||
.collect::<String>();
|
||||
edits.push((start..start, text));
|
||||
let insert_location = if action.move_upwards {
|
||||
Point::new(rows.end, 0)
|
||||
} else {
|
||||
start
|
||||
};
|
||||
edits.push((insert_location..insert_location, text));
|
||||
}
|
||||
|
||||
self.transact(cx, |this, cx| {
|
||||
@@ -7959,7 +8166,7 @@ impl Editor {
|
||||
scrollbar_width: cx.editor_style.scrollbar_width,
|
||||
syntax: cx.editor_style.syntax.clone(),
|
||||
status: cx.editor_style.status.clone(),
|
||||
inlays_style: HighlightStyle {
|
||||
inlay_hints_style: HighlightStyle {
|
||||
color: Some(cx.theme().status().hint),
|
||||
font_weight: Some(FontWeight::BOLD),
|
||||
..HighlightStyle::default()
|
||||
@@ -8736,12 +8943,93 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn highlight_rows(&mut self, rows: Option<Range<u32>>) {
|
||||
self.highlighted_rows = rows;
|
||||
/// Adds or removes (on `None` color) a highlight for the rows corresponding to the anchor range given.
|
||||
/// On matching anchor range, replaces the old highlight; does not clear the other existing highlights.
|
||||
/// If multiple anchor ranges will produce highlights for the same row, the last range added will be used.
|
||||
pub fn highlight_rows<T: 'static>(
|
||||
&mut self,
|
||||
rows: Range<Anchor>,
|
||||
color: Option<Hsla>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx);
|
||||
match self.highlighted_rows.entry(TypeId::of::<T>()) {
|
||||
hash_map::Entry::Occupied(o) => {
|
||||
let row_highlights = o.into_mut();
|
||||
let existing_highlight_index =
|
||||
row_highlights.binary_search_by(|(_, highlight_range, _)| {
|
||||
highlight_range
|
||||
.start
|
||||
.cmp(&rows.start, &multi_buffer_snapshot)
|
||||
.then(highlight_range.end.cmp(&rows.end, &multi_buffer_snapshot))
|
||||
});
|
||||
match color {
|
||||
Some(color) => {
|
||||
let insert_index = match existing_highlight_index {
|
||||
Ok(i) => i,
|
||||
Err(i) => i,
|
||||
};
|
||||
row_highlights.insert(
|
||||
insert_index,
|
||||
(post_inc(&mut self.highlight_order), rows, color),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
if let Ok(i) = existing_highlight_index {
|
||||
row_highlights.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hash_map::Entry::Vacant(v) => {
|
||||
if let Some(color) = color {
|
||||
v.insert(vec![(post_inc(&mut self.highlight_order), rows, color)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn highlighted_rows(&self) -> Option<Range<u32>> {
|
||||
self.highlighted_rows.clone()
|
||||
/// Clear all anchor ranges for a certain highlight context type, so no corresponding rows will be highlighted.
|
||||
pub fn clear_row_highlights<T: 'static>(&mut self) {
|
||||
self.highlighted_rows.remove(&TypeId::of::<T>());
|
||||
}
|
||||
|
||||
/// For a highlight given context type, gets all anchor ranges that will be used for row highlighting.
|
||||
pub fn highlighted_rows<T: 'static>(
|
||||
&self,
|
||||
) -> Option<impl Iterator<Item = (&Range<Anchor>, &Hsla)>> {
|
||||
Some(
|
||||
self.highlighted_rows
|
||||
.get(&TypeId::of::<T>())?
|
||||
.iter()
|
||||
.map(|(_, range, color)| (range, color)),
|
||||
)
|
||||
}
|
||||
|
||||
// Merges all anchor ranges for all context types ever set, picking the last highlight added in case of a row conflict.
|
||||
// Rerturns a map of display rows that are highlighted and their corresponding highlight color.
|
||||
pub fn highlighted_display_rows(&mut self, cx: &mut WindowContext) -> BTreeMap<u32, Hsla> {
|
||||
let snapshot = self.snapshot(cx);
|
||||
let mut used_highlight_orders = HashMap::default();
|
||||
self.highlighted_rows
|
||||
.iter()
|
||||
.flat_map(|(_, highlighted_rows)| highlighted_rows.iter())
|
||||
.fold(
|
||||
BTreeMap::<u32, Hsla>::new(),
|
||||
|mut unique_rows, (highlight_order, anchor_range, hsla)| {
|
||||
let start_row = anchor_range.start.to_display_point(&snapshot).row();
|
||||
let end_row = anchor_range.end.to_display_point(&snapshot).row();
|
||||
for row in start_row..=end_row {
|
||||
let used_index =
|
||||
used_highlight_orders.entry(row).or_insert(*highlight_order);
|
||||
if highlight_order >= used_index {
|
||||
*used_index = *highlight_order;
|
||||
unique_rows.insert(row, *hsla);
|
||||
}
|
||||
}
|
||||
unique_rows
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn highlight_background<T: 'static>(
|
||||
@@ -8766,29 +9054,11 @@ impl Editor {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn highlight_inlay_background<T: 'static>(
|
||||
&mut self,
|
||||
ranges: Vec<InlayHighlight>,
|
||||
color_fetcher: fn(&ThemeColors) -> Hsla,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// TODO: no actual highlights happen for inlays currently, find a way to do that
|
||||
self.inlay_background_highlights
|
||||
.insert(Some(TypeId::of::<T>()), (color_fetcher, ranges));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn clear_background_highlights<T: 'static>(
|
||||
&mut self,
|
||||
cx: &mut ViewContext<Self>,
|
||||
_cx: &mut ViewContext<Self>,
|
||||
) -> Option<BackgroundHighlight> {
|
||||
let text_highlights = self.background_highlights.remove(&TypeId::of::<T>());
|
||||
let inlay_highlights = self
|
||||
.inlay_background_highlights
|
||||
.remove(&Some(TypeId::of::<T>()));
|
||||
if text_highlights.is_some() || inlay_highlights.is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
text_highlights
|
||||
}
|
||||
|
||||
@@ -8965,7 +9235,7 @@ impl Editor {
|
||||
&self,
|
||||
search_range: Range<Anchor>,
|
||||
display_snapshot: &DisplaySnapshot,
|
||||
cx: &mut ViewContext<Self>,
|
||||
cx: &WindowContext,
|
||||
) -> Vec<Range<DisplayPoint>> {
|
||||
display_snapshot
|
||||
.buffer_snapshot
|
||||
@@ -9736,7 +10006,7 @@ impl EditorSnapshot {
|
||||
self.is_focused
|
||||
}
|
||||
|
||||
pub fn placeholder_text(&self, _cx: &mut WindowContext) -> Option<&Arc<str>> {
|
||||
pub fn placeholder_text(&self) -> Option<&Arc<str>> {
|
||||
self.placeholder_text.as_ref()
|
||||
}
|
||||
|
||||
@@ -9900,7 +10170,7 @@ impl Render for Editor {
|
||||
scrollbar_width: px(12.),
|
||||
syntax: cx.theme().syntax().clone(),
|
||||
status: cx.theme().status().clone(),
|
||||
inlays_style: HighlightStyle {
|
||||
inlay_hints_style: HighlightStyle {
|
||||
color: Some(cx.theme().status().hint),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
|
||||
@@ -3118,7 +3118,7 @@ fn test_duplicate_line(cx: &mut TestAppContext) {
|
||||
DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0),
|
||||
])
|
||||
});
|
||||
view.duplicate_line(&DuplicateLine, cx);
|
||||
view.duplicate_line(&DuplicateLine::default(), cx);
|
||||
assert_eq!(view.display_text(cx), "abc\nabc\ndef\ndef\nghi\n\n");
|
||||
assert_eq!(
|
||||
view.selections.display_ranges(cx),
|
||||
@@ -3142,7 +3142,7 @@ fn test_duplicate_line(cx: &mut TestAppContext) {
|
||||
DisplayPoint::new(1, 2)..DisplayPoint::new(2, 1),
|
||||
])
|
||||
});
|
||||
view.duplicate_line(&DuplicateLine, cx);
|
||||
view.duplicate_line(&DuplicateLine::default(), cx);
|
||||
assert_eq!(view.display_text(cx), "abc\ndef\nghi\nabc\ndef\nghi\n");
|
||||
assert_eq!(
|
||||
view.selections.display_ranges(cx),
|
||||
@@ -3152,6 +3152,56 @@ fn test_duplicate_line(cx: &mut TestAppContext) {
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
// With `move_upwards` the selections stay in place, except for
|
||||
// the lines inserted above them
|
||||
let view = cx.add_window(|cx| {
|
||||
let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx);
|
||||
build_editor(buffer, cx)
|
||||
});
|
||||
_ = view.update(cx, |view, cx| {
|
||||
view.change_selections(None, cx, |s| {
|
||||
s.select_display_ranges([
|
||||
DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1),
|
||||
DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
|
||||
DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0),
|
||||
DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0),
|
||||
])
|
||||
});
|
||||
view.duplicate_line(&DuplicateLine { move_upwards: true }, cx);
|
||||
assert_eq!(view.display_text(cx), "abc\nabc\ndef\ndef\nghi\n\n");
|
||||
assert_eq!(
|
||||
view.selections.display_ranges(cx),
|
||||
vec![
|
||||
DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1),
|
||||
DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
|
||||
DisplayPoint::new(2, 0)..DisplayPoint::new(2, 0),
|
||||
DisplayPoint::new(6, 0)..DisplayPoint::new(6, 0),
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
let view = cx.add_window(|cx| {
|
||||
let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx);
|
||||
build_editor(buffer, cx)
|
||||
});
|
||||
_ = view.update(cx, |view, cx| {
|
||||
view.change_selections(None, cx, |s| {
|
||||
s.select_display_ranges([
|
||||
DisplayPoint::new(0, 1)..DisplayPoint::new(1, 1),
|
||||
DisplayPoint::new(1, 2)..DisplayPoint::new(2, 1),
|
||||
])
|
||||
});
|
||||
view.duplicate_line(&DuplicateLine { move_upwards: true }, cx);
|
||||
assert_eq!(view.display_text(cx), "abc\ndef\nghi\nabc\ndef\nghi\n");
|
||||
assert_eq!(
|
||||
view.selections.display_ranges(cx),
|
||||
vec![
|
||||
DisplayPoint::new(0, 1)..DisplayPoint::new(1, 1),
|
||||
DisplayPoint::new(1, 2)..DisplayPoint::new(2, 1),
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -5121,6 +5171,78 @@ async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) {
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_auto_replace_emoji_shortcode(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let language = Arc::new(Language::new(
|
||||
LanguageConfig::default(),
|
||||
Some(tree_sitter_rust::language()),
|
||||
));
|
||||
|
||||
let buffer = cx.new_model(|cx| {
|
||||
Buffer::new(0, BufferId::new(cx.entity_id().as_u64()).unwrap(), "")
|
||||
.with_language(language, cx)
|
||||
});
|
||||
let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
|
||||
let (editor, cx) = cx.add_window_view(|cx| build_editor(buffer, cx));
|
||||
editor
|
||||
.condition::<crate::EditorEvent>(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx))
|
||||
.await;
|
||||
|
||||
_ = editor.update(cx, |editor, cx| {
|
||||
editor.set_auto_replace_emoji_shortcode(true);
|
||||
|
||||
editor.handle_input("Hello ", cx);
|
||||
editor.handle_input(":wave", cx);
|
||||
assert_eq!(editor.text(cx), "Hello :wave".unindent());
|
||||
|
||||
editor.handle_input(":", cx);
|
||||
assert_eq!(editor.text(cx), "Hello 👋".unindent());
|
||||
|
||||
editor.handle_input(" :smile", cx);
|
||||
assert_eq!(editor.text(cx), "Hello 👋 :smile".unindent());
|
||||
|
||||
editor.handle_input(":", cx);
|
||||
assert_eq!(editor.text(cx), "Hello 👋 😄".unindent());
|
||||
|
||||
// Ensure shortcode gets replaced when it is part of a word that only consists of emojis
|
||||
editor.handle_input(":wave", cx);
|
||||
assert_eq!(editor.text(cx), "Hello 👋 😄:wave".unindent());
|
||||
|
||||
editor.handle_input(":", cx);
|
||||
assert_eq!(editor.text(cx), "Hello 👋 😄👋".unindent());
|
||||
|
||||
editor.handle_input(":1", cx);
|
||||
assert_eq!(editor.text(cx), "Hello 👋 😄👋:1".unindent());
|
||||
|
||||
editor.handle_input(":", cx);
|
||||
assert_eq!(editor.text(cx), "Hello 👋 😄👋:1:".unindent());
|
||||
|
||||
// Ensure shortcode does not get replaced when it is part of a word
|
||||
editor.handle_input(" Test:wave", cx);
|
||||
assert_eq!(editor.text(cx), "Hello 👋 😄👋:1: Test:wave".unindent());
|
||||
|
||||
editor.handle_input(":", cx);
|
||||
assert_eq!(editor.text(cx), "Hello 👋 😄👋:1: Test:wave:".unindent());
|
||||
|
||||
editor.set_auto_replace_emoji_shortcode(false);
|
||||
|
||||
// Ensure shortcode does not get replaced when auto replace is off
|
||||
editor.handle_input(" :wave", cx);
|
||||
assert_eq!(
|
||||
editor.text(cx),
|
||||
"Hello 👋 😄👋:1: Test:wave: :wave".unindent()
|
||||
);
|
||||
|
||||
editor.handle_input(":", cx);
|
||||
assert_eq!(
|
||||
editor.text(cx),
|
||||
"Hello 👋 😄👋:1: Test:wave: :wave:".unindent()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_snippets(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
@@ -8640,6 +8762,560 @@ async fn test_find_all_references(cx: &mut gpui::TestAppContext) {
|
||||
"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_addition_reverts(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
|
||||
let base_text = indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
struct Row2;
|
||||
|
||||
struct Row4;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
|
||||
struct Row8;
|
||||
struct Row9;
|
||||
struct Row10;"#};
|
||||
|
||||
// When addition hunks are not adjacent to carets, no hunk revert is performed
|
||||
assert_hunk_revert(
|
||||
indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
struct Row1.1;
|
||||
struct Row1.2;
|
||||
struct Row2;ˇ
|
||||
|
||||
struct Row4;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
|
||||
struct Row8;
|
||||
ˇstruct Row9;
|
||||
struct Row9.1;
|
||||
struct Row9.2;
|
||||
struct Row9.3;
|
||||
struct Row10;"#},
|
||||
vec![DiffHunkStatus::Added, DiffHunkStatus::Added],
|
||||
indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
struct Row1.1;
|
||||
struct Row1.2;
|
||||
struct Row2;ˇ
|
||||
|
||||
struct Row4;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
|
||||
struct Row8;
|
||||
ˇstruct Row9;
|
||||
struct Row9.1;
|
||||
struct Row9.2;
|
||||
struct Row9.3;
|
||||
struct Row10;"#},
|
||||
base_text,
|
||||
&mut cx,
|
||||
);
|
||||
// Same for selections
|
||||
assert_hunk_revert(
|
||||
indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
struct Row2;
|
||||
struct Row2.1;
|
||||
struct Row2.2;
|
||||
«ˇ
|
||||
struct Row4;
|
||||
struct» Row5;
|
||||
«struct Row6;
|
||||
ˇ»
|
||||
struct Row9.1;
|
||||
struct Row9.2;
|
||||
struct Row9.3;
|
||||
struct Row8;
|
||||
struct Row9;
|
||||
struct Row10;"#},
|
||||
vec![DiffHunkStatus::Added, DiffHunkStatus::Added],
|
||||
indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
struct Row2;
|
||||
struct Row2.1;
|
||||
struct Row2.2;
|
||||
«ˇ
|
||||
struct Row4;
|
||||
struct» Row5;
|
||||
«struct Row6;
|
||||
ˇ»
|
||||
struct Row9.1;
|
||||
struct Row9.2;
|
||||
struct Row9.3;
|
||||
struct Row8;
|
||||
struct Row9;
|
||||
struct Row10;"#},
|
||||
base_text,
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// When carets and selections intersect the addition hunks, those are reverted.
|
||||
// Adjacent carets got merged.
|
||||
assert_hunk_revert(
|
||||
indoc! {r#"struct Row;
|
||||
ˇ// something on the top
|
||||
struct Row1;
|
||||
struct Row2;
|
||||
struct Roˇw3.1;
|
||||
struct Row2.2;
|
||||
struct Row2.3;ˇ
|
||||
|
||||
struct Row4;
|
||||
struct ˇRow5.1;
|
||||
struct Row5.2;
|
||||
struct «Rowˇ»5.3;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
ˇ
|
||||
struct Row9.1;
|
||||
struct «Rowˇ»9.2;
|
||||
struct «ˇRow»9.3;
|
||||
struct Row8;
|
||||
struct Row9;
|
||||
«ˇ// something on bottom»
|
||||
struct Row10;"#},
|
||||
vec![
|
||||
DiffHunkStatus::Added,
|
||||
DiffHunkStatus::Added,
|
||||
DiffHunkStatus::Added,
|
||||
DiffHunkStatus::Added,
|
||||
DiffHunkStatus::Added,
|
||||
],
|
||||
indoc! {r#"struct Row;
|
||||
ˇstruct Row1;
|
||||
struct Row2;
|
||||
ˇ
|
||||
struct Row4;
|
||||
ˇstruct Row5;
|
||||
struct Row6;
|
||||
ˇ
|
||||
ˇstruct Row8;
|
||||
struct Row9;
|
||||
ˇstruct Row10;"#},
|
||||
base_text,
|
||||
&mut cx,
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_modification_reverts(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
|
||||
let base_text = indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
struct Row2;
|
||||
|
||||
struct Row4;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
|
||||
struct Row8;
|
||||
struct Row9;
|
||||
struct Row10;"#};
|
||||
|
||||
// Modification hunks behave the same as the addition ones.
|
||||
assert_hunk_revert(
|
||||
indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
struct Row33;
|
||||
ˇ
|
||||
struct Row4;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
ˇ
|
||||
struct Row99;
|
||||
struct Row9;
|
||||
struct Row10;"#},
|
||||
vec![DiffHunkStatus::Modified, DiffHunkStatus::Modified],
|
||||
indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
struct Row33;
|
||||
ˇ
|
||||
struct Row4;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
ˇ
|
||||
struct Row99;
|
||||
struct Row9;
|
||||
struct Row10;"#},
|
||||
base_text,
|
||||
&mut cx,
|
||||
);
|
||||
assert_hunk_revert(
|
||||
indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
struct Row33;
|
||||
«ˇ
|
||||
struct Row4;
|
||||
struct» Row5;
|
||||
«struct Row6;
|
||||
ˇ»
|
||||
struct Row99;
|
||||
struct Row9;
|
||||
struct Row10;"#},
|
||||
vec![DiffHunkStatus::Modified, DiffHunkStatus::Modified],
|
||||
indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
struct Row33;
|
||||
«ˇ
|
||||
struct Row4;
|
||||
struct» Row5;
|
||||
«struct Row6;
|
||||
ˇ»
|
||||
struct Row99;
|
||||
struct Row9;
|
||||
struct Row10;"#},
|
||||
base_text,
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
assert_hunk_revert(
|
||||
indoc! {r#"ˇstruct Row1.1;
|
||||
struct Row1;
|
||||
«ˇstr»uct Row22;
|
||||
|
||||
struct ˇRow44;
|
||||
struct Row5;
|
||||
struct «Rˇ»ow66;ˇ
|
||||
|
||||
«struˇ»ct Row88;
|
||||
struct Row9;
|
||||
struct Row1011;ˇ"#},
|
||||
vec![
|
||||
DiffHunkStatus::Modified,
|
||||
DiffHunkStatus::Modified,
|
||||
DiffHunkStatus::Modified,
|
||||
DiffHunkStatus::Modified,
|
||||
DiffHunkStatus::Modified,
|
||||
DiffHunkStatus::Modified,
|
||||
],
|
||||
indoc! {r#"struct Row;
|
||||
ˇstruct Row1;
|
||||
struct Row2;
|
||||
ˇ
|
||||
struct Row4;
|
||||
ˇstruct Row5;
|
||||
struct Row6;
|
||||
ˇ
|
||||
struct Row8;
|
||||
ˇstruct Row9;
|
||||
struct Row10;ˇ"#},
|
||||
base_text,
|
||||
&mut cx,
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_deletion_reverts(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
|
||||
let base_text = indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
struct Row2;
|
||||
|
||||
struct Row4;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
|
||||
struct Row8;
|
||||
struct Row9;
|
||||
struct Row10;"#};
|
||||
|
||||
// Deletion hunks trigger with carets on ajacent rows, so carets and selections have to stay farther to avoid the revert
|
||||
assert_hunk_revert(
|
||||
indoc! {r#"struct Row;
|
||||
struct Row2;
|
||||
|
||||
ˇstruct Row4;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
ˇ
|
||||
struct Row8;
|
||||
struct Row10;"#},
|
||||
vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed],
|
||||
indoc! {r#"struct Row;
|
||||
struct Row2;
|
||||
|
||||
ˇstruct Row4;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
ˇ
|
||||
struct Row8;
|
||||
struct Row10;"#},
|
||||
base_text,
|
||||
&mut cx,
|
||||
);
|
||||
assert_hunk_revert(
|
||||
indoc! {r#"struct Row;
|
||||
struct Row2;
|
||||
|
||||
«ˇstruct Row4;
|
||||
struct» Row5;
|
||||
«struct Row6;
|
||||
ˇ»
|
||||
struct Row8;
|
||||
struct Row10;"#},
|
||||
vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed],
|
||||
indoc! {r#"struct Row;
|
||||
struct Row2;
|
||||
|
||||
«ˇstruct Row4;
|
||||
struct» Row5;
|
||||
«struct Row6;
|
||||
ˇ»
|
||||
struct Row8;
|
||||
struct Row10;"#},
|
||||
base_text,
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// Deletion hunks are ephemeral, so it's impossible to place the caret into them — Zed triggers reverts for lines, adjacent to carets and selections.
|
||||
assert_hunk_revert(
|
||||
indoc! {r#"struct Row;
|
||||
ˇstruct Row2;
|
||||
|
||||
struct Row4;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
|
||||
struct Row8;ˇ
|
||||
struct Row10;"#},
|
||||
vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed],
|
||||
indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
ˇstruct Row2;
|
||||
|
||||
struct Row4;
|
||||
struct Row5;
|
||||
struct Row6;
|
||||
|
||||
struct Row8;ˇ
|
||||
struct Row9;
|
||||
struct Row10;"#},
|
||||
base_text,
|
||||
&mut cx,
|
||||
);
|
||||
assert_hunk_revert(
|
||||
indoc! {r#"struct Row;
|
||||
struct Row2«ˇ;
|
||||
struct Row4;
|
||||
struct» Row5;
|
||||
«struct Row6;
|
||||
|
||||
struct Row8;ˇ»
|
||||
struct Row10;"#},
|
||||
vec![
|
||||
DiffHunkStatus::Removed,
|
||||
DiffHunkStatus::Removed,
|
||||
DiffHunkStatus::Removed,
|
||||
],
|
||||
indoc! {r#"struct Row;
|
||||
struct Row1;
|
||||
struct Row2«ˇ;
|
||||
|
||||
struct Row4;
|
||||
struct» Row5;
|
||||
«struct Row6;
|
||||
|
||||
struct Row8;ˇ»
|
||||
struct Row9;
|
||||
struct Row10;"#},
|
||||
base_text,
|
||||
&mut cx,
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_multibuffer_reverts(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let cols = 4;
|
||||
let rows = 10;
|
||||
let sample_text_1 = sample_text(rows, cols, 'a');
|
||||
assert_eq!(
|
||||
sample_text_1,
|
||||
"aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj"
|
||||
);
|
||||
let sample_text_2 = sample_text(rows, cols, 'l');
|
||||
assert_eq!(
|
||||
sample_text_2,
|
||||
"llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu"
|
||||
);
|
||||
let sample_text_3 = sample_text(rows, cols, 'v');
|
||||
assert_eq!(
|
||||
sample_text_3,
|
||||
"vvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}"
|
||||
);
|
||||
|
||||
fn diff_every_buffer_row(
|
||||
buffer: &Model<Buffer>,
|
||||
sample_text: String,
|
||||
cols: usize,
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) {
|
||||
// revert first character in each row, creating one large diff hunk per buffer
|
||||
let is_first_char = |offset: usize| offset % cols == 0;
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.set_text(
|
||||
sample_text
|
||||
.chars()
|
||||
.enumerate()
|
||||
.map(|(offset, c)| if is_first_char(offset) { 'X' } else { c })
|
||||
.collect::<String>(),
|
||||
cx,
|
||||
);
|
||||
buffer.set_diff_base(Some(sample_text), cx);
|
||||
});
|
||||
cx.executor().run_until_parked();
|
||||
}
|
||||
|
||||
let buffer_1 = cx.new_model(|cx| {
|
||||
Buffer::new(
|
||||
0,
|
||||
BufferId::new(cx.entity_id().as_u64()).unwrap(),
|
||||
sample_text_1.clone(),
|
||||
)
|
||||
});
|
||||
diff_every_buffer_row(&buffer_1, sample_text_1.clone(), cols, cx);
|
||||
|
||||
let buffer_2 = cx.new_model(|cx| {
|
||||
Buffer::new(
|
||||
1,
|
||||
BufferId::new(cx.entity_id().as_u64() + 1).unwrap(),
|
||||
sample_text_2.clone(),
|
||||
)
|
||||
});
|
||||
diff_every_buffer_row(&buffer_2, sample_text_2.clone(), cols, cx);
|
||||
|
||||
let buffer_3 = cx.new_model(|cx| {
|
||||
Buffer::new(
|
||||
2,
|
||||
BufferId::new(cx.entity_id().as_u64() + 2).unwrap(),
|
||||
sample_text_3.clone(),
|
||||
)
|
||||
});
|
||||
diff_every_buffer_row(&buffer_3, sample_text_3.clone(), cols, cx);
|
||||
|
||||
let multibuffer = cx.new_model(|cx| {
|
||||
let mut multibuffer = MultiBuffer::new(0, ReadWrite);
|
||||
multibuffer.push_excerpts(
|
||||
buffer_1.clone(),
|
||||
[
|
||||
ExcerptRange {
|
||||
context: Point::new(0, 0)..Point::new(3, 0),
|
||||
primary: None,
|
||||
},
|
||||
ExcerptRange {
|
||||
context: Point::new(5, 0)..Point::new(7, 0),
|
||||
primary: None,
|
||||
},
|
||||
ExcerptRange {
|
||||
context: Point::new(9, 0)..Point::new(10, 4),
|
||||
primary: None,
|
||||
},
|
||||
],
|
||||
cx,
|
||||
);
|
||||
multibuffer.push_excerpts(
|
||||
buffer_2.clone(),
|
||||
[
|
||||
ExcerptRange {
|
||||
context: Point::new(0, 0)..Point::new(3, 0),
|
||||
primary: None,
|
||||
},
|
||||
ExcerptRange {
|
||||
context: Point::new(5, 0)..Point::new(7, 0),
|
||||
primary: None,
|
||||
},
|
||||
ExcerptRange {
|
||||
context: Point::new(9, 0)..Point::new(10, 4),
|
||||
primary: None,
|
||||
},
|
||||
],
|
||||
cx,
|
||||
);
|
||||
multibuffer.push_excerpts(
|
||||
buffer_3.clone(),
|
||||
[
|
||||
ExcerptRange {
|
||||
context: Point::new(0, 0)..Point::new(3, 0),
|
||||
primary: None,
|
||||
},
|
||||
ExcerptRange {
|
||||
context: Point::new(5, 0)..Point::new(7, 0),
|
||||
primary: None,
|
||||
},
|
||||
ExcerptRange {
|
||||
context: Point::new(9, 0)..Point::new(10, 4),
|
||||
primary: None,
|
||||
},
|
||||
],
|
||||
cx,
|
||||
);
|
||||
multibuffer
|
||||
});
|
||||
|
||||
let (editor, cx) = cx.add_window_view(|cx| build_editor(multibuffer, cx));
|
||||
editor.update(cx, |editor, cx| {
|
||||
assert_eq!(editor.text(cx), "XaaaXbbbX\nccXc\ndXdd\n\nhXhh\nXiiiXjjjX\n\nXlllXmmmX\nnnXn\noXoo\n\nsXss\nXtttXuuuX\n\nXvvvXwwwX\nxxXx\nyXyy\n\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X\n");
|
||||
editor.select_all(&SelectAll, cx);
|
||||
editor.revert_selected_hunks(&RevertSelectedHunks, cx);
|
||||
});
|
||||
cx.executor().run_until_parked();
|
||||
// When all ranges are selected, all buffer hunks are reverted.
|
||||
editor.update(cx, |editor, cx| {
|
||||
assert_eq!(editor.text(cx), "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\nllll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu\n\n\nvvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}\n\n");
|
||||
});
|
||||
buffer_1.update(cx, |buffer, _| {
|
||||
assert_eq!(buffer.text(), sample_text_1);
|
||||
});
|
||||
buffer_2.update(cx, |buffer, _| {
|
||||
assert_eq!(buffer.text(), sample_text_2);
|
||||
});
|
||||
buffer_3.update(cx, |buffer, _| {
|
||||
assert_eq!(buffer.text(), sample_text_3);
|
||||
});
|
||||
|
||||
diff_every_buffer_row(&buffer_1, sample_text_1.clone(), cols, cx);
|
||||
diff_every_buffer_row(&buffer_2, sample_text_2.clone(), cols, cx);
|
||||
diff_every_buffer_row(&buffer_3, sample_text_3.clone(), cols, cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.change_selections(None, cx, |s| {
|
||||
s.select_ranges(Some(Point::new(0, 0)..Point::new(6, 0)));
|
||||
});
|
||||
editor.revert_selected_hunks(&RevertSelectedHunks, cx);
|
||||
});
|
||||
// Now, when all ranges selected belong to buffer_1, the revert should succeed,
|
||||
// but not affect buffer_2 and its related excerpts.
|
||||
editor.update(cx, |editor, cx| {
|
||||
assert_eq!(
|
||||
editor.text(cx),
|
||||
"aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\nXlllXmmmX\nnnXn\noXoo\nXpppXqqqX\nrrXr\nsXss\nXtttXuuuX\n\n\nXvvvXwwwX\nxxXx\nyXyy\nXzzzX{{{X\n||X|\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X\n\n"
|
||||
);
|
||||
});
|
||||
buffer_1.update(cx, |buffer, _| {
|
||||
assert_eq!(buffer.text(), sample_text_1);
|
||||
});
|
||||
buffer_2.update(cx, |buffer, _| {
|
||||
assert_eq!(
|
||||
buffer.text(),
|
||||
"XlllXmmmX\nnnXn\noXoo\nXpppXqqqX\nrrXr\nsXss\nXtttXuuuX"
|
||||
);
|
||||
});
|
||||
buffer_3.update(cx, |buffer, _| {
|
||||
assert_eq!(
|
||||
buffer.text(),
|
||||
"XvvvXwwwX\nxxXx\nyXyy\nXzzzX{{{X\n||X|\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
|
||||
let point = DisplayPoint::new(row as u32, column as u32);
|
||||
point..point
|
||||
@@ -8810,3 +9486,45 @@ pub(crate) fn rust_lang() -> Arc<Language> {
|
||||
Some(tree_sitter_rust::language()),
|
||||
))
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_hunk_revert(
|
||||
not_reverted_text_with_selections: &str,
|
||||
expected_not_reverted_hunk_statuses: Vec<DiffHunkStatus>,
|
||||
expected_reverted_text_with_selections: &str,
|
||||
base_text: &str,
|
||||
cx: &mut EditorLspTestContext,
|
||||
) {
|
||||
cx.set_state(not_reverted_text_with_selections);
|
||||
cx.update_editor(|editor, cx| {
|
||||
editor
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.unwrap()
|
||||
.update(cx, |buffer, cx| {
|
||||
buffer.set_diff_base(Some(base_text.to_string()), cx);
|
||||
});
|
||||
});
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
let reverted_hunk_statuses = cx.update_editor(|editor, cx| {
|
||||
let snapshot = editor
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.snapshot();
|
||||
let reverted_hunk_statuses = snapshot
|
||||
.git_diff_hunks_in_row_range(0..u32::MAX)
|
||||
.map(|hunk| hunk.status())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
editor.revert_selected_hunks(&RevertSelectedHunks, cx);
|
||||
reverted_hunk_statuses
|
||||
});
|
||||
cx.executor().run_until_parked();
|
||||
cx.assert_editor_state(expected_reverted_text_with_selections);
|
||||
assert_eq!(reverted_hunk_statuses, expected_not_reverted_hunk_statuses);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,20 +46,20 @@ impl DisplayDiffHunk {
|
||||
}
|
||||
|
||||
pub fn diff_hunk_to_display(hunk: DiffHunk<u32>, snapshot: &DisplaySnapshot) -> DisplayDiffHunk {
|
||||
let hunk_start_point = Point::new(hunk.buffer_range.start, 0);
|
||||
let hunk_start_point_sub = Point::new(hunk.buffer_range.start.saturating_sub(1), 0);
|
||||
let hunk_start_point = Point::new(hunk.associated_range.start, 0);
|
||||
let hunk_start_point_sub = Point::new(hunk.associated_range.start.saturating_sub(1), 0);
|
||||
let hunk_end_point_sub = Point::new(
|
||||
hunk.buffer_range
|
||||
hunk.associated_range
|
||||
.end
|
||||
.saturating_sub(1)
|
||||
.max(hunk.buffer_range.start),
|
||||
.max(hunk.associated_range.start),
|
||||
0,
|
||||
);
|
||||
|
||||
let is_removal = hunk.status() == DiffHunkStatus::Removed;
|
||||
|
||||
let folds_start = Point::new(hunk.buffer_range.start.saturating_sub(2), 0);
|
||||
let folds_end = Point::new(hunk.buffer_range.end + 2, 0);
|
||||
let folds_start = Point::new(hunk.associated_range.start.saturating_sub(2), 0);
|
||||
let folds_end = Point::new(hunk.associated_range.end + 2, 0);
|
||||
let folds_range = folds_start..folds_end;
|
||||
|
||||
let containing_fold = snapshot.folds_in_range(folds_range).find(|fold| {
|
||||
@@ -79,7 +79,7 @@ pub fn diff_hunk_to_display(hunk: DiffHunk<u32>, snapshot: &DisplaySnapshot) ->
|
||||
} else {
|
||||
let start = hunk_start_point.to_display_point(snapshot).row();
|
||||
|
||||
let hunk_end_row = hunk.buffer_range.end.max(hunk.buffer_range.start);
|
||||
let hunk_end_row = hunk.associated_range.end.max(hunk.associated_range.start);
|
||||
let hunk_end_point = Point::new(hunk_end_row, 0);
|
||||
let end = hunk_end_point.to_display_point(snapshot).row();
|
||||
|
||||
@@ -264,7 +264,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.git_diff_hunks_in_range(0..12)
|
||||
.map(|hunk| (hunk.status(), hunk.buffer_range))
|
||||
.map(|hunk| (hunk.status(), hunk.associated_range))
|
||||
.collect::<Vec<_>>(),
|
||||
&expected,
|
||||
);
|
||||
@@ -272,7 +272,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.git_diff_hunks_in_range_rev(0..12)
|
||||
.map(|hunk| (hunk.status(), hunk.buffer_range))
|
||||
.map(|hunk| (hunk.status(), hunk.associated_range))
|
||||
.collect::<Vec<_>>(),
|
||||
expected
|
||||
.iter()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
element::PointForPosition,
|
||||
hover_popover::{self, InlayHover},
|
||||
Anchor, Editor, EditorSnapshot, GoToDefinition, GoToTypeDefinition, InlayId, SelectPhase,
|
||||
Anchor, Editor, EditorSnapshot, GoToDefinition, GoToTypeDefinition, InlayId, PointForPosition,
|
||||
SelectPhase,
|
||||
};
|
||||
use gpui::{px, AsyncWindowContext, Model, Modifiers, Task, ViewContext};
|
||||
use language::{Bias, ToOffset};
|
||||
|
||||
@@ -114,12 +114,7 @@ pub fn hover_at_inlay(editor: &mut Editor, inlay_hover: InlayHover, cx: &mut Vie
|
||||
};
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
// Highlight the selected symbol using a background highlight
|
||||
this.highlight_inlay_background::<HoverState>(
|
||||
vec![inlay_hover.range],
|
||||
|theme| theme.element_hover, // todo("use a proper background here")
|
||||
cx,
|
||||
);
|
||||
// TODO: no background highlights happen for inlays currently
|
||||
this.hover_state.info_popover = Some(hover_popover);
|
||||
cx.notify();
|
||||
})?;
|
||||
@@ -504,9 +499,10 @@ impl InfoPopover {
|
||||
.overflow_y_scroll()
|
||||
.max_w(max_size.width)
|
||||
.max_h(max_size.height)
|
||||
// Prevent a mouse move on the popover from being propagated to the editor,
|
||||
// Prevent a mouse down/move on the popover from being propagated to the editor,
|
||||
// because that would dismiss the popover.
|
||||
.on_mouse_move(|_, cx| cx.stop_propagation())
|
||||
.on_mouse_down(MouseButton::Left, |_, cx| cx.stop_propagation())
|
||||
.child(crate::render_parsed_markdown(
|
||||
"content",
|
||||
&self.parsed_content,
|
||||
@@ -568,6 +564,7 @@ impl DiagnosticPopover {
|
||||
|
||||
div()
|
||||
.id("diagnostic")
|
||||
.block()
|
||||
.elevation_2(cx)
|
||||
.overflow_y_scroll()
|
||||
.px_2()
|
||||
@@ -607,11 +604,10 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
editor_tests::init_test,
|
||||
element::PointForPosition,
|
||||
hover_links::update_inlay_link_and_hover_points,
|
||||
inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels},
|
||||
test::editor_lsp_test_context::EditorLspTestContext,
|
||||
InlayId,
|
||||
InlayId, PointForPosition,
|
||||
};
|
||||
use collections::BTreeSet;
|
||||
use gpui::{FontWeight, HighlightStyle, UnderlineStyle};
|
||||
|
||||
@@ -1255,7 +1255,7 @@ fn apply_hint_update(
|
||||
editor.inlay_hint_cache.version += 1;
|
||||
}
|
||||
if displayed_inlays_changed {
|
||||
editor.splice_inlay_hints(to_remove, to_insert, cx)
|
||||
editor.splice_inlays(to_remove, to_insert, cx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ use anyhow::{anyhow, Context as _, Result};
|
||||
use collections::HashSet;
|
||||
use futures::future::try_join_all;
|
||||
use gpui::{
|
||||
div, point, AnyElement, AppContext, AsyncWindowContext, Context, Entity, EntityId,
|
||||
EventEmitter, IntoElement, Model, ParentElement, Pixels, Render, SharedString, Styled,
|
||||
Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext,
|
||||
point, AnyElement, AppContext, AsyncWindowContext, Context, Entity, EntityId, EventEmitter,
|
||||
IntoElement, Model, ParentElement, Pixels, SharedString, Styled, Task, View, ViewContext,
|
||||
VisualContext, WeakView, WindowContext,
|
||||
};
|
||||
use language::{
|
||||
proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, CharKind, OffsetRangeExt,
|
||||
@@ -21,7 +21,6 @@ use rpc::proto::{self, update_view, PeerId};
|
||||
use settings::Settings;
|
||||
use workspace::item::ItemSettings;
|
||||
|
||||
use std::fmt::Write;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
cmp::{self, Ordering},
|
||||
@@ -33,11 +32,8 @@ use std::{
|
||||
use text::{BufferId, Selection};
|
||||
use theme::Theme;
|
||||
use ui::{h_flex, prelude::*, Label};
|
||||
use util::{paths::PathExt, paths::FILE_ROW_COLUMN_DELIMITER, ResultExt, TryFutureExt};
|
||||
use workspace::{
|
||||
item::{BreadcrumbText, FollowEvent, FollowableItemHandle},
|
||||
StatusItemView,
|
||||
};
|
||||
use util::{paths::PathExt, ResultExt, TryFutureExt};
|
||||
use workspace::item::{BreadcrumbText, FollowEvent, FollowableItemHandle};
|
||||
use workspace::{
|
||||
item::{FollowableItem, Item, ItemEvent, ItemHandle, ProjectItem},
|
||||
searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
|
||||
@@ -1199,83 +1195,6 @@ pub fn active_match_index(
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CursorPosition {
|
||||
position: Option<Point>,
|
||||
selected_count: usize,
|
||||
_observe_active_editor: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl Default for CursorPosition {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CursorPosition {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
position: None,
|
||||
selected_count: 0,
|
||||
_observe_active_editor: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_position(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
|
||||
let editor = editor.read(cx);
|
||||
let buffer = editor.buffer().read(cx).snapshot(cx);
|
||||
|
||||
self.selected_count = 0;
|
||||
let mut last_selection: Option<Selection<usize>> = None;
|
||||
for selection in editor.selections.all::<usize>(cx) {
|
||||
self.selected_count += selection.end - selection.start;
|
||||
if last_selection
|
||||
.as_ref()
|
||||
.map_or(true, |last_selection| selection.id > last_selection.id)
|
||||
{
|
||||
last_selection = Some(selection);
|
||||
}
|
||||
}
|
||||
self.position = last_selection.map(|s| s.head().to_point(&buffer));
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CursorPosition {
|
||||
fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
div().when_some(self.position, |el, position| {
|
||||
let mut text = format!(
|
||||
"{}{FILE_ROW_COLUMN_DELIMITER}{}",
|
||||
position.row + 1,
|
||||
position.column + 1
|
||||
);
|
||||
if self.selected_count > 0 {
|
||||
write!(text, " ({} selected)", self.selected_count).unwrap();
|
||||
}
|
||||
|
||||
el.child(Label::new(text).size(LabelSize::Small))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl StatusItemView for CursorPosition {
|
||||
fn set_active_pane_item(
|
||||
&mut self,
|
||||
active_pane_item: Option<&dyn ItemHandle>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(editor) = active_pane_item.and_then(|item| item.act_as::<Editor>(cx)) {
|
||||
self._observe_active_editor = Some(cx.observe(&editor, Self::update_position));
|
||||
self.update_position(editor, cx);
|
||||
} else {
|
||||
self.position = None;
|
||||
self._observe_active_editor = None;
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn path_for_buffer<'a>(
|
||||
buffer: &Model<MultiBuffer>,
|
||||
height: usize,
|
||||
|
||||
@@ -81,8 +81,8 @@ impl Editor {
|
||||
|
||||
let mut target_top;
|
||||
let mut target_bottom;
|
||||
if let Some(highlighted_rows) = &self.highlighted_rows {
|
||||
target_top = highlighted_rows.start as f32;
|
||||
if let Some(first_highlighted_row) = &self.highlighted_display_rows(cx).first_entry() {
|
||||
target_top = *first_highlighted_row.key() as f32;
|
||||
target_bottom = target_top + 1.;
|
||||
} else {
|
||||
let selections = self.selections.all::<Point>(cx);
|
||||
@@ -205,10 +205,7 @@ impl Editor {
|
||||
let mut target_left;
|
||||
let mut target_right;
|
||||
|
||||
if self.highlighted_rows.is_some() {
|
||||
target_left = px(0.);
|
||||
target_right = px(0.);
|
||||
} else {
|
||||
if self.highlighted_rows.is_empty() {
|
||||
target_left = px(f32::INFINITY);
|
||||
target_right = px(0.);
|
||||
for selection in selections {
|
||||
@@ -229,6 +226,9 @@ impl Editor {
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
target_left = px(0.);
|
||||
target_right = px(0.);
|
||||
}
|
||||
|
||||
target_right = target_right.min(scroll_width);
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
DisplayPoint, Editor, EditorMode, MultiBuffer,
|
||||
};
|
||||
|
||||
use gpui::{Context, Model, Pixels, ViewContext};
|
||||
use gpui::{Context, Font, FontFeatures, FontStyle, FontWeight, Model, Pixels, ViewContext};
|
||||
|
||||
use project::Project;
|
||||
use util::test::{marked_text_offsets, marked_text_ranges};
|
||||
@@ -26,7 +26,12 @@ pub fn marked_display_snapshot(
|
||||
) -> (DisplaySnapshot, Vec<DisplayPoint>) {
|
||||
let (unmarked_text, markers) = marked_text_offsets(text);
|
||||
|
||||
let font = cx.text_style().font();
|
||||
let font = Font {
|
||||
family: "Courier".into(),
|
||||
features: FontFeatures::default(),
|
||||
weight: FontWeight::default(),
|
||||
style: FontStyle::default(),
|
||||
};
|
||||
let font_size: Pixels = 14usize.into();
|
||||
|
||||
let buffer = MultiBuffer::build_simple(&unmarked_text, cx);
|
||||
|
||||
@@ -274,7 +274,7 @@ impl EditorTestContext {
|
||||
let buffer_text = self.buffer_text();
|
||||
|
||||
if buffer_text != unmarked_text {
|
||||
panic!("Unmarked text doesn't match buffer text\nBuffer text: {buffer_text:?}\nUnmarked text: {unmarked_text:?}\nRaw buffer text\n{buffer_text}Raw unmarked text\n{unmarked_text}");
|
||||
panic!("Unmarked text doesn't match buffer text\nBuffer text: {buffer_text:?}\nUnmarked text: {unmarked_text:?}\nRaw buffer text\n{buffer_text}\nRaw unmarked text\n{unmarked_text}");
|
||||
}
|
||||
|
||||
self.assert_selections(expected_selections, marked_text.to_string())
|
||||
|
||||
@@ -20,6 +20,7 @@ anyhow.workspace = true
|
||||
async-compression.workspace = true
|
||||
async-tar.workspace = true
|
||||
async-trait.workspace = true
|
||||
cap-std.workspace = true
|
||||
collections.workspace = true
|
||||
fs.workspace = true
|
||||
futures.workspace = true
|
||||
@@ -36,11 +37,17 @@ settings.workspace = true
|
||||
theme.workspace = true
|
||||
toml.workspace = true
|
||||
util.workspace = true
|
||||
wasmtime = { workspace = true, features = ["async"] }
|
||||
wasm-encoder.workspace = true
|
||||
wasmtime.workspace = true
|
||||
wasmtime-wasi.workspace = true
|
||||
wasmparser.workspace = true
|
||||
wit-component.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ctor.workspace = true
|
||||
env_logger.workspace = true
|
||||
parking_lot.workspace = true
|
||||
|
||||
fs = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
language = { workspace = true, features = ["test-support"] }
|
||||
|
||||
456
crates/extension/src/build_extension.rs
Normal file
456
crates/extension/src/build_extension.rs
Normal file
@@ -0,0 +1,456 @@
|
||||
use crate::ExtensionManifest;
|
||||
use crate::{extension_manifest::ExtensionLibraryKind, GrammarManifestEntry};
|
||||
use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use async_compression::futures::bufread::GzipDecoder;
|
||||
use async_tar::Archive;
|
||||
use futures::io::BufReader;
|
||||
use futures::AsyncReadExt;
|
||||
use serde::Deserialize;
|
||||
use std::mem;
|
||||
use std::{
|
||||
env, fs,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
sync::Arc,
|
||||
};
|
||||
use util::http::{self, AsyncBody, HttpClient};
|
||||
use wasm_encoder::{ComponentSectionId, Encode as _, RawSection, Section as _};
|
||||
use wasmparser::Parser;
|
||||
use wit_component::ComponentEncoder;
|
||||
|
||||
/// Currently, we compile with Rust's `wasm32-wasi` target, which works with WASI `preview1`.
|
||||
/// But the WASM component model is based on WASI `preview2`. So we need an 'adapter' WASM
|
||||
/// module, which implements the `preview1` interface in terms of `preview2`.
|
||||
///
|
||||
/// Once Rust 1.78 is released, there will be a `wasm32-wasip2` target available, so we will
|
||||
/// not need the adapter anymore.
|
||||
const RUST_TARGET: &str = "wasm32-wasi";
|
||||
const WASI_ADAPTER_URL: &str =
|
||||
"https://github.com/bytecodealliance/wasmtime/releases/download/v18.0.2/wasi_snapshot_preview1.reactor.wasm";
|
||||
|
||||
/// Compiling Tree-sitter parsers from C to WASM requires Clang 17, and a WASM build of libc
|
||||
/// and clang's runtime library. The `wasi-sdk` provides these binaries.
|
||||
///
|
||||
/// Once Clang 17 and its wasm target are available via system package managers, we won't need
|
||||
/// to download this.
|
||||
const WASI_SDK_URL: &str = "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-21/";
|
||||
const WASI_SDK_ASSET_NAME: Option<&str> = if cfg!(target_os = "macos") {
|
||||
Some("wasi-sdk-21.0-macos.tar.gz")
|
||||
} else if cfg!(target_os = "linux") {
|
||||
Some("wasi-sdk-21.0-linux.tar.gz")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
pub struct ExtensionBuilder {
|
||||
cache_dir: PathBuf,
|
||||
pub http: Arc<dyn HttpClient>,
|
||||
}
|
||||
|
||||
pub struct CompileExtensionOptions {
|
||||
pub release: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CargoToml {
|
||||
package: CargoTomlPackage,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CargoTomlPackage {
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl ExtensionBuilder {
|
||||
pub fn new(cache_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
cache_dir,
|
||||
http: http::client(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn compile_extension(
|
||||
&self,
|
||||
extension_dir: &Path,
|
||||
options: CompileExtensionOptions,
|
||||
) -> Result<()> {
|
||||
fs::create_dir_all(&self.cache_dir)?;
|
||||
let extension_toml_path = extension_dir.join("extension.toml");
|
||||
let extension_toml_content = fs::read_to_string(&extension_toml_path)?;
|
||||
let extension_toml: ExtensionManifest = toml::from_str(&extension_toml_content)?;
|
||||
|
||||
let cargo_toml_path = extension_dir.join("Cargo.toml");
|
||||
if extension_toml.lib.kind == Some(ExtensionLibraryKind::Rust)
|
||||
|| fs::metadata(&cargo_toml_path)?.is_file()
|
||||
{
|
||||
self.compile_rust_extension(extension_dir, options).await?;
|
||||
}
|
||||
|
||||
for (grammar_name, grammar_metadata) in extension_toml.grammars {
|
||||
self.compile_grammar(extension_dir, grammar_name, grammar_metadata)
|
||||
.await?;
|
||||
}
|
||||
|
||||
log::info!("finished compiling extension {}", extension_dir.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn compile_rust_extension(
|
||||
&self,
|
||||
extension_dir: &Path,
|
||||
options: CompileExtensionOptions,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
self.install_rust_wasm_target_if_needed()?;
|
||||
let adapter_bytes = self.install_wasi_preview1_adapter_if_needed().await?;
|
||||
|
||||
let cargo_toml_content = fs::read_to_string(&extension_dir.join("Cargo.toml"))?;
|
||||
let cargo_toml: CargoToml = toml::from_str(&cargo_toml_content)?;
|
||||
|
||||
log::info!("compiling rust extension {}", extension_dir.display());
|
||||
let output = Command::new("cargo")
|
||||
.args(["build", "--target", RUST_TARGET])
|
||||
.args(options.release.then_some("--release"))
|
||||
.arg("--target-dir")
|
||||
.arg(extension_dir.join("target"))
|
||||
.current_dir(&extension_dir)
|
||||
.output()
|
||||
.context("failed to run `cargo`")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"failed to build extension {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let mut wasm_path = PathBuf::from(extension_dir);
|
||||
wasm_path.extend([
|
||||
"target",
|
||||
RUST_TARGET,
|
||||
if options.release { "release" } else { "debug" },
|
||||
cargo_toml.package.name.as_str(),
|
||||
]);
|
||||
wasm_path.set_extension("wasm");
|
||||
|
||||
let wasm_bytes = fs::read(&wasm_path)
|
||||
.with_context(|| format!("failed to read output module `{}`", wasm_path.display()))?;
|
||||
|
||||
let encoder = ComponentEncoder::default()
|
||||
.module(&wasm_bytes)?
|
||||
.adapter("wasi_snapshot_preview1", &adapter_bytes)
|
||||
.context("failed to load adapter module")?
|
||||
.validate(true);
|
||||
|
||||
let component_bytes = encoder
|
||||
.encode()
|
||||
.context("failed to encode wasm component")?;
|
||||
|
||||
let component_bytes = self
|
||||
.strip_custom_sections(&component_bytes)
|
||||
.context("failed to strip debug sections from wasm component")?;
|
||||
|
||||
fs::write(extension_dir.join("extension.wasm"), &component_bytes)
|
||||
.context("failed to write extension.wasm")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn compile_grammar(
|
||||
&self,
|
||||
extension_dir: &Path,
|
||||
grammar_name: Arc<str>,
|
||||
grammar_metadata: GrammarManifestEntry,
|
||||
) -> Result<()> {
|
||||
let clang_path = self.install_wasi_sdk_if_needed().await?;
|
||||
|
||||
let mut grammar_repo_dir = extension_dir.to_path_buf();
|
||||
grammar_repo_dir.extend(["grammars", grammar_name.as_ref()]);
|
||||
|
||||
let mut grammar_wasm_path = grammar_repo_dir.clone();
|
||||
grammar_wasm_path.set_extension("wasm");
|
||||
|
||||
log::info!("checking out {grammar_name} parser");
|
||||
self.checkout_repo(
|
||||
&grammar_repo_dir,
|
||||
&grammar_metadata.repository,
|
||||
&grammar_metadata.rev,
|
||||
)?;
|
||||
|
||||
let src_path = grammar_repo_dir.join("src");
|
||||
let parser_path = src_path.join("parser.c");
|
||||
let scanner_path = src_path.join("scanner.c");
|
||||
|
||||
log::info!("compiling {grammar_name} parser");
|
||||
let clang_output = Command::new(&clang_path)
|
||||
.args(["-fPIC", "-shared", "-Os"])
|
||||
.arg(format!("-Wl,--export=tree_sitter_{grammar_name}"))
|
||||
.arg("-o")
|
||||
.arg(&grammar_wasm_path)
|
||||
.arg("-I")
|
||||
.arg(&src_path)
|
||||
.arg(&parser_path)
|
||||
.args(scanner_path.exists().then_some(scanner_path))
|
||||
.output()
|
||||
.context("failed to run clang")?;
|
||||
if !clang_output.status.success() {
|
||||
bail!(
|
||||
"failed to compile {} parser with clang: {}",
|
||||
grammar_name,
|
||||
String::from_utf8_lossy(&clang_output.stderr),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn checkout_repo(&self, directory: &Path, url: &str, rev: &str) -> Result<()> {
|
||||
let git_dir = directory.join(".git");
|
||||
|
||||
if directory.exists() {
|
||||
let remotes_output = Command::new("git")
|
||||
.arg("--git-dir")
|
||||
.arg(&git_dir)
|
||||
.args(["remote", "-v"])
|
||||
.output()?;
|
||||
let has_remote = remotes_output.status.success()
|
||||
&& String::from_utf8_lossy(&remotes_output.stdout)
|
||||
.lines()
|
||||
.any(|line| {
|
||||
let mut parts = line.split(|c: char| c.is_whitespace());
|
||||
parts.next() == Some("origin") && parts.any(|part| part == url)
|
||||
});
|
||||
if !has_remote {
|
||||
bail!(
|
||||
"grammar directory '{}' already exists, but is not a git clone of '{}'",
|
||||
directory.display(),
|
||||
url
|
||||
);
|
||||
}
|
||||
} else {
|
||||
fs::create_dir_all(&directory).with_context(|| {
|
||||
format!("failed to create grammar directory {}", directory.display(),)
|
||||
})?;
|
||||
let init_output = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(&directory)
|
||||
.output()?;
|
||||
if !init_output.status.success() {
|
||||
bail!(
|
||||
"failed to run `git init` in directory '{}'",
|
||||
directory.display()
|
||||
);
|
||||
}
|
||||
|
||||
let remote_add_output = Command::new("git")
|
||||
.arg("--git-dir")
|
||||
.arg(&git_dir)
|
||||
.args(["remote", "add", "origin", url])
|
||||
.output()
|
||||
.context("failed to execute `git remote add`")?;
|
||||
if !remote_add_output.status.success() {
|
||||
bail!(
|
||||
"failed to add remote {url} for git repository {}",
|
||||
git_dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let fetch_output = Command::new("git")
|
||||
.arg("--git-dir")
|
||||
.arg(&git_dir)
|
||||
.args(["fetch", "--depth", "1", "origin", &rev])
|
||||
.output()
|
||||
.context("failed to execute `git fetch`")?;
|
||||
if !fetch_output.status.success() {
|
||||
bail!(
|
||||
"failed to fetch revision {} in directory '{}'",
|
||||
rev,
|
||||
directory.display()
|
||||
);
|
||||
}
|
||||
|
||||
let checkout_output = Command::new("git")
|
||||
.arg("--git-dir")
|
||||
.arg(&git_dir)
|
||||
.args(["checkout", &rev])
|
||||
.current_dir(&directory)
|
||||
.output()
|
||||
.context("failed to execute `git checkout`")?;
|
||||
if !checkout_output.status.success() {
|
||||
bail!(
|
||||
"failed to checkout revision {} in directory '{}'",
|
||||
rev,
|
||||
directory.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_rust_wasm_target_if_needed(&self) -> Result<()> {
|
||||
let rustc_output = Command::new("rustc")
|
||||
.arg("--print")
|
||||
.arg("sysroot")
|
||||
.output()
|
||||
.context("failed to run rustc")?;
|
||||
if !rustc_output.status.success() {
|
||||
bail!(
|
||||
"failed to retrieve rust sysroot: {}",
|
||||
String::from_utf8_lossy(&rustc_output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let sysroot = PathBuf::from(String::from_utf8(rustc_output.stdout)?.trim());
|
||||
if sysroot.join("lib/rustlib").join(RUST_TARGET).exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output = Command::new("rustup")
|
||||
.args(["target", "add", RUST_TARGET])
|
||||
.stderr(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.output()
|
||||
.context("failed to run `rustup target add`")?;
|
||||
if !output.status.success() {
|
||||
bail!("failed to install the `{RUST_TARGET}` target");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_wasi_preview1_adapter_if_needed(&self) -> Result<Vec<u8>> {
|
||||
let cache_path = self.cache_dir.join("wasi_snapshot_preview1.reactor.wasm");
|
||||
if let Ok(content) = fs::read(&cache_path) {
|
||||
if Parser::is_core_wasm(&content) {
|
||||
return Ok(content);
|
||||
}
|
||||
}
|
||||
|
||||
fs::remove_file(&cache_path).ok();
|
||||
|
||||
log::info!(
|
||||
"downloading wasi adapter module to {}",
|
||||
cache_path.display()
|
||||
);
|
||||
let mut response = self
|
||||
.http
|
||||
.get(WASI_ADAPTER_URL, AsyncBody::default(), true)
|
||||
.await?;
|
||||
|
||||
let mut content = Vec::new();
|
||||
let mut body = BufReader::new(response.body_mut());
|
||||
body.read_to_end(&mut content).await?;
|
||||
|
||||
fs::write(&cache_path, &content)
|
||||
.with_context(|| format!("failed to save file {}", cache_path.display()))?;
|
||||
|
||||
if !Parser::is_core_wasm(&content) {
|
||||
bail!("downloaded wasi adapter is invalid");
|
||||
}
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
async fn install_wasi_sdk_if_needed(&self) -> Result<PathBuf> {
|
||||
let url = if let Some(asset_name) = WASI_SDK_ASSET_NAME {
|
||||
format!("{WASI_SDK_URL}/{asset_name}")
|
||||
} else {
|
||||
bail!("wasi-sdk is not available for platform {}", env::consts::OS);
|
||||
};
|
||||
|
||||
let wasi_sdk_dir = self.cache_dir.join("wasi-sdk");
|
||||
let mut clang_path = wasi_sdk_dir.clone();
|
||||
clang_path.extend(["bin", "clang-17"]);
|
||||
|
||||
if fs::metadata(&clang_path).map_or(false, |metadata| metadata.is_file()) {
|
||||
return Ok(clang_path);
|
||||
}
|
||||
|
||||
let mut tar_out_dir = wasi_sdk_dir.clone();
|
||||
tar_out_dir.set_extension("archive");
|
||||
|
||||
fs::remove_dir_all(&wasi_sdk_dir).ok();
|
||||
fs::remove_dir_all(&tar_out_dir).ok();
|
||||
|
||||
log::info!("downloading wasi-sdk to {}", wasi_sdk_dir.display());
|
||||
let mut response = self.http.get(&url, AsyncBody::default(), true).await?;
|
||||
let body = BufReader::new(response.body_mut());
|
||||
let body = GzipDecoder::new(body);
|
||||
let tar = Archive::new(body);
|
||||
tar.unpack(&tar_out_dir)
|
||||
.await
|
||||
.context("failed to unpack wasi-sdk archive")?;
|
||||
|
||||
let inner_dir = fs::read_dir(&tar_out_dir)?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("no content"))?
|
||||
.context("failed to read contents of extracted wasi archive directory")?
|
||||
.path();
|
||||
fs::rename(&inner_dir, &wasi_sdk_dir).context("failed to move extracted wasi dir")?;
|
||||
fs::remove_dir_all(&tar_out_dir).ok();
|
||||
|
||||
Ok(clang_path)
|
||||
}
|
||||
|
||||
// This was adapted from:
|
||||
// https://github.com/bytecodealliance/wasm-tools/1791a8f139722e9f8679a2bd3d8e423e55132b22/src/bin/wasm-tools/strip.rs
|
||||
fn strip_custom_sections(&self, input: &Vec<u8>) -> Result<Vec<u8>> {
|
||||
use wasmparser::Payload::*;
|
||||
|
||||
let strip_custom_section = |name: &str| name.starts_with(".debug");
|
||||
|
||||
let mut output = Vec::new();
|
||||
let mut stack = Vec::new();
|
||||
|
||||
for payload in Parser::new(0).parse_all(input) {
|
||||
let payload = payload?;
|
||||
|
||||
// Track nesting depth, so that we don't mess with inner producer sections:
|
||||
match payload {
|
||||
Version { encoding, .. } => {
|
||||
output.extend_from_slice(match encoding {
|
||||
wasmparser::Encoding::Component => &wasm_encoder::Component::HEADER,
|
||||
wasmparser::Encoding::Module => &wasm_encoder::Module::HEADER,
|
||||
});
|
||||
}
|
||||
ModuleSection { .. } | ComponentSection { .. } => {
|
||||
stack.push(mem::take(&mut output));
|
||||
continue;
|
||||
}
|
||||
End { .. } => {
|
||||
let mut parent = match stack.pop() {
|
||||
Some(c) => c,
|
||||
None => break,
|
||||
};
|
||||
if output.starts_with(&wasm_encoder::Component::HEADER) {
|
||||
parent.push(ComponentSectionId::Component as u8);
|
||||
output.encode(&mut parent);
|
||||
} else {
|
||||
parent.push(ComponentSectionId::CoreModule as u8);
|
||||
output.encode(&mut parent);
|
||||
}
|
||||
output = parent;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match &payload {
|
||||
CustomSection(c) => {
|
||||
if strip_custom_section(c.name()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some((id, range)) = payload.as_section() {
|
||||
RawSection {
|
||||
id,
|
||||
data: &input[range],
|
||||
}
|
||||
.append_to(&mut output);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::wasm_host::{wit::LanguageServerConfig, WasmExtension};
|
||||
use crate::wasm_host::{wit::LanguageServerConfig, WasmExtension, WasmHost};
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use futures::{Future, FutureExt};
|
||||
@@ -16,7 +16,7 @@ use wasmtime_wasi::preview2::WasiView as _;
|
||||
pub struct ExtensionLspAdapter {
|
||||
pub(crate) extension: WasmExtension,
|
||||
pub(crate) config: LanguageServerConfig,
|
||||
pub(crate) work_dir: PathBuf,
|
||||
pub(crate) host: Arc<WasmHost>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -41,18 +41,23 @@ impl LspAdapter for ExtensionLspAdapter {
|
||||
|extension, store| {
|
||||
async move {
|
||||
let resource = store.data_mut().table().push(delegate)?;
|
||||
extension
|
||||
let command = extension
|
||||
.call_language_server_command(store, &this.config, resource)
|
||||
.await
|
||||
.await?
|
||||
.map_err(|e| anyhow!("{}", e))?;
|
||||
anyhow::Ok(command)
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
})
|
||||
.await?
|
||||
.map_err(|e| anyhow!("{}", e))?;
|
||||
.await?;
|
||||
|
||||
let path = self
|
||||
.host
|
||||
.path_from_extension(&self.extension.manifest.id, command.command.as_ref());
|
||||
|
||||
Ok(LanguageServerBinary {
|
||||
path: self.work_dir.join(&command.command),
|
||||
path,
|
||||
arguments: command.args.into_iter().map(|arg| arg.into()).collect(),
|
||||
env: Some(command.env.into_iter().collect()),
|
||||
})
|
||||
|
||||
72
crates/extension/src/extension_manifest.rs
Normal file
72
crates/extension/src/extension_manifest.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use collections::BTreeMap;
|
||||
use language::LanguageServerName;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
/// This is the old version of the extension manifest, from when it was `extension.json`.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
|
||||
pub struct OldExtensionManifest {
|
||||
pub name: String,
|
||||
pub version: Arc<str>,
|
||||
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub repository: Option<String>,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub themes: BTreeMap<Arc<str>, PathBuf>,
|
||||
#[serde(default)]
|
||||
pub languages: BTreeMap<Arc<str>, PathBuf>,
|
||||
#[serde(default)]
|
||||
pub grammars: BTreeMap<Arc<str>, PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
|
||||
pub struct ExtensionManifest {
|
||||
pub id: Arc<str>,
|
||||
pub name: String,
|
||||
pub version: Arc<str>,
|
||||
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub repository: Option<String>,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub lib: LibManifestEntry,
|
||||
|
||||
#[serde(default)]
|
||||
pub themes: Vec<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub languages: Vec<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub grammars: BTreeMap<Arc<str>, GrammarManifestEntry>,
|
||||
#[serde(default)]
|
||||
pub language_servers: BTreeMap<LanguageServerName, LanguageServerManifestEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
|
||||
pub struct LibManifestEntry {
|
||||
pub kind: Option<ExtensionLibraryKind>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
|
||||
pub enum ExtensionLibraryKind {
|
||||
Rust,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
|
||||
pub struct GrammarManifestEntry {
|
||||
pub repository: String,
|
||||
#[serde(alias = "commit")]
|
||||
pub rev: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
|
||||
pub struct LanguageServerManifestEntry {
|
||||
pub language: Arc<str>,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,15 @@
|
||||
use crate::{
|
||||
ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry, ExtensionManifest,
|
||||
ExtensionStore, GrammarManifestEntry,
|
||||
ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry, ExtensionIndexThemeEntry,
|
||||
ExtensionManifest, ExtensionStore, GrammarManifestEntry, RELOAD_DEBOUNCE_DURATION,
|
||||
};
|
||||
use async_compression::futures::bufread::GzipEncoder;
|
||||
use collections::BTreeMap;
|
||||
use fs::{FakeFs, Fs};
|
||||
use fs::{FakeFs, Fs, RealFs};
|
||||
use futures::{io::BufReader, AsyncReadExt, StreamExt};
|
||||
use gpui::{Context, TestAppContext};
|
||||
use language::{
|
||||
Language, LanguageConfig, LanguageMatcher, LanguageRegistry, LanguageServerBinaryStatus,
|
||||
LanguageServerName,
|
||||
};
|
||||
use language::{LanguageMatcher, LanguageRegistry, LanguageServerBinaryStatus, LanguageServerName};
|
||||
use node_runtime::FakeNodeRuntime;
|
||||
use parking_lot::Mutex;
|
||||
use project::Project;
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
@@ -21,7 +19,18 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
use theme::ThemeRegistry;
|
||||
use util::http::{FakeHttpClient, Response};
|
||||
use util::{
|
||||
http::{FakeHttpClient, Response},
|
||||
test::temp_tree,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[ctor::ctor]
|
||||
fn init_logger() {
|
||||
if std::env::var("RUST_LOG").is_ok() {
|
||||
env_logger::init();
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
@@ -131,45 +140,49 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
extensions: [
|
||||
(
|
||||
"zed-ruby".into(),
|
||||
ExtensionManifest {
|
||||
id: "zed-ruby".into(),
|
||||
name: "Zed Ruby".into(),
|
||||
version: "1.0.0".into(),
|
||||
description: None,
|
||||
authors: Vec::new(),
|
||||
repository: None,
|
||||
themes: Default::default(),
|
||||
lib: Default::default(),
|
||||
languages: vec!["languages/erb".into(), "languages/ruby".into()],
|
||||
grammars: [
|
||||
("embedded_template".into(), GrammarManifestEntry::default()),
|
||||
("ruby".into(), GrammarManifestEntry::default()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
language_servers: BTreeMap::default(),
|
||||
}
|
||||
.into(),
|
||||
ExtensionIndexEntry {
|
||||
manifest: Arc::new(ExtensionManifest {
|
||||
id: "zed-ruby".into(),
|
||||
name: "Zed Ruby".into(),
|
||||
version: "1.0.0".into(),
|
||||
description: None,
|
||||
authors: Vec::new(),
|
||||
repository: None,
|
||||
themes: Default::default(),
|
||||
lib: Default::default(),
|
||||
languages: vec!["languages/erb".into(), "languages/ruby".into()],
|
||||
grammars: [
|
||||
("embedded_template".into(), GrammarManifestEntry::default()),
|
||||
("ruby".into(), GrammarManifestEntry::default()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
language_servers: BTreeMap::default(),
|
||||
}),
|
||||
dev: false,
|
||||
},
|
||||
),
|
||||
(
|
||||
"zed-monokai".into(),
|
||||
ExtensionManifest {
|
||||
id: "zed-monokai".into(),
|
||||
name: "Zed Monokai".into(),
|
||||
version: "2.0.0".into(),
|
||||
description: None,
|
||||
authors: vec![],
|
||||
repository: None,
|
||||
themes: vec![
|
||||
"themes/monokai-pro.json".into(),
|
||||
"themes/monokai.json".into(),
|
||||
],
|
||||
lib: Default::default(),
|
||||
languages: Default::default(),
|
||||
grammars: BTreeMap::default(),
|
||||
language_servers: BTreeMap::default(),
|
||||
}
|
||||
.into(),
|
||||
ExtensionIndexEntry {
|
||||
manifest: Arc::new(ExtensionManifest {
|
||||
id: "zed-monokai".into(),
|
||||
name: "Zed Monokai".into(),
|
||||
version: "2.0.0".into(),
|
||||
description: None,
|
||||
authors: vec![],
|
||||
repository: None,
|
||||
themes: vec![
|
||||
"themes/monokai-pro.json".into(),
|
||||
"themes/monokai.json".into(),
|
||||
],
|
||||
lib: Default::default(),
|
||||
languages: Default::default(),
|
||||
grammars: BTreeMap::default(),
|
||||
language_servers: BTreeMap::default(),
|
||||
}),
|
||||
dev: false,
|
||||
},
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -205,28 +218,28 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
themes: [
|
||||
(
|
||||
"Monokai Dark".into(),
|
||||
ExtensionIndexEntry {
|
||||
ExtensionIndexThemeEntry {
|
||||
extension: "zed-monokai".into(),
|
||||
path: "themes/monokai.json".into(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Monokai Light".into(),
|
||||
ExtensionIndexEntry {
|
||||
ExtensionIndexThemeEntry {
|
||||
extension: "zed-monokai".into(),
|
||||
path: "themes/monokai.json".into(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Monokai Pro Dark".into(),
|
||||
ExtensionIndexEntry {
|
||||
ExtensionIndexThemeEntry {
|
||||
extension: "zed-monokai".into(),
|
||||
path: "themes/monokai-pro.json".into(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Monokai Pro Light".into(),
|
||||
ExtensionIndexEntry {
|
||||
ExtensionIndexThemeEntry {
|
||||
extension: "zed-monokai".into(),
|
||||
path: "themes/monokai-pro.json".into(),
|
||||
},
|
||||
@@ -243,6 +256,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
let store = cx.new_model(|cx| {
|
||||
ExtensionStore::new(
|
||||
PathBuf::from("/the-extension-dir"),
|
||||
None,
|
||||
fs.clone(),
|
||||
http_client.clone(),
|
||||
node_runtime.clone(),
|
||||
@@ -252,7 +266,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
)
|
||||
});
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
cx.executor().advance_clock(super::RELOAD_DEBOUNCE_DURATION);
|
||||
store.read_with(cx, |store, _| {
|
||||
let index = &store.extension_index;
|
||||
assert_eq!(index.extensions, expected_index.extensions);
|
||||
@@ -305,32 +319,34 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
|
||||
expected_index.extensions.insert(
|
||||
"zed-gruvbox".into(),
|
||||
ExtensionManifest {
|
||||
id: "zed-gruvbox".into(),
|
||||
name: "Zed Gruvbox".into(),
|
||||
version: "1.0.0".into(),
|
||||
description: None,
|
||||
authors: vec![],
|
||||
repository: None,
|
||||
themes: vec!["themes/gruvbox.json".into()],
|
||||
lib: Default::default(),
|
||||
languages: Default::default(),
|
||||
grammars: BTreeMap::default(),
|
||||
language_servers: BTreeMap::default(),
|
||||
}
|
||||
.into(),
|
||||
ExtensionIndexEntry {
|
||||
manifest: Arc::new(ExtensionManifest {
|
||||
id: "zed-gruvbox".into(),
|
||||
name: "Zed Gruvbox".into(),
|
||||
version: "1.0.0".into(),
|
||||
description: None,
|
||||
authors: vec![],
|
||||
repository: None,
|
||||
themes: vec!["themes/gruvbox.json".into()],
|
||||
lib: Default::default(),
|
||||
languages: Default::default(),
|
||||
grammars: BTreeMap::default(),
|
||||
language_servers: BTreeMap::default(),
|
||||
}),
|
||||
dev: false,
|
||||
},
|
||||
);
|
||||
expected_index.themes.insert(
|
||||
"Gruvbox".into(),
|
||||
ExtensionIndexEntry {
|
||||
ExtensionIndexThemeEntry {
|
||||
extension: "zed-gruvbox".into(),
|
||||
path: "themes/gruvbox.json".into(),
|
||||
},
|
||||
);
|
||||
|
||||
store.update(cx, |store, cx| store.reload(cx));
|
||||
let _ = store.update(cx, |store, cx| store.reload(None, cx));
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
|
||||
store.read_with(cx, |store, _| {
|
||||
let index = &store.extension_index;
|
||||
assert_eq!(index.extensions, expected_index.extensions);
|
||||
@@ -358,6 +374,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
let store = cx.new_model(|cx| {
|
||||
ExtensionStore::new(
|
||||
PathBuf::from("/the-extension-dir"),
|
||||
None,
|
||||
fs.clone(),
|
||||
http_client.clone(),
|
||||
node_runtime.clone(),
|
||||
@@ -400,7 +417,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
store.uninstall_extension("zed-ruby".into(), cx)
|
||||
});
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
|
||||
expected_index.extensions.remove("zed-ruby");
|
||||
expected_index.languages.remove("Ruby");
|
||||
expected_index.languages.remove("ERB");
|
||||
@@ -415,35 +432,29 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
#[gpui::test]
|
||||
async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let gleam_extension_dir = PathBuf::from_iter([
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"..",
|
||||
"..",
|
||||
"extensions",
|
||||
"gleam",
|
||||
])
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
let root_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap();
|
||||
let cache_dir = root_dir.join("target");
|
||||
let gleam_extension_dir = root_dir.join("extensions").join("gleam");
|
||||
|
||||
compile_extension("zed_gleam", &gleam_extension_dir);
|
||||
let fs = Arc::new(RealFs);
|
||||
let extensions_dir = temp_tree(json!({
|
||||
"installed": {},
|
||||
"work": {}
|
||||
}));
|
||||
let project_dir = temp_tree(json!({
|
||||
"test.gleam": ""
|
||||
}));
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree("/the-extension-dir", json!({ "installed": {} }))
|
||||
.await;
|
||||
fs.insert_tree_from_real_fs("/the-extension-dir/installed/gleam", gleam_extension_dir)
|
||||
.await;
|
||||
let extensions_dir = extensions_dir.path().canonicalize().unwrap();
|
||||
let project_dir = project_dir.path().canonicalize().unwrap();
|
||||
|
||||
fs.insert_tree(
|
||||
"/the-project-dir",
|
||||
json!({
|
||||
".tool-versions": "rust 1.73.0",
|
||||
"test.gleam": ""
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/the-project-dir".as_ref()], cx).await;
|
||||
let project = Project::test(fs.clone(), [project_dir.as_path()], cx).await;
|
||||
|
||||
let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
|
||||
let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
|
||||
@@ -451,55 +462,76 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
|
||||
|
||||
let mut status_updates = language_registry.language_server_binary_statuses();
|
||||
|
||||
let http_client = FakeHttpClient::create({
|
||||
move |request| async move {
|
||||
match request.uri().to_string().as_str() {
|
||||
"https://api.github.com/repos/gleam-lang/gleam/releases" => Ok(Response::new(
|
||||
json!([
|
||||
{
|
||||
"tag_name": "v1.2.3",
|
||||
"prerelease": false,
|
||||
"tarball_url": "",
|
||||
"zipball_url": "",
|
||||
"assets": [
|
||||
{
|
||||
"name": "gleam-v1.2.3-aarch64-apple-darwin.tar.gz",
|
||||
"browser_download_url": "http://example.com/the-download"
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
.to_string()
|
||||
.into(),
|
||||
)),
|
||||
struct FakeLanguageServerVersion {
|
||||
version: String,
|
||||
binary_contents: String,
|
||||
http_request_count: usize,
|
||||
}
|
||||
|
||||
"http://example.com/the-download" => {
|
||||
let language_server_version = Arc::new(Mutex::new(FakeLanguageServerVersion {
|
||||
version: "v1.2.3".into(),
|
||||
binary_contents: "the-binary-contents".into(),
|
||||
http_request_count: 0,
|
||||
}));
|
||||
|
||||
let http_client = FakeHttpClient::create({
|
||||
let language_server_version = language_server_version.clone();
|
||||
move |request| {
|
||||
let language_server_version = language_server_version.clone();
|
||||
async move {
|
||||
language_server_version.lock().http_request_count += 1;
|
||||
let version = language_server_version.lock().version.clone();
|
||||
let binary_contents = language_server_version.lock().binary_contents.clone();
|
||||
|
||||
let github_releases_uri = "https://api.github.com/repos/gleam-lang/gleam/releases";
|
||||
let asset_download_uri =
|
||||
format!("https://fake-download.example.com/gleam-{version}");
|
||||
|
||||
let uri = request.uri().to_string();
|
||||
if uri == github_releases_uri {
|
||||
Ok(Response::new(
|
||||
json!([
|
||||
{
|
||||
"tag_name": version,
|
||||
"prerelease": false,
|
||||
"tarball_url": "",
|
||||
"zipball_url": "",
|
||||
"assets": [
|
||||
{
|
||||
"name": format!("gleam-{version}-aarch64-apple-darwin.tar.gz"),
|
||||
"browser_download_url": asset_download_uri
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
} else if uri == asset_download_uri {
|
||||
let mut bytes = Vec::<u8>::new();
|
||||
let mut archive = async_tar::Builder::new(&mut bytes);
|
||||
let mut header = async_tar::Header::new_gnu();
|
||||
let content = "the-gleam-binary-contents".as_bytes();
|
||||
header.set_size(content.len() as u64);
|
||||
header.set_size(binary_contents.len() as u64);
|
||||
archive
|
||||
.append_data(&mut header, "gleam", content)
|
||||
.append_data(&mut header, "gleam", binary_contents.as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
archive.into_inner().await.unwrap();
|
||||
|
||||
let mut gzipped_bytes = Vec::new();
|
||||
let mut encoder = GzipEncoder::new(BufReader::new(bytes.as_slice()));
|
||||
encoder.read_to_end(&mut gzipped_bytes).await.unwrap();
|
||||
|
||||
Ok(Response::new(gzipped_bytes.into()))
|
||||
} else {
|
||||
Ok(Response::builder().status(404).body("not found".into())?)
|
||||
}
|
||||
|
||||
_ => Ok(Response::builder().status(404).body("not found".into())?),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _store = cx.new_model(|cx| {
|
||||
let extension_store = cx.new_model(|cx| {
|
||||
ExtensionStore::new(
|
||||
PathBuf::from("/the-extension-dir"),
|
||||
extensions_dir.clone(),
|
||||
Some(cache_dir),
|
||||
fs.clone(),
|
||||
http_client.clone(),
|
||||
node_runtime,
|
||||
@@ -509,46 +541,47 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
|
||||
)
|
||||
});
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
// Ensure that debounces fire.
|
||||
let mut events = cx.events(&extension_store);
|
||||
let executor = cx.executor();
|
||||
let _task = cx.executor().spawn(async move {
|
||||
while let Some(event) = events.next().await {
|
||||
match event {
|
||||
crate::Event::StartedReloading => {
|
||||
executor.advance_clock(RELOAD_DEBOUNCE_DURATION);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
extension_store
|
||||
.update(cx, |store, cx| {
|
||||
store.install_dev_extension(gleam_extension_dir.clone(), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut fake_servers = language_registry.fake_language_servers("Gleam");
|
||||
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_local_buffer("/the-project-dir/test.gleam", cx)
|
||||
project.open_local_buffer(project_dir.join("test.gleam"), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
project.update(cx, |project, cx| {
|
||||
project.set_language_for_buffer(
|
||||
&buffer,
|
||||
Arc::new(Language::new(
|
||||
LanguageConfig {
|
||||
name: "Gleam".into(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
)),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let fake_server = fake_servers.next().await.unwrap();
|
||||
let expected_server_path = extensions_dir.join("work/gleam/gleam-v1.2.3/gleam");
|
||||
let expected_binary_contents = language_server_version.lock().binary_contents.clone();
|
||||
|
||||
assert_eq!(
|
||||
fs.load("/the-extension-dir/work/gleam/gleam-v1.2.3/gleam".as_ref())
|
||||
.await
|
||||
.unwrap(),
|
||||
"the-gleam-binary-contents"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
fake_server.binary.path,
|
||||
PathBuf::from("/the-extension-dir/work/gleam/gleam-v1.2.3/gleam")
|
||||
);
|
||||
assert_eq!(fake_server.binary.path, expected_server_path);
|
||||
assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
|
||||
|
||||
assert_eq!(
|
||||
fs.load(&expected_server_path).await.unwrap(),
|
||||
expected_binary_contents
|
||||
);
|
||||
assert_eq!(language_server_version.lock().http_request_count, 2);
|
||||
assert_eq!(
|
||||
[
|
||||
status_updates.next().await.unwrap(),
|
||||
@@ -570,27 +603,51 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
|
||||
)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn compile_extension(name: &str, extension_dir_path: &Path) {
|
||||
let output = std::process::Command::new("cargo")
|
||||
.args(["component", "build", "--target-dir"])
|
||||
.arg(extension_dir_path.join("target"))
|
||||
.current_dir(&extension_dir_path)
|
||||
.output()
|
||||
.unwrap();
|
||||
// Simulate a new version of the language server being released
|
||||
language_server_version.lock().version = "v2.0.0".into();
|
||||
language_server_version.lock().binary_contents = "the-new-binary-contents".into();
|
||||
language_server_version.lock().http_request_count = 0;
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"failed to build component {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
// Start a new instance of the language server.
|
||||
project.update(cx, |project, cx| {
|
||||
project.restart_language_servers_for_buffers([buffer.clone()], cx)
|
||||
});
|
||||
|
||||
// The extension has cached the binary path, and does not attempt
|
||||
// to reinstall it.
|
||||
let fake_server = fake_servers.next().await.unwrap();
|
||||
assert_eq!(fake_server.binary.path, expected_server_path);
|
||||
assert_eq!(
|
||||
fs.load(&expected_server_path).await.unwrap(),
|
||||
expected_binary_contents
|
||||
);
|
||||
assert_eq!(language_server_version.lock().http_request_count, 0);
|
||||
|
||||
// Reload the extension, clearing its cache.
|
||||
// Start a new instance of the language server.
|
||||
extension_store
|
||||
.update(cx, |store, cx| store.reload(Some("gleam".into()), cx))
|
||||
.await;
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
project.update(cx, |project, cx| {
|
||||
project.restart_language_servers_for_buffers([buffer.clone()], cx)
|
||||
});
|
||||
|
||||
// The extension re-fetches the latest version of the language server.
|
||||
let fake_server = fake_servers.next().await.unwrap();
|
||||
let new_expected_server_path = extensions_dir.join("work/gleam/gleam-v2.0.0/gleam");
|
||||
let expected_binary_contents = language_server_version.lock().binary_contents.clone();
|
||||
assert_eq!(fake_server.binary.path, new_expected_server_path);
|
||||
assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
|
||||
assert_eq!(
|
||||
fs.load(&new_expected_server_path).await.unwrap(),
|
||||
expected_binary_contents
|
||||
);
|
||||
|
||||
let mut wasm_path = PathBuf::from(extension_dir_path);
|
||||
wasm_path.extend(["target", "wasm32-wasi", "debug", name]);
|
||||
wasm_path.set_extension("wasm");
|
||||
|
||||
std::fs::rename(wasm_path, extension_dir_path.join("extension.wasm")).unwrap();
|
||||
// The old language server directory has been cleaned up.
|
||||
assert!(fs.metadata(&expected_server_path).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) {
|
||||
|
||||
@@ -3,9 +3,12 @@ use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use async_compression::futures::bufread::GzipDecoder;
|
||||
use async_tar::Archive;
|
||||
use async_trait::async_trait;
|
||||
use fs::Fs;
|
||||
use fs::{normalize_path, Fs};
|
||||
use futures::{
|
||||
channel::{mpsc::UnboundedSender, oneshot},
|
||||
channel::{
|
||||
mpsc::{self, UnboundedSender},
|
||||
oneshot,
|
||||
},
|
||||
future::BoxFuture,
|
||||
io::BufReader,
|
||||
Future, FutureExt, StreamExt as _,
|
||||
@@ -14,7 +17,8 @@ use gpui::BackgroundExecutor;
|
||||
use language::{LanguageRegistry, LanguageServerBinaryStatus, LspAdapterDelegate};
|
||||
use node_runtime::NodeRuntime;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
env,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
use util::{http::HttpClient, SemanticVersion};
|
||||
@@ -22,7 +26,7 @@ use wasmtime::{
|
||||
component::{Component, Linker, Resource, ResourceTable},
|
||||
Engine, Store,
|
||||
};
|
||||
use wasmtime_wasi::preview2::{command as wasi_command, WasiCtx, WasiCtxBuilder, WasiView};
|
||||
use wasmtime_wasi::preview2::{self as wasi, WasiCtx};
|
||||
|
||||
pub mod wit {
|
||||
wasmtime::component::bindgen!({
|
||||
@@ -49,6 +53,7 @@ pub(crate) struct WasmHost {
|
||||
#[derive(Clone)]
|
||||
pub struct WasmExtension {
|
||||
tx: UnboundedSender<ExtensionCall>,
|
||||
pub(crate) manifest: Arc<ExtensionManifest>,
|
||||
#[allow(unused)]
|
||||
zed_api_version: SemanticVersion,
|
||||
}
|
||||
@@ -56,7 +61,7 @@ pub struct WasmExtension {
|
||||
pub(crate) struct WasmState {
|
||||
manifest: Arc<ExtensionManifest>,
|
||||
table: ResourceTable,
|
||||
ctx: WasiCtx,
|
||||
ctx: wasi::WasiCtx,
|
||||
host: Arc<WasmHost>,
|
||||
}
|
||||
|
||||
@@ -84,8 +89,8 @@ impl WasmHost {
|
||||
})
|
||||
.clone();
|
||||
let mut linker = Linker::new(&engine);
|
||||
wasi_command::add_to_linker(&mut linker).unwrap();
|
||||
wit::Extension::add_to_linker(&mut linker, |state: &mut WasmState| state).unwrap();
|
||||
wasi::command::add_to_linker(&mut linker).unwrap();
|
||||
wit::Extension::add_to_linker(&mut linker, wasi_view).unwrap();
|
||||
Arc::new(Self {
|
||||
engine,
|
||||
linker: Arc::new(linker),
|
||||
@@ -112,22 +117,14 @@ impl WasmHost {
|
||||
for part in wasmparser::Parser::new(0).parse_all(&wasm_bytes) {
|
||||
if let wasmparser::Payload::CustomSection(s) = part? {
|
||||
if s.name() == "zed:api-version" {
|
||||
if s.data().len() != 6 {
|
||||
zed_api_version = parse_extension_version(s.data());
|
||||
if zed_api_version.is_none() {
|
||||
bail!(
|
||||
"extension {} has invalid zed:api-version section: {:?}",
|
||||
manifest.id,
|
||||
s.data()
|
||||
);
|
||||
}
|
||||
|
||||
let major = u16::from_be_bytes(s.data()[0..2].try_into().unwrap()) as _;
|
||||
let minor = u16::from_be_bytes(s.data()[2..4].try_into().unwrap()) as _;
|
||||
let patch = u16::from_be_bytes(s.data()[4..6].try_into().unwrap()) as _;
|
||||
zed_api_version = Some(SemanticVersion {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,36 +136,94 @@ impl WasmHost {
|
||||
let mut store = wasmtime::Store::new(
|
||||
&this.engine,
|
||||
WasmState {
|
||||
manifest,
|
||||
ctx: this.build_wasi_ctx(&manifest).await?,
|
||||
manifest: manifest.clone(),
|
||||
table: ResourceTable::new(),
|
||||
ctx: WasiCtxBuilder::new()
|
||||
.inherit_stdio()
|
||||
.env("RUST_BACKTRACE", "1")
|
||||
.build(),
|
||||
host: this.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
let (mut extension, instance) =
|
||||
wit::Extension::instantiate_async(&mut store, &component, &this.linker)
|
||||
.await
|
||||
.context("failed to instantiate wasm component")?;
|
||||
let (tx, mut rx) = futures::channel::mpsc::unbounded::<ExtensionCall>();
|
||||
.context("failed to instantiate wasm extension")?;
|
||||
extension
|
||||
.call_init_extension(&mut store)
|
||||
.await
|
||||
.context("failed to initialize wasm extension")?;
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded::<ExtensionCall>();
|
||||
executor
|
||||
.spawn(async move {
|
||||
extension.call_init_extension(&mut store).await.unwrap();
|
||||
|
||||
let _instance = instance;
|
||||
while let Some(call) = rx.next().await {
|
||||
(call)(&mut extension, &mut store).await;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Ok(WasmExtension {
|
||||
manifest,
|
||||
tx,
|
||||
zed_api_version,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_wasi_ctx(&self, manifest: &Arc<ExtensionManifest>) -> Result<WasiCtx> {
|
||||
use cap_std::{ambient_authority, fs::Dir};
|
||||
|
||||
let extension_work_dir = self.work_dir.join(manifest.id.as_ref());
|
||||
self.fs
|
||||
.create_dir(&extension_work_dir)
|
||||
.await
|
||||
.context("failed to create extension work dir")?;
|
||||
|
||||
let work_dir_preopen = Dir::open_ambient_dir(&extension_work_dir, ambient_authority())
|
||||
.context("failed to preopen extension work directory")?;
|
||||
let current_dir_preopen = work_dir_preopen
|
||||
.try_clone()
|
||||
.context("failed to preopen extension current directory")?;
|
||||
let extension_work_dir = extension_work_dir.to_string_lossy();
|
||||
|
||||
let perms = wasi::FilePerms::all();
|
||||
let dir_perms = wasi::DirPerms::all();
|
||||
|
||||
Ok(wasi::WasiCtxBuilder::new()
|
||||
.inherit_stdio()
|
||||
.preopened_dir(current_dir_preopen, dir_perms, perms, ".")
|
||||
.preopened_dir(work_dir_preopen, dir_perms, perms, &extension_work_dir)
|
||||
.env("PWD", &extension_work_dir)
|
||||
.env("RUST_BACKTRACE", "full")
|
||||
.build())
|
||||
}
|
||||
|
||||
pub fn path_from_extension(&self, id: &Arc<str>, path: &Path) -> PathBuf {
|
||||
let extension_work_dir = self.work_dir.join(id.as_ref());
|
||||
normalize_path(&extension_work_dir.join(path))
|
||||
}
|
||||
|
||||
pub fn writeable_path_from_extension(&self, id: &Arc<str>, path: &Path) -> Result<PathBuf> {
|
||||
let extension_work_dir = self.work_dir.join(id.as_ref());
|
||||
let path = normalize_path(&extension_work_dir.join(path));
|
||||
if path.starts_with(&extension_work_dir) {
|
||||
Ok(path)
|
||||
} else {
|
||||
Err(anyhow!("cannot write to path {}", path.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_extension_version(data: &[u8]) -> Option<SemanticVersion> {
|
||||
if data.len() == 6 {
|
||||
Some(SemanticVersion {
|
||||
major: u16::from_be_bytes([data[0], data[1]]) as _,
|
||||
minor: u16::from_be_bytes([data[2], data[3]]) as _,
|
||||
patch: u16::from_be_bytes([data[4], data[5]]) as _,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl WasmExtension {
|
||||
@@ -201,13 +256,33 @@ impl wit::HostWorktree for WasmState {
|
||||
delegate: Resource<Arc<dyn LspAdapterDelegate>>,
|
||||
path: String,
|
||||
) -> wasmtime::Result<Result<String, String>> {
|
||||
let delegate = self.table().get(&delegate)?;
|
||||
let delegate = self.table.get(&delegate)?;
|
||||
Ok(delegate
|
||||
.read_text_file(path.into())
|
||||
.await
|
||||
.map_err(|error| error.to_string()))
|
||||
}
|
||||
|
||||
async fn shell_env(
|
||||
&mut self,
|
||||
delegate: Resource<Arc<dyn LspAdapterDelegate>>,
|
||||
) -> wasmtime::Result<wit::EnvVars> {
|
||||
let delegate = self.table.get(&delegate)?;
|
||||
Ok(delegate.shell_env().await.into_iter().collect())
|
||||
}
|
||||
|
||||
async fn which(
|
||||
&mut self,
|
||||
delegate: Resource<Arc<dyn LspAdapterDelegate>>,
|
||||
binary_name: String,
|
||||
) -> wasmtime::Result<Option<String>> {
|
||||
let delegate = self.table.get(&delegate)?;
|
||||
Ok(delegate
|
||||
.which(binary_name.as_ref())
|
||||
.await
|
||||
.map(|path| path.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
fn drop(&mut self, _worktree: Resource<wit::Worktree>) -> Result<()> {
|
||||
// we only ever hand out borrows of worktrees
|
||||
Ok(())
|
||||
@@ -269,13 +344,13 @@ impl wit::ExtensionImports for WasmState {
|
||||
|
||||
async fn current_platform(&mut self) -> Result<(wit::Os, wit::Architecture)> {
|
||||
Ok((
|
||||
match std::env::consts::OS {
|
||||
match env::consts::OS {
|
||||
"macos" => wit::Os::Mac,
|
||||
"linux" => wit::Os::Linux,
|
||||
"windows" => wit::Os::Windows,
|
||||
_ => panic!("unsupported os"),
|
||||
},
|
||||
match std::env::consts::ARCH {
|
||||
match env::consts::ARCH {
|
||||
"aarch64" => wit::Architecture::Aarch64,
|
||||
"x86" => wit::Architecture::X86,
|
||||
"x86_64" => wit::Architecture::X8664,
|
||||
@@ -314,18 +389,24 @@ impl wit::ExtensionImports for WasmState {
|
||||
async fn download_file(
|
||||
&mut self,
|
||||
url: String,
|
||||
filename: String,
|
||||
path: String,
|
||||
file_type: wit::DownloadedFileType,
|
||||
) -> wasmtime::Result<Result<(), String>> {
|
||||
let path = PathBuf::from(path);
|
||||
|
||||
async fn inner(
|
||||
this: &mut WasmState,
|
||||
url: String,
|
||||
filename: String,
|
||||
path: PathBuf,
|
||||
file_type: wit::DownloadedFileType,
|
||||
) -> anyhow::Result<()> {
|
||||
this.host.fs.create_dir(&this.host.work_dir).await?;
|
||||
let container_dir = this.host.work_dir.join(this.manifest.id.as_ref());
|
||||
let destination_path = container_dir.join(&filename);
|
||||
let extension_work_dir = this.host.work_dir.join(this.manifest.id.as_ref());
|
||||
|
||||
this.host.fs.create_dir(&extension_work_dir).await?;
|
||||
|
||||
let destination_path = this
|
||||
.host
|
||||
.writeable_path_from_extension(&this.manifest.id, &path)?;
|
||||
|
||||
let mut response = this
|
||||
.host
|
||||
@@ -367,19 +448,24 @@ impl wit::ExtensionImports for WasmState {
|
||||
.await?;
|
||||
}
|
||||
wit::DownloadedFileType::Zip => {
|
||||
let zip_filename = format!("{filename}.zip");
|
||||
let file_name = destination_path
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow!("invalid download path"))?
|
||||
.to_string_lossy();
|
||||
let zip_filename = format!("{file_name}.zip");
|
||||
let mut zip_path = destination_path.clone();
|
||||
zip_path.set_file_name(zip_filename);
|
||||
|
||||
futures::pin_mut!(body);
|
||||
this.host.fs.create_file_with(&zip_path, body).await?;
|
||||
|
||||
let unzip_status = std::process::Command::new("unzip")
|
||||
.current_dir(&container_dir)
|
||||
.current_dir(&extension_work_dir)
|
||||
.arg(&zip_path)
|
||||
.output()?
|
||||
.status;
|
||||
if !unzip_status.success() {
|
||||
Err(anyhow!("failed to unzip {filename} archive"))?;
|
||||
Err(anyhow!("failed to unzip {} archive", path.display()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,19 +473,23 @@ impl wit::ExtensionImports for WasmState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Ok(inner(self, url, filename, file_type)
|
||||
Ok(inner(self, url, path, file_type)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| err.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl WasiView for WasmState {
|
||||
fn wasi_view(state: &mut WasmState) -> &mut WasmState {
|
||||
state
|
||||
}
|
||||
|
||||
impl wasi::WasiView for WasmState {
|
||||
fn table(&mut self) -> &mut ResourceTable {
|
||||
&mut self.table
|
||||
}
|
||||
|
||||
fn ctx(&mut self) -> &mut WasiCtx {
|
||||
fn ctx(&mut self) -> &mut wasi::WasiCtx {
|
||||
&mut self.ctx
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ macro_rules! register_extension {
|
||||
($extension_type:ty) => {
|
||||
#[export_name = "init-extension"]
|
||||
pub extern "C" fn __init_extension() {
|
||||
std::env::set_current_dir(std::env::var("PWD").unwrap()).unwrap();
|
||||
zed_extension_api::register_extension(|| {
|
||||
Box::new(<$extension_type as zed_extension_api::Extension>::new())
|
||||
});
|
||||
|
||||
@@ -61,14 +61,18 @@ world extension {
|
||||
/// Updates the installation status for the given language server.
|
||||
import set-language-server-installation-status: func(language-server-name: string, status: language-server-installation-status);
|
||||
|
||||
type env-vars = list<tuple<string, string>>;
|
||||
|
||||
record command {
|
||||
command: string,
|
||||
args: list<string>,
|
||||
env: list<tuple<string, string>>,
|
||||
env: env-vars,
|
||||
}
|
||||
|
||||
resource worktree {
|
||||
read-text-file: func(path: string) -> result<string, string>;
|
||||
which: func(binary-name: string) -> option<string>;
|
||||
shell-env: func() -> env-vars;
|
||||
}
|
||||
|
||||
record language-server-config {
|
||||
|
||||
@@ -15,13 +15,17 @@ path = "src/extensions_ui.rs"
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
client.workspace = true
|
||||
editor.workspace = true
|
||||
extension.workspace = true
|
||||
fuzzy.workspace = true
|
||||
gpui.workspace = true
|
||||
settings.workspace = true
|
||||
smallvec.workspace = true
|
||||
theme.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
workspace.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
3
crates/extensions_ui/src/components.rs
Normal file
3
crates/extensions_ui/src/components.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod extension_card;
|
||||
|
||||
pub use extension_card::*;
|
||||
40
crates/extensions_ui/src/components/extension_card.rs
Normal file
40
crates/extensions_ui/src/components/extension_card.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use gpui::{prelude::*, AnyElement};
|
||||
use smallvec::SmallVec;
|
||||
use ui::prelude::*;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ExtensionCard {
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl ExtensionCard {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for ExtensionCard {
|
||||
fn extend(&mut self, elements: impl Iterator<Item = AnyElement>) {
|
||||
self.children.extend(elements)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ExtensionCard {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
div().w_full().child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.h(rems(7.))
|
||||
.p_3()
|
||||
.mt_4()
|
||||
.gap_2()
|
||||
.bg(cx.theme().colors().elevated_surface_background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.rounded_md()
|
||||
.children(self.children),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,58 @@
|
||||
mod components;
|
||||
|
||||
use crate::components::ExtensionCard;
|
||||
use client::telemetry::Telemetry;
|
||||
use editor::{Editor, EditorElement, EditorStyle};
|
||||
use extension::{ExtensionApiResponse, ExtensionStatus, ExtensionStore};
|
||||
use extension::{ExtensionApiResponse, ExtensionManifest, ExtensionStatus, ExtensionStore};
|
||||
use fuzzy::{match_strings, StringMatchCandidate};
|
||||
use gpui::{
|
||||
actions, canvas, uniform_list, AnyElement, AppContext, AvailableSpace, EventEmitter,
|
||||
FocusableView, FontStyle, FontWeight, InteractiveElement, KeyContext, ParentElement, Render,
|
||||
Styled, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, WhiteSpace,
|
||||
WindowContext,
|
||||
actions, canvas, uniform_list, AnyElement, AppContext, EventEmitter, FocusableView, FontStyle,
|
||||
FontWeight, InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle,
|
||||
UniformListScrollHandle, View, ViewContext, VisualContext, WhiteSpace, WindowContext,
|
||||
};
|
||||
use settings::Settings;
|
||||
use std::ops::DerefMut;
|
||||
use std::time::Duration;
|
||||
use std::{ops::Range, sync::Arc};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{prelude::*, ToggleButton, Tooltip};
|
||||
|
||||
use util::ResultExt as _;
|
||||
use workspace::{
|
||||
item::{Item, ItemEvent},
|
||||
Workspace, WorkspaceId,
|
||||
};
|
||||
|
||||
actions!(zed, [Extensions]);
|
||||
actions!(zed, [Extensions, InstallDevExtension]);
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
cx.observe_new_views(move |workspace: &mut Workspace, _cx| {
|
||||
workspace.register_action(move |workspace, _: &Extensions, cx| {
|
||||
let extensions_page = ExtensionsPage::new(workspace, cx);
|
||||
workspace.add_item_to_active_pane(Box::new(extensions_page), cx)
|
||||
});
|
||||
workspace
|
||||
.register_action(move |workspace, _: &Extensions, cx| {
|
||||
let extensions_page = ExtensionsPage::new(workspace, cx);
|
||||
workspace.add_item_to_active_pane(Box::new(extensions_page), cx)
|
||||
})
|
||||
.register_action(move |_, _: &InstallDevExtension, cx| {
|
||||
let store = ExtensionStore::global(cx);
|
||||
let prompt = cx.prompt_for_paths(gpui::PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
cx.deref_mut()
|
||||
.spawn(|mut cx| async move {
|
||||
let extension_path = prompt.await.log_err()??.pop()?;
|
||||
store
|
||||
.update(&mut cx, |store, cx| {
|
||||
store
|
||||
.install_dev_extension(extension_path, cx)
|
||||
.detach_and_log_err(cx)
|
||||
})
|
||||
.ok()?;
|
||||
Some(())
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -37,15 +64,26 @@ enum ExtensionFilter {
|
||||
NotInstalled,
|
||||
}
|
||||
|
||||
impl ExtensionFilter {
|
||||
pub fn include_dev_extensions(&self) -> bool {
|
||||
match self {
|
||||
Self::All | Self::Installed => true,
|
||||
Self::NotInstalled => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExtensionsPage {
|
||||
list: UniformListScrollHandle,
|
||||
telemetry: Arc<Telemetry>,
|
||||
is_fetching_extensions: bool,
|
||||
filter: ExtensionFilter,
|
||||
extension_entries: Vec<ExtensionApiResponse>,
|
||||
remote_extension_entries: Vec<ExtensionApiResponse>,
|
||||
dev_extension_entries: Vec<Arc<ExtensionManifest>>,
|
||||
filtered_remote_extension_indices: Vec<usize>,
|
||||
query_editor: View<Editor>,
|
||||
query_contains_error: bool,
|
||||
_subscription: gpui::Subscription,
|
||||
_subscriptions: [gpui::Subscription; 2],
|
||||
extension_fetch_task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
@@ -53,7 +91,13 @@ impl ExtensionsPage {
|
||||
pub fn new(workspace: &Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
|
||||
cx.new_view(|cx: &mut ViewContext<Self>| {
|
||||
let store = ExtensionStore::global(cx);
|
||||
let subscription = cx.observe(&store, |_, _, cx| cx.notify());
|
||||
let subscriptions = [
|
||||
cx.observe(&store, |_, _, cx| cx.notify()),
|
||||
cx.subscribe(&store, |this, _, event, cx| match event {
|
||||
extension::Event::ExtensionsUpdated => this.fetch_extensions_debounced(cx),
|
||||
_ => {}
|
||||
}),
|
||||
];
|
||||
|
||||
let query_editor = cx.new_view(|cx| {
|
||||
let mut input = Editor::single_line(cx);
|
||||
@@ -67,10 +111,12 @@ impl ExtensionsPage {
|
||||
telemetry: workspace.client().telemetry().clone(),
|
||||
is_fetching_extensions: false,
|
||||
filter: ExtensionFilter::All,
|
||||
extension_entries: Vec::new(),
|
||||
dev_extension_entries: Vec::new(),
|
||||
filtered_remote_extension_indices: Vec::new(),
|
||||
remote_extension_entries: Vec::new(),
|
||||
query_contains_error: false,
|
||||
extension_fetch_task: None,
|
||||
_subscription: subscription,
|
||||
_subscriptions: subscriptions,
|
||||
query_editor,
|
||||
};
|
||||
this.fetch_extensions(None, cx);
|
||||
@@ -78,250 +124,374 @@ impl ExtensionsPage {
|
||||
})
|
||||
}
|
||||
|
||||
fn filtered_extension_entries(&self, cx: &mut ViewContext<Self>) -> Vec<ExtensionApiResponse> {
|
||||
fn filter_extension_entries(&mut self, cx: &mut ViewContext<Self>) {
|
||||
let extension_store = ExtensionStore::global(cx).read(cx);
|
||||
|
||||
self.extension_entries
|
||||
.iter()
|
||||
.filter(|extension| match self.filter {
|
||||
ExtensionFilter::All => true,
|
||||
ExtensionFilter::Installed => {
|
||||
let status = extension_store.extension_status(&extension.id);
|
||||
self.filtered_remote_extension_indices.clear();
|
||||
self.filtered_remote_extension_indices.extend(
|
||||
self.remote_extension_entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, extension)| match self.filter {
|
||||
ExtensionFilter::All => true,
|
||||
ExtensionFilter::Installed => {
|
||||
let status = extension_store.extension_status(&extension.id);
|
||||
matches!(status, ExtensionStatus::Installed(_))
|
||||
}
|
||||
ExtensionFilter::NotInstalled => {
|
||||
let status = extension_store.extension_status(&extension.id);
|
||||
|
||||
matches!(status, ExtensionStatus::Installed(_))
|
||||
}
|
||||
ExtensionFilter::NotInstalled => {
|
||||
let status = extension_store.extension_status(&extension.id);
|
||||
|
||||
matches!(status, ExtensionStatus::NotInstalled)
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn install_extension(
|
||||
&self,
|
||||
extension_id: Arc<str>,
|
||||
version: Arc<str>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
ExtensionStore::global(cx).update(cx, |store, cx| {
|
||||
store.install_extension(extension_id, version, cx)
|
||||
});
|
||||
matches!(status, ExtensionStatus::NotInstalled)
|
||||
}
|
||||
})
|
||||
.map(|(ix, _)| ix),
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn uninstall_extension(&self, extension_id: Arc<str>, cx: &mut ViewContext<Self>) {
|
||||
ExtensionStore::global(cx)
|
||||
.update(cx, |store, cx| store.uninstall_extension(extension_id, cx));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn fetch_extensions(&mut self, search: Option<&str>, cx: &mut ViewContext<Self>) {
|
||||
fn fetch_extensions(&mut self, search: Option<String>, cx: &mut ViewContext<Self>) {
|
||||
self.is_fetching_extensions = true;
|
||||
cx.notify();
|
||||
|
||||
let extensions =
|
||||
ExtensionStore::global(cx).update(cx, |store, cx| store.fetch_extensions(search, cx));
|
||||
let extension_store = ExtensionStore::global(cx);
|
||||
|
||||
let dev_extensions = extension_store.update(cx, |store, _| {
|
||||
store.dev_extensions().cloned().collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let remote_extensions = extension_store.update(cx, |store, cx| {
|
||||
store.fetch_extensions(search.as_deref(), cx)
|
||||
});
|
||||
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
let fetch_result = extensions.await;
|
||||
match fetch_result {
|
||||
Ok(extensions) => this.update(&mut cx, |this, cx| {
|
||||
this.extension_entries = extensions;
|
||||
this.is_fetching_extensions = false;
|
||||
cx.notify();
|
||||
}),
|
||||
Err(err) => {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.is_fetching_extensions = false;
|
||||
cx.notify();
|
||||
let dev_extensions = if let Some(search) = search {
|
||||
let match_candidates = dev_extensions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, manifest)| StringMatchCandidate {
|
||||
id: ix,
|
||||
string: manifest.name.clone(),
|
||||
char_bag: manifest.name.as_str().into(),
|
||||
})
|
||||
.ok();
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
let matches = match_strings(
|
||||
&match_candidates,
|
||||
&search,
|
||||
false,
|
||||
match_candidates.len(),
|
||||
&Default::default(),
|
||||
cx.background_executor().clone(),
|
||||
)
|
||||
.await;
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|mat| dev_extensions[mat.candidate_id].clone())
|
||||
.collect()
|
||||
} else {
|
||||
dev_extensions
|
||||
};
|
||||
|
||||
let fetch_result = remote_extensions.await;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
cx.notify();
|
||||
this.dev_extension_entries = dev_extensions;
|
||||
this.is_fetching_extensions = false;
|
||||
this.remote_extension_entries = fetch_result?;
|
||||
this.filter_extension_entries(cx);
|
||||
anyhow::Ok(())
|
||||
})?
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn render_extensions(&mut self, range: Range<usize>, cx: &mut ViewContext<Self>) -> Vec<Div> {
|
||||
self.filtered_extension_entries(cx)[range]
|
||||
.iter()
|
||||
.map(|extension| self.render_entry(extension, cx))
|
||||
fn render_extensions(
|
||||
&mut self,
|
||||
range: Range<usize>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Vec<ExtensionCard> {
|
||||
let dev_extension_entries_len = if self.filter.include_dev_extensions() {
|
||||
self.dev_extension_entries.len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
range
|
||||
.map(|ix| {
|
||||
if ix < dev_extension_entries_len {
|
||||
let extension = &self.dev_extension_entries[ix];
|
||||
self.render_dev_extension(extension, cx)
|
||||
} else {
|
||||
let extension_ix =
|
||||
self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
|
||||
let extension = &self.remote_extension_entries[extension_ix];
|
||||
self.render_remote_extension(extension, cx)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_entry(&self, extension: &ExtensionApiResponse, cx: &mut ViewContext<Self>) -> Div {
|
||||
fn render_dev_extension(
|
||||
&self,
|
||||
extension: &ExtensionManifest,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> ExtensionCard {
|
||||
let status = ExtensionStore::global(cx)
|
||||
.read(cx)
|
||||
.extension_status(&extension.id);
|
||||
|
||||
let upgrade_button = match status.clone() {
|
||||
ExtensionStatus::NotInstalled
|
||||
| ExtensionStatus::Installing
|
||||
| ExtensionStatus::Removing => None,
|
||||
ExtensionStatus::Installed(installed_version) => {
|
||||
if installed_version != extension.version {
|
||||
Some(
|
||||
Button::new(
|
||||
SharedString::from(format!("upgrade-{}", extension.id)),
|
||||
"Upgrade",
|
||||
let repository_url = extension.repository.clone();
|
||||
|
||||
ExtensionCard::new()
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_end()
|
||||
.child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
|
||||
.child(
|
||||
Headline::new(format!("v{}", extension.version))
|
||||
.size(HeadlineSize::XSmall),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.child(
|
||||
Button::new(
|
||||
SharedString::from(format!("rebuild-{}", extension.id)),
|
||||
"Rebuild",
|
||||
)
|
||||
.on_click({
|
||||
let extension_id = extension.id.clone();
|
||||
move |_, cx| {
|
||||
ExtensionStore::global(cx).update(cx, |store, cx| {
|
||||
store.rebuild_dev_extension(extension_id.clone(), cx)
|
||||
});
|
||||
}
|
||||
})
|
||||
.color(Color::Accent)
|
||||
.disabled(matches!(status, ExtensionStatus::Upgrading)),
|
||||
)
|
||||
.child(
|
||||
Button::new(SharedString::from(extension.id.clone()), "Uninstall")
|
||||
.on_click({
|
||||
let extension_id = extension.id.clone();
|
||||
move |_, cx| {
|
||||
ExtensionStore::global(cx).update(cx, |store, cx| {
|
||||
store.uninstall_extension(extension_id.clone(), cx)
|
||||
});
|
||||
}
|
||||
})
|
||||
.color(Color::Accent)
|
||||
.disabled(matches!(status, ExtensionStatus::Removing)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
Label::new(format!(
|
||||
"{}: {}",
|
||||
if extension.authors.len() > 1 {
|
||||
"Authors"
|
||||
} else {
|
||||
"Author"
|
||||
},
|
||||
extension.authors.join(", ")
|
||||
))
|
||||
.size(LabelSize::Small),
|
||||
)
|
||||
.child(Label::new("<>").size(LabelSize::Small)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.children(extension.description.as_ref().map(|description| {
|
||||
Label::new(description.clone())
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Default)
|
||||
}))
|
||||
.children(repository_url.map(|repository_url| {
|
||||
IconButton::new(
|
||||
SharedString::from(format!("repository-{}", extension.id)),
|
||||
IconName::Github,
|
||||
)
|
||||
.icon_color(Color::Accent)
|
||||
.icon_size(IconSize::Small)
|
||||
.style(ButtonStyle::Filled)
|
||||
.on_click(cx.listener({
|
||||
let extension_id = extension.id.clone();
|
||||
let version = extension.version.clone();
|
||||
move |this, _, cx| {
|
||||
this.telemetry
|
||||
.report_app_event("extensions: install extension".to_string());
|
||||
this.install_extension(extension_id.clone(), version.clone(), cx);
|
||||
let repository_url = repository_url.clone();
|
||||
move |_, _, cx| {
|
||||
cx.open_url(&repository_url);
|
||||
}
|
||||
}))
|
||||
.color(Color::Accent),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
ExtensionStatus::Upgrading => Some(
|
||||
Button::new(
|
||||
SharedString::from(format!("upgrade-{}", extension.id)),
|
||||
"Upgrade",
|
||||
)
|
||||
.color(Color::Accent)
|
||||
.disabled(true),
|
||||
),
|
||||
};
|
||||
|
||||
let install_or_uninstall_button = match status {
|
||||
ExtensionStatus::NotInstalled | ExtensionStatus::Installing => Button::new(
|
||||
SharedString::from(extension.id.clone()),
|
||||
if status.is_installing() {
|
||||
"Installing..."
|
||||
} else {
|
||||
"Install"
|
||||
},
|
||||
.tooltip(move |cx| Tooltip::text(repository_url.clone(), cx))
|
||||
})),
|
||||
)
|
||||
.on_click(cx.listener({
|
||||
let extension_id = extension.id.clone();
|
||||
let version = extension.version.clone();
|
||||
move |this, _, cx| {
|
||||
this.telemetry
|
||||
.report_app_event("extensions: install extension".to_string());
|
||||
this.install_extension(extension_id.clone(), version.clone(), cx);
|
||||
}
|
||||
}))
|
||||
.disabled(status.is_installing()),
|
||||
ExtensionStatus::Installed(_)
|
||||
| ExtensionStatus::Upgrading
|
||||
| ExtensionStatus::Removing => Button::new(
|
||||
SharedString::from(extension.id.clone()),
|
||||
if status.is_upgrading() {
|
||||
"Upgrading..."
|
||||
} else if status.is_removing() {
|
||||
"Removing..."
|
||||
} else {
|
||||
"Uninstall"
|
||||
},
|
||||
)
|
||||
.on_click(cx.listener({
|
||||
let extension_id = extension.id.clone();
|
||||
move |this, _, cx| {
|
||||
this.telemetry
|
||||
.report_app_event("extensions: uninstall extension".to_string());
|
||||
this.uninstall_extension(extension_id.clone(), cx);
|
||||
}
|
||||
}))
|
||||
.disabled(matches!(
|
||||
status,
|
||||
ExtensionStatus::Upgrading | ExtensionStatus::Removing
|
||||
)),
|
||||
}
|
||||
.color(Color::Accent);
|
||||
}
|
||||
|
||||
fn render_remote_extension(
|
||||
&self,
|
||||
extension: &ExtensionApiResponse,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> ExtensionCard {
|
||||
let status = ExtensionStore::global(cx)
|
||||
.read(cx)
|
||||
.extension_status(&extension.id);
|
||||
|
||||
let (install_or_uninstall_button, upgrade_button) =
|
||||
self.buttons_for_entry(extension, &status, cx);
|
||||
let repository_url = extension.repository.clone();
|
||||
let tooltip_text = Tooltip::text(repository_url.clone(), cx);
|
||||
|
||||
div().w_full().child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.h(rems(7.))
|
||||
.p_3()
|
||||
.mt_4()
|
||||
.gap_2()
|
||||
.bg(cx.theme().colors().elevated_surface_background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.rounded_md()
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_end()
|
||||
.child(
|
||||
Headline::new(extension.name.clone())
|
||||
.size(HeadlineSize::Medium),
|
||||
)
|
||||
.child(
|
||||
Headline::new(format!("v{}", extension.version))
|
||||
.size(HeadlineSize::XSmall),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.children(upgrade_button)
|
||||
.child(install_or_uninstall_button),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
Label::new(format!(
|
||||
"{}: {}",
|
||||
if extension.authors.len() > 1 {
|
||||
"Authors"
|
||||
} else {
|
||||
"Author"
|
||||
},
|
||||
extension.authors.join(", ")
|
||||
))
|
||||
ExtensionCard::new()
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_end()
|
||||
.child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
|
||||
.child(
|
||||
Headline::new(format!("v{}", extension.version))
|
||||
.size(HeadlineSize::XSmall),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.children(upgrade_button)
|
||||
.child(install_or_uninstall_button),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
Label::new(format!(
|
||||
"{}: {}",
|
||||
if extension.authors.len() > 1 {
|
||||
"Authors"
|
||||
} else {
|
||||
"Author"
|
||||
},
|
||||
extension.authors.join(", ")
|
||||
))
|
||||
.size(LabelSize::Small),
|
||||
)
|
||||
.child(
|
||||
Label::new(format!("Downloads: {}", extension.download_count))
|
||||
.size(LabelSize::Small),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.children(extension.description.as_ref().map(|description| {
|
||||
Label::new(description.clone())
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Default)
|
||||
}))
|
||||
.child(
|
||||
IconButton::new(
|
||||
SharedString::from(format!("repository-{}", extension.id)),
|
||||
IconName::Github,
|
||||
)
|
||||
.child(
|
||||
Label::new(format!("Downloads: {}", extension.download_count))
|
||||
.size(LabelSize::Small),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.children(extension.description.as_ref().map(|description| {
|
||||
Label::new(description.clone())
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Default)
|
||||
}))
|
||||
.child(
|
||||
IconButton::new(
|
||||
SharedString::from(format!("repository-{}", extension.id)),
|
||||
IconName::Github,
|
||||
)
|
||||
.icon_color(Color::Accent)
|
||||
.icon_size(IconSize::Small)
|
||||
.style(ButtonStyle::Filled)
|
||||
.on_click(cx.listener(move |_, _, cx| {
|
||||
.icon_color(Color::Accent)
|
||||
.icon_size(IconSize::Small)
|
||||
.style(ButtonStyle::Filled)
|
||||
.on_click(cx.listener({
|
||||
let repository_url = repository_url.clone();
|
||||
move |_, _, cx| {
|
||||
cx.open_url(&repository_url);
|
||||
}))
|
||||
.tooltip(move |_| tooltip_text.clone()),
|
||||
),
|
||||
}
|
||||
}))
|
||||
.tooltip(move |cx| Tooltip::text(repository_url.clone(), cx)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn buttons_for_entry(
|
||||
&self,
|
||||
extension: &ExtensionApiResponse,
|
||||
status: &ExtensionStatus,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> (Button, Option<Button>) {
|
||||
match status.clone() {
|
||||
ExtensionStatus::NotInstalled => (
|
||||
Button::new(SharedString::from(extension.id.clone()), "Install").on_click(
|
||||
cx.listener({
|
||||
let extension_id = extension.id.clone();
|
||||
let version = extension.version.clone();
|
||||
move |this, _, cx| {
|
||||
this.telemetry
|
||||
.report_app_event("extensions: install extension".to_string());
|
||||
ExtensionStore::global(cx).update(cx, |store, cx| {
|
||||
store.install_extension(extension_id.clone(), version.clone(), cx)
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
None,
|
||||
),
|
||||
ExtensionStatus::Installing => (
|
||||
Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
|
||||
None,
|
||||
),
|
||||
ExtensionStatus::Upgrading => (
|
||||
Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
|
||||
Some(
|
||||
Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
|
||||
),
|
||||
),
|
||||
ExtensionStatus::Installed(installed_version) => (
|
||||
Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click(
|
||||
cx.listener({
|
||||
let extension_id = extension.id.clone();
|
||||
move |this, _, cx| {
|
||||
this.telemetry
|
||||
.report_app_event("extensions: uninstall extension".to_string());
|
||||
ExtensionStore::global(cx).update(cx, |store, cx| {
|
||||
store.uninstall_extension(extension_id.clone(), cx)
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
if installed_version == extension.version {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
Button::new(SharedString::from(extension.id.clone()), "Upgrade").on_click(
|
||||
cx.listener({
|
||||
let extension_id = extension.id.clone();
|
||||
let version = extension.version.clone();
|
||||
move |this, _, cx| {
|
||||
this.telemetry.report_app_event(
|
||||
"extensions: install extension".to_string(),
|
||||
);
|
||||
ExtensionStore::global(cx).update(cx, |store, cx| {
|
||||
store.upgrade_extension(
|
||||
extension_id.clone(),
|
||||
version.clone(),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
ExtensionStatus::Removing => (
|
||||
Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
|
||||
None,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
|
||||
@@ -394,32 +564,36 @@ impl ExtensionsPage {
|
||||
) {
|
||||
if let editor::EditorEvent::Edited = event {
|
||||
self.query_contains_error = false;
|
||||
self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
|
||||
let search = this
|
||||
.update(&mut cx, |this, cx| this.search_query(cx))
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
// Only debounce the fetching of extensions if we have a search
|
||||
// query.
|
||||
//
|
||||
// If the search was just cleared then we can just reload the list
|
||||
// of extensions without a debounce, which allows us to avoid seeing
|
||||
// an intermittent flash of a "no extensions" state.
|
||||
if let Some(_) = search {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(250))
|
||||
.await;
|
||||
};
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.fetch_extensions(search.as_deref(), cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
self.fetch_extensions_debounced(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_extensions_debounced(&mut self, cx: &mut ViewContext<'_, ExtensionsPage>) {
|
||||
self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
|
||||
let search = this
|
||||
.update(&mut cx, |this, cx| this.search_query(cx))
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
// Only debounce the fetching of extensions if we have a search
|
||||
// query.
|
||||
//
|
||||
// If the search was just cleared then we can just reload the list
|
||||
// of extensions without a debounce, which allows us to avoid seeing
|
||||
// an intermittent flash of a "no extensions" state.
|
||||
if let Some(_) = search {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(250))
|
||||
.await;
|
||||
};
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.fetch_extensions(search, cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
|
||||
let search = self.query_editor.read(cx).text(cx);
|
||||
if search.trim().is_empty() {
|
||||
@@ -479,7 +653,17 @@ impl Render for ExtensionsPage {
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.child(Headline::new("Extensions").size(HeadlineSize::XLarge)),
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.child(Headline::new("Extensions").size(HeadlineSize::XLarge))
|
||||
.child(
|
||||
Button::new("add-dev-extension", "Add Dev Extension")
|
||||
.style(ButtonStyle::Filled)
|
||||
.size(ButtonSize::Large)
|
||||
.on_click(|_event, cx| {
|
||||
cx.dispatch_action(Box::new(InstallDevExtension))
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -494,8 +678,9 @@ impl Render for ExtensionsPage {
|
||||
.style(ButtonStyle::Filled)
|
||||
.size(ButtonSize::Large)
|
||||
.selected(self.filter == ExtensionFilter::All)
|
||||
.on_click(cx.listener(|this, _event, _cx| {
|
||||
.on_click(cx.listener(|this, _event, cx| {
|
||||
this.filter = ExtensionFilter::All;
|
||||
this.filter_extension_entries(cx);
|
||||
}))
|
||||
.tooltip(move |cx| {
|
||||
Tooltip::text("Show all extensions", cx)
|
||||
@@ -507,8 +692,9 @@ impl Render for ExtensionsPage {
|
||||
.style(ButtonStyle::Filled)
|
||||
.size(ButtonSize::Large)
|
||||
.selected(self.filter == ExtensionFilter::Installed)
|
||||
.on_click(cx.listener(|this, _event, _cx| {
|
||||
.on_click(cx.listener(|this, _event, cx| {
|
||||
this.filter = ExtensionFilter::Installed;
|
||||
this.filter_extension_entries(cx);
|
||||
}))
|
||||
.tooltip(move |cx| {
|
||||
Tooltip::text("Show installed extensions", cx)
|
||||
@@ -520,8 +706,9 @@ impl Render for ExtensionsPage {
|
||||
.style(ButtonStyle::Filled)
|
||||
.size(ButtonSize::Large)
|
||||
.selected(self.filter == ExtensionFilter::NotInstalled)
|
||||
.on_click(cx.listener(|this, _event, _cx| {
|
||||
.on_click(cx.listener(|this, _event, cx| {
|
||||
this.filter = ExtensionFilter::NotInstalled;
|
||||
this.filter_extension_entries(cx);
|
||||
}))
|
||||
.tooltip(move |cx| {
|
||||
Tooltip::text("Show not installed extensions", cx)
|
||||
@@ -532,34 +719,35 @@ impl Render for ExtensionsPage {
|
||||
),
|
||||
)
|
||||
.child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
|
||||
let entries = self.filtered_extension_entries(cx);
|
||||
if entries.is_empty() {
|
||||
let mut count = self.filtered_remote_extension_indices.len();
|
||||
if self.filter.include_dev_extensions() {
|
||||
count += self.dev_extension_entries.len();
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return this.py_4().child(self.render_empty_state(cx));
|
||||
}
|
||||
|
||||
let view = cx.view().clone();
|
||||
let scroll_handle = self.list.clone();
|
||||
this.child(
|
||||
canvas({
|
||||
let view = cx.view().clone();
|
||||
let scroll_handle = self.list.clone();
|
||||
let item_count = entries.len();
|
||||
canvas(
|
||||
move |bounds, cx| {
|
||||
uniform_list::<_, Div, _>(
|
||||
let mut list = uniform_list::<_, ExtensionCard, _>(
|
||||
view,
|
||||
"entries",
|
||||
item_count,
|
||||
count,
|
||||
Self::render_extensions,
|
||||
)
|
||||
.size_full()
|
||||
.pb_4()
|
||||
.track_scroll(scroll_handle)
|
||||
.into_any_element()
|
||||
.draw(
|
||||
bounds.origin,
|
||||
bounds.size.map(AvailableSpace::Definite),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.into_any_element();
|
||||
list.layout(bounds.origin, bounds.size.into(), cx);
|
||||
list
|
||||
},
|
||||
|_bounds, mut list, cx| list.paint(cx),
|
||||
)
|
||||
.size_full(),
|
||||
)
|
||||
}))
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
use gpui::{Render, ViewContext, WeakView};
|
||||
use ui::{prelude::*, ButtonCommon, IconButton, IconName, Tooltip};
|
||||
use workspace::{item::ItemHandle, StatusItemView, Workspace};
|
||||
|
||||
use crate::{feedback_modal::FeedbackModal, GiveFeedback};
|
||||
|
||||
pub struct DeployFeedbackButton {
|
||||
workspace: WeakView<Workspace>,
|
||||
}
|
||||
|
||||
impl DeployFeedbackButton {
|
||||
pub fn new(workspace: &Workspace) -> Self {
|
||||
DeployFeedbackButton {
|
||||
workspace: workspace.weak_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DeployFeedbackButton {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let is_open = self
|
||||
.workspace
|
||||
.upgrade()
|
||||
.and_then(|workspace| {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.active_modal::<FeedbackModal>(cx)
|
||||
})
|
||||
})
|
||||
.is_some();
|
||||
IconButton::new("give-feedback", IconName::Envelope)
|
||||
.style(ui::ButtonStyle::Subtle)
|
||||
.icon_size(IconSize::Small)
|
||||
.selected(is_open)
|
||||
.tooltip(|cx| Tooltip::text("Share Feedback", cx))
|
||||
.on_click(|_, cx| {
|
||||
cx.dispatch_action(Box::new(GiveFeedback));
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl StatusItemView for DeployFeedbackButton {
|
||||
fn set_active_pane_item(
|
||||
&mut self,
|
||||
_item: Option<&dyn ItemHandle>,
|
||||
_cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ use gpui::{actions, AppContext, ClipboardItem, PromptLevel};
|
||||
use system_specs::SystemSpecs;
|
||||
use workspace::Workspace;
|
||||
|
||||
pub mod deploy_feedback_button;
|
||||
pub mod feedback_modal;
|
||||
|
||||
actions!(feedback, [GiveFeedback, SubmitFeedback]);
|
||||
|
||||
@@ -431,7 +431,7 @@ impl Render for FeedbackModal {
|
||||
.h(rems(32.))
|
||||
.p_4()
|
||||
.gap_2()
|
||||
.child(Headline::new("Share Feedback"))
|
||||
.child(Headline::new("Give Feedback"))
|
||||
.child(
|
||||
Label::new(if self.character_count < *FEEDBACK_CHAR_LIMIT.start() {
|
||||
format!(
|
||||
|
||||
@@ -13,7 +13,6 @@ path = "src/fs.rs"
|
||||
|
||||
[dependencies]
|
||||
collections.workspace = true
|
||||
fsevent.workspace = true
|
||||
rope.workspace = true
|
||||
text.workspace = true
|
||||
util.workspace = true
|
||||
@@ -37,6 +36,9 @@ time.workspace = true
|
||||
|
||||
gpui = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
fsevent.workspace = true
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
notify = "6.1.1"
|
||||
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
pub mod repository;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
pub use fsevent::Event;
|
||||
#[cfg(target_os = "macos")]
|
||||
use fsevent::EventStream;
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use fsevent::StreamFlags;
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use notify::{Config, EventKind, Watcher};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
@@ -43,6 +34,7 @@ use std::ffi::OsStr;
|
||||
#[async_trait::async_trait]
|
||||
pub trait Fs: Send + Sync {
|
||||
async fn create_dir(&self, path: &Path) -> Result<()>;
|
||||
async fn create_symlink(&self, path: &Path, target: PathBuf) -> Result<()>;
|
||||
async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()>;
|
||||
async fn create_file_with(
|
||||
&self,
|
||||
@@ -75,7 +67,7 @@ pub trait Fs: Send + Sync {
|
||||
&self,
|
||||
path: &Path,
|
||||
latency: Duration,
|
||||
) -> Pin<Box<dyn Send + Stream<Item = Vec<Event>>>>;
|
||||
) -> Pin<Box<dyn Send + Stream<Item = Vec<PathBuf>>>>;
|
||||
|
||||
fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<Mutex<dyn GitRepository>>>;
|
||||
fn is_fake(&self) -> bool;
|
||||
@@ -124,6 +116,16 @@ impl Fs for RealFs {
|
||||
Ok(smol::fs::create_dir_all(path).await?)
|
||||
}
|
||||
|
||||
async fn create_symlink(&self, path: &Path, target: PathBuf) -> Result<()> {
|
||||
#[cfg(target_family = "unix")]
|
||||
smol::fs::unix::symlink(target, path).await?;
|
||||
|
||||
#[cfg(target_family = "windows")]
|
||||
Err(anyhow!("not supported yet on windows"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()> {
|
||||
let mut open_options = smol::fs::OpenOptions::new();
|
||||
open_options.write(true).create(true);
|
||||
@@ -212,9 +214,11 @@ impl Fs for RealFs {
|
||||
|
||||
async fn load(&self, path: &Path) -> Result<String> {
|
||||
let mut file = smol::fs::File::open(path).await?;
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text).await?;
|
||||
Ok(text)
|
||||
// We use `read_exact` here instead of `read_to_string` as the latter is *very*
|
||||
// happy to reallocate often, which comes into play when we're loading large files.
|
||||
let mut storage = vec![0; file.metadata().await?.len() as usize];
|
||||
file.read_exact(&mut storage).await?;
|
||||
Ok(String::from_utf8(storage)?)
|
||||
}
|
||||
|
||||
async fn atomic_write(&self, path: PathBuf, data: String) -> Result<()> {
|
||||
@@ -314,12 +318,18 @@ impl Fs for RealFs {
|
||||
&self,
|
||||
path: &Path,
|
||||
latency: Duration,
|
||||
) -> Pin<Box<dyn Send + Stream<Item = Vec<Event>>>> {
|
||||
) -> Pin<Box<dyn Send + Stream<Item = Vec<PathBuf>>>> {
|
||||
use fsevent::EventStream;
|
||||
|
||||
let (tx, rx) = smol::channel::unbounded();
|
||||
let (stream, handle) = EventStream::new(&[path], latency);
|
||||
std::thread::spawn(move || {
|
||||
stream.run(move |events| smol::block_on(tx.send(events)).is_ok());
|
||||
stream.run(move |events| {
|
||||
smol::block_on(tx.send(events.into_iter().map(|event| event.path).collect()))
|
||||
.is_ok()
|
||||
});
|
||||
});
|
||||
|
||||
Box::pin(rx.chain(futures::stream::once(async move {
|
||||
drop(handle);
|
||||
vec![]
|
||||
@@ -330,49 +340,66 @@ impl Fs for RealFs {
|
||||
async fn watch(
|
||||
&self,
|
||||
path: &Path,
|
||||
latency: Duration,
|
||||
) -> Pin<Box<dyn Send + Stream<Item = Vec<Event>>>> {
|
||||
_latency: Duration,
|
||||
) -> Pin<Box<dyn Send + Stream<Item = Vec<PathBuf>>>> {
|
||||
use notify::{event::EventKind, Watcher};
|
||||
// todo(linux): This spawns two threads, while the macOS impl
|
||||
// only spawns one. Can we use a OnceLock or some such to make
|
||||
// this better
|
||||
|
||||
let (tx, rx) = smol::channel::unbounded();
|
||||
|
||||
if !path.exists() {
|
||||
log::error!("watch path does not exist: {}", path.display());
|
||||
return Box::pin(rx);
|
||||
}
|
||||
|
||||
let mut watcher =
|
||||
notify::recommended_watcher(move |res: Result<notify::Event, _>| match res {
|
||||
Ok(event) => {
|
||||
let flags = match event.kind {
|
||||
// ITEM_REMOVED is currently the only flag we care about
|
||||
EventKind::Remove(_) => StreamFlags::ITEM_REMOVED,
|
||||
_ => StreamFlags::NONE,
|
||||
};
|
||||
let events = event
|
||||
.paths
|
||||
.into_iter()
|
||||
.map(|path| Event {
|
||||
event_id: 0,
|
||||
flags,
|
||||
path,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let _ = tx.try_send(events);
|
||||
let mut file_watcher = notify::recommended_watcher({
|
||||
let tx = tx.clone();
|
||||
move |event: Result<notify::Event, _>| {
|
||||
if let Some(event) = event.log_err() {
|
||||
tx.try_send(event.paths).ok();
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("watch error: {}", err);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
})
|
||||
.expect("Could not start file watcher");
|
||||
|
||||
watcher
|
||||
.configure(Config::default().with_poll_interval(latency))
|
||||
.unwrap();
|
||||
|
||||
watcher
|
||||
file_watcher
|
||||
.watch(path, notify::RecursiveMode::Recursive)
|
||||
.unwrap();
|
||||
.ok(); // It's ok if this fails, the parent watcher will add it.
|
||||
|
||||
Box::pin(rx)
|
||||
let mut parent_watcher = notify::recommended_watcher({
|
||||
let watched_path = path.to_path_buf();
|
||||
let tx = tx.clone();
|
||||
move |event: Result<notify::Event, _>| {
|
||||
if let Some(event) = event.ok() {
|
||||
if event.paths.into_iter().any(|path| *path == watched_path) {
|
||||
match event.kind {
|
||||
EventKind::Create(_) => {
|
||||
file_watcher
|
||||
.watch(watched_path.as_path(), notify::RecursiveMode::Recursive)
|
||||
.log_err();
|
||||
let _ = tx.try_send(vec![watched_path.clone()]).ok();
|
||||
}
|
||||
EventKind::Remove(_) => {
|
||||
file_watcher.unwatch(&watched_path).log_err();
|
||||
let _ = tx.try_send(vec![watched_path.clone()]).ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("Could not start file watcher");
|
||||
|
||||
parent_watcher
|
||||
.watch(
|
||||
path.parent()
|
||||
.expect("Watching root is probably not what you want"),
|
||||
notify::RecursiveMode::NonRecursive,
|
||||
)
|
||||
.expect("Could not start watcher on parent directory");
|
||||
|
||||
Box::pin(rx.chain(futures::stream::once(async move {
|
||||
drop(parent_watcher);
|
||||
vec![]
|
||||
})))
|
||||
}
|
||||
|
||||
fn open_repo(&self, dotgit_path: &Path) -> Option<Arc<Mutex<dyn GitRepository>>> {
|
||||
@@ -430,10 +457,6 @@ impl Fs for RealFs {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fs_events_paths(events: Vec<Event>) -> Vec<PathBuf> {
|
||||
events.into_iter().map(|event| event.path).collect()
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub struct FakeFs {
|
||||
// Use an unfair lock to ensure tests are deterministic.
|
||||
@@ -446,9 +469,9 @@ struct FakeFsState {
|
||||
root: Arc<Mutex<FakeFsEntry>>,
|
||||
next_inode: u64,
|
||||
next_mtime: SystemTime,
|
||||
event_txs: Vec<smol::channel::Sender<Vec<fsevent::Event>>>,
|
||||
event_txs: Vec<smol::channel::Sender<Vec<PathBuf>>>,
|
||||
events_paused: bool,
|
||||
buffered_events: Vec<fsevent::Event>,
|
||||
buffered_events: Vec<PathBuf>,
|
||||
metadata_call_count: usize,
|
||||
read_dir_call_count: usize,
|
||||
}
|
||||
@@ -556,11 +579,7 @@ impl FakeFsState {
|
||||
T: Into<PathBuf>,
|
||||
{
|
||||
self.buffered_events
|
||||
.extend(paths.into_iter().map(|path| fsevent::Event {
|
||||
event_id: 0,
|
||||
flags: fsevent::StreamFlags::empty(),
|
||||
path: path.into(),
|
||||
}));
|
||||
.extend(paths.into_iter().map(Into::into));
|
||||
|
||||
if !self.events_paused {
|
||||
self.flush_events(self.buffered_events.len());
|
||||
@@ -994,6 +1013,25 @@ impl Fs for FakeFs {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_symlink(&self, path: &Path, target: PathBuf) -> Result<()> {
|
||||
let mut state = self.state.lock();
|
||||
let file = Arc::new(Mutex::new(FakeFsEntry::Symlink { target }));
|
||||
state
|
||||
.write_path(path.as_ref(), move |e| match e {
|
||||
btree_map::Entry::Vacant(e) => {
|
||||
e.insert(file);
|
||||
Ok(())
|
||||
}
|
||||
btree_map::Entry::Occupied(mut e) => {
|
||||
*e.get_mut() = file;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
state.emit_event(&[path]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_file_with(
|
||||
&self,
|
||||
path: &Path,
|
||||
@@ -1296,14 +1334,14 @@ impl Fs for FakeFs {
|
||||
&self,
|
||||
path: &Path,
|
||||
_: Duration,
|
||||
) -> Pin<Box<dyn Send + Stream<Item = Vec<fsevent::Event>>>> {
|
||||
) -> Pin<Box<dyn Send + Stream<Item = Vec<PathBuf>>>> {
|
||||
self.simulate_random_delay().await;
|
||||
let (tx, rx) = smol::channel::unbounded();
|
||||
self.state.lock().event_txs.push(tx);
|
||||
let path = path.to_path_buf();
|
||||
let executor = self.executor.clone();
|
||||
Box::pin(futures::StreamExt::filter(rx, move |events| {
|
||||
let result = events.iter().any(|event| event.path.starts_with(&path));
|
||||
let result = events.iter().any(|evt_path| evt_path.starts_with(&path));
|
||||
let executor = executor.clone();
|
||||
async move {
|
||||
executor.simulate_random_delay().await;
|
||||
@@ -1503,8 +1541,9 @@ mod tests {
|
||||
]
|
||||
);
|
||||
|
||||
fs.insert_symlink("/root/dir2/link-to-dir3", "./dir3".into())
|
||||
.await;
|
||||
fs.create_symlink("/root/dir2/link-to-dir3".as_ref(), "./dir3".into())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.canonicalize("/root/dir2/link-to-dir3".as_ref())
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use mac_impl::*;
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use bitflags::bitflags;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_impl;
|
||||
use fsevent_sys::{self as fs, core_foundation as cf};
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
convert::AsRef,
|
||||
ffi::{c_void, CStr, OsStr},
|
||||
os::unix::ffi::OsStrExt,
|
||||
path::{Path, PathBuf},
|
||||
ptr, slice,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Event {
|
||||
@@ -14,10 +20,244 @@ pub struct Event {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
pub struct EventStream {
|
||||
lifecycle: Arc<Mutex<Lifecycle>>,
|
||||
state: Box<State>,
|
||||
}
|
||||
|
||||
struct State {
|
||||
latency: Duration,
|
||||
paths: cf::CFMutableArrayRef,
|
||||
callback: Option<Box<dyn FnMut(Vec<Event>) -> bool>>,
|
||||
last_valid_event_id: Option<fs::FSEventStreamEventId>,
|
||||
stream: fs::FSEventStreamRef,
|
||||
}
|
||||
|
||||
impl Drop for State {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
cf::CFRelease(self.paths);
|
||||
fs::FSEventStreamStop(self.stream);
|
||||
fs::FSEventStreamInvalidate(self.stream);
|
||||
fs::FSEventStreamRelease(self.stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Lifecycle {
|
||||
New,
|
||||
Running(cf::CFRunLoopRef),
|
||||
Stopped,
|
||||
}
|
||||
|
||||
pub struct Handle(Arc<Mutex<Lifecycle>>);
|
||||
|
||||
unsafe impl Send for EventStream {}
|
||||
unsafe impl Send for Lifecycle {}
|
||||
|
||||
impl EventStream {
|
||||
pub fn new(paths: &[&Path], latency: Duration) -> (Self, Handle) {
|
||||
unsafe {
|
||||
let cf_paths =
|
||||
cf::CFArrayCreateMutable(cf::kCFAllocatorDefault, 0, &cf::kCFTypeArrayCallBacks);
|
||||
assert!(!cf_paths.is_null());
|
||||
|
||||
for path in paths {
|
||||
let path_bytes = path.as_os_str().as_bytes();
|
||||
let cf_url = cf::CFURLCreateFromFileSystemRepresentation(
|
||||
cf::kCFAllocatorDefault,
|
||||
path_bytes.as_ptr() as *const i8,
|
||||
path_bytes.len() as cf::CFIndex,
|
||||
false,
|
||||
);
|
||||
let cf_path = cf::CFURLCopyFileSystemPath(cf_url, cf::kCFURLPOSIXPathStyle);
|
||||
cf::CFArrayAppendValue(cf_paths, cf_path);
|
||||
cf::CFRelease(cf_path);
|
||||
cf::CFRelease(cf_url);
|
||||
}
|
||||
|
||||
let mut state = Box::new(State {
|
||||
latency,
|
||||
paths: cf_paths,
|
||||
callback: None,
|
||||
last_valid_event_id: None,
|
||||
stream: ptr::null_mut(),
|
||||
});
|
||||
let stream_context = fs::FSEventStreamContext {
|
||||
version: 0,
|
||||
info: state.as_ref() as *const _ as *mut c_void,
|
||||
retain: None,
|
||||
release: None,
|
||||
copy_description: None,
|
||||
};
|
||||
let stream = fs::FSEventStreamCreate(
|
||||
cf::kCFAllocatorDefault,
|
||||
Self::trampoline,
|
||||
&stream_context,
|
||||
cf_paths,
|
||||
FSEventsGetCurrentEventId(),
|
||||
latency.as_secs_f64(),
|
||||
fs::kFSEventStreamCreateFlagFileEvents
|
||||
| fs::kFSEventStreamCreateFlagNoDefer
|
||||
| fs::kFSEventStreamCreateFlagWatchRoot,
|
||||
);
|
||||
state.stream = stream;
|
||||
|
||||
let lifecycle = Arc::new(Mutex::new(Lifecycle::New));
|
||||
(
|
||||
EventStream {
|
||||
lifecycle: lifecycle.clone(),
|
||||
state,
|
||||
},
|
||||
Handle(lifecycle),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run<F>(mut self, f: F)
|
||||
where
|
||||
F: FnMut(Vec<Event>) -> bool + 'static,
|
||||
{
|
||||
self.state.callback = Some(Box::new(f));
|
||||
unsafe {
|
||||
let run_loop = cf::CFRunLoopGetCurrent();
|
||||
{
|
||||
let mut state = self.lifecycle.lock();
|
||||
match *state {
|
||||
Lifecycle::New => *state = Lifecycle::Running(run_loop),
|
||||
Lifecycle::Running(_) => unreachable!(),
|
||||
Lifecycle::Stopped => return,
|
||||
}
|
||||
}
|
||||
fs::FSEventStreamScheduleWithRunLoop(
|
||||
self.state.stream,
|
||||
run_loop,
|
||||
cf::kCFRunLoopDefaultMode,
|
||||
);
|
||||
fs::FSEventStreamStart(self.state.stream);
|
||||
cf::CFRunLoopRun();
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn trampoline(
|
||||
stream_ref: fs::FSEventStreamRef,
|
||||
info: *mut ::std::os::raw::c_void,
|
||||
num: usize, // size_t numEvents
|
||||
event_paths: *mut ::std::os::raw::c_void, // void *eventPaths
|
||||
event_flags: *const ::std::os::raw::c_void, // const FSEventStreamEventFlags eventFlags[]
|
||||
event_ids: *const ::std::os::raw::c_void, // const FSEventStreamEventId eventIds[]
|
||||
) {
|
||||
unsafe {
|
||||
let event_paths = event_paths as *const *const ::std::os::raw::c_char;
|
||||
let e_ptr = event_flags as *mut u32;
|
||||
let i_ptr = event_ids as *mut u64;
|
||||
let state = (info as *mut State).as_mut().unwrap();
|
||||
let callback = if let Some(callback) = state.callback.as_mut() {
|
||||
callback
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
let paths = slice::from_raw_parts(event_paths, num);
|
||||
let flags = slice::from_raw_parts_mut(e_ptr, num);
|
||||
let ids = slice::from_raw_parts_mut(i_ptr, num);
|
||||
let mut stream_restarted = false;
|
||||
|
||||
// Sometimes FSEvents reports a "dropped" event, an indication that either the kernel
|
||||
// or our code couldn't keep up with the sheer volume of file-system events that were
|
||||
// generated. If we observed a valid event before this happens, we'll try to read the
|
||||
// file-system journal by stopping the current stream and creating a new one starting at
|
||||
// such event. Otherwise, we'll let invoke the callback with the dropped event, which
|
||||
// will likely perform a re-scan of one of the root directories.
|
||||
if flags
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(StreamFlags::from_bits)
|
||||
.any(|flags| {
|
||||
flags.contains(StreamFlags::USER_DROPPED)
|
||||
|| flags.contains(StreamFlags::KERNEL_DROPPED)
|
||||
})
|
||||
{
|
||||
if let Some(last_valid_event_id) = state.last_valid_event_id.take() {
|
||||
fs::FSEventStreamStop(state.stream);
|
||||
fs::FSEventStreamInvalidate(state.stream);
|
||||
fs::FSEventStreamRelease(state.stream);
|
||||
|
||||
let stream_context = fs::FSEventStreamContext {
|
||||
version: 0,
|
||||
info,
|
||||
retain: None,
|
||||
release: None,
|
||||
copy_description: None,
|
||||
};
|
||||
let stream = fs::FSEventStreamCreate(
|
||||
cf::kCFAllocatorDefault,
|
||||
Self::trampoline,
|
||||
&stream_context,
|
||||
state.paths,
|
||||
last_valid_event_id,
|
||||
state.latency.as_secs_f64(),
|
||||
fs::kFSEventStreamCreateFlagFileEvents
|
||||
| fs::kFSEventStreamCreateFlagNoDefer
|
||||
| fs::kFSEventStreamCreateFlagWatchRoot,
|
||||
);
|
||||
|
||||
state.stream = stream;
|
||||
fs::FSEventStreamScheduleWithRunLoop(
|
||||
state.stream,
|
||||
cf::CFRunLoopGetCurrent(),
|
||||
cf::kCFRunLoopDefaultMode,
|
||||
);
|
||||
fs::FSEventStreamStart(state.stream);
|
||||
stream_restarted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !stream_restarted {
|
||||
let mut events = Vec::with_capacity(num);
|
||||
for p in 0..num {
|
||||
if let Some(flag) = StreamFlags::from_bits(flags[p]) {
|
||||
if !flag.contains(StreamFlags::HISTORY_DONE) {
|
||||
let path_c_str = CStr::from_ptr(paths[p]);
|
||||
let path = PathBuf::from(OsStr::from_bytes(path_c_str.to_bytes()));
|
||||
let event = Event {
|
||||
event_id: ids[p],
|
||||
flags: flag,
|
||||
path,
|
||||
};
|
||||
state.last_valid_event_id = Some(event.event_id);
|
||||
events.push(event);
|
||||
}
|
||||
} else {
|
||||
debug_assert!(false, "unknown flag set for fs event: {}", flags[p]);
|
||||
}
|
||||
}
|
||||
|
||||
if !events.is_empty() && !callback(events) {
|
||||
fs::FSEventStreamStop(stream_ref);
|
||||
cf::CFRunLoopStop(cf::CFRunLoopGetCurrent());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Handle {
|
||||
fn drop(&mut self) {
|
||||
let mut state = self.0.lock();
|
||||
if let Lifecycle::Running(run_loop) = *state {
|
||||
unsafe {
|
||||
cf::CFRunLoopStop(run_loop);
|
||||
}
|
||||
}
|
||||
*state = Lifecycle::Stopped;
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronize with
|
||||
// /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/FSEvents.framework/Versions/A/Headers/FSEvents.h
|
||||
bitflags! {
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
#[repr(C)]
|
||||
pub struct StreamFlags: u32 {
|
||||
const NONE = 0x00000000;
|
||||
@@ -121,3 +361,138 @@ impl std::fmt::Display for StreamFlags {
|
||||
write!(f, "")
|
||||
}
|
||||
}
|
||||
|
||||
#[link(name = "CoreServices", kind = "framework")]
|
||||
extern "C" {
|
||||
pub fn FSEventsGetCurrentEventId() -> u64;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{fs, sync::mpsc, thread, time::Duration};
|
||||
|
||||
#[test]
|
||||
fn test_event_stream_simple() {
|
||||
for _ in 0..3 {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("test-event-stream")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let path = dir.path().canonicalize().unwrap();
|
||||
for i in 0..10 {
|
||||
fs::write(path.join(format!("existing-file-{}", i)), "").unwrap();
|
||||
}
|
||||
flush_historical_events();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
|
||||
thread::spawn(move || stream.run(move |events| tx.send(events.to_vec()).is_ok()));
|
||||
|
||||
fs::write(path.join("new-file"), "").unwrap();
|
||||
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let event = events.last().unwrap();
|
||||
assert_eq!(event.path, path.join("new-file"));
|
||||
assert!(event.flags.contains(StreamFlags::ITEM_CREATED));
|
||||
|
||||
fs::remove_file(path.join("existing-file-5")).unwrap();
|
||||
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let event = events.last().unwrap();
|
||||
assert_eq!(event.path, path.join("existing-file-5"));
|
||||
assert!(event.flags.contains(StreamFlags::ITEM_REMOVED));
|
||||
drop(handle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_stream_delayed_start() {
|
||||
for _ in 0..3 {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("test-event-stream")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let path = dir.path().canonicalize().unwrap();
|
||||
for i in 0..10 {
|
||||
fs::write(path.join(format!("existing-file-{}", i)), "").unwrap();
|
||||
}
|
||||
flush_historical_events();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
|
||||
|
||||
// Delay the call to `run` in order to make sure we don't miss any events that occur
|
||||
// between creating the `EventStream` and calling `run`.
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
stream.run(move |events| tx.send(events.to_vec()).is_ok())
|
||||
});
|
||||
|
||||
fs::write(path.join("new-file"), "").unwrap();
|
||||
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let event = events.last().unwrap();
|
||||
assert_eq!(event.path, path.join("new-file"));
|
||||
assert!(event.flags.contains(StreamFlags::ITEM_CREATED));
|
||||
|
||||
fs::remove_file(path.join("existing-file-5")).unwrap();
|
||||
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let event = events.last().unwrap();
|
||||
assert_eq!(event.path, path.join("existing-file-5"));
|
||||
assert!(event.flags.contains(StreamFlags::ITEM_REMOVED));
|
||||
drop(handle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_stream_shutdown_by_dropping_handle() {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("test-event-stream")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let path = dir.path().canonicalize().unwrap();
|
||||
flush_historical_events();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
|
||||
thread::spawn(move || {
|
||||
stream.run({
|
||||
let tx = tx.clone();
|
||||
move |_| {
|
||||
tx.send("running").unwrap();
|
||||
true
|
||||
}
|
||||
});
|
||||
tx.send("stopped").unwrap();
|
||||
});
|
||||
|
||||
fs::write(path.join("new-file"), "").unwrap();
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), "running");
|
||||
|
||||
// Dropping the handle causes `EventStream::run` to return.
|
||||
drop(handle);
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), "stopped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_stream_shutdown_before_run() {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("test-event-stream")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let path = dir.path().canonicalize().unwrap();
|
||||
|
||||
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
|
||||
drop(handle);
|
||||
|
||||
// This returns immediately because the handle was already dropped.
|
||||
stream.run(|_| true);
|
||||
}
|
||||
|
||||
fn flush_historical_events() {
|
||||
let duration = if std::env::var("CI").is_ok() {
|
||||
Duration::from_secs(2)
|
||||
} else {
|
||||
Duration::from_millis(500)
|
||||
};
|
||||
thread::sleep(duration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
use fsevent_sys::{self as fs, core_foundation as cf};
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
convert::AsRef,
|
||||
ffi::{c_void, CStr, OsStr},
|
||||
os::unix::ffi::OsStrExt,
|
||||
path::{Path, PathBuf},
|
||||
ptr, slice,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::{Event, StreamFlags};
|
||||
|
||||
pub struct EventStream {
|
||||
lifecycle: Arc<Mutex<Lifecycle>>,
|
||||
state: Box<State>,
|
||||
}
|
||||
|
||||
struct State {
|
||||
latency: Duration,
|
||||
paths: cf::CFMutableArrayRef,
|
||||
callback: Option<Box<dyn FnMut(Vec<Event>) -> bool>>,
|
||||
last_valid_event_id: Option<fs::FSEventStreamEventId>,
|
||||
stream: fs::FSEventStreamRef,
|
||||
}
|
||||
|
||||
impl Drop for State {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
cf::CFRelease(self.paths);
|
||||
fs::FSEventStreamStop(self.stream);
|
||||
fs::FSEventStreamInvalidate(self.stream);
|
||||
fs::FSEventStreamRelease(self.stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Lifecycle {
|
||||
New,
|
||||
Running(cf::CFRunLoopRef),
|
||||
Stopped,
|
||||
}
|
||||
|
||||
pub struct Handle(Arc<Mutex<Lifecycle>>);
|
||||
|
||||
unsafe impl Send for EventStream {}
|
||||
unsafe impl Send for Lifecycle {}
|
||||
|
||||
impl EventStream {
|
||||
pub fn new(paths: &[&Path], latency: Duration) -> (Self, Handle) {
|
||||
unsafe {
|
||||
let cf_paths =
|
||||
cf::CFArrayCreateMutable(cf::kCFAllocatorDefault, 0, &cf::kCFTypeArrayCallBacks);
|
||||
assert!(!cf_paths.is_null());
|
||||
|
||||
for path in paths {
|
||||
let path_bytes = path.as_os_str().as_bytes();
|
||||
let cf_url = cf::CFURLCreateFromFileSystemRepresentation(
|
||||
cf::kCFAllocatorDefault,
|
||||
path_bytes.as_ptr() as *const i8,
|
||||
path_bytes.len() as cf::CFIndex,
|
||||
false,
|
||||
);
|
||||
let cf_path = cf::CFURLCopyFileSystemPath(cf_url, cf::kCFURLPOSIXPathStyle);
|
||||
cf::CFArrayAppendValue(cf_paths, cf_path);
|
||||
cf::CFRelease(cf_path);
|
||||
cf::CFRelease(cf_url);
|
||||
}
|
||||
|
||||
let mut state = Box::new(State {
|
||||
latency,
|
||||
paths: cf_paths,
|
||||
callback: None,
|
||||
last_valid_event_id: None,
|
||||
stream: ptr::null_mut(),
|
||||
});
|
||||
let stream_context = fs::FSEventStreamContext {
|
||||
version: 0,
|
||||
info: state.as_ref() as *const _ as *mut c_void,
|
||||
retain: None,
|
||||
release: None,
|
||||
copy_description: None,
|
||||
};
|
||||
let stream = fs::FSEventStreamCreate(
|
||||
cf::kCFAllocatorDefault,
|
||||
Self::trampoline,
|
||||
&stream_context,
|
||||
cf_paths,
|
||||
FSEventsGetCurrentEventId(),
|
||||
latency.as_secs_f64(),
|
||||
fs::kFSEventStreamCreateFlagFileEvents
|
||||
| fs::kFSEventStreamCreateFlagNoDefer
|
||||
| fs::kFSEventStreamCreateFlagWatchRoot,
|
||||
);
|
||||
state.stream = stream;
|
||||
|
||||
let lifecycle = Arc::new(Mutex::new(Lifecycle::New));
|
||||
(
|
||||
EventStream {
|
||||
lifecycle: lifecycle.clone(),
|
||||
state,
|
||||
},
|
||||
Handle(lifecycle),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run<F>(mut self, f: F)
|
||||
where
|
||||
F: FnMut(Vec<Event>) -> bool + 'static,
|
||||
{
|
||||
self.state.callback = Some(Box::new(f));
|
||||
unsafe {
|
||||
let run_loop = cf::CFRunLoopGetCurrent();
|
||||
{
|
||||
let mut state = self.lifecycle.lock();
|
||||
match *state {
|
||||
Lifecycle::New => *state = Lifecycle::Running(run_loop),
|
||||
Lifecycle::Running(_) => unreachable!(),
|
||||
Lifecycle::Stopped => return,
|
||||
}
|
||||
}
|
||||
fs::FSEventStreamScheduleWithRunLoop(
|
||||
self.state.stream,
|
||||
run_loop,
|
||||
cf::kCFRunLoopDefaultMode,
|
||||
);
|
||||
fs::FSEventStreamStart(self.state.stream);
|
||||
cf::CFRunLoopRun();
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn trampoline(
|
||||
stream_ref: fs::FSEventStreamRef,
|
||||
info: *mut ::std::os::raw::c_void,
|
||||
num: usize, // size_t numEvents
|
||||
event_paths: *mut ::std::os::raw::c_void, // void *eventPaths
|
||||
event_flags: *const ::std::os::raw::c_void, // const FSEventStreamEventFlags eventFlags[]
|
||||
event_ids: *const ::std::os::raw::c_void, // const FSEventStreamEventId eventIds[]
|
||||
) {
|
||||
unsafe {
|
||||
let event_paths = event_paths as *const *const ::std::os::raw::c_char;
|
||||
let e_ptr = event_flags as *mut u32;
|
||||
let i_ptr = event_ids as *mut u64;
|
||||
let state = (info as *mut State).as_mut().unwrap();
|
||||
let callback = if let Some(callback) = state.callback.as_mut() {
|
||||
callback
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
let paths = slice::from_raw_parts(event_paths, num);
|
||||
let flags = slice::from_raw_parts_mut(e_ptr, num);
|
||||
let ids = slice::from_raw_parts_mut(i_ptr, num);
|
||||
let mut stream_restarted = false;
|
||||
|
||||
// Sometimes FSEvents reports a "dropped" event, an indication that either the kernel
|
||||
// or our code couldn't keep up with the sheer volume of file-system events that were
|
||||
// generated. If we observed a valid event before this happens, we'll try to read the
|
||||
// file-system journal by stopping the current stream and creating a new one starting at
|
||||
// such event. Otherwise, we'll let invoke the callback with the dropped event, which
|
||||
// will likely perform a re-scan of one of the root directories.
|
||||
if flags
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(StreamFlags::from_bits)
|
||||
.any(|flags| {
|
||||
flags.contains(StreamFlags::USER_DROPPED)
|
||||
|| flags.contains(StreamFlags::KERNEL_DROPPED)
|
||||
})
|
||||
{
|
||||
if let Some(last_valid_event_id) = state.last_valid_event_id.take() {
|
||||
fs::FSEventStreamStop(state.stream);
|
||||
fs::FSEventStreamInvalidate(state.stream);
|
||||
fs::FSEventStreamRelease(state.stream);
|
||||
|
||||
let stream_context = fs::FSEventStreamContext {
|
||||
version: 0,
|
||||
info,
|
||||
retain: None,
|
||||
release: None,
|
||||
copy_description: None,
|
||||
};
|
||||
let stream = fs::FSEventStreamCreate(
|
||||
cf::kCFAllocatorDefault,
|
||||
Self::trampoline,
|
||||
&stream_context,
|
||||
state.paths,
|
||||
last_valid_event_id,
|
||||
state.latency.as_secs_f64(),
|
||||
fs::kFSEventStreamCreateFlagFileEvents
|
||||
| fs::kFSEventStreamCreateFlagNoDefer
|
||||
| fs::kFSEventStreamCreateFlagWatchRoot,
|
||||
);
|
||||
|
||||
state.stream = stream;
|
||||
fs::FSEventStreamScheduleWithRunLoop(
|
||||
state.stream,
|
||||
cf::CFRunLoopGetCurrent(),
|
||||
cf::kCFRunLoopDefaultMode,
|
||||
);
|
||||
fs::FSEventStreamStart(state.stream);
|
||||
stream_restarted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !stream_restarted {
|
||||
let mut events = Vec::with_capacity(num);
|
||||
for p in 0..num {
|
||||
if let Some(flag) = StreamFlags::from_bits(flags[p]) {
|
||||
if !flag.contains(StreamFlags::HISTORY_DONE) {
|
||||
let path_c_str = CStr::from_ptr(paths[p]);
|
||||
let path = PathBuf::from(OsStr::from_bytes(path_c_str.to_bytes()));
|
||||
let event = Event {
|
||||
event_id: ids[p],
|
||||
flags: flag,
|
||||
path,
|
||||
};
|
||||
state.last_valid_event_id = Some(event.event_id);
|
||||
events.push(event);
|
||||
}
|
||||
} else {
|
||||
debug_assert!(false, "unknown flag set for fs event: {}", flags[p]);
|
||||
}
|
||||
}
|
||||
|
||||
if !events.is_empty() && !callback(events) {
|
||||
fs::FSEventStreamStop(stream_ref);
|
||||
cf::CFRunLoopStop(cf::CFRunLoopGetCurrent());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Handle {
|
||||
fn drop(&mut self) {
|
||||
let mut state = self.0.lock();
|
||||
if let Lifecycle::Running(run_loop) = *state {
|
||||
unsafe {
|
||||
cf::CFRunLoopStop(run_loop);
|
||||
}
|
||||
}
|
||||
*state = Lifecycle::Stopped;
|
||||
}
|
||||
}
|
||||
|
||||
#[link(name = "CoreServices", kind = "framework")]
|
||||
extern "C" {
|
||||
pub fn FSEventsGetCurrentEventId() -> u64;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{fs, sync::mpsc, thread, time::Duration};
|
||||
|
||||
#[test]
|
||||
fn test_event_stream_simple() {
|
||||
for _ in 0..3 {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("test-event-stream")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let path = dir.path().canonicalize().unwrap();
|
||||
for i in 0..10 {
|
||||
fs::write(path.join(format!("existing-file-{}", i)), "").unwrap();
|
||||
}
|
||||
flush_historical_events();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
|
||||
thread::spawn(move || stream.run(move |events| tx.send(events.to_vec()).is_ok()));
|
||||
|
||||
fs::write(path.join("new-file"), "").unwrap();
|
||||
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let event = events.last().unwrap();
|
||||
assert_eq!(event.path, path.join("new-file"));
|
||||
assert!(event.flags.contains(StreamFlags::ITEM_CREATED));
|
||||
|
||||
fs::remove_file(path.join("existing-file-5")).unwrap();
|
||||
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let event = events.last().unwrap();
|
||||
assert_eq!(event.path, path.join("existing-file-5"));
|
||||
assert!(event.flags.contains(StreamFlags::ITEM_REMOVED));
|
||||
drop(handle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_stream_delayed_start() {
|
||||
for _ in 0..3 {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("test-event-stream")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let path = dir.path().canonicalize().unwrap();
|
||||
for i in 0..10 {
|
||||
fs::write(path.join(format!("existing-file-{}", i)), "").unwrap();
|
||||
}
|
||||
flush_historical_events();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
|
||||
|
||||
// Delay the call to `run` in order to make sure we don't miss any events that occur
|
||||
// between creating the `EventStream` and calling `run`.
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
stream.run(move |events| tx.send(events.to_vec()).is_ok())
|
||||
});
|
||||
|
||||
fs::write(path.join("new-file"), "").unwrap();
|
||||
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let event = events.last().unwrap();
|
||||
assert_eq!(event.path, path.join("new-file"));
|
||||
assert!(event.flags.contains(StreamFlags::ITEM_CREATED));
|
||||
|
||||
fs::remove_file(path.join("existing-file-5")).unwrap();
|
||||
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let event = events.last().unwrap();
|
||||
assert_eq!(event.path, path.join("existing-file-5"));
|
||||
assert!(event.flags.contains(StreamFlags::ITEM_REMOVED));
|
||||
drop(handle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_stream_shutdown_by_dropping_handle() {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("test-event-stream")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let path = dir.path().canonicalize().unwrap();
|
||||
flush_historical_events();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
|
||||
thread::spawn(move || {
|
||||
stream.run({
|
||||
let tx = tx.clone();
|
||||
move |_| {
|
||||
tx.send("running").unwrap();
|
||||
true
|
||||
}
|
||||
});
|
||||
tx.send("stopped").unwrap();
|
||||
});
|
||||
|
||||
fs::write(path.join("new-file"), "").unwrap();
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), "running");
|
||||
|
||||
// Dropping the handle causes `EventStream::run` to return.
|
||||
drop(handle);
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), "stopped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_stream_shutdown_before_run() {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("test-event-stream")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let path = dir.path().canonicalize().unwrap();
|
||||
|
||||
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
|
||||
drop(handle);
|
||||
|
||||
// This returns immediately because the handle was already dropped.
|
||||
stream.run(|_| true);
|
||||
}
|
||||
|
||||
fn flush_historical_events() {
|
||||
let duration = if std::env::var("CI").is_ok() {
|
||||
Duration::from_secs(2)
|
||||
} else {
|
||||
Duration::from_millis(500)
|
||||
};
|
||||
thread::sleep(duration);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{iter, ops::Range};
|
||||
use sum_tree::SumTree;
|
||||
use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point};
|
||||
use text::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, Point};
|
||||
|
||||
pub use git2 as libgit;
|
||||
use libgit::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch};
|
||||
@@ -12,9 +12,20 @@ pub enum DiffHunkStatus {
|
||||
Removed,
|
||||
}
|
||||
|
||||
/// A diff hunk, representing a range of consequent lines in a singleton buffer, associated with a generic range.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiffHunk<T> {
|
||||
pub buffer_range: Range<T>,
|
||||
/// E.g. a range in multibuffer, that has an excerpt added, singleton buffer for which has this diff hunk.
|
||||
/// Consider a singleton buffer with 10 lines, all of them are modified — so a corresponding diff hunk would have a range 0..10.
|
||||
/// And a multibuffer with the excerpt of lines 2-6 from the singleton buffer.
|
||||
/// If the multibuffer is searched for diff hunks, the associated range would be multibuffer rows, corresponding to rows 2..6 from the singleton buffer.
|
||||
/// But the hunk range would be 0..10, same for any other excerpts from the same singleton buffer.
|
||||
pub associated_range: Range<T>,
|
||||
/// Singleton buffer ID this hunk belongs to.
|
||||
pub buffer_id: BufferId,
|
||||
/// A consequent range of lines in the singleton buffer, that were changed and produced this diff hunk.
|
||||
pub buffer_range: Range<Anchor>,
|
||||
/// Original singleton buffer text before the change, that was instead of the `buffer_range`.
|
||||
pub diff_base_byte_range: Range<usize>,
|
||||
}
|
||||
|
||||
@@ -22,7 +33,7 @@ impl DiffHunk<u32> {
|
||||
pub fn status(&self) -> DiffHunkStatus {
|
||||
if self.diff_base_byte_range.is_empty() {
|
||||
DiffHunkStatus::Added
|
||||
} else if self.buffer_range.is_empty() {
|
||||
} else if self.associated_range.is_empty() {
|
||||
DiffHunkStatus::Removed
|
||||
} else {
|
||||
DiffHunkStatus::Modified
|
||||
@@ -35,7 +46,7 @@ impl sum_tree::Item for DiffHunk<Anchor> {
|
||||
|
||||
fn summary(&self) -> Self::Summary {
|
||||
DiffHunkSummary {
|
||||
buffer_range: self.buffer_range.clone(),
|
||||
buffer_range: self.associated_range.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +68,7 @@ impl sum_tree::Summary for DiffHunkSummary {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BufferDiff {
|
||||
last_buffer_version: Option<clock::Global>,
|
||||
tree: SumTree<DiffHunk<Anchor>>,
|
||||
@@ -103,8 +114,11 @@ impl BufferDiff {
|
||||
})
|
||||
.flat_map(move |hunk| {
|
||||
[
|
||||
(&hunk.buffer_range.start, hunk.diff_base_byte_range.start),
|
||||
(&hunk.buffer_range.end, hunk.diff_base_byte_range.end),
|
||||
(
|
||||
&hunk.associated_range.start,
|
||||
hunk.diff_base_byte_range.start,
|
||||
),
|
||||
(&hunk.associated_range.end, hunk.diff_base_byte_range.end),
|
||||
]
|
||||
.into_iter()
|
||||
});
|
||||
@@ -112,17 +126,17 @@ impl BufferDiff {
|
||||
let mut summaries = buffer.summaries_for_anchors_with_payload::<Point, _, _>(anchor_iter);
|
||||
iter::from_fn(move || {
|
||||
let (start_point, start_base) = summaries.next()?;
|
||||
let (end_point, end_base) = summaries.next()?;
|
||||
let (mut end_point, end_base) = summaries.next()?;
|
||||
|
||||
let end_row = if end_point.column > 0 {
|
||||
end_point.row + 1
|
||||
} else {
|
||||
end_point.row
|
||||
};
|
||||
if end_point.column > 0 {
|
||||
end_point.row += 1;
|
||||
}
|
||||
|
||||
Some(DiffHunk {
|
||||
buffer_range: start_point.row..end_row,
|
||||
associated_range: start_point.row..end_point.row,
|
||||
diff_base_byte_range: start_base..end_base,
|
||||
buffer_range: buffer.anchor_before(start_point)..buffer.anchor_after(end_point),
|
||||
buffer_id: buffer.remote_id(),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -142,7 +156,7 @@ impl BufferDiff {
|
||||
cursor.prev(buffer);
|
||||
|
||||
let hunk = cursor.item()?;
|
||||
let range = hunk.buffer_range.to_point(buffer);
|
||||
let range = hunk.associated_range.to_point(buffer);
|
||||
let end_row = if range.end.column > 0 {
|
||||
range.end.row + 1
|
||||
} else {
|
||||
@@ -150,8 +164,10 @@ impl BufferDiff {
|
||||
};
|
||||
|
||||
Some(DiffHunk {
|
||||
buffer_range: range.start.row..end_row,
|
||||
associated_range: range.start.row..end_row,
|
||||
diff_base_byte_range: hunk.diff_base_byte_range.clone(),
|
||||
buffer_range: hunk.buffer_range.clone(),
|
||||
buffer_id: hunk.buffer_id,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -269,8 +285,10 @@ impl BufferDiff {
|
||||
let end = Point::new(buffer_row_range.end, 0);
|
||||
let buffer_range = buffer.anchor_before(start)..buffer.anchor_before(end);
|
||||
DiffHunk {
|
||||
associated_range: buffer_range.clone(),
|
||||
buffer_range,
|
||||
diff_base_byte_range,
|
||||
buffer_id: buffer.remote_id(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,12 +307,12 @@ pub fn assert_hunks<Iter>(
|
||||
let actual_hunks = diff_hunks
|
||||
.map(|hunk| {
|
||||
(
|
||||
hunk.buffer_range.clone(),
|
||||
hunk.associated_range.clone(),
|
||||
&diff_base[hunk.diff_base_byte_range],
|
||||
buffer
|
||||
.text_for_range(
|
||||
Point::new(hunk.buffer_range.start, 0)
|
||||
..Point::new(hunk.buffer_range.end, 0),
|
||||
Point::new(hunk.associated_range.start, 0)
|
||||
..Point::new(hunk.associated_range.end, 0),
|
||||
)
|
||||
.collect::<String>(),
|
||||
)
|
||||
|
||||
@@ -24,3 +24,13 @@ workspace.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
editor = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
indoc.workspace = true
|
||||
language = { workspace = true, features = ["test-support"] }
|
||||
menu.workspace = true
|
||||
project = { workspace = true, features = ["test-support"] }
|
||||
rope.workspace = true
|
||||
serde_json.workspace = true
|
||||
tree-sitter-rust.workspace = true
|
||||
tree-sitter-typescript.workspace = true
|
||||
workspace = { workspace = true, features = ["test-support"] }
|
||||
|
||||
100
crates/go_to_line/src/cursor_position.rs
Normal file
100
crates/go_to_line/src/cursor_position.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use editor::{Editor, ToPoint};
|
||||
use gpui::{Subscription, View, WeakView};
|
||||
use std::fmt::Write;
|
||||
use text::{Point, Selection};
|
||||
use ui::{
|
||||
div, Button, ButtonCommon, Clickable, FluentBuilder, IntoElement, LabelSize, ParentElement,
|
||||
Render, Tooltip, ViewContext,
|
||||
};
|
||||
use util::paths::FILE_ROW_COLUMN_DELIMITER;
|
||||
use workspace::{item::ItemHandle, StatusItemView, Workspace};
|
||||
|
||||
pub struct CursorPosition {
|
||||
position: Option<Point>,
|
||||
selected_count: usize,
|
||||
workspace: WeakView<Workspace>,
|
||||
_observe_active_editor: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl CursorPosition {
|
||||
pub fn new(workspace: &Workspace) -> Self {
|
||||
Self {
|
||||
position: None,
|
||||
selected_count: 0,
|
||||
workspace: workspace.weak_handle(),
|
||||
_observe_active_editor: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_position(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
|
||||
let editor = editor.read(cx);
|
||||
let buffer = editor.buffer().read(cx).snapshot(cx);
|
||||
|
||||
self.selected_count = 0;
|
||||
let mut last_selection: Option<Selection<usize>> = None;
|
||||
for selection in editor.selections.all::<usize>(cx) {
|
||||
self.selected_count += selection.end - selection.start;
|
||||
if last_selection
|
||||
.as_ref()
|
||||
.map_or(true, |last_selection| selection.id > last_selection.id)
|
||||
{
|
||||
last_selection = Some(selection);
|
||||
}
|
||||
}
|
||||
self.position = last_selection.map(|s| s.head().to_point(&buffer));
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CursorPosition {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
div().when_some(self.position, |el, position| {
|
||||
let mut text = format!(
|
||||
"{}{FILE_ROW_COLUMN_DELIMITER}{}",
|
||||
position.row + 1,
|
||||
position.column + 1
|
||||
);
|
||||
if self.selected_count > 0 {
|
||||
write!(text, " ({} selected)", self.selected_count).unwrap();
|
||||
}
|
||||
|
||||
el.child(
|
||||
Button::new("go-to-line-column", text)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener(|this, _, cx| {
|
||||
if let Some(workspace) = this.workspace.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(editor) = workspace
|
||||
.active_item(cx)
|
||||
.and_then(|item| item.act_as::<Editor>(cx))
|
||||
{
|
||||
workspace
|
||||
.toggle_modal(cx, |cx| crate::GoToLine::new(editor, cx))
|
||||
}
|
||||
});
|
||||
}
|
||||
}))
|
||||
.tooltip(|cx| Tooltip::for_action("Go to Line/Column", &crate::Toggle, cx)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl StatusItemView for CursorPosition {
|
||||
fn set_active_pane_item(
|
||||
&mut self,
|
||||
active_pane_item: Option<&dyn ItemHandle>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(editor) = active_pane_item.and_then(|item| item.act_as::<Editor>(cx)) {
|
||||
self._observe_active_editor = Some(cx.observe(&editor, Self::update_position));
|
||||
self.update_position(editor, cx);
|
||||
} else {
|
||||
self.position = None;
|
||||
self._observe_active_editor = None;
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use editor::{display_map::ToDisplayPoint, scroll::Autoscroll, Editor};
|
||||
pub mod cursor_position;
|
||||
|
||||
use editor::{scroll::Autoscroll, Editor};
|
||||
use gpui::{
|
||||
actions, div, prelude::*, AnyWindowHandle, AppContext, DismissEvent, EventEmitter, FocusHandle,
|
||||
FocusableView, Render, SharedString, Styled, Subscription, View, ViewContext, VisualContext,
|
||||
@@ -32,6 +34,8 @@ impl FocusableView for GoToLine {
|
||||
}
|
||||
impl EventEmitter<DismissEvent> for GoToLine {}
|
||||
|
||||
enum GoToLineRowHighlights {}
|
||||
|
||||
impl GoToLine {
|
||||
fn register(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
|
||||
let handle = cx.view().downgrade();
|
||||
@@ -49,20 +53,24 @@ impl GoToLine {
|
||||
}
|
||||
|
||||
pub fn new(active_editor: View<Editor>, cx: &mut ViewContext<Self>) -> Self {
|
||||
let line_editor = cx.new_view(|cx| Editor::single_line(cx));
|
||||
let editor = active_editor.read(cx);
|
||||
let cursor = editor.selections.last::<Point>(cx).head();
|
||||
|
||||
let line = cursor.row + 1;
|
||||
let column = cursor.column + 1;
|
||||
|
||||
let line_editor = cx.new_view(|cx| {
|
||||
let mut editor = Editor::single_line(cx);
|
||||
editor.set_placeholder_text(format!("{line}{FILE_ROW_COLUMN_DELIMITER}{column}"), cx);
|
||||
editor
|
||||
});
|
||||
let line_editor_change = cx.subscribe(&line_editor, Self::on_line_editor_event);
|
||||
|
||||
let editor = active_editor.read(cx);
|
||||
let cursor = editor.selections.last::<Point>(cx).head();
|
||||
let last_line = editor.buffer().read(cx).snapshot(cx).max_point().row;
|
||||
let scroll_position = active_editor.update(cx, |editor, cx| editor.scroll_position(cx));
|
||||
|
||||
let current_text = format!(
|
||||
"line {} of {} (column {})",
|
||||
cursor.row + 1,
|
||||
last_line + 1,
|
||||
cursor.column + 1,
|
||||
);
|
||||
let current_text = format!("line {} of {} (column {})", line, last_line + 1, column);
|
||||
|
||||
Self {
|
||||
line_editor,
|
||||
@@ -78,7 +86,7 @@ impl GoToLine {
|
||||
.update(cx, |_, cx| {
|
||||
let scroll_position = self.prev_scroll_position.take();
|
||||
self.active_editor.update(cx, |editor, cx| {
|
||||
editor.highlight_rows(None);
|
||||
editor.clear_row_highlights::<GoToLineRowHighlights>();
|
||||
if let Some(scroll_position) = scroll_position {
|
||||
editor.set_scroll_position(scroll_position, cx);
|
||||
}
|
||||
@@ -106,9 +114,13 @@ impl GoToLine {
|
||||
self.active_editor.update(cx, |active_editor, cx| {
|
||||
let snapshot = active_editor.snapshot(cx).display_snapshot;
|
||||
let point = snapshot.buffer_snapshot.clip_point(point, Bias::Left);
|
||||
let display_point = point.to_display_point(&snapshot);
|
||||
let row = display_point.row();
|
||||
active_editor.highlight_rows(Some(row..row + 1));
|
||||
let anchor = snapshot.buffer_snapshot.anchor_before(point);
|
||||
active_editor.clear_row_highlights::<GoToLineRowHighlights>();
|
||||
active_editor.highlight_rows::<GoToLineRowHighlights>(
|
||||
anchor..anchor,
|
||||
Some(cx.theme().colors().editor_highlighted_line_background),
|
||||
cx,
|
||||
);
|
||||
active_editor.request_autoscroll(Autoscroll::center(), cx);
|
||||
});
|
||||
cx.notify();
|
||||
@@ -116,17 +128,22 @@ impl GoToLine {
|
||||
}
|
||||
|
||||
fn point_from_query(&self, cx: &ViewContext<Self>) -> Option<Point> {
|
||||
let line_editor = self.line_editor.read(cx).text(cx);
|
||||
let mut components = line_editor
|
||||
let (row, column) = self.line_column_from_query(cx);
|
||||
Some(Point::new(
|
||||
row?.saturating_sub(1),
|
||||
column.unwrap_or(0).saturating_sub(1),
|
||||
))
|
||||
}
|
||||
|
||||
fn line_column_from_query(&self, cx: &ViewContext<Self>) -> (Option<u32>, Option<u32>) {
|
||||
let input = self.line_editor.read(cx).text(cx);
|
||||
let mut components = input
|
||||
.splitn(2, FILE_ROW_COLUMN_DELIMITER)
|
||||
.map(str::trim)
|
||||
.fuse();
|
||||
let row = components.next().and_then(|row| row.parse::<u32>().ok())?;
|
||||
let row = components.next().and_then(|row| row.parse::<u32>().ok());
|
||||
let column = components.next().and_then(|col| col.parse::<u32>().ok());
|
||||
Some(Point::new(
|
||||
row.saturating_sub(1),
|
||||
column.unwrap_or(0).saturating_sub(1),
|
||||
))
|
||||
(row, column)
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
|
||||
@@ -153,6 +170,16 @@ impl GoToLine {
|
||||
|
||||
impl Render for GoToLine {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let mut help_text = self.current_text.clone();
|
||||
let query = self.line_column_from_query(cx);
|
||||
if let Some(line) = query.0 {
|
||||
if let Some(column) = query.1 {
|
||||
help_text = format!("Go to line {line}, column {column}").into();
|
||||
} else {
|
||||
help_text = format!("Go to line {line}").into();
|
||||
}
|
||||
}
|
||||
|
||||
div()
|
||||
.elevation_2(cx)
|
||||
.key_context("GoToLine")
|
||||
@@ -181,8 +208,182 @@ impl Render for GoToLine {
|
||||
.justify_between()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(Label::new(self.current_text.clone()).color(Color::Muted)),
|
||||
.child(Label::new(help_text).color(Color::Muted)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{TestAppContext, VisualTestContext};
|
||||
use indoc::indoc;
|
||||
use project::{FakeFs, Project};
|
||||
use serde_json::json;
|
||||
use workspace::{AppState, Workspace};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_go_to_line_view_row_highlights(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
"/dir",
|
||||
json!({
|
||||
"a.rs": indoc!{"
|
||||
struct SingleLine; // display line 0
|
||||
// display line 1
|
||||
struct MultiLine { // display line 2
|
||||
field_1: i32, // display line 3
|
||||
field_2: i32, // display line 4
|
||||
} // display line 5
|
||||
// display line 7
|
||||
struct Another { // display line 8
|
||||
field_1: i32, // display line 9
|
||||
field_2: i32, // display line 10
|
||||
field_3: i32, // display line 11
|
||||
field_4: i32, // display line 12
|
||||
} // display line 13
|
||||
"}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, ["/dir".as_ref()], cx).await;
|
||||
let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
|
||||
let worktree_id = workspace.update(cx, |workspace, cx| {
|
||||
workspace.project().update(cx, |project, cx| {
|
||||
project.worktrees().next().unwrap().read(cx).id()
|
||||
})
|
||||
});
|
||||
let _buffer = project
|
||||
.update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let editor = workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.open_path((worktree_id, "a.rs"), None, true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.downcast::<Editor>()
|
||||
.unwrap();
|
||||
|
||||
let go_to_line_view = open_go_to_line_view(&workspace, cx);
|
||||
assert_eq!(
|
||||
highlighted_display_rows(&editor, cx),
|
||||
Vec::<u32>::new(),
|
||||
"Initially opened go to line modal should not highlight any rows"
|
||||
);
|
||||
assert_single_caret_at_row(&editor, 0, cx);
|
||||
|
||||
cx.simulate_input("1");
|
||||
assert_eq!(
|
||||
highlighted_display_rows(&editor, cx),
|
||||
vec![0],
|
||||
"Go to line modal should highlight a row, corresponding to the query"
|
||||
);
|
||||
assert_single_caret_at_row(&editor, 0, cx);
|
||||
|
||||
cx.simulate_input("8");
|
||||
assert_eq!(
|
||||
highlighted_display_rows(&editor, cx),
|
||||
vec![13],
|
||||
"If the query is too large, the last row should be highlighted"
|
||||
);
|
||||
assert_single_caret_at_row(&editor, 0, cx);
|
||||
|
||||
cx.dispatch_action(menu::Cancel);
|
||||
drop(go_to_line_view);
|
||||
editor.update(cx, |_, _| {});
|
||||
assert_eq!(
|
||||
highlighted_display_rows(&editor, cx),
|
||||
Vec::<u32>::new(),
|
||||
"After cancelling and closing the modal, no rows should be highlighted"
|
||||
);
|
||||
assert_single_caret_at_row(&editor, 0, cx);
|
||||
|
||||
let go_to_line_view = open_go_to_line_view(&workspace, cx);
|
||||
assert_eq!(
|
||||
highlighted_display_rows(&editor, cx),
|
||||
Vec::<u32>::new(),
|
||||
"Reopened modal should not highlight any rows"
|
||||
);
|
||||
assert_single_caret_at_row(&editor, 0, cx);
|
||||
|
||||
let expected_highlighted_row = 4;
|
||||
cx.simulate_input("5");
|
||||
assert_eq!(
|
||||
highlighted_display_rows(&editor, cx),
|
||||
vec![expected_highlighted_row]
|
||||
);
|
||||
assert_single_caret_at_row(&editor, 0, cx);
|
||||
cx.dispatch_action(menu::Confirm);
|
||||
drop(go_to_line_view);
|
||||
editor.update(cx, |_, _| {});
|
||||
assert_eq!(
|
||||
highlighted_display_rows(&editor, cx),
|
||||
Vec::<u32>::new(),
|
||||
"After confirming and closing the modal, no rows should be highlighted"
|
||||
);
|
||||
// On confirm, should place the caret on the highlighted row.
|
||||
assert_single_caret_at_row(&editor, expected_highlighted_row, cx);
|
||||
}
|
||||
|
||||
fn open_go_to_line_view(
|
||||
workspace: &View<Workspace>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> View<GoToLine> {
|
||||
cx.dispatch_action(Toggle::default());
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.active_modal::<GoToLine>(cx).unwrap().clone()
|
||||
})
|
||||
}
|
||||
|
||||
fn highlighted_display_rows(editor: &View<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.highlighted_display_rows(cx).into_keys().collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_single_caret_at_row(
|
||||
editor: &View<Editor>,
|
||||
buffer_row: u32,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
let selections = editor.update(cx, |editor, cx| {
|
||||
editor
|
||||
.selections
|
||||
.all::<rope::Point>(cx)
|
||||
.into_iter()
|
||||
.map(|s| s.start..s.end)
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert!(
|
||||
selections.len() == 1,
|
||||
"Expected one caret selection but got: {selections:?}"
|
||||
);
|
||||
let selection = &selections[0];
|
||||
assert!(
|
||||
selection.start == selection.end,
|
||||
"Expected a single caret selection, but got: {selection:?}"
|
||||
);
|
||||
assert_eq!(selection.start.row, buffer_row);
|
||||
}
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
|
||||
cx.update(|cx| {
|
||||
let state = AppState::test(cx);
|
||||
language::init(cx);
|
||||
crate::init(cx);
|
||||
editor::init(cx);
|
||||
workspace::init_settings(cx);
|
||||
Project::init_settings(cx);
|
||||
state
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,20 +103,24 @@ blade-macros.workspace = true
|
||||
blade-rwh.workspace = true
|
||||
bytemuck = "1"
|
||||
cosmic-text = "0.10.0"
|
||||
copypasta = "0.10.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
open = "5.0.1"
|
||||
ashpd = "0.7.0"
|
||||
xcb = { version = "1.3", features = ["as-raw-xcb-connection", "randr", "xkb"] }
|
||||
wayland-client= { version = "0.31.2" }
|
||||
wayland-client = { version = "0.31.2" }
|
||||
wayland-cursor = "0.31.1"
|
||||
wayland-protocols = { version = "0.31.2", features = ["client", "staging", "unstable"] }
|
||||
wayland-protocols = { version = "0.31.2", features = [
|
||||
"client",
|
||||
"staging",
|
||||
"unstable",
|
||||
] }
|
||||
wayland-backend = { version = "0.3.3", features = ["client_system"] }
|
||||
xkbcommon = { version = "0.7", features = ["wayland", "x11"] }
|
||||
as-raw-xcb-connection = "1"
|
||||
calloop = "0.12.4"
|
||||
calloop-wayland-source = "0.2.0"
|
||||
copypasta = "0.10.1"
|
||||
oo7 = "0.3.0"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
@@ -129,3 +133,7 @@ path = "examples/hello_world.rs"
|
||||
[[example]]
|
||||
name = "image"
|
||||
path = "examples/image/image.rs"
|
||||
|
||||
[[example]]
|
||||
name = "set_menus"
|
||||
path = "examples/set_menus.rs"
|
||||
|
||||
43
crates/gpui/examples/set_menus.rs
Normal file
43
crates/gpui/examples/set_menus.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use gpui::*;
|
||||
|
||||
struct SetMenus;
|
||||
|
||||
impl Render for SetMenus {
|
||||
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.flex()
|
||||
.bg(rgb(0x2e7d32))
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.text_xl()
|
||||
.text_color(rgb(0xffffff))
|
||||
.child("Set Menus Example")
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
App::new().run(|cx: &mut AppContext| {
|
||||
// Bring the menu bar to the foreground (so you can see the menu bar)
|
||||
cx.activate(true);
|
||||
// Register the `quit` function so it can be referenced by the `MenuItem::action` in the menu bar
|
||||
cx.on_action(quit);
|
||||
// Add menu items
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "set_menus",
|
||||
items: vec![MenuItem::action("Quit", Quit)],
|
||||
}]);
|
||||
cx.open_window(WindowOptions::default(), |cx| {
|
||||
cx.new_view(|_cx| SetMenus {})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Associate actions using the `actions!` macro (or `impl_actions!` macro)
|
||||
actions!(set_menus, [Quit]);
|
||||
|
||||
// Define the quit function that is registered with the AppContext
|
||||
fn quit(_: &Quit, cx: &mut AppContext) {
|
||||
println!("Gracefully quitting the application . . .");
|
||||
cx.quit();
|
||||
}
|
||||
@@ -20,7 +20,6 @@ pub use async_context::*;
|
||||
use collections::{FxHashMap, FxHashSet, VecDeque};
|
||||
pub use entity_map::*;
|
||||
pub use model_context::*;
|
||||
use refineable::Refineable;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub use test_context::*;
|
||||
use util::{
|
||||
@@ -28,14 +27,14 @@ use util::{
|
||||
ResultExt,
|
||||
};
|
||||
|
||||
use crate::WindowAppearance;
|
||||
use crate::{
|
||||
current_platform, image_cache::ImageCache, init_app_menus, Action, ActionRegistry, Any,
|
||||
AnyView, AnyWindowHandle, AppMetadata, AssetSource, BackgroundExecutor, ClipboardItem, Context,
|
||||
DispatchPhase, Entity, EventEmitter, ForegroundExecutor, Global, KeyBinding, Keymap, Keystroke,
|
||||
LayoutId, Menu, PathPromptOptions, Pixels, Platform, PlatformDisplay, Point, Render,
|
||||
SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextStyle, TextStyleRefinement,
|
||||
TextSystem, View, ViewContext, Window, WindowContext, WindowHandle, WindowId,
|
||||
LayoutId, Menu, PathPromptOptions, Pixels, Platform, PlatformDisplay, Point, PromptBuilder,
|
||||
PromptHandle, PromptLevel, Render, RenderablePromptHandle, SharedString, SubscriberSet,
|
||||
Subscription, SvgRenderer, Task, TextSystem, View, ViewContext, Window, WindowAppearance,
|
||||
WindowContext, WindowHandle, WindowId,
|
||||
};
|
||||
|
||||
mod async_context;
|
||||
@@ -216,7 +215,6 @@ pub struct AppContext {
|
||||
pub(crate) svg_renderer: SvgRenderer,
|
||||
asset_source: Arc<dyn AssetSource>,
|
||||
pub(crate) image_cache: ImageCache,
|
||||
pub(crate) text_style_stack: Vec<TextStyleRefinement>,
|
||||
pub(crate) globals_by_type: FxHashMap<TypeId, Box<dyn Any>>,
|
||||
pub(crate) entities: EntityMap,
|
||||
pub(crate) new_view_observers: SubscriberSet<TypeId, NewViewListener>,
|
||||
@@ -237,6 +235,7 @@ pub struct AppContext {
|
||||
pub(crate) quit_observers: SubscriberSet<(), QuitHandler>,
|
||||
pub(crate) layout_id_buffer: Vec<LayoutId>, // We recycle this memory across layout requests.
|
||||
pub(crate) propagate_event: bool,
|
||||
pub(crate) prompt_builder: Option<PromptBuilder>,
|
||||
}
|
||||
|
||||
impl AppContext {
|
||||
@@ -277,7 +276,6 @@ impl AppContext {
|
||||
svg_renderer: SvgRenderer::new(asset_source.clone()),
|
||||
asset_source,
|
||||
image_cache: ImageCache::new(http_client),
|
||||
text_style_stack: Vec::new(),
|
||||
globals_by_type: FxHashMap::default(),
|
||||
entities,
|
||||
new_view_observers: SubscriberSet::new(),
|
||||
@@ -296,6 +294,7 @@ impl AppContext {
|
||||
quit_observers: SubscriberSet::new(),
|
||||
layout_id_buffer: Default::default(),
|
||||
propagate_event: true,
|
||||
prompt_builder: Some(PromptBuilder::Default),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -827,15 +826,6 @@ impl AppContext {
|
||||
&self.text_system
|
||||
}
|
||||
|
||||
/// The current text style. Which is composed of all the style refinements provided to `with_text_style`.
|
||||
pub fn text_style(&self) -> TextStyle {
|
||||
let mut style = TextStyle::default();
|
||||
for refinement in &self.text_style_stack {
|
||||
style.refine(refinement);
|
||||
}
|
||||
style
|
||||
}
|
||||
|
||||
/// Check whether a global of the given type has been assigned.
|
||||
pub fn has_global<G: Global>(&self) -> bool {
|
||||
self.globals_by_type.contains_key(&TypeId::of::<G>())
|
||||
@@ -1019,14 +1009,6 @@ impl AppContext {
|
||||
inner(&mut self.keystroke_observers, Box::new(f))
|
||||
}
|
||||
|
||||
pub(crate) fn push_text_style(&mut self, text_style: TextStyleRefinement) {
|
||||
self.text_style_stack.push(text_style);
|
||||
}
|
||||
|
||||
pub(crate) fn pop_text_style(&mut self) {
|
||||
self.text_style_stack.pop();
|
||||
}
|
||||
|
||||
/// Register key bindings.
|
||||
pub fn bind_keys(&mut self, bindings: impl IntoIterator<Item = KeyBinding>) {
|
||||
self.keymap.borrow_mut().add_bindings(bindings);
|
||||
@@ -1125,16 +1107,19 @@ impl AppContext {
|
||||
/// Checks if the given action is bound in the current context, as defined by the app's current focus,
|
||||
/// the bindings in the element tree, and any global action listeners.
|
||||
pub fn is_action_available(&mut self, action: &dyn Action) -> bool {
|
||||
let mut action_available = false;
|
||||
if let Some(window) = self.active_window() {
|
||||
if let Ok(window_action_available) =
|
||||
window.update(self, |_, cx| cx.is_action_available(action))
|
||||
{
|
||||
return window_action_available;
|
||||
action_available = window_action_available;
|
||||
}
|
||||
}
|
||||
|
||||
self.global_action_listeners
|
||||
.contains_key(&action.as_any().type_id())
|
||||
action_available
|
||||
|| self
|
||||
.global_action_listeners
|
||||
.contains_key(&action.as_any().type_id())
|
||||
}
|
||||
|
||||
/// Sets the menu bar for this application. This will replace any existing menu bar.
|
||||
@@ -1150,14 +1135,41 @@ impl AppContext {
|
||||
.update(self, |_, cx| cx.dispatch_action(action.boxed_clone()))
|
||||
.log_err();
|
||||
} else {
|
||||
self.propagate_event = true;
|
||||
self.dispatch_global_action(action);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_global_action(&mut self, action: &dyn Action) {
|
||||
self.propagate_event = true;
|
||||
|
||||
if let Some(mut global_listeners) = self
|
||||
.global_action_listeners
|
||||
.remove(&action.as_any().type_id())
|
||||
{
|
||||
for listener in &global_listeners {
|
||||
listener(action.as_any(), DispatchPhase::Capture, self);
|
||||
if !self.propagate_event {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
global_listeners.extend(
|
||||
self.global_action_listeners
|
||||
.remove(&action.as_any().type_id())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
|
||||
self.global_action_listeners
|
||||
.insert(action.as_any().type_id(), global_listeners);
|
||||
}
|
||||
|
||||
if self.propagate_event {
|
||||
if let Some(mut global_listeners) = self
|
||||
.global_action_listeners
|
||||
.remove(&action.as_any().type_id())
|
||||
{
|
||||
for listener in &global_listeners {
|
||||
listener(action.as_any(), DispatchPhase::Capture, self);
|
||||
for listener in global_listeners.iter().rev() {
|
||||
listener(action.as_any(), DispatchPhase::Bubble, self);
|
||||
if !self.propagate_event {
|
||||
break;
|
||||
}
|
||||
@@ -1172,29 +1184,6 @@ impl AppContext {
|
||||
self.global_action_listeners
|
||||
.insert(action.as_any().type_id(), global_listeners);
|
||||
}
|
||||
|
||||
if self.propagate_event {
|
||||
if let Some(mut global_listeners) = self
|
||||
.global_action_listeners
|
||||
.remove(&action.as_any().type_id())
|
||||
{
|
||||
for listener in global_listeners.iter().rev() {
|
||||
listener(action.as_any(), DispatchPhase::Bubble, self);
|
||||
if !self.propagate_event {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
global_listeners.extend(
|
||||
self.global_action_listeners
|
||||
.remove(&action.as_any().type_id())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
|
||||
self.global_action_listeners
|
||||
.insert(action.as_any().type_id(), global_listeners);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1202,6 +1191,23 @@ impl AppContext {
|
||||
pub fn has_active_drag(&self) -> bool {
|
||||
self.active_drag.is_some()
|
||||
}
|
||||
|
||||
/// Set the prompt renderer for GPUI. This will replace the default or platform specific
|
||||
/// prompts with this custom implementation.
|
||||
pub fn set_prompt_builder(
|
||||
&mut self,
|
||||
renderer: impl Fn(
|
||||
PromptLevel,
|
||||
&str,
|
||||
Option<&str>,
|
||||
&[&str],
|
||||
PromptHandle,
|
||||
&mut WindowContext,
|
||||
) -> RenderablePromptHandle
|
||||
+ 'static,
|
||||
) {
|
||||
self.prompt_builder = Some(PromptBuilder::Custom(Box::new(renderer)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Context for AppContext {
|
||||
|
||||
@@ -674,17 +674,10 @@ impl VisualTestContext {
|
||||
f: impl FnOnce(&mut WindowContext) -> AnyElement,
|
||||
) {
|
||||
self.update(|cx| {
|
||||
let entity_id = cx
|
||||
.window
|
||||
.root_view
|
||||
.as_ref()
|
||||
.expect("Can't draw to this window without a root view")
|
||||
.entity_id();
|
||||
|
||||
cx.with_element_context(|cx| {
|
||||
cx.with_view_id(entity_id, |cx| {
|
||||
f(cx).draw(origin, space, cx);
|
||||
})
|
||||
let mut element = f(cx);
|
||||
element.layout(origin, space, cx);
|
||||
element.paint(cx);
|
||||
});
|
||||
|
||||
cx.refresh();
|
||||
|
||||
292
crates/gpui/src/bounds_tree.rs
Normal file
292
crates/gpui/src/bounds_tree.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
use crate::{Bounds, Half};
|
||||
use std::{
|
||||
cmp,
|
||||
fmt::Debug,
|
||||
ops::{Add, Sub},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct BoundsTree<U>
|
||||
where
|
||||
U: Default + Clone + Debug,
|
||||
{
|
||||
root: Option<usize>,
|
||||
nodes: Vec<Node<U>>,
|
||||
stack: Vec<usize>,
|
||||
}
|
||||
|
||||
impl<U> BoundsTree<U>
|
||||
where
|
||||
U: Clone + Debug + PartialOrd + Add<U, Output = U> + Sub<Output = U> + Half + Default,
|
||||
{
|
||||
pub fn clear(&mut self) {
|
||||
self.root = None;
|
||||
self.nodes.clear();
|
||||
self.stack.clear();
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, new_bounds: Bounds<U>) -> u32 {
|
||||
// If the tree is empty, make the root the new leaf.
|
||||
if self.root.is_none() {
|
||||
let new_node = self.push_leaf(new_bounds, 1);
|
||||
self.root = Some(new_node);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Search for the best place to add the new leaf based on heuristics.
|
||||
let mut max_intersecting_ordering = 0;
|
||||
let mut index = self.root.unwrap();
|
||||
while let Node::Internal {
|
||||
left,
|
||||
right,
|
||||
bounds: node_bounds,
|
||||
..
|
||||
} = &mut self.nodes[index]
|
||||
{
|
||||
let left = *left;
|
||||
let right = *right;
|
||||
*node_bounds = node_bounds.union(&new_bounds);
|
||||
self.stack.push(index);
|
||||
|
||||
// Descend to the best-fit child, based on which one would increase
|
||||
// the surface area the least. This attempts to keep the tree balanced
|
||||
// in terms of surface area. If there is an intersection with the other child,
|
||||
// add its keys to the intersections vector.
|
||||
let left_cost = new_bounds
|
||||
.union(&self.nodes[left].bounds())
|
||||
.half_perimeter();
|
||||
let right_cost = new_bounds
|
||||
.union(&self.nodes[right].bounds())
|
||||
.half_perimeter();
|
||||
if left_cost < right_cost {
|
||||
max_intersecting_ordering =
|
||||
self.find_max_ordering(right, &new_bounds, max_intersecting_ordering);
|
||||
index = left;
|
||||
} else {
|
||||
max_intersecting_ordering =
|
||||
self.find_max_ordering(left, &new_bounds, max_intersecting_ordering);
|
||||
index = right;
|
||||
}
|
||||
}
|
||||
|
||||
// We've found a leaf ('index' now refers to a leaf node).
|
||||
// We'll insert a new parent node above the leaf and attach our new leaf to it.
|
||||
let sibling = index;
|
||||
|
||||
// Check for collision with the located leaf node
|
||||
let Node::Leaf {
|
||||
bounds: sibling_bounds,
|
||||
order: sibling_ordering,
|
||||
..
|
||||
} = &self.nodes[index]
|
||||
else {
|
||||
unreachable!();
|
||||
};
|
||||
if sibling_bounds.intersects(&new_bounds) {
|
||||
max_intersecting_ordering = cmp::max(max_intersecting_ordering, *sibling_ordering);
|
||||
}
|
||||
|
||||
let ordering = max_intersecting_ordering + 1;
|
||||
let new_node = self.push_leaf(new_bounds, ordering);
|
||||
let new_parent = self.push_internal(sibling, new_node);
|
||||
|
||||
// If there was an old parent, we need to update its children indices.
|
||||
if let Some(old_parent) = self.stack.last().copied() {
|
||||
let Node::Internal { left, right, .. } = &mut self.nodes[old_parent] else {
|
||||
unreachable!();
|
||||
};
|
||||
|
||||
if *left == sibling {
|
||||
*left = new_parent;
|
||||
} else {
|
||||
*right = new_parent;
|
||||
}
|
||||
} else {
|
||||
// If the old parent was the root, the new parent is the new root.
|
||||
self.root = Some(new_parent);
|
||||
}
|
||||
|
||||
for node_index in self.stack.drain(..) {
|
||||
let Node::Internal {
|
||||
max_order: max_ordering,
|
||||
..
|
||||
} = &mut self.nodes[node_index]
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
*max_ordering = cmp::max(*max_ordering, ordering);
|
||||
}
|
||||
|
||||
ordering
|
||||
}
|
||||
|
||||
fn find_max_ordering(&self, index: usize, bounds: &Bounds<U>, mut max_ordering: u32) -> u32 {
|
||||
match &self.nodes[index] {
|
||||
Node::Leaf {
|
||||
bounds: node_bounds,
|
||||
order: ordering,
|
||||
..
|
||||
} => {
|
||||
if bounds.intersects(node_bounds) {
|
||||
max_ordering = cmp::max(*ordering, max_ordering);
|
||||
}
|
||||
}
|
||||
Node::Internal {
|
||||
left,
|
||||
right,
|
||||
bounds: node_bounds,
|
||||
max_order: node_max_ordering,
|
||||
..
|
||||
} => {
|
||||
if bounds.intersects(node_bounds) && max_ordering < *node_max_ordering {
|
||||
let left_max_ordering = self.nodes[*left].max_ordering();
|
||||
let right_max_ordering = self.nodes[*right].max_ordering();
|
||||
if left_max_ordering > right_max_ordering {
|
||||
max_ordering = self.find_max_ordering(*left, bounds, max_ordering);
|
||||
max_ordering = self.find_max_ordering(*right, bounds, max_ordering);
|
||||
} else {
|
||||
max_ordering = self.find_max_ordering(*right, bounds, max_ordering);
|
||||
max_ordering = self.find_max_ordering(*left, bounds, max_ordering);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
max_ordering
|
||||
}
|
||||
|
||||
fn push_leaf(&mut self, bounds: Bounds<U>, order: u32) -> usize {
|
||||
self.nodes.push(Node::Leaf { bounds, order });
|
||||
self.nodes.len() - 1
|
||||
}
|
||||
|
||||
fn push_internal(&mut self, left: usize, right: usize) -> usize {
|
||||
let left_node = &self.nodes[left];
|
||||
let right_node = &self.nodes[right];
|
||||
let new_bounds = left_node.bounds().union(right_node.bounds());
|
||||
let max_ordering = cmp::max(left_node.max_ordering(), right_node.max_ordering());
|
||||
self.nodes.push(Node::Internal {
|
||||
bounds: new_bounds,
|
||||
left,
|
||||
right,
|
||||
max_order: max_ordering,
|
||||
});
|
||||
self.nodes.len() - 1
|
||||
}
|
||||
}
|
||||
|
||||
impl<U> Default for BoundsTree<U>
|
||||
where
|
||||
U: Default + Clone + Debug,
|
||||
{
|
||||
fn default() -> Self {
|
||||
BoundsTree {
|
||||
root: None,
|
||||
nodes: Vec::new(),
|
||||
stack: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Node<U>
|
||||
where
|
||||
U: Clone + Default + Debug,
|
||||
{
|
||||
Leaf {
|
||||
bounds: Bounds<U>,
|
||||
order: u32,
|
||||
},
|
||||
Internal {
|
||||
left: usize,
|
||||
right: usize,
|
||||
bounds: Bounds<U>,
|
||||
max_order: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl<U> Node<U>
|
||||
where
|
||||
U: Clone + Default + Debug,
|
||||
{
|
||||
fn bounds(&self) -> &Bounds<U> {
|
||||
match self {
|
||||
Node::Leaf { bounds, .. } => bounds,
|
||||
Node::Internal { bounds, .. } => bounds,
|
||||
}
|
||||
}
|
||||
|
||||
fn max_ordering(&self) -> u32 {
|
||||
match self {
|
||||
Node::Leaf {
|
||||
order: ordering, ..
|
||||
} => *ordering,
|
||||
Node::Internal {
|
||||
max_order: max_ordering,
|
||||
..
|
||||
} => *max_ordering,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Bounds, Point, Size};
|
||||
|
||||
#[test]
|
||||
fn test_insert() {
|
||||
let mut tree = BoundsTree::<f32>::default();
|
||||
let bounds1 = Bounds {
|
||||
origin: Point { x: 0.0, y: 0.0 },
|
||||
size: Size {
|
||||
width: 10.0,
|
||||
height: 10.0,
|
||||
},
|
||||
};
|
||||
let bounds2 = Bounds {
|
||||
origin: Point { x: 5.0, y: 5.0 },
|
||||
size: Size {
|
||||
width: 10.0,
|
||||
height: 10.0,
|
||||
},
|
||||
};
|
||||
let bounds3 = Bounds {
|
||||
origin: Point { x: 10.0, y: 10.0 },
|
||||
size: Size {
|
||||
width: 10.0,
|
||||
height: 10.0,
|
||||
},
|
||||
};
|
||||
|
||||
// Insert the bounds into the tree and verify the order is correct
|
||||
assert_eq!(tree.insert(bounds1), 1);
|
||||
assert_eq!(tree.insert(bounds2), 2);
|
||||
assert_eq!(tree.insert(bounds3), 3);
|
||||
|
||||
// Insert non-overlapping bounds and verify they can reuse orders
|
||||
let bounds4 = Bounds {
|
||||
origin: Point { x: 20.0, y: 20.0 },
|
||||
size: Size {
|
||||
width: 10.0,
|
||||
height: 10.0,
|
||||
},
|
||||
};
|
||||
let bounds5 = Bounds {
|
||||
origin: Point { x: 40.0, y: 40.0 },
|
||||
size: Size {
|
||||
width: 10.0,
|
||||
height: 10.0,
|
||||
},
|
||||
};
|
||||
let bounds6 = Bounds {
|
||||
origin: Point { x: 25.0, y: 25.0 },
|
||||
size: Size {
|
||||
width: 10.0,
|
||||
height: 10.0,
|
||||
},
|
||||
};
|
||||
assert_eq!(tree.insert(bounds4), 1); // bounds4 does not overlap with bounds1, bounds2, or bounds3
|
||||
assert_eq!(tree.insert(bounds5), 1); // bounds5 does not overlap with any other bounds
|
||||
assert_eq!(tree.insert(bounds6), 2); // bounds6 overlaps with bounds4, so it should have a different order
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,16 @@ pub fn transparent_black() -> Hsla {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque grey in [`Hsla`], values will be clamped to the range [0, 1]
|
||||
pub fn opaque_grey(lightness: f32, opacity: f32) -> Hsla {
|
||||
Hsla {
|
||||
h: 0.,
|
||||
s: 0.,
|
||||
l: lightness.clamp(0., 1.),
|
||||
a: opacity.clamp(0., 1.),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure white in [`Hsla`]
|
||||
pub fn white() -> Hsla {
|
||||
Hsla {
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
//!
|
||||
//! But some state is too simple and voluminous to store in every view that needs it, e.g.
|
||||
//! whether a hover has been started or not. For this, GPUI provides the [`Element::State`], associated type.
|
||||
//! If an element returns an [`ElementId`] from [`IntoElement::element_id()`], and that element id
|
||||
//! appears in the same place relative to other views and ElementIds in the frame, then the previous
|
||||
//! frame's state will be passed to the element's layout and paint methods.
|
||||
//!
|
||||
//! # Implementing your own elements
|
||||
//!
|
||||
@@ -35,33 +32,48 @@
|
||||
//! your own custom layout algorithm or rendering a code editor.
|
||||
|
||||
use crate::{
|
||||
util::FluentBuilder, ArenaBox, AvailableSpace, Bounds, ElementContext, ElementId, LayoutId,
|
||||
Pixels, Point, Size, ViewContext, WindowContext, ELEMENT_ARENA,
|
||||
util::FluentBuilder, ArenaBox, AvailableSpace, Bounds, DispatchNodeId, ElementContext,
|
||||
ElementId, LayoutId, Pixels, Point, Size, ViewContext, WindowContext, ELEMENT_ARENA,
|
||||
};
|
||||
use derive_more::{Deref, DerefMut};
|
||||
pub(crate) use smallvec::SmallVec;
|
||||
use std::{any::Any, fmt::Debug, ops::DerefMut};
|
||||
use std::{any::Any, fmt::Debug, mem, ops::DerefMut};
|
||||
|
||||
/// Implemented by types that participate in laying out and painting the contents of a window.
|
||||
/// Elements form a tree and are laid out according to web-based layout rules, as implemented by Taffy.
|
||||
/// You can create custom elements by implementing this trait, see the module-level documentation
|
||||
/// for more details.
|
||||
pub trait Element: 'static + IntoElement {
|
||||
/// The type of state to store for this element between frames. See the module-level documentation
|
||||
/// for details.
|
||||
type State: 'static;
|
||||
/// The type of state returned from [`Element::before_layout`]. A mutable reference to this state is subsequently
|
||||
/// provided to [`Element::after_layout`] and [`Element::paint`].
|
||||
type BeforeLayout: 'static;
|
||||
|
||||
/// The type of state returned from [`Element::after_layout`]. A mutable reference to this state is subsequently
|
||||
/// provided to [`Element::paint`].
|
||||
type AfterLayout: 'static;
|
||||
|
||||
/// Before an element can be painted, we need to know where it's going to be and how big it is.
|
||||
/// Use this method to request a layout from Taffy and initialize the element's state.
|
||||
fn request_layout(
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout);
|
||||
|
||||
/// After laying out an element, we need to commit its bounds to the current frame for hitbox
|
||||
/// purposes. The state argument is the same state that was returned from [`Element::before_layout()`].
|
||||
fn after_layout(
|
||||
&mut self,
|
||||
state: Option<Self::State>,
|
||||
bounds: Bounds<Pixels>,
|
||||
before_layout: &mut Self::BeforeLayout,
|
||||
cx: &mut ElementContext,
|
||||
) -> (LayoutId, Self::State);
|
||||
) -> Self::AfterLayout;
|
||||
|
||||
/// Once layout has been completed, this method will be called to paint the element to the screen.
|
||||
/// The state argument is the same state that was returned from [`Element::request_layout()`].
|
||||
fn paint(&mut self, bounds: Bounds<Pixels>, state: &mut Self::State, cx: &mut ElementContext);
|
||||
/// The state argument is the same state that was returned from [`Element::before_layout()`].
|
||||
fn paint(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
before_layout: &mut Self::BeforeLayout,
|
||||
after_layout: &mut Self::AfterLayout,
|
||||
cx: &mut ElementContext,
|
||||
);
|
||||
|
||||
/// Convert this element into a dynamically-typed [`AnyElement`].
|
||||
fn into_any(self) -> AnyElement {
|
||||
@@ -75,10 +87,6 @@ pub trait IntoElement: Sized {
|
||||
/// Useful for converting other types into elements automatically, like Strings
|
||||
type Element: Element;
|
||||
|
||||
/// The [`ElementId`] of self once converted into an [`Element`].
|
||||
/// If present, the resulting element's state will be carried across frames.
|
||||
fn element_id(&self) -> Option<ElementId>;
|
||||
|
||||
/// Convert self into a type that implements [`Element`].
|
||||
fn into_element(self) -> Self::Element;
|
||||
|
||||
@@ -86,41 +94,6 @@ pub trait IntoElement: Sized {
|
||||
fn into_any_element(self) -> AnyElement {
|
||||
self.into_element().into_any()
|
||||
}
|
||||
|
||||
/// Convert into an element, then draw in the current window at the given origin.
|
||||
/// The available space argument is provided to the layout engine to determine the size of the
|
||||
// root element. Once the element is drawn, its associated element state is yielded to the
|
||||
// given callback.
|
||||
fn draw_and_update_state<T, R>(
|
||||
self,
|
||||
origin: Point<Pixels>,
|
||||
available_space: Size<T>,
|
||||
cx: &mut ElementContext,
|
||||
f: impl FnOnce(&mut <Self::Element as Element>::State, &mut ElementContext) -> R,
|
||||
) -> R
|
||||
where
|
||||
T: Clone + Default + Debug + Into<AvailableSpace>,
|
||||
{
|
||||
let element = self.into_element();
|
||||
let element_id = element.element_id();
|
||||
let element = DrawableElement {
|
||||
element: Some(element),
|
||||
phase: ElementDrawPhase::Start,
|
||||
};
|
||||
|
||||
let frame_state =
|
||||
DrawableElement::draw(element, origin, available_space.map(Into::into), cx);
|
||||
|
||||
if let Some(mut frame_state) = frame_state {
|
||||
f(&mut frame_state, cx)
|
||||
} else {
|
||||
cx.with_element_state(element_id.unwrap(), |element_state, cx| {
|
||||
let mut element_state = element_state.unwrap();
|
||||
let result = f(&mut element_state, cx);
|
||||
(result, element_state)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: IntoElement> FluentBuilder for T {}
|
||||
@@ -188,24 +161,36 @@ impl<C: RenderOnce> Component<C> {
|
||||
}
|
||||
|
||||
impl<C: RenderOnce> Element for Component<C> {
|
||||
type State = AnyElement;
|
||||
type BeforeLayout = AnyElement;
|
||||
type AfterLayout = ();
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<Self::State>,
|
||||
cx: &mut ElementContext,
|
||||
) -> (LayoutId, Self::State) {
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
|
||||
let mut element = self
|
||||
.0
|
||||
.take()
|
||||
.unwrap()
|
||||
.render(cx.deref_mut())
|
||||
.into_any_element();
|
||||
let layout_id = element.request_layout(cx);
|
||||
let layout_id = element.before_layout(cx);
|
||||
(layout_id, element)
|
||||
}
|
||||
|
||||
fn paint(&mut self, _: Bounds<Pixels>, element: &mut Self::State, cx: &mut ElementContext) {
|
||||
fn after_layout(
|
||||
&mut self,
|
||||
_: Bounds<Pixels>,
|
||||
element: &mut AnyElement,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
element.after_layout(cx);
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Bounds<Pixels>,
|
||||
element: &mut Self::BeforeLayout,
|
||||
_: &mut Self::AfterLayout,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
element.paint(cx)
|
||||
}
|
||||
}
|
||||
@@ -213,10 +198,6 @@ impl<C: RenderOnce> Element for Component<C> {
|
||||
impl<C: RenderOnce> IntoElement for Component<C> {
|
||||
type Element = Self;
|
||||
|
||||
fn element_id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
@@ -227,9 +208,11 @@ impl<C: RenderOnce> IntoElement for Component<C> {
|
||||
pub(crate) struct GlobalElementId(SmallVec<[ElementId; 32]>);
|
||||
|
||||
trait ElementObject {
|
||||
fn element_id(&self) -> Option<ElementId>;
|
||||
fn inner_element(&mut self) -> &mut dyn Any;
|
||||
|
||||
fn request_layout(&mut self, cx: &mut ElementContext) -> LayoutId;
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> LayoutId;
|
||||
|
||||
fn after_layout(&mut self, cx: &mut ElementContext);
|
||||
|
||||
fn paint(&mut self, cx: &mut ElementContext);
|
||||
|
||||
@@ -238,110 +221,102 @@ trait ElementObject {
|
||||
available_space: Size<AvailableSpace>,
|
||||
cx: &mut ElementContext,
|
||||
) -> Size<Pixels>;
|
||||
|
||||
fn draw(
|
||||
&mut self,
|
||||
origin: Point<Pixels>,
|
||||
available_space: Size<AvailableSpace>,
|
||||
cx: &mut ElementContext,
|
||||
);
|
||||
}
|
||||
|
||||
/// A wrapper around an implementer of [`Element`] that allows it to be drawn in a window.
|
||||
pub(crate) struct DrawableElement<E: Element> {
|
||||
element: Option<E>,
|
||||
phase: ElementDrawPhase<E::State>,
|
||||
pub struct Drawable<E: Element> {
|
||||
/// The drawn element.
|
||||
pub element: E,
|
||||
phase: ElementDrawPhase<E::BeforeLayout, E::AfterLayout>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
enum ElementDrawPhase<S> {
|
||||
enum ElementDrawPhase<BeforeLayout, AfterLayout> {
|
||||
#[default]
|
||||
Start,
|
||||
LayoutRequested {
|
||||
BeforeLayout {
|
||||
layout_id: LayoutId,
|
||||
frame_state: Option<S>,
|
||||
before_layout: BeforeLayout,
|
||||
},
|
||||
LayoutComputed {
|
||||
layout_id: LayoutId,
|
||||
available_space: Size<AvailableSpace>,
|
||||
frame_state: Option<S>,
|
||||
before_layout: BeforeLayout,
|
||||
},
|
||||
AfterLayout {
|
||||
node_id: DispatchNodeId,
|
||||
bounds: Bounds<Pixels>,
|
||||
before_layout: BeforeLayout,
|
||||
after_layout: AfterLayout,
|
||||
},
|
||||
Painted,
|
||||
}
|
||||
|
||||
/// A wrapper around an implementer of [`Element`] that allows it to be drawn in a window.
|
||||
impl<E: Element> DrawableElement<E> {
|
||||
impl<E: Element> Drawable<E> {
|
||||
fn new(element: E) -> Self {
|
||||
DrawableElement {
|
||||
element: Some(element),
|
||||
Drawable {
|
||||
element,
|
||||
phase: ElementDrawPhase::Start,
|
||||
}
|
||||
}
|
||||
|
||||
fn element_id(&self) -> Option<ElementId> {
|
||||
self.element.as_ref()?.element_id()
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> LayoutId {
|
||||
match mem::take(&mut self.phase) {
|
||||
ElementDrawPhase::Start => {
|
||||
let (layout_id, before_layout) = self.element.before_layout(cx);
|
||||
self.phase = ElementDrawPhase::BeforeLayout {
|
||||
layout_id,
|
||||
before_layout,
|
||||
};
|
||||
layout_id
|
||||
}
|
||||
_ => panic!("must call before_layout only once"),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_layout(&mut self, cx: &mut ElementContext) -> LayoutId {
|
||||
let (layout_id, frame_state) = if let Some(id) = self.element.as_ref().unwrap().element_id()
|
||||
{
|
||||
let layout_id = cx.with_element_state(id, |element_state, cx| {
|
||||
self.element
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.request_layout(element_state, cx)
|
||||
});
|
||||
(layout_id, None)
|
||||
} else {
|
||||
let (layout_id, frame_state) = self.element.as_mut().unwrap().request_layout(None, cx);
|
||||
(layout_id, Some(frame_state))
|
||||
};
|
||||
|
||||
self.phase = ElementDrawPhase::LayoutRequested {
|
||||
layout_id,
|
||||
frame_state,
|
||||
};
|
||||
layout_id
|
||||
}
|
||||
|
||||
fn paint(mut self, cx: &mut ElementContext) -> Option<E::State> {
|
||||
match self.phase {
|
||||
ElementDrawPhase::LayoutRequested {
|
||||
fn after_layout(&mut self, cx: &mut ElementContext) {
|
||||
match mem::take(&mut self.phase) {
|
||||
ElementDrawPhase::BeforeLayout {
|
||||
layout_id,
|
||||
frame_state,
|
||||
mut before_layout,
|
||||
}
|
||||
| ElementDrawPhase::LayoutComputed {
|
||||
layout_id,
|
||||
frame_state,
|
||||
mut before_layout,
|
||||
..
|
||||
} => {
|
||||
let bounds = cx.layout_bounds(layout_id);
|
||||
|
||||
if let Some(mut frame_state) = frame_state {
|
||||
self.element
|
||||
.take()
|
||||
.unwrap()
|
||||
.paint(bounds, &mut frame_state, cx);
|
||||
Some(frame_state)
|
||||
} else {
|
||||
let element_id = self
|
||||
.element
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.element_id()
|
||||
.expect("if we don't have frame state, we should have element state");
|
||||
cx.with_element_state(element_id, |element_state, cx| {
|
||||
let mut element_state = element_state.unwrap();
|
||||
self.element
|
||||
.take()
|
||||
.unwrap()
|
||||
.paint(bounds, &mut element_state, cx);
|
||||
((), element_state)
|
||||
});
|
||||
None
|
||||
}
|
||||
let node_id = cx.window.next_frame.dispatch_tree.push_node();
|
||||
let after_layout = self.element.after_layout(bounds, &mut before_layout, cx);
|
||||
self.phase = ElementDrawPhase::AfterLayout {
|
||||
node_id,
|
||||
bounds,
|
||||
before_layout,
|
||||
after_layout,
|
||||
};
|
||||
cx.window.next_frame.dispatch_tree.pop_node();
|
||||
}
|
||||
_ => panic!("must call before_layout before after_layout"),
|
||||
}
|
||||
}
|
||||
|
||||
_ => panic!("must call layout before paint"),
|
||||
fn paint(&mut self, cx: &mut ElementContext) -> E::BeforeLayout {
|
||||
match mem::take(&mut self.phase) {
|
||||
ElementDrawPhase::AfterLayout {
|
||||
node_id,
|
||||
bounds,
|
||||
mut before_layout,
|
||||
mut after_layout,
|
||||
..
|
||||
} => {
|
||||
cx.window.next_frame.dispatch_tree.set_active_node(node_id);
|
||||
self.element
|
||||
.paint(bounds, &mut before_layout, &mut after_layout, cx);
|
||||
self.phase = ElementDrawPhase::Painted;
|
||||
before_layout
|
||||
}
|
||||
_ => panic!("must call after_layout before paint"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,66 +326,63 @@ impl<E: Element> DrawableElement<E> {
|
||||
cx: &mut ElementContext,
|
||||
) -> Size<Pixels> {
|
||||
if matches!(&self.phase, ElementDrawPhase::Start) {
|
||||
self.request_layout(cx);
|
||||
self.before_layout(cx);
|
||||
}
|
||||
|
||||
let layout_id = match &mut self.phase {
|
||||
ElementDrawPhase::LayoutRequested {
|
||||
let layout_id = match mem::take(&mut self.phase) {
|
||||
ElementDrawPhase::BeforeLayout {
|
||||
layout_id,
|
||||
frame_state,
|
||||
before_layout,
|
||||
} => {
|
||||
cx.compute_layout(*layout_id, available_space);
|
||||
let layout_id = *layout_id;
|
||||
cx.compute_layout(layout_id, available_space);
|
||||
self.phase = ElementDrawPhase::LayoutComputed {
|
||||
layout_id,
|
||||
available_space,
|
||||
frame_state: frame_state.take(),
|
||||
before_layout,
|
||||
};
|
||||
layout_id
|
||||
}
|
||||
ElementDrawPhase::LayoutComputed {
|
||||
layout_id,
|
||||
available_space: prev_available_space,
|
||||
..
|
||||
before_layout,
|
||||
} => {
|
||||
if available_space != *prev_available_space {
|
||||
cx.compute_layout(*layout_id, available_space);
|
||||
*prev_available_space = available_space;
|
||||
if available_space != prev_available_space {
|
||||
cx.compute_layout(layout_id, available_space);
|
||||
}
|
||||
*layout_id
|
||||
self.phase = ElementDrawPhase::LayoutComputed {
|
||||
layout_id,
|
||||
available_space,
|
||||
before_layout,
|
||||
};
|
||||
layout_id
|
||||
}
|
||||
_ => panic!("cannot measure after painting"),
|
||||
};
|
||||
|
||||
cx.layout_bounds(layout_id).size
|
||||
}
|
||||
|
||||
fn draw(
|
||||
mut self,
|
||||
origin: Point<Pixels>,
|
||||
available_space: Size<AvailableSpace>,
|
||||
cx: &mut ElementContext,
|
||||
) -> Option<E::State> {
|
||||
self.measure(available_space, cx);
|
||||
cx.with_absolute_element_offset(origin, |cx| self.paint(cx))
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> ElementObject for Option<DrawableElement<E>>
|
||||
impl<E> ElementObject for Drawable<E>
|
||||
where
|
||||
E: Element,
|
||||
E::State: 'static,
|
||||
E::BeforeLayout: 'static,
|
||||
{
|
||||
fn element_id(&self) -> Option<ElementId> {
|
||||
self.as_ref().unwrap().element_id()
|
||||
fn inner_element(&mut self) -> &mut dyn Any {
|
||||
&mut self.element
|
||||
}
|
||||
|
||||
fn request_layout(&mut self, cx: &mut ElementContext) -> LayoutId {
|
||||
DrawableElement::request_layout(self.as_mut().unwrap(), cx)
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> LayoutId {
|
||||
Drawable::before_layout(self, cx)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, cx: &mut ElementContext) {
|
||||
Drawable::after_layout(self, cx);
|
||||
}
|
||||
|
||||
fn paint(&mut self, cx: &mut ElementContext) {
|
||||
DrawableElement::paint(self.take().unwrap(), cx);
|
||||
Drawable::paint(self, cx);
|
||||
}
|
||||
|
||||
fn measure(
|
||||
@@ -418,16 +390,7 @@ where
|
||||
available_space: Size<AvailableSpace>,
|
||||
cx: &mut ElementContext,
|
||||
) -> Size<Pixels> {
|
||||
DrawableElement::measure(self.as_mut().unwrap(), available_space, cx)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&mut self,
|
||||
origin: Point<Pixels>,
|
||||
available_space: Size<AvailableSpace>,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
DrawableElement::draw(self.take().unwrap(), origin, available_space, cx);
|
||||
Drawable::measure(self, available_space, cx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,18 +401,28 @@ impl AnyElement {
|
||||
pub(crate) fn new<E>(element: E) -> Self
|
||||
where
|
||||
E: 'static + Element,
|
||||
E::State: Any,
|
||||
E::BeforeLayout: Any,
|
||||
{
|
||||
let element = ELEMENT_ARENA
|
||||
.with_borrow_mut(|arena| arena.alloc(|| Some(DrawableElement::new(element))))
|
||||
.with_borrow_mut(|arena| arena.alloc(|| Drawable::new(element)))
|
||||
.map(|element| element as &mut dyn ElementObject);
|
||||
AnyElement(element)
|
||||
}
|
||||
|
||||
/// Attempt to downcast a reference to the boxed element to a specific type.
|
||||
pub fn downcast_mut<T: 'static>(&mut self) -> Option<&mut T> {
|
||||
self.0.inner_element().downcast_mut::<T>()
|
||||
}
|
||||
|
||||
/// Request the layout ID of the element stored in this `AnyElement`.
|
||||
/// Used for laying out child elements in a parent element.
|
||||
pub fn request_layout(&mut self, cx: &mut ElementContext) -> LayoutId {
|
||||
self.0.request_layout(cx)
|
||||
pub fn before_layout(&mut self, cx: &mut ElementContext) -> LayoutId {
|
||||
self.0.before_layout(cx)
|
||||
}
|
||||
|
||||
/// Commits the element bounds of this [AnyElement] for hitbox purposes.
|
||||
pub fn after_layout(&mut self, cx: &mut ElementContext) {
|
||||
self.0.after_layout(cx)
|
||||
}
|
||||
|
||||
/// Paints the element stored in this `AnyElement`.
|
||||
@@ -466,35 +439,44 @@ impl AnyElement {
|
||||
self.0.measure(available_space, cx)
|
||||
}
|
||||
|
||||
/// Initializes this element and performs layout in the available space, then paints it at the given origin.
|
||||
pub fn draw(
|
||||
/// Initializes this element, performs layout if needed and commits its bounds for hitbox purposes.
|
||||
pub fn layout(
|
||||
&mut self,
|
||||
origin: Point<Pixels>,
|
||||
absolute_offset: Point<Pixels>,
|
||||
available_space: Size<AvailableSpace>,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
self.0.draw(origin, available_space, cx)
|
||||
}
|
||||
|
||||
/// Returns the element ID of the element stored in this `AnyElement`, if any.
|
||||
pub fn inner_id(&self) -> Option<ElementId> {
|
||||
self.0.element_id()
|
||||
) -> Size<Pixels> {
|
||||
let size = self.measure(available_space, cx);
|
||||
cx.with_absolute_element_offset(absolute_offset, |cx| self.after_layout(cx));
|
||||
size
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for AnyElement {
|
||||
type State = ();
|
||||
type BeforeLayout = ();
|
||||
type AfterLayout = ();
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<Self::State>,
|
||||
cx: &mut ElementContext,
|
||||
) -> (LayoutId, Self::State) {
|
||||
let layout_id = self.request_layout(cx);
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
|
||||
let layout_id = self.before_layout(cx);
|
||||
(layout_id, ())
|
||||
}
|
||||
|
||||
fn paint(&mut self, _: Bounds<Pixels>, _: &mut Self::State, cx: &mut ElementContext) {
|
||||
fn after_layout(
|
||||
&mut self,
|
||||
_: Bounds<Pixels>,
|
||||
_: &mut Self::BeforeLayout,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
self.after_layout(cx)
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Bounds<Pixels>,
|
||||
_: &mut Self::BeforeLayout,
|
||||
_: &mut Self::AfterLayout,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
self.paint(cx)
|
||||
}
|
||||
}
|
||||
@@ -502,10 +484,6 @@ impl Element for AnyElement {
|
||||
impl IntoElement for AnyElement {
|
||||
type Element = Self;
|
||||
|
||||
fn element_id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
@@ -521,30 +499,32 @@ pub struct Empty;
|
||||
impl IntoElement for Empty {
|
||||
type Element = Self;
|
||||
|
||||
fn element_id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Empty {
|
||||
type State = ();
|
||||
type BeforeLayout = ();
|
||||
type AfterLayout = ();
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_state: Option<Self::State>,
|
||||
cx: &mut ElementContext,
|
||||
) -> (LayoutId, Self::State) {
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
|
||||
(cx.request_layout(&crate::Style::default(), None), ())
|
||||
}
|
||||
|
||||
fn after_layout(
|
||||
&mut self,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_state: &mut Self::BeforeLayout,
|
||||
_cx: &mut ElementContext,
|
||||
) {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_state: &mut Self::State,
|
||||
_before_layout: &mut Self::BeforeLayout,
|
||||
_after_layout: &mut Self::AfterLayout,
|
||||
_cx: &mut ElementContext,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -4,54 +4,68 @@ use crate::{Bounds, Element, ElementContext, IntoElement, Pixels, Style, StyleRe
|
||||
|
||||
/// Construct a canvas element with the given paint callback.
|
||||
/// Useful for adding short term custom drawing to a view.
|
||||
pub fn canvas(callback: impl 'static + FnOnce(&Bounds<Pixels>, &mut ElementContext)) -> Canvas {
|
||||
pub fn canvas<T>(
|
||||
after_layout: impl 'static + FnOnce(Bounds<Pixels>, &mut ElementContext) -> T,
|
||||
paint: impl 'static + FnOnce(Bounds<Pixels>, T, &mut ElementContext),
|
||||
) -> Canvas<T> {
|
||||
Canvas {
|
||||
paint_callback: Some(Box::new(callback)),
|
||||
after_layout: Some(Box::new(after_layout)),
|
||||
paint: Some(Box::new(paint)),
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A canvas element, meant for accessing the low level paint API without defining a whole
|
||||
/// custom element
|
||||
pub struct Canvas {
|
||||
paint_callback: Option<Box<dyn FnOnce(&Bounds<Pixels>, &mut ElementContext)>>,
|
||||
pub struct Canvas<T> {
|
||||
after_layout: Option<Box<dyn FnOnce(Bounds<Pixels>, &mut ElementContext) -> T>>,
|
||||
paint: Option<Box<dyn FnOnce(Bounds<Pixels>, T, &mut ElementContext)>>,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl IntoElement for Canvas {
|
||||
impl<T: 'static> IntoElement for Canvas<T> {
|
||||
type Element = Self;
|
||||
|
||||
fn element_id(&self) -> Option<crate::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Canvas {
|
||||
type State = Style;
|
||||
impl<T: 'static> Element for Canvas<T> {
|
||||
type BeforeLayout = Style;
|
||||
type AfterLayout = Option<T>;
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<Self::State>,
|
||||
cx: &mut ElementContext,
|
||||
) -> (crate::LayoutId, Self::State) {
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> (crate::LayoutId, Self::BeforeLayout) {
|
||||
let mut style = Style::default();
|
||||
style.refine(&self.style);
|
||||
let layout_id = cx.request_layout(&style, []);
|
||||
(layout_id, style)
|
||||
}
|
||||
|
||||
fn paint(&mut self, bounds: Bounds<Pixels>, style: &mut Style, cx: &mut ElementContext) {
|
||||
fn after_layout(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
_before_layout: &mut Style,
|
||||
cx: &mut ElementContext,
|
||||
) -> Option<T> {
|
||||
Some(self.after_layout.take().unwrap()(bounds, cx))
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
style: &mut Style,
|
||||
after_layout: &mut Self::AfterLayout,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
let after_layout = after_layout.take().unwrap();
|
||||
style.paint(bounds, cx, |cx| {
|
||||
(self.paint_callback.take().unwrap())(&bounds, cx)
|
||||
(self.paint.take().unwrap())(bounds, after_layout, cx)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Canvas {
|
||||
impl<T> Styled for Canvas<T> {
|
||||
fn style(&mut self) -> &mut crate::StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,8 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
point, size, Bounds, DevicePixels, Element, ElementContext, ImageData, InteractiveElement,
|
||||
InteractiveElementState, Interactivity, IntoElement, LayoutId, Pixels, SharedUri, Size,
|
||||
point, size, Bounds, DevicePixels, Element, ElementContext, Hitbox, ImageData,
|
||||
InteractiveElement, Interactivity, IntoElement, LayoutId, Pixels, SharedUri, Size,
|
||||
StyleRefinement, Styled, UriOrPath,
|
||||
};
|
||||
use futures::FutureExt;
|
||||
@@ -88,86 +88,85 @@ impl Img {
|
||||
}
|
||||
|
||||
impl Element for Img {
|
||||
type State = InteractiveElementState;
|
||||
type BeforeLayout = ();
|
||||
type AfterLayout = Option<Hitbox>;
|
||||
|
||||
fn request_layout(
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
|
||||
let layout_id = self
|
||||
.interactivity
|
||||
.before_layout(cx, |style, cx| cx.request_layout(&style, []));
|
||||
(layout_id, ())
|
||||
}
|
||||
|
||||
fn after_layout(
|
||||
&mut self,
|
||||
element_state: Option<Self::State>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_before_layout: &mut Self::BeforeLayout,
|
||||
cx: &mut ElementContext,
|
||||
) -> (LayoutId, Self::State) {
|
||||
) -> Option<Hitbox> {
|
||||
self.interactivity
|
||||
.layout(element_state, cx, |style, cx| cx.request_layout(&style, []))
|
||||
.after_layout(bounds, bounds.size, cx, |_, _, hitbox, _| hitbox)
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
element_state: &mut Self::State,
|
||||
_: &mut Self::BeforeLayout,
|
||||
hitbox: &mut Self::AfterLayout,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
let source = self.source.clone();
|
||||
self.interactivity.paint(
|
||||
bounds,
|
||||
bounds.size,
|
||||
element_state,
|
||||
cx,
|
||||
|style, _scroll_offset, cx| {
|
||||
self.interactivity
|
||||
.paint(bounds, hitbox.as_ref(), cx, |style, cx| {
|
||||
let corner_radii = style.corner_radii.to_pixels(bounds.size, cx.rem_size());
|
||||
cx.with_z_index(1, |cx| {
|
||||
match source {
|
||||
ImageSource::Uri(_) | ImageSource::File(_) => {
|
||||
let uri_or_path: UriOrPath = match source {
|
||||
ImageSource::Uri(uri) => uri.into(),
|
||||
ImageSource::File(path) => path.into(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
match source {
|
||||
ImageSource::Uri(_) | ImageSource::File(_) => {
|
||||
let uri_or_path: UriOrPath = match source {
|
||||
ImageSource::Uri(uri) => uri.into(),
|
||||
ImageSource::File(path) => path.into(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let image_future = cx.image_cache.get(uri_or_path.clone(), cx);
|
||||
if let Some(data) = image_future
|
||||
.clone()
|
||||
.now_or_never()
|
||||
.and_then(|result| result.ok())
|
||||
{
|
||||
let new_bounds = preserve_aspect_ratio(bounds, data.size());
|
||||
cx.paint_image(new_bounds, corner_radii, data, self.grayscale)
|
||||
.log_err();
|
||||
} else {
|
||||
cx.spawn(|mut cx| async move {
|
||||
if image_future.await.ok().is_some() {
|
||||
cx.on_next_frame(|cx| cx.refresh());
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
ImageSource::Data(data) => {
|
||||
let image_future = cx.image_cache.get(uri_or_path.clone(), cx);
|
||||
if let Some(data) = image_future
|
||||
.clone()
|
||||
.now_or_never()
|
||||
.and_then(|result| result.ok())
|
||||
{
|
||||
let new_bounds = preserve_aspect_ratio(bounds, data.size());
|
||||
cx.paint_image(new_bounds, corner_radii, data, self.grayscale)
|
||||
.log_err();
|
||||
} else {
|
||||
cx.spawn(|mut cx| async move {
|
||||
if image_future.await.ok().is_some() {
|
||||
cx.on_next_frame(|cx| cx.refresh());
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
ImageSource::Surface(surface) => {
|
||||
let size = size(surface.width().into(), surface.height().into());
|
||||
let new_bounds = preserve_aspect_ratio(bounds, size);
|
||||
// TODO: Add support for corner_radii and grayscale.
|
||||
cx.paint_surface(new_bounds, surface);
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
)
|
||||
ImageSource::Data(data) => {
|
||||
let new_bounds = preserve_aspect_ratio(bounds, data.size());
|
||||
cx.paint_image(new_bounds, corner_radii, data, self.grayscale)
|
||||
.log_err();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
ImageSource::Surface(surface) => {
|
||||
let size = size(surface.width().into(), surface.height().into());
|
||||
let new_bounds = preserve_aspect_ratio(bounds, size);
|
||||
// TODO: Add support for corner_radii and grayscale.
|
||||
cx.paint_surface(new_bounds, surface);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for Img {
|
||||
type Element = Self;
|
||||
|
||||
fn element_id(&self) -> Option<crate::ElementId> {
|
||||
self.interactivity.element_id.clone()
|
||||
}
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use crate::{
|
||||
point, px, size, AnyElement, AvailableSpace, Bounds, ContentMask, DispatchPhase, Edges,
|
||||
Element, ElementContext, IntoElement, Pixels, Point, ScrollWheelEvent, Size, Style,
|
||||
Element, ElementContext, Hitbox, IntoElement, Pixels, Point, ScrollWheelEvent, Size, Style,
|
||||
StyleRefinement, Styled, WindowContext,
|
||||
};
|
||||
use collections::VecDeque;
|
||||
@@ -96,6 +96,12 @@ struct LayoutItemsResponse {
|
||||
item_elements: VecDeque<AnyElement>,
|
||||
}
|
||||
|
||||
/// Frame state used by the [List] element after layout.
|
||||
pub struct ListAfterLayoutState {
|
||||
hitbox: Hitbox,
|
||||
layout: LayoutItemsResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum ListItem {
|
||||
Unrendered,
|
||||
@@ -302,7 +308,6 @@ impl StateInner {
|
||||
height: Pixels,
|
||||
delta: Point<Pixels>,
|
||||
cx: &mut WindowContext,
|
||||
padding: Edges<Pixels>,
|
||||
) {
|
||||
// Drop scroll events after a reset, since we can't calculate
|
||||
// the new logical scroll top without the item heights
|
||||
@@ -310,6 +315,7 @@ impl StateInner {
|
||||
return;
|
||||
}
|
||||
|
||||
let padding = self.last_padding.unwrap_or_default();
|
||||
let scroll_max =
|
||||
(self.items.summary().height + padding.top + padding.bottom - height).max(px(0.));
|
||||
let new_scroll_top = (self.scroll_top(scroll_top) - delta.y)
|
||||
@@ -516,13 +522,13 @@ pub struct ListOffset {
|
||||
}
|
||||
|
||||
impl Element for List {
|
||||
type State = ();
|
||||
type BeforeLayout = ();
|
||||
type AfterLayout = ListAfterLayoutState;
|
||||
|
||||
fn request_layout(
|
||||
fn before_layout(
|
||||
&mut self,
|
||||
_state: Option<Self::State>,
|
||||
cx: &mut crate::ElementContext,
|
||||
) -> (crate::LayoutId, Self::State) {
|
||||
) -> (crate::LayoutId, Self::BeforeLayout) {
|
||||
let layout_id = match self.sizing_behavior {
|
||||
ListSizingBehavior::Infer => {
|
||||
let mut style = Style::default();
|
||||
@@ -583,18 +589,20 @@ impl Element for List {
|
||||
(layout_id, ())
|
||||
}
|
||||
|
||||
fn paint(
|
||||
fn after_layout(
|
||||
&mut self,
|
||||
bounds: Bounds<crate::Pixels>,
|
||||
_state: &mut Self::State,
|
||||
cx: &mut crate::ElementContext,
|
||||
) {
|
||||
bounds: Bounds<Pixels>,
|
||||
_: &mut Self::BeforeLayout,
|
||||
cx: &mut ElementContext,
|
||||
) -> ListAfterLayoutState {
|
||||
let state = &mut *self.state.0.borrow_mut();
|
||||
state.reset = false;
|
||||
|
||||
let mut style = Style::default();
|
||||
style.refine(&self.style);
|
||||
|
||||
let hitbox = cx.insert_hitbox(bounds, false);
|
||||
|
||||
// If the width of the list has changed, invalidate all cached item heights
|
||||
if state.last_layout_bounds.map_or(true, |last_bounds| {
|
||||
last_bounds.size.width != bounds.size.width
|
||||
@@ -615,33 +623,46 @@ impl Element for List {
|
||||
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
|
||||
let mut item_origin = bounds.origin + Point::new(px(0.), padding.top);
|
||||
item_origin.y -= layout_response.scroll_top.offset_in_item;
|
||||
for item_element in &mut layout_response.item_elements {
|
||||
let item_height = item_element
|
||||
.measure(layout_response.available_item_space, cx)
|
||||
.height;
|
||||
item_element.draw(item_origin, layout_response.available_item_space, cx);
|
||||
item_origin.y += item_height;
|
||||
for mut item_element in &mut layout_response.item_elements {
|
||||
let item_size = item_element.measure(layout_response.available_item_space, cx);
|
||||
item_element.layout(item_origin, layout_response.available_item_space, cx);
|
||||
item_origin.y += item_size.height;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
state.last_layout_bounds = Some(bounds);
|
||||
state.last_padding = Some(padding);
|
||||
ListAfterLayoutState {
|
||||
hitbox,
|
||||
layout: layout_response,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
bounds: Bounds<crate::Pixels>,
|
||||
_: &mut Self::BeforeLayout,
|
||||
after_layout: &mut Self::AfterLayout,
|
||||
cx: &mut crate::ElementContext,
|
||||
) {
|
||||
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
|
||||
for item in &mut after_layout.layout.item_elements {
|
||||
item.paint(cx);
|
||||
}
|
||||
});
|
||||
|
||||
let list_state = self.state.clone();
|
||||
let height = bounds.size.height;
|
||||
|
||||
let scroll_top = after_layout.layout.scroll_top;
|
||||
let hitbox_id = after_layout.hitbox.id;
|
||||
cx.on_mouse_event(move |event: &ScrollWheelEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& bounds.contains(&event.position)
|
||||
&& cx.was_top_layer(&event.position, cx.stacking_order())
|
||||
{
|
||||
if phase == DispatchPhase::Bubble && hitbox_id.is_hovered(cx) {
|
||||
list_state.0.borrow_mut().scroll(
|
||||
&layout_response.scroll_top,
|
||||
&scroll_top,
|
||||
height,
|
||||
event.delta.pixel_delta(px(20.)),
|
||||
cx,
|
||||
padding,
|
||||
)
|
||||
}
|
||||
});
|
||||
@@ -651,10 +672,6 @@ impl Element for List {
|
||||
impl IntoElement for List {
|
||||
type Element = Self;
|
||||
|
||||
fn element_id(&self) -> Option<crate::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
@@ -761,7 +778,7 @@ mod test {
|
||||
cx.draw(
|
||||
point(px(0.), px(0.)),
|
||||
size(px(100.), px(20.)).into(),
|
||||
|_| list(state.clone()).w_full().h_full().z_index(10).into_any(),
|
||||
|_| list(state.clone()).w_full().h_full().into_any(),
|
||||
);
|
||||
|
||||
// Reset
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
/// The state that the overlay element uses to track its children.
|
||||
pub struct OverlayState {
|
||||
child_layout_ids: SmallVec<[LayoutId; 4]>,
|
||||
offset: Point<Pixels>,
|
||||
}
|
||||
|
||||
/// An overlay element that can be used to display UI that
|
||||
@@ -69,17 +70,14 @@ impl ParentElement for Overlay {
|
||||
}
|
||||
|
||||
impl Element for Overlay {
|
||||
type State = OverlayState;
|
||||
type BeforeLayout = OverlayState;
|
||||
type AfterLayout = ();
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<Self::State>,
|
||||
cx: &mut ElementContext,
|
||||
) -> (crate::LayoutId, Self::State) {
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> (crate::LayoutId, Self::BeforeLayout) {
|
||||
let child_layout_ids = self
|
||||
.children
|
||||
.iter_mut()
|
||||
.map(|child| child.request_layout(cx))
|
||||
.map(|child| child.before_layout(cx))
|
||||
.collect::<SmallVec<_>>();
|
||||
|
||||
let overlay_style = Style {
|
||||
@@ -90,22 +88,28 @@ impl Element for Overlay {
|
||||
|
||||
let layout_id = cx.request_layout(&overlay_style, child_layout_ids.iter().copied());
|
||||
|
||||
(layout_id, OverlayState { child_layout_ids })
|
||||
(
|
||||
layout_id,
|
||||
OverlayState {
|
||||
child_layout_ids,
|
||||
offset: Point::default(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn paint(
|
||||
fn after_layout(
|
||||
&mut self,
|
||||
bounds: crate::Bounds<crate::Pixels>,
|
||||
element_state: &mut Self::State,
|
||||
bounds: Bounds<Pixels>,
|
||||
before_layout: &mut Self::BeforeLayout,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
if element_state.child_layout_ids.is_empty() {
|
||||
if before_layout.child_layout_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut child_min = point(Pixels::MAX, Pixels::MAX);
|
||||
let mut child_max = Point::default();
|
||||
for child_layout_id in &element_state.child_layout_ids {
|
||||
for child_layout_id in &before_layout.child_layout_ids {
|
||||
let child_bounds = cx.layout_bounds(*child_layout_id);
|
||||
child_min = child_min.min(&child_bounds.origin);
|
||||
child_max = child_max.max(&child_bounds.lower_right());
|
||||
@@ -165,25 +169,30 @@ impl Element for Overlay {
|
||||
desired.origin.y = limits.origin.y;
|
||||
}
|
||||
|
||||
let mut offset = cx.element_offset() + desired.origin - bounds.origin;
|
||||
offset = point(offset.x.round(), offset.y.round());
|
||||
cx.with_absolute_element_offset(offset, |cx| {
|
||||
cx.break_content_mask(|cx| {
|
||||
for child in &mut self.children {
|
||||
child.paint(cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
before_layout.offset = cx.element_offset() + desired.origin - bounds.origin;
|
||||
before_layout.offset = point(
|
||||
before_layout.offset.x.round(),
|
||||
before_layout.offset.y.round(),
|
||||
);
|
||||
|
||||
for child in self.children.drain(..) {
|
||||
cx.defer_draw(child, before_layout.offset, 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_bounds: crate::Bounds<crate::Pixels>,
|
||||
_before_layout: &mut Self::BeforeLayout,
|
||||
_after_layout: &mut Self::AfterLayout,
|
||||
_cx: &mut ElementContext,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for Overlay {
|
||||
type Element = Self;
|
||||
|
||||
fn element_id(&self) -> Option<crate::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
Bounds, Element, ElementContext, ElementId, InteractiveElement, InteractiveElementState,
|
||||
Interactivity, IntoElement, LayoutId, Pixels, SharedString, StyleRefinement, Styled,
|
||||
Bounds, Element, ElementContext, Hitbox, InteractiveElement, Interactivity, IntoElement,
|
||||
LayoutId, Pixels, SharedString, StyleRefinement, Styled,
|
||||
};
|
||||
use util::ResultExt;
|
||||
|
||||
@@ -27,28 +27,37 @@ impl Svg {
|
||||
}
|
||||
|
||||
impl Element for Svg {
|
||||
type State = InteractiveElementState;
|
||||
type BeforeLayout = ();
|
||||
type AfterLayout = Option<Hitbox>;
|
||||
|
||||
fn request_layout(
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
|
||||
let layout_id = self
|
||||
.interactivity
|
||||
.before_layout(cx, |style, cx| cx.request_layout(&style, None));
|
||||
(layout_id, ())
|
||||
}
|
||||
|
||||
fn after_layout(
|
||||
&mut self,
|
||||
element_state: Option<Self::State>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_before_layout: &mut Self::BeforeLayout,
|
||||
cx: &mut ElementContext,
|
||||
) -> (LayoutId, Self::State) {
|
||||
self.interactivity.layout(element_state, cx, |style, cx| {
|
||||
cx.request_layout(&style, None)
|
||||
})
|
||||
) -> Option<Hitbox> {
|
||||
self.interactivity
|
||||
.after_layout(bounds, bounds.size, cx, |_, _, hitbox, _| hitbox)
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
element_state: &mut Self::State,
|
||||
_before_layout: &mut Self::BeforeLayout,
|
||||
hitbox: &mut Option<Hitbox>,
|
||||
cx: &mut ElementContext,
|
||||
) where
|
||||
Self: Sized,
|
||||
{
|
||||
self.interactivity
|
||||
.paint(bounds, bounds.size, element_state, cx, |style, _, cx| {
|
||||
.paint(bounds, hitbox.as_ref(), cx, |style, cx| {
|
||||
if let Some((path, color)) = self.path.as_ref().zip(style.text.color) {
|
||||
cx.paint_svg(bounds, path.clone(), color).log_err();
|
||||
}
|
||||
@@ -59,10 +68,6 @@ impl Element for Svg {
|
||||
impl IntoElement for Svg {
|
||||
type Element = Self;
|
||||
|
||||
fn element_id(&self) -> Option<ElementId> {
|
||||
self.interactivity.element_id.clone()
|
||||
}
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
ActiveTooltip, AnyTooltip, AnyView, Bounds, DispatchPhase, Element, ElementContext, ElementId,
|
||||
HighlightStyle, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
|
||||
Point, SharedString, Size, TextRun, TextStyle, WhiteSpace, WindowContext, WrappedLine,
|
||||
HighlightStyle, Hitbox, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
|
||||
Pixels, Point, SharedString, Size, TextRun, TextStyle, WhiteSpace, WindowContext, WrappedLine,
|
||||
TOOLTIP_DELAY,
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
@@ -17,30 +17,37 @@ use std::{
|
||||
use util::ResultExt;
|
||||
|
||||
impl Element for &'static str {
|
||||
type State = TextState;
|
||||
type BeforeLayout = TextState;
|
||||
type AfterLayout = ();
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<Self::State>,
|
||||
cx: &mut ElementContext,
|
||||
) -> (LayoutId, Self::State) {
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
|
||||
let mut state = TextState::default();
|
||||
let layout_id = state.layout(SharedString::from(*self), None, cx);
|
||||
(layout_id, state)
|
||||
}
|
||||
|
||||
fn paint(&mut self, bounds: Bounds<Pixels>, state: &mut TextState, cx: &mut ElementContext) {
|
||||
state.paint(bounds, self, cx)
|
||||
fn after_layout(
|
||||
&mut self,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_text_state: &mut Self::BeforeLayout,
|
||||
_cx: &mut ElementContext,
|
||||
) {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
text_state: &mut TextState,
|
||||
_: &mut (),
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
text_state.paint(bounds, self, cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for &'static str {
|
||||
type Element = Self;
|
||||
|
||||
fn element_id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
@@ -49,41 +56,44 @@ impl IntoElement for &'static str {
|
||||
impl IntoElement for String {
|
||||
type Element = SharedString;
|
||||
|
||||
fn element_id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for SharedString {
|
||||
type State = TextState;
|
||||
type BeforeLayout = TextState;
|
||||
type AfterLayout = ();
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<Self::State>,
|
||||
cx: &mut ElementContext,
|
||||
) -> (LayoutId, Self::State) {
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
|
||||
let mut state = TextState::default();
|
||||
let layout_id = state.layout(self.clone(), None, cx);
|
||||
(layout_id, state)
|
||||
}
|
||||
|
||||
fn paint(&mut self, bounds: Bounds<Pixels>, state: &mut TextState, cx: &mut ElementContext) {
|
||||
fn after_layout(
|
||||
&mut self,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_text_state: &mut Self::BeforeLayout,
|
||||
_cx: &mut ElementContext,
|
||||
) {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
text_state: &mut Self::BeforeLayout,
|
||||
_: &mut Self::AfterLayout,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
let text_str: &str = self.as_ref();
|
||||
state.paint(bounds, text_str, cx)
|
||||
text_state.paint(bounds, text_str, cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for SharedString {
|
||||
type Element = Self;
|
||||
|
||||
fn element_id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
@@ -138,30 +148,37 @@ impl StyledText {
|
||||
}
|
||||
|
||||
impl Element for StyledText {
|
||||
type State = TextState;
|
||||
type BeforeLayout = TextState;
|
||||
type AfterLayout = ();
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<Self::State>,
|
||||
cx: &mut ElementContext,
|
||||
) -> (LayoutId, Self::State) {
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
|
||||
let mut state = TextState::default();
|
||||
let layout_id = state.layout(self.text.clone(), self.runs.take(), cx);
|
||||
(layout_id, state)
|
||||
}
|
||||
|
||||
fn paint(&mut self, bounds: Bounds<Pixels>, state: &mut Self::State, cx: &mut ElementContext) {
|
||||
state.paint(bounds, &self.text, cx)
|
||||
fn after_layout(
|
||||
&mut self,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_state: &mut Self::BeforeLayout,
|
||||
_cx: &mut ElementContext,
|
||||
) {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
text_state: &mut Self::BeforeLayout,
|
||||
_: &mut Self::AfterLayout,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
text_state.paint(bounds, &self.text, cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for StyledText {
|
||||
type Element = Self;
|
||||
|
||||
fn element_id(&self) -> Option<crate::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
@@ -324,8 +341,8 @@ struct InteractiveTextClickEvent {
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Default)]
|
||||
pub struct InteractiveTextState {
|
||||
text_state: TextState,
|
||||
mouse_down_index: Rc<Cell<Option<usize>>>,
|
||||
hovered_index: Rc<Cell<Option<usize>>>,
|
||||
active_tooltip: Rc<RefCell<Option<ActiveTooltip>>>,
|
||||
@@ -385,179 +402,184 @@ impl InteractiveText {
|
||||
}
|
||||
|
||||
impl Element for InteractiveText {
|
||||
type State = InteractiveTextState;
|
||||
type BeforeLayout = TextState;
|
||||
type AfterLayout = Hitbox;
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
state: Option<Self::State>,
|
||||
cx: &mut ElementContext,
|
||||
) -> (LayoutId, Self::State) {
|
||||
if let Some(InteractiveTextState {
|
||||
mouse_down_index,
|
||||
hovered_index,
|
||||
active_tooltip,
|
||||
..
|
||||
}) = state
|
||||
{
|
||||
let (layout_id, text_state) = self.text.request_layout(None, cx);
|
||||
let element_state = InteractiveTextState {
|
||||
text_state,
|
||||
mouse_down_index,
|
||||
hovered_index,
|
||||
active_tooltip,
|
||||
};
|
||||
(layout_id, element_state)
|
||||
} else {
|
||||
let (layout_id, text_state) = self.text.request_layout(None, cx);
|
||||
let element_state = InteractiveTextState {
|
||||
text_state,
|
||||
mouse_down_index: Rc::default(),
|
||||
hovered_index: Rc::default(),
|
||||
active_tooltip: Rc::default(),
|
||||
};
|
||||
(layout_id, element_state)
|
||||
}
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
|
||||
self.text.before_layout(cx)
|
||||
}
|
||||
|
||||
fn paint(&mut self, bounds: Bounds<Pixels>, state: &mut Self::State, cx: &mut ElementContext) {
|
||||
if let Some(click_listener) = self.click_listener.take() {
|
||||
let mouse_position = cx.mouse_position();
|
||||
if let Some(ix) = state.text_state.index_for_position(bounds, mouse_position) {
|
||||
if self
|
||||
.clickable_ranges
|
||||
.iter()
|
||||
.any(|range| range.contains(&ix))
|
||||
{
|
||||
let stacking_order = cx.stacking_order().clone();
|
||||
cx.set_cursor_style(crate::CursorStyle::PointingHand, stacking_order);
|
||||
}
|
||||
}
|
||||
fn after_layout(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
state: &mut Self::BeforeLayout,
|
||||
cx: &mut ElementContext,
|
||||
) -> Hitbox {
|
||||
self.text.after_layout(bounds, state, cx);
|
||||
cx.insert_hitbox(bounds, false)
|
||||
}
|
||||
|
||||
let text_state = state.text_state.clone();
|
||||
let mouse_down = state.mouse_down_index.clone();
|
||||
if let Some(mouse_down_index) = mouse_down.get() {
|
||||
let clickable_ranges = mem::take(&mut self.clickable_ranges);
|
||||
cx.on_mouse_event(move |event: &MouseUpEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble {
|
||||
if let Some(mouse_up_index) =
|
||||
text_state.index_for_position(bounds, event.position)
|
||||
fn paint(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
text_state: &mut Self::BeforeLayout,
|
||||
hitbox: &mut Hitbox,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
cx.with_element_state::<InteractiveTextState, _>(
|
||||
Some(self.element_id.clone()),
|
||||
|interactive_state, cx| {
|
||||
let mut interactive_state = interactive_state.unwrap().unwrap_or_default();
|
||||
if let Some(click_listener) = self.click_listener.take() {
|
||||
let mouse_position = cx.mouse_position();
|
||||
if let Some(ix) = text_state.index_for_position(bounds, mouse_position) {
|
||||
if self
|
||||
.clickable_ranges
|
||||
.iter()
|
||||
.any(|range| range.contains(&ix))
|
||||
{
|
||||
click_listener(
|
||||
&clickable_ranges,
|
||||
InteractiveTextClickEvent {
|
||||
mouse_down_index,
|
||||
mouse_up_index,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
mouse_down.take();
|
||||
cx.refresh();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble {
|
||||
if let Some(mouse_down_index) =
|
||||
text_state.index_for_position(bounds, event.position)
|
||||
{
|
||||
mouse_down.set(Some(mouse_down_index));
|
||||
cx.refresh();
|
||||
cx.set_cursor_style(crate::CursorStyle::PointingHand, hitbox)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(hover_listener) = self.hover_listener.take() {
|
||||
let text_state = state.text_state.clone();
|
||||
let hovered_index = state.hovered_index.clone();
|
||||
cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble {
|
||||
let current = hovered_index.get();
|
||||
let updated = text_state.index_for_position(bounds, event.position);
|
||||
if current != updated {
|
||||
hovered_index.set(updated);
|
||||
hover_listener(updated, event.clone(), cx);
|
||||
cx.refresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(tooltip_builder) = self.tooltip_builder.clone() {
|
||||
let active_tooltip = state.active_tooltip.clone();
|
||||
let pending_mouse_down = state.mouse_down_index.clone();
|
||||
let text_state = state.text_state.clone();
|
||||
|
||||
cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| {
|
||||
let position = text_state.index_for_position(bounds, event.position);
|
||||
let is_hovered = position.is_some() && pending_mouse_down.get().is_none();
|
||||
if !is_hovered {
|
||||
active_tooltip.take();
|
||||
return;
|
||||
}
|
||||
let position = position.unwrap();
|
||||
let text_state = text_state.clone();
|
||||
let mouse_down = interactive_state.mouse_down_index.clone();
|
||||
if let Some(mouse_down_index) = mouse_down.get() {
|
||||
let hitbox = hitbox.clone();
|
||||
let clickable_ranges = mem::take(&mut self.clickable_ranges);
|
||||
cx.on_mouse_event(move |event: &MouseUpEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble && hitbox.is_hovered(cx) {
|
||||
if let Some(mouse_up_index) =
|
||||
text_state.index_for_position(bounds, event.position)
|
||||
{
|
||||
click_listener(
|
||||
&clickable_ranges,
|
||||
InteractiveTextClickEvent {
|
||||
mouse_down_index,
|
||||
mouse_up_index,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
if phase != DispatchPhase::Bubble {
|
||||
return;
|
||||
}
|
||||
|
||||
if active_tooltip.borrow().is_none() {
|
||||
let task = cx.spawn({
|
||||
let active_tooltip = active_tooltip.clone();
|
||||
let tooltip_builder = tooltip_builder.clone();
|
||||
|
||||
move |mut cx| async move {
|
||||
cx.background_executor().timer(TOOLTIP_DELAY).await;
|
||||
cx.update(|cx| {
|
||||
let new_tooltip =
|
||||
tooltip_builder(position, cx).map(|tooltip| ActiveTooltip {
|
||||
tooltip: Some(AnyTooltip {
|
||||
view: tooltip,
|
||||
cursor_offset: cx.mouse_position(),
|
||||
}),
|
||||
_task: None,
|
||||
});
|
||||
*active_tooltip.borrow_mut() = new_tooltip;
|
||||
mouse_down.take();
|
||||
cx.refresh();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let hitbox = hitbox.clone();
|
||||
cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble && hitbox.is_hovered(cx) {
|
||||
if let Some(mouse_down_index) =
|
||||
text_state.index_for_position(bounds, event.position)
|
||||
{
|
||||
mouse_down.set(Some(mouse_down_index));
|
||||
cx.refresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cx.on_mouse_event({
|
||||
let mut hover_listener = self.hover_listener.take();
|
||||
let hitbox = hitbox.clone();
|
||||
let text_state = text_state.clone();
|
||||
let hovered_index = interactive_state.hovered_index.clone();
|
||||
move |event: &MouseMoveEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble && hitbox.is_hovered(cx) {
|
||||
let current = hovered_index.get();
|
||||
let updated = text_state.index_for_position(bounds, event.position);
|
||||
if current != updated {
|
||||
hovered_index.set(updated);
|
||||
if let Some(hover_listener) = hover_listener.as_ref() {
|
||||
hover_listener(updated, event.clone(), cx);
|
||||
}
|
||||
cx.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(tooltip_builder) = self.tooltip_builder.clone() {
|
||||
let hitbox = hitbox.clone();
|
||||
let active_tooltip = interactive_state.active_tooltip.clone();
|
||||
let pending_mouse_down = interactive_state.mouse_down_index.clone();
|
||||
let text_state = text_state.clone();
|
||||
|
||||
cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| {
|
||||
let position = text_state.index_for_position(bounds, event.position);
|
||||
let is_hovered = position.is_some()
|
||||
&& hitbox.is_hovered(cx)
|
||||
&& pending_mouse_down.get().is_none();
|
||||
if !is_hovered {
|
||||
active_tooltip.take();
|
||||
return;
|
||||
}
|
||||
let position = position.unwrap();
|
||||
|
||||
if phase != DispatchPhase::Bubble {
|
||||
return;
|
||||
}
|
||||
|
||||
if active_tooltip.borrow().is_none() {
|
||||
let task = cx.spawn({
|
||||
let active_tooltip = active_tooltip.clone();
|
||||
let tooltip_builder = tooltip_builder.clone();
|
||||
|
||||
move |mut cx| async move {
|
||||
cx.background_executor().timer(TOOLTIP_DELAY).await;
|
||||
cx.update(|cx| {
|
||||
let new_tooltip =
|
||||
tooltip_builder(position, cx).map(|tooltip| {
|
||||
ActiveTooltip {
|
||||
tooltip: Some(AnyTooltip {
|
||||
view: tooltip,
|
||||
cursor_offset: cx.mouse_position(),
|
||||
}),
|
||||
_task: None,
|
||||
}
|
||||
});
|
||||
*active_tooltip.borrow_mut() = new_tooltip;
|
||||
cx.refresh();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
*active_tooltip.borrow_mut() = Some(ActiveTooltip {
|
||||
tooltip: None,
|
||||
_task: Some(task),
|
||||
});
|
||||
}
|
||||
});
|
||||
*active_tooltip.borrow_mut() = Some(ActiveTooltip {
|
||||
tooltip: None,
|
||||
_task: Some(task),
|
||||
|
||||
let active_tooltip = interactive_state.active_tooltip.clone();
|
||||
cx.on_mouse_event(move |_: &MouseDownEvent, _, _| {
|
||||
active_tooltip.take();
|
||||
});
|
||||
|
||||
if let Some(tooltip) = interactive_state
|
||||
.active_tooltip
|
||||
.clone()
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.and_then(|at| at.tooltip.clone())
|
||||
{
|
||||
cx.set_tooltip(tooltip);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let active_tooltip = state.active_tooltip.clone();
|
||||
cx.on_mouse_event(move |_: &MouseDownEvent, _, _| {
|
||||
active_tooltip.take();
|
||||
});
|
||||
self.text.paint(bounds, text_state, &mut (), cx);
|
||||
|
||||
if let Some(tooltip) = state
|
||||
.active_tooltip
|
||||
.clone()
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.and_then(|at| at.tooltip.clone())
|
||||
{
|
||||
cx.set_tooltip(tooltip);
|
||||
}
|
||||
}
|
||||
|
||||
self.text.paint(bounds, &mut state.text_state, cx)
|
||||
((), Some(interactive_state))
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for InteractiveText {
|
||||
type Element = Self;
|
||||
|
||||
fn element_id(&self) -> Option<ElementId> {
|
||||
Some(self.element_id.clone())
|
||||
}
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
use crate::{
|
||||
point, px, size, AnyElement, AvailableSpace, Bounds, ContentMask, Element, ElementContext,
|
||||
ElementId, InteractiveElement, InteractiveElementState, Interactivity, IntoElement, LayoutId,
|
||||
Pixels, Render, Size, StyleRefinement, Styled, View, ViewContext, WindowContext,
|
||||
ElementId, Hitbox, InteractiveElement, Interactivity, IntoElement, LayoutId, Pixels, Render,
|
||||
ScrollHandle, Size, StyleRefinement, Styled, View, ViewContext, WindowContext,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
|
||||
@@ -42,13 +42,13 @@ where
|
||||
};
|
||||
|
||||
UniformList {
|
||||
id: id.clone(),
|
||||
item_count,
|
||||
item_to_measure_index: 0,
|
||||
render_items: Box::new(render_range),
|
||||
interactivity: Interactivity {
|
||||
element_id: Some(id),
|
||||
base_style: Box::new(base_style),
|
||||
occlude_mouse: true,
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
location: Some(*core::panic::Location::caller()),
|
||||
@@ -61,7 +61,6 @@ where
|
||||
|
||||
/// A list element for efficiently laying out and displaying a list of uniform-height elements.
|
||||
pub struct UniformList {
|
||||
id: ElementId,
|
||||
item_count: usize,
|
||||
item_to_measure_index: usize,
|
||||
render_items:
|
||||
@@ -70,10 +69,17 @@ pub struct UniformList {
|
||||
scroll_handle: Option<UniformListScrollHandle>,
|
||||
}
|
||||
|
||||
/// Frame state used by the [UniformList].
|
||||
pub struct UniformListFrameState {
|
||||
item_size: Size<Pixels>,
|
||||
items: SmallVec<[AnyElement; 32]>,
|
||||
}
|
||||
|
||||
/// A handle for controlling the scroll position of a uniform list.
|
||||
/// This should be stored in your view and passed to the uniform_list on each frame.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct UniformListScrollHandle {
|
||||
base_handle: ScrollHandle,
|
||||
deferred_scroll_to_item: Rc<RefCell<Option<usize>>>,
|
||||
}
|
||||
|
||||
@@ -81,6 +87,7 @@ impl UniformListScrollHandle {
|
||||
/// Create a new scroll handle to bind to a uniform list.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base_handle: ScrollHandle::new(),
|
||||
deferred_scroll_to_item: Rc::new(RefCell::new(None)),
|
||||
}
|
||||
}
|
||||
@@ -97,72 +104,47 @@ impl Styled for UniformList {
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Default)]
|
||||
pub struct UniformListState {
|
||||
interactive: InteractiveElementState,
|
||||
item_size: Size<Pixels>,
|
||||
}
|
||||
|
||||
impl Element for UniformList {
|
||||
type State = UniformListState;
|
||||
type BeforeLayout = UniformListFrameState;
|
||||
type AfterLayout = Option<Hitbox>;
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
state: Option<Self::State>,
|
||||
cx: &mut ElementContext,
|
||||
) -> (LayoutId, Self::State) {
|
||||
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
|
||||
let max_items = self.item_count;
|
||||
let item_size = state
|
||||
.as_ref()
|
||||
.map(|s| s.item_size)
|
||||
.unwrap_or_else(|| self.measure_item(None, cx));
|
||||
let item_size = self.measure_item(None, cx);
|
||||
let layout_id = self.interactivity.before_layout(cx, |style, cx| {
|
||||
cx.request_measured_layout(style, move |known_dimensions, available_space, _cx| {
|
||||
let desired_height = item_size.height * max_items;
|
||||
let width = known_dimensions
|
||||
.width
|
||||
.unwrap_or(match available_space.width {
|
||||
AvailableSpace::Definite(x) => x,
|
||||
AvailableSpace::MinContent | AvailableSpace::MaxContent => item_size.width,
|
||||
});
|
||||
|
||||
let (layout_id, interactive) =
|
||||
self.interactivity
|
||||
.layout(state.map(|s| s.interactive), cx, |style, cx| {
|
||||
cx.request_measured_layout(
|
||||
style,
|
||||
move |known_dimensions, available_space, _cx| {
|
||||
let desired_height = item_size.height * max_items;
|
||||
let width =
|
||||
known_dimensions
|
||||
.width
|
||||
.unwrap_or(match available_space.width {
|
||||
AvailableSpace::Definite(x) => x,
|
||||
AvailableSpace::MinContent | AvailableSpace::MaxContent => {
|
||||
item_size.width
|
||||
}
|
||||
});
|
||||
let height = match available_space.height {
|
||||
AvailableSpace::Definite(height) => desired_height.min(height),
|
||||
AvailableSpace::MinContent | AvailableSpace::MaxContent => desired_height,
|
||||
};
|
||||
size(width, height)
|
||||
})
|
||||
});
|
||||
|
||||
let height = match available_space.height {
|
||||
AvailableSpace::Definite(height) => desired_height.min(height),
|
||||
AvailableSpace::MinContent | AvailableSpace::MaxContent => {
|
||||
desired_height
|
||||
}
|
||||
};
|
||||
size(width, height)
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
let element_state = UniformListState {
|
||||
interactive,
|
||||
item_size,
|
||||
};
|
||||
|
||||
(layout_id, element_state)
|
||||
(
|
||||
layout_id,
|
||||
UniformListFrameState {
|
||||
item_size,
|
||||
items: SmallVec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn paint(
|
||||
fn after_layout(
|
||||
&mut self,
|
||||
bounds: Bounds<crate::Pixels>,
|
||||
element_state: &mut Self::State,
|
||||
bounds: Bounds<Pixels>,
|
||||
before_layout: &mut Self::BeforeLayout,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
let style =
|
||||
self.interactivity
|
||||
.compute_style(Some(bounds), &mut element_state.interactive, cx);
|
||||
) -> Option<Hitbox> {
|
||||
let style = self.interactivity.compute_style(None, cx);
|
||||
let border = style.border_widths.to_pixels(cx.rem_size());
|
||||
let padding = style.padding.to_pixels(bounds.size.into(), cx.rem_size());
|
||||
|
||||
@@ -172,17 +154,12 @@ impl Element for UniformList {
|
||||
- point(border.right + padding.right, border.bottom + padding.bottom),
|
||||
);
|
||||
|
||||
let item_size = element_state.item_size;
|
||||
let content_size = Size {
|
||||
width: padded_bounds.size.width,
|
||||
height: item_size.height * self.item_count + padding.top + padding.bottom,
|
||||
height: before_layout.item_size.height * self.item_count + padding.top + padding.bottom,
|
||||
};
|
||||
|
||||
let shared_scroll_offset = element_state
|
||||
.interactive
|
||||
.scroll_offset
|
||||
.get_or_insert_with(Rc::default)
|
||||
.clone();
|
||||
let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap();
|
||||
|
||||
let item_height = self.measure_item(Some(padded_bounds.size.width), cx).height;
|
||||
let shared_scroll_to_item = self
|
||||
@@ -190,12 +167,11 @@ impl Element for UniformList {
|
||||
.as_mut()
|
||||
.and_then(|handle| handle.deferred_scroll_to_item.take());
|
||||
|
||||
self.interactivity.paint(
|
||||
self.interactivity.after_layout(
|
||||
bounds,
|
||||
content_size,
|
||||
&mut element_state.interactive,
|
||||
cx,
|
||||
|style, mut scroll_offset, cx| {
|
||||
|style, mut scroll_offset, hitbox, cx| {
|
||||
let border = style.border_widths.to_pixels(cx.rem_size());
|
||||
let padding = style.padding.to_pixels(bounds.size.into(), cx.rem_size());
|
||||
|
||||
@@ -238,36 +214,45 @@ impl Element for UniformList {
|
||||
..cmp::min(last_visible_element_ix, self.item_count);
|
||||
|
||||
let mut items = (self.render_items)(visible_range.clone(), cx);
|
||||
cx.with_z_index(1, |cx| {
|
||||
let content_mask = ContentMask { bounds };
|
||||
cx.with_content_mask(Some(content_mask), |cx| {
|
||||
for (item, ix) in items.iter_mut().zip(visible_range) {
|
||||
let item_origin = padded_bounds.origin
|
||||
+ point(
|
||||
px(0.),
|
||||
item_height * ix + scroll_offset.y + padding.top,
|
||||
);
|
||||
let available_space = size(
|
||||
AvailableSpace::Definite(padded_bounds.size.width),
|
||||
AvailableSpace::Definite(item_height),
|
||||
);
|
||||
item.draw(item_origin, available_space, cx);
|
||||
}
|
||||
});
|
||||
let content_mask = ContentMask { bounds };
|
||||
cx.with_content_mask(Some(content_mask), |cx| {
|
||||
for (mut item, ix) in items.into_iter().zip(visible_range) {
|
||||
let item_origin = padded_bounds.origin
|
||||
+ point(px(0.), item_height * ix + scroll_offset.y + padding.top);
|
||||
let available_space = size(
|
||||
AvailableSpace::Definite(padded_bounds.size.width),
|
||||
AvailableSpace::Definite(item_height),
|
||||
);
|
||||
item.layout(item_origin, available_space, cx);
|
||||
before_layout.items.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hitbox
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
bounds: Bounds<crate::Pixels>,
|
||||
before_layout: &mut Self::BeforeLayout,
|
||||
hitbox: &mut Option<Hitbox>,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
self.interactivity
|
||||
.paint(bounds, hitbox.as_ref(), cx, |_, cx| {
|
||||
for item in &mut before_layout.items {
|
||||
item.paint(cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for UniformList {
|
||||
type Element = Self;
|
||||
|
||||
fn element_id(&self) -> Option<crate::ElementId> {
|
||||
Some(self.id.clone())
|
||||
}
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
@@ -299,6 +284,7 @@ impl UniformList {
|
||||
|
||||
/// Track and render scroll state of this list with reference to the given scroll handle.
|
||||
pub fn track_scroll(mut self, handle: UniformListScrollHandle) -> Self {
|
||||
self.interactivity.tracked_scroll_handle = Some(handle.base_handle.clone());
|
||||
self.scroll_handle = Some(handle);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -348,6 +348,12 @@ impl BackgroundExecutor {
|
||||
self.dispatcher.as_test().unwrap().allow_parking();
|
||||
}
|
||||
|
||||
/// undoes the effect of [`allow_parking`].
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn forbid_parking(&self) {
|
||||
self.dispatcher.as_test().unwrap().forbid_parking();
|
||||
}
|
||||
|
||||
/// in tests, returns the rng used by the dispatcher and seeded by the `SEED` environment variable
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn rng(&self) -> StdRng {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user