Compare commits

..

1 Commits

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest-8-cores
strategy:
matrix:
dir: ['src/wasm-lib']
dir: ['src/wasm-lib', 'src-tauri']
steps:
- uses: actions/checkout@v3
- name: Install latest rust
@ -45,7 +45,4 @@ jobs:
shell: bash
run: |-
cd "${{ matrix.dir }}"
cargo test --all
env:
KITTYCAD_API_TOKEN: ${{secrets.KITTYCAD_API_TOKEN}}
cargo llvm-cov nextest --lcov --output-path lcov.info --test-threads=1 --no-fail-fast

View File

@ -133,7 +133,8 @@ jobs:
- name: Generate the update static endpoint
run: |
ls -l artifact/*/*itty*
ls -l artifact
ls -l artifact/*
DARWIN_SIG=`cat artifact/macos/*.app.tar.gz.sig`
LINUX_SIG=`cat artifact/appimage/*.AppImage.tar.gz.sig`
WINDOWS_SIG=`cat artifact/nsis/*.nsis.zip.sig`
@ -141,11 +142,11 @@ jobs:
jq --null-input \
--arg version "v${VERSION_NO_V}" \
--arg darwin_sig "$DARWIN_SIG" \
--arg darwin_url "$RELEASE_DIR/macos/KittyCAD%20Modeling.app.tar.gz" \
--arg darwin_url "$RELEASE_DIR/macos/kittycad-modeling-app.app.tar.gz" \
--arg linux_sig "$LINUX_SIG" \
--arg linux_url "$RELEASE_DIR/appimage/kittycad-modeling_${VERSION_NO_V}_amd64.AppImage.tar.gz" \
--arg linux_url "$RELEASE_DIR/appimage/kittycad-modeling-app_${VERSION_NO_V}_amd64.AppImage.tar.gz" \
--arg windows_sig "$WINDOWS_SIG" \
--arg windows_url "$RELEASE_DIR/nsis/KittyCAD%20Modeling_${VERSION_NO_V}_x64-setup.nsis.zip" \
--arg windows_url "$RELEASE_DIR/nsis/kittycad-modeling-app_${VERSION_NO_V}_x64-setup.nsis.zip" \
'{
"version": $version,
"platforms": {
@ -179,7 +180,7 @@ jobs:
uses: google-github-actions/upload-cloud-storage@v1.0.3
with:
path: artifact
glob: '*/*itty*'
glob: '*/kittycad-modeling-app*'
parent: false
destination: dl.kittycad.io/releases/modeling-app/v${{ env.VERSION_NO_V }}
@ -188,8 +189,3 @@ jobs:
with:
path: last_update.json
destination: dl.kittycad.io/releases/modeling-app
- name: Upload release files to Github
uses: softprops/action-gh-release@v1
with:
files: artifact/*/*itty*

1
.gitignore vendored
View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

77
src-tauri/Cargo.lock generated
View File

@ -648,12 +648,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "errno"
version = "0.3.1"
@ -1156,7 +1150,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http",
"indexmap 1.9.3",
"indexmap",
"slab",
"tokio",
"tokio-util",
@ -1169,12 +1163,6 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a"
[[package]]
name = "heck"
version = "0.3.3"
@ -1390,18 +1378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d"
dependencies = [
"equivalent",
"hashbrown 0.14.0",
"hashbrown",
"serde",
]
@ -1651,12 +1628,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "933dca44d65cdd53b355d0b73d380a2ff5da71f87f036053188bf1eab6a19881"
[[package]]
name = "miniz_oxide"
version = "0.6.2"
@ -2151,7 +2122,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bd9647b268a3d3e14ff09c23201133a62589c658db02bb7388c7246aafe0590"
dependencies = [
"base64 0.21.2",
"indexmap 1.9.3",
"indexmap",
"line-wrap",
"quick-xml",
"serde",
@ -2724,15 +2695,14 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.2.0"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1402f54f9a3b9e2efe71c1cea24e648acce55887983553eeb858cf3115acfd49"
checksum = "07ff71d2c147a7b57362cead5e22f772cd52f6ab31cfcd9edcd7f6aeb2a0afbe"
dependencies = [
"base64 0.21.2",
"base64 0.13.1",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.0.0",
"indexmap",
"serde",
"serde_json",
"serde_with_macros",
@ -2741,9 +2711,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.2.0"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9197f1ad0e3c173a0222d3c4404fb04c3afe87e962bcb327af73e8301fa203c7"
checksum = "881b6f881b17d13214e5d494c939ebab463d01264ce1811e9d4ac3a882e7695f"
dependencies = [
"darling",
"proc-macro2",
@ -3052,7 +3022,6 @@ checksum = "d42ba3a2e8556722f31336a0750c10dbb6a81396a1c452977f515da83f69f842"
dependencies = [
"anyhow",
"attohttpc",
"base64 0.21.2",
"cocoa",
"dirs-next",
"embed_plist",
@ -3065,7 +3034,6 @@ dependencies = [
"heck 0.4.1",
"http",
"ignore",
"minisign-verify",
"objc",
"once_cell",
"open",
@ -3087,21 +3055,19 @@ dependencies = [
"tauri-utils",
"tempfile",
"thiserror",
"time",
"tokio",
"url",
"uuid",
"webkit2gtk",
"webview2-com",
"windows 0.39.0",
"zip",
]
[[package]]
name = "tauri-build"
version = "1.4.0"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d2edd6a259b5591c8efdeb9d5702cb53515b82a6affebd55c7fd6d3a27b7d1b"
checksum = "929b3bd1248afc07b63e33a6a53c3f82c32d0b0a5e216e4530e94c467e019389"
dependencies = [
"anyhow",
"cargo_toml",
@ -3112,6 +3078,7 @@ dependencies = [
"serde_json",
"tauri-utils",
"tauri-winres",
"winnow",
]
[[package]]
@ -3209,13 +3176,12 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "1.4.0"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03fc02bb6072bb397e1d473c6f76c953cda48b4a2d0cce605df284aa74a12e84"
checksum = "5a6f9c2dafef5cbcf52926af57ce9561bd33bb41d7394f8bb849c0330260d864"
dependencies = [
"brotli",
"ctor",
"dunce",
"glob",
"heck 0.4.1",
"html5ever",
@ -3431,7 +3397,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56c59d8dd7d0dcbc6428bf7aa2f0e823e26e43b3c9aca15bbc9475d23e5fa12b"
dependencies = [
"indexmap 1.9.3",
"indexmap",
"nom8",
"serde",
"serde_spanned",
@ -3444,7 +3410,7 @@ version = "0.19.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13"
dependencies = [
"indexmap 1.9.3",
"indexmap",
"serde",
"serde_spanned",
"toml_datetime 0.6.2",
@ -4262,14 +4228,3 @@ checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc"
dependencies = [
"libc",
]
[[package]]
name = "zip"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [
"byteorder",
"crc32fast",
"crossbeam-utils",
]

View File

@ -12,14 +12,14 @@ rust-version = "1.60"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.4.0", features = [] }
tauri-build = { version = "1.3.0", features = [] }
[dependencies]
anyhow = "1"
oauth2 = "4.4.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tauri = { version = "1.3.0", features = [ "updater", "path-all", "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" }

View File

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

View File

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

View File

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

View File

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

View File

@ -11,22 +11,20 @@ import { v4 as uuidv4 } from 'uuid'
import { asyncParser } from './lang/abstractSyntaxTree'
import { _executor } from './lang/executor'
import CodeMirror from '@uiw/react-codemirror'
import { langs } from '@uiw/codemirror-extensions-langs'
import { linter, lintGutter } from '@codemirror/lint'
import { ViewUpdate, EditorView } from '@codemirror/view'
import { ViewUpdate } from '@codemirror/view'
import {
lineHighlightField,
addLineHighlight,
} from './editor/highlightextension'
import { PaneType, Selections, useStore } from './useStore'
import Server from './editor/lsp/server'
import Client from './editor/lsp/client'
import { PaneType, Selections, Themes, useStore } from './useStore'
import { Logs, KCLErrors } from './components/Logs'
import { CollapsiblePanel } from './components/CollapsiblePanel'
import { MemoryPanel } from './components/MemoryPanel'
import { useHotKeyListener } from './hooks/useHotKeyListener'
import { Stream } from './components/Stream'
import ModalContainer from 'react-modal-promise'
import { FromServer, IntoServer } from './editor/lsp/codec'
import {
EngineCommand,
EngineCommandManager,
@ -43,17 +41,14 @@ import {
import { useHotkeys } from 'react-hotkeys-hook'
import { TEST } from './env'
import { getNormalisedCoordinates } from './lib/utils'
import { Themes, getSystemTheme } from './lib/theme'
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 { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { onboardingPaths } from 'routes/Onboarding'
import { LanguageServerClient } from 'editor/lsp'
import kclLanguage from 'editor/lsp/language'
import { useAuthMachine } from './hooks/useAuthMachine'
export function App() {
const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
@ -78,20 +73,23 @@ export function App() {
setArtifactMap,
engineCommandManager,
setEngineCommandManager,
highlightRange,
setHighlightRange,
setCursor2,
sourceRangeMap,
setMediaStream,
setIsStreamReady,
isStreamReady,
isLSPServerReady,
setIsLSPServerReady,
isMouseDownInStream,
cmdId,
setCmdId,
formatCode,
debugPanel,
theme,
openPanes,
setOpenPanes,
onboardingStatus,
didDragInStream,
setDidDragInStream,
setStreamDimensions,
streamDimensions,
} = useStore((s) => ({
@ -112,7 +110,6 @@ export function App() {
setArtifactMap: s.setArtifactNSourceRangeMaps,
engineCommandManager: s.engineCommandManager,
setEngineCommandManager: s.setEngineCommandManager,
highlightRange: s.highlightRange,
setHighlightRange: s.setHighlightRange,
isShiftDown: s.isShiftDown,
setCursor: s.setCursor,
@ -121,26 +118,22 @@ export function App() {
setMediaStream: s.setMediaStream,
isStreamReady: s.isStreamReady,
setIsStreamReady: s.setIsStreamReady,
isLSPServerReady: s.isLSPServerReady,
setIsLSPServerReady: s.setIsLSPServerReady,
isMouseDownInStream: s.isMouseDownInStream,
cmdId: s.cmdId,
setCmdId: s.setCmdId,
formatCode: s.formatCode,
debugPanel: s.debugPanel,
addKCLError: s.addKCLError,
theme: s.theme,
openPanes: s.openPanes,
setOpenPanes: s.setOpenPanes,
onboardingStatus: s.onboardingStatus,
didDragInStream: s.didDragInStream,
setDidDragInStream: s.setDidDragInStream,
setStreamDimensions: s.setStreamDimensions,
streamDimensions: s.streamDimensions,
}))
const {
auth: {
context: { token },
},
settings: {
context: { showDebugPanel, theme, onboardingStatus },
},
} = useGlobalStateContext()
const [token] = useAuthMachine((s) => s?.context?.token)
const editorTheme = theme === Themes.System ? getSystemTheme() : theme
@ -159,7 +152,7 @@ export function App() {
useHotkeys('shift + d', () => togglePane('debug'))
const paneOpacity =
onboardingStatus === onboardingPaths.CAMERA
onboardingStatus === 'camera'
? 'opacity-20'
: didDragInStream
? 'opacity-40'
@ -253,12 +246,13 @@ export function App() {
codeBasedSelections,
})
}
const pixelDensity = window.devicePixelRatio
const streamWidth = streamRef?.current?.offsetWidth
const streamHeight = streamRef?.current?.offsetHeight
const width = streamWidth ? streamWidth : 0
const width = streamWidth ? streamWidth * pixelDensity : 0
const quadWidth = Math.round(width / 4) * 4
const height = streamHeight ? streamHeight : 0
const height = streamHeight ? streamHeight * pixelDensity : 0
const quadHeight = Math.round(height / 4) * 4
useLayoutEffect(() => {
@ -282,8 +276,6 @@ export function App() {
useEffect(() => {
if (!isStreamReady) return
if (!engineCommandManager) return
let unsubFn: any[] = []
const asyncWrap = async () => {
try {
if (!code) {
@ -294,12 +286,27 @@ export function App() {
setAst(_ast)
resetLogs()
resetKCLErrors()
engineCommandManager.endSession()
engineCommandManager.startNewSession()
if (engineCommandManager) {
engineCommandManager.endSession()
engineCommandManager.startNewSession()
}
if (!engineCommandManager) return
const programMemory = await _executor(
_ast,
{
root: {
log: {
type: 'userVal',
value: (a: any) => {
addLog(a)
},
__meta: [
{
pathToNode: [],
sourceRange: [0, 0],
},
],
},
_0: {
type: 'userVal',
value: 0,
@ -321,40 +328,33 @@ export function App() {
__meta: [],
},
},
pendingMemory: {},
},
engineCommandManager
engineCommandManager,
{ bodyType: 'root' },
[]
)
const { artifactMap, sourceRangeMap } =
await engineCommandManager.waitForAllCommands()
setArtifactMap({ artifactMap, sourceRangeMap })
const unSubHover = engineCommandManager.subscribeToUnreliable({
event: 'highlight_set_entity',
callback: ({ data }) => {
if (data?.entity_id) {
const sourceRange = sourceRangeMap[data.entity_id]
setHighlightRange(sourceRange)
} else if (
!highlightRange ||
(highlightRange[0] !== 0 && highlightRange[1] !== 0)
) {
setHighlightRange([0, 0])
}
},
engineCommandManager.onHover((id) => {
if (!id) {
setHighlightRange([0, 0])
} else {
const sourceRange = sourceRangeMap[id]
setHighlightRange(sourceRange)
}
})
const unSubClick = engineCommandManager.subscribeTo({
event: 'select_with_point',
callback: ({ data }) => {
if (!data?.entity_id) {
setCursor2()
return
}
const sourceRange = sourceRangeMap[data.entity_id]
setCursor2({ range: sourceRange, type: 'default' })
},
engineCommandManager.onClick((selections) => {
if (!selections) {
setCursor2()
return
}
const { id, type } = selections
setCursor2({ range: sourceRangeMap[id], type })
})
unsubFn.push(unSubHover, unSubClick)
if (programMemory !== undefined) {
setProgramMemory(programMemory)
}
@ -371,10 +371,7 @@ export function App() {
}
}
asyncWrap()
return () => {
unsubFn.forEach((fn) => fn())
}
}, [code, isStreamReady, engineCommandManager])
}, [code, isStreamReady])
const debounceSocketSend = throttle<EngineCommand>((message) => {
engineCommandManager?.sendSceneCommand(message)
@ -388,6 +385,9 @@ export function App() {
nativeEvent,
}) => {
nativeEvent.preventDefault()
if (isMouseDownInStream) {
setDidDragInStream(true)
}
const { x, y } = getNormalisedCoordinates({
clientX,
@ -399,8 +399,9 @@ export function App() {
const interaction = ctrlKey ? 'zoom' : shiftKey ? 'pan' : 'rotate'
const newCmdId = uuidv4()
setCmdId(newCmdId)
if (isMouseDownInStream) {
if (cmdId && isMouseDownInStream) {
debounceSocketSend({
type: 'modeling_cmd_req',
cmd: {
@ -429,49 +430,9 @@ export function App() {
linter((_view) => {
return kclErrToDiagnostic(useStore.getState().kclErrors)
}),
EditorView.lineWrapping,
]
}, [])
// So this is a bit weird, we need to initialize the lsp server and client.
// But the server happens async so we break this into two parts.
// Below is the client and server promise.
const { lspClient } = useMemo(() => {
const intoServer: IntoServer = new IntoServer()
const fromServer: FromServer = FromServer.create()
const client = new Client(fromServer, intoServer)
if (!TEST) {
Server.initialize(intoServer, fromServer).then((lspServer) => {
lspServer.start()
setIsLSPServerReady(true)
})
}
const lspClient = new LanguageServerClient({ client })
return { lspClient }
}, [setIsLSPServerReady])
// Here we initialize the plugin which will start the client.
// When we have multi-file support the name of the file will be a dep of
// this use memo, as well as the directory structure, which I think is
// a good setup becuase it will restart the client but not the server :)
// We do not want to restart the server, its just wasteful.
const kclLSP = useMemo(() => {
let plugin = null
if (isLSPServerReady && !TEST) {
// Set up the lsp plugin.
const lsp = kclLanguage({
// When we have more than one file, we'll need to change this.
documentUri: `file:///we-just-have-one-file-for-now.kcl`,
workspaceFolders: null,
client: lspClient,
})
plugin = lsp
}
return plugin
}, [lspClient, isLSPServerReady])
return (
<div
className="h-screen overflow-hidden relative flex flex-col cursor-pointer select-none"
@ -513,7 +474,7 @@ export function App() {
<CollapsiblePanel
title="Code"
icon={faCode}
className="open:!mb-2 overflow-x-hidden"
className="open:!mb-2"
open={openPanes.includes('code')}
>
<div className="px-2 py-1">
@ -525,18 +486,15 @@ export function App() {
format
</button>
</div>
<div
id="code-mirror-override"
className="overflow-x-hidden h-full"
>
<div id="code-mirror-override">
<CodeMirror
className="h-full overflow-hidden-x"
className="h-full"
value={code}
extensions={
kclLSP
? [kclLSP, lineHighlightField, ...extraExtensions]
: [lineHighlightField, ...extraExtensions]
}
extensions={[
langs.javascript({ jsx: true }),
lineHighlightField,
...extraExtensions,
]}
onChange={onChange}
onUpdate={onUpdate}
theme={editorTheme}
@ -567,7 +525,7 @@ export function App() {
</div>
</Resizable>
<Stream className="absolute inset-0 z-0" />
{showDebugPanel && (
{debugPanel && (
<DebugPanel
title="Debug"
className={

View File

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

View File

@ -3,15 +3,8 @@ import {
createBrowserRouter,
Outlet,
redirect,
useLocation,
RouterProvider,
} from 'react-router-dom'
import {
matchRoutes,
createRoutesFromChildren,
useNavigationType,
} from 'react-router'
import { useEffect } from 'react'
import { ErrorPage } from './components/ErrorPage'
import { Settings } from './routes/Settings'
import Onboarding, {
@ -31,47 +24,7 @@ import {
} from './lib/tauriFS'
import { metadata, type Metadata } from 'tauri-plugin-fs-extra-api'
import DownloadAppBanner from './components/DownloadAppBanner'
import { GlobalStateProvider } from './components/GlobalStateProvider'
import {
SETTINGS_PERSIST_KEY,
settingsMachine,
} from './machines/settingsMachine'
import { ContextFrom } from 'xstate'
import CommandBarProvider from 'components/CommandBar'
import { TEST, VITE_KC_SENTRY_DSN } from './env'
import * as Sentry from '@sentry/react'
if (VITE_KC_SENTRY_DSN && !TEST) {
Sentry.init({
dsn: VITE_KC_SENTRY_DSN,
// TODO(paultag): pass in the right env here.
// environment: "production",
integrations: [
new Sentry.BrowserTracing({
routingInstrumentation: Sentry.reactRouterV6Instrumentation(
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes
),
}),
new Sentry.Replay(),
],
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
tracesSampleRate: 1.0,
// TODO: Add in kittycad.io endpoints
tracePropagationTargets: ['localhost'],
// Capture Replay for 10% of all sessions,
// plus for 100% of sessions with an error
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
})
}
import { GlobalStateProvider } from './hooks/useAuthMachine'
const prependRoutes =
(routesObject: Record<string, string>) => (prepend: string) => {
@ -115,11 +68,7 @@ const addGlobalContextToElements = (
'element' in route
? {
...route,
element: (
<CommandBarProvider>
<GlobalStateProvider>{route.element}</GlobalStateProvider>
</CommandBarProvider>
),
element: <GlobalStateProvider>{route.element}</GlobalStateProvider>,
}
: route
)
@ -146,25 +95,26 @@ const router = createBrowserRouter(
request,
params,
}): Promise<IndexLoaderData | Response> => {
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
ContextFrom<typeof settingsMachine>
>
const store = localStorage.getItem('store')
if (store === null) {
return redirect(paths.ONBOARDING.INDEX)
} else {
const status = JSON.parse(store).state.onboardingStatus || ''
const notEnRouteToOnboarding =
!request.url.includes(paths.ONBOARDING.INDEX) &&
request.method === 'GET'
// '' is the initial state, 'done' and 'dismissed' are the final states
const hasValidOnboardingStatus =
(status !== undefined && status.length === 0) ||
!(status === 'done' || status === 'dismissed')
const shouldRedirectToOnboarding =
notEnRouteToOnboarding && hasValidOnboardingStatus
const status = persistedSettings.onboardingStatus || ''
const notEnRouteToOnboarding = !request.url.includes(
paths.ONBOARDING.INDEX
)
// '' is the initial state, 'done' and 'dismissed' are the final states
const hasValidOnboardingStatus =
status.length === 0 || !(status === 'done' || status === 'dismissed')
const shouldRedirectToOnboarding =
notEnRouteToOnboarding && hasValidOnboardingStatus
if (shouldRedirectToOnboarding) {
return redirect(
makeUrlPathRelative(paths.ONBOARDING.INDEX) + status.slice(1)
)
if (shouldRedirectToOnboarding) {
return redirect(
makeUrlPathRelative(paths.ONBOARDING.INDEX) + status
)
}
}
if (params.id && params.id !== 'new') {
@ -214,23 +164,9 @@ const router = createBrowserRouter(
if (!isTauri()) {
return redirect(paths.FILE + '/new')
}
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
ContextFrom<typeof settingsMachine>
>
const projectDir = await initializeProjectDirectory(
persistedSettings.defaultDirectory || ''
)
if (projectDir !== persistedSettings.defaultDirectory) {
localStorage.setItem(
SETTINGS_PERSIST_KEY,
JSON.stringify({
...persistedSettings,
defaultDirectory: projectDir,
})
)
}
const projectsNoMeta = (await readDir(projectDir)).filter(
const projectDir = await initializeProjectDirectory()
const projectsNoMeta = (await readDir(projectDir.dir)).filter(
isProjectDirectory
)
const projects = await Promise.all(

View File

@ -1,60 +0,0 @@
.toolbarWrapper {
@apply relative;
}
.toolbar {
@apply flex gap-4 items-center rounded-full;
@apply border border-cool-20/30 bg-cool-10/50;
}
:global(.dark) .toolbar {
@apply border-cool-100/50 bg-cool-120/50;
}
:global(.sketch) .toolbar {
@apply border-fern-20/20 bg-fern-10/20;
}
:global(.dark .sketch) .toolbar {
@apply border-fern-120/50 bg-fern-100/30;
}
.toolbarCap {
@apply text-sm font-bold;
@apply bg-cool-20/50 text-cool-100;
}
:global(.dark) .toolbarCap {
@apply bg-cool-90/50 text-cool-30;
}
:global(.sketch) .toolbarCap {
@apply bg-fern-20/50 text-fern-100;
}
:global(.dark .sketch) .toolbarCap {
@apply bg-fern-90/50 text-fern-30;
}
.label {
@apply self-stretch flex items-center px-4 py-1;
@apply rounded-l-full;
}
.popoverToggle {
@apply self-stretch m-0 flex items-center px-4 py-1;
@apply rounded-r-full border-none;
@apply hover:bg-cool-20;
}
:global(.dark) .popoverToggle {
@apply hover:bg-cool-90;
}
:global(.sketch) .popoverToggle {
@apply hover:bg-fern-20;
}
:global(.dark .sketch) .popoverToggle {
@apply hover:bg-fern-90;
}

View File

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

View File

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

View File

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

View File

@ -2,8 +2,7 @@ import { Toolbar } from '../Toolbar'
import UserSidebarMenu from './UserSidebarMenu'
import { ProjectWithEntryPointMetadata } from '../Router'
import ProjectSidebarMenu from './ProjectSidebarMenu'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import styles from './AppHeader.module.css'
import { useAuthMachine } from '../hooks/useAuthMachine'
interface AppHeaderProps extends React.PropsWithChildren {
showToolbar?: boolean
@ -19,18 +18,12 @@ export const AppHeader = ({
className = '',
enableMenu = false,
}: AppHeaderProps) => {
const {
auth: {
context: { user },
},
} = useGlobalStateContext()
const [user] = useAuthMachine((s) => s?.context?.user)
return (
<header
className={
(showToolbar ? 'grid ' : 'flex justify-between ') +
styles.header +
' overlaid-panes sticky top-0 z-20 py-1 px-5 bg-chalkboard-10/70 dark:bg-chalkboard-100/50 border-b dark:border-b-2 border-chalkboard-30 dark:border-chalkboard-90 items-center ' +
'overlaid-panes sticky top-0 z-20 py-1 px-5 bg-chalkboard-10/50 dark:bg-chalkboard-100/50 border-b dark:border-b-2 border-chalkboard-30 dark:border-chalkboard-90 flex justify-between items-center ' +
className
}
>
@ -42,11 +35,7 @@ export const AppHeader = ({
</div>
)}
{/* If there are children, show them, otherwise show User menu */}
{children || (
<div className="ml-auto">
<UserSidebarMenu user={user} />
</div>
)}
{children || <UserSidebarMenu user={user} />}
</header>
)
}

View File

