import { useMachine } from '@xstate/react' import { useCommandsContext } from 'hooks/useCommandsContext' import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher' import { useProjectsLoader } from 'hooks/useProjectsLoader' import { projectsMachine } from 'machines/projectsMachine' import { createContext, useCallback, useEffect, useState } from 'react' import { Actor, AnyStateMachine, fromPromise, Prop, StateFrom } from 'xstate' import { useLspContext } from './LspProvider' import toast from 'react-hot-toast' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { PATHS } from 'lib/paths' import { createNewProjectDirectory, listProjects, renameProjectDirectory, } from 'lib/desktop' import { getNextProjectIndex, interpolateProjectNameWithIndex, doesProjectNameNeedInterpolated, getUniqueProjectName, getNextFileName, } from 'lib/desktopFS' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import useStateMachineCommands from 'hooks/useStateMachineCommands' import { projectsCommandBarConfig } from 'lib/commandBarConfigs/projectsCommandConfig' import { isDesktop } from 'lib/isDesktop' import { CREATE_FILE_URL_PARAM, FILE_EXT, PROJECT_ENTRYPOINT, } from 'lib/constants' import { DeepPartial } from 'lib/types' import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' import { codeManager } from 'lib/singletons' import { loadAndValidateSettings, projectConfigurationToSettingsPayload, saveSettings, setSettingsAtLevel, } from 'lib/settings/settingsUtils' import { Project } from 'lib/project' type MachineContext = { state?: StateFrom send: Prop, 'send'> } export const ProjectsMachineContext = createContext( {} as MachineContext ) /** * Watches the project directory and provides project management-related commands, * like "Create project", "Open project", "Delete project", etc. * * If in the future we implement full-fledge project management in the web version, * we can unify these components but for now, we need this to be only for the desktop version. */ export const ProjectsContextProvider = ({ children, }: { children: React.ReactNode }) => { return isDesktop() ? ( {children} ) : ( {children} ) } /** * We need some of the functionality of the ProjectsContextProvider in the web version * but we can't perform file system operations in the browser, * so most of the behavior of this machine is stubbed out. */ const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => { const [searchParams, setSearchParams] = useSearchParams() const clearImportSearchParams = useCallback(() => { // Clear the search parameters related to the "Import file from URL" command // or we'll never be able cancel or submit it. searchParams.delete(CREATE_FILE_URL_PARAM) searchParams.delete('code') searchParams.delete('name') searchParams.delete('units') setSearchParams(searchParams) }, [searchParams, setSearchParams]) const { settings: { context: settings, send: settingsSend }, } = useSettingsAuthContext() const [state, send, actor] = useMachine( projectsMachine.provide({ actions: { navigateToProject: () => {}, navigateToProjectIfNeeded: () => {}, navigateToFile: () => {}, toastSuccess: ({ event }) => toast.success( ('data' in event && typeof event.data === 'string' && event.data) || ('output' in event && 'message' in event.output && typeof event.output.message === 'string' && event.output.message) || '' ), toastError: ({ event }) => toast.error( ('data' in event && typeof event.data === 'string' && event.data) || ('output' in event && typeof event.output === 'string' && event.output) || '' ), }, actors: { readProjects: fromPromise(async () => [] as Project[]), createProject: fromPromise(async () => ({ message: 'not implemented on web', })), renameProject: fromPromise(async () => ({ message: 'not implemented on web', oldName: '', newName: '', })), deleteProject: fromPromise(async () => ({ message: 'not implemented on web', name: '', })), createFile: fromPromise(async ({ input }) => { // Browser version doesn't navigate, just overwrites the current file clearImportSearchParams() codeManager.updateCodeStateEditor(input.code || '') await codeManager.writeToFile() settingsSend({ type: 'set.modeling.defaultUnit', data: { level: 'project', value: input.units, }, }) return { message: 'File and units overwritten successfully', fileName: input.name, projectName: '', } }), }, }), { input: { projects: [], defaultProjectName: settings.projects.defaultProjectName.current, defaultDirectory: settings.app.projectDirectory.current, }, } ) // register all project-related command palette commands useStateMachineCommands({ machineId: 'projects', send, state, commandBarConfig: projectsCommandBarConfig, actor, onCancel: clearImportSearchParams, }) return ( {children} ) } const ProjectsContextDesktop = ({ children, }: { children: React.ReactNode }) => { const navigate = useNavigate() const location = useLocation() const [searchParams, setSearchParams] = useSearchParams() const clearImportSearchParams = useCallback(() => { // Clear the search parameters related to the "Import file from URL" command // or we'll never be able cancel or submit it. searchParams.delete(CREATE_FILE_URL_PARAM) searchParams.delete('code') searchParams.delete('name') searchParams.delete('units') setSearchParams(searchParams) }, [searchParams, setSearchParams]) const { commandBarSend } = useCommandsContext() const { onProjectOpen } = useLspContext() const { settings: { context: settings }, } = useSettingsAuthContext() const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0) const { projectPaths, projectsDir } = useProjectsLoader([ projectsLoaderTrigger, ]) // Re-read projects listing if the projectDir has any updates. useFileSystemWatcher( async () => { return setProjectsLoaderTrigger(projectsLoaderTrigger + 1) }, projectsDir ? [projectsDir] : [] ) const [state, send, actor] = useMachine( projectsMachine.provide({ actions: { navigateToProject: ({ context, event }) => { const nameFromEventData = 'data' in event && event.data && 'name' in event.data && event.data.name const nameFromOutputData = 'output' in event && event.output && 'name' in event.output && event.output.name const name = nameFromEventData || nameFromOutputData if (name) { let projectPath = context.defaultDirectory + window.electron.path.sep + name onProjectOpen( { name, path: projectPath, }, null ) commandBarSend({ type: 'Close' }) const newPathName = `${PATHS.FILE}/${encodeURIComponent( projectPath )}` navigate(newPathName) } }, navigateToProjectIfNeeded: ({ event }) => { if ( event.type.startsWith('xstate.done.actor.') && 'output' in event ) { const isInAProject = location.pathname.startsWith(PATHS.FILE) const isInDeletedProject = event.type === 'xstate.done.actor.delete-project' && isInAProject && decodeURIComponent(location.pathname).includes(event.output.name) if (isInDeletedProject) { navigate(PATHS.HOME) return } const isInRenamedProject = event.type === 'xstate.done.actor.rename-project' && isInAProject && decodeURIComponent(location.pathname).includes( event.output.oldName ) if (isInRenamedProject) { // TODO: In future, we can navigate to the new project path // directly, but we need to coordinate with // @lf94's useFileSystemWatcher in SettingsAuthProvider.tsx:224 // Because it's beating us to the punch and updating the route // const newPathName = location.pathname.replace( // encodeURIComponent(event.output.oldName), // encodeURIComponent(event.output.newName) // ) // navigate(newPathName) return } } }, navigateToFile: ({ context, event }) => { if (event.type !== 'xstate.done.actor.create-file') return // For now, the browser version of create-file doesn't need to navigate // since it just overwrites the current file. if (!isDesktop()) return let projectPath = window.electron.join( context.defaultDirectory, event.output.projectName ) let filePath = window.electron.join( projectPath, event.output.fileName ) onProjectOpen( { name: event.output.projectName, path: projectPath, }, null ) const pathToNavigateTo = `${PATHS.FILE}/${encodeURIComponent( filePath )}` navigate(pathToNavigateTo) }, toastSuccess: ({ event }) => toast.success( ('data' in event && typeof event.data === 'string' && event.data) || ('output' in event && 'message' in event.output && typeof event.output.message === 'string' && event.output.message) || '' ), toastError: ({ event }) => toast.error( ('data' in event && typeof event.data === 'string' && event.data) || ('output' in event && typeof event.output === 'string' && event.output) || '' ), }, actors: { readProjects: fromPromise(() => listProjects()), createProject: fromPromise(async ({ input }) => { let name = ( input && 'name' in input && input.name ? input.name : settings.projects.defaultProjectName.current ).trim() const uniqueName = getUniqueProjectName(name, input.projects) await createNewProjectDirectory(uniqueName) return { message: `Successfully created "${uniqueName}"`, name: uniqueName, } }), renameProject: fromPromise(async ({ input }) => { const { oldName, newName, defaultProjectName, defaultDirectory, projects, } = input let name = newName ? newName : defaultProjectName if (doesProjectNameNeedInterpolated(name)) { const nextIndex = getNextProjectIndex(name, projects) name = interpolateProjectNameWithIndex(name, nextIndex) } await renameProjectDirectory( window.electron.path.join(defaultDirectory, oldName), name ) return { message: `Successfully renamed "${oldName}" to "${name}"`, oldName: oldName, newName: name, } }), deleteProject: fromPromise(async ({ input }) => { await window.electron.rm( window.electron.path.join(input.defaultDirectory, input.name), { recursive: true, } ) return { message: `Successfully deleted "${input.name}"`, name: input.name, } }), createFile: fromPromise(async ({ input }) => { let projectName = (input.method === 'newProject' ? input.name : input.projectName) || settings.projects.defaultProjectName.current let fileName = input.method === 'newProject' ? PROJECT_ENTRYPOINT : input.name.endsWith(FILE_EXT) ? input.name : input.name + FILE_EXT let message = 'File created successfully' const unitsConfiguration: DeepPartial = { settings: { project: { directory: settings.app.projectDirectory.current, }, modeling: { base_unit: input.units, }, }, } const needsInterpolated = doesProjectNameNeedInterpolated(projectName) if (needsInterpolated) { const nextIndex = getNextProjectIndex(projectName, input.projects) projectName = interpolateProjectNameWithIndex( projectName, nextIndex ) } // Create the project around the file if newProject if (input.method === 'newProject') { await createNewProjectDirectory( projectName, input.code, unitsConfiguration ) message = `Project "${projectName}" created successfully with link contents` } else { let projectPath = window.electron.join( settings.app.projectDirectory.current, projectName ) message = `File "${fileName}" created successfully` const existingConfiguration = await loadAndValidateSettings( projectPath ) const settingsToSave = setSettingsAtLevel( existingConfiguration.settings, 'project', projectConfigurationToSettingsPayload(unitsConfiguration) ) await saveSettings(settingsToSave, projectPath) } // Create the file let baseDir = window.electron.join( settings.app.projectDirectory.current, projectName ) const { name, path } = getNextFileName({ entryName: fileName, baseDir, }) fileName = name await window.electron.writeFile(path, input.code || '') return { message, fileName, projectName, } }), }, }), { input: { projects: projectPaths, defaultProjectName: settings.projects.defaultProjectName.current, defaultDirectory: settings.app.projectDirectory.current, }, } ) useEffect(() => { send({ type: 'Read projects', data: {} }) }, [projectPaths]) // register all project-related command palette commands useStateMachineCommands({ machineId: 'projects', send, state, commandBarConfig: projectsCommandBarConfig, actor, onCancel: clearImportSearchParams, }) return ( {children} ) }