Command bar: add extrude command, nonlinear editing, etc (#1204)

* Tweak toaster look and feel

* Add icons, tweak plus icon names

* Rename commandBarMeta to commandBarConfig

* Refactor command bar, add support for icons

* Create a tailwind plugin for aria-pressed button state

* Remove overlay from behind command bar

* Clean up toolbar

* Button and other style tweaks

* Icon tweaks follow-up: make old icons work with new sizing

* Delete unused static icons

* More CSS tweaks

* Small CSS tweak to project sidebar

* Add command bar E2E test

* fumpt

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* fix typo in a comment

* Fix icon padding (built version only)

* Update onboarding and warning banner icons padding

* Misc minor style fixes

* Get Extrude opening and canceling from command bar

* Iconography tweaks

* Get extrude kind of working

* Refactor command bar config types and organization

* Move command bar configs to be co-located with each other

* Start building a state machine for the command bar

* Start converting command bar to state machine

* Add support for multiple args, confirmation step

* Submission behavior, hotkeys, code organization

* Add new test for extruding from command bar

* Polish step back and selection hotkeys, CSS tweaks

* Loading style tweaks

* Validate selection inputs, polish UX of args re-editing

* Prevent submission with multiple selection on singlular arg

* Remove stray console logs

* Tweak test, CSS nit, remove extrude "result" argument

* Fix linting warnings

* Show Ctrl+/ instead of ⌘K on all platforms but Mac

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* Add "Enter sketch" to command bar

* fix command bar test

* Fix flaky cmd bar extrude test by waiting for engine select response

* Cover both button labels '⌘K' and 'Ctrl+/' in test

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2023-12-13 12:49:01 -05:00
committed by GitHub
parent b01357b49e
commit d2535bb8c2
60 changed files with 4549 additions and 1939 deletions

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

View File

@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid'
import { getUtils } from './test-utils' import { getUtils } from './test-utils'
import waitOn from 'wait-on' import waitOn from 'wait-on'
import { Themes } from '../../src/lib/theme' import { Themes } from '../../src/lib/theme'
import { platform } from '@tauri-apps/api/os'
/* /*
debug helper: unfortunately we do rely on exact coord mouse clicks in a few places debug helper: unfortunately we do rely on exact coord mouse clicks in a few places
@ -643,7 +644,11 @@ test('Command bar works and can change a setting', async ({ page }) => {
let cmdSearchBar = page.getByPlaceholder('Search commands') let cmdSearchBar = page.getByPlaceholder('Search commands')
// First try opening the command bar and closing it // 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 expect(cmdSearchBar).toBeVisible()
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
await expect(cmdSearchBar).not.toBeVisible() await expect(cmdSearchBar).not.toBeVisible()
@ -658,12 +663,12 @@ test('Command bar works and can change a setting', async ({ page }) => {
const themeOption = page.getByRole('option', { name: 'Set Theme' }) const themeOption = page.getByRole('option', { name: 'Set Theme' })
await expect(themeOption).toBeVisible() await expect(themeOption).toBeVisible()
await themeOption.click() await themeOption.click()
const themeInput = page.getByPlaceholder(Themes.System) const themeInput = page.getByPlaceholder('Select an option')
await expect(themeInput).toBeVisible() await expect(themeInput).toBeVisible()
await expect(themeInput).toBeFocused() await expect(themeInput).toBeFocused()
// Select dark theme // Select dark theme
await page.keyboard.press('ArrowDown') await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowUp') await page.keyboard.press('ArrowDown')
await expect(page.getByRole('option', { name: Themes.Dark })).toHaveAttribute( await expect(page.getByRole('option', { name: Themes.Dark })).toHaveAttribute(
'data-headlessui-state', 'data-headlessui-state',
'active' 'active'
@ -675,3 +680,59 @@ test('Command bar works and can change a setting', async ({ page }) => {
// Check that the theme changed // Check that the theme changed
await expect(page.locator('body')).toHaveClass(`body-bg ${Themes.Dark}`) 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('|> line([25.1, 0.41], %)').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

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

View File

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

View File

@ -4,9 +4,11 @@ import { engineCommandManager } from './lang/std/engineConnection'
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { ActionButton } from 'components/ActionButton' import { ActionButton } from 'components/ActionButton'
import usePlatform from 'hooks/usePlatform'
export const Toolbar = () => { export const Toolbar = () => {
const { setCommandBarOpen } = useCommandsContext() const platform = usePlatform()
const { commandBarSend } = useCommandsContext()
const { state, send, context } = useModelingContext() const { state, send, context } = useModelingContext()
const toolbarButtonsRef = useRef<HTMLUListElement>(null) const toolbarButtonsRef = useRef<HTMLUListElement>(null)
const bgClassName = const bgClassName =
@ -177,10 +179,15 @@ export const Toolbar = () => {
<ActionButton <ActionButton
Element="button" Element="button"
className="text-sm" className="text-sm"
onClick={() => send('extrude intent')} onClick={() =>
disabled={!state.can('extrude intent')} commandBarSend({
type: 'Find and select command',
data: { name: 'Extrude', ownerMachine: 'modeling' },
})
}
disabled={!state.can('Extrude')}
title={ title={
state.can('extrude intent') state.can('Extrude')
? 'extrude' ? 'extrude'
: 'sketches need to be closed, or not already extruded' : 'sketches need to be closed, or not already extruded'
} }
@ -204,10 +211,10 @@ export const Toolbar = () => {
</menu> </menu>
<ActionButton <ActionButton
Element="button" 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" 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> </ActionButton>
</div> </div>
) )

View File

@ -7,6 +7,7 @@ import styles from './AppHeader.module.css'
import { NetworkHealthIndicator } from './NetworkHealthIndicator' import { NetworkHealthIndicator } from './NetworkHealthIndicator'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { ActionButton } from './ActionButton' import { ActionButton } from './ActionButton'
import usePlatform from 'hooks/usePlatform'
interface AppHeaderProps extends React.PropsWithChildren { interface AppHeaderProps extends React.PropsWithChildren {
showToolbar?: boolean showToolbar?: boolean
@ -22,7 +23,8 @@ export const AppHeader = ({
className = '', className = '',
enableMenu = false, enableMenu = false,
}: AppHeaderProps) => { }: AppHeaderProps) => {
const { setCommandBarOpen } = useCommandsContext() const platform = usePlatform()
const { commandBarSend } = useCommandsContext()
const { auth } = useGlobalStateContext() const { auth } = useGlobalStateContext()
const user = auth?.context?.user const user = auth?.context?.user
@ -47,12 +49,12 @@ export const AppHeader = ({
) : ( ) : (
<ActionButton <ActionButton
Element="button" Element="button"
onClick={() => setCommandBarOpen(true)} onClick={() => commandBarSend({ type: 'Open' })}
className="text-sm self-center flex items-center w-fit gap-3" className="text-sm self-center flex items-center w-fit gap-3"
> >
Command Palette{' '} 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"> <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> </kbd>
</ActionButton> </ActionButton>
)} )}

View File

@ -24,13 +24,13 @@ export const PanelHeader = ({
}: CollapsiblePanelProps) => { }: CollapsiblePanelProps) => {
return ( return (
<summary className={styles.header}> <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 <ActionIcon
icon={icon} icon={icon}
className="p-1" className="p-1"
size="sm" size="sm"
bgClassName={ 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 || '') (iconClassNames?.bg || '')
} }
iconClassName={ iconClassName={

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' | 'arrowLeft'
| 'arrowRight' | 'arrowRight'
| 'arrowUp' | 'arrowUp'
| 'checkmark'
| 'close' | 'close'
| 'equal' | 'equal'
| 'extrude' | 'extrude'
@ -90,6 +91,22 @@ export const CustomIcon = ({
/> />
</svg> </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': case 'close':
return ( return (
<svg <svg

View File

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

View File

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

View File

@ -15,23 +15,23 @@ const Loading = ({ children }: React.PropsWithChildren) => {
data-testid="loading" data-testid="loading"
> >
<svg viewBox="0 0 10 10" className="w-8 h-8"> <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 <circle
cx="5" cx="5"
cy="5" cy="5"
r="4" r="4"
stroke="var(--liquid-10)" stroke="var(--energy-10)"
fill="none" fill="none"
strokeDasharray="4, 4" strokeDasharray="4, 4"
className="animate-spin origin-center" className="animate-spin origin-center"
/> />
</svg> </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'} {children || 'Loading'}
</p> </p>
<p <p
className={ 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') (hasLongLoadTime ? ' opacity-100' : ' opacity-0')
} }
> >

View File

@ -29,19 +29,26 @@ import {
addNewSketchLn, addNewSketchLn,
compareVec2Epsilon, compareVec2Epsilon,
} from 'lang/std/sketch' } from 'lang/std/sketch'
import { kclManager } from 'lang/KclSinglton' import { kclManager, useKclContext } from 'lang/KclSinglton'
import { applyConstraintHorzVertDistance } from './Toolbar/SetHorzVertDistance' import { applyConstraintHorzVertDistance } from './Toolbar/SetHorzVertDistance'
import { import {
angleBetweenInfo, angleBetweenInfo,
applyConstraintAngleBetween, applyConstraintAngleBetween,
} from './Toolbar/SetAngleBetween' } from './Toolbar/SetAngleBetween'
import { applyConstraintAngleLength } from './Toolbar/setAngleLength' import { applyConstraintAngleLength } from './Toolbar/setAngleLength'
import { toast } from 'react-hot-toast'
import { pathMapToSelections } from 'lang/util' import { pathMapToSelections } from 'lang/util'
import { useStore } from 'useStore' 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 { applyConstraintIntersect } from './Toolbar/Intersect'
import { applyConstraintAbsDistance } from './Toolbar/SetAbsDistance' import { applyConstraintAbsDistance } from './Toolbar/SetAbsDistance'
import useStateMachineCommands from 'hooks/useStateMachineCommands'
import { modelingMachineConfig } from 'lib/commandBarConfigs/modelingCommandConfig'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -59,6 +66,7 @@ export const ModelingMachineProvider = ({
children: React.ReactNode children: React.ReactNode
}) => { }) => {
const { auth } = useGlobalStateContext() const { auth } = useGlobalStateContext()
const { code } = useKclContext()
const token = auth?.context?.token const token = auth?.context?.token
const streamRef = useRef<HTMLDivElement>(null) const streamRef = useRef<HTMLDivElement>(null)
useSetupEngineManager(streamRef, token) useSetupEngineManager(streamRef, token)
@ -68,8 +76,6 @@ export const ModelingMachineProvider = ({
editorView: s.editorView, editorView: s.editorView,
})) }))
// const { commands } = useCommandsContext()
// Settings machine setup // Settings machine setup
// const retrievedSettings = useRef( // const retrievedSettings = useRef(
// localStorage?.getItem(MODELING_PERSIST_KEY) || '{}' // localStorage?.getItem(MODELING_PERSIST_KEY) || '{}'
@ -83,148 +89,85 @@ export const ModelingMachineProvider = ({
// > // >
// ) // )
const [modelingState, modelingSend] = useMachine(modelingMachine, { const [modelingState, modelingSend, modelingActor] = useMachine(
// context: persistedSettings, modelingMachine,
actions: { {
'Modify AST': () => {}, // context: persistedSettings,
'Update code selection cursors': () => {}, actions: {
'show default planes': () => { 'Modify AST': () => {},
kclManager.showPlanes() 'Update code selection cursors': () => {},
}, 'show default planes': () => {
'create path': assign({ kclManager.showPlanes()
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
}, },
}), 'create path': assign({
'AST start new sketch': assign( sketchEnginePathId: () => {
({ sketchEnginePathId }, { data: { coords, axis, segmentId } }) => { const sketchUuid = uuidv4()
if (!axis) { engineCommandManager.sendSceneCommand({
// Something really weird must have happened for this to happen. type: 'modeling_cmd_req',
console.error('axis is undefined for starting a new sketch') cmd_id: sketchUuid,
return {} cmd: {
} type: 'start_path',
if (!segmentId) { },
// Something really weird must have happened for this to happen. })
console.error('segmentId is undefined for starting a new sketch') engineCommandManager.sendSceneCommand({
return {} type: 'modeling_cmd_req',
} cmd_id: uuidv4(),
cmd: {
const _addStartSketch = addStartSketch( type: 'edit_mode_enter',
kclManager.ast, target: sketchUuid,
axis, },
[roundOff(coords[0].x), roundOff(coords[0].y)], })
[ return sketchUuid
roundOff(coords[1].x - coords[0].x), },
roundOff(coords[1].y - coords[0].y), }),
] 'AST start new sketch': assign(
) ({ sketchEnginePathId }, { data: { coords, axis, segmentId } }) => {
const _modifiedAst = _addStartSketch.modifiedAst if (!axis) {
const _pathToNode = _addStartSketch.pathToNode // Something really weird must have happened for this to happen.
const newCode = recast(_modifiedAst) console.error('axis is undefined for starting a new sketch')
const astWithUpdatedSource = parse(newCode) return {}
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,
} }
const lineCallExp = updatedPipeNode.body.find( if (!segmentId) {
(exp) => exp.type === 'CallExpression' && exp.callee.name === 'line' // Something really weird must have happened for this to happen.
) console.error('segmentId is undefined for starting a new sketch')
if (lineCallExp) return {}
engineCommandManager.artifactMap[segmentId] = {
type: 'result',
range: [lineCallExp.start, lineCallExp.end],
commandType: 'extend_path',
parentId: sketchEnginePathId,
data: null,
raw: {} as any,
} }
kclManager.executeAstMock(astWithUpdatedSource, true) const _addStartSketch = addStartSketch(
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>(
kclManager.ast, 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 ).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] = { engineCommandManager.artifactMap[segmentId] = {
type: 'result', type: 'result',
range: [lineCallExp.start, lineCallExp.end], range: [lineCallExp.start, lineCallExp.end],
@ -233,120 +176,189 @@ export const ModelingMachineProvider = ({
data: null, data: null,
raw: {} as any, 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) { kclManager.executeAstMock(astWithUpdatedSource, true)
// engineCommandManager
// .sendSceneCommand({
// type: 'modeling_cmd_req',
// cmd: {
// type: 'select_clear',
// },
// cmd_id: uuidv4(),
// })
// .then(() => {
// engineCommandManager.sendSceneCommand(axisAddCmd)
// })
// } else {
// engineCommandManager.sendSceneCommand(axisAddCmd)
// }
const { return {
codeMirrorSelection, sketchPathToNode: _pathToNode,
selectionRangeTypeMap, }
otherSelections,
} = handleSelectionWithShift({
otherSelection: setSelections.selection,
currentSelections: selectionRanges,
isShiftDown,
})
setTimeout(() => {
editorView.dispatch({
selection: codeMirrorSelection,
})
})
return {
selectionRangeTypeMap,
selectionRanges: {
codeBasedSelections: selectionRanges.codeBasedSelections,
otherSelections,
},
} }
} else if (setSelections.selectionType === 'singleCodeCursor') { ),
// This DOES NOT set the `selectionRanges` in xstate context 'AST add line segment': async (
// instead it updates/dispatches to the editor, which in turn updates the xstate context { sketchPathToNode, sketchEnginePathId },
// I've found this the best way to deal with the editor without causing an infinite loop { data: { coords, segmentId } }
// 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. if (!sketchPathToNode) return
const lastCoord = coords[coords.length - 1]
// for more details on how selections see `src/lib/selections.ts`. const pathInfo = await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
const { cmd_id: uuidv4(),
codeMirrorSelection, cmd: {
selectionRangeTypeMap, type: 'path_get_info',
otherSelections, path_id: sketchEnginePathId,
} = handleSelectionWithShift({ },
codeSelection: setSelections.selection,
currentSelections: selectionRanges,
isShiftDown,
}) })
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(() => { setTimeout(() => {
editorView.dispatch({ editorView.dispatch({
selection: codeMirrorSelection, selection: codeMirrorSelection,
}) })
}) })
} return {
if (!setSelections.selection) { 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 { return {
selectionRangeTypeMap, selectionRangeTypeMap,
selectionRanges: { 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 { return {
selectionRangeTypeMap, selectionType: 'completeSelection',
selectionRanges: { selection: pathMapToSelections(
codeBasedSelections: selectionRanges.codeBasedSelections, kclManager.ast,
otherSelections, selectionRanges,
}, pathToNodeMap
),
} }
} },
// This DOES NOT set the `selectionRanges` in xstate context 'Get vertical info': async ({
// 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({
selectionRanges, selectionRanges,
}).enabled }): Promise<SetSelections> => {
? applyConstraintAngleBetween({ const { modifiedAst, pathToNodeMap } =
await applyConstraintHorzVertDistance({
constraint: 'setVertDistance',
selectionRanges, selectionRanges,
}) })
: applyConstraintAngleLength({ await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges, selectionRanges,
angleOrLength: 'setAngle', pathToNodeMap
})) ),
await kclManager.updateAst(modifiedAst, true) }
return { },
selectionType: 'completeSelection', 'Get angle info': async ({
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({
selectionRanges, selectionRanges,
}) }): Promise<SetSelections> => {
await kclManager.updateAst(modifiedAst, true) const { modifiedAst, pathToNodeMap } = await (angleBetweenInfo({
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, 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) 'Get length info': async ({
return { selectionRanges,
selectionType: 'completeSelection', }): Promise<SetSelections> => {
selection: pathMapToSelections( const { modifiedAst, pathToNodeMap } =
kclManager.ast, await applyConstraintAngleLength({ selectionRanges })
selectionRanges, await kclManager.updateAst(modifiedAst, true)
pathToNodeMap return {
), selectionType: 'completeSelection',
} selection: pathMapToSelections(
}, kclManager.ast,
'Get ABS Y info': async ({ selectionRanges }): Promise<SetSelections> => { selectionRanges,
const { modifiedAst, pathToNodeMap } = await applyConstraintAbsDistance( pathToNodeMap
{ ),
constraint: 'yAbs',
selectionRanges,
} }
) },
await kclManager.updateAst(modifiedAst, true) 'Get perpendicular distance info': async ({
return { selectionRanges,
selectionType: 'completeSelection', }): Promise<SetSelections> => {
selection: pathMapToSelections( const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect(
kclManager.ast, {
selectionRanges, selectionRanges,
pathToNodeMap }
), )
} 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(() => { useEffect(() => {
engineCommandManager.onPlaneSelected((plane_id: string) => { engineCommandManager.onPlaneSelected((plane_id: string) => {
@ -538,13 +559,17 @@ export const ModelingMachineProvider = ({
}) })
}, [modelingSend]) }, [modelingSend])
// useStateMachineCommands({ useStateMachineCommands({
// state: settingsState, machineId: 'modeling',
// send: settingsSend, state: modelingState,
// commands, send: modelingSend,
// owner: 'settings', actor: modelingActor,
// commandBarMeta: settingsCommandBarMeta, commandBarConfig: modelingMachineConfig,
// }) onCancel: () => {
console.log('firing onCancel!!')
modelingSend({ type: 'Cancel' })
},
})
return ( return (
<ModelingMachineContext.Provider <ModelingMachineContext.Provider

View File

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

View File

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

View File

@ -143,7 +143,7 @@ function ProjectCard({
className: 'p-1', className: 'p-1',
size: 'xs', size: 'xs',
bgClassName: 'bg-destroy-80', 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" className="!p-0 hover:border-destroy-40 dark:hover:border-destroy-40"
onClick={(e) => { onClick={(e) => {
@ -185,8 +185,7 @@ function ProjectCard({
bgClassName: 'bg-destroy-80', bgClassName: 'bg-destroy-80',
className: 'p-1', className: 'p-1',
size: 'sm', size: 'sm',
iconClassName: iconClassName: '!text-destroy-70 dark:!text-destroy-40',
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10',
}} }}
className="hover:border-destroy-40 dark:hover:border-destroy-40" 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 ProjectSidebarMenu from './ProjectSidebarMenu'
import { ProjectWithEntryPointMetadata } from '../Router' import { ProjectWithEntryPointMetadata } from '../Router'
import { GlobalStateProvider } from './GlobalStateProvider' import { GlobalStateProvider } from './GlobalStateProvider'
import CommandBarProvider from './CommandBar' import CommandBarProvider from './CommandBar/CommandBar'
const now = new Date() const now = new Date()
const projectWellFormed = { const projectWellFormed = {

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import {
} from 'react-router-dom' } from 'react-router-dom'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import { GlobalStateProvider } from './GlobalStateProvider' import { GlobalStateProvider } from './GlobalStateProvider'
import CommandBarProvider from './CommandBar' import CommandBarProvider from './CommandBar/CommandBar'
type User = Models['User_type'] 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' import { useContext } from 'react'
export const useCommandsContext = () => { 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 { useEffect } from 'react'
import { AnyStateMachine, StateFrom } from 'xstate' import { AnyStateMachine, InterpreterFrom, StateFrom } from 'xstate'
import { import { createMachineCommand } from '../lib/createMachineCommand'
Command,
CommandBarConfig,
createMachineCommand,
} from '../lib/commands'
import { useCommandsContext } from './useCommandsContext' 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> state: StateFrom<T>
send: Function send: Function
commandBarConfig?: CommandBarConfig<T> actor?: InterpreterFrom<T>
commands: Command[] commandBarConfig?: CommandSetConfig<T, S>
owner: string onCancel?: () => void
} }
export default function useStateMachineCommands<T extends AnyStateMachine>({ export default function useStateMachineCommands<
T extends AnyStateMachine,
S extends CommandSetSchema<T>
>({
machineId,
state, state,
send, send,
actor,
commandBarConfig, commandBarConfig,
owner, onCancel,
}: UseStateMachineCommandsArgs<T>) { }: UseStateMachineCommandsArgs<T, S>) {
const { addCommands, removeCommands } = useCommandsContext() const { commandBarSend } = useCommandsContext()
useEffect(() => { useEffect(() => {
const newCommands = state.nextEvents const newCommands = state.nextEvents
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n))) .filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
.map((type) => .map((type) =>
createMachineCommand<T>({ createMachineCommand<T, S>({
ownerMachine: machineId,
type, type,
state, state,
send, send,
actor,
commandBarConfig, commandBarConfig,
owner, onCancel,
}) })
) )
.filter((c) => c !== null) as Command[] // TS isn't smart enough to know this filter removes nulls .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 () => { return () => {
removeCommands(newCommands) commandBarSend({
type: 'Remove commands',
data: { commands: newCommands },
})
} }
}, [state]) }, [state])
} }

View File

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

View File

@ -248,7 +248,8 @@ export function mutateObjExpProp(
export function extrudeSketch( export function extrudeSketch(
node: Program, node: Program,
pathToNode: PathToNode, pathToNode: PathToNode,
shouldPipe = true shouldPipe = true,
distance = 4
): { ): {
modifiedAst: Program modifiedAst: Program
pathToNode: PathToNode pathToNode: PathToNode
@ -274,7 +275,7 @@ export function extrudeSketch(
getNodeFromPath<VariableDeclarator>(_node, pathToNode, 'VariableDeclarator') getNodeFromPath<VariableDeclarator>(_node, pathToNode, 'VariableDeclarator')
const extrudeCall = createCallExpressionStdLib('extrude', [ const extrudeCall = createCallExpressionStdLib('extrude', [
createLiteral(4), createLiteral(distance),
shouldPipe shouldPipe
? createPipeSubstitution() ? 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 { kclManager } from 'lang/KclSinglton'
import { SelectionRange } from '@uiw/react-codemirror' import { SelectionRange } from '@uiw/react-codemirror'
import { isOverlap } from 'lib/utils' 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 X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b'
export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01' 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 { createMachine, assign } from 'xstate'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import withBaseURL from '../lib/withBaseURL' import withBaseURL from '../lib/withBaseURL'
import { CommandBarConfig } from '../lib/commands'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { invoke } from '@tauri-apps/api' import { invoke } from '@tauri-apps/api'
import { VITE_KC_API_BASE_URL } from 'env' import { VITE_KC_API_BASE_URL } from 'env'
@ -40,16 +39,6 @@ export type Events =
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY' export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || '' const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
export const authCommandBarConfig: CommandBarConfig<typeof authMachine> = {
'Log in': {
hide: 'both',
},
'Log out': {
args: [],
icon: 'arrowLeft',
},
}
export const authMachine = createMachine<UserContext, Events>( export const authMachine = createMachine<UserContext, Events>(
{ {
id: 'Auth', 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 { assign, createMachine } from 'xstate'
import { ProjectWithEntryPointMetadata } from '../Router' import { ProjectWithEntryPointMetadata } from '../Router'
import { CommandBarConfig } from '../lib/commands' import { HomeCommandSchema } from 'lib/commandBarConfigs/homeCommandConfig'
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',
},
],
},
}
export const homeMachine = createMachine( export const homeMachine = createMachine(
{ {
@ -188,10 +138,10 @@ export const homeMachine = createMachine(
schema: { schema: {
events: {} as events: {} as
| { type: 'Open project'; data: { name: string } } | { type: 'Open project'; data: HomeCommandSchema['Open project'] }
| { type: 'Rename project'; data: { oldName: string; newName: string } } | { type: 'Rename project'; data: HomeCommandSchema['Rename project'] }
| { type: 'Create project'; data: { name: string } } | { type: 'Create project'; data: HomeCommandSchema['Create project'] }
| { type: 'Delete project'; data: { name: string } } | { type: 'Delete project'; data: HomeCommandSchema['Delete project'] }
| { type: 'navigate'; data: { name: string } } | { type: 'navigate'; data: { name: string } }
| { | {
type: 'done.invoke.read-projects' 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"; "Get vertical info": "done.invoke.get-vertical-info";
}; };
missingImplementations: { 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; 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"; 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: { eventsCausingActions: {
"AST add line segment": "Add point"; "AST add line segment": "Add point";
"AST extrude": "" | "extrude intent"; "AST extrude": "Extrude";
"AST start new sketch": "Add point"; "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 code-based selection": "Deselect point" | "Deselect segment" | "Select all" | "Select edge" | "Select face" | "Select point" | "Select segment";
"Add to other selection": "Select axis"; "Add to other selection": "Select axis";
@ -63,7 +63,7 @@
"edit mode enter": "Enter sketch" | "Re-execute"; "edit mode enter": "Enter sketch" | "Re-execute";
"edit_mode_exit": "Cancel"; "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"; "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"; "reset sketch metadata": "Cancel" | "Select default plane";
"set default plane id": "Select default plane"; "set default plane id": "Select default plane";
"set sketch metadata": "Enter sketch"; "set sketch metadata": "Enter sketch";
@ -72,9 +72,8 @@
"set tool line": "Equip tool"; "set tool line": "Equip tool";
"set tool move": "Equip move tool" | "Re-execute" | "Set selection"; "set tool move": "Equip move tool" | "Re-execute" | "Set selection";
"show default planes": "Enter sketch"; "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"; "sketch mode enabled": "Enter sketch" | "Re-execute" | "Select default plane";
"toast extrude failed": "";
}; };
eventsCausingDelays: { eventsCausingDelays: {
@ -105,8 +104,7 @@
"Selection is one face": "Enter sketch"; "Selection is one face": "Enter sketch";
"can move": ""; "can move": "";
"can move with execute": ""; "can move with execute": "";
"has no selection": "extrude intent"; "has valid extrude selection": "Extrude";
"has valid extrude selection": "" | "extrude intent";
"is editing existing sketch": ""; "is editing existing sketch": "";
}; };
eventsCausingServices: { eventsCausingServices: {
@ -118,7 +116,7 @@
"Get perpendicular distance info": "Constrain perpendicular distance"; "Get perpendicular distance info": "Constrain perpendicular distance";
"Get vertical info": "Constrain vertical 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"; }; }; "Move Tool"?: "Move init" | "Move with execute" | "Move without re-execute" | "No move"; }; };
tags: never; tags: never;
} }

View File

@ -1,7 +1,6 @@
import { assign, createMachine } from 'xstate' import { assign, createMachine } from 'xstate'
import { CommandBarConfig } from '../lib/commands'
import { Themes, getSystemTheme, setThemeClass } from '../lib/theme' import { Themes, getSystemTheme, setThemeClass } from '../lib/theme'
import { CameraSystem, cameraSystems } from 'lib/cameraControls' import { CameraSystem } from 'lib/cameraControls'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
export const DEFAULT_PROJECT_NAME = 'project-$nnn' 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 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( export const settingsMachine = createMachine(
{ {
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */ /** @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 { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
import Loading from '../components/Loading' import Loading from '../components/Loading'
import { useMachine } from '@xstate/react' import { useMachine } from '@xstate/react'
import { homeCommandConfig, homeMachine } from '../machines/homeMachine' import { homeMachine } from '../machines/homeMachine'
import { ContextFrom, EventFrom } from 'xstate' import { ContextFrom, EventFrom } from 'xstate'
import { paths } from '../Router' import { paths } from '../Router'
import { import {
@ -30,11 +30,12 @@ import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { DEFAULT_PROJECT_NAME } from 'machines/settingsMachine' import { DEFAULT_PROJECT_NAME } from 'machines/settingsMachine'
import { sep } from '@tauri-apps/api/path' import { sep } from '@tauri-apps/api/path'
import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig'
// This route only opens in the Tauri desktop context for now, // This route only opens in the Tauri desktop context for now,
// as defined in Router.tsx, so we can use the Tauri APIs and types. // as defined in Router.tsx, so we can use the Tauri APIs and types.
const Home = () => { const Home = () => {
const { commands, setCommandBarOpen } = useCommandsContext() const { commandBarSend } = useCommandsContext()
const navigate = useNavigate() const navigate = useNavigate()
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
const { const {
@ -56,7 +57,7 @@ const Home = () => {
event: EventFrom<typeof homeMachine> event: EventFrom<typeof homeMachine>
) => { ) => {
if (event.data && 'name' in event.data) { if (event.data && 'name' in event.data) {
setCommandBarOpen(false) commandBarSend({ type: 'Close' })
navigate( navigate(
`${paths.FILE}/${encodeURIComponent( `${paths.FILE}/${encodeURIComponent(
context.defaultDirectory + sep + event.data.name context.defaultDirectory + sep + event.data.name
@ -143,12 +144,11 @@ const Home = () => {
const isSortByModified = sort?.includes('modified') || !sort || sort === null const isSortByModified = sort?.includes('modified') || !sort || sort === null
useStateMachineCommands<typeof homeMachine>({ useStateMachineCommands({
commands, machineId: 'home',
send, send,
state, state,
commandBarConfig: homeCommandConfig, commandBarConfig: homeCommandBarConfig,
owner: 'home',
}) })
useEffect(() => { useEffect(() => {

View File

@ -1,7 +1,6 @@
import usePlatform from 'hooks/usePlatform'
import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { Platform, platform } from '@tauri-apps/api/os'
import { useEffect, useState } from 'react'
export default function CmdK() { export default function CmdK() {
const { buttonDownInStream } = useStore((s) => ({ const { buttonDownInStream } = useStore((s) => ({
@ -9,14 +8,7 @@ export default function CmdK() {
})) }))
const dismiss = useDismiss() const dismiss = useDismiss()
const next = useNextClick(onboardingPaths.USER_MENU) const next = useNextClick(onboardingPaths.USER_MENU)
const [platformName, setPlatformName] = useState<Platform | ''>('') const platformName = usePlatform()
useEffect(() => {
async function getPlatform() {
setPlatformName(await platform())
}
void getPlatform()
}, [setPlatformName])
return ( return (
<div className="fixed inset-0 z-50 grid items-end justify-center pointer-events-none"> <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> <h2 className="text-2xl">Command Bar</h2>
<p className="my-4"> <p className="my-4">
Press{' '} 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. 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 { ActionButton } from '../components/ActionButton'
import { isTauri } from '../lib/isTauri' import { isTauri } from '../lib/isTauri'
import { invoke } from '@tauri-apps/api/tauri' import { invoke } from '@tauri-apps/api/tauri'
@ -65,7 +64,7 @@ const SignIn = () => {
<ActionButton <ActionButton
Element="button" Element="button"
onClick={signInTauri} onClick={signInTauri}
icon={{ icon: faSignInAlt }} icon={{ icon: 'arrowRight' }}
className="w-fit mt-4" className="w-fit mt-4"
data-testid="sign-in-button" data-testid="sign-in-button"
> >
@ -80,7 +79,7 @@ const SignIn = () => {
typeof window !== 'undefined' && typeof window !== 'undefined' &&
window.location.href.replace('signin', '') window.location.href.replace('signin', '')
)}`} )}`}
icon={{ icon: faSignInAlt }} icon={{ icon: 'arrowRight' }}
className="w-fit mt-4" className="w-fit mt-4"
> >
Sign in Sign in

View File

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