@ -1,10 +1,10 @@
.panel {
@apply relative overflow-auto z-0;
@apply bg-chalkboard-10/70 backdrop-blur-sm;
@apply bg-chalkboard-20/40;
}
:global(.dark) .panel {
@apply bg-chalkboard-110/50 backdrop-blur-0;
@apply bg-chalkboard-110/50;
}
.header {

View File

@ -1,290 +0,0 @@
import { Combobox, Dialog, Transition } from '@headlessui/react'
import {
Dispatch,
Fragment,
SetStateAction,
createContext,
useState,
} from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { ActionIcon } from './ActionIcon'
import { faSearch } from '@fortawesome/free-solid-svg-icons'
import Fuse from 'fuse.js'
import { Command, SubCommand } from '../lib/commands'
import { useCommandsContext } from 'hooks/useCommandsContext'
export type SortedCommand = {
item: Partial<Command | SubCommand> & { name: string }
}
export const CommandsContext = createContext(
{} as {
commands: Command[]
addCommands: (commands: Command[]) => void
removeCommands: (commands: Command[]) => void
commandBarOpen: boolean
setCommandBarOpen: Dispatch<SetStateAction<boolean>>
}
)
export const CommandBarProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const [commands, internalSetCommands] = useState([] as Command[])
const [commandBarOpen, setCommandBarOpen] = useState(false)
const addCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) => [...newCommands, ...prevCommands])
}
const removeCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) =>
prevCommands.filter((command) => !newCommands.includes(command))
)
}
return (
<CommandsContext.Provider
value={{
commands,
addCommands,
removeCommands,
commandBarOpen,
setCommandBarOpen,
}}
>
{children}
<CommandBar />
</CommandsContext.Provider>
)
}
const CommandBar = () => {
const { commands, commandBarOpen, setCommandBarOpen } = useCommandsContext()
useHotkeys('meta+k', () => {
if (commands.length === 0) return
setCommandBarOpen(!commandBarOpen)
})
const [selectedCommand, setSelectedCommand] = useState<SortedCommand | null>(
null
)
// keep track of the current subcommand index
const [subCommandIndex, setSubCommandIndex] = useState<number>()
const [subCommandData, setSubCommandData] = useState<{
[key: string]: string
}>({})
// if the subcommand index is null, we're not in a subcommand
const inSubCommand =
selectedCommand &&
'meta' in selectedCommand.item &&
selectedCommand.item.meta?.args !== undefined &&
subCommandIndex !== undefined
const currentSubCommand =
inSubCommand && 'meta' in selectedCommand.item
? selectedCommand.item.meta?.args[subCommandIndex]
: undefined
const [query, setQuery] = useState('')
const availableCommands =
inSubCommand && currentSubCommand
? currentSubCommand.type === 'string'
? query
? [{ name: query }]
: currentSubCommand.options
: currentSubCommand.options
: commands
const fuse = new Fuse(availableCommands || [], {
keys: ['name', 'description'],
})
const filteredCommands = query
? fuse.search(query)
: availableCommands?.map((c) => ({ item: c } as SortedCommand))
function clearState() {
setQuery('')
setCommandBarOpen(false)
setSelectedCommand(null)
setSubCommandIndex(undefined)
setSubCommandData({})
}
function handleCommandSelection(entry: SortedCommand) {
// If we have subcommands and have not yet gathered all the
// data required from them, set the selected command to the
// current command and increment the subcommand index
if (selectedCommand === null && 'meta' in entry.item && entry.item.meta) {
setSelectedCommand(entry)
setSubCommandIndex(0)
setQuery('')
return
}
const { item } = entry
// If we have just selected a command with no subcommands, run it
const isCommandWithoutSubcommands =
'callback' in item && !('meta' in item && item.meta)
if (isCommandWithoutSubcommands) {
if (item.callback === undefined) return
item.callback()
setCommandBarOpen(false)
return
}
// If we have subcommands and have not yet gathered all the
// data required from them, set the selected command to the
// current command and increment the subcommand index
if (
selectedCommand &&
subCommandIndex !== undefined &&
'meta' in selectedCommand.item
) {
const subCommand = selectedCommand.item.meta?.args[subCommandIndex]
if (subCommand) {
const newSubCommandData = {
...subCommandData,
[subCommand.name]: item.name,
}
const newSubCommandIndex = subCommandIndex + 1
// If we have subcommands and have gathered all the data required
// from them, run the command with the gathered data
if (
selectedCommand.item.callback &&
selectedCommand.item.meta?.args.length === newSubCommandIndex
) {
selectedCommand.item.callback(newSubCommandData)
setCommandBarOpen(false)
} else {
// Otherwise, set the subcommand data and increment the subcommand index
setSubCommandData(newSubCommandData)
setSubCommandIndex(newSubCommandIndex)
setQuery('')
}
}
}
}
function getDisplayValue(command: Command) {
if (command.meta?.displayValue === undefined || !command.meta.args)
return command.name
return command.meta?.displayValue(
command.meta.args.map((c) =>
subCommandData[c.name] ? subCommandData[c.name] : `<${c.name}>`
)
)
}
return (
<Transition.Root
show={
commandBarOpen &&
availableCommands?.length !== undefined &&
availableCommands.length > 0
}
as={Fragment}
afterLeave={() => clearState()}
>
<Dialog
onClose={() => {
setCommandBarOpen(false)
clearState()
}}
className="fixed inset-0 overflow-y-auto p-4 pt-[25vh]"
>
<Transition.Child
enter="duration-100 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="duration-75 ease-in"
leaveFrom="opacity-100"
leaveTo="opacity-0"
as={Fragment}
>
<Dialog.Overlay className="fixed z-40 inset-0 bg-chalkboard-10/70 dark:bg-chalkboard-110/50" />
</Transition.Child>
<Transition.Child
enter="duration-100 ease-out"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-75 ease-in"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
as={Fragment}
>
<Combobox
value={selectedCommand}
onChange={handleCommandSelection}
className="rounded relative mx-auto z-40 p-2 bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-xl w-full shadow-lg"
as="div"
>
<div className="flex gap-2 items-center">
<ActionIcon icon={faSearch} size="xl" className="rounded-sm" />
<div>
{inSubCommand && (
<p className="text-liquid-70 dark:text-liquid-30">
{selectedCommand.item &&
getDisplayValue(selectedCommand.item as Command)}
</p>
)}
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
className="bg-transparent focus:outline-none w-full"
onKeyDown={(event) => {
if (event.metaKey && event.key === 'k')
setCommandBarOpen(false)
if (
inSubCommand &&
event.key === 'Backspace' &&
!event.currentTarget.value
) {
setSubCommandIndex(subCommandIndex - 1)
setSelectedCommand(null)
}
}}
displayValue={(command: SortedCommand) =>
command !== null ? command.item.name : ''
}
placeholder={
inSubCommand
? `Enter <${currentSubCommand?.name}>`
: 'Search for a command'
}
value={query}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
</div>
</div>
<Combobox.Options static className="max-h-96 overflow-y-auto">
{filteredCommands?.map((commandResult) => (
<Combobox.Option
key={commandResult.item.name}
value={commandResult}
className="my-2 first:mt-4 last:mb-4 ui-active:bg-liquid-10 dark:ui-active:bg-liquid-90 py-1 px-2"
>
<p>{commandResult.item.name}</p>
{(commandResult.item as SubCommand).description && (
<p className="mt-0.5 text-liquid-70 dark:text-liquid-30 text-sm">
{(commandResult.item as SubCommand).description}
</p>
)}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
</Transition.Child>
</Dialog>
</Transition.Root>
)
}
export default CommandBarProvider

View File

@ -5,7 +5,6 @@ import { EngineCommand } from '../lang/std/engineConnection'
import { useState } from 'react'
import { ActionButton } from '../components/ActionButton'
import { faCheck } from '@fortawesome/free-solid-svg-icons'
import { isReducedMotion } from 'lang/util'
type SketchModeCmd = Extract<
Extract<EngineCommand, { type: 'modeling_cmd_req' }>['cmd'],
@ -23,7 +22,7 @@ export const DebugPanel = ({ className, ...props }: CollapsiblePanelProps) => {
y_axis: { x: 0, y: 1, z: 0 },
distance_to_plane: 100,
ortho: true,
animated: !isReducedMotion(),
animated: true, // TODO #273 get prefers reduced motion from CSS
})
if (!sketchModeCmd) return null
return (

View File

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

View File

@ -1,158 +0,0 @@
import { useMachine } from '@xstate/react'
import { useNavigate } from 'react-router-dom'
import { paths } from '../Router'
import {
authCommandBarMeta,
authMachine,
TOKEN_PERSIST_KEY,
} from '../machines/authMachine'
import withBaseUrl from '../lib/withBaseURL'
import React, { createContext, useEffect, useRef } from 'react'
import useStateMachineCommands from '../hooks/useStateMachineCommands'
import {
SETTINGS_PERSIST_KEY,
settingsCommandBarMeta,
settingsMachine,
} from 'machines/settingsMachine'
import { toast } from 'react-hot-toast'
import { setThemeClass, Themes } from 'lib/theme'
import {
AnyStateMachine,
ContextFrom,
InterpreterFrom,
Prop,
StateFrom,
} from 'xstate'
import { useCommandsContext } from 'hooks/useCommandsContext'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
context: ContextFrom<T>
send: Prop<InterpreterFrom<T>, 'send'>
}
type GlobalContext = {
auth: MachineContext<typeof authMachine>
settings: MachineContext<typeof settingsMachine>
}
export const GlobalStateContext = createContext({} as GlobalContext)
export const GlobalStateProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const navigate = useNavigate()
const { commands } = useCommandsContext()
// Settings machine setup
const retrievedSettings = useRef(
localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}'
)
const persistedSettings = Object.assign(
settingsMachine.initialState.context,
JSON.parse(retrievedSettings.current) as Partial<
(typeof settingsMachine)['context']
>
)
const [settingsState, settingsSend] = useMachine(settingsMachine, {
context: persistedSettings,
actions: {
toastSuccess: (context, event) => {
const truncatedNewValue =
'data' in event && event.data instanceof Object
? (context[Object.keys(event.data)[0] as keyof typeof context]
.toString()
.substring(0, 28) as any)
: undefined
toast.success(
event.type +
(truncatedNewValue
? ` to "${truncatedNewValue}${
truncatedNewValue.length === 28 ? '...' : ''
}"`
: '')
)
},
},
})
useStateMachineCommands({
state: settingsState,
send: settingsSend,
commands,
owner: 'settings',
commandBarMeta: settingsCommandBarMeta,
})
// Listen for changes to the system theme and update the app theme accordingly
// This is only done if the theme setting is set to 'system'.
// It can't be done in XState (in an invoked callback, for example)
// because there doesn't seem to be a good way to listen to
// events outside of the machine that also depend on the machine's context
useEffect(() => {
const matcher = window.matchMedia('(prefers-color-scheme: dark)')
const listener = (e: MediaQueryListEvent) => {
if (settingsState.context.theme !== 'system') return
setThemeClass(e.matches ? Themes.Dark : Themes.Light)
}
matcher.addEventListener('change', listener)
return () => matcher.removeEventListener('change', listener)
}, [settingsState.context])
// Auth machine setup
const [authState, authSend] = useMachine(authMachine, {
actions: {
goToSignInPage: () => {
navigate(paths.SIGN_IN)
logout()
},
goToIndexPage: () => {
if (window.location.pathname.includes(paths.SIGN_IN)) {
navigate(paths.INDEX)
}
},
},
})
useStateMachineCommands({
state: authState,
send: authSend,
commands,
commandBarMeta: authCommandBarMeta,
owner: 'auth',
})
return (
<GlobalStateContext.Provider
value={{
auth: {
state: authState,
context: authState.context,
send: authSend,
},
settings: {
state: settingsState,
context: settingsState.context,
send: settingsSend,
},
}}
>
{children}
</GlobalStateContext.Provider>
)
}
export default GlobalStateProvider
export function logout() {
const url = withBaseUrl('/logout')
localStorage.removeItem(TOKEN_PERSIST_KEY)
return fetch(url, {
method: 'POST',
credentials: 'include',
})
}

View File

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

View File

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

View File

