Rework home layout to have a sidebar (#6423)
* chore: saving off skeleton * fix: saving skeleton * chore: skeleton for loading projects from project directory path * chore: cleaning up useless state transition to be an on event direct to action state * fix: new structure for web vs desktop vs react machine provider code * chore: saving off skeleton * fix: skeleton logic for react? going to move it from a string to obj.string * fix: trying to prevent error element unmount on global react components. This is bricking JS state * fix: we are so back * chore: implemented navigating to specfic KCL file * chore: implementing renaming project * chore: deleting project * fix: auto fixes * fix: old debug/testing file oops * chore: generic create new file * chore: skeleton for web create file provide * chore: basic machine vitest... need to figure out how to get window.electron implemented in vitest? * chore: save off progress before deleting other project implementation, a few missing features still * chore: trying a different init skeleton? most likely will migrate * chore: first attempt of purging projects context provider * chore: enabling toast for some machine state * chore: enabling more toast success and error * chore: writing read write state to the system io based on the project path * fix: tsc fixes * fix: use file system watcher, navigate to project after creation via the requestProjectName * chore: open project command, hooks vs snapshot context helpers * chore: implemented open and create project for e2e testing. They are hard coded in poor spot for now. * fix: codespell fixes * chore: implementing more project commands * chore: PR improvements for root.tsx * chore: leaving comment about new Router.tsx layout * fix: removing debugging code * fix: rewriting component for readability * fix: improving web initialization * chore: implementing import file from url which is not actually that? * fix: clearing search params on import file from url * fix: fixed two e2e tests, forgot needsReview when making new command * fix: fixing some import from url business logic to pass e2e tests * chore: script for diffing circular deps +/- * fix: formatting * fix: massive fix for circular depsga! * fix: trying to fix some errors and auto fmt * fix: updating deps * fix: removing debugging code * fix: big clean up * fix: more deletion * fix: tsc cleanup * fix: TSC TSC TSC TSC! * fix: typo fix * fix: clear query params on web only, desktop not required * fix: removing unused code * fmt * Bring back `trap` removed in merge * Use explicit types instead of `any`s on arg configs * Add project commands directly to command palette * fix: deleting debugging code, from PR review * fix: this got added back(?) * fix: using referred type * fix: more PR clean up * fix: big block comment for xstate architecture decision * fix: more pr comment fixes * fix: saving off logic, need a big cleanup because I hacked it together to get a POC * fix: extra business? * fix: merge conflict just added them back why dude * fix: more PR comments * fix: big ciruclar deps fix, commandBarActor in appActor * chore: writing e2e test, still need to fix 3 bugs * chore: adding more scenarios * fix: formatting * fix: fixing tsc errors * chore: deleting the old text to cad and using the new application level one, almost there * fix: prompt to edit works * fix: large push to get 1 text to cad command... the usage is a little buggy with delete and navigate within /file * fix: settings for highlight edges now works * chore: adding another e2e test * fix: cleaning up e2e tests and writing more of them * fix: tsc type * chore: more e2e improvements, unique project name in text to cad * chore: e2e tests should be good to go * fix: gotcha comment * fix: enabled web t2c, codespell fixes * fix: fixing merge conflcits?? * fix: t2c is back * Rework home layout to have a sidebar fmt I think * Add two links to the bottom of the sidebar Mostly to visually anchor it * Tweak some style things * update test util whose locator needs to change * tsc and fmt * Stupid heading change broke the dang E2E tests * Make that heading locator a part of the home page fixture * pierremtb/new-snaps-for-frank (#6516) Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com> --------- Co-authored-by: Kevin Nadro <kevin@zoo.dev> Co-authored-by: Kevin Nadro <nadr0@users.noreply.github.com> Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
@ -247,7 +247,7 @@ export class ElectronZoo {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!this.firstUrl) {
 | 
			
		||||
      await this.page.getByText('Your Projects').count()
 | 
			
		||||
      await this.page.getByRole('heading', { name: 'Projects' }).count()
 | 
			
		||||
      this.firstUrl = this.page.url()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -92,6 +92,13 @@ export class HomePageFixture {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  expectIsCurrentPage = async () => {
 | 
			
		||||
    await expect(this.page).toHaveURL(/.*home/)
 | 
			
		||||
    await expect(
 | 
			
		||||
      this.page.getByRole('heading', { name: 'Projects' })
 | 
			
		||||
    ).toBeVisible()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  projectsLoaded = async () => {
 | 
			
		||||
    await expect(this.projectSection).not.toHaveText('Loading your Projects...')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -510,9 +510,7 @@ test('Restarting onboarding on desktop takes one attempt', async ({
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  await test.step('Navigate into project', async () => {
 | 
			
		||||
    await expect(
 | 
			
		||||
      page.getByRole('heading', { name: 'Your Projects' })
 | 
			
		||||
    ).toBeVisible()
 | 
			
		||||
    await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
 | 
			
		||||
    await expect(projectCard).toBeVisible()
 | 
			
		||||
    await projectCard.click()
 | 
			
		||||
    await u.waitForPageLoad()
 | 
			
		||||
 | 
			
		||||
@ -723,7 +723,7 @@ test(
 | 
			
		||||
test(
 | 
			
		||||
  'pressing "delete" on home screen should do nothing',
 | 
			
		||||
  { tag: '@electron' },
 | 
			
		||||
  async ({ context, page }, testInfo) => {
 | 
			
		||||
  async ({ context, page, homePage }, testInfo) => {
 | 
			
		||||
    await context.folderSetupFn(async (dir) => {
 | 
			
		||||
      await fsp.mkdir(`${dir}/router-template-slate`, { recursive: true })
 | 
			
		||||
      await fsp.copyFile(
 | 
			
		||||
@ -737,7 +737,7 @@ test(
 | 
			
		||||
 | 
			
		||||
    await expect(page.getByText('router-template-slate')).toBeVisible()
 | 
			
		||||
    await expect(page.getByText('Loading your Projects...')).not.toBeVisible()
 | 
			
		||||
    await expect(page.getByText('Your Projects')).toBeVisible()
 | 
			
		||||
    await homePage.expectIsCurrentPage()
 | 
			
		||||
 | 
			
		||||
    await page.keyboard.press('Delete')
 | 
			
		||||
    await page.waitForTimeout(200)
 | 
			
		||||
@ -745,7 +745,7 @@ test(
 | 
			
		||||
 | 
			
		||||
    // expect to still be on the home page
 | 
			
		||||
    await expect(page.getByText('router-template-slate')).toBeVisible()
 | 
			
		||||
    await expect(page.getByText('Your Projects')).toBeVisible()
 | 
			
		||||
    await homePage.expectIsCurrentPage()
 | 
			
		||||
  }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -579,7 +579,7 @@ extrude002 = extrude(profile002, length = 150)
 | 
			
		||||
 | 
			
		||||
      // Locators
 | 
			
		||||
      const projectsHeading = page.getByRole('heading', {
 | 
			
		||||
        name: 'Your projects',
 | 
			
		||||
        name: 'Projects',
 | 
			
		||||
      })
 | 
			
		||||
      const projectLink = page.getByRole('link', { name: 'bracket' })
 | 
			
		||||
      const networkHealthIndicator = page.getByTestId('network-toggle')
 | 
			
		||||
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB  | 
| 
		 Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB  | 
| 
		 Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB  | 
| 
		 Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB  | 
| 
		 Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 48 KiB  | 
| 
		 Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB  | 
| 
		 Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB  | 
| 
		 Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB  | 
| 
		 Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB  | 
| 
		 Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB  | 
| 
		 Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 64 KiB  | 
| 
		 Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB  | 
| 
		 Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB  | 
| 
		 Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB  | 
| 
		 Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB  | 
| 
		 Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB  | 
| 
		 Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB  | 
| 
		 Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB  | 
| 
		 Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB  | 
| 
		 Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB  | 
| 
		 Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB  | 
| 
		 Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB  | 
@ -1014,7 +1014,7 @@ export async function createProject({
 | 
			
		||||
 | 
			
		||||
async function goToHomePageFromModeling(page: Page) {
 | 
			
		||||
  await page.getByTestId('app-logo').click()
 | 
			
		||||
  await expect(page.getByText('Your Projects')).toBeVisible()
 | 
			
		||||
  await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function executorInputPath(fileName: string): string {
 | 
			
		||||
 | 
			
		||||
@ -786,7 +786,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
  test(
 | 
			
		||||
    'Home Page -> Text To CAD -> Existing Project -> Stay in home page -> Reject -> should delete single file',
 | 
			
		||||
    { tag: '@electron' },
 | 
			
		||||
    async ({ context, page }, testInfo) => {
 | 
			
		||||
    async ({ homePage, page }, testInfo) => {
 | 
			
		||||
      const projectName = 'my-project-name'
 | 
			
		||||
      const prompt = '2x2x2 cube'
 | 
			
		||||
      await mockPageTextToCAD(page)
 | 
			
		||||
@ -794,7 +794,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
      // Create and navigate to the project then come home
 | 
			
		||||
      await createProject({ name: projectName, page, returnHome: true })
 | 
			
		||||
 | 
			
		||||
      await expect(page.getByText('Your Projects')).toBeVisible()
 | 
			
		||||
      await homePage.expectIsCurrentPage()
 | 
			
		||||
 | 
			
		||||
      await expect(page.getByText('1 file')).toBeVisible()
 | 
			
		||||
 | 
			
		||||
@ -829,7 +829,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
  test(
 | 
			
		||||
    'Home Page -> Text To CAD -> Existing Project -> Stay in home page -> Accept -> should navigate to file',
 | 
			
		||||
    { tag: '@electron' },
 | 
			
		||||
    async ({ context, page }, testInfo) => {
 | 
			
		||||
    async ({ homePage, page }, testInfo) => {
 | 
			
		||||
      const u = await getUtils(page)
 | 
			
		||||
      const projectName = 'my-project-name'
 | 
			
		||||
      const prompt = '2x2x2 cube'
 | 
			
		||||
@ -838,7 +838,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
      // Create and navigate to the project then come home
 | 
			
		||||
      await createProject({ name: projectName, page, returnHome: true })
 | 
			
		||||
 | 
			
		||||
      await expect(page.getByText('Your Projects')).toBeVisible()
 | 
			
		||||
      await homePage.expectIsCurrentPage()
 | 
			
		||||
 | 
			
		||||
      // open commands
 | 
			
		||||
      await page.getByTestId('command-bar-open-button').click()
 | 
			
		||||
@ -880,7 +880,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
  test(
 | 
			
		||||
    'Home Page -> Text To CAD -> New Project -> Navigate to the project -> Reject -> should go to home page',
 | 
			
		||||
    { tag: '@electron' },
 | 
			
		||||
    async ({ context, page }, testInfo) => {
 | 
			
		||||
    async ({ homePage, page }, testInfo) => {
 | 
			
		||||
      const projectName = 'my-project-name'
 | 
			
		||||
      const prompt = '2x2x2 cube'
 | 
			
		||||
      await mockPageTextToCAD(page)
 | 
			
		||||
@ -919,16 +919,14 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
      await page.getByRole('button', { name: 'Reject' }).click()
 | 
			
		||||
 | 
			
		||||
      // Make sure we went back home
 | 
			
		||||
      await expect(
 | 
			
		||||
        page.getByText('No Projects found, ready to make your first one?')
 | 
			
		||||
      ).toBeVisible()
 | 
			
		||||
      await homePage.expectIsCurrentPage()
 | 
			
		||||
    }
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  test(
 | 
			
		||||
    'Home Page -> Text To CAD -> New Project -> Navigate to the project -> Accept -> should stay in same file',
 | 
			
		||||
    { tag: '@electron' },
 | 
			
		||||
    async ({ context, page }, testInfo) => {
 | 
			
		||||
    async ({ homePage, page }, testInfo) => {
 | 
			
		||||
      const projectName = 'my-project-name'
 | 
			
		||||
      const prompt = '2x2x2 cube'
 | 
			
		||||
      await mockPageTextToCAD(page)
 | 
			
		||||
@ -971,7 +969,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
  test(
 | 
			
		||||
    'Home Page -> Text To CAD -> Existing Project -> Navigate to the project -> Reject -> should load main.kcl',
 | 
			
		||||
    { tag: '@electron' },
 | 
			
		||||
    async ({ context, page }, testInfo) => {
 | 
			
		||||
    async ({ homePage, page }, testInfo) => {
 | 
			
		||||
      const u = await getUtils(page)
 | 
			
		||||
      const projectName = 'my-project-name'
 | 
			
		||||
      const prompt = '2x2x2 cube'
 | 
			
		||||
@ -980,7 +978,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
      // Create and navigate to the project then come home
 | 
			
		||||
      await createProject({ name: projectName, page, returnHome: true })
 | 
			
		||||
 | 
			
		||||
      await expect(page.getByText('Your Projects')).toBeVisible()
 | 
			
		||||
      await homePage.expectIsCurrentPage()
 | 
			
		||||
 | 
			
		||||
      // open commands
 | 
			
		||||
      await page.getByTestId('command-bar-open-button').click()
 | 
			
		||||
@ -1026,7 +1024,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
  test(
 | 
			
		||||
    'Home Page -> Text To CAD -> Existing Project -> Navigate to the project -> Accept -> should load 2x2x2-cube.kcl',
 | 
			
		||||
    { tag: '@electron' },
 | 
			
		||||
    async ({ context, page }, testInfo) => {
 | 
			
		||||
    async ({ homePage, page }, testInfo) => {
 | 
			
		||||
      const u = await getUtils(page)
 | 
			
		||||
      const projectName = 'my-project-name'
 | 
			
		||||
      const prompt = '2x2x2 cube'
 | 
			
		||||
@ -1035,7 +1033,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
      // Create and navigate to the project then come home
 | 
			
		||||
      await createProject({ name: projectName, page, returnHome: true })
 | 
			
		||||
 | 
			
		||||
      await expect(page.getByText('Your Projects')).toBeVisible()
 | 
			
		||||
      await homePage.expectIsCurrentPage()
 | 
			
		||||
 | 
			
		||||
      // open commands
 | 
			
		||||
      await page.getByTestId('command-bar-open-button').click()
 | 
			
		||||
@ -1083,7 +1081,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
  test(
 | 
			
		||||
    'Home Page -> Text To CAD -> New Project -> Navigate to different project -> Reject -> should stay in project',
 | 
			
		||||
    { tag: '@electron' },
 | 
			
		||||
    async ({ context, page, homePage }, testInfo) => {
 | 
			
		||||
    async ({ homePage, page }, testInfo) => {
 | 
			
		||||
      const u = await getUtils(page)
 | 
			
		||||
      const projectName = 'my-project-name'
 | 
			
		||||
      const unrelatedProjectName = 'unrelated-project'
 | 
			
		||||
@ -1097,7 +1095,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
        returnHome: true,
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      await expect(page.getByText('Your Projects')).toBeVisible()
 | 
			
		||||
      await homePage.expectIsCurrentPage()
 | 
			
		||||
 | 
			
		||||
      // open commands
 | 
			
		||||
      await page.getByTestId('command-bar-open-button').click()
 | 
			
		||||
@ -1160,7 +1158,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
  test(
 | 
			
		||||
    'Home Page -> Text To CAD -> New Project -> Navigate to different project -> Accept -> should go to new project',
 | 
			
		||||
    { tag: '@electron' },
 | 
			
		||||
    async ({ context, page, homePage }, testInfo) => {
 | 
			
		||||
    async ({ page, homePage }, testInfo) => {
 | 
			
		||||
      const u = await getUtils(page)
 | 
			
		||||
      const projectName = 'my-project-name'
 | 
			
		||||
      const unrelatedProjectName = 'unrelated-project'
 | 
			
		||||
@ -1174,7 +1172,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
        returnHome: true,
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      await expect(page.getByText('Your Projects')).toBeVisible()
 | 
			
		||||
      await homePage.expectIsCurrentPage()
 | 
			
		||||
 | 
			
		||||
      // open commands
 | 
			
		||||
      await page.getByTestId('command-bar-open-button').click()
 | 
			
		||||
@ -1233,7 +1231,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
  test(
 | 
			
		||||
    'Home Page -> Text To CAD -> Existing Project -> Navigate to different project -> Reject -> should stay in same project',
 | 
			
		||||
    { tag: '@electron' },
 | 
			
		||||
    async ({ context, page, homePage }, testInfo) => {
 | 
			
		||||
    async ({ page, homePage }, testInfo) => {
 | 
			
		||||
      const u = await getUtils(page)
 | 
			
		||||
      const projectName = 'my-project-name'
 | 
			
		||||
      const unrelatedProjectName = 'unrelated-project'
 | 
			
		||||
@ -1246,10 +1244,10 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
        page,
 | 
			
		||||
        returnHome: true,
 | 
			
		||||
      })
 | 
			
		||||
      await expect(page.getByText('Your Projects')).toBeVisible()
 | 
			
		||||
      await homePage.expectIsCurrentPage()
 | 
			
		||||
 | 
			
		||||
      await createProject({ name: projectName, page, returnHome: true })
 | 
			
		||||
      await expect(page.getByText('Your Projects')).toBeVisible()
 | 
			
		||||
      await homePage.expectIsCurrentPage()
 | 
			
		||||
 | 
			
		||||
      // open commands
 | 
			
		||||
      await page.getByTestId('command-bar-open-button').click()
 | 
			
		||||
@ -1308,7 +1306,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
  test(
 | 
			
		||||
    'Home Page -> Text To CAD -> Existing Project -> Navigate to different project -> Accept -> should navigate to new project',
 | 
			
		||||
    { tag: '@electron' },
 | 
			
		||||
    async ({ context, page, homePage }, testInfo) => {
 | 
			
		||||
    async ({ page, homePage }, testInfo) => {
 | 
			
		||||
      const u = await getUtils(page)
 | 
			
		||||
      const projectName = 'my-project-name'
 | 
			
		||||
      const unrelatedProjectName = 'unrelated-project'
 | 
			
		||||
@ -1321,10 +1319,10 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
 | 
			
		||||
        page,
 | 
			
		||||
        returnHome: true,
 | 
			
		||||
      })
 | 
			
		||||
      await expect(page.getByText('Your Projects')).toBeVisible()
 | 
			
		||||
      await homePage.expectIsCurrentPage()
 | 
			
		||||
 | 
			
		||||
      await createProject({ name: projectName, page, returnHome: true })
 | 
			
		||||
      await expect(page.getByText('Your Projects')).toBeVisible()
 | 
			
		||||
      await homePage.expectIsCurrentPage()
 | 
			
		||||
 | 
			
		||||
      // open commands
 | 
			
		||||
      await page.getByTestId('command-bar-open-button').click()
 | 
			
		||||
 | 
			
		||||
@ -44,7 +44,7 @@ export type ActionButtonProps =
 | 
			
		||||
  | ActionButtonAsElement
 | 
			
		||||
 | 
			
		||||
export const ActionButton = forwardRef((props: ActionButtonProps, ref) => {
 | 
			
		||||
  const classNames = `action-button leading-[0.7] p-0 m-0 group mono text-xs leading-none flex items-center gap-2 rounded-sm border-solid border border-chalkboard-30 hover:border-chalkboard-40 enabled:dark:border-chalkboard-70 dark:hover:border-chalkboard-60 dark:bg-chalkboard-90/50 text-chalkboard-100 dark:text-chalkboard-10 ${
 | 
			
		||||
  const classNames = `action-button leading-[0.7] p-0 m-0 group text-xs leading-none flex items-center gap-2 rounded-sm border-solid border border-chalkboard-30 hover:border-chalkboard-40 enabled:dark:border-chalkboard-70 dark:hover:border-chalkboard-60 dark:bg-chalkboard-90/50 text-chalkboard-100 dark:text-chalkboard-10 ${
 | 
			
		||||
    props.iconStart
 | 
			
		||||
      ? props.iconEnd
 | 
			
		||||
        ? 'px-0' // No padding if both icons are present
 | 
			
		||||
 | 
			
		||||
@ -84,7 +84,7 @@ function ProjectCard({
 | 
			
		||||
  return (
 | 
			
		||||
    <li
 | 
			
		||||
      {...props}
 | 
			
		||||
      className="group relative flex flex-col rounded-sm border border-primary/40 dark:border-chalkboard-80 hover:!border-primary"
 | 
			
		||||
      className="group relative flex flex-col rounded-sm border border-chalkboard-50 dark:border-chalkboard-80 hover:!border-primary"
 | 
			
		||||
    >
 | 
			
		||||
      <Link
 | 
			
		||||
        data-testid="project-link"
 | 
			
		||||
@ -93,7 +93,7 @@ function ProjectCard({
 | 
			
		||||
            ? `${PATHS.FILE}/${encodeURIComponent(project.default_file)}`
 | 
			
		||||
            : ''
 | 
			
		||||
        }
 | 
			
		||||
        className={`flex flex-col flex-1 !no-underline !text-chalkboard-110 dark:!text-chalkboard-10 min-h-[5em] divide-y divide-primary/40 dark:divide-chalkboard-80  ${
 | 
			
		||||
        className={`flex flex-col flex-1 !no-underline !text-chalkboard-110 dark:!text-chalkboard-10 min-h-[5em] divide-y divide-chalkboard-50 dark:divide-chalkboard-80  ${
 | 
			
		||||
          project.readWriteAccess
 | 
			
		||||
            ? 'group-hover:!divide-primary group-hover:!hue-rotate-0'
 | 
			
		||||
            : 'cursor-not-allowed'
 | 
			
		||||
 | 
			
		||||
@ -286,6 +286,11 @@ code {
 | 
			
		||||
    @apply bg-chalkboard-20 dark:bg-chalkboard-90;
 | 
			
		||||
    @apply border border-t-0 border-b-2 border-chalkboard-30 dark:border-chalkboard-80;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .home-layout {
 | 
			
		||||
    @apply grid lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-x-16;
 | 
			
		||||
    grid-template-rows: auto 1fr;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@layer utilities {
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
import type { FormEvent } from 'react'
 | 
			
		||||
import type { FormEvent, HTMLProps } from 'react'
 | 
			
		||||
import { useEffect, useRef } from 'react'
 | 
			
		||||
import { toast } from 'react-hot-toast'
 | 
			
		||||
import { useHotkeys } from 'react-hotkeys-hook'
 | 
			
		||||
@ -38,11 +38,16 @@ import {
 | 
			
		||||
  SystemIOMachineStates,
 | 
			
		||||
} from '@src/machines/systemIO/utils'
 | 
			
		||||
import type { WebContentSendPayload } from '@src/menu/channels'
 | 
			
		||||
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
 | 
			
		||||
 | 
			
		||||
type ReadWriteProjectState = {
 | 
			
		||||
  value: boolean
 | 
			
		||||
  error: unknown
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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 = useSystemIOState()
 | 
			
		||||
  const readWriteProjectDir = useCanReadWriteProjectDirectory()
 | 
			
		||||
 | 
			
		||||
  // Only create the native file menus on desktop
 | 
			
		||||
@ -117,9 +122,9 @@ const Home = () => {
 | 
			
		||||
    } else if (data.menuLabel === 'File.Preferences.Keybindings') {
 | 
			
		||||
      navigate(PATHS.HOME + PATHS.SETTINGS_KEYBINDINGS)
 | 
			
		||||
    } else if (data.menuLabel === 'File.Preferences.User default units') {
 | 
			
		||||
      navigate(PATHS.HOME + PATHS.SETTINGS_USER + '#defaultUnit')
 | 
			
		||||
      navigate(`${PATHS.HOME}${PATHS.SETTINGS_USER}#defaultUnit`)
 | 
			
		||||
    } else if (data.menuLabel === 'Edit.Change project directory') {
 | 
			
		||||
      navigate(PATHS.HOME + PATHS.SETTINGS_USER + '#projectDirectory')
 | 
			
		||||
      navigate(`${PATHS.HOME}${PATHS.SETTINGS_USER}#projectDirectory`)
 | 
			
		||||
    } else if (data.menuLabel === 'File.Sign out') {
 | 
			
		||||
      authActor.send({ type: 'Log out' })
 | 
			
		||||
    } else if (
 | 
			
		||||
@ -136,7 +141,7 @@ const Home = () => {
 | 
			
		||||
        },
 | 
			
		||||
      })
 | 
			
		||||
    } else if (data.menuLabel === 'File.Preferences.Theme color') {
 | 
			
		||||
      navigate(PATHS.HOME + PATHS.SETTINGS_USER + '#themeColor')
 | 
			
		||||
      navigate(`${PATHS.HOME}${PATHS.SETTINGS_USER}#themeColor`)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  useMenuListener(cb)
 | 
			
		||||
@ -162,60 +167,24 @@ const Home = () => {
 | 
			
		||||
  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
 | 
			
		||||
 | 
			
		||||
  async function handleRenameProject(
 | 
			
		||||
    e: FormEvent<HTMLFormElement>,
 | 
			
		||||
    project: Project
 | 
			
		||||
  ) {
 | 
			
		||||
    const { newProjectName } = Object.fromEntries(
 | 
			
		||||
      new FormData(e.target as HTMLFormElement)
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    if (typeof newProjectName === 'string' && newProjectName.startsWith('.')) {
 | 
			
		||||
      toast.error('Project names cannot start with a dot (.)')
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (newProjectName !== project.name) {
 | 
			
		||||
      systemIOActor.send({
 | 
			
		||||
        type: SystemIOMachineEvents.renameProject,
 | 
			
		||||
        data: {
 | 
			
		||||
          requestedProjectName: String(newProjectName),
 | 
			
		||||
          projectName: project.name,
 | 
			
		||||
        },
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async function handleDeleteProject(project: Project) {
 | 
			
		||||
    systemIOActor.send({
 | 
			
		||||
      type: SystemIOMachineEvents.deleteProject,
 | 
			
		||||
      data: { requestedProjectName: project.name },
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
  /** Type narrowing function of unknown error to a string */
 | 
			
		||||
  function errorMessage(error: unknown): string {
 | 
			
		||||
    if (error != undefined && error instanceof Error) {
 | 
			
		||||
      return error.message
 | 
			
		||||
    } else if (error && typeof error === 'object') {
 | 
			
		||||
      return JSON.stringify(error)
 | 
			
		||||
    } else if (typeof error === 'string') {
 | 
			
		||||
      return error
 | 
			
		||||
    } else {
 | 
			
		||||
      return 'Unknown error'
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  const sidebarButtonClasses =
 | 
			
		||||
    'flex items-center p-2 gap-2 leading-tight border-transparent dark:border-transparent enabled:dark:border-transparent enabled:hover:border-primary/50 enabled:dark:hover:border-inherit active:border-primary dark:bg-transparent hover:bg-transparent'
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="relative flex flex-col h-screen overflow-hidden" ref={ref}>
 | 
			
		||||
      <AppHeader showToolbar={false} />
 | 
			
		||||
      <div className="w-full flex flex-col overflow-hidden max-w-5xl px-4 mx-auto mt-24 lg:px-2">
 | 
			
		||||
        <section>
 | 
			
		||||
          <div className="flex justify-between items-center select-none">
 | 
			
		||||
            <div className="flex gap-8 items-center">
 | 
			
		||||
              <h1 className="text-3xl font-bold">Your Projects</h1>
 | 
			
		||||
      <div className="overflow-hidden home-layout max-w-4xl xl:max-w-7xl mb-12 px-4 mx-auto mt-24 lg:px-0">
 | 
			
		||||
        <HomeHeader
 | 
			
		||||
          setQuery={setQuery}
 | 
			
		||||
          sort={sort}
 | 
			
		||||
          setSearchParams={setSearchParams}
 | 
			
		||||
          settings={settings}
 | 
			
		||||
          readWriteProjectDir={readWriteProjectDir}
 | 
			
		||||
          className="col-start-2 -col-end-1"
 | 
			
		||||
        />
 | 
			
		||||
        <aside className="row-start-2 -row-end-1 flex flex-col justify-between">
 | 
			
		||||
          <ul className="flex flex-col">
 | 
			
		||||
            <li className="contents">
 | 
			
		||||
              <ActionButton
 | 
			
		||||
                Element="button"
 | 
			
		||||
                onClick={() =>
 | 
			
		||||
@ -227,129 +196,282 @@ const Home = () => {
 | 
			
		||||
                    },
 | 
			
		||||
                  })
 | 
			
		||||
                }
 | 
			
		||||
                className="group !bg-primary !text-chalkboard-10 !border-primary hover:shadow-inner hover:hue-rotate-15"
 | 
			
		||||
                className={sidebarButtonClasses}
 | 
			
		||||
                iconStart={{
 | 
			
		||||
                  icon: 'plus',
 | 
			
		||||
                  bgClassName: '!bg-transparent rounded-sm',
 | 
			
		||||
                  iconClassName:
 | 
			
		||||
                    '!text-chalkboard-10 transition-transform group-active:rotate-90',
 | 
			
		||||
                }}
 | 
			
		||||
                data-testid="home-new-file"
 | 
			
		||||
              >
 | 
			
		||||
                Create project
 | 
			
		||||
              </ActionButton>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="flex gap-2 items-center">
 | 
			
		||||
              <ProjectSearchBar setQuery={setQuery} />
 | 
			
		||||
              <small>Sort by</small>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li className="contents">
 | 
			
		||||
              <ActionButton
 | 
			
		||||
                Element="button"
 | 
			
		||||
                data-testid="home-sort-by-name"
 | 
			
		||||
                className={
 | 
			
		||||
                  'text-xs border-primary/10 ' +
 | 
			
		||||
                  (!sort.includes('name')
 | 
			
		||||
                    ? 'text-chalkboard-80 dark:text-chalkboard-40'
 | 
			
		||||
                    : '')
 | 
			
		||||
                }
 | 
			
		||||
                onClick={() =>
 | 
			
		||||
                  setSearchParams(getNextSearchParams(sort, 'name'))
 | 
			
		||||
                  commandBarActor.send({
 | 
			
		||||
                    type: 'Find and select command',
 | 
			
		||||
                    data: {
 | 
			
		||||
                      groupId: 'application',
 | 
			
		||||
                      name: 'Text-to-CAD',
 | 
			
		||||
                      argDefaultValues: {
 | 
			
		||||
                        method: 'newProject',
 | 
			
		||||
                        newProjectName:
 | 
			
		||||
                          settings.projects.defaultProjectName.current,
 | 
			
		||||
                      },
 | 
			
		||||
                    },
 | 
			
		||||
                  })
 | 
			
		||||
                }
 | 
			
		||||
                className={sidebarButtonClasses}
 | 
			
		||||
                iconStart={{
 | 
			
		||||
                  icon: getSortIcon(sort, 'name'),
 | 
			
		||||
                  bgClassName: 'bg-transparent',
 | 
			
		||||
                  iconClassName: !sort.includes('name')
 | 
			
		||||
                    ? '!text-chalkboard-90 dark:!text-chalkboard-30'
 | 
			
		||||
                    : '',
 | 
			
		||||
                  icon: 'sparkles',
 | 
			
		||||
                  bgClassName: '!bg-transparent rounded-sm',
 | 
			
		||||
                }}
 | 
			
		||||
                data-testid="home-text-to-cad"
 | 
			
		||||
              >
 | 
			
		||||
                Name
 | 
			
		||||
                Generate with Text-to-CAD
 | 
			
		||||
              </ActionButton>
 | 
			
		||||
            </li>
 | 
			
		||||
          </ul>
 | 
			
		||||
          <ul className="flex flex-col">
 | 
			
		||||
            <li className="contents">
 | 
			
		||||
              <ActionButton
 | 
			
		||||
                Element="button"
 | 
			
		||||
                data-testid="home-sort-by-modified"
 | 
			
		||||
                className={
 | 
			
		||||
                  'text-xs border-primary/10 ' +
 | 
			
		||||
                  (!isSortByModified
 | 
			
		||||
                    ? 'text-chalkboard-80 dark:text-chalkboard-40'
 | 
			
		||||
                    : '')
 | 
			
		||||
                }
 | 
			
		||||
                onClick={() =>
 | 
			
		||||
                  setSearchParams(getNextSearchParams(sort, 'modified'))
 | 
			
		||||
                }
 | 
			
		||||
                Element="externalLink"
 | 
			
		||||
                to="https://zoo.dev/docs"
 | 
			
		||||
                onClick={openExternalBrowserIfDesktop(
 | 
			
		||||
                  'https://zoo.dev/account'
 | 
			
		||||
                )}
 | 
			
		||||
                className={sidebarButtonClasses}
 | 
			
		||||
                iconStart={{
 | 
			
		||||
                  icon: sort ? getSortIcon(sort, 'modified') : 'arrowDown',
 | 
			
		||||
                  bgClassName: 'bg-transparent',
 | 
			
		||||
                  iconClassName: !isSortByModified
 | 
			
		||||
                    ? '!text-chalkboard-90 dark:!text-chalkboard-30'
 | 
			
		||||
                    : '',
 | 
			
		||||
                  icon: 'person',
 | 
			
		||||
                  bgClassName: '!bg-transparent rounded-sm',
 | 
			
		||||
                }}
 | 
			
		||||
                data-testid="home-account"
 | 
			
		||||
              >
 | 
			
		||||
                Last Modified
 | 
			
		||||
                View your account
 | 
			
		||||
              </ActionButton>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
 | 
			
		||||
            Loaded from{' '}
 | 
			
		||||
            <Link
 | 
			
		||||
              data-testid="project-directory-settings-link"
 | 
			
		||||
              to={`${PATHS.HOME + PATHS.SETTINGS_USER}#projectDirectory`}
 | 
			
		||||
              className="text-chalkboard-90 dark:text-chalkboard-20 underline underline-offset-2"
 | 
			
		||||
            >
 | 
			
		||||
              {settings.app.projectDirectory.current}
 | 
			
		||||
            </Link>
 | 
			
		||||
            .
 | 
			
		||||
          </p>
 | 
			
		||||
          {!readWriteProjectDir.value && (
 | 
			
		||||
            <section>
 | 
			
		||||
              <div className="flex items-center select-none">
 | 
			
		||||
                <div className="flex gap-8 items-center justify-between grow bg-destroy-80 text-white py-1 px-4 my-2 rounded-sm grow">
 | 
			
		||||
                  <p className="">{errorMessage(readWriteProjectDir.error)}</p>
 | 
			
		||||
                  <Link
 | 
			
		||||
                    data-testid="project-directory-settings-link"
 | 
			
		||||
                    to={`${PATHS.HOME + PATHS.SETTINGS_USER}#projectDirectory`}
 | 
			
		||||
                    className="py-1 text-white underline underline-offset-2 text-sm"
 | 
			
		||||
                  >
 | 
			
		||||
                    Change Project Directory
 | 
			
		||||
                  </Link>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </section>
 | 
			
		||||
          )}
 | 
			
		||||
        </section>
 | 
			
		||||
        <section
 | 
			
		||||
          data-testid="home-section"
 | 
			
		||||
          className="flex-1 overflow-y-auto pr-2 pb-24"
 | 
			
		||||
        >
 | 
			
		||||
          {state?.matches(SystemIOMachineStates.readingFolders) ? (
 | 
			
		||||
            <Loading className="h-screen">Loading your Projects...</Loading>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <>
 | 
			
		||||
              {searchResults.length > 0 ? (
 | 
			
		||||
                <ul className="grid w-full grid-cols-4 gap-4">
 | 
			
		||||
                  {searchResults.sort(getSortFunction(sort)).map((project) => (
 | 
			
		||||
                    <ProjectCard
 | 
			
		||||
                      key={project.name}
 | 
			
		||||
                      project={project}
 | 
			
		||||
                      handleRenameProject={handleRenameProject}
 | 
			
		||||
                      handleDeleteProject={handleDeleteProject}
 | 
			
		||||
                    />
 | 
			
		||||
                  ))}
 | 
			
		||||
                </ul>
 | 
			
		||||
              ) : (
 | 
			
		||||
                <p className="p-4 my-8 border border-dashed rounded border-chalkboard-30 dark:border-chalkboard-70">
 | 
			
		||||
                  No Projects found
 | 
			
		||||
                  {projects.length === 0
 | 
			
		||||
                    ? ', ready to make your first one?'
 | 
			
		||||
                    : ` with the search term "${query}"`}
 | 
			
		||||
                </p>
 | 
			
		||||
              )}
 | 
			
		||||
            </>
 | 
			
		||||
          )}
 | 
			
		||||
        </section>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li className="contents">
 | 
			
		||||
              <ActionButton
 | 
			
		||||
                Element="externalLink"
 | 
			
		||||
                to="https://zoo.dev/blog"
 | 
			
		||||
                onClick={openExternalBrowserIfDesktop('https://zoo.dev/blog')}
 | 
			
		||||
                className={sidebarButtonClasses}
 | 
			
		||||
                iconStart={{
 | 
			
		||||
                  icon: 'sketch',
 | 
			
		||||
                  bgClassName: '!bg-transparent rounded-sm',
 | 
			
		||||
                }}
 | 
			
		||||
                data-testid="home-blog"
 | 
			
		||||
              >
 | 
			
		||||
                Read the Zoo blog
 | 
			
		||||
              </ActionButton>
 | 
			
		||||
            </li>
 | 
			
		||||
          </ul>
 | 
			
		||||
        </aside>
 | 
			
		||||
        <ProjectGrid
 | 
			
		||||
          searchResults={searchResults}
 | 
			
		||||
          projects={projects}
 | 
			
		||||
          query={query}
 | 
			
		||||
          sort={sort}
 | 
			
		||||
          className="flex-1 col-start-2 -col-end-1 overflow-y-auto pr-2 pb-24"
 | 
			
		||||
        />
 | 
			
		||||
        <LowerRightControls />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface HomeHeaderProps extends HTMLProps<HTMLDivElement> {
 | 
			
		||||
  setQuery: (query: string) => void
 | 
			
		||||
  sort: string
 | 
			
		||||
  setSearchParams: (params: Record<string, string>) => void
 | 
			
		||||
  settings: ReturnType<typeof useSettings>
 | 
			
		||||
  readWriteProjectDir: ReadWriteProjectState
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function HomeHeader({
 | 
			
		||||
  setQuery,
 | 
			
		||||
  sort,
 | 
			
		||||
  setSearchParams,
 | 
			
		||||
  settings,
 | 
			
		||||
  readWriteProjectDir,
 | 
			
		||||
  ...rest
 | 
			
		||||
}: HomeHeaderProps) {
 | 
			
		||||
  const isSortByModified = sort?.includes('modified') || !sort || sort === null
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <section {...rest}>
 | 
			
		||||
      <div className="flex justify-between items-center select-none">
 | 
			
		||||
        <div className="flex gap-8 items-center">
 | 
			
		||||
          <h1 className="text-3xl font-bold">Projects</h1>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="flex gap-2 items-center">
 | 
			
		||||
          <ProjectSearchBar setQuery={setQuery} />
 | 
			
		||||
          <small>Sort by</small>
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            data-testid="home-sort-by-name"
 | 
			
		||||
            className={`text-xs border-primary/10 ${
 | 
			
		||||
              !sort.includes('name')
 | 
			
		||||
                ? 'text-chalkboard-80 dark:text-chalkboard-40'
 | 
			
		||||
                : ''
 | 
			
		||||
            }`}
 | 
			
		||||
            onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))}
 | 
			
		||||
            iconStart={{
 | 
			
		||||
              icon: getSortIcon(sort, 'name'),
 | 
			
		||||
              bgClassName: 'bg-transparent',
 | 
			
		||||
              iconClassName: !sort.includes('name')
 | 
			
		||||
                ? '!text-chalkboard-90 dark:!text-chalkboard-30'
 | 
			
		||||
                : '',
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            Name
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            data-testid="home-sort-by-modified"
 | 
			
		||||
            className={`text-xs border-primary/10 ${
 | 
			
		||||
              !isSortByModified
 | 
			
		||||
                ? 'text-chalkboard-80 dark:text-chalkboard-40'
 | 
			
		||||
                : ''
 | 
			
		||||
            }`}
 | 
			
		||||
            onClick={() =>
 | 
			
		||||
              setSearchParams(getNextSearchParams(sort, 'modified'))
 | 
			
		||||
            }
 | 
			
		||||
            iconStart={{
 | 
			
		||||
              icon: sort ? getSortIcon(sort, 'modified') : 'arrowDown',
 | 
			
		||||
              bgClassName: 'bg-transparent',
 | 
			
		||||
              iconClassName: !isSortByModified
 | 
			
		||||
                ? '!text-chalkboard-90 dark:!text-chalkboard-30'
 | 
			
		||||
                : '',
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            Last Modified
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
 | 
			
		||||
        Loaded from{' '}
 | 
			
		||||
        <Link
 | 
			
		||||
          data-testid="project-directory-settings-link"
 | 
			
		||||
          to={`${PATHS.HOME + PATHS.SETTINGS_USER}#projectDirectory`}
 | 
			
		||||
          className="text-chalkboard-90 dark:text-chalkboard-20 underline underline-offset-2"
 | 
			
		||||
        >
 | 
			
		||||
          {settings.app.projectDirectory.current}
 | 
			
		||||
        </Link>
 | 
			
		||||
        .
 | 
			
		||||
      </p>
 | 
			
		||||
      {!readWriteProjectDir.value && (
 | 
			
		||||
        <section>
 | 
			
		||||
          <div className="flex items-center select-none">
 | 
			
		||||
            <div className="flex gap-8 items-center justify-between grow bg-destroy-80 text-white py-1 px-4 my-2 rounded-sm">
 | 
			
		||||
              <p className="">{errorMessage(readWriteProjectDir.error)}</p>
 | 
			
		||||
              <Link
 | 
			
		||||
                data-testid="project-directory-settings-link"
 | 
			
		||||
                to={`${PATHS.HOME + PATHS.SETTINGS_USER}#projectDirectory`}
 | 
			
		||||
                className="py-1 text-white underline underline-offset-2 text-sm"
 | 
			
		||||
              >
 | 
			
		||||
                Change Project Directory
 | 
			
		||||
              </Link>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </section>
 | 
			
		||||
      )}
 | 
			
		||||
    </section>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface ProjectGridProps extends HTMLProps<HTMLDivElement> {
 | 
			
		||||
  searchResults: Project[]
 | 
			
		||||
  projects: Project[]
 | 
			
		||||
  query: string
 | 
			
		||||
  sort: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ProjectGrid({
 | 
			
		||||
  searchResults,
 | 
			
		||||
  projects,
 | 
			
		||||
  query,
 | 
			
		||||
  sort,
 | 
			
		||||
  ...rest
 | 
			
		||||
}: ProjectGridProps) {
 | 
			
		||||
  const state = useSystemIOState()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <section data-testid="home-section" {...rest}>
 | 
			
		||||
      {state.matches(SystemIOMachineStates.readingFolders) ? (
 | 
			
		||||
        <Loading>Loading your Projects...</Loading>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <>
 | 
			
		||||
          {searchResults.length > 0 ? (
 | 
			
		||||
            <ul className="grid w-full md:grid-cols-2 xl:grid-cols-4 gap-4">
 | 
			
		||||
              {searchResults.sort(getSortFunction(sort)).map((project) => (
 | 
			
		||||
                <ProjectCard
 | 
			
		||||
                  key={project.name}
 | 
			
		||||
                  project={project}
 | 
			
		||||
                  handleRenameProject={handleRenameProject}
 | 
			
		||||
                  handleDeleteProject={handleDeleteProject}
 | 
			
		||||
                />
 | 
			
		||||
              ))}
 | 
			
		||||
            </ul>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <p className="p-4 my-8 border border-dashed rounded border-chalkboard-30 dark:border-chalkboard-70">
 | 
			
		||||
              No Projects found
 | 
			
		||||
              {projects.length === 0
 | 
			
		||||
                ? ', ready to make your first one?'
 | 
			
		||||
                : ` with the search term "${query}"`}
 | 
			
		||||
            </p>
 | 
			
		||||
          )}
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
    </section>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** Type narrowing function of unknown error to a string */
 | 
			
		||||
function errorMessage(error: unknown): string {
 | 
			
		||||
  if (error !== undefined && error instanceof Error) {
 | 
			
		||||
    return error.message
 | 
			
		||||
  }
 | 
			
		||||
  if (error && typeof error === 'object') {
 | 
			
		||||
    return JSON.stringify(error)
 | 
			
		||||
  }
 | 
			
		||||
  if (typeof error === 'string') {
 | 
			
		||||
    return error
 | 
			
		||||
  }
 | 
			
		||||
  return 'Unknown error'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function handleRenameProject(
 | 
			
		||||
  e: FormEvent<HTMLFormElement>,
 | 
			
		||||
  project: Project
 | 
			
		||||
) {
 | 
			
		||||
  const { newProjectName } = Object.fromEntries(
 | 
			
		||||
    new FormData(e.target as HTMLFormElement)
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  if (typeof newProjectName === 'string' && newProjectName.startsWith('.')) {
 | 
			
		||||
    toast.error('Project names cannot start with a dot (.)')
 | 
			
		||||
    return
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (newProjectName !== project.name) {
 | 
			
		||||
    systemIOActor.send({
 | 
			
		||||
      type: SystemIOMachineEvents.renameProject,
 | 
			
		||||
      data: {
 | 
			
		||||
        requestedProjectName: String(newProjectName),
 | 
			
		||||
        projectName: project.name,
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function handleDeleteProject(project: Project) {
 | 
			
		||||
  systemIOActor.send({
 | 
			
		||||
    type: SystemIOMachineEvents.deleteProject,
 | 
			
		||||
    data: { requestedProjectName: project.name },
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Home
 | 
			
		||||
 | 
			
		||||