diff --git a/e2e/playwright/file-tree.spec.ts b/e2e/playwright/file-tree.spec.ts index 052a5c25a3..08cd0aab31 100644 --- a/e2e/playwright/file-tree.spec.ts +++ b/e2e/playwright/file-tree.spec.ts @@ -3,6 +3,7 @@ import { test, expect } from './fixtures/fixtureSetup' import * as fsp from 'fs/promises' import * as fs from 'fs' import { + createProject, executorInputPath, getUtils, setup, @@ -114,20 +115,15 @@ test.describe('when using the file tree to', () => { async ({ browser: _, tronApp }, testInfo) => { await tronApp.initialise() - const { - panesOpen, - createAndSelectProject, - pasteCodeInEditor, - renameFile, - editorTextMatches, - } = await getUtils(tronApp.page, test) + const { panesOpen, pasteCodeInEditor, renameFile, editorTextMatches } = + await getUtils(tronApp.page, test) await tronApp.page.setViewportSize({ width: 1200, height: 500 }) tronApp.page.on('console', console.log) await panesOpen(['files', 'code']) - await createAndSelectProject('project-000') + await createProject({ name: 'project-000', page: tronApp.page }) // File the main.kcl with contents const kclCube = await fsp.readFile( @@ -164,15 +160,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('') @@ -201,7 +196,6 @@ test.describe('when using the file tree to', () => { const { openKclCodePanel, openFilePanel, - createAndSelectProject, pasteCodeInEditor, createNewFileAndSelect, renameFile, @@ -212,7 +206,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 @@ -255,20 +249,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', @@ -298,7 +287,6 @@ test.describe('when using the file tree to', () => { const { panesOpen, - createAndSelectProject, pasteCodeInEditor, createNewFile, openDebugPanel, @@ -310,7 +298,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( @@ -714,7 +702,7 @@ _test.describe('Renaming in the file tree', () => { }) await _test.step('Rename the folder', async () => { - await page.waitForTimeout(60000) + await page.waitForTimeout(1000) await folderToRename.click({ button: 'right' }) await _expect(renameMenuItem).toBeVisible() await renameMenuItem.click() diff --git a/e2e/playwright/projects.spec.ts b/e2e/playwright/projects.spec.ts index 6f3402c4b1..0ecbde64ff 100644 --- a/e2e/playwright/projects.spec.ts +++ b/e2e/playwright/projects.spec.ts @@ -7,7 +7,7 @@ import { Paths, setupElectron, tearDown, - createProjectAndRenameIt, + createProject, } from './test-utils' import fsp from 'fs/promises' import fs from 'fs' @@ -503,6 +503,244 @@ 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() + }) + + await test.step(`Check the project was renamed and we navigated`, async () => { + await expect(projectMenuButton).toContainText(projectRenamedName) + await expect(projectMenuButton).not.toContainText(projectName) + expect(page.url()).toContain(projectRenamedName) + expect(page.url()).not.toContain(projectName) + }) + + await electronApp.close() + } + ) + + test( + `Delete from project page`, + { tag: '@electron' }, + async ({ browserName: _ }, testInfo) => { + const projectName = `my_project_to_delete` + const { electronApp, page } = await setupElectron({ + testInfo, + folderSetupFn: async (dir) => { + await fsp.mkdir(`${dir}/${projectName}`, { recursive: true }) + await fsp.copyFile( + 'src/wasm-lib/tests/executor/inputs/router-template-slate.kcl', + `${dir}/${projectName}/main.kcl` + ) + }, + }) + const u = await getUtils(page) + + // Constants and locators + const projectHomeLink = page.getByTestId('project-link') + const commandButton = page.getByRole('button', { name: 'Commands' }) + const commandOption = page.getByRole('option', { name: 'delete project' }) + const projectNameOption = page.getByRole('option', { name: projectName }) + const commandWarning = page.getByText('Are you sure you want to delete?') + const commandSubmitButton = page.getByRole('button', { + name: 'Submit command', + }) + const toastMessage = page.getByText(`Successfully deleted`) + const noProjectsMessage = page.getByText('No Projects found') + + await test.step(`Setup`, async () => { + await page.setViewportSize({ width: 1200, height: 500 }) + page.on('console', console.log) + + await projectHomeLink.click() + await u.waitForPageLoad() + }) + + await test.step(`Run delete command via command palette`, async () => { + await commandButton.click() + await commandOption.click() + await projectNameOption.click() + + await expect(commandWarning).toBeVisible() + await expect(commandSubmitButton).toBeVisible() + await commandSubmitButton.click() + + await expect(toastMessage).toBeVisible() + }) + + await test.step(`Check the project was deleted and we navigated home`, async () => { + await expect(noProjectsMessage).toBeVisible() + }) + + await electronApp.close() + } + ) + test( + `Rename from home page`, + { tag: '@electron' }, + async ({ browserName: _ }, testInfo) => { + const projectName = `my_project_to_rename` + const { electronApp, page } = await setupElectron({ + testInfo, + folderSetupFn: async (dir) => { + await fsp.mkdir(`${dir}/${projectName}`, { recursive: true }) + await fsp.copyFile( + 'src/wasm-lib/tests/executor/inputs/router-template-slate.kcl', + `${dir}/${projectName}/main.kcl` + ) + }, + }) + + // Constants and locators + const projectHomeLink = page.getByTestId('project-link') + const commandButton = page.getByRole('button', { name: 'Commands' }) + const commandOption = page.getByRole('option', { name: 'rename project' }) + const projectNameOption = page.getByRole('option', { name: projectName }) + const projectRenamedName = `project-000` + const commandContinueButton = page.getByRole('button', { + name: 'Continue', + }) + const commandSubmitButton = page.getByRole('button', { + name: 'Submit command', + }) + const toastMessage = page.getByText(`Successfully renamed`) + + await test.step(`Setup`, async () => { + await page.setViewportSize({ width: 1200, height: 500 }) + page.on('console', console.log) + await expect(projectHomeLink).toBeVisible() + }) + + await test.step(`Run rename command via command palette`, async () => { + await commandButton.click() + await commandOption.click() + await projectNameOption.click() + + await expect(commandContinueButton).toBeVisible() + await commandContinueButton.click() + + await expect(commandSubmitButton).toBeVisible() + await commandSubmitButton.click() + + await expect(toastMessage).toBeVisible() + }) + + await test.step(`Check the project was renamed`, async () => { + await expect( + page.getByRole('link', { name: projectRenamedName }) + ).toBeVisible() + await expect(projectHomeLink).not.toHaveText(projectName) + }) + + await electronApp.close() + } + ) + test( + `Delete from home page`, + { tag: '@electron' }, + async ({ browserName: _ }, testInfo) => { + const projectName = `my_project_to_delete` + const { electronApp, page } = await setupElectron({ + testInfo, + folderSetupFn: async (dir) => { + await fsp.mkdir(`${dir}/${projectName}`, { recursive: true }) + await fsp.copyFile( + 'src/wasm-lib/tests/executor/inputs/router-template-slate.kcl', + `${dir}/${projectName}/main.kcl` + ) + }, + }) + + // Constants and locators + const projectHomeLink = page.getByTestId('project-link') + const commandButton = page.getByRole('button', { name: 'Commands' }) + const commandOption = page.getByRole('option', { name: 'delete project' }) + const projectNameOption = page.getByRole('option', { name: projectName }) + const commandWarning = page.getByText('Are you sure you want to delete?') + const commandSubmitButton = page.getByRole('button', { + name: 'Submit command', + }) + const toastMessage = page.getByText(`Successfully deleted`) + const noProjectsMessage = page.getByText('No Projects found') + + await test.step(`Setup`, async () => { + await page.setViewportSize({ width: 1200, height: 500 }) + page.on('console', console.log) + await expect(projectHomeLink).toBeVisible() + }) + + await test.step(`Run delete command via command palette`, async () => { + await commandButton.click() + await commandOption.click() + await projectNameOption.click() + + await expect(commandWarning).toBeVisible() + await expect(commandSubmitButton).toBeVisible() + await commandSubmitButton.click() + + await expect(toastMessage).toBeVisible() + }) + + await test.step(`Check the project was deleted`, async () => { + await expect(projectHomeLink).not.toBeVisible() + await expect(noProjectsMessage).toBeVisible() + }) + + await electronApp.close() + } + ) +}) + test( 'File in the file pane should open with a single click', { tag: '@electron' }, @@ -643,7 +881,7 @@ test( page.on('console', console.log) await test.step('delete the middle project, i.e. the bracket project', async () => { - const project = page.getByText('bracket') + const project = page.getByTestId('project-link').getByText('bracket') await project.hover() await project.focus() @@ -687,9 +925,7 @@ 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 createProject({ name: 'project-000', page, returnHome: true }) await expect(page.getByText('project-000')).toBeVisible() }) @@ -861,22 +1097,18 @@ test( page.on('console', console.log) + // 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 projectLinks.getByText('project-000').click() - await expect(page.getByText('project-000')).toBeVisible() - - await page.getByText('project-000').click() - - await expect(page.getByTestId('loading')).toBeAttached() - await expect(page.getByTestId('loading')).not.toBeAttached({ - timeout: 20_000, - }) + await u.waitForPageLoad() await expect( page.getByRole('button', { name: 'Start Sketch' }) @@ -919,16 +1151,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() } @@ -1103,10 +1329,7 @@ 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 createProject({ name: 'project-000', page, returnHome: true }) await expect(page.getByText(`project-000`)).toBeVisible() }) @@ -1433,7 +1656,7 @@ test( page.on('console', console.log) await test.step('Should create and name a project called wrist brace', async () => { - await createProjectAndRenameIt({ name: 'wrist brace', page }) + await createProject({ name: 'wrist brace', page, returnHome: true }) }) await test.step('Should go through onboarding', async () => { diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png index fb5dd4aac5..78e1c50726 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-win32.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-win32.png index fd6d67c8b3..5ff8e179df 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-win32.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-win32.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-linux.png index 2b075f3000..fe1a02df5c 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-win32.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-win32.png index 7753b12fc3..7e774fc588 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-win32.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-win32.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-2-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-2-Google-Chrome-linux.png index ec79f0465b..1ea5160b96 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-2-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-2-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-circle-should-look-right-1-Google-Chrome-win32.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-circle-should-look-right-1-Google-Chrome-win32.png index 0a4b783b03..0ee744c149 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-circle-should-look-right-1-Google-Chrome-win32.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-circle-should-look-right-1-Google-Chrome-win32.png differ diff --git a/e2e/playwright/test-utils.ts b/e2e/playwright/test-utils.ts index 5193f87265..23f6112bdd 100644 --- a/e2e/playwright/test-utils.ts +++ b/e2e/playwright/test-utils.ts @@ -459,17 +459,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 () => { - 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 }) @@ -954,30 +943,25 @@ export async function isOutOfViewInScrollContainer( return isOutOfView } -export async function createProjectAndRenameIt({ +export async function createProject({ name, page, + returnHome = false, }: { name: string page: Page + returnHome?: boolean }) { - await page.getByRole('button', { name: 'New project' }).click() - await expect(page.getByText('Successfully created')).toBeVisible() - await expect(page.getByText('Successfully created')).not.toBeVisible() - - await expect(page.getByText(`project-000`)).toBeVisible() - await page.getByText(`project-000`).hover() - await page.getByText(`project-000`).focus() - - await page.getByLabel('sketch').first().click() - - await page.waitForTimeout(100) - - // type the name passed in - await page.keyboard.press('Backspace') - await page.keyboard.type(name) - - await page.getByLabel('checkmark').last().click() + await test.step(`Create project and navigate to it`, async () => { + await page.getByRole('button', { name: 'New project' }).click() + await page.getByRole('textbox', { name: 'Name' }).fill(name) + await page.getByRole('button', { name: 'Continue' }).click() + + if (returnHome) { + await page.waitForURL('**/file/**', { waitUntil: 'domcontentloaded' }) + await page.getByTestId('app-logo').click() + } + }) } export function executorInputPath(fileName: string): string { diff --git a/e2e/playwright/testing-settings.spec.ts b/e2e/playwright/testing-settings.spec.ts index 63aa05f464..40871f9fee 100644 --- a/e2e/playwright/testing-settings.spec.ts +++ b/e2e/playwright/testing-settings.spec.ts @@ -7,6 +7,7 @@ import { setupElectron, tearDown, executorInputPath, + createProject, } from './test-utils' import { SaveSettingsPayload, SettingsLevel } from 'lib/settings/settingsTypes' import { SETTINGS_FILE_NAME } 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 page.getByRole('button', { name: 'Dismiss' }).click() await changeColor('58') await expect(logoLink).toHaveCSS('--primary-hue', '58') diff --git a/e2e/playwright/text-to-cad-tests.spec.ts b/e2e/playwright/text-to-cad-tests.spec.ts index af14f7cd9a..c4808234e5 100644 --- a/e2e/playwright/text-to-cad-tests.spec.ts +++ b/e2e/playwright/text-to-cad-tests.spec.ts @@ -1,5 +1,11 @@ import { test, expect, Page } from '@playwright/test' -import { getUtils, setup, tearDown, setupElectron } from './test-utils' +import { + getUtils, + setup, + tearDown, + setupElectron, + createProject, +} from './test-utils' import { join } from 'path' import fs from 'fs' @@ -700,12 +706,10 @@ test( const fileExists = () => fs.existsSync(join(dir, projectName, textToCadFileName)) - const { - createAndSelectProject, - openFilePanel, - openKclCodePanel, - waitForPageLoad, - } = await getUtils(page, test) + const { openFilePanel, openKclCodePanel, waitForPageLoad } = await getUtils( + page, + test + ) await page.setViewportSize({ width: 1200, height: 500 }) @@ -719,7 +723,7 @@ test( ) // Create and navigate to the project - await createAndSelectProject('project-000') + await createProject({ name: 'project-000', page }) // Wait for Start Sketch otherwise you will not have access Text-to-CAD command await waitForPageLoad() diff --git a/src/App.tsx b/src/App.tsx index 558ee67ef8..d50e54ba07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,9 +22,25 @@ import Gizmo from 'components/Gizmo' import { CoreDumpManager } from 'lib/coredump' import { UnitsMenu } from 'components/UnitsMenu' import { CameraProjectionToggle } from 'components/CameraProjectionToggle' +import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher' +import { useCommandsContext } from 'hooks/useCommandsContext' export function App() { const { project, file } = useLoaderData() as IndexLoaderData + const { commandBarSend } = useCommandsContext() + + // Keep a lookout for a URL query string that invokes the 'import file from URL' command + useCreateFileLinkQuery((argDefaultValues) => { + commandBarSend({ + type: 'Find and select command', + data: { + groupId: 'projects', + name: 'Import file from URL', + argDefaultValues, + }, + }) + }) + useRefreshSettings(PATHS.FILE + 'SETTINGS') const navigate = useNavigate() const filePath = useAbsoluteFilePath() diff --git a/src/Router.tsx b/src/Router.tsx index 71c8838a7d..5c2dd9f8d9 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -42,6 +42,8 @@ import { coreDump } from 'lang/wasm' import { useMemo } from 'react' import { AppStateProvider } from 'AppState' import { reportRejection } from 'lib/trap' +import { ProjectsContextProvider } from 'components/ProjectsContextProvider' +import { ProtocolHandler } from 'components/ProtocolHandler' const createRouter = isDesktop() ? createHashRouter : createBrowserRouter @@ -52,27 +54,34 @@ const router = createRouter([ /* Make sure auth is the outermost provider or else we will have * inefficient re-renders, use the react profiler to see. */ element: ( - - - - - - - - - - - + + + + + + + + + + + + + + + ), errorElement: , children: [ { path: PATHS.INDEX, - loader: async () => { + loader: async ({ request }) => { const onDesktop = isDesktop() + const url = new URL(request.url) return onDesktop - ? redirect(PATHS.HOME) - : redirect(PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME) + ? redirect(PATHS.HOME + (url.search || '')) + : redirect( + PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME + (url.search || '') + ) }, }, { diff --git a/src/components/CommandBar/CommandBar.tsx b/src/components/CommandBar/CommandBar.tsx index bb46c7d37d..b2a30a8b9d 100644 --- a/src/components/CommandBar/CommandBar.tsx +++ b/src/components/CommandBar/CommandBar.tsx @@ -22,6 +22,7 @@ export const CommandBar = () => { // Close the command bar when navigating useEffect(() => { + if (commandBarState.matches('Closed')) return commandBarSend({ type: 'Close' }) }, [pathname]) diff --git a/src/components/CommandComboBox.tsx b/src/components/CommandComboBox.tsx index d647c9af58..a4f1566d2a 100644 --- a/src/components/CommandComboBox.tsx +++ b/src/components/CommandComboBox.tsx @@ -4,6 +4,7 @@ import { useCommandsContext } from 'hooks/useCommandsContext' import { Command } from 'lib/commandTypes' import { useEffect, useState } from 'react' import { CustomIcon } from './CustomIcon' +import { getActorNextEvents } from 'lib/utils' function CommandComboBox({ options, @@ -73,7 +74,8 @@ function CommandComboBox({ {'icon' in option && option.icon && ( @@ -96,3 +98,11 @@ function CommandComboBox({ } export default CommandComboBox + +function optionIsDisabled(option: Command): boolean { + return ( + 'machineActor' in option && + option.machineActor !== undefined && + !getActorNextEvents(option.machineActor.getSnapshot()).includes(option.name) + ) +} diff --git a/src/components/ProjectSidebarMenu.tsx b/src/components/ProjectSidebarMenu.tsx index 986da78901..7380fcfc08 100644 --- a/src/components/ProjectSidebarMenu.tsx +++ b/src/components/ProjectSidebarMenu.tsx @@ -10,11 +10,14 @@ import { APP_NAME } from 'lib/constants' import { useCommandsContext } from 'hooks/useCommandsContext' import { CustomIcon } from './CustomIcon' import { useLspContext } from './LspProvider' -import { engineCommandManager } from 'lib/singletons' +import { codeManager, engineCommandManager } from 'lib/singletons' import { machineManager } from 'lib/machineManager' import usePlatform from 'hooks/usePlatform' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import Tooltip from './Tooltip' +import { createFileLink } from 'lib/createFileLink' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' +import toast from 'react-hot-toast' const ProjectSidebarMenu = ({ project, @@ -96,6 +99,7 @@ function ProjectMenuPopover({ const location = useLocation() const navigate = useNavigate() const filePath = useAbsoluteFilePath() + const { settings, auth } = useSettingsAuthContext() const { commandBarState, commandBarSend } = useCommandsContext() const { onProjectClose } = useLspContext() const exportCommandInfo = { name: 'Export', groupId: 'modeling' } @@ -154,7 +158,6 @@ function ProjectMenuPopover({ data: exportCommandInfo, }), }, - 'break', { id: 'make', Element: 'button', @@ -180,6 +183,28 @@ function ProjectMenuPopover({ }) }, }, + { + id: 'share-link', + Element: 'button', + children: 'Share link to file', + onClick: async () => { + const shareUrl = await createFileLink(auth.context.token, { + code: codeManager.code, + name: file?.name || '', + units: settings.context.modeling.defaultUnit.current, + }) + + console.log(shareUrl) + + await globalThis.navigator.clipboard.writeText(shareUrl) + toast.success( + 'Link copied to clipboard. Anyone who clicks this link will get a copy of this file. Share carefully!', + { + duration: 5000, + } + ) + }, + }, 'break', { id: 'go-home', diff --git a/src/components/ProjectsContextProvider.tsx b/src/components/ProjectsContextProvider.tsx new file mode 100644 index 0000000000..b6df1776fd --- /dev/null +++ b/src/components/ProjectsContextProvider.tsx @@ -0,0 +1,400 @@ +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, useCallback, 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, useSearchParams } from 'react-router-dom' +import { PATHS } from 'lib/paths' +import { + createNewProjectDirectory, + listProjects, + renameProjectDirectory, +} from 'lib/desktop' +import { + getNextProjectIndex, + interpolateProjectNameWithIndex, + doesProjectNameNeedInterpolated, + getNextFileName, +} from 'lib/desktopFS' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' +import useStateMachineCommands from 'hooks/useStateMachineCommands' +import { projectsCommandBarConfig } from 'lib/commandBarConfigs/projectsCommandConfig' +import { isDesktop } from 'lib/isDesktop' +import { + CREATE_FILE_URL_PARAM, + FILE_EXT, + PROJECT_ENTRYPOINT, +} from 'lib/constants' +import { DeepPartial } from 'lib/types' +import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' +import { codeManager } from 'lib/singletons' +import { + loadAndValidateSettings, + projectConfigurationToSettingsPayload, + saveSettings, + setSettingsAtLevel, +} from 'lib/settings/settingsUtils' +import { Project } from 'lib/project' + +type MachineContext = { + state?: StateFrom + send: Prop, 'send'> +} + +export const ProjectsMachineContext = createContext( + {} as MachineContext +) + +/** + * Watches the project directory and provides project management-related commands, + * like "Create project", "Open project", "Delete project", etc. + * + * If in the future we implement full-fledge project management in the web version, + * we can unify these components but for now, we need this to be only for the desktop version. + */ +export const ProjectsContextProvider = ({ + children, +}: { + children: React.ReactNode +}) => { + const navigate = useNavigate() + const location = useLocation() + const [searchParams, setSearchParams] = useSearchParams() + const clearImportSearchParams = useCallback(() => { + // Clear the search parameters related to the "Import file from URL" command + // or we'll never be able cancel or submit it. + searchParams.delete(CREATE_FILE_URL_PARAM) + searchParams.delete('code') + searchParams.delete('name') + searchParams.delete('units') + setSearchParams(searchParams) + }, [searchParams, setSearchParams]) + const { commandBarSend } = useCommandsContext() + const { onProjectOpen } = useLspContext() + const { + settings: { context: settings, send: settingsSend }, + } = useSettingsAuthContext() + + 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) { + const newPathName = location.pathname.replace( + encodeURIComponent(event.output.oldName), + encodeURIComponent(event.output.newName) + ) + navigate(newPathName) + return + } + } + }, + navigateToFile: ({ context, event }) => { + if (event.type !== 'xstate.done.actor.create-file') return + // For now, the browser version of create-file doesn't need to navigate + // since it just overwrites the current file. + if (!isDesktop()) return + let projectPath = window.electron.join( + context.defaultDirectory, + event.output.projectName + ) + let filePath = window.electron.join( + projectPath, + event.output.fileName + ) + onProjectOpen( + { + name: event.output.projectName, + path: projectPath, + }, + null + ) + const pathToNavigateTo = `${PATHS.FILE}/${encodeURIComponent( + filePath + )}` + navigate(pathToNavigateTo) + }, + 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(async () => { + if (!isDesktop()) return [] as Project[] + return 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) + } + + 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, + } + }), + createFile: fromPromise(async ({ input }) => { + let projectName = + (input.method === 'newProject' ? input.name : input.projectName) || + settings.projects.defaultProjectName.current + let fileName = + input.method === 'newProject' + ? PROJECT_ENTRYPOINT + : input.name.endsWith(FILE_EXT) + ? input.name + : input.name + FILE_EXT + let message = 'File created successfully' + const unitsConfiguration: DeepPartial = { + settings: { + project: { + directory: settings.app.projectDirectory.current, + }, + modeling: { + base_unit: input.units, + }, + }, + } + + if (isDesktop()) { + const needsInterpolated = + doesProjectNameNeedInterpolated(projectName) + console.log( + `The project name "${projectName}" needs interpolated: ${needsInterpolated}` + ) + if (needsInterpolated) { + const nextIndex = getNextProjectIndex(projectName, input.projects) + projectName = interpolateProjectNameWithIndex( + projectName, + nextIndex + ) + } + + // Create the project around the file if newProject + if (input.method === 'newProject') { + await createNewProjectDirectory( + projectName, + input.code, + unitsConfiguration + ) + message = `Project "${projectName}" created successfully with link contents` + } else { + let projectPath = window.electron.join( + settings.app.projectDirectory.current, + projectName + ) + + message = `File "${fileName}" created successfully` + const existingConfiguration = await loadAndValidateSettings( + projectPath + ) + const settingsToSave = setSettingsAtLevel( + existingConfiguration.settings, + 'project', + projectConfigurationToSettingsPayload(unitsConfiguration) + ) + await saveSettings(settingsToSave, projectPath) + } + + // Create the file + let baseDir = window.electron.join( + settings.app.projectDirectory.current, + projectName + ) + const { name, path } = getNextFileName({ + entryName: fileName, + baseDir, + }) + fileName = name + + await window.electron.writeFile(path, input.code || '') + } else { + // Browser version doesn't navigate, just overwrites the current file + clearImportSearchParams() + codeManager.updateCodeStateEditor(input.code || '') + await codeManager.writeToFile() + message = 'File successfully overwritten with link contents' + settingsSend({ + type: 'set.modeling.defaultUnit', + data: { + level: 'project', + value: input.units, + }, + }) + } + + return { + message, + fileName, + projectName, + } + }), + }, + 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, + onCancel: clearImportSearchParams, + }) + + return ( + + {children} + + ) +} diff --git a/src/components/ProtocolHandler.tsx b/src/components/ProtocolHandler.tsx new file mode 100644 index 0000000000..1c8c1f7051 --- /dev/null +++ b/src/components/ProtocolHandler.tsx @@ -0,0 +1,47 @@ +import { getSystemTheme } from 'lib/theme' +import { ZOO_STUDIO_PROTOCOL } from 'lib/link' +import { useState } from 'react' +import { useCreateFileLinkQuery, CreateFileSchemaMethodOptional } from 'hooks/useCreateFileLinkQueryWatcher' +import { isDesktop } from 'lib/isDesktop' +import { Spinner } from './Spinner' + +export const ProtocolHandler = (props: { children: ReactNode } ) => { + const [hasCustomProtocolScheme, setHasCustomProtocolScheme] = useState(false) + const [hasAsked, setHasAsked] = useState(false) + useCreateFileLinkQuery((args) => { + if (hasAsked) return + window.location.href = `zoo-studio:${JSON.stringify(args)}` + setHasAsked(true) + setHasCustomProtocolScheme(true) + }) + + const continueToWebApp = () => { + setHasCustomProtocolScheme(false) + } + + const pathLogomarkSvg = `${isDesktop() ? '.' : ''}/zma-logomark.svg` + + return hasCustomProtocolScheme ?
+
+
+
+
+
Launching
+ + + +
+
+
+
: props.children +} diff --git a/src/hooks/useCommandsContext.ts b/src/hooks/useCommandsContext.ts index e34e894cec..e8ae9e5b63 100644 --- a/src/hooks/useCommandsContext.ts +++ b/src/hooks/useCommandsContext.ts @@ -6,5 +6,6 @@ export const useCommandsContext = () => { return { commandBarSend: commandBarActor.send, commandBarState, + commandBarActor, } } diff --git a/src/hooks/useCreateFileLinkQueryWatcher.ts b/src/hooks/useCreateFileLinkQueryWatcher.ts new file mode 100644 index 0000000000..50e42f7794 --- /dev/null +++ b/src/hooks/useCreateFileLinkQueryWatcher.ts @@ -0,0 +1,68 @@ +import { base64ToString } from 'lib/base64' +import { CREATE_FILE_URL_PARAM, DEFAULT_FILE_NAME } from 'lib/constants' +import { useEffect } from 'react' +import { useSearchParams } from 'react-router-dom' +import { useSettingsAuthContext } from './useSettingsAuthContext' +import { isDesktop } from 'lib/isDesktop' +import { FileLinkParams } from 'lib/createFileLink' +import { ProjectsCommandSchema } from 'lib/commandBarConfigs/projectsCommandConfig' +import { baseUnitsUnion } from 'lib/settings/settingsTypes' + +// For initializing the command arguments, we actually want `method` to be undefined +// so that we don't skip it in the command palette. +export type CreateFileSchemaMethodOptional = Omit< + ProjectsCommandSchema['Import file from URL'], + 'method' +> & { + method?: 'newProject' | 'existingProject' +} + +/** + * companion to createFileLink. This hook runs an effect on mount that + * checks the URL for the CREATE_FILE_URL_PARAM and triggers the "Create file" + * command if it is present, loading the command's default values from the other + * URL parameters. + */ +export function useCreateFileLinkQuery(callback: (args: CreateFileSchemaMethodOptional) => void) { + const [searchParams] = useSearchParams() + const { settings } = useSettingsAuthContext() + + useEffect(() => { + const createFileParam = searchParams.has(CREATE_FILE_URL_PARAM) + + console.log('checking for createFileParam', { + createFileParam, + searchParams: [...searchParams.entries()], + }) + + if (createFileParam) { + const params: FileLinkParams = { + code: base64ToString( + decodeURIComponent(searchParams.get('code') ?? '') + ), + + name: searchParams.get('name') ?? DEFAULT_FILE_NAME, + + units: + (baseUnitsUnion.find((unit) => searchParams.get('units') === unit) || + settings.context.modeling.defaultUnit.default) ?? + settings.context.modeling.defaultUnit.current, + } + + const argDefaultValues: CreateFileSchemaMethodOptional = { + name: params.name + ? isDesktop() + ? params.name.replace('.kcl', '') + : params.name + : isDesktop() + ? settings.context.projects.defaultProjectName.current + : DEFAULT_FILE_NAME, + code: params.code || '', + units: params.units, + method: isDesktop() ? undefined : 'existingProject', + } + + callback(argDefaultValues) + } + }, [searchParams]) +} diff --git a/src/hooks/useProjectsContext.ts b/src/hooks/useProjectsContext.ts new file mode 100644 index 0000000000..2cc3551beb --- /dev/null +++ b/src/hooks/useProjectsContext.ts @@ -0,0 +1,6 @@ +import { ProjectsMachineContext } from 'components/ProjectsContextProvider' +import { useContext } from 'react' + +export const useProjectsContext = () => { + return useContext(ProjectsMachineContext) +} diff --git a/src/hooks/useProjectsLoader.tsx b/src/hooks/useProjectsLoader.tsx index 04e55c9178..aff8edaa8e 100644 --- a/src/hooks/useProjectsLoader.tsx +++ b/src/hooks/useProjectsLoader.tsx @@ -14,7 +14,7 @@ export const useProjectsLoader = (deps?: [number]) => { useEffect(() => { // Useless on web, until we get fake filesystems over there. - if (!isDesktop) return + if (!isDesktop()) return if (deps && deps[0] === lastTs) return diff --git a/src/hooks/useStateMachineCommands.ts b/src/hooks/useStateMachineCommands.ts index 14adeb640a..9dbb14de6a 100644 --- a/src/hooks/useStateMachineCommands.ts +++ b/src/hooks/useStateMachineCommands.ts @@ -1,11 +1,11 @@ import { useEffect } from 'react' -import { AnyStateMachine, Actor, StateFrom } from 'xstate' +import { AnyStateMachine, Actor, StateFrom, EventFrom } from 'xstate' import { createMachineCommand } from '../lib/createMachineCommand' 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, @@ -15,14 +15,13 @@ import { useKclContext } from 'lang/KclProvider' import { useNetworkContext } from 'hooks/useNetworkContext' import { NetworkHealthState } from 'hooks/useNetworkStatus' import { useAppState } from 'AppState' -import { getActorNextEvents } from 'lib/utils' // This might not be necessary, AnyStateMachine from xstate is working export type AllMachines = | typeof modelingMachine | typeof settingsMachine | typeof authMachine - | typeof homeMachine + | typeof projectsMachine interface UseStateMachineCommandsArgs< T extends AllMachines, @@ -60,21 +59,21 @@ export default function useStateMachineCommands< overallState !== NetworkHealthState.Weak) || isExecuting || !isStreamReady - const newCommands = getActorNextEvents(state) + const newCommands = Object.keys(commandBarConfig || {}) .filter((_) => !allCommandsRequireNetwork || !disableAllButtons) - .filter((e) => !['done.', 'error.'].some((n) => e.includes(n))) - .flatMap((type) => - createMachineCommand({ + .flatMap((type) => { + const typeWithProperType = type as EventFrom['type'] + return createMachineCommand({ // The group is the owner machine's ID. groupId: machineId, - type, + type: typeWithProperType, state, send, actor, commandBarConfig, onCancel, }) - ) + }) .filter((c) => c !== null) as Command[] // TS isn't smart enough to know this filter removes nulls commandBarSend({ type: 'Add commands', data: { commands: newCommands } }) @@ -85,5 +84,5 @@ export default function useStateMachineCommands< data: { commands: newCommands }, }) } - }, [state, overallState, isExecuting, isStreamReady]) + }, [overallState, isExecuting, isStreamReady]) } diff --git a/src/lib/base64.test.ts b/src/lib/base64.test.ts new file mode 100644 index 0000000000..edb7db9d25 --- /dev/null +++ b/src/lib/base64.test.ts @@ -0,0 +1,40 @@ +import { expect } from 'vitest' +import { base64ToString, stringToBase64 } from './base64' + +describe('base64 encoding', () => { + test('to base64, simple code', async () => { + const code = `extrusionDistance = 12` + // Generated by online tool + const expectedBase64 = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg==` + + const base64 = stringToBase64(code) + expect(base64).toBe(expectedBase64) + }) + + test(`to base64, code with UTF-8 characters`, async () => { + // example adapted from MDN docs: https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem + const code = `a = 5; Ā = 12.in(); 𐀀 = Ā; 文 = 12.0; 🦄 = -5;` + // Generated by online tool + const expectedBase64 = `YSA9IDU7IMSAID0gMTIuaW4oKTsg8JCAgCA9IMSAOyDmlocgPSAxMi4wOyDwn6aEID0gLTU7` + + const base64 = stringToBase64(code) + expect(base64).toBe(expectedBase64) + }) + + // The following are simply the reverse of the above tests + test('from base64, simple code', async () => { + const base64 = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg==` + const expectedCode = `extrusionDistance = 12` + + const code = base64ToString(base64) + expect(code).toBe(expectedCode) + }) + + test(`from base64, code with UTF-8 characters`, async () => { + const base64 = `YSA9IDU7IMSAID0gMTIuaW4oKTsg8JCAgCA9IMSAOyDmlocgPSAxMi4wOyDwn6aEID0gLTU7` + const expectedCode = `a = 5; Ā = 12.in(); 𐀀 = Ā; 文 = 12.0; 🦄 = -5;` + + const code = base64ToString(base64) + expect(code).toBe(expectedCode) + }) +}) diff --git a/src/lib/base64.ts b/src/lib/base64.ts new file mode 100644 index 0000000000..11b1962bef --- /dev/null +++ b/src/lib/base64.ts @@ -0,0 +1,29 @@ +/** + * Converts a string to a base64 string, preserving the UTF-8 encoding + */ +export function stringToBase64(str: string) { + return bytesToBase64(new TextEncoder().encode(str)) +} + +/** + * Converts a base64 string to a string, preserving the UTF-8 encoding + */ +export function base64ToString(base64: string) { + return new TextDecoder().decode(base64ToBytes(base64)) +} + +/** + * From the MDN Web Docs + * https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem + */ +function base64ToBytes(base64: string) { + const binString = atob(base64) + return Uint8Array.from(binString, (m) => m.codePointAt(0)!) +} + +function bytesToBase64(bytes: Uint8Array) { + const binString = Array.from(bytes, (byte) => + String.fromCodePoint(byte) + ).join('') + return btoa(binString) +} diff --git a/src/lib/commandBarConfigs/homeCommandConfig.ts b/src/lib/commandBarConfigs/homeCommandConfig.ts deleted file mode 100644 index 4ca29bbdcd..0000000000 --- a/src/lib/commandBarConfigs/homeCommandConfig.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { StateMachineCommandSetConfig } from 'lib/commandTypes' -import { homeMachine } from 'machines/homeMachine' - -export type HomeCommandSchema = { - 'Read projects': {} - 'Create project': { - name: string - } - 'Open project': { - name: string - } - 'Delete project': { - name: string - } - 'Rename project': { - oldName: string - newName: string - } -} - -export const homeCommandBarConfig: StateMachineCommandSetConfig< - typeof homeMachine, - HomeCommandSchema -> = { - 'Open project': { - icon: 'arrowRight', - description: 'Open a project', - args: { - name: { - inputType: 'options', - required: true, - options: [], - optionsFromContext: (context) => - context.projects.map((p) => ({ - name: p.name!, - value: p.name!, - })), - }, - }, - }, - 'Create project': { - icon: 'folderPlus', - description: 'Create a project', - args: { - name: { - inputType: 'string', - required: true, - defaultValueFromContext: (context) => context.defaultProjectName, - }, - }, - }, - 'Delete project': { - icon: 'close', - description: 'Delete a project', - needsReview: true, - args: { - name: { - inputType: 'options', - required: true, - options: [], - optionsFromContext: (context) => - context.projects.map((p) => ({ - name: p.name!, - value: p.name!, - })), - }, - }, - }, - 'Rename project': { - icon: 'folder', - description: 'Rename a project', - needsReview: true, - args: { - oldName: { - inputType: 'options', - required: true, - options: [], - optionsFromContext: (context) => - context.projects.map((p) => ({ - name: p.name!, - value: p.name!, - })), - }, - newName: { - inputType: 'string', - required: true, - defaultValueFromContext: (context) => context.defaultProjectName, - }, - }, - }, -} diff --git a/src/lib/commandBarConfigs/projectsCommandConfig.ts b/src/lib/commandBarConfigs/projectsCommandConfig.ts new file mode 100644 index 0000000000..2af32111be --- /dev/null +++ b/src/lib/commandBarConfigs/projectsCommandConfig.ts @@ -0,0 +1,182 @@ +import { UnitLength_type } from '@kittycad/lib/dist/types/src/models' +import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning' +import { StateMachineCommandSetConfig } from 'lib/commandTypes' +import { isDesktop } from 'lib/isDesktop' +import { baseUnitLabels, baseUnitsUnion } from 'lib/settings/settingsTypes' +import { projectsMachine } from 'machines/projectsMachine' + +export type ProjectsCommandSchema = { + 'Read projects': {} + 'Create project': { + name: string + } + 'Open project': { + name: string + } + 'Delete project': { + name: string + } + 'Rename project': { + oldName: string + newName: string + } + 'Import file from URL': { + name: string + code?: string + units: UnitLength_type + method: 'newProject' | 'existingProject' + projectName?: string + } +} + +export const projectsCommandBarConfig: StateMachineCommandSetConfig< + typeof projectsMachine, + ProjectsCommandSchema +> = { + 'Open project': { + icon: 'arrowRight', + description: 'Open a project', + args: { + name: { + inputType: 'options', + required: true, + options: [], + optionsFromContext: (context) => + context.projects.map((p) => ({ + name: p.name!, + value: p.name!, + })), + }, + }, + }, + 'Create project': { + icon: 'folderPlus', + description: 'Create a project', + args: { + name: { + inputType: 'string', + required: true, + defaultValueFromContext: (context) => context.defaultProjectName, + }, + }, + }, + 'Delete project': { + 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', + required: true, + options: [], + optionsFromContext: (context) => + context.projects.map((p) => ({ + name: p.name!, + value: p.name!, + })), + }, + }, + }, + 'Rename project': { + icon: 'folder', + description: 'Rename a project', + needsReview: true, + args: { + oldName: { + inputType: 'options', + required: true, + options: [], + optionsFromContext: (context) => + context.projects.map((p) => ({ + name: p.name!, + value: p.name!, + })), + }, + newName: { + inputType: 'string', + required: true, + defaultValueFromContext: (context) => context.defaultProjectName, + }, + }, + }, + 'Import file from URL': { + icon: 'file', + description: 'Create a file', + needsReview: true, + args: { + method: { + inputType: 'options', + required: true, + skip: true, + options: isDesktop() + ? [ + { name: 'New project', value: 'newProject' }, + { name: 'Existing project', value: 'existingProject' }, + ] + : [{ name: 'Overwrite', value: 'existingProject' }], + valueSummary(value) { + return isDesktop() + ? value === 'newProject' + ? 'New project' + : 'Existing project' + : 'Overwrite' + }, + }, + // TODO: We can't get the currently-opened project to auto-populate here because + // it's not available on projectMachine, but lower in fileMachine. Unify these. + projectName: { + inputType: 'options', + required: (commandsContext) => + isDesktop() && + commandsContext.argumentsToSubmit.method === 'existingProject', + skip: true, + options: [], + optionsFromContext: (context) => + context.projects.map((p) => ({ + name: p.name!, + value: p.name!, + })), + }, + name: { + inputType: 'string', + required: isDesktop(), + skip: true, + }, + code: { + inputType: 'text', + required: true, + skip: true, + valueSummary(value) { + return value?.trim().split('\n').length + ' lines' + }, + }, + units: { + inputType: 'options', + required: false, + skip: true, + options: baseUnitsUnion.map((unit) => ({ + name: baseUnitLabels[unit], + value: unit, + })), + }, + }, + reviewMessage(commandBarContext) { + return isDesktop() + ? `Will add the contents from URL to a new ${ + commandBarContext.argumentsToSubmit.method === 'newProject' + ? 'project with file main.kcl' + : `file within the project "${commandBarContext.argumentsToSubmit.projectName}"` + } named "${ + commandBarContext.argumentsToSubmit.name + }", and set default units to "${ + commandBarContext.argumentsToSubmit.units + }".` + : `Will overwrite the contents of the current file with the contents from the URL.` + }, + }, +} diff --git a/src/lib/commandTypes.ts b/src/lib/commandTypes.ts index 4377002f76..045a65737c 100644 --- a/src/lib/commandTypes.ts +++ b/src/lib/commandTypes.ts @@ -74,6 +74,7 @@ export type Command< | (( commandBarContext: { argumentsToSubmit: Record } // Should be the commandbarMachine's context, but it creates a circular dependency ) => string | ReactNode) + machineActor?: Actor onSubmit: (data?: CommandSchema) => void onCancel?: () => void args?: { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ae6973df70..8818979b9b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -65,6 +65,7 @@ export const KCL_DEFAULT_DEGREE = `360` export const TEST_SETTINGS_FILE_KEY = 'playwright-test-settings' export const DEFAULT_HOST = 'https://api.zoo.dev' +export const PROD_APP_URL = 'https://app.zoo.dev' export const SETTINGS_FILE_NAME = 'settings.toml' export const TOKEN_FILE_NAME = 'token.txt' export const PROJECT_SETTINGS_FILE_NAME = 'project.toml' @@ -103,5 +104,8 @@ export const KCL_SAMPLES_MANIFEST_URLS = { localFallback: '/kcl-samples-manifest-fallback.json', } as const +/** URL parameter to create a file */ +export const CREATE_FILE_URL_PARAM = 'create-file' + /** Toast id for the app auto-updater toast */ export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast' diff --git a/src/lib/createFileLink.test.ts b/src/lib/createFileLink.test.ts new file mode 100644 index 0000000000..d8c6c52ed6 --- /dev/null +++ b/src/lib/createFileLink.test.ts @@ -0,0 +1,17 @@ +import { CREATE_FILE_URL_PARAM } from './constants' +import { createFileLink } from './createFileLink' + +describe(`createFileLink`, () => { + test(`with simple code`, async () => { + const code = `extrusionDistance = 12` + const name = `test` + const units = `mm` + + // Converted with external online tools + const expectedEncodedCode = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D` + const expectedLink = `http:/localhost:3000/?${CREATE_FILE_URL_PARAM}&name=test&units=mm&code=${expectedEncodedCode}` + + const result = createFileLink({ code, name, units }) + expect(result).toBe(expectedLink) + }) +}) diff --git a/src/lib/createFileLink.ts b/src/lib/createFileLink.ts new file mode 100644 index 0000000000..d7cea58243 --- /dev/null +++ b/src/lib/createFileLink.ts @@ -0,0 +1,47 @@ +import { UnitLength_type } from '@kittycad/lib/dist/types/src/models' +import { postUserShortlink } from 'lib/desktop' +import { CREATE_FILE_URL_PARAM } from './constants' +import { stringToBase64 } from './base64' +import withBaseURL from 'lib/withBaseURL' +import { isDesktop } from 'lib/isDesktop' + +export interface FileLinkParams { + code: string + name: string + units: UnitLength_type +} + +/** + * Given a file's code, name, and units, creates shareable link + */ +export async function createFileLink(token: string, { code, name, units }: FileLinkParams) { + let urlUserShortlinks = withBaseURL('/users/shortlinks') + + // During development, the "handler" needs to first be the web app version, + // which exists on localhost:3000 typically. + let origin = 'http://localhost:3000' + + let urlFileToShare = new URL( + `/?${CREATE_FILE_URL_PARAM}&name=${encodeURIComponent( + name + )}&units=${units}&code=${encodeURIComponent(stringToBase64(code))}`, + origin, + ).toString() + + // Remove this monkey patching + function fixTheBrokenShitUntilItsFixedOnDev() { + urlUserShortlinks = urlUserShortlinks.replace('https://api.dev.zoo.dev', 'https://api.zoo.dev') + console.log(urlUserShortlinks) + } + + fixTheBrokenShitUntilItsFixedOnDev() + + return await fetch(urlUserShortlinks, { + method: 'POST', + headers: { + 'Content-type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ url: urlFileToShare }) + }).then((resp) => resp.json()) +} diff --git a/src/lib/createMachineCommand.ts b/src/lib/createMachineCommand.ts index 8903f2b5f7..efabf634dc 100644 --- a/src/lib/createMachineCommand.ts +++ b/src/lib/createMachineCommand.ts @@ -89,6 +89,7 @@ export function createMachineCommand< icon, description: commandConfig.description, needsReview: commandConfig.needsReview || false, + machineActor: actor, onSubmit: (data?: S[typeof type]) => { if (data !== undefined && data !== null) { send({ type, data }) @@ -111,6 +112,9 @@ export function createMachineCommand< if ('displayName' in commandConfig) { command.displayName = commandConfig.displayName } + if ('reviewMessage' in commandConfig) { + command.reviewMessage = commandConfig.reviewMessage + } return command } diff --git a/src/lib/link.ts b/src/lib/link.ts new file mode 100644 index 0000000000..8c61e060d9 --- /dev/null +++ b/src/lib/link.ts @@ -0,0 +1,3 @@ +export const ZOO_STUDIO_PROTOCOL = 'zoo-studio' + + diff --git a/src/lib/routeLoaders.ts b/src/lib/routeLoaders.ts index 719746367f..7bc4a0fdea 100644 --- a/src/lib/routeLoaders.ts +++ b/src/lib/routeLoaders.ts @@ -104,7 +104,7 @@ export const fileLoader: LoaderFunction = async ( return redirect( `${PATHS.FILE}/${encodeURIComponent( isDesktop() ? fallbackFile : params.id + '/' + PROJECT_ENTRYPOINT - )}` + )}${new URL(routerData.request.url).search || ''}` ) } @@ -174,11 +174,14 @@ export const fileLoader: LoaderFunction = async ( // Loads the settings and by extension the projects in the default directory // and returns them to the Home route, along with any errors that occurred -export const homeLoader: LoaderFunction = async (): Promise< - HomeLoaderData | Response -> => { +export const homeLoader: LoaderFunction = async ({ + request, +}): Promise => { + const url = new URL(request.url) if (!isDesktop()) { - return redirect(PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME) + return redirect( + PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME + (url.search || '') + ) } return {} } diff --git a/src/machines/commandBarMachine.ts b/src/machines/commandBarMachine.ts index 18487a29e5..71d28e786b 100644 --- a/src/machines/commandBarMachine.ts +++ b/src/machines/commandBarMachine.ts @@ -109,6 +109,9 @@ export const commandBarMachine = setup({ selectedCommand?.onSubmit() } }, + 'Clear selected command': assign({ + selectedCommand: undefined, + }), 'Set current argument to first non-skippable': assign({ currentArgument: ({ context, event }) => { const { selectedCommand } = context @@ -202,6 +205,11 @@ export const commandBarMachine = setup({ cmd.name === event.data.name && cmd.groupId === event.data.groupId ) + console.log('within find and select command', { + event, + found, + }) + return !!found ? found : context.selectedCommand }, }), @@ -236,6 +244,7 @@ export const commandBarMachine = setup({ context.selectedCommand?.needsReview || false, 'Command has no arguments': () => false, 'All arguments are skippable': () => false, + 'Has selected command': ({ context }) => !!context.selectedCommand, }, actors: { 'Validate argument': fromPromise(({ input }) => { @@ -329,7 +338,7 @@ export const commandBarMachine = setup({ ), }, }).createMachine({ - /** @xstate-layout N4IgpgJg5mDOIC5QGED2BbdBDAdhABAEJYBOAxMgDaqxgDaADALqKgAONAlgC6eo6sQAD0QBaAJwA6AGwAmAKwBmBoukAWafIAcDcSoA0IAJ6JZDaZIDs8hgzV6AjA61a1DWQF8PhtJlwFiciowUkYWJBAOWB4+AQiRBFF5CwdpcVkHS1lpVyU5QxNEh1lFGTUsrUUtOQd5SwZLLx8MbDwiUkkqGkgyAHk2MBwwwSiY-kEEswZJbPltM0U3eXLZAsRFeQcrerUHRbTFvfkmkF9WgI6u2ggyADFONv98WkowAGNufDeW-2GI0d443iiHESkktUUilkskqdiUWjWCAcDC0kjUqnElkc6lkoK0JzOT0CnWo1zIAEEIARvn48LA-uwuIC4qAEqIHJjJPJcYtxFoYdJFFjVsZEG5pqCquJxJoGvJxOUCT82sSrj0AEpgdCoABuYC+yog9OYIyZsQmYmhWzMmTqLnU0kyikRGVRbj5lg2SmUam5StpFxIkgAymBXh8HlADQGyKHw58aecGZEzUDWYgchY7CisWoSlpQQ5EVDLJJxKkdIpyypUop-ed2kHCW0Xu9uD1kwDzcCEJCtlUNgWhZZ1OlnaKEBpZJItHVzDZ5xlpPWiZdDc8w22O7JwozosyLUj3KiZZY85YtMUNgx5Ii81JuS5xBtPVCCyuVR0AOJYbgACzAEhI3wUgoAAV3QQZuFgCg-1wGAvjAkgSCgkCSHAyCcG4TtUxZYRED2TQZGSOw80qaQ7ARCc9mmZYSjPPFpDSQUP0DSQf3-QDgNAiCoJggAROBNw+aMkxNf5cMPHJSjPWRdhvZ9LGcF0b0kVQ8ycDRNkvNRWMbdjfwAoCcCjHjMOgyRyQAdywGITPwB42DA7hYzAgAjdAeDQjCoJw-du3TJE6mnWwoT0NIUTUAs71sMtdGsRYCyUtI9OJDijO49DeKw2BJAANSwShOAgX9IzICB+DASQHh1VAAGsqp1Qrit-MBySy8y-LGPCElSeoy2lZJcwcNRfXHQpBWmWQsRsPYdDPZJUu-QyuPssy+Py5qSt4EyyEAkhUCDNhKF-AAzQ70EkJqiu2tqOt88S926w9UhhLlykWXFHXcTJixsd60hHGxNj2Jag01HVODAKzXI8rzE1+R6U38tN8IQJSuR0OZ0gvHQXHGxBPWmUdppUWwFV0MHJAhqGYcpAh1qwrqDx7ZFajUmVpulEbqlqREsfBTQ0jcPM5P5KmaehshNW1PVvOy7Cka7VHeoYDkyjSPQtAvPMqkRQU1BnX1+UydFkQvCWwEhqWAFEIC8xnFd3ZHntZuTDZG4ob1nGxPTUfXrBnS8nDmEpT2ObxTnXVUALeOrgPanycvKyrqpwWqGqurbWsThXjWd5WesQZYtmBkplCceplIncK0SrXYqnccxxcj5s2OQWP4-s3PzJgiqcCqmr6sa7P2x7vi6AcAvJJ7XEy0dUFMWsFxlnKfn+S5CL3E9Jiuapjv3i7qNx+T-bDskY6zourObpz+6cuZgK0Y5Ua0TqEvXDkJR-YnR0s1xjkIMnDln3uuVsHwOxT1NCjIuR5nCSBUExJi1huRqyooUTYpYnzeyUFkKsy4Tg4FQBAOAgg26Nmga7QKohshSBtNYXGDonQumtPYaQfsSjLBHDefepJICUJZtQ4oMx8wXj6jebGt4JwbC2OiVwig9hh2hLpVu0cOhxjbMBBGeABFPwSA3ERRMlhKWCn9WRVQzZQirMo0BAYNzxn4RJGBh5MRbGXsHawGQFBmLrpUasOQorOAjs0OxaUVrGVMvfaCuiVYEV2KWTYg1yxyA0i6ZYaIPSL3lOkS8wSo6hOWpxCJ8te6WRsnZKMjlnIxNgSNIiiTF6vVSdI9JM1taXlxLzTwqiClBnSqtSJScLIFVvjtKANSXquENqoJwtgyZzRFIUQ4MhqgNACdNTQCpLbWyshMnsjpSwjXRJYHecgcmImsLIz0odNAaGqPiHpDYY6HwTlE+ATiqHPwohYewWQshmGhHrCcJz5Bck5toZwGhMheC8EAA */ + /** @xstate-layout N4IgpgJg5mDOIC5QGED2BbdBDAdhABAEJYBOAxMgDaqxgDaADALqKgAONAlgC6eo6sQAD0QBaAIwB2AHTiAHAE45AZjkAmdcoaSArCoA0IAJ6JxDOdJ2SF4gCySHaqZIa2Avm8NpMuAsXJUYKSMLEggHLA8fAJhIgiiOgBssokKTpJqiXK2OsqJaoYm8eJqytKJ9hqq+eJW2h5eGNh4RKRkAGKcLb74tJRgAMbc+ANNviGCEVH8gnEKubK5ympVrrlyhabm0rZ5CtYM4hVq83ININ7NfqTSVDSQZADybGA4E2FTvDOxiGoMDNJMjo9H9lLYGDpKpsEModOJpA5XOIwakwcidOdLj1-LdqLQIGQAIIQAijHx4WDvdhcL4xUBxURqSTw3LzfYKZSSOQZWzQ5S1crMuTAiElRKuTFjFo4u74sgAJTA6FQADcwCMpRBKcxJjTorMxEzkhouftuXCGGpIdCpADmZJxfzJLY5Cp5pLydcSLj7gSqeE9d96Yh+TppKoXfkGKdlHloUyFIDUvMXBzbFl3J4LprWt6AMpgfpDLpQDWesgFovDMlXf2ffU-BBZZKuczWWylRRwvlM6Q2LIMZQ2QdHZQeq65245vqDbgPOuBunCEP88MqPQchwVNLKaHptTSYUuRI6f4npyJcfYm5Ylozobz8ShamRWkGhBmeTSQeJX+JXQ6H88jQnCMiugoELCpypQKJeWa3l6U6er0hazvOajPgGr4NsGH6WhYsHOkycglLCEJ7iclgaIosKSLGGgKFe0o3AA4lg3AABZgCQJb4KQUAAK7oK83CwBQHG4DAIwCSQJAiXxJCCcJODcAu2FBsuH55GGJ7irYHYqHpGzGKYWiWB2nK2Kcv6wWO8E5jibGcdxvH8UJIliQAInAqFDGWtY6h8i7vlkZREbYZg6JuwEmQgfxhnkHbiHYJ7yHYTGIU5XE8TgpZucponSISADuWBRLl+BdGwAncBWAkAEboDwClKSJanTEucS1Bk36DicDCpOYLoKHu-x9tGuhgoozKpBlk5ZS5FX5R50gAGpYJQnAQOxJZkBA-BgNIXQqqgADWh0qhtW3sWAeYlv0hKKe5KntW+YRFGi5RyOKDrZEaUW8pppTguGuwDRFEO-nNjnsdlrlPQVsBrVd228LlZDcSQqDemwlDsQAZtj6DSJdm2o7d91gI9rUvYFL4dYIH0RV9P0Zv9CiA3EwMAmCWgVHYKVwY0yE4oqKqcGAxV1Y1zU1uMdNYQzjbMpYcgQlFxFq66u6xXRALbkyg7-Bz0bQzcYsS1LxIEMttOYfWGldYcCWwQmNiRrU0Jq2GRxJCbHZqC6ahm96FuSwqSqquqtuqQrDudVs4iJhUyZtn9qjQokYKHjk6hSLsZhciH0hh1LACiEDNTHr04ZpJT6bIEXxcKp50YDRT-mGrrJbUgFDp3xfIFxAynbx1PPaJe0HUdOAnedJMozd4+IzXjuIJCLIQqUWjJS4MVFBByS7BzyJq38WTB-ZIs3sPo8VcvHlTzgh3HWdF2L3OD8qZST66upCdxUTLBJOUUHB6GFPpSQXt1CWEGpaOiv4EyD1vmPBGj9MbY2kLjAmRMF5kyXmg7+q8AFJwbjkXQEVsj5FyO3RAiQjjfi5CReYPck7iA8FmHAqAIBwEEAhXMf8la4VEMsWwgJuTTXNGYK0tC4rwkDvsAaSQ-qlAhIPPEkBBFvWEVycoFQhywUHGaHWH04Q7FUNBdc+x2FXwnDiSss5eJyzwFo2ucQIplBWHrcEVhuoFFirCeEuxsj8mWEOFYmZhZ2JvNOXyc4ICuLXggaxCJwG70AiUHQfIzHBKHGYDMJFhTFwWjlPKhDRKJJIRFGQcIFBsiOIHJw8ZIQ7CUGrY+9g9AnmKbDRaZSaaFRKmVNGpYqo1Uqe+FKYZan1PyElbJYjrB6C5CUJQ9DL5ROvN6Ep8MBlI3WvgkZEzGzyAbnkZK-wjan38UzeEzZtBswdADYupdjm4XoTIOwuwHB5HyGkYyRRdBBLosCIE6ZvpnFsVs24KD77lPgEFf+kzxRH32EyFYlpOzQjAZYV2ehTkfI4W4IAA */ context: { commands: [], selectedCommand: undefined, @@ -349,14 +358,6 @@ export const commandBarMachine = setup({ target: 'Selecting command', }, - 'Find and select command': { - target: 'Command selected', - actions: [ - 'Find and select command', - 'Initialize arguments to submit', - ], - }, - 'Add commands': { target: 'Closed', @@ -368,8 +369,6 @@ export const commandBarMachine = setup({ ), }), ], - - reenter: false, }, 'Remove commands': { @@ -386,10 +385,13 @@ export const commandBarMachine = setup({ ), }), ], - - reenter: false, }, }, + + always: { + target: 'Command selected', + guard: 'Has selected command', + }, }, 'Selecting command': { @@ -406,7 +408,7 @@ export const commandBarMachine = setup({ { target: 'Closed', guard: 'Command has no arguments', - actions: ['Execute command'], + actions: ['Execute command', 'Clear selected command'], }, { target: 'Checking Arguments', @@ -475,7 +477,7 @@ export const commandBarMachine = setup({ on: { 'Submit command': { target: 'Closed', - actions: ['Execute command'], + actions: ['Execute command', 'Clear selected command'], }, 'Add argument': { @@ -507,7 +509,7 @@ export const commandBarMachine = setup({ }, { target: 'Closed', - actions: 'Execute command', + actions: ['Execute command', 'Clear selected command'], }, ], onError: [ @@ -522,6 +524,7 @@ export const commandBarMachine = setup({ on: { Close: { target: '.Closed', + actions: 'Clear selected command', }, Clear: { @@ -529,6 +532,11 @@ export const commandBarMachine = setup({ reenter: false, actions: ['Clear argument data'], }, + + 'Find and select command': { + target: '.Command selected', + actions: ['Find and select command', 'Initialize arguments to submit'], + }, }, }) diff --git a/src/machines/homeMachine.ts b/src/machines/homeMachine.ts deleted file mode 100644 index ca9a50e81d..0000000000 --- a/src/machines/homeMachine.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { assign, fromPromise, setup } from 'xstate' -import { HomeCommandSchema } from 'lib/commandBarConfigs/homeCommandConfig' -import { Project } from 'lib/project' - -export const homeMachine = setup({ - types: { - context: {} as { - projects: Project[] - defaultProjectName: string - defaultDirectory: string - }, - 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: 'navigate'; data: { name: string } } - | { - type: 'xstate.done.actor.read-projects' - output: Project[] - } - | { type: 'assign'; data: { [key: string]: any } }, - input: {} as { - projects: Project[] - defaultProjectName: string - defaultDirectory: string - }, - }, - actions: { - setProjects: assign({ - projects: ({ context, event }) => - 'output' in event ? event.output : context.projects, - }), - toastSuccess: () => {}, - toastError: () => {}, - navigateToProject: () => {}, - }, - actors: { - readProjects: fromPromise(() => Promise.resolve([] as Project[])), - createProject: fromPromise((_: { input: { name: string } }) => - Promise.resolve('') - ), - renameProject: fromPromise( - (_: { - input: { - oldName: string - newName: string - defaultProjectName: string - defaultDirectory: string - } - }) => Promise.resolve('') - ), - deleteProject: fromPromise( - (_: { input: { defaultDirectory: string; name: string } }) => - Promise.resolve('') - ), - }, - 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 */ - id: 'Home machine', - - initial: 'Reading projects', - - context: { - projects: [], - defaultProjectName: '', - defaultDirectory: '', - }, - - on: { - assign: { - actions: assign(({ event }) => ({ - ...event.data, - })), - target: '.Reading projects', - }, - }, - states: { - 'Has no projects': { - on: { - 'Read projects': { - target: 'Reading projects', - }, - 'Create project': { - target: 'Creating project', - }, - }, - }, - - 'Has projects': { - on: { - 'Read projects': { - target: 'Reading projects', - }, - - 'Rename project': { - target: 'Renaming project', - }, - - 'Create project': { - target: 'Creating project', - }, - - 'Delete project': { - target: 'Deleting project', - }, - - 'Open project': { - target: 'Opening project', - }, - }, - }, - - 'Creating project': { - invoke: { - id: 'create-project', - src: 'createProject', - input: ({ event }) => { - if (event.type !== 'Create project') { - return { - name: '', - } - } - return { - name: event.data.name, - } - }, - onDone: [ - { - target: 'Reading projects', - actions: ['toastSuccess'], - }, - ], - onError: [ - { - target: 'Reading projects', - actions: ['toastError'], - }, - ], - }, - }, - - 'Renaming project': { - invoke: { - id: 'rename-project', - src: 'renameProject', - input: ({ event, context }) => { - if (event.type !== 'Rename project') { - // This is to make TS happy - return { - defaultProjectName: context.defaultProjectName, - defaultDirectory: context.defaultDirectory, - oldName: '', - newName: '', - } - } - return { - defaultProjectName: context.defaultProjectName, - defaultDirectory: context.defaultDirectory, - oldName: event.data.oldName, - newName: event.data.newName, - } - }, - onDone: [ - { - target: '#Home machine.Reading projects', - actions: ['toastSuccess'], - }, - ], - onError: [ - { - target: '#Home machine.Reading projects', - actions: ['toastError'], - }, - ], - }, - }, - - 'Deleting project': { - invoke: { - id: 'delete-project', - src: 'deleteProject', - input: ({ event, context }) => { - if (event.type !== 'Delete project') { - // This is to make TS happy - return { - defaultDirectory: context.defaultDirectory, - name: '', - } - } - return { - defaultDirectory: context.defaultDirectory, - name: event.data.name, - } - }, - onDone: [ - { - actions: ['toastSuccess'], - target: '#Home machine.Reading projects', - }, - ], - onError: { - actions: ['toastError'], - target: '#Home machine.Has projects', - }, - }, - }, - - 'Reading projects': { - invoke: { - id: 'read-projects', - src: 'readProjects', - onDone: [ - { - guard: 'Has at least 1 project', - target: 'Has projects', - actions: ['setProjects'], - }, - { - target: 'Has no projects', - actions: ['setProjects'], - }, - ], - onError: [ - { - target: 'Has no projects', - actions: ['toastError'], - }, - ], - }, - }, - - 'Opening project': { - entry: ['navigateToProject'], - }, - }, -}) diff --git a/src/machines/projectsMachine.ts b/src/machines/projectsMachine.ts new file mode 100644 index 0000000000..126f5244d6 --- /dev/null +++ b/src/machines/projectsMachine.ts @@ -0,0 +1,332 @@ +import { assign, fromPromise, setup } from 'xstate' +import { ProjectsCommandSchema } from 'lib/commandBarConfigs/projectsCommandConfig' +import { Project } from 'lib/project' +import { isArray } from 'lib/utils' + +export const projectsMachine = setup({ + types: { + context: {} as { + projects: Project[] + defaultProjectName: string + defaultDirectory: string + }, + events: {} as + | { type: 'Read projects'; data: {} } + | { 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: 'Import file from URL' + data: ProjectsCommandSchema['Import file from URL'] + } + | { 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: 'xstate.done.actor.create-file' + output: { message: string; projectName: string; fileName: string } + } + | { type: 'assign'; data: { [key: string]: any } }, + input: {} as { + projects: Project[] + defaultProjectName: string + defaultDirectory: string + }, + }, + actions: { + setProjects: assign({ + projects: ({ context, event }) => + 'output' in event && isArray(event.output) + ? event.output + : context.projects, + }), + toastSuccess: () => {}, + toastError: () => {}, + navigateToProject: () => {}, + navigateToProjectIfNeeded: () => {}, + navigateToFile: () => {}, + }, + actors: { + readProjects: fromPromise(() => Promise.resolve([] as Project[])), + createProject: fromPromise( + (_: { input: { name: string; projects: Project[] } }) => + Promise.resolve({ message: '' }) + ), + renameProject: fromPromise( + (_: { + input: { + oldName: string + newName: string + defaultProjectName: string + defaultDirectory: string + projects: Project[] + } + }) => + Promise.resolve({ + message: '', + oldName: '', + newName: '', + }) + ), + deleteProject: fromPromise( + (_: { input: { defaultDirectory: string; name: string } }) => + Promise.resolve({ + message: '', + name: '', + }) + ), + createFile: fromPromise( + (_: { + input: ProjectsCommandSchema['Import file from URL'] & { + projects: Project[] + } + }) => Promise.resolve({ message: '', projectName: '', fileName: '' }) + ), + }, + guards: { + 'Has at least 1 project': () => false, + 'New project method is used': () => false, + }, +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAMS6yzFSkDaADALqKgAOqtALsaqXYgAHogAsAJgA0IAJ6IAjAHYAbADoArBJVMFTCQA4mTAMwmxAXwsy0mHARLkKASXRcATjywAzYgBtsb3cMLABVACUAGWY2JBAuXn5BONEESRl5BAUFFQM1HQUxJSYATmyFAyUTKxsMbDwiMjA1ZGosUlQsDmCAKzB8HlgKcLBcCC7e-sGYoQTiPgEhVIUy9SKSiQ0NEwkclRyMxGKJNRKlMRVDU23zpRqQW3qHJpa2jonUPoGhgGF3UZ42G6nymMzicwWyVAyzKJzECg0OiYGjERhK+kOWUMSlOGiUlTEJlMxRKBnuj3sjXIr1gHy+g2Go3GwPpsDBnG48ySS0QGj0+Q0JTOGkqBg2JhKmNRJy2OyUEn0Kml5LqlMczVatJZUyGI1IuDs2oG7PinMhPIQfKYAqFShF+PFkrkRwkYjU8OUShKKhMKg0pUs1geqoa6ppdJ1FD+AKBk2NrFmZu5KV5-L9tvtYokEsxelRagUCoMiMumyUdpVdlDL01Ee+FAAImAAoC6zwTRDk9DU9b08LRY7MWd1AZcoS-aoLiYFJWnlSNW0jQyAPIcMCkNsdpOLFOWtOC-sO7NOzLbGWFbY6acmFESWdql7R3B8UhQNsUCACZpkABuqAA1s0+D-M+YAALRLluiQ7t2WRMGIbryjeBgGBIEglNsCi5sY6hMOchQqEoCLFCh97VtST4vm+S4UGA7jBO4agcH4z7eKg7joGowExhBcbtgm4LblCIiKPBiHZiKqHoZhuaFhoBbGMYBhiPBCgStUQYUuRzR6gaZDUXxH5fmov4Ac0-z6pgvEgvGsQctBwnLGJahIZJaEYdOmKEfJuQSoRRKSRcZHPNSunoPp750QxTEsTwbEcWoFkGuBkECfZXIwSJcEIS5Ekoe5MnOggXqIaUqFiiUhIInemkhiFzRNi2EU0Z+1KmYBagQM2YCAtZ9JQRljmiTlrn5dJnlFShOLwcpZjKBs+KBrUVb1WojU9c1hlRexMWsexnFdS2KV8QN5q7laNqHlmOaTcpmjoWc8L6HoYrBfOagjGMm02QyrXfqQf4dSBEB9Tqp1dllebichUkeVhRXTiU+QecUKhCps4pvWGn0QN9rJGW1ANmYlTKg98DAKHZpoORanoyqh8oImU3pKJifJ5Ohdr7CYqjIvKWMvDjeORttjHMXtCXA2T0xpdTg20+W9MSIzgorIRXlpr6ORbPs+jwQLFEgVRPj+JQf0mUTHXcaBYG+AE4OZcsxbuts5hbCK+zFmImJgSc5zPV6BQVD6RQG80lERXblCi7tcX7VxRvgVHDtDQgaE4vK2gXKiTA6ItubmJo8IoqOylERUVhBh0XXwHEWn1YmNO7mBKg+2KpzK2IpIBUwBhqUtwYre9tbvEutfpWdsFFnkPpVJnQqz15Ozur36FoypREbGH4Zj438u7tO1qIu5qK5PBGyYjebpEkS44e9oVTbxHr5tnvk9ZeXajTuW+zaHNuzYRUmoeCEp-RKlUHJbeYVhYDDfhDVIxR5IGGnISQk6E+6EkxMUBQX9c66EMMg3Q5Zt7rWNkuOBjsjilDUAqQsZQzzgOkJNUk7o7SmF-tiXOUCmQwMGBQ1OF4TgVGzFUG8yIxQGDZiYPI4oqh+mPsrDSy05xhmfm+KO-CLT+iRucEUuwFQqWzMWXM2YTAuTtL6ZBylkRcMrkAA */ + id: 'Home machine', + + initial: 'Reading projects', + + context: ({ input }) => ({ + ...input, + }), + + on: { + assign: { + actions: assign(({ event }) => ({ + ...event.data, + })), + target: '.Reading projects', + }, + + 'Import file from URL': '.Creating file', + }, + states: { + 'Has no projects': { + on: { + 'Read projects': { + target: 'Reading projects', + }, + 'Create project': { + target: 'Creating project', + }, + }, + }, + + 'Has projects': { + on: { + 'Read projects': { + target: 'Reading projects', + }, + + 'Rename project': { + target: 'Renaming project', + }, + + 'Create project': { + target: 'Creating project', + }, + + 'Delete project': { + target: 'Deleting project', + }, + + 'Open project': { + target: 'Reading projects', + actions: 'navigateToProject', + reenter: true, + }, + }, + }, + + 'Creating project': { + invoke: { + id: 'create-project', + src: 'createProject', + input: ({ event, context }) => { + if ( + event.type !== 'Create project' && + event.type !== 'Import file from URL' + ) { + return { + name: '', + projects: context.projects, + } + } + return { + name: event.data.name, + projects: context.projects, + } + }, + onDone: [ + { + target: 'Reading projects', + actions: ['toastSuccess', 'navigateToProject'], + }, + ], + onError: [ + { + target: 'Reading projects', + actions: ['toastError'], + }, + ], + }, + }, + + 'Renaming project': { + invoke: { + id: 'rename-project', + src: 'renameProject', + input: ({ event, context }) => { + if (event.type !== 'Rename project') { + // This is to make TS happy + return { + defaultProjectName: context.defaultProjectName, + defaultDirectory: context.defaultDirectory, + oldName: '', + newName: '', + projects: context.projects, + } + } + return { + defaultProjectName: context.defaultProjectName, + defaultDirectory: context.defaultDirectory, + oldName: event.data.oldName, + newName: event.data.newName, + projects: context.projects, + } + }, + onDone: [ + { + target: '#Home machine.Reading projects', + actions: ['toastSuccess', 'navigateToProjectIfNeeded'], + }, + ], + onError: [ + { + target: '#Home machine.Reading projects', + actions: ['toastError'], + }, + ], + }, + }, + + 'Deleting project': { + invoke: { + id: 'delete-project', + src: 'deleteProject', + input: ({ event, context }) => { + if (event.type !== 'Delete project') { + // This is to make TS happy + return { + defaultDirectory: context.defaultDirectory, + name: '', + } + } + return { + defaultDirectory: context.defaultDirectory, + name: event.data.name, + } + }, + onDone: [ + { + actions: ['toastSuccess', 'navigateToProjectIfNeeded'], + target: '#Home machine.Reading projects', + }, + ], + onError: { + actions: ['toastError'], + target: '#Home machine.Has projects', + }, + }, + }, + + 'Reading projects': { + invoke: { + id: 'read-projects', + src: 'readProjects', + onDone: [ + { + guard: 'Has at least 1 project', + target: 'Has projects', + actions: ['setProjects'], + }, + { + target: 'Has no projects', + actions: ['setProjects'], + }, + ], + onError: [ + { + target: 'Has no projects', + actions: ['toastError'], + }, + ], + }, + }, + + 'Creating file': { + invoke: { + id: 'create-file', + src: 'createFile', + input: ({ event, context }) => { + if (event.type !== 'Import file from URL') { + return { + code: '', + name: '', + units: 'mm', + method: 'existingProject', + projects: context.projects, + } + } + return { + code: event.data.code || '', + name: event.data.name, + units: event.data.units, + method: event.data.method, + projectName: event.data.projectName, + projects: context.projects, + } + }, + onDone: { + target: 'Reading projects', + actions: ['navigateToFile', 'toastSuccess'], + }, + onError: { + target: 'Reading projects', + actions: 'toastError', + }, + }, + }, + }, +}) diff --git a/src/main.ts b/src/main.ts index 06b73a581a..512f2d530f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,8 +9,10 @@ import { dialog, shell, nativeTheme, + session, } from 'electron' -import path from 'path' +import path, { join } from 'path' +import fs from 'fs' import { Issuer } from 'openid-client' import { Bonjour, Service } from 'bonjour-service' // @ts-ignore: TS1343 @@ -20,6 +22,7 @@ import minimist from 'minimist' import getCurrentProjectFile from 'lib/getCurrentProjectFile' import os from 'node:os' import { reportRejection } from 'lib/trap' +import { ZOO_STUDIO_PROTOCOL } from 'lib/link' let mainWindow: BrowserWindow | null = null @@ -51,9 +54,8 @@ if (require('electron-squirrel-startup')) { app.quit() } -const ZOO_STUDIO_PROTOCOL = 'zoo-studio' -/// Register our application to handle all "electron-fiddle://" protocols. +/// Register our application to handle all "zoo-studio:" protocols. if (process.defaultApp) { if (process.argv.length >= 2) { app.setAsDefaultProtocolClient(ZOO_STUDIO_PROTOCOL, process.execPath, [ @@ -86,10 +88,27 @@ const createWindow = (filePath?: string): BrowserWindow => { backgroundColor: nativeTheme.shouldUseDarkColors ? '#1C1C1C' : '#FCFCFC', }) + const filter = { + urls: ['*://api.zoo.dev/*', '*://api.dev.zoo.dev/*'] + } + + session.defaultSession.webRequest.onBeforeSendHeaders(filter, (details, callback) => { + console.log(details) + details.requestHeaders['Origin'] = 'https://app.zoo.dev' + details.requestHeaders['Access-Control-Allow-Origin'] = ['*'] + console.log(details) + callback({ + requestHeaders: { + 'Origin': 'https://app.zoo.dev' + } + }) + }) + // and load the index.html of the app. if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { newWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL).catch(reportRejection) } else { + console.log('Loading from file', filePath) getProjectPathAtStartup(filePath) .then(async (projectPath) => { const startIndex = path.join( @@ -323,6 +342,7 @@ const getProjectPathAtStartup = async ( // macOS: open-url events that were received before the app is ready const getOpenUrls: string[] = (global as any).getOpenUrls if (getOpenUrls && getOpenUrls.length > 0) { + console.log('getOpenUrls', getOpenUrls) projectPath = getOpenUrls[0] // We only do one project at a } // Reset this so we don't accidentally use it again. @@ -392,6 +412,12 @@ function registerStartupListeners() { ) { event.preventDefault() + console.log('open-url', url) + fs.writeFileSync( + '/Users/frankjohnson/open-url.txt', + `at ${new Date().toLocaleTimeString()} opened url: ${url}` + ) + // If we have a mainWindow, lets open another window. if (mainWindow) { createWindow(url) diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 0a5331fd78..6883c00f9c 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -1,60 +1,43 @@ 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' +import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher' // 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 +56,6 @@ const Home = () => { ) const ref = useRef(null) - const [state, send, actor] = useMachine( - homeMachine.provide({ - actions: { - navigateToProject: ({ context, event }) => { - if ('data' in event && event.data && 'name' in event.data) { - let projectPath = - context.defaultDirectory + - window.electron.path.sep + - event.data.name - onProjectOpen( - { - name: event.data.name, - path: projectPath, - }, - null - ) - commandBarSend({ type: 'Close' }) - navigate(`${PATHS.FILE}/${encodeURIComponent(projectPath)}`) - } - }, - toastSuccess: ({ event }) => - toast.success( - ('data' in event && typeof event.data === 'string' && event.data) || - ('output' in event && - typeof event.output === 'string' && - event.output) || - '' - ), - toastError: ({ event }) => - toast.error( - ('data' in event && typeof event.data === 'string' && event.data) || - ('output' in event && - typeof event.output === 'string' && - event.output) || - '' - ), - }, - actors: { - readProjects: fromPromise(() => listProjects()), - createProject: fromPromise(async ({ input }) => { - let name = ( - input && 'name' in input && input.name - ? input.name - : settings.projects.defaultProjectName.current - ).trim() - - if (doesProjectNameNeedInterpolated(name)) { - const nextIndex = getNextProjectIndex(name, projects) - name = interpolateProjectNameWithIndex(name, nextIndex) - } - - await createNewProjectDirectory(name) - - return `Successfully created "${name}"` - }), - renameProject: fromPromise(async ({ input }) => { - const { oldName, newName, defaultProjectName, defaultDirectory } = - input - let name = newName ? newName : defaultProjectName - if (doesProjectNameNeedInterpolated(name)) { - const nextIndex = await getNextProjectIndex(name, projects) - name = interpolateProjectNameWithIndex(name, nextIndex) - } - - await renameProjectDirectory( - window.electron.path.join(defaultDirectory, oldName), - name - ) - return `Successfully renamed "${oldName}" to "${name}"` - }), - deleteProject: fromPromise(async ({ input }) => { - await window.electron.rm( - window.electron.path.join(input.defaultDirectory, input.name), - { - recursive: true, - } - ) - return `Successfully deleted "${input.name}"` - }), - }, - guards: { - 'Has at least 1 project': ({ event }) => { - if (event.type !== 'xstate.done.actor.read-projects') return false - console.log(`from has at least 1 project: ${event.output.length}`) - return event.output.length ? event.output.length >= 1 : false - }, - }, - }), - { - input: { - projects: projectPaths, - defaultProjectName: settings.projects.defaultProjectName.current, - defaultDirectory: settings.app.projectDirectory.current, - }, - } - ) - - useEffect(() => { - send({ type: 'Read projects', data: {} }) - }, [projectPaths]) - // Re-read projects listing if the projectDir has any updates. useFileSystemWatcher( async () => { @@ -182,21 +64,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 +121,16 @@ const Home = () => { - send({ type: 'Create project', data: { name: '' } }) + commandBarSend({ + type: 'Find and select command', + data: { + groupId: 'projects', + name: 'Create project', + argDefaultValues: { + name: settings.projects.defaultProjectName.current, + }, + }, + }) } className="group !bg-primary !text-chalkboard-10 !border-primary hover:shadow-inner hover:hue-rotate-15" iconStart={{ @@ -321,12 +204,20 @@ const Home = () => { .

+

+ + Go to a test create-file link + +

- {state.matches('Reading projects') ? ( + {state?.matches('Reading projects') ? ( Loading your Projects... ) : ( <>