Files
modeling-app/src/components/Explorer/ProjectExplorer.tsx
2025-06-24 15:05:11 -05:00

683 lines
23 KiB
TypeScript

import type { FileEntry, Project } from '@src/lib/project'
import type { CustomIconName } from '@src/components/CustomIcon'
import { FILE_EXT } from '@src/lib/constants'
import { FileExplorer, StatusDot } from '@src/components/Explorer/FileExplorer'
import {
constructPath,
flattenProject,
NOTHING_IS_SELECTED,
CONTAINER_IS_SELECTED,
STARTING_INDEX_TO_SELECT,
FILE_PLACEHOLDER_NAME,
FOLDER_PLACEHOLDER_NAME,
} from '@src/components/Explorer/utils'
import type {
FileExplorerEntry,
FileExplorerRow,
} from '@src/components/Explorer/utils'
import { useState, useRef, useEffect } from 'react'
import { systemIOActor, useSettings } from '@src/lib/singletons'
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
import { sortFilesAndDirectories } from '@src/lib/desktopFS'
import {
alwaysEndFileWithEXT,
desktopSafePathJoin,
desktopSafePathSplit,
getEXTWithPeriod,
getParentAbsolutePath,
joinOSPaths,
parentPathRelativeToApplicationDirectory,
parentPathRelativeToProject,
} from '@src/lib/paths'
import { kclErrorsByFilename } from '@src/lang/errors'
import { useKclContext } from '@src/lang/KclProvider'
const isFileExplorerEntryOpened = (
rows: { [key: string]: boolean },
entry: FileExplorerEntry
): boolean => {
return rows[entry.key]
}
/**
* Wrap the header and the tree into a single component
* This is important because the action header buttons need to know
* the selection logic of the tree since add file will be based on your
* selection within the tree.
*
* pass a Project type which is compatible with the data stored in
* the systemIOMachine
*
*/
export const ProjectExplorer = ({
project,
file,
createFilePressed,
createFolderPressed,
refreshExplorerPressed,
collapsePressed,
onRowClicked,
onRowEnter,
readOnly,
canNavigate,
}: {
project: Project
file: FileEntry | undefined
createFilePressed: number
createFolderPressed: number
refreshExplorerPressed: number
collapsePressed: number
onRowClicked: (row: FileExplorerEntry, domIndex: number) => void
onRowEnter: (row: FileExplorerEntry, domIndex: number) => void
readOnly: boolean
canNavigate: boolean
}) => {
const { errors } = useKclContext()
const settings = useSettings()
const applicationProjectDirectory = settings.app.projectDirectory.current
/**
* Read the file you are loading into and open all of the parent paths to that file
* If there is no file passed in take the default_file from the project type
*/
const defaultFileKey = parentPathRelativeToApplicationDirectory(
file?.path || project.default_file,
applicationProjectDirectory
)
const defaultOpenedRows: { [key: string]: boolean } = {}
const pathIterator = desktopSafePathSplit(defaultFileKey)
while (pathIterator.length > 0) {
const key = desktopSafePathJoin(pathIterator)
defaultOpenedRows[key] = true
pathIterator.pop()
}
// cache the state of opened rows to allow nested rows to be opened if a parent one is closed
// when the parent opens the children will already be opened
const [openedRows, setOpenedRows] = useState<{ [key: string]: boolean }>(
defaultOpenedRows
)
const [selectedRow, setSelectedRow] = useState<FileExplorerEntry | null>(null)
// -1 is the parent container, -2 is nothing is selected
const [activeIndex, setActiveIndex] = useState<number>(NOTHING_IS_SELECTED)
const [rowsToRender, setRowsToRender] = useState<FileExplorerRow[]>([])
const [contextMenuRow, setContextMenuRow] =
useState<FileExplorerEntry | null>(null)
const [isRenaming, setIsRenaming] = useState<boolean>(false)
const fileExplorerContainer = useRef<HTMLDivElement | null>(null)
const projectExplorerRef = useRef<HTMLDivElement | null>(null)
const openedRowsRef = useRef(openedRows)
const rowsToRenderRef = useRef(rowsToRender)
const activeIndexRef = useRef(activeIndex)
const selectedRowRef = useRef(selectedRow)
const isRenamingRef = useRef(isRenaming)
const previousProject = useRef(project)
// fake row is used for new files or folders, you should not be able to have multiple fake rows for creation
const [fakeRow, setFakeRow] = useState<{
entry: FileExplorerEntry | null
isFile: boolean
} | null>(null)
/**
* External state handlers since the callback logic lives here.
* If code wants to externall trigger creating a file pass in a new timestamp.
*/
useEffect(() => {
if (createFilePressed <= 0 || readOnly) {
return
}
const row = rowsToRenderRef.current[activeIndexRef.current] || null
setFakeRow({ entry: row, isFile: true })
if (row?.key) {
// If the file tree had the folder opened make the new one open.
const newOpenedRows = { ...openedRowsRef.current }
newOpenedRows[row?.key] = true
setOpenedRows(newOpenedRows)
}
}, [createFilePressed])
useEffect(() => {
if (createFolderPressed <= 0 || readOnly) {
return
}
const row = rowsToRenderRef.current[activeIndexRef.current] || null
setFakeRow({ entry: row, isFile: false })
if (row?.key) {
// If the file tree had the folder opened make the new one open.
const newOpenedRows = { ...openedRowsRef.current }
newOpenedRows[row?.key] = true
setOpenedRows(newOpenedRows)
}
}, [createFolderPressed])
useEffect(() => {
if (refreshExplorerPressed <= 0) {
return
}
// TODO: Refresh only this path from the Project. This will refresh your entire application project directory
// It is correct but can be slow if there are many projects
systemIOActor.send({
type: SystemIOMachineEvents.readFoldersFromProjectDirectory,
})
}, [refreshExplorerPressed])
useEffect(() => {
if (collapsePressed <= 0) {
return
}
setOpenedRows({})
}, [collapsePressed])
const setSelectedRowWrapper = (row: FileExplorerEntry | null) => {
setSelectedRow(row)
selectedRowRef.current = row
}
/**
* Gotcha: closure
* Needs a reference to openedRows since it is a callback
*/
const onRowClickCallback = (file: FileExplorerEntry, domIndex: number) => {
const newOpenedRows = { ...openedRowsRef.current }
const key = file.key
const value = openedRowsRef.current[key]
newOpenedRows[key] = !value
setOpenedRows(newOpenedRows)
setSelectedRowWrapper(file)
setActiveIndex(domIndex)
}
useEffect(() => {
/**
* You are loading a new project, clear the internal state!
*/
if (previousProject.current.name !== project.name) {
setOpenedRows({})
setSelectedRow(null)
setActiveIndex(NOTHING_IS_SELECTED)
setRowsToRender([])
setContextMenuRow(null)
setIsRenaming(false)
}
// gotcha: sync state
openedRowsRef.current = openedRows
activeIndexRef.current = activeIndex
const runtimeErrors = kclErrorsByFilename(errors)
// Wrap the FileEntry in a FileExplorerEntry to keep track for more metadata
let flattenedData: FileExplorerEntry[] = []
if (project && project.children) {
// moves all folders up and files down, files are sorted within folders
// gotcha: this only sorts the current level, not recursive for all children!
const sortedData = sortFilesAndDirectories(project.children)
flattenedData = flattenProject(sortedData, project.name)
// insert fake row if one is present
}
const requestedRows: FileExplorerRow[] =
flattenedData.map((child) => {
const isFile = child.children === null
const isKCLFile = isFile && child.name?.endsWith(FILE_EXT)
/**
* If any parent is closed, keep the history of open children
*/
let isAnyParentClosed = false
const pathIterator = desktopSafePathSplit(child.parentPath)
while (pathIterator.length > 0) {
const key = desktopSafePathJoin(pathIterator)
const isOpened = openedRows[key] || project.name === key
isAnyParentClosed = isAnyParentClosed || !isOpened
pathIterator.pop()
}
const isOpen = openedRows[child.key]
const render =
(openedRows[child.parentPath] || project.name === child.parentPath) &&
!isAnyParentClosed
let icon: CustomIconName = 'file'
if (isKCLFile) {
icon = 'kcl'
} else if (!isFile && !isOpen) {
icon = 'folder'
} else if (!isFile && isOpen) {
icon = 'folderOpen'
}
const errorsAsKeyValue = Array.from(runtimeErrors, ([key, value]) => ({
key,
value,
}))
const anyParentFolderHasError = errorsAsKeyValue.some(
({ key, value }) => {
return key.indexOf(child.path) >= 0
}
)
const hasRuntimeError =
runtimeErrors.has(child.path) || anyParentFolderHasError
const row: FileExplorerRow = {
// copy over all the other data that was built up to the DOM render row
...child,
icon: icon,
isFolder: !isFile,
status: hasRuntimeError ? StatusDot() : <></>,
isOpen,
render: render,
onClick: (domIndex: number) => {
onRowClickCallback(child, domIndex)
onRowClicked(child, domIndex)
},
onOpen: () => {
const newOpenedRows = { ...openedRowsRef.current }
const key = child.key
newOpenedRows[key] = true
setOpenedRows(newOpenedRows)
},
onContextMenuOpen: (domIndex: number) => {
setActiveIndex(domIndex)
setContextMenuRow(child)
},
isFake: false,
activeIndex: activeIndex,
onDelete: () => {
if (readOnly) {
return
}
const shouldWeNavigate =
file?.path?.startsWith(child.path) && canNavigate
if (shouldWeNavigate && file && file.path) {
systemIOActor.send({
type: SystemIOMachineEvents.deleteFileOrFolderAndNavigate,
data: {
requestedPath: child.path,
requestedProjectName: project.name,
},
})
} else {
systemIOActor.send({
type: SystemIOMachineEvents.deleteFileOrFolder,
data: {
requestedPath: child.path,
},
})
}
},
onOpenInNewWindow: () => {
window.electron.openInNewWindow(row.path)
},
onRenameStart: () => {
if (readOnly) {
return
}
setIsRenaming(true)
isRenamingRef.current = true
},
onRenameEnd: (event: React.KeyboardEvent<HTMLElement> | null) => {
// TODO: Implement renameFolder and renameFile to navigate
setIsRenaming(false)
isRenamingRef.current = false
setFakeRow(null)
if (!event) {
return
}
const requestedName = String(event?.target?.value || '')
if (!requestedName) {
// user pressed esc
return
}
const name = row.name
// Rename a folder
if (row.isFolder) {
if (requestedName !== name) {
if (row.isFake) {
// create
systemIOActor.send({
type: SystemIOMachineEvents.createBlankFolder,
data: {
requestedAbsolutePath: joinOSPaths(
getParentAbsolutePath(row.path),
requestedName
),
},
})
} else {
const absolutePathToParentDirectory = getParentAbsolutePath(
row.path
)
const oldPath = window.electron.path.join(
absolutePathToParentDirectory,
name
)
const newPath = window.electron.path.join(
absolutePathToParentDirectory,
requestedName
)
const shouldWeNavigate =
file?.path?.startsWith(oldPath) && canNavigate
if (shouldWeNavigate && file && file.path) {
const requestedFileNameWithExtension =
parentPathRelativeToProject(
file?.path?.replace(oldPath, newPath),
applicationProjectDirectory
)
systemIOActor.send({
type: SystemIOMachineEvents.renameFolderAndNavigateToFile,
data: {
requestedFolderName: requestedName,
folderName: name,
absolutePathToParentDirectory,
requestedProjectName: project.name,
requestedFileNameWithExtension,
},
})
} else {
systemIOActor.send({
type: SystemIOMachineEvents.renameFolder,
data: {
requestedFolderName: requestedName,
folderName: name,
absolutePathToParentDirectory,
},
})
}
// TODO: Gotcha... Set new string open even if it fails?
if (openedRowsRef.current[child.key]) {
// If the file tree had the folder opened make the new one open.
const newOpenedRows = { ...openedRowsRef.current }
const key = constructPath({
parentPath: child.parentPath,
name: requestedName,
})
newOpenedRows[key] = true
setOpenedRows(newOpenedRows)
}
}
}
} else {
// rename a file
const originalExt = getEXTWithPeriod(name)
const fileNameForcedWithOriginalExt = alwaysEndFileWithEXT(
requestedName,
originalExt
)
if (!fileNameForcedWithOriginalExt) {
// TODO: OH NO!
return
}
const pathRelativeToParent = parentPathRelativeToProject(
joinOSPaths(
getParentAbsolutePath(row.path),
fileNameForcedWithOriginalExt
),
applicationProjectDirectory
)
if (row.isFake) {
// create a file if it is fake and navigate to that file!
if (file && canNavigate) {
systemIOActor.send({
type: SystemIOMachineEvents.importFileFromURL,
data: {
requestedCode: '',
requestedProjectName: project.name,
requestedFileNameWithExtension: pathRelativeToParent,
},
})
} else {
systemIOActor.send({
type: SystemIOMachineEvents.createBlankFile,
data: {
requestedAbsolutePath: joinOSPaths(
getParentAbsolutePath(row.path),
fileNameForcedWithOriginalExt
),
},
})
}
} else {
const requestedAbsoluteFilePathWithExtension = joinOSPaths(
getParentAbsolutePath(row.path),
name
)
// If your router loader is within the file you are renaming then reroute to the new path on disk
// If you are renaming a file you are not loaded into, do not reload!
const shouldWeNavigate =
requestedAbsoluteFilePathWithExtension === file?.path &&
canNavigate
systemIOActor.send({
type: shouldWeNavigate
? SystemIOMachineEvents.renameFileAndNavigateToFile
: SystemIOMachineEvents.renameFile,
data: {
requestedFileNameWithExtension:
fileNameForcedWithOriginalExt,
fileNameWithExtension: name,
absolutePathToParentDirectory: getParentAbsolutePath(
row.path
),
},
})
}
}
},
}
return row
}) || []
const requestedRowsToRender = requestedRows.filter((row) => {
let showPlaceHolder = false
if (fakeRow?.isFile) {
// fake row is a file
const showFileAtSameLevel =
fakeRow?.entry?.parentPath === row.parentPath &&
!row.isFolder === (fakeRow?.entry?.children === null) &&
row.name === FILE_PLACEHOLDER_NAME
const showFileWithinFolder =
!row.isFolder &&
!!fakeRow?.entry?.children &&
fakeRow?.entry?.key === row.parentPath &&
row.name === FILE_PLACEHOLDER_NAME
const fakeRowIsNullShowRootFile =
fakeRow.entry === null &&
row.parentPath === project.name &&
row.name === FILE_PLACEHOLDER_NAME
showPlaceHolder =
showFileAtSameLevel ||
showFileWithinFolder ||
fakeRowIsNullShowRootFile
} else if (fakeRow?.isFile === false) {
// fake row is a folder
const showFolderAtSameLevel =
fakeRow?.entry?.parentPath === row.parentPath &&
!row.isFolder === !!fakeRow?.entry?.children &&
row.name === FOLDER_PLACEHOLDER_NAME
const showFolderWithinFolder =
row.isFolder &&
!!fakeRow?.entry?.children &&
fakeRow?.entry?.key === row.parentPath &&
row.name === FOLDER_PLACEHOLDER_NAME
const fakeRowIsNullShowRootFolder =
fakeRow.entry === null &&
row.parentPath === project.name &&
row.name === FOLDER_PLACEHOLDER_NAME
showPlaceHolder =
showFolderAtSameLevel ||
showFolderWithinFolder ||
fakeRowIsNullShowRootFolder
}
const skipPlaceHolder =
!(
row.name === FILE_PLACEHOLDER_NAME ||
row.name === FOLDER_PLACEHOLDER_NAME
) || showPlaceHolder
row.isFake = showPlaceHolder
return row.render && skipPlaceHolder
})
setRowsToRender(requestedRowsToRender)
rowsToRenderRef.current = requestedRowsToRender
previousProject.current = project
}, [project, openedRows, fakeRow, activeIndex, errors])
// Handle clicks and keyboard presses within the global DOM level
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const path = event.composedPath ? event.composedPath() : []
if (
projectExplorerRef.current &&
!path.includes(projectExplorerRef.current)
) {
setActiveIndex(NOTHING_IS_SELECTED)
}
}
const keyDownHandler = (event: KeyboardEvent) => {
if (
activeIndexRef.current === NOTHING_IS_SELECTED ||
isRenamingRef.current
) {
// NO OP you are not focused in this DOM element
return
}
const key = event.key
const focusedEntry = rowsToRenderRef.current[activeIndexRef.current]
const shouldCheckOpened = focusedEntry
const isEntryOpened =
shouldCheckOpened &&
isFileExplorerEntryOpened(openedRowsRef.current, focusedEntry)
switch (key) {
case 'ArrowLeft':
if (activeIndexRef.current === CONTAINER_IS_SELECTED) {
// NO OP
} else if (shouldCheckOpened && isEntryOpened) {
// close
const newOpenedRows = { ...openedRowsRef.current }
const key = focusedEntry.key
const value = openedRowsRef.current[key]
newOpenedRows[key] = !value
setOpenedRows(newOpenedRows)
}
break
case 'ArrowRight':
if (activeIndexRef.current === CONTAINER_IS_SELECTED) {
// NO OP
} else if (shouldCheckOpened && !isEntryOpened) {
// open!
const newOpenedRows = { ...openedRowsRef.current }
const key = focusedEntry.key
const value = openedRowsRef.current[key]
newOpenedRows[key] = !value
setOpenedRows(newOpenedRows)
}
break
case 'ArrowUp':
setActiveIndex((previous) => {
if (previous === NOTHING_IS_SELECTED) {
return STARTING_INDEX_TO_SELECT
}
return Math.max(STARTING_INDEX_TO_SELECT, previous - 1)
})
break
case 'ArrowDown':
if (fileExplorerContainer.current) {
const numberOfDOMRows =
fileExplorerContainer.current.children[0].children.length - 1
setActiveIndex((previous) => {
if (previous === NOTHING_IS_SELECTED) {
return STARTING_INDEX_TO_SELECT
}
return Math.min(numberOfDOMRows, previous + 1)
})
}
break
case 'Enter':
if (activeIndexRef.current >= STARTING_INDEX_TO_SELECT) {
// open close folder
const newOpenedRows = { ...openedRowsRef.current }
const key = focusedEntry.key
const value = openedRowsRef.current[key]
newOpenedRows[key] = !value
setOpenedRows(newOpenedRows)
onRowEnter(focusedEntry, activeIndexRef.current)
}
break
}
}
const handleFocus = () => {
setActiveIndex(CONTAINER_IS_SELECTED)
}
const handleBlur = (event: FocusEvent) => {
const path = event.composedPath ? event.composedPath() : []
if (
projectExplorerRef.current instanceof HTMLDivElement &&
fileExplorerContainer.current &&
!path.includes(projectExplorerRef.current)
) {
setActiveIndex(NOTHING_IS_SELECTED)
}
}
document.addEventListener('keydown', keyDownHandler)
document.addEventListener('click', handleClickOutside)
fileExplorerContainer.current?.addEventListener('focus', handleFocus)
fileExplorerContainer.current?.addEventListener('blur', handleBlur)
return () => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', keyDownHandler)
fileExplorerContainer.current?.removeEventListener('focus', handleFocus)
fileExplorerContainer.current?.addEventListener('blur', handleBlur)
}
}, [])
return (
<div
className="h-full relative overflow-y-auto overflow-x-hidden"
ref={projectExplorerRef}
>
<div
className={`absolute w-full h-full ${activeIndex === -1 ? 'border-sky-500' : ''}`}
tabIndex={0}
role="tree"
aria-label="Files Explorer"
ref={fileExplorerContainer}
onClick={(event) => {
if (event.target === fileExplorerContainer.current) {
setActiveIndex(CONTAINER_IS_SELECTED)
setSelectedRowWrapper(null)
}
}}
>
{project && (
<FileExplorer
rowsToRender={rowsToRender}
selectedRow={selectedRow}
contextMenuRow={contextMenuRow}
isRenaming={isRenaming}
></FileExplorer>
)}
</div>
</div>
)
}