import { useMachine } from '@xstate/react' import { useNavigate, useRouteLoaderData } from 'react-router-dom' import { type IndexLoaderData } from 'lib/types' import { PATHS } from 'lib/paths' import React, { createContext } from 'react' import { toast } from 'react-hot-toast' import { AnyStateMachine, ContextFrom, EventFrom, InterpreterFrom, Prop, StateFrom, assign, } from 'xstate' import { useCommandsContext } from 'hooks/useCommandsContext' import { fileMachine } from 'machines/fileMachine' import { isDesktop } from 'lib/isDesktop' import { DEFAULT_FILE_NAME, DEFAULT_PROJECT_KCL_FILE, FILE_EXT, } from 'lib/constants' import { getProjectInfo } from 'lib/desktop' import { getNextDirName, getNextFileName } from 'lib/desktopFS' 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 { commandBarSend } = useCommandsContext() const { project, file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData const [state, send] = useMachine(fileMachine, { context: { project, selectedDirectory: project, }, actions: { navigateToFile: (context, event) => { if (event.data && 'name' in event.data) { commandBarSend({ type: 'Close' }) navigate( `..${PATHS.FILE}/${encodeURIComponent( context.selectedDirectory + window.electron.path.sep + event.data.name )}` ) } else if ( event.data && 'path' in event.data && event.data.path.endsWith(FILE_EXT) ) { // Don't navigate to newly created directories navigate(`..${PATHS.FILE}/${encodeURIComponent(event.data.path)}`) } }, addFileToRenamingQueue: assign({ itemsBeingRenamed: (context, event) => [ ...context.itemsBeingRenamed, event.data.path, ], }), removeFileFromRenamingQueue: assign({ itemsBeingRenamed: ( context, event: EventFrom ) => context.itemsBeingRenamed.filter( (path) => path !== event.data.oldPath ), }), renameToastSuccess: (_, event) => toast.success(event.data.message), createToastSuccess: (_, event) => toast.success(event.data.message), toastSuccess: (_, event) => event.data && toast.success((event.data || '') + ''), toastError: (_, event) => toast.error((event.data || '') + ''), }, services: { readFiles: async (context: ContextFrom) => { const newFiles = isDesktop() ? (await getProjectInfo(context.project.path)).children : [] return { ...context.project, children: newFiles, } }, createAndOpenFile: async (context, event) => { let createdName = event.data.name.trim() || DEFAULT_FILE_NAME let createdPath: string if (event.data.makeDir) { let { name, path } = getNextDirName({ entryName: createdName, baseDir: context.selectedDirectory.path, }) createdName = name createdPath = path await window.electron.mkdir(createdPath) } else { const { name, path } = getNextFileName({ entryName: createdName, baseDir: context.selectedDirectory.path, }) createdName = name createdPath = path await window.electron.writeFile(createdPath, event.data.content ?? '') } return { message: `Successfully created "${createdName}"`, path: createdPath, } }, createFile: async (context, event) => { let createdName = event.data.name.trim() || DEFAULT_FILE_NAME let createdPath: string if (event.data.makeDir) { let { name, path } = getNextDirName({ entryName: createdName, baseDir: context.selectedDirectory.path, }) createdName = name createdPath = path await window.electron.mkdir(createdPath) } else { const { name, path } = getNextFileName({ entryName: createdName, baseDir: context.selectedDirectory.path, }) createdName = name createdPath = path await window.electron.writeFile(createdPath, event.data.content ?? '') } return { path: createdPath, } }, renameFile: async ( context: ContextFrom, event: EventFrom ) => { const { oldName, newName, isDir } = event.data const name = newName ? newName.endsWith(FILE_EXT) || isDir ? newName : newName + FILE_EXT : DEFAULT_FILE_NAME const oldPath = window.electron.path.join( context.selectedDirectory.path, oldName ) const newPath = window.electron.path.join( context.selectedDirectory.path, name ) // no-op if (oldPath === newPath) { return { message: `Old is the same as new.`, newPath, oldPath, } } // if there are any siblings with the same name, report error. const entries = await window.electron.readdir( window.electron.path.dirname(newPath) ) for (let entry of entries) { if (entry === newName) { return Promise.reject(new Error('Filename already exists.')) } } window.electron.rename(oldPath, newPath) if (!file) { return Promise.reject(new Error('file is not defined')) } if (oldPath === file.path && project?.path) { // If we just renamed the current file, navigate to the new path navigate(`..${PATHS.FILE}/${encodeURIComponent(newPath)}`) } else if (file?.path.includes(oldPath)) { // If we just renamed a directory that the current file is in, navigate to the new path navigate( `..${PATHS.FILE}/${encodeURIComponent( file.path.replace(oldPath, newPath) )}` ) } return { message: `Successfully renamed "${oldName}" to "${name}"`, newPath, oldPath, } }, deleteFile: async ( context: ContextFrom, event: EventFrom ) => { const isDir = !!event.data.children if (isDir) { await window.electron .rm(event.data.path, { recursive: true, }) .catch((e) => console.error('Error deleting directory', e)) } else { await window.electron .rm(event.data.path) .catch((e) => console.error('Error deleting file', e)) } // If there are no more files at all in the project, create a main.kcl // for when we navigate to the root. if (!project?.path) { return Promise.reject(new Error('Project path not set.')) } const entries = await window.electron.readdir(project.path) const hasKclEntries = entries.filter((e: string) => e.endsWith('.kcl')).length !== 0 if (!hasKclEntries) { await window.electron.writeFile( window.electron.path.join(project.path, DEFAULT_PROJECT_KCL_FILE), '' ) // Refresh the route selected above because it's possible we're on // the same path on the navigate, which doesn't cause anything to // refresh, leaving a stale execution state. navigate(0) return } // If we just deleted the current file or one of its parent directories, // navigate to the project root if ( (event.data.path === file?.path || file?.path.includes(event.data.path)) && project?.path ) { navigate(`../${PATHS.FILE}/${encodeURIComponent(project.path)}`) } 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 }, 'Is not silent': (_, event) => !event.data?.silent, }, }) return ( {children} ) } export default FileMachineProvider