Files
modeling-app/src/lib/desktop.ts
Jonathan Tran 51f0b669a4 fix: only count something as a directory if it has children (#4595)
* fix: only count something as a directory if it has children

* fix: playwright tests

* fix: return 0 if you cant find the projectfolder

* fix: remove folder count from e2e tests since it is unused currently

---------

Co-authored-by: Tom Pridham <pridham.tom@gmail.com>
2024-12-02 15:16:43 -05:00

624 lines
17 KiB
TypeScript

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<string> {
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<Configuration>
): Promise<string | undefined> {
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<Configuration> | Error
): Promise<Project> {
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<Configuration> | Error
): Promise<Project[]> {
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<Project> {
// 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<void> {
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<DeepPartial<ProjectConfiguration>> => {
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<Configuration> = {
...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<Configuration> = {
...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<Project | undefined> => {
return Promise.resolve(appStateStore)
}
export const setState = async (state: Project | undefined): Promise<void> => {
appStateStore = state
}
export const getUser = async (
token: string,
hostname: string
): Promise<Models['User_type']> => {
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'))
}