Compare commits
	
		
			13 Commits
		
	
	
		
			kurt-test-
			...
			ok
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 6af475cf91 | |||
| 2b5fd32699 | |||
| 743aa606f9 | |||
| 9a4e061be3 | |||
| 42028798e4 | |||
| 319251f98c | |||
| c545834a52 | |||
| bc8cf38936 | |||
| 535911fc0a | |||
| 19575a16d7 | |||
| 2ae92d5341 | |||
| fca1f02d88 | |||
| f752d47dd2 | 
							
								
								
									
										204
									
								
								e2e/playwright/file-tree.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								e2e/playwright/file-tree.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,204 @@ | |||||||
|  | import { test, expect } from '@playwright/test' | ||||||
|  | import * as fsp from 'fs/promises' | ||||||
|  | import { getUtils, setup, setupElectron, tearDown } from './test-utils' | ||||||
|  |  | ||||||
|  | test.beforeEach(async ({ context, page }) => { | ||||||
|  |   await setup(context, page) | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | test.afterEach(async ({ page }, testInfo) => { | ||||||
|  |   await tearDown(page, testInfo) | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | test.describe('when using the file tree to', () => { | ||||||
|  |   const fromFile = 'main.kcl' | ||||||
|  |   const toFile = 'hello.kcl' | ||||||
|  |  | ||||||
|  |   test( | ||||||
|  |     `rename ${fromFile} to ${toFile}, and doesn't crash on reload and settings load`, | ||||||
|  |     { tag: '@electron' }, | ||||||
|  |     async ({ browser: _ }, testInfo) => { | ||||||
|  |       const { electronApp, page } = await setupElectron({ | ||||||
|  |         testInfo, | ||||||
|  |         folderSetupFn: async () => {}, | ||||||
|  |       }) | ||||||
|  |  | ||||||
|  |       const { | ||||||
|  |         panesOpen, | ||||||
|  |         createAndSelectProject, | ||||||
|  |         pasteCodeInEditor, | ||||||
|  |         renameFile, | ||||||
|  |         editorTextMatches, | ||||||
|  |       } = await getUtils(page, test) | ||||||
|  |  | ||||||
|  |       await page.setViewportSize({ width: 1200, height: 500 }) | ||||||
|  |       page.on('console', console.log) | ||||||
|  |  | ||||||
|  |       await panesOpen(['files', 'code']) | ||||||
|  |  | ||||||
|  |       await createAndSelectProject('project-000') | ||||||
|  |  | ||||||
|  |       // File the main.kcl with contents | ||||||
|  |       const kclCube = await fsp.readFile( | ||||||
|  |         'src/wasm-lib/tests/executor/inputs/cube.kcl', | ||||||
|  |         'utf-8' | ||||||
|  |       ) | ||||||
|  |       await pasteCodeInEditor(kclCube) | ||||||
|  |  | ||||||
|  |       await renameFile(fromFile, toFile) | ||||||
|  |       await page.reload() | ||||||
|  |  | ||||||
|  |       await test.step('Postcondition: editor has same content as before the rename', async () => { | ||||||
|  |         await editorTextMatches(kclCube) | ||||||
|  |       }) | ||||||
|  |  | ||||||
|  |       await test.step('Postcondition: opening and closing settings works', async () => { | ||||||
|  |         const settingsOpenButton = page.getByRole('link', { | ||||||
|  |           name: 'settings Settings', | ||||||
|  |         }) | ||||||
|  |         const settingsCloseButton = page.getByTestId('settings-close-button') | ||||||
|  |         await settingsOpenButton.click() | ||||||
|  |         await settingsCloseButton.click() | ||||||
|  |       }) | ||||||
|  |  | ||||||
|  |       await electronApp.close() | ||||||
|  |     } | ||||||
|  |   ) | ||||||
|  |  | ||||||
|  |   test( | ||||||
|  |     `create many new untitled files they increment their names`, | ||||||
|  |     { tag: '@electron' }, | ||||||
|  |     async ({ browser: _ }, testInfo) => { | ||||||
|  |       const { electronApp, page } = await setupElectron({ | ||||||
|  |         testInfo, | ||||||
|  |         folderSetupFn: async () => {}, | ||||||
|  |       }) | ||||||
|  |  | ||||||
|  |       const { panesOpen, createAndSelectProject, createNewFile } = | ||||||
|  |         await getUtils(page, test) | ||||||
|  |  | ||||||
|  |       await page.setViewportSize({ width: 1200, height: 500 }) | ||||||
|  |       page.on('console', console.log) | ||||||
|  |  | ||||||
|  |       await panesOpen(['files']) | ||||||
|  |  | ||||||
|  |       await createAndSelectProject('project-000') | ||||||
|  |  | ||||||
|  |       await createNewFile('') | ||||||
|  |       await createNewFile('') | ||||||
|  |       await createNewFile('') | ||||||
|  |       await createNewFile('') | ||||||
|  |       await createNewFile('') | ||||||
|  |  | ||||||
|  |       await test.step('Postcondition: there are 5 new Untitled-*.kcl files', async () => { | ||||||
|  |         await expect( | ||||||
|  |           page | ||||||
|  |             .locator('[data-testid="file-pane-scroll-container"] button') | ||||||
|  |             .filter({ hasText: /Untitled[-]?[0-5]?/ }) | ||||||
|  |         ).toHaveCount(5) | ||||||
|  |       }) | ||||||
|  |  | ||||||
|  |       await electronApp.close() | ||||||
|  |     } | ||||||
|  |   ) | ||||||
|  |  | ||||||
|  |   test( | ||||||
|  |     'create a new file with the same name as an existing file cancels the operation', | ||||||
|  |     { tag: '@electron' }, | ||||||
|  |     async ({ browser: _ }, testInfo) => { | ||||||
|  |       const { electronApp, page } = await setupElectron({ | ||||||
|  |         testInfo, | ||||||
|  |         folderSetupFn: async () => {}, | ||||||
|  |       }) | ||||||
|  |  | ||||||
|  |       const { | ||||||
|  |         panesOpen, | ||||||
|  |         createAndSelectProject, | ||||||
|  |         pasteCodeInEditor, | ||||||
|  |         createNewFileAndSelect, | ||||||
|  |         renameFile, | ||||||
|  |         selectFile, | ||||||
|  |         editorTextMatches, | ||||||
|  |       } = await getUtils(page, test) | ||||||
|  |  | ||||||
|  |       await page.setViewportSize({ width: 1200, height: 500 }) | ||||||
|  |       page.on('console', console.log) | ||||||
|  |  | ||||||
|  |       await panesOpen(['files', 'code']) | ||||||
|  |  | ||||||
|  |       await createAndSelectProject('project-000') | ||||||
|  |       // File the main.kcl with contents | ||||||
|  |       const kclCube = await fsp.readFile( | ||||||
|  |         'src/wasm-lib/tests/executor/inputs/cube.kcl', | ||||||
|  |         'utf-8' | ||||||
|  |       ) | ||||||
|  |       await pasteCodeInEditor(kclCube) | ||||||
|  |  | ||||||
|  |       const kcl1 = 'main.kcl' | ||||||
|  |       const kcl2 = '2.kcl' | ||||||
|  |  | ||||||
|  |       await createNewFileAndSelect(kcl2) | ||||||
|  |       const kclCylinder = await fsp.readFile( | ||||||
|  |         'src/wasm-lib/tests/executor/inputs/cylinder.kcl', | ||||||
|  |         'utf-8' | ||||||
|  |       ) | ||||||
|  |       await pasteCodeInEditor(kclCylinder) | ||||||
|  |  | ||||||
|  |       await renameFile(kcl2, kcl1) | ||||||
|  |  | ||||||
|  |       await test.step(`Postcondition: ${kcl1} still has the original content`, async () => { | ||||||
|  |         await selectFile(kcl1) | ||||||
|  |         await editorTextMatches(kclCube) | ||||||
|  |       }) | ||||||
|  |  | ||||||
|  |       await test.step(`Postcondition: ${kcl2} still exists with the original content`, async () => { | ||||||
|  |         await selectFile(kcl2) | ||||||
|  |         await editorTextMatches(kclCylinder) | ||||||
|  |       }) | ||||||
|  |  | ||||||
|  |       await electronApp.close() | ||||||
|  |     } | ||||||
|  |   ) | ||||||
|  |  | ||||||
|  |   test( | ||||||
|  |     'deleting all files recreates a default main.kcl with no code', | ||||||
|  |     { tag: '@electron' }, | ||||||
|  |     async ({ browser: _ }, testInfo) => { | ||||||
|  |       const { electronApp, page } = await setupElectron({ | ||||||
|  |         testInfo, | ||||||
|  |         folderSetupFn: async () => {}, | ||||||
|  |       }) | ||||||
|  |  | ||||||
|  |       const { | ||||||
|  |         panesOpen, | ||||||
|  |         createAndSelectProject, | ||||||
|  |         pasteCodeInEditor, | ||||||
|  |         deleteFile, | ||||||
|  |         editorTextMatches, | ||||||
|  |       } = await getUtils(page, test) | ||||||
|  |  | ||||||
|  |       await page.setViewportSize({ width: 1200, height: 500 }) | ||||||
|  |       page.on('console', console.log) | ||||||
|  |  | ||||||
|  |       await panesOpen(['files', 'code']) | ||||||
|  |  | ||||||
|  |       await createAndSelectProject('project-000') | ||||||
|  |       // File the main.kcl with contents | ||||||
|  |       const kclCube = await fsp.readFile( | ||||||
|  |         'src/wasm-lib/tests/executor/inputs/cube.kcl', | ||||||
|  |         'utf-8' | ||||||
|  |       ) | ||||||
|  |       await pasteCodeInEditor(kclCube) | ||||||
|  |  | ||||||
|  |       const kcl1 = 'main.kcl' | ||||||
|  |  | ||||||
|  |       await deleteFile(kcl1) | ||||||
|  |  | ||||||
|  |       await test.step(`Postcondition: ${kcl1} is recreated but has no content`, async () => { | ||||||
|  |         await editorTextMatches('') | ||||||
|  |       }) | ||||||
|  |  | ||||||
|  |       await electronApp.close() | ||||||
|  |     } | ||||||
|  |   ) | ||||||
|  | }) | ||||||
| @ -532,18 +532,62 @@ export async function getUtils(page: Page, test_?: typeof test) { | |||||||
|       }) |       }) | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|  |     createNewFile: async (name: string) => { | ||||||
|  |       return test?.step(`Create a file named ${name}`, async () => { | ||||||
|  |         await page.getByTestId('create-file-button').click() | ||||||
|  |         await page.getByTestId('file-rename-field').fill(name) | ||||||
|  |         await page.keyboard.press('Enter') | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     selectFile: async (name: string) => { | ||||||
|  |       return test?.step(`Select ${name}`, async () => { | ||||||
|  |         await page | ||||||
|  |           .locator('[data-testid="file-pane-scroll-container"] button') | ||||||
|  |           .filter({ hasText: name }) | ||||||
|  |           .click() | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|  |  | ||||||
|     createNewFileAndSelect: async (name: string) => { |     createNewFileAndSelect: async (name: string) => { | ||||||
|       return test?.step(`Create a file named ${name}, select it`, async () => { |       return test?.step(`Create a file named ${name}, select it`, async () => { | ||||||
|         await page.getByTestId('create-file-button').click() |         await page.getByTestId('create-file-button').click() | ||||||
|         await page.getByTestId('file-rename-field').fill(name) |         await page.getByTestId('file-rename-field').fill(name) | ||||||
|         await page.keyboard.press('Enter') |         await page.keyboard.press('Enter') | ||||||
|         await page |         await page | ||||||
|           .getByTestId('file-pane-scroll-container') |           .locator('[data-testid="file-pane-scroll-container"] button') | ||||||
|           .filter({ hasText: name }) |           .filter({ hasText: name }) | ||||||
|           .click() |           .click() | ||||||
|       }) |       }) | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|  |     renameFile: async (fromName: string, toName: string) => { | ||||||
|  |       return test?.step(`Rename ${fromName} to ${toName}`, async () => { | ||||||
|  |         await page | ||||||
|  |           .locator('[data-testid="file-pane-scroll-container"] button') | ||||||
|  |           .filter({ hasText: fromName }) | ||||||
|  |           .click({ button: 'right' }) | ||||||
|  |         await page.getByTestId('context-menu-rename').click() | ||||||
|  |         await page.getByTestId('file-rename-field').fill(toName) | ||||||
|  |         await page.keyboard.press('Enter') | ||||||
|  |         await page | ||||||
|  |           .locator('[data-testid="file-pane-scroll-container"] button') | ||||||
|  |           .filter({ hasText: toName }) | ||||||
|  |           .click() | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     deleteFile: async (name: string) => { | ||||||
|  |       return test?.step(`Delete ${name}`, async () => { | ||||||
|  |         await page | ||||||
|  |           .locator('[data-testid="file-pane-scroll-container"] button') | ||||||
|  |           .filter({ hasText: name }) | ||||||
|  |           .click({ button: 'right' }) | ||||||
|  |         await page.getByTestId('context-menu-delete').click() | ||||||
|  |         await page.getByTestId('delete-confirmation').click() | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|  |  | ||||||
|     panesOpen: async (paneIds: PaneId[]) => { |     panesOpen: async (paneIds: PaneId[]) => { | ||||||
|       return test?.step(`Setting ${paneIds} panes to be open`, async () => { |       return test?.step(`Setting ${paneIds} panes to be open`, async () => { | ||||||
|         await page.addInitScript( |         await page.addInitScript( | ||||||
|  | |||||||
| @ -135,16 +135,15 @@ interface ContextMenuItemProps { | |||||||
|   icon?: ActionIconProps['icon'] |   icon?: ActionIconProps['icon'] | ||||||
|   onClick?: () => void |   onClick?: () => void | ||||||
|   hotkey?: string |   hotkey?: string | ||||||
|  |   'data-testid'?: string | ||||||
| } | } | ||||||
|  |  | ||||||
| export function ContextMenuItem({ | export function ContextMenuItem(props: ContextMenuItemProps) { | ||||||
|   children, |   const { children, icon, onClick, hotkey } = props | ||||||
|   icon, |  | ||||||
|   onClick, |  | ||||||
|   hotkey, |  | ||||||
| }: ContextMenuItemProps) { |  | ||||||
|   return ( |   return ( | ||||||
|     <button |     <button | ||||||
|  |       data-testid={props['data-testid']} | ||||||
|       className="flex items-center gap-2 py-1 px-2 cursor-pointer hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80 border-none text-left" |       className="flex items-center gap-2 py-1 px-2 cursor-pointer hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80 border-none text-left" | ||||||
|       onClick={onClick} |       onClick={onClick} | ||||||
|     > |     > | ||||||
|  | |||||||
| @ -16,7 +16,11 @@ import { | |||||||
| import { useCommandsContext } from 'hooks/useCommandsContext' | import { useCommandsContext } from 'hooks/useCommandsContext' | ||||||
| import { fileMachine } from 'machines/fileMachine' | import { fileMachine } from 'machines/fileMachine' | ||||||
| import { isDesktop } from 'lib/isDesktop' | import { isDesktop } from 'lib/isDesktop' | ||||||
| import { DEFAULT_FILE_NAME, FILE_EXT } from 'lib/constants' | import { | ||||||
|  |   DEFAULT_FILE_NAME, | ||||||
|  |   DEFAULT_PROJECT_KCL_FILE, | ||||||
|  |   FILE_EXT, | ||||||
|  | } from 'lib/constants' | ||||||
| import { getProjectInfo } from 'lib/desktop' | import { getProjectInfo } from 'lib/desktop' | ||||||
| import { getNextDirName, getNextFileName } from 'lib/desktopFS' | import { getNextDirName, getNextFileName } from 'lib/desktopFS' | ||||||
|  |  | ||||||
| @ -167,6 +171,25 @@ export const FileMachineProvider = ({ | |||||||
|           name |           name | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |         // no-op | ||||||
|  |         if (oldPath === newPath) { | ||||||
|  |           return { | ||||||
|  |             message: `Old is the same as new.`, | ||||||
|  |             newPath, | ||||||
|  |             oldPath, | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // if there are any siblings with the same name, report error. | ||||||
|  |         const entries = await window.electron.readdir( | ||||||
|  |           window.electron.path.dirname(newPath) | ||||||
|  |         ) | ||||||
|  |         for (let entry of entries) { | ||||||
|  |           if (entry === newName) { | ||||||
|  |             return Promise.reject(new Error('Filename already exists.')) | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |  | ||||||
|         window.electron.rename(oldPath, newPath) |         window.electron.rename(oldPath, newPath) | ||||||
|  |  | ||||||
|         if (!file) { |         if (!file) { | ||||||
| @ -209,6 +232,22 @@ export const FileMachineProvider = ({ | |||||||
|             .catch((e) => console.error('Error deleting file', e)) |             .catch((e) => console.error('Error deleting file', e)) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         // If there are no more files at all in the project, create a main.kcl | ||||||
|  |         // for when we navigate to the root. | ||||||
|  |         if (!project?.path) { | ||||||
|  |           return Promise.reject(new Error('Project path not set.')) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const entries = await window.electron.readdir(project.path) | ||||||
|  |         const hasKclEntries = | ||||||
|  |           entries.filter((e: string) => e.endsWith('.kcl')).length !== 0 | ||||||
|  |         if (!hasKclEntries) { | ||||||
|  |           await window.electron.writeFile( | ||||||
|  |             window.electron.path.join(project.path, DEFAULT_PROJECT_KCL_FILE), | ||||||
|  |             '' | ||||||
|  |           ) | ||||||
|  |         } | ||||||
|  |  | ||||||
|         // If we just deleted the current file or one of its parent directories, |         // If we just deleted the current file or one of its parent directories, | ||||||
|         // navigate to the project root |         // navigate to the project root | ||||||
|         if ( |         if ( | ||||||
| @ -219,6 +258,11 @@ export const FileMachineProvider = ({ | |||||||
|           navigate(`../${PATHS.FILE}/${encodeURIComponent(project.path)}`) |           navigate(`../${PATHS.FILE}/${encodeURIComponent(project.path)}`) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         // Refresh the route selected above because it's possible we're on | ||||||
|  |         // the same path on the navigate, which doesn't cause anything to | ||||||
|  |         // refresh, leaving a stale execution state. | ||||||
|  |         navigate(0) | ||||||
|  |  | ||||||
|         return `Successfully deleted ${isDir ? 'folder' : 'file'} "${ |         return `Successfully deleted ${isDir ? 'folder' : 'file'} "${ | ||||||
|           event.data.name |           event.data.name | ||||||
|         }"` |         }"` | ||||||
|  | |||||||
| @ -358,10 +358,18 @@ function FileTreeContextMenu({ | |||||||
|     <ContextMenu |     <ContextMenu | ||||||
|       menuTargetElement={itemRef} |       menuTargetElement={itemRef} | ||||||
|       items={[ |       items={[ | ||||||
|         <ContextMenuItem onClick={onRename} hotkey="Enter"> |         <ContextMenuItem | ||||||
|  |           data-testid="context-menu-rename" | ||||||
|  |           onClick={onRename} | ||||||
|  |           hotkey="Enter" | ||||||
|  |         > | ||||||
|           Rename |           Rename | ||||||
|         </ContextMenuItem>, |         </ContextMenuItem>, | ||||||
|         <ContextMenuItem onClick={onDelete} hotkey={metaKey + ' + Del'}> |         <ContextMenuItem | ||||||
|  |           data-testid="context-menu-delete" | ||||||
|  |           onClick={onDelete} | ||||||
|  |           hotkey={metaKey + ' + Del'} | ||||||
|  |         > | ||||||
|           Delete |           Delete | ||||||
|         </ContextMenuItem>, |         </ContextMenuItem>, | ||||||
|       ]} |       ]} | ||||||
|  | |||||||
| @ -8,6 +8,7 @@ export const MAX_PADDING = 7 | |||||||
|  * This is available for users to edit as a setting. |  * This is available for users to edit as a setting. | ||||||
|  */ |  */ | ||||||
| export const DEFAULT_PROJECT_NAME = 'project-$nnn' | export const DEFAULT_PROJECT_NAME = 'project-$nnn' | ||||||
|  | export const DEFAULT_PROJECT_KCL_FILE = 'main.kcl' | ||||||
| /** Name given the temporary "project" in the browser version of the app */ | /** Name given the temporary "project" in the browser version of the app */ | ||||||
| export const BROWSER_PROJECT_NAME = 'browser' | export const BROWSER_PROJECT_NAME = 'browser' | ||||||
| /** Name given the temporary file in the browser version of the app */ | /** Name given the temporary file in the browser version of the app */ | ||||||
|  | |||||||
| @ -90,12 +90,22 @@ export const fileLoader: LoaderFunction = async ( | |||||||
|     let code = '' |     let code = '' | ||||||
|  |  | ||||||
|     if (!urlObj.pathname.endsWith('/settings')) { |     if (!urlObj.pathname.endsWith('/settings')) { | ||||||
|       if (!currentFileName || !currentFilePath || !projectName) { |       const fallbackFile = (await getProjectInfo(projectPath)).default_file | ||||||
|  |       let fileExists = true | ||||||
|  |       if (currentFilePath) { | ||||||
|  |         try { | ||||||
|  |           await window.electron.stat(currentFilePath) | ||||||
|  |         } catch (e) { | ||||||
|  |           if (e === 'ENOENT') { | ||||||
|  |             fileExists = false | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (!fileExists || !currentFileName || !currentFilePath || !projectName) { | ||||||
|         return redirect( |         return redirect( | ||||||
|           `${PATHS.FILE}/${encodeURIComponent( |           `${PATHS.FILE}/${encodeURIComponent( | ||||||
|             isDesktop() |             isDesktop() ? fallbackFile : params.id + '/' + PROJECT_ENTRYPOINT | ||||||
|               ? (await getProjectInfo(projectPath)).default_file |  | ||||||
|               : params.id + '/' + PROJECT_ENTRYPOINT |  | ||||||
|           )}` |           )}` | ||||||
|         ) |         ) | ||||||
|       } |       } | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	