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:
Zookeeper Lee
2025-06-16 22:52:13 -04:00
committed by GitHub
parent 7486d25cf1
commit acb43fc82c
14 changed files with 245 additions and 105 deletions

View File

@ -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

View 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')
})
}
)
})

View File

@ -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>
)
}

View File

@ -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 */}

View File

@ -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
},
})
}

View File

@ -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 })
}
}

View File

@ -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

View 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
},
})
}

View File

@ -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)

View File

@ -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) {

View File

@ -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'