Assemblies: UX improvements around foreign file imports (#6159)

* 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

* WIP: UX improvements around foreign file imports
Fixes #6152

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

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

* Update to scene.settled(cmdBar)

* Add partNNN default name for alias

* Lint

* Lint

* Fix unit tests

* Add sad path insert test
Thanks @Irev-Dev for the suggestion

* Add step insert test

* Lint

* Add test for second foreign import thru file tree click

* Add default value for local name alias

* Aligning tests

* Fix tests

* Add padding for filenames starting with a digit

---------

Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>
This commit is contained in:
Pierre Jacquier
2025-04-09 07:47:57 -04:00
committed by GitHub
parent ae9d8be4e4
commit e78100eaac
12 changed files with 454 additions and 104 deletions

View File

@ -1,13 +1,47 @@
import * as fsp from 'fs/promises'
import path from 'path'
import { executorInputPath } from '@e2e/playwright/test-utils'
import { test } from '@e2e/playwright/zoo-test'
import type { CmdBarFixture } from '@e2e/playwright/fixtures/cmdBarFixture'
import type { ToolbarFixture } from '@e2e/playwright/fixtures/toolbarFixture'
import {
executorInputPath,
getUtils,
testsInputPath,
} from '@e2e/playwright/test-utils'
import { expect, test } from '@e2e/playwright/zoo-test'
import type { Page } from '@playwright/test'
async function insertPartIntoAssembly(
path: string,
alias: string,
toolbar: ToolbarFixture,
cmdBar: CmdBarFixture,
page: Page
) {
await toolbar.insertButton.click()
await cmdBar.selectOption({ name: path }).click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'localName',
currentArgValue: '',
headerArguments: { Path: path, LocalName: '' },
highlightedHeaderArg: 'localName',
commandName: 'Insert',
})
await page.keyboard.insertText(alias)
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: { Path: path, LocalName: alias },
commandName: 'Insert',
})
await cmdBar.progressCmdBar()
}
// 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`,
`Insert kcl parts into assembly as whole module import`,
{ tag: ['@electron'] },
async ({
context,
@ -23,11 +57,14 @@ test.describe('Point-and-click assemblies tests', () => {
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 midPoint = { x: 500, y: 250 }
const partPoint = { x: midPoint.x + 30, y: midPoint.y - 30 } // mid point, just off top right
const defaultPlanesColor: [number, number, number] = [180, 220, 180]
const partColor: [number, number, number] = [100, 100, 100]
const tolerance = 50
const u = await getUtils(page)
const gizmo = page.locator('[aria-label*=gizmo]')
const resetCameraButton = page.getByRole('button', { name: 'Reset view' })
await test.step('Setup parts and expect empty assembly scene', async () => {
const projectName = 'assembly'
@ -36,41 +73,36 @@ test.describe('Point-and-click assemblies tests', () => {
await fsp.mkdir(bracketDir, { recursive: true })
await Promise.all([
fsp.copyFile(
executorInputPath('cylinder-inches.kcl'),
executorInputPath('cylinder.kcl'),
path.join(bracketDir, 'cylinder.kcl')
),
fsp.copyFile(
executorInputPath('e2e-can-sketch-on-chamfer.kcl'),
path.join(bracketDir, 'bracket.kcl')
),
fsp.copyFile(
testsInputPath('cube.step'),
path.join(bracketDir, 'cube.step')
),
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 toolbar.closePane('code')
await scene.expectPixelColor(defaultPlanesColor, midPoint, 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 test.step('Insert kcl as first part as module', async () => {
await insertPartIntoAssembly(
'cylinder.kcl',
'cylinder',
toolbar,
cmdBar,
page
)
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
import "cylinder.kcl" as cylinder
@ -78,28 +110,27 @@ test.describe('Point-and-click assemblies tests', () => {
`,
{ shouldNormalise: true }
)
await scene.expectPixelColor(partColor, testPoint, tolerance)
await scene.settled(cmdBar)
// Check scene for changes
await toolbar.closePane('code')
await u.doAndWaitForCmd(async () => {
await gizmo.click({ button: 'right' })
await resetCameraButton.click()
}, 'zoom_to_fit')
await toolbar.closePane('debug')
await scene.expectPixelColor(partColor, partPoint, tolerance)
await toolbar.openPane('code')
})
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 test.step('Insert kcl second part as module', async () => {
await insertPartIntoAssembly(
'bracket.kcl',
'bracket',
toolbar,
cmdBar,
page
)
await editor.expectEditor.toContain(
`
import "cylinder.kcl" as cylinder
@ -109,6 +140,152 @@ test.describe('Point-and-click assemblies tests', () => {
`,
{ shouldNormalise: true }
)
await scene.settled(cmdBar)
})
await test.step('Insert a second time and expect error', async () => {
// TODO: revisit once we have clone with #6209
await insertPartIntoAssembly(
'bracket.kcl',
'bracket',
toolbar,
cmdBar,
page
)
await editor.expectEditor.toContain(
`
import "cylinder.kcl" as cylinder
import "bracket.kcl" as bracket
import "bracket.kcl" as bracket
cylinder
bracket
bracket
`,
{ shouldNormalise: true }
)
await scene.settled(cmdBar)
await expect(page.locator('.cm-lint-marker-error')).toBeVisible()
})
}
)
test(
`Insert foreign parts into assembly as whole module import`,
{ tag: ['@electron'] },
async ({
context,
page,
homePage,
scene,
editor,
toolbar,
cmdBar,
tronApp,
}) => {
if (!tronApp) {
fail()
}
const midPoint = { x: 500, y: 250 }
const partPoint = { x: midPoint.x + 30, y: midPoint.y - 30 } // mid point, just off top right
const defaultPlanesColor: [number, number, number] = [180, 220, 180]
const partColor: [number, number, number] = [150, 150, 150]
const tolerance = 50
const complexPlmFileName = 'cube_Complex-PLM_Name_-001.sldprt'
const camelCasedSolidworksFileName = 'cubeComplexPLMName001'
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(
testsInputPath('cube.step'),
path.join(bracketDir, 'cube.step')
),
fsp.copyFile(
testsInputPath('cube.sldprt'),
path.join(bracketDir, complexPlmFileName)
),
fsp.writeFile(path.join(bracketDir, 'main.kcl'), ''),
])
})
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.openProject(projectName)
await scene.settled(cmdBar)
await toolbar.closePane('code')
await scene.expectPixelColor(defaultPlanesColor, midPoint, tolerance)
})
await test.step('Insert step part as module', async () => {
await insertPartIntoAssembly('cube.step', 'cube', toolbar, cmdBar, page)
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
import "cube.step" as cube
cube
`,
{ shouldNormalise: true }
)
await scene.settled(cmdBar)
// TODO: remove this once #5780 is fixed
await page.reload()
await scene.settled(cmdBar)
await expect(page.locator('.cm-lint-marker-error')).not.toBeVisible()
await toolbar.closePane('code')
await scene.expectPixelColor(partColor, partPoint, tolerance)
})
await test.step('Insert second step part by clicking', async () => {
await toolbar.openPane('files')
await toolbar.expectFileTreeState([
complexPlmFileName,
'cube.step',
'main.kcl',
])
await toolbar.openFile(complexPlmFileName)
// Go through the ToastInsert prompt
await page.getByText('Insert into my current file').click()
// Check getPathFilenameInVariableCase output
const parsedValueFromFile =
await cmdBar.currentArgumentInput.inputValue()
expect(parsedValueFromFile).toEqual(camelCasedSolidworksFileName)
// Continue on with the flow
await page.keyboard.insertText('cubeSw')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: { Path: complexPlmFileName, LocalName: 'cubeSw' },
commandName: 'Insert',
})
await cmdBar.progressCmdBar()
await toolbar.closePane('files')
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
import "cube.step" as cube
import "${complexPlmFileName}" as cubeSw
cube
cubeSw
`,
{ shouldNormalise: true }
)
await scene.settled(cmdBar)
// TODO: remove this once #5780 is fixed
await page.reload()
await scene.settled(cmdBar)
await expect(page.locator('.cm-lint-marker-error')).not.toBeVisible()
await toolbar.closePane('code')
await scene.expectPixelColor(partColor, partPoint, tolerance)
})
}
)

View File

@ -1021,6 +1021,10 @@ export function executorInputPath(fileName: string): string {
return path.join('rust', 'kcl-lib', 'e2e', 'executor', 'inputs', fileName)
}
export function testsInputPath(fileName: string): string {
return path.join('rust', 'kcl-lib', 'tests', 'inputs', fileName)
}
export async function doAndWaitForImageDiff(
page: Page,
fn: () => Promise<unknown>,

View File

@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import { useSelector } from '@xstate/react'
import { useEffect, useMemo, useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import type { CommandArgument } from '@src/lib/commandTypes'
@ -6,6 +7,11 @@ import {
commandBarActor,
useCommandBarState,
} from '@src/machines/commandBarMachine'
import type { AnyStateMachine, SnapshotFrom } from 'xstate'
// TODO: remove the need for this selector once we decouple all actors from React
const machineContextSelector = (snapshot?: SnapshotFrom<AnyStateMachine>) =>
snapshot?.context
function CommandBarBasicInput({
arg,
@ -22,6 +28,19 @@ function CommandBarBasicInput({
const commandBarState = useCommandBarState()
useHotkeys('mod + k, mod + /', () => commandBarActor.send({ type: 'Close' }))
const inputRef = useRef<HTMLInputElement>(null)
const argMachineContext = useSelector(
arg.machineActor,
machineContextSelector
)
const defaultValue = useMemo(
() =>
arg.defaultValue
? arg.defaultValue instanceof Function
? arg.defaultValue(commandBarState.context, argMachineContext)
: arg.defaultValue
: '',
[arg.defaultValue, commandBarState.context, argMachineContext]
)
useEffect(() => {
if (inputRef.current) {
@ -53,11 +72,7 @@ function CommandBarBasicInput({
required
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
placeholder="Enter a value"
defaultValue={
(commandBarState.context.argumentsToSubmit[arg.name] as
| string
| undefined) || (arg.defaultValue as string)
}
defaultValue={defaultValue}
onKeyDown={(event) => {
if (event.key === 'Backspace' && event.shiftKey) {
stepBack()

View File

@ -19,7 +19,7 @@ import { useKclContext } from '@src/lang/KclProvider'
import type { KCLError } from '@src/lang/errors'
import { kclErrorsByFilename } from '@src/lang/errors'
import { normalizeLineEndings } from '@src/lib/codeEditor'
import { FILE_EXT } from '@src/lib/constants'
import { FILE_EXT, INSERT_FOREIGN_TOAST_ID } from '@src/lib/constants'
import { sortFilesAndDirectories } from '@src/lib/desktopFS'
import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
import { PATHS } from '@src/lib/paths'
@ -28,6 +28,9 @@ import { codeManager, kclManager } from '@src/lib/singletons'
import { reportRejection } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types'
import { ToastInsert } from '@src/components/ToastInsert'
import { commandBarActor } from '@src/machines/commandBarMachine'
import toast from 'react-hot-toast'
import styles from './FileTree.module.css'
function getIndentationCSS(level: number) {
@ -264,16 +267,26 @@ const FileTreeItem = ({
if (fileOrDir.children !== null) return // Don't open directories
if (fileOrDir.name?.endsWith(FILE_EXT) === false && project?.path) {
// Import non-kcl files
// We want to update both the state and editor here.
codeManager.updateCodeStateEditor(
`import("${fileOrDir.path.replace(project.path, '.')}")\n` +
codeManager.code
toast.custom(
ToastInsert({
onInsert: () => {
const relativeFilePath = fileOrDir.path.replace(
project.path + window.electron.sep,
''
)
commandBarActor.send({
type: 'Find and select command',
data: {
name: 'Insert',
groupId: 'code',
argDefaultValues: { path: relativeFilePath },
},
})
toast.dismiss(INSERT_FOREIGN_TOAST_ID)
},
}),
{ duration: 30000, id: INSERT_FOREIGN_TOAST_ID }
)
await codeManager.writeToFile()
// Prevent seeing the model built one piece at a time when changing files
await kclManager.executeCode()
} else {
// Let the lsp servers know we closed a file.
onFileClose(currentFile?.path || null, project?.path || null)

View File

@ -0,0 +1,40 @@
import toast from 'react-hot-toast'
import { ActionButton } from '@src/components/ActionButton'
export function ToastInsert({ onInsert }: { onInsert: () => void }) {
return (
<div className="inset-0 z-50 grid place-content-center rounded bg-chalkboard-110/50 shadow-md">
<div className="max-w-3xl min-w-[35rem] p-8 rounded bg-chalkboard-10 dark:bg-chalkboard-90">
<p className="text-md">
Non-KCL files aren't editable here in Zoo Studio, but you may insert
them using the button below or the Insert command.
</p>
<div className="mt-4 flex justify-between gap-8">
<ActionButton
Element="button"
iconStart={{
icon: 'checkmark',
}}
name="insert"
onClick={onInsert}
>
Insert into my current file
</ActionButton>
<ActionButton
Element="button"
iconStart={{
icon: 'close',
}}
name="dismiss"
onClick={() => {
toast.dismiss()
}}
>
Dismiss
</ActionButton>
</div>
</div>
</div>
)
}

View File

@ -1,4 +1,5 @@
import type { Models } from '@kittycad/lib/dist/types/src'
import type { FileImportFormat_type } from '@kittycad/lib/dist/types/src/models'
import type { UnitAngle, UnitLength } from '@rust/kcl-lib/bindings/ModelingCmd'
@ -37,13 +38,24 @@ export const FILE_PERSIST_KEY = `${PROJECT_FOLDER}-last-opened` as const
export const DEFAULT_FILE_NAME = 'Untitled'
/** The file endings that will appear in
* the file explorer if found in a project directory */
export const RELEVANT_FILE_TYPES = [
// TODO: make stp part of this enum as an alias to step
// TODO: make glb part of this enum as it is in fact supported
export type NativeFileType = 'kcl'
export type RelevantFileType =
| FileImportFormat_type
| NativeFileType
| 'stp'
| 'glb'
export const NATIVE_FILE_TYPE: NativeFileType = 'kcl'
export const RELEVANT_FILE_TYPES: RelevantFileType[] = [
'kcl',
'fbx',
'gltf',
'glb',
'obj',
'ply',
'sldprt',
'stp',
'step',
'stl',
] as const
@ -131,6 +143,9 @@ export const CREATE_FILE_URL_PARAM = 'create-file'
/** Toast id for the app auto-updater toast */
export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast'
/** Toast id for the insert foreign part toast */
export const INSERT_FOREIGN_TOAST_ID = 'insert-foreign-toast'
/** Local sketch axis values in KCL for operations, it could either be 'X' or 'Y' */
export const KCL_AXIS_X = 'X'
export const KCL_AXIS_Y = 'Y'

View File

@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Configuration } from '@rust/kcl-lib/bindings/Configuration'
import { listProjects } from '@src/lib/desktop'
import { isRelevantFile, listProjects } from '@src/lib/desktop'
import type { DeepPartial } from '@src/lib/types'
// Mock the electron window global
@ -112,6 +112,41 @@ describe('desktop utilities', () => {
mockElectron.kittycad.mockResolvedValue({})
})
describe('isRelevantFile', () => {
it('finds supported extension files relevant', () => {
expect(isRelevantFile('part.kcl')).toEqual(true)
expect(isRelevantFile('part.fbx')).toEqual(true)
expect(isRelevantFile('part.gltf')).toEqual(true)
expect(isRelevantFile('part.glb')).toEqual(true)
expect(isRelevantFile('part.obj')).toEqual(true)
expect(isRelevantFile('part.ply')).toEqual(true)
expect(isRelevantFile('part.sldprt')).toEqual(true)
expect(isRelevantFile('part.stp')).toEqual(true)
expect(isRelevantFile('part.step')).toEqual(true)
expect(isRelevantFile('part.stl')).toEqual(true)
})
// TODO: we should be lowercasing the extension here to check. .sldprt or .SLDPRT should be supported
// But the api doesn't allow it today, so revisit this and the tests once this is done
it('finds (now) supported uppercase extension files *not* relevant', () => {
expect(isRelevantFile('part.KCL')).toEqual(false)
expect(isRelevantFile('part.FBX')).toEqual(false)
expect(isRelevantFile('part.GLTF')).toEqual(false)
expect(isRelevantFile('part.GLB')).toEqual(false)
expect(isRelevantFile('part.OBJ')).toEqual(false)
expect(isRelevantFile('part.PLY')).toEqual(false)
expect(isRelevantFile('part.SLDPRT')).toEqual(false)
expect(isRelevantFile('part.STP')).toEqual(false)
expect(isRelevantFile('part.STEP')).toEqual(false)
expect(isRelevantFile('part.STL')).toEqual(false)
})
it("doesn't find .docx or .SLDASM relevant", () => {
expect(isRelevantFile('paper.docx')).toEqual(false)
expect(isRelevantFile('assembly.SLDASM')).toEqual(false)
})
})
describe('listProjects', () => {
it('does not list .git directories', async () => {
const projects = await listProjects(mockConfig)

View File

@ -16,6 +16,7 @@ import {
PROJECT_FOLDER,
PROJECT_IMAGE_NAME,
PROJECT_SETTINGS_FILE_NAME,
RELEVANT_FILE_TYPES,
SETTINGS_FILE_NAME,
TELEMETRY_FILE_NAME,
TELEMETRY_RAW_FILE_NAME,
@ -24,6 +25,7 @@ import {
import type { FileEntry, Project } from '@src/lib/project'
import { err } from '@src/lib/trap'
import type { DeepPartial } from '@src/lib/types'
import { getInVariableCase } from '@src/lib/utils'
export async function renameProjectDirectory(
projectPath: string,
@ -199,16 +201,10 @@ export async function listProjects(
return projects
}
const IMPORT_FILE_EXTENSIONS = [
// TODO Use ImportFormat enum
'stp',
'glb',
'fbxb',
'kcl',
]
const isRelevantFile = (filename: string): boolean =>
IMPORT_FILE_EXTENSIONS.some((ext) => filename.endsWith('.' + ext))
// TODO: we should be lowercasing the extension here to check. .sldprt or .SLDPRT should be supported
// But the api doesn't allow it today, so revisit this and the tests once this is done
export const isRelevantFile = (filename: string): boolean =>
RELEVANT_FILE_TYPES.some((ext) => filename.endsWith('.' + ext))
const collectAllFilesRecursiveFrom = async (
path: string,
@ -731,3 +727,12 @@ export const writeProjectThumbnailFile = async (
}
return window.electron.writeFile(filePath, asArray)
}
export function getPathFilenameInVariableCase(path: string) {
// from https://nodejs.org/en/learn/manipulating-files/nodejs-file-paths#example
const basenameNoExt = window.electron.path.basename(
path,
window.electron.path.extname(path)
)
return getInVariableCase(basenameNoExt)
}

View File

@ -1,31 +1,17 @@
import type { Models } from '@kittycad/lib/dist/types/src'
import type { Stats } from 'fs'
import * as fs from 'fs/promises'
import * as path from 'path'
import { PROJECT_ENTRYPOINT } from '@src/lib/constants'
import {
NATIVE_FILE_TYPE,
PROJECT_ENTRYPOINT,
RELEVANT_FILE_TYPES,
type RelevantFileType,
} from '@src/lib/constants'
// Create a const object with the values
const FILE_IMPORT_FORMATS = {
fbx: 'fbx',
gltf: 'gltf',
obj: 'obj',
ply: 'ply',
sldprt: 'sldprt',
step: 'step',
stl: 'stl',
} as const
// Extract the values into an array
const fileImportFormats: Models['FileImportFormat_type'][] =
Object.values(FILE_IMPORT_FORMATS)
export const allFileImportFormats: string[] = [
...fileImportFormats,
'stp',
'fbxb',
'glb',
]
export const relevantExtensions = ['kcl', ...allFileImportFormats]
const shouldWrapExtension = (extension: string) =>
RELEVANT_FILE_TYPES.includes(extension as RelevantFileType) &&
extension !== NATIVE_FILE_TYPE
/// Get the current project file from the path.
/// This is used for double-clicking on a file in the file explorer,
@ -83,11 +69,14 @@ export default async function getCurrentProjectFile(
}
// Check if the extension on what we are trying to open is a relevant file type.
const extension = path.extname(sourcePath).slice(1)
const extension = path.extname(sourcePath).slice(1).toLowerCase()
if (!relevantExtensions.includes(extension) && extension !== 'toml') {
if (
!RELEVANT_FILE_TYPES.includes(extension as RelevantFileType) &&
extension !== 'toml'
) {
return new Error(
`File type (${extension}) cannot be opened with this app: '${sourcePath}', try opening one of the following file types: ${relevantExtensions.join(
`File type (${extension}) cannot be opened with this app: '${sourcePath}', try opening one of the following file types: ${RELEVANT_FILE_TYPES.join(
', '
)}`
)
@ -99,7 +88,9 @@ export default async function getCurrentProjectFile(
// If we got an import model file, we need to check if we have a file in the project for
// this import model.
if (allFileImportFormats.includes(extension)) {
// TODO: once we have some sort of a load file into project it would make sense to stop creating these wrapper files
// and let people save their own kcl file importing
if (shouldWrapExtension(extension)) {
const importFileName = path.basename(sourcePath)
// Check if we have a file in the project for this import model.
const kclWrapperFilename = `${importFileName}.kcl`
@ -115,7 +106,8 @@ export default async function getCurrentProjectFile(
// But we recommend you keep the import statement as it is.
// For more information on the import statement, see the documentation at:
// https://zoo.dev/docs/kcl/import
const model = import("${importFileName}")`
import "${importFileName}" as model
model`
await fs.writeFile(kclWrapperFilePath, content)
}

View File

@ -17,12 +17,14 @@ import {
EXECUTION_TYPE_REAL,
FILE_EXT,
} from '@src/lib/constants'
import { getPathFilenameInVariableCase } from '@src/lib/desktop'
import { isDesktop } from '@src/lib/isDesktop'
import { copyFileShareLink } from '@src/lib/links'
import { baseUnitsUnion } from '@src/lib/settings/settingsTypes'
import { codeManager, editorManager, kclManager } from '@src/lib/singletons'
import { err, reportRejection } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types'
import type { CommandBarContext } from '@src/machines/commandBarMachine'
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
interface OnSubmitProps {
@ -122,6 +124,14 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
localName: {
inputType: 'string',
required: true,
defaultValue: (context: CommandBarContext) => {
if (!context.argumentsToSubmit['path']) {
return
}
const path = context.argumentsToSubmit['path'] as string
return getPathFilenameInVariableCase(path)
},
},
},
onSubmit: (data) => {

View File

@ -1,6 +1,7 @@
import type { SourceRange } from '@rust/kcl-lib/bindings/SourceRange'
import { topLevelRange } from '@src/lang/util'
import {
getInVariableCase,
hasDigitsLeftOfDecimal,
hasLeadingZero,
isClockwise,
@ -1308,3 +1309,24 @@ describe('testing isClockwise', () => {
expect(isClockwise(counterClockwiseTriangle)).toBe(true)
})
})
describe('testing getInVariableCase', () => {
it('properly parses cylinder into cylinder', () => {
expect(getInVariableCase('cylinder')).toBe('cylinder')
})
it('properly parses my-ugly_Cased_Part123 into myUglyCasedPart', () => {
expect(getInVariableCase('my-ugly_Cased_Part123')).toBe(
'myUglyCasedPart123'
)
})
it('properly parses PascalCase into pascalCase', () => {
expect(getInVariableCase('PascalCase')).toBe('pascalCase')
})
it('properly parses my/File/Path into myFilePath', () => {
expect(getInVariableCase('my/File/Path')).toBe('myFilePath')
})
it('properly parses prefixes 1120t74-pipe.step', () => {
expect(getInVariableCase('1120t74-pipe')).toBe('m1120T74Pipe')
expect(getInVariableCase('1120t74-pipe', 'p')).toBe('p1120T74Pipe')
})
})

View File

@ -473,3 +473,25 @@ export function binaryToUuid(
export function getModuleId(sourceRange: SourceRange) {
return sourceRange[2]
}
export function getInVariableCase(name: string, prefixIfDigit = 'm') {
// As of 2025-04-08, standard case for KCL variables is camelCase
const startsWithANumber = !Number.isNaN(Number(name.charAt(0)))
const paddedName = startsWithANumber ? `${prefixIfDigit}${name}` : name
// From https://www.30secondsofcode.org/js/s/string-case-conversion/#word-boundary-identification
const r = /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g
const boundaryIdentification = paddedName.match(r)
if (!boundaryIdentification) {
return undefined
}
const likelyPascalCase = boundaryIdentification
.map((x) => x.slice(0, 1).toUpperCase() + x.slice(1).toLowerCase())
.join('')
if (!likelyPascalCase) {
return undefined
}
return likelyPascalCase.slice(0, 1).toLowerCase() + likelyPascalCase.slice(1)
}