File tree to act more like VS Code's file tree (#4392)

* File tree acts as VS Code's file tree

* Adjust test for new expectations

* Remove screenshot

* Actually remove this screenshot

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
49fl
2024-11-06 14:32:06 -05:00
committed by GitHub
parent efd1f288b9
commit 4a4400e979
50 changed files with 473 additions and 229 deletions

View File

@ -26,10 +26,6 @@ test.describe('integrations tests', () => {
'Creating a new file or switching file while in sketchMode should exit sketchMode', 'Creating a new file or switching file while in sketchMode should exit sketchMode',
{ tag: '@electron' }, { tag: '@electron' },
async ({ tronApp, homePage, scene, editor, toolbar }) => { async ({ tronApp, homePage, scene, editor, toolbar }) => {
test.skip(
process.platform === 'win32',
'windows times out will waiting for the execution indicator?'
)
await tronApp.initialise({ await tronApp.initialise({
fixtures: { homePage, scene, editor, toolbar }, fixtures: { homePage, scene, editor, toolbar },
folderSetupFn: async (dir) => { folderSetupFn: async (dir) => {
@ -55,7 +51,6 @@ test.describe('integrations tests', () => {
sortBy: 'last-modified-desc', sortBy: 'last-modified-desc',
}) })
await homePage.openProject('test-sample') await homePage.openProject('test-sample')
// windows times out here, hence the skip above
await scene.waitForExecutionDone() await scene.waitForExecutionDone()
}) })
await test.step('enter sketch mode', async () => { await test.step('enter sketch mode', async () => {
@ -71,10 +66,13 @@ test.describe('integrations tests', () => {
await toolbar.editSketch() await toolbar.editSketch()
await expect(toolbar.exitSketchBtn).toBeVisible() await expect(toolbar.exitSketchBtn).toBeVisible()
}) })
const fileName = 'Untitled.kcl'
await test.step('check sketch mode is exited when creating new file', async () => { await test.step('check sketch mode is exited when creating new file', async () => {
await toolbar.fileTreeBtn.click() await toolbar.fileTreeBtn.click()
await toolbar.expectFileTreeState(['main.kcl']) await toolbar.expectFileTreeState(['main.kcl'])
await toolbar.createFile({ wait: true })
await toolbar.createFile({ fileName, waitForToastToDisappear: true })
// check we're out of sketch mode // check we're out of sketch mode
await expect(toolbar.exitSketchBtn).not.toBeVisible() await expect(toolbar.exitSketchBtn).not.toBeVisible()
@ -93,10 +91,10 @@ test.describe('integrations tests', () => {
}) })
await toolbar.editSketch() await toolbar.editSketch()
await expect(toolbar.exitSketchBtn).toBeVisible() await expect(toolbar.exitSketchBtn).toBeVisible()
await toolbar.expectFileTreeState(['main.kcl', 'Untitled.kcl']) await toolbar.expectFileTreeState(['main.kcl', fileName])
}) })
await test.step('check sketch mode is exited when opening a different file', async () => { await test.step('check sketch mode is exited when opening a different file', async () => {
await toolbar.openFile('untitled.kcl', { wait: false }) await toolbar.openFile(fileName, { wait: false })
// check we're out of sketch mode // check we're out of sketch mode
await expect(toolbar.exitSketchBtn).not.toBeVisible() await expect(toolbar.exitSketchBtn).not.toBeVisible()

View File

@ -195,7 +195,7 @@ export class SceneFixture {
} }
waitForExecutionDone = async () => { waitForExecutionDone = async () => {
await expect(this.exeIndicator).toBeVisible() await expect(this.exeIndicator).toBeVisible({ timeout: 30000 })
} }
expectPixelColor = async ( expectPixelColor = async (

View File

@ -16,6 +16,7 @@ export class ToolbarFixture {
fileCreateToast!: Locator fileCreateToast!: Locator
filePane!: Locator filePane!: Locator
exeIndicator!: Locator exeIndicator!: Locator
treeInputField!: Locator
constructor(page: Page) { constructor(page: Page) {
this.page = page this.page = page
@ -31,6 +32,7 @@ export class ToolbarFixture {
this.editSketchBtn = page.getByText('Edit Sketch') this.editSketchBtn = page.getByText('Edit Sketch')
this.fileTreeBtn = page.locator('[id="files-button-holder"]') this.fileTreeBtn = page.locator('[id="files-button-holder"]')
this.createFileBtn = page.getByTestId('create-file-button') this.createFileBtn = page.getByTestId('create-file-button')
this.treeInputField = page.getByTestId('tree-input-field')
this.filePane = page.locator('#files-pane') this.filePane = page.locator('#files-pane')
this.fileCreateToast = page.getByText('Successfully created') this.fileCreateToast = page.getByText('Successfully created')
@ -59,10 +61,15 @@ export class ToolbarFixture {
expectFileTreeState = async (expected: string[]) => { expectFileTreeState = async (expected: string[]) => {
await expect.poll(this._serialiseFileTree).toEqual(expected) await expect.poll(this._serialiseFileTree).toEqual(expected)
} }
createFile = async ({ wait }: { wait: boolean } = { wait: false }) => { createFile = async (args: {
fileName: string
waitForToastToDisappear: boolean
}) => {
await this.createFileBtn.click() await this.createFileBtn.click()
await this.treeInputField.fill(args.fileName)
await this.treeInputField.press('Enter')
await expect(this.fileCreateToast).toBeVisible() await expect(this.fileCreateToast).toBeVisible()
if (wait) { if (args.waitForToastToDisappear) {
await this.fileCreateToast.waitFor({ state: 'detached' }) await this.fileCreateToast.waitFor({ state: 'detached' })
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -6,10 +6,10 @@ import { Dispatch, useCallback, useRef, useState } from 'react'
import { useNavigate, useRouteLoaderData } from 'react-router-dom' import { useNavigate, useRouteLoaderData } from 'react-router-dom'
import { Disclosure } from '@headlessui/react' import { Disclosure } from '@headlessui/react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight } from '@fortawesome/free-solid-svg-icons' import { faChevronRight, faPencil } from '@fortawesome/free-solid-svg-icons'
import { useFileContext } from 'hooks/useFileContext' import { useFileContext } from 'hooks/useFileContext'
import styles from './FileTree.module.css' import styles from './FileTree.module.css'
import { sortProject } from 'lib/desktopFS' import { sortFilesAndDirectories } from 'lib/desktopFS'
import { FILE_EXT } from 'lib/constants' import { FILE_EXT } from 'lib/constants'
import { CustomIcon } from './CustomIcon' import { CustomIcon } from './CustomIcon'
import { codeManager, kclManager } from 'lib/singletons' import { codeManager, kclManager } from 'lib/singletons'
@ -27,6 +27,36 @@ function getIndentationCSS(level: number) {
return `calc(1rem * ${level + 1})` 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({ function RenameForm({
fileOrDir, fileOrDir,
onSubmit, onSubmit,
@ -113,16 +143,32 @@ function DeleteFileTreeItemDialog({
} }
const FileTreeItem = ({ const FileTreeItem = ({
parentDir,
project, project,
currentFile, currentFile,
lastDirectoryClicked,
fileOrDir, fileOrDir,
onNavigateToFile, onNavigateToFile,
onClickDirectory,
onCreateFile,
onCreateFolder,
newTreeEntry,
level = 0, level = 0,
}: { }: {
parentDir: FileEntry | undefined
project?: IndexLoaderData['project'] project?: IndexLoaderData['project']
currentFile?: IndexLoaderData['file'] currentFile?: IndexLoaderData['file']
lastDirectoryClicked?: FileEntry
fileOrDir: FileEntry fileOrDir: FileEntry
onNavigateToFile?: () => void onNavigateToFile?: () => void
onClickDirectory: (
open: boolean,
path: FileEntry,
parentDir: FileEntry | undefined
) => void
onCreateFile: (name: string) => void
onCreateFolder: (name: string) => void
newTreeEntry: TreeEntry
level?: number level?: number
}) => { }) => {
const { send: fileSend, context: fileContext } = useFileContext() const { send: fileSend, context: fileContext } = useFileContext()
@ -156,6 +202,10 @@ const FileTreeItem = ({
[fileOrDir.path] [fileOrDir.path]
) )
const showNewTreeEntry =
newTreeEntry !== undefined &&
fileOrDir.path === fileContext.selectedDirectory.path
const isRenaming = fileContext.itemsBeingRenamed.includes(fileOrDir.path) const isRenaming = fileContext.itemsBeingRenamed.includes(fileOrDir.path)
const removeCurrentItemFromRenaming = useCallback( const removeCurrentItemFromRenaming = useCallback(
() => () =>
@ -179,13 +229,6 @@ const FileTreeItem = ({
}) })
}, [fileContext.itemsBeingRenamed, fileOrDir.path, fileSend]) }, [fileContext.itemsBeingRenamed, fileOrDir.path, fileSend])
const clickDirectory = () => {
fileSend({
type: 'Set selected directory',
directory: fileOrDir,
})
}
function handleKeyUp(e: React.KeyboardEvent<HTMLButtonElement>) { function handleKeyUp(e: React.KeyboardEvent<HTMLButtonElement>) {
if (e.metaKey && e.key === 'Backspace') { if (e.metaKey && e.key === 'Backspace') {
// Open confirmation dialog // Open confirmation dialog
@ -223,6 +266,8 @@ const FileTreeItem = ({
onNavigateToFile?.() onNavigateToFile?.()
} }
// The below handles both the "root" of all directories and all subs. It's
// why some code is duplicated.
return ( return (
<div className="contents" data-testid="file-tree-item" ref={itemRef}> <div className="contents" data-testid="file-tree-item" ref={itemRef}>
{fileOrDir.children === null ? ( {fileOrDir.children === null ? (
@ -266,14 +311,15 @@ const FileTreeItem = ({
<Disclosure.Button <Disclosure.Button
className={ 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' + ' 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' +
(fileContext.selectedDirectory.path.includes(fileOrDir.path) (lastDirectoryClicked?.path === fileOrDir.path
? ' ui-open:bg-primary/10' ? ' ui-open:bg-primary/10'
: '') : '')
} }
style={{ paddingInlineStart: getIndentationCSS(level) }} style={{ paddingInlineStart: getIndentationCSS(level) }}
onClick={(e) => e.currentTarget.focus()} onClick={(e) => {
onClickCapture={clickDirectory} e.stopPropagation()
onFocusCapture={clickDirectory} onClickDirectory(open, fileOrDir, parentDir)
}}
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()} onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
> >
@ -315,35 +361,67 @@ const FileTreeItem = ({
> >
<ul <ul
className="m-0 p-0" className="m-0 p-0"
onClickCapture={(e) => { onClick={(e) => {
fileSend({ e.stopPropagation()
type: 'Set selected directory', onClickDirectory(open, fileOrDir, parentDir)
directory: fileOrDir,
})
}} }}
onFocusCapture={(e) =>
fileSend({
type: 'Set selected directory',
directory: fileOrDir,
})
}
> >
{fileOrDir.children?.map((child) => ( {showNewTreeEntry && (
<FileTreeItem <div
fileOrDir={child} className="flex items-center"
project={project} style={{
currentFile={currentFile} paddingInlineStart: getIndentationCSS(level + 1),
onNavigateToFile={onNavigateToFile} }}
level={level + 1} >
key={level + '-' + child.path} <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}
newTreeEntry={newTreeEntry}
lastDirectoryClicked={lastDirectoryClicked}
onClickDirectory={onClickDirectory}
onNavigateToFile={onNavigateToFile}
level={level + 1}
key={level + '-' + child.path}
/>
)
)}
{!showNewTreeEntry && fileOrDir.children?.length === 0 && (
<div
className="flex items-center text-chalkboard-50"
style={{
paddingInlineStart: getIndentationCSS(level + 1),
}}
>
<div>No files</div>
</div>
)}
</ul> </ul>
</Disclosure.Panel> </Disclosure.Panel>
</div> </div>
)} )}
</Disclosure> </Disclosure>
)} )}
{isConfirmingDelete && ( {isConfirmingDelete && (
<DeleteFileTreeItemDialog <DeleteFileTreeItemDialog
fileOrDir={fileOrDir} fileOrDir={fileOrDir}
@ -407,27 +485,15 @@ interface FileTreeProps {
) => void ) => void
} }
export const FileTreeMenu = () => { export const FileTreeMenu = ({
const { send } = useFileContext() onCreateFile,
const { send: modelingSend } = useModelingContext() onCreateFolder,
}: {
function createFile() { onCreateFile: () => void
send({ onCreateFolder: () => void
type: 'Create file', }) => {
data: { name: '', makeDir: false, shouldSetToRename: true }, useHotkeyWrapper(['mod + n'], onCreateFile)
}) useHotkeyWrapper(['mod + shift + n'], onCreateFolder)
modelingSend({ type: 'Cancel' })
}
function createFolder() {
send({
type: 'Create file',
data: { name: '', makeDir: true, shouldSetToRename: true },
})
}
useHotkeyWrapper(['mod + n'], createFile)
useHotkeyWrapper(['mod + shift + n'], createFolder)
return ( return (
<> <>
@ -440,7 +506,7 @@ export const FileTreeMenu = () => {
bgClassName: 'bg-transparent', bgClassName: 'bg-transparent',
}} }}
className="!p-0 !bg-transparent hover:text-primary border-transparent hover:border-primary !outline-none" className="!p-0 !bg-transparent hover:text-primary border-transparent hover:border-primary !outline-none"
onClick={createFile} onClick={onCreateFile}
> >
<Tooltip position="bottom-right" delay={750}> <Tooltip position="bottom-right" delay={750}>
Create file Create file
@ -456,7 +522,7 @@ export const FileTreeMenu = () => {
bgClassName: 'bg-transparent', bgClassName: 'bg-transparent',
}} }}
className="!p-0 !bg-transparent hover:text-primary border-transparent hover:border-primary !outline-none" className="!p-0 !bg-transparent hover:text-primary border-transparent hover:border-primary !outline-none"
onClick={createFolder} onClick={onCreateFolder}
> >
<Tooltip position="bottom-right" delay={750}> <Tooltip position="bottom-right" delay={750}>
Create folder Create folder
@ -466,30 +532,106 @@ export const FileTreeMenu = () => {
) )
} }
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, shouldSetToRename: false },
})
}
return {
createFile,
createFolder,
newTreeEntry,
}
}
export const FileTree = ({ export const FileTree = ({
className = '', className = '',
onNavigateToFile: closePanel, onNavigateToFile: closePanel,
}: FileTreeProps) => { }: FileTreeProps) => {
const { createFile, createFolder, newTreeEntry } = useFileTreeOperations()
return ( return (
<div className={className}> <div className={className}>
<div className="flex items-center gap-1 px-4 py-1 bg-chalkboard-20/40 dark:bg-chalkboard-80/50 border-b border-b-chalkboard-30 dark:border-b-chalkboard-80"> <div className="flex items-center gap-1 px-4 py-1 bg-chalkboard-20/40 dark:bg-chalkboard-80/50 border-b border-b-chalkboard-30 dark:border-b-chalkboard-80">
<h2 className="flex-1 m-0 p-0 text-sm mono">Files</h2> <h2 className="flex-1 m-0 p-0 text-sm mono">Files</h2>
<FileTreeMenu /> <FileTreeMenu
onCreateFile={() => createFile({ dryRun: true })}
onCreateFolder={() => createFolder({ dryRun: true })}
/>
</div> </div>
<FileTreeInner onNavigateToFile={closePanel} /> <FileTreeInner
onNavigateToFile={closePanel}
newTreeEntry={newTreeEntry}
onCreateFile={(name: string) => createFile({ dryRun: false, name })}
onCreateFolder={(name: string) => createFolder({ dryRun: false, name })}
/>
</div> </div>
) )
} }
export const FileTreeInner = ({ export const FileTreeInner = ({
onNavigateToFile, onNavigateToFile,
onCreateFile,
onCreateFolder,
newTreeEntry,
}: { }: {
onCreateFile: (name: string) => void
onCreateFolder: (name: string) => void
newTreeEntry: TreeEntry
onNavigateToFile?: () => void onNavigateToFile?: () => void
}) => { }) => {
const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const { send: fileSend, context: fileContext } = useFileContext() const { send: fileSend, context: fileContext } = useFileContext()
const { send: modelingSend } = useModelingContext() const { send: modelingSend } = useModelingContext()
const [lastDirectoryClicked, setLastDirectoryClicked] = useState<
FileEntry | undefined
>(undefined)
const onNavigateToFile_ = () => {
// Reset modeling state when navigating to a new file
onNavigateToFile?.()
modelingSend({ type: 'Cancel' })
}
// Refresh the file tree when there are changes. // Refresh the file tree when there are changes.
useFileSystemWatcher( useFileSystemWatcher(
async (eventType, path) => { async (eventType, path) => {
@ -513,33 +655,78 @@ export const FileTreeInner = ({
) )
) )
const clickDirectory = () => { 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
setLastDirectoryClicked(target)
fileSend({ fileSend({
type: 'Set selected directory', type: 'Set selected directory',
directory: fileContext.project, directory: target,
}) })
} }
const showNewTreeEntry =
newTreeEntry !== undefined &&
fileContext.selectedDirectory.path === loaderData.project?.path
return ( return (
<div <div className="relative">
className="overflow-auto pb-12 absolute inset-0" <div
data-testid="file-pane-scroll-container" className="overflow-auto pb-12 absolute inset-0"
> data-testid="file-pane-scroll-container"
<ul className="m-0 p-0 text-sm" onClickCapture={clickDirectory}> >
{sortProject(fileContext.project?.children || []).map((fileOrDir) => ( <ul className="m-0 p-0 text-sm">
<FileTreeItem {showNewTreeEntry && (
project={fileContext.project} <div
currentFile={loaderData?.file} className="flex items-center"
fileOrDir={fileOrDir} style={{ paddingInlineStart: getIndentationCSS(0) }}
onNavigateToFile={() => { >
// Reset modeling state when navigating to a new file <FontAwesomeIcon
modelingSend({ type: 'Cancel' }) icon={faPencil}
onNavigateToFile?.() className="inline-block mr-2 m-0 p-0 w-2 h-2"
}} />
key={fileOrDir.path} <TreeEntryInput level={-1} onSubmit={onTreeEntryInputSubmit} />
/> </div>
))} )}
</ul> {sortFilesAndDirectories(fileContext.project?.children || []).map(
(fileOrDir) => (
<FileTreeItem
parentDir={fileContext.project}
project={fileContext.project}
currentFile={loaderData?.file}
lastDirectoryClicked={lastDirectoryClicked}
fileOrDir={fileOrDir}
onCreateFile={onCreateFile}
onCreateFolder={onCreateFolder}
newTreeEntry={newTreeEntry}
onClickDirectory={onClickDirectory}
onNavigateToFile={onNavigateToFile_}
key={fileOrDir.path}
/>
)
)}
</ul>
</div>
</div> </div>
) )
} }

View File

@ -158,36 +158,39 @@ export const ModelingMachineProvider = ({
'enable copilot': () => { 'enable copilot': () => {
editorManager.setCopilotEnabled(true) editorManager.setCopilotEnabled(true)
}, },
'sketch exit execute': ({ context: { store } }) => { // tsc reports this typing as perfectly fine, but eslint is complaining.
;(async () => { // It's actually nonsensical, so I'm quieting.
// When cancelling the sketch mode we should disable sketch mode within the engine. // eslint-disable-next-line @typescript-eslint/no-misused-promises
await engineCommandManager.sendSceneCommand({ 'sketch exit execute': async ({
type: 'modeling_cmd_req', context: { store },
cmd_id: uuidv4(), }): Promise<void> => {
cmd: { type: 'sketch_mode_disable' }, // When cancelling the sketch mode we should disable sketch mode within the engine.
}) await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'sketch_mode_disable' },
})
sceneInfra.camControls.syncDirection = 'clientToEngine' sceneInfra.camControls.syncDirection = 'clientToEngine'
if (cameraProjection.current === 'perspective') { if (cameraProjection.current === 'perspective') {
await sceneInfra.camControls.snapToPerspectiveBeforeHandingBackControlToEngine() await sceneInfra.camControls.snapToPerspectiveBeforeHandingBackControlToEngine()
} }
sceneInfra.camControls.syncDirection = 'engineToClient' sceneInfra.camControls.syncDirection = 'engineToClient'
store.videoElement?.pause() store.videoElement?.pause()
kclManager return kclManager
.executeCode() .executeCode()
.then(() => { .then(() => {
if (engineCommandManager.engineConnection?.idleMode) return if (engineCommandManager.engineConnection?.idleMode) return
store.videoElement?.play().catch((e) => { store.videoElement?.play().catch((e) => {
console.warn('Video playing was prevented', e) console.warn('Video playing was prevented', e)
})
}) })
.catch(reportRejection) })
})().catch(reportRejection) .catch(reportRejection)
}, },
'Set mouse state': assign(({ context, event }) => { 'Set mouse state': assign(({ context, event }) => {
if (event.type !== 'Set mouse state') return {} if (event.type !== 'Set mouse state') return {}

View File

@ -48,7 +48,7 @@ export const ModelingPaneHeader = ({
bgClassName: 'bg-transparent dark:bg-transparent', bgClassName: 'bg-transparent dark:bg-transparent',
}} }}
className="!p-0 !bg-transparent hover:text-primary border-transparent dark:!border-transparent hover:!border-primary dark:hover:!border-chalkboard-70 !outline-none" className="!p-0 !bg-transparent hover:text-primary border-transparent dark:!border-transparent hover:!border-primary dark:hover:!border-chalkboard-70 !outline-none"
onClick={onClose} onClick={() => onClose()}
> >
<Tooltip position="bottom-right" delay={750}> <Tooltip position="bottom-right" delay={750}>
Close Close
@ -59,14 +59,12 @@ export const ModelingPaneHeader = ({
} }
export const ModelingPane = ({ export const ModelingPane = ({
title,
icon,
id, id,
children, children,
className, className,
Menu,
detailsTestId, detailsTestId,
onClose, onClose,
title,
...props ...props
}: ModelingPaneProps) => { }: ModelingPaneProps) => {
const { settings } = useSettingsAuthContext() const { settings } = useSettingsAuthContext()
@ -78,6 +76,7 @@ export const ModelingPane = ({
return ( return (
<section <section
{...props} {...props}
title={title && typeof title === 'string' ? title : ''}
data-testid={detailsTestId} data-testid={detailsTestId}
id={id} id={id}
className={ className={
@ -88,14 +87,7 @@ export const ModelingPane = ({
(className || '') (className || '')
} }
> >
<ModelingPaneHeader {children}
id={id}
icon={icon}
title={title}
Menu={Menu}
onClose={onClose}
/>
<div className="relative w-full">{children}</div>
</section> </section>
) )
} }

View File

@ -5,16 +5,18 @@ import { CamDebugSettings } from 'clientSideScene/ClientSideSceneComp'
export const DebugPane = () => { export const DebugPane = () => {
return ( return (
<section <div className="relative">
data-testid="debug-panel" <section
className="absolute inset-0 p-2 box-border overflow-auto" data-testid="debug-panel"
> className="absolute inset-0 p-2 box-border overflow-auto"
<div className="flex flex-col"> >
<EngineCommands /> <div className="flex flex-col">
<CamDebugSettings /> <EngineCommands />
<AstExplorer /> <CamDebugSettings />
<DebugFeatureTree /> <AstExplorer />
</div> <DebugFeatureTree />
</section> </div>
</section>
</div>
) )
} }

View File

@ -174,27 +174,31 @@ export const KclEditorPane = () => {
const initialCode = useRef(codeManager.code) const initialCode = useRef(codeManager.code)
return ( return (
<div <div className="relative">
id="code-mirror-override" <div
className={'absolute inset-0 ' + (cursorBlinking.current ? 'blink' : '')} id="code-mirror-override"
> className={
<CodeEditor 'absolute inset-0 ' + (cursorBlinking.current ? 'blink' : '')
initialDocValue={initialCode.current} }
extensions={editorExtensions} >
theme={theme} <CodeEditor
onCreateEditor={(_editorView) => { initialDocValue={initialCode.current}
if (_editorView === null) return extensions={editorExtensions}
theme={theme}
onCreateEditor={(_editorView) => {
if (_editorView === null) return
editorManager.setEditorView(_editorView) editorManager.setEditorView(_editorView)
// On first load of this component, ensure we show the current errors // On first load of this component, ensure we show the current errors
// in the editor. // in the editor.
// Make sure we don't add them twice. // Make sure we don't add them twice.
if (diagnosticCount(_editorView.state) === 0) { if (diagnosticCount(_editorView.state) === 0) {
kclManager.setDiagnosticsForCurrentErrors() kclManager.setDiagnosticsForCurrentErrors()
} }
}} }}
/> />
</div>
</div> </div>
) )
} }

View File

@ -2,11 +2,17 @@ import { IconDefinition, faBugSlash } from '@fortawesome/free-solid-svg-icons'
import { KclEditorMenu } from 'components/ModelingSidebar/ModelingPanes/KclEditorMenu' import { KclEditorMenu } from 'components/ModelingSidebar/ModelingPanes/KclEditorMenu'
import { CustomIconName } from 'components/CustomIcon' import { CustomIconName } from 'components/CustomIcon'
import { KclEditorPane } from 'components/ModelingSidebar/ModelingPanes/KclEditorPane' import { KclEditorPane } from 'components/ModelingSidebar/ModelingPanes/KclEditorPane'
import { ModelingPaneHeader } from 'components/ModelingSidebar/ModelingPane'
import { MouseEventHandler, ReactNode } from 'react' import { MouseEventHandler, ReactNode } from 'react'
import { MemoryPane, MemoryPaneMenu } from './MemoryPane' import { MemoryPane, MemoryPaneMenu } from './MemoryPane'
import { LogsPane } from './LoggingPanes' import { LogsPane } from './LoggingPanes'
import { DebugPane } from './DebugPane' import { DebugPane } from './DebugPane'
import { FileTreeInner, FileTreeMenu, FileTreeRoot } from 'components/FileTree' import {
FileTreeInner,
FileTreeMenu,
FileTreeRoot,
useFileTreeOperations,
} from 'components/FileTree'
import { useKclContext } from 'lang/KclProvider' import { useKclContext } from 'lang/KclProvider'
import { editorManager } from 'lib/singletons' import { editorManager } from 'lib/singletons'
import { ContextFrom } from 'xstate' import { ContextFrom } from 'xstate'
@ -38,20 +44,19 @@ interface PaneCallbackProps {
export type SidebarPane = { export type SidebarPane = {
id: SidebarType id: SidebarType
title: ReactNode sidebarName: string
sidebarName?: string
icon: CustomIconName | IconDefinition icon: CustomIconName | IconDefinition
keybinding: string keybinding: string
Content: ReactNode | React.FC Content: React.FC<{ id: SidebarType; onClose: () => void }>
Menu?: ReactNode | React.FC
hide?: boolean | ((props: PaneCallbackProps) => boolean) hide?: boolean | ((props: PaneCallbackProps) => boolean)
showBadge?: BadgeInfo showBadge?: BadgeInfo
} }
export type SidebarAction = { export type SidebarAction = {
id: string id: string
title: ReactNode sidebarName: string
icon: CustomIconName icon: CustomIconName
title: ReactNode
iconClassName?: string // Just until we get rid of FontAwesome icons iconClassName?: string // Just until we get rid of FontAwesome icons
keybinding: string keybinding: string
action: () => void action: () => void
@ -59,14 +64,30 @@ export type SidebarAction = {
disable?: () => string | undefined disable?: () => string | undefined
} }
// For now a lot of icons are the same but the reality is they could totally
// be different, like an icon based on some data for the pane, or the icon
// changes to be a spinning loader on loading.
export const sidebarPanes: SidebarPane[] = [ export const sidebarPanes: SidebarPane[] = [
{ {
id: 'code', id: 'code',
title: 'KCL Code',
icon: 'code', icon: 'code',
Content: KclEditorPane, sidebarName: 'KCL Code',
Content: (props: { id: SidebarType; onClose: () => void }) => {
return (
<>
<ModelingPaneHeader
id={props.id}
icon="code"
title="KCL Code"
Menu={<KclEditorMenu />}
onClose={props.onClose}
/>
<KclEditorPane />
</>
)
},
keybinding: 'Shift + C', keybinding: 'Shift + C',
Menu: KclEditorMenu,
showBadge: { showBadge: {
value: ({ kclContext }) => { value: ({ kclContext }) => {
return kclContext.errors.length return kclContext.errors.length
@ -79,34 +100,96 @@ export const sidebarPanes: SidebarPane[] = [
}, },
{ {
id: 'files', id: 'files',
title: <FileTreeRoot />,
sidebarName: 'Project Files',
icon: 'folder', icon: 'folder',
Content: FileTreeInner, sidebarName: 'Project Files',
Content: (props: { id: SidebarType; onClose: () => void }) => {
const { createFile, createFolder, newTreeEntry } = useFileTreeOperations()
return (
<>
<ModelingPaneHeader
id={props.id}
icon="folder"
title={<FileTreeRoot />}
Menu={
<FileTreeMenu
onCreateFile={() => createFile({ dryRun: true })}
onCreateFolder={() => createFolder({ dryRun: true })}
/>
}
onClose={props.onClose}
/>
<FileTreeInner
onCreateFile={(name: string) => createFile({ dryRun: false, name })}
onCreateFolder={(name: string) =>
createFolder({ dryRun: false, name })
}
newTreeEntry={newTreeEntry}
/>
</>
)
},
keybinding: 'Shift + F', keybinding: 'Shift + F',
Menu: FileTreeMenu,
hide: ({ platform }) => platform === 'web', hide: ({ platform }) => platform === 'web',
}, },
{ {
id: 'variables', id: 'variables',
title: 'Variables',
icon: 'make-variable', icon: 'make-variable',
Content: MemoryPane, sidebarName: 'Variables',
Menu: MemoryPaneMenu, Content: (props: { id: SidebarType; onClose: () => void }) => {
return (
<>
<ModelingPaneHeader
id={props.id}
icon="make-variable"
title="Variables"
Menu={<MemoryPaneMenu />}
onClose={props.onClose}
/>
<MemoryPane />
</>
)
},
keybinding: 'Shift + V', keybinding: 'Shift + V',
}, },
{ {
id: 'logs', id: 'logs',
title: 'Logs',
icon: 'logs', icon: 'logs',
Content: LogsPane, sidebarName: 'Logs',
Content: (props: { id: SidebarType; onClose: () => void }) => {
return (
<>
<ModelingPaneHeader
id={props.id}
icon="logs"
title="Logs"
Menu={null}
onClose={props.onClose}
/>
<LogsPane />
</>
)
},
keybinding: 'Shift + L', keybinding: 'Shift + L',
}, },
{ {
id: 'debug', id: 'debug',
title: 'Debug',
icon: faBugSlash, icon: faBugSlash,
Content: DebugPane, sidebarName: 'Debug',
Content: (props: { id: SidebarType; onClose: () => void }) => {
return (
<>
<ModelingPaneHeader
id={props.id}
icon={faBugSlash}
title="Debug"
Menu={null}
onClose={props.onClose}
/>
<DebugPane />
</>
)
},
keybinding: 'Shift + D', keybinding: 'Shift + D',
hide: ({ settings }) => !settings.modeling.showDebugPanel.current, hide: ({ settings }) => !settings.modeling.showDebugPanel.current,
}, },

View File

@ -1,11 +0,0 @@
.grid {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: 1fr 1fr;
row-gap: 0.25rem;
align-items: stretch;
position: relative;
padding-block: 1px;
max-width: 100%;
flex: 1 1 0;
}

View File

@ -5,14 +5,12 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
ReactNode,
useContext, useContext,
} from 'react' } from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { SidebarAction, SidebarType, sidebarPanes } from './ModelingPanes' import { SidebarAction, SidebarType, sidebarPanes } from './ModelingPanes'
import Tooltip from 'components/Tooltip' import Tooltip from 'components/Tooltip'
import { ActionIcon } from 'components/ActionIcon' import { ActionIcon } from 'components/ActionIcon'
import styles from './ModelingSidebar.module.css'
import { ModelingPane } from './ModelingPane' import { ModelingPane } from './ModelingPane'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
@ -62,6 +60,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
{ {
id: 'export', id: 'export',
title: 'Export part', title: 'Export part',
sidebarName: 'Export part',
icon: 'floppyDiskArrow', icon: 'floppyDiskArrow',
keybinding: 'Ctrl + Shift + E', keybinding: 'Ctrl + Shift + E',
action: () => action: () =>
@ -73,6 +72,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
{ {
id: 'make', id: 'make',
title: 'Make part', title: 'Make part',
sidebarName: 'Make part',
icon: 'printer3d', icon: 'printer3d',
keybinding: 'Ctrl + Shift + M', keybinding: 'Ctrl + Shift + M',
// eslint-disable-next-line @typescript-eslint/no-misused-promises // eslint-disable-next-line @typescript-eslint/no-misused-promises
@ -182,7 +182,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
bottomRight: 'hidden', bottomRight: 'hidden',
}} }}
> >
<div id="app-sidebar" className={styles.grid + ' flex-1'}> <div id="app-sidebar" className="flex flex-row h-full">
<ul <ul
className={ className={
(context.store?.openPanes.length === 0 ? 'rounded-r ' : '') + (context.store?.openPanes.length === 0 ? 'rounded-r ' : '') +
@ -220,7 +220,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
key={action.id} key={action.id}
paneConfig={{ paneConfig={{
id: action.id, id: action.id,
title: action.title, sidebarName: action.sidebarName,
icon: action.icon, icon: action.icon,
keybinding: action.keybinding, keybinding: action.keybinding,
iconClassName: action.iconClassName, iconClassName: action.iconClassName,
@ -237,10 +237,8 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
<ul <ul
id="pane-section" id="pane-section"
className={ className={
'ml-[-1px] col-start-2 col-span-1 flex flex-col gap-2 ' + 'ml-[-1px] col-start-2 col-span-1 flex flex-col items-stretch gap-2 ' +
(context.store?.openPanes.length >= 1 (context.store?.openPanes.length >= 1 ? `w-full` : `hidden`)
? `row-start-1 row-end-3`
: `hidden`)
} }
> >
{filteredPanes {filteredPanes
@ -249,13 +247,15 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
<ModelingPane <ModelingPane
key={pane.id} key={pane.id}
icon={pane.icon} icon={pane.icon}
title={pane.sidebarName}
onClose={() => {}}
id={`${pane.id}-pane`} id={`${pane.id}-pane`}
title={pane.title}
Menu={pane.Menu}
onClose={() => togglePane(pane.id)}
> >
{pane.Content instanceof Function ? ( {pane.Content instanceof Function ? (
<pane.Content /> <pane.Content
id={pane.id}
onClose={() => togglePane(pane.id)}
/>
) : ( ) : (
pane.Content pane.Content
)} )}
@ -271,8 +271,7 @@ interface ModelingPaneButtonProps
extends React.HTMLAttributes<HTMLButtonElement> { extends React.HTMLAttributes<HTMLButtonElement> {
paneConfig: { paneConfig: {
id: string id: string
title: ReactNode sidebarName: string
sidebarName?: string
icon: CustomIconName | IconDefinition icon: CustomIconName | IconDefinition
keybinding: string keybinding: string
iconClassName?: string iconClassName?: string
@ -301,10 +300,7 @@ function ModelingPaneButton({
<button <button
className="group pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent disabled:!border-transparent p-0 m-0 rounded-sm !outline-0 focus-visible:border-primary" className="group pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent disabled:!border-transparent p-0 m-0 rounded-sm !outline-0 focus-visible:border-primary"
onClick={onClick} onClick={onClick}
name={ name={paneConfig.sidebarName}
paneConfig.sidebarName ??
(typeof paneConfig.title === 'string' ? paneConfig.title : '')
}
data-testid={paneConfig.id + '-pane-button'} data-testid={paneConfig.id + '-pane-button'}
disabled={disabledText !== undefined} disabled={disabledText !== undefined}
aria-disabled={disabledText !== undefined} aria-disabled={disabledText !== undefined}
@ -320,7 +316,7 @@ function ModelingPaneButton({
} }
/> />
<span className="sr-only"> <span className="sr-only">
{paneConfig.sidebarName ?? paneConfig.title} {paneConfig.sidebarName}
{paneIsOpen !== undefined ? ` pane` : ''} {paneIsOpen !== undefined ? ` pane` : ''}
</span> </span>
<Tooltip <Tooltip
@ -329,7 +325,7 @@ function ModelingPaneButton({
hoverOnly hoverOnly
> >
<span className="flex-1"> <span className="flex-1">
{paneConfig.sidebarName ?? paneConfig.title} {paneConfig.sidebarName}
{disabledText !== undefined ? ` (${disabledText})` : ''} {disabledText !== undefined ? ` (${disabledText})` : ''}
{paneIsOpen !== undefined ? ` pane` : ''} {paneIsOpen !== undefined ? ` pane` : ''}
</span> </span>

View File

@ -5,7 +5,6 @@ import {
INDEX_IDENTIFIER, INDEX_IDENTIFIER,
MAX_PADDING, MAX_PADDING,
ONBOARDING_PROJECT_NAME, ONBOARDING_PROJECT_NAME,
PROJECT_ENTRYPOINT,
} from 'lib/constants' } from 'lib/constants'
import { bracket } from './exampleKcl' import { bracket } from './exampleKcl'
import { PATHS } from './paths' import { PATHS } from './paths'
@ -22,36 +21,20 @@ export const isHidden = (fileOrDir: FileEntry) =>
export const isDir = (fileOrDir: FileEntry) => export const isDir = (fileOrDir: FileEntry) =>
'children' in fileOrDir && fileOrDir.children !== undefined 'children' in fileOrDir && fileOrDir.children !== undefined
// Deeply sort the files and directories in a project like VS Code does: // Shallow sort the files and directories
// The main.kcl file is always first, then files, then directories
// Files and directories are sorted alphabetically // Files and directories are sorted alphabetically
export function sortProject(project: FileEntry[]): FileEntry[] { export function sortFilesAndDirectories(files: FileEntry[]): FileEntry[] {
const sortedProject = project.sort((a, b) => { return files.sort((a, b) => {
if (a.name === PROJECT_ENTRYPOINT) { if (a.children === null && b.children !== null) {
return -1
} else if (b.name === PROJECT_ENTRYPOINT) {
return 1 return 1
} else if (a.children === null && b.children !== null) {
return -1
} else if (a.children !== null && b.children === null) { } else if (a.children !== null && b.children === null) {
return 1 return -1
} else if (a.name && b.name) { } else if (a.name && b.name) {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
} else { } else {
return 0 return 0
} }
}) })
return sortedProject.map((fileOrDir: FileEntry) => {
if ('children' in fileOrDir && fileOrDir.children !== null) {
return {
...fileOrDir,
children: sortProject(fileOrDir.children || []),
}
} else {
return fileOrDir
}
})
} }
// create a regex to match the project name // create a regex to match the project name