Compare commits
68 Commits
Author | SHA1 | Date | |
---|---|---|---|
9e2a94fcd9 | |||
8a3e8d331d | |||
1be9b2612c | |||
7c9aaeafa2 | |||
46c0078885 | |||
87ebf3b1d6 | |||
45238f8196 | |||
44f3a12fbe | |||
61acada2a0 | |||
c68fbbd89d | |||
97a0b6a543 | |||
3bccae492d | |||
0120a89d9c | |||
3da6fc3b7e | |||
34dd15ead7 | |||
b3d441e9d6 | |||
4b3dc3756c | |||
10027b98b5 | |||
da17dad63b | |||
fba6c422a8 | |||
0b4b93932d | |||
f42900ec46 | |||
eeca624ba6 | |||
84d08bad16 | |||
1181f33e9d | |||
797e200d08 | |||
d2f231066b | |||
86d40c964f | |||
2604449239 | |||
e992a96d3b | |||
22c4406105 | |||
ad3f0fda6a | |||
cccedceea0 | |||
ed68a34560 | |||
00ee913e3f | |||
46cc67e2db | |||
ff1be34f54 | |||
848bf61277 | |||
043333d3bc | |||
19d90b8081 | |||
4837c52908 | |||
afcf820bdd | |||
18959510f8 | |||
798cbe968a | |||
9cbc088ba3 | |||
2693a5609b | |||
3507da7b39 | |||
56cfb6d1f0 | |||
2b974ef1de | |||
253f1992fd | |||
76d3794b45 | |||
e52c8c9db6 | |||
eb48d51309 | |||
f3274e03ff | |||
46937199a3 | |||
e2a4798c2f | |||
659e6d5b45 | |||
1fbd0ad675 | |||
743ea1af4d | |||
2b1a556b81 | |||
853389ba22 | |||
023af60781 | |||
18db6f2dc1 | |||
4afec15323 | |||
152108f7a5 | |||
32d928ae0c | |||
6f0fae625f | |||
9bc47cf14a |
@ -1,4 +1,6 @@
|
|||||||
VITE_KC_API_WS_MODELING_URL=wss://api.dev.kittycad.io/ws/modeling/commands
|
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_API_BASE_URL=https://api.dev.kittycad.io
|
||||||
VITE_KC_SITE_BASE_URL=https://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_TIMEOUT_MS=5000
|
||||||
|
VITE_KC_SENTRY_DSN=
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
VITE_KC_API_WS_MODELING_URL=wss://api.kittycad.io/ws/modeling/commands
|
VITE_KC_API_WS_MODELING_URL=wss://api.kittycad.io/ws/modeling/commands
|
||||||
VITE_KC_API_BASE_URL=https://api.kittycad.io
|
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_TIMEOUT_MS=15000
|
||||||
|
VITE_KC_SENTRY_DSN=https://a814f2f66734989a90367f48feee28ca@o1042111.ingest.sentry.io/4505789425844224
|
||||||
|
@ -1 +1 @@
|
|||||||
src/wasm-lib/pkg/wasm_lib.js
|
src/wasm-lib/*
|
||||||
|
2
.github/workflows/cargo-build.yml
vendored
2
.github/workflows/cargo-build.yml
vendored
@ -43,7 +43,5 @@ jobs:
|
|||||||
- name: Run cargo build
|
- name: Run cargo build
|
||||||
run: |
|
run: |
|
||||||
cd "${{ matrix.dir }}"
|
cd "${{ matrix.dir }}"
|
||||||
cargo build --all --no-default-features --features noweb
|
|
||||||
cargo build --all --no-default-features --features web
|
|
||||||
cargo build --all
|
cargo build --all
|
||||||
shell: bash
|
shell: bash
|
||||||
|
5
.github/workflows/cargo-test.yml
vendored
5
.github/workflows/cargo-test.yml
vendored
@ -45,4 +45,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |-
|
run: |-
|
||||||
cd "${{ matrix.dir }}"
|
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}}
|
||||||
|
|
||||||
|
74
.github/workflows/ci.yml
vendored
74
.github/workflows/ci.yml
vendored
@ -1,4 +1,4 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@ -13,17 +13,31 @@ jobs:
|
|||||||
check-format:
|
check-format:
|
||||||
runs-on: 'ubuntu-20.04'
|
runs-on: 'ubuntu-20.04'
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
|
cache: 'yarn'
|
||||||
- run: yarn install
|
- run: yarn install
|
||||||
|
|
||||||
- run: yarn fmt-check
|
- run: yarn fmt-check
|
||||||
|
|
||||||
|
check-types:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
cache: 'yarn'
|
||||||
|
- run: yarn install
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: "./src/wasm-lib"
|
||||||
|
|
||||||
|
- run: yarn build:wasm
|
||||||
|
- run: yarn tsc
|
||||||
|
|
||||||
|
|
||||||
build-test-web:
|
build-test-web:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
@ -36,12 +50,15 @@ jobs:
|
|||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
- run: yarn install
|
- run: yarn install
|
||||||
|
|
||||||
- run: yarn build:wasm
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: "./src/wasm-lib"
|
||||||
|
|
||||||
- run: yarn tsc
|
- run: yarn build:wasm
|
||||||
|
|
||||||
- run: yarn simpleserver:ci
|
- run: yarn simpleserver:ci
|
||||||
|
|
||||||
@ -49,14 +66,12 @@ jobs:
|
|||||||
|
|
||||||
- run: yarn test:cov
|
- run: yarn test:cov
|
||||||
|
|
||||||
- run: yarn test:rust
|
|
||||||
|
|
||||||
- id: export_version
|
- id: export_version
|
||||||
run: echo "version=`cat package.json | jq -r '.version'`" >> "$GITHUB_OUTPUT"
|
run: echo "version=`cat package.json | jq -r '.version'`" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
|
||||||
build-apps:
|
build-apps:
|
||||||
needs: [check-format, build-test-web]
|
needs: [check-format, build-test-web, check-types]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@ -87,6 +102,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
workspaces: './src-tauri -> target'
|
workspaces: './src-tauri -> target'
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: "./src/wasm-lib"
|
||||||
|
|
||||||
- name: wasm prep
|
- name: wasm prep
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@ -110,15 +129,22 @@ jobs:
|
|||||||
- name: Fix format
|
- name: Fix format
|
||||||
run: yarn fmt
|
run: yarn fmt
|
||||||
|
|
||||||
|
- name: install apple silicon target mac
|
||||||
|
if: matrix.os == 'macos-latest'
|
||||||
|
run: |
|
||||||
|
rustup target add aarch64-apple-darwin
|
||||||
|
|
||||||
- name: Build the app for the current platform (no upload)
|
- name: Build the app for the current platform (no upload)
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
|
with:
|
||||||
|
args: ${{ matrix.os == 'macos-latest' && '--target universal-apple-darwin' || '' }}
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: src-tauri/target/release/bundle/*/*
|
path: ${{ matrix.os == 'macos-latest' && 'src-tauri/target/universal-apple-darwin/release/bundle/*/*' || 'src-tauri/target/release/bundle/*/*' }}
|
||||||
|
|
||||||
|
|
||||||
publish-apps-release:
|
publish-apps-release:
|
||||||
@ -133,8 +159,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate the update static endpoint
|
- name: Generate the update static endpoint
|
||||||
run: |
|
run: |
|
||||||
ls -l artifact
|
ls -l artifact/*/*itty*
|
||||||
ls -l artifact/*
|
|
||||||
DARWIN_SIG=`cat artifact/macos/*.app.tar.gz.sig`
|
DARWIN_SIG=`cat artifact/macos/*.app.tar.gz.sig`
|
||||||
LINUX_SIG=`cat artifact/appimage/*.AppImage.tar.gz.sig`
|
LINUX_SIG=`cat artifact/appimage/*.AppImage.tar.gz.sig`
|
||||||
WINDOWS_SIG=`cat artifact/nsis/*.nsis.zip.sig`
|
WINDOWS_SIG=`cat artifact/nsis/*.nsis.zip.sig`
|
||||||
@ -142,11 +167,11 @@ jobs:
|
|||||||
jq --null-input \
|
jq --null-input \
|
||||||
--arg version "v${VERSION_NO_V}" \
|
--arg version "v${VERSION_NO_V}" \
|
||||||
--arg darwin_sig "$DARWIN_SIG" \
|
--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_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_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,
|
"version": $version,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
@ -154,6 +179,10 @@ jobs:
|
|||||||
"signature": $darwin_sig,
|
"signature": $darwin_sig,
|
||||||
"url": $darwin_url
|
"url": $darwin_url
|
||||||
},
|
},
|
||||||
|
"darwin-aarch64": {
|
||||||
|
"signature": $darwin_sig,
|
||||||
|
"url": $darwin_url
|
||||||
|
},
|
||||||
"linux-x86_64": {
|
"linux-x86_64": {
|
||||||
"signature": $linux_sig,
|
"signature": $linux_sig,
|
||||||
"url": $linux_url
|
"url": $linux_url
|
||||||
@ -175,17 +204,22 @@ jobs:
|
|||||||
uses: google-github-actions/setup-gcloud@v1.1.1
|
uses: google-github-actions/setup-gcloud@v1.1.1
|
||||||
with:
|
with:
|
||||||
project_id: kittycadapi
|
project_id: kittycadapi
|
||||||
|
|
||||||
- name: Upload release files to public bucket
|
- name: Upload release files to public bucket
|
||||||
uses: google-github-actions/upload-cloud-storage@v1.0.3
|
uses: google-github-actions/upload-cloud-storage@v1.0.3
|
||||||
with:
|
with:
|
||||||
path: artifact
|
path: artifact
|
||||||
glob: '*/kittycad-modeling-app*'
|
glob: '*/*itty*'
|
||||||
parent: false
|
parent: false
|
||||||
destination: dl.kittycad.io/releases/modeling-app/v${{ env.VERSION_NO_V }}
|
destination: dl.kittycad.io/releases/modeling-app/v${{ env.VERSION_NO_V }}
|
||||||
|
|
||||||
- name: Upload update endpoint to public bucket
|
- name: Upload update endpoint to public bucket
|
||||||
uses: google-github-actions/upload-cloud-storage@v1.0.3
|
uses: google-github-actions/upload-cloud-storage@v1.0.3
|
||||||
with:
|
with:
|
||||||
path: last_update.json
|
path: last_update.json
|
||||||
destination: dl.kittycad.io/releases/modeling-app
|
destination: dl.kittycad.io/releases/modeling-app
|
||||||
|
|
||||||
|
- name: Upload release files to Github
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: artifact/*/*itty*
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -25,5 +25,6 @@ yarn-error.log*
|
|||||||
# rust
|
# rust
|
||||||
src/wasm-lib/target
|
src/wasm-lib/target
|
||||||
src/wasm-lib/bindings
|
src/wasm-lib/bindings
|
||||||
|
src/wasm-lib/kcl/bindings
|
||||||
public/wasm_lib_bg.wasm
|
public/wasm_lib_bg.wasm
|
||||||
src/wasm-lib/lcov.info
|
src/wasm-lib/lcov.info
|
||||||
|
@ -5,3 +5,5 @@ coverage
|
|||||||
# Ignore Rust projects:
|
# Ignore Rust projects:
|
||||||
*.rs
|
*.rs
|
||||||
target
|
target
|
||||||
|
src/wasm-lib/pkg
|
||||||
|
src/wasm-lib/kcl/bindings
|
||||||
|
21
README.md
21
README.md
@ -86,3 +86,24 @@ The PR may serve as a place to discuss the human-readable changelog and extra QA
|
|||||||
3. Create a new release and tag pointing to the bump version commit using semantic versioning `v{x}.{y}.{z}`
|
3. Create a new release and tag pointing to the bump version commit using semantic versioning `v{x}.{y}.{z}`
|
||||||
|
|
||||||
4. A new Action kicks in at https://github.com/KittyCAD/modeling-app/actions, uploading artifacts to the release
|
4. A new Action kicks in at https://github.com/KittyCAD/modeling-app/actions, uploading artifacts to the release
|
||||||
|
|
||||||
|
## Fuzzing the parser
|
||||||
|
|
||||||
|
Make sure you install cargo fuzz:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cargo install cargo-fuzz
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cd src/wasm-lib/kcl
|
||||||
|
|
||||||
|
# list the fuzz targets
|
||||||
|
$ cargo fuzz list
|
||||||
|
|
||||||
|
# run the parser fuzzer
|
||||||
|
$ cargo +nightly fuzz run parser
|
||||||
|
```
|
||||||
|
|
||||||
|
For more information on fuzzing you can check out
|
||||||
|
[this guide](https://rust-fuzz.github.io/book/cargo-fuzz.html).
|
||||||
|
24593
docs/kcl.json
24593
docs/kcl.json
File diff suppressed because it is too large
Load Diff
5449
docs/kcl.md
5449
docs/kcl.md
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@ -1,28 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "untitled-app",
|
"name": "untitled-app",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.9.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
"@headlessui/react": "^1.7.13",
|
"@headlessui/react": "^1.7.13",
|
||||||
"@kittycad/lib": "^0.0.34",
|
"@headlessui/tailwindcss": "^0.2.0",
|
||||||
|
"@kittycad/lib": "^0.0.37",
|
||||||
|
"@lezer/javascript": "^1.4.7",
|
||||||
|
"@open-rpc/client-js": "^1.8.1",
|
||||||
"@react-hook/resize-observer": "^1.2.6",
|
"@react-hook/resize-observer": "^1.2.6",
|
||||||
|
"@sentry/react": "^7.65.0",
|
||||||
"@tauri-apps/api": "^1.3.0",
|
"@tauri-apps/api": "^1.3.0",
|
||||||
"@testing-library/jest-dom": "^5.14.1",
|
"@testing-library/jest-dom": "^5.14.1",
|
||||||
"@testing-library/react": "^13.0.0",
|
"@testing-library/react": "^13.0.0",
|
||||||
"@testing-library/user-event": "^13.2.1",
|
"@testing-library/user-event": "^13.2.1",
|
||||||
|
"@ts-stack/markdown": "^1.5.0",
|
||||||
"@types/node": "^16.7.13",
|
"@types/node": "^16.7.13",
|
||||||
"@types/react": "^18.0.0",
|
"@types/react": "^18.0.0",
|
||||||
"@types/react-dom": "^18.0.0",
|
"@types/react-dom": "^18.0.0",
|
||||||
"@uiw/codemirror-extensions-langs": "^4.21.9",
|
"@uiw/react-codemirror": "^4.21.13",
|
||||||
"@uiw/react-codemirror": "^4.15.1",
|
|
||||||
"@xstate/react": "^3.2.2",
|
"@xstate/react": "^3.2.2",
|
||||||
"crypto-js": "^4.1.1",
|
"crypto-js": "^4.1.1",
|
||||||
"formik": "^2.4.3",
|
"formik": "^2.4.3",
|
||||||
|
"fuse.js": "^6.6.2",
|
||||||
"http-server": "^14.1.1",
|
"http-server": "^14.1.1",
|
||||||
|
"json-rpc-2.0": "^1.6.0",
|
||||||
"re-resizable": "^6.9.9",
|
"re-resizable": "^6.9.9",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@ -40,6 +47,8 @@
|
|||||||
"typescript": "^4.4.2",
|
"typescript": "^4.4.2",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"vitest": "^0.34.1",
|
"vitest": "^0.34.1",
|
||||||
|
"vscode-jsonrpc": "^8.1.0",
|
||||||
|
"vscode-languageserver-protocol": "^3.17.3",
|
||||||
"wasm-pack": "^0.12.1",
|
"wasm-pack": "^0.12.1",
|
||||||
"web-vitals": "^2.1.0",
|
"web-vitals": "^2.1.0",
|
||||||
"ws": "^8.13.0",
|
"ws": "^8.13.0",
|
||||||
@ -54,15 +63,15 @@
|
|||||||
"build:both:local": "yarn build:wasm && vite build",
|
"build:both:local": "yarn build:wasm && vite build",
|
||||||
"test": "vitest --mode development",
|
"test": "vitest --mode development",
|
||||||
"test:nowatch": "vitest run --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",
|
"test:cov": "vitest run --coverage --mode development",
|
||||||
"simpleserver:ci": "http-server ./public --cors -p 3000 &",
|
"simpleserver:ci": "http-server ./public --cors -p 3000 &",
|
||||||
"simpleserver": "http-server ./public --cors -p 3000",
|
"simpleserver": "http-server ./public --cors -p 3000",
|
||||||
"fmt": "prettier --write ./src",
|
"fmt": "prettier --write ./src",
|
||||||
"fmt-check": "prettier --check ./src",
|
"fmt-check": "prettier --check ./src",
|
||||||
"build:wasm": "yarn wasm-prep && (cd src/wasm-lib && wasm-pack build --target web --out-dir pkg --no-default-features --features web && 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\"",
|
"remove-importmeta": "sed -i 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\"; sed -i '' 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\" || echo \"sed for both mac and linux\"",
|
||||||
"wasm-prep": "rm -rf src/wasm-lib/pkg && mkdir src/wasm-lib/pkg && rm -rf src/wasm-lib/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",
|
"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"
|
"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",
|
"@babel/preset-env": "^7.22.9",
|
||||||
"@tauri-apps/cli": "^1.3.1",
|
"@tauri-apps/cli": "^1.3.1",
|
||||||
"@types/crypto-js": "^4.1.1",
|
"@types/crypto-js": "^4.1.1",
|
||||||
|
"@types/debounce": "^1.2.1",
|
||||||
"@types/isomorphic-fetch": "^0.0.36",
|
"@types/isomorphic-fetch": "^0.0.36",
|
||||||
"@types/react-modal": "^3.16.0",
|
"@types/react-modal": "^3.16.0",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
|
77
src-tauri/Cargo.lock
generated
77
src-tauri/Cargo.lock
generated
@ -648,6 +648,12 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@ -1150,7 +1156,7 @@ dependencies = [
|
|||||||
"futures-sink",
|
"futures-sink",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"indexmap",
|
"indexmap 1.9.3",
|
||||||
"slab",
|
"slab",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
@ -1163,6 +1169,12 @@ version = "0.12.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@ -1378,7 +1390,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"autocfg",
|
"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",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1628,6 +1651,12 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minisign-verify"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "933dca44d65cdd53b355d0b73d380a2ff5da71f87f036053188bf1eab6a19881"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@ -2122,7 +2151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9bd9647b268a3d3e14ff09c23201133a62589c658db02bb7388c7246aafe0590"
|
checksum = "9bd9647b268a3d3e14ff09c23201133a62589c658db02bb7388c7246aafe0590"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.21.2",
|
"base64 0.21.2",
|
||||||
"indexmap",
|
"indexmap 1.9.3",
|
||||||
"line-wrap",
|
"line-wrap",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"serde",
|
"serde",
|
||||||
@ -2695,14 +2724,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "2.3.3"
|
version = "3.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "07ff71d2c147a7b57362cead5e22f772cd52f6ab31cfcd9edcd7f6aeb2a0afbe"
|
checksum = "1402f54f9a3b9e2efe71c1cea24e648acce55887983553eeb858cf3115acfd49"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.13.1",
|
"base64 0.21.2",
|
||||||
"chrono",
|
"chrono",
|
||||||
"hex",
|
"hex",
|
||||||
"indexmap",
|
"indexmap 1.9.3",
|
||||||
|
"indexmap 2.0.0",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with_macros",
|
"serde_with_macros",
|
||||||
@ -2711,9 +2741,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with_macros"
|
name = "serde_with_macros"
|
||||||
version = "2.3.3"
|
version = "3.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "881b6f881b17d13214e5d494c939ebab463d01264ce1811e9d4ac3a882e7695f"
|
checksum = "9197f1ad0e3c173a0222d3c4404fb04c3afe87e962bcb327af73e8301fa203c7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@ -3022,6 +3052,7 @@ checksum = "d42ba3a2e8556722f31336a0750c10dbb6a81396a1c452977f515da83f69f842"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"attohttpc",
|
"attohttpc",
|
||||||
|
"base64 0.21.2",
|
||||||
"cocoa",
|
"cocoa",
|
||||||
"dirs-next",
|
"dirs-next",
|
||||||
"embed_plist",
|
"embed_plist",
|
||||||
@ -3034,6 +3065,7 @@ dependencies = [
|
|||||||
"heck 0.4.1",
|
"heck 0.4.1",
|
||||||
"http",
|
"http",
|
||||||
"ignore",
|
"ignore",
|
||||||
|
"minisign-verify",
|
||||||
"objc",
|
"objc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"open",
|
"open",
|
||||||
@ -3055,19 +3087,21 @@ dependencies = [
|
|||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows 0.39.0",
|
"windows 0.39.0",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-build"
|
name = "tauri-build"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "929b3bd1248afc07b63e33a6a53c3f82c32d0b0a5e216e4530e94c467e019389"
|
checksum = "7d2edd6a259b5591c8efdeb9d5702cb53515b82a6affebd55c7fd6d3a27b7d1b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"cargo_toml",
|
"cargo_toml",
|
||||||
@ -3078,7 +3112,6 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"tauri-winres",
|
"tauri-winres",
|
||||||
"winnow",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -3176,12 +3209,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-utils"
|
name = "tauri-utils"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5a6f9c2dafef5cbcf52926af57ce9561bd33bb41d7394f8bb849c0330260d864"
|
checksum = "03fc02bb6072bb397e1d473c6f76c953cda48b4a2d0cce605df284aa74a12e84"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"brotli",
|
"brotli",
|
||||||
"ctor",
|
"ctor",
|
||||||
|
"dunce",
|
||||||
"glob",
|
"glob",
|
||||||
"heck 0.4.1",
|
"heck 0.4.1",
|
||||||
"html5ever",
|
"html5ever",
|
||||||
@ -3397,7 +3431,7 @@ version = "0.18.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "56c59d8dd7d0dcbc6428bf7aa2f0e823e26e43b3c9aca15bbc9475d23e5fa12b"
|
checksum = "56c59d8dd7d0dcbc6428bf7aa2f0e823e26e43b3c9aca15bbc9475d23e5fa12b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap 1.9.3",
|
||||||
"nom8",
|
"nom8",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned",
|
"serde_spanned",
|
||||||
@ -3410,7 +3444,7 @@ version = "0.19.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13"
|
checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap 1.9.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned",
|
"serde_spanned",
|
||||||
"toml_datetime 0.6.2",
|
"toml_datetime 0.6.2",
|
||||||
@ -4228,3 +4262,14 @@ checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"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
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "1.3.0", features = [] }
|
tauri-build = { version = "1.4.0", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
oauth2 = "4.4.1"
|
oauth2 = "4.4.1"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
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"] }
|
tokio = { version = "1.29.1", features = ["time"] }
|
||||||
toml = "0.6.0"
|
toml = "0.6.0"
|
||||||
tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||||
|
@ -7,8 +7,8 @@
|
|||||||
"distDir": "../build"
|
"distDir": "../build"
|
||||||
},
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"productName": "kittycad-modeling-app",
|
"productName": "kittycad-modeling",
|
||||||
"version": "0.1.0"
|
"version": "0.5.0"
|
||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": {
|
"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 { App } from './App'
|
||||||
import { describe, test, vi } from 'vitest'
|
import { describe, test, vi } from 'vitest'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
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
|
let listener: ((rect: any) => void) | undefined = undefined
|
||||||
;(global as any).ResizeObserver = class ResizeObserver {
|
;(global as any).ResizeObserver = class ResizeObserver {
|
||||||
@ -43,7 +44,9 @@ function TestWrap({ children }: { children: React.ReactNode }) {
|
|||||||
// wrap in router and xState context
|
// wrap in router and xState context
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<GlobalStateProvider>{children}</GlobalStateProvider>
|
<CommandBarProvider>
|
||||||
|
<GlobalStateProvider>{children}</GlobalStateProvider>
|
||||||
|
</CommandBarProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
306
src/App.tsx
306
src/App.tsx
@ -2,7 +2,6 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
@ -10,15 +9,7 @@ import { DebugPanel } from './components/DebugPanel'
|
|||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { asyncParser } from './lang/abstractSyntaxTree'
|
import { asyncParser } from './lang/abstractSyntaxTree'
|
||||||
import { _executor } from './lang/executor'
|
import { _executor } from './lang/executor'
|
||||||
import CodeMirror from '@uiw/react-codemirror'
|
import { PaneType, useStore } from './useStore'
|
||||||
import { langs } from '@uiw/codemirror-extensions-langs'
|
|
||||||
import { linter, lintGutter } from '@codemirror/lint'
|
|
||||||
import { ViewUpdate } from '@codemirror/view'
|
|
||||||
import {
|
|
||||||
lineHighlightField,
|
|
||||||
addLineHighlight,
|
|
||||||
} from './editor/highlightextension'
|
|
||||||
import { PaneType, Selections, Themes, useStore } from './useStore'
|
|
||||||
import { Logs, KCLErrors } from './components/Logs'
|
import { Logs, KCLErrors } from './components/Logs'
|
||||||
import { CollapsiblePanel } from './components/CollapsiblePanel'
|
import { CollapsiblePanel } from './components/CollapsiblePanel'
|
||||||
import { MemoryPanel } from './components/MemoryPanel'
|
import { MemoryPanel } from './components/MemoryPanel'
|
||||||
@ -29,9 +20,9 @@ import {
|
|||||||
EngineCommand,
|
EngineCommand,
|
||||||
EngineCommandManager,
|
EngineCommandManager,
|
||||||
} from './lang/std/engineConnection'
|
} from './lang/std/engineConnection'
|
||||||
import { isOverlap, throttle } from './lib/utils'
|
import { throttle } from './lib/utils'
|
||||||
import { AppHeader } from './components/AppHeader'
|
import { AppHeader } from './components/AppHeader'
|
||||||
import { KCLError, kclErrToDiagnostic } from './lang/errors'
|
import { KCLError } from './lang/errors'
|
||||||
import { Resizable } from 're-resizable'
|
import { Resizable } from 're-resizable'
|
||||||
import {
|
import {
|
||||||
faCode,
|
faCode,
|
||||||
@ -39,101 +30,85 @@ import {
|
|||||||
faSquareRootVariable,
|
faSquareRootVariable,
|
||||||
} from '@fortawesome/free-solid-svg-icons'
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { TEST } from './env'
|
|
||||||
import { getNormalisedCoordinates } from './lib/utils'
|
import { getNormalisedCoordinates } from './lib/utils'
|
||||||
import { getSystemTheme } from './lib/getSystemTheme'
|
|
||||||
import { isTauri } from './lib/isTauri'
|
import { isTauri } from './lib/isTauri'
|
||||||
import { useLoaderData, useParams } from 'react-router-dom'
|
import { useLoaderData } from 'react-router-dom'
|
||||||
import { writeTextFile } from '@tauri-apps/api/fs'
|
|
||||||
import { PROJECT_ENTRYPOINT } from './lib/tauriFS'
|
|
||||||
import { IndexLoaderData } from './Router'
|
import { IndexLoaderData } from './Router'
|
||||||
import { toast } from 'react-hot-toast'
|
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||||
import { useAuthMachine } from './hooks/useAuthMachine'
|
import { onboardingPaths } from 'routes/Onboarding'
|
||||||
|
import { cameraMouseDragGuards } from 'lib/cameraControls'
|
||||||
|
import { CameraDragInteractionType_type } from '@kittycad/lib/dist/types/src/models'
|
||||||
|
import { CodeMenu } from 'components/CodeMenu'
|
||||||
|
import { TextEditor } from 'components/TextEditor'
|
||||||
|
import { Themes, getSystemTheme } from 'lib/theme'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
|
const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
|
||||||
const pathParams = useParams()
|
|
||||||
const streamRef = useRef<HTMLDivElement>(null)
|
const streamRef = useRef<HTMLDivElement>(null)
|
||||||
useHotKeyListener()
|
useHotKeyListener()
|
||||||
const {
|
const {
|
||||||
editorView,
|
|
||||||
setEditorView,
|
|
||||||
setSelectionRanges,
|
|
||||||
selectionRanges,
|
|
||||||
addLog,
|
addLog,
|
||||||
addKCLError,
|
addKCLError,
|
||||||
code,
|
|
||||||
setCode,
|
setCode,
|
||||||
setAst,
|
setAst,
|
||||||
setError,
|
setError,
|
||||||
setProgramMemory,
|
setProgramMemory,
|
||||||
resetLogs,
|
resetLogs,
|
||||||
resetKCLErrors,
|
resetKCLErrors,
|
||||||
selectionRangeTypeMap,
|
|
||||||
setArtifactMap,
|
setArtifactMap,
|
||||||
engineCommandManager,
|
engineCommandManager,
|
||||||
setEngineCommandManager,
|
setEngineCommandManager,
|
||||||
|
highlightRange,
|
||||||
setHighlightRange,
|
setHighlightRange,
|
||||||
setCursor2,
|
setCursor2,
|
||||||
sourceRangeMap,
|
|
||||||
setMediaStream,
|
setMediaStream,
|
||||||
setIsStreamReady,
|
setIsStreamReady,
|
||||||
isStreamReady,
|
isStreamReady,
|
||||||
isMouseDownInStream,
|
buttonDownInStream,
|
||||||
cmdId,
|
|
||||||
setCmdId,
|
|
||||||
formatCode,
|
|
||||||
debugPanel,
|
|
||||||
theme,
|
|
||||||
openPanes,
|
openPanes,
|
||||||
setOpenPanes,
|
setOpenPanes,
|
||||||
onboardingStatus,
|
|
||||||
didDragInStream,
|
didDragInStream,
|
||||||
setDidDragInStream,
|
|
||||||
setStreamDimensions,
|
setStreamDimensions,
|
||||||
streamDimensions,
|
streamDimensions,
|
||||||
|
setIsExecuting,
|
||||||
|
defferedCode,
|
||||||
} = useStore((s) => ({
|
} = useStore((s) => ({
|
||||||
editorView: s.editorView,
|
|
||||||
setEditorView: s.setEditorView,
|
|
||||||
setSelectionRanges: s.setSelectionRanges,
|
|
||||||
selectionRanges: s.selectionRanges,
|
|
||||||
setGuiMode: s.setGuiMode,
|
|
||||||
addLog: s.addLog,
|
addLog: s.addLog,
|
||||||
code: s.code,
|
defferedCode: s.defferedCode,
|
||||||
setCode: s.setCode,
|
setCode: s.setCode,
|
||||||
setAst: s.setAst,
|
setAst: s.setAst,
|
||||||
setError: s.setError,
|
setError: s.setError,
|
||||||
setProgramMemory: s.setProgramMemory,
|
setProgramMemory: s.setProgramMemory,
|
||||||
resetLogs: s.resetLogs,
|
resetLogs: s.resetLogs,
|
||||||
resetKCLErrors: s.resetKCLErrors,
|
resetKCLErrors: s.resetKCLErrors,
|
||||||
selectionRangeTypeMap: s.selectionRangeTypeMap,
|
|
||||||
setArtifactMap: s.setArtifactNSourceRangeMaps,
|
setArtifactMap: s.setArtifactNSourceRangeMaps,
|
||||||
engineCommandManager: s.engineCommandManager,
|
engineCommandManager: s.engineCommandManager,
|
||||||
setEngineCommandManager: s.setEngineCommandManager,
|
setEngineCommandManager: s.setEngineCommandManager,
|
||||||
|
highlightRange: s.highlightRange,
|
||||||
setHighlightRange: s.setHighlightRange,
|
setHighlightRange: s.setHighlightRange,
|
||||||
isShiftDown: s.isShiftDown,
|
|
||||||
setCursor: s.setCursor,
|
|
||||||
setCursor2: s.setCursor2,
|
setCursor2: s.setCursor2,
|
||||||
sourceRangeMap: s.sourceRangeMap,
|
|
||||||
setMediaStream: s.setMediaStream,
|
setMediaStream: s.setMediaStream,
|
||||||
isStreamReady: s.isStreamReady,
|
isStreamReady: s.isStreamReady,
|
||||||
setIsStreamReady: s.setIsStreamReady,
|
setIsStreamReady: s.setIsStreamReady,
|
||||||
isMouseDownInStream: s.isMouseDownInStream,
|
buttonDownInStream: s.buttonDownInStream,
|
||||||
cmdId: s.cmdId,
|
|
||||||
setCmdId: s.setCmdId,
|
|
||||||
formatCode: s.formatCode,
|
|
||||||
debugPanel: s.debugPanel,
|
|
||||||
addKCLError: s.addKCLError,
|
addKCLError: s.addKCLError,
|
||||||
theme: s.theme,
|
|
||||||
openPanes: s.openPanes,
|
openPanes: s.openPanes,
|
||||||
setOpenPanes: s.setOpenPanes,
|
setOpenPanes: s.setOpenPanes,
|
||||||
onboardingStatus: s.onboardingStatus,
|
|
||||||
didDragInStream: s.didDragInStream,
|
didDragInStream: s.didDragInStream,
|
||||||
setDidDragInStream: s.setDidDragInStream,
|
|
||||||
setStreamDimensions: s.setStreamDimensions,
|
setStreamDimensions: s.setStreamDimensions,
|
||||||
streamDimensions: s.streamDimensions,
|
streamDimensions: s.streamDimensions,
|
||||||
|
setIsExecuting: s.setIsExecuting,
|
||||||
}))
|
}))
|
||||||
const [token] = useAuthMachine((s) => s?.context?.token)
|
|
||||||
|
const {
|
||||||
|
auth: {
|
||||||
|
context: { token },
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
context: { showDebugPanel, onboardingStatus, cameraControls, theme },
|
||||||
|
},
|
||||||
|
} = useGlobalStateContext()
|
||||||
|
|
||||||
const editorTheme = theme === Themes.System ? getSystemTheme() : theme
|
const editorTheme = theme === Themes.System ? getSystemTheme() : theme
|
||||||
|
|
||||||
@ -152,7 +127,7 @@ export function App() {
|
|||||||
useHotkeys('shift + d', () => togglePane('debug'))
|
useHotkeys('shift + d', () => togglePane('debug'))
|
||||||
|
|
||||||
const paneOpacity =
|
const paneOpacity =
|
||||||
onboardingStatus === 'camera'
|
onboardingStatus === onboardingPaths.CAMERA
|
||||||
? 'opacity-20'
|
? 'opacity-20'
|
||||||
: didDragInStream
|
: didDragInStream
|
||||||
? 'opacity-40'
|
? 'opacity-40'
|
||||||
@ -172,87 +147,12 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [loadedCode, setCode])
|
}, [loadedCode, setCode])
|
||||||
|
|
||||||
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
|
|
||||||
const onChange = (value: string, viewUpdate: ViewUpdate) => {
|
|
||||||
setCode(value)
|
|
||||||
if (isTauri() && pathParams.id) {
|
|
||||||
// Save the file to disk
|
|
||||||
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
|
|
||||||
writeTextFile(pathParams.id + '/' + PROJECT_ENTRYPOINT, value).catch(
|
|
||||||
(err) => {
|
|
||||||
// TODO: add Sentry per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254)
|
|
||||||
console.error('error saving file', err)
|
|
||||||
toast.error('Error saving file, please check file permissions')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (editorView) {
|
|
||||||
editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
|
|
||||||
}
|
|
||||||
} //, []);
|
|
||||||
const onUpdate = (viewUpdate: ViewUpdate) => {
|
|
||||||
if (!editorView) {
|
|
||||||
setEditorView(viewUpdate.view)
|
|
||||||
}
|
|
||||||
const ranges = viewUpdate.state.selection.ranges
|
|
||||||
|
|
||||||
const isChange =
|
|
||||||
ranges.length !== selectionRanges.codeBasedSelections.length ||
|
|
||||||
ranges.some(({ from, to }, i) => {
|
|
||||||
return (
|
|
||||||
from !== selectionRanges.codeBasedSelections[i].range[0] ||
|
|
||||||
to !== selectionRanges.codeBasedSelections[i].range[1]
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isChange) return
|
|
||||||
const codeBasedSelections: Selections['codeBasedSelections'] = ranges.map(
|
|
||||||
({ from, to }) => {
|
|
||||||
if (selectionRangeTypeMap[to]) {
|
|
||||||
return {
|
|
||||||
type: selectionRangeTypeMap[to],
|
|
||||||
range: [from, to],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: 'default',
|
|
||||||
range: [from, to],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const idBasedSelections = codeBasedSelections
|
|
||||||
.map(({ type, range }) => {
|
|
||||||
const hasOverlap = Object.entries(sourceRangeMap).filter(
|
|
||||||
([_, sourceRange]) => {
|
|
||||||
return isOverlap(sourceRange, range)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (hasOverlap.length) {
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
id: hasOverlap[0][0],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean) as any
|
|
||||||
|
|
||||||
engineCommandManager?.cusorsSelected({
|
|
||||||
otherSelections: [],
|
|
||||||
idBasedSelections,
|
|
||||||
})
|
|
||||||
|
|
||||||
setSelectionRanges({
|
|
||||||
otherSelections: [],
|
|
||||||
codeBasedSelections,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const pixelDensity = window.devicePixelRatio
|
|
||||||
const streamWidth = streamRef?.current?.offsetWidth
|
const streamWidth = streamRef?.current?.offsetWidth
|
||||||
const streamHeight = streamRef?.current?.offsetHeight
|
const streamHeight = streamRef?.current?.offsetHeight
|
||||||
|
|
||||||
const width = streamWidth ? streamWidth * pixelDensity : 0
|
const width = streamWidth ? streamWidth : 0
|
||||||
const quadWidth = Math.round(width / 4) * 4
|
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
|
const quadHeight = Math.round(height / 4) * 4
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@ -276,21 +176,21 @@ export function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isStreamReady) return
|
if (!isStreamReady) return
|
||||||
|
if (!engineCommandManager) return
|
||||||
|
let unsubFn: any[] = []
|
||||||
const asyncWrap = async () => {
|
const asyncWrap = async () => {
|
||||||
try {
|
try {
|
||||||
if (!code) {
|
if (!defferedCode) {
|
||||||
setAst(null)
|
setAst(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const _ast = await asyncParser(code)
|
const _ast = await asyncParser(defferedCode)
|
||||||
setAst(_ast)
|
setAst(_ast)
|
||||||
resetLogs()
|
resetLogs()
|
||||||
resetKCLErrors()
|
resetKCLErrors()
|
||||||
if (engineCommandManager) {
|
engineCommandManager.endSession()
|
||||||
engineCommandManager.endSession()
|
engineCommandManager.startNewSession()
|
||||||
engineCommandManager.startNewSession()
|
setIsExecuting(true)
|
||||||
}
|
|
||||||
if (!engineCommandManager) return
|
|
||||||
const programMemory = await _executor(
|
const programMemory = await _executor(
|
||||||
_ast,
|
_ast,
|
||||||
{
|
{
|
||||||
@ -322,30 +222,42 @@ export function App() {
|
|||||||
|
|
||||||
const { artifactMap, sourceRangeMap } =
|
const { artifactMap, sourceRangeMap } =
|
||||||
await engineCommandManager.waitForAllCommands()
|
await engineCommandManager.waitForAllCommands()
|
||||||
|
setIsExecuting(false)
|
||||||
|
|
||||||
setArtifactMap({ artifactMap, sourceRangeMap })
|
setArtifactMap({ artifactMap, sourceRangeMap })
|
||||||
engineCommandManager.onHover((id) => {
|
const unSubHover = engineCommandManager.subscribeToUnreliable({
|
||||||
if (!id) {
|
event: 'highlight_set_entity',
|
||||||
setHighlightRange([0, 0])
|
callback: ({ data }) => {
|
||||||
} else {
|
if (data?.entity_id) {
|
||||||
const sourceRange = sourceRangeMap[id]
|
const sourceRange = sourceRangeMap[data.entity_id]
|
||||||
setHighlightRange(sourceRange)
|
setHighlightRange(sourceRange)
|
||||||
}
|
} else if (
|
||||||
|
!highlightRange ||
|
||||||
|
(highlightRange[0] !== 0 && highlightRange[1] !== 0)
|
||||||
|
) {
|
||||||
|
setHighlightRange([0, 0])
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
engineCommandManager.onClick((selections) => {
|
const unSubClick = engineCommandManager.subscribeTo({
|
||||||
if (!selections) {
|
event: 'select_with_point',
|
||||||
setCursor2()
|
callback: ({ data }) => {
|
||||||
return
|
if (!data?.entity_id) {
|
||||||
}
|
setCursor2()
|
||||||
const { id, type } = selections
|
return
|
||||||
setCursor2({ range: sourceRangeMap[id], type })
|
}
|
||||||
|
const sourceRange = sourceRangeMap[data.entity_id]
|
||||||
|
setCursor2({ range: sourceRange, type: 'default' })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
unsubFn.push(unSubHover, unSubClick)
|
||||||
if (programMemory !== undefined) {
|
if (programMemory !== undefined) {
|
||||||
setProgramMemory(programMemory)
|
setProgramMemory(programMemory)
|
||||||
}
|
}
|
||||||
|
|
||||||
setError()
|
setError()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
setIsExecuting(false)
|
||||||
if (e instanceof KCLError) {
|
if (e instanceof KCLError) {
|
||||||
addKCLError(e)
|
addKCLError(e)
|
||||||
} else {
|
} else {
|
||||||
@ -356,37 +268,41 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
asyncWrap()
|
asyncWrap()
|
||||||
}, [code, isStreamReady])
|
return () => {
|
||||||
|
unsubFn.forEach((fn) => fn())
|
||||||
|
}
|
||||||
|
}, [defferedCode, isStreamReady, engineCommandManager])
|
||||||
|
|
||||||
const debounceSocketSend = throttle<EngineCommand>((message) => {
|
const debounceSocketSend = throttle<EngineCommand>((message) => {
|
||||||
engineCommandManager?.sendSceneCommand(message)
|
engineCommandManager?.sendSceneCommand(message)
|
||||||
}, 16)
|
}, 16)
|
||||||
const handleMouseMove: MouseEventHandler<HTMLDivElement> = ({
|
const handleMouseMove: MouseEventHandler<HTMLDivElement> = (e) => {
|
||||||
clientX,
|
e.nativeEvent.preventDefault()
|
||||||
clientY,
|
|
||||||
ctrlKey,
|
|
||||||
shiftKey,
|
|
||||||
currentTarget,
|
|
||||||
nativeEvent,
|
|
||||||
}) => {
|
|
||||||
nativeEvent.preventDefault()
|
|
||||||
if (isMouseDownInStream) {
|
|
||||||
setDidDragInStream(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { x, y } = getNormalisedCoordinates({
|
const { x, y } = getNormalisedCoordinates({
|
||||||
clientX,
|
clientX: e.clientX,
|
||||||
clientY,
|
clientY: e.clientY,
|
||||||
el: currentTarget,
|
el: e.currentTarget,
|
||||||
...streamDimensions,
|
...streamDimensions,
|
||||||
})
|
})
|
||||||
|
|
||||||
const interaction = ctrlKey ? 'zoom' : shiftKey ? 'pan' : 'rotate'
|
|
||||||
|
|
||||||
const newCmdId = uuidv4()
|
const newCmdId = uuidv4()
|
||||||
setCmdId(newCmdId)
|
|
||||||
|
|
||||||
if (cmdId && isMouseDownInStream) {
|
if (buttonDownInStream) {
|
||||||
|
const interactionGuards = cameraMouseDragGuards[cameraControls]
|
||||||
|
let interaction: CameraDragInteractionType_type
|
||||||
|
|
||||||
|
const eWithButton = { ...e, button: buttonDownInStream }
|
||||||
|
|
||||||
|
if (interactionGuards.pan.callback(eWithButton)) {
|
||||||
|
interaction = 'pan'
|
||||||
|
} else if (interactionGuards.rotate.callback(eWithButton)) {
|
||||||
|
interaction = 'rotate'
|
||||||
|
} else if (interactionGuards.zoom.dragCallback(eWithButton)) {
|
||||||
|
interaction = 'zoom'
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
debounceSocketSend({
|
debounceSocketSend({
|
||||||
type: 'modeling_cmd_req',
|
type: 'modeling_cmd_req',
|
||||||
cmd: {
|
cmd: {
|
||||||
@ -408,16 +324,6 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const extraExtensions = useMemo(() => {
|
|
||||||
if (TEST) return []
|
|
||||||
return [
|
|
||||||
lintGutter(),
|
|
||||||
linter((_view) => {
|
|
||||||
return kclErrToDiagnostic(useStore.getState().kclErrors)
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-screen overflow-hidden relative flex flex-col cursor-pointer select-none"
|
className="h-screen overflow-hidden relative flex flex-col cursor-pointer select-none"
|
||||||
@ -428,7 +334,7 @@ export function App() {
|
|||||||
className={
|
className={
|
||||||
'transition-opacity transition-duration-75 ' +
|
'transition-opacity transition-duration-75 ' +
|
||||||
paneOpacity +
|
paneOpacity +
|
||||||
(isMouseDownInStream ? ' pointer-events-none' : '')
|
(buttonDownInStream ? ' pointer-events-none' : '')
|
||||||
}
|
}
|
||||||
project={project}
|
project={project}
|
||||||
enableMenu={true}
|
enableMenu={true}
|
||||||
@ -437,7 +343,7 @@ export function App() {
|
|||||||
<Resizable
|
<Resizable
|
||||||
className={
|
className={
|
||||||
'h-full flex flex-col flex-1 z-10 my-5 ml-5 pr-1 transition-opacity transition-duration-75 ' +
|
'h-full flex flex-col flex-1 z-10 my-5 ml-5 pr-1 transition-opacity transition-duration-75 ' +
|
||||||
(isMouseDownInStream || onboardingStatus === 'camera'
|
(buttonDownInStream || onboardingStatus === 'camera'
|
||||||
? ' pointer-events-none '
|
? ' pointer-events-none '
|
||||||
: ' ') +
|
: ' ') +
|
||||||
paneOpacity
|
paneOpacity
|
||||||
@ -461,31 +367,9 @@ export function App() {
|
|||||||
icon={faCode}
|
icon={faCode}
|
||||||
className="open:!mb-2"
|
className="open:!mb-2"
|
||||||
open={openPanes.includes('code')}
|
open={openPanes.includes('code')}
|
||||||
|
menu={<CodeMenu />}
|
||||||
>
|
>
|
||||||
<div className="px-2 py-1">
|
<TextEditor theme={editorTheme} />
|
||||||
<button
|
|
||||||
// disabled={!shouldFormat}
|
|
||||||
onClick={formatCode}
|
|
||||||
// className={`${!shouldFormat && 'text-gray-300'}`}
|
|
||||||
>
|
|
||||||
format
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="code-mirror-override">
|
|
||||||
<CodeMirror
|
|
||||||
className="h-full"
|
|
||||||
value={code}
|
|
||||||
extensions={[
|
|
||||||
langs.javascript({ jsx: true }),
|
|
||||||
lineHighlightField,
|
|
||||||
...extraExtensions,
|
|
||||||
]}
|
|
||||||
onChange={onChange}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
theme={editorTheme}
|
|
||||||
onCreateEditor={(_editorView) => setEditorView(_editorView)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CollapsiblePanel>
|
</CollapsiblePanel>
|
||||||
<section className="flex flex-col">
|
<section className="flex flex-col">
|
||||||
<MemoryPanel
|
<MemoryPanel
|
||||||
@ -510,13 +394,13 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</Resizable>
|
</Resizable>
|
||||||
<Stream className="absolute inset-0 z-0" />
|
<Stream className="absolute inset-0 z-0" />
|
||||||
{debugPanel && (
|
{showDebugPanel && (
|
||||||
<DebugPanel
|
<DebugPanel
|
||||||
title="Debug"
|
title="Debug"
|
||||||
className={
|
className={
|
||||||
'transition-opacity transition-duration-75 ' +
|
'transition-opacity transition-duration-75 ' +
|
||||||
paneOpacity +
|
paneOpacity +
|
||||||
(isMouseDownInStream ? ' pointer-events-none' : '')
|
(buttonDownInStream ? ' pointer-events-none' : '')
|
||||||
}
|
}
|
||||||
open={openPanes.includes('debug')}
|
open={openPanes.includes('debug')}
|
||||||
/>
|
/>
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
import Loading from './components/Loading'
|
import Loading from './components/Loading'
|
||||||
import { useAuthMachine } from './hooks/useAuthMachine'
|
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||||
|
|
||||||
// Wrapper around protected routes, used in src/Router.tsx
|
// Wrapper around protected routes, used in src/Router.tsx
|
||||||
export const Auth = ({ children }: React.PropsWithChildren) => {
|
export const Auth = ({ children }: React.PropsWithChildren) => {
|
||||||
const [isLoggedIn] = useAuthMachine((s) => s.matches('checkIfLoggedIn'))
|
const {
|
||||||
|
auth: { state },
|
||||||
|
} = useGlobalStateContext()
|
||||||
|
const isLoggedIn = state.matches('checkIfLoggedIn')
|
||||||
|
|
||||||
return isLoggedIn ? (
|
return isLoggedIn ? (
|
||||||
<Loading>Loading KittyCAD Modeling App...</Loading>
|
<Loading>Loading KittyCAD Modeling App...</Loading>
|
||||||
|
112
src/Router.tsx
112
src/Router.tsx
@ -3,8 +3,15 @@ import {
|
|||||||
createBrowserRouter,
|
createBrowserRouter,
|
||||||
Outlet,
|
Outlet,
|
||||||
redirect,
|
redirect,
|
||||||
|
useLocation,
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
} from 'react-router-dom'
|
} from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
matchRoutes,
|
||||||
|
createRoutesFromChildren,
|
||||||
|
useNavigationType,
|
||||||
|
} from 'react-router'
|
||||||
|
import { useEffect } from 'react'
|
||||||
import { ErrorPage } from './components/ErrorPage'
|
import { ErrorPage } from './components/ErrorPage'
|
||||||
import { Settings } from './routes/Settings'
|
import { Settings } from './routes/Settings'
|
||||||
import Onboarding, {
|
import Onboarding, {
|
||||||
@ -24,7 +31,47 @@ import {
|
|||||||
} from './lib/tauriFS'
|
} from './lib/tauriFS'
|
||||||
import { metadata, type Metadata } from 'tauri-plugin-fs-extra-api'
|
import { metadata, type Metadata } from 'tauri-plugin-fs-extra-api'
|
||||||
import DownloadAppBanner from './components/DownloadAppBanner'
|
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 =
|
const prependRoutes =
|
||||||
(routesObject: Record<string, string>) => (prepend: string) => {
|
(routesObject: Record<string, string>) => (prepend: string) => {
|
||||||
@ -68,7 +115,11 @@ const addGlobalContextToElements = (
|
|||||||
'element' in route
|
'element' in route
|
||||||
? {
|
? {
|
||||||
...route,
|
...route,
|
||||||
element: <GlobalStateProvider>{route.element}</GlobalStateProvider>,
|
element: (
|
||||||
|
<CommandBarProvider>
|
||||||
|
<GlobalStateProvider>{route.element}</GlobalStateProvider>
|
||||||
|
</CommandBarProvider>
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: route
|
: route
|
||||||
)
|
)
|
||||||
@ -95,26 +146,25 @@ const router = createBrowserRouter(
|
|||||||
request,
|
request,
|
||||||
params,
|
params,
|
||||||
}): Promise<IndexLoaderData | Response> => {
|
}): Promise<IndexLoaderData | Response> => {
|
||||||
const store = localStorage.getItem('store')
|
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
|
||||||
if (store === null) {
|
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
|
||||||
return redirect(paths.ONBOARDING.INDEX)
|
ContextFrom<typeof settingsMachine>
|
||||||
} 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
|
|
||||||
|
|
||||||
if (shouldRedirectToOnboarding) {
|
const status = persistedSettings.onboardingStatus || ''
|
||||||
return redirect(
|
const notEnRouteToOnboarding = !request.url.includes(
|
||||||
makeUrlPathRelative(paths.ONBOARDING.INDEX) + status
|
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') {
|
if (params.id && params.id !== 'new') {
|
||||||
@ -164,9 +214,23 @@ const router = createBrowserRouter(
|
|||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
return redirect(paths.FILE + '/new')
|
return redirect(paths.FILE + '/new')
|
||||||
}
|
}
|
||||||
|
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
|
||||||
const projectDir = await initializeProjectDirectory()
|
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
|
||||||
const projectsNoMeta = (await readDir(projectDir.dir)).filter(
|
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
|
isProjectDirectory
|
||||||
)
|
)
|
||||||
const projects = await Promise.all(
|
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
@ -8,9 +8,13 @@ import { EqualAngle } from './components/Toolbar/EqualAngle'
|
|||||||
import { Intersect } from './components/Toolbar/Intersect'
|
import { Intersect } from './components/Toolbar/Intersect'
|
||||||
import { SetHorzVertDistance } from './components/Toolbar/SetHorzVertDistance'
|
import { SetHorzVertDistance } from './components/Toolbar/SetHorzVertDistance'
|
||||||
import { SetAngleLength } from './components/Toolbar/setAngleLength'
|
import { SetAngleLength } from './components/Toolbar/setAngleLength'
|
||||||
import { ConvertToVariable } from './components/Toolbar/ConvertVariable'
|
|
||||||
import { SetAbsDistance } from './components/Toolbar/SetAbsDistance'
|
import { SetAbsDistance } from './components/Toolbar/SetAbsDistance'
|
||||||
import { SetAngleBetween } from './components/Toolbar/SetAngleBetween'
|
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 = () => {
|
export const Toolbar = () => {
|
||||||
const {
|
const {
|
||||||
@ -29,72 +33,26 @@ export const Toolbar = () => {
|
|||||||
programMemory: s.programMemory,
|
programMemory: s.programMemory,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<div>
|
console.log('guiMode', guiMode)
|
||||||
{guiMode.mode === 'default' && (
|
}, [guiMode])
|
||||||
<button
|
|
||||||
onClick={() => {
|
function ToolbarButtons() {
|
||||||
setGuiMode({
|
return (
|
||||||
mode: 'sketch',
|
<>
|
||||||
sketchMode: 'selectFace',
|
{guiMode.mode === 'default' && (
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
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' && (
|
|
||||||
<>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!ast) return
|
setGuiMode({
|
||||||
const pathToNode = getNodePathFromSourceRange(
|
mode: 'sketch',
|
||||||
ast,
|
sketchMode: 'selectFace',
|
||||||
selectionRanges.codeBasedSelections[0].range
|
})
|
||||||
)
|
|
||||||
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
|
|
||||||
ast,
|
|
||||||
pathToNode
|
|
||||||
)
|
|
||||||
updateAst(modifiedAst, { focusPath: pathToExtrudeArg })
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ExtrudeSketch
|
Start Sketch
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{guiMode.mode === 'canEditExtrude' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!ast) return
|
if (!ast) return
|
||||||
@ -102,77 +60,181 @@ export const Toolbar = () => {
|
|||||||
ast,
|
ast,
|
||||||
selectionRanges.codeBasedSelections[0].range
|
selectionRanges.codeBasedSelections[0].range
|
||||||
)
|
)
|
||||||
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
|
const { modifiedAst } = sketchOnExtrudedFace(
|
||||||
ast,
|
ast,
|
||||||
pathToNode,
|
pathToNode,
|
||||||
false
|
programMemory
|
||||||
)
|
)
|
||||||
updateAst(modifiedAst, { focusPath: pathToExtrudeArg })
|
updateAst(modifiedAst)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ExtrudeSketch (w/o pipe)
|
SketchOnFace
|
||||||
</button>
|
</button>
|
||||||
</>
|
)}
|
||||||
)}
|
{(guiMode.mode === 'canEditSketch' || false) && (
|
||||||
|
<button
|
||||||
{guiMode.mode === 'sketch' && (
|
onClick={() => {
|
||||||
<button onClick={() => setGuiMode({ mode: 'default' })}>
|
setGuiMode({
|
||||||
Exit sketch
|
mode: 'sketch',
|
||||||
</button>
|
sketchMode: 'sketchEdit',
|
||||||
)}
|
pathToNode: guiMode.pathToNode,
|
||||||
{toolTips
|
rotation: guiMode.rotation,
|
||||||
.filter(
|
position: guiMode.position,
|
||||||
// (sketchFnName) => !['angledLineThatIntersects'].includes(sketchFnName)
|
})
|
||||||
(sketchFnName) => ['line'].includes(sketchFnName)
|
}}
|
||||||
)
|
>
|
||||||
.map((sketchFnName) => {
|
Edit Sketch
|
||||||
if (
|
</button>
|
||||||
guiMode.mode !== 'sketch' ||
|
)}
|
||||||
!('isTooltip' in guiMode || guiMode.sketchMode === 'sketchEdit')
|
{guiMode.mode === 'canEditSketch' && (
|
||||||
)
|
<>
|
||||||
return null
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={sketchFnName}
|
onClick={() => {
|
||||||
onClick={() =>
|
if (!ast) return
|
||||||
setGuiMode({
|
const pathToNode = getNodePathFromSourceRange(
|
||||||
...guiMode,
|
ast,
|
||||||
...(guiMode.sketchMode === sketchFnName
|
selectionRanges.codeBasedSelections[0].range
|
||||||
? {
|
)
|
||||||
sketchMode: 'sketchEdit',
|
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
|
||||||
// todo: ...guiMod is adding isTooltip: true, will probably just fix with xstate migtaion
|
ast,
|
||||||
}
|
pathToNode
|
||||||
: {
|
)
|
||||||
sketchMode: sketchFnName,
|
updateAst(modifiedAst, { focusPath: pathToExtrudeArg })
|
||||||
isTooltip: true,
|
}}
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{sketchFnName}
|
ExtrudeSketch
|
||||||
{guiMode.sketchMode === sketchFnName && '✅'}
|
|
||||||
</button>
|
</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)
|
||||||
)
|
)
|
||||||
})}
|
.map((sketchFnName) => {
|
||||||
<br></br>
|
if (
|
||||||
<ConvertToVariable />
|
guiMode.mode !== 'sketch' ||
|
||||||
<HorzVert horOrVert="horizontal" />
|
!('isTooltip' in guiMode || guiMode.sketchMode === 'sketchEdit')
|
||||||
<HorzVert horOrVert="vertical" />
|
)
|
||||||
<EqualLength />
|
return null
|
||||||
<EqualAngle />
|
return (
|
||||||
<SetHorzVertDistance buttonType="alignEndsVertically" />
|
<button
|
||||||
<SetHorzVertDistance buttonType="setHorzDistance" />
|
key={sketchFnName}
|
||||||
<SetAbsDistance buttonType="snapToYAxis" />
|
onClick={() =>
|
||||||
<SetAbsDistance buttonType="xAbs" />
|
setGuiMode({
|
||||||
<SetHorzVertDistance buttonType="alignEndsHorizontally" />
|
...guiMode,
|
||||||
<SetAbsDistance buttonType="snapToXAxis" />
|
...(guiMode.sketchMode === sketchFnName
|
||||||
<SetHorzVertDistance buttonType="setVertDistance" />
|
? {
|
||||||
<SetAbsDistance buttonType="yAbs" />
|
sketchMode: 'sketchEdit',
|
||||||
<SetAngleLength angleOrLength="setAngle" />
|
// todo: ...guiMod is adding isTooltip: true, will probably just fix with xstate migtaion
|
||||||
<SetAngleLength angleOrLength="setLength" />
|
}
|
||||||
<Intersect />
|
: {
|
||||||
<RemoveConstrainingValues />
|
sketchMode: sketchFnName,
|
||||||
<SetAngleBetween />
|
isTooltip: true,
|
||||||
</div>
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sketchFnName}
|
||||||
|
{guiMode.sketchMode === sketchFnName && '✅'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<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 = {
|
const iconSizes = {
|
||||||
sm: 12,
|
sm: 12,
|
||||||
md: 14.4,
|
md: 14.4,
|
||||||
lg: 18,
|
lg: 20,
|
||||||
|
xl: 28,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionIconProps extends React.PropsWithChildren {
|
export interface ActionIconProps extends React.PropsWithChildren {
|
||||||
icon?: SolidIconDefinition | BrandIconDefinition
|
icon?: SolidIconDefinition | BrandIconDefinition
|
||||||
|
className?: string
|
||||||
bgClassName?: string
|
bgClassName?: string
|
||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
size?: keyof typeof iconSizes
|
size?: keyof typeof iconSizes
|
||||||
@ -20,6 +22,7 @@ export interface ActionIconProps extends React.PropsWithChildren {
|
|||||||
|
|
||||||
export const ActionIcon = ({
|
export const ActionIcon = ({
|
||||||
icon = faCircleExclamation,
|
icon = faCircleExclamation,
|
||||||
|
className,
|
||||||
bgClassName,
|
bgClassName,
|
||||||
iconClassName,
|
iconClassName,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
@ -28,7 +31,9 @@ export const ActionIcon = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'p-1 w-fit inline-grid place-content-center ' +
|
`p-${
|
||||||
|
size === 'xl' ? '2' : '1'
|
||||||
|
} w-fit inline-grid place-content-center ${className} ` +
|
||||||
(bgClassName ||
|
(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')
|
'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]}
|
height={iconSizes[size]}
|
||||||
className={
|
className={
|
||||||
iconClassName ||
|
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 UserSidebarMenu from './UserSidebarMenu'
|
||||||
import { ProjectWithEntryPointMetadata } from '../Router'
|
import { ProjectWithEntryPointMetadata } from '../Router'
|
||||||
import ProjectSidebarMenu from './ProjectSidebarMenu'
|
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 {
|
interface AppHeaderProps extends React.PropsWithChildren {
|
||||||
showToolbar?: boolean
|
showToolbar?: boolean
|
||||||
@ -18,12 +19,18 @@ export const AppHeader = ({
|
|||||||
className = '',
|
className = '',
|
||||||
enableMenu = false,
|
enableMenu = false,
|
||||||
}: AppHeaderProps) => {
|
}: AppHeaderProps) => {
|
||||||
const [user] = useAuthMachine((s) => s?.context?.user)
|
const {
|
||||||
|
auth: {
|
||||||
|
context: { user },
|
||||||
|
},
|
||||||
|
} = useGlobalStateContext()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={
|
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
|
className
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -35,7 +42,11 @@ export const AppHeader = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* If there are children, show them, otherwise show User menu */}
|
{/* If there are children, show them, otherwise show User menu */}
|
||||||
{children || <UserSidebarMenu user={user} />}
|
{children || (
|
||||||
|
<div className="ml-auto">
|
||||||
|
<UserSidebarMenu user={user} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -198,29 +198,25 @@ export const CreateNewVariable = ({
|
|||||||
isNewVariableNameUnique,
|
isNewVariableNameUnique,
|
||||||
setNewVariableName,
|
setNewVariableName,
|
||||||
shouldCreateVariable,
|
shouldCreateVariable,
|
||||||
setShouldCreateVariable,
|
setShouldCreateVariable = () => {},
|
||||||
showCheckbox = true,
|
showCheckbox = true,
|
||||||
}: {
|
}: {
|
||||||
isNewVariableNameUnique: boolean
|
isNewVariableNameUnique: boolean
|
||||||
newVariableName: string
|
newVariableName: string
|
||||||
setNewVariableName: (a: string) => void
|
setNewVariableName: (a: string) => void
|
||||||
shouldCreateVariable: boolean
|
shouldCreateVariable?: boolean
|
||||||
setShouldCreateVariable: (a: boolean) => void
|
setShouldCreateVariable?: (a: boolean) => void
|
||||||
showCheckbox?: boolean
|
showCheckbox?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<label
|
<label htmlFor="create-new-variable" className="block mt-3 font-mono">
|
||||||
htmlFor="create-new-variable"
|
|
||||||
className="block text-sm font-medium text-gray-700 mt-3 font-mono"
|
|
||||||
>
|
|
||||||
Create new variable
|
Create new variable
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 flex flex-1">
|
<div className="mt-1 flex gap-2 items-center">
|
||||||
{showCheckbox && (
|
{showCheckbox && (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md font-mono pl-1 flex-shrink"
|
|
||||||
checked={shouldCreateVariable}
|
checked={shouldCreateVariable}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setShouldCreateVariable(e.target.checked)
|
setShouldCreateVariable(e.target.checked)
|
||||||
@ -232,7 +228,10 @@ export const CreateNewVariable = ({
|
|||||||
disabled={!shouldCreateVariable}
|
disabled={!shouldCreateVariable}
|
||||||
name="create-new-variable"
|
name="create-new-variable"
|
||||||
id="create-new-variable"
|
id="create-new-variable"
|
||||||
className={`shadow-sm font-[monospace] focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md font-mono pl-1 flex-shrink-0 ${
|
autoFocus={true}
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
className={`font-mono flex-1 sm:text-sm px-2 py-1 rounded-sm bg-chalkboard-10 dark:bg-chalkboard-90 text-chalkboard-90 dark:text-chalkboard-10 ${
|
||||||
!shouldCreateVariable ? 'opacity-50' : ''
|
!shouldCreateVariable ? 'opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
value={newVariableName}
|
value={newVariableName}
|
||||||
|
19
src/components/CodeMenu.module.css
Normal file
19
src/components/CodeMenu.module.css
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
.button {
|
||||||
|
@apply flex justify-between items-center gap-2 px-2 py-1 text-left border-none rounded-sm;
|
||||||
|
@apply font-mono text-xs font-bold select-none text-chalkboard-90;
|
||||||
|
@apply ui-active:bg-liquid-10/50 ui-active:text-liquid-90;
|
||||||
|
@apply transition-colors ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .button {
|
||||||
|
@apply text-chalkboard-30;
|
||||||
|
@apply ui-active:bg-chalkboard-80 ui-active:text-liquid-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button small {
|
||||||
|
@apply text-chalkboard-60;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .button small {
|
||||||
|
@apply text-chalkboard-40;
|
||||||
|
}
|
59
src/components/CodeMenu.tsx
Normal file
59
src/components/CodeMenu.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { Menu } from '@headlessui/react'
|
||||||
|
import { PropsWithChildren } from 'react'
|
||||||
|
import { faEllipsis } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { ActionIcon } from './ActionIcon'
|
||||||
|
import { useStore } from 'useStore'
|
||||||
|
import styles from './CodeMenu.module.css'
|
||||||
|
import { useConvertToVariable } from 'hooks/useToolbarGuards'
|
||||||
|
import { editorShortcutMeta } from './TextEditor'
|
||||||
|
|
||||||
|
export const CodeMenu = ({ children }: PropsWithChildren) => {
|
||||||
|
const { formatCode } = useStore((s) => ({
|
||||||
|
formatCode: s.formatCode,
|
||||||
|
}))
|
||||||
|
const { enable: convertToVarEnabled, handleClick: handleConvertToVarClick } =
|
||||||
|
useConvertToVariable()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu>
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.eventPhase === 3) {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu.Button className="p-0 border-none relative">
|
||||||
|
<ActionIcon
|
||||||
|
icon={faEllipsis}
|
||||||
|
bgClassName={
|
||||||
|
'bg-chalkboard-20 dark:bg-chalkboard-110 hover:bg-liquid-10/50 hover:dark:bg-chalkboard-90 ui-active:bg-chalkboard-80 ui-active:dark:bg-chalkboard-90 rounded'
|
||||||
|
}
|
||||||
|
iconClassName={'text-chalkboard-90 dark:text-chalkboard-40'}
|
||||||
|
/>
|
||||||
|
</Menu.Button>
|
||||||
|
<Menu.Items className="absolute right-0 left-auto w-72 flex flex-col gap-1 divide-y divide-chalkboard-20 dark:divide-chalkboard-70 align-stretch px-0 py-1 bg-chalkboard-10 dark:bg-chalkboard-90 rounded-sm shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50">
|
||||||
|
<Menu.Item>
|
||||||
|
<button onClick={() => formatCode()} className={styles.button}>
|
||||||
|
<span>Format code</span>
|
||||||
|
<small>{editorShortcutMeta.formatCode.display}</small>
|
||||||
|
</button>
|
||||||
|
</Menu.Item>
|
||||||
|
{convertToVarEnabled && (
|
||||||
|
<Menu.Item>
|
||||||
|
<button
|
||||||
|
onClick={handleConvertToVarClick}
|
||||||
|
className={styles.button}
|
||||||
|
>
|
||||||
|
<span>Convert to Variable</span>
|
||||||
|
<small>{editorShortcutMeta.convertToVariable.display}</small>
|
||||||
|
</button>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</Menu.Items>
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
}
|
@ -1,15 +1,15 @@
|
|||||||
.panel {
|
.panel {
|
||||||
@apply relative overflow-auto z-0;
|
@apply relative z-0;
|
||||||
@apply bg-chalkboard-20/40;
|
@apply bg-chalkboard-10/70 backdrop-blur-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .panel {
|
:global(.dark) .panel {
|
||||||
@apply bg-chalkboard-110/50;
|
@apply bg-chalkboard-110/50 backdrop-blur-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@apply sticky top-0 z-10 cursor-pointer;
|
@apply sticky top-0 z-10 cursor-pointer;
|
||||||
@apply flex items-center gap-2 w-full p-2;
|
@apply flex items-center justify-between gap-2 w-full p-2;
|
||||||
@apply font-mono text-xs font-bold select-none text-chalkboard-90;
|
@apply font-mono text-xs font-bold select-none text-chalkboard-90;
|
||||||
@apply bg-chalkboard-20;
|
@apply bg-chalkboard-20;
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ export interface CollapsiblePanelProps
|
|||||||
title: string
|
title: string
|
||||||
icon?: IconDefinition
|
icon?: IconDefinition
|
||||||
open?: boolean
|
open?: boolean
|
||||||
|
menu?: React.ReactNode
|
||||||
iconClassNames?: {
|
iconClassNames?: {
|
||||||
bg?: string
|
bg?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
@ -18,21 +19,27 @@ export const PanelHeader = ({
|
|||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
iconClassNames,
|
iconClassNames,
|
||||||
|
menu,
|
||||||
}: CollapsiblePanelProps) => {
|
}: CollapsiblePanelProps) => {
|
||||||
return (
|
return (
|
||||||
<summary className={styles.header}>
|
<summary className={styles.header}>
|
||||||
<ActionIcon
|
<div className="flex gap-2 align-center flex-1">
|
||||||
icon={icon}
|
<ActionIcon
|
||||||
bgClassName={
|
icon={icon}
|
||||||
'bg-chalkboard-30 dark:bg-chalkboard-90 group-open:bg-chalkboard-80 rounded ' +
|
bgClassName={
|
||||||
(iconClassNames?.bg || '')
|
'bg-chalkboard-30 dark:bg-chalkboard-90 group-open:bg-chalkboard-80 rounded ' +
|
||||||
}
|
(iconClassNames?.bg || '')
|
||||||
iconClassName={
|
}
|
||||||
'text-chalkboard-90 dark:text-chalkboard-40 group-open:text-liquid-10 ' +
|
iconClassName={
|
||||||
(iconClassNames?.icon || '')
|
'text-chalkboard-90 dark:text-chalkboard-40 group-open:text-liquid-10 ' +
|
||||||
}
|
(iconClassNames?.icon || '')
|
||||||
/>
|
}
|
||||||
{title}
|
/>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="group-open:opacity-100 opacity-0 group-open:pointer-events-auto pointer-events-none">
|
||||||
|
{menu}
|
||||||
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -43,6 +50,7 @@ export const CollapsiblePanel = ({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
iconClassNames,
|
iconClassNames,
|
||||||
|
menu,
|
||||||
...props
|
...props
|
||||||
}: CollapsiblePanelProps) => {
|
}: CollapsiblePanelProps) => {
|
||||||
return (
|
return (
|
||||||
@ -50,7 +58,12 @@ export const CollapsiblePanel = ({
|
|||||||
{...props}
|
{...props}
|
||||||
className={styles.panel + ' group ' + (className || '')}
|
className={styles.panel + ' group ' + (className || '')}
|
||||||
>
|
>
|
||||||
<PanelHeader title={title} icon={icon} iconClassNames={iconClassNames} />
|
<PanelHeader
|
||||||
|
title={title}
|
||||||
|
icon={icon}
|
||||||
|
iconClassNames={iconClassNames}
|
||||||
|
menu={menu}
|
||||||
|
/>
|
||||||
{children}
|
{children}
|
||||||
</details>
|
</details>
|
||||||
)
|
)
|
||||||
|
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 z-40 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 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 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
|
@ -39,7 +39,7 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
|
|||||||
const initialValues: OutputFormat = {
|
const initialValues: OutputFormat = {
|
||||||
type: defaultType,
|
type: defaultType,
|
||||||
storage: 'embedded',
|
storage: 'embedded',
|
||||||
presentation: 'compact',
|
presentation: 'pretty',
|
||||||
}
|
}
|
||||||
const formik = useFormik({
|
const formik = useFormik({
|
||||||
initialValues,
|
initialValues,
|
||||||
@ -83,8 +83,6 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const yo = formik.values
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
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 ReactJson from 'react-json-view'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Themes, useStore } from '../useStore'
|
import { useStore } from '../useStore'
|
||||||
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
|
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
|
||||||
|
import { Themes } from '../lib/theme'
|
||||||
|
|
||||||
const ReactJsonTypeHack = ReactJson as any
|
const ReactJsonTypeHack = ReactJson as any
|
||||||
|
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import ReactJson from 'react-json-view'
|
import ReactJson from 'react-json-view'
|
||||||
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
|
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
|
||||||
import { Themes, useStore } from '../useStore'
|
import { useStore } from '../useStore'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { ProgramMemory } from '../lang/executor'
|
import { ProgramMemory } from '../lang/executor'
|
||||||
|
import { Themes } from '../lib/theme'
|
||||||
|
|
||||||
interface MemoryPanelProps extends CollapsiblePanelProps {
|
interface MemoryPanelProps extends CollapsiblePanelProps {
|
||||||
theme?: Exclude<Themes, Themes.System>
|
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 { ActionButton } from './ActionButton'
|
||||||
import { faHome } from '@fortawesome/free-solid-svg-icons'
|
import { faHome } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { ProjectWithEntryPointMetadata, paths } from '../Router'
|
import { ProjectWithEntryPointMetadata, paths } from '../Router'
|
||||||
import { isTauri } from '../lib/isTauri'
|
import { isTauri } from '../lib/isTauri'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ExportButton } from './ExportButton'
|
import { ExportButton } from './ExportButton'
|
||||||
|
import { Fragment } from 'react'
|
||||||
|
|
||||||
const ProjectSidebarMenu = ({
|
const ProjectSidebarMenu = ({
|
||||||
project,
|
project,
|
||||||
@ -34,7 +35,7 @@ const ProjectSidebarMenu = ({
|
|||||||
) : (
|
) : (
|
||||||
<Popover className="relative">
|
<Popover className="relative">
|
||||||
<Popover.Button
|
<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"
|
data-testid="project-sidebar-toggle"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@ -46,54 +47,77 @@ const ProjectSidebarMenu = ({
|
|||||||
{isTauri() && project?.name ? project.name : 'KittyCAD Modeling App'}
|
{isTauri() && project?.name ? project.name : 'KittyCAD Modeling App'}
|
||||||
</span>
|
</span>
|
||||||
</Popover.Button>
|
</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">
|
<Transition
|
||||||
<div className="flex items-center gap-4 px-4 py-3 bg-energy-100">
|
enter="duration-100 ease-out"
|
||||||
<img
|
enterFrom="opacity-0 -translate-x-1/4"
|
||||||
src="/kitt-8bit-winking.svg"
|
enterTo="opacity-100 translate-x-0"
|
||||||
alt="KittyCAD App"
|
leave="duration-75 ease-in"
|
||||||
className="h-9 w-auto"
|
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>
|
<div>
|
||||||
<p
|
<p
|
||||||
className="m-0 text-energy-10 text-mono"
|
className="m-0 text-energy-10 text-mono"
|
||||||
data-testid="projectName"
|
data-testid="projectName"
|
||||||
>
|
>
|
||||||
{project?.name ? project.name : 'KittyCAD Modeling App'}
|
{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>
|
</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>
|
</Popover.Panel>
|
||||||
<div className="p-4 flex flex-col gap-2">
|
</Transition>
|
||||||
<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>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import { Dialog, Transition } from '@headlessui/react'
|
import { Dialog, Transition } from '@headlessui/react'
|
||||||
import { Fragment } from 'react'
|
import { Fragment } from 'react'
|
||||||
import { useCalc, CreateNewVariable } from './AvailableVarsHelpers'
|
import { useCalc, CreateNewVariable } from './AvailableVarsHelpers'
|
||||||
|
import { ActionButton } from './ActionButton'
|
||||||
|
import { faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
|
||||||
export const SetVarNameModal = ({
|
export const SetVarNameModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
@ -19,67 +22,65 @@ export const SetVarNameModal = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-10" onClose={onReject}>
|
<Dialog
|
||||||
|
as="div"
|
||||||
|
className="fixed inset-0 z-40 overflow-y-auto p-4 pt-[25vh]"
|
||||||
|
onClose={onReject}
|
||||||
|
>
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0 translate-y-4"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100 translate-y-0"
|
||||||
leave="ease-in duration-200"
|
leave="ease-in duration-75"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
<Dialog.Overlay className="fixed inset-0 bg-chalkboard-10/70 dark:bg-chalkboard-110/50" />
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<Transition.Child
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
as={Fragment}
|
||||||
<Transition.Child
|
enter="ease-out duration-300"
|
||||||
as={Fragment}
|
enterFrom="opacity-0 scale-95"
|
||||||
enter="ease-out duration-300"
|
enterTo="opacity-100 scale-100"
|
||||||
enterFrom="opacity-0 scale-95"
|
leave="ease-in duration-200"
|
||||||
enterTo="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leave="ease-in duration-200"
|
leaveTo="opacity-0 scale-95"
|
||||||
leaveFrom="opacity-100 scale-100"
|
>
|
||||||
leaveTo="opacity-0 scale-95"
|
<Dialog.Panel className="rounded relative mx-auto px-4 py-8 bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-xl w-full shadow-lg">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onResolve({
|
||||||
|
variableName: newVariableName,
|
||||||
|
})
|
||||||
|
toast.success(`Added variable ${newVariableName}`)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
<CreateNewVariable
|
||||||
<Dialog.Title
|
setNewVariableName={setNewVariableName}
|
||||||
as="h3"
|
newVariableName={newVariableName}
|
||||||
className="text-lg font-medium leading-6 text-gray-900 capitalize"
|
isNewVariableNameUnique={isNewVariableNameUnique}
|
||||||
|
shouldCreateVariable={true}
|
||||||
|
showCheckbox={false}
|
||||||
|
/>
|
||||||
|
<div className="mt-8 flex justify-between">
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
type="submit"
|
||||||
|
disabled={!isNewVariableNameUnique}
|
||||||
|
icon={{ icon: faPlus }}
|
||||||
>
|
>
|
||||||
Set {valueName}
|
Add variable
|
||||||
</Dialog.Title>
|
</ActionButton>
|
||||||
|
<ActionButton Element="button" onClick={() => onReject(false)}>
|
||||||
<CreateNewVariable
|
Cancel
|
||||||
setNewVariableName={setNewVariableName}
|
</ActionButton>
|
||||||
newVariableName={newVariableName}
|
</div>
|
||||||
isNewVariableNameUnique={isNewVariableNameUnique}
|
</form>
|
||||||
shouldCreateVariable={true}
|
</Dialog.Panel>
|
||||||
setShouldCreateVariable={() => {}}
|
</Transition.Child>
|
||||||
/>
|
|
||||||
<div className="mt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!isNewVariableNameUnique}
|
|
||||||
className={`inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 ${
|
|
||||||
!isNewVariableNameUnique
|
|
||||||
? 'opacity-50 cursor-not-allowed'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
onClick={() =>
|
|
||||||
onResolve({
|
|
||||||
variableName: newVariableName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Add variable
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Transition>
|
</Transition>
|
||||||
)
|
)
|
||||||
|
@ -9,29 +9,37 @@ import { v4 as uuidv4 } from 'uuid'
|
|||||||
import { useStore } from '../useStore'
|
import { useStore } from '../useStore'
|
||||||
import { getNormalisedCoordinates } from '../lib/utils'
|
import { getNormalisedCoordinates } from '../lib/utils'
|
||||||
import Loading from './Loading'
|
import Loading from './Loading'
|
||||||
|
import { cameraMouseDragGuards } from 'lib/cameraControls'
|
||||||
|
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||||
|
import { CameraDragInteractionType_type } from '@kittycad/lib/dist/types/src/models'
|
||||||
|
|
||||||
export const Stream = ({ className = '' }) => {
|
export const Stream = ({ className = '' }) => {
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [clickCoords, setClickCoords] = useState<{ x: number; y: number }>()
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
const {
|
const {
|
||||||
mediaStream,
|
mediaStream,
|
||||||
engineCommandManager,
|
engineCommandManager,
|
||||||
setIsMouseDownInStream,
|
setButtonDownInStream,
|
||||||
setCmdId,
|
|
||||||
didDragInStream,
|
didDragInStream,
|
||||||
setDidDragInStream,
|
setDidDragInStream,
|
||||||
streamDimensions,
|
streamDimensions,
|
||||||
|
isExecuting,
|
||||||
} = useStore((s) => ({
|
} = useStore((s) => ({
|
||||||
mediaStream: s.mediaStream,
|
mediaStream: s.mediaStream,
|
||||||
engineCommandManager: s.engineCommandManager,
|
engineCommandManager: s.engineCommandManager,
|
||||||
isMouseDownInStream: s.isMouseDownInStream,
|
setButtonDownInStream: s.setButtonDownInStream,
|
||||||
setIsMouseDownInStream: s.setIsMouseDownInStream,
|
|
||||||
fileId: s.fileId,
|
fileId: s.fileId,
|
||||||
setCmdId: s.setCmdId,
|
|
||||||
didDragInStream: s.didDragInStream,
|
didDragInStream: s.didDragInStream,
|
||||||
setDidDragInStream: s.setDidDragInStream,
|
setDidDragInStream: s.setDidDragInStream,
|
||||||
streamDimensions: s.streamDimensions,
|
streamDimensions: s.streamDimensions,
|
||||||
|
isExecuting: s.isExecuting,
|
||||||
}))
|
}))
|
||||||
|
const {
|
||||||
|
settings: {
|
||||||
|
context: { cameraControls },
|
||||||
|
},
|
||||||
|
} = useGlobalStateContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@ -44,24 +52,29 @@ export const Stream = ({ className = '' }) => {
|
|||||||
videoRef.current.srcObject = mediaStream
|
videoRef.current.srcObject = mediaStream
|
||||||
}, [mediaStream, engineCommandManager])
|
}, [mediaStream, engineCommandManager])
|
||||||
|
|
||||||
const handleMouseDown: MouseEventHandler<HTMLVideoElement> = ({
|
const handleMouseDown: MouseEventHandler<HTMLVideoElement> = (e) => {
|
||||||
clientX,
|
|
||||||
clientY,
|
|
||||||
ctrlKey,
|
|
||||||
}) => {
|
|
||||||
if (!videoRef.current) return
|
if (!videoRef.current) return
|
||||||
const { x, y } = getNormalisedCoordinates({
|
const { x, y } = getNormalisedCoordinates({
|
||||||
clientX,
|
clientX: e.clientX,
|
||||||
clientY,
|
clientY: e.clientY,
|
||||||
el: videoRef.current,
|
el: videoRef.current,
|
||||||
...streamDimensions,
|
...streamDimensions,
|
||||||
})
|
})
|
||||||
console.log('click', x, y)
|
|
||||||
|
|
||||||
const newId = uuidv4()
|
const newId = uuidv4()
|
||||||
setCmdId(newId)
|
|
||||||
|
|
||||||
const interaction = ctrlKey ? 'pan' : 'rotate'
|
const interactionGuards = cameraMouseDragGuards[cameraControls]
|
||||||
|
let interaction: CameraDragInteractionType_type
|
||||||
|
|
||||||
|
if (interactionGuards.pan.callback(e)) {
|
||||||
|
interaction = 'pan'
|
||||||
|
} else if (interactionGuards.rotate.callback(e)) {
|
||||||
|
interaction = 'rotate'
|
||||||
|
} else if (interactionGuards.zoom.dragCallback(e)) {
|
||||||
|
interaction = 'zoom'
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
engineCommandManager?.sendSceneCommand({
|
engineCommandManager?.sendSceneCommand({
|
||||||
type: 'modeling_cmd_req',
|
type: 'modeling_cmd_req',
|
||||||
@ -73,10 +86,13 @@ export const Stream = ({ className = '' }) => {
|
|||||||
cmd_id: newId,
|
cmd_id: newId,
|
||||||
})
|
})
|
||||||
|
|
||||||
setIsMouseDownInStream(true)
|
setButtonDownInStream(e.button)
|
||||||
|
setClickCoords({ x, y })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll: WheelEventHandler<HTMLVideoElement> = (e) => {
|
const handleScroll: WheelEventHandler<HTMLVideoElement> = (e) => {
|
||||||
|
if (!cameraMouseDragGuards[cameraControls].zoom.scrollCallback(e)) return
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
engineCommandManager?.sendSceneCommand({
|
engineCommandManager?.sendSceneCommand({
|
||||||
type: 'modeling_cmd_req',
|
type: 'modeling_cmd_req',
|
||||||
@ -114,7 +130,7 @@ export const Stream = ({ className = '' }) => {
|
|||||||
cmd_id: newCmdId,
|
cmd_id: newCmdId,
|
||||||
})
|
})
|
||||||
|
|
||||||
setIsMouseDownInStream(false)
|
setButtonDownInStream(0)
|
||||||
if (!didDragInStream) {
|
if (!didDragInStream) {
|
||||||
engineCommandManager?.sendSceneCommand({
|
engineCommandManager?.sendSceneCommand({
|
||||||
type: 'modeling_cmd_req',
|
type: 'modeling_cmd_req',
|
||||||
@ -127,6 +143,19 @@ export const Stream = ({ className = '' }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
setDidDragInStream(false)
|
setDidDragInStream(false)
|
||||||
|
setClickCoords(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseMove: MouseEventHandler<HTMLVideoElement> = (e) => {
|
||||||
|
if (!clickCoords) return
|
||||||
|
|
||||||
|
const delta =
|
||||||
|
((clickCoords.x - e.clientX) ** 2 + (clickCoords.y - e.clientY) ** 2) **
|
||||||
|
0.5
|
||||||
|
|
||||||
|
if (delta > 5 && !didDragInStream) {
|
||||||
|
setDidDragInStream(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -142,7 +171,9 @@ export const Stream = ({ className = '' }) => {
|
|||||||
onContextMenuCapture={(e) => e.preventDefault()}
|
onContextMenuCapture={(e) => e.preventDefault()}
|
||||||
onWheel={handleScroll}
|
onWheel={handleScroll}
|
||||||
onPlay={() => setIsLoading(false)}
|
onPlay={() => setIsLoading(false)}
|
||||||
className="w-full h-full"
|
onMouseMoveCapture={handleMouseMove}
|
||||||
|
className={`w-full h-full ${isExecuting && 'blur-md'}`}
|
||||||
|
style={{ transitionDuration: '200ms', transitionProperty: 'filter' }}
|
||||||
/>
|
/>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="text-center absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
<div className="text-center absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
|
267
src/components/TextEditor.tsx
Normal file
267
src/components/TextEditor.tsx
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
import ReactCodeMirror, {
|
||||||
|
Extension,
|
||||||
|
ViewUpdate,
|
||||||
|
keymap,
|
||||||
|
} from '@uiw/react-codemirror'
|
||||||
|
import { FromServer, IntoServer } from 'editor/lsp/codec'
|
||||||
|
import Server from '../editor/lsp/server'
|
||||||
|
import Client from '../editor/lsp/client'
|
||||||
|
import { TEST } from 'env'
|
||||||
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
|
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||||
|
import { useConvertToVariable } from 'hooks/useToolbarGuards'
|
||||||
|
import { Themes } from 'lib/theme'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { linter, lintGutter } from '@codemirror/lint'
|
||||||
|
import { Selections, useStore } from 'useStore'
|
||||||
|
import { LanguageServerClient } from 'editor/lsp'
|
||||||
|
import kclLanguage from 'editor/lsp/language'
|
||||||
|
import { isTauri } from 'lib/isTauri'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { writeTextFile } from '@tauri-apps/api/fs'
|
||||||
|
import { PROJECT_ENTRYPOINT } from 'lib/tauriFS'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
import {
|
||||||
|
EditorView,
|
||||||
|
addLineHighlight,
|
||||||
|
lineHighlightField,
|
||||||
|
} from 'editor/highlightextension'
|
||||||
|
import { isOverlap } from 'lib/utils'
|
||||||
|
import { kclErrToDiagnostic } from 'lang/errors'
|
||||||
|
import { CSSRuleObject } from 'tailwindcss/types/config'
|
||||||
|
|
||||||
|
export const editorShortcutMeta = {
|
||||||
|
formatCode: {
|
||||||
|
codeMirror: 'Alt-Shift-f',
|
||||||
|
display: 'Alt + Shift + F',
|
||||||
|
},
|
||||||
|
convertToVariable: {
|
||||||
|
codeMirror: 'Ctrl-Shift-c',
|
||||||
|
display: 'Ctrl + Shift + C',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextEditor = ({
|
||||||
|
theme,
|
||||||
|
}: {
|
||||||
|
theme: Themes.Light | Themes.Dark
|
||||||
|
}) => {
|
||||||
|
const pathParams = useParams()
|
||||||
|
const {
|
||||||
|
code,
|
||||||
|
defferedSetCode,
|
||||||
|
editorView,
|
||||||
|
engineCommandManager,
|
||||||
|
formatCode,
|
||||||
|
isLSPServerReady,
|
||||||
|
selectionRanges,
|
||||||
|
selectionRangeTypeMap,
|
||||||
|
setEditorView,
|
||||||
|
setIsLSPServerReady,
|
||||||
|
setSelectionRanges,
|
||||||
|
sourceRangeMap,
|
||||||
|
} = useStore((s) => ({
|
||||||
|
code: s.code,
|
||||||
|
defferedCode: s.defferedCode,
|
||||||
|
defferedSetCode: s.defferedSetCode,
|
||||||
|
editorView: s.editorView,
|
||||||
|
engineCommandManager: s.engineCommandManager,
|
||||||
|
formatCode: s.formatCode,
|
||||||
|
isLSPServerReady: s.isLSPServerReady,
|
||||||
|
selectionRanges: s.selectionRanges,
|
||||||
|
selectionRangeTypeMap: s.selectionRangeTypeMap,
|
||||||
|
setCode: s.setCode,
|
||||||
|
setEditorView: s.setEditorView,
|
||||||
|
setIsLSPServerReady: s.setIsLSPServerReady,
|
||||||
|
setSelectionRanges: s.setSelectionRanges,
|
||||||
|
sourceRangeMap: s.sourceRangeMap,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const {
|
||||||
|
settings: {
|
||||||
|
context: { textWrapping },
|
||||||
|
},
|
||||||
|
} = useGlobalStateContext()
|
||||||
|
const { setCommandBarOpen } = useCommandsContext()
|
||||||
|
const { enable: convertEnabled, handleClick: convertCallback } =
|
||||||
|
useConvertToVariable()
|
||||||
|
|
||||||
|
// So this is a bit weird, we need to initialize the lsp server and client.
|
||||||
|
// But the server happens async so we break this into two parts.
|
||||||
|
// Below is the client and server promise.
|
||||||
|
const { lspClient } = useMemo(() => {
|
||||||
|
const intoServer: IntoServer = new IntoServer()
|
||||||
|
const fromServer: FromServer = FromServer.create()
|
||||||
|
const client = new Client(fromServer, intoServer)
|
||||||
|
if (!TEST) {
|
||||||
|
Server.initialize(intoServer, fromServer).then((lspServer) => {
|
||||||
|
lspServer.start()
|
||||||
|
setIsLSPServerReady(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const lspClient = new LanguageServerClient({ client })
|
||||||
|
return { lspClient }
|
||||||
|
}, [setIsLSPServerReady])
|
||||||
|
|
||||||
|
// Here we initialize the plugin which will start the client.
|
||||||
|
// When we have multi-file support the name of the file will be a dep of
|
||||||
|
// this use memo, as well as the directory structure, which I think is
|
||||||
|
// a good setup becuase it will restart the client but not the server :)
|
||||||
|
// We do not want to restart the server, its just wasteful.
|
||||||
|
const kclLSP = useMemo(() => {
|
||||||
|
let plugin = null
|
||||||
|
if (isLSPServerReady && !TEST) {
|
||||||
|
// Set up the lsp plugin.
|
||||||
|
const lsp = kclLanguage({
|
||||||
|
// When we have more than one file, we'll need to change this.
|
||||||
|
documentUri: `file:///we-just-have-one-file-for-now.kcl`,
|
||||||
|
workspaceFolders: null,
|
||||||
|
client: lspClient,
|
||||||
|
})
|
||||||
|
|
||||||
|
plugin = lsp
|
||||||
|
}
|
||||||
|
return plugin
|
||||||
|
}, [lspClient, isLSPServerReady])
|
||||||
|
|
||||||
|
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
|
||||||
|
const onChange = (value: string, viewUpdate: ViewUpdate) => {
|
||||||
|
defferedSetCode(value)
|
||||||
|
if (isTauri() && pathParams.id) {
|
||||||
|
// Save the file to disk
|
||||||
|
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
|
||||||
|
writeTextFile(pathParams.id + '/' + PROJECT_ENTRYPOINT, value).catch(
|
||||||
|
(err) => {
|
||||||
|
// TODO: add Sentry per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254)
|
||||||
|
console.error('error saving file', err)
|
||||||
|
toast.error('Error saving file, please check file permissions')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (editorView) {
|
||||||
|
editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
|
||||||
|
}
|
||||||
|
} //, []);
|
||||||
|
const onUpdate = (viewUpdate: ViewUpdate) => {
|
||||||
|
if (!editorView) {
|
||||||
|
setEditorView(viewUpdate.view)
|
||||||
|
}
|
||||||
|
const ranges = viewUpdate.state.selection.ranges
|
||||||
|
|
||||||
|
const isChange =
|
||||||
|
ranges.length !== selectionRanges.codeBasedSelections.length ||
|
||||||
|
ranges.some(({ from, to }, i) => {
|
||||||
|
return (
|
||||||
|
from !== selectionRanges.codeBasedSelections[i].range[0] ||
|
||||||
|
to !== selectionRanges.codeBasedSelections[i].range[1]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isChange) return
|
||||||
|
const codeBasedSelections: Selections['codeBasedSelections'] = ranges.map(
|
||||||
|
({ from, to }) => {
|
||||||
|
if (selectionRangeTypeMap[to]) {
|
||||||
|
return {
|
||||||
|
type: selectionRangeTypeMap[to],
|
||||||
|
range: [from, to],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'default',
|
||||||
|
range: [from, to],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const idBasedSelections = codeBasedSelections
|
||||||
|
.map(({ type, range }) => {
|
||||||
|
const hasOverlap = Object.entries(sourceRangeMap).filter(
|
||||||
|
([_, sourceRange]) => {
|
||||||
|
return isOverlap(sourceRange, range)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (hasOverlap.length) {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
id: hasOverlap[0][0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean) as any
|
||||||
|
|
||||||
|
engineCommandManager?.cusorsSelected({
|
||||||
|
otherSelections: [],
|
||||||
|
idBasedSelections,
|
||||||
|
})
|
||||||
|
|
||||||
|
setSelectionRanges({
|
||||||
|
otherSelections: [],
|
||||||
|
codeBasedSelections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorExtensions = useMemo(() => {
|
||||||
|
const extensions = [
|
||||||
|
lineHighlightField,
|
||||||
|
keymap.of([
|
||||||
|
{
|
||||||
|
key: 'Meta-k',
|
||||||
|
run: () => {
|
||||||
|
setCommandBarOpen(true)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: editorShortcutMeta.formatCode.codeMirror,
|
||||||
|
run: () => {
|
||||||
|
formatCode()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: editorShortcutMeta.convertToVariable.codeMirror,
|
||||||
|
run: () => {
|
||||||
|
if (convertEnabled) {
|
||||||
|
convertCallback()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
] as Extension[]
|
||||||
|
|
||||||
|
if (kclLSP) extensions.push(kclLSP)
|
||||||
|
|
||||||
|
// These extensions have proven to mess with vitest
|
||||||
|
if (!TEST) {
|
||||||
|
extensions.push(
|
||||||
|
lintGutter(),
|
||||||
|
linter((_view) => {
|
||||||
|
return kclErrToDiagnostic(useStore.getState().kclErrors)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
if (textWrapping === 'On') extensions.push(EditorView.lineWrapping)
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensions
|
||||||
|
}, [kclLSP, textWrapping])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="code-mirror-override"
|
||||||
|
className="full-height-subtract"
|
||||||
|
style={{ '--height-subtract': '4.25rem' } as CSSRuleObject}
|
||||||
|
>
|
||||||
|
<ReactCodeMirror
|
||||||
|
className="h-full"
|
||||||
|
value={code}
|
||||||
|
extensions={editorExtensions}
|
||||||
|
onChange={onChange}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
theme={theme}
|
||||||
|
onCreateEditor={(_editorView) => setEditorView(_editorView)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,61 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { create } from 'react-modal-promise'
|
|
||||||
import { useStore } from '../../useStore'
|
|
||||||
import { isNodeSafeToReplace } from '../../lang/queryAst'
|
|
||||||
import { SetVarNameModal } from '../SetVarNameModal'
|
|
||||||
import { moveValueIntoNewVariable } from '../../lang/modifyAst'
|
|
||||||
|
|
||||||
const getModalInfo = create(SetVarNameModal as any)
|
|
||||||
|
|
||||||
export const ConvertToVariable = () => {
|
|
||||||
const { guiMode, selectionRanges, ast, programMemory, updateAst } = useStore(
|
|
||||||
(s) => ({
|
|
||||||
guiMode: s.guiMode,
|
|
||||||
ast: s.ast,
|
|
||||||
updateAst: s.updateAst,
|
|
||||||
selectionRanges: s.selectionRanges,
|
|
||||||
programMemory: s.programMemory,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const [enableAngLen, setEnableAngLen] = useState(false)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ast) return
|
|
||||||
|
|
||||||
const { isSafe, value } = isNodeSafeToReplace(
|
|
||||||
ast,
|
|
||||||
selectionRanges.codeBasedSelections?.[0]?.range || []
|
|
||||||
)
|
|
||||||
const canReplace = isSafe && value.type !== 'Identifier'
|
|
||||||
const isOnlyOneSelection = selectionRanges.codeBasedSelections.length === 1
|
|
||||||
|
|
||||||
const _enableHorz = canReplace && isOnlyOneSelection
|
|
||||||
setEnableAngLen(_enableHorz)
|
|
||||||
}, [guiMode, selectionRanges])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
if (!ast) return
|
|
||||||
try {
|
|
||||||
const { variableName } = await getModalInfo({
|
|
||||||
valueName: 'var',
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
const { modifiedAst: _modifiedAst } = moveValueIntoNewVariable(
|
|
||||||
ast,
|
|
||||||
programMemory,
|
|
||||||
selectionRanges.codeBasedSelections[0].range,
|
|
||||||
variableName
|
|
||||||
)
|
|
||||||
|
|
||||||
updateAst(_modifiedAst)
|
|
||||||
} catch (e) {
|
|
||||||
console.log('e', e)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!enableAngLen}
|
|
||||||
>
|
|
||||||
ConvertToVariable
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
@ -2,7 +2,8 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
|||||||
import UserSidebarMenu from './UserSidebarMenu'
|
import UserSidebarMenu from './UserSidebarMenu'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { Models } from '@kittycad/lib'
|
import { Models } from '@kittycad/lib'
|
||||||
import { GlobalStateProvider } from '../hooks/useAuthMachine'
|
import { GlobalStateProvider } from './GlobalStateProvider'
|
||||||
|
import CommandBarProvider from './CommandBar'
|
||||||
|
|
||||||
type User = Models['User_type']
|
type User = Models['User_type']
|
||||||
|
|
||||||
@ -94,7 +95,9 @@ function TestWrap({ children }: { children: React.ReactNode }) {
|
|||||||
// wrap in router and xState context
|
// wrap in router and xState context
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<GlobalStateProvider>{children}</GlobalStateProvider>
|
<CommandBarProvider>
|
||||||
|
<GlobalStateProvider>{children}</GlobalStateProvider>
|
||||||
|
</CommandBarProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { Popover } from '@headlessui/react'
|
import { Popover, Transition } from '@headlessui/react'
|
||||||
import { ActionButton } from './ActionButton'
|
import { ActionButton } from './ActionButton'
|
||||||
import { faBars, faGear, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
|
import { faBars, faGear, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { faGithub } from '@fortawesome/free-brands-svg-icons'
|
import { faGithub } from '@fortawesome/free-brands-svg-icons'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useState } from 'react'
|
import { Fragment, useState } from 'react'
|
||||||
import { paths } from '../Router'
|
import { paths } from '../Router'
|
||||||
import makeUrlPathRelative from '../lib/makeUrlPathRelative'
|
import makeUrlPathRelative from '../lib/makeUrlPathRelative'
|
||||||
import { useAuthMachine } from '../hooks/useAuthMachine'
|
|
||||||
import { Models } from '@kittycad/lib'
|
import { Models } from '@kittycad/lib'
|
||||||
|
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||||
|
|
||||||
type User = Models['User_type']
|
type User = Models['User_type']
|
||||||
|
|
||||||
@ -15,7 +15,9 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
|
|||||||
const displayedName = getDisplayName(user)
|
const displayedName = getDisplayName(user)
|
||||||
const [imageLoadFailed, setImageLoadFailed] = useState(false)
|
const [imageLoadFailed, setImageLoadFailed] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [_, send] = useAuthMachine()
|
const {
|
||||||
|
auth: { send },
|
||||||
|
} = useGlobalStateContext()
|
||||||
|
|
||||||
// Fallback logic for displaying user's "name":
|
// Fallback logic for displaying user's "name":
|
||||||
// 1. user.name
|
// 1. user.name
|
||||||
@ -59,82 +61,102 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
|
|||||||
Menu
|
Menu
|
||||||
</ActionButton>
|
</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">
|
<Transition
|
||||||
{({ close }) => (
|
enter="duration-100 ease-out"
|
||||||
<>
|
enterFrom="opacity-0 translate-x-1/4"
|
||||||
{user && (
|
enterTo="opacity-100 translate-x-0"
|
||||||
<div className="flex items-center gap-4 px-4 py-3 bg-liquid-100">
|
leave="duration-75 ease-in"
|
||||||
{user.image && !imageLoadFailed && (
|
leaveFrom="opacity-100 translate-x-0"
|
||||||
<div className="rounded-full shadow-inner overflow-hidden">
|
leaveTo="opacity-0 translate-x-4"
|
||||||
<img
|
as={Fragment}
|
||||||
src={user.image}
|
>
|
||||||
alt={user.name || ''}
|
<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">
|
||||||
className="h-8 w-8"
|
{({ close }) => (
|
||||||
referrerPolicy="no-referrer"
|
<>
|
||||||
onError={() => setImageLoadFailed(true)}
|
{user && (
|
||||||
/>
|
<div className="flex items-center gap-4 px-4 py-3 bg-liquid-100">
|
||||||
</div>
|
{user.image && !imageLoadFailed && (
|
||||||
)}
|
<div className="rounded-full shadow-inner overflow-hidden">
|
||||||
|
<img
|
||||||
<div>
|
src={user.image}
|
||||||
<p
|
alt={user.name || ''}
|
||||||
className="m-0 text-liquid-10 text-mono"
|
className="h-8 w-8"
|
||||||
data-testid="username"
|
referrerPolicy="no-referrer"
|
||||||
>
|
onError={() => setImageLoadFailed(true)}
|
||||||
{displayedName || ''}
|
/>
|
||||||
</p>
|
</div>
|
||||||
{displayedName !== user.email && (
|
|
||||||
<p
|
|
||||||
className="m-0 text-liquid-40 text-xs"
|
|
||||||
data-testid="email"
|
|
||||||
>
|
|
||||||
{user.email}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
)}
|
</>
|
||||||
<div className="p-4 flex flex-col gap-2">
|
)}
|
||||||
<ActionButton
|
</Popover.Panel>
|
||||||
Element="button"
|
</Transition>
|
||||||
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>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
185
src/editor/lsp/client.ts
Normal file
185
src/editor/lsp/client.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import * as jsrpc from 'json-rpc-2.0'
|
||||||
|
import * as LSP from 'vscode-languageserver-protocol'
|
||||||
|
|
||||||
|
import {
|
||||||
|
registerServerCapability,
|
||||||
|
unregisterServerCapability,
|
||||||
|
} from './server-capability-registration'
|
||||||
|
import { Codec, FromServer, IntoServer } from './codec'
|
||||||
|
|
||||||
|
const client_capabilities: LSP.ClientCapabilities = {
|
||||||
|
textDocument: {
|
||||||
|
hover: {
|
||||||
|
dynamicRegistration: true,
|
||||||
|
contentFormat: ['plaintext', 'markdown'],
|
||||||
|
},
|
||||||
|
moniker: {},
|
||||||
|
synchronization: {
|
||||||
|
dynamicRegistration: true,
|
||||||
|
willSave: false,
|
||||||
|
didSave: false,
|
||||||
|
willSaveWaitUntil: false,
|
||||||
|
},
|
||||||
|
completion: {
|
||||||
|
dynamicRegistration: true,
|
||||||
|
completionItem: {
|
||||||
|
snippetSupport: false,
|
||||||
|
commitCharactersSupport: true,
|
||||||
|
documentationFormat: ['plaintext', 'markdown'],
|
||||||
|
deprecatedSupport: false,
|
||||||
|
preselectSupport: false,
|
||||||
|
},
|
||||||
|
contextSupport: false,
|
||||||
|
},
|
||||||
|
signatureHelp: {
|
||||||
|
dynamicRegistration: true,
|
||||||
|
signatureInformation: {
|
||||||
|
documentationFormat: ['plaintext', 'markdown'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
declaration: {
|
||||||
|
dynamicRegistration: true,
|
||||||
|
linkSupport: true,
|
||||||
|
},
|
||||||
|
definition: {
|
||||||
|
dynamicRegistration: true,
|
||||||
|
linkSupport: true,
|
||||||
|
},
|
||||||
|
typeDefinition: {
|
||||||
|
dynamicRegistration: true,
|
||||||
|
linkSupport: true,
|
||||||
|
},
|
||||||
|
implementation: {
|
||||||
|
dynamicRegistration: true,
|
||||||
|
linkSupport: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
didChangeConfiguration: {
|
||||||
|
dynamicRegistration: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Client extends jsrpc.JSONRPCServerAndClient {
|
||||||
|
afterInitializedHooks: (() => Promise<void>)[] = []
|
||||||
|
#fromServer: FromServer
|
||||||
|
private serverCapabilities: LSP.ServerCapabilities<any> = {}
|
||||||
|
|
||||||
|
constructor(fromServer: FromServer, intoServer: IntoServer) {
|
||||||
|
super(
|
||||||
|
new jsrpc.JSONRPCServer(),
|
||||||
|
new jsrpc.JSONRPCClient(async (json: jsrpc.JSONRPCRequest) => {
|
||||||
|
const encoded = Codec.encode(json)
|
||||||
|
intoServer.enqueue(encoded)
|
||||||
|
if (null != json.id) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const response = await fromServer.responses.get(json.id)!
|
||||||
|
this.client.receive(response as jsrpc.JSONRPCResponse)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
this.#fromServer = fromServer
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
// process "window/logMessage": client <- server
|
||||||
|
this.addMethod(LSP.LogMessageNotification.type.method, (params) => {
|
||||||
|
const { type, message } = params as {
|
||||||
|
type: LSP.MessageType
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
let messageString = ''
|
||||||
|
switch (type) {
|
||||||
|
case LSP.MessageType.Error: {
|
||||||
|
messageString += '[error] '
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case LSP.MessageType.Warning: {
|
||||||
|
messageString += ' [warn] '
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case LSP.MessageType.Info: {
|
||||||
|
messageString += ' [info] '
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case LSP.MessageType.Log: {
|
||||||
|
messageString += ' [log] '
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messageString += message
|
||||||
|
// console.log(messageString)
|
||||||
|
return
|
||||||
|
})
|
||||||
|
|
||||||
|
// process "client/registerCapability": client <- server
|
||||||
|
this.addMethod(LSP.RegistrationRequest.type.method, (params) => {
|
||||||
|
// Register a server capability.
|
||||||
|
params.registrations.forEach(
|
||||||
|
(capabilityRegistration: LSP.Registration) => {
|
||||||
|
this.serverCapabilities = registerServerCapability(
|
||||||
|
this.serverCapabilities,
|
||||||
|
capabilityRegistration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// process "client/unregisterCapability": client <- server
|
||||||
|
this.addMethod(LSP.UnregistrationRequest.type.method, (params) => {
|
||||||
|
// Unregister a server capability.
|
||||||
|
params.unregisterations.forEach(
|
||||||
|
(capabilityUnregistration: LSP.Unregistration) => {
|
||||||
|
this.serverCapabilities = unregisterServerCapability(
|
||||||
|
this.serverCapabilities,
|
||||||
|
capabilityUnregistration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// request "initialize": client <-> server
|
||||||
|
const { capabilities } = await this.request(
|
||||||
|
LSP.InitializeRequest.type.method,
|
||||||
|
{
|
||||||
|
processId: null,
|
||||||
|
clientInfo: {
|
||||||
|
name: 'kcl-language-client',
|
||||||
|
},
|
||||||
|
capabilities: client_capabilities,
|
||||||
|
rootUri: null,
|
||||||
|
} as LSP.InitializeParams
|
||||||
|
)
|
||||||
|
|
||||||
|
this.serverCapabilities = capabilities
|
||||||
|
|
||||||
|
// notify "initialized": client --> server
|
||||||
|
this.notify(LSP.InitializedNotification.type.method, {})
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
this.afterInitializedHooks.map((f: () => Promise<void>) => f())
|
||||||
|
)
|
||||||
|
await Promise.all([this.processNotifications(), this.processRequests()])
|
||||||
|
}
|
||||||
|
|
||||||
|
getServerCapabilities(): LSP.ServerCapabilities<any> {
|
||||||
|
return this.serverCapabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
async processNotifications(): Promise<void> {
|
||||||
|
for await (const notification of this.#fromServer.notifications) {
|
||||||
|
await this.receiveAndSend(notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async processRequests(): Promise<void> {
|
||||||
|
for await (const request of this.#fromServer.requests) {
|
||||||
|
await this.receiveAndSend(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushAfterInitializeHook(...hooks: (() => Promise<void>)[]): void {
|
||||||
|
this.afterInitializedHooks.push(...hooks)
|
||||||
|
}
|
||||||
|
}
|
53
src/editor/lsp/codec.ts
Normal file
53
src/editor/lsp/codec.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import * as jsrpc from 'json-rpc-2.0'
|
||||||
|
import * as vsrpc from 'vscode-jsonrpc'
|
||||||
|
|
||||||
|
import Bytes from './codec/bytes'
|
||||||
|
import StreamDemuxer from './codec/demuxer'
|
||||||
|
import Headers from './codec/headers'
|
||||||
|
import Queue from './codec/queue'
|
||||||
|
import Tracer from './tracer'
|
||||||
|
|
||||||
|
export const encoder = new TextEncoder()
|
||||||
|
export const decoder = new TextDecoder()
|
||||||
|
|
||||||
|
export class Codec {
|
||||||
|
static encode(
|
||||||
|
json: jsrpc.JSONRPCRequest | jsrpc.JSONRPCResponse
|
||||||
|
): Uint8Array {
|
||||||
|
const message = JSON.stringify(json)
|
||||||
|
const delimited = Headers.add(message)
|
||||||
|
return Bytes.encode(delimited)
|
||||||
|
}
|
||||||
|
|
||||||
|
static decode<T>(data: Uint8Array): T {
|
||||||
|
const delimited = Bytes.decode(data)
|
||||||
|
const message = Headers.remove(delimited)
|
||||||
|
return JSON.parse(message) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: tracing effiency
|
||||||
|
export class IntoServer
|
||||||
|
extends Queue<Uint8Array>
|
||||||
|
implements AsyncGenerator<Uint8Array, never, void>
|
||||||
|
{
|
||||||
|
enqueue(item: Uint8Array): void {
|
||||||
|
Tracer.client(Headers.remove(decoder.decode(item)))
|
||||||
|
super.enqueue(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FromServer extends WritableStream<Uint8Array> {
|
||||||
|
readonly responses: {
|
||||||
|
get(key: number | string): null | Promise<vsrpc.ResponseMessage>
|
||||||
|
}
|
||||||
|
readonly notifications: AsyncGenerator<vsrpc.NotificationMessage, never, void>
|
||||||
|
readonly requests: AsyncGenerator<vsrpc.RequestMessage, never, void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
export namespace FromServer {
|
||||||
|
export function create(): FromServer {
|
||||||
|
return new StreamDemuxer()
|
||||||
|
}
|
||||||
|
}
|
27
src/editor/lsp/codec/bytes.ts
Normal file
27
src/editor/lsp/codec/bytes.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { encoder, decoder } from '../codec'
|
||||||
|
|
||||||
|
export default class Bytes {
|
||||||
|
static encode(input: string): Uint8Array {
|
||||||
|
return encoder.encode(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
static decode(input: Uint8Array): string {
|
||||||
|
return decoder.decode(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
static append<
|
||||||
|
T extends { length: number; set(arr: T, offset: number): void }
|
||||||
|
>(constructor: { new (length: number): T }, ...arrays: T[]) {
|
||||||
|
let totalLength = 0
|
||||||
|
for (const arr of arrays) {
|
||||||
|
totalLength += arr.length
|
||||||
|
}
|
||||||
|
const result = new constructor(totalLength)
|
||||||
|
let offset = 0
|
||||||
|
for (const arr of arrays) {
|
||||||
|
result.set(arr, offset)
|
||||||
|
offset += arr.length
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
82
src/editor/lsp/codec/demuxer.ts
Normal file
82
src/editor/lsp/codec/demuxer.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import * as vsrpc from 'vscode-jsonrpc'
|
||||||
|
|
||||||
|
import Bytes from './bytes'
|
||||||
|
import PromiseMap from './map'
|
||||||
|
import Queue from './queue'
|
||||||
|
import Tracer from '../tracer'
|
||||||
|
|
||||||
|
export default class StreamDemuxer extends Queue<Uint8Array> {
|
||||||
|
readonly responses: PromiseMap<number | string, vsrpc.ResponseMessage> =
|
||||||
|
new PromiseMap()
|
||||||
|
readonly notifications: Queue<vsrpc.NotificationMessage> =
|
||||||
|
new Queue<vsrpc.NotificationMessage>()
|
||||||
|
readonly requests: Queue<vsrpc.RequestMessage> =
|
||||||
|
new Queue<vsrpc.RequestMessage>()
|
||||||
|
|
||||||
|
readonly #start: Promise<void>
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.#start = this.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async start(): Promise<void> {
|
||||||
|
let contentLength: null | number = null
|
||||||
|
let buffer = new Uint8Array()
|
||||||
|
|
||||||
|
for await (const bytes of this) {
|
||||||
|
buffer = Bytes.append(Uint8Array, buffer, bytes)
|
||||||
|
while (buffer.length > 0) {
|
||||||
|
// check if the content length is known
|
||||||
|
if (null == contentLength) {
|
||||||
|
// if not, try to match the prefixed headers
|
||||||
|
const match = Bytes.decode(buffer).match(
|
||||||
|
/^Content-Length:\s*(\d+)\s*/
|
||||||
|
)
|
||||||
|
if (null == match) continue
|
||||||
|
|
||||||
|
// try to parse the content-length from the headers
|
||||||
|
const length = parseInt(match[1])
|
||||||
|
if (isNaN(length)) throw new Error('invalid content length')
|
||||||
|
|
||||||
|
// slice the headers since we now have the content length
|
||||||
|
buffer = buffer.slice(match[0].length)
|
||||||
|
|
||||||
|
// set the content length
|
||||||
|
contentLength = length
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the buffer doesn't contain a full message; await another iteration
|
||||||
|
if (buffer.length < contentLength) continue
|
||||||
|
|
||||||
|
// Get just the slice of the buffer that is our content length.
|
||||||
|
const slice = buffer.slice(0, contentLength)
|
||||||
|
|
||||||
|
// decode buffer to a string
|
||||||
|
const delimited = Bytes.decode(slice)
|
||||||
|
|
||||||
|
// reset the buffer
|
||||||
|
buffer = buffer.slice(contentLength)
|
||||||
|
// reset the contentLength
|
||||||
|
contentLength = null
|
||||||
|
|
||||||
|
const message = JSON.parse(delimited) as vsrpc.Message
|
||||||
|
Tracer.server(message)
|
||||||
|
|
||||||
|
// demux the message stream
|
||||||
|
if (vsrpc.Message.isResponse(message) && null != message.id) {
|
||||||
|
this.responses.set(message.id, message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (vsrpc.Message.isNotification(message)) {
|
||||||
|
this.notifications.enqueue(message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (vsrpc.Message.isRequest(message)) {
|
||||||
|
this.requests.enqueue(message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
src/editor/lsp/codec/headers.ts
Normal file
9
src/editor/lsp/codec/headers.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export default class Headers {
|
||||||
|
static add(message: string): string {
|
||||||
|
return `Content-Length: ${message.length}\r\n\r\n${message}`
|
||||||
|
}
|
||||||
|
|
||||||
|
static remove(delimited: string): string {
|
||||||
|
return delimited.replace(/^Content-Length:\s*\d+\s*/, '')
|
||||||
|
}
|
||||||
|
}
|
72
src/editor/lsp/codec/map.ts
Normal file
72
src/editor/lsp/codec/map.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
export default class PromiseMap<K, V extends { toString(): string }> {
|
||||||
|
#map: Map<K, PromiseMap.Entry<V>> = new Map()
|
||||||
|
|
||||||
|
get(key: K & { toString(): string }): null | Promise<V> {
|
||||||
|
let initialized: PromiseMap.Entry<V>
|
||||||
|
// if the entry doesn't exist, set it
|
||||||
|
if (!this.#map.has(key)) {
|
||||||
|
initialized = this.#set(key)
|
||||||
|
} else {
|
||||||
|
// otherwise return the entry
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
initialized = this.#map.get(key)!
|
||||||
|
}
|
||||||
|
// if the entry is a pending promise, return it
|
||||||
|
if (initialized.status === 'pending') {
|
||||||
|
return initialized.promise
|
||||||
|
} else {
|
||||||
|
// otherwise return null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#set(key: K, value?: V): PromiseMap.Entry<V> {
|
||||||
|
if (this.#map.has(key)) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
return this.#map.get(key)!
|
||||||
|
}
|
||||||
|
// placeholder resolver for entry
|
||||||
|
let resolve = (item: V) => {
|
||||||
|
void item
|
||||||
|
}
|
||||||
|
// promise for entry (which assigns the resolver
|
||||||
|
const promise = new Promise<V>((resolver) => {
|
||||||
|
resolve = resolver
|
||||||
|
})
|
||||||
|
// the initialized entry
|
||||||
|
const initialized: PromiseMap.Entry<V> = {
|
||||||
|
status: 'pending',
|
||||||
|
resolve,
|
||||||
|
promise,
|
||||||
|
}
|
||||||
|
if (null != value) {
|
||||||
|
initialized.resolve(value)
|
||||||
|
}
|
||||||
|
// set the entry
|
||||||
|
this.#map.set(key, initialized)
|
||||||
|
return initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: K & { toString(): string }, value: V): this {
|
||||||
|
const initialized = this.#set(key, value)
|
||||||
|
// if the promise is pending ...
|
||||||
|
if (initialized.status === 'pending') {
|
||||||
|
// ... set the entry status to resolved to free the promise
|
||||||
|
this.#map.set(key, { status: 'resolved' })
|
||||||
|
// ... and resolve the promise with the given value
|
||||||
|
initialized.resolve(value)
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.#map.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
export namespace PromiseMap {
|
||||||
|
export type Entry<V> =
|
||||||
|
| { status: 'pending'; resolve: (item: V) => void; promise: Promise<V> }
|
||||||
|
| { status: 'resolved' }
|
||||||
|
}
|
113
src/editor/lsp/codec/queue.ts
Normal file
113
src/editor/lsp/codec/queue.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
export default class Queue<T>
|
||||||
|
implements WritableStream<T>, AsyncGenerator<T, never, void>
|
||||||
|
{
|
||||||
|
readonly #promises: Promise<T>[] = []
|
||||||
|
readonly #resolvers: ((item: T) => void)[] = []
|
||||||
|
readonly #observers: ((item: T) => void)[] = []
|
||||||
|
|
||||||
|
#closed = false
|
||||||
|
#locked = false
|
||||||
|
readonly #stream: WritableStream<T>
|
||||||
|
|
||||||
|
static #__add<X>(
|
||||||
|
promises: Promise<X>[],
|
||||||
|
resolvers: ((item: X) => void)[]
|
||||||
|
): void {
|
||||||
|
promises.push(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolvers.push(resolve)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static #__enqueue<X>(
|
||||||
|
closed: boolean,
|
||||||
|
promises: Promise<X>[],
|
||||||
|
resolvers: ((item: X) => void)[],
|
||||||
|
item: X
|
||||||
|
): void {
|
||||||
|
if (!closed) {
|
||||||
|
if (!resolvers.length) Queue.#__add(promises, resolvers)
|
||||||
|
const resolve = resolvers.shift()! // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||||
|
resolve(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const closed = this.#closed
|
||||||
|
const promises = this.#promises
|
||||||
|
const resolvers = this.#resolvers
|
||||||
|
this.#stream = new WritableStream({
|
||||||
|
write(item: T): void {
|
||||||
|
Queue.#__enqueue(closed, promises, resolvers, item)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#add(): void {
|
||||||
|
return Queue.#__add(this.#promises, this.#resolvers)
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(item: T): void {
|
||||||
|
return Queue.#__enqueue(this.#closed, this.#promises, this.#resolvers, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
dequeue(): Promise<T> {
|
||||||
|
if (!this.#promises.length) this.#add()
|
||||||
|
const item = this.#promises.shift()! // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
isEmpty(): boolean {
|
||||||
|
return !this.#promises.length
|
||||||
|
}
|
||||||
|
|
||||||
|
isBlocked(): boolean {
|
||||||
|
return !!this.#resolvers.length
|
||||||
|
}
|
||||||
|
|
||||||
|
get length(): number {
|
||||||
|
return this.#promises.length - this.#resolvers.length
|
||||||
|
}
|
||||||
|
|
||||||
|
async next(): Promise<IteratorResult<T, never>> {
|
||||||
|
const done = false
|
||||||
|
const value = await this.dequeue()
|
||||||
|
for (const observer of this.#observers) {
|
||||||
|
observer(value)
|
||||||
|
}
|
||||||
|
return { done, value }
|
||||||
|
}
|
||||||
|
|
||||||
|
return(): Promise<IteratorResult<T, never>> {
|
||||||
|
return new Promise(() => {
|
||||||
|
// empty
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw(err: Error): Promise<IteratorResult<T, never>> {
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.asyncIterator](): AsyncGenerator<T, never, void> {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
get locked(): boolean {
|
||||||
|
return this.#stream.locked
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(reason?: Error): Promise<void> {
|
||||||
|
return this.#stream.abort(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): Promise<void> {
|
||||||
|
return this.#stream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
getWriter(): WritableStreamDefaultWriter<T> {
|
||||||
|
return this.#stream.getWriter()
|
||||||
|
}
|
||||||
|
}
|
151
src/editor/lsp/index.ts
Normal file
151
src/editor/lsp/index.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import type * as LSP from 'vscode-languageserver-protocol'
|
||||||
|
import Client from './client'
|
||||||
|
import { LanguageServerPlugin } from './plugin'
|
||||||
|
import { SemanticToken, deserializeTokens } from './semantic_tokens'
|
||||||
|
|
||||||
|
// https://microsoft.github.io/language-server-protocol/specifications/specification-current/
|
||||||
|
|
||||||
|
// Client to server then server to client
|
||||||
|
interface LSPRequestMap {
|
||||||
|
initialize: [LSP.InitializeParams, LSP.InitializeResult]
|
||||||
|
'textDocument/hover': [LSP.HoverParams, LSP.Hover]
|
||||||
|
'textDocument/completion': [
|
||||||
|
LSP.CompletionParams,
|
||||||
|
LSP.CompletionItem[] | LSP.CompletionList | null
|
||||||
|
]
|
||||||
|
'textDocument/semanticTokens/full': [
|
||||||
|
LSP.SemanticTokensParams,
|
||||||
|
LSP.SemanticTokens
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client to server
|
||||||
|
interface LSPNotifyMap {
|
||||||
|
initialized: LSP.InitializedParams
|
||||||
|
'textDocument/didChange': LSP.DidChangeTextDocumentParams
|
||||||
|
'textDocument/didOpen': LSP.DidOpenTextDocumentParams
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server to client
|
||||||
|
interface LSPEventMap {
|
||||||
|
'textDocument/publishDiagnostics': LSP.PublishDiagnosticsParams
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Notification = {
|
||||||
|
[key in keyof LSPEventMap]: {
|
||||||
|
jsonrpc: '2.0'
|
||||||
|
id?: null | undefined
|
||||||
|
method: key
|
||||||
|
params: LSPEventMap[key]
|
||||||
|
}
|
||||||
|
}[keyof LSPEventMap]
|
||||||
|
|
||||||
|
export interface LanguageServerClientOptions {
|
||||||
|
client: Client
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LanguageServerClient {
|
||||||
|
private client: Client
|
||||||
|
|
||||||
|
public ready: boolean
|
||||||
|
|
||||||
|
private plugins: LanguageServerPlugin[]
|
||||||
|
|
||||||
|
public initializePromise: Promise<void>
|
||||||
|
|
||||||
|
private isUpdatingSemanticTokens: boolean = false
|
||||||
|
private semanticTokens: SemanticToken[] = []
|
||||||
|
|
||||||
|
constructor(options: LanguageServerClientOptions) {
|
||||||
|
this.plugins = []
|
||||||
|
this.client = options.client
|
||||||
|
|
||||||
|
this.ready = false
|
||||||
|
|
||||||
|
this.initializePromise = this.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
// Start the client in the background.
|
||||||
|
this.client.start()
|
||||||
|
|
||||||
|
this.ready = true
|
||||||
|
}
|
||||||
|
|
||||||
|
getServerCapabilities(): LSP.ServerCapabilities<any> {
|
||||||
|
return this.client.getServerCapabilities()
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {}
|
||||||
|
|
||||||
|
textDocumentDidOpen(params: LSP.DidOpenTextDocumentParams) {
|
||||||
|
this.notify('textDocument/didOpen', params)
|
||||||
|
|
||||||
|
this.updateSemanticTokens(params.textDocument.uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
textDocumentDidChange(params: LSP.DidChangeTextDocumentParams) {
|
||||||
|
this.notify('textDocument/didChange', params)
|
||||||
|
this.updateSemanticTokens(params.textDocument.uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSemanticTokens(uri: string) {
|
||||||
|
// Make sure we can only run, if we aren't already running.
|
||||||
|
if (!this.isUpdatingSemanticTokens) {
|
||||||
|
this.isUpdatingSemanticTokens = true
|
||||||
|
|
||||||
|
const result = await this.request('textDocument/semanticTokens/full', {
|
||||||
|
textDocument: {
|
||||||
|
uri,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.semanticTokens = deserializeTokens(
|
||||||
|
result.data,
|
||||||
|
this.getServerCapabilities().semanticTokensProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
this.isUpdatingSemanticTokens = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSemanticTokens(): SemanticToken[] {
|
||||||
|
return this.semanticTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
async textDocumentHover(params: LSP.HoverParams) {
|
||||||
|
return await this.request('textDocument/hover', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
async textDocumentCompletion(params: LSP.CompletionParams) {
|
||||||
|
return await this.request('textDocument/completion', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
attachPlugin(plugin: LanguageServerPlugin) {
|
||||||
|
this.plugins.push(plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
detachPlugin(plugin: LanguageServerPlugin) {
|
||||||
|
const i = this.plugins.indexOf(plugin)
|
||||||
|
if (i === -1) return
|
||||||
|
this.plugins.splice(i, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private request<K extends keyof LSPRequestMap>(
|
||||||
|
method: K,
|
||||||
|
params: LSPRequestMap[K][0]
|
||||||
|
): Promise<LSPRequestMap[K][1]> {
|
||||||
|
return this.client.request(method, params) as Promise<LSPRequestMap[K][1]>
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify<K extends keyof LSPNotifyMap>(
|
||||||
|
method: K,
|
||||||
|
params: LSPNotifyMap[K]
|
||||||
|
): void {
|
||||||
|
return this.client.notify(method, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
private processNotification(notification: Notification) {
|
||||||
|
for (const plugin of this.plugins) plugin.processNotification(notification)
|
||||||
|
}
|
||||||
|
}
|
36
src/editor/lsp/language.ts
Normal file
36
src/editor/lsp/language.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// Code mirror language implementation for kcl.
|
||||||
|
|
||||||
|
import {
|
||||||
|
Language,
|
||||||
|
defineLanguageFacet,
|
||||||
|
LanguageSupport,
|
||||||
|
} from '@codemirror/language'
|
||||||
|
import { LanguageServerClient } from '.'
|
||||||
|
import { kclPlugin } from './plugin'
|
||||||
|
import type * as LSP from 'vscode-languageserver-protocol'
|
||||||
|
import { parser as jsParser } from '@lezer/javascript'
|
||||||
|
|
||||||
|
const data = defineLanguageFacet({})
|
||||||
|
|
||||||
|
export interface LanguageOptions {
|
||||||
|
workspaceFolders: LSP.WorkspaceFolder[] | null
|
||||||
|
documentUri: string
|
||||||
|
client: LanguageServerClient
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function kclLanguage(options: LanguageOptions): LanguageSupport {
|
||||||
|
// For now let's use the javascript parser.
|
||||||
|
// It works really well and has good syntax highlighting.
|
||||||
|
// We can use our lsp for the rest.
|
||||||
|
const lang = new Language(data, jsParser, [], 'kcl')
|
||||||
|
|
||||||
|
// Create our supporting extension.
|
||||||
|
const kclLsp = kclPlugin({
|
||||||
|
documentUri: options.documentUri,
|
||||||
|
workspaceFolders: options.workspaceFolders,
|
||||||
|
allowHTMLContent: true,
|
||||||
|
client: options.client,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new LanguageSupport(lang, [kclLsp])
|
||||||
|
}
|
168
src/editor/lsp/parser.ts
Normal file
168
src/editor/lsp/parser.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
// Extends the codemirror Parser for kcl.
|
||||||
|
|
||||||
|
import {
|
||||||
|
Parser,
|
||||||
|
Input,
|
||||||
|
TreeFragment,
|
||||||
|
PartialParse,
|
||||||
|
Tree,
|
||||||
|
NodeType,
|
||||||
|
NodeSet,
|
||||||
|
} from '@lezer/common'
|
||||||
|
import { LanguageServerClient } from '.'
|
||||||
|
import { posToOffset } from './plugin'
|
||||||
|
import { SemanticToken } from './semantic_tokens'
|
||||||
|
import { DocInput } from '@codemirror/language'
|
||||||
|
import { tags, styleTags } from '@lezer/highlight'
|
||||||
|
|
||||||
|
export default class KclParser extends Parser {
|
||||||
|
private client: LanguageServerClient
|
||||||
|
|
||||||
|
constructor(client: LanguageServerClient) {
|
||||||
|
super()
|
||||||
|
this.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
createParse(
|
||||||
|
input: Input,
|
||||||
|
fragments: readonly TreeFragment[],
|
||||||
|
ranges: readonly { from: number; to: number }[]
|
||||||
|
): PartialParse {
|
||||||
|
let parse: PartialParse = new Context(this, input, fragments, ranges)
|
||||||
|
return parse
|
||||||
|
}
|
||||||
|
|
||||||
|
getTokenTypes(): string[] {
|
||||||
|
return this.client.getServerCapabilities().semanticTokensProvider!.legend
|
||||||
|
.tokenTypes
|
||||||
|
}
|
||||||
|
|
||||||
|
getSemanticTokens(): SemanticToken[] {
|
||||||
|
return this.client.getSemanticTokens()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Context implements PartialParse {
|
||||||
|
private parser: KclParser
|
||||||
|
private input: DocInput
|
||||||
|
private fragments: readonly TreeFragment[]
|
||||||
|
private ranges: readonly { from: number; to: number }[]
|
||||||
|
|
||||||
|
private nodeTypes: { [key: string]: NodeType }
|
||||||
|
stoppedAt: number = 0
|
||||||
|
|
||||||
|
private semanticTokens: SemanticToken[] = []
|
||||||
|
private currentLine: number = 0
|
||||||
|
private currentColumn: number = 0
|
||||||
|
private nodeSet: NodeSet
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
/// The parser configuration used.
|
||||||
|
parser: KclParser,
|
||||||
|
input: Input,
|
||||||
|
fragments: readonly TreeFragment[],
|
||||||
|
ranges: readonly { from: number; to: number }[]
|
||||||
|
) {
|
||||||
|
this.parser = parser
|
||||||
|
this.input = input as DocInput
|
||||||
|
this.fragments = fragments
|
||||||
|
this.ranges = ranges
|
||||||
|
|
||||||
|
// Iterate over the semantic token types and create a node type for each.
|
||||||
|
this.nodeTypes = {}
|
||||||
|
let nodeArray: NodeType[] = []
|
||||||
|
this.parser.getTokenTypes().forEach((tokenType, index) => {
|
||||||
|
const nodeType = NodeType.define({
|
||||||
|
id: index,
|
||||||
|
name: tokenType,
|
||||||
|
// props: [this.styleTags],
|
||||||
|
})
|
||||||
|
this.nodeTypes[tokenType] = nodeType
|
||||||
|
nodeArray.push(nodeType)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.semanticTokens = this.parser.getSemanticTokens()
|
||||||
|
const styles = styleTags({
|
||||||
|
number: tags.number,
|
||||||
|
variable: tags.variableName,
|
||||||
|
operator: tags.operator,
|
||||||
|
keyword: tags.keyword,
|
||||||
|
string: tags.string,
|
||||||
|
comment: tags.comment,
|
||||||
|
function: tags.function(tags.variableName),
|
||||||
|
})
|
||||||
|
this.nodeSet = new NodeSet(nodeArray).extend(styles)
|
||||||
|
}
|
||||||
|
|
||||||
|
get parsedPos(): number {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
advance(): Tree | null {
|
||||||
|
if (this.semanticTokens.length === 0) {
|
||||||
|
return new Tree(NodeType.none, [], [], 0)
|
||||||
|
}
|
||||||
|
const tree = this.createTree(this.semanticTokens[0], 0)
|
||||||
|
this.stoppedAt = this.input.doc.length
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
createTree(token: SemanticToken, index: number): Tree {
|
||||||
|
const changedLine = token.delta_line !== 0
|
||||||
|
this.currentLine += token.delta_line
|
||||||
|
if (changedLine) {
|
||||||
|
this.currentColumn = 0
|
||||||
|
}
|
||||||
|
this.currentColumn += token.delta_start
|
||||||
|
|
||||||
|
// Let's get our position relative to the start of the file.
|
||||||
|
let currentPosition = posToOffset(this.input.doc, {
|
||||||
|
line: this.currentLine,
|
||||||
|
character: this.currentColumn,
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeType = this.nodeSet.types[this.nodeTypes[token.token_type].id]
|
||||||
|
|
||||||
|
if (currentPosition === undefined) {
|
||||||
|
// This is bad and weird.
|
||||||
|
return new Tree(nodeType, [], [], token.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= this.semanticTokens.length - 1) {
|
||||||
|
// We have no children.
|
||||||
|
return new Tree(nodeType, [], [], token.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndex = index + 1
|
||||||
|
const nextToken = this.semanticTokens[nextIndex]
|
||||||
|
const changedLineNext = nextToken.delta_line !== 0
|
||||||
|
const nextLine = this.currentLine + nextToken.delta_line
|
||||||
|
const nextColumn = changedLineNext
|
||||||
|
? nextToken.delta_start
|
||||||
|
: this.currentColumn + nextToken.delta_start
|
||||||
|
const nextPosition = posToOffset(this.input.doc, {
|
||||||
|
line: nextLine,
|
||||||
|
character: nextColumn,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (nextPosition === undefined) {
|
||||||
|
// This is bad and weird.
|
||||||
|
return new Tree(nodeType, [], [], token.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let's get the
|
||||||
|
|
||||||
|
return new Tree(
|
||||||
|
nodeType,
|
||||||
|
[this.createTree(nextToken, nextIndex)],
|
||||||
|
|
||||||
|
// The positions (offsets relative to the start of this tree) of the children.
|
||||||
|
[nextPosition - currentPosition],
|
||||||
|
token.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAt(pos: number) {
|
||||||
|
this.stoppedAt = pos
|
||||||
|
}
|
||||||
|
}
|
360
src/editor/lsp/plugin.ts
Normal file
360
src/editor/lsp/plugin.ts
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
import { autocompletion, completeFromList } from '@codemirror/autocomplete'
|
||||||
|
import { setDiagnostics } from '@codemirror/lint'
|
||||||
|
import { Facet } from '@codemirror/state'
|
||||||
|
import {
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
Tooltip,
|
||||||
|
hoverTooltip,
|
||||||
|
tooltips,
|
||||||
|
} from '@codemirror/view'
|
||||||
|
import {
|
||||||
|
DiagnosticSeverity,
|
||||||
|
CompletionItemKind,
|
||||||
|
CompletionTriggerKind,
|
||||||
|
} from 'vscode-languageserver-protocol'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Completion,
|
||||||
|
CompletionContext,
|
||||||
|
CompletionResult,
|
||||||
|
} from '@codemirror/autocomplete'
|
||||||
|
import type { PublishDiagnosticsParams } from 'vscode-languageserver-protocol'
|
||||||
|
import type { ViewUpdate, PluginValue } from '@codemirror/view'
|
||||||
|
import type { Text } from '@codemirror/state'
|
||||||
|
import type * as LSP from 'vscode-languageserver-protocol'
|
||||||
|
import { LanguageServerClient, Notification } from '.'
|
||||||
|
import { Marked } from '@ts-stack/markdown'
|
||||||
|
|
||||||
|
const changesDelay = 500
|
||||||
|
|
||||||
|
const CompletionItemKindMap = Object.fromEntries(
|
||||||
|
Object.entries(CompletionItemKind).map(([key, value]) => [value, key])
|
||||||
|
) as Record<CompletionItemKind, string>
|
||||||
|
|
||||||
|
const useLast = (values: readonly any[]) => values.reduce((_, v) => v, '')
|
||||||
|
const documentUri = Facet.define<string, string>({ combine: useLast })
|
||||||
|
const languageId = Facet.define<string, string>({ combine: useLast })
|
||||||
|
const client = Facet.define<LanguageServerClient, LanguageServerClient>({
|
||||||
|
combine: useLast,
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface LanguageServerOptions {
|
||||||
|
workspaceFolders: LSP.WorkspaceFolder[] | null
|
||||||
|
documentUri: string
|
||||||
|
allowHTMLContent: boolean
|
||||||
|
client: LanguageServerClient
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LanguageServerPlugin implements PluginValue {
|
||||||
|
public client: LanguageServerClient
|
||||||
|
|
||||||
|
private documentUri: string
|
||||||
|
private languageId: string
|
||||||
|
private documentVersion: number
|
||||||
|
|
||||||
|
private changesTimeout: number
|
||||||
|
|
||||||
|
constructor(private view: EditorView, private allowHTMLContent: boolean) {
|
||||||
|
this.client = this.view.state.facet(client)
|
||||||
|
this.documentUri = this.view.state.facet(documentUri)
|
||||||
|
this.languageId = this.view.state.facet(languageId)
|
||||||
|
this.documentVersion = 0
|
||||||
|
this.changesTimeout = 0
|
||||||
|
|
||||||
|
this.client.attachPlugin(this)
|
||||||
|
|
||||||
|
this.initialize({
|
||||||
|
documentText: this.view.state.doc.toString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
update({ docChanged }: ViewUpdate) {
|
||||||
|
if (!docChanged) return
|
||||||
|
if (this.changesTimeout) clearTimeout(this.changesTimeout)
|
||||||
|
this.changesTimeout = window.setTimeout(() => {
|
||||||
|
this.sendChange({
|
||||||
|
documentText: this.view.state.doc.toString(),
|
||||||
|
})
|
||||||
|
}, changesDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.client.detachPlugin(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize({ documentText }: { documentText: string }) {
|
||||||
|
if (this.client.initializePromise) {
|
||||||
|
await this.client.initializePromise
|
||||||
|
}
|
||||||
|
this.client.textDocumentDidOpen({
|
||||||
|
textDocument: {
|
||||||
|
uri: this.documentUri,
|
||||||
|
languageId: this.languageId,
|
||||||
|
text: documentText,
|
||||||
|
version: this.documentVersion,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendChange({ documentText }: { documentText: string }) {
|
||||||
|
if (!this.client.ready) return
|
||||||
|
try {
|
||||||
|
await this.client.textDocumentDidChange({
|
||||||
|
textDocument: {
|
||||||
|
uri: this.documentUri,
|
||||||
|
version: this.documentVersion++,
|
||||||
|
},
|
||||||
|
contentChanges: [{ text: documentText }],
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestDiagnostics(view: EditorView) {
|
||||||
|
this.sendChange({ documentText: view.state.doc.toString() })
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestHoverTooltip(
|
||||||
|
view: EditorView,
|
||||||
|
{ line, character }: { line: number; character: number }
|
||||||
|
): Promise<Tooltip | null> {
|
||||||
|
if (
|
||||||
|
!this.client.ready ||
|
||||||
|
!this.client.getServerCapabilities().hoverProvider
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
|
||||||
|
this.sendChange({ documentText: view.state.doc.toString() })
|
||||||
|
const result = await this.client.textDocumentHover({
|
||||||
|
textDocument: { uri: this.documentUri },
|
||||||
|
position: { line, character },
|
||||||
|
})
|
||||||
|
if (!result) return null
|
||||||
|
const { contents, range } = result
|
||||||
|
let pos = posToOffset(view.state.doc, { line, character })!
|
||||||
|
let end: number | undefined
|
||||||
|
if (range) {
|
||||||
|
pos = posToOffset(view.state.doc, range.start)!
|
||||||
|
end = posToOffset(view.state.doc, range.end)
|
||||||
|
}
|
||||||
|
if (pos === null) return null
|
||||||
|
const dom = document.createElement('div')
|
||||||
|
dom.classList.add('documentation')
|
||||||
|
if (this.allowHTMLContent) dom.innerHTML = formatContents(contents)
|
||||||
|
else dom.textContent = formatContents(contents)
|
||||||
|
return { pos, end, create: (view) => ({ dom }), above: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestCompletion(
|
||||||
|
context: CompletionContext,
|
||||||
|
{ line, character }: { line: number; character: number },
|
||||||
|
{
|
||||||
|
triggerKind,
|
||||||
|
triggerCharacter,
|
||||||
|
}: {
|
||||||
|
triggerKind: CompletionTriggerKind
|
||||||
|
triggerCharacter: string | undefined
|
||||||
|
}
|
||||||
|
): Promise<CompletionResult | null> {
|
||||||
|
if (
|
||||||
|
!this.client.ready ||
|
||||||
|
!this.client.getServerCapabilities().completionProvider
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
|
||||||
|
this.sendChange({
|
||||||
|
documentText: context.state.doc.toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await this.client.textDocumentCompletion({
|
||||||
|
textDocument: { uri: this.documentUri },
|
||||||
|
position: { line, character },
|
||||||
|
context: {
|
||||||
|
triggerKind,
|
||||||
|
triggerCharacter,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) return null
|
||||||
|
|
||||||
|
const items = 'items' in result ? result.items : result
|
||||||
|
|
||||||
|
let options = items.map(
|
||||||
|
({
|
||||||
|
detail,
|
||||||
|
label,
|
||||||
|
labelDetails,
|
||||||
|
kind,
|
||||||
|
textEdit,
|
||||||
|
documentation,
|
||||||
|
deprecated,
|
||||||
|
insertText,
|
||||||
|
insertTextFormat,
|
||||||
|
sortText,
|
||||||
|
filterText,
|
||||||
|
}) => {
|
||||||
|
const completion: Completion & {
|
||||||
|
filterText: string
|
||||||
|
sortText?: string
|
||||||
|
apply: string
|
||||||
|
} = {
|
||||||
|
label,
|
||||||
|
detail: labelDetails ? labelDetails.detail : detail,
|
||||||
|
apply: label,
|
||||||
|
type: kind && CompletionItemKindMap[kind].toLowerCase(),
|
||||||
|
sortText: sortText ?? label,
|
||||||
|
filterText: filterText ?? label,
|
||||||
|
}
|
||||||
|
if (documentation) {
|
||||||
|
completion.info = () => {
|
||||||
|
const htmlString = formatContents(documentation)
|
||||||
|
const htmlNode = document.createElement('div')
|
||||||
|
htmlNode.style.display = 'contents'
|
||||||
|
htmlNode.innerHTML = htmlString
|
||||||
|
return { dom: htmlNode }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completion
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return completeFromList(options)(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
processNotification(notification: Notification) {
|
||||||
|
try {
|
||||||
|
switch (notification.method) {
|
||||||
|
case 'textDocument/publishDiagnostics':
|
||||||
|
this.processDiagnostics(notification.params)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processDiagnostics(params: PublishDiagnosticsParams) {
|
||||||
|
if (params.uri !== this.documentUri) return
|
||||||
|
|
||||||
|
const diagnostics = params.diagnostics
|
||||||
|
.map(({ range, message, severity }) => ({
|
||||||
|
from: posToOffset(this.view.state.doc, range.start)!,
|
||||||
|
to: posToOffset(this.view.state.doc, range.end)!,
|
||||||
|
severity: (
|
||||||
|
{
|
||||||
|
[DiagnosticSeverity.Error]: 'error',
|
||||||
|
[DiagnosticSeverity.Warning]: 'warning',
|
||||||
|
[DiagnosticSeverity.Information]: 'info',
|
||||||
|
[DiagnosticSeverity.Hint]: 'info',
|
||||||
|
} as const
|
||||||
|
)[severity!],
|
||||||
|
message,
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
({ from, to }) =>
|
||||||
|
from !== null && to !== null && from !== undefined && to !== undefined
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
switch (true) {
|
||||||
|
case a.from < b.from:
|
||||||
|
return -1
|
||||||
|
case a.from > b.from:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
this.view.dispatch(setDiagnostics(this.view.state, diagnostics))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kclPlugin(options: LanguageServerOptions) {
|
||||||
|
let plugin: LanguageServerPlugin | null = null
|
||||||
|
|
||||||
|
return [
|
||||||
|
client.of(options.client),
|
||||||
|
documentUri.of(options.documentUri),
|
||||||
|
languageId.of('kcl'),
|
||||||
|
ViewPlugin.define(
|
||||||
|
(view) =>
|
||||||
|
(plugin = new LanguageServerPlugin(view, options.allowHTMLContent))
|
||||||
|
),
|
||||||
|
hoverTooltip(
|
||||||
|
(view, pos) =>
|
||||||
|
plugin?.requestHoverTooltip(view, offsetToPos(view.state.doc, pos)) ??
|
||||||
|
null
|
||||||
|
),
|
||||||
|
tooltips({
|
||||||
|
position: 'absolute',
|
||||||
|
}),
|
||||||
|
autocompletion({
|
||||||
|
override: [
|
||||||
|
async (context) => {
|
||||||
|
if (plugin == null) return null
|
||||||
|
|
||||||
|
const { state, pos, explicit } = context
|
||||||
|
const line = state.doc.lineAt(pos)
|
||||||
|
let trigKind: CompletionTriggerKind = CompletionTriggerKind.Invoked
|
||||||
|
let trigChar: string | undefined
|
||||||
|
if (
|
||||||
|
!explicit &&
|
||||||
|
plugin.client
|
||||||
|
.getServerCapabilities()
|
||||||
|
.completionProvider?.triggerCharacters?.includes(
|
||||||
|
line.text[pos - line.from - 1]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
trigKind = CompletionTriggerKind.TriggerCharacter
|
||||||
|
trigChar = line.text[pos - line.from - 1]
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
trigKind === CompletionTriggerKind.Invoked &&
|
||||||
|
!context.matchBefore(/\w+$/)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return await plugin.requestCompletion(
|
||||||
|
context,
|
||||||
|
offsetToPos(state.doc, pos),
|
||||||
|
{
|
||||||
|
triggerKind: trigKind,
|
||||||
|
triggerCharacter: trigChar,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function posToOffset(
|
||||||
|
doc: Text,
|
||||||
|
pos: { line: number; character: number }
|
||||||
|
): number | undefined {
|
||||||
|
if (pos.line >= doc.lines) return
|
||||||
|
const offset = doc.line(pos.line + 1).from + pos.character
|
||||||
|
if (offset > doc.length) return
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetToPos(doc: Text, offset: number) {
|
||||||
|
const line = doc.lineAt(offset)
|
||||||
|
return {
|
||||||
|
line: line.number - 1,
|
||||||
|
character: offset - line.from,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatContents(
|
||||||
|
contents: LSP.MarkupContent | LSP.MarkedString | LSP.MarkedString[]
|
||||||
|
): string {
|
||||||
|
if (Array.isArray(contents)) {
|
||||||
|
return contents.map((c) => formatContents(c) + '\n\n').join('')
|
||||||
|
} else if (typeof contents === 'string') {
|
||||||
|
return Marked.parse(contents)
|
||||||
|
} else {
|
||||||
|
return Marked.parse(contents.value)
|
||||||
|
}
|
||||||
|
}
|
51
src/editor/lsp/semantic_tokens.ts
Normal file
51
src/editor/lsp/semantic_tokens.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type * as LSP from 'vscode-languageserver-protocol'
|
||||||
|
|
||||||
|
export class SemanticToken {
|
||||||
|
delta_line: number
|
||||||
|
delta_start: number
|
||||||
|
length: number
|
||||||
|
token_type: string
|
||||||
|
token_modifiers_bitset: string
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
delta_line = 0,
|
||||||
|
delta_start = 0,
|
||||||
|
length = 0,
|
||||||
|
token_type = '',
|
||||||
|
token_modifiers_bitset = ''
|
||||||
|
) {
|
||||||
|
this.delta_line = delta_line
|
||||||
|
this.delta_start = delta_start
|
||||||
|
this.length = length
|
||||||
|
this.token_type = token_type
|
||||||
|
this.token_modifiers_bitset = token_modifiers_bitset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeTokens(
|
||||||
|
data: number[],
|
||||||
|
semanticTokensProvider?: LSP.SemanticTokensOptions
|
||||||
|
): SemanticToken[] {
|
||||||
|
if (!semanticTokensProvider) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
// Check if data length is divisible by 5
|
||||||
|
if (data.length % 5 !== 0) {
|
||||||
|
throw new Error('Length is not divisible by 5')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = []
|
||||||
|
for (let i = 0; i < data.length; i += 5) {
|
||||||
|
tokens.push(
|
||||||
|
new SemanticToken(
|
||||||
|
data[i],
|
||||||
|
data[i + 1],
|
||||||
|
data[i + 2],
|
||||||
|
semanticTokensProvider.legend.tokenTypes[data[i + 3]],
|
||||||
|
semanticTokensProvider.legend.tokenModifiers[data[i + 4]]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
}
|
80
src/editor/lsp/server-capability-registration.ts
Normal file
80
src/editor/lsp/server-capability-registration.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Registration,
|
||||||
|
ServerCapabilities,
|
||||||
|
Unregistration,
|
||||||
|
} from 'vscode-languageserver-protocol'
|
||||||
|
|
||||||
|
interface IFlexibleServerCapabilities extends ServerCapabilities {
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IMethodServerCapabilityProviderDictionary {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServerCapabilitiesProviders: IMethodServerCapabilityProviderDictionary = {
|
||||||
|
'textDocument/hover': 'hoverProvider',
|
||||||
|
'textDocument/completion': 'completionProvider',
|
||||||
|
'textDocument/signatureHelp': 'signatureHelpProvider',
|
||||||
|
'textDocument/definition': 'definitionProvider',
|
||||||
|
'textDocument/typeDefinition': 'typeDefinitionProvider',
|
||||||
|
'textDocument/implementation': 'implementationProvider',
|
||||||
|
'textDocument/references': 'referencesProvider',
|
||||||
|
'textDocument/documentHighlight': 'documentHighlightProvider',
|
||||||
|
'textDocument/documentSymbol': 'documentSymbolProvider',
|
||||||
|
'textDocument/workspaceSymbol': 'workspaceSymbolProvider',
|
||||||
|
'textDocument/codeAction': 'codeActionProvider',
|
||||||
|
'textDocument/codeLens': 'codeLensProvider',
|
||||||
|
'textDocument/documentFormatting': 'documentFormattingProvider',
|
||||||
|
'textDocument/documentRangeFormatting': 'documentRangeFormattingProvider',
|
||||||
|
'textDocument/documentOnTypeFormatting': 'documentOnTypeFormattingProvider',
|
||||||
|
'textDocument/rename': 'renameProvider',
|
||||||
|
'textDocument/documentLink': 'documentLinkProvider',
|
||||||
|
'textDocument/color': 'colorProvider',
|
||||||
|
'textDocument/foldingRange': 'foldingRangeProvider',
|
||||||
|
'textDocument/declaration': 'declarationProvider',
|
||||||
|
'textDocument/executeCommand': 'executeCommandProvider',
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerServerCapability(
|
||||||
|
serverCapabilities: ServerCapabilities,
|
||||||
|
registration: Registration
|
||||||
|
): ServerCapabilities {
|
||||||
|
const serverCapabilitiesCopy = JSON.parse(
|
||||||
|
JSON.stringify(serverCapabilities)
|
||||||
|
) as IFlexibleServerCapabilities
|
||||||
|
const { method, registerOptions } = registration
|
||||||
|
const providerName = ServerCapabilitiesProviders[method]
|
||||||
|
|
||||||
|
if (providerName) {
|
||||||
|
if (!registerOptions) {
|
||||||
|
serverCapabilitiesCopy[providerName] = true
|
||||||
|
} else {
|
||||||
|
serverCapabilitiesCopy[providerName] = Object.assign(
|
||||||
|
{},
|
||||||
|
JSON.parse(JSON.stringify(registerOptions))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Could not register server capability.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverCapabilitiesCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterServerCapability(
|
||||||
|
serverCapabilities: ServerCapabilities,
|
||||||
|
unregistration: Unregistration
|
||||||
|
): ServerCapabilities {
|
||||||
|
const serverCapabilitiesCopy = JSON.parse(
|
||||||
|
JSON.stringify(serverCapabilities)
|
||||||
|
) as IFlexibleServerCapabilities
|
||||||
|
const { method } = unregistration
|
||||||
|
const providerName = ServerCapabilitiesProviders[method]
|
||||||
|
|
||||||
|
delete serverCapabilitiesCopy[providerName]
|
||||||
|
|
||||||
|
return serverCapabilitiesCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
export { registerServerCapability, unregisterServerCapability }
|
42
src/editor/lsp/server.ts
Normal file
42
src/editor/lsp/server.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import init, {
|
||||||
|
InitOutput,
|
||||||
|
lsp_run,
|
||||||
|
ServerConfig,
|
||||||
|
} from '../../wasm-lib/pkg/wasm_lib'
|
||||||
|
import { FromServer, IntoServer } from './codec'
|
||||||
|
|
||||||
|
let server: null | Server
|
||||||
|
|
||||||
|
export default class Server {
|
||||||
|
readonly initOutput: InitOutput
|
||||||
|
readonly #intoServer: IntoServer
|
||||||
|
readonly #fromServer: FromServer
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
initOutput: InitOutput,
|
||||||
|
intoServer: IntoServer,
|
||||||
|
fromServer: FromServer
|
||||||
|
) {
|
||||||
|
this.initOutput = initOutput
|
||||||
|
this.#intoServer = intoServer
|
||||||
|
this.#fromServer = fromServer
|
||||||
|
}
|
||||||
|
|
||||||
|
static async initialize(
|
||||||
|
intoServer: IntoServer,
|
||||||
|
fromServer: FromServer
|
||||||
|
): Promise<Server> {
|
||||||
|
if (null == server) {
|
||||||
|
const initOutput = await init()
|
||||||
|
server = new Server(initOutput, intoServer, fromServer)
|
||||||
|
} else {
|
||||||
|
console.warn('Server already initialized; ignoring')
|
||||||
|
}
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
const config = new ServerConfig(this.#intoServer, this.#fromServer)
|
||||||
|
await lsp_run(config)
|
||||||
|
}
|
||||||
|
}
|
21
src/editor/lsp/tracer.ts
Normal file
21
src/editor/lsp/tracer.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Message } from 'vscode-languageserver-protocol'
|
||||||
|
|
||||||
|
const env = import.meta.env.MODE
|
||||||
|
|
||||||
|
export default class Tracer {
|
||||||
|
static client(message: string): void {
|
||||||
|
// These are really noisy, so we have a special env var for them.
|
||||||
|
if (env === 'lsp_tracing') {
|
||||||
|
console.log('lsp client message', message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static server(input: string | Message): void {
|
||||||
|
// These are really noisy, so we have a special env var for them.
|
||||||
|
if (env === 'lsp_tracing') {
|
||||||
|
const message: string =
|
||||||
|
typeof input === 'string' ? input : JSON.stringify(input)
|
||||||
|
console.log('lsp server message', message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -10,4 +10,5 @@ export const VITE_KC_API_BASE_URL = import.meta.env.VITE_KC_API_BASE_URL
|
|||||||
export const VITE_KC_SITE_BASE_URL = import.meta.env.VITE_KC_SITE_BASE_URL
|
export const VITE_KC_SITE_BASE_URL = import.meta.env.VITE_KC_SITE_BASE_URL
|
||||||
export const VITE_KC_CONNECTION_TIMEOUT_MS = import.meta.env
|
export const VITE_KC_CONNECTION_TIMEOUT_MS = import.meta.env
|
||||||
.VITE_KC_CONNECTION_TIMEOUT_MS
|
.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
|
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()
|
|
||||||
}, [])
|
|
||||||
}
|
|
56
src/hooks/useToolbarGuards.ts
Normal file
56
src/hooks/useToolbarGuards.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { SetVarNameModal } from 'components/SetVarNameModal'
|
||||||
|
import { moveValueIntoNewVariable } from 'lang/modifyAst'
|
||||||
|
import { isNodeSafeToReplace } from 'lang/queryAst'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { create } from 'react-modal-promise'
|
||||||
|
import { useStore } from 'useStore'
|
||||||
|
|
||||||
|
const getModalInfo = create(SetVarNameModal as any)
|
||||||
|
|
||||||
|
export function useConvertToVariable() {
|
||||||
|
const { guiMode, selectionRanges, ast, programMemory, updateAst } = useStore(
|
||||||
|
(s) => ({
|
||||||
|
guiMode: s.guiMode,
|
||||||
|
ast: s.ast,
|
||||||
|
updateAst: s.updateAst,
|
||||||
|
selectionRanges: s.selectionRanges,
|
||||||
|
programMemory: s.programMemory,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const [enable, setEnabled] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ast) return
|
||||||
|
|
||||||
|
const { isSafe, value } = isNodeSafeToReplace(
|
||||||
|
ast,
|
||||||
|
selectionRanges.codeBasedSelections?.[0]?.range || []
|
||||||
|
)
|
||||||
|
const canReplace = isSafe && value.type !== 'Identifier'
|
||||||
|
const isOnlyOneSelection = selectionRanges.codeBasedSelections.length === 1
|
||||||
|
|
||||||
|
const _enableHorz = canReplace && isOnlyOneSelection
|
||||||
|
setEnabled(_enableHorz)
|
||||||
|
}, [guiMode, selectionRanges])
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (!ast) return
|
||||||
|
try {
|
||||||
|
const { variableName } = await getModalInfo({
|
||||||
|
valueName: 'var',
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
const { modifiedAst: _modifiedAst } = moveValueIntoNewVariable(
|
||||||
|
ast,
|
||||||
|
programMemory,
|
||||||
|
selectionRanges.codeBasedSelections[0].range,
|
||||||
|
variableName
|
||||||
|
)
|
||||||
|
|
||||||
|
updateAst(_modifiedAst)
|
||||||
|
} catch (e) {
|
||||||
|
console.log('e', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enable, handleClick }
|
||||||
|
}
|
@ -82,12 +82,36 @@ code {
|
|||||||
monospace;
|
monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.full-height-subtract {
|
||||||
|
--height-subtract: 2.25rem;
|
||||||
|
height: 100%;
|
||||||
|
max-height: calc(100% - var(--height-subtract));
|
||||||
|
}
|
||||||
|
|
||||||
#code-mirror-override .cm-editor {
|
#code-mirror-override .cm-editor {
|
||||||
@apply bg-transparent;
|
@apply h-full bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#code-mirror-override .cm-scroller {
|
||||||
|
@apply h-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
#code-mirror-override .cm-scroller::-webkit-scrollbar {
|
||||||
|
@apply h-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#code-mirror-override .cm-activeLine,
|
||||||
|
#code-mirror-override .cm-activeLineGutter {
|
||||||
|
@apply bg-liquid-10/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark #code-mirror-override .cm-activeLine,
|
||||||
|
.dark #code-mirror-override .cm-activeLineGutter {
|
||||||
|
@apply bg-liquid-80/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
#code-mirror-override .cm-gutters {
|
#code-mirror-override .cm-gutters {
|
||||||
@apply bg-chalkboard-10/50;
|
@apply bg-chalkboard-10/30;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark #code-mirror-override .cm-gutters {
|
.dark #code-mirror-override .cm-gutters {
|
||||||
@ -99,16 +123,68 @@ code {
|
|||||||
}
|
}
|
||||||
#code-mirror-override .cm-cursor {
|
#code-mirror-override .cm-cursor {
|
||||||
display: block;
|
display: block;
|
||||||
width: 200px;
|
width: 1ch;
|
||||||
background: linear-gradient(
|
@apply bg-liquid-40 mix-blend-multiply;
|
||||||
to right,
|
|
||||||
rgb(0, 55, 94) 0%,
|
animation: blink 2s ease-out infinite;
|
||||||
#0084e2ff 2%,
|
}
|
||||||
#0084e255 5%,
|
|
||||||
transparent 100%
|
.dark #code-mirror-override .cm-cursor {
|
||||||
);
|
@apply bg-liquid-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
15% {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-json-view {
|
.react-json-view {
|
||||||
@apply bg-transparent !important;
|
@apply bg-transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#code-mirror-override .cm-tooltip {
|
||||||
|
@apply text-xs shadow-md;
|
||||||
|
@apply bg-chalkboard-10 text-chalkboard-80;
|
||||||
|
@apply rounded-sm border-solid border border-chalkboard-40/30 border-l-liquid-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark #code-mirror-override .cm-tooltip {
|
||||||
|
@apply bg-chalkboard-110 text-chalkboard-40;
|
||||||
|
@apply border-chalkboard-70/20 border-l-liquid-70;
|
||||||
|
}
|
||||||
|
|
||||||
|
#code-mirror-override .cm-tooltip-hover {
|
||||||
|
@apply py-1 px-2 w-max max-w-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
#code-mirror-override .cm-completionInfo {
|
||||||
|
@apply px-4 rounded-l-none;
|
||||||
|
@apply bg-chalkboard-10 text-liquid-90;
|
||||||
|
@apply border-liquid-40/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark #code-mirror-override .cm-completionInfo {
|
||||||
|
@apply bg-liquid-120 text-liquid-50;
|
||||||
|
@apply border-liquid-90/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
#code-mirror-override .cm-tooltip-autocomplete li {
|
||||||
|
@apply px-2 py-1;
|
||||||
|
}
|
||||||
|
#code-mirror-override .cm-tooltip-autocomplete li[aria-selected='true'] {
|
||||||
|
@apply bg-liquid-10 text-liquid-110;
|
||||||
|
}
|
||||||
|
.dark #code-mirror-override .cm-tooltip-autocomplete li[aria-selected='true'] {
|
||||||
|
@apply bg-liquid-100 text-liquid-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
#code-mirror-override .cm-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
@ -2,23 +2,10 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import reportWebVitals from './reportWebVitals'
|
import reportWebVitals from './reportWebVitals'
|
||||||
import { Toaster } from 'react-hot-toast'
|
import { Toaster } from 'react-hot-toast'
|
||||||
import { Themes, useStore } from './useStore'
|
|
||||||
import { Router } from './Router'
|
import { Router } from './Router'
|
||||||
import { HotkeysProvider } from 'react-hotkeys-hook'
|
import { HotkeysProvider } from 'react-hotkeys-hook'
|
||||||
import { getSystemTheme } from './lib/getSystemTheme'
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
|
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(
|
root.render(
|
||||||
<HotkeysProvider>
|
<HotkeysProvider>
|
||||||
|
@ -179,6 +179,9 @@ const newVar = myVar + 1
|
|||||||
name: 'aIdentifier',
|
name: 'aIdentifier',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
function: {
|
||||||
|
type: 'InMemory',
|
||||||
|
},
|
||||||
optional: false,
|
optional: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -211,7 +214,6 @@ describe('testing function declaration', () => {
|
|||||||
type: 'FunctionExpression',
|
type: 'FunctionExpression',
|
||||||
start: 11,
|
start: 11,
|
||||||
end: 19,
|
end: 19,
|
||||||
id: null,
|
|
||||||
params: [],
|
params: [],
|
||||||
body: {
|
body: {
|
||||||
start: 17,
|
start: 17,
|
||||||
@ -250,7 +252,6 @@ describe('testing function declaration', () => {
|
|||||||
type: 'FunctionExpression',
|
type: 'FunctionExpression',
|
||||||
start: 11,
|
start: 11,
|
||||||
end: 39,
|
end: 39,
|
||||||
id: null,
|
|
||||||
params: [
|
params: [
|
||||||
{
|
{
|
||||||
type: 'Identifier',
|
type: 'Identifier',
|
||||||
@ -326,7 +327,6 @@ const myVar = funcN(1, 2)`
|
|||||||
type: 'FunctionExpression',
|
type: 'FunctionExpression',
|
||||||
start: 11,
|
start: 11,
|
||||||
end: 37,
|
end: 37,
|
||||||
id: null,
|
|
||||||
params: [
|
params: [
|
||||||
{
|
{
|
||||||
type: 'Identifier',
|
type: 'Identifier',
|
||||||
@ -416,6 +416,9 @@ const myVar = funcN(1, 2)`
|
|||||||
raw: '2',
|
raw: '2',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
function: {
|
||||||
|
type: 'InMemory',
|
||||||
|
},
|
||||||
optional: false,
|
optional: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -485,6 +488,7 @@ describe('testing pipe operator special', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
function: expect.any(Object),
|
||||||
optional: false,
|
optional: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -521,6 +525,7 @@ describe('testing pipe operator special', () => {
|
|||||||
},
|
},
|
||||||
{ type: 'PipeSubstitution', start: 59, end: 60 },
|
{ type: 'PipeSubstitution', start: 59, end: 60 },
|
||||||
],
|
],
|
||||||
|
function: expect.any(Object),
|
||||||
optional: false,
|
optional: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -593,6 +598,7 @@ describe('testing pipe operator special', () => {
|
|||||||
},
|
},
|
||||||
{ type: 'PipeSubstitution', start: 105, end: 106 },
|
{ type: 'PipeSubstitution', start: 105, end: 106 },
|
||||||
],
|
],
|
||||||
|
function: expect.any(Object),
|
||||||
optional: false,
|
optional: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -629,6 +635,7 @@ describe('testing pipe operator special', () => {
|
|||||||
},
|
},
|
||||||
{ type: 'PipeSubstitution', start: 128, end: 129 },
|
{ type: 'PipeSubstitution', start: 128, end: 129 },
|
||||||
],
|
],
|
||||||
|
function: expect.any(Object),
|
||||||
optional: false,
|
optional: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -651,6 +658,9 @@ describe('testing pipe operator special', () => {
|
|||||||
},
|
},
|
||||||
{ type: 'PipeSubstitution', start: 143, end: 144 },
|
{ type: 'PipeSubstitution', start: 143, end: 144 },
|
||||||
],
|
],
|
||||||
|
function: {
|
||||||
|
type: 'InMemory',
|
||||||
|
},
|
||||||
optional: false,
|
optional: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -730,6 +740,9 @@ describe('testing pipe operator special', () => {
|
|||||||
end: 35,
|
end: 35,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
function: {
|
||||||
|
type: 'InMemory',
|
||||||
|
},
|
||||||
optional: false,
|
optional: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -1550,7 +1563,10 @@ const key = 'c'`
|
|||||||
type: 'NoneCodeNode',
|
type: 'NoneCodeNode',
|
||||||
start: code.indexOf('\n// this is a comment'),
|
start: code.indexOf('\n// this is a comment'),
|
||||||
end: code.indexOf('const key'),
|
end: code.indexOf('const key'),
|
||||||
value: '\n// this is a comment\n',
|
value: {
|
||||||
|
type: 'blockComment',
|
||||||
|
value: 'this is a comment',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
const { nonCodeMeta } = parser_wasm(code)
|
const { nonCodeMeta } = parser_wasm(code)
|
||||||
expect(nonCodeMeta.noneCodeNodes[0]).toEqual(nonCodeMetaInstance)
|
expect(nonCodeMeta.noneCodeNodes[0]).toEqual(nonCodeMetaInstance)
|
||||||
@ -1560,7 +1576,9 @@ const key = 'c'`
|
|||||||
const { nonCodeMeta: nonCodeMeta2 } = parser_wasm(
|
const { nonCodeMeta: nonCodeMeta2 } = parser_wasm(
|
||||||
codeWithExtraStartWhitespace
|
codeWithExtraStartWhitespace
|
||||||
)
|
)
|
||||||
expect(nonCodeMeta2.noneCodeNodes[0].value).toBe(nonCodeMetaInstance.value)
|
expect(nonCodeMeta2.noneCodeNodes[0].value).toStrictEqual(
|
||||||
|
nonCodeMetaInstance.value
|
||||||
|
)
|
||||||
expect(nonCodeMeta2.noneCodeNodes[0].start).not.toBe(
|
expect(nonCodeMeta2.noneCodeNodes[0].start).not.toBe(
|
||||||
nonCodeMetaInstance.start
|
nonCodeMetaInstance.start
|
||||||
)
|
)
|
||||||
@ -1583,7 +1601,10 @@ const key = 'c'`
|
|||||||
type: 'NoneCodeNode',
|
type: 'NoneCodeNode',
|
||||||
start: 106,
|
start: 106,
|
||||||
end: 166,
|
end: 166,
|
||||||
value: ' /* this is\n a comment\n spanning a few lines */\n ',
|
value: {
|
||||||
|
type: 'blockComment',
|
||||||
|
value: 'this is\n a comment\n spanning a few lines',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
it('comments in a pipe expression', () => {
|
it('comments in a pipe expression', () => {
|
||||||
@ -1603,7 +1624,10 @@ const key = 'c'`
|
|||||||
type: 'NoneCodeNode',
|
type: 'NoneCodeNode',
|
||||||
start: 125,
|
start: 125,
|
||||||
end: 141,
|
end: 141,
|
||||||
value: '\n// a comment\n ',
|
value: {
|
||||||
|
type: 'blockComment',
|
||||||
|
value: 'a comment',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -1627,6 +1651,7 @@ describe('test UnaryExpression', () => {
|
|||||||
{ type: 'Literal', start: 19, end: 20, value: 4, raw: '4' },
|
{ type: 'Literal', start: 19, end: 20, value: 4, raw: '4' },
|
||||||
{ type: 'Literal', start: 22, end: 25, value: 100, raw: '100' },
|
{ type: 'Literal', start: 22, end: 25, value: 100, raw: '100' },
|
||||||
],
|
],
|
||||||
|
function: expect.any(Object),
|
||||||
optional: false,
|
optional: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -1660,10 +1685,12 @@ describe('testing nested call expressions', () => {
|
|||||||
{ type: 'Literal', start: 34, end: 35, value: 5, raw: '5' },
|
{ type: 'Literal', start: 34, end: 35, value: 5, raw: '5' },
|
||||||
{ type: 'Literal', start: 37, end: 38, value: 3, raw: '3' },
|
{ type: 'Literal', start: 37, end: 38, value: 3, raw: '3' },
|
||||||
],
|
],
|
||||||
|
function: expect.any(Object),
|
||||||
optional: false,
|
optional: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
function: expect.any(Object),
|
||||||
optional: false,
|
optional: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -1695,6 +1722,7 @@ describe('should recognise callExpresions in binaryExpressions', () => {
|
|||||||
},
|
},
|
||||||
{ type: 'PipeSubstitution', start: 25, end: 26 },
|
{ type: 'PipeSubstitution', start: 25, end: 26 },
|
||||||
],
|
],
|
||||||
|
function: expect.any(Object),
|
||||||
optional: false,
|
optional: false,
|
||||||
},
|
},
|
||||||
right: { type: 'Literal', value: 1, raw: '1', start: 30, end: 31 },
|
right: { type: 'Literal', value: 1, raw: '1', start: 30, end: 31 },
|
||||||
|
@ -3,7 +3,7 @@ import { parse_js } from '../wasm-lib/pkg/wasm_lib'
|
|||||||
import { initPromise } from './rust'
|
import { initPromise } from './rust'
|
||||||
import { Token } from './tokeniser'
|
import { Token } from './tokeniser'
|
||||||
import { KCLError } from './errors'
|
import { KCLError } from './errors'
|
||||||
import { KclError as RustKclError } from '../wasm-lib/bindings/KclError'
|
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
|
||||||
|
|
||||||
export const rangeTypeFix = (ranges: number[][]): [number, number][] =>
|
export const rangeTypeFix = (ranges: number[][]): [number, number][] =>
|
||||||
ranges.map(([start, end]) => [start, end])
|
ranges.map(([start, end]) => [start, end])
|
||||||
@ -16,10 +16,8 @@ export const parser_wasm = (code: string): Program => {
|
|||||||
const parsed: RustKclError = JSON.parse(e.toString())
|
const parsed: RustKclError = JSON.parse(e.toString())
|
||||||
const kclError = new KCLError(
|
const kclError = new KCLError(
|
||||||
parsed.kind,
|
parsed.kind,
|
||||||
parsed.kind === 'invalid_expression' ? parsed.kind : parsed.msg,
|
parsed.msg,
|
||||||
parsed.kind === 'invalid_expression'
|
rangeTypeFix(parsed.sourceRanges)
|
||||||
? [[parsed.start, parsed.end]]
|
|
||||||
: rangeTypeFix(parsed.sourceRanges)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(kclError)
|
console.log(kclError)
|
||||||
@ -36,10 +34,8 @@ export async function asyncParser(code: string): Promise<Program> {
|
|||||||
const parsed: RustKclError = JSON.parse(e.toString())
|
const parsed: RustKclError = JSON.parse(e.toString())
|
||||||
const kclError = new KCLError(
|
const kclError = new KCLError(
|
||||||
parsed.kind,
|
parsed.kind,
|
||||||
parsed.kind === 'invalid_expression' ? parsed.kind : parsed.msg,
|
parsed.msg,
|
||||||
parsed.kind === 'invalid_expression'
|
rangeTypeFix(parsed.sourceRanges)
|
||||||
? [[parsed.start, parsed.end]]
|
|
||||||
: rangeTypeFix(parsed.sourceRanges)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(kclError)
|
console.log(kclError)
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
export type { Program } from '../wasm-lib/bindings/Program'
|
export type { Program } from '../wasm-lib/kcl/bindings/Program'
|
||||||
export type { Value } from '../wasm-lib/bindings/Value'
|
export type { Value } from '../wasm-lib/kcl/bindings/Value'
|
||||||
export type { ObjectExpression } from '../wasm-lib/bindings/ObjectExpression'
|
export type { ObjectExpression } from '../wasm-lib/kcl/bindings/ObjectExpression'
|
||||||
export type { MemberExpression } from '../wasm-lib/bindings/MemberExpression'
|
export type { MemberExpression } from '../wasm-lib/kcl/bindings/MemberExpression'
|
||||||
export type { PipeExpression } from '../wasm-lib/bindings/PipeExpression'
|
export type { PipeExpression } from '../wasm-lib/kcl/bindings/PipeExpression'
|
||||||
export type { VariableDeclaration } from '../wasm-lib/bindings/VariableDeclaration'
|
export type { VariableDeclaration } from '../wasm-lib/kcl/bindings/VariableDeclaration'
|
||||||
export type { PipeSubstitution } from '../wasm-lib/bindings/PipeSubstitution'
|
export type { PipeSubstitution } from '../wasm-lib/kcl/bindings/PipeSubstitution'
|
||||||
export type { Identifier } from '../wasm-lib/bindings/Identifier'
|
export type { Identifier } from '../wasm-lib/kcl/bindings/Identifier'
|
||||||
export type { UnaryExpression } from '../wasm-lib/bindings/UnaryExpression'
|
export type { UnaryExpression } from '../wasm-lib/kcl/bindings/UnaryExpression'
|
||||||
export type { BinaryExpression } from '../wasm-lib/bindings/BinaryExpression'
|
export type { BinaryExpression } from '../wasm-lib/kcl/bindings/BinaryExpression'
|
||||||
export type { ReturnStatement } from '../wasm-lib/bindings/ReturnStatement'
|
export type { ReturnStatement } from '../wasm-lib/kcl/bindings/ReturnStatement'
|
||||||
export type { ExpressionStatement } from '../wasm-lib/bindings/ExpressionStatement'
|
export type { ExpressionStatement } from '../wasm-lib/kcl/bindings/ExpressionStatement'
|
||||||
export type { CallExpression } from '../wasm-lib/bindings/CallExpression'
|
export type { CallExpression } from '../wasm-lib/kcl/bindings/CallExpression'
|
||||||
export type { VariableDeclarator } from '../wasm-lib/bindings/VariableDeclarator'
|
export type { VariableDeclarator } from '../wasm-lib/kcl/bindings/VariableDeclarator'
|
||||||
export type { BinaryPart } from '../wasm-lib/bindings/BinaryPart'
|
export type { BinaryPart } from '../wasm-lib/kcl/bindings/BinaryPart'
|
||||||
export type { Literal } from '../wasm-lib/bindings/Literal'
|
export type { Literal } from '../wasm-lib/kcl/bindings/Literal'
|
||||||
export type { ArrayExpression } from '../wasm-lib/bindings/ArrayExpression'
|
export type { ArrayExpression } from '../wasm-lib/kcl/bindings/ArrayExpression'
|
||||||
|
|
||||||
export type SyntaxType =
|
export type SyntaxType =
|
||||||
| 'Program'
|
| 'Program'
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Diagnostic } from '@codemirror/lint'
|
import { Diagnostic } from '@codemirror/lint'
|
||||||
import { KclError as RustKclError } from '../wasm-lib/bindings/KclError'
|
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
|
||||||
|
|
||||||
type ExtractKind<T> = T extends { kind: infer K } ? K : never
|
type ExtractKind<T> = T extends { kind: infer K } ? K : never
|
||||||
export class KCLError {
|
export class KCLError {
|
||||||
|
@ -4,10 +4,10 @@ import {
|
|||||||
ArtifactMap,
|
ArtifactMap,
|
||||||
SourceRangeMap,
|
SourceRangeMap,
|
||||||
} from './std/engineConnection'
|
} from './std/engineConnection'
|
||||||
import { ProgramReturn } from '../wasm-lib/bindings/ProgramReturn'
|
import { ProgramReturn } from '../wasm-lib/kcl/bindings/ProgramReturn'
|
||||||
import { execute_wasm } from '../wasm-lib/pkg/wasm_lib'
|
import { execute_wasm } from '../wasm-lib/pkg/wasm_lib'
|
||||||
import { KCLError } from './errors'
|
import { KCLError } from './errors'
|
||||||
import { KclError as RustKclError } from '../wasm-lib/bindings/KclError'
|
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
|
||||||
import { rangeTypeFix } from './abstractSyntaxTree'
|
import { rangeTypeFix } from './abstractSyntaxTree'
|
||||||
|
|
||||||
export type SourceRange = [number, number]
|
export type SourceRange = [number, number]
|
||||||
@ -146,10 +146,8 @@ export const _executor = async (
|
|||||||
const parsed: RustKclError = JSON.parse(e.toString())
|
const parsed: RustKclError = JSON.parse(e.toString())
|
||||||
const kclError = new KCLError(
|
const kclError = new KCLError(
|
||||||
parsed.kind,
|
parsed.kind,
|
||||||
parsed.kind === 'invalid_expression' ? parsed.kind : parsed.msg,
|
parsed.msg,
|
||||||
parsed.kind === 'invalid_expression'
|
rangeTypeFix(parsed.sourceRanges)
|
||||||
? [[parsed.start, parsed.end]]
|
|
||||||
: rangeTypeFix(parsed.sourceRanges)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(kclError)
|
console.log(kclError)
|
||||||
|
@ -36,14 +36,14 @@ export function addSketchTo(
|
|||||||
const _node = { ...node }
|
const _node = { ...node }
|
||||||
const _name = name || findUniqueName(node, 'part')
|
const _name = name || findUniqueName(node, 'part')
|
||||||
|
|
||||||
const startSketchAt = createCallExpression('startSketchAt', [
|
const startSketchAt = createCallExpressionStdLib('startSketchAt', [
|
||||||
createLiteral('default'),
|
createLiteral('default'),
|
||||||
])
|
])
|
||||||
const rotate = createCallExpression(axis === 'xz' ? 'rx' : 'ry', [
|
const rotate = createCallExpression(axis === 'xz' ? 'rx' : 'ry', [
|
||||||
createLiteral(90),
|
createLiteral(90),
|
||||||
createPipeSubstitution(),
|
createPipeSubstitution(),
|
||||||
])
|
])
|
||||||
const initialLineTo = createCallExpression('line', [
|
const initialLineTo = createCallExpressionStdLib('line', [
|
||||||
createLiteral('default'),
|
createLiteral('default'),
|
||||||
createPipeSubstitution(),
|
createPipeSubstitution(),
|
||||||
])
|
])
|
||||||
@ -112,7 +112,9 @@ function addToShow(node: Program, name: string): Program {
|
|||||||
const dumbyStartend = { start: 0, end: 0 }
|
const dumbyStartend = { start: 0, end: 0 }
|
||||||
const showCallIndex = getShowIndex(_node)
|
const showCallIndex = getShowIndex(_node)
|
||||||
if (showCallIndex === -1) {
|
if (showCallIndex === -1) {
|
||||||
const showCall = createCallExpression('show', [createIdentifier(name)])
|
const showCall = createCallExpressionStdLib('show', [
|
||||||
|
createIdentifier(name),
|
||||||
|
])
|
||||||
const showExpressionStatement: ExpressionStatement = {
|
const showExpressionStatement: ExpressionStatement = {
|
||||||
type: 'ExpressionStatement',
|
type: 'ExpressionStatement',
|
||||||
...dumbyStartend,
|
...dumbyStartend,
|
||||||
@ -124,7 +126,7 @@ function addToShow(node: Program, name: string): Program {
|
|||||||
const showCall = { ..._node.body[showCallIndex] } as ExpressionStatement
|
const showCall = { ..._node.body[showCallIndex] } as ExpressionStatement
|
||||||
const showCallArgs = (showCall.expression as CallExpression).arguments
|
const showCallArgs = (showCall.expression as CallExpression).arguments
|
||||||
const newShowCallArgs: Value[] = [...showCallArgs, createIdentifier(name)]
|
const newShowCallArgs: Value[] = [...showCallArgs, createIdentifier(name)]
|
||||||
const newShowExpression = createCallExpression('show', newShowCallArgs)
|
const newShowExpression = createCallExpressionStdLib('show', newShowCallArgs)
|
||||||
|
|
||||||
_node.body[showCallIndex] = {
|
_node.body[showCallIndex] = {
|
||||||
...showCall,
|
...showCall,
|
||||||
@ -225,7 +227,7 @@ export function extrudeSketch(
|
|||||||
const { node: variableDeclorator, shallowPath: pathToDecleration } =
|
const { node: variableDeclorator, shallowPath: pathToDecleration } =
|
||||||
getNodeFromPath<VariableDeclarator>(_node, pathToNode, 'VariableDeclarator')
|
getNodeFromPath<VariableDeclarator>(_node, pathToNode, 'VariableDeclarator')
|
||||||
|
|
||||||
const extrudeCall = createCallExpression('extrude', [
|
const extrudeCall = createCallExpressionStdLib('extrude', [
|
||||||
createLiteral(4),
|
createLiteral(4),
|
||||||
shouldPipe
|
shouldPipe
|
||||||
? createPipeSubstitution()
|
? createPipeSubstitution()
|
||||||
@ -313,15 +315,15 @@ export function sketchOnExtrudedFace(
|
|||||||
const newSketch = createVariableDeclaration(
|
const newSketch = createVariableDeclaration(
|
||||||
newSketchName,
|
newSketchName,
|
||||||
createPipeExpression([
|
createPipeExpression([
|
||||||
createCallExpression('startSketchAt', [
|
createCallExpressionStdLib('startSketchAt', [
|
||||||
createArrayExpression([createLiteral(0), createLiteral(0)]),
|
createArrayExpression([createLiteral(0), createLiteral(0)]),
|
||||||
]),
|
]),
|
||||||
createCallExpression('lineTo', [
|
createCallExpressionStdLib('lineTo', [
|
||||||
createArrayExpression([createLiteral(1), createLiteral(1)]),
|
createArrayExpression([createLiteral(1), createLiteral(1)]),
|
||||||
createPipeSubstitution(),
|
createPipeSubstitution(),
|
||||||
]),
|
]),
|
||||||
createCallExpression('transform', [
|
createCallExpression('transform', [
|
||||||
createCallExpression('getExtrudeWallTransform', [
|
createCallExpressionStdLib('getExtrudeWallTransform', [
|
||||||
createLiteral(tag),
|
createLiteral(tag),
|
||||||
createIdentifier(oldSketchName),
|
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(
|
export function createCallExpression(
|
||||||
name: string,
|
name: string,
|
||||||
args: CallExpression['arguments']
|
args: CallExpression['arguments']
|
||||||
@ -428,6 +464,9 @@ export function createCallExpression(
|
|||||||
end: 0,
|
end: 0,
|
||||||
name,
|
name,
|
||||||
},
|
},
|
||||||
|
function: {
|
||||||
|
type: 'InMemory',
|
||||||
|
},
|
||||||
optional: false,
|
optional: false,
|
||||||
arguments: args,
|
arguments: args,
|
||||||
}
|
}
|
||||||
|
@ -45,8 +45,7 @@ const newVar = myVar + 1`
|
|||||||
expect(recasted).toBe(code.trim())
|
expect(recasted).toBe(code.trim())
|
||||||
})
|
})
|
||||||
it('test with function call', () => {
|
it('test with function call', () => {
|
||||||
const code = `
|
const code = `const myVar = "hello"
|
||||||
const myVar = "hello"
|
|
||||||
log(5, myVar)`
|
log(5, myVar)`
|
||||||
const { ast } = code2ast(code)
|
const { ast } = code2ast(code)
|
||||||
const recasted = recast(ast)
|
const recasted = recast(ast)
|
||||||
@ -71,8 +70,7 @@ log(5, myVar)`
|
|||||||
|> lineTo({ to: [1, 0], tag: "rightPath" }, %)
|
|> lineTo({ to: [1, 0], tag: "rightPath" }, %)
|
||||||
|> close(%)
|
|> close(%)
|
||||||
|
|
||||||
show(mySketch)
|
show(mySketch)`
|
||||||
`
|
|
||||||
const { ast } = code2ast(code)
|
const { ast } = code2ast(code)
|
||||||
const recasted = recast(ast)
|
const recasted = recast(ast)
|
||||||
expect(recasted).toBe(code.trim())
|
expect(recasted).toBe(code.trim())
|
||||||
@ -186,8 +184,7 @@ const myVar2 = yo['a'][key2].c`
|
|||||||
|
|
||||||
describe('testing recasting with comments and whitespace', () => {
|
describe('testing recasting with comments and whitespace', () => {
|
||||||
it('code with comments', () => {
|
it('code with comments', () => {
|
||||||
const code = `
|
const code = `const yo = { a: { b: { c: '123' } } }
|
||||||
const yo = { a: { b: { c: '123' } } }
|
|
||||||
// this is a comment
|
// this is a comment
|
||||||
const key = 'c'`
|
const key = 'c'`
|
||||||
|
|
||||||
@ -197,20 +194,18 @@ const key = 'c'`
|
|||||||
expect(recasted).toBe(code)
|
expect(recasted).toBe(code)
|
||||||
})
|
})
|
||||||
it('code with comment and extra lines', () => {
|
it('code with comment and extra lines', () => {
|
||||||
const code = `
|
const code = `const yo = 'c'
|
||||||
const yo = 'c' /* this is
|
|
||||||
|
/* this is
|
||||||
a
|
a
|
||||||
comment */
|
comment */
|
||||||
|
|
||||||
const yo = 'bing'`
|
const yo = 'bing'`
|
||||||
const { ast } = code2ast(code)
|
const { ast } = code2ast(code)
|
||||||
const recasted = recast(ast)
|
const recasted = recast(ast)
|
||||||
expect(recasted).toBe(code)
|
expect(recasted).toBe(code)
|
||||||
})
|
})
|
||||||
it('comments at the start and end', () => {
|
it('comments at the start and end', () => {
|
||||||
const code = `
|
const code = `// this is a comment
|
||||||
// this is a comment
|
|
||||||
|
|
||||||
const yo = { a: { b: { c: '123' } } }
|
const yo = { a: { b: { c: '123' } } }
|
||||||
const key = 'c'
|
const key = 'c'
|
||||||
|
|
||||||
@ -220,12 +215,12 @@ const key = 'c'
|
|||||||
expect(recasted).toBe(code)
|
expect(recasted).toBe(code)
|
||||||
})
|
})
|
||||||
it('comments in a fn block', () => {
|
it('comments in a fn block', () => {
|
||||||
const code = `
|
const code = `const myFn = () => {
|
||||||
const myFn = () => {
|
|
||||||
// this is a comment
|
// this is a comment
|
||||||
const yo = { a: { b: { c: '123' } } } /* block
|
const yo = { a: { b: { c: '123' } } }
|
||||||
comment */
|
|
||||||
|
|
||||||
|
/* block
|
||||||
|
comment */
|
||||||
const key = 'c'
|
const key = 'c'
|
||||||
// this is also a comment
|
// this is also a comment
|
||||||
}`
|
}`
|
||||||
@ -255,7 +250,7 @@ const mySk1 = startSketchAt([0, 0])
|
|||||||
// comment here
|
// comment here
|
||||||
|> lineTo({ to: [0, 1], tag: 'myTag' }, %)
|
|> lineTo({ to: [0, 1], tag: 'myTag' }, %)
|
||||||
|> lineTo([1, 1], %) /* and
|
|> lineTo([1, 1], %) /* and
|
||||||
here
|
here
|
||||||
*/
|
*/
|
||||||
// a comment between pipe expression statements
|
// a comment between pipe expression statements
|
||||||
|> rx(90, %)
|
|> rx(90, %)
|
||||||
@ -269,7 +264,21 @@ const mySk1 = startSketchAt([0, 0])
|
|||||||
*/`
|
*/`
|
||||||
const { ast } = code2ast(code)
|
const { ast } = code2ast(code)
|
||||||
const recasted = recast(ast)
|
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', () => {
|
it('with unaryExpression in sketch situation', () => {
|
||||||
const code = [
|
const code = [
|
||||||
'const part001 = startSketchAt([0, 0])',
|
'const part001 = startSketchAt([0, 0])',
|
||||||
'|> line([-2.21, -legLen(5, min(3, 999))], %)',
|
' |> line([-2.21, -legLen(5, min(3, 999))], %)',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
const { ast } = code2ast(code)
|
const { ast } = code2ast(code)
|
||||||
const recasted = recast(ast)
|
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({ to: [0.62, 4.15], tag: 'seg01' }, %)
|
||||||
|> line([2.77, -1.24], %)
|
|> line([2.77, -1.24], %)
|
||||||
|> angledLineThatIntersects({
|
|> angledLineThatIntersects({
|
||||||
angle: 201,
|
angle: 201,
|
||||||
offset: -1.35,
|
offset: -1.35,
|
||||||
intersectTag: 'seg01'
|
intersectTag: 'seg01'
|
||||||
}, %)
|
}, %)
|
||||||
|> line([-0.42, -1.72], %)
|
|> line([-0.42, -1.72], %)
|
||||||
show(part001)`
|
show(part001)`
|
||||||
const { ast } = code2ast(code)
|
const { ast } = code2ast(code)
|
||||||
|
@ -4,12 +4,18 @@ import { VITE_KC_API_WS_MODELING_URL, VITE_KC_CONNECTION_TIMEOUT_MS } from 'env'
|
|||||||
import { Models } from '@kittycad/lib'
|
import { Models } from '@kittycad/lib'
|
||||||
import { exportSave } from 'lib/exportSave'
|
import { exportSave } from 'lib/exportSave'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import * as Sentry from '@sentry/react'
|
||||||
|
|
||||||
interface ResultCommand {
|
interface CommandInfo {
|
||||||
|
commandType: CommandTypes
|
||||||
|
range: SourceRange
|
||||||
|
parentId?: string
|
||||||
|
}
|
||||||
|
interface ResultCommand extends CommandInfo {
|
||||||
type: 'result'
|
type: 'result'
|
||||||
data: any
|
data: any
|
||||||
}
|
}
|
||||||
interface PendingCommand {
|
interface PendingCommand extends CommandInfo {
|
||||||
type: 'pending'
|
type: 'pending'
|
||||||
promise: Promise<any>
|
promise: Promise<any>
|
||||||
resolve: (val: any) => void
|
resolve: (val: any) => void
|
||||||
@ -22,16 +28,6 @@ export interface SourceRangeMap {
|
|||||||
[key: string]: SourceRange
|
[key: string]: SourceRange
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectionsArgs {
|
|
||||||
id: string
|
|
||||||
type: Selections['codeBasedSelections'][number]['type']
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CursorSelectionsArgs {
|
|
||||||
otherSelections: Selections['otherSelections']
|
|
||||||
idBasedSelections: { type: string; id: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NewTrackArgs {
|
interface NewTrackArgs {
|
||||||
conn: EngineConnection
|
conn: EngineConnection
|
||||||
mediaStream: MediaStream
|
mediaStream: MediaStream
|
||||||
@ -39,13 +35,15 @@ interface NewTrackArgs {
|
|||||||
|
|
||||||
type WebSocketResponse = Models['OkWebSocketResponseData_type']
|
type WebSocketResponse = Models['OkWebSocketResponseData_type']
|
||||||
|
|
||||||
|
type ClientMetrics = Models['ClientMetrics_type']
|
||||||
|
|
||||||
// EngineConnection encapsulates the connection(s) to the Engine
|
// EngineConnection encapsulates the connection(s) to the Engine
|
||||||
// for the EngineCommandManager; namely, the underlying WebSocket
|
// for the EngineCommandManager; namely, the underlying WebSocket
|
||||||
// and WebRTC connections.
|
// and WebRTC connections.
|
||||||
export class EngineConnection {
|
export class EngineConnection {
|
||||||
websocket?: WebSocket
|
websocket?: WebSocket
|
||||||
pc?: RTCPeerConnection
|
pc?: RTCPeerConnection
|
||||||
lossyDataChannel?: RTCDataChannel
|
unreliableDataChannel?: RTCDataChannel
|
||||||
|
|
||||||
private ready: boolean
|
private ready: boolean
|
||||||
|
|
||||||
@ -58,6 +56,9 @@ export class EngineConnection {
|
|||||||
private onClose: (engineConnection: EngineConnection) => void
|
private onClose: (engineConnection: EngineConnection) => void
|
||||||
private onNewTrack: (track: NewTrackArgs) => void
|
private onNewTrack: (track: NewTrackArgs) => void
|
||||||
|
|
||||||
|
// TODO: actual type is ClientMetrics
|
||||||
|
private webrtcStatsCollector?: () => Promise<ClientMetrics>
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
url,
|
url,
|
||||||
token,
|
token,
|
||||||
@ -107,6 +108,11 @@ export class EngineConnection {
|
|||||||
isReady() {
|
isReady() {
|
||||||
return this.ready
|
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
|
// connect will attempt to connect to the Engine over a WebSocket, and
|
||||||
// establish the WebRTC connections.
|
// establish the WebRTC connections.
|
||||||
//
|
//
|
||||||
@ -116,6 +122,44 @@ export class EngineConnection {
|
|||||||
// TODO(paultag): make this safe to call multiple times, and figure out
|
// TODO(paultag): make this safe to call multiple times, and figure out
|
||||||
// when a connection is in progress (state: connecting or something).
|
// 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 = new WebSocket(this.url, [])
|
||||||
this.websocket.binaryType = 'arraybuffer'
|
this.websocket.binaryType = 'arraybuffer'
|
||||||
|
|
||||||
@ -129,6 +173,39 @@ export class EngineConnection {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.websocket.addEventListener('open', (event) => {
|
this.websocket.addEventListener('open', (event) => {
|
||||||
|
if (this.shouldTrace()) {
|
||||||
|
websocketSpan.resolve?.()
|
||||||
|
|
||||||
|
handshakeSpan = new SpanPromise(
|
||||||
|
webrtcMediaTransaction.startChild({ op: 'handshake' })
|
||||||
|
)
|
||||||
|
iceSpan = new SpanPromise(
|
||||||
|
webrtcMediaTransaction.startChild({ op: 'ice' })
|
||||||
|
)
|
||||||
|
dataChannelSpan = new SpanPromise(
|
||||||
|
webrtcMediaTransaction.startChild({
|
||||||
|
op: 'data-channel',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
mediaTrackSpan = new SpanPromise(
|
||||||
|
webrtcMediaTransaction.startChild({
|
||||||
|
op: 'media-track',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.shouldTrace()) {
|
||||||
|
Promise.all([
|
||||||
|
handshakeSpan.promise,
|
||||||
|
iceSpan.promise,
|
||||||
|
dataChannelSpan.promise,
|
||||||
|
mediaTrackSpan.promise,
|
||||||
|
]).then(() => {
|
||||||
|
console.log('All spans finished, reporting')
|
||||||
|
webrtcMediaTransaction?.finish()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
this.onWebsocketOpen(this)
|
this.onWebsocketOpen(this)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -162,7 +239,7 @@ export class EngineConnection {
|
|||||||
} else {
|
} else {
|
||||||
console.error(`Error from server:`)
|
console.error(`Error from server:`)
|
||||||
}
|
}
|
||||||
message.errors.forEach((error) => {
|
message?.errors?.forEach((error) => {
|
||||||
console.error(` - ${error.error_code}: ${error.message}`)
|
console.error(` - ${error.error_code}: ${error.message}`)
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -191,6 +268,13 @@ export class EngineConnection {
|
|||||||
sdp: answer.sdp,
|
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') {
|
} else if (resp.type === 'trickle_ice') {
|
||||||
let candidate = resp.data?.candidate
|
let candidate = resp.data?.candidate
|
||||||
@ -220,9 +304,11 @@ export class EngineConnection {
|
|||||||
// PeerConnection and waiting for events to fire our callbacks.
|
// PeerConnection and waiting for events to fire our callbacks.
|
||||||
|
|
||||||
this.pc.addEventListener('connectionstatechange', (event) => {
|
this.pc.addEventListener('connectionstatechange', (event) => {
|
||||||
// if (this.pc?.iceConnectionState === 'disconnected') {
|
if (this.pc?.iceConnectionState === 'connected') {
|
||||||
// this.close()
|
if (this.shouldTrace()) {
|
||||||
// }
|
iceSpan.resolve?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.pc.addEventListener('icecandidate', (event) => {
|
this.pc.addEventListener('icecandidate', (event) => {
|
||||||
@ -254,6 +340,17 @@ export class EngineConnection {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(console.log)
|
.catch(console.log)
|
||||||
|
} else if (resp.type === 'metrics_request') {
|
||||||
|
if (this.webrtcStatsCollector === undefined) {
|
||||||
|
// TODO: Error message here?
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.webrtcStatsCollector().then((client_metrics) => {
|
||||||
|
this.send({
|
||||||
|
type: 'metrics_response',
|
||||||
|
metrics: client_metrics,
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(paultag): This ought to be both controllable, as well as something
|
// TODO(paultag): This ought to be both controllable, as well as something
|
||||||
@ -272,58 +369,123 @@ export class EngineConnection {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.pc.addEventListener('track', (event) => {
|
this.pc.addEventListener('track', (event) => {
|
||||||
console.log('received track', event)
|
|
||||||
const mediaStream = event.streams[0]
|
const mediaStream = event.streams[0]
|
||||||
|
|
||||||
|
if (this.shouldTrace()) {
|
||||||
|
let mediaStreamTrack = mediaStream.getVideoTracks()[0]
|
||||||
|
mediaStreamTrack.addEventListener('unmute', () => {
|
||||||
|
// let settings = mediaStreamTrack.getSettings()
|
||||||
|
// mediaTrackSpan.span.setTag("fps", settings.frameRate)
|
||||||
|
// mediaTrackSpan.span.setTag("width", settings.width)
|
||||||
|
// mediaTrackSpan.span.setTag("height", settings.height)
|
||||||
|
mediaTrackSpan.resolve?.()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.webrtcStatsCollector = (): Promise<ClientMetrics> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (mediaStream.getVideoTracks().length !== 1) {
|
||||||
|
reject(new Error('too many video tracks to report'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoTrack = mediaStream.getVideoTracks()[0]
|
||||||
|
this.pc?.getStats(videoTrack).then((videoTrackStats) => {
|
||||||
|
// TODO(paultag): this needs type information from the KittyCAD typescript
|
||||||
|
// library once it's updated
|
||||||
|
let client_metrics: ClientMetrics = {
|
||||||
|
rtc_frames_decoded: 0,
|
||||||
|
rtc_frames_dropped: 0,
|
||||||
|
rtc_frames_received: 0,
|
||||||
|
rtc_frames_per_second: 0,
|
||||||
|
rtc_freeze_count: 0,
|
||||||
|
rtc_jitter_sec: 0.0,
|
||||||
|
rtc_keyframes_decoded: 0,
|
||||||
|
rtc_total_freezes_duration_sec: 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(paultag): Since we can technically have multiple WebRTC
|
||||||
|
// video tracks (even if the Server doesn't at the moment), we
|
||||||
|
// ought to send stats for every video track(?), and add the stream
|
||||||
|
// ID into it. This raises the cardinality of collected metrics
|
||||||
|
// when/if we do, but for now, just report the one stream.
|
||||||
|
|
||||||
|
videoTrackStats.forEach((videoTrackReport) => {
|
||||||
|
if (videoTrackReport.type === 'inbound-rtp') {
|
||||||
|
client_metrics.rtc_frames_decoded =
|
||||||
|
videoTrackReport.framesDecoded
|
||||||
|
client_metrics.rtc_frames_dropped =
|
||||||
|
videoTrackReport.framesDropped
|
||||||
|
client_metrics.rtc_frames_received =
|
||||||
|
videoTrackReport.framesReceived
|
||||||
|
client_metrics.rtc_frames_per_second =
|
||||||
|
videoTrackReport.framesPerSecond || 0
|
||||||
|
client_metrics.rtc_freeze_count = videoTrackReport.freezeCount
|
||||||
|
client_metrics.rtc_jitter_sec = videoTrackReport.jitter
|
||||||
|
client_metrics.rtc_keyframes_decoded =
|
||||||
|
videoTrackReport.keyFramesDecoded
|
||||||
|
client_metrics.rtc_total_freezes_duration_sec =
|
||||||
|
videoTrackReport.totalFreezesDuration
|
||||||
|
} else if (videoTrackReport.type === 'transport') {
|
||||||
|
// videoTrackReport.bytesReceived,
|
||||||
|
// videoTrackReport.bytesSent,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
resolve(client_metrics)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
this.onNewTrack({
|
this.onNewTrack({
|
||||||
conn: this,
|
conn: this,
|
||||||
mediaStream: mediaStream,
|
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.pc.addEventListener('datachannel', (event) => {
|
||||||
this.lossyDataChannel = event.channel
|
this.unreliableDataChannel = event.channel
|
||||||
|
|
||||||
console.log('accepted lossy data channel', event.channel.label)
|
console.log('accepted unreliable data channel', event.channel.label)
|
||||||
this.lossyDataChannel.addEventListener('open', (event) => {
|
this.unreliableDataChannel.addEventListener('open', (event) => {
|
||||||
console.log('lossy data channel opened', event)
|
console.log('unreliable data channel opened', event)
|
||||||
|
if (this.shouldTrace()) {
|
||||||
|
dataChannelSpan.resolve?.()
|
||||||
|
}
|
||||||
|
|
||||||
this.onDataChannelOpen(this)
|
this.onDataChannelOpen(this)
|
||||||
|
|
||||||
let timeToConnectMs = new Date().getTime() - connectionStarted.getTime()
|
|
||||||
console.log(`engine connection time to connect: ${timeToConnectMs}ms`)
|
|
||||||
this.onEngineConnectionOpen(this)
|
this.onEngineConnectionOpen(this)
|
||||||
this.ready = true
|
this.ready = true
|
||||||
})
|
})
|
||||||
|
|
||||||
this.lossyDataChannel.addEventListener('close', (event) => {
|
this.unreliableDataChannel.addEventListener('close', (event) => {
|
||||||
console.log('lossy data channel closed')
|
console.log('unreliable data channel closed')
|
||||||
this.close()
|
this.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.lossyDataChannel.addEventListener('error', (event) => {
|
this.unreliableDataChannel.addEventListener('error', (event) => {
|
||||||
console.log('lossy data channel error')
|
console.log('unreliable data channel error')
|
||||||
this.close()
|
this.close()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
this.onConnectionStarted(this)
|
this.onConnectionStarted(this)
|
||||||
}
|
}
|
||||||
send(message: object) {
|
send(message: object | string) {
|
||||||
// TODO(paultag): Add in logic to determine the connection state and
|
// TODO(paultag): Add in logic to determine the connection state and
|
||||||
// take actions if needed?
|
// take actions if needed?
|
||||||
this.websocket?.send(JSON.stringify(message))
|
this.websocket?.send(
|
||||||
|
typeof message === 'string' ? message : JSON.stringify(message)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
close() {
|
close() {
|
||||||
this.websocket?.close()
|
this.websocket?.close()
|
||||||
this.pc?.close()
|
this.pc?.close()
|
||||||
this.lossyDataChannel?.close()
|
this.unreliableDataChannel?.close()
|
||||||
this.websocket = undefined
|
this.websocket = undefined
|
||||||
this.pc = undefined
|
this.pc = undefined
|
||||||
this.lossyDataChannel = undefined
|
this.unreliableDataChannel = undefined
|
||||||
|
this.webrtcStatsCollector = undefined
|
||||||
|
|
||||||
this.onClose(this)
|
this.onClose(this)
|
||||||
this.ready = false
|
this.ready = false
|
||||||
@ -331,6 +493,25 @@ export class EngineConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type EngineCommand = Models['WebSocketRequest_type']
|
export type EngineCommand = Models['WebSocketRequest_type']
|
||||||
|
type ModelTypes = Models['OkModelingCmdResponse_type']['type']
|
||||||
|
|
||||||
|
type CommandTypes = Models['ModelingCmd_type']['type']
|
||||||
|
|
||||||
|
type UnreliableResponses = Extract<
|
||||||
|
Models['OkModelingCmdResponse_type'],
|
||||||
|
{ type: 'highlight_set_entity' }
|
||||||
|
>
|
||||||
|
interface UnreliableSubscription<T extends UnreliableResponses['type']> {
|
||||||
|
event: T
|
||||||
|
callback: (data: Extract<UnreliableResponses, { type: T }>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Subscription<T extends ModelTypes> {
|
||||||
|
event: T
|
||||||
|
callback: (
|
||||||
|
data: Extract<Models['OkModelingCmdResponse_type'], { type: T }>
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
export class EngineCommandManager {
|
export class EngineCommandManager {
|
||||||
artifactMap: ArtifactMap = {}
|
artifactMap: ArtifactMap = {}
|
||||||
@ -340,10 +521,17 @@ export class EngineCommandManager {
|
|||||||
engineConnection?: EngineConnection
|
engineConnection?: EngineConnection
|
||||||
waitForReady: Promise<void> = new Promise(() => {})
|
waitForReady: Promise<void> = new Promise(() => {})
|
||||||
private resolveReady = () => {}
|
private resolveReady = () => {}
|
||||||
onHoverCallback: (id?: string) => void = () => {}
|
|
||||||
onClickCallback: (selection?: SelectionsArgs) => void = () => {}
|
subscriptions: {
|
||||||
onCursorsSelectedCallback: (selections: CursorSelectionsArgs) => void =
|
[event: string]: {
|
||||||
() => {}
|
[localUnsubscribeId: string]: (a: any) => void
|
||||||
|
}
|
||||||
|
} = {} as any
|
||||||
|
unreliableSubscriptions: {
|
||||||
|
[event: string]: {
|
||||||
|
[localUnsubscribeId: string]: (a: any) => void
|
||||||
|
}
|
||||||
|
} = {} as any
|
||||||
constructor({
|
constructor({
|
||||||
setMediaStream,
|
setMediaStream,
|
||||||
setIsStreamReady,
|
setIsStreamReady,
|
||||||
@ -373,20 +561,28 @@ export class EngineCommandManager {
|
|||||||
},
|
},
|
||||||
onConnectionStarted: (engineConnection) => {
|
onConnectionStarted: (engineConnection) => {
|
||||||
engineConnection?.pc?.addEventListener('datachannel', (event) => {
|
engineConnection?.pc?.addEventListener('datachannel', (event) => {
|
||||||
let lossyDataChannel = event.channel
|
let unreliableDataChannel = event.channel
|
||||||
|
|
||||||
lossyDataChannel.addEventListener('message', (event) => {
|
unreliableDataChannel.addEventListener('message', (event) => {
|
||||||
const result: Models['OkModelingCmdResponse_type'] = JSON.parse(
|
const result: UnreliableResponses = JSON.parse(event.data)
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
if (
|
|
||||||
result.type === 'highlight_set_entity' &&
|
|
||||||
result?.data?.sequence &&
|
|
||||||
result.data.sequence > this.inSequence
|
|
||||||
) {
|
|
||||||
this.onHoverCallback(result.data.entity_id)
|
|
||||||
this.inSequence = result.data.sequence
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -418,8 +614,8 @@ export class EngineCommandManager {
|
|||||||
|
|
||||||
mediaStream.getVideoTracks()[0].addEventListener('mute', () => {
|
mediaStream.getVideoTracks()[0].addEventListener('mute', () => {
|
||||||
console.log('peer is not sending video to us')
|
console.log('peer is not sending video to us')
|
||||||
this.engineConnection?.close()
|
// this.engineConnection?.close()
|
||||||
this.engineConnection?.connect()
|
// this.engineConnection?.connect()
|
||||||
})
|
})
|
||||||
|
|
||||||
setMediaStream(mediaStream)
|
setMediaStream(mediaStream)
|
||||||
@ -433,30 +629,31 @@ export class EngineCommandManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const modelingResponse = message.data.modeling_response
|
const modelingResponse = message.data.modeling_response
|
||||||
|
Object.values(this.subscriptions[modelingResponse.type] || {}).forEach(
|
||||||
|
(callback) => callback(modelingResponse)
|
||||||
|
)
|
||||||
|
|
||||||
const command = this.artifactMap[id]
|
const command = this.artifactMap[id]
|
||||||
if (modelingResponse.type === 'select_with_point') {
|
|
||||||
if (modelingResponse?.data?.entity_id) {
|
|
||||||
this.onClickCallback({
|
|
||||||
id: modelingResponse?.data?.entity_id,
|
|
||||||
type: 'default',
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.onClickCallback()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (command && command.type === 'pending') {
|
if (command && command.type === 'pending') {
|
||||||
const resolve = command.resolve
|
const resolve = command.resolve
|
||||||
this.artifactMap[id] = {
|
this.artifactMap[id] = {
|
||||||
type: 'result',
|
type: 'result',
|
||||||
|
range: command.range,
|
||||||
|
commandType: command.commandType,
|
||||||
|
parentId: command.parentId ? command.parentId : undefined,
|
||||||
data: modelingResponse,
|
data: modelingResponse,
|
||||||
}
|
}
|
||||||
resolve({
|
resolve({
|
||||||
id,
|
id,
|
||||||
|
commandType: command.commandType,
|
||||||
|
range: command.range,
|
||||||
|
data: modelingResponse,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.artifactMap[id] = {
|
this.artifactMap[id] = {
|
||||||
type: 'result',
|
type: 'result',
|
||||||
|
commandType: command?.commandType,
|
||||||
|
range: command?.range,
|
||||||
data: modelingResponse,
|
data: modelingResponse,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -468,20 +665,69 @@ export class EngineCommandManager {
|
|||||||
this.artifactMap = {}
|
this.artifactMap = {}
|
||||||
this.sourceRangeMap = {}
|
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() {
|
endSession() {
|
||||||
// this.websocket?.close()
|
// TODO: instead of sending a single command with `object_ids: Object.keys(this.artifactMap)`
|
||||||
// socket.off('command')
|
// we need to loop over them each individualy because if the engine doesn't recognise a single
|
||||||
}
|
// id the whole command fails.
|
||||||
onHover(callback: (id?: string) => void) {
|
Object.entries(this.artifactMap).forEach(([id, artifact]) => {
|
||||||
// It's when the user hovers over a part in the 3d scene, and so the engine should tell the
|
const artifactTypesToDelete: ArtifactMap[string]['commandType'][] = [
|
||||||
// frontend about that (with it's id) so that the FE can highlight code associated with that id
|
// 'start_path' creates a new scene object for the path, which is why it needs to be deleted,
|
||||||
this.onHoverCallback = callback
|
// however all of the segments in the path are its children so there don't need to be deleted.
|
||||||
}
|
// this fact is very opaque in the api and docs (as to what should can be deleted).
|
||||||
onClick(callback: (selection?: SelectionsArgs) => void) {
|
// Using an array is the list is likely to grow.
|
||||||
// It's when the user clicks on a part in the 3d scene, and so the engine should tell the
|
'start_path',
|
||||||
// frontend about that (with it's id) so that the FE can put the user's cursor on the right
|
]
|
||||||
// line of code
|
if (!artifactTypesToDelete.includes(artifact.commandType)) return
|
||||||
this.onClickCallback = callback
|
|
||||||
|
const deletCmd: EngineCommand = {
|
||||||
|
type: 'modeling_cmd_req',
|
||||||
|
cmd_id: uuidv4(),
|
||||||
|
cmd: {
|
||||||
|
type: 'remove_scene_objects',
|
||||||
|
object_ids: [id],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.engineConnection?.send(deletCmd)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
cusorsSelected(selections: {
|
cusorsSelected(selections: {
|
||||||
otherSelections: Selections['otherSelections']
|
otherSelections: Selections['otherSelections']
|
||||||
@ -507,32 +753,47 @@ export class EngineCommandManager {
|
|||||||
cmd_id: uuidv4(),
|
cmd_id: uuidv4(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sendSceneCommand(command: EngineCommand) {
|
sendSceneCommand(command: EngineCommand): Promise<any> {
|
||||||
if (!this.engineConnection?.isReady()) {
|
if (!this.engineConnection?.isReady()) {
|
||||||
console.log('socket not ready')
|
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
|
const cmd = command.cmd
|
||||||
if (
|
if (
|
||||||
cmd.type === 'camera_drag_move' &&
|
cmd.type === 'camera_drag_move' &&
|
||||||
this.engineConnection?.lossyDataChannel
|
this.engineConnection?.unreliableDataChannel
|
||||||
) {
|
) {
|
||||||
cmd.sequence = this.outSequence
|
cmd.sequence = this.outSequence
|
||||||
this.outSequence++
|
this.outSequence++
|
||||||
this.engineConnection?.lossyDataChannel?.send(JSON.stringify(command))
|
this.engineConnection?.unreliableDataChannel?.send(
|
||||||
return
|
JSON.stringify(command)
|
||||||
|
)
|
||||||
|
return Promise.resolve()
|
||||||
} else if (
|
} else if (
|
||||||
cmd.type === 'highlight_set_entity' &&
|
cmd.type === 'highlight_set_entity' &&
|
||||||
this.engineConnection?.lossyDataChannel
|
this.engineConnection?.unreliableDataChannel
|
||||||
) {
|
) {
|
||||||
cmd.sequence = this.outSequence
|
cmd.sequence = this.outSequence
|
||||||
this.outSequence++
|
this.outSequence++
|
||||||
this.engineConnection?.lossyDataChannel?.send(JSON.stringify(command))
|
this.engineConnection?.unreliableDataChannel?.send(
|
||||||
return
|
JSON.stringify(command)
|
||||||
|
)
|
||||||
|
return Promise.resolve()
|
||||||
|
} else if (
|
||||||
|
cmd.type === 'mouse_move' &&
|
||||||
|
this.engineConnection.unreliableDataChannel
|
||||||
|
) {
|
||||||
|
cmd.sequence = this.outSequence
|
||||||
|
this.outSequence++
|
||||||
|
this.engineConnection?.unreliableDataChannel?.send(
|
||||||
|
JSON.stringify(command)
|
||||||
|
)
|
||||||
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
console.log('sending command', command)
|
// since it's not mouse drag or highlighting send over TCP and keep track of the command
|
||||||
this.engineConnection?.send(command)
|
this.engineConnection?.send(command)
|
||||||
|
return this.handlePendingCommand(command.cmd_id, command.cmd)
|
||||||
}
|
}
|
||||||
sendModelingCommand({
|
sendModelingCommand({
|
||||||
id,
|
id,
|
||||||
@ -541,21 +802,44 @@ export class EngineCommandManager {
|
|||||||
}: {
|
}: {
|
||||||
id: string
|
id: string
|
||||||
range: SourceRange
|
range: SourceRange
|
||||||
command: EngineCommand
|
command: EngineCommand | string
|
||||||
}): Promise<any> {
|
}): Promise<any> {
|
||||||
this.sourceRangeMap[id] = range
|
this.sourceRangeMap[id] = range
|
||||||
|
|
||||||
if (!this.engineConnection?.isReady()) {
|
if (!this.engineConnection?.isReady()) {
|
||||||
console.log('socket not ready')
|
console.log('socket not ready')
|
||||||
return new Promise(() => {})
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
this.engineConnection?.send(command)
|
this.engineConnection?.send(command)
|
||||||
|
if (typeof command !== 'string' && command.type === 'modeling_cmd_req') {
|
||||||
|
return this.handlePendingCommand(id, command?.cmd, range)
|
||||||
|
} else if (typeof command === 'string') {
|
||||||
|
const parseCommand: EngineCommand = JSON.parse(command)
|
||||||
|
if (parseCommand.type === 'modeling_cmd_req')
|
||||||
|
return this.handlePendingCommand(id, parseCommand?.cmd, range)
|
||||||
|
}
|
||||||
|
throw 'shouldnt reach here'
|
||||||
|
}
|
||||||
|
handlePendingCommand(
|
||||||
|
id: string,
|
||||||
|
command: Models['ModelingCmd_type'],
|
||||||
|
range?: SourceRange
|
||||||
|
) {
|
||||||
let resolve: (val: any) => void = () => {}
|
let resolve: (val: any) => void = () => {}
|
||||||
const promise = new Promise((_resolve, reject) => {
|
const promise = new Promise((_resolve, reject) => {
|
||||||
resolve = _resolve
|
resolve = _resolve
|
||||||
})
|
})
|
||||||
|
const getParentId = (): string | undefined => {
|
||||||
|
if (command.type === 'extend_path') {
|
||||||
|
return command.path
|
||||||
|
}
|
||||||
|
// TODO handle other commands that have a parent
|
||||||
|
}
|
||||||
this.artifactMap[id] = {
|
this.artifactMap[id] = {
|
||||||
|
range: range || [0, 0],
|
||||||
type: 'pending',
|
type: 'pending',
|
||||||
|
commandType: command.type,
|
||||||
|
parentId: getParentId(),
|
||||||
promise,
|
promise,
|
||||||
resolve,
|
resolve,
|
||||||
}
|
}
|
||||||
@ -575,10 +859,9 @@ export class EngineCommandManager {
|
|||||||
if (commandStr === undefined) {
|
if (commandStr === undefined) {
|
||||||
throw new Error('commandStr is undefined')
|
throw new Error('commandStr is undefined')
|
||||||
}
|
}
|
||||||
const command: EngineCommand = JSON.parse(commandStr)
|
|
||||||
const range: SourceRange = JSON.parse(rangeStr)
|
const range: SourceRange = JSON.parse(rangeStr)
|
||||||
|
|
||||||
return this.sendModelingCommand({ id, range, command })
|
return this.sendModelingCommand({ id, range, command: commandStr })
|
||||||
}
|
}
|
||||||
commandResult(id: string): Promise<any> {
|
commandResult(id: string): Promise<any> {
|
||||||
const command = this.artifactMap[id]
|
const command = this.artifactMap[id]
|
||||||
|
@ -97,11 +97,10 @@ describe('testing changeSketchArguments', () => {
|
|||||||
const lineAfterChange = 'lineTo([2, 3], %)'
|
const lineAfterChange = 'lineTo([2, 3], %)'
|
||||||
test('changeSketchArguments', async () => {
|
test('changeSketchArguments', async () => {
|
||||||
// Enable rotations #152
|
// Enable rotations #152
|
||||||
const genCode = (line: string) => `
|
const genCode = (line: string) => `const mySketch001 = startSketchAt([0, 0])
|
||||||
const mySketch001 = startSketchAt([0, 0])
|
|> ${line}
|
||||||
|> ${line}
|
|> lineTo([0.46, -5.82], %)
|
||||||
|> lineTo([0.46, -5.82], %)
|
// |> rx(45, %)
|
||||||
// |> rx(45, %)
|
|
||||||
show(mySketch001)`
|
show(mySketch001)`
|
||||||
const code = genCode(lineToChange)
|
const code = genCode(lineToChange)
|
||||||
const expectedCode = genCode(lineAfterChange)
|
const expectedCode = genCode(lineAfterChange)
|
||||||
@ -160,8 +159,7 @@ show(mySketch001)`
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
// Enable rotations #152
|
// Enable rotations #152
|
||||||
const expectedCode = `
|
const expectedCode = `const mySketch001 = startSketchAt([0, 0])
|
||||||
const mySketch001 = startSketchAt([0, 0])
|
|
||||||
// |> rx(45, %)
|
// |> rx(45, %)
|
||||||
|> lineTo([-1.59, -1.54], %)
|
|> lineTo([-1.59, -1.54], %)
|
||||||
|> lineTo([0.46, -5.82], %)
|
|> lineTo([0.46, -5.82], %)
|
||||||
@ -175,12 +173,11 @@ describe('testing addTagForSketchOnFace', () => {
|
|||||||
it('needs to be in it', async () => {
|
it('needs to be in it', async () => {
|
||||||
const originalLine = 'lineTo([-1.59, -1.54], %)'
|
const originalLine = 'lineTo([-1.59, -1.54], %)'
|
||||||
// Enable rotations #152
|
// Enable rotations #152
|
||||||
const genCode = (line: string) => `
|
const genCode = (line: string) => `const mySketch001 = startSketchAt([0, 0])
|
||||||
const mySketch001 = startSketchAt([0, 0])
|
// |> rx(45, %)
|
||||||
// |> rx(45, %)
|
|> ${line}
|
||||||
|> ${line}
|
|> lineTo([0.46, -5.82], %)
|
||||||
|> lineTo([0.46, -5.82], %)
|
show(mySketch001)`
|
||||||
show(mySketch001)`
|
|
||||||
const code = genCode(originalLine)
|
const code = genCode(originalLine)
|
||||||
const ast = parser_wasm(code)
|
const ast = parser_wasm(code)
|
||||||
const programMemory = await enginelessExecutor(ast)
|
const programMemory = await enginelessExecutor(ast)
|
||||||
|
@ -59,20 +59,20 @@ describe('testing swaping out sketch calls with xLine/xLineTo', () => {
|
|||||||
` |> lineTo({ to: [1, 1], tag: 'abc1' }, %)`,
|
` |> lineTo({ to: [1, 1], tag: 'abc1' }, %)`,
|
||||||
` |> line({ to: [-2.04, -0.7], tag: 'abc2' }, %)`,
|
` |> line({ to: [-2.04, -0.7], tag: 'abc2' }, %)`,
|
||||||
` |> angledLine({`,
|
` |> angledLine({`,
|
||||||
` angle: 157,`,
|
` angle: 157,`,
|
||||||
` length: 1.69,`,
|
` length: 1.69,`,
|
||||||
` tag: 'abc3'`,
|
` tag: 'abc3'`,
|
||||||
` }, %)`,
|
` }, %)`,
|
||||||
` |> angledLineOfXLength({`,
|
` |> angledLineOfXLength({`,
|
||||||
` angle: 217,`,
|
` angle: 217,`,
|
||||||
` length: 0.86,`,
|
` length: 0.86,`,
|
||||||
` tag: 'abc4'`,
|
` tag: 'abc4'`,
|
||||||
` }, %)`,
|
` }, %)`,
|
||||||
` |> angledLineOfYLength({`,
|
` |> angledLineOfYLength({`,
|
||||||
` angle: 104,`,
|
` angle: 104,`,
|
||||||
` length: 1.58,`,
|
` length: 1.58,`,
|
||||||
` tag: 'abc5'`,
|
` tag: 'abc5'`,
|
||||||
` }, %)`,
|
` }, %)`,
|
||||||
` |> angledLineToX({ angle: 55, to: -2.89, tag: 'abc6' }, %)`,
|
` |> angledLineToX({ angle: 55, to: -2.89, tag: 'abc6' }, %)`,
|
||||||
` |> angledLineToY({ angle: 330, to: 2.53, tag: 'abc7' }, %)`,
|
` |> angledLineToY({ angle: 330, to: 2.53, tag: 'abc7' }, %)`,
|
||||||
` |> xLine({ length: 1.47, tag: 'abc8' }, %)`,
|
` |> xLine({ length: 1.47, tag: 'abc8' }, %)`,
|
||||||
@ -144,10 +144,10 @@ describe('testing swaping out sketch calls with xLine/xLineTo', () => {
|
|||||||
inputCode: bigExample,
|
inputCode: bigExample,
|
||||||
callToSwap: [
|
callToSwap: [
|
||||||
`angledLine({`,
|
`angledLine({`,
|
||||||
` angle: 157,`,
|
` angle: 157,`,
|
||||||
` length: 1.69,`,
|
` length: 1.69,`,
|
||||||
` tag: 'abc3'`,
|
` tag: 'abc3'`,
|
||||||
` }, %)`,
|
` }, %)`,
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
constraintType: 'horizontal',
|
constraintType: 'horizontal',
|
||||||
})
|
})
|
||||||
@ -172,10 +172,10 @@ describe('testing swaping out sketch calls with xLine/xLineTo', () => {
|
|||||||
inputCode: bigExample,
|
inputCode: bigExample,
|
||||||
callToSwap: [
|
callToSwap: [
|
||||||
`angledLineOfXLength({`,
|
`angledLineOfXLength({`,
|
||||||
` angle: 217,`,
|
` angle: 217,`,
|
||||||
` length: 0.86,`,
|
` length: 0.86,`,
|
||||||
` tag: 'abc4'`,
|
` tag: 'abc4'`,
|
||||||
` }, %)`,
|
` }, %)`,
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
constraintType: 'horizontal',
|
constraintType: 'horizontal',
|
||||||
})
|
})
|
||||||
@ -201,10 +201,10 @@ describe('testing swaping out sketch calls with xLine/xLineTo', () => {
|
|||||||
inputCode: bigExample,
|
inputCode: bigExample,
|
||||||
callToSwap: [
|
callToSwap: [
|
||||||
`angledLineOfYLength({`,
|
`angledLineOfYLength({`,
|
||||||
` angle: 104,`,
|
` angle: 104,`,
|
||||||
` length: 1.58,`,
|
` length: 1.58,`,
|
||||||
` tag: 'abc5'`,
|
` tag: 'abc5'`,
|
||||||
` }, %)`,
|
` }, %)`,
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
constraintType: 'vertical',
|
constraintType: 'vertical',
|
||||||
})
|
})
|
||||||
|
@ -133,64 +133,64 @@ const myAng2 = 134
|
|||||||
const part001 = startSketchAt([0, 0])
|
const part001 = startSketchAt([0, 0])
|
||||||
|> line({ to: [1, 3.82], tag: 'seg01' }, %) // ln-should-get-tag
|
|> line({ to: [1, 3.82], tag: 'seg01' }, %) // ln-should-get-tag
|
||||||
|> angledLineToX([
|
|> angledLineToX([
|
||||||
-angleToMatchLengthX('seg01', myVar, %),
|
-angleToMatchLengthX('seg01', myVar, %),
|
||||||
myVar
|
myVar
|
||||||
], %) // ln-lineTo-xAbsolute should use angleToMatchLengthX helper
|
], %) // ln-lineTo-xAbsolute should use angleToMatchLengthX helper
|
||||||
|> angledLineToY([
|
|> angledLineToY([
|
||||||
-angleToMatchLengthY('seg01', myVar, %),
|
-angleToMatchLengthY('seg01', myVar, %),
|
||||||
myVar
|
myVar
|
||||||
], %) // ln-lineTo-yAbsolute should use angleToMatchLengthY helper
|
], %) // ln-lineTo-yAbsolute should use angleToMatchLengthY helper
|
||||||
|> angledLine([45, segLen('seg01', %)], %) // ln-lineTo-free should become angledLine
|
|> angledLine([45, segLen('seg01', %)], %) // ln-lineTo-free should become angledLine
|
||||||
|> angledLine([45, segLen('seg01', %)], %) // ln-angledLineToX-free should become angledLine
|
|> angledLine([45, segLen('seg01', %)], %) // ln-angledLineToX-free should become angledLine
|
||||||
|> angledLine([myAng, segLen('seg01', %)], %) // ln-angledLineToX-angle should become angledLine
|
|> angledLine([myAng, segLen('seg01', %)], %) // ln-angledLineToX-angle should become angledLine
|
||||||
|> angledLineToX([
|
|> angledLineToX([
|
||||||
angleToMatchLengthX('seg01', myVar2, %),
|
angleToMatchLengthX('seg01', myVar2, %),
|
||||||
myVar2
|
myVar2
|
||||||
], %) // ln-angledLineToX-xAbsolute should use angleToMatchLengthX to get angle
|
], %) // ln-angledLineToX-xAbsolute should use angleToMatchLengthX to get angle
|
||||||
|> angledLine([-45, segLen('seg01', %)], %) // ln-angledLineToY-free should become angledLine
|
|> angledLine([-45, segLen('seg01', %)], %) // ln-angledLineToY-free should become angledLine
|
||||||
|> angledLine([myAng2, segLen('seg01', %)], %) // ln-angledLineToY-angle should become angledLine
|
|> angledLine([myAng2, segLen('seg01', %)], %) // ln-angledLineToY-angle should become angledLine
|
||||||
|> angledLineToY([
|
|> angledLineToY([
|
||||||
angleToMatchLengthY('seg01', myVar3, %),
|
angleToMatchLengthY('seg01', myVar3, %),
|
||||||
myVar3
|
myVar3
|
||||||
], %) // ln-angledLineToY-yAbsolute should use angleToMatchLengthY to get angle
|
], %) // ln-angledLineToY-yAbsolute should use angleToMatchLengthY to get angle
|
||||||
|> line([
|
|> line([
|
||||||
min(segLen('seg01', %), myVar),
|
min(segLen('seg01', %), myVar),
|
||||||
legLen(segLen('seg01', %), myVar)
|
legLen(segLen('seg01', %), myVar)
|
||||||
], %) // ln-should use legLen for y
|
], %) // ln-should use legLen for y
|
||||||
|> line([
|
|> line([
|
||||||
min(segLen('seg01', %), myVar),
|
min(segLen('seg01', %), myVar),
|
||||||
-legLen(segLen('seg01', %), myVar)
|
-legLen(segLen('seg01', %), myVar)
|
||||||
], %) // ln-legLen but negative
|
], %) // ln-legLen but negative
|
||||||
|> angledLine([-112, segLen('seg01', %)], %) // ln-should become angledLine
|
|> angledLine([-112, segLen('seg01', %)], %) // ln-should become angledLine
|
||||||
|> angledLine([myVar, segLen('seg01', %)], %) // ln-use segLen for secound arg
|
|> angledLine([myVar, segLen('seg01', %)], %) // ln-use segLen for secound arg
|
||||||
|> angledLine([45, segLen('seg01', %)], %) // ln-segLen again
|
|> angledLine([45, segLen('seg01', %)], %) // ln-segLen again
|
||||||
|> angledLine([54, segLen('seg01', %)], %) // ln-should be transformed to angledLine
|
|> angledLine([54, segLen('seg01', %)], %) // ln-should be transformed to angledLine
|
||||||
|> angledLineOfXLength([
|
|> angledLineOfXLength([
|
||||||
legAngX(segLen('seg01', %), myVar),
|
legAngX(segLen('seg01', %), myVar),
|
||||||
min(segLen('seg01', %), myVar)
|
min(segLen('seg01', %), myVar)
|
||||||
], %) // ln-should use legAngX to calculate angle
|
], %) // ln-should use legAngX to calculate angle
|
||||||
|> angledLineOfXLength([
|
|> angledLineOfXLength([
|
||||||
180 + legAngX(segLen('seg01', %), myVar),
|
180 + legAngX(segLen('seg01', %), myVar),
|
||||||
min(segLen('seg01', %), myVar)
|
min(segLen('seg01', %), myVar)
|
||||||
], %) // ln-same as above but should have + 180 to match original quadrant
|
], %) // ln-same as above but should have + 180 to match original quadrant
|
||||||
|> line([
|
|> line([
|
||||||
legLen(segLen('seg01', %), myVar),
|
legLen(segLen('seg01', %), myVar),
|
||||||
min(segLen('seg01', %), myVar)
|
min(segLen('seg01', %), myVar)
|
||||||
], %) // ln-legLen again but yRelative
|
], %) // ln-legLen again but yRelative
|
||||||
|> line([
|
|> line([
|
||||||
-legLen(segLen('seg01', %), myVar),
|
-legLen(segLen('seg01', %), myVar),
|
||||||
min(segLen('seg01', %), myVar)
|
min(segLen('seg01', %), myVar)
|
||||||
], %) // ln-negative legLen yRelative
|
], %) // ln-negative legLen yRelative
|
||||||
|> angledLine([58, segLen('seg01', %)], %) // ln-angledLineOfYLength-free should become angledLine
|
|> angledLine([58, segLen('seg01', %)], %) // ln-angledLineOfYLength-free should become angledLine
|
||||||
|> angledLine([myAng, segLen('seg01', %)], %) // ln-angledLineOfYLength-angle should become angledLine
|
|> angledLine([myAng, segLen('seg01', %)], %) // ln-angledLineOfYLength-angle should become angledLine
|
||||||
|> angledLineOfXLength([
|
|> angledLineOfXLength([
|
||||||
legAngY(segLen('seg01', %), myVar),
|
legAngY(segLen('seg01', %), myVar),
|
||||||
min(segLen('seg01', %), myVar)
|
min(segLen('seg01', %), myVar)
|
||||||
], %) // ln-angledLineOfYLength-yRelative use legAngY
|
], %) // ln-angledLineOfYLength-yRelative use legAngY
|
||||||
|> angledLineOfXLength([
|
|> angledLineOfXLength([
|
||||||
270 + legAngY(segLen('seg01', %), myVar),
|
270 + legAngY(segLen('seg01', %), myVar),
|
||||||
min(segLen('seg01', %), myVar)
|
min(segLen('seg01', %), myVar)
|
||||||
], %) // ln-angledLineOfYLength-yRelative with angle > 90 use binExp
|
], %) // ln-angledLineOfYLength-yRelative with angle > 90 use binExp
|
||||||
|> xLine(segLen('seg01', %), %) // ln-xLine-free should sub in segLen
|
|> xLine(segLen('seg01', %), %) // ln-xLine-free should sub in segLen
|
||||||
|> yLine(segLen('seg01', %), %) // ln-yLine-free should sub in segLen
|
|> yLine(segLen('seg01', %), %) // ln-yLine-free should sub in segLen
|
||||||
|> xLine(segLen('seg01', %), %) // ln-xLineTo-free should convert to xLine
|
|> xLine(segLen('seg01', %), %) // ln-xLineTo-free should convert to xLine
|
||||||
@ -406,9 +406,9 @@ show(part001)`
|
|||||||
'setVertDistance'
|
'setVertDistance'
|
||||||
)
|
)
|
||||||
expect(expectedCode).toContain(`|> lineTo([
|
expect(expectedCode).toContain(`|> lineTo([
|
||||||
lastSegX(%) + myVar,
|
lastSegX(%) + myVar,
|
||||||
segEndY('seg01', %) + 2.93
|
segEndY('seg01', %) + 2.93
|
||||||
], %) // xRelative`)
|
], %) // xRelative`)
|
||||||
})
|
})
|
||||||
it('testing for yRelative to horizontal distance', async () => {
|
it('testing for yRelative to horizontal distance', async () => {
|
||||||
const expectedCode = await helperThing(
|
const expectedCode = await helperThing(
|
||||||
@ -417,9 +417,9 @@ show(part001)`
|
|||||||
'setHorzDistance'
|
'setHorzDistance'
|
||||||
)
|
)
|
||||||
expect(expectedCode).toContain(`|> lineTo([
|
expect(expectedCode).toContain(`|> lineTo([
|
||||||
segEndX('seg01', %) + 2.6,
|
segEndX('seg01', %) + 2.6,
|
||||||
lastSegY(%) + myVar
|
lastSegY(%) + myVar
|
||||||
], %) // yRelative`)
|
], %) // yRelative`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -110,7 +110,7 @@ const yi=45`
|
|||||||
"brace ')' from 17 to 18",
|
"brace ')' from 17 to 18",
|
||||||
])
|
])
|
||||||
expect(stringSummaryLexer('fn funcName = (param1, param2) => {}')).toEqual([
|
expect(stringSummaryLexer('fn funcName = (param1, param2) => {}')).toEqual([
|
||||||
"word 'fn' from 0 to 2",
|
"keyword 'fn' from 0 to 2",
|
||||||
"whitespace ' ' from 2 to 3",
|
"whitespace ' ' from 2 to 3",
|
||||||
"word 'funcName' from 3 to 11",
|
"word 'funcName' from 3 to 11",
|
||||||
"whitespace ' ' from 11 to 12",
|
"whitespace ' ' from 11 to 12",
|
||||||
@ -203,7 +203,7 @@ const yi=45`
|
|||||||
it('testing array declaration', () => {
|
it('testing array declaration', () => {
|
||||||
const result = stringSummaryLexer(`const yo = [1, 2]`)
|
const result = stringSummaryLexer(`const yo = [1, 2]`)
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
"word 'const' from 0 to 5",
|
"keyword 'const' from 0 to 5",
|
||||||
"whitespace ' ' from 5 to 6",
|
"whitespace ' ' from 5 to 6",
|
||||||
"word 'yo' from 6 to 8",
|
"word 'yo' from 6 to 8",
|
||||||
"whitespace ' ' from 8 to 9",
|
"whitespace ' ' from 8 to 9",
|
||||||
@ -220,7 +220,7 @@ const yi=45`
|
|||||||
it('testing object declaration', () => {
|
it('testing object declaration', () => {
|
||||||
const result = stringSummaryLexer(`const yo = {key: 'value'}`)
|
const result = stringSummaryLexer(`const yo = {key: 'value'}`)
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
"word 'const' from 0 to 5",
|
"keyword 'const' from 0 to 5",
|
||||||
"whitespace ' ' from 5 to 6",
|
"whitespace ' ' from 5 to 6",
|
||||||
"word 'yo' from 6 to 8",
|
"word 'yo' from 6 to 8",
|
||||||
"whitespace ' ' from 8 to 9",
|
"whitespace ' ' from 8 to 9",
|
||||||
@ -241,7 +241,7 @@ const prop2 = yo['key']
|
|||||||
const key = 'key'
|
const key = 'key'
|
||||||
const prop3 = yo[key]`)
|
const prop3 = yo[key]`)
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
"word 'const' from 0 to 5",
|
"keyword 'const' from 0 to 5",
|
||||||
"whitespace ' ' from 5 to 6",
|
"whitespace ' ' from 5 to 6",
|
||||||
"word 'yo' from 6 to 8",
|
"word 'yo' from 6 to 8",
|
||||||
"whitespace ' ' from 8 to 9",
|
"whitespace ' ' from 8 to 9",
|
||||||
@ -254,7 +254,7 @@ const prop3 = yo[key]`)
|
|||||||
"string ''value'' from 17 to 24",
|
"string ''value'' from 17 to 24",
|
||||||
"brace '}' from 24 to 25",
|
"brace '}' from 24 to 25",
|
||||||
"whitespace '\n' from 25 to 26",
|
"whitespace '\n' from 25 to 26",
|
||||||
"word 'const' from 26 to 31",
|
"keyword 'const' from 26 to 31",
|
||||||
"whitespace ' ' from 31 to 32",
|
"whitespace ' ' from 31 to 32",
|
||||||
"word 'prop' from 32 to 36",
|
"word 'prop' from 32 to 36",
|
||||||
"whitespace ' ' from 36 to 37",
|
"whitespace ' ' from 36 to 37",
|
||||||
@ -264,7 +264,7 @@ const prop3 = yo[key]`)
|
|||||||
"period '.' from 41 to 42",
|
"period '.' from 41 to 42",
|
||||||
"word 'key' from 42 to 45",
|
"word 'key' from 42 to 45",
|
||||||
"whitespace '\n' from 45 to 46",
|
"whitespace '\n' from 45 to 46",
|
||||||
"word 'const' from 46 to 51",
|
"keyword 'const' from 46 to 51",
|
||||||
"whitespace ' ' from 51 to 52",
|
"whitespace ' ' from 51 to 52",
|
||||||
"word 'prop2' from 52 to 57",
|
"word 'prop2' from 52 to 57",
|
||||||
"whitespace ' ' from 57 to 58",
|
"whitespace ' ' from 57 to 58",
|
||||||
@ -275,7 +275,7 @@ const prop3 = yo[key]`)
|
|||||||
"string ''key'' from 63 to 68",
|
"string ''key'' from 63 to 68",
|
||||||
"brace ']' from 68 to 69",
|
"brace ']' from 68 to 69",
|
||||||
"whitespace '\n' from 69 to 70",
|
"whitespace '\n' from 69 to 70",
|
||||||
"word 'const' from 70 to 75",
|
"keyword 'const' from 70 to 75",
|
||||||
"whitespace ' ' from 75 to 76",
|
"whitespace ' ' from 75 to 76",
|
||||||
"word 'key' from 76 to 79",
|
"word 'key' from 76 to 79",
|
||||||
"whitespace ' ' from 79 to 80",
|
"whitespace ' ' from 79 to 80",
|
||||||
@ -283,7 +283,7 @@ const prop3 = yo[key]`)
|
|||||||
"whitespace ' ' from 81 to 82",
|
"whitespace ' ' from 81 to 82",
|
||||||
"string ''key'' from 82 to 87",
|
"string ''key'' from 82 to 87",
|
||||||
"whitespace '\n' from 87 to 88",
|
"whitespace '\n' from 87 to 88",
|
||||||
"word 'const' from 88 to 93",
|
"keyword 'const' from 88 to 93",
|
||||||
"whitespace ' ' from 93 to 94",
|
"whitespace ' ' from 93 to 94",
|
||||||
"word 'prop3' from 94 to 99",
|
"word 'prop3' from 94 to 99",
|
||||||
"whitespace ' ' from 99 to 100",
|
"whitespace ' ' from 99 to 100",
|
||||||
@ -299,7 +299,7 @@ const prop3 = yo[key]`)
|
|||||||
const result = stringSummaryLexer(`const yo = 45 // this is a comment
|
const result = stringSummaryLexer(`const yo = 45 // this is a comment
|
||||||
const yo = 6`)
|
const yo = 6`)
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
"word 'const' from 0 to 5",
|
"keyword 'const' from 0 to 5",
|
||||||
"whitespace ' ' from 5 to 6",
|
"whitespace ' ' from 5 to 6",
|
||||||
"word 'yo' from 6 to 8",
|
"word 'yo' from 6 to 8",
|
||||||
"whitespace ' ' from 8 to 9",
|
"whitespace ' ' from 8 to 9",
|
||||||
@ -307,9 +307,9 @@ const yo = 6`)
|
|||||||
"whitespace ' ' from 10 to 11",
|
"whitespace ' ' from 10 to 11",
|
||||||
"number '45' from 11 to 13",
|
"number '45' from 11 to 13",
|
||||||
"whitespace ' ' from 13 to 14",
|
"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",
|
"whitespace '\n' from 34 to 35",
|
||||||
"word 'const' from 35 to 40",
|
"keyword 'const' from 35 to 40",
|
||||||
"whitespace ' ' from 40 to 41",
|
"whitespace ' ' from 40 to 41",
|
||||||
"word 'yo' from 41 to 43",
|
"word 'yo' from 41 to 43",
|
||||||
"whitespace ' ' from 43 to 44",
|
"whitespace ' ' from 43 to 44",
|
||||||
@ -328,9 +328,9 @@ const yo=45`)
|
|||||||
"string ''hi'' from 4 to 8",
|
"string ''hi'' from 4 to 8",
|
||||||
"brace ')' from 8 to 9",
|
"brace ')' from 8 to 9",
|
||||||
"whitespace '\n' from 9 to 10",
|
"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",
|
"whitespace '\n' from 40 to 41",
|
||||||
"word 'const' from 41 to 46",
|
"keyword 'const' from 41 to 46",
|
||||||
"whitespace ' ' from 46 to 47",
|
"whitespace ' ' from 46 to 47",
|
||||||
"word 'yo' from 47 to 49",
|
"word 'yo' from 47 to 49",
|
||||||
"operator '=' from 49 to 50",
|
"operator '=' from 49 to 50",
|
||||||
@ -342,7 +342,7 @@ const yo=45`)
|
|||||||
const ya = 6 */
|
const ya = 6 */
|
||||||
const yi=45`)
|
const yi=45`)
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
"word 'const' from 0 to 5",
|
"keyword 'const' from 0 to 5",
|
||||||
"whitespace ' ' from 5 to 6",
|
"whitespace ' ' from 5 to 6",
|
||||||
"word 'yo' from 6 to 8",
|
"word 'yo' from 6 to 8",
|
||||||
"whitespace ' ' from 8 to 9",
|
"whitespace ' ' from 8 to 9",
|
||||||
@ -350,10 +350,10 @@ const yi=45`)
|
|||||||
"whitespace ' ' from 10 to 11",
|
"whitespace ' ' from 10 to 11",
|
||||||
"number '45' from 11 to 13",
|
"number '45' from 11 to 13",
|
||||||
"whitespace ' ' from 13 to 14",
|
"whitespace ' ' from 13 to 14",
|
||||||
`blockcomment '/* this is a comment
|
`blockComment '/* this is a comment
|
||||||
const ya = 6 */' from 14 to 50`,
|
const ya = 6 */' from 14 to 50`,
|
||||||
"whitespace '\n' from 50 to 51",
|
"whitespace '\n' from 50 to 51",
|
||||||
"word 'const' from 51 to 56",
|
"keyword 'const' from 51 to 56",
|
||||||
"whitespace ' ' from 56 to 57",
|
"whitespace ' ' from 56 to 57",
|
||||||
"word 'yi' from 57 to 59",
|
"word 'yi' from 57 to 59",
|
||||||
"operator '=' from 59 to 60",
|
"operator '=' from 59 to 60",
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { lexer_js } from '../wasm-lib/pkg/wasm_lib'
|
import { lexer_js } from '../wasm-lib/pkg/wasm_lib'
|
||||||
import { initPromise } from './rust'
|
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[]> {
|
export async function asyncLexer(str: string): Promise<Token[]> {
|
||||||
await initPromise
|
await initPromise
|
||||||
|
133
src/lib/cameraControls.ts
Normal file
133
src/lib/cameraControls.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
const noModifiersPressed = (e: React.MouseEvent) =>
|
||||||
|
!e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey
|
||||||
|
|
||||||
|
export type CADProgram =
|
||||||
|
| 'KittyCAD'
|
||||||
|
| 'OnShape'
|
||||||
|
| 'Solidworks'
|
||||||
|
| 'NX'
|
||||||
|
| 'Creo'
|
||||||
|
| 'AutoCAD'
|
||||||
|
|
||||||
|
export const cadPrograms: CADProgram[] = [
|
||||||
|
'KittyCAD',
|
||||||
|
'OnShape',
|
||||||
|
'Solidworks',
|
||||||
|
'NX',
|
||||||
|
'Creo',
|
||||||
|
'AutoCAD',
|
||||||
|
]
|
||||||
|
|
||||||
|
interface MouseGuardHandler {
|
||||||
|
description: string
|
||||||
|
callback: (e: React.MouseEvent) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MouseGuardZoomHandler {
|
||||||
|
description: string
|
||||||
|
dragCallback: (e: React.MouseEvent) => boolean
|
||||||
|
scrollCallback: (e: React.MouseEvent) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MouseGuard {
|
||||||
|
pan: MouseGuardHandler
|
||||||
|
zoom: MouseGuardZoomHandler
|
||||||
|
rotate: MouseGuardHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cameraMouseDragGuards: Record<CADProgram, MouseGuard> = {
|
||||||
|
KittyCAD: {
|
||||||
|
pan: {
|
||||||
|
description: 'Right click + Shift + drag or middle click + drag',
|
||||||
|
callback: (e) =>
|
||||||
|
(e.button === 3 && noModifiersPressed(e)) ||
|
||||||
|
(e.button === 2 && e.shiftKey),
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
description: 'Scroll wheel or Right click + Ctrl + drag',
|
||||||
|
dragCallback: (e) => e.button === 2 && e.ctrlKey,
|
||||||
|
scrollCallback: () => true,
|
||||||
|
},
|
||||||
|
rotate: {
|
||||||
|
description: 'Right click + drag',
|
||||||
|
callback: (e) => e.button === 2 && noModifiersPressed(e),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OnShape: {
|
||||||
|
pan: {
|
||||||
|
description: 'Right click + Ctrl + drag or middle click + drag',
|
||||||
|
callback: (e) =>
|
||||||
|
(e.button === 2 && e.ctrlKey) ||
|
||||||
|
(e.button === 3 && noModifiersPressed(e)),
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
description: 'Scroll wheel',
|
||||||
|
dragCallback: () => false,
|
||||||
|
scrollCallback: () => true,
|
||||||
|
},
|
||||||
|
rotate: {
|
||||||
|
description: 'Right click + drag',
|
||||||
|
callback: (e) => e.button === 2 && noModifiersPressed(e),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Solidworks: {
|
||||||
|
pan: {
|
||||||
|
description: 'Right click + Ctrl + drag',
|
||||||
|
callback: (e) => e.button === 2 && e.ctrlKey,
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
description: 'Scroll wheel or Middle click + Shift + drag',
|
||||||
|
dragCallback: (e) => e.button === 3 && e.shiftKey,
|
||||||
|
scrollCallback: () => true,
|
||||||
|
},
|
||||||
|
rotate: {
|
||||||
|
description: 'Middle click + drag',
|
||||||
|
callback: (e) => e.button === 3 && noModifiersPressed(e),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NX: {
|
||||||
|
pan: {
|
||||||
|
description: 'Middle click + Shift + drag',
|
||||||
|
callback: (e) => e.button === 3 && e.shiftKey,
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
description: 'Scroll wheel or Middle click + Ctrl + drag',
|
||||||
|
dragCallback: (e) => e.button === 3 && e.ctrlKey,
|
||||||
|
scrollCallback: () => true,
|
||||||
|
},
|
||||||
|
rotate: {
|
||||||
|
description: 'Middle click + drag',
|
||||||
|
callback: (e) => e.button === 3 && noModifiersPressed(e),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Creo: {
|
||||||
|
pan: {
|
||||||
|
description: 'Middle click + Shift + drag',
|
||||||
|
callback: (e) => e.button === 3 && e.shiftKey,
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
description: 'Scroll wheel or Middle click + Ctrl + drag',
|
||||||
|
dragCallback: (e) => e.button === 3 && e.ctrlKey,
|
||||||
|
scrollCallback: () => true,
|
||||||
|
},
|
||||||
|
rotate: {
|
||||||
|
description: 'Middle click + drag',
|
||||||
|
callback: (e) => e.button === 3 && noModifiersPressed(e),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AutoCAD: {
|
||||||
|
pan: {
|
||||||
|
description: 'Middle click + drag',
|
||||||
|
callback: (e) => e.button === 3 && noModifiersPressed(e),
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
description: 'Scroll wheel',
|
||||||
|
dragCallback: () => false,
|
||||||
|
scrollCallback: () => true,
|
||||||
|
},
|
||||||
|
rotate: {
|
||||||
|
description: 'Middle click + Shift + drag',
|
||||||
|
callback: (e) => e.button === 3 && e.shiftKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
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>(
|
const credentials = 'include' as RequestCredentials
|
||||||
input: RequestInfo,
|
const res = await fetch(input, { ...init, credentials, headers })
|
||||||
init: RequestInit = {}
|
return res.json()
|
||||||
): Promise<JSON> {
|
|
||||||
const [token] = useAuthMachine((s) => s?.context?.token)
|
|
||||||
const headers = { ...init.headers } as Record<string, string>
|
|
||||||
if (token) {
|
|
||||||
headers.Authorization = `Bearer ${token}`
|
|
||||||
}
|
}
|
||||||
|
return fetcherWithToken
|
||||||
const credentials = 'include' as RequestCredentials
|
|
||||||
const res = await fetch(input, { ...init, credentials, headers })
|
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
@ -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 { documentDir } from '@tauri-apps/api/path'
|
||||||
import { useStore } from '../useStore'
|
|
||||||
import { isTauri } from './isTauri'
|
import { isTauri } from './isTauri'
|
||||||
import { ProjectWithEntryPointMetadata } from '../Router'
|
import { ProjectWithEntryPointMetadata } from '../Router'
|
||||||
import { metadata } from 'tauri-plugin-fs-extra-api'
|
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
|
export const MAX_PADDING = 7
|
||||||
|
|
||||||
// Initializes the project directory and returns the path
|
// Initializes the project directory and returns the path
|
||||||
export async function initializeProjectDirectory() {
|
export async function initializeProjectDirectory(directory: string) {
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'initializeProjectDirectory() can only be called from a Tauri app'
|
'initializeProjectDirectory() can only be called from a Tauri app'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const { defaultDir: projectDir, setDefaultDir } = useStore.getState()
|
|
||||||
|
|
||||||
if (projectDir && projectDir.dir.length > 0) {
|
if (directory) {
|
||||||
const dirExists = await exists(projectDir.dir)
|
const dirExists = await exists(directory)
|
||||||
if (!dirExists) {
|
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 = {
|
const INITIAL_DEFAULT_DIR = docDirectory + PROJECT_FOLDER
|
||||||
dir: appData + PROJECT_FOLDER,
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR.dir)
|
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR)
|
||||||
|
|
||||||
if (!defaultDirExists) {
|
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
|
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
|
// Creates a new file in the default directory with the default project name
|
||||||
// Returns the path to the new file
|
// Returns the path to the new file
|
||||||
export async function createNewProject(
|
export async function createNewProject(
|
||||||
|
@ -39,7 +39,6 @@ class MockEngineCommandManager {
|
|||||||
if (commandStr === undefined) {
|
if (commandStr === undefined) {
|
||||||
throw new Error('commandStr is undefined')
|
throw new Error('commandStr is undefined')
|
||||||
}
|
}
|
||||||
console.log('sendModelingCommandFromWasm', id, rangeStr, commandStr)
|
|
||||||
const command: EngineCommand = JSON.parse(commandStr)
|
const command: EngineCommand = JSON.parse(commandStr)
|
||||||
const range: SourceRange = JSON.parse(rangeStr)
|
const range: SourceRange = JSON.parse(rangeStr)
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
@ -56,6 +56,27 @@ export function throttle<T>(
|
|||||||
return throttled
|
return throttled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// takes a function and executes it after the wait time, if the function is called again before the wait time is up, the timer is reset
|
||||||
|
export function defferExecution<T>(func: (args: T) => any, wait: number) {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null
|
||||||
|
let latestArgs: T
|
||||||
|
|
||||||
|
function later() {
|
||||||
|
timeout = null
|
||||||
|
func(latestArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deffered(args: T) {
|
||||||
|
latestArgs = args
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
timeout = setTimeout(later, wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
return deffered
|
||||||
|
}
|
||||||
|
|
||||||
export function getNormalisedCoordinates({
|
export function getNormalisedCoordinates({
|
||||||
clientX,
|
clientX,
|
||||||
clientY,
|
clientY,
|
||||||
|
@ -1,6 +1,24 @@
|
|||||||
import { createMachine, assign } from 'xstate'
|
import { createMachine, assign } from 'xstate'
|
||||||
import { Models } from '@kittycad/lib'
|
import { Models } from '@kittycad/lib'
|
||||||
import withBaseURL from '../lib/withBaseURL'
|
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 {
|
export interface UserContext {
|
||||||
user?: Models['User_type']
|
user?: Models['User_type']
|
||||||
@ -9,16 +27,22 @@ export interface UserContext {
|
|||||||
|
|
||||||
export type Events =
|
export type Events =
|
||||||
| {
|
| {
|
||||||
type: 'logout'
|
type: 'Log out'
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'tryLogin'
|
type: 'Log in'
|
||||||
token?: string
|
token?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
|
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
|
||||||
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
|
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
|
||||||
|
|
||||||
|
export const authCommandBarMeta: CommandBarMeta = {
|
||||||
|
'Log in': {
|
||||||
|
hide: 'both',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const authMachine = createMachine<UserContext, Events>(
|
export const authMachine = createMachine<UserContext, Events>(
|
||||||
{
|
{
|
||||||
id: 'Auth',
|
id: 'Auth',
|
||||||
@ -50,7 +74,7 @@ export const authMachine = createMachine<UserContext, Events>(
|
|||||||
loggedIn: {
|
loggedIn: {
|
||||||
entry: ['goToIndexPage'],
|
entry: ['goToIndexPage'],
|
||||||
on: {
|
on: {
|
||||||
logout: {
|
'Log out': {
|
||||||
target: 'loggedOut',
|
target: 'loggedOut',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -58,10 +82,10 @@ export const authMachine = createMachine<UserContext, Events>(
|
|||||||
loggedOut: {
|
loggedOut: {
|
||||||
entry: ['goToSignInPage'],
|
entry: ['goToSignInPage'],
|
||||||
on: {
|
on: {
|
||||||
tryLogin: {
|
'Log in': {
|
||||||
target: 'checkIfLoggedIn',
|
target: 'checkIfLoggedIn',
|
||||||
actions: assign({
|
actions: assign({
|
||||||
token: (context, event) => {
|
token: (_, event) => {
|
||||||
const token = event.token || ''
|
const token = event.token || ''
|
||||||
localStorage.setItem(TOKEN_PERSIST_KEY, token)
|
localStorage.setItem(TOKEN_PERSIST_KEY, token)
|
||||||
return 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,
|
predictableActionArguments: true,
|
||||||
preserveActionOrder: true,
|
preserveActionOrder: true,
|
||||||
context: { token: persistedToken },
|
context: {
|
||||||
|
token: persistedToken,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
actions: {},
|
actions: {},
|
||||||
@ -91,12 +117,17 @@ async function getUser(context: UserContext) {
|
|||||||
}
|
}
|
||||||
if (!context.token && '__TAURI__' in window) throw 'not log in'
|
if (!context.token && '__TAURI__' in window) throw 'not log in'
|
||||||
if (context.token) headers['Authorization'] = `Bearer ${context.token}`
|
if (context.token) headers['Authorization'] = `Bearer ${context.token}`
|
||||||
const response = await fetch(url, {
|
if (SKIP_AUTH) return LOCAL_USER
|
||||||
method: 'GET',
|
try {
|
||||||
credentials: 'include',
|
const response = await fetch(url, {
|
||||||
headers,
|
method: 'GET',
|
||||||
})
|
credentials: 'include',
|
||||||
const user = await response.json()
|
headers,
|
||||||
if ('error_code' in user) throw new Error(user.message)
|
})
|
||||||
return user
|
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
|
||||||
|
}
|
269
src/machines/settingsMachine.ts
Normal file
269
src/machines/settingsMachine.ts
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
import { assign, createMachine } from 'xstate'
|
||||||
|
import { CommandBarMeta } from '../lib/commands'
|
||||||
|
import { Themes, getSystemTheme, setThemeClass } from '../lib/theme'
|
||||||
|
import { CADProgram, cadPrograms } from 'lib/cameraControls'
|
||||||
|
|
||||||
|
export const DEFAULT_PROJECT_NAME = 'project-$nnn'
|
||||||
|
|
||||||
|
export enum UnitSystem {
|
||||||
|
Imperial = 'imperial',
|
||||||
|
Metric = 'metric',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const baseUnits = {
|
||||||
|
imperial: ['in', 'ft'],
|
||||||
|
metric: ['mm', 'cm', 'm'],
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type BaseUnit = 'in' | 'ft' | 'mm' | 'cm' | 'm'
|
||||||
|
|
||||||
|
export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v)
|
||||||
|
|
||||||
|
export type Toggle = 'On' | 'Off'
|
||||||
|
|
||||||
|
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
|
||||||
|
|
||||||
|
export const settingsCommandBarMeta: CommandBarMeta = {
|
||||||
|
'Set 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 Camera Controls': {
|
||||||
|
displayValue: (args: string[]) => 'Set your camera controls',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'cameraControls',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'cameraControls',
|
||||||
|
options: Object.values(cadPrograms).map((v) => ({ name: v })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Set Default Directory': {
|
||||||
|
hide: 'both',
|
||||||
|
},
|
||||||
|
'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 Onboarding Status': {
|
||||||
|
hide: 'both',
|
||||||
|
},
|
||||||
|
'Set Text Wrapping': {
|
||||||
|
displayValue: (args: string[]) => 'Set whether text in the editor wraps',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'textWrapping',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'textWrapping',
|
||||||
|
options: [{ name: 'On' }, { name: 'Off' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Set Theme': {
|
||||||
|
displayValue: (args: string[]) => 'Change the app theme',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'theme',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'theme',
|
||||||
|
options: Object.values(Themes).map((v): { name: string } => ({
|
||||||
|
name: v,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Set Unit System': {
|
||||||
|
displayValue: (args: string[]) => 'Set your default unit system',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'unitSystem',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'unitSystem',
|
||||||
|
options: [{ name: UnitSystem.Imperial }, { name: UnitSystem.Metric }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsMachine = createMachine(
|
||||||
|
{
|
||||||
|
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */
|
||||||
|
id: 'Settings',
|
||||||
|
predictableActionArguments: true,
|
||||||
|
context: {
|
||||||
|
baseUnit: 'in' as BaseUnit,
|
||||||
|
cameraControls: 'KittyCAD' as CADProgram,
|
||||||
|
defaultDirectory: '',
|
||||||
|
defaultProjectName: DEFAULT_PROJECT_NAME,
|
||||||
|
onboardingStatus: '',
|
||||||
|
showDebugPanel: false,
|
||||||
|
textWrapping: 'On' as Toggle,
|
||||||
|
theme: Themes.System,
|
||||||
|
unitSystem: UnitSystem.Imperial,
|
||||||
|
},
|
||||||
|
initial: 'idle',
|
||||||
|
states: {
|
||||||
|
idle: {
|
||||||
|
entry: ['setThemeClass'],
|
||||||
|
on: {
|
||||||
|
'Set Base Unit': {
|
||||||
|
actions: [
|
||||||
|
assign({ baseUnit: (_, event) => event.data.baseUnit }),
|
||||||
|
'persistSettings',
|
||||||
|
'toastSuccess',
|
||||||
|
],
|
||||||
|
target: 'idle',
|
||||||
|
internal: true,
|
||||||
|
},
|
||||||
|
'Set Camera Controls': {
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
cameraControls: (_, event) => event.data.cameraControls,
|
||||||
|
}),
|
||||||
|
'persistSettings',
|
||||||
|
'toastSuccess',
|
||||||
|
],
|
||||||
|
target: 'idle',
|
||||||
|
internal: true,
|
||||||
|
},
|
||||||
|
'Set Default Directory': {
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
defaultDirectory: (_, event) => event.data.defaultDirectory,
|
||||||
|
}),
|
||||||
|
'persistSettings',
|
||||||
|
'toastSuccess',
|
||||||
|
],
|
||||||
|
target: 'idle',
|
||||||
|
internal: true,
|
||||||
|
},
|
||||||
|
'Set Default Project Name': {
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
defaultProjectName: (_, event) =>
|
||||||
|
event.data.defaultProjectName.trim() || DEFAULT_PROJECT_NAME,
|
||||||
|
}),
|
||||||
|
'persistSettings',
|
||||||
|
'toastSuccess',
|
||||||
|
],
|
||||||
|
target: 'idle',
|
||||||
|
internal: true,
|
||||||
|
},
|
||||||
|
'Set Onboarding Status': {
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
onboardingStatus: (_, event) => event.data.onboardingStatus,
|
||||||
|
}),
|
||||||
|
'persistSettings',
|
||||||
|
],
|
||||||
|
target: 'idle',
|
||||||
|
internal: true,
|
||||||
|
},
|
||||||
|
'Set Text Wrapping': {
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
textWrapping: (_, event) => event.data.textWrapping,
|
||||||
|
}),
|
||||||
|
'persistSettings',
|
||||||
|
'toastSuccess',
|
||||||
|
],
|
||||||
|
target: 'idle',
|
||||||
|
internal: true,
|
||||||
|
},
|
||||||
|
'Set Theme': {
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
theme: (_, event) => event.data.theme,
|
||||||
|
}),
|
||||||
|
'persistSettings',
|
||||||
|
'toastSuccess',
|
||||||
|
'setThemeClass',
|
||||||
|
],
|
||||||
|
target: 'idle',
|
||||||
|
internal: true,
|
||||||
|
},
|
||||||
|
'Set Unit System': {
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
unitSystem: (_, event) => event.data.unitSystem,
|
||||||
|
baseUnit: (_, event) =>
|
||||||
|
event.data.unitSystem === 'imperial' ? 'in' : 'mm',
|
||||||
|
}),
|
||||||
|
'persistSettings',
|
||||||
|
'toastSuccess',
|
||||||
|
],
|
||||||
|
target: 'idle',
|
||||||
|
internal: true,
|
||||||
|
},
|
||||||
|
'Toggle Debug Panel': {
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
showDebugPanel: (context) => {
|
||||||
|
return !context.showDebugPanel
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'persistSettings',
|
||||||
|
'toastSuccess',
|
||||||
|
],
|
||||||
|
target: 'idle',
|
||||||
|
internal: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tsTypes: {} as import('./settingsMachine.typegen').Typegen0,
|
||||||
|
schema: {
|
||||||
|
events: {} as
|
||||||
|
| { type: 'Set Base Unit'; data: { baseUnit: BaseUnit } }
|
||||||
|
| { type: 'Set Camera Controls'; data: { cameraControls: CADProgram } }
|
||||||
|
| { type: 'Set Default Directory'; data: { defaultDirectory: string } }
|
||||||
|
| {
|
||||||
|
type: 'Set Default Project Name'
|
||||||
|
data: { defaultProjectName: string }
|
||||||
|
}
|
||||||
|
| { type: 'Set Onboarding Status'; data: { onboardingStatus: string } }
|
||||||
|
| { type: 'Set Text Wrapping'; data: { textWrapping: Toggle } }
|
||||||
|
| { type: 'Set Theme'; data: { theme: Themes } }
|
||||||
|
| {
|
||||||
|
type: 'Set Unit System'
|
||||||
|
data: { unitSystem: UnitSystem }
|
||||||
|
}
|
||||||
|
| { 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
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
52
src/machines/settingsMachine.typegen.ts
Normal file
52
src/machines/settingsMachine.typegen.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// 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 Camera Controls'
|
||||||
|
| 'Set Default Directory'
|
||||||
|
| 'Set Default Project Name'
|
||||||
|
| 'Set Onboarding Status'
|
||||||
|
| 'Set Text Wrapping'
|
||||||
|
| 'Set Theme'
|
||||||
|
| 'Set Unit System'
|
||||||
|
| 'Toggle Debug Panel'
|
||||||
|
setThemeClass:
|
||||||
|
| 'Set Base Unit'
|
||||||
|
| 'Set Camera Controls'
|
||||||
|
| 'Set Default Directory'
|
||||||
|
| 'Set Default Project Name'
|
||||||
|
| 'Set Onboarding Status'
|
||||||
|
| 'Set Text Wrapping'
|
||||||
|
| 'Set Theme'
|
||||||
|
| 'Set Unit System'
|
||||||
|
| 'Toggle Debug Panel'
|
||||||
|
| 'xstate.init'
|
||||||
|
toastSuccess:
|
||||||
|
| 'Set Base Unit'
|
||||||
|
| 'Set Camera Controls'
|
||||||
|
| 'Set Default Directory'
|
||||||
|
| 'Set Default Project Name'
|
||||||
|
| 'Set Text Wrapping'
|
||||||
|
| 'Set Theme'
|
||||||
|
| 'Set Unit System'
|
||||||
|
| 'Toggle Debug Panel'
|
||||||
|
}
|
||||||
|
eventsCausingDelays: {}
|
||||||
|
eventsCausingGuards: {}
|
||||||
|
eventsCausingServices: {}
|
||||||
|
matchesStates: 'idle'
|
||||||
|
tags: never
|
||||||
|
}
|
@ -1,93 +1,158 @@
|
|||||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
import { FormEvent, useEffect } from 'react'
|
||||||
import { readDir, removeDir, renameFile } from '@tauri-apps/api/fs'
|
import { removeDir, renameFile } from '@tauri-apps/api/fs'
|
||||||
import {
|
import {
|
||||||
createNewProject,
|
createNewProject,
|
||||||
getNextProjectIndex,
|
getNextProjectIndex,
|
||||||
interpolateProjectNameWithIndex,
|
interpolateProjectNameWithIndex,
|
||||||
doesProjectNameNeedInterpolated,
|
doesProjectNameNeedInterpolated,
|
||||||
isProjectDirectory,
|
getProjectsInDir,
|
||||||
PROJECT_ENTRYPOINT,
|
|
||||||
} from '../lib/tauriFS'
|
} from '../lib/tauriFS'
|
||||||
import { ActionButton } from '../components/ActionButton'
|
import { ActionButton } from '../components/ActionButton'
|
||||||
import {
|
import { faArrowDown, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||||
faArrowDown,
|
|
||||||
faArrowUp,
|
|
||||||
faCircleDot,
|
|
||||||
faPlus,
|
|
||||||
} from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { useStore } from '../useStore'
|
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
import { AppHeader } from '../components/AppHeader'
|
import { AppHeader } from '../components/AppHeader'
|
||||||
import ProjectCard from '../components/ProjectCard'
|
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 { Link } from 'react-router-dom'
|
||||||
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
|
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
|
||||||
import Loading from '../components/Loading'
|
import Loading from '../components/Loading'
|
||||||
import { metadata } from 'tauri-plugin-fs-extra-api'
|
import { useMachine } from '@xstate/react'
|
||||||
|
import { homeCommandMeta, homeMachine } from '../machines/homeMachine'
|
||||||
const DESC = ':desc'
|
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'
|
||||||
|
import { DEFAULT_PROJECT_NAME } from 'machines/settingsMachine'
|
||||||
|
|
||||||
// This route only opens in the Tauri desktop context for now,
|
// 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.
|
// as defined in Router.tsx, so we can use the Tauri APIs and types.
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const { commands, setCommandBarOpen } = useCommandsContext()
|
||||||
const sort = searchParams.get('sort_by') ?? 'modified:desc'
|
const navigate = useNavigate()
|
||||||
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
|
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const {
|
||||||
const [projects, setProjects] = useState(loadedProjects || [])
|
settings: {
|
||||||
const { defaultDir, defaultProjectName } = useStore((s) => ({
|
context: { defaultDirectory, defaultProjectName },
|
||||||
defaultDir: s.defaultDir,
|
send: sendToSettings,
|
||||||
defaultProjectName: s.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
|
||||||
|
).trim()
|
||||||
|
let shouldUpdateDefaultProjectName = false
|
||||||
|
|
||||||
const refreshProjects = useCallback(
|
// If there is no default project name, flag it to be set to the default
|
||||||
async (projectDir = defaultDir) => {
|
if (!name) {
|
||||||
const readProjects = (
|
name = DEFAULT_PROJECT_NAME
|
||||||
await readDir(projectDir.dir, {
|
shouldUpdateDefaultProjectName = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doesProjectNameNeedInterpolated(name)) {
|
||||||
|
const nextIndex = await getNextProjectIndex(name, projects)
|
||||||
|
name = interpolateProjectNameWithIndex(name, nextIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
await createNewProject(context.defaultDirectory + '/' + name)
|
||||||
|
|
||||||
|
if (shouldUpdateDefaultProjectName) {
|
||||||
|
sendToSettings({
|
||||||
|
type: 'Set Default Project Name',
|
||||||
|
data: { defaultProjectName: DEFAULT_PROJECT_NAME },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Successfully created "${name}"`
|
||||||
|
},
|
||||||
|
renameProject: async (
|
||||||
|
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,
|
recursive: true,
|
||||||
})
|
})
|
||||||
).filter(isProjectDirectory)
|
return `Successfully deleted "${event.data.name}"`
|
||||||
|
},
|
||||||
const projectsWithMetadata = await Promise.all(
|
|
||||||
readProjects.map(async (p) => ({
|
|
||||||
entrypoint_metadata: await metadata(
|
|
||||||
p.path + '/' + PROJECT_ENTRYPOINT
|
|
||||||
),
|
|
||||||
...p,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
setProjects(projectsWithMetadata)
|
|
||||||
},
|
},
|
||||||
[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(() => {
|
useEffect(() => {
|
||||||
refreshProjects(defaultDir).then(() => {
|
send({ type: 'assign', data: { defaultProjectName, defaultDirectory } })
|
||||||
setIsLoading(false)
|
}, [defaultDirectory, defaultProjectName, send])
|
||||||
})
|
|
||||||
}, [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')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRenameProject(
|
async function handleRenameProject(
|
||||||
e: FormEvent<HTMLFormElement>,
|
e: FormEvent<HTMLFormElement>,
|
||||||
@ -96,85 +161,14 @@ const Home = () => {
|
|||||||
const { newProjectName } = Object.fromEntries(
|
const { newProjectName } = Object.fromEntries(
|
||||||
new FormData(e.target as HTMLFormElement)
|
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()
|
send('Rename project', {
|
||||||
toast.success('Project renamed')
|
data: { oldName: project.name, newName: newProjectName },
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteProject(project: ProjectWithEntryPointMetadata) {
|
async function handleDeleteProject(project: ProjectWithEntryPointMetadata) {
|
||||||
if (project.path) {
|
send('Delete project', { data: { name: project.name || '' } })
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -191,9 +185,9 @@ const Home = () => {
|
|||||||
? 'text-chalkboard-80 dark:text-chalkboard-40'
|
? 'text-chalkboard-80 dark:text-chalkboard-40'
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
onClick={() => setSearchParams(getNextSearchParams('name'))}
|
onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))}
|
||||||
icon={{
|
icon={{
|
||||||
icon: getSortIcon('name'),
|
icon: getSortIcon(sort, 'name'),
|
||||||
bgClassName: !sort?.includes('name')
|
bgClassName: !sort?.includes('name')
|
||||||
? 'bg-liquid-50 dark:bg-liquid-70'
|
? 'bg-liquid-50 dark:bg-liquid-70'
|
||||||
: '',
|
: '',
|
||||||
@ -207,17 +201,19 @@ const Home = () => {
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
Element="button"
|
Element="button"
|
||||||
className={
|
className={
|
||||||
!modifiedSelected
|
!isSortByModified
|
||||||
? 'text-chalkboard-80 dark:text-chalkboard-40'
|
? 'text-chalkboard-80 dark:text-chalkboard-40'
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
onClick={() => setSearchParams(getNextSearchParams('modified'))}
|
onClick={() =>
|
||||||
|
setSearchParams(getNextSearchParams(sort, 'modified'))
|
||||||
|
}
|
||||||
icon={{
|
icon={{
|
||||||
icon: sort ? getSortIcon('modified') : faArrowDown,
|
icon: sort ? getSortIcon(sort, 'modified') : faArrowDown,
|
||||||
bgClassName: !modifiedSelected
|
bgClassName: !isSortByModified
|
||||||
? 'bg-liquid-50 dark:bg-liquid-70'
|
? 'bg-liquid-50 dark:bg-liquid-70'
|
||||||
: '',
|
: '',
|
||||||
iconClassName: !modifiedSelected
|
iconClassName: !isSortByModified
|
||||||
? 'text-liquid-80 dark:text-liquid-30'
|
? 'text-liquid-80 dark:text-liquid-30'
|
||||||
: '',
|
: '',
|
||||||
}}
|
}}
|
||||||
@ -230,11 +226,11 @@ const Home = () => {
|
|||||||
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
|
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
|
||||||
Are being saved at{' '}
|
Are being saved at{' '}
|
||||||
<code className="text-liquid-80 dark:text-liquid-30">
|
<code className="text-liquid-80 dark:text-liquid-30">
|
||||||
{defaultDir.dir}
|
{defaultDirectory}
|
||||||
</code>
|
</code>
|
||||||
, which you can change in your <Link to="settings">Settings</Link>.
|
, which you can change in your <Link to="settings">Settings</Link>.
|
||||||
</p>
|
</p>
|
||||||
{isLoading ? (
|
{state.matches('Reading projects') ? (
|
||||||
<Loading>Loading your Projects...</Loading>
|
<Loading>Loading your Projects...</Loading>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -256,7 +252,7 @@ const Home = () => {
|
|||||||
)}
|
)}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
Element="button"
|
Element="button"
|
||||||
onClick={handleNewProject}
|
onClick={() => send('Create project')}
|
||||||
icon={{ icon: faPlus }}
|
icon={{ icon: faPlus }}
|
||||||
>
|
>
|
||||||
New file
|
New file
|
||||||
|
@ -4,8 +4,8 @@ import { onboardingPaths, useDismiss, useNextClick } from '.'
|
|||||||
import { useStore } from '../../useStore'
|
import { useStore } from '../../useStore'
|
||||||
|
|
||||||
export default function Units() {
|
export default function Units() {
|
||||||
const { isMouseDownInStream } = useStore((s) => ({
|
const { buttonDownInStream } = useStore((s) => ({
|
||||||
isMouseDownInStream: s.isMouseDownInStream,
|
buttonDownInStream: s.buttonDownInStream,
|
||||||
}))
|
}))
|
||||||
const dismiss = useDismiss()
|
const dismiss = useDismiss()
|
||||||
const next = useNextClick(onboardingPaths.SKETCHING)
|
const next = useNextClick(onboardingPaths.SKETCHING)
|
||||||
@ -15,14 +15,30 @@ export default function Units() {
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'max-w-2xl flex flex-col justify-center bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded' +
|
'max-w-2xl flex flex-col justify-center bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded' +
|
||||||
(isMouseDownInStream ? '' : ' pointer-events-auto')
|
(buttonDownInStream ? '' : ' pointer-events-auto')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<h1 className="text-2xl font-bold">Camera</h1>
|
<h1 className="text-2xl font-bold">Camera</h1>
|
||||||
<p className="mt-6">
|
<p className="mt-6">
|
||||||
Moving the camera is easy. Just click and drag anywhere in the scene
|
Moving the camera is easy! The controls are as you might expect:
|
||||||
to rotate the camera, or hold down the <kbd>Ctrl</kbd> key and drag to
|
</p>
|
||||||
pan the camera.
|
<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>
|
</p>
|
||||||
<div className="flex justify-between mt-6">
|
<div className="flex justify-between mt-6">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
@ -1,34 +1,21 @@
|
|||||||
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { baseUnits, useStore } from '../../useStore'
|
import { BaseUnit, baseUnits } from '../../machines/settingsMachine'
|
||||||
import { ActionButton } from '../../components/ActionButton'
|
import { ActionButton } from '../../components/ActionButton'
|
||||||
import { SettingsSection } from '../Settings'
|
import { SettingsSection } from '../Settings'
|
||||||
import { Toggle } from '../../components/Toggle/Toggle'
|
import { Toggle } from '../../components/Toggle/Toggle'
|
||||||
import { useState } from 'react'
|
|
||||||
import { onboardingPaths, useDismiss, useNextClick } from '.'
|
import { onboardingPaths, useDismiss, useNextClick } from '.'
|
||||||
|
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||||
|
import { UnitSystem } from 'machines/settingsMachine'
|
||||||
|
|
||||||
export default function Units() {
|
export default function Units() {
|
||||||
const dismiss = useDismiss()
|
const dismiss = useDismiss()
|
||||||
const next = useNextClick(onboardingPaths.CAMERA)
|
const next = useNextClick(onboardingPaths.CAMERA)
|
||||||
const {
|
const {
|
||||||
defaultUnitSystem: ogDefaultUnitSystem,
|
settings: {
|
||||||
setDefaultUnitSystem: saveDefaultUnitSystem,
|
send,
|
||||||
defaultBaseUnit: ogDefaultBaseUnit,
|
context: { unitSystem, baseUnit },
|
||||||
setDefaultBaseUnit: saveDefaultBaseUnit,
|
},
|
||||||
} = useStore((s) => ({
|
} = useGlobalStateContext()
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50">
|
<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"
|
offLabel="Imperial"
|
||||||
onLabel="Metric"
|
onLabel="Metric"
|
||||||
name="settings-units"
|
name="settings-units"
|
||||||
checked={defaultUnitSystem === 'metric'}
|
checked={unitSystem === UnitSystem.Metric}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setDefaultUnitSystem(e.target.checked ? 'metric' : 'imperial')
|
const newUnitSystem = e.target.checked
|
||||||
}
|
? UnitSystem.Metric
|
||||||
|
: UnitSystem.Imperial
|
||||||
|
send({
|
||||||
|
type: 'Set Unit System',
|
||||||
|
data: { unitSystem: newUnitSystem },
|
||||||
|
})
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
@ -55,10 +48,15 @@ export default function Units() {
|
|||||||
<select
|
<select
|
||||||
id="base-unit"
|
id="base-unit"
|
||||||
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
|
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
|
||||||
value={defaultBaseUnit}
|
value={baseUnit}
|
||||||
onChange={(e) => setDefaultBaseUnit(e.target.value)}
|
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}>
|
<option key={unit} value={unit}>
|
||||||
{unit}
|
{unit}
|
||||||
</option>
|
</option>
|
||||||
@ -81,7 +79,7 @@ export default function Units() {
|
|||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
Element="button"
|
Element="button"
|
||||||
onClick={handleNextClick}
|
onClick={next}
|
||||||
icon={{ icon: faArrowRight }}
|
icon={{ icon: faArrowRight }}
|
||||||
>
|
>
|
||||||
Next: Camera
|
Next: Camera
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { Outlet, useNavigate } from 'react-router-dom'
|
import { Outlet, useNavigate } from 'react-router-dom'
|
||||||
import { useStore } from '../../useStore'
|
|
||||||
|
|
||||||
import Introduction from './Introduction'
|
import Introduction from './Introduction'
|
||||||
import Units from './Units'
|
import Units from './Units'
|
||||||
import Camera from './Camera'
|
import Camera from './Camera'
|
||||||
import Sketching from './Sketching'
|
import Sketching from './Sketching'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import makeUrlPathRelative from '../../lib/makeUrlPathRelative'
|
import makeUrlPathRelative from '../../lib/makeUrlPathRelative'
|
||||||
|
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||||
|
|
||||||
export const onboardingPaths = {
|
export const onboardingPaths = {
|
||||||
INDEX: '/',
|
INDEX: '/',
|
||||||
@ -36,29 +35,35 @@ export const onboardingRoutes = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function useNextClick(newStatus: string) {
|
export function useNextClick(newStatus: string) {
|
||||||
const { setOnboardingStatus } = useStore((s) => ({
|
const {
|
||||||
setOnboardingStatus: s.setOnboardingStatus,
|
settings: { send },
|
||||||
}))
|
} = useGlobalStateContext()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return useCallback(() => {
|
return useCallback(() => {
|
||||||
setOnboardingStatus(newStatus)
|
send({
|
||||||
|
type: 'Set Onboarding Status',
|
||||||
|
data: { onboardingStatus: newStatus },
|
||||||
|
})
|
||||||
navigate((newStatus !== onboardingPaths.UNITS ? '..' : '.') + newStatus)
|
navigate((newStatus !== onboardingPaths.UNITS ? '..' : '.') + newStatus)
|
||||||
}, [newStatus, setOnboardingStatus, navigate])
|
}, [newStatus, send, navigate])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDismiss() {
|
export function useDismiss() {
|
||||||
const { setOnboardingStatus } = useStore((s) => ({
|
const {
|
||||||
setOnboardingStatus: s.setOnboardingStatus,
|
settings: { send },
|
||||||
}))
|
} = useGlobalStateContext()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
setOnboardingStatus('dismissed')
|
send({
|
||||||
|
type: 'Set Onboarding Status',
|
||||||
|
data: { onboardingStatus: 'dismissed' },
|
||||||
|
})
|
||||||
navigate(path)
|
navigate(path)
|
||||||
},
|
},
|
||||||
[setOnboardingStatus, navigate]
|
[send, navigate]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,59 +6,58 @@ import {
|
|||||||
import { ActionButton } from '../components/ActionButton'
|
import { ActionButton } from '../components/ActionButton'
|
||||||
import { AppHeader } from '../components/AppHeader'
|
import { AppHeader } from '../components/AppHeader'
|
||||||
import { open } from '@tauri-apps/api/dialog'
|
import { open } from '@tauri-apps/api/dialog'
|
||||||
import { Themes, baseUnits, useStore } from '../useStore'
|
import {
|
||||||
import { useRef } from 'react'
|
BaseUnit,
|
||||||
import { toast } from 'react-hot-toast'
|
DEFAULT_PROJECT_NAME,
|
||||||
|
baseUnits,
|
||||||
|
} from '../machines/settingsMachine'
|
||||||
import { Toggle } from '../components/Toggle/Toggle'
|
import { Toggle } from '../components/Toggle/Toggle'
|
||||||
import { useNavigate, useRouteLoaderData } from 'react-router-dom'
|
import { useLocation, useNavigate, useRouteLoaderData } from 'react-router-dom'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { IndexLoaderData, paths } from '../Router'
|
import { IndexLoaderData, paths } from '../Router'
|
||||||
|
import { Themes } from '../lib/theme'
|
||||||
|
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||||
|
import {
|
||||||
|
CADProgram,
|
||||||
|
cadPrograms,
|
||||||
|
cameraMouseDragGuards,
|
||||||
|
} from 'lib/cameraControls'
|
||||||
|
import { UnitSystem } from 'machines/settingsMachine'
|
||||||
|
|
||||||
export const Settings = () => {
|
export const Settings = () => {
|
||||||
const loaderData = useRouteLoaderData(paths.FILE) as IndexLoaderData
|
const loaderData = useRouteLoaderData(paths.FILE) as IndexLoaderData
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
useHotkeys('esc', () => navigate('../'))
|
useHotkeys('esc', () => navigate('../'))
|
||||||
const {
|
const {
|
||||||
defaultDir,
|
settings: {
|
||||||
setDefaultDir,
|
send,
|
||||||
defaultProjectName,
|
state: {
|
||||||
setDefaultProjectName,
|
context: {
|
||||||
defaultUnitSystem,
|
baseUnit,
|
||||||
setDefaultUnitSystem,
|
cameraControls,
|
||||||
defaultBaseUnit,
|
defaultDirectory,
|
||||||
setDefaultBaseUnit,
|
defaultProjectName,
|
||||||
setDebugPanel,
|
showDebugPanel,
|
||||||
debugPanel,
|
theme,
|
||||||
setOnboardingStatus,
|
unitSystem,
|
||||||
theme,
|
},
|
||||||
setTheme,
|
},
|
||||||
} = useStore((s) => ({
|
},
|
||||||
defaultDir: s.defaultDir,
|
} = useGlobalStateContext()
|
||||||
setDefaultDir: s.setDefaultDir,
|
|
||||||
defaultProjectName: s.defaultProjectName,
|
|
||||||
setDefaultProjectName: s.setDefaultProjectName,
|
|
||||||
defaultUnitSystem: s.defaultUnitSystem,
|
|
||||||
setDefaultUnitSystem: s.setDefaultUnitSystem,
|
|
||||||
defaultBaseUnit: s.defaultBaseUnit,
|
|
||||||
setDefaultBaseUnit: s.setDefaultBaseUnit,
|
|
||||||
setDebugPanel: s.setDebugPanel,
|
|
||||||
debugPanel: s.debugPanel,
|
|
||||||
setOnboardingStatus: s.setOnboardingStatus,
|
|
||||||
theme: s.theme,
|
|
||||||
setTheme: s.setTheme,
|
|
||||||
}))
|
|
||||||
const ogDefaultDir = useRef(defaultDir)
|
|
||||||
const ogDefaultProjectName = useRef(defaultProjectName)
|
|
||||||
|
|
||||||
async function handleDirectorySelection() {
|
async function handleDirectorySelection() {
|
||||||
const newDirectory = await open({
|
const newDirectory = await open({
|
||||||
directory: true,
|
directory: true,
|
||||||
defaultPath: (defaultDir.base || '') + (defaultDir.dir || paths.INDEX),
|
defaultPath: defaultDirectory || paths.INDEX,
|
||||||
title: 'Choose a new default directory',
|
title: 'Choose a new default directory',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (newDirectory && newDirectory !== null && !Array.isArray(newDirectory)) {
|
if (newDirectory && newDirectory !== null && !Array.isArray(newDirectory)) {
|
||||||
setDefaultDir({ base: defaultDir.base, dir: newDirectory })
|
send({
|
||||||
|
type: 'Set Default Directory',
|
||||||
|
data: { defaultDirectory: newDirectory },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,6 +92,42 @@ export const Settings = () => {
|
|||||||
, and start a discussion if you don't see it! Your feedback will help
|
, and start a discussion if you don't see it! Your feedback will help
|
||||||
us prioritize what to build next.
|
us prioritize what to build next.
|
||||||
</p>
|
</p>
|
||||||
|
<SettingsSection
|
||||||
|
title="Camera Controls"
|
||||||
|
description="How you want to control the camera in the 3D view"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="camera-controls"
|
||||||
|
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
|
||||||
|
value={cameraControls}
|
||||||
|
onChange={(e) => {
|
||||||
|
send({
|
||||||
|
type: 'Set Camera Controls',
|
||||||
|
data: { cameraControls: e.target.value as CADProgram },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cadPrograms.map((program) => (
|
||||||
|
<option key={program} value={program}>
|
||||||
|
{program}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ul className="text-sm my-2 mx-4 leading-relaxed">
|
||||||
|
<li>
|
||||||
|
<strong>Pan:</strong>{' '}
|
||||||
|
{cameraMouseDragGuards[cameraControls].pan.description}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Zoom:</strong>{' '}
|
||||||
|
{cameraMouseDragGuards[cameraControls].zoom.description}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Rotate:</strong>{' '}
|
||||||
|
{cameraMouseDragGuards[cameraControls].rotate.description}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</SettingsSection>
|
||||||
{(window as any).__TAURI__ && (
|
{(window as any).__TAURI__ && (
|
||||||
<>
|
<>
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
@ -102,18 +137,8 @@ export const Settings = () => {
|
|||||||
<div className="w-full flex gap-4 p-1 rounded border border-chalkboard-30">
|
<div className="w-full flex gap-4 p-1 rounded border border-chalkboard-30">
|
||||||
<input
|
<input
|
||||||
className="flex-1 px-2 bg-transparent"
|
className="flex-1 px-2 bg-transparent"
|
||||||
value={defaultDir.dir}
|
value={defaultDirectory}
|
||||||
onChange={(e) => {
|
disabled
|
||||||
setDefaultDir({
|
|
||||||
base: defaultDir.base,
|
|
||||||
dir: e.target.value,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
ogDefaultDir.current.dir !== defaultDir.dir &&
|
|
||||||
toast.success('Default directory updated')
|
|
||||||
ogDefaultDir.current.dir = defaultDir.dir
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
Element="button"
|
Element="button"
|
||||||
@ -137,15 +162,19 @@ export const Settings = () => {
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
|
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
|
||||||
value={defaultProjectName}
|
defaultValue={defaultProjectName}
|
||||||
onChange={(e) => {
|
onBlur={(e) => {
|
||||||
setDefaultProjectName(e.target.value)
|
const newValue = e.target.value.trim() || DEFAULT_PROJECT_NAME
|
||||||
}}
|
send({
|
||||||
onBlur={() => {
|
type: 'Set Default Project Name',
|
||||||
ogDefaultProjectName.current !== defaultProjectName &&
|
data: {
|
||||||
toast.success('Default project name updated')
|
defaultProjectName: newValue,
|
||||||
ogDefaultProjectName.current = defaultProjectName
|
},
|
||||||
|
})
|
||||||
|
e.target.value = newValue
|
||||||
}}
|
}}
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</>
|
</>
|
||||||
@ -158,12 +187,15 @@ export const Settings = () => {
|
|||||||
offLabel="Imperial"
|
offLabel="Imperial"
|
||||||
onLabel="Metric"
|
onLabel="Metric"
|
||||||
name="settings-units"
|
name="settings-units"
|
||||||
checked={defaultUnitSystem === 'metric'}
|
checked={unitSystem === UnitSystem.Metric}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newUnitSystem = e.target.checked ? 'metric' : 'imperial'
|
const newUnitSystem = e.target.checked
|
||||||
setDefaultUnitSystem(newUnitSystem)
|
? UnitSystem.Metric
|
||||||
setDefaultBaseUnit(baseUnits[newUnitSystem][0])
|
: UnitSystem.Imperial
|
||||||
toast.success('Unit system set to ' + newUnitSystem)
|
send({
|
||||||
|
type: 'Set Unit System',
|
||||||
|
data: { unitSystem: newUnitSystem },
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
@ -174,13 +206,15 @@ export const Settings = () => {
|
|||||||
<select
|
<select
|
||||||
id="base-unit"
|
id="base-unit"
|
||||||
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
|
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
|
||||||
value={defaultBaseUnit}
|
value={baseUnit}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setDefaultBaseUnit(e.target.value)
|
send({
|
||||||
toast.success('Base unit changed to ' + e.target.value)
|
type: 'Set Base Unit',
|
||||||
|
data: { baseUnit: e.target.value as BaseUnit },
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{baseUnits[defaultUnitSystem].map((unit) => (
|
{baseUnits[unitSystem as keyof typeof baseUnits].map((unit) => (
|
||||||
<option key={unit} value={unit}>
|
<option key={unit} value={unit}>
|
||||||
{unit}
|
{unit}
|
||||||
</option>
|
</option>
|
||||||
@ -193,12 +227,9 @@ export const Settings = () => {
|
|||||||
>
|
>
|
||||||
<Toggle
|
<Toggle
|
||||||
name="settings-debug-panel"
|
name="settings-debug-panel"
|
||||||
checked={debugPanel}
|
checked={showDebugPanel}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setDebugPanel(e.target.checked)
|
send('Toggle Debug Panel')
|
||||||
toast.success(
|
|
||||||
'Debug panel toggled ' + (e.target.checked ? 'on' : 'off')
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
@ -211,12 +242,10 @@ export const Settings = () => {
|
|||||||
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
|
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
|
||||||
value={theme}
|
value={theme}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTheme(e.target.value as Themes)
|
send({
|
||||||
toast.success(
|
type: 'Set Theme',
|
||||||
'Theme changed to ' +
|
data: { theme: e.target.value as Themes },
|
||||||
e.target.value.slice(0, 1).toLocaleUpperCase() +
|
})
|
||||||
e.target.value.slice(1)
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Object.entries(Themes).map(([label, value]) => (
|
{Object.entries(Themes).map(([label, value]) => (
|
||||||
@ -226,21 +255,26 @@ export const Settings = () => {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
<SettingsSection
|
{location.pathname.includes(paths.FILE) && (
|
||||||
title="Onboarding"
|
<SettingsSection
|
||||||
description="Replay the onboarding process"
|
title="Onboarding"
|
||||||
>
|
description="Replay the onboarding process"
|
||||||
<ActionButton
|
|
||||||
Element="button"
|
|
||||||
onClick={() => {
|
|
||||||
setOnboardingStatus('')
|
|
||||||
navigate('..' + paths.ONBOARDING.INDEX)
|
|
||||||
}}
|
|
||||||
icon={{ icon: faArrowRotateBack }}
|
|
||||||
>
|
>
|
||||||
Replay Onboarding
|
<ActionButton
|
||||||
</ActionButton>
|
Element="button"
|
||||||
</SettingsSection>
|
onClick={() => {
|
||||||
|
send({
|
||||||
|
type: 'Set Onboarding Status',
|
||||||
|
data: { onboardingStatus: '' },
|
||||||
|
})
|
||||||
|
navigate('..' + paths.ONBOARDING.INDEX)
|
||||||
|
}}
|
||||||
|
icon={{ icon: faArrowRotateBack }}
|
||||||
|
>
|
||||||
|
Replay Onboarding
|
||||||
|
</ActionButton>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -1,20 +1,22 @@
|
|||||||
import { faSignInAlt } from '@fortawesome/free-solid-svg-icons'
|
import { faSignInAlt } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { ActionButton } from '../components/ActionButton'
|
import { ActionButton } from '../components/ActionButton'
|
||||||
import { isTauri } from '../lib/isTauri'
|
import { isTauri } from '../lib/isTauri'
|
||||||
import { Themes, useStore } from '../useStore'
|
|
||||||
import { invoke } from '@tauri-apps/api/tauri'
|
import { invoke } from '@tauri-apps/api/tauri'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env'
|
import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env'
|
||||||
import { getSystemTheme } from '../lib/getSystemTheme'
|
import { Themes, getSystemTheme } from '../lib/theme'
|
||||||
import { paths } from '../Router'
|
import { paths } from '../Router'
|
||||||
import { useAuthMachine } from '../hooks/useAuthMachine'
|
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||||
|
|
||||||
const SignIn = () => {
|
const SignIn = () => {
|
||||||
const navigate = useNavigate()
|
const {
|
||||||
const { theme } = useStore((s) => ({
|
auth: { send },
|
||||||
theme: s.theme,
|
settings: {
|
||||||
}))
|
state: {
|
||||||
const [_, send] = useAuthMachine()
|
context: { theme },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} = useGlobalStateContext()
|
||||||
|
|
||||||
const appliedTheme = theme === Themes.System ? getSystemTheme() : theme
|
const appliedTheme = theme === Themes.System ? getSystemTheme() : theme
|
||||||
const signInTauri = async () => {
|
const signInTauri = async () => {
|
||||||
// We want to invoke our command to login via device auth.
|
// We want to invoke our command to login via device auth.
|
||||||
@ -22,7 +24,7 @@ const SignIn = () => {
|
|||||||
const token: string = await invoke('login', {
|
const token: string = await invoke('login', {
|
||||||
host: VITE_KC_API_BASE_URL,
|
host: VITE_KC_API_BASE_URL,
|
||||||
})
|
})
|
||||||
send({ type: 'tryLogin', token })
|
send({ type: 'Log in', token })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('login button', error)
|
console.error('login button', error)
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user