chore: deleted the old file tree:

This commit is contained in:
Kevin
2025-06-25 10:10:26 -05:00
parent 332cc272fc
commit 9b90c98fb4
6 changed files with 44 additions and 1225 deletions

View File

@ -101,7 +101,7 @@ const flattenProjectHelper = (
name: f.name,
}),
setSize,
positionInSet
positionInSet,
}
// keep track of the file once within the recursive list that will be built up
list.push(content)

View File

@ -1,51 +1,21 @@
import { useMachine } from '@xstate/react'
import React, { createContext, useEffect, useMemo } from 'react'
import { toast } from 'react-hot-toast'
import React, { useEffect, useMemo } from 'react'
import { useLocation, useNavigate, useRouteLoaderData } from 'react-router-dom'
import type {
Actor,
AnyStateMachine,
ContextFrom,
Prop,
StateFrom,
} from 'xstate'
import { fromPromise } from 'xstate'
import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
import { useMenuListener } from '@src/hooks/useMenu'
import { newKclFile } from '@src/lang/project'
import { createNamedViewsCommand } from '@src/lib/commandBarConfigs/namedViewsConfig'
import { createRouteCommands } from '@src/lib/commandBarConfigs/routeCommandConfig'
import {
DEFAULT_DEFAULT_LENGTH_UNIT,
DEFAULT_FILE_NAME,
DEFAULT_PROJECT_KCL_FILE,
FILE_EXT,
} from '@src/lib/constants'
import { getProjectInfo } from '@src/lib/desktop'
import { getNextDirName, getNextFileName } from '@src/lib/desktopFS'
import { DEFAULT_DEFAULT_LENGTH_UNIT } from '@src/lib/constants'
import { isDesktop } from '@src/lib/isDesktop'
import { kclCommands } from '@src/lib/kclCommands'
import { BROWSER_PATH, PATHS } from '@src/lib/paths'
import { markOnce } from '@src/lib/performance'
import { codeManager, kclManager } from '@src/lib/singletons'
import { err } from '@src/lib/trap'
import { type IndexLoaderData } from '@src/lib/types'
import { useSettings, useToken } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons'
import { fileMachine } from '@src/machines/fileMachine'
import { modelingMenuCallbackMostActions } from '@src/menu/register'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
context: ContextFrom<T>
send: Prop<Actor<T>, 'send'>
}
export const FileContext = createContext(
{} as MachineContext<typeof fileMachine>
)
export const FileMachineProvider = ({
children,
}: {
@ -93,300 +63,6 @@ export const FileMachineProvider = ({
markOnce('code/didLoadFile')
}, [])
const [state, send] = useMachine(
fileMachine.provide({
actions: {
renameToastSuccess: ({ event }) => {
if (event.type !== 'xstate.done.actor.rename-file') return
toast.success(event.output.message)
},
createToastSuccess: ({ event }) => {
if (event.type !== 'xstate.done.actor.create-and-open-file') return
toast.success(event.output.message)
},
toastSuccess: ({ event }) => {
if (
event.type !== 'xstate.done.actor.rename-file' &&
event.type !== 'xstate.done.actor.delete-file'
)
return
toast.success(event.output.message)
},
toastError: ({ event }) => {
if (event.type !== 'xstate.done.actor.rename-file') return
toast.error(event.output.message)
},
navigateToFile: ({ context, event }) => {
if (event.type !== 'xstate.done.actor.create-and-open-file') return
if (event.output && 'name' in event.output) {
// TODO: Technically this is not the same as the FileTree Onclick even if they are in the same page
// What is "Open file?"
commandBarActor.send({ type: 'Close' })
navigate(
`..${PATHS.FILE}/${encodeURIComponent(
// TODO: Should this be context.selectedDirectory.path?
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
context.selectedDirectory +
window.electron.path.sep +
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
event.output.name
)}`
)
} else if (
event.output &&
'path' in event.output &&
event.output.path.endsWith(FILE_EXT)
) {
// Don't navigate to newly created directories
navigate(`..${PATHS.FILE}/${encodeURIComponent(event.output.path)}`)
}
},
openFileInNewWindow: ({ event }) => {
if (event.type !== 'Open file in new window') {
return
}
commandBarActor.send({ type: 'Close' })
window.electron.openInNewWindow(event.data.name)
},
},
actors: {
readFiles: fromPromise(async ({ input }) => {
const newFiles =
(isDesktop() ? (await getProjectInfo(input.path)).children : []) ??
[]
return {
...input,
children: newFiles,
}
}),
createAndOpenFile: fromPromise(async ({ input }) => {
let createdName = input.name.trim() || DEFAULT_FILE_NAME
let createdPath: string
if (
(input.targetPathToClone &&
(await window.electron.statIsDirectory(
input.targetPathToClone
))) ||
input.makeDir
) {
let { name, path } = getNextDirName({
entryName: input.targetPathToClone
? window.electron.path.basename(input.targetPathToClone)
: createdName,
baseDir: input.targetPathToClone
? window.electron.path.dirname(input.targetPathToClone)
: input.selectedDirectory.path,
})
createdName = name
createdPath = path
await window.electron.mkdir(createdPath)
} else {
const isTargetPathToCloneASubPath =
input.targetPathToClone &&
input.selectedDirectory.path.indexOf(input.targetPathToClone) > -1
if (isTargetPathToCloneASubPath) {
const { name, path } = getNextFileName({
entryName: input.targetPathToClone
? window.electron.path.basename(input.targetPathToClone)
: createdName,
baseDir: input.targetPathToClone
? window.electron.path.dirname(input.targetPathToClone)
: input.selectedDirectory.path,
})
createdName = name
createdPath = path
} else {
const { name, path } = getNextFileName({
entryName: input.targetPathToClone
? window.electron.path.basename(input.targetPathToClone)
: createdName,
baseDir: input.selectedDirectory.path,
})
createdName = name
createdPath = path
}
if (input.targetPathToClone) {
await window.electron.copyFile(
input.targetPathToClone,
createdPath
)
} else {
const codeToWrite = newKclFile(
input.content,
settings.modeling.defaultUnit.current
)
if (err(codeToWrite)) return Promise.reject(codeToWrite)
await window.electron.writeFile(createdPath, codeToWrite)
}
}
return {
message: `Successfully created "${createdName}"`,
path: createdPath,
shouldSetToRename: input.shouldSetToRename,
}
}),
createFile: fromPromise(async ({ input }) => {
let createdName = input.name.trim() || DEFAULT_FILE_NAME
let createdPath: string
if (input.makeDir) {
let { name, path } = getNextDirName({
entryName: createdName,
baseDir: input.selectedDirectory.path,
})
createdName = name
createdPath = path
await window.electron.mkdir(createdPath)
} else {
const { name, path } = getNextFileName({
entryName: createdName,
baseDir: input.selectedDirectory.path,
})
createdName = name
createdPath = path
const codeToWrite = newKclFile(
input.content,
settings.modeling.defaultUnit.current
)
if (err(codeToWrite)) return Promise.reject(codeToWrite)
await window.electron.writeFile(createdPath, codeToWrite)
}
return {
path: createdPath,
}
}),
renameFile: fromPromise(async ({ input }) => {
const { oldName, newName, isDir } = input
const name = newName
? newName.endsWith(FILE_EXT) || isDir
? newName
: newName + FILE_EXT
: DEFAULT_FILE_NAME
const oldPath = window.electron.path.join(
input.parentDirectory.path,
oldName
)
const newPath = window.electron.path.join(
input.parentDirectory.path,
name
)
// no-op
if (oldPath === newPath) {
return {
message: `Old is the same as new.`,
newPath,
oldPath,
}
}
// if there are any siblings with the same name, report error.
const entries = await window.electron.readdir(
window.electron.path.dirname(newPath)
)
for (let entry of entries) {
if (entry === newName) {
return Promise.reject(new Error('Filename already exists.'))
}
}
window.electron.rename(oldPath, newPath)
if (!file) {
return Promise.reject(new Error('file is not defined'))
}
if (oldPath === file.path && project?.path) {
// If we just renamed the current file, navigate to the new path
navigate(`..${PATHS.FILE}/${encodeURIComponent(newPath)}`)
} else if (file?.path.includes(oldPath)) {
// If we just renamed a directory that the current file is in, navigate to the new path
navigate(
`..${PATHS.FILE}/${encodeURIComponent(
file.path.replace(oldPath, newPath)
)}`
)
}
return {
message: `Successfully renamed "${oldName}" to "${name}"`,
newPath,
oldPath,
}
}),
deleteFile: fromPromise(async ({ input }) => {
const isDir = !!input.children
if (isDir) {
await window.electron
.rm(input.path, {
recursive: true,
})
.catch((e) => console.error('Error deleting directory', e))
} else {
await window.electron
.rm(input.path)
.catch((e) => console.error('Error deleting file', e))
}
// If there are no more files at all in the project, create a main.kcl
// for when we navigate to the root.
if (!project?.path) {
return Promise.reject(new Error('Project path not set.'))
}
const entries = await window.electron.readdir(project.path)
const hasKclEntries =
entries.filter((e: string) => e.endsWith('.kcl')).length !== 0
if (!hasKclEntries) {
const codeToWrite = newKclFile(
undefined,
settings.modeling.defaultUnit.current
)
if (err(codeToWrite)) return Promise.reject(codeToWrite)
const path = window.electron.path.join(
project.path,
DEFAULT_PROJECT_KCL_FILE
)
await window.electron.writeFile(path, codeToWrite)
// Refresh the route selected above because it's possible we're on
// the same path on the navigate, which doesn't cause anything to
// refresh, leaving a stale execution state.
navigate(0)
return {
message: 'No more files in project, created main.kcl',
}
}
// If we just deleted the current file or one of its parent directories,
// navigate to the project root
if (
(input.path === file?.path || file?.path.includes(input.path)) &&
project?.path
) {
navigate(`../${PATHS.FILE}/${encodeURIComponent(project.path)}`)
}
return {
message: `Successfully deleted ${isDir ? 'folder' : 'file'} "${
input.name
}"`,
}
}),
},
}),
{
input: {
project,
selectedDirectory: project,
},
}
)
// Due to the route provider, i've moved this to the FileMachineProvider instead of CommandBarProvider
// This will register the commands to route to Telemetry, Home, and Settings.
useEffect(() => {
@ -422,14 +98,15 @@ export const FileMachineProvider = ({
})
}
// GOTCHA: If we call navigate() while in the /file route the fileMachineProvider
// has a context.project of the original one that was loaded. It does not update
// Watch when the navigation changes, if it changes set a new Project within the fileMachine
// to load the latest state of the project you are in.
if (project) {
// TODO: Clean this up with global application state when fileMachine gets merged into SystemIOMachine
send({ type: 'Refresh with new project', data: { project } })
}
// TODO: KEVIN DELETE
// // GOTCHA: If we call navigate() while in the /file route the fileMachineProvider
// // has a context.project of the original one that was loaded. It does not update
// // Watch when the navigation changes, if it changes set a new Project within the fileMachine
// // to load the latest state of the project you are in.
// if (project) {
// // TODO: Clean this up with global application state when fileMachine gets merged into SystemIOMachine
// send({ type: 'Refresh with new project', data: { project } })
// }
}, [location])
const cb = modelingMenuCallbackMostActions(
@ -480,7 +157,7 @@ export const FileMachineProvider = ({
},
specialPropsForInsertCommand: { providedOptions },
})
}, [codeManager, kclManager, send, project, file])
}, [codeManager, kclManager, project, file])
useEffect(() => {
commandBarActor.send({
@ -496,17 +173,7 @@ export const FileMachineProvider = ({
}
}, [commandBarActor.send, kclCommandMemo])
return (
<FileContext.Provider
value={{
send,
state,
context: state.context, // just a convenience, can remove if we need to save on memory
}}
>
{children}
</FileContext.Provider>
)
return <div>{children}</div>
}
export default FileMachineProvider

View File

@ -1,16 +0,0 @@
.folder {
position: relative;
}
.folder::after {
content: "";
width: 1px;
z-index: -1;
@apply absolute top-0 bottom-0;
left: calc(var(--indent-line-left, 1rem) + 0.25rem);
@apply bg-chalkboard-30;
}
:global(.dark) .folder::after {
@apply bg-chalkboard-80;
}

View File

@ -1,845 +0,0 @@
import { faChevronRight, faPencil } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Disclosure } from '@headlessui/react'
import type { Dispatch } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useNavigate, useRouteLoaderData } from 'react-router-dom'
import { ActionButton } from '@src/components/ActionButton'
import { ContextMenu, ContextMenuItem } from '@src/components/ContextMenu'
import { CustomIcon } from '@src/components/CustomIcon'
import { useLspContext } from '@src/components/LspProvider'
import { DeleteConfirmationDialog } from '@src/components/ProjectCard/DeleteProjectDialog'
import Tooltip from '@src/components/Tooltip'
import { useFileContext } from '@src/hooks/useFileContext'
import { useFileSystemWatcher } from '@src/hooks/useFileSystemWatcher'
import { useModelingContext } from '@src/hooks/useModelingContext'
import usePlatform from '@src/hooks/usePlatform'
import { useKclContext } from '@src/lang/KclProvider'
import type { KCLError } from '@src/lang/errors'
import { kclErrorsByFilename } from '@src/lang/errors'
import { normalizeLineEndings } from '@src/lib/codeEditor'
import { FILE_EXT, INSERT_FOREIGN_TOAST_ID } from '@src/lib/constants'
import { sortFilesAndDirectories } from '@src/lib/desktopFS'
import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
import { PATHS } from '@src/lib/paths'
import type { FileEntry } from '@src/lib/project'
import { codeManager, kclManager } from '@src/lib/singletons'
import { reportRejection } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types'
import { ToastInsert } from '@src/components/ToastInsert'
import { commandBarActor } from '@src/lib/singletons'
import toast from 'react-hot-toast'
import styles from './FileTree.module.css'
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<HTMLInputElement>) => {
if (e.key !== 'Enter') return
props.onSubmit(value)
}
return (
<label>
<span className="sr-only">Entry input</span>
<input
data-testid="tree-input-field"
type="text"
autoFocus
autoCapitalize="off"
autoCorrect="off"
className="w-full py-1 bg-transparent text-chalkboard-100 placeholder:text-chalkboard-70 dark:text-chalkboard-10 dark:placeholder:text-chalkboard-50 focus:outline-none focus:ring-0"
onBlur={() => props.onSubmit(value)}
onChange={(e) => setValue(e.target.value)}
onKeyPress={onKeyPress}
style={{ paddingInlineStart: getIndentationCSS(props.level) }}
value={value}
/>
</label>
)
}
function RenameForm({
fileOrDir,
parentDir,
onSubmit,
level = 0,
}: {
fileOrDir: FileEntry
parentDir: FileEntry | undefined
onSubmit: () => void
level?: number
}) {
const { send } = useFileContext()
const inputRef = useRef<HTMLInputElement>(null)
const fileContext = useFileContext()
function handleRenameSubmit(e: React.KeyboardEvent<HTMLElement>) {
if (e.key !== 'Enter') {
return
}
send({
type: 'Rename file',
data: {
oldName: fileOrDir.name || '',
newName: inputRef.current?.value || fileOrDir.name || '',
isDir: fileOrDir.children !== null,
parentDirectory: parentDir ?? fileContext.context.project,
},
})
// To get out of the renaming state, without this the current file is still in renaming mode
onSubmit()
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Escape') {
e.stopPropagation()
onSubmit()
} else if (e.key === 'Enter') {
// This is needed to prevent events to bubble up and the form to be submitted.
// (Alternatively the form could be changed into a div.)
// Bug without this:
// - open a parent folder (close and open if it's already open)
// - right click -> rename one of its children
// - give new name and press enter
// -> new name is not applied, old name is reverted
e.preventDefault()
e.stopPropagation()
}
}
return (
<form onKeyUp={handleRenameSubmit}>
<label>
<span className="sr-only">Rename file</span>
<input
data-testid="file-rename-field"
ref={inputRef}
type="text"
autoFocus
autoCapitalize="off"
autoCorrect="off"
placeholder={fileOrDir.name}
className="w-full py-1 bg-transparent text-chalkboard-100 placeholder:text-chalkboard-70 dark:text-chalkboard-10 dark:placeholder:text-chalkboard-50 focus:outline-none focus:ring-0"
onKeyDown={handleKeyDown}
onBlur={onSubmit}
style={{ paddingInlineStart: getIndentationCSS(level) }}
/>
</label>
<button className="sr-only" type="submit">
Submit
</button>
</form>
)
}
function DeleteFileTreeItemDialog({
fileOrDir,
setIsOpen,
}: {
fileOrDir: FileEntry
setIsOpen: Dispatch<React.SetStateAction<boolean>>
}) {
const { send } = useFileContext()
return (
<DeleteConfirmationDialog
title={`Delete ${fileOrDir.children !== null ? 'folder' : 'file'}`}
onDismiss={() => setIsOpen(false)}
onConfirm={() => {
send({ type: 'Delete file', data: fileOrDir })
setIsOpen(false)
}}
>
<p className="my-4">
This will permanently delete "{fileOrDir.name || 'this file'}"
{fileOrDir.children !== null ? ' and all of its contents. ' : '. '}
</p>
<p className="my-4">
Are you sure you want to delete "{fileOrDir.name || 'this file'}
"? This action cannot be undone.
</p>
</DeleteConfirmationDialog>
)
}
const FileTreeItem = ({
parentDir,
project,
currentFile,
fileOrDir,
onNavigateToFile,
onClickDirectory,
onCreateFile,
onCreateFolder,
onCloneFileOrFolder,
onOpenInNewWindow,
newTreeEntry,
level = 0,
treeSelection,
setTreeSelection,
runtimeErrors,
}: {
parentDir: FileEntry | undefined
project?: IndexLoaderData['project']
currentFile?: IndexLoaderData['file']
fileOrDir: FileEntry
onNavigateToFile?: () => void
onClickDirectory: (
open: boolean,
path: FileEntry,
parentDir: FileEntry | undefined
) => void
onCreateFile: (name: string) => void
onCreateFolder: (name: string) => void
onCloneFileOrFolder: (path: string) => void
onOpenInNewWindow: (path: string) => void
newTreeEntry: TreeEntry
level?: number
treeSelection: FileEntry | undefined
setTreeSelection: Dispatch<React.SetStateAction<FileEntry | undefined>>
runtimeErrors: Map<string, KCLError[]>
}) => {
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)
const hasRuntimeError = runtimeErrors.has(fileOrDir.path)
// 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) => {
// Prevents a cyclic read / write causing editor problems such as
// misplaced cursor positions.
if (codeManager.writeCausedByAppCheckedInFileTreeFileSystemWatcher) {
codeManager.writeCausedByAppCheckedInFileTreeFileSystemWatcher = false
return
}
// TODO: make this not just name based but sub path instead
const isImportedInCurrentFile = kclManager.ast.body.some(
(n) =>
n.type === 'ImportStatement' &&
((n.path.type === 'Kcl' &&
n.path.filename.includes(fileOrDir.name)) ||
(n.path.type === 'Foreign' && n.path.path.includes(fileOrDir.name)))
)
if (isCurrentFile && eventType === 'change') {
let code = await window.electron.readFile(path, { encoding: 'utf-8' })
code = normalizeLineEndings(code)
codeManager.updateCodeStateEditor(code)
await kclManager.executeCode()
} else if (isImportedInCurrentFile && eventType === 'change') {
await kclManager.executeAst()
}
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<HTMLButtonElement>) {
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') {
void handleClick().catch(reportRejection)
}
}
async function handleClick() {
setTreeSelection(fileOrDir)
if (fileOrDir.children !== null) return // Don't open directories
if (fileOrDir.name?.endsWith(FILE_EXT) === false && project?.path) {
toast.custom(
ToastInsert({
onInsert: () => {
const relativeFilePath = fileOrDir.path.replace(
project.path + window.electron.sep,
''
)
commandBarActor.send({
type: 'Find and select command',
data: {
name: 'Insert',
groupId: 'code',
argDefaultValues: { path: relativeFilePath },
},
})
toast.dismiss(INSERT_FOREIGN_TOAST_ID)
},
}),
{ duration: 30000, id: INSERT_FOREIGN_TOAST_ID }
)
} else {
// Let the lsp servers know we closed a file.
onFileClose(currentFile?.path || null, project?.path || null)
onFileOpen(fileOrDir.path, project?.path || null)
kclManager.switchedFiles = true
// 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 (
<div className="contents" data-testid="file-tree-item" ref={itemRef}>
{fileOrDir.children === null ? (
<li
className={
'group m-0 p-0 border-solid border-0 hover:bg-primary/5 focus-within:bg-primary/5 dark:hover:bg-primary/20 dark:focus-within:bg-primary/20 ' +
(isFileOrDirHighlighted || isCurrentFile
? '!bg-primary/10 !text-primary dark:!bg-primary/20 dark:!text-inherit'
: '')
}
>
{!isRenaming ? (
<button
className="relative flex gap-1 items-center py-0.5 rounded-none border-none p-0 m-0 text-sm w-full hover:!bg-transparent text-left !text-inherit"
style={{ paddingInlineStart: getIndentationCSS(level) }}
onClick={(e) => {
e.currentTarget.focus()
void handleClick().catch(reportRejection)
}}
onKeyUp={handleKeyUp}
>
{hasRuntimeError && (
<p
className={
'absolute m-0 p-0 bottom-3 left-6 w-3 h-3 flex items-center justify-center text-[9px] font-semibold text-white bg-primary hue-rotate-90 rounded-full border border-chalkboard-10 dark:border-chalkboard-80 z-50 hover:cursor-pointer hover:scale-[2] transition-transform duration-200'
}
title={`Click to view notifications`}
>
<span>x</span>
</p>
)}
<CustomIcon
name={fileOrDir.name?.endsWith(FILE_EXT) ? 'kcl' : 'file'}
className="inline-block w-3 text-current"
/>
<span className="pl-1">{fileOrDir.name}</span>
</button>
) : (
<RenameForm
fileOrDir={fileOrDir}
parentDir={parentDir}
onSubmit={removeCurrentItemFromRenaming}
level={level}
/>
)}
</li>
) : (
<Disclosure defaultOpen={currentFile?.path.includes(fileOrDir.path)}>
{({ open }) => (
<div className="group">
{!isRenaming ? (
<Disclosure.Button
className={
' group border-none text-sm rounded-none p-0 m-0 flex items-center justify-start w-full py-0.5 hover:text-primary hover:bg-primary/5 dark:hover:text-inherit dark:hover:bg-primary/10' +
(isFileOrDirHighlighted ? ' ui-open:bg-primary/10' : '')
}
style={{ paddingInlineStart: getIndentationCSS(level) }}
onClick={(e) => {
e.stopPropagation()
onClickDirectory(open, fileOrDir, parentDir)
}}
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
onKeyUp={handleKeyUp}
>
<FontAwesomeIcon
icon={faChevronRight}
className={
'inline-block mr-2 m-0 p-0 w-2 h-2 ' +
(open ? 'transform rotate-90' : '')
}
/>
{fileOrDir.name}
</Disclosure.Button>
) : (
<div
className="flex items-center"
style={{ paddingInlineStart: getIndentationCSS(level) }}
>
<FontAwesomeIcon
icon={faChevronRight}
className={
'inline-block mr-2 m-0 p-0 w-2 h-2 ' +
(open ? 'transform rotate-90' : '')
}
/>
<RenameForm
fileOrDir={fileOrDir}
parentDir={parentDir}
onSubmit={removeCurrentItemFromRenaming}
level={-1}
/>
</div>
)}
<Disclosure.Panel
className={styles.folder}
style={
{
'--indent-line-left': getIndentationCSS(level),
} as React.CSSProperties
}
>
<ul
className="m-0 p-0"
onClick={(e) => {
e.stopPropagation()
onClickDirectory(open, fileOrDir, parentDir)
}}
>
{showNewTreeEntry && (
<div
className="flex items-center"
style={{
paddingInlineStart: getIndentationCSS(level + 1),
}}
>
<FontAwesomeIcon
icon={faPencil}
className="inline-block mr-2 m-0 p-0 w-2 h-2"
/>
<TreeEntryInput
level={-1}
onSubmit={(value: string) =>
newTreeEntry === 'file'
? onCreateFile(value)
: onCreateFolder(value)
}
/>
</div>
)}
{sortFilesAndDirectories(fileOrDir.children || []).map(
(child) => (
<FileTreeItem
parentDir={fileOrDir}
fileOrDir={child}
project={project}
currentFile={currentFile}
onCreateFile={onCreateFile}
onCreateFolder={onCreateFolder}
onCloneFileOrFolder={onCloneFileOrFolder}
onOpenInNewWindow={onOpenInNewWindow}
newTreeEntry={newTreeEntry}
onClickDirectory={onClickDirectory}
onNavigateToFile={onNavigateToFile}
level={level + 1}
key={level + '-' + child.path}
treeSelection={treeSelection}
setTreeSelection={setTreeSelection}
runtimeErrors={runtimeErrors}
/>
)
)}
{!showNewTreeEntry && fileOrDir.children?.length === 0 && (
<div
className="flex items-center text-chalkboard-50"
style={{
paddingInlineStart: getIndentationCSS(level + 1),
}}
>
<div>No files</div>
</div>
)}
</ul>
</Disclosure.Panel>
</div>
)}
</Disclosure>
)}
{isConfirmingDelete && (
<DeleteFileTreeItemDialog
fileOrDir={fileOrDir}
setIsOpen={setIsConfirmingDelete}
/>
)}
<FileTreeContextMenu
itemRef={itemRef}
onRename={addCurrentItemToRenaming}
onDelete={() => setIsConfirmingDelete(true)}
onClone={() => onCloneFileOrFolder(fileOrDir.path)}
onOpenInNewWindow={() => onOpenInNewWindow(fileOrDir.path)}
/>
</div>
)
}
interface FileTreeContextMenuProps {
itemRef: React.RefObject<HTMLElement>
onRename: () => void
onDelete: () => void
onClone: () => void
onOpenInNewWindow: () => void
}
function FileTreeContextMenu({
itemRef,
onRename,
onDelete,
onClone,
onOpenInNewWindow,
}: FileTreeContextMenuProps) {
const platform = usePlatform()
const metaKey = platform === 'macos' ? '⌘' : 'Ctrl'
return (
<ContextMenu
menuTargetElement={itemRef}
items={[
<ContextMenuItem
data-testid="context-menu-rename"
onClick={onRename}
hotkey="Enter"
>
Rename
</ContextMenuItem>,
<ContextMenuItem
data-testid="context-menu-delete"
onClick={onDelete}
hotkey={metaKey + ' + Del'}
>
Delete
</ContextMenuItem>,
<ContextMenuItem
data-testid="context-menu-clone"
onClick={onClone}
hotkey=""
>
Clone
</ContextMenuItem>,
<ContextMenuItem
data-testid="context-menu-open-in-new-window"
onClick={onOpenInNewWindow}
>
Open in new window
</ContextMenuItem>,
]}
/>
)
}
export const FileTreeMenu = ({
onCreateFile,
onCreateFolder,
}: {
onCreateFile: () => void
onCreateFolder: () => void
}) => {
useHotkeyWrapper(['mod + n'], onCreateFile)
useHotkeyWrapper(['mod + shift + n'], onCreateFolder)
return (
<>
<ActionButton
Element="button"
data-testid="create-file-button"
iconStart={{
icon: 'filePlus',
iconClassName: '!text-current',
bgClassName: 'bg-transparent',
}}
className="!p-0 !bg-transparent hover:text-primary border-transparent hover:border-primary !outline-none"
onClick={onCreateFile}
>
<Tooltip position="bottom-right">Create file</Tooltip>
</ActionButton>
<ActionButton
Element="button"
data-testid="create-folder-button"
iconStart={{
icon: 'folderPlus',
iconClassName: '!text-current',
bgClassName: 'bg-transparent',
}}
className="!p-0 !bg-transparent hover:text-primary border-transparent hover:border-primary !outline-none"
onClick={onCreateFolder}
>
<Tooltip position="bottom-right">Create folder</Tooltip>
</ActionButton>
</>
)
}
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<TreeEntry>(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,
silent: true,
shouldSetToRename: false,
},
})
}
function cloneFileOrDir(args: { path: string }) {
send({
type: 'Create file',
data: {
name: '',
makeDir: false,
shouldSetToRename: false,
targetPathToClone: args.path,
},
})
}
function openInNewWindow(args: { path: string }) {
send({
type: 'Open file in new window',
data: {
name: args.path,
},
})
}
return {
createFile,
createFolder,
cloneFileOrDir,
newTreeEntry,
openInNewWindow,
}
}
export const FileTreeInner = ({
onNavigateToFile,
onCreateFile,
onCreateFolder,
onCloneFileOrFolder,
onOpenInNewWindow,
newTreeEntry,
}: {
onCreateFile: (name: string) => void
onCreateFolder: (name: string) => void
onCloneFileOrFolder: (path: string) => void
onOpenInNewWindow: (path: string) => void
newTreeEntry: TreeEntry
onNavigateToFile?: () => void
}) => {
const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const { send: fileSend, context: fileContext } = useFileContext()
const { send: modelingSend } = useModelingContext()
const { errors } = useKclContext()
const runtimeErrors = kclErrorsByFilename(errors)
const [treeSelection, setTreeSelection] = useState<FileEntry | undefined>(
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
// If it's a settings file we wrote to already from the app ignore it.
if (codeManager.writeCausedByAppCheckedInFileTreeFileSystemWatcher) {
codeManager.writeCausedByAppCheckedInFileTreeFileSystemWatcher = false
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)
fileSend({
type: 'Set selected directory',
directory: target,
})
}
const showNewTreeEntry =
newTreeEntry !== undefined &&
fileContext.selectedDirectory.path === loaderData.project?.path
return (
<div className="relative">
<div
className="overflow-auto pb-12 absolute inset-0"
data-testid="file-pane-scroll-container"
>
<ul className="m-0 p-0 text-sm">
{showNewTreeEntry && (
<div
className="flex items-center"
style={{ paddingInlineStart: getIndentationCSS(0) }}
>
<FontAwesomeIcon
icon={faPencil}
className="inline-block mr-2 m-0 p-0 w-2 h-2"
/>
<TreeEntryInput level={-1} onSubmit={onTreeEntryInputSubmit} />
</div>
)}
{sortFilesAndDirectories(fileContext.project?.children || []).map(
(fileOrDir) => (
<FileTreeItem
parentDir={fileContext.project}
project={fileContext.project}
currentFile={loaderData?.file}
fileOrDir={fileOrDir}
onCreateFile={onCreateFile}
onCreateFolder={onCreateFolder}
onCloneFileOrFolder={onCloneFileOrFolder}
onOpenInNewWindow={onOpenInNewWindow}
newTreeEntry={newTreeEntry}
onClickDirectory={onClickDirectory}
onNavigateToFile={onNavigateToFile_}
key={fileOrDir.path}
treeSelection={treeSelection}
setTreeSelection={setTreeSelection}
runtimeErrors={runtimeErrors}
/>
)
)}
</ul>
</div>
</div>
)
}
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 (
<div
className="max-w-xs text-ellipsis overflow-hidden cursor-pointer"
title={project?.path ?? ''}
>
{project?.name ?? ''}
</div>
)
}

