Chore: separate out projectMachine from Home route (#4109)

* Rename `homeMachine` and accessories to `projectsMachine`

* Separate out `/home` route from `projectsMachine`

* Add logic to navigate out from deleted or renamed project

* Show a warning in the command palette for deleting a project

* Make it navigate when you create a project

* Update "New project" button to use command bar flow
Closes #2585

* More explicit warning message text

* Make projects watching code not run in web

* Tests first version: nested loops

* Tests second version: flattened

* Remove console logs

* Fix tsc

* @jtran feedback, use the type guard util

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)

* Fix tests that relied on one-click, no-navigation project creation

* Revert "A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)"

This reverts commit 7545b61b49.

* Revert "A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)"

This reverts commit 3d2e48732c.

* Add a mask to the state indicator to client-side scale test

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest)

* Fix lint

* Fix tsc

* Fix a couple stray tests that still relied on the old way of creating projects

* De-flake another text that could be thrown off by toast-based selectors

* FMT

* Dumb test error because I was rushing

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest)

* Ahhh more flaky toasts, they're everywhere!

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)

* Re-run CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)

* Re-run CI

* Fix one test added since this PR was made

* Fix a few tests that failed due to changes since PR was made

* Prevent double selector issue in Ubuntu test

* Prevent *a different* double selector issue

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2024-10-28 16:18:06 -04:00
committed by GitHub
parent 05610bb0f3
commit 550c8ae165
20 changed files with 700 additions and 280 deletions

View File

