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) => { selectFile: async (name: string) => {
return test?.step(`Select ${name}`, async () => { return test?.step(`Select ${name}`, async () => {
await page await page

1
interface.d.ts vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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