Prompt for temporary workspace when loading external sample or share link code (#7393)
* Prompt for temporary workspace when loading external sample * Always go into temporary workspace when loading sample * Always go into temporary workspace even on external links * x * Add tests * Fix typo * Update snapshots * Update snapshots * Fix tests that now strip code param * Fix test * Weird... * fmt * x * Add await * agh * Do not clear query parameters, causes more problems than not --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
@ -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,
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
Binary file not shown.
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
Binary file not shown.
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 47 KiB |
117
e2e/playwright/temporary-workspace.spec.ts
Normal file
117
e2e/playwright/temporary-workspace.spec.ts
Normal file
@ -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')
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
@ -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({
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{state.matches('Sketch no face') && (
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-2 py-1 px-2 bg-chalkboard-10 dark:bg-chalkboard-90 border border-chalkboard-20 dark:border-chalkboard-80 rounded shadow-lg">
|
||||
<p className="text-xs">Select a plane or face to start sketching</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-center absolute top-full left-1/2 -translate-x-1/2">
|
||||
{isInTemporaryWorkspace && (
|
||||
<div className="flex flex-row gap-2 justify-center">
|
||||
<div className="mt-2 py-1 animate-pulse w-fit uppercase text-xs rounded-full ml-2 px-2 py-1 border border-chalkboard-40 dark:text-chalkboard-40 bg-chalkboard-10 dark:bg-chalkboard-90 shadow-lg">
|
||||
Temporary workspace
|
||||
</div>
|
||||
<button
|
||||
data-testid="tws-save"
|
||||
onClick={onClickSave}
|
||||
className="mt-2 py-1 rounded-sm border-solid border border-chalkboard-30 hover:border-chalkboard-40 dark:hover:border-chalkboard-60 dark:bg-chalkboard-90/50 text-chalkboard-100 dark:text-chalkboard-10 bg-chalkboard-10 dark:bg-chalkboard-90 px-2"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{state.matches('Sketch no face') && (
|
||||
<div className="mt-2 py-1 px-2 bg-chalkboard-10 dark:bg-chalkboard-90 border border-chalkboard-20 dark:border-chalkboard-80 rounded shadow-lg">
|
||||
<p className="text-xs">Select a plane or face to start sketching</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</menu>
|
||||
)
|
||||
}
|
||||
|
@ -49,8 +49,10 @@ export const AppHeader = ({
|
||||
file={project?.file}
|
||||
/>
|
||||
{/* Toolbar if the context deems it */}
|
||||
<div className="flex-grow flex justify-center max-w-lg md:max-w-xl lg:max-w-2xl xl:max-w-4xl 2xl:max-w-5xl">
|
||||
{showToolbar && <Toolbar />}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex-grow flex justify-center max-w-lg md:max-w-xl lg:max-w-2xl xl:max-w-4xl 2xl:max-w-5xl">
|
||||
{showToolbar && <Toolbar />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 py-1 ml-auto">
|
||||
{/* If there are children, show them, otherwise show User menu */}
|
||||
|
@ -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 (
|
||||
<div className="bg-chalkboard-10 dark:bg-chalkboard-90 p-4 rounded-md shadow-lg max-w-md">
|
||||
<div className="font-bold mb-2">Replace current code?</div>
|
||||
<div className="mb-3">
|
||||
Do you want to replace your current code with this sample?
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="bg-primary text-white px-3 py-1 rounded"
|
||||
onClick={() => {
|
||||
codeManager.updateCodeEditor(code, true)
|
||||
kclManager.executeCode().catch((err) => {
|
||||
console.error('Error executing code:', err)
|
||||
})
|
||||
toast.dismiss('code-replace-toast')
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
className="bg-chalkboard-20 dark:bg-chalkboard-80 px-3 py-1 rounded"
|
||||
onClick={() => toast.dismiss('code-replace-toast')}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function showCodeReplaceToast(code: string) {
|
||||
// Create a persistent toast that doesn't auto-dismiss
|
||||
return toast.custom(() => <CodeReplaceToast code={code} />, {
|
||||
id: 'code-replace-toast',
|
||||
duration: Infinity, // Won't auto dismiss
|
||||
position: 'top-center',
|
||||
style: {
|
||||
zIndex: 9999, // Ensure it's above other elements
|
||||
},
|
||||
})
|
||||
}
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
50
src/components/ToastQuestion.tsx
Normal file
50
src/components/ToastQuestion.tsx
Normal file
@ -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 (
|
||||
<div className="bg-chalkboard-10 dark:bg-chalkboard-90 p-4 rounded-md shadow-lg max-w-md">
|
||||
<div className="font-bold mb-2">Question</div>
|
||||
<div className="mb-3">{props.question}</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="bg-chalkboard-20 dark:bg-chalkboard-80 px-3 py-1 rounded"
|
||||
onClick={() => {
|
||||
toast.dismiss(TOAST_ID)
|
||||
props.onYes()
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
className="bg-chalkboard-20 dark:bg-chalkboard-80 px-3 py-1 rounded"
|
||||
onClick={() => {
|
||||
toast.dismiss(TOAST_ID)
|
||||
props.onNo()
|
||||
}}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function askQuestionPrompt(props: ToastQuestionProps) {
|
||||
// Create a persistent toast that doesn't auto-dismiss
|
||||
return toast.custom(() => <ToastQuestion {...props} />, {
|
||||
id: TOAST_ID,
|
||||
duration: Infinity, // Won't auto dismiss
|
||||
position: 'top-center',
|
||||
style: {
|
||||
zIndex: 9999, // Ensure it's above other elements
|
||||
},
|
||||
})
|
||||
}
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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'
|
||||
|
Reference in New Issue
Block a user