@ -3,6 +3,7 @@ import { test, expect } from './fixtures/fixtureSetup'
import * as fsp from 'fs/promises' import * as fsp from 'fs/promises'
import * as fs from 'fs' import * as fs from 'fs'
import { import {
createProject,
executorInputPath, executorInputPath,
getUtils, getUtils,
setup, setup,
@ -114,20 +115,15 @@ test.describe('when using the file tree to', () => {
async ({ browser: _, tronApp }, testInfo) => { async ({ browser: _, tronApp }, testInfo) => {
await tronApp.initialise() await tronApp.initialise()
const { const { panesOpen, pasteCodeInEditor, renameFile, editorTextMatches } =
panesOpen, await getUtils(tronApp.page, test)
createAndSelectProject,
pasteCodeInEditor,
renameFile,
editorTextMatches,
} = await getUtils(tronApp.page, test)
await tronApp.page.setViewportSize({ width: 1200, height: 500 }) await tronApp.page.setViewportSize({ width: 1200, height: 500 })
tronApp.page.on('console', console.log) tronApp.page.on('console', console.log)
await panesOpen(['files', 'code']) await panesOpen(['files', 'code'])
await createAndSelectProject('project-000') await createProject({ name: 'project-000', page: tronApp.page })
// File the main.kcl with contents // File the main.kcl with contents
const kclCube = await fsp.readFile( const kclCube = await fsp.readFile(
@ -167,15 +163,14 @@ test.describe('when using the file tree to', () => {
async ({ browser: _, tronApp }, testInfo) => { async ({ browser: _, tronApp }, testInfo) => {
await tronApp.initialise() await tronApp.initialise()
const { panesOpen, createAndSelectProject, createNewFile } = const { panesOpen, createNewFile } = await getUtils(tronApp.page, test)
await getUtils(tronApp.page, test)
await tronApp.page.setViewportSize({ width: 1200, height: 500 }) await tronApp.page.setViewportSize({ width: 1200, height: 500 })
tronApp.page.on('console', console.log) tronApp.page.on('console', console.log)
await panesOpen(['files']) await panesOpen(['files'])
await createAndSelectProject('project-000') await createProject({ name: 'project-000', page: tronApp.page })
await createNewFile('') await createNewFile('')
await createNewFile('') await createNewFile('')
@ -204,7 +199,6 @@ test.describe('when using the file tree to', () => {
const { const {
openKclCodePanel, openKclCodePanel,
openFilePanel, openFilePanel,
createAndSelectProject,
pasteCodeInEditor, pasteCodeInEditor,
createNewFileAndSelect, createNewFileAndSelect,
renameFile, renameFile,
@ -215,7 +209,7 @@ test.describe('when using the file tree to', () => {
await tronApp.page.setViewportSize({ width: 1200, height: 500 }) await tronApp.page.setViewportSize({ width: 1200, height: 500 })
tronApp.page.on('console', console.log) tronApp.page.on('console', console.log)
await createAndSelectProject('project-000') await createProject({ name: 'project-000', page: tronApp.page })
await openKclCodePanel() await openKclCodePanel()
await openFilePanel() await openFilePanel()
// File the main.kcl with contents // File the main.kcl with contents
@ -263,20 +257,15 @@ test.describe('when using the file tree to', () => {
async ({ browser: _, tronApp }, testInfo) => { async ({ browser: _, tronApp }, testInfo) => {
await tronApp.initialise() await tronApp.initialise()
const { const { panesOpen, pasteCodeInEditor, deleteFile, editorTextMatches } =
panesOpen, await getUtils(tronApp.page, _test)
createAndSelectProject,
pasteCodeInEditor,
deleteFile,
editorTextMatches,
} = await getUtils(tronApp.page, _test)
await tronApp.page.setViewportSize({ width: 1200, height: 500 }) await tronApp.page.setViewportSize({ width: 1200, height: 500 })
tronApp.page.on('console', console.log) tronApp.page.on('console', console.log)
await panesOpen(['files', 'code']) await panesOpen(['files', 'code'])
await createAndSelectProject('project-000') await createProject({ name: 'project-000', page: tronApp.page })
// File the main.kcl with contents // File the main.kcl with contents
const kclCube = await fsp.readFile( const kclCube = await fsp.readFile(
'src/wasm-lib/tests/executor/inputs/cube.kcl', 'src/wasm-lib/tests/executor/inputs/cube.kcl',
@ -306,7 +295,6 @@ test.describe('when using the file tree to', () => {
const { const {
panesOpen, panesOpen,
createAndSelectProject,
pasteCodeInEditor, pasteCodeInEditor,
createNewFile, createNewFile,
openDebugPanel, openDebugPanel,
@ -318,7 +306,7 @@ test.describe('when using the file tree to', () => {
tronApp.page.on('console', console.log) tronApp.page.on('console', console.log)
await panesOpen(['files', 'code']) await panesOpen(['files', 'code'])
await createAndSelectProject('project-000') await createProject({ name: 'project-000', page: tronApp.page })
// Create a small file // Create a small file
const kclCube = await fsp.readFile( const kclCube = await fsp.readFile(
@ -722,7 +710,7 @@ _test.describe('Renaming in the file tree', () => {
}) })
await _test.step('Rename the folder', async () => { await _test.step('Rename the folder', async () => {
await page.waitForTimeout(60000) await page.waitForTimeout(1000)
await folderToRename.click({ button: 'right' }) await folderToRename.click({ button: 'right' })
await _expect(renameMenuItem).toBeVisible() await _expect(renameMenuItem).toBeVisible()
await renameMenuItem.click() await renameMenuItem.click()

View File

@ -7,6 +7,7 @@ import {
setupElectron, setupElectron,
tearDown, tearDown,
executorInputPath, executorInputPath,
createProject,
} from './test-utils' } from './test-utils'
import { bracket } from 'lib/exampleKcl' import { bracket } from 'lib/exampleKcl'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
@ -74,13 +75,8 @@ test.describe('Onboarding tests', () => {
const viewportSize = { width: 1200, height: 500 } const viewportSize = { width: 1200, height: 500 }
await page.setViewportSize(viewportSize) 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 test.step(`Create a project and open to the onboarding`, async () => {
await newProjectButton.click() await createProject({ name: 'project-link', page })
await projectLink.click()
await test.step(`Ensure the engine connection works by testing the sketch button`, async () => { await test.step(`Ensure the engine connection works by testing the sketch button`, async () => {
await u.waitForPageLoad() await u.waitForPageLoad()
}) })

View File

@ -7,7 +7,7 @@ import {
Paths, Paths,
setupElectron, setupElectron,
tearDown, tearDown,
createProjectAndRenameIt, createProject,
} from './test-utils' } from './test-utils'
import fsp from 'fs/promises' import fsp from 'fs/promises'
import fs from 'fs' 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( test(
'File in the file pane should open with a single click', 'File in the file pane should open with a single click',
{ tag: '@electron' }, { tag: '@electron' },
@ -644,7 +883,7 @@ test(
page.on('console', console.log) page.on('console', console.log)
await test.step('delete the middle project, i.e. the bracket project', async () => { 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.hover()
await project.focus() await project.focus()
@ -688,10 +927,10 @@ test(
}) })
await test.step('Check we can still create a project', async () => { await test.step('Check we can still create a project', async () => {
await page.getByRole('button', { name: 'New project' }).click() await createProject({ name: 'project-000', page, returnHome: true })
await expect(page.getByText('Successfully created')).toBeVisible() await expect(
await expect(page.getByText('Successfully created')).not.toBeVisible() page.getByTestId('project-link').filter({ hasText: 'project-000' })
await expect(page.getByText('project-000')).toBeVisible() ).toBeVisible()
}) })
await electronApp.close() await electronApp.close()
@ -868,17 +1107,16 @@ test.fixme(
const pointOnModel = { x: 660, y: 250 } const pointOnModel = { x: 660, y: 250 }
const expectedStartCamZPosition = 15633.47 const expectedStartCamZPosition = 15633.47
// Constants and locators
const projectLinks = page.getByTestId('project-link')
// expect to see text "No Projects found" // expect to see text "No Projects found"
await expect(page.getByText('No Projects found')).toBeVisible() await expect(page.getByText('No Projects found')).toBeVisible()
await page.getByRole('button', { name: 'New project' }).click() await createProject({ name: 'project-000', page, returnHome: true })
await expect(projectLinks.getByText('project-000')).toBeVisible()
await expect(page.getByText('Successfully created')).toBeVisible() await projectLinks.getByText('project-000').click()
await expect(page.getByText('Successfully created')).not.toBeVisible()
await expect(page.getByText('project-000')).toBeVisible()
await page.getByText('project-000').click()
await u.waitForPageLoad() await u.waitForPageLoad()
@ -937,16 +1175,10 @@ extrude001 = extrude(200, sketch001)`)
page.getByRole('button', { name: 'New project' }) page.getByRole('button', { name: 'New project' })
).toBeVisible() ).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++) { 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() await electronApp.close()
} }
@ -1121,11 +1353,10 @@ test(
await page.getByTestId('settings-close-button').click() await page.getByTestId('settings-close-button').click()
await expect(page.getByText('No Projects found')).toBeVisible() await expect(page.getByText('No Projects found')).toBeVisible()
await page.getByRole('button', { name: 'New project' }).click() await createProject({ name: 'project-000', page, returnHome: true })
await expect(page.getByText('Successfully created')).toBeVisible() await expect(
await expect(page.getByText('Successfully created')).not.toBeVisible() page.getByTestId('project-link').filter({ hasText: 'project-000' })
).toBeVisible()
await expect(page.getByText(`project-000`)).toBeVisible()
}) })
await test.step('We can change back to the original root project directory', async () => { await test.step('We can change back to the original root project directory', async () => {
@ -1451,7 +1682,7 @@ test(
page.on('console', console.log) page.on('console', console.log)
await test.step('Should create and name a project called wrist brace', async () => { 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 () => { await test.step('Should go through onboarding', async () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -467,20 +467,6 @@ export async function getUtils(page: Page, test_?: typeof test) {
return text.replace(/\s+/g, '') 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) => { editorTextMatches: async (code: string) => {
const editor = page.locator(editorSelector) const editor = page.locator(editorSelector)
return expect(editor).toHaveText(code, { useInnerText: true }) return expect(editor).toHaveText(code, { useInnerText: true })
@ -980,30 +966,25 @@ export async function isOutOfViewInScrollContainer(
return isOutOfView return isOutOfView
} }
export async function createProjectAndRenameIt({ export async function createProject({
name, name,
page, page,
returnHome = false,
}: { }: {
name: string name: string
page: Page page: Page
returnHome?: boolean
}) { }) {
await page.getByRole('button', { name: 'New project' }).click() await test.step(`Create project and navigate to it`, async () => {
await expect(page.getByText('Successfully created')).toBeVisible() await page.getByRole('button', { name: 'New project' }).click()
await expect(page.getByText('Successfully created')).not.toBeVisible() await page.getByRole('textbox', { name: 'Name' }).fill(name)
await page.getByRole('button', { name: 'Continue' }).click()
await expect(page.getByText(`project-000`)).toBeVisible() if (returnHome) {
await page.getByText(`project-000`).hover() await page.waitForURL('**/file/**', { waitUntil: 'domcontentloaded' })
await page.getByText(`project-000`).focus() await page.getByTestId('app-logo').click()
}
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()
} }
export function executorInputPath(fileName: string): string { export function executorInputPath(fileName: string): string {

View File

@ -7,6 +7,7 @@ import {
setupElectron, setupElectron,
tearDown, tearDown,
executorInputPath, executorInputPath,
createProject,
} from './test-utils' } from './test-utils'
import { SaveSettingsPayload, SettingsLevel } from 'lib/settings/settingsTypes' import { SaveSettingsPayload, SettingsLevel } from 'lib/settings/settingsTypes'
import { SETTINGS_FILE_NAME, PROJECT_SETTINGS_FILE_NAME } from 'lib/constants' 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 test.step('Check color of logo changed when in modeling view', async () => {
await page.getByRole('button', { name: 'New project' }).click() await createProject({ name: 'project-000', page })
await page.getByTestId('project-link').first().click()
await changeColor('58') await changeColor('58')
await expect(logoLink).toHaveCSS('--primary-hue', '58') await expect(logoLink).toHaveCSS('--primary-hue', '58')
}) })
@ -447,7 +447,7 @@ test.describe('Testing settings', () => {
test( test(
'project settings reload on external change', 'project settings reload on external change',
{ tag: '@electron' }, { tag: '@electron' },
async ({ browserName }, testInfo) => { async ({ browserName: _ }, testInfo) => {
const { const {
electronApp, electronApp,
page, page,
@ -465,11 +465,7 @@ test.describe('Testing settings', () => {
await expect(projectDirLink).toBeVisible() await expect(projectDirLink).toBeVisible()
}) })
const projectLinks = page.getByTestId('project-link') await createProject({ name: 'project-000', page })
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()
const changeColorFs = async (color: string) => { const changeColorFs = async (color: string) => {
const tempSettingsFilePath = join( const tempSettingsFilePath = join(

View File

@ -1,5 +1,11 @@
import { test, expect, Page } from '@playwright/test' 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 { join } from 'path'
import fs from 'fs' import fs from 'fs'
@ -700,12 +706,10 @@ test(
const fileExists = () => const fileExists = () =>
fs.existsSync(join(dir, projectName, textToCadFileName)) fs.existsSync(join(dir, projectName, textToCadFileName))
const { const { openFilePanel, openKclCodePanel, waitForPageLoad } = await getUtils(
createAndSelectProject, page,
openFilePanel, test
openKclCodePanel, )
waitForPageLoad,
} = await getUtils(page, test)
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
@ -721,7 +725,7 @@ test(
) )
// Create and navigate to the project // 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 // Wait for Start Sketch otherwise you will not have access Text-to-CAD command
await waitForPageLoad() await waitForPageLoad()

View File

@ -43,6 +43,7 @@ import { coreDump } from 'lang/wasm'
import { useMemo } from 'react' import { useMemo } from 'react'
import { AppStateProvider } from 'AppState' import { AppStateProvider } from 'AppState'
import { reportRejection } from 'lib/trap' import { reportRejection } from 'lib/trap'
import { ProjectsContextProvider } from 'components/ProjectsContextProvider'
const createRouter = isDesktop() ? createHashRouter : createBrowserRouter const createRouter = isDesktop() ? createHashRouter : createBrowserRouter
@ -57,13 +58,15 @@ const router = createRouter([
<CommandBarProvider> <CommandBarProvider>
<SettingsAuthProvider> <SettingsAuthProvider>
<LspProvider> <LspProvider>
<KclContextProvider> <ProjectsContextProvider>
<AppStateProvider> <KclContextProvider>
<MachineManagerProvider> <AppStateProvider>
<Outlet /> <MachineManagerProvider>
</MachineManagerProvider> <Outlet />
</AppStateProvider> </MachineManagerProvider>
</KclContextProvider> </AppStateProvider>
</KclContextProvider>
</ProjectsContextProvider>
</LspProvider> </LspProvider>
</SettingsAuthProvider> </SettingsAuthProvider>
</CommandBarProvider> </CommandBarProvider>

View File

@ -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<T extends AnyStateMachine> = {
state?: StateFrom<T>
send: Prop<Actor<T>, 'send'>
}
export const ProjectsMachineContext = createContext(
{} as MachineContext<typeof projectsMachine>
)
/**
* 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() ? (
<ProjectsContextDesktop>{children}</ProjectsContextDesktop>
) : (
<ProjectsContextWeb>{children}</ProjectsContextWeb>
)
}
const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => {
return (
<ProjectsMachineContext.Provider
value={{
state: undefined,
send: () => {},
}}
>
{children}
</ProjectsMachineContext.Provider>
)
}
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 (
<ProjectsMachineContext.Provider
value={{
state,
send,
}}
>
{children}
</ProjectsMachineContext.Provider>
)
}

View File

@ -0,0 +1,6 @@
import { ProjectsMachineContext } from 'components/ProjectsContextProvider'
import { useContext } from 'react'
export const useProjectsContext = () => {
return useContext(ProjectsMachineContext)
}

View File

@ -5,7 +5,7 @@ import { useCommandsContext } from './useCommandsContext'
import { modelingMachine } from 'machines/modelingMachine' import { modelingMachine } from 'machines/modelingMachine'
import { authMachine } from 'machines/authMachine' import { authMachine } from 'machines/authMachine'
import { settingsMachine } from 'machines/settingsMachine' import { settingsMachine } from 'machines/settingsMachine'
import { homeMachine } from 'machines/homeMachine' import { projectsMachine } from 'machines/projectsMachine'
import { import {
Command, Command,
StateMachineCommandSetConfig, StateMachineCommandSetConfig,
@ -22,7 +22,7 @@ export type AllMachines =
| typeof modelingMachine | typeof modelingMachine
| typeof settingsMachine | typeof settingsMachine
| typeof authMachine | typeof authMachine
| typeof homeMachine | typeof projectsMachine
interface UseStateMachineCommandsArgs< interface UseStateMachineCommandsArgs<
T extends AllMachines, T extends AllMachines,

View File

@ -1,7 +1,8 @@
import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning'
import { StateMachineCommandSetConfig } from 'lib/commandTypes' import { StateMachineCommandSetConfig } from 'lib/commandTypes'
import { homeMachine } from 'machines/homeMachine' import { projectsMachine } from 'machines/projectsMachine'
export type HomeCommandSchema = { export type ProjectsCommandSchema = {
'Read projects': {} 'Read projects': {}
'Create project': { 'Create project': {
name: string name: string
@ -18,9 +19,9 @@ export type HomeCommandSchema = {
} }
} }
export const homeCommandBarConfig: StateMachineCommandSetConfig< export const projectsCommandBarConfig: StateMachineCommandSetConfig<
typeof homeMachine, typeof projectsMachine,
HomeCommandSchema ProjectsCommandSchema
> = { > = {
'Open project': { 'Open project': {
icon: 'arrowRight', icon: 'arrowRight',
@ -53,6 +54,11 @@ export const homeCommandBarConfig: StateMachineCommandSetConfig<
icon: 'close', icon: 'close',
description: 'Delete a project', description: 'Delete a project',
needsReview: true, 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: { args: {
name: { name: {
inputType: 'options', inputType: 'options',

View File

@ -111,6 +111,9 @@ export function createMachineCommand<
if ('displayName' in commandConfig) { if ('displayName' in commandConfig) {
command.displayName = commandConfig.displayName command.displayName = commandConfig.displayName
} }
if ('reviewMessage' in commandConfig) {
command.reviewMessage = commandConfig.reviewMessage
}
return command return command
} }

View File

@ -1,8 +1,9 @@
import { assign, fromPromise, setup } from 'xstate' import { assign, fromPromise, setup } from 'xstate'
import { HomeCommandSchema } from 'lib/commandBarConfigs/homeCommandConfig' import { ProjectsCommandSchema } from 'lib/commandBarConfigs/projectsCommandConfig'
import { Project } from 'lib/project' import { Project } from 'lib/project'
import { isArray } from 'lib/utils'
export const homeMachine = setup({ export const projectsMachine = setup({
types: { types: {
context: {} as { context: {} as {
projects: Project[] projects: Project[]
@ -11,15 +12,36 @@ export const homeMachine = setup({
}, },
events: {} as events: {} as
| { type: 'Read projects'; data: {} } | { type: 'Read projects'; data: {} }
| { type: 'Open project'; data: HomeCommandSchema['Open project'] } | { type: 'Open project'; data: ProjectsCommandSchema['Open project'] }
| { type: 'Rename project'; data: HomeCommandSchema['Rename project'] } | {
| { type: 'Create project'; data: HomeCommandSchema['Create project'] } type: 'Rename project'
| { type: 'Delete project'; data: HomeCommandSchema['Delete 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: 'navigate'; data: { name: string } }
| { | {
type: 'xstate.done.actor.read-projects' type: 'xstate.done.actor.read-projects'
output: Project[] 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 } }, | { type: 'assign'; data: { [key: string]: any } },
input: {} as { input: {} as {
projects: Project[] projects: Project[]
@ -30,16 +52,20 @@ export const homeMachine = setup({
actions: { actions: {
setProjects: assign({ setProjects: assign({
projects: ({ context, event }) => projects: ({ context, event }) =>
'output' in event ? event.output : context.projects, 'output' in event && isArray(event.output)
? event.output
: context.projects,
}), }),
toastSuccess: () => {}, toastSuccess: () => {},
toastError: () => {}, toastError: () => {},
navigateToProject: () => {}, navigateToProject: () => {},
navigateToProjectIfNeeded: () => {},
}, },
actors: { actors: {
readProjects: fromPromise(() => Promise.resolve([] as Project[])), readProjects: fromPromise(() => Promise.resolve([] as Project[])),
createProject: fromPromise((_: { input: { name: string } }) => createProject: fromPromise(
Promise.resolve('') (_: { input: { name: string; projects: Project[] } }) =>
Promise.resolve({ message: '' })
), ),
renameProject: fromPromise( renameProject: fromPromise(
(_: { (_: {
@ -48,28 +74,35 @@ export const homeMachine = setup({
newName: string newName: string
defaultProjectName: string defaultProjectName: string
defaultDirectory: string defaultDirectory: string
projects: Project[]
} }
}) => Promise.resolve('') }) =>
Promise.resolve({
message: '',
oldName: '',
newName: '',
})
), ),
deleteProject: fromPromise( deleteProject: fromPromise(
(_: { input: { defaultDirectory: string; name: string } }) => (_: { input: { defaultDirectory: string; name: string } }) =>
Promise.resolve('') Promise.resolve({
message: '',
name: '',
})
), ),
}, },
guards: { guards: {
'Has at least 1 project': () => false, 'Has at least 1 project': () => false,
}, },
}).createMachine({ }).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', id: 'Home machine',
initial: 'Reading projects', initial: 'Reading projects',
context: { context: ({ input }) => ({
projects: [], ...input,
defaultProjectName: '', }),
defaultDirectory: '',
},
on: { on: {
assign: { assign: {
@ -110,7 +143,9 @@ export const homeMachine = setup({
}, },
'Open project': { 'Open project': {
target: 'Opening project', target: 'Reading projects',
actions: 'navigateToProject',
reenter: true,
}, },
}, },
}, },
@ -119,20 +154,22 @@ export const homeMachine = setup({
invoke: { invoke: {
id: 'create-project', id: 'create-project',
src: 'createProject', src: 'createProject',
input: ({ event }) => { input: ({ event, context }) => {
if (event.type !== 'Create project') { if (event.type !== 'Create project') {
return { return {
name: '', name: '',
projects: context.projects,
} }
} }
return { return {
name: event.data.name, name: event.data.name,
projects: context.projects,
} }
}, },
onDone: [ onDone: [
{ {
target: 'Reading projects', target: 'Reading projects',
actions: ['toastSuccess'], actions: ['toastSuccess', 'navigateToProject'],
}, },
], ],
onError: [ onError: [
@ -156,6 +193,7 @@ export const homeMachine = setup({
defaultDirectory: context.defaultDirectory, defaultDirectory: context.defaultDirectory,
oldName: '', oldName: '',
newName: '', newName: '',
projects: context.projects,
} }
} }
return { return {
@ -163,12 +201,13 @@ export const homeMachine = setup({
defaultDirectory: context.defaultDirectory, defaultDirectory: context.defaultDirectory,
oldName: event.data.oldName, oldName: event.data.oldName,
newName: event.data.newName, newName: event.data.newName,
projects: context.projects,
} }
}, },
onDone: [ onDone: [
{ {
target: '#Home machine.Reading projects', target: '#Home machine.Reading projects',
actions: ['toastSuccess'], actions: ['toastSuccess', 'navigateToProjectIfNeeded'],
}, },
], ],
onError: [ onError: [
@ -199,7 +238,7 @@ export const homeMachine = setup({
}, },
onDone: [ onDone: [
{ {
actions: ['toastSuccess'], actions: ['toastSuccess', 'navigateToProjectIfNeeded'],
target: '#Home machine.Reading projects', target: '#Home machine.Reading projects',
}, },
], ],
@ -233,9 +272,5 @@ export const homeMachine = setup({
], ],
}, },
}, },
'Opening project': {
entry: ['navigateToProject'],
},
}, },
}) })

View File

@ -1,60 +1,42 @@
import { FormEvent, useEffect, useRef, useState } from 'react' import { FormEvent, useEffect, useRef, useState } from 'react'
import {
getNextProjectIndex,
interpolateProjectNameWithIndex,
doesProjectNameNeedInterpolated,
} from 'lib/desktopFS'
import { ActionButton } from 'components/ActionButton' import { ActionButton } from 'components/ActionButton'
import { toast } from 'react-hot-toast'
import { AppHeader } from 'components/AppHeader' import { AppHeader } from 'components/AppHeader'
import ProjectCard from 'components/ProjectCard/ProjectCard' import ProjectCard from 'components/ProjectCard/ProjectCard'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import Loading from 'components/Loading' 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 { PATHS } from 'lib/paths'
import { import {
getNextSearchParams, getNextSearchParams,
getSortFunction, getSortFunction,
getSortIcon, getSortIcon,
} from '../lib/sorting' } from '../lib/sorting'
import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { kclManager } from 'lib/singletons' import { kclManager } from 'lib/singletons'
import { useLspContext } from 'components/LspProvider'
import { useRefreshSettings } from 'hooks/useRefreshSettings' import { useRefreshSettings } from 'hooks/useRefreshSettings'
import { LowerRightControls } from 'components/LowerRightControls' import { LowerRightControls } from 'components/LowerRightControls'
import {
createNewProjectDirectory,
listProjects,
renameProjectDirectory,
} from 'lib/desktop'
import { ProjectSearchBar, useProjectSearch } from 'components/ProjectSearchBar' import { ProjectSearchBar, useProjectSearch } from 'components/ProjectSearchBar'
import { Project } from 'lib/project' import { Project } from 'lib/project'
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher' import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
import { useProjectsLoader } from 'hooks/useProjectsLoader' 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, // This route only opens in the desktop context for now,
// as defined in Router.tsx, so we can use the desktop APIs and types. // as defined in Router.tsx, so we can use the desktop APIs and types.
const Home = () => { const Home = () => {
const { state, send } = useProjectsContext()
const { commandBarSend } = useCommandsContext()
const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0) const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0)
const { projectPaths, projectsDir } = useProjectsLoader([ const { projectsDir } = useProjectsLoader([projectsLoaderTrigger])
projectsLoaderTrigger,
])
useRefreshSettings(PATHS.HOME + 'SETTINGS') useRefreshSettings(PATHS.HOME + 'SETTINGS')
const { commandBarSend } = useCommandsContext()
const navigate = useNavigate() const navigate = useNavigate()
const { const {
settings: { context: settings }, settings: { context: settings },
} = useSettingsAuthContext() } = useSettingsAuthContext()
const { onProjectOpen } = useLspContext()
// Cancel all KCL executions while on the home page // Cancel all KCL executions while on the home page
useEffect(() => { useEffect(() => {
@ -73,107 +55,6 @@ const Home = () => {
) )
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(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. // Re-read projects listing if the projectDir has any updates.
useFileSystemWatcher( useFileSystemWatcher(
async () => { async () => {
@ -182,21 +63,13 @@ const Home = () => {
projectsDir ? [projectsDir] : [] projectsDir ? [projectsDir] : []
) )
const { projects } = state.context const projects = state?.context.projects ?? []
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const { searchResults, query, setQuery } = useProjectSearch(projects) const { searchResults, query, setQuery } = useProjectSearch(projects)
const sort = searchParams.get('sort_by') ?? 'modified:desc' const sort = searchParams.get('sort_by') ?? 'modified:desc'
const isSortByModified = sort?.includes('modified') || !sort || sort === null 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 // Update the default project name and directory in the home machine
// when the settings change // when the settings change
useEffect(() => { useEffect(() => {
@ -247,7 +120,16 @@ const Home = () => {
<ActionButton <ActionButton
Element="button" Element="button"
onClick={() => onClick={() =>
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" className="group !bg-primary !text-chalkboard-10 !border-primary hover:shadow-inner hover:hue-rotate-15"
iconStart={{ iconStart={{
@ -326,7 +208,7 @@ const Home = () => {
data-testid="home-section" data-testid="home-section"
className="flex-1 overflow-y-auto pr-2 pb-24" className="flex-1 overflow-y-auto pr-2 pb-24"
> >
{state.matches('Reading projects') ? ( {state?.matches('Reading projects') ? (
<Loading>Loading your Projects...</Loading> <Loading>Loading your Projects...</Loading>
) : ( ) : (
<> <>