diff --git a/public/Icon/Icon/Projects/Create File.svg b/public/Icon/Icon/Projects/Create File.svg new file mode 100644 index 000000000..757ee17b3 --- /dev/null +++ b/public/Icon/Icon/Projects/Create File.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/Icon/Icon/Projects/Create Folder.svg b/public/Icon/Icon/Projects/Create Folder.svg new file mode 100644 index 000000000..a0a23431d --- /dev/null +++ b/public/Icon/Icon/Projects/Create Folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/Icon/Icon/Projects/File.svg b/public/Icon/Icon/Projects/File.svg new file mode 100644 index 000000000..2b8287a46 --- /dev/null +++ b/public/Icon/Icon/Projects/File.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/kcl-icon.svg b/public/kcl-icon.svg new file mode 100644 index 000000000..71b4cc1b2 --- /dev/null +++ b/public/kcl-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/App.tsx b/src/App.tsx index 2dc18400e..27cc3d0b6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,7 +35,7 @@ import { kclManager } from 'lang/KclSinglton' import { useModelingContext } from 'hooks/useModelingContext' export function App() { - const { code: loadedCode, project } = useLoaderData() as IndexLoaderData + const { code: loadedCode, project, file } = useLoaderData() as IndexLoaderData useHotKeyListener() const { @@ -86,7 +86,13 @@ export function App() { // on mount, and overwrite any locally-stored code useEffect(() => { if (isTauri() && loadedCode !== null) { - kclManager.setCode(loadedCode) + if (kclManager.engineCommandManager.engineConnection?.isReady()) { + // If the engine is ready, promptly execute the loaded code + kclManager.setCodeAndExecute(loadedCode) + } else { + // Otherwise, just set the code and wait for the connection to complete + kclManager.setCode(loadedCode) + } } return () => { // Clear code on unmount if in desktop app @@ -182,7 +188,7 @@ export function App() { paneOpacity + (buttonDownInStream ? ' pointer-events-none' : '') } - project={project} + project={{ project, file }} enableMenu={true} /> diff --git a/src/Router.tsx b/src/Router.tsx index 6e44c3097..f8ec890a5 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -42,6 +42,7 @@ import { TEST, VITE_KC_SENTRY_DSN } from './env' import * as Sentry from '@sentry/react' import ModelingMachineProvider from 'components/ModelingMachineProvider' import { KclContextProvider } from 'lang/KclSinglton' +import FileMachineProvider from 'components/FileMachineProvider' if (VITE_KC_SENTRY_DSN && !TEST) { Sentry.init({ @@ -101,10 +102,11 @@ export const BROWSER_FILE_NAME = 'new' export type IndexLoaderData = { code: string | null project?: ProjectWithEntryPointMetadata + file?: FileEntry } export type ProjectWithEntryPointMetadata = FileEntry & { - entrypoint_metadata: Metadata + entrypointMetadata: Metadata } export type HomeLoaderData = { projects: ProjectWithEntryPointMetadata[] @@ -143,11 +145,13 @@ const router = createBrowserRouter( element: ( - - - - - + + + + + + + {!isTauri() && import.meta.env.PROD && } ), @@ -177,21 +181,41 @@ const router = createBrowserRouter( ) } + const defaultDir = persistedSettings.defaultDirectory || '' + if (params.id && params.id !== BROWSER_FILE_NAME) { + const decodedId = decodeURIComponent(params.id) + const projectAndFile = decodedId.replace(defaultDir + '/', '') + const firstSlashIndex = projectAndFile.indexOf('/') + const projectName = projectAndFile.slice(0, firstSlashIndex) + const projectPath = defaultDir + '/' + projectName + const currentFileName = projectAndFile.slice(firstSlashIndex + 1) + + if (firstSlashIndex === -1 || !currentFileName) + return redirect( + `${paths.FILE}/${encodeURIComponent( + `${params.id}/${PROJECT_ENTRYPOINT}` + )}` + ) + // Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files - const code = await readTextFile(params.id + '/' + PROJECT_ENTRYPOINT) - const entrypoint_metadata = await metadata( - params.id + '/' + PROJECT_ENTRYPOINT + const code = await readTextFile(decodedId) + const entrypointMetadata = await metadata( + projectPath + '/' + PROJECT_ENTRYPOINT ) - const children = await readDir(params.id) + const children = await readDir(projectPath, { recursive: true }) return { code, project: { - name: params.id.slice(params.id.lastIndexOf('/') + 1), - path: params.id, + name: projectName, + path: projectPath, children, - entrypoint_metadata, + entrypointMetadata, + }, + file: { + name: currentFileName, + path: params.id, }, } } @@ -245,7 +269,7 @@ const router = createBrowserRouter( ) const projects = await Promise.all( projectsNoMeta.map(async (p) => ({ - entrypoint_metadata: await metadata( + entrypointMetadata: await metadata( p.path + '/' + PROJECT_ENTRYPOINT ), ...p, diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index c0aa84183..aa8851249 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,6 +1,6 @@ import { Toolbar } from '../Toolbar' import UserSidebarMenu from './UserSidebarMenu' -import { ProjectWithEntryPointMetadata } from '../Router' +import { IndexLoaderData } from '../Router' import ProjectSidebarMenu from './ProjectSidebarMenu' import { useGlobalStateContext } from 'hooks/useGlobalStateContext' import styles from './AppHeader.module.css' @@ -8,7 +8,7 @@ import { NetworkHealthIndicator } from './NetworkHealthIndicator' interface AppHeaderProps extends React.PropsWithChildren { showToolbar?: boolean - project?: ProjectWithEntryPointMetadata + project?: Omit className?: string enableMenu?: boolean } @@ -32,7 +32,13 @@ export const AppHeader = ({ className } > - + {project && ( + + )} {/* Toolbar if the context deems it */} {showToolbar && (
diff --git a/src/components/CustomIcon.tsx b/src/components/CustomIcon.tsx index c54be3e7e..a1e59f662 100644 --- a/src/components/CustomIcon.tsx +++ b/src/components/CustomIcon.tsx @@ -1,7 +1,10 @@ export type CustomIconName = + | 'createFile' + | 'createFolder' | 'equal' | 'exit' | 'extrude' + | 'file' | 'horizontal' | 'line' | 'move' @@ -16,6 +19,38 @@ export const CustomIcon = ({ name: CustomIconName } & React.SVGProps) => { switch (name) { + case 'createFile': + return ( + + + + ) + case 'createFolder': + return ( + + + + ) case 'equal': return ( ) + case 'file': + return ( + + + + ) case 'horizontal': return ( interface ExportButtonProps extends React.PropsWithChildren { className?: { button?: string - // If we wanted more classname configuration of sub-elements, - // put them here + icon?: string + bg?: string } } @@ -109,7 +109,11 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => { {children || 'Export'} diff --git a/src/components/FileMachineProvider.tsx b/src/components/FileMachineProvider.tsx new file mode 100644 index 000000000..e63fa379a --- /dev/null +++ b/src/components/FileMachineProvider.tsx @@ -0,0 +1,157 @@ +import { useMachine } from '@xstate/react' +import { useNavigate, useRouteLoaderData } from 'react-router-dom' +import { IndexLoaderData, paths } from '../Router' +import React, { createContext } from 'react' +import { toast } from 'react-hot-toast' +import { + AnyStateMachine, + ContextFrom, + EventFrom, + InterpreterFrom, + Prop, + StateFrom, +} from 'xstate' +import { useCommandsContext } from 'hooks/useCommandsContext' +import { DEFAULT_FILE_NAME, fileMachine } from 'machines/fileMachine' +import { + createDir, + removeDir, + removeFile, + renameFile, + writeFile, +} from '@tauri-apps/api/fs' +import { FILE_EXT, readProject } from 'lib/tauriFS' +import { isTauri } from 'lib/isTauri' + +type MachineContext = { + state: StateFrom + context: ContextFrom + send: Prop, 'send'> +} + +export const FileContext = createContext( + {} as MachineContext +) + +export const FileMachineProvider = ({ + children, +}: { + children: React.ReactNode +}) => { + const navigate = useNavigate() + const { setCommandBarOpen } = useCommandsContext() + const { project } = useRouteLoaderData(paths.FILE) as IndexLoaderData + + const [state, send] = useMachine(fileMachine, { + context: { + project, + selectedDirectory: project, + }, + actions: { + navigateToFile: ( + context: ContextFrom, + event: EventFrom + ) => { + if (event.data && 'name' in event.data) { + setCommandBarOpen(false) + navigate( + `${paths.FILE}/${encodeURIComponent( + context.selectedDirectory + '/' + event.data.name + )}` + ) + } + }, + toastSuccess: (_, event) => + event.data && toast.success((event.data || '') + ''), + toastError: (_, event) => toast.error((event.data || '') + ''), + }, + services: { + readFiles: async (context: ContextFrom) => { + const newFiles = isTauri() + ? await readProject(context.project.path) + : [] + return { + ...context.project, + children: newFiles, + } + }, + createFile: async ( + context: ContextFrom, + event: EventFrom + ) => { + let name = event.data.name.trim() || DEFAULT_FILE_NAME + + if (event.data.makeDir) { + await createDir(context.selectedDirectory.path + '/' + name) + } else { + await writeFile( + context.selectedDirectory.path + + '/' + + name + + (name.endsWith(FILE_EXT) ? '' : FILE_EXT), + '' + ) + } + + return `Successfully created "${name}"` + }, + renameFile: async ( + context: ContextFrom, + event: EventFrom + ) => { + const { oldName, newName, isDir } = event.data + let name = newName ? newName : DEFAULT_FILE_NAME + + await renameFile( + context.selectedDirectory.path + '/' + oldName, + context.selectedDirectory.path + + '/' + + name + + (name.endsWith(FILE_EXT) || isDir ? '' : FILE_EXT) + ) + return ( + oldName !== name && `Successfully renamed "${oldName}" to "${name}"` + ) + }, + deleteFile: async ( + context: ContextFrom, + event: EventFrom + ) => { + const isDir = !!event.data.children + + if (isDir) { + await removeDir(event.data.path, { + recursive: true, + }).catch((e) => console.error('Error deleting directory', e)) + } else { + await removeFile(event.data.path).catch((e) => + console.error('Error deleting file', e) + ) + } + return `Successfully deleted ${isDir ? 'folder' : 'file'} "${ + event.data.name + }"` + }, + }, + guards: { + 'Has at least 1 file': (_, event: EventFrom) => { + if (event.type !== 'done.invoke.read-files') return false + return !!event?.data?.children && event.data.children.length > 0 + }, + }, + }) + + return ( + + {children} + + ) +} + +export default FileMachineProvider diff --git a/src/components/FileTree.module.css b/src/components/FileTree.module.css new file mode 100644 index 000000000..eac058581 --- /dev/null +++ b/src/components/FileTree.module.css @@ -0,0 +1,16 @@ +.folder { + position: relative; +} + +.folder::after { + content: ''; + width: 1px; + z-index: -1; + @apply absolute top-0 bottom-0; + left: calc(var(--indent-line-left, 1rem) + 0.25rem); + @apply bg-chalkboard-30; +} + +:global(.dark) .folder::after { + @apply bg-chalkboard-80; +} diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx new file mode 100644 index 000000000..101f5d4f9 --- /dev/null +++ b/src/components/FileTree.tsx @@ -0,0 +1,400 @@ +import { IndexLoaderData, paths } from 'Router' +import { ActionButton } from './ActionButton' +import Tooltip from './Tooltip' +import { FileEntry } from '@tauri-apps/api/fs' +import { Dispatch, useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Dialog, Disclosure } from '@headlessui/react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faChevronRight, faTrashAlt } from '@fortawesome/free-solid-svg-icons' +import { useFileContext } from 'hooks/useFileContext' +import { useHotkeys } from 'react-hotkeys-hook' +import { kclManager } from 'lang/KclSinglton' +import styles from './FileTree.module.css' +import { sortProject } from 'lib/tauriFS' + +function getIndentationCSS(level: number) { + return `calc(1rem * ${level + 1})` +} + +function RenameForm({ + fileOrDir, + setIsRenaming, + level = 0, +}: { + fileOrDir: FileEntry + setIsRenaming: Dispatch> + level?: number +}) { + const { send } = useFileContext() + const inputRef = useRef(null) + + function handleRenameSubmit(e: React.FormEvent) { + e.preventDefault() + setIsRenaming(false) + send({ + type: 'Rename file', + data: { + oldName: fileOrDir.name || '', + newName: inputRef.current?.value || fileOrDir.name || '', + isDir: fileOrDir.children !== undefined, + }, + }) + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Escape') { + e.stopPropagation() + setIsRenaming(false) + } + } + + return ( +
+
Rename file + setIsRenaming(false)} + style={{ paddingInlineStart: getIndentationCSS(level) }} + /> + + + + ) +} + +function DeleteConfirmationDialog({ + fileOrDir, + setIsOpen, +}: { + fileOrDir: FileEntry + setIsOpen: Dispatch> +}) { + const { send } = useFileContext() + return ( + setIsOpen(false)} + className="relative z-50" + > +
+ + + Delete {fileOrDir.children !== undefined ? 'Folder' : 'File'} + + + This will permanently delete "{fileOrDir.name || 'this file'}" + {fileOrDir.children !== undefined + ? ' and all of its contents. ' + : '. '} + This action cannot be undone. + + +
+ { + send({ type: 'Delete file', data: fileOrDir }) + setIsOpen(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 + + setIsOpen(false)}> + Cancel + +
+
+
+
+ ) +} + +const FileTreeItem = ({ + project, + currentFile, + fileOrDir, + closePanel, + level = 0, +}: { + project?: IndexLoaderData['project'] + currentFile?: IndexLoaderData['file'] + fileOrDir: FileEntry + closePanel: ( + focusableElement?: + | HTMLElement + | React.MutableRefObject + | undefined + ) => void + level?: number +}) => { + const { send, context } = useFileContext() + const navigate = useNavigate() + const [isRenaming, setIsRenaming] = useState(false) + const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) + const isCurrentFile = fileOrDir.path === currentFile?.path + + function handleKeyUp(e: React.KeyboardEvent) { + if (e.metaKey && e.key === 'Backspace') { + // Open confirmation dialog + setIsConfirmingDelete(true) + } else if (e.key === 'Enter') { + // Show the renaming form + setIsRenaming(true) + } else if (e.code === 'Space') { + openFile() + } + } + + function openFile() { + if (fileOrDir.children !== undefined) return // Don't open directories + kclManager.setCode('') + navigate(`${paths.FILE}/${encodeURIComponent(fileOrDir.path)}`) + closePanel() + } + + return ( + <> + {fileOrDir.children === undefined ? ( +
  • + {!isRenaming ? ( + + ) : ( + + )} +
  • + ) : ( + + {({ open }) => ( +
    + {!isRenaming ? ( + e.currentTarget.focus()} + onClickCapture={(e) => + send({ type: 'Set selected directory', data: fileOrDir }) + } + onFocusCapture={(e) => + send({ type: 'Set selected directory', data: fileOrDir }) + } + onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()} + onKeyUp={handleKeyUp} + > + + {fileOrDir.name} + + ) : ( +
    + + +
    + )} + +
      { + send({ type: 'Set selected directory', data: fileOrDir }) + }} + onFocusCapture={(e) => + send({ type: 'Set selected directory', data: fileOrDir }) + } + > + {fileOrDir.children?.map((child) => ( + + ))} +
    +
    +
    + )} +
    + )} + {isConfirmingDelete && ( + + )} + + ) +} + +interface FileTreeProps { + className?: string + file?: IndexLoaderData['file'] + closePanel: ( + focusableElement?: + | HTMLElement + | React.MutableRefObject + | undefined + ) => void +} + +export const FileTree = ({ + className = '', + file, + closePanel, +}: FileTreeProps) => { + const { send, context } = useFileContext() + useHotkeys('meta + n', createFile) + useHotkeys('meta + shift + n', createFolder) + + async function createFile() { + send({ type: 'Create file', data: { name: '', makeDir: false } }) + } + + async function createFolder() { + send({ type: 'Create file', data: { name: '', makeDir: true } }) + } + + return ( +
    +
    +

    Files

    + + + Create File + + + + + + Create Folder + + +
    +
    +
      { + send({ type: 'Set selected directory', data: context.project }) + }} + > + {sortProject(context.project.children || []).map((fileOrDir) => ( + + ))} +
    +
    +
    + ) +} + +function KclIcon({ className = '' }: { className?: string }) { + return ( + + + + ) +} diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx index 104637a27..7a456b340 100644 --- a/src/components/ProjectCard.tsx +++ b/src/components/ProjectCard.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useState } from 'react' +import { FormEvent, useEffect, useState } from 'react' import { type ProjectWithEntryPointMetadata, paths } from '../Router' import { Link } from 'react-router-dom' import { ActionButton } from './ActionButton' @@ -8,7 +8,7 @@ import { faTrashAlt, faX, } from '@fortawesome/free-solid-svg-icons' -import { FILE_EXT } from '../lib/tauriFS' +import { FILE_EXT, getPartsCount, readProject } from '../lib/tauriFS' import { Dialog } from '@headlessui/react' import { useHotkeys } from 'react-hotkeys-hook' @@ -28,6 +28,8 @@ function ProjectCard({ useHotkeys('esc', () => setIsEditing(false)) const [isEditing, setIsEditing] = useState(false) const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) + const [numberOfParts, setNumberOfParts] = useState(1) + const [numberOfFolders, setNumberOfFolders] = useState(0) function handleSave(e: FormEvent) { e.preventDefault() @@ -42,6 +44,17 @@ function ProjectCard({ : date.toLocaleTimeString() } + useEffect(() => { + async function getNumberOfParts() { + const { kclFileCount, kclDirCount } = getPartsCount( + await readProject(project.path) + ) + setNumberOfParts(kclFileCount) + setNumberOfFolders(kclDirCount) + } + getNumberOfParts() + }, [project.path]) + return (
  • ) : ( <> -
    +
    - Edited {getDisplayedTime(project.entrypoint_metadata.modifiedAt)} + {numberOfParts} part{numberOfParts === 1 ? '' : 's'}{' '} + {numberOfFolders > 0 && + `/ ${numberOfFolders} folder${ + numberOfFolders === 1 ? '' : 's' + }`} + + + Edited {getDisplayedTime(project.entrypointMetadata.modifiedAt)}
    + project?: IndexLoaderData['project'] + file?: IndexLoaderData['file'] }) => { return renderAsLink ? ( - - {isTauri() && project?.name ? project.name : 'KittyCAD Modeling App'} - +
    + + {isTauri() && file?.name + ? file.name.slice(file.name.lastIndexOf('/') + 1) + : 'KittyCAD Modeling App'} + + {isTauri() && project?.name && ( + + {project.name} + + )} +
    - -
    - KittyCAD App + + {({ close }) => ( + <> +
    + KittyCAD App -
    -

    - {project?.name ? project.name : 'KittyCAD Modeling App'} -

    - {project?.entrypoint_metadata && ( -

    - Created{' '} - {project?.entrypoint_metadata.createdAt.toLocaleDateString()} -

    +
    +

    + {project?.name ? project.name : 'KittyCAD Modeling App'} +

    + {project?.entrypointMetadata && ( +

    + Created{' '} + {project.entrypointMetadata.createdAt.toLocaleDateString()} +

    + )} +
    +
    + {isTauri() ? ( + + ) : ( +
    )} -
    -
    -
    - - Export Model - - {isTauri() && ( - - Go to Home - - )} -
    +
    + + Export Model + + {isTauri() && ( + + Go to Home + + )} +
    + + )}
    diff --git a/src/components/TextEditor.tsx b/src/components/TextEditor.tsx index b0e7a7a19..8adada6ac 100644 --- a/src/components/TextEditor.tsx +++ b/src/components/TextEditor.tsx @@ -117,13 +117,11 @@ export const TextEditor = ({ 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, newCode).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') - } - ) + writeTextFile(pathParams.id, newCode).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]) }) diff --git a/src/components/Tooltip.module.css b/src/components/Tooltip.module.css new file mode 100644 index 000000000..261d0c114 --- /dev/null +++ b/src/components/Tooltip.module.css @@ -0,0 +1,229 @@ +/* Adapted from https://github.com/argyleink/gui-challenges/blob/main/tooltips/tool-tip.css */ + +.tooltip { + /* internal CSS vars */ + --_delay: 200ms; + --_p-inline: 1ch; + --_p-block: 4px; + --_triangle-size: 7px; + /* --_bg: hsl(0 0% 20%); */ + --_bg: var(--chalkboard-10); + --_shadow-alpha: 20%; + + /* Used to power spacing and layout for RTL languages */ + --isRTL: -1; + + /* Using conic gradients to get a clear tip triangle */ + --_bottom-tip: conic-gradient( + from -30deg at bottom, + #0000, + #000 1deg 60deg, + #0000 61deg + ) + bottom / 100% 50% no-repeat; + --_top-tip: conic-gradient( + from 150deg at top, + #0000, + #000 1deg 60deg, + #0000 61deg + ) + top / 100% 50% no-repeat; + --_right-tip: conic-gradient( + from -120deg at right, + #0000, + #000 1deg 60deg, + #0000 61deg + ) + right / 50% 100% no-repeat; + --_left-tip: conic-gradient( + from 60deg at left, + #0000, + #000 1deg 60deg, + #0000 61deg + ) + left / 50% 100% no-repeat; + + pointer-events: none; + user-select: none; + + /* The parts that will be transitioned */ + opacity: 0; + transform: translate(var(--_x, 0), var(--_y, 0)); + transition: transform 0.15s ease-out, opacity 0.11s ease-out; + + position: absolute; + z-index: 1; + inline-size: max-content; + max-inline-size: 25ch; + text-align: start; + font-family: var(--mono-font-family); + text-transform: none; + font-size: 0.9rem; + font-weight: normal; + line-height: initial; + letter-spacing: 0; + padding: var(--_p-block) var(--_p-inline); + margin: 0; + border-radius: 3px; + background: var(--_bg); + @apply text-chalkboard-110; + will-change: filter; + filter: drop-shadow(0 1px 3px hsl(0 0% 0% / var(--_shadow-alpha))) + drop-shadow(0 6px 12px hsl(0 0% 0% / var(--_shadow-alpha))); +} + +:global(.dark) .tooltip { + --_bg: var(--chalkboard-110); + @apply text-chalkboard-10; +} + +/* TODO we don't support a light theme yet */ +/* @media (prefers-color-scheme: light) { + .tooltip { + --_bg: white; + --_shadow-alpha: 15%; + } +} */ + +.tooltip:dir(rtl) { + --isRTL: 1; +} + +/* :has and :is are pretty fresh CSS pseudo-selectors, may not see full support */ +:has(> .tooltip) { + position: relative; +} + +:is(:hover, :focus-visible, :active) > .tooltip { + opacity: 1; + transition-delay: var(--_delay); +} + +:is(:focus, :focus-visible, :focus-within) > .tooltip { + --_delay: 0 !important; +} + +/* prepend some prose for screen readers only */ +.tooltip::before { + content: '; Has tooltip: '; + clip: rect(1px, 1px, 1px, 1px); + clip-path: inset(50%); + height: 1px; + width: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; +} + +/* tooltip shape is a pseudo element so we can cast a shadow */ +.tooltip::after { + content: ''; + background: var(--_bg); + position: absolute; + z-index: -1; + inset: 0; + mask: var(--_tip); +} + +.tooltip.top, +.tooltip.blockStart, +.tooltip.bottom, +.tooltip.blockEnd { + text-align: center; +} + +/* TOP || BLOCK-START */ +.tooltip.top, +.tooltip.blockStart { + inset-inline-start: 50%; + inset-block-end: calc(100% + var(--_p-block) + var(--_triangle-size)); + --_x: calc(50% * var(--isRTL)); +} + +.tooltip.top::after, +.tooltip.tooltip.blockStart::after { + --_tip: var(--_bottom-tip); + inset-block-end: calc(var(--_triangle-size) * -1); + border-block-end: var(--_triangle-size) solid transparent; +} + +/* RIGHT || INLINE-END */ +.tooltip.right, +.tooltip.inlineEnd { + inset-inline-start: calc(100% + var(--_p-inline) + var(--_triangle-size)); + inset-block-end: 50%; + --_y: 50%; +} + +.tooltip.right::after, +.tooltip.tooltip.inlineEnd::after { + --_tip: var(--_left-tip); + inset-inline-start: calc(var(--_triangle-size) * -1); + border-inline-start: var(--_triangle-size) solid transparent; +} + +.tooltip.right:dir(rtl)::after, +.tooltip.inlineEnd:dir(rtl)::after { + --_tip: var(--_right-tip); +} + +/* BOTTOM || BLOCK-END */ +.tooltip.bottom, +.tooltip.blockEnd { + inset-inline-start: 50%; + inset-block-start: calc(100% + var(--_p-block) + var(--_triangle-size)); + --_x: calc(50% * var(--isRTL)); +} + +.tooltip.bottom::after, +.tooltip.tooltip.blockEnd::after { + --_tip: var(--_top-tip); + inset-block-start: calc(var(--_triangle-size) * -1); + border-block-start: var(--_triangle-size) solid transparent; +} + +/* LEFT || INLINE-START */ +.tooltip.left, +.tooltip.inlineStart { + inset-inline-end: calc(100% + var(--_p-inline) + var(--_triangle-size)); + inset-block-end: 50%; + --_y: 50%; +} + +.tooltip.left::after, +.tooltip.tooltip.inlineStart::after { + --_tip: var(--_right-tip); + inset-inline-end: calc(var(--_triangle-size) * -1); + border-inline-end: var(--_triangle-size) solid transparent; +} + +.tooltip.left:dir(rtl)::after, +.tooltip.inlineStart:dir(rtl)::after { + --_tip: var(--_left-tip); +} + +@media (prefers-reduced-motion: no-preference) { + /* TOP || BLOCK-START */ + :has(> :is(.tooltip.top, .tooltip.blockStart)):not(:hover, :active) .tooltip { + --_y: 3px; + } + + /* RIGHT || INLINE-END */ + :has(> :is(.tooltip.right, .tooltip.inlineEnd)):not(:hover, :active) + .tooltip { + --_x: calc(var(--isRTL) * -3px * -1); + } + + /* BOTTOM || BLOCK-END */ + :has(> :is(.tooltip.bottom, .tooltip.blockEnd)):not(:hover, :active) + .tooltip { + --_y: -3px; + } + + /* BOTTOM || BLOCK-END */ + :has(> :is(.tooltip.left, .tooltip.inlineStart)):not(:hover, :active) + .tooltip { + --_x: calc(var(--isRTL) * 3px * -1); + } +} diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx new file mode 100644 index 000000000..6f9dfa368 --- /dev/null +++ b/src/components/Tooltip.tsx @@ -0,0 +1,37 @@ +// We do use all the classes in this file currently, but we +// index into them with styles[position], which CSS Modules doesn't pick up. +// eslint-disable-next-line css-modules/no-unused-class +import styles from './Tooltip.module.css' + +interface TooltipProps extends React.PropsWithChildren { + position?: + | 'top' + | 'bottom' + | 'left' + | 'right' + | 'blockStart' + | 'blockEnd' + | 'inlineStart' + | 'inlineEnd' + className?: string + delay?: number +} + +export default function Tooltip({ + children, + position = 'top', + className, + delay = 200, +}: TooltipProps) { + return ( +
    + {children} +
    + ) +} diff --git a/src/hooks/useFileContext.ts b/src/hooks/useFileContext.ts new file mode 100644 index 000000000..a41135d06 --- /dev/null +++ b/src/hooks/useFileContext.ts @@ -0,0 +1,6 @@ +import { FileContext } from 'components/FileMachineProvider' +import { useContext } from 'react' + +export const useFileContext = () => { + return useContext(FileContext) +} diff --git a/src/lib/sorting.ts b/src/lib/sorting.ts index 48637a7cd..7d13e09ae 100644 --- a/src/lib/sorting.ts +++ b/src/lib/sorting.ts @@ -43,15 +43,12 @@ export function getSortFunction(sortBy: string) { a: ProjectWithEntryPointMetadata, b: ProjectWithEntryPointMetadata ) => { - if ( - a.entrypoint_metadata?.modifiedAt && - b.entrypoint_metadata?.modifiedAt - ) { + if (a.entrypointMetadata?.modifiedAt && b.entrypointMetadata?.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() + ? b.entrypointMetadata.modifiedAt.getTime() - + a.entrypointMetadata.modifiedAt.getTime() + : a.entrypointMetadata.modifiedAt.getTime() - + b.entrypointMetadata.modifiedAt.getTime() } return 0 } diff --git a/src/lib/tauriFS.test.ts b/src/lib/tauriFS.test.ts index 60b268686..ca31a6369 100644 --- a/src/lib/tauriFS.test.ts +++ b/src/lib/tauriFS.test.ts @@ -1,10 +1,14 @@ +import { FileEntry } from '@tauri-apps/api/fs' import { MAX_PADDING, + deepFileFilter, getNextProjectIndex, + getPartsCount, interpolateProjectNameWithIndex, + isRelevantFileOrDir, } from './tauriFS' -describe('Test file utility functions', () => { +describe('Test project name utility functions', () => { it('interpolates a project name without an index', () => { expect(interpolateProjectNameWithIndex('test', 1)).toBe('test') }) @@ -46,3 +50,101 @@ describe('Test file utility functions', () => { expect(getNextProjectIndex('new-project-$n', testFiles)).toBe(8) }) }) + +describe('Test file tree utility functions', () => { + const baseFiles: FileEntry[] = [ + { + name: 'show-me.kcl', + path: '/projects/show-me.kcl', + }, + { + name: 'hide-me.jpg', + path: '/projects/hide-me.jpg', + }, + { + name: '.gitignore', + path: '/projects/.gitignore', + }, + ] + + const filteredBaseFiles: FileEntry[] = [ + { + name: 'show-me.kcl', + path: '/projects/show-me.kcl', + }, + ] + + it('Only includes files relevant to the project in a flat directory', () => { + expect(deepFileFilter(baseFiles, isRelevantFileOrDir)).toEqual( + filteredBaseFiles + ) + }) + + const nestedFiles: FileEntry[] = [ + ...baseFiles, + { + name: 'show-me', + path: '/projects/show-me', + children: [ + { + name: 'show-me-nested', + path: '/projects/show-me/show-me-nested', + children: baseFiles, + }, + { + name: 'hide-me', + path: '/projects/show-me/hide-me', + children: baseFiles.filter((file) => file.name !== 'show-me.kcl'), + }, + ], + }, + { + name: 'hide-me', + path: '/projects/hide-me', + children: baseFiles.filter((file) => file.name !== 'show-me.kcl'), + }, + ] + + const filteredNestedFiles: FileEntry[] = [ + ...filteredBaseFiles, + { + name: 'show-me', + path: '/projects/show-me', + children: [ + { + name: 'show-me-nested', + path: '/projects/show-me/show-me-nested', + children: filteredBaseFiles, + }, + ], + }, + ] + + it('Only includes directories that include files relevant to the project in a nested directory', () => { + expect(deepFileFilter(nestedFiles, isRelevantFileOrDir)).toEqual( + filteredNestedFiles + ) + }) + + const withHiddenDir: FileEntry[] = [ + ...baseFiles, + { + name: '.hide-me', + path: '/projects/.hide-me', + children: baseFiles, + }, + ] + + it(`Hides folders that begin with a ".", even if they contain relevant files`, () => { + expect(deepFileFilter(withHiddenDir, isRelevantFileOrDir)).toEqual( + filteredBaseFiles + ) + }) + + it(`Properly counts the number of relevant files and directories in a project`, () => { + expect(getPartsCount(nestedFiles)).toEqual({ + kclFileCount: 2, + kclDirCount: 2, + }) + }) +}) diff --git a/src/lib/tauriFS.ts b/src/lib/tauriFS.ts index 989e06bcf..b1942eaab 100644 --- a/src/lib/tauriFS.ts +++ b/src/lib/tauriFS.ts @@ -15,6 +15,7 @@ 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 +const RELEVANT_FILE_TYPES = ['kcl'] // Initializes the project directory and returns the path export async function initializeProjectDirectory(directory: string) { @@ -69,7 +70,7 @@ export async function getProjectsInDir(projectDir: string) { const projectsWithMetadata = await Promise.all( readProjects.map(async (p) => ({ - entrypoint_metadata: await metadata(p.path + '/' + PROJECT_ENTRYPOINT), + entrypointMetadata: await metadata(p.path + '/' + PROJECT_ENTRYPOINT), ...p, })) ) @@ -77,6 +78,135 @@ export async function getProjectsInDir(projectDir: string) { return projectsWithMetadata } +export const isHidden = (fileOrDir: FileEntry) => + !!fileOrDir.name?.startsWith('.') + +export const isDir = (fileOrDir: FileEntry) => + 'children' in fileOrDir && fileOrDir.children !== undefined + +export function deepFileFilter( + entries: FileEntry[], + filterFn: (f: FileEntry) => boolean +): FileEntry[] { + const filteredEntries: FileEntry[] = [] + for (const fileOrDir of entries) { + if ('children' in fileOrDir && fileOrDir.children !== undefined) { + const filteredChildren = deepFileFilter(fileOrDir.children, filterFn) + if (filterFn(fileOrDir)) { + filteredEntries.push({ + ...fileOrDir, + children: filteredChildren, + }) + } + } else if (filterFn(fileOrDir)) { + filteredEntries.push(fileOrDir) + } + } + return filteredEntries +} + +export function deepFileFilterFlat( + entries: FileEntry[], + filterFn: (f: FileEntry) => boolean +): FileEntry[] { + const filteredEntries: FileEntry[] = [] + for (const fileOrDir of entries) { + if ('children' in fileOrDir && fileOrDir.children !== undefined) { + const filteredChildren = deepFileFilterFlat(fileOrDir.children, filterFn) + if (filterFn(fileOrDir)) { + filteredEntries.push({ + ...fileOrDir, + children: filteredChildren, + }) + } + filteredEntries.push(...filteredChildren) + } else if (filterFn(fileOrDir)) { + filteredEntries.push(fileOrDir) + } + } + return filteredEntries +} + +// Read the contents of a project directory +// and return all relevant files and sub-directories recursively +export async function readProject(projectDir: string) { + const readFiles = await readDir(projectDir, { + recursive: true, + }) + + return deepFileFilter(readFiles, isRelevantFileOrDir) +} + +// Given a read project, return the number of .kcl files, +// both in the root directory and in sub-directories, +// and folders that contain at least one .kcl file +export function getPartsCount(project: FileEntry[]) { + const flatProject = deepFileFilterFlat(project, isRelevantFileOrDir) + + const kclFileCount = flatProject.filter((f) => + f.name?.endsWith(FILE_EXT) + ).length + const kclDirCount = flatProject.filter((f) => f.children !== undefined).length + + return { + kclFileCount, + kclDirCount, + } +} + +// Determines if a file or directory is relevant to the project +// i.e. not a hidden file or directory, and is a relevant file type +// or contains at least one relevant file (even if it's nested) +// or is a completely empty directory +export function isRelevantFileOrDir(fileOrDir: FileEntry) { + let isRelevantDir = false + if ('children' in fileOrDir && fileOrDir.children !== undefined) { + isRelevantDir = + !isHidden(fileOrDir) && + (fileOrDir.children.some(isRelevantFileOrDir) || + fileOrDir.children.length === 0) + } + const isRelevantFile = + !isHidden(fileOrDir) && + RELEVANT_FILE_TYPES.some((ext) => fileOrDir.name?.endsWith(ext)) + + return ( + (isDir(fileOrDir) && isRelevantDir) || (!isDir(fileOrDir) && isRelevantFile) + ) +} + +// Deeply sort the files and directories in a project like VS Code does: +// The main.kcl file is always first, then files, then directories +// Files and directories are sorted alphabetically +export function sortProject(project: FileEntry[]): FileEntry[] { + const sortedProject = project.sort((a, b) => { + if (a.name === PROJECT_ENTRYPOINT) { + return -1 + } else if (b.name === PROJECT_ENTRYPOINT) { + return 1 + } else if (a.children === undefined && b.children !== undefined) { + return -1 + } else if (a.children !== undefined && b.children === undefined) { + return 1 + } else if (a.name && b.name) { + return a.name.localeCompare(b.name) + } else { + return 0 + } + }) + + return sortedProject.map((fileOrDir: FileEntry) => { + if ('children' in fileOrDir && fileOrDir.children !== undefined) { + return { + ...fileOrDir, + children: sortProject(fileOrDir.children), + } + } else { + return fileOrDir + } + }) +} + // Creates a new file in the default directory with the default project name // Returns the path to the new file export async function createNewProject( @@ -104,7 +234,7 @@ export async function createNewProject( return { name: path.slice(path.lastIndexOf('/') + 1), path: path, - entrypoint_metadata: m, + entrypointMetadata: m, children: [ { name: PROJECT_ENTRYPOINT, diff --git a/src/machines/fileMachine.ts b/src/machines/fileMachine.ts new file mode 100644 index 000000000..c32201517 --- /dev/null +++ b/src/machines/fileMachine.ts @@ -0,0 +1,178 @@ +import { assign, createMachine } from 'xstate' +import { ProjectWithEntryPointMetadata } from 'Router' +import { FileEntry } from '@tauri-apps/api/fs' + +export const FILE_PERSIST_KEY = 'Last opened KCL files' +export const DEFAULT_FILE_NAME = 'Untitled' + +export const fileMachine = createMachine( + { + /** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAHTK6xampYAOATqgFZj4AusAxAMLMwuLthbtOXANoAGALqJQjVLGJdiqUopAAPRAHYAbPooAWABwBGUwE5zAJgeGArM-MAaEAE9EN0wGYKGX97GX1nGVNDS0MbfwBfeM80TBwCEnIqGiZWDm4+ACUwUlxU8TzpeW1lVXVNbT0EcJNg02d-fzt7fU77Tx8EQ0iKCPtnfUsjGRtLGXtE5IxsPCIySmpacsk+QWFRHIluWQUkEBq1DS1TxqN7ChjzOxtXf0t7a37EcwsRibH-ZzRezA8wLEApZbpNZZTa5ba8AAiYAANmB9lsjlVTuc6ldQDdDOYKP5bm0os5TDJDJ8mlEzPpzIZHA4bO9umCIWlVpkNgcKnwAPKMYp8yTHaoqC71a6IEmBUz6BkWZzWDq2Uw0qzOIJAwz+PXWfSmeZJcFLLkZSi7ERkKCi7i8CCaShkABuqAA1pR8EIRGAALQYyonJSS3ENRDA2wUeyvd6dPVhGw0-RhGOp8IA8xGFkc80rS0Ua3qUh2oO8MDMVjMCiMZEiABmqGY6AoPr2AaD4uxYcuEYQoQpQWNNjsMnMgLGKbT3TC7TcOfsNjzqQL0KKJXQtvtXEdzoobs9lCEm87cMxIbOvel+MQqtMQRmS5ks31sZpAUsZkcIX+cQZJIrpC3KUBupTbuWlbVrW9ZcE2LYUCepRnocwYSrUfYyggbzvBQ+jMq49imLYwTUt4iCft+5i-u0-7UfoQEWtCSKoiWZbnruTqZIeXoUBAKJoihFTdqGGE3rod7UdqsQTI8hiGAqrIauRA7RvYeoqhO1jtAqjFrpkLFohBHEVlWzYwY2zatvxrFCWKWKiVKeISdh4yBJE-jGs4fhhA4zg0kRNgxhplhaW0nn4XpUKZEUuAQMZqF8FxLqkO6vG+hAgYcbAIlXmJzmNERdy0RYNiKgpthxDSEU6q8MSTJYjWGFFIEULF8WljuSX7jxx7CJlQY5ZYl44pht4IP61gyPc8njt0lIuH51UKrVVITEyMy2C1hbtQl-KmdBdaWQhGVZYluWjeJjSTf402shMEyuEyljPAFL0UNmMiuN86lWHMiSmvQ-HwKcnL6WA6FOf2k3mESMRDA4RpUm4U4qf6gSEt0QIvvqfjOCaiyrtF6zZPQXWQ+GWFlUEsbmNMf1TV9NLeXDcqRIySnNaaYPEzC5M9vl-b+IyFCjupryPF9jKWP5Kks-cbMWLERHRNt0LFntkgU2NLk4dqsz43YsTK++Kk2C+MbTOOcxzOMrhqzFxTgZ1Qba1dd6BUE1jGsLMxxK9KlDNqm3tMLUQvqYlgO5QhlsTubsFXesTTUuPTfHExshDS0RftRftGgEnTZtHbX9Zr+QJ-2S4Y3qnmTC+4tMyp1EfeOnmeQqdOhyXQrFOXXCV1hCkmLDOnBJYvRRDSsyRzGjiKj0lKdAkANAA */ + id: 'File machine', + + initial: 'Reading files', + + context: { + project: {} as ProjectWithEntryPointMetadata, + selectedDirectory: {} as FileEntry, + }, + + on: { + assign: { + actions: assign((_, event) => ({ + ...event.data, + })), + target: '.Reading files', + }, + }, + states: { + 'Has no files': { + on: { + 'Create file': { + target: 'Creating file', + }, + }, + }, + + 'Has files': { + on: { + 'Rename file': { + target: 'Renaming file', + }, + + 'Create file': { + target: 'Creating file', + }, + + 'Delete file': { + target: 'Deleting file', + }, + + 'Open file': { + target: 'Opening file', + }, + + 'Set selected directory': { + target: 'Has files', + actions: ['setSelectedDirectory'], + }, + }, + }, + + 'Creating file': { + invoke: { + id: 'create-file', + src: 'createFile', + onDone: [ + { + target: 'Reading files', + actions: ['toastSuccess'], + }, + ], + onError: [ + { + target: 'Reading files', + actions: ['toastError'], + }, + ], + }, + }, + + 'Renaming file': { + invoke: { + id: 'rename-file', + src: 'renameFile', + onDone: [ + { + target: '#File machine.Reading files', + actions: ['toastSuccess'], + }, + ], + onError: [ + { + target: '#File machine.Reading files', + actions: ['toastError'], + }, + ], + }, + }, + + 'Deleting file': { + invoke: { + id: 'delete-file', + src: 'deleteFile', + onDone: [ + { + actions: ['toastSuccess'], + target: '#File machine.Reading files', + }, + ], + onError: { + actions: ['toastError'], + target: '#File machine.Has files', + }, + }, + }, + + 'Reading files': { + invoke: { + id: 'read-files', + src: 'readFiles', + onDone: [ + { + cond: 'Has at least 1 file', + target: 'Has files', + actions: ['setFiles'], + }, + { + target: 'Has no files', + actions: ['setFiles'], + }, + ], + onError: [ + { + target: 'Has no files', + actions: ['toastError'], + }, + ], + }, + }, + + 'Opening file': { + entry: ['navigateToFile'], + }, + }, + + schema: { + events: {} as + | { type: 'Open file'; data: { name: string } } + | { + type: 'Rename file' + data: { oldName: string; newName: string; isDir: boolean } + } + | { type: 'Create file'; data: { name: string; makeDir: boolean } } + | { type: 'Delete file'; data: FileEntry } + | { type: 'Set selected directory'; data: FileEntry } + | { type: 'navigate'; data: { name: string } } + | { + type: 'done.invoke.read-files' + data: ProjectWithEntryPointMetadata + } + | { type: 'assign'; data: { [key: string]: any } }, + }, + + predictableActionArguments: true, + preserveActionOrder: true, + tsTypes: {} as import('./fileMachine.typegen').Typegen0, + }, + { + actions: { + setFiles: assign((_, event) => { + return { project: event.data } + }), + setSelectedDirectory: assign((_, event) => { + return { selectedDirectory: event.data } + }), + }, + } +) diff --git a/src/machines/fileMachine.typegen.ts b/src/machines/fileMachine.typegen.ts new file mode 100644 index 000000000..47e6c8ea0 --- /dev/null +++ b/src/machines/fileMachine.typegen.ts @@ -0,0 +1,96 @@ +// This file was automatically generated. Edits will be overwritten + +export interface Typegen0 { + '@@xstate/typegen': true + internalEvents: { + 'done.invoke.create-file': { + type: 'done.invoke.create-file' + data: unknown + __tip: 'See the XState TS docs to learn how to strongly type this.' + } + 'done.invoke.delete-file': { + type: 'done.invoke.delete-file' + data: unknown + __tip: 'See the XState TS docs to learn how to strongly type this.' + } + 'done.invoke.read-files': { + type: 'done.invoke.read-files' + data: unknown + __tip: 'See the XState TS docs to learn how to strongly type this.' + } + 'done.invoke.rename-file': { + type: 'done.invoke.rename-file' + data: unknown + __tip: 'See the XState TS docs to learn how to strongly type this.' + } + 'error.platform.create-file': { + type: 'error.platform.create-file' + data: unknown + } + 'error.platform.delete-file': { + type: 'error.platform.delete-file' + data: unknown + } + 'error.platform.read-files': { + type: 'error.platform.read-files' + data: unknown + } + 'error.platform.rename-file': { + type: 'error.platform.rename-file' + data: unknown + } + 'xstate.init': { type: 'xstate.init' } + } + invokeSrcNameMap: { + createFile: 'done.invoke.create-file' + deleteFile: 'done.invoke.delete-file' + readFiles: 'done.invoke.read-files' + renameFile: 'done.invoke.rename-file' + } + missingImplementations: { + actions: 'navigateToFile' | 'toastError' | 'toastSuccess' + delays: never + guards: 'Has at least 1 file' + services: 'createFile' | 'deleteFile' | 'readFiles' | 'renameFile' + } + eventsCausingActions: { + navigateToFile: 'Open file' + setFiles: 'done.invoke.read-files' + setSelectedDirectory: 'Set selected directory' + toastError: + | 'error.platform.create-file' + | 'error.platform.delete-file' + | 'error.platform.read-files' + | 'error.platform.rename-file' + toastSuccess: + | 'done.invoke.create-file' + | 'done.invoke.delete-file' + | 'done.invoke.rename-file' + } + eventsCausingDelays: {} + eventsCausingGuards: { + 'Has at least 1 file': 'done.invoke.read-files' + } + eventsCausingServices: { + createFile: 'Create file' + deleteFile: 'Delete file' + readFiles: + | 'assign' + | 'done.invoke.create-file' + | 'done.invoke.delete-file' + | 'done.invoke.rename-file' + | 'error.platform.create-file' + | 'error.platform.rename-file' + | 'xstate.init' + renameFile: 'Rename file' + } + matchesStates: + | 'Creating file' + | 'Deleting file' + | 'Has files' + | 'Has no files' + | 'Opening file' + | 'Reading files' + | 'Renaming file' + tags: never +} diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index bbc14a728..d59d12eb5 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -174,7 +174,7 @@ const Home = () => { return (
    -
    +

    Your Projects

    diff --git a/src/routes/Settings.tsx b/src/routes/Settings.tsx index 3b601cb9e..b29463b58 100644 --- a/src/routes/Settings.tsx +++ b/src/routes/Settings.tsx @@ -33,7 +33,8 @@ import { import { ONBOARDING_PROJECT_NAME } from './Onboarding' export const Settings = () => { - const loaderData = useRouteLoaderData(paths.FILE) as IndexLoaderData + const loaderData = + (useRouteLoaderData(paths.FILE) as IndexLoaderData) || undefined const navigate = useNavigate() const location = useLocation() const isFileSettings = location.pathname.includes(paths.FILE) @@ -100,7 +101,7 @@ export const Settings = () => { return (
    - +