@ -1,9 +1,8 @@
import ReactJson from 'react-json-view'
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
import { useStore } from '../useStore'
import { Themes, useStore } from '../useStore'
import { useMemo } from 'react'
import { ProgramMemory } from '../lang/executor'
import { Themes } from '../lib/theme'
interface MemoryPanelProps extends CollapsiblePanelProps {
theme?: Exclude<Themes, Themes.System>

View File

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

View File

@ -12,12 +12,12 @@ import Loading from './Loading'
export const Stream = ({ className = '' }) => {
const [isLoading, setIsLoading] = useState(true)
const [clickCoords, setClickCoords] = useState<{ x: number; y: number }>()
const videoRef = useRef<HTMLVideoElement>(null)
const {
mediaStream,
engineCommandManager,
setIsMouseDownInStream,
setCmdId,
didDragInStream,
setDidDragInStream,
streamDimensions,
@ -27,6 +27,7 @@ export const Stream = ({ className = '' }) => {
isMouseDownInStream: s.isMouseDownInStream,
setIsMouseDownInStream: s.setIsMouseDownInStream,
fileId: s.fileId,
setCmdId: s.setCmdId,
didDragInStream: s.didDragInStream,
setDidDragInStream: s.setDidDragInStream,
streamDimensions: s.streamDimensions,
@ -40,7 +41,9 @@ export const Stream = ({ className = '' }) => {
return
if (!videoRef.current) return
if (!mediaStream) return
console.log('setting video ref')
videoRef.current.srcObject = mediaStream
videoRef.current.play()
}, [mediaStream, engineCommandManager])
const handleMouseDown: MouseEventHandler<HTMLVideoElement> = ({
@ -58,6 +61,7 @@ export const Stream = ({ className = '' }) => {
console.log('click', x, y)
const newId = uuidv4()
setCmdId(newId)
const interaction = ctrlKey ? 'pan' : 'rotate'
@ -72,7 +76,6 @@ export const Stream = ({ className = '' }) => {
})
setIsMouseDownInStream(true)
setClickCoords({ x, y })
}
const handleScroll: WheelEventHandler<HTMLVideoElement> = (e) => {
@ -126,19 +129,6 @@ export const Stream = ({ className = '' }) => {
})
}
setDidDragInStream(false)
setClickCoords(undefined)
}
const handleMouseMove: MouseEventHandler<HTMLVideoElement> = (e) => {
if (!clickCoords) return
const delta =
((clickCoords.x - e.clientX) ** 2 + (clickCoords.y - e.clientY) ** 2) **
0.5
if (delta > 5 && !didDragInStream) {
setDidDragInStream(true)
}
}
return (
@ -154,7 +144,6 @@ export const Stream = ({ className = '' }) => {
onContextMenuCapture={(e) => e.preventDefault()}
onWheel={handleScroll}
onPlay={() => setIsLoading(false)}
onMouseMoveCapture={handleMouseMove}
className="w-full h-full"
/>
{isLoading && (

View File

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

View File

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

View File

@ -1,185 +0,0 @@
import * as jsrpc from 'json-rpc-2.0'
import * as LSP from 'vscode-languageserver-protocol'
import {
registerServerCapability,
unregisterServerCapability,
} from './server-capability-registration'
import { Codec, FromServer, IntoServer } from './codec'
const client_capabilities: LSP.ClientCapabilities = {
textDocument: {
hover: {
dynamicRegistration: true,
contentFormat: ['plaintext', 'markdown'],
},
moniker: {},
synchronization: {
dynamicRegistration: true,
willSave: false,
didSave: false,
willSaveWaitUntil: false,
},
completion: {
dynamicRegistration: true,
completionItem: {
snippetSupport: false,
commitCharactersSupport: true,
documentationFormat: ['plaintext', 'markdown'],
deprecatedSupport: false,
preselectSupport: false,
},
contextSupport: false,
},
signatureHelp: {
dynamicRegistration: true,
signatureInformation: {
documentationFormat: ['plaintext', 'markdown'],
},
},
declaration: {
dynamicRegistration: true,
linkSupport: true,
},
definition: {
dynamicRegistration: true,
linkSupport: true,
},
typeDefinition: {
dynamicRegistration: true,
linkSupport: true,
},
implementation: {
dynamicRegistration: true,
linkSupport: true,
},
},
workspace: {
didChangeConfiguration: {
dynamicRegistration: true,
},
},
}
export default class Client extends jsrpc.JSONRPCServerAndClient {
afterInitializedHooks: (() => Promise<void>)[] = []
#fromServer: FromServer
private serverCapabilities: LSP.ServerCapabilities<any> = {}
constructor(fromServer: FromServer, intoServer: IntoServer) {
super(
new jsrpc.JSONRPCServer(),
new jsrpc.JSONRPCClient(async (json: jsrpc.JSONRPCRequest) => {
const encoded = Codec.encode(json)
intoServer.enqueue(encoded)
if (null != json.id) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const response = await fromServer.responses.get(json.id)!
this.client.receive(response as jsrpc.JSONRPCResponse)
}
})
)
this.#fromServer = fromServer
}
async start(): Promise<void> {
// process "window/logMessage": client <- server
this.addMethod(LSP.LogMessageNotification.type.method, (params) => {
const { type, message } = params as {
type: LSP.MessageType
message: string
}
let messageString = ''
switch (type) {
case LSP.MessageType.Error: {
messageString += '[error] '
break
}
case LSP.MessageType.Warning: {
messageString += ' [warn] '
break
}
case LSP.MessageType.Info: {
messageString += ' [info] '
break
}
case LSP.MessageType.Log: {
messageString += ' [log] '
break
}
}
messageString += message
// console.log(messageString)
return
})
// process "client/registerCapability": client <- server
this.addMethod(LSP.RegistrationRequest.type.method, (params) => {
// Register a server capability.
params.registrations.forEach(
(capabilityRegistration: LSP.Registration) => {
this.serverCapabilities = registerServerCapability(
this.serverCapabilities,
capabilityRegistration
)
}
)
})
// process "client/unregisterCapability": client <- server
this.addMethod(LSP.UnregistrationRequest.type.method, (params) => {
// Unregister a server capability.
params.unregisterations.forEach(
(capabilityUnregistration: LSP.Unregistration) => {
this.serverCapabilities = unregisterServerCapability(
this.serverCapabilities,
capabilityUnregistration
)
}
)
})
// request "initialize": client <-> server
const { capabilities } = await this.request(
LSP.InitializeRequest.type.method,
{
processId: null,
clientInfo: {
name: 'kcl-language-client',
},
capabilities: client_capabilities,
rootUri: null,
} as LSP.InitializeParams
)
this.serverCapabilities = capabilities
// notify "initialized": client --> server
this.notify(LSP.InitializedNotification.type.method, {})
await Promise.all(
this.afterInitializedHooks.map((f: () => Promise<void>) => f())
)
await Promise.all([this.processNotifications(), this.processRequests()])
}
getServerCapabilities(): LSP.ServerCapabilities<any> {
return this.serverCapabilities
}
async processNotifications(): Promise<void> {
for await (const notification of this.#fromServer.notifications) {
await this.receiveAndSend(notification)
}
}
async processRequests(): Promise<void> {
for await (const request of this.#fromServer.requests) {
await this.receiveAndSend(request)
}
}
pushAfterInitializeHook(...hooks: (() => Promise<void>)[]): void {
this.afterInitializedHooks.push(...hooks)
}
}

View File

@ -1,53 +0,0 @@
import * as jsrpc from 'json-rpc-2.0'
import * as vsrpc from 'vscode-jsonrpc'
import Bytes from './codec/bytes'
import StreamDemuxer from './codec/demuxer'
import Headers from './codec/headers'
import Queue from './codec/queue'
import Tracer from './tracer'
export const encoder = new TextEncoder()
export const decoder = new TextDecoder()
export class Codec {
static encode(
json: jsrpc.JSONRPCRequest | jsrpc.JSONRPCResponse
): Uint8Array {
const message = JSON.stringify(json)
const delimited = Headers.add(message)
return Bytes.encode(delimited)
}
static decode<T>(data: Uint8Array): T {
const delimited = Bytes.decode(data)
const message = Headers.remove(delimited)
return JSON.parse(message) as T
}
}
// FIXME: tracing effiency
export class IntoServer
extends Queue<Uint8Array>
implements AsyncGenerator<Uint8Array, never, void>
{
enqueue(item: Uint8Array): void {
Tracer.client(Headers.remove(decoder.decode(item)))
super.enqueue(item)
}
}
export interface FromServer extends WritableStream<Uint8Array> {
readonly responses: {
get(key: number | string): null | Promise<vsrpc.ResponseMessage>
}
readonly notifications: AsyncGenerator<vsrpc.NotificationMessage, never, void>
readonly requests: AsyncGenerator<vsrpc.RequestMessage, never, void>
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace FromServer {
export function create(): FromServer {
return new StreamDemuxer()
}
}

View File

@ -1,27 +0,0 @@
import { encoder, decoder } from '../codec'
export default class Bytes {
static encode(input: string): Uint8Array {
return encoder.encode(input)
}
static decode(input: Uint8Array): string {
return decoder.decode(input)
}
static append<
T extends { length: number; set(arr: T, offset: number): void }
>(constructor: { new (length: number): T }, ...arrays: T[]) {
let totalLength = 0
for (const arr of arrays) {
totalLength += arr.length
}
const result = new constructor(totalLength)
let offset = 0
for (const arr of arrays) {
result.set(arr, offset)
offset += arr.length
}
return result
}
}

View File

@ -1,82 +0,0 @@
import * as vsrpc from 'vscode-jsonrpc'
import Bytes from './bytes'
import PromiseMap from './map'
import Queue from './queue'
import Tracer from '../tracer'
export default class StreamDemuxer extends Queue<Uint8Array> {
readonly responses: PromiseMap<number | string, vsrpc.ResponseMessage> =
new PromiseMap()
readonly notifications: Queue<vsrpc.NotificationMessage> =
new Queue<vsrpc.NotificationMessage>()
readonly requests: Queue<vsrpc.RequestMessage> =
new Queue<vsrpc.RequestMessage>()
readonly #start: Promise<void>
constructor() {
super()
this.#start = this.start()
}
private async start(): Promise<void> {
let contentLength: null | number = null
let buffer = new Uint8Array()
for await (const bytes of this) {
buffer = Bytes.append(Uint8Array, buffer, bytes)
while (buffer.length > 0) {
// check if the content length is known
if (null == contentLength) {
// if not, try to match the prefixed headers
const match = Bytes.decode(buffer).match(
/^Content-Length:\s*(\d+)\s*/
)
if (null == match) continue
// try to parse the content-length from the headers
const length = parseInt(match[1])
if (isNaN(length)) throw new Error('invalid content length')
// slice the headers since we now have the content length
buffer = buffer.slice(match[0].length)
// set the content length
contentLength = length
}
// if the buffer doesn't contain a full message; await another iteration
if (buffer.length < contentLength) continue
// Get just the slice of the buffer that is our content length.
const slice = buffer.slice(0, contentLength)
// decode buffer to a string
const delimited = Bytes.decode(slice)
// reset the buffer
buffer = buffer.slice(contentLength)
// reset the contentLength
contentLength = null
const message = JSON.parse(delimited) as vsrpc.Message
Tracer.server(message)
// demux the message stream
if (vsrpc.Message.isResponse(message) && null != message.id) {
this.responses.set(message.id, message)
continue
}
if (vsrpc.Message.isNotification(message)) {
this.notifications.enqueue(message)
continue
}
if (vsrpc.Message.isRequest(message)) {
this.requests.enqueue(message)
continue
}
}
}
}
}

View File

@ -1,9 +0,0 @@
export default class Headers {
static add(message: string): string {
return `Content-Length: ${message.length}\r\n\r\n${message}`
}
static remove(delimited: string): string {
return delimited.replace(/^Content-Length:\s*\d+\s*/, '')
}
}

View File

@ -1,72 +0,0 @@
export default class PromiseMap<K, V extends { toString(): string }> {
#map: Map<K, PromiseMap.Entry<V>> = new Map()
get(key: K & { toString(): string }): null | Promise<V> {
let initialized: PromiseMap.Entry<V>
// if the entry doesn't exist, set it
if (!this.#map.has(key)) {
initialized = this.#set(key)
} else {
// otherwise return the entry
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
initialized = this.#map.get(key)!
}
// if the entry is a pending promise, return it
if (initialized.status === 'pending') {
return initialized.promise
} else {
// otherwise return null
return null
}
}
#set(key: K, value?: V): PromiseMap.Entry<V> {
if (this.#map.has(key)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this.#map.get(key)!
}
// placeholder resolver for entry
let resolve = (item: V) => {
void item
}
// promise for entry (which assigns the resolver
const promise = new Promise<V>((resolver) => {
resolve = resolver
})
// the initialized entry
const initialized: PromiseMap.Entry<V> = {
status: 'pending',
resolve,
promise,
}
if (null != value) {
initialized.resolve(value)
}
// set the entry
this.#map.set(key, initialized)
return initialized
}
set(key: K & { toString(): string }, value: V): this {
const initialized = this.#set(key, value)
// if the promise is pending ...
if (initialized.status === 'pending') {
// ... set the entry status to resolved to free the promise
this.#map.set(key, { status: 'resolved' })
// ... and resolve the promise with the given value
initialized.resolve(value)
}
return this
}
get size(): number {
return this.#map.size
}
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace PromiseMap {
export type Entry<V> =
| { status: 'pending'; resolve: (item: V) => void; promise: Promise<V> }
| { status: 'resolved' }
}

View File

@ -1,113 +0,0 @@
export default class Queue<T>
implements WritableStream<T>, AsyncGenerator<T, never, void>
{
readonly #promises: Promise<T>[] = []
readonly #resolvers: ((item: T) => void)[] = []
readonly #observers: ((item: T) => void)[] = []
#closed = false
#locked = false
readonly #stream: WritableStream<T>
static #__add<X>(
promises: Promise<X>[],
resolvers: ((item: X) => void)[]
): void {
promises.push(
new Promise((resolve) => {
resolvers.push(resolve)
})
)
}
static #__enqueue<X>(
closed: boolean,
promises: Promise<X>[],
resolvers: ((item: X) => void)[],
item: X
): void {
if (!closed) {
if (!resolvers.length) Queue.#__add(promises, resolvers)
const resolve = resolvers.shift()! // eslint-disable-line @typescript-eslint/no-non-null-assertion
resolve(item)
}
}
constructor() {
const closed = this.#closed
const promises = this.#promises
const resolvers = this.#resolvers
this.#stream = new WritableStream({
write(item: T): void {
Queue.#__enqueue(closed, promises, resolvers, item)
},
})
}
#add(): void {
return Queue.#__add(this.#promises, this.#resolvers)
}
enqueue(item: T): void {
return Queue.#__enqueue(this.#closed, this.#promises, this.#resolvers, item)
}
dequeue(): Promise<T> {
if (!this.#promises.length) this.#add()
const item = this.#promises.shift()! // eslint-disable-line @typescript-eslint/no-non-null-assertion
return item
}
isEmpty(): boolean {
return !this.#promises.length
}
isBlocked(): boolean {
return !!this.#resolvers.length
}
get length(): number {
return this.#promises.length - this.#resolvers.length
}
async next(): Promise<IteratorResult<T, never>> {
const done = false
const value = await this.dequeue()
for (const observer of this.#observers) {
observer(value)
}
return { done, value }
}
return(): Promise<IteratorResult<T, never>> {
return new Promise(() => {
// empty
})
}
throw(err: Error): Promise<IteratorResult<T, never>> {
return new Promise((_resolve, reject) => {
reject(err)
})
}
[Symbol.asyncIterator](): AsyncGenerator<T, never, void> {
return this
}
get locked(): boolean {
return this.#stream.locked
}
abort(reason?: Error): Promise<void> {
return this.#stream.abort(reason)
}
close(): Promise<void> {
return this.#stream.close()
}
getWriter(): WritableStreamDefaultWriter<T> {
return this.#stream.getWriter()
}
}

View File

@ -1,151 +0,0 @@
import type * as LSP from 'vscode-languageserver-protocol'
import Client from './client'
import { LanguageServerPlugin } from './plugin'
import { SemanticToken, deserializeTokens } from './semantic_tokens'
// https://microsoft.github.io/language-server-protocol/specifications/specification-current/
// Client to server then server to client
interface LSPRequestMap {
initialize: [LSP.InitializeParams, LSP.InitializeResult]
'textDocument/hover': [LSP.HoverParams, LSP.Hover]
'textDocument/completion': [
LSP.CompletionParams,
LSP.CompletionItem[] | LSP.CompletionList | null
]
'textDocument/semanticTokens/full': [
LSP.SemanticTokensParams,
LSP.SemanticTokens
]
}
// Client to server
interface LSPNotifyMap {
initialized: LSP.InitializedParams
'textDocument/didChange': LSP.DidChangeTextDocumentParams
'textDocument/didOpen': LSP.DidOpenTextDocumentParams
}
// Server to client
interface LSPEventMap {
'textDocument/publishDiagnostics': LSP.PublishDiagnosticsParams
}
export type Notification = {
[key in keyof LSPEventMap]: {
jsonrpc: '2.0'
id?: null | undefined
method: key
params: LSPEventMap[key]
}
}[keyof LSPEventMap]
export interface LanguageServerClientOptions {
client: Client
}
export class LanguageServerClient {
private client: Client
public ready: boolean
private plugins: LanguageServerPlugin[]
public initializePromise: Promise<void>
private isUpdatingSemanticTokens: boolean = false
private semanticTokens: SemanticToken[] = []
constructor(options: LanguageServerClientOptions) {
this.plugins = []
this.client = options.client
this.ready = false
this.initializePromise = this.initialize()
}
async initialize() {
// Start the client in the background.
this.client.start()
this.ready = true
}
getServerCapabilities(): LSP.ServerCapabilities<any> {
return this.client.getServerCapabilities()
}
close() {}
textDocumentDidOpen(params: LSP.DidOpenTextDocumentParams) {
this.notify('textDocument/didOpen', params)
this.updateSemanticTokens(params.textDocument.uri)
}
textDocumentDidChange(params: LSP.DidChangeTextDocumentParams) {
this.notify('textDocument/didChange', params)
this.updateSemanticTokens(params.textDocument.uri)
}
async updateSemanticTokens(uri: string) {
// Make sure we can only run, if we aren't already running.
if (!this.isUpdatingSemanticTokens) {
this.isUpdatingSemanticTokens = true
const result = await this.request('textDocument/semanticTokens/full', {
textDocument: {
uri,
},
})
this.semanticTokens = deserializeTokens(
result.data,
this.getServerCapabilities().semanticTokensProvider
)
this.isUpdatingSemanticTokens = false
}
}
getSemanticTokens(): SemanticToken[] {
return this.semanticTokens
}
async textDocumentHover(params: LSP.HoverParams) {
return await this.request('textDocument/hover', params)
}
async textDocumentCompletion(params: LSP.CompletionParams) {
return await this.request('textDocument/completion', params)
}
attachPlugin(plugin: LanguageServerPlugin) {
this.plugins.push(plugin)
}
detachPlugin(plugin: LanguageServerPlugin) {
const i = this.plugins.indexOf(plugin)
if (i === -1) return
this.plugins.splice(i, 1)
}
private request<K extends keyof LSPRequestMap>(
method: K,
params: LSPRequestMap[K][0]
): Promise<LSPRequestMap[K][1]> {
return this.client.request(method, params) as Promise<LSPRequestMap[K][1]>
}
private notify<K extends keyof LSPNotifyMap>(
method: K,
params: LSPNotifyMap[K]
): void {
return this.client.notify(method, params)
}
private processNotification(notification: Notification) {
for (const plugin of this.plugins) plugin.processNotification(notification)
}
}

View File

@ -1,36 +0,0 @@
// Code mirror language implementation for kcl.
import {
Language,
defineLanguageFacet,
LanguageSupport,
} from '@codemirror/language'
import { LanguageServerClient } from '.'
import { kclPlugin } from './plugin'
import type * as LSP from 'vscode-languageserver-protocol'
import { parser as jsParser } from '@lezer/javascript'
const data = defineLanguageFacet({})
export interface LanguageOptions {
workspaceFolders: LSP.WorkspaceFolder[] | null
documentUri: string
client: LanguageServerClient
}
export default function kclLanguage(options: LanguageOptions): LanguageSupport {
// For now let's use the javascript parser.
// It works really well and has good syntax highlighting.
// We can use our lsp for the rest.
const lang = new Language(data, jsParser, [], 'kcl')
// Create our supporting extension.
const kclLsp = kclPlugin({
documentUri: options.documentUri,
workspaceFolders: options.workspaceFolders,
allowHTMLContent: true,
client: options.client,
})
return new LanguageSupport(lang, [kclLsp])
}

View File

@ -1,168 +0,0 @@
// Extends the codemirror Parser for kcl.
import {
Parser,
Input,
TreeFragment,
PartialParse,
Tree,
NodeType,
NodeSet,
} from '@lezer/common'
import { LanguageServerClient } from '.'
import { posToOffset } from './plugin'
import { SemanticToken } from './semantic_tokens'
import { DocInput } from '@codemirror/language'
import { tags, styleTags } from '@lezer/highlight'
export default class KclParser extends Parser {
private client: LanguageServerClient
constructor(client: LanguageServerClient) {
super()
this.client = client
}
createParse(
input: Input,
fragments: readonly TreeFragment[],
ranges: readonly { from: number; to: number }[]
): PartialParse {
let parse: PartialParse = new Context(this, input, fragments, ranges)
return parse
}
getTokenTypes(): string[] {
return this.client.getServerCapabilities().semanticTokensProvider!.legend
.tokenTypes
}
getSemanticTokens(): SemanticToken[] {
return this.client.getSemanticTokens()
}
}
class Context implements PartialParse {
private parser: KclParser
private input: DocInput
private fragments: readonly TreeFragment[]
private ranges: readonly { from: number; to: number }[]
private nodeTypes: { [key: string]: NodeType }
stoppedAt: number = 0
private semanticTokens: SemanticToken[] = []
private currentLine: number = 0
private currentColumn: number = 0
private nodeSet: NodeSet
constructor(
/// The parser configuration used.
parser: KclParser,
input: Input,
fragments: readonly TreeFragment[],
ranges: readonly { from: number; to: number }[]
) {
this.parser = parser
this.input = input as DocInput
this.fragments = fragments
this.ranges = ranges
// Iterate over the semantic token types and create a node type for each.
this.nodeTypes = {}
let nodeArray: NodeType[] = []
this.parser.getTokenTypes().forEach((tokenType, index) => {
const nodeType = NodeType.define({
id: index,
name: tokenType,
// props: [this.styleTags],
})
this.nodeTypes[tokenType] = nodeType
nodeArray.push(nodeType)
})
this.semanticTokens = this.parser.getSemanticTokens()
const styles = styleTags({
number: tags.number,
variable: tags.variableName,
operator: tags.operator,
keyword: tags.keyword,
string: tags.string,
comment: tags.comment,
function: tags.function(tags.variableName),
})
this.nodeSet = new NodeSet(nodeArray).extend(styles)
}
get parsedPos(): number {
return 0
}
advance(): Tree | null {
if (this.semanticTokens.length === 0) {
return new Tree(NodeType.none, [], [], 0)
}
const tree = this.createTree(this.semanticTokens[0], 0)
this.stoppedAt = this.input.doc.length
return tree
}
createTree(token: SemanticToken, index: number): Tree {
const changedLine = token.delta_line !== 0
this.currentLine += token.delta_line
if (changedLine) {
this.currentColumn = 0
}
this.currentColumn += token.delta_start
// Let's get our position relative to the start of the file.
let currentPosition = posToOffset(this.input.doc, {
line: this.currentLine,
character: this.currentColumn,
})
const nodeType = this.nodeSet.types[this.nodeTypes[token.token_type].id]
if (currentPosition === undefined) {
// This is bad and weird.
return new Tree(nodeType, [], [], token.length)
}
if (index >= this.semanticTokens.length - 1) {
// We have no children.
return new Tree(nodeType, [], [], token.length)
}
const nextIndex = index + 1
const nextToken = this.semanticTokens[nextIndex]
const changedLineNext = nextToken.delta_line !== 0
const nextLine = this.currentLine + nextToken.delta_line
const nextColumn = changedLineNext
? nextToken.delta_start
: this.currentColumn + nextToken.delta_start
const nextPosition = posToOffset(this.input.doc, {
line: nextLine,
character: nextColumn,
})
if (nextPosition === undefined) {
// This is bad and weird.
return new Tree(nodeType, [], [], token.length)
}
// Let's get the
return new Tree(
nodeType,
[this.createTree(nextToken, nextIndex)],
// The positions (offsets relative to the start of this tree) of the children.
[nextPosition - currentPosition],
token.length
)
}
stopAt(pos: number) {
this.stoppedAt = pos
}
}

View File

@ -1,354 +0,0 @@
import { autocompletion, completeFromList } from '@codemirror/autocomplete'
import { setDiagnostics } from '@codemirror/lint'
import { Facet } from '@codemirror/state'
import {
EditorView,
ViewPlugin,
Tooltip,
hoverTooltip,
tooltips,
} from '@codemirror/view'
import {
DiagnosticSeverity,
CompletionItemKind,
CompletionTriggerKind,
} from 'vscode-languageserver-protocol'
import type {
Completion,
CompletionContext,
CompletionResult,
} from '@codemirror/autocomplete'
import type { PublishDiagnosticsParams } from 'vscode-languageserver-protocol'
import type { ViewUpdate, PluginValue } from '@codemirror/view'
import type { Text } from '@codemirror/state'
import type * as LSP from 'vscode-languageserver-protocol'
import { LanguageServerClient, Notification } from '.'
import { Marked } from '@ts-stack/markdown'
const changesDelay = 500
const CompletionItemKindMap = Object.fromEntries(
Object.entries(CompletionItemKind).map(([key, value]) => [value, key])
) as Record<CompletionItemKind, string>
const useLast = (values: readonly any[]) => values.reduce((_, v) => v, '')
const documentUri = Facet.define<string, string>({ combine: useLast })
const languageId = Facet.define<string, string>({ combine: useLast })
const client = Facet.define<LanguageServerClient, LanguageServerClient>({
combine: useLast,
})
export interface LanguageServerOptions {
workspaceFolders: LSP.WorkspaceFolder[] | null
documentUri: string
allowHTMLContent: boolean
client: LanguageServerClient
}
export class LanguageServerPlugin implements PluginValue {
public client: LanguageServerClient
private documentUri: string
private languageId: string
private documentVersion: number
private changesTimeout: number
constructor(private view: EditorView, private allowHTMLContent: boolean) {
this.client = this.view.state.facet(client)
this.documentUri = this.view.state.facet(documentUri)
this.languageId = this.view.state.facet(languageId)
this.documentVersion = 0
this.changesTimeout = 0
this.client.attachPlugin(this)
this.initialize({
documentText: this.view.state.doc.toString(),
})
}
update({ docChanged }: ViewUpdate) {
if (!docChanged) return
if (this.changesTimeout) clearTimeout(this.changesTimeout)
this.changesTimeout = window.setTimeout(() => {
this.sendChange({
documentText: this.view.state.doc.toString(),
})
}, changesDelay)
}
destroy() {
this.client.detachPlugin(this)
}
async initialize({ documentText }: { documentText: string }) {
if (this.client.initializePromise) {
await this.client.initializePromise
}
this.client.textDocumentDidOpen({
textDocument: {
uri: this.documentUri,
languageId: this.languageId,
text: documentText,
version: this.documentVersion,
},
})
}
async sendChange({ documentText }: { documentText: string }) {
if (!this.client.ready) return
try {
await this.client.textDocumentDidChange({
textDocument: {
uri: this.documentUri,
version: this.documentVersion++,
},
contentChanges: [{ text: documentText }],
})
} catch (e) {
console.error(e)
}
}
requestDiagnostics(view: EditorView) {
this.sendChange({ documentText: view.state.doc.toString() })
}
async requestHoverTooltip(
view: EditorView,
{ line, character }: { line: number; character: number }
): Promise<Tooltip | null> {
if (
!this.client.ready ||
!this.client.getServerCapabilities().hoverProvider
)
return null
this.sendChange({ documentText: view.state.doc.toString() })
const result = await this.client.textDocumentHover({
textDocument: { uri: this.documentUri },
position: { line, character },
})
if (!result) return null
const { contents, range } = result
let pos = posToOffset(view.state.doc, { line, character })!
let end: number | undefined
if (range) {
pos = posToOffset(view.state.doc, range.start)!
end = posToOffset(view.state.doc, range.end)
}
if (pos === null) return null
const dom = document.createElement('div')
dom.classList.add('documentation')
if (this.allowHTMLContent) dom.innerHTML = formatContents(contents)
else dom.textContent = formatContents(contents)
return { pos, end, create: (view) => ({ dom }), above: true }
}
async requestCompletion(
context: CompletionContext,
{ line, character }: { line: number; character: number },
{
triggerKind,
triggerCharacter,
}: {
triggerKind: CompletionTriggerKind
triggerCharacter: string | undefined
}
): Promise<CompletionResult | null> {
if (
!this.client.ready ||
!this.client.getServerCapabilities().completionProvider
)
return null
this.sendChange({
documentText: context.state.doc.toString(),
})
const result = await this.client.textDocumentCompletion({
textDocument: { uri: this.documentUri },
position: { line, character },
context: {
triggerKind,
triggerCharacter,
},
})
if (!result) return null
const items = 'items' in result ? result.items : result
let options = items.map(
({
detail,
label,
labelDetails,
kind,
textEdit,
documentation,
deprecated,
insertText,
insertTextFormat,
sortText,
filterText,
}) => {
const completion: Completion & {
filterText: string
sortText?: string
apply: string
} = {
label,
detail: labelDetails ? labelDetails.detail : detail,
apply: label,
type: kind && CompletionItemKindMap[kind].toLowerCase(),
sortText: sortText ?? label,
filterText: filterText ?? label,
}
if (documentation) {
completion.info = formatContents(documentation)
}
return completion
}
)
return completeFromList(options)(context)
}
processNotification(notification: Notification) {
try {
switch (notification.method) {
case 'textDocument/publishDiagnostics':
this.processDiagnostics(notification.params)
}
} catch (error) {
console.error(error)
}
}
processDiagnostics(params: PublishDiagnosticsParams) {
if (params.uri !== this.documentUri) return
const diagnostics = params.diagnostics
.map(({ range, message, severity }) => ({
from: posToOffset(this.view.state.doc, range.start)!,
to: posToOffset(this.view.state.doc, range.end)!,
severity: (
{
[DiagnosticSeverity.Error]: 'error',
[DiagnosticSeverity.Warning]: 'warning',
[DiagnosticSeverity.Information]: 'info',
[DiagnosticSeverity.Hint]: 'info',
} as const
)[severity!],
message,
}))
.filter(
({ from, to }) =>
from !== null && to !== null && from !== undefined && to !== undefined
)
.sort((a, b) => {
switch (true) {
case a.from < b.from:
return -1
case a.from > b.from:
return 1
}
return 0
})
this.view.dispatch(setDiagnostics(this.view.state, diagnostics))
}
}
export function kclPlugin(options: LanguageServerOptions) {
let plugin: LanguageServerPlugin | null = null
return [
client.of(options.client),
documentUri.of(options.documentUri),
languageId.of('kcl'),
ViewPlugin.define(
(view) =>
(plugin = new LanguageServerPlugin(view, options.allowHTMLContent))
),
hoverTooltip(
(view, pos) =>
plugin?.requestHoverTooltip(view, offsetToPos(view.state.doc, pos)) ??
null
),
tooltips({
position: 'absolute',
}),
autocompletion({
override: [
async (context) => {
if (plugin == null) return null
const { state, pos, explicit } = context
const line = state.doc.lineAt(pos)
let trigKind: CompletionTriggerKind = CompletionTriggerKind.Invoked
let trigChar: string | undefined
if (
!explicit &&
plugin.client
.getServerCapabilities()
.completionProvider?.triggerCharacters?.includes(
line.text[pos - line.from - 1]
)
) {
trigKind = CompletionTriggerKind.TriggerCharacter
trigChar = line.text[pos - line.from - 1]
}
if (
trigKind === CompletionTriggerKind.Invoked &&
!context.matchBefore(/\w+$/)
) {
return null
}
return await plugin.requestCompletion(
context,
offsetToPos(state.doc, pos),
{
triggerKind: trigKind,
triggerCharacter: trigChar,
}
)
},
],
}),
]
}
export function posToOffset(
doc: Text,
pos: { line: number; character: number }
): number | undefined {
if (pos.line >= doc.lines) return
const offset = doc.line(pos.line + 1).from + pos.character
if (offset > doc.length) return
return offset
}
function offsetToPos(doc: Text, offset: number) {
const line = doc.lineAt(offset)
return {
line: line.number - 1,
character: offset - line.from,
}
}
function formatContents(
contents: LSP.MarkupContent | LSP.MarkedString | LSP.MarkedString[]
): string {
if (Array.isArray(contents)) {
return contents.map((c) => formatContents(c) + '\n\n').join('')
} else if (typeof contents === 'string') {
return Marked.parse(contents)
} else {
return Marked.parse(contents.value)
}
}

View File

@ -1,51 +0,0 @@
import type * as LSP from 'vscode-languageserver-protocol'
export class SemanticToken {
delta_line: number
delta_start: number
length: number
token_type: string
token_modifiers_bitset: string
constructor(
delta_line = 0,
delta_start = 0,
length = 0,
token_type = '',
token_modifiers_bitset = ''
) {
this.delta_line = delta_line
this.delta_start = delta_start
this.length = length
this.token_type = token_type
this.token_modifiers_bitset = token_modifiers_bitset
}
}
export function deserializeTokens(
data: number[],
semanticTokensProvider?: LSP.SemanticTokensOptions
): SemanticToken[] {
if (!semanticTokensProvider) {
return []
}
// Check if data length is divisible by 5
if (data.length % 5 !== 0) {
throw new Error('Length is not divisible by 5')
}
const tokens = []
for (let i = 0; i < data.length; i += 5) {
tokens.push(
new SemanticToken(
data[i],
data[i + 1],
data[i + 2],
semanticTokensProvider.legend.tokenTypes[data[i + 3]],
semanticTokensProvider.legend.tokenModifiers[data[i + 4]]
)
)
}
return tokens
}

View File

@ -1,80 +0,0 @@
import {
Registration,
ServerCapabilities,
Unregistration,
} from 'vscode-languageserver-protocol'
interface IFlexibleServerCapabilities extends ServerCapabilities {
[key: string]: any
}
interface IMethodServerCapabilityProviderDictionary {
[key: string]: string
}
const ServerCapabilitiesProviders: IMethodServerCapabilityProviderDictionary = {
'textDocument/hover': 'hoverProvider',
'textDocument/completion': 'completionProvider',
'textDocument/signatureHelp': 'signatureHelpProvider',
'textDocument/definition': 'definitionProvider',
'textDocument/typeDefinition': 'typeDefinitionProvider',
'textDocument/implementation': 'implementationProvider',
'textDocument/references': 'referencesProvider',
'textDocument/documentHighlight': 'documentHighlightProvider',
'textDocument/documentSymbol': 'documentSymbolProvider',
'textDocument/workspaceSymbol': 'workspaceSymbolProvider',
'textDocument/codeAction': 'codeActionProvider',
'textDocument/codeLens': 'codeLensProvider',
'textDocument/documentFormatting': 'documentFormattingProvider',
'textDocument/documentRangeFormatting': 'documentRangeFormattingProvider',
'textDocument/documentOnTypeFormatting': 'documentOnTypeFormattingProvider',
'textDocument/rename': 'renameProvider',
'textDocument/documentLink': 'documentLinkProvider',
'textDocument/color': 'colorProvider',
'textDocument/foldingRange': 'foldingRangeProvider',
'textDocument/declaration': 'declarationProvider',
'textDocument/executeCommand': 'executeCommandProvider',
}
function registerServerCapability(
serverCapabilities: ServerCapabilities,
registration: Registration
): ServerCapabilities {
const serverCapabilitiesCopy = JSON.parse(
JSON.stringify(serverCapabilities)
) as IFlexibleServerCapabilities
const { method, registerOptions } = registration
const providerName = ServerCapabilitiesProviders[method]
if (providerName) {
if (!registerOptions) {
serverCapabilitiesCopy[providerName] = true
} else {
serverCapabilitiesCopy[providerName] = Object.assign(
{},
JSON.parse(JSON.stringify(registerOptions))
)
}
} else {
throw new Error('Could not register server capability.')
}
return serverCapabilitiesCopy
}
function unregisterServerCapability(
serverCapabilities: ServerCapabilities,
unregistration: Unregistration
): ServerCapabilities {
const serverCapabilitiesCopy = JSON.parse(
JSON.stringify(serverCapabilities)
) as IFlexibleServerCapabilities
const { method } = unregistration
const providerName = ServerCapabilitiesProviders[method]
delete serverCapabilitiesCopy[providerName]
return serverCapabilitiesCopy
}
export { registerServerCapability, unregisterServerCapability }

View File

@ -1,42 +0,0 @@
import init, {
InitOutput,
lsp_run,
ServerConfig,
} from '../../wasm-lib/pkg/wasm_lib'
import { FromServer, IntoServer } from './codec'
let server: null | Server
export default class Server {
readonly initOutput: InitOutput
readonly #intoServer: IntoServer
readonly #fromServer: FromServer
private constructor(
initOutput: InitOutput,
intoServer: IntoServer,
fromServer: FromServer
) {
this.initOutput = initOutput
this.#intoServer = intoServer
this.#fromServer = fromServer
}
static async initialize(
intoServer: IntoServer,
fromServer: FromServer
): Promise<Server> {
if (null == server) {
const initOutput = await init()
server = new Server(initOutput, intoServer, fromServer)
} else {
console.warn('Server already initialized; ignoring')
}
return server
}
async start(): Promise<void> {
const config = new ServerConfig(this.#intoServer, this.#fromServer)
await lsp_run(config)
}
}

View File

@ -1,21 +0,0 @@
import { Message } from 'vscode-languageserver-protocol'
const env = import.meta.env.MODE
export default class Tracer {
static client(message: string): void {
// These are really noisy, so we have a special env var for them.
if (env === 'lsp_tracing') {
console.log('lsp client message', message)
}
}
static server(input: string | Message): void {
// These are really noisy, so we have a special env var for them.
if (env === 'lsp_tracing') {
const message: string =
typeof input === 'string' ? input : JSON.stringify(input)
console.log('lsp server message', message)
}
}
}

View File

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

View File

@ -0,0 +1,54 @@
import { createActorContext } from '@xstate/react'
import { useNavigate } from 'react-router-dom'
import { paths } from '../Router'
import { authMachine, TOKEN_PERSIST_KEY } from '../lib/authMachine'
import withBaseUrl from '../lib/withBaseURL'
export const AuthMachineContext = createActorContext(authMachine)
export const GlobalStateProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const navigate = useNavigate()
return (
<AuthMachineContext.Provider
machine={() =>
authMachine.withConfig({
actions: {
goToSignInPage: () => {
navigate(paths.SIGN_IN)
logout()
},
goToIndexPage: () => navigate(paths.INDEX),
},
})
}
>
{children}
</AuthMachineContext.Provider>
)
}
export function useAuthMachine<T>(
selector: (
state: Parameters<Parameters<typeof AuthMachineContext.useSelector>[0]>[0]
) => T = () => null as T
): [T, ReturnType<typeof AuthMachineContext.useActor>[1]] {
// useActor api normally `[state, send] = useActor`
// we're only interested in send because of the selector
const send = AuthMachineContext.useActor()[1]
const selection = AuthMachineContext.useSelector(selector)
return [selection, send]
}
export function logout() {
const url = withBaseUrl('/logout')
localStorage.removeItem(TOKEN_PERSIST_KEY)
return fetch(url, {
method: 'POST',
credentials: 'include',
})
}

View File

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

View File

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

View File

@ -1,42 +0,0 @@
import { useEffect } from 'react'
import { AnyStateMachine, StateFrom } from 'xstate'
import { Command, CommandBarMeta, createMachineCommand } from '../lib/commands'
import { useCommandsContext } from './useCommandsContext'
interface UseStateMachineCommandsArgs<T extends AnyStateMachine> {
state: StateFrom<T>
send: Function
commandBarMeta?: CommandBarMeta
commands: Command[]
owner: string
}
export default function useStateMachineCommands<T extends AnyStateMachine>({
state,
send,
commandBarMeta,
owner,
}: UseStateMachineCommandsArgs<T>) {
const { addCommands, removeCommands } = useCommandsContext()
useEffect(() => {
const newCommands = state.nextEvents
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
.map((type) =>
createMachineCommand<T>({
type,
state,
send,
commandBarMeta,
owner,
})
)
.filter((c) => c !== null) as Command[]
addCommands(newCommands)
return () => {
removeCommands(newCommands)
}
}, [state])
}

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

@ -0,0 +1,67 @@
import { useEffect } from 'react'
import { useStore } from '../useStore'
import { parse } from 'toml'
import {
createDir,
BaseDirectory,
readDir,
readTextFile,
} from '@tauri-apps/api/fs'
export const useTauriBoot = () => {
const { defaultDir, setDefaultDir, setHomeMenuItems } = useStore((s) => ({
defaultDir: s.defaultDir,
setDefaultDir: s.setDefaultDir,
setHomeMenuItems: s.setHomeMenuItems,
}))
useEffect(() => {
const isTauri = (window as any).__TAURI__
if (!isTauri) return
const run = async () => {
if (!defaultDir.base) {
createDir('puffin-projects/example', {
dir: BaseDirectory.Home,
recursive: true,
})
setDefaultDir({
base: BaseDirectory.Home,
dir: 'puffin-projects',
})
} else {
const directoryResult = await readDir(defaultDir.dir, {
dir: defaultDir.base,
recursive: true,
})
const puffinProjects = directoryResult.filter(
(file) =>
!file?.name?.startsWith('.') &&
file?.children?.find((child) => child?.name === 'wax.toml')
)
const tomlFiles = await Promise.all(
puffinProjects.map(async (file) => {
const parsedToml = parse(
await readTextFile(`${file.path}/wax.toml`, {
dir: defaultDir.base,
})
)
const mainPath = parsedToml?.package?.main
const projectName = parsedToml?.package?.name
return {
file,
mainPath,
projectName,
}
})
)
setHomeMenuItems(
tomlFiles.map(({ file, mainPath, projectName }) => ({
name: projectName,
path: mainPath ? `${file.path}/${mainPath}` : file.path,
}))
)
}
}
run()
}, [])
}

View File

@ -86,21 +86,8 @@ code {
@apply bg-transparent;
}
#code-mirror-override .cm-scroller {
}
#code-mirror-override .cm-activeLine,
#code-mirror-override .cm-activeLineGutter {
@apply bg-liquid-10/50;
}
.dark #code-mirror-override .cm-activeLine,
.dark #code-mirror-override .cm-activeLineGutter {
@apply bg-liquid-80/50;
}
#code-mirror-override .cm-gutters {
@apply bg-chalkboard-10/30;
@apply bg-chalkboard-10/50;
}
.dark #code-mirror-override .cm-gutters {
@ -112,43 +99,16 @@ code {
}
#code-mirror-override .cm-cursor {
display: block;
width: 1ch;
@apply bg-liquid-40 mix-blend-multiply;
animation: blink 2s ease-out infinite;
}
.dark #code-mirror-override .cm-cursor {
@apply bg-liquid-50;
}
@keyframes blink {
0%,
100% {
opacity: 0;
}
15% {
opacity: 0.75;
}
width: 200px;
background: linear-gradient(
to right,
rgb(0, 55, 94) 0%,
#0084e2ff 2%,
#0084e255 5%,
transparent 100%
);
}
.react-json-view {
@apply bg-transparent !important;
}
#code-mirror-override .cm-tooltip {
font-size: 80%;
}
#code-mirror-override .cm-tooltip-hover {
}
#code-mirror-override .cm-tooltip-hover .documentation {
padding: 5;
}
#code-mirror-override .cm-content {
white-space: pre-wrap;
word-break: normal;
word-wrap: break-word;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import { ProgramMemory } from './executor'
import { initPromise } from './rust'
import { enginelessExecutor } from '../lib/testHelpers'
import { vi } from 'vitest'
import { KCLError } from './errors'
import { KCLUndefinedValueError } from './errors'
beforeAll(() => initPromise)
@ -30,6 +30,29 @@ const newVar = myVar + 1`
const { root } = await exe(code)
expect(root.myVar.value).toBe('a str another str')
})
it('test with function call', async () => {
const code = `
const myVar = "hello"
log(5, myVar)`
const programMemoryOverride: ProgramMemory['root'] = {
log: {
type: 'userVal',
value: vi.fn(),
__meta: [
{
sourceRange: [0, 0],
pathToNode: [],
},
],
},
}
const { root } = await enginelessExecutor(parser_wasm(code), {
root: programMemoryOverride,
pendingMemory: {},
})
expect(root.myVar.value).toBe('hello')
expect(programMemoryOverride.log.value).toHaveBeenCalledWith(5, 'hello')
})
it('fn funcN = () => {} execute', async () => {
const { root } = await exe(
[
@ -61,7 +84,8 @@ show(mySketch)
from: [0, 0],
__geoMeta: {
sourceRange: [43, 80],
id: expect.any(String),
id: '37333036-3033-4432-b530-643030303837',
pathToNode: [],
},
name: 'myPath',
},
@ -69,10 +93,10 @@ show(mySketch)
type: 'toPoint',
to: [2, 3],
from: [0, 2],
name: '',
__geoMeta: {
sourceRange: [86, 102],
id: expect.any(String),
id: '32343136-3330-4134-a462-376437386365',
pathToNode: [],
},
},
{
@ -81,7 +105,8 @@ show(mySketch)
from: [2, 3],
__geoMeta: {
sourceRange: [108, 151],
id: expect.any(String),
id: '32306132-6130-4138-b832-636363326330',
pathToNode: [],
},
name: 'rightPath',
},
@ -145,12 +170,13 @@ show(mySketch)
expect(root.mySk1).toEqual({
type: 'sketchGroup',
start: {
type: 'base',
to: [0, 0],
from: [0, 0],
name: '',
__geoMeta: {
id: expect.any(String),
id: '37663863-3664-4366-a637-623739336334',
sourceRange: [14, 34],
pathToNode: [],
},
},
value: [
@ -158,10 +184,10 @@ show(mySketch)
type: 'toPoint',
to: [1, 1],
from: [0, 0],
name: '',
__geoMeta: {
sourceRange: [40, 56],
id: expect.any(String),
id: '34356231-3362-4363-b935-393033353034',
pathToNode: [],
},
},
{
@ -170,7 +196,8 @@ show(mySketch)
from: [1, 1],
__geoMeta: {
sourceRange: [62, 100],
id: expect.any(String),
id: '39623339-3538-4366-b633-356630326639',
pathToNode: [],
},
name: 'myPath',
},
@ -178,17 +205,17 @@ show(mySketch)
type: 'toPoint',
to: [1, 1],
from: [0, 1],
name: '',
__geoMeta: {
sourceRange: [106, 122],
id: expect.any(String),
id: '30636135-6232-4335-b665-366562303161',
pathToNode: [],
},
},
],
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
id: expect.any(String),
__meta: [{ sourceRange: [14, 34] }],
id: '30376661-3039-4965-b532-653665313731',
__meta: [{ sourceRange: [14, 34], pathToNode: [] }],
})
})
it('execute array expression', async () => {
@ -203,6 +230,13 @@ show(mySketch)
value: 3,
__meta: [
{
pathToNode: [
['body', ''],
[0, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [14, 15],
},
],
@ -212,8 +246,25 @@ show(mySketch)
value: [1, '2', 3, 9],
__meta: [
{
pathToNode: [
['body', ''],
[1, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [27, 49],
},
{
pathToNode: [
['body', ''],
[0, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [14, 15],
},
],
},
})
@ -229,6 +280,13 @@ show(mySketch)
value: { aStr: 'str', anum: 2, identifier: 3, binExp: 9 },
__meta: [
{
pathToNode: [
['body', ''],
[1, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [27, 83],
},
],
@ -244,6 +302,13 @@ show(mySketch)
value: '123',
__meta: [
{
pathToNode: [
['body', ''],
[1, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [41, 50],
},
],
@ -386,18 +451,17 @@ const theExtrude = startSketchAt([0, 0])
|> extrude(4, %)
show(theExtrude)`
await expect(exe(code)).rejects.toEqual(
new KCLError(
'undefined_value',
'memory item key `myVarZ` is not defined',
[[100, 106]]
)
new KCLUndefinedValueError('Memory item myVarZ not found', [[100, 106]])
)
})
})
// helpers
async function exe(code: string, programMemory: ProgramMemory = { root: {} }) {
async function exe(
code: string,
programMemory: ProgramMemory = { root: {}, pendingMemory: {} }
) {
const ast = parser_wasm(code)
const result = await enginelessExecutor(ast, programMemory)

View File

@ -1,19 +1,35 @@
import { Program } from './abstractSyntaxTreeTypes'
import {
Program,
BinaryPart,
BinaryExpression,
PipeExpression,
ObjectExpression,
MemberExpression,
Identifier,
CallExpression,
ArrayExpression,
UnaryExpression,
} from './abstractSyntaxTreeTypes'
import { InternalFnNames } from './std/stdTypes'
import { internalFns } from './std/std'
import {
KCLUndefinedValueError,
KCLValueAlreadyDefined,
KCLSyntaxError,
KCLSemanticError,
KCLTypeError,
} from './errors'
import {
EngineCommandManager,
ArtifactMap,
SourceRangeMap,
} from './std/engineConnection'
import { ProgramReturn } from '../wasm-lib/kcl/bindings/ProgramReturn'
import { execute_wasm } from '../wasm-lib/pkg/wasm_lib'
import { KCLError } from './errors'
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
import { rangeTypeFix } from './abstractSyntaxTree'
export type SourceRange = [number, number]
export type PathToNode = [string | number, string][] // [pathKey, nodeType][]
export type Metadata = {
sourceRange: SourceRange
pathToNode: PathToNode
}
export type Position = [number, number, number]
export type Rotation = [number, number, number, number]
@ -25,6 +41,7 @@ interface BasePath {
__geoMeta: {
id: string
sourceRange: SourceRange
pathToNode: PathToNode
}
}
@ -51,7 +68,9 @@ export interface AngledLineTo extends BasePath {
interface GeoMeta {
__geoMeta: {
id: string
refId?: string
sourceRange: SourceRange
pathToNode: PathToNode
}
}
@ -99,16 +118,69 @@ type MemoryItem = UserVal | SketchGroup | ExtrudeGroup
interface Memory {
[key: string]: MemoryItem
}
interface PendingMemory {
[key: string]: Promise<MemoryItem>
}
export interface ProgramMemory {
root: Memory
return?: ProgramReturn
pendingMemory: Partial<PendingMemory>
return?: Identifier[]
}
const addItemToMemory = (
programMemory: ProgramMemory,
key: string,
sourceRange: [[number, number]],
value: MemoryItem | Promise<MemoryItem>
) => {
const _programMemory = programMemory
if (_programMemory.root[key] || _programMemory.pendingMemory[key]) {
throw new KCLValueAlreadyDefined(key, sourceRange)
}
if (value instanceof Promise) {
_programMemory.pendingMemory[key] = value
value.then((resolvedValue) => {
_programMemory.root[key] = resolvedValue
delete _programMemory.pendingMemory[key]
})
} else {
_programMemory.root[key] = value
}
return _programMemory
}
const promisifyMemoryItem = async (obj: MemoryItem) => {
if (obj.value instanceof Promise) {
const resolvedGuy = await obj.value
return {
...obj,
value: resolvedGuy,
}
}
return obj
}
const getMemoryItem = async (
programMemory: ProgramMemory,
key: string,
sourceRanges: [number, number][]
): Promise<MemoryItem> => {
if (programMemory.root[key]) {
return programMemory.root[key]
}
if (programMemory.pendingMemory[key]) {
return programMemory.pendingMemory[key] as Promise<MemoryItem>
}
throw new KCLUndefinedValueError(`Memory item ${key} not found`, sourceRanges)
}
export const executor = async (
node: Program,
programMemory: ProgramMemory = { root: {} },
programMemory: ProgramMemory = { root: {}, pendingMemory: {} },
engineCommandManager: EngineCommandManager,
options: { bodyType: 'root' | 'sketch' | 'block' } = { bodyType: 'root' },
previousPathToNode: PathToNode = [],
// work around while the gemotry is still be stored on the frontend
// will be removed when the stream UI is added.
tempMapCallback: (a: {
@ -120,7 +192,9 @@ export const executor = async (
const _programMemory = await _executor(
node,
programMemory,
engineCommandManager
engineCommandManager,
options,
previousPathToNode
)
const { artifactMap, sourceRangeMap } =
await engineCommandManager.waitForAllCommands()
@ -132,25 +206,840 @@ export const executor = async (
export const _executor = async (
node: Program,
programMemory: ProgramMemory = { root: {} },
engineCommandManager: EngineCommandManager
programMemory: ProgramMemory = { root: {}, pendingMemory: {} },
engineCommandManager: EngineCommandManager,
options: { bodyType: 'root' | 'sketch' | 'block' } = { bodyType: 'root' },
previousPathToNode: PathToNode = []
): Promise<ProgramMemory> => {
try {
const memory: ProgramMemory = await execute_wasm(
JSON.stringify(node),
JSON.stringify(programMemory),
engineCommandManager
)
return memory
} catch (e: any) {
const parsed: RustKclError = JSON.parse(e.toString())
const kclError = new KCLError(
parsed.kind,
parsed.msg,
rangeTypeFix(parsed.sourceRanges)
)
let _programMemory: ProgramMemory = {
root: {
...programMemory.root,
},
pendingMemory: {
...programMemory.pendingMemory,
},
return: programMemory.return,
}
const { body } = node
const proms: Promise<any>[] = []
for (let bodyIndex = 0; bodyIndex < body.length; bodyIndex++) {
const statement = body[bodyIndex]
if (statement.type === 'VariableDeclaration') {
for (let index = 0; index < statement.declarations.length; index++) {
const declaration = statement.declarations[index]
const variableName = declaration.id.name
const pathToNode: PathToNode = [
...previousPathToNode,
['body', ''],
[bodyIndex, 'index'],
['declarations', 'VariableDeclaration'],
[index, 'index'],
['init', 'VariableDeclaration'],
]
const sourceRange: SourceRange = [
declaration.init.start,
declaration.init.end,
]
const __meta: Metadata[] = [
{
pathToNode,
sourceRange,
},
]
console.log(kclError)
throw kclError
if (declaration.init.type === 'PipeExpression') {
const prom = getPipeExpressionResult(
declaration.init,
_programMemory,
engineCommandManager,
pathToNode
)
proms.push(prom)
const value = await prom
if (value?.type === 'sketchGroup' || value?.type === 'extrudeGroup') {
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
value
)
} else {
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
{
type: 'userVal',
value,
__meta,
}
)
}
} else if (declaration.init.type === 'Identifier') {
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
{
type: 'userVal',
value: _programMemory.root[declaration.init.name].value,
__meta,
}
)
} else if (declaration.init.type === 'Literal') {
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
{
type: 'userVal',
value: declaration.init.value,
__meta,
}
)
} else if (declaration.init.type === 'BinaryExpression') {
const prom = getBinaryExpressionResult(
declaration.init,
_programMemory,
engineCommandManager
)
proms.push(prom)
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
promisifyMemoryItem({
type: 'userVal',
value: prom,
__meta,
})
)
} else if (declaration.init.type === 'UnaryExpression') {
const prom = getUnaryExpressionResult(
declaration.init,
_programMemory,
engineCommandManager
)
proms.push(prom)
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
promisifyMemoryItem({
type: 'userVal',
value: prom,
__meta,
})
)
} else if (declaration.init.type === 'ArrayExpression') {
const valueInfo: Promise<{ value: any; __meta?: Metadata }>[] =
declaration.init.elements.map(
async (element): Promise<{ value: any; __meta?: Metadata }> => {
if (element.type === 'Literal') {
return {
value: element.value,
}
} else if (element.type === 'BinaryExpression') {
const prom = getBinaryExpressionResult(
element,
_programMemory,
engineCommandManager
)
proms.push(prom)
return {
value: await prom,
}
} else if (element.type === 'PipeExpression') {
const prom = getPipeExpressionResult(
element,
_programMemory,
engineCommandManager,
pathToNode
)
proms.push(prom)
return {
value: await prom,
}
} else if (element.type === 'Identifier') {
const node = await getMemoryItem(
_programMemory,
element.name,
[[element.start, element.end]]
)
return {
value: node.value,
__meta: node.__meta[node.__meta.length - 1],
}
} else if (element.type === 'UnaryExpression') {
const prom = getUnaryExpressionResult(
element,
_programMemory,
engineCommandManager
)
proms.push(prom)
return {
value: await prom,
}
} else {
throw new KCLSyntaxError(
`Unexpected element type ${element.type} in array expression`,
// TODO: Refactor this whole block into a `switch` so that we have a specific
// type here and can put a sourceRange.
[]
)
}
}
)
const awaitedValueInfo = await Promise.all(valueInfo)
const meta = awaitedValueInfo
.filter(({ __meta }) => __meta)
.map(({ __meta }) => __meta) as Metadata[]
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
{
type: 'userVal',
value: awaitedValueInfo.map(({ value }) => value),
__meta: [...__meta, ...meta],
}
)
} else if (declaration.init.type === 'ObjectExpression') {
const prom = executeObjectExpression(
_programMemory,
declaration.init,
engineCommandManager
)
proms.push(prom)
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
promisifyMemoryItem({
type: 'userVal',
value: prom,
__meta,
})
)
} else if (declaration.init.type === 'FunctionExpression') {
const fnInit = declaration.init
_programMemory = addItemToMemory(
_programMemory,
declaration.id.name,
[sourceRange],
{
type: 'userVal',
value: async (...args: any[]) => {
let fnMemory: ProgramMemory = {
root: {
..._programMemory.root,
},
pendingMemory: {
..._programMemory.pendingMemory,
},
}
if (args.length > fnInit.params.length) {
throw new KCLSyntaxError(
`Too many arguments passed to function ${declaration.id.name}`,
[[declaration.start, declaration.end]]
)
} else if (args.length < fnInit.params.length) {
throw new KCLSyntaxError(
`Too few arguments passed to function ${declaration.id.name}`,
[[declaration.start, declaration.end]]
)
}
fnInit.params.forEach((param, index) => {
fnMemory = addItemToMemory(
fnMemory,
param.name,
[sourceRange],
{
type: 'userVal',
value: args[index],
__meta,
}
)
})
const prom = _executor(
fnInit.body,
fnMemory,
engineCommandManager,
{
bodyType: 'block',
}
)
proms.push(prom)
const result = (await prom).return
return result
},
__meta,
}
)
} else if (declaration.init.type === 'MemberExpression') {
await Promise.all([...proms]) // TODO wait for previous promises, does that makes sense?
const prom = getMemberExpressionResult(
declaration.init,
_programMemory
)
proms.push(prom)
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
promisifyMemoryItem({
type: 'userVal',
value: prom,
__meta,
})
)
} else if (declaration.init.type === 'CallExpression') {
const prom = executeCallExpression(
_programMemory,
declaration.init,
engineCommandManager,
previousPathToNode
)
proms.push(prom)
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
prom.then((a) => {
return a?.type === 'sketchGroup' || a?.type === 'extrudeGroup'
? a
: {
type: 'userVal',
value: a,
__meta,
}
})
)
} else {
throw new KCLSyntaxError(
'Unsupported declaration type: ' + declaration.init.type,
[[declaration.start, declaration.end]]
)
}
}
} else if (statement.type === 'ExpressionStatement') {
const expression = statement.expression
if (expression.type === 'CallExpression') {
const functionName = expression.callee.name
const args = expression.arguments.map((arg) => {
if (arg.type === 'Literal') {
return arg.value
} else if (arg.type === 'Identifier') {
return _programMemory.root[arg.name]?.value
}
})
if ('show' === functionName) {
if (options.bodyType !== 'root') {
throw new KCLSemanticError(
`Cannot call ${functionName} outside of a root`,
[[statement.start, statement.end]]
)
}
_programMemory.return = expression.arguments as any // todo memory redo
} else {
if (_programMemory.root[functionName] === undefined) {
throw new KCLSemanticError(`No such name ${functionName} defined`, [
[statement.start, statement.end],
])
}
_programMemory.root[functionName].value(...args)
}
}
} else if (statement.type === 'ReturnStatement') {
if (statement.argument.type === 'BinaryExpression') {
const prom = getBinaryExpressionResult(
statement.argument,
_programMemory,
engineCommandManager
)
proms.push(prom)
_programMemory.return = await prom
}
}
}
await Promise.all(proms)
return _programMemory
}
function getMemberExpressionResult(
expression: MemberExpression,
programMemory: ProgramMemory
) {
const propertyName = (
expression.property.type === 'Identifier'
? expression.property.name
: expression.property.value
) as any
const object: any =
expression.object.type === 'MemberExpression'
? getMemberExpressionResult(expression.object, programMemory)
: programMemory.root[expression.object.name]?.value
return object?.[propertyName]
}
async function getBinaryExpressionResult(
expression: BinaryExpression,
programMemory: ProgramMemory,
engineCommandManager: EngineCommandManager,
pipeInfo: {
isInPipe: boolean
previousResults: any[]
expressionIndex: number
body: PipeExpression['body']
sourceRangeOverride?: SourceRange
} = {
isInPipe: false,
previousResults: [],
expressionIndex: 0,
body: [],
}
) {
const _pipeInfo = {
...pipeInfo,
isInPipe: false,
}
const left = await getBinaryPartResult(
expression.left,
programMemory,
engineCommandManager,
_pipeInfo
)
const right = await getBinaryPartResult(
expression.right,
programMemory,
engineCommandManager,
_pipeInfo
)
if (expression.operator === '+') return left + right
if (expression.operator === '-') return left - right
if (expression.operator === '*') return left * right
if (expression.operator === '/') return left / right
if (expression.operator === '%') return left % right
}
async function getBinaryPartResult(
part: BinaryPart,
programMemory: ProgramMemory,
engineCommandManager: EngineCommandManager,
pipeInfo: {
isInPipe: boolean
previousResults: any[]
expressionIndex: number
body: PipeExpression['body']
sourceRangeOverride?: SourceRange
} = {
isInPipe: false,
previousResults: [],
expressionIndex: 0,
body: [],
}
): Promise<any> {
const _pipeInfo = {
...pipeInfo,
isInPipe: false,
}
if (part.type === 'Literal') {
return part.value
} else if (part.type === 'Identifier') {
return programMemory.root[part.name].value
} else if (part.type === 'BinaryExpression') {
const prom = getBinaryExpressionResult(
part,
programMemory,
engineCommandManager,
_pipeInfo
)
const result = await prom
return result
} else if (part.type === 'CallExpression') {
const result = await executeCallExpression(
programMemory,
part,
engineCommandManager,
[],
_pipeInfo
)
return result
}
}
async function getUnaryExpressionResult(
expression: UnaryExpression,
programMemory: ProgramMemory,
engineCommandManager: EngineCommandManager,
pipeInfo: {
isInPipe: boolean
previousResults: any[]
expressionIndex: number
body: PipeExpression['body']
sourceRangeOverride?: SourceRange
} = {
isInPipe: false,
previousResults: [],
expressionIndex: 0,
body: [],
}
) {
return -(await getBinaryPartResult(
expression.argument,
programMemory,
engineCommandManager,
{
...pipeInfo,
isInPipe: false,
}
))
}
async function getPipeExpressionResult(
expression: PipeExpression,
programMemory: ProgramMemory,
engineCommandManager: EngineCommandManager,
previousPathToNode: PathToNode = []
) {
const executedBody = await executePipeBody(
expression.body,
programMemory,
engineCommandManager,
previousPathToNode
)
const result = executedBody[executedBody.length - 1]
return result
}
async function executePipeBody(
body: PipeExpression['body'],
programMemory: ProgramMemory,
engineCommandManager: EngineCommandManager,
previousPathToNode: PathToNode = [],
expressionIndex = 0,
previousResults: any[] = []
): Promise<any[]> {
if (expressionIndex === body.length) {
return previousResults
}
const expression = body[expressionIndex]
if (expression.type === 'BinaryExpression') {
const result = await getBinaryExpressionResult(
expression,
programMemory,
engineCommandManager
)
return executePipeBody(
body,
programMemory,
engineCommandManager,
previousPathToNode,
expressionIndex + 1,
[...previousResults, result]
)
} else if (expression.type === 'CallExpression') {
return await executeCallExpression(
programMemory,
expression,
engineCommandManager,
previousPathToNode,
{
isInPipe: true,
previousResults,
expressionIndex,
body,
}
)
}
throw new KCLSyntaxError('Invalid pipe expression', [
[expression.start, expression.end],
])
}
async function executeObjectExpression(
_programMemory: ProgramMemory,
objExp: ObjectExpression,
engineCommandManager: EngineCommandManager,
pipeInfo: {
isInPipe: boolean
previousResults: any[]
expressionIndex: number
body: PipeExpression['body']
sourceRangeOverride?: SourceRange
} = {
isInPipe: false,
previousResults: [],
expressionIndex: 0,
body: [],
}
) {
const _pipeInfo = {
...pipeInfo,
isInPipe: false,
}
const obj: { [key: string]: any } = {}
const proms: Promise<any>[] = []
objExp.properties.forEach(async (property) => {
if (property.type === 'ObjectProperty') {
if (property.value.type === 'Literal') {
obj[property.key.name] = property.value.value
} else if (property.value.type === 'BinaryExpression') {
const prom = getBinaryExpressionResult(
property.value,
_programMemory,
engineCommandManager,
_pipeInfo
)
proms.push(prom)
obj[property.key.name] = await prom
} else if (property.value.type === 'PipeExpression') {
const prom = getPipeExpressionResult(
property.value,
_programMemory,
engineCommandManager
)
proms.push(prom)
obj[property.key.name] = await prom
} else if (property.value.type === 'Identifier') {
obj[property.key.name] = (
await getMemoryItem(_programMemory, property.value.name, [
[property.value.start, property.value.end],
])
).value
} else if (property.value.type === 'ObjectExpression') {
const prom = executeObjectExpression(
_programMemory,
property.value,
engineCommandManager
)
proms.push(prom)
obj[property.key.name] = await prom
} else if (property.value.type === 'ArrayExpression') {
const prom = executeArrayExpression(
_programMemory,
property.value,
engineCommandManager
)
proms.push(prom)
obj[property.key.name] = await prom
} else if (property.value.type === 'CallExpression') {
const prom = executeCallExpression(
_programMemory,
property.value,
engineCommandManager,
[],
_pipeInfo
)
proms.push(prom)
const result = await prom
obj[property.key.name] = result
} else if (property.value.type === 'UnaryExpression') {
const prom = getUnaryExpressionResult(
property.value,
_programMemory,
engineCommandManager
)
proms.push(prom)
obj[property.key.name] = await prom
} else {
throw new KCLSyntaxError(
`Unexpected property type ${property.value.type} in object expression`,
[[property.value.start, property.value.end]]
)
}
} else {
throw new KCLSyntaxError(
`Unexpected property type ${property.type} in object expression`,
[[property.value.start, property.value.end]]
)
}
})
await Promise.all(proms)
return obj
}
async function executeArrayExpression(
_programMemory: ProgramMemory,
arrExp: ArrayExpression,
engineCommandManager: EngineCommandManager,
pipeInfo: {
isInPipe: boolean
previousResults: any[]
expressionIndex: number
body: PipeExpression['body']
sourceRangeOverride?: SourceRange
} = {
isInPipe: false,
previousResults: [],
expressionIndex: 0,
body: [],
}
) {
const _pipeInfo = {
...pipeInfo,
isInPipe: false,
}
return await Promise.all(
arrExp.elements.map((el) => {
if (el.type === 'Literal') {
return el.value
} else if (el.type === 'Identifier') {
return _programMemory.root?.[el.name]?.value
} else if (el.type === 'BinaryExpression') {
return getBinaryExpressionResult(
el,
_programMemory,
engineCommandManager,
_pipeInfo
)
} else if (el.type === 'ObjectExpression') {
return executeObjectExpression(_programMemory, el, engineCommandManager)
} else if (el.type === 'CallExpression') {
const result: any = executeCallExpression(
_programMemory,
el,
engineCommandManager,
[],
_pipeInfo
)
return result
} else if (el.type === 'UnaryExpression') {
return getUnaryExpressionResult(
el,
_programMemory,
engineCommandManager,
{
...pipeInfo,
isInPipe: false,
}
)
}
throw new KCLTypeError('Invalid argument type', [[el.start, el.end]])
})
)
}
async function executeCallExpression(
programMemory: ProgramMemory,
expression: CallExpression,
engineCommandManager: EngineCommandManager,
previousPathToNode: PathToNode = [],
pipeInfo: {
isInPipe: boolean
previousResults: any[]
expressionIndex: number
body: PipeExpression['body']
sourceRangeOverride?: SourceRange
} = {
isInPipe: false,
previousResults: [],
expressionIndex: 0,
body: [],
}
) {
const {
isInPipe,
previousResults,
expressionIndex,
body,
sourceRangeOverride,
} = pipeInfo
const functionName = expression?.callee?.name
const _pipeInfo = {
...pipeInfo,
isInPipe: false,
}
const fnArgs = await Promise.all(
expression?.arguments?.map(async (arg) => {
if (arg.type === 'Literal') {
return arg.value
} else if (arg.type === 'Identifier') {
await new Promise((r) => setTimeout(r)) // push into next even loop, but also probably should fix this
const temp = await getMemoryItem(programMemory, arg.name, [
[arg.start, arg.end],
])
return temp?.type === 'userVal' ? temp.value : temp
} else if (arg.type === 'PipeSubstitution') {
return previousResults[expressionIndex - 1]
} else if (arg.type === 'ArrayExpression') {
return await executeArrayExpression(
programMemory,
arg,
engineCommandManager,
pipeInfo
)
} else if (arg.type === 'CallExpression') {
const result: any = await executeCallExpression(
programMemory,
arg,
engineCommandManager,
previousPathToNode,
_pipeInfo
)
return result
} else if (arg.type === 'ObjectExpression') {
return await executeObjectExpression(
programMemory,
arg,
engineCommandManager,
_pipeInfo
)
} else if (arg.type === 'UnaryExpression') {
return getUnaryExpressionResult(
arg,
programMemory,
engineCommandManager,
_pipeInfo
)
} else if (arg.type === 'BinaryExpression') {
return getBinaryExpressionResult(
arg,
programMemory,
engineCommandManager,
_pipeInfo
)
}
throw new KCLSyntaxError('Invalid argument type in function call', [
[arg.start, arg.end],
])
})
)
if (functionName in internalFns) {
const fnNameWithSketchOrExtrude = functionName as InternalFnNames
const result = await internalFns[fnNameWithSketchOrExtrude](
{
programMemory,
sourceRange: sourceRangeOverride || [expression.start, expression.end],
engineCommandManager,
code: JSON.stringify(expression),
},
fnArgs[0],
fnArgs[1],
fnArgs[2]
)
return isInPipe
? await executePipeBody(
body,
programMemory,
engineCommandManager,
previousPathToNode,
expressionIndex + 1,
[...previousResults, result]
)
: result
}
const result = await programMemory.root[functionName].value(...fnArgs)
return isInPipe
? await executePipeBody(
body,
programMemory,
engineCommandManager,
previousPathToNode,
expressionIndex + 1,
[...previousResults, result]
)
: result
}

