diff --git a/e2e/playwright/file-tree.spec.ts b/e2e/playwright/file-tree.spec.ts index effcaa861..dd16e1a71 100644 --- a/e2e/playwright/file-tree.spec.ts +++ b/e2e/playwright/file-tree.spec.ts @@ -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) + } + }) + } + ) }) diff --git a/e2e/playwright/test-utils.ts b/e2e/playwright/test-utils.ts index f91f0dbc7..e883dba78 100644 --- a/e2e/playwright/test-utils.ts +++ b/e2e/playwright/test-utils.ts @@ -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 diff --git a/interface.d.ts b/interface.d.ts index de55e5398..66543f402 100644 --- a/interface.d.ts +++ b/interface.d.ts @@ -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, diff --git a/src/components/FileMachineProvider.tsx b/src/components/FileMachineProvider.tsx index 24d8773e1..c4973e903 100644 --- a/src/components/FileMachineProvider.tsx +++ b/src/components/FileMachineProvider.tsx @@ -124,22 +124,43 @@ 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 - await window.electron.writeFile(createdPath, input.content ?? '') + if (input.targetPathToClone) { + await window.electron.copyFile( + input.targetPathToClone, + createdPath + ) + } else { + await window.electron.writeFile(createdPath, input.content ?? '') + } } return { diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx index bc6a9bc78..7aa82254b 100644 --- a/src/components/FileTree.tsx +++ b/src/components/FileTree.tsx @@ -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)} /> ) @@ -450,12 +454,14 @@ interface FileTreeContextMenuProps { itemRef: React.RefObject 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 , + + Clone + , ]} /> ) @@ -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 (
@@ -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 })} />
) @@ -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_} diff --git a/src/components/ModelingSidebar/ModelingPanes/index.tsx b/src/components/ModelingSidebar/ModelingPanes/index.tsx index 2cc846f84..26cf6e89a 100644 --- a/src/components/ModelingSidebar/ModelingPanes/index.tsx +++ b/src/components/ModelingSidebar/ModelingPanes/index.tsx @@ -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} /> diff --git a/src/machines/fileMachine.ts b/src/machines/fileMachine.ts index 196d71e7e..e067d85dc 100644 --- a/src/machines/fileMachine.ts +++ b/src/machines/fileMachine.ts @@ -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, } diff --git a/src/preload.ts b/src/preload.ts index 0a6f3cf67..bf5089547 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -144,6 +144,7 @@ contextBridge.exposeInMainWorld('electron', { // exported. watchFileOn, watchFileOff, + copyFile: fs.copyFile, readFile, writeFile, exists,