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 * as fsp from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
import { executorInputPath } from '@e2e/playwright/test-utils'
|
import type { CmdBarFixture } from '@e2e/playwright/fixtures/cmdBarFixture'
|
||||||
import { test } from '@e2e/playwright/zoo-test'
|
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 file is for testing point an click code gen functionality that's assemblies related
|
||||||
test.describe('Point-and-click assemblies tests', () => {
|
test.describe('Point-and-click assemblies tests', () => {
|
||||||
test(
|
test(
|
||||||
`Insert kcl part into assembly as whole module import`,
|
`Insert kcl parts into assembly as whole module import`,
|
||||||
{ tag: ['@electron'] },
|
{ tag: ['@electron'] },
|
||||||
async ({
|
async ({
|
||||||
context,
|
context,
|
||||||
@ -23,11 +57,14 @@ test.describe('Point-and-click assemblies tests', () => {
|
|||||||
fail()
|
fail()
|
||||||
}
|
}
|
||||||
|
|
||||||
// One dumb hardcoded screen pixel value
|
const midPoint = { x: 500, y: 250 }
|
||||||
const testPoint = { x: 575, y: 200 }
|
const partPoint = { x: midPoint.x + 30, y: midPoint.y - 30 } // mid point, just off top right
|
||||||
const initialColor: [number, number, number] = [50, 50, 50]
|
const defaultPlanesColor: [number, number, number] = [180, 220, 180]
|
||||||
const partColor: [number, number, number] = [150, 150, 150]
|
const partColor: [number, number, number] = [100, 100, 100]
|
||||||
const tolerance = 50
|
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 () => {
|
await test.step('Setup parts and expect empty assembly scene', async () => {
|
||||||
const projectName = 'assembly'
|
const projectName = 'assembly'
|
||||||
@ -36,41 +73,36 @@ test.describe('Point-and-click assemblies tests', () => {
|
|||||||
await fsp.mkdir(bracketDir, { recursive: true })
|
await fsp.mkdir(bracketDir, { recursive: true })
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fsp.copyFile(
|
fsp.copyFile(
|
||||||
executorInputPath('cylinder-inches.kcl'),
|
executorInputPath('cylinder.kcl'),
|
||||||
path.join(bracketDir, 'cylinder.kcl')
|
path.join(bracketDir, 'cylinder.kcl')
|
||||||
),
|
),
|
||||||
fsp.copyFile(
|
fsp.copyFile(
|
||||||
executorInputPath('e2e-can-sketch-on-chamfer.kcl'),
|
executorInputPath('e2e-can-sketch-on-chamfer.kcl'),
|
||||||
path.join(bracketDir, 'bracket.kcl')
|
path.join(bracketDir, 'bracket.kcl')
|
||||||
),
|
),
|
||||||
|
fsp.copyFile(
|
||||||
|
testsInputPath('cube.step'),
|
||||||
|
path.join(bracketDir, 'cube.step')
|
||||||
|
),
|
||||||
fsp.writeFile(path.join(bracketDir, 'main.kcl'), ''),
|
fsp.writeFile(path.join(bracketDir, 'main.kcl'), ''),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
await page.setBodyDimensions({ width: 1000, height: 500 })
|
await page.setBodyDimensions({ width: 1000, height: 500 })
|
||||||
await homePage.openProject(projectName)
|
await homePage.openProject(projectName)
|
||||||
await scene.settled(cmdBar)
|
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 test.step('Insert kcl as first part as module', async () => {
|
||||||
await toolbar.insertButton.click()
|
await insertPartIntoAssembly(
|
||||||
await cmdBar.selectOption({ name: 'cylinder.kcl' }).click()
|
'cylinder.kcl',
|
||||||
await cmdBar.expectState({
|
'cylinder',
|
||||||
stage: 'arguments',
|
toolbar,
|
||||||
currentArgKey: 'localName',
|
cmdBar,
|
||||||
currentArgValue: '',
|
page
|
||||||
headerArguments: { Path: 'cylinder.kcl', LocalName: '' },
|
)
|
||||||
highlightedHeaderArg: 'localName',
|
await toolbar.openPane('code')
|
||||||
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(
|
await editor.expectEditor.toContain(
|
||||||
`
|
`
|
||||||
import "cylinder.kcl" as cylinder
|
import "cylinder.kcl" as cylinder
|
||||||
@ -78,28 +110,27 @@ test.describe('Point-and-click assemblies tests', () => {
|
|||||||
`,
|
`,
|
||||||
{ shouldNormalise: true }
|
{ 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 test.step('Insert kcl second part as module', async () => {
|
||||||
await toolbar.insertButton.click()
|
await insertPartIntoAssembly(
|
||||||
await cmdBar.selectOption({ name: 'bracket.kcl' }).click()
|
'bracket.kcl',
|
||||||
await cmdBar.expectState({
|
'bracket',
|
||||||
stage: 'arguments',
|
toolbar,
|
||||||
currentArgKey: 'localName',
|
cmdBar,
|
||||||
currentArgValue: '',
|
page
|
||||||
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(
|
await editor.expectEditor.toContain(
|
||||||
`
|
`
|
||||||
import "cylinder.kcl" as cylinder
|
import "cylinder.kcl" as cylinder
|
||||||
@ -109,6 +140,152 @@ test.describe('Point-and-click assemblies tests', () => {
|
|||||||
`,
|
`,
|
||||||
{ shouldNormalise: true }
|
{ 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)
|
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(
|
export async function doAndWaitForImageDiff(
|
||||||
page: Page,
|
page: Page,
|
||||||
fn: () => Promise<unknown>,
|
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 { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
|
||||||
import type { CommandArgument } from '@src/lib/commandTypes'
|
import type { CommandArgument } from '@src/lib/commandTypes'
|
||||||
@ -6,6 +7,11 @@ import {
|
|||||||
commandBarActor,
|
commandBarActor,
|
||||||
useCommandBarState,
|
useCommandBarState,
|
||||||
} from '@src/machines/commandBarMachine'
|
} 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({
|
function CommandBarBasicInput({
|
||||||
arg,
|
arg,
|
||||||
@ -22,6 +28,19 @@ function CommandBarBasicInput({
|
|||||||
const commandBarState = useCommandBarState()
|
const commandBarState = useCommandBarState()
|
||||||
useHotkeys('mod + k, mod + /', () => commandBarActor.send({ type: 'Close' }))
|
useHotkeys('mod + k, mod + /', () => commandBarActor.send({ type: 'Close' }))
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
@ -53,11 +72,7 @@ function CommandBarBasicInput({
|
|||||||
required
|
required
|
||||||
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
|
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"
|
placeholder="Enter a value"
|
||||||
defaultValue={
|
defaultValue={defaultValue}
|
||||||
(commandBarState.context.argumentsToSubmit[arg.name] as
|
|
||||||
| string
|
|
||||||
| undefined) || (arg.defaultValue as string)
|
|
||||||
}
|
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Backspace' && event.shiftKey) {
|
if (event.key === 'Backspace' && event.shiftKey) {
|
||||||
stepBack()
|
stepBack()
|
||||||
|
@ -19,7 +19,7 @@ import { useKclContext } from '@src/lang/KclProvider'
|
|||||||
import type { KCLError } from '@src/lang/errors'
|
import type { KCLError } from '@src/lang/errors'
|
||||||
import { kclErrorsByFilename } from '@src/lang/errors'
|
import { kclErrorsByFilename } from '@src/lang/errors'
|
||||||
import { normalizeLineEndings } from '@src/lib/codeEditor'
|
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 { sortFilesAndDirectories } from '@src/lib/desktopFS'
|
||||||
import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
|
import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
|
||||||
import { PATHS } from '@src/lib/paths'
|
import { PATHS } from '@src/lib/paths'
|
||||||
@ -28,6 +28,9 @@ import { codeManager, kclManager } from '@src/lib/singletons'
|
|||||||
import { reportRejection } from '@src/lib/trap'
|
import { reportRejection } from '@src/lib/trap'
|
||||||
import type { IndexLoaderData } from '@src/lib/types'
|
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'
|
import styles from './FileTree.module.css'
|
||||||
|
|
||||||
function getIndentationCSS(level: number) {
|
function getIndentationCSS(level: number) {
|
||||||
@ -264,16 +267,26 @@ const FileTreeItem = ({
|
|||||||
if (fileOrDir.children !== null) return // Don't open directories
|
if (fileOrDir.children !== null) return // Don't open directories
|
||||||
|
|
||||||
if (fileOrDir.name?.endsWith(FILE_EXT) === false && project?.path) {
|
if (fileOrDir.name?.endsWith(FILE_EXT) === false && project?.path) {
|
||||||
// Import non-kcl files
|
toast.custom(
|
||||||
// We want to update both the state and editor here.
|
ToastInsert({
|
||||||
codeManager.updateCodeStateEditor(
|
onInsert: () => {
|
||||||
`import("${fileOrDir.path.replace(project.path, '.')}")\n` +
|
const relativeFilePath = fileOrDir.path.replace(
|
||||||
codeManager.code
|
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 {
|
} else {
|
||||||
// Let the lsp servers know we closed a file.
|
// Let the lsp servers know we closed a file.
|
||||||
onFileClose(currentFile?.path || null, project?.path || null)
|
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 { 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'
|
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'
|
export const DEFAULT_FILE_NAME = 'Untitled'
|
||||||
/** The file endings that will appear in
|
/** The file endings that will appear in
|
||||||
* the file explorer if found in a project directory */
|
* 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',
|
'kcl',
|
||||||
'fbx',
|
'fbx',
|
||||||
'gltf',
|
'gltf',
|
||||||
'glb',
|
'glb',
|
||||||
'obj',
|
'obj',
|
||||||
'ply',
|
'ply',
|
||||||
|
'sldprt',
|
||||||
|
'stp',
|
||||||
'step',
|
'step',
|
||||||
'stl',
|
'stl',
|
||||||
] as const
|
] as const
|
||||||
@ -131,6 +143,9 @@ export const CREATE_FILE_URL_PARAM = 'create-file'
|
|||||||
/** Toast id for the app auto-updater toast */
|
/** Toast id for the app auto-updater toast */
|
||||||
export const AUTO_UPDATER_TOAST_ID = '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' */
|
/** 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_X = 'X'
|
||||||
export const KCL_AXIS_Y = 'Y'
|
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 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'
|
import type { DeepPartial } from '@src/lib/types'
|
||||||
|
|
||||||
// Mock the electron window global
|
// Mock the electron window global
|
||||||
@ -112,6 +112,41 @@ describe('desktop utilities', () => {
|
|||||||
mockElectron.kittycad.mockResolvedValue({})
|
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', () => {
|
describe('listProjects', () => {
|
||||||
it('does not list .git directories', async () => {
|
it('does not list .git directories', async () => {
|
||||||
const projects = await listProjects(mockConfig)
|
const projects = await listProjects(mockConfig)
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
PROJECT_FOLDER,
|
PROJECT_FOLDER,
|
||||||
PROJECT_IMAGE_NAME,
|
PROJECT_IMAGE_NAME,
|
||||||
PROJECT_SETTINGS_FILE_NAME,
|
PROJECT_SETTINGS_FILE_NAME,
|
||||||
|
RELEVANT_FILE_TYPES,
|
||||||
SETTINGS_FILE_NAME,
|
SETTINGS_FILE_NAME,
|
||||||
TELEMETRY_FILE_NAME,
|
TELEMETRY_FILE_NAME,
|
||||||
TELEMETRY_RAW_FILE_NAME,
|
TELEMETRY_RAW_FILE_NAME,
|
||||||
@ -24,6 +25,7 @@ import {
|
|||||||
import type { FileEntry, Project } from '@src/lib/project'
|
import type { FileEntry, Project } from '@src/lib/project'
|
||||||
import { err } from '@src/lib/trap'
|
import { err } from '@src/lib/trap'
|
||||||
import type { DeepPartial } from '@src/lib/types'
|
import type { DeepPartial } from '@src/lib/types'
|
||||||
|
import { getInVariableCase } from '@src/lib/utils'
|
||||||
|
|
||||||
export async function renameProjectDirectory(
|
export async function renameProjectDirectory(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
@ -199,16 +201,10 @@ export async function listProjects(
|
|||||||
return projects
|
return projects
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMPORT_FILE_EXTENSIONS = [
|
// TODO: we should be lowercasing the extension here to check. .sldprt or .SLDPRT should be supported
|
||||||
// TODO Use ImportFormat enum
|
// But the api doesn't allow it today, so revisit this and the tests once this is done
|
||||||
'stp',
|
export const isRelevantFile = (filename: string): boolean =>
|
||||||
'glb',
|
RELEVANT_FILE_TYPES.some((ext) => filename.endsWith('.' + ext))
|
||||||
'fbxb',
|
|
||||||
'kcl',
|
|
||||||
]
|
|
||||||
|
|
||||||
const isRelevantFile = (filename: string): boolean =>
|
|
||||||
IMPORT_FILE_EXTENSIONS.some((ext) => filename.endsWith('.' + ext))
|
|
||||||
|
|
||||||
const collectAllFilesRecursiveFrom = async (
|
const collectAllFilesRecursiveFrom = async (
|
||||||
path: string,
|
path: string,
|
||||||
@ -731,3 +727,12 @@ export const writeProjectThumbnailFile = async (
|
|||||||
}
|
}
|
||||||
return window.electron.writeFile(filePath, asArray)
|
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 type { Stats } from 'fs'
|
||||||
import * as fs from 'fs/promises'
|
import * as fs from 'fs/promises'
|
||||||
import * as path from 'path'
|
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 shouldWrapExtension = (extension: string) =>
|
||||||
const FILE_IMPORT_FORMATS = {
|
RELEVANT_FILE_TYPES.includes(extension as RelevantFileType) &&
|
||||||
fbx: 'fbx',
|
extension !== NATIVE_FILE_TYPE
|
||||||
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]
|
|
||||||
|
|
||||||
/// Get the current project file from the path.
|
/// Get the current project file from the path.
|
||||||
/// This is used for double-clicking on a file in the file explorer,
|
/// 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.
|
// 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(
|
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
|
// If we got an import model file, we need to check if we have a file in the project for
|
||||||
// this import model.
|
// 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)
|
const importFileName = path.basename(sourcePath)
|
||||||
// Check if we have a file in the project for this import model.
|
// Check if we have a file in the project for this import model.
|
||||||
const kclWrapperFilename = `${importFileName}.kcl`
|
const kclWrapperFilename = `${importFileName}.kcl`
|
||||||
@ -115,7 +106,8 @@ export default async function getCurrentProjectFile(
|
|||||||
// But we recommend you keep the import statement as it is.
|
// But we recommend you keep the import statement as it is.
|
||||||
// For more information on the import statement, see the documentation at:
|
// For more information on the import statement, see the documentation at:
|
||||||
// https://zoo.dev/docs/kcl/import
|
// https://zoo.dev/docs/kcl/import
|
||||||
const model = import("${importFileName}")`
|
import "${importFileName}" as model
|
||||||
|
model`
|
||||||
await fs.writeFile(kclWrapperFilePath, content)
|
await fs.writeFile(kclWrapperFilePath, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,12 +17,14 @@ import {
|
|||||||
EXECUTION_TYPE_REAL,
|
EXECUTION_TYPE_REAL,
|
||||||
FILE_EXT,
|
FILE_EXT,
|
||||||
} from '@src/lib/constants'
|
} from '@src/lib/constants'
|
||||||
|
import { getPathFilenameInVariableCase } from '@src/lib/desktop'
|
||||||
import { isDesktop } from '@src/lib/isDesktop'
|
import { isDesktop } from '@src/lib/isDesktop'
|
||||||
import { copyFileShareLink } from '@src/lib/links'
|
import { copyFileShareLink } from '@src/lib/links'
|
||||||
import { baseUnitsUnion } from '@src/lib/settings/settingsTypes'
|
import { baseUnitsUnion } from '@src/lib/settings/settingsTypes'
|
||||||
import { codeManager, editorManager, kclManager } from '@src/lib/singletons'
|
import { codeManager, editorManager, kclManager } from '@src/lib/singletons'
|
||||||
import { err, reportRejection } from '@src/lib/trap'
|
import { err, reportRejection } from '@src/lib/trap'
|
||||||
import type { IndexLoaderData } from '@src/lib/types'
|
import type { IndexLoaderData } from '@src/lib/types'
|
||||||
|
import type { CommandBarContext } from '@src/machines/commandBarMachine'
|
||||||
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
|
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
|
||||||
|
|
||||||
interface OnSubmitProps {
|
interface OnSubmitProps {
|
||||||
@ -122,6 +124,14 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
|
|||||||
localName: {
|
localName: {
|
||||||
inputType: 'string',
|
inputType: 'string',
|
||||||
required: true,
|
required: true,
|
||||||
|
defaultValue: (context: CommandBarContext) => {
|
||||||
|
if (!context.argumentsToSubmit['path']) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = context.argumentsToSubmit['path'] as string
|
||||||
|
return getPathFilenameInVariableCase(path)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onSubmit: (data) => {
|
onSubmit: (data) => {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import type { SourceRange } from '@rust/kcl-lib/bindings/SourceRange'
|
import type { SourceRange } from '@rust/kcl-lib/bindings/SourceRange'
|
||||||
import { topLevelRange } from '@src/lang/util'
|
import { topLevelRange } from '@src/lang/util'
|
||||||
import {
|
import {
|
||||||
|
getInVariableCase,
|
||||||
hasDigitsLeftOfDecimal,
|
hasDigitsLeftOfDecimal,
|
||||||
hasLeadingZero,
|
hasLeadingZero,
|
||||||
isClockwise,
|
isClockwise,
|
||||||
@ -1308,3 +1309,24 @@ describe('testing isClockwise', () => {
|
|||||||
expect(isClockwise(counterClockwiseTriangle)).toBe(true)
|
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) {
|
export function getModuleId(sourceRange: SourceRange) {
|
||||||
return sourceRange[2]
|
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