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:
		@ -32,6 +32,7 @@
 | 
			
		||||
    "react-router-dom": "^6.14.2",
 | 
			
		||||
    "sketch-helpers": "^0.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",
 | 
			
		||||
    "ts-node": "^10.9.1",
 | 
			
		||||
    "typescript": "^4.4.2",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										13
									
								
								src-tauri/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										13
									
								
								src-tauri/Cargo.lock
									
									
									
										generated
									
									
									
								
							@ -81,6 +81,7 @@ dependencies = [
 | 
			
		||||
 "serde_json",
 | 
			
		||||
 "tauri",
 | 
			
		||||
 "tauri-build",
 | 
			
		||||
 "tauri-plugin-fs-extra",
 | 
			
		||||
 "tokio",
 | 
			
		||||
 "toml 0.6.0",
 | 
			
		||||
]
 | 
			
		||||
@ -3120,6 +3121,18 @@ dependencies = [
 | 
			
		||||
 "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]]
 | 
			
		||||
name = "tauri-runtime"
 | 
			
		||||
version = "0.13.0"
 | 
			
		||||
 | 
			
		||||
@ -19,9 +19,10 @@ anyhow = "1"
 | 
			
		||||
oauth2 = "4.4.1"
 | 
			
		||||
serde = { version = "1.0", features = ["derive"] }
 | 
			
		||||
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"] }
 | 
			
		||||
toml = "0.6.0"
 | 
			
		||||
tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
 | 
			
		||||
 | 
			
		||||
[features]
 | 
			
		||||
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
 | 
			
		||||
 | 
			
		||||
@ -98,6 +98,7 @@ fn main() {
 | 
			
		||||
            Ok(())
 | 
			
		||||
        })
 | 
			
		||||
        .invoke_handler(tauri::generate_handler![login, read_toml, read_txt_file])
 | 
			
		||||
        .plugin(tauri_plugin_fs_extra::init())
 | 
			
		||||
        .run(tauri::generate_context!())
 | 
			
		||||
        .expect("error while running tauri application");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -22,9 +22,7 @@
 | 
			
		||||
        "save": true
 | 
			
		||||
      },
 | 
			
		||||
      "fs": {
 | 
			
		||||
        "scope": [
 | 
			
		||||
          "$HOME/**/*"
 | 
			
		||||
        ],
 | 
			
		||||
        "scope": ["$HOME/**/*", "$APPDATA/**/*"],
 | 
			
		||||
        "all": true
 | 
			
		||||
      },
 | 
			
		||||
      "http": {
 | 
			
		||||
@ -37,6 +35,9 @@
 | 
			
		||||
      },
 | 
			
		||||
      "shell": {
 | 
			
		||||
        "open": true
 | 
			
		||||
      },
 | 
			
		||||
      "path": {
 | 
			
		||||
        "all": true
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "bundle": {
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import { render, screen } from '@testing-library/react'
 | 
			
		||||
import { App } from './App'
 | 
			
		||||
import { describe, test, vi } from 'vitest'
 | 
			
		||||
import { BrowserRouter } from 'react-router-dom'
 | 
			
		||||
 | 
			
		||||
let listener: ((rect: any) => void) | undefined = undefined
 | 
			
		||||
@ -12,12 +13,27 @@ let listener: ((rect: any) => void) | undefined = undefined
 | 
			
		||||
  disconnect() {}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
test('renders learn react link', () => {
 | 
			
		||||
  render(
 | 
			
		||||
    <BrowserRouter>
 | 
			
		||||
      <App />
 | 
			
		||||
    </BrowserRouter>
 | 
			
		||||
  )
 | 
			
		||||
  const linkElement = screen.getByText(/Variables/i)
 | 
			
		||||
  expect(linkElement).toBeInTheDocument()
 | 
			
		||||
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(
 | 
			
		||||
      <BrowserRouter>
 | 
			
		||||
        <App />
 | 
			
		||||
      </BrowserRouter>
 | 
			
		||||
    )
 | 
			
		||||
    const linkElement = screen.getByText(/Variables/i)
 | 
			
		||||
    expect(linkElement).toBeInTheDocument()
 | 
			
		||||
 | 
			
		||||
    vi.restoreAllMocks()
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										38
									
								
								src/App.tsx
									
									
									
									
									
								
							
							
						
						
									
										38
									
								
								src/App.tsx
									
									
									
									
									
								
							@ -43,8 +43,16 @@ import { useHotkeys } from 'react-hotkeys-hook'
 | 
			
		||||
import { TEST } from './env'
 | 
			
		||||
import { getNormalisedCoordinates } from './lib/utils'
 | 
			
		||||
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() {
 | 
			
		||||
  const { code: loadedCode } = useLoaderData() as IndexLoaderData
 | 
			
		||||
  const pathParams = useParams()
 | 
			
		||||
  const streamRef = useRef<HTMLDivElement>(null)
 | 
			
		||||
  useHotKeyListener()
 | 
			
		||||
  const {
 | 
			
		||||
@ -151,9 +159,34 @@ export function App() {
 | 
			
		||||
      ? '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 = (value: string, viewUpdate: ViewUpdate) => {
 | 
			
		||||
    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) {
 | 
			
		||||
      editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
 | 
			
		||||
    }
 | 
			
		||||
@ -413,6 +446,11 @@ export function App() {
 | 
			
		||||
          paneOpacity +
 | 
			
		||||
          (isMouseDownInStream ? ' pointer-events-none' : '')
 | 
			
		||||
        }
 | 
			
		||||
        filename={
 | 
			
		||||
          pathParams.id
 | 
			
		||||
            ?.slice(pathParams.id.lastIndexOf('/') + 1)
 | 
			
		||||
            .replace(FILE_EXT, '') || ''
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      <ModalContainer />
 | 
			
		||||
      <Resizable
 | 
			
		||||
 | 
			
		||||
@ -13,6 +13,16 @@ import Onboarding, {
 | 
			
		||||
} from './routes/Onboarding'
 | 
			
		||||
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, type Metadata } from 'tauri-plugin-fs-extra-api'
 | 
			
		||||
 | 
			
		||||
const prependRoutes =
 | 
			
		||||
  (routesObject: Record<string, string>) => (prepend: string) => {
 | 
			
		||||
@ -26,16 +36,34 @@ const prependRoutes =
 | 
			
		||||
 | 
			
		||||
export const paths = {
 | 
			
		||||
  INDEX: '/',
 | 
			
		||||
  HOME: '/home',
 | 
			
		||||
  FILE: '/file',
 | 
			
		||||
  SETTINGS: '/settings',
 | 
			
		||||
  SIGN_IN: '/signin',
 | 
			
		||||
  ONBOARDING: prependRoutes(onboardingPaths)(
 | 
			
		||||
    '/onboarding/'
 | 
			
		||||
    '/onboarding'
 | 
			
		||||
  ) as typeof onboardingPaths,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type IndexLoaderData = {
 | 
			
		||||
  code: string | null
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type ProjectWithEntryPointMetadata = FileEntry & {
 | 
			
		||||
  entrypoint_metadata: Metadata
 | 
			
		||||
}
 | 
			
		||||
export type HomeLoaderData = {
 | 
			
		||||
  projects: ProjectWithEntryPointMetadata[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const router = createBrowserRouter([
 | 
			
		||||
  {
 | 
			
		||||
    path: paths.INDEX,
 | 
			
		||||
    loader: () =>
 | 
			
		||||
      isTauri() ? redirect(paths.HOME) : redirect(paths.FILE + '/new'),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: paths.FILE + '/:id',
 | 
			
		||||
    element: (
 | 
			
		||||
      <Auth>
 | 
			
		||||
        <Outlet />
 | 
			
		||||
@ -43,7 +71,10 @@ const router = createBrowserRouter([
 | 
			
		||||
      </Auth>
 | 
			
		||||
    ),
 | 
			
		||||
    errorElement: <ErrorPage />,
 | 
			
		||||
    loader: ({ request }) => {
 | 
			
		||||
    loader: async ({
 | 
			
		||||
      request,
 | 
			
		||||
      params,
 | 
			
		||||
    }): Promise<IndexLoaderData | Response> => {
 | 
			
		||||
      const store = localStorage.getItem('store')
 | 
			
		||||
      if (store === null) {
 | 
			
		||||
        return redirect(paths.ONBOARDING.INDEX)
 | 
			
		||||
@ -60,23 +91,72 @@ const router = createBrowserRouter([
 | 
			
		||||
          notEnRouteToOnboarding && hasValidOnboardingStatus
 | 
			
		||||
 | 
			
		||||
        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: [
 | 
			
		||||
      {
 | 
			
		||||
        path: paths.SETTINGS,
 | 
			
		||||
        path: makeUrlPathRelative(paths.SETTINGS),
 | 
			
		||||
        element: <Settings />,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        path: paths.ONBOARDING.INDEX,
 | 
			
		||||
        path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
 | 
			
		||||
        element: <Onboarding />,
 | 
			
		||||
        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,
 | 
			
		||||
    element: <SignIn />,
 | 
			
		||||
 | 
			
		||||
@ -1,52 +1,92 @@
 | 
			
		||||
import { Link } from 'react-router-dom'
 | 
			
		||||
import { ActionIcon, ActionIconProps } from './ActionIcon'
 | 
			
		||||
import React from 'react'
 | 
			
		||||
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
 | 
			
		||||
  className?: string
 | 
			
		||||
  onClick?: () => void
 | 
			
		||||
  to?: string
 | 
			
		||||
  Element?:
 | 
			
		||||
    | 'button'
 | 
			
		||||
    | 'link'
 | 
			
		||||
    | React.ComponentType<React.HTMLAttributes<HTMLButtonElement>>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ActionButton = ({
 | 
			
		||||
  icon,
 | 
			
		||||
  className,
 | 
			
		||||
  onClick,
 | 
			
		||||
  to = paths.INDEX,
 | 
			
		||||
  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}`
 | 
			
		||||
type ActionButtonAsButton = BaseActionButtonProps &
 | 
			
		||||
  Omit<
 | 
			
		||||
    React.ButtonHTMLAttributes<HTMLButtonElement>,
 | 
			
		||||
    keyof BaseActionButtonProps
 | 
			
		||||
  > & {
 | 
			
		||||
    Element: 'button'
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (Element === 'button') {
 | 
			
		||||
    return (
 | 
			
		||||
      <button onClick={onClick} className={classNames} {...props}>
 | 
			
		||||
        {icon && <ActionIcon {...icon} />}
 | 
			
		||||
        {children}
 | 
			
		||||
      </button>
 | 
			
		||||
    )
 | 
			
		||||
  } else if (Element === 'link') {
 | 
			
		||||
    return (
 | 
			
		||||
      <Link to={to} className={classNames} {...props}>
 | 
			
		||||
        {icon && <ActionIcon {...icon} />}
 | 
			
		||||
        {children}
 | 
			
		||||
      </Link>
 | 
			
		||||
    )
 | 
			
		||||
  } else {
 | 
			
		||||
    return (
 | 
			
		||||
      <Element onClick={onClick} className={classNames} {...props}>
 | 
			
		||||
        {icon && <ActionIcon {...icon} />}
 | 
			
		||||
        {children}
 | 
			
		||||
      </Element>
 | 
			
		||||
    )
 | 
			
		||||
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 (
 | 
			
		||||
        <button className={classNames} {...rest}>
 | 
			
		||||
          {props.icon && <ActionIcon {...icon} />}
 | 
			
		||||
          {children}
 | 
			
		||||
        </button>
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
    case 'link': {
 | 
			
		||||
      const { Element, to, icon, children, className, ...rest } = props
 | 
			
		||||
      return (
 | 
			
		||||
        <Link to={to || paths.INDEX} className={classNames} {...rest}>
 | 
			
		||||
          {icon && <ActionIcon {...icon} />}
 | 
			
		||||
          {children}
 | 
			
		||||
        </Link>
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
    case 'externalLink': {
 | 
			
		||||
      const { Element, icon, children, className, ...rest } = props
 | 
			
		||||
      return (
 | 
			
		||||
        <a className={classNames} {...rest}>
 | 
			
		||||
          {icon && <ActionIcon {...icon} />}
 | 
			
		||||
          {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>
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -3,14 +3,17 @@ import { Toolbar } from '../Toolbar'
 | 
			
		||||
import { useStore } from '../useStore'
 | 
			
		||||
import UserSidebarMenu from './UserSidebarMenu'
 | 
			
		||||
import { paths } from '../Router'
 | 
			
		||||
import { isTauri } from '../lib/isTauri'
 | 
			
		||||
 | 
			
		||||
interface AppHeaderProps extends React.PropsWithChildren {
 | 
			
		||||
  showToolbar?: boolean
 | 
			
		||||
  filename?: string
 | 
			
		||||
  className?: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const AppHeader = ({
 | 
			
		||||
  showToolbar = true,
 | 
			
		||||
  filename = '',
 | 
			
		||||
  children,
 | 
			
		||||
  className = '',
 | 
			
		||||
}: AppHeaderProps) => {
 | 
			
		||||
@ -25,13 +28,18 @@ export const AppHeader = ({
 | 
			
		||||
        className
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <Link to={paths.INDEX}>
 | 
			
		||||
      <Link
 | 
			
		||||
        to={isTauri() ? paths.HOME : paths.INDEX}
 | 
			
		||||
        className="flex items-center gap-4"
 | 
			
		||||
      >
 | 
			
		||||
        <img
 | 
			
		||||
          src="/kitt-arcade-winking.svg"
 | 
			
		||||
          alt="KittyCAD App"
 | 
			
		||||
          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>
 | 
			
		||||
      {/* Toolbar if the context deems it */}
 | 
			
		||||
      {showToolbar && (
 | 
			
		||||
 | 
			
		||||
@ -73,6 +73,7 @@ export const DebugPanel = ({ className, ...props }: CollapsiblePanelProps) => {
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
        <ActionButton
 | 
			
		||||
          Element="button"
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            engineCommandManager?.sendSceneCommand({
 | 
			
		||||
              type: 'modeling_cmd_req',
 | 
			
		||||
 | 
			
		||||
@ -166,6 +166,7 @@ export const ExportButton = () => {
 | 
			
		||||
          </form>
 | 
			
		||||
          <div className="flex justify-between mt-6">
 | 
			
		||||
            <ActionButton
 | 
			
		||||
              Element="button"
 | 
			
		||||
              onClick={closeModal}
 | 
			
		||||
              icon={{
 | 
			
		||||
                icon: faXmark,
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										162
									
								
								src/components/ProjectCard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								src/components/ProjectCard.tsx
									
									
									
									
									
										Normal 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
 | 
			
		||||
@ -3,64 +3,66 @@ import { User } from '../useStore'
 | 
			
		||||
import UserSidebarMenu from './UserSidebarMenu'
 | 
			
		||||
import { BrowserRouter } from 'react-router-dom'
 | 
			
		||||
 | 
			
		||||
it("Renders user's name and email if available", () => {
 | 
			
		||||
  const userWellFormed: User = {
 | 
			
		||||
    id: '8675309',
 | 
			
		||||
    name: 'Test User',
 | 
			
		||||
    email: 'kittycad.sidebar.test@example.com',
 | 
			
		||||
    image: 'https://placekitten.com/200/200',
 | 
			
		||||
    created_at: 'yesteryear',
 | 
			
		||||
    updated_at: 'today',
 | 
			
		||||
  }
 | 
			
		||||
describe('UserSidebarMenu tests', () => {
 | 
			
		||||
  test("Renders user's name and email if available", () => {
 | 
			
		||||
    const userWellFormed: User = {
 | 
			
		||||
      id: '8675309',
 | 
			
		||||
      name: 'Test User',
 | 
			
		||||
      email: 'kittycad.sidebar.test@example.com',
 | 
			
		||||
      image: 'https://placekitten.com/200/200',
 | 
			
		||||
      created_at: 'yesteryear',
 | 
			
		||||
      updated_at: 'today',
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  render(
 | 
			
		||||
    <BrowserRouter>
 | 
			
		||||
      <UserSidebarMenu user={userWellFormed} />
 | 
			
		||||
    </BrowserRouter>
 | 
			
		||||
  )
 | 
			
		||||
    render(
 | 
			
		||||
      <BrowserRouter>
 | 
			
		||||
        <UserSidebarMenu user={userWellFormed} />
 | 
			
		||||
      </BrowserRouter>
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
  fireEvent.click(screen.getByTestId('user-sidebar-toggle'))
 | 
			
		||||
    fireEvent.click(screen.getByTestId('user-sidebar-toggle'))
 | 
			
		||||
 | 
			
		||||
  expect(screen.getByTestId('username')).toHaveTextContent(
 | 
			
		||||
    userWellFormed.name || ''
 | 
			
		||||
  )
 | 
			
		||||
  expect(screen.getByTestId('email')).toHaveTextContent(userWellFormed.email)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
it("Renders just the user's email if no name is available", () => {
 | 
			
		||||
  const userNoName: User = {
 | 
			
		||||
    id: '8675309',
 | 
			
		||||
    email: 'kittycad.sidebar.test@example.com',
 | 
			
		||||
    image: 'https://placekitten.com/200/200',
 | 
			
		||||
    created_at: 'yesteryear',
 | 
			
		||||
    updated_at: 'today',
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render(
 | 
			
		||||
    <BrowserRouter>
 | 
			
		||||
      <UserSidebarMenu user={userNoName} />
 | 
			
		||||
    </BrowserRouter>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  fireEvent.click(screen.getByTestId('user-sidebar-toggle'))
 | 
			
		||||
 | 
			
		||||
  expect(screen.getByTestId('username')).toHaveTextContent(userNoName.email)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
it('Renders a menu button if no user avatar is available', () => {
 | 
			
		||||
  const userNoAvatar: User = {
 | 
			
		||||
    id: '8675309',
 | 
			
		||||
    name: 'Test User',
 | 
			
		||||
    email: 'kittycad.sidebar.test@example.com',
 | 
			
		||||
    created_at: 'yesteryear',
 | 
			
		||||
    updated_at: 'today',
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render(
 | 
			
		||||
    <BrowserRouter>
 | 
			
		||||
      <UserSidebarMenu user={userNoAvatar} />
 | 
			
		||||
    </BrowserRouter>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  expect(screen.getByTestId('user-sidebar-toggle')).toHaveTextContent('Menu')
 | 
			
		||||
    expect(screen.getByTestId('username')).toHaveTextContent(
 | 
			
		||||
      userWellFormed.name || ''
 | 
			
		||||
    )
 | 
			
		||||
    expect(screen.getByTestId('email')).toHaveTextContent(userWellFormed.email)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  test("Renders just the user's email if no name is available", () => {
 | 
			
		||||
    const userNoName: User = {
 | 
			
		||||
      id: '8675309',
 | 
			
		||||
      email: 'kittycad.sidebar.test@example.com',
 | 
			
		||||
      image: 'https://placekitten.com/200/200',
 | 
			
		||||
      created_at: 'yesteryear',
 | 
			
		||||
      updated_at: 'today',
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render(
 | 
			
		||||
      <BrowserRouter>
 | 
			
		||||
        <UserSidebarMenu user={userNoName} />
 | 
			
		||||
      </BrowserRouter>
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    fireEvent.click(screen.getByTestId('user-sidebar-toggle'))
 | 
			
		||||
 | 
			
		||||
    expect(screen.getByTestId('username')).toHaveTextContent(userNoName.email)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  test('Renders a menu button if no user avatar is available', () => {
 | 
			
		||||
    const userNoAvatar: User = {
 | 
			
		||||
      id: '8675309',
 | 
			
		||||
      name: 'Test User',
 | 
			
		||||
      email: 'kittycad.sidebar.test@example.com',
 | 
			
		||||
      created_at: 'yesteryear',
 | 
			
		||||
      updated_at: 'today',
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render(
 | 
			
		||||
      <BrowserRouter>
 | 
			
		||||
        <UserSidebarMenu user={userNoAvatar} />
 | 
			
		||||
      </BrowserRouter>
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    expect(screen.getByTestId('user-sidebar-toggle')).toHaveTextContent('Menu')
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
@ -5,6 +5,7 @@ import { faBars, faGear, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
 | 
			
		||||
import { useNavigate } from 'react-router-dom'
 | 
			
		||||
import { useState } from 'react'
 | 
			
		||||
import { paths } from '../Router'
 | 
			
		||||
import makeUrlPathRelative from '../lib/makeUrlPathRelative'
 | 
			
		||||
 | 
			
		||||
const UserSidebarMenu = ({ user }: { user?: User }) => {
 | 
			
		||||
  const displayedName = getDisplayName(user)
 | 
			
		||||
@ -95,13 +96,14 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
 | 
			
		||||
            )}
 | 
			
		||||
            <div className="p-4 flex flex-col gap-2">
 | 
			
		||||
              <ActionButton
 | 
			
		||||
                Element="button"
 | 
			
		||||
                icon={{ icon: faGear }}
 | 
			
		||||
                className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  // since /settings is a nested route the sidebar doesn't close
 | 
			
		||||
                  // automatically when navigating to it
 | 
			
		||||
                  close()
 | 
			
		||||
                  navigate(paths.SETTINGS)
 | 
			
		||||
                  navigate(makeUrlPathRelative(paths.SETTINGS))
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                Settings
 | 
			
		||||
 | 
			
		||||
@ -32,7 +32,7 @@ body.dark {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
::-webkit-scrollbar {
 | 
			
		||||
  @apply w-2 rounded-sm;
 | 
			
		||||
  @apply w-2 h-2 rounded-sm;
 | 
			
		||||
  @apply bg-chalkboard-20;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								src/lib/makeUrlPathRelative.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/lib/makeUrlPathRelative.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,3 @@
 | 
			
		||||
export default function makeUrlPathRelative(path: string) {
 | 
			
		||||
  return path.replace(/^\//, '')
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										48
									
								
								src/lib/tauriFS.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/lib/tauriFS.test.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										143
									
								
								src/lib/tauriFS.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										258
									
								
								src/routes/Home.tsx
									
									
									
									
									
										Normal 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
 | 
			
		||||
@ -1,14 +1,14 @@
 | 
			
		||||
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
 | 
			
		||||
import { ActionButton } from '../../components/ActionButton'
 | 
			
		||||
import { useDismiss, useNextClick } from '.'
 | 
			
		||||
import { onboardingPaths, useDismiss, useNextClick } from '.'
 | 
			
		||||
import { useStore } from '../../useStore'
 | 
			
		||||
 | 
			
		||||
const Units = () => {
 | 
			
		||||
export default function Units() {
 | 
			
		||||
  const { isMouseDownInStream } = useStore((s) => ({
 | 
			
		||||
    isMouseDownInStream: s.isMouseDownInStream,
 | 
			
		||||
  }))
 | 
			
		||||
  const dismiss = useDismiss()
 | 
			
		||||
  const next = useNextClick('sketching')
 | 
			
		||||
  const next = useNextClick(onboardingPaths.SKETCHING)
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="fixed grid justify-center items-end inset-0 z-50 pointer-events-none">
 | 
			
		||||
@ -26,6 +26,7 @@ const Units = () => {
 | 
			
		||||
        </p>
 | 
			
		||||
        <div className="flex justify-between mt-6">
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            onClick={dismiss}
 | 
			
		||||
            icon={{
 | 
			
		||||
              icon: faXmark,
 | 
			
		||||
@ -37,7 +38,11 @@ const Units = () => {
 | 
			
		||||
          >
 | 
			
		||||
            Dismiss
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
          <ActionButton onClick={next} icon={{ icon: faArrowRight }}>
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            onClick={next}
 | 
			
		||||
            icon={{ icon: faArrowRight }}
 | 
			
		||||
          >
 | 
			
		||||
            Next: Sketching
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
        </div>
 | 
			
		||||
@ -45,5 +50,3 @@ const Units = () => {
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Units
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,10 @@
 | 
			
		||||
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
 | 
			
		||||
import { ActionButton } from '../../components/ActionButton'
 | 
			
		||||
import { useDismiss, useNextClick } from '.'
 | 
			
		||||
import { onboardingPaths, useDismiss, useNextClick } from '.'
 | 
			
		||||
 | 
			
		||||
const Introduction = () => {
 | 
			
		||||
export default function Introduction() {
 | 
			
		||||
  const dismiss = useDismiss()
 | 
			
		||||
  const next = useNextClick('units')
 | 
			
		||||
  const next = useNextClick(onboardingPaths.UNITS)
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50">
 | 
			
		||||
@ -22,6 +22,7 @@ const Introduction = () => {
 | 
			
		||||
        </p>
 | 
			
		||||
        <div className="flex justify-between mt-6">
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            onClick={dismiss}
 | 
			
		||||
            icon={{
 | 
			
		||||
              icon: faXmark,
 | 
			
		||||
@ -33,7 +34,11 @@ const Introduction = () => {
 | 
			
		||||
          >
 | 
			
		||||
            Dismiss
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
          <ActionButton onClick={next} icon={{ icon: faArrowRight }}>
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            onClick={next}
 | 
			
		||||
            icon={{ icon: faArrowRight }}
 | 
			
		||||
          >
 | 
			
		||||
            Get Started
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
        </div>
 | 
			
		||||
@ -41,5 +46,3 @@ const Introduction = () => {
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Introduction
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
 | 
			
		||||
import { ActionButton } from '../../components/ActionButton'
 | 
			
		||||
import { useDismiss } from '.'
 | 
			
		||||
 | 
			
		||||
const Sketching = () => {
 | 
			
		||||
export default function Sketching() {
 | 
			
		||||
  const dismiss = useDismiss()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
@ -14,6 +14,7 @@ const Sketching = () => {
 | 
			
		||||
        </p>
 | 
			
		||||
        <div className="flex justify-between mt-6">
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            onClick={dismiss}
 | 
			
		||||
            icon={{
 | 
			
		||||
              icon: faXmark,
 | 
			
		||||
@ -25,7 +26,11 @@ const Sketching = () => {
 | 
			
		||||
          >
 | 
			
		||||
            Dismiss
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
          <ActionButton onClick={dismiss} icon={{ icon: faArrowRight }}>
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            onClick={dismiss}
 | 
			
		||||
            icon={{ icon: faArrowRight }}
 | 
			
		||||
          >
 | 
			
		||||
            Finish
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
        </div>
 | 
			
		||||
@ -33,5 +38,3 @@ const Sketching = () => {
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Sketching
 | 
			
		||||
 | 
			
		||||
@ -4,11 +4,11 @@ import { ActionButton } from '../../components/ActionButton'
 | 
			
		||||
import { SettingsSection } from '../Settings'
 | 
			
		||||
import { Toggle } from '../../components/Toggle/Toggle'
 | 
			
		||||
import { useState } from 'react'
 | 
			
		||||
import { useDismiss, useNextClick } from '.'
 | 
			
		||||
import { onboardingPaths, useDismiss, useNextClick } from '.'
 | 
			
		||||
 | 
			
		||||
const Units = () => {
 | 
			
		||||
export default function Units() {
 | 
			
		||||
  const dismiss = useDismiss()
 | 
			
		||||
  const next = useNextClick('camera')
 | 
			
		||||
  const next = useNextClick(onboardingPaths.CAMERA)
 | 
			
		||||
  const {
 | 
			
		||||
    defaultUnitSystem: ogDefaultUnitSystem,
 | 
			
		||||
    setDefaultUnitSystem: saveDefaultUnitSystem,
 | 
			
		||||
@ -67,6 +67,7 @@ const Units = () => {
 | 
			
		||||
        </SettingsSection>
 | 
			
		||||
        <div className="flex justify-between mt-6">
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            onClick={dismiss}
 | 
			
		||||
            icon={{
 | 
			
		||||
              icon: faXmark,
 | 
			
		||||
@ -78,7 +79,11 @@ const Units = () => {
 | 
			
		||||
          >
 | 
			
		||||
            Dismiss
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
          <ActionButton onClick={handleNextClick} icon={{ icon: faArrowRight }}>
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            onClick={handleNextClick}
 | 
			
		||||
            icon={{ icon: faArrowRight }}
 | 
			
		||||
          >
 | 
			
		||||
            Next: Camera
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
        </div>
 | 
			
		||||
@ -86,5 +91,3 @@ const Units = () => {
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Units
 | 
			
		||||
 | 
			
		||||
@ -8,12 +8,13 @@ import Camera from './Camera'
 | 
			
		||||
import Sketching from './Sketching'
 | 
			
		||||
import { useCallback } from 'react'
 | 
			
		||||
import { paths } from '../../Router'
 | 
			
		||||
import makeUrlPathRelative from '../../lib/makeUrlPathRelative'
 | 
			
		||||
 | 
			
		||||
export const onboardingPaths = {
 | 
			
		||||
  INDEX: '',
 | 
			
		||||
  UNITS: 'units',
 | 
			
		||||
  CAMERA: 'camera',
 | 
			
		||||
  SKETCHING: 'sketching',
 | 
			
		||||
  INDEX: '/',
 | 
			
		||||
  UNITS: '/units',
 | 
			
		||||
  CAMERA: '/camera',
 | 
			
		||||
  SKETCHING: '/sketching',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const onboardingRoutes = [
 | 
			
		||||
@ -22,15 +23,15 @@ export const onboardingRoutes = [
 | 
			
		||||
    element: <Introduction />,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: onboardingPaths.UNITS,
 | 
			
		||||
    path: makeUrlPathRelative(onboardingPaths.UNITS),
 | 
			
		||||
    element: <Units />,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: onboardingPaths.CAMERA,
 | 
			
		||||
    path: makeUrlPathRelative(onboardingPaths.CAMERA),
 | 
			
		||||
    element: <Camera />,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: onboardingPaths.SKETCHING,
 | 
			
		||||
    path: makeUrlPathRelative(onboardingPaths.SKETCHING),
 | 
			
		||||
    element: <Sketching />,
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
@ -43,7 +44,7 @@ export function useNextClick(newStatus: string) {
 | 
			
		||||
 | 
			
		||||
  return useCallback(() => {
 | 
			
		||||
    setOnboardingStatus(newStatus)
 | 
			
		||||
    navigate('/onboarding/' + newStatus)
 | 
			
		||||
    navigate((newStatus !== onboardingPaths.UNITS ? '..' : '.') + newStatus)
 | 
			
		||||
  }, [newStatus, setOnboardingStatus, navigate])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -16,7 +16,7 @@ import { paths } from '../Router'
 | 
			
		||||
 | 
			
		||||
export const Settings = () => {
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
  useHotkeys('esc', () => navigate(paths.INDEX))
 | 
			
		||||
  useHotkeys('esc', () => navigate('../'))
 | 
			
		||||
  const {
 | 
			
		||||
    defaultDir,
 | 
			
		||||
    setDefaultDir,
 | 
			
		||||
@ -46,6 +46,7 @@ export const Settings = () => {
 | 
			
		||||
    theme: s.theme,
 | 
			
		||||
    setTheme: s.setTheme,
 | 
			
		||||
  }))
 | 
			
		||||
  const ogDefaultDir = useRef(defaultDir)
 | 
			
		||||
  const ogDefaultProjectName = useRef(defaultProjectName)
 | 
			
		||||
 | 
			
		||||
  async function handleDirectorySelection() {
 | 
			
		||||
@ -65,7 +66,7 @@ export const Settings = () => {
 | 
			
		||||
      <AppHeader showToolbar={false}>
 | 
			
		||||
        <ActionButton
 | 
			
		||||
          Element="link"
 | 
			
		||||
          to={paths.INDEX}
 | 
			
		||||
          to={'../'}
 | 
			
		||||
          icon={{
 | 
			
		||||
            icon: faXmark,
 | 
			
		||||
            bgClassName: 'bg-destroy-80',
 | 
			
		||||
@ -93,7 +94,11 @@ export const Settings = () => {
 | 
			
		||||
                    base: defaultDir.base,
 | 
			
		||||
                    dir: e.target.value,
 | 
			
		||||
                  })
 | 
			
		||||
                  toast.success('Default directory updated')
 | 
			
		||||
                }}
 | 
			
		||||
                onBlur={() => {
 | 
			
		||||
                  ogDefaultDir.current.dir !== defaultDir.dir &&
 | 
			
		||||
                    toast.success('Default directory updated')
 | 
			
		||||
                  ogDefaultDir.current.dir = defaultDir.dir
 | 
			
		||||
                }}
 | 
			
		||||
              />
 | 
			
		||||
              <ActionButton
 | 
			
		||||
@ -126,6 +131,7 @@ export const Settings = () => {
 | 
			
		||||
            onBlur={() => {
 | 
			
		||||
              ogDefaultProjectName.current !== defaultProjectName &&
 | 
			
		||||
                toast.success('Default project name updated')
 | 
			
		||||
              ogDefaultProjectName.current = defaultProjectName
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        </SettingsSection>
 | 
			
		||||
@ -210,9 +216,10 @@ export const Settings = () => {
 | 
			
		||||
          description="Replay the onboarding process"
 | 
			
		||||
        >
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              setOnboardingStatus('')
 | 
			
		||||
              navigate(paths.ONBOARDING.INDEX)
 | 
			
		||||
              navigate('..' + paths.ONBOARDING.INDEX)
 | 
			
		||||
            }}
 | 
			
		||||
            icon={{ icon: faArrowRotateBack }}
 | 
			
		||||
          >
 | 
			
		||||
 | 
			
		||||
@ -61,6 +61,7 @@ const SignIn = () => {
 | 
			
		||||
        </p>
 | 
			
		||||
        {isTauri() ? (
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            onClick={signInTauri}
 | 
			
		||||
            icon={{ icon: faSignInAlt }}
 | 
			
		||||
            className="w-fit mt-4"
 | 
			
		||||
 | 
			
		||||
@ -402,10 +402,10 @@ export const useStore = create<StoreState>()(
 | 
			
		||||
 | 
			
		||||
      // tauri specific app settings
 | 
			
		||||
      defaultDir: {
 | 
			
		||||
        dir: '~/Documents/',
 | 
			
		||||
        dir: '',
 | 
			
		||||
      },
 | 
			
		||||
      setDefaultDir: (dir) => set({ defaultDir: dir }),
 | 
			
		||||
      defaultProjectName: 'new-project-$n',
 | 
			
		||||
      defaultProjectName: 'new-project-$nnn',
 | 
			
		||||
      setDefaultProjectName: (defaultProjectName) =>
 | 
			
		||||
        set({ defaultProjectName }),
 | 
			
		||||
      defaultUnitSystem: 'imperial',
 | 
			
		||||
 | 
			
		||||
@ -1979,7 +1979,7 @@
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e"
 | 
			
		||||
  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"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-1.4.0.tgz#b4013ca3d17b853f7df29fe14079ebb4d52dbffa"
 | 
			
		||||
  integrity sha512-Jd6HPoTM1PZSFIzq7FB8VmMu3qSSyo/3lSwLpoapW+lQ41CL5Dow2KryLg+gyazA/58DRWI9vu/XpEeHK4uMdw==
 | 
			
		||||
@ -5613,6 +5613,12 @@ tar@^6.1.11:
 | 
			
		||||
    mkdirp "^1.0.3"
 | 
			
		||||
    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:
 | 
			
		||||
  version "6.0.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e"
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user