View File

@ -182,14 +182,14 @@ describe('Testing moveValueIntoNewVariable', () => {
const code = `${fn('def')}${fn('ghi')}${fn('jkl')}${fn('hmm')}
const abc = 3
const identifierGuy = 5
const yo = 5 + 6
const part001 = startSketchAt([-1.2, 4.83])
|> line([2.8, 0], %)
|> angledLine([100 + 100, 3.09], %)
|> angledLine([abc, 3.09], %)
|> angledLine([def(yo), 3.09], %)
|> angledLine([def('yo'), 3.09], %)
|> angledLine([ghi(%), 3.09], %)
|> angledLine([jkl(yo) + 2, 3.09], %)
|> angledLine([jkl('yo') + 2, 3.09], %)
const yo = 5 + 6
const yo2 = hmm([identifierGuy + 5])
show(part001)`
it('should move a binary expression into a new variable', async () => {
@ -231,7 +231,7 @@ show(part001)`
'newVar'
)
const newCode = recast(modifiedAst)
expect(newCode).toContain(`const newVar = def(yo)`)
expect(newCode).toContain(`const newVar = def('yo')`)
expect(newCode).toContain(`angledLine([newVar, 3.09], %)`)
})
it('should move a binary expression with call expression into a new variable', async () => {
@ -245,7 +245,7 @@ show(part001)`
'newVar'
)
const newCode = recast(modifiedAst)
expect(newCode).toContain(`const newVar = jkl(yo) + 2`)
expect(newCode).toContain(`const newVar = jkl('yo') + 2`)
expect(newCode).toContain(`angledLine([newVar, 3.09], %)`)
})
it('should move a identifier into a new variable', async () => {

View File

@ -36,14 +36,14 @@ export function addSketchTo(
const _node = { ...node }
const _name = name || findUniqueName(node, 'part')
const startSketchAt = createCallExpressionStdLib('startSketchAt', [
const startSketchAt = createCallExpression('startSketchAt', [
createLiteral('default'),
])
const rotate = createCallExpression(axis === 'xz' ? 'rx' : 'ry', [
createLiteral(90),
createPipeSubstitution(),
])
const initialLineTo = createCallExpressionStdLib('line', [
const initialLineTo = createCallExpression('line', [
createLiteral('default'),
createPipeSubstitution(),
])
@ -112,9 +112,7 @@ function addToShow(node: Program, name: string): Program {
const dumbyStartend = { start: 0, end: 0 }
const showCallIndex = getShowIndex(_node)
if (showCallIndex === -1) {
const showCall = createCallExpressionStdLib('show', [
createIdentifier(name),
])
const showCall = createCallExpression('show', [createIdentifier(name)])
const showExpressionStatement: ExpressionStatement = {
type: 'ExpressionStatement',
...dumbyStartend,
@ -126,7 +124,7 @@ function addToShow(node: Program, name: string): Program {
const showCall = { ..._node.body[showCallIndex] } as ExpressionStatement
const showCallArgs = (showCall.expression as CallExpression).arguments
const newShowCallArgs: Value[] = [...showCallArgs, createIdentifier(name)]
const newShowExpression = createCallExpressionStdLib('show', newShowCallArgs)
const newShowExpression = createCallExpression('show', newShowCallArgs)
_node.body[showCallIndex] = {
...showCall,
@ -227,7 +225,7 @@ export function extrudeSketch(
const { node: variableDeclorator, shallowPath: pathToDecleration } =
getNodeFromPath<VariableDeclarator>(_node, pathToNode, 'VariableDeclarator')
const extrudeCall = createCallExpressionStdLib('extrude', [
const extrudeCall = createCallExpression('extrude', [
createLiteral(4),
shouldPipe
? createPipeSubstitution()
@ -315,15 +313,15 @@ export function sketchOnExtrudedFace(
const newSketch = createVariableDeclaration(
newSketchName,
createPipeExpression([
createCallExpressionStdLib('startSketchAt', [
createCallExpression('startSketchAt', [
createArrayExpression([createLiteral(0), createLiteral(0)]),
]),
createCallExpressionStdLib('lineTo', [
createCallExpression('lineTo', [
createArrayExpression([createLiteral(1), createLiteral(1)]),
createPipeSubstitution(),
]),
createCallExpression('transform', [
createCallExpressionStdLib('getExtrudeWallTransform', [
createCallExpression('getExtrudeWallTransform', [
createLiteral(tag),
createIdentifier(oldSketchName),
]),
@ -416,40 +414,6 @@ export function createPipeSubstitution(): PipeSubstitution {
}
}
export function createCallExpressionStdLib(
name: string,
args: CallExpression['arguments']
): CallExpression {
return {
type: 'CallExpression',
start: 0,
end: 0,
callee: {
type: 'Identifier',
start: 0,
end: 0,
name,
},
function: {
type: 'StdLib',
func: {
// We only need the name here to map it back when it serializes
// to rust, don't worry about the rest.
name,
summary: '',
description: '',
tags: [],
returnValue: { type: '', required: false, name: '', schema: {} },
args: [],
unpublished: false,
deprecated: false,
},
},
optional: false,
arguments: args,
}
}
export function createCallExpression(
name: string,
args: CallExpression['arguments']
@ -464,9 +428,6 @@ export function createCallExpression(
end: 0,
name,
},
function: {
type: 'InMemory',
},
optional: false,
arguments: args,
}

View File

@ -45,7 +45,8 @@ const newVar = myVar + 1`
expect(recasted).toBe(code.trim())
})
it('test with function call', () => {
const code = `const myVar = "hello"
const code = `
const myVar = "hello"
log(5, myVar)`
const { ast } = code2ast(code)
const recasted = recast(ast)
@ -70,7 +71,8 @@ log(5, myVar)`
|> lineTo({ to: [1, 0], tag: "rightPath" }, %)
|> close(%)
show(mySketch)`
show(mySketch)
`
const { ast } = code2ast(code)
const recasted = recast(ast)
expect(recasted).toBe(code.trim())
@ -184,7 +186,8 @@ const myVar2 = yo['a'][key2].c`
describe('testing recasting with comments and whitespace', () => {
it('code with comments', () => {
const code = `const yo = { a: { b: { c: '123' } } }
const code = `
const yo = { a: { b: { c: '123' } } }
// this is a comment
const key = 'c'`
@ -194,18 +197,20 @@ const key = 'c'`
expect(recasted).toBe(code)
})
it('code with comment and extra lines', () => {
const code = `const yo = 'c'
/* this is
const code = `
const yo = 'c' /* this is
a
comment */
const yo = 'bing'`
const { ast } = code2ast(code)
const recasted = recast(ast)
expect(recasted).toBe(code)
})
it('comments at the start and end', () => {
const code = `// this is a comment
const code = `
// this is a comment
const yo = { a: { b: { c: '123' } } }
const key = 'c'
@ -215,12 +220,12 @@ const key = 'c'
expect(recasted).toBe(code)
})
it('comments in a fn block', () => {
const code = `const myFn = () => {
const code = `
const myFn = () => {
// this is a comment
const yo = { a: { b: { c: '123' } } }
/* block
const yo = { a: { b: { c: '123' } } } /* block
comment */
const key = 'c'
// this is also a comment
}`
@ -250,7 +255,7 @@ const mySk1 = startSketchAt([0, 0])
// comment here
|> lineTo({ to: [0, 1], tag: 'myTag' }, %)
|> lineTo([1, 1], %) /* and
here
here
*/
// a comment between pipe expression statements
|> rx(90, %)
@ -264,21 +269,7 @@ const mySk1 = startSketchAt([0, 0])
*/`
const { ast } = code2ast(code)
const recasted = recast(ast)
expect(recasted).toBe(`// comment at start
const mySk1 = startSketchAt([0, 0])
|> lineTo([1, 1], %)
// comment here
|> lineTo({ to: [0, 1], tag: 'myTag' }, %)
|> lineTo([1, 1], %)
/* and
here
a comment between pipe expression statements */
|> rx(90, %)
// and another with just white space between others below
|> ry(45, %)
|> rx(45, %)
// one more for good measure`)
expect(recasted).toBe(code)
})
})
@ -304,7 +295,7 @@ describe('testing call Expressions in BinaryExpressions and UnaryExpressions', (
it('with unaryExpression in sketch situation', () => {
const code = [
'const part001 = startSketchAt([0, 0])',
' |> line([-2.21, -legLen(5, min(3, 999))], %)',
'|> line([-2.21, -legLen(5, min(3, 999))], %)',
].join('\n')
const { ast } = code2ast(code)
const recasted = recast(ast)
@ -318,10 +309,10 @@ describe('it recasts wrapped object expressions in pipe bodies with correct inde
|> line({ to: [0.62, 4.15], tag: 'seg01' }, %)
|> line([2.77, -1.24], %)
|> angledLineThatIntersects({
angle: 201,
offset: -1.35,
intersectTag: 'seg01'
}, %)
angle: 201,
offset: -1.35,
intersectTag: 'seg01'
}, %)
|> line([-0.42, -1.72], %)
show(part001)`
const { ast } = code2ast(code)

View File

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

View File

@ -1,14 +1,9 @@
import { SourceRange } from 'lang/executor'
import { Selections } from 'useStore'
import {
VITE_KC_API_WS_MODELING_URL,
VITE_KC_CONNECTION_TIMEOUT_MS,
VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS,
} from 'env'
import { SourceRange } from '../executor'
import { Selections } from '../../useStore'
import { VITE_KC_API_WS_MODELING_URL } from '../../env'
import { Models } from '@kittycad/lib'
import { exportSave } from 'lib/exportSave'
import { exportSave } from '../../lib/exportSave'
import { v4 as uuidv4 } from 'uuid'
import * as Sentry from '@sentry/react'
interface ResultCommand {
type: 'result'
@ -27,60 +22,61 @@ export interface SourceRangeMap {
[key: string]: SourceRange
}
interface SelectionsArgs {
id: string
type: Selections['codeBasedSelections'][number]['type']
}
interface CursorSelectionsArgs {
otherSelections: Selections['otherSelections']
idBasedSelections: { type: string; id: string }[]
}
interface NewTrackArgs {
conn: EngineConnection
mediaStream: MediaStream
}
type WebSocketResponse = Models['OkWebSocketResponseData_type']
export type EngineCommand = Models['WebSocketMessages_type']
type OkResponse = Models['OkModelingCmdResponse_type']
type WebSocketResponse = Models['WebSocketResponses_type']
enum EngineConnectionEvents {
ConnectionStarted = 'connectionStarted',
WebsocketOpen = 'websocketOpen',
NewTrack = 'newTrack',
DataChannelOpen = 'dataChannelOpen',
Open = 'open',
Close = 'close',
}
// EngineConnection encapsulates the connection(s) to the Engine
// for the EngineCommandManager; namely, the underlying WebSocket
// and WebRTC connections.
export class EngineConnection {
export class EngineConnection extends EventTarget {
websocket?: WebSocket
pc?: RTCPeerConnection
unreliableDataChannel?: RTCDataChannel
lossyDataChannel?: RTCDataChannel
private ready: boolean
readonly url: string
private readonly token?: string
private onWebsocketOpen: (engineConnection: EngineConnection) => void
private onDataChannelOpen: (engineConnection: EngineConnection) => void
private onEngineConnectionOpen: (engineConnection: EngineConnection) => void
private onConnectionStarted: (engineConnection: EngineConnection) => void
private onClose: (engineConnection: EngineConnection) => void
private onNewTrack: (track: NewTrackArgs) => void
constructor({
url,
token,
onWebsocketOpen = () => {},
onNewTrack = () => {},
onEngineConnectionOpen = () => {},
onConnectionStarted = () => {},
onClose = () => {},
onDataChannelOpen = () => {},
}: {
url: string
token?: string
onWebsocketOpen?: (engineConnection: EngineConnection) => void
onDataChannelOpen?: (engineConnection: EngineConnection) => void
onEngineConnectionOpen?: (engineConnection: EngineConnection) => void
onConnectionStarted?: (engineConnection: EngineConnection) => void
onClose?: (engineConnection: EngineConnection) => void
onNewTrack?: (track: NewTrackArgs) => void
}) {
constructor({ url, token }: { url: string; token?: string }) {
super()
this.url = url
this.token = token
this.ready = false
this.onWebsocketOpen = onWebsocketOpen
this.onDataChannelOpen = onDataChannelOpen
this.onEngineConnectionOpen = onEngineConnectionOpen
this.onConnectionStarted = onConnectionStarted
this.onClose = onClose
this.onNewTrack = onNewTrack
this.addEventListener(EngineConnectionEvents.Open, () => {
this.ready = true
})
this.addEventListener(EngineConnectionEvents.Close, () => {
this.ready = false
})
// TODO(paultag): This ought to be tweakable.
const pingIntervalMs = 10000
@ -102,11 +98,6 @@ export class EngineConnection {
isReady() {
return this.ready
}
// shouldTrace will return true when Sentry should be used to instrument
// the Engine.
shouldTrace() {
return Sentry.getCurrentHub()?.getClient()?.getOptions()?.sendClientReports
}
// connect will attempt to connect to the Engine over a WebSocket, and
// establish the WebRTC connections.
//
@ -116,44 +107,6 @@ export class EngineConnection {
// TODO(paultag): make this safe to call multiple times, and figure out
// when a connection is in progress (state: connecting or something).
// Information on the connect transaction
class SpanPromise {
span: Sentry.Span
promise: Promise<void>
resolve?: (v: void) => void
constructor(span: Sentry.Span) {
this.span = span
this.promise = new Promise((resolve) => {
this.resolve = (v: void) => {
// here we're going to invoke finish before resolving the
// promise so that a `.then()` will order strictly after
// all spans have -- for sure -- been resolved, rather than
// doing a `then` on this promise.
this.span.finish()
resolve(v)
}
})
}
}
let webrtcMediaTransaction: Sentry.Transaction
let websocketSpan: SpanPromise
let mediaTrackSpan: SpanPromise
let dataChannelSpan: SpanPromise
let handshakeSpan: SpanPromise
let iceSpan: SpanPromise
if (this.shouldTrace()) {
webrtcMediaTransaction = Sentry.startTransaction({
name: 'webrtc-media',
})
websocketSpan = new SpanPromise(
webrtcMediaTransaction.startChild({ op: 'websocket' })
)
}
this.websocket = new WebSocket(this.url, [])
this.websocket.binaryType = 'arraybuffer'
@ -167,38 +120,11 @@ export class EngineConnection {
})
this.websocket.addEventListener('open', (event) => {
if (this.shouldTrace()) {
websocketSpan.resolve?.()
handshakeSpan = new SpanPromise(
webrtcMediaTransaction.startChild({ op: 'handshake' })
)
iceSpan = new SpanPromise(
webrtcMediaTransaction.startChild({ op: 'ice' })
)
dataChannelSpan = new SpanPromise(
webrtcMediaTransaction.startChild({
op: 'data-channel',
})
)
mediaTrackSpan = new SpanPromise(
webrtcMediaTransaction.startChild({
op: 'media-track',
})
)
}
Promise.all([
handshakeSpan.promise,
iceSpan.promise,
dataChannelSpan.promise,
mediaTrackSpan.promise,
]).then(() => {
console.log('All spans finished, reporting')
webrtcMediaTransaction?.finish()
})
this.onWebsocketOpen(this)
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.WebsocketOpen, {
detail: this,
})
)
})
this.websocket.addEventListener('close', (event) => {
@ -223,32 +149,17 @@ export class EngineConnection {
return
}
const message: Models['WebSocketResponse_type'] = JSON.parse(event.data)
if (!message.success) {
if (message.request_id) {
console.error(`Error in response to request ${message.request_id}:`)
} else {
console.error(`Error from server:`)
}
message?.errors?.forEach((error) => {
console.error(` - ${error.error_code}: ${error.message}`)
})
if (event.data.toLocaleLowerCase().startsWith('error')) {
console.error('something went wrong: ', event.data)
return
}
let resp = message.resp
if (!resp) {
// If there's no body to the response, we can bail here.
return
}
if (resp.type === 'sdp_answer') {
let answer = resp.data?.answer
if (!answer || answer.type === 'unspecified') {
return
}
const message: WebSocketResponse = JSON.parse(event.data)
if (
message.type === 'sdp_answer' &&
message.answer.type !== 'unspecified'
) {
if (this.pc?.signalingState !== 'stable') {
// If the connection is stable, we shouldn't bother updating the
// SDP, since we have a stable connection to the backend. If we
@ -256,33 +167,24 @@ export class EngineConnection {
// tore down.
this.pc?.setRemoteDescription(
new RTCSessionDescription({
type: answer.type,
sdp: answer.sdp,
type: message.answer.type,
sdp: message.answer.sdp,
})
)
if (this.shouldTrace()) {
// When both ends have a local and remote SDP, we've been able to
// set up successfully. We'll still need to find the right ICE
// servers, but this is hand-shook.
handshakeSpan.resolve?.()
}
}
} else if (resp.type === 'trickle_ice') {
let candidate = resp.data?.candidate
this.pc?.addIceCandidate(candidate as RTCIceCandidateInit)
} else if (resp.type === 'ice_server_info' && this.pc) {
} 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')
let ice_servers = resp.data?.ice_servers
if (ice_servers?.length > 0) {
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: ice_servers,
iceServers: message.ice_servers,
iceTransportPolicy: 'relay',
})
} else {
@ -296,14 +198,20 @@ export class EngineConnection {
// PeerConnection and waiting for events to fire our callbacks.
this.pc.addEventListener('connectionstatechange', (event) => {
if (this.pc?.iceConnectionState === 'connected') {
iceSpan.resolve?.()
}
// if (this.pc?.iceConnectionState === 'disconnected') {
// this.close()
// }
})
this.pc.addEventListener('icecandidate', (event) => {
if (!this.pc || !this.websocket) return
if (event.candidate !== null) {
if (event.candidate === null) {
console.log('sent sdp_offer')
this.send({
type: 'sdp_offer',
offer: this.pc.localDescription,
})
} else {
console.log('sending trickle ice candidate')
const { candidate } = event
this.send({
@ -335,7 +243,7 @@ export class EngineConnection {
// TODO(paultag): This ought to be both controllable, as well as something
// like exponential backoff to have some grace on the backend, as well as
// fix responsiveness for clients that had a weird network hiccup.
const connectionTimeoutMs = VITE_KC_CONNECTION_TIMEOUT_MS
const connectionTimeoutMs = 5000
setTimeout(() => {
if (this.isReady()) {
@ -348,220 +256,73 @@ export class EngineConnection {
})
this.pc.addEventListener('track', (event) => {
console.log('received track', event)
const mediaStream = event.streams[0]
if (this.shouldTrace()) {
let mediaStreamTrack = mediaStream.getVideoTracks()[0]
mediaStreamTrack.addEventListener('unmute', () => {
// let settings = mediaStreamTrack.getSettings()
// mediaTrackSpan.span.setTag("fps", settings.frameRate)
// mediaTrackSpan.span.setTag("width", settings.width)
// mediaTrackSpan.span.setTag("height", settings.height)
mediaTrackSpan.resolve?.()
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.NewTrack, {
detail: {
conn: this,
mediaStream: mediaStream,
},
})
}
// Set up the background thread to keep an eye on statistical
// information about the WebRTC media stream from the server to
// us. We'll also eventually want more global statistical information,
// but this will give us a baseline.
if (parseInt(VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS) !== 0) {
setInterval(() => {
if (this.pc === undefined) {
return
}
if (!this.shouldTrace()) {
return
}
// Use the WebRTC Statistics API to collect statistical information
// about the WebRTC connection we're using to report to Sentry.
mediaStream.getVideoTracks().forEach((videoTrack) => {
let trackStats = new Map<string, any>()
this.pc?.getStats(videoTrack).then((videoTrackStats) => {
// Sentry only allows 10 metrics per transaction. We're going
// to have to pick carefully here, eventually send like a prom
// file or something to the peer.
const transaction = Sentry.startTransaction({
name: 'webrtc-stats',
})
videoTrackStats.forEach((videoTrackReport) => {
if (videoTrackReport.type === 'inbound-rtp') {
// RTC Stream Info
// transaction.setMeasurement(
// 'mediaStreamTrack.framesDecoded',
// videoTrackReport.framesDecoded,
// 'frame'
// )
transaction.setMeasurement(
'rtcFramesDropped',
videoTrackReport.framesDropped,
''
)
// transaction.setMeasurement(
// 'mediaStreamTrack.framesReceived',
// videoTrackReport.framesReceived,
// 'frame'
// )
transaction.setMeasurement(
'rtcFramesPerSecond',
videoTrackReport.framesPerSecond,
'fps'
)
transaction.setMeasurement(
'rtcFreezeCount',
videoTrackReport.freezeCount,
''
)
transaction.setMeasurement(
'rtcJitter',
videoTrackReport.jitter,
'second'
)
// transaction.setMeasurement(
// 'mediaStreamTrack.jitterBufferDelay',
// videoTrackReport.jitterBufferDelay,
// ''
// )
// transaction.setMeasurement(
// 'mediaStreamTrack.jitterBufferEmittedCount',
// videoTrackReport.jitterBufferEmittedCount,
// ''
// )
// transaction.setMeasurement(
// 'mediaStreamTrack.jitterBufferMinimumDelay',
// videoTrackReport.jitterBufferMinimumDelay,
// ''
// )
// transaction.setMeasurement(
// 'mediaStreamTrack.jitterBufferTargetDelay',
// videoTrackReport.jitterBufferTargetDelay,
// ''
// )
transaction.setMeasurement(
'rtcKeyFramesDecoded',
videoTrackReport.keyFramesDecoded,
''
)
transaction.setMeasurement(
'rtcTotalFreezesDuration',
videoTrackReport.totalFreezesDuration,
'second'
)
// transaction.setMeasurement(
// 'mediaStreamTrack.totalInterFrameDelay',
// videoTrackReport.totalInterFrameDelay,
// ''
// )
transaction.setMeasurement(
'rtcTotalPausesDuration',
videoTrackReport.totalPausesDuration,
'second'
)
// transaction.setMeasurement(
// 'mediaStreamTrack.totalProcessingDelay',
// videoTrackReport.totalProcessingDelay,
// 'second'
// )
} else if (videoTrackReport.type === 'transport') {
// // Bytes i/o
// transaction.setMeasurement(
// 'mediaStreamTrack.bytesReceived',
// videoTrackReport.bytesReceived,
// 'byte'
// )
// transaction.setMeasurement(
// 'mediaStreamTrack.bytesSent',
// videoTrackReport.bytesSent,
// 'byte'
// )
}
})
transaction?.finish()
})
})
}, VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS)
}
this.onNewTrack({
conn: this,
mediaStream: mediaStream,
})
)
})
// During startup, we'll track the time from `connect` being called
// until the 'done' event fires.
let connectionStarted = new Date()
this.pc.addEventListener('datachannel', (event) => {
this.unreliableDataChannel = event.channel
this.lossyDataChannel = event.channel
console.log('accepted unreliable data channel', event.channel.label)
this.unreliableDataChannel.addEventListener('open', (event) => {
console.log('unreliable data channel opened', event)
if (this.shouldTrace()) {
dataChannelSpan.resolve?.()
}
console.log('accepted lossy data channel', event.channel.label)
this.lossyDataChannel.addEventListener('open', (event) => {
console.log('lossy data channel opened', event)
this.onDataChannelOpen(this)
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.DataChannelOpen, {
detail: this,
})
)
this.onEngineConnectionOpen(this)
this.ready = true
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.Open, {
detail: this,
})
)
})
this.unreliableDataChannel.addEventListener('close', (event) => {
console.log('unreliable data channel closed')
this.lossyDataChannel.addEventListener('close', (event) => {
console.log('lossy data channel closed')
this.close()
})
this.unreliableDataChannel.addEventListener('error', (event) => {
console.log('unreliable data channel error')
this.lossyDataChannel.addEventListener('error', (event) => {
console.log('lossy data channel error')
this.close()
})
})
this.onConnectionStarted(this)
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.ConnectionStarted, {
detail: this,
})
)
}
send(message: object | string) {
send(message: object) {
// TODO(paultag): Add in logic to determine the connection state and
// take actions if needed?
this.websocket?.send(
typeof message === 'string' ? message : JSON.stringify(message)
)
this.websocket?.send(JSON.stringify(message))
}
close() {
this.websocket?.close()
this.pc?.close()
this.unreliableDataChannel?.close()
this.websocket = undefined
this.pc = undefined
this.unreliableDataChannel = undefined
this.lossyDataChannel?.close()
this.onClose(this)
this.ready = false
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.Close, {
detail: this,
})
)
}
}
export type EngineCommand = Models['WebSocketRequest_type']
type ModelTypes = Models['OkModelingCmdResponse_type']['type']
type UnreliableResponses = Extract<
Models['OkModelingCmdResponse_type'],
{ type: 'highlight_set_entity' }
>
interface UnreliableSubscription<T extends UnreliableResponses['type']> {
event: T
callback: (data: Extract<UnreliableResponses, { type: T }>) => void
}
interface Subscription<T extends ModelTypes> {
event: T
callback: (
data: Extract<Models['OkModelingCmdResponse_type'], { type: T }>
) => void
}
export class EngineCommandManager {
artifactMap: ArtifactMap = {}
sourceRangeMap: SourceRangeMap = {}
@ -570,17 +331,10 @@ export class EngineCommandManager {
engineConnection?: EngineConnection
waitForReady: Promise<void> = new Promise(() => {})
private resolveReady = () => {}
subscriptions: {
[event: string]: {
[localUnsubscribeId: string]: (a: any) => void
}
} = {} as any
unreliableSubscriptions: {
[event: string]: {
[localUnsubscribeId: string]: (a: any) => void
}
} = {} as any
onHoverCallback: (id?: string) => void = () => {}
onClickCallback: (selection?: SelectionsArgs) => void = () => {}
onCursorsSelectedCallback: (selections: CursorSelectionsArgs) => void =
() => {}
constructor({
setMediaStream,
setIsStreamReady,
@ -601,43 +355,48 @@ export class EngineCommandManager {
this.engineConnection = new EngineConnection({
url,
token,
onEngineConnectionOpen: () => {
})
this.engineConnection.addEventListener(
EngineConnectionEvents.Open,
(event) => {
this.resolveReady()
setIsStreamReady(true)
},
onClose: () => {
setIsStreamReady(false)
},
onConnectionStarted: (engineConnection) => {
engineConnection?.pc?.addEventListener('datachannel', (event) => {
let unreliableDataChannel = event.channel
}
)
unreliableDataChannel.addEventListener('message', (event) => {
const result: UnreliableResponses = JSON.parse(event.data)
Object.values(
this.unreliableSubscriptions[result.type] || {}
).forEach(
// TODO: There is only one response that uses the unreliable channel atm,
// highlight_set_entity, if there are more it's likely they will all have the same
// sequence logic, but I'm not sure if we use a single global sequence or a sequence
// per unreliable subscription.
(callback) => {
if (
result?.data?.sequence &&
result?.data.sequence > this.inSequence &&
result.type === 'highlight_set_entity'
) {
this.inSequence = result.data.sequence
callback(result)
}
}
)
this.engineConnection.addEventListener(
EngineConnectionEvents.Close,
(event) => {
setIsStreamReady(false)
}
)
this.engineConnection.addEventListener(
EngineConnectionEvents.ConnectionStarted,
(event: Event) => {
let customEvent = <CustomEvent<EngineConnection>>event
let conn = customEvent.detail
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
}
})
})
// When the EngineConnection starts a connection, we want to register
// callbacks into the WebSocket/PeerConnection.
engineConnection.websocket?.addEventListener('message', (event) => {
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
@ -645,58 +404,65 @@ export class EngineCommandManager {
// Pass this to our export function.
exportSave(event.data)
} else {
const message: Models['WebSocketResponse_type'] = JSON.parse(
event.data
)
if (
message.success &&
message.resp.type === 'modeling' &&
message.request_id
) {
this.handleModelingCommand(message.resp, message.request_id)
if (event.data.toLocaleLowerCase().startsWith('error')) {
// Errors are not JSON encoded; if we have an error we can bail
// here; debugging the error to the console happens in the core
// engine code.
return
}
const message: WebSocketResponse = JSON.parse(event.data)
if (message.type === 'modeling') {
this.handleModelingCommand(message)
}
}
})
},
onNewTrack: ({ mediaStream }) => {
}
)
this.engineConnection.addEventListener(
EngineConnectionEvents.NewTrack,
(event: Event) => {
let customEvent = <CustomEvent<NewTrackArgs>>event
let mediaStream = customEvent.detail.mediaStream
console.log('received track', mediaStream)
mediaStream.getVideoTracks()[0].addEventListener('mute', () => {
console.log('peer is not sending video to us')
// this.engineConnection?.close()
// this.engineConnection?.connect()
})
setMediaStream(mediaStream)
},
})
}
)
this.engineConnection?.connect()
}
handleModelingCommand(message: WebSocketResponse, id: string) {
handleModelingCommand(message: WebSocketResponse) {
if (message.type !== 'modeling') {
return
}
const modelingResponse = message.data.modeling_response
Object.values(this.subscriptions[modelingResponse.type] || {}).forEach(
(callback) => callback(modelingResponse)
)
const id = message.cmd_id
const command = this.artifactMap[id]
if ('ok' in message.result) {
const result: OkResponse = message.result.ok
if (result.type === 'select_with_point') {
if (result.entity_id) {
this.onClickCallback({
id: result.entity_id,
type: 'default',
})
} else {
this.onClickCallback()
}
}
}
if (command && command.type === 'pending') {
const resolve = command.resolve
this.artifactMap[id] = {
type: 'result',
data: modelingResponse,
data: message.result,
}
resolve({
id,
data: modelingResponse,
})
} else {
this.artifactMap[id] = {
type: 'result',
data: modelingResponse,
data: message.result,
}
}
}
@ -707,49 +473,21 @@ export class EngineCommandManager {
this.artifactMap = {}
this.sourceRangeMap = {}
}
subscribeTo<T extends ModelTypes>({
event,
callback,
}: Subscription<T>): () => void {
const localUnsubscribeId = uuidv4()
const otherEventCallbacks = this.subscriptions[event]
if (otherEventCallbacks) {
otherEventCallbacks[localUnsubscribeId] = callback
} else {
this.subscriptions[event] = {
[localUnsubscribeId]: callback,
}
}
return () => this.unSubscribeTo(event, localUnsubscribeId)
}
private unSubscribeTo(event: ModelTypes, id: string) {
delete this.subscriptions[event][id]
}
subscribeToUnreliable<T extends UnreliableResponses['type']>({
event,
callback,
}: UnreliableSubscription<T>): () => void {
const localUnsubscribeId = uuidv4()
const otherEventCallbacks = this.unreliableSubscriptions[event]
if (otherEventCallbacks) {
otherEventCallbacks[localUnsubscribeId] = callback
} else {
this.unreliableSubscriptions[event] = {
[localUnsubscribeId]: callback,
}
}
return () => this.unSubscribeToUnreliable(event, localUnsubscribeId)
}
private unSubscribeToUnreliable(
event: UnreliableResponses['type'],
id: string
) {
delete this.unreliableSubscriptions[event][id]
}
endSession() {
// this.websocket?.close()
// socket.off('command')
}
onHover(callback: (id?: string) => void) {
// It's when the user hovers over a part in the 3d scene, and so the engine should tell the
// frontend about that (with it's id) so that the FE can highlight code associated with that id
this.onHoverCallback = callback
}
onClick(callback: (selection?: SelectionsArgs) => void) {
// It's when the user clicks on a part in the 3d scene, and so the engine should tell the
// frontend about that (with it's id) so that the FE can put the user's cursor on the right
// line of code
this.onClickCallback = callback
}
cusorsSelected(selections: {
otherSelections: Selections['otherSelections']
idBasedSelections: { type: string; id: string }[]
@ -774,58 +512,51 @@ export class EngineCommandManager {
cmd_id: uuidv4(),
})
}
sendSceneCommand(command: EngineCommand): Promise<any> {
sendSceneCommand(command: EngineCommand) {
if (!this.engineConnection?.isReady()) {
console.log('socket not ready')
return Promise.resolve()
return
}
if (command.type !== 'modeling_cmd_req') return Promise.resolve()
if (command.type !== 'modeling_cmd_req') return
const cmd = command.cmd
if (
cmd.type === 'camera_drag_move' &&
this.engineConnection?.unreliableDataChannel
this.engineConnection?.lossyDataChannel
) {
cmd.sequence = this.outSequence
this.outSequence++
this.engineConnection?.unreliableDataChannel?.send(
JSON.stringify(command)
)
return Promise.resolve()
this.engineConnection?.lossyDataChannel?.send(JSON.stringify(command))
return
} else if (
cmd.type === 'highlight_set_entity' &&
this.engineConnection?.unreliableDataChannel
this.engineConnection?.lossyDataChannel
) {
cmd.sequence = this.outSequence
this.outSequence++
this.engineConnection?.unreliableDataChannel?.send(
JSON.stringify(command)
)
return Promise.resolve()
this.engineConnection?.lossyDataChannel?.send(JSON.stringify(command))
return
}
console.log('sending command', command)
// since it's not mouse drag or highlighting send over TCP and keep track of the command
this.engineConnection?.send(command)
return this.handlePendingCommand(command.cmd_id)
}
sendModelingCommand({
sendModellingCommand({
id,
params,
range,
command,
}: {
id: string
params: any
range: SourceRange
command: EngineCommand | string
command: EngineCommand
}): Promise<any> {
this.sourceRangeMap[id] = range
if (!this.engineConnection?.isReady()) {
console.log('socket not ready')
return Promise.resolve()
return new Promise(() => {})
}
this.engineConnection?.send(command)
return this.handlePendingCommand(id)
}
handlePendingCommand(id: string) {
let resolve: (val: any) => void = () => {}
const promise = new Promise((_resolve, reject) => {
resolve = _resolve
@ -837,24 +568,6 @@ export class EngineCommandManager {
}
return promise
}
sendModelingCommandFromWasm(
id: string,
rangeStr: string,
commandStr: string
): Promise<any> {
if (id === undefined) {
throw new Error('id is undefined')
}
if (rangeStr === undefined) {
throw new Error('rangeStr is undefined')
}
if (commandStr === undefined) {
throw new Error('commandStr is undefined')
}
const range: SourceRange = JSON.parse(rangeStr)
return this.sendModelingCommand({ id, range, command: commandStr })
}
commandResult(id: string): Promise<any> {
const command = this.artifactMap[id]
if (!command) {

88
src/lang/std/extrude.ts Normal file
View File

@ -0,0 +1,88 @@
import { InternalFn } from './stdTypes'
import {
ExtrudeGroup,
ExtrudeSurface,
SketchGroup,
Position,
Rotation,
} from '../executor'
import { clockwiseSign } from './std'
import { generateUuidFromHashSeed } from '../../lib/uuid'
export const extrude: InternalFn = (
{ sourceRange, engineCommandManager, code },
length: number,
sketchVal: SketchGroup
): ExtrudeGroup => {
const sketch = sketchVal
const { position, rotation } = sketchVal
const id = generateUuidFromHashSeed(
JSON.stringify({
code,
sourceRange,
data: {
length,
sketchVal,
},
})
)
const extrudeSurfaces: ExtrudeSurface[] = []
const extrusionDirection = clockwiseSign(sketch.value.map((line) => line.to))
engineCommandManager.sendModellingCommand({
id,
params: [
{
length,
extrusionDirection: extrusionDirection,
},
],
range: sourceRange,
command: {
type: 'modeling_cmd_req',
cmd: {
type: 'extrude',
target: sketch.id,
distance: length,
cap: true,
},
cmd_id: id,
},
})
return {
type: 'extrudeGroup',
id,
value: extrudeSurfaces, // TODO, this is just an empty array now, should be deleted.
height: length,
position,
rotation,
__meta: [
{
sourceRange,
pathToNode: [], // TODO
},
{
sourceRange: sketchVal.__meta[0].sourceRange,
pathToNode: sketchVal.__meta[0].pathToNode,
},
],
}
}
export const getExtrudeWallTransform: InternalFn = (
_,
pathName: string,
extrudeGroup: ExtrudeGroup
): {
position: Position
quaternion: Rotation
} => {
const path = extrudeGroup?.value.find((path) => path.name === pathName)
if (!path) throw new Error(`Could not find path with name ${pathName}`)
return {
position: path.position,
quaternion: path.rotation,
}
}

View File

@ -97,10 +97,11 @@ describe('testing changeSketchArguments', () => {
const lineAfterChange = 'lineTo([2, 3], %)'
test('changeSketchArguments', async () => {
// Enable rotations #152
const genCode = (line: string) => `const mySketch001 = startSketchAt([0, 0])
|> ${line}
|> lineTo([0.46, -5.82], %)
// |> rx(45, %)
const genCode = (line: string) => `
const mySketch001 = startSketchAt([0, 0])
|> ${line}
|> lineTo([0.46, -5.82], %)
// |> rx(45, %)
show(mySketch001)`
const code = genCode(lineToChange)
const expectedCode = genCode(lineAfterChange)
@ -159,7 +160,8 @@ show(mySketch001)`
],
})
// Enable rotations #152
const expectedCode = `const mySketch001 = startSketchAt([0, 0])
const expectedCode = `
const mySketch001 = startSketchAt([0, 0])
// |> rx(45, %)
|> lineTo([-1.59, -1.54], %)
|> lineTo([0.46, -5.82], %)
@ -173,11 +175,12 @@ describe('testing addTagForSketchOnFace', () => {
it('needs to be in it', async () => {
const originalLine = 'lineTo([-1.59, -1.54], %)'
// Enable rotations #152
const genCode = (line: string) => `const mySketch001 = startSketchAt([0, 0])
// |> rx(45, %)
|> ${line}
|> lineTo([0.46, -5.82], %)
show(mySketch001)`
const genCode = (line: string) => `
const mySketch001 = startSketchAt([0, 0])
// |> rx(45, %)
|> ${line}
|> lineTo([0.46, -5.82], %)
show(mySketch001)`
const code = genCode(originalLine)
const ast = parser_wasm(code)
const programMemory = await enginelessExecutor(ast)

View File

@ -23,7 +23,12 @@ import { GuiModes, toolTips, TooTip } from '../../useStore'
import { splitPathAtPipeExpression } from '../modifyAst'
import { generateUuidFromHashSeed } from '../../lib/uuid'
import { SketchLineHelper, ModifyAstBase, TransformCallback } from './stdTypes'
import {
SketchLineHelper,
ModifyAstBase,
InternalFn,
TransformCallback,
} from './stdTypes'
import {
createLiteral,
@ -37,7 +42,10 @@ import {
} from '../modifyAst'
import { roundOff, getLength, getAngle } from '../../lib/utils'
import { getSketchSegmentFromSourceRange } from './sketchConstraints'
import { perpendicularDistance } from 'sketch-helpers'
import {
intersectionWithParallelLine,
perpendicularDistance,
} from 'sketch-helpers'
export type Coords2d = [number, number]
@ -107,6 +115,45 @@ function makeId(seed: string | any) {
}
export const lineTo: SketchLineHelper = {
fn: (
{ sourceRange, code },
data:
| [number, number]
| {
to: [number, number]
tag?: string
},
previousSketch: SketchGroup
): SketchGroup => {
if (!previousSketch)
throw new Error('lineTo must be called after startSketchAt')
const sketchGroup = { ...previousSketch }
const from = getCoordsFromPaths(sketchGroup, sketchGroup.value.length - 1)
const to = 'to' in data ? data.to : data
const id = makeId({
code,
sourceRange,
data,
})
const currentPath: Path = {
type: 'toPoint',
to,
from,
__geoMeta: {
sourceRange,
id,
pathToNode: [], // TODO
},
}
if ('tag' in data) {
currentPath.name = data.tag
}
return {
...sketchGroup,
value: [...sketchGroup.value, currentPath],
}
},
add: ({
node,
pathToNode,
@ -174,6 +221,77 @@ export const lineTo: SketchLineHelper = {
}
export const line: SketchLineHelper = {
fn: (
{ sourceRange, engineCommandManager, code },
data:
| [number, number]
| 'default'
| {
to: [number, number] | 'default'
// name?: string
tag?: string
},
previousSketch: SketchGroup
): SketchGroup => {
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
const sketchGroup = { ...previousSketch }
const from = getCoordsFromPaths(sketchGroup, sketchGroup.value.length - 1)
let args: [number, number] = [0.2, 1]
if (data !== 'default' && 'to' in data && data.to !== 'default') {
args = data.to
} else if (data !== 'default' && !('to' in data)) {
args = data
}
const to: [number, number] = [from[0] + args[0], from[1] + args[1]]
const lineData: LineData = {
from: [...from, 0],
to: [...to, 0],
}
const id = makeId({
code,
sourceRange,
data,
})
engineCommandManager.sendModellingCommand({
id,
params: [lineData, previousSketch],
range: sourceRange,
command: {
type: 'modeling_cmd_req',
cmd: {
type: 'extend_path',
path: sketchGroup.id,
segment: {
type: 'line',
end: {
x: lineData.to[0],
y: lineData.to[1],
z: 0,
},
},
},
cmd_id: id,
},
})
const currentPath: Path = {
type: 'toPoint',
to,
from,
__geoMeta: {
id,
sourceRange,
pathToNode: [], // TODO
},
}
if (data !== 'default' && 'tag' in data) {
currentPath.name = data.tag
}
return {
...sketchGroup,
value: [...sketchGroup.value, currentPath],
}
},
add: ({
node,
previousProgramMemory,
@ -267,6 +385,25 @@ export const line: SketchLineHelper = {
}
export const xLineTo: SketchLineHelper = {
fn: (
meta,
data:
| number
| {
to: number
// name?: string
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('bad bad bad')
const from = getCoordsFromPaths(
previousSketch,
previousSketch.value.length - 1
)
const [xVal, tag] = typeof data !== 'number' ? [data.to, data.tag] : [data]
return lineTo.fn(meta, { to: [xVal, from[1]], tag }, previousSketch)
},
add: ({ node, pathToNode, to, replaceExisting, createCallback }) => {
const _node = { ...node }
const getNode = getNodeFromPathCurry(_node, pathToNode)
@ -315,6 +452,25 @@ export const xLineTo: SketchLineHelper = {
}
export const yLineTo: SketchLineHelper = {
fn: (
meta,
data:
| number
| {
to: number
// name?: string
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('bad bad bad')
const from = getCoordsFromPaths(
previousSketch,
previousSketch.value.length - 1
)
const [yVal, tag] = typeof data !== 'number' ? [data.to, data.tag] : [data]
return lineTo.fn(meta, { to: [from[0], yVal], tag }, previousSketch)
},
add: ({ node, pathToNode, to, replaceExisting, createCallback }) => {
const _node = { ...node }
const getNode = getNodeFromPathCurry(_node, pathToNode)
@ -363,6 +519,21 @@ export const yLineTo: SketchLineHelper = {
}
export const xLine: SketchLineHelper = {
fn: (
meta,
data:
| number
| {
length: number
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('bad bad bad')
const [xVal, tag] =
typeof data !== 'number' ? [data.length, data.tag] : [data]
return line.fn(meta, { to: [xVal, 0], tag }, previousSketch)
},
add: ({ node, pathToNode, to, from, replaceExisting, createCallback }) => {
const _node = { ...node }
const getNode = getNodeFromPathCurry(_node, pathToNode)
@ -413,6 +584,22 @@ export const xLine: SketchLineHelper = {
}
export const yLine: SketchLineHelper = {
fn: (
meta,
data:
| number
| {
length: number
// name?: string
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('bad bad bad')
const [yVal, tag] =
typeof data !== 'number' ? [data.length, data.tag] : [data]
return line.fn(meta, { to: [0, yVal], tag }, previousSketch)
},
add: ({ node, pathToNode, to, from, replaceExisting, createCallback }) => {
const _node = { ...node }
const getNode = getNodeFromPathCurry(_node, pathToNode)
@ -457,6 +644,48 @@ export const yLine: SketchLineHelper = {
}
export const angledLine: SketchLineHelper = {
fn: (
{ sourceRange, engineCommandManager, code },
data:
| [number, number]
| {
angle: number
length: number
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
const sketchGroup = { ...previousSketch }
const from = getCoordsFromPaths(sketchGroup, sketchGroup.value.length - 1)
const [angle, length] = 'angle' in data ? [data.angle, data.length] : data
const to: [number, number] = [
from[0] + length * Math.cos((angle * Math.PI) / 180),
from[1] + length * Math.sin((angle * Math.PI) / 180),
]
const id = makeId({
code,
sourceRange,
data,
})
const currentPath: Path = {
type: 'toPoint',
to,
from,
__geoMeta: {
id,
sourceRange,
pathToNode: [], // TODO
},
}
if ('tag' in data) {
currentPath.name = data.tag
}
return {
...sketchGroup,
value: [...sketchGroup.value, currentPath],
}
},
add: ({
node,
pathToNode,
@ -524,6 +753,26 @@ export const angledLine: SketchLineHelper = {
}
export const angledLineOfXLength: SketchLineHelper = {
fn: (
{ sourceRange, programMemory, engineCommandManager, code },
data:
| [number, number]
| {
angle: number
length: number
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
const [angle, length, tag] =
'angle' in data ? [data.angle, data.length, data.tag] : data
return line.fn(
{ sourceRange, programMemory, engineCommandManager, code },
{ to: getYComponent(angle, length), tag },
previousSketch
)
},
add: ({
node,
previousProgramMemory,
@ -597,6 +846,26 @@ export const angledLineOfXLength: SketchLineHelper = {
}
export const angledLineOfYLength: SketchLineHelper = {
fn: (
{ sourceRange, programMemory, engineCommandManager, code },
data:
| [number, number]
| {
angle: number
length: number
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
const [angle, length, tag] =
'angle' in data ? [data.angle, data.length, data.tag] : data
return line.fn(
{ sourceRange, programMemory, engineCommandManager, code },
{ to: getXComponent(angle, length), tag },
previousSketch
)
},
add: ({
node,
previousProgramMemory,
@ -671,6 +940,33 @@ export const angledLineOfYLength: SketchLineHelper = {
}
export const angledLineToX: SketchLineHelper = {
fn: (
{ sourceRange, programMemory, engineCommandManager, code },
data:
| [number, number]
| {
angle: number
to: number
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
const from = getCoordsFromPaths(
previousSketch,
previousSketch.value.length - 1
)
const [angle, xTo, tag] =
'angle' in data ? [data.angle, data.to, data.tag] : data
const xComponent = xTo - from[0]
const yComponent = xComponent * Math.tan((angle * Math.PI) / 180)
const yTo = from[1] + yComponent
return lineTo.fn(
{ sourceRange, programMemory, engineCommandManager, code },
{ to: [xTo, yTo], tag },
previousSketch
)
},
add: ({
node,
pathToNode,
@ -740,6 +1036,33 @@ export const angledLineToX: SketchLineHelper = {
}
export const angledLineToY: SketchLineHelper = {
fn: (
{ sourceRange, programMemory, engineCommandManager, code },
data:
| [number, number]
| {
angle: number
to: number
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
const from = getCoordsFromPaths(
previousSketch,
previousSketch.value.length - 1
)
const [angle, yTo, tag] =
'angle' in data ? [data.angle, data.to, data.tag] : data
const yComponent = yTo - from[1]
const xComponent = yComponent / Math.tan((angle * Math.PI) / 180)
const xTo = from[0] + xComponent
return lineTo.fn(
{ sourceRange, programMemory, engineCommandManager, code },
{ to: [xTo, yTo], tag },
previousSketch
)
},
add: ({
node,
pathToNode,
@ -810,6 +1133,37 @@ export const angledLineToY: SketchLineHelper = {
}
export const angledLineThatIntersects: SketchLineHelper = {
fn: (
{ sourceRange, programMemory, engineCommandManager, code },
data: {
angle: number
intersectTag: string
offset?: number
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
const intersectPath = previousSketch.value.find(
({ name }) => name === data.intersectTag
)
if (!intersectPath) throw new Error('intersectTag must match a line')
const from = getCoordsFromPaths(
previousSketch,
previousSketch.value.length - 1
)
const to = intersectionWithParallelLine({
line1: [intersectPath.from, intersectPath.to],
line1Offset: data.offset || 0,
line2Point: from,
line2Angle: data.angle,
})
return lineTo.fn(
{ sourceRange, programMemory, engineCommandManager, code },
{ to, tag: data.tag },
previousSketch
)
},
add: ({
node,
pathToNode,
@ -1172,6 +1526,142 @@ function addTagWithTo(
}
}
export const close: InternalFn = (
{ sourceRange, engineCommandManager, code },
sketchGroup: SketchGroup
): SketchGroup => {
const from = getCoordsFromPaths(sketchGroup, sketchGroup.value.length - 1)
const to = sketchGroup.start
? sketchGroup.start.from
: getCoordsFromPaths(sketchGroup, 0)
const lineData: LineData = {
from: [...from, 0],
to: [...to, 0],
}
const id = makeId({
code,
sourceRange,
data: sketchGroup,
})
engineCommandManager.sendModellingCommand({
id,
params: [lineData],
range: sourceRange,
command: {
type: 'modeling_cmd_req',
cmd: {
type: 'close_path',
path_id: sketchGroup.id,
},
cmd_id: id,
},
})
const currentPath: Path = {
type: 'toPoint',
to,
from,
__geoMeta: {
id,
sourceRange,
pathToNode: [], // TODO
},
}
const newValue = [...sketchGroup.value]
newValue.push(currentPath)
return {
...sketchGroup,
value: newValue,
}
}
export const startSketchAt: InternalFn = (
{ sourceRange, programMemory, engineCommandManager, code },
data:
| [number, number]
| 'default'
| {
to: [number, number] | 'default'
// name?: string
tag?: string
}
): SketchGroup => {
let to: [number, number] = [0, 0]
if (data !== 'default' && 'to' in data && data.to !== 'default') {
to = data.to
} else if (data !== 'default' && !('to' in data)) {
to = data
}
const lineData: { to: [number, number, number] } = {
to: [...to, 0],
}
const id = makeId({
code,
sourceRange,
data,
})
const pathId = makeId({
code,
sourceRange,
data,
isPath: true,
})
engineCommandManager.sendModellingCommand({
id: pathId,
params: [lineData],
range: sourceRange,
command: {
type: 'modeling_cmd_req',
cmd: {
type: 'start_path',
},
cmd_id: pathId,
},
})
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'move_path_pen',
path: pathId,
to: {
x: lineData.to[0],
y: lineData.to[1],
z: 0,
},
},
cmd_id: id,
})
const currentPath: Path = {
type: 'base',
to,
from: to,
__geoMeta: {
id,
sourceRange,
pathToNode: [], // TODO
},
}
if (data !== 'default' && 'tag' in data) {
currentPath.name = data.tag
}
return {
type: 'sketchGroup',
start: currentPath,
value: [],
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
id: pathId,
__meta: [
{
sourceRange,
pathToNode: [], // TODO
},
],
}
}
export function getYComponent(
angleDegree: number,
xComponent: number

View File

@ -59,20 +59,20 @@ describe('testing swaping out sketch calls with xLine/xLineTo', () => {
` |> lineTo({ to: [1, 1], tag: 'abc1' }, %)`,
` |> line({ to: [-2.04, -0.7], tag: 'abc2' }, %)`,
` |> angledLine({`,
` angle: 157,`,
` length: 1.69,`,
` tag: 'abc3'`,
` }, %)`,
` angle: 157,`,
` length: 1.69,`,
` tag: 'abc3'`,
` }, %)`,
` |> angledLineOfXLength({`,
` angle: 217,`,
` length: 0.86,`,
` tag: 'abc4'`,
` }, %)`,
` angle: 217,`,
` length: 0.86,`,
` tag: 'abc4'`,
` }, %)`,
` |> angledLineOfYLength({`,
` angle: 104,`,
` length: 1.58,`,
` tag: 'abc5'`,
` }, %)`,
` angle: 104,`,
` length: 1.58,`,
` tag: 'abc5'`,
` }, %)`,
` |> angledLineToX({ angle: 55, to: -2.89, tag: 'abc6' }, %)`,
` |> angledLineToY({ angle: 330, to: 2.53, tag: 'abc7' }, %)`,
` |> xLine({ length: 1.47, tag: 'abc8' }, %)`,
@ -144,10 +144,10 @@ describe('testing swaping out sketch calls with xLine/xLineTo', () => {
inputCode: bigExample,
callToSwap: [
`angledLine({`,
` angle: 157,`,
` length: 1.69,`,
` tag: 'abc3'`,
` }, %)`,
` angle: 157,`,
` length: 1.69,`,
` tag: 'abc3'`,
` }, %)`,
].join('\n'),
constraintType: 'horizontal',
})
@ -172,10 +172,10 @@ describe('testing swaping out sketch calls with xLine/xLineTo', () => {
inputCode: bigExample,
callToSwap: [
`angledLineOfXLength({`,
` angle: 217,`,
` length: 0.86,`,
` tag: 'abc4'`,
` }, %)`,
` angle: 217,`,
` length: 0.86,`,
` tag: 'abc4'`,
` }, %)`,
].join('\n'),
constraintType: 'horizontal',
})
@ -201,10 +201,10 @@ describe('testing swaping out sketch calls with xLine/xLineTo', () => {
inputCode: bigExample,
callToSwap: [
`angledLineOfYLength({`,
` angle: 104,`,
` length: 1.58,`,
` tag: 'abc5'`,
` }, %)`,
` angle: 104,`,
` length: 1.58,`,
` tag: 'abc5'`,
` }, %)`,
].join('\n'),
constraintType: 'vertical',
})
@ -391,7 +391,6 @@ show(part001)`
type: 'toPoint',
to: [5.62, 1.79],
from: [3.48, 0.44],
name: '',
})
})
it('verify it works when the segment is in the `start` property', async () => {
@ -401,6 +400,6 @@ show(part001)`
programMemory.root['part001'] as SketchGroup,
[index, index]
).segment
expect(segment).toEqual({ to: [0, 0.04], from: [0, 0.04], name: '' })
expect(segment).toEqual({ type: 'base', to: [0, 0.04], from: [0, 0.04] })
})
})

View File

@ -1,3 +1,4 @@
import { getAngle } from '../../lib/utils'
import { TooTip, toolTips } from '../../useStore'
import {
Program,
@ -5,6 +6,7 @@ import {
CallExpression,
} from '../abstractSyntaxTreeTypes'
import { SketchGroup, SourceRange } from '../executor'
import { InternalFn } from './stdTypes'
export function getSketchSegmentFromSourceRange(
sketchGroup: SketchGroup,
@ -34,6 +36,79 @@ export function getSketchSegmentFromSourceRange(
}
}
export const segLen: InternalFn = (
_,
segName: string,
sketchGroup: SketchGroup
): number => {
const line = sketchGroup?.value.find((seg) => seg?.name === segName)
// maybe this should throw, but the language doesn't have a way to handle errors yet
if (!line) return 0
return Math.sqrt(
(line.from[1] - line.to[1]) ** 2 + (line.from[0] - line.to[0]) ** 2
)
}
export const segAng: InternalFn = (
_,
segName: string,
sketchGroup: SketchGroup
): number => {
const line = sketchGroup?.value.find((seg) => seg.name === segName)
// maybe this should throw, but the language doesn't have a way to handle errors yet
if (!line) return 0
return getAngle(line.from, line.to)
}
function segEndFactory(which: 'x' | 'y'): InternalFn {
return (_, segName: string, sketchGroup: SketchGroup): number => {
const line =
sketchGroup?.start?.name === segName
? sketchGroup?.start
: sketchGroup?.value.find((seg) => seg.name === segName)
// maybe this should throw, but the language doesn't have a way to handle errors yet
if (!line) return 0
return which === 'x' ? line.to[0] : line.to[1]
}
}
export const segEndX: InternalFn = segEndFactory('x')
export const segEndY: InternalFn = segEndFactory('y')
function lastSegFactory(which: 'x' | 'y'): InternalFn {
return (_, sketchGroup: SketchGroup): number => {
const lastLine = sketchGroup?.value[sketchGroup.value.length - 1]
return which === 'x' ? lastLine.to[0] : lastLine.to[1]
}
}
export const lastSegX: InternalFn = lastSegFactory('x')
export const lastSegY: InternalFn = lastSegFactory('y')
function angleToMatchLengthFactory(which: 'x' | 'y'): InternalFn {
return (_, segName: string, to: number, sketchGroup: SketchGroup): number => {
const isX = which === 'x'
const lineToMatch = sketchGroup?.value.find((seg) => seg.name === segName)
// maybe this should throw, but the language doesn't have a way to handle errors yet
if (!lineToMatch) return 0
const lengthToMatch = Math.sqrt(
(lineToMatch.from[1] - lineToMatch.to[1]) ** 2 +
(lineToMatch.from[0] - lineToMatch.to[0]) ** 2
)
const lastLine = sketchGroup?.value[sketchGroup.value.length - 1]
const diff = Math.abs(to - (isX ? lastLine.to[0] : lastLine.to[1]))
const angleR = Math[isX ? 'acos' : 'asin'](diff / lengthToMatch)
return diff > lengthToMatch ? 0 : (angleR * 180) / Math.PI
}
}
export const angleToMatchLengthX: InternalFn = angleToMatchLengthFactory('x')
export const angleToMatchLengthY: InternalFn = angleToMatchLengthFactory('y')
export function isSketchVariablesLinked(
secondaryVarDec: VariableDeclarator,
primaryVarDec: VariableDeclarator,

View File

@ -133,63 +133,63 @@ const myAng2 = 134
const part001 = startSketchAt([0, 0])
|> line({ to: [1, 3.82], tag: 'seg01' }, %) // ln-should-get-tag
|> angledLineToX([
-angleToMatchLengthX('seg01', myVar, %),
myVar
-angleToMatchLengthX('seg01', myVar, %),
myVar
], %) // ln-lineTo-xAbsolute should use angleToMatchLengthX helper
|> angledLineToY([
-angleToMatchLengthY('seg01', myVar, %),
myVar
-angleToMatchLengthY('seg01', myVar, %),
myVar
], %) // ln-lineTo-yAbsolute should use angleToMatchLengthY helper
|> angledLine([45, segLen('seg01', %)], %) // ln-lineTo-free should become angledLine
|> angledLine([45, segLen('seg01', %)], %) // ln-angledLineToX-free should become angledLine
|> angledLine([myAng, segLen('seg01', %)], %) // ln-angledLineToX-angle should become angledLine
|> angledLineToX([
angleToMatchLengthX('seg01', myVar2, %),
myVar2
angleToMatchLengthX('seg01', myVar2, %),
myVar2
], %) // ln-angledLineToX-xAbsolute should use angleToMatchLengthX to get angle
|> angledLine([-45, segLen('seg01', %)], %) // ln-angledLineToY-free should become angledLine
|> angledLine([myAng2, segLen('seg01', %)], %) // ln-angledLineToY-angle should become angledLine
|> angledLineToY([
angleToMatchLengthY('seg01', myVar3, %),
myVar3
angleToMatchLengthY('seg01', myVar3, %),
myVar3
], %) // ln-angledLineToY-yAbsolute should use angleToMatchLengthY to get angle
|> line([
min(segLen('seg01', %), myVar),
legLen(segLen('seg01', %), myVar)
min(segLen('seg01', %), myVar),
legLen(segLen('seg01', %), myVar)
], %) // ln-should use legLen for y
|> line([
min(segLen('seg01', %), myVar),
-legLen(segLen('seg01', %), myVar)
min(segLen('seg01', %), myVar),
-legLen(segLen('seg01', %), myVar)
], %) // ln-legLen but negative
|> angledLine([-112, segLen('seg01', %)], %) // ln-should become angledLine
|> angledLine([myVar, segLen('seg01', %)], %) // ln-use segLen for secound arg
|> angledLine([45, segLen('seg01', %)], %) // ln-segLen again
|> angledLine([54, segLen('seg01', %)], %) // ln-should be transformed to angledLine
|> angledLineOfXLength([
legAngX(segLen('seg01', %), myVar),
min(segLen('seg01', %), myVar)
legAngX(segLen('seg01', %), myVar),
min(segLen('seg01', %), myVar)
], %) // ln-should use legAngX to calculate angle
|> angledLineOfXLength([
180 + legAngX(segLen('seg01', %), myVar),
min(segLen('seg01', %), myVar)
180 + legAngX(segLen('seg01', %), myVar),
min(segLen('seg01', %), myVar)
], %) // ln-same as above but should have + 180 to match original quadrant
|> line([
legLen(segLen('seg01', %), myVar),
min(segLen('seg01', %), myVar)
legLen(segLen('seg01', %), myVar),
min(segLen('seg01', %), myVar)
], %) // ln-legLen again but yRelative
|> line([
-legLen(segLen('seg01', %), myVar),
min(segLen('seg01', %), myVar)
-legLen(segLen('seg01', %), myVar),
min(segLen('seg01', %), myVar)
], %) // ln-negative legLen yRelative
|> angledLine([58, segLen('seg01', %)], %) // ln-angledLineOfYLength-free should become angledLine
|> angledLine([myAng, segLen('seg01', %)], %) // ln-angledLineOfYLength-angle should become angledLine
|> angledLineOfXLength([
legAngY(segLen('seg01', %), myVar),
min(segLen('seg01', %), myVar)
legAngY(segLen('seg01', %), myVar),
min(segLen('seg01', %), myVar)
], %) // ln-angledLineOfYLength-yRelative use legAngY
|> angledLineOfXLength([
270 + legAngY(segLen('seg01', %), myVar),
min(segLen('seg01', %), myVar)
270 + legAngY(segLen('seg01', %), myVar),
min(segLen('seg01', %), myVar)
], %) // ln-angledLineOfYLength-yRelative with angle > 90 use binExp
|> xLine(segLen('seg01', %), %) // ln-xLine-free should sub in segLen
|> yLine(segLen('seg01', %), %) // ln-yLine-free should sub in segLen
@ -406,8 +406,8 @@ show(part001)`
'setVertDistance'
)
expect(expectedCode).toContain(`|> lineTo([
lastSegX(%) + myVar,
segEndY('seg01', %) + 2.93
lastSegX(%) + myVar,
segEndY('seg01', %) + 2.93
], %) // xRelative`)
})
it('testing for yRelative to horizontal distance', async () => {
@ -417,8 +417,8 @@ show(part001)`
'setHorzDistance'
)
expect(expectedCode).toContain(`|> lineTo([
segEndX('seg01', %) + 2.6,
lastSegY(%) + myVar
segEndX('seg01', %) + 2.6,
lastSegY(%) + myVar
], %) // yRelative`)
})
})

View File

@ -1426,7 +1426,6 @@ export function transformAstSketchLines({
selectionRanges.codeBasedSelections.forEach(({ range }, index) => {
const callBack = transformInfos?.[index].createNode
const transformTo = transformInfos?.[index].tooltip
console.log('transformTo', transformInfos)
if (!callBack || !transformTo) throw new Error('no callback helper')
const getNode = getNodeFromPathCurry(

170
src/lang/std/std.ts Normal file
View File

@ -0,0 +1,170 @@
import {
lineTo,
xLineTo,
yLineTo,
line,
xLine,
yLine,
angledLine,
angledLineOfXLength,
angledLineToX,
angledLineOfYLength,
angledLineToY,
close,
startSketchAt,
angledLineThatIntersects,
} from './sketch'
import {
segLen,
segAng,
angleToMatchLengthX,
angleToMatchLengthY,
segEndX,
segEndY,
lastSegX,
lastSegY,
} from './sketchConstraints'
import { getExtrudeWallTransform, extrude } from './extrude'
import { InternalFn, InternalFnNames } from './stdTypes'
// const transform: InternalFn = <T extends SketchGroup | ExtrudeGroup>(
// { sourceRange }: InternalFirstArg,
// transformInfo: {
// position: Position
// quaternion: Rotation
// },
// sketch: T
// ): T => {
// const quaternionToApply = new Quaternion(...transformInfo?.quaternion)
// const newQuaternion = new Quaternion(...sketch.rotation).multiply(
// quaternionToApply.invert()
// )
// const oldPosition = new Vector3(...sketch?.position)
// const newPosition = oldPosition
// .applyQuaternion(quaternionToApply)
// .add(new Vector3(...transformInfo?.position))
// return {
// ...sketch,
// position: newPosition.toArray(),
// rotation: newQuaternion.toArray(),
// __meta: [
// ...sketch.__meta,
// {
// sourceRange,
// pathToNode: [], // TODO
// },
// ],
// }
// }
// const translate: InternalFn = <T extends SketchGroup | ExtrudeGroup>(
// { sourceRange }: InternalFirstArg,
// vec3: [number, number, number],
// sketch: T
// ): T => {
// const oldPosition = new Vector3(...sketch.position)
// const newPosition = oldPosition.add(new Vector3(...vec3))
// return {
// ...sketch,
// position: newPosition.toArray(),
// __meta: [
// ...sketch.__meta,
// {
// sourceRange,
// pathToNode: [], // TODO
// },
// ],
// }
// }
const min: InternalFn = (_, a: number, b: number): number => Math.min(a, b)
const legLen: InternalFn = (_, hypotenuse: number, leg: number): number =>
Math.sqrt(
hypotenuse ** 2 - Math.min(Math.abs(leg), Math.abs(hypotenuse)) ** 2
)
const legAngX: InternalFn = (_, hypotenuse: number, leg: number): number =>
(Math.acos(Math.min(leg, hypotenuse) / hypotenuse) * 180) / Math.PI
const legAngY: InternalFn = (_, hypotenuse: number, leg: number): number =>
(Math.asin(Math.min(leg, hypotenuse) / hypotenuse) * 180) / Math.PI
export const internalFns: { [key in InternalFnNames]: InternalFn } = {
// TODO - re-enable these
// rx: rotateOnAxis([1, 0, 0]), // Enable rotations #152
// ry: rotateOnAxis([0, 1, 0]),
// rz: rotateOnAxis([0, 0, 1]),
extrude,
// translate,
// transform,
getExtrudeWallTransform,
min,
legLen,
legAngX,
legAngY,
segEndX,
segEndY,
lastSegX,
lastSegY,
segLen,
segAng,
angleToMatchLengthX,
angleToMatchLengthY,
lineTo: lineTo.fn,
xLineTo: xLineTo.fn,
yLineTo: yLineTo.fn,
line: line.fn,
xLine: xLine.fn,
yLine: yLine.fn,
angledLine: angledLine.fn,
angledLineOfXLength: angledLineOfXLength.fn,
angledLineToX: angledLineToX.fn,
angledLineOfYLength: angledLineOfYLength.fn,
angledLineToY: angledLineToY.fn,
angledLineThatIntersects: angledLineThatIntersects.fn,
startSketchAt,
close,
}
// function rotateOnAxis<T extends SketchGroup | ExtrudeGroup>(
// axisMultiplier: [number, number, number]
// ): InternalFn {
// return ({ sourceRange }, rotationD: number, sketch: T): T => {
// const rotationR = rotationD * (Math.PI / 180)
// const rotateVec = new Vector3(...axisMultiplier)
// const quaternion = new Quaternion()
// quaternion.setFromAxisAngle(rotateVec, rotationR)
// const position = new Vector3(...sketch.position)
// .applyQuaternion(quaternion)
// .toArray()
// const existingQuat = new Quaternion(...sketch.rotation)
// const rotation = quaternion.multiply(existingQuat).toArray()
// return {
// ...sketch,
// rotation,
// position,
// __meta: [
// ...sketch.__meta,
// {
// sourceRange,
// pathToNode: [], // TODO
// },
// ],
// }
// }
// }
export function clockwiseSign(points: [number, number][]): number {
let sum = 0
for (let i = 0; i < points.length; i++) {
const currentPoint = points[i]
const nextPoint = points[(i + 1) % points.length]
sum += (nextPoint[0] - currentPoint[0]) * (nextPoint[1] + currentPoint[1])
}
return sum >= 0 ? 1 : -1
}

View File

@ -17,6 +17,44 @@ export interface PathReturn {
currentPath: Path
}
export type InternalFn = (internals: InternalFirstArg, ...args: any[]) => any
export type InternalFnNames =
// TODO re-enable these
// | 'translate'
// | 'transform'
// | 'rx' // Enable rotations #152
// | 'ry'
// | 'rz'
| 'extrude'
| 'getExtrudeWallTransform'
| 'min'
| 'legLen'
| 'legAngX'
| 'legAngY'
| 'segEndX'
| 'segEndY'
| 'lastSegX'
| 'lastSegY'
| 'segLen'
| 'segAng'
| 'angleToMatchLengthX'
| 'angleToMatchLengthY'
| 'lineTo'
| 'yLineTo'
| 'xLineTo'
| 'line'
| 'yLine'
| 'xLine'
| 'angledLine'
| 'angledLineOfXLength'
| 'angledLineToX'
| 'angledLineOfYLength'
| 'angledLineToY'
| 'startSketchAt'
| 'close'
| 'angledLineThatIntersects'
export interface ModifyAstBase {
node: Program
previousProgramMemory: ProgramMemory
@ -49,6 +87,7 @@ export type SketchCallTransfromMap = {
}
export interface SketchLineHelper {
fn: InternalFn
add: (a: addCall) => {
modifiedAst: Program
pathToNode: PathToNode

View File

@ -110,7 +110,7 @@ const yi=45`
"brace ')' from 17 to 18",
])
expect(stringSummaryLexer('fn funcName = (param1, param2) => {}')).toEqual([
"keyword 'fn' from 0 to 2",
"word 'fn' from 0 to 2",
"whitespace ' ' from 2 to 3",
"word 'funcName' from 3 to 11",
"whitespace ' ' from 11 to 12",
@ -203,7 +203,7 @@ const yi=45`
it('testing array declaration', () => {
const result = stringSummaryLexer(`const yo = [1, 2]`)
expect(result).toEqual([
"keyword 'const' from 0 to 5",
"word 'const' from 0 to 5",
"whitespace ' ' from 5 to 6",
"word 'yo' from 6 to 8",
"whitespace ' ' from 8 to 9",
@ -220,7 +220,7 @@ const yi=45`
it('testing object declaration', () => {
const result = stringSummaryLexer(`const yo = {key: 'value'}`)
expect(result).toEqual([
"keyword 'const' from 0 to 5",
"word 'const' from 0 to 5",
"whitespace ' ' from 5 to 6",
"word 'yo' from 6 to 8",
"whitespace ' ' from 8 to 9",
@ -241,7 +241,7 @@ const prop2 = yo['key']
const key = 'key'
const prop3 = yo[key]`)
expect(result).toEqual([
"keyword 'const' from 0 to 5",
"word 'const' from 0 to 5",
"whitespace ' ' from 5 to 6",
"word 'yo' from 6 to 8",
"whitespace ' ' from 8 to 9",
@ -254,7 +254,7 @@ const prop3 = yo[key]`)
"string ''value'' from 17 to 24",
"brace '}' from 24 to 25",
"whitespace '\n' from 25 to 26",
"keyword 'const' from 26 to 31",
"word 'const' from 26 to 31",
"whitespace ' ' from 31 to 32",
"word 'prop' from 32 to 36",
"whitespace ' ' from 36 to 37",
@ -264,7 +264,7 @@ const prop3 = yo[key]`)
"period '.' from 41 to 42",
"word 'key' from 42 to 45",
"whitespace '\n' from 45 to 46",
"keyword 'const' from 46 to 51",
"word 'const' from 46 to 51",
"whitespace ' ' from 51 to 52",
"word 'prop2' from 52 to 57",
"whitespace ' ' from 57 to 58",
@ -275,7 +275,7 @@ const prop3 = yo[key]`)
"string ''key'' from 63 to 68",
"brace ']' from 68 to 69",
"whitespace '\n' from 69 to 70",
"keyword 'const' from 70 to 75",
"word 'const' from 70 to 75",
"whitespace ' ' from 75 to 76",
"word 'key' from 76 to 79",
"whitespace ' ' from 79 to 80",
@ -283,7 +283,7 @@ const prop3 = yo[key]`)
"whitespace ' ' from 81 to 82",
"string ''key'' from 82 to 87",
"whitespace '\n' from 87 to 88",
"keyword 'const' from 88 to 93",
"word 'const' from 88 to 93",
"whitespace ' ' from 93 to 94",
"word 'prop3' from 94 to 99",
"whitespace ' ' from 99 to 100",
@ -299,7 +299,7 @@ const prop3 = yo[key]`)
const result = stringSummaryLexer(`const yo = 45 // this is a comment
const yo = 6`)
expect(result).toEqual([
"keyword 'const' from 0 to 5",
"word 'const' from 0 to 5",
"whitespace ' ' from 5 to 6",
"word 'yo' from 6 to 8",
"whitespace ' ' from 8 to 9",
@ -307,9 +307,9 @@ const yo = 6`)
"whitespace ' ' from 10 to 11",
"number '45' from 11 to 13",
"whitespace ' ' from 13 to 14",
"lineComment '// this is a comment' from 14 to 34",
"linecomment '// this is a comment' from 14 to 34",
"whitespace '\n' from 34 to 35",
"keyword 'const' from 35 to 40",
"word 'const' from 35 to 40",
"whitespace ' ' from 40 to 41",
"word 'yo' from 41 to 43",
"whitespace ' ' from 43 to 44",
@ -328,9 +328,9 @@ const yo=45`)
"string ''hi'' from 4 to 8",
"brace ')' from 8 to 9",
"whitespace '\n' from 9 to 10",
"lineComment '// comment on a line by itself' from 10 to 40",
"linecomment '// comment on a line by itself' from 10 to 40",
"whitespace '\n' from 40 to 41",
"keyword 'const' from 41 to 46",
"word 'const' from 41 to 46",
"whitespace ' ' from 46 to 47",
"word 'yo' from 47 to 49",
"operator '=' from 49 to 50",
@ -342,7 +342,7 @@ const yo=45`)
const ya = 6 */
const yi=45`)
expect(result).toEqual([
"keyword 'const' from 0 to 5",
"word 'const' from 0 to 5",
"whitespace ' ' from 5 to 6",
"word 'yo' from 6 to 8",
"whitespace ' ' from 8 to 9",
@ -350,10 +350,10 @@ const yi=45`)
"whitespace ' ' from 10 to 11",
"number '45' from 11 to 13",
"whitespace ' ' from 13 to 14",
`blockComment '/* this is a comment
`blockcomment '/* this is a comment
const ya = 6 */' from 14 to 50`,
"whitespace '\n' from 50 to 51",
"keyword 'const' from 51 to 56",
"word 'const' from 51 to 56",
"whitespace ' ' from 56 to 57",
"word 'yi' from 57 to 59",
"operator '=' from 59 to 60",

View File

@ -1,8 +1,8 @@
import { lexer_js } from '../wasm-lib/pkg/wasm_lib'
import { initPromise } from './rust'
import { Token } from '../wasm-lib/kcl/bindings/Token'
import { Token } from '../wasm-lib/bindings/Token'
export type { Token } from '../wasm-lib/kcl/bindings/Token'
export type { Token } from '../wasm-lib/bindings/Token'
export async function asyncLexer(str: string): Promise<Token[]> {
await initPromise

View File

@ -26,12 +26,3 @@ export function updateCursors(
setCursor(newSelections)
}
}
export function isReducedMotion(): boolean {
return (
typeof window !== 'undefined' &&
window.matchMedia &&
// TODO/Note I (Kurt) think '(prefers-reduced-motion: reduce)' and '(prefers-reduced-motion)' are equivalent, but not 100% sure
window.matchMedia('(prefers-reduced-motion)').matches
)
}

View File

@ -1,24 +1,6 @@
import { createMachine, assign } from 'xstate'
import { Models } from '@kittycad/lib'
import withBaseURL from '../lib/withBaseURL'
import { CommandBarMeta } from '../lib/commands'
const SKIP_AUTH =
import.meta.env.VITE_KC_SKIP_AUTH === 'true' && import.meta.env.DEV
const LOCAL_USER: Models['User_type'] = {
id: '8675309',
name: 'Test User',
email: 'kittycad.sidebar.test@example.com',
image: 'https://placekitten.com/200/200',
created_at: 'yesteryear',
updated_at: 'today',
company: 'Test Company',
discord: 'Test User#1234',
github: 'testuser',
phone: '555-555-5555',
first_name: 'Test',
last_name: 'User',
}
export interface UserContext {
user?: Models['User_type']
@ -27,22 +9,16 @@ export interface UserContext {
export type Events =
| {
type: 'Log out'
type: 'logout'
}
| {
type: 'Log in'
type: 'tryLogin'
token?: string
}
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
export const authCommandBarMeta: CommandBarMeta = {
'Log in': {
hide: 'both',
},
}
export const authMachine = createMachine<UserContext, Events>(
{
id: 'Auth',
@ -74,7 +50,7 @@ export const authMachine = createMachine<UserContext, Events>(
loggedIn: {
entry: ['goToIndexPage'],
on: {
'Log out': {
logout: {
target: 'loggedOut',
},
},
@ -82,10 +58,10 @@ export const authMachine = createMachine<UserContext, Events>(
loggedOut: {
entry: ['goToSignInPage'],
on: {
'Log in': {
tryLogin: {
target: 'checkIfLoggedIn',
actions: assign({
token: (_, event) => {
token: (context, event) => {
const token = event.token || ''
localStorage.setItem(TOKEN_PERSIST_KEY, token)
return token
@ -95,12 +71,10 @@ export const authMachine = createMachine<UserContext, Events>(
},
},
},
schema: { events: {} as { type: 'Log out' } | { type: 'Log in' } },
schema: { events: {} as { type: 'logout' } | { type: 'tryLogin' } },
predictableActionArguments: true,
preserveActionOrder: true,
context: {
token: persistedToken,
},
context: { token: persistedToken },
},
{
actions: {},
@ -117,17 +91,12 @@ async function getUser(context: UserContext) {
}
if (!context.token && '__TAURI__' in window) throw 'not log in'
if (context.token) headers['Authorization'] = `Bearer ${context.token}`
if (SKIP_AUTH) return LOCAL_USER
try {
const response = await fetch(url, {
method: 'GET',
credentials: 'include',
headers,
})
const user = await response.json()
if ('error_code' in user) throw new Error(user.message)
return user
} catch (e) {
console.error(e)
}
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

@ -1,124 +0,0 @@
import { AnyStateMachine, EventFrom, StateFrom } from 'xstate'
import { isTauri } from './isTauri'
type InitialCommandBarMetaArg = {
name: string
type: 'string' | 'select'
description?: string
defaultValue?: string
options: string | Array<{ name: string }>
}
type Platform = 'both' | 'web' | 'desktop'
export type CommandBarMeta = {
[key: string]:
| {
displayValue: (args: string[]) => string
args: InitialCommandBarMetaArg[]
hide?: Platform
}
| {
hide?: Platform
}
}
export type Command = {
owner: string
name: string
callback: Function
meta?: {
displayValue(args: string[]): string | string
args: SubCommand[]
}
}
export type SubCommand = {
name: string
type: 'select' | 'string'
description?: string
options?: Partial<{ name: string }>[]
}
interface CommandBarArgs<T extends AnyStateMachine> {
type: EventFrom<T>['type']
state: StateFrom<T>
commandBarMeta?: CommandBarMeta
send: Function
owner: string
}
export function createMachineCommand<T extends AnyStateMachine>({
type,
state,
commandBarMeta,
send,
owner,
}: CommandBarArgs<T>): Command | null {
const lookedUpMeta = commandBarMeta && commandBarMeta[type]
if (lookedUpMeta && 'hide' in lookedUpMeta) {
const { hide } = lookedUpMeta
if (hide === 'both') return null
else if (hide === 'desktop' && isTauri()) return null
else if (hide === 'web' && !isTauri()) return null
}
let replacedArgs
if (lookedUpMeta && 'args' in lookedUpMeta) {
replacedArgs = lookedUpMeta.args.map((arg) => {
const optionsFromContext = state.context[
arg.options as keyof typeof state.context
] as { name: string }[] | string | undefined
const defaultValueFromContext = state.context[
arg.defaultValue as keyof typeof state.context
] as string | undefined
const options =
arg.options instanceof Array
? arg.options.map((o) => ({
...o,
description:
defaultValueFromContext === o.name ? '(current)' : '',
}))
: !optionsFromContext || typeof optionsFromContext === 'string'
? [
{
name: optionsFromContext,
description: arg.description || '',
},
]
: optionsFromContext.map((o) => ({
name: o.name || '',
description: arg.description || '',
}))
return {
...arg,
options,
}
}) as any[]
}
// We have to recreate this object every time,
// otherwise we'll have stale state in the CommandBar
// after completing our first action
const meta = lookedUpMeta
? {
...lookedUpMeta,
args: replacedArgs,
}
: undefined
return {
name: type,
owner,
callback: (data: EventFrom<T, typeof type>) => {
if (data !== undefined && data !== null) {
send(type, { data })
} else {
send(type)
}
},
meta: meta as any,
}
}

View File

@ -1,13 +1,16 @@
export default function fetcher(input: RequestInfo, init: RequestInit = {}) {
const fetcherWithToken = async (token?: string): Promise<JSON> => {
const headers = { ...init.headers } as Record<string, string>
if (token) {
headers.Authorization = `Bearer ${token}`
}
import { useAuthMachine } from '../hooks/useAuthMachine'
const credentials = 'include' as RequestCredentials
const res = await fetch(input, { ...init, credentials, headers })
return res.json()
export default async function fetcher<JSON = any>(
input: RequestInfo,
init: RequestInit = {}
): Promise<JSON> {
const [token] = useAuthMachine((s) => s?.context?.token)
const headers = { ...init.headers } as Record<string, string>
if (token) {
headers.Authorization = `Bearer ${token}`
}
return fetcherWithToken
const credentials = 'include' as RequestCredentials
const res = await fetch(input, { ...init, credentials, headers })
return res.json()
}

View File

@ -0,0 +1,9 @@
import { Themes } from '../useStore'
export function getSystemTheme(): Exclude<Themes, 'system'> {
return typeof window !== 'undefined' &&
'matchMedia' in window &&
window.matchMedia('(prefers-color-scheme: dark)').matches
? Themes.Dark
: Themes.Light
}

View File

@ -1,64 +0,0 @@
import {
faArrowDown,
faArrowUp,
faCircleDot,
} from '@fortawesome/free-solid-svg-icons'
import { ProjectWithEntryPointMetadata } from '../Router'
const DESC = ':desc'
export function getSortIcon(currentSort: string, newSort: string) {
if (currentSort === newSort) {
return faArrowUp
} else if (currentSort === newSort + DESC) {
return faArrowDown
}
return faCircleDot
}
export function getNextSearchParams(currentSort: string, newSort: string) {
if (currentSort === null || !currentSort)
return { sort_by: newSort + (newSort !== 'modified' ? DESC : '') }
if (currentSort.includes(newSort) && !currentSort.includes(DESC))
return { sort_by: '' }
return {
sort_by: newSort + (currentSort.includes(DESC) ? '' : DESC),
}
}
export function getSortFunction(sortBy: string) {
const sortByName = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (a.name && b.name) {
return sortBy.includes('desc')
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
}
return 0
}
const sortByModified = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (
a.entrypoint_metadata?.modifiedAt &&
b.entrypoint_metadata?.modifiedAt
) {
return !sortBy || sortBy.includes('desc')
? b.entrypoint_metadata.modifiedAt.getTime() -
a.entrypoint_metadata.modifiedAt.getTime()
: a.entrypoint_metadata.modifiedAt.getTime() -
b.entrypoint_metadata.modifiedAt.getTime()
}
return 0
}
if (sortBy?.includes('name')) {
return sortByName
} else {
return sortByModified
}
}

View File

@ -1,11 +1,6 @@
import {
FileEntry,
createDir,
exists,
readDir,
writeTextFile,
} from '@tauri-apps/api/fs'
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'
@ -17,31 +12,35 @@ 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(directory: string) {
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 (directory) {
const dirExists = await exists(directory)
if (projectDir && projectDir.dir.length > 0) {
const dirExists = await exists(projectDir.dir)
if (!dirExists) {
await createDir(directory, { recursive: true })
await createDir(projectDir.dir, { recursive: true })
}
return directory
return projectDir
}
const docDirectory = await documentDir()
const appData = await documentDir()
const INITIAL_DEFAULT_DIR = docDirectory + PROJECT_FOLDER
const INITIAL_DEFAULT_DIR = {
dir: appData + PROJECT_FOLDER,
}
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR)
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR.dir)
if (!defaultDirExists) {
await createDir(INITIAL_DEFAULT_DIR, { recursive: true })
await createDir(INITIAL_DEFAULT_DIR.dir, { recursive: true })
}
setDefaultDir(INITIAL_DEFAULT_DIR)
return INITIAL_DEFAULT_DIR
}
@ -52,25 +51,6 @@ export function isProjectDirectory(fileOrDir: Partial<FileEntry>) {
)
}
// Read the contents of a directory
// and return the valid projects
export async function getProjectsInDir(projectDir: string) {
const readProjects = (
await readDir(projectDir, {
recursive: true,
})
).filter(isProjectDirectory)
const projectsWithMetadata = await Promise.all(
readProjects.map(async (p) => ({
entrypoint_metadata: await metadata(p.path + '/' + PROJECT_ENTRYPOINT),
...p,
}))
)
return projectsWithMetadata
}
// Creates a new file in the default directory with the default project name
// Returns the path to the new file
export async function createNewProject(

View File

@ -1,10 +1,6 @@
import { Program } from '../lang/abstractSyntaxTreeTypes'
import { ProgramMemory, _executor } from '../lang/executor'
import {
EngineCommandManager,
EngineCommand,
} from '../lang/std/engineConnection'
import { SourceRange } from 'lang/executor'
import { EngineCommandManager } from '../lang/std/engineConnection'
class MockEngineCommandManager {
constructor(mockParams: {
@ -14,42 +10,13 @@ class MockEngineCommandManager {
startNewSession() {}
waitForAllCommands() {}
waitForReady = new Promise<void>((resolve) => resolve())
sendModelingCommand({
id,
range,
command,
}: {
id: string
range: SourceRange
command: EngineCommand
}): Promise<any> {
return Promise.resolve()
}
sendModelingCommandFromWasm(
id: string,
rangeStr: string,
commandStr: string
): Promise<any> {
if (id === undefined) {
throw new Error('id is undefined')
}
if (rangeStr === undefined) {
throw new Error('rangeStr is undefined')
}
if (commandStr === undefined) {
throw new Error('commandStr is undefined')
}
const command: EngineCommand = JSON.parse(commandStr)
const range: SourceRange = JSON.parse(rangeStr)
return this.sendModelingCommand({ id, range, command })
}
sendModellingCommand() {}
sendSceneCommand() {}
}
export async function enginelessExecutor(
ast: Program,
pm: ProgramMemory = { root: {} }
pm: ProgramMemory = { root: {}, pendingMemory: {} }
): Promise<ProgramMemory> {
const mockEngineCommandManager = new MockEngineCommandManager({
setIsStreamReady: () => {},
@ -64,7 +31,7 @@ export async function enginelessExecutor(
export async function executor(
ast: Program,
pm: ProgramMemory = { root: {} }
pm: ProgramMemory = { root: {}, pendingMemory: {} }
): Promise<ProgramMemory> {
const engineCommandManager = new EngineCommandManager({
setIsStreamReady: () => {},

View File

@ -1,23 +0,0 @@
export enum Themes {
Light = 'light',
Dark = 'dark',
System = 'system',
}
// Get the theme from the system settings manually
export function getSystemTheme(): Exclude<Themes, 'system'> {
return typeof window !== 'undefined' && 'matchMedia' in window
? window.matchMedia('(prefers-color-scheme: dark)').matches
? Themes.Dark
: Themes.Light
: Themes.Light
}
// Set the theme class on the body element
export function setThemeClass(theme: Themes) {
if (theme === Themes.Dark) {
document.body.classList.add('dark')
} else {
document.body.classList.remove('dark')
}
}

View File

@ -1,218 +0,0 @@
import { assign, createMachine } from 'xstate'
import { ProjectWithEntryPointMetadata } from '../Router'
import { CommandBarMeta } from '../lib/commands'
export const homeCommandMeta: CommandBarMeta = {
'Create project': {
displayValue: (args: string[]) => `Create project "${args[0]}"`,
args: [
{
name: 'name',
type: 'string',
description: '(default)',
options: 'defaultProjectName',
},
],
},
'Open project': {
displayValue: (args: string[]) => `Open project "${args[0]}"`,
args: [
{
name: 'name',
type: 'select',
options: 'projects',
},
],
},
'Delete project': {
displayValue: (args: string[]) => `Delete project "${args[0]}"`,
args: [
{
name: 'name',
type: 'select',
options: 'projects',
},
],
},
'Rename project': {
displayValue: (args: string[]) =>
`Rename project "${args[0]}" to "${args[1]}"`,
args: [
{
name: 'oldName',
type: 'select',
options: 'projects',
},
{
name: 'newName',
type: 'string',
description: '(default)',
options: 'defaultProjectName',
},
],
},
assign: {
hide: 'both',
},
}
export const homeMachine = createMachine(
{
/** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAHTK6xampYAOATqgFZj4AusAxAMLMwuLthbtOXANoAGALqJQjVLGJdiqUopAAPRAHYAbPooAWABwBGUwE5zAJgeGArM-MAaEAE9EN0wGYKGX97GX1nGVNDS0MbfwBfeM80TBwCEnIqGiZWDm4+ACUwUlxU8TzpeW1lVXVNbT0EcJNg02d-fzt7fU77Tx8EQ0iKCPtnfUsjGRtLGXtE5IxsPCIySmpacsk+QWFRHIluWQUkEBq1DS1TxqN7ChjzOxtXf0t7a37EcwsRibH-ZzRezA8wLEApZbpNZZTa5ba8AAiYAANmB9lsjlVTuc6ldQDdDOYKP5bm0os5TDJDJ8mlEzPpzIZHA4bO9umCIWlVpkNgcKnwAPKMYp8yTHaoqC71a6IEmBUz6BkWZzWDq2Uw0qzOIJAwz+PXWfSmeZJcFLLkZSi7ERkKCi7i8CCaShkABuqAA1pR8EIRGAALQYyonJSS3ENRDA2wUeyvd6dPVhGw0-RhGOp8IA8xGFkc80rS0Ua3qUh2oO8MDMVjMCiMZEiABmqGY6AoPr2AaD4uxYcuEYQoQpQWNNjsMnMgLGKbT3TC7TcOfsNjzqQL0KKJXQtvtXEdzoobs9lCEm87cMxIbOvel+MQqtMQRmS5ks31sZpAUsZkcIX+cQZJIrpC3KUBupTbuWlbVrW9ZcE2LYUCepRnocwYSrUfYyggbzvBQ+jMq49imLYwTUt4iCft+5i-u0-7UfoQEWtCSKoiWZbnruTqZIeXoUBAKJoihFTdqGGE3rod7UdqsQTI8hiGAqrIauRA7RvYeoqhO1jtAqjFrpkLFohBHEVlWzYwY2zatvxrFCWKWKiVKeISdh4yBJE-jGs4fhhA4zg0kRNgxhplhaW0nn4XpUKZEUuAQMZqF8FxLqkO6vG+hAgYcbAIlXmJzmNERdy0RYNiKgpthxDSEU6q8MSTJYjWGFFIEULF8WljuSX7jxx7CJlQY5ZYl44pht4IP61gyPc8njt0lIuH51UKrVVITEyMy2C1hbtQl-KmdBdaWQhGVZYluWjeJjSTf402shMEyuEyljPAFL0UNmMiuN86lWHMiSmvQ-HwKcnL6WA6FOf2k3mESMRDA4RpUm4U4qf6gSEt0QIvvqfjOCaiyrtF6zZPQXWQ+GWFlUEsbmNMf1TV9NLeXDcqRIySnNaaYPEzC5M9vl-b+IyFCjupryPF9jKWP5Kks-cbMWLERHRNt0LFntkgU2NLk4dqsz43YsTK++Kk2C+MbTOOcxzOMrhqzFxTgZ1Qba1dd6BUE1jGsLMxxK9KlDNqm3tMLUQvqYlgO5QhlsTubsFXesTTUuPTfHExshDS0RftRftGgEnTZtHbX9Zr+QJ-2S4Y3qnmTC+4tMyp1EfeOnmeQqdOhyXQrFOXXCV1hCkmLDOnBJYvRRDSsyRzGjiKj0lKdAkANAA */
id: 'Home machine',
initial: 'Reading projects',
context: {
projects: [] as ProjectWithEntryPointMetadata[],
defaultProjectName: '',
defaultDirectory: '',
},
on: {
assign: {
actions: assign((_, event) => ({
...event.data,
})),
target: '.Reading projects',
},
},
states: {
'Has no projects': {
on: {
'Create project': {
target: 'Creating project',
},
},
},
'Has projects': {
on: {
'Rename project': {
target: 'Renaming project',
},
'Create project': {
target: 'Creating project',
},
'Delete project': {
target: 'Deleting project',
},
'Open project': {
target: 'Opening project',
},
},
},
'Creating project': {
invoke: {
id: 'create-project',
src: 'createProject',
onDone: [
{
target: 'Reading projects',
actions: ['toastSuccess'],
},
],
onError: [
{
target: 'Reading projects',
actions: ['toastError'],
},
],
},
},
'Renaming project': {
invoke: {
id: 'rename-project',
src: 'renameProject',
onDone: [
{
target: '#Home machine.Reading projects',
actions: ['toastSuccess'],
},
],
onError: [
{
target: '#Home machine.Reading projects',
actions: ['toastError'],
},
],
},
},
'Deleting project': {
invoke: {
id: 'delete-project',
src: 'deleteProject',
onDone: [
{
actions: ['toastSuccess'],
target: '#Home machine.Reading projects',
},
],
onError: {
actions: ['toastError'],
target: '#Home machine.Has projects',
},
},
},
'Reading projects': {
invoke: {
id: 'read-projects',
src: 'readProjects',
onDone: [
{
cond: 'Has at least 1 project',
target: 'Has projects',
actions: ['setProjects'],
},
{
target: 'Has no projects',
actions: ['setProjects'],
},
],
onError: [
{
target: 'Has no projects',
actions: ['toastError'],
},
],
},
},
'Opening project': {
entry: ['navigateToProject'],
},
},
schema: {
events: {} as
| { type: 'Open project'; data: { name: string } }
| { type: 'Rename project'; data: { oldName: string; newName: string } }
| { type: 'Create project'; data: { name: string } }
| { type: 'Delete project'; data: { name: string } }
| { type: 'navigate'; data: { name: string } }
| {
type: 'done.invoke.read-projects'
data: ProjectWithEntryPointMetadata[]
}
| { type: 'assign'; data: { [key: string]: any } },
},
predictableActionArguments: true,
preserveActionOrder: true,
tsTypes: {} as import('./homeMachine.typegen').Typegen0,
},
{
actions: {
setProjects: assign((_, event) => {
return { projects: event.data as ProjectWithEntryPointMetadata[] }
}),
},
}
)

View File

@ -1,99 +0,0 @@
// This file was automatically generated. Edits will be overwritten
export interface Typegen0 {
'@@xstate/typegen': true
internalEvents: {
'done.invoke.create-project': {
type: 'done.invoke.create-project'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'done.invoke.delete-project': {
type: 'done.invoke.delete-project'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'done.invoke.read-projects': {
type: 'done.invoke.read-projects'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'done.invoke.rename-project': {
type: 'done.invoke.rename-project'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'error.platform.create-project': {
type: 'error.platform.create-project'
data: unknown
}
'error.platform.delete-project': {
type: 'error.platform.delete-project'
data: unknown
}
'error.platform.read-projects': {
type: 'error.platform.read-projects'
data: unknown
}
'error.platform.rename-project': {
type: 'error.platform.rename-project'
data: unknown
}
'xstate.init': { type: 'xstate.init' }
}
invokeSrcNameMap: {
createProject: 'done.invoke.create-project'
deleteProject: 'done.invoke.delete-project'
readProjects: 'done.invoke.read-projects'
renameProject: 'done.invoke.rename-project'
}
missingImplementations: {
actions: 'navigateToProject' | 'toastError' | 'toastSuccess'
delays: never
guards: 'Has at least 1 project'
services:
| 'createProject'
| 'deleteProject'
| 'readProjects'
| 'renameProject'
}
eventsCausingActions: {
navigateToProject: 'Open project'
setProjects: 'done.invoke.read-projects'
toastError:
| 'error.platform.create-project'
| 'error.platform.delete-project'
| 'error.platform.read-projects'
| 'error.platform.rename-project'
toastSuccess:
| 'done.invoke.create-project'
| 'done.invoke.delete-project'
| 'done.invoke.rename-project'
}
eventsCausingDelays: {}
eventsCausingGuards: {
'Has at least 1 project': 'done.invoke.read-projects'
}
eventsCausingServices: {
createProject: 'Create project'
deleteProject: 'Delete project'
readProjects:
| 'assign'
| 'done.invoke.create-project'
| 'done.invoke.delete-project'
| 'done.invoke.rename-project'
| 'error.platform.create-project'
| 'error.platform.rename-project'
| 'xstate.init'
renameProject: 'Rename project'
}
matchesStates:
| 'Creating project'
| 'Deleting project'
| 'Has no projects'
| 'Has projects'
| 'Opening project'
| 'Reading projects'
| 'Renaming project'
tags: never
}

View File

@ -1,207 +0,0 @@
import { assign, createMachine } from 'xstate'
import { BaseUnit, baseUnitsUnion } from '../useStore'
import { CommandBarMeta } from '../lib/commands'
import { Themes, getSystemTheme, setThemeClass } from '../lib/theme'
export enum UnitSystem {
Imperial = 'imperial',
Metric = 'metric',
}
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
export const settingsCommandBarMeta: CommandBarMeta = {
'Set Theme': {
displayValue: (args: string[]) => 'Change the app theme',
args: [
{
name: 'theme',
type: 'select',
defaultValue: 'theme',
options: Object.values(Themes).map((v) => ({ name: v })) as {
name: string
}[],
},
],
},
'Set Default Project Name': {
displayValue: (args: string[]) => 'Set a new default project name',
hide: 'web',
args: [
{
name: 'defaultProjectName',
type: 'string',
description: '(default)',
defaultValue: 'defaultProjectName',
options: 'defaultProjectName',
},
],
},
'Set Default Directory': {
hide: 'both',
},
'Set Unit System': {
displayValue: (args: string[]) => 'Set your default unit system',
args: [
{
name: 'unitSystem',
type: 'select',
defaultValue: 'unitSystem',
options: [{ name: UnitSystem.Imperial }, { name: UnitSystem.Metric }],
},
],
},
'Set Base Unit': {
displayValue: (args: string[]) => 'Set your default base unit',
args: [
{
name: 'baseUnit',
type: 'select',
defaultValue: 'baseUnit',
options: Object.values(baseUnitsUnion).map((v) => ({ name: v })),
},
],
},
'Set Onboarding Status': {
hide: 'both',
},
}
export const settingsMachine = createMachine(
{
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */
id: 'Settings',
predictableActionArguments: true,
context: {
theme: Themes.System,
defaultProjectName: '',
unitSystem: UnitSystem.Imperial,
baseUnit: 'in' as BaseUnit,
defaultDirectory: '',
showDebugPanel: false,
onboardingStatus: '',
},
initial: 'idle',
states: {
idle: {
entry: ['setThemeClass'],
on: {
'Set Theme': {
actions: [
assign({
theme: (_, event) => event.data.theme,
}),
'persistSettings',
'toastSuccess',
'setThemeClass',
],
target: 'idle',
internal: true,
},
'Set Default Project Name': {
actions: [
assign({
defaultProjectName: (_, event) => event.data.defaultProjectName,
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Default Directory': {
actions: [
assign({
defaultDirectory: (_, event) => event.data.defaultDirectory,
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Unit System': {
actions: [
assign({
unitSystem: (_, event) => event.data.unitSystem,
baseUnit: (_, event) =>
event.data.unitSystem === 'imperial' ? 'in' : 'mm',
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Base Unit': {
actions: [
assign({ baseUnit: (_, event) => event.data.baseUnit }),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Toggle Debug Panel': {
actions: [
assign({
showDebugPanel: (context) => {
return !context.showDebugPanel
},
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Onboarding Status': {
actions: [
assign({
onboardingStatus: (_, event) => event.data.onboardingStatus,
}),
'persistSettings',
],
target: 'idle',
internal: true,
},
},
},
},
tsTypes: {} as import('./settingsMachine.typegen').Typegen0,
schema: {
events: {} as
| { type: 'Set Theme'; data: { theme: Themes } }
| {
type: 'Set Default Project Name'
data: { defaultProjectName: string }
}
| { type: 'Set Default Directory'; data: { defaultDirectory: string } }
| {
type: 'Set Unit System'
data: { unitSystem: UnitSystem }
}
| { type: 'Set Base Unit'; data: { baseUnit: BaseUnit } }
| { type: 'Set Onboarding Status'; data: { onboardingStatus: string } }
| { type: 'Toggle Debug Panel' },
},
},
{
actions: {
persistSettings: (context) => {
try {
localStorage.setItem(SETTINGS_PERSIST_KEY, JSON.stringify(context))
} catch (e) {
console.error(e)
}
},
setThemeClass: (context, event) => {
const currentTheme =
event.type === 'Set Theme' ? event.data.theme : context.theme
setThemeClass(
currentTheme === Themes.System ? getSystemTheme() : currentTheme
)
},
},
}
)

View File

@ -1,46 +0,0 @@
// This file was automatically generated. Edits will be overwritten
export interface Typegen0 {
'@@xstate/typegen': true
internalEvents: {
'xstate.init': { type: 'xstate.init' }
}
invokeSrcNameMap: {}
missingImplementations: {
actions: 'toastSuccess'
delays: never
guards: never
services: never
}
eventsCausingActions: {
persistSettings:
| 'Set Base Unit'
| 'Set Default Directory'
| 'Set Default Project Name'
| 'Set Onboarding Status'
| 'Set Theme'
| 'Set Unit System'
| 'Toggle Debug Panel'
setThemeClass:
| 'Set Base Unit'
| 'Set Default Directory'
| 'Set Default Project Name'
| 'Set Onboarding Status'
| 'Set Theme'
| 'Set Unit System'
| 'Toggle Debug Panel'
| 'xstate.init'
toastSuccess:
| 'Set Base Unit'
| 'Set Default Directory'
| 'Set Default Project Name'
| 'Set Theme'
| 'Set Unit System'
| 'Toggle Debug Panel'
}
eventsCausingDelays: {}
eventsCausingGuards: {}
eventsCausingServices: {}
matchesStates: 'idle'
tags: never
}

View File

@ -1,139 +1,93 @@
import { FormEvent, useEffect } from 'react'
import { removeDir, renameFile } from '@tauri-apps/api/fs'
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { readDir, removeDir, renameFile } from '@tauri-apps/api/fs'
import {
createNewProject,
getNextProjectIndex,
interpolateProjectNameWithIndex,
doesProjectNameNeedInterpolated,
getProjectsInDir,
isProjectDirectory,
PROJECT_ENTRYPOINT,
} from '../lib/tauriFS'
import { ActionButton } from '../components/ActionButton'
import { faArrowDown, faPlus } from '@fortawesome/free-solid-svg-icons'
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, useNavigate, useSearchParams } from 'react-router-dom'
import { useLoaderData, useSearchParams } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
import Loading from '../components/Loading'
import { useMachine } from '@xstate/react'
import { homeCommandMeta, homeMachine } from '../machines/homeMachine'
import { ContextFrom, EventFrom } from 'xstate'
import { paths } from '../Router'
import {
getNextSearchParams,
getSortFunction,
getSortIcon,
} from '../lib/sorting'
import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useCommandsContext } from 'hooks/useCommandsContext'
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 { commands, setCommandBarOpen } = useCommandsContext()
const navigate = useNavigate()
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
const {
settings: {
context: { defaultDirectory, defaultProjectName },
},
} = useGlobalStateContext()
const [state, send] = useMachine(homeMachine, {
context: {
projects: loadedProjects,
defaultProjectName,
defaultDirectory,
},
actions: {
navigateToProject: (
context: ContextFrom<typeof homeMachine>,
event: EventFrom<typeof homeMachine>
) => {
if (event.data && 'name' in event.data) {
setCommandBarOpen(false)
navigate(
`${paths.FILE}/${encodeURIComponent(
context.defaultDirectory + '/' + event.data.name
)}`
)
}
},
toastSuccess: (_, event) => toast.success((event.data || '') + ''),
toastError: (_, event) => toast.error((event.data || '') + ''),
},
services: {
readProjects: async (context: ContextFrom<typeof homeMachine>) =>
getProjectsInDir(context.defaultDirectory),
createProject: async (
context: ContextFrom<typeof homeMachine>,
event: EventFrom<typeof homeMachine, 'Create project'>
) => {
let name =
event.data && 'name' in event.data
? event.data.name
: defaultProjectName
if (doesProjectNameNeedInterpolated(name)) {
const nextIndex = await getNextProjectIndex(name, projects)
name = interpolateProjectNameWithIndex(name, nextIndex)
}
await createNewProject(context.defaultDirectory + '/' + name)
return `Successfully created "${name}"`
},
renameProject: async (
context: ContextFrom<typeof homeMachine>,
event: EventFrom<typeof homeMachine, 'Rename project'>
) => {
const { oldName, newName } = event.data
let name = newName ? newName : context.defaultProjectName
if (doesProjectNameNeedInterpolated(name)) {
const nextIndex = await getNextProjectIndex(name, projects)
name = interpolateProjectNameWithIndex(name, nextIndex)
}
await renameFile(
context.defaultDirectory + '/' + oldName,
context.defaultDirectory + '/' + name
)
return `Successfully renamed "${oldName}" to "${name}"`
},
deleteProject: async (
context: ContextFrom<typeof homeMachine>,
event: EventFrom<typeof homeMachine, 'Delete project'>
) => {
await removeDir(context.defaultDirectory + '/' + event.data.name, {
recursive: true,
})
return `Successfully deleted "${event.data.name}"`
},
},
guards: {
'Has at least 1 project': (_, event: EventFrom<typeof homeMachine>) => {
if (event.type !== 'done.invoke.read-projects') return false
return event?.data?.length ? event.data?.length >= 1 : false
},
},
})
const { projects } = state.context
const [searchParams, setSearchParams] = useSearchParams()
const sort = searchParams.get('sort_by') ?? 'modified:desc'
const { 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 isSortByModified = sort?.includes('modified') || !sort || sort === null
const modifiedSelected = sort?.includes('modified') || !sort || sort === null
useStateMachineCommands<typeof homeMachine>({
commands,
send,
state,
commandBarMeta: homeCommandMeta,
owner: 'home',
})
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(() => {
send({ type: 'assign', data: { defaultProjectName, defaultDirectory } })
}, [defaultDirectory, defaultProjectName, send])
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>,
@ -142,14 +96,85 @@ const Home = () => {
const { newProjectName } = Object.fromEntries(
new FormData(e.target as HTMLFormElement)
)
if (newProjectName && project.name && newProjectName !== project.name) {
const dir = project.path?.slice(0, project.path?.lastIndexOf('/'))
await renameFile(project.path, dir + '/' + newProjectName).catch(
(err) => {
console.error('Error renaming project:', err)
toast.error('Error renaming project')
}
)
send('Rename project', {
data: { oldName: project.name, newName: newProjectName },
})
await refreshProjects()
toast.success('Project renamed')
}
}
async function handleDeleteProject(project: ProjectWithEntryPointMetadata) {
send('Delete project', { data: { name: project.name || '' } })
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 (
@ -166,9 +191,9 @@ const Home = () => {
? 'text-chalkboard-80 dark:text-chalkboard-40'
: ''
}
onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))}
onClick={() => setSearchParams(getNextSearchParams('name'))}
icon={{
icon: getSortIcon(sort, 'name'),
icon: getSortIcon('name'),
bgClassName: !sort?.includes('name')
? 'bg-liquid-50 dark:bg-liquid-70'
: '',
@ -182,19 +207,17 @@ const Home = () => {
<ActionButton
Element="button"
className={
!isSortByModified
!modifiedSelected
? 'text-chalkboard-80 dark:text-chalkboard-40'
: ''
}
onClick={() =>
setSearchParams(getNextSearchParams(sort, 'modified'))
}
onClick={() => setSearchParams(getNextSearchParams('modified'))}
icon={{
icon: sort ? getSortIcon(sort, 'modified') : faArrowDown,
bgClassName: !isSortByModified
icon: sort ? getSortIcon('modified') : faArrowDown,
bgClassName: !modifiedSelected
? 'bg-liquid-50 dark:bg-liquid-70'
: '',
iconClassName: !isSortByModified
iconClassName: !modifiedSelected
? 'text-liquid-80 dark:text-liquid-30'
: '',
}}
@ -207,11 +230,11 @@ const Home = () => {
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
Are being saved at{' '}
<code className="text-liquid-80 dark:text-liquid-30">
{defaultDirectory}
{defaultDir.dir}
</code>
, which you can change in your <Link to="settings">Settings</Link>.
</p>
{state.matches('Reading projects') ? (
{isLoading ? (
<Loading>Loading your Projects...</Loading>
) : (
<>
@ -233,7 +256,7 @@ const Home = () => {
)}
<ActionButton
Element="button"
onClick={() => send('Create project')}
onClick={handleNewProject}
icon={{ icon: faPlus }}
>
New file

View File

@ -20,25 +20,9 @@ export default function Units() {
>
<h1 className="text-2xl font-bold">Camera</h1>
<p className="mt-6">
Moving the camera is easy! The controls are as you might expect:
</p>
<ul className="list-disc list-outside ms-8 mb-4">
<li>Click and drag anywhere in the scene to rotate the camera</li>
<li>
Hold down the <kbd>Shift</kbd> key while clicking and dragging to
pan the camera
</li>
<li>
Hold down the <kbd>Ctrl</kbd> key while dragging to zoom. You can
also use the scroll wheel to zoom in and out.
</li>
</ul>
<p>
What you're seeing here is just a video, and your interactions are
being sent to our Geometry Engine API, which sends back video frames
in real time. How cool is that? It means that you can use KittyCAD
Modeling App (or whatever you want to build) on any device, even a
cheap laptop with no graphics card!
Moving the camera is easy. Just click and drag anywhere in the scene
to rotate the camera, or hold down the <kbd>Ctrl</kbd> key and drag to
pan the camera.
</p>
<div className="flex justify-between mt-6">
<ActionButton

View File

@ -1,21 +1,34 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
import { BaseUnit, baseUnits } from '../../useStore'
import { baseUnits, useStore } from '../../useStore'
import { ActionButton } from '../../components/ActionButton'
import { SettingsSection } from '../Settings'
import { Toggle } from '../../components/Toggle/Toggle'
import { useState } from 'react'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { UnitSystem } from 'machines/settingsMachine'
export default function Units() {
const dismiss = useDismiss()
const next = useNextClick(onboardingPaths.CAMERA)
const {
settings: {
send,
context: { unitSystem, baseUnit },
},
} = useGlobalStateContext()
defaultUnitSystem: ogDefaultUnitSystem,
setDefaultUnitSystem: saveDefaultUnitSystem,
defaultBaseUnit: ogDefaultBaseUnit,
setDefaultBaseUnit: saveDefaultBaseUnit,
} = useStore((s) => ({
defaultUnitSystem: s.defaultUnitSystem,
setDefaultUnitSystem: s.setDefaultUnitSystem,
defaultBaseUnit: s.defaultBaseUnit,
setDefaultBaseUnit: s.setDefaultBaseUnit,
}))
const [defaultUnitSystem, setDefaultUnitSystem] =
useState(ogDefaultUnitSystem)
const [defaultBaseUnit, setDefaultBaseUnit] = useState(ogDefaultBaseUnit)
function handleNextClick() {
saveDefaultUnitSystem(defaultUnitSystem)
saveDefaultBaseUnit(defaultBaseUnit)
next()
}
return (
<div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50">
@ -29,16 +42,10 @@ export default function Units() {
offLabel="Imperial"
onLabel="Metric"
name="settings-units"
checked={unitSystem === UnitSystem.Metric}
onChange={(e) => {
const newUnitSystem = e.target.checked
? UnitSystem.Metric
: UnitSystem.Imperial
send({
type: 'Set Unit System',
data: { unitSystem: newUnitSystem },
})
}}
checked={defaultUnitSystem === 'metric'}
onChange={(e) =>
setDefaultUnitSystem(e.target.checked ? 'metric' : 'imperial')
}
/>
</SettingsSection>
<SettingsSection
@ -48,15 +55,10 @@ export default function Units() {
<select
id="base-unit"
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
value={baseUnit}
onChange={(e) => {
send({
type: 'Set Base Unit',
data: { baseUnit: e.target.value as BaseUnit },
})
}}
value={defaultBaseUnit}
onChange={(e) => setDefaultBaseUnit(e.target.value)}
>
{baseUnits[unitSystem].map((unit) => (
{baseUnits[defaultUnitSystem].map((unit) => (
<option key={unit} value={unit}>
{unit}
</option>
@ -79,7 +81,7 @@ export default function Units() {
</ActionButton>
<ActionButton
Element="button"
onClick={next}
onClick={handleNextClick}
icon={{ icon: faArrowRight }}
>
Next: Camera

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