diff --git a/e2e/playwright/file-tree.spec.ts b/e2e/playwright/file-tree.spec.ts index b9b75756a9..8fbdb0a156 100644 --- a/e2e/playwright/file-tree.spec.ts +++ b/e2e/playwright/file-tree.spec.ts @@ -3,6 +3,7 @@ import { test, expect } from './fixtures/fixtureSetup' import * as fsp from 'fs/promises' import * as fs from 'fs' import { + createProject, executorInputPath, getUtils, setup, @@ -114,20 +115,15 @@ test.describe('when using the file tree to', () => { async ({ browser: _, tronApp }, testInfo) => { await tronApp.initialise() - const { - panesOpen, - createAndSelectProject, - pasteCodeInEditor, - renameFile, - editorTextMatches, - } = await getUtils(tronApp.page, test) + const { panesOpen, pasteCodeInEditor, renameFile, editorTextMatches } = + await getUtils(tronApp.page, test) await tronApp.page.setViewportSize({ width: 1200, height: 500 }) tronApp.page.on('console', console.log) await panesOpen(['files', 'code']) - await createAndSelectProject('project-000') + await createProject({ name: 'project-000', page: tronApp.page }) // File the main.kcl with contents const kclCube = await fsp.readFile( @@ -167,15 +163,14 @@ test.describe('when using the file tree to', () => { async ({ browser: _, tronApp }, testInfo) => { await tronApp.initialise() - const { panesOpen, createAndSelectProject, createNewFile } = - await getUtils(tronApp.page, test) + const { panesOpen, createNewFile } = await getUtils(tronApp.page, test) await tronApp.page.setViewportSize({ width: 1200, height: 500 }) tronApp.page.on('console', console.log) await panesOpen(['files']) - await createAndSelectProject('project-000') + await createProject({ name: 'project-000', page: tronApp.page }) await createNewFile('') await createNewFile('') @@ -204,7 +199,6 @@ test.describe('when using the file tree to', () => { const { openKclCodePanel, openFilePanel, - createAndSelectProject, pasteCodeInEditor, createNewFileAndSelect, renameFile, @@ -215,7 +209,7 @@ test.describe('when using the file tree to', () => { await tronApp.page.setViewportSize({ width: 1200, height: 500 }) tronApp.page.on('console', console.log) - await createAndSelectProject('project-000') + await createProject({ name: 'project-000', page: tronApp.page }) await openKclCodePanel() await openFilePanel() // File the main.kcl with contents @@ -263,20 +257,15 @@ test.describe('when using the file tree to', () => { async ({ browser: _, tronApp }, testInfo) => { await tronApp.initialise() - const { - panesOpen, - createAndSelectProject, - pasteCodeInEditor, - deleteFile, - editorTextMatches, - } = await getUtils(tronApp.page, _test) + const { panesOpen, pasteCodeInEditor, deleteFile, editorTextMatches } = + await getUtils(tronApp.page, _test) await tronApp.page.setViewportSize({ width: 1200, height: 500 }) tronApp.page.on('console', console.log) await panesOpen(['files', 'code']) - await createAndSelectProject('project-000') + await createProject({ name: 'project-000', page: tronApp.page }) // File the main.kcl with contents const kclCube = await fsp.readFile( 'src/wasm-lib/tests/executor/inputs/cube.kcl', @@ -306,7 +295,6 @@ test.describe('when using the file tree to', () => { const { panesOpen, - createAndSelectProject, pasteCodeInEditor, createNewFile, openDebugPanel, @@ -318,7 +306,7 @@ test.describe('when using the file tree to', () => { tronApp.page.on('console', console.log) await panesOpen(['files', 'code']) - await createAndSelectProject('project-000') + await createProject({ name: 'project-000', page: tronApp.page }) // Create a small file const kclCube = await fsp.readFile( @@ -722,7 +710,7 @@ _test.describe('Renaming in the file tree', () => { }) await _test.step('Rename the folder', async () => { - await page.waitForTimeout(60000) + await page.waitForTimeout(1000) await folderToRename.click({ button: 'right' }) await _expect(renameMenuItem).toBeVisible() await renameMenuItem.click() diff --git a/e2e/playwright/onboarding-tests.spec.ts b/e2e/playwright/onboarding-tests.spec.ts index 84288213bd..49a4dc3d09 100644 --- a/e2e/playwright/onboarding-tests.spec.ts +++ b/e2e/playwright/onboarding-tests.spec.ts @@ -7,6 +7,7 @@ import { setupElectron, tearDown, executorInputPath, + createProject, } from './test-utils' import { bracket } from 'lib/exampleKcl' import { onboardingPaths } from 'routes/Onboarding/paths' @@ -74,13 +75,8 @@ test.describe('Onboarding tests', () => { const viewportSize = { width: 1200, height: 500 } await page.setViewportSize(viewportSize) - // Locators and constants - const newProjectButton = page.getByRole('button', { name: 'New project' }) - const projectLink = page.getByTestId('project-link') - await test.step(`Create a project and open to the onboarding`, async () => { - await newProjectButton.click() - await projectLink.click() + await createProject({ name: 'project-link', page }) await test.step(`Ensure the engine connection works by testing the sketch button`, async () => { await u.waitForPageLoad() }) diff --git a/e2e/playwright/projects.spec.ts b/e2e/playwright/projects.spec.ts index a27d26faf8..fc1a3c73ba 100644 --- a/e2e/playwright/projects.spec.ts +++ b/e2e/playwright/projects.spec.ts @@ -7,7 +7,7 @@ import { Paths, setupElectron, tearDown, - createProjectAndRenameIt, + createProject, } from './test-utils' import fsp from 'fs/promises' import fs from 'fs' @@ -503,6 +503,245 @@ test( } ) +test.describe(`Project management commands`, () => { + test( + `Rename from project page`, + { tag: '@electron' }, + async ({ browserName }, testInfo) => { + const projectName = `my_project_to_rename` + const { electronApp, page } = await setupElectron({ + testInfo, + folderSetupFn: async (dir) => { + await fsp.mkdir(`${dir}/${projectName}`, { recursive: true }) + await fsp.copyFile( + 'src/wasm-lib/tests/executor/inputs/router-template-slate.kcl', + `${dir}/${projectName}/main.kcl` + ) + }, + }) + const u = await getUtils(page) + + // Constants and locators + const projectHomeLink = page.getByTestId('project-link') + const commandButton = page.getByRole('button', { name: 'Commands' }) + const commandOption = page.getByRole('option', { name: 'rename project' }) + const projectNameOption = page.getByRole('option', { name: projectName }) + const projectRenamedName = `project-000` + // const projectMenuButton = page.getByTestId('project-sidebar-toggle') + const commandContinueButton = page.getByRole('button', { + name: 'Continue', + }) + const commandSubmitButton = page.getByRole('button', { + name: 'Submit command', + }) + const toastMessage = page.getByText(`Successfully renamed`) + + await test.step(`Setup`, async () => { + await page.setViewportSize({ width: 1200, height: 500 }) + page.on('console', console.log) + + await projectHomeLink.click() + await u.waitForPageLoad() + }) + + await test.step(`Run rename command via command palette`, async () => { + await commandButton.click() + await commandOption.click() + await projectNameOption.click() + + await expect(commandContinueButton).toBeVisible() + await commandContinueButton.click() + + await expect(commandSubmitButton).toBeVisible() + await commandSubmitButton.click() + + await expect(toastMessage).toBeVisible() + }) + + // TODO: in future I'd like the behavior to be to + // navigate to the new project's page directly, + // see ProjectContextProvider.tsx:158 + await test.step(`Check the project was renamed and we navigated home`, async () => { + await expect(projectHomeLink.first()).toBeVisible() + await expect(projectHomeLink.first()).toContainText(projectRenamedName) + }) + + await electronApp.close() + } + ) + + test( + `Delete from project page`, + { tag: '@electron' }, + async ({ browserName: _ }, testInfo) => { + const projectName = `my_project_to_delete` + const { electronApp, page } = await setupElectron({ + testInfo, + folderSetupFn: async (dir) => { + await fsp.mkdir(`${dir}/${projectName}`, { recursive: true }) + await fsp.copyFile( + 'src/wasm-lib/tests/executor/inputs/router-template-slate.kcl', + `${dir}/${projectName}/main.kcl` + ) + }, + }) + const u = await getUtils(page) + + // Constants and locators + const projectHomeLink = page.getByTestId('project-link') + const commandButton = page.getByRole('button', { name: 'Commands' }) + const commandOption = page.getByRole('option', { name: 'delete project' }) + const projectNameOption = page.getByRole('option', { name: projectName }) + const commandWarning = page.getByText('Are you sure you want to delete?') + const commandSubmitButton = page.getByRole('button', { + name: 'Submit command', + }) + const toastMessage = page.getByText(`Successfully deleted`) + const noProjectsMessage = page.getByText('No Projects found') + + await test.step(`Setup`, async () => { + await page.setViewportSize({ width: 1200, height: 500 }) + page.on('console', console.log) + + await projectHomeLink.click() + await u.waitForPageLoad() + }) + + await test.step(`Run delete command via command palette`, async () => { + await commandButton.click() + await commandOption.click() + await projectNameOption.click() + + await expect(commandWarning).toBeVisible() + await expect(commandSubmitButton).toBeVisible() + await commandSubmitButton.click() + + await expect(toastMessage).toBeVisible() + }) + + await test.step(`Check the project was deleted and we navigated home`, async () => { + await expect(noProjectsMessage).toBeVisible() + }) + + await electronApp.close() + } + ) + test( + `Rename from home page`, + { tag: '@electron' }, + async ({ browserName: _ }, testInfo) => { + const projectName = `my_project_to_rename` + const { electronApp, page } = await setupElectron({ + testInfo, + folderSetupFn: async (dir) => { + await fsp.mkdir(`${dir}/${projectName}`, { recursive: true }) + await fsp.copyFile( + 'src/wasm-lib/tests/executor/inputs/router-template-slate.kcl', + `${dir}/${projectName}/main.kcl` + ) + }, + }) + + // Constants and locators + const projectHomeLink = page.getByTestId('project-link') + const commandButton = page.getByRole('button', { name: 'Commands' }) + const commandOption = page.getByRole('option', { name: 'rename project' }) + const projectNameOption = page.getByRole('option', { name: projectName }) + const projectRenamedName = `project-000` + const commandContinueButton = page.getByRole('button', { + name: 'Continue', + }) + const commandSubmitButton = page.getByRole('button', { + name: 'Submit command', + }) + const toastMessage = page.getByText(`Successfully renamed`) + + await test.step(`Setup`, async () => { + await page.setViewportSize({ width: 1200, height: 500 }) + page.on('console', console.log) + await expect(projectHomeLink).toBeVisible() + }) + + await test.step(`Run rename command via command palette`, async () => { + await commandButton.click() + await commandOption.click() + await projectNameOption.click() + + await expect(commandContinueButton).toBeVisible() + await commandContinueButton.click() + + await expect(commandSubmitButton).toBeVisible() + await commandSubmitButton.click() + + await expect(toastMessage).toBeVisible() + }) + + await test.step(`Check the project was renamed`, async () => { + await expect( + page.getByRole('link', { name: projectRenamedName }) + ).toBeVisible() + await expect(projectHomeLink).not.toHaveText(projectName) + }) + + await electronApp.close() + } + ) + test( + `Delete from home page`, + { tag: '@electron' }, + async ({ browserName: _ }, testInfo) => { + const projectName = `my_project_to_delete` + const { electronApp, page } = await setupElectron({ + testInfo, + folderSetupFn: async (dir) => { + await fsp.mkdir(`${dir}/${projectName}`, { recursive: true }) + await fsp.copyFile( + 'src/wasm-lib/tests/executor/inputs/router-template-slate.kcl', + `${dir}/${projectName}/main.kcl` + ) + }, + }) + + // Constants and locators + const projectHomeLink = page.getByTestId('project-link') + const commandButton = page.getByRole('button', { name: 'Commands' }) + const commandOption = page.getByRole('option', { name: 'delete project' }) + const projectNameOption = page.getByRole('option', { name: projectName }) + const commandWarning = page.getByText('Are you sure you want to delete?') + const commandSubmitButton = page.getByRole('button', { + name: 'Submit command', + }) + const toastMessage = page.getByText(`Successfully deleted`) + const noProjectsMessage = page.getByText('No Projects found') + + await test.step(`Setup`, async () => { + await page.setViewportSize({ width: 1200, height: 500 }) + page.on('console', console.log) + await expect(projectHomeLink).toBeVisible() + }) + + await test.step(`Run delete command via command palette`, async () => { + await commandButton.click() + await commandOption.click() + await projectNameOption.click() + + await expect(commandWarning).toBeVisible() + await expect(commandSubmitButton).toBeVisible() + await commandSubmitButton.click() + + await expect(toastMessage).toBeVisible() + }) + + await test.step(`Check the project was deleted`, async () => { + await expect(projectHomeLink).not.toBeVisible() + await expect(noProjectsMessage).toBeVisible() + }) + + await electronApp.close() + } + ) +}) + test( 'File in the file pane should open with a single click', { tag: '@electron' }, @@ -643,7 +882,7 @@ test( page.on('console', console.log) await test.step('delete the middle project, i.e. the bracket project', async () => { - const project = page.getByText('bracket') + const project = page.getByTestId('project-link').getByText('bracket') await project.hover() await project.focus() @@ -687,10 +926,10 @@ test( }) await test.step('Check we can still create a project', async () => { - await page.getByRole('button', { name: 'New project' }).click() - await expect(page.getByText('Successfully created')).toBeVisible() - await expect(page.getByText('Successfully created')).not.toBeVisible() - await expect(page.getByText('project-000')).toBeVisible() + await createProject({ name: 'project-000', page, returnHome: true }) + await expect( + page.getByTestId('project-link').filter({ hasText: 'project-000' }) + ).toBeVisible() }) await electronApp.close() @@ -867,17 +1106,16 @@ test.fixme( const pointOnModel = { x: 660, y: 250 } const expectedStartCamZPosition = 15633.47 + // Constants and locators + const projectLinks = page.getByTestId('project-link') + // expect to see text "No Projects found" await expect(page.getByText('No Projects found')).toBeVisible() - await page.getByRole('button', { name: 'New project' }).click() - - await expect(page.getByText('Successfully created')).toBeVisible() - await expect(page.getByText('Successfully created')).not.toBeVisible() + await createProject({ name: 'project-000', page, returnHome: true }) + await expect(projectLinks.getByText('project-000')).toBeVisible() - await expect(page.getByText('project-000')).toBeVisible() - - await page.getByText('project-000').click() + await projectLinks.getByText('project-000').click() await u.waitForPageLoad() @@ -936,16 +1174,10 @@ extrude001 = extrude(200, sketch001)`) page.getByRole('button', { name: 'New project' }) ).toBeVisible() - const createProject = async (projectNum: number) => { - await page.getByRole('button', { name: 'New project' }).click() - await expect(page.getByText('Successfully created')).toBeVisible() - await expect(page.getByText('Successfully created')).not.toBeVisible() - - const projectNumStr = projectNum.toString().padStart(3, '0') - await expect(page.getByText(`project-${projectNumStr}`)).toBeVisible() - } for (let i = 1; i <= 10; i++) { - await createProject(i) + const name = `project-${i.toString().padStart(3, '0')}` + await createProject({ name, page, returnHome: true }) + await expect(projectLinks.getByText(name)).toBeVisible() } await electronApp.close() } @@ -1120,11 +1352,10 @@ test( await page.getByTestId('settings-close-button').click() await expect(page.getByText('No Projects found')).toBeVisible() - await page.getByRole('button', { name: 'New project' }).click() - await expect(page.getByText('Successfully created')).toBeVisible() - await expect(page.getByText('Successfully created')).not.toBeVisible() - - await expect(page.getByText(`project-000`)).toBeVisible() + await createProject({ name: 'project-000', page, returnHome: true }) + await expect( + page.getByTestId('project-link').filter({ hasText: 'project-000' }) + ).toBeVisible() }) await test.step('We can change back to the original root project directory', async () => { @@ -1450,7 +1681,7 @@ test( page.on('console', console.log) await test.step('Should create and name a project called wrist brace', async () => { - await createProjectAndRenameIt({ name: 'wrist brace', page }) + await createProject({ name: 'wrist brace', page, returnHome: true }) }) await test.step('Should go through onboarding', async () => { diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png index fb5dd4aac5..78e1c50726 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-win32.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-win32.png index fd6d67c8b3..5ff8e179df 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-win32.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-win32.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-linux.png index 2b075f3000..fe1a02df5c 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-win32.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-win32.png index 7753b12fc3..7e774fc588 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-win32.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-win32.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-2-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-2-Google-Chrome-linux.png index 5b8396b93b..1ea5160b96 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-2-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-2-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-circle-should-look-right-1-Google-Chrome-win32.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-circle-should-look-right-1-Google-Chrome-win32.png index d302db830e..1a21406c02 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-circle-should-look-right-1-Google-Chrome-win32.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-circle-should-look-right-1-Google-Chrome-win32.png differ diff --git a/e2e/playwright/test-utils.ts b/e2e/playwright/test-utils.ts index c739f88fb5..e74f4c1924 100644 --- a/e2e/playwright/test-utils.ts +++ b/e2e/playwright/test-utils.ts @@ -467,20 +467,6 @@ export async function getUtils(page: Page, test_?: typeof test) { return text.replace(/\s+/g, '') }, - createAndSelectProject: async (hasText: string) => { - return test_?.step( - `Create and select project with text "${hasText}"`, - async () => { - // Without this, we get unreliable project creation. It's probably - // due to a race between the FS being read and clicking doing something. - await page.waitForTimeout(100) - await page.getByTestId('home-new-file').click() - const projectLinksPost = page.getByTestId('project-link') - await projectLinksPost.filter({ hasText }).click() - } - ) - }, - editorTextMatches: async (code: string) => { const editor = page.locator(editorSelector) return expect(editor).toHaveText(code, { useInnerText: true }) @@ -980,30 +966,25 @@ export async function isOutOfViewInScrollContainer( return isOutOfView } -export async function createProjectAndRenameIt({ +export async function createProject({ name, page, + returnHome = false, }: { name: string page: Page + returnHome?: boolean }) { - await page.getByRole('button', { name: 'New project' }).click() - await expect(page.getByText('Successfully created')).toBeVisible() - await expect(page.getByText('Successfully created')).not.toBeVisible() - - await expect(page.getByText(`project-000`)).toBeVisible() - await page.getByText(`project-000`).hover() - await page.getByText(`project-000`).focus() - - await page.getByLabel('sketch').first().click() - - await page.waitForTimeout(100) - - // type the name passed in - await page.keyboard.press('Backspace') - await page.keyboard.type(name) - - await page.getByLabel('checkmark').last().click() + await test.step(`Create project and navigate to it`, async () => { + await page.getByRole('button', { name: 'New project' }).click() + await page.getByRole('textbox', { name: 'Name' }).fill(name) + await page.getByRole('button', { name: 'Continue' }).click() + + if (returnHome) { + await page.waitForURL('**/file/**', { waitUntil: 'domcontentloaded' }) + await page.getByTestId('app-logo').click() + } + }) } export function executorInputPath(fileName: string): string { diff --git a/e2e/playwright/testing-settings.spec.ts b/e2e/playwright/testing-settings.spec.ts index c2675948c7..0ddd16fbe8 100644 --- a/e2e/playwright/testing-settings.spec.ts +++ b/e2e/playwright/testing-settings.spec.ts @@ -7,6 +7,7 @@ import { setupElectron, tearDown, executorInputPath, + createProject, } from './test-utils' import { SaveSettingsPayload, SettingsLevel } from 'lib/settings/settingsTypes' import { SETTINGS_FILE_NAME, PROJECT_SETTINGS_FILE_NAME } from 'lib/constants' @@ -428,8 +429,7 @@ test.describe('Testing settings', () => { }) await test.step('Check color of logo changed when in modeling view', async () => { - await page.getByRole('button', { name: 'New project' }).click() - await page.getByTestId('project-link').first().click() + await createProject({ name: 'project-000', page }) await changeColor('58') await expect(logoLink).toHaveCSS('--primary-hue', '58') }) @@ -447,7 +447,7 @@ test.describe('Testing settings', () => { test( 'project settings reload on external change', { tag: '@electron' }, - async ({ browserName }, testInfo) => { + async ({ browserName: _ }, testInfo) => { const { electronApp, page, @@ -465,11 +465,7 @@ test.describe('Testing settings', () => { await expect(projectDirLink).toBeVisible() }) - const projectLinks = page.getByTestId('project-link') - const oldCount = await projectLinks.count() - await page.getByRole('button', { name: 'New project' }).click() - await expect(projectLinks).toHaveCount(oldCount + 1) - await projectLinks.filter({ hasText: 'project-000' }).first().click() + await createProject({ name: 'project-000', page }) const changeColorFs = async (color: string) => { const tempSettingsFilePath = join( diff --git a/e2e/playwright/text-to-cad-tests.spec.ts b/e2e/playwright/text-to-cad-tests.spec.ts index af14f7cd9a..c4808234e5 100644 --- a/e2e/playwright/text-to-cad-tests.spec.ts +++ b/e2e/playwright/text-to-cad-tests.spec.ts @@ -1,5 +1,11 @@ import { test, expect, Page } from '@playwright/test' -import { getUtils, setup, tearDown, setupElectron } from './test-utils' +import { + getUtils, + setup, + tearDown, + setupElectron, + createProject, +} from './test-utils' import { join } from 'path' import fs from 'fs' @@ -700,12 +706,10 @@ test( const fileExists = () => fs.existsSync(join(dir, projectName, textToCadFileName)) - const { - createAndSelectProject, - openFilePanel, - openKclCodePanel, - waitForPageLoad, - } = await getUtils(page, test) + const { openFilePanel, openKclCodePanel, waitForPageLoad } = await getUtils( + page, + test + ) await page.setViewportSize({ width: 1200, height: 500 }) @@ -719,7 +723,7 @@ test( ) // Create and navigate to the project - await createAndSelectProject('project-000') + await createProject({ name: 'project-000', page }) // Wait for Start Sketch otherwise you will not have access Text-to-CAD command await waitForPageLoad() diff --git a/src/Router.tsx b/src/Router.tsx index 665e1bda6b..d135cfc76b 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -43,6 +43,7 @@ import { coreDump } from 'lang/wasm' import { useMemo } from 'react' import { AppStateProvider } from 'AppState' import { reportRejection } from 'lib/trap' +import { ProjectsContextProvider } from 'components/ProjectsContextProvider' const createRouter = isDesktop() ? createHashRouter : createBrowserRouter @@ -57,13 +58,15 @@ const router = createRouter([ - - - - - - - + + + + + + + + + diff --git a/src/components/ProjectsContextProvider.tsx b/src/components/ProjectsContextProvider.tsx new file mode 100644 index 0000000000..21f672f88d --- /dev/null +++ b/src/components/ProjectsContextProvider.tsx @@ -0,0 +1,289 @@ +import { useMachine } from '@xstate/react' +import { useCommandsContext } from 'hooks/useCommandsContext' +import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher' +import { useProjectsLoader } from 'hooks/useProjectsLoader' +import { projectsMachine } from 'machines/projectsMachine' +import { createContext, useEffect, useState } from 'react' +import { Actor, AnyStateMachine, fromPromise, Prop, StateFrom } from 'xstate' +import { useLspContext } from './LspProvider' +import toast from 'react-hot-toast' +import { useLocation, useNavigate } from 'react-router-dom' +import { PATHS } from 'lib/paths' +import { + createNewProjectDirectory, + listProjects, + renameProjectDirectory, +} from 'lib/desktop' +import { + getNextProjectIndex, + interpolateProjectNameWithIndex, + doesProjectNameNeedInterpolated, +} from 'lib/desktopFS' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' +import useStateMachineCommands from 'hooks/useStateMachineCommands' +import { projectsCommandBarConfig } from 'lib/commandBarConfigs/projectsCommandConfig' +import { isDesktop } from 'lib/isDesktop' + +type MachineContext = { + state?: StateFrom + send: Prop, 'send'> +} + +export const ProjectsMachineContext = createContext( + {} as MachineContext +) + +/** + * Watches the project directory and provides project management-related commands, + * like "Create project", "Open project", "Delete project", etc. + * + * If in the future we implement full-fledge project management in the web version, + * we can unify these components but for now, we need this to be only for the desktop version. + */ +export const ProjectsContextProvider = ({ + children, +}: { + children: React.ReactNode +}) => { + return isDesktop() ? ( + {children} + ) : ( + {children} + ) +} + +const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => { + return ( + {}, + }} + > + {children} + + ) +} + +const ProjectsContextDesktop = ({ + children, +}: { + children: React.ReactNode +}) => { + const navigate = useNavigate() + const location = useLocation() + const { commandBarSend } = useCommandsContext() + const { onProjectOpen } = useLspContext() + const { + settings: { context: settings }, + } = useSettingsAuthContext() + + useEffect(() => { + console.log( + 'project directory changed', + settings.app.projectDirectory.current + ) + }, [settings.app.projectDirectory.current]) + + const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0) + const { projectPaths, projectsDir } = useProjectsLoader([ + projectsLoaderTrigger, + ]) + + // Re-read projects listing if the projectDir has any updates. + useFileSystemWatcher( + async () => { + return setProjectsLoaderTrigger(projectsLoaderTrigger + 1) + }, + projectsDir ? [projectsDir] : [] + ) + + const [state, send, actor] = useMachine( + projectsMachine.provide({ + actions: { + navigateToProject: ({ context, event }) => { + const nameFromEventData = + 'data' in event && + event.data && + 'name' in event.data && + event.data.name + const nameFromOutputData = + 'output' in event && + event.output && + 'name' in event.output && + event.output.name + + const name = nameFromEventData || nameFromOutputData + + if (name) { + let projectPath = + context.defaultDirectory + window.electron.path.sep + name + onProjectOpen( + { + name, + path: projectPath, + }, + null + ) + commandBarSend({ type: 'Close' }) + const newPathName = `${PATHS.FILE}/${encodeURIComponent( + projectPath + )}` + navigate(newPathName) + } + }, + navigateToProjectIfNeeded: ({ event }) => { + if ( + event.type.startsWith('xstate.done.actor.') && + 'output' in event + ) { + const isInAProject = location.pathname.startsWith(PATHS.FILE) + const isInDeletedProject = + event.type === 'xstate.done.actor.delete-project' && + isInAProject && + decodeURIComponent(location.pathname).includes(event.output.name) + if (isInDeletedProject) { + navigate(PATHS.HOME) + return + } + + const isInRenamedProject = + event.type === 'xstate.done.actor.rename-project' && + isInAProject && + decodeURIComponent(location.pathname).includes( + event.output.oldName + ) + + if (isInRenamedProject) { + // TODO: In future, we can navigate to the new project path + // directly, but we need to coordinate with + // @lf94's useFileSystemWatcher in SettingsAuthProvider.tsx:224 + // Because it's beating us to the punch and updating the route + // const newPathName = location.pathname.replace( + // encodeURIComponent(event.output.oldName), + // encodeURIComponent(event.output.newName) + // ) + // navigate(newPathName) + return + } + } + }, + toastSuccess: ({ event }) => + toast.success( + ('data' in event && typeof event.data === 'string' && event.data) || + ('output' in event && + 'message' in event.output && + typeof event.output.message === 'string' && + event.output.message) || + '' + ), + toastError: ({ event }) => + toast.error( + ('data' in event && typeof event.data === 'string' && event.data) || + ('output' in event && + typeof event.output === 'string' && + event.output) || + '' + ), + }, + actors: { + readProjects: fromPromise(() => listProjects()), + createProject: fromPromise(async ({ input }) => { + let name = ( + input && 'name' in input && input.name + ? input.name + : settings.projects.defaultProjectName.current + ).trim() + + if (doesProjectNameNeedInterpolated(name)) { + const nextIndex = getNextProjectIndex(name, input.projects) + name = interpolateProjectNameWithIndex(name, nextIndex) + } + + await createNewProjectDirectory(name) + + return { + message: `Successfully created "${name}"`, + name, + } + }), + renameProject: fromPromise(async ({ input }) => { + const { + oldName, + newName, + defaultProjectName, + defaultDirectory, + projects, + } = input + let name = newName ? newName : defaultProjectName + if (doesProjectNameNeedInterpolated(name)) { + const nextIndex = getNextProjectIndex(name, projects) + name = interpolateProjectNameWithIndex(name, nextIndex) + } + + console.log('from Project') + + await renameProjectDirectory( + window.electron.path.join(defaultDirectory, oldName), + name + ) + return { + message: `Successfully renamed "${oldName}" to "${name}"`, + oldName: oldName, + newName: name, + } + }), + deleteProject: fromPromise(async ({ input }) => { + await window.electron.rm( + window.electron.path.join(input.defaultDirectory, input.name), + { + recursive: true, + } + ) + return { + message: `Successfully deleted "${input.name}"`, + name: input.name, + } + }), + }, + guards: { + 'Has at least 1 project': ({ event }) => { + if (event.type !== 'xstate.done.actor.read-projects') return false + console.log(`from has at least 1 project: ${event.output.length}`) + return event.output.length ? event.output.length >= 1 : false + }, + }, + }), + { + input: { + projects: projectPaths, + defaultProjectName: settings.projects.defaultProjectName.current, + defaultDirectory: settings.app.projectDirectory.current, + }, + } + ) + + useEffect(() => { + send({ type: 'Read projects', data: {} }) + }, [projectPaths]) + + // register all project-related command palette commands + useStateMachineCommands({ + machineId: 'projects', + send, + state, + commandBarConfig: projectsCommandBarConfig, + actor, + }) + + return ( + + {children} + + ) +} diff --git a/src/hooks/useProjectsContext.ts b/src/hooks/useProjectsContext.ts new file mode 100644 index 0000000000..2cc3551beb --- /dev/null +++ b/src/hooks/useProjectsContext.ts @@ -0,0 +1,6 @@ +import { ProjectsMachineContext } from 'components/ProjectsContextProvider' +import { useContext } from 'react' + +export const useProjectsContext = () => { + return useContext(ProjectsMachineContext) +} diff --git a/src/hooks/useStateMachineCommands.ts b/src/hooks/useStateMachineCommands.ts index 14adeb640a..927def5bee 100644 --- a/src/hooks/useStateMachineCommands.ts +++ b/src/hooks/useStateMachineCommands.ts @@ -5,7 +5,7 @@ import { useCommandsContext } from './useCommandsContext' import { modelingMachine } from 'machines/modelingMachine' import { authMachine } from 'machines/authMachine' import { settingsMachine } from 'machines/settingsMachine' -import { homeMachine } from 'machines/homeMachine' +import { projectsMachine } from 'machines/projectsMachine' import { Command, StateMachineCommandSetConfig, @@ -22,7 +22,7 @@ export type AllMachines = | typeof modelingMachine | typeof settingsMachine | typeof authMachine - | typeof homeMachine + | typeof projectsMachine interface UseStateMachineCommandsArgs< T extends AllMachines, diff --git a/src/lib/commandBarConfigs/homeCommandConfig.ts b/src/lib/commandBarConfigs/projectsCommandConfig.ts similarity index 76% rename from src/lib/commandBarConfigs/homeCommandConfig.ts rename to src/lib/commandBarConfigs/projectsCommandConfig.ts index 4ca29bbdcd..deec0a8bdd 100644 --- a/src/lib/commandBarConfigs/homeCommandConfig.ts +++ b/src/lib/commandBarConfigs/projectsCommandConfig.ts @@ -1,7 +1,8 @@ +import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning' import { StateMachineCommandSetConfig } from 'lib/commandTypes' -import { homeMachine } from 'machines/homeMachine' +import { projectsMachine } from 'machines/projectsMachine' -export type HomeCommandSchema = { +export type ProjectsCommandSchema = { 'Read projects': {} 'Create project': { name: string @@ -18,9 +19,9 @@ export type HomeCommandSchema = { } } -export const homeCommandBarConfig: StateMachineCommandSetConfig< - typeof homeMachine, - HomeCommandSchema +export const projectsCommandBarConfig: StateMachineCommandSetConfig< + typeof projectsMachine, + ProjectsCommandSchema > = { 'Open project': { icon: 'arrowRight', @@ -53,6 +54,11 @@ export const homeCommandBarConfig: StateMachineCommandSetConfig< icon: 'close', description: 'Delete a project', needsReview: true, + reviewMessage: ({ argumentsToSubmit }) => + CommandBarOverwriteWarning({ + heading: 'Are you sure you want to delete?', + message: `This will permanently delete the project "${argumentsToSubmit.name}" and all its contents.`, + }), args: { name: { inputType: 'options', diff --git a/src/lib/createMachineCommand.ts b/src/lib/createMachineCommand.ts index 8903f2b5f7..0c4d89a5f0 100644 --- a/src/lib/createMachineCommand.ts +++ b/src/lib/createMachineCommand.ts @@ -111,6 +111,9 @@ export function createMachineCommand< if ('displayName' in commandConfig) { command.displayName = commandConfig.displayName } + if ('reviewMessage' in commandConfig) { + command.reviewMessage = commandConfig.reviewMessage + } return command } diff --git a/src/machines/homeMachine.ts b/src/machines/projectsMachine.ts similarity index 57% rename from src/machines/homeMachine.ts rename to src/machines/projectsMachine.ts index ca9a50e81d..322e9c0f6c 100644 --- a/src/machines/homeMachine.ts +++ b/src/machines/projectsMachine.ts @@ -1,8 +1,9 @@ import { assign, fromPromise, setup } from 'xstate' -import { HomeCommandSchema } from 'lib/commandBarConfigs/homeCommandConfig' +import { ProjectsCommandSchema } from 'lib/commandBarConfigs/projectsCommandConfig' import { Project } from 'lib/project' +import { isArray } from 'lib/utils' -export const homeMachine = setup({ +export const projectsMachine = setup({ types: { context: {} as { projects: Project[] @@ -11,15 +12,36 @@ export const homeMachine = setup({ }, events: {} as | { type: 'Read projects'; data: {} } - | { type: 'Open project'; data: HomeCommandSchema['Open project'] } - | { type: 'Rename project'; data: HomeCommandSchema['Rename project'] } - | { type: 'Create project'; data: HomeCommandSchema['Create project'] } - | { type: 'Delete project'; data: HomeCommandSchema['Delete project'] } + | { type: 'Open project'; data: ProjectsCommandSchema['Open project'] } + | { + type: 'Rename project' + data: ProjectsCommandSchema['Rename project'] + } + | { + type: 'Create project' + data: ProjectsCommandSchema['Create project'] + } + | { + type: 'Delete project' + data: ProjectsCommandSchema['Delete project'] + } | { type: 'navigate'; data: { name: string } } | { type: 'xstate.done.actor.read-projects' output: Project[] } + | { + type: 'xstate.done.actor.delete-project' + output: { message: string; name: string } + } + | { + type: 'xstate.done.actor.create-project' + output: { message: string; name: string } + } + | { + type: 'xstate.done.actor.rename-project' + output: { message: string; oldName: string; newName: string } + } | { type: 'assign'; data: { [key: string]: any } }, input: {} as { projects: Project[] @@ -30,16 +52,20 @@ export const homeMachine = setup({ actions: { setProjects: assign({ projects: ({ context, event }) => - 'output' in event ? event.output : context.projects, + 'output' in event && isArray(event.output) + ? event.output + : context.projects, }), toastSuccess: () => {}, toastError: () => {}, navigateToProject: () => {}, + navigateToProjectIfNeeded: () => {}, }, actors: { readProjects: fromPromise(() => Promise.resolve([] as Project[])), - createProject: fromPromise((_: { input: { name: string } }) => - Promise.resolve('') + createProject: fromPromise( + (_: { input: { name: string; projects: Project[] } }) => + Promise.resolve({ message: '' }) ), renameProject: fromPromise( (_: { @@ -48,28 +74,35 @@ export const homeMachine = setup({ newName: string defaultProjectName: string defaultDirectory: string + projects: Project[] } - }) => Promise.resolve('') + }) => + Promise.resolve({ + message: '', + oldName: '', + newName: '', + }) ), deleteProject: fromPromise( (_: { input: { defaultDirectory: string; name: string } }) => - Promise.resolve('') + Promise.resolve({ + message: '', + name: '', + }) ), }, guards: { 'Has at least 1 project': () => false, }, }).createMachine({ - /** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAHTK6xampYAOATqgFZj4AusAxAMLMwuLthbtOXANoAGALqJQjVLGJdiqUopAAPRAHYAbPooAWABwBGUwE5zAJgeGArM-MAaEAE9EN0wGYKGX97GX1nGVNDS0MbfwBfeM80TBwCEnIqGiZWDm4+ACUwUlxU8TzpeW1lVXVNbT0EcJNg02d-fzt7fU77Tx8EQ0iKCPtnfUsjGRtLGXtE5IxsPCIySmpacsk+QWFRHIluWQUkEBq1DS1TxqN7ChjzOxtXf0t7a37EcwsRibH-ZzRezA8wLEApZbpNZZTa5ba8AAiYAANmB9lsjlVTuc6ldQDdDOYKP5bm0os5TDJDJ8mlEzPpzIZHA4bO9umCIWlVpkNgcKnwAPKMYp8yTHaoqC71a6IEmBUz6BkWZzWDq2Uw0qzOIJAwz+PXWfSmeZJcFLLkZSi7ERkKCi7i8CCaShkABuqAA1pR8EIRGAALQYyonJSS3ENRDA2wUeyvd6dPVhGw0-RhGOp8IA8xGFkc80rS0Ua3qUh2oO8MDMVjMCiMZEiABmqGY6AoPr2AaD4uxYcuEYQoQpQWNNjsMnMgLGKbT3TC7TcOfsNjzqQL0KKJXQtvtXEdzoobs9lCEm87cMxIbOvel+MQqtMQRmS5ks31sZpAUsZkcIX+cQZJIrpC3KUBupTbuWlbVrW9ZcE2LYUCepRnocwYSrUfYyggbzvBQ+jMq49imLYwTUt4iCft+5i-u0-7UfoQEWtCSKoiWZbnruTqZIeXoUBAKJoihFTdqGGE3rod7UdqsQTI8hiGAqrIauRA7RvYeoqhO1jtAqjFrpkLFohBHEVlWzYwY2zatvxrFCWKWKiVKeISdh4yBJE-jGs4fhhA4zg0kRNgxhplhaW0nn4XpUKZEUuAQMZqF8FxLqkO6vG+hAgYcbAIlXmJzmNERdy0RYNiKgpthxDSEU6q8MSTJYjWGFFIEULF8WljuSX7jxx7CJlQY5ZYl44pht4IP61gyPc8njt0lIuH51UKrVVITEyMy2C1hbtQl-KmdBdaWQhGVZYluWjeJjSTf402shMEyuEyljPAFL0UNmMiuN86lWHMiSmvQ-HwKcnL6WA6FOf2k3mESMRDA4RpUm4U4qf6gSEt0QIvvqfjOCaiyrtF6zZPQXWQ+GWFlUEsbmNMf1TV9NLeXDcqRIySnNaaYPEzC5M9vl-b+IyFCjupryPF9jKWP5Kks-cbMWLERHRNt0LFntkgU2NLk4dqsz43YsTK++Kk2C+MbTOOcxzOMrhqzFxTgZ1Qba1dd6BUE1jGsLMxxK9KlDNqm3tMLUQvqYlgO5QhlsTubsFXesTTUuPTfHExshDS0RftRftGgEnTZtHbX9Zr+QJ-2S4Y3qnmTC+4tMyp1EfeOnmeQqdOhyXQrFOXXCV1hCkmLDOnBJYvRRDSsyRzGjiKj0lKdAkANAA */ + /** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAMS6yzFSkDaADALqKgAOqtALsaqXYgAHogAsAJgA0IAJ6IAjBIkA2AHQAOCUw0qNkjQE4xYjQF8zMtJhwES5NcmpZSqLBwBOqAFZh8PWAoAJTBcCHcvX39YZjYkEC5efkF40QQFFQBmAHY1Qwz9QwBWU0NMrRl5BGyxBTUVJlVM01rtBXNLEGtsPCIyMEdnVwifPwCKAGEPUJ5sT1H-WKFE4j4BITSMzMzNIsyJDSZMhSYmFWzKxAkTNSYxIuU9zOKT7IsrDB67fsHYEajxiEwv8xjFWMtuKtkhsrpJboZsioincFMdTIjLggVGJ1GImMVMg0JISjEV3l1PrY+g4nH95gDAiFSLgbPSxkt4is1ilQGlrhJ4YjkbU0RoMXJEIYkWoikV8hJinjdOdyd0qfYBrSQdFJtNcLNtTwOZxIdyYQh+YKkSjReKqqYBQoEQpsgiSi9MqrKb0Nb9DYEACJgAA2YANbMW4M5puhqVhAvxQptCnRKkxCleuw0Gm2+2aRVRXpsPp+Woj4wA8hwwKRDcaEjH1nGLXDE9aRSmxWmJVipWocmdsbL8kxC501SWHFMZmQoIaKBABAMyAA3VAAawG+D1swAtOX61zY7zENlEWoMkoatcdPoipjCWIZRIirozsPiYYi19qQNp-rZ3nMAPC8Dw1A4YN9QAM1QDx0DUbcZjAfdInZKMTSSJsT2qc9Lwka8lTvTFTB2WUMiyQwc3yPRv3VH4mRZQDywXJc1FXDcBmmZlMBQhYjXQhtMJ5ERT1wlQr0kQj7kxKiZTxM5s2zbQEVoycBgY9AmNQ-wKGA0DwMgngYLgtQuJZZCDwEo8sJEnD1Dwgjb2kntig0GUkWVfZDEMfFVO+Bwg1DPhSDnZjFwcdjNzUCAQzDCztP4uIMKhGy0jPezxPwySnPvHsTjlPIhyOHRsnwlM-N-NRArDLS+N0kDYIM6DYPgmKgvivjD0bYS+VbBF21RTs7UUUcimfRpXyYRF8LFCrfSBCBaoZFiItINcor1CBeIZLqhPNdKL0yxzs2cqoNDqdpSo0EoSoLOb6NCRaQv9FblzWjjTMe7bQQYBQksElKesUMRjFubRMllCHsm2a5MVldRDBfFpmj0IpxPuhwFqW0F6v0iDmpMzbvuiXbAfNFNQcaI5IaKaH9jETFsUMNRVDxOU5TxBULE6VwYvgeIJ38sAIT25td27KpdzG7yZeho5slHVmMc1IY3HLfnkrNZt2hOW5smRCQM2uhQHkZ6VtkRBFnmu0qFGVv11ZFsnm0kdN8QFJVjlUPZ9co+3-2C0KEqdrXsJMJ8ryc86iUyYiCvxFNzldb3jHtjTsf8EPj1ssQchZlR8jR5EmDRrIGZcuF7maF1aZtslx29IWqtiwPDSz1LxDxepnlOfYDghp03eyOpngzXuYdHNPHozgJ26B9IXR2cTvP0BVkSRXKqgLtyq8OfQcXOwlubMIA */ id: 'Home machine', initial: 'Reading projects', - context: { - projects: [], - defaultProjectName: '', - defaultDirectory: '', - }, + context: ({ input }) => ({ + ...input, + }), on: { assign: { @@ -110,7 +143,9 @@ export const homeMachine = setup({ }, 'Open project': { - target: 'Opening project', + target: 'Reading projects', + actions: 'navigateToProject', + reenter: true, }, }, }, @@ -119,20 +154,22 @@ export const homeMachine = setup({ invoke: { id: 'create-project', src: 'createProject', - input: ({ event }) => { + input: ({ event, context }) => { if (event.type !== 'Create project') { return { name: '', + projects: context.projects, } } return { name: event.data.name, + projects: context.projects, } }, onDone: [ { target: 'Reading projects', - actions: ['toastSuccess'], + actions: ['toastSuccess', 'navigateToProject'], }, ], onError: [ @@ -156,6 +193,7 @@ export const homeMachine = setup({ defaultDirectory: context.defaultDirectory, oldName: '', newName: '', + projects: context.projects, } } return { @@ -163,12 +201,13 @@ export const homeMachine = setup({ defaultDirectory: context.defaultDirectory, oldName: event.data.oldName, newName: event.data.newName, + projects: context.projects, } }, onDone: [ { target: '#Home machine.Reading projects', - actions: ['toastSuccess'], + actions: ['toastSuccess', 'navigateToProjectIfNeeded'], }, ], onError: [ @@ -199,7 +238,7 @@ export const homeMachine = setup({ }, onDone: [ { - actions: ['toastSuccess'], + actions: ['toastSuccess', 'navigateToProjectIfNeeded'], target: '#Home machine.Reading projects', }, ], @@ -233,9 +272,5 @@ export const homeMachine = setup({ ], }, }, - - 'Opening project': { - entry: ['navigateToProject'], - }, }, }) diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 0a5331fd78..f54fcbe516 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -1,60 +1,42 @@ import { FormEvent, useEffect, useRef, useState } from 'react' -import { - getNextProjectIndex, - interpolateProjectNameWithIndex, - doesProjectNameNeedInterpolated, -} from 'lib/desktopFS' import { ActionButton } from 'components/ActionButton' -import { toast } from 'react-hot-toast' import { AppHeader } from 'components/AppHeader' import ProjectCard from 'components/ProjectCard/ProjectCard' import { useNavigate, useSearchParams } from 'react-router-dom' import { Link } from 'react-router-dom' import Loading from 'components/Loading' -import { useMachine } from '@xstate/react' -import { homeMachine } from '../machines/homeMachine' -import { fromPromise } from 'xstate' import { PATHS } from 'lib/paths' import { getNextSearchParams, getSortFunction, getSortIcon, } from '../lib/sorting' -import useStateMachineCommands from '../hooks/useStateMachineCommands' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' -import { useCommandsContext } from 'hooks/useCommandsContext' -import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig' import { useHotkeys } from 'react-hotkeys-hook' import { isDesktop } from 'lib/isDesktop' import { kclManager } from 'lib/singletons' -import { useLspContext } from 'components/LspProvider' import { useRefreshSettings } from 'hooks/useRefreshSettings' import { LowerRightControls } from 'components/LowerRightControls' -import { - createNewProjectDirectory, - listProjects, - renameProjectDirectory, -} from 'lib/desktop' import { ProjectSearchBar, useProjectSearch } from 'components/ProjectSearchBar' import { Project } from 'lib/project' import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher' import { useProjectsLoader } from 'hooks/useProjectsLoader' +import { useProjectsContext } from 'hooks/useProjectsContext' +import { useCommandsContext } from 'hooks/useCommandsContext' // This route only opens in the desktop context for now, // as defined in Router.tsx, so we can use the desktop APIs and types. const Home = () => { + const { state, send } = useProjectsContext() + const { commandBarSend } = useCommandsContext() const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0) - const { projectPaths, projectsDir } = useProjectsLoader([ - projectsLoaderTrigger, - ]) + const { projectsDir } = useProjectsLoader([projectsLoaderTrigger]) useRefreshSettings(PATHS.HOME + 'SETTINGS') - const { commandBarSend } = useCommandsContext() const navigate = useNavigate() const { settings: { context: settings }, } = useSettingsAuthContext() - const { onProjectOpen } = useLspContext() // Cancel all KCL executions while on the home page useEffect(() => { @@ -73,107 +55,6 @@ const Home = () => { ) const ref = useRef(null) - const [state, send, actor] = useMachine( - homeMachine.provide({ - actions: { - navigateToProject: ({ context, event }) => { - if ('data' in event && event.data && 'name' in event.data) { - let projectPath = - context.defaultDirectory + - window.electron.path.sep + - event.data.name - onProjectOpen( - { - name: event.data.name, - path: projectPath, - }, - null - ) - commandBarSend({ type: 'Close' }) - navigate(`${PATHS.FILE}/${encodeURIComponent(projectPath)}`) - } - }, - toastSuccess: ({ event }) => - toast.success( - ('data' in event && typeof event.data === 'string' && event.data) || - ('output' in event && - typeof event.output === 'string' && - event.output) || - '' - ), - toastError: ({ event }) => - toast.error( - ('data' in event && typeof event.data === 'string' && event.data) || - ('output' in event && - typeof event.output === 'string' && - event.output) || - '' - ), - }, - actors: { - readProjects: fromPromise(() => listProjects()), - createProject: fromPromise(async ({ input }) => { - let name = ( - input && 'name' in input && input.name - ? input.name - : settings.projects.defaultProjectName.current - ).trim() - - if (doesProjectNameNeedInterpolated(name)) { - const nextIndex = getNextProjectIndex(name, projects) - name = interpolateProjectNameWithIndex(name, nextIndex) - } - - await createNewProjectDirectory(name) - - return `Successfully created "${name}"` - }), - renameProject: fromPromise(async ({ input }) => { - const { oldName, newName, defaultProjectName, defaultDirectory } = - input - let name = newName ? newName : defaultProjectName - if (doesProjectNameNeedInterpolated(name)) { - const nextIndex = await getNextProjectIndex(name, projects) - name = interpolateProjectNameWithIndex(name, nextIndex) - } - - await renameProjectDirectory( - window.electron.path.join(defaultDirectory, oldName), - name - ) - return `Successfully renamed "${oldName}" to "${name}"` - }), - deleteProject: fromPromise(async ({ input }) => { - await window.electron.rm( - window.electron.path.join(input.defaultDirectory, input.name), - { - recursive: true, - } - ) - return `Successfully deleted "${input.name}"` - }), - }, - guards: { - 'Has at least 1 project': ({ event }) => { - if (event.type !== 'xstate.done.actor.read-projects') return false - console.log(`from has at least 1 project: ${event.output.length}`) - return event.output.length ? event.output.length >= 1 : false - }, - }, - }), - { - input: { - projects: projectPaths, - defaultProjectName: settings.projects.defaultProjectName.current, - defaultDirectory: settings.app.projectDirectory.current, - }, - } - ) - - useEffect(() => { - send({ type: 'Read projects', data: {} }) - }, [projectPaths]) - // Re-read projects listing if the projectDir has any updates. useFileSystemWatcher( async () => { @@ -182,21 +63,13 @@ const Home = () => { projectsDir ? [projectsDir] : [] ) - const { projects } = state.context + const projects = state?.context.projects ?? [] const [searchParams, setSearchParams] = useSearchParams() const { searchResults, query, setQuery } = useProjectSearch(projects) const sort = searchParams.get('sort_by') ?? 'modified:desc' const isSortByModified = sort?.includes('modified') || !sort || sort === null - useStateMachineCommands({ - machineId: 'home', - send, - state, - commandBarConfig: homeCommandBarConfig, - actor, - }) - // Update the default project name and directory in the home machine // when the settings change useEffect(() => { @@ -247,7 +120,16 @@ const Home = () => { - send({ type: 'Create project', data: { name: '' } }) + commandBarSend({ + type: 'Find and select command', + data: { + groupId: 'projects', + name: 'Create project', + argDefaultValues: { + name: settings.projects.defaultProjectName.current, + }, + }, + }) } className="group !bg-primary !text-chalkboard-10 !border-primary hover:shadow-inner hover:hue-rotate-15" iconStart={{ @@ -326,7 +208,7 @@ const Home = () => { data-testid="home-section" className="flex-1 overflow-y-auto pr-2 pb-24" > - {state.matches('Reading projects') ? ( + {state?.matches('Reading projects') ? ( Loading your Projects... ) : ( <>