Compare commits
	
		
			113 Commits
		
	
	
		
			nightly-v2
			...
			pierremtb/
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 6c78dbd4c8 | |||
| 059593372a | |||
| 1ba8c5af00 | |||
| 410b4e81eb | |||
| 30275d86cc | |||
| 39c40b2cde | |||
| 907102a8fa | |||
| 353eca110e | |||
| fb56820811 | |||
| fb37bb83a8 | |||
| f90811695d | |||
| 5c1dfe0c8e | |||
| f06873a0e2 | |||
| 09025179f9 | |||
| 521a593451 | |||
| 87c4e6c74e | |||
| 82cd106898 | |||
| e14cc4ace3 | |||
| 2a2a31d0ef | |||
| f2669223c5 | |||
| c3bc1fad6d | |||
| 96ff1dd55b | |||
| 82bd04631a | |||
| abec2d6d66 | |||
| 6089b1932a | |||
| 074fd2b5c7 | |||
| b2485b804c | |||
| e753082653 | |||
| 634745bb81 | |||
| e3660c75fc | |||
| ef61d10615 | |||
| c208e16c76 | |||
| 585ca7e80f | |||
| f7bae1d221 | |||
| 339de00e68 | |||
| 4f02e45da3 | |||
| 1908383f0e | |||
| 68204bb23d | |||
| 5438a987ab | |||
| fa3f934948 | |||
| 08e714080e | |||
| df01c233e4 | |||
| b30a37a0b3 | |||
| 82aefec34d | |||
| 679b65f643 | |||
| d64270d494 | |||
| c06b2b4029 | |||
| 8b8a2bc4e2 | |||
| af702ae1b2 | |||
| 83e72dafa3 | |||
| e417e60053 | |||
| ebc6b6460d | |||
| 91f0cfe467 | |||
| a2ff0aeceb | |||
| f05acf92cc | |||
| 670faac1e8 | |||
| ca09224c92 | |||
| 5cbd11cec8 | |||
| 28eb99f655 | |||
| c29be6e341 | |||
| 2193d563c5 | |||
| 570d159c29 | |||
| 713886b274 | |||
| 2aa4a01cb7 | |||
| 2048c26b9f | |||
| cbb8df5904 | |||
| bb67a9e9cf | |||
| b84d5951b7 | |||
| 1e5954e5ed | |||
| d58a147b7d | |||
| 96b06247a4 | |||
| 36d49b1bcb | |||
| 4748c2d1e0 | |||
| 698ce671df | |||
| a2330a0dbc | |||
| c882e34ea9 | |||
| 1ce3d8ccd0 | |||
| 15bedd56f4 | |||
| 746ebf80d1 | |||
| 02b249bd31 | |||
| 524fcb03ad | |||
| 3a9e0c72a8 | |||
| 5dc983ad7b | |||
| 81411033d7 | |||
| 30a24c8ae6 | |||
| 403cee5f16 | |||
| 14eeafb70a | |||
| f4ecd16ffa | |||
| 48380be480 | |||
| 80e32b337f | |||
| 9378d9862b | |||
| 1f515b712b | |||
| 372f2eebcc | |||
| e22a9edde8 | |||
| 75e3f843eb | |||
| f0136a5939 | |||
| 3d2e48732c | |||
| 7545b61b49 | |||
| d1be6d7b64 | |||
| 8ab24ceee7 | |||
| f163870b86 | |||
| 3fc707a2a4 | |||
| 238163d7db | |||
| bfccb79c1c | |||
| fe6d1f8119 | |||
| f496d94258 | |||
| 5d8f3f988a | |||
| 4f06524776 | |||
| d7fe827a9e | |||
| 049e487ac4 | |||
| 5bd89047b2 | |||
| 5822321f35 | |||
| 401dcf8152 | 
							
								
								
									
										1
									
								
								.github/workflows/build-apps.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/build-apps.yml
									
									
									
									
										vendored
									
									
								
							| @ -5,6 +5,7 @@ on: | ||||