View File

@ -25,7 +25,6 @@ import {
SEGMENT_BODIES_PLUS_PROFILE_START,
getParentGroup,
} from '@src/clientSideScene/sceneConstants'
import { useFileContext } from '@src/hooks/useFileContext'
import {
useMenuListener,
useSketchModeMenuEnableDisable,
@ -65,7 +64,7 @@ import {
import { exportMake } from '@src/lib/exportMake'
import { exportSave } from '@src/lib/exportSave'
import { isDesktop } from '@src/lib/isDesktop'
import type { FileEntry } from '@src/lib/project'
import type { FileEntry, Project } from '@src/lib/project'
import type { WebContentSendPayload } from '@src/menu/channels'
import {
getPersistedContext,
@ -105,6 +104,7 @@ import { applyConstraintAbsDistance } from '@src/components/Toolbar/SetAbsDistan
import type { SidebarType } from '@src/components/ModelingSidebar/ModelingPanes'
import { useNetworkContext } from '@src/hooks/useNetworkContext'
import { resetCameraPosition } from '@src/lib/resetCameraPosition'
import { useFolders } from '@src/machines/systemIO/hooks'
export const ModelingMachineContext = createContext(
{} as {
@ -127,8 +127,28 @@ export const ModelingMachineProvider = ({
app: { allowOrbitInSketchMode },
modeling: { defaultUnit, cameraProjection, cameraOrbit },
} = useSettings()
const { context } = useFileContext()
const { file } = useLoaderData() as IndexLoaderData
const loaderData = useLoaderData() as IndexLoaderData
const projects = useFolders()
const { project, file } = loaderData
const theProject = useRef<Project | undefined>(project)
useEffect(() => {
// Have no idea why the project loader data doesn't have the children from the ls on disk
// That means it is a different object or cached incorrectly?
if (!project || !file) {
return
}
// You need to find the real project in the storage from the loader information since the loader Project is not hydrated
const foundYourProject = projects.find((p) => {
return p.name === project.name
})
if (!foundYourProject) {
return
}
theProject.current = foundYourProject
}, [projects, loaderData, file])
const token = useToken()
const streamRef = useRef<HTMLDivElement>(null)
const persistedContext = useMemo(() => getPersistedContext(), [])
@ -1176,9 +1196,9 @@ export const ModelingMachineProvider = ({
}
)
let basePath = ''
if (isDesktop() && context?.project?.children) {
if (isDesktop() && theProject?.current?.children) {
// Use the entire project directory as the basePath for prompt to edit, do not use relative subdir paths
basePath = context?.project?.path
basePath = theProject?.current?.path
const filePromises: Promise<FileMeta | null>[] = []
let uploadSize = 0
const recursivelyPushFilePromises = (files: FileEntry[]) => {
@ -1234,7 +1254,7 @@ export const ModelingMachineProvider = ({
filePromises.push(filePromise)
}
}
recursivelyPushFilePromises(context?.project?.children)
recursivelyPushFilePromises(theProject?.current?.children)
projectFiles = (await Promise.all(filePromises)).filter(
isNonNullable
)
@ -1258,7 +1278,7 @@ export const ModelingMachineProvider = ({
selections: input.selection,
token,
artifactGraph: kclManager.artifactGraph,
projectName: context.project.name,
projectName: theProject?.current?.name || '',
filePath,
})
}),

View File

@ -1,7 +0,0 @@
import { useContext } from 'react'
import { FileContext } from '@src/components/FileMachineProvider'
export const useFileContext = () => {
return useContext(FileContext)
}