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>
@ -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()
|
||||||
|
@ -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 (
|
||||||
|
@ -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' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 73 KiB |
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 36 KiB |
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 {}
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|