Settings move to rust (for read/write from files) (#2220)

* start of settings types

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* add validator

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* start of settings in rust

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix wasm

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix wasm

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* more tests

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* derive docs

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* configuration

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* read and write functions with migration

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* make more dry

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* more parsing of app settings

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* more things

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* cleanup

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* trim end

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* project settings

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixes

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixes

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* cleanup tauri commands

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* refactor

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* refactor

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* change to files

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* better

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* cleanup more

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* get rid of dead code

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixed

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* cleanup some more shit

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* add validation

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* validation

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* validate

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* validate

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* clippuy

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* clippuy

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix;

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
This commit is contained in:
Jess Frazelle
2024-04-25 00:13:09 -07:00
committed by GitHub
parent dcbed4f06f
commit 1afed68dd7
79 changed files with 3966 additions and 1923 deletions

View File

@ -1,154 +1,19 @@
import {
mkdir,
exists,
readTextFile,
writeTextFile,
stat,
} from '@tauri-apps/plugin-fs'
import { invoke } from '@tauri-apps/api/core'
import {
appConfigDir,
documentDir,
homeDir,
join,
sep,
} from '@tauri-apps/api/path'
import { appConfigDir } from '@tauri-apps/api/path'
import { isTauri } from './isTauri'
import type { FileEntry, ProjectWithEntryPointMetadata } from 'lib/types'
import type { FileEntry } from 'lib/types'
import {
FILE_EXT,
INDEX_IDENTIFIER,
MAX_PADDING,
ONBOARDING_PROJECT_NAME,
PROJECT_ENTRYPOINT,
PROJECT_FOLDER,
RELEVANT_FILE_TYPES,
SETTINGS_FILE_EXT,
} from 'lib/constants'
import { SaveSettingsPayload, SettingsLevel } from './settings/settingsTypes'
import { initPromise, tomlParse } from 'lang/wasm'
import { bracket } from './exampleKcl'
import { paths } from './paths'
type PathWithPossibleError = {
path: string | null
error: Error | null
}
export async function getInitialDefaultDir() {
if (!isTauri()) return ''
let dir
try {
dir = await documentDir()
} catch (e) {
dir = await join(await homeDir(), 'Documents') // for headless Linux (eg. Github Actions)
}
return await join(dir, PROJECT_FOLDER)
}
// Initializes the project directory and returns the path
// with any Errors that occurred
export async function initializeProjectDirectory(
directory: string
): Promise<PathWithPossibleError> {
let returnValue: PathWithPossibleError = {
path: null,
error: null,
}
if (!isTauri()) return returnValue
if (directory) {
returnValue = await testAndCreateDir(directory, returnValue)
}
// If the directory from settings does not exist or could not be created,
// use the default directory
if (returnValue.path === null) {
const INITIAL_DEFAULT_DIR = await getInitialDefaultDir()
const defaultReturnValue = await testAndCreateDir(
INITIAL_DEFAULT_DIR,
returnValue,
{
exists: 'Error checking default directory.',
create: 'Error creating default directory.',
}
)
returnValue.path = defaultReturnValue.path
returnValue.error =
returnValue.error === null ? defaultReturnValue.error : returnValue.error
}
return returnValue
}
async function testAndCreateDir(
directory: string,
returnValue = {
path: null,
error: null,
} as PathWithPossibleError,
errorMessages = {
exists:
'Error checking directory at path from saved settings. Using default.',
create:
'Error creating directory at path from saved settings. Using default.',
}
): Promise<PathWithPossibleError> {
const dirExists = await exists(directory).catch((e) => {
console.error(`Error checking directory ${directory}. Original error:`, e)
return new Error(errorMessages.exists)
})
if (dirExists instanceof Error) {
returnValue.error = dirExists
} else if (dirExists === false) {
const newDirCreated = await mkdir(directory, { recursive: true }).catch(
(e) => {
console.error(
`Error creating directory ${directory}. Original error:`,
e
)
return new Error(errorMessages.create)
}
)
if (newDirCreated instanceof Error) {
returnValue.error = newDirCreated
} else {
returnValue.path = directory
}
} else if (dirExists === true) {
returnValue.path = directory
}
return returnValue
}
export function isProjectDirectory(fileOrDir: Partial<FileEntry>) {
return (
fileOrDir.children?.length &&
fileOrDir.children.some((child) => child.name === PROJECT_ENTRYPOINT)
)
}
// Read the contents of a directory
// and return the valid projects
export async function getProjectsInDir(projectDir: string) {
const readProjects = (
await invoke<FileEntry[]>('read_dir_recursive', { path: projectDir })
).filter(isProjectDirectory)
const projectsWithMetadata = await Promise.all(
readProjects.map(async (p) => ({
entrypointMetadata: await stat(await join(p.path, PROJECT_ENTRYPOINT)),
...p,
}))
)
return projectsWithMetadata
}
import {
createNewProjectDirectory,
listProjects,
readAppSettingsFile,
} from './tauri'
export const isHidden = (fileOrDir: FileEntry) =>
!!fileOrDir.name?.startsWith('.')
@ -156,97 +21,6 @@ export const isHidden = (fileOrDir: FileEntry) =>
export const isDir = (fileOrDir: FileEntry) =>
'children' in fileOrDir && fileOrDir.children !== undefined
export function deepFileFilter(
entries: FileEntry[],
filterFn: (f: FileEntry) => boolean
): FileEntry[] {
const filteredEntries: FileEntry[] = []
for (const fileOrDir of entries) {
if ('children' in fileOrDir && fileOrDir.children !== undefined) {
const filteredChildren = deepFileFilter(fileOrDir.children, filterFn)
if (filterFn(fileOrDir)) {
filteredEntries.push({
...fileOrDir,
children: filteredChildren,
})
}
} else if (filterFn(fileOrDir)) {
filteredEntries.push(fileOrDir)
}
}
return filteredEntries
}
export function deepFileFilterFlat(
entries: FileEntry[],
filterFn: (f: FileEntry) => boolean
): FileEntry[] {
const filteredEntries: FileEntry[] = []
for (const fileOrDir of entries) {
if ('children' in fileOrDir && fileOrDir.children !== undefined) {
const filteredChildren = deepFileFilterFlat(fileOrDir.children, filterFn)
if (filterFn(fileOrDir)) {
filteredEntries.push({
...fileOrDir,
children: filteredChildren,
})
}
filteredEntries.push(...filteredChildren)
} else if (filterFn(fileOrDir)) {
filteredEntries.push(fileOrDir)
}
}
return filteredEntries
}
// Read the contents of a project directory
// and return all relevant files and sub-directories recursively
export async function readProject(projectDir: string) {
const readFiles = await invoke<FileEntry[]>('read_dir_recursive', {
path: projectDir,
})
return deepFileFilter(readFiles, isRelevantFileOrDir)
}
// Given a read project, return the number of .kcl files,
// both in the root directory and in sub-directories,
// and folders that contain at least one .kcl file
export function getPartsCount(project: FileEntry[]) {
const flatProject = deepFileFilterFlat(project, isRelevantFileOrDir)
const kclFileCount = flatProject.filter((f) =>
f.name?.endsWith(FILE_EXT)
).length
const kclDirCount = flatProject.filter((f) => f.children !== undefined).length
return {
kclFileCount,
kclDirCount,
}
}
// Determines if a file or directory is relevant to the project
// i.e. not a hidden file or directory, and is a relevant file type
// or contains at least one relevant file (even if it's nested)
// or is a completely empty directory
export function isRelevantFileOrDir(fileOrDir: FileEntry) {
let isRelevantDir = false
if ('children' in fileOrDir && fileOrDir.children !== undefined) {
isRelevantDir =
!isHidden(fileOrDir) &&
(fileOrDir.children.some(isRelevantFileOrDir) ||
fileOrDir.children.length === 0)
}
const isRelevantFile =
!isHidden(fileOrDir) &&
RELEVANT_FILE_TYPES.some((ext) => fileOrDir.name?.endsWith(ext))
return (
(isDir(fileOrDir) && isRelevantDir) || (!isDir(fileOrDir) && isRelevantFile)
)
}
// Deeply sort the files and directories in a project like VS Code does:
// The main.kcl file is always first, then files, then directories
// Files and directories are sorted alphabetically
@ -279,47 +53,6 @@ export function sortProject(project: FileEntry[]): FileEntry[] {
})
}
// Creates a new file in the default directory with the default project name
// Returns the path to the new file
export async function createNewProject(
path: string,
initCode = ''
): Promise<ProjectWithEntryPointMetadata> {
if (!isTauri) {
throw new Error('createNewProject() can only be called from a Tauri app')
}
const dirExists = await exists(path)
if (!dirExists) {
await mkdir(path, { recursive: true }).catch((err) => {
console.error('Error creating new directory:', err)
throw err
})
}
await writeTextFile(await join(path, PROJECT_ENTRYPOINT), initCode).catch(
(err) => {
console.error('Error creating new file:', err)
throw err
}
)
const m = await stat(path)
return {
name: path.slice(path.lastIndexOf(sep()) + 1),
path: path,
entrypointMetadata: m,
children: [
{
name: PROJECT_ENTRYPOINT,
path: await join(path, PROJECT_ENTRYPOINT),
children: [],
},
],
}
}
// create a regex to match the project name
// replacing any instances of "$n" with a regex to match any number
function interpolateProjectName(projectName: string) {
@ -373,55 +106,6 @@ function getPaddedIdentifierRegExp() {
return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`)
}
export async function getUserSettingsFilePath(
filename: string = SETTINGS_FILE_EXT
) {
const dir = await appConfigDir()
return await join(dir, filename)
}
export async function readSettingsFile(
path: string
): Promise<Partial<SaveSettingsPayload>> {
const dir = path.slice(0, path.lastIndexOf(sep()))
const dirExists = await exists(dir)
if (!dirExists) {
await mkdir(dir, { recursive: true })
}
const settingsExist = dirExists ? await exists(path) : false
if (!settingsExist) {
console.log(`Settings file does not exist at ${path}`)
return {}
}
try {
await initPromise
const settings = await readTextFile(path)
// We expect the settings to be under a top-level [settings] key
return tomlParse(settings).settings as Partial<SaveSettingsPayload>
} catch (e) {
console.error('Error reading settings file:', e)
return {}
}
}
export async function getSettingsFilePaths(
projectPath?: string
): Promise<Partial<Record<SettingsLevel, string>>> {
const { user, project } = await getSettingsFolderPaths(projectPath)
return {
user: user + 'user' + SETTINGS_FILE_EXT,
project:
project !== undefined
? project + (isTauri() ? sep() : '/') + 'project' + SETTINGS_FILE_EXT
: undefined,
}
}
export async function getSettingsFolderPaths(projectPath?: string) {
const user = isTauri() ? await appConfigDir() : '/'
const project = projectPath !== undefined ? projectPath : undefined
@ -433,18 +117,15 @@ export async function getSettingsFolderPaths(projectPath?: string) {
}
export async function createAndOpenNewProject(
projectDirectory: string,
navigate: (path: string) => void
) {
const projects = await getProjectsInDir(projectDirectory)
const nextIndex = await getNextProjectIndex(ONBOARDING_PROJECT_NAME, projects)
const configuration = await readAppSettingsFile()
const projects = await listProjects(configuration)
const nextIndex = getNextProjectIndex(ONBOARDING_PROJECT_NAME, projects)
const name = interpolateProjectNameWithIndex(
ONBOARDING_PROJECT_NAME,
nextIndex
)
const newFile = await createNewProject(
await join(projectDirectory, name),
bracket
)
const newFile = await createNewProjectDirectory(name, bracket, configuration)
navigate(`${paths.FILE}/${encodeURIComponent(newFile.path)}`)
}