Add point-and-click Insert from local project files (#6129)

* WIP: Add point-and-click Import for geometry
Will eventually fix #6120
Right now the whole loop is there but the codemod doesn't work yet

* Better pathToNOde, log on non-working cm dispatch call

* Add workaround to updateModelingState not working

* Back to updateModelingState with a skip flag

* Better todo

* Change working from Import to Insert, cleanups

* Sister command in kclCommands to populate file options

* Improve path selector

* Unsure: move importAstMod to kclCommands onSubmit 😶

* Add e2e test

* Clean up for review

* Add native file menu entry and test

* No await yo lint said so

* @lrev-Dev's suggestion to remove a comment

Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>

* Update to scene.settled(cmdBar)

* Lint

---------

Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>
This commit is contained in:
Pierre Jacquier
2025-04-07 16:28:11 -04:00
committed by GitHub
parent 962eb0e376
commit bc0f5b5787
14 changed files with 400 additions and 9 deletions

View File

@ -44,6 +44,7 @@ export class ToolbarFixture {
featureTreePane!: Locator
gizmo!: Locator
gizmoDisabled!: Locator
insertButton!: Locator
constructor(page: Page) {
this.page = page
@ -78,6 +79,8 @@ export class ToolbarFixture {
// element or two different elements can represent these states.
this.gizmo = page.getByTestId('gizmo')
this.gizmoDisabled = page.getByTestId('gizmo-disabled')
this.insertButton = page.getByTestId('insert-pane-button')
}
get logoLink() {

View File

@ -570,6 +570,43 @@ test.describe('Native file menu', { tag: ['@electron'] }, () => {
const expected = 'Open sample'
expect(actual).toBe(expected)
})
test('Modeling.File.Insert from project file', async ({
tronApp,
cmdBar,
page,
homePage,
scene,
}) => {
if (!tronApp) {
throwTronAppMissing()
return
}
await homePage.goToModelingScene()
await scene.settled(cmdBar)
// Run electron snippet to find the Menu!
await page.waitForTimeout(100) // wait for createModelingPageMenu() to run
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) {
throw new Error('app or app.applicationMenu is missing')
}
const openProject = app.applicationMenu.getMenuItemById(
'File.Insert from project file'
)
if (!openProject) {
throw new Error('File.Insert from project file')
}
openProject.click()
})
// Check that the command bar is opened
await expect(cmdBar.cmdBarElement).toBeVisible()
// Check the placeholder project name exists
const actual = await cmdBar.cmdBarElement
.getByTestId('command-name')
.textContent()
const expected = 'Insert'
expect(actual).toBe(expected)
})
test('Modeling.File.Export current part', async ({
tronApp,
cmdBar,

View File

@ -0,0 +1,115 @@
import * as fsp from 'fs/promises'
import path from 'path'
import { executorInputPath } from '@e2e/playwright/test-utils'
import { test } from '@e2e/playwright/zoo-test'
// test file is for testing point an click code gen functionality that's assemblies related
test.describe('Point-and-click assemblies tests', () => {
test(
`Insert kcl part into assembly as whole module import`,
{ tag: ['@electron'] },
async ({
context,
page,
homePage,
scene,
editor,
toolbar,
cmdBar,
tronApp,
}) => {
if (!tronApp) {
fail()
}
// One dumb hardcoded screen pixel value
const testPoint = { x: 575, y: 200 }
const initialColor: [number, number, number] = [50, 50, 50]
const partColor: [number, number, number] = [150, 150, 150]
const tolerance = 50
await test.step('Setup parts and expect empty assembly scene', async () => {
const projectName = 'assembly'
await context.folderSetupFn(async (dir) => {
const bracketDir = path.join(dir, projectName)
await fsp.mkdir(bracketDir, { recursive: true })
await Promise.all([
fsp.copyFile(
executorInputPath('cylinder-inches.kcl'),
path.join(bracketDir, 'cylinder.kcl')
),
fsp.copyFile(
executorInputPath('e2e-can-sketch-on-chamfer.kcl'),
path.join(bracketDir, 'bracket.kcl')
),
fsp.writeFile(path.join(bracketDir, 'main.kcl'), ''),
])
})
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.openProject(projectName)
await scene.settled(cmdBar)
await scene.expectPixelColor(initialColor, testPoint, tolerance)
})
await test.step('Insert first part into the assembly', async () => {
await toolbar.insertButton.click()
await cmdBar.selectOption({ name: 'cylinder.kcl' }).click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'localName',
currentArgValue: '',
headerArguments: { Path: 'cylinder.kcl', LocalName: '' },
highlightedHeaderArg: 'localName',
commandName: 'Insert',
})
await page.keyboard.insertText('cylinder')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: { Path: 'cylinder.kcl', LocalName: 'cylinder' },
commandName: 'Insert',
})
await cmdBar.progressCmdBar()
await editor.expectEditor.toContain(
`
import "cylinder.kcl" as cylinder
cylinder
`,
{ shouldNormalise: true }
)
await scene.expectPixelColor(partColor, testPoint, tolerance)
})
await test.step('Insert second part into the assembly', async () => {
await toolbar.insertButton.click()
await cmdBar.selectOption({ name: 'bracket.kcl' }).click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'localName',
currentArgValue: '',
headerArguments: { Path: 'bracket.kcl', LocalName: '' },
highlightedHeaderArg: 'localName',
commandName: 'Insert',
})
await page.keyboard.insertText('bracket')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: { Path: 'bracket.kcl', LocalName: 'bracket' },
commandName: 'Insert',
})
await cmdBar.progressCmdBar()
await editor.expectEditor.toContain(
`
import "cylinder.kcl" as cylinder
import "bracket.kcl" as bracket
cylinder
bracket
`,
{ shouldNormalise: true }
)
})
}
)
})

