Compare commits
59 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 | |||
1fbd0ad675 | |||
743ea1af4d | |||
2b1a556b81 | |||
853389ba22 | |||
023af60781 | |||
18db6f2dc1 | |||
4afec15323 | |||
152108f7a5 | |||
32d928ae0c | |||
6f0fae625f | |||
9bc47cf14a | |||
eea47aae1e | |||
25b9b4cf98 | |||
0f3f0b3b68 | |||
33eb6126d4 | |||
dccb83f614 | |||
b56a3398ad | |||
11658e2ff5 | |||
de255acc59 | |||
d33ddb2f1b | |||
a0730ded4e | |||
afd2b507ef | |||
8983a8231b | |||
9dd708db5d | |||
c25dd1800c | |||
e56e7ba0fa | |||
dbe4e7faa6 | |||
148e125dd7 | |||
75bb91c7e1 |
@ -1,3 +1,7 @@
|
||||
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_CONNECTION_WEBRTC_REPORT_STATS_MS=0
|
||||
VITE_KC_SENTRY_DSN=
|
||||
|
@ -1,3 +1,7 @@
|
||||
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_SITE_BASE_URL=https://kittycad.io
|
||||
VITE_KC_SKIP_AUTH=false
|
||||
VITE_KC_CONNECTION_TIMEOUT_MS=15000
|
||||
VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS=30000
|
||||
VITE_KC_SENTRY_DSN=https://a814f2f66734989a90367f48feee28ca@o1042111.ingest.sentry.io/4505789425844224
|
||||
|
1
.eslintignore
Normal file
1
.eslintignore
Normal file
@ -0,0 +1 @@
|
||||
src/wasm-lib/*
|
2
.github/workflows/cargo-build.yml
vendored
2
.github/workflows/cargo-build.yml
vendored
@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
dir: ['src/wasm-lib', 'src-tauri']
|
||||
dir: ['src/wasm-lib']
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
|
2
.github/workflows/cargo-clippy.yml
vendored
2
.github/workflows/cargo-clippy.yml
vendored
@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
dir: ['src/wasm-lib', 'src-tauri']
|
||||
dir: ['src/wasm-lib']
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install latest rust
|
||||
|
7
.github/workflows/cargo-test.yml
vendored
7
.github/workflows/cargo-test.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
strategy:
|
||||
matrix:
|
||||
dir: ['src/wasm-lib', 'src-tauri']
|
||||
dir: ['src/wasm-lib']
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install latest rust
|
||||
@ -45,4 +45,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |-
|
||||
cd "${{ matrix.dir }}"
|
||||
cargo llvm-cov nextest --lcov --output-path lcov.info --test-threads=1 --no-fail-fast
|
||||
cargo test --all
|
||||
env:
|
||||
KITTYCAD_API_TOKEN: ${{secrets.KITTYCAD_API_TOKEN}}
|
||||
|
||||
|
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@ -142,11 +142,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": {
|
||||
@ -180,7 +180,7 @@ jobs:
|
||||
uses: google-github-actions/upload-cloud-storage@v1.0.3
|
||||
with:
|
||||
path: artifact
|
||||
glob: '*/kittycad-modeling-app*'
|
||||
glob: '*/*'
|
||||
parent: false
|
||||
destination: dl.kittycad.io/releases/modeling-app/v${{ env.VERSION_NO_V }}
|
||||
|
||||
@ -189,3 +189,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/*/*
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -25,5 +25,6 @@ yarn-error.log*
|
||||
# rust
|
||||
src/wasm-lib/target
|
||||
src/wasm-lib/bindings
|
||||
src/wasm-lib/kcl/bindings
|
||||
public/wasm_lib_bg.wasm
|
||||
src/wasm-lib/lcov.info
|
||||
|
19458
docs/kcl.json
Normal file
19458
docs/kcl.json
Normal file
File diff suppressed because it is too large
Load Diff
3477
docs/kcl.md
Normal file
3477
docs/kcl.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,7 @@
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<script defer data-domain="app.kittycad.io" src="https://plausible.corp.kittycad.io/js/script.js"></script>
|
||||
<title>KittyCAD Modeling App</title>
|
||||
</head>
|
||||
<body class="body-bg">
|
||||
|
24
package.json
24
package.json
@ -1,28 +1,35 @@
|
||||
{
|
||||
"name": "untitled-app",
|
||||
"version": "0.0.4",
|
||||
"version": "0.3.1",
|
||||
"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",
|
||||
"@kittycad/lib": "^0.0.29",
|
||||
"@headlessui/tailwindcss": "^0.2.0",
|
||||
"@kittycad/lib": "^0.0.35",
|
||||
"@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",
|
||||
@ -40,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",
|
||||
@ -54,15 +63,15 @@
|
||||
"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",
|
||||
"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//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\"",
|
||||
"wasm-prep": "rm -rf src/wasm-lib/pkg && mkdir src/wasm-lib/pkg && rm -rf src/wasm-lib/bindings",
|
||||
"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"
|
||||
},
|
||||
@ -89,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",
|
||||
|
77
src-tauri/Cargo.lock
generated
77
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",
|
||||
]
|
||||
|
||||
@ -1628,6 +1651,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "933dca44d65cdd53b355d0b73d380a2ff5da71f87f036053188bf1eab6a19881"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.6.2"
|
||||
@ -2122,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",
|
||||
@ -2695,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",
|
||||
@ -2711,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",
|
||||
@ -3022,6 +3052,7 @@ checksum = "d42ba3a2e8556722f31336a0750c10dbb6a81396a1c452977f515da83f69f842"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"attohttpc",
|
||||
"base64 0.21.2",
|
||||
"cocoa",
|
||||
"dirs-next",
|
||||
"embed_plist",
|
||||
@ -3034,6 +3065,7 @@ dependencies = [
|
||||
"heck 0.4.1",
|
||||
"http",
|
||||
"ignore",
|
||||
"minisign-verify",
|
||||
"objc",
|
||||
"once_cell",
|
||||
"open",
|
||||
@ -3055,19 +3087,21 @@ dependencies = [
|
||||
"tauri-utils",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.39.0",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[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",
|
||||
@ -3078,7 +3112,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"tauri-winres",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3176,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",
|
||||
@ -3397,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",
|
||||
@ -3410,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",
|
||||
@ -4228,3 +4262,14 @@ checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
@ -12,14 +12,14 @@ 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"
|
||||
oauth2 = "4.4.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tauri = { version = "1.3.0", features = [ "path-all", "dialog-all", "fs-all", "http-request", "shell-open", "shell-open-api"] }
|
||||
tauri = { version = "1.3.0", features = [ "updater", "path-all", "dialog-all", "fs-all", "http-request", "shell-open", "shell-open-api"] }
|
||||
tokio = { version = "1.29.1", features = ["time"] }
|
||||
toml = "0.6.0"
|
||||
tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
|
@ -7,8 +7,8 @@
|
||||
"distDir": "../build"
|
||||
},
|
||||
"package": {
|
||||
"productName": "kittycad-modeling-app",
|
||||
"version": "0.0.4"
|
||||
"productName": "kittycad-modeling",
|
||||
"version": "0.3.1"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
@ -2,7 +2,8 @@ import { render, screen } from '@testing-library/react'
|
||||
import { App } from './App'
|
||||
import { describe, test, vi } from 'vitest'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { GlobalStateProvider } from './hooks/useAuthMachine'
|
||||
import { GlobalStateProvider } from './components/GlobalStateProvider'
|
||||
import CommandBarProvider from 'components/CommandBar'
|
||||
|
||||
let listener: ((rect: any) => void) | undefined = undefined
|
||||
;(global as any).ResizeObserver = class ResizeObserver {
|
||||
@ -43,7 +44,9 @@ function TestWrap({ children }: { children: React.ReactNode }) {
|
||||
// wrap in router and xState context
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<GlobalStateProvider>{children}</GlobalStateProvider>
|
||||
<CommandBarProvider>
|
||||
<GlobalStateProvider>{children}</GlobalStateProvider>
|
||||
</CommandBarProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
176
src/App.tsx
176
src/App.tsx
@ -11,20 +11,22 @@ 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 { ViewUpdate, EditorView } from '@codemirror/view'
|
||||
import {
|
||||
lineHighlightField,
|
||||
addLineHighlight,
|
||||
} from './editor/highlightextension'
|
||||
import { PaneType, Selections, Themes, useStore } from './useStore'
|
||||
import { PaneType, Selections, useStore } from './useStore'
|
||||
import Server from './editor/lsp/server'
|
||||
import Client from './editor/lsp/client'
|
||||
import { Logs, KCLErrors } from './components/Logs'
|
||||
import { CollapsiblePanel } from './components/CollapsiblePanel'
|
||||
import { MemoryPanel } from './components/MemoryPanel'
|
||||
import { useHotKeyListener } from './hooks/useHotKeyListener'
|
||||
import { Stream } from './components/Stream'
|
||||
import ModalContainer from 'react-modal-promise'
|
||||
import { FromServer, IntoServer } from './editor/lsp/codec'
|
||||
import {
|
||||
EngineCommand,
|
||||
EngineCommandManager,
|
||||
@ -41,14 +43,17 @@ import {
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { TEST } from './env'
|
||||
import { getNormalisedCoordinates } from './lib/utils'
|
||||
import { getSystemTheme } from './lib/getSystemTheme'
|
||||
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 { IndexLoaderData } from './Router'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { useAuthMachine } from './hooks/useAuthMachine'
|
||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||
import { onboardingPaths } from 'routes/Onboarding'
|
||||
import { LanguageServerClient } from 'editor/lsp'
|
||||
import kclLanguage from 'editor/lsp/language'
|
||||
|
||||
export function App() {
|
||||
const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
|
||||
@ -79,15 +84,12 @@ export function App() {
|
||||
setMediaStream,
|
||||
setIsStreamReady,
|
||||
isStreamReady,
|
||||
isLSPServerReady,
|
||||
setIsLSPServerReady,
|
||||
isMouseDownInStream,
|
||||
cmdId,
|
||||
setCmdId,
|
||||
formatCode,
|
||||
debugPanel,
|
||||
theme,
|
||||
openPanes,
|
||||
setOpenPanes,
|
||||
onboardingStatus,
|
||||
didDragInStream,
|
||||
setDidDragInStream,
|
||||
setStreamDimensions,
|
||||
@ -118,22 +120,27 @@ export function App() {
|
||||
setMediaStream: s.setMediaStream,
|
||||
isStreamReady: s.isStreamReady,
|
||||
setIsStreamReady: s.setIsStreamReady,
|
||||
isLSPServerReady: s.isLSPServerReady,
|
||||
setIsLSPServerReady: s.setIsLSPServerReady,
|
||||
isMouseDownInStream: s.isMouseDownInStream,
|
||||
cmdId: s.cmdId,
|
||||
setCmdId: s.setCmdId,
|
||||
formatCode: s.formatCode,
|
||||
debugPanel: s.debugPanel,
|
||||
addKCLError: s.addKCLError,
|
||||
theme: s.theme,
|
||||
openPanes: s.openPanes,
|
||||
setOpenPanes: s.setOpenPanes,
|
||||
onboardingStatus: s.onboardingStatus,
|
||||
didDragInStream: s.didDragInStream,
|
||||
setDidDragInStream: s.setDidDragInStream,
|
||||
setStreamDimensions: s.setStreamDimensions,
|
||||
streamDimensions: s.streamDimensions,
|
||||
}))
|
||||
const [token] = useAuthMachine((s) => s?.context?.token)
|
||||
|
||||
const {
|
||||
auth: {
|
||||
context: { token },
|
||||
},
|
||||
settings: {
|
||||
context: { showDebugPanel, theme, onboardingStatus },
|
||||
},
|
||||
} = useGlobalStateContext()
|
||||
|
||||
const editorTheme = theme === Themes.System ? getSystemTheme() : theme
|
||||
|
||||
@ -152,7 +159,7 @@ export function App() {
|
||||
useHotkeys('shift + d', () => togglePane('debug'))
|
||||
|
||||
const paneOpacity =
|
||||
onboardingStatus === 'camera'
|
||||
onboardingStatus === onboardingPaths.CAMERA
|
||||
? 'opacity-20'
|
||||
: didDragInStream
|
||||
? 'opacity-40'
|
||||
@ -246,13 +253,12 @@ export function App() {
|
||||
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(() => {
|
||||
@ -276,6 +282,8 @@ export function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreamReady) return
|
||||
if (!engineCommandManager) return
|
||||
let unsubFn: any[] = []
|
||||
const asyncWrap = async () => {
|
||||
try {
|
||||
if (!code) {
|
||||
@ -286,27 +294,12 @@ export function App() {
|
||||
setAst(_ast)
|
||||
resetLogs()
|
||||
resetKCLErrors()
|
||||
if (engineCommandManager) {
|
||||
engineCommandManager.endSession()
|
||||
engineCommandManager.startNewSession()
|
||||
}
|
||||
if (!engineCommandManager) return
|
||||
engineCommandManager.endSession()
|
||||
engineCommandManager.startNewSession()
|
||||
const programMemory = await _executor(
|
||||
_ast,
|
||||
{
|
||||
root: {
|
||||
log: {
|
||||
type: 'userVal',
|
||||
value: (a: any) => {
|
||||
addLog(a)
|
||||
},
|
||||
__meta: [
|
||||
{
|
||||
pathToNode: [],
|
||||
sourceRange: [0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
_0: {
|
||||
type: 'userVal',
|
||||
value: 0,
|
||||
@ -328,33 +321,37 @@ export function App() {
|
||||
__meta: [],
|
||||
},
|
||||
},
|
||||
pendingMemory: {},
|
||||
},
|
||||
engineCommandManager,
|
||||
{ bodyType: 'root' },
|
||||
[]
|
||||
engineCommandManager
|
||||
)
|
||||
|
||||
const { artifactMap, sourceRangeMap } =
|
||||
await engineCommandManager.waitForAllCommands()
|
||||
|
||||
setArtifactMap({ artifactMap, sourceRangeMap })
|
||||
engineCommandManager.onHover((id) => {
|
||||
if (!id) {
|
||||
setHighlightRange([0, 0])
|
||||
} else {
|
||||
const sourceRange = sourceRangeMap[id]
|
||||
setHighlightRange(sourceRange)
|
||||
}
|
||||
const unSubHover = engineCommandManager.subscribeToUnreliable({
|
||||
event: 'highlight_set_entity',
|
||||
callback: ({ data }) => {
|
||||
if (!data?.entity_id) {
|
||||
setHighlightRange([0, 0])
|
||||
} else {
|
||||
const sourceRange = sourceRangeMap[data.entity_id]
|
||||
setHighlightRange(sourceRange)
|
||||
}
|
||||
},
|
||||
})
|
||||
engineCommandManager.onClick((selections) => {
|
||||
if (!selections) {
|
||||
setCursor2()
|
||||
return
|
||||
}
|
||||
const { id, type } = selections
|
||||
setCursor2({ range: sourceRangeMap[id], type })
|
||||
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)
|
||||
if (programMemory !== undefined) {
|
||||
setProgramMemory(programMemory)
|
||||
}
|
||||
@ -371,7 +368,10 @@ export function App() {
|
||||
}
|
||||
}
|
||||
asyncWrap()
|
||||
}, [code, isStreamReady])
|
||||
return () => {
|
||||
unsubFn.forEach((fn) => fn())
|
||||
}
|
||||
}, [code, isStreamReady, engineCommandManager])
|
||||
|
||||
const debounceSocketSend = throttle<EngineCommand>((message) => {
|
||||
engineCommandManager?.sendSceneCommand(message)
|
||||
@ -399,9 +399,8 @@ export function App() {
|
||||
const interaction = ctrlKey ? 'zoom' : shiftKey ? 'pan' : 'rotate'
|
||||
|
||||
const newCmdId = uuidv4()
|
||||
setCmdId(newCmdId)
|
||||
|
||||
if (cmdId && isMouseDownInStream) {
|
||||
if (isMouseDownInStream) {
|
||||
debounceSocketSend({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
@ -430,9 +429,49 @@ export function App() {
|
||||
linter((_view) => {
|
||||
return kclErrToDiagnostic(useStore.getState().kclErrors)
|
||||
}),
|
||||
EditorView.lineWrapping,
|
||||
]
|
||||
}, [])
|
||||
|
||||
// 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])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-screen overflow-hidden relative flex flex-col cursor-pointer select-none"
|
||||
@ -474,7 +513,7 @@ export function App() {
|
||||
<CollapsiblePanel
|
||||
title="Code"
|
||||
icon={faCode}
|
||||
className="open:!mb-2"
|
||||
className="open:!mb-2 overflow-x-hidden"
|
||||
open={openPanes.includes('code')}
|
||||
>
|
||||
<div className="px-2 py-1">
|
||||
@ -486,15 +525,18 @@ export function App() {
|
||||
format
|
||||
</button>
|
||||
</div>
|
||||
<div id="code-mirror-override">
|
||||
<div
|
||||
id="code-mirror-override"
|
||||
className="overflow-x-hidden h-full"
|
||||
>
|
||||
<CodeMirror
|
||||
className="h-full"
|
||||
className="h-full overflow-hidden-x"
|
||||
value={code}
|
||||
extensions={[
|
||||
langs.javascript({ jsx: true }),
|
||||
lineHighlightField,
|
||||
...extraExtensions,
|
||||
]}
|
||||
extensions={
|
||||
kclLSP
|
||||
? [kclLSP, lineHighlightField, ...extraExtensions]
|
||||
: [lineHighlightField, ...extraExtensions]
|
||||
}
|
||||
onChange={onChange}
|
||||
onUpdate={onUpdate}
|
||||
theme={editorTheme}
|
||||
@ -525,7 +567,7 @@ export function App() {
|
||||
</div>
|
||||
</Resizable>
|
||||
<Stream className="absolute inset-0 z-0" />
|
||||
{debugPanel && (
|
||||
{showDebugPanel && (
|
||||
<DebugPanel
|
||||
title="Debug"
|
||||
className={
|
||||
|
@ -1,9 +1,12 @@
|
||||
import Loading from './components/Loading'
|
||||
import { useAuthMachine } from './hooks/useAuthMachine'
|
||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||
|
||||
// Wrapper around protected routes, used in src/Router.tsx
|
||||
export const Auth = ({ children }: React.PropsWithChildren) => {
|
||||
const [isLoggedIn] = useAuthMachine((s) => s.matches('checkIfLoggedIn'))
|
||||
const {
|
||||
auth: { state },
|
||||
} = useGlobalStateContext()
|
||||
const isLoggedIn = state.matches('checkIfLoggedIn')
|
||||
|
||||
return isLoggedIn ? (
|
||||
<Loading>Loading KittyCAD Modeling App...</Loading>
|
||||
|
112
src/Router.tsx
112
src/Router.tsx
@ -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, {
|
||||
@ -24,7 +31,47 @@ import {
|
||||
} from './lib/tauriFS'
|
||||
import { metadata, type Metadata } from 'tauri-plugin-fs-extra-api'
|
||||
import DownloadAppBanner from './components/DownloadAppBanner'
|
||||
import { GlobalStateProvider } from './hooks/useAuthMachine'
|
||||
import { GlobalStateProvider } from './components/GlobalStateProvider'
|
||||
import {
|
||||
SETTINGS_PERSIST_KEY,
|
||||
settingsMachine,
|
||||
} 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) => {
|
||||
@ -68,7 +115,11 @@ const addGlobalContextToElements = (
|
||||
'element' in route
|
||||
? {
|
||||
...route,
|
||||
element: <GlobalStateProvider>{route.element}</GlobalStateProvider>,
|
||||
element: (
|
||||
<CommandBarProvider>
|
||||
<GlobalStateProvider>{route.element}</GlobalStateProvider>
|
||||
</CommandBarProvider>
|
||||
),
|
||||
}
|
||||
: route
|
||||
)
|
||||
@ -95,26 +146,25 @@ const router = createBrowserRouter(
|
||||
request,
|
||||
params,
|
||||
}): Promise<IndexLoaderData | Response> => {
|
||||
const store = localStorage.getItem('store')
|
||||
if (store === null) {
|
||||
return redirect(paths.ONBOARDING.INDEX)
|
||||
} else {
|
||||
const status = JSON.parse(store).state.onboardingStatus || ''
|
||||
const notEnRouteToOnboarding =
|
||||
!request.url.includes(paths.ONBOARDING.INDEX) &&
|
||||
request.method === 'GET'
|
||||
// '' is the initial state, 'done' and 'dismissed' are the final states
|
||||
const hasValidOnboardingStatus =
|
||||
(status !== undefined && status.length === 0) ||
|
||||
!(status === 'done' || status === 'dismissed')
|
||||
const shouldRedirectToOnboarding =
|
||||
notEnRouteToOnboarding && hasValidOnboardingStatus
|
||||
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
|
||||
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
|
||||
ContextFrom<typeof settingsMachine>
|
||||
>
|
||||
|
||||
if (shouldRedirectToOnboarding) {
|
||||
return redirect(
|
||||
makeUrlPathRelative(paths.ONBOARDING.INDEX) + status
|
||||
)
|
||||
}
|
||||
const status = persistedSettings.onboardingStatus || ''
|
||||
const notEnRouteToOnboarding = !request.url.includes(
|
||||
paths.ONBOARDING.INDEX
|
||||
)
|
||||
// '' is the initial state, 'done' and 'dismissed' are the final states
|
||||
const hasValidOnboardingStatus =
|
||||
status.length === 0 || !(status === 'done' || status === 'dismissed')
|
||||
const shouldRedirectToOnboarding =
|
||||
notEnRouteToOnboarding && hasValidOnboardingStatus
|
||||
|
||||
if (shouldRedirectToOnboarding) {
|
||||
return redirect(
|
||||
makeUrlPathRelative(paths.ONBOARDING.INDEX) + status.slice(1)
|
||||
)
|
||||
}
|
||||
|
||||
if (params.id && params.id !== 'new') {
|
||||
@ -164,9 +214,23 @@ const router = createBrowserRouter(
|
||||
if (!isTauri()) {
|
||||
return redirect(paths.FILE + '/new')
|
||||
}
|
||||
|
||||
const projectDir = await initializeProjectDirectory()
|
||||
const projectsNoMeta = (await readDir(projectDir.dir)).filter(
|
||||
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
|
||||
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
|
||||
ContextFrom<typeof settingsMachine>
|
||||
>
|
||||
const projectDir = await initializeProjectDirectory(
|
||||
persistedSettings.defaultDirectory || ''
|
||||
)
|
||||
if (projectDir !== persistedSettings.defaultDirectory) {
|
||||
localStorage.setItem(
|
||||
SETTINGS_PERSIST_KEY,
|
||||
JSON.stringify({
|
||||
...persistedSettings,
|
||||
defaultDirectory: projectDir,
|
||||
})
|
||||
)
|
||||
}
|
||||
const projectsNoMeta = (await readDir(projectDir)).filter(
|
||||
isProjectDirectory
|
||||
)
|
||||
const projects = await Promise.all(
|
||||
|
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;
|
||||
}
|
310
src/Toolbar.tsx
310
src/Toolbar.tsx
@ -11,6 +11,11 @@ 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'
|
||||
|
||||
export const Toolbar = () => {
|
||||
const {
|
||||
@ -29,72 +34,26 @@ export const Toolbar = () => {
|
||||
programMemory: s.programMemory,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div>
|
||||
{guiMode.mode === 'default' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setGuiMode({
|
||||
mode: 'sketch',
|
||||
sketchMode: 'selectFace',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Start Sketch
|
||||
</button>
|
||||
)}
|
||||
{guiMode.mode === 'canEditExtrude' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!ast) return
|
||||
const pathToNode = getNodePathFromSourceRange(
|
||||
ast,
|
||||
selectionRanges.codeBasedSelections[0].range
|
||||
)
|
||||
const { modifiedAst } = sketchOnExtrudedFace(
|
||||
ast,
|
||||
pathToNode,
|
||||
programMemory
|
||||
)
|
||||
updateAst(modifiedAst)
|
||||
}}
|
||||
>
|
||||
SketchOnFace
|
||||
</button>
|
||||
)}
|
||||
{(guiMode.mode === 'canEditSketch' || false) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setGuiMode({
|
||||
mode: 'sketch',
|
||||
sketchMode: 'sketchEdit',
|
||||
pathToNode: guiMode.pathToNode,
|
||||
rotation: guiMode.rotation,
|
||||
position: guiMode.position,
|
||||
})
|
||||
}}
|
||||
>
|
||||
Edit Sketch
|
||||
</button>
|
||||
)}
|
||||
{guiMode.mode === 'canEditSketch' && (
|
||||
<>
|
||||
useEffect(() => {
|
||||
console.log('guiMode', guiMode)
|
||||
}, [guiMode])
|
||||
|
||||
function ToolbarButtons() {
|
||||
return (
|
||||
<>
|
||||
{guiMode.mode === 'default' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!ast) return
|
||||
const pathToNode = getNodePathFromSourceRange(
|
||||
ast,
|
||||
selectionRanges.codeBasedSelections[0].range
|
||||
)
|
||||
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
|
||||
ast,
|
||||
pathToNode
|
||||
)
|
||||
updateAst(modifiedAst, { focusPath: pathToExtrudeArg })
|
||||
setGuiMode({
|
||||
mode: 'sketch',
|
||||
sketchMode: 'selectFace',
|
||||
})
|
||||
}}
|
||||
>
|
||||
ExtrudeSketch
|
||||
Start Sketch
|
||||
</button>
|
||||
)}
|
||||
{guiMode.mode === 'canEditExtrude' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!ast) return
|
||||
@ -102,77 +61,182 @@ export const Toolbar = () => {
|
||||
ast,
|
||||
selectionRanges.codeBasedSelections[0].range
|
||||
)
|
||||
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
|
||||
const { modifiedAst } = sketchOnExtrudedFace(
|
||||
ast,
|
||||
pathToNode,
|
||||
false
|
||||
programMemory
|
||||
)
|
||||
updateAst(modifiedAst, { focusPath: pathToExtrudeArg })
|
||||
updateAst(modifiedAst)
|
||||
}}
|
||||
>
|
||||
ExtrudeSketch (w/o pipe)
|
||||
SketchOnFace
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{guiMode.mode === 'sketch' && (
|
||||
<button onClick={() => setGuiMode({ mode: 'default' })}>
|
||||
Exit sketch
|
||||
</button>
|
||||
)}
|
||||
{toolTips
|
||||
.filter(
|
||||
// (sketchFnName) => !['angledLineThatIntersects'].includes(sketchFnName)
|
||||
(sketchFnName) => ['line'].includes(sketchFnName)
|
||||
)
|
||||
.map((sketchFnName) => {
|
||||
if (
|
||||
guiMode.mode !== 'sketch' ||
|
||||
!('isTooltip' in guiMode || guiMode.sketchMode === 'sketchEdit')
|
||||
)
|
||||
return null
|
||||
return (
|
||||
)}
|
||||
{(guiMode.mode === 'canEditSketch' || false) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setGuiMode({
|
||||
mode: 'sketch',
|
||||
sketchMode: 'sketchEdit',
|
||||
pathToNode: guiMode.pathToNode,
|
||||
rotation: guiMode.rotation,
|
||||
position: guiMode.position,
|
||||
})
|
||||
}}
|
||||
>
|
||||
Edit Sketch
|
||||
</button>
|
||||
)}
|
||||
{guiMode.mode === 'canEditSketch' && (
|
||||
<>
|
||||
<button
|
||||
key={sketchFnName}
|
||||
onClick={() =>
|
||||
setGuiMode({
|
||||
...guiMode,
|
||||
...(guiMode.sketchMode === sketchFnName
|
||||
? {
|
||||
sketchMode: 'sketchEdit',
|
||||
// todo: ...guiMod is adding isTooltip: true, will probably just fix with xstate migtaion
|
||||
}
|
||||
: {
|
||||
sketchMode: sketchFnName,
|
||||
isTooltip: true,
|
||||
}),
|
||||
})
|
||||
}
|
||||
onClick={() => {
|
||||
if (!ast) return
|
||||
const pathToNode = getNodePathFromSourceRange(
|
||||
ast,
|
||||
selectionRanges.codeBasedSelections[0].range
|
||||
)
|
||||
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
|
||||
ast,
|
||||
pathToNode
|
||||
)
|
||||
updateAst(modifiedAst, { focusPath: pathToExtrudeArg })
|
||||
}}
|
||||
>
|
||||
{sketchFnName}
|
||||
{guiMode.sketchMode === sketchFnName && '✅'}
|
||||
ExtrudeSketch
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!ast) return
|
||||
const pathToNode = getNodePathFromSourceRange(
|
||||
ast,
|
||||
selectionRanges.codeBasedSelections[0].range
|
||||
)
|
||||
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
|
||||
ast,
|
||||
pathToNode,
|
||||
false
|
||||
)
|
||||
updateAst(modifiedAst, { focusPath: pathToExtrudeArg })
|
||||
}}
|
||||
>
|
||||
ExtrudeSketch (w/o pipe)
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{guiMode.mode === 'sketch' && (
|
||||
<button onClick={() => setGuiMode({ mode: 'default' })}>
|
||||
Exit sketch
|
||||
</button>
|
||||
)}
|
||||
{toolTips
|
||||
.filter(
|
||||
// (sketchFnName) => !['angledLineThatIntersects'].includes(sketchFnName)
|
||||
(sketchFnName) => ['line'].includes(sketchFnName)
|
||||
)
|
||||
})}
|
||||
<br></br>
|
||||
<ConvertToVariable />
|
||||
<HorzVert horOrVert="horizontal" />
|
||||
<HorzVert horOrVert="vertical" />
|
||||
<EqualLength />
|
||||
<EqualAngle />
|
||||
<SetHorzVertDistance buttonType="alignEndsVertically" />
|
||||
<SetHorzVertDistance buttonType="setHorzDistance" />
|
||||
<SetAbsDistance buttonType="snapToYAxis" />
|
||||
<SetAbsDistance buttonType="xAbs" />
|
||||
<SetHorzVertDistance buttonType="alignEndsHorizontally" />
|
||||
<SetAbsDistance buttonType="snapToXAxis" />
|
||||
<SetHorzVertDistance buttonType="setVertDistance" />
|
||||
<SetAbsDistance buttonType="yAbs" />
|
||||
<SetAngleLength angleOrLength="setAngle" />
|
||||
<SetAngleLength angleOrLength="setLength" />
|
||||
<Intersect />
|
||||
<RemoveConstrainingValues />
|
||||
<SetAngleBetween />
|
||||
</div>
|
||||
.map((sketchFnName) => {
|
||||
if (
|
||||
guiMode.mode !== 'sketch' ||
|
||||
!('isTooltip' in guiMode || guiMode.sketchMode === 'sketchEdit')
|
||||
)
|
||||
return null
|
||||
return (
|
||||
<button
|
||||
key={sketchFnName}
|
||||
onClick={() =>
|
||||
setGuiMode({
|
||||
...guiMode,
|
||||
...(guiMode.sketchMode === sketchFnName
|
||||
? {
|
||||
sketchMode: 'sketchEdit',
|
||||
// todo: ...guiMod is adding isTooltip: true, will probably just fix with xstate migtaion
|
||||
}
|
||||
: {
|
||||
sketchMode: sketchFnName,
|
||||
isTooltip: true,
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
{sketchFnName}
|
||||
{guiMode.sketchMode === sketchFnName && '✅'}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<ConvertToVariable />
|
||||
<HorzVert horOrVert="horizontal" />
|
||||
<HorzVert horOrVert="vertical" />
|
||||
<EqualLength />
|
||||
<EqualAngle />
|
||||
<SetHorzVertDistance buttonType="alignEndsVertically" />
|
||||
<SetHorzVertDistance buttonType="setHorzDistance" />
|
||||
<SetAbsDistance buttonType="snapToYAxis" />
|
||||
<SetAbsDistance buttonType="xAbs" />
|
||||
<SetHorzVertDistance buttonType="alignEndsHorizontally" />
|
||||
<SetAbsDistance buttonType="snapToXAxis" />
|
||||
<SetHorzVertDistance buttonType="setVertDistance" />
|
||||
<SetAbsDistance buttonType="yAbs" />
|
||||
<SetAngleLength angleOrLength="setAngle" />
|
||||
<SetAngleLength angleOrLength="setLength" />
|
||||
<Intersect />
|
||||
<RemoveConstrainingValues />
|
||||
<SetAngleBetween />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
@ -8,11 +8,13 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
const iconSizes = {
|
||||
sm: 12,
|
||||
md: 14.4,
|
||||
lg: 18,
|
||||
lg: 20,
|
||||
xl: 28,
|
||||
}
|
||||
|
||||
export interface ActionIconProps extends React.PropsWithChildren {
|
||||
icon?: SolidIconDefinition | BrandIconDefinition
|
||||
className?: string
|
||||
bgClassName?: string
|
||||
iconClassName?: string
|
||||
size?: keyof typeof iconSizes
|
||||
@ -20,6 +22,7 @@ export interface ActionIconProps extends React.PropsWithChildren {
|
||||
|
||||
export const ActionIcon = ({
|
||||
icon = faCircleExclamation,
|
||||
className,
|
||||
bgClassName,
|
||||
iconClassName,
|
||||
size = 'md',
|
||||
@ -28,7 +31,9 @@ export const ActionIcon = ({
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'p-1 w-fit inline-grid place-content-center ' +
|
||||
`p-${
|
||||
size === 'xl' ? '2' : '1'
|
||||
} w-fit inline-grid place-content-center ${className} ` +
|
||||
(bgClassName ||
|
||||
'bg-chalkboard-100 group-hover:bg-chalkboard-90 hover:bg-chalkboard-90 dark:bg-liquid-20 dark:group-hover:bg-liquid-10 dark:hover:bg-liquid-10')
|
||||
}
|
||||
@ -40,7 +45,7 @@ export const ActionIcon = ({
|
||||
height={iconSizes[size]}
|
||||
className={
|
||||
iconClassName ||
|
||||
'text-liquid-20 group-hover:text-liquid-10 hover:text-liquid-10 dark:text-liquid-100 dark:group-hover:text-liquid-100 dark:hover:text-liquid-100'
|
||||
'text-liquid-20 h-auto group-hover:text-liquid-10 hover:text-liquid-10 dark:text-liquid-100 dark:group-hover:text-liquid-100 dark:hover:text-liquid-100'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
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;
|
||||
}
|
@ -2,7 +2,8 @@ import { Toolbar } from '../Toolbar'
|
||||
import UserSidebarMenu from './UserSidebarMenu'
|
||||
import { ProjectWithEntryPointMetadata } from '../Router'
|
||||
import ProjectSidebarMenu from './ProjectSidebarMenu'
|
||||
import { useAuthMachine } from '../hooks/useAuthMachine'
|
||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||
import styles from './AppHeader.module.css'
|
||||
|
||||
interface AppHeaderProps extends React.PropsWithChildren {
|
||||
showToolbar?: boolean
|
||||
@ -18,12 +19,18 @@ export const AppHeader = ({
|
||||
className = '',
|
||||
enableMenu = false,
|
||||
}: AppHeaderProps) => {
|
||||
const [user] = useAuthMachine((s) => s?.context?.user)
|
||||
const {
|
||||
auth: {
|
||||
context: { user },
|
||||
},
|
||||
} = useGlobalStateContext()
|
||||
|
||||
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
|
||||
}
|
||||
>
|
||||
@ -35,7 +42,11 @@ export const AppHeader = ({
|
||||
</div>
|
||||
)}
|
||||
{/* If there are children, show them, otherwise show User menu */}
|
||||
{children || <UserSidebarMenu user={user} />}
|
||||
{children || (
|
||||
<div className="ml-auto">
|
||||
<UserSidebarMenu user={user} />
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
.panel {
|
||||
@apply relative overflow-auto z-0;
|
||||
@apply bg-chalkboard-20/40;
|
||||
@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 {
|
||||
|
290
src/components/CommandBar.tsx
Normal file
290
src/components/CommandBar.tsx
Normal file
@ -0,0 +1,290 @@
|
||||
import { Combobox, Dialog, Transition } from '@headlessui/react'
|
||||
import {
|
||||
Dispatch,
|
||||
Fragment,
|
||||
SetStateAction,
|
||||
createContext,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { ActionIcon } from './ActionIcon'
|
||||
import { faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||
import Fuse from 'fuse.js'
|
||||
import { Command, SubCommand } from '../lib/commands'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
|
||||
export type SortedCommand = {
|
||||
item: Partial<Command | SubCommand> & { name: string }
|
||||
}
|
||||
|
||||
export const CommandsContext = createContext(
|
||||
{} as {
|
||||
commands: Command[]
|
||||
addCommands: (commands: Command[]) => void
|
||||
removeCommands: (commands: Command[]) => void
|
||||
commandBarOpen: boolean
|
||||
setCommandBarOpen: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
)
|
||||
|
||||
export const CommandBarProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const [commands, internalSetCommands] = useState([] as Command[])
|
||||
const [commandBarOpen, setCommandBarOpen] = useState(false)
|
||||
|
||||
const addCommands = (newCommands: Command[]) => {
|
||||
internalSetCommands((prevCommands) => [...newCommands, ...prevCommands])
|
||||
}
|
||||
const removeCommands = (newCommands: Command[]) => {
|
||||
internalSetCommands((prevCommands) =>
|
||||
prevCommands.filter((command) => !newCommands.includes(command))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandsContext.Provider
|
||||
value={{
|
||||
commands,
|
||||
addCommands,
|
||||
removeCommands,
|
||||
commandBarOpen,
|
||||
setCommandBarOpen,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<CommandBar />
|
||||
</CommandsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandBar = () => {
|
||||
const { commands, commandBarOpen, setCommandBarOpen } = useCommandsContext()
|
||||
useHotkeys('meta+k', () => {
|
||||
if (commands.length === 0) return
|
||||
setCommandBarOpen(!commandBarOpen)
|
||||
})
|
||||
|
||||
const [selectedCommand, setSelectedCommand] = useState<SortedCommand | null>(
|
||||
null
|
||||
)
|
||||
// keep track of the current subcommand index
|
||||
const [subCommandIndex, setSubCommandIndex] = useState<number>()
|
||||
const [subCommandData, setSubCommandData] = useState<{
|
||||
[key: string]: string
|
||||
}>({})
|
||||
|
||||
// if the subcommand index is null, we're not in a subcommand
|
||||
const inSubCommand =
|
||||
selectedCommand &&
|
||||
'meta' in selectedCommand.item &&
|
||||
selectedCommand.item.meta?.args !== undefined &&
|
||||
subCommandIndex !== undefined
|
||||
const currentSubCommand =
|
||||
inSubCommand && 'meta' in selectedCommand.item
|
||||
? selectedCommand.item.meta?.args[subCommandIndex]
|
||||
: undefined
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const availableCommands =
|
||||
inSubCommand && currentSubCommand
|
||||
? currentSubCommand.type === 'string'
|
||||
? query
|
||||
? [{ name: query }]
|
||||
: currentSubCommand.options
|
||||
: currentSubCommand.options
|
||||
: commands
|
||||
|
||||
const fuse = new Fuse(availableCommands || [], {
|
||||
keys: ['name', 'description'],
|
||||
})
|
||||
|
||||
const filteredCommands = query
|
||||
? fuse.search(query)
|
||||
: availableCommands?.map((c) => ({ item: c } as SortedCommand))
|
||||
|
||||
function clearState() {
|
||||
setQuery('')
|
||||
setCommandBarOpen(false)
|
||||
setSelectedCommand(null)
|
||||
setSubCommandIndex(undefined)
|
||||
setSubCommandData({})
|
||||
}
|
||||
|
||||
function handleCommandSelection(entry: SortedCommand) {
|
||||
// If we have subcommands and have not yet gathered all the
|
||||
// data required from them, set the selected command to the
|
||||
// current command and increment the subcommand index
|
||||
if (selectedCommand === null && 'meta' in entry.item && entry.item.meta) {
|
||||
setSelectedCommand(entry)
|
||||
setSubCommandIndex(0)
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
|
||||
const { item } = entry
|
||||
// If we have just selected a command with no subcommands, run it
|
||||
const isCommandWithoutSubcommands =
|
||||
'callback' in item && !('meta' in item && item.meta)
|
||||
if (isCommandWithoutSubcommands) {
|
||||
if (item.callback === undefined) return
|
||||
item.callback()
|
||||
setCommandBarOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
// If we have subcommands and have not yet gathered all the
|
||||
// data required from them, set the selected command to the
|
||||
// current command and increment the subcommand index
|
||||
if (
|
||||
selectedCommand &&
|
||||
subCommandIndex !== undefined &&
|
||||
'meta' in selectedCommand.item
|
||||
) {
|
||||
const subCommand = selectedCommand.item.meta?.args[subCommandIndex]
|
||||
|
||||
if (subCommand) {
|
||||
const newSubCommandData = {
|
||||
...subCommandData,
|
||||
[subCommand.name]: item.name,
|
||||
}
|
||||
const newSubCommandIndex = subCommandIndex + 1
|
||||
|
||||
// If we have subcommands and have gathered all the data required
|
||||
// from them, run the command with the gathered data
|
||||
if (
|
||||
selectedCommand.item.callback &&
|
||||
selectedCommand.item.meta?.args.length === newSubCommandIndex
|
||||
) {
|
||||
selectedCommand.item.callback(newSubCommandData)
|
||||
setCommandBarOpen(false)
|
||||
} else {
|
||||
// Otherwise, set the subcommand data and increment the subcommand index
|
||||
setSubCommandData(newSubCommandData)
|
||||
setSubCommandIndex(newSubCommandIndex)
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplayValue(command: Command) {
|
||||
if (command.meta?.displayValue === undefined || !command.meta.args)
|
||||
return command.name
|
||||
return command.meta?.displayValue(
|
||||
command.meta.args.map((c) =>
|
||||
subCommandData[c.name] ? subCommandData[c.name] : `<${c.name}>`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition.Root
|
||||
show={
|
||||
commandBarOpen &&
|
||||
availableCommands?.length !== undefined &&
|
||||
availableCommands.length > 0
|
||||
}
|
||||
as={Fragment}
|
||||
afterLeave={() => clearState()}
|
||||
>
|
||||
<Dialog
|
||||
onClose={() => {
|
||||
setCommandBarOpen(false)
|
||||
clearState()
|
||||
}}
|
||||
className="fixed inset-0 overflow-y-auto p-4 pt-[25vh]"
|
||||
>
|
||||
<Transition.Child
|
||||
enter="duration-100 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="duration-75 ease-in"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
as={Fragment}
|
||||
>
|
||||
<Dialog.Overlay className="fixed z-40 inset-0 bg-chalkboard-10/70 dark:bg-chalkboard-110/50" />
|
||||
</Transition.Child>
|
||||
<Transition.Child
|
||||
enter="duration-100 ease-out"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="duration-75 ease-in"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
as={Fragment}
|
||||
>
|
||||
<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"
|
||||
as="div"
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<ActionIcon icon={faSearch} size="xl" className="rounded-sm" />
|
||||
<div>
|
||||
{inSubCommand && (
|
||||
<p className="text-liquid-70 dark:text-liquid-30">
|
||||
{selectedCommand.item &&
|
||||
getDisplayValue(selectedCommand.item as Command)}
|
||||
</p>
|
||||
)}
|
||||
<Combobox.Input
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="bg-transparent focus:outline-none w-full"
|
||||
onKeyDown={(event) => {
|
||||
if (event.metaKey && event.key === 'k')
|
||||
setCommandBarOpen(false)
|
||||
if (
|
||||
inSubCommand &&
|
||||
event.key === 'Backspace' &&
|
||||
!event.currentTarget.value
|
||||
) {
|
||||
setSubCommandIndex(subCommandIndex - 1)
|
||||
setSelectedCommand(null)
|
||||
}
|
||||
}}
|
||||
displayValue={(command: SortedCommand) =>
|
||||
command !== null ? command.item.name : ''
|
||||
}
|
||||
placeholder={
|
||||
inSubCommand
|
||||
? `Enter <${currentSubCommand?.name}>`
|
||||
: 'Search for a command'
|
||||
}
|
||||
value={query}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Combobox.Options static className="max-h-96 overflow-y-auto">
|
||||
{filteredCommands?.map((commandResult) => (
|
||||
<Combobox.Option
|
||||
key={commandResult.item.name}
|
||||
value={commandResult}
|
||||
className="my-2 first:mt-4 last:mb-4 ui-active:bg-liquid-10 dark:ui-active:bg-liquid-90 py-1 px-2"
|
||||
>
|
||||
<p>{commandResult.item.name}</p>
|
||||
{(commandResult.item as SubCommand).description && (
|
||||
<p className="mt-0.5 text-liquid-70 dark:text-liquid-30 text-sm">
|
||||
{(commandResult.item as SubCommand).description}
|
||||
</p>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandBarProvider
|
@ -5,6 +5,7 @@ import { EngineCommand } from '../lang/std/engineConnection'
|
||||
import { useState } from 'react'
|
||||
import { ActionButton } from '../components/ActionButton'
|
||||
import { faCheck } from '@fortawesome/free-solid-svg-icons'
|
||||
import { isReducedMotion } from 'lang/util'
|
||||
|
||||
type SketchModeCmd = Extract<
|
||||
Extract<EngineCommand, { type: 'modeling_cmd_req' }>['cmd'],
|
||||
@ -22,7 +23,7 @@ export const DebugPanel = ({ className, ...props }: CollapsiblePanelProps) => {
|
||||
y_axis: { x: 0, y: 1, z: 0 },
|
||||
distance_to_plane: 100,
|
||||
ortho: true,
|
||||
animated: true, // TODO #273 get prefers reduced motion from CSS
|
||||
animated: !isReducedMotion(),
|
||||
})
|
||||
if (!sketchModeCmd) return null
|
||||
return (
|
||||
|
@ -39,6 +39,7 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
|
||||
const initialValues: OutputFormat = {
|
||||
type: defaultType,
|
||||
storage: 'embedded',
|
||||
presentation: 'pretty',
|
||||
}
|
||||
const formik = useFormik({
|
||||
initialValues,
|
||||
@ -127,7 +128,9 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
|
||||
id="storage"
|
||||
name="storage"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.storage}
|
||||
value={
|
||||
'storage' in formik.values ? formik.values.storage : ''
|
||||
}
|
||||
className="bg-chalkboard-20 dark:bg-chalkboard-90 w-full"
|
||||
>
|
||||
{type === 'gltf' && (
|
||||
|
158
src/components/GlobalStateProvider.tsx
Normal file
158
src/components/GlobalStateProvider.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import { useMachine } from '@xstate/react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { paths } from '../Router'
|
||||
import {
|
||||
authCommandBarMeta,
|
||||
authMachine,
|
||||
TOKEN_PERSIST_KEY,
|
||||
} from '../machines/authMachine'
|
||||
import withBaseUrl from '../lib/withBaseURL'
|
||||
import React, { createContext, useEffect, useRef } from 'react'
|
||||
import useStateMachineCommands from '../hooks/useStateMachineCommands'
|
||||
import {
|
||||
SETTINGS_PERSIST_KEY,
|
||||
settingsCommandBarMeta,
|
||||
settingsMachine,
|
||||
} from 'machines/settingsMachine'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { setThemeClass, Themes } from 'lib/theme'
|
||||
import {
|
||||
AnyStateMachine,
|
||||
ContextFrom,
|
||||
InterpreterFrom,
|
||||
Prop,
|
||||
StateFrom,
|
||||
} from 'xstate'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
|
||||
type MachineContext<T extends AnyStateMachine> = {
|
||||
state: StateFrom<T>
|
||||
context: ContextFrom<T>
|
||||
send: Prop<InterpreterFrom<T>, 'send'>
|
||||
}
|
||||
|
||||
type GlobalContext = {
|
||||
auth: MachineContext<typeof authMachine>
|
||||
settings: MachineContext<typeof settingsMachine>
|
||||
}
|
||||
|
||||
export const GlobalStateContext = createContext({} as GlobalContext)
|
||||
|
||||
export const GlobalStateProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const { commands } = useCommandsContext()
|
||||
|
||||
// Settings machine setup
|
||||
const retrievedSettings = useRef(
|
||||
localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}'
|
||||
)
|
||||
const persistedSettings = Object.assign(
|
||||
settingsMachine.initialState.context,
|
||||
JSON.parse(retrievedSettings.current) as Partial<
|
||||
(typeof settingsMachine)['context']
|
||||
>
|
||||
)
|
||||
|
||||
const [settingsState, settingsSend] = useMachine(settingsMachine, {
|
||||
context: persistedSettings,
|
||||
actions: {
|
||||
toastSuccess: (context, event) => {
|
||||
const truncatedNewValue =
|
||||
'data' in event && event.data instanceof Object
|
||||
? (context[Object.keys(event.data)[0] as keyof typeof context]
|
||||
.toString()
|
||||
.substring(0, 28) as any)
|
||||
: undefined
|
||||
toast.success(
|
||||
event.type +
|
||||
(truncatedNewValue
|
||||
? ` to "${truncatedNewValue}${
|
||||
truncatedNewValue.length === 28 ? '...' : ''
|
||||
}"`
|
||||
: '')
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
useStateMachineCommands({
|
||||
state: settingsState,
|
||||
send: settingsSend,
|
||||
commands,
|
||||
owner: 'settings',
|
||||
commandBarMeta: settingsCommandBarMeta,
|
||||
})
|
||||
|
||||
// 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, {
|
||||
actions: {
|
||||
goToSignInPage: () => {
|
||||
navigate(paths.SIGN_IN)
|
||||
logout()
|
||||
},
|
||||
goToIndexPage: () => {
|
||||
if (window.location.pathname.includes(paths.SIGN_IN)) {
|
||||
navigate(paths.INDEX)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
useStateMachineCommands({
|
||||
state: authState,
|
||||
send: authSend,
|
||||
commands,
|
||||
commandBarMeta: authCommandBarMeta,
|
||||
owner: 'auth',
|
||||
})
|
||||
|
||||
return (
|
||||
<GlobalStateContext.Provider
|
||||
value={{
|
||||
auth: {
|
||||
state: authState,
|
||||
context: authState.context,
|
||||
send: authSend,
|
||||
},
|
||||
settings: {
|
||||
state: settingsState,
|
||||
context: settingsState.context,
|
||||
send: settingsSend,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</GlobalStateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default GlobalStateProvider
|
||||
|
||||
export function logout() {
|
||||
const url = withBaseUrl('/logout')
|
||||
localStorage.removeItem(TOKEN_PERSIST_KEY)
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
import ReactJson from 'react-json-view'
|
||||
import { useEffect } from 'react'
|
||||
import { Themes, useStore } from '../useStore'
|
||||
import { useStore } from '../useStore'
|
||||
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
|
||||
import { Themes } from '../lib/theme'
|
||||
|
||||
const ReactJsonTypeHack = ReactJson as any
|
||||
|
||||
|
@ -14,12 +14,12 @@ describe('processMemory', () => {
|
||||
return a - 2
|
||||
}
|
||||
const otherVar = myFn(5)
|
||||
|
||||
const theExtrude = startSketchAt([0, 0])
|
||||
|
||||
const theExtrude = startSketchAt([0, 0])
|
||||
|> lineTo([-2.4, myVar], %)
|
||||
|> lineTo([-0.76, otherVar], %)
|
||||
|> extrude(4, %)
|
||||
|
||||
|
||||
const theSketch = startSketchAt([0, 0])
|
||||
|> lineTo([-3.35, 0.17], %)
|
||||
|> lineTo([0.98, 5.16], %)
|
||||
@ -28,30 +28,20 @@ describe('processMemory', () => {
|
||||
show(theExtrude, theSketch)`
|
||||
const ast = parser_wasm(code)
|
||||
const programMemory = await enginelessExecutor(ast, {
|
||||
root: {
|
||||
log: {
|
||||
type: 'userVal',
|
||||
value: (a: any) => {
|
||||
console.log('raw log', a)
|
||||
},
|
||||
__meta: [],
|
||||
},
|
||||
},
|
||||
pendingMemory: {},
|
||||
root: {},
|
||||
})
|
||||
const output = processMemory(programMemory)
|
||||
expect(output.myVar).toEqual(5)
|
||||
expect(output.myFn).toEqual('__function__')
|
||||
expect(output.otherVar).toEqual(3)
|
||||
expect(output).toEqual({
|
||||
myVar: 5,
|
||||
myFn: '__function__',
|
||||
myFn: undefined,
|
||||
otherVar: 3,
|
||||
theExtrude: [],
|
||||
theSketch: [
|
||||
{ type: 'toPoint', to: [-3.35, 0.17], from: [0, 0] },
|
||||
{ type: 'toPoint', to: [0.98, 5.16], from: [-3.35, 0.17] },
|
||||
{ type: 'toPoint', to: [2.15, 4.32], from: [0.98, 5.16] },
|
||||
{ type: 'toPoint', to: [-3.35, 0.17], from: [0, 0], name: '' },
|
||||
{ type: 'toPoint', to: [0.98, 5.16], from: [-3.35, 0.17], name: '' },
|
||||
{ type: 'toPoint', to: [2.15, 4.32], from: [0.98, 5.16], name: '' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
@ -1,8 +1,9 @@
|
||||
import ReactJson from 'react-json-view'
|
||||
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
|
||||
import { Themes, useStore } from '../useStore'
|
||||
import { useStore } from '../useStore'
|
||||
import { useMemo } from 'react'
|
||||
import { ProgramMemory } from '../lang/executor'
|
||||
import { Themes } from '../lib/theme'
|
||||
|
||||
interface MemoryPanelProps extends CollapsiblePanelProps {
|
||||
theme?: Exclude<Themes, Themes.System>
|
||||
|
@ -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,54 +47,77 @@ const ProjectSidebarMenu = ({
|
||||
{isTauri() && project?.name ? project.name : 'KittyCAD Modeling App'}
|
||||
</span>
|
||||
</Popover.Button>
|
||||
<Popover.Overlay className="fixed z-20 inset-0 bg-chalkboard-110/50" />
|
||||
<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">
|
||||
<div className="flex items-center gap-4 px-4 py-3 bg-energy-100">
|
||||
<img
|
||||
src="/kitt-8bit-winking.svg"
|
||||
alt="KittyCAD App"
|
||||
className="h-9 w-auto"
|
||||
/>
|
||||
<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"
|
||||
alt="KittyCAD App"
|
||||
className="h-9 w-auto"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p
|
||||
className="m-0 text-energy-10 text-mono"
|
||||
data-testid="projectName"
|
||||
>
|
||||
{project?.name ? project.name : 'KittyCAD Modeling App'}
|
||||
</p>
|
||||
{project?.entrypoint_metadata && (
|
||||
<p className="m-0 text-energy-40 text-xs" data-testid="createdAt">
|
||||
Created{' '}
|
||||
{project?.entrypoint_metadata.createdAt.toLocaleDateString()}
|
||||
<div>
|
||||
<p
|
||||
className="m-0 text-energy-10 text-mono"
|
||||
data-testid="projectName"
|
||||
>
|
||||
{project?.name ? project.name : 'KittyCAD Modeling App'}
|
||||
</p>
|
||||
{project?.entrypoint_metadata && (
|
||||
<p
|
||||
className="m-0 text-energy-40 text-xs"
|
||||
data-testid="createdAt"
|
||||
>
|
||||
Created{' '}
|
||||
{project?.entrypoint_metadata.createdAt.toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 flex flex-col gap-2">
|
||||
<ExportButton
|
||||
className={{
|
||||
button:
|
||||
'border-transparent dark:border-transparent dark:hover:border-energy-60',
|
||||
}}
|
||||
>
|
||||
Export Model
|
||||
</ExportButton>
|
||||
{isTauri() && (
|
||||
<ActionButton
|
||||
Element="link"
|
||||
to={paths.HOME}
|
||||
icon={{
|
||||
icon: faHome,
|
||||
}}
|
||||
className="border-transparent dark:border-transparent dark:hover:border-energy-60"
|
||||
>
|
||||
Go to Home
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 flex flex-col gap-2">
|
||||
<ExportButton
|
||||
className={{
|
||||
button:
|
||||
'border-transparent dark:border-transparent dark:hover:border-energy-60',
|
||||
}}
|
||||
>
|
||||
Export Model
|
||||
</ExportButton>
|
||||
{isTauri() && (
|
||||
<ActionButton
|
||||
Element="link"
|
||||
to={paths.HOME}
|
||||
icon={{
|
||||
icon: faHome,
|
||||
}}
|
||||
className="border-transparent dark:border-transparent dark:hover:border-energy-60"
|
||||
>
|
||||
Go to Home
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
@ -17,7 +17,6 @@ export const Stream = ({ className = '' }) => {
|
||||
mediaStream,
|
||||
engineCommandManager,
|
||||
setIsMouseDownInStream,
|
||||
setCmdId,
|
||||
didDragInStream,
|
||||
setDidDragInStream,
|
||||
streamDimensions,
|
||||
@ -27,7 +26,6 @@ export const Stream = ({ className = '' }) => {
|
||||
isMouseDownInStream: s.isMouseDownInStream,
|
||||
setIsMouseDownInStream: s.setIsMouseDownInStream,
|
||||
fileId: s.fileId,
|
||||
setCmdId: s.setCmdId,
|
||||
didDragInStream: s.didDragInStream,
|
||||
setDidDragInStream: s.setDidDragInStream,
|
||||
streamDimensions: s.streamDimensions,
|
||||
@ -59,7 +57,6 @@ export const Stream = ({ className = '' }) => {
|
||||
console.log('click', x, y)
|
||||
|
||||
const newId = uuidv4()
|
||||
setCmdId(newId)
|
||||
|
||||
const interaction = ctrlKey ? 'pan' : 'rotate'
|
||||
|
||||
|
@ -2,7 +2,8 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import UserSidebarMenu from './UserSidebarMenu'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Models } from '@kittycad/lib'
|
||||
import { GlobalStateProvider } from '../hooks/useAuthMachine'
|
||||
import { GlobalStateProvider } from './GlobalStateProvider'
|
||||
import CommandBarProvider from './CommandBar'
|
||||
|
||||
type User = Models['User_type']
|
||||
|
||||
@ -94,7 +95,9 @@ function TestWrap({ children }: { children: React.ReactNode }) {
|
||||
// wrap in router and xState context
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<GlobalStateProvider>{children}</GlobalStateProvider>
|
||||
<CommandBarProvider>
|
||||
<GlobalStateProvider>{children}</GlobalStateProvider>
|
||||
</CommandBarProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
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 { useAuthMachine } from '../hooks/useAuthMachine'
|
||||
import { Models } from '@kittycad/lib'
|
||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||
|
||||
type User = Models['User_type']
|
||||
|
||||
@ -15,7 +15,9 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
|
||||
const displayedName = getDisplayName(user)
|
||||
const [imageLoadFailed, setImageLoadFailed] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const [_, send] = useAuthMachine()
|
||||
const {
|
||||
auth: { send },
|
||||
} = useGlobalStateContext()
|
||||
|
||||
// Fallback logic for displaying user's "name":
|
||||
// 1. user.name
|
||||
@ -59,82 +61,102 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
|
||||
Menu
|
||||
</ActionButton>
|
||||
)}
|
||||
<Popover.Overlay className="fixed z-20 inset-0 bg-chalkboard-110/50" />
|
||||
<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">
|
||||
{({ close }) => (
|
||||
<>
|
||||
{user && (
|
||||
<div className="flex items-center gap-4 px-4 py-3 bg-liquid-100">
|
||||
{user.image && !imageLoadFailed && (
|
||||
<div className="rounded-full shadow-inner overflow-hidden">
|
||||
<img
|
||||
src={user.image}
|
||||
alt={user.name || ''}
|
||||
className="h-8 w-8"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setImageLoadFailed(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p
|
||||
className="m-0 text-liquid-10 text-mono"
|
||||
data-testid="username"
|
||||
>
|
||||
{displayedName || ''}
|
||||
</p>
|
||||
{displayedName !== user.email && (
|
||||
<p
|
||||
className="m-0 text-liquid-40 text-xs"
|
||||
data-testid="email"
|
||||
>
|
||||
{user.email}
|
||||
</p>
|
||||
<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 && (
|
||||
<div className="flex items-center gap-4 px-4 py-3 bg-liquid-100">
|
||||
{user.image && !imageLoadFailed && (
|
||||
<div className="rounded-full shadow-inner overflow-hidden">
|
||||
<img
|
||||
src={user.image}
|
||||
alt={user.name || ''}
|
||||
className="h-8 w-8"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setImageLoadFailed(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p
|
||||
className="m-0 text-liquid-10 text-mono"
|
||||
data-testid="username"
|
||||
>
|
||||
{displayedName || ''}
|
||||
</p>
|
||||
{displayedName !== user.email && (
|
||||
<p
|
||||
className="m-0 text-liquid-40 text-xs"
|
||||
data-testid="email"
|
||||
>
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 flex flex-col gap-2">
|
||||
<ActionButton
|
||||
Element="button"
|
||||
icon={{ icon: faGear }}
|
||||
className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
|
||||
onClick={() => {
|
||||
// since /settings is a nested route the sidebar doesn't close
|
||||
// automatically when navigating to it
|
||||
close()
|
||||
navigate(makeUrlPathRelative(paths.SETTINGS))
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
Element="link"
|
||||
to="https://github.com/KittyCAD/modeling-app/discussions"
|
||||
icon={{ icon: faGithub }}
|
||||
className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
|
||||
>
|
||||
Request a feature
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={() => send('Log out')}
|
||||
icon={{
|
||||
icon: faSignOutAlt,
|
||||
bgClassName: 'bg-destroy-80',
|
||||
iconClassName:
|
||||
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
|
||||
}}
|
||||
className="border-transparent dark:border-transparent hover:border-destroy-40 dark:hover:border-destroy-60"
|
||||
>
|
||||
Sign out
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 flex flex-col gap-2">
|
||||
<ActionButton
|
||||
Element="button"
|
||||
icon={{ icon: faGear }}
|
||||
className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
|
||||
onClick={() => {
|
||||
// since /settings is a nested route the sidebar doesn't close
|
||||
// automatically when navigating to it
|
||||
close()
|
||||
navigate(makeUrlPathRelative(paths.SETTINGS))
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
Element="link"
|
||||
to="https://github.com/KittyCAD/modeling-app/discussions"
|
||||
icon={{ icon: faGithub }}
|
||||
className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
|
||||
>
|
||||
Request a feature
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={() => send('logout')}
|
||||
icon={{
|
||||
icon: faSignOutAlt,
|
||||
bgClassName: 'bg-destroy-80',
|
||||
iconClassName:
|
||||
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
|
||||
}}
|
||||
className="border-transparent dark:border-transparent hover:border-destroy-40 dark:hover:border-destroy-60"
|
||||
>
|
||||
Sign out
|
||||
</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</>
|
||||
)}
|
||||
</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
|
||||
}
|
||||
}
|
354
src/editor/lsp/plugin.ts
Normal file
354
src/editor/lsp/plugin.ts
Normal file
@ -0,0 +1,354 @@
|
||||
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 = formatContents(documentation)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
@ -8,4 +8,9 @@ export const VITE_KC_API_WS_MODELING_URL = import.meta.env
|
||||
.VITE_KC_API_WS_MODELING_URL
|
||||
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_WEBRTC_REPORT_STATS_MS = import.meta.env
|
||||
.VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS
|
||||
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
|
||||
|
@ -1,54 +0,0 @@
|
||||
import { createActorContext } from '@xstate/react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { paths } from '../Router'
|
||||
import { authMachine, TOKEN_PERSIST_KEY } from '../lib/authMachine'
|
||||
import withBaseUrl from '../lib/withBaseURL'
|
||||
|
||||
export const AuthMachineContext = createActorContext(authMachine)
|
||||
|
||||
export const GlobalStateProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<AuthMachineContext.Provider
|
||||
machine={() =>
|
||||
authMachine.withConfig({
|
||||
actions: {
|
||||
goToSignInPage: () => {
|
||||
navigate(paths.SIGN_IN)
|
||||
logout()
|
||||
},
|
||||
goToIndexPage: () => navigate(paths.INDEX),
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AuthMachineContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuthMachine<T>(
|
||||
selector: (
|
||||
state: Parameters<Parameters<typeof AuthMachineContext.useSelector>[0]>[0]
|
||||
) => T = () => null as T
|
||||
): [T, ReturnType<typeof AuthMachineContext.useActor>[1]] {
|
||||
// useActor api normally `[state, send] = useActor`
|
||||
// we're only interested in send because of the selector
|
||||
const send = AuthMachineContext.useActor()[1]
|
||||
|
||||
const selection = AuthMachineContext.useSelector(selector)
|
||||
return [selection, send]
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
const url = withBaseUrl('/logout')
|
||||
localStorage.removeItem(TOKEN_PERSIST_KEY)
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
6
src/hooks/useCommandsContext.ts
Normal file
6
src/hooks/useCommandsContext.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { CommandsContext } from 'components/CommandBar'
|
||||
import { useContext } from 'react'
|
||||
|
||||
export const useCommandsContext = () => {
|
||||
return useContext(CommandsContext)
|
||||
}
|
6
src/hooks/useGlobalStateContext.ts
Normal file
6
src/hooks/useGlobalStateContext.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { GlobalStateContext } from 'components/GlobalStateProvider'
|
||||
import { useContext } from 'react'
|
||||
|
||||
export const useGlobalStateContext = () => {
|
||||
return useContext(GlobalStateContext)
|
||||
}
|
42
src/hooks/useStateMachineCommands.ts
Normal file
42
src/hooks/useStateMachineCommands.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { useEffect } from 'react'
|
||||
import { AnyStateMachine, StateFrom } from 'xstate'
|
||||
import { Command, CommandBarMeta, createMachineCommand } from '../lib/commands'
|
||||
import { useCommandsContext } from './useCommandsContext'
|
||||
|
||||
interface UseStateMachineCommandsArgs<T extends AnyStateMachine> {
|
||||
state: StateFrom<T>
|
||||
send: Function
|
||||
commandBarMeta?: CommandBarMeta
|
||||
commands: Command[]
|
||||
owner: string
|
||||
}
|
||||
|
||||
export default function useStateMachineCommands<T extends AnyStateMachine>({
|
||||
state,
|
||||
send,
|
||||
commandBarMeta,
|
||||
owner,
|
||||
}: UseStateMachineCommandsArgs<T>) {
|
||||
const { addCommands, removeCommands } = useCommandsContext()
|
||||
|
||||
useEffect(() => {
|
||||
const newCommands = state.nextEvents
|
||||
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
|
||||
.map((type) =>
|
||||
createMachineCommand<T>({
|
||||
type,
|
||||
state,
|
||||
send,
|
||||
commandBarMeta,
|
||||
owner,
|
||||
})
|
||||
)
|
||||
.filter((c) => c !== null) as Command[]
|
||||
|
||||
addCommands(newCommands)
|
||||
|
||||
return () => {
|
||||
removeCommands(newCommands)
|
||||
}
|
||||
}, [state])
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useStore } from '../useStore'
|
||||
import { parse } from 'toml'
|
||||
import {
|
||||
createDir,
|
||||
BaseDirectory,
|
||||
readDir,
|
||||
readTextFile,
|
||||
} from '@tauri-apps/api/fs'
|
||||
|
||||
export const useTauriBoot = () => {
|
||||
const { defaultDir, setDefaultDir, setHomeMenuItems } = useStore((s) => ({
|
||||
defaultDir: s.defaultDir,
|
||||
setDefaultDir: s.setDefaultDir,
|
||||
setHomeMenuItems: s.setHomeMenuItems,
|
||||
}))
|
||||
useEffect(() => {
|
||||
const isTauri = (window as any).__TAURI__
|
||||
if (!isTauri) return
|
||||
const run = async () => {
|
||||
if (!defaultDir.base) {
|
||||
createDir('puffin-projects/example', {
|
||||
dir: BaseDirectory.Home,
|
||||
recursive: true,
|
||||
})
|
||||
setDefaultDir({
|
||||
base: BaseDirectory.Home,
|
||||
dir: 'puffin-projects',
|
||||
})
|
||||
} else {
|
||||
const directoryResult = await readDir(defaultDir.dir, {
|
||||
dir: defaultDir.base,
|
||||
recursive: true,
|
||||
})
|
||||
const puffinProjects = directoryResult.filter(
|
||||
(file) =>
|
||||
!file?.name?.startsWith('.') &&
|
||||
file?.children?.find((child) => child?.name === 'wax.toml')
|
||||
)
|
||||
|
||||
const tomlFiles = await Promise.all(
|
||||
puffinProjects.map(async (file) => {
|
||||
const parsedToml = parse(
|
||||
await readTextFile(`${file.path}/wax.toml`, {
|
||||
dir: defaultDir.base,
|
||||
})
|
||||
)
|
||||
const mainPath = parsedToml?.package?.main
|
||||
const projectName = parsedToml?.package?.name
|
||||
return {
|
||||
file,
|
||||
mainPath,
|
||||
projectName,
|
||||
}
|
||||
})
|
||||
)
|
||||
setHomeMenuItems(
|
||||
tomlFiles.map(({ file, mainPath, projectName }) => ({
|
||||
name: projectName,
|
||||
path: mainPath ? `${file.path}/${mainPath}` : file.path,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
run()
|
||||
}, [])
|
||||
}
|
@ -86,8 +86,21 @@ code {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
#code-mirror-override .cm-scroller {
|
||||
}
|
||||
|
||||
#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 +112,43 @@ 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 {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
#code-mirror-override .cm-tooltip-hover {
|
||||
}
|
||||
|
||||
#code-mirror-override .cm-tooltip-hover .documentation {
|
||||
padding: 5;
|
||||
}
|
||||
|
||||
#code-mirror-override .cm-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
@ -2,23 +2,10 @@ import ReactDOM from 'react-dom/client'
|
||||
import './index.css'
|
||||
import reportWebVitals from './reportWebVitals'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import { Themes, useStore } from './useStore'
|
||||
import { Router } from './Router'
|
||||
import { HotkeysProvider } from 'react-hotkeys-hook'
|
||||
import { getSystemTheme } from './lib/getSystemTheme'
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
|
||||
function setThemeClass(state: Partial<{ theme: Themes }>) {
|
||||
const systemTheme = state.theme === Themes.System && getSystemTheme()
|
||||
if (state.theme === Themes.Dark || systemTheme === Themes.Dark) {
|
||||
document.body.classList.add('dark')
|
||||
} else {
|
||||
document.body.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
const { theme } = useStore.getState()
|
||||
setThemeClass({ theme })
|
||||
useStore.subscribe(setThemeClass)
|
||||
|
||||
root.render(
|
||||
<HotkeysProvider>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { parser_wasm } from './abstractSyntaxTree'
|
||||
import { KCLUnexpectedError } from './errors'
|
||||
import { initPromise } from './rust'
|
||||
|
||||
beforeAll(() => initPromise)
|
||||
@ -178,6 +179,9 @@ const newVar = myVar + 1
|
||||
name: 'aIdentifier',
|
||||
},
|
||||
],
|
||||
function: {
|
||||
type: 'InMemory',
|
||||
},
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
@ -210,10 +214,8 @@ describe('testing function declaration', () => {
|
||||
type: 'FunctionExpression',
|
||||
start: 11,
|
||||
end: 19,
|
||||
id: null,
|
||||
params: [],
|
||||
body: {
|
||||
type: 'BlockStatement',
|
||||
start: 17,
|
||||
end: 19,
|
||||
body: [],
|
||||
@ -250,7 +252,6 @@ describe('testing function declaration', () => {
|
||||
type: 'FunctionExpression',
|
||||
start: 11,
|
||||
end: 39,
|
||||
id: null,
|
||||
params: [
|
||||
{
|
||||
type: 'Identifier',
|
||||
@ -266,7 +267,6 @@ describe('testing function declaration', () => {
|
||||
},
|
||||
],
|
||||
body: {
|
||||
type: 'BlockStatement',
|
||||
start: 21,
|
||||
end: 39,
|
||||
body: [
|
||||
@ -327,7 +327,6 @@ const myVar = funcN(1, 2)`
|
||||
type: 'FunctionExpression',
|
||||
start: 11,
|
||||
end: 37,
|
||||
id: null,
|
||||
params: [
|
||||
{
|
||||
type: 'Identifier',
|
||||
@ -343,7 +342,6 @@ const myVar = funcN(1, 2)`
|
||||
},
|
||||
],
|
||||
body: {
|
||||
type: 'BlockStatement',
|
||||
start: 21,
|
||||
end: 37,
|
||||
body: [
|
||||
@ -418,6 +416,9 @@ const myVar = funcN(1, 2)`
|
||||
raw: '2',
|
||||
},
|
||||
],
|
||||
function: {
|
||||
type: 'InMemory',
|
||||
},
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
@ -487,6 +488,7 @@ describe('testing pipe operator special', () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
function: expect.any(Object),
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
@ -523,6 +525,7 @@ describe('testing pipe operator special', () => {
|
||||
},
|
||||
{ type: 'PipeSubstitution', start: 59, end: 60 },
|
||||
],
|
||||
function: expect.any(Object),
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
@ -595,6 +598,7 @@ describe('testing pipe operator special', () => {
|
||||
},
|
||||
{ type: 'PipeSubstitution', start: 105, end: 106 },
|
||||
],
|
||||
function: expect.any(Object),
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
@ -631,6 +635,7 @@ describe('testing pipe operator special', () => {
|
||||
},
|
||||
{ type: 'PipeSubstitution', start: 128, end: 129 },
|
||||
],
|
||||
function: expect.any(Object),
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
@ -653,6 +658,9 @@ describe('testing pipe operator special', () => {
|
||||
},
|
||||
{ type: 'PipeSubstitution', start: 143, end: 144 },
|
||||
],
|
||||
function: {
|
||||
type: 'InMemory',
|
||||
},
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
@ -732,6 +740,9 @@ describe('testing pipe operator special', () => {
|
||||
end: 35,
|
||||
},
|
||||
],
|
||||
function: {
|
||||
type: 'InMemory',
|
||||
},
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
@ -1552,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: 'block',
|
||||
value: 'this is a comment',
|
||||
},
|
||||
}
|
||||
const { nonCodeMeta } = parser_wasm(code)
|
||||
expect(nonCodeMeta.noneCodeNodes[0]).toEqual(nonCodeMetaInstance)
|
||||
@ -1562,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
|
||||
)
|
||||
@ -1570,8 +1586,8 @@ const key = 'c'`
|
||||
it('comments nested within a block statement', () => {
|
||||
const code = `const mySketch = startSketchAt([0,0])
|
||||
|> lineTo({ to: [0, 1], tag: 'myPath' }, %)
|
||||
|> lineTo([1, 1], %) /* this is
|
||||
a comment
|
||||
|> lineTo([1, 1], %) /* this is
|
||||
a comment
|
||||
spanning a few lines */
|
||||
|> lineTo({ to: [1,0], tag: "rightPath" }, %)
|
||||
|> close(%)
|
||||
@ -1584,9 +1600,11 @@ const key = 'c'`
|
||||
expect(sketchNonCodeMeta[indexOfSecondLineToExpression]).toEqual({
|
||||
type: 'NoneCodeNode',
|
||||
start: 106,
|
||||
end: 168,
|
||||
value:
|
||||
' /* this is \n a comment \n spanning a few lines */\n ',
|
||||
end: 166,
|
||||
value: {
|
||||
type: 'block',
|
||||
value: 'this is\n a comment\n spanning a few lines',
|
||||
},
|
||||
})
|
||||
})
|
||||
it('comments in a pipe expression', () => {
|
||||
@ -1606,7 +1624,10 @@ const key = 'c'`
|
||||
type: 'NoneCodeNode',
|
||||
start: 125,
|
||||
end: 141,
|
||||
value: '\n// a comment\n ',
|
||||
value: {
|
||||
type: 'block',
|
||||
value: 'a comment',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1630,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,
|
||||
},
|
||||
})
|
||||
@ -1663,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,
|
||||
})
|
||||
})
|
||||
@ -1698,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 },
|
||||
@ -1706,3 +1731,19 @@ describe('should recognise callExpresions in binaryExpressions', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsing errors', () => {
|
||||
it('should return an error when there is a unexpected closed curly brace', async () => {
|
||||
const code = `const myVar = startSketchAt([}], %)`
|
||||
|
||||
let _theError
|
||||
try {
|
||||
const result = expect(parser_wasm(code))
|
||||
console.log('result', result)
|
||||
} catch (e) {
|
||||
_theError = e
|
||||
}
|
||||
const theError = _theError as any
|
||||
expect(theError).toEqual(new KCLUnexpectedError('Brace', [[29, 30]]))
|
||||
})
|
||||
})
|
||||
|
@ -3,21 +3,21 @@ import { parse_js } from '../wasm-lib/pkg/wasm_lib'
|
||||
import { initPromise } from './rust'
|
||||
import { Token } from './tokeniser'
|
||||
import { KCLError } from './errors'
|
||||
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
|
||||
|
||||
export const rangeTypeFix = (ranges: number[][]): [number, number][] =>
|
||||
ranges.map(([start, end]) => [start, end])
|
||||
|
||||
export const parser_wasm = (code: string): Program => {
|
||||
try {
|
||||
const program: Program = parse_js(code)
|
||||
return program
|
||||
} catch (e: any) {
|
||||
const parsed: {
|
||||
kind: string
|
||||
msg: string
|
||||
sourceRanges: [number, number][]
|
||||
} = JSON.parse(e.toString())
|
||||
const kclError: KCLError = new KCLError(
|
||||
const parsed: RustKclError = JSON.parse(e.toString())
|
||||
const kclError = new KCLError(
|
||||
parsed.kind,
|
||||
parsed.msg,
|
||||
parsed.sourceRanges
|
||||
rangeTypeFix(parsed.sourceRanges)
|
||||
)
|
||||
|
||||
console.log(kclError)
|
||||
@ -31,15 +31,11 @@ export async function asyncParser(code: string): Promise<Program> {
|
||||
const program: Program = parse_js(code)
|
||||
return program
|
||||
} catch (e: any) {
|
||||
const parsed: {
|
||||
kind: string
|
||||
msg: string
|
||||
sourceRanges: [number, number][]
|
||||
} = JSON.parse(e.toString())
|
||||
const kclError: KCLError = new KCLError(
|
||||
const parsed: RustKclError = JSON.parse(e.toString())
|
||||
const kclError = new KCLError(
|
||||
parsed.kind,
|
||||
parsed.msg,
|
||||
parsed.sourceRanges
|
||||
rangeTypeFix(parsed.sourceRanges)
|
||||
)
|
||||
|
||||
console.log(kclError)
|
||||
|
@ -1,20 +1,20 @@
|
||||
export type { Program } from '../wasm-lib/bindings/Program'
|
||||
export type { Value } from '../wasm-lib/bindings/Value'
|
||||
export type { ObjectExpression } from '../wasm-lib/bindings/ObjectExpression'
|
||||
export type { MemberExpression } from '../wasm-lib/bindings/MemberExpression'
|
||||
export type { PipeExpression } from '../wasm-lib/bindings/PipeExpression'
|
||||
export type { VariableDeclaration } from '../wasm-lib/bindings/VariableDeclaration'
|
||||
export type { PipeSubstitution } from '../wasm-lib/bindings/PipeSubstitution'
|
||||
export type { Identifier } from '../wasm-lib/bindings/Identifier'
|
||||
export type { UnaryExpression } from '../wasm-lib/bindings/UnaryExpression'
|
||||
export type { BinaryExpression } from '../wasm-lib/bindings/BinaryExpression'
|
||||
export type { ReturnStatement } from '../wasm-lib/bindings/ReturnStatement'
|
||||
export type { ExpressionStatement } from '../wasm-lib/bindings/ExpressionStatement'
|
||||
export type { CallExpression } from '../wasm-lib/bindings/CallExpression'
|
||||
export type { VariableDeclarator } from '../wasm-lib/bindings/VariableDeclarator'
|
||||
export type { BinaryPart } from '../wasm-lib/bindings/BinaryPart'
|
||||
export type { Literal } from '../wasm-lib/bindings/Literal'
|
||||
export type { ArrayExpression } from '../wasm-lib/bindings/ArrayExpression'
|
||||
export type { Program } from '../wasm-lib/kcl/bindings/Program'
|
||||
export type { Value } from '../wasm-lib/kcl/bindings/Value'
|
||||
export type { ObjectExpression } from '../wasm-lib/kcl/bindings/ObjectExpression'
|
||||
export type { MemberExpression } from '../wasm-lib/kcl/bindings/MemberExpression'
|
||||
export type { PipeExpression } from '../wasm-lib/kcl/bindings/PipeExpression'
|
||||
export type { VariableDeclaration } from '../wasm-lib/kcl/bindings/VariableDeclaration'
|
||||
export type { PipeSubstitution } from '../wasm-lib/kcl/bindings/PipeSubstitution'
|
||||
export type { Identifier } from '../wasm-lib/kcl/bindings/Identifier'
|
||||
export type { UnaryExpression } from '../wasm-lib/kcl/bindings/UnaryExpression'
|
||||
export type { BinaryExpression } from '../wasm-lib/kcl/bindings/BinaryExpression'
|
||||
export type { ReturnStatement } from '../wasm-lib/kcl/bindings/ReturnStatement'
|
||||
export type { ExpressionStatement } from '../wasm-lib/kcl/bindings/ExpressionStatement'
|
||||
export type { CallExpression } from '../wasm-lib/kcl/bindings/CallExpression'
|
||||
export type { VariableDeclarator } from '../wasm-lib/kcl/bindings/VariableDeclarator'
|
||||
export type { BinaryPart } from '../wasm-lib/kcl/bindings/BinaryPart'
|
||||
export type { Literal } from '../wasm-lib/kcl/bindings/Literal'
|
||||
export type { ArrayExpression } from '../wasm-lib/kcl/bindings/ArrayExpression'
|
||||
|
||||
export type SyntaxType =
|
||||
| 'Program'
|
||||
@ -22,7 +22,6 @@ export type SyntaxType =
|
||||
| 'BinaryExpression'
|
||||
| 'CallExpression'
|
||||
| 'Identifier'
|
||||
| 'BlockStatement'
|
||||
| 'ReturnStatement'
|
||||
| 'VariableDeclaration'
|
||||
| 'VariableDeclarator'
|
||||
|
@ -11,51 +11,52 @@ describe('testing artifacts', () => {
|
||||
const mySketch001 = startSketchAt([0, 0])
|
||||
|> lineTo([-1.59, -1.54], %)
|
||||
|> lineTo([0.46, -5.82], %)
|
||||
// |> rx(45, %)
|
||||
// |> rx(45, %)
|
||||
show(mySketch001)`
|
||||
const programMemory = await enginelessExecutor(parser_wasm(code))
|
||||
// @ts-ignore
|
||||
const shown = programMemory?.return?.map(
|
||||
// @ts-ignore
|
||||
(a) => programMemory?.root?.[a.name]
|
||||
)
|
||||
expect(shown).toEqual([
|
||||
{
|
||||
type: 'sketchGroup',
|
||||
start: {
|
||||
type: 'base',
|
||||
to: [0, 0],
|
||||
from: [0, 0],
|
||||
name: '',
|
||||
__geoMeta: {
|
||||
id: '66366561-6465-4734-a463-366330356563',
|
||||
id: expect.any(String),
|
||||
sourceRange: [21, 42],
|
||||
pathToNode: [],
|
||||
},
|
||||
},
|
||||
value: [
|
||||
{
|
||||
type: 'toPoint',
|
||||
name: '',
|
||||
to: [-1.59, -1.54],
|
||||
from: [0, 0],
|
||||
__geoMeta: {
|
||||
sourceRange: [48, 73],
|
||||
id: '30366338-6462-4330-a364-303935626163',
|
||||
pathToNode: [],
|
||||
id: expect.any(String),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'toPoint',
|
||||
to: [0.46, -5.82],
|
||||
from: [-1.59, -1.54],
|
||||
name: '',
|
||||
__geoMeta: {
|
||||
sourceRange: [79, 103],
|
||||
id: '32653334-6331-4231-b162-663334363535',
|
||||
pathToNode: [],
|
||||
id: expect.any(String),
|
||||
},
|
||||
},
|
||||
],
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
id: '39643164-6130-4734-b432-623638393262',
|
||||
__meta: [{ sourceRange: [21, 42], pathToNode: [] }],
|
||||
id: expect.any(String),
|
||||
__meta: [{ sourceRange: [21, 42] }],
|
||||
},
|
||||
])
|
||||
})
|
||||
@ -69,21 +70,20 @@ const mySketch001 = startSketchAt([0, 0])
|
||||
|> extrude(2, %)
|
||||
show(mySketch001)`
|
||||
const programMemory = await enginelessExecutor(parser_wasm(code))
|
||||
// @ts-ignore
|
||||
const shown = programMemory?.return?.map(
|
||||
// @ts-ignore
|
||||
(a) => programMemory?.root?.[a.name]
|
||||
)
|
||||
expect(shown).toEqual([
|
||||
{
|
||||
type: 'extrudeGroup',
|
||||
id: '65383433-3839-4333-b836-343263636638',
|
||||
id: expect.any(String),
|
||||
value: [],
|
||||
height: 2,
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
__meta: [
|
||||
{ sourceRange: [127, 140], pathToNode: [] },
|
||||
{ sourceRange: [21, 42], pathToNode: [] },
|
||||
],
|
||||
__meta: [{ sourceRange: [21, 42] }],
|
||||
},
|
||||
])
|
||||
})
|
||||
@ -106,37 +106,33 @@ const sk2 = startSketchAt([0, 0])
|
||||
|> lineTo([2.5, 0], %)
|
||||
// |> transform(theTransf, %)
|
||||
|> extrude(2, %)
|
||||
|
||||
|
||||
|
||||
show(theExtrude, sk2)`
|
||||
const programMemory = await enginelessExecutor(parser_wasm(code))
|
||||
// @ts-ignore
|
||||
const geos = programMemory?.return?.map(
|
||||
// @ts-ignore
|
||||
({ name }) => programMemory?.root?.[name]
|
||||
)
|
||||
expect(geos).toEqual([
|
||||
{
|
||||
type: 'extrudeGroup',
|
||||
id: '63333330-3631-4230-b664-623132643731',
|
||||
id: expect.any(String),
|
||||
value: [],
|
||||
height: 2,
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
__meta: [
|
||||
{ sourceRange: [212, 227], pathToNode: [] },
|
||||
{ sourceRange: [13, 34], pathToNode: [] },
|
||||
],
|
||||
__meta: [{ sourceRange: [13, 34] }],
|
||||
},
|
||||
{
|
||||
type: 'extrudeGroup',
|
||||
id: '33316639-3438-4661-a334-663262383737',
|
||||
id: expect.any(String),
|
||||
value: [],
|
||||
height: 2,
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
__meta: [
|
||||
{ sourceRange: [453, 466], pathToNode: [] },
|
||||
{ sourceRange: [302, 323], pathToNode: [] },
|
||||
],
|
||||
__meta: [{ sourceRange: [302, 323] }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { Diagnostic } from '@codemirror/lint'
|
||||
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
|
||||
|
||||
type ExtractKind<T> = T extends { kind: infer K } ? K : never
|
||||
export class KCLError {
|
||||
kind: string | undefined
|
||||
kind: ExtractKind<RustKclError> | 'name'
|
||||
sourceRanges: [number, number][]
|
||||
msg: string
|
||||
constructor(
|
||||
kind: string | undefined,
|
||||
kind: ExtractKind<RustKclError> | 'name',
|
||||
msg: string,
|
||||
sourceRanges: [number, number][]
|
||||
) {
|
||||
@ -39,11 +41,18 @@ export class KCLTypeError extends KCLError {
|
||||
|
||||
export class KCLUnimplementedError extends KCLError {
|
||||
constructor(msg: string, sourceRanges: [number, number][]) {
|
||||
super('unimplemented feature', msg, sourceRanges)
|
||||
super('unimplemented', msg, sourceRanges)
|
||||
Object.setPrototypeOf(this, KCLUnimplementedError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export class KCLUnexpectedError extends KCLError {
|
||||
constructor(msg: string, sourceRanges: [number, number][]) {
|
||||
super('unexpected', msg, sourceRanges)
|
||||
Object.setPrototypeOf(this, KCLUnexpectedError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export class KCLValueAlreadyDefined extends KCLError {
|
||||
constructor(key: string, sourceRanges: [number, number][]) {
|
||||
super('name', `Key ${key} was already defined elsewhere`, sourceRanges)
|
||||
|
@ -5,7 +5,7 @@ import { ProgramMemory } from './executor'
|
||||
import { initPromise } from './rust'
|
||||
import { enginelessExecutor } from '../lib/testHelpers'
|
||||
import { vi } from 'vitest'
|
||||
import { KCLUndefinedValueError } from './errors'
|
||||
import { KCLError } from './errors'
|
||||
|
||||
beforeAll(() => initPromise)
|
||||
|
||||
@ -30,29 +30,6 @@ const newVar = myVar + 1`
|
||||
const { root } = await exe(code)
|
||||
expect(root.myVar.value).toBe('a str another str')
|
||||
})
|
||||
it('test with function call', async () => {
|
||||
const code = `
|
||||
const myVar = "hello"
|
||||
log(5, myVar)`
|
||||
const programMemoryOverride: ProgramMemory['root'] = {
|
||||
log: {
|
||||
type: 'userVal',
|
||||
value: vi.fn(),
|
||||
__meta: [
|
||||
{
|
||||
sourceRange: [0, 0],
|
||||
pathToNode: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const { root } = await enginelessExecutor(parser_wasm(code), {
|
||||
root: programMemoryOverride,
|
||||
pendingMemory: {},
|
||||
})
|
||||
expect(root.myVar.value).toBe('hello')
|
||||
expect(programMemoryOverride.log.value).toHaveBeenCalledWith(5, 'hello')
|
||||
})
|
||||
it('fn funcN = () => {} execute', async () => {
|
||||
const { root } = await exe(
|
||||
[
|
||||
@ -84,8 +61,7 @@ show(mySketch)
|
||||
from: [0, 0],
|
||||
__geoMeta: {
|
||||
sourceRange: [43, 80],
|
||||
id: '37333036-3033-4432-b530-643030303837',
|
||||
pathToNode: [],
|
||||
id: expect.any(String),
|
||||
},
|
||||
name: 'myPath',
|
||||
},
|
||||
@ -93,10 +69,10 @@ show(mySketch)
|
||||
type: 'toPoint',
|
||||
to: [2, 3],
|
||||
from: [0, 2],
|
||||
name: '',
|
||||
__geoMeta: {
|
||||
sourceRange: [86, 102],
|
||||
id: '32343136-3330-4134-a462-376437386365',
|
||||
pathToNode: [],
|
||||
id: expect.any(String),
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -105,8 +81,7 @@ show(mySketch)
|
||||
from: [2, 3],
|
||||
__geoMeta: {
|
||||
sourceRange: [108, 151],
|
||||
id: '32306132-6130-4138-b832-636363326330',
|
||||
pathToNode: [],
|
||||
id: expect.any(String),
|
||||
},
|
||||
name: 'rightPath',
|
||||
},
|
||||
@ -170,13 +145,12 @@ show(mySketch)
|
||||
expect(root.mySk1).toEqual({
|
||||
type: 'sketchGroup',
|
||||
start: {
|
||||
type: 'base',
|
||||
to: [0, 0],
|
||||
from: [0, 0],
|
||||
name: '',
|
||||
__geoMeta: {
|
||||
id: '37663863-3664-4366-a637-623739336334',
|
||||
id: expect.any(String),
|
||||
sourceRange: [14, 34],
|
||||
pathToNode: [],
|
||||
},
|
||||
},
|
||||
value: [
|
||||
@ -184,10 +158,10 @@ show(mySketch)
|
||||
type: 'toPoint',
|
||||
to: [1, 1],
|
||||
from: [0, 0],
|
||||
name: '',
|
||||
__geoMeta: {
|
||||
sourceRange: [40, 56],
|
||||
id: '34356231-3362-4363-b935-393033353034',
|
||||
pathToNode: [],
|
||||
id: expect.any(String),
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -196,8 +170,7 @@ show(mySketch)
|
||||
from: [1, 1],
|
||||
__geoMeta: {
|
||||
sourceRange: [62, 100],
|
||||
id: '39623339-3538-4366-b633-356630326639',
|
||||
pathToNode: [],
|
||||
id: expect.any(String),
|
||||
},
|
||||
name: 'myPath',
|
||||
},
|
||||
@ -205,17 +178,17 @@ show(mySketch)
|
||||
type: 'toPoint',
|
||||
to: [1, 1],
|
||||
from: [0, 1],
|
||||
name: '',
|
||||
__geoMeta: {
|
||||
sourceRange: [106, 122],
|
||||
id: '30636135-6232-4335-b665-366562303161',
|
||||
pathToNode: [],
|
||||
id: expect.any(String),
|
||||
},
|
||||
},
|
||||
],
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
id: '30376661-3039-4965-b532-653665313731',
|
||||
__meta: [{ sourceRange: [14, 34], pathToNode: [] }],
|
||||
id: expect.any(String),
|
||||
__meta: [{ sourceRange: [14, 34] }],
|
||||
})
|
||||
})
|
||||
it('execute array expression', async () => {
|
||||
@ -230,13 +203,6 @@ show(mySketch)
|
||||
value: 3,
|
||||
__meta: [
|
||||
{
|
||||
pathToNode: [
|
||||
['body', ''],
|
||||
[0, 'index'],
|
||||
['declarations', 'VariableDeclaration'],
|
||||
[0, 'index'],
|
||||
['init', 'VariableDeclaration'],
|
||||
],
|
||||
sourceRange: [14, 15],
|
||||
},
|
||||
],
|
||||
@ -246,25 +212,8 @@ show(mySketch)
|
||||
value: [1, '2', 3, 9],
|
||||
__meta: [
|
||||
{
|
||||
pathToNode: [
|
||||
['body', ''],
|
||||
[1, 'index'],
|
||||
['declarations', 'VariableDeclaration'],
|
||||
[0, 'index'],
|
||||
['init', 'VariableDeclaration'],
|
||||
],
|
||||
sourceRange: [27, 49],
|
||||
},
|
||||
{
|
||||
pathToNode: [
|
||||
['body', ''],
|
||||
[0, 'index'],
|
||||
['declarations', 'VariableDeclaration'],
|
||||
[0, 'index'],
|
||||
['init', 'VariableDeclaration'],
|
||||
],
|
||||
sourceRange: [14, 15],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
@ -280,13 +229,6 @@ show(mySketch)
|
||||
value: { aStr: 'str', anum: 2, identifier: 3, binExp: 9 },
|
||||
__meta: [
|
||||
{
|
||||
pathToNode: [
|
||||
['body', ''],
|
||||
[1, 'index'],
|
||||
['declarations', 'VariableDeclaration'],
|
||||
[0, 'index'],
|
||||
['init', 'VariableDeclaration'],
|
||||
],
|
||||
sourceRange: [27, 83],
|
||||
},
|
||||
],
|
||||
@ -302,13 +244,6 @@ show(mySketch)
|
||||
value: '123',
|
||||
__meta: [
|
||||
{
|
||||
pathToNode: [
|
||||
['body', ''],
|
||||
[1, 'index'],
|
||||
['declarations', 'VariableDeclaration'],
|
||||
[0, 'index'],
|
||||
['init', 'VariableDeclaration'],
|
||||
],
|
||||
sourceRange: [41, 50],
|
||||
},
|
||||
],
|
||||
@ -451,17 +386,18 @@ const theExtrude = startSketchAt([0, 0])
|
||||
|> extrude(4, %)
|
||||
show(theExtrude)`
|
||||
await expect(exe(code)).rejects.toEqual(
|
||||
new KCLUndefinedValueError('Memory item myVarZ not found', [[100, 106]])
|
||||
new KCLError(
|
||||
'undefined_value',
|
||||
'memory item key `myVarZ` is not defined',
|
||||
[[100, 106]]
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// helpers
|
||||
|
||||
async function exe(
|
||||
code: string,
|
||||
programMemory: ProgramMemory = { root: {}, pendingMemory: {} }
|
||||
) {
|
||||
async function exe(code: string, programMemory: ProgramMemory = { root: {} }) {
|
||||
const ast = parser_wasm(code)
|
||||
|
||||
const result = await enginelessExecutor(ast, programMemory)
|
||||
|
@ -1,35 +1,19 @@
|
||||
import {
|
||||
Program,
|
||||
BinaryPart,
|
||||
BinaryExpression,
|
||||
PipeExpression,
|
||||
ObjectExpression,
|
||||
MemberExpression,
|
||||
Identifier,
|
||||
CallExpression,
|
||||
ArrayExpression,
|
||||
UnaryExpression,
|
||||
} from './abstractSyntaxTreeTypes'
|
||||
import { InternalFnNames } from './std/stdTypes'
|
||||
import { internalFns } from './std/std'
|
||||
import {
|
||||
KCLUndefinedValueError,
|
||||
KCLValueAlreadyDefined,
|
||||
KCLSyntaxError,
|
||||
KCLSemanticError,
|
||||
KCLTypeError,
|
||||
} from './errors'
|
||||
import { Program } from './abstractSyntaxTreeTypes'
|
||||
import {
|
||||
EngineCommandManager,
|
||||
ArtifactMap,
|
||||
SourceRangeMap,
|
||||
} from './std/engineConnection'
|
||||
import { ProgramReturn } from '../wasm-lib/kcl/bindings/ProgramReturn'
|
||||
import { execute_wasm } from '../wasm-lib/pkg/wasm_lib'
|
||||
import { KCLError } from './errors'
|
||||
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
|
||||
import { rangeTypeFix } from './abstractSyntaxTree'
|
||||
|
||||
export type SourceRange = [number, number]
|
||||
export type PathToNode = [string | number, string][] // [pathKey, nodeType][]
|
||||
export type Metadata = {
|
||||
sourceRange: SourceRange
|
||||
pathToNode: PathToNode
|
||||
}
|
||||
export type Position = [number, number, number]
|
||||
export type Rotation = [number, number, number, number]
|
||||
@ -41,7 +25,6 @@ interface BasePath {
|
||||
__geoMeta: {
|
||||
id: string
|
||||
sourceRange: SourceRange
|
||||
pathToNode: PathToNode
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,9 +51,7 @@ export interface AngledLineTo extends BasePath {
|
||||
interface GeoMeta {
|
||||
__geoMeta: {
|
||||
id: string
|
||||
refId?: string
|
||||
sourceRange: SourceRange
|
||||
pathToNode: PathToNode
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,69 +99,16 @@ type MemoryItem = UserVal | SketchGroup | ExtrudeGroup
|
||||
interface Memory {
|
||||
[key: string]: MemoryItem
|
||||
}
|
||||
interface PendingMemory {
|
||||
[key: string]: Promise<MemoryItem>
|
||||
}
|
||||
|
||||
export interface ProgramMemory {
|
||||
root: Memory
|
||||
pendingMemory: Partial<PendingMemory>
|
||||
return?: Identifier[]
|
||||
}
|
||||
|
||||
const addItemToMemory = (
|
||||
programMemory: ProgramMemory,
|
||||
key: string,
|
||||
sourceRange: [[number, number]],
|
||||
value: MemoryItem | Promise<MemoryItem>
|
||||
) => {
|
||||
const _programMemory = programMemory
|
||||
if (_programMemory.root[key] || _programMemory.pendingMemory[key]) {
|
||||
throw new KCLValueAlreadyDefined(key, sourceRange)
|
||||
}
|
||||
if (value instanceof Promise) {
|
||||
_programMemory.pendingMemory[key] = value
|
||||
value.then((resolvedValue) => {
|
||||
_programMemory.root[key] = resolvedValue
|
||||
delete _programMemory.pendingMemory[key]
|
||||
})
|
||||
} else {
|
||||
_programMemory.root[key] = value
|
||||
}
|
||||
return _programMemory
|
||||
}
|
||||
|
||||
const promisifyMemoryItem = async (obj: MemoryItem) => {
|
||||
if (obj.value instanceof Promise) {
|
||||
const resolvedGuy = await obj.value
|
||||
return {
|
||||
...obj,
|
||||
value: resolvedGuy,
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
const getMemoryItem = async (
|
||||
programMemory: ProgramMemory,
|
||||
key: string,
|
||||
sourceRanges: [number, number][]
|
||||
): Promise<MemoryItem> => {
|
||||
if (programMemory.root[key]) {
|
||||
return programMemory.root[key]
|
||||
}
|
||||
if (programMemory.pendingMemory[key]) {
|
||||
return programMemory.pendingMemory[key] as Promise<MemoryItem>
|
||||
}
|
||||
throw new KCLUndefinedValueError(`Memory item ${key} not found`, sourceRanges)
|
||||
return?: ProgramReturn
|
||||
}
|
||||
|
||||
export const executor = async (
|
||||
node: Program,
|
||||
programMemory: ProgramMemory = { root: {}, pendingMemory: {} },
|
||||
programMemory: ProgramMemory = { root: {} },
|
||||
engineCommandManager: EngineCommandManager,
|
||||
options: { bodyType: 'root' | 'sketch' | 'block' } = { bodyType: 'root' },
|
||||
previousPathToNode: PathToNode = [],
|
||||
// work around while the gemotry is still be stored on the frontend
|
||||
// will be removed when the stream UI is added.
|
||||
tempMapCallback: (a: {
|
||||
@ -192,9 +120,7 @@ export const executor = async (
|
||||
const _programMemory = await _executor(
|
||||
node,
|
||||
programMemory,
|
||||
engineCommandManager,
|
||||
options,
|
||||
previousPathToNode
|
||||
engineCommandManager
|
||||
)
|
||||
const { artifactMap, sourceRangeMap } =
|
||||
await engineCommandManager.waitForAllCommands()
|
||||
@ -206,840 +132,25 @@ export const executor = async (
|
||||
|
||||
export const _executor = async (
|
||||
node: Program,
|
||||
programMemory: ProgramMemory = { root: {}, pendingMemory: {} },
|
||||
engineCommandManager: EngineCommandManager,
|
||||
options: { bodyType: 'root' | 'sketch' | 'block' } = { bodyType: 'root' },
|
||||
previousPathToNode: PathToNode = []
|
||||
programMemory: ProgramMemory = { root: {} },
|
||||
engineCommandManager: EngineCommandManager
|
||||
): Promise<ProgramMemory> => {
|
||||
let _programMemory: ProgramMemory = {
|
||||
root: {
|
||||
...programMemory.root,
|
||||
},
|
||||
pendingMemory: {
|
||||
...programMemory.pendingMemory,
|
||||
},
|
||||
return: programMemory.return,
|
||||
}
|
||||
const { body } = node
|
||||
const proms: Promise<any>[] = []
|
||||
for (let bodyIndex = 0; bodyIndex < body.length; bodyIndex++) {
|
||||
const statement = body[bodyIndex]
|
||||
if (statement.type === 'VariableDeclaration') {
|
||||
for (let index = 0; index < statement.declarations.length; index++) {
|
||||
const declaration = statement.declarations[index]
|
||||
const variableName = declaration.id.name
|
||||
const pathToNode: PathToNode = [
|
||||
...previousPathToNode,
|
||||
['body', ''],
|
||||
[bodyIndex, 'index'],
|
||||
['declarations', 'VariableDeclaration'],
|
||||
[index, 'index'],
|
||||
['init', 'VariableDeclaration'],
|
||||
]
|
||||
const sourceRange: SourceRange = [
|
||||
declaration.init.start,
|
||||
declaration.init.end,
|
||||
]
|
||||
const __meta: Metadata[] = [
|
||||
{
|
||||
pathToNode,
|
||||
sourceRange,
|
||||
},
|
||||
]
|
||||
|
||||
if (declaration.init.type === 'PipeExpression') {
|
||||
const prom = getPipeExpressionResult(
|
||||
declaration.init,
|
||||
_programMemory,
|
||||
engineCommandManager,
|
||||
pathToNode
|
||||
)
|
||||
proms.push(prom)
|
||||
const value = await prom
|
||||
if (value?.type === 'sketchGroup' || value?.type === 'extrudeGroup') {
|
||||
_programMemory = addItemToMemory(
|
||||
_programMemory,
|
||||
variableName,
|
||||
[sourceRange],
|
||||
value
|
||||
)
|
||||
} else {
|
||||
_programMemory = addItemToMemory(
|
||||
_programMemory,
|
||||
variableName,
|
||||
[sourceRange],
|
||||
{
|
||||
type: 'userVal',
|
||||
value,
|
||||
__meta,
|
||||
}
|
||||
)
|
||||
}
|
||||
} else if (declaration.init.type === 'Identifier') {
|
||||
_programMemory = addItemToMemory(
|
||||
_programMemory,
|
||||
variableName,
|
||||
[sourceRange],
|
||||
{
|
||||
type: 'userVal',
|
||||
value: _programMemory.root[declaration.init.name].value,
|
||||
__meta,
|
||||
}
|
||||
)
|
||||
} else if (declaration.init.type === 'Literal') {
|
||||
_programMemory = addItemToMemory(
|
||||
_programMemory,
|
||||
variableName,
|
||||
[sourceRange],
|
||||
{
|
||||
type: 'userVal',
|
||||
value: declaration.init.value,
|
||||
__meta,
|
||||
}
|
||||
)
|
||||
} else if (declaration.init.type === 'BinaryExpression') {
|
||||
const prom = getBinaryExpressionResult(
|
||||
declaration.init,
|
||||
_programMemory,
|
||||
engineCommandManager
|
||||
)
|
||||
proms.push(prom)
|
||||
_programMemory = addItemToMemory(
|
||||
_programMemory,
|
||||
variableName,
|
||||
[sourceRange],
|
||||
promisifyMemoryItem({
|
||||
type: 'userVal',
|
||||
value: prom,
|
||||
__meta,
|
||||
})
|
||||
)
|
||||
} else if (declaration.init.type === 'UnaryExpression') {
|
||||
const prom = getUnaryExpressionResult(
|
||||
declaration.init,
|
||||
_programMemory,
|
||||
engineCommandManager
|
||||
)
|
||||
proms.push(prom)
|
||||
_programMemory = addItemToMemory(
|
||||
_programMemory,
|
||||
variableName,
|
||||
[sourceRange],
|
||||
promisifyMemoryItem({
|
||||
type: 'userVal',
|
||||
value: prom,
|
||||
__meta,
|
||||
})
|
||||
)
|
||||
} else if (declaration.init.type === 'ArrayExpression') {
|
||||
const valueInfo: Promise<{ value: any; __meta?: Metadata }>[] =
|
||||
declaration.init.elements.map(
|
||||
async (element): Promise<{ value: any; __meta?: Metadata }> => {
|
||||
if (element.type === 'Literal') {
|
||||
return {
|
||||
value: element.value,
|
||||
}
|
||||
} else if (element.type === 'BinaryExpression') {
|
||||
const prom = getBinaryExpressionResult(
|
||||
element,
|
||||
_programMemory,
|
||||
engineCommandManager
|
||||
)
|
||||
proms.push(prom)
|
||||
return {
|
||||
value: await prom,
|
||||
}
|
||||
} else if (element.type === 'PipeExpression') {
|
||||
const prom = getPipeExpressionResult(
|
||||
element,
|
||||
_programMemory,
|
||||
engineCommandManager,
|
||||
pathToNode
|
||||
)
|
||||
proms.push(prom)
|
||||
return {
|
||||
value: await prom,
|
||||
}
|
||||
} else if (element.type === 'Identifier') {
|
||||
const node = await getMemoryItem(
|
||||
_programMemory,
|
||||
element.name,
|
||||
[[element.start, element.end]]
|
||||
)
|
||||
return {
|
||||
value: node.value,
|
||||
__meta: node.__meta[node.__meta.length - 1],
|
||||
}
|
||||
} else if (element.type === 'UnaryExpression') {
|
||||
const prom = getUnaryExpressionResult(
|
||||
element,
|
||||
_programMemory,
|
||||
engineCommandManager
|
||||
)
|
||||
proms.push(prom)
|
||||
return {
|
||||
value: await prom,
|
||||
}
|
||||
} else {
|
||||
throw new KCLSyntaxError(
|
||||
`Unexpected element type ${element.type} in array expression`,
|
||||
// TODO: Refactor this whole block into a `switch` so that we have a specific
|
||||
// type here and can put a sourceRange.
|
||||
[]
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
const awaitedValueInfo = await Promise.all(valueInfo)
|
||||
const meta = awaitedValueInfo
|
||||
.filter(({ __meta }) => __meta)
|
||||
.map(({ __meta }) => __meta) as Metadata[]
|
||||
_programMemory = addItemToMemory(
|
||||
_programMemory,
|
||||
variableName,
|
||||
[sourceRange],
|
||||
{
|
||||
type: 'userVal',
|
||||
value: awaitedValueInfo.map(({ value }) => value),
|
||||
__meta: [...__meta, ...meta],
|
||||
}
|
||||
)
|
||||
} else if (declaration.init.type === 'ObjectExpression') {
|
||||
const prom = executeObjectExpression(
|
||||
_programMemory,
|
||||
declaration.init,
|
||||
engineCommandManager
|
||||
)
|
||||
proms.push(prom)
|
||||
_programMemory = addItemToMemory(
|
||||
_programMemory,
|
||||
variableName,
|
||||
[sourceRange],
|
||||
promisifyMemoryItem({
|
||||
type: 'userVal',
|
||||
value: prom,
|
||||
__meta,
|
||||
})
|
||||
)
|
||||
} else if (declaration.init.type === 'FunctionExpression') {
|
||||
const fnInit = declaration.init
|
||||
|
||||
_programMemory = addItemToMemory(
|
||||
_programMemory,
|
||||
declaration.id.name,
|
||||
[sourceRange],
|
||||
{
|
||||
type: 'userVal',
|
||||
value: async (...args: any[]) => {
|
||||
let fnMemory: ProgramMemory = {
|
||||
root: {
|
||||
..._programMemory.root,
|
||||
},
|
||||
pendingMemory: {
|
||||
..._programMemory.pendingMemory,
|
||||
},
|
||||
}
|
||||
if (args.length > fnInit.params.length) {
|
||||
throw new KCLSyntaxError(
|
||||
`Too many arguments passed to function ${declaration.id.name}`,
|
||||
[[declaration.start, declaration.end]]
|
||||
)
|
||||
} else if (args.length < fnInit.params.length) {
|
||||
throw new KCLSyntaxError(
|
||||
`Too few arguments passed to function ${declaration.id.name}`,
|
||||
[[declaration.start, declaration.end]]
|
||||
)
|
||||
}
|
||||
fnInit.params.forEach((param, index) => {
|
||||
fnMemory = addItemToMemory(
|
||||
fnMemory,
|
||||
param.name,
|
||||
[sourceRange],
|
||||
{
|
||||
type: 'userVal',
|
||||
value: args[index],
|
||||
__meta,
|
||||
}
|
||||
)
|
||||
})
|
||||
const prom = _executor(
|
||||
fnInit.body,
|
||||
fnMemory,
|
||||
engineCommandManager,
|
||||
{
|
||||
bodyType: 'block',
|
||||
}
|
||||
)
|
||||
proms.push(prom)
|
||||
const result = (await prom).return
|
||||
return result
|
||||
},
|
||||
__meta,
|
||||
}
|
||||
)
|
||||
} else if (declaration.init.type === 'MemberExpression') {
|
||||
await Promise.all([...proms]) // TODO wait for previous promises, does that makes sense?
|
||||
const prom = getMemberExpressionResult(
|
||||
declaration.init,
|
||||
_programMemory
|
||||
)
|
||||
proms.push(prom)
|
||||
_programMemory = addItemToMemory(
|
||||
_programMemory,
|
||||
variableName,
|
||||
[sourceRange],
|
||||
promisifyMemoryItem({
|
||||
type: 'userVal',
|
||||
value: prom,
|
||||
__meta,
|
||||
})
|
||||
)
|
||||
} else if (declaration.init.type === 'CallExpression') {
|
||||
const prom = executeCallExpression(
|
||||
_programMemory,
|
||||
declaration.init,
|
||||
engineCommandManager,
|
||||
previousPathToNode
|
||||
)
|
||||
proms.push(prom)
|
||||
_programMemory = addItemToMemory(
|
||||
_programMemory,
|
||||
variableName,
|
||||
[sourceRange],
|
||||
prom.then((a) => {
|
||||
return a?.type === 'sketchGroup' || a?.type === 'extrudeGroup'
|
||||
? a
|
||||
: {
|
||||
type: 'userVal',
|
||||
value: a,
|
||||
__meta,
|
||||
}
|
||||
})
|
||||
)
|
||||
} else {
|
||||
throw new KCLSyntaxError(
|
||||
'Unsupported declaration type: ' + declaration.init.type,
|
||||
[[declaration.start, declaration.end]]
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (statement.type === 'ExpressionStatement') {
|
||||
const expression = statement.expression
|
||||
if (expression.type === 'CallExpression') {
|
||||
const functionName = expression.callee.name
|
||||
const args = expression.arguments.map((arg) => {
|
||||
if (arg.type === 'Literal') {
|
||||
return arg.value
|
||||
} else if (arg.type === 'Identifier') {
|
||||
return _programMemory.root[arg.name]?.value
|
||||
}
|
||||
})
|
||||
if ('show' === functionName) {
|
||||
if (options.bodyType !== 'root') {
|
||||
throw new KCLSemanticError(
|
||||
`Cannot call ${functionName} outside of a root`,
|
||||
[[statement.start, statement.end]]
|
||||
)
|
||||
}
|
||||
_programMemory.return = expression.arguments as any // todo memory redo
|
||||
} else {
|
||||
if (_programMemory.root[functionName] === undefined) {
|
||||
throw new KCLSemanticError(`No such name ${functionName} defined`, [
|
||||
[statement.start, statement.end],
|
||||
])
|
||||
}
|
||||
_programMemory.root[functionName].value(...args)
|
||||
}
|
||||
}
|
||||
} else if (statement.type === 'ReturnStatement') {
|
||||
if (statement.argument.type === 'BinaryExpression') {
|
||||
const prom = getBinaryExpressionResult(
|
||||
statement.argument,
|
||||
_programMemory,
|
||||
engineCommandManager
|
||||
)
|
||||
proms.push(prom)
|
||||
_programMemory.return = await prom
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(proms)
|
||||
return _programMemory
|
||||
}
|
||||
|
||||
function getMemberExpressionResult(
|
||||
expression: MemberExpression,
|
||||
programMemory: ProgramMemory
|
||||
) {
|
||||
const propertyName = (
|
||||
expression.property.type === 'Identifier'
|
||||
? expression.property.name
|
||||
: expression.property.value
|
||||
) as any
|
||||
const object: any =
|
||||
expression.object.type === 'MemberExpression'
|
||||
? getMemberExpressionResult(expression.object, programMemory)
|
||||
: programMemory.root[expression.object.name]?.value
|
||||
return object?.[propertyName]
|
||||
}
|
||||
|
||||
async function getBinaryExpressionResult(
|
||||
expression: BinaryExpression,
|
||||
programMemory: ProgramMemory,
|
||||
engineCommandManager: EngineCommandManager,
|
||||
pipeInfo: {
|
||||
isInPipe: boolean
|
||||
previousResults: any[]
|
||||
expressionIndex: number
|
||||
body: PipeExpression['body']
|
||||
sourceRangeOverride?: SourceRange
|
||||
} = {
|
||||
isInPipe: false,
|
||||
previousResults: [],
|
||||
expressionIndex: 0,
|
||||
body: [],
|
||||
}
|
||||
) {
|
||||
const _pipeInfo = {
|
||||
...pipeInfo,
|
||||
isInPipe: false,
|
||||
}
|
||||
const left = await getBinaryPartResult(
|
||||
expression.left,
|
||||
programMemory,
|
||||
engineCommandManager,
|
||||
_pipeInfo
|
||||
)
|
||||
const right = await getBinaryPartResult(
|
||||
expression.right,
|
||||
programMemory,
|
||||
engineCommandManager,
|
||||
_pipeInfo
|
||||
)
|
||||
if (expression.operator === '+') return left + right
|
||||
if (expression.operator === '-') return left - right
|
||||
if (expression.operator === '*') return left * right
|
||||
if (expression.operator === '/') return left / right
|
||||
if (expression.operator === '%') return left % right
|
||||
}
|
||||
|
||||
async function getBinaryPartResult(
|
||||
part: BinaryPart,
|
||||
programMemory: ProgramMemory,
|
||||
engineCommandManager: EngineCommandManager,
|
||||
pipeInfo: {
|
||||
isInPipe: boolean
|
||||
previousResults: any[]
|
||||
expressionIndex: number
|
||||
body: PipeExpression['body']
|
||||
sourceRangeOverride?: SourceRange
|
||||
} = {
|
||||
isInPipe: false,
|
||||
previousResults: [],
|
||||
expressionIndex: 0,
|
||||
body: [],
|
||||
}
|
||||
): Promise<any> {
|
||||
const _pipeInfo = {
|
||||
...pipeInfo,
|
||||
isInPipe: false,
|
||||
}
|
||||
if (part.type === 'Literal') {
|
||||
return part.value
|
||||
} else if (part.type === 'Identifier') {
|
||||
return programMemory.root[part.name].value
|
||||
} else if (part.type === 'BinaryExpression') {
|
||||
const prom = getBinaryExpressionResult(
|
||||
part,
|
||||
programMemory,
|
||||
engineCommandManager,
|
||||
_pipeInfo
|
||||
)
|
||||
const result = await prom
|
||||
return result
|
||||
} else if (part.type === 'CallExpression') {
|
||||
const result = await executeCallExpression(
|
||||
programMemory,
|
||||
part,
|
||||
engineCommandManager,
|
||||
[],
|
||||
_pipeInfo
|
||||
)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
async function getUnaryExpressionResult(
|
||||
expression: UnaryExpression,
|
||||
programMemory: ProgramMemory,
|
||||
engineCommandManager: EngineCommandManager,
|
||||
pipeInfo: {
|
||||
isInPipe: boolean
|
||||
previousResults: any[]
|
||||
expressionIndex: number
|
||||
body: PipeExpression['body']
|
||||
sourceRangeOverride?: SourceRange
|
||||
} = {
|
||||
isInPipe: false,
|
||||
previousResults: [],
|
||||
expressionIndex: 0,
|
||||
body: [],
|
||||
}
|
||||
) {
|
||||
return -(await getBinaryPartResult(
|
||||
expression.argument,
|
||||
programMemory,
|
||||
engineCommandManager,
|
||||
{
|
||||
...pipeInfo,
|
||||
isInPipe: false,
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
async function getPipeExpressionResult(
|
||||
expression: PipeExpression,
|
||||
programMemory: ProgramMemory,
|
||||
engineCommandManager: EngineCommandManager,
|
||||
previousPathToNode: PathToNode = []
|
||||
) {
|
||||
const executedBody = await executePipeBody(
|
||||
expression.body,
|
||||
programMemory,
|
||||
engineCommandManager,
|
||||
previousPathToNode
|
||||
)
|
||||
const result = executedBody[executedBody.length - 1]
|
||||
return result
|
||||
}
|
||||
|
||||
async function executePipeBody(
|
||||
body: PipeExpression['body'],
|
||||
programMemory: ProgramMemory,
|
||||
engineCommandManager: EngineCommandManager,
|
||||
previousPathToNode: PathToNode = [],
|
||||
expressionIndex = 0,
|
||||
previousResults: any[] = []
|
||||
): Promise<any[]> {
|
||||
if (expressionIndex === body.length) {
|
||||
return previousResults
|
||||
}
|
||||
const expression = body[expressionIndex]
|
||||
if (expression.type === 'BinaryExpression') {
|
||||
const result = await getBinaryExpressionResult(
|
||||
expression,
|
||||
programMemory,
|
||||
try {
|
||||
const memory: ProgramMemory = await execute_wasm(
|
||||
JSON.stringify(node),
|
||||
JSON.stringify(programMemory),
|
||||
engineCommandManager
|
||||
)
|
||||
return executePipeBody(
|
||||
body,
|
||||
programMemory,
|
||||
engineCommandManager,
|
||||
previousPathToNode,
|
||||
expressionIndex + 1,
|
||||
[...previousResults, result]
|
||||
return memory
|
||||
} catch (e: any) {
|
||||
const parsed: RustKclError = JSON.parse(e.toString())
|
||||
const kclError = new KCLError(
|
||||
parsed.kind,
|
||||
parsed.msg,
|
||||
rangeTypeFix(parsed.sourceRanges)
|
||||
)
|
||||
} else if (expression.type === 'CallExpression') {
|
||||
return await executeCallExpression(
|
||||
programMemory,
|
||||
expression,
|
||||
engineCommandManager,
|
||||
previousPathToNode,
|
||||
{
|
||||
isInPipe: true,
|
||||
previousResults,
|
||||
expressionIndex,
|
||||
body,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
throw new KCLSyntaxError('Invalid pipe expression', [
|
||||
[expression.start, expression.end],
|
||||
])
|
||||
}
|
||||
|
||||
async function executeObjectExpression(
|
||||
_programMemory: ProgramMemory,
|
||||
objExp: ObjectExpression,
|
||||
engineCommandManager: EngineCommandManager,
|
||||
pipeInfo: {
|
||||
isInPipe: boolean
|
||||
previousResults: any[]
|
||||
expressionIndex: number
|
||||
body: PipeExpression['body']
|
||||
sourceRangeOverride?: SourceRange
|
||||
} = {
|
||||
isInPipe: false,
|
||||
previousResults: [],
|
||||
expressionIndex: 0,
|
||||
body: [],
|
||||
}
|
||||
) {
|
||||
const _pipeInfo = {
|
||||
...pipeInfo,
|
||||
isInPipe: false,
|
||||
}
|
||||
const obj: { [key: string]: any } = {}
|
||||
const proms: Promise<any>[] = []
|
||||
objExp.properties.forEach(async (property) => {
|
||||
if (property.type === 'ObjectProperty') {
|
||||
if (property.value.type === 'Literal') {
|
||||
obj[property.key.name] = property.value.value
|
||||
} else if (property.value.type === 'BinaryExpression') {
|
||||
const prom = getBinaryExpressionResult(
|
||||
property.value,
|
||||
_programMemory,
|
||||
engineCommandManager,
|
||||
_pipeInfo
|
||||
)
|
||||
proms.push(prom)
|
||||
obj[property.key.name] = await prom
|
||||
} else if (property.value.type === 'PipeExpression') {
|
||||
const prom = getPipeExpressionResult(
|
||||
property.value,
|
||||
_programMemory,
|
||||
engineCommandManager
|
||||
)
|
||||
proms.push(prom)
|
||||
obj[property.key.name] = await prom
|
||||
} else if (property.value.type === 'Identifier') {
|
||||
obj[property.key.name] = (
|
||||
await getMemoryItem(_programMemory, property.value.name, [
|
||||
[property.value.start, property.value.end],
|
||||
])
|
||||
).value
|
||||
} else if (property.value.type === 'ObjectExpression') {
|
||||
const prom = executeObjectExpression(
|
||||
_programMemory,
|
||||
property.value,
|
||||
engineCommandManager
|
||||
)
|
||||
proms.push(prom)
|
||||
obj[property.key.name] = await prom
|
||||
} else if (property.value.type === 'ArrayExpression') {
|
||||
const prom = executeArrayExpression(
|
||||
_programMemory,
|
||||
property.value,
|
||||
engineCommandManager
|
||||
)
|
||||
proms.push(prom)
|
||||
obj[property.key.name] = await prom
|
||||
} else if (property.value.type === 'CallExpression') {
|
||||
const prom = executeCallExpression(
|
||||
_programMemory,
|
||||
property.value,
|
||||
engineCommandManager,
|
||||
[],
|
||||
_pipeInfo
|
||||
)
|
||||
proms.push(prom)
|
||||
const result = await prom
|
||||
obj[property.key.name] = result
|
||||
} else if (property.value.type === 'UnaryExpression') {
|
||||
const prom = getUnaryExpressionResult(
|
||||
property.value,
|
||||
_programMemory,
|
||||
engineCommandManager
|
||||
)
|
||||
proms.push(prom)
|
||||
obj[property.key.name] = await prom
|
||||
} else {
|
||||
throw new KCLSyntaxError(
|
||||
`Unexpected property type ${property.value.type} in object expression`,
|
||||
[[property.value.start, property.value.end]]
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw new KCLSyntaxError(
|
||||
`Unexpected property type ${property.type} in object expression`,
|
||||
[[property.value.start, property.value.end]]
|
||||
)
|
||||
}
|
||||
})
|
||||
await Promise.all(proms)
|
||||
return obj
|
||||
}
|
||||
|
||||
async function executeArrayExpression(
|
||||
_programMemory: ProgramMemory,
|
||||
arrExp: ArrayExpression,
|
||||
engineCommandManager: EngineCommandManager,
|
||||
pipeInfo: {
|
||||
isInPipe: boolean
|
||||
previousResults: any[]
|
||||
expressionIndex: number
|
||||
body: PipeExpression['body']
|
||||
sourceRangeOverride?: SourceRange
|
||||
} = {
|
||||
isInPipe: false,
|
||||
previousResults: [],
|
||||
expressionIndex: 0,
|
||||
body: [],
|
||||
}
|
||||
) {
|
||||
const _pipeInfo = {
|
||||
...pipeInfo,
|
||||
isInPipe: false,
|
||||
}
|
||||
return await Promise.all(
|
||||
arrExp.elements.map((el) => {
|
||||
if (el.type === 'Literal') {
|
||||
return el.value
|
||||
} else if (el.type === 'Identifier') {
|
||||
return _programMemory.root?.[el.name]?.value
|
||||
} else if (el.type === 'BinaryExpression') {
|
||||
return getBinaryExpressionResult(
|
||||
el,
|
||||
_programMemory,
|
||||
engineCommandManager,
|
||||
_pipeInfo
|
||||
)
|
||||
} else if (el.type === 'ObjectExpression') {
|
||||
return executeObjectExpression(_programMemory, el, engineCommandManager)
|
||||
} else if (el.type === 'CallExpression') {
|
||||
const result: any = executeCallExpression(
|
||||
_programMemory,
|
||||
el,
|
||||
engineCommandManager,
|
||||
[],
|
||||
_pipeInfo
|
||||
)
|
||||
return result
|
||||
} else if (el.type === 'UnaryExpression') {
|
||||
return getUnaryExpressionResult(
|
||||
el,
|
||||
_programMemory,
|
||||
engineCommandManager,
|
||||
{
|
||||
...pipeInfo,
|
||||
isInPipe: false,
|
||||
}
|
||||
)
|
||||
}
|
||||
throw new KCLTypeError('Invalid argument type', [[el.start, el.end]])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function executeCallExpression(
|
||||
programMemory: ProgramMemory,
|
||||
expression: CallExpression,
|
||||
engineCommandManager: EngineCommandManager,
|
||||
previousPathToNode: PathToNode = [],
|
||||
pipeInfo: {
|
||||
isInPipe: boolean
|
||||
previousResults: any[]
|
||||
expressionIndex: number
|
||||
body: PipeExpression['body']
|
||||
sourceRangeOverride?: SourceRange
|
||||
} = {
|
||||
isInPipe: false,
|
||||
previousResults: [],
|
||||
expressionIndex: 0,
|
||||
body: [],
|
||||
}
|
||||
) {
|
||||
const {
|
||||
isInPipe,
|
||||
previousResults,
|
||||
expressionIndex,
|
||||
body,
|
||||
sourceRangeOverride,
|
||||
} = pipeInfo
|
||||
const functionName = expression?.callee?.name
|
||||
const _pipeInfo = {
|
||||
...pipeInfo,
|
||||
isInPipe: false,
|
||||
}
|
||||
const fnArgs = await Promise.all(
|
||||
expression?.arguments?.map(async (arg) => {
|
||||
if (arg.type === 'Literal') {
|
||||
return arg.value
|
||||
} else if (arg.type === 'Identifier') {
|
||||
await new Promise((r) => setTimeout(r)) // push into next even loop, but also probably should fix this
|
||||
const temp = await getMemoryItem(programMemory, arg.name, [
|
||||
[arg.start, arg.end],
|
||||
])
|
||||
return temp?.type === 'userVal' ? temp.value : temp
|
||||
} else if (arg.type === 'PipeSubstitution') {
|
||||
return previousResults[expressionIndex - 1]
|
||||
} else if (arg.type === 'ArrayExpression') {
|
||||
return await executeArrayExpression(
|
||||
programMemory,
|
||||
arg,
|
||||
engineCommandManager,
|
||||
pipeInfo
|
||||
)
|
||||
} else if (arg.type === 'CallExpression') {
|
||||
const result: any = await executeCallExpression(
|
||||
programMemory,
|
||||
arg,
|
||||
engineCommandManager,
|
||||
previousPathToNode,
|
||||
_pipeInfo
|
||||
)
|
||||
return result
|
||||
} else if (arg.type === 'ObjectExpression') {
|
||||
return await executeObjectExpression(
|
||||
programMemory,
|
||||
arg,
|
||||
engineCommandManager,
|
||||
_pipeInfo
|
||||
)
|
||||
} else if (arg.type === 'UnaryExpression') {
|
||||
return getUnaryExpressionResult(
|
||||
arg,
|
||||
programMemory,
|
||||
engineCommandManager,
|
||||
_pipeInfo
|
||||
)
|
||||
} else if (arg.type === 'BinaryExpression') {
|
||||
return getBinaryExpressionResult(
|
||||
arg,
|
||||
programMemory,
|
||||
engineCommandManager,
|
||||
_pipeInfo
|
||||
)
|
||||
}
|
||||
throw new KCLSyntaxError('Invalid argument type in function call', [
|
||||
[arg.start, arg.end],
|
||||
])
|
||||
})
|
||||
)
|
||||
if (functionName in internalFns) {
|
||||
const fnNameWithSketchOrExtrude = functionName as InternalFnNames
|
||||
const result = await internalFns[fnNameWithSketchOrExtrude](
|
||||
{
|
||||
programMemory,
|
||||
sourceRange: sourceRangeOverride || [expression.start, expression.end],
|
||||
engineCommandManager,
|
||||
code: JSON.stringify(expression),
|
||||
},
|
||||
fnArgs[0],
|
||||
fnArgs[1],
|
||||
fnArgs[2]
|
||||
)
|
||||
return isInPipe
|
||||
? await executePipeBody(
|
||||
body,
|
||||
programMemory,
|
||||
engineCommandManager,
|
||||
previousPathToNode,
|
||||
expressionIndex + 1,
|
||||
[...previousResults, result]
|
||||
)
|
||||
: result
|
||||
}
|
||||
const result = await programMemory.root[functionName].value(...fnArgs)
|
||||
return isInPipe
|
||||
? await executePipeBody(
|
||||
body,
|
||||
programMemory,
|
||||
engineCommandManager,
|
||||
previousPathToNode,
|
||||
expressionIndex + 1,
|
||||
[...previousResults, result]
|
||||
)
|
||||
: result
|
||||
console.log(kclError)
|
||||
throw kclError
|
||||
}
|
||||
}
|
||||
|
@ -182,14 +182,14 @@ describe('Testing moveValueIntoNewVariable', () => {
|
||||
const code = `${fn('def')}${fn('ghi')}${fn('jkl')}${fn('hmm')}
|
||||
const abc = 3
|
||||
const identifierGuy = 5
|
||||
const yo = 5 + 6
|
||||
const part001 = startSketchAt([-1.2, 4.83])
|
||||
|> line([2.8, 0], %)
|
||||
|> angledLine([100 + 100, 3.09], %)
|
||||
|> angledLine([abc, 3.09], %)
|
||||
|> angledLine([def('yo'), 3.09], %)
|
||||
|> angledLine([def(yo), 3.09], %)
|
||||
|> angledLine([ghi(%), 3.09], %)
|
||||
|> angledLine([jkl('yo') + 2, 3.09], %)
|
||||
const yo = 5 + 6
|
||||
|> angledLine([jkl(yo) + 2, 3.09], %)
|
||||
const yo2 = hmm([identifierGuy + 5])
|
||||
show(part001)`
|
||||
it('should move a binary expression into a new variable', async () => {
|
||||
@ -231,7 +231,7 @@ show(part001)`
|
||||
'newVar'
|
||||
)
|
||||
const newCode = recast(modifiedAst)
|
||||
expect(newCode).toContain(`const newVar = def('yo')`)
|
||||
expect(newCode).toContain(`const newVar = def(yo)`)
|
||||
expect(newCode).toContain(`angledLine([newVar, 3.09], %)`)
|
||||
})
|
||||
it('should move a binary expression with call expression into a new variable', async () => {
|
||||
@ -245,7 +245,7 @@ show(part001)`
|
||||
'newVar'
|
||||
)
|
||||
const newCode = recast(modifiedAst)
|
||||
expect(newCode).toContain(`const newVar = jkl('yo') + 2`)
|
||||
expect(newCode).toContain(`const newVar = jkl(yo) + 2`)
|
||||
expect(newCode).toContain(`angledLine([newVar, 3.09], %)`)
|
||||
})
|
||||
it('should move a identifier into a new variable', async () => {
|
||||
|
@ -36,14 +36,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 +112,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 +126,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 +227,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 +315,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 +416,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 +464,9 @@ export function createCallExpression(
|
||||
end: 0,
|
||||
name,
|
||||
},
|
||||
function: {
|
||||
type: 'InMemory',
|
||||
},
|
||||
optional: false,
|
||||
arguments: args,
|
||||
}
|
||||
|
@ -45,8 +45,7 @@ const newVar = myVar + 1`
|
||||
expect(recasted).toBe(code.trim())
|
||||
})
|
||||
it('test with function call', () => {
|
||||
const code = `
|
||||
const myVar = "hello"
|
||||
const code = `const myVar = "hello"
|
||||
log(5, myVar)`
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
@ -71,8 +70,7 @@ log(5, myVar)`
|
||||
|> lineTo({ to: [1, 0], tag: "rightPath" }, %)
|
||||
|> close(%)
|
||||
|
||||
show(mySketch)
|
||||
`
|
||||
show(mySketch)`
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
expect(recasted).toBe(code.trim())
|
||||
@ -186,8 +184,7 @@ const myVar2 = yo['a'][key2].c`
|
||||
|
||||
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'`
|
||||
|
||||
@ -197,20 +194,18 @@ 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 { 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'
|
||||
|
||||
@ -220,12 +215,12 @@ const key = 'c'
|
||||
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
|
||||
}`
|
||||
@ -255,7 +250,7 @@ const mySk1 = startSketchAt([0, 0])
|
||||
// comment here
|
||||
|> lineTo({ to: [0, 1], tag: 'myTag' }, %)
|
||||
|> lineTo([1, 1], %) /* and
|
||||
here
|
||||
here
|
||||
*/
|
||||
// a comment between pipe expression statements
|
||||
|> rx(90, %)
|
||||
@ -269,7 +264,21 @@ const mySk1 = startSketchAt([0, 0])
|
||||
*/`
|
||||
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`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -295,7 +304,7 @@ describe('testing call Expressions in BinaryExpressions and UnaryExpressions', (
|
||||
it('with unaryExpression in sketch situation', () => {
|
||||
const code = [
|
||||
'const part001 = startSketchAt([0, 0])',
|
||||
'|> line([-2.21, -legLen(5, min(3, 999))], %)',
|
||||
' |> line([-2.21, -legLen(5, min(3, 999))], %)',
|
||||
].join('\n')
|
||||
const { ast } = code2ast(code)
|
||||
const recasted = recast(ast)
|
||||
@ -309,10 +318,10 @@ describe('it recasts wrapped object expressions in pipe bodies with correct inde
|
||||
|> line({ to: [0.62, 4.15], tag: 'seg01' }, %)
|
||||
|> line([2.77, -1.24], %)
|
||||
|> angledLineThatIntersects({
|
||||
angle: 201,
|
||||
offset: -1.35,
|
||||
intersectTag: 'seg01'
|
||||
}, %)
|
||||
angle: 201,
|
||||
offset: -1.35,
|
||||
intersectTag: 'seg01'
|
||||
}, %)
|
||||
|> line([-0.42, -1.72], %)
|
||||
show(part001)`
|
||||
const { ast } = code2ast(code)
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Program } from './abstractSyntaxTreeTypes'
|
||||
import { recast_js } from '../wasm-lib/pkg/wasm_lib'
|
||||
import { recast_wasm } from '../wasm-lib/pkg/wasm_lib'
|
||||
|
||||
export const recast = (ast: Program): string => {
|
||||
try {
|
||||
const s: string = recast_js(JSON.stringify(ast))
|
||||
const s: string = recast_wasm(JSON.stringify(ast))
|
||||
return s
|
||||
} catch (e) {
|
||||
// TODO: do something real with the error.
|
||||
|
@ -1,9 +1,14 @@
|
||||
import { SourceRange } from '../executor'
|
||||
import { Selections } from '../../useStore'
|
||||
import { VITE_KC_API_WS_MODELING_URL } from '../../env'
|
||||
import { SourceRange } from 'lang/executor'
|
||||
import { Selections } from 'useStore'
|
||||
import {
|
||||
VITE_KC_API_WS_MODELING_URL,
|
||||
VITE_KC_CONNECTION_TIMEOUT_MS,
|
||||
VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS,
|
||||
} from 'env'
|
||||
import { Models } from '@kittycad/lib'
|
||||
import { exportSave } from '../../lib/exportSave'
|
||||
import { exportSave } from 'lib/exportSave'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import * as Sentry from '@sentry/react'
|
||||
|
||||
interface ResultCommand {
|
||||
type: 'result'
|
||||
@ -22,21 +27,12 @@ export interface SourceRangeMap {
|
||||
[key: string]: SourceRange
|
||||
}
|
||||
|
||||
interface SelectionsArgs {
|
||||
id: string
|
||||
type: Selections['codeBasedSelections'][number]['type']
|
||||
interface NewTrackArgs {
|
||||
conn: EngineConnection
|
||||
mediaStream: MediaStream
|
||||
}
|
||||
|
||||
interface CursorSelectionsArgs {
|
||||
otherSelections: Selections['otherSelections']
|
||||
idBasedSelections: { type: string; id: string }[]
|
||||
}
|
||||
|
||||
export type EngineCommand = Models['WebSocketMessages_type']
|
||||
|
||||
type OkResponse = Models['OkModelingCmdResponse_type']
|
||||
|
||||
type WebSocketResponse = Models['WebSocketResponses_type']
|
||||
type WebSocketResponse = Models['OkWebSocketResponseData_type']
|
||||
|
||||
// EngineConnection encapsulates the connection(s) to the Engine
|
||||
// for the EngineCommandManager; namely, the underlying WebSocket
|
||||
@ -44,44 +40,121 @@ type WebSocketResponse = Models['WebSocketResponses_type']
|
||||
export class EngineConnection {
|
||||
websocket?: WebSocket
|
||||
pc?: RTCPeerConnection
|
||||
lossyDataChannel?: RTCDataChannel
|
||||
unreliableDataChannel?: RTCDataChannel
|
||||
|
||||
onConnectionStarted: (conn: EngineConnection) => void = () => {}
|
||||
|
||||
waitForReady: Promise<void> = new Promise(() => {})
|
||||
private resolveReady = () => {}
|
||||
private ready: boolean
|
||||
|
||||
readonly url: string
|
||||
private readonly token?: string
|
||||
private onWebsocketOpen: (engineConnection: EngineConnection) => void
|
||||
private onDataChannelOpen: (engineConnection: EngineConnection) => void
|
||||
private onEngineConnectionOpen: (engineConnection: EngineConnection) => void
|
||||
private onConnectionStarted: (engineConnection: EngineConnection) => void
|
||||
private onClose: (engineConnection: EngineConnection) => void
|
||||
private onNewTrack: (track: NewTrackArgs) => void
|
||||
|
||||
constructor({
|
||||
url,
|
||||
token,
|
||||
onConnectionStarted,
|
||||
onWebsocketOpen = () => {},
|
||||
onNewTrack = () => {},
|
||||
onEngineConnectionOpen = () => {},
|
||||
onConnectionStarted = () => {},
|
||||
onClose = () => {},
|
||||
onDataChannelOpen = () => {},
|
||||
}: {
|
||||
url: string
|
||||
token?: string
|
||||
onConnectionStarted: (conn: EngineConnection) => void
|
||||
onWebsocketOpen?: (engineConnection: EngineConnection) => void
|
||||
onDataChannelOpen?: (engineConnection: EngineConnection) => void
|
||||
onEngineConnectionOpen?: (engineConnection: EngineConnection) => void
|
||||
onConnectionStarted?: (engineConnection: EngineConnection) => void
|
||||
onClose?: (engineConnection: EngineConnection) => void
|
||||
onNewTrack?: (track: NewTrackArgs) => void
|
||||
}) {
|
||||
this.url = url
|
||||
this.token = token
|
||||
this.ready = false
|
||||
this.onWebsocketOpen = onWebsocketOpen
|
||||
this.onDataChannelOpen = onDataChannelOpen
|
||||
this.onEngineConnectionOpen = onEngineConnectionOpen
|
||||
this.onConnectionStarted = onConnectionStarted
|
||||
this.onClose = onClose
|
||||
this.onNewTrack = onNewTrack
|
||||
|
||||
// TODO(paultag): This isn't right; this should be when the
|
||||
// connection is in a good place, and tied to the connect() method,
|
||||
// but this is part of a larger refactor to untangle logic. Once the
|
||||
// Connection is pulled apart, we can rework how ready is represented.
|
||||
// This was just the easiest way to ensure some level of parity between
|
||||
// the CommandManager and the Connection until I send a rework for
|
||||
// retry logic.
|
||||
this.waitForReady = new Promise((resolve) => {
|
||||
this.resolveReady = resolve
|
||||
})
|
||||
// TODO(paultag): This ought to be tweakable.
|
||||
const pingIntervalMs = 10000
|
||||
|
||||
setInterval(() => {
|
||||
if (this.isReady()) {
|
||||
// When we're online, every 10 seconds, we'll attempt to put a 'ping'
|
||||
// command through the WebSocket connection. This will help both ends
|
||||
// of the connection maintain the TCP connection without hitting a
|
||||
// timeout condition.
|
||||
this.send({ type: 'ping' })
|
||||
}
|
||||
}, pingIntervalMs)
|
||||
}
|
||||
|
||||
// isReady will return true only when the WebRTC *and* WebSocket connection
|
||||
// are connected. During setup, the WebSocket connection comes online first,
|
||||
// which is used to establish the WebRTC connection. The EngineConnection
|
||||
// is not "Ready" until both are connected.
|
||||
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.
|
||||
//
|
||||
// This will attempt the full handshake, and retry if the connection
|
||||
// did not establish.
|
||||
connect() {
|
||||
this.websocket = new WebSocket(this.url, [])
|
||||
// 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'
|
||||
|
||||
this.pc = new RTCPeerConnection()
|
||||
@ -89,18 +162,53 @@ export class EngineConnection {
|
||||
this.websocket.addEventListener('open', (event) => {
|
||||
console.log('Connected to websocket, waiting for ICE servers')
|
||||
if (this.token) {
|
||||
this.websocket?.send(
|
||||
JSON.stringify({ headers: { Authorization: `Bearer ${this.token}` } })
|
||||
this.send({ headers: { Authorization: `Bearer ${this.token}` } })
|
||||
}
|
||||
})
|
||||
|
||||
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',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
handshakeSpan.promise,
|
||||
iceSpan.promise,
|
||||
dataChannelSpan.promise,
|
||||
mediaTrackSpan.promise,
|
||||
]).then(() => {
|
||||
console.log('All spans finished, reporting')
|
||||
webrtcMediaTransaction?.finish()
|
||||
})
|
||||
|
||||
this.onWebsocketOpen(this)
|
||||
})
|
||||
|
||||
this.websocket.addEventListener('close', (event) => {
|
||||
console.log('websocket connection closed', event)
|
||||
this.close()
|
||||
})
|
||||
|
||||
this.websocket.addEventListener('error', (event) => {
|
||||
console.log('websocket connection error', event)
|
||||
this.close()
|
||||
})
|
||||
|
||||
this.websocket.addEventListener('message', (event) => {
|
||||
@ -115,31 +223,66 @@ export class EngineConnection {
|
||||
return
|
||||
}
|
||||
|
||||
const message: WebSocketResponse = JSON.parse(event.data)
|
||||
const message: Models['WebSocketResponse_type'] = JSON.parse(event.data)
|
||||
|
||||
if (
|
||||
message.type === 'sdp_answer' &&
|
||||
message.answer.type !== 'unspecified'
|
||||
) {
|
||||
this.pc?.setRemoteDescription(
|
||||
new RTCSessionDescription({
|
||||
type: message.answer.type,
|
||||
sdp: message.answer.sdp,
|
||||
})
|
||||
)
|
||||
} else if (message.type === 'trickle_ice') {
|
||||
this.pc?.addIceCandidate(message.candidate as RTCIceCandidateInit)
|
||||
} else if (message.type === 'ice_server_info' && this.pc) {
|
||||
if (!message.success) {
|
||||
if (message.request_id) {
|
||||
console.error(`Error in response to request ${message.request_id}:`)
|
||||
} else {
|
||||
console.error(`Error from server:`)
|
||||
}
|
||||
message?.errors?.forEach((error) => {
|
||||
console.error(` - ${error.error_code}: ${error.message}`)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let resp = message.resp
|
||||
if (!resp) {
|
||||
// If there's no body to the response, we can bail here.
|
||||
return
|
||||
}
|
||||
|
||||
if (resp.type === 'sdp_answer') {
|
||||
let answer = resp.data?.answer
|
||||
if (!answer || answer.type === 'unspecified') {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.pc?.signalingState !== 'stable') {
|
||||
// If the connection is stable, we shouldn't bother updating the
|
||||
// SDP, since we have a stable connection to the backend. If we
|
||||
// need to renegotiate, the whole PeerConnection needs to get
|
||||
// tore down.
|
||||
this.pc?.setRemoteDescription(
|
||||
new RTCSessionDescription({
|
||||
type: answer.type,
|
||||
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
|
||||
this.pc?.addIceCandidate(candidate as RTCIceCandidateInit)
|
||||
} else if (resp.type === 'ice_server_info' && this.pc) {
|
||||
console.log('received ice_server_info')
|
||||
let ice_servers = resp.data?.ice_servers
|
||||
|
||||
if (message.ice_servers.length > 0) {
|
||||
if (ice_servers?.length > 0) {
|
||||
// When we set the Configuration, we want to always force
|
||||
// iceTransportPolicy to 'relay', since we know the topology
|
||||
// of the ICE/STUN/TUN server and the engine. We don't wish to
|
||||
// talk to the engine in any configuration /other/ than relay
|
||||
// from a infra POV.
|
||||
this.pc.setConfiguration({
|
||||
iceServers: message.ice_servers,
|
||||
iceServers: ice_servers,
|
||||
iceTransportPolicy: 'relay',
|
||||
})
|
||||
} else {
|
||||
@ -152,29 +295,21 @@ export class EngineConnection {
|
||||
// until the end of this function is setup of our end of the
|
||||
// PeerConnection and waiting for events to fire our callbacks.
|
||||
|
||||
this.pc.addEventListener('connectionstatechange', (e) =>
|
||||
console.log(this.pc?.iceConnectionState)
|
||||
)
|
||||
this.pc.addEventListener('connectionstatechange', (event) => {
|
||||
if (this.pc?.iceConnectionState === 'connected') {
|
||||
iceSpan.resolve?.()
|
||||
}
|
||||
})
|
||||
|
||||
this.pc.addEventListener('icecandidate', (event) => {
|
||||
if (!this.pc || !this.websocket) return
|
||||
if (event.candidate === null) {
|
||||
console.log('sent sdp_offer')
|
||||
this.websocket.send(
|
||||
JSON.stringify({
|
||||
type: 'sdp_offer',
|
||||
offer: this.pc.localDescription,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
if (event.candidate !== null) {
|
||||
console.log('sending trickle ice candidate')
|
||||
const { candidate } = event
|
||||
this.websocket?.send(
|
||||
JSON.stringify({
|
||||
type: 'trickle_ice',
|
||||
candidate: candidate.toJSON(),
|
||||
})
|
||||
)
|
||||
this.send({
|
||||
type: 'trickle_ice',
|
||||
candidate: candidate.toJSON(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@ -189,43 +324,244 @@ export class EngineConnection {
|
||||
.then(async (descriptionInit) => {
|
||||
await this?.pc?.setLocalDescription(descriptionInit)
|
||||
console.log('sent sdp_offer begin')
|
||||
const msg = JSON.stringify({
|
||||
this.send({
|
||||
type: 'sdp_offer',
|
||||
offer: this.pc?.localDescription,
|
||||
})
|
||||
this.websocket?.send(msg)
|
||||
})
|
||||
.catch(console.log)
|
||||
}
|
||||
|
||||
// TODO(paultag): This ought to be both controllable, as well as something
|
||||
// like exponential backoff to have some grace on the backend, as well as
|
||||
// fix responsiveness for clients that had a weird network hiccup.
|
||||
const connectionTimeoutMs = VITE_KC_CONNECTION_TIMEOUT_MS
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.isReady()) {
|
||||
return
|
||||
}
|
||||
console.log('engine connection timeout on connection, retrying')
|
||||
this.close()
|
||||
this.connect()
|
||||
}, connectionTimeoutMs)
|
||||
})
|
||||
|
||||
this.pc.addEventListener('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?.()
|
||||
})
|
||||
}
|
||||
|
||||
// Set up the background thread to keep an eye on statistical
|
||||
// information about the WebRTC media stream from the server to
|
||||
// us. We'll also eventually want more global statistical information,
|
||||
// but this will give us a baseline.
|
||||
if (parseInt(VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS) !== 0) {
|
||||
setInterval(() => {
|
||||
if (this.pc === undefined) {
|
||||
return
|
||||
}
|
||||
if (!this.shouldTrace()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use the WebRTC Statistics API to collect statistical information
|
||||
// about the WebRTC connection we're using to report to Sentry.
|
||||
mediaStream.getVideoTracks().forEach((videoTrack) => {
|
||||
let trackStats = new Map<string, any>()
|
||||
this.pc?.getStats(videoTrack).then((videoTrackStats) => {
|
||||
// Sentry only allows 10 metrics per transaction. We're going
|
||||
// to have to pick carefully here, eventually send like a prom
|
||||
// file or something to the peer.
|
||||
|
||||
const transaction = Sentry.startTransaction({
|
||||
name: 'webrtc-stats',
|
||||
})
|
||||
videoTrackStats.forEach((videoTrackReport) => {
|
||||
if (videoTrackReport.type === 'inbound-rtp') {
|
||||
// RTC Stream Info
|
||||
// transaction.setMeasurement(
|
||||
// 'mediaStreamTrack.framesDecoded',
|
||||
// videoTrackReport.framesDecoded,
|
||||
// 'frame'
|
||||
// )
|
||||
transaction.setMeasurement(
|
||||
'rtcFramesDropped',
|
||||
videoTrackReport.framesDropped,
|
||||
''
|
||||
)
|
||||
// transaction.setMeasurement(
|
||||
// 'mediaStreamTrack.framesReceived',
|
||||
// videoTrackReport.framesReceived,
|
||||
// 'frame'
|
||||
// )
|
||||
transaction.setMeasurement(
|
||||
'rtcFramesPerSecond',
|
||||
videoTrackReport.framesPerSecond,
|
||||
'fps'
|
||||
)
|
||||
transaction.setMeasurement(
|
||||
'rtcFreezeCount',
|
||||
videoTrackReport.freezeCount,
|
||||
''
|
||||
)
|
||||
transaction.setMeasurement(
|
||||
'rtcJitter',
|
||||
videoTrackReport.jitter,
|
||||
'second'
|
||||
)
|
||||
// transaction.setMeasurement(
|
||||
// 'mediaStreamTrack.jitterBufferDelay',
|
||||
// videoTrackReport.jitterBufferDelay,
|
||||
// ''
|
||||
// )
|
||||
// transaction.setMeasurement(
|
||||
// 'mediaStreamTrack.jitterBufferEmittedCount',
|
||||
// videoTrackReport.jitterBufferEmittedCount,
|
||||
// ''
|
||||
// )
|
||||
// transaction.setMeasurement(
|
||||
// 'mediaStreamTrack.jitterBufferMinimumDelay',
|
||||
// videoTrackReport.jitterBufferMinimumDelay,
|
||||
// ''
|
||||
// )
|
||||
// transaction.setMeasurement(
|
||||
// 'mediaStreamTrack.jitterBufferTargetDelay',
|
||||
// videoTrackReport.jitterBufferTargetDelay,
|
||||
// ''
|
||||
// )
|
||||
transaction.setMeasurement(
|
||||
'rtcKeyFramesDecoded',
|
||||
videoTrackReport.keyFramesDecoded,
|
||||
''
|
||||
)
|
||||
transaction.setMeasurement(
|
||||
'rtcTotalFreezesDuration',
|
||||
videoTrackReport.totalFreezesDuration,
|
||||
'second'
|
||||
)
|
||||
// transaction.setMeasurement(
|
||||
// 'mediaStreamTrack.totalInterFrameDelay',
|
||||
// videoTrackReport.totalInterFrameDelay,
|
||||
// ''
|
||||
// )
|
||||
transaction.setMeasurement(
|
||||
'rtcTotalPausesDuration',
|
||||
videoTrackReport.totalPausesDuration,
|
||||
'second'
|
||||
)
|
||||
// transaction.setMeasurement(
|
||||
// 'mediaStreamTrack.totalProcessingDelay',
|
||||
// videoTrackReport.totalProcessingDelay,
|
||||
// 'second'
|
||||
// )
|
||||
} else if (videoTrackReport.type === 'transport') {
|
||||
// // Bytes i/o
|
||||
// transaction.setMeasurement(
|
||||
// 'mediaStreamTrack.bytesReceived',
|
||||
// videoTrackReport.bytesReceived,
|
||||
// 'byte'
|
||||
// )
|
||||
// transaction.setMeasurement(
|
||||
// 'mediaStreamTrack.bytesSent',
|
||||
// videoTrackReport.bytesSent,
|
||||
// 'byte'
|
||||
// )
|
||||
}
|
||||
})
|
||||
transaction?.finish()
|
||||
})
|
||||
})
|
||||
}, VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS)
|
||||
}
|
||||
|
||||
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) => {
|
||||
this.resolveReady()
|
||||
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)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
if (this.onConnectionStarted) this.onConnectionStarted(this)
|
||||
this.onConnectionStarted(this)
|
||||
}
|
||||
send(message: object | string) {
|
||||
// TODO(paultag): Add in logic to determine the connection state and
|
||||
// take actions if needed?
|
||||
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.unreliableDataChannel = undefined
|
||||
|
||||
this.onClose(this)
|
||||
this.ready = false
|
||||
}
|
||||
}
|
||||
|
||||
export type EngineCommand = Models['WebSocketRequest_type']
|
||||
type ModelTypes = Models['OkModelingCmdResponse_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 = {}
|
||||
sourceRangeMap: SourceRangeMap = {}
|
||||
@ -234,10 +570,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,
|
||||
@ -258,122 +601,161 @@ export class EngineCommandManager {
|
||||
this.engineConnection = new EngineConnection({
|
||||
url,
|
||||
token,
|
||||
onConnectionStarted: (conn) => {
|
||||
this.engineConnection?.pc?.addEventListener('track', (event) => {
|
||||
console.log('received track', event)
|
||||
const mediaStream = event.streams[0]
|
||||
setMediaStream(mediaStream)
|
||||
})
|
||||
onEngineConnectionOpen: () => {
|
||||
this.resolveReady()
|
||||
setIsStreamReady(true)
|
||||
},
|
||||
onClose: () => {
|
||||
setIsStreamReady(false)
|
||||
},
|
||||
onConnectionStarted: (engineConnection) => {
|
||||
engineConnection?.pc?.addEventListener('datachannel', (event) => {
|
||||
let unreliableDataChannel = event.channel
|
||||
|
||||
this.engineConnection?.pc?.addEventListener('datachannel', (event) => {
|
||||
let lossyDataChannel = event.channel
|
||||
lossyDataChannel.addEventListener('message', (event) => {
|
||||
const result: OkResponse = JSON.parse(event.data)
|
||||
if (
|
||||
result.type === 'highlight_set_entity' &&
|
||||
result.sequence &&
|
||||
result.sequence > this.inSequence
|
||||
) {
|
||||
this.onHoverCallback(result.entity_id)
|
||||
this.inSequence = result.sequence
|
||||
}
|
||||
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?.data?.sequence &&
|
||||
result?.data.sequence > this.inSequence &&
|
||||
result.type === 'highlight_set_entity'
|
||||
) {
|
||||
this.inSequence = result.data.sequence
|
||||
callback(result)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// When the EngineConnection starts a connection, we want to register
|
||||
// callbacks into the WebSocket/PeerConnection.
|
||||
conn.websocket?.addEventListener('message', (event) => {
|
||||
engineConnection.websocket?.addEventListener('message', (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
// If the data is an ArrayBuffer, it's the result of an export command,
|
||||
// because in all other cases we send JSON strings. But in the case of
|
||||
// export we send a binary blob.
|
||||
// Pass this to our export function.
|
||||
exportSave(event.data)
|
||||
} else if (
|
||||
typeof event.data === 'string' &&
|
||||
event.data.toLocaleLowerCase().startsWith('error')
|
||||
) {
|
||||
console.warn('something went wrong: ', event.data)
|
||||
} else {
|
||||
const message: WebSocketResponse = JSON.parse(event.data)
|
||||
|
||||
if (message.type === 'modeling') {
|
||||
const id = message.cmd_id
|
||||
const command = this.artifactMap[id]
|
||||
if ('ok' in message.result) {
|
||||
const result: OkResponse = message.result.ok
|
||||
if (result.type === 'select_with_point') {
|
||||
if (result.entity_id) {
|
||||
this.onClickCallback({
|
||||
id: result.entity_id,
|
||||
type: 'default',
|
||||
})
|
||||
} else {
|
||||
this.onClickCallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (command && command.type === 'pending') {
|
||||
const resolve = command.resolve
|
||||
this.artifactMap[id] = {
|
||||
type: 'result',
|
||||
data: message.result,
|
||||
}
|
||||
resolve({
|
||||
id,
|
||||
})
|
||||
} else {
|
||||
this.artifactMap[id] = {
|
||||
type: 'result',
|
||||
data: message.result,
|
||||
}
|
||||
}
|
||||
const message: Models['WebSocketResponse_type'] = JSON.parse(
|
||||
event.data
|
||||
)
|
||||
if (
|
||||
message.success &&
|
||||
message.resp.type === 'modeling' &&
|
||||
message.request_id
|
||||
) {
|
||||
this.handleModelingCommand(message.resp, message.request_id)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
onNewTrack: ({ mediaStream }) => {
|
||||
console.log('received track', mediaStream)
|
||||
|
||||
// TODO(paultag): this isn't quite right, and the double promises is
|
||||
// pretty grim.
|
||||
this.engineConnection?.waitForReady.then(this.resolveReady)
|
||||
mediaStream.getVideoTracks()[0].addEventListener('mute', () => {
|
||||
console.log('peer is not sending video to us')
|
||||
// this.engineConnection?.close()
|
||||
// this.engineConnection?.connect()
|
||||
})
|
||||
|
||||
this.waitForReady.then(() => {
|
||||
setIsStreamReady(true)
|
||||
setMediaStream(mediaStream)
|
||||
},
|
||||
})
|
||||
|
||||
this.engineConnection?.connect()
|
||||
}
|
||||
handleModelingCommand(message: WebSocketResponse, id: string) {
|
||||
if (message.type !== 'modeling') {
|
||||
return
|
||||
}
|
||||
const modelingResponse = message.data.modeling_response
|
||||
Object.values(this.subscriptions[modelingResponse.type] || {}).forEach(
|
||||
(callback) => callback(modelingResponse)
|
||||
)
|
||||
|
||||
const command = this.artifactMap[id]
|
||||
if (command && command.type === 'pending') {
|
||||
const resolve = command.resolve
|
||||
this.artifactMap[id] = {
|
||||
type: 'result',
|
||||
data: modelingResponse,
|
||||
}
|
||||
resolve({
|
||||
id,
|
||||
data: modelingResponse,
|
||||
})
|
||||
} else {
|
||||
this.artifactMap[id] = {
|
||||
type: 'result',
|
||||
data: modelingResponse,
|
||||
}
|
||||
}
|
||||
}
|
||||
tearDown() {
|
||||
// close all channels, sockets and WebRTC connections
|
||||
this.engineConnection?.close()
|
||||
}
|
||||
|
||||
startNewSession() {
|
||||
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')
|
||||
}
|
||||
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
|
||||
}
|
||||
cusorsSelected(selections: {
|
||||
otherSelections: Selections['otherSelections']
|
||||
idBasedSelections: { type: string; id: string }[]
|
||||
}) {
|
||||
if (this.engineConnection?.websocket?.readyState === 0) {
|
||||
console.log('socket not open')
|
||||
if (!this.engineConnection?.isReady()) {
|
||||
console.log('engine connection isnt ready')
|
||||
return
|
||||
}
|
||||
this.sendSceneCommand({
|
||||
@ -392,51 +774,58 @@ export class EngineCommandManager {
|
||||
cmd_id: uuidv4(),
|
||||
})
|
||||
}
|
||||
sendSceneCommand(command: EngineCommand) {
|
||||
if (this.engineConnection?.websocket?.readyState === 0) {
|
||||
sendSceneCommand(command: EngineCommand): Promise<any> {
|
||||
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()
|
||||
}
|
||||
console.log('sending command', command)
|
||||
this.engineConnection?.websocket?.send(JSON.stringify(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)
|
||||
}
|
||||
sendModellingCommand({
|
||||
sendModelingCommand({
|
||||
id,
|
||||
params,
|
||||
range,
|
||||
command,
|
||||
}: {
|
||||
id: string
|
||||
params: any
|
||||
range: SourceRange
|
||||
command: EngineCommand
|
||||
command: EngineCommand | string
|
||||
}): Promise<any> {
|
||||
this.sourceRangeMap[id] = range
|
||||
|
||||
if (this.engineConnection?.websocket?.readyState === 0) {
|
||||
if (!this.engineConnection?.isReady()) {
|
||||
console.log('socket not ready')
|
||||
return new Promise(() => {})
|
||||
return Promise.resolve()
|
||||
}
|
||||
this.engineConnection?.websocket?.send(JSON.stringify(command))
|
||||
this.engineConnection?.send(command)
|
||||
return this.handlePendingCommand(id)
|
||||
}
|
||||
handlePendingCommand(id: string) {
|
||||
let resolve: (val: any) => void = () => {}
|
||||
const promise = new Promise((_resolve, reject) => {
|
||||
resolve = _resolve
|
||||
@ -448,6 +837,24 @@ export class EngineCommandManager {
|
||||
}
|
||||
return promise
|
||||
}
|
||||
sendModelingCommandFromWasm(
|
||||
id: string,
|
||||
rangeStr: string,
|
||||
commandStr: string
|
||||
): Promise<any> {
|
||||
if (id === undefined) {
|
||||
throw new Error('id is undefined')
|
||||
}
|
||||
if (rangeStr === undefined) {
|
||||
throw new Error('rangeStr is undefined')
|
||||
}
|
||||
if (commandStr === undefined) {
|
||||
throw new Error('commandStr is undefined')
|
||||
}
|
||||
const range: SourceRange = JSON.parse(rangeStr)
|
||||
|
||||
return this.sendModelingCommand({ id, range, command: commandStr })
|
||||
}
|
||||
commandResult(id: string): Promise<any> {
|
||||
const command = this.artifactMap[id]
|
||||
if (!command) {
|
||||
|
@ -1,88 +0,0 @@
|
||||
import { InternalFn } from './stdTypes'
|
||||
import {
|
||||
ExtrudeGroup,
|
||||
ExtrudeSurface,
|
||||
SketchGroup,
|
||||
Position,
|
||||
Rotation,
|
||||
} from '../executor'
|
||||
import { clockwiseSign } from './std'
|
||||
import { generateUuidFromHashSeed } from '../../lib/uuid'
|
||||
|
||||
export const extrude: InternalFn = (
|
||||
{ sourceRange, engineCommandManager, code },
|
||||
length: number,
|
||||
sketchVal: SketchGroup
|
||||
): ExtrudeGroup => {
|
||||
const sketch = sketchVal
|
||||
const { position, rotation } = sketchVal
|
||||
|
||||
const id = generateUuidFromHashSeed(
|
||||
JSON.stringify({
|
||||
code,
|
||||
sourceRange,
|
||||
data: {
|
||||
length,
|
||||
sketchVal,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const extrudeSurfaces: ExtrudeSurface[] = []
|
||||
const extrusionDirection = clockwiseSign(sketch.value.map((line) => line.to))
|
||||
engineCommandManager.sendModellingCommand({
|
||||
id,
|
||||
params: [
|
||||
{
|
||||
length,
|
||||
extrusionDirection: extrusionDirection,
|
||||
},
|
||||
],
|
||||
range: sourceRange,
|
||||
command: {
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'extrude',
|
||||
target: sketch.id,
|
||||
distance: length,
|
||||
cap: true,
|
||||
},
|
||||
cmd_id: id,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'extrudeGroup',
|
||||
id,
|
||||
value: extrudeSurfaces, // TODO, this is just an empty array now, should be deleted.
|
||||
height: length,
|
||||
position,
|
||||
rotation,
|
||||
__meta: [
|
||||
{
|
||||
sourceRange,
|
||||
pathToNode: [], // TODO
|
||||
},
|
||||
{
|
||||
sourceRange: sketchVal.__meta[0].sourceRange,
|
||||
pathToNode: sketchVal.__meta[0].pathToNode,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export const getExtrudeWallTransform: InternalFn = (
|
||||
_,
|
||||
pathName: string,
|
||||
extrudeGroup: ExtrudeGroup
|
||||
): {
|
||||
position: Position
|
||||
quaternion: Rotation
|
||||
} => {
|
||||
const path = extrudeGroup?.value.find((path) => path.name === pathName)
|
||||
if (!path) throw new Error(`Could not find path with name ${pathName}`)
|
||||
return {
|
||||
position: path.position,
|
||||
quaternion: path.rotation,
|
||||
}
|
||||
}
|
@ -97,11 +97,10 @@ describe('testing changeSketchArguments', () => {
|
||||
const lineAfterChange = 'lineTo([2, 3], %)'
|
||||
test('changeSketchArguments', async () => {
|
||||
// Enable rotations #152
|
||||
const genCode = (line: string) => `
|
||||
const mySketch001 = startSketchAt([0, 0])
|
||||
|> ${line}
|
||||
|> lineTo([0.46, -5.82], %)
|
||||
// |> rx(45, %)
|
||||
const genCode = (line: string) => `const mySketch001 = startSketchAt([0, 0])
|
||||
|> ${line}
|
||||
|> lineTo([0.46, -5.82], %)
|
||||
// |> rx(45, %)
|
||||
show(mySketch001)`
|
||||
const code = genCode(lineToChange)
|
||||
const expectedCode = genCode(lineAfterChange)
|
||||
@ -160,8 +159,7 @@ 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], %)
|
||||
@ -175,12 +173,11 @@ 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])
|
||||
// |> rx(45, %)
|
||||
|> ${line}
|
||||
|> lineTo([0.46, -5.82], %)
|
||||
show(mySketch001)`
|
||||
const genCode = (line: string) => `const mySketch001 = startSketchAt([0, 0])
|
||||
// |> rx(45, %)
|
||||
|> ${line}
|
||||
|> lineTo([0.46, -5.82], %)
|
||||
show(mySketch001)`
|
||||
const code = genCode(originalLine)
|
||||
const ast = parser_wasm(code)
|
||||
const programMemory = await enginelessExecutor(ast)
|
||||
|
@ -23,12 +23,7 @@ import { GuiModes, toolTips, TooTip } from '../../useStore'
|
||||
import { splitPathAtPipeExpression } from '../modifyAst'
|
||||
import { generateUuidFromHashSeed } from '../../lib/uuid'
|
||||
|
||||
import {
|
||||
SketchLineHelper,
|
||||
ModifyAstBase,
|
||||
InternalFn,
|
||||
TransformCallback,
|
||||
} from './stdTypes'
|
||||
import { SketchLineHelper, ModifyAstBase, TransformCallback } from './stdTypes'
|
||||
|
||||
import {
|
||||
createLiteral,
|
||||
@ -42,10 +37,7 @@ import {
|
||||
} from '../modifyAst'
|
||||
import { roundOff, getLength, getAngle } from '../../lib/utils'
|
||||
import { getSketchSegmentFromSourceRange } from './sketchConstraints'
|
||||
import {
|
||||
intersectionWithParallelLine,
|
||||
perpendicularDistance,
|
||||
} from 'sketch-helpers'
|
||||
import { perpendicularDistance } from 'sketch-helpers'
|
||||
|
||||
export type Coords2d = [number, number]
|
||||
|
||||
@ -115,45 +107,6 @@ function makeId(seed: string | any) {
|
||||
}
|
||||
|
||||
export const lineTo: SketchLineHelper = {
|
||||
fn: (
|
||||
{ sourceRange, code },
|
||||
data:
|
||||
| [number, number]
|
||||
| {
|
||||
to: [number, number]
|
||||
tag?: string
|
||||
},
|
||||
previousSketch: SketchGroup
|
||||
): SketchGroup => {
|
||||
if (!previousSketch)
|
||||
throw new Error('lineTo must be called after startSketchAt')
|
||||
const sketchGroup = { ...previousSketch }
|
||||
const from = getCoordsFromPaths(sketchGroup, sketchGroup.value.length - 1)
|
||||
const to = 'to' in data ? data.to : data
|
||||
|
||||
const id = makeId({
|
||||
code,
|
||||
sourceRange,
|
||||
data,
|
||||
})
|
||||
const currentPath: Path = {
|
||||
type: 'toPoint',
|
||||
to,
|
||||
from,
|
||||
__geoMeta: {
|
||||
sourceRange,
|
||||
id,
|
||||
pathToNode: [], // TODO
|
||||
},
|
||||
}
|
||||
if ('tag' in data) {
|
||||
currentPath.name = data.tag
|
||||
}
|
||||
return {
|
||||
...sketchGroup,
|
||||
value: [...sketchGroup.value, currentPath],
|
||||
}
|
||||
},
|
||||
add: ({
|
||||
node,
|
||||
pathToNode,
|
||||
@ -221,77 +174,6 @@ export const lineTo: SketchLineHelper = {
|
||||
}
|
||||
|
||||
export const line: SketchLineHelper = {
|
||||
fn: (
|
||||
{ sourceRange, engineCommandManager, code },
|
||||
data:
|
||||
| [number, number]
|
||||
| 'default'
|
||||
| {
|
||||
to: [number, number] | 'default'
|
||||
// name?: string
|
||||
tag?: string
|
||||
},
|
||||
previousSketch: SketchGroup
|
||||
): SketchGroup => {
|
||||
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
|
||||
const sketchGroup = { ...previousSketch }
|
||||
const from = getCoordsFromPaths(sketchGroup, sketchGroup.value.length - 1)
|
||||
let args: [number, number] = [0.2, 1]
|
||||
if (data !== 'default' && 'to' in data && data.to !== 'default') {
|
||||
args = data.to
|
||||
} else if (data !== 'default' && !('to' in data)) {
|
||||
args = data
|
||||
}
|
||||
|
||||
const to: [number, number] = [from[0] + args[0], from[1] + args[1]]
|
||||
const lineData: LineData = {
|
||||
from: [...from, 0],
|
||||
to: [...to, 0],
|
||||
}
|
||||
const id = makeId({
|
||||
code,
|
||||
sourceRange,
|
||||
data,
|
||||
})
|
||||
engineCommandManager.sendModellingCommand({
|
||||
id,
|
||||
params: [lineData, previousSketch],
|
||||
range: sourceRange,
|
||||
command: {
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'extend_path',
|
||||
path: sketchGroup.id,
|
||||
segment: {
|
||||
type: 'line',
|
||||
end: {
|
||||
x: lineData.to[0],
|
||||
y: lineData.to[1],
|
||||
z: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
cmd_id: id,
|
||||
},
|
||||
})
|
||||
const currentPath: Path = {
|
||||
type: 'toPoint',
|
||||
to,
|
||||
from,
|
||||
__geoMeta: {
|
||||
id,
|
||||
sourceRange,
|
||||
pathToNode: [], // TODO
|
||||
},
|
||||
}
|
||||
if (data !== 'default' && 'tag' in data) {
|
||||
currentPath.name = data.tag
|
||||
}
|
||||
return {
|
||||
...sketchGroup,
|
||||
value: [...sketchGroup.value, currentPath],
|
||||
}
|
||||
},
|
||||
add: ({
|
||||
node,
|
||||
previousProgramMemory,
|
||||
@ -385,25 +267,6 @@ export const line: SketchLineHelper = {
|
||||
}
|
||||
|
||||
export const xLineTo: SketchLineHelper = {
|
||||
fn: (
|
||||
meta,
|
||||
data:
|
||||
| number
|
||||
| {
|
||||
to: number
|
||||
// name?: string
|
||||
tag?: string
|
||||
},
|
||||
previousSketch: SketchGroup
|
||||
) => {
|
||||
if (!previousSketch) throw new Error('bad bad bad')
|
||||
const from = getCoordsFromPaths(
|
||||
previousSketch,
|
||||
previousSketch.value.length - 1
|
||||
)
|
||||
const [xVal, tag] = typeof data !== 'number' ? [data.to, data.tag] : [data]
|
||||
return lineTo.fn(meta, { to: [xVal, from[1]], tag }, previousSketch)
|
||||
},
|
||||
add: ({ node, pathToNode, to, replaceExisting, createCallback }) => {
|
||||
const _node = { ...node }
|
||||
const getNode = getNodeFromPathCurry(_node, pathToNode)
|
||||
@ -452,25 +315,6 @@ export const xLineTo: SketchLineHelper = {
|
||||
}
|
||||
|
||||
export const yLineTo: SketchLineHelper = {
|
||||
fn: (
|
||||
meta,
|
||||
data:
|
||||
| number
|
||||
| {
|
||||
to: number
|
||||
// name?: string
|
||||
tag?: string
|
||||
},
|
||||
previousSketch: SketchGroup
|
||||
) => {
|
||||
if (!previousSketch) throw new Error('bad bad bad')
|
||||
const from = getCoordsFromPaths(
|
||||
previousSketch,
|
||||
previousSketch.value.length - 1
|
||||
)
|
||||
const [yVal, tag] = typeof data !== 'number' ? [data.to, data.tag] : [data]
|
||||
return lineTo.fn(meta, { to: [from[0], yVal], tag }, previousSketch)
|
||||
},
|
||||
add: ({ node, pathToNode, to, replaceExisting, createCallback }) => {
|
||||
const _node = { ...node }
|
||||
const getNode = getNodeFromPathCurry(_node, pathToNode)
|
||||
@ -519,21 +363,6 @@ export const yLineTo: SketchLineHelper = {
|
||||
}
|
||||
|
||||
export const xLine: SketchLineHelper = {
|
||||
fn: (
|
||||
meta,
|
||||
data:
|
||||
| number
|
||||
| {
|
||||
length: number
|
||||
tag?: string
|
||||
},
|
||||
previousSketch: SketchGroup
|
||||
) => {
|
||||
if (!previousSketch) throw new Error('bad bad bad')
|
||||
const [xVal, tag] =
|
||||
typeof data !== 'number' ? [data.length, data.tag] : [data]
|
||||
return line.fn(meta, { to: [xVal, 0], tag }, previousSketch)
|
||||
},
|
||||
add: ({ node, pathToNode, to, from, replaceExisting, createCallback }) => {
|
||||
const _node = { ...node }
|
||||
const getNode = getNodeFromPathCurry(_node, pathToNode)
|
||||
@ -584,22 +413,6 @@ export const xLine: SketchLineHelper = {
|
||||
}
|
||||
|
||||
export const yLine: SketchLineHelper = {
|
||||
fn: (
|
||||
meta,
|
||||
data:
|
||||
| number
|
||||
| {
|
||||
length: number
|
||||
// name?: string
|
||||
tag?: string
|
||||
},
|
||||
previousSketch: SketchGroup
|
||||
) => {
|
||||
if (!previousSketch) throw new Error('bad bad bad')
|
||||
const [yVal, tag] =
|
||||
typeof data !== 'number' ? [data.length, data.tag] : [data]
|
||||
return line.fn(meta, { to: [0, yVal], tag }, previousSketch)
|
||||
},
|
||||
add: ({ node, pathToNode, to, from, replaceExisting, createCallback }) => {
|
||||
const _node = { ...node }
|
||||
const getNode = getNodeFromPathCurry(_node, pathToNode)
|
||||
@ -644,48 +457,6 @@ export const yLine: SketchLineHelper = {
|
||||
}
|
||||
|
||||
export const angledLine: SketchLineHelper = {
|
||||
fn: (
|
||||
{ sourceRange, engineCommandManager, code },
|
||||
data:
|
||||
| [number, number]
|
||||
| {
|
||||
angle: number
|
||||
length: number
|
||||
tag?: string
|
||||
},
|
||||
previousSketch: SketchGroup
|
||||
) => {
|
||||
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
|
||||
const sketchGroup = { ...previousSketch }
|
||||
const from = getCoordsFromPaths(sketchGroup, sketchGroup.value.length - 1)
|
||||
const [angle, length] = 'angle' in data ? [data.angle, data.length] : data
|
||||
const to: [number, number] = [
|
||||
from[0] + length * Math.cos((angle * Math.PI) / 180),
|
||||
from[1] + length * Math.sin((angle * Math.PI) / 180),
|
||||
]
|
||||
const id = makeId({
|
||||
code,
|
||||
sourceRange,
|
||||
data,
|
||||
})
|
||||
const currentPath: Path = {
|
||||
type: 'toPoint',
|
||||
to,
|
||||
from,
|
||||
__geoMeta: {
|
||||
id,
|
||||
sourceRange,
|
||||
pathToNode: [], // TODO
|
||||
},
|
||||
}
|
||||
if ('tag' in data) {
|
||||
currentPath.name = data.tag
|
||||
}
|
||||
return {
|
||||
...sketchGroup,
|
||||
value: [...sketchGroup.value, currentPath],
|
||||
}
|
||||
},
|
||||
add: ({
|
||||
node,
|
||||
pathToNode,
|
||||
@ -753,26 +524,6 @@ export const angledLine: SketchLineHelper = {
|
||||
}
|
||||
|
||||
export const angledLineOfXLength: SketchLineHelper = {
|
||||
fn: (
|
||||
{ sourceRange, programMemory, engineCommandManager, code },
|
||||
data:
|
||||
| [number, number]
|
||||
| {
|
||||
angle: number
|
||||
length: number
|
||||
tag?: string
|
||||
},
|
||||
previousSketch: SketchGroup
|
||||
) => {
|
||||
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
|
||||
const [angle, length, tag] =
|
||||
'angle' in data ? [data.angle, data.length, data.tag] : data
|
||||
return line.fn(
|
||||
{ sourceRange, programMemory, engineCommandManager, code },
|
||||
{ to: getYComponent(angle, length), tag },
|
||||
previousSketch
|
||||
)
|
||||
},
|
||||
add: ({
|
||||
node,
|
||||
previousProgramMemory,
|
||||
@ -846,26 +597,6 @@ export const angledLineOfXLength: SketchLineHelper = {
|
||||
}
|
||||
|
||||
export const angledLineOfYLength: SketchLineHelper = {
|
||||
fn: (
|
||||
{ sourceRange, programMemory, engineCommandManager, code },
|
||||
data:
|
||||
| [number, number]
|
||||
| {
|
||||
angle: number
|
||||
length: number
|
||||
tag?: string
|
||||
},
|
||||
previousSketch: SketchGroup
|
||||
) => {
|
||||
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
|
||||
const [angle, length, tag] =
|
||||
'angle' in data ? [data.angle, data.length, data.tag] : data
|
||||
return line.fn(
|
||||
{ sourceRange, programMemory, engineCommandManager, code },
|
||||
{ to: getXComponent(angle, length), tag },
|
||||
previousSketch
|
||||
)
|
||||
},
|
||||
add: ({
|
||||
node,
|
||||
previousProgramMemory,
|
||||
@ -940,33 +671,6 @@ export const angledLineOfYLength: SketchLineHelper = {
|
||||
}
|
||||
|
||||
export const angledLineToX: SketchLineHelper = {
|
||||
fn: (
|
||||
{ sourceRange, programMemory, engineCommandManager, code },
|
||||
data:
|
||||
| [number, number]
|
||||
| {
|
||||
angle: number
|
||||
to: number
|
||||
tag?: string
|
||||
},
|
||||
previousSketch: SketchGroup
|
||||
) => {
|
||||
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
|
||||
const from = getCoordsFromPaths(
|
||||
previousSketch,
|
||||
previousSketch.value.length - 1
|
||||
)
|
||||
const [angle, xTo, tag] =
|
||||
'angle' in data ? [data.angle, data.to, data.tag] : data
|
||||
const xComponent = xTo - from[0]
|
||||
const yComponent = xComponent * Math.tan((angle * Math.PI) / 180)
|
||||
const yTo = from[1] + yComponent
|
||||
return lineTo.fn(
|
||||
{ sourceRange, programMemory, engineCommandManager, code },
|
||||
{ to: [xTo, yTo], tag },
|
||||
previousSketch
|
||||
)
|
||||
},
|
||||
add: ({
|
||||
node,
|
||||
pathToNode,
|
||||
@ -1036,33 +740,6 @@ export const angledLineToX: SketchLineHelper = {
|
||||
}
|
||||
|
||||
export const angledLineToY: SketchLineHelper = {
|
||||
fn: (
|
||||
{ sourceRange, programMemory, engineCommandManager, code },
|
||||
data:
|
||||
| [number, number]
|
||||
| {
|
||||
angle: number
|
||||
to: number
|
||||
tag?: string
|
||||
},
|
||||
previousSketch: SketchGroup
|
||||
) => {
|
||||
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
|
||||
const from = getCoordsFromPaths(
|
||||
previousSketch,
|
||||
previousSketch.value.length - 1
|
||||
)
|
||||
const [angle, yTo, tag] =
|
||||
'angle' in data ? [data.angle, data.to, data.tag] : data
|
||||
const yComponent = yTo - from[1]
|
||||
const xComponent = yComponent / Math.tan((angle * Math.PI) / 180)
|
||||
const xTo = from[0] + xComponent
|
||||
return lineTo.fn(
|
||||
{ sourceRange, programMemory, engineCommandManager, code },
|
||||
{ to: [xTo, yTo], tag },
|
||||
previousSketch
|
||||
)
|
||||
},
|
||||
add: ({
|
||||
node,
|
||||
pathToNode,
|
||||
@ -1133,37 +810,6 @@ export const angledLineToY: SketchLineHelper = {
|
||||
}
|
||||
|
||||
export const angledLineThatIntersects: SketchLineHelper = {
|
||||
fn: (
|
||||
{ sourceRange, programMemory, engineCommandManager, code },
|
||||
data: {
|
||||
angle: number
|
||||
intersectTag: string
|
||||
offset?: number
|
||||
tag?: string
|
||||
},
|
||||
previousSketch: SketchGroup
|
||||
) => {
|
||||
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
|
||||
const intersectPath = previousSketch.value.find(
|
||||
({ name }) => name === data.intersectTag
|
||||
)
|
||||
if (!intersectPath) throw new Error('intersectTag must match a line')
|
||||
const from = getCoordsFromPaths(
|
||||
previousSketch,
|
||||
previousSketch.value.length - 1
|
||||
)
|
||||
const to = intersectionWithParallelLine({
|
||||
line1: [intersectPath.from, intersectPath.to],
|
||||
line1Offset: data.offset || 0,
|
||||
line2Point: from,
|
||||
line2Angle: data.angle,
|
||||
})
|
||||
return lineTo.fn(
|
||||
{ sourceRange, programMemory, engineCommandManager, code },
|
||||
{ to, tag: data.tag },
|
||||
previousSketch
|
||||
)
|
||||
},
|
||||
add: ({
|
||||
node,
|
||||
pathToNode,
|
||||
@ -1526,142 +1172,6 @@ function addTagWithTo(
|
||||
}
|
||||
}
|
||||
|
||||
export const close: InternalFn = (
|
||||
{ sourceRange, engineCommandManager, code },
|
||||
sketchGroup: SketchGroup
|
||||
): SketchGroup => {
|
||||
const from = getCoordsFromPaths(sketchGroup, sketchGroup.value.length - 1)
|
||||
const to = sketchGroup.start
|
||||
? sketchGroup.start.from
|
||||
: getCoordsFromPaths(sketchGroup, 0)
|
||||
|
||||
const lineData: LineData = {
|
||||
from: [...from, 0],
|
||||
to: [...to, 0],
|
||||
}
|
||||
const id = makeId({
|
||||
code,
|
||||
sourceRange,
|
||||
data: sketchGroup,
|
||||
})
|
||||
engineCommandManager.sendModellingCommand({
|
||||
id,
|
||||
params: [lineData],
|
||||
range: sourceRange,
|
||||
command: {
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'close_path',
|
||||
path_id: sketchGroup.id,
|
||||
},
|
||||
cmd_id: id,
|
||||
},
|
||||
})
|
||||
|
||||
const currentPath: Path = {
|
||||
type: 'toPoint',
|
||||
to,
|
||||
from,
|
||||
__geoMeta: {
|
||||
id,
|
||||
sourceRange,
|
||||
pathToNode: [], // TODO
|
||||
},
|
||||
}
|
||||
const newValue = [...sketchGroup.value]
|
||||
newValue.push(currentPath)
|
||||
return {
|
||||
...sketchGroup,
|
||||
value: newValue,
|
||||
}
|
||||
}
|
||||
|
||||
export const startSketchAt: InternalFn = (
|
||||
{ sourceRange, programMemory, engineCommandManager, code },
|
||||
data:
|
||||
| [number, number]
|
||||
| 'default'
|
||||
| {
|
||||
to: [number, number] | 'default'
|
||||
// name?: string
|
||||
tag?: string
|
||||
}
|
||||
): SketchGroup => {
|
||||
let to: [number, number] = [0, 0]
|
||||
if (data !== 'default' && 'to' in data && data.to !== 'default') {
|
||||
to = data.to
|
||||
} else if (data !== 'default' && !('to' in data)) {
|
||||
to = data
|
||||
}
|
||||
|
||||
const lineData: { to: [number, number, number] } = {
|
||||
to: [...to, 0],
|
||||
}
|
||||
const id = makeId({
|
||||
code,
|
||||
sourceRange,
|
||||
data,
|
||||
})
|
||||
const pathId = makeId({
|
||||
code,
|
||||
sourceRange,
|
||||
data,
|
||||
isPath: true,
|
||||
})
|
||||
engineCommandManager.sendModellingCommand({
|
||||
id: pathId,
|
||||
params: [lineData],
|
||||
range: sourceRange,
|
||||
command: {
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'start_path',
|
||||
},
|
||||
cmd_id: pathId,
|
||||
},
|
||||
})
|
||||
engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'move_path_pen',
|
||||
path: pathId,
|
||||
to: {
|
||||
x: lineData.to[0],
|
||||
y: lineData.to[1],
|
||||
z: 0,
|
||||
},
|
||||
},
|
||||
cmd_id: id,
|
||||
})
|
||||
const currentPath: Path = {
|
||||
type: 'base',
|
||||
to,
|
||||
from: to,
|
||||
__geoMeta: {
|
||||
id,
|
||||
sourceRange,
|
||||
pathToNode: [], // TODO
|
||||
},
|
||||
}
|
||||
if (data !== 'default' && 'tag' in data) {
|
||||
currentPath.name = data.tag
|
||||
}
|
||||
return {
|
||||
type: 'sketchGroup',
|
||||
start: currentPath,
|
||||
value: [],
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
id: pathId,
|
||||
__meta: [
|
||||
{
|
||||
sourceRange,
|
||||
pathToNode: [], // TODO
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function getYComponent(
|
||||
angleDegree: number,
|
||||
xComponent: number
|
||||
|
@ -59,20 +59,20 @@ describe('testing swaping out sketch calls with xLine/xLineTo', () => {
|
||||
` |> lineTo({ to: [1, 1], tag: 'abc1' }, %)`,
|
||||
` |> line({ to: [-2.04, -0.7], tag: 'abc2' }, %)`,
|
||||
` |> angledLine({`,
|
||||
` angle: 157,`,
|
||||
` length: 1.69,`,
|
||||
` tag: 'abc3'`,
|
||||
` }, %)`,
|
||||
` angle: 157,`,
|
||||
` length: 1.69,`,
|
||||
` tag: 'abc3'`,
|
||||
` }, %)`,
|
||||
` |> angledLineOfXLength({`,
|
||||
` angle: 217,`,
|
||||
` length: 0.86,`,
|
||||
` tag: 'abc4'`,
|
||||
` }, %)`,
|
||||
` angle: 217,`,
|
||||
` length: 0.86,`,
|
||||
` tag: 'abc4'`,
|
||||
` }, %)`,
|
||||
` |> angledLineOfYLength({`,
|
||||
` angle: 104,`,
|
||||
` length: 1.58,`,
|
||||
` tag: 'abc5'`,
|
||||
` }, %)`,
|
||||
` angle: 104,`,
|
||||
` length: 1.58,`,
|
||||
` tag: 'abc5'`,
|
||||
` }, %)`,
|
||||
` |> angledLineToX({ angle: 55, to: -2.89, tag: 'abc6' }, %)`,
|
||||
` |> angledLineToY({ angle: 330, to: 2.53, tag: 'abc7' }, %)`,
|
||||
` |> xLine({ length: 1.47, tag: 'abc8' }, %)`,
|
||||
@ -144,10 +144,10 @@ describe('testing swaping out sketch calls with xLine/xLineTo', () => {
|
||||
inputCode: bigExample,
|
||||
callToSwap: [
|
||||
`angledLine({`,
|
||||
` angle: 157,`,
|
||||
` length: 1.69,`,
|
||||
` tag: 'abc3'`,
|
||||
` }, %)`,
|
||||
` angle: 157,`,
|
||||
` length: 1.69,`,
|
||||
` tag: 'abc3'`,
|
||||
` }, %)`,
|
||||
].join('\n'),
|
||||
constraintType: 'horizontal',
|
||||
})
|
||||
@ -172,10 +172,10 @@ describe('testing swaping out sketch calls with xLine/xLineTo', () => {
|
||||
inputCode: bigExample,
|
||||
callToSwap: [
|
||||
`angledLineOfXLength({`,
|
||||
` angle: 217,`,
|
||||
` length: 0.86,`,
|
||||
` tag: 'abc4'`,
|
||||
` }, %)`,
|
||||
` angle: 217,`,
|
||||
` length: 0.86,`,
|
||||
` tag: 'abc4'`,
|
||||
` }, %)`,
|
||||
].join('\n'),
|
||||
constraintType: 'horizontal',
|
||||
})
|
||||
@ -201,10 +201,10 @@ describe('testing swaping out sketch calls with xLine/xLineTo', () => {
|
||||
inputCode: bigExample,
|
||||
callToSwap: [
|
||||
`angledLineOfYLength({`,
|
||||
` angle: 104,`,
|
||||
` length: 1.58,`,
|
||||
` tag: 'abc5'`,
|
||||
` }, %)`,
|
||||
` angle: 104,`,
|
||||
` length: 1.58,`,
|
||||
` tag: 'abc5'`,
|
||||
` }, %)`,
|
||||
].join('\n'),
|
||||
constraintType: 'vertical',
|
||||
})
|
||||
@ -391,6 +391,7 @@ show(part001)`
|
||||
type: 'toPoint',
|
||||
to: [5.62, 1.79],
|
||||
from: [3.48, 0.44],
|
||||
name: '',
|
||||
})
|
||||
})
|
||||
it('verify it works when the segment is in the `start` property', async () => {
|
||||
@ -400,6 +401,6 @@ show(part001)`
|
||||
programMemory.root['part001'] as SketchGroup,
|
||||
[index, index]
|
||||
).segment
|
||||
expect(segment).toEqual({ type: 'base', to: [0, 0.04], from: [0, 0.04] })
|
||||
expect(segment).toEqual({ to: [0, 0.04], from: [0, 0.04], name: '' })
|
||||
})
|
||||
})
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { getAngle } from '../../lib/utils'
|
||||
import { TooTip, toolTips } from '../../useStore'
|
||||
import {
|
||||
Program,
|
||||
@ -6,7 +5,6 @@ import {
|
||||
CallExpression,
|
||||
} from '../abstractSyntaxTreeTypes'
|
||||
import { SketchGroup, SourceRange } from '../executor'
|
||||
import { InternalFn } from './stdTypes'
|
||||
|
||||
export function getSketchSegmentFromSourceRange(
|
||||
sketchGroup: SketchGroup,
|
||||
@ -36,79 +34,6 @@ export function getSketchSegmentFromSourceRange(
|
||||
}
|
||||
}
|
||||
|
||||
export const segLen: InternalFn = (
|
||||
_,
|
||||
segName: string,
|
||||
sketchGroup: SketchGroup
|
||||
): number => {
|
||||
const line = sketchGroup?.value.find((seg) => seg?.name === segName)
|
||||
// maybe this should throw, but the language doesn't have a way to handle errors yet
|
||||
if (!line) return 0
|
||||
|
||||
return Math.sqrt(
|
||||
(line.from[1] - line.to[1]) ** 2 + (line.from[0] - line.to[0]) ** 2
|
||||
)
|
||||
}
|
||||
|
||||
export const segAng: InternalFn = (
|
||||
_,
|
||||
segName: string,
|
||||
sketchGroup: SketchGroup
|
||||
): number => {
|
||||
const line = sketchGroup?.value.find((seg) => seg.name === segName)
|
||||
// maybe this should throw, but the language doesn't have a way to handle errors yet
|
||||
if (!line) return 0
|
||||
return getAngle(line.from, line.to)
|
||||
}
|
||||
|
||||
function segEndFactory(which: 'x' | 'y'): InternalFn {
|
||||
return (_, segName: string, sketchGroup: SketchGroup): number => {
|
||||
const line =
|
||||
sketchGroup?.start?.name === segName
|
||||
? sketchGroup?.start
|
||||
: sketchGroup?.value.find((seg) => seg.name === segName)
|
||||
// maybe this should throw, but the language doesn't have a way to handle errors yet
|
||||
if (!line) return 0
|
||||
return which === 'x' ? line.to[0] : line.to[1]
|
||||
}
|
||||
}
|
||||
|
||||
export const segEndX: InternalFn = segEndFactory('x')
|
||||
export const segEndY: InternalFn = segEndFactory('y')
|
||||
|
||||
function lastSegFactory(which: 'x' | 'y'): InternalFn {
|
||||
return (_, sketchGroup: SketchGroup): number => {
|
||||
const lastLine = sketchGroup?.value[sketchGroup.value.length - 1]
|
||||
return which === 'x' ? lastLine.to[0] : lastLine.to[1]
|
||||
}
|
||||
}
|
||||
|
||||
export const lastSegX: InternalFn = lastSegFactory('x')
|
||||
export const lastSegY: InternalFn = lastSegFactory('y')
|
||||
|
||||
function angleToMatchLengthFactory(which: 'x' | 'y'): InternalFn {
|
||||
return (_, segName: string, to: number, sketchGroup: SketchGroup): number => {
|
||||
const isX = which === 'x'
|
||||
const lineToMatch = sketchGroup?.value.find((seg) => seg.name === segName)
|
||||
// maybe this should throw, but the language doesn't have a way to handle errors yet
|
||||
if (!lineToMatch) return 0
|
||||
const lengthToMatch = Math.sqrt(
|
||||
(lineToMatch.from[1] - lineToMatch.to[1]) ** 2 +
|
||||
(lineToMatch.from[0] - lineToMatch.to[0]) ** 2
|
||||
)
|
||||
|
||||
const lastLine = sketchGroup?.value[sketchGroup.value.length - 1]
|
||||
const diff = Math.abs(to - (isX ? lastLine.to[0] : lastLine.to[1]))
|
||||
|
||||
const angleR = Math[isX ? 'acos' : 'asin'](diff / lengthToMatch)
|
||||
|
||||
return diff > lengthToMatch ? 0 : (angleR * 180) / Math.PI
|
||||
}
|
||||
}
|
||||
|
||||
export const angleToMatchLengthX: InternalFn = angleToMatchLengthFactory('x')
|
||||
export const angleToMatchLengthY: InternalFn = angleToMatchLengthFactory('y')
|
||||
|
||||
export function isSketchVariablesLinked(
|
||||
secondaryVarDec: VariableDeclarator,
|
||||
primaryVarDec: VariableDeclarator,
|
||||
|
@ -133,63 +133,63 @@ const myAng2 = 134
|
||||
const part001 = startSketchAt([0, 0])
|
||||
|> line({ to: [1, 3.82], tag: 'seg01' }, %) // ln-should-get-tag
|
||||
|> angledLineToX([
|
||||
-angleToMatchLengthX('seg01', myVar, %),
|
||||
myVar
|
||||
-angleToMatchLengthX('seg01', myVar, %),
|
||||
myVar
|
||||
], %) // ln-lineTo-xAbsolute should use angleToMatchLengthX helper
|
||||
|> angledLineToY([
|
||||
-angleToMatchLengthY('seg01', myVar, %),
|
||||
myVar
|
||||
-angleToMatchLengthY('seg01', myVar, %),
|
||||
myVar
|
||||
], %) // ln-lineTo-yAbsolute should use angleToMatchLengthY helper
|
||||
|> angledLine([45, segLen('seg01', %)], %) // ln-lineTo-free should become angledLine
|
||||
|> angledLine([45, segLen('seg01', %)], %) // ln-angledLineToX-free should become angledLine
|
||||
|> angledLine([myAng, segLen('seg01', %)], %) // ln-angledLineToX-angle should become angledLine
|
||||
|> angledLineToX([
|
||||
angleToMatchLengthX('seg01', myVar2, %),
|
||||
myVar2
|
||||
angleToMatchLengthX('seg01', myVar2, %),
|
||||
myVar2
|
||||
], %) // ln-angledLineToX-xAbsolute should use angleToMatchLengthX to get angle
|
||||
|> angledLine([-45, segLen('seg01', %)], %) // ln-angledLineToY-free should become angledLine
|
||||
|> angledLine([myAng2, segLen('seg01', %)], %) // ln-angledLineToY-angle should become angledLine
|
||||
|> angledLineToY([
|
||||
angleToMatchLengthY('seg01', myVar3, %),
|
||||
myVar3
|
||||
angleToMatchLengthY('seg01', myVar3, %),
|
||||
myVar3
|
||||
], %) // ln-angledLineToY-yAbsolute should use angleToMatchLengthY to get angle
|
||||
|> line([
|
||||
min(segLen('seg01', %), myVar),
|
||||
legLen(segLen('seg01', %), myVar)
|
||||
min(segLen('seg01', %), myVar),
|
||||
legLen(segLen('seg01', %), myVar)
|
||||
], %) // ln-should use legLen for y
|
||||
|> line([
|
||||
min(segLen('seg01', %), myVar),
|
||||
-legLen(segLen('seg01', %), myVar)
|
||||
min(segLen('seg01', %), myVar),
|
||||
-legLen(segLen('seg01', %), myVar)
|
||||
], %) // ln-legLen but negative
|
||||
|> angledLine([-112, segLen('seg01', %)], %) // ln-should become angledLine
|
||||
|> angledLine([myVar, segLen('seg01', %)], %) // ln-use segLen for secound arg
|
||||
|> angledLine([45, segLen('seg01', %)], %) // ln-segLen again
|
||||
|> angledLine([54, segLen('seg01', %)], %) // ln-should be transformed to angledLine
|
||||
|> angledLineOfXLength([
|
||||
legAngX(segLen('seg01', %), myVar),
|
||||
min(segLen('seg01', %), myVar)
|
||||
legAngX(segLen('seg01', %), myVar),
|
||||
min(segLen('seg01', %), myVar)
|
||||
], %) // ln-should use legAngX to calculate angle
|
||||
|> angledLineOfXLength([
|
||||
180 + legAngX(segLen('seg01', %), myVar),
|
||||
min(segLen('seg01', %), myVar)
|
||||
180 + legAngX(segLen('seg01', %), myVar),
|
||||
min(segLen('seg01', %), myVar)
|
||||
], %) // ln-same as above but should have + 180 to match original quadrant
|
||||
|> line([
|
||||
legLen(segLen('seg01', %), myVar),
|
||||
min(segLen('seg01', %), myVar)
|
||||
legLen(segLen('seg01', %), myVar),
|
||||
min(segLen('seg01', %), myVar)
|
||||
], %) // ln-legLen again but yRelative
|
||||
|> line([
|
||||
-legLen(segLen('seg01', %), myVar),
|
||||
min(segLen('seg01', %), myVar)
|
||||
-legLen(segLen('seg01', %), myVar),
|
||||
min(segLen('seg01', %), myVar)
|
||||
], %) // ln-negative legLen yRelative
|
||||
|> angledLine([58, segLen('seg01', %)], %) // ln-angledLineOfYLength-free should become angledLine
|
||||
|> angledLine([myAng, segLen('seg01', %)], %) // ln-angledLineOfYLength-angle should become angledLine
|
||||
|> angledLineOfXLength([
|
||||
legAngY(segLen('seg01', %), myVar),
|
||||
min(segLen('seg01', %), myVar)
|
||||
legAngY(segLen('seg01', %), myVar),
|
||||
min(segLen('seg01', %), myVar)
|
||||
], %) // ln-angledLineOfYLength-yRelative use legAngY
|
||||
|> angledLineOfXLength([
|
||||
270 + legAngY(segLen('seg01', %), myVar),
|
||||
min(segLen('seg01', %), myVar)
|
||||
270 + legAngY(segLen('seg01', %), myVar),
|
||||
min(segLen('seg01', %), myVar)
|
||||
], %) // ln-angledLineOfYLength-yRelative with angle > 90 use binExp
|
||||
|> xLine(segLen('seg01', %), %) // ln-xLine-free should sub in segLen
|
||||
|> yLine(segLen('seg01', %), %) // ln-yLine-free should sub in segLen
|
||||
@ -406,8 +406,8 @@ show(part001)`
|
||||
'setVertDistance'
|
||||
)
|
||||
expect(expectedCode).toContain(`|> lineTo([
|
||||
lastSegX(%) + myVar,
|
||||
segEndY('seg01', %) + 2.93
|
||||
lastSegX(%) + myVar,
|
||||
segEndY('seg01', %) + 2.93
|
||||
], %) // xRelative`)
|
||||
})
|
||||
it('testing for yRelative to horizontal distance', async () => {
|
||||
@ -417,8 +417,8 @@ show(part001)`
|
||||
'setHorzDistance'
|
||||
)
|
||||
expect(expectedCode).toContain(`|> lineTo([
|
||||
segEndX('seg01', %) + 2.6,
|
||||
lastSegY(%) + myVar
|
||||
segEndX('seg01', %) + 2.6,
|
||||
lastSegY(%) + myVar
|
||||
], %) // yRelative`)
|
||||
})
|
||||
})
|
||||
|
@ -1426,6 +1426,7 @@ export function transformAstSketchLines({
|
||||
selectionRanges.codeBasedSelections.forEach(({ range }, index) => {
|
||||
const callBack = transformInfos?.[index].createNode
|
||||
const transformTo = transformInfos?.[index].tooltip
|
||||
console.log('transformTo', transformInfos)
|
||||
if (!callBack || !transformTo) throw new Error('no callback helper')
|
||||
|
||||
const getNode = getNodeFromPathCurry(
|
||||
|
@ -1,170 +0,0 @@
|
||||
import {
|
||||
lineTo,
|
||||
xLineTo,
|
||||
yLineTo,
|
||||
line,
|
||||
xLine,
|
||||
yLine,
|
||||
angledLine,
|
||||
angledLineOfXLength,
|
||||
angledLineToX,
|
||||
angledLineOfYLength,
|
||||
angledLineToY,
|
||||
close,
|
||||
startSketchAt,
|
||||
angledLineThatIntersects,
|
||||
} from './sketch'
|
||||
import {
|
||||
segLen,
|
||||
segAng,
|
||||
angleToMatchLengthX,
|
||||
angleToMatchLengthY,
|
||||
segEndX,
|
||||
segEndY,
|
||||
lastSegX,
|
||||
lastSegY,
|
||||
} from './sketchConstraints'
|
||||
import { getExtrudeWallTransform, extrude } from './extrude'
|
||||
|
||||
import { InternalFn, InternalFnNames } from './stdTypes'
|
||||
|
||||
// const transform: InternalFn = <T extends SketchGroup | ExtrudeGroup>(
|
||||
// { sourceRange }: InternalFirstArg,
|
||||
// transformInfo: {
|
||||
// position: Position
|
||||
// quaternion: Rotation
|
||||
// },
|
||||
// sketch: T
|
||||
// ): T => {
|
||||
// const quaternionToApply = new Quaternion(...transformInfo?.quaternion)
|
||||
// const newQuaternion = new Quaternion(...sketch.rotation).multiply(
|
||||
// quaternionToApply.invert()
|
||||
// )
|
||||
|
||||
// const oldPosition = new Vector3(...sketch?.position)
|
||||
// const newPosition = oldPosition
|
||||
// .applyQuaternion(quaternionToApply)
|
||||
// .add(new Vector3(...transformInfo?.position))
|
||||
// return {
|
||||
// ...sketch,
|
||||
// position: newPosition.toArray(),
|
||||
// rotation: newQuaternion.toArray(),
|
||||
// __meta: [
|
||||
// ...sketch.__meta,
|
||||
// {
|
||||
// sourceRange,
|
||||
// pathToNode: [], // TODO
|
||||
// },
|
||||
// ],
|
||||
// }
|
||||
// }
|
||||
|
||||
// const translate: InternalFn = <T extends SketchGroup | ExtrudeGroup>(
|
||||
// { sourceRange }: InternalFirstArg,
|
||||
// vec3: [number, number, number],
|
||||
// sketch: T
|
||||
// ): T => {
|
||||
// const oldPosition = new Vector3(...sketch.position)
|
||||
// const newPosition = oldPosition.add(new Vector3(...vec3))
|
||||
// return {
|
||||
// ...sketch,
|
||||
// position: newPosition.toArray(),
|
||||
// __meta: [
|
||||
// ...sketch.__meta,
|
||||
// {
|
||||
// sourceRange,
|
||||
// pathToNode: [], // TODO
|
||||
// },
|
||||
// ],
|
||||
// }
|
||||
// }
|
||||
|
||||
const min: InternalFn = (_, a: number, b: number): number => Math.min(a, b)
|
||||
|
||||
const legLen: InternalFn = (_, hypotenuse: number, leg: number): number =>
|
||||
Math.sqrt(
|
||||
hypotenuse ** 2 - Math.min(Math.abs(leg), Math.abs(hypotenuse)) ** 2
|
||||
)
|
||||
|
||||
const legAngX: InternalFn = (_, hypotenuse: number, leg: number): number =>
|
||||
(Math.acos(Math.min(leg, hypotenuse) / hypotenuse) * 180) / Math.PI
|
||||
|
||||
const legAngY: InternalFn = (_, hypotenuse: number, leg: number): number =>
|
||||
(Math.asin(Math.min(leg, hypotenuse) / hypotenuse) * 180) / Math.PI
|
||||
|
||||
export const internalFns: { [key in InternalFnNames]: InternalFn } = {
|
||||
// TODO - re-enable these
|
||||
// rx: rotateOnAxis([1, 0, 0]), // Enable rotations #152
|
||||
// ry: rotateOnAxis([0, 1, 0]),
|
||||
// rz: rotateOnAxis([0, 0, 1]),
|
||||
extrude,
|
||||
// translate,
|
||||
// transform,
|
||||
getExtrudeWallTransform,
|
||||
min,
|
||||
legLen,
|
||||
legAngX,
|
||||
legAngY,
|
||||
segEndX,
|
||||
segEndY,
|
||||
lastSegX,
|
||||
lastSegY,
|
||||
segLen,
|
||||
segAng,
|
||||
angleToMatchLengthX,
|
||||
angleToMatchLengthY,
|
||||
lineTo: lineTo.fn,
|
||||
xLineTo: xLineTo.fn,
|
||||
yLineTo: yLineTo.fn,
|
||||
line: line.fn,
|
||||
xLine: xLine.fn,
|
||||
yLine: yLine.fn,
|
||||
angledLine: angledLine.fn,
|
||||
angledLineOfXLength: angledLineOfXLength.fn,
|
||||
angledLineToX: angledLineToX.fn,
|
||||
angledLineOfYLength: angledLineOfYLength.fn,
|
||||
angledLineToY: angledLineToY.fn,
|
||||
angledLineThatIntersects: angledLineThatIntersects.fn,
|
||||
startSketchAt,
|
||||
close,
|
||||
}
|
||||
|
||||
// function rotateOnAxis<T extends SketchGroup | ExtrudeGroup>(
|
||||
// axisMultiplier: [number, number, number]
|
||||
// ): InternalFn {
|
||||
// return ({ sourceRange }, rotationD: number, sketch: T): T => {
|
||||
// const rotationR = rotationD * (Math.PI / 180)
|
||||
// const rotateVec = new Vector3(...axisMultiplier)
|
||||
// const quaternion = new Quaternion()
|
||||
// quaternion.setFromAxisAngle(rotateVec, rotationR)
|
||||
|
||||
// const position = new Vector3(...sketch.position)
|
||||
// .applyQuaternion(quaternion)
|
||||
// .toArray()
|
||||
|
||||
// const existingQuat = new Quaternion(...sketch.rotation)
|
||||
// const rotation = quaternion.multiply(existingQuat).toArray()
|
||||
// return {
|
||||
// ...sketch,
|
||||
// rotation,
|
||||
// position,
|
||||
// __meta: [
|
||||
// ...sketch.__meta,
|
||||
// {
|
||||
// sourceRange,
|
||||
// pathToNode: [], // TODO
|
||||
// },
|
||||
// ],
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
export function clockwiseSign(points: [number, number][]): number {
|
||||
let sum = 0
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const currentPoint = points[i]
|
||||
const nextPoint = points[(i + 1) % points.length]
|
||||
sum += (nextPoint[0] - currentPoint[0]) * (nextPoint[1] + currentPoint[1])
|
||||
}
|
||||
return sum >= 0 ? 1 : -1
|
||||
}
|
@ -17,44 +17,6 @@ export interface PathReturn {
|
||||
currentPath: Path
|
||||
}
|
||||
|
||||
export type InternalFn = (internals: InternalFirstArg, ...args: any[]) => any
|
||||
|
||||
export type InternalFnNames =
|
||||
// TODO re-enable these
|
||||
// | 'translate'
|
||||
// | 'transform'
|
||||
// | 'rx' // Enable rotations #152
|
||||
// | 'ry'
|
||||
// | 'rz'
|
||||
| 'extrude'
|
||||
| 'getExtrudeWallTransform'
|
||||
| 'min'
|
||||
| 'legLen'
|
||||
| 'legAngX'
|
||||
| 'legAngY'
|
||||
| 'segEndX'
|
||||
| 'segEndY'
|
||||
| 'lastSegX'
|
||||
| 'lastSegY'
|
||||
| 'segLen'
|
||||
| 'segAng'
|
||||
| 'angleToMatchLengthX'
|
||||
| 'angleToMatchLengthY'
|
||||
| 'lineTo'
|
||||
| 'yLineTo'
|
||||
| 'xLineTo'
|
||||
| 'line'
|
||||
| 'yLine'
|
||||
| 'xLine'
|
||||
| 'angledLine'
|
||||
| 'angledLineOfXLength'
|
||||
| 'angledLineToX'
|
||||
| 'angledLineOfYLength'
|
||||
| 'angledLineToY'
|
||||
| 'startSketchAt'
|
||||
| 'close'
|
||||
| 'angledLineThatIntersects'
|
||||
|
||||
export interface ModifyAstBase {
|
||||
node: Program
|
||||
previousProgramMemory: ProgramMemory
|
||||
@ -87,7 +49,6 @@ export type SketchCallTransfromMap = {
|
||||
}
|
||||
|
||||
export interface SketchLineHelper {
|
||||
fn: InternalFn
|
||||
add: (a: addCall) => {
|
||||
modifiedAst: Program
|
||||
pathToNode: PathToNode
|
||||
|
@ -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",
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { lexer_js } from '../wasm-lib/pkg/wasm_lib'
|
||||
import { initPromise } from './rust'
|
||||
import { Token } from '../wasm-lib/bindings/Token'
|
||||
import { Token } from '../wasm-lib/kcl/bindings/Token'
|
||||
|
||||
export type { Token } from '../wasm-lib/bindings/Token'
|
||||
export type { Token } from '../wasm-lib/kcl/bindings/Token'
|
||||
|
||||
export async function asyncLexer(str: string): Promise<Token[]> {
|
||||
await initPromise
|
||||
|
@ -26,3 +26,12 @@ export function updateCursors(
|
||||
setCursor(newSelections)
|
||||
}
|
||||
}
|
||||
|
||||
export function isReducedMotion(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia &&
|
||||
// TODO/Note I (Kurt) think '(prefers-reduced-motion: reduce)' and '(prefers-reduced-motion)' are equivalent, but not 100% sure
|
||||
window.matchMedia('(prefers-reduced-motion)').matches
|
||||
)
|
||||
}
|
||||
|
124
src/lib/commands.ts
Normal file
124
src/lib/commands.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { AnyStateMachine, EventFrom, StateFrom } from 'xstate'
|
||||
import { isTauri } from './isTauri'
|
||||
|
||||
type InitialCommandBarMetaArg = {
|
||||
name: string
|
||||
type: 'string' | 'select'
|
||||
description?: string
|
||||
defaultValue?: string
|
||||
options: string | Array<{ name: string }>
|
||||
}
|
||||
|
||||
type Platform = 'both' | 'web' | 'desktop'
|
||||
|
||||
export type CommandBarMeta = {
|
||||
[key: string]:
|
||||
| {
|
||||
displayValue: (args: string[]) => string
|
||||
args: InitialCommandBarMetaArg[]
|
||||
hide?: Platform
|
||||
}
|
||||
| {
|
||||
hide?: Platform
|
||||
}
|
||||
}
|
||||
|
||||
export type Command = {
|
||||
owner: string
|
||||
name: string
|
||||
callback: Function
|
||||
meta?: {
|
||||
displayValue(args: string[]): string | string
|
||||
args: SubCommand[]
|
||||
}
|
||||
}
|
||||
|
||||
export type SubCommand = {
|
||||
name: string
|
||||
type: 'select' | 'string'
|
||||
description?: string
|
||||
options?: Partial<{ name: string }>[]
|
||||
}
|
||||
|
||||
interface CommandBarArgs<T extends AnyStateMachine> {
|
||||
type: EventFrom<T>['type']
|
||||
state: StateFrom<T>
|
||||
commandBarMeta?: CommandBarMeta
|
||||
send: Function
|
||||
owner: string
|
||||
}
|
||||
|
||||
export function createMachineCommand<T extends AnyStateMachine>({
|
||||
type,
|
||||
state,
|
||||
commandBarMeta,
|
||||
send,
|
||||
owner,
|
||||
}: CommandBarArgs<T>): Command | null {
|
||||
const lookedUpMeta = commandBarMeta && commandBarMeta[type]
|
||||
if (lookedUpMeta && 'hide' in lookedUpMeta) {
|
||||
const { hide } = lookedUpMeta
|
||||
if (hide === 'both') return null
|
||||
else if (hide === 'desktop' && isTauri()) return null
|
||||
else if (hide === 'web' && !isTauri()) return null
|
||||
}
|
||||
let replacedArgs
|
||||
|
||||
if (lookedUpMeta && 'args' in lookedUpMeta) {
|
||||
replacedArgs = lookedUpMeta.args.map((arg) => {
|
||||
const optionsFromContext = state.context[
|
||||
arg.options as keyof typeof state.context
|
||||
] as { name: string }[] | string | undefined
|
||||
const defaultValueFromContext = state.context[
|
||||
arg.defaultValue as keyof typeof state.context
|
||||
] as string | undefined
|
||||
|
||||
const options =
|
||||
arg.options instanceof Array
|
||||
? arg.options.map((o) => ({
|
||||
...o,
|
||||
description:
|
||||
defaultValueFromContext === o.name ? '(current)' : '',
|
||||
}))
|
||||
: !optionsFromContext || typeof optionsFromContext === 'string'
|
||||
? [
|
||||
{
|
||||
name: optionsFromContext,
|
||||
description: arg.description || '',
|
||||
},
|
||||
]
|
||||
: optionsFromContext.map((o) => ({
|
||||
name: o.name || '',
|
||||
description: arg.description || '',
|
||||
}))
|
||||
|
||||
return {
|
||||
...arg,
|
||||
options,
|
||||
}
|
||||
}) as any[]
|
||||
}
|
||||
|
||||
// We have to recreate this object every time,
|
||||
// otherwise we'll have stale state in the CommandBar
|
||||
// after completing our first action
|
||||
const meta = lookedUpMeta
|
||||
? {
|
||||
...lookedUpMeta,
|
||||
args: replacedArgs,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
name: type,
|
||||
owner,
|
||||
callback: (data: EventFrom<T, typeof type>) => {
|
||||
if (data !== undefined && data !== null) {
|
||||
send(type, { data })
|
||||
} else {
|
||||
send(type)
|
||||
}
|
||||
},
|
||||
meta: meta as any,
|
||||
}
|
||||
}
|
@ -1,16 +1,13 @@
|
||||
import { useAuthMachine } from '../hooks/useAuthMachine'
|
||||
export default function fetcher(input: RequestInfo, init: RequestInit = {}) {
|
||||
const fetcherWithToken = async (token?: string): Promise<JSON> => {
|
||||
const headers = { ...init.headers } as Record<string, string>
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
export default async function fetcher<JSON = any>(
|
||||
input: RequestInfo,
|
||||
init: RequestInit = {}
|
||||
): Promise<JSON> {
|
||||
const [token] = useAuthMachine((s) => s?.context?.token)
|
||||
const headers = { ...init.headers } as Record<string, string>
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
const credentials = 'include' as RequestCredentials
|
||||
const res = await fetch(input, { ...init, credentials, headers })
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const credentials = 'include' as RequestCredentials
|
||||
const res = await fetch(input, { ...init, credentials, headers })
|
||||
return res.json()
|
||||
return fetcherWithToken
|
||||
}
|
||||
|
@ -1,9 +0,0 @@
|
||||
import { Themes } from '../useStore'
|
||||
|
||||
export function getSystemTheme(): Exclude<Themes, 'system'> {
|
||||
return typeof window !== 'undefined' &&
|
||||
'matchMedia' in window &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? Themes.Dark
|
||||
: Themes.Light
|
||||
}
|
64
src/lib/sorting.ts
Normal file
64
src/lib/sorting.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faCircleDot,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { ProjectWithEntryPointMetadata } from '../Router'
|
||||
|
||||
const DESC = ':desc'
|
||||
|
||||
export function getSortIcon(currentSort: string, newSort: string) {
|
||||
if (currentSort === newSort) {
|
||||
return faArrowUp
|
||||
} else if (currentSort === newSort + DESC) {
|
||||
return faArrowDown
|
||||
}
|
||||
return faCircleDot
|
||||
}
|
||||
|
||||
export function getNextSearchParams(currentSort: string, newSort: string) {
|
||||
if (currentSort === null || !currentSort)
|
||||
return { sort_by: newSort + (newSort !== 'modified' ? DESC : '') }
|
||||
if (currentSort.includes(newSort) && !currentSort.includes(DESC))
|
||||
return { sort_by: '' }
|
||||
return {
|
||||
sort_by: newSort + (currentSort.includes(DESC) ? '' : DESC),
|
||||
}
|
||||
}
|
||||
|
||||
export function getSortFunction(sortBy: string) {
|
||||
const sortByName = (
|
||||
a: ProjectWithEntryPointMetadata,
|
||||
b: ProjectWithEntryPointMetadata
|
||||
) => {
|
||||
if (a.name && b.name) {
|
||||
return sortBy.includes('desc')
|
||||
? a.name.localeCompare(b.name)
|
||||
: b.name.localeCompare(a.name)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const sortByModified = (
|
||||
a: ProjectWithEntryPointMetadata,
|
||||
b: ProjectWithEntryPointMetadata
|
||||
) => {
|
||||
if (
|
||||
a.entrypoint_metadata?.modifiedAt &&
|
||||
b.entrypoint_metadata?.modifiedAt
|
||||
) {
|
||||
return !sortBy || sortBy.includes('desc')
|
||||
? b.entrypoint_metadata.modifiedAt.getTime() -
|
||||
a.entrypoint_metadata.modifiedAt.getTime()
|
||||
: a.entrypoint_metadata.modifiedAt.getTime() -
|
||||
b.entrypoint_metadata.modifiedAt.getTime()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
if (sortBy?.includes('name')) {
|
||||
return sortByName
|
||||
} else {
|
||||
return sortByModified
|
||||
}
|
||||
}
|
@ -1,6 +1,11 @@
|
||||
import { FileEntry, createDir, exists, writeTextFile } from '@tauri-apps/api/fs'
|
||||
import {
|
||||
FileEntry,
|
||||
createDir,
|
||||
exists,
|
||||
readDir,
|
||||
writeTextFile,
|
||||
} from '@tauri-apps/api/fs'
|
||||
import { documentDir } from '@tauri-apps/api/path'
|
||||
import { useStore } from '../useStore'
|
||||
import { isTauri } from './isTauri'
|
||||
import { ProjectWithEntryPointMetadata } from '../Router'
|
||||
import { metadata } from 'tauri-plugin-fs-extra-api'
|
||||
@ -12,35 +17,31 @@ const INDEX_IDENTIFIER = '$n' // $nn.. will pad the number with 0s
|
||||
export const MAX_PADDING = 7
|
||||
|
||||
// Initializes the project directory and returns the path
|
||||
export async function initializeProjectDirectory() {
|
||||
export async function initializeProjectDirectory(directory: string) {
|
||||
if (!isTauri()) {
|
||||
throw new Error(
|
||||
'initializeProjectDirectory() can only be called from a Tauri app'
|
||||
)
|
||||
}
|
||||
const { defaultDir: projectDir, setDefaultDir } = useStore.getState()
|
||||
|
||||
if (projectDir && projectDir.dir.length > 0) {
|
||||
const dirExists = await exists(projectDir.dir)
|
||||
if (directory) {
|
||||
const dirExists = await exists(directory)
|
||||
if (!dirExists) {
|
||||
await createDir(projectDir.dir, { recursive: true })
|
||||
await createDir(directory, { recursive: true })
|
||||
}
|
||||
return projectDir
|
||||
return directory
|
||||
}
|
||||
|
||||
const appData = await documentDir()
|
||||
const docDirectory = await documentDir()
|
||||
|
||||
const INITIAL_DEFAULT_DIR = {
|
||||
dir: appData + PROJECT_FOLDER,
|
||||
}
|
||||
const INITIAL_DEFAULT_DIR = docDirectory + PROJECT_FOLDER
|
||||
|
||||
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR.dir)
|
||||
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR)
|
||||
|
||||
if (!defaultDirExists) {
|
||||
await createDir(INITIAL_DEFAULT_DIR.dir, { recursive: true })
|
||||
await createDir(INITIAL_DEFAULT_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
setDefaultDir(INITIAL_DEFAULT_DIR)
|
||||
return INITIAL_DEFAULT_DIR
|
||||
}
|
||||
|
||||
@ -51,6 +52,25 @@ export function isProjectDirectory(fileOrDir: Partial<FileEntry>) {
|
||||
)
|
||||
}
|
||||
|
||||
// Read the contents of a directory
|
||||
// and return the valid projects
|
||||
export async function getProjectsInDir(projectDir: string) {
|
||||
const readProjects = (
|
||||
await readDir(projectDir, {
|
||||
recursive: true,
|
||||
})
|
||||
).filter(isProjectDirectory)
|
||||
|
||||
const projectsWithMetadata = await Promise.all(
|
||||
readProjects.map(async (p) => ({
|
||||
entrypoint_metadata: await metadata(p.path + '/' + PROJECT_ENTRYPOINT),
|
||||
...p,
|
||||
}))
|
||||
)
|
||||
|
||||
return projectsWithMetadata
|
||||
}
|
||||
|
||||
// Creates a new file in the default directory with the default project name
|
||||
// Returns the path to the new file
|
||||
export async function createNewProject(
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { Program } from '../lang/abstractSyntaxTreeTypes'
|
||||
import { ProgramMemory, _executor } from '../lang/executor'
|
||||
import { EngineCommandManager } from '../lang/std/engineConnection'
|
||||
import {
|
||||
EngineCommandManager,
|
||||
EngineCommand,
|
||||
} from '../lang/std/engineConnection'
|
||||
import { SourceRange } from 'lang/executor'
|
||||
|
||||
class MockEngineCommandManager {
|
||||
constructor(mockParams: {
|
||||
@ -10,13 +14,42 @@ class MockEngineCommandManager {
|
||||
startNewSession() {}
|
||||
waitForAllCommands() {}
|
||||
waitForReady = new Promise<void>((resolve) => resolve())
|
||||
sendModellingCommand() {}
|
||||
sendModelingCommand({
|
||||
id,
|
||||
range,
|
||||
command,
|
||||
}: {
|
||||
id: string
|
||||
range: SourceRange
|
||||
command: EngineCommand
|
||||
}): Promise<any> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
sendModelingCommandFromWasm(
|
||||
id: string,
|
||||
rangeStr: string,
|
||||
commandStr: string
|
||||
): Promise<any> {
|
||||
if (id === undefined) {
|
||||
throw new Error('id is undefined')
|
||||
}
|
||||
if (rangeStr === undefined) {
|
||||
throw new Error('rangeStr is undefined')
|
||||
}
|
||||
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 })
|
||||
}
|
||||
sendSceneCommand() {}
|
||||
}
|
||||
|
||||
export async function enginelessExecutor(
|
||||
ast: Program,
|
||||
pm: ProgramMemory = { root: {}, pendingMemory: {} }
|
||||
pm: ProgramMemory = { root: {} }
|
||||
): Promise<ProgramMemory> {
|
||||
const mockEngineCommandManager = new MockEngineCommandManager({
|
||||
setIsStreamReady: () => {},
|
||||
@ -31,7 +64,7 @@ export async function enginelessExecutor(
|
||||
|
||||
export async function executor(
|
||||
ast: Program,
|
||||
pm: ProgramMemory = { root: {}, pendingMemory: {} }
|
||||
pm: ProgramMemory = { root: {} }
|
||||
): Promise<ProgramMemory> {
|
||||
const engineCommandManager = new EngineCommandManager({
|
||||
setIsStreamReady: () => {},
|
||||
|
23
src/lib/theme.ts
Normal file
23
src/lib/theme.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export enum Themes {
|
||||
Light = 'light',
|
||||
Dark = 'dark',
|
||||
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
|
||||
? Themes.Dark
|
||||
: Themes.Light
|
||||
: Themes.Light
|
||||
}
|
||||
|
||||
// Set the theme class on the body element
|
||||
export function setThemeClass(theme: Themes) {
|
||||
if (theme === Themes.Dark) {
|
||||
document.body.classList.add('dark')
|
||||
} else {
|
||||
document.body.classList.remove('dark')
|
||||
}
|
||||
}
|
@ -1,6 +1,24 @@
|
||||
import { createMachine, assign } from 'xstate'
|
||||
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']
|
||||
@ -9,16 +27,22 @@ export interface UserContext {
|
||||
|
||||
export type Events =
|
||||
| {
|
||||
type: 'logout'
|
||||
type: 'Log out'
|
||||
}
|
||||
| {
|
||||
type: 'tryLogin'
|
||||
type: 'Log in'
|
||||
token?: string
|
||||
}
|
||||
|
||||
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
|
||||
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
|
||||
|
||||
export const authCommandBarMeta: CommandBarMeta = {
|
||||
'Log in': {
|
||||
hide: 'both',
|
||||
},
|
||||
}
|
||||
|
||||
export const authMachine = createMachine<UserContext, Events>(
|
||||
{
|
||||
id: 'Auth',
|
||||
@ -50,7 +74,7 @@ export const authMachine = createMachine<UserContext, Events>(
|
||||
loggedIn: {
|
||||
entry: ['goToIndexPage'],
|
||||
on: {
|
||||
logout: {
|
||||
'Log out': {
|
||||
target: 'loggedOut',
|
||||
},
|
||||
},
|
||||
@ -58,10 +82,10 @@ export const authMachine = createMachine<UserContext, Events>(
|
||||
loggedOut: {
|
||||
entry: ['goToSignInPage'],
|
||||
on: {
|
||||
tryLogin: {
|
||||
'Log in': {
|
||||
target: 'checkIfLoggedIn',
|
||||
actions: assign({
|
||||
token: (context, event) => {
|
||||
token: (_, event) => {
|
||||
const token = event.token || ''
|
||||
localStorage.setItem(TOKEN_PERSIST_KEY, token)
|
||||
return token
|
||||
@ -71,10 +95,12 @@ export const authMachine = createMachine<UserContext, Events>(
|
||||
},
|
||||
},
|
||||
},
|
||||
schema: { events: {} as { type: 'logout' } | { type: 'tryLogin' } },
|
||||
schema: { events: {} as { type: 'Log out' } | { type: 'Log in' } },
|
||||
predictableActionArguments: true,
|
||||
preserveActionOrder: true,
|
||||
context: { token: persistedToken },
|
||||
context: {
|
||||
token: persistedToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {},
|
||||
@ -91,12 +117,17 @@ async function getUser(context: UserContext) {
|
||||
}
|
||||
if (!context.token && '__TAURI__' in window) throw 'not log in'
|
||||
if (context.token) headers['Authorization'] = `Bearer ${context.token}`
|
||||
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
|
||||
if (SKIP_AUTH) return LOCAL_USER
|
||||
try {
|
||||
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)
|
||||
}
|
||||
}
|
218
src/machines/homeMachine.ts
Normal file
218
src/machines/homeMachine.ts
Normal file
@ -0,0 +1,218 @@
|
||||
import { assign, createMachine } from 'xstate'
|
||||
import { ProjectWithEntryPointMetadata } from '../Router'
|
||||
import { CommandBarMeta } from '../lib/commands'
|
||||
|
||||
export const homeCommandMeta: CommandBarMeta = {
|
||||
'Create project': {
|
||||
displayValue: (args: string[]) => `Create project "${args[0]}"`,
|
||||
args: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
description: '(default)',
|
||||
options: 'defaultProjectName',
|
||||
},
|
||||
],
|
||||
},
|
||||
'Open project': {
|
||||
displayValue: (args: string[]) => `Open project "${args[0]}"`,
|
||||
args: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'select',
|
||||
options: 'projects',
|
||||
},
|
||||
],
|
||||
},
|
||||
'Delete project': {
|
||||
displayValue: (args: string[]) => `Delete project "${args[0]}"`,
|
||||
args: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'select',
|
||||
options: 'projects',
|
||||
},
|
||||
],
|
||||
},
|
||||
'Rename project': {
|
||||
displayValue: (args: string[]) =>
|
||||
`Rename project "${args[0]}" to "${args[1]}"`,
|
||||
args: [
|
||||
{
|
||||
name: 'oldName',
|
||||
type: 'select',
|
||||
options: 'projects',
|
||||
},
|
||||
{
|
||||
name: 'newName',
|
||||
type: 'string',
|
||||
description: '(default)',
|
||||
options: 'defaultProjectName',
|
||||
},
|
||||
],
|
||||
},
|
||||
assign: {
|
||||
hide: 'both',
|
||||
},
|
||||
}
|
||||
|
||||
export const homeMachine = createMachine(
|
||||
{
|
||||
/** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAHTK6xampYAOATqgFZj4AusAxAMLMwuLthbtOXANoAGALqJQjVLGJdiqUopAAPRAHYAbPooAWABwBGUwE5zAJgeGArM-MAaEAE9EN0wGYKGX97GX1nGVNDS0MbfwBfeM80TBwCEnIqGiZWDm4+ACUwUlxU8TzpeW1lVXVNbT0EcJNg02d-fzt7fU77Tx8EQ0iKCPtnfUsjGRtLGXtE5IxsPCIySmpacsk+QWFRHIluWQUkEBq1DS1TxqN7ChjzOxtXf0t7a37EcwsRibH-ZzRezA8wLEApZbpNZZTa5ba8AAiYAANmB9lsjlVTuc6ldQDdDOYKP5bm0os5TDJDJ8mlEzPpzIZHA4bO9umCIWlVpkNgcKnwAPKMYp8yTHaoqC71a6IEmBUz6BkWZzWDq2Uw0qzOIJAwz+PXWfSmeZJcFLLkZSi7ERkKCi7i8CCaShkABuqAA1pR8EIRGAALQYyonJSS3ENRDA2wUeyvd6dPVhGw0-RhGOp8IA8xGFkc80rS0Ua3qUh2oO8MDMVjMCiMZEiABmqGY6AoPr2AaD4uxYcuEYQoQpQWNNjsMnMgLGKbT3TC7TcOfsNjzqQL0KKJXQtvtXEdzoobs9lCEm87cMxIbOvel+MQqtMQRmS5ks31sZpAUsZkcIX+cQZJIrpC3KUBupTbuWlbVrW9ZcE2LYUCepRnocwYSrUfYyggbzvBQ+jMq49imLYwTUt4iCft+5i-u0-7UfoQEWtCSKoiWZbnruTqZIeXoUBAKJoihFTdqGGE3rod7UdqsQTI8hiGAqrIauRA7RvYeoqhO1jtAqjFrpkLFohBHEVlWzYwY2zatvxrFCWKWKiVKeISdh4yBJE-jGs4fhhA4zg0kRNgxhplhaW0nn4XpUKZEUuAQMZqF8FxLqkO6vG+hAgYcbAIlXmJzmNERdy0RYNiKgpthxDSEU6q8MSTJYjWGFFIEULF8WljuSX7jxx7CJlQY5ZYl44pht4IP61gyPc8njt0lIuH51UKrVVITEyMy2C1hbtQl-KmdBdaWQhGVZYluWjeJjSTf402shMEyuEyljPAFL0UNmMiuN86lWHMiSmvQ-HwKcnL6WA6FOf2k3mESMRDA4RpUm4U4qf6gSEt0QIvvqfjOCaiyrtF6zZPQXWQ+GWFlUEsbmNMf1TV9NLeXDcqRIySnNaaYPEzC5M9vl-b+IyFCjupryPF9jKWP5Kks-cbMWLERHRNt0LFntkgU2NLk4dqsz43YsTK++Kk2C+MbTOOcxzOMrhqzFxTgZ1Qba1dd6BUE1jGsLMxxK9KlDNqm3tMLUQvqYlgO5QhlsTubsFXesTTUuPTfHExshDS0RftRftGgEnTZtHbX9Zr+QJ-2S4Y3qnmTC+4tMyp1EfeOnmeQqdOhyXQrFOXXCV1hCkmLDOnBJYvRRDSsyRzGjiKj0lKdAkANAA */
|
||||
id: 'Home machine',
|
||||
|
||||
initial: 'Reading projects',
|
||||
|
||||
context: {
|
||||
projects: [] as ProjectWithEntryPointMetadata[],
|
||||
defaultProjectName: '',
|
||||
defaultDirectory: '',
|
||||
},
|
||||
|
||||
on: {
|
||||
assign: {
|
||||
actions: assign((_, event) => ({
|
||||
...event.data,
|
||||
})),
|
||||
target: '.Reading projects',
|
||||
},
|
||||
},
|
||||
states: {
|
||||
'Has no projects': {
|
||||
on: {
|
||||
'Create project': {
|
||||
target: 'Creating project',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'Has projects': {
|
||||
on: {
|
||||
'Rename project': {
|
||||
target: 'Renaming project',
|
||||
},
|
||||
|
||||
'Create project': {
|
||||
target: 'Creating project',
|
||||
},
|
||||
|
||||
'Delete project': {
|
||||
target: 'Deleting project',
|
||||
},
|
||||
|
||||
'Open project': {
|
||||
target: 'Opening project',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'Creating project': {
|
||||
invoke: {
|
||||
id: 'create-project',
|
||||
src: 'createProject',
|
||||
onDone: [
|
||||
{
|
||||
target: 'Reading projects',
|
||||
actions: ['toastSuccess'],
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
target: 'Reading projects',
|
||||
actions: ['toastError'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
'Renaming project': {
|
||||
invoke: {
|
||||
id: 'rename-project',
|
||||
src: 'renameProject',
|
||||
onDone: [
|
||||
{
|
||||
target: '#Home machine.Reading projects',
|
||||
actions: ['toastSuccess'],
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
target: '#Home machine.Reading projects',
|
||||
actions: ['toastError'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
'Deleting project': {
|
||||
invoke: {
|
||||
id: 'delete-project',
|
||||
src: 'deleteProject',
|
||||
onDone: [
|
||||
{
|
||||
actions: ['toastSuccess'],
|
||||
target: '#Home machine.Reading projects',
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
actions: ['toastError'],
|
||||
target: '#Home machine.Has projects',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'Reading projects': {
|
||||
invoke: {
|
||||
id: 'read-projects',
|
||||
src: 'readProjects',
|
||||
onDone: [
|
||||
{
|
||||
cond: 'Has at least 1 project',
|
||||
target: 'Has projects',
|
||||
actions: ['setProjects'],
|
||||
},
|
||||
{
|
||||
target: 'Has no projects',
|
||||
actions: ['setProjects'],
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
target: 'Has no projects',
|
||||
actions: ['toastError'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
'Opening project': {
|
||||
entry: ['navigateToProject'],
|
||||
},
|
||||
},
|
||||
|
||||
schema: {
|
||||
events: {} as
|
||||
| { type: 'Open project'; data: { name: string } }
|
||||
| { type: 'Rename project'; data: { oldName: string; newName: string } }
|
||||
| { type: 'Create project'; data: { name: string } }
|
||||
| { type: 'Delete project'; data: { name: string } }
|
||||
| { type: 'navigate'; data: { name: string } }
|
||||
| {
|
||||
type: 'done.invoke.read-projects'
|
||||
data: ProjectWithEntryPointMetadata[]
|
||||
}
|
||||
| { type: 'assign'; data: { [key: string]: any } },
|
||||
},
|
||||
|
||||
predictableActionArguments: true,
|
||||
preserveActionOrder: true,
|
||||
tsTypes: {} as import('./homeMachine.typegen').Typegen0,
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
setProjects: assign((_, event) => {
|
||||
return { projects: event.data as ProjectWithEntryPointMetadata[] }
|
||||
}),
|
||||
},
|
||||
}
|
||||
)
|
99
src/machines/homeMachine.typegen.ts
Normal file
99
src/machines/homeMachine.typegen.ts
Normal file
@ -0,0 +1,99 @@
|
||||
// This file was automatically generated. Edits will be overwritten
|
||||
|
||||
export interface Typegen0 {
|
||||
'@@xstate/typegen': true
|
||||
internalEvents: {
|
||||
'done.invoke.create-project': {
|
||||
type: 'done.invoke.create-project'
|
||||
data: unknown
|
||||
__tip: 'See the XState TS docs to learn how to strongly type this.'
|
||||
}
|
||||
'done.invoke.delete-project': {
|
||||
type: 'done.invoke.delete-project'
|
||||
data: unknown
|
||||
__tip: 'See the XState TS docs to learn how to strongly type this.'
|
||||
}
|
||||
'done.invoke.read-projects': {
|
||||
type: 'done.invoke.read-projects'
|
||||
data: unknown
|
||||
__tip: 'See the XState TS docs to learn how to strongly type this.'
|
||||
}
|
||||
'done.invoke.rename-project': {
|
||||
type: 'done.invoke.rename-project'
|
||||
data: unknown
|
||||
__tip: 'See the XState TS docs to learn how to strongly type this.'
|
||||
}
|
||||
'error.platform.create-project': {
|
||||
type: 'error.platform.create-project'
|
||||
data: unknown
|
||||
}
|
||||
'error.platform.delete-project': {
|
||||
type: 'error.platform.delete-project'
|
||||
data: unknown
|
||||
}
|
||||
'error.platform.read-projects': {
|
||||
type: 'error.platform.read-projects'
|
||||
data: unknown
|
||||
}
|
||||
'error.platform.rename-project': {
|
||||
type: 'error.platform.rename-project'
|
||||
data: unknown
|
||||
}
|
||||
'xstate.init': { type: 'xstate.init' }
|
||||
}
|
||||
invokeSrcNameMap: {
|
||||
createProject: 'done.invoke.create-project'
|
||||
deleteProject: 'done.invoke.delete-project'
|
||||
readProjects: 'done.invoke.read-projects'
|
||||
renameProject: 'done.invoke.rename-project'
|
||||
}
|
||||
missingImplementations: {
|
||||
actions: 'navigateToProject' | 'toastError' | 'toastSuccess'
|
||||
delays: never
|
||||
guards: 'Has at least 1 project'
|
||||
services:
|
||||
| 'createProject'
|
||||
| 'deleteProject'
|
||||
| 'readProjects'
|
||||
| 'renameProject'
|
||||
}
|
||||
eventsCausingActions: {
|
||||
navigateToProject: 'Open project'
|
||||
setProjects: 'done.invoke.read-projects'
|
||||
toastError:
|
||||
| 'error.platform.create-project'
|
||||
| 'error.platform.delete-project'
|
||||
| 'error.platform.read-projects'
|
||||
| 'error.platform.rename-project'
|
||||
toastSuccess:
|
||||
| 'done.invoke.create-project'
|
||||
| 'done.invoke.delete-project'
|
||||
| 'done.invoke.rename-project'
|
||||
}
|
||||
eventsCausingDelays: {}
|
||||
eventsCausingGuards: {
|
||||
'Has at least 1 project': 'done.invoke.read-projects'
|
||||
}
|
||||
eventsCausingServices: {
|
||||
createProject: 'Create project'
|
||||
deleteProject: 'Delete project'
|
||||
readProjects:
|
||||
| 'assign'
|
||||
| 'done.invoke.create-project'
|
||||
| 'done.invoke.delete-project'
|
||||
| 'done.invoke.rename-project'
|
||||
| 'error.platform.create-project'
|
||||
| 'error.platform.rename-project'
|
||||
| 'xstate.init'
|
||||
renameProject: 'Rename project'
|
||||
}
|
||||
matchesStates:
|
||||
| 'Creating project'
|
||||
| 'Deleting project'
|
||||
| 'Has no projects'
|
||||
| 'Has projects'
|
||||
| 'Opening project'
|
||||
| 'Reading projects'
|
||||
| 'Renaming project'
|
||||
tags: never
|
||||
}
|
207
src/machines/settingsMachine.ts
Normal file
207
src/machines/settingsMachine.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import { assign, createMachine } from 'xstate'
|
||||
import { BaseUnit, baseUnitsUnion } from '../useStore'
|
||||
import { CommandBarMeta } from '../lib/commands'
|
||||
import { Themes, getSystemTheme, setThemeClass } from '../lib/theme'
|
||||
|
||||
export enum UnitSystem {
|
||||
Imperial = 'imperial',
|
||||
Metric = 'metric',
|
||||
}
|
||||
|
||||
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
|
||||
|
||||
export const settingsCommandBarMeta: CommandBarMeta = {
|
||||
'Set Theme': {
|
||||
displayValue: (args: string[]) => 'Change the app theme',
|
||||
args: [
|
||||
{
|
||||
name: 'theme',
|
||||
type: 'select',
|
||||
defaultValue: 'theme',
|
||||
options: Object.values(Themes).map((v) => ({ name: v })) as {
|
||||
name: string
|
||||
}[],
|
||||
},
|
||||
],
|
||||
},
|
||||
'Set Default Project Name': {
|
||||
displayValue: (args: string[]) => 'Set a new default project name',
|
||||
hide: 'web',
|
||||
args: [
|
||||
{
|
||||
name: 'defaultProjectName',
|
||||
type: 'string',
|
||||
description: '(default)',
|
||||
defaultValue: 'defaultProjectName',
|
||||
options: 'defaultProjectName',
|
||||
},
|
||||
],
|
||||
},
|
||||
'Set Default Directory': {
|
||||
hide: 'both',
|
||||
},
|
||||
'Set Unit System': {
|
||||
displayValue: (args: string[]) => 'Set your default unit system',
|
||||
args: [
|
||||
{
|
||||
name: 'unitSystem',
|
||||
type: 'select',
|
||||
defaultValue: 'unitSystem',
|
||||
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(
|
||||
{
|
||||
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */
|
||||
id: 'Settings',
|
||||
predictableActionArguments: true,
|
||||
context: {
|
||||
theme: Themes.System,
|
||||
defaultProjectName: '',
|
||||
unitSystem: UnitSystem.Imperial,
|
||||
baseUnit: 'in' as BaseUnit,
|
||||
defaultDirectory: '',
|
||||
showDebugPanel: false,
|
||||
onboardingStatus: '',
|
||||
},
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: {
|
||||
entry: ['setThemeClass'],
|
||||
on: {
|
||||
'Set Theme': {
|
||||
actions: [
|
||||
assign({
|
||||
theme: (_, event) => event.data.theme,
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
'setThemeClass',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Default Project Name': {
|
||||
actions: [
|
||||
assign({
|
||||
defaultProjectName: (_, event) => event.data.defaultProjectName,
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Default Directory': {
|
||||
actions: [
|
||||
assign({
|
||||
defaultDirectory: (_, event) => event.data.defaultDirectory,
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Unit System': {
|
||||
actions: [
|
||||
assign({
|
||||
unitSystem: (_, event) => event.data.unitSystem,
|
||||
baseUnit: (_, event) =>
|
||||
event.data.unitSystem === 'imperial' ? 'in' : 'mm',
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Base Unit': {
|
||||
actions: [
|
||||
assign({ baseUnit: (_, event) => event.data.baseUnit }),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Toggle Debug Panel': {
|
||||
actions: [
|
||||
assign({
|
||||
showDebugPanel: (context) => {
|
||||
return !context.showDebugPanel
|
||||
},
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
],
|
||||
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 Default Project Name'
|
||||
data: { defaultProjectName: string }
|
||||
}
|
||||
| { type: 'Set Default Directory'; data: { defaultDirectory: string } }
|
||||
| {
|
||||
type: 'Set Unit System'
|
||||
data: { unitSystem: UnitSystem }
|
||||
}
|
||||
| { type: 'Set Base Unit'; data: { baseUnit: BaseUnit } }
|
||||
| { type: 'Set Onboarding Status'; data: { onboardingStatus: string } }
|
||||
| { type: 'Toggle Debug Panel' },
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
persistSettings: (context) => {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_PERSIST_KEY, JSON.stringify(context))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
},
|
||||
setThemeClass: (context, event) => {
|
||||
const currentTheme =
|
||||
event.type === 'Set Theme' ? event.data.theme : context.theme
|
||||
setThemeClass(
|
||||
currentTheme === Themes.System ? getSystemTheme() : currentTheme
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
46
src/machines/settingsMachine.typegen.ts
Normal file
46
src/machines/settingsMachine.typegen.ts
Normal file
@ -0,0 +1,46 @@
|
||||
// This file was automatically generated. Edits will be overwritten
|
||||
|
||||
export interface Typegen0 {
|
||||
'@@xstate/typegen': true
|
||||
internalEvents: {
|
||||
'xstate.init': { type: 'xstate.init' }
|
||||
}
|
||||
invokeSrcNameMap: {}
|
||||
missingImplementations: {
|
||||
actions: 'toastSuccess'
|
||||
delays: never
|
||||
guards: never
|
||||
services: never
|
||||
}
|
||||
eventsCausingActions: {
|
||||
persistSettings:
|
||||
| 'Set Base Unit'
|
||||
| 'Set Default Directory'
|
||||
| 'Set Default Project Name'
|
||||
| 'Set Onboarding Status'
|
||||
| 'Set Theme'
|
||||
| 'Set Unit System'
|
||||
| 'Toggle Debug Panel'
|
||||
setThemeClass:
|
||||
| 'Set Base Unit'
|
||||
| 'Set Default Directory'
|
||||
| 'Set Default Project Name'
|
||||
| 'Set Onboarding Status'
|
||||
| 'Set Theme'
|
||||
| 'Set Unit System'
|
||||
| 'Toggle Debug Panel'
|
||||
| 'xstate.init'
|
||||
toastSuccess:
|
||||
| 'Set Base Unit'
|
||||
| 'Set Default Directory'
|
||||
| 'Set Default Project Name'
|
||||
| 'Set Theme'
|
||||
| 'Set Unit System'
|
||||
| 'Toggle Debug Panel'
|
||||
}
|
||||
eventsCausingDelays: {}
|
||||
eventsCausingGuards: {}
|
||||
eventsCausingServices: {}
|
||||
matchesStates: 'idle'
|
||||
tags: never
|
||||
}
|
@ -1,93 +1,139 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { readDir, removeDir, renameFile } from '@tauri-apps/api/fs'
|
||||
import { FormEvent, useEffect } from 'react'
|
||||
import { removeDir, renameFile } from '@tauri-apps/api/fs'
|
||||
import {
|
||||
createNewProject,
|
||||
getNextProjectIndex,
|
||||
interpolateProjectNameWithIndex,
|
||||
doesProjectNameNeedInterpolated,
|
||||
isProjectDirectory,
|
||||
PROJECT_ENTRYPOINT,
|
||||
getProjectsInDir,
|
||||
} from '../lib/tauriFS'
|
||||
import { ActionButton } from '../components/ActionButton'
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faCircleDot,
|
||||
faPlus,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { useStore } from '../useStore'
|
||||
import { faArrowDown, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { AppHeader } from '../components/AppHeader'
|
||||
import ProjectCard from '../components/ProjectCard'
|
||||
import { useLoaderData, useSearchParams } from 'react-router-dom'
|
||||
import { useLoaderData, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
|
||||
import Loading from '../components/Loading'
|
||||
import { metadata } from 'tauri-plugin-fs-extra-api'
|
||||
|
||||
const DESC = ':desc'
|
||||
import { useMachine } from '@xstate/react'
|
||||
import { homeCommandMeta, homeMachine } from '../machines/homeMachine'
|
||||
import { ContextFrom, EventFrom } from 'xstate'
|
||||
import { paths } from '../Router'
|
||||
import {
|
||||
getNextSearchParams,
|
||||
getSortFunction,
|
||||
getSortIcon,
|
||||
} from '../lib/sorting'
|
||||
import useStateMachineCommands from '../hooks/useStateMachineCommands'
|
||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
|
||||
// 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.
|
||||
const Home = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const sort = searchParams.get('sort_by') ?? 'modified:desc'
|
||||
const { commands, setCommandBarOpen } = useCommandsContext()
|
||||
const navigate = useNavigate()
|
||||
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [projects, setProjects] = useState(loadedProjects || [])
|
||||
const { defaultDir, defaultProjectName } = useStore((s) => ({
|
||||
defaultDir: s.defaultDir,
|
||||
defaultProjectName: s.defaultProjectName,
|
||||
}))
|
||||
const {
|
||||
settings: {
|
||||
context: { defaultDirectory, defaultProjectName },
|
||||
},
|
||||
} = useGlobalStateContext()
|
||||
|
||||
const modifiedSelected = sort?.includes('modified') || !sort || sort === null
|
||||
const [state, send] = useMachine(homeMachine, {
|
||||
context: {
|
||||
projects: loadedProjects,
|
||||
defaultProjectName,
|
||||
defaultDirectory,
|
||||
},
|
||||
actions: {
|
||||
navigateToProject: (
|
||||
context: ContextFrom<typeof homeMachine>,
|
||||
event: EventFrom<typeof homeMachine>
|
||||
) => {
|
||||
if (event.data && 'name' in event.data) {
|
||||
setCommandBarOpen(false)
|
||||
navigate(
|
||||
`${paths.FILE}/${encodeURIComponent(
|
||||
context.defaultDirectory + '/' + event.data.name
|
||||
)}`
|
||||
)
|
||||
}
|
||||
},
|
||||
toastSuccess: (_, event) => toast.success((event.data || '') + ''),
|
||||
toastError: (_, event) => toast.error((event.data || '') + ''),
|
||||
},
|
||||
services: {
|
||||
readProjects: async (context: ContextFrom<typeof homeMachine>) =>
|
||||
getProjectsInDir(context.defaultDirectory),
|
||||
createProject: async (
|
||||
context: ContextFrom<typeof homeMachine>,
|
||||
event: EventFrom<typeof homeMachine, 'Create project'>
|
||||
) => {
|
||||
let name =
|
||||
event.data && 'name' in event.data
|
||||
? event.data.name
|
||||
: defaultProjectName
|
||||
if (doesProjectNameNeedInterpolated(name)) {
|
||||
const nextIndex = await getNextProjectIndex(name, projects)
|
||||
name = interpolateProjectNameWithIndex(name, nextIndex)
|
||||
}
|
||||
|
||||
const refreshProjects = useCallback(
|
||||
async (projectDir = defaultDir) => {
|
||||
const readProjects = (
|
||||
await readDir(projectDir.dir, {
|
||||
await createNewProject(context.defaultDirectory + '/' + name)
|
||||
return `Successfully created "${name}"`
|
||||
},
|
||||
renameProject: async (
|
||||
context: ContextFrom<typeof homeMachine>,
|
||||
event: EventFrom<typeof homeMachine, 'Rename project'>
|
||||
) => {
|
||||
const { oldName, newName } = event.data
|
||||
let name = newName ? newName : context.defaultProjectName
|
||||
if (doesProjectNameNeedInterpolated(name)) {
|
||||
const nextIndex = await getNextProjectIndex(name, projects)
|
||||
name = interpolateProjectNameWithIndex(name, nextIndex)
|
||||
}
|
||||
|
||||
await renameFile(
|
||||
context.defaultDirectory + '/' + oldName,
|
||||
context.defaultDirectory + '/' + name
|
||||
)
|
||||
return `Successfully renamed "${oldName}" to "${name}"`
|
||||
},
|
||||
deleteProject: async (
|
||||
context: ContextFrom<typeof homeMachine>,
|
||||
event: EventFrom<typeof homeMachine, 'Delete project'>
|
||||
) => {
|
||||
await removeDir(context.defaultDirectory + '/' + event.data.name, {
|
||||
recursive: true,
|
||||
})
|
||||
).filter(isProjectDirectory)
|
||||
|
||||
const projectsWithMetadata = await Promise.all(
|
||||
readProjects.map(async (p) => ({
|
||||
entrypoint_metadata: await metadata(
|
||||
p.path + '/' + PROJECT_ENTRYPOINT
|
||||
),
|
||||
...p,
|
||||
}))
|
||||
)
|
||||
|
||||
setProjects(projectsWithMetadata)
|
||||
return `Successfully deleted "${event.data.name}"`
|
||||
},
|
||||
},
|
||||
[defaultDir, setProjects]
|
||||
)
|
||||
guards: {
|
||||
'Has at least 1 project': (_, event: EventFrom<typeof homeMachine>) => {
|
||||
if (event.type !== 'done.invoke.read-projects') return false
|
||||
return event?.data?.length ? event.data?.length >= 1 : false
|
||||
},
|
||||
},
|
||||
})
|
||||
const { projects } = state.context
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const sort = searchParams.get('sort_by') ?? 'modified:desc'
|
||||
|
||||
const isSortByModified = sort?.includes('modified') || !sort || sort === null
|
||||
|
||||
useStateMachineCommands<typeof homeMachine>({
|
||||
commands,
|
||||
send,
|
||||
state,
|
||||
commandBarMeta: homeCommandMeta,
|
||||
owner: 'home',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
refreshProjects(defaultDir).then(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [setIsLoading, refreshProjects, defaultDir])
|
||||
|
||||
async function handleNewProject() {
|
||||
let projectName = defaultProjectName
|
||||
if (doesProjectNameNeedInterpolated(projectName)) {
|
||||
const nextIndex = await getNextProjectIndex(defaultProjectName, projects)
|
||||
projectName = interpolateProjectNameWithIndex(
|
||||
defaultProjectName,
|
||||
nextIndex
|
||||
)
|
||||
}
|
||||
|
||||
await createNewProject(defaultDir.dir + '/' + projectName).catch((err) => {
|
||||
console.error('Error creating project:', err)
|
||||
toast.error('Error creating project')
|
||||
})
|
||||
|
||||
await refreshProjects()
|
||||
toast.success('Project created')
|
||||
}
|
||||
send({ type: 'assign', data: { defaultProjectName, defaultDirectory } })
|
||||
}, [defaultDirectory, defaultProjectName, send])
|
||||
|
||||
async function handleRenameProject(
|
||||
e: FormEvent<HTMLFormElement>,
|
||||
@ -96,85 +142,14 @@ const Home = () => {
|
||||
const { newProjectName } = Object.fromEntries(
|
||||
new FormData(e.target as HTMLFormElement)
|
||||
)
|
||||
if (newProjectName && project.name && newProjectName !== project.name) {
|
||||
const dir = project.path?.slice(0, project.path?.lastIndexOf('/'))
|
||||
await renameFile(project.path, dir + '/' + newProjectName).catch(
|
||||
(err) => {
|
||||
console.error('Error renaming project:', err)
|
||||
toast.error('Error renaming project')
|
||||
}
|
||||
)
|
||||
|
||||
await refreshProjects()
|
||||
toast.success('Project renamed')
|
||||
}
|
||||
send('Rename project', {
|
||||
data: { oldName: project.name, newName: newProjectName },
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDeleteProject(project: ProjectWithEntryPointMetadata) {
|
||||
if (project.path) {
|
||||
await removeDir(project.path, { recursive: true }).catch((err) => {
|
||||
console.error('Error deleting project:', err)
|
||||
toast.error('Error deleting project')
|
||||
})
|
||||
|
||||
await refreshProjects()
|
||||
toast.success('Project deleted')
|
||||
}
|
||||
}
|
||||
|
||||
function getSortIcon(sortBy: string) {
|
||||
if (sort === sortBy) {
|
||||
return faArrowUp
|
||||
} else if (sort === sortBy + DESC) {
|
||||
return faArrowDown
|
||||
}
|
||||
return faCircleDot
|
||||
}
|
||||
|
||||
function getNextSearchParams(sortBy: string) {
|
||||
if (sort === null || !sort)
|
||||
return { sort_by: sortBy + (sortBy !== 'modified' ? DESC : '') }
|
||||
if (sort.includes(sortBy) && !sort.includes(DESC)) return { sort_by: '' }
|
||||
return {
|
||||
sort_by: sortBy + (sort.includes(DESC) ? '' : DESC),
|
||||
}
|
||||
}
|
||||
|
||||
function getSortFunction(sortBy: string) {
|
||||
const sortByName = (
|
||||
a: ProjectWithEntryPointMetadata,
|
||||
b: ProjectWithEntryPointMetadata
|
||||
) => {
|
||||
if (a.name && b.name) {
|
||||
return sortBy.includes('desc')
|
||||
? a.name.localeCompare(b.name)
|
||||
: b.name.localeCompare(a.name)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const sortByModified = (
|
||||
a: ProjectWithEntryPointMetadata,
|
||||
b: ProjectWithEntryPointMetadata
|
||||
) => {
|
||||
if (
|
||||
a.entrypoint_metadata?.modifiedAt &&
|
||||
b.entrypoint_metadata?.modifiedAt
|
||||
) {
|
||||
return !sortBy || sortBy.includes('desc')
|
||||
? b.entrypoint_metadata.modifiedAt.getTime() -
|
||||
a.entrypoint_metadata.modifiedAt.getTime()
|
||||
: a.entrypoint_metadata.modifiedAt.getTime() -
|
||||
b.entrypoint_metadata.modifiedAt.getTime()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
if (sortBy?.includes('name')) {
|
||||
return sortByName
|
||||
} else {
|
||||
return sortByModified
|
||||
}
|
||||
send('Delete project', { data: { name: project.name || '' } })
|
||||
}
|
||||
|
||||
return (
|
||||
@ -191,9 +166,9 @@ const Home = () => {
|
||||
? 'text-chalkboard-80 dark:text-chalkboard-40'
|
||||
: ''
|
||||
}
|
||||
onClick={() => setSearchParams(getNextSearchParams('name'))}
|
||||
onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))}
|
||||
icon={{
|
||||
icon: getSortIcon('name'),
|
||||
icon: getSortIcon(sort, 'name'),
|
||||
bgClassName: !sort?.includes('name')
|
||||
? 'bg-liquid-50 dark:bg-liquid-70'
|
||||
: '',
|
||||
@ -207,17 +182,19 @@ const Home = () => {
|
||||
<ActionButton
|
||||
Element="button"
|
||||
className={
|
||||
!modifiedSelected
|
||||
!isSortByModified
|
||||
? 'text-chalkboard-80 dark:text-chalkboard-40'
|
||||
: ''
|
||||
}
|
||||
onClick={() => setSearchParams(getNextSearchParams('modified'))}
|
||||
onClick={() =>
|
||||
setSearchParams(getNextSearchParams(sort, 'modified'))
|
||||
}
|
||||
icon={{
|
||||
icon: sort ? getSortIcon('modified') : faArrowDown,
|
||||
bgClassName: !modifiedSelected
|
||||
icon: sort ? getSortIcon(sort, 'modified') : faArrowDown,
|
||||
bgClassName: !isSortByModified
|
||||
? 'bg-liquid-50 dark:bg-liquid-70'
|
||||
: '',
|
||||
iconClassName: !modifiedSelected
|
||||
iconClassName: !isSortByModified
|
||||
? 'text-liquid-80 dark:text-liquid-30'
|
||||
: '',
|
||||
}}
|
||||
@ -230,11 +207,11 @@ const Home = () => {
|
||||
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
|
||||
Are being saved at{' '}
|
||||
<code className="text-liquid-80 dark:text-liquid-30">
|
||||
{defaultDir.dir}
|
||||
{defaultDirectory}
|
||||
</code>
|
||||
, which you can change in your <Link to="settings">Settings</Link>.
|
||||
</p>
|
||||
{isLoading ? (
|
||||
{state.matches('Reading projects') ? (
|
||||
<Loading>Loading your Projects...</Loading>
|
||||
) : (
|
||||
<>
|
||||
@ -256,7 +233,7 @@ const Home = () => {
|
||||
)}
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={handleNewProject}
|
||||
onClick={() => send('Create project')}
|
||||
icon={{ icon: faPlus }}
|
||||
>
|
||||
New file
|
||||
|
@ -20,9 +20,25 @@ export default function Units() {
|
||||
>
|
||||
<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,34 +1,21 @@
|
||||
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { baseUnits, useStore } from '../../useStore'
|
||||
import { BaseUnit, baseUnits } from '../../useStore'
|
||||
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()
|
||||
const next = useNextClick(onboardingPaths.CAMERA)
|
||||
const {
|
||||
defaultUnitSystem: ogDefaultUnitSystem,
|
||||
setDefaultUnitSystem: saveDefaultUnitSystem,
|
||||
defaultBaseUnit: ogDefaultBaseUnit,
|
||||
setDefaultBaseUnit: saveDefaultBaseUnit,
|
||||
} = useStore((s) => ({
|
||||
defaultUnitSystem: s.defaultUnitSystem,
|
||||
setDefaultUnitSystem: s.setDefaultUnitSystem,
|
||||
defaultBaseUnit: s.defaultBaseUnit,
|
||||
setDefaultBaseUnit: s.setDefaultBaseUnit,
|
||||
}))
|
||||
const [defaultUnitSystem, setDefaultUnitSystem] =
|
||||
useState(ogDefaultUnitSystem)
|
||||
const [defaultBaseUnit, setDefaultBaseUnit] = useState(ogDefaultBaseUnit)
|
||||
|
||||
function handleNextClick() {
|
||||
saveDefaultUnitSystem(defaultUnitSystem)
|
||||
saveDefaultBaseUnit(defaultBaseUnit)
|
||||
next()
|
||||
}
|
||||
settings: {
|
||||
send,
|
||||
context: { unitSystem, baseUnit },
|
||||
},
|
||||
} = useGlobalStateContext()
|
||||
|
||||
return (
|
||||
<div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50">
|
||||
@ -42,10 +29,16 @@ export default function Units() {
|
||||
offLabel="Imperial"
|
||||
onLabel="Metric"
|
||||
name="settings-units"
|
||||
checked={defaultUnitSystem === 'metric'}
|
||||
onChange={(e) =>
|
||||
setDefaultUnitSystem(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
|
||||
@ -55,10 +48,15 @@ export default function Units() {
|
||||
<select
|
||||
id="base-unit"
|
||||
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
|
||||
value={defaultBaseUnit}
|
||||
onChange={(e) => setDefaultBaseUnit(e.target.value)}
|
||||
value={baseUnit}
|
||||
onChange={(e) => {
|
||||
send({
|
||||
type: 'Set Base Unit',
|
||||
data: { baseUnit: e.target.value as BaseUnit },
|
||||
})
|
||||
}}
|
||||
>
|
||||
{baseUnits[defaultUnitSystem].map((unit) => (
|
||||
{baseUnits[unitSystem].map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{unit}
|
||||
</option>
|
||||
@ -81,7 +79,7 @@ export default function Units() {
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={handleNextClick}
|
||||
onClick={next}
|
||||
icon={{ icon: faArrowRight }}
|
||||
>
|
||||
Next: Camera
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user