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...
) : (
<>