497 lines
16 KiB
TypeScript
497 lines
16 KiB
TypeScript
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<T extends AnyStateMachine> = {
|
|
state?: StateFrom<T>
|
|
send: Prop<Actor<T>, 'send'>
|
|
}
|
|
|
|
export const ProjectsMachineContext = createContext(
|
|
{} as MachineContext<typeof projectsMachine>
|
|
)
|
|
|
|
/**
|
|
* 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() ? (
|
|
<ProjectsContextDesktop>{children}</ProjectsContextDesktop>
|
|
) : (
|
|
<ProjectsContextWeb>{children}</ProjectsContextWeb>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<ProjectsMachineContext.Provider
|
|
value={{
|
|
state,
|
|
send,
|
|
}}
|
|
>
|
|
{children}
|
|
</ProjectsMachineContext.Provider>
|
|
)
|
|
}
|
|
|
|
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<Configuration> = {
|
|
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 (
|
|
<ProjectsMachineContext.Provider
|
|
value={{
|
|
state,
|
|
send,
|
|
}}
|
|
>
|
|
{children}
|
|
</ProjectsMachineContext.Provider>
|
|
)
|
|
}
|