Add edit flow for Parameters to Feature Tree (#7536)

* Add edit flow to named parameters, show their value in feature tree

* Amend a feature tree test to include editing a parameter

* Enforce disallowing "create new variable" in edit parameter flow

* Add wrapping behavior! Sorry forgot to commit this

* Update src/machines/commandBarMachine.ts

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

---------

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2025-06-19 11:14:37 -04:00
committed by GitHub
parent 9dd6e3e852
commit 23a01e86e6
8 changed files with 273 additions and 118 deletions

View File

@ -229,11 +229,12 @@ test.describe('Feature Tree pane', () => {
const initialCode = `sketch001 = startSketchOn(XZ) const initialCode = `sketch001 = startSketchOn(XZ)
|> circle(center = [0, 0], radius = 5) |> circle(center = [0, 0], radius = 5)
renamedExtrude = extrude(sketch001, length = ${initialInput})` renamedExtrude = extrude(sketch001, length = ${initialInput})`
const newConstantName = 'length001' const newParameterName = 'length001'
const expectedCode = `${newConstantName} = 23 const expectedCode = `${newParameterName} = 23
sketch001 = startSketchOn(XZ) sketch001 = startSketchOn(XZ)
|> circle(center = [0, 0], radius = 5) |> circle(center = [0, 0], radius = 5)
renamedExtrude = extrude(sketch001, length = ${newConstantName})` renamedExtrude = extrude(sketch001, length = ${newParameterName})`
const editedParameterValue = '23 * 2'
await context.folderSetupFn(async (dir) => { await context.folderSetupFn(async (dir) => {
const testDir = join(dir, 'test-sample') const testDir = join(dir, 'test-sample')
@ -279,7 +280,7 @@ test.describe('Feature Tree pane', () => {
}) })
}) })
await test.step('Add a named constant for distance argument and submit', async () => { await test.step('Add a parameter for distance argument and submit', async () => {
await expect(cmdBar.currentArgumentInput).toBeVisible() await expect(cmdBar.currentArgumentInput).toBeVisible()
await cmdBar.variableCheckbox.click() await cmdBar.variableCheckbox.click()
await cmdBar.progressCmdBar() await cmdBar.progressCmdBar()
@ -296,13 +297,43 @@ test.describe('Feature Tree pane', () => {
highlightedCode: '', highlightedCode: '',
diagnostics: [], diagnostics: [],
activeLines: [ activeLines: [
`renamedExtrude = extrude(sketch001, length = ${newConstantName})`, `renamedExtrude = extrude(sketch001, length = ${newParameterName})`,
], ],
}) })
await editor.expectEditor.toContain(expectedCode, { await editor.expectEditor.toContain(expectedCode, {
shouldNormalise: true, shouldNormalise: true,
}) })
}) })
await test.step('Edit the parameter via the feature tree', async () => {
const parameter = await toolbar.getFeatureTreeOperation('Parameter', 0)
await parameter.dblclick()
await cmdBar.expectState({
commandName: 'Edit parameter',
currentArgKey: 'value',
currentArgValue: '23',
headerArguments: {
Name: newParameterName,
Value: '23',
},
stage: 'arguments',
highlightedHeaderArg: 'value',
})
await cmdBar.argumentInput
.locator('[contenteditable]')
.fill(editedParameterValue)
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
commandName: 'Edit parameter',
headerArguments: {
Name: newParameterName,
Value: '46', // Shows calculated result
},
})
await cmdBar.progressCmdBar()
await editor.expectEditor.toContain(editedParameterValue)
})
}) })
test(`User can edit an offset plane operation from the feature tree`, async ({ test(`User can edit an offset plane operation from the feature tree`, async ({
context, context,

View File

@ -286,65 +286,67 @@ function CommandBarKclInput({
)} )}
</span> </span>
</label> </label>
<div className="flex items-baseline gap-4 mx-4"> {arg.createVariable !== 'disallow' && (
<input <div className="flex items-baseline gap-4 mx-4">
type="checkbox" <input
id="variable-checkbox" type="checkbox"
data-testid="cmd-bar-variable-checkbox" id="variable-checkbox"
checked={createNewVariable} data-testid="cmd-bar-variable-checkbox"
onChange={(e) => { checked={createNewVariable}
setCreateNewVariable(e.target.checked) onChange={(e) => {
}} setCreateNewVariable(e.target.checked)
className="bg-chalkboard-10 dark:bg-chalkboard-80" }}
/> className="bg-chalkboard-10 dark:bg-chalkboard-80"
<label />
htmlFor="variable-checkbox" <label
className="text-blue border-none bg-transparent font-sm flex gap-1 items-center pl-0 pr-1" htmlFor="variable-checkbox"
> className="text-blue border-none bg-transparent font-sm flex gap-1 items-center pl-0 pr-1"
Create new variable >
</label> Create new variable
{createNewVariable && ( </label>
<> {createNewVariable && (
<input <>
type="text" <input
id="variable-name" type="text"
name="variable-name" id="variable-name"
className="flex-1 border-solid border-0 border-b border-chalkboard-50 bg-transparent focus:outline-none" name="variable-name"
placeholder="Variable name" className="flex-1 border-solid border-0 border-b border-chalkboard-50 bg-transparent focus:outline-none"
value={newVariableName} placeholder="Variable name"
autoCapitalize="off" value={newVariableName}
autoCorrect="off" autoCapitalize="off"
autoComplete="off" autoCorrect="off"
spellCheck="false" autoComplete="off"
autoFocus spellCheck="false"
onChange={(e) => setNewVariableName(e.target.value)} autoFocus
onKeyDown={(e) => { onChange={(e) => setNewVariableName(e.target.value)}
if ( onKeyDown={(e) => {
e.currentTarget.value === '' && if (
e.key === 'Backspace' && e.currentTarget.value === '' &&
arg.createVariable !== 'force' e.key === 'Backspace' &&
) { arg.createVariable !== 'force'
setCreateNewVariable(false) ) {
setCreateNewVariable(false)
}
}}
onKeyUp={(e) => {
if (e.key === 'Enter' && canSubmit) {
handleSubmit()
}
}}
/>
<span
className={
isNewVariableNameUnique
? 'text-succeed-60 dark:text-succeed-40'
: 'text-destroy-60 dark:text-destroy-40'
} }
}} >
onKeyUp={(e) => { {isNewVariableNameUnique ? 'Available' : 'Unavailable'}
if (e.key === 'Enter' && canSubmit) { </span>
handleSubmit() </>
} )}
}} </div>
/> )}
<span
className={
isNewVariableNameUnique
? 'text-succeed-60 dark:text-succeed-40'
: 'text-destroy-60 dark:text-destroy-40'
}
>
{isNewVariableNameUnique ? 'Available' : 'Unavailable'}
</span>
</>
)}
</div>
</form> </form>
) )
} }

