File based settings (#1679)

* Rename GlobalStateContext to SettingsAuthContext

* Naive initial impl of settings persistence to file system

* Update app identifier in tauri config

* Add "show in folder" tauri command

* Load from and save to file system in Tauri app

* Add documents drive to tauri permission scope

* Add recursive prop to default dir selection dialog

* Add success toast to web restore defaults action

* Add a way to validate read-in settings

* Update imports to use separate settings lib file

* Validate localStorage-loaded settings, combine error message

* Add a e2e test for validation

* Clean up state state bugs

* Reverse validation looping so new users don't error

* update settingsMachine typegen to remove conflicts

* Fmt

* Fix TS errors

* Fix import paths, etc post-merge

* Make default length units `mm` and 'metric'

* Rename to SettingsAuth*

* cargo fmt

* Revert Tauri config identifier change

* Update clientSideInfra's baseUnits from settings

* Break apart CommandBar and CommandBarProvider

* Bugfix: don't validate defaultValue when it's not configured

* Allow some TauriFS functions to no-op from browser

* Sidestep circular deps by loading context and kclManager only from React-land

* Update broken import paths

* Separate loaders from Router, load settings on every route

* Break apart settings types, utils, and constants

* Fix Jest tests by decoupling reliance on useLoaderData from SettingsAuthProvider

* Fix up Router loader data with "layout routes"
https://reactrouter.com/en/main/route/route#layout-routes

* Move settings validation and toast to custom hook so the toast renders

* fmt

* Use forks for Vitest
https://vitest.dev/guide/common-errors.html#failed-to-terminate-worker

* $APPCONFIG !== $APPDATA only on Linux
+ change the identifier back since it really doesn't seem to affect app signing

* Debugging on Linux

* Better directory validation, fix reset settings button

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* defaultDirectory can be empty in browser

* fmt

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* re-trigger CI

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2024-03-14 15:56:45 -04:00
committed by GitHub
parent 13cd3e179b
commit f40cdabfdf
30 changed files with 842 additions and 389 deletions

View File

@ -12,68 +12,54 @@ import SignIn from './routes/SignIn'
import { Auth } from './Auth'
import { isTauri } from './lib/isTauri'
import Home from './routes/Home'
import { FileEntry, readDir, readTextFile } from '@tauri-apps/api/fs'
import makeUrlPathRelative from './lib/makeUrlPathRelative'
import {
initializeProjectDirectory,
isProjectDirectory,
PROJECT_ENTRYPOINT,
} from './lib/tauriFS'
import { metadata } from 'tauri-plugin-fs-extra-api'
import DownloadAppBanner from './components/DownloadAppBanner'
import { WasmErrBanner } from './components/WasmErrBanner'
import { SettingsAuthProvider } from './components/SettingsAuthProvider'
import { settingsMachine } from './machines/settingsMachine'
import { SETTINGS_PERSIST_KEY } from './lib/settings'
import { ContextFrom } from 'xstate'
import CommandBarProvider, {
CommandBar,
} from 'components/CommandBar/CommandBar'
import DownloadAppBanner from 'components/DownloadAppBanner'
import { WasmErrBanner } from 'components/WasmErrBanner'
import { CommandBar } from 'components/CommandBar/CommandBar'
import ModelingMachineProvider from 'components/ModelingMachineProvider'
import { KclContextProvider, kclManager } from 'lang/KclSingleton'
import FileMachineProvider from 'components/FileMachineProvider'
import { sep } from '@tauri-apps/api/path'
import { paths } from 'lib/paths'
import { IndexLoaderData, HomeLoaderData } from 'lib/types'
import { fileSystemManager } from 'lang/std/fileSystemManager'
import {
fileLoader,
homeLoader,
indexLoader,
onboardingRedirectLoader,
} from 'lib/routeLoaders'
import { CommandBarProvider } from 'components/CommandBar/CommandBarProvider'
import SettingsAuthProvider from 'components/SettingsAuthProvider'
import LspProvider from 'components/LspProvider'
import { KclContextProvider } from 'lang/KclSingleton'
export const BROWSER_FILE_NAME = 'new'
type CreateBrowserRouterArg = Parameters<typeof createBrowserRouter>[0]
const addGlobalContextToElements = (
routes: CreateBrowserRouterArg
): CreateBrowserRouterArg =>
routes.map((route) =>
'element' in route
? {
...route,
element: (
<CommandBarProvider>
<SettingsAuthProvider>
<LspProvider>{route.element}</LspProvider>
</SettingsAuthProvider>
</CommandBarProvider>
),
}
: route
)
const router = createBrowserRouter(
addGlobalContextToElements([
{
path: paths.INDEX,
loader: () =>
isTauri()
? redirect(paths.HOME)
: redirect(paths.FILE + '/' + BROWSER_FILE_NAME),
errorElement: <ErrorPage />,
},
{
path: paths.FILE + '/:id',
element: (
const router = createBrowserRouter([
{
loader: indexLoader,
id: paths.INDEX,
element: (
<CommandBarProvider>
<KclContextProvider>
<SettingsAuthProvider>
<LspProvider>
<Outlet />
</LspProvider>
</SettingsAuthProvider>
</KclContextProvider>
</CommandBarProvider>
),
children: [
{
path: paths.INDEX,
loader: () =>
isTauri()
? redirect(paths.HOME)
: redirect(paths.FILE + '/' + BROWSER_FILE_NAME),
errorElement: <ErrorPage />,
},
{
loader: fileLoader,
id: paths.FILE,
element: (
<Auth>
<FileMachineProvider>
<ModelingMachineProvider>
@ -85,155 +71,52 @@ const router = createBrowserRouter(
</FileMachineProvider>
{!isTauri() && import.meta.env.PROD && <DownloadAppBanner />}
</Auth>
</KclContextProvider>
),
id: paths.FILE,
loader: async ({
request,
params,
}): Promise<IndexLoaderData | Response> => {
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
ContextFrom<typeof settingsMachine>
>
const status = persistedSettings.onboardingStatus || ''
const notEnRouteToOnboarding = !request.url.includes(
paths.ONBOARDING.INDEX
)
// '' is the initial state, 'done' and 'dismissed' are the final states
const hasValidOnboardingStatus =
status.length === 0 || !(status === 'done' || status === 'dismissed')
const shouldRedirectToOnboarding =
notEnRouteToOnboarding && hasValidOnboardingStatus
if (shouldRedirectToOnboarding) {
return redirect(
makeUrlPathRelative(paths.ONBOARDING.INDEX) + status.slice(1)
)
}
const defaultDir = persistedSettings.defaultDirectory || ''
if (params.id && params.id !== BROWSER_FILE_NAME) {
const decodedId = decodeURIComponent(params.id)
const projectAndFile = decodedId.replace(defaultDir + sep, '')
const firstSlashIndex = projectAndFile.indexOf(sep)
const projectName = projectAndFile.slice(0, firstSlashIndex)
const projectPath = defaultDir + sep + projectName
const currentFileName = projectAndFile.slice(firstSlashIndex + 1)
if (firstSlashIndex === -1 || !currentFileName)
return redirect(
`${paths.FILE}/${encodeURIComponent(
`${params.id}${sep}${PROJECT_ENTRYPOINT}`
)}`
)
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
const code = await readTextFile(decodedId)
const entrypointMetadata = await metadata(
projectPath + sep + PROJECT_ENTRYPOINT
)
const children = await readDir(projectPath, { recursive: true })
kclManager.setCodeAndExecute(code, false)
// Set the file system manager to the project path
// So that WASM gets an updated path for operations
fileSystemManager.dir = projectPath
return {
code,
project: {
name: projectName,
path: projectPath,
children,
entrypointMetadata,
},
file: {
name: currentFileName,
path: params.id,
},
}
}
return {
code: '',
}
),
children: [
{
path: paths.FILE + '/:id',
loader: onboardingRedirectLoader,
children: [
{
path: makeUrlPathRelative(paths.SETTINGS),
loader: indexLoader, // very rare someone will load into settings first, but it's possible in the browser
element: <Settings />,
},
{
path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
element: <Onboarding />,
loader: indexLoader, // very rare someone will load into settings first, but it's possible in the browser
children: onboardingRoutes,
},
],
},
],
},
children: [
{
path: makeUrlPathRelative(paths.SETTINGS),
element: <Settings />,
},
{
path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
element: <Onboarding />,
children: onboardingRoutes,
},
],
},
{
path: paths.HOME,
element: (
<Auth>
<Outlet />
<Home />
<CommandBar />
</Auth>
),
loader: async (): Promise<HomeLoaderData | Response> => {
if (!isTauri()) {
return redirect(paths.FILE + '/' + BROWSER_FILE_NAME)
}
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
ContextFrom<typeof settingsMachine>
>
const projectDir = await initializeProjectDirectory(
persistedSettings.defaultDirectory || ''
)
let newDefaultDirectory: string | undefined = undefined
if (projectDir !== persistedSettings.defaultDirectory) {
localStorage.setItem(
SETTINGS_PERSIST_KEY,
JSON.stringify({
...persistedSettings,
defaultDirectory: projectDir,
})
)
newDefaultDirectory = projectDir
}
const projectsNoMeta = (await readDir(projectDir)).filter(
isProjectDirectory
)
const projects = await Promise.all(
projectsNoMeta.map(async (p: FileEntry) => ({
entrypointMetadata: await metadata(
p.path + sep + PROJECT_ENTRYPOINT
),
...p,
}))
)
return {
projects,
newDefaultDirectory,
}
{
path: paths.HOME,
element: (
<Auth>
<Outlet />
<Home />
<CommandBar />
</Auth>
),
id: paths.HOME,
loader: homeLoader,
children: [
{
path: makeUrlPathRelative(paths.SETTINGS),
element: <Settings />,
},
],
},
children: [
{
path: makeUrlPathRelative(paths.SETTINGS),
element: <Settings />,
},
],
},
{
path: paths.SIGN_IN,
element: <SignIn />,
},
])
)
{
path: paths.SIGN_IN,
element: <SignIn />,
},
],
},
])
/**
* All routes in the app, used in src/index.tsx