Files
modeling-app/src/components/Explorer/ProjectExplorer.tsx
2025-06-17 16:34:07 -05:00

418 lines
14 KiB
TypeScript

import type { 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,
} from '@src/components/Explorer/utils'
import type {
FileExplorerEntry,
FileExplorerRow,
} from '@src/components/Explorer/utils'
import { FileExplorerHeaderActions } from '@src/components/Explorer/FileExplorerHeaderActions'
import { useState, useRef, useEffect } from 'react'
import { systemIOActor } from '@src/lib/singletons'
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
import { sortFilesAndDirectories } from '@src/lib/desktopFS'
import {
alwaysEndFileWithEXT,
getEXTWithPeriod,
joinOSPaths,
} from '@src/lib/paths'
import { useProjectDirectoryPath } from '@src/machines/systemIO/hooks'
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 compatiable with the data stored in
* the systemIOMachine
*
*/
export const ProjectExplorer = ({
project,
}: {
project: Project
}) => {
const projectDirectoryPath = useProjectDirectoryPath()
// 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 }>({})
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<FileExplorerRow | null>(
null
)
const [isRenaming, setIsRenaming] = useState<boolean>(false)
const fileExplorerContainer = useRef<HTMLDivElement | null>(null)
const openedRowsRef = useRef(openedRows)
const rowsToRenderRef = useRef(rowsToRender)
const activeIndexRef = useRef(activeIndex)
const selectedRowRef = useRef(selectedRow)
const isRenamingRef = useRef(isRenaming)
// 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)
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(() => {
// TODO What to do when a different project comes in? Clear old state.
// Clear openedRows
// Clear rowsToRender
// Clear selected information
// gotcha: sync state
openedRowsRef.current = openedRows
activeIndexRef.current = activeIndex
// 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
// Not a real path
/* eslint-disable */
const pathIterator = child.parentPath.split('/')
while (pathIterator.length > 0) {
// Not a real path
/* eslint-disable */
const key = pathIterator.join('/')
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 row: FileExplorerRow = {
// copy over all the other data that was built up to the DOM render row
...child,
icon: icon,
isFolder: !isFile,
status: StatusDot(),
isOpen,
render: render,
onClick: (domIndex: number) => {
onRowClickCallback(child, domIndex)
},
onOpen: () => {
const newOpenedRows = { ...openedRowsRef.current }
const key = child.key
newOpenedRows[key] = true
setOpenedRows(newOpenedRows)
},
rowContextMenu: () => {
// NO OP
},
isFake: false,
activeIndex: activeIndex,
rowDelete: () => {
systemIOActor.send({
type: SystemIOMachineEvents.deleteFileOrFolder,
data: {
requestedPath: child.path,
},
})
},
rowOpenInNewWindow: () => {
window.electron.openInNewWindow(row.path)
},
rowRenameStart: () => {
setIsRenaming(true)
isRenamingRef.current = true
},
rowRenameEnd: (event) => {
// TODO: Implement renameFolder and renameFile to navigate
setIsRenaming(false)
isRenamingRef.current = false
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) {
systemIOActor.send({
type: SystemIOMachineEvents.renameFolder,
data: {
requestedFolderName: requestedName,
folderName: name,
absolutePathToParentDirectory: joinOSPaths(
projectDirectoryPath,
child.parentPath
),
},
})
// 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
}
systemIOActor.send({
type: SystemIOMachineEvents.renameFile,
data: {
requestedFileNameWithExtension: fileNameForcedWithOriginalExt,
fileNameWithExtension: name,
absolutePathToParentDirectory: joinOSPaths(
projectDirectoryPath,
child.parentPath
),
},
})
}
},
}
return row
}) || []
const requestedRowsToRender = requestedRows.filter((row) => {
return row.render
})
// update the callback for rowContextMenu to be the index based on rendering
// Gotcha: you will see if you spam the context menu you will not be able to select a new one
// until closing
requestedRowsToRender.forEach((r, index) => {
r.rowContextMenu = () => {
setActiveIndex(index)
setContextMenuRow(r)
}
})
setRowsToRender(requestedRowsToRender)
rowsToRenderRef.current = requestedRowsToRender
console.log(activeIndex)
}, [project, openedRows, fakeRow, activeIndex])
// Handle clicks and keyboard presses within the global DOM level
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const path = event.composedPath ? event.composedPath() : []
if (
fileExplorerContainer.current &&
!path.includes(fileExplorerContainer.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)
}
break
}
}
document.addEventListener('keydown', keyDownHandler)
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', keyDownHandler)
}
}, [])
return (
<div>
<div className="flex flex-row justify-between">
<div>{project?.name || 'No Project Selected'}</div>
<div className="h-6 flex flex-row gap-1">
<FileExplorerHeaderActions
onCreateFile={() => {
setFakeRow({ entry: selectedRow, isFile: true })
}}
onCreateFolder={() => {
console.log('onCreateFolder TODO')
}}
onRefreshExplorer={() => {
// 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,
})
}}
onCollapseExplorer={() => {
setOpenedRows({})
}}
></FileExplorerHeaderActions>
</div>
</div>
<div
className={`h-96 overflow-y-auto overflow-x-hidden border border-transparent ${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)
}
}}
>
{activeIndex}
{project && (
<FileExplorer
rowsToRender={rowsToRender}
selectedRow={selectedRow}
contextMenuRow={contextMenuRow}
isRenaming={isRenaming}
></FileExplorer>
)}
</div>
</div>
)
}