View File

@ -4,7 +4,7 @@ import type { ComponentProps } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import type { Actor, Prop } from 'xstate' import type { Actor, Prop } from 'xstate'
import type { Operation } from '@rust/kcl-lib/bindings/Operation' import type { Operation, OpKclValue } from '@rust/kcl-lib/bindings/Operation'
import { ContextMenu, ContextMenuItem } from '@src/components/ContextMenu' import { ContextMenu, ContextMenuItem } from '@src/components/ContextMenu'
import type { CustomIconName } from '@src/components/CustomIcon' import type { CustomIconName } from '@src/components/CustomIcon'
@ -241,6 +241,7 @@ const OperationItemWrapper = ({
name, name,
variableName, variableName,
visibilityToggle, visibilityToggle,
valueDetail,
menuItems, menuItems,
errors, errors,
customSuffix, customSuffix,
@ -252,6 +253,7 @@ const OperationItemWrapper = ({
name: string name: string
variableName?: string variableName?: string
visibilityToggle?: VisibilityToggleProps visibilityToggle?: VisibilityToggleProps
valueDetail?: { calculated: OpKclValue; display: string }
customSuffix?: JSX.Element customSuffix?: JSX.Element
menuItems?: ComponentProps<typeof ContextMenu>['items'] menuItems?: ComponentProps<typeof ContextMenu>['items']
errors?: Diagnostic[] errors?: Diagnostic[]
@ -266,19 +268,24 @@ const OperationItemWrapper = ({
> >
<button <button
{...props} {...props}
className={`reset flex-1 flex items-center gap-2 text-left text-base ${selectable ? 'border-transparent dark:border-transparent' : 'border-none cursor-default'} ${className}`} className={`reset !py-0.5 !px-1 flex-1 flex items-center gap-2 text-left text-base ${selectable ? 'border-transparent dark:border-transparent' : 'border-none cursor-default'} ${className}`}
> >
<CustomIcon name={icon} className="w-5 h-5 block" /> <CustomIcon name={icon} className="w-5 h-5 block" />
<div className="flex items-baseline align-baseline"> <div className="flex flex-1 items-baseline align-baseline">
<div className="mr-2"> <div className="flex-1 inline-flex items-baseline flex-wrap gap-x-2">
{name} {name}
{variableName && ( {variableName && (
<span className="ml-2 opacity-50 text-[11px] font-semibold"> <span className="text-chalkboard-70 dark:text-chalkboard-40 text-xs">
{variableName} {variableName}
</span> </span>
)} )}
{customSuffix && customSuffix}
</div> </div>
{customSuffix && customSuffix} {valueDetail && (
<code className="px-1 text-right text-chalkboard-70 dark:text-chalkboard-40 text-xs">
{valueDetail.display}
</code>
)}
</div> </div>
</button> </button>
{errors && errors.length > 0 && ( {errors && errors.length > 0 && (
@ -302,6 +309,19 @@ const OperationItem = (props: {
}) => { }) => {
const kclContext = useKclContext() const kclContext = useKclContext()
const name = getOperationLabel(props.item) const name = getOperationLabel(props.item)
const valueDetail = useMemo(
() =>
props.item.type === 'VariableDeclaration'
? {
display: kclContext.code.slice(
props.item.sourceRange[0],
props.item.sourceRange[1]
),
calculated: props.item.value,
}
: undefined,
[props.item, kclContext.code]
)
const variableName = useMemo(() => { const variableName = useMemo(() => {
return getOperationVariableName(props.item, kclContext.ast) return getOperationVariableName(props.item, kclContext.ast)
@ -334,7 +354,10 @@ const OperationItem = (props: {
* TODO: https://github.com/KittyCAD/modeling-app/issues/4442 * TODO: https://github.com/KittyCAD/modeling-app/issues/4442
*/ */
function enterEditFlow() { function enterEditFlow() {
if (props.item.type === 'StdLibCall') { if (
props.item.type === 'StdLibCall' ||
props.item.type === 'VariableDeclaration'
) {
props.send({ props.send({
type: 'enterEditFlow', type: 'enterEditFlow',
data: { data: {
@ -449,15 +472,25 @@ const OperationItem = (props: {
</ContextMenuItem>, </ContextMenuItem>,
] ]
: []), : []),
...(props.item.type === 'StdLibCall' ...(props.item.type === 'StdLibCall' ||
props.item.type === 'VariableDeclaration'
? [ ? [
<ContextMenuItem <ContextMenuItem
disabled={!stdLibMap[props.item.name]?.prepareToEdit} disabled={
!(
stdLibMap[props.item.name]?.prepareToEdit ||
props.item.type === 'VariableDeclaration'
)
}
onClick={enterEditFlow} onClick={enterEditFlow}
hotkey="Double click" hotkey="Double click"
> >
Edit Edit
</ContextMenuItem>, </ContextMenuItem>,
]
: []),
...(props.item.type === 'StdLibCall'
? [
<ContextMenuItem <ContextMenuItem
disabled={!stdLibMap[props.item.name]?.supportsAppearance} disabled={!stdLibMap[props.item.name]?.supportsAppearance}
onClick={enterAppearanceFlow} onClick={enterAppearanceFlow}
@ -517,6 +550,7 @@ const OperationItem = (props: {
icon={getOperationIcon(props.item)} icon={getOperationIcon(props.item)}
name={name} name={name}
variableName={variableName} variableName={variableName}
valueDetail={valueDetail}
menuItems={menuItems} menuItems={menuItems}
onClick={selectOperation} onClick={selectOperation}
onDoubleClick={enterEditFlow} onDoubleClick={enterEditFlow}

View File

@ -149,6 +149,7 @@ function moreNodePathFromSourceRange(
return moreNodePathFromSourceRange(init, sourceRange, path) return moreNodePathFromSourceRange(init, sourceRange, path)
} }
} }
return path
} }
if (_node.type === 'UnaryExpression' && isInRange) { if (_node.type === 'UnaryExpression' && isInRange) {
const { argument } = _node const { argument } = _node

View File

@ -803,7 +803,9 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
valueSummary: (nodeToEdit: PathToNode) => { valueSummary: (nodeToEdit: PathToNode) => {
const node = getNodeFromPath<VariableDeclarator>( const node = getNodeFromPath<VariableDeclarator>(
kclManager.ast, kclManager.ast,
nodeToEdit nodeToEdit,
'VariableDeclarator',
true
) )
if (err(node) || node.node.type !== 'VariableDeclarator') if (err(node) || node.node.type !== 'VariableDeclarator')
return 'Error' return 'Error'
@ -993,16 +995,46 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
inputType: 'options', inputType: 'options',
required: true, required: true,
options: [ options: [
{ name: 'Red', value: '#FF0000' }, {
{ name: 'Green', value: '#00FF00' }, name: 'Red',
{ name: 'Blue', value: '#0000FF' }, value: '#FF0000',
{ name: 'Turquoise', value: '#00FFFF' }, },
{ name: 'Purple', value: '#FF00FF' }, {
{ name: 'Yellow', value: '#FFFF00' }, name: 'Green',
{ name: 'Black', value: '#000000' }, value: '#00FF00',
{ name: 'Dark Grey', value: '#080808' }, },
{ name: 'Light Grey', value: '#D3D3D3' }, {
{ name: 'White', value: '#FFFFFF' }, name: 'Blue',
value: '#0000FF',
},
{
name: 'Turquoise',
value: '#00FFFF',
},
{
name: 'Purple',
value: '#FF00FF',
},
{
name: 'Yellow',
value: '#FFFF00',
},
{
name: 'Black',
value: '#000000',
},
{
name: 'Dark Grey',
value: '#080808',
},
{
name: 'Light Grey',
value: '#D3D3D3',
},
{
name: 'White',
value: '#FFFFFF',
},
{ {
name: 'Default (clear appearance)', name: 'Default (clear appearance)',
value: COMMAND_APPEARANCE_COLOR_DEFAULT, value: COMMAND_APPEARANCE_COLOR_DEFAULT,
@ -1124,7 +1156,11 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
KCL_DEFAULT_CONSTANT_PREFIXES.CLONE KCL_DEFAULT_CONSTANT_PREFIXES.CLONE
) )
}, },
validation: async ({ data }: { data: string }) => { validation: async ({
data,
}: {
data: string
}) => {
// Be conservative and error out if there is an item or module with the same name. // Be conservative and error out if there is an item or module with the same name.
const variableExists = const variableExists =
kclManager.variables[data] || kclManager.variables['__mod_' + data] kclManager.variables[data] || kclManager.variables['__mod_' + data]

View File

@ -62,6 +62,45 @@ interface StdLibCallInfo {
supportsTransform?: boolean supportsTransform?: boolean
} }
/**
* Gather up the a Parameter operation's data
* to be used in the command bar edit flow.
*/
const prepareToEditParameter: PrepareToEditCallback = async ({ operation }) => {
if (operation.type !== 'VariableDeclaration') {
return { reason: 'Called on something not a variable declaration' }
}
const baseCommand = {
name: 'event.parameter.edit',
groupId: 'modeling',
}
// 1. Convert from the parameter's Operation to a KCL-type arg value
const value = await stringToKclExpression(
codeManager.code.slice(operation.sourceRange[0], operation.sourceRange[1])
)
if (err(value) || 'errors' in value) {
return { reason: "Couldn't retrieve length argument" }
}
// 2. The nodeToEdit is much simpler to transform.
// We need the VariableDeclarator PathToNode though, so we slice it
const nodeToEdit = pathToNodeFromRustNodePath(operation.nodePath).slice(0, -1)
// 3. Assemble the default argument values for the command,
// with `nodeToEdit` set, which will let the actor know
// to edit the node that corresponds to the StdLibCall.
const argDefaultValues: ModelingCommandSchema['event.parameter.edit'] = {
value,
nodeToEdit,
}
return {
...baseCommand,
argDefaultValues,
}
}
/** /**
* Gather up the argument values for the Extrude command * Gather up the argument values for the Extrude command
* to be used in the command bar edit flow. * to be used in the command bar edit flow.
@ -1240,6 +1279,23 @@ export async function enterEditFlow({
operation, operation,
artifact, artifact,
}: EnterEditFlowProps): Promise<Error | CommandBarMachineEvent> { }: EnterEditFlowProps): Promise<Error | CommandBarMachineEvent> {
// Operate on VariableDeclarations differently from StdLibCall's
if (operation.type === 'VariableDeclaration') {
const eventPayload = await prepareToEditParameter({
operation,
})
if ('reason' in eventPayload) {
return new Error(eventPayload.reason)
}
return {
type: 'Find and select command',
data: eventPayload,
}
}
// Begin StdLibCall processing
if (operation.type !== 'StdLibCall') { if (operation.type !== 'StdLibCall') {
return new Error( return new Error(
'Feature tree editing not yet supported for user-defined functions or modules. Please edit in the code editor.' 'Feature tree editing not yet supported for user-defined functions or modules. Please edit in the code editor.'

View File

@ -397,6 +397,7 @@ export const commandBarMachine = setup({
typeof o.value === 'object' && typeof o.value === 'object' &&
typeof argValue === 'object' typeof argValue === 'object'
) { ) {
return JSON.stringify(o.value) === JSON.stringify(argValue) return JSON.stringify(o.value) === JSON.stringify(argValue)
} else { } else {
return o.value === argValue return o.value === argValue

View File

@ -3794,12 +3794,12 @@ export const modelingMachine = setup({
}, },
'event.parameter.create': { 'event.parameter.create': {
target: '#Modeling.parameter.creating', target: '#Modeling.state:parameter:creating',
guard: 'no kcl errors', guard: 'no kcl errors',
}, },
'event.parameter.edit': { 'event.parameter.edit': {
target: '#Modeling.parameter.editing', target: '#Modeling.state:parameter:editing',
guard: 'no kcl errors', guard: 'no kcl errors',
}, },
@ -5206,39 +5206,33 @@ export const modelingMachine = setup({
}, },
}, },
parameter: { 'state:parameter:creating': {
type: 'parallel', invoke: {
src: 'actor.parameter.create',
states: { id: 'actor.parameter.create',
creating: { input: ({ event }) => {
invoke: { if (event.type !== 'event.parameter.create') return undefined
src: 'actor.parameter.create', return event.data
id: 'actor.parameter.create',
input: ({ event }) => {
if (event.type !== 'event.parameter.create') return undefined
return event.data
},
onDone: ['#Modeling.idle'],
onError: {
target: '#Modeling.idle',
actions: 'toastError',
},
},
}, },
editing: { onDone: ['#Modeling.idle'],
invoke: { onError: {
src: 'actor.parameter.edit', target: '#Modeling.idle',
id: 'actor.parameter.edit', actions: 'toastError',
input: ({ event }) => { },
if (event.type !== 'event.parameter.edit') return undefined },
return event.data },
}, 'state:parameter:editing': {
onDone: ['#Modeling.idle'], invoke: {
onError: { src: 'actor.parameter.edit',
target: '#Modeling.idle', id: 'actor.parameter.edit',
actions: 'toastError', input: ({ event }) => {
}, if (event.type !== 'event.parameter.edit') return undefined
}, return event.data
},
onDone: ['#Modeling.idle'],
onError: {
target: '#Modeling.idle',
actions: 'toastError',
}, },
}, },
}, },