Home page in desktop, separate file support (#252)

* Bugfix: don't toast on every change of defaultDir

* Refactor app to live under /file/:id

* Stub out Tauri-only home page

* home reads and writes blank files to defaultDir

* Fix initial directory creation

* Make file names editable

* Refactor onboarding to use normal fns for load issues

* Feature: load and write files to and from disk

* Feature: Add file deletion, break out FileCard component

* Fix settings close URLs to be relative, button types

* Add filename and link to AppHeader

* Style tweaks: scrollbar, header name, card size

* Style: add header, empty state to Home

* Refactor: load file in route loader

* Move makePathRelative to lib to fix tests

* Fix App test

* Use '$nnn' default name scheme

* Fix type error on ActionButton

* Fix type error on ActionButton

* @adamchalmers review

* Fix merge mistake

* Refactor: rename all things "file" to "project"

* Feature: migrate to <project-name>/main.kcl setup

* Fix tsc test

* @Irev-Dev review part 1: renames and imports

* @Irev-Dev review pt 2: simplify file list refresh

* @Irev-Dev review pt 3: filter out non-projects

* @Irev-review pt 4: folder conventions + home auth

* Add sort functionality to new welcome page (#255)

* Add todo for Sentry
This commit is contained in:
Frank Noirot
2023-08-15 21:56:24 -04:00
committed by GitHub
parent 826ad267b4
commit 19761baba6
29 changed files with 1003 additions and 157 deletions

View File

@ -32,6 +32,7 @@
"react-router-dom": "^6.14.2", "react-router-dom": "^6.14.2",
"sketch-helpers": "^0.0.4", "sketch-helpers": "^0.0.4",
"swr": "^2.0.4", "swr": "^2.0.4",
"tauri-plugin-fs-extra-api": "https://github.com/tauri-apps/tauri-plugin-fs-extra#v1",
"toml": "^3.0.0", "toml": "^3.0.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^4.4.2", "typescript": "^4.4.2",

13
src-tauri/Cargo.lock generated
View File

@ -81,6 +81,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-fs-extra",
"tokio", "tokio",
"toml 0.6.0", "toml 0.6.0",
] ]
@ -3120,6 +3121,18 @@ dependencies = [
"tauri-utils", "tauri-utils",
] ]
[[package]]
name = "tauri-plugin-fs-extra"
version = "0.0.0"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#7e58dc8502f654b99d51c087421f84ccc0e03119"
dependencies = [
"log",
"serde",
"serde_json",
"tauri",
"thiserror",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "0.13.0" version = "0.13.0"

View File

@ -19,9 +19,10 @@ anyhow = "1"
oauth2 = "4.4.1" oauth2 = "4.4.1"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
tauri = { version = "1.3.0", features = ["dialog-all", "fs-all", "http-request", "shell-open", "shell-open-api"] } tauri = { version = "1.3.0", features = [ "path-all", "dialog-all", "fs-all", "http-request", "shell-open", "shell-open-api"] }
tokio = { version = "1.29.1", features = ["time"] } tokio = { version = "1.29.1", features = ["time"] }
toml = "0.6.0" toml = "0.6.0"
tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
[features] [features]
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.

View File

@ -98,6 +98,7 @@ fn main() {
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![login, read_toml, read_txt_file]) .invoke_handler(tauri::generate_handler![login, read_toml, read_txt_file])
.plugin(tauri_plugin_fs_extra::init())
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@ -22,9 +22,7 @@
"save": true "save": true
}, },
"fs": { "fs": {
"scope": [ "scope": ["$HOME/**/*", "$APPDATA/**/*"],
"$HOME/**/*"
],
"all": true "all": true
}, },
"http": { "http": {
@ -37,6 +35,9 @@
}, },
"shell": { "shell": {
"open": true "open": true
},
"path": {
"all": true
} }
}, },
"bundle": { "bundle": {

View File

@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { App } from './App' import { App } from './App'
import { describe, test, vi } from 'vitest'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
let listener: ((rect: any) => void) | undefined = undefined let listener: ((rect: any) => void) | undefined = undefined
@ -12,7 +13,19 @@ let listener: ((rect: any) => void) | undefined = undefined
disconnect() {} disconnect() {}
} }
test('renders learn react link', () => { describe('App tests', () => {
test('Renders the modeling app screen, including "Variables" pane.', () => {
vi.mock('react-router-dom', async () => {
const actual = (await vi.importActual('react-router-dom')) as Record<
string,
any
>
return {
...actual,
useParams: () => ({ id: 'new' }),
useLoaderData: () => ({ code: null }),
}
})
render( render(
<BrowserRouter> <BrowserRouter>
<App /> <App />
@ -20,4 +33,7 @@ test('renders learn react link', () => {
) )
const linkElement = screen.getByText(/Variables/i) const linkElement = screen.getByText(/Variables/i)
expect(linkElement).toBeInTheDocument() expect(linkElement).toBeInTheDocument()
vi.restoreAllMocks()
})
}) })

View File

@ -43,8 +43,16 @@ import { useHotkeys } from 'react-hotkeys-hook'
import { TEST } from './env' import { TEST } from './env'
import { getNormalisedCoordinates } from './lib/utils' import { getNormalisedCoordinates } from './lib/utils'
import { getSystemTheme } from './lib/getSystemTheme' import { getSystemTheme } from './lib/getSystemTheme'
import { isTauri } from './lib/isTauri'
import { useLoaderData, useParams } from 'react-router-dom'
import { writeTextFile } from '@tauri-apps/api/fs'
import { FILE_EXT, PROJECT_ENTRYPOINT } from './lib/tauriFS'
import { IndexLoaderData } from './Router'
import { toast } from 'react-hot-toast'
export function App() { export function App() {
const { code: loadedCode } = useLoaderData() as IndexLoaderData
const pathParams = useParams()
const streamRef = useRef<HTMLDivElement>(null) const streamRef = useRef<HTMLDivElement>(null)
useHotKeyListener() useHotKeyListener()
const { const {
@ -151,9 +159,34 @@ export function App() {
? 'opacity-40' ? 'opacity-40'
: '' : ''
// Use file code loaded from disk
// on mount, and overwrite any locally-stored code
useEffect(() => {
if (isTauri() && loadedCode !== null) {
setCode(loadedCode)
}
return () => {
// Clear code on unmount if in desktop app
if (isTauri()) {
setCode('')
}
}
}, [loadedCode, setCode])
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => { // const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
const onChange = (value: string, viewUpdate: ViewUpdate) => { const onChange = (value: string, viewUpdate: ViewUpdate) => {
setCode(value) setCode(value)
if (isTauri() && pathParams.id) {
// Save the file to disk
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
writeTextFile(pathParams.id + '/' + PROJECT_ENTRYPOINT, value).catch(
(err) => {
// TODO: add Sentry per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254)
console.error('error saving file', err)
toast.error('Error saving file, please check file permissions')
}
)
}
if (editorView) { if (editorView) {
editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) }) editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
} }
@ -413,6 +446,11 @@ export function App() {
paneOpacity + paneOpacity +
(isMouseDownInStream ? ' pointer-events-none' : '') (isMouseDownInStream ? ' pointer-events-none' : '')
} }
filename={
pathParams.id
?.slice(pathParams.id.lastIndexOf('/') + 1)
.replace(FILE_EXT, '') || ''
}
/> />
<ModalContainer /> <ModalContainer />
<Resizable <Resizable

View File

@ -13,6 +13,16 @@ import Onboarding, {
} from './routes/Onboarding' } from './routes/Onboarding'
import SignIn from './routes/SignIn' import SignIn from './routes/SignIn'
import { Auth } from './Auth' 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, type Metadata } from 'tauri-plugin-fs-extra-api'
const prependRoutes = const prependRoutes =
(routesObject: Record<string, string>) => (prepend: string) => { (routesObject: Record<string, string>) => (prepend: string) => {
@ -26,16 +36,34 @@ const prependRoutes =
export const paths = { export const paths = {
INDEX: '/', INDEX: '/',
HOME: '/home',
FILE: '/file',
SETTINGS: '/settings', SETTINGS: '/settings',
SIGN_IN: '/signin', SIGN_IN: '/signin',
ONBOARDING: prependRoutes(onboardingPaths)( ONBOARDING: prependRoutes(onboardingPaths)(
'/onboarding/' '/onboarding'
) as typeof onboardingPaths, ) as typeof onboardingPaths,
} }
export type IndexLoaderData = {
code: string | null
}
export type ProjectWithEntryPointMetadata = FileEntry & {
entrypoint_metadata: Metadata
}
export type HomeLoaderData = {
projects: ProjectWithEntryPointMetadata[]
}
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
path: paths.INDEX, path: paths.INDEX,
loader: () =>
isTauri() ? redirect(paths.HOME) : redirect(paths.FILE + '/new'),
},
{
path: paths.FILE + '/:id',
element: ( element: (
<Auth> <Auth>
<Outlet /> <Outlet />
@ -43,7 +71,10 @@ const router = createBrowserRouter([
</Auth> </Auth>
), ),
errorElement: <ErrorPage />, errorElement: <ErrorPage />,
loader: ({ request }) => { loader: async ({
request,
params,
}): Promise<IndexLoaderData | Response> => {
const store = localStorage.getItem('store') const store = localStorage.getItem('store')
if (store === null) { if (store === null) {
return redirect(paths.ONBOARDING.INDEX) return redirect(paths.ONBOARDING.INDEX)
@ -60,23 +91,72 @@ const router = createBrowserRouter([
notEnRouteToOnboarding && hasValidOnboardingStatus notEnRouteToOnboarding && hasValidOnboardingStatus
if (shouldRedirectToOnboarding) { if (shouldRedirectToOnboarding) {
return redirect(paths.ONBOARDING.INDEX + status) return redirect(makeUrlPathRelative(paths.ONBOARDING.INDEX) + status)
} }
} }
return null
if (params.id && params.id !== 'new') {
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
const code = await readTextFile(params.id + '/' + PROJECT_ENTRYPOINT)
return {
code,
}
}
return {
code: '',
}
}, },
children: [ children: [
{ {
path: paths.SETTINGS, path: makeUrlPathRelative(paths.SETTINGS),
element: <Settings />, element: <Settings />,
}, },
{ {
path: paths.ONBOARDING.INDEX, path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
element: <Onboarding />, element: <Onboarding />,
children: onboardingRoutes, children: onboardingRoutes,
}, },
], ],
}, },
{
path: paths.HOME,
element: (
<Auth>
<Outlet />
<Home />
</Auth>
),
loader: async () => {
if (!isTauri()) {
return redirect(paths.FILE + '/new')
}
const projectDir = await initializeProjectDirectory()
const projectsNoMeta = (await readDir(projectDir.dir)).filter(
isProjectDirectory
)
const projects = await Promise.all(
projectsNoMeta.map(async (p) => ({
entrypoint_metadata: await metadata(
p.path + '/' + PROJECT_ENTRYPOINT
),
...p,
}))
)
return {
projects,
}
},
children: [
{
path: makeUrlPathRelative(paths.SETTINGS),
element: <Settings />,
},
],
},
{ {
path: paths.SIGN_IN, path: paths.SIGN_IN,
element: <SignIn />, element: <SignIn />,

View File

@ -1,52 +1,92 @@
import { Link } from 'react-router-dom'
import { ActionIcon, ActionIconProps } from './ActionIcon' import { ActionIcon, ActionIconProps } from './ActionIcon'
import React from 'react' import React from 'react'
import { paths } from '../Router' import { paths } from '../Router'
import { Link } from 'react-router-dom'
import type { LinkProps } from 'react-router-dom'
interface ActionButtonProps extends React.PropsWithChildren { interface BaseActionButtonProps {
icon?: ActionIconProps icon?: ActionIconProps
className?: string className?: string
onClick?: () => void
to?: string
Element?:
| 'button'
| 'link'
| React.ComponentType<React.HTMLAttributes<HTMLButtonElement>>
} }
export const ActionButton = ({ type ActionButtonAsButton = BaseActionButtonProps &
icon, Omit<
className, React.ButtonHTMLAttributes<HTMLButtonElement>,
onClick, keyof BaseActionButtonProps
to = paths.INDEX, > & {
Element = 'button', Element: 'button'
children, }
...props
}: ActionButtonProps) => {
const classNames = `group mono text-base flex items-center gap-2 rounded-sm border border-chalkboard-40 dark:border-chalkboard-60 hover:border-liquid-40 dark:hover:bg-chalkboard-90 p-[3px] text-chalkboard-110 dark:text-chalkboard-10 hover:text-chalkboard-110 hover:dark:text-chalkboard-10 ${
icon ? 'pr-2' : 'px-2'
} ${className}`
if (Element === 'button') { type ActionButtonAsLink = BaseActionButtonProps &
Omit<LinkProps, keyof BaseActionButtonProps> & {
Element: 'link'
}
type ActionButtonAsExternal = BaseActionButtonProps &
Omit<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
keyof BaseActionButtonProps
> & {
Element: 'externalLink'
}
type ActionButtonAsElement = BaseActionButtonProps &
Omit<React.HTMLAttributes<HTMLElement>, keyof BaseActionButtonProps> & {
Element: React.ComponentType<React.HTMLAttributes<HTMLButtonElement>>
}
type ActionButtonProps =
| ActionButtonAsButton
| ActionButtonAsLink
| ActionButtonAsExternal
| ActionButtonAsElement
export const ActionButton = (props: ActionButtonProps) => {
const classNames = `group mono text-base flex items-center gap-2 rounded-sm border border-chalkboard-40 dark:border-chalkboard-60 hover:border-liquid-40 dark:hover:bg-chalkboard-90 p-[3px] text-chalkboard-110 dark:text-chalkboard-10 hover:text-chalkboard-110 hover:dark:text-chalkboard-10 ${
props.icon ? 'pr-2' : 'px-2'
} ${props.className || ''}`
switch (props.Element) {
case 'button': {
// Note we have to destructure 'className' and 'Element' out of props
// because we don't want to pass them to the button element;
// the same is true for the other cases below.
const { Element, icon, children, className, ...rest } = props
return ( return (
<button onClick={onClick} className={classNames} {...props}> <button className={classNames} {...rest}>
{icon && <ActionIcon {...icon} />} {props.icon && <ActionIcon {...icon} />}
{children} {children}
</button> </button>
) )
} else if (Element === 'link') { }
case 'link': {
const { Element, to, icon, children, className, ...rest } = props
return ( return (
<Link to={to} className={classNames} {...props}> <Link to={to || paths.INDEX} className={classNames} {...rest}>
{icon && <ActionIcon {...icon} />} {icon && <ActionIcon {...icon} />}
{children} {children}
</Link> </Link>
) )
} else { }
case 'externalLink': {
const { Element, icon, children, className, ...rest } = props
return ( return (
<Element onClick={onClick} className={classNames} {...props}> <a className={classNames} {...rest}>
{icon && <ActionIcon {...icon} />} {icon && <ActionIcon {...icon} />}
{children} {children}
</a>
)
}
default: {
const { Element, icon, children, className, ...rest } = props
if (!Element) throw new Error('Element is required')
return (
<Element className={classNames} {...rest}>
{props.icon && <ActionIcon {...props.icon} />}
{children}
</Element> </Element>
) )
} }
}
} }

View File

@ -3,14 +3,17 @@ import { Toolbar } from '../Toolbar'
import { useStore } from '../useStore' import { useStore } from '../useStore'
import UserSidebarMenu from './UserSidebarMenu' import UserSidebarMenu from './UserSidebarMenu'
import { paths } from '../Router' import { paths } from '../Router'
import { isTauri } from '../lib/isTauri'
interface AppHeaderProps extends React.PropsWithChildren { interface AppHeaderProps extends React.PropsWithChildren {
showToolbar?: boolean showToolbar?: boolean
filename?: string
className?: string className?: string
} }
export const AppHeader = ({ export const AppHeader = ({
showToolbar = true, showToolbar = true,
filename = '',
children, children,
className = '', className = '',
}: AppHeaderProps) => { }: AppHeaderProps) => {
@ -25,13 +28,18 @@ export const AppHeader = ({
className className
} }
> >
<Link to={paths.INDEX}> <Link
to={isTauri() ? paths.HOME : paths.INDEX}
className="flex items-center gap-4"
>
<img <img
src="/kitt-arcade-winking.svg" src="/kitt-arcade-winking.svg"
alt="KittyCAD App" alt="KittyCAD App"
className="h-9 w-auto" className="h-9 w-auto"
/> />
<span className="sr-only">KittyCAD App</span> <span className="text-sm text-chalkboard-110 dark:text-chalkboard-20 min-w-max">
{isTauri() && filename ? filename : 'KittyCAD Modeling App'}
</span>
</Link> </Link>
{/* Toolbar if the context deems it */} {/* Toolbar if the context deems it */}
{showToolbar && ( {showToolbar && (

View File

@ -73,6 +73,7 @@ export const DebugPanel = ({ className, ...props }: CollapsiblePanelProps) => {
/> />
</div> </div>
<ActionButton <ActionButton
Element="button"
onClick={() => { onClick={() => {
engineCommandManager?.sendSceneCommand({ engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req', type: 'modeling_cmd_req',

View File

@ -166,6 +166,7 @@ export const ExportButton = () => {
</form> </form>
<div className="flex justify-between mt-6"> <div className="flex justify-between mt-6">
<ActionButton <ActionButton
Element="button"
onClick={closeModal} onClick={closeModal}
icon={{ icon={{
icon: faXmark, icon: faXmark,

View File

@ -0,0 +1,162 @@
import { FormEvent, useState } from 'react'
import { type ProjectWithEntryPointMetadata, paths } from '../Router'
import { Link } from 'react-router-dom'
import { ActionButton } from './ActionButton'
import {
faCheck,
faPenAlt,
faTrashAlt,
faX,
} from '@fortawesome/free-solid-svg-icons'
import { FILE_EXT } from '../lib/tauriFS'
import { Dialog } from '@headlessui/react'
import { useHotkeys } from 'react-hotkeys-hook'
function ProjectCard({
project,
handleRenameProject,
handleDeleteProject,
...props
}: {
project: ProjectWithEntryPointMetadata
handleRenameProject: (
e: FormEvent<HTMLFormElement>,
f: ProjectWithEntryPointMetadata
) => Promise<void>
handleDeleteProject: (f: ProjectWithEntryPointMetadata) => Promise<void>
}) {
useHotkeys('esc', () => setIsEditing(false))
const [isEditing, setIsEditing] = useState(false)
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
function handleSave(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
handleRenameProject(e, project).then(() => setIsEditing(false))
}
function getDisplayedTime(date: Date) {
const startOfToday = new Date()
startOfToday.setHours(0, 0, 0, 0)
return date.getTime() < startOfToday.getTime()
? date.toLocaleDateString()
: date.toLocaleTimeString()
}
return (
<li
{...props}
className="group relative min-h-[5em] p-1 rounded-sm border border-chalkboard-20 dark:border-chalkboard-90 hover:border-chalkboard-30 dark:hover:border-chalkboard-80"
>
{isEditing ? (
<form onSubmit={handleSave} className="flex gap-2 items-center">
<input
className="dark:bg-chalkboard-80 dark:border-chalkboard-40 min-w-0 p-1"
type="text"
id="newProjectName"
name="newProjectName"
autoCorrect="off"
autoCapitalize="off"
defaultValue={project.name}
autoFocus={true}
/>
<div className="flex gap-1 items-center">
<ActionButton
Element="button"
type="submit"
icon={{ icon: faCheck, size: 'sm' }}
className="!p-0"
></ActionButton>
<ActionButton
Element="button"
icon={{ icon: faX, size: 'sm' }}
className="!p-0"
onClick={() => setIsEditing(false)}
/>
</div>
</form>
) : (
<>
<div className="p-1 flex flex-col gap-2">
<Link
to={`${paths.FILE}/${encodeURIComponent(project.path)}`}
className="flex-1"
>
{project.name?.replace(FILE_EXT, '')}
</Link>
<span className="text-chalkboard-40 dark:text-chalkboard-60 text-xs">
Edited {getDisplayedTime(project.entrypoint_metadata.modifiedAt)}
</span>
<div className="absolute bottom-2 right-2 flex gap-1 items-center opacity-0 group-hover:opacity-100 group-focus-within:opacity-100">
<ActionButton
Element="button"
icon={{ icon: faPenAlt, size: 'sm' }}
onClick={() => setIsEditing(true)}
className="!p-0"
/>
<ActionButton
Element="button"
icon={{
icon: faTrashAlt,
size: 'sm',
bgClassName: 'bg-destroy-80 hover:bg-destroy-70',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10',
}}
className="!p-0 hover:border-destroy-40 dark:hover:border-destroy-40"
onClick={() => setIsConfirmingDelete(true)}
/>
</div>
</div>
<Dialog
open={isConfirmingDelete}
onClose={() => setIsConfirmingDelete(false)}
className="relative z-50"
>
<div className="fixed inset-0 bg-chalkboard-110/80 grid place-content-center">
<Dialog.Panel className="rounded p-4 bg-chalkboard-10 dark:bg-chalkboard-100 border border-destroy-80 max-w-2xl">
<Dialog.Title as="h2" className="text-2xl font-bold mb-4">
Delete File
</Dialog.Title>
<Dialog.Description>
This will permanently delete "{project.name || 'this file'}".
</Dialog.Description>
<p className="my-4">
Are you sure you want to delete "{project.name || 'this file'}
"? This action cannot be undone.
</p>
<div className="flex justify-between">
<ActionButton
Element="button"
onClick={async () => {
await handleDeleteProject(project)
setIsConfirmingDelete(false)
}}
icon={{
icon: faTrashAlt,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10',
}}
className="hover:border-destroy-40 dark:hover:border-destroy-40"
>
Delete
</ActionButton>
<ActionButton
Element="button"
onClick={() => setIsConfirmingDelete(false)}
>
Cancel
</ActionButton>
</div>
</Dialog.Panel>
</div>
</Dialog>
</>
)}
</li>
)
}
export default ProjectCard

View File

@ -3,7 +3,8 @@ import { User } from '../useStore'
import UserSidebarMenu from './UserSidebarMenu' import UserSidebarMenu from './UserSidebarMenu'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
it("Renders user's name and email if available", () => { describe('UserSidebarMenu tests', () => {
test("Renders user's name and email if available", () => {
const userWellFormed: User = { const userWellFormed: User = {
id: '8675309', id: '8675309',
name: 'Test User', name: 'Test User',
@ -25,9 +26,9 @@ it("Renders user's name and email if available", () => {
userWellFormed.name || '' userWellFormed.name || ''
) )
expect(screen.getByTestId('email')).toHaveTextContent(userWellFormed.email) expect(screen.getByTestId('email')).toHaveTextContent(userWellFormed.email)
}) })
it("Renders just the user's email if no name is available", () => { test("Renders just the user's email if no name is available", () => {
const userNoName: User = { const userNoName: User = {
id: '8675309', id: '8675309',
email: 'kittycad.sidebar.test@example.com', email: 'kittycad.sidebar.test@example.com',
@ -45,9 +46,9 @@ it("Renders just the user's email if no name is available", () => {
fireEvent.click(screen.getByTestId('user-sidebar-toggle')) fireEvent.click(screen.getByTestId('user-sidebar-toggle'))
expect(screen.getByTestId('username')).toHaveTextContent(userNoName.email) expect(screen.getByTestId('username')).toHaveTextContent(userNoName.email)
}) })
it('Renders a menu button if no user avatar is available', () => { test('Renders a menu button if no user avatar is available', () => {
const userNoAvatar: User = { const userNoAvatar: User = {
id: '8675309', id: '8675309',
name: 'Test User', name: 'Test User',
@ -63,4 +64,5 @@ it('Renders a menu button if no user avatar is available', () => {
) )
expect(screen.getByTestId('user-sidebar-toggle')).toHaveTextContent('Menu') expect(screen.getByTestId('user-sidebar-toggle')).toHaveTextContent('Menu')
})
}) })

View File

@ -5,6 +5,7 @@ import { faBars, faGear, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useState } from 'react' import { useState } from 'react'
import { paths } from '../Router' import { paths } from '../Router'
import makeUrlPathRelative from '../lib/makeUrlPathRelative'
const UserSidebarMenu = ({ user }: { user?: User }) => { const UserSidebarMenu = ({ user }: { user?: User }) => {
const displayedName = getDisplayName(user) const displayedName = getDisplayName(user)
@ -95,13 +96,14 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
)} )}
<div className="p-4 flex flex-col gap-2"> <div className="p-4 flex flex-col gap-2">
<ActionButton <ActionButton
Element="button"
icon={{ icon: faGear }} icon={{ icon: faGear }}
className="border-transparent dark:border-transparent dark:hover:border-liquid-60" className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
onClick={() => { onClick={() => {
// since /settings is a nested route the sidebar doesn't close // since /settings is a nested route the sidebar doesn't close
// automatically when navigating to it // automatically when navigating to it
close() close()
navigate(paths.SETTINGS) navigate(makeUrlPathRelative(paths.SETTINGS))
}} }}
> >
Settings Settings

View File

@ -32,7 +32,7 @@ body.dark {
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
@apply w-2 rounded-sm; @apply w-2 h-2 rounded-sm;
@apply bg-chalkboard-20; @apply bg-chalkboard-20;
} }

View File

@ -0,0 +1,3 @@
export default function makeUrlPathRelative(path: string) {
return path.replace(/^\//, '')
}

48
src/lib/tauriFS.test.ts Normal file
View File

@ -0,0 +1,48 @@
import {
MAX_PADDING,
getNextProjectIndex,
interpolateProjectNameWithIndex,
} from './tauriFS'
describe('Test file utility functions', () => {
it('interpolates a project name without an index', () => {
expect(interpolateProjectNameWithIndex('test', 1)).toBe('test')
})
it('interpolates a project name with an index and no padding', () => {
expect(interpolateProjectNameWithIndex('test-$n', 2)).toBe('test-2')
})
it('interpolates a project name with an index and padding', () => {
expect(interpolateProjectNameWithIndex('test-$nnn', 12)).toBe('test-012')
})
it('interpolates a project name with an index and max padding', () => {
expect(interpolateProjectNameWithIndex('test-$nnnnnnnnnnn', 3)).toBe(
`test-${'0'.repeat(MAX_PADDING)}3`
)
})
const testFiles = [
{
name: 'new-project-04.kcl',
path: '/projects/new-project-04.kcl',
},
{
name: 'new-project-007.kcl',
path: '/projects/new-project-007.kcl',
},
{
name: 'new-project-05.kcl',
path: '/projects/new-project-05.kcl',
},
{
name: 'new-project-0.kcl',
path: '/projects/new-project-0.kcl',
},
]
it('gets the correct next project index', () => {
expect(getNextProjectIndex('new-project-$n', testFiles)).toBe(8)
})
})

143
src/lib/tauriFS.ts Normal file
View File

@ -0,0 +1,143 @@
import { FileEntry, createDir, exists, writeTextFile } from '@tauri-apps/api/fs'
import { documentDir } from '@tauri-apps/api/path'
import { useStore } from '../useStore'
import { isTauri } from './isTauri'
import { ProjectWithEntryPointMetadata } from '../Router'
import { metadata } from 'tauri-plugin-fs-extra-api'
const PROJECT_FOLDER = 'kittycad-modeling-projects'
export const FILE_EXT = '.kcl'
export const PROJECT_ENTRYPOINT = 'main' + FILE_EXT
const INDEX_IDENTIFIER = '$n' // $nn.. will pad the number with 0s
export const MAX_PADDING = 7
// Initializes the project directory and returns the path
export async function initializeProjectDirectory() {
if (!isTauri()) {
throw new Error(
'initializeProjectDirectory() can only be called from a Tauri app'
)
}
const { defaultDir: projectDir, setDefaultDir } = useStore.getState()
if (projectDir && projectDir.dir.length > 0) {
const dirExists = await exists(projectDir.dir)
if (!dirExists) {
await createDir(projectDir.dir, { recursive: true })
}
return projectDir
}
const appData = await documentDir()
const INITIAL_DEFAULT_DIR = {
dir: appData + PROJECT_FOLDER,
}
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR.dir)
if (!defaultDirExists) {
await createDir(INITIAL_DEFAULT_DIR.dir, { recursive: true })
}
setDefaultDir(INITIAL_DEFAULT_DIR)
return INITIAL_DEFAULT_DIR
}
export function isProjectDirectory(fileOrDir: Partial<FileEntry>) {
return (
fileOrDir.children?.length &&
fileOrDir.children.some((child) => child.name === PROJECT_ENTRYPOINT)
)
}
// 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
): Promise<ProjectWithEntryPointMetadata> {
if (!isTauri) {
throw new Error('createNewProject() can only be called from a Tauri app')
}
const dirExists = await exists(path)
if (!dirExists) {
await createDir(path, { recursive: true }).catch((err) => {
console.error('Error creating new directory:', err)
throw err
})
}
await writeTextFile(path + '/' + PROJECT_ENTRYPOINT, '').catch((err) => {
console.error('Error creating new file:', err)
throw err
})
const m = await metadata(path)
return {
name: path.slice(path.lastIndexOf('/') + 1),
path: path,
entrypoint_metadata: m,
children: [
{
name: PROJECT_ENTRYPOINT,
path: 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) {
const regex = new RegExp(
projectName.replace(getPaddedIdentifierRegExp(), '([0-9]+)')
)
return regex
}
// Returns the next available index for a project name
export function getNextProjectIndex(projectName: string, files: FileEntry[]) {
const regex = interpolateProjectName(projectName)
const matches = files.map((file) => file.name?.match(regex))
const indices = matches
.filter(Boolean)
.map((match) => match![1])
.map(Number)
const maxIndex = Math.max(...indices, -1)
return maxIndex + 1
}
// Interpolates the project name with the next available index,
// padding the index with 0s if necessary
export function interpolateProjectNameWithIndex(
projectName: string,
index: number
) {
const regex = getPaddedIdentifierRegExp()
const matches = projectName.match(regex)
const padStartLength = Math.min(
matches !== null ? matches[1]?.length || 0 : 0,
MAX_PADDING
)
return projectName.replace(
regex,
index.toString().padStart(padStartLength + 1, '0')
)
}
export function doesProjectNameNeedInterpolated(projectName: string) {
return projectName.includes(INDEX_IDENTIFIER)
}
function escapeRegExpChars(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function getPaddedIdentifierRegExp() {
const escapedIdentifier = escapeRegExpChars(INDEX_IDENTIFIER)
return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`)
}

258
src/routes/Home.tsx Normal file
View File

@ -0,0 +1,258 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { readDir, removeDir, renameFile } from '@tauri-apps/api/fs'
import {
createNewProject,
getNextProjectIndex,
interpolateProjectNameWithIndex,
doesProjectNameNeedInterpolated,
isProjectDirectory,
PROJECT_ENTRYPOINT,
} from '../lib/tauriFS'
import { ActionButton } from '../components/ActionButton'
import {
faArrowDown,
faArrowUp,
faCircleDot,
faPlus,
} from '@fortawesome/free-solid-svg-icons'
import { useStore } from '../useStore'
import { toast } from 'react-hot-toast'
import { AppHeader } from '../components/AppHeader'
import ProjectCard from '../components/ProjectCard'
import { useLoaderData, useSearchParams } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
import Loading from '../components/Loading'
import { metadata } from 'tauri-plugin-fs-extra-api'
const DESC = ':desc'
// This route only opens in the Tauri desktop context for now,
// as defined in Router.tsx, so we can use the Tauri APIs and types.
const Home = () => {
const [searchParams, setSearchParams] = useSearchParams()
const sort = searchParams.get('sort_by') ?? 'modified:desc'
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
const [isLoading, setIsLoading] = useState(true)
const [projects, setProjects] = useState(loadedProjects || [])
const { defaultDir, defaultProjectName } = useStore((s) => ({
defaultDir: s.defaultDir,
defaultProjectName: s.defaultProjectName,
}))
const refreshProjects = useCallback(
async (projectDir = defaultDir) => {
const readProjects = (
await readDir(projectDir.dir, {
recursive: true,
})
).filter(isProjectDirectory)
const projectsWithMetadata = await Promise.all(
readProjects.map(async (p) => ({
entrypoint_metadata: await metadata(
p.path + '/' + PROJECT_ENTRYPOINT
),
...p,
}))
)
setProjects(projectsWithMetadata)
},
[defaultDir, setProjects]
)
useEffect(() => {
refreshProjects(defaultDir).then(() => {
setIsLoading(false)
})
}, [setIsLoading, refreshProjects, defaultDir])
async function handleNewProject() {
let projectName = defaultProjectName
if (doesProjectNameNeedInterpolated(projectName)) {
const nextIndex = await getNextProjectIndex(defaultProjectName, projects)
projectName = interpolateProjectNameWithIndex(
defaultProjectName,
nextIndex
)
}
await createNewProject(defaultDir.dir + '/' + projectName).catch((err) => {
console.error('Error creating project:', err)
toast.error('Error creating project')
})
await refreshProjects()
toast.success('Project created')
}
async function handleRenameProject(
e: FormEvent<HTMLFormElement>,
project: ProjectWithEntryPointMetadata
) {
const { newProjectName } = Object.fromEntries(
new FormData(e.target as HTMLFormElement)
)
if (newProjectName && project.name && newProjectName !== project.name) {
const dir = project.path?.slice(0, project.path?.lastIndexOf('/'))
await renameFile(project.path, dir + '/' + newProjectName).catch(
(err) => {
console.error('Error renaming project:', err)
toast.error('Error renaming project')
}
)
await refreshProjects()
toast.success('Project renamed')
}
}
async function handleDeleteProject(project: ProjectWithEntryPointMetadata) {
if (project.path) {
await removeDir(project.path, { recursive: true }).catch((err) => {
console.error('Error deleting project:', err)
toast.error('Error deleting project')
})
await refreshProjects()
toast.success('Project deleted')
}
}
function getSortIcon(sortBy: string) {
if (sort === sortBy) {
return faArrowUp
} else if (sort === sortBy + DESC) {
return faArrowDown
}
return faCircleDot
}
function getNextSearchParams(sortBy: string) {
if (sort === null || !sort)
return { sort_by: sortBy + (sortBy !== 'modified' ? DESC : '') }
if (sort.includes(sortBy) && !sort.includes(DESC)) return { sort_by: '' }
return {
sort_by: sortBy + (sort.includes(DESC) ? '' : DESC),
}
}
function getSortFunction(sortBy: string) {
const sortByName = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (a.name && b.name) {
return sortBy.includes('desc')
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
}
return 0
}
const sortByModified = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (
a.entrypoint_metadata?.modifiedAt &&
b.entrypoint_metadata?.modifiedAt
) {
return !sortBy || sortBy.includes('desc')
? b.entrypoint_metadata.modifiedAt.getTime() -
a.entrypoint_metadata.modifiedAt.getTime()
: a.entrypoint_metadata.modifiedAt.getTime() -
b.entrypoint_metadata.modifiedAt.getTime()
}
return 0
}
if (sortBy?.includes('name')) {
return sortByName
} else {
return sortByModified
}
}
return (
<div className="h-screen overflow-hidden relative flex flex-col">
<AppHeader showToolbar={false} />
<div className="my-24 overflow-y-auto max-w-5xl w-full mx-auto">
<section className="flex justify-between">
<h1 className="text-3xl text-bold">Your Projects</h1>
<div className="flex">
<ActionButton
Element="button"
onClick={() => setSearchParams(getNextSearchParams('name'))}
icon={{
icon: getSortIcon('name'),
bgClassName: !sort?.includes('name')
? 'bg-liquid-30 dark:bg-liquid-70'
: '',
}}
>
Name
</ActionButton>
<ActionButton
Element="button"
onClick={() => setSearchParams(getNextSearchParams('modified'))}
icon={{
icon: sort ? getSortIcon('modified') : faArrowDown,
bgClassName: !(
sort?.includes('modified') ||
!sort ||
sort === null
)
? 'bg-liquid-30 dark:bg-liquid-70'
: '',
}}
>
Last Modified
</ActionButton>
</div>
</section>
<section>
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
Are being saved at{' '}
<code className="text-liquid-80 dark:text-liquid-30">
{defaultDir.dir}
</code>
, which you can change in your <Link to="settings">Settings</Link>.
</p>
{isLoading ? (
<Loading>Loading your Projects...</Loading>
) : (
<>
{projects.length > 0 ? (
<ul className="my-8 w-full grid grid-cols-4 gap-4">
{projects.sort(getSortFunction(sort)).map((project) => (
<ProjectCard
key={project.name}
project={project}
handleRenameProject={handleRenameProject}
handleDeleteProject={handleDeleteProject}
/>
))}
</ul>
) : (
<p className="rounded my-8 border border-dashed border-chalkboard-30 dark:border-chalkboard-70 p-4">
No Projects found, ready to make your first one?
</p>
)}
<ActionButton
Element="button"
onClick={handleNewProject}
icon={{ icon: faPlus }}
>
New file
</ActionButton>
</>
)}
</section>
</div>
</div>
)
}
export default Home

View File

@ -1,14 +1,14 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../../components/ActionButton' import { ActionButton } from '../../components/ActionButton'
import { useDismiss, useNextClick } from '.' import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
const Units = () => { export default function Units() {
const { isMouseDownInStream } = useStore((s) => ({ const { isMouseDownInStream } = useStore((s) => ({
isMouseDownInStream: s.isMouseDownInStream, isMouseDownInStream: s.isMouseDownInStream,
})) }))
const dismiss = useDismiss() const dismiss = useDismiss()
const next = useNextClick('sketching') const next = useNextClick(onboardingPaths.SKETCHING)
return ( return (
<div className="fixed grid justify-center items-end inset-0 z-50 pointer-events-none"> <div className="fixed grid justify-center items-end inset-0 z-50 pointer-events-none">
@ -26,6 +26,7 @@ const Units = () => {
</p> </p>
<div className="flex justify-between mt-6"> <div className="flex justify-between mt-6">
<ActionButton <ActionButton
Element="button"
onClick={dismiss} onClick={dismiss}
icon={{ icon={{
icon: faXmark, icon: faXmark,
@ -37,7 +38,11 @@ const Units = () => {
> >
Dismiss Dismiss
</ActionButton> </ActionButton>
<ActionButton onClick={next} icon={{ icon: faArrowRight }}> <ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Sketching Next: Sketching
</ActionButton> </ActionButton>
</div> </div>
@ -45,5 +50,3 @@ const Units = () => {
</div> </div>
) )
} }
export default Units

View File

@ -1,10 +1,10 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../../components/ActionButton' import { ActionButton } from '../../components/ActionButton'
import { useDismiss, useNextClick } from '.' import { onboardingPaths, useDismiss, useNextClick } from '.'
const Introduction = () => { export default function Introduction() {
const dismiss = useDismiss() const dismiss = useDismiss()
const next = useNextClick('units') const next = useNextClick(onboardingPaths.UNITS)
return ( return (
<div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50"> <div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50">
@ -22,6 +22,7 @@ const Introduction = () => {
</p> </p>
<div className="flex justify-between mt-6"> <div className="flex justify-between mt-6">
<ActionButton <ActionButton
Element="button"
onClick={dismiss} onClick={dismiss}
icon={{ icon={{
icon: faXmark, icon: faXmark,
@ -33,7 +34,11 @@ const Introduction = () => {
> >
Dismiss Dismiss
</ActionButton> </ActionButton>
<ActionButton onClick={next} icon={{ icon: faArrowRight }}> <ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Get Started Get Started
</ActionButton> </ActionButton>
</div> </div>
@ -41,5 +46,3 @@ const Introduction = () => {
</div> </div>
) )
} }
export default Introduction

View File

@ -2,7 +2,7 @@ import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../../components/ActionButton' import { ActionButton } from '../../components/ActionButton'
import { useDismiss } from '.' import { useDismiss } from '.'
const Sketching = () => { export default function Sketching() {
const dismiss = useDismiss() const dismiss = useDismiss()
return ( return (
@ -14,6 +14,7 @@ const Sketching = () => {
</p> </p>
<div className="flex justify-between mt-6"> <div className="flex justify-between mt-6">
<ActionButton <ActionButton
Element="button"
onClick={dismiss} onClick={dismiss}
icon={{ icon={{
icon: faXmark, icon: faXmark,
@ -25,7 +26,11 @@ const Sketching = () => {
> >
Dismiss Dismiss
</ActionButton> </ActionButton>
<ActionButton onClick={dismiss} icon={{ icon: faArrowRight }}> <ActionButton
Element="button"
onClick={dismiss}
icon={{ icon: faArrowRight }}
>
Finish Finish
</ActionButton> </ActionButton>
</div> </div>
@ -33,5 +38,3 @@ const Sketching = () => {
</div> </div>
) )
} }
export default Sketching

View File

@ -4,11 +4,11 @@ import { ActionButton } from '../../components/ActionButton'
import { SettingsSection } from '../Settings' import { SettingsSection } from '../Settings'
import { Toggle } from '../../components/Toggle/Toggle' import { Toggle } from '../../components/Toggle/Toggle'
import { useState } from 'react' import { useState } from 'react'
import { useDismiss, useNextClick } from '.' import { onboardingPaths, useDismiss, useNextClick } from '.'
const Units = () => { export default function Units() {
const dismiss = useDismiss() const dismiss = useDismiss()
const next = useNextClick('camera') const next = useNextClick(onboardingPaths.CAMERA)
const { const {
defaultUnitSystem: ogDefaultUnitSystem, defaultUnitSystem: ogDefaultUnitSystem,
setDefaultUnitSystem: saveDefaultUnitSystem, setDefaultUnitSystem: saveDefaultUnitSystem,
@ -67,6 +67,7 @@ const Units = () => {
</SettingsSection> </SettingsSection>
<div className="flex justify-between mt-6"> <div className="flex justify-between mt-6">
<ActionButton <ActionButton
Element="button"
onClick={dismiss} onClick={dismiss}
icon={{ icon={{
icon: faXmark, icon: faXmark,
@ -78,7 +79,11 @@ const Units = () => {
> >
Dismiss Dismiss
</ActionButton> </ActionButton>
<ActionButton onClick={handleNextClick} icon={{ icon: faArrowRight }}> <ActionButton
Element="button"
onClick={handleNextClick}
icon={{ icon: faArrowRight }}
>
Next: Camera Next: Camera
</ActionButton> </ActionButton>
</div> </div>
@ -86,5 +91,3 @@ const Units = () => {
</div> </div>
) )
} }
export default Units

View File

@ -8,12 +8,13 @@ import Camera from './Camera'
import Sketching from './Sketching' import Sketching from './Sketching'
import { useCallback } from 'react' import { useCallback } from 'react'
import { paths } from '../../Router' import { paths } from '../../Router'
import makeUrlPathRelative from '../../lib/makeUrlPathRelative'
export const onboardingPaths = { export const onboardingPaths = {
INDEX: '', INDEX: '/',
UNITS: 'units', UNITS: '/units',
CAMERA: 'camera', CAMERA: '/camera',
SKETCHING: 'sketching', SKETCHING: '/sketching',
} }
export const onboardingRoutes = [ export const onboardingRoutes = [
@ -22,15 +23,15 @@ export const onboardingRoutes = [
element: <Introduction />, element: <Introduction />,
}, },
{ {
path: onboardingPaths.UNITS, path: makeUrlPathRelative(onboardingPaths.UNITS),
element: <Units />, element: <Units />,
}, },
{ {
path: onboardingPaths.CAMERA, path: makeUrlPathRelative(onboardingPaths.CAMERA),
element: <Camera />, element: <Camera />,
}, },
{ {
path: onboardingPaths.SKETCHING, path: makeUrlPathRelative(onboardingPaths.SKETCHING),
element: <Sketching />, element: <Sketching />,
}, },
] ]
@ -43,7 +44,7 @@ export function useNextClick(newStatus: string) {
return useCallback(() => { return useCallback(() => {
setOnboardingStatus(newStatus) setOnboardingStatus(newStatus)
navigate('/onboarding/' + newStatus) navigate((newStatus !== onboardingPaths.UNITS ? '..' : '.') + newStatus)
}, [newStatus, setOnboardingStatus, navigate]) }, [newStatus, setOnboardingStatus, navigate])
} }

View File

@ -16,7 +16,7 @@ import { paths } from '../Router'
export const Settings = () => { export const Settings = () => {
const navigate = useNavigate() const navigate = useNavigate()
useHotkeys('esc', () => navigate(paths.INDEX)) useHotkeys('esc', () => navigate('../'))
const { const {
defaultDir, defaultDir,
setDefaultDir, setDefaultDir,
@ -46,6 +46,7 @@ export const Settings = () => {
theme: s.theme, theme: s.theme,
setTheme: s.setTheme, setTheme: s.setTheme,
})) }))
const ogDefaultDir = useRef(defaultDir)
const ogDefaultProjectName = useRef(defaultProjectName) const ogDefaultProjectName = useRef(defaultProjectName)
async function handleDirectorySelection() { async function handleDirectorySelection() {
@ -65,7 +66,7 @@ export const Settings = () => {
<AppHeader showToolbar={false}> <AppHeader showToolbar={false}>
<ActionButton <ActionButton
Element="link" Element="link"
to={paths.INDEX} to={'../'}
icon={{ icon={{
icon: faXmark, icon: faXmark,
bgClassName: 'bg-destroy-80', bgClassName: 'bg-destroy-80',
@ -93,7 +94,11 @@ export const Settings = () => {
base: defaultDir.base, base: defaultDir.base,
dir: e.target.value, dir: e.target.value,
}) })
}}
onBlur={() => {
ogDefaultDir.current.dir !== defaultDir.dir &&
toast.success('Default directory updated') toast.success('Default directory updated')
ogDefaultDir.current.dir = defaultDir.dir
}} }}
/> />
<ActionButton <ActionButton
@ -126,6 +131,7 @@ export const Settings = () => {
onBlur={() => { onBlur={() => {
ogDefaultProjectName.current !== defaultProjectName && ogDefaultProjectName.current !== defaultProjectName &&
toast.success('Default project name updated') toast.success('Default project name updated')
ogDefaultProjectName.current = defaultProjectName
}} }}
/> />
</SettingsSection> </SettingsSection>
@ -210,9 +216,10 @@ export const Settings = () => {
description="Replay the onboarding process" description="Replay the onboarding process"
> >
<ActionButton <ActionButton
Element="button"
onClick={() => { onClick={() => {
setOnboardingStatus('') setOnboardingStatus('')
navigate(paths.ONBOARDING.INDEX) navigate('..' + paths.ONBOARDING.INDEX)
}} }}
icon={{ icon: faArrowRotateBack }} icon={{ icon: faArrowRotateBack }}
> >

View File

@ -61,6 +61,7 @@ const SignIn = () => {
</p> </p>
{isTauri() ? ( {isTauri() ? (
<ActionButton <ActionButton
Element="button"
onClick={signInTauri} onClick={signInTauri}
icon={{ icon: faSignInAlt }} icon={{ icon: faSignInAlt }}
className="w-fit mt-4" className="w-fit mt-4"

View File

@ -402,10 +402,10 @@ export const useStore = create<StoreState>()(
// tauri specific app settings // tauri specific app settings
defaultDir: { defaultDir: {
dir: '~/Documents/', dir: '',
}, },
setDefaultDir: (dir) => set({ defaultDir: dir }), setDefaultDir: (dir) => set({ defaultDir: dir }),
defaultProjectName: 'new-project-$n', defaultProjectName: 'new-project-$nnn',
setDefaultProjectName: (defaultProjectName) => setDefaultProjectName: (defaultProjectName) =>
set({ defaultProjectName }), set({ defaultProjectName }),
defaultUnitSystem: 'imperial', defaultUnitSystem: 'imperial',

View File

@ -1979,7 +1979,7 @@
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e"
integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==
"@tauri-apps/api@^1.3.0": "@tauri-apps/api@1.4.0", "@tauri-apps/api@^1.3.0":
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-1.4.0.tgz#b4013ca3d17b853f7df29fe14079ebb4d52dbffa" resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-1.4.0.tgz#b4013ca3d17b853f7df29fe14079ebb4d52dbffa"
integrity sha512-Jd6HPoTM1PZSFIzq7FB8VmMu3qSSyo/3lSwLpoapW+lQ41CL5Dow2KryLg+gyazA/58DRWI9vu/XpEeHK4uMdw== integrity sha512-Jd6HPoTM1PZSFIzq7FB8VmMu3qSSyo/3lSwLpoapW+lQ41CL5Dow2KryLg+gyazA/58DRWI9vu/XpEeHK4uMdw==
@ -5613,6 +5613,12 @@ tar@^6.1.11:
mkdirp "^1.0.3" mkdirp "^1.0.3"
yallist "^4.0.0" yallist "^4.0.0"
"tauri-plugin-fs-extra-api@https://github.com/tauri-apps/tauri-plugin-fs-extra#v1":
version "0.0.0"
resolved "https://github.com/tauri-apps/tauri-plugin-fs-extra#1344db48a39b44fe46e9943bf7cddca2fa00caaf"
dependencies:
"@tauri-apps/api" "1.4.0"
test-exclude@^6.0.0: test-exclude@^6.0.0:
version "6.0.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e"