import type { Diagnostic } from '@codemirror/lint'
import { useMachine, useSelector } from '@xstate/react'
import type { ComponentProps } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import type { Actor, Prop } from 'xstate'
import type { Operation } from '@rust/kcl-lib/bindings/Operation'
import { ContextMenu, ContextMenuItem } from '@src/components/ContextMenu'
import type { CustomIconName } from '@src/components/CustomIcon'
import { CustomIcon } from '@src/components/CustomIcon'
import Loading from '@src/components/Loading'
import { useModelingContext } from '@src/hooks/useModelingContext'
import { useKclContext } from '@src/lang/KclProvider'
import {
codeRefFromRange,
getArtifactFromRange,
} from '@src/lang/std/artifactGraph'
import { sourceRangeFromRust } from '@src/lang/wasm'
import {
filterOperations,
getOperationIcon,
getOperationLabel,
stdLibMap,
} from '@src/lib/operations'
import { editorManager, kclManager, rustContext } from '@src/lib/singletons'
import {
featureTreeMachine,
featureTreeMachineDefaultContext,
} from '@src/machines/featureTreeMachine'
import {
editorIsMountedSelector,
kclEditorActor,
selectionEventSelector,
} from '@src/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,
kclManager.artifactGraph
)
: null
if (!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,
},
})
}
},
},
}),
{
input: {
...featureTreeMachineDefaultContext,
},
// devTools: 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 (
{kclManager.isExecuting ? (
Building feature tree...
) : (
<>
{!modelingState.matches('Sketch') && }
{parseErrors.length > 0 && (
Errors found in KCL code.
Please fix them before continuing.
View error
)}
{operationList.map((operation) => {
const key = `${operation.type}-${
'name' in operation ? operation.name : 'anonymous'
}-${
'sourceRange' in operation ? operation.sourceRange[0] : 'start'
}`
return (
)
})}
>
)}
)
}
interface VisibilityToggleProps {
visible: boolean
onVisibilityChange: () => unknown
}
/**
* A button that toggles the visibility of an entity
* tied to an artifact in the feature tree.
* For now just used for default planes.
*/
const VisibilityToggle = (props: VisibilityToggleProps) => {
const visible = props.visible
const handleToggleVisible = useCallback(() => {
props.onVisibilityChange()
}, [props.onVisibilityChange])
return (
)
}
/**
* 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,
selectable = true,
...props
}: React.HTMLAttributes & {
icon: CustomIconName
name: string
visibilityToggle?: VisibilityToggleProps
menuItems?: ComponentProps['items']
errors?: Diagnostic[]
selectable?: boolean
}) => {
const menuRef = useRef(null)
return (
{name}
{errors && errors.length > 0 && (
has error
)}
{visibilityToggle && }
{menuItems && (
)}
)
}
/**
* A button with an icon, name, and context menu
* for an operation in the feature tree.
*/
const OperationItem = (props: {
item: Operation
send: Prop, 'send'>
}) => {
const kclContext = useKclContext()
const name = getOperationLabel(props.item)
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 === 'GroupEnd') {
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.type === 'KclStdLibCall'
) {
props.send({
type: 'enterEditFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
currentOperation: props.item,
},
})
}
}
function enterAppearanceFlow() {
if (
props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall'
) {
props.send({
type: 'enterAppearanceFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
currentOperation: props.item,
},
})
}
}
function enterTranslateFlow() {
if (
props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall' ||
props.item.type === 'GroupBegin'
) {
props.send({
type: 'enterTranslateFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
currentOperation: props.item,
},
})
}
}
function enterRotateFlow() {
if (
props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall' ||
props.item.type === 'GroupBegin'
) {
props.send({
type: 'enterRotateFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
currentOperation: props.item,
},
})
}
}
function deleteOperation() {
if (
props.item.type === 'StdLibCall' ||
props.item.type === 'GroupBegin' ||
props.item.type === 'KclStdLibCall'
) {
props.send({
type: 'deleteOperation',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
},
})
}
}
const menuItems = useMemo(
() => [
{
if (props.item.type === 'GroupEnd') {
return
}
props.send({
type: 'goToKclSource',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
},
})
}}
>
View KCL source code
,
...(props.item.type === 'GroupBegin' &&
props.item.group.type === 'FunctionCall'
? [
{
if (props.item.type !== 'GroupBegin') {
return
}
if (props.item.group.type !== 'FunctionCall') {
// TODO: Add module instance support.
return
}
const functionRange = props.item.group.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
,
]
: []),
...(props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall'
? [
Edit
,
Set appearance
,
]
: []),
...(props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall' ||
props.item.type === 'GroupBegin'
? [
Set translate
,
Set rotate
,
Delete
,
]
: []),
],
[props.item, props.send]
)
return (
)
}
const DefaultPlanes = () => {
const { state: modelingState, send } = useModelingContext()
const defaultPlanes = rustContext.defaultPlanes
if (!defaultPlanes) return null
const planes = [
{ name: 'Front plane', id: defaultPlanes.xz, key: 'xz' },
{ name: 'Top plane', id: defaultPlanes.xy, key: 'xy' },
{ name: 'Side plane', id: defaultPlanes.yz, key: 'yz' },
] as const
return (
{planes.map((plane) => (
{
send({
type: 'Toggle default plane visibility',
planeId: plane.id,
planeKey: plane.key,
})
},
}}
/>
))}
)
}