418 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|