Merge branch 'main' into pierremtb/issue1217

This commit is contained in:
Pierre Jacquier
2023-12-15 07:14:20 -05:00
committed by GitHub
74 changed files with 4628 additions and 2821 deletions

View File

@ -107,7 +107,7 @@ jobs:
echo "$(jq --arg url 'https://dl.kittycad.io/releases/modeling-app/nightly/last_update.json' \
'.tauri.updater.endpoints[]=$url' src-tauri/tauri.release.conf.json --indent 2)" > src-tauri/tauri.release.conf.json
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: github.event_name == 'schedule'
with:
path: |
@ -128,7 +128,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
- uses: actions/download-artifact@v4
if: github.event_name == 'schedule'
- name: Copy updated .json files
@ -243,7 +243,7 @@ jobs:
with:
args: "${{ matrix.os == 'macos-latest' && '--target universal-apple-darwin' || '' }} ${{ env.TAURI_CONF_ARGS }}"
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
env:
PREFIX: ${{ matrix.os == 'macos-latest' && 'src-tauri/target/universal-apple-darwin' || 'src-tauri/target' }}
MODE: ${{ env.BUILD_RELEASE == 'true' && 'release' || 'debug' }}
@ -273,7 +273,7 @@ jobs:
NOTES: ${{ github.event_name == 'release' && github.event.release.body || format('Nightly build, commit {0}', github.sha) }}
BUCKET_DIR: ${{ github.event_name == 'release' && 'dl.kittycad.io/releases/modeling-app' || 'dl.kittycad.io/releases/modeling-app/nightly' }}
steps:
- uses: actions/download-artifact@v3
- uses: actions/download-artifact@v4
- name: Generate the update static endpoint
run: |
@ -351,12 +351,12 @@ jobs:
credentials_json: '${{ secrets.GOOGLE_CLOUD_DL_SA }}'
- name: Set up Google Cloud SDK
uses: google-github-actions/setup-gcloud@v1.1.1
uses: google-github-actions/setup-gcloud@v2.0.0
with:
project_id: kittycadapi
- name: Upload release files to public bucket
uses: google-github-actions/upload-cloud-storage@v1.0.3
uses: google-github-actions/upload-cloud-storage@v2.0.0
with:
path: artifact
glob: '*/*itty*'
@ -364,13 +364,13 @@ jobs:
destination: ${{ env.BUCKET_DIR }}/${{ env.VERSION }}
- name: Upload update endpoint to public bucket
uses: google-github-actions/upload-cloud-storage@v1.0.3
uses: google-github-actions/upload-cloud-storage@v2.0.0
with:
path: last_update.json
destination: ${{ env.BUCKET_DIR }}
- name: Upload download endpoint to public bucket
uses: google-github-actions/upload-cloud-storage@v1.0.3
uses: google-github-actions/upload-cloud-storage@v2.0.0
with:
path: last_download.json
destination: ${{ env.BUCKET_DIR }}

View File

@ -33,7 +33,7 @@ jobs:
env:
CI: true
token: ${{ secrets.KITTYCAD_API_TOKEN_DEV }}
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
@ -68,7 +68,7 @@ jobs:
env:
CI: true
token: ${{ secrets.KITTYCAD_API_TOKEN_DEV }}
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
@ -106,7 +106,7 @@ jobs:
env:
CI: true
token: ${{ secrets.KITTYCAD_API_TOKEN_DEV }}
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report

View File

@ -1,4 +1,4 @@
![KittyCAD Modeling App](/public/kcma-logomark.png)
![KittyCAD Modeling App](/public/kcma-logomark-outlined.png)
## KittyCAD Modeling App

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -8,275 +8,275 @@ property float z
element face 68
property list uchar uint vertex_indices
end_header
0 0 4
0 0 0
0 -1 4
0 -1 4
0 0 0
0 -1 0
0 -1 4
0 -1 0
3.0950184 -1 4
3.0950184 -1 4
0 -1 0
3.0950184 -1 0
3.0950184 -1 4
3.0950184 -1 0
5.9513144 -3 4
5.9513144 -3 4
3.0950184 -1 0
5.9513144 -3 0
5.9513144 -3 4
5.9513144 -3 0
9.5 -3 4
9.5 -3 4
5.9513144 -3 0
9.5 -3 0
9.5 -3 4
9.5 -3 0
9.5 -2.5 4
9.5 -2.5 4
9.5 -3 0
9.5 -2.5 0
9.5 -2.5 4
9.5 -2.5 0
6.108964 -2.5 4
6.108964 -2.5 4
9.5 -2.5 0
6.108964 -2.5 0
3.4311862 -0.625 4
4.323779 -1.25 4
4.323779 -1.25 0
4.323779 -1.25 4
6.108964 -2.5 4
6.108964 -2.5 0
3.4311862 -0.625 0
2.5385938 0 0
2.5385938 0 4
3.4311862 -0.625 4
3.4311862 -0.625 0
2.5385938 0 4
4.323779 -1.25 4
6.108964 -2.5 0
4.323779 -1.25 0
3.4311862 -0.625 0
3.4311862 -0.625 4
4.323779 -1.25 0
3.342784 0.375 4
2.5385938 0 4
2.5385938 0 0
4.146974 0.75 4
3.342784 0.375 4
3.342784 0.375 0
3.342784 0.375 0
4.146974 0.75 0
4.146974 0.75 4
4.146974 0.75 0
5.755354 1.5 0
5.755354 1.5 4
3.342784 0.375 4
2.5385938 0 0
3.342784 0.375 0
5.755354 1.5 4
4.146974 0.75 4
4.146974 0.75 0
5.755354 1.5 4
5.755354 1.5 0
9.5 1.5 4
9.5 1.5 4
5.755354 1.5 0
9.5 1.5 0
9.5 1.5 4
9.5 1.5 0
9.5 2 4
9.5 2 4
9.5 1.5 0
9.5 2 0
9.5 2 4
9.5 2 0
5.644507 2 4
5.644507 2 4
9.5 2 0
5.644507 2 0
5.644507 2 4
5.644507 2 0
3.5 1 4
3.5 1 4
5.644507 2 0
3.5 1 0
3.5 1 4
3.5 1 0
0 1 4
0 1 4
3.5 1 0
0 1 0
0 1 4
0 1 0
0 0 4
0 0 4
0 1 0
0 0 0
3.342784 0.375 0
2.5385938 0 0
3.5 1 0
3.4311862 -0.625 0
4.323779 -1.25 0
3.0950184 -1 0
3.342784 0.375 0
3.5 1 0
4.146974 0.75 0
4.323779 -1.25 0
5.9513144 -3 0
3.0950184 -1 0
0 -1 0
2.5385938 0 0
3.0950184 -1 0
0 -1 0
0 0 0
2.5385938 0 0
9.5 -3 0
6.108964 -2.5 0
9.5 -2.5 0
9.5 -3 0
5.9513144 -3 0
6.108964 -2.5 0
5.9513144 -3 0
4.323779 -1.25 0
6.108964 -2.5 0
5.644507 2 0
5.755354 1.5 0
4.146974 0.75 0
3.0950184 -1 0
2.5385938 0 0
3.4311862 -0.625 0
4.146974 0.75 0
3.5 1 0
5.644507 2 0
9.5 1.5 0
5.755354 1.5 0
9.5 2 0
5.755354 1.5 0
5.644507 2 0
9.5 2 0
2.5385938 0 0
0 0 0
0 1 0
3.5 1 0
2.5385938 0 0
0 1 0
3.342784 0.375 4
3.5 1 4
2.5385938 0 4
4.146974 0.75 4
3.5 1 4
3.342784 0.375 4
3.4311862 -0.625 4
3.0950184 -1 4
4.323779 -1.25 4
4.146974 0.75 4
5.755354 1.5 4
5.644507 2 4
0 1 4
2.5385938 0 4
3.5 1 4
0 1 4
0 0 4
2.5385938 0 4
5.644507 2 4
5.755354 1.5 4
9.5 2 4
9.5 2 4
5.755354 1.5 4
9.5 1.5 4
4.146974 0.75 4
5.644507 2 4
3.5 1 4
2.5385938 0 4
3.0950184 -1 4
3.4311862 -0.625 4
4.323779 -1.25 4
3.0950184 -1 4
5.9513144 -3 4
6.108964 -2.5 4
4.323779 -1.25 4
5.9513144 -3 4
9.5 -2.5 4
6.108964 -2.5 4
9.5 -3 4
6.108964 -2.5 4
5.9513144 -3 4
9.5 -3 4
2.5385938 0 4
0 -1 4
3.0950184 -1 4
0 -1 4
2.5385938 0 4
0 0 4
3 0 1 2
3 3 4 5
3 6 7 8
3 9 10 11
3 12 13 14
3 15 16 17
3 18 19 20
3 21 22 23
3 24 25 26
3 27 28 29
3 30 31 32
3 33 34 35
3 36 37 38
3 39 40 41
3 42 43 44
3 45 46 47
3 48 49 50
3 51 52 53
3 54 55 56
3 57 58 59
3 60 61 62
3 63 64 65
3 66 67 68
3 69 70 71
3 72 73 74
3 75 76 77
3 78 79 80
3 81 82 83
3 84 85 86
3 87 88 89
3 90 91 92
3 93 94 95
3 96 97 98
3 99 100 101
3 102 103 104
3 105 106 107
3 108 109 110
3 111 112 113
3 114 115 116
3 117 118 119
3 120 121 122
3 123 124 125
3 126 127 128
3 129 130 131
3 132 133 134
3 135 136 137
3 138 139 140
3 141 142 143
3 144 145 146
3 147 148 149
3 150 151 152
3 153 154 155
3 156 157 158
3 159 160 161
3 162 163 164
3 165 166 167
3 168 169 170
3 171 172 173
3 174 175 176
3 177 178 179
3 180 181 182
3 183 184 185
3 186 187 188
3 189 190 191
3 192 193 194
3 195 196 197
3 198 199 200
3 201 202 203
0 0 4
0 0 0
0 -1 4
0 -1 4
0 0 0
0 -1 0
0 -1 4
0 -1 0
3.0950184 -1 4
3.0950184 -1 4
0 -1 0
3.0950184 -1 0
3.0950184 -1 4
3.0950184 -1 0
5.9513144 -3 4
5.9513144 -3 4
3.0950184 -1 0
5.9513144 -3 0
5.9513144 -3 4
5.9513144 -3 0
9.5 -3 4
9.5 -3 4
5.9513144 -3 0
9.5 -3 0
9.5 -3 4
9.5 -3 0
9.5 -2.5 4
9.5 -2.5 4
9.5 -3 0
9.5 -2.5 0
9.5 -2.5 4
9.5 -2.5 0
6.108964 -2.5 4
6.108964 -2.5 4
9.5 -2.5 0
6.108964 -2.5 0
3.4311862 -0.625 4
4.323779 -1.25 4
4.323779 -1.25 0
4.323779 -1.25 4
6.108964 -2.5 4
6.108964 -2.5 0
3.4311862 -0.625 0
2.5385938 0 0
2.5385938 0 4
3.4311862 -0.625 4
3.4311862 -0.625 0
2.5385938 0 4
4.323779 -1.25 4
6.108964 -2.5 0
4.323779 -1.25 0
3.4311862 -0.625 0
3.4311862 -0.625 4
4.323779 -1.25 0
3.342784 0.375 4
2.5385938 0 4
2.5385938 0 0
4.146974 0.75 4
3.342784 0.375 4
3.342784 0.375 0
3.342784 0.375 0
4.146974 0.75 0
4.146974 0.75 4
4.146974 0.75 0
5.755354 1.5 0
5.755354 1.5 4
3.342784 0.375 4
2.5385938 0 0
3.342784 0.375 0
5.755354 1.5 4
4.146974 0.75 4
4.146974 0.75 0
5.755354 1.5 4
5.755354 1.5 0
9.5 1.5 4
9.5 1.5 4
5.755354 1.5 0
9.5 1.5 0
9.5 1.5 4
9.5 1.5 0
9.5 2 4
9.5 2 4
9.5 1.5 0
9.5 2 0
9.5 2 4
9.5 2 0
5.644507 2 4
5.644507 2 4
9.5 2 0
5.644507 2 0
5.644507 2 4
5.644507 2 0
3.5 1 4
3.5 1 4
5.644507 2 0
3.5 1 0
3.5 1 4
3.5 1 0
0 1 4
0 1 4
3.5 1 0
0 1 0
0 1 4
0 1 0
0 0 4
0 0 4
0 1 0
0 0 0
3.342784 0.375 0
2.5385938 0 0
3.5 1 0
3.4311862 -0.625 0
4.323779 -1.25 0
3.0950184 -1 0
3.342784 0.375 0
3.5 1 0
4.146974 0.75 0
4.323779 -1.25 0
5.9513144 -3 0
3.0950184 -1 0
0 -1 0
2.5385938 0 0
3.0950184 -1 0
0 -1 0
0 0 0
2.5385938 0 0
9.5 -3 0
6.108964 -2.5 0
9.5 -2.5 0
9.5 -3 0
5.9513144 -3 0
6.108964 -2.5 0
5.9513144 -3 0
4.323779 -1.25 0
6.108964 -2.5 0
5.644507 2 0
5.755354 1.5 0
4.146974 0.75 0
3.0950184 -1 0
2.5385938 0 0
3.4311862 -0.625 0
4.146974 0.75 0
3.5 1 0
5.644507 2 0
9.5 1.5 0
5.755354 1.5 0
9.5 2 0
5.755354 1.5 0
5.644507 2 0
9.5 2 0
2.5385938 0 0
0 0 0
0 1 0
3.5 1 0
2.5385938 0 0
0 1 0
3.342784 0.375 4
3.5 1 4
2.5385938 0 4
4.146974 0.75 4
3.5 1 4
3.342784 0.375 4
3.4311862 -0.625 4
3.0950184 -1 4
4.323779 -1.25 4
4.146974 0.75 4
5.755354 1.5 4
5.644507 2 4
0 1 4
2.5385938 0 4
3.5 1 4
0 1 4
0 0 4
2.5385938 0 4
5.644507 2 4
5.755354 1.5 4
9.5 2 4
9.5 2 4
5.755354 1.5 4
9.5 1.5 4
4.146974 0.75 4
5.644507 2 4
3.5 1 4
2.5385938 0 4
3.0950184 -1 4
3.4311862 -0.625 4
4.323779 -1.25 4
3.0950184 -1 4
5.9513144 -3 4
6.108964 -2.5 4
4.323779 -1.25 4
5.9513144 -3 4
9.5 -2.5 4
6.108964 -2.5 4
9.5 -3 4
6.108964 -2.5 4
5.9513144 -3 4
9.5 -3 4
2.5385938 0 4
0 -1 4
3.0950184 -1 4
0 -1 4
2.5385938 0 4
0 0 4
3 0 1 2
3 3 4 5
3 6 7 8
3 9 10 11
3 12 13 14
3 15 16 17
3 18 19 20
3 21 22 23
3 24 25 26
3 27 28 29
3 30 31 32
3 33 34 35
3 36 37 38
3 39 40 41
3 42 43 44
3 45 46 47
3 48 49 50
3 51 52 53
3 54 55 56
3 57 58 59
3 60 61 62
3 63 64 65
3 66 67 68
3 69 70 71
3 72 73 74
3 75 76 77
3 78 79 80
3 81 82 83
3 84 85 86
3 87 88 89
3 90 91 92
3 93 94 95
3 96 97 98
3 99 100 101
3 102 103 104
3 105 106 107
3 108 109 110
3 111 112 113
3 114 115 116
3 117 118 119
3 120 121 122
3 123 124 125
3 126 127 128
3 129 130 131
3 132 133 134
3 135 136 137
3 138 139 140
3 141 142 143
3 144 145 146
3 147 148 149
3 150 151 152
3 153 154 155
3 156 157 158
3 159 160 161
3 162 163 164
3 165 166 167
3 168 169 170
3 171 172 173
3 174 175 176
3 177 178 179
3 180 181 182
3 183 184 185
3 186 187 188
3 189 190 191
3 192 193 194
3 195 196 197
3 198 199 200
3 201 202 203

View File

