Add 'Clone' feature to file tree (#5232)

* Add 'Clone' file / folder feature to file tree

* E2E Test: clone file in file tree

* Don't stat if there's no target
This commit is contained in:
49fl
2025-02-04 18:13:59 -05:00
committed by GitHub
parent bc6f0fceca
commit 8f90c352fe
8 changed files with 129 additions and 8 deletions

View File

@ -1186,4 +1186,56 @@ test.describe('Undo and redo do not keep history when navigating between files',
})
}
)
test(
`cloned file has an incremented name and same contents`,
{ tag: '@electron' },
async ({ page, context, homePage }, testInfo) => {
const { panesOpen, createNewFile, cloneFile } = await getUtils(page, test)
const { dir } = await context.folderSetupFn(async (dir) => {
const finalDir = join(dir, 'testDefault')
await fsp.mkdir(finalDir, { recursive: true })
await fsp.copyFile(
executorInputPath('e2e-can-sketch-on-chamfer.kcl'),
join(finalDir, 'lee.kcl')
)
})
const contentOriginal = await fsp.readFile(
join(dir, 'testDefault', 'lee.kcl'),
'utf-8'
)
await page.setBodyDimensions({ width: 1200, height: 500 })
page.on('console', console.log)
await panesOpen(['files'])
await homePage.openProject('testDefault')
await cloneFile('lee.kcl')
await cloneFile('lee-1.kcl')
await cloneFile('lee-2.kcl')
await cloneFile('lee-3.kcl')
await cloneFile('lee-4.kcl')
await test.step('Postcondition: there are 5 new lee-*.kcl files', async () => {
await expect(
page
.locator('[data-testid="file-pane-scroll-container"] button')
.filter({ hasText: /lee[-]?[0-5]?/ })
).toHaveCount(5)
})
await test.step('Postcondition: the files have the same contents', async () => {
for (let n = 0; n < 5; n += 1) {
const content = await fsp.readFile(
join(dir, 'testDefault', `lee-${n + 1}.kcl`),
'utf-8'
)
await expect(content).toEqual(contentOriginal)
}
})
}
)
})

View File

