Add ability to create and open URLs to create files (#4166)
* Rename `homeMachine` and accessories to `projectsMachine` * Separate out `/home` route from `projectsMachine` * Add logic to navigate out from deleted or renamed project * Show a warning in the command palette for deleting a project * Make it navigate when you create a project * Update "New project" button to use command bar flow Closes #2585 * More explicit warning message text * Make projects watching code not run in web * Tests first version: nested loops * Tests second version: flattened * Remove console logs * Fix tsc * @jtran feedback, use the type guard util * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest) * Fix tests that relied on one-click, no-navigation project creation * Revert "A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)" This reverts commit7545b61b49
. * Revert "A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)" This reverts commit3d2e48732c
. * Add a mask to the state indicator to client-side scale test * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest) * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) * Fix lint * Fix tsc * Add menu item to share link to file * Forward query params while redirecting to /home or /file * Add (broken) event logic and command triggering logic * Fix a couple stray tests that still relied on the old way of creating projects * De-flake another text that could be thrown off by toast-based selectors * FMT * Dumb test error because I was rushing * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) * Ahhh more flaky toasts, they're everywhere! * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) * Side quest: Only register commands once, power their disabled status while selecting commands via optional actor * Get query-triggered command working in browser too * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest) * Tests always run on localhost, don't expect the prod origin * rerun CI * wip * wip * Everything's pretty much done but url.zoo.dev has been broken and we need to think about how to test reliably * Merge branch 'main' into franknoirot/4088/create-file-url * Add useCreateFileLinkQuery on Home page * Get primary user flow working on desktop * Rework to open browser app first, then send along to the desktop app if asked * Styling updates to OpenInDesktopAppHandler * Clean up unecessary file * Merge branch 'main' into franknoirot/4088/create-file-url * Separate creating `createFileUrl` and shortlink so it is unit testable * Add E2E test for importing file from URL * Add a couple component tests for OpenInDesktopAppHandler * Fix the "existing project" user flow * Add E2E test for "add to existing project" user flow * Undo mistaken or unecessary changes * Lints, fmt, tsc * Fix unit test * Fix broken rename and delete project commands Something about the `optionsFromContext` config no longer works with file I/O-related commands. I suspect this has to do with our read/write loop patching * Fix unit test, use kebab-case for url query param * Use dev urls everywhere when configured that way I think we were just using some constants that ended up returning bad values for dev, it seemed to return a working shortlink when I went through the flow. * Clean up unneeded PROD_TOKEN * Fix browser command flow, because we had made the projectMachine desktop-only on main * Make the test executor a bit more patient (#5004) * Fix so that all artifact commands are returned regardless of caching (#5005) * Fix so that all artifact commands are returned regardless of caching * Add some more docs and fix up old ones * Add new lint to disallow use of confusing isNaN (#4999) * Point-and-click Sweep (first PR) (#4989) * Refactor 'Delete selection' as actor Will fix #4662 * WIP logging * WIP: working Solid3dGetExtrusionFaceInfo for loft * Working wall deletion of loft * Add offset plane deletion * Add feature tree deletion of shell * Clean up * Revert "Clean up" This reverts commit214763cc2b
. * Clean up rust changes, taking the sketch with the most paths * Working cap selection and deletion * Clean up * Add test for loft and offset plane deletion via selection * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-16-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-macos-8-cores) * Set reenter: false as it was originally * Passing test * Add shell deletion via feature tree test * Revert the migration to promise actor * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * Trigger CI * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * Trigger CI * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * Trigger CI * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * Trigger CI * Use cmd.id as solid_id after latest engine merge * Add feature tree deletion of offset plane and fix lint * Add feature tree deletion of loft * Clean up * Better comment * Lint fix * Remove sketch sorting * WIP: sweep point-and-click * Working sweep * Add test * Make sweep a development command * Fix tsc error * Clean up for review --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Upgrade typescript-eslint from 5.62.0 to 8.19.1 and remove eslint-config-react-app (#5006) * Fix lost lints and add new ones (#5011) * Add eslint-plugin-jsx-a11y dependency * Add jsx-a11y lint * Add eslint-plugin-react-hooks dependency * Add react hooks lints * Ignore new react hooks lint in tests * Add eslint-plugin-testing-library dependency * Add testing-library lint * Fix yarn lint to use all files recursively * Developer workflow: added auto generated workspace file from vitest extension in vscode (#4997) * chore: added auto generated workspace file from vitest extension in vscode * fix: auto fmt fixes * Change Dependabot PRs to always be made on Mondays (#5025) * Add packages to Dependabot updates (#5024) * Bump @lezer/generator from 1.7.1 to 1.7.2 (#5018) Bumps [@lezer/generator](https://github.com/lezer-parser/generator) from 1.7.1 to 1.7.2. - [Changelog](https://github.com/lezer-parser/generator/blob/main/CHANGELOG.md) - [Commits](https://github.com/lezer-parser/generator/compare/1.7.1...1.7.2) --- updated-dependencies: - dependency-name: "@lezer/generator" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump handlebars from 6.2.0 to 6.3.0 in /src/wasm-lib (#5012) Bumps [handlebars](https://github.com/sunng87/handlebars-rust) from 6.2.0 to 6.3.0. - [Release notes](https://github.com/sunng87/handlebars-rust/releases) - [Changelog](https://github.com/sunng87/handlebars-rust/blob/master/CHANGELOG.md) - [Commits](https://github.com/sunng87/handlebars-rust/compare/v6.2.0...v6.3.0) --- updated-dependencies: - dependency-name: handlebars dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump syn from 2.0.95 to 2.0.96 in /src/wasm-lib (#5015) Bumps [syn](https://github.com/dtolnay/syn) from 2.0.95 to 2.0.96. - [Release notes](https://github.com/dtolnay/syn/releases) - [Commits](https://github.com/dtolnay/syn/compare/2.0.95...2.0.96) --- updated-dependencies: - dependency-name: syn dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Fix artifact types to be more accurate (#5022) * Fix Cargo.lock to not have changes (#5034) * Upgrade all wasm-bindgen dependencies together (#5037) * Disable auto-updater on non-versioned builds (#5042) * turns on helix from edge (#5036) * updates for new lib Signed-off-by: Jess Frazelle <github@jessfraz.com> * autocomplete Signed-off-by: Jess Frazelle <github@jessfraz.com> * bump version Signed-off-by: Jess Frazelle <github@jessfraz.com> * bump all the things Signed-off-by: Jess Frazelle <github@jessfraz.com> * new samples Signed-off-by: Jess Frazelle <github@jessfraz.com> * docs Signed-off-by: Jess Frazelle <github@jessfraz.com> --------- Signed-off-by: Jess Frazelle <github@jessfraz.com> * ci: Add yarn test of packages/codemirror-lang-kcl (#5035) * ci: Add yarn test of packages/codemirror-lang-kcl * Fix CI error running tests * Fix postcss config error * Bump xstate from 5.17.4 to 5.19.2 (#5027) * Hook up chamfer UI with AST-mod (#4694) * button * config * hook up with ast * cmd bar test * button states fix and test * little naming fix * xState action to actor * remove button state test updates * fixture-based approach * nightly Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com> * Update src/lib/toolbar.ts Co-authored-by: Frank Noirot <frank@zoo.dev> --------- Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com> Co-authored-by: Frank Noirot <frank@zoo.dev> * Remove Redundant Fillet Button State Test (#5009) delete obsolete test * Bump @types/node from 20.14.9 to 22.10.6 in /packages/codemirror-lsp-client (#5041) * custom axis and origin example for helix (#5057) * custom axis and origin for helix Signed-off-by: Jess Frazelle <github@jessfraz.com> * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * empty --------- Signed-off-by: Jess Frazelle <github@jessfraz.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Bump typescript from 5.7.2 to 5.7.3 (#5021) Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.7.2 to 5.7.3. - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml) - [Commits](https://github.com/microsoft/TypeScript/compare/v5.7.2...v5.7.3) --- updated-dependencies: - dependency-name: typescript dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Refactor: break out `copyFileShareLink` into standalone function * Add "Share file" to command palette * Update dumb use of site URL instead of prod app URL * fmt * @lf94 nit * @pierremtb spinner feedback * Hide share link command and disable menu item for now * Just comment out the command config for now --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: Jess Frazelle <github@jessfraz.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: 49lf <ircsurfer33@gmail.com> Co-authored-by: Adam Sunderland <iterion@gmail.com> Co-authored-by: Adam Sunderland <adam@kittycad.io> Co-authored-by: Jonathan Tran <jonnytran@gmail.com> Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com> Co-authored-by: Kevin Nadro <nadr0@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jess Frazelle <jessfraz@users.noreply.github.com> Co-authored-by: max <margorskyi@gmail.com>
This commit is contained in:
@ -1,7 +1,8 @@
|
|||||||
import { test, expect } from './zoo-test'
|
import { test, expect } from './zoo-test'
|
||||||
|
import * as fsp from 'fs/promises'
|
||||||
import { getUtils } from './test-utils'
|
import { executorInputPath, getUtils } from './test-utils'
|
||||||
import { KCL_DEFAULT_LENGTH } from 'lib/constants'
|
import { KCL_DEFAULT_LENGTH } from 'lib/constants'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
test.describe('Command bar tests', () => {
|
test.describe('Command bar tests', () => {
|
||||||
test('Extrude from command bar selects extrude line after', async ({
|
test('Extrude from command bar selects extrude line after', async ({
|
||||||
@ -305,4 +306,132 @@ test.describe('Command bar tests', () => {
|
|||||||
await arcToolCommand.click()
|
await arcToolCommand.click()
|
||||||
await expect(arcToolButton).toHaveAttribute('aria-pressed', 'true')
|
await expect(arcToolButton).toHaveAttribute('aria-pressed', 'true')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test(`Reacts to query param to open "import from URL" command`, async ({
|
||||||
|
page,
|
||||||
|
cmdBar,
|
||||||
|
editor,
|
||||||
|
homePage,
|
||||||
|
}) => {
|
||||||
|
await test.step(`Prepare and navigate to home page with query params`, async () => {
|
||||||
|
const targetURL = `?create-file&name=test&units=mm&code=ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D&ask-open-desktop`
|
||||||
|
await homePage.expectState({
|
||||||
|
projectCards: [],
|
||||||
|
sortBy: 'last-modified-desc',
|
||||||
|
})
|
||||||
|
await page.goto(page.url() + targetURL)
|
||||||
|
expect(page.url()).toContain(targetURL)
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step(`Submit the command`, async () => {
|
||||||
|
await cmdBar.expectState({
|
||||||
|
stage: 'arguments',
|
||||||
|
commandName: 'Import file from URL',
|
||||||
|
currentArgKey: 'method',
|
||||||
|
currentArgValue: '',
|
||||||
|
headerArguments: {
|
||||||
|
Method: '',
|
||||||
|
Name: 'test',
|
||||||
|
Code: '1 line',
|
||||||
|
},
|
||||||
|
highlightedHeaderArg: 'method',
|
||||||
|
})
|
||||||
|
await cmdBar.selectOption({ name: 'New Project' }).click()
|
||||||
|
await cmdBar.expectState({
|
||||||
|
stage: 'review',
|
||||||
|
commandName: 'Import file from URL',
|
||||||
|
headerArguments: {
|
||||||
|
Method: 'New project',
|
||||||
|
Name: 'test',
|
||||||
|
Code: '1 line',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await cmdBar.progressCmdBar()
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step(`Ensure we created the project and are in the modeling scene`, async () => {
|
||||||
|
await editor.expectEditor.toContain('extrusionDistance = 12')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`"import from URL" can add to existing project`, async ({
|
||||||
|
page,
|
||||||
|
cmdBar,
|
||||||
|
editor,
|
||||||
|
homePage,
|
||||||
|
toolbar,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await context.folderSetupFn(async (dir) => {
|
||||||
|
const testProjectDir = path.join(dir, 'testProjectDir')
|
||||||
|
await Promise.all([fsp.mkdir(testProjectDir, { recursive: true })])
|
||||||
|
await Promise.all([
|
||||||
|
fsp.copyFile(
|
||||||
|
executorInputPath('cylinder.kcl'),
|
||||||
|
path.join(testProjectDir, 'main.kcl')
|
||||||
|
),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
await test.step(`Prepare and navigate to home page with query params`, async () => {
|
||||||
|
const targetURL = `?create-file&name=test&units=mm&code=ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D&ask-open-desktop`
|
||||||
|
await homePage.expectState({
|
||||||
|
projectCards: [
|
||||||
|
{
|
||||||
|
fileCount: 1,
|
||||||
|
title: 'testProjectDir',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sortBy: 'last-modified-desc',
|
||||||
|
})
|
||||||
|
await page.goto(page.url() + targetURL)
|
||||||
|
expect(page.url()).toContain(targetURL)
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step(`Submit the command`, async () => {
|
||||||
|
await cmdBar.expectState({
|
||||||
|
stage: 'arguments',
|
||||||
|
commandName: 'Import file from URL',
|
||||||
|
currentArgKey: 'method',
|
||||||
|
currentArgValue: '',
|
||||||
|
headerArguments: {
|
||||||
|
Method: '',
|
||||||
|
Name: 'test',
|
||||||
|
Code: '1 line',
|
||||||
|
},
|
||||||
|
highlightedHeaderArg: 'method',
|
||||||
|
})
|
||||||
|
await cmdBar.selectOption({ name: 'Existing Project' }).click()
|
||||||
|
await cmdBar.expectState({
|
||||||
|
stage: 'arguments',
|
||||||
|
commandName: 'Import file from URL',
|
||||||
|
currentArgKey: 'projectName',
|
||||||
|
currentArgValue: '',
|
||||||
|
headerArguments: {
|
||||||
|
Method: 'Existing project',
|
||||||
|
Name: 'test',
|
||||||
|
ProjectName: '',
|
||||||
|
Code: '1 line',
|
||||||
|
},
|
||||||
|
highlightedHeaderArg: 'projectName',
|
||||||
|
})
|
||||||
|
await cmdBar.selectOption({ name: 'testProjectDir' }).click()
|
||||||
|
await cmdBar.expectState({
|
||||||
|
stage: 'review',
|
||||||
|
commandName: 'Import file from URL',
|
||||||
|
headerArguments: {
|
||||||
|
Method: 'Existing project',
|
||||||
|
ProjectName: 'testProjectDir',
|
||||||
|
Name: 'test',
|
||||||
|
Code: '1 line',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await cmdBar.progressCmdBar()
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step(`Ensure we created the project and are in the modeling scene`, async () => {
|
||||||
|
await editor.expectEditor.toContain('extrusionDistance = 12')
|
||||||
|
await toolbar.openPane('files')
|
||||||
|
await toolbar.expectFileTreeState(['main.kcl', 'test.kcl'])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -151,4 +151,11 @@ export class CmdBarFixture {
|
|||||||
chooseCommand = async (commandName: string) => {
|
chooseCommand = async (commandName: string) => {
|
||||||
await this.cmdOptions.getByText(commandName).click()
|
await this.cmdOptions.getByText(commandName).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select an option from the command bar
|
||||||
|
*/
|
||||||
|
selectOption = (options: Parameters<typeof this.page.getByRole>[1]) => {
|
||||||
|
return this.page.getByRole('option', options)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
15
src/App.tsx
15
src/App.tsx
@ -22,13 +22,28 @@ import Gizmo from 'components/Gizmo'
|
|||||||
import { CoreDumpManager } from 'lib/coredump'
|
import { CoreDumpManager } from 'lib/coredump'
|
||||||
import { UnitsMenu } from 'components/UnitsMenu'
|
import { UnitsMenu } from 'components/UnitsMenu'
|
||||||
import { CameraProjectionToggle } from 'components/CameraProjectionToggle'
|
import { CameraProjectionToggle } from 'components/CameraProjectionToggle'
|
||||||
|
import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher'
|
||||||
import { maybeWriteToDisk } from 'lib/telemetry'
|
import { maybeWriteToDisk } from 'lib/telemetry'
|
||||||
|
import { commandBarActor } from 'machines/commandBarMachine'
|
||||||
maybeWriteToDisk()
|
maybeWriteToDisk()
|
||||||
.then(() => {})
|
.then(() => {})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { project, file } = useLoaderData() as IndexLoaderData
|
const { project, file } = useLoaderData() as IndexLoaderData
|
||||||
|
|
||||||
|
// Keep a lookout for a URL query string that invokes the 'import file from URL' command
|
||||||
|
useCreateFileLinkQuery((argDefaultValues) => {
|
||||||
|
commandBarActor.send({
|
||||||
|
type: 'Find and select command',
|
||||||
|
data: {
|
||||||
|
groupId: 'projects',
|
||||||
|
name: 'Import file from URL',
|
||||||
|
argDefaultValues,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
useRefreshSettings(PATHS.FILE + 'SETTINGS')
|
useRefreshSettings(PATHS.FILE + 'SETTINGS')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const filePath = useAbsoluteFilePath()
|
const filePath = useAbsoluteFilePath()
|
||||||
|
@ -34,7 +34,7 @@ import {
|
|||||||
import SettingsAuthProvider from 'components/SettingsAuthProvider'
|
import SettingsAuthProvider from 'components/SettingsAuthProvider'
|
||||||
import LspProvider from 'components/LspProvider'
|
import LspProvider from 'components/LspProvider'
|
||||||
import { KclContextProvider } from 'lang/KclProvider'
|
import { KclContextProvider } from 'lang/KclProvider'
|
||||||
import { BROWSER_PROJECT_NAME } from 'lib/constants'
|
import { ASK_TO_OPEN_QUERY_PARAM, BROWSER_PROJECT_NAME } from 'lib/constants'
|
||||||
import { CoreDumpManager } from 'lib/coredump'
|
import { CoreDumpManager } from 'lib/coredump'
|
||||||
import { codeManager, engineCommandManager } from 'lib/singletons'
|
import { codeManager, engineCommandManager } from 'lib/singletons'
|
||||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||||
@ -46,6 +46,7 @@ import { AppStateProvider } from 'AppState'
|
|||||||
import { reportRejection } from 'lib/trap'
|
import { reportRejection } from 'lib/trap'
|
||||||
import { RouteProvider } from 'components/RouteProvider'
|
import { RouteProvider } from 'components/RouteProvider'
|
||||||
import { ProjectsContextProvider } from 'components/ProjectsContextProvider'
|
import { ProjectsContextProvider } from 'components/ProjectsContextProvider'
|
||||||
|
import { OpenInDesktopAppHandler } from 'components/OpenInDesktopAppHandler'
|
||||||
|
|
||||||
const createRouter = isDesktop() ? createHashRouter : createBrowserRouter
|
const createRouter = isDesktop() ? createHashRouter : createBrowserRouter
|
||||||
|
|
||||||
@ -57,6 +58,7 @@ const router = createRouter([
|
|||||||
/* Make sure auth is the outermost provider or else we will have
|
/* Make sure auth is the outermost provider or else we will have
|
||||||
* inefficient re-renders, use the react profiler to see. */
|
* inefficient re-renders, use the react profiler to see. */
|
||||||
element: (
|
element: (
|
||||||
|
<OpenInDesktopAppHandler>
|
||||||
<RouteProvider>
|
<RouteProvider>
|
||||||
<SettingsAuthProvider>
|
<SettingsAuthProvider>
|
||||||
<LspProvider>
|
<LspProvider>
|
||||||
@ -72,16 +74,26 @@ const router = createRouter([
|
|||||||
</LspProvider>
|
</LspProvider>
|
||||||
</SettingsAuthProvider>
|
</SettingsAuthProvider>
|
||||||
</RouteProvider>
|
</RouteProvider>
|
||||||
|
</OpenInDesktopAppHandler>
|
||||||
),
|
),
|
||||||
errorElement: <ErrorPage />,
|
errorElement: <ErrorPage />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: PATHS.INDEX,
|
path: PATHS.INDEX,
|
||||||
loader: async () => {
|
loader: async ({ request }) => {
|
||||||
const onDesktop = isDesktop()
|
const onDesktop = isDesktop()
|
||||||
return onDesktop
|
const url = new URL(request.url)
|
||||||
? redirect(PATHS.HOME)
|
if (onDesktop) {
|
||||||
: redirect(PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME)
|
return redirect(PATHS.HOME + (url.search || ''))
|
||||||
|
} else {
|
||||||
|
const searchParams = new URLSearchParams(url.search)
|
||||||
|
if (!searchParams.has(ASK_TO_OPEN_QUERY_PARAM)) {
|
||||||
|
return redirect(
|
||||||
|
PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME + (url.search || '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -129,6 +129,7 @@ function CommandArgOptionInput({
|
|||||||
<label
|
<label
|
||||||
htmlFor="option-input"
|
htmlFor="option-input"
|
||||||
className="capitalize px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80"
|
className="capitalize px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80"
|
||||||
|
data-testid="cmd-bar-arg-name"
|
||||||
>
|
>
|
||||||
{argName}
|
{argName}
|
||||||
</label>
|
</label>
|
||||||
|
@ -47,8 +47,9 @@ export const FileMachineProvider = ({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { settings } = useSettingsAuthContext()
|
const { settings, auth } = useSettingsAuthContext()
|
||||||
const { project, file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
|
const projectData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
|
||||||
|
const { project, file } = projectData
|
||||||
const [kclSamples, setKclSamples] = React.useState<KclSamplesManifestItem[]>(
|
const [kclSamples, setKclSamples] = React.useState<KclSamplesManifestItem[]>(
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
@ -295,8 +296,14 @@ export const FileMachineProvider = ({
|
|||||||
|
|
||||||
const kclCommandMemo = useMemo(
|
const kclCommandMemo = useMemo(
|
||||||
() =>
|
() =>
|
||||||
kclCommands(
|
kclCommands({
|
||||||
async (data) => {
|
authToken: auth?.context?.token ?? '',
|
||||||
|
projectData,
|
||||||
|
settings: {
|
||||||
|
defaultUnit: settings?.context?.modeling.defaultUnit.current ?? 'mm',
|
||||||
|
},
|
||||||
|
specialPropsForSampleCommand: {
|
||||||
|
onSubmit: async (data) => {
|
||||||
if (data.method === 'overwrite') {
|
if (data.method === 'overwrite') {
|
||||||
codeManager.updateCodeStateEditor(data.code)
|
codeManager.updateCodeStateEditor(data.code)
|
||||||
await kclManager.executeCode(true)
|
await kclManager.executeCode(true)
|
||||||
@ -324,11 +331,12 @@ export const FileMachineProvider = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
kclSamples.map((sample) => ({
|
providedOptions: kclSamples.map((sample) => ({
|
||||||
value: sample.pathFromProjectDirectoryToFirstFile,
|
value: sample.pathFromProjectDirectoryToFirstFile,
|
||||||
name: sample.title,
|
name: sample.title,
|
||||||
}))
|
})),
|
||||||
).filter(
|
},
|
||||||
|
}).filter(
|
||||||
(command) => kclSamples.length || command.name !== 'open-kcl-example'
|
(command) => kclSamples.length || command.name !== 'open-kcl-example'
|
||||||
),
|
),
|
||||||
[codeManager, kclManager, send, kclSamples]
|
[codeManager, kclManager, send, kclSamples]
|
||||||
|
68
src/components/OpenInDesktopAppHandler.test.tsx
Normal file
68
src/components/OpenInDesktopAppHandler.test.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||||
|
import { OpenInDesktopAppHandler } from './OpenInDesktopAppHandler'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The behavior under test requires a router,
|
||||||
|
* so we wrap the component in a minimal router setup.
|
||||||
|
*/
|
||||||
|
function TestingMinimalRouterWrapper({
|
||||||
|
children,
|
||||||
|
location,
|
||||||
|
}: {
|
||||||
|
location?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Routes location={location}>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={<OpenInDesktopAppHandler>{children}</OpenInDesktopAppHandler>}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OpenInDesktopAppHandler tests', () => {
|
||||||
|
test(`does not render the modal if no query param is present`, () => {
|
||||||
|
render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<TestingMinimalRouterWrapper>
|
||||||
|
<p>Dummy app contents</p>
|
||||||
|
</TestingMinimalRouterWrapper>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
|
||||||
|
const dummyAppContents = screen.getByText('Dummy app contents')
|
||||||
|
const modalContents = screen.queryByText('Open in desktop app')
|
||||||
|
|
||||||
|
expect(dummyAppContents).toBeInTheDocument()
|
||||||
|
expect(modalContents).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`renders the modal if the query param is present`, () => {
|
||||||
|
render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<TestingMinimalRouterWrapper location="/?ask-open-desktop">
|
||||||
|
<p>Dummy app contents</p>
|
||||||
|
</TestingMinimalRouterWrapper>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
|
||||||
|
let dummyAppContents = screen.queryByText('Dummy app contents')
|
||||||
|
let modalButton = screen.queryByText('Continue to web app')
|
||||||
|
|
||||||
|
// Starts as disconnected
|
||||||
|
expect(dummyAppContents).not.toBeInTheDocument()
|
||||||
|
expect(modalButton).not.toBeFalsy()
|
||||||
|
expect(modalButton).toBeInTheDocument()
|
||||||
|
fireEvent.click(modalButton as Element)
|
||||||
|
|
||||||
|
// I don't like that you have to re-query the screen here
|
||||||
|
dummyAppContents = screen.queryByText('Dummy app contents')
|
||||||
|
modalButton = screen.queryByText('Continue to web app')
|
||||||
|
|
||||||
|
expect(dummyAppContents).toBeInTheDocument()
|
||||||
|
expect(modalButton).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
125
src/components/OpenInDesktopAppHandler.tsx
Normal file
125
src/components/OpenInDesktopAppHandler.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { getSystemTheme, Themes } from 'lib/theme'
|
||||||
|
import { ZOO_STUDIO_PROTOCOL } from 'lib/constants'
|
||||||
|
import { isDesktop } from 'lib/isDesktop'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { ASK_TO_OPEN_QUERY_PARAM } from 'lib/constants'
|
||||||
|
import { VITE_KC_SITE_BASE_URL } from 'env'
|
||||||
|
import { ActionButton } from './ActionButton'
|
||||||
|
import { Transition } from '@headlessui/react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component is a handler that checks if a certain query parameter
|
||||||
|
* is present, and if so, it will show a modal asking the user if they
|
||||||
|
* want to open the current page in the desktop app.
|
||||||
|
*/
|
||||||
|
export const OpenInDesktopAppHandler = (props: React.PropsWithChildren) => {
|
||||||
|
const theme = getSystemTheme()
|
||||||
|
const buttonClasses =
|
||||||
|
'bg-transparent flex-0 hover:bg-primary/10 dark:hover:bg-primary/10'
|
||||||
|
const pathLogomarkSvg = `${isDesktop() ? '.' : ''}/zma-logomark${
|
||||||
|
theme === Themes.Light ? '-dark' : ''
|
||||||
|
}.svg`
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
// We also ignore this param on desktop, as it is redundant
|
||||||
|
const hasAskToOpenParam =
|
||||||
|
!isDesktop() && searchParams.has(ASK_TO_OPEN_QUERY_PARAM)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function removes the query param to ask to open in desktop app
|
||||||
|
* and then navigates to the same route but with our custom protocol
|
||||||
|
* `zoo-studio:` instead of `https://${BASE_URL}`, to trigger the user's
|
||||||
|
* desktop app to open.
|
||||||
|
*/
|
||||||
|
function onOpenInDesktopApp() {
|
||||||
|
const newSearchParams = new URLSearchParams(globalThis.location.search)
|
||||||
|
newSearchParams.delete(ASK_TO_OPEN_QUERY_PARAM)
|
||||||
|
const newURL = `${ZOO_STUDIO_PROTOCOL}${globalThis.location.pathname.replace(
|
||||||
|
'/',
|
||||||
|
''
|
||||||
|
)}${searchParams.size > 0 ? `?${newSearchParams.toString()}` : ''}`
|
||||||
|
globalThis.location.href = newURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Just remove the query param to ask to open in desktop app
|
||||||
|
* and continue to the web app.
|
||||||
|
*/
|
||||||
|
function continueToWebApp() {
|
||||||
|
searchParams.delete(ASK_TO_OPEN_QUERY_PARAM)
|
||||||
|
setSearchParams(searchParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasAskToOpenParam ? (
|
||||||
|
<Transition
|
||||||
|
appear
|
||||||
|
show={true}
|
||||||
|
as="div"
|
||||||
|
className={
|
||||||
|
theme +
|
||||||
|
` fixed inset-0 grid p-4 place-content-center ${
|
||||||
|
theme === Themes.Dark ? '!bg-chalkboard-110 text-chalkboard-20' : ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Transition.Child
|
||||||
|
as="div"
|
||||||
|
className={`max-w-3xl py-6 px-10 flex flex-col items-center gap-8
|
||||||
|
mx-auto border rounded-lg shadow-lg dark:bg-chalkboard-100`}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
style={{ zIndex: 10 }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl">
|
||||||
|
Launching{' '}
|
||||||
|
<img
|
||||||
|
src={pathLogomarkSvg}
|
||||||
|
className="w-48"
|
||||||
|
alt="Zoo Modeling App"
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-primary flex items-center gap-2">
|
||||||
|
Choose where to open this link...
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col md:flex-row items-start justify-between gap-4 xl:gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
className={buttonClasses + ' !text-base'}
|
||||||
|
onClick={onOpenInDesktopApp}
|
||||||
|
iconEnd={{ icon: 'arrowRight' }}
|
||||||
|
>
|
||||||
|
Open in desktop app
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
Element="externalLink"
|
||||||
|
className={
|
||||||
|
buttonClasses +
|
||||||
|
' text-sm border-transparent justify-center dark:bg-transparent'
|
||||||
|
}
|
||||||
|
to={`${VITE_KC_SITE_BASE_URL}/modeling-app/download`}
|
||||||
|
iconEnd={{ icon: 'link', bgClassName: '!bg-transparent' }}
|
||||||
|
>
|
||||||
|
Download desktop app
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
className={buttonClasses + ' -order-1 !text-base'}
|
||||||
|
onClick={continueToWebApp}
|
||||||
|
iconStart={{ icon: 'arrowLeft' }}
|
||||||
|
>
|
||||||
|
Continue to web app
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</Transition>
|
||||||
|
) : (
|
||||||
|
props.children
|
||||||
|
)
|
||||||
|
}
|
@ -9,7 +9,7 @@ import { Logo } from './Logo'
|
|||||||
import { APP_NAME } from 'lib/constants'
|
import { APP_NAME } from 'lib/constants'
|
||||||
import { CustomIcon } from './CustomIcon'
|
import { CustomIcon } from './CustomIcon'
|
||||||
import { useLspContext } from './LspProvider'
|
import { useLspContext } from './LspProvider'
|
||||||
import { engineCommandManager, kclManager } from 'lib/singletons'
|
import { codeManager, engineCommandManager, kclManager } from 'lib/singletons'
|
||||||
import { MachineManagerContext } from 'components/MachineManagerProvider'
|
import { MachineManagerContext } from 'components/MachineManagerProvider'
|
||||||
import usePlatform from 'hooks/usePlatform'
|
import usePlatform from 'hooks/usePlatform'
|
||||||
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
|
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
|
||||||
@ -17,6 +17,9 @@ import Tooltip from './Tooltip'
|
|||||||
import { SnapshotFrom } from 'xstate'
|
import { SnapshotFrom } from 'xstate'
|
||||||
import { commandBarActor } from 'machines/commandBarMachine'
|
import { commandBarActor } from 'machines/commandBarMachine'
|
||||||
import { useSelector } from '@xstate/react'
|
import { useSelector } from '@xstate/react'
|
||||||
|
import { copyFileShareLink } from 'lib/links'
|
||||||
|
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||||
|
import { DEV } from 'env'
|
||||||
|
|
||||||
const ProjectSidebarMenu = ({
|
const ProjectSidebarMenu = ({
|
||||||
project,
|
project,
|
||||||
@ -100,6 +103,7 @@ function ProjectMenuPopover({
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const filePath = useAbsoluteFilePath()
|
const filePath = useAbsoluteFilePath()
|
||||||
|
const { settings, auth } = useSettingsAuthContext()
|
||||||
const machineManager = useContext(MachineManagerContext)
|
const machineManager = useContext(MachineManagerContext)
|
||||||
const commands = useSelector(commandBarActor, commandsSelector)
|
const commands = useSelector(commandBarActor, commandsSelector)
|
||||||
|
|
||||||
@ -158,7 +162,6 @@ function ProjectMenuPopover({
|
|||||||
data: exportCommandInfo,
|
data: exportCommandInfo,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'break',
|
|
||||||
{
|
{
|
||||||
id: 'make',
|
id: 'make',
|
||||||
Element: 'button',
|
Element: 'button',
|
||||||
@ -184,6 +187,20 @@ function ProjectMenuPopover({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'share-link',
|
||||||
|
Element: 'button',
|
||||||
|
children: 'Share link to file',
|
||||||
|
disabled: !DEV,
|
||||||
|
onClick: async () => {
|
||||||
|
await copyFileShareLink({
|
||||||
|
token: auth?.context.token || '',
|
||||||
|
code: codeManager.code,
|
||||||
|
name: project?.name || '',
|
||||||
|
units: settings.context.modeling.defaultUnit.current,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
'break',
|
'break',
|
||||||
{
|
{
|
||||||
id: 'go-home',
|
id: 'go-home',
|
||||||
|
@ -2,11 +2,11 @@ import { useMachine } from '@xstate/react'
|
|||||||
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
|
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
|
||||||
import { useProjectsLoader } from 'hooks/useProjectsLoader'
|
import { useProjectsLoader } from 'hooks/useProjectsLoader'
|
||||||
import { projectsMachine } from 'machines/projectsMachine'
|
import { projectsMachine } from 'machines/projectsMachine'
|
||||||
import { createContext, useEffect, useState } from 'react'
|
import { createContext, useCallback, useEffect, useState } from 'react'
|
||||||
import { Actor, AnyStateMachine, fromPromise, Prop, StateFrom } from 'xstate'
|
import { Actor, AnyStateMachine, fromPromise, Prop, StateFrom } from 'xstate'
|
||||||
import { useLspContext } from './LspProvider'
|
import { useLspContext } from './LspProvider'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { PATHS } from 'lib/paths'
|
import { PATHS } from 'lib/paths'
|
||||||
import {
|
import {
|
||||||
createNewProjectDirectory,
|
createNewProjectDirectory,
|
||||||
@ -18,12 +18,28 @@ import {
|
|||||||
interpolateProjectNameWithIndex,
|
interpolateProjectNameWithIndex,
|
||||||
doesProjectNameNeedInterpolated,
|
doesProjectNameNeedInterpolated,
|
||||||
getUniqueProjectName,
|
getUniqueProjectName,
|
||||||
|
getNextFileName,
|
||||||
} from 'lib/desktopFS'
|
} from 'lib/desktopFS'
|
||||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||||
import useStateMachineCommands from 'hooks/useStateMachineCommands'
|
import useStateMachineCommands from 'hooks/useStateMachineCommands'
|
||||||
import { projectsCommandBarConfig } from 'lib/commandBarConfigs/projectsCommandConfig'
|
import { projectsCommandBarConfig } from 'lib/commandBarConfigs/projectsCommandConfig'
|
||||||
import { isDesktop } from 'lib/isDesktop'
|
import { isDesktop } from 'lib/isDesktop'
|
||||||
import { commandBarActor } from 'machines/commandBarMachine'
|
import { commandBarActor } from 'machines/commandBarMachine'
|
||||||
|
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<T extends AnyStateMachine> = {
|
type MachineContext<T extends AnyStateMachine> = {
|
||||||
state?: StateFrom<T>
|
state?: StateFrom<T>
|
||||||
@ -53,12 +69,110 @@ export const ProjectsContextProvider = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We need some of the functionality of the ProjectsContextProvider in the web version
|
||||||
|
* but we can't perform file system operations in the browser,
|
||||||
|
* so most of the behavior of this machine is stubbed out.
|
||||||
|
*/
|
||||||
const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => {
|
const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
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 {
|
||||||
|
settings: { context: settings, send: settingsSend },
|
||||||
|
} = useSettingsAuthContext()
|
||||||
|
|
||||||
|
const [state, send, actor] = useMachine(
|
||||||
|
projectsMachine.provide({
|
||||||
|
actions: {
|
||||||
|
navigateToProject: () => {},
|
||||||
|
navigateToProjectIfNeeded: () => {},
|
||||||
|
navigateToFile: () => {},
|
||||||
|
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 () => [] as Project[]),
|
||||||
|
createProject: fromPromise(async () => ({
|
||||||
|
message: 'not implemented on web',
|
||||||
|
})),
|
||||||
|
renameProject: fromPromise(async () => ({
|
||||||
|
message: 'not implemented on web',
|
||||||
|
oldName: '',
|
||||||
|
newName: '',
|
||||||
|
})),
|
||||||
|
deleteProject: fromPromise(async () => ({
|
||||||
|
message: 'not implemented on web',
|
||||||
|
name: '',
|
||||||
|
})),
|
||||||
|
createFile: fromPromise(async ({ input }) => {
|
||||||
|
// Browser version doesn't navigate, just overwrites the current file
|
||||||
|
clearImportSearchParams()
|
||||||
|
codeManager.updateCodeStateEditor(input.code || '')
|
||||||
|
await codeManager.writeToFile()
|
||||||
|
|
||||||
|
settingsSend({
|
||||||
|
type: 'set.modeling.defaultUnit',
|
||||||
|
data: {
|
||||||
|
level: 'project',
|
||||||
|
value: input.units,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'File and units overwritten successfully',
|
||||||
|
fileName: input.name,
|
||||||
|
projectName: '',
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
projects: [],
|
||||||
|
defaultProjectName: settings.projects.defaultProjectName.current,
|
||||||
|
defaultDirectory: settings.app.projectDirectory.current,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// register all project-related command palette commands
|
||||||
|
useStateMachineCommands({
|
||||||
|
machineId: 'projects',
|
||||||
|
send,
|
||||||
|
state,
|
||||||
|
commandBarConfig: projectsCommandBarConfig,
|
||||||
|
actor,
|
||||||
|
onCancel: clearImportSearchParams,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProjectsMachineContext.Provider
|
<ProjectsMachineContext.Provider
|
||||||
value={{
|
value={{
|
||||||
state: undefined,
|
state,
|
||||||
send: () => {},
|
send,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@ -73,18 +187,21 @@ const ProjectsContextDesktop = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
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 { onProjectOpen } = useLspContext()
|
const { onProjectOpen } = useLspContext()
|
||||||
const {
|
const {
|
||||||
settings: { context: settings },
|
settings: { context: settings },
|
||||||
} = useSettingsAuthContext()
|
} = useSettingsAuthContext()
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(
|
|
||||||
'project directory changed',
|
|
||||||
settings.app.projectDirectory.current
|
|
||||||
)
|
|
||||||
}, [settings.app.projectDirectory.current])
|
|
||||||
|
|
||||||
const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0)
|
const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0)
|
||||||
const { projectPaths, projectsDir } = useProjectsLoader([
|
const { projectPaths, projectsDir } = useProjectsLoader([
|
||||||
projectsLoaderTrigger,
|
projectsLoaderTrigger,
|
||||||
@ -168,6 +285,31 @@ const ProjectsContextDesktop = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
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 }) =>
|
toastSuccess: ({ event }) =>
|
||||||
toast.success(
|
toast.success(
|
||||||
('data' in event && typeof event.data === 'string' && event.data) ||
|
('data' in event && typeof event.data === 'string' && event.data) ||
|
||||||
@ -217,8 +359,6 @@ const ProjectsContextDesktop = ({
|
|||||||
name = interpolateProjectNameWithIndex(name, nextIndex)
|
name = interpolateProjectNameWithIndex(name, nextIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('from Project')
|
|
||||||
|
|
||||||
await renameProjectDirectory(
|
await renameProjectDirectory(
|
||||||
window.electron.path.join(defaultDirectory, oldName),
|
window.electron.path.join(defaultDirectory, oldName),
|
||||||
name
|
name
|
||||||
@ -241,14 +381,83 @@ const ProjectsContextDesktop = ({
|
|||||||
name: 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<Configuration> = {
|
||||||
|
settings: {
|
||||||
|
project: {
|
||||||
|
directory: settings.app.projectDirectory.current,
|
||||||
},
|
},
|
||||||
guards: {
|
modeling: {
|
||||||
'Has at least 1 project': ({ event }) => {
|
base_unit: input.units,
|
||||||
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
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsInterpolated = doesProjectNameNeedInterpolated(projectName)
|
||||||
|
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 || '')
|
||||||
|
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
fileName,
|
||||||
|
projectName,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
input: {
|
input: {
|
||||||
@ -270,6 +479,7 @@ const ProjectsContextDesktop = ({
|
|||||||
state,
|
state,
|
||||||
commandBarConfig: projectsCommandBarConfig,
|
commandBarConfig: projectsCommandBarConfig,
|
||||||
actor,
|
actor,
|
||||||
|
onCancel: clearImportSearchParams,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
65
src/hooks/useCreateFileLinkQueryWatcher.ts
Normal file
65
src/hooks/useCreateFileLinkQueryWatcher.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
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/links'
|
||||||
|
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)
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
@ -14,7 +14,7 @@ export const useProjectsLoader = (deps?: [number]) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Useless on web, until we get fake filesystems over there.
|
// Useless on web, until we get fake filesystems over there.
|
||||||
if (!isDesktop) return
|
if (!isDesktop()) return
|
||||||
|
|
||||||
if (deps && deps[0] === lastTs) return
|
if (deps && deps[0] === lastTs) return
|
||||||
|
|
||||||
|
40
src/lib/base64.test.ts
Normal file
40
src/lib/base64.test.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
})
|
29
src/lib/base64.ts
Normal file
29
src/lib/base64.ts
Normal file
@ -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)
|
||||||
|
}
|
@ -1,5 +1,8 @@
|
|||||||
|
import { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
|
||||||
import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning'
|
import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning'
|
||||||
import { StateMachineCommandSetConfig } from 'lib/commandTypes'
|
import { StateMachineCommandSetConfig } from 'lib/commandTypes'
|
||||||
|
import { isDesktop } from 'lib/isDesktop'
|
||||||
|
import { baseUnitLabels, baseUnitsUnion } from 'lib/settings/settingsTypes'
|
||||||
import { projectsMachine } from 'machines/projectsMachine'
|
import { projectsMachine } from 'machines/projectsMachine'
|
||||||
|
|
||||||
export type ProjectsCommandSchema = {
|
export type ProjectsCommandSchema = {
|
||||||
@ -17,6 +20,13 @@ export type ProjectsCommandSchema = {
|
|||||||
oldName: string
|
oldName: string
|
||||||
newName: string
|
newName: string
|
||||||
}
|
}
|
||||||
|
'Import file from URL': {
|
||||||
|
name: string
|
||||||
|
code?: string
|
||||||
|
units: UnitLength_type
|
||||||
|
method: 'newProject' | 'existingProject'
|
||||||
|
projectName?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const projectsCommandBarConfig: StateMachineCommandSetConfig<
|
export const projectsCommandBarConfig: StateMachineCommandSetConfig<
|
||||||
@ -26,6 +36,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
|
|||||||
'Open project': {
|
'Open project': {
|
||||||
icon: 'arrowRight',
|
icon: 'arrowRight',
|
||||||
description: 'Open a project',
|
description: 'Open a project',
|
||||||
|
status: isDesktop() ? 'active' : 'inactive',
|
||||||
args: {
|
args: {
|
||||||
name: {
|
name: {
|
||||||
inputType: 'options',
|
inputType: 'options',
|
||||||
@ -42,6 +53,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
|
|||||||
'Create project': {
|
'Create project': {
|
||||||
icon: 'folderPlus',
|
icon: 'folderPlus',
|
||||||
description: 'Create a project',
|
description: 'Create a project',
|
||||||
|
status: isDesktop() ? 'active' : 'inactive',
|
||||||
args: {
|
args: {
|
||||||
name: {
|
name: {
|
||||||
inputType: 'string',
|
inputType: 'string',
|
||||||
@ -53,6 +65,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
|
|||||||
'Delete project': {
|
'Delete project': {
|
||||||
icon: 'close',
|
icon: 'close',
|
||||||
description: 'Delete a project',
|
description: 'Delete a project',
|
||||||
|
status: isDesktop() ? 'active' : 'inactive',
|
||||||
needsReview: true,
|
needsReview: true,
|
||||||
reviewMessage: ({ argumentsToSubmit }) =>
|
reviewMessage: ({ argumentsToSubmit }) =>
|
||||||
CommandBarOverwriteWarning({
|
CommandBarOverwriteWarning({
|
||||||
@ -75,6 +88,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
|
|||||||
icon: 'folder',
|
icon: 'folder',
|
||||||
description: 'Rename a project',
|
description: 'Rename a project',
|
||||||
needsReview: true,
|
needsReview: true,
|
||||||
|
status: isDesktop() ? 'active' : 'inactive',
|
||||||
args: {
|
args: {
|
||||||
oldName: {
|
oldName: {
|
||||||
inputType: 'options',
|
inputType: 'options',
|
||||||
@ -92,4 +106,80 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'Import file from URL': {
|
||||||
|
icon: 'file',
|
||||||
|
description: 'Create a file',
|
||||||
|
needsReview: true,
|
||||||
|
status: 'active',
|
||||||
|
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: (_, 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) {
|
||||||
|
const lineCount = value?.trim().split('\n').length
|
||||||
|
return `${lineCount} line${lineCount === 1 ? '' : 's'}`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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.`
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
@ -69,6 +69,7 @@ export const KCL_DEFAULT_DEGREE = `360`
|
|||||||
export const TEST_SETTINGS_FILE_KEY = 'playwright-test-settings'
|
export const TEST_SETTINGS_FILE_KEY = 'playwright-test-settings'
|
||||||
|
|
||||||
export const DEFAULT_HOST = 'https://api.zoo.dev'
|
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 SETTINGS_FILE_NAME = 'settings.toml'
|
||||||
export const TOKEN_FILE_NAME = 'token.txt'
|
export const TOKEN_FILE_NAME = 'token.txt'
|
||||||
export const PROJECT_SETTINGS_FILE_NAME = 'project.toml'
|
export const PROJECT_SETTINGS_FILE_NAME = 'project.toml'
|
||||||
@ -110,6 +111,9 @@ export const KCL_SAMPLES_MANIFEST_URLS = {
|
|||||||
localFallback: '/kcl-samples-manifest-fallback.json',
|
localFallback: '/kcl-samples-manifest-fallback.json',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
/** URL parameter to create a file */
|
||||||
|
export const CREATE_FILE_URL_PARAM = 'create-file'
|
||||||
|
|
||||||
/** Toast id for the app auto-updater toast */
|
/** Toast id for the app auto-updater toast */
|
||||||
export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast'
|
export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast'
|
||||||
|
|
||||||
@ -139,3 +143,12 @@ export const VIEW_NAMES_SEMANTIC = {
|
|||||||
} as const
|
} as const
|
||||||
/** The modeling sidebar buttons' IDs get a suffix to prevent collisions */
|
/** The modeling sidebar buttons' IDs get a suffix to prevent collisions */
|
||||||
export const SIDEBAR_BUTTON_SUFFIX = '-pane-button'
|
export const SIDEBAR_BUTTON_SUFFIX = '-pane-button'
|
||||||
|
|
||||||
|
/** Custom URL protocol our desktop registers */
|
||||||
|
export const ZOO_STUDIO_PROTOCOL = 'zoo-studio:'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A query parameter that triggers a modal
|
||||||
|
* to "open in desktop app" when present in the URL
|
||||||
|
*/
|
||||||
|
export const ASK_TO_OPEN_QUERY_PARAM = 'ask-open-desktop'
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning'
|
import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning'
|
||||||
import { Command, CommandArgumentOption } from './commandTypes'
|
import { Command, CommandArgumentOption } from './commandTypes'
|
||||||
import { kclManager } from './singletons'
|
import { codeManager, kclManager } from './singletons'
|
||||||
import { isDesktop } from './isDesktop'
|
import { isDesktop } from './isDesktop'
|
||||||
import { FILE_EXT, PROJECT_SETTINGS_FILE_NAME } from './constants'
|
import { FILE_EXT, PROJECT_SETTINGS_FILE_NAME } from './constants'
|
||||||
import { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
|
import { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
|
||||||
import { parseProjectSettings } from 'lang/wasm'
|
import { parseProjectSettings } from 'lang/wasm'
|
||||||
import { err, reportRejection } from './trap'
|
import { err, reportRejection } from './trap'
|
||||||
import { projectConfigurationToSettingsPayload } from './settings/settingsUtils'
|
import { projectConfigurationToSettingsPayload } from './settings/settingsUtils'
|
||||||
|
import { copyFileShareLink } from './links'
|
||||||
|
import { IndexLoaderData } from './types'
|
||||||
|
|
||||||
interface OnSubmitProps {
|
interface OnSubmitProps {
|
||||||
sampleName: string
|
sampleName: string
|
||||||
@ -15,10 +17,21 @@ interface OnSubmitProps {
|
|||||||
method: 'overwrite' | 'newFile'
|
method: 'overwrite' | 'newFile'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function kclCommands(
|
interface KclCommandConfig {
|
||||||
onSubmit: (p: OnSubmitProps) => Promise<void>,
|
// TODO: find a different approach that doesn't require
|
||||||
|
// special props for a single command
|
||||||
|
specialPropsForSampleCommand: {
|
||||||
|
onSubmit: (p: OnSubmitProps) => Promise<void>
|
||||||
providedOptions: CommandArgumentOption<string>[]
|
providedOptions: CommandArgumentOption<string>[]
|
||||||
): Command[] {
|
}
|
||||||
|
projectData: IndexLoaderData
|
||||||
|
authToken: string
|
||||||
|
settings: {
|
||||||
|
defaultUnit: UnitLength_type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kclCommands(commandProps: KclCommandConfig): Command[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: 'format-code',
|
name: 'format-code',
|
||||||
@ -107,7 +120,9 @@ export function kclCommands(
|
|||||||
)
|
)
|
||||||
.then((props) => {
|
.then((props) => {
|
||||||
if (props?.code) {
|
if (props?.code) {
|
||||||
onSubmit(props).catch(reportError)
|
commandProps.specialPropsForSampleCommand
|
||||||
|
.onSubmit(props)
|
||||||
|
.catch(reportError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(reportError)
|
.catch(reportError)
|
||||||
@ -149,9 +164,25 @@ export function kclCommands(
|
|||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
},
|
},
|
||||||
options: providedOptions,
|
options: commandProps.specialPropsForSampleCommand.providedOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// name: 'share-file-link',
|
||||||
|
// displayName: 'Share file',
|
||||||
|
// description: 'Create a link that contains a copy of the current file.',
|
||||||
|
// groupId: 'code',
|
||||||
|
// needsReview: false,
|
||||||
|
// icon: 'link',
|
||||||
|
// onSubmit: () => {
|
||||||
|
// copyFileShareLink({
|
||||||
|
// token: commandProps.authToken,
|
||||||
|
// code: codeManager.code,
|
||||||
|
// name: commandProps.projectData.project?.name || '',
|
||||||
|
// units: commandProps.settings.defaultUnit,
|
||||||
|
// }).catch(reportRejection)
|
||||||
|
// },
|
||||||
|
// },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
16
src/lib/links.test.ts
Normal file
16
src/lib/links.test.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { createCreateFileUrl } from './links'
|
||||||
|
|
||||||
|
describe(`link creation tests`, () => {
|
||||||
|
test(`createCreateFileUrl happy path`, 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=true&name=test&units=mm&code=${expectedEncodedCode}&ask-open-desktop=true`
|
||||||
|
|
||||||
|
const result = createCreateFileUrl({ code, name, units })
|
||||||
|
expect(result.toString()).toBe(expectedLink)
|
||||||
|
})
|
||||||
|
})
|
100
src/lib/links.ts
Normal file
100
src/lib/links.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
|
||||||
|
import {
|
||||||
|
ASK_TO_OPEN_QUERY_PARAM,
|
||||||
|
CREATE_FILE_URL_PARAM,
|
||||||
|
PROD_APP_URL,
|
||||||
|
} from './constants'
|
||||||
|
import { stringToBase64 } from './base64'
|
||||||
|
import { DEV, VITE_KC_API_BASE_URL } from 'env'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { err } from './trap'
|
||||||
|
export interface FileLinkParams {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
units: UnitLength_type
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyFileShareLink(
|
||||||
|
args: FileLinkParams & { token: string }
|
||||||
|
) {
|
||||||
|
const token = args.token
|
||||||
|
if (!token) {
|
||||||
|
toast.error('You need to be signed in to share a file.', {
|
||||||
|
duration: 5000,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const shareUrl = createCreateFileUrl(args)
|
||||||
|
const shortlink = await createShortlink(token, shareUrl.toString())
|
||||||
|
|
||||||
|
if (err(shortlink)) {
|
||||||
|
toast.error(shortlink.message, {
|
||||||
|
duration: 5000,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await globalThis.navigator.clipboard.writeText(shortlink.url)
|
||||||
|
toast.success(
|
||||||
|
'Link copied to clipboard. Anyone who clicks this link will get a copy of this file. Share carefully!',
|
||||||
|
{
|
||||||
|
duration: 5000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a URL with the necessary query parameters to trigger
|
||||||
|
* the "Import file from URL" command in the app.
|
||||||
|
*
|
||||||
|
* With the additional step of asking the user if they want to
|
||||||
|
* open the URL in the desktop app.
|
||||||
|
*/
|
||||||
|
export function createCreateFileUrl({ code, name, units }: FileLinkParams) {
|
||||||
|
// Use the dev server if we are in development mode
|
||||||
|
let origin = DEV ? 'http://localhost:3000' : PROD_APP_URL
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
[CREATE_FILE_URL_PARAM]: String(true),
|
||||||
|
name,
|
||||||
|
units,
|
||||||
|
code: stringToBase64(code),
|
||||||
|
[ASK_TO_OPEN_QUERY_PARAM]: String(true),
|
||||||
|
})
|
||||||
|
const createFileUrl = new URL(`?${searchParams.toString()}`, origin)
|
||||||
|
|
||||||
|
return createFileUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a file's code, name, and units, creates shareable link to the
|
||||||
|
* web app with a query parameter that triggers a modal to "open in desktop app".
|
||||||
|
* That modal is defined in the `OpenInDesktopAppHandler` component.
|
||||||
|
* TODO: update the return type to use TS library after its updated
|
||||||
|
*/
|
||||||
|
export async function createShortlink(
|
||||||
|
token: string,
|
||||||
|
url: string
|
||||||
|
): Promise<Error | { key: string; url: string }> {
|
||||||
|
/**
|
||||||
|
* We don't use our `withBaseURL` function here because
|
||||||
|
* there is no URL shortener service in the dev API.
|
||||||
|
*/
|
||||||
|
const response = await fetch(`${VITE_KC_API_BASE_URL}/user/shortlinks`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
url,
|
||||||
|
// In future we can support org-scoped and password-protected shortlinks here
|
||||||
|
// https://zoo.dev/docs/api/shortlinks/create-a-shortlink-for-a-user?lang=typescript
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
return new Error(`Failed to create shortlink: ${error.message}`)
|
||||||
|
} else {
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
}
|
@ -114,7 +114,7 @@ export const fileLoader: LoaderFunction = async (
|
|||||||
return redirect(
|
return redirect(
|
||||||
`${PATHS.FILE}/${encodeURIComponent(
|
`${PATHS.FILE}/${encodeURIComponent(
|
||||||
isDesktop() ? fallbackFile : params.id + '/' + PROJECT_ENTRYPOINT
|
isDesktop() ? fallbackFile : params.id + '/' + PROJECT_ENTRYPOINT
|
||||||
)}`
|
)}${new URL(routerData.request.url).search || ''}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,11 +188,14 @@ export const fileLoader: LoaderFunction = async (
|
|||||||
|
|
||||||
// Loads the settings and by extension the projects in the default directory
|
// 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
|
// and returns them to the Home route, along with any errors that occurred
|
||||||
export const homeLoader: LoaderFunction = async (): Promise<
|
export const homeLoader: LoaderFunction = async ({
|
||||||
HomeLoaderData | Response
|
request,
|
||||||
> => {
|
}): Promise<HomeLoaderData | Response> => {
|
||||||
|
const url = new URL(request.url)
|
||||||
if (!isDesktop()) {
|
if (!isDesktop()) {
|
||||||
return redirect(PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME)
|
return redirect(
|
||||||
|
PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME + (url.search || '')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,10 @@ export const projectsMachine = setup({
|
|||||||
type: 'Delete project'
|
type: 'Delete project'
|
||||||
data: ProjectsCommandSchema['Delete project']
|
data: ProjectsCommandSchema['Delete project']
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'Import file from URL'
|
||||||
|
data: ProjectsCommandSchema['Import file from URL']
|
||||||
|
}
|
||||||
| { type: 'navigate'; data: { name: string } }
|
| { type: 'navigate'; data: { name: string } }
|
||||||
| {
|
| {
|
||||||
type: 'xstate.done.actor.read-projects'
|
type: 'xstate.done.actor.read-projects'
|
||||||
@ -42,6 +46,10 @@ export const projectsMachine = setup({
|
|||||||
type: 'xstate.done.actor.rename-project'
|
type: 'xstate.done.actor.rename-project'
|
||||||
output: { message: string; oldName: string; newName: string }
|
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 } },
|
| { type: 'assign'; data: { [key: string]: any } },
|
||||||
input: {} as {
|
input: {} as {
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
@ -60,6 +68,7 @@ export const projectsMachine = setup({
|
|||||||
toastError: () => {},
|
toastError: () => {},
|
||||||
navigateToProject: () => {},
|
navigateToProject: () => {},
|
||||||
navigateToProjectIfNeeded: () => {},
|
navigateToProjectIfNeeded: () => {},
|
||||||
|
navigateToFile: () => {},
|
||||||
},
|
},
|
||||||
actors: {
|
actors: {
|
||||||
readProjects: fromPromise(() => Promise.resolve([] as Project[])),
|
readProjects: fromPromise(() => Promise.resolve([] as Project[])),
|
||||||
@ -90,12 +99,22 @@ export const projectsMachine = setup({
|
|||||||
name: '',
|
name: '',
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
createFile: fromPromise(
|
||||||
|
(_: {
|
||||||
|
input: ProjectsCommandSchema['Import file from URL'] & {
|
||||||
|
projects: Project[]
|
||||||
|
}
|
||||||
|
}) => Promise.resolve({ message: '', projectName: '', fileName: '' })
|
||||||
|
),
|
||||||
},
|
},
|
||||||
guards: {
|
guards: {
|
||||||
'Has at least 1 project': () => false,
|
'Has at least 1 project': ({ event }) => {
|
||||||
|
if (event.type !== 'xstate.done.actor.read-projects') return false
|
||||||
|
return event.output.length ? event.output.length >= 1 : false
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}).createMachine({
|
}).createMachine({
|
||||||
/** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAMS6yzFSkDaADALqKgAOqtALsaqXYgAHogAsAJgA0IAJ6IAjBIkA2AHQAOCUw0qNkjQE4xYjQF8zMtJhwES5NcmpZSqLBwBOqAFZh8PWAoAJTBcCHcvX39YZjYkEC5efkF40QQFFQBmAHY1Qwz9QwBWU0NMrRl5BGyxBTUVJlVM01rtBXNLEGtsPCIyMEdnVwifPwCKAGEPUJ5sT1H-WKFE4j4BITSMzMzNIsyJDSZMhSYmFWzKxAkTNSYxIuU9zOKT7IsrDB67fsHYEajxiEwv8xjFWMtuKtkhsrpJboZsioincFMdTIjLggVGJ1GImMVMg0JISjEV3l1PrY+g4nH95gDAiFSLgbPSxkt4is1ilQGlrhJ4YjkbU0RoMXJEIYkWoikV8hJinjdOdyd0qfYBrSQdFJtNcLNtTwOZxIdyYQh+YKkSjReKqqYBQoEQpsgiSi9MqrKb0Nb9DYEACJgAA2YANbMW4M5puhqVhAvxQptCnRKkxCleuw0Gm2+2aRVRXpsPp+Woj4wA8hwwKRDcaEjH1nGLXDE9aRSmxWmJVipWocmdsbL8kxC501SWHFMZmQoIaKBABAMyAA3VAAawG+D1swAtOX61zY7zENlEWoMkoatcdPoipjCWIZRIirozsPiYYi19qQNp-rZ3nMAPC8Dw1A4YN9QAM1QDx0DUbcZjAfdInZKMTSSJsT2qc9Lwka8lTvTFTB2WUMiyQwc3yPRv3VH4mRZQDywXJc1FXDcBmmZlMBQhYjXQhtMJ5ERT1wlQr0kQj7kxKiZTxM5s2zbQEVoycBgY9AmNQ-wKGA0DwMgngYLgtQuJZZCDwEo8sJEnD1Dwgjb2kntig0GUkWVfZDEMfFVO+Bwg1DPhSDnZjFwcdjNzUCAQzDCztP4uIMKhGy0jPezxPwySnPvHsTjlPIhyOHRsnwlM-N-NRArDLS+N0kDYIM6DYPgmKgvivjD0bYS+VbBF21RTs7UUUcimfRpXyYRF8LFCrfSBCBaoZFiItINcor1CBeIZLqhPNdKL0yxzs2cqoNDqdpSo0EoSoLOb6NCRaQv9FblzWjjTMe7bQQYBQksElKesUMRjFubRMllCHsm2a5MVldRDBfFpmj0IpxPuhwFqW0F6v0iDmpMzbvuiXbAfNFNQcaI5IaKaH9jETFsUMNRVDxOU5TxBULE6VwYvgeIJ38sAIT25td27KpdzG7yZeho5slHVmMc1IY3HLfnkrNZt2hOW5smRCQM2uhQHkZ6VtkRBFnmu0qFGVv11ZFsnm0kdN8QFJVjlUPZ9co+3-2C0KEqdrXsJMJ8ryc86iUyYiCvxFNzldb3jHtjTsf8EPj1ssQchZlR8jR5EmDRrIGZcuF7maF1aZtslx29IWqtiwPDSz1LxDxepnlOfYDghp03eyOpngzXuYdHNPHozgJ26B9IXR2cTvP0BVkSRXKqgLtyq8OfQcXOwlubMIA */
|
/** @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',
|
id: 'Home machine',
|
||||||
|
|
||||||
initial: 'Reading projects',
|
initial: 'Reading projects',
|
||||||
@ -111,6 +130,8 @@ export const projectsMachine = setup({
|
|||||||
})),
|
})),
|
||||||
target: '.Reading projects',
|
target: '.Reading projects',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'Import file from URL': '.Creating file',
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
'Has no projects': {
|
'Has no projects': {
|
||||||
@ -155,7 +176,10 @@ export const projectsMachine = setup({
|
|||||||
id: 'create-project',
|
id: 'create-project',
|
||||||
src: 'createProject',
|
src: 'createProject',
|
||||||
input: ({ event, context }) => {
|
input: ({ event, context }) => {
|
||||||
if (event.type !== 'Create project') {
|
if (
|
||||||
|
event.type !== 'Create project' &&
|
||||||
|
event.type !== 'Import file from URL'
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
projects: context.projects,
|
projects: context.projects,
|
||||||
@ -272,5 +296,39 @@ export const projectsMachine = setup({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
37
src/main.ts
37
src/main.ts
@ -21,6 +21,7 @@ import minimist from 'minimist'
|
|||||||
import getCurrentProjectFile from 'lib/getCurrentProjectFile'
|
import getCurrentProjectFile from 'lib/getCurrentProjectFile'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import { reportRejection } from 'lib/trap'
|
import { reportRejection } from 'lib/trap'
|
||||||
|
import { ZOO_STUDIO_PROTOCOL } from 'lib/constants'
|
||||||
import argvFromYargs from './commandLineArgs'
|
import argvFromYargs from './commandLineArgs'
|
||||||
|
|
||||||
import * as packageJSON from '../package.json'
|
import * as packageJSON from '../package.json'
|
||||||
@ -48,9 +49,7 @@ process.env.VITE_KC_SITE_BASE_URL ??= 'https://zoo.dev'
|
|||||||
process.env.VITE_KC_SKIP_AUTH ??= 'false'
|
process.env.VITE_KC_SKIP_AUTH ??= 'false'
|
||||||
process.env.VITE_KC_CONNECTION_TIMEOUT_MS ??= '15000'
|
process.env.VITE_KC_CONNECTION_TIMEOUT_MS ??= '15000'
|
||||||
|
|
||||||
const ZOO_STUDIO_PROTOCOL = 'zoo-studio'
|
/// Register our application to handle all "zoo-studio:" protocols.
|
||||||
|
|
||||||
/// Register our application to handle all "electron-fiddle://" protocols.
|
|
||||||
if (process.defaultApp) {
|
if (process.defaultApp) {
|
||||||
if (process.argv.length >= 2) {
|
if (process.argv.length >= 2) {
|
||||||
app.setAsDefaultProtocolClient(ZOO_STUDIO_PROTOCOL, process.execPath, [
|
app.setAsDefaultProtocolClient(ZOO_STUDIO_PROTOCOL, process.execPath, [
|
||||||
@ -65,7 +64,7 @@ if (process.defaultApp) {
|
|||||||
// Must be done before ready event.
|
// Must be done before ready event.
|
||||||
registerStartupListeners()
|
registerStartupListeners()
|
||||||
|
|
||||||
const createWindow = (filePath?: string, reuse?: boolean): BrowserWindow => {
|
const createWindow = (pathToOpen?: string, reuse?: boolean): BrowserWindow => {
|
||||||
let newWindow
|
let newWindow
|
||||||
|
|
||||||
if (reuse) {
|
if (reuse) {
|
||||||
@ -90,11 +89,34 @@ const createWindow = (filePath?: string, reuse?: boolean): BrowserWindow => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pathIsCustomProtocolLink =
|
||||||
|
pathToOpen?.startsWith(ZOO_STUDIO_PROTOCOL) ?? false
|
||||||
|
|
||||||
// and load the index.html of the app.
|
// and load the index.html of the app.
|
||||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||||
newWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL).catch(reportRejection)
|
const filteredPath = pathToOpen
|
||||||
|
? decodeURI(pathToOpen.replace(ZOO_STUDIO_PROTOCOL, ''))
|
||||||
|
: ''
|
||||||
|
const fullHashBasedUrl = `${MAIN_WINDOW_VITE_DEV_SERVER_URL}/#/${filteredPath}`
|
||||||
|
newWindow.loadURL(fullHashBasedUrl).catch(reportRejection)
|
||||||
} else {
|
} else {
|
||||||
getProjectPathAtStartup(filePath)
|
if (pathIsCustomProtocolLink && pathToOpen) {
|
||||||
|
// We're trying to open a custom protocol link
|
||||||
|
const filteredPath = pathToOpen
|
||||||
|
? decodeURI(pathToOpen.replace(ZOO_STUDIO_PROTOCOL, ''))
|
||||||
|
: ''
|
||||||
|
const startIndex = path.join(
|
||||||
|
__dirname,
|
||||||
|
`../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`
|
||||||
|
)
|
||||||
|
newWindow
|
||||||
|
.loadFile(startIndex, {
|
||||||
|
hash: filteredPath,
|
||||||
|
})
|
||||||
|
.catch(reportRejection)
|
||||||
|
} else {
|
||||||
|
// otherwise we're trying to open a local file from the command line
|
||||||
|
getProjectPathAtStartup(pathToOpen)
|
||||||
.then(async (projectPath) => {
|
.then(async (projectPath) => {
|
||||||
const startIndex = path.join(
|
const startIndex = path.join(
|
||||||
__dirname,
|
__dirname,
|
||||||
@ -106,8 +128,6 @@ const createWindow = (filePath?: string, reuse?: boolean): BrowserWindow => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Loading file', projectPath)
|
|
||||||
|
|
||||||
const fullUrl = `/file/${encodeURIComponent(projectPath)}`
|
const fullUrl = `/file/${encodeURIComponent(projectPath)}`
|
||||||
console.log('Full URL', fullUrl)
|
console.log('Full URL', fullUrl)
|
||||||
|
|
||||||
@ -117,6 +137,7 @@ const createWindow = (filePath?: string, reuse?: boolean): BrowserWindow => {
|
|||||||
})
|
})
|
||||||
.catch(reportRejection)
|
.catch(reportRejection)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Open the DevTools.
|
// Open the DevTools.
|
||||||
// mainWindow.webContents.openDevTools()
|
// mainWindow.webContents.openDevTools()
|
||||||
|
@ -25,6 +25,7 @@ import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
|
|||||||
import { useProjectsLoader } from 'hooks/useProjectsLoader'
|
import { useProjectsLoader } from 'hooks/useProjectsLoader'
|
||||||
import { useProjectsContext } from 'hooks/useProjectsContext'
|
import { useProjectsContext } from 'hooks/useProjectsContext'
|
||||||
import { commandBarActor } from 'machines/commandBarMachine'
|
import { commandBarActor } from 'machines/commandBarMachine'
|
||||||
|
import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher'
|
||||||
|
|
||||||
// This route only opens in the desktop context for now,
|
// This route only opens in the desktop context for now,
|
||||||
// as defined in Router.tsx, so we can use the desktop APIs and types.
|
// as defined in Router.tsx, so we can use the desktop APIs and types.
|
||||||
@ -33,6 +34,18 @@ const Home = () => {
|
|||||||
const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0)
|
const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0)
|
||||||
const { projectsDir } = useProjectsLoader([projectsLoaderTrigger])
|
const { projectsDir } = useProjectsLoader([projectsLoaderTrigger])
|
||||||
|
|
||||||
|
// Keep a lookout for a URL query string that invokes the 'import file from URL' command
|
||||||
|
useCreateFileLinkQuery((argDefaultValues) => {
|
||||||
|
commandBarActor.send({
|
||||||
|
type: 'Find and select command',
|
||||||
|
data: {
|
||||||
|
groupId: 'projects',
|
||||||
|
name: 'Import file from URL',
|
||||||
|
argDefaultValues,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
useRefreshSettings(PATHS.HOME + 'SETTINGS')
|
useRefreshSettings(PATHS.HOME + 'SETTINGS')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const {
|
const {
|
||||||
|
Reference in New Issue
Block a user