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:
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
@ -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
1
interface.d.ts
vendored
@ -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,
|
||||
|
@ -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}"`,
|
||||
|
@ -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_}
|
||||
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -144,6 +144,7 @@ contextBridge.exposeInMainWorld('electron', {
|
||||
// exported.
|
||||
watchFileOn,
|
||||
watchFileOff,
|
||||
copyFile: fs.copyFile,
|
||||
readFile,
|
||||
writeFile,
|
||||
exists,
|
||||
|
Reference in New Issue
Block a user