@ -552,6 +552,16 @@ export async function getUtils(page: Page, test_?: typeof test) {
})
},
cloneFile: async (name: string) => {
return test?.step(`Cloning file '${name}'`, async () => {
await page
.locator('[data-testid="file-pane-scroll-container"] button')
.filter({ hasText: name })
.click({ button: 'right' })
await page.getByTestId('context-menu-clone').click()
})
},
selectFile: async (name: string) => {
return test?.step(`Select ${name}`, async () => {
await page

1
interface.d.ts vendored
View File

@ -32,6 +32,7 @@ export interface IElectronAPI {
callback: (eventType: string, path: string) => void
) => void
readFile: typeof fs.readFile
copyFile: typeof fs.copyFile
watchFileOff: (path: string, key: string) => void
writeFile: (
path: string,

View File

@ -124,23 +124,44 @@ export const FileMachineProvider = ({
let createdName = input.name.trim() || DEFAULT_FILE_NAME
let createdPath: string
if (input.makeDir) {
if (
(input.targetPathToClone &&
(await window.electron.statIsDirectory(
input.targetPathToClone
))) ||
input.makeDir
) {
let { name, path } = getNextDirName({
entryName: createdName,
baseDir: input.selectedDirectory.path,
entryName: input.targetPathToClone
? window.electron.path.basename(input.targetPathToClone)
: createdName,
baseDir: input.targetPathToClone
? window.electron.path.dirname(input.targetPathToClone)
: input.selectedDirectory.path,
})
createdName = name
createdPath = path
await window.electron.mkdir(createdPath)
} else {
const { name, path } = getNextFileName({
entryName: createdName,
baseDir: input.selectedDirectory.path,
entryName: input.targetPathToClone
? window.electron.path.basename(input.targetPathToClone)
: createdName,
baseDir: input.targetPathToClone
? window.electron.path.dirname(input.targetPathToClone)
: input.selectedDirectory.path,
})
createdName = name
createdPath = path
if (input.targetPathToClone) {
await window.electron.copyFile(
input.targetPathToClone,
createdPath
)
} else {
await window.electron.writeFile(createdPath, input.content ?? '')
}
}
return {
message: `Successfully created "${createdName}"`,

View File

@ -153,6 +153,7 @@ const FileTreeItem = ({
onClickDirectory,
onCreateFile,
onCreateFolder,
onCloneFileOrFolder,
newTreeEntry,
level = 0,
treeSelection,
@ -171,6 +172,7 @@ const FileTreeItem = ({
) => void
onCreateFile: (name: string) => void
onCreateFolder: (name: string) => void
onCloneFileOrFolder: (path: string) => void
newTreeEntry: TreeEntry
level?: number
treeSelection: FileEntry | undefined
@ -403,6 +405,7 @@ const FileTreeItem = ({
currentFile={currentFile}
onCreateFile={onCreateFile}
onCreateFolder={onCreateFolder}
onCloneFileOrFolder={onCloneFileOrFolder}
newTreeEntry={newTreeEntry}
lastDirectoryClicked={lastDirectoryClicked}
onClickDirectory={onClickDirectory}
@ -441,6 +444,7 @@ const FileTreeItem = ({
itemRef={itemRef}
onRename={addCurrentItemToRenaming}
onDelete={() => setIsConfirmingDelete(true)}
onClone={() => onCloneFileOrFolder(fileOrDir.path)}
/>
</div>
)
@ -450,12 +454,14 @@ interface FileTreeContextMenuProps {
itemRef: React.RefObject<HTMLElement>
onRename: () => void
onDelete: () => void
onClone: () => void
}
function FileTreeContextMenu({
itemRef,
onRename,
onDelete,
onClone,
}: FileTreeContextMenuProps) {
const platform = usePlatform()
const metaKey = platform === 'macos' ? '⌘' : 'Ctrl'
@ -478,6 +484,13 @@ function FileTreeContextMenu({
>
Delete
</ContextMenuItem>,
<ContextMenuItem
data-testid="context-menu-clone"
onClick={onClone}
hotkey=""
>
Clone
</ContextMenuItem>,
]}
/>
)
@ -584,9 +597,22 @@ export const useFileTreeOperations = () => {
})
}
function cloneFileOrDir(args: { path: string }) {
send({
type: 'Create file',
data: {
name: '',
makeDir: false,
shouldSetToRename: false,
targetPathToClone: args.path,
},
})
}
return {
createFile,
createFolder,
cloneFileOrDir,
newTreeEntry,
}
}
@ -595,7 +621,8 @@ export const FileTree = ({
className = '',
onNavigateToFile: closePanel,
}: FileTreeProps) => {
const { createFile, createFolder, newTreeEntry } = useFileTreeOperations()
const { createFile, createFolder, cloneFileOrDir, newTreeEntry } =
useFileTreeOperations()
return (
<div className={className}>
@ -611,6 +638,7 @@ export const FileTree = ({
newTreeEntry={newTreeEntry}
onCreateFile={(name: string) => createFile({ dryRun: false, name })}
onCreateFolder={(name: string) => createFolder({ dryRun: false, name })}
onCloneFileOrFolder={(path: string) => cloneFileOrDir({ path })}
/>
</div>
)
@ -620,10 +648,12 @@ export const FileTreeInner = ({
onNavigateToFile,
onCreateFile,
onCreateFolder,
onCloneFileOrFolder,
newTreeEntry,
}: {
onCreateFile: (name: string) => void
onCreateFolder: (name: string) => void
onCloneFileOrFolder: (path: string) => void
newTreeEntry: TreeEntry
onNavigateToFile?: () => void
}) => {
@ -732,6 +762,7 @@ export const FileTreeInner = ({
fileOrDir={fileOrDir}
onCreateFile={onCreateFile}
onCreateFolder={onCreateFolder}
onCloneFileOrFolder={onCloneFileOrFolder}
newTreeEntry={newTreeEntry}
onClickDirectory={onClickDirectory}
onNavigateToFile={onNavigateToFile_}

View File

@ -122,7 +122,8 @@ export const sidebarPanes: SidebarPane[] = [
icon: 'folder',
sidebarName: 'Project Files',
Content: (props: { id: SidebarType; onClose: () => void }) => {
const { createFile, createFolder, newTreeEntry } = useFileTreeOperations()
const { createFile, createFolder, cloneFileOrDir, newTreeEntry } =
useFileTreeOperations()
return (
<>
@ -143,6 +144,7 @@ export const sidebarPanes: SidebarPane[] = [
onCreateFolder={(name: string) =>
createFolder({ dryRun: false, name })
}
onCloneFileOrFolder={(path: string) => cloneFileOrDir({ path })}
newTreeEntry={newTreeEntry}
/>
</>

View File

@ -21,6 +21,7 @@ type FileMachineEvents =
content?: string
silent?: boolean
shouldSetToRename?: boolean
targetPathToClone?: string
}
}
| { type: 'Delete file'; data: FileEntry }
@ -124,6 +125,7 @@ export const fileMachine = setup({
name: string
makeDir: boolean
selectedDirectory: FileEntry
targetPathToClone?: string
content: string
shouldSetToRename: boolean
}
@ -235,6 +237,7 @@ export const fileMachine = setup({
name: event.data.name,
makeDir: event.data.makeDir,
selectedDirectory: context.selectedDirectory,
targetPathToClone: event.data.targetPathToClone,
content: event.data.content ?? '',
shouldSetToRename: event.data.shouldSetToRename ?? false,
}

View File

@ -144,6 +144,7 @@ contextBridge.exposeInMainWorld('electron', {
// exported.
watchFileOn,
watchFileOff,
copyFile: fs.copyFile,
readFile,
writeFile,
exists,