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 - 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 ? ( +
    + +
    + + setIsEditing(false)} + /> +
    +
    + ) : ( + <> +
    + + {project.name?.replace(FILE_EXT, '')} + + + Edited {getDisplayedTime(project.entrypoint_metadata.modifiedAt)} + +
    + setIsEditing(true)} + className="!p-0" + /> + setIsConfirmingDelete(true)} + /> +
    +
    + setIsConfirmingDelete(false)} + className="relative z-50" + > +
    + + + Delete File + + + This will permanently delete "{project.name || 'this file'}". + + +

    + Are you sure you want to delete "{project.name || 'this file'} + "? This action cannot be undone. +

    + +
    + { + 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 + + setIsConfirmingDelete(false)} + > + Cancel + +
    +
    +
    +
    + + )} +
  • + ) +} + +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 = () => {

    { > Dismiss - + Finish
    @@ -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"