@ -643,7 +643,11 @@ test('Command bar works and can change a setting', async ({ page }) => {
let cmdSearchBar = page.getByPlaceholder('Search commands')
// First try opening the command bar and closing it
await page.getByRole('button', { name: '⌘K' }).click()
// It has a different label on mac and windows/linux, "Meta+K" and "Ctrl+/" respectively
await page
.getByRole('button', { name: 'Ctrl+/' })
.or(page.getByRole('button', { name: '⌘K' }))
.click()
await expect(cmdSearchBar).toBeVisible()
await page.keyboard.press('Escape')
await expect(cmdSearchBar).not.toBeVisible()
@ -658,12 +662,12 @@ test('Command bar works and can change a setting', async ({ page }) => {
const themeOption = page.getByRole('option', { name: 'Set Theme' })
await expect(themeOption).toBeVisible()
await themeOption.click()
const themeInput = page.getByPlaceholder(Themes.System)
const themeInput = page.getByPlaceholder('Select an option')
await expect(themeInput).toBeVisible()
await expect(themeInput).toBeFocused()
// Select dark theme
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowUp')
await page.keyboard.press('ArrowDown')
await expect(page.getByRole('option', { name: Themes.Dark })).toHaveAttribute(
'data-headlessui-state',
'active'
@ -675,3 +679,59 @@ test('Command bar works and can change a setting', async ({ page }) => {
// Check that the theme changed
await expect(page.locator('body')).toHaveClass(`body-bg ${Themes.Dark}`)
})
test('Can extrude from the command bar', async ({ page, context }) => {
await context.addInitScript(async (token) => {
localStorage.setItem(
'persistCode',
`const part001 = startSketchOn('-XZ')
|> startProfileAt([-6.95, 4.98], %)
|> line([25.1, 0.41], %)
|> line([0.73, -14.93], %)
|> line([-23.44, 0.52], %)
|> close(%)`
)
})
const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
let cmdSearchBar = page.getByPlaceholder('Search commands')
await page.keyboard.press('Meta+K')
await expect(cmdSearchBar).toBeVisible()
// Search for extrude command and choose it
await page.getByRole('option', { name: 'Extrude' }).click()
await expect(page.locator('#arg-form > label')).toContainText(
'Please select one face'
)
await expect(page.getByRole('button', { name: 'selection' })).toBeDisabled()
// Click to select face and set distance
await u.openAndClearDebugPanel()
await page.getByText('|> startProfileAt([-6.95, 4.98], %)').click()
await u.waitForCmdReceive('select_add')
await u.closeDebugPanel()
await page.getByRole('button', { name: 'Continue' }).click()
await expect(page.getByRole('button', { name: 'distance' })).toBeDisabled()
await page.keyboard.press('Enter')
// Review step and argument hotkeys
await page.keyboard.press('2')
await expect(page.getByRole('button', { name: '5' })).toBeDisabled()
await page.keyboard.press('Enter')
// Check that the code was updated
await page.keyboard.press('Enter')
await expect(page.locator('.cm-content')).toHaveText(
`const part001 = startSketchOn('-XZ')
|> startProfileAt([-6.95, 4.98], %)
|> line([25.1, 0.41], %)
|> line([0.73, -14.93], %)
|> line([-23.44, 0.52], %)
|> close(%)
|> extrude(5, %)`
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -60,6 +60,7 @@
},
"scripts": {
"start": "vite",
"start:prod": "vite preview --port=3000",
"serve": "vite serve --port=3000",
"build": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && source \"$HOME/.cargo/env\" && curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y && yarn build:wasm && vite build",
"build:local": "vite build",

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -8,7 +8,7 @@ import {
createRoutesFromElements,
} from 'react-router-dom'
import { GlobalStateProvider } from './components/GlobalStateProvider'
import CommandBarProvider from 'components/CommandBar'
import CommandBarProvider from 'components/CommandBar/CommandBar'
import ModelingMachineProvider from 'components/ModelingMachineProvider'
import { BROWSER_FILE_NAME } from 'Router'

View File

@ -172,11 +172,8 @@ export function App() {
<ModalContainer />
<Resizable
className={
'h-full flex flex-col flex-1 z-10 my-5 ml-5 pr-1 transition-opacity transition-duration-75 ' +
(buttonDownInStream || onboardingStatus === 'camera'
? ' pointer-events-none '
: ' ') +
paneOpacity
'pointer-events-none h-full flex flex-col flex-1 z-10 my-5 ml-5 pr-1 transition-opacity transition-duration-75 ' +
+paneOpacity
}
defaultSize={{
width: '550px',
@ -188,10 +185,16 @@ export function App() {
maxHeight={'auto'}
handleClasses={{
right:
'hover:bg-liquid-30/40 dark:hover:bg-liquid-10/40 bg-transparent transition-colors duration-100 transition-ease-out delay-100',
'hover:bg-chalkboard-10/50 bg-transparent transition-colors duration-75 transition-ease-out delay-100 ' +
(buttonDownInStream || onboardingStatus === 'camera'
? 'pointer-events-none '
: 'pointer-events-auto'),
}}
>
<div id="code-pane" className="h-full flex flex-col justify-between">
<div
id="code-pane"
className="h-full flex flex-col justify-between pointer-events-none"
>
<CollapsiblePanel
title="Code"
icon={faCode}

View File

@ -38,7 +38,7 @@ import {
settingsMachine,
} from './machines/settingsMachine'
import { ContextFrom } from 'xstate'
import CommandBarProvider from 'components/CommandBar'
import CommandBarProvider from 'components/CommandBar/CommandBar'
import { TEST, VITE_KC_SENTRY_DSN } from './env'
import * as Sentry from '@sentry/react'
import ModelingMachineProvider from 'components/ModelingMachineProvider'

View File

@ -4,9 +4,11 @@ import { engineCommandManager } from './lang/std/engineConnection'
import { useModelingContext } from 'hooks/useModelingContext'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { ActionButton } from 'components/ActionButton'
import usePlatform from 'hooks/usePlatform'
export const Toolbar = () => {
const { setCommandBarOpen } = useCommandsContext()
const platform = usePlatform()
const { commandBarSend } = useCommandsContext()
const { state, send, context } = useModelingContext()
const toolbarButtonsRef = useRef<HTMLUListElement>(null)
const bgClassName =
@ -177,10 +179,15 @@ export const Toolbar = () => {
<ActionButton
Element="button"
className="text-sm"
onClick={() => send('extrude intent')}
disabled={!state.can('extrude intent')}
onClick={() =>
commandBarSend({
type: 'Find and select command',
data: { name: 'Extrude', ownerMachine: 'modeling' },
})
}
disabled={!state.can('Extrude')}
title={
state.can('extrude intent')
state.can('Extrude')
? 'extrude'
: 'sketches need to be closed, or not already extruded'
}
@ -204,10 +211,10 @@ export const Toolbar = () => {
</menu>
<ActionButton
Element="button"
onClick={() => setCommandBarOpen(true)}
onClick={() => commandBarSend({ type: 'Open' })}
className="rounded-r-full pr-4 self-stretch border-energy-10 hover:border-energy-10 dark:border-chalkboard-80 bg-energy-10/50 hover:bg-energy-10 dark:bg-chalkboard-80 dark:text-energy-10"
>
K
{platform === 'darwin' ? '⌘K' : 'Ctrl+/'}
</ActionButton>
</div>
)

View File

@ -7,6 +7,7 @@ import styles from './AppHeader.module.css'
import { NetworkHealthIndicator } from './NetworkHealthIndicator'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { ActionButton } from './ActionButton'
import usePlatform from 'hooks/usePlatform'
interface AppHeaderProps extends React.PropsWithChildren {
showToolbar?: boolean
@ -22,7 +23,8 @@ export const AppHeader = ({
className = '',
enableMenu = false,
}: AppHeaderProps) => {
const { setCommandBarOpen } = useCommandsContext()
const platform = usePlatform()
const { commandBarSend } = useCommandsContext()
const { auth } = useGlobalStateContext()
const user = auth?.context?.user
@ -47,12 +49,12 @@ export const AppHeader = ({
) : (
<ActionButton
Element="button"
onClick={() => setCommandBarOpen(true)}
onClick={() => commandBarSend({ type: 'Open' })}
className="text-sm self-center flex items-center w-fit gap-3"
>
Command Palette{' '}
<kbd className="bg-energy-10/50 dark:bg-chalkboard-100 dark:text-energy-10 inline-block px-1 py-0.5 border-energy-10 dark:border-chalkboard-90">
K
{platform === 'darwin' ? '⌘K' : 'Ctrl+/'}
</kbd>
</ActionButton>
)}

View File

@ -24,13 +24,13 @@ export const PanelHeader = ({
}: CollapsiblePanelProps) => {
return (
<summary className={styles.header}>
<div className="flex gap-2 align-center items-center flex-1">
<div className="flex gap-2 items-center flex-1">
<ActionIcon
icon={icon}
className="p-1"
size="sm"
bgClassName={
'dark:!bg-chalkboard-100 group-open:bg-chalkboard-80 dark:group-open:!bg-chalkboard-90 group-open:border dark:group-open:border-chalkboard-60 rounded-sm ' +
'dark:!bg-chalkboard-100 group-open:bg-chalkboard-80 dark:group-open:!bg-chalkboard-90 border border-transparent dark:group-open:border-chalkboard-60 rounded-sm ' +
(iconClassNames?.bg || '')
}
iconClassName={
@ -60,7 +60,9 @@ export const CollapsiblePanel = ({
<details
{...props}
data-testid={detailsTestId}
className={styles.panel + ' group ' + (className || '')}
className={
styles.panel + ' pointer-events-auto group ' + (className || '')
}
>
<PanelHeader
title={title}

View File

@ -1,404 +0,0 @@
import { Combobox, Dialog, Transition } from '@headlessui/react'
import {
Dispatch,
Fragment,
SetStateAction,
createContext,
useEffect,
useRef,
useState,
} from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import Fuse from 'fuse.js'
import {
Command,
CommandArgument,
CommandArgumentOption,
} from '../lib/commands'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { CustomIcon } from './CustomIcon'
type ComboboxOption = Command | CommandArgumentOption
type CommandArgumentData = [string, any]
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)
function sortCommands(a: Command, b: Command) {
if (b.owner === 'auth') return -1
if (a.owner === 'auth') return 1
return a.name.localeCompare(b.name)
}
useEffect(() => console.log('commands updated', commands), [commands])
const addCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) =>
[...newCommands, ...prevCommands].sort(sortCommands)
)
}
const removeCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) =>
prevCommands
.filter((command) => !newCommands.includes(command))
.sort(sortCommands)
)
}
return (
<CommandsContext.Provider
value={{
commands,
addCommands,
removeCommands,
commandBarOpen,
setCommandBarOpen,
}}
>
{children}
<CommandBar />
</CommandsContext.Provider>
)
}
const CommandBar = () => {
const { commands, commandBarOpen, setCommandBarOpen } = useCommandsContext()
useHotkeys(['meta+k', 'meta+/'], () => {
if (commands?.length === 0) return
setCommandBarOpen(!commandBarOpen)
})
const [selectedCommand, setSelectedCommand] = useState<Command>()
const [commandArguments, setCommandArguments] = useState<CommandArgument[]>(
[]
)
const [commandArgumentData, setCommandArgumentData] = useState<
CommandArgumentData[]
>([])
const [commandArgumentIndex, setCommandArgumentIndex] = useState<number>(0)
function clearState() {
setCommandBarOpen(false)
setSelectedCommand(undefined)
setCommandArguments([])
setCommandArgumentData([])
setCommandArgumentIndex(0)
}
function selectCommand(command: Command) {
console.log('selecting command', command)
if (!('args' in command && command.args?.length)) {
submitCommand({ command })
} else {
setCommandArguments(command.args)
setSelectedCommand(command)
}
}
function stepBack() {
if (!selectedCommand) {
clearState()
} else {
if (commandArgumentIndex === 0) {
setSelectedCommand(undefined)
} else {
setCommandArgumentIndex((prevIndex) => Math.max(0, prevIndex - 1))
}
if (commandArgumentData.length > 0) {
setCommandArgumentData((prevData) => prevData.slice(0, -1))
}
}
}
function appendCommandArgumentData(data: { name: any }) {
const transformedData = [
commandArguments[commandArgumentIndex].name,
data.name,
]
if (commandArgumentIndex + 1 === commandArguments.length) {
submitCommand({
dataArr: [
...commandArgumentData,
transformedData,
] as CommandArgumentData[],
})
} else {
setCommandArgumentData(
(prevData) => [...prevData, transformedData] as CommandArgumentData[]
)
setCommandArgumentIndex((prevIndex) => prevIndex + 1)
}
}
function submitCommand({
command = selectedCommand,
dataArr = commandArgumentData,
}) {
console.log('submitting command', command, dataArr)
if (dataArr.length === 0) {
command?.callback()
} else {
const data = Object.fromEntries(dataArr)
console.log('submitting data', data)
command?.callback(data)
}
setCommandBarOpen(false)
}
function getDisplayValue(command: Command) {
if (
'args' in command &&
command.args &&
command.args?.length > 0 &&
'formatFunction' in command &&
command.formatFunction
) {
command.formatFunction(
command.args.map((c, i) =>
commandArgumentData[i] ? commandArgumentData[i][0] : `<${c.name}>`
)
)
}
return command.name
}
return (
<Transition.Root
show={commandBarOpen || false}
afterLeave={() => clearState()}
as={Fragment}
>
<Dialog
onClose={() => {
setCommandBarOpen(false)
}}
className="fixed inset-0 z-40 overflow-y-auto pb-4 pt-1"
>
<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"
>
<Dialog.Panel
className="relative w-full max-w-xl py-2 mx-auto border rounded shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
as="div"
>
{!(
commandArguments &&
commandArguments.length &&
selectedCommand
) ? (
<CommandComboBox
options={commands}
handleSelection={selectCommand}
stepBack={stepBack}
/>
) : (
<>
<div className="px-4 text-sm flex flex-wrap gap-2">
<p className="pr-4 flex gap-2 items-center">
{selectedCommand &&
'icon' in selectedCommand &&
selectedCommand.icon && (
<CustomIcon
name={selectedCommand.icon}
className="w-5 h-5"
/>
)}
{getDisplayValue(selectedCommand)}
</p>
{commandArguments.map((arg, i) => (
<p
key={arg.name}
className={`w-fit px-2 py-1 rounded-sm flex gap-2 items-center border ${
i === commandArgumentIndex
? 'bg-energy-10/50 dark:bg-energy-10/20 border-energy-10 dark:border-energy-10'
: 'bg-chalkboard-20/50 dark:bg-chalkboard-80/50 border-chalkboard-20 dark:border-chalkboard-80'
}`}
>
{commandArgumentIndex >= i && commandArgumentData[i] ? (
commandArgumentData[i][1]
) : arg.defaultValue ? (
arg.defaultValue
) : (
<em>{arg.name}</em>
)}
</p>
))}
</div>
<div className="block w-full my-2 h-[1px] bg-chalkboard-20 dark:bg-chalkboard-80" />
<Argument
arg={commandArguments[commandArgumentIndex]}
appendCommandArgumentData={appendCommandArgumentData}
stepBack={stepBack}
/>
</>
)}
</Dialog.Panel>
</Transition.Child>
</Dialog>
</Transition.Root>
)
}
function Argument({
arg,
appendCommandArgumentData,
stepBack,
}: {
arg: CommandArgument
appendCommandArgumentData: Dispatch<SetStateAction<any>>
stepBack: () => void
}) {
const { setCommandBarOpen } = useCommandsContext()
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [arg, inputRef])
return arg.type === 'select' ? (
<CommandComboBox
options={arg.options}
handleSelection={appendCommandArgumentData}
stepBack={stepBack}
placeholder="Select an option"
/>
) : (
<form
onSubmit={(event) => {
event.preventDefault()
appendCommandArgumentData({ name: inputRef.current?.value })
}}
>
<label className="flex items-center mx-4 my-4">
<span className="px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80">
{arg.name}
</span>
<input
ref={inputRef}
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
placeholder="Enter a value"
defaultValue={arg.defaultValue}
onKeyDown={(event) => {
if (event.metaKey && event.key === 'k') setCommandBarOpen(false)
if (event.key === 'Backspace' && !event.currentTarget.value) {
stepBack()
}
}}
autoFocus
/>
</label>
</form>
)
}
export default CommandBarProvider
function CommandComboBox({
options,
handleSelection,
stepBack,
placeholder,
}: {
options: ComboboxOption[]
handleSelection: Dispatch<SetStateAction<any>>
stepBack: () => void
placeholder?: string
}) {
const { setCommandBarOpen } = useCommandsContext()
const [query, setQuery] = useState('')
const [filteredOptions, setFilteredOptions] = useState<ComboboxOption[]>()
const defaultOption =
options.find((o) => 'isCurrent' in o && o.isCurrent) || null
const fuse = new Fuse(options, {
keys: ['name', 'description'],
threshold: 0.3,
})
useEffect(() => {
const results = fuse.search(query).map((result) => result.item)
setFilteredOptions(query.length > 0 ? results : options)
}, [query])
return (
<Combobox defaultValue={defaultOption} onChange={handleSelection}>
<div className="flex items-center gap-2 px-4 pb-2 border-solid border-0 border-b border-b-chalkboard-20 dark:border-b-chalkboard-80">
<CustomIcon
name="search"
className="w-5 h-5 bg-energy-10/50 dark:bg-chalkboard-90 dark:text-energy-10"
/>
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
className="w-full bg-transparent focus:outline-none selection:bg-energy-10/50 dark:selection:bg-energy-10/20 dark:focus:outline-none"
onKeyDown={(event) => {
if (event.metaKey && event.key === 'k') setCommandBarOpen(false)
if (event.key === 'Backspace' && !event.currentTarget.value) {
stepBack()
}
}}
placeholder={
(defaultOption && defaultOption.name) ||
placeholder ||
'Search commands'
}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
autoFocus
/>
</div>
<Combobox.Options
static
className="overflow-y-auto max-h-96 cursor-pointer"
>
{filteredOptions?.map((option) => (
<Combobox.Option
key={option.name}
value={option}
className="flex items-center gap-2 px-4 py-1 first:mt-2 last:mb-2 ui-active:bg-energy-10/50 dark:ui-active:bg-chalkboard-90"
>
{'icon' in option && option.icon && (
<CustomIcon
name={option.icon}
className="w-5 h-5 dark:text-energy-10"
/>
)}
<p className="flex-grow">{option.name} </p>
{'isCurrent' in option && option.isCurrent && (
<small className="text-chalkboard-70 dark:text-chalkboard-50">
current
</small>
)}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
)
}

View File

@ -0,0 +1,114 @@
import { Combobox } from '@headlessui/react'
import Fuse from 'fuse.js'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { CommandArgumentOption } from 'lib/commandTypes'
import { useEffect, useRef, useState } from 'react'
function CommandArgOptionInput({
options,
argName,
stepBack,
onSubmit,
placeholder,
}: {
options: CommandArgumentOption<unknown>[]
argName: string
stepBack: () => void
onSubmit: (data: unknown) => void
placeholder?: string
}) {
const { commandBarSend, commandBarState } = useCommandsContext()
const inputRef = useRef<HTMLInputElement>(null)
const formRef = useRef<HTMLFormElement>(null)
const [argValue, setArgValue] = useState<(typeof options)[number]['value']>(
options.find((o) => 'isCurrent' in o && o.isCurrent)?.value ||
commandBarState.context.argumentsToSubmit[argName] ||
options[0].value
)
const [query, setQuery] = useState('')
const [filteredOptions, setFilteredOptions] = useState<typeof options>()
const fuse = new Fuse(options, {
keys: ['name', 'description'],
threshold: 0.3,
})
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [inputRef])
useEffect(() => {
const results = fuse.search(query).map((result) => result.item)
setFilteredOptions(query.length > 0 ? results : options)
}, [query])
function handleSelectOption(option: CommandArgumentOption<unknown>) {
setArgValue(option)
onSubmit(option.value)
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
onSubmit(argValue)
}
return (
<form id="arg-form" onSubmit={handleSubmit} ref={formRef}>
<Combobox value={argValue} onChange={handleSelectOption} name="options">
<div className="flex items-center mx-4 mt-4 mb-2">
<label
htmlFor="option-input"
className="capitalize px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80"
>
{argName}
</label>
<Combobox.Input
id="option-input"
ref={inputRef}
onChange={(event) => setQuery(event.target.value)}
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
onKeyDown={(event) => {
if (event.metaKey && event.key === 'k')
commandBarSend({ type: 'Close' })
if (event.key === 'Backspace' && !event.currentTarget.value) {
stepBack()
}
}}
placeholder={
(argValue as CommandArgumentOption<unknown>)?.name ||
placeholder ||
'Select an option for ' + argName
}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
autoFocus
/>
</div>
<Combobox.Options
static
className="overflow-y-auto max-h-96 cursor-pointer"
>
{filteredOptions?.map((option) => (
<Combobox.Option
key={option.name}
value={option}
className="flex items-center gap-2 px-4 py-1 first:mt-2 last:mb-2 ui-active:bg-energy-10/50 dark:ui-active:bg-chalkboard-90"
>
<p className="flex-grow">{option.name} </p>
{'isCurrent' in option && option.isCurrent && (
<small className="text-chalkboard-70 dark:text-chalkboard-50">
current
</small>
)}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
</form>
)
}
export default CommandArgOptionInput

View File

@ -0,0 +1,166 @@
import { Dialog, Popover, Transition } from '@headlessui/react'
import { Fragment, createContext, useEffect } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { useMachine } from '@xstate/react'
import { commandBarMachine } from 'machines/commandBarMachine'
import { EventFrom, StateFrom } from 'xstate'
import CommandBarArgument from './CommandBarArgument'
import CommandComboBox from '../CommandComboBox'
import { useLocation } from 'react-router-dom'
import CommandBarReview from './CommandBarReview'
type CommandsContextType = {
commandBarState: StateFrom<typeof commandBarMachine>
commandBarSend: (event: EventFrom<typeof commandBarMachine>) => void
}
export const CommandsContext = createContext<CommandsContextType>({
commandBarState: commandBarMachine.initialState,
commandBarSend: () => {},
})
export const CommandBarProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const { pathname } = useLocation()
const [commandBarState, commandBarSend] = useMachine(commandBarMachine, {
guards: {
'Arguments are ready': (context, _) => {
return context.selectedCommand?.args
? context.argumentsToSubmit.length ===
Object.keys(context.selectedCommand.args)?.length
: false
},
'Command has no arguments': (context, _event) => {
return (
!context.selectedCommand?.args ||
Object.keys(context.selectedCommand?.args).length === 0
)
},
},
})
// Close the command bar when navigating
useEffect(() => {
commandBarSend({ type: 'Close' })
}, [pathname])
return (
<CommandsContext.Provider
value={{
commandBarState,
commandBarSend,
}}
>
{children}
<CommandBar />
</CommandsContext.Provider>
)
}
const CommandBar = () => {
const { commandBarState, commandBarSend } = useCommandsContext()
const {
context: { selectedCommand, currentArgument, commands },
} = commandBarState
const isSelectionArgument = currentArgument?.inputType === 'selection'
const WrapperComponent = isSelectionArgument ? Popover : Dialog
useHotkeys(['mod+k', 'mod+/'], () => {
if (commandBarState.context.commands.length === 0) return
if (commandBarState.matches('Closed')) {
commandBarSend({ type: 'Open' })
} else {
commandBarSend({ type: 'Close' })
}
})
function stepBack() {
if (!currentArgument) {
if (commandBarState.matches('Review')) {
const entries = Object.entries(selectedCommand?.args || {})
commandBarSend({
type: commandBarState.matches('Review')
? 'Edit argument'
: 'Change current argument',
data: {
arg: {
name: entries[entries.length - 1][0],
...entries[entries.length - 1][1],
},
},
})
} else {
commandBarSend({ type: 'Deselect command' })
}
} else {
const entries = Object.entries(selectedCommand?.args || {})
const index = entries.findIndex(
([key, _]) => key === currentArgument.name
)
if (index === 0) {
commandBarSend({ type: 'Deselect command' })
} else {
commandBarSend({
type: 'Change current argument',
data: {
arg: { name: entries[index - 1][0], ...entries[index - 1][1] },
},
})
}
}
}
return (
<Transition.Root
show={!commandBarState.matches('Closed') || false}
afterLeave={() => {
if (selectedCommand?.onCancel) selectedCommand.onCancel()
commandBarSend({ type: 'Clear' })
}}
as={Fragment}
>
<WrapperComponent
open={!commandBarState.matches('Closed') || isSelectionArgument}
onClose={() => {
commandBarSend({ type: 'Close' })
}}
className={
'fixed inset-0 z-50 overflow-y-auto pb-4 pt-1 ' +
(isSelectionArgument ? 'pointer-events-none' : '')
}
>
<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"
>
<WrapperComponent.Panel
className="relative z-50 pointer-events-auto w-full max-w-xl py-2 mx-auto border rounded shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
as="div"
>
{commandBarState.matches('Selecting command') ? (
<CommandComboBox options={commands} />
) : commandBarState.matches('Gathering arguments') ? (
<CommandBarArgument stepBack={stepBack} />
) : (
commandBarState.matches('Review') && (
<CommandBarReview stepBack={stepBack} />
)
)}
</WrapperComponent.Panel>
</Transition.Child>
</WrapperComponent>
</Transition.Root>
)
}
export default CommandBarProvider

View File

@ -0,0 +1,80 @@
import CommandArgOptionInput from './CommandArgOptionInput'
import CommandBarBasicInput from './CommandBarBasicInput'
import CommandBarSelectionInput from './CommandBarSelectionInput'
import { CommandArgument } from 'lib/commandTypes'
import { useCommandsContext } from 'hooks/useCommandsContext'
import CommandBarHeader from './CommandBarHeader'
function CommandBarArgument({ stepBack }: { stepBack: () => void }) {
const { commandBarState, commandBarSend } = useCommandsContext()
const {
context: { currentArgument },
} = commandBarState
function onSubmit(data: unknown) {
if (!currentArgument) return
commandBarSend({
type: 'Submit argument',
data: {
[currentArgument.name]:
currentArgument.inputType === 'number'
? parseFloat((data as string) || '0')
: data,
},
})
}
return (
currentArgument && (
<CommandBarHeader>
<ArgumentInput
arg={currentArgument}
stepBack={stepBack}
onSubmit={onSubmit}
/>
</CommandBarHeader>
)
)
}
export default CommandBarArgument
function ArgumentInput({
arg,
stepBack,
onSubmit,
}: {
arg: CommandArgument<unknown> & { name: string }
stepBack: () => void
onSubmit: (event: any) => void
}) {
switch (arg.inputType) {
case 'options':
return (
<CommandArgOptionInput
options={arg.options}
argName={arg.name}
stepBack={stepBack}
onSubmit={onSubmit}
placeholder="Select an option"
/>
)
case 'selection':
return (
<CommandBarSelectionInput
arg={arg}
stepBack={stepBack}
onSubmit={onSubmit}
/>
)
default:
return (
<CommandBarBasicInput
arg={arg}
stepBack={stepBack}
onSubmit={onSubmit}
/>
)
}
}

View File

@ -0,0 +1,66 @@
import { useCommandsContext } from 'hooks/useCommandsContext'
import { CommandArgument } from 'lib/commandTypes'
import { useEffect, useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
function CommandBarBasicInput({
arg,
stepBack,
onSubmit,
}: {
arg: CommandArgument<unknown> & {
inputType: 'number' | 'string'
name: string
}
stepBack: () => void
onSubmit: (event: unknown) => void
}) {
const { commandBarSend, commandBarState } = useCommandsContext()
useHotkeys('mod + k, mod + /', () => commandBarSend({ type: 'Close' }))
const inputRef = useRef<HTMLInputElement>(null)
const inputType = arg.inputType === 'number' ? 'number' : 'text'
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [arg, inputRef])
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
onSubmit(inputRef.current?.value)
}
return (
<form id="arg-form" onSubmit={handleSubmit}>
<label className="flex items-center mx-4 my-4">
<span className="capitalize px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80">
{arg.name}
</span>
<input
id="arg-form"
name={inputType}
ref={inputRef}
type={inputType}
required
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
placeholder="Enter a value"
defaultValue={
(commandBarState.context.argumentsToSubmit[arg.name] as
| string
| undefined) || (arg.defaultValue as string)
}
onKeyDown={(event) => {
if (event.key === 'Backspace' && !event.currentTarget.value) {
stepBack()
}
}}
autoFocus
/>
</label>
</form>
)
}
export default CommandBarBasicInput

View File

@ -0,0 +1,171 @@
import { useCommandsContext } from 'hooks/useCommandsContext'
import { CustomIcon } from '../CustomIcon'
import React, { useState } from 'react'
import { ActionButton } from '../ActionButton'
import { Selections, getSelectionTypeDisplayText } from 'lib/selections'
import { useHotkeys } from 'react-hotkeys-hook'
function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
const { commandBarState, commandBarSend } = useCommandsContext()
const {
context: { selectedCommand, currentArgument, argumentsToSubmit },
} = commandBarState
const isReviewing = commandBarState.matches('Review')
const [showShortcuts, setShowShortcuts] = useState(false)
useHotkeys(
'alt',
() => setShowShortcuts(true),
{ enableOnFormTags: true, enableOnContentEditable: true },
[showShortcuts]
)
useHotkeys(
'alt',
() => setShowShortcuts(false),
{ keyup: true, enableOnFormTags: true, enableOnContentEditable: true },
[showShortcuts]
)
useHotkeys(
[
'alt+1',
'alt+2',
'alt+3',
'alt+4',
'alt+5',
'alt+6',
'alt+7',
'alt+8',
'alt+9',
'alt+0',
],
(_, b) => {
if (b.keys && !Number.isNaN(parseInt(b.keys[0], 10))) {
if (!selectedCommand?.args) return
const argName = Object.keys(selectedCommand.args)[
parseInt(b.keys[0], 10) - 1
]
const arg = selectedCommand?.args[argName]
commandBarSend({
type: 'Change current argument',
data: { arg: { ...arg, name: argName } },
})
}
},
{ keyup: true, enableOnFormTags: true, enableOnContentEditable: true },
[argumentsToSubmit, selectedCommand]
)
return (
selectedCommand &&
argumentsToSubmit && (
<>
<div className="px-4 text-sm flex gap-4 items-start">
<div className="flex flex-1 flex-wrap gap-2">
<p
data-command-name={selectedCommand?.name}
className="pr-4 flex gap-2 items-center"
>
{selectedCommand &&
'icon' in selectedCommand &&
selectedCommand.icon && (
<CustomIcon name={selectedCommand.icon} className="w-5 h-5" />
)}
{selectedCommand?.name}
</p>
{Object.entries(selectedCommand?.args || {}).map(
([argName, arg], i) => (
<button
disabled={!isReviewing && currentArgument?.name === argName}
onClick={() => {
commandBarSend({
type: isReviewing
? 'Edit argument'
: 'Change current argument',
data: { arg: { ...arg, name: argName } },
})
}}
key={argName}
className={`relative w-fit px-2 py-1 rounded-sm flex gap-2 items-center border ${
argName === currentArgument?.name
? 'disabled:bg-energy-10/50 dark:disabled:bg-energy-10/20 disabled:border-energy-10 dark:disabled:border-energy-10 disabled:text-chalkboard-100 dark:disabled:text-chalkboard-10'
: 'bg-chalkboard-20/50 dark:bg-chalkboard-80/50 border-chalkboard-20 dark:border-chalkboard-80'
}`}
>
{argumentsToSubmit[argName] ? (
arg.inputType === 'selection' ? (
getSelectionTypeDisplayText(
argumentsToSubmit[argName] as Selections
)
) : typeof argumentsToSubmit[argName] === 'object' ? (
JSON.stringify(argumentsToSubmit[argName])
) : (
argumentsToSubmit[argName]
)
) : arg.payload ? (
arg.inputType === 'selection' ? (
getSelectionTypeDisplayText(arg.payload as Selections)
) : typeof arg.payload === 'object' ? (
JSON.stringify(arg.payload)
) : (
arg.payload
)
) : (
<em>{argName}</em>
)}
{showShortcuts && (
<small className="absolute -top-[1px] right-full translate-x-1/2 px-0.5 rounded-sm bg-chalkboard-80 text-chalkboard-10 dark:bg-energy-10 dark:text-chalkboard-100">
<span className="sr-only">Hotkey: </span>
{i + 1}
</small>
)}
</button>
)
)}
</div>
{isReviewing ? <ReviewingButton /> : <GatheringArgsButton />}
</div>
<div className="block w-full my-2 h-[1px] bg-chalkboard-20 dark:bg-chalkboard-80" />
{children}
</>
)
)
}
function ReviewingButton() {
return (
<ActionButton
Element="button"
autoFocus
type="submit"
form="review-form"
className="w-fit !p-0 rounded-sm border !border-chalkboard-100 dark:!border-energy-10 hover:shadow"
icon={{
icon: 'checkmark',
bgClassName:
'p-1 rounded-sm !bg-chalkboard-100 hover:!bg-chalkboard-110 dark:!bg-energy-20 dark:hover:!bg-energy-10',
iconClassName: '!text-energy-10 dark:!text-chalkboard-100',
}}
>
<span className="sr-only">Submit command</span>
</ActionButton>
)
}
function GatheringArgsButton() {
return (
<ActionButton
Element="button"
type="submit"
form="arg-form"
className="w-fit !p-0 rounded-sm"
icon={{
icon: 'arrowRight',
bgClassName: 'p-1 rounded-sm',
}}
>
<span className="sr-only">Continue</span>
</ActionButton>
)
}
export default CommandBarHeader

View File

@ -0,0 +1,81 @@
import { useCommandsContext } from 'hooks/useCommandsContext'
import CommandBarHeader from './CommandBarHeader'
import { useHotkeys } from 'react-hotkeys-hook'
function CommandBarReview({ stepBack }: { stepBack: () => void }) {
const { commandBarState, commandBarSend } = useCommandsContext()
const {
context: { argumentsToSubmit, selectedCommand },
} = commandBarState
useHotkeys('backspace', stepBack, {
enableOnFormTags: true,
enableOnContentEditable: true,
})
useHotkeys(
'1, 2, 3, 4, 5, 6, 7, 8, 9, 0',
(_, b) => {
if (b.keys && !Number.isNaN(parseInt(b.keys[0], 10))) {
if (!selectedCommand?.args) return
const argName = Object.keys(selectedCommand.args)[
parseInt(b.keys[0], 10) - 1
]
const arg = selectedCommand?.args[argName]
commandBarSend({
type: 'Edit argument',
data: { arg: { ...arg, name: argName } },
})
}
},
{ keyup: true, enableOnFormTags: true, enableOnContentEditable: true },
[argumentsToSubmit, selectedCommand]
)
Object.keys(argumentsToSubmit).forEach((key, i) => {
const arg = selectedCommand?.args ? selectedCommand?.args[key] : undefined
if (!arg) return
})
function submitCommand() {
commandBarSend({
type: 'Submit command',
data: argumentsToSubmit,
})
}
return (
<CommandBarHeader>
<p className="px-4">Confirm {selectedCommand?.name}</p>
<form
id="review-form"
className="absolute opacity-0 inset-0 pointer-events-none"
onSubmit={submitCommand}
>
{Object.entries(argumentsToSubmit).map(([key, value], i) => {
const arg = selectedCommand?.args
? selectedCommand?.args[key]
: undefined
if (!arg) return null
return (
<input
id={key}
name={key}
key={key}
type="text"
defaultValue={
typeof value === 'object'
? JSON.stringify(value)
: (value as string)
}
hidden
/>
)
})}
</form>
</CommandBarHeader>
)
}
export default CommandBarReview

View File

@ -0,0 +1,114 @@
import { useSelector } from '@xstate/react'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { useKclContext } from 'lang/KclSinglton'
import { CommandArgument } from 'lib/commandTypes'
import {
ResolvedSelectionType,
canSubmitSelectionArg,
getSelectionType,
getSelectionTypeDisplayText,
} from 'lib/selections'
import { modelingMachine } from 'machines/modelingMachine'
import { useEffect, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { StateFrom } from 'xstate'
const selectionSelector = (snapshot: StateFrom<typeof modelingMachine>) =>
snapshot.context.selectionRanges
function CommandBarSelectionInput({
arg,
stepBack,
onSubmit,
}: {
arg: CommandArgument<unknown> & { inputType: 'selection'; name: string }
stepBack: () => void
onSubmit: (data: unknown) => void
}) {
const { code } = useKclContext()
const inputRef = useRef<HTMLInputElement>(null)
const { commandBarSend } = useCommandsContext()
const [hasSubmitted, setHasSubmitted] = useState(false)
const selection = useSelector(arg.actor, selectionSelector)
const [selectionsByType, setSelectionsByType] = useState<
'none' | ResolvedSelectionType[]
>(
selection.codeBasedSelections[0]?.range[1] === code.length
? 'none'
: getSelectionType(selection)
)
const [canSubmitSelection, setCanSubmitSelection] = useState<boolean>(
canSubmitSelectionArg(selectionsByType, arg)
)
useHotkeys('tab', () => onSubmit(selection), {
enableOnFormTags: true,
enableOnContentEditable: true,
keyup: true,
})
useEffect(() => {
inputRef.current?.focus()
}, [selection, inputRef])
useEffect(() => {
setSelectionsByType(
selection.codeBasedSelections[0]?.range[1] === code.length
? 'none'
: getSelectionType(selection)
)
}, [selection])
useEffect(() => {
setCanSubmitSelection(canSubmitSelectionArg(selectionsByType, arg))
}, [selectionsByType, arg])
function handleChange() {
inputRef.current?.focus()
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!canSubmitSelection) {
setHasSubmitted(true)
return
}
onSubmit(selection)
}
return (
<form id="arg-form" onSubmit={handleSubmit}>
<label
className={
'relative flex items-center mx-4 my-4 ' +
(!hasSubmitted || canSubmitSelection || 'text-destroy-50')
}
>
{canSubmitSelection
? getSelectionTypeDisplayText(selection) + ' selected'
: `Please select ${arg.multiple ? 'one or more faces' : 'one face'}`}
<input
id="selection"
name="selection"
ref={inputRef}
required
placeholder="Select an entity with your mouse"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onKeyDown={(event) => {
if (event.key === 'Backspace') {
stepBack()
} else if (event.key === 'Escape') {
commandBarSend({ type: 'Close' })
}
}}
onChange={handleChange}
value={JSON.stringify(selection || {})}
/>
</label>
</form>
)
}
export default CommandBarSelectionInput

View File

@ -0,0 +1,90 @@
import { Combobox } from '@headlessui/react'
import Fuse from 'fuse.js'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { Command } from 'lib/commandTypes'
import { useEffect, useState } from 'react'
import { CustomIcon } from './CustomIcon'
function CommandComboBox({
options,
placeholder,
}: {
options: Command[]
placeholder?: string
}) {
const { commandBarSend } = useCommandsContext()
const [query, setQuery] = useState('')
const [filteredOptions, setFilteredOptions] = useState<typeof options>()
const defaultOption =
options.find((o) => 'isCurrent' in o && o.isCurrent) || null
const fuse = new Fuse(options, {
keys: ['name', 'description'],
threshold: 0.3,
})
useEffect(() => {
const results = fuse.search(query).map((result) => result.item)
setFilteredOptions(query.length > 0 ? results : options)
}, [query])
function handleSelection(command: Command) {
commandBarSend({ type: 'Select command', data: { command } })
}
return (
<Combobox defaultValue={defaultOption} onChange={handleSelection}>
<div className="flex items-center gap-2 px-4 pb-2 border-solid border-0 border-b border-b-chalkboard-20 dark:border-b-chalkboard-80">
<CustomIcon
name="search"
className="w-5 h-5 bg-energy-10/50 dark:bg-chalkboard-90 dark:text-energy-10"
/>
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
className="w-full bg-transparent focus:outline-none selection:bg-energy-10/50 dark:selection:bg-energy-10/20 dark:focus:outline-none"
onKeyDown={(event) => {
if (
(event.metaKey && event.key === 'k') ||
(event.key === 'Backspace' && !event.currentTarget.value)
) {
commandBarSend({ type: 'Close' })
}
}}
placeholder={
(defaultOption && defaultOption.name) ||
placeholder ||
'Search commands'
}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
autoFocus
/>
</div>
<Combobox.Options
static
className="overflow-y-auto max-h-96 cursor-pointer"
>
{filteredOptions?.map((option) => (
<Combobox.Option
key={option.name}
value={option}
className="flex items-center gap-2 px-4 py-1 first:mt-2 last:mb-2 ui-active:bg-energy-10/50 dark:ui-active:bg-chalkboard-90"
>
{'icon' in option && option.icon && (
<CustomIcon
name={option.icon}
className="w-5 h-5 dark:text-energy-10"
/>
)}
<p className="flex-grow">{option.name} </p>
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
)
}
export default CommandComboBox

View File

@ -3,6 +3,7 @@ export type CustomIconName =
| 'arrowLeft'
| 'arrowRight'
| 'arrowUp'
| 'checkmark'
| 'close'
| 'equal'
| 'extrude'
@ -90,6 +91,22 @@ export const CustomIcon = ({
/>
</svg>
)
case 'checkmark':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.29956 13.5388L13.9537 6L14.7537 6.6L8.75367 14.6L8.00012 14.6536L5 11.6536L5.70709 10.9465L8.29956 13.5388Z"
fill="currentColor"
/>
</svg>
)
case 'close':
return (
<svg

View File

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

View File

@ -40,7 +40,7 @@ export const FileMachineProvider = ({
children: React.ReactNode
}) => {
const navigate = useNavigate()
const { setCommandBarOpen } = useCommandsContext()
const { commandBarSend } = useCommandsContext()
const { project } = useRouteLoaderData(paths.FILE) as IndexLoaderData
const [state, send] = useMachine(fileMachine, {
@ -54,7 +54,7 @@ export const FileMachineProvider = ({
event: EventFrom<typeof fileMachine>
) => {
if (event.data && 'name' in event.data) {
setCommandBarOpen(false)
commandBarSend({ type: 'Close' })
navigate(
`${paths.FILE}/${encodeURIComponent(
context.selectedDirectory + sep + event.data.name

View File

@ -1,19 +1,11 @@
import { useMachine } from '@xstate/react'
import { useNavigate } from 'react-router-dom'
import { paths } from '../Router'
import {
authCommandBarConfig,
authMachine,
TOKEN_PERSIST_KEY,
} from '../machines/authMachine'
import { 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,
settingsCommandBarConfig,
settingsMachine,
} from 'machines/settingsMachine'
import { SETTINGS_PERSIST_KEY, settingsMachine } from 'machines/settingsMachine'
import { toast } from 'react-hot-toast'
import { setThemeClass, Themes } from 'lib/theme'
import {
@ -23,8 +15,9 @@ import {
Prop,
StateFrom,
} from 'xstate'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { isTauri } from 'lib/isTauri'
import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig'
import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
@ -45,7 +38,6 @@ export const GlobalStateProvider = ({
children: React.ReactNode
}) => {
const navigate = useNavigate()
const { commands } = useCommandsContext()
// Settings machine setup
const retrievedSettings = useRef(
@ -81,10 +73,9 @@ export const GlobalStateProvider = ({
})
useStateMachineCommands({
machineId: 'settings',
state: settingsState,
send: settingsSend,
commands,
owner: 'settings',
commandBarConfig: settingsCommandBarConfig,
})
@ -121,11 +112,10 @@ export const GlobalStateProvider = ({
})
useStateMachineCommands({
machineId: 'auth',
state: authState,
send: authSend,
commands,
commandBarConfig: authCommandBarConfig,
owner: 'auth',
})
return (

View File

@ -15,23 +15,23 @@ const Loading = ({ children }: React.PropsWithChildren) => {
data-testid="loading"
>
<svg viewBox="0 0 10 10" className="w-8 h-8">
<circle cx="5" cy="5" r="4" stroke="var(--liquid-20)" fill="none" />
<circle cx="5" cy="5" r="4" stroke="var(--energy-50)" fill="none" />
<circle
cx="5"
cy="5"
r="4"
stroke="var(--liquid-10)"
stroke="var(--energy-10)"
fill="none"
strokeDasharray="4, 4"
className="animate-spin origin-center"
/>
</svg>
<p className="text-base mt-4 text-liquid-80 dark:text-liquid-20">
<p className="text-base mt-4 text-energy-80 dark:text-energy-30">
{children || 'Loading'}
</p>
<p
className={
'text-sm mt-4 text-liquid-90 dark:text-liquid-10 transition-opacity duration-500' +
'text-sm mt-4 text-energy-70 dark:text-energy-50 transition-opacity duration-500' +
(hasLongLoadTime ? ' opacity-100' : ' opacity-0')
}
>

View File

@ -29,19 +29,26 @@ import {
addNewSketchLn,
compareVec2Epsilon,
} from 'lang/std/sketch'
import { kclManager } from 'lang/KclSinglton'
import { kclManager, useKclContext } from 'lang/KclSinglton'
import { applyConstraintHorzVertDistance } from './Toolbar/SetHorzVertDistance'
import {
angleBetweenInfo,
applyConstraintAngleBetween,
} from './Toolbar/SetAngleBetween'
import { applyConstraintAngleLength } from './Toolbar/setAngleLength'
import { toast } from 'react-hot-toast'
import { pathMapToSelections } from 'lang/util'
import { useStore } from 'useStore'
import { handleSelectionBatch, handleSelectionWithShift } from 'lib/selections'
import {
canExtrudeSelection,
handleSelectionBatch,
handleSelectionWithShift,
isSelectionLastLine,
isSketchPipe,
} from 'lib/selections'
import { applyConstraintIntersect } from './Toolbar/Intersect'
import { applyConstraintAbsDistance } from './Toolbar/SetAbsDistance'
import useStateMachineCommands from 'hooks/useStateMachineCommands'
import { modelingMachineConfig } from 'lib/commandBarConfigs/modelingCommandConfig'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
@ -59,6 +66,7 @@ export const ModelingMachineProvider = ({
children: React.ReactNode
}) => {
const { auth } = useGlobalStateContext()
const { code } = useKclContext()
const token = auth?.context?.token
const streamRef = useRef<HTMLDivElement>(null)
useSetupEngineManager(streamRef, token)
@ -68,8 +76,6 @@ export const ModelingMachineProvider = ({
editorView: s.editorView,
}))
// const { commands } = useCommandsContext()
// Settings machine setup
// const retrievedSettings = useRef(
// localStorage?.getItem(MODELING_PERSIST_KEY) || '{}'
@ -83,148 +89,85 @@ export const ModelingMachineProvider = ({
// >
// )
const [modelingState, modelingSend] = useMachine(modelingMachine, {
// context: persistedSettings,
actions: {
'Modify AST': () => {},
'Update code selection cursors': () => {},
'show default planes': () => {
kclManager.showPlanes()
},
'create path': assign({
sketchEnginePathId: () => {
const sketchUuid = uuidv4()
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: sketchUuid,
cmd: {
type: 'start_path',
},
})
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'edit_mode_enter',
target: sketchUuid,
},
})
return sketchUuid
const [modelingState, modelingSend, modelingActor] = useMachine(
modelingMachine,
{
// context: persistedSettings,
actions: {
'Modify AST': () => {},
'Update code selection cursors': () => {},
'show default planes': () => {
kclManager.showPlanes()
},
}),
'AST start new sketch': assign(
({ sketchEnginePathId }, { data: { coords, axis, segmentId } }) => {
if (!axis) {
// Something really weird must have happened for this to happen.
console.error('axis is undefined for starting a new sketch')
return {}
}
if (!segmentId) {
// Something really weird must have happened for this to happen.
console.error('segmentId is undefined for starting a new sketch')
return {}
}
const _addStartSketch = addStartSketch(
kclManager.ast,
axis,
[roundOff(coords[0].x), roundOff(coords[0].y)],
[
roundOff(coords[1].x - coords[0].x),
roundOff(coords[1].y - coords[0].y),
]
)
const _modifiedAst = _addStartSketch.modifiedAst
const _pathToNode = _addStartSketch.pathToNode
const newCode = recast(_modifiedAst)
const astWithUpdatedSource = parse(newCode)
const updatedPipeNode = getNodeFromPath<PipeExpression>(
astWithUpdatedSource,
_pathToNode
).node
const startProfileAtCallExp = updatedPipeNode.body.find(
(exp) =>
exp.type === 'CallExpression' &&
exp.callee.name === 'startProfileAt'
)
if (startProfileAtCallExp)
engineCommandManager.artifactMap[sketchEnginePathId] = {
type: 'result',
range: [startProfileAtCallExp.start, startProfileAtCallExp.end],
commandType: 'start_path',
data: null,
raw: {} as any,
'create path': assign({
sketchEnginePathId: () => {
const sketchUuid = uuidv4()
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: sketchUuid,
cmd: {
type: 'start_path',
},
})
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'edit_mode_enter',
target: sketchUuid,
},
})
return sketchUuid
},
}),
'AST start new sketch': assign(
({ sketchEnginePathId }, { data: { coords, axis, segmentId } }) => {
if (!axis) {
// Something really weird must have happened for this to happen.
console.error('axis is undefined for starting a new sketch')
return {}
}
const lineCallExp = updatedPipeNode.body.find(
(exp) => exp.type === 'CallExpression' && exp.callee.name === 'line'
)
if (lineCallExp)
engineCommandManager.artifactMap[segmentId] = {
type: 'result',
range: [lineCallExp.start, lineCallExp.end],
commandType: 'extend_path',
parentId: sketchEnginePathId,
data: null,
raw: {} as any,
if (!segmentId) {
// Something really weird must have happened for this to happen.
console.error('segmentId is undefined for starting a new sketch')
return {}
}
kclManager.executeAstMock(astWithUpdatedSource, true)
return {
sketchPathToNode: _pathToNode,
}
}
),
'AST add line segment': async (
{ sketchPathToNode, sketchEnginePathId },
{ data: { coords, segmentId } }
) => {
if (!sketchPathToNode) return
const lastCoord = coords[coords.length - 1]
const pathInfo = await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'path_get_info',
path_id: sketchEnginePathId,
},
})
const firstSegment = pathInfo?.data?.data?.segments.find(
(seg: any) => seg.command === 'line_to'
)
const firstSegCoords = await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'curve_get_control_points',
curve_id: firstSegment.command_id,
},
})
const startPathCoord = firstSegCoords?.data?.data?.control_points[0]
const isClose = compareVec2Epsilon(
[startPathCoord.x, startPathCoord.y],
[lastCoord.x, lastCoord.y]
)
let _modifiedAst: Program
if (!isClose) {
const newSketchLn = addNewSketchLn({
node: kclManager.ast,
programMemory: kclManager.programMemory,
to: [lastCoord.x, lastCoord.y],
from: [coords[0].x, coords[0].y],
fnName: 'line',
pathToNode: sketchPathToNode,
})
const _modifiedAst = newSketchLn.modifiedAst
kclManager.executeAstMock(_modifiedAst, true).then(() => {
const lineCallExp = getNodeFromPath<CallExpression>(
const _addStartSketch = addStartSketch(
kclManager.ast,
newSketchLn.pathToNode
axis,
[roundOff(coords[0].x), roundOff(coords[0].y)],
[
roundOff(coords[1].x - coords[0].x),
roundOff(coords[1].y - coords[0].y),
]
)
const _modifiedAst = _addStartSketch.modifiedAst
const _pathToNode = _addStartSketch.pathToNode
const newCode = recast(_modifiedAst)
const astWithUpdatedSource = parse(newCode)
const updatedPipeNode = getNodeFromPath<PipeExpression>(
astWithUpdatedSource,
_pathToNode
).node
if (segmentId)
const startProfileAtCallExp = updatedPipeNode.body.find(
(exp) =>
exp.type === 'CallExpression' &&
exp.callee.name === 'startProfileAt'
)
if (startProfileAtCallExp)
engineCommandManager.artifactMap[sketchEnginePathId] = {
type: 'result',
range: [startProfileAtCallExp.start, startProfileAtCallExp.end],
commandType: 'start_path',
data: null,
raw: {} as any,
}
const lineCallExp = updatedPipeNode.body.find(
(exp) =>
exp.type === 'CallExpression' && exp.callee.name === 'line'
)
if (lineCallExp)
engineCommandManager.artifactMap[segmentId] = {
type: 'result',
range: [lineCallExp.start, lineCallExp.end],
@ -233,120 +176,189 @@ export const ModelingMachineProvider = ({
data: null,
raw: {} as any,
}
})
} else {
_modifiedAst = addCloseToPipe({
node: kclManager.ast,
programMemory: kclManager.programMemory,
pathToNode: sketchPathToNode,
})
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'edit_mode_exit' },
})
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'default_camera_disable_sketch_mode' },
})
kclManager.executeAstMock(_modifiedAst, true)
// updateAst(_modifiedAst, true)
}
},
'sketch exit execute': () => {
kclManager.executeAst()
},
'set tool': () => {}, // TODO
'toast extrude failed': () => {
toast.error(
'Extrude failed, sketches need to be closed, or not already extruded'
)
},
'Set selection': assign(({ selectionRanges }, event) => {
if (event.type !== 'Set selection') return {} // this was needed for ts after adding 'Set selection' action to on done modal events
const setSelections = event.data
if (!editorView) return {}
if (setSelections.selectionType === 'mirrorCodeMirrorSelections')
return { selectionRanges: setSelections.selection }
else if (setSelections.selectionType === 'otherSelection') {
// TODO KittyCAD/engine/issues/1620: send axis highlight when it's working (if that's what we settle on)
// const axisAddCmd: EngineCommand = {
// type: 'modeling_cmd_req',
// cmd: {
// type: 'highlight_set_entities',
// entities: [
// setSelections.selection === 'x-axis'
// ? X_AXIS_UUID
// : Y_AXIS_UUID,
// ],
// },
// cmd_id: uuidv4(),
// }
// if (!isShiftDown) {
// engineCommandManager
// .sendSceneCommand({
// type: 'modeling_cmd_req',
// cmd: {
// type: 'select_clear',
// },
// cmd_id: uuidv4(),
// })
// .then(() => {
// engineCommandManager.sendSceneCommand(axisAddCmd)
// })
// } else {
// engineCommandManager.sendSceneCommand(axisAddCmd)
// }
kclManager.executeAstMock(astWithUpdatedSource, true)
const {
codeMirrorSelection,
selectionRangeTypeMap,
otherSelections,
} = handleSelectionWithShift({
otherSelection: setSelections.selection,
currentSelections: selectionRanges,
isShiftDown,
})
setTimeout(() => {
editorView.dispatch({
selection: codeMirrorSelection,
})
})
return {
selectionRangeTypeMap,
selectionRanges: {
codeBasedSelections: selectionRanges.codeBasedSelections,
otherSelections,
},
return {
sketchPathToNode: _pathToNode,
}
}
} else if (setSelections.selectionType === 'singleCodeCursor') {
// This DOES NOT set the `selectionRanges` in xstate context
// instead it updates/dispatches to the editor, which in turn updates the xstate context
// I've found this the best way to deal with the editor without causing an infinite loop
// and really we want the editor to be in charge of cursor positions and for `selectionRanges` mirror it
// because we want to respect the user manually placing the cursor too.
),
'AST add line segment': async (
{ sketchPathToNode, sketchEnginePathId },
{ data: { coords, segmentId } }
) => {
if (!sketchPathToNode) return
const lastCoord = coords[coords.length - 1]
// for more details on how selections see `src/lib/selections.ts`.
const {
codeMirrorSelection,
selectionRangeTypeMap,
otherSelections,
} = handleSelectionWithShift({
codeSelection: setSelections.selection,
currentSelections: selectionRanges,
isShiftDown,
const pathInfo = await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'path_get_info',
path_id: sketchEnginePathId,
},
})
if (codeMirrorSelection) {
const firstSegment = pathInfo?.data?.data?.segments.find(
(seg: any) => seg.command === 'line_to'
)
const firstSegCoords = await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'curve_get_control_points',
curve_id: firstSegment.command_id,
},
})
const startPathCoord = firstSegCoords?.data?.data?.control_points[0]
const isClose = compareVec2Epsilon(
[startPathCoord.x, startPathCoord.y],
[lastCoord.x, lastCoord.y]
)
let _modifiedAst: Program
if (!isClose) {
const newSketchLn = addNewSketchLn({
node: kclManager.ast,
programMemory: kclManager.programMemory,
to: [lastCoord.x, lastCoord.y],
from: [coords[0].x, coords[0].y],
fnName: 'line',
pathToNode: sketchPathToNode,
})
const _modifiedAst = newSketchLn.modifiedAst
kclManager.executeAstMock(_modifiedAst, true).then(() => {
const lineCallExp = getNodeFromPath<CallExpression>(
kclManager.ast,
newSketchLn.pathToNode
).node
if (segmentId)
engineCommandManager.artifactMap[segmentId] = {
type: 'result',
range: [lineCallExp.start, lineCallExp.end],
commandType: 'extend_path',
parentId: sketchEnginePathId,
data: null,
raw: {} as any,
}
})
} else {
_modifiedAst = addCloseToPipe({
node: kclManager.ast,
programMemory: kclManager.programMemory,
pathToNode: sketchPathToNode,
})
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'edit_mode_exit' },
})
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'default_camera_disable_sketch_mode' },
})
kclManager.executeAstMock(_modifiedAst, true)
// updateAst(_modifiedAst, true)
}
},
'sketch exit execute': () => {
kclManager.executeAst()
},
'set tool': () => {}, // TODO
'Set selection': assign(({ selectionRanges }, event) => {
if (event.type !== 'Set selection') return {} // this was needed for ts after adding 'Set selection' action to on done modal events
const setSelections = event.data
if (!editorView) return {}
if (setSelections.selectionType === 'mirrorCodeMirrorSelections')
return { selectionRanges: setSelections.selection }
else if (setSelections.selectionType === 'otherSelection') {
// TODO KittyCAD/engine/issues/1620: send axis highlight when it's working (if that's what we settle on)
// const axisAddCmd: EngineCommand = {
// type: 'modeling_cmd_req',
// cmd: {
// type: 'highlight_set_entities',
// entities: [
// setSelections.selection === 'x-axis'
// ? X_AXIS_UUID
// : Y_AXIS_UUID,
// ],
// },
// cmd_id: uuidv4(),
// }
// if (!isShiftDown) {
// engineCommandManager
// .sendSceneCommand({
// type: 'modeling_cmd_req',
// cmd: {
// type: 'select_clear',
// },
// cmd_id: uuidv4(),
// })
// .then(() => {
// engineCommandManager.sendSceneCommand(axisAddCmd)
// })
// } else {
// engineCommandManager.sendSceneCommand(axisAddCmd)
// }
const {
codeMirrorSelection,
selectionRangeTypeMap,
otherSelections,
} = handleSelectionWithShift({
otherSelection: setSelections.selection,
currentSelections: selectionRanges,
isShiftDown,
})
setTimeout(() => {
editorView.dispatch({
selection: codeMirrorSelection,
})
})
}
if (!setSelections.selection) {
return {
selectionRangeTypeMap,
selectionRanges: {
codeBasedSelections: selectionRanges.codeBasedSelections,
otherSelections,
},
}
} else if (setSelections.selectionType === 'singleCodeCursor') {
// This DOES NOT set the `selectionRanges` in xstate context
// instead it updates/dispatches to the editor, which in turn updates the xstate context
// I've found this the best way to deal with the editor without causing an infinite loop
// and really we want the editor to be in charge of cursor positions and for `selectionRanges` mirror it
// because we want to respect the user manually placing the cursor too.
// for more details on how selections see `src/lib/selections.ts`.
const {
codeMirrorSelection,
selectionRangeTypeMap,
otherSelections,
} = handleSelectionWithShift({
codeSelection: setSelections.selection,
currentSelections: selectionRanges,
isShiftDown,
})
if (codeMirrorSelection) {
setTimeout(() => {
editorView.dispatch({
selection: codeMirrorSelection,
})
})
}
if (!setSelections.selection) {
return {
selectionRangeTypeMap,
selectionRanges: {
codeBasedSelections: selectionRanges.codeBasedSelections,
otherSelections,
},
}
}
return {
selectionRangeTypeMap,
selectionRanges: {
@ -355,171 +367,180 @@ export const ModelingMachineProvider = ({
},
}
}
// This DOES NOT set the `selectionRanges` in xstate context
// same as comment above
const { codeMirrorSelection, selectionRangeTypeMap } =
handleSelectionBatch({
selections: setSelections.selection,
})
if (codeMirrorSelection) {
setTimeout(() => {
editorView.dispatch({
selection: codeMirrorSelection,
})
})
}
return { selectionRangeTypeMap }
}),
},
guards: {
'Selection contains axis': () => true,
'Selection contains edge': () => true,
'Selection contains face': () => true,
'Selection contains line': () => true,
'Selection contains point': () => true,
'Selection is not empty': () => true,
'has valid extrude selection': ({ selectionRanges }) => {
// A user can begin extruding if they either have 1+ faces selected or nothing selected
// TODO: I believe this guard only allows for extruding a single face at a time
if (selectionRanges.codeBasedSelections.length < 1) return false
const isPipe = isSketchPipe(selectionRanges)
if (isSelectionLastLine(selectionRanges, code)) return true
if (!isPipe) return false
return canExtrudeSelection(selectionRanges)
},
'Selection is one face': ({ selectionRanges }) => {
return !!isCursorInSketchCommandRange(
engineCommandManager.artifactMap,
selectionRanges
)
},
},
services: {
'Get horizontal info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } =
await applyConstraintHorzVertDistance({
constraint: 'setHorzDistance',
selectionRanges,
})
await kclManager.updateAst(modifiedAst, true)
return {
selectionRangeTypeMap,
selectionRanges: {
codeBasedSelections: selectionRanges.codeBasedSelections,
otherSelections,
},
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
}
// This DOES NOT set the `selectionRanges` in xstate context
// same as comment above
const { codeMirrorSelection, selectionRangeTypeMap } =
handleSelectionBatch({
selections: setSelections.selection,
})
if (codeMirrorSelection) {
setTimeout(() => {
editorView.dispatch({
selection: codeMirrorSelection,
})
})
}
return { selectionRangeTypeMap }
}),
},
guards: {
'Selection contains axis': () => true,
'Selection contains edge': () => true,
'Selection contains face': () => true,
'Selection contains line': () => true,
'Selection contains point': () => true,
'Selection is not empty': () => true,
'Selection is one face': ({ selectionRanges }) => {
return !!isCursorInSketchCommandRange(
engineCommandManager.artifactMap,
selectionRanges
)
},
},
services: {
'Get horizontal info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } =
await applyConstraintHorzVertDistance({
constraint: 'setHorzDistance',
selectionRanges,
})
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get vertical info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } =
await applyConstraintHorzVertDistance({
constraint: 'setVertDistance',
selectionRanges,
})
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get angle info': async ({ selectionRanges }): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = await (angleBetweenInfo({
},
'Get vertical info': async ({
selectionRanges,
}).enabled
? applyConstraintAngleBetween({
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } =
await applyConstraintHorzVertDistance({
constraint: 'setVertDistance',
selectionRanges,
})
: applyConstraintAngleLength({
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
angleOrLength: 'setAngle',
}))
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get length info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = await applyConstraintAngleLength(
{ selectionRanges }
)
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get perpendicular distance info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect({
pathToNodeMap
),
}
},
'Get angle info': async ({
selectionRanges,
})
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get ABS X info': async ({ selectionRanges }): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = await applyConstraintAbsDistance(
{
constraint: 'xAbs',
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = await (angleBetweenInfo({
selectionRanges,
}).enabled
? applyConstraintAngleBetween({
selectionRanges,
})
: applyConstraintAngleLength({
selectionRanges,
angleOrLength: 'setAngle',
}))
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
)
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get ABS Y info': async ({ selectionRanges }): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = await applyConstraintAbsDistance(
{
constraint: 'yAbs',
selectionRanges,
},
'Get length info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } =
await applyConstraintAngleLength({ selectionRanges })
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
)
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get perpendicular distance info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect(
{
selectionRanges,
}
)
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get ABS X info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } =
await applyConstraintAbsDistance({
constraint: 'xAbs',
selectionRanges,
})
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get ABS Y info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } =
await applyConstraintAbsDistance({
constraint: 'yAbs',
selectionRanges,
})
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
},
},
devTools: true,
})
devTools: true,
}
)
useEffect(() => {
engineCommandManager.onPlaneSelected((plane_id: string) => {
@ -538,13 +559,17 @@ export const ModelingMachineProvider = ({
})
}, [modelingSend])
// useStateMachineCommands({
// state: settingsState,
// send: settingsSend,
// commands,
// owner: 'settings',
// commandBarMeta: settingsCommandBarMeta,
// })
useStateMachineCommands({
machineId: 'modeling',
state: modelingState,
send: modelingSend,
actor: modelingActor,
commandBarConfig: modelingMachineConfig,
onCancel: () => {
console.log('firing onCancel!!')
modelingSend({ type: 'Cancel' })
},
})
return (
<ModelingMachineContext.Provider

View File

@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { GlobalStateProvider } from './GlobalStateProvider'
import CommandBarProvider from './CommandBar'
import CommandBarProvider from './CommandBar/CommandBar'
import {
NETWORK_CONTENT,
NetworkHealthIndicator,

View File

@ -1,8 +1,4 @@
import {
faCheck,
faExclamation,
faWifi,
} from '@fortawesome/free-solid-svg-icons'
import { faExclamation, faWifi } from '@fortawesome/free-solid-svg-icons'
import { Popover } from '@headlessui/react'
import { useEffect, useState } from 'react'
import { ActionIcon } from './ActionIcon'
@ -77,8 +73,8 @@ export const NetworkHealthIndicator = () => {
data-testid="network-good"
>
<ActionIcon
icon={faCheck}
bgClassName={'bg-succeed-10/50 dark:bg-succeed-80/50 rounded'}
icon="checkmark"
bgClassName={'bg-succeed-10/50 dark:bg-succeed-80/50 rounded-sm'}
iconClassName={'text-succeed-80 dark:text-succeed-30'}
/>
{NETWORK_CONTENT.good}

View File

@ -143,7 +143,7 @@ function ProjectCard({
className: 'p-1',
size: 'xs',
bgClassName: 'bg-destroy-80',
iconClassName: 'text-destroy-20 dark:text-destroy-40',
iconClassName: '!text-destroy-20 dark:!text-destroy-40',
}}
className="!p-0 hover:border-destroy-40 dark:hover:border-destroy-40"
onClick={(e) => {
@ -185,8 +185,7 @@ function ProjectCard({
bgClassName: 'bg-destroy-80',
className: 'p-1',
size: 'sm',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10',
iconClassName: '!text-destroy-70 dark:!text-destroy-40',
}}
className="hover:border-destroy-40 dark:hover:border-destroy-40"
>

View File

@ -3,7 +3,7 @@ import { BrowserRouter } from 'react-router-dom'
import ProjectSidebarMenu from './ProjectSidebarMenu'
import { ProjectWithEntryPointMetadata } from '../Router'
import { GlobalStateProvider } from './GlobalStateProvider'
import CommandBarProvider from './CommandBar'
import CommandBarProvider from './CommandBar/CommandBar'
const now = new Date()
const projectWellFormed = {

View File

@ -243,15 +243,14 @@ export const Stream = ({ className = '' }) => {
})
} else if (
!didDragInStream &&
(state.matches('Sketch.SketchIdle') ||
state.matches('idle') ||
state.matches('awaiting selection'))
(state.matches('Sketch.SketchIdle') || state.matches('idle'))
) {
command.cmd = {
type: 'select_with_point',
selected_at_window: { x, y },
selection_type: 'add',
}
engineCommandManager.sendSceneCommand(command)
} else if (!didDragInStream && state.matches('Sketch.Move Tool')) {
command.cmd = {

View File

@ -64,7 +64,7 @@ export const TextEditor = ({
const { settings: { context: { textWrapping } = {} } = {} } =
useGlobalStateContext()
const { setCommandBarOpen } = useCommandsContext()
const { commandBarSend } = useCommandsContext()
const { enable: convertEnabled, handleClick: convertCallback } =
useConvertToVariable()
@ -136,7 +136,7 @@ export const TextEditor = ({
{
key: 'Meta-k',
run: () => {
setCommandBarOpen(true)
commandBarSend({ type: 'Open' })
return false
},
},

View File

@ -8,7 +8,7 @@ import {
} from 'react-router-dom'
import { Models } from '@kittycad/lib'
import { GlobalStateProvider } from './GlobalStateProvider'
import CommandBarProvider from './CommandBar'
import CommandBarProvider from './CommandBar/CommandBar'
type User = Models['User_type']

View File

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

27
src/hooks/usePlatform.ts Normal file
View File

@ -0,0 +1,27 @@
import { Platform, platform } from '@tauri-apps/api/os'
import { isTauri } from 'lib/isTauri'
import { useEffect, useState } from 'react'
export default function usePlatform() {
const [platformName, setPlatformName] = useState<Platform | ''>('')
useEffect(() => {
async function getPlatform() {
setPlatformName(await platform())
}
if (isTauri()) {
void getPlatform()
} else {
if (navigator.userAgent.indexOf('Mac') !== -1) {
setPlatformName('darwin')
} else if (navigator.userAgent.indexOf('Win') !== -1) {
setPlatformName('win32')
} else if (navigator.userAgent.indexOf('Linux') !== -1) {
setPlatformName('linux')
}
}
}, [setPlatformName])
return platformName
}

View File

@ -1,46 +1,68 @@
import { useEffect } from 'react'
import { AnyStateMachine, StateFrom } from 'xstate'
import {
Command,
CommandBarConfig,
createMachineCommand,
} from '../lib/commands'
import { AnyStateMachine, InterpreterFrom, StateFrom } from 'xstate'
import { createMachineCommand } from '../lib/createMachineCommand'
import { useCommandsContext } from './useCommandsContext'
import { modelingMachine } from 'machines/modelingMachine'
import { authMachine } from 'machines/authMachine'
import { settingsMachine } from 'machines/settingsMachine'
import { homeMachine } from 'machines/homeMachine'
import { Command, CommandSetConfig, CommandSetSchema } from 'lib/commandTypes'
interface UseStateMachineCommandsArgs<T extends AnyStateMachine> {
// This might not be necessary, AnyStateMachine from xstate is working
export type AllMachines =
| typeof modelingMachine
| typeof settingsMachine
| typeof authMachine
| typeof homeMachine
interface UseStateMachineCommandsArgs<
T extends AllMachines,
S extends CommandSetSchema<T>
> {
machineId: T['id']
state: StateFrom<T>
send: Function
commandBarConfig?: CommandBarConfig<T>
commands: Command[]
owner: string
actor?: InterpreterFrom<T>
commandBarConfig?: CommandSetConfig<T, S>
onCancel?: () => void
}
export default function useStateMachineCommands<T extends AnyStateMachine>({
export default function useStateMachineCommands<
T extends AnyStateMachine,
S extends CommandSetSchema<T>
>({
machineId,
state,
send,
actor,
commandBarConfig,
owner,
}: UseStateMachineCommandsArgs<T>) {
const { addCommands, removeCommands } = useCommandsContext()
onCancel,
}: UseStateMachineCommandsArgs<T, S>) {
const { commandBarSend } = useCommandsContext()
useEffect(() => {
const newCommands = state.nextEvents
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
.map((type) =>
createMachineCommand<T>({
createMachineCommand<T, S>({
ownerMachine: machineId,
type,
state,
send,
actor,
commandBarConfig,
owner,
onCancel,
})
)
.filter((c) => c !== null) as Command[] // TS isn't smart enough to know this filter removes nulls
addCommands(newCommands)
commandBarSend({ type: 'Add commands', data: { commands: newCommands } })
return () => {
removeCommands(newCommands)
commandBarSend({
type: 'Remove commands',
data: { commands: newCommands },
})
}
}, [state])
}

View File

@ -57,7 +57,7 @@ select {
}
button {
@apply border border-chalkboard-30 m-0.5 px-3 rounded text-xs;
@apply border border-chalkboard-30 m-0.5 px-3 rounded text-xs focus-visible:ring-energy-10;
}
button:hover {
@ -65,7 +65,7 @@ button:hover {
}
.dark button {
@apply border-chalkboard-70;
@apply border-chalkboard-70 focus-visible:ring-energy-10/50;
}
.dark button:hover {
@ -88,6 +88,14 @@ a:not(.action-button) {
@apply text-chalkboard-20 hover:text-energy-10;
}
input {
@apply selection:bg-energy-10/50;
}
.dark input {
@apply selection:bg-energy-10/40;
}
.mono {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;

View File

@ -248,7 +248,8 @@ export function mutateObjExpProp(
export function extrudeSketch(
node: Program,
pathToNode: PathToNode,
shouldPipe = true
shouldPipe = true,
distance = 4
): {
modifiedAst: Program
pathToNode: PathToNode
@ -274,7 +275,7 @@ export function extrudeSketch(
getNodeFromPath<VariableDeclarator>(_node, pathToNode, 'VariableDeclarator')
const extrudeCall = createCallExpressionStdLib('extrude', [
createLiteral(4),
createLiteral(distance),
shouldPipe
? createPipeSubstitution()
: {

View File

@ -0,0 +1,17 @@
import { CommandSetConfig } from 'lib/commandTypes'
import { authMachine } from 'machines/authMachine'
type AuthCommandSchema = {}
export const authCommandBarConfig: CommandSetConfig<
typeof authMachine,
AuthCommandSchema
> = {
'Log in': {
hide: 'both',
},
'Log out': {
args: [],
icon: 'arrowLeft',
},
}

View File

@ -0,0 +1,87 @@
import { CommandSetConfig } from 'lib/commandTypes'
import { homeMachine } from 'machines/homeMachine'
export type HomeCommandSchema = {
'Create project': {
name: string
}
'Open project': {
name: string
}
'Delete project': {
name: string
}
'Rename project': {
oldName: string
newName: string
}
}
export const homeCommandBarConfig: CommandSetConfig<
typeof homeMachine,
HomeCommandSchema
> = {
'Open project': {
icon: 'arrowRight',
description: 'Open a project',
args: {
name: {
inputType: 'options',
required: true,
options: (context) =>
context.projects.map((p) => ({
name: p.name!,
value: p.name!,
})),
},
},
},
'Create project': {
icon: 'folderPlus',
description: 'Create a project',
args: {
name: {
inputType: 'string',
required: true,
defaultValue: (context) => context.defaultProjectName,
},
},
},
'Delete project': {
icon: 'close',
description: 'Delete a project',
needsReview: true,
args: {
name: {
inputType: 'options',
required: true,
options: (context) =>
context.projects.map((p) => ({
name: p.name!,
value: p.name!,
})),
},
},
},
'Rename project': {
icon: 'folder',
description: 'Rename a project',
needsReview: true,
args: {
oldName: {
inputType: 'options',
required: true,
options: (context) =>
context.projects.map((p) => ({
name: p.name!,
value: p.name!,
})),
},
newName: {
inputType: 'string',
required: true,
defaultValue: (context) => context.defaultProjectName,
},
},
},
}

View File

@ -0,0 +1,57 @@
import { CommandSetConfig } from 'lib/commandTypes'
import { Selections } from 'lib/selections'
import { modelingMachine } from 'machines/modelingMachine'
export const EXTRUSION_RESULTS = [
'new',
'add',
'subtract',
'intersect',
] as const
export type ModelingCommandSchema = {
'Enter sketch': {}
Extrude: {
selection: Selections // & { type: 'face' } would be cool to lock that down
// result: (typeof EXTRUSION_RESULTS)[number]
distance: number
}
}
export const modelingMachineConfig: CommandSetConfig<
typeof modelingMachine,
ModelingCommandSchema
> = {
'Enter sketch': {
description: 'Enter sketch mode.',
icon: 'sketch',
},
Extrude: {
description: 'Pull a sketch into 3D along its normal or perpendicular.',
icon: 'extrude',
needsReview: true,
args: {
selection: {
inputType: 'selection',
selectionTypes: ['face'],
multiple: false, // TODO: multiple selection
required: true,
},
// result: {
// inputType: 'options',
// payload: 'add',
// required: true,
// options: EXTRUSION_RESULTS.map((r) => ({
// name: r,
// isCurrent: r === 'add',
// value: r,
// })),
// },
distance: {
inputType: 'number',
defaultValue: 5,
required: true,
},
},
},
}

View File

@ -0,0 +1,141 @@
import { CommandSetConfig } from '../commandTypes'
import {
BaseUnit,
Toggle,
UnitSystem,
baseUnitsUnion,
settingsMachine,
} from 'machines/settingsMachine'
import { CameraSystem, cameraSystems } from '../cameraControls'
import { Themes } from '../theme'
// SETTINGS MACHINE
export type SettingsCommandSchema = {
'Set Base Unit': {
baseUnit: BaseUnit
}
'Set Camera Controls': {
cameraControls: CameraSystem
}
'Set Default Project Name': {
defaultProjectName: string
}
'Set Text Wrapping': {
textWrapping: Toggle
}
'Set Theme': {
theme: Themes
}
'Set Unit System': {
unitSystem: UnitSystem
}
}
export const settingsCommandBarConfig: CommandSetConfig<
typeof settingsMachine,
SettingsCommandSchema
> = {
'Set Base Unit': {
icon: 'gear',
args: {
baseUnit: {
inputType: 'options',
required: true,
defaultValue: (context) => context.baseUnit,
options: (context) =>
Object.values(baseUnitsUnion).map((v) => ({
name: v,
value: v,
isCurrent: v === context.baseUnit,
})),
},
},
},
'Set Camera Controls': {
icon: 'gear',
args: {
cameraControls: {
inputType: 'options',
required: true,
defaultValue: (context) => context.cameraControls,
options: (context) =>
Object.values(cameraSystems).map((v) => ({
name: v,
value: v,
isCurrent: v === context.cameraControls,
})),
},
},
},
'Set Default Project Name': {
icon: 'gear',
hide: 'web',
args: {
defaultProjectName: {
inputType: 'string',
required: true,
defaultValue: (context) => context.defaultProjectName,
},
},
},
'Set Text Wrapping': {
icon: 'gear',
args: {
textWrapping: {
inputType: 'options',
required: true,
defaultValue: (context) => context.textWrapping,
options: (context) => [
{
name: 'On',
value: 'On' as Toggle,
isCurrent: context.textWrapping === 'On',
},
{
name: 'Off',
value: 'Off' as Toggle,
isCurrent: context.textWrapping === 'Off',
},
],
},
},
},
'Set Theme': {
icon: 'gear',
args: {
theme: {
inputType: 'options',
required: true,
defaultValue: (context) => context.theme,
options: (context) =>
Object.values(Themes).map((v) => ({
name: v,
value: v,
isCurrent: v === context.theme,
})),
},
},
},
'Set Unit System': {
icon: 'gear',
args: {
unitSystem: {
inputType: 'options',
required: true,
defaultValue: (context) => context.unitSystem,
options: (context) => [
{
name: 'Imperial',
value: 'imperial' as UnitSystem,
isCurrent: context.unitSystem === 'imperial',
},
{
name: 'Metric',
value: 'metric' as UnitSystem,
isCurrent: context.unitSystem === 'metric',
},
],
},
},
},
}

136
src/lib/commandTypes.ts Normal file
View File

@ -0,0 +1,136 @@
import { CustomIconName } from 'components/CustomIcon'
import { AllMachines } from 'hooks/useStateMachineCommands'
import {
AnyStateMachine,
ContextFrom,
EventFrom,
InterpreterFrom,
} from 'xstate'
import { Selection } from './selections'
type Icon = CustomIconName
const PLATFORMS = ['both', 'web', 'desktop'] as const
const INPUT_TYPES = ['options', 'string', 'number', 'selection'] as const
export type CommandInputType = (typeof INPUT_TYPES)[number]
export type CommandSetSchema<T extends AnyStateMachine> = Partial<{
[EventType in EventFrom<T>['type']]: Record<string, any>
}>
export type CommandSet<
T extends AllMachines,
Schema extends CommandSetSchema<T>
> = Partial<{
[EventType in EventFrom<T>['type']]: Command<
T,
EventFrom<T>['type'],
Schema[EventType]
>
}>
export type CommandSetConfig<
T extends AllMachines,
Schema extends CommandSetSchema<T>
> = Partial<{
[EventType in EventFrom<T>['type']]: CommandConfig<
T,
EventFrom<T>['type'],
Schema[EventType]
>
}>
export type Command<
T extends AnyStateMachine = AnyStateMachine,
CommandName extends EventFrom<T>['type'] = EventFrom<T>['type'],
CommandSchema extends CommandSetSchema<T>[CommandName] = CommandSetSchema<T>[CommandName]
> = {
name: CommandName
ownerMachine: T['id']
needsReview: boolean
onSubmit: (data?: CommandSchema) => void
onCancel?: () => void
args?: {
[ArgName in keyof CommandSchema]: CommandArgument<CommandSchema[ArgName], T>
}
description?: string
icon?: Icon
hide?: (typeof PLATFORMS)[number]
}
export type CommandConfig<
T extends AnyStateMachine = AnyStateMachine,
CommandName extends EventFrom<T>['type'] = EventFrom<T>['type'],
CommandSchema extends CommandSetSchema<T>[CommandName] = CommandSetSchema<T>[CommandName]
> = Omit<
Command<T, CommandName, CommandSchema>,
'name' | 'ownerMachine' | 'onSubmit' | 'onCancel' | 'args' | 'needsReview'
> & {
needsReview?: true
args?: {
[ArgName in keyof CommandSchema]: CommandArgumentConfig<
CommandSchema[ArgName],
T
>
}
}
export type CommandArgumentConfig<
OutputType,
T extends AnyStateMachine = AnyStateMachine
> =
| {
description?: string
required: boolean
skip?: true
defaultValue?: OutputType | ((context: ContextFrom<T>) => OutputType)
payload?: OutputType
} & (
| {
inputType: Extract<CommandInputType, 'options'>
options:
| CommandArgumentOption<OutputType>[]
| ((context: ContextFrom<T>) => CommandArgumentOption<OutputType>[])
}
| {
inputType: Extract<CommandInputType, 'selection'>
selectionTypes: Selection['type'][]
multiple: boolean
}
| { inputType: Exclude<CommandInputType, 'options' | 'selection'> }
)
export type CommandArgument<
OutputType,
T extends AnyStateMachine = AnyStateMachine
> =
| {
description?: string
required: boolean
payload?: OutputType // Payload sets the initialized value and more importantly its type
defaultValue?: OutputType // Default value is used as the starting value for the input on this argument
} & (
| {
inputType: Extract<CommandInputType, 'options'>
options: CommandArgumentOption<OutputType>[]
}
| {
inputType: Extract<CommandInputType, 'selection'>
selectionTypes: Selection['type'][]
actor: InterpreterFrom<T>
multiple: boolean
}
| { inputType: Exclude<CommandInputType, 'options' | 'selection'> }
)
export type CommandArgumentWithName<
OutputType,
T extends AnyStateMachine = AnyStateMachine
> = CommandArgument<OutputType, T> & {
name: string
}
export type CommandArgumentOption<A> = {
name: string
isCurrent?: boolean
value: A
}

View File

@ -1,177 +0,0 @@
import { AnyStateMachine, ContextFrom, EventFrom, StateFrom } from 'xstate'
import { isTauri } from './isTauri'
import { CustomIconName } from 'components/CustomIcon'
type Icon = CustomIconName
type Platform = 'both' | 'web' | 'desktop'
type InputType = 'select' | 'string' | 'interaction'
export type CommandArgumentOption = { name: string; isCurrent?: boolean }
// Command arguments can either be defined manually
// or flagged as needing to be looked up from the context.
// This is useful for things like settings, where
// we want to show the current setting value as the default.
// The lookup is done in createMachineCommand.
type CommandArgumentConfig<T extends AnyStateMachine> = {
name: string // TODO: I would love for this to be strongly-typed so we could guarantee it's a valid data payload key on the event type.
type: InputType
description?: string
} & (
| {
type: 'select'
options?: CommandArgumentOption[]
getOptionsFromContext?: keyof ContextFrom<T>
defaultValue?: string
getDefaultValueFromContext?: keyof ContextFrom<T>
}
| {
type: 'string'
defaultValue?: string
getDefaultValueFromContext?: keyof ContextFrom<T>
}
| { type: 'interaction' }
)
export type CommandBarConfig<T extends AnyStateMachine> = Partial<{
[EventType in EventFrom<T>['type']]:
| {
args: CommandArgumentConfig<T>[]
formatFunction?: (args: string[]) => string
icon?: Icon
hide?: Platform
}
| {
hide?: Platform
}
}>
export type Command = {
owner: string
name: string
callback: Function
icon?: Icon
args?: CommandArgument[]
formatFunction?: (args: string[]) => string
}
export type CommandArgument = {
name: string
defaultValue?: string
} & (
| {
type: Extract<InputType, 'select'>
options: CommandArgumentOption[]
}
| {
type: Exclude<InputType, 'select'>
}
)
interface CreateMachineCommandProps<T extends AnyStateMachine> {
type: EventFrom<T>['type']
state: StateFrom<T>
commandBarConfig?: CommandBarConfig<T>
send: Function
owner: string
}
// Creates a command with subcommands, ready for use in the CommandBar component,
// from a more terse Command Bar Meta definition.
export function createMachineCommand<T extends AnyStateMachine>({
type,
state,
commandBarConfig,
send,
owner,
}: CreateMachineCommandProps<T>): Command | null {
const lookedUpMeta = commandBarConfig && commandBarConfig[type]
if (!lookedUpMeta) return null
// Hide commands based on platform by returning `null`
// so the consumer can filter them out
if ('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
}
const icon = ('icon' in lookedUpMeta && lookedUpMeta.icon) || undefined
const formatFunction =
('formatFunction' in lookedUpMeta && lookedUpMeta.formatFunction) ||
undefined
return {
name: type,
owner,
icon,
callback: (data: EventFrom<T, typeof type>) => {
if (data !== undefined && data !== null) {
send(type, { data })
} else {
send(type)
}
},
...('args' in lookedUpMeta
? {
args: getCommandArgumentValuesFromContext(state, lookedUpMeta.args),
formatFunction,
}
: {}),
}
}
function getCommandArgumentValuesFromContext<T extends AnyStateMachine>(
state: StateFrom<T>,
args: CommandArgumentConfig<T>[]
): CommandArgument[] {
function getDefaultValue(
arg: CommandArgumentConfig<T> & { type: 'string' | 'select' }
) {
if (
arg.type === 'select' ||
('getDefaultValueFromContext' in arg && arg.getDefaultValueFromContext)
) {
return state.context[arg.getDefaultValueFromContext]
} else {
return arg.defaultValue
}
}
return args.map((arg) => {
switch (arg.type) {
case 'interaction':
return {
name: arg.name,
type: 'interaction',
}
case 'string':
return {
name: arg.name,
type: arg.type,
defaultValue: arg.getDefaultValueFromContext
? state.context[arg.getDefaultValueFromContext]
: arg.defaultValue,
}
default:
return {
name: arg.name,
type: arg.type,
defaultValue: getDefaultValue(arg),
options: arg.getOptionsFromContext
? state.context[arg.getOptionsFromContext].map(
(v: string | { name: string }) => ({
name: typeof v === 'string' ? v : v.name,
isCurrent: v === getDefaultValue(arg),
})
)
: arg.getDefaultValueFromContext
? arg.options?.map((v) => ({
...v,
isCurrent: v.name === getDefaultValue(arg),
}))
: arg.options,
}
}
})
}

View File

@ -0,0 +1,158 @@
import { AnyStateMachine, EventFrom, InterpreterFrom, StateFrom } from 'xstate'
import { isTauri } from './isTauri'
import {
Command,
CommandArgument,
CommandArgumentConfig,
CommandConfig,
CommandSetConfig,
CommandSetSchema,
} from './commandTypes'
interface CreateMachineCommandProps<
T extends AnyStateMachine,
S extends CommandSetSchema<T>
> {
type: EventFrom<T>['type']
ownerMachine: T['id']
state: StateFrom<T>
send: Function
actor?: InterpreterFrom<T>
commandBarConfig?: CommandSetConfig<T, S>
onCancel?: () => void
}
// Creates a command with subcommands, ready for use in the CommandBar component,
// from a more terse Command Bar Meta definition.
export function createMachineCommand<
T extends AnyStateMachine,
S extends CommandSetSchema<T>
>({
ownerMachine,
type,
state,
send,
actor,
commandBarConfig,
onCancel,
}: CreateMachineCommandProps<T, S>): Command<
T,
typeof type,
S[typeof type]
> | null {
const commandConfig = commandBarConfig && commandBarConfig[type]
if (!commandConfig) return null
// Hide commands based on platform by returning `null`
// so the consumer can filter them out
if ('hide' in commandConfig) {
const { hide } = commandConfig
if (hide === 'both') return null
else if (hide === 'desktop' && isTauri()) return null
else if (hide === 'web' && !isTauri()) return null
}
const icon = ('icon' in commandConfig && commandConfig.icon) || undefined
const command: Command<T, typeof type, S[typeof type]> = {
name: type,
ownerMachine: ownerMachine,
icon,
needsReview: commandConfig.needsReview || false,
onSubmit: (data?: S[typeof type]) => {
if (data !== undefined && data !== null) {
send(type, { data })
} else {
send(type)
}
},
}
if (commandConfig.args) {
const newArgs = buildCommandArguments(state, commandConfig.args, actor)
command.args = newArgs
}
if (onCancel) {
command.onCancel = onCancel
}
return command
}
// Takes the args from a CommandConfig and creates
// a finalized CommandArgument object for each one,
// bundled together into the args for a Command.
function buildCommandArguments<
T extends AnyStateMachine,
S extends CommandSetSchema<T>,
CommandName extends EventFrom<T>['type'] = EventFrom<T>['type']
>(
state: StateFrom<T>,
args: CommandConfig<T, CommandName, S>['args'],
actor?: InterpreterFrom<T>
): NonNullable<Command<T, CommandName, S>['args']> {
const newArgs = {} as NonNullable<Command<T, CommandName, S>['args']>
for (const arg in args) {
const argConfig = args[arg] as CommandArgumentConfig<S[typeof arg], T>
const newArg = buildCommandArgument(argConfig, state, actor)
newArgs[arg] = newArg
}
return newArgs
}
function buildCommandArgument<
O extends CommandSetSchema<T>,
T extends AnyStateMachine
>(
arg: CommandArgumentConfig<O, T>,
state: StateFrom<T>,
actor?: InterpreterFrom<T>
): CommandArgument<O, T> & { inputType: typeof arg.inputType } {
const baseCommandArgument = {
description: arg.description,
required: arg.required,
payload: arg.payload,
defaultValue:
arg.defaultValue instanceof Function
? arg.defaultValue(state.context)
: arg.defaultValue,
} satisfies Omit<CommandArgument<O, T>, 'inputType'>
if (arg.inputType === 'options') {
const options = arg.options
? arg.options instanceof Function
? arg.options(state.context)
: arg.options
: undefined
if (!options) {
throw new Error('Options must be provided for options input type')
}
return {
inputType: arg.inputType,
...baseCommandArgument,
options,
} satisfies CommandArgument<O, T> & { inputType: 'options' }
} else if (arg.inputType === 'selection') {
if (!actor)
throw new Error('Actor must be provided for selection input type')
return {
inputType: arg.inputType,
...baseCommandArgument,
multiple: arg.multiple,
selectionTypes: arg.selectionTypes,
actor,
} satisfies CommandArgument<O, T> & { inputType: 'selection' }
} else {
return {
inputType: arg.inputType,
...baseCommandArgument,
}
}
}

View File

@ -7,6 +7,10 @@ import { EditorSelection } from '@codemirror/state'
import { kclManager } from 'lang/KclSinglton'
import { SelectionRange } from '@uiw/react-codemirror'
import { isOverlap } from 'lib/utils'
import { isCursorInSketchCommandRange } from 'lang/util'
import { Program } from 'lang/wasm'
import { doesPipeHaveCallExp } from 'lang/queryAst'
import { CommandArgument } from './commandTypes'
export const X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b'
export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01'
@ -371,3 +375,128 @@ function resetAndSetEngineEntitySelectionCmds(
},
]
}
export function isSketchPipe(selectionRanges: Selections) {
return isCursorInSketchCommandRange(
engineCommandManager.artifactMap,
selectionRanges
)
}
export function isSelectionLastLine(
selectionRanges: Selections,
code: string,
i = 0
) {
return selectionRanges.codeBasedSelections[i].range[1] === code.length
}
export type CommonASTNode = {
selection: Selection
ast: Program
}
export function buildCommonNodeFromSelection(
selectionRanges: Selections,
i: number
) {
return {
selection: selectionRanges.codeBasedSelections[i],
ast: kclManager.ast,
}
}
export function nodeHasExtrude(node: CommonASTNode) {
return doesPipeHaveCallExp({
calleeName: 'extrude',
...node,
})
}
export function nodeHasClose(node: CommonASTNode) {
return doesPipeHaveCallExp({
calleeName: 'close',
...node,
})
}
export function canExtrudeSelection(selection: Selections) {
const commonNodes = selection.codeBasedSelections.map((_, i) =>
buildCommonNodeFromSelection(selection, i)
)
return (
!!isSketchPipe(selection) &&
commonNodes.every((n) => nodeHasClose(n)) &&
commonNodes.every((n) => !nodeHasExtrude(n))
)
}
export function canExtrudeSelectionItem(selection: Selections, i: number) {
const commonNode = buildCommonNodeFromSelection(selection, i)
return (
!!isSketchPipe(selection) &&
nodeHasClose(commonNode) &&
!nodeHasExtrude(commonNode)
)
}
// This accounts for non-geometry selections under "other"
export type ResolvedSelectionType = [Selection['type'] | 'other', number]
/**
* In the future, I'd like this function to properly return the type of each selected entity based on
* its code source range, so that we can show something like "0 objects" or "1 face" or "1 line, 2 edges",
* and then validate the selection in CommandBarSelectionInput.tsx and show the proper label.
* @param selection
* @returns
*/
export function getSelectionType(
selection: Selections
): ResolvedSelectionType[] {
return selection.codeBasedSelections
.map((s, i) => {
if (canExtrudeSelectionItem(selection, i)) {
return ['face', 1] as ResolvedSelectionType // This is implicitly determining what a face is, which is bad
} else {
return ['other', 1] as ResolvedSelectionType
}
})
.reduce((acc, [type, count]) => {
const foundIndex = acc.findIndex((item) => item && item[0] === type)
if (foundIndex === -1) {
return [...acc, [type, count]]
} else {
const temp = [...acc]
temp[foundIndex][1] += count
return temp
}
}, [] as ResolvedSelectionType[])
}
export function getSelectionTypeDisplayText(
selection: Selections
): string | null {
const selectionsByType = getSelectionType(selection)
return (selectionsByType as Exclude<typeof selectionsByType, 'none'>)
.map(([type, count]) => `${count} ${type}${count > 1 ? 's' : ''}`)
.join(', ')
}
export function canSubmitSelectionArg(
selectionsByType: 'none' | ResolvedSelectionType[],
argument: CommandArgument<unknown> & { inputType: 'selection' }
) {
return (
selectionsByType !== 'none' &&
selectionsByType.every(([type, count]) => {
const foundIndex = argument.selectionTypes.findIndex((s) => s === type)
return (
foundIndex !== -1 &&
(!argument.multiple ? count < 2 && count > 0 : count > 0)
)
})
)
}

View File

@ -1,7 +1,6 @@
import { createMachine, assign } from 'xstate'
import { Models } from '@kittycad/lib'
import withBaseURL from '../lib/withBaseURL'
import { CommandBarConfig } from '../lib/commands'
import { isTauri } from 'lib/isTauri'
import { invoke } from '@tauri-apps/api'
import { VITE_KC_API_BASE_URL } from 'env'
@ -40,16 +39,6 @@ export type Events =
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
export const authCommandBarConfig: CommandBarConfig<typeof authMachine> = {
'Log in': {
hide: 'both',
},
'Log out': {
args: [],
icon: 'arrowLeft',
},
}
export const authMachine = createMachine<UserContext, Events>(
{
id: 'Auth',

View File

@ -0,0 +1,425 @@
import { assign, createMachine } from 'xstate'
import {
Command,
CommandArgument,
CommandArgumentWithName,
} from 'lib/commandTypes'
import { Selections } from 'lib/selections'
export const commandBarMachine = createMachine(
{
/** @xstate-layout N4IgpgJg5mDOIC5QGED2BbdBDAdhABAEJYBOAxMgDaqxgDaADALqKgAONAlgC6eo6sQAD0QBaAJwA6AGwAmAKwBmBoukAWafIAcDcSoA0IAJ6JZDaZIDs8hgzV6AjA61a1DWQF8PhtJlwFiciowUkYWJBAOWB4+AQiRBFF5CwdpcVkHS1lpVyU5QxNEh1lFGTUsrUUtOQd5SwZLLx8MbDwiUkkqGkgyAHk2MBwwwSiY-kEEswdJNWcNbPFLNMr5AsRFWUtJcVSdRR2VVMUmkF9WgI6u2ggyADFONv98WkowAGNufDeW-2GI0d443iiHESkktUUG1klTsSi0awQDgYWhmqkWjnUslBWhOZyegU61GuZAAghACN8-HhYH92FxAXFQAlRA5FpJ5FjFPYtNDpIpLOkEW4GNs4eJxJoGvJxOVcT82gSrj0AEpgdCoABuYC+8ogNOYI3psQmYlkGUkU2slhc6mkmUUCIyKLc4i0lkU8iUyjUHLlVIuJEkAGUwK8Pg8oDr-WQQ2HPpTzrTIkagUzEDkLHZkQK1CUtKCHAiNlsdjkVAdFEc-ed2oGAOJYbgACzAJAj+FIUAAruhBtxYGQACJwUPveO6pMA43AhA5UqWXOzBjS-nOR3LySqXNODTyZwONTV-GXXXPUcfHqTlOM4TrSublb5-lLewlIVySRaOrmGw-jLSI8FRPf0zzjS8HHCOlogZE1ERUEUSgPa05yQ1ZjEQeZP2-VwDzUN1zEabxTlPAkG2bVt207Hs+wHZAm1wGAvi7EgSD7DsSG7XscG4K9oOnNNEVUeQZGSOxc0qaQ7HhdDBJFeRc35extGkNI+UAgNJDIls2xwSMqK4-tJBJAB3LAYl0-AHjYLtuBjLsACN0B4djOL7XixhvBIDxUT8qj5VIkTwtQHRk8oUQcD15LMXQclcdTa00xttMojjqO42BJAANSwShOAgRsIzICB+DASQHg1VAAGtSo1HK8sbMASVSgz3JgmdMnKGYzRlbIdGXA8EX8zd3RsTY9yyY4iLxID6ySiiLP0misrq-LeF0shWxIVBAzYShGwAM229BJFq3LVsa5q3INf5r1gg9JIfXqqh0TQsiFTYrCyJZRpsXJ4oJVUNU4MBjLsxznITX5rqgjzYMsaZtGXaVNhcZFKgRd0RXUdJ6mUXQXX+jpAeB0GyQIRbuNa-jbwQcVSmhAUeTkWQ3HyGSBVKDQMyRAKAsJwNiZBshVXVLUXLSnjoeTPjUxpnmpFtcVs1cPNBq5T8fR5DrKwaHEppIomwCBoWAFEIGcinJcg6XYZnTRhJsbQJU9VlQTVtQNbqcVZhsSFxH5zoWzeSr2ya1z0qKkqypwCrqpOlaGrDiX9WtqdZYSeSEeXEplCceo1xkvQLGCmUIp0Mwck8fWQMVIOQ4spODIHYqcFK8qqpqhPuAu8P+zoCDDRlzzTCkCVWWlSxrBceTygRFxZHZNJbE2VQ5AFAO6PeevI0bmiNpY7bJF2g6jvjs7E8u9KqfTxAkK2fZYuRvdYUGjQZnqYVxRKdx-ZOHBUAgHAQQ00AyD1tgJUQCwLQMEyHUG0Gh7SOmmDuGB6gOTyVBMkDeRJIBgLahA4oFo8zWlSPUT00o0KFA9NMYKrhKwbH2GaQ81cawEljGOdskM8B4OpgkWY9MV5ZjqLUN6MlqGojoRFEo6QWYb1PC8McuCbpD1gosLYrhkTZCxNIawhYxEfW0HhCKKgzT8lBAHLS809KX37Dwm+iIWZSEinjTRy51BFnvNofM7hGZfgXBYuaOlrG9wyiZMya1IxWRsnY4eDi2TONsK45IaghQbEkO4VIKlsaVE2AE8iQTxZN2WufCJMS7o6JSBKNQLp7CT35EKZw2wnDVDyCvBczDmg10NsbYyZS7aZHBNU58q8sTQgxnud+kVsjyQzHrTprDLh11DjY+AyjwFy00DQ2EaQBSLAPLIDGWRNw2BUiXHkwVCJeCAA */
context: {
commands: [] as Command[],
selectedCommand: undefined as Command | undefined,
currentArgument: undefined as
| (CommandArgument<unknown> & { name: string })
| undefined,
selectionRanges: {
otherSelections: [],
codeBasedSelections: [],
} as Selections,
argumentsToSubmit: {} as { [x: string]: unknown },
},
id: 'Command Bar',
initial: 'Closed',
states: {
Closed: {
on: {
Open: {
target: 'Selecting command',
},
'Find and select command': {
target: 'Command selected',
actions: [
'Find and select command',
'Initialize arguments to submit',
],
},
'Add commands': {
target: 'Closed',
actions: [
assign({
commands: (context, event) =>
[...context.commands, ...event.data.commands].sort(
sortCommands
),
}),
],
internal: true,
},
'Remove commands': {
target: 'Closed',
actions: [
assign({
commands: (context, event) =>
context.commands.filter(
(c) =>
!event.data.commands.some(
(c2) =>
c2.name === c.name &&
c2.ownerMachine === c.ownerMachine
)
),
}),
],
internal: true,
},
},
},
'Selecting command': {
on: {
'Select command': {
target: 'Command selected',
actions: ['Set selected command', 'Initialize arguments to submit'],
},
},
},
'Command selected': {
always: [
{
target: 'Closed',
cond: 'Command has no arguments',
actions: ['Execute command'],
},
{
target: 'Gathering arguments',
actions: [
assign({
currentArgument: (context, event) => {
const { selectedCommand } = context
if (!(selectedCommand && selectedCommand.args))
return undefined
const argName = Object.keys(selectedCommand.args)[0]
return {
...selectedCommand.args[argName],
name: argName,
}
},
}),
],
},
],
},
'Gathering arguments': {
states: {
'Awaiting input': {
on: {
'Submit argument': {
target: 'Validating',
},
},
},
Validating: {
invoke: {
src: 'Validate argument',
id: 'validateArgument',
onDone: {
target: '#Command Bar.Checking Arguments',
actions: [
assign({
argumentsToSubmit: (context, event) => {
const [argName, argData] = Object.entries(event.data)[0]
const { currentArgument } = context
if (!currentArgument) return {}
return {
...context.argumentsToSubmit,
[argName]: argData,
}
},
}),
],
},
onError: [
{
target: 'Awaiting input',
},
],
},
},
},
initial: 'Awaiting input',
on: {
'Change current argument': {
target: 'Gathering arguments',
internal: true,
actions: ['Set current argument'],
},
'Deselect command': {
target: 'Selecting command',
actions: [
assign({
selectedCommand: (_c, _e) => undefined,
}),
],
},
},
},
Review: {
entry: ['Clear current argument'],
on: {
'Submit command': {
target: 'Closed',
actions: ['Execute command'],
},
'Add argument': {
target: 'Gathering arguments',
actions: ['Set current argument'],
},
'Remove argument': {
target: 'Review',
actions: [
assign({
argumentsToSubmit: (context, event) => {
const argName = Object.keys(event.data)[0]
const { argumentsToSubmit } = context
const newArgumentsToSubmit = { ...argumentsToSubmit }
newArgumentsToSubmit[argName] = undefined
return newArgumentsToSubmit
},
}),
],
},
'Edit argument': {
target: 'Gathering arguments',
actions: ['Set current argument'],
},
},
},
'Checking Arguments': {
invoke: {
src: 'Validate all arguments',
id: 'validateArguments',
onDone: [
{
target: 'Review',
cond: 'Command needs review',
},
{
target: 'Closed',
actions: 'Execute command',
},
],
onError: [
{
target: 'Gathering arguments',
actions: ['Set current argument'],
},
],
},
},
},
on: {
Close: {
target: '.Closed',
},
Clear: {
target: '#Command Bar',
internal: true,
actions: ['Clear argument data'],
},
},
schema: {
events: {} as
| { type: 'Open' }
| { type: 'Close' }
| { type: 'Clear' }
| {
type: 'Select command'
data: { command: Command }
}
| { type: 'Deselect command' }
| { type: 'Submit command'; data: { [x: string]: unknown } }
| {
type: 'Add argument'
data: { argument: CommandArgumentWithName<unknown> }
}
| {
type: 'Remove argument'
data: { [x: string]: CommandArgumentWithName<unknown> }
}
| {
type: 'Edit argument'
data: { arg: CommandArgumentWithName<unknown> }
}
| {
type: 'Add commands'
data: { commands: Command[] }
}
| {
type: 'Remove commands'
data: { commands: Command[] }
}
| { type: 'Submit argument'; data: { [x: string]: unknown } }
| {
type: 'done.invoke.validateArguments'
data: { [x: string]: unknown }
}
| {
type: 'error.platform.validateArguments'
data: { message: string; arg: CommandArgumentWithName<unknown> }
}
| {
type: 'Find and select command'
data: { name: string; ownerMachine: string }
}
| {
type: 'Change current argument'
data: { arg: CommandArgumentWithName<unknown> }
},
},
predictableActionArguments: true,
preserveActionOrder: true,
},
{
actions: {
'Execute command': (context, event) => {
const { selectedCommand } = context
if (!selectedCommand) return
if (selectedCommand?.args) {
selectedCommand?.onSubmit(
event.type === 'Submit command' ||
event.type === 'done.invoke.validateArguments'
? event.data
: undefined
)
} else {
selectedCommand?.onSubmit()
}
},
'Clear current argument': assign({
currentArgument: undefined,
}),
'Set current argument': assign({
currentArgument: (context, event) => {
switch (event.type) {
case 'error.platform.validateArguments':
return event.data.arg
case 'Edit argument':
return event.data.arg
case 'Change current argument':
return event.data.arg
default:
return context.currentArgument
}
},
}),
'Clear argument data': assign({
selectedCommand: undefined,
currentArgument: undefined,
argumentsToSubmit: {},
}),
'Set selected command': assign({
selectedCommand: (c, e) =>
e.type === 'Select command' ? e.data.command : c.selectedCommand,
}),
'Find and select command': assign({
selectedCommand: (c, e) => {
if (e.type !== 'Find and select command') return c.selectedCommand
const found = c.commands.find(
(cmd) =>
cmd.name === e.data.name &&
cmd.ownerMachine === e.data.ownerMachine
)
return !!found ? found : c.selectedCommand
},
}),
'Initialize arguments to submit': assign({
argumentsToSubmit: (c, e) => {
if (
e.type !== 'Select command' &&
e.type !== 'Find and select command'
)
return c.argumentsToSubmit
const command =
'command' in e.data ? e.data.command : c.selectedCommand!
if (!command.args) return {}
const args: { [x: string]: unknown } = {}
for (const [argName, arg] of Object.entries(command.args)) {
args[argName] = arg.payload
}
return args
},
}),
},
guards: {
'Command needs review': (context, _) =>
context.selectedCommand?.needsReview || false,
},
services: {
'Validate argument': (context, event) => {
if (event.type !== 'Submit argument') return Promise.reject()
return new Promise((resolve, reject) => {
// TODO: figure out if we should validate argument data here or in the form itself,
// and if we should support people configuring a argument's validation function
resolve(event.data)
})
},
'Validate all arguments': (context, _) => {
return new Promise((resolve, reject) => {
for (const [argName, arg] of Object.entries(
context.argumentsToSubmit
)) {
let argConfig = context.selectedCommand!.args![argName]
if (
typeof arg !== typeof argConfig.payload &&
typeof arg !== typeof argConfig.defaultValue &&
'options' in argConfig &&
typeof arg !== typeof argConfig.options[0].value
) {
return reject({
message: 'Argument payload is of the wrong type',
arg: {
...argConfig,
name: argName,
},
})
}
if (!arg && argConfig.required) {
return reject({
message: 'Argument payload is falsy but is required',
arg: {
...argConfig,
name: argName,
},
})
}
}
return resolve(context.argumentsToSubmit)
})
},
},
delays: {},
}
)
function sortCommands(a: Command, b: Command) {
if (b.ownerMachine === 'auth') return -1
if (a.ownerMachine === 'auth') return 1
return a.name.localeCompare(b.name)
}

View File

@ -0,0 +1,74 @@
// This file was automatically generated. Edits will be overwritten
export interface Typegen0 {
'@@xstate/typegen': true
internalEvents: {
'': { type: '' }
'done.invoke.validateArgument': {
type: 'done.invoke.validateArgument'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'done.invoke.validateArguments': {
type: 'done.invoke.validateArguments'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'error.platform.validateArgument': {
type: 'error.platform.validateArgument'
data: unknown
}
'error.platform.validateArguments': {
type: 'error.platform.validateArguments'
data: unknown
}
'xstate.init': { type: 'xstate.init' }
}
invokeSrcNameMap: {
'Validate all arguments': 'done.invoke.validateArguments'
'Validate argument': 'done.invoke.validateArgument'
}
missingImplementations: {
actions:
| 'Add arguments'
| 'Close dialog'
| 'Execute command'
| 'Open dialog'
delays: never
guards: never
services: never
}
eventsCausingActions: {
'Add arguments': 'done.invoke.validateArguments'
'Add commands': 'Add commands'
'Close dialog': 'Close'
'Execute command': '' | 'Submit'
'Open dialog': 'Open'
'Remove argument': 'Remove argument'
'Remove commands': 'Remove commands'
'Set current argument':
| 'Add argument'
| 'Edit argument'
| 'error.platform.validateArguments'
}
eventsCausingDelays: {}
eventsCausingGuards: {
'Arguments are ready': 'done.invoke.validateArguments'
'Command has no arguments': ''
}
eventsCausingServices: {
'Validate all arguments': 'done.invoke.validateArgument'
'Validate argument': 'Submit'
}
matchesStates:
| 'Checking Arguments'
| 'Closed'
| 'Command selected'
| 'Gathering arguments'
| 'Gathering arguments.Awaiting input'
| 'Gathering arguments.Validating'
| 'Review'
| 'Selecting command'
| { 'Gathering arguments'?: 'Awaiting input' | 'Validating' }
tags: never
}

View File

@ -1,56 +1,6 @@
import { assign, createMachine } from 'xstate'
import { ProjectWithEntryPointMetadata } from '../Router'
import { CommandBarConfig } from '../lib/commands'
export const homeCommandConfig: CommandBarConfig<typeof homeMachine> = {
'Create project': {
icon: 'folderPlus',
args: [
{
name: 'name',
type: 'string',
getDefaultValueFromContext: 'defaultProjectName',
},
],
},
'Open project': {
icon: 'arrowRight',
args: [
{
name: 'name',
type: 'select',
getOptionsFromContext: 'projects',
},
],
},
'Delete project': {
icon: 'close',
args: [
{
name: 'name',
type: 'select',
getOptionsFromContext: 'projects',
},
],
},
'Rename project': {
icon: 'folder',
formatFunction: (args: string[]) =>
`Rename project "${args[0]}" to "${args[1]}"`,
args: [
{
name: 'oldName',
type: 'select',
getOptionsFromContext: 'projects',
},
{
name: 'newName',
type: 'string',
getDefaultValueFromContext: 'defaultProjectName',
},
],
},
}
import { HomeCommandSchema } from 'lib/commandBarConfigs/homeCommandConfig'
export const homeMachine = createMachine(
{
@ -188,10 +138,10 @@ export const homeMachine = createMachine(
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: 'Open project'; data: HomeCommandSchema['Open project'] }
| { type: 'Rename project'; data: HomeCommandSchema['Rename project'] }
| { type: 'Create project'; data: HomeCommandSchema['Create project'] }
| { type: 'Delete project'; data: HomeCommandSchema['Delete project'] }
| { type: 'navigate'; data: { name: string } }
| {
type: 'done.invoke.read-projects'

File diff suppressed because one or more lines are too long

View File

@ -32,14 +32,14 @@
"Get vertical info": "done.invoke.get-vertical-info";
};
missingImplementations: {
actions: "AST add line segment" | "AST start new sketch" | "Modify AST" | "Set selection" | "Update code selection cursors" | "create path" | "set tool" | "show default planes" | "sketch exit execute" | "toast extrude failed";
actions: "AST add line segment" | "AST start new sketch" | "Modify AST" | "Set selection" | "Update code selection cursors" | "create path" | "set tool" | "show default planes" | "sketch exit execute";
delays: never;
guards: "Selection contains axis" | "Selection contains edge" | "Selection contains face" | "Selection contains line" | "Selection contains point" | "Selection is not empty" | "Selection is one face";
guards: "Selection contains axis" | "Selection contains edge" | "Selection contains face" | "Selection contains line" | "Selection contains point" | "Selection is not empty" | "Selection is one face" | "has valid extrude selection";
services: "Get ABS X info" | "Get ABS Y info" | "Get angle info" | "Get horizontal info" | "Get length info" | "Get perpendicular distance info" | "Get vertical info";
};
eventsCausingActions: {
"AST add line segment": "Add point";
"AST extrude": "" | "extrude intent";
"AST extrude": "Extrude";
"AST start new sketch": "Add point";
"Add to code-based selection": "Deselect point" | "Deselect segment" | "Select all" | "Select edge" | "Select face" | "Select point" | "Select segment";
"Add to other selection": "Select axis";
@ -63,7 +63,7 @@
"edit mode enter": "Enter sketch" | "Re-execute";
"edit_mode_exit": "Cancel";
"equip select": "CancelSketch" | "Constrain equal length" | "Constrain horizontally align" | "Constrain parallel" | "Constrain remove constraints" | "Constrain snap to X" | "Constrain snap to Y" | "Constrain vertically align" | "Deselect point" | "Deselect segment" | "Enter sketch" | "Make segment horizontal" | "Make segment vertical" | "Re-execute" | "Select default plane" | "Select point" | "Select segment" | "Set selection" | "done.invoke.get-abs-x-info" | "done.invoke.get-abs-y-info" | "done.invoke.get-angle-info" | "done.invoke.get-horizontal-info" | "done.invoke.get-length-info" | "done.invoke.get-perpendicular-distance-info" | "done.invoke.get-vertical-info" | "error.platform.get-abs-x-info" | "error.platform.get-abs-y-info" | "error.platform.get-angle-info" | "error.platform.get-horizontal-info" | "error.platform.get-length-info" | "error.platform.get-perpendicular-distance-info" | "error.platform.get-vertical-info";
"hide default planes": "Cancel" | "Select default plane" | "xstate.stop";
"hide default planes": "Cancel" | "Select default plane" | "Set selection" | "xstate.stop";
"reset sketch metadata": "Cancel" | "Select default plane";
"set default plane id": "Select default plane";
"set sketch metadata": "Enter sketch";
@ -72,9 +72,8 @@
"set tool line": "Equip tool";
"set tool move": "Equip move tool" | "Re-execute" | "Set selection";
"show default planes": "Enter sketch";
"sketch exit execute": "Cancel" | "Complete line" | "xstate.stop";
"sketch exit execute": "Cancel" | "Complete line" | "Set selection" | "xstate.stop";
"sketch mode enabled": "Enter sketch" | "Re-execute" | "Select default plane";
"toast extrude failed": "";
};
eventsCausingDelays: {
@ -105,8 +104,7 @@
"Selection is one face": "Enter sketch";
"can move": "";
"can move with execute": "";
"has no selection": "extrude intent";
"has valid extrude selection": "" | "extrude intent";
"has valid extrude selection": "Extrude";
"is editing existing sketch": "";
};
eventsCausingServices: {
@ -118,7 +116,7 @@
"Get perpendicular distance info": "Constrain perpendicular distance";
"Get vertical info": "Constrain vertical distance";
};
matchesStates: "Sketch" | "Sketch no face" | "Sketch.Await ABS X info" | "Sketch.Await ABS Y info" | "Sketch.Await angle info" | "Sketch.Await horizontal distance info" | "Sketch.Await length info" | "Sketch.Await perpendicular distance info" | "Sketch.Await vertical distance info" | "Sketch.Line Tool" | "Sketch.Line Tool.Done" | "Sketch.Line Tool.Init" | "Sketch.Line Tool.No Points" | "Sketch.Line Tool.Point Added" | "Sketch.Line Tool.Segment Added" | "Sketch.Move Tool" | "Sketch.Move Tool.Move init" | "Sketch.Move Tool.Move with execute" | "Sketch.Move Tool.Move without re-execute" | "Sketch.Move Tool.No move" | "Sketch.SketchIdle" | "awaiting selection" | "checking selection" | "idle" | { "Sketch"?: "Await ABS X info" | "Await ABS Y info" | "Await angle info" | "Await horizontal distance info" | "Await length info" | "Await perpendicular distance info" | "Await vertical distance info" | "Line Tool" | "Move Tool" | "SketchIdle" | { "Line Tool"?: "Done" | "Init" | "No Points" | "Point Added" | "Segment Added";
matchesStates: "Sketch" | "Sketch no face" | "Sketch.Await ABS X info" | "Sketch.Await ABS Y info" | "Sketch.Await angle info" | "Sketch.Await horizontal distance info" | "Sketch.Await length info" | "Sketch.Await perpendicular distance info" | "Sketch.Await vertical distance info" | "Sketch.Line Tool" | "Sketch.Line Tool.Done" | "Sketch.Line Tool.Init" | "Sketch.Line Tool.No Points" | "Sketch.Line Tool.Point Added" | "Sketch.Line Tool.Segment Added" | "Sketch.Move Tool" | "Sketch.Move Tool.Move init" | "Sketch.Move Tool.Move with execute" | "Sketch.Move Tool.Move without re-execute" | "Sketch.Move Tool.No move" | "Sketch.SketchIdle" | "idle" | { "Sketch"?: "Await ABS X info" | "Await ABS Y info" | "Await angle info" | "Await horizontal distance info" | "Await length info" | "Await perpendicular distance info" | "Await vertical distance info" | "Line Tool" | "Move Tool" | "SketchIdle" | { "Line Tool"?: "Done" | "Init" | "No Points" | "Point Added" | "Segment Added";
"Move Tool"?: "Move init" | "Move with execute" | "Move without re-execute" | "No move"; }; };
tags: never;
}

View File

@ -1,7 +1,6 @@
import { assign, createMachine } from 'xstate'
import { CommandBarConfig } from '../lib/commands'
import { Themes, getSystemTheme, setThemeClass } from '../lib/theme'
import { CameraSystem, cameraSystems } from 'lib/cameraControls'
import { CameraSystem } from 'lib/cameraControls'
import { Models } from '@kittycad/lib'
export const DEFAULT_PROJECT_NAME = 'project-$nnn'
@ -24,85 +23,6 @@ export type Toggle = 'On' | 'Off'
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
export const settingsCommandBarConfig: CommandBarConfig<
typeof settingsMachine
> = {
'Set Base Unit': {
icon: 'gear',
args: [
{
name: 'baseUnit',
type: 'select',
getDefaultValueFromContext: 'baseUnit',
options: Object.values(baseUnitsUnion).map((v) => ({ name: v })),
},
],
},
'Set Camera Controls': {
icon: 'gear',
args: [
{
name: 'cameraControls',
type: 'select',
getDefaultValueFromContext: 'cameraControls',
options: Object.values(cameraSystems).map((v) => ({ name: v })),
},
],
},
'Set Default Directory': {
hide: 'both',
},
'Set Default Project Name': {
icon: 'gear',
hide: 'web',
args: [
{
name: 'defaultProjectName',
type: 'string',
getDefaultValueFromContext: 'defaultProjectName',
},
],
},
'Set Onboarding Status': {
hide: 'both',
},
'Set Text Wrapping': {
icon: 'gear',
args: [
{
name: 'textWrapping',
type: 'select',
getDefaultValueFromContext: 'textWrapping',
options: [{ name: 'On' }, { name: 'Off' }],
},
],
},
'Set Theme': {
icon: 'gear',
args: [
{
name: 'theme',
type: 'select',
getDefaultValueFromContext: 'theme',
options: Object.values(Themes).map((v): { name: string } => ({
name: v,
})),
},
],
},
'Set Unit System': {
icon: 'gear',
args: [
{
name: 'unitSystem',
type: 'select',
getDefaultValueFromContext: 'unitSystem',
options: [{ name: UnitSystem.Imperial }, { name: UnitSystem.Metric }],
},
],
},
}
export const settingsMachine = createMachine(
{
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */

View File

@ -17,7 +17,7 @@ import { Link } from 'react-router-dom'
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
import Loading from '../components/Loading'
import { useMachine } from '@xstate/react'
import { homeCommandConfig, homeMachine } from '../machines/homeMachine'
import { homeMachine } from '../machines/homeMachine'
import { ContextFrom, EventFrom } from 'xstate'
import { paths } from '../Router'
import {
@ -30,11 +30,12 @@ import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { DEFAULT_PROJECT_NAME } from 'machines/settingsMachine'
import { sep } from '@tauri-apps/api/path'
import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig'
// 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 { commandBarSend } = useCommandsContext()
const navigate = useNavigate()
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
const {
@ -56,7 +57,7 @@ const Home = () => {
event: EventFrom<typeof homeMachine>
) => {
if (event.data && 'name' in event.data) {
setCommandBarOpen(false)
commandBarSend({ type: 'Close' })
navigate(
`${paths.FILE}/${encodeURIComponent(
context.defaultDirectory + sep + event.data.name
@ -143,12 +144,11 @@ const Home = () => {
const isSortByModified = sort?.includes('modified') || !sort || sort === null
useStateMachineCommands<typeof homeMachine>({
commands,
useStateMachineCommands({
machineId: 'home',
send,
state,
commandBarConfig: homeCommandConfig,
owner: 'home',
commandBarConfig: homeCommandBarConfig,
})
useEffect(() => {

View File

@ -1,7 +1,6 @@
import usePlatform from 'hooks/usePlatform'
import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore'
import { Platform, platform } from '@tauri-apps/api/os'
import { useEffect, useState } from 'react'
export default function CmdK() {
const { buttonDownInStream } = useStore((s) => ({
@ -9,14 +8,7 @@ export default function CmdK() {
}))
const dismiss = useDismiss()
const next = useNextClick(onboardingPaths.USER_MENU)
const [platformName, setPlatformName] = useState<Platform | ''>('')
useEffect(() => {
async function getPlatform() {
setPlatformName(await platform())
}
void getPlatform()
}, [setPlatformName])
const platformName = usePlatform()
return (
<div className="fixed inset-0 z-50 grid items-end justify-center pointer-events-none">
@ -29,13 +21,13 @@ export default function CmdK() {
<h2 className="text-2xl">Command Bar</h2>
<p className="my-4">
Press{' '}
{platformName === 'win32' ? (
{platformName === 'darwin' ? (
<>
<kbd>Win</kbd> + <kbd>/</kbd>
<kbd></kbd> + <kbd>K</kbd>
</>
) : (
<>
<kbd>OS</kbd> + <kbd>K</kbd>
<kbd>Ctrl</kbd> + <kbd>/</kbd>
</>
)}{' '}
to open the command bar. Try changing your theme with it.

View File

@ -1,4 +1,3 @@
import { faSignInAlt } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../components/ActionButton'
import { isTauri } from '../lib/isTauri'
import { invoke } from '@tauri-apps/api/tauri'
@ -65,7 +64,7 @@ const SignIn = () => {
<ActionButton
Element="button"
onClick={signInTauri}
icon={{ icon: faSignInAlt }}
icon={{ icon: 'arrowRight' }}
className="w-fit mt-4"
data-testid="sign-in-button"
>
@ -80,7 +79,7 @@ const SignIn = () => {
typeof window !== 'undefined' &&
window.location.href.replace('signin', '')
)}`}
icon={{ icon: faSignInAlt }}
icon={{ icon: 'arrowRight' }}
className="w-fit mt-4"
>
Sign in

276
src/wasm-lib/Cargo.lock generated
View File

@ -127,12 +127,6 @@ dependencies = [
"backtrace",
]
[[package]]
name = "approx"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08abcc3b4e9339e33a3d0a5ed15d84a687350c05689d825e0f6655eef9e76a94"
[[package]]
name = "arc-swap"
version = "1.6.0"
@ -249,7 +243,7 @@ dependencies = [
"libm",
"num-bigint",
"num-integer",
"num-traits 0.2.17",
"num-traits",
"serde",
]
@ -335,7 +329,7 @@ dependencies = [
"indexmap 1.9.3",
"js-sys",
"once_cell",
"rand 0.8.5",
"rand",
"serde",
"serde_bytes",
"serde_json",
@ -400,18 +394,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cgmath"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64a4b57c8f4e3a2e9ac07e0f6abc9c24b6fc9e1b54c3478cfb598f3d0023e51c"
dependencies = [
"approx",
"mint",
"num-traits 0.1.43",
"rand 0.4.6",
]
[[package]]
name = "chrono"
version = "0.4.31"
@ -420,10 +402,8 @@ checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits 0.2.17",
"num-traits",
"serde",
"wasm-bindgen",
"windows-targets 0.48.5",
]
@ -600,7 +580,7 @@ dependencies = [
"criterion-plot",
"is-terminal",
"itertools 0.10.5",
"num-traits 0.2.17",
"num-traits",
"once_cell",
"oorandom",
"plotters",
@ -762,49 +742,6 @@ dependencies = [
"syn 2.0.39",
]
[[package]]
name = "diesel"
version = "2.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62c6fcf842f17f8c78ecf7c81d75c5ce84436b41ee07e03f490fbb5f5a8731d8"
dependencies = [
"bigdecimal",
"bitflags 2.4.1",
"byteorder",
"chrono",
"diesel_derives",
"mysqlclient-sys",
"num-bigint",
"num-integer",
"num-traits 0.2.17",
"percent-encoding",
"r2d2",
"serde_json",
"url",
"uuid",
]
[[package]]
name = "diesel_derives"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8337737574f55a468005a83499da720f20c65586241ffea339db9ecdfd2b44"
dependencies = [
"diesel_table_macro_syntax",
"proc-macro2",
"quote",
"syn 2.0.39",
]
[[package]]
name = "diesel_table_macro_syntax"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc5557efc453706fed5e4fa85006fe9817c224c3f480a34c7e5959fd700921c5"
dependencies = [
"syn 2.0.39",
]
[[package]]
name = "diff"
version = "0.1.13"
@ -869,26 +806,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "enum-iterator"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7add3873b5dd076766ee79c8e406ad1a472c385476b9e38849f8eec24f1be689"
dependencies = [
"enum-iterator-derive",
]
[[package]]
name = "enum-iterator-derive"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eecf8589574ce9b895052fa12d69af7a233f99e6107f5cb8dd1044f2a17bfdcb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.39",
]
[[package]]
name = "equivalent"
version = "1.0.1"
@ -905,28 +822,6 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "euler"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f19d11568a4a46aee488bdab3a2963e5e2c3cfd6091aa0abceaddcea82c0bc1"
dependencies = [
"approx",
"cgmath",
]
[[package]]
name = "execution-plan"
version = "0.1.0"
dependencies = [
"bytes",
"kittycad",
"kittycad-modeling-cmds",
"serde",
"thiserror",
"uuid",
]
[[package]]
name = "expectorate"
version = "1.1.0"
@ -1041,12 +936,6 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "fuchsia-cprng"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
[[package]]
name = "funty"
version = "2.0.0"
@ -1400,7 +1289,7 @@ dependencies = [
"gif",
"jpeg-decoder",
"num-rational",
"num-traits 0.2.17",
"num-traits",
"png",
"qoi",
"tiff",
@ -1439,12 +1328,6 @@ dependencies = [
"serde",
]
[[package]]
name = "inflections"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"
[[package]]
name = "insta"
version = "1.34.0"
@ -1611,7 +1494,7 @@ dependencies = [
"log",
"parse-display",
"phonenumber",
"rand 0.8.5",
"rand",
"reqwest",
"reqwest-conditional-middleware",
"reqwest-middleware",
@ -1628,42 +1511,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "kittycad-modeling-cmds"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3217f9ffe9dce4b16303eeef539e7e27b743bc1c46eff8ce64657745a2b75ca"
dependencies = [
"chrono",
"data-encoding",
"diesel",
"diesel_derives",
"enum-iterator",
"enum-iterator-derive",
"euler",
"http",
"kittycad-unit-conversion-derive",
"measurements",
"parse-display",
"parse-display-derive",
"schemars",
"serde",
"serde_bytes",
"uuid",
]
[[package]]
name = "kittycad-unit-conversion-derive"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7001c46a92c1edce6722a3900539b198230980799035f02d92b4e7df3fc08738"
dependencies = [
"inflections",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
@ -1763,15 +1610,6 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "measurements"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5b734b4e8187ea5777bc29c086f0970a27d8de42061b48f5af32cafc0ca904b"
dependencies = [
"libm",
]
[[package]]
name = "memchr"
version = "2.6.4"
@ -1819,12 +1657,6 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mint"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff"
[[package]]
name = "mio"
version = "0.8.9"
@ -1836,16 +1668,6 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "mysqlclient-sys"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f61b381528ba293005c42a409dd73d034508e273bf90481f17ec2e964a6e969b"
dependencies = [
"pkg-config",
"vcpkg",
]
[[package]]
name = "newline-converter"
version = "0.3.0"
@ -1873,7 +1695,7 @@ checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0"
dependencies = [
"autocfg",
"num-integer",
"num-traits 0.2.17",
"num-traits",
]
[[package]]
@ -1883,7 +1705,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9"
dependencies = [
"autocfg",
"num-traits 0.2.17",
"num-traits",
]
[[package]]
@ -1894,16 +1716,7 @@ checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0"
dependencies = [
"autocfg",
"num-integer",
"num-traits 0.2.17",
]
[[package]]
name = "num-traits"
version = "0.1.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e5113e9fd4cc14ded8e499429f396a20f98c772a47cc8622a736e1ec843c31"
dependencies = [
"num-traits 0.2.17",
"num-traits",
]
[[package]]
@ -1989,7 +1802,7 @@ dependencies = [
"phonenumber",
"proc-macro2",
"quote",
"rand 0.8.5",
"rand",
"regex",
"reqwest",
"reqwest-middleware",
@ -2041,7 +1854,7 @@ dependencies = [
"lazy_static",
"percent-encoding",
"pin-project",
"rand 0.8.5",
"rand",
"thiserror",
]
@ -2196,7 +2009,7 @@ version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2c224ba00d7cadd4d5c660deaf2098e5e80e07846537c51f9cfa4be50c1fd45"
dependencies = [
"num-traits 0.2.17",
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
@ -2313,36 +2126,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r2d2"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93"
dependencies = [
"log",
"parking_lot 0.12.1",
"scheduled-thread-pool",
]
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293"
dependencies = [
"fuchsia-cprng",
"libc",
"rand_core 0.3.1",
"rdrand",
"winapi",
]
[[package]]
name = "rand"
version = "0.8.5"
@ -2351,7 +2140,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core 0.6.4",
"rand_core",
]
[[package]]
@ -2361,24 +2150,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b"
dependencies = [
"rand_core 0.4.2",
]
[[package]]
name = "rand_core"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc"
[[package]]
name = "rand_core"
version = "0.6.4"
@ -2408,15 +2182,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rdrand"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2"
dependencies = [
"rand_core 0.3.1",
]
[[package]]
name = "redox_syscall"
version = "0.2.16"
@ -2625,7 +2390,7 @@ checksum = "e09bbcb5003282bcb688f0bae741b278e9c7e8f378f561522c9806c58e075d9b"
dependencies = [
"anyhow",
"chrono",
"rand 0.8.5",
"rand",
]
[[package]]
@ -2753,15 +2518,6 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "scheduled-thread-pool"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19"
dependencies = [
"parking_lot 0.12.1",
]
[[package]]
name = "schemars"
version = "0.8.16"
@ -3678,7 +3434,7 @@ dependencies = [
"http",
"httparse",
"log",
"rand 0.8.5",
"rand",
"rustls",
"sha1",
"thiserror",

View File

@ -54,7 +54,6 @@ members = [
"derive-docs",
"kcl",
"kcl-macros",
"execution-plan",
]
[workspace.dependencies]

View File

@ -1,15 +0,0 @@
[package]
name = "execution-plan"
version = "0.1.0"
edition = "2021"
repository = "https://github.com/KittyCAD/modeling-app"
rust-version = "1.73"
description = "A DSL for composing KittyCAD API queries"
[dependencies]
bytes = "1.5"
kittycad = { workspace = true, features = ["requests"] }
kittycad-modeling-cmds = "0.1.4"
serde = { version = "1", features = ["derive"] }
thiserror = "1"
uuid = "1.6.1"

View File

@ -1,13 +0,0 @@
use crate::{ExecutionError, Value};
mod impls;
/// Types that can be written to or read from KCEP program memory,
/// but require multiple values to store.
/// They get laid out into multiple consecutive memory addresses.
pub trait Composite: Sized {
/// Store the value in memory.
fn into_parts(self) -> Vec<Value>;
/// Read the value from memory.
fn from_parts(values: &[Option<Value>]) -> Result<Self, ExecutionError>;
}

View File

@ -1,98 +0,0 @@
use kittycad_modeling_cmds::{id::ModelingCmdId, shared::Point3d, MovePathPen};
use uuid::Uuid;
use crate::{Address, ExecutionError, Value};
use super::Composite;
impl<T> Composite for kittycad_modeling_cmds::shared::Point3d<T>
where
Value: From<T>,
T: TryFrom<Value, Error = ExecutionError>,
{
fn into_parts(self) -> Vec<Value> {
let points = [self.x, self.y, self.z];
points.into_iter().map(|component| component.into()).collect()
}
fn from_parts(values: &[Option<Value>]) -> Result<Self, ExecutionError> {
let err = ExecutionError::MemoryWrongSize { expected: 3 };
let [x, y, z] = [0, 1, 2].map(|n| values.get(n).ok_or(err.clone()));
let x = x?.to_owned().ok_or(err.clone())?.try_into()?;
let y = y?.to_owned().ok_or(err.clone())?.try_into()?;
let z = z?.to_owned().ok_or(err.clone())?.try_into()?;
Ok(Self { x, y, z })
}
}
const START_PATH: &str = "StartPath";
const MOVE_PATH_PEN: &str = "MovePathPen";
impl Composite for MovePathPen {
fn into_parts(self) -> Vec<Value> {
let MovePathPen { path, to } = self;
let to = to.into_parts();
let mut vals = Vec::with_capacity(1 + to.len());
vals.push(Value::Uuid(path.into()));
vals.extend(to);
vals
}
fn from_parts(values: &[Option<Value>]) -> Result<Self, ExecutionError> {
let path = get_some(values, 0)?;
let path = Uuid::try_from(path)?;
let path = ModelingCmdId::from(path);
let to = Point3d::from_parts(&values[1..])?;
let params = MovePathPen { path, to };
Ok(params)
}
}
/// Memory layout for modeling commands:
/// Field 0 is the command's name.
/// Fields 1 onwards are the command's fields.
impl Composite for kittycad_modeling_cmds::ModelingCmd {
fn into_parts(self) -> Vec<Value> {
let (endpoint_name, params) = match self {
kittycad_modeling_cmds::ModelingCmd::StartPath => (START_PATH, vec![]),
kittycad_modeling_cmds::ModelingCmd::MovePathPen(MovePathPen { path, to }) => {
let to = to.into_parts();
let mut vals = Vec::with_capacity(1 + to.len());
vals.push(Value::Uuid(path.into()));
vals.extend(to);
(MOVE_PATH_PEN, vals)
}
_ => todo!(),
};
let mut out = Vec::with_capacity(params.len() + 1);
out.push(endpoint_name.to_owned().into());
out.extend(params);
out
}
fn from_parts(values: &[Option<Value>]) -> Result<Self, ExecutionError> {
// Check the array has an element at index 0
let first_memory = values
.get(0)
.ok_or(ExecutionError::MemoryWrongSize { expected: 1 })?
.to_owned();
// The element should be Some
let first_memory = first_memory.ok_or(ExecutionError::MemoryWrongSize { expected: 1 })?;
let endpoint_name: String = first_memory.try_into()?;
match endpoint_name.as_str() {
START_PATH => Ok(Self::StartPath),
MOVE_PATH_PEN => {
let params = MovePathPen::from_parts(&values[1..])?;
Ok(Self::MovePathPen(params))
}
other => Err(ExecutionError::UnrecognizedEndpoint(other.to_owned())),
}
}
}
fn get_some(values: &[Option<Value>], i: usize) -> Result<Value, ExecutionError> {
let addr = Address(0); // TODO: pass the `start` addr in
let v = values.get(i).ok_or(ExecutionError::MemoryEmpty { addr })?.to_owned();
let v = v.ok_or(ExecutionError::MemoryEmpty { addr })?.to_owned();
Ok(v)
}

View File

@ -1,323 +0,0 @@
//! A KittyCAD execution plan (KCEP) is a list of
//! - KittyCAD API requests to make
//! - Values to send in API requests
//! - Values to assign from API responses
//! - Computation to perform on values
//! You can think of it as a domain-specific language for making KittyCAD API calls and using
//! the results to make other API calls.
use composite::Composite;
use serde::{Deserialize, Serialize};
use std::fmt;
use uuid::Uuid;
mod composite;
#[cfg(test)]
mod tests;
/// KCEP's program memory. A flat, linear list of values.
#[derive(Debug)]
#[cfg_attr(test, derive(PartialEq))]
pub struct Memory(Vec<Option<Value>>);
impl Default for Memory {
fn default() -> Self {
Self(vec![None; 1024])
}
}
/// An address in KCEP's program memory.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Address(usize);
impl fmt::Display for Address {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl From<usize> for Address {
fn from(value: usize) -> Self {
Self(value)
}
}
impl Memory {
/// Get a value from KCEP's program memory.
pub fn get(&self, Address(addr): &Address) -> Option<&Value> {
self.0[*addr].as_ref()
}
/// Store a value in KCEP's program memory.
pub fn set(&mut self, Address(addr): Address, value: Value) {
// If isn't big enough for this value, double the size of memory until it is.
while addr > self.0.len() {
self.0.extend(vec![None; self.0.len()]);
}
self.0[addr] = Some(value);
}
/// Store a composite value (i.e. a value which takes up multiple addresses in memory).
/// Store its parts in consecutive memory addresses starting at `start`.
pub fn set_composite<T: Composite>(&mut self, composite_value: T, start: Address) {
let parts = composite_value.into_parts().into_iter();
for (value, addr) in parts.zip(start.0..) {
self.0[addr] = Some(value);
}
}
/// Get a composite value (i.e. a value which takes up multiple addresses in memory).
/// Its parts are stored in consecutive memory addresses starting at `start`.
pub fn get_composite<T: Composite>(&self, start: Address) -> Result<T, ExecutionError> {
let values = &self.0[start.0..];
T::from_parts(values)
}
}
/// A value stored in KCEP program memory.
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub enum Value {
String(String),
NumericValue(NumericValue),
Uuid(Uuid),
}
impl From<Uuid> for Value {
fn from(u: Uuid) -> Self {
Self::Uuid(u)
}
}
impl From<String> for Value {
fn from(value: String) -> Self {
Self::String(value)
}
}
impl From<f64> for Value {
fn from(value: f64) -> Self {
Self::NumericValue(NumericValue::Float(value))
}
}
impl TryFrom<Value> for String {
type Error = ExecutionError;
fn try_from(value: Value) -> Result<Self, Self::Error> {
if let Value::String(s) = value {
Ok(s)
} else {
Err(ExecutionError::MemoryWrongType {
expected: "string",
actual: format!("{value:?}"),
})
}
}
}
impl TryFrom<Value> for Uuid {
type Error = ExecutionError;
fn try_from(value: Value) -> Result<Self, Self::Error> {
if let Value::Uuid(u) = value {
Ok(u)
} else {
Err(ExecutionError::MemoryWrongType {
expected: "uuid",
actual: format!("{value:?}"),
})
}
}
}
impl TryFrom<Value> for f64 {
type Error = ExecutionError;
fn try_from(value: Value) -> Result<Self, Self::Error> {
if let Value::NumericValue(NumericValue::Float(x)) = value {
Ok(x)
} else {
Err(ExecutionError::MemoryWrongType {
expected: "float",
actual: format!("{value:?}"),
})
}
}
}
#[cfg(test)]
impl From<usize> for Value {
fn from(value: usize) -> Self {
Self::NumericValue(NumericValue::Integer(value))
}
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub enum NumericValue {
Integer(usize),
Float(f64),
}
/// One step of the execution plan.
#[derive(Serialize, Deserialize)]
pub enum Instruction {
/// Call the KittyCAD API.
ApiRequest {
/// Which ModelingCmd to call.
/// It's a composite value starting at the given address.
endpoint: Address,
/// Which address should the response be stored in?
store_response: Option<usize>,
/// Look up each API request in this register number.
arguments: Vec<Address>,
},
/// Set a value in memory.
Set {
/// Which memory address to set.
address: Address,
/// What value to set the memory address to.
value: Value,
},
/// Perform arithmetic on values in memory.
Arithmetic {
/// What to do.
arithmetic: Arithmetic,
/// Write the output to this memory address.
destination: Address,
},
}
/// Instruction to perform arithmetic on values in memory.
#[derive(Deserialize, Serialize)]
pub struct Arithmetic {
/// Apply this operation
pub operation: Operation,
/// First operand for the operation
pub operand0: Operand,
/// Second operand for the operation
pub operand1: Operand,
}
macro_rules! arithmetic_body {
($arith:ident, $mem:ident, $method:ident) => {
match (
$arith.operand0.eval(&$mem)?.clone(),
$arith.operand1.eval(&$mem)?.clone(),
) {
// If both operands are numeric, then do the arithmetic operation.
(Value::NumericValue(x), Value::NumericValue(y)) => {
let num = match (x, y) {
(NumericValue::Integer(x), NumericValue::Integer(y)) => NumericValue::Integer(x.$method(y)),
(NumericValue::Integer(x), NumericValue::Float(y)) => NumericValue::Float((x as f64).$method(y)),
(NumericValue::Float(x), NumericValue::Integer(y)) => NumericValue::Float(x.$method(y as f64)),
(NumericValue::Float(x), NumericValue::Float(y)) => NumericValue::Float(x.$method(y)),
};
Ok(Value::NumericValue(num))
}
// This operation can only be done on numeric types.
_ => Err(ExecutionError::CannotApplyOperation {
op: $arith.operation,
operands: vec![
$arith.operand0.eval(&$mem)?.clone().to_owned(),
$arith.operand1.eval(&$mem)?.clone().to_owned(),
],
}),
}
};
}
impl Arithmetic {
/// Calculate the the arithmetic equation.
/// May read values from the given memory.
fn calculate(self, mem: &Memory) -> Result<Value, ExecutionError> {
use std::ops::{Add, Div, Mul, Sub};
match self.operation {
Operation::Add => {
arithmetic_body!(self, mem, add)
}
Operation::Mul => {
arithmetic_body!(self, mem, mul)
}
Operation::Sub => {
arithmetic_body!(self, mem, sub)
}
Operation::Div => {
arithmetic_body!(self, mem, div)
}
}
}
}
/// Operations that can be applied to values in memory.
#[derive(Debug, Deserialize, Serialize, Clone, Copy)]
pub enum Operation {
Add,
Mul,
Sub,
Div,
}
impl fmt::Display for Operation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Operation::Add => "+",
Operation::Mul => "*",
Operation::Sub => "-",
Operation::Div => "/",
}
.fmt(f)
}
}
/// Argument to an operation.
#[derive(Deserialize, Serialize)]
pub enum Operand {
Literal(Value),
Reference(Address),
}
impl Operand {
/// Evaluate the operand, getting its value.
fn eval(&self, mem: &Memory) -> Result<Value, ExecutionError> {
match self {
Operand::Literal(v) => Ok(v.to_owned()),
Operand::Reference(addr) => match mem.get(addr) {
None => Err(ExecutionError::MemoryEmpty { addr: *addr }),
Some(v) => Ok(v.to_owned()),
},
}
}
}
/// Execute the plan.
pub fn execute(mem: &mut Memory, plan: Vec<Instruction>) -> Result<(), ExecutionError> {
for step in plan {
match step {
Instruction::ApiRequest { .. } => todo!("Execute API calls"),
Instruction::Set { address, value } => {
mem.set(address, value);
}
Instruction::Arithmetic {
arithmetic,
destination,
} => {
let out = arithmetic.calculate(mem)?;
mem.set(destination, out);
}
}
}
Ok(())
}
/// Errors that could occur when executing a KittyCAD execution plan.
#[derive(Debug, thiserror::Error, Clone)]
pub enum ExecutionError {
#[error("Memory address {addr} was not set")]
MemoryEmpty { addr: Address },
#[error("Cannot apply operation {op} to operands {operands:?}")]
CannotApplyOperation { op: Operation, operands: Vec<Value> },
#[error("Tried to read a '{expected}' from KCEP program memory, found an '{actual}' instead")]
MemoryWrongType { expected: &'static str, actual: String },
#[error("Wanted {expected} values but did not get enough")]
MemoryWrongSize { expected: usize },
#[error("No endpoint {0} recognized")]
UnrecognizedEndpoint(String),
}

View File

@ -1,117 +0,0 @@
use kittycad_modeling_cmds::{id::ModelingCmdId, shared::Point3d, ModelingCmd, MovePathPen};
use super::*;
#[test]
fn write_addr_to_memory() {
let plan = vec![Instruction::Set {
address: Address(0),
value: 3.4.into(),
}];
let mut mem = Memory::default();
execute(&mut mem, plan).unwrap();
assert_eq!(mem.get(&Address(0)), Some(&3.4.into()))
}
#[test]
fn add_literals() {
let plan = vec![Instruction::Arithmetic {
arithmetic: Arithmetic {
operation: Operation::Add,
operand0: Operand::Literal(3.into()),
operand1: Operand::Literal(2.into()),
},
destination: Address(1),
}];
let mut mem = Memory::default();
execute(&mut mem, plan).unwrap();
assert_eq!(mem.get(&Address(1)), Some(&5.into()))
}
#[test]
fn add_literal_to_reference() {
let plan = vec![
// Memory addr 0 contains 450
Instruction::Set {
address: Address(0),
value: 450.into(),
},
// Add 20 to addr 0
Instruction::Arithmetic {
arithmetic: Arithmetic {
operation: Operation::Add,
operand0: Operand::Reference(Address(0)),
operand1: Operand::Literal(20.into()),
},
destination: Address(1),
},
];
// 20 + 450 = 470
let mut mem = Memory::default();
execute(&mut mem, plan).unwrap();
assert_eq!(mem.get(&Address(1)), Some(&470.into()))
}
#[test]
fn add_to_composite_value() {
let mut mem = Memory::default();
// Write a point to memory.
let point_before = Point3d {
x: 2.0f64,
y: 3.0,
z: 4.0,
};
let start_addr = Address(0);
mem.set_composite(point_before, start_addr);
assert_eq!(mem.0[0], Some(2.0.into()));
assert_eq!(mem.0[1], Some(3.0.into()));
assert_eq!(mem.0[2], Some(4.0.into()));
// Update the point's x-value in memory.
execute(
&mut mem,
vec![Instruction::Arithmetic {
arithmetic: Arithmetic {
operation: Operation::Add,
operand0: Operand::Reference(start_addr),
operand1: Operand::Literal(40.into()),
},
destination: start_addr,
}],
)
.unwrap();
// Read the point out of memory, validate it.
let point_after: Point3d<f64> = mem.get_composite(start_addr).unwrap();
assert_eq!(
point_after,
Point3d {
x: 42.0,
y: 3.0,
z: 4.0
}
)
}
#[test]
fn api_types() {
let mut mem = Memory::default();
let start_addr = Address(0);
let id = ModelingCmdId(Uuid::parse_str("6306afa2-3999-4b03-af30-1efad7cdc6fc").unwrap());
let p = Point3d {
x: 2.0f64,
y: 3.0,
z: 4.0,
};
let val_in = ModelingCmd::MovePathPen(MovePathPen { path: id, to: p });
mem.set_composite(val_in, start_addr);
let val_out: ModelingCmd = mem.get_composite(start_addr).unwrap();
match val_out {
ModelingCmd::MovePathPen(params) => {
assert_eq!(params.to, p);
assert_eq!(params.path, id);
}
_ => panic!("unexpected ModelingCmd variant"),
}
}

View File

@ -8497,9 +8497,9 @@ ws@^8.8.0:
integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==
xstate@^4.38.2:
version "4.38.2"
resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.2.tgz#1b74544fc9c8c6c713ba77f81c6017e65aa89804"
integrity sha512-Fba/DwEPDLneHT3tbJ9F3zafbQXszOlyCJyQqqdzmtlY/cwE2th462KK48yaANf98jHlP6lJvxfNtN0LFKXPQg==
version "4.38.3"
resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.3.tgz#4e15e7ad3aa0ca1eea2010548a5379966d8f1075"
integrity sha512-SH7nAaaPQx57dx6qvfcIgqKRXIh4L0A1iYEqim4s1u7c9VoCgzZc+63FY90AKU4ZzOC2cfJzTnpO4zK7fCUzzw==
y18n@^5.0.5:
version "5.0.8"