Merge branch 'main' into pierremtb/issue3528-Add-electron-updater
This commit is contained in:
		
							
								
								
									
										2
									
								
								.github/workflows/cargo-check.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/cargo-check.yml
									
									
									
									
										vendored
									
									
								
							| @ -37,4 +37,4 @@ jobs: | ||||
|           # We specifically want to test the disable-println feature | ||||
|           # Since it is not enabled by default, we need to specify it | ||||
|           # This is used in kcl-lsp | ||||
|           cargo check --all --features disable-println --features pyo3 | ||||
|           cargo check --all --features disable-println --features pyo3 --features cli | ||||
|  | ||||
							
								
								
									
										30
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										30
									
								
								README.md
									
									
									
									
									
								
							| @ -189,12 +189,22 @@ For more information on fuzzing you can check out | ||||
|  | ||||
| ### Playwright tests | ||||
|  | ||||
| You will need a `./e2e/playwright/playwright-secrets.env` file: | ||||
|  | ||||
| ```bash | ||||
| $ touch ./e2e/playwright/playwright-secrets.env | ||||
| $ cat ./e2e/playwright/playwright-secrets.env | ||||
| token=<dev.zoo.dev/account/api-tokens> | ||||
| snapshottoken=<your-snapshot-token> | ||||
| ``` | ||||
|  | ||||
| For a portable way to run Playwright you'll need Docker. | ||||
|  | ||||
| #### Generic example | ||||
| After that, open a terminal and run: | ||||
|  | ||||
| ```bash | ||||
| docker run --network host  --rm --init -it playwright/chrome:playwright-1.43.1 | ||||
| docker run --network host  --rm --init -it playwright/chrome:playwright-x.xx.x | ||||
| ``` | ||||
|  | ||||
| and in another terminal, run: | ||||
| @ -203,21 +213,27 @@ and in another terminal, run: | ||||
| PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:4444/ yarn playwright test --project="Google Chrome" <test suite> | ||||
| ``` | ||||
|  | ||||
| An example of a `<test suite>` is: `e2e/playwright/flow-tests.spec.ts` | ||||
|  | ||||
| YOU WILL NEED A PLAYWRIGHT-SECRETS.ENV FILE: | ||||
| #### Specific example | ||||
|  | ||||
| open a terminal and run: | ||||
|  | ||||
| ```bash | ||||
| # ./e2e/playwright/playwright-secrets.env | ||||
| token=<your-token> | ||||
| snapshottoken=<your-snapshot-token> | ||||
| docker run --network host  --rm --init -it playwright/chrome:playwright-1.46.0 | ||||
| ``` | ||||
|  | ||||
| and in another terminal, run: | ||||
|  | ||||
| ```bash | ||||
| PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:4444/ yarn playwright test --project="Google Chrome" e2e/playwright/command-bar-tests.spec.ts | ||||
| ``` | ||||
| then replace "your-token" with a dev token from dev.zoo.dev/account/api-tokens | ||||
|  | ||||
| run a specific test change the test from `test('...` to `test.only('...` | ||||
| (note if you commit this, the tests will instantly fail without running any of the tests) | ||||
|  | ||||
|  | ||||
| **Gotcha**: running the docker container with a mismatched image against your `./node_modules/playwright` will cause a failure. Make sure the versions are matched and up to date. | ||||
|  | ||||
| run headed | ||||
|  | ||||
| ``` | ||||
|  | ||||
| @ -454,6 +454,7 @@ test( | ||||
|     await electronApp.close() | ||||
|   } | ||||
| ) | ||||
|  | ||||
| test( | ||||
|   'File in the file pane should open with a single click', | ||||
|   { tag: '@electron' }, | ||||
| @ -506,6 +507,69 @@ test( | ||||
|   } | ||||
| ) | ||||
|  | ||||
| test( | ||||
|   'Nested directories in project without main.kcl do not create main.kcl', | ||||
|   { tag: '@electron' }, | ||||
|   async ({ browserName }, testInfo) => { | ||||
|     let testDir: string | undefined | ||||
|     const { electronApp, page } = await setupElectron({ | ||||
|       testInfo, | ||||
|       folderSetupFn: async (dir) => { | ||||
|         await fsp.mkdir(join(dir, 'router-template-slate', 'nested'), { | ||||
|           recursive: true, | ||||
|         }) | ||||
|         await fsp.copyFile( | ||||
|           executorInputPath('router-template-slate.kcl'), | ||||
|           join(dir, 'router-template-slate', 'nested', 'slate.kcl') | ||||
|         ) | ||||
|         await fsp.copyFile( | ||||
|           executorInputPath('focusrite_scarlett_mounting_braket.kcl'), | ||||
|           join(dir, 'router-template-slate', 'nested', 'bracket.kcl') | ||||
|         ) | ||||
|         testDir = dir | ||||
|       }, | ||||
|     }) | ||||
|     const u = await getUtils(page) | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|  | ||||
|     page.on('console', console.log) | ||||
|  | ||||
|     await test.step('Open the project', async () => { | ||||
|       await page.getByText('router-template-slate').click() | ||||
|       await expect(page.getByTestId('loading')).toBeAttached() | ||||
|       await expect(page.getByTestId('loading')).not.toBeAttached({ | ||||
|         timeout: 20_000, | ||||
|       }) | ||||
|  | ||||
|       // It actually loads. | ||||
|       await expect(u.codeLocator).toContainText('mounting bracket') | ||||
|       await expect(u.codeLocator).toContainText('const radius =') | ||||
|     }) | ||||
|  | ||||
|     await u.openFilePanel() | ||||
|  | ||||
|     // Find the current file. | ||||
|     const filesPane = page.locator('#files-pane') | ||||
|     await expect(filesPane.getByText('bracket.kcl')).toBeVisible() | ||||
|     // But there's no main.kcl in the file tree browser. | ||||
|     await expect(filesPane.getByText('main.kcl')).not.toBeVisible() | ||||
|     // No main.kcl file is created on the filesystem. | ||||
|     expect(testDir).toBeDefined() | ||||
|     if (testDir !== undefined) { | ||||
|       // eslint-disable-next-line jest/no-conditional-expect | ||||
|       await expect( | ||||
|         fsp.access(join(testDir, 'router-template-slate', 'main.kcl')) | ||||
|       ).rejects.toThrow() | ||||
|       // eslint-disable-next-line jest/no-conditional-expect | ||||
|       await expect( | ||||
|         fsp.access(join(testDir, 'router-template-slate', 'nested', 'main.kcl')) | ||||
|       ).rejects.toThrow() | ||||
|     } | ||||
|  | ||||
|     await electronApp.close() | ||||
|   } | ||||
| ) | ||||
|  | ||||
| test( | ||||
|   'Deleting projects, can delete individual project, can still create projects after deleting all', | ||||
|   { tag: '@electron' }, | ||||
|  | ||||
| @ -367,4 +367,130 @@ test.describe('Testing settings', () => { | ||||
|       await electronApp.close() | ||||
|     } | ||||
|   ) | ||||
|  | ||||
|   test('Changing modeling default unit', async ({ page }) => { | ||||
|     const u = await getUtils(page) | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|     await u.waitForAuthSkipAppStart() | ||||
|     await page | ||||
|       .getByRole('button', { name: 'Start Sketch' }) | ||||
|       .waitFor({ state: 'visible' }) | ||||
|  | ||||
|     const userSettingsTab = page.getByRole('radio', { name: 'User' }) | ||||
|  | ||||
|     // Open the settings modal with lower-right button | ||||
|     await page.getByRole('link', { name: 'Settings' }).last().click() | ||||
|     await expect( | ||||
|       page.getByRole('heading', { name: 'Settings', exact: true }) | ||||
|     ).toBeVisible() | ||||
|  | ||||
|     const resetButton = page.getByRole('button', { | ||||
|       name: 'Restore default settings', | ||||
|     }) | ||||
|     // Default unit should be mm | ||||
|     await resetButton.click() | ||||
|  | ||||
|     await test.step('Change modeling default unit within project tab', async () => { | ||||
|       const changeUnitOfMeasureInProjectTab = async (unitOfMeasure: string) => { | ||||
|         await test.step(`Set modeling default unit to ${unitOfMeasure}`, async () => { | ||||
|           await page | ||||
|             .getByTestId('modeling-defaultUnit') | ||||
|             .selectOption(`${unitOfMeasure}`) | ||||
|           const toastMessage = page.getByText( | ||||
|             `Set default unit to "${unitOfMeasure}" for this project` | ||||
|           ) | ||||
|           await expect(toastMessage).toBeVisible() | ||||
|         }) | ||||
|       } | ||||
|       await changeUnitOfMeasureInProjectTab('in') | ||||
|       await changeUnitOfMeasureInProjectTab('ft') | ||||
|       await changeUnitOfMeasureInProjectTab('yd') | ||||
|       await changeUnitOfMeasureInProjectTab('mm') | ||||
|       await changeUnitOfMeasureInProjectTab('cm') | ||||
|       await changeUnitOfMeasureInProjectTab('m') | ||||
|     }) | ||||
|  | ||||
|     // Go to the user tab | ||||
|     await userSettingsTab.click() | ||||
|     await test.step('Change modeling default unit within user tab', async () => { | ||||
|       const changeUnitOfMeasureInUserTab = async (unitOfMeasure: string) => { | ||||
|         await test.step(`Set modeling default unit to ${unitOfMeasure}`, async () => { | ||||
|           await page | ||||
|             .getByTestId('modeling-defaultUnit') | ||||
|             .selectOption(`${unitOfMeasure}`) | ||||
|           const toastMessage = page.getByText( | ||||
|             `Set default unit to "${unitOfMeasure}" as a user default` | ||||
|           ) | ||||
|           await expect(toastMessage).toBeVisible() | ||||
|         }) | ||||
|       } | ||||
|       await changeUnitOfMeasureInUserTab('in') | ||||
|       await changeUnitOfMeasureInUserTab('ft') | ||||
|       await changeUnitOfMeasureInUserTab('yd') | ||||
|       await changeUnitOfMeasureInUserTab('mm') | ||||
|       await changeUnitOfMeasureInUserTab('cm') | ||||
|       await changeUnitOfMeasureInUserTab('m') | ||||
|     }) | ||||
|  | ||||
|     // Close settings | ||||
|     const settingsCloseButton = page.getByTestId('settings-close-button') | ||||
|     await settingsCloseButton.click() | ||||
|  | ||||
|     await test.step('Change modeling default unit within command bar', async () => { | ||||
|       const commands = page.getByRole('button', { name: 'Commands' }) | ||||
|       const changeUnitOfMeasureInCommandBar = async (unitOfMeasure: string) => { | ||||
|         // Open command bar | ||||
|         await commands.click() | ||||
|         const settingsModelingDefaultUnitCommand = page.getByText( | ||||
|           'Settings · modeling · default unit' | ||||
|         ) | ||||
|         await settingsModelingDefaultUnitCommand.click() | ||||
|  | ||||
|         const commandOption = page.getByRole('option', { | ||||
|           name: unitOfMeasure, | ||||
|           exact: true, | ||||
|         }) | ||||
|         await commandOption.click() | ||||
|  | ||||
|         const toastMessage = page.getByText( | ||||
|           `Set default unit to "${unitOfMeasure}" for this project` | ||||
|         ) | ||||
|         await expect(toastMessage).toBeVisible() | ||||
|       } | ||||
|       await changeUnitOfMeasureInCommandBar('in') | ||||
|       await changeUnitOfMeasureInCommandBar('ft') | ||||
|       await changeUnitOfMeasureInCommandBar('yd') | ||||
|       await changeUnitOfMeasureInCommandBar('mm') | ||||
|       await changeUnitOfMeasureInCommandBar('cm') | ||||
|       await changeUnitOfMeasureInCommandBar('m') | ||||
|     }) | ||||
|  | ||||
|     await test.step('Change modeling default unit within gizmo', async () => { | ||||
|       const changeUnitOfMeasureInGizmo = async ( | ||||
|         unitOfMeasure: string, | ||||
|         copy: string | ||||
|       ) => { | ||||
|         const gizmo = page.getByRole('button', { | ||||
|           name: 'Current units are: ', | ||||
|         }) | ||||
|         await gizmo.click() | ||||
|         const button = page.getByRole('button', { | ||||
|           name: copy, | ||||
|           exact: true, | ||||
|         }) | ||||
|         await button.click() | ||||
|         const toastMessage = page.getByText( | ||||
|           `Set default unit to "${unitOfMeasure}" for this project` | ||||
|         ) | ||||
|         await expect(toastMessage).toBeVisible() | ||||
|       } | ||||
|  | ||||
|       await changeUnitOfMeasureInGizmo('in', 'Inches') | ||||
|       await changeUnitOfMeasureInGizmo('ft', 'Feet') | ||||
|       await changeUnitOfMeasureInGizmo('yd', 'Yards') | ||||
|       await changeUnitOfMeasureInGizmo('mm', 'Millimeters') | ||||
|       await changeUnitOfMeasureInGizmo('cm', 'Centimeters') | ||||
|       await changeUnitOfMeasureInGizmo('m', 'Meters') | ||||
|     }) | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| @ -1,5 +1,11 @@ | ||||
| import { test, expect, Page } from '@playwright/test' | ||||
| import { getUtils, setup, tearDown } from './test-utils' | ||||
| import { | ||||
|   getUtils, | ||||
|   setup, | ||||
|   tearDown, | ||||
|   setupElectron, | ||||
|   createProjectAndRenameIt, | ||||
| } from './test-utils' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| @ -683,3 +689,60 @@ async function sendPromptFromCommandBar(page: Page, promptStr: string) { | ||||
|     await page.keyboard.press('Enter') | ||||
|   }) | ||||
| } | ||||
|  | ||||
| test( | ||||
|   'Text-to-CAD functionality', | ||||
|   { tag: '@electron' }, | ||||
|   async ({ browserName }, testInfo) => { | ||||
|     const { electronApp, page } = await setupElectron({ | ||||
|       testInfo, | ||||
|       folderSetupFn: async () => {}, | ||||
|     }) | ||||
|  | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|  | ||||
|     // Create and navigate to the project | ||||
|     await createProjectAndRenameIt({ name: 'test-000', page }) | ||||
|     await page.getByTestId('project-link').click() | ||||
|  | ||||
|     // Wait for Start Sketch otherwise you will not have access Text-to-CAD command | ||||
|     await expect( | ||||
|       page.getByRole('button', { name: 'Start Sketch' }) | ||||
|     ).toBeEnabled({ | ||||
|       timeout: 20_000, | ||||
|     }) | ||||
|  | ||||
|     // Open the files pane | ||||
|     const filesPaneButton = page.getByTestId('files-pane-button') | ||||
|     await filesPaneButton.click() | ||||
|  | ||||
|     await test.step(`Test file creation`, async () => { | ||||
|       await sendPromptFromCommandBar(page, 'lego 2x4') | ||||
|       // File is considered created if it shows up in the Project Files pane | ||||
|       const file = page.getByRole('button', { name: 'lego-2x4.kcl' }) | ||||
|       await expect(file).toBeVisible({ timeout: 20_000 }) | ||||
|     }) | ||||
|  | ||||
|     await test.step(`Test file navigation`, async () => { | ||||
|       const file = page.getByRole('button', { name: 'lego-2x4.kcl' }) | ||||
|       await file.click() | ||||
|       const kclComment = page.getByText('Lego 2x4 Brick') | ||||
|       // File can be navigated and loaded assuming a specific KCL comment is loaded into the KCL code pane | ||||
|       await expect(kclComment).toBeVisible({ timeout: 20_000 }) | ||||
|     }) | ||||
|  | ||||
|     await test.step(`Test file deletion on rejection`, async () => { | ||||
|       const rejectButton = page.getByRole('button', { name: 'Reject' }) | ||||
|       // A file is created and can be navigated to while this prompt is still opened | ||||
|       // Click the "Reject" button within the prompt and it will delete the file. | ||||
|       await rejectButton.click() | ||||
|  | ||||
|       const submittingToastMessage = page.getByText( | ||||
|         `Successfully deleted file "lego-2x4.kcl"` | ||||
|       ) | ||||
|       await expect(submittingToastMessage).toBeVisible() | ||||
|     }) | ||||
|  | ||||
|     await electronApp.close() | ||||
|   } | ||||
| ) | ||||
|  | ||||
							
								
								
									
										1
									
								
								interface.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								interface.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -31,6 +31,7 @@ export interface IElectronAPI { | ||||
|   sep: typeof path.sep | ||||
|   rename: (prev: string, next: string) => typeof fs.rename | ||||
|   setBaseUrl: (value: string) => void | ||||
|   loadProjectAtStartup: () => Promise<ProjectState | null> | ||||
|   packageJson: { | ||||
|     name: string | ||||
|   } | ||||
|  | ||||
| @ -44,6 +44,7 @@ | ||||
|     "isomorphic-fetch": "^3.0.0", | ||||
|     "json-rpc-2.0": "^1.6.0", | ||||
|     "jszip": "^3.10.1", | ||||
|     "minimist": "^1.2.8", | ||||
|     "openid-client": "^5.6.5", | ||||
|     "re-resizable": "^6.9.11", | ||||
|     "react": "^18.3.1", | ||||
| @ -90,7 +91,7 @@ | ||||
|     "wasm-prep": "rm -rf src/wasm-lib/pkg && mkdir src/wasm-lib/pkg && rm -rf src/wasm-lib/kcl/bindings", | ||||
|     "lint": "eslint --fix src e2e", | ||||
|     "bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json && echo \"$(jq --arg v \"$VERSION\" '.version=$v' src-tauri/tauri.conf.json --indent 2)\" > src-tauri/tauri.conf.json", | ||||
|     "postinstall": "yarn xstate:typegen", | ||||
|     "postinstall": "yarn xstate:typegen && ./node_modules/.bin/electron-rebuild", | ||||
|     "xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"", | ||||
|     "make:dev": "make dev", | ||||
|     "generate:machine-api": "npx openapi-typescript ./openapi/machine-api.json -o src/lib/machine-api.d.ts", | ||||
| @ -133,6 +134,7 @@ | ||||
|     "@electron-forge/plugin-vite": "^7.4.0", | ||||
|     "@electron-forge/publisher-gcs": "^7.4.0", | ||||
|     "@electron/fuses": "^1.8.0", | ||||
|     "@electron/rebuild": "^3.6.0", | ||||
|     "@iarna/toml": "^2.2.5", | ||||
|     "@lezer/generator": "^1.7.1", | ||||
|     "@playwright/test": "^1.46.1", | ||||
| @ -141,6 +143,7 @@ | ||||
|     "@types/d3-force": "^3.0.10", | ||||
|     "@types/electron": "^1.6.10", | ||||
|     "@types/isomorphic-fetch": "^0.0.39", | ||||
|     "@types/minimist": "^1.2.5", | ||||
|     "@types/mocha": "^10.0.6", | ||||
|     "@types/node": "^22.5.0", | ||||
|     "@types/pixelmatch": "^5.2.6", | ||||
|  | ||||
| @ -33,7 +33,6 @@ import SettingsAuthProvider from 'components/SettingsAuthProvider' | ||||
| import LspProvider from 'components/LspProvider' | ||||
| import { KclContextProvider } from 'lang/KclProvider' | ||||
| import { BROWSER_PROJECT_NAME } from 'lib/constants' | ||||
| import { getState, setState } from 'lib/desktop' | ||||
| import { CoreDumpManager } from 'lib/coredump' | ||||
| import { codeManager, engineCommandManager } from 'lib/singletons' | ||||
| import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' | ||||
| @ -71,17 +70,13 @@ const router = createRouter([ | ||||
|         loader: async () => { | ||||
|           const onDesktop = isDesktop() | ||||
|           if (onDesktop) { | ||||
|             const appState = await getState() | ||||
|  | ||||
|             if (appState) { | ||||
|               // Reset the state. | ||||
|               // We do this so that we load the initial state from the cli but everything | ||||
|               // else we can ignore. | ||||
|               await setState(undefined) | ||||
|             const projectStartupFile = | ||||
|               await window.electron.loadProjectAtStartup() | ||||
|             if (projectStartupFile !== null) { | ||||
|               // Redirect to the file if we have a file path. | ||||
|               if (appState.current_file) { | ||||
|               if (projectStartupFile.length > 0) { | ||||
|                 return redirect( | ||||
|                   PATHS.FILE + '/' + encodeURIComponent(appState.current_file) | ||||
|                   PATHS.FILE + '/' + encodeURIComponent(projectStartupFile) | ||||
|                 ) | ||||
|               } | ||||
|             } | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| import type { FileEntry, IndexLoaderData } from 'lib/types' | ||||
| import type { IndexLoaderData } from 'lib/types' | ||||
| import { PATHS } from 'lib/paths' | ||||
| import { ActionButton } from './ActionButton' | ||||
| import Tooltip from './Tooltip' | ||||
| @ -20,6 +20,7 @@ import { useModelingContext } from 'hooks/useModelingContext' | ||||
| import { DeleteConfirmationDialog } from './ProjectCard/DeleteProjectDialog' | ||||
| import { ContextMenu, ContextMenuItem } from './ContextMenu' | ||||
| import usePlatform from 'hooks/usePlatform' | ||||
| import { FileEntry } from 'lib/project' | ||||
|  | ||||
| function getIndentationCSS(level: number) { | ||||
|   return `calc(1rem * ${level + 1})` | ||||
|  | ||||
| @ -15,7 +15,7 @@ import { Extension } from '@codemirror/state' | ||||
| import { LanguageSupport } from '@codemirror/language' | ||||
| import { useNavigate } from 'react-router-dom' | ||||
| import { PATHS } from 'lib/paths' | ||||
| import { FileEntry } from 'lib/types' | ||||
| import { FileEntry } from 'lib/project' | ||||
| import Worker from 'editor/plugins/lsp/worker.ts?worker' | ||||
| import { | ||||
|   KclWorkerOptions, | ||||
|  | ||||
| @ -7,7 +7,7 @@ import { useHotkeys } from 'react-hotkeys-hook' | ||||
| import Tooltip from '../Tooltip' | ||||
| import { DeleteConfirmationDialog } from './DeleteProjectDialog' | ||||
| import { ProjectCardRenameForm } from './ProjectCardRenameForm' | ||||
| import { Project } from 'wasm-lib/kcl/bindings/Project' | ||||
| import { Project } from 'lib/project' | ||||
|  | ||||
| function ProjectCard({ | ||||
|   project, | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| import { ActionButton } from 'components/ActionButton' | ||||
| import Tooltip from 'components/Tooltip' | ||||
| import { HTMLProps, forwardRef } from 'react' | ||||
| import { Project } from 'wasm-lib/kcl/bindings/Project' | ||||
| import { Project } from 'lib/project' | ||||
|  | ||||
| interface ProjectCardRenameFormProps extends HTMLProps<HTMLFormElement> { | ||||
|   project: Project | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| import { Project } from 'wasm-lib/kcl/bindings/Project' | ||||
| import { Project } from 'lib/project' | ||||
| import { CustomIcon } from './CustomIcon' | ||||
| import { useEffect, useRef, useState } from 'react' | ||||
| import { useHotkeys } from 'react-hotkeys-hook' | ||||
|  | ||||
| @ -3,7 +3,7 @@ import { BrowserRouter } from 'react-router-dom' | ||||
| import ProjectSidebarMenu from './ProjectSidebarMenu' | ||||
| import { SettingsAuthProviderJest } from './SettingsAuthProvider' | ||||
| import { CommandBarProvider } from './CommandBar/CommandBarProvider' | ||||
| import { Project } from 'wasm-lib/kcl/bindings/Project' | ||||
| import { Project } from 'lib/project' | ||||
|  | ||||
| const now = new Date() | ||||
| const projectWellFormed = { | ||||
|  | ||||
| @ -1,8 +1,6 @@ | ||||
| import { err } from 'lib/trap' | ||||
| import { Models } from '@kittycad/lib' | ||||
| import { Project } from 'wasm-lib/kcl/bindings/Project' | ||||
| import { ProjectState } from 'wasm-lib/kcl/bindings/ProjectState' | ||||
| import { FileEntry } from 'wasm-lib/kcl/bindings/FileEntry' | ||||
| import { Project, FileEntry } from 'lib/project' | ||||
|  | ||||
| import { | ||||
|   defaultAppSettings, | ||||
| @ -477,18 +475,6 @@ export const writeAppSettingsFile = async (tomlStr: string) => { | ||||
|   return window.electron.writeFile(appSettingsFilePath, tomlStr) | ||||
| } | ||||
|  | ||||
| let appStateStore: ProjectState | undefined = undefined | ||||
|  | ||||
| export const getState = async (): Promise<ProjectState | undefined> => { | ||||
|   return Promise.resolve(appStateStore) | ||||
| } | ||||
|  | ||||
| export const setState = async ( | ||||
|   state: ProjectState | undefined | ||||
| ): Promise<void> => { | ||||
|   appStateStore = state | ||||
| } | ||||
|  | ||||
| export const getUser = async ( | ||||
|   token: string, | ||||
|   hostname: string | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import { isDesktop } from './isDesktop' | ||||
| import type { FileEntry } from 'lib/types' | ||||
| import type { FileEntry } from 'lib/project' | ||||
| import { | ||||
|   FILE_EXT, | ||||
|   INDEX_IDENTIFIER, | ||||
|  | ||||
							
								
								
									
										98
									
								
								src/lib/getCurrentProjectFile.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								src/lib/getCurrentProjectFile.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,98 @@ | ||||
| import { promises as fs } from 'fs' | ||||
| import path from 'path' | ||||
| import os from 'os' | ||||
| import { v4 as uuidv4 } from 'uuid' | ||||
| import getCurrentProjectFile from './getCurrentProjectFile' | ||||
|  | ||||
| describe('getCurrentProjectFile', () => { | ||||
|   test('with explicit open file with space (URL encoded)', async () => { | ||||
|     const name = `kittycad-modeling-projects-${uuidv4()}` | ||||
|     const tmpProjectDir = path.join(os.tmpdir(), name) | ||||
|  | ||||
|     await fs.mkdir(tmpProjectDir, { recursive: true }) | ||||
|     await fs.writeFile(path.join(tmpProjectDir, 'i have a space.kcl'), '') | ||||
|  | ||||
|     const state = await getCurrentProjectFile( | ||||
|       path.join(tmpProjectDir, 'i%20have%20a%20space.kcl') | ||||
|     ) | ||||
|  | ||||
|     expect(state).toBe(path.join(tmpProjectDir, 'i have a space.kcl')) | ||||
|  | ||||
|     await fs.rm(tmpProjectDir, { recursive: true, force: true }) | ||||
|   }) | ||||
|  | ||||
|   test('with explicit open file with space', async () => { | ||||
|     const name = `kittycad-modeling-projects-${uuidv4()}` | ||||
|     const tmpProjectDir = path.join(os.tmpdir(), name) | ||||
|  | ||||
|     await fs.mkdir(tmpProjectDir, { recursive: true }) | ||||
|     await fs.writeFile(path.join(tmpProjectDir, 'i have a space.kcl'), '') | ||||
|  | ||||
|     const state = await getCurrentProjectFile( | ||||
|       path.join(tmpProjectDir, 'i have a space.kcl') | ||||
|     ) | ||||
|  | ||||
|     expect(state).toBe(path.join(tmpProjectDir, 'i have a space.kcl')) | ||||
|  | ||||
|     await fs.rm(tmpProjectDir, { recursive: true, force: true }) | ||||
|   }) | ||||
|  | ||||
|   test('with source path dot', async () => { | ||||
|     const name = `kittycad-modeling-projects-${uuidv4()}` | ||||
|     const tmpProjectDir = path.join(os.tmpdir(), name) | ||||
|     await fs.mkdir(tmpProjectDir, { recursive: true }) | ||||
|  | ||||
|     // Set the current directory to the temp project directory. | ||||
|     const originalCwd = process.cwd() | ||||
|     process.chdir(tmpProjectDir) | ||||
|  | ||||
|     try { | ||||
|       const state = await getCurrentProjectFile('.') | ||||
|  | ||||
|       if (state instanceof Error) { | ||||
|         throw state | ||||
|       } | ||||
|  | ||||
|       expect(state.replace('/private', '')).toBe( | ||||
|         path.join(tmpProjectDir, 'main.kcl') | ||||
|       ) | ||||
|     } finally { | ||||
|       process.chdir(originalCwd) | ||||
|       await fs.rm(tmpProjectDir, { recursive: true, force: true }) | ||||
|     } | ||||
|   }) | ||||
|  | ||||
|   test('with main.kcl not existing', async () => { | ||||
|     const name = `kittycad-modeling-projects-${uuidv4()}` | ||||
|     const tmpProjectDir = path.join(os.tmpdir(), name) | ||||
|     await fs.mkdir(tmpProjectDir, { recursive: true }) | ||||
|  | ||||
|     try { | ||||
|       const state = await getCurrentProjectFile(tmpProjectDir) | ||||
|  | ||||
|       expect(state).toBe(path.join(tmpProjectDir, 'main.kcl')) | ||||
|     } finally { | ||||
|       await fs.rm(tmpProjectDir, { recursive: true, force: true }) | ||||
|     } | ||||
|   }) | ||||
|  | ||||
|   test('with directory, main.kcl not existing, other.kcl does', async () => { | ||||
|     const name = `kittycad-modeling-projects-${uuidv4()}` | ||||
|     const tmpProjectDir = path.join(os.tmpdir(), name) | ||||
|     await fs.mkdir(tmpProjectDir, { recursive: true }) | ||||
|     await fs.writeFile(path.join(tmpProjectDir, 'other.kcl'), '') | ||||
|  | ||||
|     try { | ||||
|       const state = await getCurrentProjectFile(tmpProjectDir) | ||||
|  | ||||
|       expect(state).toBe(path.join(tmpProjectDir, 'other.kcl')) | ||||
|  | ||||
|       // make sure we didn't create a main.kcl file | ||||
|       await expect( | ||||
|         fs.access(path.join(tmpProjectDir, 'main.kcl')) | ||||
|       ).rejects.toThrow() | ||||
|     } finally { | ||||
|       await fs.rm(tmpProjectDir, { recursive: true, force: true }) | ||||
|     } | ||||
|   }) | ||||
| }) | ||||
							
								
								
									
										116
									
								
								src/lib/getCurrentProjectFile.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								src/lib/getCurrentProjectFile.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,116 @@ | ||||
| import * as path from 'path' | ||||
| import * as fs from 'fs/promises' | ||||
| import { Models } from '@kittycad/lib/dist/types/src' | ||||
| import { PROJECT_ENTRYPOINT } from './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] | ||||
|  | ||||
| /// Get the current project file from the path. | ||||
| /// This is used for double-clicking on a file in the file explorer, | ||||
| /// or the command line args, or deep linking. | ||||
| export default async function getCurrentProjectFile( | ||||
|   pathString: string | ||||
| ): Promise<string | Error> { | ||||
|   // Fix for "." path, which is the current directory. | ||||
|   let sourcePath = pathString === '.' ? process.cwd() : pathString | ||||
|  | ||||
|   // URL decode the path. | ||||
|   sourcePath = decodeURIComponent(sourcePath) | ||||
|  | ||||
|   // If the path does not start with a slash, it is a relative path. | ||||
|   // We need to convert it to an absolute path. | ||||
|   sourcePath = path.isAbsolute(sourcePath) | ||||
|     ? sourcePath | ||||
|     : path.join(process.cwd(), sourcePath) | ||||
|  | ||||
|   // If the path is a directory, let's assume it is a project directory. | ||||
|   const stats = await fs.stat(sourcePath) | ||||
|   if (stats.isDirectory()) { | ||||
|     // Walk the directory and look for a kcl file. | ||||
|     const files = await fs.readdir(sourcePath) | ||||
|     const kclFiles = files.filter((file) => path.extname(file) === '.kcl') | ||||
|  | ||||
|     if (kclFiles.length === 0) { | ||||
|       let projectFile = path.join(sourcePath, PROJECT_ENTRYPOINT) | ||||
|       // Check if we have a main.kcl file in the project. | ||||
|       try { | ||||
|         await fs.access(projectFile) | ||||
|       } catch { | ||||
|         // Create the default file in the project. | ||||
|         await fs.writeFile(projectFile, '') | ||||
|       } | ||||
|  | ||||
|       return projectFile | ||||
|     } | ||||
|  | ||||
|     // If a project entrypoint file exists, use it. | ||||
|     // Otherwise, use the first kcl file in the project. | ||||
|     const gotMain = files.filter((file) => file === PROJECT_ENTRYPOINT) | ||||
|     if (gotMain.length === 0) { | ||||
|       return path.join(sourcePath, kclFiles[0]) | ||||
|     } | ||||
|     return path.join(sourcePath, PROJECT_ENTRYPOINT) | ||||
|   } | ||||
|  | ||||
|   // Check if the extension on what we are trying to open is a relevant file type. | ||||
|   const extension = path.extname(sourcePath).slice(1) | ||||
|  | ||||
|   if (!relevantExtensions.includes(extension) && 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( | ||||
|         ', ' | ||||
|       )}` | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   // We were given a file path, not a directory. | ||||
|   // Let's get the parent directory of the file. | ||||
|   const parent = path.dirname(sourcePath) | ||||
|  | ||||
|   // 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)) { | ||||
|     const importFileName = path.basename(sourcePath) | ||||
|     // Check if we have a file in the project for this import model. | ||||
|     const kclWrapperFilename = `${importFileName}.kcl` | ||||
|     const kclWrapperFilePath = path.join(parent, kclWrapperFilename) | ||||
|  | ||||
|     try { | ||||
|       await fs.access(kclWrapperFilePath) | ||||
|     } catch { | ||||
|       // Create the file in the project with the default import content. | ||||
|       const content = `// This file was automatically generated by the application when you | ||||
| // double-clicked on the model file. | ||||
| // You can edit this file to add your own content. | ||||
| // 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}")` | ||||
|       await fs.writeFile(kclWrapperFilePath, content) | ||||
|     } | ||||
|  | ||||
|     return kclWrapperFilePath | ||||
|   } | ||||
|  | ||||
|   return sourcePath | ||||
| } | ||||
							
								
								
									
										46
									
								
								src/lib/project.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/lib/project.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | ||||
| /** | ||||
|  * The permissions of a file. | ||||
|  */ | ||||
| export type FilePermission = 'read' | 'write' | 'execute' | ||||
|  | ||||
| /** | ||||
|  * The type of a file. | ||||
|  */ | ||||
| export type FileType = 'file' | 'directory' | 'symlink' | ||||
|  | ||||
| /** | ||||
|  * Metadata about a file or directory. | ||||
|  */ | ||||
| export type FileMetadata = { | ||||
|   accessed: string | null | ||||
|   created: string | null | ||||
|   type: FileType | null | ||||
|   size: number | ||||
|   modified: string | null | ||||
|   permission: FilePermission | null | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Information about a file or directory. | ||||
|  */ | ||||
| export type FileEntry = { | ||||
|   path: string | ||||
|   name: string | ||||
|   children: Array<FileEntry> | null | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Information about project. | ||||
|  */ | ||||
| export type Project = { | ||||
|   metadata: FileMetadata | null | ||||
|   kcl_file_count: number | ||||
|   directory_count: number | ||||
|   /** | ||||
|    * The default file to open on load. | ||||
|    */ | ||||
|   default_file: string | ||||
|   path: string | ||||
|   name: string | ||||
|   children: Array<FileEntry> | null | ||||
| } | ||||
| @ -1,5 +1,5 @@ | ||||
| import { CustomIconName } from 'components/CustomIcon' | ||||
| import { Project } from 'wasm-lib/kcl/bindings/Project' | ||||
| import { Project } from 'lib/project' | ||||
|  | ||||
| const DESC = ':desc' | ||||
|  | ||||
|  | ||||
| @ -1,7 +1,4 @@ | ||||
| import { FileEntry } from 'wasm-lib/kcl/bindings/FileEntry' | ||||
| import { Project } from 'wasm-lib/kcl/bindings/Project' | ||||
|  | ||||
| export type { FileEntry } from 'wasm-lib/kcl/bindings/FileEntry' | ||||
| import { Project, FileEntry } from 'lib/project' | ||||
|  | ||||
| export type IndexLoaderData = { | ||||
|   code: string | null | ||||
|  | ||||
| @ -1,6 +1,5 @@ | ||||
| import { assign, createMachine } from 'xstate' | ||||
| import type { FileEntry } from 'lib/types' | ||||
| import { Project } from 'wasm-lib/kcl/bindings/Project' | ||||
| import { Project, FileEntry } from 'lib/project' | ||||
|  | ||||
| export const fileMachine = createMachine( | ||||
|   { | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { assign, createMachine } from 'xstate' | ||||
| import { HomeCommandSchema } from 'lib/commandBarConfigs/homeCommandConfig' | ||||
| import { Project } from 'wasm-lib/kcl/bindings/Project' | ||||
| import { Project } from 'lib/project' | ||||
|  | ||||
| export const homeMachine = createMachine( | ||||
|   { | ||||
|  | ||||
							
								
								
									
										108
									
								
								src/main.ts
									
									
									
									
									
								
							
							
						
						
									
										108
									
								
								src/main.ts
									
									
									
									
									
								
							| @ -9,6 +9,11 @@ import { Bonjour, Service } from 'bonjour-service' | ||||
| // @ts-ignore: TS1343 | ||||
| import * as kittycad from '@kittycad/lib/import' | ||||
| import { updateElectronApp, UpdateSourceType } from 'update-electron-app' | ||||
| import minimist from 'minimist' | ||||
| import getCurrentProjectFile from 'lib/getCurrentProjectFile' | ||||
|  | ||||
| // Check the command line arguments for a project path | ||||
| const args = parseCLIArgs() | ||||
|  | ||||
| // If it's not set, scream. | ||||
| const NODE_ENV = process.env.NODE_ENV || 'production' | ||||
| @ -23,6 +28,10 @@ if (require('electron-squirrel-startup')) { | ||||
|   app.quit() | ||||
| } | ||||
|  | ||||
| // Global app listeners | ||||
| // Must be done before ready event | ||||
| registerListeners() | ||||
|  | ||||
| const createWindow = () => { | ||||
|   const mainWindow = new BrowserWindow({ | ||||
|     autoHideMenuBar: true, | ||||
| @ -171,3 +180,102 @@ app.on('ready', () => { | ||||
|     }, | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| ipcMain.handle('loadProjectAtStartup', async () => { | ||||
|   // If we are in development mode, we don't want to load a project at | ||||
|   // startup. | ||||
|   // Since the args passed are always '.' | ||||
|   if (NODE_ENV !== 'production') { | ||||
|     return null | ||||
|   } | ||||
|  | ||||
|   let projectPath: string | null = null | ||||
|   // macOS: open-file events that were received before the app is ready | ||||
|   const macOpenFiles: string[] = (global as any).macOpenFiles | ||||
|   if (macOpenFiles && macOpenFiles && macOpenFiles.length > 0) { | ||||
|     projectPath = macOpenFiles[0] // We only do one project at a time | ||||
|   } | ||||
|   // Reset this so we don't accidentally use it again. | ||||
|   const macOpenFilesEmpty: string[] = [] | ||||
|   // @ts-ignore | ||||
|   global['macOpenFiles'] = macOpenFilesEmpty | ||||
|  | ||||
|   // macOS: open-url events that were received before the app is ready | ||||
|   const getOpenUrls: string[] = ((global as any).getOpenUrls() || | ||||
|     []) as string[] | ||||
|   if (getOpenUrls && getOpenUrls.length > 0) { | ||||
|     projectPath = getOpenUrls[0] // We only do one project at a | ||||
|   } | ||||
|   // Reset this so we don't accidentally use it again. | ||||
|   // @ts-ignore | ||||
|   global['getOpenUrls'] = function () { | ||||
|     return [] | ||||
|   } | ||||
|  | ||||
|   // Check if we have a project path in the command line arguments | ||||
|   // If we do, we will load the project at that path | ||||
|   if (args._.length > 1) { | ||||
|     if (args._[1].length > 0) { | ||||
|       projectPath = args._[1] | ||||
|       // Reset all this value so we don't accidentally use it again. | ||||
|       args._[1] = '' | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (projectPath) { | ||||
|     // We have a project path, load the project information. | ||||
|     console.log(`Loading project at startup: ${projectPath}`) | ||||
|     try { | ||||
|       const currentFile = await getCurrentProjectFile(projectPath) | ||||
|       console.log(`Project loaded: ${currentFile}`) | ||||
|       return currentFile | ||||
|     } catch (e) { | ||||
|       console.error(e) | ||||
|     } | ||||
|  | ||||
|     return null | ||||
|   } | ||||
|  | ||||
|   return null | ||||
| }) | ||||
|  | ||||
| function parseCLIArgs(): minimist.ParsedArgs { | ||||
|   return minimist(process.argv, {}) | ||||
| } | ||||
|  | ||||
| function registerListeners() { | ||||
|   /** | ||||
|    * macOS: when someone drops a file to the not-yet running VSCode, the open-file event fires even before | ||||
|    * the app-ready event. We listen very early for open-file and remember this upon startup as path to open. | ||||
|    */ | ||||
|   const macOpenFiles: string[] = [] | ||||
|   // @ts-ignore | ||||
|   global['macOpenFiles'] = macOpenFiles | ||||
|   app.on('open-file', function (event, path) { | ||||
|     macOpenFiles.push(path) | ||||
|   }) | ||||
|  | ||||
|   /** | ||||
|    * macOS: react to open-url requests. | ||||
|    */ | ||||
|   const openUrls: string[] = [] | ||||
|   const onOpenUrl = function ( | ||||
|     event: { preventDefault: () => void }, | ||||
|     url: string | ||||
|   ) { | ||||
|     event.preventDefault() | ||||
|  | ||||
|     openUrls.push(url) | ||||
|   } | ||||
|  | ||||
|   app.on('will-finish-launching', function () { | ||||
|     app.on('open-url', onOpenUrl) | ||||
|   }) | ||||
|  | ||||
|   // @ts-ignore | ||||
|   global['getOpenUrls'] = function () { | ||||
|     app.removeListener('open-url', onOpenUrl) | ||||
|  | ||||
|     return openUrls | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -60,6 +60,9 @@ const listMachines = async (): Promise<MachinesListing> => { | ||||
| const getMachineApiIp = async (): Promise<String | null> => | ||||
|   ipcRenderer.invoke('find_machine_api') | ||||
|  | ||||
| const loadProjectAtStartup = async (): Promise<string | null> => | ||||
|   ipcRenderer.invoke('loadProjectAtStartup') | ||||
|  | ||||
| contextBridge.exposeInMainWorld('electron', { | ||||
|   login, | ||||
|   // Passing fs directly is not recommended since it gives a lot of power | ||||
| @ -93,6 +96,7 @@ contextBridge.exposeInMainWorld('electron', { | ||||
|     isWindows, | ||||
|     isLinux, | ||||
|   }, | ||||
|   loadProjectAtStartup, | ||||
|   // IMPORTANT NOTE: kittycad.ts reads process.env.BASE_URL. But there is | ||||
|   // no way to set it across the bridge boundary. We need to make it a command. | ||||
|   setBaseUrl: (value: string) => (process.env.BASE_URL = value), | ||||
|  | ||||
| @ -31,13 +31,13 @@ import { kclManager } from 'lib/singletons' | ||||
| import { useLspContext } from 'components/LspProvider' | ||||
| import { useRefreshSettings } from 'hooks/useRefreshSettings' | ||||
| import { LowerRightControls } from 'components/LowerRightControls' | ||||
| import { Project } from 'wasm-lib/kcl/bindings/Project' | ||||
| import { | ||||
|   createNewProjectDirectory, | ||||
|   listProjects, | ||||
|   renameProjectDirectory, | ||||
| } from 'lib/desktop' | ||||
| import { ProjectSearchBar, useProjectSearch } from 'components/ProjectSearchBar' | ||||
| import { Project } from 'lib/project' | ||||
|  | ||||
| // This route only opens in the desktop context for now, | ||||
| // as defined in Router.tsx, so we can use the desktop APIs and types. | ||||
|  | ||||
							
								
								
									
										70
									
								
								src/wasm-lib/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										70
									
								
								src/wasm-lib/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @ -70,54 +70,12 @@ version = "0.1.6" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" | ||||
|  | ||||
| [[package]] | ||||
| name = "anstream" | ||||
| version = "0.6.13" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb" | ||||
| dependencies = [ | ||||
|  "anstyle", | ||||
|  "anstyle-parse", | ||||
|  "anstyle-query", | ||||
|  "anstyle-wincon", | ||||
|  "colorchoice", | ||||
|  "utf8parse", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "anstyle" | ||||
| version = "1.0.8" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" | ||||
|  | ||||
| [[package]] | ||||
| name = "anstyle-parse" | ||||
| version = "0.2.3" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" | ||||
| dependencies = [ | ||||
|  "utf8parse", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "anstyle-query" | ||||
| version = "1.0.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" | ||||
| dependencies = [ | ||||
|  "windows-sys 0.52.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "anstyle-wincon" | ||||
| version = "3.0.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" | ||||
| dependencies = [ | ||||
|  "anstyle", | ||||
|  "windows-sys 0.52.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "anyhow" | ||||
| version = "1.0.86" | ||||
| @ -426,12 +384,8 @@ version = "4.5.15" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6" | ||||
| dependencies = [ | ||||
|  "anstream", | ||||
|  "anstyle", | ||||
|  "clap_lex", | ||||
|  "strsim 0.11.0", | ||||
|  "unicase", | ||||
|  "unicode-width", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @ -452,12 +406,6 @@ version = "0.7.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" | ||||
|  | ||||
| [[package]] | ||||
| name = "colorchoice" | ||||
| version = "1.0.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" | ||||
|  | ||||
| [[package]] | ||||
| name = "colored" | ||||
| version = "2.1.0" | ||||
| @ -642,7 +590,7 @@ dependencies = [ | ||||
|  "ident_case", | ||||
|  "proc-macro2", | ||||
|  "quote", | ||||
|  "strsim 0.10.0", | ||||
|  "strsim", | ||||
|  "syn 2.0.75", | ||||
| ] | ||||
|  | ||||
| @ -1397,7 +1345,7 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "kcl-lib" | ||||
| version = "0.2.10" | ||||
| version = "0.2.11" | ||||
| dependencies = [ | ||||
|  "anyhow", | ||||
|  "approx", | ||||
| @ -1492,7 +1440,6 @@ dependencies = [ | ||||
|  "bigdecimal", | ||||
|  "bytes", | ||||
|  "chrono", | ||||
|  "clap", | ||||
|  "data-encoding", | ||||
|  "format_serde_error", | ||||
|  "futures", | ||||
| @ -2796,12 +2743,6 @@ version = "0.10.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" | ||||
|  | ||||
| [[package]] | ||||
| name = "strsim" | ||||
| version = "0.11.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" | ||||
|  | ||||
| [[package]] | ||||
| name = "structmeta" | ||||
| version = "0.3.0" | ||||
| @ -3469,12 +3410,6 @@ version = "0.7.6" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" | ||||
|  | ||||
| [[package]] | ||||
| name = "utf8parse" | ||||
| version = "0.2.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" | ||||
|  | ||||
| [[package]] | ||||
| name = "uuid" | ||||
| version = "1.10.0" | ||||
| @ -3626,7 +3561,6 @@ version = "0.1.0" | ||||
| dependencies = [ | ||||
|  "anyhow", | ||||
|  "bson", | ||||
|  "clap", | ||||
|  "console_error_panic_hook", | ||||
|  "data-encoding", | ||||
|  "futures", | ||||
|  | ||||
| @ -11,7 +11,6 @@ crate-type = ["cdylib"] | ||||
|  | ||||
| [dependencies] | ||||
| bson = { version = "2.11.0", features = ["uuid-1", "chrono"] } | ||||
| clap = "4.5.16" | ||||
| data-encoding = "2.6.0" | ||||
| gloo-utils = "0.2.0" | ||||
| kcl-lib = { path = "kcl" } | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| [package] | ||||
| name = "kcl-lib" | ||||
| description = "KittyCAD Language implementation and tools" | ||||
| version = "0.2.10" | ||||
| version = "0.2.11" | ||||
| edition = "2021" | ||||
| license = "MIT" | ||||
| repository = "https://github.com/KittyCAD/modeling-app" | ||||
| @ -16,7 +16,7 @@ async-recursion = "1.1.1" | ||||
| async-trait = "0.1.81" | ||||
| base64 = "0.22.1" | ||||
| chrono = "0.4.38" | ||||
| clap = { version = "4.5.16", default-features = false, optional = true } | ||||
| clap = { version = "4.5.16", default-features = false, optional = true, features = ["std", "derive"] } | ||||
| convert_case = "0.6.0" | ||||
| dashmap = "6.0.1" | ||||
| databake = { version = "0.1.8", features = ["derive"] } | ||||
| @ -27,7 +27,7 @@ git_rev = "0.1.0" | ||||
| gltf-json = "1.4.1" | ||||
| http = { workspace = true } | ||||
| image = { version = "0.25.1", default-features = false, features = ["png"] } | ||||
| kittycad = { workspace = true, features = ["clap"] } | ||||
| kittycad = { workspace = true } | ||||
| lazy_static = "1.5.0" | ||||
| measurements = "0.11.0" | ||||
| mime_guess = "2.0.5" | ||||
| @ -66,7 +66,7 @@ tokio-tungstenite = { version = "0.23.1", features = ["rustls-tls-native-roots"] | ||||
| tower-lsp = { version = "0.20.0", features = ["proposed"] } | ||||
|  | ||||
| [features] | ||||
| default = ["cli", "engine"] | ||||
| default = ["engine"] | ||||
| cli = ["dep:clap"] | ||||
| # For the lsp server, when run with stdout for rpc we want to disable println. | ||||
| # This is used for editor extensions that use the lsp server. | ||||
|  | ||||
| @ -18,3 +18,11 @@ We've built a lot of tooling to make contributing to KCL easier. If you are inte | ||||
| 10. Run `TWENTY_TWENTY=overwrite cargo nextest run --workspace --no-fail-fast` to take snapshot tests of your example code running in the engine | ||||
| 11. Run `EXPECTORATE=overwrite cargo test --all generate_stdlib -- --nocapture` to generate new Markdown documentation for your function that will be used [to generate docs on our website](https://zoo.dev/docs/kcl). | ||||
| 12. Create a PR in GitHub. | ||||
|  | ||||
| ## Bumping the version | ||||
|  | ||||
| If you bump the version of kcl-lib and push it to crates, be sure to update the repos we own that use it as well. These are: | ||||
|  | ||||
| - [kcl.py](https://github.com/kittycad/kcl.py) | ||||
| - [kcl-lsp](https://github.com/kittycad/kcl-lsp) | ||||
| - [cli](https://github.com/kittycad/cli) | ||||
|  | ||||
							
								
								
									
										134
									
								
								src/wasm-lib/kcl/fuzz/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										134
									
								
								src/wasm-lib/kcl/fuzz/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @ -70,55 +70,6 @@ dependencies = [ | ||||
|  "libc", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "anstream" | ||||
| version = "0.6.14" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" | ||||
| dependencies = [ | ||||
|  "anstyle", | ||||
|  "anstyle-parse", | ||||
|  "anstyle-query", | ||||
|  "anstyle-wincon", | ||||
|  "colorchoice", | ||||
|  "is_terminal_polyfill", | ||||
|  "utf8parse", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "anstyle" | ||||
| version = "1.0.8" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" | ||||
|  | ||||
| [[package]] | ||||
| name = "anstyle-parse" | ||||
| version = "0.2.4" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" | ||||
| dependencies = [ | ||||
|  "utf8parse", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "anstyle-query" | ||||
| version = "1.0.3" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5" | ||||
| dependencies = [ | ||||
|  "windows-sys 0.52.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "anstyle-wincon" | ||||
| version = "3.0.3" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" | ||||
| dependencies = [ | ||||
|  "anstyle", | ||||
|  "windows-sys 0.52.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "anyhow" | ||||
| version = "1.0.86" | ||||
| @ -377,54 +328,6 @@ dependencies = [ | ||||
|  "windows-targets 0.52.5", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "clap" | ||||
| version = "4.5.16" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "ed6719fffa43d0d87e5fd8caeab59be1554fb028cd30edc88fc4369b17971019" | ||||
| dependencies = [ | ||||
|  "clap_builder", | ||||
|  "clap_derive", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "clap_builder" | ||||
| version = "4.5.15" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6" | ||||
| dependencies = [ | ||||
|  "anstream", | ||||
|  "anstyle", | ||||
|  "clap_lex", | ||||
|  "strsim", | ||||
|  "unicase", | ||||
|  "unicode-width", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "clap_derive" | ||||
| version = "4.5.13" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" | ||||
| dependencies = [ | ||||
|  "heck 0.5.0", | ||||
|  "proc-macro2", | ||||
|  "quote", | ||||
|  "syn 2.0.75", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "clap_lex" | ||||
| version = "0.7.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" | ||||
|  | ||||
| [[package]] | ||||
| name = "colorchoice" | ||||
| version = "1.0.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" | ||||
|  | ||||
| [[package]] | ||||
| name = "colored" | ||||
| version = "2.1.0" | ||||
| @ -596,7 +499,7 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "derive-docs" | ||||
| version = "0.1.24" | ||||
| version = "0.1.25" | ||||
| dependencies = [ | ||||
|  "Inflector", | ||||
|  "convert_case", | ||||
| @ -906,12 +809,6 @@ version = "0.4.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" | ||||
|  | ||||
| [[package]] | ||||
| name = "heck" | ||||
| version = "0.5.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | ||||
|  | ||||
| [[package]] | ||||
| name = "hermit-abi" | ||||
| version = "0.3.9" | ||||
| @ -1090,12 +987,6 @@ version = "2.9.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" | ||||
|  | ||||
| [[package]] | ||||
| name = "is_terminal_polyfill" | ||||
| version = "1.70.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" | ||||
|  | ||||
| [[package]] | ||||
| name = "itertools" | ||||
| version = "0.12.1" | ||||
| @ -1140,7 +1031,7 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "kcl-lib" | ||||
| version = "0.2.6" | ||||
| version = "0.2.10" | ||||
| dependencies = [ | ||||
|  "anyhow", | ||||
|  "approx", | ||||
| @ -1149,7 +1040,6 @@ dependencies = [ | ||||
|  "base64 0.22.1", | ||||
|  "bson", | ||||
|  "chrono", | ||||
|  "clap", | ||||
|  "convert_case", | ||||
|  "dashmap 6.0.1", | ||||
|  "databake", | ||||
| @ -1158,6 +1048,7 @@ dependencies = [ | ||||
|  "futures", | ||||
|  "git_rev", | ||||
|  "gltf-json", | ||||
|  "http 0.2.12", | ||||
|  "image", | ||||
|  "js-sys", | ||||
|  "kittycad", | ||||
| @ -1198,9 +1089,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "kittycad" | ||||
| version = "0.3.14" | ||||
| version = "0.3.17" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "ce5e9c51976882cdf6777557fd8c3ee68b00bb53e9307fc1721acb397f2ece9a" | ||||
| checksum = "fbb7c076d64ad00a29ae900108707d1bbb583944d4b2d005e1eca9914a18c7c2" | ||||
| dependencies = [ | ||||
|  "anyhow", | ||||
|  "async-trait", | ||||
| @ -1208,7 +1099,6 @@ dependencies = [ | ||||
|  "bigdecimal", | ||||
|  "bytes", | ||||
|  "chrono", | ||||
|  "clap", | ||||
|  "data-encoding", | ||||
|  "format_serde_error", | ||||
|  "futures", | ||||
| @ -2197,7 +2087,7 @@ version = "0.26.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946" | ||||
| dependencies = [ | ||||
|  "heck 0.4.1", | ||||
|  "heck", | ||||
|  "proc-macro2", | ||||
|  "quote", | ||||
|  "rustversion", | ||||
| @ -2649,12 +2539,6 @@ version = "1.11.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" | ||||
|  | ||||
| [[package]] | ||||
| name = "unicode-width" | ||||
| version = "0.1.12" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6" | ||||
|  | ||||
| [[package]] | ||||
| name = "untrusted" | ||||
| version = "0.9.0" | ||||
| @ -2685,12 +2569,6 @@ version = "0.7.6" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" | ||||
|  | ||||
| [[package]] | ||||
| name = "utf8parse" | ||||
| version = "0.2.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" | ||||
|  | ||||
| [[package]] | ||||
| name = "uuid" | ||||
| version = "1.10.0" | ||||
|  | ||||
| @ -1,5 +1,3 @@ | ||||
| //! This module contains settings for kcl projects as well as the modeling app. | ||||
|  | ||||
| pub mod types; | ||||
| #[cfg(not(target_arch = "wasm32"))] | ||||
| pub mod utils; | ||||
|  | ||||
| @ -1,893 +0,0 @@ | ||||
| //! Types for interacting with files in projects. | ||||
|  | ||||
| #[cfg(not(target_arch = "wasm32"))] | ||||
| use std::path::{Path, PathBuf}; | ||||
|  | ||||
| use anyhow::Result; | ||||
| use parse_display::{Display, FromStr}; | ||||
| use schemars::JsonSchema; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| /// State management for the application. | ||||
| #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)] | ||||
| #[ts(export)] | ||||
| #[serde(rename_all = "snake_case")] | ||||
| pub struct ProjectState { | ||||
|     pub project: Project, | ||||
|     pub current_file: Option<String>, | ||||
| } | ||||
|  | ||||
| impl ProjectState { | ||||
|     /// Create a new project state from a path. | ||||
|     #[cfg(not(target_arch = "wasm32"))] | ||||
|     pub async fn new_from_path(path: PathBuf) -> Result<ProjectState> { | ||||
|         // Fix for "." path, which is the current directory. | ||||
|         let source_path = if path == Path::new(".") { | ||||
|             std::env::current_dir().map_err(|e| anyhow::anyhow!("Error getting the current directory: {:?}", e))? | ||||
|         } else { | ||||
|             path | ||||
|         }; | ||||
|  | ||||
|         // Url decode the path. | ||||
|         let source_path = | ||||
|             std::path::Path::new(&urlencoding::decode(&source_path.display().to_string())?.to_string()).to_path_buf(); | ||||
|  | ||||
|         // If the path does not start with a slash, it is a relative path. | ||||
|         // We need to convert it to an absolute path. | ||||
|         let source_path = if source_path.is_relative() { | ||||
|             std::env::current_dir() | ||||
|                 .map_err(|e| anyhow::anyhow!("Error getting the current directory: {:?}", e))? | ||||
|                 .join(source_path) | ||||
|         } else { | ||||
|             source_path | ||||
|         }; | ||||
|  | ||||
|         // If the path is a directory, let's assume it is a project directory. | ||||
|         if source_path.is_dir() { | ||||
|             // Load the details about the project from the path. | ||||
|             let project = Project::from_path(&source_path) | ||||
|                 .await | ||||
|                 .map_err(|e| anyhow::anyhow!("Error loading project from path {}: {:?}", source_path.display(), e))?; | ||||
|  | ||||
|             // Check if we have a main.kcl file in the project. | ||||
|             let project_file = source_path.join(crate::settings::types::DEFAULT_PROJECT_KCL_FILE); | ||||
|  | ||||
|             if !project_file.exists() { | ||||
|                 // Create the default file in the project. | ||||
|                 // Write the initial project file. | ||||
|                 tokio::fs::write(&project_file, vec![]).await?; | ||||
|             } | ||||
|  | ||||
|             return Ok(ProjectState { | ||||
|                 project, | ||||
|                 current_file: Some(project_file.display().to_string()), | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         // Check if the extension on what we are trying to open is a relevant file type. | ||||
|         // Get the extension of the file. | ||||
|         let extension = source_path | ||||
|             .extension() | ||||
|             .ok_or_else(|| anyhow::anyhow!("Error getting the extension of the file: `{}`", source_path.display()))?; | ||||
|         let ext = extension.to_string_lossy().to_string(); | ||||
|  | ||||
|         // Check if the extension is a relevant file type. | ||||
|         if !crate::settings::utils::RELEVANT_EXTENSIONS.contains(&ext) || ext == "toml" { | ||||
|             return Err(anyhow::anyhow!( | ||||
|                 "File type ({}) cannot be opened with this app: `{}`, try opening one of the following file types: {}", | ||||
|                 ext, | ||||
|                 source_path.display(), | ||||
|                 crate::settings::utils::RELEVANT_EXTENSIONS.join(", ") | ||||
|             )); | ||||
|         } | ||||
|  | ||||
|         // We were given a file path, not a directory. | ||||
|         // Let's get the parent directory of the file. | ||||
|         let parent = source_path.parent().ok_or_else(|| { | ||||
|             anyhow::anyhow!( | ||||
|                 "Error getting the parent directory of the file: {}", | ||||
|                 source_path.display() | ||||
|             ) | ||||
|         })?; | ||||
|  | ||||
|         // If we got a import model file, we need to check if we have a file in the project for | ||||
|         // this import model. | ||||
|         if crate::settings::utils::IMPORT_FILE_EXTENSIONS.contains(&ext) { | ||||
|             let import_file_name = source_path | ||||
|                 .file_name() | ||||
|                 .ok_or_else(|| anyhow::anyhow!("Error getting the file name of the file: {}", source_path.display()))? | ||||
|                 .to_string_lossy() | ||||
|                 .to_string(); | ||||
|             // Check if we have a file in the project for this import model. | ||||
|             let kcl_wrapper_filename = format!("{}.kcl", import_file_name); | ||||
|             let kcl_wrapper_file_path = parent.join(&kcl_wrapper_filename); | ||||
|  | ||||
|             if !kcl_wrapper_file_path.exists() { | ||||
|                 // Create the file in the project. | ||||
|                 // With the default import content. | ||||
|                 tokio::fs::write( | ||||
|                     &kcl_wrapper_file_path, | ||||
|                     format!( | ||||
|                         r#"// This file was automatically generated by the application when you | ||||
| // double-clicked on the model file. | ||||
| // You can edit this file to add your own content. | ||||
| // 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("{}")"#, | ||||
|                         import_file_name | ||||
|                     ) | ||||
|                     .as_bytes(), | ||||
|                 ) | ||||
|                 .await?; | ||||
|             } | ||||
|  | ||||
|             // Load the details about the project from the parent directory. | ||||
|             // We do this after we generate the import file so that the file is included in the project. | ||||
|             let project = Project::from_path(&parent) | ||||
|                 .await | ||||
|                 .map_err(|e| anyhow::anyhow!("Error loading project from path {}: {:?}", source_path.display(), e))?; | ||||
|  | ||||
|             return Ok(ProjectState { | ||||
|                 project, | ||||
|                 current_file: Some(kcl_wrapper_file_path.display().to_string()), | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         // Load the details about the project from the parent directory. | ||||
|         let project = Project::from_path(&parent) | ||||
|             .await | ||||
|             .map_err(|e| anyhow::anyhow!("Error loading project from path {}: {:?}", source_path.display(), e))?; | ||||
|  | ||||
|         Ok(ProjectState { | ||||
|             project, | ||||
|             current_file: Some(source_path.display().to_string()), | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Information about project. | ||||
| #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)] | ||||
| #[ts(export)] | ||||
| #[serde(rename_all = "snake_case")] | ||||
| pub struct Project { | ||||
|     #[serde(flatten)] | ||||
|     pub file: FileEntry, | ||||
|     #[serde(default, skip_serializing_if = "Option::is_none")] | ||||
|     pub metadata: Option<FileMetadata>, | ||||
|     #[serde(default)] | ||||
|     #[ts(type = "number")] | ||||
|     pub kcl_file_count: u64, | ||||
|     #[serde(default)] | ||||
|     #[ts(type = "number")] | ||||
|     pub directory_count: u64, | ||||
|     /// The default file to open on load. | ||||
|     pub default_file: String, | ||||
| } | ||||
|  | ||||
| impl Project { | ||||
|     #[cfg(not(target_arch = "wasm32"))] | ||||
|     /// Populate a project from a path. | ||||
|     pub async fn from_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self> { | ||||
|         // Check if they are using '.' as the path. | ||||
|         let path = if path.as_ref() == std::path::Path::new(".") { | ||||
|             std::env::current_dir()? | ||||
|         } else { | ||||
|             path.as_ref().to_path_buf() | ||||
|         }; | ||||
|  | ||||
|         // Make sure the path exists. | ||||
|         if !path.exists() { | ||||
|             return Err(anyhow::anyhow!("Path does not exist")); | ||||
|         } | ||||
|  | ||||
|         let file = crate::settings::utils::walk_dir(&path).await?; | ||||
|         let metadata = std::fs::metadata(&path).ok().map(|m| m.into()); | ||||
|         let mut project = Self { | ||||
|             file: file.clone(), | ||||
|             metadata, | ||||
|             kcl_file_count: 0, | ||||
|             directory_count: 0, | ||||
|             default_file: get_default_kcl_file_for_dir(path, file).await?, | ||||
|         }; | ||||
|         project.populate_kcl_file_count()?; | ||||
|         project.populate_directory_count()?; | ||||
|         Ok(project) | ||||
|     } | ||||
|  | ||||
|     /// Populate the number of KCL files in the project. | ||||
|     pub fn populate_kcl_file_count(&mut self) -> Result<()> { | ||||
|         let mut count = 0; | ||||
|         if let Some(children) = &self.file.children { | ||||
|             for entry in children.iter() { | ||||
|                 if entry.name.ends_with(".kcl") { | ||||
|                     count += 1; | ||||
|                 } else { | ||||
|                     count += entry.kcl_file_count(); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         self.kcl_file_count = count; | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Populate the number of directories in the project. | ||||
|     pub fn populate_directory_count(&mut self) -> Result<()> { | ||||
|         let mut count = 0; | ||||
|         if let Some(children) = &self.file.children { | ||||
|             for entry in children.iter() { | ||||
|                 count += entry.directory_count(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         self.directory_count = count; | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Get the default KCL file for a directory. | ||||
| /// This determines what the default file to open is. | ||||
| #[cfg(not(target_arch = "wasm32"))] | ||||
| #[async_recursion::async_recursion] | ||||
| pub async fn get_default_kcl_file_for_dir<P>(dir: P, file: FileEntry) -> Result<String> | ||||
| where | ||||
|     P: AsRef<Path> + Send, | ||||
| { | ||||
|     // Make sure the dir is a directory. | ||||
|     if !dir.as_ref().is_dir() { | ||||
|         return Err(anyhow::anyhow!("Path `{}` is not a directory", dir.as_ref().display())); | ||||
|     } | ||||
|  | ||||
|     let default_file = dir.as_ref().join(crate::settings::types::DEFAULT_PROJECT_KCL_FILE); | ||||
|     if !default_file.exists() { | ||||
|         // Find a kcl file in the directory. | ||||
|         if let Some(children) = file.children { | ||||
|             for entry in children.iter() { | ||||
|                 if entry.name.ends_with(".kcl") { | ||||
|                     return Ok(dir.as_ref().join(&entry.name).display().to_string()); | ||||
|                 } else if entry.children.is_some() { | ||||
|                     // Recursively find a kcl file in the directory. | ||||
|                     return get_default_kcl_file_for_dir(entry.path.clone(), entry.clone()).await; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // If we didn't find a kcl file, create one. | ||||
|         tokio::fs::write(&default_file, vec![]).await?; | ||||
|     } | ||||
|  | ||||
|     Ok(default_file.display().to_string()) | ||||
| } | ||||
|  | ||||
| #[cfg(not(target_arch = "wasm32"))] | ||||
| /// Rename a directory for a project. | ||||
| /// This returns the new path of the directory. | ||||
| pub async fn rename_project_directory<P>(path: P, new_name: &str) -> Result<std::path::PathBuf> | ||||
| where | ||||
|     P: AsRef<Path> + Send, | ||||
| { | ||||
|     if new_name.is_empty() { | ||||
|         return Err(anyhow::anyhow!("New name for project cannot be empty")); | ||||
|     } | ||||
|  | ||||
|     // Make sure the path is a directory. | ||||
|     if !path.as_ref().is_dir() { | ||||
|         return Err(anyhow::anyhow!("Path `{}` is not a directory", path.as_ref().display())); | ||||
|     } | ||||
|  | ||||
|     // Make sure the new name does not exist. | ||||
|     let new_path = path | ||||
|         .as_ref() | ||||
|         .parent() | ||||
|         .ok_or_else(|| anyhow::anyhow!("Parent directory of `{}` not found", path.as_ref().display()))? | ||||
|         .join(new_name); | ||||
|     if new_path.exists() { | ||||
|         return Err(anyhow::anyhow!( | ||||
|             "Path `{}` already exists, cannot rename to an existing path", | ||||
|             new_path.display() | ||||
|         )); | ||||
|     } | ||||
|  | ||||
|     tokio::fs::rename(path.as_ref(), &new_path).await?; | ||||
|     Ok(new_path) | ||||
| } | ||||
|  | ||||
| /// Information about a file or directory. | ||||
| #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)] | ||||
| #[ts(export)] | ||||
| #[serde(rename_all = "snake_case")] | ||||
| pub struct FileEntry { | ||||
|     pub path: String, | ||||
|     pub name: String, | ||||
|     #[serde(default, skip_serializing_if = "Option::is_none")] | ||||
|     pub children: Option<Vec<FileEntry>>, | ||||
| } | ||||
|  | ||||
| impl FileEntry { | ||||
|     /// Recursively get the number of kcl files in the file entry. | ||||
|     pub fn kcl_file_count(&self) -> u64 { | ||||
|         let mut count = 0; | ||||
|         if let Some(children) = &self.children { | ||||
|             for entry in children.iter() { | ||||
|                 if entry.name.ends_with(".kcl") { | ||||
|                     count += 1; | ||||
|                 } else { | ||||
|                     count += entry.kcl_file_count(); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         count | ||||
|     } | ||||
|  | ||||
|     /// Recursively get the number of directories in the file entry. | ||||
|     pub fn directory_count(&self) -> u64 { | ||||
|         let mut count = 0; | ||||
|         if let Some(children) = &self.children { | ||||
|             for entry in children.iter() { | ||||
|                 if entry.children.is_some() { | ||||
|                     count += 1; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         count | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Metadata about a file or directory. | ||||
| #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)] | ||||
| #[ts(export)] | ||||
| #[serde(rename_all = "snake_case")] | ||||
| pub struct FileMetadata { | ||||
|     #[serde(default, skip_serializing_if = "Option::is_none")] | ||||
|     pub accessed: Option<chrono::DateTime<chrono::Utc>>, | ||||
|     #[serde(default, skip_serializing_if = "Option::is_none")] | ||||
|     pub created: Option<chrono::DateTime<chrono::Utc>>, | ||||
|     #[serde(default, skip_serializing_if = "Option::is_none")] | ||||
|     pub r#type: Option<FileType>, | ||||
|     #[serde(default)] | ||||
|     #[ts(type = "number")] | ||||
|     pub size: u64, | ||||
|     #[serde(default, skip_serializing_if = "Option::is_none")] | ||||
|     pub modified: Option<chrono::DateTime<chrono::Utc>>, | ||||
|     #[serde(default, skip_serializing_if = "Option::is_none")] | ||||
|     pub permission: Option<FilePermission>, | ||||
| } | ||||
|  | ||||
| /// The type of a file. | ||||
| #[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq)] | ||||
| #[ts(export)] | ||||
| #[serde(rename_all = "snake_case")] | ||||
| #[display(style = "snake_case")] | ||||
| pub enum FileType { | ||||
|     /// A file. | ||||
|     File, | ||||
|     /// A directory. | ||||
|     Directory, | ||||
|     /// A symbolic link. | ||||
|     Symlink, | ||||
| } | ||||
|  | ||||
| /// The permissions of a file. | ||||
| #[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq)] | ||||
| #[ts(export)] | ||||
| #[serde(rename_all = "snake_case")] | ||||
| #[display(style = "snake_case")] | ||||
| pub enum FilePermission { | ||||
|     /// Read permission. | ||||
|     Read, | ||||
|     /// Write permission. | ||||
|     Write, | ||||
|     /// Execute permission. | ||||
|     Execute, | ||||
| } | ||||
|  | ||||
| impl From<std::fs::FileType> for FileType { | ||||
|     fn from(file_type: std::fs::FileType) -> Self { | ||||
|         if file_type.is_file() { | ||||
|             FileType::File | ||||
|         } else if file_type.is_dir() { | ||||
|             FileType::Directory | ||||
|         } else if file_type.is_symlink() { | ||||
|             FileType::Symlink | ||||
|         } else { | ||||
|             unreachable!() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<std::fs::Permissions> for FilePermission { | ||||
|     fn from(permissions: std::fs::Permissions) -> Self { | ||||
|         #[cfg(unix)] | ||||
|         { | ||||
|             use std::os::unix::fs::PermissionsExt; | ||||
|             let mode = permissions.mode(); | ||||
|             if mode & 0o400 != 0 { | ||||
|                 FilePermission::Read | ||||
|             } else if mode & 0o200 != 0 { | ||||
|                 FilePermission::Write | ||||
|             } else if mode & 0o100 != 0 { | ||||
|                 FilePermission::Execute | ||||
|             } else { | ||||
|                 unreachable!() | ||||
|             } | ||||
|         } | ||||
|         #[cfg(not(unix))] | ||||
|         { | ||||
|             if permissions.readonly() { | ||||
|                 FilePermission::Read | ||||
|             } else { | ||||
|                 FilePermission::Write | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<std::fs::Metadata> for FileMetadata { | ||||
|     fn from(metadata: std::fs::Metadata) -> Self { | ||||
|         Self { | ||||
|             accessed: metadata.accessed().ok().map(|t| t.into()), | ||||
|             created: metadata.created().ok().map(|t| t.into()), | ||||
|             r#type: Some(metadata.file_type().into()), | ||||
|             size: metadata.len(), | ||||
|             modified: metadata.modified().ok().map(|t| t.into()), | ||||
|             permission: Some(metadata.permissions().into()), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use pretty_assertions::assert_eq; | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_default_kcl_file_for_dir_non_exist() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&dir).unwrap(); | ||||
|         let file = crate::settings::utils::walk_dir(&dir).await.unwrap(); | ||||
|  | ||||
|         let default_file = super::get_default_kcl_file_for_dir(&dir, file).await.unwrap(); | ||||
|         assert_eq!(default_file, dir.join("main.kcl").display().to_string()); | ||||
|  | ||||
|         std::fs::remove_dir_all(dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_default_kcl_file_for_dir_main_kcl() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&dir).unwrap(); | ||||
|         std::fs::write(dir.join("main.kcl"), vec![]).unwrap(); | ||||
|         let file = crate::settings::utils::walk_dir(&dir).await.unwrap(); | ||||
|  | ||||
|         let default_file = super::get_default_kcl_file_for_dir(&dir, file).await.unwrap(); | ||||
|         assert_eq!(default_file, dir.join("main.kcl").display().to_string()); | ||||
|  | ||||
|         std::fs::remove_dir_all(dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_default_kcl_file_for_dir_thing_kcl() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&dir).unwrap(); | ||||
|         std::fs::write(dir.join("thing.kcl"), vec![]).unwrap(); | ||||
|         let file = crate::settings::utils::walk_dir(&dir).await.unwrap(); | ||||
|  | ||||
|         let default_file = super::get_default_kcl_file_for_dir(&dir, file).await.unwrap(); | ||||
|         assert_eq!(default_file, dir.join("thing.kcl").display().to_string()); | ||||
|         std::fs::remove_dir_all(dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_default_kcl_file_for_dir_nested_main_kcl() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&dir).unwrap(); | ||||
|         std::fs::create_dir_all(dir.join("assembly")).unwrap(); | ||||
|         std::fs::write(dir.join("assembly").join("main.kcl"), vec![]).unwrap(); | ||||
|         let file = crate::settings::utils::walk_dir(&dir).await.unwrap(); | ||||
|  | ||||
|         let default_file = super::get_default_kcl_file_for_dir(&dir, file).await.unwrap(); | ||||
|         assert_eq!( | ||||
|             default_file, | ||||
|             dir.join("assembly").join("main.kcl").display().to_string() | ||||
|         ); | ||||
|  | ||||
|         std::fs::remove_dir_all(dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_default_kcl_file_for_dir_nested_thing_kcl() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&dir).unwrap(); | ||||
|         std::fs::create_dir_all(dir.join("assembly")).unwrap(); | ||||
|         std::fs::write(dir.join("assembly").join("thing.kcl"), vec![]).unwrap(); | ||||
|         let file = crate::settings::utils::walk_dir(&dir).await.unwrap(); | ||||
|  | ||||
|         let default_file = super::get_default_kcl_file_for_dir(&dir, file).await.unwrap(); | ||||
|         assert_eq!( | ||||
|             default_file, | ||||
|             dir.join("assembly").join("thing.kcl").display().to_string() | ||||
|         ); | ||||
|         std::fs::remove_dir_all(dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_rename_project_directory_empty_dir() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&dir).unwrap(); | ||||
|  | ||||
|         let new_name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let new_dir = super::rename_project_directory(&dir, &new_name).await.unwrap(); | ||||
|         assert_eq!(new_dir, std::env::temp_dir().join(&new_name)); | ||||
|  | ||||
|         std::fs::remove_dir_all(new_dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_rename_project_directory_empty_name() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&dir).unwrap(); | ||||
|  | ||||
|         let result = super::rename_project_directory(&dir, "").await; | ||||
|         assert!(result.is_err()); | ||||
|         assert_eq!(result.unwrap_err().to_string(), "New name for project cannot be empty"); | ||||
|  | ||||
|         std::fs::remove_dir_all(dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_rename_project_directory_non_empty_dir() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&dir).unwrap(); | ||||
|         std::fs::write(dir.join("main.kcl"), vec![]).unwrap(); | ||||
|  | ||||
|         let new_name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let new_dir = super::rename_project_directory(&dir, &new_name).await.unwrap(); | ||||
|         assert_eq!(new_dir, std::env::temp_dir().join(&new_name)); | ||||
|  | ||||
|         std::fs::remove_dir_all(new_dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_rename_project_directory_non_empty_dir_recursive() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&dir).unwrap(); | ||||
|         std::fs::create_dir_all(dir.join("assembly")).unwrap(); | ||||
|         std::fs::write(dir.join("assembly").join("main.kcl"), vec![]).unwrap(); | ||||
|  | ||||
|         let new_name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let new_dir = super::rename_project_directory(&dir, &new_name).await.unwrap(); | ||||
|         assert_eq!(new_dir, std::env::temp_dir().join(&new_name)); | ||||
|  | ||||
|         std::fs::remove_dir_all(new_dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_rename_project_directory_dir_is_file() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::write(&dir, vec![]).unwrap(); | ||||
|  | ||||
|         let new_name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let result = super::rename_project_directory(&dir, &new_name).await; | ||||
|         assert!(result.is_err()); | ||||
|         assert_eq!( | ||||
|             result.unwrap_err().to_string(), | ||||
|             format!("Path `{}` is not a directory", dir.display()) | ||||
|         ); | ||||
|  | ||||
|         std::fs::remove_file(dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_rename_project_directory_new_name_exists() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&dir).unwrap(); | ||||
|  | ||||
|         let new_name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let new_dir = std::env::temp_dir().join(&new_name); | ||||
|         std::fs::create_dir_all(&new_dir).unwrap(); | ||||
|  | ||||
|         let result = super::rename_project_directory(&dir, &new_name).await; | ||||
|         assert!(result.is_err()); | ||||
|         assert_eq!( | ||||
|             result.unwrap_err().to_string(), | ||||
|             format!( | ||||
|                 "Path `{}` already exists, cannot rename to an existing path", | ||||
|                 new_dir.display() | ||||
|             ) | ||||
|         ); | ||||
|  | ||||
|         std::fs::remove_dir_all(new_dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_project_state_new_from_path_source_path_dot() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let tmp_project_dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&tmp_project_dir).unwrap(); | ||||
|         // Set the current directory to the temp project directory. | ||||
|         // This is to simulate the "." path. | ||||
|         std::env::set_current_dir(&tmp_project_dir).unwrap(); | ||||
|  | ||||
|         let state = super::ProjectState::new_from_path(std::path::PathBuf::from(".")) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         assert_eq!(state.project.file.name, name); | ||||
|         assert_eq!( | ||||
|             state | ||||
|                 .project | ||||
|                 .file | ||||
|                 .path | ||||
|                 // macOS adds /private to the path i think because we changed curdirs | ||||
|                 .trim_start_matches("/private"), | ||||
|             tmp_project_dir.display().to_string() | ||||
|         ); | ||||
|         assert_eq!( | ||||
|             state | ||||
|                 .current_file | ||||
|                 .unwrap() | ||||
|                 // macOS adds /private to the path i think because we changed curdirs | ||||
|                 .trim_start_matches("/private"), | ||||
|             tmp_project_dir.join("main.kcl").display().to_string() | ||||
|         ); | ||||
|         assert_eq!( | ||||
|             state | ||||
|                 .project | ||||
|                 .default_file | ||||
|                 // macOS adds /private to the path i think because we changed curdirs | ||||
|                 .trim_start_matches("/private"), | ||||
|             tmp_project_dir.join("main.kcl").display().to_string() | ||||
|         ); | ||||
|  | ||||
|         std::fs::remove_dir_all(tmp_project_dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_project_state_new_from_path_main_kcl_not_exists() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let tmp_project_dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&tmp_project_dir).unwrap(); | ||||
|  | ||||
|         let state = super::ProjectState::new_from_path(tmp_project_dir.clone()) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         assert_eq!(state.project.file.name, name); | ||||
|         assert_eq!(state.project.file.path, tmp_project_dir.display().to_string()); | ||||
|         assert_eq!( | ||||
|             state.current_file, | ||||
|             Some(tmp_project_dir.join("main.kcl").display().to_string()) | ||||
|         ); | ||||
|         assert_eq!( | ||||
|             state.project.default_file, | ||||
|             tmp_project_dir.join("main.kcl").display().to_string() | ||||
|         ); | ||||
|  | ||||
|         std::fs::remove_dir_all(tmp_project_dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_project_state_new_from_path_main_kcl_exists() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let tmp_project_dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&tmp_project_dir).unwrap(); | ||||
|         std::fs::write(tmp_project_dir.join("main.kcl"), vec![]).unwrap(); | ||||
|  | ||||
|         let state = super::ProjectState::new_from_path(tmp_project_dir.clone()) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         assert_eq!(state.project.file.name, name); | ||||
|         assert_eq!(state.project.file.path, tmp_project_dir.display().to_string()); | ||||
|         assert_eq!( | ||||
|             state.current_file, | ||||
|             Some(tmp_project_dir.join("main.kcl").display().to_string()) | ||||
|         ); | ||||
|         assert_eq!( | ||||
|             state.project.default_file, | ||||
|             tmp_project_dir.join("main.kcl").display().to_string() | ||||
|         ); | ||||
|  | ||||
|         std::fs::remove_dir_all(tmp_project_dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_project_state_new_from_path_explicit_open_main_kcl() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let tmp_project_dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&tmp_project_dir).unwrap(); | ||||
|         std::fs::write(tmp_project_dir.join("main.kcl"), vec![]).unwrap(); | ||||
|  | ||||
|         let state = super::ProjectState::new_from_path(tmp_project_dir.join("main.kcl")) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         assert_eq!(state.project.file.name, name); | ||||
|         assert_eq!(state.project.file.path, tmp_project_dir.display().to_string()); | ||||
|         assert_eq!( | ||||
|             state.current_file, | ||||
|             Some(tmp_project_dir.join("main.kcl").display().to_string()) | ||||
|         ); | ||||
|         assert_eq!( | ||||
|             state.project.default_file, | ||||
|             tmp_project_dir.join("main.kcl").display().to_string() | ||||
|         ); | ||||
|  | ||||
|         std::fs::remove_dir_all(tmp_project_dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_project_state_new_from_path_explicit_open_thing_kcl() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let tmp_project_dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&tmp_project_dir).unwrap(); | ||||
|         std::fs::write(tmp_project_dir.join("thing.kcl"), vec![]).unwrap(); | ||||
|  | ||||
|         let state = super::ProjectState::new_from_path(tmp_project_dir.join("thing.kcl")) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         assert_eq!(state.project.file.name, name); | ||||
|         assert_eq!(state.project.file.path, tmp_project_dir.display().to_string()); | ||||
|         assert_eq!( | ||||
|             state.current_file, | ||||
|             Some(tmp_project_dir.join("thing.kcl").display().to_string()) | ||||
|         ); | ||||
|         assert_eq!( | ||||
|             state.project.default_file, | ||||
|             tmp_project_dir.join("thing.kcl").display().to_string() | ||||
|         ); | ||||
|  | ||||
|         std::fs::remove_dir_all(tmp_project_dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_project_state_new_from_path_explicit_open_model_obj() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let tmp_project_dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&tmp_project_dir).unwrap(); | ||||
|         std::fs::write(tmp_project_dir.join("model.obj"), vec![]).unwrap(); | ||||
|  | ||||
|         let state = super::ProjectState::new_from_path(tmp_project_dir.join("model.obj")) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         assert_eq!(state.project.file.name, name); | ||||
|         assert_eq!(state.project.file.path, tmp_project_dir.display().to_string()); | ||||
|         assert_eq!( | ||||
|             state.current_file, | ||||
|             Some(tmp_project_dir.join("model.obj.kcl").display().to_string()) | ||||
|         ); | ||||
|         assert_eq!( | ||||
|             state.project.default_file, | ||||
|             tmp_project_dir.join("model.obj.kcl").display().to_string() | ||||
|         ); | ||||
|  | ||||
|         // Get the contents of the generated kcl file. | ||||
|         let kcl_file_contents = tokio::fs::read(tmp_project_dir.join("model.obj.kcl")).await.unwrap(); | ||||
|         assert_eq!( | ||||
|             String::from_utf8_lossy(&kcl_file_contents), | ||||
|             r#"// This file was automatically generated by the application when you | ||||
| // double-clicked on the model file. | ||||
| // You can edit this file to add your own content. | ||||
| // 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("model.obj")"# | ||||
|         ); | ||||
|  | ||||
|         std::fs::remove_dir_all(tmp_project_dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_project_state_new_from_path_explicit_open_settings_toml() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let tmp_project_dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&tmp_project_dir).unwrap(); | ||||
|         std::fs::write(tmp_project_dir.join("settings.toml"), vec![]).unwrap(); | ||||
|  | ||||
|         let result = super::ProjectState::new_from_path(tmp_project_dir.join("settings.toml")).await; | ||||
|  | ||||
|         assert!(result.is_err()); | ||||
|         assert_eq!(result.unwrap_err().to_string(), format!("File type (toml) cannot be opened with this app: `{}`, try opening one of the following file types: stp, glb, fbxb, fbx, gltf, obj, ply, sldprt, step, stl, kcl", tmp_project_dir.join("settings.toml").display())); | ||||
|  | ||||
|         std::fs::remove_dir_all(tmp_project_dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_project_state_new_from_path_explicit_open_non_relevant_file() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let tmp_project_dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&tmp_project_dir).unwrap(); | ||||
|         std::fs::write(tmp_project_dir.join("settings.docx"), vec![]).unwrap(); | ||||
|  | ||||
|         let result = super::ProjectState::new_from_path(tmp_project_dir.join("settings.docx")).await; | ||||
|  | ||||
|         assert!(result.is_err()); | ||||
|         assert_eq!(result.unwrap_err().to_string(), format!("File type (docx) cannot be opened with this app: `{}`, try opening one of the following file types: stp, glb, fbxb, fbx, gltf, obj, ply, sldprt, step, stl, kcl", tmp_project_dir.join("settings.docx").display())); | ||||
|  | ||||
|         std::fs::remove_dir_all(tmp_project_dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_project_state_new_from_path_explicit_open_no_file_extension() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let tmp_project_dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&tmp_project_dir).unwrap(); | ||||
|         std::fs::write(tmp_project_dir.join("file"), vec![]).unwrap(); | ||||
|  | ||||
|         let result = super::ProjectState::new_from_path(tmp_project_dir.join("file")).await; | ||||
|  | ||||
|         assert!(result.is_err()); | ||||
|         assert_eq!( | ||||
|             result.unwrap_err().to_string(), | ||||
|             format!( | ||||
|                 "Error getting the extension of the file: `{}`", | ||||
|                 tmp_project_dir.join("file").display() | ||||
|             ) | ||||
|         ); | ||||
|  | ||||
|         std::fs::remove_dir_all(tmp_project_dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_project_state_new_from_path_explicit_open_file_with_space_kcl() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let tmp_project_dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&tmp_project_dir).unwrap(); | ||||
|         std::fs::write(tmp_project_dir.join("i have a space.kcl"), vec![]).unwrap(); | ||||
|  | ||||
|         let state = super::ProjectState::new_from_path(tmp_project_dir.join("i have a space.kcl")) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         assert_eq!(state.project.file.name, name); | ||||
|         assert_eq!(state.project.file.path, tmp_project_dir.display().to_string()); | ||||
|         assert_eq!( | ||||
|             state.current_file, | ||||
|             Some(tmp_project_dir.join("i have a space.kcl").display().to_string()) | ||||
|         ); | ||||
|         assert_eq!( | ||||
|             state.project.default_file, | ||||
|             tmp_project_dir.join("i have a space.kcl").display().to_string() | ||||
|         ); | ||||
|  | ||||
|         std::fs::remove_dir_all(tmp_project_dir).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_project_state_new_from_path_explicit_open_file_with_space_kcl_url_encoded() { | ||||
|         let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); | ||||
|         let tmp_project_dir = std::env::temp_dir().join(&name); | ||||
|         std::fs::create_dir_all(&tmp_project_dir).unwrap(); | ||||
|         std::fs::write(tmp_project_dir.join("i have a space.kcl"), vec![]).unwrap(); | ||||
|  | ||||
|         let state = super::ProjectState::new_from_path(tmp_project_dir.join("i%20have%20a%20space.kcl")) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         assert_eq!(state.project.file.name, name); | ||||
|         assert_eq!(state.project.file.path, tmp_project_dir.display().to_string()); | ||||
|         assert_eq!( | ||||
|             state.current_file, | ||||
|             Some(tmp_project_dir.join("i have a space.kcl").display().to_string()) | ||||
|         ); | ||||
|         assert_eq!( | ||||
|             state.project.default_file, | ||||
|             tmp_project_dir.join("i have a space.kcl").display().to_string() | ||||
|         ); | ||||
|  | ||||
|         std::fs::remove_dir_all(tmp_project_dir).unwrap(); | ||||
|     } | ||||
| } | ||||
| @ -1,6 +1,5 @@ | ||||
| //! Types for kcl project and modeling-app settings. | ||||
|  | ||||
| pub mod file; | ||||
| pub mod project; | ||||
|  | ||||
| use anyhow::Result; | ||||
| @ -61,120 +60,6 @@ impl Configuration { | ||||
|  | ||||
|         Ok(settings) | ||||
|     } | ||||
|  | ||||
|     #[cfg(not(target_arch = "wasm32"))] | ||||
|     /// Initialize the project directory. | ||||
|     pub async fn ensure_project_directory_exists(&self) -> Result<std::path::PathBuf> { | ||||
|         let project_dir = &self.settings.project.directory; | ||||
|  | ||||
|         // Check if the directory exists. | ||||
|         if !project_dir.exists() { | ||||
|             // Create the directory. | ||||
|             tokio::fs::create_dir_all(project_dir).await?; | ||||
|         } | ||||
|  | ||||
|         Ok(project_dir.clone()) | ||||
|     } | ||||
|  | ||||
|     #[cfg(not(target_arch = "wasm32"))] | ||||
|     /// Create a new project directory. | ||||
|     pub async fn create_new_project_directory( | ||||
|         &self, | ||||
|         project_name: &str, | ||||
|         initial_code: Option<&str>, | ||||
|     ) -> Result<crate::settings::types::file::Project> { | ||||
|         let main_dir = &self.ensure_project_directory_exists().await?; | ||||
|  | ||||
|         if project_name.is_empty() { | ||||
|             return Err(anyhow::anyhow!("Project name cannot be empty.")); | ||||
|         } | ||||
|  | ||||
|         // Create the project directory. | ||||
|         let project_dir = main_dir.join(project_name); | ||||
|  | ||||
|         // Create the directory. | ||||
|         if !project_dir.exists() { | ||||
|             tokio::fs::create_dir_all(&project_dir).await?; | ||||
|         } | ||||
|  | ||||
|         // Write the initial project file. | ||||
|         let project_file = project_dir.join(DEFAULT_PROJECT_KCL_FILE); | ||||
|         tokio::fs::write(&project_file, initial_code.unwrap_or_default()).await?; | ||||
|  | ||||
|         Ok(crate::settings::types::file::Project { | ||||
|             file: crate::settings::types::file::FileEntry { | ||||
|                 path: project_dir.to_string_lossy().to_string(), | ||||
|                 name: project_name.to_string(), | ||||
|                 // We don't need to recursively get all files in the project directory. | ||||
|                 // Because we just created it and it's empty. | ||||
|                 children: None, | ||||
|             }, | ||||
|             default_file: project_file.to_string_lossy().to_string(), | ||||
|             metadata: Some(tokio::fs::metadata(&project_dir).await?.into()), | ||||
|             kcl_file_count: 1, | ||||
|             directory_count: 0, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     #[cfg(not(target_arch = "wasm32"))] | ||||
|     /// List all the projects for the configuration. | ||||
|     pub async fn list_projects(&self) -> Result<Vec<crate::settings::types::file::Project>> { | ||||
|         // Get all the top level directories in the project directory. | ||||
|         let main_dir = &self.ensure_project_directory_exists().await?; | ||||
|         let mut projects = vec![]; | ||||
|  | ||||
|         let mut entries = tokio::fs::read_dir(main_dir).await?; | ||||
|         while let Some(e) = entries.next_entry().await? { | ||||
|             if !e.file_type().await?.is_dir() || e.file_name().to_string_lossy().starts_with('.') { | ||||
|                 // We don't care it's not a directory | ||||
|                 // or it's a hidden directory. | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             // Make sure the project has at least one kcl file in it. | ||||
|             let project = self.get_project_info(&e.path().display().to_string()).await?; | ||||
|             if project.kcl_file_count == 0 { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             projects.push(project); | ||||
|         } | ||||
|  | ||||
|         Ok(projects) | ||||
|     } | ||||
|  | ||||
|     #[cfg(not(target_arch = "wasm32"))] | ||||
|     /// Get information about a project. | ||||
|     pub async fn get_project_info(&self, project_path: &str) -> Result<crate::settings::types::file::Project> { | ||||
|         // Check the directory. | ||||
|         let project_dir = std::path::Path::new(project_path); | ||||
|         if !project_dir.exists() { | ||||
|             return Err(anyhow::anyhow!("Project directory does not exist: {}", project_path)); | ||||
|         } | ||||
|  | ||||
|         // Make sure it is a directory. | ||||
|         if !project_dir.is_dir() { | ||||
|             return Err(anyhow::anyhow!("Project path is not a directory: {}", project_path)); | ||||
|         } | ||||
|  | ||||
|         let walked = crate::settings::utils::walk_dir(project_dir).await?; | ||||
|  | ||||
|         let mut project = crate::settings::types::file::Project { | ||||
|             file: walked.clone(), | ||||
|             metadata: Some(tokio::fs::metadata(&project_dir).await?.into()), | ||||
|             kcl_file_count: 0, | ||||
|             directory_count: 0, | ||||
|             default_file: crate::settings::types::file::get_default_kcl_file_for_dir(project_dir, walked).await?, | ||||
|         }; | ||||
|  | ||||
|         // Populate the number of KCL files in the project. | ||||
|         project.populate_kcl_file_count()?; | ||||
|  | ||||
|         //Populate the number of directories in the project. | ||||
|         project.populate_directory_count()?; | ||||
|  | ||||
|         Ok(project) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// High level settings. | ||||
| @ -954,196 +839,4 @@ color = 1567.4"#; | ||||
|             .to_string() | ||||
|             .contains("color: Validation error: color")); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_create_new_project_directory_no_initial_code() { | ||||
|         let mut settings = Configuration::default(); | ||||
|         settings.settings.project.directory = | ||||
|             std::env::temp_dir().join(format!("test_project_{}", uuid::Uuid::new_v4())); | ||||
|  | ||||
|         let project_name = format!("test_project_{}", uuid::Uuid::new_v4()); | ||||
|         let project = settings | ||||
|             .create_new_project_directory(&project_name, None) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         assert_eq!(project.file.name, project_name); | ||||
|         assert_eq!( | ||||
|             project.file.path, | ||||
|             settings | ||||
|                 .settings | ||||
|                 .project | ||||
|                 .directory | ||||
|                 .join(&project_name) | ||||
|                 .to_string_lossy() | ||||
|         ); | ||||
|         assert_eq!(project.kcl_file_count, 1); | ||||
|         assert_eq!(project.directory_count, 0); | ||||
|         assert_eq!( | ||||
|             project.default_file, | ||||
|             std::path::Path::new(&project.file.path) | ||||
|                 .join(super::DEFAULT_PROJECT_KCL_FILE) | ||||
|                 .to_string_lossy() | ||||
|         ); | ||||
|  | ||||
|         std::fs::remove_dir_all(&settings.settings.project.directory).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_create_new_project_directory_empty_name() { | ||||
|         let mut settings = Configuration::default(); | ||||
|         settings.settings.project.directory = | ||||
|             std::env::temp_dir().join(format!("test_project_{}", uuid::Uuid::new_v4())); | ||||
|  | ||||
|         let project_name = ""; | ||||
|         let project = settings.create_new_project_directory(project_name, None).await; | ||||
|  | ||||
|         assert!(project.is_err()); | ||||
|         assert_eq!(project.unwrap_err().to_string(), "Project name cannot be empty."); | ||||
|  | ||||
|         std::fs::remove_dir_all(&settings.settings.project.directory).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_create_new_project_directory_with_initial_code() { | ||||
|         let mut settings = Configuration::default(); | ||||
|         settings.settings.project.directory = | ||||
|             std::env::temp_dir().join(format!("test_project_{}", uuid::Uuid::new_v4())); | ||||
|  | ||||
|         let project_name = format!("test_project_{}", uuid::Uuid::new_v4()); | ||||
|         let initial_code = "initial code"; | ||||
|         let project = settings | ||||
|             .create_new_project_directory(&project_name, Some(initial_code)) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         assert_eq!(project.file.name, project_name); | ||||
|         assert_eq!( | ||||
|             project.file.path, | ||||
|             settings | ||||
|                 .settings | ||||
|                 .project | ||||
|                 .directory | ||||
|                 .join(&project_name) | ||||
|                 .to_string_lossy() | ||||
|         ); | ||||
|         assert_eq!(project.kcl_file_count, 1); | ||||
|         assert_eq!(project.directory_count, 0); | ||||
|         assert_eq!( | ||||
|             project.default_file, | ||||
|             std::path::Path::new(&project.file.path) | ||||
|                 .join(super::DEFAULT_PROJECT_KCL_FILE) | ||||
|                 .to_string_lossy() | ||||
|         ); | ||||
|         assert_eq!( | ||||
|             tokio::fs::read_to_string(&project.default_file).await.unwrap(), | ||||
|             initial_code | ||||
|         ); | ||||
|  | ||||
|         std::fs::remove_dir_all(&settings.settings.project.directory).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_list_projects() { | ||||
|         let mut settings = Configuration::default(); | ||||
|         settings.settings.project.directory = | ||||
|             std::env::temp_dir().join(format!("test_project_{}", uuid::Uuid::new_v4())); | ||||
|  | ||||
|         let project_name = format!("test_project_{}", uuid::Uuid::new_v4()); | ||||
|         let project = settings | ||||
|             .create_new_project_directory(&project_name, None) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         let projects = settings.list_projects().await.unwrap(); | ||||
|         assert_eq!(projects.len(), 1); | ||||
|         assert_eq!(projects[0].file.name, project_name); | ||||
|         assert_eq!(projects[0].file.path, project.file.path); | ||||
|         assert_eq!(projects[0].kcl_file_count, 1); | ||||
|         assert_eq!(projects[0].directory_count, 0); | ||||
|         assert_eq!(projects[0].default_file, project.default_file); | ||||
|  | ||||
|         std::fs::remove_dir_all(&settings.settings.project.directory).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_list_projects_with_rando_files() { | ||||
|         let mut settings = Configuration::default(); | ||||
|         settings.settings.project.directory = | ||||
|             std::env::temp_dir().join(format!("test_project_{}", uuid::Uuid::new_v4())); | ||||
|  | ||||
|         let project_name = format!("test_project_{}", uuid::Uuid::new_v4()); | ||||
|         let project = settings | ||||
|             .create_new_project_directory(&project_name, None) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         // Create a random file in the root project directory. | ||||
|         let random_file = std::path::Path::new(&settings.settings.project.directory).join("random_file.txt"); | ||||
|         tokio::fs::write(&random_file, "random file").await.unwrap(); | ||||
|  | ||||
|         let projects = settings.list_projects().await.unwrap(); | ||||
|         assert_eq!(projects.len(), 1); | ||||
|         assert_eq!(projects[0].file.name, project_name); | ||||
|         assert_eq!(projects[0].file.path, project.file.path); | ||||
|         assert_eq!(projects[0].kcl_file_count, 1); | ||||
|         assert_eq!(projects[0].directory_count, 0); | ||||
|         assert_eq!(projects[0].default_file, project.default_file); | ||||
|  | ||||
|         std::fs::remove_dir_all(&settings.settings.project.directory).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_list_projects_with_hidden_dir() { | ||||
|         let mut settings = Configuration::default(); | ||||
|         settings.settings.project.directory = | ||||
|             std::env::temp_dir().join(format!("test_project_{}", uuid::Uuid::new_v4())); | ||||
|  | ||||
|         let project_name = format!("test_project_{}", uuid::Uuid::new_v4()); | ||||
|         let project = settings | ||||
|             .create_new_project_directory(&project_name, None) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         // Create a hidden directory in the project directory. | ||||
|         let hidden_dir = std::path::Path::new(&settings.settings.project.directory).join(".git"); | ||||
|         tokio::fs::create_dir_all(&hidden_dir).await.unwrap(); | ||||
|  | ||||
|         let projects = settings.list_projects().await.unwrap(); | ||||
|         assert_eq!(projects.len(), 1); | ||||
|         assert_eq!(projects[0].file.name, project_name); | ||||
|         assert_eq!(projects[0].file.path, project.file.path); | ||||
|         assert_eq!(projects[0].kcl_file_count, 1); | ||||
|         assert_eq!(projects[0].directory_count, 0); | ||||
|         assert_eq!(projects[0].default_file, project.default_file); | ||||
|  | ||||
|         std::fs::remove_dir_all(&settings.settings.project.directory).unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_list_projects_with_dir_not_containing_kcl_file() { | ||||
|         let mut settings = Configuration::default(); | ||||
|         settings.settings.project.directory = | ||||
|             std::env::temp_dir().join(format!("test_project_{}", uuid::Uuid::new_v4())); | ||||
|  | ||||
|         let project_name = format!("test_project_{}", uuid::Uuid::new_v4()); | ||||
|         let project = settings | ||||
|             .create_new_project_directory(&project_name, None) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         // Create a directory in the project directory that doesn't contain a KCL file. | ||||
|         let random_dir = std::path::Path::new(&settings.settings.project.directory).join("random_dir"); | ||||
|         tokio::fs::create_dir_all(&random_dir).await.unwrap(); | ||||
|  | ||||
|         let projects = settings.list_projects().await.unwrap(); | ||||
|         assert_eq!(projects.len(), 1); | ||||
|         assert_eq!(projects[0].file.name, project_name); | ||||
|         assert_eq!(projects[0].file.path, project.file.path); | ||||
|         assert_eq!(projects[0].kcl_file_count, 1); | ||||
|         assert_eq!(projects[0].directory_count, 0); | ||||
|         assert_eq!(projects[0].default_file, project.default_file); | ||||
|  | ||||
|         std::fs::remove_dir_all(&settings.settings.project.directory).unwrap(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -1,93 +0,0 @@ | ||||
| //! Utility functions for settings. | ||||
|  | ||||
| use std::path::Path; | ||||
|  | ||||
| use anyhow::Result; | ||||
| use clap::ValueEnum; | ||||
|  | ||||
| use crate::settings::types::file::FileEntry; | ||||
|  | ||||
| lazy_static::lazy_static! { | ||||
|  | ||||
|     pub static ref IMPORT_FILE_EXTENSIONS: Vec<String> = { | ||||
|         let mut import_file_extensions = vec!["stp".to_string(), "glb".to_string(), "fbxb".to_string()]; | ||||
|         let named_extensions = kittycad::types::FileImportFormat::value_variants() | ||||
|             .iter() | ||||
|             .map(|x| format!("{}", x)) | ||||
|             .collect::<Vec<String>>(); | ||||
|         // Add all the default import formats. | ||||
|         import_file_extensions.extend_from_slice(&named_extensions); | ||||
|         import_file_extensions | ||||
|     }; | ||||
|  | ||||
|     pub static ref RELEVANT_EXTENSIONS: Vec<String> = { | ||||
|         let mut relevant_extensions = IMPORT_FILE_EXTENSIONS.clone(); | ||||
|         relevant_extensions.push("kcl".to_string()); | ||||
|         relevant_extensions | ||||
|     }; | ||||
| } | ||||
|  | ||||
| /// Walk a directory recursively and return a list of all files. | ||||
| #[async_recursion::async_recursion] | ||||
| pub async fn walk_dir<P>(dir: P) -> Result<FileEntry> | ||||
| where | ||||
|     P: AsRef<Path> + Send, | ||||
| { | ||||
|     // Make sure the path is a directory. | ||||
|     if !dir.as_ref().is_dir() { | ||||
|         return Err(anyhow::anyhow!("Path `{}` is not a directory", dir.as_ref().display())); | ||||
|     } | ||||
|  | ||||
|     // Make sure the directory exists. | ||||
|     if !dir.as_ref().exists() { | ||||
|         return Err(anyhow::anyhow!("Directory `{}` does not exist", dir.as_ref().display())); | ||||
|     } | ||||
|  | ||||
|     let mut entry = FileEntry { | ||||
|         name: dir | ||||
|             .as_ref() | ||||
|             .file_name() | ||||
|             .ok_or_else(|| anyhow::anyhow!("No file name"))? | ||||
|             .to_string_lossy() | ||||
|             .to_string(), | ||||
|         path: dir.as_ref().display().to_string(), | ||||
|         children: None, | ||||
|     }; | ||||
|  | ||||
|     let mut children = vec![]; | ||||
|  | ||||
|     let mut entries = tokio::fs::read_dir(&dir.as_ref()).await?; | ||||
|     while let Some(e) = entries.next_entry().await? { | ||||
|         // ignore hidden files and directories (starting with a dot) | ||||
|         if e.file_name().to_string_lossy().starts_with('.') { | ||||
|             continue; | ||||
|         } | ||||
|  | ||||
|         if e.file_type().await?.is_dir() { | ||||
|             children.push(walk_dir(e.path()).await?); | ||||
|         } else { | ||||
|             if !is_relevant_file(e.path())? { | ||||
|                 continue; | ||||
|             } | ||||
|             children.push(FileEntry { | ||||
|                 name: e.file_name().to_string_lossy().to_string(), | ||||
|                 path: e.path().display().to_string(), | ||||
|                 children: None, | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // We don't set this to none if there are no children, because it's a directory. | ||||
|     entry.children = Some(children); | ||||
|  | ||||
|     Ok(entry) | ||||
| } | ||||
|  | ||||
| /// Check if a file is relevant for the application. | ||||
| fn is_relevant_file<P: AsRef<Path>>(path: P) -> Result<bool> { | ||||
|     if let Some(ext) = path.as_ref().extension() { | ||||
|         Ok(RELEVANT_EXTENSIONS.contains(&ext.to_string_lossy().to_string())) | ||||
|     } else { | ||||
|         Ok(false) | ||||
|     } | ||||
| } | ||||
| @ -1713,7 +1713,7 @@ | ||||
|     semver "^7.1.3" | ||||
|     yargs-parser "^21.1.1" | ||||
|  | ||||
| "@electron/rebuild@^3.2.10": | ||||
| "@electron/rebuild@^3.2.10", "@electron/rebuild@^3.6.0": | ||||
|   version "3.6.0" | ||||
|   resolved "https://registry.yarnpkg.com/@electron/rebuild/-/rebuild-3.6.0.tgz#60211375a5f8541a71eb07dd2f97354ad0b2b96f" | ||||
|   integrity sha512-zF4x3QupRU3uNGaP5X1wjpmcjfw1H87kyqZ00Tc3HvriV+4gmOGuvQjGNkrJuXdsApssdNyVwLsy+TaeTGGcVw== | ||||
| @ -2615,6 +2615,11 @@ | ||||
|   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" | ||||
|   integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== | ||||
|  | ||||
| "@types/minimist@^1.2.5": | ||||
|   version "1.2.5" | ||||
|   resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e" | ||||
|   integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== | ||||
|  | ||||
| "@types/mocha@^10.0.6": | ||||
|   version "10.0.7" | ||||
|   resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.7.tgz#4c620090f28ca7f905a94b706f74dc5b57b44f2f" | ||||
|  | ||||
		Reference in New Issue
	
	Block a user