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