View File

@ -459,10 +459,30 @@ export const FileMachineProvider = ({
name: sample.title,
})),
},
specialPropsForInsertCommand: {
providedOptions: (isDesktop() && project?.children
? project.children
: []
).flatMap((v) => {
// TODO: add support for full tree traversal when KCL support subdir imports
const relativeFilePath = v.path.replace(
project?.path + window.electron.sep,
''
)
const isDirectory = v.children
const isCurrentFile = v.path === file?.path
return isDirectory || isCurrentFile
? []
: {
name: relativeFilePath,
value: relativeFilePath,
}
}),
},
}).filter(
(command) => kclSamples.length || command.name !== 'open-kcl-example'
),
[codeManager, kclManager, send, kclSamples]
[codeManager, kclManager, send, kclSamples, project, file]
)
useEffect(() => {

View File

@ -14,6 +14,7 @@ import type {
} from '@src/components/ModelingSidebar/ModelingPanes'
import { sidebarPanes } from '@src/components/ModelingSidebar/ModelingPanes'
import Tooltip from '@src/components/Tooltip'
import { DEV } from '@src/env'
import { useModelingContext } from '@src/hooks/useModelingContext'
import { useKclContext } from '@src/lang/KclProvider'
import { SIDEBAR_BUTTON_SUFFIX } from '@src/lib/constants'
@ -21,6 +22,7 @@ import { isDesktop } from '@src/lib/isDesktop'
import { useSettings } from '@src/machines/appMachine'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
interface ModelingSidebarProps {
paneOpacity: '' | 'opacity-20' | 'opacity-40'
@ -60,6 +62,19 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
)
const sidebarActions: SidebarAction[] = [
{
id: 'insert',
title: 'Insert from project file',
sidebarName: 'Insert from project file',
icon: 'import',
keybinding: 'Ctrl + Shift + I',
hide: (a) => a.platform === 'web' || !(DEV || IS_NIGHTLY_OR_DEBUG),
action: () =>
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Insert', groupId: 'code' },
}),
},
{
id: 'export',
title: 'Export part',

View File

@ -113,6 +113,7 @@ function ProjectMenuPopover({
const commands = useSelector(commandBarActor, commandsSelector)
const { onProjectClose } = useLspContext()
const insertCommandInfo = { name: 'Insert', groupId: 'code' }
const exportCommandInfo = { name: 'Export', groupId: 'modeling' }
const makeCommandInfo = { name: 'Make', groupId: 'modeling' }
const shareCommandInfo = { name: 'share-file-link', groupId: 'code' }
@ -145,6 +146,29 @@ function ProjectMenuPopover({
},
},
'break',
{
id: 'insert',
Element: 'button',
children: (
<>
<span>Insert from project file</span>
{!findCommand(insertCommandInfo) && (
<Tooltip
position="right"
wrapperClassName="!max-w-none min-w-fit"
>
Awaiting engine connection
</Tooltip>
)}
</>
),
disabled: !findCommand(insertCommandInfo),
onClick: () =>
commandBarActor.send({
type: 'Find and select command',
data: insertCommandInfo,
}),
},
{
id: 'export',
Element: 'button',

View File

@ -1,7 +1,11 @@
import type { ImportStatement } from '@rust/kcl-lib/bindings/ImportStatement'
import type { Name } from '@rust/kcl-lib/bindings/Name'
import type { Node } from '@rust/kcl-lib/bindings/Node'
import type { TagDeclarator } from '@rust/kcl-lib/bindings/TagDeclarator'
import type { ImportPath } from '@rust/kcl-lib/bindings/ImportPath'
import type { ImportSelector } from '@rust/kcl-lib/bindings/ImportSelector'
import type { ItemVisibility } from '@rust/kcl-lib/bindings/ItemVisibility'
import { ARG_TAG } from '@src/lang/constants'
import { getNodeFromPath } from '@src/lang/queryAst'
import { getNodePathFromSourceRange } from '@src/lang/queryAstNodePathUtils'
@ -12,6 +16,7 @@ import type {
CallExpression,
CallExpressionKw,
Expr,
ExpressionStatement,
Identifier,
LabeledArg,
Literal,
@ -333,6 +338,44 @@ export function createBinaryExpressionWithUnary([left, right]: [
return createBinaryExpression([left, '+', right])
}
export function createImportAsSelector(name: string): ImportSelector {
return { type: 'None', alias: createIdentifier(name) }
}
export function createImportStatement(
selector: ImportSelector,
path: ImportPath,
visibility: ItemVisibility = 'default'
): Node<ImportStatement> {
return {
type: 'ImportStatement',
start: 0,
end: 0,
moduleId: 0,
outerAttrs: [],
preComments: [],
commentStart: 0,
selector,
path,
visibility,
}
}
export function createExpressionStatement(
expression: Expr
): Node<ExpressionStatement> {
return {
type: 'ExpressionStatement',
start: 0,
end: 0,
moduleId: 0,
outerAttrs: [],
preComments: [],
commentStart: 0,
expression,
}
}
export function findUniqueName(
ast: Program | string,
name: string,

View File

@ -17,6 +17,7 @@ import {
EXECUTION_TYPE_NONE,
EXECUTION_TYPE_REAL,
} from '@src/lib/constants'
import type { Selections } from '@src/lib/selections'
/**
* Updates the complete modeling state:
@ -52,15 +53,23 @@ export async function updateModelingState(
},
options?: {
focusPath?: Array<PathToNode>
skipUpdateAst?: boolean
}
): Promise<void> {
let updatedAst: {
newAst: Node<Program>
selections?: Selections
} = { newAst: ast }
// TODO: understand why this skip flag is needed for insertAstMod.
// It's unclear why we double casts the AST
if (!options?.skipUpdateAst) {
// Step 1: Update AST without executing (prepare selections)
const updatedAst = await dependencies.kclManager.updateAst(
updatedAst = await dependencies.kclManager.updateAst(
ast,
// false == mock execution. Is this what we want?
false, // Execution handled separately for error resilience
options
)
}
// Step 2: Update the code editor and save file
await dependencies.codeManager.updateEditorWithAstAndWriteToFile(

View File

@ -9,7 +9,10 @@ import {
createArrayExpression,
createCallExpressionStdLib,
createCallExpressionStdLibKw,
createExpressionStatement,
createIdentifier,
createImportAsSelector,
createImportStatement,
createLabeledArg,
createLiteral,
createLocalName,
@ -778,6 +781,57 @@ export function addOffsetPlane({
}
}
/**
* Add an import call to load a part
*/
export function addImportAndInsert({
node,
path,
localName,
}: {
node: Node<Program>
path: string
localName: string
}): {
modifiedAst: Node<Program>
pathToImportNode: PathToNode
pathToInsertNode: PathToNode
} {
const modifiedAst = structuredClone(node)
// Add import statement
const importStatement = createImportStatement(
createImportAsSelector(localName),
{ type: 'Kcl', filename: path }
)
const lastImportIndex = node.body.findLastIndex(
(v) => v.type === 'ImportStatement'
)
const importIndex = lastImportIndex + 1 // either -1 + 1 = 0 or after the last import
modifiedAst.body.splice(importIndex, 0, importStatement)
const pathToImportNode: PathToNode = [
['body', ''],
[importIndex, 'index'],
['path', 'ImportStatement'],
]
// Add insert statement
const insertStatement = createExpressionStatement(createLocalName(localName))
const insertIndex = modifiedAst.body.length
modifiedAst.body.push(insertStatement)
const pathToInsertNode: PathToNode = [
['body', ''],
[insertIndex, 'index'],
['expression', 'ExpressionStatement'],
]
return {
modifiedAst,
pathToImportNode,
pathToInsertNode,
}
}
/**
* Append a helix to the AST
*/

View File

@ -2,6 +2,9 @@ import type { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
import toast from 'react-hot-toast'
import { CommandBarOverwriteWarning } from '@src/components/CommandBarOverwriteWarning'
import { DEV } from '@src/env'
import { updateModelingState } from '@src/lang/modelingWorkflows'
import { addImportAndInsert } from '@src/lang/modifyAst'
import {
changeKclSettings,
unitAngleToUnitAng,
@ -11,14 +14,16 @@ import type { Command, CommandArgumentOption } from '@src/lib/commandTypes'
import {
DEFAULT_DEFAULT_ANGLE_UNIT,
DEFAULT_DEFAULT_LENGTH_UNIT,
EXECUTION_TYPE_REAL,
FILE_EXT,
} from '@src/lib/constants'
import { isDesktop } from '@src/lib/isDesktop'
import { copyFileShareLink } from '@src/lib/links'
import { baseUnitsUnion } from '@src/lib/settings/settingsTypes'
import { codeManager, kclManager } from '@src/lib/singletons'
import { codeManager, editorManager, kclManager } from '@src/lib/singletons'
import { err, reportRejection } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types'
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
interface OnSubmitProps {
sampleName: string
@ -34,6 +39,9 @@ interface KclCommandConfig {
onSubmit: (p: OnSubmitProps) => Promise<void>
providedOptions: CommandArgumentOption<string>[]
}
specialPropsForInsertCommand: {
providedOptions: CommandArgumentOption<string>[]
}
projectData: IndexLoaderData
authToken: string
settings: {
@ -96,6 +104,50 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
}
},
},
{
name: 'Insert',
description: 'Insert from a file in the current project directory',
icon: 'import',
groupId: 'code',
hide: DEV || IS_NIGHTLY_OR_DEBUG ? 'web' : 'both',
needsReview: true,
reviewMessage:
'Reminder: point-and-click insert is in development and only supports one part instance per assembly.',
args: {
path: {
inputType: 'options',
required: true,
options: commandProps.specialPropsForInsertCommand.providedOptions,
},
localName: {
inputType: 'string',
required: true,
},
},
onSubmit: (data) => {
if (!data) {
return new Error('No input provided')
}
const ast = kclManager.ast
const { path, localName } = data
const { modifiedAst, pathToImportNode, pathToInsertNode } =
addImportAndInsert({
node: ast,
path,
localName,
})
updateModelingState(
modifiedAst,
EXECUTION_TYPE_REAL,
{ kclManager, editorManager, codeManager },
{
skipUpdateAst: true,
focusPath: [pathToImportNode, pathToInsertNode],
}
).catch(reportRejection)
},
},
{
name: 'format-code',
displayName: 'Format Code',

View File

@ -25,6 +25,7 @@ export type MenuLabels =
| 'File.Create new file'
| 'File.Create new folder'
| 'File.Load a sample model'
| 'File.Insert from project file'
| 'File.Export current part'
| 'File.Share current part (via Zoo link)'
| 'File.Preferences.Project settings'

View File

@ -157,6 +157,15 @@ export const modelingFileRole = (
},
},
{ type: 'separator' },
{
label: 'Insert from project file',
id: 'File.Insert from project file',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Insert from project file',
})
},
},
{
label: 'Export current part',
id: 'File.Export current part',

View File

@ -92,6 +92,14 @@ export function modelingMenuCallbackMostActions(
}).catch(reportRejection)
} else if (data.menuLabel === 'File.Preferences.User default units') {
navigate(filePath + PATHS.SETTINGS_USER + '#defaultUnit')
} else if (data.menuLabel === 'File.Insert from project file') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'code',
name: 'Insert',
},
})
} else if (data.menuLabel === 'File.Export current part') {
commandBarActor.send({
type: 'Find and select command',

View File

@ -21,6 +21,7 @@ type FileRoleLabel =
| 'Sign out'
| 'Theme'
| 'Theme color'
| 'Insert from project file'
| 'Export current part'
| 'Create new file'
| 'Create new folder'