diff --git a/e2e/playwright/command-bar-tests.spec.ts b/e2e/playwright/command-bar-tests.spec.ts index 58340214dd..285edcef2a 100644 --- a/e2e/playwright/command-bar-tests.spec.ts +++ b/e2e/playwright/command-bar-tests.spec.ts @@ -1,7 +1,8 @@ import { test, expect } from './zoo-test' - -import { getUtils } from './test-utils' +import * as fsp from 'fs/promises' +import { executorInputPath, getUtils } from './test-utils' import { KCL_DEFAULT_LENGTH } from 'lib/constants' +import path from 'path' test.describe('Command bar tests', () => { test('Extrude from command bar selects extrude line after', async ({ @@ -305,4 +306,132 @@ test.describe('Command bar tests', () => { await arcToolCommand.click() await expect(arcToolButton).toHaveAttribute('aria-pressed', 'true') }) + + test(`Reacts to query param to open "import from URL" command`, async ({ + page, + cmdBar, + editor, + homePage, + }) => { + await test.step(`Prepare and navigate to home page with query params`, async () => { + const targetURL = `?create-file&name=test&units=mm&code=ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D&ask-open-desktop` + await homePage.expectState({ + projectCards: [], + sortBy: 'last-modified-desc', + }) + await page.goto(page.url() + targetURL) + expect(page.url()).toContain(targetURL) + }) + + await test.step(`Submit the command`, async () => { + await cmdBar.expectState({ + stage: 'arguments', + commandName: 'Import file from URL', + currentArgKey: 'method', + currentArgValue: '', + headerArguments: { + Method: '', + Name: 'test', + Code: '1 line', + }, + highlightedHeaderArg: 'method', + }) + await cmdBar.selectOption({ name: 'New Project' }).click() + await cmdBar.expectState({ + stage: 'review', + commandName: 'Import file from URL', + headerArguments: { + Method: 'New project', + Name: 'test', + Code: '1 line', + }, + }) + await cmdBar.progressCmdBar() + }) + + await test.step(`Ensure we created the project and are in the modeling scene`, async () => { + await editor.expectEditor.toContain('extrusionDistance = 12') + }) + }) + + test(`"import from URL" can add to existing project`, async ({ + page, + cmdBar, + editor, + homePage, + toolbar, + context, + }) => { + await context.folderSetupFn(async (dir) => { + const testProjectDir = path.join(dir, 'testProjectDir') + await Promise.all([fsp.mkdir(testProjectDir, { recursive: true })]) + await Promise.all([ + fsp.copyFile( + executorInputPath('cylinder.kcl'), + path.join(testProjectDir, 'main.kcl') + ), + ]) + }) + await test.step(`Prepare and navigate to home page with query params`, async () => { + const targetURL = `?create-file&name=test&units=mm&code=ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D&ask-open-desktop` + await homePage.expectState({ + projectCards: [ + { + fileCount: 1, + title: 'testProjectDir', + }, + ], + sortBy: 'last-modified-desc', + }) + await page.goto(page.url() + targetURL) + expect(page.url()).toContain(targetURL) + }) + + await test.step(`Submit the command`, async () => { + await cmdBar.expectState({ + stage: 'arguments', + commandName: 'Import file from URL', + currentArgKey: 'method', + currentArgValue: '', + headerArguments: { + Method: '', + Name: 'test', + Code: '1 line', + }, + highlightedHeaderArg: 'method', + }) + await cmdBar.selectOption({ name: 'Existing Project' }).click() + await cmdBar.expectState({ + stage: 'arguments', + commandName: 'Import file from URL', + currentArgKey: 'projectName', + currentArgValue: '', + headerArguments: { + Method: 'Existing project', + Name: 'test', + ProjectName: '', + Code: '1 line', + }, + highlightedHeaderArg: 'projectName', + }) + await cmdBar.selectOption({ name: 'testProjectDir' }).click() + await cmdBar.expectState({ + stage: 'review', + commandName: 'Import file from URL', + headerArguments: { + Method: 'Existing project', + ProjectName: 'testProjectDir', + Name: 'test', + Code: '1 line', + }, + }) + await cmdBar.progressCmdBar() + }) + + await test.step(`Ensure we created the project and are in the modeling scene`, async () => { + await editor.expectEditor.toContain('extrusionDistance = 12') + await toolbar.openPane('files') + await toolbar.expectFileTreeState(['main.kcl', 'test.kcl']) + }) + }) }) diff --git a/e2e/playwright/fixtures/cmdBarFixture.ts b/e2e/playwright/fixtures/cmdBarFixture.ts index c37f79dc67..634c28c816 100644 --- a/e2e/playwright/fixtures/cmdBarFixture.ts +++ b/e2e/playwright/fixtures/cmdBarFixture.ts @@ -151,4 +151,11 @@ export class CmdBarFixture { chooseCommand = async (commandName: string) => { await this.cmdOptions.getByText(commandName).click() } + + /** + * Select an option from the command bar + */ + selectOption = (options: Parameters[1]) => { + return this.page.getByRole('option', options) + } } diff --git a/src/App.tsx b/src/App.tsx index e0beee00da..dbf34ce13b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,13 +22,28 @@ import Gizmo from 'components/Gizmo' import { CoreDumpManager } from 'lib/coredump' import { UnitsMenu } from 'components/UnitsMenu' import { CameraProjectionToggle } from 'components/CameraProjectionToggle' +import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher' import { maybeWriteToDisk } from 'lib/telemetry' +import { commandBarActor } from 'machines/commandBarMachine' maybeWriteToDisk() .then(() => {}) .catch(() => {}) export function App() { const { project, file } = useLoaderData() as IndexLoaderData + + // Keep a lookout for a URL query string that invokes the 'import file from URL' command + useCreateFileLinkQuery((argDefaultValues) => { + commandBarActor.send({ + type: 'Find and select command', + data: { + groupId: 'projects', + name: 'Import file from URL', + argDefaultValues, + }, + }) + }) + useRefreshSettings(PATHS.FILE + 'SETTINGS') const navigate = useNavigate() const filePath = useAbsoluteFilePath() diff --git a/src/Router.tsx b/src/Router.tsx index cd8ba8c4c4..d4d3e44f51 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -34,7 +34,7 @@ import { import SettingsAuthProvider from 'components/SettingsAuthProvider' import LspProvider from 'components/LspProvider' import { KclContextProvider } from 'lang/KclProvider' -import { BROWSER_PROJECT_NAME } from 'lib/constants' +import { ASK_TO_OPEN_QUERY_PARAM, BROWSER_PROJECT_NAME } from 'lib/constants' import { CoreDumpManager } from 'lib/coredump' import { codeManager, engineCommandManager } from 'lib/singletons' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' @@ -46,6 +46,7 @@ import { AppStateProvider } from 'AppState' import { reportRejection } from 'lib/trap' import { RouteProvider } from 'components/RouteProvider' import { ProjectsContextProvider } from 'components/ProjectsContextProvider' +import { OpenInDesktopAppHandler } from 'components/OpenInDesktopAppHandler' const createRouter = isDesktop() ? createHashRouter : createBrowserRouter @@ -57,31 +58,42 @@ const router = createRouter([ /* Make sure auth is the outermost provider or else we will have * inefficient re-renders, use the react profiler to see. */ element: ( - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + ), errorElement: , children: [ { path: PATHS.INDEX, - loader: async () => { + loader: async ({ request }) => { const onDesktop = isDesktop() - return onDesktop - ? redirect(PATHS.HOME) - : redirect(PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME) + const url = new URL(request.url) + if (onDesktop) { + return redirect(PATHS.HOME + (url.search || '')) + } else { + const searchParams = new URLSearchParams(url.search) + if (!searchParams.has(ASK_TO_OPEN_QUERY_PARAM)) { + return redirect( + PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME + (url.search || '') + ) + } + } + return null }, }, { diff --git a/src/components/CommandBar/CommandArgOptionInput.tsx b/src/components/CommandBar/CommandArgOptionInput.tsx index 990a31162e..c1d715af65 100644 --- a/src/components/CommandBar/CommandArgOptionInput.tsx +++ b/src/components/CommandBar/CommandArgOptionInput.tsx @@ -129,6 +129,7 @@ function CommandArgOptionInput({ diff --git a/src/components/FileMachineProvider.tsx b/src/components/FileMachineProvider.tsx index 7833324e10..22900e51a7 100644 --- a/src/components/FileMachineProvider.tsx +++ b/src/components/FileMachineProvider.tsx @@ -47,8 +47,9 @@ export const FileMachineProvider = ({ children: React.ReactNode }) => { const navigate = useNavigate() - const { settings } = useSettingsAuthContext() - const { project, file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData + const { settings, auth } = useSettingsAuthContext() + const projectData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData + const { project, file } = projectData const [kclSamples, setKclSamples] = React.useState( [] ) @@ -295,40 +296,47 @@ export const FileMachineProvider = ({ const kclCommandMemo = useMemo( () => - kclCommands( - async (data) => { - if (data.method === 'overwrite') { - codeManager.updateCodeStateEditor(data.code) - 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, - }, - }) - } + kclCommands({ + authToken: auth?.context?.token ?? '', + projectData, + settings: { + defaultUnit: settings?.context?.modeling.defaultUnit.current ?? 'mm', + }, + specialPropsForSampleCommand: { + onSubmit: async (data) => { + if (data.method === 'overwrite') { + codeManager.updateCodeStateEditor(data.code) + await kclManager.executeCode(true) + 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, - }, - }) - } + // 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, + })), }, - kclSamples.map((sample) => ({ - value: sample.pathFromProjectDirectoryToFirstFile, - name: sample.title, - })) - ).filter( + }).filter( (command) => kclSamples.length || command.name !== 'open-kcl-example' ), [codeManager, kclManager, send, kclSamples] diff --git a/src/components/OpenInDesktopAppHandler.test.tsx b/src/components/OpenInDesktopAppHandler.test.tsx new file mode 100644 index 0000000000..9cac84fee2 --- /dev/null +++ b/src/components/OpenInDesktopAppHandler.test.tsx @@ -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 ( + + {children}} + /> + + ) +} + +describe('OpenInDesktopAppHandler tests', () => { + test(`does not render the modal if no query param is present`, () => { + render( + + +

Dummy app contents

+
+
+ ) + + 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( + + +

Dummy app contents

+
+
+ ) + + 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() + }) +}) diff --git a/src/components/OpenInDesktopAppHandler.tsx b/src/components/OpenInDesktopAppHandler.tsx new file mode 100644 index 0000000000..d0b2a347d7 --- /dev/null +++ b/src/components/OpenInDesktopAppHandler.tsx @@ -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 ? ( + + +
+

+ Launching{' '} + Zoo Modeling App +

+
+

+ Choose where to open this link... +

+
+
+ + Open in desktop app + + + Download desktop app + +
+ + Continue to web app + +
+
+
+ ) : ( + props.children + ) +} diff --git a/src/components/ProjectSidebarMenu.tsx b/src/components/ProjectSidebarMenu.tsx index 6733ab8180..9ae704f1e4 100644 --- a/src/components/ProjectSidebarMenu.tsx +++ b/src/components/ProjectSidebarMenu.tsx @@ -9,7 +9,7 @@ import { Logo } from './Logo' import { APP_NAME } from 'lib/constants' import { CustomIcon } from './CustomIcon' import { useLspContext } from './LspProvider' -import { engineCommandManager, kclManager } from 'lib/singletons' +import { codeManager, engineCommandManager, kclManager } from 'lib/singletons' import { MachineManagerContext } from 'components/MachineManagerProvider' import usePlatform from 'hooks/usePlatform' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' @@ -17,6 +17,9 @@ import Tooltip from './Tooltip' import { SnapshotFrom } from 'xstate' import { commandBarActor } from 'machines/commandBarMachine' import { useSelector } from '@xstate/react' +import { copyFileShareLink } from 'lib/links' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' +import { DEV } from 'env' const ProjectSidebarMenu = ({ project, @@ -100,6 +103,7 @@ function ProjectMenuPopover({ const location = useLocation() const navigate = useNavigate() const filePath = useAbsoluteFilePath() + const { settings, auth } = useSettingsAuthContext() const machineManager = useContext(MachineManagerContext) const commands = useSelector(commandBarActor, commandsSelector) @@ -158,7 +162,6 @@ function ProjectMenuPopover({ data: exportCommandInfo, }), }, - 'break', { id: 'make', Element: 'button', @@ -184,6 +187,20 @@ function ProjectMenuPopover({ }) }, }, + { + id: 'share-link', + Element: 'button', + children: 'Share link to file', + disabled: !DEV, + onClick: async () => { + await copyFileShareLink({ + token: auth?.context.token || '', + code: codeManager.code, + name: project?.name || '', + units: settings.context.modeling.defaultUnit.current, + }) + }, + }, 'break', { id: 'go-home', diff --git a/src/components/ProjectsContextProvider.tsx b/src/components/ProjectsContextProvider.tsx index 0e56fba3be..65d4348fbf 100644 --- a/src/components/ProjectsContextProvider.tsx +++ b/src/components/ProjectsContextProvider.tsx @@ -2,11 +2,11 @@ import { useMachine } from '@xstate/react' import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher' import { useProjectsLoader } from 'hooks/useProjectsLoader' import { projectsMachine } from 'machines/projectsMachine' -import { createContext, useEffect, useState } from 'react' +import { createContext, useCallback, useEffect, useState } from 'react' import { Actor, AnyStateMachine, fromPromise, Prop, StateFrom } from 'xstate' import { useLspContext } from './LspProvider' import toast from 'react-hot-toast' -import { useLocation, useNavigate } from 'react-router-dom' +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { PATHS } from 'lib/paths' import { createNewProjectDirectory, @@ -18,12 +18,28 @@ import { interpolateProjectNameWithIndex, doesProjectNameNeedInterpolated, getUniqueProjectName, + getNextFileName, } from 'lib/desktopFS' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import useStateMachineCommands from 'hooks/useStateMachineCommands' import { projectsCommandBarConfig } from 'lib/commandBarConfigs/projectsCommandConfig' import { isDesktop } from 'lib/isDesktop' import { commandBarActor } from 'machines/commandBarMachine' +import { + CREATE_FILE_URL_PARAM, + FILE_EXT, + PROJECT_ENTRYPOINT, +} from 'lib/constants' +import { DeepPartial } from 'lib/types' +import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' +import { codeManager } from 'lib/singletons' +import { + loadAndValidateSettings, + projectConfigurationToSettingsPayload, + saveSettings, + setSettingsAtLevel, +} from 'lib/settings/settingsUtils' +import { Project } from 'lib/project' type MachineContext = { state?: StateFrom @@ -53,12 +69,110 @@ export const ProjectsContextProvider = ({ ) } +/** + * We need some of the functionality of the ProjectsContextProvider in the web version + * but we can't perform file system operations in the browser, + * so most of the behavior of this machine is stubbed out. + */ const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => { + const [searchParams, setSearchParams] = useSearchParams() + const clearImportSearchParams = useCallback(() => { + // Clear the search parameters related to the "Import file from URL" command + // or we'll never be able cancel or submit it. + searchParams.delete(CREATE_FILE_URL_PARAM) + searchParams.delete('code') + searchParams.delete('name') + searchParams.delete('units') + setSearchParams(searchParams) + }, [searchParams, setSearchParams]) + const { + settings: { context: settings, send: settingsSend }, + } = useSettingsAuthContext() + + const [state, send, actor] = useMachine( + projectsMachine.provide({ + actions: { + navigateToProject: () => {}, + navigateToProjectIfNeeded: () => {}, + navigateToFile: () => {}, + toastSuccess: ({ event }) => + toast.success( + ('data' in event && typeof event.data === 'string' && event.data) || + ('output' in event && + 'message' in event.output && + typeof event.output.message === 'string' && + event.output.message) || + '' + ), + toastError: ({ event }) => + toast.error( + ('data' in event && typeof event.data === 'string' && event.data) || + ('output' in event && + typeof event.output === 'string' && + event.output) || + '' + ), + }, + actors: { + readProjects: fromPromise(async () => [] as Project[]), + createProject: fromPromise(async () => ({ + message: 'not implemented on web', + })), + renameProject: fromPromise(async () => ({ + message: 'not implemented on web', + oldName: '', + newName: '', + })), + deleteProject: fromPromise(async () => ({ + message: 'not implemented on web', + name: '', + })), + createFile: fromPromise(async ({ input }) => { + // Browser version doesn't navigate, just overwrites the current file + clearImportSearchParams() + codeManager.updateCodeStateEditor(input.code || '') + await codeManager.writeToFile() + + settingsSend({ + type: 'set.modeling.defaultUnit', + data: { + level: 'project', + value: input.units, + }, + }) + + return { + message: 'File and units overwritten successfully', + fileName: input.name, + projectName: '', + } + }), + }, + }), + { + input: { + projects: [], + defaultProjectName: settings.projects.defaultProjectName.current, + defaultDirectory: settings.app.projectDirectory.current, + }, + } + ) + + // register all project-related command palette commands + useStateMachineCommands({ + machineId: 'projects', + send, + state, + commandBarConfig: projectsCommandBarConfig, + actor, + onCancel: clearImportSearchParams, + }) + return ( {}, + state, + send, }} > {children} @@ -73,18 +187,21 @@ const ProjectsContextDesktop = ({ }) => { const navigate = useNavigate() const location = useLocation() + const [searchParams, setSearchParams] = useSearchParams() + const clearImportSearchParams = useCallback(() => { + // Clear the search parameters related to the "Import file from URL" command + // or we'll never be able cancel or submit it. + searchParams.delete(CREATE_FILE_URL_PARAM) + searchParams.delete('code') + searchParams.delete('name') + searchParams.delete('units') + setSearchParams(searchParams) + }, [searchParams, setSearchParams]) const { onProjectOpen } = useLspContext() const { settings: { context: settings }, } = useSettingsAuthContext() - useEffect(() => { - console.log( - 'project directory changed', - settings.app.projectDirectory.current - ) - }, [settings.app.projectDirectory.current]) - const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0) const { projectPaths, projectsDir } = useProjectsLoader([ projectsLoaderTrigger, @@ -168,6 +285,31 @@ const ProjectsContextDesktop = ({ } } }, + navigateToFile: ({ context, event }) => { + if (event.type !== 'xstate.done.actor.create-file') return + // For now, the browser version of create-file doesn't need to navigate + // since it just overwrites the current file. + if (!isDesktop()) return + let projectPath = window.electron.join( + context.defaultDirectory, + event.output.projectName + ) + let filePath = window.electron.join( + projectPath, + event.output.fileName + ) + onProjectOpen( + { + name: event.output.projectName, + path: projectPath, + }, + null + ) + const pathToNavigateTo = `${PATHS.FILE}/${encodeURIComponent( + filePath + )}` + navigate(pathToNavigateTo) + }, toastSuccess: ({ event }) => toast.success( ('data' in event && typeof event.data === 'string' && event.data) || @@ -217,8 +359,6 @@ const ProjectsContextDesktop = ({ name = interpolateProjectNameWithIndex(name, nextIndex) } - console.log('from Project') - await renameProjectDirectory( window.electron.path.join(defaultDirectory, oldName), name @@ -241,13 +381,82 @@ const ProjectsContextDesktop = ({ name: input.name, } }), - }, - guards: { - 'Has at least 1 project': ({ event }) => { - if (event.type !== 'xstate.done.actor.read-projects') return false - console.log(`from has at least 1 project: ${event.output.length}`) - return event.output.length ? event.output.length >= 1 : false - }, + createFile: fromPromise(async ({ input }) => { + let projectName = + (input.method === 'newProject' ? input.name : input.projectName) || + settings.projects.defaultProjectName.current + let fileName = + input.method === 'newProject' + ? PROJECT_ENTRYPOINT + : input.name.endsWith(FILE_EXT) + ? input.name + : input.name + FILE_EXT + let message = 'File created successfully' + const unitsConfiguration: DeepPartial = { + settings: { + project: { + directory: settings.app.projectDirectory.current, + }, + modeling: { + base_unit: input.units, + }, + }, + } + + 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, + } + }), }, }), { @@ -270,6 +479,7 @@ const ProjectsContextDesktop = ({ state, commandBarConfig: projectsCommandBarConfig, actor, + onCancel: clearImportSearchParams, }) return ( diff --git a/src/hooks/useCreateFileLinkQueryWatcher.ts b/src/hooks/useCreateFileLinkQueryWatcher.ts new file mode 100644 index 0000000000..4f70d977eb --- /dev/null +++ b/src/hooks/useCreateFileLinkQueryWatcher.ts @@ -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]) +} diff --git a/src/hooks/useProjectsLoader.tsx b/src/hooks/useProjectsLoader.tsx index 04e55c9178..aff8edaa8e 100644 --- a/src/hooks/useProjectsLoader.tsx +++ b/src/hooks/useProjectsLoader.tsx @@ -14,7 +14,7 @@ export const useProjectsLoader = (deps?: [number]) => { useEffect(() => { // Useless on web, until we get fake filesystems over there. - if (!isDesktop) return + if (!isDesktop()) return if (deps && deps[0] === lastTs) return diff --git a/src/lib/base64.test.ts b/src/lib/base64.test.ts new file mode 100644 index 0000000000..edb7db9d25 --- /dev/null +++ b/src/lib/base64.test.ts @@ -0,0 +1,40 @@ +import { expect } from 'vitest' +import { base64ToString, stringToBase64 } from './base64' + +describe('base64 encoding', () => { + test('to base64, simple code', async () => { + const code = `extrusionDistance = 12` + // Generated by online tool + const expectedBase64 = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg==` + + const base64 = stringToBase64(code) + expect(base64).toBe(expectedBase64) + }) + + test(`to base64, code with UTF-8 characters`, async () => { + // example adapted from MDN docs: https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem + const code = `a = 5; Ā = 12.in(); 𐀀 = Ā; 文 = 12.0; 🦄 = -5;` + // Generated by online tool + const expectedBase64 = `YSA9IDU7IMSAID0gMTIuaW4oKTsg8JCAgCA9IMSAOyDmlocgPSAxMi4wOyDwn6aEID0gLTU7` + + const base64 = stringToBase64(code) + expect(base64).toBe(expectedBase64) + }) + + // The following are simply the reverse of the above tests + test('from base64, simple code', async () => { + const base64 = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg==` + const expectedCode = `extrusionDistance = 12` + + const code = base64ToString(base64) + expect(code).toBe(expectedCode) + }) + + test(`from base64, code with UTF-8 characters`, async () => { + const base64 = `YSA9IDU7IMSAID0gMTIuaW4oKTsg8JCAgCA9IMSAOyDmlocgPSAxMi4wOyDwn6aEID0gLTU7` + const expectedCode = `a = 5; Ā = 12.in(); 𐀀 = Ā; 文 = 12.0; 🦄 = -5;` + + const code = base64ToString(base64) + expect(code).toBe(expectedCode) + }) +}) diff --git a/src/lib/base64.ts b/src/lib/base64.ts new file mode 100644 index 0000000000..11b1962bef --- /dev/null +++ b/src/lib/base64.ts @@ -0,0 +1,29 @@ +/** + * Converts a string to a base64 string, preserving the UTF-8 encoding + */ +export function stringToBase64(str: string) { + return bytesToBase64(new TextEncoder().encode(str)) +} + +/** + * Converts a base64 string to a string, preserving the UTF-8 encoding + */ +export function base64ToString(base64: string) { + return new TextDecoder().decode(base64ToBytes(base64)) +} + +/** + * From the MDN Web Docs + * https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem + */ +function base64ToBytes(base64: string) { + const binString = atob(base64) + return Uint8Array.from(binString, (m) => m.codePointAt(0)!) +} + +function bytesToBase64(bytes: Uint8Array) { + const binString = Array.from(bytes, (byte) => + String.fromCodePoint(byte) + ).join('') + return btoa(binString) +} diff --git a/src/lib/commandBarConfigs/projectsCommandConfig.ts b/src/lib/commandBarConfigs/projectsCommandConfig.ts index 4d313e29a7..caa3549d2d 100644 --- a/src/lib/commandBarConfigs/projectsCommandConfig.ts +++ b/src/lib/commandBarConfigs/projectsCommandConfig.ts @@ -1,5 +1,8 @@ +import { UnitLength_type } from '@kittycad/lib/dist/types/src/models' import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning' import { StateMachineCommandSetConfig } from 'lib/commandTypes' +import { isDesktop } from 'lib/isDesktop' +import { baseUnitLabels, baseUnitsUnion } from 'lib/settings/settingsTypes' import { projectsMachine } from 'machines/projectsMachine' export type ProjectsCommandSchema = { @@ -17,6 +20,13 @@ export type ProjectsCommandSchema = { oldName: string newName: string } + 'Import file from URL': { + name: string + code?: string + units: UnitLength_type + method: 'newProject' | 'existingProject' + projectName?: string + } } export const projectsCommandBarConfig: StateMachineCommandSetConfig< @@ -26,6 +36,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig< 'Open project': { icon: 'arrowRight', description: 'Open a project', + status: isDesktop() ? 'active' : 'inactive', args: { name: { inputType: 'options', @@ -42,6 +53,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig< 'Create project': { icon: 'folderPlus', description: 'Create a project', + status: isDesktop() ? 'active' : 'inactive', args: { name: { inputType: 'string', @@ -53,6 +65,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig< 'Delete project': { icon: 'close', description: 'Delete a project', + status: isDesktop() ? 'active' : 'inactive', needsReview: true, reviewMessage: ({ argumentsToSubmit }) => CommandBarOverwriteWarning({ @@ -75,6 +88,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig< icon: 'folder', description: 'Rename a project', needsReview: true, + status: isDesktop() ? 'active' : 'inactive', args: { oldName: { inputType: 'options', @@ -92,4 +106,80 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig< }, }, }, + 'Import file from URL': { + icon: 'file', + description: 'Create a file', + needsReview: true, + status: 'active', + args: { + method: { + inputType: 'options', + required: true, + skip: true, + options: isDesktop() + ? [ + { name: 'New project', value: 'newProject' }, + { name: 'Existing project', value: 'existingProject' }, + ] + : [{ name: 'Overwrite', value: 'existingProject' }], + valueSummary(value) { + return isDesktop() + ? value === 'newProject' + ? 'New project' + : 'Existing project' + : 'Overwrite' + }, + }, + // TODO: We can't get the currently-opened project to auto-populate here because + // it's not available on projectMachine, but lower in fileMachine. Unify these. + projectName: { + inputType: 'options', + required: (commandsContext) => + isDesktop() && + commandsContext.argumentsToSubmit.method === 'existingProject', + skip: true, + options: (_, context) => + context?.projects.map((p) => ({ + name: p.name!, + value: p.name!, + })) || [], + }, + name: { + inputType: 'string', + required: isDesktop(), + skip: true, + }, + code: { + inputType: 'text', + required: true, + skip: true, + valueSummary(value) { + const lineCount = value?.trim().split('\n').length + return `${lineCount} line${lineCount === 1 ? '' : 's'}` + }, + }, + units: { + inputType: 'options', + required: false, + skip: true, + options: baseUnitsUnion.map((unit) => ({ + name: baseUnitLabels[unit], + value: unit, + })), + }, + }, + reviewMessage(commandBarContext) { + return isDesktop() + ? `Will add the contents from URL to a new ${ + commandBarContext.argumentsToSubmit.method === 'newProject' + ? 'project with file main.kcl' + : `file within the project "${commandBarContext.argumentsToSubmit.projectName}"` + } named "${ + commandBarContext.argumentsToSubmit.name + }", and set default units to "${ + commandBarContext.argumentsToSubmit.units + }".` + : `Will overwrite the contents of the current file with the contents from the URL.` + }, + }, } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ed62bc3a80..e490c00cf8 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -69,6 +69,7 @@ export const KCL_DEFAULT_DEGREE = `360` export const TEST_SETTINGS_FILE_KEY = 'playwright-test-settings' export const DEFAULT_HOST = 'https://api.zoo.dev' +export const PROD_APP_URL = 'https://app.zoo.dev' export const SETTINGS_FILE_NAME = 'settings.toml' export const TOKEN_FILE_NAME = 'token.txt' export const PROJECT_SETTINGS_FILE_NAME = 'project.toml' @@ -110,6 +111,9 @@ export const KCL_SAMPLES_MANIFEST_URLS = { localFallback: '/kcl-samples-manifest-fallback.json', } as const +/** URL parameter to create a file */ +export const CREATE_FILE_URL_PARAM = 'create-file' + /** Toast id for the app auto-updater toast */ export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast' @@ -139,3 +143,12 @@ export const VIEW_NAMES_SEMANTIC = { } as const /** The modeling sidebar buttons' IDs get a suffix to prevent collisions */ export const SIDEBAR_BUTTON_SUFFIX = '-pane-button' + +/** Custom URL protocol our desktop registers */ +export const ZOO_STUDIO_PROTOCOL = 'zoo-studio:' + +/** + * A query parameter that triggers a modal + * to "open in desktop app" when present in the URL + */ +export const ASK_TO_OPEN_QUERY_PARAM = 'ask-open-desktop' diff --git a/src/lib/kclCommands.ts b/src/lib/kclCommands.ts index ba0fb4db04..fd3ab12b47 100644 --- a/src/lib/kclCommands.ts +++ b/src/lib/kclCommands.ts @@ -1,12 +1,14 @@ import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning' import { Command, CommandArgumentOption } from './commandTypes' -import { kclManager } from './singletons' +import { codeManager, kclManager } from './singletons' import { isDesktop } from './isDesktop' import { FILE_EXT, PROJECT_SETTINGS_FILE_NAME } from './constants' import { UnitLength_type } from '@kittycad/lib/dist/types/src/models' import { parseProjectSettings } from 'lang/wasm' import { err, reportRejection } from './trap' import { projectConfigurationToSettingsPayload } from './settings/settingsUtils' +import { copyFileShareLink } from './links' +import { IndexLoaderData } from './types' interface OnSubmitProps { sampleName: string @@ -15,10 +17,21 @@ interface OnSubmitProps { method: 'overwrite' | 'newFile' } -export function kclCommands( - onSubmit: (p: OnSubmitProps) => Promise, - providedOptions: CommandArgumentOption[] -): Command[] { +interface KclCommandConfig { + // TODO: find a different approach that doesn't require + // special props for a single command + specialPropsForSampleCommand: { + onSubmit: (p: OnSubmitProps) => Promise + providedOptions: CommandArgumentOption[] + } + projectData: IndexLoaderData + authToken: string + settings: { + defaultUnit: UnitLength_type + } +} + +export function kclCommands(commandProps: KclCommandConfig): Command[] { return [ { name: 'format-code', @@ -107,7 +120,9 @@ export function kclCommands( ) .then((props) => { if (props?.code) { - onSubmit(props).catch(reportError) + commandProps.specialPropsForSampleCommand + .onSubmit(props) + .catch(reportError) } }) .catch(reportError) @@ -149,9 +164,25 @@ export function kclCommands( } return value }, - options: providedOptions, + options: commandProps.specialPropsForSampleCommand.providedOptions, }, }, }, + // { + // name: 'share-file-link', + // displayName: 'Share file', + // description: 'Create a link that contains a copy of the current file.', + // groupId: 'code', + // needsReview: false, + // icon: 'link', + // onSubmit: () => { + // copyFileShareLink({ + // token: commandProps.authToken, + // code: codeManager.code, + // name: commandProps.projectData.project?.name || '', + // units: commandProps.settings.defaultUnit, + // }).catch(reportRejection) + // }, + // }, ] } diff --git a/src/lib/links.test.ts b/src/lib/links.test.ts new file mode 100644 index 0000000000..9267e3cc9d --- /dev/null +++ b/src/lib/links.test.ts @@ -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) + }) +}) diff --git a/src/lib/links.ts b/src/lib/links.ts new file mode 100644 index 0000000000..b17a104ba0 --- /dev/null +++ b/src/lib/links.ts @@ -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 { + /** + * 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() + } +} diff --git a/src/lib/routeLoaders.ts b/src/lib/routeLoaders.ts index 94237ac86b..550c20c8f7 100644 --- a/src/lib/routeLoaders.ts +++ b/src/lib/routeLoaders.ts @@ -114,7 +114,7 @@ export const fileLoader: LoaderFunction = async ( return redirect( `${PATHS.FILE}/${encodeURIComponent( isDesktop() ? fallbackFile : params.id + '/' + PROJECT_ENTRYPOINT - )}` + )}${new URL(routerData.request.url).search || ''}` ) } @@ -188,11 +188,14 @@ export const fileLoader: LoaderFunction = async ( // Loads the settings and by extension the projects in the default directory // and returns them to the Home route, along with any errors that occurred -export const homeLoader: LoaderFunction = async (): Promise< - HomeLoaderData | Response -> => { +export const homeLoader: LoaderFunction = async ({ + request, +}): Promise => { + const url = new URL(request.url) if (!isDesktop()) { - return redirect(PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME) + return redirect( + PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME + (url.search || '') + ) } return {} } diff --git a/src/machines/projectsMachine.ts b/src/machines/projectsMachine.ts index 322e9c0f6c..5f4b01562f 100644 --- a/src/machines/projectsMachine.ts +++ b/src/machines/projectsMachine.ts @@ -25,6 +25,10 @@ export const projectsMachine = setup({ type: 'Delete project' data: ProjectsCommandSchema['Delete project'] } + | { + type: 'Import file from URL' + data: ProjectsCommandSchema['Import file from URL'] + } | { type: 'navigate'; data: { name: string } } | { type: 'xstate.done.actor.read-projects' @@ -42,6 +46,10 @@ export const projectsMachine = setup({ type: 'xstate.done.actor.rename-project' output: { message: string; oldName: string; newName: string } } + | { + type: 'xstate.done.actor.create-file' + output: { message: string; projectName: string; fileName: string } + } | { type: 'assign'; data: { [key: string]: any } }, input: {} as { projects: Project[] @@ -60,6 +68,7 @@ export const projectsMachine = setup({ toastError: () => {}, navigateToProject: () => {}, navigateToProjectIfNeeded: () => {}, + navigateToFile: () => {}, }, actors: { readProjects: fromPromise(() => Promise.resolve([] as Project[])), @@ -90,12 +99,22 @@ export const projectsMachine = setup({ name: '', }) ), + createFile: fromPromise( + (_: { + input: ProjectsCommandSchema['Import file from URL'] & { + projects: Project[] + } + }) => Promise.resolve({ message: '', projectName: '', fileName: '' }) + ), }, guards: { - 'Has at least 1 project': () => false, + 'Has at least 1 project': ({ event }) => { + if (event.type !== 'xstate.done.actor.read-projects') return false + return event.output.length ? event.output.length >= 1 : false + }, }, }).createMachine({ - /** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAMS6yzFSkDaADALqKgAOqtALsaqXYgAHogAsAJgA0IAJ6IAjBIkA2AHQAOCUw0qNkjQE4xYjQF8zMtJhwES5NcmpZSqLBwBOqAFZh8PWAoAJTBcCHcvX39YZjYkEC5efkF40QQFFQBmAHY1Qwz9QwBWU0NMrRl5BGyxBTUVJlVM01rtBXNLEGtsPCIyMEdnVwifPwCKAGEPUJ5sT1H-WKFE4j4BITSMzMzNIsyJDSZMhSYmFWzKxAkTNSYxIuU9zOKT7IsrDB67fsHYEajxiEwv8xjFWMtuKtkhsrpJboZsioincFMdTIjLggVGJ1GImMVMg0JISjEV3l1PrY+g4nH95gDAiFSLgbPSxkt4is1ilQGlrhJ4YjkbU0RoMXJEIYkWoikV8hJinjdOdyd0qfYBrSQdFJtNcLNtTwOZxIdyYQh+YKkSjReKqqYBQoEQpsgiSi9MqrKb0Nb9DYEACJgAA2YANbMW4M5puhqVhAvxQptCnRKkxCleuw0Gm2+2aRVRXpsPp+Woj4wA8hwwKRDcaEjH1nGLXDE9aRSmxWmJVipWocmdsbL8kxC501SWHFMZmQoIaKBABAMyAA3VAAawG+D1swAtOX61zY7zENlEWoMkoatcdPoipjCWIZRIirozsPiYYi19qQNp-rZ3nMAPC8Dw1A4YN9QAM1QDx0DUbcZjAfdInZKMTSSJsT2qc9Lwka8lTvTFTB2WUMiyQwc3yPRv3VH4mRZQDywXJc1FXDcBmmZlMBQhYjXQhtMJ5ERT1wlQr0kQj7kxKiZTxM5s2zbQEVoycBgY9AmNQ-wKGA0DwMgngYLgtQuJZZCDwEo8sJEnD1Dwgjb2kntig0GUkWVfZDEMfFVO+Bwg1DPhSDnZjFwcdjNzUCAQzDCztP4uIMKhGy0jPezxPwySnPvHsTjlPIhyOHRsnwlM-N-NRArDLS+N0kDYIM6DYPgmKgvivjD0bYS+VbBF21RTs7UUUcimfRpXyYRF8LFCrfSBCBaoZFiItINcor1CBeIZLqhPNdKL0yxzs2cqoNDqdpSo0EoSoLOb6NCRaQv9FblzWjjTMe7bQQYBQksElKesUMRjFubRMllCHsm2a5MVldRDBfFpmj0IpxPuhwFqW0F6v0iDmpMzbvuiXbAfNFNQcaI5IaKaH9jETFsUMNRVDxOU5TxBULE6VwYvgeIJ38sAIT25td27KpdzG7yZeho5slHVmMc1IY3HLfnkrNZt2hOW5smRCQM2uhQHkZ6VtkRBFnmu0qFGVv11ZFsnm0kdN8QFJVjlUPZ9co+3-2C0KEqdrXsJMJ8ryc86iUyYiCvxFNzldb3jHtjTsf8EPj1ssQchZlR8jR5EmDRrIGZcuF7maF1aZtslx29IWqtiwPDSz1LxDxepnlOfYDghp03eyOpngzXuYdHNPHozgJ26B9IXR2cTvP0BVkSRXKqgLtyq8OfQcXOwlubMIA */ + /** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAMS6yzFSkDaADALqKgAOqtALsaqXYgAHogAsAJgA0IAJ6IAjAHYAbADoArBJVMFTCQA4mTAMwmxAXwsy0mHARLkKASXRcATjywAzYgBtsb3cMLABVACUAGWY2JBAuXn5BONEESRl5BAUFFQM1HQUxJSYATmyFAyUTKxsMbDwiMjA1ZGosUlQsDmCAKzB8HlgKcLBcCC7e-sGYoQTiPgEhVIUy9SKSiQ0NEwkclRyMxGKJNRKlMRVDU23zpRqQW3qHJpa2jonUPoGhgGF3UZ42G6nymMzicwWyVAyzKJzECg0OiYGjERhK+kOWUMSlOGiUlTEJlMxRKBnuj3sjXIr1gHy+g2Go3GwPpsDBnG48ySS0QGj0+Q0JTOGkqBg2JhKmNRJy2OyUEn0Kml5LqlMczVatJZUyGI1IuDs2oG7PinMhPIQfKYAqFShF+PFkrkRwkYjU8OUShKKhMKg0pUs1geqoa6ppdJ1FD+AKBk2NrFmZu5KV5-L9tvtYokEsxelRagUCoMiMumyUdpVdlDL01Ee+FAAImAAoC6zwTRDk9DU9b08LRY7MWd1AZcoS-aoLiYFJWnlSNW0jQyAPIcMCkNsdpOLFOWtOC-sO7NOzLbGWFbY6acmFESWdql7R3B8UhQNsUCACZpkABuqAA1s0+D-M+YAALRLluiQ7t2WRMGIbryjeBgGBIEglNsCi5sY6hMOchQqEoCLFCh97VtST4vm+S4UGA7jBO4agcH4z7eKg7joGowExhBcbtgm4LblCIiKPBiHZiKqHoZhuaFhoBbGMYBhiPBCgStUQYUuRzR6gaZDUXxH5fmov4Ac0-z6pgvEgvGsQctBwnLGJahIZJaEYdOmKEfJuQSoRRKSRcZHPNSunoPp750QxTEsTwbEcWoFkGuBkECfZXIwSJcEIS5Ekoe5MnOggXqIaUqFiiUhIInemkhiFzRNi2EU0Z+1KmYBagQM2YCAtZ9JQRljmiTlrn5dJnlFShOLwcpZjKBs+KBrUVb1WojU9c1hlRexMWsexnFdS2KV8QN5q7laNqHlmOaTcpmjoWc8L6HoYrBfOagjGMm02QyrXfqQf4dSBEB9Tqp1dllebichUkeVhRXTiU+QecUKhCps4pvWGn0QN9rJGW1ANmYlTKg98DAKHZpoORanoyqh8oImU3pKJifJ5Ohdr7CYqjIvKWMvDjeORttjHMXtCXA2T0xpdTg20+W9MSIzgorIRXlpr6ORbPs+jwQLFEgVRPj+JQf0mUTHXcaBYG+AE4OZcsxbuts5hbCK+zFmImJgSc5zPV6BQVD6RQG80lERXblCi7tcX7VxRvgVHDtDQgaE4vK2gXKiTA6ItubmJo8IoqOylERUVhBh0XXwHEWn1YmNO7mBKg+2KpzK2IpIBUwBhqUtwYre9tbvEutfpWdsFFnkPpVJnQqz15Ozur36FoypREbGH4Zj438u7tO1qIu5qK5PBGyYjebpEkS44e9oVTbxHr5tnvk9ZeXajTuW+zaHNuzYRUmoeCEp-RKlUHJbeYVhYDDfhDVIxR5IGGnISQk6E+6EkxMUBQX9c66EMMg3Q5Zt7rWNkuOBjsjilDUAqQsZQzzgOkJNUk7o7SmF-tiXOUCmQwMGBQ1OF4TgVGzFUG8yIxQGDZiYPI4oqh+mPsrDSy05xhmfm+KO-CLT+iRucEUuwFQqWzMWXM2YTAuTtL6ZBylkRcMrkAA */ id: 'Home machine', initial: 'Reading projects', @@ -111,6 +130,8 @@ export const projectsMachine = setup({ })), target: '.Reading projects', }, + + 'Import file from URL': '.Creating file', }, states: { 'Has no projects': { @@ -155,7 +176,10 @@ export const projectsMachine = setup({ id: 'create-project', src: 'createProject', input: ({ event, context }) => { - if (event.type !== 'Create project') { + if ( + event.type !== 'Create project' && + event.type !== 'Import file from URL' + ) { return { name: '', projects: context.projects, @@ -272,5 +296,39 @@ export const projectsMachine = setup({ ], }, }, + + 'Creating file': { + invoke: { + id: 'create-file', + src: 'createFile', + input: ({ event, context }) => { + if (event.type !== 'Import file from URL') { + return { + code: '', + name: '', + units: 'mm', + method: 'existingProject', + projects: context.projects, + } + } + return { + code: event.data.code || '', + name: event.data.name, + units: event.data.units, + method: event.data.method, + projectName: event.data.projectName, + projects: context.projects, + } + }, + onDone: { + target: 'Reading projects', + actions: ['navigateToFile', 'toastSuccess'], + }, + onError: { + target: 'Reading projects', + actions: 'toastError', + }, + }, + }, }, }) diff --git a/src/main.ts b/src/main.ts index 6fd1dbfca0..b6cd1c538a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,7 @@ import minimist from 'minimist' import getCurrentProjectFile from 'lib/getCurrentProjectFile' import os from 'node:os' import { reportRejection } from 'lib/trap' +import { ZOO_STUDIO_PROTOCOL } from 'lib/constants' import argvFromYargs from './commandLineArgs' import * as packageJSON from '../package.json' @@ -48,9 +49,7 @@ process.env.VITE_KC_SITE_BASE_URL ??= 'https://zoo.dev' process.env.VITE_KC_SKIP_AUTH ??= 'false' process.env.VITE_KC_CONNECTION_TIMEOUT_MS ??= '15000' -const ZOO_STUDIO_PROTOCOL = 'zoo-studio' - -/// Register our application to handle all "electron-fiddle://" protocols. +/// Register our application to handle all "zoo-studio:" protocols. if (process.defaultApp) { if (process.argv.length >= 2) { app.setAsDefaultProtocolClient(ZOO_STUDIO_PROTOCOL, process.execPath, [ @@ -65,7 +64,7 @@ if (process.defaultApp) { // Must be done before ready event. registerStartupListeners() -const createWindow = (filePath?: string, reuse?: boolean): BrowserWindow => { +const createWindow = (pathToOpen?: string, reuse?: boolean): BrowserWindow => { let newWindow if (reuse) { @@ -90,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. if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { - newWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL).catch(reportRejection) + const filteredPath = pathToOpen + ? decodeURI(pathToOpen.replace(ZOO_STUDIO_PROTOCOL, '')) + : '' + const fullHashBasedUrl = `${MAIN_WINDOW_VITE_DEV_SERVER_URL}/#/${filteredPath}` + newWindow.loadURL(fullHashBasedUrl).catch(reportRejection) } else { - getProjectPathAtStartup(filePath) - .then(async (projectPath) => { - const startIndex = path.join( - __dirname, - `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html` - ) - - if (projectPath === null) { - await newWindow.loadFile(startIndex) - return - } - - console.log('Loading file', projectPath) - - const fullUrl = `/file/${encodeURIComponent(projectPath)}` - console.log('Full URL', fullUrl) - - await newWindow.loadFile(startIndex, { - hash: fullUrl, + if (pathIsCustomProtocolLink && pathToOpen) { + // We're trying to open a custom protocol link + const filteredPath = pathToOpen + ? decodeURI(pathToOpen.replace(ZOO_STUDIO_PROTOCOL, '')) + : '' + const startIndex = path.join( + __dirname, + `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html` + ) + newWindow + .loadFile(startIndex, { + hash: filteredPath, }) - }) - .catch(reportRejection) + .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. diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 13a0c7bf43..20ddc6819a 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -25,6 +25,7 @@ import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher' import { useProjectsLoader } from 'hooks/useProjectsLoader' import { useProjectsContext } from 'hooks/useProjectsContext' import { commandBarActor } from 'machines/commandBarMachine' +import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher' // This route only opens in the desktop context for now, // as defined in Router.tsx, so we can use the desktop APIs and types. @@ -33,6 +34,18 @@ const Home = () => { const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0) const { projectsDir } = useProjectsLoader([projectsLoaderTrigger]) + // Keep a lookout for a URL query string that invokes the 'import file from URL' command + useCreateFileLinkQuery((argDefaultValues) => { + commandBarActor.send({ + type: 'Find and select command', + data: { + groupId: 'projects', + name: 'Import file from URL', + argDefaultValues, + }, + }) + }) + useRefreshSettings(PATHS.HOME + 'SETTINGS') const navigate = useNavigate() const {