Compare commits

..

1 Commits

Author SHA1 Message Date
73129b9f1c Use HTMLMediaElement.play() on video assignment
This is a bit of a long-shot debugging attempt; but when I've inspected
the state of a frozen KCMA app, the stream had never been told to play.
I don't know that I've been able to interpret the debug messages.

There's an autoPlay set, but I'm not clear enough about the ordering of
how the events fire; so I'm going to add this to avoid doubt; and the
ancidata of smashing f5 has been helpful in seeing the play message get
to the stream now.

Signed-off-by: Paul R. Tagliamonte <paul@kittycad.io>
2023-08-22 14:12:32 -04:00
182 changed files with 9688 additions and 50574 deletions

View File

@ -1,6 +1,3 @@
VITE_KC_API_WS_MODELING_URL=wss://api.kittycad.io/ws/modeling/commands
VITE_KC_API_BASE_URL=https://api.kittycad.io
VITE_KC_SITE_BASE_URL=https://kittycad.io
VITE_KC_SKIP_AUTH=false
VITE_KC_CONNECTION_TIMEOUT_MS=15000
VITE_KC_SENTRY_DSN=
VITE_KC_API_WS_MODELING_URL=wss://api.dev.kittycad.io/ws/modeling/commands
VITE_KC_API_BASE_URL=https://api.dev.kittycad.io
VITE_KC_SITE_BASE_URL=https://dev.kittycad.io

View File

@ -1,6 +1,3 @@
VITE_KC_API_WS_MODELING_URL=wss://api.kittycad.io/ws/modeling/commands
VITE_KC_API_BASE_URL=https://api.kittycad.io
VITE_KC_SITE_BASE_URL=https://kittycad.io
VITE_KC_SKIP_AUTH=false
VITE_KC_CONNECTION_TIMEOUT_MS=15000
VITE_KC_SENTRY_DSN=https://a814f2f66734989a90367f48feee28ca@o1042111.ingest.sentry.io/4505789425844224
VITE_KC_SITE_BASE_URL=https://kittycad.io

View File

@ -1 +0,0 @@
src/wasm-lib/*

View File

@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
dir: ['src/wasm-lib']
dir: ['src/wasm-lib', 'src-tauri']
steps:
- uses: actions/checkout@v3

View File

@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
dir: ['src/wasm-lib']
dir: ['src/wasm-lib', 'src-tauri']
steps:
- uses: actions/checkout@v3
- name: Install latest rust
@ -40,17 +40,6 @@ jobs:
- name: Rust Cache
uses: Swatinem/rust-cache@v2.6.1
- name: Install ffmpeg
run: |
sudo apt update
sudo apt install \
ffmpeg \
libavformat-dev \
libavutil-dev \
libclang-dev \
libswscale-dev \
--no-install-recommends
- name: Run clippy
run: |
cd "${{ matrix.dir }}"

View File

@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest-8-cores
strategy:
matrix:
dir: ['src/wasm-lib']
dir: ['src/wasm-lib', 'src-tauri']
steps:
- uses: actions/checkout@v3
- name: Install latest rust
@ -41,21 +41,8 @@ jobs:
- uses: taiki-e/install-action@nextest
- name: Rust Cache
uses: Swatinem/rust-cache@v2.6.1
- name: Install ffmpeg
run: |
sudo apt update
sudo apt install \
ffmpeg \
libavformat-dev \
libavutil-dev \
libclang-dev \
libswscale-dev \
--no-install-recommends
- name: cargo test
shell: bash
run: |-
cd "${{ matrix.dir }}"
cargo test --all
env:
KITTYCAD_API_TOKEN: ${{secrets.KITTYCAD_API_TOKEN}}
cargo llvm-cov nextest --lcov --output-path lcov.info --test-threads=1 --no-fail-fast

View File

@ -1,4 +1,4 @@
name: CI
name: CI
on:
pull_request:
@ -13,31 +13,17 @@ jobs:
check-format:
runs-on: 'ubuntu-20.04'
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- run: yarn install
- run: yarn fmt-check
check-types:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- run: yarn install
- uses: Swatinem/rust-cache@v2
with:
workspaces: "./src/wasm-lib"
- run: yarn build:wasm
- run: yarn tsc
build-test-web:
runs-on: ubuntu-20.04
@ -50,28 +36,27 @@ jobs:
- 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
- run: yarn simpleserver:ci
- run: yarn test:nowatch
- run: yarn test:cov
- run: yarn test:rust
- id: export_version
run: echo "version=`cat package.json | jq -r '.version'`" >> "$GITHUB_OUTPUT"
build-apps:
needs: [check-format, build-test-web, check-types]
needs: [check-format, build-test-web]
runs-on: ${{ matrix.os }}
strategy:
matrix:
@ -102,10 +87,6 @@ jobs:
with:
workspaces: './src-tauri -> target'
- uses: Swatinem/rust-cache@v2
with:
workspaces: "./src/wasm-lib"
- name: wasm prep
shell: bash
run: |
@ -129,26 +110,19 @@ jobs:
- name: Fix format
run: yarn fmt
- name: install apple silicon target mac
if: matrix.os == 'macos-latest'
run: |
rustup target add aarch64-apple-darwin
- name: Build the app for the current platform (no upload)
uses: tauri-apps/tauri-action@v0
env:
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
with:
args: ${{ matrix.os == 'macos-latest' && '--target universal-apple-darwin' || '' }}
- uses: actions/upload-artifact@v3
with:
path: ${{ matrix.os == 'macos-latest' && 'src-tauri/target/universal-apple-darwin/release/bundle/*/*' || 'src-tauri/target/release/bundle/*/*' }}
path: src-tauri/target/release/bundle/*/*
sign-windows-msi:
runs-on: windows-latest
publish-apps-release:
runs-on: ubuntu-20.04
if: github.event_name == 'release'
needs: [build-test-web, build-apps]
env:
@ -157,89 +131,29 @@ jobs:
- uses: actions/download-artifact@v3
- name: Setup Certificate
run: |
echo "${{secrets.SM_CLIENT_CERT_FILE_B64 }}" | base64 --decode > /d/Certificate_pkcs12.p12
cat /d/Certificate_pkcs12.p12
shell: bash
- name: Set variables
id: variables
run: |
echo "::set-output name=version::${GITHUB_REF#refs/tags/v}"
echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV"
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV"
echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV"
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> "$GITHUB_ENV"
echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH
echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH
echo "C:\Program Files\DigiCert\DigiCert One Signing Manager Tools" >> $GITHUB_PATH
shell: bash
- name: Setup SSM KSP on windows latest
run: |
curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/smtools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" -o smtools-windows-x64.msi
msiexec /i smtools-windows-x64.msi /quiet /qn
smksp_registrar.exe list
smctl.exe keypair ls
C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user
smksp_cert_sync.exe
shell: cmd
- name: Signing using Signtool
run: |
signtool.exe sign /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 "artifact\msi\*.msi"
signtool.exe verify /v /pa "artifact\msi\*.msi"
# TODO: for the updater, investigate if we need to also replace what's in the .zip, and what to do about the .sig file
- uses: actions/upload-artifact@v3
with:
path: artifact/*
publish-apps-release:
runs-on: ubuntu-20.04
if: github.event_name == 'release'
needs: [build-test-web, build-apps, sign-windows-msi]
env:
VERSION_NO_V: ${{ needs.build-test-web.outputs.version }}
PUB_DATE: ${{ github.event.release.created_at }}
NOTES: ${{ github.event.release.body }}
steps:
- uses: actions/download-artifact@v3
- name: Generate the update static endpoint
run: |
ls -l artifact/*/*itty*
ls -l artifact
ls -l artifact/*
DARWIN_SIG=`cat artifact/macos/*.app.tar.gz.sig`
LINUX_SIG=`cat artifact/appimage/*.AppImage.tar.gz.sig`
WINDOWS_SIG=`cat artifact/msi/*.msi.zip.sig`
WINDOWS_SIG=`cat artifact/nsis/*.nsis.zip.sig`
RELEASE_DIR=https://dl.kittycad.io/releases/modeling-app/v${VERSION_NO_V}
jq --null-input \
--arg version "v${VERSION_NO_V}" \
--arg pub_date "${PUB_DATE}" \
--arg notes "${NOTES}" \
--arg darwin_sig "$DARWIN_SIG" \
--arg darwin_url "$RELEASE_DIR/macos/KittyCAD%20Modeling.app.tar.gz" \
--arg darwin_url "$RELEASE_DIR/macos/kittycad-modeling-app.app.tar.gz" \
--arg linux_sig "$LINUX_SIG" \
--arg linux_url "$RELEASE_DIR/appimage/kittycad-modeling_${VERSION_NO_V}_amd64.AppImage.tar.gz" \
--arg linux_url "$RELEASE_DIR/appimage/kittycad-modeling-app_${VERSION_NO_V}_amd64.AppImage.tar.gz" \
--arg windows_sig "$WINDOWS_SIG" \
--arg windows_url "$RELEASE_DIR/msi/KittyCAD%20Modeling_${VERSION_NO_V}_x64_en-US.msi.zip" \
--arg windows_url "$RELEASE_DIR/nsis/kittycad-modeling-app_${VERSION_NO_V}_x64-setup.nsis.zip" \
'{
"version": $version,
"pub_date": $pub_date,
"notes": $notes,
"platforms": {
"darwin-x86_64": {
"signature": $darwin_sig,
"url": $darwin_url
},
"darwin-aarch64": {
"signature": $darwin_sig,
"url": $darwin_url
},
"linux-x86_64": {
"signature": $linux_sig,
"url": $linux_url
@ -252,34 +166,6 @@ jobs:
}' > last_update.json
cat last_update.json
- name: Generate the download static endpoint
run: |
RELEASE_DIR=https://dl.kittycad.io/releases/modeling-app/v${VERSION_NO_V}
jq --null-input \
--arg version "v${VERSION_NO_V}" \
--arg pub_date "${PUB_DATE}" \
--arg notes "${NOTES}" \
--arg darwin_url "$RELEASE_DIR/dmg/KittyCAD%20Modeling_${VERSION_NO_V}_universal.dmg" \
--arg linux_url "$RELEASE_DIR/appimage/kittycad-modeling_${VERSION_NO_V}_amd64.AppImage" \
--arg windows_url "$RELEASE_DIR/msi/KittyCAD%20Modeling_${VERSION_NO_V}_x64_en-US.msi" \
'{
"version": $version,
"pub_date": $pub_date,
"notes": $notes,
"platforms": {
"dmg-universal": {
"url": $darwin_url
},
"appimage-x86_64": {
"url": $linux_url
},
"msi-x86_64": {
"url": $windows_url
}
}
}' > last_download.json
cat last_download.json
- name: Authenticate to Google Cloud
uses: 'google-github-actions/auth@v1.1.1'
with:
@ -289,28 +175,17 @@ jobs:
uses: google-github-actions/setup-gcloud@v1.1.1
with:
project_id: kittycadapi
- name: Upload release files to public bucket
uses: google-github-actions/upload-cloud-storage@v1.0.3
with:
path: artifact
glob: '*/*itty*'
glob: '*/kittycad-modeling-app*'
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
uses: google-github-actions/upload-cloud-storage@v1.0.3
with:
path: last_update.json
destination: dl.kittycad.io/releases/modeling-app
- name: Upload download endpoint to public bucket
uses: google-github-actions/upload-cloud-storage@v1.0.3
with:
path: last_download.json
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
View File

@ -25,6 +25,5 @@ yarn-error.log*
# rust
src/wasm-lib/target
src/wasm-lib/bindings
src/wasm-lib/kcl/bindings
public/wasm_lib_bg.wasm
src/wasm-lib/lcov.info

View File

@ -5,5 +5,3 @@ coverage
# Ignore Rust projects:
*.rs
target
src/wasm-lib/pkg
src/wasm-lib/kcl/bindings

121
README.md
View File

