diff --git a/package.json b/package.json
index 08052296b..742cd00e9 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 67716494e..b56402012 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -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"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 2a15dad1e..95b8b40f3 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -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.
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index f7581221e..760b302a6 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -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");
}
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index d33ea6a1f..e0704e918 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -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": {
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 324db079e..9548e99b7 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -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(
-
-
-
- )
- 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(
+
+
+
+ )
+ const linkElement = screen.getByText(/Variables/i)
+ expect(linkElement).toBeInTheDocument()
+
+ vi.restoreAllMocks()
+ })
})
diff --git a/src/App.tsx b/src/App.tsx
index 5535bbdcd..1ec9a9679 100644
--- a/src/App.tsx
+++ b/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(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, '') || ''
+ }
/>
) => (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: (
@@ -43,7 +71,10 @@ const router = createBrowserRouter([
),
errorElement: ,
- loader: ({ request }) => {
+ loader: async ({
+ request,
+ params,
+ }): Promise => {
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: ,
},
{
- path: paths.ONBOARDING.INDEX,
+ path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
element: ,
children: onboardingRoutes,
},
],
},
+ {
+ path: paths.HOME,
+ element: (
+
+
+
+
+ ),
+ 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: ,
+ },
+ ],
+ },
{
path: paths.SIGN_IN,
element: ,
diff --git a/src/components/ActionButton.tsx b/src/components/ActionButton.tsx
index e1f0f8eb2..be0dc45f4 100644
--- a/src/components/ActionButton.tsx
+++ b/src/components/ActionButton.tsx
@@ -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>
}
-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,
+ keyof BaseActionButtonProps
+ > & {
+ Element: 'button'
+ }
- if (Element === 'button') {
- return (
-
- )
- } else if (Element === 'link') {
- return (
-
- {icon && }
- {children}
-
- )
- } else {
- return (
-
- {icon && }
- {children}
-
- )
+type ActionButtonAsLink = BaseActionButtonProps &
+ Omit & {
+ Element: 'link'
+ }
+
+type ActionButtonAsExternal = BaseActionButtonProps &
+ Omit<
+ React.AnchorHTMLAttributes,
+ keyof BaseActionButtonProps
+ > & {
+ Element: 'externalLink'
+ }
+
+type ActionButtonAsElement = BaseActionButtonProps &
+ Omit, keyof BaseActionButtonProps> & {
+ Element: React.ComponentType>
+ }
+
+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 (
+
+ )
+ }
+ case 'link': {
+ const { Element, to, icon, children, className, ...rest } = props
+ return (
+
+ {icon && }
+ {children}
+
+ )
+ }
+ case 'externalLink': {
+ const { Element, icon, children, className, ...rest } = props
+ return (
+
+ {icon && }
+ {children}
+
+ )
+ }
+ default: {
+ const { Element, icon, children, className, ...rest } = props
+ if (!Element) throw new Error('Element is required')
+
+ return (
+
+ {props.icon && }
+ {children}
+
+ )
+ }
}
}
diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx
index f1174cf01..85295d5c5 100644
--- a/src/components/AppHeader.tsx
+++ b/src/components/AppHeader.tsx
@@ -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
}
>
-
+
- KittyCAD App
+
+ {isTauri() && filename ? filename : 'KittyCAD Modeling App'}
+
{/* Toolbar if the context deems it */}
{showToolbar && (
diff --git a/src/components/DebugPanel.tsx b/src/components/DebugPanel.tsx
index 3987556d3..3cbc2cc7c 100644
--- a/src/components/DebugPanel.tsx
+++ b/src/components/DebugPanel.tsx
@@ -73,6 +73,7 @@ export const DebugPanel = ({ className, ...props }: CollapsiblePanelProps) => {
/>
{
engineCommandManager?.sendSceneCommand({
type: 'modeling_cmd_req',
diff --git a/src/components/ExportButton.tsx b/src/components/ExportButton.tsx
index 2d6a5d2e1..af150b828 100644
--- a/src/components/ExportButton.tsx
+++ b/src/components/ExportButton.tsx
@@ -166,6 +166,7 @@ export const ExportButton = () => {
,
+ f: ProjectWithEntryPointMetadata
+ ) => Promise
+ handleDeleteProject: (f: ProjectWithEntryPointMetadata) => Promise
+}) {
+ useHotkeys('esc', () => setIsEditing(false))
+ const [isEditing, setIsEditing] = useState(false)
+ const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
+
+ function handleSave(e: FormEvent) {
+ 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 (
+
+ {isEditing ? (
+
+ ) : (
+ <>
+
+
+ {project.name?.replace(FILE_EXT, '')}
+
+
+ Edited {getDisplayedTime(project.entrypoint_metadata.modifiedAt)}
+
+
+
setIsEditing(true)}
+ className="!p-0"
+ />
+ setIsConfirmingDelete(true)}
+ />
+
+
+
+ >
+ )}
+
+ )
+}
+
+export default ProjectCard
diff --git a/src/components/UserSidebarMenu.test.tsx b/src/components/UserSidebarMenu.test.tsx
index 176e53e18..014df2925 100644
--- a/src/components/UserSidebarMenu.test.tsx
+++ b/src/components/UserSidebarMenu.test.tsx
@@ -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(
-
-
-
- )
+ render(
+
+
+
+ )
- 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(
-
-
-
- )
-
- 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(
-
-
-
- )
-
- 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(
+
+
+
+ )
+
+ 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(
+
+
+
+ )
+
+ expect(screen.getByTestId('user-sidebar-toggle')).toHaveTextContent('Menu')
+ })
})
diff --git a/src/components/UserSidebarMenu.tsx b/src/components/UserSidebarMenu.tsx
index 4fd4b2123..7ad195d6f 100644
--- a/src/components/UserSidebarMenu.tsx
+++ b/src/components/UserSidebarMenu.tsx
@@ -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 }) => {
)}
{
// 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
diff --git a/src/index.css b/src/index.css
index f2f8b3ec9..87cdc8cd1 100644
--- a/src/index.css
+++ b/src/index.css
@@ -32,7 +32,7 @@ body.dark {
}
::-webkit-scrollbar {
- @apply w-2 rounded-sm;
+ @apply w-2 h-2 rounded-sm;
@apply bg-chalkboard-20;
}
diff --git a/src/lib/makeUrlPathRelative.ts b/src/lib/makeUrlPathRelative.ts
new file mode 100644
index 000000000..90f04efd6
--- /dev/null
+++ b/src/lib/makeUrlPathRelative.ts
@@ -0,0 +1,3 @@
+export default function makeUrlPathRelative(path: string) {
+ return path.replace(/^\//, '')
+}
diff --git a/src/lib/tauriFS.test.ts b/src/lib/tauriFS.test.ts
new file mode 100644
index 000000000..60b268686
--- /dev/null
+++ b/src/lib/tauriFS.test.ts
@@ -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)
+ })
+})
diff --git a/src/lib/tauriFS.ts b/src/lib/tauriFS.ts
new file mode 100644
index 000000000..2f47d7fee
--- /dev/null
+++ b/src/lib/tauriFS.ts
@@ -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) {
+ 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 {
+ 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)}*)`)
+}
diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx
new file mode 100644
index 000000000..11146fb60
--- /dev/null
+++ b/src/routes/Home.tsx
@@ -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,
+ 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 (
+
+
+
+
+ Your Projects
+
+
setSearchParams(getNextSearchParams('name'))}
+ icon={{
+ icon: getSortIcon('name'),
+ bgClassName: !sort?.includes('name')
+ ? 'bg-liquid-30 dark:bg-liquid-70'
+ : '',
+ }}
+ >
+ Name
+
+
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
+
+
+
+
+
+ Are being saved at{' '}
+
+ {defaultDir.dir}
+
+ , which you can change in your Settings.
+
+ {isLoading ? (
+ Loading your Projects...
+ ) : (
+ <>
+ {projects.length > 0 ? (
+
+ {projects.sort(getSortFunction(sort)).map((project) => (
+
+ ))}
+
+ ) : (
+
+ No Projects found, ready to make your first one?
+
+ )}
+
+ New file
+
+ >
+ )}
+
+
+
+ )
+}
+
+export default Home
diff --git a/src/routes/Onboarding/Camera.tsx b/src/routes/Onboarding/Camera.tsx
index 0f4ae0cb1..39a8ecdd4 100644
--- a/src/routes/Onboarding/Camera.tsx
+++ b/src/routes/Onboarding/Camera.tsx
@@ -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 (
@@ -26,6 +26,7 @@ const Units = () => {
{
>
Dismiss
-
+
Next: Sketching
@@ -45,5 +50,3 @@ const Units = () => {
)
}
-
-export default Units
diff --git a/src/routes/Onboarding/Introduction.tsx b/src/routes/Onboarding/Introduction.tsx
index 948214f5c..c0fc20339 100644
--- a/src/routes/Onboarding/Introduction.tsx
+++ b/src/routes/Onboarding/Introduction.tsx
@@ -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 (
@@ -22,6 +22,7 @@ const Introduction = () => {
{
>
Dismiss
-
+
Get Started
@@ -41,5 +46,3 @@ const Introduction = () => {
)
}
-
-export default Introduction
diff --git a/src/routes/Onboarding/Sketching.tsx b/src/routes/Onboarding/Sketching.tsx
index fbf8a5b79..3c33357ff 100644
--- a/src/routes/Onboarding/Sketching.tsx
+++ b/src/routes/Onboarding/Sketching.tsx
@@ -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 = () => {
@@ -33,5 +38,3 @@ const Sketching = () => {
)
}
-
-export default Sketching
diff --git a/src/routes/Onboarding/Units.tsx b/src/routes/Onboarding/Units.tsx
index c0e20eb9d..e659362c5 100644
--- a/src/routes/Onboarding/Units.tsx
+++ b/src/routes/Onboarding/Units.tsx
@@ -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 = () => {
{
>
Dismiss
-
+
Next: Camera
@@ -86,5 +91,3 @@ const Units = () => {
)
}
-
-export default Units
diff --git a/src/routes/Onboarding/index.tsx b/src/routes/Onboarding/index.tsx
index fa5f73a37..14996d4d9 100644
--- a/src/routes/Onboarding/index.tsx
+++ b/src/routes/Onboarding/index.tsx
@@ -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: ,
},
{
- path: onboardingPaths.UNITS,
+ path: makeUrlPathRelative(onboardingPaths.UNITS),
element: ,
},
{
- path: onboardingPaths.CAMERA,
+ path: makeUrlPathRelative(onboardingPaths.CAMERA),
element: ,
},
{
- path: onboardingPaths.SKETCHING,
+ path: makeUrlPathRelative(onboardingPaths.SKETCHING),
element: ,
},
]
@@ -43,7 +44,7 @@ export function useNextClick(newStatus: string) {
return useCallback(() => {
setOnboardingStatus(newStatus)
- navigate('/onboarding/' + newStatus)
+ navigate((newStatus !== onboardingPaths.UNITS ? '..' : '.') + newStatus)
}, [newStatus, setOnboardingStatus, navigate])
}
diff --git a/src/routes/Settings.tsx b/src/routes/Settings.tsx
index 3d7c91b1a..516ba582c 100644
--- a/src/routes/Settings.tsx
+++ b/src/routes/Settings.tsx
@@ -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 = () => {
{
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
}}
/>
{
onBlur={() => {
ogDefaultProjectName.current !== defaultProjectName &&
toast.success('Default project name updated')
+ ogDefaultProjectName.current = defaultProjectName
}}
/>
@@ -210,9 +216,10 @@ export const Settings = () => {
description="Replay the onboarding process"
>
{
setOnboardingStatus('')
- navigate(paths.ONBOARDING.INDEX)
+ navigate('..' + paths.ONBOARDING.INDEX)
}}
icon={{ icon: faArrowRotateBack }}
>
diff --git a/src/routes/SignIn.tsx b/src/routes/SignIn.tsx
index 289ecc60c..de78b346f 100644
--- a/src/routes/SignIn.tsx
+++ b/src/routes/SignIn.tsx
@@ -61,6 +61,7 @@ const SignIn = () => {
{isTauri() ? (
()(
// 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',
diff --git a/yarn.lock b/yarn.lock
index 77e7b84e2..55e414eb5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"