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