@ -1,72 +1,48 @@
![KittyCAD Modeling App](/public/kcma-logomark.png)
## KittyCAD Modeling App
## Kurt demo project
live at [app.kittycad.io](https://app.kittycad.io/)
A CAD application from the future, brought to you by the [KittyCAD team](https://kittycad.io).
Not sure what to call this, it's both a language/interpreter and a UI that uses the language as the source of truth model the user build with direct-manipulation with the UI.
The KittyCAD modeling app is our take on what a modern modelling experience can be. It is applying several lessons learned in the decades since most major CAD tools came into existence:
It might make sense to split this repo up at some point, but not the lang and the UI are all togther in a react app
- All artifacts—including parts and assemblies—should be represented as human-readable code. At the end of the day, your CAD project should be "plain text"
- This makes version control—which is a solved problem in software engineering—trivial for CAD
- All GUI (or point-and-click) interactions should be actions performed on this code representation under the hood
- This unlocks a hybrid approach to modeling. Whether you point-and-click as you always have or you write your own KCL code, you are performing the same action in KittyCAD Modeling App
- Everything graphics _has_ to be built for the GPU
- Most CAD applications have had to retrofit support for GPUs, but our geometry engine is made for GPUs (primarily Nvidia's Vulkan), getting the order of magnitude rendering performance boost with it
- Make the resource-intensive pieces of an application auto-scaling
- One of the bottlenecks of today's hardware design tools is that they all rely on the local machine's resources to do the hardest parts, which include geometry rendering and analysis. Our geometry engine parallelizes rendering and just sends video frames back to the app (seriously, inspect source, it's just a `<video>` element), and our API will offload analysis as we build it in
Originally Presented on 10/01/2023
We are excited about what a small team of people could build in a short time with our API. We welcome you to try our API, build your own applications, or contribute to ours!
[Video](https://drive.google.com/file/d/183_wjqGdzZ8EEZXSqZ3eDcJocYPCyOdC/view?pli=1)
KittyCAD Modeling App is a _hybrid_ user interface for CAD modeling. You can point-and-click to design parts (and soon assemblies), but everything you make is really just [`kcl` code](https://github.com/KittyCAD/kcl-experiments) under the hood. All of your CAD models can be checked into source control such as GitHub and responsibly versioned, rolled back, and more.
[demo-slides.pdf](https://github.com/KittyCAD/Eng/files/10398178/demo.pdf)
The 3D view in KittyCAD Modeling App is just a video stream from our hosted geometry engine. The app sends new modeling commands to the engine via WebSockets, which returns back video frames of the view within the engine.
## Tools
- UI
- [React](https://react.dev/)
- [Headless UI](https://headlessui.com/)
- [TailwindCSS](https://tailwindcss.com/)
- Networking
- WebSockets (via [KittyCAD TS client](https://github.com/KittyCAD/kittycad.ts))
- Code Editor
- [CodeMirror](https://codemirror.net/)
- Custom WASM LSP Server
- Modeling
- [KittyCAD TypeScript client](https://github.com/KittyCAD/kittycad.ts)
[Original demo video](https://drive.google.com/file/d/183_wjqGdzZ8EEZXSqZ3eDcJocYPCyOdC/view?pli=1)
[Original demo slides](https://github.com/KittyCAD/Eng/files/10398178/demo.pdf)
## Get started
We recommend downloading the latest application binary from [our Releases page](https://github.com/KittyCAD/modeling-app/releases). If you don't see your platform or architecture supported there, please file an issue.
## Running a development build
First, [install Rust via `rustup`](https://www.rust-lang.org/tools/install). This project uses a lot of Rust compiled to [WASM](https://webassembly.org/) within it. Then, run:
## To run, there are a couple steps since we're compiling rust to WASM, you'll need to have rust stuff installed, then
```
yarn install
```
followed by:
then
```
yarn build:wasm
```
That will build the WASM binary and put in the `public` dir (though gitignored)
finally, to run the web app only, run:
finally
```
yarn start
```
and `yarn test` you would have need to have built the WASM previously. The tests need to download the binary from a server, so if you've already got `yarn start` running, that will work, otherwise running
```
yarn simpleserver
```
in one terminal
and
```
yarn test
```
in another.
If you want to edit the rust files, you can cd into `src/wasm-lib` and then use the usual rust commands, `cargo build`, `cargo test`, when you want to bring the changes back to the web-app, a fresh `yarn build:wasm` in the root will be needed.
Worth noting that the integration of the WASM into this project is very hacky because I'm really pushing create-react-app further than what's practical, but focusing on features atm rather than the setup.
## Developing in Chrome
Chrome is in the process of rolling out a new default which
@ -76,26 +52,12 @@ enable third-party cookies. You can enable third-party cookies by clicking on
the eye with a slash through it in the URL bar, and clicking on "Enable
Third-Party Cookies".
## Running tests
First, start the dev server following "Running a development build" above.
Then in another terminal tab, run:
```
yarn test
```
Which will run our suite of [Vitest unit](https://vitest.dev/) and [React Testing Library E2E](https://testing-library.com/docs/react-testing-library/intro/) tests, in interactive mode by default.
## Tauri
To spin up up tauri dev, `yarn install` and `yarn build:wasm` need to have been done before hand then
```
yarn tauri dev
```
Will spin up the web app before opening up the tauri dev desktop app. Note that it's probably a good idea to close the browser tab that gets opened since at the time of writting they can conflict.
The dev instance automatically opens up the browser devtools which can be disabled by [commenting it out](https://github.com/KittyCAD/modeling-app/blob/main/src-tauri/src/main.rs#L92.)
@ -105,22 +67,11 @@ To build, run `yarn tauri build`, or `yarn tauri build --debug` to keep access t
Note that these became separate apps on Macos, so make sure you open the right one after a build 😉
![image](https://github.com/KittyCAD/modeling-app/assets/29681384/a08762c5-8d16-42d8-a02f-a5efc9ae5551)
<img width="1232" alt="image" src="https://user-images.githubusercontent.com/29681384/211947063-46164bb4-7bdd-45cb-9a76-2f40c71a24aa.png">
<img width="1232" alt="image (1)" src="https://user-images.githubusercontent.com/29681384/211947073-e76b4933-bef5-4636-bc4d-e930ac8e290f.png">
## Before submitting a PR
Before you submit a contribution PR to this repo, please ensure that:
- There is a corresponding issue for the changes you want to make, so that discussion of approach can be had before work begins.
- You have separated out refactoring commits from feature commits as much as possible
- You have run all of the following commands locally:
- `yarn fmt`
- `yarn tsc`
- `yarn test`
- Here they are all together: `yarn fmt && yarn tsc && yarn test`
## Release a new version
1. Bump the versions in the .json files by creating a `Bump to v{x}.{y}.{z}` PR, committing the changes from
@ -128,7 +79,6 @@ Before you submit a contribution PR to this repo, please ensure that:
```bash
VERSION=x.y.z yarn run bump-jsons
```
The PR may serve as a place to discuss the human-readable changelog and extra QA.
2. Merge the PR
@ -136,24 +86,3 @@ The PR may serve as a place to discuss the human-readable changelog and extra QA
3. Create a new release and tag pointing to the bump version commit using semantic versioning `v{x}.{y}.{z}`
4. A new Action kicks in at https://github.com/KittyCAD/modeling-app/actions, uploading artifacts to the release
## Fuzzing the parser
Make sure you install cargo fuzz:
```bash
$ cargo install cargo-fuzz
```
```bash
$ cd src/wasm-lib/kcl
# list the fuzz targets
$ cargo fuzz list
# run the parser fuzzer
$ cargo +nightly fuzz run parser
```
For more information on fuzzing you can check out
[this guide](https://rust-fuzz.github.io/book/cargo-fuzz.html).

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,75 +0,0 @@
# Types
`KCL` defines the following types and keywords the language.
All these types can be nested in various forms where nesting applies. Like
arrays can hold objects and vice versa.
## Boolean
`true` or `false` work when defining values.
## Variable declaration
Variables are defined with the `let` keyword like so:
```
let myBool = false
```
## Array
An array is defined with `[]` braces. What is inside the brackets can
be of any type. For example, the following is completely valid:
```
let myArray = ["thing", 2, false]
```
If you want to get a value from an array you can use the index like so:
`myArray[0]`.
## Object
An object is defined with `{}` braces. Here is an example object:
```
let myObj = {a: 0, b: "thing"}
```
We support two different ways of getting properties from objects, you can call
`myObj.a` or `myObj["a"]` both work.
## Functions
We also have support for defining your own functions. Functions can take in any
type of argument. Below is an example of the syntax:
```
fn myFn = (x) => {
return x
}
```
As you can see above `myFn` just returns whatever it is given.
## Binary expressions
You can also do math! Let's show an example below:
```
let myMathExpression = 3 + 1 * 2 / 3 - 7
```
You can nest expressions in parenthesis as well:
```
let myMathExpression = 3 + (1 * 2 / (3 - 7))
```
Please if you find any issues using any of the above expressions or syntax
please file an issue with the `ast` label on the [modeling-app
repo](https://github.com/KittyCAD/modeling-app/issues/new).

View File

@ -11,7 +11,6 @@
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<script defer data-domain="app.kittycad.io" src="https://plausible.corp.kittycad.io/js/script.js"></script>
<title>KittyCAD Modeling App</title>
</head>
<body class="body-bg">

View File

@ -1,36 +1,28 @@
{
"name": "untitled-app",
"version": "0.7.1",
"version": "0.0.4",
"private": true,
"dependencies": {
"@codemirror/autocomplete": "^6.9.0",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-brands-svg-icons": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.13",
"@headlessui/tailwindcss": "^0.2.0",
"@kittycad/lib": "^0.0.37",
"@lezer/javascript": "^1.4.7",
"@open-rpc/client-js": "^1.8.1",
"@kittycad/lib": "^0.0.29",
"@react-hook/resize-observer": "^1.2.6",
"@replit/codemirror-interact": "^6.3.0",
"@sentry/react": "^7.65.0",
"@tauri-apps/api": "^1.3.0",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^13.0.0",
"@testing-library/user-event": "^13.2.1",
"@ts-stack/markdown": "^1.5.0",
"@types/node": "^16.7.13",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@uiw/react-codemirror": "^4.21.13",
"@uiw/codemirror-extensions-langs": "^4.21.9",
"@uiw/react-codemirror": "^4.15.1",
"@xstate/react": "^3.2.2",
"crypto-js": "^4.1.1",
"formik": "^2.4.3",
"fuse.js": "^6.6.2",
"http-server": "^14.1.1",
"json-rpc-2.0": "^1.6.0",
"re-resizable": "^6.9.9",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -48,8 +40,6 @@
"typescript": "^4.4.2",
"uuid": "^9.0.0",
"vitest": "^0.34.1",
"vscode-jsonrpc": "^8.1.0",
"vscode-languageserver-protocol": "^3.17.3",
"wasm-pack": "^0.12.1",
"web-vitals": "^2.1.0",
"ws": "^8.13.0",
@ -64,15 +54,15 @@
"build:both:local": "yarn build:wasm && vite build",
"test": "vitest --mode development",
"test:nowatch": "vitest run --mode development",
"test:rust": "(cd src/wasm-lib && cargo test --all && cargo clippy --all --tests)",
"test:rust": "(cd src/wasm-lib && cargo test && cargo clippy)",
"test:cov": "vitest run --coverage --mode development",
"simpleserver:ci": "http-server ./public --cors -p 3000 &",
"simpleserver": "http-server ./public --cors -p 3000",
"fmt": "prettier --write ./src",
"fmt-check": "prettier --check ./src",
"build:wasm": "yarn wasm-prep && (cd src/wasm-lib && wasm-pack build --target web --out-dir pkg && cargo test -p kcl-lib export_bindings) && cp src/wasm-lib/pkg/wasm_lib_bg.wasm public && yarn fmt && yarn remove-importmeta",
"remove-importmeta": "sed -i 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\"; sed -i '' 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\" || echo \"sed for both mac and linux\"",
"wasm-prep": "rm -rf src/wasm-lib/pkg && mkdir src/wasm-lib/pkg && rm -rf src/wasm-lib/kcl/bindings",
"build:wasm": "yarn wasm-prep && (cd src/wasm-lib && wasm-pack build --target web --out-dir pkg && cargo test --all) && cp src/wasm-lib/pkg/wasm_lib_bg.wasm public && yarn fmt && yarn remove-importmeta",
"remove-importmeta": "sed -i 's/import.meta.url//g' \"./src/wasm-lib/pkg/wasm_lib.js\"; sed -i '' 's/import.meta.url//g' \"./src/wasm-lib/pkg/wasm_lib.js\" || echo \"sed for both mac and linux\"",
"wasm-prep": "rm -rf src/wasm-lib/pkg && mkdir src/wasm-lib/pkg && rm -rf src/wasm-lib/bindings",
"lint": "eslint --fix src",
"bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json && echo \"$(jq --arg v \"$VERSION\" '.package.version=$v' src-tauri/tauri.conf.json --indent 2)\" > src-tauri/tauri.conf.json"
},
@ -99,7 +89,6 @@
"@babel/preset-env": "^7.22.9",
"@tauri-apps/cli": "^1.3.1",
"@types/crypto-js": "^4.1.1",
"@types/debounce": "^1.2.1",
"@types/isomorphic-fetch": "^0.0.36",
"@types/react-modal": "^3.16.0",
"@types/uuid": "^9.0.1",

View File

@ -1,42 +0,0 @@
## Alpha Users Expectations
### Welcome
First off, thank you so much for your interest in being a part of the closed Alpha program! We are thrilled to have others use our product and see what you build with it (and truthfully, how you break it too).
### KittyCAD Modeling App (KCMA)
What we are introducing to you is our KittyCAD Modeling App (KCMA). KCMA is a CAD application that expresses a hybrid style of traditional CAD interface along with a code-CAD interface. KCMA is a great way for us to test our own APIs as well as inspire others to develop their own applications.
### Why Code?
Plenty of you have professional CAD experience, and may not understand why coding your model would be helpful. The "code-CAD" paradigm isnt as popular as traditional CAD programs (SolidWorks, NX, CREO, OnShape, etc.), but it certainly has its benefits. Some benefits include:
- Automation and parametric design
- Customization and flexibility
- Algorithmic and generative design
- Reproducibility
- Easier integration with other tools
### Before You Use KCMA
Before you dive straight into the app, we wanted to lay some expectations out for you.
- KCMA is in early development. Kurt pitched the idea back in January, and the team has been working hard on it since then. KCMA has really basic CAD features for now, but we have plenty of features on our roadmap. Most of the features that you may be currently used to in your CAD workflow today will be available down the road.
- For a list of all scripting functions, please reference our [documentation](https://github.com/KittyCAD/modeling-app/blob/main/docs/kcl/std.md). For a basic rundown of our types, please reference [this document](https://github.com/KittyCAD/modeling-app/blob/main/docs/kcl/types.md).
- With that being said, we have created an external new features list in [GH Discussions](https://github.com/KittyCAD/modeling-app/discussions). For our current priority list, please click [here](https://github.com/KittyCAD/modeling-app/blob/main/public/roadmap.md). Please upvote any features in the GH Discussions page that you would like to see implemented first. We will prioritize the highest upvoted items or items that are foundational for other features on the list. You can also add your own, but we will review it to make sure its not a duplicate or its feasible for the current state of the app.
- Please report any and all bugs/issues you find. Even the smallest bugs are important! You can report them in a GH Issue [here](https://github.com/KittyCAD/modeling-app/issues/new). You are more than welcome to link your GH Issue in the **bugs** section of our Discord, but if you want to discuss the bug further, please keep that in the GH Issue thread. Please include the severity of the bug in your GH Issue ticket (High, Medium, or Low). If you are having trouble deciding what severity the bug is, use this guideline:
- **High:** The bug is blocking you from continuing.
- Example: Every time I click the extrude button with two faces selected, the app crashes.
- **Medium:** You can find a workaround to the problem, but it increases your time spent working or makes it unenjoyable.
- Example: When the app is full screen on Mac, the settings are not showing properly. It works if I have the app windowed.
- **Low:** The bug is annoying but doesnt affect workflow or block you from continuing (usually you can say “It would be nice if ___, but its not needed”)
- Example: It would be nice if the camera would orient normal to the sketching surface when I select a face/plane and click “sketch”.
- We want you all to be aware that we may reach out to you in regard to issues, bugs, problems, and satisfaction. This will typically be for further clarification so we can really nail things down.
### Discord
We will be using Discord a lot more now that the Alpha has been released to people outside of the company. Please feel free to discuss and talk with us in the **alpha users** section of the server. We highly encourage you to engage with us on Discord!
### Thank You!
Once again, from all of us to you, thank you for being a part of the closed Alpha. We are happy to chat with you all, hear your feedback, and see some of your projects!

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

View File

@ -1,26 +0,0 @@
## KittyCAD Modeling App Roadmap
This document ties into our [GH Discussions Feature List](https://github.com/KittyCAD/modeling-app/discussions). Please upvote any features that you want to see next, or add ones that are not listed and we will review.
### Current Priority List
1. [Sketch on Face](https://github.com/KittyCAD/modeling-app/discussions/477)
2. [Revolve](https://github.com/KittyCAD/modeling-app/discussions/496)
3. [Fillet](https://github.com/KittyCAD/modeling-app/discussions/501)
4. [Linear Pattern](https://github.com/KittyCAD/modeling-app/discussions/256)
5. [Circular Pattern](https://github.com/KittyCAD/modeling-app/discussions/257)
6. [Mirror-Sketch](https://github.com/KittyCAD/modeling-app/discussions/507)
7. [Chamfer](https://github.com/KittyCAD/modeling-app/discussions/502)
8. [Sweep](https://github.com/KittyCAD/modeling-app/discussions/498)
9. [Draft](https://github.com/KittyCAD/modeling-app/discussions/495)
10. [Shell](https://github.com/KittyCAD/modeling-app/discussions/503)
11. [Union](https://github.com/KittyCAD/modeling-app/discussions/509)
12. [Mirror-Model](https://github.com/KittyCAD/modeling-app/discussions/508)
13. [Subtract](https://github.com/KittyCAD/modeling-app/discussions/510)
14. [Intersect](https://github.com/KittyCAD/modeling-app/discussions/511)
15. [Offset](https://github.com/KittyCAD/modeling-app/discussions/512)
16. [Thicken](https://github.com/KittyCAD/modeling-app/discussions/499)
17. [Import](https://github.com/KittyCAD/modeling-app/discussions/478)
18. [Assemblies](https://github.com/KittyCAD/modeling-app/discussions/494)
19. [External Thread](https://github.com/KittyCAD/modeling-app/discussions/505)

1012
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -12,18 +12,17 @@ rust-version = "1.60"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.4.0", features = [] }
tauri-build = { version = "1.3.0", features = [] }
[dependencies]
anyhow = "1"
kittycad = "0.2.25"
oauth2 = "4.4.2"
oauth2 = "4.4.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tauri = { version = "1.4.1", features = ["dialog-all", "fs-all", "http-request", "path-all", "shell-open", "shell-open-api", "updater", "devtools"] }
tauri = { version = "1.3.0", features = [ "path-all", "dialog-all", "fs-all", "http-request", "shell-open", "shell-open-api"] }
tokio = { version = "1.29.1", features = ["time"] }
toml = "0.6.0"
tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tokio = { version = "1.32.0", features = ["time"] }
toml = "0.8.0"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.

View File

@ -85,24 +85,6 @@ async fn login(app: tauri::AppHandle, host: &str) -> Result<String, InvokeError>
Ok(token)
}
///This command returns the KittyCAD user info given a token.
/// The string returned from this method is the user info as a json string.
#[tauri::command]
async fn get_user(token: Option<String>) -> Result<kittycad::types::User, InvokeError> {
println!("Getting user info...");
// use kittycad library to fetch the user info from /user/me
let client = kittycad::Client::new(token.unwrap());
let user_info: kittycad::types::User = client
.users()
.get_self()
.await
.map_err(|e| InvokeError::from_anyhow(e.into()))?;
Ok(user_info)
}
fn main() {
tauri::Builder::default()
.setup(|app| {
@ -115,12 +97,7 @@ fn main() {
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_user,
login,
read_toml,
read_txt_file
])
.invoke_handler(tauri::generate_handler![login, read_toml, read_txt_file])
.plugin(tauri_plugin_fs_extra::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -7,8 +7,8 @@
"distDir": "../build"
},
"package": {
"productName": "kittycad-modeling",
"version": "0.7.1"
"productName": "kittycad-modeling-app",
"version": "0.0.4"
},
"tauri": {
"allowlist": {

View File

@ -1,7 +0,0 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"package": {
"productName": "KittyCAD Modeling"
}
}

View File

@ -1,7 +0,0 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"package": {
"productName": "KittyCAD Modeling"
}
}

View File

@ -2,8 +2,7 @@ import { render, screen } from '@testing-library/react'
import { App } from './App'
import { describe, test, vi } from 'vitest'
import { BrowserRouter } from 'react-router-dom'
import { GlobalStateProvider } from './components/GlobalStateProvider'
import CommandBarProvider from 'components/CommandBar'
import { GlobalStateProvider } from './hooks/useAuthMachine'
let listener: ((rect: any) => void) | undefined = undefined
;(global as any).ResizeObserver = class ResizeObserver {
@ -44,9 +43,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
// wrap in router and xState context
return (
<BrowserRouter>
<CommandBarProvider>
<GlobalStateProvider>{children}</GlobalStateProvider>
</CommandBarProvider>
<GlobalStateProvider>{children}</GlobalStateProvider>
</BrowserRouter>
)
}

View File

@ -1,17 +1,37 @@
import { useRef, useEffect, useCallback, MouseEventHandler } from 'react'
import {
useRef,
useEffect,
useLayoutEffect,
useMemo,
useCallback,
MouseEventHandler,
} from 'react'
import { DebugPanel } from './components/DebugPanel'
import { v4 as uuidv4 } from 'uuid'
import { asyncParser } from './lang/abstractSyntaxTree'
import { _executor } from './lang/executor'
import { PaneType, useStore } from './useStore'
import CodeMirror from '@uiw/react-codemirror'
import { langs } from '@uiw/codemirror-extensions-langs'
import { linter, lintGutter } from '@codemirror/lint'
import { ViewUpdate } from '@codemirror/view'
import {
lineHighlightField,
addLineHighlight,
} from './editor/highlightextension'
import { PaneType, Selections, Themes, useStore } from './useStore'
import { Logs, KCLErrors } from './components/Logs'
import { CollapsiblePanel } from './components/CollapsiblePanel'
import { MemoryPanel } from './components/MemoryPanel'
import { useHotKeyListener } from './hooks/useHotKeyListener'
import { Stream } from './components/Stream'
import ModalContainer from 'react-modal-promise'
import { EngineCommand } from './lang/std/engineConnection'
import { throttle } from './lib/utils'
import {
EngineCommand,
EngineCommandManager,
} from './lang/std/engineConnection'
import { isOverlap, throttle } from './lib/utils'
import { AppHeader } from './components/AppHeader'
import { KCLError, kclErrToDiagnostic } from './lang/errors'
import { Resizable } from 're-resizable'
import {
faCode,
@ -19,55 +39,101 @@ import {
faSquareRootVariable,
} from '@fortawesome/free-solid-svg-icons'
import { useHotkeys } from 'react-hotkeys-hook'
import { TEST } from './env'
import { getNormalisedCoordinates } from './lib/utils'
import { getSystemTheme } from './lib/getSystemTheme'
import { isTauri } from './lib/isTauri'
import { useLoaderData } from 'react-router-dom'
import { useLoaderData, useParams } from 'react-router-dom'
import { writeTextFile } from '@tauri-apps/api/fs'
import { PROJECT_ENTRYPOINT } from './lib/tauriFS'
import { IndexLoaderData } from './Router'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { onboardingPaths } from 'routes/Onboarding'
import { cameraMouseDragGuards } from 'lib/cameraControls'
import { CameraDragInteractionType_type } from '@kittycad/lib/dist/types/src/models'
import { CodeMenu } from 'components/CodeMenu'
import { TextEditor } from 'components/TextEditor'
import { Themes, getSystemTheme } from 'lib/theme'
import { useSetupEngineManager } from 'hooks/useSetupEngineManager'
import { useEngineConnectionSubscriptions } from 'hooks/useEngineConnectionSubscriptions'
import { toast } from 'react-hot-toast'
import { useAuthMachine } from './hooks/useAuthMachine'
export function App() {
const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
const pathParams = useParams()
const streamRef = useRef<HTMLDivElement>(null)
useHotKeyListener()
const {
editorView,
setEditorView,
setSelectionRanges,
selectionRanges,
addLog,
addKCLError,
code,
setCode,
setAst,
setError,
setProgramMemory,
resetLogs,
resetKCLErrors,
selectionRangeTypeMap,
setArtifactMap,
engineCommandManager,
buttonDownInStream,
setEngineCommandManager,
setHighlightRange,
setCursor2,
sourceRangeMap,
setMediaStream,
setIsStreamReady,
isStreamReady,
isMouseDownInStream,
cmdId,
setCmdId,
formatCode,
debugPanel,
theme,
openPanes,
setOpenPanes,
onboardingStatus,
didDragInStream,
setDidDragInStream,
setStreamDimensions,
streamDimensions,
guiMode,
setGuiMode,
} = useStore((s) => ({
guiMode: s.guiMode,
editorView: s.editorView,
setEditorView: s.setEditorView,
setSelectionRanges: s.setSelectionRanges,
selectionRanges: s.selectionRanges,
setGuiMode: s.setGuiMode,
addLog: s.addLog,
code: s.code,
setCode: s.setCode,
setAst: s.setAst,
setError: s.setError,
setProgramMemory: s.setProgramMemory,
resetLogs: s.resetLogs,
resetKCLErrors: s.resetKCLErrors,
selectionRangeTypeMap: s.selectionRangeTypeMap,
setArtifactMap: s.setArtifactNSourceRangeMaps,
engineCommandManager: s.engineCommandManager,
buttonDownInStream: s.buttonDownInStream,
setEngineCommandManager: s.setEngineCommandManager,
setHighlightRange: s.setHighlightRange,
isShiftDown: s.isShiftDown,
setCursor: s.setCursor,
setCursor2: s.setCursor2,
sourceRangeMap: s.sourceRangeMap,
setMediaStream: s.setMediaStream,
isStreamReady: s.isStreamReady,
setIsStreamReady: s.setIsStreamReady,
isMouseDownInStream: s.isMouseDownInStream,
cmdId: s.cmdId,
setCmdId: s.setCmdId,
formatCode: s.formatCode,
debugPanel: s.debugPanel,
addKCLError: s.addKCLError,
theme: s.theme,
openPanes: s.openPanes,
setOpenPanes: s.setOpenPanes,
onboardingStatus: s.onboardingStatus,
didDragInStream: s.didDragInStream,
setDidDragInStream: s.setDidDragInStream,
setStreamDimensions: s.setStreamDimensions,
streamDimensions: s.streamDimensions,
}))
const {
auth: {
context: { token },
},
settings: {
context: { showDebugPanel, onboardingStatus, cameraControls, theme },
},
} = useGlobalStateContext()
const [token] = useAuthMachine((s) => s?.context?.token)
const editorTheme = theme === Themes.System ? getSystemTheme() : theme
@ -84,41 +150,9 @@ export function App() {
useHotkeys('shift + l', () => togglePane('logs'))
useHotkeys('shift + e', () => togglePane('kclErrors'))
useHotkeys('shift + d', () => togglePane('debug'))
useHotkeys('esc', () => {
if (guiMode.mode === 'sketch') {
if (guiMode.sketchMode === 'selectFace') return
if (guiMode.sketchMode === 'sketchEdit') {
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'edit_mode_exit' },
})
setGuiMode({ mode: 'default' })
} else {
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'set_tool',
tool: 'select',
},
})
setGuiMode({
mode: 'sketch',
sketchMode: 'sketchEdit',
rotation: guiMode.rotation,
position: guiMode.position,
pathToNode: guiMode.pathToNode,
// todo: ...guiMod is adding isTooltip: true, will probably just fix with xstate migtaion
})
}
} else {
setGuiMode({ mode: 'default' })
}
})
const paneOpacity =
onboardingStatus === onboardingPaths.CAMERA
onboardingStatus === 'camera'
? 'opacity-20'
: didDragInStream
? 'opacity-40'
@ -138,74 +172,236 @@ export function App() {
}
}, [loadedCode, setCode])
useSetupEngineManager(streamRef, token)
useEngineConnectionSubscriptions()
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
const onChange = (value: string, viewUpdate: ViewUpdate) => {
setCode(value)
if (isTauri() && pathParams.id) {
// Save the file to disk
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
writeTextFile(pathParams.id + '/' + PROJECT_ENTRYPOINT, value).catch(
(err) => {
// TODO: add Sentry per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254)
console.error('error saving file', err)
toast.error('Error saving file, please check file permissions')
}
)
}
if (editorView) {
editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
}
} //, []);
const onUpdate = (viewUpdate: ViewUpdate) => {
if (!editorView) {
setEditorView(viewUpdate.view)
}
const ranges = viewUpdate.state.selection.ranges
const isChange =
ranges.length !== selectionRanges.codeBasedSelections.length ||
ranges.some(({ from, to }, i) => {
return (
from !== selectionRanges.codeBasedSelections[i].range[0] ||
to !== selectionRanges.codeBasedSelections[i].range[1]
)
})
if (!isChange) return
const codeBasedSelections: Selections['codeBasedSelections'] = ranges.map(
({ from, to }) => {
if (selectionRangeTypeMap[to]) {
return {
type: selectionRangeTypeMap[to],
range: [from, to],
}
}
return {
type: 'default',
range: [from, to],
}
}
)
const idBasedSelections = codeBasedSelections
.map(({ type, range }) => {
const hasOverlap = Object.entries(sourceRangeMap).filter(
([_, sourceRange]) => {
return isOverlap(sourceRange, range)
}
)
if (hasOverlap.length) {
return {
type,
id: hasOverlap[0][0],
}
}
})
.filter(Boolean) as any
engineCommandManager?.cusorsSelected({
otherSelections: [],
idBasedSelections,
})
setSelectionRanges({
otherSelections: [],
codeBasedSelections,
})
}
const pixelDensity = window.devicePixelRatio
const streamWidth = streamRef?.current?.offsetWidth
const streamHeight = streamRef?.current?.offsetHeight
const width = streamWidth ? streamWidth * pixelDensity : 0
const quadWidth = Math.round(width / 4) * 4
const height = streamHeight ? streamHeight * pixelDensity : 0
const quadHeight = Math.round(height / 4) * 4
useLayoutEffect(() => {
setStreamDimensions({
streamWidth: quadWidth,
streamHeight: quadHeight,
})
if (!width || !height) return
const eng = new EngineCommandManager({
setMediaStream,
setIsStreamReady,
width: quadWidth,
height: quadHeight,
token,
})
setEngineCommandManager(eng)
return () => {
eng?.tearDown()
}
}, [quadWidth, quadHeight])
useEffect(() => {
if (!isStreamReady) return
const asyncWrap = async () => {
try {
if (!code) {
setAst(null)
return
}
const _ast = await asyncParser(code)
setAst(_ast)
resetLogs()
resetKCLErrors()
if (engineCommandManager) {
engineCommandManager.endSession()
engineCommandManager.startNewSession()
}
if (!engineCommandManager) return
const programMemory = await _executor(
_ast,
{
root: {
log: {
type: 'userVal',
value: (a: any) => {
addLog(a)
},
__meta: [
{
pathToNode: [],
sourceRange: [0, 0],
},
],
},
_0: {
type: 'userVal',
value: 0,
__meta: [],
},
_90: {
type: 'userVal',
value: 90,
__meta: [],
},
_180: {
type: 'userVal',
value: 180,
__meta: [],
},
_270: {
type: 'userVal',
value: 270,
__meta: [],
},
},
pendingMemory: {},
},
engineCommandManager,
{ bodyType: 'root' },
[]
)
const { artifactMap, sourceRangeMap } =
await engineCommandManager.waitForAllCommands()
setArtifactMap({ artifactMap, sourceRangeMap })
engineCommandManager.onHover((id) => {
if (!id) {
setHighlightRange([0, 0])
} else {
const sourceRange = sourceRangeMap[id]
setHighlightRange(sourceRange)
}
})
engineCommandManager.onClick((selections) => {
if (!selections) {
setCursor2()
return
}
const { id, type } = selections
setCursor2({ range: sourceRangeMap[id], type })
})
if (programMemory !== undefined) {
setProgramMemory(programMemory)
}
setError()
} catch (e: any) {
if (e instanceof KCLError) {
addKCLError(e)
} else {
setError('problem')
console.log(e)
addLog(e)
}
}
}
asyncWrap()
}, [code, isStreamReady])
const debounceSocketSend = throttle<EngineCommand>((message) => {
engineCommandManager?.sendSceneCommand(message)
}, 16)
const handleMouseMove: MouseEventHandler<HTMLDivElement> = (e) => {
e.nativeEvent.preventDefault()
const handleMouseMove: MouseEventHandler<HTMLDivElement> = ({
clientX,
clientY,
ctrlKey,
shiftKey,
currentTarget,
nativeEvent,
}) => {
nativeEvent.preventDefault()
if (isMouseDownInStream) {
setDidDragInStream(true)
}
const { x, y } = getNormalisedCoordinates({
clientX: e.clientX,
clientY: e.clientY,
el: e.currentTarget,
clientX,
clientY,
el: currentTarget,
...streamDimensions,
})
const interaction = ctrlKey ? 'zoom' : shiftKey ? 'pan' : 'rotate'
const newCmdId = uuidv4()
if (buttonDownInStream === undefined) {
if (
guiMode.mode === 'sketch' &&
guiMode.sketchMode === ('sketch_line' as any)
) {
debounceSocketSend({
type: 'modeling_cmd_req',
cmd_id: newCmdId,
cmd: {
type: 'mouse_move',
window: { x, y },
},
})
} else {
debounceSocketSend({
type: 'modeling_cmd_req',
cmd: {
type: 'highlight_set_entity',
selected_at_window: { x, y },
},
cmd_id: newCmdId,
})
}
} else {
if (guiMode.mode === 'sketch' && guiMode.sketchMode === ('move' as any)) {
debounceSocketSend({
type: 'modeling_cmd_req',
cmd_id: newCmdId,
cmd: {
type: 'handle_mouse_drag_move',
window: { x, y },
},
})
return
}
const interactionGuards = cameraMouseDragGuards[cameraControls]
let interaction: CameraDragInteractionType_type
const eWithButton = { ...e, button: buttonDownInStream }
if (interactionGuards.pan.callback(eWithButton)) {
interaction = 'pan'
} else if (interactionGuards.rotate.callback(eWithButton)) {
interaction = 'rotate'
} else if (interactionGuards.zoom.dragCallback(eWithButton)) {
interaction = 'zoom'
} else {
console.log('none')
return
}
setCmdId(newCmdId)
if (cmdId && isMouseDownInStream) {
debounceSocketSend({
type: 'modeling_cmd_req',
cmd: {
@ -215,9 +411,28 @@ export function App() {
},
cmd_id: newCmdId,
})
} else {
debounceSocketSend({
type: 'modeling_cmd_req',
cmd: {
type: 'highlight_set_entity',
selected_at_window: { x, y },
},
cmd_id: newCmdId,
})
}
}
const extraExtensions = useMemo(() => {
if (TEST) return []
return [
lintGutter(),
linter((_view) => {
return kclErrToDiagnostic(useStore.getState().kclErrors)
}),
]
}, [])
return (
<div
className="h-screen overflow-hidden relative flex flex-col cursor-pointer select-none"
@ -228,7 +443,7 @@ export function App() {
className={
'transition-opacity transition-duration-75 ' +
paneOpacity +
(buttonDownInStream ? ' pointer-events-none' : '')
(isMouseDownInStream ? ' pointer-events-none' : '')
}
project={project}
enableMenu={true}
@ -237,17 +452,17 @@ export function App() {
<Resizable
className={
'h-full flex flex-col flex-1 z-10 my-5 ml-5 pr-1 transition-opacity transition-duration-75 ' +
(buttonDownInStream || onboardingStatus === 'camera'
(isMouseDownInStream || onboardingStatus === 'camera'
? ' pointer-events-none '
: ' ') +
paneOpacity
}
defaultSize={{
width: '550px',
width: '400px',
height: 'auto',
}}
minWidth={200}
maxWidth={800}
maxWidth={600}
minHeight={'auto'}
maxHeight={'auto'}
handleClasses={{
@ -261,9 +476,31 @@ export function App() {
icon={faCode}
className="open:!mb-2"
open={openPanes.includes('code')}
menu={<CodeMenu />}
>
<TextEditor theme={editorTheme} />
<div className="px-2 py-1">
<button
// disabled={!shouldFormat}
onClick={formatCode}
// className={`${!shouldFormat && 'text-gray-300'}`}
>
format
</button>
</div>
<div id="code-mirror-override">
<CodeMirror
className="h-full"
value={code}
extensions={[
langs.javascript({ jsx: true }),
lineHighlightField,
...extraExtensions,
]}
onChange={onChange}
onUpdate={onUpdate}
theme={editorTheme}
onCreateEditor={(_editorView) => setEditorView(_editorView)}
/>
</div>
</CollapsiblePanel>
<section className="flex flex-col">
<MemoryPanel
@ -288,13 +525,13 @@ export function App() {
</div>
</Resizable>
<Stream className="absolute inset-0 z-0" />
{showDebugPanel && (
{debugPanel && (
<DebugPanel
title="Debug"
className={
'transition-opacity transition-duration-75 ' +
paneOpacity +
(buttonDownInStream ? ' pointer-events-none' : '')
(isMouseDownInStream ? ' pointer-events-none' : '')
}
open={openPanes.includes('debug')}
/>

View File

@ -1,12 +1,9 @@
import Loading from './components/Loading'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useAuthMachine } from './hooks/useAuthMachine'
// Wrapper around protected routes, used in src/Router.tsx
export const Auth = ({ children }: React.PropsWithChildren) => {
const {
auth: { state },
} = useGlobalStateContext()
const isLoggedIn = state.matches('checkIfLoggedIn')
const [isLoggedIn] = useAuthMachine((s) => s.matches('checkIfLoggedIn'))
return isLoggedIn ? (
<Loading>Loading KittyCAD Modeling App...</Loading>

View File

@ -3,15 +3,8 @@ import {
createBrowserRouter,
Outlet,
redirect,
useLocation,
RouterProvider,
} from 'react-router-dom'
import {
matchRoutes,
createRoutesFromChildren,
useNavigationType,
} from 'react-router'
import { useEffect } from 'react'
import { ErrorPage } from './components/ErrorPage'
import { Settings } from './routes/Settings'
import Onboarding, {
@ -31,47 +24,7 @@ import {
} from './lib/tauriFS'
import { metadata, type Metadata } from 'tauri-plugin-fs-extra-api'
import DownloadAppBanner from './components/DownloadAppBanner'
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,
})
}
import { GlobalStateProvider } from './hooks/useAuthMachine'
const prependRoutes =
(routesObject: Record<string, string>) => (prepend: string) => {
@ -115,11 +68,7 @@ const addGlobalContextToElements = (
'element' in route
? {
...route,
element: (
<CommandBarProvider>
<GlobalStateProvider>{route.element}</GlobalStateProvider>
</CommandBarProvider>
),
element: <GlobalStateProvider>{route.element}</GlobalStateProvider>,
}
: route
)
@ -146,25 +95,26 @@ const router = createBrowserRouter(
request,
params,
}): Promise<IndexLoaderData | Response> => {
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
ContextFrom<typeof settingsMachine>
>
const store = localStorage.getItem('store')
if (store === null) {
return redirect(paths.ONBOARDING.INDEX)
} else {
const status = JSON.parse(store).state.onboardingStatus || ''
const notEnRouteToOnboarding =
!request.url.includes(paths.ONBOARDING.INDEX) &&
request.method === 'GET'
// '' is the initial state, 'done' and 'dismissed' are the final states
const hasValidOnboardingStatus =
(status !== undefined && status.length === 0) ||
!(status === 'done' || status === 'dismissed')
const shouldRedirectToOnboarding =
notEnRouteToOnboarding && hasValidOnboardingStatus
const status = persistedSettings.onboardingStatus || ''
const notEnRouteToOnboarding = !request.url.includes(
paths.ONBOARDING.INDEX
)
// '' is the initial state, 'done' and 'dismissed' are the final states
const hasValidOnboardingStatus =
status.length === 0 || !(status === 'done' || status === 'dismissed')
const shouldRedirectToOnboarding =
notEnRouteToOnboarding && hasValidOnboardingStatus
if (shouldRedirectToOnboarding) {
return redirect(
makeUrlPathRelative(paths.ONBOARDING.INDEX) + status.slice(1)
)
if (shouldRedirectToOnboarding) {
return redirect(
makeUrlPathRelative(paths.ONBOARDING.INDEX) + status
)
}
}
if (params.id && params.id !== 'new') {
@ -214,23 +164,9 @@ const router = createBrowserRouter(
if (!isTauri()) {
return redirect(paths.FILE + '/new')
}
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
ContextFrom<typeof settingsMachine>
>
const projectDir = await initializeProjectDirectory(
persistedSettings.defaultDirectory || ''
)
if (projectDir !== persistedSettings.defaultDirectory) {
localStorage.setItem(
SETTINGS_PERSIST_KEY,
JSON.stringify({
...persistedSettings,
defaultDirectory: projectDir,
})
)
}
const projectsNoMeta = (await readDir(projectDir)).filter(
const projectDir = await initializeProjectDirectory()
const projectsNoMeta = (await readDir(projectDir.dir)).filter(
isProjectDirectory
)
const projects = await Promise.all(

View File

@ -1,69 +0,0 @@
.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;
}
.smallScrollbar::-webkit-scrollbar {
@apply h-0.5;
}
.smallScrollbar {
@apply overflow-x-auto;
scrollbar-width: thin;
}
: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;
}

View File

@ -1,4 +1,4 @@
import { useStore, toolTips, Selections } from './useStore'
import { useStore, toolTips } from './useStore'
import { extrudeSketch, sketchOnExtrudedFace } from './lang/modifyAst'
import { getNodePathFromSourceRange } from './lang/queryAst'
import { HorzVert } from './components/Toolbar/HorzVert'
@ -8,15 +8,9 @@ import { EqualAngle } from './components/Toolbar/EqualAngle'
import { Intersect } from './components/Toolbar/Intersect'
import { SetHorzVertDistance } from './components/Toolbar/SetHorzVertDistance'
import { SetAngleLength } from './components/Toolbar/setAngleLength'
import { ConvertToVariable } from './components/Toolbar/ConvertVariable'
import { SetAbsDistance } from './components/Toolbar/SetAbsDistance'
import { SetAngleBetween } from './components/Toolbar/SetAngleBetween'
import { Fragment, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSearch, faX } from '@fortawesome/free-solid-svg-icons'
import { Popover, Transition } from '@headlessui/react'
import styles from './Toolbar.module.css'
import { v4 as uuidv4 } from 'uuid'
import { useAppMode } from 'hooks/useAppMode'
export const Toolbar = () => {
const {
@ -26,8 +20,6 @@ export const Toolbar = () => {
ast,
updateAst,
programMemory,
engineCommandManager,
executeAst,
} = useStore((s) => ({
guiMode: s.guiMode,
setGuiMode: s.setGuiMode,
@ -35,31 +27,58 @@ export const Toolbar = () => {
ast: s.ast,
updateAst: s.updateAst,
programMemory: s.programMemory,
engineCommandManager: s.engineCommandManager,
executeAst: s.executeAst,
}))
useAppMode()
useEffect(() => {
console.log('guiMode', guiMode)
}, [guiMode])
function ToolbarButtons() {
return (
<span className={styles.smallScrollbar}>
{guiMode.mode === 'default' && (
<button
onClick={() => {
setGuiMode({
mode: 'sketch',
sketchMode: 'selectFace',
})
}}
>
Start Sketch
</button>
)}
{guiMode.mode === 'canEditExtrude' && (
return (
<div>
{guiMode.mode === 'default' && (
<button
onClick={() => {
setGuiMode({
mode: 'sketch',
sketchMode: 'selectFace',
})
}}
>
Start Sketch
</button>
)}
{guiMode.mode === 'canEditExtrude' && (
<button
onClick={() => {
if (!ast) return
const pathToNode = getNodePathFromSourceRange(
ast,
selectionRanges.codeBasedSelections[0].range
)
const { modifiedAst } = sketchOnExtrudedFace(
ast,
pathToNode,
programMemory
)
updateAst(modifiedAst)
}}
>
SketchOnFace
</button>
)}
{(guiMode.mode === 'canEditSketch' || false) && (
<button
onClick={() => {
setGuiMode({
mode: 'sketch',
sketchMode: 'sketchEdit',
pathToNode: guiMode.pathToNode,
rotation: guiMode.rotation,
position: guiMode.position,
})
}}
>
Edit Sketch
</button>
)}
{guiMode.mode === 'canEditSketch' && (
<>
<button
onClick={() => {
if (!ast) return
@ -67,218 +86,93 @@ export const Toolbar = () => {
ast,
selectionRanges.codeBasedSelections[0].range
)
const { modifiedAst } = sketchOnExtrudedFace(
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
ast,
pathToNode
)
updateAst(modifiedAst, { focusPath: pathToExtrudeArg })
}}
>
ExtrudeSketch
</button>
<button
onClick={() => {
if (!ast) return
const pathToNode = getNodePathFromSourceRange(
ast,
selectionRanges.codeBasedSelections[0].range
)
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
ast,
pathToNode,
programMemory
false
)
updateAst(modifiedAst, true)
updateAst(modifiedAst, { focusPath: pathToExtrudeArg })
}}
>
SketchOnFace
ExtrudeSketch (w/o pipe)
</button>
)}
{guiMode.mode === 'canEditSketch' && (
<button
onClick={() => {
console.log('guiMode.pathId', guiMode.pathId)
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'edit_mode_enter',
target: guiMode.pathId,
},
})
setGuiMode({
mode: 'sketch',
sketchMode: 'sketchEdit',
pathToNode: guiMode.pathToNode,
rotation: guiMode.rotation,
position: guiMode.position,
})
}}
>
Edit Sketch
</button>
)}
{guiMode.mode === 'canEditSketch' && (
<>
<button
onClick={() => {
if (!ast) return
const pathToNode = getNodePathFromSourceRange(
ast,
selectionRanges.codeBasedSelections[0].range
)
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
ast,
pathToNode
)
updateAst(modifiedAst, true, { focusPath: pathToExtrudeArg })
}}
>
ExtrudeSketch
</button>
<button
onClick={() => {
if (!ast) return
const pathToNode = getNodePathFromSourceRange(
ast,
selectionRanges.codeBasedSelections[0].range
)
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
ast,
pathToNode,
false
)
updateAst(modifiedAst, true, { focusPath: pathToExtrudeArg })
}}
>
ExtrudeSketch (w/o pipe)
</button>
</>
)}
</>
)}
{guiMode.mode === 'sketch' && (
<button
onClick={() => {
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'edit_mode_exit' },
})
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'default_camera_disable_sketch_mode' },
})
setGuiMode({ mode: 'default' })
executeAst()
}}
>
Exit sketch
</button>
)}
{toolTips
.filter(
// (sketchFnName) => !['angledLineThatIntersects'].includes(sketchFnName)
(sketchFnName) => ['sketch_line', 'move'].includes(sketchFnName)
{guiMode.mode === 'sketch' && (
<button onClick={() => setGuiMode({ mode: 'default' })}>
Exit sketch
</button>
)}
{toolTips
.filter(
// (sketchFnName) => !['angledLineThatIntersects'].includes(sketchFnName)
(sketchFnName) => ['line'].includes(sketchFnName)
)
.map((sketchFnName) => {
if (
guiMode.mode !== 'sketch' ||
!('isTooltip' in guiMode || guiMode.sketchMode === 'sketchEdit')
)
.map((sketchFnName) => {
if (
guiMode.mode !== 'sketch' ||
!('isTooltip' in guiMode || guiMode.sketchMode === 'sketchEdit')
)
return null
return (
<button
key={sketchFnName}
onClick={() => {
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'set_tool',
tool:
guiMode.sketchMode === sketchFnName
? 'select'
: (sketchFnName as any),
},
})
setGuiMode({
...guiMode,
...(guiMode.sketchMode === sketchFnName
? {
sketchMode: 'sketchEdit',
// todo: ...guiMod is adding isTooltip: true, will probably just fix with xstate migtaion
}
: {
sketchMode: sketchFnName,
waitingFirstClick: true,
isTooltip: true,
}),
})
}}
>
{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 />
</span>
)
}
return (
<Popover className={styles.toolbarWrapper + ' ' + guiMode.mode}>
<div className={styles.toolbar}>
<span className={styles.toolbarCap + ' ' + styles.label}>
{guiMode.mode === 'sketch' ? '2D' : '3D'}
</span>
<menu className="flex flex-1 gap-2 py-0.5 overflow-hidden whitespace-nowrap">
<ToolbarButtons />
</menu>
<Popover.Button
className={styles.toolbarCap + ' ' + styles.popoverToggle}
>
<FontAwesomeIcon icon={faSearch} />
</Popover.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-out duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Popover.Overlay className="fixed inset-0 bg-chalkboard-110/20 dark:bg-chalkboard-110/50" />
</Transition>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 translate-y-1 scale-95"
enterTo="opacity-100 translate-y-0 scale-100"
leave="transition ease-out duration-75"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-2"
>
<Popover.Panel className="absolute top-0 w-screen max-w-xl left-1/2 -translate-x-1/2 flex flex-col gap-8 bg-chalkboard-10 dark:bg-chalkboard-100 p-5 rounded border border-chalkboard-20/30 dark:border-chalkboard-70/50">
<section className="flex justify-between items-center">
<p
className={`${styles.toolbarCap} ${styles.label} !self-center rounded-r-full w-fit`}
return null
return (
<button
key={sketchFnName}
onClick={() =>
setGuiMode({
...guiMode,
...(guiMode.sketchMode === sketchFnName
? {
sketchMode: 'sketchEdit',
// todo: ...guiMod is adding isTooltip: true, will probably just fix with xstate migtaion
}
: {
sketchMode: sketchFnName,
isTooltip: true,
}),
})
}
>
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>
{sketchFnName}
{guiMode.sketchMode === sketchFnName && '✅'}
</button>
)
})}
<br></br>
<ConvertToVariable />
<HorzVert horOrVert="horizontal" />
<HorzVert horOrVert="vertical" />
<EqualLength />
<EqualAngle />
<SetHorzVertDistance buttonType="alignEndsVertically" />
<SetHorzVertDistance buttonType="setHorzDistance" />
<SetAbsDistance buttonType="snapToYAxis" />
<SetAbsDistance buttonType="xAbs" />
<SetHorzVertDistance buttonType="alignEndsHorizontally" />
<SetAbsDistance buttonType="snapToXAxis" />
<SetHorzVertDistance buttonType="setVertDistance" />
<SetAbsDistance buttonType="yAbs" />
<SetAngleLength angleOrLength="setAngle" />
<SetAngleLength angleOrLength="setLength" />
<Intersect />
<RemoveConstrainingValues />
<SetAngleBetween />
</div>
)
}

View File

@ -8,13 +8,11 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
const iconSizes = {
sm: 12,
md: 14.4,
lg: 20,
xl: 28,
lg: 18,
}
export interface ActionIconProps extends React.PropsWithChildren {
icon?: SolidIconDefinition | BrandIconDefinition
className?: string
bgClassName?: string
iconClassName?: string
size?: keyof typeof iconSizes
@ -22,7 +20,6 @@ export interface ActionIconProps extends React.PropsWithChildren {
export const ActionIcon = ({
icon = faCircleExclamation,
className,
bgClassName,
iconClassName,
size = 'md',
@ -31,9 +28,7 @@ export const ActionIcon = ({
return (
<div
className={
`p-${
size === 'xl' ? '2' : '1'
} w-fit inline-grid place-content-center ${className} ` +
'p-1 w-fit inline-grid place-content-center ' +
(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')
}
@ -45,7 +40,7 @@ export const ActionIcon = ({
height={iconSizes[size]}
className={
iconClassName ||
'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'
'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'
}
/>
)}

View File

@ -1,7 +0,0 @@
/*
Some CSS cannot be represented
in Tailwind, such as complex grid layouts.
*/
.header {
grid-template-columns: 1fr auto 1fr;
}

View File

@ -2,9 +2,7 @@ import { Toolbar } from '../Toolbar'
import UserSidebarMenu from './UserSidebarMenu'
import { ProjectWithEntryPointMetadata } from '../Router'
import ProjectSidebarMenu from './ProjectSidebarMenu'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import styles from './AppHeader.module.css'
import { NetworkHealthIndicator } from './NetworkHealthIndicator'
import { useAuthMachine } from '../hooks/useAuthMachine'
interface AppHeaderProps extends React.PropsWithChildren {
showToolbar?: boolean
@ -20,18 +18,12 @@ export const AppHeader = ({
className = '',
enableMenu = false,
}: AppHeaderProps) => {
const {
auth: {
context: { user },
},
} = useGlobalStateContext()
const [user] = useAuthMachine((s) => s?.context?.user)
return (
<header
className={
(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 ' +
'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 ' +
className
}
>
@ -43,12 +35,7 @@ export const AppHeader = ({
</div>
)}
{/* If there are children, show them, otherwise show User menu */}
{children || (
<div className="ml-auto flex items-center gap-1">
<NetworkHealthIndicator />
<UserSidebarMenu user={user} />
</div>
)}
{children || <UserSidebarMenu user={user} />}
</header>
)
}

View File

@ -1,182 +0,0 @@
import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst'
import { useEffect, useRef, useState } from 'react'
import { useStore } from 'useStore'
export function AstExplorer() {
const { ast, setHighlightRange, selectionRanges } = useStore((s) => ({
ast: s.ast,
setHighlightRange: s.setHighlightRange,
selectionRanges: s.selectionRanges,
}))
const pathToNode = getNodePathFromSourceRange(
ast,
selectionRanges.codeBasedSelections?.[0]?.range
)
const node = getNodeFromPath(ast, pathToNode).node
const [filterKeys, setFilterKeys] = useState<string[]>(['start', 'end'])
return (
<div className="relative" style={{ width: '300px' }}>
<div className="">
filter out keys:<div className="w-2 inline-block"></div>
{['start', 'end', 'type'].map((key) => {
return (
<label key={key} className="inline-flex items-center">
<input
type="checkbox"
className="form-checkbox"
checked={filterKeys.includes(key)}
onChange={(e) => {
if (filterKeys.includes(key)) {
setFilterKeys(filterKeys.filter((k) => k !== key))
} else {
setFilterKeys([...filterKeys, key])
}
}}
/>
<span className="mr-2">{key}</span>
</label>
)
})}
</div>
<div
className="h-full relative"
onMouseLeave={(e) => {
setHighlightRange([0, 0])
}}
>
<pre className=" text-xs overflow-y-auto" style={{ width: '300px' }}>
<DisplayObj obj={ast} filterKeys={filterKeys} node={node} />
</pre>
</div>
</div>
)
}
function DisplayBody({
body,
filterKeys,
node,
}: {
body: { start: number; end: number; [key: string]: any }[]
filterKeys: string[]
node: any
}) {
return (
<>
{body.map((b, index) => {
return (
<div className="my-2" key={index}>
<DisplayObj obj={b} filterKeys={filterKeys} node={node} />
</div>
)
})}
</>
)
}
function DisplayObj({
obj,
filterKeys,
node,
}: {
obj: { start: number; end: number; [key: string]: any }
filterKeys: string[]
node: any
}) {
const { setHighlightRange, setCursor2 } = useStore((s) => ({
setHighlightRange: s.setHighlightRange,
setCursor2: s.setCursor2,
}))
const ref = useRef<HTMLPreElement>(null)
const [hasCursor, setHasCursor] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(false)
useEffect(() => {
if (
node?.start === obj?.start &&
node?.end === obj?.end &&
node.type === obj?.type
) {
ref?.current?.scrollIntoView?.({ behavior: 'smooth', block: 'center' })
setHasCursor(true)
} else {
setHasCursor(false)
}
}, [node.start, node.end, node.type])
return (
<pre
ref={ref}
className={`ml-2 border-l border-violet-600 pl-1 ${
hasCursor ? 'bg-violet-100/25' : ''
}`}
onMouseEnter={(e) => {
setHighlightRange([obj?.start || 0, obj.end])
e.stopPropagation()
}}
onMouseMove={(e) => {
e.stopPropagation()
setHighlightRange([obj?.start || 0, obj.end])
}}
onClick={(e) => {
setCursor2({ type: 'default', range: [obj?.start || 0, obj.end || 0] })
e.stopPropagation()
}}
>
{isCollapsed ? (
<button
className="m-0 p-0 border-0"
onClick={() => setIsCollapsed(false)}
>
{'>'}type: {obj.type}
</button>
) : (
<span className="flex">
{/* <button className="m-0 p-0 border-0 mb-auto" onClick={() => setIsCollapsed(true)}>{'⬇️'}</button> */}
<ul className="inline-block">
{Object.entries(obj).map(([key, value]) => {
if (filterKeys.includes(key)) {
return null
} else if (Array.isArray(value)) {
return (
<li key={key}>
{`${key}: [`}
<DisplayBody
body={value}
filterKeys={filterKeys}
node={node}
/>
{']'}
</li>
)
} else if (
typeof value === 'object' &&
value !== null &&
value?.end
) {
return (
<li key={key}>
{key}:
<DisplayObj
obj={value}
filterKeys={filterKeys}
node={node}
/>
</li>
)
} else if (
typeof value === 'string' ||
typeof value === 'number'
) {
return (
<li key={key}>
{key}: {value}
</li>
)
}
})}
</ul>
</span>
)}
</pre>
)
}

View File

@ -144,7 +144,7 @@ export function useCalc({
try {
const code = `const __result__ = ${value}\nshow(__result__)`
const ast = parser_wasm(code)
const _programMem: any = { root: {}, return: null }
const _programMem: any = { root: {} }
availableVarInfo.variables.forEach(({ key, value }) => {
_programMem.root[key] = { type: 'userVal', value, __meta: [] }
})
@ -198,25 +198,29 @@ export const CreateNewVariable = ({
isNewVariableNameUnique,
setNewVariableName,
shouldCreateVariable,
setShouldCreateVariable = () => {},
setShouldCreateVariable,
showCheckbox = true,
}: {
isNewVariableNameUnique: boolean
newVariableName: string
setNewVariableName: (a: string) => void
shouldCreateVariable?: boolean
setShouldCreateVariable?: (a: boolean) => void
shouldCreateVariable: boolean
setShouldCreateVariable: (a: boolean) => void
showCheckbox?: boolean
}) => {
return (
<>
<label htmlFor="create-new-variable" className="block mt-3 font-mono">
<label
htmlFor="create-new-variable"
className="block text-sm font-medium text-gray-700 mt-3 font-mono"
>
Create new variable
</label>
<div className="mt-1 flex gap-2 items-center">
<div className="mt-1 flex flex-1">
{showCheckbox && (
<input
type="checkbox"
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md font-mono pl-1 flex-shrink"
checked={shouldCreateVariable}
onChange={(e) => {
setShouldCreateVariable(e.target.checked)
@ -228,10 +232,7 @@ export const CreateNewVariable = ({
disabled={!shouldCreateVariable}
name="create-new-variable"
id="create-new-variable"
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 ${
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 ${
!shouldCreateVariable ? 'opacity-50' : ''
}`}
value={newVariableName}

View File

@ -1,19 +0,0 @@
.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;
}

View File

@ -1,81 +0,0 @@
import { Menu } from '@headlessui/react'
import { PropsWithChildren } from 'react'
import {
faArrowUpRightFromSquare,
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'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
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.Item>
<a
className={styles.button}
href="https://github.com/KittyCAD/modeling-app/blob/main/docs/kcl/std.md"
target="_blank"
rel="noopener noreferrer"
>
<span>Read the KCL docs</span>
<small>
On GitHub
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1 align-text-top"
width={12}
/>
</small>
</a>
</Menu.Item>
</Menu.Items>
</div>
</Menu>
)
}

View File

@ -1,15 +1,15 @@
.panel {
@apply relative z-0;
@apply bg-chalkboard-10/70 backdrop-blur-sm;
@apply relative overflow-auto z-0;
@apply bg-chalkboard-20/40;
}
:global(.dark) .panel {
@apply bg-chalkboard-110/50 backdrop-blur-0;
@apply bg-chalkboard-110/50;
}
.header {
@apply sticky top-0 z-10 cursor-pointer;
@apply flex items-center justify-between gap-2 w-full p-2;
@apply flex items-center gap-2 w-full p-2;
@apply font-mono text-xs font-bold select-none text-chalkboard-90;
@apply bg-chalkboard-20;
}

View File

@ -8,7 +8,6 @@ export interface CollapsiblePanelProps
title: string
icon?: IconDefinition
open?: boolean
menu?: React.ReactNode
iconClassNames?: {
bg?: string
icon?: string
@ -19,27 +18,21 @@ export const PanelHeader = ({
title,
icon,
iconClassNames,
menu,
}: CollapsiblePanelProps) => {
return (
<summary className={styles.header}>
<div className="flex gap-2 align-center flex-1">
<ActionIcon
icon={icon}
bgClassName={
'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 ' +
(iconClassNames?.icon || '')
}
/>
{title}
</div>
<div className="group-open:opacity-100 opacity-0 group-open:pointer-events-auto pointer-events-none">
{menu}
</div>
<ActionIcon
icon={icon}
bgClassName={
'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 ' +
(iconClassNames?.icon || '')
}
/>
{title}
</summary>
)
}
@ -50,7 +43,6 @@ export const CollapsiblePanel = ({
children,
className,
iconClassNames,
menu,
...props
}: CollapsiblePanelProps) => {
return (
@ -58,12 +50,7 @@ export const CollapsiblePanel = ({
{...props}
className={styles.panel + ' group ' + (className || '')}
>
<PanelHeader
title={title}
icon={icon}
iconClassNames={iconClassNames}
menu={menu}
/>
<PanelHeader title={title} icon={icon} iconClassNames={iconClassNames} />
{children}
</details>
)

View File

@ -1,290 +0,0 @@
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

View File

@ -5,8 +5,6 @@ import { EngineCommand } from '../lang/std/engineConnection'
import { useState } from 'react'
import { ActionButton } from '../components/ActionButton'
import { faCheck } from '@fortawesome/free-solid-svg-icons'
import { isReducedMotion } from 'lang/util'
import { AstExplorer } from './AstExplorer'
type SketchModeCmd = Extract<
Extract<EngineCommand, { type: 'modeling_cmd_req' }>['cmd'],
@ -24,7 +22,7 @@ export const DebugPanel = ({ className, ...props }: CollapsiblePanelProps) => {
y_axis: { x: 0, y: 1, z: 0 },
distance_to_plane: 100,
ortho: true,
animated: !isReducedMotion(),
animated: true, // TODO #273 get prefers reduced motion from CSS
})
if (!sketchModeCmd) return null
return (
@ -95,9 +93,6 @@ export const DebugPanel = ({ className, ...props }: CollapsiblePanelProps) => {
>
Send sketch mode command
</ActionButton>
<div style={{ height: '400px' }} className="overflow-y-auto">
<AstExplorer />
</div>
</section>
</CollapsiblePanel>
)

View File

@ -39,7 +39,6 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
const initialValues: OutputFormat = {
type: defaultType,
storage: 'embedded',
presentation: 'pretty',
}
const formik = useFormik({
initialValues,
@ -128,9 +127,7 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
id="storage"
name="storage"
onChange={formik.handleChange}
value={
'storage' in formik.values ? formik.values.storage : ''
}
value={formik.values.storage}
className="bg-chalkboard-20 dark:bg-chalkboard-90 w-full"
>
{type === 'gltf' && (

View File

@ -1,164 +0,0 @@
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'
import { invoke } from '@tauri-apps/api'
import { isTauri } from 'lib/isTauri'
import { VITE_KC_API_BASE_URL } from 'env'
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() {
localStorage.removeItem(TOKEN_PERSIST_KEY)
return (
!isTauri() &&
fetch(withBaseUrl('/logout'), {
method: 'POST',
credentials: 'include',
})
)
}

View File

@ -1,8 +1,7 @@
import ReactJson from 'react-json-view'
import { useEffect } from 'react'
import { useStore } from '../useStore'
import { Themes, useStore } from '../useStore'
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
import { Themes } from '../lib/theme'
const ReactJsonTypeHack = ReactJson as any

View File

@ -10,16 +10,16 @@ describe('processMemory', () => {
// Enable rotations #152
const code = `
const myVar = 5
fn myFn = (a) => {
const myFn = (a) => {
return a - 2
}
const otherVar = myFn(5)
const theExtrude = startSketchAt([0, 0])
const theExtrude = startSketchAt([0, 0])
|> lineTo([-2.4, myVar], %)
|> lineTo([-0.76, otherVar], %)
|> extrude(4, %)
const theSketch = startSketchAt([0, 0])
|> lineTo([-3.35, 0.17], %)
|> lineTo([0.98, 5.16], %)
@ -28,21 +28,30 @@ describe('processMemory', () => {
show(theExtrude, theSketch)`
const ast = parser_wasm(code)
const programMemory = await enginelessExecutor(ast, {
root: {},
return: null,
root: {
log: {
type: 'userVal',
value: (a: any) => {
console.log('raw log', a)
},
__meta: [],
},
},
pendingMemory: {},
})
const output = processMemory(programMemory)
expect(output.myVar).toEqual(5)
expect(output.myFn).toEqual('__function__')
expect(output.otherVar).toEqual(3)
expect(output).toEqual({
myVar: 5,
myFn: undefined,
myFn: '__function__',
otherVar: 3,
theExtrude: [],
theSketch: [
{ type: 'toPoint', to: [-3.35, 0.17], from: [0, 0], name: '' },
{ type: 'toPoint', to: [0.98, 5.16], from: [-3.35, 0.17], name: '' },
{ type: 'toPoint', to: [2.15, 4.32], from: [0.98, 5.16], name: '' },
{ type: 'toPoint', to: [-3.35, 0.17], from: [0, 0] },
{ type: 'toPoint', to: [0.98, 5.16], from: [-3.35, 0.17] },
{ type: 'toPoint', to: [2.15, 4.32], from: [0.98, 5.16] },
],
})
})

View File

@ -1,9 +1,8 @@
import ReactJson from 'react-json-view'
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
import { useStore } from '../useStore'
import { Themes, useStore } from '../useStore'
import { useMemo } from 'react'
import { ProgramMemory, Path, ExtrudeSurface } from '../lang/executor'
import { Themes } from '../lib/theme'
import { ProgramMemory } from '../lang/executor'
interface MemoryPanelProps extends CollapsiblePanelProps {
theme?: Exclude<Themes, Themes.System>
@ -49,12 +48,8 @@ export const processMemory = (programMemory: ProgramMemory) => {
Object.keys(programMemory.root).forEach((key) => {
const val = programMemory.root[key]
if (typeof val.value !== 'function') {
if (val.type === 'SketchGroup') {
processedMemory[key] = val.value.map(({ __geoMeta, ...rest }: Path) => {
return rest
})
} else if (val.type === 'ExtrudeGroup') {
processedMemory[key] = val.value.map(({ ...rest }: ExtrudeSurface) => {
if (val.type === 'sketchGroup' || val.type === 'extrudeGroup') {
processedMemory[key] = val.value.map(({ __geoMeta, ...rest }) => {
return rest
})
} else {

View File

@ -1,51 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import UserSidebarMenu from './UserSidebarMenu'
import { BrowserRouter } from 'react-router-dom'
import { GlobalStateProvider } from './GlobalStateProvider'
import CommandBarProvider from './CommandBar'
import {
NETWORK_CONTENT,
NetworkHealthIndicator,
} from './NetworkHealthIndicator'
function TestWrap({ children }: { children: React.ReactNode }) {
// wrap in router and xState context
return (
<BrowserRouter>
<CommandBarProvider>
<GlobalStateProvider>{children}</GlobalStateProvider>
</CommandBarProvider>
</BrowserRouter>
)
}
describe('NetworkHealthIndicator tests', () => {
test('Renders the network indicator', () => {
render(
<TestWrap>
<NetworkHealthIndicator />
</TestWrap>
)
fireEvent.click(screen.getByTestId('network-toggle'))
expect(screen.getByTestId('network-good')).toHaveTextContent(
NETWORK_CONTENT.good
)
})
test('Responds to network changes', () => {
render(
<TestWrap>
<NetworkHealthIndicator />
</TestWrap>
)
fireEvent.offline(window)
fireEvent.click(screen.getByTestId('network-toggle'))
expect(screen.getByTestId('network-bad')).toHaveTextContent(
NETWORK_CONTENT.bad
)
})
})

View File

@ -1,112 +0,0 @@
import {
faCheck,
faExclamation,
faWifi,
} from '@fortawesome/free-solid-svg-icons'
import { Popover } from '@headlessui/react'
import { useEffect, useState } from 'react'
import { ActionIcon } from './ActionIcon'
export const NETWORK_CONTENT = {
good: 'Network health is good',
bad: 'Network issue',
}
const NETWORK_MESSAGES = {
offline: 'You are offline',
}
export const NetworkHealthIndicator = () => {
const [networkIssues, setNetworkIssues] = useState<string[]>([])
const hasIssues = [...networkIssues.values()].length > 0
useEffect(() => {
const offlineListener = () =>
setNetworkIssues((issues) => {
return [
...issues.filter((issue) => issue !== NETWORK_MESSAGES.offline),
NETWORK_MESSAGES.offline,
]
})
window.addEventListener('offline', offlineListener)
const onlineListener = () =>
setNetworkIssues((issues) => {
return [...issues.filter((issue) => issue !== NETWORK_MESSAGES.offline)]
})
window.addEventListener('online', onlineListener)
return () => {
window.removeEventListener('offline', offlineListener)
window.removeEventListener('online', onlineListener)
}
}, [])
return (
<Popover className="relative">
<Popover.Button
className={
'p-0 border-none relative ' +
(hasIssues
? 'focus-visible:outline-destroy-80'
: 'focus-visible:outline-succeed-80')
}
data-testid="network-toggle"
>
<span className="sr-only">Network Health</span>
<ActionIcon
icon={faWifi}
iconClassName={
hasIssues
? 'text-destroy-80 dark:text-destroy-30'
: 'text-succeed-80 dark:text-succeed-30'
}
bgClassName={
hasIssues
? 'hover:bg-destroy-10/50 hover:dark:bg-destroy-80/50 rounded'
: 'hover:bg-succeed-10/50 hover:dark:bg-succeed-80/50 rounded'
}
/>
</Popover.Button>
<Popover.Panel className="absolute right-0 left-auto top-full mt-1 w-56 flex flex-col gap-1 divide-y divide-chalkboard-20 dark:divide-chalkboard-70 align-stretch py-2 bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm">
{!hasIssues ? (
<span
className="flex items-center justify-center gap-1 px-4"
data-testid="network-good"
>
<ActionIcon
icon={faCheck}
bgClassName={'bg-succeed-10/50 dark:bg-succeed-80/50 rounded'}
iconClassName={'text-succeed-80 dark:text-succeed-30'}
/>
{NETWORK_CONTENT.good}
</span>
) : (
<ul className="divide-y divide-chalkboard-20 dark:divide-chalkboard-80">
<span
className="font-bold text-xs uppercase text-destroy-60 dark:text-destroy-50 px-4"
data-testid="network-bad"
>
{NETWORK_CONTENT.bad}
{networkIssues.length > 1 ? 's' : ''}
</span>
{networkIssues.map((issue) => (
<li
key={issue}
className="flex items-center gap-1 py-2 my-2 last:mb-0"
>
<ActionIcon
icon={faExclamation}
bgClassName={'bg-destroy-10/50 dark:bg-destroy-80/50 rounded'}
iconClassName={'text-destroy-80 dark:text-destroy-30'}
className="ml-4"
/>
<p className="flex-1 mr-4">{issue}</p>
</li>
))}
</ul>
)}
</Popover.Panel>
</Popover>
)
}

View File

@ -1,11 +1,10 @@
import { Popover, Transition } from '@headlessui/react'
import { Popover } from '@headlessui/react'
import { ActionButton } from './ActionButton'
import { faHome } from '@fortawesome/free-solid-svg-icons'
import { ProjectWithEntryPointMetadata, paths } from '../Router'
import { isTauri } from '../lib/isTauri'
import { Link } from 'react-router-dom'
import { ExportButton } from './ExportButton'
import { Fragment } from 'react'
const ProjectSidebarMenu = ({
project,
@ -35,7 +34,7 @@ const ProjectSidebarMenu = ({
) : (
<Popover className="relative">
<Popover.Button
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"
className="border-0 px-1 pr-2 pl-0 flex items-center gap-4 focus:outline-none focus:ring-2 focus:ring-energy-50"
data-testid="project-sidebar-toggle"
>
<img
@ -47,77 +46,54 @@ const ProjectSidebarMenu = ({
{isTauri() && project?.name ? project.name : 'KittyCAD Modeling App'}
</span>
</Popover.Button>
<Transition
enter="duration-200 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="duration-100 ease-in"
leaveFrom="opacity-100"
leaveTo="opacity-0"
as={Fragment}
>
<Popover.Overlay className="fixed z-20 inset-0 bg-chalkboard-110/50" />
</Transition>
<Popover.Overlay className="fixed z-20 inset-0 bg-chalkboard-110/50" />
<Transition
enter="duration-100 ease-out"
enterFrom="opacity-0 -translate-x-1/4"
enterTo="opacity-100 translate-x-0"
leave="duration-75 ease-in"
leaveFrom="opacity-100 translate-x-0"
leaveTo="opacity-0 -translate-x-4"
as={Fragment}
>
<Popover.Panel className="fixed inset-0 right-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-energy-100 dark:border-energy-100/50 shadow-md rounded-r-lg overflow-hidden">
<div className="flex items-center gap-4 px-4 py-3 bg-energy-100">
<img
src="/kitt-8bit-winking.svg"
alt="KittyCAD App"
className="h-9 w-auto"
/>
<Popover.Panel className="fixed inset-0 right-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-energy-100 shadow-md rounded-r-lg overflow-hidden">
<div className="flex items-center gap-4 px-4 py-3 bg-energy-100">
<img
src="/kitt-8bit-winking.svg"
alt="KittyCAD App"
className="h-9 w-auto"
/>
<div>
<p
className="m-0 text-energy-10 text-mono"
data-testid="projectName"
>
{project?.name ? project.name : 'KittyCAD Modeling App'}
</p>
{project?.entrypoint_metadata && (
<p
className="m-0 text-energy-40 text-xs"
data-testid="createdAt"
>
Created{' '}
{project?.entrypoint_metadata.createdAt.toLocaleDateString()}
</p>
)}
</div>
</div>
<div className="p-4 flex flex-col gap-2">
<ExportButton
className={{
button:
'border-transparent dark:border-transparent dark:hover:border-energy-60',
}}
<div>
<p
className="m-0 text-energy-10 text-mono"
data-testid="projectName"
>
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>
{project?.name ? project.name : 'KittyCAD Modeling App'}
</p>
{project?.entrypoint_metadata && (
<p className="m-0 text-energy-40 text-xs" data-testid="createdAt">
Created{' '}
{project?.entrypoint_metadata.createdAt.toLocaleDateString()}
</p>
)}
</div>
</Popover.Panel>
</Transition>
</div>
<div className="p-4 flex flex-col gap-2">
<ExportButton
className={{
button:
'border-transparent dark:border-transparent dark:hover:border-energy-60',
}}
>
Export Model
</ExportButton>
{isTauri() && (
<ActionButton
Element="link"
to={paths.HOME}
icon={{
icon: faHome,
}}
className="border-transparent dark:border-transparent dark:hover:border-energy-60"
>
Go to Home
</ActionButton>
)}
</div>
</Popover.Panel>
</Popover>
)
}

View File

@ -1,9 +1,6 @@
import { Dialog, Transition } from '@headlessui/react'
import { Fragment } from 'react'
import { useCalc, CreateNewVariable } from './AvailableVarsHelpers'
import { ActionButton } from './ActionButton'
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import { toast } from 'react-hot-toast'
export const SetVarNameModal = ({
isOpen,
@ -22,65 +19,67 @@ export const SetVarNameModal = ({
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog
as="div"
className="fixed inset-0 z-40 overflow-y-auto p-4 pt-[25vh]"
onClose={onReject}
>
<Dialog as="div" className="relative z-10" onClose={onReject}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4"
enterTo="opacity-100 translate-y-0"
leave="ease-in duration-75"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-chalkboard-10/70 dark:bg-chalkboard-110/50" />
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
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}`)
}}
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<CreateNewVariable
setNewVariableName={setNewVariableName}
newVariableName={newVariableName}
isNewVariableNameUnique={isNewVariableNameUnique}
shouldCreateVariable={true}
showCheckbox={false}
/>
<div className="mt-8 flex justify-between">
<ActionButton
Element="button"
type="submit"
disabled={!isNewVariableNameUnique}
icon={{ icon: faPlus }}
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900 capitalize"
>
Add variable
</ActionButton>
<ActionButton Element="button" onClick={() => onReject(false)}>
Cancel
</ActionButton>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
Set {valueName}
</Dialog.Title>
<CreateNewVariable
setNewVariableName={setNewVariableName}
newVariableName={newVariableName}
isNewVariableNameUnique={isNewVariableNameUnique}
shouldCreateVariable={true}
setShouldCreateVariable={() => {}}
/>
<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>
</Transition>
)

View File

@ -7,58 +7,31 @@ import {
} from 'react'
import { v4 as uuidv4 } from 'uuid'
import { useStore } from '../useStore'
import { getNormalisedCoordinates, roundOff } from '../lib/utils'
import { getNormalisedCoordinates } from '../lib/utils'
import Loading from './Loading'
import { cameraMouseDragGuards } from 'lib/cameraControls'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { CameraDragInteractionType_type } from '@kittycad/lib/dist/types/src/models'
import { Models } from '@kittycad/lib'
import { addStartSketch } from 'lang/modifyAst'
import {
addCloseToPipe,
addNewSketchLn,
compareVec2Epsilon,
} from 'lang/std/sketch'
import { getNodeFromPath } from 'lang/queryAst'
import { Program, VariableDeclarator } from 'lang/abstractSyntaxTreeTypes'
export const Stream = ({ className = '' }) => {
const [isLoading, setIsLoading] = useState(true)
const [clickCoords, setClickCoords] = useState<{ x: number; y: number }>()
const videoRef = useRef<HTMLVideoElement>(null)
const {
mediaStream,
engineCommandManager,
setButtonDownInStream,
setIsMouseDownInStream,
setCmdId,
didDragInStream,
setDidDragInStream,
streamDimensions,
isExecuting,
guiMode,
ast,
updateAst,
setGuiMode,
programMemory,
} = useStore((s) => ({
mediaStream: s.mediaStream,
engineCommandManager: s.engineCommandManager,
setButtonDownInStream: s.setButtonDownInStream,
isMouseDownInStream: s.isMouseDownInStream,
setIsMouseDownInStream: s.setIsMouseDownInStream,
fileId: s.fileId,
setCmdId: s.setCmdId,
didDragInStream: s.didDragInStream,
setDidDragInStream: s.setDidDragInStream,
streamDimensions: s.streamDimensions,
isExecuting: s.isExecuting,
guiMode: s.guiMode,
ast: s.ast,
updateAst: s.updateAst,
setGuiMode: s.setGuiMode,
programMemory: s.programMemory,
}))
const {
settings: {
context: { cameraControls },
},
} = useGlobalStateContext()
useEffect(() => {
if (
@ -68,73 +41,45 @@ export const Stream = ({ className = '' }) => {
return
if (!videoRef.current) return
if (!mediaStream) return
console.log('setting video ref')
videoRef.current.srcObject = mediaStream
videoRef.current.play()
}, [mediaStream, engineCommandManager])
const handleMouseDown: MouseEventHandler<HTMLVideoElement> = (e) => {
const handleMouseDown: MouseEventHandler<HTMLVideoElement> = ({
clientX,
clientY,
ctrlKey,
}) => {
if (!videoRef.current) return
const { x, y } = getNormalisedCoordinates({
clientX: e.clientX,
clientY: e.clientY,
clientX,
clientY,
el: videoRef.current,
...streamDimensions,
})
console.log('click', x, y)
const newId = uuidv4()
setCmdId(newId)
const interactionGuards = cameraMouseDragGuards[cameraControls]
let interaction: CameraDragInteractionType_type = 'rotate'
const interaction = ctrlKey ? 'pan' : 'rotate'
if (
interactionGuards.pan.callback(e) ||
interactionGuards.pan.lenientDragStartButton === e.button
) {
interaction = 'pan'
} else if (
interactionGuards.rotate.callback(e) ||
interactionGuards.rotate.lenientDragStartButton === e.button
) {
interaction = 'rotate'
} else if (
interactionGuards.zoom.dragCallback(e) ||
interactionGuards.zoom.lenientDragStartButton === e.button
) {
interaction = 'zoom'
}
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'camera_drag_start',
interaction,
window: { x, y },
},
cmd_id: newId,
})
if (guiMode.mode === 'sketch' && guiMode.sketchMode === ('move' as any)) {
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'handle_mouse_drag_start',
window: { x, y },
},
cmd_id: newId,
})
} else if (
!(
guiMode.mode === 'sketch' &&
guiMode.sketchMode === ('sketch_line' as any)
)
) {
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'camera_drag_start',
interaction,
window: { x, y },
},
cmd_id: newId,
})
}
setButtonDownInStream(e.button)
setClickCoords({ x, y })
setIsMouseDownInStream(true)
}
const handleScroll: WheelEventHandler<HTMLVideoElement> = (e) => {
if (!cameraMouseDragGuards[cameraControls].zoom.scrollCallback(e)) return
e.preventDefault()
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
@ -151,7 +96,6 @@ export const Stream = ({ className = '' }) => {
ctrlKey,
}) => {
if (!videoRef.current) return
setButtonDownInStream(undefined)
const { x, y } = getNormalisedCoordinates({
clientX,
clientY,
@ -162,7 +106,7 @@ export const Stream = ({ className = '' }) => {
const newCmdId = uuidv4()
const interaction = ctrlKey ? 'pan' : 'rotate'
const command: Models['WebSocketRequest_type'] = {
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'camera_drag_end',
@ -170,8 +114,9 @@ export const Stream = ({ className = '' }) => {
window: { x, y },
},
cmd_id: newCmdId,
}
})
setIsMouseDownInStream(false)
if (!didDragInStream) {
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
@ -183,160 +128,7 @@ export const Stream = ({ className = '' }) => {
cmd_id: uuidv4(),
})
}
if (!didDragInStream && guiMode.mode === 'default') {
command.cmd = {
type: 'select_with_point',
selection_type: 'add',
selected_at_window: { x, y },
}
} else if (
(!didDragInStream &&
guiMode.mode === 'sketch' &&
['move', 'select'].includes(guiMode.sketchMode)) ||
(guiMode.mode === 'sketch' &&
guiMode.sketchMode === ('sketch_line' as any))
) {
command.cmd = {
type: 'mouse_click',
window: { x, y },
}
} else if (
guiMode.mode === 'sketch' &&
guiMode.sketchMode === ('move' as any)
) {
command.cmd = {
type: 'handle_mouse_drag_end',
window: { x, y },
}
}
engineCommandManager?.sendSceneCommand(command).then(async (resp) => {
if (command?.cmd?.type !== 'mouse_click' || !ast) return
if (
!(
guiMode.mode === 'sketch' &&
guiMode.sketchMode === ('sketch_line' as any as 'line')
)
)
return
// Check if the sketch group already exists.
const varDec = getNodeFromPath<VariableDeclarator>(
ast,
guiMode.pathToNode,
'VariableDeclarator'
).node
const variableName = varDec?.id?.name
const sketchGroup = programMemory.root[variableName]
const isEditingExistingSketch =
sketchGroup?.type === 'SketchGroup' && sketchGroup.value.length
if (
resp?.data?.data?.entities_modified?.length &&
guiMode.waitingFirstClick &&
!isEditingExistingSketch
) {
const curve = await engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'curve_get_control_points',
curve_id: resp?.data?.data?.entities_modified[0],
},
})
const coords: { x: number; y: number }[] =
curve.data.data.control_points
const _addStartSketch = addStartSketch(
ast,
[roundOff(coords[0].x), roundOff(coords[0].y)],
[
roundOff(coords[1].x - coords[0].x),
roundOff(coords[1].y - coords[0].y),
]
)
const _modifiedAst = _addStartSketch.modifiedAst
const _pathToNode = _addStartSketch.pathToNode
setGuiMode({
...guiMode,
pathToNode: _pathToNode,
waitingFirstClick: false,
})
updateAst(_modifiedAst, false)
} else if (
resp?.data?.data?.entities_modified?.length &&
(!guiMode.waitingFirstClick || isEditingExistingSketch)
) {
const curve = await engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'curve_get_control_points',
curve_id: resp?.data?.data?.entities_modified[0],
},
})
const coords: { x: number; y: number }[] =
curve.data.data.control_points
const { node: varDec } = getNodeFromPath<VariableDeclarator>(
ast,
guiMode.pathToNode,
'VariableDeclarator'
)
const variableName = varDec.id.name
const sketchGroup = programMemory.root[variableName]
if (!sketchGroup || sketchGroup.type !== 'SketchGroup') return
const initialCoords = sketchGroup.value[0].from
const isClose = compareVec2Epsilon(initialCoords, [
coords[1].x,
coords[1].y,
])
let _modifiedAst: Program
if (!isClose) {
_modifiedAst = addNewSketchLn({
node: ast,
programMemory,
to: [coords[1].x, coords[1].y],
fnName: 'line',
pathToNode: guiMode.pathToNode,
}).modifiedAst
updateAst(_modifiedAst, false)
} else {
_modifiedAst = addCloseToPipe({
node: ast,
programMemory,
pathToNode: guiMode.pathToNode,
})
setGuiMode({
mode: 'default',
})
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'sketch_mode_disable',
},
})
updateAst(_modifiedAst, true)
}
}
})
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 (
@ -352,9 +144,7 @@ export const Stream = ({ className = '' }) => {
onContextMenuCapture={(e) => e.preventDefault()}
onWheel={handleScroll}
onPlay={() => setIsLoading(false)}
onMouseMoveCapture={handleMouseMove}
className={`w-full h-full ${isExecuting && 'blur-md'}`}
style={{ transitionDuration: '200ms', transitionProperty: 'filter' }}
className="w-full h-full"
/>
{isLoading && (
<div className="text-center absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">

View File

@ -1,296 +0,0 @@
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, roundOff } from 'lib/utils'
import { kclErrToDiagnostic } from 'lang/errors'
import { CSSRuleObject } from 'tailwindcss/types/config'
import interact from '@replit/codemirror-interact'
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,
deferredSetCode,
editorView,
engineCommandManager,
formatCode,
isLSPServerReady,
selectionRanges,
selectionRangeTypeMap,
setEditorView,
setIsLSPServerReady,
setSelectionRanges,
} = useStore((s) => ({
code: s.code,
deferredSetCode: s.deferredSetCode,
editorView: s.editorView,
engineCommandManager: s.engineCommandManager,
formatCode: s.formatCode,
isLSPServerReady: s.isLSPServerReady,
selectionRanges: s.selectionRanges,
selectionRangeTypeMap: s.selectionRangeTypeMap,
setEditorView: s.setEditorView,
setIsLSPServerReady: s.setIsLSPServerReady,
setSelectionRanges: s.setSelectionRanges,
}))
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) => {
deferredSetCode(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(
engineCommandManager?.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)
}),
interact({
rules: [
// a rule for a number dragger
{
// the regexp matching the value
regexp: /-?\b\d+\.?\d*\b/g,
// set cursor to "ew-resize" on hover
cursor: 'ew-resize',
// change number value based on mouse X movement on drag
onDrag: (text, setText, e) => {
const multiplier =
e.shiftKey && e.metaKey
? 0.01
: e.metaKey
? 0.1
: e.shiftKey
? 10
: 1
const delta = e.movementX * multiplier
const newVal = roundOff(
Number(text) + delta,
multiplier === 0.01 ? 2 : multiplier === 0.1 ? 1 : 0
)
if (isNaN(newVal)) return
setText(newVal.toString())
},
},
],
})
)
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>
)
}

View File

@ -0,0 +1,61 @@
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>
)
}

View File

@ -82,7 +82,7 @@ export const EqualAngle = () => {
transformInfos,
programMemory,
})
updateAst(modifiedAst, true, {
updateAst(modifiedAst, {
callBack: updateCursors(setCursor, selectionRanges, pathToNodeMap),
})
}}

View File

@ -82,7 +82,7 @@ export const EqualLength = () => {
transformInfos,
programMemory,
})
updateAst(modifiedAst, true, {
updateAst(modifiedAst, {
callBack: updateCursors(setCursor, selectionRanges, pathToNodeMap),
})
}}

View File

@ -61,7 +61,7 @@ export const HorzVert = ({
programMemory,
referenceSegName: '',
})
updateAst(modifiedAst, true, {
updateAst(modifiedAst, {
callBack: updateCursors(setCursor, selectionRanges, pathToNodeMap),
})
}}

View File

@ -154,7 +154,7 @@ export const Intersect = () => {
initialVariableName: 'offset',
} as any)
if (segName === tagInfo?.tag && value === valueUsedInTransform) {
updateAst(modifiedAst, true, {
updateAst(modifiedAst, {
callBack: updateCursors(setCursor, selectionRanges, pathToNodeMap),
})
} else {
@ -182,7 +182,7 @@ export const Intersect = () => {
)
_modifiedAst.body = newBody
}
updateAst(_modifiedAst, true, {
updateAst(_modifiedAst, {
callBack: updateCursors(setCursor, selectionRanges, pathToNodeMap),
})
}

View File

@ -65,7 +65,7 @@ export const RemoveConstrainingValues = () => {
programMemory,
referenceSegName: '',
})
updateAst(modifiedAst, true, {
updateAst(modifiedAst, {
callBack: updateCursors(setCursor, selectionRanges, pathToNodeMap),
})
}}

View File

@ -124,7 +124,7 @@ export const SetAbsDistance = ({
_modifiedAst.body = newBody
}
updateAst(_modifiedAst, true, {
updateAst(_modifiedAst, {
callBack: updateCursors(setCursor, selectionRanges, pathToNodeMap),
})
} catch (e) {

View File

@ -113,7 +113,7 @@ export const SetAngleBetween = () => {
initialVariableName: 'angle',
} as any)
if (segName === tagInfo?.tag && value === valueUsedInTransform) {
updateAst(modifiedAst, true, {
updateAst(modifiedAst, {
callBack: updateCursors(setCursor, selectionRanges, pathToNodeMap),
})
} else {
@ -141,7 +141,7 @@ export const SetAngleBetween = () => {
)
_modifiedAst.body = newBody
}
updateAst(_modifiedAst, true, {
updateAst(_modifiedAst, {
callBack: updateCursors(setCursor, selectionRanges, pathToNodeMap),
})
}

View File

@ -137,7 +137,7 @@ export const SetHorzVertDistance = ({
constraint === 'setHorzDistance' ? 'xDis' : 'yDis',
} as any))
if (segName === tagInfo?.tag && value === valueUsedInTransform) {
updateAst(modifiedAst, true, {
updateAst(modifiedAst, {
callBack: updateCursors(setCursor, selectionRanges, pathToNodeMap),
})
} else {
@ -163,7 +163,7 @@ export const SetHorzVertDistance = ({
)
_modifiedAst.body = newBody
}
updateAst(_modifiedAst, true, {
updateAst(_modifiedAst, {
callBack: updateCursors(setCursor, selectionRanges, pathToNodeMap),
})
}

View File

@ -136,7 +136,7 @@ export const SetAngleLength = ({
_modifiedAst.body = newBody
}
updateAst(_modifiedAst, true, {
updateAst(_modifiedAst, {
callBack: updateCursors(setCursor, selectionRanges, pathToNodeMap),
})
} catch (e) {

View File

@ -2,8 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import UserSidebarMenu from './UserSidebarMenu'
import { BrowserRouter } from 'react-router-dom'
import { Models } from '@kittycad/lib'
import { GlobalStateProvider } from './GlobalStateProvider'
import CommandBarProvider from './CommandBar'
import { GlobalStateProvider } from '../hooks/useAuthMachine'
type User = Models['User_type']
@ -95,9 +94,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
// wrap in router and xState context
return (
<BrowserRouter>
<CommandBarProvider>
<GlobalStateProvider>{children}</GlobalStateProvider>
</CommandBarProvider>
<GlobalStateProvider>{children}</GlobalStateProvider>
</BrowserRouter>
)
}

View File

@ -1,13 +1,13 @@
import { Popover, Transition } from '@headlessui/react'
import { Popover } from '@headlessui/react'
import { ActionButton } from './ActionButton'
import { faBars, faGear, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
import { faGithub } from '@fortawesome/free-brands-svg-icons'
import { useNavigate } from 'react-router-dom'
import { Fragment, useState } from 'react'
import { useState } from 'react'
import { paths } from '../Router'
import makeUrlPathRelative from '../lib/makeUrlPathRelative'
import { useAuthMachine } from '../hooks/useAuthMachine'
import { Models } from '@kittycad/lib'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
type User = Models['User_type']
@ -15,9 +15,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
const displayedName = getDisplayName(user)
const [imageLoadFailed, setImageLoadFailed] = useState(false)
const navigate = useNavigate()
const {
auth: { send },
} = useGlobalStateContext()
const [_, send] = useAuthMachine()
// Fallback logic for displaying user's "name":
// 1. user.name
@ -61,102 +59,82 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
Menu
</ActionButton>
)}
<Transition
enter="duration-200 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="duration-100 ease-in"
leaveFrom="opacity-100"
leaveTo="opacity-0"
as={Fragment}
>
<Popover.Overlay className="fixed z-20 inset-0 bg-chalkboard-110/50" />
</Transition>
<Popover.Overlay className="fixed z-20 inset-0 bg-chalkboard-110/50" />
<Transition
enter="duration-100 ease-out"
enterFrom="opacity-0 translate-x-1/4"
enterTo="opacity-100 translate-x-0"
leave="duration-75 ease-in"
leaveFrom="opacity-100 translate-x-0"
leaveTo="opacity-0 translate-x-4"
as={Fragment}
>
<Popover.Panel className="fixed inset-0 left-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-liquid-100 dark:border-liquid-100/50 shadow-md rounded-l-lg overflow-hidden">
{({ close }) => (
<>
{user && (
<div className="flex items-center gap-4 px-4 py-3 bg-liquid-100">
{user.image && !imageLoadFailed && (
<div className="rounded-full shadow-inner overflow-hidden">
<img
src={user.image}
alt={user.name || ''}
className="h-8 w-8"
referrerPolicy="no-referrer"
onError={() => setImageLoadFailed(true)}
/>
</div>
)}
<div>
<p
className="m-0 text-liquid-10 text-mono"
data-testid="username"
>
{displayedName || ''}
</p>
{displayedName !== user.email && (
<p
className="m-0 text-liquid-40 text-xs"
data-testid="email"
>
{user.email}
</p>
)}
<Popover.Panel className="fixed inset-0 left-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-liquid-100 shadow-md rounded-l-lg overflow-hidden">
{({ close }) => (
<>
{user && (
<div className="flex items-center gap-4 px-4 py-3 bg-liquid-100">
{user.image && !imageLoadFailed && (
<div className="rounded-full shadow-inner overflow-hidden">
<img
src={user.image}
alt={user.name || ''}
className="h-8 w-8"
referrerPolicy="no-referrer"
onError={() => setImageLoadFailed(true)}
/>
</div>
)}
<div>
<p
className="m-0 text-liquid-10 text-mono"
data-testid="username"
>
{displayedName || ''}
</p>
{displayedName !== user.email && (
<p
className="m-0 text-liquid-40 text-xs"
data-testid="email"
>
{user.email}
</p>
)}
</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>
</>
)}
</Popover.Panel>
</Transition>
)}
<div className="p-4 flex flex-col gap-2">
<ActionButton
Element="button"
icon={{ icon: faGear }}
className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
onClick={() => {
// since /settings is a nested route the sidebar doesn't close
// automatically when navigating to it
close()
navigate(makeUrlPathRelative(paths.SETTINGS))
}}
>
Settings
</ActionButton>
<ActionButton
Element="link"
to="https://github.com/KittyCAD/modeling-app/discussions"
icon={{ icon: faGithub }}
className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
>
Request a feature
</ActionButton>
<ActionButton
Element="button"
onClick={() => send('logout')}
icon={{
icon: faSignOutAlt,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="border-transparent dark:border-transparent hover:border-destroy-40 dark:hover:border-destroy-60"
>
Sign out
</ActionButton>
</div>
</>
)}
</Popover.Panel>
</Popover>
)
}

View File

@ -1,185 +0,0 @@
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)
}
}

View File

@ -1,53 +0,0 @@
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()
}
}

View File

@ -1,27 +0,0 @@
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
}
}

View File

@ -1,82 +0,0 @@
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
}
}
}
}
}

View File

@ -1,9 +0,0 @@
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*/, '')
}
}

View File

@ -1,72 +0,0 @@
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' }
}

View File

@ -1,113 +0,0 @@
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()
}
}

View File

@ -1,151 +0,0 @@
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)
}
}

View File

@ -1,36 +0,0 @@
// 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])
}

View File

@ -1,168 +0,0 @@
// 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
}
}

View File

@ -1,360 +0,0 @@
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)
}
}

View File

@ -1,51 +0,0 @@
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
}

View File

@ -1,80 +0,0 @@
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 }

View File

@ -1,42 +0,0 @@
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)
}
}

View File

@ -1,21 +0,0 @@
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)
}
}
}

View File

@ -8,7 +8,4 @@ export const VITE_KC_API_WS_MODELING_URL = import.meta.env
.VITE_KC_API_WS_MODELING_URL
export const VITE_KC_API_BASE_URL = import.meta.env.VITE_KC_API_BASE_URL
export const VITE_KC_SITE_BASE_URL = import.meta.env.VITE_KC_SITE_BASE_URL
export const VITE_KC_CONNECTION_TIMEOUT_MS = import.meta.env
.VITE_KC_CONNECTION_TIMEOUT_MS
export const VITE_KC_SENTRY_DSN = import.meta.env.VITE_KC_SENTRY_DSN
export const TEST = import.meta.env.TEST

View File

@ -1,239 +0,0 @@
// needed somewhere to dump this logic,
// Once we have xState this should be removed
import { useStore, Selections } from 'useStore'
import { useEffect, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { ArtifactMap, EngineCommandManager } from 'lang/std/engineConnection'
import { Models } from '@kittycad/lib/dist/types/src'
import { isReducedMotion } from 'lang/util'
import { isOverlap } from 'lib/utils'
interface DefaultPlanes {
xy: string
// TODO re-enable
// yz: string
// xz: string
}
export function useAppMode() {
const {
guiMode,
setGuiMode,
selectionRanges,
engineCommandManager,
selectionRangeTypeMap,
} = useStore((s) => ({
guiMode: s.guiMode,
setGuiMode: s.setGuiMode,
selectionRanges: s.selectionRanges,
engineCommandManager: s.engineCommandManager,
selectionRangeTypeMap: s.selectionRangeTypeMap,
}))
const [defaultPlanes, setDefaultPlanes] = useState<DefaultPlanes | null>(null)
useEffect(() => {
if (
guiMode.mode === 'sketch' &&
guiMode.sketchMode === 'selectFace' &&
engineCommandManager
) {
if (!defaultPlanes) {
const xy = createPlane(engineCommandManager, {
x_axis: { x: 1, y: 0, z: 0 },
y_axis: { x: 0, y: 1, z: 0 },
color: { r: 0.7, g: 0.28, b: 0.28, a: 0.4 },
})
// TODO re-enable
// const yz = createPlane(engineCommandManager, {
// x_axis: { x: 0, y: 1, z: 0 },
// y_axis: { x: 0, y: 0, z: 1 },
// color: { r: 0.28, g: 0.7, b: 0.28, a: 0.4 },
// })
// const xz = createPlane(engineCommandManager, {
// x_axis: { x: 1, y: 0, z: 0 },
// y_axis: { x: 0, y: 0, z: 1 },
// color: { r: 0.28, g: 0.28, b: 0.7, a: 0.4 },
// })
setDefaultPlanes({ xy })
} else {
setDefaultPlanesHidden(engineCommandManager, defaultPlanes, false)
}
}
if (guiMode.mode !== 'sketch' && defaultPlanes) {
setDefaultPlanesHidden(engineCommandManager, defaultPlanes, true)
}
if (guiMode.mode === 'default') {
const pathId =
engineCommandManager &&
isCursorInSketchCommandRange(
engineCommandManager.artifactMap,
selectionRanges
)
if (pathId) {
setGuiMode({
mode: 'canEditSketch',
rotation: [0, 0, 0, 1],
position: [0, 0, 0],
pathToNode: [],
pathId,
})
}
} else if (guiMode.mode === 'canEditSketch') {
if (
!engineCommandManager ||
!isCursorInSketchCommandRange(
engineCommandManager.artifactMap,
selectionRanges
)
) {
setGuiMode({
mode: 'default',
})
}
}
}, [
guiMode,
guiMode.mode,
engineCommandManager,
selectionRanges,
selectionRangeTypeMap,
])
useEffect(() => {
const unSub = engineCommandManager?.subscribeTo({
event: 'select_with_point',
callback: async ({ data }) => {
if (!data.entity_id) return
if (!defaultPlanes) return
if (!Object.values(defaultPlanes || {}).includes(data.entity_id)) {
// user clicked something else in the scene
return
}
const sketchModeResponse = await engineCommandManager?.sendSceneCommand(
{
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'sketch_mode_enable',
plane_id: data.entity_id,
ortho: true,
animated: !isReducedMotion(),
},
}
)
setDefaultPlanesHidden(engineCommandManager, defaultPlanes, true)
const sketchUuid = uuidv4()
const proms: any[] = []
proms.push(
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: sketchUuid,
cmd: {
type: 'start_path',
},
})
)
proms.push(
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'edit_mode_enter',
target: sketchUuid,
},
})
)
const res = await Promise.all(proms)
console.log('res', res)
setGuiMode({
mode: 'sketch',
sketchMode: 'sketchEdit',
rotation: [0, 0, 0, 1],
position: [0, 0, 0],
pathToNode: [],
})
console.log('sketchModeResponse', sketchModeResponse)
},
})
return unSub
}, [engineCommandManager, defaultPlanes])
}
function createPlane(
engineCommandManager: EngineCommandManager,
{
x_axis,
y_axis,
color,
}: {
x_axis: Models['Point3d_type']
y_axis: Models['Point3d_type']
color: Models['Color_type']
}
) {
const planeId = uuidv4()
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'make_plane',
size: 60,
origin: { x: 0, y: 0, z: 0 },
x_axis,
y_axis,
clobber: false,
},
cmd_id: planeId,
})
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'plane_set_color',
plane_id: planeId,
color,
},
cmd_id: uuidv4(),
})
return planeId
}
function setDefaultPlanesHidden(
engineCommandManager: EngineCommandManager | undefined,
defaultPlanes: DefaultPlanes,
hidden: boolean
) {
Object.values(defaultPlanes).forEach((planeId) => {
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'object_visible',
object_id: planeId,
hidden: hidden,
},
})
})
}
function isCursorInSketchCommandRange(
artifactMap: ArtifactMap,
selectionRanges: Selections
): string | false {
const overlapingEntries = Object.entries(artifactMap || {}).filter(
([id, artifact]) =>
selectionRanges.codeBasedSelections.some(
(selection) =>
Array.isArray(selection?.range) &&
Array.isArray(artifact?.range) &&
isOverlap(selection.range, artifact.range) &&
(artifact.commandType === 'start_path' ||
artifact.commandType === 'extend_path' ||
artifact.commandType === 'close_path')
)
)
return overlapingEntries.length && overlapingEntries[0][1].parentId
? overlapingEntries[0][1].parentId
: overlapingEntries.find(
([, artifact]) => artifact.commandType === 'start_path'
)?.[0] || false
}

View File

@ -0,0 +1,54 @@
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',
})
}

View File

@ -1,6 +0,0 @@
import { CommandsContext } from 'components/CommandBar'
import { useContext } from 'react'
export const useCommandsContext = () => {
return useContext(CommandsContext)
}

View File

@ -1,50 +0,0 @@
import { useEffect } from 'react'
import { useStore } from 'useStore'
export function useEngineConnectionSubscriptions() {
const {
engineCommandManager,
setCursor2,
setHighlightRange,
highlightRange,
} = useStore((s) => ({
engineCommandManager: s.engineCommandManager,
setCursor2: s.setCursor2,
setHighlightRange: s.setHighlightRange,
highlightRange: s.highlightRange,
}))
useEffect(() => {
if (!engineCommandManager) return
const unSubHover = engineCommandManager.subscribeToUnreliable({
event: 'highlight_set_entity',
callback: ({ data }) => {
if (data?.entity_id) {
const sourceRange =
engineCommandManager.sourceRangeMap[data.entity_id]
setHighlightRange(sourceRange)
} else if (
!highlightRange ||
(highlightRange[0] !== 0 && highlightRange[1] !== 0)
) {
setHighlightRange([0, 0])
}
},
})
const unSubClick = engineCommandManager.subscribeTo({
event: 'select_with_point',
callback: ({ data }) => {
if (!data?.entity_id) {
setCursor2()
return
}
const sourceRange = engineCommandManager.sourceRangeMap[data.entity_id]
setCursor2({ range: sourceRange, type: 'default' })
},
})
return () => {
unSubHover()
unSubClick()
}
}, [engineCommandManager, setCursor2, setHighlightRange, highlightRange])
}

View File

@ -1,6 +0,0 @@
import { GlobalStateContext } from 'components/GlobalStateProvider'
import { useContext } from 'react'
export const useGlobalStateContext = () => {
return useContext(GlobalStateContext)
}

View File

@ -1,53 +0,0 @@
import { useLayoutEffect } from 'react'
import { _executor } from '../lang/executor'
import { useStore } from '../useStore'
import { EngineCommandManager } from '../lang/std/engineConnection'
export function useSetupEngineManager(
streamRef: React.RefObject<HTMLDivElement>,
token?: string
) {
const {
setEngineCommandManager,
setMediaStream,
setIsStreamReady,
setStreamDimensions,
executeCode,
} = useStore((s) => ({
setEngineCommandManager: s.setEngineCommandManager,
setMediaStream: s.setMediaStream,
setIsStreamReady: s.setIsStreamReady,
setStreamDimensions: s.setStreamDimensions,
executeCode: s.executeCode,
}))
const streamWidth = streamRef?.current?.offsetWidth
const streamHeight = streamRef?.current?.offsetHeight
const width = streamWidth ? streamWidth : 0
const quadWidth = Math.round(width / 4) * 4
const height = streamHeight ? streamHeight : 0
const quadHeight = Math.round(height / 4) * 4
useLayoutEffect(() => {
setStreamDimensions({
streamWidth: quadWidth,
streamHeight: quadHeight,
})
if (!width || !height) return
const eng = new EngineCommandManager({
setMediaStream,
setIsStreamReady,
width: quadWidth,
height: quadHeight,
token,
})
setEngineCommandManager(eng)
eng.waitForReady.then(() => {
executeCode()
})
return () => {
eng?.tearDown()
}
}, [quadWidth, quadHeight])
}

View File

@ -1,42 +0,0 @@
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])
}

67
src/hooks/useTauriBoot.ts Normal file
View File

@ -0,0 +1,67 @@
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()
}, [])
}

View File

@ -1,56 +0,0 @@
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, true)
} catch (e) {
console.log('e', e)
}
}
return { enable, handleClick }
}

View File

@ -82,36 +82,12 @@ code {
monospace;
}
.full-height-subtract {
--height-subtract: 2.25rem;
height: 100%;
max-height: calc(100% - var(--height-subtract));
}
#code-mirror-override .cm-editor {
@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;
@apply bg-transparent;
}
#code-mirror-override .cm-gutters {
@apply bg-chalkboard-10/30;
@apply bg-chalkboard-10/50;
}
.dark #code-mirror-override .cm-gutters {
@ -123,68 +99,16 @@ code {
}
#code-mirror-override .cm-cursor {
display: block;
width: 1ch;
@apply bg-liquid-40 mix-blend-multiply;
animation: blink 2s ease-out infinite;
}
.dark #code-mirror-override .cm-cursor {
@apply bg-liquid-50;
}
@keyframes blink {
0%,
100% {
opacity: 0;
}
15% {
opacity: 0.75;
}
width: 200px;
background: linear-gradient(
to right,
rgb(0, 55, 94) 0%,
#0084e2ff 2%,
#0084e255 5%,
transparent 100%
);
}
.react-json-view {
@apply bg-transparent !important;
}
#code-mirror-override .cm-tooltip {
@apply text-xs shadow-md;
@apply bg-chalkboard-10 text-chalkboard-80;
@apply rounded-sm border-solid border border-chalkboard-40/30 border-l-liquid-10;
}
.dark #code-mirror-override .cm-tooltip {
@apply bg-chalkboard-110 text-chalkboard-40;
@apply border-chalkboard-70/20 border-l-liquid-70;
}
#code-mirror-override .cm-tooltip-hover {
@apply py-1 px-2 w-max max-w-md;
}
#code-mirror-override .cm-completionInfo {
@apply px-4 rounded-l-none;
@apply bg-chalkboard-10 text-liquid-90;
@apply border-liquid-40/30;
}
.dark #code-mirror-override .cm-completionInfo {
@apply bg-liquid-120 text-liquid-50;
@apply border-liquid-90/60;
}
#code-mirror-override .cm-tooltip-autocomplete li {
@apply px-2 py-1;
}
#code-mirror-override .cm-tooltip-autocomplete li[aria-selected='true'] {
@apply bg-liquid-10 text-liquid-110;
}
.dark #code-mirror-override .cm-tooltip-autocomplete li[aria-selected='true'] {
@apply bg-liquid-100 text-liquid-20;
}
#code-mirror-override .cm-content {
white-space: pre-wrap;
word-break: normal;
word-wrap: break-word;
}

