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: |   push: | ||||||
|     branches: |     branches: | ||||||
|       - main |       - main | ||||||
|  |       - pierremtb/4088/create-file-url | ||||||
|     tags: |     tags: | ||||||
|       - 'v[0-9]+.[0-9]+.[0-9]+' |       - 'v[0-9]+.[0-9]+.[0-9]+' | ||||||
|   schedule: |   schedule: | ||||||
|  | |||||||
| @ -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) | ||||||
|  |   } | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										16
									
								
								src/App.tsx
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								src/App.tsx
									
									
									
									
									
								
							| @ -22,6 +22,8 @@ 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 { useCommandsContext } from 'hooks/useCommandsContext' | ||||||
| import { maybeWriteToDisk } from 'lib/telemetry' | import { maybeWriteToDisk } from 'lib/telemetry' | ||||||
| maybeWriteToDisk() | maybeWriteToDisk() | ||||||
|   .then(() => {}) |   .then(() => {}) | ||||||
| @ -29,6 +31,20 @@ maybeWriteToDisk() | |||||||
|  |  | ||||||
| export function App() { | export function App() { | ||||||
|   const { project, file } = useLoaderData() as IndexLoaderData |   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') |   useRefreshSettings(PATHS.FILE + 'SETTINGS') | ||||||
|   const navigate = useNavigate() |   const navigate = useNavigate() | ||||||
|   const filePath = useAbsoluteFilePath() |   const filePath = useAbsoluteFilePath() | ||||||
|  | |||||||
| @ -35,7 +35,7 @@ import { CommandBarProvider } from 'components/CommandBar/CommandBarProvider' | |||||||
| 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' | ||||||
| @ -47,6 +47,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 | ||||||
|  |  | ||||||
| @ -58,33 +59,44 @@ 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: ( | ||||||
|       <CommandBarProvider> |       <OpenInDesktopAppHandler> | ||||||
|         <RouteProvider> |         <CommandBarProvider> | ||||||
|           <SettingsAuthProvider> |           <RouteProvider> | ||||||
|             <LspProvider> |             <SettingsAuthProvider> | ||||||
|               <ProjectsContextProvider> |               <LspProvider> | ||||||
|                 <KclContextProvider> |                 <ProjectsContextProvider> | ||||||
|                   <AppStateProvider> |                   <KclContextProvider> | ||||||
|                     <MachineManagerProvider> |                     <AppStateProvider> | ||||||
|                       <Outlet /> |                       <MachineManagerProvider> | ||||||
|                     </MachineManagerProvider> |                         <Outlet /> | ||||||
|                   </AppStateProvider> |                       </MachineManagerProvider> | ||||||
|                 </KclContextProvider> |                     </AppStateProvider> | ||||||
|               </ProjectsContextProvider> |                   </KclContextProvider> | ||||||
|             </LspProvider> |                 </ProjectsContextProvider> | ||||||
|           </SettingsAuthProvider> |               </LspProvider> | ||||||
|         </RouteProvider> |             </SettingsAuthProvider> | ||||||
|       </CommandBarProvider> |           </RouteProvider> | ||||||
|  |         </CommandBarProvider> | ||||||
|  |       </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> | ||||||
|  | |||||||
| @ -48,8 +48,9 @@ export const FileMachineProvider = ({ | |||||||
| }) => { | }) => { | ||||||
|   const navigate = useNavigate() |   const navigate = useNavigate() | ||||||
|   const { commandBarSend } = useCommandsContext() |   const { commandBarSend } = useCommandsContext() | ||||||
|   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[]>( | ||||||
|     [] |     [] | ||||||
|   ) |   ) | ||||||
| @ -296,40 +297,47 @@ export const FileMachineProvider = ({ | |||||||
|  |  | ||||||
|   const kclCommandMemo = useMemo( |   const kclCommandMemo = useMemo( | ||||||
|     () => |     () => | ||||||
|       kclCommands( |       kclCommands({ | ||||||
|         async (data) => { |         authToken: auth?.context?.token ?? '', | ||||||
|           if (data.method === 'overwrite') { |         projectData, | ||||||
|             codeManager.updateCodeStateEditor(data.code) |         settings: { | ||||||
|             await kclManager.executeCode(true) |           defaultUnit: settings?.context?.modeling.defaultUnit.current ?? 'mm', | ||||||
|             await codeManager.writeToFile() |  | ||||||
|           } else if (data.method === 'newFile' && isDesktop()) { |  | ||||||
|             send({ |  | ||||||
|               type: 'Create file', |  | ||||||
|               data: { |  | ||||||
|                 name: data.sampleName, |  | ||||||
|                 content: data.code, |  | ||||||
|                 makeDir: false, |  | ||||||
|               }, |  | ||||||
|             }) |  | ||||||
|           } |  | ||||||
|  |  | ||||||
|           // Either way, we want to overwrite the defaultUnit project setting |  | ||||||
|           // with the sample's setting. |  | ||||||
|           if (data.sampleUnits) { |  | ||||||
|             settings.send({ |  | ||||||
|               type: 'set.modeling.defaultUnit', |  | ||||||
|               data: { |  | ||||||
|                 level: 'project', |  | ||||||
|                 value: data.sampleUnits, |  | ||||||
|               }, |  | ||||||
|             }) |  | ||||||
|           } |  | ||||||
|         }, |         }, | ||||||
|         kclSamples.map((sample) => ({ |         specialPropsForSampleCommand: { | ||||||
|           value: sample.pathFromProjectDirectoryToFirstFile, |           onSubmit: async (data) => { | ||||||
|           name: sample.title, |             if (data.method === 'overwrite') { | ||||||
|         })) |               codeManager.updateCodeStateEditor(data.code) | ||||||
|       ).filter( |               await kclManager.executeCode(true) | ||||||
|  |               await codeManager.writeToFile() | ||||||
|  |             } else if (data.method === 'newFile' && isDesktop()) { | ||||||
|  |               send({ | ||||||
|  |                 type: 'Create file', | ||||||
|  |                 data: { | ||||||
|  |                   name: data.sampleName, | ||||||
|  |                   content: data.code, | ||||||
|  |                   makeDir: false, | ||||||
|  |                 }, | ||||||
|  |               }) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Either way, we want to overwrite the defaultUnit project setting | ||||||
|  |             // with the sample's setting. | ||||||
|  |             if (data.sampleUnits) { | ||||||
|  |               settings.send({ | ||||||
|  |                 type: 'set.modeling.defaultUnit', | ||||||
|  |                 data: { | ||||||
|  |                   level: 'project', | ||||||
|  |                   value: data.sampleUnits, | ||||||
|  |                 }, | ||||||
|  |               }) | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           providedOptions: kclSamples.map((sample) => ({ | ||||||
|  |             value: sample.pathFromProjectDirectoryToFirstFile, | ||||||
|  |             name: sample.title, | ||||||
|  |           })), | ||||||
|  |         }, | ||||||
|  |       }).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 | ||||||
|  |   ) | ||||||
|  | } | ||||||
| @ -10,11 +10,13 @@ import { APP_NAME } from 'lib/constants' | |||||||
| import { useCommandsContext } from 'hooks/useCommandsContext' | import { useCommandsContext } from 'hooks/useCommandsContext' | ||||||
| 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' | ||||||
| import Tooltip from './Tooltip' | import Tooltip from './Tooltip' | ||||||
|  | import { copyFileShareLink } from 'lib/links' | ||||||
|  | import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' | ||||||
|  |  | ||||||
| const ProjectSidebarMenu = ({ | const ProjectSidebarMenu = ({ | ||||||
|   project, |   project, | ||||||
| @ -95,6 +97,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 { commandBarState, commandBarSend } = useCommandsContext() |   const { commandBarState, commandBarSend } = useCommandsContext() | ||||||
| @ -155,7 +158,6 @@ function ProjectMenuPopover({ | |||||||
|               data: exportCommandInfo, |               data: exportCommandInfo, | ||||||
|             }), |             }), | ||||||
|         }, |         }, | ||||||
|         'break', |  | ||||||
|         { |         { | ||||||
|           id: 'make', |           id: 'make', | ||||||
|           Element: 'button', |           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', |         'break', | ||||||
|         { |         { | ||||||
|           id: 'go-home', |           id: 'go-home', | ||||||
|  | |||||||
| @ -3,11 +3,11 @@ import { useCommandsContext } from 'hooks/useCommandsContext' | |||||||
| 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, | ||||||
| @ -19,11 +19,27 @@ 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 { | ||||||
|  |   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,19 +187,22 @@ 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 { commandBarSend } = useCommandsContext() |   const { commandBarSend } = useCommandsContext() | ||||||
|   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, | ||||||
| @ -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 }) => |         toastSuccess: ({ event }) => | ||||||
|           toast.success( |           toast.success( | ||||||
|             ('data' in event && typeof event.data === 'string' && event.data) || |             ('data' in event && typeof event.data === 'string' && event.data) || | ||||||
| @ -218,8 +360,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 | ||||||
| @ -242,13 +382,82 @@ const ProjectsContextDesktop = ({ | |||||||
|             name: input.name, |             name: input.name, | ||||||
|           } |           } | ||||||
|         }), |         }), | ||||||
|       }, |         createFile: fromPromise(async ({ input }) => { | ||||||
|       guards: { |           let projectName = | ||||||
|         'Has at least 1 project': ({ event }) => { |             (input.method === 'newProject' ? input.name : input.projectName) || | ||||||
|           if (event.type !== 'xstate.done.actor.read-projects') return false |             settings.projects.defaultProjectName.current | ||||||
|           console.log(`from has at least 1 project: ${event.output.length}`) |           let fileName = | ||||||
|           return event.output.length ? event.output.length >= 1 : false |             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, | ||||||
|  |               }, | ||||||
|  |               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, | ||||||
|  |           } | ||||||
|  |         }), | ||||||
|       }, |       }, | ||||||
|     }), |     }), | ||||||
|     { |     { | ||||||
| @ -271,6 +480,7 @@ const ProjectsContextDesktop = ({ | |||||||
|     state, |     state, | ||||||
|     commandBarConfig: projectsCommandBarConfig, |     commandBarConfig: projectsCommandBarConfig, | ||||||
|     actor, |     actor, | ||||||
|  |     onCancel: clearImportSearchParams, | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|  | |||||||
| @ -6,5 +6,6 @@ export const useCommandsContext = () => { | |||||||
|   return { |   return { | ||||||
|     commandBarSend: commandBarActor.send, |     commandBarSend: commandBarActor.send, | ||||||
|     commandBarState, |     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(() => { |   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 | ||||||
|   providedOptions: CommandArgumentOption<string>[] |   // special props for a single command | ||||||
| ): Command[] { |   specialPropsForSampleCommand: { | ||||||
|  |     onSubmit: (p: OnSubmitProps) => Promise<void> | ||||||
|  |     providedOptions: CommandArgumentOption<string>[] | ||||||
|  |   } | ||||||
|  |   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', | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|   }, |   }, | ||||||
| }) | }) | ||||||
|  | |||||||
							
								
								
									
										79
									
								
								src/main.ts
									
									
									
									
									
								
							
							
						
						
									
										79
									
								
								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' | ||||||
| @ -42,15 +43,13 @@ if (!process.env.NODE_ENV) | |||||||
| dotenv.config({ path: [`.env.${NODE_ENV}.local`, `.env.${NODE_ENV}`] }) | dotenv.config({ path: [`.env.${NODE_ENV}.local`, `.env.${NODE_ENV}`] }) | ||||||
|  |  | ||||||
| process.env.VITE_KC_API_WS_MODELING_URL ??= | process.env.VITE_KC_API_WS_MODELING_URL ??= | ||||||
|   'wss://api.zoo.dev/ws/modeling/commands' |   'wss://api.dev.zoo.dev/ws/modeling/commands' | ||||||
| process.env.VITE_KC_API_BASE_URL ??= 'https://api.zoo.dev' | process.env.VITE_KC_API_BASE_URL ??= 'https://api.dev.zoo.dev' | ||||||
| process.env.VITE_KC_SITE_BASE_URL ??= 'https://zoo.dev' | process.env.VITE_KC_SITE_BASE_URL ??= 'https://dev.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,32 +89,54 @@ 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) { | ||||||
|       .then(async (projectPath) => { |       // We're trying to open a custom protocol link | ||||||
|         const startIndex = path.join( |       const filteredPath = pathToOpen | ||||||
|           __dirname, |         ? decodeURI(pathToOpen.replace(ZOO_STUDIO_PROTOCOL, '')) | ||||||
|           `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html` |         : '' | ||||||
|         ) |       const startIndex = path.join( | ||||||
|  |         __dirname, | ||||||
|         if (projectPath === null) { |         `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html` | ||||||
|           await newWindow.loadFile(startIndex) |       ) | ||||||
|           return |       newWindow | ||||||
|         } |         .loadFile(startIndex, { | ||||||
|  |           hash: filteredPath, | ||||||
|         console.log('Loading file', projectPath) |  | ||||||
|  |  | ||||||
|         const fullUrl = `/file/${encodeURIComponent(projectPath)}` |  | ||||||
|         console.log('Full URL', fullUrl) |  | ||||||
|  |  | ||||||
|         await newWindow.loadFile(startIndex, { |  | ||||||
|           hash: fullUrl, |  | ||||||
|         }) |         }) | ||||||
|       }) |         .catch(reportRejection) | ||||||
|       .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, | ||||||
|  |             `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html` | ||||||
|  |           ) | ||||||
|  |  | ||||||
|  |           if (projectPath === null) { | ||||||
|  |             await newWindow.loadFile(startIndex) | ||||||
|  |             return | ||||||
|  |           } | ||||||
|  |  | ||||||
|  |           const fullUrl = `/file/${encodeURIComponent(projectPath)}` | ||||||
|  |           console.log('Full URL', fullUrl) | ||||||
|  |  | ||||||
|  |           await newWindow.loadFile(startIndex, { | ||||||
|  |             hash: fullUrl, | ||||||
|  |           }) | ||||||
|  |         }) | ||||||
|  |         .catch(reportRejection) | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Open the DevTools. |   // Open the DevTools. | ||||||
|  | |||||||
| @ -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 { useCommandsContext } from 'hooks/useCommandsContext' | import { useCommandsContext } from 'hooks/useCommandsContext' | ||||||
|  | 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. | ||||||
| @ -34,6 +35,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) => { | ||||||
|  |     commandBarSend({ | ||||||
|  |       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
	