import { err } from 'lib/trap' import { Models } from '@kittycad/lib' import { Project, FileEntry } from 'lib/project' import { defaultAppSettings, parseAppSettings, parseProjectSettings, } from 'lang/wasm' import { PROJECT_ENTRYPOINT, PROJECT_FOLDER, PROJECT_SETTINGS_FILE_NAME, SETTINGS_FILE_NAME, TELEMETRY_FILE_NAME, TELEMETRY_RAW_FILE_NAME, TOKEN_FILE_NAME, } from './constants' import { DeepPartial } from './types' import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration' import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' export async function renameProjectDirectory( projectPath: string, newName: string ): Promise { if (!newName) { return Promise.reject(new Error(`New name for project cannot be empty`)) } try { await window.electron.stat(projectPath) } catch (e) { if (e === 'ENOENT') { return Promise.reject(new Error(`Path ${projectPath} is not a directory`)) } } // Make sure the new name does not exist. const newPath = window.electron.path.join( window.electron.path.dirname(projectPath), newName ) try { await window.electron.stat(newPath) // If we get here it means the stat succeeded and there's a file already // with the same name... return Promise.reject( new Error( `Path ${newPath} already exists, cannot rename to an existing path` ) ) } catch (e) { // Otherwise if it failed and the failure is "it doesn't exist" then rename it! if (e === 'ENOENT') { await window.electron.rename(projectPath, newPath) return newPath } } return Promise.reject(new Error('Unreachable')) } export async function ensureProjectDirectoryExists( config: DeepPartial ): Promise { const projectDir = config.settings?.app?.project_directory || config.settings?.project?.directory if (!projectDir) { console.error('projectDir is falsey', config) return Promise.reject(new Error('projectDir is falsey')) } try { await window.electron.stat(projectDir) } catch (e) { if (e === 'ENOENT') { await window.electron.mkdir(projectDir, { recursive: true }) } } return projectDir } export async function createNewProjectDirectory( projectName: string, initialCode?: string, configuration?: DeepPartial | Error ): Promise { if (!configuration) { configuration = await readAppSettingsFile() } if (err(configuration)) return Promise.reject(configuration) const mainDir = await ensureProjectDirectoryExists(configuration) if (!projectName) { return Promise.reject('Project name cannot be empty.') } if (!mainDir) { return Promise.reject(new Error('mainDir is falsey')) } const projectDir = window.electron.path.join(mainDir, projectName) try { await window.electron.stat(projectDir) } catch (e) { if (e === 'ENOENT') { await window.electron.mkdir(projectDir, { recursive: true }) } } const projectFile = window.electron.path.join(projectDir, PROJECT_ENTRYPOINT) await window.electron.writeFile(projectFile, initialCode ?? '') const metadata = await window.electron.stat(projectFile) return { path: projectDir, name: projectName, // We don't need to recursively get all files in the project directory. // Because we just created it and it's empty. children: null, default_file: projectFile, metadata, kcl_file_count: 1, directory_count: 0, } } export async function listProjects( configuration?: DeepPartial | Error ): Promise { if (configuration === undefined) { configuration = await readAppSettingsFile() } if (err(configuration)) return Promise.reject(configuration) const projectDir = await ensureProjectDirectoryExists(configuration) const projects = [] if (!projectDir) return Promise.reject(new Error('projectDir was falsey')) const entries = await window.electron.readdir(projectDir) for (let entry of entries) { // Skip directories that start with a dot if (entry.startsWith('.')) { continue } const projectPath = window.electron.path.join(projectDir, entry) // if it's not a directory ignore. const isDirectory = await window.electron.statIsDirectory(projectPath) if (!isDirectory) { continue } const project = await getProjectInfo(projectPath) // Needs at least one file to be added to the projects list if (project.kcl_file_count === 0) { continue } projects.push(project) } return projects } const IMPORT_FILE_EXTENSIONS = [ // TODO Use ImportFormat enum 'stp', 'glb', 'fbxb', 'kcl', ] const isRelevantFile = (filename: string): boolean => IMPORT_FILE_EXTENSIONS.some((ext) => filename.endsWith('.' + ext)) const collectAllFilesRecursiveFrom = async (path: string) => { // Make sure the filesystem object exists. try { await window.electron.stat(path) } catch (e) { if (e === 'ENOENT') { return Promise.reject(new Error(`Directory ${path} does not exist`)) } } // Make sure the path is a directory. const isPathDir = await window.electron.statIsDirectory(path) if (!isPathDir) { return Promise.reject(new Error(`Path ${path} is not a directory`)) } const name = window.electron.path.basename(path) let entry: FileEntry = { name: name, path, children: [], } const children = [] const entries = await window.electron.readdir(path) // Sort all entries so files come first and directories last // so a top-most KCL file is returned first. entries.sort((a: string, b: string) => { if (a.endsWith('.kcl') && !b.endsWith('.kcl')) { return -1 } if (!a.endsWith('.kcl') && b.endsWith('.kcl')) { return 1 } return 0 }) for (let e of entries) { // ignore hidden files and directories (starting with a dot) if (e.indexOf('.') === 0) { continue } const ePath = window.electron.path.join(path, e) const isEDir = await window.electron.statIsDirectory(ePath) if (isEDir) { const subChildren = await collectAllFilesRecursiveFrom(ePath) children.push(subChildren) } else { if (!isRelevantFile(ePath)) { continue } children.push( /* FileEntry */ { name: e, path: ePath, children: null, } ) } } // We don't set this to none if there are no children, because it's a directory. entry.children = children return entry } export async function getDefaultKclFileForDir( projectDir: string, file: FileEntry ) { // Make sure the dir is a directory. const isFileEntryDir = await window.electron.statIsDirectory(projectDir) if (!isFileEntryDir) { return Promise.reject(new Error(`Path ${projectDir} is not a directory`)) } let defaultFilePath = window.electron.path.join( projectDir, PROJECT_ENTRYPOINT ) try { await window.electron.stat(defaultFilePath) } catch (e) { if (e === 'ENOENT') { // Find a kcl file in the directory. if (file.children) { for (let entry of file.children) { if (entry.name.endsWith('.kcl')) { return window.electron.path.join(projectDir, entry.name) } else if ((entry.children?.length ?? 0) > 0) { // Recursively find a kcl file in the directory. return getDefaultKclFileForDir(entry.path, entry) } } // If we didn't find a kcl file, create one. await window.electron.writeFile(defaultFilePath, '') return defaultFilePath } } } if (!file.children) { return file.name } return defaultFilePath } const kclFileCount = (file: FileEntry) => { let count = 0 if (file.children) { for (let entry of file.children) { if (entry.name.endsWith('.kcl')) { count += 1 } else { count += kclFileCount(entry) } } } return count } /// Populate the number of directories in the project. const directoryCount = (file: FileEntry) => { let count = 0 if (file.children) { for (let entry of file.children) { // We only want to count FileEntries with children, e.g. folders if (entry.children !== null) { count += 1 } directoryCount(entry) } } return count } export async function getProjectInfo(projectPath: string): Promise { // Check the directory. try { await window.electron.stat(projectPath) } catch (e) { if (e === 'ENOENT') { return Promise.reject( new Error(`Project directory does not exist: ${projectPath}`) ) } } // Make sure it is a directory. const projectPathIsDir = await window.electron.statIsDirectory(projectPath) if (!projectPathIsDir) { return Promise.reject( new Error(`Project path is not a directory: ${projectPath}`) ) } let walked = await collectAllFilesRecursiveFrom(projectPath) let default_file = await getDefaultKclFileForDir(projectPath, walked) const metadata = await window.electron.stat(projectPath) let project = { ...walked, // We need to map from node fs.Stats to FileMetadata metadata: { modified: metadata.mtimeMs, accessed: metadata.atimeMs, created: metadata.ctimeMs, // this is not used anywhere and we use statIsDirectory in other places // that need to know if it's a file or directory. type: null, size: metadata.size, permission: metadata.mode, }, kcl_file_count: 0, directory_count: 0, default_file, } // Populate the number of KCL files in the project. project.kcl_file_count = kclFileCount(project) //Populate the number of directories in the project. project.directory_count = directoryCount(project) return project } // Write project settings file. export async function writeProjectSettingsFile( projectPath: string, tomlStr: string ): Promise { const projectSettingsFilePath = await getProjectSettingsFilePath(projectPath) if (err(tomlStr)) return Promise.reject(tomlStr) return window.electron.writeFile(projectSettingsFilePath, tomlStr) } // Since we want backwards compatibility with the old settings file, we need to // rename the folder for macos. const MACOS_APP_NAME = 'dev.zoo.modeling-app' const getAppFolderName = () => { if (window.electron.os.isMac || window.electron.os.isWindows) { return MACOS_APP_NAME } return window.electron.packageJson.name } export const getAppSettingsFilePath = async () => { const isTestEnv = window.electron.process.env.IS_PLAYWRIGHT === 'true' const testSettingsPath = window.electron.process.env.TEST_SETTINGS_FILE_KEY const appConfig = await window.electron.getPath('appData') const fullPath = isTestEnv ? testSettingsPath : window.electron.path.join(appConfig, getAppFolderName()) try { await window.electron.stat(fullPath) } catch (e) { // File/path doesn't exist if (e === 'ENOENT') { await window.electron.mkdir(fullPath, { recursive: true }) } } return window.electron.path.join(fullPath, SETTINGS_FILE_NAME) } const getTokenFilePath = async () => { const isTestEnv = window.electron.process.env.IS_PLAYWRIGHT === 'true' const testSettingsPath = window.electron.process.env.TEST_SETTINGS_FILE_KEY const appConfig = await window.electron.getPath('appData') const fullPath = isTestEnv ? testSettingsPath : window.electron.path.join(appConfig, getAppFolderName()) try { await window.electron.stat(fullPath) } catch (e) { // File/path doesn't exist if (e === 'ENOENT') { await window.electron.mkdir(fullPath, { recursive: true }) } } return window.electron.path.join(fullPath, TOKEN_FILE_NAME) } const getTelemetryFilePath = async () => { const appConfig = await window.electron.getPath('appData') const fullPath = window.electron.path.join(appConfig, getAppFolderName()) try { await window.electron.stat(fullPath) } catch (e) { // File/path doesn't exist if (e === 'ENOENT') { await window.electron.mkdir(fullPath, { recursive: true }) } } return window.electron.path.join(fullPath, TELEMETRY_FILE_NAME) } const getRawTelemetryFilePath = async () => { const appConfig = await window.electron.getPath('appData') const fullPath = window.electron.path.join(appConfig, getAppFolderName()) try { await window.electron.stat(fullPath) } catch (e) { // File/path doesn't exist if (e === 'ENOENT') { await window.electron.mkdir(fullPath, { recursive: true }) } } return window.electron.path.join(fullPath, TELEMETRY_RAW_FILE_NAME) } const getProjectSettingsFilePath = async (projectPath: string) => { try { await window.electron.stat(projectPath) } catch (e) { if (e === 'ENOENT') { await window.electron.mkdir(projectPath, { recursive: true }) } } return window.electron.path.join(projectPath, PROJECT_SETTINGS_FILE_NAME) } export const getInitialDefaultDir = async () => { if (!window.electron) { return '' } const dir = await window.electron.getPath('documents') return window.electron.path.join(dir, PROJECT_FOLDER) } export const readProjectSettingsFile = async ( projectPath: string ): Promise> => { let settingsPath = await getProjectSettingsFilePath(projectPath) // Check if this file exists. try { await window.electron.stat(settingsPath) } catch (e) { if (e === 'ENOENT') { // Return the default configuration. return {} } } const configToml = await window.electron.readFile(settingsPath, { encoding: 'utf-8', }) const configObj = parseProjectSettings(configToml) if (err(configObj)) { return Promise.reject(configObj) } return configObj } /** * Read the app settings file, or creates an initial one if it doesn't exist. */ export const readAppSettingsFile = async () => { let settingsPath = await getAppSettingsFilePath() const initialProjectDirConfig: DeepPartial< Configuration['settings']['project'] > = { directory: await getInitialDefaultDir() } // The file exists, read it and parse it. if (window.electron.exists(settingsPath)) { const configToml = await window.electron.readFile(settingsPath, { encoding: 'utf-8', }) const parsedAppConfig = parseAppSettings(configToml) if (err(parsedAppConfig)) { return Promise.reject(parsedAppConfig) } const hasProjectDirectorySetting = parsedAppConfig.settings?.project?.directory || parsedAppConfig.settings?.app?.project_directory if (hasProjectDirectorySetting) { return parsedAppConfig } else { // inject the default project directory setting const mergedConfig: DeepPartial = { ...parsedAppConfig, settings: { ...parsedAppConfig.settings, project: Object.assign( {}, parsedAppConfig.settings?.project, initialProjectDirConfig ), }, } return mergedConfig } } // The file doesn't exist, create a new one. const defaultAppConfig = defaultAppSettings() if (err(defaultAppConfig)) { return Promise.reject(defaultAppConfig) } // inject the default project directory setting const mergedDefaultConfig: DeepPartial = { ...defaultAppConfig, settings: { ...defaultAppConfig.settings, project: Object.assign( {}, defaultAppConfig.settings?.project, initialProjectDirConfig ), }, } return mergedDefaultConfig } export const writeAppSettingsFile = async (tomlStr: string) => { const appSettingsFilePath = await getAppSettingsFilePath() if (err(tomlStr)) return Promise.reject(tomlStr) return window.electron.writeFile(appSettingsFilePath, tomlStr) } export const readTokenFile = async () => { let settingsPath = await getTokenFilePath() if (window.electron.exists(settingsPath)) { const token: string = await window.electron.readFile(settingsPath, { encoding: 'utf-8', }) if (!token) return '' return token } return '' } export const writeTokenFile = async (token: string) => { const tokenFilePath = await getTokenFilePath() if (err(token)) return Promise.reject(token) return window.electron.writeFile(tokenFilePath, token) } export const writeTelemetryFile = async (content: string) => { const telemetryFilePath = await getTelemetryFilePath() if (err(content)) return Promise.reject(content) return window.electron.writeFile(telemetryFilePath, content) } export const writeRawTelemetryFile = async (content: string) => { const rawTelemetryFilePath = await getRawTelemetryFilePath() if (err(content)) return Promise.reject(content) return window.electron.writeFile(rawTelemetryFilePath, content) } let appStateStore: Project | undefined = undefined export const getState = async (): Promise => { return Promise.resolve(appStateStore) } export const setState = async (state: Project | undefined): Promise => { appStateStore = state } export const getUser = async ( token: string, hostname: string ): Promise => { try { const user = await window.electron.kittycad('users.get_user_self', { client: { token }, }) return user } catch (e) { console.error(e) } return Promise.reject(new Error('unreachable')) }