diff --git a/.github/workflows/cargo-check.yml b/.github/workflows/cargo-check.yml index 4548a18557..1074c2d2d0 100644 --- a/.github/workflows/cargo-check.yml +++ b/.github/workflows/cargo-check.yml @@ -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 diff --git a/interface.d.ts b/interface.d.ts index f4a879cabc..37eee378e0 100644 --- a/interface.d.ts +++ b/interface.d.ts @@ -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 packageJson: { name: string } diff --git a/package.json b/package.json index d27d28aa33..38e6923924 100644 --- a/package.json +++ b/package.json @@ -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", @@ -89,7 +90,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", @@ -129,6 +130,7 @@ "@electron-forge/plugin-fuses": "^7.4.0", "@electron-forge/plugin-vite": "^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", @@ -137,6 +139,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", diff --git a/src/Router.tsx b/src/Router.tsx index a7a80af973..4c9c4d228f 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -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) ) } } diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx index 13ec6ca757..9c3acad3c5 100644 --- a/src/components/FileTree.tsx +++ b/src/components/FileTree.tsx @@ -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})` diff --git a/src/components/LspProvider.tsx b/src/components/LspProvider.tsx index 345e804727..444b245560 100644 --- a/src/components/LspProvider.tsx +++ b/src/components/LspProvider.tsx @@ -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, diff --git a/src/components/ProjectCard/ProjectCard.tsx b/src/components/ProjectCard/ProjectCard.tsx index a21d98829f..2ccc28c3e1 100644 --- a/src/components/ProjectCard/ProjectCard.tsx +++ b/src/components/ProjectCard/ProjectCard.tsx @@ -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, diff --git a/src/components/ProjectCard/ProjectCardRenameForm.tsx b/src/components/ProjectCard/ProjectCardRenameForm.tsx index 837b05db1f..18dc4e16fa 100644 --- a/src/components/ProjectCard/ProjectCardRenameForm.tsx +++ b/src/components/ProjectCard/ProjectCardRenameForm.tsx @@ -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 { project: Project diff --git a/src/components/ProjectSearchBar.tsx b/src/components/ProjectSearchBar.tsx index eb1674ee8e..2eabd30358 100644 --- a/src/components/ProjectSearchBar.tsx +++ b/src/components/ProjectSearchBar.tsx @@ -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' diff --git a/src/components/ProjectSidebarMenu.test.tsx b/src/components/ProjectSidebarMenu.test.tsx index 281c0527c3..ffce1605c7 100644 --- a/src/components/ProjectSidebarMenu.test.tsx +++ b/src/components/ProjectSidebarMenu.test.tsx @@ -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 = { diff --git a/src/lib/desktop.ts b/src/lib/desktop.ts index 0190935cd3..e5a1cf6d2d 100644 --- a/src/lib/desktop.ts +++ b/src/lib/desktop.ts @@ -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 => { - return Promise.resolve(appStateStore) -} - -export const setState = async ( - state: ProjectState | undefined -): Promise => { - appStateStore = state -} - export const getUser = async ( token: string, hostname: string diff --git a/src/lib/desktopFS.ts b/src/lib/desktopFS.ts index 256c4da869..56093fe66d 100644 --- a/src/lib/desktopFS.ts +++ b/src/lib/desktopFS.ts @@ -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, diff --git a/src/lib/getCurrentProjectFile.test.ts b/src/lib/getCurrentProjectFile.test.ts new file mode 100644 index 0000000000..78b0cea52f --- /dev/null +++ b/src/lib/getCurrentProjectFile.test.ts @@ -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 }) + } + }) +}) diff --git a/src/lib/getCurrentProjectFile.ts b/src/lib/getCurrentProjectFile.ts new file mode 100644 index 0000000000..27d25c4bb8 --- /dev/null +++ b/src/lib/getCurrentProjectFile.ts @@ -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 { + // 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 +} diff --git a/src/lib/project.d.ts b/src/lib/project.d.ts new file mode 100644 index 0000000000..987f231645 --- /dev/null +++ b/src/lib/project.d.ts @@ -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 | 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 | null +} diff --git a/src/lib/sorting.ts b/src/lib/sorting.ts index 64c52b2648..63306f11e2 100644 --- a/src/lib/sorting.ts +++ b/src/lib/sorting.ts @@ -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' diff --git a/src/lib/types.ts b/src/lib/types.ts index f2775623a1..b8cfc537c0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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 diff --git a/src/machines/fileMachine.ts b/src/machines/fileMachine.ts index 23de3fc53d..b3c114e296 100644 --- a/src/machines/fileMachine.ts +++ b/src/machines/fileMachine.ts @@ -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( { diff --git a/src/machines/homeMachine.ts b/src/machines/homeMachine.ts index ee79c4340a..555ea171d6 100644 --- a/src/machines/homeMachine.ts +++ b/src/machines/homeMachine.ts @@ -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( { diff --git a/src/main.ts b/src/main.ts index beb329b2af..2ccb539d22 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,11 @@ import { Issuer } from 'openid-client' import { Bonjour, Service } from 'bonjour-service' // @ts-ignore: TS1343 import * as kittycad from '@kittycad/lib/import' +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' @@ -22,6 +27,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, @@ -159,3 +168,102 @@ ipcMain.handle('find_machine_api', () => { ) }) }) + +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 + } +} diff --git a/src/preload.ts b/src/preload.ts index 9683aa5432..9a19fdbf5e 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -60,6 +60,9 @@ const listMachines = async (): Promise => { const getMachineApiIp = async (): Promise => ipcRenderer.invoke('find_machine_api') +const loadProjectAtStartup = async (): Promise => + 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), diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 2d3ec185ce..69f44793e7 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -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. diff --git a/src/wasm-lib/Cargo.lock b/src/wasm-lib/Cargo.lock index 8103235680..0f25a2ddb0 100644 --- a/src/wasm-lib/Cargo.lock +++ b/src/wasm-lib/Cargo.lock @@ -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", diff --git a/src/wasm-lib/Cargo.toml b/src/wasm-lib/Cargo.toml index 99c8238e54..1d04dc49cd 100644 --- a/src/wasm-lib/Cargo.toml +++ b/src/wasm-lib/Cargo.toml @@ -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" } diff --git a/src/wasm-lib/kcl/Cargo.toml b/src/wasm-lib/kcl/Cargo.toml index 70e75396c4..c4face2e76 100644 --- a/src/wasm-lib/kcl/Cargo.toml +++ b/src/wasm-lib/kcl/Cargo.toml @@ -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. diff --git a/src/wasm-lib/kcl/fuzz/Cargo.lock b/src/wasm-lib/kcl/fuzz/Cargo.lock index abf5aaa849..f1e57855cb 100644 --- a/src/wasm-lib/kcl/fuzz/Cargo.lock +++ b/src/wasm-lib/kcl/fuzz/Cargo.lock @@ -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" diff --git a/src/wasm-lib/kcl/src/settings/mod.rs b/src/wasm-lib/kcl/src/settings/mod.rs index cd628d83b1..e922d282d1 100644 --- a/src/wasm-lib/kcl/src/settings/mod.rs +++ b/src/wasm-lib/kcl/src/settings/mod.rs @@ -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; diff --git a/src/wasm-lib/kcl/src/settings/types/file.rs b/src/wasm-lib/kcl/src/settings/types/file.rs deleted file mode 100644 index 91c9e4f736..0000000000 --- a/src/wasm-lib/kcl/src/settings/types/file.rs +++ /dev/null @@ -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, -} - -impl ProjectState { - /// Create a new project state from a path. - #[cfg(not(target_arch = "wasm32"))] - pub async fn new_from_path(path: PathBuf) -> Result { - // 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, - #[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>(path: P) -> Result { - // 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

(dir: P, file: FileEntry) -> Result -where - P: AsRef + 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

(path: P, new_name: &str) -> Result -where - P: AsRef + 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>, -} - -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>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub created: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub r#type: Option, - #[serde(default)] - #[ts(type = "number")] - pub size: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub modified: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub permission: Option, -} - -/// 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 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 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 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(); - } -} diff --git a/src/wasm-lib/kcl/src/settings/types/mod.rs b/src/wasm-lib/kcl/src/settings/types/mod.rs index 48d2a3709a..91189a57a4 100644 --- a/src/wasm-lib/kcl/src/settings/types/mod.rs +++ b/src/wasm-lib/kcl/src/settings/types/mod.rs @@ -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 { - 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 { - 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> { - // 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 { - // 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(); - } } diff --git a/src/wasm-lib/kcl/src/settings/utils.rs b/src/wasm-lib/kcl/src/settings/utils.rs deleted file mode 100644 index 7c44728171..0000000000 --- a/src/wasm-lib/kcl/src/settings/utils.rs +++ /dev/null @@ -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 = { - 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::>(); - // Add all the default import formats. - import_file_extensions.extend_from_slice(&named_extensions); - import_file_extensions - }; - - pub static ref RELEVANT_EXTENSIONS: Vec = { - 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

(dir: P) -> Result -where - P: AsRef + 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>(path: P) -> Result { - if let Some(ext) = path.as_ref().extension() { - Ok(RELEVANT_EXTENSIONS.contains(&ext.to_string_lossy().to_string())) - } else { - Ok(false) - } -} diff --git a/yarn.lock b/yarn.lock index 7c235faeef..3c467e7886 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1667,7 +1667,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== @@ -2525,6 +2525,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"