|   push: | ||||
|     branches: | ||||
|       - main | ||||
|       - pierremtb/4088/create-file-url | ||||
|     tags: | ||||
|       - 'v[0-9]+.[0-9]+.[0-9]+' | ||||
|   schedule: | ||||
|  | ||||
| @ -1,7 +1,8 @@ | ||||
| import { test, expect } from './zoo-test' | ||||
|  | ||||
| import { getUtils } from './test-utils' | ||||
| import * as fsp from 'fs/promises' | ||||
| import { executorInputPath, getUtils } from './test-utils' | ||||
| import { KCL_DEFAULT_LENGTH } from 'lib/constants' | ||||
| import path from 'path' | ||||
|  | ||||
| test.describe('Command bar tests', () => { | ||||
|   test('Extrude from command bar selects extrude line after', async ({ | ||||
| @ -305,4 +306,132 @@ test.describe('Command bar tests', () => { | ||||
|     await arcToolCommand.click() | ||||
|     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) => { | ||||
|     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) | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										16
									
								
								src/App.tsx
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								src/App.tsx
									
									
									
									
									
								
							| @ -22,6 +22,8 @@ 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' | ||||
| import { maybeWriteToDisk } from 'lib/telemetry' | ||||
| maybeWriteToDisk() | ||||
|   .then(() => {}) | ||||
| @ -29,6 +31,20 @@ maybeWriteToDisk() | ||||
|  | ||||
| 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() | ||||
|  | ||||
| @ -35,7 +35,7 @@ import { CommandBarProvider } from 'components/CommandBar/CommandBarProvider' | ||||
| import SettingsAuthProvider from 'components/SettingsAuthProvider' | ||||
| import LspProvider from 'components/LspProvider' | ||||
| 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 { codeManager, engineCommandManager } from 'lib/singletons' | ||||
| import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' | ||||
| @ -47,6 +47,7 @@ import { AppStateProvider } from 'AppState' | ||||
| import { reportRejection } from 'lib/trap' | ||||
| import { RouteProvider } from 'components/RouteProvider' | ||||
| import { ProjectsContextProvider } from 'components/ProjectsContextProvider' | ||||
| import { OpenInDesktopAppHandler } from 'components/OpenInDesktopAppHandler' | ||||
|  | ||||
| const createRouter = isDesktop() ? createHashRouter : createBrowserRouter | ||||
|  | ||||
| @ -58,6 +59,7 @@ 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: ( | ||||
|       <OpenInDesktopAppHandler> | ||||
|         <CommandBarProvider> | ||||
|           <RouteProvider> | ||||
|             <SettingsAuthProvider> | ||||
| @ -75,16 +77,26 @@ const router = createRouter([ | ||||
|             </SettingsAuthProvider> | ||||
|           </RouteProvider> | ||||
|         </CommandBarProvider> | ||||
|       </OpenInDesktopAppHandler> | ||||
|     ), | ||||
|     errorElement: <ErrorPage />, | ||||
|     children: [ | ||||
|       { | ||||
|         path: PATHS.INDEX, | ||||
|         loader: async () => { | ||||
|         loader: async ({ request }) => { | ||||
|           const onDesktop = isDesktop() | ||||
|           return onDesktop | ||||
|             ? redirect(PATHS.HOME) | ||||
|             : redirect(PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME) | ||||
|           const url = new URL(request.url) | ||||
|           if (onDesktop) { | ||||
|             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 | ||||
|             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" | ||||
|             data-testid="cmd-bar-arg-name" | ||||
|           > | ||||
|             {argName} | ||||
|           </label> | ||||
|  | ||||
| @ -48,8 +48,9 @@ export const FileMachineProvider = ({ | ||||
| }) => { | ||||
|   const navigate = useNavigate() | ||||
|   const { commandBarSend } = useCommandsContext() | ||||
|   const { settings } = useSettingsAuthContext() | ||||
|   const { project, file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData | ||||
|   const { settings, auth } = useSettingsAuthContext() | ||||
|   const projectData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData | ||||
|   const { project, file } = projectData | ||||
|   const [kclSamples, setKclSamples] = React.useState<KclSamplesManifestItem[]>( | ||||
|     [] | ||||
|   ) | ||||
| @ -296,8 +297,14 @@ export const FileMachineProvider = ({ | ||||
|  | ||||
|   const kclCommandMemo = useMemo( | ||||
|     () => | ||||
|       kclCommands( | ||||
|         async (data) => { | ||||
|       kclCommands({ | ||||
|         authToken: auth?.context?.token ?? '', | ||||
|         projectData, | ||||
|         settings: { | ||||
|           defaultUnit: settings?.context?.modeling.defaultUnit.current ?? 'mm', | ||||
|         }, | ||||
|         specialPropsForSampleCommand: { | ||||
|           onSubmit: async (data) => { | ||||
|             if (data.method === 'overwrite') { | ||||
|               codeManager.updateCodeStateEditor(data.code) | ||||
|               await kclManager.executeCode(true) | ||||
| @ -325,11 +332,12 @@ export const FileMachineProvider = ({ | ||||
|               }) | ||||
|             } | ||||
|           }, | ||||
|         kclSamples.map((sample) => ({ | ||||
|           providedOptions: kclSamples.map((sample) => ({ | ||||
|             value: sample.pathFromProjectDirectoryToFirstFile, | ||||
|             name: sample.title, | ||||
|         })) | ||||
|       ).filter( | ||||
|           })), | ||||
|         }, | ||||
|       }).filter( | ||||
|         (command) => kclSamples.length || command.name !== 'open-kcl-example' | ||||
|       ), | ||||
|     [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 | ||||
|   ) | ||||
| } | ||||
| @ -10,11 +10,13 @@ import { APP_NAME } from 'lib/constants' | ||||
| import { useCommandsContext } from 'hooks/useCommandsContext' | ||||
| import { CustomIcon } from './CustomIcon' | ||||
| import { useLspContext } from './LspProvider' | ||||
| import { engineCommandManager, kclManager } from 'lib/singletons' | ||||
| import { codeManager, engineCommandManager, kclManager } from 'lib/singletons' | ||||
| import { MachineManagerContext } from 'components/MachineManagerProvider' | ||||
| import usePlatform from 'hooks/usePlatform' | ||||
| import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' | ||||
| import Tooltip from './Tooltip' | ||||
| import { copyFileShareLink } from 'lib/links' | ||||
| import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' | ||||
|  | ||||
| const ProjectSidebarMenu = ({ | ||||
|   project, | ||||
| @ -95,6 +97,7 @@ function ProjectMenuPopover({ | ||||
|   const location = useLocation() | ||||
|   const navigate = useNavigate() | ||||
|   const filePath = useAbsoluteFilePath() | ||||
|   const { settings, auth } = useSettingsAuthContext() | ||||
|   const machineManager = useContext(MachineManagerContext) | ||||
|  | ||||
|   const { commandBarState, commandBarSend } = useCommandsContext() | ||||
| @ -155,7 +158,6 @@ function ProjectMenuPopover({ | ||||
|               data: exportCommandInfo, | ||||
|             }), | ||||
|         }, | ||||
|         'break', | ||||
|         { | ||||
|           id: 'make', | ||||
|           Element: 'button', | ||||
| @ -181,6 +183,19 @@ function ProjectMenuPopover({ | ||||
|             }) | ||||
|           }, | ||||
|         }, | ||||
|         { | ||||
|           id: 'share-link', | ||||
|           Element: 'button', | ||||
|           children: 'Share link to file', | ||||
|           onClick: async () => { | ||||
|             await copyFileShareLink({ | ||||
|               token: auth?.context.token || '', | ||||
|               code: codeManager.code, | ||||
|               name: project?.name || '', | ||||
|               units: settings.context.modeling.defaultUnit.current, | ||||
|             }) | ||||
|           }, | ||||
|         }, | ||||
|         'break', | ||||
|         { | ||||
|           id: 'go-home', | ||||
|  | ||||
| @ -3,11 +3,11 @@ import { useCommandsContext } from 'hooks/useCommandsContext' | ||||
| import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher' | ||||
| import { useProjectsLoader } from 'hooks/useProjectsLoader' | ||||
| import { projectsMachine } from 'machines/projectsMachine' | ||||
| import { createContext, useEffect, useState } from 'react' | ||||
| import { 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 } from 'react-router-dom' | ||||
| import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' | ||||
| import { PATHS } from 'lib/paths' | ||||
| import { | ||||
|   createNewProjectDirectory, | ||||
| @ -19,11 +19,27 @@ import { | ||||
|   interpolateProjectNameWithIndex, | ||||
|   doesProjectNameNeedInterpolated, | ||||
|   getUniqueProjectName, | ||||
|   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<T extends AnyStateMachine> = { | ||||
|   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 [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 ( | ||||
|     <ProjectsMachineContext.Provider | ||||
|       value={{ | ||||
|         state: undefined, | ||||
|         send: () => {}, | ||||
|         state, | ||||
|         send, | ||||
|       }} | ||||
|     > | ||||
|       {children} | ||||
| @ -73,19 +187,22 @@ const ProjectsContextDesktop = ({ | ||||
| }) => { | ||||
|   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 }, | ||||
|   } = useSettingsAuthContext() | ||||
|  | ||||
|   useEffect(() => { | ||||
|     console.log( | ||||
|       'project directory changed', | ||||
|       settings.app.projectDirectory.current | ||||
|     ) | ||||
|   }, [settings.app.projectDirectory.current]) | ||||
|  | ||||
|   const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0) | ||||
|   const { projectPaths, projectsDir } = useProjectsLoader([ | ||||
|     projectsLoaderTrigger, | ||||
| @ -169,6 +286,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 }) => | ||||
|           toast.success( | ||||
|             ('data' in event && typeof event.data === 'string' && event.data) || | ||||
| @ -218,8 +360,6 @@ const ProjectsContextDesktop = ({ | ||||
|             name = interpolateProjectNameWithIndex(name, nextIndex) | ||||
|           } | ||||
|  | ||||
|           console.log('from Project') | ||||
|  | ||||
|           await renameProjectDirectory( | ||||
|             window.electron.path.join(defaultDirectory, oldName), | ||||
|             name | ||||
| @ -242,14 +382,83 @@ const ProjectsContextDesktop = ({ | ||||
|             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: { | ||||
|         '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 | ||||
|               modeling: { | ||||
|                 base_unit: input.units, | ||||
|               }, | ||||
|             }, | ||||
|           } | ||||
|  | ||||
|           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: { | ||||
| @ -271,6 +480,7 @@ const ProjectsContextDesktop = ({ | ||||
|     state, | ||||
|     commandBarConfig: projectsCommandBarConfig, | ||||
|     actor, | ||||
|     onCancel: clearImportSearchParams, | ||||
|   }) | ||||
|  | ||||
|   return ( | ||||
|  | ||||
| @ -6,5 +6,6 @@ export const useCommandsContext = () => { | ||||
|   return { | ||||
|     commandBarSend: commandBarActor.send, | ||||
|     commandBarState, | ||||
|     commandBarActor, | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										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(() => { | ||||
|     // Useless on web, until we get fake filesystems over there. | ||||
|     if (!isDesktop) return | ||||
|     if (!isDesktop()) 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 { StateMachineCommandSetConfig } from 'lib/commandTypes' | ||||
| import { isDesktop } from 'lib/isDesktop' | ||||
| import { baseUnitLabels, baseUnitsUnion } from 'lib/settings/settingsTypes' | ||||
| import { projectsMachine } from 'machines/projectsMachine' | ||||
|  | ||||
| export type ProjectsCommandSchema = { | ||||
| @ -17,6 +20,13 @@ export type ProjectsCommandSchema = { | ||||
|     oldName: string | ||||
|     newName: string | ||||
|   } | ||||
|   'Import file from URL': { | ||||
|     name: string | ||||
|     code?: string | ||||
|     units: UnitLength_type | ||||
|     method: 'newProject' | 'existingProject' | ||||
|     projectName?: string | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const projectsCommandBarConfig: StateMachineCommandSetConfig< | ||||
| @ -26,6 +36,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig< | ||||
|   'Open project': { | ||||
|     icon: 'arrowRight', | ||||
|     description: 'Open a project', | ||||
|     status: isDesktop() ? 'active' : 'inactive', | ||||
|     args: { | ||||
|       name: { | ||||
|         inputType: 'options', | ||||
| @ -42,6 +53,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig< | ||||
|   'Create project': { | ||||
|     icon: 'folderPlus', | ||||
|     description: 'Create a project', | ||||
|     status: isDesktop() ? 'active' : 'inactive', | ||||
|     args: { | ||||
|       name: { | ||||
|         inputType: 'string', | ||||
| @ -53,6 +65,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig< | ||||
|   'Delete project': { | ||||
|     icon: 'close', | ||||
|     description: 'Delete a project', | ||||
|     status: isDesktop() ? 'active' : 'inactive', | ||||
|     needsReview: true, | ||||
|     reviewMessage: ({ argumentsToSubmit }) => | ||||
|       CommandBarOverwriteWarning({ | ||||
| @ -75,6 +88,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig< | ||||
|     icon: 'folder', | ||||
|     description: 'Rename a project', | ||||
|     needsReview: true, | ||||
|     status: isDesktop() ? 'active' : 'inactive', | ||||
|     args: { | ||||
|       oldName: { | ||||
|         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 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' | ||||
| @ -110,6 +111,9 @@ 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' | ||||
|  | ||||
| @ -139,3 +143,12 @@ export const VIEW_NAMES_SEMANTIC = { | ||||
| } as const | ||||
| /** The modeling sidebar buttons' IDs get a suffix to prevent collisions */ | ||||
| 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 { Command, CommandArgumentOption } from './commandTypes' | ||||
| import { kclManager } from './singletons' | ||||
| import { codeManager, kclManager } from './singletons' | ||||
| import { isDesktop } from './isDesktop' | ||||
| import { FILE_EXT, PROJECT_SETTINGS_FILE_NAME } from './constants' | ||||
| import { UnitLength_type } from '@kittycad/lib/dist/types/src/models' | ||||
| import { parseProjectSettings } from 'lang/wasm' | ||||
| import { err, reportRejection } from './trap' | ||||
| import { projectConfigurationToSettingsPayload } from './settings/settingsUtils' | ||||
| import { copyFileShareLink } from './links' | ||||
| import { IndexLoaderData } from './types' | ||||
|  | ||||
| interface OnSubmitProps { | ||||
|   sampleName: string | ||||
| @ -15,10 +17,21 @@ interface OnSubmitProps { | ||||
|   method: 'overwrite' | 'newFile' | ||||
| } | ||||
|  | ||||
| export function kclCommands( | ||||
|   onSubmit: (p: OnSubmitProps) => Promise<void>, | ||||
| interface KclCommandConfig { | ||||
|   // TODO: find a different approach that doesn't require | ||||
|   // special props for a single command | ||||
|   specialPropsForSampleCommand: { | ||||
|     onSubmit: (p: OnSubmitProps) => Promise<void> | ||||
|     providedOptions: CommandArgumentOption<string>[] | ||||
| ): Command[] { | ||||
|   } | ||||
|   projectData: IndexLoaderData | ||||
|   authToken: string | ||||
|   settings: { | ||||
|     defaultUnit: UnitLength_type | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function kclCommands(commandProps: KclCommandConfig): Command[] { | ||||
|   return [ | ||||
|     { | ||||
|       name: 'format-code', | ||||
| @ -107,7 +120,9 @@ export function kclCommands( | ||||
|           ) | ||||
|           .then((props) => { | ||||
|             if (props?.code) { | ||||
|               onSubmit(props).catch(reportError) | ||||
|               commandProps.specialPropsForSampleCommand | ||||
|                 .onSubmit(props) | ||||
|                 .catch(reportError) | ||||
|             } | ||||
|           }) | ||||
|           .catch(reportError) | ||||
| @ -149,9 +164,25 @@ export function kclCommands( | ||||
|             } | ||||
|             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( | ||||
|           `${PATHS.FILE}/${encodeURIComponent( | ||||
|             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 | ||||
| // 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<HomeLoaderData | Response> => { | ||||
|   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 {} | ||||
| } | ||||
|  | ||||
| @ -25,6 +25,10 @@ export const projectsMachine = setup({ | ||||
|           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' | ||||
| @ -42,6 +46,10 @@ export const projectsMachine = setup({ | ||||
|           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[] | ||||
| @ -60,6 +68,7 @@ export const projectsMachine = setup({ | ||||
|     toastError: () => {}, | ||||
|     navigateToProject: () => {}, | ||||
|     navigateToProjectIfNeeded: () => {}, | ||||
|     navigateToFile: () => {}, | ||||
|   }, | ||||
|   actors: { | ||||
|     readProjects: fromPromise(() => Promise.resolve([] as Project[])), | ||||
| @ -90,12 +99,22 @@ export const projectsMachine = setup({ | ||||
|           name: '', | ||||
|         }) | ||||
|     ), | ||||
|     createFile: fromPromise( | ||||
|       (_: { | ||||
|         input: ProjectsCommandSchema['Import file from URL'] & { | ||||
|           projects: Project[] | ||||
|         } | ||||
|       }) => Promise.resolve({ message: '', projectName: '', fileName: '' }) | ||||
|     ), | ||||
|   }, | ||||
|   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({ | ||||
|   /** @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', | ||||
|  | ||||
|   initial: 'Reading projects', | ||||
| @ -111,6 +130,8 @@ export const projectsMachine = setup({ | ||||
|       })), | ||||
|       target: '.Reading projects', | ||||
|     }, | ||||
|  | ||||
|     'Import file from URL': '.Creating file', | ||||
|   }, | ||||
|   states: { | ||||
|     'Has no projects': { | ||||
| @ -155,7 +176,10 @@ export const projectsMachine = setup({ | ||||
|         id: 'create-project', | ||||
|         src: 'createProject', | ||||
|         input: ({ event, context }) => { | ||||
|           if (event.type !== 'Create project') { | ||||
|           if ( | ||||
|             event.type !== 'Create project' && | ||||
|             event.type !== 'Import file from URL' | ||||
|           ) { | ||||
|             return { | ||||
|               name: '', | ||||
|               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', | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
| }) | ||||
|  | ||||
							
								
								
									
										43
									
								
								src/main.ts
									
									
									
									
									
								
							
							
						
						
									
										43
									
								
								src/main.ts
									
									
									
									
									
								
							| @ -21,6 +21,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/constants' | ||||
| import argvFromYargs from './commandLineArgs' | ||||
|  | ||||
| import * as packageJSON from '../package.json' | ||||
| @ -42,15 +43,13 @@ if (!process.env.NODE_ENV) | ||||
| dotenv.config({ path: [`.env.${NODE_ENV}.local`, `.env.${NODE_ENV}`] }) | ||||
|  | ||||
| process.env.VITE_KC_API_WS_MODELING_URL ??= | ||||
|   'wss://api.zoo.dev/ws/modeling/commands' | ||||
| process.env.VITE_KC_API_BASE_URL ??= 'https://api.zoo.dev' | ||||
| process.env.VITE_KC_SITE_BASE_URL ??= 'https://zoo.dev' | ||||
|   'wss://api.dev.zoo.dev/ws/modeling/commands' | ||||
| process.env.VITE_KC_API_BASE_URL ??= 'https://api.dev.zoo.dev' | ||||
| process.env.VITE_KC_SITE_BASE_URL ??= 'https://dev.zoo.dev' | ||||
| process.env.VITE_KC_SKIP_AUTH ??= 'false' | ||||
| process.env.VITE_KC_CONNECTION_TIMEOUT_MS ??= '15000' | ||||
|  | ||||
| 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, [ | ||||
| @ -65,7 +64,7 @@ if (process.defaultApp) { | ||||
| // Must be done before ready event. | ||||
| registerStartupListeners() | ||||
|  | ||||
| const createWindow = (filePath?: string, reuse?: boolean): BrowserWindow => { | ||||
| const createWindow = (pathToOpen?: string, reuse?: boolean): BrowserWindow => { | ||||
|   let newWindow | ||||
|  | ||||
|   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. | ||||
|   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 { | ||||
|     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) => { | ||||
|           const startIndex = path.join( | ||||
|             __dirname, | ||||
| @ -106,8 +128,6 @@ const createWindow = (filePath?: string, reuse?: boolean): BrowserWindow => { | ||||
|             return | ||||
|           } | ||||
|  | ||||
|         console.log('Loading file', projectPath) | ||||
|  | ||||
|           const fullUrl = `/file/${encodeURIComponent(projectPath)}` | ||||
|           console.log('Full URL', fullUrl) | ||||
|  | ||||
| @ -117,6 +137,7 @@ const createWindow = (filePath?: string, reuse?: boolean): BrowserWindow => { | ||||
|         }) | ||||
|         .catch(reportRejection) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Open the DevTools. | ||||
|   // mainWindow.webContents.openDevTools() | ||||
|  | ||||
| @ -25,6 +25,7 @@ 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. | ||||
| @ -34,6 +35,18 @@ const Home = () => { | ||||
|   const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0) | ||||
|   const { projectsDir } = useProjectsLoader([projectsLoaderTrigger]) | ||||
|  | ||||
|   // 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.HOME + 'SETTINGS') | ||||
|   const navigate = useNavigate() | ||||
|   const { | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	