import type { IndexLoaderData } from 'lib/types' import { PATHS } from 'lib/paths' import { ActionButton } from './ActionButton' import Tooltip from './Tooltip' import { Dispatch, useCallback, useRef, useState } from 'react' import { useNavigate, useRouteLoaderData } from 'react-router-dom' import { Disclosure } from '@headlessui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faChevronRight, faPencil } from '@fortawesome/free-solid-svg-icons' import { useFileContext } from 'hooks/useFileContext' import styles from './FileTree.module.css' import { sortFilesAndDirectories } from 'lib/desktopFS' import { FILE_EXT } from 'lib/constants' import { CustomIcon } from './CustomIcon' import { codeManager, kclManager } from 'lib/singletons' import { useLspContext } from './LspProvider' import useHotkeyWrapper from 'lib/hotkeyWrapper' import { useModelingContext } from 'hooks/useModelingContext' import { DeleteConfirmationDialog } from './ProjectCard/DeleteProjectDialog' import { ContextMenu, ContextMenuItem } from './ContextMenu' import usePlatform from 'hooks/usePlatform' import { FileEntry } from 'lib/project' import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher' import { normalizeLineEndings } from 'lib/codeEditor' function getIndentationCSS(level: number) { return `calc(1rem * ${level + 1})` } function TreeEntryInput(props: { level: number onSubmit: (value: string) => void }) { const [value, setValue] = useState('') const onKeyPress = (e: React.KeyboardEvent) => { if (e.key !== 'Enter') return props.onSubmit(value) } return ( ) } function RenameForm({ fileOrDir, onSubmit, level = 0, }: { fileOrDir: FileEntry onSubmit: () => void level?: number }) { const { send } = useFileContext() const inputRef = useRef(null) function handleRenameSubmit(e: React.FormEvent) { e.preventDefault() send({ type: 'Rename file', data: { oldName: fileOrDir.name || '', newName: inputRef.current?.value || fileOrDir.name || '', isDir: fileOrDir.children !== null, }, }) } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === 'Escape') { e.stopPropagation() onSubmit() } } return (
) } function DeleteFileTreeItemDialog({ fileOrDir, setIsOpen, }: { fileOrDir: FileEntry setIsOpen: Dispatch> }) { const { send } = useFileContext() return ( setIsOpen(false)} onConfirm={() => { send({ type: 'Delete file', data: fileOrDir }) setIsOpen(false) }} >

This will permanently delete "{fileOrDir.name || 'this file'}" {fileOrDir.children !== null ? ' and all of its contents. ' : '. '}

Are you sure you want to delete "{fileOrDir.name || 'this file'} "? This action cannot be undone.

) } const FileTreeItem = ({ parentDir, project, currentFile, lastDirectoryClicked, fileOrDir, onNavigateToFile, onClickDirectory, onCreateFile, onCreateFolder, newTreeEntry, level = 0, treeSelection, setTreeSelection, }: { parentDir: FileEntry | undefined project?: IndexLoaderData['project'] currentFile?: IndexLoaderData['file'] lastDirectoryClicked?: FileEntry fileOrDir: FileEntry onNavigateToFile?: () => void onClickDirectory: ( open: boolean, path: FileEntry, parentDir: FileEntry | undefined ) => void onCreateFile: (name: string) => void onCreateFolder: (name: string) => void newTreeEntry: TreeEntry level?: number treeSelection: FileEntry | undefined setTreeSelection: Dispatch> }) => { const { send: fileSend, context: fileContext } = useFileContext() const { onFileOpen, onFileClose } = useLspContext() const navigate = useNavigate() const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) const isCurrentFile = fileOrDir.path === currentFile?.path const isFileOrDirHighlighted = treeSelection?.path === fileOrDir?.path const itemRef = useRef(null) // Since every file or directory gets its own FileTreeItem, we can do this. // Because subtrees only render when they are opened, that means this // only listens when they open. Because this acts like a useEffect, when // the ReactNodes are destroyed, so is this listener :) useFileSystemWatcher( async (eventType, path) => { // Don't try to read a file that was removed. if (isCurrentFile && eventType !== 'unlink') { // Prevents a cyclic read / write causing editor problems such as // misplaced cursor positions. if (codeManager.writeCausedByAppCheckedInFileTreeFileSystemWatcher) { codeManager.writeCausedByAppCheckedInFileTreeFileSystemWatcher = false return } let code = await window.electron.readFile(path, { encoding: 'utf-8' }) code = normalizeLineEndings(code) codeManager.updateCodeStateEditor(code) } fileSend({ type: 'Refresh' }) }, [fileOrDir.path] ) const showNewTreeEntry = newTreeEntry !== undefined && fileOrDir.path === fileContext.selectedDirectory.path const isRenaming = fileContext.itemsBeingRenamed.includes(fileOrDir.path) const removeCurrentItemFromRenaming = useCallback( () => fileSend({ type: 'assign', data: { itemsBeingRenamed: fileContext.itemsBeingRenamed.filter( (path) => path !== fileOrDir.path ), }, }), [fileContext.itemsBeingRenamed, fileOrDir.path, fileSend] ) const addCurrentItemToRenaming = useCallback(() => { fileSend({ type: 'assign', data: { itemsBeingRenamed: [...fileContext.itemsBeingRenamed, fileOrDir.path], }, }) }, [fileContext.itemsBeingRenamed, fileOrDir.path, fileSend]) function handleKeyUp(e: React.KeyboardEvent) { if (e.metaKey && e.key === 'Backspace') { // Open confirmation dialog setIsConfirmingDelete(true) } else if (e.key === 'Enter') { // Show the renaming form addCurrentItemToRenaming() } else if (e.code === 'Space') { handleClick() } } function handleClick() { setTreeSelection(fileOrDir) if (fileOrDir.children !== null) return // Don't open directories if (fileOrDir.name?.endsWith(FILE_EXT) === false && project?.path) { // Import non-kcl files // We want to update both the state and editor here. codeManager.updateCodeStateEditor( `import("${fileOrDir.path.replace(project.path, '.')}")\n` + codeManager.code ) // eslint-disable-next-line @typescript-eslint/no-floating-promises codeManager.writeToFile() // Prevent seeing the model built one piece at a time when changing files // eslint-disable-next-line @typescript-eslint/no-floating-promises kclManager.executeCode(true) } else { // Let the lsp servers know we closed a file. onFileClose(currentFile?.path || null, project?.path || null) onFileOpen(fileOrDir.path, project?.path || null) // Open kcl files navigate(`${PATHS.FILE}/${encodeURIComponent(fileOrDir.path)}`) } onNavigateToFile?.() } // The below handles both the "root" of all directories and all subs. It's // why some code is duplicated. return (
{fileOrDir.children === null ? (
  • {!isRenaming ? ( ) : ( )}
  • ) : ( {({ open }) => (
    {!isRenaming ? ( { e.stopPropagation() onClickDirectory(open, fileOrDir, parentDir) }} onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()} onKeyUp={handleKeyUp} > {fileOrDir.name} ) : (
    )}
      { e.stopPropagation() onClickDirectory(open, fileOrDir, parentDir) }} > {showNewTreeEntry && (
      newTreeEntry === 'file' ? onCreateFile(value) : onCreateFolder(value) } />
      )} {sortFilesAndDirectories(fileOrDir.children || []).map( (child) => ( ) )} {!showNewTreeEntry && fileOrDir.children?.length === 0 && (
      No files
      )}
    )}
    )} {isConfirmingDelete && ( )} setIsConfirmingDelete(true)} />
    ) } interface FileTreeContextMenuProps { itemRef: React.RefObject onRename: () => void onDelete: () => void } function FileTreeContextMenu({ itemRef, onRename, onDelete, }: FileTreeContextMenuProps) { const platform = usePlatform() const metaKey = platform === 'macos' ? '⌘' : 'Ctrl' return ( Rename , Delete , ]} /> ) } interface FileTreeProps { className?: string file?: IndexLoaderData['file'] onNavigateToFile: ( focusableElement?: | HTMLElement | React.MutableRefObject | undefined ) => void } export const FileTreeMenu = ({ onCreateFile, onCreateFolder, }: { onCreateFile: () => void onCreateFolder: () => void }) => { useHotkeyWrapper(['mod + n'], onCreateFile) useHotkeyWrapper(['mod + shift + n'], onCreateFolder) return ( <> Create file Create folder ) } type TreeEntry = 'file' | 'folder' | undefined export const useFileTreeOperations = () => { const { send } = useFileContext() const { send: modelingSend } = useModelingContext() // As long as this is undefined, a new "file tree entry prompt" is not shown. const [newTreeEntry, setNewTreeEntry] = useState(undefined) function createFile(args: { dryRun: boolean; name?: string }) { if (args.dryRun) { setNewTreeEntry('file') return } // Clear so that the entry prompt goes away. setNewTreeEntry(undefined) if (!args.name) return send({ type: 'Create file', data: { name: args.name, makeDir: false, shouldSetToRename: false }, }) modelingSend({ type: 'Cancel' }) } function createFolder(args: { dryRun: boolean; name?: string }) { if (args.dryRun) { setNewTreeEntry('folder') return } setNewTreeEntry(undefined) if (!args.name) return send({ type: 'Create file', data: { name: args.name, makeDir: true, shouldSetToRename: false }, }) } return { createFile, createFolder, newTreeEntry, } } export const FileTree = ({ className = '', onNavigateToFile: closePanel, }: FileTreeProps) => { const { createFile, createFolder, newTreeEntry } = useFileTreeOperations() return (

    Files

    createFile({ dryRun: true })} onCreateFolder={() => createFolder({ dryRun: true })} />
    createFile({ dryRun: false, name })} onCreateFolder={(name: string) => createFolder({ dryRun: false, name })} />
    ) } export const FileTreeInner = ({ onNavigateToFile, onCreateFile, onCreateFolder, newTreeEntry, }: { onCreateFile: (name: string) => void onCreateFolder: (name: string) => void newTreeEntry: TreeEntry onNavigateToFile?: () => void }) => { const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData const { send: fileSend, context: fileContext } = useFileContext() const { send: modelingSend } = useModelingContext() const [lastDirectoryClicked, setLastDirectoryClicked] = useState< FileEntry | undefined >(undefined) const [treeSelection, setTreeSelection] = useState( loaderData.file ) const onNavigateToFile_ = () => { // Reset modeling state when navigating to a new file onNavigateToFile?.() modelingSend({ type: 'Cancel' }) } // Refresh the file tree when there are changes. useFileSystemWatcher( async (eventType, path) => { // Our other watcher races with this watcher on the current file changes, // so we need to stop this one from reacting at all, otherwise Bad Things // Happen™. const isCurrentFile = loaderData.file?.path === path const hasChanged = eventType === 'change' if (isCurrentFile && hasChanged) return fileSend({ type: 'Refresh' }) }, [loaderData?.project?.path, fileContext.selectedDirectory.path].filter( (x: string | undefined) => x !== undefined ) ) const onTreeEntryInputSubmit = (value: string) => { if (newTreeEntry === 'file') { onCreateFile(value) onNavigateToFile_() } else { onCreateFolder(value) } } const onClickDirectory = ( open_: boolean, fileOrDir: FileEntry, parentDir: FileEntry | undefined ) => { // open true is closed... it's broken. Save me. I've inverted it here for // sanity. const open = !open_ const target = open ? fileOrDir : parentDir // We're at the root, can't select anything further if (!target) return setTreeSelection(target) setLastDirectoryClicked(target) fileSend({ type: 'Set selected directory', directory: target, }) } const showNewTreeEntry = newTreeEntry !== undefined && fileContext.selectedDirectory.path === loaderData.project?.path return (
      {showNewTreeEntry && (
      )} {sortFilesAndDirectories(fileContext.project?.children || []).map( (fileOrDir) => ( ) )}
    ) } export const FileTreeRoot = () => { const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData const { project } = loaderData // project.path should never be empty here but I guess during initial loading // it can be. return (
    {project?.name ?? ''}
    ) }