First draft of a feature tree pane (#4782)

This commit is contained in:
Frank Noirot
2024-12-20 16:19:59 -05:00
committed by GitHub
parent 1d06cc7845
commit c02e31a530
78 changed files with 1466 additions and 65 deletions

View File

@ -0,0 +1,127 @@
import { test, expect } from './zoo-test'
import * as fsp from 'fs/promises'
import { join } from 'path'
const FEATURE_TREE_EXAMPLE_CODE = `export fn timesFive(x) {
return 5 * x
}
export fn triangle() {
return startSketchOn('XZ')
|> startProfileAt([0, 0], %)
|> xLine(10, %)
|> line([-10, -5], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
}
length001 = timesFive(1) * 5
sketch001 = startSketchOn('XZ')
|> startProfileAt([20, 10], %)
|> line([10, 10], %)
|> angledLine([-45, length001], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
revolve001 = revolve({ axis = "X" }, sketch001)
triangle()
|> extrude(30, %)
plane001 = offsetPlane('XY', 10)
sketch002 = startSketchOn(plane001)
|> startProfileAt([-20, 0], %)
|> line([5, -15], %)
|> xLine(-10, %)
|> lineTo([-40, 0], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(10, sketch002)
`
test.describe('Feature Tree pane', () => {
test(
'User can go to definition and go to function definition',
{ tag: '@electron' },
async ({ context, homePage, scene, editor, toolbar }) => {
await context.folderSetupFn(async (dir) => {
const bracketDir = join(dir, 'test-sample')
await fsp.mkdir(bracketDir, { recursive: true })
await fsp.writeFile(
join(bracketDir, 'main.kcl'),
FEATURE_TREE_EXAMPLE_CODE,
'utf-8'
)
})
await test.step('setup test', async () => {
await homePage.expectState({
projectCards: [
{
title: 'test-sample',
fileCount: 1,
},
],
sortBy: 'last-modified-desc',
})
await homePage.openProject('test-sample')
await scene.waitForExecutionDone()
await editor.closePane()
await toolbar.openFeatureTreePane()
})
async function testViewSource({
operationName,
operationIndex,
expectedActiveLine,
}: {
operationName: string
operationIndex: number
expectedActiveLine: string
}) {
await test.step(`Go to definition of the ${operationName}`, async () => {
await toolbar.viewSourceOnOperation(operationName, operationIndex)
await editor.expectState({
highlightedCode: '',
diagnostics: [],
activeLines: [expectedActiveLine],
})
await expect(
editor.activeLine.first(),
`${operationName} code should be scrolled into view`
).toBeVisible()
})
}
await testViewSource({
operationName: 'Offset Plane',
operationIndex: 0,
expectedActiveLine: "plane001 = offsetPlane('XY', 10)",
})
await testViewSource({
operationName: 'Extrude',
operationIndex: 1,
expectedActiveLine: 'extrude001 = extrude(10, sketch002)',
})
await testViewSource({
operationName: 'Revolve',
operationIndex: 0,
expectedActiveLine: 'revolve001 = revolve({ axis = "X" }, sketch001)',
})
await testViewSource({
operationName: 'Triangle',
operationIndex: 0,
expectedActiveLine: 'triangle()',
})
await test.step('Go to definition on the triangle function', async () => {
await toolbar.goToDefinitionOnOperation('Triangle', 0)
await editor.expectState({
highlightedCode: '',
diagnostics: [],
activeLines: ['export fn triangle() {'],
})
await expect(
editor.activeLine.first(),
'Triangle function definition should be scrolled into view'
).toBeVisible()
})
}
)
})

View File

@ -20,7 +20,7 @@ export class EditorFixture {
private diagnosticsTooltip!: Locator private diagnosticsTooltip!: Locator
private diagnosticsGutterIcon!: Locator private diagnosticsGutterIcon!: Locator
private codeContent!: Locator private codeContent!: Locator
private activeLine!: Locator public activeLine!: Locator
constructor(page: Page) { constructor(page: Page) {
this.page = page this.page = page

View File

@ -1,6 +1,13 @@
import type { Page, Locator } from '@playwright/test' import type { Page, Locator } from '@playwright/test'
import { expect } from '../zoo-test' import { expect } from '../zoo-test'
import { doAndWaitForImageDiff } from '../test-utils' import {
checkIfPaneIsOpen,
closePane,
doAndWaitForImageDiff,
openPane,
} from '../test-utils'
import { SidebarType } from 'components/ModelingSidebar/ModelingPanes'
import { SIDEBAR_BUTTON_SUFFIX } from 'lib/constants'
export class ToolbarFixture { export class ToolbarFixture {
public page: Page public page: Page
@ -20,6 +27,10 @@ export class ToolbarFixture {
filePane!: Locator filePane!: Locator
exeIndicator!: Locator exeIndicator!: Locator
treeInputField!: Locator treeInputField!: Locator
/** The sidebar button for the Feature Tree pane */
featureTreeId = 'feature-tree' as const
/** The pane element for the Feature Tree */
featureTreePane!: Locator
constructor(page: Page) { constructor(page: Page) {
this.page = page this.page = page
@ -41,6 +52,7 @@ export class ToolbarFixture {
this.treeInputField = page.getByTestId('tree-input-field') this.treeInputField = page.getByTestId('tree-input-field')
this.filePane = page.locator('#files-pane') this.filePane = page.locator('#files-pane')
this.featureTreePane = page.locator('#feature-tree-pane')
this.fileCreateToast = page.getByText('Successfully created') this.fileCreateToast = page.getByText('Successfully created')
this.exeIndicator = page.getByTestId('model-state-indicator-execution-done') this.exeIndicator = page.getByTestId('model-state-indicator-execution-done')
} }
@ -91,4 +103,76 @@ export class ToolbarFixture {
await expect(this.exeIndicator).toBeVisible({ timeout: 15_000 }) await expect(this.exeIndicator).toBeVisible({ timeout: 15_000 })
} }
} }
async closePane(paneId: SidebarType) {
return closePane(this.page, paneId + SIDEBAR_BUTTON_SUFFIX)
}
async openPane(paneId: SidebarType) {
return openPane(this.page, paneId + SIDEBAR_BUTTON_SUFFIX)
}
async checkIfPaneIsOpen(paneId: SidebarType) {
return checkIfPaneIsOpen(this.page, paneId + SIDEBAR_BUTTON_SUFFIX)
}
async openFeatureTreePane() {
return this.openPane(this.featureTreeId)
}
async closeFeatureTreePane() {
await this.closePane(this.featureTreeId)
}
async checkIfFeatureTreePaneIsOpen() {
return this.checkIfPaneIsOpen(this.featureTreeId)
}
/**
* Get a specific operation button from the Feature Tree pane
*/
async getFeatureTreeOperation(operationName: string, operationIndex: number) {
await this.openFeatureTreePane()
await expect(this.featureTreePane).toBeVisible()
return this.featureTreePane
.getByRole('button', {
name: operationName,
})
.nth(operationIndex)
}
/**
* View source on a specific operation in the Feature Tree pane.
* @param operationName The name of the operation type
* @param operationIndex The index out of operations of this type
*/
async viewSourceOnOperation(operationName: string, operationIndex: number) {
const operationButton = await this.getFeatureTreeOperation(
operationName,
operationIndex
)
const viewSourceMenuButton = this.page.getByRole('button', {
name: 'View KCL source code',
})
await operationButton.click({ button: 'right' })
await expect(viewSourceMenuButton).toBeVisible()
await viewSourceMenuButton.click()
}
/**
* Go to definition on a specific operation in the Feature Tree pane
*/
async goToDefinitionOnOperation(
operationName: string,
operationIndex: number
) {
const operationButton = await this.getFeatureTreeOperation(
operationName,
operationIndex
)
const goToDefinitionMenuButton = this.page.getByRole('button', {
name: 'View function definition',
})
await operationButton.click({ button: 'right' })
await expect(goToDefinitionMenuButton).toBeVisible()
await goToDefinitionMenuButton.click()
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -486,7 +486,7 @@ test.describe('Testing selections', () => {
await u.clearCommandLogs() await u.clearCommandLogs()
await page.keyboard.press('Backspace') await page.keyboard.press('Backspace')
await expect(page.getByText('Unable to delete part')).toBeVisible() await expect(page.getByText('Unable to delete selection')).toBeVisible()
}) })
test('Hovering over 3d features highlights code, clicking puts the cursor in the right place and sends selection id to engine', async ({ test('Hovering over 3d features highlights code, clicking puts the cursor in the right place and sends selection id to engine', async ({
page, page,

View File

@ -21,7 +21,8 @@ export function AstExplorer() {
const node = _node const node = _node
return ( return (
<div id="ast-explorer" className="relative"> <details id="ast-explorer" className="relative">
<summary>AST Explorer</summary>
<div className=""> <div className="">
filter out keys:<div className="w-2 inline-block"></div> filter out keys:<div className="w-2 inline-block"></div>
{['start', 'end', 'type'].map((key) => { {['start', 'end', 'type'].map((key) => {
@ -58,7 +59,7 @@ export function AstExplorer() {
/> />
</pre> </pre>
</div> </div>
</div> </details>
) )
} }

View File

@ -392,6 +392,26 @@ const CustomIconMap = {
/> />
</svg> </svg>
), ),
eyeOpen: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 14.5C5.58172 14.5 3 10.5 3 10.5C3 10.5 5.58172 5.5 10 5.5C14.4183 5.5 17 10.5 17 10.5C17 10.5 14.4183 14.5 10 14.5ZM4.24209 10.4865L4.19946 10.4348C4.23234 10.3833 4.26774 10.3288 4.30565 10.2717C4.59304 9.83862 5.01753 9.26342 5.56519 8.69184C6.67884 7.52958 8.18459 6.5 10 6.5C11.8154 6.5 13.3212 7.52958 14.4348 8.69184C14.9825 9.26342 15.407 9.83862 15.6944 10.2717C15.7323 10.3288 15.7677 10.3833 15.8005 10.4348L15.7579 10.4865C15.4766 10.8257 15.0582 11.2796 14.516 11.7324C13.4249 12.6433 11.8946 13.5 10 13.5C8.10539 13.5 6.57507 12.6433 5.48405 11.7324C4.9418 11.2796 4.52342 10.8257 4.24209 10.4865ZM12 10C12 11.1046 11.1046 12 10 12C8.89543 12 8 11.1046 8 10C8 8.89543 8.89543 8 10 8C11.1046 8 12 8.89543 12 10ZM13 10C13 11.6569 11.6569 13 10 13C8.34315 13 7 11.6569 7 10C7 8.34315 8.34315 7 10 7C11.6569 7 13 8.34315 13 10Z"
fill="currentColor"
/>
</svg>
),
eyeCrossedOut: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.35352 5.39647L14.253 15.296L14.9601 14.5889L5.06062 4.68936L4.35352 5.39647ZM10.9303 13.4303L11.7785 14.2785C11.2246 14.4184 10.631 14.5 10 14.5C5.58172 14.5 3 10.5 3 10.5C3 10.5 3.76379 9.02078 5.17147 7.67148L5.8787 8.37872C5.771 8.48155 5.66647 8.58616 5.56519 8.69186C5.01753 9.26343 4.59304 9.83863 4.30565 10.2717C4.26774 10.3288 4.23234 10.3833 4.19946 10.4348L4.24209 10.4866C4.52342 10.8257 4.9418 11.2797 5.48405 11.7324C6.57507 12.6433 8.10539 13.5 10 13.5C10.3206 13.5 10.6309 13.4755 10.9303 13.4303ZM10 5.50001C9.16896 5.50001 8.4029 5.6769 7.70677 5.96414L8.48545 6.74282C8.96231 6.58848 9.46785 6.50001 10 6.50001C11.8154 6.50001 13.3212 7.52959 14.4348 8.69186C14.9825 9.26343 15.407 9.83863 15.6944 10.2717C15.7323 10.3288 15.7677 10.3833 15.8005 10.4348L15.7579 10.4866C15.4766 10.8257 15.0582 11.2797 14.516 11.7324C14.3321 11.8859 14.1357 12.0379 13.9272 12.1845L14.6438 12.9011C16.1692 11.7871 17 10.5 17 10.5C17 10.5 14.4183 5.50001 10 5.50001ZM10 7.00001C9.62554 7.00001 9.2671 7.06862 8.93658 7.19395L9.75723 8.0146C9.8368 8.00497 9.91782 8.00001 10 8.00001C11.1046 8.00001 12 8.89544 12 10C12 10.0822 11.995 10.1632 11.9854 10.2428L12.8061 11.0634C12.9314 10.7329 13 10.3745 13 10C13 8.34316 11.6569 7.00001 10 7.00001ZM7 10C7 9.8421 7.0122 9.68704 7.03571 9.53572L8.08776 10.5878C8.28175 11.2197 8.78035 11.7183 9.41224 11.9123L10.4643 12.9643C10.313 12.9878 10.1579 13 10 13C8.34315 13 7 11.6569 7 10Z"
fill="currentColor"
/>
</svg>
),
fillet: ( fillet: (
<svg <svg
viewBox="0 0 20 20" viewBox="0 0 20 20"
@ -533,6 +553,16 @@ const CustomIconMap = {
/> />
</svg> </svg>
), ),
hollow: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.67443 5.38863L7.24585 3.87359L6.55986 3.15721L4.51827 5.12555L4.5391 5.14731L4.51129 5.14139V6.14139V13.9073V14.9073L5.48944 15.1152L12.5048 16.6064L13.4829 16.8143V16.7896L13.5043 16.8119L15.5459 14.8436L14.8599 14.1272L13.4829 15.4548V8.04838V7.63961L13.5043 7.66193L15.5459 5.69359L14.8599 4.97721L12.851 6.91405L12.5048 6.84046L5.67443 5.38863ZM12.5048 7.84046L5.48944 6.34931V14.1152L12.5048 15.6064V7.84046ZM6.21381 8.04101V8.51384V8.54101L7.1075 8.73098V8.7038L7.19195 8.72175V7.74893L7.1075 7.73098L6.70288 7.64497L6.21381 7.54101V8.04101ZM6.21381 12.4051V12.3779L7.1075 12.5679V12.5951L7.19195 12.613V13.5859L7.1075 13.5679L6.70288 13.4819L6.21381 13.3779V12.8779V12.4051ZM10.6823 14.3277L10.5978 14.3098V13.337L10.6823 13.3549V13.3277L11.576 13.5177V13.5449V14.0177V14.5177L11.0869 14.4138L10.6823 14.3277ZM11.576 9.6536V9.68078L10.6823 9.49082V9.46364L10.5978 9.44569V8.47287L10.6823 8.49082L11.0869 8.57683L11.576 8.68078V9.18078V9.6536ZM8.0012 8.42094V7.92094L9.7886 8.30086V8.80086V9.30086L8.0012 8.92094V8.42094ZM11.0869 10.5225L11.576 10.6264V12.5721L11.0869 12.4681L10.5978 12.3642V10.4185L11.0869 10.5225ZM9.7886 13.6378V14.1378L8.0012 13.7579V13.2579V12.7579L9.7886 13.1378V13.6378ZM6.70288 11.5363L6.21381 11.4323V9.48666L6.70288 9.59061L7.19195 9.69457V11.6402L6.70288 11.5363Z"
fill="currentColor"
/>
</svg>
),
horizontal: ( horizontal: (
<svg <svg
viewBox="0 0 20 20" viewBox="0 0 20 20"
@ -563,6 +593,22 @@ const CustomIconMap = {
/> />
</svg> </svg>
), ),
import: (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.64645 12.3535L10 12.7071L10.3536 12.3535L13.8536 8.85352L13.1464 8.14642L10.5 10.7929L10.5 3H9.5L9.5 10.7929L6.85355 8.14642L6.14645 8.85352L9.64645 12.3535ZM15 5H12.4999V4H15H16V5V15V16H15H5H4V15V5V4H5H7.49988V5H5V15H15V5Z"
fill="currentColor"
/>
</svg>
),
'intersection-offset': ( 'intersection-offset': (
<svg <svg
viewBox="0 0 20 20" viewBox="0 0 20 20"
@ -741,6 +787,16 @@ const CustomIconMap = {
/> />
</svg> </svg>
), ),
model: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.91773 10L10 6.89417L15.0823 10L10 13.1058L4.91773 10ZM10 5.72222L16.0411 9.41403L17 10L17 12.0541H16V10.6111L10.5 13.9722V16.0541H9.5V13.9722L4 10.6111V12.0541H3V10L3.95886 9.41403L10 5.72222Z"
fill="currentColor"
/>
</svg>
),
move: ( move: (
<svg <svg
viewBox="0 0 20 20" viewBox="0 0 20 20"
@ -801,6 +857,58 @@ const CustomIconMap = {
/> />
</svg> </svg>
), ),
patternCircular2d: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM12 4C12 5.10457 11.1046 6 10 6C8.89543 6 8 5.10457 8 4C8 2.89543 8.89543 2 10 2C11.1046 2 12 2.89543 12 4ZM16 7C16 7.55228 15.5523 8 15 8C14.4477 8 14 7.55228 14 7C14 6.44772 14.4477 6 15 6C15.5523 6 16 6.44772 16 7ZM17 7C17 8.10457 16.1046 9 15 9C13.8954 9 13 8.10457 13 7C13 5.89543 13.8954 5 15 5C16.1046 5 17 5.89543 17 7ZM15 14C15.5523 14 16 13.5523 16 13C16 12.4477 15.5523 12 15 12C14.4477 12 14 12.4477 14 13C14 13.5523 14.4477 14 15 14ZM15 15C16.1046 15 17 14.1046 17 13C17 11.8954 16.1046 11 15 11C13.8954 11 13 11.8954 13 13C13 14.1046 13.8954 15 15 15ZM11 16C11 16.5523 10.5523 17 10 17C9.44772 17 9 16.5523 9 16C9 15.4477 9.44772 15 10 15C10.5523 15 11 15.4477 11 16ZM12 16C12 17.1046 11.1046 18 10 18C8.89543 18 8 17.1046 8 16C8 14.8954 8.89543 14 10 14C11.1046 14 12 14.8954 12 16ZM5 14C5.55228 14 6 13.5523 6 13C6 12.4477 5.55228 12 5 12C4.44772 12 4 12.4477 4 13C4 13.5523 4.44772 14 5 14ZM5 15C6.10457 15 7 14.1046 7 13C7 11.8954 6.10457 11 5 11C3.89543 11 3 11.8954 3 13C3 14.1046 3.89543 15 5 15ZM6 7C6 7.55228 5.55228 8 5 8C4.44772 8 4 7.55228 4 7C4 6.44772 4.44772 6 5 6C5.55228 6 6 6.44772 6 7ZM7 7C7 8.10457 6.10457 9 5 9C3.89543 9 3 8.10457 3 7C3 5.89543 3.89543 5 5 5C6.10457 5 7 5.89543 7 7Z"
fill="currentColor"
/>
</svg>
),
patternCircular3d: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM12 4C12 5.10457 11.1046 6 10 6C8.89543 6 8 5.10457 8 4C8 2.89543 8.89543 2 10 2C11.1046 2 12 2.89543 12 4ZM16 7C16 7.55228 15.5523 8 15 8C14.4477 8 14 7.55228 14 7C14 6.44772 14.4477 6 15 6C15.5523 6 16 6.44772 16 7ZM17 7C17 8.10457 16.1046 9 15 9C13.8954 9 13 8.10457 13 7C13 5.89543 13.8954 5 15 5C16.1046 5 17 5.89543 17 7ZM15 14C15.5523 14 16 13.5523 16 13C16 12.4477 15.5523 12 15 12C14.4477 12 14 12.4477 14 13C14 13.5523 14.4477 14 15 14ZM15 15C16.1046 15 17 14.1046 17 13C17 11.8954 16.1046 11 15 11C13.8954 11 13 11.8954 13 13C13 14.1046 13.8954 15 15 15ZM11 16C11 16.5523 10.5523 17 10 17C9.44772 17 9 16.5523 9 16C9 15.4477 9.44772 15 10 15C10.5523 15 11 15.4477 11 16ZM12 16C12 17.1046 11.1046 18 10 18C8.89543 18 8 17.1046 8 16C8 14.8954 8.89543 14 10 14C11.1046 14 12 14.8954 12 16ZM5 14C5.55228 14 6 13.5523 6 13C6 12.4477 5.55228 12 5 12C4.44772 12 4 12.4477 4 13C4 13.5523 4.44772 14 5 14ZM5 15C6.10457 15 7 14.1046 7 13C7 11.8954 6.10457 11 5 11C3.89543 11 3 11.8954 3 13C3 14.1046 3.89543 15 5 15ZM6 7C6 7.55228 5.55228 8 5 8C4.44772 8 4 7.55228 4 7C4 6.44772 4.44772 6 5 6C5.55228 6 6 6.44772 6 7ZM7 7C7 8.10457 6.10457 9 5 9C3.89543 9 3 8.10457 3 7C3 5.89543 3.89543 5 5 5C6.10457 5 7 5.89543 7 7Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.642 5.1421C12.1606 4.78075 12.5 4.18001 12.5 3.5C12.5 2.39543 11.6046 1.5 10.5 1.5C9.82004 1.5 9.21933 1.83933 8.85797 2.35789C9.18176 2.13228 9.57543 1.99999 9.99999 1.99999C11.1046 1.99999 12 2.89542 12 3.99999C12 4.42459 11.8677 4.81829 11.642 5.1421ZM16.642 8.1421C17.1606 7.78075 17.5 7.18001 17.5 6.5C17.5 5.39543 16.6046 4.5 15.5 4.5C14.82 4.5 14.2193 4.83933 13.858 5.35789C14.1818 5.13228 14.5754 4.99999 15 4.99999C16.1046 4.99999 17 5.89542 17 6.99999C17 7.42459 16.8677 7.81829 16.642 8.1421ZM13.858 11.3579C14.1818 11.1323 14.5754 11 15 11C16.1046 11 17 11.8954 17 13C17 13.4246 16.8677 13.8183 16.642 14.1421C17.1606 13.7808 17.5 13.18 17.5 12.5C17.5 11.3954 16.6046 10.5 15.5 10.5C14.82 10.5 14.2193 10.8393 13.858 11.3579ZM11.642 17.1421C12.1606 16.7808 12.5 16.18 12.5 15.5C12.5 14.3954 11.6046 13.5 10.5 13.5C9.82004 13.5 9.21933 13.8393 8.85797 14.3579C9.18176 14.1323 9.57543 14 9.99999 14C11.1046 14 12 14.8954 12 16C12 16.4246 11.8677 16.8183 11.642 17.1421ZM6.64202 14.1421C7.16064 13.7808 7.50001 13.18 7.50001 12.5C7.50001 11.3954 6.60458 10.5 5.50001 10.5C4.82004 10.5 4.21933 10.8393 3.85797 11.3579C4.18176 11.1323 4.57543 11 4.99999 11C6.10456 11 6.99999 11.8954 6.99999 13C6.99999 13.4246 6.86767 13.8183 6.64202 14.1421ZM6.64202 8.1421C7.16064 7.78075 7.50001 7.18001 7.50001 6.5C7.50001 5.39543 6.60458 4.5 5.50001 4.5C4.82004 4.5 4.21933 4.83933 3.85797 5.35789C4.18176 5.13228 4.57543 4.99999 4.99999 4.99999C6.10456 4.99999 6.99999 5.89542 6.99999 6.99999C6.99999 7.42459 6.86767 7.81829 6.64202 8.1421Z"
fill="currentColor"
/>
</svg>
),
patternLinear2d: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 4H6V6H4V4ZM3 3H4H6H7V4V6V7H6H4H3V6V4V3ZM4 9H6V11H4V9ZM3 8H4H6H7V9V11V12H6H4H3V11V9V8ZM6 14H4V16H6V14ZM4 13H3V14V16V17H4H6H7V16V14V13H6H4ZM9 4H11V6H9V4ZM8 3H9H11H12V4V6V7H11H9H8V6V4V3ZM11 9H9V11H11V9ZM9 8H8V9V11V12H9H11H12V11V9V8H11H9ZM14 4H16V6H14V4ZM13 3H14H16H17V4V6V7H16H14H13V6V4V3Z"
fill="currentColor"
/>
</svg>
),
patternLinear3d: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 4H6V6H4V4ZM3 3H4H6H7V4V6V7H6H4H3V6V4V3ZM4 9H6V11H4V9ZM3 8H4H6H7V9V11V12H6H4H3V11V9V8ZM6 14H4V16H6V14ZM4 13H3V14V16V17H4H6H7V16V14V13H6H4ZM9 4H11V6H9V4ZM8 3H9H11H12V4V6V7H11H9H8V6V4V3ZM11 9H9V11H11V9ZM9 8H8V9V11V12H9H11H12V11V9V8H11H9ZM14 4H16V6H14V4ZM13 3H14H16H17V4V6V7H16H14H13V6V4V3Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.5 2.5H3.5V3H7V6.5H7.5V5.5V3.5V2.5H6.5H4.5ZM4.5 7.5H3.5V8H7V11.5H7.5V10.5V8.5V7.5H6.5H4.5ZM3.5 12.5H4.5H6.5H7.5V13.5V15.5V16.5H7V13H3.5V12.5ZM9.5 2.5H8.5V3H12V6.5H12.5V5.5V3.5V2.5H11.5H9.5ZM8.5 7.5H9.5H11.5H12.5V8.5V10.5V11.5H12V8H8.5V7.5ZM14.5 2.5H13.5V3H17V6.5H17.5V5.5V3.5V2.5H16.5H14.5Z"
fill="currentColor"
/>
</svg>
),
person: ( person: (
<svg <svg
viewBox="0 0 20 20" viewBox="0 0 20 20"

View File

@ -13,7 +13,11 @@ import { engineCommandManager } from '../lib/singletons'
import { Spinner } from './Spinner' import { Spinner } from './Spinner'
const Loading = ({ children }: React.PropsWithChildren) => { interface LoadingProps extends React.PropsWithChildren {
className?: string
}
const Loading = ({ children, className }: LoadingProps) => {
const [error, setError] = useState<ConnectionError>(ConnectionError.Unset) const [error, setError] = useState<ConnectionError>(ConnectionError.Unset)
useEffect(() => { useEffect(() => {
@ -64,7 +68,7 @@ const Loading = ({ children }: React.PropsWithChildren) => {
return ( return (
<div <div
className="body-bg flex flex-col items-center justify-center h-screen" className={`body-bg flex flex-col items-center justify-center h-screen ${className}`}
data-testid="loading" data-testid="loading"
> >
<Spinner /> <Spinner />

View File

@ -67,16 +67,18 @@ import {
startSketchOnDefault, startSketchOnDefault,
} from 'lang/modifyAst' } from 'lang/modifyAst'
import { PathToNode, Program, parse, recast, resultIsOk } from 'lang/wasm' import { PathToNode, Program, parse, recast, resultIsOk } from 'lang/wasm'
import { getNodePathFromSourceRange, isSingleCursorInPipe } from 'lang/queryAst' import {
artifactIsPlaneWithPaths,
getNodePathFromSourceRange,
isSingleCursorInPipe,
} from 'lang/queryAst'
import { exportFromEngine } from 'lib/exportFromEngine' import { exportFromEngine } from 'lib/exportFromEngine'
import { Models } from '@kittycad/lib/dist/types/src' import { Models } from '@kittycad/lib/dist/types/src'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { EditorSelection, Transaction } from '@codemirror/state'
import { useLoaderData, useNavigate, useSearchParams } from 'react-router-dom' import { useLoaderData, useNavigate, useSearchParams } from 'react-router-dom'
import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls' import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls'
import { err, reportRejection, trap } from 'lib/trap' import { err, reportRejection, trap } from 'lib/trap'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { modelingMachineEvent } from 'editor/manager'
import { import {
ExportIntent, ExportIntent,
EngineConnectionStateType, EngineConnectionStateType,
@ -88,6 +90,7 @@ import { uuidv4 } from 'lib/utils'
import { IndexLoaderData } from 'lib/types' import { IndexLoaderData } from 'lib/types'
import { Node } from 'wasm-lib/kcl/bindings/Node' import { Node } from 'wasm-lib/kcl/bindings/Node'
import { promptToEditFlow } from 'lib/promptToEdit' import { promptToEditFlow } from 'lib/promptToEdit'
import { kclEditorActor } from 'machines/kclEditorMachine'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -297,22 +300,6 @@ export const ModelingMachineProvider = ({
null null
if (!setSelections) return {} if (!setSelections) return {}
const dispatchSelection = (selection?: EditorSelection) => {
if (!selection) return // TODO less of hack for the below please
if (!editorManager.editorView) return
setTimeout(() => {
if (!editorManager.editorView) return
editorManager.editorView.dispatch({
selection,
annotations: [
modelingMachineEvent,
Transaction.addToHistory.of(false),
],
})
})
}
let selections: Selections = { let selections: Selections = {
graphSelections: [], graphSelections: [],
otherSelections: [], otherSelections: [],
@ -352,7 +339,15 @@ export const ModelingMachineProvider = ({
} = handleSelectionBatch({ } = handleSelectionBatch({
selections, selections,
}) })
codeMirrorSelection && dispatchSelection(codeMirrorSelection) if (codeMirrorSelection) {
kclEditorActor.send({
type: 'setLastSelectionEvent',
data: {
codeMirrorSelection,
scrollIntoView: setSelections.scrollIntoView ?? false,
},
})
}
engineEvents && engineEvents &&
engineEvents.forEach((event) => { engineEvents.forEach((event) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
@ -554,6 +549,9 @@ export const ModelingMachineProvider = ({
'Selection is on face': ({ context: { selectionRanges }, event }) => { 'Selection is on face': ({ context: { selectionRanges }, event }) => {
if (event.type !== 'Enter sketch') return false if (event.type !== 'Enter sketch') return false
if (event.data?.forceNewSketch) return false if (event.data?.forceNewSketch) return false
if (artifactIsPlaneWithPaths(selectionRanges)) {
return true
}
if (!isSingleCursorInPipe(selectionRanges, kclManager.ast)) if (!isSingleCursorInPipe(selectionRanges, kclManager.ast))
return false return false
return !!isCursorInSketchCommandRange( return !!isCursorInSketchCommandRange(

View File

@ -0,0 +1,381 @@
import { Diagnostic } from '@codemirror/lint'
import { useMachine, useSelector } from '@xstate/react'
import { ContextMenu, ContextMenuItem } from 'components/ContextMenu'
import { CustomIcon, CustomIconName } from 'components/CustomIcon'
import Loading from 'components/Loading'
import { useModelingContext } from 'hooks/useModelingContext'
import { useKclContext } from 'lang/KclProvider'
import { codeRefFromRange, getArtifactFromRange } from 'lang/std/artifactGraph'
import { sourceRangeFromRust } from 'lang/wasm'
import {
filterOperations,
getOperationIcon,
getOperationLabel,
} from 'lib/operations'
import { editorManager, engineCommandManager, kclManager } from 'lib/singletons'
import { ComponentProps, useEffect, useMemo, useRef, useState } from 'react'
import { Operation } from 'wasm-lib/kcl/bindings/Operation'
import { Actor, Prop } from 'xstate'
import { featureTreeMachine } from 'machines/featureTreeMachine'
import {
editorIsMountedSelector,
kclEditorActor,
selectionEventSelector,
} from 'machines/kclEditorMachine'
export const FeatureTreePane = () => {
const isEditorMounted = useSelector(kclEditorActor, editorIsMountedSelector)
const lastSelectionEvent = useSelector(kclEditorActor, selectionEventSelector)
const { send: modelingSend, state: modelingState } = useModelingContext()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_featureTreeState, featureTreeSend] = useMachine(
featureTreeMachine.provide({
guards: {
codePaneIsOpen: () =>
modelingState.context.store.openPanes.includes('code') &&
editorManager.editorView !== null,
},
actions: {
openCodePane: () => {
modelingSend({
type: 'Set context',
data: {
openPanes: [...modelingState.context.store.openPanes, 'code'],
},
})
},
sendEditFlowStart: () => {
modelingSend({ type: 'Enter sketch' })
},
scrollToError: () => {
editorManager.scrollToFirstErrorDiagnosticIfExists()
},
sendSelectionEvent: ({ context }) => {
if (!context.targetSourceRange) {
return
}
const artifact = context.targetSourceRange
? getArtifactFromRange(
context.targetSourceRange,
engineCommandManager.artifactGraph
)
: null
if (!artifact || !('codeRef' in artifact)) {
modelingSend({
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: {
codeRef: codeRefFromRange(
context.targetSourceRange,
kclManager.ast
),
},
scrollIntoView: true,
},
})
} else {
modelingSend({
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: {
artifact: artifact,
codeRef: codeRefFromRange(
context.targetSourceRange,
kclManager.ast
),
},
scrollIntoView: true,
},
})
}
},
},
})
)
// If there are parse errors we show the last successful operations
// and overlay a message on top of the pane
const parseErrors = kclManager.errors.filter((e) => e.kind !== 'engine')
// If there are engine errors we show the successful operations
// Errors return an operation list, so use the longest one if there are multiple
const longestErrorOperationList = kclManager.errors.reduce((acc, error) => {
return error.operations && error.operations.length > acc.length
? error.operations
: acc
}, [] as Operation[])
const unfilteredOperationList = !parseErrors.length
? !kclManager.errors.length
? kclManager.execState.operations
: longestErrorOperationList
: kclManager.lastSuccessfulOperations
// We filter out operations that are not useful to show in the feature tree
const operationList = filterOperations(unfilteredOperationList)
// Watch for changes in the open panes and send an event to the feature tree machine
useEffect(() => {
const codeOpen = modelingState.context.store.openPanes.includes('code')
if (codeOpen && isEditorMounted) {
featureTreeSend({ type: 'codePaneOpened' })
}
}, [modelingState.context.store.openPanes, isEditorMounted])
// Watch for changes in the selection and send an event to the feature tree machine
useEffect(() => {
featureTreeSend({ type: 'selected' })
}, [lastSelectionEvent])
function goToError() {
featureTreeSend({ type: 'goToError' })
}
return (
<div className="relative">
<section
data-testid="debug-panel"
className="absolute inset-0 p-1 box-border overflow-auto"
>
{kclManager.isExecuting ? (
<Loading className="h-full">Building feature tree...</Loading>
) : (
<>
{parseErrors.length > 0 && (
<div
className={`absolute inset-0 rounded-lg p-2 ${
operationList.length &&
`bg-destroy-10/40 dark:bg-destroy-80/40`
}`}
>
<div className="text-sm bg-destroy-80 text-chalkboard-10 py-1 px-2 rounded flex gap-2 items-center">
<p className="flex-1">
Errors found in KCL code.
<br />
Please fix them before continuing.
</p>
<button
onClick={goToError}
className="bg-chalkboard-10 text-destroy-80 p-1 rounded-sm flex-none hover:bg-chalkboard-10 hover:border-destroy-70 hover:text-destroy-80 border-transparent"
>
View error
</button>
</div>
</div>
)}
{operationList.map((operation) => {
const key = `${operation.type}-${
'name' in operation ? operation.name : 'anonymous'
}-${
'sourceRange' in operation ? operation.sourceRange[0] : 'start'
}`
return (
<OperationItem
key={key}
item={operation}
send={featureTreeSend}
/>
)
})}
</>
)}
</section>
</div>
)
}
export const visibilityMap = new Map<string, boolean>()
interface VisibilityToggleProps {
entityId: string
initialVisibility: boolean
onVisibilityChange?: () => void
}
/**
* A button that toggles the visibility of an entity
* tied to an artifact in the feature tree.
* TODO: this is unimplemented and will be used for
* default planes after we fix them and add them to the artifact graph / feature tree
*/
const VisibilityToggle = (props: VisibilityToggleProps) => {
const [visible, setVisible] = useState(props.initialVisibility)
function handleToggleVisible() {
setVisible(!visible)
visibilityMap.set(props.entityId, !visible)
props.onVisibilityChange?.()
}
return (
<button
onClick={handleToggleVisible}
className="border-transparent p-0 m-0"
>
<CustomIcon
name={visible ? 'eyeOpen' : 'eyeCrossedOut'}
className={`w-5 h-5 ${
visible
? 'hidden group-hover/item:block group-focus-within/item:block'
: 'text-chalkboard-50'
}`}
/>
</button>
)
}
/**
* More generic version of OperationListItem,
* to be used for default planes after we fix them and
* add them to the artifact graph / feature tree
*/
const OperationItemWrapper = ({
icon,
name,
visibilityToggle,
menuItems,
errors,
className,
...props
}: React.HTMLAttributes<HTMLButtonElement> & {
icon: CustomIconName
name: string
visibilityToggle?: VisibilityToggleProps
menuItems?: ComponentProps<typeof ContextMenu>['items']
errors?: Diagnostic[]
}) => {
const menuRef = useRef<HTMLDivElement>(null)
return (
<div
ref={menuRef}
className="flex select-none items-center group/item my-0 py-0.5 px-1 focus-within:bg-primary/10 hover:bg-primary/5"
>
<button
{...props}
className={`reset flex-1 flex items-center gap-2 border-transparent dark:border-transparent text-left text-base ${className}`}
>
<CustomIcon name={icon} className="w-5 h-5 block" />
{name}
</button>
{errors && errors.length > 0 && (
<em className="text-destroy-80 text-xs">has error</em>
)}
{visibilityToggle && <VisibilityToggle {...visibilityToggle} />}
{menuItems && (
<ContextMenu menuTargetElement={menuRef} items={menuItems} />
)}
</div>
)
}
/**
* A button with an icon, name, and context menu
* for an operation in the feature tree.
*/
const OperationItem = (props: {
item: Operation
send: Prop<Actor<typeof featureTreeMachine>, 'send'>
}) => {
const kclContext = useKclContext()
const name =
'name' in props.item && props.item.name !== null
? getOperationLabel(props.item)
: 'anonymous'
const errors = useMemo(() => {
return kclContext.diagnostics.filter(
(diag) =>
diag.severity === 'error' &&
'sourceRange' in props.item &&
diag.from >= props.item.sourceRange[0] &&
diag.to <= props.item.sourceRange[1]
)
}, [kclContext.diagnostics.length])
function selectOperation() {
if (props.item.type === 'UserDefinedFunctionReturn') {
return
}
props.send({
type: 'selectOperation',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
},
})
}
/**
* For now we can only enter the "edit" flow for the startSketchOn operation.
* TODO: https://github.com/KittyCAD/modeling-app/issues/4442
*/
function enterEditFlow() {
if (
props.item.type === 'StdLibCall' &&
props.item.name === 'startSketchOn'
) {
props.send({
type: 'enterEditFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
},
})
}
}
const menuItems = useMemo(
() => [
<ContextMenuItem
onClick={() => {
if (props.item.type === 'UserDefinedFunctionReturn') {
return
}
props.send({
type: 'goToKclSource',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
},
})
}}
>
View KCL source code
</ContextMenuItem>,
...(props.item.type === 'UserDefinedFunctionCall'
? [
<ContextMenuItem
onClick={() => {
if (props.item.type !== 'UserDefinedFunctionCall') {
return
}
const functionRange = props.item.functionSourceRange
// For some reason, the cursor goes to the end of the source
// range we select. So set the end equal to the beginning.
functionRange[1] = functionRange[0]
props.send({
type: 'goToKclSource',
data: {
targetSourceRange: sourceRangeFromRust(functionRange),
},
})
}}
>
View function definition
</ContextMenuItem>,
]
: []),
],
[props.item, props.send]
)
return (
<OperationItemWrapper
icon={getOperationIcon(props.item)}
name={name}
menuItems={menuItems}
onClick={selectOperation}
onDoubleClick={enterEditFlow}
errors={errors}
/>
)
}

View File

@ -1,7 +1,7 @@
import { TEST } from 'env' import { TEST } from 'env'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Themes, getSystemTheme } from 'lib/theme' import { Themes, getSystemTheme } from 'lib/theme'
import { useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search' import { highlightSelectionMatches, searchKeymap } from '@codemirror/search'
import { lineHighlightField } from 'editor/highlightextension' import { lineHighlightField } from 'editor/highlightextension'
import { onMouseDragMakeANewNumber, onMouseDragRegex } from 'lib/utils' import { onMouseDragMakeANewNumber, onMouseDragRegex } from 'lib/utils'
@ -36,7 +36,7 @@ import interact from '@replit/codemirror-interact'
import { kclManager, editorManager, codeManager } from 'lib/singletons' import { kclManager, editorManager, codeManager } from 'lib/singletons'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { useLspContext } from 'components/LspProvider' import { useLspContext } from 'components/LspProvider'
import { Prec, EditorState, Extension } from '@codemirror/state' import { Prec, EditorState, Extension, Transaction } from '@codemirror/state'
import { import {
closeBrackets, closeBrackets,
closeBracketsKeymap, closeBracketsKeymap,
@ -44,6 +44,13 @@ import {
} from '@codemirror/autocomplete' } from '@codemirror/autocomplete'
import CodeEditor from './CodeEditor' import CodeEditor from './CodeEditor'
import { codeManagerHistoryCompartment } from 'lang/codeManager' import { codeManagerHistoryCompartment } from 'lang/codeManager'
import {
editorIsMountedSelector,
kclEditorActor,
selectionEventSelector,
} from 'machines/kclEditorMachine'
import { useSelector } from '@xstate/react'
import { modelingMachineEvent } from 'editor/manager'
export const editorShortcutMeta = { export const editorShortcutMeta = {
formatCode: { formatCode: {
@ -59,6 +66,8 @@ export const KclEditorPane = () => {
const { const {
settings: { context }, settings: { context },
} = useSettingsAuthContext() } = useSettingsAuthContext()
const lastSelectionEvent = useSelector(kclEditorActor, selectionEventSelector)
const editorIsMounted = useSelector(kclEditorActor, editorIsMountedSelector)
const theme = const theme =
context.app.theme.current === Themes.System context.app.theme.current === Themes.System
? getSystemTheme() ? getSystemTheme()
@ -76,6 +85,25 @@ export const KclEditorPane = () => {
editorManager.redo() editorManager.redo()
}) })
// When this component unmounts, we need to tell the machine that the editor
useEffect(() => {
return () => {
kclEditorActor.send({ type: 'setKclEditorMounted', data: false })
kclEditorActor.send({ type: 'setLastSelectionEvent', data: undefined })
}
}, [])
useEffect(() => {
if (!editorIsMounted || !lastSelectionEvent || !editorManager.editorView) {
return
}
editorManager.editorView.dispatch({
selection: lastSelectionEvent.codeMirrorSelection,
annotations: [modelingMachineEvent, Transaction.addToHistory.of(false)],
scrollIntoView: lastSelectionEvent.scrollIntoView,
})
}, [editorIsMounted, lastSelectionEvent])
const textWrapping = context.textEditor.textWrapping const textWrapping = context.textEditor.textWrapping
const cursorBlinking = context.textEditor.blinkingCursor const cursorBlinking = context.textEditor.blinkingCursor
// DO NOT ADD THE CODEMIRROR HOTKEYS HERE TO THE DEPENDENCY ARRAY // DO NOT ADD THE CODEMIRROR HOTKEYS HERE TO THE DEPENDENCY ARRAY
@ -174,6 +202,7 @@ export const KclEditorPane = () => {
if (_editorView === null) return if (_editorView === null) return
editorManager.setEditorView(_editorView) editorManager.setEditorView(_editorView)
kclEditorActor.send({ type: 'setKclEditorMounted', data: true })
// On first load of this component, ensure we show the current errors // On first load of this component, ensure we show the current errors
// in the editor. // in the editor.

View File

@ -17,12 +17,14 @@ import { useKclContext } from 'lang/KclProvider'
import { editorManager } from 'lib/singletons' import { editorManager } from 'lib/singletons'
import { ContextFrom } from 'xstate' import { ContextFrom } from 'xstate'
import { settingsMachine } from 'machines/settingsMachine' import { settingsMachine } from 'machines/settingsMachine'
import { FeatureTreePane } from './FeatureTreePane'
export type SidebarType = export type SidebarType =
| 'code' | 'code'
| 'debug' | 'debug'
| 'export' | 'export'
| 'files' | 'files'
| 'feature-tree'
| 'logs' | 'logs'
| 'lspMessages' | 'lspMessages'
| 'variables' | 'variables'
@ -69,6 +71,23 @@ export type SidebarAction = {
// changes to be a spinning loader on loading. // changes to be a spinning loader on loading.
export const sidebarPanes: SidebarPane[] = [ export const sidebarPanes: SidebarPane[] = [
{
id: 'feature-tree',
icon: 'model',
keybinding: 'Shift + T',
sidebarName: 'Feature Tree',
Content: (props) => (
<>
<ModelingPaneHeader
id={props.id}
icon="model"
title="Feature Tree"
onClose={props.onClose}
/>
<FeatureTreePane />
</>
),
},
{ {
id: 'code', id: 'code',
icon: 'code', icon: 'code',

View File

@ -20,6 +20,7 @@ import { IconDefinition } from '@fortawesome/free-solid-svg-icons'
import { useKclContext } from 'lang/KclProvider' import { useKclContext } from 'lang/KclProvider'
import { MachineManagerContext } from 'components/MachineManagerProvider' import { MachineManagerContext } from 'components/MachineManagerProvider'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { SIDEBAR_BUTTON_SUFFIX } from 'lib/constants'
interface ModelingSidebarProps { interface ModelingSidebarProps {
paneOpacity: '' | 'opacity-20' | 'opacity-40' paneOpacity: '' | 'opacity-20' | 'opacity-40'
@ -302,7 +303,7 @@ function ModelingPaneButton({
className="group pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent disabled:!border-transparent p-0 m-0 rounded-sm !outline-0 focus-visible:border-primary" className="group pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent disabled:!border-transparent p-0 m-0 rounded-sm !outline-0 focus-visible:border-primary"
onClick={onClick} onClick={onClick}
name={paneConfig.sidebarName} name={paneConfig.sidebarName}
data-testid={paneConfig.id + '-pane-button'} data-testid={paneConfig.id + SIDEBAR_BUTTON_SUFFIX}
disabled={disabledText !== undefined} disabled={disabledText !== undefined}
aria-disabled={disabledText !== undefined} aria-disabled={disabledText !== undefined}
{...props} {...props}

View File

@ -14,6 +14,7 @@ import {
} from '@codemirror/lint' } from '@codemirror/lint'
import { StateFrom } from 'xstate' import { StateFrom } from 'xstate'
import { markOnce } from 'lib/performance' import { markOnce } from 'lib/performance'
import { kclEditorActor } from 'machines/kclEditorMachine'
declare global { declare global {
interface Window { interface Window {
@ -70,6 +71,7 @@ export default class EditorManager {
setEditorView(editorView: EditorView) { setEditorView(editorView: EditorView) {
this._editorView = editorView this._editorView = editorView
kclEditorActor.send({ type: 'setKclEditorMounted', data: true })
this.overrideTreeHighlighterUpdateForPerformanceTracking() this.overrideTreeHighlighterUpdateForPerformanceTracking()
} }
@ -207,6 +209,32 @@ export default class EditorManager {
}) })
} }
/**
* Scroll to the first selection in the editor.
*/
scrollToSelection() {
if (!this._editorView || !this._selectionRanges.graphSelections[0]) return
const firstSelection = this._selectionRanges.graphSelections[0]
this._editorView.focus()
this._editorView.dispatch({
effects: [
EditorView.scrollIntoView(
EditorSelection.range(
firstSelection.codeRef.range[0],
firstSelection.codeRef.range[1]
),
{ y: 'center' }
),
],
annotations: [
updateOutsideEditorEvent,
Transaction.addToHistory.of(false),
],
})
}
scrollToFirstErrorDiagnosticIfExists() { scrollToFirstErrorDiagnosticIfExists() {
if (!this._editorView) return if (!this._editorView) return

View File

@ -311,6 +311,14 @@ code {
@apply bg-chalkboard-20 text-chalkboard-80; @apply bg-chalkboard-20 text-chalkboard-80;
@apply dark:bg-chalkboard-80 dark:text-chalkboard-30; @apply dark:bg-chalkboard-80 dark:text-chalkboard-30;
} }
button.reset {
@apply bg-transparent border-transparent m-0 p-0 rounded-none text-base;
}
button.reset:hover {
@apply bg-transparent border-transparent;
}
} }
#code-mirror-override .cm-scroller, #code-mirror-override .cm-scroller,

View File

@ -3,6 +3,7 @@ import { type IndexLoaderData } from 'lib/types'
import { useLoaderData } from 'react-router-dom' import { useLoaderData } from 'react-router-dom'
import { codeManager, kclManager } from 'lib/singletons' import { codeManager, kclManager } from 'lib/singletons'
import { Diagnostic } from '@codemirror/lint' import { Diagnostic } from '@codemirror/lint'
import { KCLError } from './errors'
const KclContext = createContext({ const KclContext = createContext({
code: codeManager?.code || '', code: codeManager?.code || '',
@ -11,6 +12,7 @@ const KclContext = createContext({
isExecuting: kclManager?.isExecuting, isExecuting: kclManager?.isExecuting,
diagnostics: kclManager?.diagnostics, diagnostics: kclManager?.diagnostics,
logs: kclManager?.logs, logs: kclManager?.logs,
errors: kclManager?.errors,
wasmInitFailed: kclManager?.wasmInitFailed, wasmInitFailed: kclManager?.wasmInitFailed,
}) })
@ -32,7 +34,8 @@ export function KclContextProvider({
const [programMemory, setProgramMemory] = useState(kclManager.programMemory) const [programMemory, setProgramMemory] = useState(kclManager.programMemory)
const [ast, setAst] = useState(kclManager.ast) const [ast, setAst] = useState(kclManager.ast)
const [isExecuting, setIsExecuting] = useState(false) const [isExecuting, setIsExecuting] = useState(false)
const [diagnostics, setErrors] = useState<Diagnostic[]>([]) const [diagnostics, setDiagnostics] = useState<Diagnostic[]>([])
const [errors, setErrors] = useState<KCLError[]>([])
const [logs, setLogs] = useState<string[]>([]) const [logs, setLogs] = useState<string[]>([])
const [wasmInitFailed, setWasmInitFailed] = useState(false) const [wasmInitFailed, setWasmInitFailed] = useState(false)
@ -44,7 +47,8 @@ export function KclContextProvider({
setProgramMemory, setProgramMemory,
setAst, setAst,
setLogs, setLogs,
setKclErrors: setErrors, setErrors,
setDiagnostics,
setIsExecuting, setIsExecuting,
setWasmInitFailed, setWasmInitFailed,
}) })
@ -59,6 +63,7 @@ export function KclContextProvider({
isExecuting, isExecuting,
diagnostics, diagnostics,
logs, logs,
errors,
wasmInitFailed, wasmInitFailed,
}} }}
> >

View File

@ -32,6 +32,7 @@ import {
EntityType_type, EntityType_type,
ModelingCmdReq_type, ModelingCmdReq_type,
} from '@kittycad/lib/dist/types/src/models' } from '@kittycad/lib/dist/types/src/models'
import { Operation } from 'wasm-lib/kcl/bindings/Operation'
interface ExecuteArgs { interface ExecuteArgs {
ast?: Node<Program> ast?: Node<Program>
@ -58,7 +59,9 @@ export class KclManager {
private _execState: ExecState = emptyExecState() private _execState: ExecState = emptyExecState()
private _programMemory: ProgramMemory = ProgramMemory.empty() private _programMemory: ProgramMemory = ProgramMemory.empty()
lastSuccessfulProgramMemory: ProgramMemory = ProgramMemory.empty() lastSuccessfulProgramMemory: ProgramMemory = ProgramMemory.empty()
lastSuccessfulOperations: Operation[] = []
private _logs: string[] = [] private _logs: string[] = []
private _errors: KCLError[] = []
private _diagnostics: Diagnostic[] = [] private _diagnostics: Diagnostic[] = []
private _isExecuting = false private _isExecuting = false
private _executeIsStale: ExecuteArgs | null = null private _executeIsStale: ExecuteArgs | null = null
@ -72,7 +75,8 @@ export class KclManager {
private _astCallBack: (arg: Node<Program>) => void = () => {} private _astCallBack: (arg: Node<Program>) => void = () => {}
private _programMemoryCallBack: (arg: ProgramMemory) => void = () => {} private _programMemoryCallBack: (arg: ProgramMemory) => void = () => {}
private _logsCallBack: (arg: string[]) => void = () => {} private _logsCallBack: (arg: string[]) => void = () => {}
private _kclErrorsCallBack: (errors: Diagnostic[]) => void = () => {} private _kclErrorsCallBack: (errors: KCLError[]) => void = () => {}
private _diagnosticsCallback: (errors: Diagnostic[]) => void = () => {}
private _wasmInitFailedCallback: (arg: boolean) => void = () => {} private _wasmInitFailedCallback: (arg: boolean) => void = () => {}
private _executeCallback: () => void = () => {} private _executeCallback: () => void = () => {}
@ -106,6 +110,13 @@ export class KclManager {
return this._execState return this._execState
} }
get errors() {
return this._errors
}
set errors(errors) {
this._errors = errors
this._kclErrorsCallBack(errors)
}
get logs() { get logs() {
return this._logs return this._logs
} }
@ -135,7 +146,7 @@ export class KclManager {
setDiagnosticsForCurrentErrors() { setDiagnosticsForCurrentErrors() {
editorManager?.setDiagnostics(this.diagnostics) editorManager?.setDiagnostics(this.diagnostics)
this._kclErrorsCallBack(this.diagnostics) this._diagnosticsCallback(this.diagnostics)
} }
get isExecuting() { get isExecuting() {
@ -188,21 +199,24 @@ export class KclManager {
setProgramMemory, setProgramMemory,
setAst, setAst,
setLogs, setLogs,
setKclErrors, setErrors,
setDiagnostics,
setIsExecuting, setIsExecuting,
setWasmInitFailed, setWasmInitFailed,
}: { }: {
setProgramMemory: (arg: ProgramMemory) => void setProgramMemory: (arg: ProgramMemory) => void
setAst: (arg: Node<Program>) => void setAst: (arg: Node<Program>) => void
setLogs: (arg: string[]) => void setLogs: (arg: string[]) => void
setKclErrors: (errors: Diagnostic[]) => void setErrors: (errors: KCLError[]) => void
setDiagnostics: (errors: Diagnostic[]) => void
setIsExecuting: (arg: boolean) => void setIsExecuting: (arg: boolean) => void
setWasmInitFailed: (arg: boolean) => void setWasmInitFailed: (arg: boolean) => void
}) { }) {
this._programMemoryCallBack = setProgramMemory this._programMemoryCallBack = setProgramMemory
this._astCallBack = setAst this._astCallBack = setAst
this._logsCallBack = setLogs this._logsCallBack = setLogs
this._kclErrorsCallBack = setKclErrors this._kclErrorsCallBack = setErrors
this._diagnosticsCallback = setDiagnostics
this._isExecutingCallback = setIsExecuting this._isExecutingCallback = setIsExecuting
this._wasmInitFailedCallback = setWasmInitFailed this._wasmInitFailedCallback = setWasmInitFailed
} }
@ -352,11 +366,13 @@ export class KclManager {
} }
this.logs = logs this.logs = logs
this.errors = errors
// Do not add the errors since the program was interrupted and the error is not a real KCL error // Do not add the errors since the program was interrupted and the error is not a real KCL error
this.addDiagnostics(isInterrupted ? [] : kclErrorsToDiagnostics(errors)) this.addDiagnostics(isInterrupted ? [] : kclErrorsToDiagnostics(errors))
this.execState = execState this.execState = execState
if (!errors.length) { if (!errors.length) {
this.lastSuccessfulProgramMemory = execState.memory this.lastSuccessfulProgramMemory = execState.memory
this.lastSuccessfulOperations = execState.operations
} }
this.ast = { ...ast } this.ast = { ...ast }
// updateArtifactGraph relies on updated executeState/programMemory // updateArtifactGraph relies on updated executeState/programMemory
@ -411,6 +427,7 @@ export class KclManager {
this._programMemory = execState.memory this._programMemory = execState.memory
if (!errors.length) { if (!errors.length) {
this.lastSuccessfulProgramMemory = execState.memory this.lastSuccessfulProgramMemory = execState.memory
this.lastSuccessfulOperations = execState.operations
} }
if (updates !== 'artifactRanges') return if (updates !== 'artifactRanges') return

View File

@ -1146,31 +1146,43 @@ export async function deleteFromSelection(
) )
if (err(varDec)) return varDec if (err(varDec)) return varDec
if ( if (
(selection?.artifact?.type === 'wall' || ((selection?.artifact?.type === 'wall' ||
selection?.artifact?.type === 'cap') && selection?.artifact?.type === 'cap') &&
varDec.node.init.type === 'PipeExpression' varDec.node.init.type === 'PipeExpression') ||
selection.artifact?.type === 'sweep'
) { ) {
const varDecName = varDec.node.id.name
let pathToNode: PathToNode | null = null
let extrudeNameToDelete = '' let extrudeNameToDelete = ''
traverse(astClone, { let pathToNode: PathToNode | null = null
enter: (node, path) => { if (selection.artifact?.type !== 'sweep') {
if (node.type === 'VariableDeclaration') { const varDecName = varDec.node.id.name
const dec = node.declaration traverse(astClone, {
if ( enter: (node, path) => {
dec.init.type === 'CallExpression' && if (node.type === 'VariableDeclaration') {
(dec.init.callee.name === 'extrude' || const dec = node.declaration
dec.init.callee.name === 'revolve') && if (
dec.init.arguments?.[1].type === 'Identifier' && dec.init.type === 'CallExpression' &&
dec.init.arguments?.[1].name === varDecName (dec.init.callee.name === 'extrude' ||
) { dec.init.callee.name === 'revolve') &&
pathToNode = path dec.init.arguments?.[1].type === 'Identifier' &&
extrudeNameToDelete = dec.id.name dec.init.arguments?.[1].name === varDecName
) {
pathToNode = path
extrudeNameToDelete = dec.id.name
}
} }
} },
}, })
}) if (!pathToNode) return new Error('Could not find extrude variable')
if (!pathToNode) return new Error('Could not find extrude variable') } else {
pathToNode = selection.codeRef.pathToNode
const extrudeVarDec = getNodeFromPath<VariableDeclarator>(
astClone,
pathToNode,
'VariableDeclarator'
)
if (err(extrudeVarDec)) return extrudeVarDec
extrudeNameToDelete = extrudeVarDec.node.id.name
}
const expressionIndex = pathToNode[1][0] as number const expressionIndex = pathToNode[1][0] as number
astClone.body.splice(expressionIndex, 1) astClone.body.splice(expressionIndex, 1)

View File

@ -859,6 +859,14 @@ export function hasExtrudeSketch({
) )
} }
export function artifactIsPlaneWithPaths(selectionRanges: Selections) {
return (
selectionRanges.graphSelections.length &&
selectionRanges.graphSelections[0].artifact?.type === 'plane' &&
selectionRanges.graphSelections[0].artifact.pathIds.length
)
}
export function isSingleCursorInPipe( export function isSingleCursorInPipe(
selectionRanges: Selections, selectionRanges: Selections,
ast: Program ast: Program

View File

@ -882,7 +882,7 @@ export function getArtifactFromRange(
for (const artifact of artifactGraph.values()) { for (const artifact of artifactGraph.values()) {
if ('codeRef' in artifact) { if ('codeRef' in artifact) {
const match = const match =
artifact.codeRef.range[0] === range[0] && artifact.codeRef?.range[0] === range[0] &&
artifact.codeRef.range[1] === range[1] artifact.codeRef.range[1] === range[1]
if (match) return artifact if (match) return artifact
} }

View File

@ -43,6 +43,7 @@ import { Node } from 'wasm-lib/kcl/bindings/Node'
import { CompilationError } from 'wasm-lib/kcl/bindings/CompilationError' import { CompilationError } from 'wasm-lib/kcl/bindings/CompilationError'
import { SourceRange as RustSourceRange } from 'wasm-lib/kcl/bindings/SourceRange' import { SourceRange as RustSourceRange } from 'wasm-lib/kcl/bindings/SourceRange'
import { getAllCurrentSettings } from 'lib/settings/settingsUtils' import { getAllCurrentSettings } from 'lib/settings/settingsUtils'
import { Operation } from 'wasm-lib/kcl/bindings/Operation'
import { KclErrorWithOutputs } from 'wasm-lib/kcl/bindings/KclErrorWithOutputs' import { KclErrorWithOutputs } from 'wasm-lib/kcl/bindings/KclErrorWithOutputs'
export type { Configuration } from 'wasm-lib/kcl/bindings/Configuration' export type { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
@ -245,6 +246,7 @@ export const isPathToNodeNumber = (
export interface ExecState { export interface ExecState {
memory: ProgramMemory memory: ProgramMemory
operations: Operation[]
} }
/** /**
@ -254,12 +256,14 @@ export interface ExecState {
export function emptyExecState(): ExecState { export function emptyExecState(): ExecState {
return { return {
memory: ProgramMemory.empty(), memory: ProgramMemory.empty(),
operations: [],
} }
} }
function execStateFromRaw(raw: RawExecState): ExecState { function execStateFromRaw(raw: RawExecState): ExecState {
return { return {
memory: ProgramMemory.fromRaw(raw.modLocal.memory), memory: ProgramMemory.fromRaw(raw.modLocal.memory),
operations: raw.modLocal.operations,
} }
} }

View File

@ -136,3 +136,5 @@ export const VIEW_NAMES_SEMANTIC = {
[AxisNames.NEG_Y]: 'Front', [AxisNames.NEG_Y]: 'Front',
[AxisNames.NEG_Z]: 'Bottom', [AxisNames.NEG_Z]: 'Bottom',
} as const } as const
/** The modeling sidebar buttons' IDs get a suffix to prevent collisions */
export const SIDEBAR_BUTTON_SUFFIX = '-pane-button'

122
src/lib/operations.test.ts Normal file
View File

@ -0,0 +1,122 @@
import { defaultRustSourceRange } from 'lang/wasm'
import { filterOperations } from './operations'
import { Operation } from 'wasm-lib/kcl/bindings/Operation'
function stdlib(name: string): Operation {
return {
type: 'StdLibCall',
name,
unlabeledArg: null,
labeledArgs: {},
sourceRange: defaultRustSourceRange(),
isError: false,
}
}
function userCall(name: string): Operation {
return {
type: 'UserDefinedFunctionCall',
name,
functionSourceRange: defaultRustSourceRange(),
unlabeledArg: null,
labeledArgs: {},
sourceRange: defaultRustSourceRange(),
}
}
function userReturn(): Operation {
return {
type: 'UserDefinedFunctionReturn',
}
}
describe('operations filtering', () => {
it('drops stdlib operations inside a user-defined function call', async () => {
const operations = [
stdlib('std1'),
userCall('foo'),
stdlib('std2'),
stdlib('std3'),
userReturn(),
stdlib('std4'),
stdlib('std5'),
]
const actual = filterOperations(operations)
expect(actual).toEqual([
stdlib('std1'),
userCall('foo'),
stdlib('std4'),
stdlib('std5'),
])
})
it('drops user-defined function calls that contain no stdlib operations', async () => {
const operations = [
stdlib('std1'),
userCall('foo'),
userReturn(),
stdlib('std2'),
userCall('bar'),
userReturn(),
stdlib('std3'),
]
const actual = filterOperations(operations)
expect(actual).toEqual([stdlib('std1'), stdlib('std2'), stdlib('std3')])
})
it('preserves user-defined function calls at the end of the list', async () => {
const operations = [stdlib('std1'), userCall('foo')]
const actual = filterOperations(operations)
expect(actual).toEqual([stdlib('std1'), userCall('foo')])
})
it('drops all user-defined function return operations', async () => {
// The returns allow us to group operations with the call, but we never
// display the returns.
const operations = [
stdlib('std1'),
userCall('foo'),
stdlib('std2'),
userReturn(),
stdlib('std3'),
stdlib('std4'),
userCall('foo2'),
stdlib('std5'),
stdlib('std6'),
userReturn(),
stdlib('std7'),
]
const actual = filterOperations(operations)
expect(actual).toEqual([
stdlib('std1'),
userCall('foo'),
stdlib('std3'),
stdlib('std4'),
userCall('foo2'),
stdlib('std7'),
])
})
it('correctly filters with nested function calls', async () => {
const operations = [
stdlib('std1'),
userCall('foo'),
stdlib('std2'),
userReturn(),
stdlib('std3'),
stdlib('std4'),
userCall('foo2'),
stdlib('std5'),
userCall('foo3-nested'),
stdlib('std6'),
userReturn(),
stdlib('std7'),
userReturn(),
stdlib('std8'),
]
const actual = filterOperations(operations)
expect(actual).toEqual([
stdlib('std1'),
userCall('foo'),
stdlib('std3'),
stdlib('std4'),
userCall('foo2'),
stdlib('std8'),
])
})
})

180
src/lib/operations.ts Normal file
View File

@ -0,0 +1,180 @@
import { CustomIconName } from 'components/CustomIcon'
import { Operation } from 'wasm-lib/kcl/bindings/Operation'
interface StdLibCallInfo {
label: string
icon: CustomIconName
}
const stdLibMap: Record<string, StdLibCallInfo> = {
chamfer: {
label: 'Chamfer',
icon: 'chamfer3d',
},
extrude: {
label: 'Extrude',
icon: 'extrude',
},
fillet: {
label: 'Fillet',
icon: 'fillet3d',
},
hole: {
label: 'Hole',
icon: 'hole',
},
hollow: {
label: 'Hollow',
icon: 'hollow',
},
import: {
label: 'Import',
icon: 'import',
},
loft: {
label: 'Loft',
icon: 'loft',
},
offsetPlane: {
label: 'Offset Plane',
icon: 'plane',
},
patternCircular2d: {
label: 'Circular Pattern',
icon: 'patternCircular2d',
},
patternCircular3d: {
label: 'Circular Pattern',
icon: 'patternCircular3d',
},
patternLinear2d: {
label: 'Linear Pattern',
icon: 'patternLinear2d',
},
patternLinear3d: {
label: 'Linear Pattern',
icon: 'patternLinear3d',
},
revolve: {
label: 'Revolve',
icon: 'revolve',
},
shell: {
label: 'Shell',
icon: 'shell',
},
startSketchOn: {
label: 'Sketch',
icon: 'sketch',
},
sweep: {
label: 'Sweep',
icon: 'sweep',
},
}
/**
* Returns the label of the operation
*/
export function getOperationLabel(op: Operation): string {
switch (op.type) {
case 'StdLibCall':
return stdLibMap[op.name]?.label ?? op.name
case 'UserDefinedFunctionCall':
return op.name ?? 'Anonymous custom function'
case 'UserDefinedFunctionReturn':
return 'User function return'
}
}
/**
* Returns the icon of the operation
*/
export function getOperationIcon(op: Operation): CustomIconName {
switch (op.type) {
case 'StdLibCall':
return stdLibMap[op.name]?.icon ?? 'questionMark'
default:
return 'make-variable'
}
}
/**
* Apply all filters to a list of operations.
*/
export function filterOperations(operations: Operation[]): Operation[] {
return operationFilters.reduce((ops, filterFn) => filterFn(ops), operations)
}
/**
* The filters to apply to a list of operations
* for use in the feature tree UI
*/
const operationFilters = [
isNotUserFunctionWithNoOperations,
isNotInsideUserFunction,
isNotUserFunctionReturn,
]
/**
* A filter to exclude everything that occurs inside a UserDefinedFunctionCall
* and its corresponding UserDefinedFunctionReturn from a list of operations.
* This works even when there are nested function calls.
*/
function isNotInsideUserFunction(operations: Operation[]): Operation[] {
const ops: Operation[] = []
let depth = 0
for (const op of operations) {
if (depth === 0) {
ops.push(op)
}
if (op.type === 'UserDefinedFunctionCall') {
depth++
}
if (op.type === 'UserDefinedFunctionReturn') {
depth--
console.assert(
depth >= 0,
'Unbalanced UserDefinedFunctionCall and UserDefinedFunctionReturn; too many returns'
)
}
}
// Depth could be non-zero here if there was an error in execution.
return ops
}
/**
* A filter to exclude UserDefinedFunctionCall operations and their
* corresponding UserDefinedFunctionReturn that don't have any operations inside
* them from a list of operations.
*/
function isNotUserFunctionWithNoOperations(
operations: Operation[]
): Operation[] {
return operations.filter((op, index) => {
if (
op.type === 'UserDefinedFunctionCall' &&
// If this is a call at the end of the array, it's preserved.
index < operations.length - 1 &&
operations[index + 1].type === 'UserDefinedFunctionReturn'
)
return false
if (
op.type === 'UserDefinedFunctionReturn' &&
// If this return is at the beginning of the array, it's preserved.
index > 0 &&
operations[index - 1].type === 'UserDefinedFunctionCall'
)
return false
return true
})
}
/**
* A filter to exclude UserDefinedFunctionReturn operations from a list of
* operations.
*/
function isNotUserFunctionReturn(ops: Operation[]): Operation[] {
return ops.filter((op) => op.type !== 'UserDefinedFunctionReturn')
}

View File

@ -781,6 +781,14 @@ export function codeToIdSelections(
} }
} }
} }
if (entry.artifact.type === 'sweep') {
bestCandidate = {
artifact: entry.artifact,
selection,
id: entry.id,
}
}
}) })
if (bestCandidate) { if (bestCandidate) {

View File

@ -0,0 +1,157 @@
import { SourceRange } from 'lang/wasm'
import { assign, setup } from 'xstate'
type FeatureTreeEvent =
| {
type: 'goToKclSource'
data: { targetSourceRange: SourceRange }
}
| {
type: 'selectOperation'
data: { targetSourceRange: SourceRange }
}
| {
type: 'enterEditFlow'
data: { targetSourceRange: SourceRange }
}
| { type: 'goToError' }
| { type: 'codePaneOpened' }
| { type: 'selected' }
| { type: 'done' }
export const featureTreeMachine = setup({
types: {
context: {} as { targetSourceRange?: SourceRange },
events: {} as FeatureTreeEvent,
},
guards: {
codePaneIsOpen: () => false,
},
actions: {
saveTargetSourceRange: assign({
targetSourceRange: ({ event }) =>
'data' in event ? event.data.targetSourceRange : undefined,
}),
clearTargetSourceRange: assign({
targetSourceRange: undefined,
}),
sendSelectionEvent: () => {},
openCodePane: () => {},
sendEditFlowStart: () => {},
scrollToError: () => {},
},
}).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QDMwEMAuBXATmAKnmAHQCWEANmAMRQD2+dA0gMYUDKduLYA2gAwBdRKAAOdWKQyk6AOxEgAHogCMAFn7EAbAFYAHAHYAnGoOm9OgMw6tAGhABPRBsvF+a6wYBM-A-0uWepYGAL4h9qiYuAREZJQ0sGBULBgA8qJgOJgysgLCSCDiktJyCsoIKipabmo6-Co2Wip+-DoG9k4IWpp+Ae6W-N16KpZeYRHo2HiEYCTkVNRgshiZAKIQUgBiFHQA7nkKRVI5ZapergN6el46515GVdYdiN4GbjpGXiNGV2pan+MQJEpjFZsR6KRZFBGKwOFwcDxiIlktIodRkWAUpADgUjiV5AVyipWipiGZupYVCY1D5+F5ngg-jpiJ8DFo1OoDA13GNwkDJtEZiQIVCYWxONwSBA5DQcWIJMdSoTVG0yTodGoPAZKfwjP52o5EHVNIYvNd9M0zJZAcDBbERdDmOL4Yi6BlZJCoABhOgQMAABTQshoLF9AaDYHSS2xQkOCvxpy6lWIbS0Bm8JkM+ksDKZLK8bL0-S8NOCNoF01iGJSnqRSUxqKg6PrWIgcsK8ZOyq6aj0avTbU1Hg0KgZzVcrU+Xn+9Q+dPLUUrYOrjeI0uD1HbeK7oCJBj7xkafhMX28agZJeqHz0RhstTUD21WgXIKFxCWKxwnvWWx2uzrKKes2KIxvk8rFDuShGpY1RaKMRiHr4va9gyegPsQQRmiYVK+GYL52mCH6ZN+GwYNsexrjKm6xrinZKruqhaLBmr8PUvj6F4nGoeoxDEpYCFMVSowfPhS7CnQnqMKsOA4HQODEG6Syej6fqBhuoaqRGUbBm2NHgYqBIMV0-EproxLpoE7hWGOnFeGSxgaPwei6EYmaiaCczxLQDB0NJsk4FudGGVBFRpsQwRGGmgx6n4cH0oaFSVH21i6LqD5mtYejuW+DpSTJcmURugUQfRIX6MQfwcpqDxpUETwJSoXxqMQ04PjoSXspYtRhHyshhvABS2mJcYlcF5QALR2Al43TtoVJDD46ZfJS1p8kNHlxFQI0GYmNJjn8c21PuIzjuq2X2hJopOnCkrbQm3ZdXZNhsl1fzOSW1kJdYzKeK5VQqJh+7nWCuXXRKCIkCunp3ZB5RFq46ofOq6geDeo4JZqdlaEWt66l8jXfcD4mSWDLpSjKMOlUSzSwXS2qztVfy5jS2g43UnyVOcZ1rRWG2g7C4Ouu6ylhmpYCU2NiD8Zoz1wZq2NaB9OYYyz2O6gE3TtR8XVEwBDbQ7Ro2JtYT1pnLb2K7UyudIrFU3h8ZoamafhZTzi4bVDUJ6zWUIS7trGmS98vvVb+1GBhjUPtqer3KxOi657UCFeLhs7d2FmHe1ARmFr54NdqLUFrZnLBFUutEV+UI-mRf5+w9NgYZSNyBHB6rxTbcHhTY5wmDVMGrRM7tvhXJG-hRid10ZGjNUEjVWM533t4gaHh8aFgaOc+7XOXyzEVXpHkf+64p-p91T744VBEE7h0oEGpTZ0Dx2Sbhi9r4ozNLroN+XJk8hTBV51SRQGE7JiS9ErJjgnSRWC0bCu0Hq+C6JMf7yUUh6KEKlwzBj-uUKqKYgFQNAYrMcVI+yVFcqxfwFhAhf0uo6FByccHLyaC1JygRp5FkagaTo5CyFUj1KxO+gReRhCAA */
id: 'featureTree',
description: 'Workflows for interacting with the feature tree pane',
states: {
idle: {
on: {
goToKclSource: {
target: 'goingToKclSource',
actions: 'saveTargetSourceRange',
},
selectOperation: {
target: 'selecting',
actions: 'saveTargetSourceRange',
},
enterEditFlow: {
target: 'enteringEditFlow',
actions: 'saveTargetSourceRange',
},
goToError: 'goingToError',
},
},
goingToKclSource: {
states: {
selecting: {
on: {
selected: {
target: 'done',
},
},
entry: ['sendSelectionEvent'],
},
done: {
entry: ['clearTargetSourceRange'],
always: '#featureTree.idle',
},
openingCodePane: {
on: {
codePaneOpened: 'selecting',
},
entry: 'openCodePane',
},
},
initial: 'openingCodePane',
},
selecting: {
states: {
selecting: {
on: {
selected: 'done',
},
entry: 'sendSelectionEvent',
},
done: {
always: '#featureTree.idle',
entry: 'clearTargetSourceRange',
},
},
initial: 'selecting',
},
enteringEditFlow: {
states: {
selecting: {
on: {
selected: 'done',
},
},
done: {
always: '#featureTree.idle',
},
},
initial: 'selecting',
entry: 'sendSelectionEvent',
exit: ['clearTargetSourceRange', 'sendEditFlowStart'],
},
goingToError: {
states: {
openingCodePane: {
entry: 'openCodePane',
on: {
codePaneOpened: 'done',
},
},
done: {
entry: 'scrollToError',
always: '#featureTree.idle',
},
},
initial: 'openingCodePane',
},
},
initial: 'idle',
})

View File

@ -0,0 +1,67 @@
import { assign, createActor, setup, StateFrom } from 'xstate'
import { EditorSelection } from '@codemirror/state'
type SelectionEvent = {
codeMirrorSelection: EditorSelection
scrollIntoView: boolean
}
type KclEditorMachineEvent =
| { type: 'setKclEditorMounted'; data: boolean }
| { type: 'setLastSelectionEvent'; data?: SelectionEvent }
interface KclEditorMachineContext {
isKclEditorMounted: boolean
lastSelectionEvent?: SelectionEvent
}
/**
* This is a one-off XState machine not tied to React, so that we can publish
* state to it from singletons and other parts of the app.
*/
export const kclEditorMachine = setup({
types: {
events: {} as KclEditorMachineEvent,
context: {} as KclEditorMachineContext,
},
actions: {
setKclEditorMounted: assign({
isKclEditorMounted: ({ context, event }) =>
event.type === 'setKclEditorMounted'
? event.data
: context.isKclEditorMounted,
}),
setLastSelectionEvent: assign({
lastSelectionEvent: ({ context, event }) =>
event.type === 'setLastSelectionEvent'
? event.data
: context.lastSelectionEvent,
}),
},
}).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QGsDGAbAohAlgFwHsAnAWQENUALHAOzAGJYw8BpDbfYkggVxr0gBtAAwBdRKAAOBWPhwEaEkAA9EARmEB2AHTCALACYAzGs0BWMwDYAHNZOWANCACeiALRrL2tQesGzamZ+FgYAnJqaAL7RTjQEEHBKaFi4hKQU1HRK0rJ48opIKupq2tZmwgZ6RpZ6msJGeqaOLupG1tq+bQFalZqWZtHRQA */
id: 'kclEditorMachine',
context: {
isKclEditorMounted: false,
lastSelectionEvent: undefined,
},
on: {
setKclEditorMounted: {
actions: 'setKclEditorMounted',
},
setLastSelectionEvent: {
actions: 'setLastSelectionEvent',
},
},
})
export const kclEditorActor = createActor(kclEditorMachine).start()
/** Watch for changes to `lastSelectionEvent` */
export const selectionEventSelector = (
snapshot?: StateFrom<typeof kclEditorMachine>
) => snapshot?.context?.lastSelectionEvent
/** Watch for the editorView to be mounted */
export const editorIsMountedSelector = (
snapshot?: StateFrom<typeof kclEditorMachine>
) => snapshot?.context?.isKclEditorMounted

File diff suppressed because one or more lines are too long

View File

@ -8543,7 +8543,16 @@ string-natural-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: "string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3" version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -8637,7 +8646,14 @@ string_decoder@~1.1.1:
dependencies: dependencies:
safe-buffer "~5.1.0" safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: "strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -9494,7 +9510,16 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0" version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==