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 commit7545b61b49
. * Revert "A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)" This reverts commit3d2e48732c
. * 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>
@ -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()
|
||||
|
@ -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()
|
||||
})
|
||||
|
@ -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' },
|
||||
@ -644,7 +883,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()
|
||||
@ -688,10 +927,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()
|
||||
@ -868,17 +1107,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 createProject({ name: 'project-000', page, returnHome: true })
|
||||
await expect(projectLinks.getByText('project-000')).toBeVisible()
|
||||
|
||||
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').click()
|
||||
await projectLinks.getByText('project-000').click()
|
||||
|
||||
await u.waitForPageLoad()
|
||||
|
||||
@ -937,16 +1175,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()
|
||||
}
|
||||
@ -1121,11 +1353,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 () => {
|
||||
@ -1451,7 +1682,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 () => {
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 51 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
@ -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 test.step(`Create project and navigate to it`, 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 page.getByRole('textbox', { name: 'Name' }).fill(name)
|
||||
await page.getByRole('button', { name: 'Continue' }).click()
|
||||
|
||||
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()
|
||||
if (returnHome) {
|
||||
await page.waitForURL('**/file/**', { waitUntil: 'domcontentloaded' })
|
||||
await page.getByTestId('app-logo').click()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function executorInputPath(fileName: string): string {
|
||||
|
@ -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(
|
||||
|
@ -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 })
|
||||
|
||||
@ -721,7 +725,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()
|
||||
|
@ -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,6 +58,7 @@ const router = createRouter([
|
||||
<CommandBarProvider>
|
||||
<SettingsAuthProvider>
|
||||
<LspProvider>
|
||||
<ProjectsContextProvider>
|
||||
<KclContextProvider>
|
||||
<AppStateProvider>
|
||||
<MachineManagerProvider>
|
||||
@ -64,6 +66,7 @@ const router = createRouter([
|
||||
</MachineManagerProvider>
|
||||
</AppStateProvider>
|
||||
</KclContextProvider>
|
||||
</ProjectsContextProvider>
|
||||
</LspProvider>
|
||||
</SettingsAuthProvider>
|
||||
</CommandBarProvider>
|
||||
|
289
src/components/ProjectsContextProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
6
src/hooks/useProjectsContext.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { ProjectsMachineContext } from 'components/ProjectsContextProvider'
|
||||
import { useContext } from 'react'
|
||||
|
||||
export const useProjectsContext = () => {
|
||||
return useContext(ProjectsMachineContext)
|
||||
}
|
@ -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,
|
||||
|
@ -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',
|
@ -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
|
||||
}
|
||||
|
@ -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'],
|
||||
},
|
||||
},
|
||||
})
|
@ -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<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.
|
||||
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 = () => {
|
||||
<ActionButton
|
||||
Element="button"
|
||||
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"
|
||||
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>Loading your Projects...</Loading>
|
||||
) : (
|
||||
<>
|
||||
|