Most FS functions work now

This commit is contained in:
49lf
2024-07-30 15:58:27 -04:00
parent 397eb9bf5a
commit 1a1e358238
23 changed files with 235 additions and 138 deletions

View File

@ -65,6 +65,7 @@
"xstate": "^4.38.2" "xstate": "^4.38.2"
}, },
"scripts": { "scripts": {
"start": "vite",
"start:prod": "vite preview --port=3000", "start:prod": "vite preview --port=3000",
"serve": "vite serve --port=3000", "serve": "vite serve --port=3000",
"build": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && source \"$HOME/.cargo/env\" && curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y && yarn build:wasm && vite build", "build": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && source \"$HOME/.cargo/env\" && curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y && yarn build:wasm && vite build",

View File

@ -15,7 +15,6 @@ import {
} from 'xstate' } from 'xstate'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { fileMachine } from 'machines/fileMachine' import { fileMachine } from 'machines/fileMachine'
import { mkdir, remove, rename, create } from '@tauri-apps/plugin-fs'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { join, sep } from '@tauri-apps/api/path' import { join, sep } from '@tauri-apps/api/path'
import { DEFAULT_FILE_NAME, FILE_EXT } from 'lib/constants' import { DEFAULT_FILE_NAME, FILE_EXT } from 'lib/constants'
@ -87,7 +86,7 @@ export const FileMachineProvider = ({
services: { services: {
readFiles: async (context: ContextFrom<typeof fileMachine>) => { readFiles: async (context: ContextFrom<typeof fileMachine>) => {
const newFiles = isDesktop() const newFiles = isDesktop()
? (await getProjectInfo(context.project.file.path)).children ? (await getProjectInfo(context.project.path)).children
: [] : []
return { return {
...context.project, ...context.project,
@ -99,15 +98,15 @@ export const FileMachineProvider = ({
let createdPath: string let createdPath: string
if (event.data.makeDir) { if (event.data.makeDir) {
createdPath = await join(context.selectedDirectory.path, createdName) createdPath = window.electron.path.join(context.selectedDirectory.path, createdName)
await mkdir(createdPath) await window.electron.mkdir(createdPath)
} else { } else {
createdPath = createdPath =
context.selectedDirectory.path + context.selectedDirectory.path +
window.electron.path.sep + window.electron.path.sep +
createdName + createdName +
(createdName.endsWith(FILE_EXT) ? '' : FILE_EXT) (createdName.endsWith(FILE_EXT) ? '' : FILE_EXT)
await create(createdPath) await window.electron.writeFile(createdPath, '')
} }
return { return {
@ -121,14 +120,15 @@ export const FileMachineProvider = ({
) => { ) => {
const { oldName, newName, isDir } = event.data const { oldName, newName, isDir } = event.data
const name = newName ? newName : DEFAULT_FILE_NAME const name = newName ? newName : DEFAULT_FILE_NAME
const oldPath = await join(context.selectedDirectory.path, oldName) const oldPath = window.electron.path.join(context.selectedDirectory.path, oldName)
const newDirPath = await join(context.selectedDirectory.path, name) const newDirPath = window.electron.path.join(context.selectedDirectory.path, name)
const newPath = const newPath =
newDirPath + (name.endsWith(FILE_EXT) || isDir ? '' : FILE_EXT) newDirPath + (name.endsWith(FILE_EXT) || isDir ? '' : FILE_EXT)
await rename(oldPath, newPath, {}) await window.electron.rename(oldPath, newPath, {})
if (oldPath === file?.path && project?.path) { const currentFilePath = window.electron.path.join(file.path, file.name)
if (oldPath === currentFilePath && project?.path) {
// If we just renamed the current file, navigate to the new path // If we just renamed the current file, navigate to the new path
navigate(paths.FILE + '/' + encodeURIComponent(newPath)) navigate(paths.FILE + '/' + encodeURIComponent(newPath))
} else if (file?.path.includes(oldPath)) { } else if (file?.path.includes(oldPath)) {
@ -153,11 +153,11 @@ export const FileMachineProvider = ({
const isDir = !!event.data.children const isDir = !!event.data.children
if (isDir) { if (isDir) {
await remove(event.data.path, { await window.electron.rm(event.data.path, {
recursive: true, recursive: true,
}).catch((e) => console.error('Error deleting directory', e)) }).catch((e) => console.error('Error deleting directory', e))
} else { } else {
await remove(event.data.path).catch((e) => await window.electron.rm(event.data.path).catch((e) =>
console.error('Error deleting file', e) console.error('Error deleting file', e)
) )
} }
@ -169,7 +169,7 @@ export const FileMachineProvider = ({
file?.path.includes(event.data.path)) && file?.path.includes(event.data.path)) &&
project?.path project?.path
) { ) {
navigate(paths.FILE + '/' + encodeURIComponent(project.file.path)) navigate(paths.FILE + '/' + encodeURIComponent(project.path))
} }
return `Successfully deleted ${isDir ? 'folder' : 'file'} "${ return `Successfully deleted ${isDir ? 'folder' : 'file'} "${

View File

@ -9,7 +9,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight } from '@fortawesome/free-solid-svg-icons' import { faChevronRight } from '@fortawesome/free-solid-svg-icons'
import { useFileContext } from 'hooks/useFileContext' import { useFileContext } from 'hooks/useFileContext'
import styles from './FileTree.module.css' import styles from './FileTree.module.css'
import { sortProject } from 'lib/tauriFS' import { sortProject } from 'lib/desktopFS'
import { FILE_EXT } from 'lib/constants' import { FILE_EXT } from 'lib/constants'
import { CustomIcon } from './CustomIcon' import { CustomIcon } from './CustomIcon'
import { codeManager, kclManager } from 'lib/singletons' import { codeManager, kclManager } from 'lib/singletons'
@ -171,7 +171,7 @@ const FileTreeItem = ({
// Import non-kcl files // Import non-kcl files
// We want to update both the state and editor here. // We want to update both the state and editor here.
codeManager.updateCodeStateEditor( codeManager.updateCodeStateEditor(
`import("${fileOrDir.path.replace(project.file.path, '.')}")\n` + `import("${fileOrDir.path.replace(project.path, '.')}")\n` +
codeManager.code codeManager.code
) )
codeManager.writeToFile() codeManager.writeToFile()

View File

@ -3,7 +3,7 @@ import Tooltip from './Tooltip'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { CustomIcon } from './CustomIcon' import { CustomIcon } from './CustomIcon'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { createAndOpenNewProject } from 'lib/tauriFS' import { createAndOpenNewProject } from 'lib/desktopFS'
import { paths } from 'lib/paths' import { paths } from 'lib/paths'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import { useLspContext } from './LspProvider' import { useLspContext } from './LspProvider'

View File

@ -36,8 +36,8 @@ function ProjectCard({
void handleRenameProject(e, project).then(() => setIsEditing(false)) void handleRenameProject(e, project).then(() => setIsEditing(false))
} }
function getDisplayedTime(dateStr: string) { function getDisplayedTime(dateTimeMs: number) {
const date = new Date(dateStr) const date = new Date(dateTimeMs)
const startOfToday = new Date() const startOfToday = new Date()
startOfToday.setHours(0, 0, 0, 0) startOfToday.setHours(0, 0, 0, 0)
return date.getTime() < startOfToday.getTime() return date.getTime() < startOfToday.getTime()
@ -103,7 +103,7 @@ function ProjectCard({
/> />
) : ( ) : (
<h3 className="font-sans relative z-0 p-2"> <h3 className="font-sans relative z-0 p-2">
{project.file.name?.replace(FILE_EXT, '')} {project.name?.replace(FILE_EXT, '')}
</h3> </h3>
)} )}
<span className="px-2 text-chalkboard-60 text-xs"> <span className="px-2 text-chalkboard-60 text-xs">
@ -113,8 +113,8 @@ function ProjectCard({
</span> </span>
<span className="px-2 text-chalkboard-60 text-xs"> <span className="px-2 text-chalkboard-60 text-xs">
Edited{' '} Edited{' '}
{project.metadata && project.metadata?.modified {project.metadata && project.metadata.mtimeMs
? getDisplayedTime(project.metadata.modified) ? getDisplayedTime(project.metadata.mtimeMs)
: 'never'} : 'never'}
</span> </span>
</div> </div>
@ -169,11 +169,11 @@ function ProjectCard({
onDismiss={() => setIsConfirmingDelete(false)} onDismiss={() => setIsConfirmingDelete(false)}
> >
<p className="my-4"> <p className="my-4">
This will permanently delete "{project.file.name || 'this file'} This will permanently delete "{project.name || 'this file'}
". ".
</p> </p>
<p className="my-4"> <p className="my-4">
Are you sure you want to delete "{project.file.name || 'this file'} Are you sure you want to delete "{project.name || 'this file'}
"? This action cannot be undone. "? This action cannot be undone.
</p> </p>
</DeleteConfirmationDialog> </DeleteConfirmationDialog>

View File

@ -24,7 +24,7 @@ export const ProjectCardRenameForm = forwardRef(
required required
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
defaultValue={project.file.name} defaultValue={project.name}
ref={ref} ref={ref}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {

View File

@ -35,7 +35,7 @@ const ProjectSidebarMenu = ({
className="hidden select-none cursor-default text-sm text-chalkboard-110 dark:text-chalkboard-20 whitespace-nowrap lg:block" className="hidden select-none cursor-default text-sm text-chalkboard-110 dark:text-chalkboard-20 whitespace-nowrap lg:block"
data-testid="project-name" data-testid="project-name"
> >
{project?.name ? project.file.name : APP_NAME} {project?.name ? project.name : APP_NAME}
</span> </span>
)} )}
</div> </div>
@ -212,7 +212,7 @@ function ProjectMenuPopover({
</span> </span>
{isDesktop() && project?.name && ( {isDesktop() && project?.name && (
<span className="hidden text-xs text-chalkboard-70 dark:text-chalkboard-40 whitespace-nowrap lg:block"> <span className="hidden text-xs text-chalkboard-70 dark:text-chalkboard-40 whitespace-nowrap lg:block">
{project.file.name} {project.name}
</span> </span>
)} )}
</div> </div>

View File

@ -12,10 +12,10 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { ActionButton } from 'components/ActionButton' import { ActionButton } from 'components/ActionButton'
import { SettingsFieldInput } from './SettingsFieldInput' import { SettingsFieldInput } from './SettingsFieldInput'
import { getInitialDefaultDir, showInFolder } from 'lib/desktop' import { getInitialDefaultDir } from 'lib/desktop'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { APP_VERSION } from 'routes/Settings' import { APP_VERSION } from 'routes/Settings'
import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS' import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/desktopFS'
import { paths } from 'lib/paths' import { paths } from 'lib/paths'
import { useDotDotSlash } from 'hooks/useDotDotSlash' import { useDotDotSlash } from 'hooks/useDotDotSlash'
import { sep } from '@tauri-apps/api/path' import { sep } from '@tauri-apps/api/path'
@ -190,7 +190,7 @@ export const AllSettingsFields = forwardRef(
const paths = await getSettingsFolderPaths( const paths = await getSettingsFolderPaths(
projectPath ? decodeURIComponent(projectPath) : undefined projectPath ? decodeURIComponent(projectPath) : undefined
) )
showInFolder(paths[searchParamTab]) window.electron.showInFolder(paths[searchParamTab])
}} }}
iconStart={{ iconStart={{
icon: 'folder', icon: 'folder',

View File

@ -209,7 +209,7 @@ export const SettingsAuthProviderBase = ({
}, },
services: { services: {
'Persist settings': (context) => 'Persist settings': (context) =>
saveSettings(context, loadedProject?.project?.path), saveSettings(context, loadedProject?.path),
}, },
} }
) )

View File

@ -206,6 +206,7 @@ export const Stream = () => {
if (!videoRef.current) return if (!videoRef.current) return
if (!mediaStream) return if (!mediaStream) return
// The browser complains if we try to load a new stream without pausing first.
// Do not immediately play the stream! // Do not immediately play the stream!
try { try {
videoRef.current.srcObject = mediaStream videoRef.current.srcObject = mediaStream

View File

@ -1,12 +1,11 @@
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration' import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration'
import { Project } from 'wasm-lib/kcl/bindings/Project' import { Project } from 'wasm-lib/kcl/bindings/Project'
import { FileEntry } from 'wasm-lib/kcl/bindings/FileEntry'
import { ProjectState } from 'wasm-lib/kcl/bindings/ProjectState' import { ProjectState } from 'wasm-lib/kcl/bindings/ProjectState'
import { ProjectRoute } from 'wasm-lib/kcl/bindings/ProjectRoute' import { ProjectRoute } from 'wasm-lib/kcl/bindings/ProjectRoute'
import { components } from './machine-api' import { components } from './machine-api'
import { isDesktop } from './isDesktop' import { isDesktop } from './isDesktop'
import { SaveSettingsPayload } from 'lib/settings/settingsUtils'
import { import {
defaultAppSettings, defaultAppSettings,
@ -17,7 +16,6 @@ import {
export { export {
parseProjectRoute, parseProjectRoute,
} from 'lang/wasm' } from 'lang/wasm'
import { SaveSettingsPayload } from 'lib/settings/settingsUtils'
const DEFAULT_HOST = 'https://api.zoo.dev' const DEFAULT_HOST = 'https://api.zoo.dev'
const SETTINGS_FILE_NAME = 'settings.toml' const SETTINGS_FILE_NAME = 'settings.toml'
@ -25,7 +23,6 @@ const PROJECT_SETTINGS_FILE_NAME = 'project.toml'
const PROJECT_FOLDER = 'zoo-modeling-app-projects' const PROJECT_FOLDER = 'zoo-modeling-app-projects'
const DEFAULT_PROJECT_KCL_FILE = "main.kcl" const DEFAULT_PROJECT_KCL_FILE = "main.kcl"
// List machines on the local network. // List machines on the local network.
export async function listMachines(): Promise<{ export async function listMachines(): Promise<{
[key: string]: components['schemas']['Machine'] [key: string]: components['schemas']['Machine']
@ -43,15 +40,32 @@ export async function renameProjectDirectory(
projectPath: string, projectPath: string,
newName: string newName: string
): Promise<string> { ): Promise<string> {
debugger if (!newName) {
} return Promise.reject(new Error(`New name for project cannot be empty`))
export async function showInFolder(path: string | undefined): Promise<void> {
if (!path) {
console.error('path is undefined cannot call desktop showInFolder')
return
} }
debugger
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(projectPath.split('/').slice(0, -1).join('/'), 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 doesnt 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( export async function ensureProjectDirectoryExists(
@ -62,7 +76,7 @@ export async function ensureProjectDirectoryExists(
await window.electron.stat(projectDir) await window.electron.stat(projectDir)
} catch (e) { } catch (e) {
if (e === 'ENOENT') { if (e === 'ENOENT') {
window.electron.mkdir(projectDir, { recursive: true }, (e) => { await window.electron.mkdir(projectDir, { recursive: true }, (e) => {
console.log(e) console.log(e)
}) })
} }
@ -92,7 +106,7 @@ export async function createNewProjectDirectory(
await window.electron.stat(projectDir) await window.electron.stat(projectDir)
} catch (e) { } catch (e) {
if (e === 'ENOENT') { if (e === 'ENOENT') {
window.electron.mkdir(projectDir, { recursive: true }) await window.electron.mkdir(projectDir, { recursive: true })
} }
} }
@ -101,17 +115,16 @@ export async function createNewProjectDirectory(
DEFAULT_PROJECT_KCL_FILE DEFAULT_PROJECT_KCL_FILE
) )
await window.electron.writeFile(projectFile, initialCode ?? '') await window.electron.writeFile(projectFile, initialCode ?? '')
const metadata = await window.electron.stat(projectFile)
return { return {
file: { path: projectDir,
path: projectDir, name: projectName,
name: projectName, // We don't need to recursively get all files in the project directory.
// We don't need to recursively get all files in the project directory. // Because we just created it and it's empty.
// Because we just created it and it's empty. children: undefined,
children: undefined,
},
default_file: projectFile, default_file: projectFile,
metadata: undefined /* TODO */, metadata,
kcl_file_count: 1, kcl_file_count: 1,
directory_count: 0, directory_count: 0,
} }
@ -120,6 +133,9 @@ export async function createNewProjectDirectory(
export async function listProjects( export async function listProjects(
configuration?: Partial<SaveSettingsPayload> configuration?: Partial<SaveSettingsPayload>
): Promise<Project[]> { ): Promise<Project[]> {
if (configuration === undefined) {
configuration = await readAppSettingsFile()
}
const projectDir = await ensureProjectDirectoryExists(configuration) const projectDir = await ensureProjectDirectoryExists(configuration)
const projects = [] const projects = []
const entries = await window.electron.readdir(projectDir) const entries = await window.electron.readdir(projectDir)
@ -140,7 +156,7 @@ export async function listProjects(
const IMPORT_FILE_EXTENSIONS = [ const IMPORT_FILE_EXTENSIONS = [
// TODO Use ImportFormat enum // TODO Use ImportFormat enum
"stp", "glb", "fbxb", "kcl" "stp", "glb", "fbxb", "kcl"
]; ]
const isRelevantFile = (filename: string): boolean => IMPORT_FILE_EXTENSIONS.some( const isRelevantFile = (filename: string): boolean => IMPORT_FILE_EXTENSIONS.some(
(ext) => filename.endsWith('.' + ext)) (ext) => filename.endsWith('.' + ext))
@ -157,13 +173,13 @@ const collectAllFilesRecursiveFrom = async (path: string) => {
// Make sure the path is a directory. // Make sure the path is a directory.
const isPathDir = await window.electron.statIsDirectory(path) const isPathDir = await window.electron.statIsDirectory(path)
if (!isPathDir) { if (!isPathDir) {
return Promise.reject(new Error(`Path ${path} is not a directory`)); return Promise.reject(new Error(`Path ${path} is not a directory`))
} }
const pathParts = path.split('/') const pathParts = path.split('/')
let entry = /* FileEntry */ { let entry = /* FileEntry */ {
name: pathParts.slice(-1)[0], name: pathParts.slice(-1)[0],
path: pathParts.slice(0, -1).join('/'), path,
children: [], children: [],
} }
@ -185,9 +201,9 @@ const collectAllFilesRecursiveFrom = async (path: string) => {
if (!isRelevantFile(ePath)) { continue } if (!isRelevantFile(ePath)) { continue }
children.push(/* FileEntry */ { children.push(/* FileEntry */ {
name: e, name: e,
path: ePath.split('/').slice(0, -1).join('/'), path: ePath,
children: undefined, children: undefined,
}); })
} }
} }
@ -199,13 +215,13 @@ const collectAllFilesRecursiveFrom = async (path: string) => {
const getDefaultKclFileForDir = async (projectDir, file) => { const getDefaultKclFileForDir = async (projectDir, file) => {
// Make sure the dir is a directory. // Make sure the dir is a directory.
const isFileEntryDir = await window.electron.statIsDirectory(file.path) const isFileEntryDir = await window.electron.statIsDirectory(projectDir)
if (!isFileEntryDir) { if (!isFileEntryDir) {
return Promise.reject(new Error(`Path ${file.path} is not a directory`)) return Promise.reject(new Error(`Path ${projectDir} is not a directory`))
} }
let defaultFilePath = window.electron.path.join(file.path, DEFAULT_PROJECT_KCL_FILE) let defaultFilePath = window.electron.path.join(projectDir, DEFAULT_PROJECT_KCL_FILE)
try { await window.eletron.stat(defaultFilePath) } try { await window.electron.stat(defaultFilePath) }
catch (e) { catch (e) {
if (e === 'ENOENT') { if (e === 'ENOENT') {
// Find a kcl file in the directory. // Find a kcl file in the directory.
@ -213,7 +229,7 @@ const getDefaultKclFileForDir = async (projectDir, file) => {
for (let entry of file.children) { for (let entry of file.children) {
if (entry.name.endsWith(".kcl")) { if (entry.name.endsWith(".kcl")) {
return window.electron.path.join(projectDir, entry.name) return window.electron.path.join(projectDir, entry.name)
} else if (entry.children.is_some()) { } else if (entry.children.length > 0) {
// Recursively find a kcl file in the directory. // Recursively find a kcl file in the directory.
return getDefaultKclFileForDir(entry.path, entry) return getDefaultKclFileForDir(entry.path, entry)
} }
@ -228,7 +244,7 @@ const getDefaultKclFileForDir = async (projectDir, file) => {
} }
const kclFileCount = (file /* fileEntry */) => { const kclFileCount = (file /* fileEntry */) => {
let count = 0; let count = 0
if (file.children) { if (file.children) {
for (let entry of file.children) { for (let entry of file.children) {
if (entry.name.endsWith(".kcl")) { if (entry.name.endsWith(".kcl")) {
@ -263,40 +279,37 @@ export async function getProjectInfo(
try { await window.electron.stat(projectPath) } try { await window.electron.stat(projectPath) }
catch (e) { catch (e) {
if (e === 'ENOENT') { if (e === 'ENOENT') {
return Promise.reject(new Error(`Project directory does not exist: ${project_path}`)); return Promise.reject(new Error(`Project directory does not exist: ${project_path}`))
} }
} }
// Make sure it is a directory. // Make sure it is a directory.
const projectPathIsDir = await window.electron.statIsDirectory(projectPath) const projectPathIsDir = await window.electron.statIsDirectory(projectPath)
if (!projectPathIsDir) { if (!projectPathIsDir) {
return Promise.reject(new Error(`Project path is not a directory: ${project_path}`)); return Promise.reject(new Error(`Project path is not a directory: ${project_path}`))
} }
let walked = await collectAllFilesRecursiveFrom(projectPath) let walked = await collectAllFilesRecursiveFrom(projectPath)
let default_file = await getDefaultKclFileForDir(projectPath, walked) let default_file = await getDefaultKclFileForDir(projectPath, walked)
const metadata = await window.electron.stat(projectPath)
let project = /* FileEntry */ { let project = /* FileEntry */ {
file: walked, ...walked,
metadata: undefined, metadata,
kcl_file_count: 0, kcl_file_count: 0,
directory_count: 0, directory_count: 0,
default_file, default_file,
}; }
// Populate the number of KCL files in the project. // Populate the number of KCL files in the project.
project.kcl_file_count = kclFileCount(project.file) project.kcl_file_count = kclFileCount(project)
//Populate the number of directories in the project. //Populate the number of directories in the project.
project.directory_count = directoryCount(project.file) project.directory_count = directoryCount(project)
return project return project
} }
export async function readDirRecursive(path: string): Promise<FileEntry[]> {
debugger
}
// Write project settings file. // Write project settings file.
export async function writeProjectSettingsFile({ export async function writeProjectSettingsFile({
projectPath, projectPath,
@ -320,8 +333,8 @@ const getAppSettingsFilePath = async () => {
await window.electron.stat(fullPath) await window.electron.stat(fullPath)
} catch (e) { } catch (e) {
// File/path doesn't exist // File/path doesn't exist
if (e.code === 'ENOENT') { if (e === 'ENOENT') {
window.electron.mkdir(fullPath, { recursive: true }) await window.electron.mkdir(fullPath, { recursive: true })
} }
} }
return window.electron.path.join(fullPath, SETTINGS_FILE_NAME) return window.electron.path.join(fullPath, SETTINGS_FILE_NAME)
@ -331,8 +344,8 @@ const getProjectSettingsFilePath = async (projectPath: string) => {
try { try {
await window.electron.stat(projectPath) await window.electron.stat(projectPath)
} catch (e) { } catch (e) {
if (e.code === 'ENOENT') { if (e === 'ENOENT') {
window.electron.mkdir(projectPath, { recursive: true }) await window.electron.mkdir(projectPath, { recursive: true })
} }
} }
return window.electron.path.join(projectPath, PROJECT_SETTINGS_FILE_NAME) return window.electron.path.join(projectPath, PROJECT_SETTINGS_FILE_NAME)
@ -419,7 +432,7 @@ export const getUser = async (
} }
// Use kittycad library to fetch the user info from /user/me // Use kittycad library to fetch the user info from /user/me
if (baseurl != DEFAULT_HOST) { if (baseurl !== DEFAULT_HOST) {
// The TypeScript generated library uses environment variables for this // The TypeScript generated library uses environment variables for this
// because it was intended for NodeJS. // because it was intended for NodeJS.
window.electron.process.env.BASE_URL(baseurl) window.electron.process.env.BASE_URL(baseurl)

View File

@ -1,4 +1,3 @@
import { appConfigDir } from '@tauri-apps/api/path'
import { isDesktop } from './isDesktop' import { isDesktop } from './isDesktop'
import type { FileEntry } from 'lib/types' import type { FileEntry } from 'lib/types'
import { import {
@ -64,9 +63,9 @@ function interpolateProjectName(projectName: string) {
} }
// Returns the next available index for a project name // Returns the next available index for a project name
export function getNextProjectIndex(projectName: string, files: FileEntry[]) { export function getNextProjectIndex(projectName: string, projects: FileEntry[]) {
const regex = interpolateProjectName(projectName) const regex = interpolateProjectName(projectName)
const matches = files.map((file) => file.name?.match(regex)) const matches = projects.map((project) => project.name?.match(regex))
const indices = matches const indices = matches
.filter(Boolean) .filter(Boolean)
.map((match) => match![1]) .map((match) => match![1])
@ -108,7 +107,7 @@ function getPaddedIdentifierRegExp() {
} }
export async function getSettingsFolderPaths(projectPath?: string) { export async function getSettingsFolderPaths(projectPath?: string) {
const user = isDesktop() ? await appConfigDir() : '/' const user = isDesktop() ? await window.electron.getPath('appData') : '/'
const project = projectPath !== undefined ? projectPath : undefined const project = projectPath !== undefined ? projectPath : undefined
return { return {

View File

@ -3,7 +3,11 @@ import path from 'path'
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
import packageJson from '../../package.json' import packageJson from '../../package.json'
const open = (args: any) => ipcRenderer.invoke('dialog', args)
const showInFolder = (path: string) => ipcRenderer.invoke('shell.showItemInFolder', path)
const readFile = (path: string) => fs.readFile(path, 'utf-8') const readFile = (path: string) => fs.readFile(path, 'utf-8')
const rename = (prev: string, next: string) => fs.rename(prev, next)
const writeFile = (path: string, data: string) => const writeFile = (path: string, data: string) =>
fs.writeFile(path, data, 'utf-8') fs.writeFile(path, data, 'utf-8')
const readdir = (path: string) => fs.readdir(path, 'utf-8') const readdir = (path: string) => fs.readdir(path, 'utf-8')
@ -27,13 +31,21 @@ const exposeProcessEnv = (varName: string) => {
import('@kittycad/lib').then((kittycad) => { import('@kittycad/lib').then((kittycad) => {
contextBridge.exposeInMainWorld('electron', { contextBridge.exposeInMainWorld('electron', {
// Passing fs directly is not recommended since it gives a lot of power
// to the browser side / potential malicious code. We restrict what is
// exported.
readFile, readFile,
writeFile, writeFile,
readdir, readdir,
rename,
rm: fs.rm,
path, path,
stat, stat,
statIsDirectory, statIsDirectory,
mkdir: fs.mkdir, mkdir: fs.mkdir,
// opens a dialog
open,
showInFolder,
getPath, getPath,
packageJson, packageJson,
platform: process.platform, platform: process.platform,

View File

@ -4,7 +4,6 @@ import { isDesktop } from './isDesktop'
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
import { ProjectRoute } from 'wasm-lib/kcl/bindings/ProjectRoute' import { ProjectRoute } from 'wasm-lib/kcl/bindings/ProjectRoute'
import { parseProjectRoute, readAppSettingsFile } from './desktop' import { parseProjectRoute, readAppSettingsFile } from './desktop'
import { parseProjectRoute as parseProjectRouteWasm } from 'lang/wasm'
import { readLocalStorageAppSettingsFile } from './settings/settingsUtils' import { readLocalStorageAppSettingsFile } from './settings/settingsUtils'
import { err } from 'lib/trap' import { err } from 'lib/trap'
@ -38,19 +37,17 @@ export async function getProjectMetaByRouteId(
): Promise<ProjectRoute | undefined> { ): Promise<ProjectRoute | undefined> {
if (!id) return undefined if (!id) return undefined
const inTauri = isDesktop() const onDesktop = isDesktop()
if (configuration === undefined) { if (configuration === undefined) {
configuration = inTauri configuration = onDesktop
? await readAppSettingsFile() ? await readAppSettingsFile()
: readLocalStorageAppSettingsFile() : readLocalStorageAppSettingsFile()
} }
if (err(configuration)) return Promise.reject(configuration) if (err(configuration)) return Promise.reject(configuration)
const route = inTauri const route = parseProjectRoute(configuration, id)
? await parseProjectRoute(configuration, id)
: parseProjectRouteWasm(configuration, id)
if (err(route)) return Promise.reject(route) if (err(route)) return Promise.reject(route)

View File

@ -126,7 +126,7 @@ export const fileLoader: LoaderFunction = async ({
}, },
file: { file: {
name: current_file_name, name: current_file_name,
path: current_file_path, path: current_file_path.split('/').slice(0, -1).join('/'),
children: [], children: [],
}, },
} }

View File

@ -14,7 +14,6 @@ import {
} from 'lib/cameraControls' } from 'lib/cameraControls'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { useRef } from 'react' import { useRef } from 'react'
import { open } from '@tauri-apps/plugin-dialog'
import { CustomIcon } from 'components/CustomIcon' import { CustomIcon } from 'components/CustomIcon'
import Tooltip from 'components/Tooltip' import Tooltip from 'components/Tooltip'
@ -209,22 +208,20 @@ export function createSettings() {
onClick={async () => { onClick={async () => {
// In Tauri end-to-end tests we can't control the file picker, // In Tauri end-to-end tests we can't control the file picker,
// so we seed the new directory value in the element's dataset // so we seed the new directory value in the element's dataset
const newValue = const inputRefVal = inputRef.current?.dataset.testValue
inputRef.current && inputRef.current.dataset.testValue if (inputRef.current && inputRefVal && !Array.isArray(inputRefVal)) {
? inputRef.current.dataset.testValue updateValue(inputRefVal)
: await open({ } else {
directory: true, const newPath = await window.electron.open({
recursive: true, properties: [
defaultPath: value, 'openDirectory',
title: 'Choose a new project directory', 'createDirectory',
}) ],
if ( defaultPath: value,
newValue && title: 'Choose a new project directory',
newValue !== null && })
newValue !== value && if (newPath.canceled) return
!Array.isArray(newValue) updateValue(newPath.filePaths[0])
) {
updateValue(newValue)
} }
}} }}
className="p-0 m-0 border-none hover:bg-primary/10 focus:bg-primary/10 dark:hover:bg-primary/20 dark:focus::bg-primary/20" className="p-0 m-0 border-none hover:bg-primary/10 focus:bg-primary/10 dark:hover:bg-primary/20 dark:focus::bg-primary/20"

View File

@ -36,9 +36,9 @@ export function getSortFunction(sortBy: string) {
} }
const sortByModified = (a: Project, b: Project) => { const sortByModified = (a: Project, b: Project) => {
if (a.metadata?.modified && b.metadata?.modified) { if (a.metadata?.mtimeMs && b.metadata?.mtimeMs) {
const aDate = new Date(a.metadata.modified) const aDate = new Date(a.metadata.mtimeMs)
const bDate = new Date(b.metadata.modified) const bDate = new Date(b.metadata.mtimeMs)
return !sortBy || sortBy.includes('desc') return !sortBy || sortBy.includes('desc')
? bDate.getTime() - aDate.getTime() ? bDate.getTime() - aDate.getTime()
: aDate.getTime() - bDate.getTime() : aDate.getTime() - bDate.getTime()

View File

@ -1,4 +1,4 @@
import { getNextProjectIndex, interpolateProjectNameWithIndex } from './tauriFS' import { getNextProjectIndex, interpolateProjectNameWithIndex } from './desktopFS'
import { MAX_PADDING } from './constants' import { MAX_PADDING } from './constants'
describe('Test project name utility functions', () => { describe('Test project name utility functions', () => {

View File

@ -2,7 +2,7 @@
// template that ElectronJS provides. // template that ElectronJS provides.
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
import { app, BrowserWindow, ipcMain } from 'electron' import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'
import path from 'path' import path from 'path'
// Handle creating/removing shortcuts on Windows when installing/uninstalling. // Handle creating/removing shortcuts on Windows when installing/uninstalling.
@ -12,6 +12,7 @@ if (require('electron-squirrel-startup')) {
const createWindow = () => { const createWindow = () => {
let mainWindow = new BrowserWindow({ let mainWindow = new BrowserWindow({
autoHideMenuBar: true,
width: 800, width: 800,
height: 600, height: 600,
webPreferences: { webPreferences: {
@ -52,3 +53,11 @@ app.on('ready', createWindow)
ipcMain.handle('app.getPath', (event, data) => { ipcMain.handle('app.getPath', (event, data) => {
return app.getPath(data) return app.getPath(data)
}) })
ipcMain.handle('dialog', (event, data) => {
return dialog.showOpenDialog(data)
})
ipcMain.handle('shell.showItemInFolder', (event, data) => {
return shell.showItemInFolder(data)
})

View File

@ -1,10 +1,9 @@
import { FormEvent, useEffect, useRef } from 'react' import { FormEvent, useEffect, useRef } from 'react'
import { remove } from '@tauri-apps/plugin-fs'
import { import {
getNextProjectIndex, getNextProjectIndex,
interpolateProjectNameWithIndex, interpolateProjectNameWithIndex,
doesProjectNameNeedInterpolated, doesProjectNameNeedInterpolated,
} from 'lib/tauriFS' } from 'lib/desktopFS'
import { ActionButton } from 'components/ActionButton' import { ActionButton } from 'components/ActionButton'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { AppHeader } from 'components/AppHeader' import { AppHeader } from 'components/AppHeader'
@ -131,7 +130,7 @@ const Home = () => {
} }
await renameProjectDirectory( await renameProjectDirectory(
await join(context.defaultDirectory, oldName), window.electron.path.join(context.defaultDirectory, oldName),
name name
) )
return `Successfully renamed "${oldName}" to "${name}"` return `Successfully renamed "${oldName}" to "${name}"`
@ -140,7 +139,7 @@ const Home = () => {
context: ContextFrom<typeof homeMachine>, context: ContextFrom<typeof homeMachine>,
event: EventFrom<typeof homeMachine, 'Delete project'> event: EventFrom<typeof homeMachine, 'Delete project'>
) => { ) => {
await remove(await join(context.defaultDirectory, event.data.name), { await window.electron.rm(window.electron.path.join(context.defaultDirectory, event.data.name), {
recursive: true, recursive: true,
}) })
return `Successfully deleted "${event.data.name}"` return `Successfully deleted "${event.data.name}"`
@ -192,15 +191,15 @@ const Home = () => {
new FormData(e.target as HTMLFormElement) new FormData(e.target as HTMLFormElement)
) )
if (newProjectName !== project.file.name) { if (newProjectName !== project.name) {
send('Rename project', { send('Rename project', {
data: { oldName: project.file.name, newName: newProjectName }, data: { oldName: project.name, newName: newProjectName },
}) })
} }
} }
async function handleDeleteProject(project: Project) { async function handleDeleteProject(project: Project) {
send('Delete project', { data: { name: project.file.name || '' } }) send('Delete project', { data: { name: project.name || '' } })
} }
return ( return (
@ -296,7 +295,7 @@ const Home = () => {
<ul className="grid w-full grid-cols-4 gap-4"> <ul className="grid w-full grid-cols-4 gap-4">
{searchResults.sort(getSortFunction(sort)).map((project) => ( {searchResults.sort(getSortFunction(sort)).map((project) => (
<ProjectCard <ProjectCard
key={project.file.name} key={project.name}
project={project} project={project}
handleRenameProject={handleRenameProject} handleRenameProject={handleRenameProject}
handleDeleteProject={handleDeleteProject} handleDeleteProject={handleDeleteProject}

View File

@ -913,7 +913,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(state.project.file.name, name); assert_eq!(state.project.file.name, name);
assert_eq!(state.project.file.path, tmp_project_dir.display().to_string()); assert_eq!(state.project.path, tmp_project_dir.display().to_string());
assert_eq!( assert_eq!(
state.current_file, state.current_file,
Some(tmp_project_dir.join("main.kcl").display().to_string()) Some(tmp_project_dir.join("main.kcl").display().to_string())
@ -938,7 +938,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(state.project.file.name, name); assert_eq!(state.project.file.name, name);
assert_eq!(state.project.file.path, tmp_project_dir.display().to_string()); assert_eq!(state.project.path, tmp_project_dir.display().to_string());
assert_eq!( assert_eq!(
state.current_file, state.current_file,
Some(tmp_project_dir.join("main.kcl").display().to_string()) Some(tmp_project_dir.join("main.kcl").display().to_string())
@ -963,7 +963,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(state.project.file.name, name); assert_eq!(state.project.file.name, name);
assert_eq!(state.project.file.path, tmp_project_dir.display().to_string()); assert_eq!(state.project.path, tmp_project_dir.display().to_string());
assert_eq!( assert_eq!(
state.current_file, state.current_file,
Some(tmp_project_dir.join("main.kcl").display().to_string()) Some(tmp_project_dir.join("main.kcl").display().to_string())
@ -988,7 +988,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(state.project.file.name, name); assert_eq!(state.project.file.name, name);
assert_eq!(state.project.file.path, tmp_project_dir.display().to_string()); assert_eq!(state.project.path, tmp_project_dir.display().to_string());
assert_eq!( assert_eq!(
state.current_file, state.current_file,
Some(tmp_project_dir.join("thing.kcl").display().to_string()) Some(tmp_project_dir.join("thing.kcl").display().to_string())
@ -1013,7 +1013,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(state.project.file.name, name); assert_eq!(state.project.file.name, name);
assert_eq!(state.project.file.path, tmp_project_dir.display().to_string()); assert_eq!(state.project.path, tmp_project_dir.display().to_string());
assert_eq!( assert_eq!(
state.current_file, state.current_file,
Some(tmp_project_dir.join("model.obj.kcl").display().to_string()) Some(tmp_project_dir.join("model.obj.kcl").display().to_string())

View File

@ -967,9 +967,9 @@ color = 1567.4"#;
.await .await
.unwrap(); .unwrap();
assert_eq!(project.file.name, project_name); assert_eq!(project.name, project_name);
assert_eq!( assert_eq!(
project.file.path, project.path,
settings settings
.settings .settings
.project .project
@ -981,7 +981,7 @@ color = 1567.4"#;
assert_eq!(project.directory_count, 0); assert_eq!(project.directory_count, 0);
assert_eq!( assert_eq!(
project.default_file, project.default_file,
std::path::Path::new(&project.file.path) std::path::Path::new(&project.path)
.join(super::DEFAULT_PROJECT_KCL_FILE) .join(super::DEFAULT_PROJECT_KCL_FILE)
.to_string_lossy() .to_string_lossy()
); );
@ -1017,9 +1017,9 @@ color = 1567.4"#;
.await .await
.unwrap(); .unwrap();
assert_eq!(project.file.name, project_name); assert_eq!(project.name, project_name);
assert_eq!( assert_eq!(
project.file.path, project.path,
settings settings
.settings .settings
.project .project
@ -1031,7 +1031,7 @@ color = 1567.4"#;
assert_eq!(project.directory_count, 0); assert_eq!(project.directory_count, 0);
assert_eq!( assert_eq!(
project.default_file, project.default_file,
std::path::Path::new(&project.file.path) std::path::Path::new(&project.path)
.join(super::DEFAULT_PROJECT_KCL_FILE) .join(super::DEFAULT_PROJECT_KCL_FILE)
.to_string_lossy() .to_string_lossy()
); );
@ -1057,8 +1057,8 @@ color = 1567.4"#;
let projects = settings.list_projects().await.unwrap(); let projects = settings.list_projects().await.unwrap();
assert_eq!(projects.len(), 1); assert_eq!(projects.len(), 1);
assert_eq!(projects[0].file.name, project_name); assert_eq!(projects[0].name, project_name);
assert_eq!(projects[0].file.path, project.file.path); assert_eq!(projects[0].path, project.path);
assert_eq!(projects[0].kcl_file_count, 1); assert_eq!(projects[0].kcl_file_count, 1);
assert_eq!(projects[0].directory_count, 0); assert_eq!(projects[0].directory_count, 0);
assert_eq!(projects[0].default_file, project.default_file); assert_eq!(projects[0].default_file, project.default_file);
@ -1084,8 +1084,8 @@ color = 1567.4"#;
let projects = settings.list_projects().await.unwrap(); let projects = settings.list_projects().await.unwrap();
assert_eq!(projects.len(), 1); assert_eq!(projects.len(), 1);
assert_eq!(projects[0].file.name, project_name); assert_eq!(projects[0].name, project_name);
assert_eq!(projects[0].file.path, project.file.path); assert_eq!(projects[0].path, project.path);
assert_eq!(projects[0].kcl_file_count, 1); assert_eq!(projects[0].kcl_file_count, 1);
assert_eq!(projects[0].directory_count, 0); assert_eq!(projects[0].directory_count, 0);
assert_eq!(projects[0].default_file, project.default_file); assert_eq!(projects[0].default_file, project.default_file);
@ -1111,8 +1111,8 @@ color = 1567.4"#;
let projects = settings.list_projects().await.unwrap(); let projects = settings.list_projects().await.unwrap();
assert_eq!(projects.len(), 1); assert_eq!(projects.len(), 1);
assert_eq!(projects[0].file.name, project_name); assert_eq!(projects[0].name, project_name);
assert_eq!(projects[0].file.path, project.file.path); assert_eq!(projects[0].path, project.path);
assert_eq!(projects[0].kcl_file_count, 1); assert_eq!(projects[0].kcl_file_count, 1);
assert_eq!(projects[0].directory_count, 0); assert_eq!(projects[0].directory_count, 0);
assert_eq!(projects[0].default_file, project.default_file); assert_eq!(projects[0].default_file, project.default_file);
@ -1138,8 +1138,8 @@ color = 1567.4"#;
let projects = settings.list_projects().await.unwrap(); let projects = settings.list_projects().await.unwrap();
assert_eq!(projects.len(), 1); assert_eq!(projects.len(), 1);
assert_eq!(projects[0].file.name, project_name); assert_eq!(projects[0].name, project_name);
assert_eq!(projects[0].file.path, project.file.path); assert_eq!(projects[0].path, project.path);
assert_eq!(projects[0].kcl_file_count, 1); assert_eq!(projects[0].kcl_file_count, 1);
assert_eq!(projects[0].directory_count, 0); assert_eq!(projects[0].directory_count, 0);
assert_eq!(projects[0].default_file, project.default_file); assert_eq!(projects[0].default_file, project.default_file);

69
vite.config.ts Normal file
View File

@ -0,0 +1,69 @@
import react from '@vitejs/plugin-react'
import viteTsconfigPaths from 'vite-tsconfig-paths'
import eslint from 'vite-plugin-eslint'
import { defineConfig, configDefaults } from 'vitest/config'
import version from 'vite-plugin-package-version'
// @ts-ignore: No types available
import { lezer } from '@lezer/generator/rollup'
const config = defineConfig({
server: {
open: true,
port: 3000,
watch: {
ignored: [
'**/target/**',
'**/dist/**',
'**/build/**',
'**/test-results/**',
'**/playwright-report/**',
],
},
},
test: {
globals: true,
pool: 'forks',
poolOptions: {
forks: {
maxForks: 2,
minForks: 1,
},
},
setupFiles: ['src/setupTests.ts', '@vitest/web-worker'],
environment: 'happy-dom',
coverage: {
provider: 'istanbul', // or 'v8'
},
exclude: [...configDefaults.exclude, '**/e2e/**/*'],
deps: {
optimizer: {
web: {
include: ['vitest-canvas-mock'],
},
},
},
clearMocks: true,
restoreMocks: true,
mockReset: true,
reporters: process.env.GITHUB_ACTIONS
? ['dot', 'github-actions']
: ['verbose', 'hanging-process'],
testTimeout: 1000,
hookTimeout: 1000,
teardownTimeout: 1000,
},
build: {
outDir: 'build',
},
resolve: {
alias: {
'@kittycad/codemirror-lsp-client': '/packages/codemirror-lsp-client/src',
},
},
plugins: [react(), viteTsconfigPaths(), eslint(), version(), lezer()],
worker: {
plugins: () => [viteTsconfigPaths()],
},
})
export default config