Compare commits
66 Commits
Author | SHA1 | Date | |
---|---|---|---|
cf39c08428 | |||
2f25564fcc | |||
fd2ed8acbd | |||
5f3e1cfb6c | |||
ee767afc3f | |||
8071eb6f8a | |||
11f789e980 | |||
3f82522fe9 | |||
c5cb0e2fd4 | |||
9e2a94fcd9 | |||
8a3e8d331d | |||
1be9b2612c | |||
7c9aaeafa2 | |||
46c0078885 | |||
87ebf3b1d6 | |||
45238f8196 | |||
44f3a12fbe | |||
61acada2a0 | |||
c68fbbd89d | |||
97a0b6a543 | |||
3bccae492d | |||
0120a89d9c | |||
3da6fc3b7e | |||
34dd15ead7 | |||
b3d441e9d6 | |||
4b3dc3756c | |||
10027b98b5 | |||
da17dad63b | |||
fba6c422a8 | |||
0b4b93932d | |||
f42900ec46 | |||
eeca624ba6 | |||
84d08bad16 | |||
1181f33e9d | |||
797e200d08 | |||
d2f231066b | |||
86d40c964f | |||
2604449239 | |||
e992a96d3b | |||
22c4406105 | |||
ad3f0fda6a | |||
cccedceea0 | |||
ed68a34560 | |||
00ee913e3f | |||
46cc67e2db | |||
ff1be34f54 | |||
848bf61277 | |||
043333d3bc | |||
19d90b8081 | |||
4837c52908 | |||
afcf820bdd | |||
18959510f8 | |||
798cbe968a | |||
9cbc088ba3 | |||
2693a5609b | |||
3507da7b39 | |||
56cfb6d1f0 | |||
2b974ef1de | |||
253f1992fd | |||
76d3794b45 | |||
e52c8c9db6 | |||
eb48d51309 | |||
f3274e03ff | |||
46937199a3 | |||
e2a4798c2f | |||
659e6d5b45 |
@ -1,4 +1,6 @@
|
||||
VITE_KC_API_WS_MODELING_URL=wss://api.dev.kittycad.io/ws/modeling/commands
|
||||
VITE_KC_API_BASE_URL=https://api.dev.kittycad.io
|
||||
VITE_KC_SITE_BASE_URL=https://dev.kittycad.io
|
||||
VITE_KC_SKIP_AUTH=false
|
||||
VITE_KC_CONNECTION_TIMEOUT_MS=5000
|
||||
VITE_KC_SENTRY_DSN=
|
||||
|
@ -1,4 +1,6 @@
|
||||
VITE_KC_API_WS_MODELING_URL=wss://api.kittycad.io/ws/modeling/commands
|
||||
VITE_KC_API_BASE_URL=https://api.kittycad.io
|
||||
VITE_KC_SITE_BASE_URL=https://kittycad.io
|
||||
VITE_KC_SKIP_AUTH=false
|
||||
VITE_KC_CONNECTION_TIMEOUT_MS=15000
|
||||
VITE_KC_SENTRY_DSN=https://a814f2f66734989a90367f48feee28ca@o1042111.ingest.sentry.io/4505789425844224
|
||||
|
@ -1 +1 @@
|
||||
src/wasm-lib/pkg/wasm_lib.js
|
||||
src/wasm-lib/*
|
||||
|
11
.github/workflows/cargo-clippy.yml
vendored
11
.github/workflows/cargo-clippy.yml
vendored
@ -40,6 +40,17 @@ jobs:
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2.6.1
|
||||
|
||||
- name: Install ffmpeg
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install \
|
||||
ffmpeg \
|
||||
libavformat-dev \
|
||||
libavutil-dev \
|
||||
libclang-dev \
|
||||
libswscale-dev \
|
||||
--no-install-recommends
|
||||
|
||||
- name: Run clippy
|
||||
run: |
|
||||
cd "${{ matrix.dir }}"
|
||||
|
10
.github/workflows/cargo-test.yml
vendored
10
.github/workflows/cargo-test.yml
vendored
@ -41,6 +41,16 @@ jobs:
|
||||
- uses: taiki-e/install-action@nextest
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2.6.1
|
||||
- name: Install ffmpeg
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install \
|
||||
ffmpeg \
|
||||
libavformat-dev \
|
||||
libavutil-dev \
|
||||
libclang-dev \
|
||||
libswscale-dev \
|
||||
--no-install-recommends
|
||||
- name: cargo test
|
||||
shell: bash
|
||||
run: |-
|
||||
|
66
.github/workflows/ci.yml
vendored
66
.github/workflows/ci.yml
vendored
@ -13,17 +13,31 @@ jobs:
|
||||
check-format:
|
||||
runs-on: 'ubuntu-20.04'
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
cache: 'yarn'
|
||||
- run: yarn install
|
||||
|
||||
- run: yarn fmt-check
|
||||
|
||||
check-types:
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
- run: yarn install
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./src/wasm-lib"
|
||||
|
||||
- run: yarn build:wasm
|
||||
- run: yarn tsc
|
||||
|
||||
|
||||
build-test-web:
|
||||
runs-on: ubuntu-20.04
|
||||
@ -36,12 +50,15 @@ jobs:
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
|
||||
- run: yarn install
|
||||
|
||||
- run: yarn build:wasm
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./src/wasm-lib"
|
||||
|
||||
- run: yarn tsc
|
||||
- run: yarn build:wasm
|
||||
|
||||
- run: yarn simpleserver:ci
|
||||
|
||||
@ -49,14 +66,12 @@ jobs:
|
||||
|
||||
- run: yarn test:cov
|
||||
|
||||
- run: yarn test:rust
|
||||
|
||||
- id: export_version
|
||||
run: echo "version=`cat package.json | jq -r '.version'`" >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
build-apps:
|
||||
needs: [check-format, build-test-web]
|
||||
needs: [check-format, build-test-web, check-types]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
@ -87,6 +102,10 @@ jobs:
|
||||
with:
|
||||
workspaces: './src-tauri -> target'
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./src/wasm-lib"
|
||||
|
||||
- name: wasm prep
|
||||
shell: bash
|
||||
run: |
|
||||
@ -110,15 +129,22 @@ jobs:
|
||||
- name: Fix format
|
||||
run: yarn fmt
|
||||
|
||||
- name: install apple silicon target mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
rustup target add aarch64-apple-darwin
|
||||
|
||||
- name: Build the app for the current platform (no upload)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
with:
|
||||
args: ${{ matrix.os == 'macos-latest' && '--target universal-apple-darwin' || '' }}
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
path: src-tauri/target/release/bundle/*/*
|
||||
path: ${{ matrix.os == 'macos-latest' && 'src-tauri/target/universal-apple-darwin/release/bundle/*/*' || 'src-tauri/target/release/bundle/*/*' }}
|
||||
|
||||
|
||||
publish-apps-release:
|
||||
@ -133,8 +159,7 @@ jobs:
|
||||
|
||||
- name: Generate the update static endpoint
|
||||
run: |
|
||||
ls -l artifact
|
||||
ls -l artifact/*
|
||||
ls -l artifact/*/*itty*
|
||||
DARWIN_SIG=`cat artifact/macos/*.app.tar.gz.sig`
|
||||
LINUX_SIG=`cat artifact/appimage/*.AppImage.tar.gz.sig`
|
||||
WINDOWS_SIG=`cat artifact/nsis/*.nsis.zip.sig`
|
||||
@ -142,11 +167,11 @@ jobs:
|
||||
jq --null-input \
|
||||
--arg version "v${VERSION_NO_V}" \
|
||||
--arg darwin_sig "$DARWIN_SIG" \
|
||||
--arg darwin_url "$RELEASE_DIR/macos/kittycad-modeling-app.app.tar.gz" \
|
||||
--arg darwin_url "$RELEASE_DIR/macos/KittyCAD%20Modeling.app.tar.gz" \
|
||||
--arg linux_sig "$LINUX_SIG" \
|
||||
--arg linux_url "$RELEASE_DIR/appimage/kittycad-modeling-app_${VERSION_NO_V}_amd64.AppImage.tar.gz" \
|
||||
--arg linux_url "$RELEASE_DIR/appimage/kittycad-modeling_${VERSION_NO_V}_amd64.AppImage.tar.gz" \
|
||||
--arg windows_sig "$WINDOWS_SIG" \
|
||||
--arg windows_url "$RELEASE_DIR/nsis/kittycad-modeling-app_${VERSION_NO_V}_x64-setup.nsis.zip" \
|
||||
--arg windows_url "$RELEASE_DIR/nsis/KittyCAD%20Modeling_${VERSION_NO_V}_x64-setup.nsis.zip" \
|
||||
'{
|
||||
"version": $version,
|
||||
"platforms": {
|
||||
@ -154,6 +179,10 @@ jobs:
|
||||
"signature": $darwin_sig,
|
||||
"url": $darwin_url
|
||||
},
|
||||
"darwin-aarch64": {
|
||||
"signature": $darwin_sig,
|
||||
"url": $darwin_url
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"signature": $linux_sig,
|
||||
"url": $linux_url
|
||||
@ -180,7 +209,7 @@ jobs:
|
||||
uses: google-github-actions/upload-cloud-storage@v1.0.3
|
||||
with:
|
||||
path: artifact
|
||||
glob: '*/kittycad-modeling-app*'
|
||||
glob: '*/*itty*'
|
||||
parent: false
|
||||
destination: dl.kittycad.io/releases/modeling-app/v${{ env.VERSION_NO_V }}
|
||||
|
||||
@ -189,3 +218,8 @@ jobs:
|
||||
with:
|
||||
path: last_update.json
|
||||
destination: dl.kittycad.io/releases/modeling-app
|
||||
|
||||
- name: Upload release files to Github
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: artifact/*/*itty*
|
||||
|
@ -5,3 +5,5 @@ coverage
|
||||
# Ignore Rust projects:
|
||||
*.rs
|
||||
target
|
||||
src/wasm-lib/pkg
|
||||
src/wasm-lib/kcl/bindings
|
||||
|
21
README.md
21
README.md
@ -86,3 +86,24 @@ The PR may serve as a place to discuss the human-readable changelog and extra QA
|
||||
3. Create a new release and tag pointing to the bump version commit using semantic versioning `v{x}.{y}.{z}`
|
||||
|
||||
4. A new Action kicks in at https://github.com/KittyCAD/modeling-app/actions, uploading artifacts to the release
|
||||
|
||||
## Fuzzing the parser
|
||||
|
||||
Make sure you install cargo fuzz:
|
||||
|
||||
```bash
|
||||
$ cargo install cargo-fuzz
|
||||
```
|
||||
|
||||
```bash
|
||||
$ cd src/wasm-lib/kcl
|
||||
|
||||
# list the fuzz targets
|
||||
$ cargo fuzz list
|
||||
|
||||
# run the parser fuzzer
|
||||
$ cargo +nightly fuzz run parser
|
||||
```
|
||||
|
||||
For more information on fuzzing you can check out
|
||||
[this guide](https://rust-fuzz.github.io/book/cargo-fuzz.html).
|
||||
|
24601
docs/kcl.json
24601
docs/kcl.json
File diff suppressed because it is too large
Load Diff
5449
docs/kcl.md
5449
docs/kcl.md
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@ -1,30 +1,35 @@
|
||||
{
|
||||
"name": "untitled-app",
|
||||
"version": "0.2.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.9.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@headlessui/tailwindcss": "^0.2.0",
|
||||
"@kittycad/lib": "^0.0.34",
|
||||
"@kittycad/lib": "^0.0.37",
|
||||
"@lezer/javascript": "^1.4.7",
|
||||
"@open-rpc/client-js": "^1.8.1",
|
||||
"@react-hook/resize-observer": "^1.2.6",
|
||||
"@sentry/react": "^7.65.0",
|
||||
"@tauri-apps/api": "^1.3.0",
|
||||
"@testing-library/jest-dom": "^5.14.1",
|
||||
"@testing-library/react": "^13.0.0",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"@ts-stack/markdown": "^1.5.0",
|
||||
"@types/node": "^16.7.13",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@uiw/codemirror-extensions-langs": "^4.21.9",
|
||||
"@uiw/react-codemirror": "^4.15.1",
|
||||
"@uiw/react-codemirror": "^4.21.13",
|
||||
"@xstate/react": "^3.2.2",
|
||||
"crypto-js": "^4.1.1",
|
||||
"formik": "^2.4.3",
|
||||
"fuse.js": "^6.6.2",
|
||||
"http-server": "^14.1.1",
|
||||
"json-rpc-2.0": "^1.6.0",
|
||||
"re-resizable": "^6.9.9",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@ -42,6 +47,8 @@
|
||||
"typescript": "^4.4.2",
|
||||
"uuid": "^9.0.0",
|
||||
"vitest": "^0.34.1",
|
||||
"vscode-jsonrpc": "^8.1.0",
|
||||
"vscode-languageserver-protocol": "^3.17.3",
|
||||
"wasm-pack": "^0.12.1",
|
||||
"web-vitals": "^2.1.0",
|
||||
"ws": "^8.13.0",
|
||||
@ -56,14 +63,14 @@
|
||||
"build:both:local": "yarn build:wasm && vite build",
|
||||
"test": "vitest --mode development",
|
||||
"test:nowatch": "vitest run --mode development",
|
||||
"test:rust": "(cd src/wasm-lib && cargo test && cargo clippy)",
|
||||
"test:rust": "(cd src/wasm-lib && cargo test --all && cargo clippy --all --tests)",
|
||||
"test:cov": "vitest run --coverage --mode development",
|
||||
"simpleserver:ci": "http-server ./public --cors -p 3000 &",
|
||||
"simpleserver": "http-server ./public --cors -p 3000",
|
||||
"fmt": "prettier --write ./src",
|
||||
"fmt-check": "prettier --check ./src",
|
||||
"build:wasm": "yarn wasm-prep && (cd src/wasm-lib && wasm-pack build --target web --out-dir pkg && cargo test --all) && cp src/wasm-lib/pkg/wasm_lib_bg.wasm public && yarn fmt && yarn remove-importmeta",
|
||||
"remove-importmeta": "sed -i 's/import.meta.url//g' \"./src/wasm-lib/pkg/wasm_lib.js\"; sed -i '' 's/import.meta.url//g' \"./src/wasm-lib/pkg/wasm_lib.js\" || echo \"sed for both mac and linux\"",
|
||||
"build:wasm": "yarn wasm-prep && (cd src/wasm-lib && wasm-pack build --target web --out-dir pkg && cargo test -p kcl-lib export_bindings) && cp src/wasm-lib/pkg/wasm_lib_bg.wasm public && yarn fmt && yarn remove-importmeta",
|
||||
"remove-importmeta": "sed -i 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\"; sed -i '' 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\" || echo \"sed for both mac and linux\"",
|
||||
"wasm-prep": "rm -rf src/wasm-lib/pkg && mkdir src/wasm-lib/pkg && rm -rf src/wasm-lib/kcl/bindings",
|
||||
"lint": "eslint --fix src",
|
||||
"bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json && echo \"$(jq --arg v \"$VERSION\" '.package.version=$v' src-tauri/tauri.conf.json --indent 2)\" > src-tauri/tauri.conf.json"
|
||||
@ -91,6 +98,7 @@
|
||||
"@babel/preset-env": "^7.22.9",
|
||||
"@tauri-apps/cli": "^1.3.1",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/debounce": "^1.2.1",
|
||||
"@types/isomorphic-fetch": "^0.0.36",
|
||||
"@types/react-modal": "^3.16.0",
|
||||
"@types/uuid": "^9.0.1",
|
||||
|
56
src-tauri/Cargo.lock
generated
56
src-tauri/Cargo.lock
generated
@ -648,6 +648,12 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.1"
|
||||
@ -1150,7 +1156,7 @@ dependencies = [
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http",
|
||||
"indexmap",
|
||||
"indexmap 1.9.3",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@ -1163,6 +1169,12 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.3.3"
|
||||
@ -1378,7 +1390,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown",
|
||||
"hashbrown 0.12.3",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.14.0",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@ -2128,7 +2151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bd9647b268a3d3e14ff09c23201133a62589c658db02bb7388c7246aafe0590"
|
||||
dependencies = [
|
||||
"base64 0.21.2",
|
||||
"indexmap",
|
||||
"indexmap 1.9.3",
|
||||
"line-wrap",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
@ -2701,14 +2724,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "2.3.3"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07ff71d2c147a7b57362cead5e22f772cd52f6ab31cfcd9edcd7f6aeb2a0afbe"
|
||||
checksum = "1402f54f9a3b9e2efe71c1cea24e648acce55887983553eeb858cf3115acfd49"
|
||||
dependencies = [
|
||||
"base64 0.13.1",
|
||||
"base64 0.21.2",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.0.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
@ -2717,9 +2741,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "2.3.3"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "881b6f881b17d13214e5d494c939ebab463d01264ce1811e9d4ac3a882e7695f"
|
||||
checksum = "9197f1ad0e3c173a0222d3c4404fb04c3afe87e962bcb327af73e8301fa203c7"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
@ -3075,9 +3099,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-build"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "929b3bd1248afc07b63e33a6a53c3f82c32d0b0a5e216e4530e94c467e019389"
|
||||
checksum = "7d2edd6a259b5591c8efdeb9d5702cb53515b82a6affebd55c7fd6d3a27b7d1b"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
@ -3088,7 +3112,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"tauri-winres",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3186,12 +3209,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6f9c2dafef5cbcf52926af57ce9561bd33bb41d7394f8bb849c0330260d864"
|
||||
checksum = "03fc02bb6072bb397e1d473c6f76c953cda48b4a2d0cce605df284aa74a12e84"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"ctor",
|
||||
"dunce",
|
||||
"glob",
|
||||
"heck 0.4.1",
|
||||
"html5ever",
|
||||
@ -3407,7 +3431,7 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56c59d8dd7d0dcbc6428bf7aa2f0e823e26e43b3c9aca15bbc9475d23e5fa12b"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 1.9.3",
|
||||
"nom8",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
@ -3420,7 +3444,7 @@ version = "0.19.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 1.9.3",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.2",
|
||||
|
@ -12,7 +12,7 @@ rust-version = "1.60"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.3.0", features = [] }
|
||||
tauri-build = { version = "1.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
|
@ -7,8 +7,8 @@
|
||||
"distDir": "../build"
|
||||
},
|
||||
"package": {
|
||||
"productName": "kittycad-modeling-app",
|
||||
"version": "0.2.0"
|
||||
"productName": "kittycad-modeling",
|
||||
"version": "0.6.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
|
7
src-tauri/tauri.macos.conf.json
Normal file
7
src-tauri/tauri.macos.conf.json
Normal file
@ -0,0 +1,7 @@
|
||||
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"package": {
|
||||
"productName": "KittyCAD Modeling"
|
||||
}
|
||||
}
|
7
src-tauri/tauri.windows.conf.json
Normal file
7
src-tauri/tauri.windows.conf.json
Normal file
@ -0,0 +1,7 @@
|
||||
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"package": {
|
||||
"productName": "KittyCAD Modeling"
|
||||
}
|
||||
}
|
342
src/App.tsx
342
src/App.tsx
@ -2,7 +2,6 @@ import {
|
||||
useRef,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
MouseEventHandler,
|
||||
} from 'react'
|
||||
@ -10,15 +9,7 @@ import { DebugPanel } from './components/DebugPanel'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { asyncParser } from './lang/abstractSyntaxTree'
|
||||
import { _executor } from './lang/executor'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { langs } from '@uiw/codemirror-extensions-langs'
|
||||
import { linter, lintGutter } from '@codemirror/lint'
|
||||
import { ViewUpdate } from '@codemirror/view'
|
||||
import {
|
||||
lineHighlightField,
|
||||
addLineHighlight,
|
||||
} from './editor/highlightextension'
|
||||
import { PaneType, Selections, useStore } from './useStore'
|
||||
import { PaneType, useStore } from './useStore'
|
||||
import { Logs, KCLErrors } from './components/Logs'
|
||||
import { CollapsiblePanel } from './components/CollapsiblePanel'
|
||||
import { MemoryPanel } from './components/MemoryPanel'
|
||||
@ -29,9 +20,9 @@ import {
|
||||
EngineCommand,
|
||||
EngineCommandManager,
|
||||
} from './lang/std/engineConnection'
|
||||
import { isOverlap, throttle } from './lib/utils'
|
||||
import { throttle } from './lib/utils'
|
||||
import { AppHeader } from './components/AppHeader'
|
||||
import { KCLError, kclErrToDiagnostic } from './lang/errors'
|
||||
import { KCLError } from './lang/errors'
|
||||
import { Resizable } from 're-resizable'
|
||||
import {
|
||||
faCode,
|
||||
@ -39,93 +30,77 @@ import {
|
||||
faSquareRootVariable,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { TEST } from './env'
|
||||
import { getNormalisedCoordinates } from './lib/utils'
|
||||
import { Themes, getSystemTheme } from './lib/theme'
|
||||
import { isTauri } from './lib/isTauri'
|
||||
import { useLoaderData, useParams } from 'react-router-dom'
|
||||
import { writeTextFile } from '@tauri-apps/api/fs'
|
||||
import { PROJECT_ENTRYPOINT } from './lib/tauriFS'
|
||||
import { useLoaderData } from 'react-router-dom'
|
||||
import { IndexLoaderData } from './Router'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||
import { onboardingPaths } from 'routes/Onboarding'
|
||||
import { cameraMouseDragGuards } from 'lib/cameraControls'
|
||||
import { CameraDragInteractionType_type } from '@kittycad/lib/dist/types/src/models'
|
||||
import { CodeMenu } from 'components/CodeMenu'
|
||||
import { TextEditor } from 'components/TextEditor'
|
||||
import { Themes, getSystemTheme } from 'lib/theme'
|
||||
|
||||
export function App() {
|
||||
const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
|
||||
const pathParams = useParams()
|
||||
|
||||
const streamRef = useRef<HTMLDivElement>(null)
|
||||
useHotKeyListener()
|
||||
const {
|
||||
editorView,
|
||||
setEditorView,
|
||||
setSelectionRanges,
|
||||
selectionRanges,
|
||||
addLog,
|
||||
addKCLError,
|
||||
code,
|
||||
setCode,
|
||||
setAst,
|
||||
setError,
|
||||
setProgramMemory,
|
||||
resetLogs,
|
||||
resetKCLErrors,
|
||||
selectionRangeTypeMap,
|
||||
setArtifactMap,
|
||||
engineCommandManager,
|
||||
setEngineCommandManager,
|
||||
highlightRange,
|
||||
setHighlightRange,
|
||||
setCursor2,
|
||||
sourceRangeMap,
|
||||
setMediaStream,
|
||||
setIsStreamReady,
|
||||
isStreamReady,
|
||||
isMouseDownInStream,
|
||||
cmdId,
|
||||
setCmdId,
|
||||
formatCode,
|
||||
buttonDownInStream,
|
||||
openPanes,
|
||||
setOpenPanes,
|
||||
didDragInStream,
|
||||
setDidDragInStream,
|
||||
setStreamDimensions,
|
||||
streamDimensions,
|
||||
setIsExecuting,
|
||||
defferedCode,
|
||||
guiMode,
|
||||
} = useStore((s) => ({
|
||||
editorView: s.editorView,
|
||||
setEditorView: s.setEditorView,
|
||||
setSelectionRanges: s.setSelectionRanges,
|
||||
selectionRanges: s.selectionRanges,
|
||||
setGuiMode: s.setGuiMode,
|
||||
guiMode: s.guiMode,
|
||||
addLog: s.addLog,
|
||||
code: s.code,
|
||||
defferedCode: s.defferedCode,
|
||||
setCode: s.setCode,
|
||||
setAst: s.setAst,
|
||||
setError: s.setError,
|
||||
setProgramMemory: s.setProgramMemory,
|
||||
resetLogs: s.resetLogs,
|
||||
resetKCLErrors: s.resetKCLErrors,
|
||||
selectionRangeTypeMap: s.selectionRangeTypeMap,
|
||||
setArtifactMap: s.setArtifactNSourceRangeMaps,
|
||||
engineCommandManager: s.engineCommandManager,
|
||||
setEngineCommandManager: s.setEngineCommandManager,
|
||||
highlightRange: s.highlightRange,
|
||||
setHighlightRange: s.setHighlightRange,
|
||||
isShiftDown: s.isShiftDown,
|
||||
setCursor: s.setCursor,
|
||||
setCursor2: s.setCursor2,
|
||||
sourceRangeMap: s.sourceRangeMap,
|
||||
setMediaStream: s.setMediaStream,
|
||||
isStreamReady: s.isStreamReady,
|
||||
setIsStreamReady: s.setIsStreamReady,
|
||||
isMouseDownInStream: s.isMouseDownInStream,
|
||||
cmdId: s.cmdId,
|
||||
setCmdId: s.setCmdId,
|
||||
formatCode: s.formatCode,
|
||||
buttonDownInStream: s.buttonDownInStream,
|
||||
addKCLError: s.addKCLError,
|
||||
openPanes: s.openPanes,
|
||||
setOpenPanes: s.setOpenPanes,
|
||||
didDragInStream: s.didDragInStream,
|
||||
setDidDragInStream: s.setDidDragInStream,
|
||||
setStreamDimensions: s.setStreamDimensions,
|
||||
streamDimensions: s.streamDimensions,
|
||||
setIsExecuting: s.setIsExecuting,
|
||||
}))
|
||||
|
||||
const {
|
||||
@ -133,7 +108,7 @@ export function App() {
|
||||
context: { token },
|
||||
},
|
||||
settings: {
|
||||
context: { showDebugPanel, theme, onboardingStatus },
|
||||
context: { showDebugPanel, onboardingStatus, cameraControls, theme },
|
||||
},
|
||||
} = useGlobalStateContext()
|
||||
|
||||
@ -154,7 +129,7 @@ export function App() {
|
||||
useHotkeys('shift + d', () => togglePane('debug'))
|
||||
|
||||
const paneOpacity =
|
||||
onboardingStatus === 'camera'
|
||||
onboardingStatus === onboardingPaths.CAMERA
|
||||
? 'opacity-20'
|
||||
: didDragInStream
|
||||
? 'opacity-40'
|
||||
@ -174,87 +149,12 @@ export function App() {
|
||||
}
|
||||
}, [loadedCode, setCode])
|
||||
|
||||
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
|
||||
const onChange = (value: string, viewUpdate: ViewUpdate) => {
|
||||
setCode(value)
|
||||
if (isTauri() && pathParams.id) {
|
||||
// Save the file to disk
|
||||
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
|
||||
writeTextFile(pathParams.id + '/' + PROJECT_ENTRYPOINT, value).catch(
|
||||
(err) => {
|
||||
// TODO: add Sentry per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254)
|
||||
console.error('error saving file', err)
|
||||
toast.error('Error saving file, please check file permissions')
|
||||
}
|
||||
)
|
||||
}
|
||||
if (editorView) {
|
||||
editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
|
||||
}
|
||||
} //, []);
|
||||
const onUpdate = (viewUpdate: ViewUpdate) => {
|
||||
if (!editorView) {
|
||||
setEditorView(viewUpdate.view)
|
||||
}
|
||||
const ranges = viewUpdate.state.selection.ranges
|
||||
|
||||
const isChange =
|
||||
ranges.length !== selectionRanges.codeBasedSelections.length ||
|
||||
ranges.some(({ from, to }, i) => {
|
||||
return (
|
||||
from !== selectionRanges.codeBasedSelections[i].range[0] ||
|
||||
to !== selectionRanges.codeBasedSelections[i].range[1]
|
||||
)
|
||||
})
|
||||
|
||||
if (!isChange) return
|
||||
const codeBasedSelections: Selections['codeBasedSelections'] = ranges.map(
|
||||
({ from, to }) => {
|
||||
if (selectionRangeTypeMap[to]) {
|
||||
return {
|
||||
type: selectionRangeTypeMap[to],
|
||||
range: [from, to],
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'default',
|
||||
range: [from, to],
|
||||
}
|
||||
}
|
||||
)
|
||||
const idBasedSelections = codeBasedSelections
|
||||
.map(({ type, range }) => {
|
||||
const hasOverlap = Object.entries(sourceRangeMap).filter(
|
||||
([_, sourceRange]) => {
|
||||
return isOverlap(sourceRange, range)
|
||||
}
|
||||
)
|
||||
if (hasOverlap.length) {
|
||||
return {
|
||||
type,
|
||||
id: hasOverlap[0][0],
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as any
|
||||
|
||||
engineCommandManager?.cusorsSelected({
|
||||
otherSelections: [],
|
||||
idBasedSelections,
|
||||
})
|
||||
|
||||
setSelectionRanges({
|
||||
otherSelections: [],
|
||||
codeBasedSelections,
|
||||
})
|
||||
}
|
||||
const pixelDensity = window.devicePixelRatio
|
||||
const streamWidth = streamRef?.current?.offsetWidth
|
||||
const streamHeight = streamRef?.current?.offsetHeight
|
||||
|
||||
const width = streamWidth ? streamWidth * pixelDensity : 0
|
||||
const width = streamWidth ? streamWidth : 0
|
||||
const quadWidth = Math.round(width / 4) * 4
|
||||
const height = streamHeight ? streamHeight * pixelDensity : 0
|
||||
const height = streamHeight ? streamHeight : 0
|
||||
const quadHeight = Math.round(height / 4) * 4
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@ -278,21 +178,32 @@ export function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreamReady) return
|
||||
if (!engineCommandManager) return
|
||||
let unsubFn: any[] = []
|
||||
const asyncWrap = async () => {
|
||||
try {
|
||||
if (!code) {
|
||||
setAst(null)
|
||||
if (!defferedCode) {
|
||||
setAst({
|
||||
start: 0,
|
||||
end: 0,
|
||||
body: [],
|
||||
nonCodeMeta: {
|
||||
noneCodeNodes: {},
|
||||
start: null,
|
||||
},
|
||||
})
|
||||
setProgramMemory({ root: {} })
|
||||
engineCommandManager.endSession()
|
||||
engineCommandManager.startNewSession()
|
||||
return
|
||||
}
|
||||
const _ast = await asyncParser(code)
|
||||
const _ast = await asyncParser(defferedCode)
|
||||
setAst(_ast)
|
||||
resetLogs()
|
||||
resetKCLErrors()
|
||||
if (engineCommandManager) {
|
||||
engineCommandManager.endSession()
|
||||
engineCommandManager.startNewSession()
|
||||
}
|
||||
if (!engineCommandManager) return
|
||||
setIsExecuting(true)
|
||||
const programMemory = await _executor(
|
||||
_ast,
|
||||
{
|
||||
@ -324,30 +235,42 @@ export function App() {
|
||||
|
||||
const { artifactMap, sourceRangeMap } =
|
||||
await engineCommandManager.waitForAllCommands()
|
||||
|
||||
setArtifactMap({ artifactMap, sourceRangeMap })
|
||||
engineCommandManager.onHover((id) => {
|
||||
if (!id) {
|
||||
setHighlightRange([0, 0])
|
||||
} else {
|
||||
const sourceRange = sourceRangeMap[id]
|
||||
setHighlightRange(sourceRange)
|
||||
}
|
||||
})
|
||||
engineCommandManager.onClick((selections) => {
|
||||
if (!selections) {
|
||||
setCursor2()
|
||||
return
|
||||
}
|
||||
const { id, type } = selections
|
||||
setCursor2({ range: sourceRangeMap[id], type })
|
||||
})
|
||||
setIsExecuting(false)
|
||||
if (programMemory !== undefined) {
|
||||
setProgramMemory(programMemory)
|
||||
}
|
||||
|
||||
setArtifactMap({ artifactMap, sourceRangeMap })
|
||||
const unSubHover = engineCommandManager.subscribeToUnreliable({
|
||||
event: 'highlight_set_entity',
|
||||
callback: ({ data }) => {
|
||||
if (data?.entity_id) {
|
||||
const sourceRange = sourceRangeMap[data.entity_id]
|
||||
setHighlightRange(sourceRange)
|
||||
} else if (
|
||||
!highlightRange ||
|
||||
(highlightRange[0] !== 0 && highlightRange[1] !== 0)
|
||||
) {
|
||||
setHighlightRange([0, 0])
|
||||
}
|
||||
},
|
||||
})
|
||||
const unSubClick = engineCommandManager.subscribeTo({
|
||||
event: 'select_with_point',
|
||||
callback: ({ data }) => {
|
||||
if (!data?.entity_id) {
|
||||
setCursor2()
|
||||
return
|
||||
}
|
||||
const sourceRange = sourceRangeMap[data.entity_id]
|
||||
setCursor2({ range: sourceRange, type: 'default' })
|
||||
},
|
||||
})
|
||||
unsubFn.push(unSubHover, unSubClick)
|
||||
|
||||
setError()
|
||||
} catch (e: any) {
|
||||
setIsExecuting(false)
|
||||
if (e instanceof KCLError) {
|
||||
addKCLError(e)
|
||||
} else {
|
||||
@ -358,45 +281,49 @@ export function App() {
|
||||
}
|
||||
}
|
||||
asyncWrap()
|
||||
}, [code, isStreamReady])
|
||||
return () => {
|
||||
unsubFn.forEach((fn) => fn())
|
||||
}
|
||||
}, [defferedCode, isStreamReady, engineCommandManager])
|
||||
|
||||
const debounceSocketSend = throttle<EngineCommand>((message) => {
|
||||
engineCommandManager?.sendSceneCommand(message)
|
||||
}, 16)
|
||||
const handleMouseMove: MouseEventHandler<HTMLDivElement> = ({
|
||||
clientX,
|
||||
clientY,
|
||||
ctrlKey,
|
||||
shiftKey,
|
||||
currentTarget,
|
||||
nativeEvent,
|
||||
}) => {
|
||||
nativeEvent.preventDefault()
|
||||
if (isMouseDownInStream) {
|
||||
setDidDragInStream(true)
|
||||
}
|
||||
const handleMouseMove: MouseEventHandler<HTMLDivElement> = (e) => {
|
||||
e.nativeEvent.preventDefault()
|
||||
|
||||
const { x, y } = getNormalisedCoordinates({
|
||||
clientX,
|
||||
clientY,
|
||||
el: currentTarget,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
el: e.currentTarget,
|
||||
...streamDimensions,
|
||||
})
|
||||
|
||||
const interaction = ctrlKey ? 'zoom' : shiftKey ? 'pan' : 'rotate'
|
||||
|
||||
const newCmdId = uuidv4()
|
||||
setCmdId(newCmdId)
|
||||
|
||||
if (cmdId && isMouseDownInStream) {
|
||||
if (buttonDownInStream === undefined) {
|
||||
if (
|
||||
guiMode.mode === 'sketch' &&
|
||||
guiMode.sketchMode === ('sketch_line' as any)
|
||||
) {
|
||||
debounceSocketSend({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: newCmdId,
|
||||
cmd: {
|
||||
type: 'camera_drag_move',
|
||||
interaction,
|
||||
type: 'mouse_move',
|
||||
window: { x, y },
|
||||
},
|
||||
})
|
||||
} else if (
|
||||
guiMode.mode === 'sketch' &&
|
||||
guiMode.sketchMode === ('move' as any)
|
||||
) {
|
||||
debounceSocketSend({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: newCmdId,
|
||||
cmd: {
|
||||
type: 'handle_mouse_drag_move',
|
||||
window: { x, y },
|
||||
},
|
||||
})
|
||||
} else {
|
||||
debounceSocketSend({
|
||||
@ -408,17 +335,34 @@ export function App() {
|
||||
cmd_id: newCmdId,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const interactionGuards = cameraMouseDragGuards[cameraControls]
|
||||
let interaction: CameraDragInteractionType_type
|
||||
|
||||
const eWithButton = { ...e, button: buttonDownInStream }
|
||||
|
||||
if (interactionGuards.pan.callback(eWithButton)) {
|
||||
interaction = 'pan'
|
||||
} else if (interactionGuards.rotate.callback(eWithButton)) {
|
||||
interaction = 'rotate'
|
||||
} else if (interactionGuards.zoom.dragCallback(eWithButton)) {
|
||||
interaction = 'zoom'
|
||||
} else {
|
||||
console.log('none')
|
||||
return
|
||||
}
|
||||
|
||||
const extraExtensions = useMemo(() => {
|
||||
if (TEST) return []
|
||||
return [
|
||||
lintGutter(),
|
||||
linter((_view) => {
|
||||
return kclErrToDiagnostic(useStore.getState().kclErrors)
|
||||
}),
|
||||
]
|
||||
}, [])
|
||||
debounceSocketSend({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'camera_drag_move',
|
||||
interaction,
|
||||
window: { x, y },
|
||||
},
|
||||
cmd_id: newCmdId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -430,7 +374,7 @@ export function App() {
|
||||
className={
|
||||
'transition-opacity transition-duration-75 ' +
|
||||
paneOpacity +
|
||||
(isMouseDownInStream ? ' pointer-events-none' : '')
|
||||
(buttonDownInStream ? ' pointer-events-none' : '')
|
||||
}
|
||||
project={project}
|
||||
enableMenu={true}
|
||||
@ -439,17 +383,17 @@ export function App() {
|
||||
<Resizable
|
||||
className={
|
||||
'h-full flex flex-col flex-1 z-10 my-5 ml-5 pr-1 transition-opacity transition-duration-75 ' +
|
||||
(isMouseDownInStream || onboardingStatus === 'camera'
|
||||
(buttonDownInStream || onboardingStatus === 'camera'
|
||||
? ' pointer-events-none '
|
||||
: ' ') +
|
||||
paneOpacity
|
||||
}
|
||||
defaultSize={{
|
||||
width: '400px',
|
||||
width: '550px',
|
||||
height: 'auto',
|
||||
}}
|
||||
minWidth={200}
|
||||
maxWidth={600}
|
||||
maxWidth={800}
|
||||
minHeight={'auto'}
|
||||
maxHeight={'auto'}
|
||||
handleClasses={{
|
||||
@ -463,31 +407,9 @@ export function App() {
|
||||
icon={faCode}
|
||||
className="open:!mb-2"
|
||||
open={openPanes.includes('code')}
|
||||
menu={<CodeMenu />}
|
||||
>
|
||||
<div className="px-2 py-1">
|
||||
<button
|
||||
// disabled={!shouldFormat}
|
||||
onClick={formatCode}
|
||||
// className={`${!shouldFormat && 'text-gray-300'}`}
|
||||
>
|
||||
format
|
||||
</button>
|
||||
</div>
|
||||
<div id="code-mirror-override">
|
||||
<CodeMirror
|
||||
className="h-full"
|
||||
value={code}
|
||||
extensions={[
|
||||
langs.javascript({ jsx: true }),
|
||||
lineHighlightField,
|
||||
...extraExtensions,
|
||||
]}
|
||||
onChange={onChange}
|
||||
onUpdate={onUpdate}
|
||||
theme={editorTheme}
|
||||
onCreateEditor={(_editorView) => setEditorView(_editorView)}
|
||||
/>
|
||||
</div>
|
||||
<TextEditor theme={editorTheme} />
|
||||
</CollapsiblePanel>
|
||||
<section className="flex flex-col">
|
||||
<MemoryPanel
|
||||
@ -518,7 +440,7 @@ export function App() {
|
||||
className={
|
||||
'transition-opacity transition-duration-75 ' +
|
||||
paneOpacity +
|
||||
(isMouseDownInStream ? ' pointer-events-none' : '')
|
||||
(buttonDownInStream ? ' pointer-events-none' : '')
|
||||
}
|
||||
open={openPanes.includes('debug')}
|
||||
/>
|
||||
|
@ -3,8 +3,15 @@ import {
|
||||
createBrowserRouter,
|
||||
Outlet,
|
||||
redirect,
|
||||
useLocation,
|
||||
RouterProvider,
|
||||
} from 'react-router-dom'
|
||||
import {
|
||||
matchRoutes,
|
||||
createRoutesFromChildren,
|
||||
useNavigationType,
|
||||
} from 'react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { ErrorPage } from './components/ErrorPage'
|
||||
import { Settings } from './routes/Settings'
|
||||
import Onboarding, {
|
||||
@ -31,6 +38,40 @@ import {
|
||||
} from './machines/settingsMachine'
|
||||
import { ContextFrom } from 'xstate'
|
||||
import CommandBarProvider from 'components/CommandBar'
|
||||
import { TEST, VITE_KC_SENTRY_DSN } from './env'
|
||||
import * as Sentry from '@sentry/react'
|
||||
|
||||
if (VITE_KC_SENTRY_DSN && !TEST) {
|
||||
Sentry.init({
|
||||
dsn: VITE_KC_SENTRY_DSN,
|
||||
// TODO(paultag): pass in the right env here.
|
||||
// environment: "production",
|
||||
integrations: [
|
||||
new Sentry.BrowserTracing({
|
||||
routingInstrumentation: Sentry.reactRouterV6Instrumentation(
|
||||
useEffect,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
createRoutesFromChildren,
|
||||
matchRoutes
|
||||
),
|
||||
}),
|
||||
new Sentry.Replay(),
|
||||
],
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
tracesSampleRate: 1.0,
|
||||
|
||||
// TODO: Add in kittycad.io endpoints
|
||||
tracePropagationTargets: ['localhost'],
|
||||
|
||||
// Capture Replay for 10% of all sessions,
|
||||
// plus for 100% of sessions with an error
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
const prependRoutes =
|
||||
(routesObject: Record<string, string>) => (prepend: string) => {
|
||||
@ -121,7 +162,9 @@ const router = createBrowserRouter(
|
||||
notEnRouteToOnboarding && hasValidOnboardingStatus
|
||||
|
||||
if (shouldRedirectToOnboarding) {
|
||||
return redirect(makeUrlPathRelative(paths.ONBOARDING.INDEX) + status)
|
||||
return redirect(
|
||||
makeUrlPathRelative(paths.ONBOARDING.INDEX) + status.slice(1)
|
||||
)
|
||||
}
|
||||
|
||||
if (params.id && params.id !== 'new') {
|
||||
|
60
src/Toolbar.module.css
Normal file
60
src/Toolbar.module.css
Normal file
@ -0,0 +1,60 @@
|
||||
.toolbarWrapper {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
@apply flex gap-4 items-center rounded-full;
|
||||
@apply border border-cool-20/30 bg-cool-10/50;
|
||||
}
|
||||
|
||||
:global(.dark) .toolbar {
|
||||
@apply border-cool-100/50 bg-cool-120/50;
|
||||
}
|
||||
|
||||
:global(.sketch) .toolbar {
|
||||
@apply border-fern-20/20 bg-fern-10/20;
|
||||
}
|
||||
|
||||
:global(.dark .sketch) .toolbar {
|
||||
@apply border-fern-120/50 bg-fern-100/30;
|
||||
}
|
||||
|
||||
.toolbarCap {
|
||||
@apply text-sm font-bold;
|
||||
@apply bg-cool-20/50 text-cool-100;
|
||||
}
|
||||
|
||||
:global(.dark) .toolbarCap {
|
||||
@apply bg-cool-90/50 text-cool-30;
|
||||
}
|
||||
|
||||
:global(.sketch) .toolbarCap {
|
||||
@apply bg-fern-20/50 text-fern-100;
|
||||
}
|
||||
|
||||
:global(.dark .sketch) .toolbarCap {
|
||||
@apply bg-fern-90/50 text-fern-30;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply self-stretch flex items-center px-4 py-1;
|
||||
@apply rounded-l-full;
|
||||
}
|
||||
|
||||
.popoverToggle {
|
||||
@apply self-stretch m-0 flex items-center px-4 py-1;
|
||||
@apply rounded-r-full border-none;
|
||||
@apply hover:bg-cool-20;
|
||||
}
|
||||
|
||||
:global(.dark) .popoverToggle {
|
||||
@apply hover:bg-cool-90;
|
||||
}
|
||||
|
||||
:global(.sketch) .popoverToggle {
|
||||
@apply hover:bg-fern-20;
|
||||
}
|
||||
|
||||
:global(.dark .sketch) .popoverToggle {
|
||||
@apply hover:bg-fern-90;
|
||||
}
|
119
src/Toolbar.tsx
119
src/Toolbar.tsx
@ -1,4 +1,4 @@
|
||||
import { useStore, toolTips } from './useStore'
|
||||
import { useStore, toolTips, Selections } from './useStore'
|
||||
import { extrudeSketch, sketchOnExtrudedFace } from './lang/modifyAst'
|
||||
import { getNodePathFromSourceRange } from './lang/queryAst'
|
||||
import { HorzVert } from './components/Toolbar/HorzVert'
|
||||
@ -8,9 +8,15 @@ import { EqualAngle } from './components/Toolbar/EqualAngle'
|
||||
import { Intersect } from './components/Toolbar/Intersect'
|
||||
import { SetHorzVertDistance } from './components/Toolbar/SetHorzVertDistance'
|
||||
import { SetAngleLength } from './components/Toolbar/setAngleLength'
|
||||
import { ConvertToVariable } from './components/Toolbar/ConvertVariable'
|
||||
import { SetAbsDistance } from './components/Toolbar/SetAbsDistance'
|
||||
import { SetAngleBetween } from './components/Toolbar/SetAngleBetween'
|
||||
import { Fragment, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSearch, faX } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Popover, Transition } from '@headlessui/react'
|
||||
import styles from './Toolbar.module.css'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { useAppMode } from 'hooks/useAppMode'
|
||||
|
||||
export const Toolbar = () => {
|
||||
const {
|
||||
@ -20,6 +26,7 @@ export const Toolbar = () => {
|
||||
ast,
|
||||
updateAst,
|
||||
programMemory,
|
||||
engineCommandManager,
|
||||
} = useStore((s) => ({
|
||||
guiMode: s.guiMode,
|
||||
setGuiMode: s.setGuiMode,
|
||||
@ -27,10 +34,17 @@ export const Toolbar = () => {
|
||||
ast: s.ast,
|
||||
updateAst: s.updateAst,
|
||||
programMemory: s.programMemory,
|
||||
engineCommandManager: s.engineCommandManager,
|
||||
}))
|
||||
useAppMode()
|
||||
|
||||
useEffect(() => {
|
||||
console.log('guiMode', guiMode)
|
||||
}, [guiMode])
|
||||
|
||||
function ToolbarButtons() {
|
||||
return (
|
||||
<div>
|
||||
<span className="overflow-x-auto">
|
||||
{guiMode.mode === 'default' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
@ -62,9 +76,18 @@ export const Toolbar = () => {
|
||||
SketchOnFace
|
||||
</button>
|
||||
)}
|
||||
{(guiMode.mode === 'canEditSketch' || false) && (
|
||||
{guiMode.mode === 'canEditSketch' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('guiMode.pathId', guiMode.pathId)
|
||||
engineCommandManager?.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'edit_mode_enter',
|
||||
target: guiMode.pathId,
|
||||
},
|
||||
})
|
||||
setGuiMode({
|
||||
mode: 'sketch',
|
||||
sketchMode: 'sketchEdit',
|
||||
@ -116,14 +139,23 @@ export const Toolbar = () => {
|
||||
)}
|
||||
|
||||
{guiMode.mode === 'sketch' && (
|
||||
<button onClick={() => setGuiMode({ mode: 'default' })}>
|
||||
<button
|
||||
onClick={() => {
|
||||
engineCommandManager?.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: { type: 'edit_mode_exit' },
|
||||
})
|
||||
setGuiMode({ mode: 'default' })
|
||||
}}
|
||||
>
|
||||
Exit sketch
|
||||
</button>
|
||||
)}
|
||||
{toolTips
|
||||
.filter(
|
||||
// (sketchFnName) => !['angledLineThatIntersects'].includes(sketchFnName)
|
||||
(sketchFnName) => ['line'].includes(sketchFnName)
|
||||
(sketchFnName) => ['sketch_line', 'move'].includes(sketchFnName)
|
||||
)
|
||||
.map((sketchFnName) => {
|
||||
if (
|
||||
@ -134,7 +166,18 @@ export const Toolbar = () => {
|
||||
return (
|
||||
<button
|
||||
key={sketchFnName}
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
engineCommandManager?.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'set_tool',
|
||||
tool:
|
||||
guiMode.sketchMode === sketchFnName
|
||||
? 'select'
|
||||
: (sketchFnName as any),
|
||||
},
|
||||
})
|
||||
setGuiMode({
|
||||
...guiMode,
|
||||
...(guiMode.sketchMode === sketchFnName
|
||||
@ -144,18 +187,17 @@ export const Toolbar = () => {
|
||||
}
|
||||
: {
|
||||
sketchMode: sketchFnName,
|
||||
waitingFirstClick: true,
|
||||
isTooltip: true,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{sketchFnName}
|
||||
{guiMode.sketchMode === sketchFnName && '✅'}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<br></br>
|
||||
<ConvertToVariable />
|
||||
<HorzVert horOrVert="horizontal" />
|
||||
<HorzVert horOrVert="vertical" />
|
||||
<EqualLength />
|
||||
@ -173,6 +215,61 @@ export const Toolbar = () => {
|
||||
<Intersect />
|
||||
<RemoveConstrainingValues />
|
||||
<SetAngleBetween />
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover className={styles.toolbarWrapper + ' ' + guiMode.mode}>
|
||||
<div className={styles.toolbar}>
|
||||
<span className={styles.toolbarCap + ' ' + styles.label}>
|
||||
{guiMode.mode === 'sketch' ? '2D' : '3D'}
|
||||
</span>
|
||||
<menu className="flex flex-1 gap-2 py-0.5 overflow-hidden whitespace-nowrap">
|
||||
<ToolbarButtons />
|
||||
</menu>
|
||||
<Popover.Button
|
||||
className={styles.toolbarCap + ' ' + styles.popoverToggle}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSearch} />
|
||||
</Popover.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition ease-out duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Popover.Overlay className="fixed inset-0 bg-chalkboard-110/20 dark:bg-chalkboard-110/50" />
|
||||
</Transition>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 translate-y-1 scale-95"
|
||||
enterTo="opacity-100 translate-y-0 scale-100"
|
||||
leave="transition ease-out duration-75"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-2"
|
||||
>
|
||||
<Popover.Panel className="absolute top-0 w-screen max-w-xl left-1/2 -translate-x-1/2 flex flex-col gap-8 bg-chalkboard-10 dark:bg-chalkboard-100 p-5 rounded border border-chalkboard-20/30 dark:border-chalkboard-70/50">
|
||||
<section className="flex justify-between items-center">
|
||||
<p
|
||||
className={`${styles.toolbarCap} ${styles.label} !self-center rounded-r-full w-fit`}
|
||||
>
|
||||
You're in {guiMode.mode === 'sketch' ? '2D' : '3D'}
|
||||
</p>
|
||||
<Popover.Button className="p-2 flex items-center justify-center rounded-sm bg-chalkboard-20 text-chalkboard-110 dark:bg-chalkboard-70 dark:text-chalkboard-20 border-none hover:bg-chalkboard-30 dark:hover:bg-chalkboard-60">
|
||||
<FontAwesomeIcon icon={faX} className="w-4 h-4" />
|
||||
</Popover.Button>
|
||||
</section>
|
||||
<section>
|
||||
<ToolbarButtons />
|
||||
</section>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
7
src/components/AppHeader.module.css
Normal file
7
src/components/AppHeader.module.css
Normal file
@ -0,0 +1,7 @@
|
||||
/*
|
||||
Some CSS cannot be represented
|
||||
in Tailwind, such as complex grid layouts.
|
||||
*/
|
||||
.header {
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
}
|
@ -3,6 +3,8 @@ import UserSidebarMenu from './UserSidebarMenu'
|
||||
import { ProjectWithEntryPointMetadata } from '../Router'
|
||||
import ProjectSidebarMenu from './ProjectSidebarMenu'
|
||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||
import styles from './AppHeader.module.css'
|
||||
import { NetworkHealthIndicator } from './NetworkHealthIndicator'
|
||||
|
||||
interface AppHeaderProps extends React.PropsWithChildren {
|
||||
showToolbar?: boolean
|
||||
@ -27,7 +29,9 @@ export const AppHeader = ({
|
||||
return (
|
||||
<header
|
||||
className={
|
||||
'overlaid-panes sticky top-0 z-20 py-1 px-5 bg-chalkboard-10/50 dark:bg-chalkboard-100/50 border-b dark:border-b-2 border-chalkboard-30 dark:border-chalkboard-90 flex justify-between items-center ' +
|
||||
(showToolbar ? 'grid ' : 'flex justify-between ') +
|
||||
styles.header +
|
||||
' overlaid-panes sticky top-0 z-20 py-1 px-5 bg-chalkboard-10/70 dark:bg-chalkboard-100/50 border-b dark:border-b-2 border-chalkboard-30 dark:border-chalkboard-90 items-center ' +
|
||||
className
|
||||
}
|
||||
>
|
||||
@ -39,7 +43,12 @@ export const AppHeader = ({
|
||||
</div>
|
||||
)}
|
||||
{/* If there are children, show them, otherwise show User menu */}
|
||||
{children || <UserSidebarMenu user={user} />}
|
||||
{children || (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<NetworkHealthIndicator />
|
||||
<UserSidebarMenu user={user} />
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
@ -198,29 +198,25 @@ export const CreateNewVariable = ({
|
||||
isNewVariableNameUnique,
|
||||
setNewVariableName,
|
||||
shouldCreateVariable,
|
||||
setShouldCreateVariable,
|
||||
setShouldCreateVariable = () => {},
|
||||
showCheckbox = true,
|
||||
}: {
|
||||
isNewVariableNameUnique: boolean
|
||||
newVariableName: string
|
||||
setNewVariableName: (a: string) => void
|
||||
shouldCreateVariable: boolean
|
||||
setShouldCreateVariable: (a: boolean) => void
|
||||
shouldCreateVariable?: boolean
|
||||
setShouldCreateVariable?: (a: boolean) => void
|
||||
showCheckbox?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<label
|
||||
htmlFor="create-new-variable"
|
||||
className="block text-sm font-medium text-gray-700 mt-3 font-mono"
|
||||
>
|
||||
<label htmlFor="create-new-variable" className="block mt-3 font-mono">
|
||||
Create new variable
|
||||
</label>
|
||||
<div className="mt-1 flex flex-1">
|
||||
<div className="mt-1 flex gap-2 items-center">
|
||||
{showCheckbox && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md font-mono pl-1 flex-shrink"
|
||||
checked={shouldCreateVariable}
|
||||
onChange={(e) => {
|
||||
setShouldCreateVariable(e.target.checked)
|
||||
@ -232,7 +228,10 @@ export const CreateNewVariable = ({
|
||||
disabled={!shouldCreateVariable}
|
||||
name="create-new-variable"
|
||||
id="create-new-variable"
|
||||
className={`shadow-sm font-[monospace] focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md font-mono pl-1 flex-shrink-0 ${
|
||||
autoFocus={true}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={`font-mono flex-1 sm:text-sm px-2 py-1 rounded-sm bg-chalkboard-10 dark:bg-chalkboard-90 text-chalkboard-90 dark:text-chalkboard-10 ${
|
||||
!shouldCreateVariable ? 'opacity-50' : ''
|
||||
}`}
|
||||
value={newVariableName}
|
||||
|
19
src/components/CodeMenu.module.css
Normal file
19
src/components/CodeMenu.module.css
Normal file
@ -0,0 +1,19 @@
|
||||
.button {
|
||||
@apply flex justify-between items-center gap-2 px-2 py-1 text-left border-none rounded-sm;
|
||||
@apply font-mono text-xs font-bold select-none text-chalkboard-90;
|
||||
@apply ui-active:bg-liquid-10/50 ui-active:text-liquid-90;
|
||||
@apply transition-colors ease-out;
|
||||
}
|
||||
|
||||
:global(.dark) .button {
|
||||
@apply text-chalkboard-30;
|
||||
@apply ui-active:bg-chalkboard-80 ui-active:text-liquid-10;
|
||||
}
|
||||
|
||||
.button small {
|
||||
@apply text-chalkboard-60;
|
||||
}
|
||||
|
||||
:global(.dark) .button small {
|
||||
@apply text-chalkboard-40;
|
||||
}
|
59
src/components/CodeMenu.tsx
Normal file
59
src/components/CodeMenu.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { Menu } from '@headlessui/react'
|
||||
import { PropsWithChildren } from 'react'
|
||||
import { faEllipsis } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ActionIcon } from './ActionIcon'
|
||||
import { useStore } from 'useStore'
|
||||
import styles from './CodeMenu.module.css'
|
||||
import { useConvertToVariable } from 'hooks/useToolbarGuards'
|
||||
import { editorShortcutMeta } from './TextEditor'
|
||||
|
||||
export const CodeMenu = ({ children }: PropsWithChildren) => {
|
||||
const { formatCode } = useStore((s) => ({
|
||||
formatCode: s.formatCode,
|
||||
}))
|
||||
const { enable: convertToVarEnabled, handleClick: handleConvertToVarClick } =
|
||||
useConvertToVariable()
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<div
|
||||
className="relative"
|
||||
onClick={(e) => {
|
||||
if (e.eventPhase === 3) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Menu.Button className="p-0 border-none relative">
|
||||
<ActionIcon
|
||||
icon={faEllipsis}
|
||||
bgClassName={
|
||||
'bg-chalkboard-20 dark:bg-chalkboard-110 hover:bg-liquid-10/50 hover:dark:bg-chalkboard-90 ui-active:bg-chalkboard-80 ui-active:dark:bg-chalkboard-90 rounded'
|
||||
}
|
||||
iconClassName={'text-chalkboard-90 dark:text-chalkboard-40'}
|
||||
/>
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute right-0 left-auto w-72 flex flex-col gap-1 divide-y divide-chalkboard-20 dark:divide-chalkboard-70 align-stretch px-0 py-1 bg-chalkboard-10 dark:bg-chalkboard-90 rounded-sm shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50">
|
||||
<Menu.Item>
|
||||
<button onClick={() => formatCode()} className={styles.button}>
|
||||
<span>Format code</span>
|
||||
<small>{editorShortcutMeta.formatCode.display}</small>
|
||||
</button>
|
||||
</Menu.Item>
|
||||
{convertToVarEnabled && (
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={handleConvertToVarClick}
|
||||
className={styles.button}
|
||||
>
|
||||
<span>Convert to Variable</span>
|
||||
<small>{editorShortcutMeta.convertToVariable.display}</small>
|
||||
</button>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Items>
|
||||
</div>
|
||||
</Menu>
|
||||
)
|
||||
}
|
@ -1,15 +1,15 @@
|
||||
.panel {
|
||||
@apply relative overflow-auto z-0;
|
||||
@apply bg-chalkboard-20/40;
|
||||
@apply relative z-0;
|
||||
@apply bg-chalkboard-10/70 backdrop-blur-sm;
|
||||
}
|
||||
|
||||
:global(.dark) .panel {
|
||||
@apply bg-chalkboard-110/50;
|
||||
@apply bg-chalkboard-110/50 backdrop-blur-0;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply sticky top-0 z-10 cursor-pointer;
|
||||
@apply flex items-center gap-2 w-full p-2;
|
||||
@apply flex items-center justify-between gap-2 w-full p-2;
|
||||
@apply font-mono text-xs font-bold select-none text-chalkboard-90;
|
||||
@apply bg-chalkboard-20;
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ export interface CollapsiblePanelProps
|
||||
title: string
|
||||
icon?: IconDefinition
|
||||
open?: boolean
|
||||
menu?: React.ReactNode
|
||||
iconClassNames?: {
|
||||
bg?: string
|
||||
icon?: string
|
||||
@ -18,9 +19,11 @@ export const PanelHeader = ({
|
||||
title,
|
||||
icon,
|
||||
iconClassNames,
|
||||
menu,
|
||||
}: CollapsiblePanelProps) => {
|
||||
return (
|
||||
<summary className={styles.header}>
|
||||
<div className="flex gap-2 align-center flex-1">
|
||||
<ActionIcon
|
||||
icon={icon}
|
||||
bgClassName={
|
||||
@ -33,6 +36,10 @@ export const PanelHeader = ({
|
||||
}
|
||||
/>
|
||||
{title}
|
||||
</div>
|
||||
<div className="group-open:opacity-100 opacity-0 group-open:pointer-events-auto pointer-events-none">
|
||||
{menu}
|
||||
</div>
|
||||
</summary>
|
||||
)
|
||||
}
|
||||
@ -43,6 +50,7 @@ export const CollapsiblePanel = ({
|
||||
children,
|
||||
className,
|
||||
iconClassNames,
|
||||
menu,
|
||||
...props
|
||||
}: CollapsiblePanelProps) => {
|
||||
return (
|
||||
@ -50,7 +58,12 @@ export const CollapsiblePanel = ({
|
||||
{...props}
|
||||
className={styles.panel + ' group ' + (className || '')}
|
||||
>
|
||||
<PanelHeader title={title} icon={icon} iconClassNames={iconClassNames} />
|
||||
<PanelHeader
|
||||
title={title}
|
||||
icon={icon}
|
||||
iconClassNames={iconClassNames}
|
||||
menu={menu}
|
||||
/>
|
||||
{children}
|
||||
</details>
|
||||
)
|
||||
|
@ -196,7 +196,7 @@ const CommandBar = () => {
|
||||
setCommandBarOpen(false)
|
||||
clearState()
|
||||
}}
|
||||
className="fixed inset-0 overflow-y-auto p-4 pt-[25vh]"
|
||||
className="fixed inset-0 z-40 overflow-y-auto p-4 pt-[25vh]"
|
||||
>
|
||||
<Transition.Child
|
||||
enter="duration-100 ease-out"
|
||||
@ -207,7 +207,7 @@ const CommandBar = () => {
|
||||
leaveTo="opacity-0"
|
||||
as={Fragment}
|
||||
>
|
||||
<Dialog.Overlay className="fixed z-40 inset-0 bg-chalkboard-10/70 dark:bg-chalkboard-110/50" />
|
||||
<Dialog.Overlay className="fixed inset-0 bg-chalkboard-10/70 dark:bg-chalkboard-110/50" />
|
||||
</Transition.Child>
|
||||
<Transition.Child
|
||||
enter="duration-100 ease-out"
|
||||
@ -221,7 +221,7 @@ const CommandBar = () => {
|
||||
<Combobox
|
||||
value={selectedCommand}
|
||||
onChange={handleCommandSelection}
|
||||
className="rounded relative mx-auto z-40 p-2 bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-xl w-full shadow-lg"
|
||||
className="rounded relative mx-auto p-2 bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-xl w-full shadow-lg"
|
||||
as="div"
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
|
@ -39,7 +39,7 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
|
||||
const initialValues: OutputFormat = {
|
||||
type: defaultType,
|
||||
storage: 'embedded',
|
||||
presentation: 'compact',
|
||||
presentation: 'pretty',
|
||||
}
|
||||
const formik = useFormik({
|
||||
initialValues,
|
||||
@ -83,8 +83,6 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
|
||||
},
|
||||
})
|
||||
|
||||
const yo = formik.values
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionButton
|
||||
|
@ -15,7 +15,7 @@ import {
|
||||
settingsMachine,
|
||||
} from 'machines/settingsMachine'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { setThemeClass } from 'lib/theme'
|
||||
import { setThemeClass, Themes } from 'lib/theme'
|
||||
import {
|
||||
AnyStateMachine,
|
||||
ContextFrom,
|
||||
@ -87,10 +87,21 @@ export const GlobalStateProvider = ({
|
||||
commandBarMeta: settingsCommandBarMeta,
|
||||
})
|
||||
|
||||
useEffect(
|
||||
() => setThemeClass(settingsState.context.theme),
|
||||
[settingsState.context.theme]
|
||||
)
|
||||
// Listen for changes to the system theme and update the app theme accordingly
|
||||
// This is only done if the theme setting is set to 'system'.
|
||||
// It can't be done in XState (in an invoked callback, for example)
|
||||
// because there doesn't seem to be a good way to listen to
|
||||
// events outside of the machine that also depend on the machine's context
|
||||
useEffect(() => {
|
||||
const matcher = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const listener = (e: MediaQueryListEvent) => {
|
||||
if (settingsState.context.theme !== 'system') return
|
||||
setThemeClass(e.matches ? Themes.Dark : Themes.Light)
|
||||
}
|
||||
|
||||
matcher.addEventListener('change', listener)
|
||||
return () => matcher.removeEventListener('change', listener)
|
||||
}, [settingsState.context])
|
||||
|
||||
// Auth machine setup
|
||||
const [authState, authSend] = useMachine(authMachine, {
|
||||
|
51
src/components/NetworkHealthIndicator.test.tsx
Normal file
51
src/components/NetworkHealthIndicator.test.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import UserSidebarMenu from './UserSidebarMenu'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { GlobalStateProvider } from './GlobalStateProvider'
|
||||
import CommandBarProvider from './CommandBar'
|
||||
import {
|
||||
NETWORK_CONTENT,
|
||||
NetworkHealthIndicator,
|
||||
} from './NetworkHealthIndicator'
|
||||
|
||||
function TestWrap({ children }: { children: React.ReactNode }) {
|
||||
// wrap in router and xState context
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<CommandBarProvider>
|
||||
<GlobalStateProvider>{children}</GlobalStateProvider>
|
||||
</CommandBarProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('NetworkHealthIndicator tests', () => {
|
||||
test('Renders the network indicator', () => {
|
||||
render(
|
||||
<TestWrap>
|
||||
<NetworkHealthIndicator />
|
||||
</TestWrap>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('network-toggle'))
|
||||
|
||||
expect(screen.getByTestId('network-good')).toHaveTextContent(
|
||||
NETWORK_CONTENT.good
|
||||
)
|
||||
})
|
||||
|
||||
test('Responds to network changes', () => {
|
||||
render(
|
||||
<TestWrap>
|
||||
<NetworkHealthIndicator />
|
||||
</TestWrap>
|
||||
)
|
||||
|
||||
fireEvent.offline(window)
|
||||
fireEvent.click(screen.getByTestId('network-toggle'))
|
||||
|
||||
expect(screen.getByTestId('network-bad')).toHaveTextContent(
|
||||
NETWORK_CONTENT.bad
|
||||
)
|
||||
})
|
||||
})
|
112
src/components/NetworkHealthIndicator.tsx
Normal file
112
src/components/NetworkHealthIndicator.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import {
|
||||
faCheck,
|
||||
faExclamation,
|
||||
faWifi,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { Popover } from '@headlessui/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ActionIcon } from './ActionIcon'
|
||||
|
||||
export const NETWORK_CONTENT = {
|
||||
good: 'Network health is good',
|
||||
bad: 'Network issue',
|
||||
}
|
||||
|
||||
const NETWORK_MESSAGES = {
|
||||
offline: 'You are offline',
|
||||
}
|
||||
|
||||
export const NetworkHealthIndicator = () => {
|
||||
const [networkIssues, setNetworkIssues] = useState<string[]>([])
|
||||
const hasIssues = [...networkIssues.values()].length > 0
|
||||
|
||||
useEffect(() => {
|
||||
const offlineListener = () =>
|
||||
setNetworkIssues((issues) => {
|
||||
return [
|
||||
...issues.filter((issue) => issue !== NETWORK_MESSAGES.offline),
|
||||
NETWORK_MESSAGES.offline,
|
||||
]
|
||||
})
|
||||
window.addEventListener('offline', offlineListener)
|
||||
|
||||
const onlineListener = () =>
|
||||
setNetworkIssues((issues) => {
|
||||
return [...issues.filter((issue) => issue !== NETWORK_MESSAGES.offline)]
|
||||
})
|
||||
window.addEventListener('online', onlineListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('offline', offlineListener)
|
||||
window.removeEventListener('online', onlineListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<Popover.Button
|
||||
className={
|
||||
'p-0 border-none relative ' +
|
||||
(hasIssues
|
||||
? 'focus-visible:outline-destroy-80'
|
||||
: 'focus-visible:outline-succeed-80')
|
||||
}
|
||||
data-testid="network-toggle"
|
||||
>
|
||||
<span className="sr-only">Network Health</span>
|
||||
<ActionIcon
|
||||
icon={faWifi}
|
||||
iconClassName={
|
||||
hasIssues
|
||||
? 'text-destroy-80 dark:text-destroy-30'
|
||||
: 'text-succeed-80 dark:text-succeed-30'
|
||||
}
|
||||
bgClassName={
|
||||
hasIssues
|
||||
? 'hover:bg-destroy-10/50 hover:dark:bg-destroy-80/50 rounded'
|
||||
: 'hover:bg-succeed-10/50 hover:dark:bg-succeed-80/50 rounded'
|
||||
}
|
||||
/>
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="absolute right-0 left-auto top-full mt-1 w-56 flex flex-col gap-1 divide-y divide-chalkboard-20 dark:divide-chalkboard-70 align-stretch py-2 bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm">
|
||||
{!hasIssues ? (
|
||||
<span
|
||||
className="flex items-center justify-center gap-1 px-4"
|
||||
data-testid="network-good"
|
||||
>
|
||||
<ActionIcon
|
||||
icon={faCheck}
|
||||
bgClassName={'bg-succeed-10/50 dark:bg-succeed-80/50 rounded'}
|
||||
iconClassName={'text-succeed-80 dark:text-succeed-30'}
|
||||
/>
|
||||
{NETWORK_CONTENT.good}
|
||||
</span>
|
||||
) : (
|
||||
<ul className="divide-y divide-chalkboard-20 dark:divide-chalkboard-80">
|
||||
<span
|
||||
className="font-bold text-xs uppercase text-destroy-60 dark:text-destroy-50 px-4"
|
||||
data-testid="network-bad"
|
||||
>
|
||||
{NETWORK_CONTENT.bad}
|
||||
{networkIssues.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
{networkIssues.map((issue) => (
|
||||
<li
|
||||
key={issue}
|
||||
className="flex items-center gap-1 py-2 my-2 last:mb-0"
|
||||
>
|
||||
<ActionIcon
|
||||
icon={faExclamation}
|
||||
bgClassName={'bg-destroy-10/50 dark:bg-destroy-80/50 rounded'}
|
||||
iconClassName={'text-destroy-80 dark:text-destroy-30'}
|
||||
className="ml-4"
|
||||
/>
|
||||
<p className="flex-1 mr-4">{issue}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
)
|
||||
}
|
@ -1,10 +1,11 @@
|
||||
import { Popover } from '@headlessui/react'
|
||||
import { Popover, Transition } from '@headlessui/react'
|
||||
import { ActionButton } from './ActionButton'
|
||||
import { faHome } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ProjectWithEntryPointMetadata, paths } from '../Router'
|
||||
import { isTauri } from '../lib/isTauri'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ExportButton } from './ExportButton'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
const ProjectSidebarMenu = ({
|
||||
project,
|
||||
@ -34,7 +35,7 @@ const ProjectSidebarMenu = ({
|
||||
) : (
|
||||
<Popover className="relative">
|
||||
<Popover.Button
|
||||
className="border-0 px-1 pr-2 pl-0 flex items-center gap-4 focus:outline-none focus:ring-2 focus:ring-energy-50"
|
||||
className="border-0 p-0.5 pr-2 flex items-center gap-4 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-energy-50"
|
||||
data-testid="project-sidebar-toggle"
|
||||
>
|
||||
<img
|
||||
@ -46,9 +47,28 @@ const ProjectSidebarMenu = ({
|
||||
{isTauri() && project?.name ? project.name : 'KittyCAD Modeling App'}
|
||||
</span>
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
enter="duration-200 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="duration-100 ease-in"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
as={Fragment}
|
||||
>
|
||||
<Popover.Overlay className="fixed z-20 inset-0 bg-chalkboard-110/50" />
|
||||
</Transition>
|
||||
|
||||
<Popover.Panel className="fixed inset-0 right-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-energy-100 shadow-md rounded-r-lg overflow-hidden">
|
||||
<Transition
|
||||
enter="duration-100 ease-out"
|
||||
enterFrom="opacity-0 -translate-x-1/4"
|
||||
enterTo="opacity-100 translate-x-0"
|
||||
leave="duration-75 ease-in"
|
||||
leaveFrom="opacity-100 translate-x-0"
|
||||
leaveTo="opacity-0 -translate-x-4"
|
||||
as={Fragment}
|
||||
>
|
||||
<Popover.Panel className="fixed inset-0 right-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-energy-100 dark:border-energy-100/50 shadow-md rounded-r-lg overflow-hidden">
|
||||
<div className="flex items-center gap-4 px-4 py-3 bg-energy-100">
|
||||
<img
|
||||
src="/kitt-8bit-winking.svg"
|
||||
@ -64,7 +84,10 @@ const ProjectSidebarMenu = ({
|
||||
{project?.name ? project.name : 'KittyCAD Modeling App'}
|
||||
</p>
|
||||
{project?.entrypoint_metadata && (
|
||||
<p className="m-0 text-energy-40 text-xs" data-testid="createdAt">
|
||||
<p
|
||||
className="m-0 text-energy-40 text-xs"
|
||||
data-testid="createdAt"
|
||||
>
|
||||
Created{' '}
|
||||
{project?.entrypoint_metadata.createdAt.toLocaleDateString()}
|
||||
</p>
|
||||
@ -94,6 +117,7 @@ const ProjectSidebarMenu = ({
|
||||
)}
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { Fragment } from 'react'
|
||||
import { useCalc, CreateNewVariable } from './AvailableVarsHelpers'
|
||||
import { ActionButton } from './ActionButton'
|
||||
import { faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
export const SetVarNameModal = ({
|
||||
isOpen,
|
||||
@ -19,21 +22,23 @@ export const SetVarNameModal = ({
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onReject}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="fixed inset-0 z-40 overflow-y-auto p-4 pt-[25vh]"
|
||||
onClose={onReject}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
enterFrom="opacity-0 translate-y-4"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="ease-in duration-75"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
<Dialog.Overlay className="fixed inset-0 bg-chalkboard-10/70 dark:bg-chalkboard-110/50" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@ -43,43 +48,39 @@ export const SetVarNameModal = ({
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-gray-900 capitalize"
|
||||
<Dialog.Panel className="rounded relative mx-auto px-4 py-8 bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-xl w-full shadow-lg">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
onResolve({
|
||||
variableName: newVariableName,
|
||||
})
|
||||
toast.success(`Added variable ${newVariableName}`)
|
||||
}}
|
||||
>
|
||||
Set {valueName}
|
||||
</Dialog.Title>
|
||||
|
||||
<CreateNewVariable
|
||||
setNewVariableName={setNewVariableName}
|
||||
newVariableName={newVariableName}
|
||||
isNewVariableNameUnique={isNewVariableNameUnique}
|
||||
shouldCreateVariable={true}
|
||||
setShouldCreateVariable={() => {}}
|
||||
showCheckbox={false}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
<div className="mt-8 flex justify-between">
|
||||
<ActionButton
|
||||
Element="button"
|
||||
type="submit"
|
||||
disabled={!isNewVariableNameUnique}
|
||||
className={`inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 ${
|
||||
!isNewVariableNameUnique
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
onResolve({
|
||||
variableName: newVariableName,
|
||||
})
|
||||
}
|
||||
icon={{ icon: faPlus }}
|
||||
>
|
||||
Add variable
|
||||
</button>
|
||||
</ActionButton>
|
||||
<ActionButton Element="button" onClick={() => onReject(false)}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)
|
||||
|
@ -7,31 +7,52 @@ import {
|
||||
} from 'react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { useStore } from '../useStore'
|
||||
import { getNormalisedCoordinates } from '../lib/utils'
|
||||
import { getNormalisedCoordinates, roundOff } from '../lib/utils'
|
||||
import Loading from './Loading'
|
||||
import { cameraMouseDragGuards } from 'lib/cameraControls'
|
||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||
import { CameraDragInteractionType_type } from '@kittycad/lib/dist/types/src/models'
|
||||
import { Models } from '@kittycad/lib'
|
||||
import { addStartSketch } from 'lang/modifyAst'
|
||||
import { addNewSketchLn } from 'lang/std/sketch'
|
||||
|
||||
export const Stream = ({ className = '' }) => {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [clickCoords, setClickCoords] = useState<{ x: number; y: number }>()
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const {
|
||||
mediaStream,
|
||||
engineCommandManager,
|
||||
setIsMouseDownInStream,
|
||||
setCmdId,
|
||||
setButtonDownInStream,
|
||||
didDragInStream,
|
||||
setDidDragInStream,
|
||||
streamDimensions,
|
||||
isExecuting,
|
||||
guiMode,
|
||||
ast,
|
||||
updateAst,
|
||||
setGuiMode,
|
||||
programMemory,
|
||||
} = useStore((s) => ({
|
||||
mediaStream: s.mediaStream,
|
||||
engineCommandManager: s.engineCommandManager,
|
||||
isMouseDownInStream: s.isMouseDownInStream,
|
||||
setIsMouseDownInStream: s.setIsMouseDownInStream,
|
||||
setButtonDownInStream: s.setButtonDownInStream,
|
||||
fileId: s.fileId,
|
||||
setCmdId: s.setCmdId,
|
||||
didDragInStream: s.didDragInStream,
|
||||
setDidDragInStream: s.setDidDragInStream,
|
||||
streamDimensions: s.streamDimensions,
|
||||
isExecuting: s.isExecuting,
|
||||
guiMode: s.guiMode,
|
||||
ast: s.ast,
|
||||
updateAst: s.updateAst,
|
||||
setGuiMode: s.setGuiMode,
|
||||
programMemory: s.programMemory,
|
||||
}))
|
||||
const {
|
||||
settings: {
|
||||
context: { cameraControls },
|
||||
},
|
||||
} = useGlobalStateContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@ -44,25 +65,52 @@ export const Stream = ({ className = '' }) => {
|
||||
videoRef.current.srcObject = mediaStream
|
||||
}, [mediaStream, engineCommandManager])
|
||||
|
||||
const handleMouseDown: MouseEventHandler<HTMLVideoElement> = ({
|
||||
clientX,
|
||||
clientY,
|
||||
ctrlKey,
|
||||
}) => {
|
||||
const handleMouseDown: MouseEventHandler<HTMLVideoElement> = (e) => {
|
||||
if (!videoRef.current) return
|
||||
const { x, y } = getNormalisedCoordinates({
|
||||
clientX,
|
||||
clientY,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
el: videoRef.current,
|
||||
...streamDimensions,
|
||||
})
|
||||
console.log('click', x, y)
|
||||
|
||||
const newId = uuidv4()
|
||||
setCmdId(newId)
|
||||
|
||||
const interaction = ctrlKey ? 'pan' : 'rotate'
|
||||
const interactionGuards = cameraMouseDragGuards[cameraControls]
|
||||
let interaction: CameraDragInteractionType_type = 'rotate'
|
||||
|
||||
if (
|
||||
interactionGuards.pan.callback(e) ||
|
||||
interactionGuards.pan.lenientDragStartButton === e.button
|
||||
) {
|
||||
interaction = 'pan'
|
||||
} else if (
|
||||
interactionGuards.rotate.callback(e) ||
|
||||
interactionGuards.rotate.lenientDragStartButton === e.button
|
||||
) {
|
||||
interaction = 'rotate'
|
||||
} else if (
|
||||
interactionGuards.zoom.dragCallback(e) ||
|
||||
interactionGuards.zoom.lenientDragStartButton === e.button
|
||||
) {
|
||||
interaction = 'zoom'
|
||||
}
|
||||
|
||||
if (guiMode.mode === 'sketch' && guiMode.sketchMode === ('move' as any)) {
|
||||
engineCommandManager?.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'handle_mouse_drag_start',
|
||||
window: { x, y },
|
||||
},
|
||||
cmd_id: newId,
|
||||
})
|
||||
} else if (
|
||||
!(
|
||||
guiMode.mode === 'sketch' &&
|
||||
guiMode.sketchMode === ('sketch_line' as any)
|
||||
)
|
||||
) {
|
||||
engineCommandManager?.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
@ -72,12 +120,15 @@ export const Stream = ({ className = '' }) => {
|
||||
},
|
||||
cmd_id: newId,
|
||||
})
|
||||
}
|
||||
|
||||
setIsMouseDownInStream(true)
|
||||
setButtonDownInStream(e.button)
|
||||
setClickCoords({ x, y })
|
||||
}
|
||||
|
||||
const handleScroll: WheelEventHandler<HTMLVideoElement> = (e) => {
|
||||
e.preventDefault()
|
||||
if (!cameraMouseDragGuards[cameraControls].zoom.scrollCallback(e)) return
|
||||
|
||||
engineCommandManager?.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
@ -94,6 +145,7 @@ export const Stream = ({ className = '' }) => {
|
||||
ctrlKey,
|
||||
}) => {
|
||||
if (!videoRef.current) return
|
||||
setButtonDownInStream(undefined)
|
||||
const { x, y } = getNormalisedCoordinates({
|
||||
clientX,
|
||||
clientY,
|
||||
@ -104,7 +156,7 @@ export const Stream = ({ className = '' }) => {
|
||||
const newCmdId = uuidv4()
|
||||
const interaction = ctrlKey ? 'pan' : 'rotate'
|
||||
|
||||
engineCommandManager?.sendSceneCommand({
|
||||
const command: Models['WebSocketRequest_type'] = {
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'camera_drag_end',
|
||||
@ -112,9 +164,8 @@ export const Stream = ({ className = '' }) => {
|
||||
window: { x, y },
|
||||
},
|
||||
cmd_id: newCmdId,
|
||||
})
|
||||
}
|
||||
|
||||
setIsMouseDownInStream(false)
|
||||
if (!didDragInStream) {
|
||||
engineCommandManager?.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
@ -126,7 +177,109 @@ export const Stream = ({ className = '' }) => {
|
||||
cmd_id: uuidv4(),
|
||||
})
|
||||
}
|
||||
|
||||
if (!didDragInStream && guiMode.mode === 'default') {
|
||||
command.cmd = {
|
||||
type: 'select_with_point',
|
||||
selection_type: 'add',
|
||||
selected_at_window: { x, y },
|
||||
}
|
||||
} else if (
|
||||
(!didDragInStream &&
|
||||
guiMode.mode === 'sketch' &&
|
||||
['move', 'select'].includes(guiMode.sketchMode)) ||
|
||||
(guiMode.mode === 'sketch' &&
|
||||
guiMode.sketchMode === ('sketch_line' as any))
|
||||
) {
|
||||
command.cmd = {
|
||||
type: 'mouse_click',
|
||||
window: { x, y },
|
||||
}
|
||||
} else if (
|
||||
guiMode.mode === 'sketch' &&
|
||||
guiMode.sketchMode === ('move' as any)
|
||||
) {
|
||||
command.cmd = {
|
||||
type: 'handle_mouse_drag_end',
|
||||
window: { x, y },
|
||||
}
|
||||
}
|
||||
engineCommandManager?.sendSceneCommand(command).then(async ({ data }) => {
|
||||
if (command.cmd.type !== 'mouse_click' || !ast) return
|
||||
if (
|
||||
!(
|
||||
guiMode.mode === 'sketch' &&
|
||||
guiMode.sketchMode === ('sketch_line' as any as 'line')
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if (data?.data?.entities_modified?.length && guiMode.waitingFirstClick) {
|
||||
const curve = await engineCommandManager?.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'curve_get_control_points',
|
||||
curve_id: data?.data?.entities_modified[0],
|
||||
},
|
||||
})
|
||||
const coords: { x: number; y: number }[] =
|
||||
curve.data.data.control_points
|
||||
const _addStartSketch = addStartSketch(
|
||||
ast,
|
||||
[roundOff(coords[0].x), roundOff(coords[0].y)],
|
||||
[
|
||||
roundOff(coords[1].x - coords[0].x),
|
||||
roundOff(coords[1].y - coords[0].y),
|
||||
]
|
||||
)
|
||||
const _modifiedAst = _addStartSketch.modifiedAst
|
||||
const _pathToNode = _addStartSketch.pathToNode
|
||||
|
||||
setGuiMode({
|
||||
...guiMode,
|
||||
pathToNode: _pathToNode,
|
||||
waitingFirstClick: false,
|
||||
})
|
||||
updateAst(_modifiedAst)
|
||||
} else if (
|
||||
data?.data?.entities_modified?.length &&
|
||||
!guiMode.waitingFirstClick
|
||||
) {
|
||||
const curve = await engineCommandManager?.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'curve_get_control_points',
|
||||
curve_id: data?.data?.entities_modified[0],
|
||||
},
|
||||
})
|
||||
const coords: { x: number; y: number }[] =
|
||||
curve.data.data.control_points
|
||||
const _modifiedAst = addNewSketchLn({
|
||||
node: ast,
|
||||
programMemory,
|
||||
to: [coords[1].x, coords[1].y],
|
||||
fnName: 'line',
|
||||
pathToNode: guiMode.pathToNode,
|
||||
}).modifiedAst
|
||||
updateAst(_modifiedAst)
|
||||
}
|
||||
})
|
||||
setDidDragInStream(false)
|
||||
setClickCoords(undefined)
|
||||
}
|
||||
|
||||
const handleMouseMove: MouseEventHandler<HTMLVideoElement> = (e) => {
|
||||
if (!clickCoords) return
|
||||
|
||||
const delta =
|
||||
((clickCoords.x - e.clientX) ** 2 + (clickCoords.y - e.clientY) ** 2) **
|
||||
0.5
|
||||
|
||||
if (delta > 5 && !didDragInStream) {
|
||||
setDidDragInStream(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -142,7 +295,9 @@ export const Stream = ({ className = '' }) => {
|
||||
onContextMenuCapture={(e) => e.preventDefault()}
|
||||
onWheel={handleScroll}
|
||||
onPlay={() => setIsLoading(false)}
|
||||
className="w-full h-full"
|
||||
onMouseMoveCapture={handleMouseMove}
|
||||
className={`w-full h-full ${isExecuting && 'blur-md'}`}
|
||||
style={{ transitionDuration: '200ms', transitionProperty: 'filter' }}
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="text-center absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
|
265
src/components/TextEditor.tsx
Normal file
265
src/components/TextEditor.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
import ReactCodeMirror, {
|
||||
Extension,
|
||||
ViewUpdate,
|
||||
keymap,
|
||||
} from '@uiw/react-codemirror'
|
||||
import { FromServer, IntoServer } from 'editor/lsp/codec'
|
||||
import Server from '../editor/lsp/server'
|
||||
import Client from '../editor/lsp/client'
|
||||
import { TEST } from 'env'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||
import { useConvertToVariable } from 'hooks/useToolbarGuards'
|
||||
import { Themes } from 'lib/theme'
|
||||
import { useMemo } from 'react'
|
||||
import { linter, lintGutter } from '@codemirror/lint'
|
||||
import { Selections, useStore } from 'useStore'
|
||||
import { LanguageServerClient } from 'editor/lsp'
|
||||
import kclLanguage from 'editor/lsp/language'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { writeTextFile } from '@tauri-apps/api/fs'
|
||||
import { PROJECT_ENTRYPOINT } from 'lib/tauriFS'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import {
|
||||
EditorView,
|
||||
addLineHighlight,
|
||||
lineHighlightField,
|
||||
} from 'editor/highlightextension'
|
||||
import { isOverlap } from 'lib/utils'
|
||||
import { kclErrToDiagnostic } from 'lang/errors'
|
||||
import { CSSRuleObject } from 'tailwindcss/types/config'
|
||||
|
||||
export const editorShortcutMeta = {
|
||||
formatCode: {
|
||||
codeMirror: 'Alt-Shift-f',
|
||||
display: 'Alt + Shift + F',
|
||||
},
|
||||
convertToVariable: {
|
||||
codeMirror: 'Ctrl-Shift-c',
|
||||
display: 'Ctrl + Shift + C',
|
||||
},
|
||||
}
|
||||
|
||||
export const TextEditor = ({
|
||||
theme,
|
||||
}: {
|
||||
theme: Themes.Light | Themes.Dark
|
||||
}) => {
|
||||
const pathParams = useParams()
|
||||
const {
|
||||
code,
|
||||
defferedSetCode,
|
||||
editorView,
|
||||
engineCommandManager,
|
||||
formatCode,
|
||||
isLSPServerReady,
|
||||
selectionRanges,
|
||||
selectionRangeTypeMap,
|
||||
setEditorView,
|
||||
setIsLSPServerReady,
|
||||
setSelectionRanges,
|
||||
sourceRangeMap,
|
||||
} = useStore((s) => ({
|
||||
code: s.code,
|
||||
defferedSetCode: s.defferedSetCode,
|
||||
editorView: s.editorView,
|
||||
engineCommandManager: s.engineCommandManager,
|
||||
formatCode: s.formatCode,
|
||||
isLSPServerReady: s.isLSPServerReady,
|
||||
selectionRanges: s.selectionRanges,
|
||||
selectionRangeTypeMap: s.selectionRangeTypeMap,
|
||||
setEditorView: s.setEditorView,
|
||||
setIsLSPServerReady: s.setIsLSPServerReady,
|
||||
setSelectionRanges: s.setSelectionRanges,
|
||||
sourceRangeMap: s.sourceRangeMap,
|
||||
}))
|
||||
|
||||
const {
|
||||
settings: {
|
||||
context: { textWrapping },
|
||||
},
|
||||
} = useGlobalStateContext()
|
||||
const { setCommandBarOpen } = useCommandsContext()
|
||||
const { enable: convertEnabled, handleClick: convertCallback } =
|
||||
useConvertToVariable()
|
||||
|
||||
// So this is a bit weird, we need to initialize the lsp server and client.
|
||||
// But the server happens async so we break this into two parts.
|
||||
// Below is the client and server promise.
|
||||
const { lspClient } = useMemo(() => {
|
||||
const intoServer: IntoServer = new IntoServer()
|
||||
const fromServer: FromServer = FromServer.create()
|
||||
const client = new Client(fromServer, intoServer)
|
||||
if (!TEST) {
|
||||
Server.initialize(intoServer, fromServer).then((lspServer) => {
|
||||
lspServer.start()
|
||||
setIsLSPServerReady(true)
|
||||
})
|
||||
}
|
||||
|
||||
const lspClient = new LanguageServerClient({ client })
|
||||
return { lspClient }
|
||||
}, [setIsLSPServerReady])
|
||||
|
||||
// Here we initialize the plugin which will start the client.
|
||||
// When we have multi-file support the name of the file will be a dep of
|
||||
// this use memo, as well as the directory structure, which I think is
|
||||
// a good setup becuase it will restart the client but not the server :)
|
||||
// We do not want to restart the server, its just wasteful.
|
||||
const kclLSP = useMemo(() => {
|
||||
let plugin = null
|
||||
if (isLSPServerReady && !TEST) {
|
||||
// Set up the lsp plugin.
|
||||
const lsp = kclLanguage({
|
||||
// When we have more than one file, we'll need to change this.
|
||||
documentUri: `file:///we-just-have-one-file-for-now.kcl`,
|
||||
workspaceFolders: null,
|
||||
client: lspClient,
|
||||
})
|
||||
|
||||
plugin = lsp
|
||||
}
|
||||
return plugin
|
||||
}, [lspClient, isLSPServerReady])
|
||||
|
||||
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
|
||||
const onChange = (value: string, viewUpdate: ViewUpdate) => {
|
||||
defferedSetCode(value)
|
||||
if (isTauri() && pathParams.id) {
|
||||
// Save the file to disk
|
||||
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
|
||||
writeTextFile(pathParams.id + '/' + PROJECT_ENTRYPOINT, value).catch(
|
||||
(err) => {
|
||||
// TODO: add Sentry per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254)
|
||||
console.error('error saving file', err)
|
||||
toast.error('Error saving file, please check file permissions')
|
||||
}
|
||||
)
|
||||
}
|
||||
if (editorView) {
|
||||
editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
|
||||
}
|
||||
} //, []);
|
||||
const onUpdate = (viewUpdate: ViewUpdate) => {
|
||||
if (!editorView) {
|
||||
setEditorView(viewUpdate.view)
|
||||
}
|
||||
const ranges = viewUpdate.state.selection.ranges
|
||||
|
||||
const isChange =
|
||||
ranges.length !== selectionRanges.codeBasedSelections.length ||
|
||||
ranges.some(({ from, to }, i) => {
|
||||
return (
|
||||
from !== selectionRanges.codeBasedSelections[i].range[0] ||
|
||||
to !== selectionRanges.codeBasedSelections[i].range[1]
|
||||
)
|
||||
})
|
||||
|
||||
if (!isChange) return
|
||||
const codeBasedSelections: Selections['codeBasedSelections'] = ranges.map(
|
||||
({ from, to }) => {
|
||||
if (selectionRangeTypeMap[to]) {
|
||||
return {
|
||||
type: selectionRangeTypeMap[to],
|
||||
range: [from, to],
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'default',
|
||||
range: [from, to],
|
||||
}
|
||||
}
|
||||
)
|
||||
const idBasedSelections = codeBasedSelections
|
||||
.map(({ type, range }) => {
|
||||
const hasOverlap = Object.entries(sourceRangeMap).filter(
|
||||
([_, sourceRange]) => {
|
||||
return isOverlap(sourceRange, range)
|
||||
}
|
||||
)
|
||||
if (hasOverlap.length) {
|
||||
return {
|
||||
type,
|
||||
id: hasOverlap[0][0],
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as any
|
||||
|
||||
engineCommandManager?.cusorsSelected({
|
||||
otherSelections: [],
|
||||
idBasedSelections,
|
||||
})
|
||||
|
||||
setSelectionRanges({
|
||||
otherSelections: [],
|
||||
codeBasedSelections,
|
||||
})
|
||||
}
|
||||
|
||||
const editorExtensions = useMemo(() => {
|
||||
const extensions = [
|
||||
lineHighlightField,
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Meta-k',
|
||||
run: () => {
|
||||
setCommandBarOpen(true)
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
key: editorShortcutMeta.formatCode.codeMirror,
|
||||
run: () => {
|
||||
formatCode()
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
key: editorShortcutMeta.convertToVariable.codeMirror,
|
||||
run: () => {
|
||||
if (convertEnabled) {
|
||||
convertCallback()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]),
|
||||
] as Extension[]
|
||||
|
||||
if (kclLSP) extensions.push(kclLSP)
|
||||
|
||||
// These extensions have proven to mess with vitest
|
||||
if (!TEST) {
|
||||
extensions.push(
|
||||
lintGutter(),
|
||||
linter((_view) => {
|
||||
return kclErrToDiagnostic(useStore.getState().kclErrors)
|
||||
})
|
||||
)
|
||||
if (textWrapping === 'On') extensions.push(EditorView.lineWrapping)
|
||||
}
|
||||
|
||||
return extensions
|
||||
}, [kclLSP, textWrapping])
|
||||
|
||||
return (
|
||||
<div
|
||||
id="code-mirror-override"
|
||||
className="full-height-subtract"
|
||||
style={{ '--height-subtract': '4.25rem' } as CSSRuleObject}
|
||||
>
|
||||
<ReactCodeMirror
|
||||
className="h-full"
|
||||
value={code}
|
||||
extensions={editorExtensions}
|
||||
onChange={onChange}
|
||||
onUpdate={onUpdate}
|
||||
theme={theme}
|
||||
onCreateEditor={(_editorView) => setEditorView(_editorView)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { create } from 'react-modal-promise'
|
||||
import { useStore } from '../../useStore'
|
||||
import { isNodeSafeToReplace } from '../../lang/queryAst'
|
||||
import { SetVarNameModal } from '../SetVarNameModal'
|
||||
import { moveValueIntoNewVariable } from '../../lang/modifyAst'
|
||||
|
||||
const getModalInfo = create(SetVarNameModal as any)
|
||||
|
||||
export const ConvertToVariable = () => {
|
||||
const { guiMode, selectionRanges, ast, programMemory, updateAst } = useStore(
|
||||
(s) => ({
|
||||
guiMode: s.guiMode,
|
||||
ast: s.ast,
|
||||
updateAst: s.updateAst,
|
||||
selectionRanges: s.selectionRanges,
|
||||
programMemory: s.programMemory,
|
||||
})
|
||||
)
|
||||
const [enableAngLen, setEnableAngLen] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!ast) return
|
||||
|
||||
const { isSafe, value } = isNodeSafeToReplace(
|
||||
ast,
|
||||
selectionRanges.codeBasedSelections?.[0]?.range || []
|
||||
)
|
||||
const canReplace = isSafe && value.type !== 'Identifier'
|
||||
const isOnlyOneSelection = selectionRanges.codeBasedSelections.length === 1
|
||||
|
||||
const _enableHorz = canReplace && isOnlyOneSelection
|
||||
setEnableAngLen(_enableHorz)
|
||||
}, [guiMode, selectionRanges])
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!ast) return
|
||||
try {
|
||||
const { variableName } = await getModalInfo({
|
||||
valueName: 'var',
|
||||
} as any)
|
||||
|
||||
const { modifiedAst: _modifiedAst } = moveValueIntoNewVariable(
|
||||
ast,
|
||||
programMemory,
|
||||
selectionRanges.codeBasedSelections[0].range,
|
||||
variableName
|
||||
)
|
||||
|
||||
updateAst(_modifiedAst)
|
||||
} catch (e) {
|
||||
console.log('e', e)
|
||||
}
|
||||
}}
|
||||
disabled={!enableAngLen}
|
||||
>
|
||||
ConvertToVariable
|
||||
</button>
|
||||
)
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
import { Popover } from '@headlessui/react'
|
||||
import { Popover, Transition } from '@headlessui/react'
|
||||
import { ActionButton } from './ActionButton'
|
||||
import { faBars, faGear, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faGithub } from '@fortawesome/free-brands-svg-icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { paths } from '../Router'
|
||||
import makeUrlPathRelative from '../lib/makeUrlPathRelative'
|
||||
import { Models } from '@kittycad/lib'
|
||||
@ -61,9 +61,28 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
|
||||
Menu
|
||||
</ActionButton>
|
||||
)}
|
||||
<Transition
|
||||
enter="duration-200 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="duration-100 ease-in"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
as={Fragment}
|
||||
>
|
||||
<Popover.Overlay className="fixed z-20 inset-0 bg-chalkboard-110/50" />
|
||||
</Transition>
|
||||
|
||||
<Popover.Panel className="fixed inset-0 left-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-liquid-100 shadow-md rounded-l-lg overflow-hidden">
|
||||
<Transition
|
||||
enter="duration-100 ease-out"
|
||||
enterFrom="opacity-0 translate-x-1/4"
|
||||
enterTo="opacity-100 translate-x-0"
|
||||
leave="duration-75 ease-in"
|
||||
leaveFrom="opacity-100 translate-x-0"
|
||||
leaveTo="opacity-0 translate-x-4"
|
||||
as={Fragment}
|
||||
>
|
||||
<Popover.Panel className="fixed inset-0 left-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-liquid-100 dark:border-liquid-100/50 shadow-md rounded-l-lg overflow-hidden">
|
||||
{({ close }) => (
|
||||
<>
|
||||
{user && (
|
||||
@ -137,6 +156,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
|
||||
</>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
185
src/editor/lsp/client.ts
Normal file
185
src/editor/lsp/client.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import * as jsrpc from 'json-rpc-2.0'
|
||||
import * as LSP from 'vscode-languageserver-protocol'
|
||||
|
||||
import {
|
||||
registerServerCapability,
|
||||
unregisterServerCapability,
|
||||
} from './server-capability-registration'
|
||||
import { Codec, FromServer, IntoServer } from './codec'
|
||||
|
||||
const client_capabilities: LSP.ClientCapabilities = {
|
||||
textDocument: {
|
||||
hover: {
|
||||
dynamicRegistration: true,
|
||||
contentFormat: ['plaintext', 'markdown'],
|
||||
},
|
||||
moniker: {},
|
||||
synchronization: {
|
||||
dynamicRegistration: true,
|
||||
willSave: false,
|
||||
didSave: false,
|
||||
willSaveWaitUntil: false,
|
||||
},
|
||||
completion: {
|
||||
dynamicRegistration: true,
|
||||
completionItem: {
|
||||
snippetSupport: false,
|
||||
commitCharactersSupport: true,
|
||||
documentationFormat: ['plaintext', 'markdown'],
|
||||
deprecatedSupport: false,
|
||||
preselectSupport: false,
|
||||
},
|
||||
contextSupport: false,
|
||||
},
|
||||
signatureHelp: {
|
||||
dynamicRegistration: true,
|
||||
signatureInformation: {
|
||||
documentationFormat: ['plaintext', 'markdown'],
|
||||
},
|
||||
},
|
||||
declaration: {
|
||||
dynamicRegistration: true,
|
||||
linkSupport: true,
|
||||
},
|
||||
definition: {
|
||||
dynamicRegistration: true,
|
||||
linkSupport: true,
|
||||
},
|
||||
typeDefinition: {
|
||||
dynamicRegistration: true,
|
||||
linkSupport: true,
|
||||
},
|
||||
implementation: {
|
||||
dynamicRegistration: true,
|
||||
linkSupport: true,
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
didChangeConfiguration: {
|
||||
dynamicRegistration: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default class Client extends jsrpc.JSONRPCServerAndClient {
|
||||
afterInitializedHooks: (() => Promise<void>)[] = []
|
||||
#fromServer: FromServer
|
||||
private serverCapabilities: LSP.ServerCapabilities<any> = {}
|
||||
|
||||
constructor(fromServer: FromServer, intoServer: IntoServer) {
|
||||
super(
|
||||
new jsrpc.JSONRPCServer(),
|
||||
new jsrpc.JSONRPCClient(async (json: jsrpc.JSONRPCRequest) => {
|
||||
const encoded = Codec.encode(json)
|
||||
intoServer.enqueue(encoded)
|
||||
if (null != json.id) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const response = await fromServer.responses.get(json.id)!
|
||||
this.client.receive(response as jsrpc.JSONRPCResponse)
|
||||
}
|
||||
})
|
||||
)
|
||||
this.#fromServer = fromServer
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// process "window/logMessage": client <- server
|
||||
this.addMethod(LSP.LogMessageNotification.type.method, (params) => {
|
||||
const { type, message } = params as {
|
||||
type: LSP.MessageType
|
||||
message: string
|
||||
}
|
||||
let messageString = ''
|
||||
switch (type) {
|
||||
case LSP.MessageType.Error: {
|
||||
messageString += '[error] '
|
||||
break
|
||||
}
|
||||
case LSP.MessageType.Warning: {
|
||||
messageString += ' [warn] '
|
||||
break
|
||||
}
|
||||
case LSP.MessageType.Info: {
|
||||
messageString += ' [info] '
|
||||
break
|
||||
}
|
||||
case LSP.MessageType.Log: {
|
||||
messageString += ' [log] '
|
||||
break
|
||||
}
|
||||
}
|
||||
messageString += message
|
||||
// console.log(messageString)
|
||||
return
|
||||
})
|
||||
|
||||
// process "client/registerCapability": client <- server
|
||||
this.addMethod(LSP.RegistrationRequest.type.method, (params) => {
|
||||
// Register a server capability.
|
||||
params.registrations.forEach(
|
||||
(capabilityRegistration: LSP.Registration) => {
|
||||
this.serverCapabilities = registerServerCapability(
|
||||
this.serverCapabilities,
|
||||
capabilityRegistration
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// process "client/unregisterCapability": client <- server
|
||||
this.addMethod(LSP.UnregistrationRequest.type.method, (params) => {
|
||||
// Unregister a server capability.
|
||||
params.unregisterations.forEach(
|
||||
(capabilityUnregistration: LSP.Unregistration) => {
|
||||
this.serverCapabilities = unregisterServerCapability(
|
||||
this.serverCapabilities,
|
||||
capabilityUnregistration
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// request "initialize": client <-> server
|
||||
const { capabilities } = await this.request(
|
||||
LSP.InitializeRequest.type.method,
|
||||
{
|
||||
processId: null,
|
||||
clientInfo: {
|
||||
name: 'kcl-language-client',
|
||||
},
|
||||
capabilities: client_capabilities,
|
||||
rootUri: null,
|
||||
} as LSP.InitializeParams
|
||||
)
|
||||
|
||||
this.serverCapabilities = capabilities
|
||||
|
||||
// notify "initialized": client --> server
|
||||
this.notify(LSP.InitializedNotification.type.method, {})
|
||||
|
||||
await Promise.all(
|
||||
this.afterInitializedHooks.map((f: () => Promise<void>) => f())
|
||||
)
|
||||
await Promise.all([this.processNotifications(), this.processRequests()])
|
||||
}
|
||||
|
||||
getServerCapabilities(): LSP.ServerCapabilities<any> {
|
||||
return this.serverCapabilities
|
||||
}
|
||||
|
||||
async processNotifications(): Promise<void> {
|
||||
for await (const notification of this.#fromServer.notifications) {
|
||||
await this.receiveAndSend(notification)
|
||||
}
|
||||
}
|
||||
|
||||
async processRequests(): Promise<void> {
|
||||
for await (const request of this.#fromServer.requests) {
|
||||
await this.receiveAndSend(request)
|
||||
}
|
||||
}
|
||||
|
||||
pushAfterInitializeHook(...hooks: (() => Promise<void>)[]): void {
|
||||
this.afterInitializedHooks.push(...hooks)
|
||||
}
|
||||
}
|
53
src/editor/lsp/codec.ts
Normal file
53
src/editor/lsp/codec.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import * as jsrpc from 'json-rpc-2.0'
|
||||
import * as vsrpc from 'vscode-jsonrpc'
|
||||
|
||||
import Bytes from './codec/bytes'
|
||||
import StreamDemuxer from './codec/demuxer'
|
||||
import Headers from './codec/headers'
|
||||
import Queue from './codec/queue'
|
||||
import Tracer from './tracer'
|
||||
|
||||
export const encoder = new TextEncoder()
|
||||
export const decoder = new TextDecoder()
|
||||
|
||||
export class Codec {
|
||||
static encode(
|
||||
json: jsrpc.JSONRPCRequest | jsrpc.JSONRPCResponse
|
||||
): Uint8Array {
|
||||
const message = JSON.stringify(json)
|
||||
const delimited = Headers.add(message)
|
||||
return Bytes.encode(delimited)
|
||||
}
|
||||
|
||||
static decode<T>(data: Uint8Array): T {
|
||||
const delimited = Bytes.decode(data)
|
||||
const message = Headers.remove(delimited)
|
||||
return JSON.parse(message) as T
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: tracing effiency
|
||||
export class IntoServer
|
||||
extends Queue<Uint8Array>
|
||||
implements AsyncGenerator<Uint8Array, never, void>
|
||||
{
|
||||
enqueue(item: Uint8Array): void {
|
||||
Tracer.client(Headers.remove(decoder.decode(item)))
|
||||
super.enqueue(item)
|
||||
}
|
||||
}
|
||||
|
||||
export interface FromServer extends WritableStream<Uint8Array> {
|
||||
readonly responses: {
|
||||
get(key: number | string): null | Promise<vsrpc.ResponseMessage>
|
||||
}
|
||||
readonly notifications: AsyncGenerator<vsrpc.NotificationMessage, never, void>
|
||||
readonly requests: AsyncGenerator<vsrpc.RequestMessage, never, void>
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace FromServer {
|
||||
export function create(): FromServer {
|
||||
return new StreamDemuxer()
|
||||
}
|
||||
}
|
27
src/editor/lsp/codec/bytes.ts
Normal file
27
src/editor/lsp/codec/bytes.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { encoder, decoder } from '../codec'
|
||||
|
||||
export default class Bytes {
|
||||
static encode(input: string): Uint8Array {
|
||||
return encoder.encode(input)
|
||||
}
|
||||
|
||||
static decode(input: Uint8Array): string {
|
||||
return decoder.decode(input)
|
||||
}
|
||||
|
||||
static append<
|
||||
T extends { length: number; set(arr: T, offset: number): void }
|
||||
>(constructor: { new (length: number): T }, ...arrays: T[]) {
|
||||
let totalLength = 0
|
||||
for (const arr of arrays) {
|
||||
totalLength += arr.length
|
||||
}
|
||||
const result = new constructor(totalLength)
|
||||
let offset = 0
|
||||
for (const arr of arrays) {
|
||||
result.set(arr, offset)
|
||||
offset += arr.length
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
82
src/editor/lsp/codec/demuxer.ts
Normal file
82
src/editor/lsp/codec/demuxer.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import * as vsrpc from 'vscode-jsonrpc'
|
||||
|
||||
import Bytes from './bytes'
|
||||
import PromiseMap from './map'
|
||||
import Queue from './queue'
|
||||
import Tracer from '../tracer'
|
||||
|
||||
export default class StreamDemuxer extends Queue<Uint8Array> {
|
||||
readonly responses: PromiseMap<number | string, vsrpc.ResponseMessage> =
|
||||
new PromiseMap()
|
||||
readonly notifications: Queue<vsrpc.NotificationMessage> =
|
||||
new Queue<vsrpc.NotificationMessage>()
|
||||
readonly requests: Queue<vsrpc.RequestMessage> =
|
||||
new Queue<vsrpc.RequestMessage>()
|
||||
|
||||
readonly #start: Promise<void>
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.#start = this.start()
|
||||
}
|
||||
|
||||
private async start(): Promise<void> {
|
||||
let contentLength: null | number = null
|
||||
let buffer = new Uint8Array()
|
||||
|
||||
for await (const bytes of this) {
|
||||
buffer = Bytes.append(Uint8Array, buffer, bytes)
|
||||
while (buffer.length > 0) {
|
||||
// check if the content length is known
|
||||
if (null == contentLength) {
|
||||
// if not, try to match the prefixed headers
|
||||
const match = Bytes.decode(buffer).match(
|
||||
/^Content-Length:\s*(\d+)\s*/
|
||||
)
|
||||
if (null == match) continue
|
||||
|
||||
// try to parse the content-length from the headers
|
||||
const length = parseInt(match[1])
|
||||
if (isNaN(length)) throw new Error('invalid content length')
|
||||
|
||||
// slice the headers since we now have the content length
|
||||
buffer = buffer.slice(match[0].length)
|
||||
|
||||
// set the content length
|
||||
contentLength = length
|
||||
}
|
||||
|
||||
// if the buffer doesn't contain a full message; await another iteration
|
||||
if (buffer.length < contentLength) continue
|
||||
|
||||
// Get just the slice of the buffer that is our content length.
|
||||
const slice = buffer.slice(0, contentLength)
|
||||
|
||||
// decode buffer to a string
|
||||
const delimited = Bytes.decode(slice)
|
||||
|
||||
// reset the buffer
|
||||
buffer = buffer.slice(contentLength)
|
||||
// reset the contentLength
|
||||
contentLength = null
|
||||
|
||||
const message = JSON.parse(delimited) as vsrpc.Message
|
||||
Tracer.server(message)
|
||||
|
||||
// demux the message stream
|
||||
if (vsrpc.Message.isResponse(message) && null != message.id) {
|
||||
this.responses.set(message.id, message)
|
||||
continue
|
||||
}
|
||||
if (vsrpc.Message.isNotification(message)) {
|
||||
this.notifications.enqueue(message)
|
||||
continue
|
||||
}
|
||||
if (vsrpc.Message.isRequest(message)) {
|
||||
this.requests.enqueue(message)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
9
src/editor/lsp/codec/headers.ts
Normal file
9
src/editor/lsp/codec/headers.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export default class Headers {
|
||||
static add(message: string): string {
|
||||
return `Content-Length: ${message.length}\r\n\r\n${message}`
|
||||
}
|
||||
|
||||
static remove(delimited: string): string {
|
||||
return delimited.replace(/^Content-Length:\s*\d+\s*/, '')
|
||||
}
|
||||
}
|
72
src/editor/lsp/codec/map.ts
Normal file
72
src/editor/lsp/codec/map.ts
Normal file
@ -0,0 +1,72 @@
|
||||
export default class PromiseMap<K, V extends { toString(): string }> {
|
||||
#map: Map<K, PromiseMap.Entry<V>> = new Map()
|
||||
|
||||
get(key: K & { toString(): string }): null | Promise<V> {
|
||||
let initialized: PromiseMap.Entry<V>
|
||||
// if the entry doesn't exist, set it
|
||||
if (!this.#map.has(key)) {
|
||||
initialized = this.#set(key)
|
||||
} else {
|
||||
// otherwise return the entry
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
initialized = this.#map.get(key)!
|
||||
}
|
||||
// if the entry is a pending promise, return it
|
||||
if (initialized.status === 'pending') {
|
||||
return initialized.promise
|
||||
} else {
|
||||
// otherwise return null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
#set(key: K, value?: V): PromiseMap.Entry<V> {
|
||||
if (this.#map.has(key)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return this.#map.get(key)!
|
||||
}
|
||||
// placeholder resolver for entry
|
||||
let resolve = (item: V) => {
|
||||
void item
|
||||
}
|
||||
// promise for entry (which assigns the resolver
|
||||
const promise = new Promise<V>((resolver) => {
|
||||
resolve = resolver
|
||||
})
|
||||
// the initialized entry
|
||||
const initialized: PromiseMap.Entry<V> = {
|
||||
status: 'pending',
|
||||
resolve,
|
||||
promise,
|
||||
}
|
||||
if (null != value) {
|
||||
initialized.resolve(value)
|
||||
}
|
||||
// set the entry
|
||||
this.#map.set(key, initialized)
|
||||
return initialized
|
||||
}
|
||||
|
||||
set(key: K & { toString(): string }, value: V): this {
|
||||
const initialized = this.#set(key, value)
|
||||
// if the promise is pending ...
|
||||
if (initialized.status === 'pending') {
|
||||
// ... set the entry status to resolved to free the promise
|
||||
this.#map.set(key, { status: 'resolved' })
|
||||
// ... and resolve the promise with the given value
|
||||
initialized.resolve(value)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.#map.size
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace PromiseMap {
|
||||
export type Entry<V> =
|
||||
| { status: 'pending'; resolve: (item: V) => void; promise: Promise<V> }
|
||||
| { status: 'resolved' }
|
||||
}
|
113
src/editor/lsp/codec/queue.ts
Normal file
113
src/editor/lsp/codec/queue.ts
Normal file
@ -0,0 +1,113 @@
|
||||
export default class Queue<T>
|
||||
implements WritableStream<T>, AsyncGenerator<T, never, void>
|
||||
{
|
||||
readonly #promises: Promise<T>[] = []
|
||||
readonly #resolvers: ((item: T) => void)[] = []
|
||||
readonly #observers: ((item: T) => void)[] = []
|
||||
|
||||
#closed = false
|
||||
#locked = false
|
||||
readonly #stream: WritableStream<T>
|
||||
|
||||
static #__add<X>(
|
||||
promises: Promise<X>[],
|
||||
resolvers: ((item: X) => void)[]
|
||||
): void {
|
||||
promises.push(
|
||||
new Promise((resolve) => {
|
||||
resolvers.push(resolve)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
static #__enqueue<X>(
|
||||
closed: boolean,
|
||||
promises: Promise<X>[],
|
||||
resolvers: ((item: X) => void)[],
|
||||
item: X
|
||||
): void {
|
||||
if (!closed) {
|
||||
if (!resolvers.length) Queue.#__add(promises, resolvers)
|
||||
const resolve = resolvers.shift()! // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||
resolve(item)
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
const closed = this.#closed
|
||||
const promises = this.#promises
|
||||
const resolvers = this.#resolvers
|
||||
this.#stream = new WritableStream({
|
||||
write(item: T): void {
|
||||
Queue.#__enqueue(closed, promises, resolvers, item)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#add(): void {
|
||||
return Queue.#__add(this.#promises, this.#resolvers)
|
||||
}
|
||||
|
||||
enqueue(item: T): void {
|
||||
return Queue.#__enqueue(this.#closed, this.#promises, this.#resolvers, item)
|
||||
}
|
||||
|
||||
dequeue(): Promise<T> {
|
||||
if (!this.#promises.length) this.#add()
|
||||
const item = this.#promises.shift()! // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||
return item
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return !this.#promises.length
|
||||
}
|
||||
|
||||
isBlocked(): boolean {
|
||||
return !!this.#resolvers.length
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.#promises.length - this.#resolvers.length
|
||||
}
|
||||
|
||||
async next(): Promise<IteratorResult<T, never>> {
|
||||
const done = false
|
||||
const value = await this.dequeue()
|
||||
for (const observer of this.#observers) {
|
||||
observer(value)
|
||||
}
|
||||
return { done, value }
|
||||
}
|
||||
|
||||
return(): Promise<IteratorResult<T, never>> {
|
||||
return new Promise(() => {
|
||||
// empty
|
||||
})
|
||||
}
|
||||
|
||||
throw(err: Error): Promise<IteratorResult<T, never>> {
|
||||
return new Promise((_resolve, reject) => {
|
||||
reject(err)
|
||||
})
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncGenerator<T, never, void> {
|
||||
return this
|
||||
}
|
||||
|
||||
get locked(): boolean {
|
||||
return this.#stream.locked
|
||||
}
|
||||
|
||||
abort(reason?: Error): Promise<void> {
|
||||
return this.#stream.abort(reason)
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.#stream.close()
|
||||
}
|
||||
|
||||
getWriter(): WritableStreamDefaultWriter<T> {
|
||||
return this.#stream.getWriter()
|
||||
}
|
||||
}
|
151
src/editor/lsp/index.ts
Normal file
151
src/editor/lsp/index.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import type * as LSP from 'vscode-languageserver-protocol'
|
||||
import Client from './client'
|
||||
import { LanguageServerPlugin } from './plugin'
|
||||
import { SemanticToken, deserializeTokens } from './semantic_tokens'
|
||||
|
||||
// https://microsoft.github.io/language-server-protocol/specifications/specification-current/
|
||||
|
||||
// Client to server then server to client
|
||||
interface LSPRequestMap {
|
||||
initialize: [LSP.InitializeParams, LSP.InitializeResult]
|
||||
'textDocument/hover': [LSP.HoverParams, LSP.Hover]
|
||||
'textDocument/completion': [
|
||||
LSP.CompletionParams,
|
||||
LSP.CompletionItem[] | LSP.CompletionList | null
|
||||
]
|
||||
'textDocument/semanticTokens/full': [
|
||||
LSP.SemanticTokensParams,
|
||||
LSP.SemanticTokens
|
||||
]
|
||||
}
|
||||
|
||||
// Client to server
|
||||
interface LSPNotifyMap {
|
||||
initialized: LSP.InitializedParams
|
||||
'textDocument/didChange': LSP.DidChangeTextDocumentParams
|
||||
'textDocument/didOpen': LSP.DidOpenTextDocumentParams
|
||||
}
|
||||
|
||||
// Server to client
|
||||
interface LSPEventMap {
|
||||
'textDocument/publishDiagnostics': LSP.PublishDiagnosticsParams
|
||||
}
|
||||
|
||||
export type Notification = {
|
||||
[key in keyof LSPEventMap]: {
|
||||
jsonrpc: '2.0'
|
||||
id?: null | undefined
|
||||
method: key
|
||||
params: LSPEventMap[key]
|
||||
}
|
||||
}[keyof LSPEventMap]
|
||||
|
||||
export interface LanguageServerClientOptions {
|
||||
client: Client
|
||||
}
|
||||
|
||||
export class LanguageServerClient {
|
||||
private client: Client
|
||||
|
||||
public ready: boolean
|
||||
|
||||
private plugins: LanguageServerPlugin[]
|
||||
|
||||
public initializePromise: Promise<void>
|
||||
|
||||
private isUpdatingSemanticTokens: boolean = false
|
||||
private semanticTokens: SemanticToken[] = []
|
||||
|
||||
constructor(options: LanguageServerClientOptions) {
|
||||
this.plugins = []
|
||||
this.client = options.client
|
||||
|
||||
this.ready = false
|
||||
|
||||
this.initializePromise = this.initialize()
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Start the client in the background.
|
||||
this.client.start()
|
||||
|
||||
this.ready = true
|
||||
}
|
||||
|
||||
getServerCapabilities(): LSP.ServerCapabilities<any> {
|
||||
return this.client.getServerCapabilities()
|
||||
}
|
||||
|
||||
close() {}
|
||||
|
||||
textDocumentDidOpen(params: LSP.DidOpenTextDocumentParams) {
|
||||
this.notify('textDocument/didOpen', params)
|
||||
|
||||
this.updateSemanticTokens(params.textDocument.uri)
|
||||
}
|
||||
|
||||
textDocumentDidChange(params: LSP.DidChangeTextDocumentParams) {
|
||||
this.notify('textDocument/didChange', params)
|
||||
this.updateSemanticTokens(params.textDocument.uri)
|
||||
}
|
||||
|
||||
async updateSemanticTokens(uri: string) {
|
||||
// Make sure we can only run, if we aren't already running.
|
||||
if (!this.isUpdatingSemanticTokens) {
|
||||
this.isUpdatingSemanticTokens = true
|
||||
|
||||
const result = await this.request('textDocument/semanticTokens/full', {
|
||||
textDocument: {
|
||||
uri,
|
||||
},
|
||||
})
|
||||
|
||||
this.semanticTokens = deserializeTokens(
|
||||
result.data,
|
||||
this.getServerCapabilities().semanticTokensProvider
|
||||
)
|
||||
|
||||
this.isUpdatingSemanticTokens = false
|
||||
}
|
||||
}
|
||||
|
||||
getSemanticTokens(): SemanticToken[] {
|
||||
return this.semanticTokens
|
||||
}
|
||||
|
||||
async textDocumentHover(params: LSP.HoverParams) {
|
||||
return await this.request('textDocument/hover', params)
|
||||
}
|
||||
|
||||
async textDocumentCompletion(params: LSP.CompletionParams) {
|
||||
return await this.request('textDocument/completion', params)
|
||||
}
|
||||
|
||||
attachPlugin(plugin: LanguageServerPlugin) {
|
||||
this.plugins.push(plugin)
|
||||
}
|
||||
|
||||
detachPlugin(plugin: LanguageServerPlugin) {
|
||||
const i = this.plugins.indexOf(plugin)
|
||||
if (i === -1) return
|
||||
this.plugins.splice(i, 1)
|
||||
}
|
||||
|
||||
private request<K extends keyof LSPRequestMap>(
|
||||
method: K,
|
||||
params: LSPRequestMap[K][0]
|
||||
): Promise<LSPRequestMap[K][1]> {
|
||||
return this.client.request(method, params) as Promise<LSPRequestMap[K][1]>
|
||||
}
|
||||
|
||||
private notify<K extends keyof LSPNotifyMap>(
|
||||
method: K,
|
||||
params: LSPNotifyMap[K]
|
||||
): void {
|
||||
return this.client.notify(method, params)
|
||||
}
|
||||
|
||||
private processNotification(notification: Notification) {
|
||||
for (const plugin of this.plugins) plugin.processNotification(notification)
|
||||
}
|
||||
}
|
36
src/editor/lsp/language.ts
Normal file
36
src/editor/lsp/language.ts
Normal file
@ -0,0 +1,36 @@
|
||||
// Code mirror language implementation for kcl.
|
||||
|
||||
import {
|
||||
Language,
|
||||
defineLanguageFacet,
|
||||
LanguageSupport,
|
||||
} from '@codemirror/language'
|
||||
import { LanguageServerClient } from '.'
|
||||
import { kclPlugin } from './plugin'
|
||||
import type * as LSP from 'vscode-languageserver-protocol'
|
||||
import { parser as jsParser } from '@lezer/javascript'
|
||||
|
||||
const data = defineLanguageFacet({})
|
||||
|
||||
export interface LanguageOptions {
|
||||
workspaceFolders: LSP.WorkspaceFolder[] | null
|
||||
documentUri: string
|
||||
client: LanguageServerClient
|
||||
}
|
||||
|
||||
export default function kclLanguage(options: LanguageOptions): LanguageSupport {
|
||||
// For now let's use the javascript parser.
|
||||
// It works really well and has good syntax highlighting.
|
||||
// We can use our lsp for the rest.
|
||||
const lang = new Language(data, jsParser, [], 'kcl')
|
||||
|
||||
// Create our supporting extension.
|
||||
const kclLsp = kclPlugin({
|
||||
documentUri: options.documentUri,
|
||||
workspaceFolders: options.workspaceFolders,
|
||||
allowHTMLContent: true,
|
||||
client: options.client,
|
||||
})
|
||||
|
||||
return new LanguageSupport(lang, [kclLsp])
|
||||
}
|
168
src/editor/lsp/parser.ts
Normal file
168
src/editor/lsp/parser.ts
Normal file
@ -0,0 +1,168 @@
|
||||
// Extends the codemirror Parser for kcl.
|
||||
|
||||
import {
|
||||
Parser,
|
||||
Input,
|
||||
TreeFragment,
|
||||
PartialParse,
|
||||
Tree,
|
||||
NodeType,
|
||||
NodeSet,
|
||||
} from '@lezer/common'
|
||||
import { LanguageServerClient } from '.'
|
||||
import { posToOffset } from './plugin'
|
||||
import { SemanticToken } from './semantic_tokens'
|
||||
import { DocInput } from '@codemirror/language'
|
||||
import { tags, styleTags } from '@lezer/highlight'
|
||||
|
||||
export default class KclParser extends Parser {
|
||||
private client: LanguageServerClient
|
||||
|
||||
constructor(client: LanguageServerClient) {
|
||||
super()
|
||||
this.client = client
|
||||
}
|
||||
|
||||
createParse(
|
||||
input: Input,
|
||||
fragments: readonly TreeFragment[],
|
||||
ranges: readonly { from: number; to: number }[]
|
||||
): PartialParse {
|
||||
let parse: PartialParse = new Context(this, input, fragments, ranges)
|
||||
return parse
|
||||
}
|
||||
|
||||
getTokenTypes(): string[] {
|
||||
return this.client.getServerCapabilities().semanticTokensProvider!.legend
|
||||
.tokenTypes
|
||||
}
|
||||
|
||||
getSemanticTokens(): SemanticToken[] {
|
||||
return this.client.getSemanticTokens()
|
||||
}
|
||||
}
|
||||
|
||||
class Context implements PartialParse {
|
||||
private parser: KclParser
|
||||
private input: DocInput
|
||||
private fragments: readonly TreeFragment[]
|
||||
private ranges: readonly { from: number; to: number }[]
|
||||
|
||||
private nodeTypes: { [key: string]: NodeType }
|
||||
stoppedAt: number = 0
|
||||
|
||||
private semanticTokens: SemanticToken[] = []
|
||||
private currentLine: number = 0
|
||||
private currentColumn: number = 0
|
||||
private nodeSet: NodeSet
|
||||
|
||||
constructor(
|
||||
/// The parser configuration used.
|
||||
parser: KclParser,
|
||||
input: Input,
|
||||
fragments: readonly TreeFragment[],
|
||||
ranges: readonly { from: number; to: number }[]
|
||||
) {
|
||||
this.parser = parser
|
||||
this.input = input as DocInput
|
||||
this.fragments = fragments
|
||||
this.ranges = ranges
|
||||
|
||||
// Iterate over the semantic token types and create a node type for each.
|
||||
this.nodeTypes = {}
|
||||
let nodeArray: NodeType[] = []
|
||||
this.parser.getTokenTypes().forEach((tokenType, index) => {
|
||||
const nodeType = NodeType.define({
|
||||
id: index,
|
||||
name: tokenType,
|
||||
// props: [this.styleTags],
|
||||
})
|
||||
this.nodeTypes[tokenType] = nodeType
|
||||
nodeArray.push(nodeType)
|
||||
})
|
||||
|
||||
this.semanticTokens = this.parser.getSemanticTokens()
|
||||
const styles = styleTags({
|
||||
number: tags.number,
|
||||
variable: tags.variableName,
|
||||
operator: tags.operator,
|
||||
keyword: tags.keyword,
|
||||
string: tags.string,
|
||||
comment: tags.comment,
|
||||
function: tags.function(tags.variableName),
|
||||
})
|
||||
this.nodeSet = new NodeSet(nodeArray).extend(styles)
|
||||
}
|
||||
|
||||
get parsedPos(): number {
|
||||
return 0
|
||||
}
|
||||
|
||||
advance(): Tree | null {
|
||||
if (this.semanticTokens.length === 0) {
|
||||
return new Tree(NodeType.none, [], [], 0)
|
||||
}
|
||||
const tree = this.createTree(this.semanticTokens[0], 0)
|
||||
this.stoppedAt = this.input.doc.length
|
||||
return tree
|
||||
}
|
||||
|
||||
createTree(token: SemanticToken, index: number): Tree {
|
||||
const changedLine = token.delta_line !== 0
|
||||
this.currentLine += token.delta_line
|
||||
if (changedLine) {
|
||||
this.currentColumn = 0
|
||||
}
|
||||
this.currentColumn += token.delta_start
|
||||
|
||||
// Let's get our position relative to the start of the file.
|
||||
let currentPosition = posToOffset(this.input.doc, {
|
||||
line: this.currentLine,
|
||||
character: this.currentColumn,
|
||||
})
|
||||
|
||||
const nodeType = this.nodeSet.types[this.nodeTypes[token.token_type].id]
|
||||
|
||||
if (currentPosition === undefined) {
|
||||
// This is bad and weird.
|
||||
return new Tree(nodeType, [], [], token.length)
|
||||
}
|
||||
|
||||
if (index >= this.semanticTokens.length - 1) {
|
||||
// We have no children.
|
||||
return new Tree(nodeType, [], [], token.length)
|
||||
}
|
||||
|
||||
const nextIndex = index + 1
|
||||
const nextToken = this.semanticTokens[nextIndex]
|
||||
const changedLineNext = nextToken.delta_line !== 0
|
||||
const nextLine = this.currentLine + nextToken.delta_line
|
||||
const nextColumn = changedLineNext
|
||||
? nextToken.delta_start
|
||||
: this.currentColumn + nextToken.delta_start
|
||||
const nextPosition = posToOffset(this.input.doc, {
|
||||
line: nextLine,
|
||||
character: nextColumn,
|
||||
})
|
||||
|
||||
if (nextPosition === undefined) {
|
||||
// This is bad and weird.
|
||||
return new Tree(nodeType, [], [], token.length)
|
||||
}
|
||||
|
||||
// Let's get the
|
||||
|
||||
return new Tree(
|
||||
nodeType,
|
||||
[this.createTree(nextToken, nextIndex)],
|
||||
|
||||
// The positions (offsets relative to the start of this tree) of the children.
|
||||
[nextPosition - currentPosition],
|
||||
token.length
|
||||
)
|
||||
}
|
||||
|
||||
stopAt(pos: number) {
|
||||
this.stoppedAt = pos
|
||||
}
|
||||
}
|
360
src/editor/lsp/plugin.ts
Normal file
360
src/editor/lsp/plugin.ts
Normal file
@ -0,0 +1,360 @@
|
||||
import { autocompletion, completeFromList } from '@codemirror/autocomplete'
|
||||
import { setDiagnostics } from '@codemirror/lint'
|
||||
import { Facet } from '@codemirror/state'
|
||||
import {
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
Tooltip,
|
||||
hoverTooltip,
|
||||
tooltips,
|
||||
} from '@codemirror/view'
|
||||
import {
|
||||
DiagnosticSeverity,
|
||||
CompletionItemKind,
|
||||
CompletionTriggerKind,
|
||||
} from 'vscode-languageserver-protocol'
|
||||
|
||||
import type {
|
||||
Completion,
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
} from '@codemirror/autocomplete'
|
||||
import type { PublishDiagnosticsParams } from 'vscode-languageserver-protocol'
|
||||
import type { ViewUpdate, PluginValue } from '@codemirror/view'
|
||||
import type { Text } from '@codemirror/state'
|
||||
import type * as LSP from 'vscode-languageserver-protocol'
|
||||
import { LanguageServerClient, Notification } from '.'
|
||||
import { Marked } from '@ts-stack/markdown'
|
||||
|
||||
const changesDelay = 500
|
||||
|
||||
const CompletionItemKindMap = Object.fromEntries(
|
||||
Object.entries(CompletionItemKind).map(([key, value]) => [value, key])
|
||||
) as Record<CompletionItemKind, string>
|
||||
|
||||
const useLast = (values: readonly any[]) => values.reduce((_, v) => v, '')
|
||||
const documentUri = Facet.define<string, string>({ combine: useLast })
|
||||
const languageId = Facet.define<string, string>({ combine: useLast })
|
||||
const client = Facet.define<LanguageServerClient, LanguageServerClient>({
|
||||
combine: useLast,
|
||||
})
|
||||
|
||||
export interface LanguageServerOptions {
|
||||
workspaceFolders: LSP.WorkspaceFolder[] | null
|
||||
documentUri: string
|
||||
allowHTMLContent: boolean
|
||||
client: LanguageServerClient
|
||||
}
|
||||
|
||||
export class LanguageServerPlugin implements PluginValue {
|
||||
public client: LanguageServerClient
|
||||
|
||||
private documentUri: string
|
||||
private languageId: string
|
||||
private documentVersion: number
|
||||
|
||||
private changesTimeout: number
|
||||
|
||||
constructor(private view: EditorView, private allowHTMLContent: boolean) {
|
||||
this.client = this.view.state.facet(client)
|
||||
this.documentUri = this.view.state.facet(documentUri)
|
||||
this.languageId = this.view.state.facet(languageId)
|
||||
this.documentVersion = 0
|
||||
this.changesTimeout = 0
|
||||
|
||||
this.client.attachPlugin(this)
|
||||
|
||||
this.initialize({
|
||||
documentText: this.view.state.doc.toString(),
|
||||
})
|
||||
}
|
||||
|
||||
update({ docChanged }: ViewUpdate) {
|
||||
if (!docChanged) return
|
||||
if (this.changesTimeout) clearTimeout(this.changesTimeout)
|
||||
this.changesTimeout = window.setTimeout(() => {
|
||||
this.sendChange({
|
||||
documentText: this.view.state.doc.toString(),
|
||||
})
|
||||
}, changesDelay)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.client.detachPlugin(this)
|
||||
}
|
||||
|
||||
async initialize({ documentText }: { documentText: string }) {
|
||||
if (this.client.initializePromise) {
|
||||
await this.client.initializePromise
|
||||
}
|
||||
this.client.textDocumentDidOpen({
|
||||
textDocument: {
|
||||
uri: this.documentUri,
|
||||
languageId: this.languageId,
|
||||
text: documentText,
|
||||
version: this.documentVersion,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async sendChange({ documentText }: { documentText: string }) {
|
||||
if (!this.client.ready) return
|
||||
try {
|
||||
await this.client.textDocumentDidChange({
|
||||
textDocument: {
|
||||
uri: this.documentUri,
|
||||
version: this.documentVersion++,
|
||||
},
|
||||
contentChanges: [{ text: documentText }],
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
requestDiagnostics(view: EditorView) {
|
||||
this.sendChange({ documentText: view.state.doc.toString() })
|
||||
}
|
||||
|
||||
async requestHoverTooltip(
|
||||
view: EditorView,
|
||||
{ line, character }: { line: number; character: number }
|
||||
): Promise<Tooltip | null> {
|
||||
if (
|
||||
!this.client.ready ||
|
||||
!this.client.getServerCapabilities().hoverProvider
|
||||
)
|
||||
return null
|
||||
|
||||
this.sendChange({ documentText: view.state.doc.toString() })
|
||||
const result = await this.client.textDocumentHover({
|
||||
textDocument: { uri: this.documentUri },
|
||||
position: { line, character },
|
||||
})
|
||||
if (!result) return null
|
||||
const { contents, range } = result
|
||||
let pos = posToOffset(view.state.doc, { line, character })!
|
||||
let end: number | undefined
|
||||
if (range) {
|
||||
pos = posToOffset(view.state.doc, range.start)!
|
||||
end = posToOffset(view.state.doc, range.end)
|
||||
}
|
||||
if (pos === null) return null
|
||||
const dom = document.createElement('div')
|
||||
dom.classList.add('documentation')
|
||||
if (this.allowHTMLContent) dom.innerHTML = formatContents(contents)
|
||||
else dom.textContent = formatContents(contents)
|
||||
return { pos, end, create: (view) => ({ dom }), above: true }
|
||||
}
|
||||
|
||||
async requestCompletion(
|
||||
context: CompletionContext,
|
||||
{ line, character }: { line: number; character: number },
|
||||
{
|
||||
triggerKind,
|
||||
triggerCharacter,
|
||||
}: {
|
||||
triggerKind: CompletionTriggerKind
|
||||
triggerCharacter: string | undefined
|
||||
}
|
||||
): Promise<CompletionResult | null> {
|
||||
if (
|
||||
!this.client.ready ||
|
||||
!this.client.getServerCapabilities().completionProvider
|
||||
)
|
||||
return null
|
||||
|
||||
this.sendChange({
|
||||
documentText: context.state.doc.toString(),
|
||||
})
|
||||
|
||||
const result = await this.client.textDocumentCompletion({
|
||||
textDocument: { uri: this.documentUri },
|
||||
position: { line, character },
|
||||
context: {
|
||||
triggerKind,
|
||||
triggerCharacter,
|
||||
},
|
||||
})
|
||||
|
||||
if (!result) return null
|
||||
|
||||
const items = 'items' in result ? result.items : result
|
||||
|
||||
let options = items.map(
|
||||
({
|
||||
detail,
|
||||
label,
|
||||
labelDetails,
|
||||
kind,
|
||||
textEdit,
|
||||
documentation,
|
||||
deprecated,
|
||||
insertText,
|
||||
insertTextFormat,
|
||||
sortText,
|
||||
filterText,
|
||||
}) => {
|
||||
const completion: Completion & {
|
||||
filterText: string
|
||||
sortText?: string
|
||||
apply: string
|
||||
} = {
|
||||
label,
|
||||
detail: labelDetails ? labelDetails.detail : detail,
|
||||
apply: label,
|
||||
type: kind && CompletionItemKindMap[kind].toLowerCase(),
|
||||
sortText: sortText ?? label,
|
||||
filterText: filterText ?? label,
|
||||
}
|
||||
if (documentation) {
|
||||
completion.info = () => {
|
||||
const htmlString = formatContents(documentation)
|
||||
const htmlNode = document.createElement('div')
|
||||
htmlNode.style.display = 'contents'
|
||||
htmlNode.innerHTML = htmlString
|
||||
return { dom: htmlNode }
|
||||
}
|
||||
}
|
||||
|
||||
return completion
|
||||
}
|
||||
)
|
||||
|
||||
return completeFromList(options)(context)
|
||||
}
|
||||
|
||||
processNotification(notification: Notification) {
|
||||
try {
|
||||
switch (notification.method) {
|
||||
case 'textDocument/publishDiagnostics':
|
||||
this.processDiagnostics(notification.params)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
processDiagnostics(params: PublishDiagnosticsParams) {
|
||||
if (params.uri !== this.documentUri) return
|
||||
|
||||
const diagnostics = params.diagnostics
|
||||
.map(({ range, message, severity }) => ({
|
||||
from: posToOffset(this.view.state.doc, range.start)!,
|
||||
to: posToOffset(this.view.state.doc, range.end)!,
|
||||
severity: (
|
||||
{
|
||||
[DiagnosticSeverity.Error]: 'error',
|
||||
[DiagnosticSeverity.Warning]: 'warning',
|
||||
[DiagnosticSeverity.Information]: 'info',
|
||||
[DiagnosticSeverity.Hint]: 'info',
|
||||
} as const
|
||||
)[severity!],
|
||||
message,
|
||||
}))
|
||||
.filter(
|
||||
({ from, to }) =>
|
||||
from !== null && to !== null && from !== undefined && to !== undefined
|
||||
)
|
||||
.sort((a, b) => {
|
||||
switch (true) {
|
||||
case a.from < b.from:
|
||||
return -1
|
||||
case a.from > b.from:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
this.view.dispatch(setDiagnostics(this.view.state, diagnostics))
|
||||
}
|
||||
}
|
||||
|
||||
export function kclPlugin(options: LanguageServerOptions) {
|
||||
let plugin: LanguageServerPlugin | null = null
|
||||
|
||||
return [
|
||||
client.of(options.client),
|
||||
documentUri.of(options.documentUri),
|
||||
languageId.of('kcl'),
|
||||
ViewPlugin.define(
|
||||
(view) =>
|
||||
(plugin = new LanguageServerPlugin(view, options.allowHTMLContent))
|
||||
),
|
||||
hoverTooltip(
|
||||
(view, pos) =>
|
||||
plugin?.requestHoverTooltip(view, offsetToPos(view.state.doc, pos)) ??
|
||||
null
|
||||
),
|
||||
tooltips({
|
||||
position: 'absolute',
|
||||
}),
|
||||
autocompletion({
|
||||
override: [
|
||||
async (context) => {
|
||||
if (plugin == null) return null
|
||||
|
||||
const { state, pos, explicit } = context
|
||||
const line = state.doc.lineAt(pos)
|
||||
let trigKind: CompletionTriggerKind = CompletionTriggerKind.Invoked
|
||||
let trigChar: string | undefined
|
||||
if (
|
||||
!explicit &&
|
||||
plugin.client
|
||||
.getServerCapabilities()
|
||||
.completionProvider?.triggerCharacters?.includes(
|
||||
line.text[pos - line.from - 1]
|
||||
)
|
||||
) {
|
||||
trigKind = CompletionTriggerKind.TriggerCharacter
|
||||
trigChar = line.text[pos - line.from - 1]
|
||||
}
|
||||
if (
|
||||
trigKind === CompletionTriggerKind.Invoked &&
|
||||
!context.matchBefore(/\w+$/)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return await plugin.requestCompletion(
|
||||
context,
|
||||
offsetToPos(state.doc, pos),
|
||||
{
|
||||
triggerKind: trigKind,
|
||||
triggerCharacter: trigChar,
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
export function posToOffset(
|
||||
doc: Text,
|
||||
pos: { line: number; character: number }
|
||||
): number | undefined {
|
||||
if (pos.line >= doc.lines) return
|
||||
const offset = doc.line(pos.line + 1).from + pos.character
|
||||
if (offset > doc.length) return
|
||||
return offset
|
||||
}
|
||||
|
||||
function offsetToPos(doc: Text, offset: number) {
|
||||
const line = doc.lineAt(offset)
|
||||
return {
|
||||
line: line.number - 1,
|
||||
character: offset - line.from,
|
||||
}
|
||||
}
|
||||
|
||||
function formatContents(
|
||||
contents: LSP.MarkupContent | LSP.MarkedString | LSP.MarkedString[]
|
||||
): string {
|
||||
if (Array.isArray(contents)) {
|
||||
return contents.map((c) => formatContents(c) + '\n\n').join('')
|
||||
} else if (typeof contents === 'string') {
|
||||
return Marked.parse(contents)
|
||||
} else {
|
||||
return Marked.parse(contents.value)
|
||||
}
|
||||
}
|
51
src/editor/lsp/semantic_tokens.ts
Normal file
51
src/editor/lsp/semantic_tokens.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import type * as LSP from 'vscode-languageserver-protocol'
|
||||
|
||||
export class SemanticToken {
|
||||
delta_line: number
|
||||
delta_start: number
|
||||
length: number
|
||||
token_type: string
|
||||
token_modifiers_bitset: string
|
||||
|
||||
constructor(
|
||||
delta_line = 0,
|
||||
delta_start = 0,
|
||||
length = 0,
|
||||
token_type = '',
|
||||
token_modifiers_bitset = ''
|
||||
) {
|
||||
this.delta_line = delta_line
|
||||
this.delta_start = delta_start
|
||||
this.length = length
|
||||
this.token_type = token_type
|
||||
this.token_modifiers_bitset = token_modifiers_bitset
|
||||
}
|
||||
}
|
||||
|
||||
export function deserializeTokens(
|
||||
data: number[],
|
||||
semanticTokensProvider?: LSP.SemanticTokensOptions
|
||||
): SemanticToken[] {
|
||||
if (!semanticTokensProvider) {
|
||||
return []
|
||||
}
|
||||
// Check if data length is divisible by 5
|
||||
if (data.length % 5 !== 0) {
|
||||
throw new Error('Length is not divisible by 5')
|
||||
}
|
||||
|
||||
const tokens = []
|
||||
for (let i = 0; i < data.length; i += 5) {
|
||||
tokens.push(
|
||||
new SemanticToken(
|
||||
data[i],
|
||||
data[i + 1],
|
||||
data[i + 2],
|
||||
semanticTokensProvider.legend.tokenTypes[data[i + 3]],
|
||||
semanticTokensProvider.legend.tokenModifiers[data[i + 4]]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
80
src/editor/lsp/server-capability-registration.ts
Normal file
80
src/editor/lsp/server-capability-registration.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import {
|
||||
Registration,
|
||||
ServerCapabilities,
|
||||
Unregistration,
|
||||
} from 'vscode-languageserver-protocol'
|
||||
|
||||
interface IFlexibleServerCapabilities extends ServerCapabilities {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface IMethodServerCapabilityProviderDictionary {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
const ServerCapabilitiesProviders: IMethodServerCapabilityProviderDictionary = {
|
||||
'textDocument/hover': 'hoverProvider',
|
||||
'textDocument/completion': 'completionProvider',
|
||||
'textDocument/signatureHelp': 'signatureHelpProvider',
|
||||
'textDocument/definition': 'definitionProvider',
|
||||
'textDocument/typeDefinition': 'typeDefinitionProvider',
|
||||
'textDocument/implementation': 'implementationProvider',
|
||||
'textDocument/references': 'referencesProvider',
|
||||
'textDocument/documentHighlight': 'documentHighlightProvider',
|
||||
'textDocument/documentSymbol': 'documentSymbolProvider',
|
||||
'textDocument/workspaceSymbol': 'workspaceSymbolProvider',
|
||||
'textDocument/codeAction': 'codeActionProvider',
|
||||
'textDocument/codeLens': 'codeLensProvider',
|
||||
'textDocument/documentFormatting': 'documentFormattingProvider',
|
||||
'textDocument/documentRangeFormatting': 'documentRangeFormattingProvider',
|
||||
'textDocument/documentOnTypeFormatting': 'documentOnTypeFormattingProvider',
|
||||
'textDocument/rename': 'renameProvider',
|
||||
'textDocument/documentLink': 'documentLinkProvider',
|
||||
'textDocument/color': 'colorProvider',
|
||||
'textDocument/foldingRange': 'foldingRangeProvider',
|
||||
'textDocument/declaration': 'declarationProvider',
|
||||
'textDocument/executeCommand': 'executeCommandProvider',
|
||||
}
|
||||
|
||||
function registerServerCapability(
|
||||
serverCapabilities: ServerCapabilities,
|
||||
registration: Registration
|
||||
): ServerCapabilities {
|
||||
const serverCapabilitiesCopy = JSON.parse(
|
||||
JSON.stringify(serverCapabilities)
|
||||
) as IFlexibleServerCapabilities
|
||||
const { method, registerOptions } = registration
|
||||
const providerName = ServerCapabilitiesProviders[method]
|
||||
|
||||
if (providerName) {
|
||||
if (!registerOptions) {
|
||||
serverCapabilitiesCopy[providerName] = true
|
||||
} else {
|
||||
serverCapabilitiesCopy[providerName] = Object.assign(
|
||||
{},
|
||||
JSON.parse(JSON.stringify(registerOptions))
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw new Error('Could not register server capability.')
|
||||
}
|
||||
|
||||
return serverCapabilitiesCopy
|
||||
}
|
||||
|
||||
function unregisterServerCapability(
|
||||
serverCapabilities: ServerCapabilities,
|
||||
unregistration: Unregistration
|
||||
): ServerCapabilities {
|
||||
const serverCapabilitiesCopy = JSON.parse(
|
||||
JSON.stringify(serverCapabilities)
|
||||
) as IFlexibleServerCapabilities
|
||||
const { method } = unregistration
|
||||
const providerName = ServerCapabilitiesProviders[method]
|
||||
|
||||
delete serverCapabilitiesCopy[providerName]
|
||||
|
||||
return serverCapabilitiesCopy
|
||||
}
|
||||
|
||||
export { registerServerCapability, unregisterServerCapability }
|
42
src/editor/lsp/server.ts
Normal file
42
src/editor/lsp/server.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import init, {
|
||||
InitOutput,
|
||||
lsp_run,
|
||||
ServerConfig,
|
||||
} from '../../wasm-lib/pkg/wasm_lib'
|
||||
import { FromServer, IntoServer } from './codec'
|
||||
|
||||
let server: null | Server
|
||||
|
||||
export default class Server {
|
||||
readonly initOutput: InitOutput
|
||||
readonly #intoServer: IntoServer
|
||||
readonly #fromServer: FromServer
|
||||
|
||||
private constructor(
|
||||
initOutput: InitOutput,
|
||||
intoServer: IntoServer,
|
||||
fromServer: FromServer
|
||||
) {
|
||||
this.initOutput = initOutput
|
||||
this.#intoServer = intoServer
|
||||
this.#fromServer = fromServer
|
||||
}
|
||||
|
||||
static async initialize(
|
||||
intoServer: IntoServer,
|
||||
fromServer: FromServer
|
||||
): Promise<Server> {
|
||||
if (null == server) {
|
||||
const initOutput = await init()
|
||||
server = new Server(initOutput, intoServer, fromServer)
|
||||
} else {
|
||||
console.warn('Server already initialized; ignoring')
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const config = new ServerConfig(this.#intoServer, this.#fromServer)
|
||||
await lsp_run(config)
|
||||
}
|
||||
}
|
21
src/editor/lsp/tracer.ts
Normal file
21
src/editor/lsp/tracer.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Message } from 'vscode-languageserver-protocol'
|
||||
|
||||
const env = import.meta.env.MODE
|
||||
|
||||
export default class Tracer {
|
||||
static client(message: string): void {
|
||||
// These are really noisy, so we have a special env var for them.
|
||||
if (env === 'lsp_tracing') {
|
||||
console.log('lsp client message', message)
|
||||
}
|
||||
}
|
||||
|
||||
static server(input: string | Message): void {
|
||||
// These are really noisy, so we have a special env var for them.
|
||||
if (env === 'lsp_tracing') {
|
||||
const message: string =
|
||||
typeof input === 'string' ? input : JSON.stringify(input)
|
||||
console.log('lsp server message', message)
|
||||
}
|
||||
}
|
||||
}
|
@ -10,4 +10,5 @@ export const VITE_KC_API_BASE_URL = import.meta.env.VITE_KC_API_BASE_URL
|
||||
export const VITE_KC_SITE_BASE_URL = import.meta.env.VITE_KC_SITE_BASE_URL
|
||||
export const VITE_KC_CONNECTION_TIMEOUT_MS = import.meta.env
|
||||
.VITE_KC_CONNECTION_TIMEOUT_MS
|
||||
export const VITE_KC_SENTRY_DSN = import.meta.env.VITE_KC_SENTRY_DSN
|
||||
export const TEST = import.meta.env.TEST
|
||||
|
243
src/hooks/useAppMode.ts
Normal file
243
src/hooks/useAppMode.ts
Normal file
@ -0,0 +1,243 @@
|
||||
// needed somewhere to dump this logic,
|
||||
// Once we have xState this should be removed
|
||||
|
||||
import { useStore, Selections } from 'useStore'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { ArtifactMap, EngineCommandManager } from 'lang/std/engineConnection'
|
||||
import { Models } from '@kittycad/lib/dist/types/src'
|
||||
import { isReducedMotion } from 'lang/util'
|
||||
import { isOverlap } from 'lib/utils'
|
||||
|
||||
interface DefaultPlanes {
|
||||
xy: string
|
||||
yz: string
|
||||
xz: string
|
||||
}
|
||||
|
||||
export function useAppMode() {
|
||||
const {
|
||||
guiMode,
|
||||
setGuiMode,
|
||||
selectionRanges,
|
||||
engineCommandManager,
|
||||
selectionRangeTypeMap,
|
||||
} = useStore((s) => ({
|
||||
guiMode: s.guiMode,
|
||||
setGuiMode: s.setGuiMode,
|
||||
selectionRanges: s.selectionRanges,
|
||||
engineCommandManager: s.engineCommandManager,
|
||||
selectionRangeTypeMap: s.selectionRangeTypeMap,
|
||||
}))
|
||||
const [defaultPlanes, setDefaultPlanes] = useState<DefaultPlanes | null>(null)
|
||||
useEffect(() => {
|
||||
if (
|
||||
guiMode.mode === 'sketch' &&
|
||||
guiMode.sketchMode === 'selectFace' &&
|
||||
engineCommandManager
|
||||
) {
|
||||
if (!defaultPlanes) {
|
||||
const xy = createPlane(engineCommandManager, {
|
||||
x_axis: { x: 1, y: 0, z: 0 },
|
||||
y_axis: { x: 0, y: 1, z: 0 },
|
||||
color: { r: 0.7, g: 0.28, b: 0.28, a: 0.4 },
|
||||
})
|
||||
const yz = createPlane(engineCommandManager, {
|
||||
x_axis: { x: 0, y: 1, z: 0 },
|
||||
y_axis: { x: 0, y: 0, z: 1 },
|
||||
color: { r: 0.28, g: 0.7, b: 0.28, a: 0.4 },
|
||||
})
|
||||
const xz = createPlane(engineCommandManager, {
|
||||
x_axis: { x: 1, y: 0, z: 0 },
|
||||
y_axis: { x: 0, y: 0, z: 1 },
|
||||
color: { r: 0.28, g: 0.28, b: 0.7, a: 0.4 },
|
||||
})
|
||||
setDefaultPlanes({ xy, yz, xz })
|
||||
} else {
|
||||
hideDefaultPlanes(engineCommandManager, defaultPlanes)
|
||||
}
|
||||
}
|
||||
if (guiMode.mode !== 'sketch' && defaultPlanes) {
|
||||
Object.values(defaultPlanes).forEach((planeId) => {
|
||||
engineCommandManager?.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'object_visible',
|
||||
object_id: planeId,
|
||||
hidden: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
} else if (guiMode.mode === 'default') {
|
||||
const pathId =
|
||||
engineCommandManager &&
|
||||
isCursorInSketchCommandRange(
|
||||
engineCommandManager.artifactMap,
|
||||
selectionRanges
|
||||
)
|
||||
if (pathId) {
|
||||
setGuiMode({
|
||||
mode: 'canEditSketch',
|
||||
rotation: [0, 0, 0, 1],
|
||||
position: [0, 0, 0],
|
||||
pathToNode: [],
|
||||
pathId,
|
||||
})
|
||||
}
|
||||
} else if (guiMode.mode === 'canEditSketch') {
|
||||
if (
|
||||
!engineCommandManager ||
|
||||
!isCursorInSketchCommandRange(
|
||||
engineCommandManager.artifactMap,
|
||||
selectionRanges
|
||||
)
|
||||
) {
|
||||
setGuiMode({
|
||||
mode: 'default',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [
|
||||
guiMode,
|
||||
guiMode.mode,
|
||||
engineCommandManager,
|
||||
selectionRanges,
|
||||
selectionRangeTypeMap,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const unSub = engineCommandManager?.subscribeTo({
|
||||
event: 'select_with_point',
|
||||
callback: async ({ data }) => {
|
||||
if (!data.entity_id) return
|
||||
if (!defaultPlanes) return
|
||||
if (!Object.values(defaultPlanes || {}).includes(data.entity_id)) {
|
||||
// user clicked something else in the scene
|
||||
return
|
||||
}
|
||||
const sketchModeResponse = await engineCommandManager?.sendSceneCommand(
|
||||
{
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'sketch_mode_enable',
|
||||
plane_id: data.entity_id,
|
||||
ortho: true,
|
||||
animated: !isReducedMotion(),
|
||||
},
|
||||
}
|
||||
)
|
||||
hideDefaultPlanes(engineCommandManager, defaultPlanes)
|
||||
const sketchUuid = uuidv4()
|
||||
const proms: any[] = []
|
||||
proms.push(
|
||||
engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: sketchUuid,
|
||||
cmd: {
|
||||
type: 'start_path',
|
||||
},
|
||||
})
|
||||
)
|
||||
proms.push(
|
||||
engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'edit_mode_enter',
|
||||
target: sketchUuid,
|
||||
},
|
||||
})
|
||||
)
|
||||
const res = await Promise.all(proms)
|
||||
console.log('res', res)
|
||||
setGuiMode({
|
||||
mode: 'sketch',
|
||||
sketchMode: 'sketchEdit',
|
||||
rotation: [0, 0, 0, 1],
|
||||
position: [0, 0, 0],
|
||||
pathToNode: [],
|
||||
})
|
||||
|
||||
console.log('sketchModeResponse', sketchModeResponse)
|
||||
},
|
||||
})
|
||||
return unSub
|
||||
}, [engineCommandManager, defaultPlanes])
|
||||
}
|
||||
|
||||
function createPlane(
|
||||
engineCommandManager: EngineCommandManager,
|
||||
{
|
||||
x_axis,
|
||||
y_axis,
|
||||
color,
|
||||
}: {
|
||||
x_axis: Models['Point3d_type']
|
||||
y_axis: Models['Point3d_type']
|
||||
color: Models['Color_type']
|
||||
}
|
||||
) {
|
||||
const planeId = uuidv4()
|
||||
engineCommandManager?.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'make_plane',
|
||||
size: 60,
|
||||
origin: { x: 0, y: 0, z: 0 },
|
||||
x_axis,
|
||||
y_axis,
|
||||
clobber: false,
|
||||
},
|
||||
cmd_id: planeId,
|
||||
})
|
||||
engineCommandManager?.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'plane_set_color',
|
||||
plane_id: planeId,
|
||||
color,
|
||||
},
|
||||
cmd_id: uuidv4(),
|
||||
})
|
||||
return planeId
|
||||
}
|
||||
|
||||
function hideDefaultPlanes(
|
||||
engineCommandManager: EngineCommandManager,
|
||||
defaultPlanes: DefaultPlanes
|
||||
) {
|
||||
Object.values(defaultPlanes).forEach((planeId) => {
|
||||
engineCommandManager?.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'object_visible',
|
||||
object_id: planeId,
|
||||
hidden: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isCursorInSketchCommandRange(
|
||||
artifactMap: ArtifactMap,
|
||||
selectionRanges: Selections
|
||||
): string | false {
|
||||
const overlapingEntries = Object.entries(artifactMap || {}).filter(
|
||||
([id, artifact]) =>
|
||||
selectionRanges.codeBasedSelections.some(
|
||||
(selection) =>
|
||||
Array.isArray(selection.range) &&
|
||||
Array.isArray(artifact.range) &&
|
||||
isOverlap(selection.range, artifact.range) &&
|
||||
(artifact.commandType === 'start_path' ||
|
||||
artifact.commandType === 'extend_path' ||
|
||||
'close_path')
|
||||
)
|
||||
)
|
||||
return overlapingEntries.length === 1 && overlapingEntries[0][1].parentId
|
||||
? overlapingEntries[0][1].parentId
|
||||
: false
|
||||
}
|
56
src/hooks/useToolbarGuards.ts
Normal file
56
src/hooks/useToolbarGuards.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { SetVarNameModal } from 'components/SetVarNameModal'
|
||||
import { moveValueIntoNewVariable } from 'lang/modifyAst'
|
||||
import { isNodeSafeToReplace } from 'lang/queryAst'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { create } from 'react-modal-promise'
|
||||
import { useStore } from 'useStore'
|
||||
|
||||
const getModalInfo = create(SetVarNameModal as any)
|
||||
|
||||
export function useConvertToVariable() {
|
||||
const { guiMode, selectionRanges, ast, programMemory, updateAst } = useStore(
|
||||
(s) => ({
|
||||
guiMode: s.guiMode,
|
||||
ast: s.ast,
|
||||
updateAst: s.updateAst,
|
||||
selectionRanges: s.selectionRanges,
|
||||
programMemory: s.programMemory,
|
||||
})
|
||||
)
|
||||
const [enable, setEnabled] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!ast) return
|
||||
|
||||
const { isSafe, value } = isNodeSafeToReplace(
|
||||
ast,
|
||||
selectionRanges.codeBasedSelections?.[0]?.range || []
|
||||
)
|
||||
const canReplace = isSafe && value.type !== 'Identifier'
|
||||
const isOnlyOneSelection = selectionRanges.codeBasedSelections.length === 1
|
||||
|
||||
const _enableHorz = canReplace && isOnlyOneSelection
|
||||
setEnabled(_enableHorz)
|
||||
}, [guiMode, selectionRanges])
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!ast) return
|
||||
try {
|
||||
const { variableName } = await getModalInfo({
|
||||
valueName: 'var',
|
||||
} as any)
|
||||
|
||||
const { modifiedAst: _modifiedAst } = moveValueIntoNewVariable(
|
||||
ast,
|
||||
programMemory,
|
||||
selectionRanges.codeBasedSelections[0].range,
|
||||
variableName
|
||||
)
|
||||
|
||||
updateAst(_modifiedAst)
|
||||
} catch (e) {
|
||||
console.log('e', e)
|
||||
}
|
||||
}
|
||||
|
||||
return { enable, handleClick }
|
||||
}
|
@ -82,12 +82,36 @@ code {
|
||||
monospace;
|
||||
}
|
||||
|
||||
.full-height-subtract {
|
||||
--height-subtract: 2.25rem;
|
||||
height: 100%;
|
||||
max-height: calc(100% - var(--height-subtract));
|
||||
}
|
||||
|
||||
#code-mirror-override .cm-editor {
|
||||
@apply bg-transparent;
|
||||
@apply h-full bg-transparent;
|
||||
}
|
||||
|
||||
#code-mirror-override .cm-scroller {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
#code-mirror-override .cm-scroller::-webkit-scrollbar {
|
||||
@apply h-0;
|
||||
}
|
||||
|
||||
#code-mirror-override .cm-activeLine,
|
||||
#code-mirror-override .cm-activeLineGutter {
|
||||
@apply bg-liquid-10/50;
|
||||
}
|
||||
|
||||
.dark #code-mirror-override .cm-activeLine,
|
||||
.dark #code-mirror-override .cm-activeLineGutter {
|
||||
@apply bg-liquid-80/50;
|
||||
}
|
||||
|
||||
#code-mirror-override .cm-gutters {
|
||||
@apply bg-chalkboard-10/50;
|
||||
@apply bg-chalkboard-10/30;
|
||||
}
|
||||
|
||||
.dark #code-mirror-override .cm-gutters {
|
||||
@ -99,16 +123,68 @@ code {
|
||||
}
|
||||
#code-mirror-override .cm-cursor {
|
||||
display: block;
|
||||
width: 200px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgb(0, 55, 94) 0%,
|
||||
#0084e2ff 2%,
|
||||
#0084e255 5%,
|
||||
transparent 100%
|
||||
);
|
||||
width: 1ch;
|
||||
@apply bg-liquid-40 mix-blend-multiply;
|
||||
|
||||
animation: blink 2s ease-out infinite;
|
||||
}
|
||||
|
||||
.dark #code-mirror-override .cm-cursor {
|
||||
@apply bg-liquid-50;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
.react-json-view {
|
||||
@apply bg-transparent !important;
|
||||
}
|
||||
|
||||
#code-mirror-override .cm-tooltip {
|
||||
@apply text-xs shadow-md;
|
||||
@apply bg-chalkboard-10 text-chalkboard-80;
|
||||
@apply rounded-sm border-solid border border-chalkboard-40/30 border-l-liquid-10;
|
||||
}
|
||||
|
||||
.dark #code-mirror-override .cm-tooltip {
|
||||
@apply bg-chalkboard-110 text-chalkboard-40;
|
||||
@apply border-chalkboard-70/20 border-l-liquid-70;
|
||||
}
|
||||
|
||||
#code-mirror-override .cm-tooltip-hover {
|
||||
@apply py-1 px-2 w-max max-w-md;
|
||||
}
|
||||
|
||||
#code-mirror-override .cm-completionInfo {
|
||||
@apply px-4 rounded-l-none;
|
||||
@apply bg-chalkboard-10 text-liquid-90;
|
||||
@apply border-liquid-40/30;
|
||||
}
|
||||
|
||||
.dark #code-mirror-override .cm-completionInfo {
|
||||
@apply bg-liquid-120 text-liquid-50;
|
||||
@apply border-liquid-90/60;
|
||||
}
|
||||
|
||||
#code-mirror-override .cm-tooltip-autocomplete li {
|
||||
@apply px-2 py-1;
|
||||
}
|
||||
#code-mirror-override .cm-tooltip-autocomplete li[aria-selected='true'] {
|
||||
@apply bg-liquid-10 text-liquid-110;
|
||||
}
|
||||
.dark #code-mirror-override .cm-tooltip-autocomplete li[aria-selected='true'] {
|
||||
@apply bg-liquid-100 text-liquid-20;
|
||||
}
|
||||
|
||||
#code-mirror-override .cm-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
@ -179,6 +179,9 @@ const newVar = myVar + 1
|
||||
name: 'aIdentifier',
|
||||
},
|
||||
],
|
||||
function: {
|
||||
type: 'InMemory',
|
||||
},
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
@ -211,7 +214,6 @@ describe('testing function declaration', () => {
|
||||
type: 'FunctionExpression',
|
||||
start: 11,
|
||||
end: 19,
|
||||
id: null,
|
||||
params: [],
|
||||
body: {
|
||||
start: 17,
|
||||
@ -250,7 +252,6 @@ describe('testing function declaration', () => {
|
||||
type: 'FunctionExpression',
|
||||
start: 11,
|
||||
end: 39,
|
||||
id: null,
|
||||
params: [
|
||||
{
|
||||
type: 'Identifier',
|
||||
@ -326,7 +327,6 @@ const myVar = funcN(1, 2)`
|
||||
type: 'FunctionExpression',
|
||||
start: 11,
|
||||
end: 37,
|
||||
id: null,
|
||||
params: [
|
||||
{
|
||||
type: 'Identifier',
|
||||
@ -416,6 +416,9 @@ const myVar = funcN(1, 2)`
|
||||
raw: '2',
|
||||
},
|
||||
],
|
||||
function: {
|
||||
type: 'InMemory',
|
||||
},
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
@ -485,6 +488,7 @@ describe('testing pipe operator special', () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
function: expect.any(Object),
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
@ -521,6 +525,7 @@ describe('testing pipe operator special', () => {
|
||||
},
|
||||
{ type: 'PipeSubstitution', start: 59, end: 60 },
|
||||
],
|
||||
function: expect.any(Object),
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
@ -593,6 +598,7 @@ describe('testing pipe operator special', () => {
|
||||
},
|
||||
{ type: 'PipeSubstitution', start: 105, end: 106 },
|
||||
],
|
||||
function: expect.any(Object),
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
@ -629,6 +635,7 @@ describe('testing pipe operator special', () => {
|
||||
},
|
||||
{ type: 'PipeSubstitution', start: 128, end: 129 },
|
||||
],
|
||||
function: expect.any(Object),
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
@ -651,6 +658,9 @@ describe('testing pipe operator special', () => {
|
||||
},
|
||||
{ type: 'PipeSubstitution', start: 143, end: 144 },
|
||||
],
|
||||
function: {
|
||||
type: 'InMemory',
|
||||
},
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
@ -730,6 +740,9 @@ describe('testing pipe operator special', () => {
|
||||
end: 35,
|
||||
},
|
||||
],
|
||||
function: {
|
||||
type: 'InMemory',
|
||||
},
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
@ -1550,7 +1563,10 @@ const key = 'c'`
|
||||
type: 'NoneCodeNode',
|
||||
start: code.indexOf('\n// this is a comment'),
|
||||
end: code.indexOf('const key'),
|
||||
value: '\n// this is a comment\n',
|
||||
value: {
|
||||
type: 'blockComment',
|
||||
value: 'this is a comment',
|
||||
},
|
||||
}
|
||||
const { nonCodeMeta } = parser_wasm(code)
|
||||
expect(nonCodeMeta.noneCodeNodes[0]).toEqual(nonCodeMetaInstance)
|
||||
@ -1560,7 +1576,9 @@ const key = 'c'`
|
||||
const { nonCodeMeta: nonCodeMeta2 } = parser_wasm(
|
||||
codeWithExtraStartWhitespace
|
||||
)
|
||||
expect(nonCodeMeta2.noneCodeNodes[0].value).toBe(nonCodeMetaInstance.value)
|
||||
expect(nonCodeMeta2.noneCodeNodes[0].value).toStrictEqual(
|
||||
nonCodeMetaInstance.value
|
||||
)
|
||||
expect(nonCodeMeta2.noneCodeNodes[0].start).not.toBe(
|
||||
nonCodeMetaInstance.start
|
||||
)
|
||||
@ -1583,7 +1601,10 @@ const key = 'c'`
|
||||
type: 'NoneCodeNode',
|
||||
start: 106,
|
||||
end: 166,
|
||||
value: ' /* this is\n a comment\n spanning a few lines */\n ',
|
||||
value: {
|
||||
type: 'blockComment',
|
||||
value: 'this is\n a comment\n spanning a few lines',
|
||||
},
|
||||
})
|
||||
})
|
||||
it('comments in a pipe expression', () => {
|
||||
@ -1603,7 +1624,10 @@ const key = 'c'`
|
||||
type: 'NoneCodeNode',
|
||||
start: 125,
|
||||
end: 141,
|
||||
value: '\n// a comment\n ',
|
||||
value: {
|
||||
type: 'blockComment',
|
||||
value: 'a comment',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1627,6 +1651,7 @@ describe('test UnaryExpression', () => {
|
||||
{ type: 'Literal', start: 19, end: 20, value: 4, raw: '4' },
|
||||
{ type: 'Literal', start: 22, end: 25, value: 100, raw: '100' },
|
||||
],
|
||||
function: expect.any(Object),
|
||||
optional: false,
|
||||
},
|
||||
})
|
||||
@ -1660,10 +1685,12 @@ describe('testing nested call expressions', () => {
|
||||
{ type: 'Literal', start: 34, end: 35, value: 5, raw: '5' },
|
||||
{ type: 'Literal', start: 37, end: 38, value: 3, raw: '3' },
|
||||
],
|
||||
function: expect.any(Object),
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
function: expect.any(Object),
|
||||
optional: false,
|
||||
})
|
||||
})
|
||||
@ -1695,6 +1722,7 @@ describe('should recognise callExpresions in binaryExpressions', () => {
|
||||
},
|
||||
{ type: 'PipeSubstitution', start: 25, end: 26 },
|
||||
],
|
||||
function: expect.any(Object),
|
||||
optional: false,
|
||||
},
|
||||
right: { type: 'Literal', value: 1, raw: '1', start: 30, end: 31 },
|
||||
|
@ -114,7 +114,8 @@ describe('Testing addSketchTo', () => {
|
||||
expect(str).toBe(`const part001 = startSketchAt('default')
|
||||
|> ry(90, %)
|
||||
|> line('default', %)
|
||||
show(part001)`)
|
||||
show(part001)
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -179,7 +180,10 @@ describe('Testing moveValueIntoNewVariable', () => {
|
||||
return x
|
||||
}
|
||||
`
|
||||
const code = `${fn('def')}${fn('ghi')}${fn('jkl')}${fn('hmm')}
|
||||
const code = `${fn('def')}${fn('jkl')}${fn('hmm')}
|
||||
fn ghi = (x) => {
|
||||
return 2
|
||||
}
|
||||
const abc = 3
|
||||
const identifierGuy = 5
|
||||
const yo = 5 + 6
|
||||
|
@ -28,6 +28,46 @@ import {
|
||||
createFirstArg,
|
||||
} from './std/sketch'
|
||||
|
||||
export function addStartSketch(
|
||||
node: Program,
|
||||
start: [number, number],
|
||||
end: [number, number]
|
||||
): { modifiedAst: Program; id: string; pathToNode: PathToNode } {
|
||||
const _node = { ...node }
|
||||
const _name = findUniqueName(node, 'part')
|
||||
|
||||
const startSketchAt = createCallExpression('startSketchAt', [
|
||||
createArrayExpression([createLiteral(start[0]), createLiteral(start[1])]),
|
||||
])
|
||||
const initialLineTo = createCallExpression('line', [
|
||||
createArrayExpression([createLiteral(end[0]), createLiteral(end[1])]),
|
||||
createPipeSubstitution(),
|
||||
])
|
||||
|
||||
const pipeBody = [startSketchAt, initialLineTo]
|
||||
|
||||
const variableDeclaration = createVariableDeclaration(
|
||||
_name,
|
||||
createPipeExpression(pipeBody)
|
||||
)
|
||||
|
||||
_node.body = [...node.body, variableDeclaration]
|
||||
|
||||
let pathToNode: PathToNode = [
|
||||
['body', ''],
|
||||
['0', 'index'],
|
||||
['declarations', 'VariableDeclaration'],
|
||||
['0', 'index'],
|
||||
['init', 'VariableDeclarator'],
|
||||
]
|
||||
|
||||
return {
|
||||
modifiedAst: _node,
|
||||
id: _name,
|
||||
pathToNode,
|
||||
}
|
||||
}
|
||||
|
||||
export function addSketchTo(
|
||||
node: Program,
|
||||
axis: 'xy' | 'xz' | 'yz',
|
||||
@ -36,14 +76,14 @@ export function addSketchTo(
|
||||
const _node = { ...node }
|
||||
const _name = name || findUniqueName(node, 'part')
|
||||
|
||||
const startSketchAt = createCallExpression('startSketchAt', [
|
||||
const startSketchAt = createCallExpressionStdLib('startSketchAt', [
|
||||
createLiteral('default'),
|
||||
])
|
||||
const rotate = createCallExpression(axis === 'xz' ? 'rx' : 'ry', [
|
||||
createLiteral(90),
|
||||
createPipeSubstitution(),
|
||||
])
|
||||
const initialLineTo = createCallExpression('line', [
|
||||
const initialLineTo = createCallExpressionStdLib('line', [
|
||||
createLiteral('default'),
|
||||
createPipeSubstitution(),
|
||||
])
|
||||
@ -112,7 +152,9 @@ function addToShow(node: Program, name: string): Program {
|
||||
const dumbyStartend = { start: 0, end: 0 }
|
||||
const showCallIndex = getShowIndex(_node)
|
||||
if (showCallIndex === -1) {
|
||||
const showCall = createCallExpression('show', [createIdentifier(name)])
|
||||
const showCall = createCallExpressionStdLib('show', [
|
||||
createIdentifier(name),
|
||||
])
|
||||
const showExpressionStatement: ExpressionStatement = {
|
||||
type: 'ExpressionStatement',
|
||||
...dumbyStartend,
|
||||
@ -124,7 +166,7 @@ function addToShow(node: Program, name: string): Program {
|
||||
const showCall = { ..._node.body[showCallIndex] } as ExpressionStatement
|
||||
const showCallArgs = (showCall.expression as CallExpression).arguments
|
||||
const newShowCallArgs: Value[] = [...showCallArgs, createIdentifier(name)]
|
||||
const newShowExpression = createCallExpression('show', newShowCallArgs)
|
||||
const newShowExpression = createCallExpressionStdLib('show', newShowCallArgs)
|
||||
|
||||
_node.body[showCallIndex] = {
|
||||
...showCall,
|
||||
@ -225,7 +267,7 @@ export function extrudeSketch(
|
||||
const { node: variableDeclorator, shallowPath: pathToDecleration } =
|
||||
getNodeFromPath<VariableDeclarator>(_node, pathToNode, 'VariableDeclarator')
|
||||
|
||||
const extrudeCall = createCallExpression('extrude', [
|
||||
const extrudeCall = createCallExpressionStdLib('extrude', [
|
||||
createLiteral(4),
|
||||
shouldPipe
|
||||
? createPipeSubstitution()
|
||||
@ -313,15 +355,15 @@ export function sketchOnExtrudedFace(
|
||||
const newSketch = createVariableDeclaration(
|
||||
newSketchName,
|
||||
createPipeExpression([
|
||||
createCallExpression('startSketchAt', [
|
||||
createCallExpressionStdLib('startSketchAt', [
|
||||
createArrayExpression([createLiteral(0), createLiteral(0)]),
|
||||
]),
|
||||
createCallExpression('lineTo', [
|
||||
createCallExpressionStdLib('lineTo', [
|
||||
createArrayExpression([createLiteral(1), createLiteral(1)]),
|
||||
createPipeSubstitution(),
|
||||
]),
|
||||
createCallExpression('transform', [
|
||||
createCallExpression('getExtrudeWallTransform', [
|
||||
createCallExpressionStdLib('getExtrudeWallTransform', [
|
||||
createLiteral(tag),
|
||||
createIdentifier(oldSketchName),
|
||||
]),
|
||||
@ -414,6 +456,40 @@ export function createPipeSubstitution(): PipeSubstitution {
|
||||
}
|
||||
}
|
||||
|
||||
export function createCallExpressionStdLib(
|
||||
name: string,
|
||||
args: CallExpression['arguments']
|
||||
): CallExpression {
|
||||
return {
|
||||
type: 'CallExpression',
|
||||
start: 0,
|
||||
end: 0,
|
||||
callee: {
|
||||
type: 'Identifier',
|
||||
start: 0,
|
||||
end: 0,
|
||||
name,
|
||||
},
|
||||
function: {
|
||||
type: 'StdLib',
|
||||
func: {
|
||||
// We only need the name here to map it back when it serializes
|
||||
// to rust, don't worry about the rest.
|
||||
name,
|
||||
summary: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
returnValue: { type: '', required: false, name: '', schema: {} },
|
||||
args: [],
|
||||
unpublished: false,
|
||||
deprecated: false,
|
||||
},
|
||||
},
|
||||
optional: false,
|
||||
arguments: args,
|
||||
}
|
||||
}
|
||||
|
||||
export function createCallExpression(
|
||||
name: string,
|
||||
args: CallExpression['arguments']
|
||||
@ -428,6 +504,9 @@ export function createCallExpression(
|
||||
end: 0,
|
||||
name,
|
||||
},
|
||||
function: {
|
||||
type: 'InMemory',
|
||||
},
|
||||
optional: false,
|
||||
arguments: args,
|
||||
}
|
||||
|
@ -11,26 +11,27 @@ describe('recast', () => {
|
||||
const code = '1 + 2'
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
expect(recasted.trim()).toBe(code)
|
||||
})
|
||||
it('variable declaration', () => {
|
||||
const code = 'const myVar = 5'
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
expect(recasted.trim()).toBe(code)
|
||||
})
|
||||
it("variable declaration that's binary with string", () => {
|
||||
const code = "const myVar = 5 + 'yo'"
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
expect(recasted.trim()).toBe(code)
|
||||
const codeWithOtherQuotes = 'const myVar = 5 + "yo"'
|
||||
const { ast: ast2 } = code2ast(codeWithOtherQuotes)
|
||||
expect(recast(ast2)).toBe(codeWithOtherQuotes)
|
||||
expect(recast(ast2).trim()).toBe(codeWithOtherQuotes)
|
||||
})
|
||||
it('test assigning two variables, the second summing with the first', () => {
|
||||
const code = `const myVar = 5
|
||||
const newVar = myVar + 1`
|
||||
const newVar = myVar + 1
|
||||
`
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
@ -42,12 +43,12 @@ const newVar = myVar + 1`
|
||||
)
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code.trim())
|
||||
expect(recasted.trim()).toBe(code.trim())
|
||||
})
|
||||
it('test with function call', () => {
|
||||
const code = `
|
||||
const myVar = "hello"
|
||||
log(5, myVar)`
|
||||
const code = `const myVar = "hello"
|
||||
log(5, myVar)
|
||||
`
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
@ -62,7 +63,7 @@ log(5, myVar)`
|
||||
].join('\n')
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
expect(recasted.trim()).toBe(code)
|
||||
})
|
||||
it('recast sketch declaration', () => {
|
||||
let code = `const mySketch = startSketchAt([0, 0])
|
||||
@ -75,7 +76,7 @@ show(mySketch)
|
||||
`
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code.trim())
|
||||
expect(recasted).toBe(code)
|
||||
})
|
||||
it('sketch piped into callExpression', () => {
|
||||
const code = [
|
||||
@ -87,7 +88,7 @@ show(mySketch)
|
||||
].join('\n')
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code.trim())
|
||||
expect(recasted.trim()).toBe(code.trim())
|
||||
})
|
||||
it('recast BinaryExpression piped into CallExpression', () => {
|
||||
const code = [
|
||||
@ -99,37 +100,37 @@ show(mySketch)
|
||||
].join('\n')
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
expect(recasted.trim()).toBe(code)
|
||||
})
|
||||
it('recast nested binary expression', () => {
|
||||
const code = ['const myVar = 1 + 2 * 5'].join('\n')
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code.trim())
|
||||
expect(recasted.trim()).toBe(code.trim())
|
||||
})
|
||||
it('recast nested binary expression with parans', () => {
|
||||
const code = ['const myVar = 1 + (1 + 2) * 5'].join('\n')
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code.trim())
|
||||
expect(recasted.trim()).toBe(code.trim())
|
||||
})
|
||||
it('unnecessary paran wrap will be remove', () => {
|
||||
const code = ['const myVar = 1 + (2 * 5)'].join('\n')
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code.replace('(', '').replace(')', ''))
|
||||
expect(recasted.trim()).toBe(code.replace('(', '').replace(')', ''))
|
||||
})
|
||||
it('complex nested binary expression', () => {
|
||||
const code = ['1 * ((2 + 3) / 4 + 5)'].join('\n')
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code.trim())
|
||||
expect(recasted.trim()).toBe(code.trim())
|
||||
})
|
||||
it('multiplied paren expressions', () => {
|
||||
const code = ['3 + (1 + 2) * (3 + 4)'].join('\n')
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code.trim())
|
||||
expect(recasted.trim()).toBe(code.trim())
|
||||
})
|
||||
it('recast array declaration', () => {
|
||||
const code = ['const three = 3', "const yo = [1, '2', three, 4 + 5]"].join(
|
||||
@ -137,7 +138,7 @@ show(mySketch)
|
||||
)
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code.trim())
|
||||
expect(recasted.trim()).toBe(code.trim())
|
||||
})
|
||||
it('recast long array declaration', () => {
|
||||
const code = [
|
||||
@ -152,7 +153,7 @@ show(mySketch)
|
||||
].join('\n')
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code.trim())
|
||||
expect(recasted.trim()).toBe(code.trim())
|
||||
})
|
||||
it('recast long object exectution', () => {
|
||||
const code = `const three = 3
|
||||
@ -161,35 +162,38 @@ const yo = {
|
||||
anum: 2,
|
||||
identifier: three,
|
||||
binExp: 4 + 5
|
||||
}`
|
||||
}
|
||||
`
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code.trim())
|
||||
expect(recasted).toBe(code)
|
||||
})
|
||||
it('recast short object exectution', () => {
|
||||
const code = `const yo = { key: 'val' }`
|
||||
const code = `const yo = { key: 'val' }
|
||||
`
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code.trim())
|
||||
expect(recasted).toBe(code)
|
||||
})
|
||||
it('recast object execution with member expression', () => {
|
||||
const code = `const yo = { a: { b: { c: '123' } } }
|
||||
const key = 'c'
|
||||
const myVar = yo.a['b'][key]
|
||||
const key2 = 'b'
|
||||
const myVar2 = yo['a'][key2].c`
|
||||
const myVar2 = yo['a'][key2].c
|
||||
`
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code.trim())
|
||||
expect(recasted).toBe(code)
|
||||
})
|
||||
})
|
||||
|
||||
describe('testing recasting with comments and whitespace', () => {
|
||||
it('code with comments', () => {
|
||||
const code = `
|
||||
const yo = { a: { b: { c: '123' } } }
|
||||
const code = `const yo = { a: { b: { c: '123' } } }
|
||||
// this is a comment
|
||||
const key = 'c'`
|
||||
const key = 'c'
|
||||
`
|
||||
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
@ -197,38 +201,39 @@ const key = 'c'`
|
||||
expect(recasted).toBe(code)
|
||||
})
|
||||
it('code with comment and extra lines', () => {
|
||||
const code = `
|
||||
const yo = 'c' /* this is
|
||||
const code = `const yo = 'c'
|
||||
|
||||
/* this is
|
||||
a
|
||||
comment */
|
||||
|
||||
const yo = 'bing'`
|
||||
const yo = 'bing'
|
||||
`
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
})
|
||||
it('comments at the start and end', () => {
|
||||
const code = `
|
||||
// this is a comment
|
||||
|
||||
const code = `// this is a comment
|
||||
const yo = { a: { b: { c: '123' } } }
|
||||
const key = 'c'
|
||||
|
||||
// this is also a comment`
|
||||
// this is also a comment
|
||||
`
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
})
|
||||
it('comments in a fn block', () => {
|
||||
const code = `
|
||||
const myFn = () => {
|
||||
const code = `const myFn = () => {
|
||||
// this is a comment
|
||||
const yo = { a: { b: { c: '123' } } } /* block
|
||||
comment */
|
||||
const yo = { a: { b: { c: '123' } } }
|
||||
|
||||
/* block
|
||||
comment */
|
||||
const key = 'c'
|
||||
// this is also a comment
|
||||
}`
|
||||
}
|
||||
`
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
@ -244,7 +249,7 @@ const myFn = () => {
|
||||
].join('\n')
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
expect(recasted.trim()).toBe(code)
|
||||
})
|
||||
it('comments sprinkled in all over the place', () => {
|
||||
const code = `
|
||||
@ -266,10 +271,26 @@ const mySk1 = startSketchAt([0, 0])
|
||||
|> rx(45, %)
|
||||
/*
|
||||
one more for good measure
|
||||
*/`
|
||||
*/
|
||||
`
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
expect(recasted).toBe(`// comment at start
|
||||
const mySk1 = startSketchAt([0, 0])
|
||||
|> lineTo([1, 1], %)
|
||||
// comment here
|
||||
|> lineTo({ to: [0, 1], tag: 'myTag' }, %)
|
||||
|> lineTo([1, 1], %)
|
||||
/* and
|
||||
here
|
||||
|
||||
a comment between pipe expression statements */
|
||||
|> rx(90, %)
|
||||
// and another with just white space between others below
|
||||
|> ry(45, %)
|
||||
|> rx(45, %)
|
||||
// one more for good measure
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -278,19 +299,19 @@ describe('testing call Expressions in BinaryExpressions and UnaryExpressions', (
|
||||
const code = 'const myVar = 2 + min(100, legLen(5, 3))'
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
expect(recasted.trim()).toBe(code)
|
||||
})
|
||||
it('nested callExpression in unaryExpression', () => {
|
||||
const code = 'const myVar = -min(100, legLen(5, 3))'
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
expect(recasted.trim()).toBe(code)
|
||||
})
|
||||
it('with unaryExpression in callExpression', () => {
|
||||
const code = 'const myVar = min(5, -legLen(5, 4))'
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
expect(recasted.trim()).toBe(code)
|
||||
})
|
||||
it('with unaryExpression in sketch situation', () => {
|
||||
const code = [
|
||||
@ -299,7 +320,7 @@ describe('testing call Expressions in BinaryExpressions and UnaryExpressions', (
|
||||
].join('\n')
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
expect(recasted.trim()).toBe(code)
|
||||
})
|
||||
})
|
||||
|
||||
@ -314,7 +335,8 @@ describe('it recasts wrapped object expressions in pipe bodies with correct inde
|
||||
intersectTag: 'seg01'
|
||||
}, %)
|
||||
|> line([-0.42, -1.72], %)
|
||||
show(part001)`
|
||||
show(part001)
|
||||
`
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
@ -324,7 +346,8 @@ show(part001)`
|
||||
angle: 201,
|
||||
offset: -1.35,
|
||||
intersectTag: 'seg01'
|
||||
}, %)`
|
||||
}, %)
|
||||
`
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code)
|
||||
@ -333,7 +356,8 @@ show(part001)`
|
||||
|
||||
describe('it recasts binary expression using brackets where needed', () => {
|
||||
it('when there are two minus in a row', () => {
|
||||
const code = `const part001 = 1 - (def - abc)`
|
||||
const code = `const part001 = 1 - (def - abc)
|
||||
`
|
||||
const recasted = recast(code2ast(code).ast)
|
||||
expect(recasted).toBe(code)
|
||||
})
|
||||
|
@ -4,12 +4,20 @@ import { VITE_KC_API_WS_MODELING_URL, VITE_KC_CONNECTION_TIMEOUT_MS } from 'env'
|
||||
import { Models } from '@kittycad/lib'
|
||||
import { exportSave } from 'lib/exportSave'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import * as Sentry from '@sentry/react'
|
||||
|
||||
interface ResultCommand {
|
||||
let lastMessage = ''
|
||||
|
||||
interface CommandInfo {
|
||||
commandType: CommandTypes
|
||||
range: SourceRange
|
||||
parentId?: string
|
||||
}
|
||||
interface ResultCommand extends CommandInfo {
|
||||
type: 'result'
|
||||
data: any
|
||||
}
|
||||
interface PendingCommand {
|
||||
interface PendingCommand extends CommandInfo {
|
||||
type: 'pending'
|
||||
promise: Promise<any>
|
||||
resolve: (val: any) => void
|
||||
@ -22,16 +30,6 @@ export interface SourceRangeMap {
|
||||
[key: string]: SourceRange
|
||||
}
|
||||
|
||||
interface SelectionsArgs {
|
||||
id: string
|
||||
type: Selections['codeBasedSelections'][number]['type']
|
||||
}
|
||||
|
||||
interface CursorSelectionsArgs {
|
||||
otherSelections: Selections['otherSelections']
|
||||
idBasedSelections: { type: string; id: string }[]
|
||||
}
|
||||
|
||||
interface NewTrackArgs {
|
||||
conn: EngineConnection
|
||||
mediaStream: MediaStream
|
||||
@ -39,13 +37,15 @@ interface NewTrackArgs {
|
||||
|
||||
type WebSocketResponse = Models['OkWebSocketResponseData_type']
|
||||
|
||||
type ClientMetrics = Models['ClientMetrics_type']
|
||||
|
||||
// EngineConnection encapsulates the connection(s) to the Engine
|
||||
// for the EngineCommandManager; namely, the underlying WebSocket
|
||||
// and WebRTC connections.
|
||||
export class EngineConnection {
|
||||
websocket?: WebSocket
|
||||
pc?: RTCPeerConnection
|
||||
lossyDataChannel?: RTCDataChannel
|
||||
unreliableDataChannel?: RTCDataChannel
|
||||
|
||||
private ready: boolean
|
||||
|
||||
@ -58,6 +58,9 @@ export class EngineConnection {
|
||||
private onClose: (engineConnection: EngineConnection) => void
|
||||
private onNewTrack: (track: NewTrackArgs) => void
|
||||
|
||||
// TODO: actual type is ClientMetrics
|
||||
private webrtcStatsCollector?: () => Promise<ClientMetrics>
|
||||
|
||||
constructor({
|
||||
url,
|
||||
token,
|
||||
@ -107,6 +110,11 @@ export class EngineConnection {
|
||||
isReady() {
|
||||
return this.ready
|
||||
}
|
||||
// shouldTrace will return true when Sentry should be used to instrument
|
||||
// the Engine.
|
||||
shouldTrace() {
|
||||
return Sentry.getCurrentHub()?.getClient()?.getOptions()?.sendClientReports
|
||||
}
|
||||
// connect will attempt to connect to the Engine over a WebSocket, and
|
||||
// establish the WebRTC connections.
|
||||
//
|
||||
@ -116,6 +124,44 @@ export class EngineConnection {
|
||||
// TODO(paultag): make this safe to call multiple times, and figure out
|
||||
// when a connection is in progress (state: connecting or something).
|
||||
|
||||
// Information on the connect transaction
|
||||
|
||||
class SpanPromise {
|
||||
span: Sentry.Span
|
||||
promise: Promise<void>
|
||||
resolve?: (v: void) => void
|
||||
|
||||
constructor(span: Sentry.Span) {
|
||||
this.span = span
|
||||
this.promise = new Promise((resolve) => {
|
||||
this.resolve = (v: void) => {
|
||||
// here we're going to invoke finish before resolving the
|
||||
// promise so that a `.then()` will order strictly after
|
||||
// all spans have -- for sure -- been resolved, rather than
|
||||
// doing a `then` on this promise.
|
||||
this.span.finish()
|
||||
resolve(v)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let webrtcMediaTransaction: Sentry.Transaction
|
||||
let websocketSpan: SpanPromise
|
||||
let mediaTrackSpan: SpanPromise
|
||||
let dataChannelSpan: SpanPromise
|
||||
let handshakeSpan: SpanPromise
|
||||
let iceSpan: SpanPromise
|
||||
|
||||
if (this.shouldTrace()) {
|
||||
webrtcMediaTransaction = Sentry.startTransaction({
|
||||
name: 'webrtc-media',
|
||||
})
|
||||
websocketSpan = new SpanPromise(
|
||||
webrtcMediaTransaction.startChild({ op: 'websocket' })
|
||||
)
|
||||
}
|
||||
|
||||
this.websocket = new WebSocket(this.url, [])
|
||||
this.websocket.binaryType = 'arraybuffer'
|
||||
|
||||
@ -129,6 +175,39 @@ export class EngineConnection {
|
||||
})
|
||||
|
||||
this.websocket.addEventListener('open', (event) => {
|
||||
if (this.shouldTrace()) {
|
||||
websocketSpan.resolve?.()
|
||||
|
||||
handshakeSpan = new SpanPromise(
|
||||
webrtcMediaTransaction.startChild({ op: 'handshake' })
|
||||
)
|
||||
iceSpan = new SpanPromise(
|
||||
webrtcMediaTransaction.startChild({ op: 'ice' })
|
||||
)
|
||||
dataChannelSpan = new SpanPromise(
|
||||
webrtcMediaTransaction.startChild({
|
||||
op: 'data-channel',
|
||||
})
|
||||
)
|
||||
mediaTrackSpan = new SpanPromise(
|
||||
webrtcMediaTransaction.startChild({
|
||||
op: 'media-track',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (this.shouldTrace()) {
|
||||
Promise.all([
|
||||
handshakeSpan.promise,
|
||||
iceSpan.promise,
|
||||
dataChannelSpan.promise,
|
||||
mediaTrackSpan.promise,
|
||||
]).then(() => {
|
||||
console.log('All spans finished, reporting')
|
||||
webrtcMediaTransaction?.finish()
|
||||
})
|
||||
}
|
||||
|
||||
this.onWebsocketOpen(this)
|
||||
})
|
||||
|
||||
@ -191,6 +270,13 @@ export class EngineConnection {
|
||||
sdp: answer.sdp,
|
||||
})
|
||||
)
|
||||
|
||||
if (this.shouldTrace()) {
|
||||
// When both ends have a local and remote SDP, we've been able to
|
||||
// set up successfully. We'll still need to find the right ICE
|
||||
// servers, but this is hand-shook.
|
||||
handshakeSpan.resolve?.()
|
||||
}
|
||||
}
|
||||
} else if (resp.type === 'trickle_ice') {
|
||||
let candidate = resp.data?.candidate
|
||||
@ -220,9 +306,11 @@ export class EngineConnection {
|
||||
// PeerConnection and waiting for events to fire our callbacks.
|
||||
|
||||
this.pc.addEventListener('connectionstatechange', (event) => {
|
||||
// if (this.pc?.iceConnectionState === 'disconnected') {
|
||||
// this.close()
|
||||
// }
|
||||
if (this.pc?.iceConnectionState === 'connected') {
|
||||
if (this.shouldTrace()) {
|
||||
iceSpan.resolve?.()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.pc.addEventListener('icecandidate', (event) => {
|
||||
@ -254,6 +342,17 @@ export class EngineConnection {
|
||||
})
|
||||
})
|
||||
.catch(console.log)
|
||||
} else if (resp.type === 'metrics_request') {
|
||||
if (this.webrtcStatsCollector === undefined) {
|
||||
// TODO: Error message here?
|
||||
return
|
||||
}
|
||||
this.webrtcStatsCollector().then((client_metrics) => {
|
||||
this.send({
|
||||
type: 'metrics_response',
|
||||
metrics: client_metrics,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TODO(paultag): This ought to be both controllable, as well as something
|
||||
@ -272,58 +371,123 @@ export class EngineConnection {
|
||||
})
|
||||
|
||||
this.pc.addEventListener('track', (event) => {
|
||||
console.log('received track', event)
|
||||
const mediaStream = event.streams[0]
|
||||
|
||||
if (this.shouldTrace()) {
|
||||
let mediaStreamTrack = mediaStream.getVideoTracks()[0]
|
||||
mediaStreamTrack.addEventListener('unmute', () => {
|
||||
// let settings = mediaStreamTrack.getSettings()
|
||||
// mediaTrackSpan.span.setTag("fps", settings.frameRate)
|
||||
// mediaTrackSpan.span.setTag("width", settings.width)
|
||||
// mediaTrackSpan.span.setTag("height", settings.height)
|
||||
mediaTrackSpan.resolve?.()
|
||||
})
|
||||
}
|
||||
|
||||
this.webrtcStatsCollector = (): Promise<ClientMetrics> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (mediaStream.getVideoTracks().length !== 1) {
|
||||
reject(new Error('too many video tracks to report'))
|
||||
return
|
||||
}
|
||||
|
||||
let videoTrack = mediaStream.getVideoTracks()[0]
|
||||
this.pc?.getStats(videoTrack).then((videoTrackStats) => {
|
||||
// TODO(paultag): this needs type information from the KittyCAD typescript
|
||||
// library once it's updated
|
||||
let client_metrics: ClientMetrics = {
|
||||
rtc_frames_decoded: 0,
|
||||
rtc_frames_dropped: 0,
|
||||
rtc_frames_received: 0,
|
||||
rtc_frames_per_second: 0,
|
||||
rtc_freeze_count: 0,
|
||||
rtc_jitter_sec: 0.0,
|
||||
rtc_keyframes_decoded: 0,
|
||||
rtc_total_freezes_duration_sec: 0.0,
|
||||
}
|
||||
|
||||
// TODO(paultag): Since we can technically have multiple WebRTC
|
||||
// video tracks (even if the Server doesn't at the moment), we
|
||||
// ought to send stats for every video track(?), and add the stream
|
||||
// ID into it. This raises the cardinality of collected metrics
|
||||
// when/if we do, but for now, just report the one stream.
|
||||
|
||||
videoTrackStats.forEach((videoTrackReport) => {
|
||||
if (videoTrackReport.type === 'inbound-rtp') {
|
||||
client_metrics.rtc_frames_decoded =
|
||||
videoTrackReport.framesDecoded
|
||||
client_metrics.rtc_frames_dropped =
|
||||
videoTrackReport.framesDropped
|
||||
client_metrics.rtc_frames_received =
|
||||
videoTrackReport.framesReceived
|
||||
client_metrics.rtc_frames_per_second =
|
||||
videoTrackReport.framesPerSecond || 0
|
||||
client_metrics.rtc_freeze_count = videoTrackReport.freezeCount
|
||||
client_metrics.rtc_jitter_sec = videoTrackReport.jitter
|
||||
client_metrics.rtc_keyframes_decoded =
|
||||
videoTrackReport.keyFramesDecoded
|
||||
client_metrics.rtc_total_freezes_duration_sec =
|
||||
videoTrackReport.totalFreezesDuration
|
||||
} else if (videoTrackReport.type === 'transport') {
|
||||
// videoTrackReport.bytesReceived,
|
||||
// videoTrackReport.bytesSent,
|
||||
}
|
||||
})
|
||||
resolve(client_metrics)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
this.onNewTrack({
|
||||
conn: this,
|
||||
mediaStream: mediaStream,
|
||||
})
|
||||
})
|
||||
|
||||
// During startup, we'll track the time from `connect` being called
|
||||
// until the 'done' event fires.
|
||||
let connectionStarted = new Date()
|
||||
|
||||
this.pc.addEventListener('datachannel', (event) => {
|
||||
this.lossyDataChannel = event.channel
|
||||
this.unreliableDataChannel = event.channel
|
||||
|
||||
console.log('accepted lossy data channel', event.channel.label)
|
||||
this.lossyDataChannel.addEventListener('open', (event) => {
|
||||
console.log('lossy data channel opened', event)
|
||||
console.log('accepted unreliable data channel', event.channel.label)
|
||||
this.unreliableDataChannel.addEventListener('open', (event) => {
|
||||
console.log('unreliable data channel opened', event)
|
||||
if (this.shouldTrace()) {
|
||||
dataChannelSpan.resolve?.()
|
||||
}
|
||||
|
||||
this.onDataChannelOpen(this)
|
||||
|
||||
let timeToConnectMs = new Date().getTime() - connectionStarted.getTime()
|
||||
console.log(`engine connection time to connect: ${timeToConnectMs}ms`)
|
||||
this.onEngineConnectionOpen(this)
|
||||
this.ready = true
|
||||
})
|
||||
|
||||
this.lossyDataChannel.addEventListener('close', (event) => {
|
||||
console.log('lossy data channel closed')
|
||||
this.unreliableDataChannel.addEventListener('close', (event) => {
|
||||
console.log('unreliable data channel closed')
|
||||
this.close()
|
||||
})
|
||||
|
||||
this.lossyDataChannel.addEventListener('error', (event) => {
|
||||
console.log('lossy data channel error')
|
||||
this.unreliableDataChannel.addEventListener('error', (event) => {
|
||||
console.log('unreliable data channel error')
|
||||
this.close()
|
||||
})
|
||||
})
|
||||
|
||||
this.onConnectionStarted(this)
|
||||
}
|
||||
send(message: object) {
|
||||
send(message: object | string) {
|
||||
// TODO(paultag): Add in logic to determine the connection state and
|
||||
// take actions if needed?
|
||||
this.websocket?.send(JSON.stringify(message))
|
||||
this.websocket?.send(
|
||||
typeof message === 'string' ? message : JSON.stringify(message)
|
||||
)
|
||||
}
|
||||
close() {
|
||||
this.websocket?.close()
|
||||
this.pc?.close()
|
||||
this.lossyDataChannel?.close()
|
||||
this.unreliableDataChannel?.close()
|
||||
this.websocket = undefined
|
||||
this.pc = undefined
|
||||
this.lossyDataChannel = undefined
|
||||
this.unreliableDataChannel = undefined
|
||||
this.webrtcStatsCollector = undefined
|
||||
|
||||
this.onClose(this)
|
||||
this.ready = false
|
||||
@ -331,6 +495,25 @@ export class EngineConnection {
|
||||
}
|
||||
|
||||
export type EngineCommand = Models['WebSocketRequest_type']
|
||||
type ModelTypes = Models['OkModelingCmdResponse_type']['type']
|
||||
|
||||
type CommandTypes = Models['ModelingCmd_type']['type']
|
||||
|
||||
type UnreliableResponses = Extract<
|
||||
Models['OkModelingCmdResponse_type'],
|
||||
{ type: 'highlight_set_entity' }
|
||||
>
|
||||
interface UnreliableSubscription<T extends UnreliableResponses['type']> {
|
||||
event: T
|
||||
callback: (data: Extract<UnreliableResponses, { type: T }>) => void
|
||||
}
|
||||
|
||||
interface Subscription<T extends ModelTypes> {
|
||||
event: T
|
||||
callback: (
|
||||
data: Extract<Models['OkModelingCmdResponse_type'], { type: T }>
|
||||
) => void
|
||||
}
|
||||
|
||||
export class EngineCommandManager {
|
||||
artifactMap: ArtifactMap = {}
|
||||
@ -340,10 +523,17 @@ export class EngineCommandManager {
|
||||
engineConnection?: EngineConnection
|
||||
waitForReady: Promise<void> = new Promise(() => {})
|
||||
private resolveReady = () => {}
|
||||
onHoverCallback: (id?: string) => void = () => {}
|
||||
onClickCallback: (selection?: SelectionsArgs) => void = () => {}
|
||||
onCursorsSelectedCallback: (selections: CursorSelectionsArgs) => void =
|
||||
() => {}
|
||||
|
||||
subscriptions: {
|
||||
[event: string]: {
|
||||
[localUnsubscribeId: string]: (a: any) => void
|
||||
}
|
||||
} = {} as any
|
||||
unreliableSubscriptions: {
|
||||
[event: string]: {
|
||||
[localUnsubscribeId: string]: (a: any) => void
|
||||
}
|
||||
} = {} as any
|
||||
constructor({
|
||||
setMediaStream,
|
||||
setIsStreamReady,
|
||||
@ -373,20 +563,28 @@ export class EngineCommandManager {
|
||||
},
|
||||
onConnectionStarted: (engineConnection) => {
|
||||
engineConnection?.pc?.addEventListener('datachannel', (event) => {
|
||||
let lossyDataChannel = event.channel
|
||||
let unreliableDataChannel = event.channel
|
||||
|
||||
lossyDataChannel.addEventListener('message', (event) => {
|
||||
const result: Models['OkModelingCmdResponse_type'] = JSON.parse(
|
||||
event.data
|
||||
)
|
||||
unreliableDataChannel.addEventListener('message', (event) => {
|
||||
const result: UnreliableResponses = JSON.parse(event.data)
|
||||
Object.values(
|
||||
this.unreliableSubscriptions[result.type] || {}
|
||||
).forEach(
|
||||
// TODO: There is only one response that uses the unreliable channel atm,
|
||||
// highlight_set_entity, if there are more it's likely they will all have the same
|
||||
// sequence logic, but I'm not sure if we use a single global sequence or a sequence
|
||||
// per unreliable subscription.
|
||||
(callback) => {
|
||||
if (
|
||||
result.type === 'highlight_set_entity' &&
|
||||
result?.data?.sequence &&
|
||||
result.data.sequence > this.inSequence
|
||||
result?.data.sequence > this.inSequence &&
|
||||
result.type === 'highlight_set_entity'
|
||||
) {
|
||||
this.onHoverCallback(result.data.entity_id)
|
||||
this.inSequence = result.data.sequence
|
||||
callback(result)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -418,8 +616,8 @@ export class EngineCommandManager {
|
||||
|
||||
mediaStream.getVideoTracks()[0].addEventListener('mute', () => {
|
||||
console.log('peer is not sending video to us')
|
||||
this.engineConnection?.close()
|
||||
this.engineConnection?.connect()
|
||||
// this.engineConnection?.close()
|
||||
// this.engineConnection?.connect()
|
||||
})
|
||||
|
||||
setMediaStream(mediaStream)
|
||||
@ -433,30 +631,31 @@ export class EngineCommandManager {
|
||||
return
|
||||
}
|
||||
const modelingResponse = message.data.modeling_response
|
||||
Object.values(this.subscriptions[modelingResponse.type] || {}).forEach(
|
||||
(callback) => callback(modelingResponse)
|
||||
)
|
||||
|
||||
const command = this.artifactMap[id]
|
||||
if (modelingResponse.type === 'select_with_point') {
|
||||
if (modelingResponse?.data?.entity_id) {
|
||||
this.onClickCallback({
|
||||
id: modelingResponse?.data?.entity_id,
|
||||
type: 'default',
|
||||
})
|
||||
} else {
|
||||
this.onClickCallback()
|
||||
}
|
||||
}
|
||||
if (command && command.type === 'pending') {
|
||||
const resolve = command.resolve
|
||||
this.artifactMap[id] = {
|
||||
type: 'result',
|
||||
range: command.range,
|
||||
commandType: command.commandType,
|
||||
parentId: command.parentId ? command.parentId : undefined,
|
||||
data: modelingResponse,
|
||||
}
|
||||
resolve({
|
||||
id,
|
||||
commandType: command.commandType,
|
||||
range: command.range,
|
||||
data: modelingResponse,
|
||||
})
|
||||
} else {
|
||||
this.artifactMap[id] = {
|
||||
type: 'result',
|
||||
commandType: command?.commandType,
|
||||
range: command?.range,
|
||||
data: modelingResponse,
|
||||
}
|
||||
}
|
||||
@ -468,20 +667,69 @@ export class EngineCommandManager {
|
||||
this.artifactMap = {}
|
||||
this.sourceRangeMap = {}
|
||||
}
|
||||
subscribeTo<T extends ModelTypes>({
|
||||
event,
|
||||
callback,
|
||||
}: Subscription<T>): () => void {
|
||||
const localUnsubscribeId = uuidv4()
|
||||
const otherEventCallbacks = this.subscriptions[event]
|
||||
if (otherEventCallbacks) {
|
||||
otherEventCallbacks[localUnsubscribeId] = callback
|
||||
} else {
|
||||
this.subscriptions[event] = {
|
||||
[localUnsubscribeId]: callback,
|
||||
}
|
||||
}
|
||||
return () => this.unSubscribeTo(event, localUnsubscribeId)
|
||||
}
|
||||
private unSubscribeTo(event: ModelTypes, id: string) {
|
||||
delete this.subscriptions[event][id]
|
||||
}
|
||||
subscribeToUnreliable<T extends UnreliableResponses['type']>({
|
||||
event,
|
||||
callback,
|
||||
}: UnreliableSubscription<T>): () => void {
|
||||
const localUnsubscribeId = uuidv4()
|
||||
const otherEventCallbacks = this.unreliableSubscriptions[event]
|
||||
if (otherEventCallbacks) {
|
||||
otherEventCallbacks[localUnsubscribeId] = callback
|
||||
} else {
|
||||
this.unreliableSubscriptions[event] = {
|
||||
[localUnsubscribeId]: callback,
|
||||
}
|
||||
}
|
||||
return () => this.unSubscribeToUnreliable(event, localUnsubscribeId)
|
||||
}
|
||||
private unSubscribeToUnreliable(
|
||||
event: UnreliableResponses['type'],
|
||||
id: string
|
||||
) {
|
||||
delete this.unreliableSubscriptions[event][id]
|
||||
}
|
||||
endSession() {
|
||||
// this.websocket?.close()
|
||||
// socket.off('command')
|
||||
// TODO: instead of sending a single command with `object_ids: Object.keys(this.artifactMap)`
|
||||
// we need to loop over them each individualy because if the engine doesn't recognise a single
|
||||
// id the whole command fails.
|
||||
Object.entries(this.artifactMap).forEach(([id, artifact]) => {
|
||||
const artifactTypesToDelete: ArtifactMap[string]['commandType'][] = [
|
||||
// 'start_path' creates a new scene object for the path, which is why it needs to be deleted,
|
||||
// however all of the segments in the path are its children so there don't need to be deleted.
|
||||
// this fact is very opaque in the api and docs (as to what should can be deleted).
|
||||
// Using an array is the list is likely to grow.
|
||||
'start_path',
|
||||
]
|
||||
if (!artifactTypesToDelete.includes(artifact.commandType)) return
|
||||
|
||||
const deletCmd: EngineCommand = {
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'remove_scene_objects',
|
||||
object_ids: [id],
|
||||
},
|
||||
}
|
||||
onHover(callback: (id?: string) => void) {
|
||||
// It's when the user hovers over a part in the 3d scene, and so the engine should tell the
|
||||
// frontend about that (with it's id) so that the FE can highlight code associated with that id
|
||||
this.onHoverCallback = callback
|
||||
}
|
||||
onClick(callback: (selection?: SelectionsArgs) => void) {
|
||||
// It's when the user clicks on a part in the 3d scene, and so the engine should tell the
|
||||
// frontend about that (with it's id) so that the FE can put the user's cursor on the right
|
||||
// line of code
|
||||
this.onClickCallback = callback
|
||||
this.engineConnection?.send(deletCmd)
|
||||
})
|
||||
}
|
||||
cusorsSelected(selections: {
|
||||
otherSelections: Selections['otherSelections']
|
||||
@ -507,32 +755,54 @@ export class EngineCommandManager {
|
||||
cmd_id: uuidv4(),
|
||||
})
|
||||
}
|
||||
sendSceneCommand(command: EngineCommand) {
|
||||
sendSceneCommand(command: EngineCommand): Promise<any> {
|
||||
if (
|
||||
command.type === 'modeling_cmd_req' &&
|
||||
command.cmd.type !== lastMessage
|
||||
) {
|
||||
console.log('sending command', command.cmd.type)
|
||||
lastMessage = command.cmd.type
|
||||
}
|
||||
if (!this.engineConnection?.isReady()) {
|
||||
console.log('socket not ready')
|
||||
return
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (command.type !== 'modeling_cmd_req') return
|
||||
if (command.type !== 'modeling_cmd_req') return Promise.resolve()
|
||||
const cmd = command.cmd
|
||||
if (
|
||||
cmd.type === 'camera_drag_move' &&
|
||||
this.engineConnection?.lossyDataChannel
|
||||
this.engineConnection?.unreliableDataChannel
|
||||
) {
|
||||
cmd.sequence = this.outSequence
|
||||
this.outSequence++
|
||||
this.engineConnection?.lossyDataChannel?.send(JSON.stringify(command))
|
||||
return
|
||||
this.engineConnection?.unreliableDataChannel?.send(
|
||||
JSON.stringify(command)
|
||||
)
|
||||
return Promise.resolve()
|
||||
} else if (
|
||||
cmd.type === 'highlight_set_entity' &&
|
||||
this.engineConnection?.lossyDataChannel
|
||||
this.engineConnection?.unreliableDataChannel
|
||||
) {
|
||||
cmd.sequence = this.outSequence
|
||||
this.outSequence++
|
||||
this.engineConnection?.lossyDataChannel?.send(JSON.stringify(command))
|
||||
return
|
||||
this.engineConnection?.unreliableDataChannel?.send(
|
||||
JSON.stringify(command)
|
||||
)
|
||||
return Promise.resolve()
|
||||
} else if (
|
||||
cmd.type === 'mouse_move' &&
|
||||
this.engineConnection.unreliableDataChannel
|
||||
) {
|
||||
cmd.sequence = this.outSequence
|
||||
this.outSequence++
|
||||
this.engineConnection?.unreliableDataChannel?.send(
|
||||
JSON.stringify(command)
|
||||
)
|
||||
return Promise.resolve()
|
||||
}
|
||||
console.log('sending command', command)
|
||||
// since it's not mouse drag or highlighting send over TCP and keep track of the command
|
||||
this.engineConnection?.send(command)
|
||||
return this.handlePendingCommand(command.cmd_id, command.cmd)
|
||||
}
|
||||
sendModelingCommand({
|
||||
id,
|
||||
@ -541,21 +811,44 @@ export class EngineCommandManager {
|
||||
}: {
|
||||
id: string
|
||||
range: SourceRange
|
||||
command: EngineCommand
|
||||
command: EngineCommand | string
|
||||
}): Promise<any> {
|
||||
this.sourceRangeMap[id] = range
|
||||
|
||||
if (!this.engineConnection?.isReady()) {
|
||||
console.log('socket not ready')
|
||||
return new Promise(() => {})
|
||||
return Promise.resolve()
|
||||
}
|
||||
this.engineConnection?.send(command)
|
||||
if (typeof command !== 'string' && command.type === 'modeling_cmd_req') {
|
||||
return this.handlePendingCommand(id, command?.cmd, range)
|
||||
} else if (typeof command === 'string') {
|
||||
const parseCommand: EngineCommand = JSON.parse(command)
|
||||
if (parseCommand.type === 'modeling_cmd_req')
|
||||
return this.handlePendingCommand(id, parseCommand?.cmd, range)
|
||||
}
|
||||
throw 'shouldnt reach here'
|
||||
}
|
||||
handlePendingCommand(
|
||||
id: string,
|
||||
command: Models['ModelingCmd_type'],
|
||||
range?: SourceRange
|
||||
) {
|
||||
let resolve: (val: any) => void = () => {}
|
||||
const promise = new Promise((_resolve, reject) => {
|
||||
resolve = _resolve
|
||||
})
|
||||
const getParentId = (): string | undefined => {
|
||||
if (command.type === 'extend_path') {
|
||||
return command.path
|
||||
}
|
||||
// TODO handle other commands that have a parent
|
||||
}
|
||||
this.artifactMap[id] = {
|
||||
range: range || [0, 0],
|
||||
type: 'pending',
|
||||
commandType: command.type,
|
||||
parentId: getParentId(),
|
||||
promise,
|
||||
resolve,
|
||||
}
|
||||
@ -575,10 +868,9 @@ export class EngineCommandManager {
|
||||
if (commandStr === undefined) {
|
||||
throw new Error('commandStr is undefined')
|
||||
}
|
||||
const command: EngineCommand = JSON.parse(commandStr)
|
||||
const range: SourceRange = JSON.parse(rangeStr)
|
||||
|
||||
return this.sendModelingCommand({ id, range, command })
|
||||
return this.sendModelingCommand({ id, range, command: commandStr })
|
||||
}
|
||||
commandResult(id: string): Promise<any> {
|
||||
const command = this.artifactMap[id]
|
||||
|
@ -97,12 +97,12 @@ describe('testing changeSketchArguments', () => {
|
||||
const lineAfterChange = 'lineTo([2, 3], %)'
|
||||
test('changeSketchArguments', async () => {
|
||||
// Enable rotations #152
|
||||
const genCode = (line: string) => `
|
||||
const mySketch001 = startSketchAt([0, 0])
|
||||
const genCode = (line: string) => `const mySketch001 = startSketchAt([0, 0])
|
||||
|> ${line}
|
||||
|> lineTo([0.46, -5.82], %)
|
||||
// |> rx(45, %)
|
||||
show(mySketch001)`
|
||||
show(mySketch001)
|
||||
`
|
||||
const code = genCode(lineToChange)
|
||||
const expectedCode = genCode(lineAfterChange)
|
||||
const ast = parser_wasm(code)
|
||||
@ -160,13 +160,13 @@ show(mySketch001)`
|
||||
],
|
||||
})
|
||||
// Enable rotations #152
|
||||
const expectedCode = `
|
||||
const mySketch001 = startSketchAt([0, 0])
|
||||
const expectedCode = `const mySketch001 = startSketchAt([0, 0])
|
||||
// |> rx(45, %)
|
||||
|> lineTo([-1.59, -1.54], %)
|
||||
|> lineTo([0.46, -5.82], %)
|
||||
|> lineTo([2, 3], %)
|
||||
show(mySketch001)`
|
||||
show(mySketch001)
|
||||
`
|
||||
expect(recast(modifiedAst)).toBe(expectedCode)
|
||||
})
|
||||
})
|
||||
@ -175,12 +175,12 @@ describe('testing addTagForSketchOnFace', () => {
|
||||
it('needs to be in it', async () => {
|
||||
const originalLine = 'lineTo([-1.59, -1.54], %)'
|
||||
// Enable rotations #152
|
||||
const genCode = (line: string) => `
|
||||
const mySketch001 = startSketchAt([0, 0])
|
||||
const genCode = (line: string) => `const mySketch001 = startSketchAt([0, 0])
|
||||
// |> rx(45, %)
|
||||
|> ${line}
|
||||
|> lineTo([0.46, -5.82], %)
|
||||
show(mySketch001)`
|
||||
show(mySketch001)
|
||||
`
|
||||
const code = genCode(originalLine)
|
||||
const ast = parser_wasm(code)
|
||||
const programMemory = await enginelessExecutor(ast)
|
||||
|
@ -20,7 +20,7 @@ import {
|
||||
getNodePathFromSourceRange,
|
||||
} from '../queryAst'
|
||||
import { GuiModes, toolTips, TooTip } from '../../useStore'
|
||||
import { splitPathAtPipeExpression } from '../modifyAst'
|
||||
import { createPipeExpression, splitPathAtPipeExpression } from '../modifyAst'
|
||||
import { generateUuidFromHashSeed } from '../../lib/uuid'
|
||||
|
||||
import { SketchLineHelper, ModifyAstBase, TransformCallback } from './stdTypes'
|
||||
@ -185,7 +185,7 @@ export const line: SketchLineHelper = {
|
||||
createCallback,
|
||||
}) => {
|
||||
const _node = { ...node }
|
||||
const { node: pipe } = getNodeFromPath<PipeExpression>(
|
||||
const { node: pipe } = getNodeFromPath<PipeExpression | CallExpression>(
|
||||
_node,
|
||||
pathToNode,
|
||||
'PipeExpression'
|
||||
@ -202,7 +202,7 @@ export const line: SketchLineHelper = {
|
||||
const newXVal = createLiteral(roundOff(to[0] - from[0], 2))
|
||||
const newYVal = createLiteral(roundOff(to[1] - from[1], 2))
|
||||
|
||||
if (replaceExisting && createCallback) {
|
||||
if (replaceExisting && createCallback && pipe.type !== 'CallExpression') {
|
||||
const { index: callIndex } = splitPathAtPipeExpression(pathToNode)
|
||||
const { callExp, valueUsedInTransform } = createCallback(
|
||||
[newXVal, newYVal],
|
||||
@ -220,7 +220,11 @@ export const line: SketchLineHelper = {
|
||||
createArrayExpression([newXVal, newYVal]),
|
||||
createPipeSubstitution(),
|
||||
])
|
||||
if (pipe.type === 'PipeExpression') {
|
||||
pipe.body = [...pipe.body, callExp]
|
||||
} else {
|
||||
varDec.init = createPipeExpression([varDec.init, callExp])
|
||||
}
|
||||
return {
|
||||
modifiedAst: _node,
|
||||
pathToNode,
|
||||
@ -238,22 +242,10 @@ export const line: SketchLineHelper = {
|
||||
createLiteral(roundOff(to[1] - from[1], 2)),
|
||||
])
|
||||
|
||||
if (
|
||||
callExpression.arguments?.[0].type === 'Literal' &&
|
||||
callExpression.arguments?.[0].value === 'default'
|
||||
) {
|
||||
callExpression.arguments[0] = toArrExp
|
||||
} else if (callExpression.arguments?.[0].type === 'ObjectExpression') {
|
||||
if (callExpression.arguments?.[0].type === 'ObjectExpression') {
|
||||
const toProp = callExpression.arguments?.[0].properties?.find(
|
||||
({ key }) => key.name === 'to'
|
||||
)
|
||||
if (
|
||||
toProp &&
|
||||
toProp.value.type === 'Literal' &&
|
||||
toProp.value.value === 'default'
|
||||
) {
|
||||
toProp.value = toArrExp
|
||||
}
|
||||
mutateObjExpProp(callExpression.arguments?.[0], toArrExp, 'to')
|
||||
} else {
|
||||
mutateArrExp(callExpression.arguments?.[0], toArrExp)
|
||||
@ -968,60 +960,14 @@ export function addNewSketchLn({
|
||||
pathToNode,
|
||||
'VariableDeclarator'
|
||||
)
|
||||
const { node: pipeExp, shallowPath: pipePath } =
|
||||
getNodeFromPath<PipeExpression>(node, pathToNode, 'PipeExpression')
|
||||
const maybeStartSketchAt = pipeExp.body.find(
|
||||
(exp) =>
|
||||
exp.type === 'CallExpression' &&
|
||||
exp.callee.name === 'startSketchAt' &&
|
||||
exp.arguments[0].type === 'Literal' &&
|
||||
exp.arguments[0].value === 'default'
|
||||
)
|
||||
const maybeDefaultLine = pipeExp.body.findIndex(
|
||||
(exp) =>
|
||||
exp.type === 'CallExpression' &&
|
||||
exp.callee.name === 'line' &&
|
||||
exp.arguments[0].type === 'Literal' &&
|
||||
exp.arguments[0].value === 'default'
|
||||
)
|
||||
const defaultLinePath: PathToNode = [
|
||||
...pipePath,
|
||||
['body', ''],
|
||||
[maybeDefaultLine, ''],
|
||||
]
|
||||
const { node: pipeExp, shallowPath: pipePath } = getNodeFromPath<
|
||||
PipeExpression | CallExpression
|
||||
>(node, pathToNode, 'PipeExpression')
|
||||
const variableName = varDec.id.name
|
||||
const sketch = previousProgramMemory?.root?.[variableName]
|
||||
if (sketch.type !== 'sketchGroup') throw new Error('not a sketchGroup')
|
||||
|
||||
if (maybeStartSketchAt) {
|
||||
const startSketchAt = maybeStartSketchAt as any
|
||||
startSketchAt.arguments[0] = createArrayExpression([
|
||||
createLiteral(to[0]),
|
||||
createLiteral(to[1]),
|
||||
])
|
||||
return {
|
||||
modifiedAst: node,
|
||||
}
|
||||
}
|
||||
if (maybeDefaultLine !== -1) {
|
||||
const defaultLine = getNodeFromPath<CallExpression>(
|
||||
node,
|
||||
defaultLinePath
|
||||
).node
|
||||
const { from } = getSketchSegmentFromSourceRange(sketch, [
|
||||
defaultLine.start,
|
||||
defaultLine.end,
|
||||
]).segment
|
||||
return updateArgs({
|
||||
node,
|
||||
previousProgramMemory,
|
||||
pathToNode: defaultLinePath,
|
||||
to,
|
||||
from,
|
||||
})
|
||||
}
|
||||
|
||||
const last = sketch.value[sketch.value.length - 1]
|
||||
const last = sketch.value[sketch.value.length - 1] || sketch.start
|
||||
const from = last.to
|
||||
|
||||
return add({
|
||||
@ -1198,14 +1144,6 @@ function getFirstArgValuesForXYFns(callExpression: CallExpression): {
|
||||
} {
|
||||
// used for lineTo, line
|
||||
const firstArg = callExpression.arguments[0]
|
||||
if (firstArg.type === 'Literal' && firstArg.value === 'default') {
|
||||
return {
|
||||
val:
|
||||
callExpression.callee.name === 'startSketchAt'
|
||||
? [createLiteral(0), createLiteral(0)]
|
||||
: [createLiteral(1), createLiteral(1)],
|
||||
}
|
||||
}
|
||||
if (firstArg.type === 'ArrayExpression') {
|
||||
return { val: [firstArg.elements[0], firstArg.elements[1]] }
|
||||
}
|
||||
@ -1215,8 +1153,6 @@ function getFirstArgValuesForXYFns(callExpression: CallExpression): {
|
||||
if (to?.type === 'ArrayExpression') {
|
||||
const [x, y] = to.elements
|
||||
return { val: [x, y], tag }
|
||||
} else if (to?.type === 'Literal' && to.value === 'default') {
|
||||
return { val: [createLiteral(0), createLiteral(0)], tag }
|
||||
}
|
||||
}
|
||||
throw new Error('expected ArrayExpression or ObjectExpression')
|
||||
|
@ -124,7 +124,8 @@ const part001 = startSketchAt([0, 0])
|
||||
|> yLine(1.04, %) // ln-yLine-free should sub in segLen
|
||||
|> xLineTo(30, %) // ln-xLineTo-free should convert to xLine
|
||||
|> yLineTo(20, %) // ln-yLineTo-free should convert to yLine
|
||||
show(part001)`
|
||||
show(part001)
|
||||
`
|
||||
const expectModifiedScript = `const myVar = 3
|
||||
const myVar2 = 5
|
||||
const myVar3 = 6
|
||||
@ -195,7 +196,8 @@ const part001 = startSketchAt([0, 0])
|
||||
|> yLine(segLen('seg01', %), %) // ln-yLine-free should sub in segLen
|
||||
|> xLine(segLen('seg01', %), %) // ln-xLineTo-free should convert to xLine
|
||||
|> yLine(segLen('seg01', %), %) // ln-yLineTo-free should convert to yLine
|
||||
show(part001)`
|
||||
show(part001)
|
||||
`
|
||||
it('should transform the ast', async () => {
|
||||
const ast = parser_wasm(inputScript)
|
||||
const selectionRanges: Selections['codeBasedSelections'] = inputScript
|
||||
@ -254,7 +256,8 @@ const part001 = startSketchAt([0, 0])
|
||||
|> angledLineToY([223, 7.68], %) // select for vertical constraint 9
|
||||
|> angledLineToX([333, myVar3], %) // select for horizontal constraint 10
|
||||
|> angledLineToY([301, myVar], %) // select for vertical constraint 10
|
||||
show(part001)`
|
||||
show(part001)
|
||||
`
|
||||
it('should transform horizontal lines the ast', async () => {
|
||||
const expectModifiedScript = `const myVar = 2
|
||||
const myVar2 = 12
|
||||
@ -281,7 +284,8 @@ const part001 = startSketchAt([0, 0])
|
||||
|> angledLineToY([223, 7.68], %) // select for vertical constraint 9
|
||||
|> xLineTo(myVar3, %) // select for horizontal constraint 10
|
||||
|> angledLineToY([301, myVar], %) // select for vertical constraint 10
|
||||
show(part001)`
|
||||
show(part001)
|
||||
`
|
||||
const ast = parser_wasm(inputScript)
|
||||
const selectionRanges: Selections['codeBasedSelections'] = inputScript
|
||||
.split('\n')
|
||||
@ -338,7 +342,8 @@ const part001 = startSketchAt([0, 0])
|
||||
|> yLineTo(7.68, %) // select for vertical constraint 9
|
||||
|> angledLineToX([333, myVar3], %) // select for horizontal constraint 10
|
||||
|> yLineTo(myVar, %) // select for vertical constraint 10
|
||||
show(part001)`
|
||||
show(part001)
|
||||
`
|
||||
const ast = parser_wasm(inputScript)
|
||||
const selectionRanges: Selections['codeBasedSelections'] = inputScript
|
||||
.split('\n')
|
||||
@ -380,7 +385,8 @@ const part001 = startSketchAt([0, 0])
|
||||
|> line([0.45, 1.46], %) // free
|
||||
|> line([myVar, 0.01], %) // xRelative
|
||||
|> line([0.7, myVar], %) // yRelative
|
||||
show(part001)`
|
||||
show(part001)
|
||||
`
|
||||
it('testing for free to horizontal and vertical distance', async () => {
|
||||
const expectedHorizontalCode = await helperThing(
|
||||
inputScript,
|
||||
|
@ -110,7 +110,7 @@ const yi=45`
|
||||
"brace ')' from 17 to 18",
|
||||
])
|
||||
expect(stringSummaryLexer('fn funcName = (param1, param2) => {}')).toEqual([
|
||||
"word 'fn' from 0 to 2",
|
||||
"keyword 'fn' from 0 to 2",
|
||||
"whitespace ' ' from 2 to 3",
|
||||
"word 'funcName' from 3 to 11",
|
||||
"whitespace ' ' from 11 to 12",
|
||||
@ -203,7 +203,7 @@ const yi=45`
|
||||
it('testing array declaration', () => {
|
||||
const result = stringSummaryLexer(`const yo = [1, 2]`)
|
||||
expect(result).toEqual([
|
||||
"word 'const' from 0 to 5",
|
||||
"keyword 'const' from 0 to 5",
|
||||
"whitespace ' ' from 5 to 6",
|
||||
"word 'yo' from 6 to 8",
|
||||
"whitespace ' ' from 8 to 9",
|
||||
@ -220,7 +220,7 @@ const yi=45`
|
||||
it('testing object declaration', () => {
|
||||
const result = stringSummaryLexer(`const yo = {key: 'value'}`)
|
||||
expect(result).toEqual([
|
||||
"word 'const' from 0 to 5",
|
||||
"keyword 'const' from 0 to 5",
|
||||
"whitespace ' ' from 5 to 6",
|
||||
"word 'yo' from 6 to 8",
|
||||
"whitespace ' ' from 8 to 9",
|
||||
@ -241,7 +241,7 @@ const prop2 = yo['key']
|
||||
const key = 'key'
|
||||
const prop3 = yo[key]`)
|
||||
expect(result).toEqual([
|
||||
"word 'const' from 0 to 5",
|
||||
"keyword 'const' from 0 to 5",
|
||||
"whitespace ' ' from 5 to 6",
|
||||
"word 'yo' from 6 to 8",
|
||||
"whitespace ' ' from 8 to 9",
|
||||
@ -254,7 +254,7 @@ const prop3 = yo[key]`)
|
||||
"string ''value'' from 17 to 24",
|
||||
"brace '}' from 24 to 25",
|
||||
"whitespace '\n' from 25 to 26",
|
||||
"word 'const' from 26 to 31",
|
||||
"keyword 'const' from 26 to 31",
|
||||
"whitespace ' ' from 31 to 32",
|
||||
"word 'prop' from 32 to 36",
|
||||
"whitespace ' ' from 36 to 37",
|
||||
@ -264,7 +264,7 @@ const prop3 = yo[key]`)
|
||||
"period '.' from 41 to 42",
|
||||
"word 'key' from 42 to 45",
|
||||
"whitespace '\n' from 45 to 46",
|
||||
"word 'const' from 46 to 51",
|
||||
"keyword 'const' from 46 to 51",
|
||||
"whitespace ' ' from 51 to 52",
|
||||
"word 'prop2' from 52 to 57",
|
||||
"whitespace ' ' from 57 to 58",
|
||||
@ -275,7 +275,7 @@ const prop3 = yo[key]`)
|
||||
"string ''key'' from 63 to 68",
|
||||
"brace ']' from 68 to 69",
|
||||
"whitespace '\n' from 69 to 70",
|
||||
"word 'const' from 70 to 75",
|
||||
"keyword 'const' from 70 to 75",
|
||||
"whitespace ' ' from 75 to 76",
|
||||
"word 'key' from 76 to 79",
|
||||
"whitespace ' ' from 79 to 80",
|
||||
@ -283,7 +283,7 @@ const prop3 = yo[key]`)
|
||||
"whitespace ' ' from 81 to 82",
|
||||
"string ''key'' from 82 to 87",
|
||||
"whitespace '\n' from 87 to 88",
|
||||
"word 'const' from 88 to 93",
|
||||
"keyword 'const' from 88 to 93",
|
||||
"whitespace ' ' from 93 to 94",
|
||||
"word 'prop3' from 94 to 99",
|
||||
"whitespace ' ' from 99 to 100",
|
||||
@ -299,7 +299,7 @@ const prop3 = yo[key]`)
|
||||
const result = stringSummaryLexer(`const yo = 45 // this is a comment
|
||||
const yo = 6`)
|
||||
expect(result).toEqual([
|
||||
"word 'const' from 0 to 5",
|
||||
"keyword 'const' from 0 to 5",
|
||||
"whitespace ' ' from 5 to 6",
|
||||
"word 'yo' from 6 to 8",
|
||||
"whitespace ' ' from 8 to 9",
|
||||
@ -307,9 +307,9 @@ const yo = 6`)
|
||||
"whitespace ' ' from 10 to 11",
|
||||
"number '45' from 11 to 13",
|
||||
"whitespace ' ' from 13 to 14",
|
||||
"linecomment '// this is a comment' from 14 to 34",
|
||||
"lineComment '// this is a comment' from 14 to 34",
|
||||
"whitespace '\n' from 34 to 35",
|
||||
"word 'const' from 35 to 40",
|
||||
"keyword 'const' from 35 to 40",
|
||||
"whitespace ' ' from 40 to 41",
|
||||
"word 'yo' from 41 to 43",
|
||||
"whitespace ' ' from 43 to 44",
|
||||
@ -328,9 +328,9 @@ const yo=45`)
|
||||
"string ''hi'' from 4 to 8",
|
||||
"brace ')' from 8 to 9",
|
||||
"whitespace '\n' from 9 to 10",
|
||||
"linecomment '// comment on a line by itself' from 10 to 40",
|
||||
"lineComment '// comment on a line by itself' from 10 to 40",
|
||||
"whitespace '\n' from 40 to 41",
|
||||
"word 'const' from 41 to 46",
|
||||
"keyword 'const' from 41 to 46",
|
||||
"whitespace ' ' from 46 to 47",
|
||||
"word 'yo' from 47 to 49",
|
||||
"operator '=' from 49 to 50",
|
||||
@ -342,7 +342,7 @@ const yo=45`)
|
||||
const ya = 6 */
|
||||
const yi=45`)
|
||||
expect(result).toEqual([
|
||||
"word 'const' from 0 to 5",
|
||||
"keyword 'const' from 0 to 5",
|
||||
"whitespace ' ' from 5 to 6",
|
||||
"word 'yo' from 6 to 8",
|
||||
"whitespace ' ' from 8 to 9",
|
||||
@ -350,10 +350,10 @@ const yi=45`)
|
||||
"whitespace ' ' from 10 to 11",
|
||||
"number '45' from 11 to 13",
|
||||
"whitespace ' ' from 13 to 14",
|
||||
`blockcomment '/* this is a comment
|
||||
`blockComment '/* this is a comment
|
||||
const ya = 6 */' from 14 to 50`,
|
||||
"whitespace '\n' from 50 to 51",
|
||||
"word 'const' from 51 to 56",
|
||||
"keyword 'const' from 51 to 56",
|
||||
"whitespace ' ' from 56 to 57",
|
||||
"word 'yi' from 57 to 59",
|
||||
"operator '=' from 59 to 60",
|
||||
|
156
src/lib/cameraControls.ts
Normal file
156
src/lib/cameraControls.ts
Normal file
@ -0,0 +1,156 @@
|
||||
const noModifiersPressed = (e: React.MouseEvent) =>
|
||||
!e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey
|
||||
|
||||
export type CameraSystem =
|
||||
| 'KittyCAD'
|
||||
| 'OnShape'
|
||||
| 'Trackpad Friendly'
|
||||
| 'Solidworks'
|
||||
| 'NX'
|
||||
| 'Creo'
|
||||
| 'AutoCAD'
|
||||
|
||||
export const cameraSystems: CameraSystem[] = [
|
||||
'KittyCAD',
|
||||
'OnShape',
|
||||
'Trackpad Friendly',
|
||||
'Solidworks',
|
||||
'NX',
|
||||
'Creo',
|
||||
'AutoCAD',
|
||||
]
|
||||
|
||||
interface MouseGuardHandler {
|
||||
description: string
|
||||
callback: (e: React.MouseEvent) => boolean
|
||||
lenientDragStartButton?: number
|
||||
}
|
||||
|
||||
interface MouseGuardZoomHandler {
|
||||
description: string
|
||||
dragCallback: (e: React.MouseEvent) => boolean
|
||||
scrollCallback: (e: React.MouseEvent) => boolean
|
||||
lenientDragStartButton?: number
|
||||
}
|
||||
|
||||
interface MouseGuard {
|
||||
pan: MouseGuardHandler
|
||||
zoom: MouseGuardZoomHandler
|
||||
rotate: MouseGuardHandler
|
||||
}
|
||||
|
||||
export const cameraMouseDragGuards: Record<CameraSystem, MouseGuard> = {
|
||||
KittyCAD: {
|
||||
pan: {
|
||||
description: 'Right click + Shift + drag or middle click + drag',
|
||||
callback: (e) =>
|
||||
(e.button === 1 && noModifiersPressed(e)) ||
|
||||
(e.button === 2 && e.shiftKey),
|
||||
},
|
||||
zoom: {
|
||||
description: 'Scroll wheel or Right click + Ctrl + drag',
|
||||
dragCallback: (e) => e.button === 2 && e.ctrlKey,
|
||||
scrollCallback: () => true,
|
||||
},
|
||||
rotate: {
|
||||
description: 'Right click + drag',
|
||||
callback: (e) => e.button === 2 && noModifiersPressed(e),
|
||||
},
|
||||
},
|
||||
OnShape: {
|
||||
pan: {
|
||||
description: 'Right click + Ctrl + drag or middle click + drag',
|
||||
callback: (e) =>
|
||||
(e.button === 2 && e.ctrlKey) ||
|
||||
(e.button === 1 && noModifiersPressed(e)),
|
||||
},
|
||||
zoom: {
|
||||
description: 'Scroll wheel',
|
||||
dragCallback: () => false,
|
||||
scrollCallback: () => true,
|
||||
},
|
||||
rotate: {
|
||||
description: 'Right click + drag',
|
||||
callback: (e) => e.button === 2 && noModifiersPressed(e),
|
||||
},
|
||||
},
|
||||
'Trackpad Friendly': {
|
||||
pan: {
|
||||
description: 'Left click + Alt + Shift + drag or middle click + drag',
|
||||
callback: (e) =>
|
||||
(e.button === 0 && e.altKey && e.shiftKey && !e.metaKey) ||
|
||||
(e.button === 1 && noModifiersPressed(e)),
|
||||
},
|
||||
zoom: {
|
||||
description: 'Scroll wheel or Left click + Alt + OS + drag',
|
||||
dragCallback: (e) => e.button === 0 && e.altKey && e.metaKey,
|
||||
scrollCallback: () => true,
|
||||
},
|
||||
rotate: {
|
||||
description: 'Left click + Alt + drag',
|
||||
callback: (e) => e.button === 0 && e.altKey && !e.shiftKey && !e.metaKey,
|
||||
lenientDragStartButton: 0,
|
||||
},
|
||||
},
|
||||
Solidworks: {
|
||||
pan: {
|
||||
description: 'Right click + Ctrl + drag',
|
||||
callback: (e) => e.button === 2 && e.ctrlKey,
|
||||
lenientDragStartButton: 2,
|
||||
},
|
||||
zoom: {
|
||||
description: 'Scroll wheel or Middle click + Shift + drag',
|
||||
dragCallback: (e) => e.button === 1 && e.shiftKey,
|
||||
scrollCallback: () => true,
|
||||
},
|
||||
rotate: {
|
||||
description: 'Middle click + drag',
|
||||
callback: (e) => e.button === 1 && noModifiersPressed(e),
|
||||
},
|
||||
},
|
||||
NX: {
|
||||
pan: {
|
||||
description: 'Middle click + Shift + drag',
|
||||
callback: (e) => e.button === 1 && e.shiftKey,
|
||||
},
|
||||
zoom: {
|
||||
description: 'Scroll wheel or Middle click + Ctrl + drag',
|
||||
dragCallback: (e) => e.button === 1 && e.ctrlKey,
|
||||
scrollCallback: () => true,
|
||||
},
|
||||
rotate: {
|
||||
description: 'Middle click + drag',
|
||||
callback: (e) => e.button === 1 && noModifiersPressed(e),
|
||||
},
|
||||
},
|
||||
Creo: {
|
||||
pan: {
|
||||
description: 'Middle click + Shift + drag',
|
||||
callback: (e) => e.button === 1 && e.shiftKey,
|
||||
},
|
||||
zoom: {
|
||||
description: 'Scroll wheel or Middle click + Ctrl + drag',
|
||||
dragCallback: (e) => e.button === 1 && e.ctrlKey,
|
||||
scrollCallback: () => true,
|
||||
},
|
||||
rotate: {
|
||||
description: 'Middle click + drag',
|
||||
callback: (e) => e.button === 1 && noModifiersPressed(e),
|
||||
},
|
||||
},
|
||||
AutoCAD: {
|
||||
pan: {
|
||||
description: 'Middle click + drag',
|
||||
callback: (e) => e.button === 1 && noModifiersPressed(e),
|
||||
},
|
||||
zoom: {
|
||||
description: 'Scroll wheel',
|
||||
dragCallback: () => false,
|
||||
scrollCallback: () => true,
|
||||
},
|
||||
rotate: {
|
||||
description: 'Middle click + Shift + drag',
|
||||
callback: (e) => e.button === 1 && e.shiftKey,
|
||||
},
|
||||
},
|
||||
}
|
@ -73,8 +73,6 @@ export function createMachineCommand<T extends AnyStateMachine>({
|
||||
arg.defaultValue as keyof typeof state.context
|
||||
] as string | undefined
|
||||
|
||||
console.log(arg.name, { defaultValueFromContext })
|
||||
|
||||
const options =
|
||||
arg.options instanceof Array
|
||||
? arg.options.map((o) => ({
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
readDir,
|
||||
writeTextFile,
|
||||
} from '@tauri-apps/api/fs'
|
||||
import { documentDir } from '@tauri-apps/api/path'
|
||||
import { documentDir, homeDir } from '@tauri-apps/api/path'
|
||||
import { isTauri } from './isTauri'
|
||||
import { ProjectWithEntryPointMetadata } from '../Router'
|
||||
import { metadata } from 'tauri-plugin-fs-extra-api'
|
||||
@ -32,7 +32,13 @@ export async function initializeProjectDirectory(directory: string) {
|
||||
return directory
|
||||
}
|
||||
|
||||
const docDirectory = await documentDir()
|
||||
let docDirectory: string
|
||||
try {
|
||||
docDirectory = await documentDir()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
docDirectory = await homeDir() // seems to work better on Linux
|
||||
}
|
||||
|
||||
const INITIAL_DEFAULT_DIR = docDirectory + PROJECT_FOLDER
|
||||
|
||||
|
@ -39,7 +39,6 @@ class MockEngineCommandManager {
|
||||
if (commandStr === undefined) {
|
||||
throw new Error('commandStr is undefined')
|
||||
}
|
||||
console.log('sendModelingCommandFromWasm', id, rangeStr, commandStr)
|
||||
const command: EngineCommand = JSON.parse(commandStr)
|
||||
const range: SourceRange = JSON.parse(rangeStr)
|
||||
|
||||
|
@ -4,17 +4,18 @@ export enum Themes {
|
||||
System = 'system',
|
||||
}
|
||||
|
||||
// Get the theme from the system settings manually
|
||||
export function getSystemTheme(): Exclude<Themes, 'system'> {
|
||||
return typeof window !== 'undefined' &&
|
||||
'matchMedia' in window &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
return typeof window !== 'undefined' && 'matchMedia' in window
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? Themes.Dark
|
||||
: Themes.Light
|
||||
: Themes.Light
|
||||
}
|
||||
|
||||
// Set the theme class on the body element
|
||||
export function setThemeClass(theme: Themes) {
|
||||
const systemTheme = theme === Themes.System && getSystemTheme()
|
||||
if (theme === Themes.Dark || systemTheme === Themes.Dark) {
|
||||
if (theme === Themes.Dark) {
|
||||
document.body.classList.add('dark')
|
||||
} else {
|
||||
document.body.classList.remove('dark')
|
||||
|
@ -56,6 +56,27 @@ export function throttle<T>(
|
||||
return throttled
|
||||
}
|
||||
|
||||
// takes a function and executes it after the wait time, if the function is called again before the wait time is up, the timer is reset
|
||||
export function defferExecution<T>(func: (args: T) => any, wait: number) {
|
||||
let timeout: ReturnType<typeof setTimeout> | null
|
||||
let latestArgs: T
|
||||
|
||||
function later() {
|
||||
timeout = null
|
||||
func(latestArgs)
|
||||
}
|
||||
|
||||
function deffered(args: T) {
|
||||
latestArgs = args
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
timeout = setTimeout(later, wait)
|
||||
}
|
||||
|
||||
return deffered
|
||||
}
|
||||
|
||||
export function getNormalisedCoordinates({
|
||||
clientX,
|
||||
clientY,
|
||||
|
@ -3,6 +3,23 @@ import { Models } from '@kittycad/lib'
|
||||
import withBaseURL from '../lib/withBaseURL'
|
||||
import { CommandBarMeta } from '../lib/commands'
|
||||
|
||||
const SKIP_AUTH =
|
||||
import.meta.env.VITE_KC_SKIP_AUTH === 'true' && import.meta.env.DEV
|
||||
const LOCAL_USER: Models['User_type'] = {
|
||||
id: '8675309',
|
||||
name: 'Test User',
|
||||
email: 'kittycad.sidebar.test@example.com',
|
||||
image: 'https://placekitten.com/200/200',
|
||||
created_at: 'yesteryear',
|
||||
updated_at: 'today',
|
||||
company: 'Test Company',
|
||||
discord: 'Test User#1234',
|
||||
github: 'testuser',
|
||||
phone: '555-555-5555',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
}
|
||||
|
||||
export interface UserContext {
|
||||
user?: Models['User_type']
|
||||
token?: string
|
||||
@ -81,7 +98,9 @@ export const authMachine = createMachine<UserContext, Events>(
|
||||
schema: { events: {} as { type: 'Log out' } | { type: 'Log in' } },
|
||||
predictableActionArguments: true,
|
||||
preserveActionOrder: true,
|
||||
context: { token: persistedToken },
|
||||
context: {
|
||||
token: persistedToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {},
|
||||
@ -98,16 +117,15 @@ async function getUser(context: UserContext) {
|
||||
}
|
||||
if (!context.token && '__TAURI__' in window) throw 'not log in'
|
||||
if (context.token) headers['Authorization'] = `Bearer ${context.token}`
|
||||
try {
|
||||
if (SKIP_AUTH) return LOCAL_USER
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
})
|
||||
|
||||
const user = await response.json()
|
||||
if ('error_code' in user) throw new Error(user.message)
|
||||
|
||||
return user
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
@ -1,24 +1,54 @@
|
||||
import { assign, createMachine } from 'xstate'
|
||||
import { BaseUnit, baseUnitsUnion } from '../useStore'
|
||||
import { CommandBarMeta } from '../lib/commands'
|
||||
import { Themes } from '../lib/theme'
|
||||
import { Themes, getSystemTheme, setThemeClass } from '../lib/theme'
|
||||
import { CameraSystem, cameraSystems } from 'lib/cameraControls'
|
||||
|
||||
export const DEFAULT_PROJECT_NAME = 'project-$nnn'
|
||||
|
||||
export enum UnitSystem {
|
||||
Imperial = 'imperial',
|
||||
Metric = 'metric',
|
||||
}
|
||||
|
||||
export const baseUnits = {
|
||||
imperial: ['in', 'ft'],
|
||||
metric: ['mm', 'cm', 'm'],
|
||||
} as const
|
||||
|
||||
export type BaseUnit = 'in' | 'ft' | 'mm' | 'cm' | 'm'
|
||||
|
||||
export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v)
|
||||
|
||||
export type Toggle = 'On' | 'Off'
|
||||
|
||||
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
|
||||
|
||||
export const settingsCommandBarMeta: CommandBarMeta = {
|
||||
'Set Theme': {
|
||||
displayValue: (args: string[]) => 'Change the app theme',
|
||||
'Set Base Unit': {
|
||||
displayValue: (args: string[]) => 'Set your default base unit',
|
||||
args: [
|
||||
{
|
||||
name: 'theme',
|
||||
name: 'baseUnit',
|
||||
type: 'select',
|
||||
defaultValue: 'theme',
|
||||
options: Object.values(Themes).map((v) => ({ name: v })) as {
|
||||
name: string
|
||||
}[],
|
||||
defaultValue: 'baseUnit',
|
||||
options: Object.values(baseUnitsUnion).map((v) => ({ name: v })),
|
||||
},
|
||||
],
|
||||
},
|
||||
'Set Camera Controls': {
|
||||
displayValue: (args: string[]) => 'Set your camera controls',
|
||||
args: [
|
||||
{
|
||||
name: 'cameraControls',
|
||||
type: 'select',
|
||||
defaultValue: 'cameraControls',
|
||||
options: Object.values(cameraSystems).map((v) => ({ name: v })),
|
||||
},
|
||||
],
|
||||
},
|
||||
'Set Default Directory': {
|
||||
hide: 'both',
|
||||
},
|
||||
'Set Default Project Name': {
|
||||
displayValue: (args: string[]) => 'Set a new default project name',
|
||||
hide: 'web',
|
||||
@ -32,9 +62,33 @@ export const settingsCommandBarMeta: CommandBarMeta = {
|
||||
},
|
||||
],
|
||||
},
|
||||
'Set Default Directory': {
|
||||
'Set Onboarding Status': {
|
||||
hide: 'both',
|
||||
},
|
||||
'Set Text Wrapping': {
|
||||
displayValue: (args: string[]) => 'Set whether text in the editor wraps',
|
||||
args: [
|
||||
{
|
||||
name: 'textWrapping',
|
||||
type: 'select',
|
||||
defaultValue: 'textWrapping',
|
||||
options: [{ name: 'On' }, { name: 'Off' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
'Set Theme': {
|
||||
displayValue: (args: string[]) => 'Change the app theme',
|
||||
args: [
|
||||
{
|
||||
name: 'theme',
|
||||
type: 'select',
|
||||
defaultValue: 'theme',
|
||||
options: Object.values(Themes).map((v): { name: string } => ({
|
||||
name: v,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
'Set Unit System': {
|
||||
displayValue: (args: string[]) => 'Set your default unit system',
|
||||
args: [
|
||||
@ -42,24 +96,10 @@ export const settingsCommandBarMeta: CommandBarMeta = {
|
||||
name: 'unitSystem',
|
||||
type: 'select',
|
||||
defaultValue: 'unitSystem',
|
||||
options: [{ name: 'imperial' }, { name: 'metric' }],
|
||||
options: [{ name: UnitSystem.Imperial }, { name: UnitSystem.Metric }],
|
||||
},
|
||||
],
|
||||
},
|
||||
'Set Base Unit': {
|
||||
displayValue: (args: string[]) => 'Set your default base unit',
|
||||
args: [
|
||||
{
|
||||
name: 'baseUnit',
|
||||
type: 'select',
|
||||
defaultValue: 'baseUnit',
|
||||
options: Object.values(baseUnitsUnion).map((v) => ({ name: v })),
|
||||
},
|
||||
],
|
||||
},
|
||||
'Set Onboarding Status': {
|
||||
hide: 'both',
|
||||
},
|
||||
}
|
||||
|
||||
export const settingsMachine = createMachine(
|
||||
@ -68,33 +108,34 @@ export const settingsMachine = createMachine(
|
||||
id: 'Settings',
|
||||
predictableActionArguments: true,
|
||||
context: {
|
||||
theme: Themes.System,
|
||||
defaultProjectName: '',
|
||||
unitSystem: 'imperial' as 'imperial' | 'metric',
|
||||
baseUnit: 'in' as BaseUnit,
|
||||
cameraControls: 'KittyCAD' as CameraSystem,
|
||||
defaultDirectory: '',
|
||||
showDebugPanel: false,
|
||||
defaultProjectName: DEFAULT_PROJECT_NAME,
|
||||
onboardingStatus: '',
|
||||
showDebugPanel: false,
|
||||
textWrapping: 'On' as Toggle,
|
||||
theme: Themes.System,
|
||||
unitSystem: UnitSystem.Imperial,
|
||||
},
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: {
|
||||
entry: ['setThemeClass'],
|
||||
on: {
|
||||
'Set Theme': {
|
||||
'Set Base Unit': {
|
||||
actions: [
|
||||
assign({
|
||||
theme: (_, event) => event.data.theme,
|
||||
}),
|
||||
assign({ baseUnit: (_, event) => event.data.baseUnit }),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Default Project Name': {
|
||||
'Set Camera Controls': {
|
||||
actions: [
|
||||
assign({
|
||||
defaultProjectName: (_, event) => event.data.defaultProjectName,
|
||||
cameraControls: (_, event) => event.data.cameraControls,
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
@ -113,12 +154,11 @@ export const settingsMachine = createMachine(
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Unit System': {
|
||||
'Set Default Project Name': {
|
||||
actions: [
|
||||
assign({
|
||||
unitSystem: (_, event) => event.data.unitSystem,
|
||||
baseUnit: (_, event) =>
|
||||
event.data.unitSystem === 'imperial' ? 'in' : 'mm',
|
||||
defaultProjectName: (_, event) =>
|
||||
event.data.defaultProjectName.trim() || DEFAULT_PROJECT_NAME,
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
@ -126,9 +166,46 @@ export const settingsMachine = createMachine(
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Base Unit': {
|
||||
'Set Onboarding Status': {
|
||||
actions: [
|
||||
assign({ baseUnit: (_, event) => event.data.baseUnit }),
|
||||
assign({
|
||||
onboardingStatus: (_, event) => event.data.onboardingStatus,
|
||||
}),
|
||||
'persistSettings',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Text Wrapping': {
|
||||
actions: [
|
||||
assign({
|
||||
textWrapping: (_, event) => event.data.textWrapping,
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Theme': {
|
||||
actions: [
|
||||
assign({
|
||||
theme: (_, event) => event.data.theme,
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
'setThemeClass',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Unit System': {
|
||||
actions: [
|
||||
assign({
|
||||
unitSystem: (_, event) => event.data.unitSystem,
|
||||
baseUnit: (_, event) =>
|
||||
event.data.unitSystem === 'imperial' ? 'in' : 'mm',
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
],
|
||||
@ -148,34 +225,29 @@ export const settingsMachine = createMachine(
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Onboarding Status': {
|
||||
actions: [
|
||||
assign({
|
||||
onboardingStatus: (_, event) => event.data.onboardingStatus,
|
||||
}),
|
||||
'persistSettings',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tsTypes: {} as import('./settingsMachine.typegen').Typegen0,
|
||||
schema: {
|
||||
events: {} as
|
||||
| { type: 'Set Theme'; data: { theme: Themes } }
|
||||
| { type: 'Set Base Unit'; data: { baseUnit: BaseUnit } }
|
||||
| {
|
||||
type: 'Set Camera Controls'
|
||||
data: { cameraControls: CameraSystem }
|
||||
}
|
||||
| { type: 'Set Default Directory'; data: { defaultDirectory: string } }
|
||||
| {
|
||||
type: 'Set Default Project Name'
|
||||
data: { defaultProjectName: string }
|
||||
}
|
||||
| { type: 'Set Default Directory'; data: { defaultDirectory: string } }
|
||||
| { type: 'Set Onboarding Status'; data: { onboardingStatus: string } }
|
||||
| { type: 'Set Text Wrapping'; data: { textWrapping: Toggle } }
|
||||
| { type: 'Set Theme'; data: { theme: Themes } }
|
||||
| {
|
||||
type: 'Set Unit System'
|
||||
data: { unitSystem: 'imperial' | 'metric' }
|
||||
data: { unitSystem: UnitSystem }
|
||||
}
|
||||
| { type: 'Set Base Unit'; data: { baseUnit: BaseUnit } }
|
||||
| { type: 'Set Onboarding Status'; data: { onboardingStatus: string } }
|
||||
| { type: 'Toggle Debug Panel' },
|
||||
},
|
||||
},
|
||||
@ -188,6 +260,13 @@ export const settingsMachine = createMachine(
|
||||
console.error(e)
|
||||
}
|
||||
},
|
||||
setThemeClass: (context, event) => {
|
||||
const currentTheme =
|
||||
event.type === 'Set Theme' ? event.data.theme : context.theme
|
||||
setThemeClass(
|
||||
currentTheme === Themes.System ? getSystemTheme() : currentTheme
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
@ -15,16 +15,31 @@ export interface Typegen0 {
|
||||
eventsCausingActions: {
|
||||
persistSettings:
|
||||
| 'Set Base Unit'
|
||||
| 'Set Camera Controls'
|
||||
| 'Set Default Directory'
|
||||
| 'Set Default Project Name'
|
||||
| 'Set Onboarding Status'
|
||||
| 'Set Text Wrapping'
|
||||
| 'Set Theme'
|
||||
| 'Set Unit System'
|
||||
| 'Toggle Debug Panel'
|
||||
toastSuccess:
|
||||
setThemeClass:
|
||||
| 'Set Base Unit'
|
||||
| 'Set Camera Controls'
|
||||
| 'Set Default Directory'
|
||||
| 'Set Default Project Name'
|
||||
| 'Set Onboarding Status'
|
||||
| 'Set Text Wrapping'
|
||||
| 'Set Theme'
|
||||
| 'Set Unit System'
|
||||
| 'Toggle Debug Panel'
|
||||
| 'xstate.init'
|
||||
toastSuccess:
|
||||
| 'Set Base Unit'
|
||||
| 'Set Camera Controls'
|
||||
| 'Set Default Directory'
|
||||
| 'Set Default Project Name'
|
||||
| 'Set Text Wrapping'
|
||||
| 'Set Theme'
|
||||
| 'Set Unit System'
|
||||
| 'Toggle Debug Panel'
|
||||
|
@ -28,6 +28,7 @@ import {
|
||||
import useStateMachineCommands from '../hooks/useStateMachineCommands'
|
||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { DEFAULT_PROJECT_NAME } from 'machines/settingsMachine'
|
||||
|
||||
// This route only opens in the Tauri desktop context for now,
|
||||
// as defined in Router.tsx, so we can use the Tauri APIs and types.
|
||||
@ -38,6 +39,7 @@ const Home = () => {
|
||||
const {
|
||||
settings: {
|
||||
context: { defaultDirectory, defaultProjectName },
|
||||
send: sendToSettings,
|
||||
},
|
||||
} = useGlobalStateContext()
|
||||
|
||||
@ -71,16 +73,33 @@ const Home = () => {
|
||||
context: ContextFrom<typeof homeMachine>,
|
||||
event: EventFrom<typeof homeMachine, 'Create project'>
|
||||
) => {
|
||||
let name =
|
||||
let name = (
|
||||
event.data && 'name' in event.data
|
||||
? event.data.name
|
||||
: defaultProjectName
|
||||
).trim()
|
||||
let shouldUpdateDefaultProjectName = false
|
||||
|
||||
// If there is no default project name, flag it to be set to the default
|
||||
if (!name) {
|
||||
name = DEFAULT_PROJECT_NAME
|
||||
shouldUpdateDefaultProjectName = true
|
||||
}
|
||||
|
||||
if (doesProjectNameNeedInterpolated(name)) {
|
||||
const nextIndex = await getNextProjectIndex(name, projects)
|
||||
name = interpolateProjectNameWithIndex(name, nextIndex)
|
||||
}
|
||||
|
||||
await createNewProject(context.defaultDirectory + '/' + name)
|
||||
|
||||
if (shouldUpdateDefaultProjectName) {
|
||||
sendToSettings({
|
||||
type: 'Set Default Project Name',
|
||||
data: { defaultProjectName: DEFAULT_PROJECT_NAME },
|
||||
})
|
||||
}
|
||||
|
||||
return `Successfully created "${name}"`
|
||||
},
|
||||
renameProject: async (
|
||||
|
@ -4,8 +4,8 @@ import { onboardingPaths, useDismiss, useNextClick } from '.'
|
||||
import { useStore } from '../../useStore'
|
||||
|
||||
export default function Units() {
|
||||
const { isMouseDownInStream } = useStore((s) => ({
|
||||
isMouseDownInStream: s.isMouseDownInStream,
|
||||
const { buttonDownInStream } = useStore((s) => ({
|
||||
buttonDownInStream: s.buttonDownInStream,
|
||||
}))
|
||||
const dismiss = useDismiss()
|
||||
const next = useNextClick(onboardingPaths.SKETCHING)
|
||||
@ -15,14 +15,30 @@ export default function Units() {
|
||||
<div
|
||||
className={
|
||||
'max-w-2xl flex flex-col justify-center bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded' +
|
||||
(isMouseDownInStream ? '' : ' pointer-events-auto')
|
||||
(buttonDownInStream ? '' : ' pointer-events-auto')
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">Camera</h1>
|
||||
<p className="mt-6">
|
||||
Moving the camera is easy. Just click and drag anywhere in the scene
|
||||
to rotate the camera, or hold down the <kbd>Ctrl</kbd> key and drag to
|
||||
pan the camera.
|
||||
Moving the camera is easy! The controls are as you might expect:
|
||||
</p>
|
||||
<ul className="list-disc list-outside ms-8 mb-4">
|
||||
<li>Click and drag anywhere in the scene to rotate the camera</li>
|
||||
<li>
|
||||
Hold down the <kbd>Shift</kbd> key while clicking and dragging to
|
||||
pan the camera
|
||||
</li>
|
||||
<li>
|
||||
Hold down the <kbd>Ctrl</kbd> key while dragging to zoom. You can
|
||||
also use the scroll wheel to zoom in and out.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
What you're seeing here is just a video, and your interactions are
|
||||
being sent to our Geometry Engine API, which sends back video frames
|
||||
in real time. How cool is that? It means that you can use KittyCAD
|
||||
Modeling App (or whatever you want to build) on any device, even a
|
||||
cheap laptop with no graphics card!
|
||||
</p>
|
||||
<div className="flex justify-between mt-6">
|
||||
<ActionButton
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { BaseUnit, baseUnits } from '../../useStore'
|
||||
import { BaseUnit, baseUnits } from '../../machines/settingsMachine'
|
||||
import { ActionButton } from '../../components/ActionButton'
|
||||
import { SettingsSection } from '../Settings'
|
||||
import { Toggle } from '../../components/Toggle/Toggle'
|
||||
import { useState } from 'react'
|
||||
import { onboardingPaths, useDismiss, useNextClick } from '.'
|
||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||
import { UnitSystem } from 'machines/settingsMachine'
|
||||
|
||||
export default function Units() {
|
||||
const dismiss = useDismiss()
|
||||
@ -16,15 +16,6 @@ export default function Units() {
|
||||
context: { unitSystem, baseUnit },
|
||||
},
|
||||
} = useGlobalStateContext()
|
||||
const [tempUnitSystem, setTempUnitSystem] = useState(unitSystem)
|
||||
const [tempBaseUnit, setTempBaseUnit] = useState(baseUnit)
|
||||
|
||||
function handleNextClick() {
|
||||
send({ type: 'Set Unit System', data: { unitSystem: tempUnitSystem } })
|
||||
send({ type: 'Set Base Unit', data: { baseUnit: tempBaseUnit } })
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50">
|
||||
@ -38,10 +29,16 @@ export default function Units() {
|
||||
offLabel="Imperial"
|
||||
onLabel="Metric"
|
||||
name="settings-units"
|
||||
checked={tempUnitSystem === 'metric'}
|
||||
onChange={(e) =>
|
||||
setTempUnitSystem(e.target.checked ? 'metric' : 'imperial')
|
||||
}
|
||||
checked={unitSystem === UnitSystem.Metric}
|
||||
onChange={(e) => {
|
||||
const newUnitSystem = e.target.checked
|
||||
? UnitSystem.Metric
|
||||
: UnitSystem.Imperial
|
||||
send({
|
||||
type: 'Set Unit System',
|
||||
data: { unitSystem: newUnitSystem },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
@ -51,8 +48,13 @@ export default function Units() {
|
||||
<select
|
||||
id="base-unit"
|
||||
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
|
||||
value={tempBaseUnit}
|
||||
onChange={(e) => setTempBaseUnit(e.target.value as BaseUnit)}
|
||||
value={baseUnit}
|
||||
onChange={(e) => {
|
||||
send({
|
||||
type: 'Set Base Unit',
|
||||
data: { baseUnit: e.target.value as BaseUnit },
|
||||
})
|
||||
}}
|
||||
>
|
||||
{baseUnits[unitSystem].map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
@ -77,7 +79,7 @@ export default function Units() {
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={handleNextClick}
|
||||
onClick={next}
|
||||
icon={{ icon: faArrowRight }}
|
||||
>
|
||||
Next: Camera
|
||||
|
@ -6,29 +6,41 @@ import {
|
||||
import { ActionButton } from '../components/ActionButton'
|
||||
import { AppHeader } from '../components/AppHeader'
|
||||
import { open } from '@tauri-apps/api/dialog'
|
||||
import { BaseUnit, baseUnits } from '../useStore'
|
||||
import {
|
||||
BaseUnit,
|
||||
DEFAULT_PROJECT_NAME,
|
||||
baseUnits,
|
||||
} from '../machines/settingsMachine'
|
||||
import { Toggle } from '../components/Toggle/Toggle'
|
||||
import { useNavigate, useRouteLoaderData } from 'react-router-dom'
|
||||
import { useLocation, useNavigate, useRouteLoaderData } from 'react-router-dom'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { IndexLoaderData, paths } from '../Router'
|
||||
import { Themes } from '../lib/theme'
|
||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||
import {
|
||||
CameraSystem,
|
||||
cameraSystems,
|
||||
cameraMouseDragGuards,
|
||||
} from 'lib/cameraControls'
|
||||
import { UnitSystem } from 'machines/settingsMachine'
|
||||
|
||||
export const Settings = () => {
|
||||
const loaderData = useRouteLoaderData(paths.FILE) as IndexLoaderData
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
useHotkeys('esc', () => navigate('../'))
|
||||
const {
|
||||
settings: {
|
||||
send,
|
||||
state: {
|
||||
context: {
|
||||
baseUnit,
|
||||
cameraControls,
|
||||
defaultDirectory,
|
||||
defaultProjectName,
|
||||
showDebugPanel,
|
||||
defaultDirectory,
|
||||
unitSystem,
|
||||
baseUnit,
|
||||
theme,
|
||||
unitSystem,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -80,6 +92,42 @@ export const Settings = () => {
|
||||
, and start a discussion if you don't see it! Your feedback will help
|
||||
us prioritize what to build next.
|
||||
</p>
|
||||
<SettingsSection
|
||||
title="Camera Controls"
|
||||
description="How you want to control the camera in the 3D view"
|
||||
>
|
||||
<select
|
||||
id="camera-controls"
|
||||
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
|
||||
value={cameraControls}
|
||||
onChange={(e) => {
|
||||
send({
|
||||
type: 'Set Camera Controls',
|
||||
data: { cameraControls: e.target.value as CameraSystem },
|
||||
})
|
||||
}}
|
||||
>
|
||||
{cameraSystems.map((program) => (
|
||||
<option key={program} value={program}>
|
||||
{program}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ul className="text-sm my-2 mx-4 leading-relaxed">
|
||||
<li>
|
||||
<strong>Pan:</strong>{' '}
|
||||
{cameraMouseDragGuards[cameraControls].pan.description}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Zoom:</strong>{' '}
|
||||
{cameraMouseDragGuards[cameraControls].zoom.description}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Rotate:</strong>{' '}
|
||||
{cameraMouseDragGuards[cameraControls].rotate.description}
|
||||
</li>
|
||||
</ul>
|
||||
</SettingsSection>
|
||||
{(window as any).__TAURI__ && (
|
||||
<>
|
||||
<SettingsSection
|
||||
@ -116,10 +164,14 @@ export const Settings = () => {
|
||||
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
|
||||
defaultValue={defaultProjectName}
|
||||
onBlur={(e) => {
|
||||
const newValue = e.target.value.trim() || DEFAULT_PROJECT_NAME
|
||||
send({
|
||||
type: 'Set Default Project Name',
|
||||
data: { defaultProjectName: e.target.value },
|
||||
data: {
|
||||
defaultProjectName: newValue,
|
||||
},
|
||||
})
|
||||
e.target.value = newValue
|
||||
}}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
@ -135,9 +187,11 @@ export const Settings = () => {
|
||||
offLabel="Imperial"
|
||||
onLabel="Metric"
|
||||
name="settings-units"
|
||||
checked={unitSystem === 'metric'}
|
||||
checked={unitSystem === UnitSystem.Metric}
|
||||
onChange={(e) => {
|
||||
const newUnitSystem = e.target.checked ? 'metric' : 'imperial'
|
||||
const newUnitSystem = e.target.checked
|
||||
? UnitSystem.Metric
|
||||
: UnitSystem.Imperial
|
||||
send({
|
||||
type: 'Set Unit System',
|
||||
data: { unitSystem: newUnitSystem },
|
||||
@ -201,6 +255,7 @@ export const Settings = () => {
|
||||
))}
|
||||
</select>
|
||||
</SettingsSection>
|
||||
{location.pathname.includes(paths.FILE) && (
|
||||
<SettingsSection
|
||||
title="Onboarding"
|
||||
description="Replay the onboarding process"
|
||||
@ -219,6 +274,7 @@ export const Settings = () => {
|
||||
Replay Onboarding
|
||||
</ActionButton>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
EngineCommandManager,
|
||||
} from './lang/std/engineConnection'
|
||||
import { KCLError } from './lang/errors'
|
||||
import { defferExecution } from 'lib/utils'
|
||||
|
||||
export type Selection = {
|
||||
type: 'default' | 'line-end' | 'line-mid'
|
||||
@ -42,9 +43,12 @@ export type TooTip =
|
||||
| 'yLineTo'
|
||||
| 'angledLineThatIntersects'
|
||||
|
||||
export const toolTips: TooTip[] = [
|
||||
'lineTo',
|
||||
export const toolTips = [
|
||||
'sketch_line',
|
||||
'move',
|
||||
// original tooltips
|
||||
'line',
|
||||
'lineTo',
|
||||
'angledLine',
|
||||
'angledLineOfXLength',
|
||||
'angledLineOfYLength',
|
||||
@ -55,7 +59,7 @@ export const toolTips: TooTip[] = [
|
||||
'xLineTo',
|
||||
'yLineTo',
|
||||
'angledLineThatIntersects',
|
||||
]
|
||||
] as any as TooTip[]
|
||||
|
||||
export type GuiModes =
|
||||
| {
|
||||
@ -65,6 +69,7 @@ export type GuiModes =
|
||||
mode: 'sketch'
|
||||
sketchMode: TooTip
|
||||
isTooltip: true
|
||||
waitingFirstClick: boolean
|
||||
rotation: Rotation
|
||||
position: Position
|
||||
id?: string
|
||||
@ -83,6 +88,7 @@ export type GuiModes =
|
||||
}
|
||||
| {
|
||||
mode: 'canEditSketch'
|
||||
pathId: string
|
||||
pathToNode: PathToNode
|
||||
rotation: Rotation
|
||||
position: Position
|
||||
@ -94,16 +100,13 @@ export type GuiModes =
|
||||
position: Position
|
||||
}
|
||||
|
||||
export const baseUnits = {
|
||||
imperial: ['in', 'ft'],
|
||||
metric: ['mm', 'cm', 'm'],
|
||||
} as const
|
||||
|
||||
export type BaseUnit = 'in' | 'ft' | 'mm' | 'cm' | 'm'
|
||||
|
||||
export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v)
|
||||
|
||||
export type PaneType = 'code' | 'variables' | 'debug' | 'kclErrors' | 'logs'
|
||||
export type PaneType =
|
||||
| 'code'
|
||||
| 'variables'
|
||||
| 'debug'
|
||||
| 'kclErrors'
|
||||
| 'logs'
|
||||
| 'lspMessages'
|
||||
|
||||
export interface StoreState {
|
||||
editorView: EditorView | null
|
||||
@ -124,8 +127,8 @@ export interface StoreState {
|
||||
kclErrors: KCLError[]
|
||||
addKCLError: (err: KCLError) => void
|
||||
resetKCLErrors: () => void
|
||||
ast: Program | null
|
||||
setAst: (ast: Program | null) => void
|
||||
ast: Program
|
||||
setAst: (ast: Program) => void
|
||||
updateAst: (
|
||||
ast: Program,
|
||||
optionalParams?: {
|
||||
@ -135,7 +138,9 @@ export interface StoreState {
|
||||
) => void
|
||||
updateAstAsync: (ast: Program, focusPath?: PathToNode) => void
|
||||
code: string
|
||||
defferedCode: string
|
||||
setCode: (code: string) => void
|
||||
defferedSetCode: (code: string) => void
|
||||
formatCode: () => void
|
||||
errorState: {
|
||||
isError: boolean
|
||||
@ -158,12 +163,12 @@ export interface StoreState {
|
||||
setMediaStream: (mediaStream: MediaStream) => void
|
||||
isStreamReady: boolean
|
||||
setIsStreamReady: (isStreamReady: boolean) => void
|
||||
isMouseDownInStream: boolean
|
||||
setIsMouseDownInStream: (isMouseDownInStream: boolean) => void
|
||||
isLSPServerReady: boolean
|
||||
setIsLSPServerReady: (isLSPServerReady: boolean) => void
|
||||
buttonDownInStream: number | undefined
|
||||
setButtonDownInStream: (buttonDownInStream: number | undefined) => void
|
||||
didDragInStream: boolean
|
||||
setDidDragInStream: (didDragInStream: boolean) => void
|
||||
cmdId?: string
|
||||
setCmdId: (cmdId: string) => void
|
||||
fileId: string
|
||||
setFileId: (fileId: string) => void
|
||||
streamDimensions: { streamWidth: number; streamHeight: number }
|
||||
@ -171,6 +176,8 @@ export interface StoreState {
|
||||
streamWidth: number
|
||||
streamHeight: number
|
||||
}) => void
|
||||
isExecuting: boolean
|
||||
setIsExecuting: (isExecuting: boolean) => void
|
||||
|
||||
showHomeMenu: boolean
|
||||
setHomeShowMenu: (showMenu: boolean) => void
|
||||
@ -189,7 +196,12 @@ let pendingAstUpdates: number[] = []
|
||||
|
||||
export const useStore = create<StoreState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
(set, get) => {
|
||||
const setDefferedCode = defferExecution(
|
||||
(code: string) => set({ defferedCode: code }),
|
||||
600
|
||||
)
|
||||
return {
|
||||
editorView: null,
|
||||
setEditorView: (editorView) => {
|
||||
set({ editorView })
|
||||
@ -215,6 +227,7 @@ export const useStore = create<StoreState>()(
|
||||
}
|
||||
})
|
||||
setTimeout(() => {
|
||||
ranges.length &&
|
||||
editorView.dispatch({
|
||||
selection: EditorSelection.create(
|
||||
ranges,
|
||||
@ -274,7 +287,15 @@ export const useStore = create<StoreState>()(
|
||||
resetKCLErrors: () => {
|
||||
set({ kclErrors: [] })
|
||||
},
|
||||
ast: null,
|
||||
ast: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
body: [],
|
||||
nonCodeMeta: {
|
||||
noneCodeNodes: {},
|
||||
start: null,
|
||||
},
|
||||
},
|
||||
setAst: (ast) => {
|
||||
set({ ast })
|
||||
},
|
||||
@ -283,9 +304,16 @@ export const useStore = create<StoreState>()(
|
||||
const astWithUpdatedSource = parser_wasm(newCode)
|
||||
callBack(astWithUpdatedSource)
|
||||
|
||||
set({ ast: astWithUpdatedSource, code: newCode })
|
||||
set({
|
||||
ast: astWithUpdatedSource,
|
||||
code: newCode,
|
||||
defferedCode: newCode,
|
||||
})
|
||||
if (focusPath) {
|
||||
const { node } = getNodeFromPath<any>(astWithUpdatedSource, focusPath)
|
||||
const { node } = getNodeFromPath<any>(
|
||||
astWithUpdatedSource,
|
||||
focusPath
|
||||
)
|
||||
const { start, end } = node
|
||||
if (!start || !end) return
|
||||
setTimeout(() => {
|
||||
@ -313,8 +341,11 @@ export const useStore = create<StoreState>()(
|
||||
)
|
||||
},
|
||||
code: '',
|
||||
setCode: (code) => {
|
||||
defferedCode: '',
|
||||
setCode: (code) => set({ code, defferedCode: code }),
|
||||
defferedSetCode: (code) => {
|
||||
set({ code })
|
||||
setDefferedCode(code)
|
||||
},
|
||||
formatCode: async () => {
|
||||
const code = get().code
|
||||
@ -341,21 +372,23 @@ export const useStore = create<StoreState>()(
|
||||
setMediaStream: (mediaStream) => set({ mediaStream }),
|
||||
isStreamReady: false,
|
||||
setIsStreamReady: (isStreamReady) => set({ isStreamReady }),
|
||||
isMouseDownInStream: false,
|
||||
setIsMouseDownInStream: (isMouseDownInStream) => {
|
||||
set({ isMouseDownInStream })
|
||||
isLSPServerReady: false,
|
||||
setIsLSPServerReady: (isLSPServerReady) => set({ isLSPServerReady }),
|
||||
buttonDownInStream: undefined,
|
||||
setButtonDownInStream: (buttonDownInStream) => {
|
||||
set({ buttonDownInStream })
|
||||
},
|
||||
didDragInStream: false,
|
||||
setDidDragInStream: (didDragInStream) => {
|
||||
set({ didDragInStream })
|
||||
},
|
||||
// For stream event handling
|
||||
cmdId: undefined,
|
||||
setCmdId: (cmdId) => set({ cmdId }),
|
||||
fileId: '',
|
||||
setFileId: (fileId) => set({ fileId }),
|
||||
streamDimensions: { streamWidth: 1280, streamHeight: 720 },
|
||||
setStreamDimensions: (streamDimensions) => set({ streamDimensions }),
|
||||
isExecuting: false,
|
||||
setIsExecuting: (isExecuting) => set({ isExecuting }),
|
||||
|
||||
// tauri specific app settings
|
||||
defaultDir: {
|
||||
@ -369,13 +402,14 @@ export const useStore = create<StoreState>()(
|
||||
setHomeShowMenu: (showHomeMenu) => set({ showHomeMenu }),
|
||||
homeMenuItems: [],
|
||||
setHomeMenuItems: (homeMenuItems) => set({ homeMenuItems }),
|
||||
}),
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'store',
|
||||
partialize: (state) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(state).filter(([key]) =>
|
||||
['code', 'openPanes'].includes(key)
|
||||
['code', 'defferedCode', 'openPanes'].includes(key)
|
||||
)
|
||||
),
|
||||
}
|
||||
|
988
src/wasm-lib/Cargo.lock
generated
988
src/wasm-lib/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -8,14 +8,39 @@ edition = "2021"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
bson = { version = "2.6.1", features = ["uuid-1", "chrono"] }
|
||||
bson = { version = "2.7.0", features = ["uuid-1", "chrono"] }
|
||||
gloo-utils = "0.2.0"
|
||||
kcl-lib = { path = "kcl" }
|
||||
kittycad = { version = "0.2.15", default-features = false, features = ["js"] }
|
||||
kittycad = { version = "0.2.25", default-features = false, features = ["js"] }
|
||||
serde_json = "1.0.93"
|
||||
wasm-bindgen = "0.2.87"
|
||||
wasm-bindgen-futures = "0.4.37"
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = "1"
|
||||
image = "0.24.7"
|
||||
kittycad = "0.2.25"
|
||||
reqwest = { version = "0.11.20", default-features = false }
|
||||
tokio = { version = "1.32.0", features = ["rt-multi-thread", "macros", "time"] }
|
||||
twenty-twenty = "0.6.1"
|
||||
uuid = { version = "1.4.1", features = ["v4", "js", "serde"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
futures = "0.3.28"
|
||||
js-sys = "0.3.64"
|
||||
tower-lsp = { version = "0.20.0", default-features = false, features = ["runtime-agnostic"] }
|
||||
wasm-bindgen-futures = { version = "0.4.37", features = ["futures-core-03-stream"] }
|
||||
wasm-streams = "0.3.0"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies.web-sys]
|
||||
version = "0.3.57"
|
||||
features = [
|
||||
"console",
|
||||
"HtmlTextAreaElement",
|
||||
"ReadableStream",
|
||||
"WritableStream",
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
debug = true
|
||||
@ -23,5 +48,5 @@ debug = true
|
||||
[workspace]
|
||||
members = [
|
||||
"derive-docs",
|
||||
"kcl"
|
||||
"kcl",
|
||||
]
|
||||
|
@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "derive-docs"
|
||||
description = "A tool for generating documentation from Rust derive macros"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
|
@ -195,7 +195,9 @@ fn do_stdlib_inner(
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
.trim_start_matches('_')
|
||||
.to_string();
|
||||
|
||||
let ty = match arg {
|
||||
syn::FnArg::Receiver(pat) => pat.ty.as_ref().into_token_stream(),
|
||||
@ -247,15 +249,21 @@ fn do_stdlib_inner(
|
||||
.replace("-> ", "")
|
||||
.replace("Result < ", "")
|
||||
.replace(", KclError >", "");
|
||||
let return_type = if !ret_ty_string.is_empty() {
|
||||
let ret_ty_string = ret_ty_string.trim().to_string();
|
||||
let ret_ty_ident = format_ident!("{}", ret_ty_string);
|
||||
let ret_ty_string = clean_type(&ret_ty_string);
|
||||
let return_type = quote! {
|
||||
#docs_crate::StdLibFnArg {
|
||||
quote! {
|
||||
Some(#docs_crate::StdLibFnArg {
|
||||
name: "".to_string(),
|
||||
type_: #ret_ty_string.to_string(),
|
||||
schema: #ret_ty_ident::json_schema(&mut generator),
|
||||
required: true,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
@ -275,6 +283,8 @@ fn do_stdlib_inner(
|
||||
// ... a struct type called `#name_ident` that has no members
|
||||
#[allow(non_camel_case_types, missing_docs)]
|
||||
#description_doc_comment
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, schemars::JsonSchema, ts_rs::TS)]
|
||||
#[ts(export)]
|
||||
pub(crate) struct #name_ident {}
|
||||
// ... a constant of type `#name` whose identifier is also #name_ident
|
||||
#[allow(non_upper_case_globals, missing_docs)]
|
||||
@ -307,7 +317,7 @@ fn do_stdlib_inner(
|
||||
vec![#(#arg_types),*]
|
||||
}
|
||||
|
||||
fn return_value(&self) -> #docs_crate::StdLibFnArg {
|
||||
fn return_value(&self) -> Option<#docs_crate::StdLibFnArg> {
|
||||
let mut settings = schemars::gen::SchemaSettings::openapi3();
|
||||
settings.inline_subschemas = true;
|
||||
let mut generator = schemars::gen::SchemaGenerator::new(settings);
|
||||
@ -326,6 +336,10 @@ fn do_stdlib_inner(
|
||||
fn std_lib_fn(&self) -> crate::std::StdFn {
|
||||
#fn_name_ident
|
||||
}
|
||||
|
||||
fn clone_box(&self) -> Box<dyn #docs_crate::StdLibFn> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#item
|
||||
@ -529,4 +543,25 @@ mod tests {
|
||||
assert!(errors.is_empty());
|
||||
expectorate::assert_contents("tests/min.gen", &openapitor::types::get_text_fmt(&item).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stdlib_show() {
|
||||
let (item, errors) = do_stdlib(
|
||||
quote! {
|
||||
name = "show",
|
||||
},
|
||||
quote! {
|
||||
fn inner_show(
|
||||
/// The args to do shit to.
|
||||
_args: Vec<f64>
|
||||
) {
|
||||
}
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let _expected = quote! {};
|
||||
|
||||
assert!(errors.is_empty());
|
||||
expectorate::assert_contents("tests/show.gen", &openapitor::types::get_text_fmt(&item).unwrap());
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
#[allow(non_camel_case_types, missing_docs)]
|
||||
#[doc = "Std lib function: lineTo"]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, schemars :: JsonSchema, ts_rs :: TS)]
|
||||
#[ts(export)]
|
||||
pub(crate) struct LineTo {}
|
||||
|
||||
#[allow(non_upper_case_globals, missing_docs)]
|
||||
@ -42,16 +44,16 @@ impl crate::docs::StdLibFn for LineTo {
|
||||
]
|
||||
}
|
||||
|
||||
fn return_value(&self) -> crate::docs::StdLibFnArg {
|
||||
fn return_value(&self) -> Option<crate::docs::StdLibFnArg> {
|
||||
let mut settings = schemars::gen::SchemaSettings::openapi3();
|
||||
settings.inline_subschemas = true;
|
||||
let mut generator = schemars::gen::SchemaGenerator::new(settings);
|
||||
crate::docs::StdLibFnArg {
|
||||
Some(crate::docs::StdLibFnArg {
|
||||
name: "".to_string(),
|
||||
type_: "SketchGroup".to_string(),
|
||||
schema: SketchGroup::json_schema(&mut generator),
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn unpublished(&self) -> bool {
|
||||
@ -65,6 +67,10 @@ impl crate::docs::StdLibFn for LineTo {
|
||||
fn std_lib_fn(&self) -> crate::std::StdFn {
|
||||
line_to
|
||||
}
|
||||
|
||||
fn clone_box(&self) -> Box<dyn crate::docs::StdLibFn> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn inner_line_to(
|
||||
|
@ -1,5 +1,7 @@
|
||||
#[allow(non_camel_case_types, missing_docs)]
|
||||
#[doc = "Std lib function: min"]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, schemars :: JsonSchema, ts_rs :: TS)]
|
||||
#[ts(export)]
|
||||
pub(crate) struct Min {}
|
||||
|
||||
#[allow(non_upper_case_globals, missing_docs)]
|
||||
@ -34,16 +36,16 @@ impl crate::docs::StdLibFn for Min {
|
||||
}]
|
||||
}
|
||||
|
||||
fn return_value(&self) -> crate::docs::StdLibFnArg {
|
||||
fn return_value(&self) -> Option<crate::docs::StdLibFnArg> {
|
||||
let mut settings = schemars::gen::SchemaSettings::openapi3();
|
||||
settings.inline_subschemas = true;
|
||||
let mut generator = schemars::gen::SchemaGenerator::new(settings);
|
||||
crate::docs::StdLibFnArg {
|
||||
Some(crate::docs::StdLibFnArg {
|
||||
name: "".to_string(),
|
||||
type_: "number".to_string(),
|
||||
schema: f64::json_schema(&mut generator),
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn unpublished(&self) -> bool {
|
||||
@ -57,6 +59,10 @@ impl crate::docs::StdLibFn for Min {
|
||||
fn std_lib_fn(&self) -> crate::std::StdFn {
|
||||
min
|
||||
}
|
||||
|
||||
fn clone_box(&self) -> Box<dyn crate::docs::StdLibFn> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn inner_min(#[doc = r" The args to do shit to."] args: Vec<f64>) -> f64 {
|
||||
|
63
src/wasm-lib/derive-docs/tests/show.gen
Normal file
63
src/wasm-lib/derive-docs/tests/show.gen
Normal file
@ -0,0 +1,63 @@
|
||||
#[allow(non_camel_case_types, missing_docs)]
|
||||
#[doc = "Std lib function: show"]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, schemars :: JsonSchema, ts_rs :: TS)]
|
||||
#[ts(export)]
|
||||
pub(crate) struct Show {}
|
||||
|
||||
#[allow(non_upper_case_globals, missing_docs)]
|
||||
#[doc = "Std lib function: show"]
|
||||
pub(crate) const Show: Show = Show {};
|
||||
impl crate::docs::StdLibFn for Show {
|
||||
fn name(&self) -> String {
|
||||
"show".to_string()
|
||||
}
|
||||
|
||||
fn summary(&self) -> String {
|
||||
"".to_string()
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
"".to_string()
|
||||
}
|
||||
|
||||
fn tags(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn args(&self) -> Vec<crate::docs::StdLibFnArg> {
|
||||
let mut settings = schemars::gen::SchemaSettings::openapi3();
|
||||
settings.inline_subschemas = true;
|
||||
let mut generator = schemars::gen::SchemaGenerator::new(settings);
|
||||
vec![crate::docs::StdLibFnArg {
|
||||
name: "args".to_string(),
|
||||
type_: "[number]".to_string(),
|
||||
schema: <Vec<f64>>::json_schema(&mut generator),
|
||||
required: true,
|
||||
}]
|
||||
}
|
||||
|
||||
fn return_value(&self) -> Option<crate::docs::StdLibFnArg> {
|
||||
let mut settings = schemars::gen::SchemaSettings::openapi3();
|
||||
settings.inline_subschemas = true;
|
||||
let mut generator = schemars::gen::SchemaGenerator::new(settings);
|
||||
None
|
||||
}
|
||||
|
||||
fn unpublished(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn deprecated(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn std_lib_fn(&self) -> crate::std::StdFn {
|
||||
show
|
||||
}
|
||||
|
||||
fn clone_box(&self) -> Box<dyn crate::docs::StdLibFn> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn inner_show(#[doc = r" The args to do shit to."] _args: Vec<f64>) {}
|
@ -1,37 +1,46 @@
|
||||
[package]
|
||||
name = "kcl-lib"
|
||||
description = "KittyCAD Language"
|
||||
version = "0.1.3"
|
||||
version = "0.1.26"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.75"
|
||||
derive-docs = { version = "0.1.0" }
|
||||
kittycad = { version = "0.2.15", default-features = false, features = ["js"] }
|
||||
anyhow = { version = "1.0.75", features = ["backtrace"] }
|
||||
clap = { version = "4.4.2", features = ["cargo", "derive", "env", "unicode"] }
|
||||
dashmap = "5.5.3"
|
||||
derive-docs = { version = "0.1.3" }
|
||||
#derive-docs = { path = "../derive-docs" }
|
||||
kittycad = { version = "0.2.25", default-features = false, features = ["js"] }
|
||||
lazy_static = "1.4.0"
|
||||
parse-display = "0.8.2"
|
||||
regex = "1.7.1"
|
||||
schemars = { version = "0.8", features = ["url", "uuid1"] }
|
||||
schemars = { version = "0.8", features = ["impl_json_schema", "url", "uuid1"] }
|
||||
serde = {version = "1.0.152", features = ["derive"] }
|
||||
serde_json = "1.0.93"
|
||||
thiserror = "1.0.47"
|
||||
ts-rs = { version = "7", package = "ts-rs-json-value", features = ["serde-json-impl", "uuid-impl"] }
|
||||
ts-rs = { version = "7", package = "ts-rs-json-value", features = ["serde-json-impl", "schemars-impl", "uuid-impl"] }
|
||||
uuid = { version = "1.4.1", features = ["v4", "js", "serde"] }
|
||||
wasm-bindgen = "0.2.87"
|
||||
wasm-bindgen-futures = "0.4.37"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = { version = "0.3.64" }
|
||||
tower-lsp = { version = "0.20.0", default-features = false, features = ["runtime-agnostic"] }
|
||||
wasm-bindgen = "0.2.87"
|
||||
wasm-bindgen-futures = "0.4.37"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
bson = { version = "2.6.1", features = ["uuid-1", "chrono"] }
|
||||
bson = { version = "2.7.0", features = ["uuid-1", "chrono"] }
|
||||
futures = { version = "0.3.28" }
|
||||
reqwest = { version = "0.11.20", default-features = false }
|
||||
tokio = { version = "1.32.0", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.20.0", features = ["rustls-tls-native-roots"] }
|
||||
tower-lsp = { version = "0.20.0", features = ["proposed"] }
|
||||
|
||||
[features]
|
||||
default = ["engine"]
|
||||
engine = []
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
@ -39,5 +48,6 @@ debug = true
|
||||
|
||||
[dev-dependencies]
|
||||
expectorate = "1.0.7"
|
||||
itertools = "0.11.0"
|
||||
pretty_assertions = "1.4.0"
|
||||
tokio = { version = "1.32.0", features = ["rt-multi-thread", "macros", "time"] }
|
||||
|
4
src/wasm-lib/kcl/fuzz/.gitignore
vendored
Normal file
4
src/wasm-lib/kcl/fuzz/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
target
|
||||
corpus
|
||||
artifacts
|
||||
coverage
|
2218
src/wasm-lib/kcl/fuzz/Cargo.lock
generated
Normal file
2218
src/wasm-lib/kcl/fuzz/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
src/wasm-lib/kcl/fuzz/Cargo.toml
Normal file
27
src/wasm-lib/kcl/fuzz/Cargo.toml
Normal file
@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "kcl-lib-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
|
||||
[dependencies.kcl-lib]
|
||||
path = ".."
|
||||
|
||||
# Prevent this from interfering with workspaces
|
||||
[workspace]
|
||||
members = ["."]
|
||||
|
||||
[profile.release]
|
||||
debug = 1
|
||||
|
||||
[[bin]]
|
||||
name = "parser"
|
||||
path = "fuzz_targets/parser.rs"
|
||||
test = false
|
||||
doc = false
|
14
src/wasm-lib/kcl/fuzz/fuzz_targets/parser.rs
Normal file
14
src/wasm-lib/kcl/fuzz/fuzz_targets/parser.rs
Normal file
@ -0,0 +1,14 @@
|
||||
#![no_main]
|
||||
#[macro_use]
|
||||
extern crate libfuzzer_sys;
|
||||
extern crate kcl_lib;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(s) = std::str::from_utf8(data) {
|
||||
let tokens = kcl_lib::tokeniser::lexer(s);
|
||||
let parser = kcl_lib::parser::Parser::new(tokens);
|
||||
if let Ok(_) = parser.ast() {
|
||||
println!("OK");
|
||||
}
|
||||
}
|
||||
});
|
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,18 @@
|
||||
//! Functions for generating docs for our stdlib functions.
|
||||
|
||||
use anyhow::Result;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_lsp::lsp_types::{
|
||||
CompletionItem, CompletionItemKind, CompletionItemLabelDetails, Documentation, InsertTextFormat, MarkupContent,
|
||||
MarkupKind, ParameterInformation, ParameterLabel, SignatureHelp, SignatureInformation,
|
||||
};
|
||||
|
||||
use crate::std::Primitive;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, ts_rs::TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StdLibFnData {
|
||||
/// The name of the function.
|
||||
pub name: String,
|
||||
@ -18,7 +25,7 @@ pub struct StdLibFnData {
|
||||
/// The args of the function.
|
||||
pub args: Vec<StdLibFnArg>,
|
||||
/// The return value of the function.
|
||||
pub return_value: StdLibFnArg,
|
||||
pub return_value: Option<StdLibFnArg>,
|
||||
/// If the function is unpublished.
|
||||
pub unpublished: bool,
|
||||
/// If the function is deprecated.
|
||||
@ -26,7 +33,9 @@ pub struct StdLibFnData {
|
||||
}
|
||||
|
||||
/// This struct defines a single argument to a stdlib function.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, ts_rs::TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StdLibFnArg {
|
||||
/// The name of the argument.
|
||||
pub name: String,
|
||||
@ -41,18 +50,36 @@ pub struct StdLibFnArg {
|
||||
impl StdLibFnArg {
|
||||
#[allow(dead_code)]
|
||||
pub fn get_type_string(&self) -> Result<(String, bool)> {
|
||||
get_type_string_from_schema(&self.schema)
|
||||
get_type_string_from_schema(&self.schema.clone())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_autocomplete_string(&self) -> Result<String> {
|
||||
get_autocomplete_string_from_schema(&self.schema.clone())
|
||||
}
|
||||
|
||||
pub fn description(&self) -> Option<String> {
|
||||
get_description_string_from_schema(&self.schema)
|
||||
get_description_string_from_schema(&self.schema.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StdLibFnArg> for ParameterInformation {
|
||||
fn from(arg: StdLibFnArg) -> Self {
|
||||
ParameterInformation {
|
||||
label: ParameterLabel::Simple(arg.name.to_string()),
|
||||
documentation: arg.description().map(|description| {
|
||||
Documentation::MarkupContent(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: description,
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This trait defines functions called upon stdlib functions to generate
|
||||
/// documentation for them.
|
||||
pub trait StdLibFn {
|
||||
pub trait StdLibFn: std::fmt::Debug + Send + Sync {
|
||||
/// The name of the function.
|
||||
fn name(&self) -> String;
|
||||
|
||||
@ -69,7 +96,7 @@ pub trait StdLibFn {
|
||||
fn args(&self) -> Vec<StdLibFnArg>;
|
||||
|
||||
/// The return value of the function.
|
||||
fn return_value(&self) -> StdLibFnArg;
|
||||
fn return_value(&self) -> Option<StdLibFnArg>;
|
||||
|
||||
/// If the function is unpublished.
|
||||
fn unpublished(&self) -> bool;
|
||||
@ -80,6 +107,9 @@ pub trait StdLibFn {
|
||||
/// The function itself.
|
||||
fn std_lib_fn(&self) -> crate::std::StdFn;
|
||||
|
||||
/// Helper function to clone the boxed trait object.
|
||||
fn clone_box(&self) -> Box<dyn StdLibFn>;
|
||||
|
||||
/// Return a JSON struct representing the function.
|
||||
fn to_json(&self) -> Result<StdLibFnData> {
|
||||
Ok(StdLibFnData {
|
||||
@ -93,9 +123,152 @@ pub trait StdLibFn {
|
||||
deprecated: self.deprecated(),
|
||||
})
|
||||
}
|
||||
|
||||
fn fn_signature(&self) -> String {
|
||||
let mut signature = String::new();
|
||||
signature.push_str(&format!("{}(", self.name()));
|
||||
for (i, arg) in self.args().iter().enumerate() {
|
||||
if i > 0 {
|
||||
signature.push_str(", ");
|
||||
}
|
||||
signature.push_str(&format!("{}: {}", arg.name, arg.type_));
|
||||
}
|
||||
signature.push(')');
|
||||
if let Some(return_value) = self.return_value() {
|
||||
signature.push_str(&format!(" -> {}", return_value.type_));
|
||||
}
|
||||
|
||||
fn get_description_string_from_schema(schema: &schemars::schema::Schema) -> Option<String> {
|
||||
signature
|
||||
}
|
||||
|
||||
fn to_completion_item(&self) -> CompletionItem {
|
||||
CompletionItem {
|
||||
label: self.name(),
|
||||
label_details: Some(CompletionItemLabelDetails {
|
||||
detail: Some(self.fn_signature().replace(&self.name(), "")),
|
||||
description: None,
|
||||
}),
|
||||
kind: Some(CompletionItemKind::FUNCTION),
|
||||
detail: None,
|
||||
documentation: Some(Documentation::MarkupContent(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: if !self.description().is_empty() {
|
||||
format!("{}\n\n{}", self.summary(), self.description())
|
||||
} else {
|
||||
self.summary()
|
||||
},
|
||||
})),
|
||||
deprecated: Some(self.deprecated()),
|
||||
preselect: None,
|
||||
sort_text: None,
|
||||
filter_text: None,
|
||||
insert_text: Some(format!(
|
||||
"{}({})",
|
||||
self.name(),
|
||||
self.args()
|
||||
.iter()
|
||||
.enumerate()
|
||||
// It is okay to unwrap here since in the `kcl-lib` tests, we would have caught
|
||||
// any errors in the `self`'s signature.
|
||||
.map(|(index, item)| {
|
||||
let format = item.get_autocomplete_string().unwrap();
|
||||
if item.type_ == "SketchGroup" || item.type_ == "ExtrudeGroup" {
|
||||
format!("${{{}:{}}}", index + 1, "%")
|
||||
} else {
|
||||
format!("${{{}:{}}}", index + 1, format)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)),
|
||||
insert_text_format: Some(InsertTextFormat::SNIPPET),
|
||||
insert_text_mode: None,
|
||||
text_edit: None,
|
||||
additional_text_edits: None,
|
||||
command: None,
|
||||
commit_characters: None,
|
||||
data: None,
|
||||
tags: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_signature_help(&self) -> SignatureHelp {
|
||||
// Fill this in based on the current positon of the cursor.
|
||||
let active_parameter = None;
|
||||
|
||||
SignatureHelp {
|
||||
signatures: vec![SignatureInformation {
|
||||
label: self.name(),
|
||||
documentation: Some(Documentation::MarkupContent(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: if !self.description().is_empty() {
|
||||
format!("{}\n\n{}", self.summary(), self.description())
|
||||
} else {
|
||||
self.summary()
|
||||
},
|
||||
})),
|
||||
parameters: Some(self.args().into_iter().map(|arg| arg.into()).collect()),
|
||||
active_parameter,
|
||||
}],
|
||||
active_signature: Some(0),
|
||||
active_parameter,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for dyn StdLibFn {
|
||||
fn schema_name() -> String {
|
||||
"StdLibFn".to_string()
|
||||
}
|
||||
|
||||
fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
|
||||
gen.subschema_for::<StdLibFnData>()
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Box<dyn StdLibFn> {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
self.to_json().unwrap().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Box<dyn StdLibFn> {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let data = StdLibFnData::deserialize(deserializer)?;
|
||||
let stdlib = crate::std::StdLib::new();
|
||||
let stdlib_fn = stdlib
|
||||
.get(&data.name)
|
||||
.ok_or_else(|| serde::de::Error::custom(format!("StdLibFn {} not found", data.name)))?;
|
||||
Ok(stdlib_fn)
|
||||
}
|
||||
}
|
||||
|
||||
impl ts_rs::TS for dyn StdLibFn {
|
||||
const EXPORT_TO: Option<&'static str> = Some("bindings/StdLibFnData");
|
||||
|
||||
fn name() -> String {
|
||||
"StdLibFnData".to_string()
|
||||
}
|
||||
|
||||
fn dependencies() -> Vec<ts_rs::Dependency>
|
||||
where
|
||||
Self: 'static,
|
||||
{
|
||||
StdLibFnData::dependencies()
|
||||
}
|
||||
|
||||
fn transparent() -> bool {
|
||||
StdLibFnData::transparent()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Box<dyn StdLibFn> {
|
||||
fn clone(&self) -> Box<dyn StdLibFn> {
|
||||
self.clone_box()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_description_string_from_schema(schema: &schemars::schema::Schema) -> Option<String> {
|
||||
if let schemars::schema::Schema::Object(o) = schema {
|
||||
if let Some(metadata) = &o.metadata {
|
||||
if let Some(description) = &metadata.description {
|
||||
@ -107,7 +280,7 @@ fn get_description_string_from_schema(schema: &schemars::schema::Schema) -> Opti
|
||||
None
|
||||
}
|
||||
|
||||
fn get_type_string_from_schema(schema: &schemars::schema::Schema) -> Result<(String, bool)> {
|
||||
pub fn get_type_string_from_schema(schema: &schemars::schema::Schema) -> Result<(String, bool)> {
|
||||
match schema {
|
||||
schemars::schema::Schema::Object(o) => {
|
||||
if let Some(format) = &o.format {
|
||||
@ -132,11 +305,7 @@ fn get_type_string_from_schema(schema: &schemars::schema::Schema) -> Result<(Str
|
||||
if let Some(description) = get_description_string_from_schema(prop) {
|
||||
fn_docs.push_str(&format!("\t// {}\n", description));
|
||||
}
|
||||
fn_docs.push_str(&format!(
|
||||
"\t\"{}\": {},\n",
|
||||
prop_name,
|
||||
get_type_string_from_schema(prop)?.0,
|
||||
));
|
||||
fn_docs.push_str(&format!("\t{}: {},\n", prop_name, get_type_string_from_schema(prop)?.0,));
|
||||
}
|
||||
|
||||
fn_docs.push('}');
|
||||
@ -187,3 +356,168 @@ fn get_type_string_from_schema(schema: &schemars::schema::Schema) -> Result<(Str
|
||||
schemars::schema::Schema::Bool(_) => Ok((Primitive::Bool.to_string(), false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_autocomplete_string_from_schema(schema: &schemars::schema::Schema) -> Result<String> {
|
||||
match schema {
|
||||
schemars::schema::Schema::Object(o) => {
|
||||
if let Some(format) = &o.format {
|
||||
if format == "uuid" {
|
||||
return Ok(Primitive::Uuid.to_string());
|
||||
} else if format == "double" || format == "uint" {
|
||||
return Ok(Primitive::Number.to_string());
|
||||
} else {
|
||||
anyhow::bail!("unknown format: {}", format);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(obj_val) = &o.object {
|
||||
let mut fn_docs = String::new();
|
||||
fn_docs.push_str("{\n");
|
||||
// Let's print out the object's properties.
|
||||
for (prop_name, prop) in obj_val.properties.iter() {
|
||||
if prop_name.starts_with('_') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(description) = get_description_string_from_schema(prop) {
|
||||
fn_docs.push_str(&format!("\t// {}\n", description));
|
||||
}
|
||||
fn_docs.push_str(&format!(
|
||||
"\t{}: {},\n",
|
||||
prop_name,
|
||||
get_autocomplete_string_from_schema(prop)?,
|
||||
));
|
||||
}
|
||||
|
||||
fn_docs.push('}');
|
||||
|
||||
return Ok(fn_docs);
|
||||
}
|
||||
|
||||
if let Some(array_val) = &o.array {
|
||||
if let Some(schemars::schema::SingleOrVec::Single(items)) = &array_val.items {
|
||||
// Let's print out the object's properties.
|
||||
return Ok(format!("[{}]", get_autocomplete_string_from_schema(items)?));
|
||||
} else if let Some(items) = &array_val.contains {
|
||||
return Ok(format!("[{}]", get_autocomplete_string_from_schema(items)?));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(subschemas) = &o.subschemas {
|
||||
let mut fn_docs = String::new();
|
||||
if let Some(items) = &subschemas.one_of {
|
||||
if let Some(item) = items.iter().next() {
|
||||
// Let's print out the object's properties.
|
||||
fn_docs.push_str(&get_autocomplete_string_from_schema(item)?);
|
||||
}
|
||||
} else if let Some(items) = &subschemas.any_of {
|
||||
if let Some(item) = items.iter().next() {
|
||||
// Let's print out the object's properties.
|
||||
fn_docs.push_str(&get_autocomplete_string_from_schema(item)?);
|
||||
}
|
||||
} else {
|
||||
anyhow::bail!("unknown subschemas: {:#?}", subschemas);
|
||||
}
|
||||
|
||||
return Ok(fn_docs);
|
||||
}
|
||||
|
||||
if let Some(schemars::schema::SingleOrVec::Single(_string)) = &o.instance_type {
|
||||
return Ok(Primitive::String.to_string());
|
||||
}
|
||||
|
||||
anyhow::bail!("unknown type: {:#?}", o)
|
||||
}
|
||||
schemars::schema::Schema::Bool(_) => Ok(Primitive::Bool.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn completion_item_from_enum_schema(
|
||||
schema: &schemars::schema::Schema,
|
||||
kind: CompletionItemKind,
|
||||
) -> Result<CompletionItem> {
|
||||
// Get the docs for the schema.
|
||||
let description = get_description_string_from_schema(schema).unwrap_or_default();
|
||||
let schemars::schema::Schema::Object(o) = schema else {
|
||||
anyhow::bail!("expected object schema: {:#?}", schema);
|
||||
};
|
||||
let Some(enum_values) = o.enum_values.as_ref() else {
|
||||
anyhow::bail!("expected enum values: {:#?}", o);
|
||||
};
|
||||
|
||||
if enum_values.len() > 1 {
|
||||
anyhow::bail!("expected only one enum value: {:#?}", o);
|
||||
}
|
||||
|
||||
if enum_values.is_empty() {
|
||||
anyhow::bail!("expected at least one enum value: {:#?}", o);
|
||||
}
|
||||
|
||||
let label = enum_values[0].to_string();
|
||||
|
||||
Ok(CompletionItem {
|
||||
label,
|
||||
label_details: None,
|
||||
kind: Some(kind),
|
||||
detail: Some(description.to_string()),
|
||||
documentation: Some(Documentation::MarkupContent(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: description.to_string(),
|
||||
})),
|
||||
deprecated: Some(false),
|
||||
preselect: None,
|
||||
sort_text: None,
|
||||
filter_text: None,
|
||||
insert_text: None,
|
||||
insert_text_format: None,
|
||||
insert_text_mode: None,
|
||||
text_edit: None,
|
||||
additional_text_edits: None,
|
||||
command: None,
|
||||
commit_characters: None,
|
||||
data: None,
|
||||
tags: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_serialize_function() {
|
||||
let some_function = crate::abstract_syntax_tree_types::Function::StdLib {
|
||||
func: Box::new(crate::std::sketch::Line),
|
||||
};
|
||||
let serialized = serde_json::to_string(&some_function).unwrap();
|
||||
assert!(serialized.contains(r#"{"type":"StdLib""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_function() {
|
||||
let some_function_string = r#"{"type":"StdLib","func":{"name":"line","summary":"","description":"","tags":[],"returnValue":{"type":"","required":false,"name":"","schema":{}},"args":[],"unpublished":false,"deprecated":false}}"#;
|
||||
let some_function: crate::abstract_syntax_tree_types::Function =
|
||||
serde_json::from_str(some_function_string).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
some_function,
|
||||
crate::abstract_syntax_tree_types::Function::StdLib {
|
||||
func: Box::new(crate::std::sketch::Line),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_function_show() {
|
||||
let some_function_string = r#"{"type":"StdLib","func":{"name":"show","summary":"","description":"","tags":[],"returnValue":{"type":"","required":false,"name":"","schema":{}},"args":[],"unpublished":false,"deprecated":false}}"#;
|
||||
let some_function: crate::abstract_syntax_tree_types::Function =
|
||||
serde_json::from_str(some_function_string).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
some_function,
|
||||
crate::abstract_syntax_tree_types::Function::StdLib {
|
||||
func: Box::new(crate::std::Show),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -87,6 +87,9 @@ impl EngineConnection {
|
||||
|
||||
if let Some(msg) = ws_resp.resp {
|
||||
match msg {
|
||||
OkWebSocketResponseData::MetricsRequest {} => {
|
||||
// @paultag todo
|
||||
}
|
||||
OkWebSocketResponseData::IceServerInfo { ice_servers } => {
|
||||
println!("got ice server info: {:?}", ice_servers);
|
||||
}
|
||||
|
@ -1,19 +1,26 @@
|
||||
//! Functions for managing engine communications.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(not(test))]
|
||||
#[cfg(feature = "engine")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(test))]
|
||||
#[cfg(feature = "engine")]
|
||||
pub mod conn;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(test))]
|
||||
#[cfg(feature = "engine")]
|
||||
pub use conn::EngineConnection;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(not(test))]
|
||||
#[cfg(feature = "engine")]
|
||||
pub mod conn_wasm;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(not(test))]
|
||||
#[cfg(feature = "engine")]
|
||||
pub use conn_wasm::EngineConnection;
|
||||
|
||||
#[cfg(test)]
|
||||
@ -21,18 +28,25 @@ pub mod conn_mock;
|
||||
#[cfg(test)]
|
||||
pub use conn_mock::EngineConnection;
|
||||
|
||||
use crate::executor::SourceRange;
|
||||
#[cfg(not(feature = "engine"))]
|
||||
#[cfg(not(test))]
|
||||
pub mod conn_mock;
|
||||
#[cfg(not(feature = "engine"))]
|
||||
#[cfg(not(test))]
|
||||
pub use conn_mock::EngineConnection;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(not(test))]
|
||||
#[derive(Debug)]
|
||||
#[wasm_bindgen]
|
||||
pub struct EngineManager {
|
||||
connection: EngineConnection,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl EngineManager {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(not(test))]
|
||||
#[cfg(feature = "engine")]
|
||||
#[wasm_bindgen]
|
||||
impl EngineManager {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub async fn new(manager: conn_wasm::EngineCommandManager) -> EngineManager {
|
||||
EngineManager {
|
||||
@ -45,7 +59,7 @@ impl EngineManager {
|
||||
let id = uuid::Uuid::parse_str(id_str).map_err(|e| e.to_string())?;
|
||||
let cmd = serde_json::from_str(cmd_str).map_err(|e| e.to_string())?;
|
||||
self.connection
|
||||
.send_modeling_cmd(id, SourceRange::default(), cmd)
|
||||
.send_modeling_cmd(id, crate::executor::SourceRange::default(), cmd)
|
||||
.map_err(String::from)?;
|
||||
|
||||
Ok(())
|
||||
|
@ -1,5 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity};
|
||||
|
||||
use crate::executor::SourceRange;
|
||||
|
||||
#[derive(Error, Debug, Serialize, Deserialize, ts_rs::TS)]
|
||||
#[ts(export)]
|
||||
@ -29,7 +32,7 @@ pub enum KclError {
|
||||
#[ts(export)]
|
||||
pub struct KclErrorDetails {
|
||||
#[serde(rename = "sourceRanges")]
|
||||
pub source_ranges: Vec<crate::executor::SourceRange>,
|
||||
pub source_ranges: Vec<SourceRange>,
|
||||
#[serde(rename = "msg")]
|
||||
pub message: String,
|
||||
}
|
||||
@ -61,6 +64,37 @@ impl KclError {
|
||||
|
||||
(format!("{}: {}", type_, message), line, column)
|
||||
}
|
||||
|
||||
pub fn source_ranges(&self) -> Vec<SourceRange> {
|
||||
match &self {
|
||||
KclError::Syntax(e) => e.source_ranges.clone(),
|
||||
KclError::Semantic(e) => e.source_ranges.clone(),
|
||||
KclError::Type(e) => e.source_ranges.clone(),
|
||||
KclError::Unimplemented(e) => e.source_ranges.clone(),
|
||||
KclError::Unexpected(e) => e.source_ranges.clone(),
|
||||
KclError::ValueAlreadyDefined(e) => e.source_ranges.clone(),
|
||||
KclError::UndefinedValue(e) => e.source_ranges.clone(),
|
||||
KclError::InvalidExpression(e) => e.source_ranges.clone(),
|
||||
KclError::Engine(e) => e.source_ranges.clone(),
|
||||
}
|
||||
}
|
||||
pub fn to_lsp_diagnostic(&self, code: &str) -> Diagnostic {
|
||||
let (message, _, _) = self.get_message_line_column(code);
|
||||
let source_ranges = self.source_ranges();
|
||||
|
||||
Diagnostic {
|
||||
range: source_ranges.first().map(|r| r.to_lsp_range(code)).unwrap_or_default(),
|
||||
severity: Some(DiagnosticSeverity::ERROR),
|
||||
code: None,
|
||||
// TODO: this is neat we can pass a URL to a help page here for this specific error.
|
||||
code_description: None,
|
||||
source: Some("kcl".to_string()),
|
||||
message,
|
||||
related_information: None,
|
||||
tags: None,
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This is different than to_string() in that it will serialize the Error
|
||||
|
@ -5,9 +5,10 @@ use std::collections::HashMap;
|
||||
use anyhow::Result;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_lsp::lsp_types::{Position as LspPosition, Range as LspRange};
|
||||
|
||||
use crate::{
|
||||
abstract_syntax_tree_types::{BodyItem, FunctionExpression, Value},
|
||||
abstract_syntax_tree_types::{BodyItem, Function, FunctionExpression, Value},
|
||||
engine::EngineConnection,
|
||||
errors::{KclError, KclErrorDetails},
|
||||
};
|
||||
@ -281,10 +282,65 @@ pub struct Position(pub [f64; 3]);
|
||||
#[ts(export)]
|
||||
pub struct Rotation(pub [f64; 4]);
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Copy, Clone, ts_rs::TS, JsonSchema)]
|
||||
#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Copy, Clone, ts_rs::TS, JsonSchema, Hash, Eq)]
|
||||
#[ts(export)]
|
||||
pub struct SourceRange(pub [usize; 2]);
|
||||
|
||||
impl SourceRange {
|
||||
/// Create a new source range.
|
||||
pub fn new(start: usize, end: usize) -> Self {
|
||||
Self([start, end])
|
||||
}
|
||||
|
||||
/// Get the start of the range.
|
||||
pub fn start(&self) -> usize {
|
||||
self.0[0]
|
||||
}
|
||||
|
||||
/// Get the end of the range.
|
||||
pub fn end(&self) -> usize {
|
||||
self.0[1]
|
||||
}
|
||||
|
||||
/// Check if the range contains a position.
|
||||
pub fn contains(&self, pos: usize) -> bool {
|
||||
pos >= self.start() && pos <= self.end()
|
||||
}
|
||||
|
||||
pub fn start_to_lsp_position(&self, code: &str) -> LspPosition {
|
||||
// Calculate the line and column of the error from the source range.
|
||||
// Lines are zero indexed in vscode so we need to subtract 1.
|
||||
let mut line = code[..self.start()].lines().count();
|
||||
if line > 0 {
|
||||
line = line.saturating_sub(1);
|
||||
}
|
||||
let column = code[..self.start()].lines().last().map(|l| l.len()).unwrap_or_default();
|
||||
|
||||
LspPosition {
|
||||
line: line as u32,
|
||||
character: column as u32,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn end_to_lsp_position(&self, code: &str) -> LspPosition {
|
||||
// Calculate the line and column of the error from the source range.
|
||||
// Lines are zero indexed in vscode so we need to subtract 1.
|
||||
let line = code[..self.end()].lines().count() - 1;
|
||||
let column = code[..self.end()].lines().last().map(|l| l.len()).unwrap_or_default();
|
||||
|
||||
LspPosition {
|
||||
line: line as u32,
|
||||
character: column as u32,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_lsp_range(&self, code: &str) -> LspRange {
|
||||
let start = self.start_to_lsp_position(code);
|
||||
let end = self.end_to_lsp_position(code);
|
||||
LspRange { start, end }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, ts_rs::TS, JsonSchema)]
|
||||
#[ts(export)]
|
||||
pub struct Point2d {
|
||||
@ -298,12 +354,24 @@ impl From<[f64; 2]> for Point2d {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&[f64; 2]> for Point2d {
|
||||
fn from(p: &[f64; 2]) -> Self {
|
||||
Self { x: p[0], y: p[1] }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Point2d> for [f64; 2] {
|
||||
fn from(p: Point2d) -> Self {
|
||||
[p.x, p.y]
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Point2d> for kittycad::types::Point2D {
|
||||
fn from(p: Point2d) -> Self {
|
||||
Self { x: p.x, y: p.y }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, ts_rs::TS, JsonSchema)]
|
||||
#[ts(export)]
|
||||
pub struct Point3d {
|
||||
@ -497,7 +565,6 @@ pub fn execute(
|
||||
engine: &mut EngineConnection,
|
||||
) -> Result<ProgramMemory, KclError> {
|
||||
let mut pipe_info = PipeInfo::default();
|
||||
let stdlib = crate::std::StdLib::new();
|
||||
|
||||
// Iterate over the body of the program.
|
||||
for statement in &program.body {
|
||||
@ -517,7 +584,8 @@ pub fn execute(
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
if fn_name == "show" {
|
||||
let _show_fn = Box::new(crate::std::Show);
|
||||
if let Function::StdLib { func: _show_fn } = &call_expr.function {
|
||||
if options != BodyType::Root {
|
||||
return Err(KclError::Semantic(KclErrorDetails {
|
||||
message: "Cannot call show outside of a root".to_string(),
|
||||
@ -527,7 +595,9 @@ pub fn execute(
|
||||
|
||||
memory.return_ = Some(ProgramReturn::Arguments(call_expr.arguments.clone()));
|
||||
} else if let Some(func) = memory.clone().root.get(&fn_name) {
|
||||
func.call_fn(&args, memory, engine)?;
|
||||
let result = func.call_fn(&args, memory, engine)?;
|
||||
|
||||
memory.return_ = result;
|
||||
} else {
|
||||
return Err(KclError::Semantic(KclErrorDetails {
|
||||
message: format!("No such name {} defined", fn_name),
|
||||
@ -551,7 +621,7 @@ pub fn execute(
|
||||
memory.add(&var_name, value.clone(), source_range)?;
|
||||
}
|
||||
Value::BinaryExpression(binary_expression) => {
|
||||
let result = binary_expression.get_result(memory, &mut pipe_info, &stdlib, engine)?;
|
||||
let result = binary_expression.get_result(memory, &mut pipe_info, engine)?;
|
||||
memory.add(&var_name, result, source_range)?;
|
||||
}
|
||||
Value::FunctionExpression(function_expression) => {
|
||||
@ -574,7 +644,7 @@ pub fn execute(
|
||||
for (index, param) in function_expression.params.iter().enumerate() {
|
||||
fn_memory.add(
|
||||
¶m.name,
|
||||
args.clone().get(index).unwrap().clone(),
|
||||
args.get(index).unwrap().clone(),
|
||||
param.into(),
|
||||
)?;
|
||||
}
|
||||
@ -588,11 +658,11 @@ pub fn execute(
|
||||
)?;
|
||||
}
|
||||
Value::CallExpression(call_expression) => {
|
||||
let result = call_expression.execute(memory, &mut pipe_info, &stdlib, engine)?;
|
||||
let result = call_expression.execute(memory, &mut pipe_info, engine)?;
|
||||
memory.add(&var_name, result, source_range)?;
|
||||
}
|
||||
Value::PipeExpression(pipe_expression) => {
|
||||
let result = pipe_expression.get_result(memory, &mut pipe_info, &stdlib, engine)?;
|
||||
let result = pipe_expression.get_result(memory, &mut pipe_info, engine)?;
|
||||
memory.add(&var_name, result, source_range)?;
|
||||
}
|
||||
Value::PipeSubstitution(pipe_substitution) => {
|
||||
@ -605,11 +675,11 @@ pub fn execute(
|
||||
}));
|
||||
}
|
||||
Value::ArrayExpression(array_expression) => {
|
||||
let result = array_expression.execute(memory, &mut pipe_info, &stdlib, engine)?;
|
||||
let result = array_expression.execute(memory, &mut pipe_info, engine)?;
|
||||
memory.add(&var_name, result, source_range)?;
|
||||
}
|
||||
Value::ObjectExpression(object_expression) => {
|
||||
let result = object_expression.execute(memory, &mut pipe_info, &stdlib, engine)?;
|
||||
let result = object_expression.execute(memory, &mut pipe_info, engine)?;
|
||||
memory.add(&var_name, result, source_range)?;
|
||||
}
|
||||
Value::MemberExpression(member_expression) => {
|
||||
@ -617,7 +687,7 @@ pub fn execute(
|
||||
memory.add(&var_name, result, source_range)?;
|
||||
}
|
||||
Value::UnaryExpression(unary_expression) => {
|
||||
let result = unary_expression.get_result(memory, &mut pipe_info, &stdlib, engine)?;
|
||||
let result = unary_expression.get_result(memory, &mut pipe_info, engine)?;
|
||||
memory.add(&var_name, result, source_range)?;
|
||||
}
|
||||
}
|
||||
@ -625,14 +695,42 @@ pub fn execute(
|
||||
}
|
||||
BodyItem::ReturnStatement(return_statement) => match &return_statement.argument {
|
||||
Value::BinaryExpression(bin_expr) => {
|
||||
let result = bin_expr.get_result(memory, &mut pipe_info, &stdlib, engine)?;
|
||||
let result = bin_expr.get_result(memory, &mut pipe_info, engine)?;
|
||||
memory.return_ = Some(ProgramReturn::Value(result));
|
||||
}
|
||||
Value::UnaryExpression(unary_expr) => {
|
||||
let result = unary_expr.get_result(memory, &mut pipe_info, engine)?;
|
||||
memory.return_ = Some(ProgramReturn::Value(result));
|
||||
}
|
||||
Value::Identifier(identifier) => {
|
||||
let value = memory.get(&identifier.name, identifier.into())?.clone();
|
||||
memory.return_ = Some(ProgramReturn::Value(value));
|
||||
}
|
||||
_ => (),
|
||||
Value::Literal(literal) => {
|
||||
memory.return_ = Some(ProgramReturn::Value(literal.into()));
|
||||
}
|
||||
Value::ArrayExpression(array_expr) => {
|
||||
let result = array_expr.execute(memory, &mut pipe_info, engine)?;
|
||||
memory.return_ = Some(ProgramReturn::Value(result));
|
||||
}
|
||||
Value::ObjectExpression(obj_expr) => {
|
||||
let result = obj_expr.execute(memory, &mut pipe_info, engine)?;
|
||||
memory.return_ = Some(ProgramReturn::Value(result));
|
||||
}
|
||||
Value::CallExpression(call_expr) => {
|
||||
let result = call_expr.execute(memory, &mut pipe_info, engine)?;
|
||||
memory.return_ = Some(ProgramReturn::Value(result));
|
||||
}
|
||||
Value::MemberExpression(member_expr) => {
|
||||
let result = member_expr.get_result(memory)?;
|
||||
memory.return_ = Some(ProgramReturn::Value(result));
|
||||
}
|
||||
Value::PipeExpression(pipe_expr) => {
|
||||
let result = pipe_expr.get_result(memory, &mut pipe_info, engine)?;
|
||||
memory.return_ = Some(ProgramReturn::Value(result));
|
||||
}
|
||||
Value::PipeSubstitution(_) => {}
|
||||
Value::FunctionExpression(_) => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -648,7 +746,8 @@ mod tests {
|
||||
|
||||
pub async fn parse_execute(code: &str) -> Result<ProgramMemory> {
|
||||
let tokens = crate::tokeniser::lexer(code);
|
||||
let program = crate::parser::abstract_syntax_tree(&tokens)?;
|
||||
let parser = crate::parser::Parser::new(tokens);
|
||||
let program = parser.ast()?;
|
||||
let mut mem: ProgramMemory = Default::default();
|
||||
let mut engine = EngineConnection::new().await?;
|
||||
let memory = execute(program, &mut mem, BodyType::Root, &mut engine)?;
|
||||
@ -765,4 +864,138 @@ show(part001)"#;
|
||||
|
||||
parse_execute(ast).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_execute_with_inline_comment() {
|
||||
let ast = r#"const baseThick = 1
|
||||
const armAngle = 60
|
||||
|
||||
const baseThickHalf = baseThick / 2
|
||||
const halfArmAngle = armAngle / 2
|
||||
|
||||
const arrExpShouldNotBeIncluded = [1, 2, 3]
|
||||
const objExpShouldNotBeIncluded = { a: 1, b: 2, c: 3 }
|
||||
|
||||
const part001 = startSketchAt([0, 0])
|
||||
|> yLineTo(1, %)
|
||||
|> xLine(3.84, %) // selection-range-7ish-before-this
|
||||
|
||||
const variableBelowShouldNotBeIncluded = 3
|
||||
|
||||
show(part001)"#;
|
||||
|
||||
parse_execute(ast).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_execute_with_function_literal_in_pipe() {
|
||||
let ast = r#"const w = 20
|
||||
const l = 8
|
||||
const h = 10
|
||||
|
||||
fn thing = () => {
|
||||
return -8
|
||||
}
|
||||
|
||||
const firstExtrude = startSketchAt([0,0])
|
||||
|> line([0, l], %)
|
||||
|> line([w, 0], %)
|
||||
|> line([0, thing()], %)
|
||||
|> close(%)
|
||||
|> extrude(h, %)
|
||||
|
||||
show(firstExtrude)"#;
|
||||
|
||||
parse_execute(ast).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_execute_with_function_unary_in_pipe() {
|
||||
let ast = r#"const w = 20
|
||||
const l = 8
|
||||
const h = 10
|
||||
|
||||
fn thing = (x) => {
|
||||
return -x
|
||||
}
|
||||
|
||||
const firstExtrude = startSketchAt([0,0])
|
||||
|> line([0, l], %)
|
||||
|> line([w, 0], %)
|
||||
|> line([0, thing(8)], %)
|
||||
|> close(%)
|
||||
|> extrude(h, %)
|
||||
|
||||
show(firstExtrude)"#;
|
||||
|
||||
parse_execute(ast).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_execute_with_function_array_in_pipe() {
|
||||
let ast = r#"const w = 20
|
||||
const l = 8
|
||||
const h = 10
|
||||
|
||||
fn thing = (x) => {
|
||||
return [0, -x]
|
||||
}
|
||||
|
||||
const firstExtrude = startSketchAt([0,0])
|
||||
|> line([0, l], %)
|
||||
|> line([w, 0], %)
|
||||
|> line(thing(8), %)
|
||||
|> close(%)
|
||||
|> extrude(h, %)
|
||||
|
||||
show(firstExtrude)"#;
|
||||
|
||||
parse_execute(ast).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_execute_with_function_call_in_pipe() {
|
||||
let ast = r#"const w = 20
|
||||
const l = 8
|
||||
const h = 10
|
||||
|
||||
fn other_thing = (y) => {
|
||||
return -y
|
||||
}
|
||||
|
||||
fn thing = (x) => {
|
||||
return other_thing(x)
|
||||
}
|
||||
|
||||
const firstExtrude = startSketchAt([0,0])
|
||||
|> line([0, l], %)
|
||||
|> line([w, 0], %)
|
||||
|> line([0, thing(8)], %)
|
||||
|> close(%)
|
||||
|> extrude(h, %)
|
||||
|
||||
show(firstExtrude)"#;
|
||||
|
||||
parse_execute(ast).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_execute_with_function_sketch() {
|
||||
let ast = r#"const box = (h, l, w) => {
|
||||
const myBox = startSketchAt([0,0])
|
||||
|> line([0, l], %)
|
||||
|> line([w, 0], %)
|
||||
|> line([0, -l], %)
|
||||
|> close(%)
|
||||
|> extrude(h, %)
|
||||
|
||||
return myBox
|
||||
}
|
||||
|
||||
const fnBox = box(3, 6, 10)
|
||||
|
||||
show(fnBox)"#;
|
||||
|
||||
parse_execute(ast).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
pub mod abstract_syntax_tree_types;
|
||||
mod docs;
|
||||
pub mod docs;
|
||||
pub mod engine;
|
||||
pub mod errors;
|
||||
pub mod executor;
|
||||
pub mod math_parser;
|
||||
pub mod parser;
|
||||
pub mod recast;
|
||||
pub mod server;
|
||||
pub mod std;
|
||||
pub mod tokeniser;
|
||||
|
@ -1,161 +1,18 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
abstract_syntax_tree_types::{BinaryExpression, BinaryPart, CallExpression, Identifier, Literal, ValueMeta},
|
||||
abstract_syntax_tree_types::{
|
||||
BinaryExpression, BinaryOperator, BinaryPart, CallExpression, Identifier, Literal, ValueMeta,
|
||||
},
|
||||
errors::{KclError, KclErrorDetails},
|
||||
executor::SourceRange,
|
||||
parser::{find_closing_brace, is_not_code_token, make_call_expression},
|
||||
parser::{is_not_code_token, Parser},
|
||||
tokeniser::{Token, TokenType},
|
||||
};
|
||||
|
||||
pub fn precedence(operator: &str) -> u8 {
|
||||
// might be useful for reference to make it match
|
||||
// another commonly used lang https://www.w3schools.com/js/js_precedence.asp
|
||||
match operator {
|
||||
"+" | "-" => 11,
|
||||
"*" | "/" | "%" => 12,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reverse_polish_notation(
|
||||
tokens: &[Token],
|
||||
previous_postfix: &[Token],
|
||||
operators: &[Token],
|
||||
) -> Result<Vec<Token>, KclError> {
|
||||
if tokens.is_empty() {
|
||||
return Ok(previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(operators.iter().cloned().rev())
|
||||
.collect());
|
||||
}
|
||||
let current_token = &tokens[0];
|
||||
let next = tokens.get(1);
|
||||
if current_token.token_type == TokenType::Word {
|
||||
return if let Some(next) = next {
|
||||
if next.token_type == TokenType::Brace && next.value == "(" {
|
||||
let closing_brace = find_closing_brace(tokens, 1, 0, "")?;
|
||||
reverse_polish_notation(
|
||||
&tokens[closing_brace + 1..],
|
||||
&previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(tokens[0..closing_brace + 1].iter().cloned())
|
||||
.collect::<Vec<Token>>(),
|
||||
operators,
|
||||
)
|
||||
} else {
|
||||
reverse_polish_notation(
|
||||
&tokens[1..],
|
||||
&previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![current_token.clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
operators,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
reverse_polish_notation(
|
||||
&tokens[1..],
|
||||
&previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![current_token.clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
operators,
|
||||
)
|
||||
};
|
||||
} else if current_token.token_type == TokenType::Number
|
||||
|| current_token.token_type == TokenType::Word
|
||||
|| current_token.token_type == TokenType::String
|
||||
{
|
||||
return reverse_polish_notation(
|
||||
&tokens[1..],
|
||||
&previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![current_token.clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
operators,
|
||||
);
|
||||
} else if ["+", "-", "*", "/", "%"].contains(¤t_token.value.as_str()) {
|
||||
if !operators.is_empty()
|
||||
&& precedence(&operators[operators.len() - 1].value) >= precedence(¤t_token.value)
|
||||
{
|
||||
return reverse_polish_notation(
|
||||
tokens,
|
||||
&previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![operators[operators.len() - 1].clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
&operators[0..operators.len() - 1],
|
||||
);
|
||||
}
|
||||
return reverse_polish_notation(
|
||||
&tokens[1..],
|
||||
previous_postfix,
|
||||
&operators
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![current_token.clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
);
|
||||
} else if current_token.value == "(" {
|
||||
// push current token to both stacks as it is a legitimate operator
|
||||
// but later we'll need to pop other operators off the stack until we find the matching ')'
|
||||
return reverse_polish_notation(
|
||||
&tokens[1..],
|
||||
&previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![current_token.clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
&operators
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![current_token.clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
);
|
||||
} else if current_token.value == ")" {
|
||||
if operators[operators.len() - 1].value != "(" {
|
||||
// pop operators off the stack and push them to postFix until we find the matching '('
|
||||
return reverse_polish_notation(
|
||||
tokens,
|
||||
&previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![operators[operators.len() - 1].clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
&operators[0..operators.len() - 1],
|
||||
);
|
||||
}
|
||||
return reverse_polish_notation(
|
||||
&tokens[1..],
|
||||
&previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![current_token.clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
&operators[0..operators.len() - 1],
|
||||
);
|
||||
}
|
||||
if is_not_code_token(current_token) {
|
||||
return reverse_polish_notation(&tokens[1..], previous_postfix, operators);
|
||||
}
|
||||
|
||||
Err(KclError::Syntax(KclErrorDetails {
|
||||
source_ranges: vec![current_token.into()],
|
||||
message: format!(
|
||||
"Unexpected token: {} {:?}",
|
||||
current_token.value, current_token.token_type
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone, ts_rs::TS)]
|
||||
#[ts(export)]
|
||||
pub enum MathTokenType {
|
||||
@ -191,7 +48,7 @@ crate::abstract_syntax_tree_types::impl_value_meta!(ParenthesisToken);
|
||||
pub struct ExtendedBinaryExpression {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub operator: String,
|
||||
pub operator: BinaryOperator,
|
||||
pub left: BinaryPart,
|
||||
pub right: BinaryPart,
|
||||
pub start_extended: Option<usize>,
|
||||
@ -250,10 +107,177 @@ impl MathExpression {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReversePolishNotation {
|
||||
parser: Parser,
|
||||
previous_postfix: Vec<Token>,
|
||||
operators: Vec<Token>,
|
||||
}
|
||||
|
||||
impl ReversePolishNotation {
|
||||
pub fn new(tokens: &[Token], previous_postfix: &[Token], operators: &[Token]) -> Self {
|
||||
Self {
|
||||
parser: Parser::new(tokens.to_vec()),
|
||||
previous_postfix: previous_postfix.to_vec(),
|
||||
operators: operators.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(&self) -> Result<Vec<Token>, KclError> {
|
||||
if self.parser.tokens.is_empty() {
|
||||
return Ok(self
|
||||
.previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(self.operators.iter().cloned().rev())
|
||||
.collect());
|
||||
}
|
||||
|
||||
let current_token = self.parser.get_token(0)?;
|
||||
if current_token.token_type == TokenType::Word || current_token.token_type == TokenType::Keyword {
|
||||
if let Ok(next) = self.parser.get_token(1) {
|
||||
if next.token_type == TokenType::Brace && next.value == "(" {
|
||||
let closing_brace = self.parser.find_closing_brace(1, 0, "")?;
|
||||
let rpn = ReversePolishNotation::new(
|
||||
&self.parser.tokens[closing_brace + 1..],
|
||||
&self
|
||||
.previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(self.parser.tokens[0..closing_brace + 1].iter().cloned())
|
||||
.collect::<Vec<Token>>(),
|
||||
&self.operators,
|
||||
);
|
||||
return rpn.parse();
|
||||
}
|
||||
}
|
||||
|
||||
let rpn = ReversePolishNotation::new(
|
||||
&self.parser.tokens[1..],
|
||||
&self
|
||||
.previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![current_token.clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
&self.operators,
|
||||
);
|
||||
return rpn.parse();
|
||||
} else if current_token.token_type == TokenType::Number
|
||||
|| current_token.token_type == TokenType::Word
|
||||
|| current_token.token_type == TokenType::Keyword
|
||||
|| current_token.token_type == TokenType::String
|
||||
{
|
||||
let rpn = ReversePolishNotation::new(
|
||||
&self.parser.tokens[1..],
|
||||
&self
|
||||
.previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![current_token.clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
&self.operators,
|
||||
);
|
||||
return rpn.parse();
|
||||
} else if let Ok(binop) = BinaryOperator::from_str(current_token.value.as_str()) {
|
||||
if !self.operators.is_empty() {
|
||||
if let Ok(prevbinop) = BinaryOperator::from_str(self.operators[self.operators.len() - 1].value.as_str())
|
||||
{
|
||||
if prevbinop.precedence() >= binop.precedence() {
|
||||
let rpn = ReversePolishNotation::new(
|
||||
&self.parser.tokens,
|
||||
&self
|
||||
.previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![self.operators[self.operators.len() - 1].clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
&self.operators[0..self.operators.len() - 1],
|
||||
);
|
||||
return rpn.parse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rpn = ReversePolishNotation::new(
|
||||
&self.parser.tokens[1..],
|
||||
&self.previous_postfix,
|
||||
&self
|
||||
.operators
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![current_token.clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
);
|
||||
return rpn.parse();
|
||||
} else if current_token.value == "(" {
|
||||
// push current token to both stacks as it is a legitimate operator
|
||||
// but later we'll need to pop other operators off the stack until we find the matching ')'
|
||||
let rpn = ReversePolishNotation::new(
|
||||
&self.parser.tokens[1..],
|
||||
&self
|
||||
.previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![current_token.clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
&self
|
||||
.operators
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![current_token.clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
);
|
||||
return rpn.parse();
|
||||
} else if current_token.value == ")" && !self.operators.is_empty() {
|
||||
if self.operators[self.operators.len() - 1].value != "(" {
|
||||
// pop operators off the stack and push them to postFix until we find the matching '('
|
||||
let rpn = ReversePolishNotation::new(
|
||||
&self.parser.tokens,
|
||||
&self
|
||||
.previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![self.operators[self.operators.len() - 1].clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
&self.operators[0..self.operators.len() - 1],
|
||||
);
|
||||
return rpn.parse();
|
||||
}
|
||||
|
||||
let rpn = ReversePolishNotation::new(
|
||||
&self.parser.tokens[1..],
|
||||
&self
|
||||
.previous_postfix
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(vec![current_token.clone()])
|
||||
.collect::<Vec<Token>>(),
|
||||
&self.operators[0..self.operators.len() - 1],
|
||||
);
|
||||
return rpn.parse();
|
||||
}
|
||||
|
||||
if is_not_code_token(current_token) {
|
||||
let rpn = ReversePolishNotation::new(&self.parser.tokens[1..], &self.previous_postfix, &self.operators);
|
||||
return rpn.parse();
|
||||
}
|
||||
|
||||
Err(KclError::Syntax(KclErrorDetails {
|
||||
source_ranges: vec![current_token.into()],
|
||||
message: format!(
|
||||
"Unexpected token: {} {:?}",
|
||||
current_token.value, current_token.token_type
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_tree(
|
||||
&mut self,
|
||||
reverse_polish_notation_tokens: &[Token],
|
||||
stack: Vec<MathExpression>,
|
||||
) -> Result<BinaryExpression, KclError> {
|
||||
self.parser = Parser::new(reverse_polish_notation_tokens.to_vec());
|
||||
|
||||
if reverse_polish_notation_tokens.is_empty() {
|
||||
return match &stack[0] {
|
||||
MathExpression::ExtendedBinaryExpression(bin_exp) => Ok(BinaryExpression {
|
||||
@ -313,17 +337,18 @@ fn build_tree(
|
||||
end_extended: None,
|
||||
start_extended: None,
|
||||
})));
|
||||
return build_tree(&reverse_polish_notation_tokens[1..], new_stack);
|
||||
} else if current_token.token_type == TokenType::Word {
|
||||
return self.build_tree(&reverse_polish_notation_tokens[1..], new_stack);
|
||||
} else if current_token.token_type == TokenType::Word || current_token.token_type == TokenType::Keyword {
|
||||
if reverse_polish_notation_tokens.len() > 1 {
|
||||
if reverse_polish_notation_tokens[1].token_type == TokenType::Brace
|
||||
&& reverse_polish_notation_tokens[1].value == "("
|
||||
{
|
||||
let closing_brace = find_closing_brace(reverse_polish_notation_tokens, 1, 0, "")?;
|
||||
let closing_brace = self.parser.find_closing_brace(1, 0, "")?;
|
||||
let mut new_stack = stack;
|
||||
new_stack.push(MathExpression::CallExpression(Box::new(
|
||||
make_call_expression(reverse_polish_notation_tokens, 0)?.expression,
|
||||
self.parser.make_call_expression(0)?.expression,
|
||||
)));
|
||||
return build_tree(&reverse_polish_notation_tokens[closing_brace + 1..], new_stack);
|
||||
return self.build_tree(&reverse_polish_notation_tokens[closing_brace + 1..], new_stack);
|
||||
}
|
||||
let mut new_stack = stack;
|
||||
new_stack.push(MathExpression::Identifier(Box::new(Identifier {
|
||||
@ -331,7 +356,8 @@ fn build_tree(
|
||||
start: current_token.start,
|
||||
end: current_token.end,
|
||||
})));
|
||||
return build_tree(&reverse_polish_notation_tokens[1..], new_stack);
|
||||
return self.build_tree(&reverse_polish_notation_tokens[1..], new_stack);
|
||||
}
|
||||
} else if current_token.token_type == TokenType::Brace && current_token.value == "(" {
|
||||
let mut new_stack = stack;
|
||||
new_stack.push(MathExpression::ParenthesisToken(Box::new(ParenthesisToken {
|
||||
@ -340,7 +366,7 @@ fn build_tree(
|
||||
end: current_token.end,
|
||||
token_type: MathTokenType::Parenthesis,
|
||||
})));
|
||||
return build_tree(&reverse_polish_notation_tokens[1..], new_stack);
|
||||
return self.build_tree(&reverse_polish_notation_tokens[1..], new_stack);
|
||||
} else if current_token.token_type == TokenType::Brace && current_token.value == ")" {
|
||||
let inner_node: MathExpression = match &stack[stack.len() - 1] {
|
||||
MathExpression::ExtendedBinaryExpression(bin_exp) => {
|
||||
@ -405,14 +431,16 @@ fn build_tree(
|
||||
end_extended: Some(current_token.end),
|
||||
}))
|
||||
}
|
||||
MathExpression::ExtendedLiteral(literal) => MathExpression::ExtendedLiteral(Box::new(ExtendedLiteral {
|
||||
MathExpression::ExtendedLiteral(literal) => {
|
||||
MathExpression::ExtendedLiteral(Box::new(ExtendedLiteral {
|
||||
value: literal.value.clone(),
|
||||
start: literal.start,
|
||||
end: literal.end,
|
||||
raw: literal.raw.clone(),
|
||||
end_extended: Some(current_token.end),
|
||||
start_extended: Some(paran.start),
|
||||
})),
|
||||
}))
|
||||
}
|
||||
a => {
|
||||
return Err(KclError::InvalidExpression(KclErrorDetails {
|
||||
source_ranges: vec![current_token.into()],
|
||||
@ -422,8 +450,16 @@ fn build_tree(
|
||||
};
|
||||
let mut new_stack = stack[0..stack.len() - 2].to_vec();
|
||||
new_stack.push(expression);
|
||||
return build_tree(&reverse_polish_notation_tokens[1..], new_stack);
|
||||
return self.build_tree(&reverse_polish_notation_tokens[1..], new_stack);
|
||||
}
|
||||
|
||||
if stack.len() < 2 {
|
||||
return Err(KclError::Syntax(KclErrorDetails {
|
||||
source_ranges: vec![current_token.into()],
|
||||
message: "unexpected end of expression".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
let left: (BinaryPart, usize) = match &stack[stack.len() - 2] {
|
||||
MathExpression::ExtendedBinaryExpression(bin_exp) => (
|
||||
BinaryPart::BinaryExpression(Box::new(BinaryExpression {
|
||||
@ -494,7 +530,12 @@ fn build_tree(
|
||||
};
|
||||
|
||||
let tree = BinaryExpression {
|
||||
operator: current_token.value.clone(),
|
||||
operator: BinaryOperator::from_str(¤t_token.value.clone()).map_err(|err| {
|
||||
KclError::Syntax(KclErrorDetails {
|
||||
source_ranges: vec![current_token.into()],
|
||||
message: format!("{}", err),
|
||||
})
|
||||
})?,
|
||||
start: left.1,
|
||||
end: if right.1 > right_end { right.1 } else { right_end },
|
||||
left: left.0,
|
||||
@ -503,12 +544,24 @@ fn build_tree(
|
||||
let mut new_stack = stack[0..stack.len() - 2].to_vec();
|
||||
new_stack.push(MathExpression::BinaryExpression(Box::new(tree)));
|
||||
|
||||
build_tree(&reverse_polish_notation_tokens[1..], new_stack)
|
||||
self.build_tree(&reverse_polish_notation_tokens[1..], new_stack)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_expression(tokens: &[Token]) -> Result<BinaryExpression, KclError> {
|
||||
let rpn = reverse_polish_notation(tokens, &[], &[])?;
|
||||
let tree_with_maybe_bad_top_level_start_end = build_tree(&rpn, vec![])?;
|
||||
pub struct MathParser {
|
||||
rpn: ReversePolishNotation,
|
||||
}
|
||||
|
||||
impl MathParser {
|
||||
pub fn new(tokens: &[Token]) -> Self {
|
||||
MathParser {
|
||||
rpn: ReversePolishNotation::new(tokens, &[], &[]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(&mut self) -> Result<BinaryExpression, KclError> {
|
||||
let rpn = self.rpn.parse()?;
|
||||
let tree_with_maybe_bad_top_level_start_end = self.rpn.build_tree(&rpn, vec![])?;
|
||||
let left_start = match tree_with_maybe_bad_top_level_start_end.clone().left {
|
||||
BinaryPart::BinaryExpression(bin_exp) => bin_exp.start,
|
||||
BinaryPart::Literal(lit) => lit.start,
|
||||
@ -541,6 +594,7 @@ pub fn parse_expression(tokens: &[Token]) -> Result<BinaryExpression, KclError>
|
||||
operator: tree_with_maybe_bad_top_level_start_end.operator,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
@ -551,11 +605,12 @@ mod test {
|
||||
#[test]
|
||||
fn test_parse_expression() {
|
||||
let tokens = crate::tokeniser::lexer("1 + 2");
|
||||
let result = parse_expression(&tokens).unwrap();
|
||||
let mut parser = MathParser::new(&tokens);
|
||||
let result = parser.parse().unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
BinaryExpression {
|
||||
operator: "+".to_string(),
|
||||
operator: BinaryOperator::Add,
|
||||
start: 0,
|
||||
end: 5,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -577,11 +632,12 @@ mod test {
|
||||
#[test]
|
||||
fn test_parse_expression_plus_followed_by_star() {
|
||||
let tokens = crate::tokeniser::lexer("1 + 2 * 3");
|
||||
let result = parse_expression(&tokens).unwrap();
|
||||
let mut parser = MathParser::new(&tokens);
|
||||
let result = parser.parse().unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
BinaryExpression {
|
||||
operator: "+".to_string(),
|
||||
operator: BinaryOperator::Add,
|
||||
start: 0,
|
||||
end: 9,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -591,7 +647,7 @@ mod test {
|
||||
end: 1,
|
||||
})),
|
||||
right: BinaryPart::BinaryExpression(Box::new(BinaryExpression {
|
||||
operator: "*".to_string(),
|
||||
operator: BinaryOperator::Mul,
|
||||
start: 4,
|
||||
end: 9,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -614,11 +670,12 @@ mod test {
|
||||
#[test]
|
||||
fn test_parse_expression_with_parentheses() {
|
||||
let tokens = crate::tokeniser::lexer("1 * ( 2 + 3 )");
|
||||
let result = parse_expression(&tokens).unwrap();
|
||||
let mut parser = MathParser::new(&tokens);
|
||||
let result = parser.parse().unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
BinaryExpression {
|
||||
operator: "*".to_string(),
|
||||
operator: BinaryOperator::Mul,
|
||||
start: 0,
|
||||
end: 13,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -628,7 +685,7 @@ mod test {
|
||||
end: 1,
|
||||
})),
|
||||
right: BinaryPart::BinaryExpression(Box::new(BinaryExpression {
|
||||
operator: "+".to_string(),
|
||||
operator: BinaryOperator::Add,
|
||||
start: 6,
|
||||
end: 11,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -651,15 +708,16 @@ mod test {
|
||||
#[test]
|
||||
fn test_parse_expression_parens_in_middle() {
|
||||
let tokens = crate::tokeniser::lexer("1 * ( 2 + 3 ) / 4");
|
||||
let result = parse_expression(&tokens).unwrap();
|
||||
let mut parser = MathParser::new(&tokens);
|
||||
let result = parser.parse().unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
BinaryExpression {
|
||||
operator: "/".to_string(),
|
||||
operator: BinaryOperator::Div,
|
||||
start: 0,
|
||||
end: 17,
|
||||
left: BinaryPart::BinaryExpression(Box::new(BinaryExpression {
|
||||
operator: "*".to_string(),
|
||||
operator: BinaryOperator::Mul,
|
||||
start: 0,
|
||||
end: 13,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -669,7 +727,7 @@ mod test {
|
||||
end: 1,
|
||||
})),
|
||||
right: BinaryPart::BinaryExpression(Box::new(BinaryExpression {
|
||||
operator: "+".to_string(),
|
||||
operator: BinaryOperator::Add,
|
||||
start: 6,
|
||||
end: 11,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -699,11 +757,12 @@ mod test {
|
||||
#[test]
|
||||
fn test_parse_expression_parans_and_predence() {
|
||||
let tokens = crate::tokeniser::lexer("1 + ( 2 + 3 ) / 4");
|
||||
let result = parse_expression(&tokens).unwrap();
|
||||
let mut parser = MathParser::new(&tokens);
|
||||
let result = parser.parse().unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
BinaryExpression {
|
||||
operator: "+".to_string(),
|
||||
operator: BinaryOperator::Add,
|
||||
start: 0,
|
||||
end: 17,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -713,11 +772,11 @@ mod test {
|
||||
end: 1,
|
||||
})),
|
||||
right: BinaryPart::BinaryExpression(Box::new(BinaryExpression {
|
||||
operator: "/".to_string(),
|
||||
operator: BinaryOperator::Div,
|
||||
start: 4,
|
||||
end: 17,
|
||||
left: BinaryPart::BinaryExpression(Box::new(BinaryExpression {
|
||||
operator: "+".to_string(),
|
||||
operator: BinaryOperator::Add,
|
||||
start: 6,
|
||||
end: 11,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -746,11 +805,12 @@ mod test {
|
||||
#[test]
|
||||
fn test_parse_expression_nested() {
|
||||
let tokens = crate::tokeniser::lexer("1 * (( 2 + 3 ) / 4 + 5 )");
|
||||
let result = parse_expression(&tokens).unwrap();
|
||||
let mut parser = MathParser::new(&tokens);
|
||||
let result = parser.parse().unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
BinaryExpression {
|
||||
operator: "*".to_string(),
|
||||
operator: BinaryOperator::Mul,
|
||||
start: 0,
|
||||
end: 24,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -760,15 +820,15 @@ mod test {
|
||||
end: 1,
|
||||
})),
|
||||
right: BinaryPart::BinaryExpression(Box::new(BinaryExpression {
|
||||
operator: "+".to_string(),
|
||||
operator: BinaryOperator::Add,
|
||||
start: 5,
|
||||
end: 22,
|
||||
left: BinaryPart::BinaryExpression(Box::new(BinaryExpression {
|
||||
operator: "/".to_string(),
|
||||
operator: BinaryOperator::Div,
|
||||
start: 5,
|
||||
end: 18,
|
||||
left: BinaryPart::BinaryExpression(Box::new(BinaryExpression {
|
||||
operator: "+".to_string(),
|
||||
operator: BinaryOperator::Add,
|
||||
start: 7,
|
||||
end: 12,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -804,11 +864,12 @@ mod test {
|
||||
#[test]
|
||||
fn test_parse_expression_redundant_braces() {
|
||||
let tokens = crate::tokeniser::lexer("1 * ((( 2 + 3 )))");
|
||||
let result = parse_expression(&tokens).unwrap();
|
||||
let mut parser = MathParser::new(&tokens);
|
||||
let result = parser.parse().unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
BinaryExpression {
|
||||
operator: "*".to_string(),
|
||||
operator: BinaryOperator::Mul,
|
||||
start: 0,
|
||||
end: 17,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -818,7 +879,7 @@ mod test {
|
||||
end: 1,
|
||||
})),
|
||||
right: BinaryPart::BinaryExpression(Box::new(BinaryExpression {
|
||||
operator: "+".to_string(),
|
||||
operator: BinaryOperator::Add,
|
||||
start: 8,
|
||||
end: 13,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -840,7 +901,8 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn test_reverse_polish_notation_simple() {
|
||||
let result = reverse_polish_notation(&crate::tokeniser::lexer("1 + 2"), &[], &[]).unwrap();
|
||||
let parser = ReversePolishNotation::new(&crate::tokeniser::lexer("1 + 2"), &[], &[]);
|
||||
let result = parser.parse().unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![
|
||||
@ -868,7 +930,8 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn test_reverse_polish_notation_complex() {
|
||||
let result = reverse_polish_notation(&crate::tokeniser::lexer("1 + 2 * 3"), &[], &[]).unwrap();
|
||||
let parser = ReversePolishNotation::new(&crate::tokeniser::lexer("1 + 2 * 3"), &[], &[]);
|
||||
let result = parser.parse().unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![
|
||||
@ -908,7 +971,8 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn test_reverse_polish_notation_complex_with_parentheses() {
|
||||
let result = reverse_polish_notation(&crate::tokeniser::lexer("1 * ( 2 + 3 )"), &[], &[]).unwrap();
|
||||
let parser = ReversePolishNotation::new(&crate::tokeniser::lexer("1 * ( 2 + 3 )"), &[], &[]);
|
||||
let result = parser.parse().unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![
|
||||
@ -962,11 +1026,12 @@ mod test {
|
||||
fn test_parse_expression_redundant_braces_around_literal() {
|
||||
let code = "2 + (((3)))";
|
||||
let tokens = crate::tokeniser::lexer(code);
|
||||
let result = parse_expression(&tokens).unwrap();
|
||||
let mut parser = MathParser::new(&tokens);
|
||||
let result = parser.parse().unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
BinaryExpression {
|
||||
operator: "+".to_string(),
|
||||
operator: BinaryOperator::Add,
|
||||
start: 0,
|
||||
end: code.find(")))").unwrap() + 3,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -1020,7 +1085,7 @@ mod test {
|
||||
},
|
||||
];
|
||||
let expected_output = BinaryExpression {
|
||||
operator: "+".to_string(),
|
||||
operator: BinaryOperator::Add,
|
||||
start: 0,
|
||||
end: 9,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -1030,7 +1095,7 @@ mod test {
|
||||
end: 1,
|
||||
})),
|
||||
right: BinaryPart::BinaryExpression(Box::new(BinaryExpression {
|
||||
operator: "*".to_string(),
|
||||
operator: BinaryOperator::Mul,
|
||||
start: 4,
|
||||
end: 9,
|
||||
left: BinaryPart::Literal(Box::new(Literal {
|
||||
@ -1047,7 +1112,8 @@ mod test {
|
||||
})),
|
||||
})),
|
||||
};
|
||||
let output = build_tree(&input_tokens, vec![]).unwrap();
|
||||
let mut parser = ReversePolishNotation::new(&[], &[], &[]);
|
||||
let output = parser.build_tree(&input_tokens, vec![]).unwrap();
|
||||
assert_eq!(output, expected_output);
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user