diff --git a/e2e/playwright/command-bar-tests.spec.ts b/e2e/playwright/command-bar-tests.spec.ts index 8cd115009..8f6b00a7c 100644 --- a/e2e/playwright/command-bar-tests.spec.ts +++ b/e2e/playwright/command-bar-tests.spec.ts @@ -4,7 +4,6 @@ import * as fsp from 'fs/promises' import { executorInputPath, getUtils } from '@e2e/playwright/test-utils' import { expect, test } from '@e2e/playwright/zoo-test' -import { expectPixelColor } from '@e2e/playwright/fixtures/sceneFixture' test.describe('Command bar tests', () => { test('Extrude from command bar selects extrude line after', async ({ @@ -515,47 +514,6 @@ test.describe('Command bar tests', () => { }) }) - test( - `Zoom to fit to shared model on web`, - { tag: ['@web'] }, - async ({ page, scene }) => { - if (process.env.TARGET !== 'web') { - // This test is web-only - // TODO: re-enable on CI as part of a new @web test suite - return - } - await test.step(`Prepare and navigate to home page with query params`, async () => { - // a quad in the top left corner of the XZ plane (which is out of the current view) - const code = `sketch001 = startSketchOn(XZ) -profile001 = startProfile(sketch001, at = [-484.34, 484.95]) - |> yLine(length = -69.1) - |> xLine(length = 66.84) - |> yLine(length = 71.37) - |> line(endAbsolute = [profileStartX(%), profileStartY(%)]) - |> close() -` - const targetURL = `?create-file=true&name=test&units=mm&code=${encodeURIComponent(btoa(code))}&ask-open-desktop=true` - await page.goto(page.url() + targetURL) - expect(page.url()).toContain(targetURL) - }) - - await test.step(`Submit the command`, async () => { - await page.getByTestId('continue-to-web-app-button').click() - - await scene.connectionEstablished() - - // This makes SystemIOMachineActors.createKCLFile run after EngineStream/firstPlay - await page.waitForTimeout(3000) - - await page.getByTestId('command-bar-submit').click() - }) - - await test.step(`Ensure we created the project and are in the modeling scene`, async () => { - await expectPixelColor(page, [252, 252, 252], { x: 600, y: 260 }, 8) - }) - } - ) - test(`Can add and edit a named parameter or constant`, async ({ page, homePage, diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png index 4d00a06fe..8169515bc 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-1-Google-Chrome-linux.png index 13512d16d..2813e518e 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-segments-should-look-right-2-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-segments-should-look-right-2-Google-Chrome-linux.png index cb0ee415b..e7c016cf6 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-segments-should-look-right-2-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-segments-should-look-right-2-Google-Chrome-linux.png differ diff --git a/e2e/playwright/temporary-workspace.spec.ts b/e2e/playwright/temporary-workspace.spec.ts new file mode 100644 index 000000000..1aff27635 --- /dev/null +++ b/e2e/playwright/temporary-workspace.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from '@e2e/playwright/zoo-test' +import { stringToBase64 } from '@src/lib/base64' + +test.describe('Temporary workspace', () => { + test( + 'Opening a share link creates a temporary environment that is not saved', + { tag: ['@web'] }, + async ({ page, editor, scene, cmdBar, homePage }) => { + await test.step('Pre-condition: editor is empty', async () => { + await homePage.goToModelingScene() + await scene.settled(cmdBar) + await editor.expectEditor.toContain('') + }) + + await test.step('Go to share link, check new content present, make a change', async () => { + const code = `sketch001 = startSketchOn(XY) + profile001 = startProfile(sketch001, at = [-124.89, -186.4]) + |> line(end = [391.31, 444.04]) + |> line(end = [96.21, -493.07]) + |> line(endAbsolute = [profileStartX(%), profileStartY(%)]) + |> close() + extrude001 = extrude(profile001, length = 5) +` + + const codeQueryParam = encodeURIComponent(stringToBase64(code)) + const targetURL = `?create-file=true&browser=test&code=${codeQueryParam}&ask-open-desktop=true` + await page.goto(page.url() + targetURL) + await expect.poll(() => page.url()).toContain(targetURL) + const button = page.getByRole('button', { name: 'Continue to web app' }) + await button.click() + + await editor.expectEditor.toContain(code, { shouldNormalise: true }) + await editor.scrollToText('-124.89', true) + await page.keyboard.press('9') + await page.keyboard.press('9') + }) + + await test.step('Post-condition: empty editor once again (original state)', async () => { + await homePage.goToModelingScene() + await scene.settled(cmdBar) + const code = await page.evaluate(() => + window.localStorage.getItem('persistCode') + ) + await expect(code).toContain('') + }) + } + ) + + test( + 'Opening a sample link creates a temporary environment that is not saved', + { tag: ['@web'] }, + async ({ page, editor, scene, cmdBar, homePage }) => { + await test.step('Pre-condition: editor is empty', async () => { + await homePage.goToModelingScene() + await scene.settled(cmdBar) + await editor.expectEditor.toContain('') + }) + + await test.step('Load sample, make an edit', async () => { + await page.goto( + `${page.url()}/?cmd=add-kcl-file-to-project&groupId=application&projectName=browser&source=kcl-samples&sample=brake-rotor/main.kcl` + ) + + await editor.scrollToText('114.3', true) + await page.keyboard.press('9') + await page.keyboard.press('9') + }) + + await test.step('Post-condition: empty editor once again (original state)', async () => { + await homePage.goToModelingScene() + await scene.settled(cmdBar) + const code = await page.evaluate(() => + window.localStorage.getItem('persistCode') + ) + await expect(code).toContain('') + }) + } + ) + + test( + 'Hitting save will save the temporary workspace', + { tag: ['@web'] }, + async ({ page, editor, scene, cmdBar, homePage }) => { + const buttonSaveTemporaryWorkspace = page.getByTestId('tws-save') + + await test.step('Pre-condition: editor is empty', async () => { + await homePage.goToModelingScene() + await scene.settled(cmdBar) + await editor.expectEditor.toContain('') + }) + + await test.step('Load sample, make an edit, *save*', async () => { + await page.goto( + `${page.url()}/?cmd=add-kcl-file-to-project&groupId=application&projectName=browser&source=kcl-samples&sample=brake-rotor/main.kcl` + ) + await homePage.goToModelingScene() + await scene.settled(cmdBar) + + await editor.scrollToText('114.3') + await editor.replaceCode('114.3', '999.9133') + await editor.expectEditor.toContain('999.9133') + + await buttonSaveTemporaryWorkspace.click() + await expect(buttonSaveTemporaryWorkspace).not.toBeVisible() + + await editor.expectEditor.toContain('999.9133') + }) + + await test.step('Post-condition: has the edits in localStorage', async () => { + const code = await page.evaluate(() => + window.localStorage.getItem('persistCode') + ) + await expect(code).toContain('999.9133') + }) + } + ) +}) diff --git a/src/Toolbar.tsx b/src/Toolbar.tsx index e825859ff..f9c295f42 100644 --- a/src/Toolbar.tsx +++ b/src/Toolbar.tsx @@ -25,7 +25,7 @@ import type { ToolbarModeName, } from '@src/lib/toolbar' import { isToolbarItemResolvedDropdown, toolbarConfig } from '@src/lib/toolbar' -import { commandBarActor } from '@src/lib/singletons' +import { codeManager, commandBarActor } from '@src/lib/singletons' import { filterEscHotkey } from '@src/lib/hotkeyWrapper' export function Toolbar({ @@ -40,6 +40,12 @@ export function Toolbar({ 'bg-chalkboard-transparent dark:bg-transparent disabled:bg-transparent dark:disabled:bg-transparent enabled:hover:bg-chalkboard-10 dark:enabled:hover:bg-chalkboard-100 pressed:!bg-primary pressed:enabled:hover:!text-chalkboard-10' const buttonBorderClassName = '!border-transparent' + const isInTemporaryWorkspace = codeManager.isBufferMode + + const onClickSave = () => { + codeManager.exitFromTemporaryWorkspaceMode() + } + const sketchPathId = useMemo(() => { if ( isCursorInFunctionDefinition( @@ -385,11 +391,27 @@ export function Toolbar({ ) })} - {state.matches('Sketch no face') && ( -
-

Select a plane or face to start sketching

-
- )} +
+ {isInTemporaryWorkspace && ( +
+
+ Temporary workspace +
+ +
+ )} + {state.matches('Sketch no face') && ( +
+

Select a plane or face to start sketching

+
+ )} +
) } diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 98c0ef983..0bd944057 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -49,8 +49,10 @@ export const AppHeader = ({ file={project?.file} /> {/* Toolbar if the context deems it */} -
- {showToolbar && } +
+
+ {showToolbar && } +
{/* If there are children, show them, otherwise show User menu */} diff --git a/src/components/CodeReplaceToast.tsx b/src/components/CodeReplaceToast.tsx deleted file mode 100644 index 7b418ed8d..000000000 --- a/src/components/CodeReplaceToast.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import toast from 'react-hot-toast' -import { codeManager, kclManager } from '@src/lib/singletons' - -interface CodeReplaceToastProps { - code: string -} - -export function CodeReplaceToast({ code }: CodeReplaceToastProps) { - return ( -
-
Replace current code?
-
- Do you want to replace your current code with this sample? -
-
- - -
-
- ) -} - -export function showCodeReplaceToast(code: string) { - // Create a persistent toast that doesn't auto-dismiss - return toast.custom(() => , { - id: 'code-replace-toast', - duration: Infinity, // Won't auto dismiss - position: 'top-center', - style: { - zIndex: 9999, // Ensure it's above other elements - }, - }) -} diff --git a/src/components/EngineStream.tsx b/src/components/EngineStream.tsx index 8fd579127..2c4feecbc 100644 --- a/src/components/EngineStream.tsx +++ b/src/components/EngineStream.tsx @@ -399,7 +399,11 @@ export const EngineStream = (props: { engineStreamState.value === EngineStreamState.Playing ) { timeoutStart.current = null - console.log('PAUSING') + console.log('Pausing') + console.log( + engineStreamActor.getSnapshot().value, + engineStreamState.value + ) engineStreamActor.send({ type: EngineStreamTransition.Pause }) } } diff --git a/src/components/OpenInDesktopAppHandler.tsx b/src/components/OpenInDesktopAppHandler.tsx index 113ba7740..36cc7c30f 100644 --- a/src/components/OpenInDesktopAppHandler.tsx +++ b/src/components/OpenInDesktopAppHandler.tsx @@ -1,6 +1,7 @@ import { Transition } from '@headlessui/react' import { VITE_KC_SITE_BASE_URL } from '@src/env' import { useSearchParams } from 'react-router-dom' +import { base64ToString } from '@src/lib/base64' import { ActionButton } from '@src/components/ActionButton' import { @@ -12,6 +13,7 @@ import { isDesktop } from '@src/lib/isDesktop' import { Themes, darkModeMatcher, setThemeClass } from '@src/lib/theme' import toast from 'react-hot-toast' import { platform } from '@src/lib/utils' +import { codeManager } from '@src/lib/singletons' import { Logo } from '@src/components/Logo' import { useEffect } from 'react' @@ -38,6 +40,17 @@ export const OpenInDesktopAppHandler = (props: React.PropsWithChildren) => { return () => darkModeMatcher?.removeEventListener('change', listener) }, []) + useEffect(() => { + if (!hasAskToOpenParam) { + return + } + + const codeB64 = base64ToString( + decodeURIComponent(searchParams.get('code') ?? '') + ) + codeManager.goIntoTemporaryWorkspaceModeWithCode(codeB64) + }, [hasAskToOpenParam]) + /** * 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 diff --git a/src/components/ToastQuestion.tsx b/src/components/ToastQuestion.tsx new file mode 100644 index 000000000..c0fc40039 --- /dev/null +++ b/src/components/ToastQuestion.tsx @@ -0,0 +1,50 @@ +import toast from 'react-hot-toast' + +interface ToastQuestionProps { + question: string + onYes: () => void + onNo: () => void +} + +const TOAST_ID = 'toast-question' + +export function ToastQuestion(props: ToastQuestionProps) { + return ( +
+
Question
+
{props.question}
+
+ + +
+
+ ) +} + +export function askQuestionPrompt(props: ToastQuestionProps) { + // Create a persistent toast that doesn't auto-dismiss + return toast.custom(() => , { + id: TOAST_ID, + duration: Infinity, // Won't auto dismiss + position: 'top-center', + style: { + zIndex: 9999, // Ensure it's above other elements + }, + }) +} diff --git a/src/hooks/useQueryParamEffects.ts b/src/hooks/useQueryParamEffects.ts index 8a2c95be2..75dbab92d 100644 --- a/src/hooks/useQueryParamEffects.ts +++ b/src/hooks/useQueryParamEffects.ts @@ -8,14 +8,15 @@ import { CMD_GROUP_QUERY_PARAM, CMD_NAME_QUERY_PARAM, CREATE_FILE_URL_PARAM, + FILE_NAME_QUERY_PARAM, + CODE_QUERY_PARAM, DEFAULT_FILE_NAME, POOL_QUERY_PARAM, PROJECT_ENTRYPOINT, } from '@src/lib/constants' import { isDesktop } from '@src/lib/isDesktop' import type { FileLinkParams } from '@src/lib/links' -import { commandBarActor, useAuthState } from '@src/lib/singletons' -import { showCodeReplaceToast } from '@src/components/CodeReplaceToast' +import { codeManager, commandBarActor, useAuthState } from '@src/lib/singletons' import { findKclSample } from '@src/lib/kclSamples' import { webSafePathSplit } from '@src/lib/paths' @@ -55,7 +56,11 @@ export function useQueryParamEffects() { * Watches for legacy `?create-file` hook, which share links currently use. */ useEffect(() => { - if (shouldInvokeCreateFile && authState.matches('loggedIn')) { + if ( + shouldInvokeCreateFile && + authState.matches('loggedIn') && + isDesktop() + ) { const argDefaultValues = buildCreateFileCommandArgs(searchParams) commandBarActor.send({ type: 'Find and select command', @@ -66,8 +71,10 @@ export function useQueryParamEffects() { }, }) - // Delete the query param after the command has been invoked. + // Delete the query params after the command has been invoked. searchParams.delete(CREATE_FILE_URL_PARAM) + searchParams.delete(FILE_NAME_QUERY_PARAM) + searchParams.delete(CODE_QUERY_PARAM) setSearchParams(searchParams) } }, [shouldInvokeCreateFile, setSearchParams, authState]) @@ -146,7 +153,7 @@ export function useQueryParamEffects() { return response.text() }) .then((code) => { - showCodeReplaceToast(code) + codeManager.goIntoTemporaryWorkspaceModeWithCode(code) }) .catch((error) => { console.error('Error loading KCL sample:', error) diff --git a/src/lang/codeManager.ts b/src/lang/codeManager.ts index 955b2b9d7..3b80c0a9e 100644 --- a/src/lang/codeManager.ts +++ b/src/lang/codeManager.ts @@ -28,6 +28,8 @@ export default class CodeManager { public writeCausedByAppCheckedInFileTreeFileSystemWatcher = false + public isBufferMode = false + constructor() { if (isDesktop()) { this.code = '' @@ -134,6 +136,8 @@ export default class CodeManager { } async writeToFile() { + if (this.isBufferMode) return + if (isDesktop()) { // Only write our buffer contents to file once per second. Any faster // and file-system watchers which read, will receive empty data during @@ -187,6 +191,16 @@ export default class CodeManager { this.updateCodeStateEditor(newCode) this.writeToFile().catch(reportRejection) } + + goIntoTemporaryWorkspaceModeWithCode(code: string) { + this.isBufferMode = true + this.updateCodeStateEditor(code, true) + } + + exitFromTemporaryWorkspaceMode() { + this.isBufferMode = false + this.writeToFile().catch(reportRejection) + } } function safeLSGetItem(key: string) { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index df293744c..801b9af36 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -217,6 +217,8 @@ export const POOL_QUERY_PARAM = 'pool' * @deprecated: supporting old share links with this. For new command URLs, use "cmd" */ export const CREATE_FILE_URL_PARAM = 'create-file' +export const FILE_NAME_QUERY_PARAM = 'name' +export const CODE_QUERY_PARAM = 'code' /** A query parameter to skip the sign-on view if unnecessary. */ export const IMMEDIATE_SIGN_IN_IF_NECESSARY_QUERY_PARAM = 'immediate-sign-in-if-necessary'