View File

@ -2,10 +2,23 @@ import ReactDOM from 'react-dom/client'
import './index.css'
import reportWebVitals from './reportWebVitals'
import { Toaster } from 'react-hot-toast'
import { Themes, useStore } from './useStore'
import { Router } from './Router'
import { HotkeysProvider } from 'react-hotkeys-hook'
import { getSystemTheme } from './lib/getSystemTheme'
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
function setThemeClass(state: Partial<{ theme: Themes }>) {
const systemTheme = state.theme === Themes.System && getSystemTheme()
if (state.theme === Themes.Dark || systemTheme === Themes.Dark) {
document.body.classList.add('dark')
} else {
document.body.classList.remove('dark')
}
}
const { theme } = useStore.getState()
setThemeClass({ theme })
useStore.subscribe(setThemeClass)
root.render(
<HotkeysProvider>

View File

@ -1,5 +1,5 @@
import { parser_wasm } from './abstractSyntaxTree'
import { KCLError } from './errors'
import { KCLUnexpectedError } from './errors'
import { initPromise } from './rust'
beforeAll(() => initPromise)
@ -179,9 +179,6 @@ const newVar = myVar + 1
name: 'aIdentifier',
},
],
function: {
type: 'InMemory',
},
optional: false,
},
},
@ -214,8 +211,10 @@ describe('testing function declaration', () => {
type: 'FunctionExpression',
start: 11,
end: 19,
id: null,
params: [],
body: {
type: 'BlockStatement',
start: 17,
end: 19,
body: [],
@ -252,6 +251,7 @@ describe('testing function declaration', () => {
type: 'FunctionExpression',
start: 11,
end: 39,
id: null,
params: [
{
type: 'Identifier',
@ -267,6 +267,7 @@ describe('testing function declaration', () => {
},
],
body: {
type: 'BlockStatement',
start: 21,
end: 39,
body: [
@ -327,6 +328,7 @@ const myVar = funcN(1, 2)`
type: 'FunctionExpression',
start: 11,
end: 37,
id: null,
params: [
{
type: 'Identifier',
@ -342,6 +344,7 @@ const myVar = funcN(1, 2)`
},
],
body: {
type: 'BlockStatement',
start: 21,
end: 37,
body: [
@ -416,9 +419,6 @@ const myVar = funcN(1, 2)`
raw: '2',
},
],
function: {
type: 'InMemory',
},
optional: false,
},
},
@ -488,7 +488,6 @@ describe('testing pipe operator special', () => {
],
},
],
function: expect.any(Object),
optional: false,
},
{
@ -525,7 +524,6 @@ describe('testing pipe operator special', () => {
},
{ type: 'PipeSubstitution', start: 59, end: 60 },
],
function: expect.any(Object),
optional: false,
},
{
@ -598,7 +596,6 @@ describe('testing pipe operator special', () => {
},
{ type: 'PipeSubstitution', start: 105, end: 106 },
],
function: expect.any(Object),
optional: false,
},
{
@ -635,7 +632,6 @@ describe('testing pipe operator special', () => {
},
{ type: 'PipeSubstitution', start: 128, end: 129 },
],
function: expect.any(Object),
optional: false,
},
{
@ -658,9 +654,6 @@ describe('testing pipe operator special', () => {
},
{ type: 'PipeSubstitution', start: 143, end: 144 },
],
function: {
type: 'InMemory',
},
optional: false,
},
],
@ -740,9 +733,6 @@ describe('testing pipe operator special', () => {
end: 35,
},
],
function: {
type: 'InMemory',
},
optional: false,
},
],
@ -1563,10 +1553,7 @@ const key = 'c'`
type: 'NoneCodeNode',
start: code.indexOf('\n// this is a comment'),
end: code.indexOf('const key'),
value: {
type: 'blockComment',
value: 'this is a comment',
},
value: '\n// this is a comment\n',
}
const { nonCodeMeta } = parser_wasm(code)
expect(nonCodeMeta.noneCodeNodes[0]).toEqual(nonCodeMetaInstance)
@ -1576,9 +1563,7 @@ const key = 'c'`
const { nonCodeMeta: nonCodeMeta2 } = parser_wasm(
codeWithExtraStartWhitespace
)
expect(nonCodeMeta2.noneCodeNodes[0].value).toStrictEqual(
nonCodeMetaInstance.value
)
expect(nonCodeMeta2.noneCodeNodes[0].value).toBe(nonCodeMetaInstance.value)
expect(nonCodeMeta2.noneCodeNodes[0].start).not.toBe(
nonCodeMetaInstance.start
)
@ -1586,8 +1571,8 @@ const key = 'c'`
it('comments nested within a block statement', () => {
const code = `const mySketch = startSketchAt([0,0])
|> lineTo({ to: [0, 1], tag: 'myPath' }, %)
|> lineTo([1, 1], %) /* this is
a comment
|> lineTo([1, 1], %) /* this is
a comment
spanning a few lines */
|> lineTo({ to: [1,0], tag: "rightPath" }, %)
|> close(%)
@ -1600,11 +1585,9 @@ const key = 'c'`
expect(sketchNonCodeMeta[indexOfSecondLineToExpression]).toEqual({
type: 'NoneCodeNode',
start: 106,
end: 166,
value: {
type: 'blockComment',
value: 'this is\n a comment\n spanning a few lines',
},
end: 168,
value:
' /* this is \n a comment \n spanning a few lines */\n ',
})
})
it('comments in a pipe expression', () => {
@ -1624,10 +1607,7 @@ const key = 'c'`
type: 'NoneCodeNode',
start: 125,
end: 141,
value: {
type: 'blockComment',
value: 'a comment',
},
value: '\n// a comment\n ',
})
})
})
@ -1651,7 +1631,6 @@ describe('test UnaryExpression', () => {
{ type: 'Literal', start: 19, end: 20, value: 4, raw: '4' },
{ type: 'Literal', start: 22, end: 25, value: 100, raw: '100' },
],
function: expect.any(Object),
optional: false,
},
})
@ -1685,12 +1664,10 @@ describe('testing nested call expressions', () => {
{ type: 'Literal', start: 34, end: 35, value: 5, raw: '5' },
{ type: 'Literal', start: 37, end: 38, value: 3, raw: '3' },
],
function: expect.any(Object),
optional: false,
},
},
],
function: expect.any(Object),
optional: false,
})
})
@ -1722,7 +1699,6 @@ describe('should recognise callExpresions in binaryExpressions', () => {
},
{ type: 'PipeSubstitution', start: 25, end: 26 },
],
function: expect.any(Object),
optional: false,
},
right: { type: 'Literal', value: 1, raw: '1', start: 30, end: 31 },
@ -1744,12 +1720,6 @@ describe('parsing errors', () => {
_theError = e
}
const theError = _theError as any
expect(theError).toEqual(
new KCLError(
'unexpected',
'Unexpected token Token { token_type: Brace, start: 29, end: 30, value: "}" }',
[[29, 30]]
)
)
expect(theError).toEqual(new KCLUnexpectedError('Brace', [[29, 30]]))
})
})

View File

@ -3,9 +3,9 @@ import { parse_js } from '../wasm-lib/pkg/wasm_lib'
import { initPromise } from './rust'
import { Token } from './tokeniser'
import { KCLError } from './errors'
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
import { KclError as RustKclError } from '../wasm-lib/bindings/KclError'
export const rangeTypeFix = (ranges: number[][]): [number, number][] =>
const rangeTypeFix = (ranges: number[][]): [number, number][] =>
ranges.map(([start, end]) => [start, end])
export const parser_wasm = (code: string): Program => {
@ -16,8 +16,10 @@ export const parser_wasm = (code: string): Program => {
const parsed: RustKclError = JSON.parse(e.toString())
const kclError = new KCLError(
parsed.kind,
parsed.msg,
rangeTypeFix(parsed.sourceRanges)
parsed.kind === 'invalid_expression' ? parsed.kind : parsed.msg,
parsed.kind === 'invalid_expression'
? [[parsed.start, parsed.end]]
: rangeTypeFix(parsed.sourceRanges)
)
console.log(kclError)
@ -34,8 +36,10 @@ export async function asyncParser(code: string): Promise<Program> {
const parsed: RustKclError = JSON.parse(e.toString())
const kclError = new KCLError(
parsed.kind,
parsed.msg,
rangeTypeFix(parsed.sourceRanges)
parsed.kind === 'invalid_expression' ? parsed.kind : parsed.msg,
parsed.kind === 'invalid_expression'
? [[parsed.start, parsed.end]]
: rangeTypeFix(parsed.sourceRanges)
)
console.log(kclError)

View File

@ -1,20 +1,20 @@
export type { Program } from '../wasm-lib/kcl/bindings/Program'
export type { Value } from '../wasm-lib/kcl/bindings/Value'
export type { ObjectExpression } from '../wasm-lib/kcl/bindings/ObjectExpression'
export type { MemberExpression } from '../wasm-lib/kcl/bindings/MemberExpression'
export type { PipeExpression } from '../wasm-lib/kcl/bindings/PipeExpression'
export type { VariableDeclaration } from '../wasm-lib/kcl/bindings/VariableDeclaration'
export type { PipeSubstitution } from '../wasm-lib/kcl/bindings/PipeSubstitution'
export type { Identifier } from '../wasm-lib/kcl/bindings/Identifier'
export type { UnaryExpression } from '../wasm-lib/kcl/bindings/UnaryExpression'
export type { BinaryExpression } from '../wasm-lib/kcl/bindings/BinaryExpression'
export type { ReturnStatement } from '../wasm-lib/kcl/bindings/ReturnStatement'
export type { ExpressionStatement } from '../wasm-lib/kcl/bindings/ExpressionStatement'
export type { CallExpression } from '../wasm-lib/kcl/bindings/CallExpression'
export type { VariableDeclarator } from '../wasm-lib/kcl/bindings/VariableDeclarator'
export type { BinaryPart } from '../wasm-lib/kcl/bindings/BinaryPart'
export type { Literal } from '../wasm-lib/kcl/bindings/Literal'
export type { ArrayExpression } from '../wasm-lib/kcl/bindings/ArrayExpression'
export type { Program } from '../wasm-lib/bindings/Program'
export type { Value } from '../wasm-lib/bindings/Value'
export type { ObjectExpression } from '../wasm-lib/bindings/ObjectExpression'
export type { MemberExpression } from '../wasm-lib/bindings/MemberExpression'
export type { PipeExpression } from '../wasm-lib/bindings/PipeExpression'
export type { VariableDeclaration } from '../wasm-lib/bindings/VariableDeclaration'
export type { PipeSubstitution } from '../wasm-lib/bindings/PipeSubstitution'
export type { Identifier } from '../wasm-lib/bindings/Identifier'
export type { UnaryExpression } from '../wasm-lib/bindings/UnaryExpression'
export type { BinaryExpression } from '../wasm-lib/bindings/BinaryExpression'
export type { ReturnStatement } from '../wasm-lib/bindings/ReturnStatement'
export type { ExpressionStatement } from '../wasm-lib/bindings/ExpressionStatement'
export type { CallExpression } from '../wasm-lib/bindings/CallExpression'
export type { VariableDeclarator } from '../wasm-lib/bindings/VariableDeclarator'
export type { BinaryPart } from '../wasm-lib/bindings/BinaryPart'
export type { Literal } from '../wasm-lib/bindings/Literal'
export type { ArrayExpression } from '../wasm-lib/bindings/ArrayExpression'
export type SyntaxType =
| 'Program'
@ -22,6 +22,7 @@ export type SyntaxType =
| 'BinaryExpression'
| 'CallExpression'
| 'Identifier'
| 'BlockStatement'
| 'ReturnStatement'
| 'VariableDeclaration'
| 'VariableDeclarator'

View File

@ -11,52 +11,51 @@ describe('testing artifacts', () => {
const mySketch001 = startSketchAt([0, 0])
|> lineTo([-1.59, -1.54], %)
|> lineTo([0.46, -5.82], %)
// |> rx(45, %)
// |> rx(45, %)
show(mySketch001)`
const programMemory = await enginelessExecutor(parser_wasm(code))
// @ts-ignore
const shown = programMemory?.return?.map(
// @ts-ignore
(a) => programMemory?.root?.[a.name]
)
expect(shown).toEqual([
{
type: 'SketchGroup',
type: 'sketchGroup',
start: {
type: 'base',
to: [0, 0],
from: [0, 0],
name: '',
__geoMeta: {
id: expect.any(String),
id: '66366561-6465-4734-a463-366330356563',
sourceRange: [21, 42],
pathToNode: [],
},
},
value: [
{
type: 'toPoint',
name: '',
to: [-1.59, -1.54],
from: [0, 0],
__geoMeta: {
sourceRange: [48, 73],
id: expect.any(String),
id: '30366338-6462-4330-a364-303935626163',
pathToNode: [],
},
},
{
type: 'toPoint',
to: [0.46, -5.82],
from: [-1.59, -1.54],
name: '',
__geoMeta: {
sourceRange: [79, 103],
id: expect.any(String),
id: '32653334-6331-4231-b162-663334363535',
pathToNode: [],
},
},
],
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
id: expect.any(String),
__meta: [{ sourceRange: [21, 42] }],
id: '39643164-6130-4734-b432-623638393262',
__meta: [{ sourceRange: [21, 42], pathToNode: [] }],
},
])
})
@ -70,20 +69,21 @@ const mySketch001 = startSketchAt([0, 0])
|> extrude(2, %)
show(mySketch001)`
const programMemory = await enginelessExecutor(parser_wasm(code))
// @ts-ignore
const shown = programMemory?.return?.map(
// @ts-ignore
(a) => programMemory?.root?.[a.name]
)
expect(shown).toEqual([
{
type: 'ExtrudeGroup',
id: expect.any(String),
type: 'extrudeGroup',
id: '65383433-3839-4333-b836-343263636638',
value: [],
height: 2,
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
__meta: [{ sourceRange: [21, 42] }],
__meta: [
{ sourceRange: [127, 140], pathToNode: [] },
{ sourceRange: [21, 42], pathToNode: [] },
],
},
])
})
@ -106,33 +106,37 @@ const sk2 = startSketchAt([0, 0])
|> lineTo([2.5, 0], %)
// |> transform(theTransf, %)
|> extrude(2, %)
show(theExtrude, sk2)`
const programMemory = await enginelessExecutor(parser_wasm(code))
// @ts-ignore
const geos = programMemory?.return?.map(
// @ts-ignore
({ name }) => programMemory?.root?.[name]
)
expect(geos).toEqual([
{
type: 'ExtrudeGroup',
id: expect.any(String),
type: 'extrudeGroup',
id: '63333330-3631-4230-b664-623132643731',
value: [],
height: 2,
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
__meta: [{ sourceRange: [13, 34] }],
__meta: [
{ sourceRange: [212, 227], pathToNode: [] },
{ sourceRange: [13, 34], pathToNode: [] },
],
},
{
type: 'ExtrudeGroup',
id: expect.any(String),
type: 'extrudeGroup',
id: '33316639-3438-4661-a334-663262383737',
value: [],
height: 2,
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
__meta: [{ sourceRange: [302, 323] }],
__meta: [
{ sourceRange: [453, 466], pathToNode: [] },
{ sourceRange: [302, 323], pathToNode: [] },
],
},
])
})

View File

@ -1,5 +1,5 @@
import { Diagnostic } from '@codemirror/lint'
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
import { KclError as RustKclError } from '../wasm-lib/bindings/KclError'
type ExtractKind<T> = T extends { kind: infer K } ? K : never
export class KCLError {

View File

@ -1,11 +1,11 @@
import fs from 'node:fs'
import { parser_wasm } from './abstractSyntaxTree'
import { ProgramMemory, SketchGroup } from './executor'
import { ProgramMemory } from './executor'
import { initPromise } from './rust'
import { enginelessExecutor } from '../lib/testHelpers'
import { vi } from 'vitest'
import { KCLError } from './errors'
import { KCLUndefinedValueError } from './errors'
beforeAll(() => initPromise)
@ -30,6 +30,29 @@ const newVar = myVar + 1`
const { root } = await exe(code)
expect(root.myVar.value).toBe('a str another str')
})
it('test with function call', async () => {
const code = `
const myVar = "hello"
log(5, myVar)`
const programMemoryOverride: ProgramMemory['root'] = {
log: {
type: 'userVal',
value: vi.fn(),
__meta: [
{
sourceRange: [0, 0],
pathToNode: [],
},
],
},
}
const { root } = await enginelessExecutor(parser_wasm(code), {
root: programMemoryOverride,
pendingMemory: {},
})
expect(root.myVar.value).toBe('hello')
expect(programMemoryOverride.log.value).toHaveBeenCalledWith(5, 'hello')
})
it('fn funcN = () => {} execute', async () => {
const { root } = await exe(
[
@ -61,7 +84,8 @@ show(mySketch)
from: [0, 0],
__geoMeta: {
sourceRange: [43, 80],
id: expect.any(String),
id: '37333036-3033-4432-b530-643030303837',
pathToNode: [],
},
name: 'myPath',
},
@ -69,10 +93,10 @@ show(mySketch)
type: 'toPoint',
to: [2, 3],
from: [0, 2],
name: '',
__geoMeta: {
sourceRange: [86, 102],
id: expect.any(String),
id: '32343136-3330-4134-a462-376437386365',
pathToNode: [],
},
},
{
@ -81,7 +105,8 @@ show(mySketch)
from: [2, 3],
__geoMeta: {
sourceRange: [108, 151],
id: expect.any(String),
id: '32306132-6130-4138-b832-636363326330',
pathToNode: [],
},
name: 'rightPath',
},
@ -117,10 +142,10 @@ show(mySketch)
// ].join('\n')
// const { root } = await exe(code)
// expect(root.mySk1.value).toHaveLength(3)
// expect(root?.rotated?.type).toBe('SketchGroup')
// expect(root?.rotated?.type).toBe('sketchGroup')
// if (
// root?.mySk1?.type !== 'SketchGroup' ||
// root?.rotated?.type !== 'SketchGroup'
// root?.mySk1?.type !== 'sketchGroup' ||
// root?.rotated?.type !== 'sketchGroup'
// )
// throw new Error('not a sketch group')
// expect(root.mySk1.rotation).toEqual([0, 0, 0, 1])
@ -143,14 +168,15 @@ show(mySketch)
].join('\n')
const { root } = await exe(code)
expect(root.mySk1).toEqual({
type: 'SketchGroup',
type: 'sketchGroup',
start: {
type: 'base',
to: [0, 0],
from: [0, 0],
name: '',
__geoMeta: {
id: expect.any(String),
id: '37663863-3664-4366-a637-623739336334',
sourceRange: [14, 34],
pathToNode: [],
},
},
value: [
@ -158,10 +184,10 @@ show(mySketch)
type: 'toPoint',
to: [1, 1],
from: [0, 0],
name: '',
__geoMeta: {
sourceRange: [40, 56],
id: expect.any(String),
id: '34356231-3362-4363-b935-393033353034',
pathToNode: [],
},
},
{
@ -170,7 +196,8 @@ show(mySketch)
from: [1, 1],
__geoMeta: {
sourceRange: [62, 100],
id: expect.any(String),
id: '39623339-3538-4366-b633-356630326639',
pathToNode: [],
},
name: 'myPath',
},
@ -178,17 +205,17 @@ show(mySketch)
type: 'toPoint',
to: [1, 1],
from: [0, 1],
name: '',
__geoMeta: {
sourceRange: [106, 122],
id: expect.any(String),
id: '30636135-6232-4335-b665-366562303161',
pathToNode: [],
},
},
],
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
id: expect.any(String),
__meta: [{ sourceRange: [14, 34] }],
id: '30376661-3039-4965-b532-653665313731',
__meta: [{ sourceRange: [14, 34], pathToNode: [] }],
})
})
it('execute array expression', async () => {
@ -199,21 +226,45 @@ show(mySketch)
// TODO path to node is probably wrong here, zero indexes are not correct
expect(root).toEqual({
three: {
type: 'UserVal',
type: 'userVal',
value: 3,
__meta: [
{
pathToNode: [
['body', ''],
[0, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [14, 15],
},
],
},
yo: {
type: 'UserVal',
type: 'userVal',
value: [1, '2', 3, 9],
__meta: [
{
pathToNode: [
['body', ''],
[1, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [27, 49],
},
{
pathToNode: [
['body', ''],
[0, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [14, 15],
},
],
},
})
@ -225,10 +276,17 @@ show(mySketch)
].join('\n')
const { root } = await exe(code)
expect(root.yo).toEqual({
type: 'UserVal',
type: 'userVal',
value: { aStr: 'str', anum: 2, identifier: 3, binExp: 9 },
__meta: [
{
pathToNode: [
['body', ''],
[1, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [27, 83],
},
],
@ -240,10 +298,17 @@ show(mySketch)
)
const { root } = await exe(code)
expect(root.myVar).toEqual({
type: 'UserVal',
type: 'userVal',
value: '123',
__meta: [
{
pathToNode: [
['body', ''],
[1, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [41, 50],
},
],
@ -338,7 +403,7 @@ describe('testing math operators', () => {
const { root } = await exe(code)
const sketch = root.part001
// result of `-legLen(5, min(3, 999))` should be -4
const yVal = (sketch as SketchGroup).value?.[0]?.to?.[1]
const yVal = sketch.value?.[0]?.to?.[1]
expect(yVal).toBe(-4)
})
it('test that % substitution feeds down CallExp->ArrExp->UnaryExp->CallExp', async () => {
@ -356,8 +421,8 @@ describe('testing math operators', () => {
const { root } = await exe(code)
const sketch = root.part001
// expect -legLen(segLen('seg01', %), myVar) to equal -4 setting the y value back to 0
expect((sketch as SketchGroup).value?.[1]?.from).toEqual([3, 4])
expect((sketch as SketchGroup).value?.[1]?.to).toEqual([6, 0])
expect(sketch.value?.[1]?.from).toEqual([3, 4])
expect(sketch.value?.[1]?.to).toEqual([6, 0])
const removedUnaryExp = code.replace(
`-legLen(segLen('seg01', %), myVar)`,
`legLen(segLen('seg01', %), myVar)`
@ -366,9 +431,7 @@ describe('testing math operators', () => {
const removedUnaryExpRootSketch = removedUnaryExpRoot.part001
// without the minus sign, the y value should be 8
expect((removedUnaryExpRootSketch as SketchGroup).value?.[1]?.to).toEqual([
6, 8,
])
expect(removedUnaryExpRootSketch.value?.[1]?.to).toEqual([6, 8])
})
it('with nested callExpression and binaryExpression', async () => {
const code = 'const myVar = 2 + min(100, -1 + legLen(5, 3))'
@ -388,11 +451,7 @@ const theExtrude = startSketchAt([0, 0])
|> extrude(4, %)
show(theExtrude)`
await expect(exe(code)).rejects.toEqual(
new KCLError(
'undefined_value',
'memory item key `myVarZ` is not defined',
[[100, 106]]
)
new KCLUndefinedValueError('Memory item myVarZ not found', [[100, 106]])
)
})
})
@ -401,7 +460,7 @@ show(theExtrude)`
async function exe(
code: string,
programMemory: ProgramMemory = { root: {}, return: null }
programMemory: ProgramMemory = { root: {}, pendingMemory: {} }
) {
const ast = parser_wasm(code)

File diff suppressed because it is too large Load Diff

View File

@ -114,8 +114,7 @@ describe('Testing addSketchTo', () => {
expect(str).toBe(`const part001 = startSketchAt('default')
|> ry(90, %)
|> line('default', %)
show(part001)
`)
show(part001)`)
})
})
@ -176,24 +175,21 @@ show(part001)`
})
describe('Testing moveValueIntoNewVariable', () => {
const fn = (fnName: string) => `fn ${fnName} = (x) => {
const fn = (fnName: string) => `const ${fnName} = (x) => {
return x
}
`
const code = `${fn('def')}${fn('jkl')}${fn('hmm')}
fn ghi = (x) => {
return 2
}
const code = `${fn('def')}${fn('ghi')}${fn('jkl')}${fn('hmm')}
const abc = 3
const identifierGuy = 5
const yo = 5 + 6
const part001 = startSketchAt([-1.2, 4.83])
|> line([2.8, 0], %)
|> angledLine([100 + 100, 3.09], %)
|> angledLine([abc, 3.09], %)
|> angledLine([def(yo), 3.09], %)
|> angledLine([def('yo'), 3.09], %)
|> angledLine([ghi(%), 3.09], %)
|> angledLine([jkl(yo) + 2, 3.09], %)
|> angledLine([jkl('yo') + 2, 3.09], %)
const yo = 5 + 6
const yo2 = hmm([identifierGuy + 5])
show(part001)`
it('should move a binary expression into a new variable', async () => {
@ -235,7 +231,7 @@ show(part001)`
'newVar'
)
const newCode = recast(modifiedAst)
expect(newCode).toContain(`const newVar = def(yo)`)
expect(newCode).toContain(`const newVar = def('yo')`)
expect(newCode).toContain(`angledLine([newVar, 3.09], %)`)
})
it('should move a binary expression with call expression into a new variable', async () => {
@ -249,7 +245,7 @@ show(part001)`
'newVar'
)
const newCode = recast(modifiedAst)
expect(newCode).toContain(`const newVar = jkl(yo) + 2`)
expect(newCode).toContain(`const newVar = jkl('yo') + 2`)
expect(newCode).toContain(`angledLine([newVar, 3.09], %)`)
})
it('should move a identifier into a new variable', async () => {

View File

@ -1,4 +1,4 @@
import { Selection, ToolTip } from '../useStore'
import { Selection, TooTip } from '../useStore'
import {
Program,
CallExpression,
@ -27,48 +27,6 @@ import {
getFirstArg,
createFirstArg,
} from './std/sketch'
import { isLiteralArrayOrStatic } from './std/sketchcombos'
export function addStartSketch(
node: Program,
start: [number, number],
end: [number, number]
): { modifiedAst: Program; id: string; pathToNode: PathToNode } {
const _node = { ...node }
const _name = findUniqueName(node, 'part')
const startSketchAt = createCallExpression('startSketchAt', [
createArrayExpression([createLiteral(start[0]), createLiteral(start[1])]),
])
const initialLineTo = createCallExpression('line', [
createArrayExpression([createLiteral(end[0]), createLiteral(end[1])]),
createPipeSubstitution(),
])
const pipeBody = [startSketchAt, initialLineTo]
const variableDeclaration = createVariableDeclaration(
_name,
createPipeExpression(pipeBody)
)
const newIndex = node.body.length
_node.body = [...node.body, variableDeclaration]
let pathToNode: PathToNode = [
['body', ''],
[newIndex.toString(10), 'index'],
['declarations', 'VariableDeclaration'],
['0', 'index'],
['init', 'VariableDeclarator'],
]
return {
modifiedAst: _node,
id: _name,
pathToNode,
}
}
export function addSketchTo(
node: Program,
@ -78,14 +36,14 @@ export function addSketchTo(
const _node = { ...node }
const _name = name || findUniqueName(node, 'part')
const startSketchAt = createCallExpressionStdLib('startSketchAt', [
const startSketchAt = createCallExpression('startSketchAt', [
createLiteral('default'),
])
const rotate = createCallExpression(axis === 'xz' ? 'rx' : 'ry', [
createLiteral(90),
createPipeSubstitution(),
])
const initialLineTo = createCallExpressionStdLib('line', [
const initialLineTo = createCallExpression('line', [
createLiteral('default'),
createPipeSubstitution(),
])
@ -154,9 +112,7 @@ function addToShow(node: Program, name: string): Program {
const dumbyStartend = { start: 0, end: 0 }
const showCallIndex = getShowIndex(_node)
if (showCallIndex === -1) {
const showCall = createCallExpressionStdLib('show', [
createIdentifier(name),
])
const showCall = createCallExpression('show', [createIdentifier(name)])
const showExpressionStatement: ExpressionStatement = {
type: 'ExpressionStatement',
...dumbyStartend,
@ -168,7 +124,7 @@ function addToShow(node: Program, name: string): Program {
const showCall = { ..._node.body[showCallIndex] } as ExpressionStatement
const showCallArgs = (showCall.expression as CallExpression).arguments
const newShowCallArgs: Value[] = [...showCallArgs, createIdentifier(name)]
const newShowExpression = createCallExpressionStdLib('show', newShowCallArgs)
const newShowExpression = createCallExpression('show', newShowCallArgs)
_node.body[showCallIndex] = {
...showCall,
@ -193,7 +149,7 @@ export function mutateArrExp(
): boolean {
if (node.type === 'ArrayExpression') {
node.elements.forEach((element, i) => {
if (isLiteralArrayOrStatic(element)) {
if (element.type === 'Literal') {
node.elements[i] = updateWith.elements[i]
}
})
@ -211,8 +167,8 @@ export function mutateObjExpProp(
const keyIndex = node.properties.findIndex((a) => a.key.name === key)
if (keyIndex !== -1) {
if (
isLiteralArrayOrStatic(updateWith) &&
isLiteralArrayOrStatic(node.properties[keyIndex].value)
updateWith.type === 'Literal' &&
node.properties[keyIndex].value.type === 'Literal'
) {
node.properties[keyIndex].value = updateWith
return true
@ -222,7 +178,7 @@ export function mutateObjExpProp(
) {
const arrExp = node.properties[keyIndex].value as ArrayExpression
arrExp.elements.forEach((element, i) => {
if (isLiteralArrayOrStatic(element)) {
if (element.type === 'Literal') {
arrExp.elements[i] = updateWith.elements[i]
}
})
@ -269,7 +225,7 @@ export function extrudeSketch(
const { node: variableDeclorator, shallowPath: pathToDecleration } =
getNodeFromPath<VariableDeclarator>(_node, pathToNode, 'VariableDeclarator')
const extrudeCall = createCallExpressionStdLib('extrude', [
const extrudeCall = createCallExpression('extrude', [
createLiteral(4),
shouldPipe
? createPipeSubstitution()
@ -305,11 +261,7 @@ export function extrudeSketch(
}
const name = findUniqueName(node, 'part')
const VariableDeclaration = createVariableDeclaration(name, extrudeCall)
let showCallIndex = getShowIndex(_node)
if (showCallIndex == -1) {
// We didn't find a show, so let's just append everything
showCallIndex = _node.body.length
}
const showCallIndex = getShowIndex(_node)
_node.body.splice(showCallIndex, 0, VariableDeclaration)
const pathToExtrudeArg: PathToNode = [
['body', ''],
@ -361,15 +313,15 @@ export function sketchOnExtrudedFace(
const newSketch = createVariableDeclaration(
newSketchName,
createPipeExpression([
createCallExpressionStdLib('startSketchAt', [
createCallExpression('startSketchAt', [
createArrayExpression([createLiteral(0), createLiteral(0)]),
]),
createCallExpressionStdLib('lineTo', [
createCallExpression('lineTo', [
createArrayExpression([createLiteral(1), createLiteral(1)]),
createPipeSubstitution(),
]),
createCallExpression('transform', [
createCallExpressionStdLib('getExtrudeWallTransform', [
createCallExpression('getExtrudeWallTransform', [
createLiteral(tag),
createIdentifier(oldSketchName),
]),
@ -462,40 +414,6 @@ export function createPipeSubstitution(): PipeSubstitution {
}
}
export function createCallExpressionStdLib(
name: string,
args: CallExpression['arguments']
): CallExpression {
return {
type: 'CallExpression',
start: 0,
end: 0,
callee: {
type: 'Identifier',
start: 0,
end: 0,
name,
},
function: {
type: 'StdLib',
func: {
// We only need the name here to map it back when it serializes
// to rust, don't worry about the rest.
name,
summary: '',
description: '',
tags: [],
returnValue: { type: '', required: false, name: '', schema: {} },
args: [],
unpublished: false,
deprecated: false,
},
},
optional: false,
arguments: args,
}
}
export function createCallExpression(
name: string,
args: CallExpression['arguments']
@ -510,9 +428,6 @@ export function createCallExpression(
end: 0,
name,
},
function: {
type: 'InMemory',
},
optional: false,
arguments: args,
}
@ -639,7 +554,7 @@ export function giveSketchFnCallTag(
createLiteral(tag || findUniqueName(ast, 'seg', 2))) as Literal
const tagStr = String(tagValue.value)
const newFirstArg = createFirstArg(
primaryCallExp.callee.name as ToolTip,
primaryCallExp.callee.name as TooTip,
firstArg.val,
tagValue
)

Some files were not shown because too many files have changed in this diff Show More