From e78100eaac7d038ec1172141e5485a82911f3395 Mon Sep 17 00:00:00 2001 From: Pierre Jacquier Date: Wed, 9 Apr 2025 07:47:57 -0400 Subject: [PATCH] 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 :no_mouth: * 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 * 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 --- e2e/playwright/point-click-assemblies.spec.ts | 273 +++++++++++++++--- e2e/playwright/test-utils.ts | 4 + .../CommandBar/CommandBarBasicInput.tsx | 27 +- src/components/FileTree.tsx | 33 ++- src/components/ToastInsert.tsx | 40 +++ src/lib/constants.ts | 17 +- src/lib/desktop.test.ts | 37 ++- src/lib/desktop.ts | 25 +- src/lib/getCurrentProjectFile.ts | 48 ++- src/lib/kclCommands.ts | 10 + src/lib/utils.test.ts | 22 ++ src/lib/utils.ts | 22 ++ 12 files changed, 454 insertions(+), 104 deletions(-) create mode 100644 src/components/ToastInsert.tsx diff --git a/e2e/playwright/point-click-assemblies.spec.ts b/e2e/playwright/point-click-assemblies.spec.ts index fab574404..0a61fe9c9 100644 --- a/e2e/playwright/point-click-assemblies.spec.ts +++ b/e2e/playwright/point-click-assemblies.spec.ts @@ -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) }) } ) diff --git a/e2e/playwright/test-utils.ts b/e2e/playwright/test-utils.ts index 3396e38b1..524dda15d 100644 --- a/e2e/playwright/test-utils.ts +++ b/e2e/playwright/test-utils.ts @@ -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, diff --git a/src/components/CommandBar/CommandBarBasicInput.tsx b/src/components/CommandBar/CommandBarBasicInput.tsx index b079af0d2..137653bc1 100644 --- a/src/components/CommandBar/CommandBarBasicInput.tsx +++ b/src/components/CommandBar/CommandBarBasicInput.tsx @@ -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) => + 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(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() diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx index c950c128a..da5bc0ec8 100644 --- a/src/components/FileTree.tsx +++ b/src/components/FileTree.tsx @@ -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) diff --git a/src/components/ToastInsert.tsx b/src/components/ToastInsert.tsx new file mode 100644 index 000000000..ef4f8a778 --- /dev/null +++ b/src/components/ToastInsert.tsx @@ -0,0 +1,40 @@ +import toast from 'react-hot-toast' + +import { ActionButton } from '@src/components/ActionButton' + +export function ToastInsert({ onInsert }: { onInsert: () => void }) { + return ( +
+
+

+ Non-KCL files aren't editable here in Zoo Studio, but you may insert + them using the button below or the Insert command. +

+
+ + Insert into my current file + + { + toast.dismiss() + }} + > + Dismiss + +
+
+
+ ) +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 5762235ba..c63e4f4b6 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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' diff --git a/src/lib/desktop.test.ts b/src/lib/desktop.test.ts index bcb415f9a..93a3443b8 100644 --- a/src/lib/desktop.test.ts +++ b/src/lib/desktop.test.ts @@ -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) diff --git a/src/lib/desktop.ts b/src/lib/desktop.ts index 4c29a4e27..352d61b75 100644 --- a/src/lib/desktop.ts +++ b/src/lib/desktop.ts @@ -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) +} diff --git a/src/lib/getCurrentProjectFile.ts b/src/lib/getCurrentProjectFile.ts index 10235d802..f85c50b7f 100644 --- a/src/lib/getCurrentProjectFile.ts +++ b/src/lib/getCurrentProjectFile.ts @@ -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) } diff --git a/src/lib/kclCommands.ts b/src/lib/kclCommands.ts index f72f0f869..e31745ffc 100644 --- a/src/lib/kclCommands.ts +++ b/src/lib/kclCommands.ts @@ -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) => { diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index cd792403f..cf96a8423 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -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') + }) +}) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 30dec5386..ab510466b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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) +}