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:
		@ -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)
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -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>,
 | 
			
		||||
 | 
			
		||||
@ -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()
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										40
									
								
								src/components/ToastInsert.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/components/ToastInsert.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@ -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'
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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) => {
 | 
			
		||||
 | 
			
		||||
@ -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')
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user