Compare commits

...

18 Commits

Author SHA1 Message Date
6809a46b6a Bump to 0.0.4 (#298) 2023-08-21 16:28:05 -04:00
965d2b23cf Xstate Auth migration (#250)
* auth migrate progress, web only

* wrap home in state provider

* use consistent logged spelling

* use createActorContext

* typo

* fix wraping problem
2023-08-22 05:34:20 +10:00
25392824cb Fix zoom to not jump anymore (#272)
* Remove scroll handling, honor zoom on drag + ctrl

* Add back ability to zoom with mouse wheel, but properly

* Add TODO for 'any' removal

* Update kittycad lib to remove 'any'
2023-08-21 10:52:41 -04:00
43b1272538 Signed Auto-Updates and CI Reorg (#251)
* Start to add things to updater section

* Fix public key

* Fix conf.json

* Add secrets

* Add localhost updater endpoint

* Revert "Add localhost updater endpoint"

This reverts commit e9e08868aa.

* Add localhost updater endpoint

* Bump to 0.0.4

* Back to current v0.0.3 with localhost updater

* Bump to 0.0.4 for testing purpose

* Back to 0.0.3 and ngrok https endpoint

* Bump to fake 0.0.4

* revert 19761baba6 and back to 0.0.3

* Revert "revert 19761baba6 and back to 0.0.3"

This reverts commit 763cc1ee47.

* Back to 0.0.3, new releases endpoint

* Add template static json endpoint

* Add Google Cloud actions

* Test multi-job single-workflow CI for build, test, release

* Clean up

* Reorg to comply with non-persistence

* Reshuffle to speed things up

* Clean up

* Missing node sync in build-test-web

* Further download test

* Clean up

* Test simpler name, add TODOs, preparing endpoint.json

* Draft static endpoint generation

* Fix a few things on endpoint.json

* Test google cloud upload

* Replace non-existing version with temporary

* Try to have working test upload to bucket

* Fixes, version output, first attemp at test release and endpoint upload

* Fix jq

* Try to fix json

* Fix typo

* Fix attempt

* Trying to fix the version issue

* Add back test release upload to bucket

* WIP

* parent: false

* One upload per release

* WIP

* Test bump to 0.0.4

* Back to 0.0.3 with test endpoint URL

* Bump to 0.0.4 for testing purpose

* Remove test/ dir, put back release check

* Back to 0.0.3

* Clean up
2023-08-21 07:37:03 -04:00
24268fa744 generate ts types from rust (#291)
* initial types

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* start using generated types

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* generate ast types

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* cleanup

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* generate for error types as well

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-19 23:18:54 -07:00
63dd417d33 fix serialization of errors to client errors (#290)
* fix errors

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* add comment

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-19 14:22:11 -07:00
a8d23f5c0d Fix lint warnings (#289) 2023-08-19 08:14:35 +10:00
6ea3a37e8a Remove redundant error-to-string fn (#288) 2023-08-18 17:10:55 -05:00
01f95d4ace Start a refactor of the connection to the Engine (#284)
The front-end and the back-end communicate with three channels.  The
first is the WebSocket connection to the Engine API. Once that
connection is online, a WebRTC connection is negotiated, which contains
one video stream from the server to us for the GUI, and a second, which
is a binary data channel from us to the server, which we send JSON over
for real-time events like mouse positioning.

The lifecycle of the WebRTC connection and the WebSocket connection are
tied, since if the WebSocket connection breaks down, the WebRTC
connection must get restarted (to get a connection to the *same* backend
that we have an open WebSocket connection to).

This starts a move to split the WebRTC and WebSocket pair to be managed
by a new class (EngineConnection), which will only start and maintain
the WebSocket and WebRTC channels. Anything using the EngineConnection
will be able to communnicate commands without needing to add control
logic for the underlying data channels.

Signed-off-by: Paul R. Tagliamonte <paul@kittycad.io>
2023-08-18 16:16:16 -04:00
3b9094e0dd Rust style + performance tweaks (#285)
* Rust style tweaks

* Use references instead of cloning

* Further reduce allocations

* Reduce allocations
2023-08-18 13:23:18 -05:00
a6d0f17970 Allow warning banner to be dismissed (#286) 2023-08-18 13:58:29 -04:00
108827075d fix export types (#271)
* fix export types

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* update kittycad lib

* fix wasm

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>
2023-08-18 08:12:32 -07:00
aa24b9d6bd Polish: UI theme colors, onboarding dismiss, Export button in sidebar (#270)
* Light mode style fixes

* Fix dismissing onboarding for nested routes

* Refactor: move export button to  user side panel

* Refactor: add project data to modeling page loader

* Add new ProjectSidebarMenu

* Convert AppHeader to use ProjectSidebarMenu

* Move ExportButton to ProjectSidebarMenu

* Fix: hide default dir setting in Web

* Add DownloadAppBanner when in Prod

* Add unit tests to ProjectSidebarMenu

* Tiny CSS rounding tweak to sidebars

* Fix formatting in unit tests

* Update icons and logos to use full-color Kitt

* Fix: dim UI on camera drag, not click
2023-08-18 10:27:01 -04:00
ff08c30ddc Port abstractSyntaxtTree (the Parser) to Rust/WASM 🦀 (#207)
* initial port

leafs progress

leafs progress

leafs progress

all ast with binary expressions are passing

abstractSyntaxTree replaced in all js-test

clippy

lexer?

trying to make tests happy

clean up comments etc

remove unused

lexer is needed though

re-org rust files

remove old ast

* fmt

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* rearrange test fns

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* start of returning results

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* make tests compile again

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixes

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixes

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* more errors

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* more errors

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* replace more panics

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* cleanup more unwraps

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* cleanup more unwraps

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* less unwraps

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixes

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixes

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix clippy

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixups

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* deps

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix tests

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix some tests

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix some tests

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix more tests

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixes

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* passing

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixes

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* up[date

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: Jess Frazelle <github@jessfraz.com>
2023-08-18 02:37:52 -07:00
ea35db506b better url (#267) 2023-08-17 20:50:19 +10:00
81a744faa5 Add in a note about Third-Party cookies in Chrome (#262)
Signed-off-by: Paul R. Tagliamonte <paultag@kittycad.io>
2023-08-16 11:45:03 -04:00
7866686a1d Feature: add 'Request a feature' links (#260) 2023-08-15 22:33:52 -04:00
19761baba6 Home page in desktop, separate file support (#252)
* Bugfix: don't toast on every change of defaultDir

* Refactor app to live under /file/:id

* Stub out Tauri-only home page

* home reads and writes blank files to defaultDir

* Fix initial directory creation

* Make file names editable

* Refactor onboarding to use normal fns for load issues

* Feature: load and write files to and from disk

* Feature: Add file deletion, break out FileCard component

* Fix settings close URLs to be relative, button types

* Add filename and link to AppHeader

* Style tweaks: scrollbar, header name, card size

* Style: add header, empty state to Home

* Refactor: load file in route loader

* Move makePathRelative to lib to fix tests

* Fix App test

* Use '$nnn' default name scheme

* Fix type error on ActionButton

* Fix type error on ActionButton

* @adamchalmers review

* Fix merge mistake

* Refactor: rename all things "file" to "project"

* Feature: migrate to <project-name>/main.kcl setup

* Fix tsc test

* @Irev-Dev review part 1: renames and imports

* @Irev-Dev review pt 2: simplify file list refresh

* @Irev-Dev review pt 3: filter out non-projects

* @Irev-review pt 4: folder conventions + home auth

* Add sort functionality to new welcome page (#255)

* Add todo for Sentry
2023-08-15 21:56:24 -04:00
112 changed files with 8178 additions and 3354 deletions

23
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,23 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: 'npm' # See documentation for possible values
directory: '/' # Location of package manifests
schedule:
interval: 'daily'
- package-ecosystem: 'github-actions' # See documentation for possible values
directory: '/' # Location of package manifests
schedule:
interval: 'daily'
- package-ecosystem: 'cargo' # See documentation for possible values
directory: '/src/wasm-lib/' # Location of package manifests
schedule:
interval: 'daily'
- package-ecosystem: 'cargo' # See documentation for possible values
directory: '/src-tauri/' # Location of package manifests
schedule:
interval: 'daily'

View File

@ -1,81 +0,0 @@
name: Build
on:
pull_request:
push:
branches:
- main
release:
types: [published]
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, ubuntu-20.04, windows-latest]
steps:
- uses: actions/checkout@v3
- name: install ubuntu system dependencies
if: matrix.os == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev
- name: Sync node version and setup cache
uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
cache: 'yarn' # Set this to npm, yarn or pnpm.
- run: yarn install
- name: Rust setup
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
- name: wasm prep
shell: bash
run: |
mkdir src/wasm-lib/pkg; cd src/wasm-lib
npx wasm-pack build --target web --out-dir pkg
cd ../../
cp src/wasm-lib/pkg/wasm_lib_bg.wasm public
- name: macos sed
if: matrix.os == 'macos-latest'
shell: bash
run: |
sed -i '' 's/import.meta.url//g' "./src/wasm-lib/pkg/wasm_lib.js"
- name: ubuntu and windows sed
if: matrix.os != 'macos-latest'
shell: bash
run: |
sed -i 's/import.meta.url//g' "./src/wasm-lib/pkg/wasm_lib.js"
- name: Fix format
run: yarn fmt
- name: Build the app for the current platform (no upload)
if: github.event_name == 'pull_request'
uses: tauri-apps/tauri-action@v0
- uses: actions/upload-artifact@v3
if: github.event_name == 'pull_request'
with:
path: src-tauri/target/release/bundle
name: modeling-app_macos_linux_windows
- name: Build the app for the current platform and upload to release
if: github.event_name == 'release'
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
releaseId: ${{ github.event.release.id }}

47
.github/workflows/cargo-build.yml vendored Normal file
View File

@ -0,0 +1,47 @@
on:
push:
branches:
- main
paths:
- '**.rs'
- '**/Cargo.toml'
- '**/Cargo.lock'
- '**/rust-toolchain.toml'
- .github/workflows/cargo-build.yml
pull_request:
paths:
- '**.rs'
- '**/Cargo.toml'
- '**/Cargo.lock'
- '**/rust-toolchain.toml'
- .github/workflows/cargo-build.yml
name: cargo build
jobs:
cargobuild:
name: cargo build
runs-on: ubuntu-latest
strategy:
matrix:
dir: ['src/wasm-lib', 'src-tauri']
steps:
- uses: actions/checkout@v3
- name: Install latest rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: install dependencies
if: matrix.dir == 'src-tauri'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Rust Cache
uses: Swatinem/rust-cache@v2.6.1
- name: Run cargo build
run: |
cd "${{ matrix.dir }}"
cargo build --all
shell: bash

46
.github/workflows/cargo-clippy.yml vendored Normal file
View File

@ -0,0 +1,46 @@
on:
push:
branches:
- main
paths:
- '**/Cargo.toml'
- '**/Cargo.lock'
- '**/rust-toolchain.toml'
- '**.rs'
- .github/workflows/cargo-clippy.yml
pull_request:
paths:
- '**/Cargo.toml'
- '**/Cargo.lock'
- '**/rust-toolchain.toml'
- '**.rs'
- .github/workflows/cargo-build.yml
name: cargo clippy
jobs:
cargoclippy:
name: cargo clippy
runs-on: ubuntu-latest
strategy:
matrix:
dir: ['src/wasm-lib', 'src-tauri']
steps:
- uses: actions/checkout@v3
- name: Install latest rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
components: clippy
- name: install dependencies
if: matrix.dir == 'src-tauri'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Rust Cache
uses: Swatinem/rust-cache@v2.6.1
- name: Run clippy
run: |
cd "${{ matrix.dir }}"
cargo clippy --all --tests -- -D warnings

45
.github/workflows/cargo-fmt.yml vendored Normal file
View File

@ -0,0 +1,45 @@
on:
push:
branches:
- main
paths:
- '**/Cargo.toml'
- '**/Cargo.lock'
- '**/rust-toolchain.toml'
- '**.rs'
- .github/workflows/cargo-fmt.yml
pull_request:
paths:
- '**/Cargo.toml'
- '**/Cargo.lock'
- '**/rust-toolchain.toml'
- '**.rs'
- .github/workflows/cargo-fmt.yml
permissions:
packages: read
contents: read
name: cargo fmt
jobs:
cargofmt:
name: cargo fmt
runs-on: ubuntu-latest
strategy:
matrix:
dir: ['src/wasm-lib', 'src-tauri']
steps:
- uses: actions/checkout@v3
- name: Install latest rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
components: rustfmt
- name: Rust Cache
uses: Swatinem/rust-cache@v2.6.1
- name: Run cargo fmt
run: |
cd "${{ matrix.dir }}"
cargo fmt -- --check
shell: bash

48
.github/workflows/cargo-test.yml vendored Normal file
View File

@ -0,0 +1,48 @@
on:
push:
branches:
- main
paths:
- '**.rs'
- '**/Cargo.toml'
- '**/Cargo.lock'
- '**/rust-toolchain.toml'
- .github/workflows/cargo-test.yml
pull_request:
paths:
- '**.rs'
- '**/Cargo.toml'
- '**/Cargo.lock'
- '**/rust-toolchain.toml'
- .github/workflows/cargo-test.yml
workflow_dispatch:
permissions: read-all
name: cargo test
jobs:
cargotest:
name: cargo test
runs-on: ubuntu-latest-8-cores
strategy:
matrix:
dir: ['src/wasm-lib', 'src-tauri']
steps:
- uses: actions/checkout@v3
- name: Install latest rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: install dependencies
if: matrix.dir == 'src-tauri'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- uses: taiki-e/install-action@cargo-llvm-cov
- uses: taiki-e/install-action@nextest
- name: Rust Cache
uses: Swatinem/rust-cache@v2.6.1
- name: cargo test
shell: bash
run: |-
cd "${{ matrix.dir }}"
cargo llvm-cov nextest --lcov --output-path lcov.info --test-threads=1 --no-fail-fast

191
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,191 @@
name: CI
on:
pull_request:
push:
branches:
- main
release:
types: [published]
jobs:
check-format:
runs-on: 'ubuntu-20.04'
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- run: yarn install
- run: yarn fmt-check
build-test-web:
runs-on: ubuntu-20.04
outputs:
version: ${{ steps.export_version.outputs.version }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- run: yarn install
- 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]
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, ubuntu-20.04, windows-latest]
steps:
- uses: actions/checkout@v3
- name: install ubuntu system dependencies
if: matrix.os == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev
- name: Sync node version and setup cache
uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
cache: 'yarn' # Set this to npm, yarn or pnpm.
- run: yarn install
- name: Rust setup
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
- name: wasm prep
shell: bash
run: |
mkdir src/wasm-lib/pkg; cd src/wasm-lib
npx wasm-pack build --target web --out-dir pkg
cd ../../
cp src/wasm-lib/pkg/wasm_lib_bg.wasm public
- name: macos sed
if: matrix.os == 'macos-latest'
shell: bash
run: |
sed -i '' 's/import.meta.url//g' "./src/wasm-lib/pkg/wasm_lib.js"
- name: ubuntu and windows sed
if: matrix.os != 'macos-latest'
shell: bash
run: |
sed -i 's/import.meta.url//g' "./src/wasm-lib/pkg/wasm_lib.js"
- name: Fix format
run: yarn fmt
- 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 }}
- uses: actions/upload-artifact@v3
with:
path: src-tauri/target/release/bundle/*/*
publish-apps-release:
runs-on: ubuntu-20.04
if: github.event_name == 'release'
needs: [build-test-web, build-apps]
env:
VERSION_NO_V: ${{ needs.build-test-web.outputs.version }}
steps:
- uses: actions/download-artifact@v3
- name: Generate the update static endpoint
run: |
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/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 darwin_sig "$DARWIN_SIG" \
--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-app_${VERSION_NO_V}_amd64.AppImage.tar.gz" \
--arg windows_sig "$WINDOWS_SIG" \
--arg windows_url "$RELEASE_DIR/nsis/kittycad-modeling-app_${VERSION_NO_V}_x64-setup.nsis.zip" \
'{
"version": $version,
"platforms": {
"darwin-x86_64": {
"signature": $darwin_sig,
"url": $darwin_url
},
"linux-x86_64": {
"signature": $linux_sig,
"url": $linux_url
},
"windows-x86_64": {
"signature": $windows_sig,
"url": $windows_url
}
}
}' > last_update.json
cat last_update.json
- name: Authenticate to Google Cloud
uses: 'google-github-actions/auth@v1.1.1'
with:
credentials_json: '${{ secrets.GOOGLE_CLOUD_DL_SA }}'
- name: Set up Google Cloud SDK
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: '*/kittycad-modeling-app*'
parent: false
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

View File

@ -1,16 +0,0 @@
# on pull requests, setup node, run `yarn prettier --check`
name: Check formatting
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- run: yarn install
- run: yarn fmt-check

View File

@ -1,21 +0,0 @@
# on pull requests, setup node, run `yarn install` and `yarn test:nowatch`
name: Test
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- run: yarn install
- run: yarn build:wasm
- run: yarn tsc
- run: yarn simpleserver:ci
- run: yarn test:nowatch
- run: yarn test:cov
- run: yarn test:rust

2
.gitignore vendored
View File

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

View File

@ -1,6 +1,6 @@
## Kurt demo project
live at [untitled-app.kittycad.io](https://untitled-app.kittycad.io/)
live at [app.kittycad.io](https://app.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.
@ -43,6 +43,15 @@ If you want to edit the rust files, you can cd into `src/wasm-lib` and then use
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
[blocks Third-Party Cookies](https://developer.chrome.com/en/docs/privacy-sandbox/third-party-cookie-phase-out/).
If you're having trouble logging into the `modeling-app`, you may need to
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".
## Tauri
To spin up up tauri dev, `yarn install` and `yarn build:wasm` need to have been done before hand then

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 207 KiB

View File

@ -1,13 +1,14 @@
{
"name": "untitled-app",
"version": "0.0.3",
"version": "0.0.4",
"private": true,
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-brands-svg-icons": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.13",
"@kittycad/lib": "^0.0.27",
"@kittycad/lib": "^0.0.29",
"@react-hook/resize-observer": "^1.2.6",
"@tauri-apps/api": "^1.3.0",
"@testing-library/jest-dom": "^5.14.1",
@ -18,6 +19,7 @@
"@types/react-dom": "^18.0.0",
"@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",
"http-server": "^14.1.1",
@ -32,6 +34,7 @@
"react-router-dom": "^6.14.2",
"sketch-helpers": "^0.0.4",
"swr": "^2.0.4",
"tauri-plugin-fs-extra-api": "https://github.com/tauri-apps/tauri-plugin-fs-extra#v1",
"toml": "^3.0.0",
"ts-node": "^10.9.1",
"typescript": "^4.4.2",
@ -40,6 +43,7 @@
"wasm-pack": "^0.12.1",
"web-vitals": "^2.1.0",
"ws": "^8.13.0",
"xstate": "^4.38.2",
"zustand": "^4.1.4"
},
"scripts": {
@ -56,9 +60,9 @@
"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) && cp src/wasm-lib/pkg/wasm_lib_bg.wasm public && yarn fmt && yarn remove-importmeta",
"build:wasm": "yarn wasm-prep && (cd src/wasm-lib && wasm-pack build --target web --out-dir pkg && cargo test --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",
"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"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,26 +1,45 @@
<svg width="316" height="75" viewBox="0 0 316 75" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.33449 67.7274V65.5747H3.02906V63.4219H0.876343V18.2149H3.02906V16.0622H5.18177V13.9095H7.33449V11.7568H9.4872V7.45137H11.6399V5.29866H13.7926V3.14594H15.9453V0.993229H18.0981V3.14594H20.2508V5.29866H22.4035V7.45137H24.5562V9.60409H31.0143V7.45137H33.1671V5.29866H35.3198V3.14594H37.4725V0.993229H39.6252V3.14594H41.7779V5.29866H43.9306V7.45137H46.0833V11.7568H48.2361V13.9095H50.3888V16.0622H52.5415V18.2149H54.6942V63.4219H52.5415V65.5747H48.2361V67.7274H41.7779V69.8801H43.9306V74.1855H31.0143V69.8801H33.1671V67.7274H22.4035V69.8801H24.5562V74.1855H11.6399V69.8801H13.7926V67.7274H7.33449Z" fill="#101412"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.5563 11.7568H31.0145V9.60409H33.1672V7.45137H35.3199V5.29866H37.4726V3.14594H39.6253V5.29866H41.778V7.45137H43.9307V11.7568H46.0835V13.9095H48.2361V16.0622H50.3888V18.2149H52.5415V20.3677V22.5204V50.5057V52.6584V54.8111V63.4219H48.2361V65.5747H39.6253V67.7274V69.8801H41.778V72.0328H33.1671V69.8801H35.3199V67.7274V65.5747H20.2509V67.7274V69.8801H22.4036V72.0328H13.7927V69.8801H15.9454V67.7274V65.5747H7.33454V63.4219H3.02911V54.8111V52.6584V50.5057V22.5204V20.3677V18.2149H5.18183V16.0622H7.33454V13.9095H9.48726V11.7568L9.48731 11.7568H11.64V7.45137H13.7927V5.29866H15.9455V3.14594H18.0982V5.29866H20.2509V7.45137H22.4036V9.60409H24.5563V11.7568ZM5.18191 56.9638V59.1165H15.9455V56.9638H5.18191ZM9.48734 61.2692V63.4219H15.9455V61.2692H9.48734ZM39.6253 59.1165V56.9638H41.7781V59.1165H39.6253ZM43.9308 56.9638V59.1165H46.0835V56.9638H43.9308ZM48.2362 59.1165V56.9638H50.3889V59.1165H48.2362ZM39.6253 61.2692V63.4219H46.0835V61.2692H39.6253ZM20.2509 59.1165H35.3199V63.4219H20.2509V59.1165Z" fill="#D0FF00"/>
<path d="M3.02911 52.6584V50.5057H52.5415V52.6584H3.02911Z" fill="#B1E515"/>
<path d="M9.48725 26.8258V24.6731H46.0834V26.8258H48.2361V44.0475H46.0834V46.2002H9.48725V44.0475H7.33453V26.8258H9.48725Z" fill="#1F2320"/>
<path d="M35.3198 35.4367V26.8258H39.6252V35.4367H35.3198Z" fill="#D0FF00"/>
<path d="M24.5562 35.4367H31.0144V37.5894H28.8617V41.8948H35.3198V39.7421H37.4725V41.8948H35.3198V44.0475H20.2508V41.8948H18.0981V39.7421H20.2508V41.8948H26.709V37.5894H24.5562V35.4367Z" fill="#D0FF00"/>
<path d="M20.2508 33.2839V31.1312H13.7927V33.2839H11.64V31.1312H13.7927V28.9785H20.2508V31.1312H22.4035V33.2839H20.2508Z" fill="#D0FF00"/>
<path d="M48.2361 18.2149V16.0622H50.3888V18.2149H48.2361Z" fill="#92C51B"/>
<path d="M7.33448 18.2149V16.0622H5.18176V18.2149H7.33448Z" fill="#92C51B"/>
<path d="M46.0834 16.0622V13.9095H48.2361V16.0622H46.0834Z" fill="#92C51B"/>
<path d="M9.48725 16.0622V13.9095H7.33453V16.0622H9.48725Z" fill="#92C51B"/>
<rect x="26.709" y="11.7568" width="2.15271" height="4.30543" fill="#B1E515"/>
<path d="M35.3197 16.0622V13.9095H37.4725V11.7568H39.6252V13.9095H41.7779V16.0622H35.3197Z" fill="#101412"/>
<path d="M15.9453 13.9095V11.7568H18.098V13.9095H20.2507V16.0622H13.7926V13.9095H15.9453Z" fill="#101412"/>
<path d="M9.48718 52.6584V50.5057H15.9453V52.6584H9.48718Z" fill="#92C51B"/>
<rect x="24.5562" y="11.7568" width="6.45814" height="2.15271" fill="#92C51B"/>
<path d="M77.4822 18.0099V33.8657L92.8664 17.1258L99.4091 22.077L86.9721 35.5161L103.535 58.0914H92.6306L81.0777 41.8231L77.4822 45.7133V58.0914H68.8175V18.0099H77.4822Z" fill="#FFFFFA"/>
<path d="M115.158 20.7213C115.158 22.0574 114.666 23.1969 113.684 24.14C112.741 25.0438 111.601 25.4957 110.265 25.4957C108.929 25.4957 107.809 25.0438 106.906 24.14C106.002 23.1969 105.55 22.0574 105.55 20.7213C105.55 19.3853 106.002 18.2457 106.906 17.3026C107.809 16.3595 108.929 15.888 110.265 15.888C111.601 15.888 112.741 16.3595 113.684 17.3026C114.666 18.2457 115.158 19.3853 115.158 20.7213ZM114.627 29.5039V58.0914H105.962V29.5039H114.627Z" fill="#FFFFFA"/>
<path d="M133.871 59.0935C130.335 59.0935 127.407 58.1897 125.089 56.3821C122.809 54.5745 121.67 51.922 121.67 48.4247V36.636H117.603V29.9165H121.67V22.8433L130.276 21.4286V29.9165H136.052L137.938 36.636H130.276V47.128C130.276 48.5033 130.629 49.6429 131.337 50.5467C132.044 51.4112 133.066 51.8434 134.402 51.8434C134.913 51.8434 135.463 51.7648 136.052 51.6077C136.642 51.4505 137.231 51.2343 137.82 50.9593L140.355 57.0894C139.687 57.6395 138.705 58.1111 137.408 58.504C136.111 58.897 134.932 59.0935 133.871 59.0935Z" fill="#FFFFFA"/>
<path d="M156.465 59.0935C152.929 59.0935 150.001 58.1897 147.683 56.3821C145.404 54.5745 144.264 51.922 144.264 48.4247V36.636H140.197V29.9165H144.264V22.8433L152.87 21.4286V29.9165H158.646L160.532 36.636H152.87V47.128C152.87 48.5033 153.223 49.6429 153.931 50.5467C154.638 51.4112 155.66 51.8434 156.996 51.8434C157.507 51.8434 158.057 51.7648 158.646 51.6077C159.236 51.4505 159.825 51.2343 160.415 50.9593L162.949 57.0894C162.281 57.6395 161.299 58.1111 160.002 58.504C158.705 58.897 157.526 59.0935 156.465 59.0935Z" fill="#FFFFFA"/>
<path d="M172.163 59.0345L173.165 56.6178L162.791 30.5649L171.515 29.5039C172.576 32.3332 173.637 35.1625 174.698 37.9917C175.759 40.821 176.8 43.6503 177.822 46.4796L183.834 29.5039H192.793L180.062 61.687C179.119 64.0054 177.488 65.9898 175.169 67.6403C172.851 69.33 170.375 70.4892 167.742 71.1179L164.736 64.1036C166.151 63.5535 167.625 62.8658 169.157 62.0406C170.69 61.2154 171.692 60.2134 172.163 59.0345Z" fill="#FFFFFA"/>
<path d="M203.975 58.0914L197.64 51.7563V21.3723L203.975 15.0371H223.072L229.438 21.3723V31.2748H220.735V25.6162L218.859 23.7402H208.219L206.343 25.6162V47.5124L208.219 49.3883H218.859L220.735 47.5124V41.8538H229.438V51.7563L223.072 58.0914H203.975Z" fill="#FFFFFA"/>
<path d="M236.208 58.0914V21.3723L242.544 15.0371H262.41L268.745 21.3723V58.0914H260.073V45.4212H244.881V58.0914H236.208ZM244.881 36.7488H260.073V25.6162L258.197 23.7402H246.757L244.881 25.6162V36.7488Z" fill="#FFFFFA"/>
<path d="M276.098 58.0914V15.0371H301.716L308.051 21.3723V51.7563L301.716 58.0914H276.098ZM284.802 49.3883H297.503L299.379 47.5124V25.6162L297.503 23.7402H284.802V49.3883Z" fill="#FFFFFA"/>
<svg width="123" height="29" viewBox="0 0 123 29" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.52 26.04V25.2H0.84V24.36H0V6.72H0.84V5.88H1.68V5.04H2.52V4.2H3.36V3.36H17.64V4.2H18.48V5.04H19.32V5.88H20.16V6.72H21V24.36H20.16V25.2H18.48V26.04H15.96V26.88H16.8V28.56H11.76V26.88H12.6V26.04H8.4V26.88H9.24V28.56H4.2V26.88H5.04V26.04H2.52Z" fill="#101412"/>
<path d="M5.04 26.04V24.78H8.4V26.04H7.56V26.88H8.4V27.72H5.04V26.88H5.88V26.04H5.04Z" fill="#4B4862"/>
<path d="M12.6 26.04V24.78H15.96V26.04H15.12V26.88H15.96V27.72H12.6V26.88H13.44V26.04H12.6Z" fill="#4B4862"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.839996 20.58V24.36H2.52V25.2H18.48V24.36H20.16V20.58H0.839996ZM7.56 24.36V22.68H13.44V24.36H7.56Z" fill="#9BADB7"/>
<path d="M0.839996 21V19.32H20.16V21H0.839996Z" fill="#BECAD0"/>
<path d="M1.68 22.68V21.84H5.88V22.68H1.68Z" fill="#2B3E48"/>
<path d="M3.36 24.36V23.52H5.88V24.36H3.36Z" fill="#2B3E48"/>
<path d="M15.12 22.68V21.84H15.96V22.68H15.12Z" fill="#2B3E48"/>
<path d="M16.8 22.68V21.84H17.64V22.68H16.8Z" fill="#2B3E48"/>
<path d="M18.48 22.68V21.84H19.32V22.68H18.48Z" fill="#2B3E48"/>
<path d="M15.12 24.36V23.52H17.64V24.36H15.12Z" fill="#2B3E48"/>
<path d="M18.48 5.88V5.04H17.64V4.2H3.36V5.04H2.52V5.88H1.68V6.72H0.839996V8.4H20.16V6.72H19.32V5.88H18.48Z" fill="#FBF580"/>
<path d="M0.839996 20.16V18.48H20.16V20.16H0.839996Z" fill="#AEAA4C"/>
<path d="M20.16 7.56H0.839996V19.32H20.16V7.56Z" fill="#E5E3A1"/>
<path d="M3.36 10.08V9.24001H17.64V10.08H18.48V16.8H17.64V17.64H3.36V16.8H2.52V10.08H3.36Z" fill="#1F2320"/>
<rect x="8.4" y="4.2" width="4.2" height="1.68" fill="#AEAA4C"/>
<path d="M13.44 10.92V10.08H15.12V13.44H14.28L13.44 10.92Z" fill="#DBFF3C"/>
<path d="M9.24 13.44H11.76V14.28H10.92V15.96H13.44V15.12H14.28V15.96H13.44V16.8H7.56V15.96H6.72V15.12H7.56V15.96H10.08V14.28H9.24V13.44Z" fill="#DBFF3C"/>
<path d="M7.56 12.6V11.76H5.04V12.6H4.2V11.76H5.04V10.92H7.56V11.76H8.4V12.6H7.56Z" fill="#DBFF3C"/>
<path d="M3.36 5.88V5.04H4.2V3.36H5.04V1.68H5.88V0.839996H6.72V1.68H7.56V3.36H8.4V5.04H9.24V5.88H3.36Z" fill="#DBFF3C"/>
<path d="M17.64 5.04V5.88H11.76V5.04H12.6V3.36H13.44V1.68H14.28V0.839996H15.12V1.68H15.96V3.36H16.8V5.04H17.64Z" fill="#DBFF3C"/>
<path d="M13.44 1.68V0H15.96V1.68H16.8V3.36H17.64V4.62H16.8V3.36H15.96V1.68H15.12V0.84H14.28V1.68H13.44V3.36H12.6V4.62H11.76V3.36H12.6V1.68H13.44Z" fill="#92C51B"/>
<path d="M5.04 1.68V0H7.56V1.68H8.4V3.36H9.24V4.62H8.4V3.36H7.56V1.68H6.72V0.84H5.88V1.68H5.04V3.36H4.2V4.62H3.36V3.36H4.2V1.68H5.04Z" fill="#92C51B"/>
<rect x="9.24" y="5.03999" width="2.52" height="0.84" fill="#D0CC6A"/>
<path d="M13.44 5.88V5.04H14.28V4.2H15.12V5.04H15.96V5.88H13.44Z" fill="#76AA1D"/>
<path d="M5.88 5.04V4.2H6.72V5.04H7.56V5.88H5.04V5.04H5.88Z" fill="#76AA1D"/>
<path d="M17.64 5.88V5.04H16.8V4.2H17.64V5.04H18.48V5.88H17.64Z" fill="#76AA1D"/>
<path d="M3.36 5.04V5.88H2.52V5.04H3.36V4.2H4.2V5.04H3.36Z" fill="#76AA1D"/>
<path d="M8.4 4.2H9.24V5.04H10.08V5.88H9.24V5.04H8.4V4.2Z" fill="#76AA1D"/>
<path d="M11.76 4.2H12.6V5.04H11.76V5.88H10.92V5.04H11.76V4.2Z" fill="#76AA1D"/>
<path d="M14.28 10.92H13.44V13.44H14.28V10.92Z" fill="#92C51B"/>
<path d="M1.68 21V19.32H0.839996V21H1.68Z" fill="#D0CC6A"/>
<path d="M19.32 21V19.32H20.16V21H19.32Z" fill="#D0CC6A"/>
<path d="M3.36 20.16V19.32H5.88V20.16H3.36Z" fill="#D56161"/>
<path d="M3.36 21V20.16H5.88V21H3.36Z" fill="#AC3232"/>
<path d="M29.991 6.64V12.827L35.994 6.295L38.547 8.227L33.694 13.471L40.157 22.28H35.902L31.394 15.932L29.991 17.45V22.28H26.61V6.64H29.991Z" fill="#FFFFFA"/>
<path d="M44.6921 7.698C44.6921 8.21933 44.5005 8.664 44.1171 9.032C43.7491 9.38466 43.3045 9.561 42.7831 9.561C42.2618 9.561 41.8248 9.38466 41.4721 9.032C41.1195 8.664 40.9431 8.21933 40.9431 7.698C40.9431 7.17666 41.1195 6.732 41.4721 6.364C41.8248 5.996 42.2618 5.812 42.7831 5.812C43.3045 5.812 43.7491 5.996 44.1171 6.364C44.5005 6.732 44.6921 7.17666 44.6921 7.698ZM44.4851 11.125V22.28H41.1041V11.125H44.4851Z" fill="#FFFFFA"/>
<path d="M51.9943 22.671C50.6143 22.671 49.4719 22.3183 48.5673 21.613C47.6779 20.9077 47.2333 19.8727 47.2333 18.508V13.908H45.6463V11.286H47.2333V8.526L50.5913 7.974V11.286H52.8453L53.5813 13.908H50.5913V18.002C50.5913 18.5387 50.7293 18.9833 51.0053 19.336C51.2813 19.6733 51.6799 19.842 52.2013 19.842C52.4006 19.842 52.6153 19.8113 52.8453 19.75C53.0753 19.6887 53.3053 19.6043 53.5353 19.497L54.5243 21.889C54.2636 22.1037 53.8803 22.2877 53.3743 22.441C52.8683 22.5943 52.4083 22.671 51.9943 22.671Z" fill="#FFFFFA"/>
<path d="M60.8106 22.671C59.4306 22.671 58.2883 22.3183 57.3836 21.613C56.4943 20.9077 56.0496 19.8727 56.0496 18.508V13.908H54.4626V11.286H56.0496V8.526L59.4076 7.974V11.286H61.6616L62.3976 13.908H59.4076V18.002C59.4076 18.5387 59.5456 18.9833 59.8216 19.336C60.0976 19.6733 60.4963 19.842 61.0176 19.842C61.217 19.842 61.4316 19.8113 61.6616 19.75C61.8916 19.6887 62.1216 19.6043 62.3516 19.497L63.3406 21.889C63.08 22.1037 62.6966 22.2877 62.1906 22.441C61.6846 22.5943 61.2246 22.671 60.8106 22.671Z" fill="#FFFFFA"/>
<path d="M66.936 22.648L67.327 21.705L63.279 11.539L66.683 11.125C67.097 12.229 67.511 13.333 67.925 14.437C68.339 15.541 68.7453 16.645 69.144 17.749L71.49 11.125H74.986L70.018 23.683C69.65 24.5877 69.0137 25.362 68.109 26.006C67.2043 26.6653 66.2383 27.1177 65.211 27.363L64.038 24.626C64.59 24.4113 65.165 24.143 65.763 23.821C66.361 23.499 66.752 23.108 66.936 22.648Z" fill="#FFFFFA"/>
<path d="M79.3491 22.28L76.8771 19.808V7.952L79.3491 5.48H86.8011L89.2851 7.952V11.816H85.8891V9.608L85.1571 8.876H81.0051L80.2731 9.608V18.152L81.0051 18.884H85.1571L85.8891 18.152V15.944H89.2851V19.808L86.8011 22.28H79.3491Z" fill="#FFFFFA"/>
<path d="M91.9268 22.28V7.952L94.3988 5.48H102.151L104.623 7.952V22.28H101.239V17.336H95.3108V22.28H91.9268ZM95.3108 13.952H101.239V9.608L100.507 8.876H96.0428L95.3108 9.608V13.952Z" fill="#FFFFFA"/>
<path d="M107.492 22.28V5.48H117.488L119.96 7.952V19.808L117.488 22.28H107.492ZM110.888 18.884H115.844L116.576 18.152V9.608L115.844 8.876H110.888V18.884Z" fill="#FFFFFA"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@ -1,26 +1,45 @@
<svg width="788" height="183" viewBox="0 0 788 183" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6075 166.835V161.454H5.84388V156.072H0.462097V43.0543H5.84388V37.6725H11.2257V32.2907H16.6075V26.9089H21.9892V16.1454H27.371V10.7636H32.7528V5.38179H38.1346V0H43.5164V5.38179H48.8982V10.7636H54.28V16.1454H59.6617V21.5271H75.8071V16.1454H81.1889V10.7636H86.5707V5.38179H91.9525V0H97.3342V5.38179H102.716V10.7636H108.098V16.1454H113.48V26.9089H118.861V32.2907H124.243V37.6725H129.625V43.0543H135.007V156.072H129.625V161.454H118.861V166.835H102.716V172.217H108.098V182.981H75.8071V172.217H81.1889V166.835H54.28V172.217H59.6617V182.981H27.371V172.217H32.7528V166.835H16.6075Z" fill="#101412"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.9892 26.9095L21.9892 26.9102V32.2919H16.6075V37.6737H11.2257V43.0555H5.84388V48.4364V53.8191V123.781V129.163V129.164V134.545V156.073H16.6075V161.455H38.1346V166.836V172.217H32.7528V177.599H54.28V172.217H48.8982V166.836V161.455H86.5707V166.836V172.217H81.1889V177.599H102.716V172.217H97.3342V166.836V161.455H118.861V156.073H129.625V134.545V129.164V129.163V123.781V53.8191V48.4364V43.0555H124.243V37.6737H118.861V32.2919H113.48V26.9102V26.9095H108.098V16.1459H102.716V10.7641H97.3342V5.38232H91.9525V10.7641H86.5707V16.1459H81.1889V21.5277H75.8071V26.9102H59.6617V21.5277H54.28V16.1459H48.8982V10.7641H43.5164V5.38232H38.1346V10.7641H32.7528V16.1459H27.371V26.9095H21.9892ZM11.2257 129.164H124.243V129.163H11.2257V129.164ZM11.2257 139.927V145.309H38.1346V139.927H11.2257ZM21.9893 150.691V156.072H38.1346V150.691H21.9893ZM97.3343 145.309V139.927H102.716V145.309H97.3343ZM108.098 139.927V145.309H113.48V139.927H108.098ZM118.861 145.309V139.927H124.243V145.309H118.861ZM97.3343 150.691V156.072H113.48V150.691H97.3343ZM48.8982 145.309H86.5707V156.073H48.8982V145.309Z" fill="#D0FF00"/>
<path d="M5.84388 129.163V123.781H129.625V129.163H5.84388Z" fill="#B1E515"/>
<path d="M21.9892 64.5812V59.1995H113.48V64.5812H118.861V107.636H113.48V113.017H21.9892V107.636H16.6075V64.5812H21.9892Z" fill="#1F2320"/>
<path d="M86.5707 86.1092V64.582H97.3343V86.1092H86.5707Z" fill="#D0FF00"/>
<path d="M59.6617 86.1092H75.8071V91.491H70.4253V102.255H86.5707V96.8727H91.9525V102.255H86.5707V107.636H48.8982V102.255H43.5164V96.8727H48.8982V102.255H65.0435V91.491H59.6617V86.1092Z" fill="#D0FF00"/>
<path d="M48.8982 80.7274V75.3456H32.7528V80.7274H27.371V75.3456H32.7528V69.9638H48.8982V75.3456H54.28V80.7274H48.8982Z" fill="#D0FF00"/>
<path d="M118.861 43.0534V37.6716H124.243V43.0534H118.861Z" fill="#92C51B"/>
<path d="M16.6075 43.0534V37.6716H11.2257V43.0534H16.6075Z" fill="#92C51B"/>
<path d="M113.48 37.6728V32.291H118.861V37.6728H113.48Z" fill="#92C51B"/>
<path d="M21.9892 37.6728V32.291H16.6075V37.6728H21.9892Z" fill="#92C51B"/>
<rect x="65.0435" y="26.9087" width="5.38179" height="10.7636" fill="#B1E515"/>
<path d="M86.5707 37.6723V32.2905H91.9525V26.9087H97.3342V32.2905H102.716V37.6723H86.5707Z" fill="#101412"/>
<path d="M38.1346 32.2905V26.9087H43.5164V32.2905H48.8982V37.6723H32.7528V32.2905H38.1346Z" fill="#101412"/>
<path d="M21.9892 129.163V123.781H38.1346V129.163H21.9892Z" fill="#92C51B"/>
<rect x="59.6617" y="26.9087" width="16.1454" height="5.38179" fill="#92C51B"/>
<path d="M191.977 42.5414V82.1808L230.437 40.331L246.794 52.7091L215.701 86.3068L257.109 142.745H229.848L200.966 102.074L191.977 111.8V142.745H170.315V42.5414H191.977Z" fill="#101412"/>
<path d="M286.165 49.3199C286.165 52.66 284.937 55.5089 282.481 57.8666C280.124 60.1261 277.275 61.2559 273.935 61.2559C270.594 61.2559 267.795 60.1261 265.535 57.8666C263.276 55.5089 262.146 52.66 262.146 49.3199C262.146 45.9797 263.276 43.1308 265.535 40.7731C267.795 38.4153 270.594 37.2365 273.935 37.2365C277.275 37.2365 280.124 38.4153 282.481 40.7731C284.937 43.1308 286.165 45.9797 286.165 49.3199ZM284.839 71.2763V142.745H263.177V71.2763H284.839Z" fill="#101412"/>
<path d="M332.949 145.25C324.108 145.25 316.789 142.991 310.993 138.472C305.295 133.953 302.446 127.322 302.446 118.578V89.1066H292.278V72.3078H302.446V54.6248L323.96 51.0882V72.3078H338.402L343.117 89.1066H323.96V115.336C323.96 118.775 324.845 121.624 326.613 123.883C328.381 126.044 330.935 127.125 334.276 127.125C335.553 127.125 336.928 126.929 338.402 126.536C339.875 126.143 341.349 125.602 342.822 124.915L349.159 140.24C347.489 141.615 345.033 142.794 341.791 143.777C338.549 144.759 335.602 145.25 332.949 145.25Z" fill="#101412"/>
<path d="M389.435 145.25C380.593 145.25 373.274 142.991 367.478 138.472C361.781 133.953 358.932 127.322 358.932 118.578V89.1066H348.764V72.3078H358.932V54.6248L380.446 51.0882V72.3078H394.887L399.602 89.1066H380.446V115.336C380.446 118.775 381.33 121.624 383.098 123.883C384.867 126.044 387.421 127.125 390.761 127.125C392.038 127.125 393.413 126.929 394.887 126.536C396.361 126.143 397.834 125.602 399.308 124.915L405.644 140.24C403.974 141.615 401.518 142.794 398.276 143.777C395.034 144.759 392.087 145.25 389.435 145.25Z" fill="#101412"/>
<path d="M428.679 145.103L431.184 139.061L405.249 73.9287L427.058 71.2763C429.711 78.3495 432.363 85.4227 435.016 92.4959C437.668 99.5691 440.272 106.642 442.826 113.715L457.856 71.2763H480.255L448.425 151.734C446.068 157.53 441.991 162.491 436.195 166.617C430.398 170.841 424.209 173.739 417.627 175.311L410.112 157.776C413.649 156.4 417.333 154.681 421.164 152.618C424.995 150.555 427.5 148.05 428.679 145.103Z" fill="#101412"/>
<path d="M508.208 142.745L492.371 126.907V50.9472L508.208 35.1094H555.953L571.867 50.9472V75.7034H550.109V61.557L545.42 56.8672H518.818L514.128 61.557V116.297L518.818 120.987H545.42L550.109 116.297V102.151H571.867V126.907L555.953 142.745H508.208Z" fill="#101412"/>
<path d="M588.792 142.745V50.9472L604.63 35.1094H654.296L670.134 50.9472V142.745H648.453V111.069H610.473V142.745H588.792ZM610.473 89.3885H648.453V61.557L643.763 56.8672H615.163L610.473 61.557V89.3885Z" fill="#101412"/>
<path d="M688.517 142.745V35.1094H752.561L768.399 50.9472V126.907L752.561 142.745H688.517ZM710.275 120.987H742.028L746.718 116.297V61.557L742.028 56.8672H710.275V120.987Z" fill="#101412"/>
<svg width="123" height="29" viewBox="0 0 123 29" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.52 26.04V25.2H0.84V24.36H0V6.72H0.84V5.88H1.68V5.04H2.52V4.2H3.36V3.36H17.64V4.2H18.48V5.04H19.32V5.88H20.16V6.72H21V24.36H20.16V25.2H18.48V26.04H15.96V26.88H16.8V28.56H11.76V26.88H12.6V26.04H8.4V26.88H9.24V28.56H4.2V26.88H5.04V26.04H2.52Z" fill="#101412"/>
<path d="M5.04 26.04V24.78H8.4V26.04H7.56V26.88H8.4V27.72H5.04V26.88H5.88V26.04H5.04Z" fill="#4B4862"/>
<path d="M12.6 26.04V24.78H15.96V26.04H15.12V26.88H15.96V27.72H12.6V26.88H13.44V26.04H12.6Z" fill="#4B4862"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.839996 20.58V24.36H2.52V25.2H18.48V24.36H20.16V20.58H0.839996ZM7.56 24.36V22.68H13.44V24.36H7.56Z" fill="#9BADB7"/>
<path d="M0.839996 21V19.32H20.16V21H0.839996Z" fill="#BECAD0"/>
<path d="M1.68 22.68V21.84H5.88V22.68H1.68Z" fill="#2B3E48"/>
<path d="M3.36 24.36V23.52H5.88V24.36H3.36Z" fill="#2B3E48"/>
<path d="M15.12 22.68V21.84H15.96V22.68H15.12Z" fill="#2B3E48"/>
<path d="M16.8 22.68V21.84H17.64V22.68H16.8Z" fill="#2B3E48"/>
<path d="M18.48 22.68V21.84H19.32V22.68H18.48Z" fill="#2B3E48"/>
<path d="M15.12 24.36V23.52H17.64V24.36H15.12Z" fill="#2B3E48"/>
<path d="M18.48 5.88V5.04H17.64V4.2H3.36V5.04H2.52V5.88H1.68V6.72H0.839996V8.4H20.16V6.72H19.32V5.88H18.48Z" fill="#FBF580"/>
<path d="M0.839996 20.16V18.48H20.16V20.16H0.839996Z" fill="#AEAA4C"/>
<path d="M20.16 7.56H0.839996V19.32H20.16V7.56Z" fill="#E5E3A1"/>
<path d="M3.36 10.08V9.24001H17.64V10.08H18.48V16.8H17.64V17.64H3.36V16.8H2.52V10.08H3.36Z" fill="#1F2320"/>
<rect x="8.4" y="4.2" width="4.2" height="1.68" fill="#AEAA4C"/>
<path d="M13.44 10.92V10.08H15.12V13.44H14.28L13.44 10.92Z" fill="#DBFF3C"/>
<path d="M9.24 13.44H11.76V14.28H10.92V15.96H13.44V15.12H14.28V15.96H13.44V16.8H7.56V15.96H6.72V15.12H7.56V15.96H10.08V14.28H9.24V13.44Z" fill="#DBFF3C"/>
<path d="M7.56 12.6V11.76H5.04V12.6H4.2V11.76H5.04V10.92H7.56V11.76H8.4V12.6H7.56Z" fill="#DBFF3C"/>
<path d="M3.36 5.88V5.04H4.2V3.36H5.04V1.68H5.88V0.839996H6.72V1.68H7.56V3.36H8.4V5.04H9.24V5.88H3.36Z" fill="#DBFF3C"/>
<path d="M17.64 5.04V5.88H11.76V5.04H12.6V3.36H13.44V1.68H14.28V0.839996H15.12V1.68H15.96V3.36H16.8V5.04H17.64Z" fill="#DBFF3C"/>
<path d="M13.44 1.68V0H15.96V1.68H16.8V3.36H17.64V4.62H16.8V3.36H15.96V1.68H15.12V0.84H14.28V1.68H13.44V3.36H12.6V4.62H11.76V3.36H12.6V1.68H13.44Z" fill="#92C51B"/>
<path d="M5.04 1.68V0H7.56V1.68H8.4V3.36H9.24V4.62H8.4V3.36H7.56V1.68H6.72V0.84H5.88V1.68H5.04V3.36H4.2V4.62H3.36V3.36H4.2V1.68H5.04Z" fill="#92C51B"/>
<rect x="9.24" y="5.03999" width="2.52" height="0.84" fill="#D0CC6A"/>
<path d="M13.44 5.88V5.04H14.28V4.2H15.12V5.04H15.96V5.88H13.44Z" fill="#76AA1D"/>
<path d="M5.88 5.04V4.2H6.72V5.04H7.56V5.88H5.04V5.04H5.88Z" fill="#76AA1D"/>
<path d="M17.64 5.88V5.04H16.8V4.2H17.64V5.04H18.48V5.88H17.64Z" fill="#76AA1D"/>
<path d="M3.36 5.04V5.88H2.52V5.04H3.36V4.2H4.2V5.04H3.36Z" fill="#76AA1D"/>
<path d="M8.4 4.2H9.24V5.04H10.08V5.88H9.24V5.04H8.4V4.2Z" fill="#76AA1D"/>
<path d="M11.76 4.2H12.6V5.04H11.76V5.88H10.92V5.04H11.76V4.2Z" fill="#76AA1D"/>
<path d="M14.28 10.92H13.44V13.44H14.28V10.92Z" fill="#92C51B"/>
<path d="M1.68 21V19.32H0.839996V21H1.68Z" fill="#D0CC6A"/>
<path d="M19.32 21V19.32H20.16V21H19.32Z" fill="#D0CC6A"/>
<path d="M3.36 20.16V19.32H5.88V20.16H3.36Z" fill="#D56161"/>
<path d="M3.36 21V20.16H5.88V21H3.36Z" fill="#AC3232"/>
<path d="M29.991 6.64V12.827L35.994 6.295L38.547 8.227L33.694 13.471L40.157 22.28H35.902L31.394 15.932L29.991 17.45V22.28H26.61V6.64H29.991Z" fill="#08110D"/>
<path d="M44.6921 7.698C44.6921 8.21933 44.5005 8.664 44.1171 9.032C43.7491 9.38466 43.3045 9.561 42.7831 9.561C42.2618 9.561 41.8248 9.38466 41.4721 9.032C41.1195 8.664 40.9431 8.21933 40.9431 7.698C40.9431 7.17666 41.1195 6.732 41.4721 6.364C41.8248 5.996 42.2618 5.812 42.7831 5.812C43.3045 5.812 43.7491 5.996 44.1171 6.364C44.5005 6.732 44.6921 7.17666 44.6921 7.698ZM44.4851 11.125V22.28H41.1041V11.125H44.4851Z" fill="#08110D"/>
<path d="M51.9943 22.671C50.6143 22.671 49.4719 22.3183 48.5673 21.613C47.6779 20.9077 47.2333 19.8727 47.2333 18.508V13.908H45.6463V11.286H47.2333V8.526L50.5913 7.974V11.286H52.8453L53.5813 13.908H50.5913V18.002C50.5913 18.5387 50.7293 18.9833 51.0053 19.336C51.2813 19.6733 51.6799 19.842 52.2013 19.842C52.4006 19.842 52.6153 19.8113 52.8453 19.75C53.0753 19.6887 53.3053 19.6043 53.5353 19.497L54.5243 21.889C54.2636 22.1037 53.8803 22.2877 53.3743 22.441C52.8683 22.5943 52.4083 22.671 51.9943 22.671Z" fill="#08110D"/>
<path d="M60.8106 22.671C59.4306 22.671 58.2883 22.3183 57.3836 21.613C56.4943 20.9077 56.0496 19.8727 56.0496 18.508V13.908H54.4626V11.286H56.0496V8.526L59.4076 7.974V11.286H61.6616L62.3976 13.908H59.4076V18.002C59.4076 18.5387 59.5456 18.9833 59.8216 19.336C60.0976 19.6733 60.4963 19.842 61.0176 19.842C61.217 19.842 61.4316 19.8113 61.6616 19.75C61.8916 19.6887 62.1216 19.6043 62.3516 19.497L63.3406 21.889C63.08 22.1037 62.6966 22.2877 62.1906 22.441C61.6846 22.5943 61.2246 22.671 60.8106 22.671Z" fill="#08110D"/>
<path d="M66.936 22.648L67.327 21.705L63.279 11.539L66.683 11.125C67.097 12.229 67.511 13.333 67.925 14.437C68.339 15.541 68.7453 16.645 69.144 17.749L71.49 11.125H74.986L70.018 23.683C69.65 24.5877 69.0137 25.362 68.109 26.006C67.2043 26.6653 66.2383 27.1177 65.211 27.363L64.038 24.626C64.59 24.4113 65.165 24.143 65.763 23.821C66.361 23.499 66.752 23.108 66.936 22.648Z" fill="#08110D"/>
<path d="M79.3491 22.28L76.8771 19.808V7.952L79.3491 5.48H86.8011L89.2851 7.952V11.816H85.8891V9.608L85.1571 8.876H81.0051L80.2731 9.608V18.152L81.0051 18.884H85.1571L85.8891 18.152V15.944H89.2851V19.808L86.8011 22.28H79.3491Z" fill="#08110D"/>
<path d="M91.9268 22.28V7.952L94.3988 5.48H102.151L104.623 7.952V22.28H101.239V17.336H95.3108V22.28H91.9268ZM95.3108 13.952H101.239V9.608L100.507 8.876H96.0428L95.3108 9.608V13.952Z" fill="#08110D"/>
<path d="M107.492 22.28V5.48H117.488L119.96 7.952V19.808L117.488 22.28H107.492ZM110.888 18.884H115.844L116.576 18.152V9.608L115.844 8.876H110.888V18.884Z" fill="#08110D"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 36 KiB

13
src-tauri/Cargo.lock generated
View File

@ -81,6 +81,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-fs-extra",
"tokio",
"toml 0.6.0",
]
@ -3120,6 +3121,18 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin-fs-extra"
version = "0.0.0"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#7e58dc8502f654b99d51c087421f84ccc0e03119"
dependencies = [
"log",
"serde",
"serde_json",
"tauri",
"thiserror",
]
[[package]]
name = "tauri-runtime"
version = "0.13.0"

View File

@ -19,9 +19,10 @@ anyhow = "1"
oauth2 = "4.4.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tauri = { version = "1.3.0", features = ["dialog-all", "fs-all", "http-request", "shell-open", "shell-open-api"] }
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" }
[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

@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
tauri_build::build()
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

@ -98,6 +98,7 @@ fn main() {
Ok(())
})
.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.0.3"
"productName": "kittycad-modeling-app",
"version": "0.0.4"
},
"tauri": {
"allowlist": {
@ -23,7 +23,8 @@
},
"fs": {
"scope": [
"$HOME/**/*"
"$HOME/**/*",
"$APPDATA/**/*"
],
"all": true
},
@ -37,6 +38,9 @@
},
"shell": {
"open": true
},
"path": {
"all": true
}
},
"bundle": {
@ -54,7 +58,7 @@
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "KittyCAD-modeling-app",
"identifier": "io.kittycad.modeling-app",
"longDescription": "",
"macOS": {
"entitlements": null,
@ -76,7 +80,12 @@
"csp": null
},
"updater": {
"active": false
"active": true,
"endpoints": [
"https://dl.kittycad.io/releases/modeling-app/last_update.json"
],
"dialog": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUzNzA4MjBEQjFBRTY4NzYKUldSMmFLNnhEWUp3NCtsT21Jd05wQktOaGVkOVp6MUFma0hNTDRDSnI2RkJJTEZOWG1ncFhqcU8K"
},
"windows": [
{

View File

@ -1,6 +1,8 @@
import { render, screen } from '@testing-library/react'
import { App } from './App'
import { describe, test, vi } from 'vitest'
import { BrowserRouter } from 'react-router-dom'
import { GlobalStateProvider } from './hooks/useAuthMachine'
let listener: ((rect: any) => void) | undefined = undefined
;(global as any).ResizeObserver = class ResizeObserver {
@ -12,12 +14,36 @@ let listener: ((rect: any) => void) | undefined = undefined
disconnect() {}
}
test('renders learn react link', () => {
render(
describe('App tests', () => {
test('Renders the modeling app screen, including "Variables" pane.', () => {
vi.mock('react-router-dom', async () => {
const actual = (await vi.importActual('react-router-dom')) as Record<
string,
any
>
return {
...actual,
useParams: () => ({ id: 'new' }),
useLoaderData: () => ({ code: null }),
}
})
render(
<TestWrap>
<App />
</TestWrap>
)
const linkElement = screen.getByText(/Variables/i)
expect(linkElement).toBeInTheDocument()
vi.restoreAllMocks()
})
})
function TestWrap({ children }: { children: React.ReactNode }) {
// wrap in router and xState context
return (
<BrowserRouter>
<App />
<GlobalStateProvider>{children}</GlobalStateProvider>
</BrowserRouter>
)
const linkElement = screen.getByText(/Variables/i)
expect(linkElement).toBeInTheDocument()
})
}

View File

@ -8,8 +8,7 @@ import {
} from 'react'
import { DebugPanel } from './components/DebugPanel'
import { v4 as uuidv4 } from 'uuid'
import { asyncLexer } from './lang/tokeniser'
import { abstractSyntaxTree } from './lang/abstractSyntaxTree'
import { asyncParser } from './lang/abstractSyntaxTree'
import { _executor } from './lang/executor'
import CodeMirror from '@uiw/react-codemirror'
import { langs } from '@uiw/codemirror-extensions-langs'
@ -43,8 +42,17 @@ 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, useParams } from 'react-router-dom'
import { writeTextFile } from '@tauri-apps/api/fs'
import { PROJECT_ENTRYPOINT } from './lib/tauriFS'
import { IndexLoaderData } from './Router'
import { toast } from 'react-hot-toast'
import { useAuthMachine } from './hooks/useAuthMachine'
export function App() {
const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
const pathParams = useParams()
const streamRef = useRef<HTMLDivElement>(null)
useHotKeyListener()
const {
@ -72,16 +80,15 @@ export function App() {
setIsStreamReady,
isStreamReady,
isMouseDownInStream,
fileId,
cmdId,
setCmdId,
token,
formatCode,
debugPanel,
theme,
openPanes,
setOpenPanes,
onboardingStatus,
didDragInStream,
setDidDragInStream,
setStreamDimensions,
streamDimensions,
@ -112,10 +119,8 @@ export function App() {
isStreamReady: s.isStreamReady,
setIsStreamReady: s.setIsStreamReady,
isMouseDownInStream: s.isMouseDownInStream,
fileId: s.fileId,
cmdId: s.cmdId,
setCmdId: s.setCmdId,
token: s.token,
formatCode: s.formatCode,
debugPanel: s.debugPanel,
addKCLError: s.addKCLError,
@ -123,10 +128,12 @@ export function App() {
openPanes: s.openPanes,
setOpenPanes: s.setOpenPanes,
onboardingStatus: s.onboardingStatus,
didDragInStream: s.didDragInStream,
setDidDragInStream: s.setDidDragInStream,
setStreamDimensions: s.setStreamDimensions,
streamDimensions: s.streamDimensions,
}))
const [token] = useAuthMachine((s) => s?.context?.token)
const editorTheme = theme === Themes.System ? getSystemTheme() : theme
@ -147,13 +154,38 @@ export function App() {
const paneOpacity =
onboardingStatus === 'camera'
? 'opacity-20'
: isMouseDownInStream
: didDragInStream
? 'opacity-40'
: ''
// Use file code loaded from disk
// on mount, and overwrite any locally-stored code
useEffect(() => {
if (isTauri() && loadedCode !== null) {
setCode(loadedCode)
}
return () => {
// Clear code on unmount if in desktop app
if (isTauri()) {
setCode('')
}
}
}, [loadedCode, setCode])
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
const onChange = (value: string, viewUpdate: ViewUpdate) => {
setCode(value)
if (isTauri() && pathParams.id) {
// Save the file to disk
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
writeTextFile(pathParams.id + '/' + PROJECT_ENTRYPOINT, value).catch(
(err) => {
// TODO: add Sentry per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254)
console.error('error saving file', err)
toast.error('Error saving file, please check file permissions')
}
)
}
if (editorView) {
editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
}
@ -250,8 +282,7 @@ export function App() {
setAst(null)
return
}
const tokens = await asyncLexer(code)
const _ast = abstractSyntaxTree(tokens)
const _ast = await asyncParser(code)
setAst(_ast)
resetLogs()
resetKCLErrors()
@ -349,8 +380,11 @@ export function App() {
clientX,
clientY,
ctrlKey,
shiftKey,
currentTarget,
nativeEvent,
}) => {
nativeEvent.preventDefault()
if (isMouseDownInStream) {
setDidDragInStream(true)
}
@ -362,7 +396,7 @@ export function App() {
...streamDimensions,
})
const interaction = ctrlKey ? 'pan' : 'rotate'
const interaction = ctrlKey ? 'zoom' : shiftKey ? 'pan' : 'rotate'
const newCmdId = uuidv4()
setCmdId(newCmdId)
@ -376,7 +410,6 @@ export function App() {
window: { x, y },
},
cmd_id: newCmdId,
file_id: fileId,
})
} else {
debounceSocketSend({
@ -386,7 +419,6 @@ export function App() {
selected_at_window: { x, y },
},
cmd_id: newCmdId,
file_id: fileId,
})
}
}
@ -403,7 +435,7 @@ export function App() {
return (
<div
className="h-screen overflow-hidden relative flex flex-col"
className="h-screen overflow-hidden relative flex flex-col cursor-pointer select-none"
onMouseMove={handleMouseMove}
ref={streamRef}
>
@ -413,6 +445,8 @@ export function App() {
paneOpacity +
(isMouseDownInStream ? ' pointer-events-none' : '')
}
project={project}
enableMenu={true}
/>
<ModalContainer />
<Resizable

View File

@ -1,38 +1,11 @@
import useSWR from 'swr'
import fetcher from './lib/fetcher'
import withBaseUrl from './lib/withBaseURL'
import { User, useStore } from './useStore'
import { useNavigate } from 'react-router-dom'
import { useEffect } from 'react'
import { isTauri } from './lib/isTauri'
import Loading from './components/Loading'
import { paths } from './Router'
import { useAuthMachine } from './hooks/useAuthMachine'
// Wrapper around protected routes, used in src/Router.tsx
export const Auth = ({ children }: React.PropsWithChildren) => {
const { data: user, isLoading } = useSWR<
User | Partial<{ error_code: string }>
>(withBaseUrl('/user'), fetcher)
const { token, setUser } = useStore((s) => ({
token: s.token,
setUser: s.setUser,
}))
const navigate = useNavigate()
const [isLoggedIn] = useAuthMachine((s) => s.matches('checkIfLoggedIn'))
useEffect(() => {
if (user && 'id' in user) setUser(user)
}, [user, setUser])
useEffect(() => {
if (
(isTauri() && !token) ||
(!isTauri() && !isLoading && !(user && 'id' in user))
) {
navigate(paths.SIGN_IN)
}
}, [user, token, navigate, isLoading])
return isLoading ? (
return isLoggedIn ? (
<Loading>Loading KittyCAD Modeling App...</Loading>
) : (
<>{children}</>

View File

@ -13,6 +13,18 @@ import Onboarding, {
} from './routes/Onboarding'
import SignIn from './routes/SignIn'
import { Auth } from './Auth'
import { isTauri } from './lib/isTauri'
import Home from './routes/Home'
import { FileEntry, readDir, readTextFile } from '@tauri-apps/api/fs'
import makeUrlPathRelative from './lib/makeUrlPathRelative'
import {
initializeProjectDirectory,
isProjectDirectory,
PROJECT_ENTRYPOINT,
} from './lib/tauriFS'
import { metadata, type Metadata } from 'tauri-plugin-fs-extra-api'
import DownloadAppBanner from './components/DownloadAppBanner'
import { GlobalStateProvider } from './hooks/useAuthMachine'
const prependRoutes =
(routesObject: Record<string, string>) => (prepend: string) => {
@ -26,62 +38,163 @@ const prependRoutes =
export const paths = {
INDEX: '/',
HOME: '/home',
FILE: '/file',
SETTINGS: '/settings',
SIGN_IN: '/signin',
ONBOARDING: prependRoutes(onboardingPaths)(
'/onboarding/'
'/onboarding'
) as typeof onboardingPaths,
}
const router = createBrowserRouter([
{
path: paths.INDEX,
element: (
<Auth>
<Outlet />
<App />
</Auth>
),
errorElement: <ErrorPage />,
loader: ({ request }) => {
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
export type IndexLoaderData = {
code: string | null
project?: ProjectWithEntryPointMetadata
}
if (shouldRedirectToOnboarding) {
return redirect(paths.ONBOARDING.INDEX + status)
export type ProjectWithEntryPointMetadata = FileEntry & {
entrypoint_metadata: Metadata
}
export type HomeLoaderData = {
projects: ProjectWithEntryPointMetadata[]
}
type CreateBrowserRouterArg = Parameters<typeof createBrowserRouter>[0]
const addGlobalContextToElements = (
routes: CreateBrowserRouterArg
): CreateBrowserRouterArg =>
routes.map((route) =>
'element' in route
? {
...route,
element: <GlobalStateProvider>{route.element}</GlobalStateProvider>,
}
}
return null
: route
)
const router = createBrowserRouter(
addGlobalContextToElements([
{
path: paths.INDEX,
loader: () =>
isTauri() ? redirect(paths.HOME) : redirect(paths.FILE + '/new'),
},
children: [
{
path: paths.SETTINGS,
element: <Settings />,
{
path: paths.FILE + '/:id',
element: (
<Auth>
<Outlet />
<App />
{!isTauri() && import.meta.env.PROD && <DownloadAppBanner />}
</Auth>
),
errorElement: <ErrorPage />,
id: paths.FILE,
loader: async ({
request,
params,
}): Promise<IndexLoaderData | Response> => {
const store = localStorage.getItem('store')
if (store === null) {
return redirect(paths.ONBOARDING.INDEX)
} else {
const status = JSON.parse(store).state.onboardingStatus || ''
const notEnRouteToOnboarding =
!request.url.includes(paths.ONBOARDING.INDEX) &&
request.method === 'GET'
// '' is the initial state, 'done' and 'dismissed' are the final states
const hasValidOnboardingStatus =
(status !== undefined && status.length === 0) ||
!(status === 'done' || status === 'dismissed')
const shouldRedirectToOnboarding =
notEnRouteToOnboarding && hasValidOnboardingStatus
if (shouldRedirectToOnboarding) {
return redirect(
makeUrlPathRelative(paths.ONBOARDING.INDEX) + status
)
}
}
if (params.id && params.id !== 'new') {
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
const code = await readTextFile(params.id + '/' + PROJECT_ENTRYPOINT)
const entrypoint_metadata = await metadata(
params.id + '/' + PROJECT_ENTRYPOINT
)
const children = await readDir(params.id)
return {
code,
project: {
name: params.id.slice(params.id.lastIndexOf('/') + 1),
path: params.id,
children,
entrypoint_metadata,
},
}
}
return {
code: '',
}
},
{
path: paths.ONBOARDING.INDEX,
element: <Onboarding />,
children: onboardingRoutes,
children: [
{
path: makeUrlPathRelative(paths.SETTINGS),
element: <Settings />,
},
{
path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
element: <Onboarding />,
children: onboardingRoutes,
},
],
},
{
path: paths.HOME,
element: (
<Auth>
<Outlet />
<Home />
</Auth>
),
loader: async () => {
if (!isTauri()) {
return redirect(paths.FILE + '/new')
}
const projectDir = await initializeProjectDirectory()
const projectsNoMeta = (await readDir(projectDir.dir)).filter(
isProjectDirectory
)
const projects = await Promise.all(
projectsNoMeta.map(async (p) => ({
entrypoint_metadata: await metadata(
p.path + '/' + PROJECT_ENTRYPOINT
),
...p,
}))
)
return {
projects,
}
},
],
},
{
path: paths.SIGN_IN,
element: <SignIn />,
},
])
children: [
{
path: makeUrlPathRelative(paths.SETTINGS),
element: <Settings />,
},
],
},
{
path: paths.SIGN_IN,
element: <SignIn />,
},
])
)
/**
* All routes in the app, used in src/index.tsx

View File

@ -11,7 +11,6 @@ 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 { ExportButton } from './components/ExportButton'
export const Toolbar = () => {
const {
@ -32,7 +31,6 @@ export const Toolbar = () => {
return (
<div>
<ExportButton />
{guiMode.mode === 'default' && (
<button
onClick={() => {

View File

@ -1,52 +1,92 @@
import { Link } from 'react-router-dom'
import { ActionIcon, ActionIconProps } from './ActionIcon'
import React from 'react'
import { paths } from '../Router'
import { Link } from 'react-router-dom'
import type { LinkProps } from 'react-router-dom'
interface ActionButtonProps extends React.PropsWithChildren {
interface BaseActionButtonProps {
icon?: ActionIconProps
className?: string
onClick?: () => void
to?: string
Element?:
| 'button'
| 'link'
| React.ComponentType<React.HTMLAttributes<HTMLButtonElement>>
}
export const ActionButton = ({
icon,
className,
onClick,
to = paths.INDEX,
Element = 'button',
children,
...props
}: ActionButtonProps) => {
const classNames = `group mono text-base flex items-center gap-2 rounded-sm border border-chalkboard-40 dark:border-chalkboard-60 hover:border-liquid-40 dark:hover:bg-chalkboard-90 p-[3px] text-chalkboard-110 dark:text-chalkboard-10 hover:text-chalkboard-110 hover:dark:text-chalkboard-10 ${
icon ? 'pr-2' : 'px-2'
} ${className}`
type ActionButtonAsButton = BaseActionButtonProps &
Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
keyof BaseActionButtonProps
> & {
Element: 'button'
}
if (Element === 'button') {
return (
<button onClick={onClick} className={classNames} {...props}>
{icon && <ActionIcon {...icon} />}
{children}
</button>
)
} else if (Element === 'link') {
return (
<Link to={to} className={classNames} {...props}>
{icon && <ActionIcon {...icon} />}
{children}
</Link>
)
} else {
return (
<Element onClick={onClick} className={classNames} {...props}>
{icon && <ActionIcon {...icon} />}
{children}
</Element>
)
type ActionButtonAsLink = BaseActionButtonProps &
Omit<LinkProps, keyof BaseActionButtonProps> & {
Element: 'link'
}
type ActionButtonAsExternal = BaseActionButtonProps &
Omit<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
keyof BaseActionButtonProps
> & {
Element: 'externalLink'
}
type ActionButtonAsElement = BaseActionButtonProps &
Omit<React.HTMLAttributes<HTMLElement>, keyof BaseActionButtonProps> & {
Element: React.ComponentType<React.HTMLAttributes<HTMLButtonElement>>
}
type ActionButtonProps =
| ActionButtonAsButton
| ActionButtonAsLink
| ActionButtonAsExternal
| ActionButtonAsElement
export const ActionButton = (props: ActionButtonProps) => {
const classNames = `group mono text-base flex items-center gap-2 rounded-sm border border-chalkboard-40 dark:border-chalkboard-60 hover:border-liquid-40 dark:hover:bg-chalkboard-90 p-[3px] text-chalkboard-110 dark:text-chalkboard-10 hover:text-chalkboard-110 hover:dark:text-chalkboard-10 ${
props.icon ? 'pr-2' : 'px-2'
} ${props.className || ''}`
switch (props.Element) {
case 'button': {
// Note we have to destructure 'className' and 'Element' out of props
// because we don't want to pass them to the button element;
// the same is true for the other cases below.
const { Element, icon, children, className, ...rest } = props
return (
<button className={classNames} {...rest}>
{props.icon && <ActionIcon {...icon} />}
{children}
</button>
)
}
case 'link': {
const { Element, to, icon, children, className, ...rest } = props
return (
<Link to={to || paths.INDEX} className={classNames} {...rest}>
{icon && <ActionIcon {...icon} />}
{children}
</Link>
)
}
case 'externalLink': {
const { Element, icon, children, className, ...rest } = props
return (
<a className={classNames} {...rest}>
{icon && <ActionIcon {...icon} />}
{children}
</a>
)
}
default: {
const { Element, icon, children, className, ...rest } = props
if (!Element) throw new Error('Element is required')
return (
<Element className={classNames} {...rest}>
{props.icon && <ActionIcon {...props.icon} />}
{children}
</Element>
)
}
}
}

View File

@ -1,7 +1,8 @@
import {
IconDefinition,
IconDefinition as SolidIconDefinition,
faCircleExclamation,
} from '@fortawesome/free-solid-svg-icons'
import { IconDefinition as BrandIconDefinition } from '@fortawesome/free-brands-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
const iconSizes = {
@ -11,14 +12,14 @@ const iconSizes = {
}
export interface ActionIconProps extends React.PropsWithChildren {
icon?: IconDefinition
icon?: SolidIconDefinition | BrandIconDefinition
bgClassName?: string
iconClassName?: string
size?: keyof typeof iconSizes
}
export const ActionIcon = ({
icon,
icon = faCircleExclamation,
bgClassName,
iconClassName,
size = 'md',
@ -34,7 +35,7 @@ export const ActionIcon = ({
>
{children || (
<FontAwesomeIcon
icon={icon || faCircleExclamation}
icon={icon}
width={iconSizes[size]}
height={iconSizes[size]}
className={

View File

@ -1,38 +1,33 @@
import { Link } from 'react-router-dom'
import { Toolbar } from '../Toolbar'
import { useStore } from '../useStore'
import UserSidebarMenu from './UserSidebarMenu'
import { paths } from '../Router'
import { ProjectWithEntryPointMetadata } from '../Router'
import ProjectSidebarMenu from './ProjectSidebarMenu'
import { useAuthMachine } from '../hooks/useAuthMachine'
interface AppHeaderProps extends React.PropsWithChildren {
showToolbar?: boolean
project?: ProjectWithEntryPointMetadata
className?: string
enableMenu?: boolean
}
export const AppHeader = ({
showToolbar = true,
project,
children,
className = '',
enableMenu = false,
}: AppHeaderProps) => {
const { user } = useStore((s) => ({
user: s.user,
}))
const [user] = useAuthMachine((s) => s?.context?.user)
return (
<header
className={
'overlaid-panes sticky top-0 z-10 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 ' +
'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
}
>
<Link to={paths.INDEX}>
<img
src="/kitt-arcade-winking.svg"
alt="KittyCAD App"
className="h-9 w-auto"
/>
<span className="sr-only">KittyCAD App</span>
</Link>
<ProjectSidebarMenu renderAsLink={!enableMenu} project={project} />
{/* Toolbar if the context deems it */}
{showToolbar && (
<div className="max-w-4xl">

View File

@ -1,5 +1,5 @@
import { useEffect, useState, useRef } from 'react'
import { abstractSyntaxTree } from '../lang/abstractSyntaxTree'
import { parser_wasm } from '../lang/abstractSyntaxTree'
import { BinaryPart, Value } from '../lang/abstractSyntaxTreeTypes'
import { executor } from '../lang/executor'
import {
@ -9,7 +9,6 @@ import {
findUniqueName,
} from '../lang/modifyAst'
import { findAllPreviousVariables, PrevVariable } from '../lang/queryAst'
import { lexer } from '../lang/tokeniser'
import { useStore } from '../useStore'
export const AvailableVars = ({
@ -144,7 +143,7 @@ export function useCalc({
if (!engineCommandManager) return
try {
const code = `const __result__ = ${value}\nshow(__result__)`
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const _programMem: any = { root: {} }
availableVarInfo.variables.forEach(({ key, value }) => {
_programMem.root[key] = { type: 'userVal', value, __meta: [] }

View File

@ -7,7 +7,7 @@ import { ActionButton } from '../components/ActionButton'
import { faCheck } from '@fortawesome/free-solid-svg-icons'
type SketchModeCmd = Extract<
EngineCommand['cmd'],
Extract<EngineCommand, { type: 'modeling_cmd_req' }>['cmd'],
{ type: 'default_camera_enable_sketch_mode' }
>
@ -22,6 +22,7 @@ export const DebugPanel = ({ className, ...props }: CollapsiblePanelProps) => {
y_axis: { x: 0, y: 1, z: 0 },
distance_to_plane: 100,
ortho: true,
animated: true, // TODO #273 get prefers reduced motion from CSS
})
if (!sketchModeCmd) return null
return (
@ -73,12 +74,12 @@ export const DebugPanel = ({ className, ...props }: CollapsiblePanelProps) => {
/>
</div>
<ActionButton
Element="button"
onClick={() => {
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: sketchModeCmd,
cmd_id: uuidv4(),
file_id: uuidv4(),
})
}}
className="hover:border-succeed-50"

View File

@ -0,0 +1,57 @@
import { Dialog } from '@headlessui/react'
import { useStore } from '../useStore'
import { ActionButton } from './ActionButton'
import { faX } from '@fortawesome/free-solid-svg-icons'
const DownloadAppBanner = () => {
const { isBannerDismissed, setBannerDismissed } = useStore((s) => ({
isBannerDismissed: s.isBannerDismissed,
setBannerDismissed: s.setBannerDismissed,
}))
return (
<Dialog
className="fixed inset-0 top-auto z-50 bg-warn-20 text-warn-80 px-8 py-4"
open={!isBannerDismissed}
onClose={() => ({})}
>
<Dialog.Panel className="max-w-3xl mx-auto">
<div className="flex gap-2 justify-between items-start">
<h2 className="text-xl font-bold mb-4">
KittyCAD Modeling App is better as a desktop app!
</h2>
<ActionButton
Element="button"
onClick={() => setBannerDismissed(true)}
icon={{
icon: faX,
bgClassName:
'bg-warn-70 hover:bg-warn-80 dark:bg-warn-70 dark:hover:bg-warn-80',
iconClassName:
'text-warn-10 group-hover:text-warn-10 dark:text-warn-10 dark:group-hover:text-warn-10',
}}
className="!p-0 !bg-transparent !border-transparent"
/>
</div>
<p>
The browser version of the app only saves your data temporarily in{' '}
<code className="text-base inline-block px-0.5 bg-warn-30/50 rounded">
localStorage
</code>
, and isn't backed up anywhere! Visit{' '}
<a
href="https://github.com/KittyCAD/modeling-app/releases"
rel="noopener noreferrer"
target="_blank"
className="text-warn-80 dark:text-warn-80 dark:hover:text-warn-70 underline"
>
our GitHub repository
</a>{' '}
to download the app for the best experience.
</p>
</Dialog.Panel>
</Dialog>
)
}
export default DownloadAppBanner

View File

@ -1,6 +1,6 @@
import { v4 as uuidv4 } from 'uuid'
import { useStore } from '../useStore'
import { faXmark } from '@fortawesome/free-solid-svg-icons'
import { faFileExport, faXmark } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from './ActionButton'
import Modal from 'react-modal'
import React from 'react'
@ -9,7 +9,15 @@ import { Models } from '@kittycad/lib'
type OutputFormat = Models['OutputFormat_type']
export const ExportButton = () => {
interface ExportButtonProps extends React.PropsWithChildren {
className?: {
button?: string
// If we wanted more classname configuration of sub-elements,
// put them here
}
}
export const ExportButton = ({ children, className }: ExportButtonProps) => {
const { engineCommandManager } = useStore((s) => ({
engineCommandManager: s.engineCommandManager,
}))
@ -19,17 +27,6 @@ export const ExportButton = () => {
const defaultType = 'gltf'
const [type, setType] = React.useState(defaultType)
const customModalStyles = {
content: {
top: '50%',
left: '50%',
right: 'auto',
bottom: 'auto',
marginRight: '-50%',
transform: 'translate(-50%, -50%)',
},
}
function openModal() {
setIsOpen(true)
}
@ -79,7 +76,6 @@ export const ExportButton = () => {
format: values,
},
cmd_id: uuidv4(),
file_id: uuidv4(),
})
closeModal()
@ -88,20 +84,26 @@ export const ExportButton = () => {
return (
<>
<button onClick={openModal}>Export</button>
<ActionButton
onClick={openModal}
Element="button"
icon={{ icon: faFileExport }}
className={className?.button}
>
{children || 'Export'}
</ActionButton>
<Modal
isOpen={modalIsOpen}
onRequestClose={closeModal}
contentLabel="Export"
style={customModalStyles}
overlayClassName="z-40 fixed inset-0 grid place-items-center"
className="rounded p-4 bg-chalkboard-10 dark:bg-chalkboard-100 border max-w-xl w-full"
>
<div className="text-black">
<h1 className="text-2xl font-bold">Export your design</h1>
<form onSubmit={formik.handleSubmit}>
<p>
<label htmlFor="type">Type</label>
</p>
<p>
<h1 className="text-2xl font-bold">Export your design</h1>
<form onSubmit={formik.handleSubmit}>
<div className="flex flex-wrap justify-between gap-8 items-center w-full my-8">
<label htmlFor="type" className="flex-1">
<p className="mb-2">Type</p>
<select
id="type"
name="type"
@ -109,6 +111,7 @@ export const ExportButton = () => {
setType(e.target.value)
formik.handleChange(e)
}}
className="bg-chalkboard-20 dark:bg-chalkboard-90 w-full"
>
<option value="gltf">gltf</option>
<option value="obj">obj</option>
@ -116,56 +119,49 @@ export const ExportButton = () => {
<option value="step">step</option>
<option value="stl">stl</option>
</select>
</p>
</label>
{(type === 'gltf' || type === 'ply' || type === 'stl') && (
<>
<p>
{' '}
<label htmlFor="storage">Storage</label>
</p>
<p>
<select
id="storage"
name="storage"
onChange={formik.handleChange}
value={formik.values.storage}
>
{type === 'gltf' && (
<>
<option value="embedded">embedded</option>
<option value="binary">binary</option>
<option value="standard">standard</option>
</>
)}
{type === 'ply' && (
<>
<option value="ascii">ascii</option>
<option value="binary">binary</option>
</>
)}
{type === 'stl' && (
<>
<option value="ascii">ascii</option>
<option value="binary_little_endian">
binary_little_endian
</option>
<option value="binary_big_endian">
binary_big_endian
</option>
</>
)}
</select>
</p>
</>
<label htmlFor="storage" className="flex-1">
<p className="mb-2">Storage</p>
<select
id="storage"
name="storage"
onChange={formik.handleChange}
value={formik.values.storage}
className="bg-chalkboard-20 dark:bg-chalkboard-90 w-full"
>
{type === 'gltf' && (
<>
<option value="embedded">embedded</option>
<option value="binary">binary</option>
<option value="standard">standard</option>
</>
)}
{type === 'ply' && (
<>
<option value="ascii">ascii</option>
<option value="binary">binary</option>
</>
)}
{type === 'stl' && (
<>
<option value="ascii">ascii</option>
<option value="binary_little_endian">
binary_little_endian
</option>
<option value="binary_big_endian">
binary_big_endian
</option>
</>
)}
</select>
</label>
)}
</div>
<div className="flex justify-between mt-6">
<button type="submit">Submit</button>
</div>
</form>
<div className="flex justify-between mt-6">
<ActionButton
Element="button"
onClick={closeModal}
icon={{
icon: faXmark,
@ -177,8 +173,15 @@ export const ExportButton = () => {
>
Close
</ActionButton>
<ActionButton
Element="button"
type="submit"
icon={{ icon: faFileExport }}
>
Export
</ActionButton>
</div>
</div>
</form>
</Modal>
</>
)

View File

@ -1,6 +1,5 @@
import { processMemory } from './MemoryPanel'
import { lexer } from '../lang/tokeniser'
import { abstractSyntaxTree } from '../lang/abstractSyntaxTree'
import { parser_wasm } from '../lang/abstractSyntaxTree'
import { enginelessExecutor } from '../lib/testHelpers'
import { initPromise } from '../lang/rust'
@ -27,8 +26,7 @@ describe('processMemory', () => {
|> lineTo([2.15, 4.32], %)
// |> rx(90, %)
show(theExtrude, theSketch)`
const tokens = lexer(code)
const ast = abstractSyntaxTree(tokens)
const ast = parser_wasm(code)
const programMemory = await enginelessExecutor(ast, {
root: {
log: {

View File

@ -0,0 +1,162 @@
import { FormEvent, useState } from 'react'
import { type ProjectWithEntryPointMetadata, paths } from '../Router'
import { Link } from 'react-router-dom'
import { ActionButton } from './ActionButton'
import {
faCheck,
faPenAlt,
faTrashAlt,
faX,
} from '@fortawesome/free-solid-svg-icons'
import { FILE_EXT } from '../lib/tauriFS'
import { Dialog } from '@headlessui/react'
import { useHotkeys } from 'react-hotkeys-hook'
function ProjectCard({
project,
handleRenameProject,
handleDeleteProject,
...props
}: {
project: ProjectWithEntryPointMetadata
handleRenameProject: (
e: FormEvent<HTMLFormElement>,
f: ProjectWithEntryPointMetadata
) => Promise<void>
handleDeleteProject: (f: ProjectWithEntryPointMetadata) => Promise<void>
}) {
useHotkeys('esc', () => setIsEditing(false))
const [isEditing, setIsEditing] = useState(false)
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
function handleSave(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
handleRenameProject(e, project).then(() => setIsEditing(false))
}
function getDisplayedTime(date: Date) {
const startOfToday = new Date()
startOfToday.setHours(0, 0, 0, 0)
return date.getTime() < startOfToday.getTime()
? date.toLocaleDateString()
: date.toLocaleTimeString()
}
return (
<li
{...props}
className="group relative min-h-[5em] p-1 rounded-sm border border-chalkboard-20 dark:border-chalkboard-90 hover:border-chalkboard-30 dark:hover:border-chalkboard-80"
>
{isEditing ? (
<form onSubmit={handleSave} className="flex gap-2 items-center">
<input
className="dark:bg-chalkboard-80 dark:border-chalkboard-40 min-w-0 p-1"
type="text"
id="newProjectName"
name="newProjectName"
autoCorrect="off"
autoCapitalize="off"
defaultValue={project.name}
autoFocus={true}
/>
<div className="flex gap-1 items-center">
<ActionButton
Element="button"
type="submit"
icon={{ icon: faCheck, size: 'sm' }}
className="!p-0"
></ActionButton>
<ActionButton
Element="button"
icon={{ icon: faX, size: 'sm' }}
className="!p-0"
onClick={() => setIsEditing(false)}
/>
</div>
</form>
) : (
<>
<div className="p-1 flex flex-col gap-2">
<Link
to={`${paths.FILE}/${encodeURIComponent(project.path)}`}
className="flex-1 text-liquid-100"
>
{project.name?.replace(FILE_EXT, '')}
</Link>
<span className="text-chalkboard-60 text-xs">
Edited {getDisplayedTime(project.entrypoint_metadata.modifiedAt)}
</span>
<div className="absolute bottom-2 right-2 flex gap-1 items-center opacity-0 group-hover:opacity-100 group-focus-within:opacity-100">
<ActionButton
Element="button"
icon={{ icon: faPenAlt, size: 'sm' }}
onClick={() => setIsEditing(true)}
className="!p-0"
/>
<ActionButton
Element="button"
icon={{
icon: faTrashAlt,
size: 'sm',
bgClassName: 'bg-destroy-80 hover:bg-destroy-70',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10',
}}
className="!p-0 hover:border-destroy-40 dark:hover:border-destroy-40"
onClick={() => setIsConfirmingDelete(true)}
/>
</div>
</div>
<Dialog
open={isConfirmingDelete}
onClose={() => setIsConfirmingDelete(false)}
className="relative z-50"
>
<div className="fixed inset-0 bg-chalkboard-110/80 grid place-content-center">
<Dialog.Panel className="rounded p-4 bg-chalkboard-10 dark:bg-chalkboard-100 border border-destroy-80 max-w-2xl">
<Dialog.Title as="h2" className="text-2xl font-bold mb-4">
Delete File
</Dialog.Title>
<Dialog.Description>
This will permanently delete "{project.name || 'this file'}".
</Dialog.Description>
<p className="my-4">
Are you sure you want to delete "{project.name || 'this file'}
"? This action cannot be undone.
</p>
<div className="flex justify-between">
<ActionButton
Element="button"
onClick={async () => {
await handleDeleteProject(project)
setIsConfirmingDelete(false)
}}
icon={{
icon: faTrashAlt,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10',
}}
className="hover:border-destroy-40 dark:hover:border-destroy-40"
>
Delete
</ActionButton>
<ActionButton
Element="button"
onClick={() => setIsConfirmingDelete(false)}
>
Cancel
</ActionButton>
</div>
</Dialog.Panel>
</div>
</Dialog>
</>
)}
</li>
)
}
export default ProjectCard

View File

@ -0,0 +1,81 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import ProjectSidebarMenu from './ProjectSidebarMenu'
import { ProjectWithEntryPointMetadata } from '../Router'
const now = new Date()
const projectWellFormed = {
name: 'Simple Box',
path: '/some/path/Simple Box',
children: [
{
name: 'main.kcl',
path: '/some/path/Simple Box/main.kcl',
},
],
entrypoint_metadata: {
accessedAt: now,
blksize: 32,
blocks: 32,
createdAt: now,
dev: 1,
gid: 1,
ino: 1,
isDir: false,
isFile: true,
isSymlink: false,
mode: 1,
modifiedAt: now,
nlink: 1,
permissions: { readonly: false, mode: 1 },
rdev: 1,
size: 32,
uid: 1,
},
} satisfies ProjectWithEntryPointMetadata
describe('ProjectSidebarMenu tests', () => {
test('Renders the project name', () => {
render(
<BrowserRouter>
<ProjectSidebarMenu project={projectWellFormed} />
</BrowserRouter>
)
fireEvent.click(screen.getByTestId('project-sidebar-toggle'))
expect(screen.getByTestId('projectName')).toHaveTextContent(
projectWellFormed.name
)
expect(screen.getByTestId('createdAt')).toHaveTextContent(
`Created ${now.toLocaleDateString()}`
)
})
test('Renders app name if given no project', () => {
render(
<BrowserRouter>
<ProjectSidebarMenu />
</BrowserRouter>
)
fireEvent.click(screen.getByTestId('project-sidebar-toggle'))
expect(screen.getByTestId('projectName')).toHaveTextContent(
'KittyCAD Modeling App'
)
})
test('Renders as a link if set to do so', () => {
render(
<BrowserRouter>
<ProjectSidebarMenu project={projectWellFormed} renderAsLink={true} />
</BrowserRouter>
)
expect(screen.getByTestId('project-sidebar-link')).toBeInTheDocument()
expect(screen.getByTestId('project-sidebar-link-name')).toHaveTextContent(
projectWellFormed.name
)
})
})

View File

@ -0,0 +1,101 @@
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'
const ProjectSidebarMenu = ({
project,
renderAsLink = false,
}: {
renderAsLink?: boolean
project?: Partial<ProjectWithEntryPointMetadata>
}) => {
return renderAsLink ? (
<Link
to={'../'}
className="flex items-center gap-4 my-2"
data-testid="project-sidebar-link"
>
<img
src="/kitt-8bit-winking.svg"
alt="KittyCAD App"
className="h-9 w-auto"
/>
<span
className="text-sm text-chalkboard-110 dark:text-chalkboard-20 min-w-max"
data-testid="project-sidebar-link-name"
>
{project?.name ? project.name : 'KittyCAD Modeling App'}
</span>
</Link>
) : (
<Popover className="relative">
<Popover.Button
className="border-0 px-1 pr-2 pl-0 flex items-center gap-4 focus:outline-none focus:ring-2 focus:ring-energy-50"
data-testid="project-sidebar-toggle"
>
<img
src="/kitt-8bit-winking.svg"
alt="KittyCAD App"
className="h-9 w-auto"
/>
<span className="text-sm text-chalkboard-110 dark:text-chalkboard-20 min-w-max">
{isTauri() && project?.name ? project.name : 'KittyCAD Modeling App'}
</span>
</Popover.Button>
<Popover.Overlay className="fixed z-20 inset-0 bg-chalkboard-110/50" />
<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',
}}
>
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>
)
}
export default ProjectSidebarMenu

View File

@ -1,5 +1,5 @@
import { Dialog, Transition } from '@headlessui/react'
import { Fragment, useState } from 'react'
import { Fragment } from 'react'
import { useCalc, CreateNewVariable } from './AvailableVarsHelpers'
export const SetVarNameModal = ({

View File

@ -7,21 +7,16 @@ import {
} from 'react'
import { v4 as uuidv4 } from 'uuid'
import { useStore } from '../useStore'
import { throttle } from '../lib/utils'
import { EngineCommand } from '../lang/std/engineConnection'
import { getNormalisedCoordinates } from '../lib/utils'
import Loading from './Loading'
export const Stream = ({ className = '' }) => {
const [isLoading, setIsLoading] = useState(true)
const [zoom, setZoom] = useState(0)
const videoRef = useRef<HTMLVideoElement>(null)
const {
mediaStream,
engineCommandManager,
setIsMouseDownInStream,
fileId,
setFileId,
setCmdId,
didDragInStream,
setDidDragInStream,
@ -32,7 +27,6 @@ export const Stream = ({ className = '' }) => {
isMouseDownInStream: s.isMouseDownInStream,
setIsMouseDownInStream: s.setIsMouseDownInStream,
fileId: s.fileId,
setFileId: s.setFileId,
setCmdId: s.setCmdId,
didDragInStream: s.didDragInStream,
setDidDragInStream: s.setDidDragInStream,
@ -48,9 +42,7 @@ export const Stream = ({ className = '' }) => {
if (!videoRef.current) return
if (!mediaStream) return
videoRef.current.srcObject = mediaStream
setFileId(uuidv4())
setZoom(videoRef.current.getBoundingClientRect().height / 2)
}, [mediaStream, engineCommandManager, setFileId])
}, [mediaStream, engineCommandManager])
const handleMouseDown: MouseEventHandler<HTMLVideoElement> = ({
clientX,
@ -79,31 +71,21 @@ export const Stream = ({ className = '' }) => {
window: { x, y },
},
cmd_id: newId,
file_id: fileId,
})
setIsMouseDownInStream(true)
}
// TODO: consolidate this with the same function in App.tsx
const debounceSocketSend = throttle<EngineCommand>((message) => {
engineCommandManager?.sendSceneCommand(message)
}, 16)
const handleScroll: WheelEventHandler<HTMLVideoElement> = (e) => {
e.preventDefault()
debounceSocketSend({
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'camera_drag_move',
interaction: 'zoom',
window: { x: 0, y: zoom + e.deltaY },
type: 'default_camera_zoom',
magnitude: e.deltaY * 0.4,
},
cmd_id: uuidv4(),
file_id: uuidv4(),
})
setZoom(zoom + e.deltaY)
}
const handleMouseUp: MouseEventHandler<HTMLVideoElement> = ({
@ -130,7 +112,6 @@ export const Stream = ({ className = '' }) => {
window: { x, y },
},
cmd_id: newCmdId,
file_id: fileId,
})
setIsMouseDownInStream(false)
@ -143,7 +124,6 @@ export const Stream = ({ className = '' }) => {
selected_at_window: { x, y },
},
cmd_id: uuidv4(),
file_id: fileId,
})
}
setDidDragInStream(false)
@ -160,7 +140,7 @@ export const Stream = ({ className = '' }) => {
onMouseUp={handleMouseUp}
onContextMenu={(e) => e.preventDefault()}
onContextMenuCapture={(e) => e.preventDefault()}
onWheelCapture={handleScroll}
onWheel={handleScroll}
onPlay={() => setIsLoading(false)}
className="w-full h-full"
/>

View File

@ -13,13 +13,13 @@
}
.toggle > span {
@apply relative rounded border border-chalkboard-110;
@apply relative rounded border border-chalkboard-110 hover:border-chalkboard-100 cursor-pointer;
width: calc(2 * (var(--toggle-size) + var(--padding)));
height: calc(var(--toggle-size) + var(--padding));
}
:global(.dark) .toggle > span {
@apply border-chalkboard-40;
@apply border-chalkboard-40 hover:border-chalkboard-30;
}
.toggle > span::after {

View File

@ -1,66 +1,100 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { User } from '../useStore'
import UserSidebarMenu from './UserSidebarMenu'
import { BrowserRouter } from 'react-router-dom'
import { Models } from '@kittycad/lib'
import { GlobalStateProvider } from '../hooks/useAuthMachine'
it("Renders user's name and email if available", () => {
const userWellFormed: User = {
id: '8675309',
name: 'Test User',
email: 'kittycad.sidebar.test@example.com',
image: 'https://placekitten.com/200/200',
created_at: 'yesteryear',
updated_at: 'today',
}
type User = Models['User_type']
render(
<BrowserRouter>
<UserSidebarMenu user={userWellFormed} />
</BrowserRouter>
)
describe('UserSidebarMenu tests', () => {
test("Renders user's name and email if available", () => {
const userWellFormed: User = {
id: '8675309',
name: 'Test User',
email: 'kittycad.sidebar.test@example.com',
image: 'https://placekitten.com/200/200',
created_at: 'yesteryear',
updated_at: 'today',
company: 'Test Company',
discord: 'Test User#1234',
github: 'testuser',
phone: '555-555-5555',
first_name: 'Test',
last_name: 'User',
}
fireEvent.click(screen.getByTestId('user-sidebar-toggle'))
render(
<TestWrap>
<UserSidebarMenu user={userWellFormed} />
</TestWrap>
)
expect(screen.getByTestId('username')).toHaveTextContent(
userWellFormed.name || ''
)
expect(screen.getByTestId('email')).toHaveTextContent(userWellFormed.email)
fireEvent.click(screen.getByTestId('user-sidebar-toggle'))
expect(screen.getByTestId('username')).toHaveTextContent(
userWellFormed.name || ''
)
expect(screen.getByTestId('email')).toHaveTextContent(userWellFormed.email)
})
test("Renders just the user's email if no name is available", () => {
const userNoName: User = {
id: '8675309',
email: 'kittycad.sidebar.test@example.com',
image: 'https://placekitten.com/200/200',
created_at: 'yesteryear',
updated_at: 'today',
company: 'Test Company',
discord: 'Test User#1234',
github: 'testuser',
phone: '555-555-5555',
first_name: '',
last_name: '',
name: '',
}
render(
<TestWrap>
<UserSidebarMenu user={userNoName} />
</TestWrap>
)
fireEvent.click(screen.getByTestId('user-sidebar-toggle'))
expect(screen.getByTestId('username')).toHaveTextContent(userNoName.email)
})
test('Renders a menu button if no user avatar is available', () => {
const userNoAvatar: User = {
id: '8675309',
name: 'Test User',
email: 'kittycad.sidebar.test@example.com',
created_at: 'yesteryear',
updated_at: 'today',
company: 'Test Company',
discord: 'Test User#1234',
github: 'testuser',
phone: '555-555-5555',
first_name: 'Test',
last_name: 'User',
image: '',
}
render(
<TestWrap>
<UserSidebarMenu user={userNoAvatar} />
</TestWrap>
)
expect(screen.getByTestId('user-sidebar-toggle')).toHaveTextContent('Menu')
})
})
it("Renders just the user's email if no name is available", () => {
const userNoName: User = {
id: '8675309',
email: 'kittycad.sidebar.test@example.com',
image: 'https://placekitten.com/200/200',
created_at: 'yesteryear',
updated_at: 'today',
}
render(
function TestWrap({ children }: { children: React.ReactNode }) {
// wrap in router and xState context
return (
<BrowserRouter>
<UserSidebarMenu user={userNoName} />
<GlobalStateProvider>{children}</GlobalStateProvider>
</BrowserRouter>
)
fireEvent.click(screen.getByTestId('user-sidebar-toggle'))
expect(screen.getByTestId('username')).toHaveTextContent(userNoName.email)
})
it('Renders a menu button if no user avatar is available', () => {
const userNoAvatar: User = {
id: '8675309',
name: 'Test User',
email: 'kittycad.sidebar.test@example.com',
created_at: 'yesteryear',
updated_at: 'today',
}
render(
<BrowserRouter>
<UserSidebarMenu user={userNoAvatar} />
</BrowserRouter>
)
expect(screen.getByTestId('user-sidebar-toggle')).toHaveTextContent('Menu')
})
}

View File

@ -1,18 +1,21 @@
import { Popover } from '@headlessui/react'
import { User, useStore } from '../useStore'
import { ActionButton } from './ActionButton'
import { faBars, faGear, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
import { faGithub } from '@fortawesome/free-brands-svg-icons'
import { useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { paths } from '../Router'
import makeUrlPathRelative from '../lib/makeUrlPathRelative'
import { useAuthMachine } from '../hooks/useAuthMachine'
import { Models } from '@kittycad/lib'
type User = Models['User_type']
const UserSidebarMenu = ({ user }: { user?: User }) => {
const displayedName = getDisplayName(user)
const [imageLoadFailed, setImageLoadFailed] = useState(false)
const navigate = useNavigate()
const { setToken } = useStore((s) => ({
setToken: s.setToken,
}))
const [_, send] = useAuthMachine()
// Fallback logic for displaying user's "name":
// 1. user.name
@ -33,10 +36,10 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
<Popover className="relative">
{user?.image && !imageLoadFailed ? (
<Popover.Button
className="border-0 rounded-full w-fit p-0"
className="border-0 rounded-full w-fit p-0 focus:outline-none group"
data-testid="user-sidebar-toggle"
>
<div className="rounded-full border border-chalkboard-70/50 hover:border-liquid-50 overflow-hidden">
<div className="rounded-full border border-chalkboard-70/50 hover:border-liquid-50 group-focus:border-liquid-50 overflow-hidden">
<img
src={user?.image || ''}
alt={user?.name || ''}
@ -56,9 +59,9 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
Menu
</ActionButton>
)}
<Popover.Overlay className="fixed z-40 inset-0 bg-chalkboard-110/50" />
<Popover.Overlay className="fixed z-20 inset-0 bg-chalkboard-110/50" />
<Popover.Panel className="fixed inset-0 left-auto z-50 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-liquid-100 shadow-md rounded-l-lg">
<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 && (
@ -95,23 +98,29 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
)}
<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(paths.SETTINGS)
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={() => {
setToken('')
navigate(paths.SIGN_IN)
}}
onClick={() => send('logout')}
icon={{
icon: faSignOutAlt,
bgClassName: 'bg-destroy-80',

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

@ -32,7 +32,7 @@ body.dark {
}
::-webkit-scrollbar {
@apply w-2 rounded-sm;
@apply w-2 h-2 rounded-sm;
@apply bg-chalkboard-20;
}

View File

@ -1,74 +1,42 @@
import {
abstractSyntaxTree,
findClosingBrace,
hasPipeOperator,
findEndOfBinaryExpression,
} from './abstractSyntaxTree'
import { lexer } from './tokeniser'
import { parser_wasm } from './abstractSyntaxTree'
import { initPromise } from './rust'
beforeAll(() => initPromise)
describe('findClosingBrace', () => {
test('finds the closing brace', () => {
const basic = '( hey )'
expect(findClosingBrace(lexer(basic), 0)).toBe(4)
const handlesNonZeroIndex =
'(indexForBracketToRightOfThisIsTwo(shouldBeFour)AndNotThisSix)'
expect(findClosingBrace(lexer(handlesNonZeroIndex), 2)).toBe(4)
expect(findClosingBrace(lexer(handlesNonZeroIndex), 0)).toBe(6)
const handlesNested =
'{a{b{c(}d]}eathou athoeu tah u} thatOneToTheLeftIsLast }'
expect(findClosingBrace(lexer(handlesNested), 0)).toBe(18)
// throws when not started on a brace
expect(() => findClosingBrace(lexer(handlesNested), 1)).toThrow()
})
})
describe('testing AST', () => {
test('5 + 6', () => {
const tokens = lexer('5 +6')
const result = abstractSyntaxTree(tokens)
const result = parser_wasm('5 +6')
delete (result as any).nonCodeMeta
expect(result).toEqual({
type: 'Program',
start: 0,
end: 4,
body: [
{
type: 'ExpressionStatement',
expect(result.body).toEqual([
{
type: 'ExpressionStatement',
start: 0,
end: 4,
expression: {
type: 'BinaryExpression',
start: 0,
end: 4,
expression: {
type: 'BinaryExpression',
left: {
type: 'Literal',
start: 0,
end: 1,
value: 5,
raw: '5',
},
operator: '+',
right: {
type: 'Literal',
start: 3,
end: 4,
left: {
type: 'Literal',
start: 0,
end: 1,
value: 5,
raw: '5',
},
operator: '+',
right: {
type: 'Literal',
start: 3,
end: 4,
value: 6,
raw: '6',
},
value: 6,
raw: '6',
},
},
],
})
},
])
})
test('const myVar = 5', () => {
const tokens = lexer('const myVar = 5')
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm('const myVar = 5')
expect(body).toEqual([
{
type: 'VariableDeclaration',
@ -102,8 +70,7 @@ describe('testing AST', () => {
const code = `const myVar = 5
const newVar = myVar + 1
`
const tokens = lexer(code)
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm(code)
expect(body).toEqual([
{
type: 'VariableDeclaration',
@ -173,8 +140,7 @@ const newVar = myVar + 1
})
test('using std function "log"', () => {
const code = `log(5, "hello", aIdentifier)`
const tokens = lexer(code)
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm(code)
expect(body).toEqual([
{
type: 'ExpressionStatement',
@ -221,8 +187,7 @@ const newVar = myVar + 1
describe('testing function declaration', () => {
test('fn funcN = () => {}', () => {
const tokens = lexer('fn funcN = () => {}')
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm('fn funcN = () => {}')
delete (body[0] as any).declarations[0].init.body.nonCodeMeta
expect(body).toEqual([
{
@ -260,10 +225,9 @@ describe('testing function declaration', () => {
])
})
test('fn funcN = (a, b) => {return a + b}', () => {
const tokens = lexer(
const { body } = parser_wasm(
['fn funcN = (a, b) => {', ' return a + b', '}'].join('\n')
)
const { body } = abstractSyntaxTree(tokens)
delete (body[0] as any).declarations[0].init.body.nonCodeMeta
expect(body).toEqual([
{
@ -338,11 +302,9 @@ describe('testing function declaration', () => {
])
})
test('call expression assignment', () => {
const tokens = lexer(
`fn funcN = (a, b) => { return a + b }
const code = `fn funcN = (a, b) => { return a + b }
const myVar = funcN(1, 2)`
)
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm(code)
delete (body[0] as any).declarations[0].init.body.nonCodeMeta
expect(body).toEqual([
{
@ -465,99 +427,15 @@ const myVar = funcN(1, 2)`
})
})
describe('testing hasPipeOperator', () => {
test('hasPipeOperator is true', () => {
let code = `sketch mySketch {
lineTo(2, 3)
} |> rx(45, %)
`
const tokens = lexer(code)
const result = hasPipeOperator(tokens, 0)
delete (result as any).bonusNonCodeNode
expect(result).toEqual({
index: 16,
token: { end: 37, start: 35, type: 'operator', value: '|>' },
})
})
test('matches the first pipe', () => {
let code = `sketch mySketch {
lineTo(2, 3)
} |> rx(45, %) |> rx(45, %)
`
const tokens = lexer(code)
const result = hasPipeOperator(tokens, 0)
delete (result as any).bonusNonCodeNode
expect(result).toEqual({
index: 16,
token: { end: 37, start: 35, type: 'operator', value: '|>' },
})
if (!result) throw new Error('should not happen')
expect(code.slice(result.token.start, result.token.end)).toEqual('|>')
})
test('hasPipeOperator is false when the pipe operator is after a new variable declaration', () => {
let code = `sketch mySketch {
lineTo(2, 3)
}
const yo = myFunc(9()
|> rx(45, %)
`
const tokens = lexer(code)
expect(hasPipeOperator(tokens, 0)).toEqual(false)
})
test('hasPipeOperator with binary expression', () => {
let code = `const myVar2 = 5 + 1 |> myFn(%)`
const tokens = lexer(code)
const result = hasPipeOperator(tokens, 1)
delete (result as any).bonusNonCodeNode
expect(result).toEqual({
index: 12,
token: { end: 23, start: 21, type: 'operator', value: '|>' },
})
if (!result) throw new Error('should not happen')
expect(code.slice(result.token.start, result.token.end)).toEqual('|>')
})
test('hasPipeOperator of called mid sketchExpression on a callExpression, and called at the start of the sketchExpression at "{"', () => {
const code = [
'sketch mySk1 {',
' lineTo(1,1)',
' path myPath = lineTo(0, 1)',
' lineTo(1,1)',
'} |> rx(90, %)',
'show(mySk1)',
].join('\n')
const tokens = lexer(code)
const tokenWithMyPathIndex = tokens.findIndex(
({ value }) => value === 'myPath'
)
const tokenWithLineToIndexForVarDecIndex = tokens.findIndex(
({ value }, index) => value === 'lineTo' && index > tokenWithMyPathIndex
)
const result = hasPipeOperator(tokens, tokenWithLineToIndexForVarDecIndex)
expect(result).toBe(false)
const braceTokenIndex = tokens.findIndex(({ value }) => value === '{')
const result2 = hasPipeOperator(tokens, braceTokenIndex)
delete (result2 as any).bonusNonCodeNode
expect(result2).toEqual({
index: 36,
token: { end: 76, start: 74, type: 'operator', value: '|>' },
})
if (!result2) throw new Error('should not happen')
expect(code.slice(result2?.token?.start, result2?.token?.end)).toEqual('|>')
})
})
describe('testing pipe operator special', () => {
test('pipe operator with sketch', () => {
let code = `const mySketch = startSketchAt([0, 0])
|> lineTo([2, 3], %)
|> lineTo({ to: [0, 1], tag: "myPath" }, %)
|> lineTo([1, 1], %)
} |> rx(45, %)
|> rx(45, %)
`
const tokens = lexer(code)
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm(code)
delete (body[0] as any).declarations[0].init.nonCodeMeta
expect(body).toEqual([
{
@ -786,8 +664,7 @@ describe('testing pipe operator special', () => {
})
test('pipe operator with binary expression', () => {
let code = `const myVar = 5 + 6 |> myFunc(45, %)`
const tokens = lexer(code)
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm(code)
delete (body as any)[0].declarations[0].init.nonCodeMeta
expect(body).toEqual([
{
@ -866,8 +743,7 @@ describe('testing pipe operator special', () => {
})
test('array expression', () => {
let code = `const yo = [1, '2', three, 4 + 5]`
const tokens = lexer(code)
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm(code)
expect(body).toEqual([
{
type: 'VariableDeclaration',
@ -942,8 +818,7 @@ describe('testing pipe operator special', () => {
'const three = 3',
"const yo = {aStr: 'str', anum: 2, identifier: three, binExp: 4 + 5}",
].join('\n')
const tokens = lexer(code)
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm(code)
expect(body).toEqual([
{
type: 'VariableDeclaration',
@ -1087,8 +962,7 @@ describe('testing pipe operator special', () => {
const code = `const yo = {key: {
key2: 'value'
}}`
const tokens = lexer(code)
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm(code)
expect(body).toEqual([
{
type: 'VariableDeclaration',
@ -1156,8 +1030,7 @@ describe('testing pipe operator special', () => {
})
test('object expression with array ast', () => {
const code = `const yo = {key: [1, '2']}`
const tokens = lexer(code)
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm(code)
expect(body).toEqual([
{
type: 'VariableDeclaration',
@ -1221,8 +1094,7 @@ describe('testing pipe operator special', () => {
})
test('object memberExpression simple', () => {
const code = `const prop = yo.one.two`
const tokens = lexer(code)
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm(code)
expect(body).toEqual([
{
type: 'VariableDeclaration',
@ -1277,8 +1149,7 @@ describe('testing pipe operator special', () => {
})
test('object memberExpression with square braces', () => {
const code = `const prop = yo.one["two"]`
const tokens = lexer(code)
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm(code)
expect(body).toEqual([
{
type: 'VariableDeclaration',
@ -1334,8 +1205,7 @@ describe('testing pipe operator special', () => {
})
test('object memberExpression with two square braces literal and identifier', () => {
const code = `const prop = yo["one"][two]`
const tokens = lexer(code)
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm(code)
expect(body).toEqual([
{
type: 'VariableDeclaration',
@ -1394,7 +1264,7 @@ describe('testing pipe operator special', () => {
describe('nests binary expressions correctly', () => {
it('works with the simple case', () => {
const code = `const yo = 1 + 2`
const { body } = abstractSyntaxTree(lexer(code))
const { body } = parser_wasm(code)
expect(body[0]).toEqual({
type: 'VariableDeclaration',
start: 0,
@ -1438,7 +1308,7 @@ describe('nests binary expressions correctly', () => {
it('should nest according to precedence with multiply first', () => {
// should be binExp { binExp { lit-1 * lit-2 } + lit}
const code = `const yo = 1 * 2 + 3`
const { body } = abstractSyntaxTree(lexer(code))
const { body } = parser_wasm(code)
expect(body[0]).toEqual({
type: 'VariableDeclaration',
start: 0,
@ -1495,7 +1365,7 @@ describe('nests binary expressions correctly', () => {
it('should nest according to precedence with sum first', () => {
// should be binExp { lit-1 + binExp { lit-2 * lit-3 } }
const code = `const yo = 1 + 2 * 3`
const { body } = abstractSyntaxTree(lexer(code))
const { body } = parser_wasm(code)
expect(body[0]).toEqual({
type: 'VariableDeclaration',
start: 0,
@ -1551,7 +1421,7 @@ describe('nests binary expressions correctly', () => {
})
it('should nest properly with two opperators of equal precedence', () => {
const code = `const yo = 1 + 2 - 3`
const { body } = abstractSyntaxTree(lexer(code))
const { body } = parser_wasm(code)
expect((body[0] as any).declarations[0].init).toEqual({
type: 'BinaryExpression',
start: 11,
@ -1588,7 +1458,7 @@ describe('nests binary expressions correctly', () => {
})
it('should nest properly with two opperators of equal (but higher) precedence', () => {
const code = `const yo = 1 * 2 / 3`
const { body } = abstractSyntaxTree(lexer(code))
const { body } = parser_wasm(code)
expect((body[0] as any).declarations[0].init).toEqual({
type: 'BinaryExpression',
start: 11,
@ -1625,7 +1495,7 @@ describe('nests binary expressions correctly', () => {
})
it('should nest properly with longer example', () => {
const code = `const yo = 1 + 2 * (3 - 4) / 5 + 6`
const { body } = abstractSyntaxTree(lexer(code))
const { body } = parser_wasm(code)
const init = (body[0] as any).declarations[0].init
expect(init).toEqual({
type: 'BinaryExpression',
@ -1684,13 +1554,13 @@ const key = 'c'`
end: code.indexOf('const key'),
value: '\n// this is a comment\n',
}
const { nonCodeMeta } = abstractSyntaxTree(lexer(code))
const { nonCodeMeta } = parser_wasm(code)
expect(nonCodeMeta.noneCodeNodes[0]).toEqual(nonCodeMetaInstance)
// extra whitespace won't change it's position (0) or value (NB the start end would have changed though)
const codeWithExtraStartWhitespace = '\n\n\n' + code
const { nonCodeMeta: nonCodeMeta2 } = abstractSyntaxTree(
lexer(codeWithExtraStartWhitespace)
const { nonCodeMeta: nonCodeMeta2 } = parser_wasm(
codeWithExtraStartWhitespace
)
expect(nonCodeMeta2.noneCodeNodes[0].value).toBe(nonCodeMetaInstance.value)
expect(nonCodeMeta2.noneCodeNodes[0].start).not.toBe(
@ -1707,7 +1577,7 @@ const key = 'c'`
|> close(%)
`
const { body } = abstractSyntaxTree(lexer(code))
const { body } = parser_wasm(code)
const indexOfSecondLineToExpression = 2
const sketchNonCodeMeta = (body as any)[0].declarations[0].init.nonCodeMeta
.noneCodeNodes
@ -1729,7 +1599,7 @@ const key = 'c'`
' |> rx(90, %)',
].join('\n')
const { body } = abstractSyntaxTree(lexer(code))
const { body } = parser_wasm(code)
const sketchNonCodeMeta = (body[0] as any).declarations[0].init.nonCodeMeta
.noneCodeNodes
expect(sketchNonCodeMeta[3]).toEqual({
@ -1741,72 +1611,10 @@ const key = 'c'`
})
})
describe('testing findEndofBinaryExpression', () => {
it('1 + 2 * 3', () => {
const code = `1 + 2 * 3\nconst yo = 5`
const tokens = lexer(code)
const end = findEndOfBinaryExpression(tokens, 0)
expect(tokens[end].value).toBe('3')
})
it('(1 + 2) / 5 - 3', () => {
const code = `(1 + 25) / 5 - 3\nconst yo = 5`
const tokens = lexer(code)
const end = findEndOfBinaryExpression(tokens, 0)
expect(tokens[end].value).toBe('3')
// expect to have the same end if started later in the string at a legitimate place
const indexOf5 = code.indexOf('5')
const endStartingAtThe5 = findEndOfBinaryExpression(tokens, indexOf5)
expect(endStartingAtThe5).toBe(end)
})
it('whole thing wraped: ((1 + 2) / 5 - 3)', () => {
const code = '((1 + 2) / 5 - 3)\nconst yo = 5'
const tokens = lexer(code)
const end = findEndOfBinaryExpression(tokens, 0)
expect(tokens[end].end).toBe(code.indexOf('3)') + 2)
})
it('whole thing wraped but given index after the first brace: ((1 + 2) / 5 - 3)', () => {
const code = '((1 + 2) / 5 - 3)\nconst yo = 5'
const tokens = lexer(code)
const end = findEndOfBinaryExpression(tokens, 1)
expect(tokens[end].value).toBe('3')
})
it('given the index of a small wrapped section i.e. `1 + 2` in ((1 + 2) / 5 - 3)', () => {
const code = '((1 + 2) / 5 - 3)\nconst yo = 5'
const tokens = lexer(code)
const end = findEndOfBinaryExpression(tokens, 2)
expect(tokens[end].value).toBe('2')
})
it('lots of silly nesting: (1 + 2) / (5 - (3))', () => {
const code = '(1 + 2) / (5 - (3))\nconst yo = 5'
const tokens = lexer(code)
const end = findEndOfBinaryExpression(tokens, 0)
expect(tokens[end].end).toBe(code.indexOf('))') + 2)
})
it('with pipe operator at the end', () => {
const code = '(1 + 2) / (5 - (3))\n |> fn(%)'
const tokens = lexer(code)
const end = findEndOfBinaryExpression(tokens, 0)
expect(tokens[end].end).toBe(code.indexOf('))') + 2)
})
it('with call expression at the start of binary expression', () => {
const code = 'yo(2) + 3\n |> fn(%)'
const tokens = lexer(code)
const end = findEndOfBinaryExpression(tokens, 0)
expect(tokens[end].value).toBe('3')
})
it('with call expression at the end of binary expression', () => {
const code = '3 + yo(2)\n |> fn(%)'
const tokens = lexer(code)
const end = findEndOfBinaryExpression(tokens, 0)
expect(tokens[end].value).toBe(')')
})
})
describe('test UnaryExpression', () => {
it('should parse a unary expression in simple var dec situation', () => {
const code = `const myVar = -min(4, 100)`
const { body } = abstractSyntaxTree(lexer(code))
const { body } = parser_wasm(code)
const myVarInit = (body?.[0] as any).declarations[0]?.init
expect(myVarInit).toEqual({
type: 'UnaryExpression',
@ -1831,7 +1639,7 @@ describe('test UnaryExpression', () => {
describe('testing nested call expressions', () => {
it('callExp in a binExp in a callExp', () => {
const code = 'const myVar = min(100, 1 + legLen(5, 3))'
const { body } = abstractSyntaxTree(lexer(code))
const { body } = parser_wasm(code)
const myVarInit = (body?.[0] as any).declarations[0]?.init
expect(myVarInit).toEqual({
type: 'CallExpression',
@ -1867,8 +1675,7 @@ describe('testing nested call expressions', () => {
describe('should recognise callExpresions in binaryExpressions', () => {
const code = "xLineTo(segEndX('seg02', %) + 1, %)"
it('should recognise the callExp', () => {
const tokens = lexer(code)
const { body } = abstractSyntaxTree(tokens)
const { body } = parser_wasm(code)
const callExpArgs = (body?.[0] as any).expression?.arguments
expect(callExpArgs).toEqual([
{

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,21 @@
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'
| 'ExpressionStatement'
@ -18,160 +36,3 @@ export type SyntaxType =
| 'Literal'
| 'NoneCodeNode'
| 'UnaryExpression'
export interface Program {
type: SyntaxType
start: number
end: number
body: BodyItem[]
nonCodeMeta: NoneCodeMeta
}
interface GeneralStatement {
type: SyntaxType
start: number
end: number
}
export type BodyItem =
| ExpressionStatement
| VariableDeclaration
| ReturnStatement
export type Value =
| Literal
| Identifier
| BinaryExpression
| FunctionExpression
| CallExpression
| PipeExpression
| PipeSubstitution
| ArrayExpression
| ObjectExpression
| MemberExpression
| UnaryExpression
export type BinaryPart =
| Literal
| Identifier
| BinaryExpression
| CallExpression
| UnaryExpression
export interface NoneCodeNode extends GeneralStatement {
type: 'NoneCodeNode'
value: string
}
export interface NoneCodeMeta {
// Stores the whitespace/comments that go after the statement who's index we're using here
noneCodeNodes: { [statementIndex: number]: NoneCodeNode }
// Which is why we also need `start` for and whitespace at the start of the file/block
start?: NoneCodeNode
}
export interface ExpressionStatement extends GeneralStatement {
type: 'ExpressionStatement'
expression: Value
}
export interface CallExpression extends GeneralStatement {
type: 'CallExpression'
callee: Identifier
arguments: Value[]
optional: boolean
}
export interface VariableDeclaration extends GeneralStatement {
type: 'VariableDeclaration'
declarations: VariableDeclarator[]
kind: 'const' | 'unknown' | 'fn' //| "solid" | "surface" | "face"
}
export interface VariableDeclarator extends GeneralStatement {
type: 'VariableDeclarator'
id: Identifier
init: Value
}
export interface Literal extends GeneralStatement {
type: 'Literal'
value: string | number | boolean | null
raw: string
}
export interface Identifier extends GeneralStatement {
type: 'Identifier'
name: string
}
export interface PipeSubstitution extends GeneralStatement {
type: 'PipeSubstitution'
}
export interface ArrayExpression extends GeneralStatement {
type: 'ArrayExpression'
elements: Value[]
}
export interface ObjectExpression extends GeneralStatement {
type: 'ObjectExpression'
properties: ObjectProperty[]
}
export interface ObjectProperty extends GeneralStatement {
type: 'ObjectProperty'
key: Identifier
value: Value
}
export interface MemberExpression extends GeneralStatement {
type: 'MemberExpression'
object: MemberExpression | Identifier
property: Identifier | Literal
computed: boolean
}
export interface ObjectKeyInfo {
key: Identifier | Literal
index: number
computed: boolean
}
export interface BinaryExpression extends GeneralStatement {
type: 'BinaryExpression'
operator: string
left: BinaryPart
right: BinaryPart
}
export interface UnaryExpression extends GeneralStatement {
type: 'UnaryExpression'
operator: '-' | '!'
argument: BinaryPart
}
export interface PipeExpression extends GeneralStatement {
type: 'PipeExpression'
body: Value[]
nonCodeMeta: NoneCodeMeta
}
export interface FunctionExpression extends GeneralStatement {
type: 'FunctionExpression'
id: Identifier | null
params: Identifier[]
body: BlockStatement
}
export interface BlockStatement extends GeneralStatement {
type: 'BlockStatement'
body: BodyItem[]
nonCodeMeta: NoneCodeMeta
}
export interface ReturnStatement extends GeneralStatement {
type: 'ReturnStatement'
argument: Value
}
export type All = Program | ExpressionStatement[] | BinaryExpression | Literal

View File

@ -1,8 +1,6 @@
import { abstractSyntaxTree } from './abstractSyntaxTree'
import { lexer } from './tokeniser'
import { SketchGroup, ExtrudeGroup } from './executor'
import { parser_wasm } from './abstractSyntaxTree'
import { initPromise } from './rust'
import { enginelessExecutor, executor } from '../lib/testHelpers'
import { enginelessExecutor } from '../lib/testHelpers'
beforeAll(() => initPromise)
@ -15,9 +13,7 @@ const mySketch001 = startSketchAt([0, 0])
|> lineTo([0.46, -5.82], %)
// |> rx(45, %)
show(mySketch001)`
const programMemory = await enginelessExecutor(
abstractSyntaxTree(lexer(code))
)
const programMemory = await enginelessExecutor(parser_wasm(code))
const shown = programMemory?.return?.map(
(a) => programMemory?.root?.[a.name]
)
@ -72,9 +68,7 @@ const mySketch001 = startSketchAt([0, 0])
// |> rx(45, %)
|> extrude(2, %)
show(mySketch001)`
const programMemory = await enginelessExecutor(
abstractSyntaxTree(lexer(code))
)
const programMemory = await enginelessExecutor(parser_wasm(code))
const shown = programMemory?.return?.map(
(a) => programMemory?.root?.[a.name]
)
@ -115,9 +109,7 @@ const sk2 = startSketchAt([0, 0])
show(theExtrude, sk2)`
const programMemory = await enginelessExecutor(
abstractSyntaxTree(lexer(code))
)
const programMemory = await enginelessExecutor(parser_wasm(code))
const geos = programMemory?.return?.map(
({ name }) => programMemory?.root?.[name]
)

View File

@ -1,207 +0,0 @@
import { parseExpression, reversePolishNotation } from './astMathExpressions'
import { lexer } from './tokeniser'
import { initPromise } from './rust'
beforeAll(() => initPromise)
describe('parseExpression', () => {
it('parses a simple expression', () => {
const result = parseExpression(lexer('1 + 2'))
expect(result).toEqual({
type: 'BinaryExpression',
operator: '+',
start: 0,
end: 5,
left: { type: 'Literal', value: 1, raw: '1', start: 0, end: 1 },
right: { type: 'Literal', value: 2, raw: '2', start: 4, end: 5 },
})
})
it('parses a more complex expression + followed by *', () => {
const tokens = lexer('1 + 2 * 3')
const result = parseExpression(tokens)
expect(result).toEqual({
type: 'BinaryExpression',
operator: '+',
start: 0,
end: 9,
left: { type: 'Literal', value: 1, raw: '1', start: 0, end: 1 },
right: {
type: 'BinaryExpression',
operator: '*',
start: 4,
end: 9,
left: { type: 'Literal', value: 2, raw: '2', start: 4, end: 5 },
right: { type: 'Literal', value: 3, raw: '3', start: 8, end: 9 },
},
})
})
it('parses a more complex expression with parentheses: 1 * ( 2 + 3 )', () => {
const result = parseExpression(lexer('1 * ( 2 + 3 )'))
expect(result).toEqual({
type: 'BinaryExpression',
operator: '*',
start: 0,
end: 13,
left: { type: 'Literal', value: 1, raw: '1', start: 0, end: 1 },
right: {
type: 'BinaryExpression',
operator: '+',
start: 6,
end: 11,
left: { type: 'Literal', value: 2, raw: '2', start: 6, end: 7 },
right: { type: 'Literal', value: 3, raw: '3', start: 10, end: 11 },
},
})
})
it('parses a more complex expression with parentheses with more', () => {
const result = parseExpression(lexer('1 * ( 2 + 3 ) / 4'))
expect(result).toEqual({
type: 'BinaryExpression',
operator: '/',
start: 0,
end: 17,
left: {
type: 'BinaryExpression',
operator: '*',
start: 0,
end: 13,
left: { type: 'Literal', value: 1, raw: '1', start: 0, end: 1 },
right: {
type: 'BinaryExpression',
operator: '+',
start: 6,
end: 11,
left: { type: 'Literal', value: 2, raw: '2', start: 6, end: 7 },
right: { type: 'Literal', value: 3, raw: '3', start: 10, end: 11 },
},
},
right: { type: 'Literal', value: 4, raw: '4', start: 16, end: 17 },
})
})
it('same as last one but with a 1 + at the start', () => {
const result = parseExpression(lexer('1 + ( 2 + 3 ) / 4'))
expect(result).toEqual({
type: 'BinaryExpression',
operator: '+',
start: 0,
end: 17,
left: { type: 'Literal', value: 1, raw: '1', start: 0, end: 1 },
right: {
type: 'BinaryExpression',
operator: '/',
start: 4,
end: 17,
left: {
type: 'BinaryExpression',
operator: '+',
start: 6,
end: 11,
left: { type: 'Literal', value: 2, raw: '2', start: 6, end: 7 },
right: { type: 'Literal', value: 3, raw: '3', start: 10, end: 11 },
},
right: { type: 'Literal', value: 4, raw: '4', start: 16, end: 17 },
},
})
})
it('nested braces', () => {
const result = parseExpression(lexer('1 * (( 2 + 3 ) / 4 + 5 )'))
expect(result).toEqual({
type: 'BinaryExpression',
operator: '*',
start: 0,
end: 24,
left: { type: 'Literal', value: 1, raw: '1', start: 0, end: 1 },
right: {
type: 'BinaryExpression',
operator: '+',
start: 5,
end: 22,
left: {
type: 'BinaryExpression',
operator: '/',
start: 5,
end: 18,
left: {
type: 'BinaryExpression',
operator: '+',
start: 7,
end: 12,
left: { type: 'Literal', value: 2, raw: '2', start: 7, end: 8 },
right: {
type: 'Literal',
value: 3,
raw: '3',
start: 11,
end: 12,
},
},
right: { type: 'Literal', value: 4, raw: '4', start: 17, end: 18 },
},
right: { type: 'Literal', value: 5, raw: '5', start: 21, end: 22 },
},
})
})
it('multiple braces around the same thing', () => {
const result = parseExpression(lexer('1 * ((( 2 + 3 )))'))
expect(result).toEqual({
type: 'BinaryExpression',
operator: '*',
start: 0,
end: 17,
left: { type: 'Literal', value: 1, raw: '1', start: 0, end: 1 },
right: {
type: 'BinaryExpression',
operator: '+',
start: 8,
end: 13,
left: { type: 'Literal', value: 2, raw: '2', start: 8, end: 9 },
right: { type: 'Literal', value: 3, raw: '3', start: 12, end: 13 },
},
})
})
it('multiple braces around a sing literal', () => {
const code = '2 + (((3)))'
const result = parseExpression(lexer(code))
expect(result).toEqual({
type: 'BinaryExpression',
operator: '+',
start: 0,
end: code.indexOf(')))') + 3,
left: { type: 'Literal', value: 2, raw: '2', start: 0, end: 1 },
right: { type: 'Literal', value: 3, raw: '3', start: 7, end: 8 },
})
})
})
describe('reversePolishNotation', () => {
it('converts a simple expression', () => {
const result = reversePolishNotation(lexer('1 + 2'))
expect(result).toEqual([
{ type: 'number', value: '1', start: 0, end: 1 },
{ type: 'number', value: '2', start: 4, end: 5 },
{ type: 'operator', value: '+', start: 2, end: 3 },
])
})
it('converts a more complex expression', () => {
const result = reversePolishNotation(lexer('1 + 2 * 3'))
expect(result).toEqual([
{ type: 'number', value: '1', start: 0, end: 1 },
{ type: 'number', value: '2', start: 4, end: 5 },
{ type: 'number', value: '3', start: 8, end: 9 },
{ type: 'operator', value: '*', start: 6, end: 7 },
{ type: 'operator', value: '+', start: 2, end: 3 },
])
})
it('converts a more complex expression with parentheses', () => {
const result = reversePolishNotation(lexer('1 * ( 2 + 3 )'))
expect(result).toEqual([
{ type: 'number', value: '1', start: 0, end: 1 },
{ type: 'brace', value: '(', start: 4, end: 5 },
{ type: 'number', value: '2', start: 6, end: 7 },
{ type: 'number', value: '3', start: 10, end: 11 },
{ type: 'operator', value: '+', start: 8, end: 9 },
{ type: 'brace', value: ')', start: 12, end: 13 },
{ type: 'operator', value: '*', start: 2, end: 3 },
])
})
})

View File

@ -1,253 +0,0 @@
import {
BinaryExpression,
Literal,
Identifier,
CallExpression,
} from './abstractSyntaxTreeTypes'
import {
findClosingBrace,
makeCallExpression,
isNotCodeToken,
} from './abstractSyntaxTree'
import { Token } from './tokeniser'
import { KCLSyntaxError } from './errors'
export function reversePolishNotation(
tokens: Token[],
previousPostfix: Token[] = [],
operators: Token[] = []
): Token[] {
if (tokens.length === 0) {
return [...previousPostfix, ...operators.slice().reverse()] // reverse mutates, so slice/clone is needed
}
const currentToken = tokens[0]
if (
currentToken.type === 'word' &&
tokens?.[1]?.type === 'brace' &&
tokens?.[1]?.value === '('
) {
const closingBrace = findClosingBrace(tokens, 1)
return reversePolishNotation(
tokens.slice(closingBrace + 1),
[...previousPostfix, ...tokens.slice(0, closingBrace + 1)],
operators
)
} else if (
currentToken.type === 'number' ||
currentToken.type === 'word' ||
currentToken.type === 'string'
) {
return reversePolishNotation(
tokens.slice(1),
[...previousPostfix, currentToken],
operators
)
} else if (['+', '-', '*', '/', '%'].includes(currentToken.value)) {
if (
operators.length > 0 &&
_precedence(operators[operators.length - 1]) >= _precedence(currentToken)
) {
return reversePolishNotation(
tokens,
[...previousPostfix, operators[operators.length - 1]],
operators.slice(0, -1)
)
}
return reversePolishNotation(tokens.slice(1), previousPostfix, [
...operators,
currentToken,
])
} else if (currentToken.value === '(') {
// push current token to both stacks as it is a legitimate operator
// but later we'll need to pop other operators off the stack until we find the matching ')'
return reversePolishNotation(
tokens.slice(1),
[...previousPostfix, currentToken],
[...operators, currentToken]
)
} else if (currentToken.value === ')') {
if (operators[operators.length - 1]?.value !== '(') {
// pop operators off the stack and push them to postFix until we find the matching '('
return reversePolishNotation(
tokens,
[...previousPostfix, operators[operators.length - 1]],
operators.slice(0, -1)
)
}
return reversePolishNotation(
tokens.slice(1),
[...previousPostfix, currentToken],
operators.slice(0, -1)
)
}
if (isNotCodeToken(currentToken)) {
return reversePolishNotation(tokens.slice(1), previousPostfix, operators)
}
throw new KCLSyntaxError('Unknown token', [
[currentToken.start, currentToken.end],
])
}
interface ParenthesisToken {
type: 'parenthesis'
value: '(' | ')'
start: number
end: number
}
interface ExtendedBinaryExpression extends BinaryExpression {
startExtended?: number
endExtended?: number
}
const buildTree = (
reversePolishNotationTokens: Token[],
stack: (
| ExtendedBinaryExpression
| Literal
| Identifier
| ParenthesisToken
| CallExpression
)[] = []
): BinaryExpression => {
if (reversePolishNotationTokens.length === 0) {
return stack[0] as BinaryExpression
}
const currentToken = reversePolishNotationTokens[0]
if (currentToken.type === 'number' || currentToken.type === 'string') {
return buildTree(reversePolishNotationTokens.slice(1), [
...stack,
{
type: 'Literal',
value:
currentToken.type === 'number'
? Number(currentToken.value)
: currentToken.value.slice(1, -1),
raw: currentToken.value,
start: currentToken.start,
end: currentToken.end,
},
])
} else if (currentToken.type === 'word') {
if (
reversePolishNotationTokens?.[1]?.type === 'brace' &&
reversePolishNotationTokens?.[1]?.value === '('
) {
const closingBrace = findClosingBrace(reversePolishNotationTokens, 1)
return buildTree(reversePolishNotationTokens.slice(closingBrace + 1), [
...stack,
makeCallExpression(reversePolishNotationTokens, 0).expression,
])
}
return buildTree(reversePolishNotationTokens.slice(1), [
...stack,
{
type: 'Identifier',
name: currentToken.value,
start: currentToken.start,
end: currentToken.end,
},
])
} else if (currentToken.type === 'brace' && currentToken.value === '(') {
const paranToken: ParenthesisToken = {
type: 'parenthesis',
value: '(',
start: currentToken.start,
end: currentToken.end,
}
return buildTree(reversePolishNotationTokens.slice(1), [
...stack,
paranToken,
])
} else if (currentToken.type === 'brace' && currentToken.value === ')') {
const innerNode = stack[stack.length - 1]
const paran = stack[stack.length - 2]
const binExp: ExtendedBinaryExpression = {
...innerNode,
startExtended: paran.start,
endExtended: currentToken.end,
} as ExtendedBinaryExpression
return buildTree(reversePolishNotationTokens.slice(1), [
...stack.slice(0, -2),
binExp,
])
}
const left = { ...stack[stack.length - 2] }
let start = left.start
if (left.type === 'BinaryExpression') {
start = left?.startExtended || left.start
delete left.startExtended
delete left.endExtended
}
const right = { ...stack[stack.length - 1] }
let end = right.end
if (right.type === 'BinaryExpression') {
end = right?.endExtended || right.end
delete right.startExtended
delete right.endExtended
}
const binExp: BinaryExpression = {
type: 'BinaryExpression',
operator: currentToken.value,
start,
end,
left: left as any,
right: right as any,
}
return buildTree(reversePolishNotationTokens.slice(1), [
...stack.slice(0, -2),
binExp,
])
}
export function parseExpression(tokens: Token[]): BinaryExpression {
const treeWithMaybeBadTopLevelStartEnd = buildTree(
reversePolishNotation(tokens)
)
const left = treeWithMaybeBadTopLevelStartEnd?.left as any
const start = left?.startExtended || treeWithMaybeBadTopLevelStartEnd?.start
if (left == undefined || left == null) {
throw new KCLSyntaxError(
'syntax',
tokens.map((token) => [token.start, token.end])
) // Add text
}
delete left.startExtended
delete left.endExtended
const right = treeWithMaybeBadTopLevelStartEnd?.right as any
const end = right?.endExtended || treeWithMaybeBadTopLevelStartEnd?.end
delete right.startExtended
delete right.endExtended
const tree: BinaryExpression = {
...treeWithMaybeBadTopLevelStartEnd,
start,
end,
left,
right,
}
return tree
}
function _precedence(operator: Token): number {
return precedence(operator.value)
}
export function precedence(operator: string): number {
// might be useful for reference to make it match
// another commonly used lang https://www.w3schools.com/js/js_precedence.asp
if (['+', '-'].includes(operator)) {
return 11
} else if (['*', '/', '%'].includes(operator)) {
return 12
} else {
return 0
}
}

View File

@ -1,8 +1,7 @@
import fs from 'node:fs'
import { abstractSyntaxTree } from './abstractSyntaxTree'
import { lexer } from './tokeniser'
import { ProgramMemory, Path, SketchGroup } from './executor'
import { parser_wasm } from './abstractSyntaxTree'
import { ProgramMemory } from './executor'
import { initPromise } from './rust'
import { enginelessExecutor } from '../lib/testHelpers'
import { vi } from 'vitest'
@ -47,7 +46,7 @@ log(5, myVar)`
],
},
}
const { root } = await enginelessExecutor(abstractSyntaxTree(lexer(code)), {
const { root } = await enginelessExecutor(parser_wasm(code), {
root: programMemoryOverride,
pendingMemory: {},
})
@ -463,8 +462,7 @@ async function exe(
code: string,
programMemory: ProgramMemory = { root: {}, pendingMemory: {} }
) {
const tokens = lexer(code)
const ast = abstractSyntaxTree(tokens)
const ast = parser_wasm(code)
const result = await enginelessExecutor(ast, programMemory)
return result

View File

@ -543,7 +543,7 @@ export const _executor = async (
}
_programMemory.return = expression.arguments as any // todo memory redo
} else {
if (_programMemory.root[functionName] == undefined) {
if (_programMemory.root[functionName] === undefined) {
throw new KCLSemanticError(`No such name ${functionName} defined`, [
[statement.start, statement.end],
])

View File

@ -1,6 +1,5 @@
import { getNodePathFromSourceRange, getNodeFromPath } from './queryAst'
import { lexer } from './tokeniser'
import { abstractSyntaxTree } from './abstractSyntaxTree'
import { parser_wasm } from './abstractSyntaxTree'
import { initPromise } from './rust'
beforeAll(() => initPromise)
@ -21,7 +20,7 @@ const sk3 = startSketchAt([0, 0])
lineToSubstringIndex + subStr.length,
]
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const nodePath = getNodePathFromSourceRange(ast, sourceRange)
const { node } = getNodeFromPath<any>(ast, nodePath)

View File

@ -1,4 +1,4 @@
import { abstractSyntaxTree } from './abstractSyntaxTree'
import { parser_wasm } from './abstractSyntaxTree'
import {
createLiteral,
createIdentifier,
@ -14,7 +14,6 @@ import {
moveValueIntoNewVariable,
} from './modifyAst'
import { recast } from './recast'
import { lexer } from './tokeniser'
import { initPromise } from './rust'
import { enginelessExecutor } from '../lib/testHelpers'
@ -104,11 +103,10 @@ describe('Testing addSketchTo', () => {
it('should add a sketch to a program', () => {
const result = addSketchTo(
{
type: 'Program',
body: [],
start: 0,
end: 0,
nonCodeMeta: { noneCodeNodes: {} },
nonCodeMeta: { noneCodeNodes: {}, start: null },
},
'yz'
)
@ -127,7 +125,7 @@ function giveSketchFnCallTagTestHelper(
// giveSketchFnCallTag inputs and outputs an ast, which is very verbose for testing
// this wrapper changes the input and output to code
// making it more of an integration test, but easier to read the test intention is the goal
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const start = code.indexOf(searchStr)
const range: [number, number] = [start, start + searchStr.length]
const { modifiedAst, tag, isTagExisting } = giveSketchFnCallTag(ast, range)
@ -195,7 +193,7 @@ const yo = 5 + 6
const yo2 = hmm([identifierGuy + 5])
show(part001)`
it('should move a binary expression into a new variable', async () => {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const programMemory = await enginelessExecutor(ast)
const startIndex = code.indexOf('100 + 100') + 1
const { modifiedAst } = moveValueIntoNewVariable(
@ -209,7 +207,7 @@ show(part001)`
expect(newCode).toContain(`angledLine([newVar, 3.09], %)`)
})
it('should move a value into a new variable', async () => {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const programMemory = await enginelessExecutor(ast)
const startIndex = code.indexOf('2.8') + 1
const { modifiedAst } = moveValueIntoNewVariable(
@ -223,7 +221,7 @@ show(part001)`
expect(newCode).toContain(`line([newVar, 0], %)`)
})
it('should move a callExpression into a new variable', async () => {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const programMemory = await enginelessExecutor(ast)
const startIndex = code.indexOf('def(')
const { modifiedAst } = moveValueIntoNewVariable(
@ -237,7 +235,7 @@ show(part001)`
expect(newCode).toContain(`angledLine([newVar, 3.09], %)`)
})
it('should move a binary expression with call expression into a new variable', async () => {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const programMemory = await enginelessExecutor(ast)
const startIndex = code.indexOf('jkl(') + 1
const { modifiedAst } = moveValueIntoNewVariable(
@ -251,7 +249,7 @@ show(part001)`
expect(newCode).toContain(`angledLine([newVar, 3.09], %)`)
})
it('should move a identifier into a new variable', async () => {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const programMemory = await enginelessExecutor(ast)
const startIndex = code.indexOf('identifierGuy +') + 1
const { modifiedAst } = moveValueIntoNewVariable(

View File

@ -452,7 +452,7 @@ export function createPipeExpression(
start: 0,
end: 0,
body,
nonCodeMeta: { noneCodeNodes: {} },
nonCodeMeta: { noneCodeNodes: {}, start: null },
}
}

View File

@ -1,11 +1,10 @@
import { abstractSyntaxTree } from './abstractSyntaxTree'
import { parser_wasm } from './abstractSyntaxTree'
import {
findAllPreviousVariables,
isNodeSafeToReplace,
isTypeInValue,
getNodePathFromSourceRange,
} from './queryAst'
import { lexer } from './tokeniser'
import { initPromise } from './rust'
import { enginelessExecutor } from '../lib/testHelpers'
import {
@ -37,7 +36,7 @@ const variableBelowShouldNotBeIncluded = 3
show(part001)`
const rangeStart = code.indexOf('// selection-range-7ish-before-this') - 7
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const programMemory = await enginelessExecutor(ast)
const { variables, bodyPath, insertIndex } = findAllPreviousVariables(
@ -71,7 +70,7 @@ const yo = 5 + 6
const yo2 = hmm([identifierGuy + 5])
show(part001)`
it('find a safe binaryExpression', () => {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const rangeStart = code.indexOf('100 + 100') + 2
const result = isNodeSafeToReplace(ast, [rangeStart, rangeStart])
expect(result.isSafe).toBe(true)
@ -85,7 +84,7 @@ show(part001)`
expect(outCode).toContain(`angledLine([replaceName, 3.09], %)`)
})
it('find a safe Identifier', () => {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const rangeStart = code.indexOf('abc')
const result = isNodeSafeToReplace(ast, [rangeStart, rangeStart])
expect(result.isSafe).toBe(true)
@ -93,7 +92,7 @@ show(part001)`
expect(code.slice(result.value.start, result.value.end)).toBe('abc')
})
it('find a safe CallExpression', () => {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const rangeStart = code.indexOf('def')
const result = isNodeSafeToReplace(ast, [rangeStart, rangeStart])
expect(result.isSafe).toBe(true)
@ -107,7 +106,7 @@ show(part001)`
expect(outCode).toContain(`angledLine([replaceName, 3.09], %)`)
})
it('find an UNsafe CallExpression, as it has a PipeSubstitution', () => {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const rangeStart = code.indexOf('ghi')
const range: [number, number] = [rangeStart, rangeStart]
const result = isNodeSafeToReplace(ast, range)
@ -116,7 +115,7 @@ show(part001)`
expect(code.slice(result.value.start, result.value.end)).toBe('ghi(%)')
})
it('find an UNsafe Identifier, as it is a callee', () => {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const rangeStart = code.indexOf('ine([2.8,')
const result = isNodeSafeToReplace(ast, [rangeStart, rangeStart])
expect(result.isSafe).toBe(false)
@ -126,7 +125,7 @@ show(part001)`
)
})
it("find a safe BinaryExpression that's assigned to a variable", () => {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const rangeStart = code.indexOf('5 + 6') + 1
const result = isNodeSafeToReplace(ast, [rangeStart, rangeStart])
expect(result.isSafe).toBe(true)
@ -140,7 +139,7 @@ show(part001)`
expect(outCode).toContain(`const yo = replaceName`)
})
it('find a safe BinaryExpression that has a CallExpression within', () => {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const rangeStart = code.indexOf('jkl') + 1
const result = isNodeSafeToReplace(ast, [rangeStart, rangeStart])
expect(result.isSafe).toBe(true)
@ -156,7 +155,7 @@ show(part001)`
expect(outCode).toContain(`angledLine([replaceName, 3.09], %)`)
})
it('find a safe BinaryExpression within a CallExpression', () => {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const rangeStart = code.indexOf('identifierGuy') + 1
const result = isNodeSafeToReplace(ast, [rangeStart, rangeStart])
expect(result.isSafe).toBe(true)
@ -204,7 +203,7 @@ show(part001)`
it('finds the second line when cursor is put at the end', () => {
const searchLn = `line([0.94, 2.61], %)`
const sourceIndex = code.indexOf(searchLn) + searchLn.length
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const result = getNodePathFromSourceRange(ast, [sourceIndex, sourceIndex])
expect(result).toEqual([
['body', ''],
@ -219,7 +218,7 @@ show(part001)`
it('finds the last line when cursor is put at the end', () => {
const searchLn = `line([-0.21, -1.4], %)`
const sourceIndex = code.indexOf(searchLn) + searchLn.length
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const result = getNodePathFromSourceRange(ast, [sourceIndex, sourceIndex])
const expected = [
['body', ''],

View File

@ -1,7 +1,6 @@
import { recast } from './recast'
import { abstractSyntaxTree } from './abstractSyntaxTree'
import { parser_wasm } from './abstractSyntaxTree'
import { Program } from './abstractSyntaxTreeTypes'
import { lexer, Token } from './tokeniser'
import fs from 'node:fs'
import { initPromise } from './rust'
@ -342,11 +341,7 @@ describe('it recasts binary expression using brackets where needed', () => {
// helpers
function code2ast(code: string): { ast: Program; tokens: Token[] } {
const tokens = lexer(code)
const ast = abstractSyntaxTree(tokens)
return {
ast,
tokens,
}
function code2ast(code: string): { ast: Program } {
const ast = parser_wasm(code)
return { ast }
}

View File

@ -1,4 +1,13 @@
import { Program } from './abstractSyntaxTreeTypes'
import { recast_js } from '../wasm-lib/pkg/wasm_lib'
export const recast = (ast: Program): string => recast_js(JSON.stringify(ast))
export const recast = (ast: Program): string => {
try {
const s: string = recast_js(JSON.stringify(ast))
return s
} catch (e) {
// TODO: do something real with the error.
console.log('recast', e)
throw e
}
}

View File

@ -32,24 +32,206 @@ interface CursorSelectionsArgs {
idBasedSelections: { type: string; id: string }[]
}
type _EngineCommand = Models['ModelingCmdReq_type']
export type EngineCommand = Models['WebSocketMessages_type']
// TODO extending this type to add the type property is a work around
// see https://github.com/KittyCAD/api-deux/issues/1096
export interface EngineCommand extends _EngineCommand {
type: 'modeling_cmd_req'
type OkResponse = Models['OkModelingCmdResponse_type']
type WebSocketResponse = Models['WebSocketResponses_type']
// EngineConnection encapsulates the connection(s) to the Engine
// for the EngineCommandManager; namely, the underlying WebSocket
// and WebRTC connections.
export class EngineConnection {
websocket?: WebSocket
pc?: RTCPeerConnection
lossyDataChannel?: RTCDataChannel
onConnectionStarted: (conn: EngineConnection) => void = () => {}
waitForReady: Promise<void> = new Promise(() => {})
private resolveReady = () => {}
readonly url: string
private readonly token?: string
constructor({
url,
token,
onConnectionStarted,
}: {
url: string
token?: string
onConnectionStarted: (conn: EngineConnection) => void
}) {
this.url = url
this.token = token
this.onConnectionStarted = onConnectionStarted
// TODO(paultag): This isn't right; this should be when the
// connection is in a good place, and tied to the connect() method,
// but this is part of a larger refactor to untangle logic. Once the
// Connection is pulled apart, we can rework how ready is represented.
// This was just the easiest way to ensure some level of parity between
// the CommandManager and the Connection until I send a rework for
// retry logic.
this.waitForReady = new Promise((resolve) => {
this.resolveReady = resolve
})
}
connect() {
this.websocket = new WebSocket(this.url, [])
this.websocket.binaryType = 'arraybuffer'
this.pc = new RTCPeerConnection()
this.pc.createDataChannel('unreliable_modeling_cmds')
this.websocket.addEventListener('open', (event) => {
console.log('Connected to websocket, waiting for ICE servers')
if (this.token) {
this.websocket?.send(
JSON.stringify({ headers: { Authorization: `Bearer ${this.token}` } })
)
}
})
this.websocket.addEventListener('close', (event) => {
console.log('websocket connection closed', event)
})
this.websocket.addEventListener('error', (event) => {
console.log('websocket connection error', event)
})
this.websocket.addEventListener('message', (event) => {
// In the EngineConnection, we're looking for messages to/from
// the server that relate to the ICE handshake, or WebRTC
// negotiation. There may be other messages (including ArrayBuffer
// messages) that are intended for the GUI itself, so be careful
// when assuming we're the only consumer or that all messages will
// be carefully formatted here.
if (typeof event.data !== 'string') {
return
}
const message: WebSocketResponse = JSON.parse(event.data)
if (
message.type === 'sdp_answer' &&
message.answer.type !== 'unspecified'
) {
this.pc?.setRemoteDescription(
new RTCSessionDescription({
type: message.answer.type,
sdp: message.answer.sdp,
})
)
} else if (message.type === 'trickle_ice') {
this.pc?.addIceCandidate(message.candidate as RTCIceCandidateInit)
} else if (message.type === 'ice_server_info' && this.pc) {
console.log('received ice_server_info')
if (message.ice_servers.length > 0) {
// When we set the Configuration, we want to always force
// iceTransportPolicy to 'relay', since we know the topology
// of the ICE/STUN/TUN server and the engine. We don't wish to
// talk to the engine in any configuration /other/ than relay
// from a infra POV.
this.pc.setConfiguration({
iceServers: message.ice_servers,
iceTransportPolicy: 'relay',
})
} else {
this.pc?.setConfiguration({})
}
// We have an ICE Servers set now. We just setConfiguration, so let's
// start adding things we care about to the PeerConnection and let
// ICE negotiation happen in the background. Everything from here
// until the end of this function is setup of our end of the
// PeerConnection and waiting for events to fire our callbacks.
this.pc.addEventListener('connectionstatechange', (e) =>
console.log(this.pc?.iceConnectionState)
)
this.pc.addEventListener('icecandidate', (event) => {
if (!this.pc || !this.websocket) return
if (event.candidate === null) {
console.log('sent sdp_offer')
this.websocket.send(
JSON.stringify({
type: 'sdp_offer',
offer: this.pc.localDescription,
})
)
} else {
console.log('sending trickle ice candidate')
const { candidate } = event
this.websocket?.send(
JSON.stringify({
type: 'trickle_ice',
candidate: candidate.toJSON(),
})
)
}
})
// Offer to receive 1 video track
this.pc.addTransceiver('video', {})
// Finally (but actually firstly!), to kick things off, we're going to
// generate our SDP, set it on our PeerConnection, and let the server
// know about our capabilities.
this.pc
.createOffer()
.then(async (descriptionInit) => {
await this?.pc?.setLocalDescription(descriptionInit)
console.log('sent sdp_offer begin')
const msg = JSON.stringify({
type: 'sdp_offer',
offer: this.pc?.localDescription,
})
this.websocket?.send(msg)
})
.catch(console.log)
}
})
this.pc.addEventListener('datachannel', (event) => {
this.lossyDataChannel = event.channel
console.log('accepted lossy data channel', event.channel.label)
this.lossyDataChannel.addEventListener('open', (event) => {
this.resolveReady()
console.log('lossy data channel opened', event)
})
this.lossyDataChannel.addEventListener('close', (event) => {
console.log('lossy data channel closed')
})
this.lossyDataChannel.addEventListener('error', (event) => {
console.log('lossy data channel error')
})
})
if (this.onConnectionStarted) this.onConnectionStarted(this)
}
close() {
this.websocket?.close()
this.pc?.close()
this.lossyDataChannel?.close()
}
}
type WSResponse = Models['OkModelingCmdResponse_type']
export class EngineCommandManager {
artifactMap: ArtifactMap = {}
sourceRangeMap: SourceRangeMap = {}
outSequence = 1
inSequence = 1
socket?: WebSocket
pc?: RTCPeerConnection
lossyDataChannel?: RTCDataChannel
engineConnection?: EngineConnection
waitForReady: Promise<void> = new Promise(() => {})
private resolveReady = () => {}
onHoverCallback: (id?: string) => void = () => {}
@ -73,177 +255,98 @@ export class EngineCommandManager {
this.resolveReady = resolve
})
const url = `${VITE_KC_API_WS_MODELING_URL}?video_res_width=${width}&video_res_height=${height}`
this.socket = new WebSocket(url, [])
this.engineConnection = new EngineConnection({
url,
token,
onConnectionStarted: (conn) => {
this.engineConnection?.pc?.addEventListener('track', (event) => {
console.log('received track', event)
const mediaStream = event.streams[0]
setMediaStream(mediaStream)
})
// Change binary type from "blob" to "arraybuffer"
this.socket.binaryType = 'arraybuffer'
this.pc = new RTCPeerConnection()
this.pc.createDataChannel('unreliable_modeling_cmds')
this.socket.addEventListener('open', (event) => {
console.log('Connected to websocket, waiting for ICE servers')
if (token) {
this.socket?.send(
JSON.stringify({ headers: { Authorization: `Bearer ${token}` } })
)
}
})
this.socket.addEventListener('close', (event) => {
console.log('websocket connection closed', event)
})
this.socket.addEventListener('error', (event) => {
console.log('websocket connection error', event)
})
this?.socket?.addEventListener('message', (event) => {
if (!this.socket || !this.pc) return
// console.log('Message from server ', event.data);
if (event.data instanceof ArrayBuffer) {
// If the data is an ArrayBuffer, it's the result of an export command,
// because in all other cases we send JSON strings. But in the case of
// export we send a binary blob.
// Pass this to our export function.
exportSave(event.data)
} else if (
typeof event.data === 'string' &&
event.data.toLocaleLowerCase().startsWith('error')
) {
console.warn('something went wrong: ', event.data)
} else {
const message = JSON.parse(event.data)
if (message.type === 'sdp_answer') {
this.pc?.setRemoteDescription(
new RTCSessionDescription(message.answer)
)
} else if (message.type === 'trickle_ice') {
this.pc?.addIceCandidate(message.candidate)
} else if (message.type === 'ice_server_info' && this.pc) {
console.log('received ice_server_info')
if (message.ice_servers.length > 0) {
this.pc?.setConfiguration({
iceServers: message.ice_servers,
iceTransportPolicy: 'relay',
})
} else {
this.pc?.setConfiguration({})
}
this.pc.addEventListener('track', (event) => {
console.log('received track', event)
const mediaStream = event.streams[0]
setMediaStream(mediaStream)
})
this.pc.addEventListener('connectionstatechange', (e) =>
console.log(this?.pc?.iceConnectionState)
)
this.pc.addEventListener('icecandidate', (event) => {
if (!this.pc || !this.socket) return
if (event.candidate === null) {
console.log('sent sdp_offer')
this.socket.send(
JSON.stringify({
type: 'sdp_offer',
offer: this.pc.localDescription,
})
)
} else {
console.log('sending trickle ice candidate')
const { candidate } = event
this.socket?.send(
JSON.stringify({
type: 'trickle_ice',
candidate: candidate.toJSON(),
})
)
this.engineConnection?.pc?.addEventListener('datachannel', (event) => {
let lossyDataChannel = event.channel
lossyDataChannel.addEventListener('message', (event) => {
const result: OkResponse = JSON.parse(event.data)
if (
result.type === 'highlight_set_entity' &&
result.sequence &&
result.sequence > this.inSequence
) {
this.onHoverCallback(result.entity_id)
this.inSequence = result.sequence
}
})
})
// Offer to receive 1 video track
this.pc.addTransceiver('video', {
direction: 'sendrecv',
})
this.pc
.createOffer()
.then(async (descriptionInit) => {
await this?.pc?.setLocalDescription(descriptionInit)
console.log('sent sdp_offer begin')
const msg = JSON.stringify({
type: 'sdp_offer',
offer: this.pc?.localDescription,
})
this.socket?.send(msg)
})
.catch(console.log)
// When the EngineConnection starts a connection, we want to register
// callbacks into the WebSocket/PeerConnection.
conn.websocket?.addEventListener('message', (event) => {
if (event.data instanceof ArrayBuffer) {
// If the data is an ArrayBuffer, it's the result of an export command,
// because in all other cases we send JSON strings. But in the case of
// export we send a binary blob.
// Pass this to our export function.
exportSave(event.data)
} else if (
typeof event.data === 'string' &&
event.data.toLocaleLowerCase().startsWith('error')
) {
console.warn('something went wrong: ', event.data)
} else {
const message: WebSocketResponse = JSON.parse(event.data)
this.pc.addEventListener('datachannel', (event) => {
this.lossyDataChannel = event.channel
console.log('accepted lossy data channel', event.channel.label)
this.lossyDataChannel.addEventListener('open', (event) => {
setIsStreamReady(true)
this.resolveReady()
console.log('lossy data channel opened', event)
})
this.lossyDataChannel.addEventListener('close', (event) => {
console.log('lossy data channel closed')
})
this.lossyDataChannel.addEventListener('error', (event) => {
console.log('lossy data channel error')
})
this.lossyDataChannel.addEventListener('message', (event) => {
const result: WSResponse = JSON.parse(event.data)
if (
result.type === 'highlight_set_entity' &&
result.sequence &&
result.sequence > this.inSequence
) {
this.onHoverCallback(result.entity_id)
this.inSequence = result.sequence
if (message.type === 'modeling') {
const id = message.cmd_id
const command = this.artifactMap[id]
if ('ok' in message.result) {
const result: OkResponse = message.result.ok
if (result.type === 'select_with_point') {
if (result.entity_id) {
this.onClickCallback({
id: result.entity_id,
type: 'default',
})
} else {
this.onClickCallback()
}
}
}
})
})
} else if (message.cmd_id) {
const id = message.cmd_id
const command = this.artifactMap[id]
if (message?.result?.ok) {
const result: WSResponse = message.result.ok
if (result.type === 'select_with_point') {
if (result.entity_id) {
this.onClickCallback({
id: result.entity_id,
type: 'default',
if (command && command.type === 'pending') {
const resolve = command.resolve
this.artifactMap[id] = {
type: 'result',
data: message.result,
}
resolve({
id,
})
} else {
this.onClickCallback()
this.artifactMap[id] = {
type: 'result',
data: message.result,
}
}
}
}
if (command && command.type === 'pending') {
const resolve = command.resolve
this.artifactMap[id] = {
type: 'result',
data: message.result,
}
resolve({
id,
})
} else {
this.artifactMap[id] = {
type: 'result',
data: message.result,
}
}
}
}
})
},
})
// TODO(paultag): this isn't quite right, and the double promises is
// pretty grim.
this.engineConnection?.waitForReady.then(this.resolveReady)
this.waitForReady.then(() => {
setIsStreamReady(true)
})
this.engineConnection?.connect()
}
tearDown() {
// close all channels, sockets and WebRTC connections
this.lossyDataChannel?.close()
this.socket?.close()
this.pc?.close()
this.engineConnection?.close()
}
startNewSession() {
@ -251,7 +354,7 @@ export class EngineCommandManager {
this.sourceRangeMap = {}
}
endSession() {
// this.socket?.close()
// this.websocket?.close()
// socket.off('command')
}
onHover(callback: (id?: string) => void) {
@ -260,7 +363,6 @@ export class EngineCommandManager {
this.onHoverCallback = callback
}
onClick(callback: (selection?: SelectionsArgs) => void) {
// TODO talk to the gang about this
// It's when the user clicks on a part in the 3d scene, and so the engine should tell the
// frontend about that (with it's id) so that the FE can put the user's cursor on the right
// line of code
@ -270,7 +372,7 @@ export class EngineCommandManager {
otherSelections: Selections['otherSelections']
idBasedSelections: { type: string; id: string }[]
}) {
if (this.socket?.readyState === 0) {
if (this.engineConnection?.websocket?.readyState === 0) {
console.log('socket not open')
return
}
@ -280,7 +382,6 @@ export class EngineCommandManager {
type: 'select_clear',
},
cmd_id: uuidv4(),
file_id: uuidv4(),
})
this.sendSceneCommand({
type: 'modeling_cmd_req',
@ -289,28 +390,34 @@ export class EngineCommandManager {
entities: selections.idBasedSelections.map((s) => s.id),
},
cmd_id: uuidv4(),
file_id: uuidv4(),
})
}
sendSceneCommand(command: EngineCommand) {
if (this.socket?.readyState === 0) {
if (this.engineConnection?.websocket?.readyState === 0) {
console.log('socket not ready')
return
}
if (command.type !== 'modeling_cmd_req') return
const cmd = command.cmd
if (cmd.type === 'camera_drag_move' && this.lossyDataChannel) {
if (
cmd.type === 'camera_drag_move' &&
this.engineConnection?.lossyDataChannel
) {
cmd.sequence = this.outSequence
this.outSequence++
this.lossyDataChannel.send(JSON.stringify(command))
this.engineConnection?.lossyDataChannel?.send(JSON.stringify(command))
return
} else if (cmd.type === 'highlight_set_entity' && this.lossyDataChannel) {
} else if (
cmd.type === 'highlight_set_entity' &&
this.engineConnection?.lossyDataChannel
) {
cmd.sequence = this.outSequence
this.outSequence++
this.lossyDataChannel.send(JSON.stringify(command))
this.engineConnection?.lossyDataChannel?.send(JSON.stringify(command))
return
}
console.log('sending command', command)
this.socket?.send(JSON.stringify(command))
this.engineConnection?.websocket?.send(JSON.stringify(command))
}
sendModellingCommand({
id,
@ -325,11 +432,11 @@ export class EngineCommandManager {
}): Promise<any> {
this.sourceRangeMap[id] = range
if (this.socket?.readyState === 0) {
if (this.engineConnection?.websocket?.readyState === 0) {
console.log('socket not ready')
return new Promise(() => {})
}
this.socket?.send(JSON.stringify(command))
this.engineConnection?.websocket?.send(JSON.stringify(command))
let resolve: (val: any) => void = () => {}
const promise = new Promise((_resolve, reject) => {
resolve = _resolve

View File

@ -1,4 +1,3 @@
import { v4 as uuidv4 } from 'uuid'
import { InternalFn } from './stdTypes'
import {
ExtrudeGroup,
@ -49,7 +48,6 @@ export const extrude: InternalFn = (
cap: true,
},
cmd_id: id,
file_id: uuidv4(),
},
})

View File

@ -5,8 +5,7 @@ import {
getYComponent,
getXComponent,
} from './sketch'
import { lexer } from '../tokeniser'
import { abstractSyntaxTree } from '../abstractSyntaxTree'
import { parser_wasm } from '../abstractSyntaxTree'
import { getNodePathFromSourceRange } from '../queryAst'
import { recast } from '../recast'
import { enginelessExecutor } from '../../lib/testHelpers'
@ -106,7 +105,7 @@ const mySketch001 = startSketchAt([0, 0])
show(mySketch001)`
const code = genCode(lineToChange)
const expectedCode = genCode(lineAfterChange)
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const programMemory = await enginelessExecutor(ast)
const sourceStart = code.indexOf(lineToChange)
const { modifiedAst } = changeSketchArguments(
@ -135,7 +134,6 @@ show(mySketch001)`
describe('testing addNewSketchLn', () => {
const lineToChange = 'lineTo([-1.59, -1.54], %)'
const lineAfterChange = 'lineTo([2, 3], %)'
test('addNewSketchLn', async () => {
// Enable rotations #152
const code = `
@ -144,9 +142,10 @@ const mySketch001 = startSketchAt([0, 0])
|> lineTo([-1.59, -1.54], %)
|> lineTo([0.46, -5.82], %)
show(mySketch001)`
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const programMemory = await enginelessExecutor(ast)
const sourceStart = code.indexOf(lineToChange)
expect(sourceStart).toBe(66)
const { modifiedAst } = addNewSketchLn({
node: ast,
programMemory,
@ -183,7 +182,7 @@ describe('testing addTagForSketchOnFace', () => {
|> lineTo([0.46, -5.82], %)
show(mySketch001)`
const code = genCode(originalLine)
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const programMemory = await enginelessExecutor(ast)
const sourceStart = code.indexOf(originalLine)
const sourceRange: [number, number] = [

View File

@ -22,7 +22,6 @@ import {
import { GuiModes, toolTips, TooTip } from '../../useStore'
import { splitPathAtPipeExpression } from '../modifyAst'
import { generateUuidFromHashSeed } from '../../lib/uuid'
import { v4 as uuidv4 } from 'uuid'
import {
SketchLineHelper,
@ -117,7 +116,7 @@ function makeId(seed: string | any) {
export const lineTo: SketchLineHelper = {
fn: (
{ sourceRange, engineCommandManager, code },
{ sourceRange, code },
data:
| [number, number]
| {
@ -132,20 +131,11 @@ export const lineTo: SketchLineHelper = {
const from = getCoordsFromPaths(sketchGroup, sketchGroup.value.length - 1)
const to = 'to' in data ? data.to : data
const lineData: LineData = {
from: [...from, 0],
to: [...to, 0],
}
const id = makeId({
code,
sourceRange,
data,
})
// engineCommandManager.sendModellingCommand({
// id,
// params: [lineData, previousSketch],
// range: sourceRange,
// })
const currentPath: Path = {
type: 'toPoint',
to,
@ -282,7 +272,6 @@ export const line: SketchLineHelper = {
},
},
cmd_id: id,
file_id: uuidv4(),
},
})
const currentPath: Path = {
@ -674,20 +663,11 @@ export const angledLine: SketchLineHelper = {
from[0] + length * Math.cos((angle * Math.PI) / 180),
from[1] + length * Math.sin((angle * Math.PI) / 180),
]
const lineData: LineData = {
from: [...from, 0],
to: [...to, 0],
}
const id = makeId({
code,
sourceRange,
data,
})
// engineCommandManager.sendModellingCommand({
// id,
// params: [lineData, previousSketch],
// range: sourceRange,
// })
const currentPath: Path = {
type: 'toPoint',
to,
@ -1575,7 +1555,6 @@ export const close: InternalFn = (
path_id: sketchGroup.id,
},
cmd_id: id,
file_id: uuidv4(),
},
})
@ -1639,7 +1618,6 @@ export const startSketchAt: InternalFn = (
type: 'start_path',
},
cmd_id: pathId,
file_id: uuidv4(),
},
})
engineCommandManager.sendSceneCommand({
@ -1654,7 +1632,6 @@ export const startSketchAt: InternalFn = (
},
},
cmd_id: id,
file_id: uuidv4(),
})
const currentPath: Path = {
type: 'base',

View File

@ -1,6 +1,5 @@
import { abstractSyntaxTree } from '../abstractSyntaxTree'
import { parser_wasm } from '../abstractSyntaxTree'
import { SketchGroup } from '../executor'
import { lexer } from '../tokeniser'
import {
ConstraintType,
getTransformInfos,
@ -32,8 +31,7 @@ async function testingSwapSketchFnCall({
type: 'default',
range: [startIndex, startIndex + callToSwap.length],
}
const tokens = lexer(inputCode)
const ast = abstractSyntaxTree(tokens)
const ast = parser_wasm(inputCode)
const programMemory = await enginelessExecutor(ast)
const selections = {
codeBasedSelections: [range],
@ -383,9 +381,7 @@ const part001 = startSketchAt([0, 0.04]) // segment-in-start
|> xLine(3.54, %)
show(part001)`
it('normal case works', async () => {
const programMemory = await enginelessExecutor(
abstractSyntaxTree(lexer(code))
)
const programMemory = await enginelessExecutor(parser_wasm(code))
const index = code.indexOf('// normal-segment') - 7
const { __geoMeta, ...segment } = getSketchSegmentFromSourceRange(
programMemory.root['part001'] as SketchGroup,
@ -398,9 +394,7 @@ show(part001)`
})
})
it('verify it works when the segment is in the `start` property', async () => {
const programMemory = await enginelessExecutor(
abstractSyntaxTree(lexer(code))
)
const programMemory = await enginelessExecutor(parser_wasm(code))
const index = code.indexOf('// segment-in-start') - 7
const { __geoMeta, ...segment } = getSketchSegmentFromSourceRange(
programMemory.root['part001'] as SketchGroup,

View File

@ -1,6 +1,5 @@
import { abstractSyntaxTree } from '../abstractSyntaxTree'
import { parser_wasm } from '../abstractSyntaxTree'
import { Value } from '../abstractSyntaxTreeTypes'
import { lexer } from '../tokeniser'
import {
getConstraintType,
getTransformInfos,
@ -64,7 +63,7 @@ describe('testing getConstraintType', () => {
function getConstraintTypeFromSourceHelper(
code: string
): ReturnType<typeof getConstraintType> {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const args = (ast.body[0] as any).expression.arguments[0].elements as [
Value,
Value
@ -75,7 +74,7 @@ function getConstraintTypeFromSourceHelper(
function getConstraintTypeFromSourceHelper2(
code: string
): ReturnType<typeof getConstraintType> {
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const arg = (ast.body[0] as any).expression.arguments[0] as Value
const fnName = (ast.body[0] as any).expression.callee.name as TooTip
return getConstraintType(arg, fnName)
@ -198,7 +197,7 @@ const part001 = startSketchAt([0, 0])
|> yLine(segLen('seg01', %), %) // ln-yLineTo-free should convert to yLine
show(part001)`
it('should transform the ast', async () => {
const ast = abstractSyntaxTree(lexer(inputScript))
const ast = parser_wasm(inputScript)
const selectionRanges: Selections['codeBasedSelections'] = inputScript
.split('\n')
.filter((ln) => ln.includes('//'))
@ -283,7 +282,7 @@ const part001 = startSketchAt([0, 0])
|> xLineTo(myVar3, %) // select for horizontal constraint 10
|> angledLineToY([301, myVar], %) // select for vertical constraint 10
show(part001)`
const ast = abstractSyntaxTree(lexer(inputScript))
const ast = parser_wasm(inputScript)
const selectionRanges: Selections['codeBasedSelections'] = inputScript
.split('\n')
.filter((ln) => ln.includes('// select for horizontal constraint'))
@ -340,7 +339,7 @@ const part001 = startSketchAt([0, 0])
|> angledLineToX([333, myVar3], %) // select for horizontal constraint 10
|> yLineTo(myVar, %) // select for vertical constraint 10
show(part001)`
const ast = abstractSyntaxTree(lexer(inputScript))
const ast = parser_wasm(inputScript)
const selectionRanges: Selections['codeBasedSelections'] = inputScript
.split('\n')
.filter((ln) => ln.includes('// select for vertical constraint'))
@ -430,7 +429,7 @@ async function helperThing(
linesOfInterest: string[],
constraint: ConstraintType
): Promise<string> {
const ast = abstractSyntaxTree(lexer(inputScript))
const ast = parser_wasm(inputScript)
const selectionRanges: Selections['codeBasedSelections'] = inputScript
.split('\n')
.filter((ln) =>
@ -493,7 +492,7 @@ const part001 = startSketchAt([-0.01, -0.05])
|> xLine(-3.43 + 0, %) // full
|> angledLineOfXLength([243 + 0, 1.2 + 0], %) // full
show(part001)`
const ast = abstractSyntaxTree(lexer(code))
const ast = parser_wasm(code)
const constraintLevels: ReturnType<
typeof getConstraintLevelFromSourceRange
>[] = ['full', 'partial', 'free']

View File

@ -1,6 +1,5 @@
import { abstractSyntaxTree } from '../abstractSyntaxTree'
import { parser_wasm } from '../abstractSyntaxTree'
import { enginelessExecutor } from '../../lib/testHelpers'
import { lexer } from '../tokeniser'
import { initPromise } from '../rust'
beforeAll(() => initPromise)
@ -18,13 +17,9 @@ describe('testing angledLineThatIntersects', () => {
}, %)
const intersect = segEndX('yo2', part001)
show(part001)`
const { root } = await enginelessExecutor(
abstractSyntaxTree(lexer(code('-1')))
)
const { root } = await enginelessExecutor(parser_wasm(code('-1')))
expect(root.intersect.value).toBe(1 + Math.sqrt(2))
const { root: noOffset } = await enginelessExecutor(
abstractSyntaxTree(lexer(code('0')))
)
const { root: noOffset } = await enginelessExecutor(parser_wasm(code('0')))
expect(noOffset.intersect.value).toBeCloseTo(1)
})
})

View File

@ -25,9 +25,8 @@ import {
lastSegY,
} from './sketchConstraints'
import { getExtrudeWallTransform, extrude } from './extrude'
import { SketchGroup, ExtrudeGroup, Position, Rotation } from '../executor'
import { InternalFn, InternalFnNames, InternalFirstArg } from './stdTypes'
import { InternalFn, InternalFnNames } from './stdTypes'
// const transform: InternalFn = <T extends SketchGroup | ExtrudeGroup>(
// { sourceRange }: InternalFirstArg,

View File

@ -1,29 +1,28 @@
import { lexer_js } from '../wasm-lib/pkg/wasm_lib'
import { initPromise } from './rust'
import { Token } from '../wasm-lib/bindings/Token'
export interface Token {
type:
| 'number'
| 'word'
| 'operator'
| 'string'
| 'brace'
| 'whitespace'
| 'comma'
| 'colon'
| 'period'
| 'linecomment'
| 'blockcomment'
value: string
start: number
end: number
}
export type { Token } from '../wasm-lib/bindings/Token'
export async function asyncLexer(str: string): Promise<Token[]> {
await initPromise
return JSON.parse(lexer_js(str)) as Token[]
try {
const tokens: Token[] = lexer_js(str)
return tokens
} catch (e) {
// TODO: do something real with the error.
console.log('lexer', e)
throw e
}
}
export function lexer(str: string): Token[] {
return JSON.parse(lexer_js(str)) as Token[]
try {
const tokens: Token[] = lexer_js(str)
return tokens
} catch (e) {
// TODO: do something real with the error.
console.log('lexer', e)
throw e
}
}

102
src/lib/authMachine.ts Normal file
View File

@ -0,0 +1,102 @@
import { createMachine, assign } from 'xstate'
import { Models } from '@kittycad/lib'
import withBaseURL from '../lib/withBaseURL'
export interface UserContext {
user?: Models['User_type']
token?: string
}
export type Events =
| {
type: 'logout'
}
| {
type: 'tryLogin'
token?: string
}
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
export const authMachine = createMachine<UserContext, Events>(
{
id: 'Auth',
initial: 'checkIfLoggedIn',
states: {
checkIfLoggedIn: {
id: 'check-if-logged-in',
invoke: {
src: 'getUser',
id: 'check-logged-in',
onDone: [
{
target: 'loggedIn',
actions: assign({
user: (context, event) => event.data,
}),
},
],
onError: [
{
target: 'loggedOut',
actions: assign({
user: () => undefined,
}),
},
],
},
},
loggedIn: {
entry: ['goToIndexPage'],
on: {
logout: {
target: 'loggedOut',
},
},
},
loggedOut: {
entry: ['goToSignInPage'],
on: {
tryLogin: {
target: 'checkIfLoggedIn',
actions: assign({
token: (context, event) => {
const token = event.token || ''
localStorage.setItem(TOKEN_PERSIST_KEY, token)
return token
},
}),
},
},
},
},
schema: { events: {} as { type: 'logout' } | { type: 'tryLogin' } },
predictableActionArguments: true,
preserveActionOrder: true,
context: { token: persistedToken },
},
{
actions: {},
services: { getUser },
guards: {},
delays: {},
}
)
async function getUser(context: UserContext) {
const url = withBaseURL('/user')
const headers: { [key: string]: string } = {
'Content-Type': 'application/json',
}
if (!context.token && '__TAURI__' in window) throw 'not log in'
if (context.token) headers['Authorization'] = `Bearer ${context.token}`
const response = await fetch(url, {
method: 'GET',
credentials: 'include',
headers,
})
const user = await response.json()
if ('error_code' in user) throw new Error(user.message)
return user
}

View File

@ -39,6 +39,6 @@ export async function exportSave(data: ArrayBuffer) {
}
} catch (e) {
// TODO: do something real with the error.
console.log(e)
console.log('export', e)
}
}

View File

@ -1,10 +1,10 @@
import { useStore } from '../useStore'
import { useAuthMachine } from '../hooks/useAuthMachine'
export default async function fetcher<JSON = any>(
input: RequestInfo,
init: RequestInit = {}
): Promise<JSON> {
const { token } = useStore.getState()
const [token] = useAuthMachine((s) => s?.context?.token)
const headers = { ...init.headers } as Record<string, string>
if (token) {
headers.Authorization = `Bearer ${token}`

View File

@ -0,0 +1,3 @@
export default function makeUrlPathRelative(path: string) {
return path.replace(/^\//, '')
}

48
src/lib/tauriFS.test.ts Normal file
View File

@ -0,0 +1,48 @@
import {
MAX_PADDING,
getNextProjectIndex,
interpolateProjectNameWithIndex,
} from './tauriFS'
describe('Test file utility functions', () => {
it('interpolates a project name without an index', () => {
expect(interpolateProjectNameWithIndex('test', 1)).toBe('test')
})
it('interpolates a project name with an index and no padding', () => {
expect(interpolateProjectNameWithIndex('test-$n', 2)).toBe('test-2')
})
it('interpolates a project name with an index and padding', () => {
expect(interpolateProjectNameWithIndex('test-$nnn', 12)).toBe('test-012')
})
it('interpolates a project name with an index and max padding', () => {
expect(interpolateProjectNameWithIndex('test-$nnnnnnnnnnn', 3)).toBe(
`test-${'0'.repeat(MAX_PADDING)}3`
)
})
const testFiles = [
{
name: 'new-project-04.kcl',
path: '/projects/new-project-04.kcl',
},
{
name: 'new-project-007.kcl',
path: '/projects/new-project-007.kcl',
},
{
name: 'new-project-05.kcl',
path: '/projects/new-project-05.kcl',
},
{
name: 'new-project-0.kcl',
path: '/projects/new-project-0.kcl',
},
]
it('gets the correct next project index', () => {
expect(getNextProjectIndex('new-project-$n', testFiles)).toBe(8)
})
})

143
src/lib/tauriFS.ts Normal file
View File

@ -0,0 +1,143 @@
import { FileEntry, createDir, exists, writeTextFile } from '@tauri-apps/api/fs'
import { documentDir } from '@tauri-apps/api/path'
import { useStore } from '../useStore'
import { isTauri } from './isTauri'
import { ProjectWithEntryPointMetadata } from '../Router'
import { metadata } from 'tauri-plugin-fs-extra-api'
const PROJECT_FOLDER = 'kittycad-modeling-projects'
export const FILE_EXT = '.kcl'
export const PROJECT_ENTRYPOINT = 'main' + FILE_EXT
const INDEX_IDENTIFIER = '$n' // $nn.. will pad the number with 0s
export const MAX_PADDING = 7
// Initializes the project directory and returns the path
export async function initializeProjectDirectory() {
if (!isTauri()) {
throw new Error(
'initializeProjectDirectory() can only be called from a Tauri app'
)
}
const { defaultDir: projectDir, setDefaultDir } = useStore.getState()
if (projectDir && projectDir.dir.length > 0) {
const dirExists = await exists(projectDir.dir)
if (!dirExists) {
await createDir(projectDir.dir, { recursive: true })
}
return projectDir
}
const appData = await documentDir()
const INITIAL_DEFAULT_DIR = {
dir: appData + PROJECT_FOLDER,
}
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR.dir)
if (!defaultDirExists) {
await createDir(INITIAL_DEFAULT_DIR.dir, { recursive: true })
}
setDefaultDir(INITIAL_DEFAULT_DIR)
return INITIAL_DEFAULT_DIR
}
export function isProjectDirectory(fileOrDir: Partial<FileEntry>) {
return (
fileOrDir.children?.length &&
fileOrDir.children.some((child) => child.name === PROJECT_ENTRYPOINT)
)
}
// Creates a new file in the default directory with the default project name
// Returns the path to the new file
export async function createNewProject(
path: string
): Promise<ProjectWithEntryPointMetadata> {
if (!isTauri) {
throw new Error('createNewProject() can only be called from a Tauri app')
}
const dirExists = await exists(path)
if (!dirExists) {
await createDir(path, { recursive: true }).catch((err) => {
console.error('Error creating new directory:', err)
throw err
})
}
await writeTextFile(path + '/' + PROJECT_ENTRYPOINT, '').catch((err) => {
console.error('Error creating new file:', err)
throw err
})
const m = await metadata(path)
return {
name: path.slice(path.lastIndexOf('/') + 1),
path: path,
entrypoint_metadata: m,
children: [
{
name: PROJECT_ENTRYPOINT,
path: path + '/' + PROJECT_ENTRYPOINT,
children: [],
},
],
}
}
// create a regex to match the project name
// replacing any instances of "$n" with a regex to match any number
function interpolateProjectName(projectName: string) {
const regex = new RegExp(
projectName.replace(getPaddedIdentifierRegExp(), '([0-9]+)')
)
return regex
}
// Returns the next available index for a project name
export function getNextProjectIndex(projectName: string, files: FileEntry[]) {
const regex = interpolateProjectName(projectName)
const matches = files.map((file) => file.name?.match(regex))
const indices = matches
.filter(Boolean)
.map((match) => match![1])
.map(Number)
const maxIndex = Math.max(...indices, -1)
return maxIndex + 1
}
// Interpolates the project name with the next available index,
// padding the index with 0s if necessary
export function interpolateProjectNameWithIndex(
projectName: string,
index: number
) {
const regex = getPaddedIdentifierRegExp()
const matches = projectName.match(regex)
const padStartLength = Math.min(
matches !== null ? matches[1]?.length || 0 : 0,
MAX_PADDING
)
return projectName.replace(
regex,
index.toString().padStart(padStartLength + 1, '0')
)
}
export function doesProjectNameNeedInterpolated(projectName: string) {
return projectName.includes(INDEX_IDENTIFIER)
}
function escapeRegExpChars(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function getPaddedIdentifierRegExp() {
const escapedIdentifier = escapeRegExpChars(INDEX_IDENTIFIER)
return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`)
}

272
src/routes/Home.tsx Normal file
View File

@ -0,0 +1,272 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { readDir, removeDir, renameFile } from '@tauri-apps/api/fs'
import {
createNewProject,
getNextProjectIndex,
interpolateProjectNameWithIndex,
doesProjectNameNeedInterpolated,
isProjectDirectory,
PROJECT_ENTRYPOINT,
} from '../lib/tauriFS'
import { ActionButton } from '../components/ActionButton'
import {
faArrowDown,
faArrowUp,
faCircleDot,
faPlus,
} from '@fortawesome/free-solid-svg-icons'
import { useStore } from '../useStore'
import { toast } from 'react-hot-toast'
import { AppHeader } from '../components/AppHeader'
import ProjectCard from '../components/ProjectCard'
import { useLoaderData, useSearchParams } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
import Loading from '../components/Loading'
import { metadata } from 'tauri-plugin-fs-extra-api'
const DESC = ':desc'
// This route only opens in the Tauri desktop context for now,
// as defined in Router.tsx, so we can use the Tauri APIs and types.
const Home = () => {
const [searchParams, setSearchParams] = useSearchParams()
const sort = searchParams.get('sort_by') ?? 'modified:desc'
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
const [isLoading, setIsLoading] = useState(true)
const [projects, setProjects] = useState(loadedProjects || [])
const { defaultDir, defaultProjectName } = useStore((s) => ({
defaultDir: s.defaultDir,
defaultProjectName: s.defaultProjectName,
}))
const modifiedSelected = sort?.includes('modified') || !sort || sort === null
const refreshProjects = useCallback(
async (projectDir = defaultDir) => {
const readProjects = (
await readDir(projectDir.dir, {
recursive: true,
})
).filter(isProjectDirectory)
const projectsWithMetadata = await Promise.all(
readProjects.map(async (p) => ({
entrypoint_metadata: await metadata(
p.path + '/' + PROJECT_ENTRYPOINT
),
...p,
}))
)
setProjects(projectsWithMetadata)
},
[defaultDir, setProjects]
)
useEffect(() => {
refreshProjects(defaultDir).then(() => {
setIsLoading(false)
})
}, [setIsLoading, refreshProjects, defaultDir])
async function handleNewProject() {
let projectName = defaultProjectName
if (doesProjectNameNeedInterpolated(projectName)) {
const nextIndex = await getNextProjectIndex(defaultProjectName, projects)
projectName = interpolateProjectNameWithIndex(
defaultProjectName,
nextIndex
)
}
await createNewProject(defaultDir.dir + '/' + projectName).catch((err) => {
console.error('Error creating project:', err)
toast.error('Error creating project')
})
await refreshProjects()
toast.success('Project created')
}
async function handleRenameProject(
e: FormEvent<HTMLFormElement>,
project: ProjectWithEntryPointMetadata
) {
const { newProjectName } = Object.fromEntries(
new FormData(e.target as HTMLFormElement)
)
if (newProjectName && project.name && newProjectName !== project.name) {
const dir = project.path?.slice(0, project.path?.lastIndexOf('/'))
await renameFile(project.path, dir + '/' + newProjectName).catch(
(err) => {
console.error('Error renaming project:', err)
toast.error('Error renaming project')
}
)
await refreshProjects()
toast.success('Project renamed')
}
}
async function handleDeleteProject(project: ProjectWithEntryPointMetadata) {
if (project.path) {
await removeDir(project.path, { recursive: true }).catch((err) => {
console.error('Error deleting project:', err)
toast.error('Error deleting project')
})
await refreshProjects()
toast.success('Project deleted')
}
}
function getSortIcon(sortBy: string) {
if (sort === sortBy) {
return faArrowUp
} else if (sort === sortBy + DESC) {
return faArrowDown
}
return faCircleDot
}
function getNextSearchParams(sortBy: string) {
if (sort === null || !sort)
return { sort_by: sortBy + (sortBy !== 'modified' ? DESC : '') }
if (sort.includes(sortBy) && !sort.includes(DESC)) return { sort_by: '' }
return {
sort_by: sortBy + (sort.includes(DESC) ? '' : DESC),
}
}
function getSortFunction(sortBy: string) {
const sortByName = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (a.name && b.name) {
return sortBy.includes('desc')
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
}
return 0
}
const sortByModified = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (
a.entrypoint_metadata?.modifiedAt &&
b.entrypoint_metadata?.modifiedAt
) {
return !sortBy || sortBy.includes('desc')
? b.entrypoint_metadata.modifiedAt.getTime() -
a.entrypoint_metadata.modifiedAt.getTime()
: a.entrypoint_metadata.modifiedAt.getTime() -
b.entrypoint_metadata.modifiedAt.getTime()
}
return 0
}
if (sortBy?.includes('name')) {
return sortByName
} else {
return sortByModified
}
}
return (
<div className="h-screen overflow-hidden relative flex flex-col">
<AppHeader showToolbar={false} />
<div className="my-24 overflow-y-auto max-w-5xl w-full mx-auto">
<section className="flex justify-between">
<h1 className="text-3xl text-bold">Your Projects</h1>
<div className="flex">
<ActionButton
Element="button"
className={
!sort.includes('name')
? 'text-chalkboard-80 dark:text-chalkboard-40'
: ''
}
onClick={() => setSearchParams(getNextSearchParams('name'))}
icon={{
icon: getSortIcon('name'),
bgClassName: !sort?.includes('name')
? 'bg-liquid-50 dark:bg-liquid-70'
: '',
iconClassName: !sort?.includes('name')
? 'text-liquid-80 dark:text-liquid-30'
: '',
}}
>
Name
</ActionButton>
<ActionButton
Element="button"
className={
!modifiedSelected
? 'text-chalkboard-80 dark:text-chalkboard-40'
: ''
}
onClick={() => setSearchParams(getNextSearchParams('modified'))}
icon={{
icon: sort ? getSortIcon('modified') : faArrowDown,
bgClassName: !modifiedSelected
? 'bg-liquid-50 dark:bg-liquid-70'
: '',
iconClassName: !modifiedSelected
? 'text-liquid-80 dark:text-liquid-30'
: '',
}}
>
Last Modified
</ActionButton>
</div>
</section>
<section>
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
Are being saved at{' '}
<code className="text-liquid-80 dark:text-liquid-30">
{defaultDir.dir}
</code>
, which you can change in your <Link to="settings">Settings</Link>.
</p>
{isLoading ? (
<Loading>Loading your Projects...</Loading>
) : (
<>
{projects.length > 0 ? (
<ul className="my-8 w-full grid grid-cols-4 gap-4">
{projects.sort(getSortFunction(sort)).map((project) => (
<ProjectCard
key={project.name}
project={project}
handleRenameProject={handleRenameProject}
handleDeleteProject={handleDeleteProject}
/>
))}
</ul>
) : (
<p className="rounded my-8 border border-dashed border-chalkboard-30 dark:border-chalkboard-70 p-4">
No Projects found, ready to make your first one?
</p>
)}
<ActionButton
Element="button"
onClick={handleNewProject}
icon={{ icon: faPlus }}
>
New file
</ActionButton>
</>
)}
</section>
</div>
</div>
)
}
export default Home

View File

@ -1,14 +1,14 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../../components/ActionButton'
import { useDismiss, useNextClick } from '.'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore'
const Units = () => {
export default function Units() {
const { isMouseDownInStream } = useStore((s) => ({
isMouseDownInStream: s.isMouseDownInStream,
}))
const dismiss = useDismiss()
const next = useNextClick('sketching')
const next = useNextClick(onboardingPaths.SKETCHING)
return (
<div className="fixed grid justify-center items-end inset-0 z-50 pointer-events-none">
@ -26,7 +26,8 @@ const Units = () => {
</p>
<div className="flex justify-between mt-6">
<ActionButton
onClick={dismiss}
Element="button"
onClick={() => dismiss('../../')}
icon={{
icon: faXmark,
bgClassName: 'bg-destroy-80',
@ -37,7 +38,11 @@ const Units = () => {
>
Dismiss
</ActionButton>
<ActionButton onClick={next} icon={{ icon: faArrowRight }}>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Sketching
</ActionButton>
</div>
@ -45,5 +50,3 @@ const Units = () => {
</div>
)
}
export default Units

View File

@ -1,10 +1,10 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../../components/ActionButton'
import { useDismiss, useNextClick } from '.'
import { onboardingPaths, useDismiss, useNextClick } from '.'
const Introduction = () => {
export default function Introduction() {
const dismiss = useDismiss()
const next = useNextClick('units')
const next = useNextClick(onboardingPaths.UNITS)
return (
<div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50">
@ -22,7 +22,8 @@ const Introduction = () => {
</p>
<div className="flex justify-between mt-6">
<ActionButton
onClick={dismiss}
Element="button"
onClick={() => dismiss('../')}
icon={{
icon: faXmark,
bgClassName: 'bg-destroy-80',
@ -33,7 +34,11 @@ const Introduction = () => {
>
Dismiss
</ActionButton>
<ActionButton onClick={next} icon={{ icon: faArrowRight }}>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Get Started
</ActionButton>
</div>
@ -41,5 +46,3 @@ const Introduction = () => {
</div>
)
}
export default Introduction

View File

@ -2,7 +2,7 @@ import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../../components/ActionButton'
import { useDismiss } from '.'
const Sketching = () => {
export default function Sketching() {
const dismiss = useDismiss()
return (
@ -14,7 +14,8 @@ const Sketching = () => {
</p>
<div className="flex justify-between mt-6">
<ActionButton
onClick={dismiss}
Element="button"
onClick={() => dismiss('../../')}
icon={{
icon: faXmark,
bgClassName: 'bg-destroy-80',
@ -25,7 +26,11 @@ const Sketching = () => {
>
Dismiss
</ActionButton>
<ActionButton onClick={dismiss} icon={{ icon: faArrowRight }}>
<ActionButton
Element="button"
onClick={() => dismiss('../../')}
icon={{ icon: faArrowRight }}
>
Finish
</ActionButton>
</div>
@ -33,5 +38,3 @@ const Sketching = () => {
</div>
)
}
export default Sketching

View File

@ -4,11 +4,11 @@ import { ActionButton } from '../../components/ActionButton'
import { SettingsSection } from '../Settings'
import { Toggle } from '../../components/Toggle/Toggle'
import { useState } from 'react'
import { useDismiss, useNextClick } from '.'
import { onboardingPaths, useDismiss, useNextClick } from '.'
const Units = () => {
export default function Units() {
const dismiss = useDismiss()
const next = useNextClick('camera')
const next = useNextClick(onboardingPaths.CAMERA)
const {
defaultUnitSystem: ogDefaultUnitSystem,
setDefaultUnitSystem: saveDefaultUnitSystem,
@ -67,7 +67,8 @@ const Units = () => {
</SettingsSection>
<div className="flex justify-between mt-6">
<ActionButton
onClick={dismiss}
Element="button"
onClick={() => dismiss('../../')}
icon={{
icon: faXmark,
bgClassName: 'bg-destroy-80',
@ -78,7 +79,11 @@ const Units = () => {
>
Dismiss
</ActionButton>
<ActionButton onClick={handleNextClick} icon={{ icon: faArrowRight }}>
<ActionButton
Element="button"
onClick={handleNextClick}
icon={{ icon: faArrowRight }}
>
Next: Camera
</ActionButton>
</div>
@ -86,5 +91,3 @@ const Units = () => {
</div>
)
}
export default Units

View File

@ -7,13 +7,13 @@ import Units from './Units'
import Camera from './Camera'
import Sketching from './Sketching'
import { useCallback } from 'react'
import { paths } from '../../Router'
import makeUrlPathRelative from '../../lib/makeUrlPathRelative'
export const onboardingPaths = {
INDEX: '',
UNITS: 'units',
CAMERA: 'camera',
SKETCHING: 'sketching',
INDEX: '/',
UNITS: '/units',
CAMERA: '/camera',
SKETCHING: '/sketching',
}
export const onboardingRoutes = [
@ -22,15 +22,15 @@ export const onboardingRoutes = [
element: <Introduction />,
},
{
path: onboardingPaths.UNITS,
path: makeUrlPathRelative(onboardingPaths.UNITS),
element: <Units />,
},
{
path: onboardingPaths.CAMERA,
path: makeUrlPathRelative(onboardingPaths.CAMERA),
element: <Camera />,
},
{
path: onboardingPaths.SKETCHING,
path: makeUrlPathRelative(onboardingPaths.SKETCHING),
element: <Sketching />,
},
]
@ -43,7 +43,7 @@ export function useNextClick(newStatus: string) {
return useCallback(() => {
setOnboardingStatus(newStatus)
navigate('/onboarding/' + newStatus)
navigate((newStatus !== onboardingPaths.UNITS ? '..' : '.') + newStatus)
}, [newStatus, setOnboardingStatus, navigate])
}
@ -53,15 +53,18 @@ export function useDismiss() {
}))
const navigate = useNavigate()
return useCallback(() => {
setOnboardingStatus('dismissed')
navigate(paths.INDEX)
}, [setOnboardingStatus, navigate])
return useCallback(
(path: string) => {
setOnboardingStatus('dismissed')
navigate(path)
},
[setOnboardingStatus, navigate]
)
}
const Onboarding = () => {
const dismiss = useDismiss()
useHotkeys('esc', dismiss)
useHotkeys('esc', () => dismiss('../'))
return (
<>

View File

@ -10,13 +10,14 @@ import { Themes, baseUnits, useStore } from '../useStore'
import { useRef } from 'react'
import { toast } from 'react-hot-toast'
import { Toggle } from '../components/Toggle/Toggle'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useRouteLoaderData } from 'react-router-dom'
import { useHotkeys } from 'react-hotkeys-hook'
import { paths } from '../Router'
import { IndexLoaderData, paths } from '../Router'
export const Settings = () => {
const loaderData = useRouteLoaderData(paths.FILE) as IndexLoaderData
const navigate = useNavigate()
useHotkeys('esc', () => navigate(paths.INDEX))
useHotkeys('esc', () => navigate('../'))
const {
defaultDir,
setDefaultDir,
@ -46,6 +47,7 @@ export const Settings = () => {
theme: s.theme,
setTheme: s.setTheme,
}))
const ogDefaultDir = useRef(defaultDir)
const ogDefaultProjectName = useRef(defaultProjectName)
async function handleDirectorySelection() {
@ -62,10 +64,10 @@ export const Settings = () => {
return (
<div className="body-bg fixed inset-0 z-40 overflow-auto">
<AppHeader showToolbar={false}>
<AppHeader showToolbar={false} project={loaderData?.project}>
<ActionButton
Element="link"
to={paths.INDEX}
to={'../'}
icon={{
icon: faXmark,
bgClassName: 'bg-destroy-80',
@ -79,56 +81,75 @@ export const Settings = () => {
</AppHeader>
<div className="my-24 max-w-5xl mx-auto">
<h1 className="text-4xl font-bold">User Settings</h1>
{(window as any).__TAURI__ && (
<SettingsSection
title="Default Directory"
description="Where newly-created projects are saved on your local computer"
<p className="mt-6 max-w-2xl">
Don't see the feature you want? Check to see if it's on{' '}
<a
href="https://github.com/KittyCAD/modeling-app/discussions"
target="_blank"
rel="noopener noreferrer"
>
<div className="w-full flex gap-4 p-1 rounded border border-chalkboard-30">
our roadmap
</a>
, and start a discussion if you don't see it! Your feedback will help
us prioritize what to build next.
</p>
{(window as any).__TAURI__ && (
<>
<SettingsSection
title="Default Directory"
description="Where newly-created projects are saved on your local computer"
>
<div className="w-full flex gap-4 p-1 rounded border border-chalkboard-30">
<input
className="flex-1 px-2 bg-transparent"
value={defaultDir.dir}
onChange={(e) => {
setDefaultDir({
base: defaultDir.base,
dir: e.target.value,
})
}}
onBlur={() => {
ogDefaultDir.current.dir !== defaultDir.dir &&
toast.success('Default directory updated')
ogDefaultDir.current.dir = defaultDir.dir
}}
/>
<ActionButton
Element="button"
className="bg-chalkboard-100 dark:bg-chalkboard-90 hover:bg-chalkboard-90 dark:hover:bg-chalkboard-80 !text-chalkboard-10 border-chalkboard-100 hover:border-chalkboard-70"
onClick={handleDirectorySelection}
icon={{
icon: faFolder,
bgClassName:
'bg-liquid-20 group-hover:bg-liquid-10 hover:bg-liquid-10',
iconClassName:
'text-liquid-90 group-hover:text-liquid-90 hover:text-liquid-90',
}}
>
Choose a folder
</ActionButton>
</div>
</SettingsSection>
<SettingsSection
title="Default Project Name"
description="Name template for new projects. Use $n to include an incrementing index"
>
<input
className="flex-1 px-2 bg-transparent"
value={defaultDir.dir}
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
value={defaultProjectName}
onChange={(e) => {
setDefaultDir({
base: defaultDir.base,
dir: e.target.value,
})
toast.success('Default directory updated')
setDefaultProjectName(e.target.value)
}}
onBlur={() => {
ogDefaultProjectName.current !== defaultProjectName &&
toast.success('Default project name updated')
ogDefaultProjectName.current = defaultProjectName
}}
/>
<ActionButton
Element="button"
className="bg-chalkboard-100 hover:bg-chalkboard-90 text-chalkboard-10 border-chalkboard-100 hover:border-chalkboard-70"
onClick={handleDirectorySelection}
icon={{
icon: faFolder,
bgClassName:
'bg-liquid-20 group-hover:bg-liquid-10 hover:bg-liquid-10',
iconClassName:
'text-liquid-90 group-hover:text-liquid-90 hover:text-liquid-90',
}}
>
Choose a folder
</ActionButton>
</div>
</SettingsSection>
</SettingsSection>
</>
)}
<SettingsSection
title="Default Project Name"
description="Name template for new projects. Use $n to include an incrementing index"
>
<input
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
value={defaultProjectName}
onChange={(e) => {
setDefaultProjectName(e.target.value)
}}
onBlur={() => {
ogDefaultProjectName.current !== defaultProjectName &&
toast.success('Default project name updated')
}}
/>
</SettingsSection>
<SettingsSection
title="Unit System"
description="Which unit system to use by default"
@ -210,9 +231,10 @@ export const Settings = () => {
description="Replay the onboarding process"
>
<ActionButton
Element="button"
onClick={() => {
setOnboardingStatus('')
navigate(paths.ONBOARDING.INDEX)
navigate('..' + paths.ONBOARDING.INDEX)
}}
icon={{ icon: faArrowRotateBack }}
>

View File

@ -7,13 +7,14 @@ import { useNavigate } from 'react-router-dom'
import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env'
import { getSystemTheme } from '../lib/getSystemTheme'
import { paths } from '../Router'
import { useAuthMachine } from '../hooks/useAuthMachine'
const SignIn = () => {
const navigate = useNavigate()
const { setToken, theme } = useStore((s) => ({
setToken: s.setToken,
const { theme } = useStore((s) => ({
theme: s.theme,
}))
const [_, send] = useAuthMachine()
const appliedTheme = theme === Themes.System ? getSystemTheme() : theme
const signInTauri = async () => {
// We want to invoke our command to login via device auth.
@ -21,8 +22,7 @@ const SignIn = () => {
const token: string = await invoke('login', {
host: VITE_KC_API_BASE_URL,
})
setToken(token)
navigate(paths.INDEX)
send({ type: 'tryLogin', token })
} catch (error) {
console.error('login button', error)
}
@ -61,6 +61,7 @@ const SignIn = () => {
</p>
{isTauri() ? (
<ActionButton
Element="button"
onClick={signInTauri}
icon={{ icon: faSignInAlt }}
className="w-fit mt-4"

View File

@ -2,7 +2,6 @@ import '@testing-library/jest-dom'
import { WebSocket } from 'ws'
class MockRTCPeerConnection {
constructor() {}
createDataChannel() {
return
}

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