chore: deleted the old file tree:
This commit is contained in:
@ -101,7 +101,7 @@ const flattenProjectHelper = (
|
|||||||
name: f.name,
|
name: f.name,
|
||||||
}),
|
}),
|
||||||
setSize,
|
setSize,
|
||||||
positionInSet
|
positionInSet,
|
||||||
}
|
}
|
||||||
// keep track of the file once within the recursive list that will be built up
|
// keep track of the file once within the recursive list that will be built up
|
||||||
list.push(content)
|
list.push(content)
|
||||||
@ -120,7 +120,7 @@ const flattenProjectHelper = (
|
|||||||
constructPath({ parentPath: parentPath, name: f.name }),
|
constructPath({ parentPath: parentPath, name: f.name }),
|
||||||
level + 1,
|
level + 1,
|
||||||
f.children.length,
|
f.children.length,
|
||||||
i+1
|
i + 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -145,7 +145,7 @@ export const flattenProject = (
|
|||||||
projectName, // first parent
|
projectName, // first parent
|
||||||
0,
|
0,
|
||||||
projectChildren.length,
|
projectChildren.length,
|
||||||
index+1
|
index + 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return flattenTreeInOrder
|
return flattenTreeInOrder
|
||||||
|
@ -1,51 +1,21 @@
|
|||||||
import { useMachine } from '@xstate/react'
|
import React, { useEffect, useMemo } from 'react'
|
||||||
import React, { createContext, useEffect, useMemo } from 'react'
|
|
||||||
import { toast } from 'react-hot-toast'
|
|
||||||
import { useLocation, useNavigate, useRouteLoaderData } from 'react-router-dom'
|
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 { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
|
||||||
import { useMenuListener } from '@src/hooks/useMenu'
|
import { useMenuListener } from '@src/hooks/useMenu'
|
||||||
import { newKclFile } from '@src/lang/project'
|
|
||||||
import { createNamedViewsCommand } from '@src/lib/commandBarConfigs/namedViewsConfig'
|
import { createNamedViewsCommand } from '@src/lib/commandBarConfigs/namedViewsConfig'
|
||||||
import { createRouteCommands } from '@src/lib/commandBarConfigs/routeCommandConfig'
|
import { createRouteCommands } from '@src/lib/commandBarConfigs/routeCommandConfig'
|
||||||
import {
|
import { DEFAULT_DEFAULT_LENGTH_UNIT } from '@src/lib/constants'
|
||||||
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 { isDesktop } from '@src/lib/isDesktop'
|
import { isDesktop } from '@src/lib/isDesktop'
|
||||||
import { kclCommands } from '@src/lib/kclCommands'
|
import { kclCommands } from '@src/lib/kclCommands'
|
||||||
import { BROWSER_PATH, PATHS } from '@src/lib/paths'
|
import { BROWSER_PATH, PATHS } from '@src/lib/paths'
|
||||||
import { markOnce } from '@src/lib/performance'
|
import { markOnce } from '@src/lib/performance'
|
||||||
import { codeManager, kclManager } from '@src/lib/singletons'
|
import { codeManager, kclManager } from '@src/lib/singletons'
|
||||||
import { err } from '@src/lib/trap'
|
|
||||||
import { type IndexLoaderData } from '@src/lib/types'
|
import { type IndexLoaderData } from '@src/lib/types'
|
||||||
import { useSettings, useToken } from '@src/lib/singletons'
|
import { useSettings, useToken } from '@src/lib/singletons'
|
||||||
import { commandBarActor } from '@src/lib/singletons'
|
import { commandBarActor } from '@src/lib/singletons'
|
||||||
import { fileMachine } from '@src/machines/fileMachine'
|
|
||||||
import { modelingMenuCallbackMostActions } from '@src/menu/register'
|
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 = ({
|
export const FileMachineProvider = ({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@ -93,300 +63,6 @@ export const FileMachineProvider = ({
|
|||||||
markOnce('code/didLoadFile')
|
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
|
// 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.
|
// This will register the commands to route to Telemetry, Home, and Settings.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -422,14 +98,15 @@ export const FileMachineProvider = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GOTCHA: If we call navigate() while in the /file route the fileMachineProvider
|
// TODO: KEVIN DELETE
|
||||||
// has a context.project of the original one that was loaded. It does not update
|
// // GOTCHA: If we call navigate() while in the /file route the fileMachineProvider
|
||||||
// Watch when the navigation changes, if it changes set a new Project within the fileMachine
|
// // has a context.project of the original one that was loaded. It does not update
|
||||||
// to load the latest state of the project you are in.
|
// // Watch when the navigation changes, if it changes set a new Project within the fileMachine
|
||||||
if (project) {
|
// // to load the latest state of the project you are in.
|
||||||
// TODO: Clean this up with global application state when fileMachine gets merged into SystemIOMachine
|
// if (project) {
|
||||||
send({ type: 'Refresh with new project', data: { project } })
|
// // TODO: Clean this up with global application state when fileMachine gets merged into SystemIOMachine
|
||||||
}
|
// send({ type: 'Refresh with new project', data: { project } })
|
||||||
|
// }
|
||||||
}, [location])
|
}, [location])
|
||||||
|
|
||||||
const cb = modelingMenuCallbackMostActions(
|
const cb = modelingMenuCallbackMostActions(
|
||||||
@ -480,7 +157,7 @@ export const FileMachineProvider = ({
|
|||||||
},
|
},
|
||||||
specialPropsForInsertCommand: { providedOptions },
|
specialPropsForInsertCommand: { providedOptions },
|
||||||
})
|
})
|
||||||
}, [codeManager, kclManager, send, project, file])
|
}, [codeManager, kclManager, project, file])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
commandBarActor.send({
|
commandBarActor.send({
|
||||||
@ -496,17 +173,7 @@ export const FileMachineProvider = ({
|
|||||||
}
|
}
|
||||||
}, [commandBarActor.send, kclCommandMemo])
|
}, [commandBarActor.send, kclCommandMemo])
|
||||||
|
|
||||||
return (
|
return <div>{children}</div>
|
||||||
<FileContext.Provider
|
|
||||||
value={{
|
|
||||||
send,
|
|
||||||
state,
|
|
||||||
context: state.context, // just a convenience, can remove if we need to save on memory
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</FileContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FileMachineProvider
|
export default FileMachineProvider
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
@ -25,7 +25,6 @@ import {
|
|||||||
SEGMENT_BODIES_PLUS_PROFILE_START,
|
SEGMENT_BODIES_PLUS_PROFILE_START,
|
||||||
getParentGroup,
|
getParentGroup,
|
||||||
} from '@src/clientSideScene/sceneConstants'
|
} from '@src/clientSideScene/sceneConstants'
|
||||||
import { useFileContext } from '@src/hooks/useFileContext'
|
|
||||||
import {
|
import {
|
||||||
useMenuListener,
|
useMenuListener,
|
||||||
useSketchModeMenuEnableDisable,
|
useSketchModeMenuEnableDisable,
|
||||||
@ -65,7 +64,7 @@ import {
|
|||||||
import { exportMake } from '@src/lib/exportMake'
|
import { exportMake } from '@src/lib/exportMake'
|
||||||
import { exportSave } from '@src/lib/exportSave'
|
import { exportSave } from '@src/lib/exportSave'
|
||||||
import { isDesktop } from '@src/lib/isDesktop'
|
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 type { WebContentSendPayload } from '@src/menu/channels'
|
||||||
import {
|
import {
|
||||||
getPersistedContext,
|
getPersistedContext,
|
||||||
@ -105,6 +104,7 @@ import { applyConstraintAbsDistance } from '@src/components/Toolbar/SetAbsDistan
|
|||||||
import type { SidebarType } from '@src/components/ModelingSidebar/ModelingPanes'
|
import type { SidebarType } from '@src/components/ModelingSidebar/ModelingPanes'
|
||||||
import { useNetworkContext } from '@src/hooks/useNetworkContext'
|
import { useNetworkContext } from '@src/hooks/useNetworkContext'
|
||||||
import { resetCameraPosition } from '@src/lib/resetCameraPosition'
|
import { resetCameraPosition } from '@src/lib/resetCameraPosition'
|
||||||
|
import { useFolders } from '@src/machines/systemIO/hooks'
|
||||||
|
|
||||||
export const ModelingMachineContext = createContext(
|
export const ModelingMachineContext = createContext(
|
||||||
{} as {
|
{} as {
|
||||||
@ -127,8 +127,28 @@ export const ModelingMachineProvider = ({
|
|||||||
app: { allowOrbitInSketchMode },
|
app: { allowOrbitInSketchMode },
|
||||||
modeling: { defaultUnit, cameraProjection, cameraOrbit },
|
modeling: { defaultUnit, cameraProjection, cameraOrbit },
|
||||||
} = useSettings()
|
} = useSettings()
|
||||||
const { context } = useFileContext()
|
const loaderData = useLoaderData() as IndexLoaderData
|
||||||
const { file } = 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 token = useToken()
|
||||||
const streamRef = useRef<HTMLDivElement>(null)
|
const streamRef = useRef<HTMLDivElement>(null)
|
||||||
const persistedContext = useMemo(() => getPersistedContext(), [])
|
const persistedContext = useMemo(() => getPersistedContext(), [])
|
||||||
@ -1176,9 +1196,9 @@ export const ModelingMachineProvider = ({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
let basePath = ''
|
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
|
// 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>[] = []
|
const filePromises: Promise<FileMeta | null>[] = []
|
||||||
let uploadSize = 0
|
let uploadSize = 0
|
||||||
const recursivelyPushFilePromises = (files: FileEntry[]) => {
|
const recursivelyPushFilePromises = (files: FileEntry[]) => {
|
||||||
@ -1234,7 +1254,7 @@ export const ModelingMachineProvider = ({
|
|||||||
filePromises.push(filePromise)
|
filePromises.push(filePromise)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
recursivelyPushFilePromises(context?.project?.children)
|
recursivelyPushFilePromises(theProject?.current?.children)
|
||||||
projectFiles = (await Promise.all(filePromises)).filter(
|
projectFiles = (await Promise.all(filePromises)).filter(
|
||||||
isNonNullable
|
isNonNullable
|
||||||
)
|
)
|
||||||
@ -1258,7 +1278,7 @@ export const ModelingMachineProvider = ({
|
|||||||
selections: input.selection,
|
selections: input.selection,
|
||||||
token,
|
token,
|
||||||
artifactGraph: kclManager.artifactGraph,
|
artifactGraph: kclManager.artifactGraph,
|
||||||
projectName: context.project.name,
|
projectName: theProject?.current?.name || '',
|
||||||
filePath,
|
filePath,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
@ -1,7 +0,0 @@
|
|||||||
import { useContext } from 'react'
|
|
||||||
|
|
||||||
import { FileContext } from '@src/components/FileMachineProvider'
|
|
||||||
|
|
||||||
export const useFileContext = () => {
|
|
||||||
return useContext(FileContext)
|
|
||||||
}
|
|
Reference in New Issue
Block a user