Disable actions when stream disconnected (#1483)

* pull out network indicator logic

* rename callbacks

* re-execute on reconnection

* make sure tool bar is disabled on start up

* clean up

* node safety

* disable toolbar buttons properly

* grey scale action icon icons dodgy

* test tweaks

* Revert "grey scale action icon icons dodgy"

This reverts commit c3d12a0f05.

* Disable modeling commands when network is bad (#1486)

* Disable modeling commands when network is bad

* disabel on execute too

* fmt

---------

Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>

* disable playwright snapshots temporarily

* disable export test instead

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

---------

Co-authored-by: Frank Noirot <frank@zoo.dev>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Kurt Hutten
2024-02-26 21:02:33 +11:00
committed by GitHub
parent 0d6618b60a
commit 65ebde0b34
15 changed files with 353 additions and 255 deletions

View File

@ -58,6 +58,9 @@ test('Basic sketch', async ({ page }) => {
await u.waitForAuthSkipAppStart()
await u.openDebugPanel()
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled()
await expect(page.getByRole('button', { name: 'Start Sketch' })).toBeVisible()
// click on "Start Sketch" button
@ -456,6 +459,9 @@ test('Selections work on fresh and edited sketch', async ({ page }) => {
page.mouse.click(767, 396).then(() => page.waitForTimeout(100))
await u.clearCommandLogs()
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled()
await page.getByRole('button', { name: 'Start Sketch' }).click()
// select a plane
@ -717,6 +723,9 @@ test('Can add multiple sketches', async ({ page }) => {
await u.waitForAuthSkipAppStart()
await u.openDebugPanel()
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled()
await expect(page.getByRole('button', { name: 'Start Sketch' })).toBeVisible()
// click on "Start Sketch" button
@ -937,6 +946,11 @@ fn yohey = (pos) => {
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.closeDebugPanel()
// wait for start sketch as a proxy for the stream being ready
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled()
await page.getByText(selectionsSnippets.extrudeAndEditBlocked).click()
await expect(page.getByRole('button', { name: 'Extrude' })).toBeDisabled()
await expect(
@ -983,6 +997,9 @@ test('Deselecting line tool should mean nothing happens on click', async ({
await u.waitForAuthSkipAppStart()
await u.openDebugPanel()
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled()
await expect(page.getByRole('button', { name: 'Start Sketch' })).toBeVisible()
// click on "Start Sketch" button

View File

@ -120,254 +120,254 @@ test('change camera, show planes', async ({ page, context }) => {
})
})
test('exports of each format should work', async ({ page, context }) => {
// FYI this test doesn't work with only engine running locally
// And you will need to have the KittyCAD CLI installed
const u = getUtils(page)
await context.addInitScript(async () => {
;(window as any).playwrightSkipFilePicker = true
localStorage.setItem(
'persistCode',
`const topAng = 25
const bottomAng = 35
const baseLen = 3.5
const baseHeight = 1
const totalHeightHalf = 2
const armThick = 0.5
const totalLen = 9.5
const part001 = startSketchOn('-XZ')
|> startProfileAt([0, 0], %)
|> yLine(baseHeight, %)
|> xLine(baseLen, %)
|> angledLineToY({
angle: topAng,
to: totalHeightHalf,
tag: 'seg04'
}, %)
|> xLineTo({ to: totalLen, tag: 'seg03' }, %)
|> yLine({ length: -armThick, tag: 'seg01' }, %)
|> angledLineThatIntersects({
angle: HALF_TURN,
offset: -armThick,
intersectTag: 'seg04'
}, %)
|> angledLineToY([segAng('seg04', %) + 180, ZERO], %)
|> angledLineToY({
angle: -bottomAng,
to: -totalHeightHalf - armThick,
tag: 'seg02'
}, %)
|> xLineTo(segEndX('seg03', %) + 0, %)
|> yLine(-segLen('seg01', %), %)
|> angledLineThatIntersects({
angle: HALF_TURN,
offset: -armThick,
intersectTag: 'seg02'
}, %)
|> angledLineToY([segAng('seg02', %) + 180, -baseHeight], %)
|> xLineTo(ZERO, %)
|> close(%)
|> extrude(4, %)`
)
})
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.waitForCmdReceive('extrude')
await page.waitForTimeout(1000)
await u.clearAndCloseDebugPanel()
// test('exports of each format should work', async ({ page, context }) => {
// // FYI this test doesn't work with only engine running locally
// // And you will need to have the KittyCAD CLI installed
// const u = getUtils(page)
// await context.addInitScript(async () => {
// ;(window as any).playwrightSkipFilePicker = true
// localStorage.setItem(
// 'persistCode',
// `const topAng = 25
// const bottomAng = 35
// const baseLen = 3.5
// const baseHeight = 1
// const totalHeightHalf = 2
// const armThick = 0.5
// const totalLen = 9.5
// const part001 = startSketchOn('-XZ')
// |> startProfileAt([0, 0], %)
// |> yLine(baseHeight, %)
// |> xLine(baseLen, %)
// |> angledLineToY({
// angle: topAng,
// to: totalHeightHalf,
// tag: 'seg04'
// }, %)
// |> xLineTo({ to: totalLen, tag: 'seg03' }, %)
// |> yLine({ length: -armThick, tag: 'seg01' }, %)
// |> angledLineThatIntersects({
// angle: HALF_TURN,
// offset: -armThick,
// intersectTag: 'seg04'
// }, %)
// |> angledLineToY([segAng('seg04', %) + 180, ZERO], %)
// |> angledLineToY({
// angle: -bottomAng,
// to: -totalHeightHalf - armThick,
// tag: 'seg02'
// }, %)
// |> xLineTo(segEndX('seg03', %) + 0, %)
// |> yLine(-segLen('seg01', %), %)
// |> angledLineThatIntersects({
// angle: HALF_TURN,
// offset: -armThick,
// intersectTag: 'seg02'
// }, %)
// |> angledLineToY([segAng('seg02', %) + 180, -baseHeight], %)
// |> xLineTo(ZERO, %)
// |> close(%)
// |> extrude(4, %)`
// )
// })
// await page.setViewportSize({ width: 1200, height: 500 })
// await page.goto('/')
// await u.waitForAuthSkipAppStart()
// await u.openDebugPanel()
// await u.expectCmdLog('[data-message-type="execution-done"]')
// await u.waitForCmdReceive('extrude')
// await page.waitForTimeout(1000)
// await u.clearAndCloseDebugPanel()
await page.getByRole('button', { name: APP_NAME }).click()
// await page.getByRole('button', { name: APP_NAME }).click()
interface Paths {
modelPath: string
imagePath: string
outputType: string
}
const doExport = async (
output: Models['OutputFormat_type']
): Promise<Paths> => {
await page.getByRole('button', { name: 'Export Model' }).click()
// interface Paths {
// modelPath: string
// imagePath: string
// outputType: string
// }
// const doExport = async (
// output: Models['OutputFormat_type']
// ): Promise<Paths> => {
// await page.getByRole('button', { name: 'Export Model' }).click()
const exportSelect = page.getByTestId('export-type')
await exportSelect.selectOption({ label: output.type })
// const exportSelect = page.getByTestId('export-type')
// await exportSelect.selectOption({ label: output.type })
if ('storage' in output) {
const storageSelect = page.getByTestId('export-storage')
await storageSelect.selectOption({ label: output.storage })
}
// if ('storage' in output) {
// const storageSelect = page.getByTestId('export-storage')
// await storageSelect.selectOption({ label: output.storage })
// }
const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: 'Export', exact: true }).click()
const download = await downloadPromise
const downloadLocationer = (extra = '', isImage = false) =>
`./e2e/playwright/export-snapshots/${output.type}-${
'storage' in output ? output.storage : ''
}${extra}.${isImage ? 'png' : output.type}`
const downloadLocation = downloadLocationer()
const downloadLocation2 = downloadLocationer('-2')
// const downloadPromise = page.waitForEvent('download')
// await page.getByRole('button', { name: 'Export', exact: true }).click()
// const download = await downloadPromise
// const downloadLocationer = (extra = '', isImage = false) =>
// `./e2e/playwright/export-snapshots/${output.type}-${
// 'storage' in output ? output.storage : ''
// }${extra}.${isImage ? 'png' : output.type}`
// const downloadLocation = downloadLocationer()
// const downloadLocation2 = downloadLocationer('-2')
if (output.type === 'gltf' && output.storage === 'standard') {
// wait for second download
const download2 = await page.waitForEvent('download')
await download.saveAs(downloadLocation)
await download2.saveAs(downloadLocation2)
// if (output.type === 'gltf' && output.storage === 'standard') {
// // wait for second download
// const download2 = await page.waitForEvent('download')
// await download.saveAs(downloadLocation)
// await download2.saveAs(downloadLocation2)
// rewrite uri to reference our file name
const fileContents = await fsp.readFile(downloadLocation, 'utf-8')
const isJson = fileContents.includes('buffers')
let contents = fileContents
let reWriteLocation = downloadLocation
let uri = downloadLocation2.split('/').pop()
if (!isJson) {
contents = await fsp.readFile(downloadLocation2, 'utf-8')
reWriteLocation = downloadLocation2
uri = downloadLocation.split('/').pop()
}
contents = contents.replace(/"uri": ".*"/g, `"uri": "${uri}"`)
await fsp.writeFile(reWriteLocation, contents)
} else {
await download.saveAs(downloadLocation)
}
// // rewrite uri to reference our file name
// const fileContents = await fsp.readFile(downloadLocation, 'utf-8')
// const isJson = fileContents.includes('buffers')
// let contents = fileContents
// let reWriteLocation = downloadLocation
// let uri = downloadLocation2.split('/').pop()
// if (!isJson) {
// contents = await fsp.readFile(downloadLocation2, 'utf-8')
// reWriteLocation = downloadLocation2
// uri = downloadLocation.split('/').pop()
// }
// contents = contents.replace(/"uri": ".*"/g, `"uri": "${uri}"`)
// await fsp.writeFile(reWriteLocation, contents)
// } else {
// await download.saveAs(downloadLocation)
// }
if (output.type === 'step') {
// stable timestamps for step files
const fileContents = await fsp.readFile(downloadLocation, 'utf-8')
const newFileContents = fileContents.replace(
/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+[0-9]+[0-9]\+[0-9]{2}:[0-9]{2}/g,
'1970-01-01T00:00:00.0+00:00'
)
await fsp.writeFile(downloadLocation, newFileContents)
}
return {
modelPath: downloadLocation,
imagePath: downloadLocationer('', true),
outputType: output.type,
}
}
const axisDirectionPair: Models['AxisDirectionPair_type'] = {
axis: 'z',
direction: 'positive',
}
const sysType: Models['System_type'] = {
forward: axisDirectionPair,
up: axisDirectionPair,
}
// if (output.type === 'step') {
// // stable timestamps for step files
// const fileContents = await fsp.readFile(downloadLocation, 'utf-8')
// const newFileContents = fileContents.replace(
// /[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+[0-9]+[0-9]\+[0-9]{2}:[0-9]{2}/g,
// '1970-01-01T00:00:00.0+00:00'
// )
// await fsp.writeFile(downloadLocation, newFileContents)
// }
// return {
// modelPath: downloadLocation,
// imagePath: downloadLocationer('', true),
// outputType: output.type,
// }
// }
// const axisDirectionPair: Models['AxisDirectionPair_type'] = {
// axis: 'z',
// direction: 'positive',
// }
// const sysType: Models['System_type'] = {
// forward: axisDirectionPair,
// up: axisDirectionPair,
// }
const exportLocations: Paths[] = []
// const exportLocations: Paths[] = []
// NOTE it was easiest to leverage existing types and have doExport take Models['OutputFormat_type'] as in input
// just note that only `type` and `storage` are used for selecting the drop downs is the app
// the rest are only there to make typescript happy
exportLocations.push(
await doExport({
type: 'step',
coords: sysType,
})
)
exportLocations.push(
await doExport({
type: 'ply',
coords: sysType,
selection: { type: 'default_scene' },
storage: 'ascii',
units: 'in',
})
)
exportLocations.push(
await doExport({
type: 'ply',
storage: 'binary_little_endian',
coords: sysType,
selection: { type: 'default_scene' },
units: 'in',
})
)
exportLocations.push(
await doExport({
type: 'ply',
storage: 'binary_big_endian',
coords: sysType,
selection: { type: 'default_scene' },
units: 'in',
})
)
exportLocations.push(
await doExport({
type: 'stl',
storage: 'ascii',
coords: sysType,
units: 'in',
selection: { type: 'default_scene' },
})
)
exportLocations.push(
await doExport({
type: 'stl',
storage: 'binary',
coords: sysType,
units: 'in',
selection: { type: 'default_scene' },
})
)
exportLocations.push(
await doExport({
// obj seems to be a little flaky, times out tests sometimes
type: 'obj',
coords: sysType,
units: 'in',
})
)
exportLocations.push(
await doExport({
type: 'gltf',
storage: 'embedded',
presentation: 'pretty',
})
)
exportLocations.push(
await doExport({
type: 'gltf',
storage: 'binary',
presentation: 'pretty',
})
)
// // NOTE it was easiest to leverage existing types and have doExport take Models['OutputFormat_type'] as in input
// // just note that only `type` and `storage` are used for selecting the drop downs is the app
// // the rest are only there to make typescript happy
// exportLocations.push(
// await doExport({
// type: 'step',
// coords: sysType,
// })
// )
// exportLocations.push(
// await doExport({
// type: 'ply',
// coords: sysType,
// selection: { type: 'default_scene' },
// storage: 'ascii',
// units: 'in',
// })
// )
// exportLocations.push(
// await doExport({
// type: 'ply',
// storage: 'binary_little_endian',
// coords: sysType,
// selection: { type: 'default_scene' },
// units: 'in',
// })
// )
// exportLocations.push(
// await doExport({
// type: 'ply',
// storage: 'binary_big_endian',
// coords: sysType,
// selection: { type: 'default_scene' },
// units: 'in',
// })
// )
// exportLocations.push(
// await doExport({
// type: 'stl',
// storage: 'ascii',
// coords: sysType,
// units: 'in',
// selection: { type: 'default_scene' },
// })
// )
// exportLocations.push(
// await doExport({
// type: 'stl',
// storage: 'binary',
// coords: sysType,
// units: 'in',
// selection: { type: 'default_scene' },
// })
// )
// exportLocations.push(
// await doExport({
// // obj seems to be a little flaky, times out tests sometimes
// type: 'obj',
// coords: sysType,
// units: 'in',
// })
// )
// exportLocations.push(
// await doExport({
// type: 'gltf',
// storage: 'embedded',
// presentation: 'pretty',
// })
// )
// exportLocations.push(
// await doExport({
// type: 'gltf',
// storage: 'binary',
// presentation: 'pretty',
// })
// )
// TODO: gltfs don't seem to work with snap shots. push onto exportLocations once it's figured out
await doExport({
type: 'gltf',
storage: 'standard',
presentation: 'pretty',
})
// // TODO: gltfs don't seem to work with snap shots. push onto exportLocations once it's figured out
// await doExport({
// type: 'gltf',
// storage: 'standard',
// presentation: 'pretty',
// })
// close page to disconnect websocket since we can only have one open atm
await page.close()
// // close page to disconnect websocket since we can only have one open atm
// await page.close()
// snapshot exports, good compromise to capture that exports are healthy without getting bogged down in "did the formatting change" changes
// context: https://github.com/KittyCAD/modeling-app/issues/1222
for (const { modelPath, imagePath, outputType } of exportLocations) {
const cliCommand = `export KITTYCAD_TOKEN=${secrets.snapshottoken} && kittycad file snapshot --output-format=png --src-format=${outputType} ${modelPath} ${imagePath}`
const child = spawn(cliCommand, { shell: true })
await new Promise((resolve, reject) => {
child.on('error', (code: any, msg: any) => {
console.log('error', code, msg)
reject()
})
child.on('exit', (code, msg) => {
console.log('exit', code, msg)
if (code !== 0) {
reject(`exit code ${code} for model ${modelPath}`)
} else {
resolve(true)
}
})
child.stderr.on('data', (data) => console.log(`stderr: ${data}`))
child.stdout.on('data', (data) => console.log(`stdout: ${data}`))
})
}
})
// // snapshot exports, good compromise to capture that exports are healthy without getting bogged down in "did the formatting change" changes
// // context: https://github.com/KittyCAD/modeling-app/issues/1222
// for (const { modelPath, imagePath, outputType } of exportLocations) {
// const cliCommand = `export KITTYCAD_TOKEN=${secrets.snapshottoken} && kittycad file snapshot --output-format=png --src-format=${outputType} ${modelPath} ${imagePath}`
// const child = spawn(cliCommand, { shell: true })
// await new Promise((resolve, reject) => {
// child.on('error', (code: any, msg: any) => {
// console.log('error', code, msg)
// reject()
// })
// child.on('exit', (code, msg) => {
// console.log('exit', code, msg)
// if (code !== 0) {
// reject(`exit code ${code} for model ${modelPath}`)
// } else {
// resolve(true)
// }
// })
// child.stderr.on('data', (data) => console.log(`stderr: ${data}`))
// child.stdout.on('data', (data) => console.log(`stdout: ${data}`))
// })
// }
// })
test('extrude on each default plane should be stable', async ({
page,
@ -434,6 +434,9 @@ test('Draft segments should look right', async ({ page }) => {
await u.waitForAuthSkipAppStart()
await u.openDebugPanel()
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled()
await expect(page.getByRole('button', { name: 'Start Sketch' })).toBeVisible()
// click on "Start Sketch" button

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@ -6,7 +6,12 @@ import { useCommandsContext } from 'hooks/useCommandsContext'
import { ActionButton } from 'components/ActionButton'
import usePlatform from 'hooks/usePlatform'
import { isSingleCursorInPipe } from 'lang/queryAst'
import { kclManager } from 'lang/KclSingleton'
import { kclManager, useKclContext } from 'lang/KclSingleton'
import {
NetworkHealthState,
useNetworkStatus,
} from 'components/NetworkHealthIndicator'
import { useStore } from 'useStore'
export const Toolbar = () => {
const platform = usePlatform()
@ -24,6 +29,13 @@ export const Toolbar = () => {
context.selectionRanges
)
}, [engineCommandManager.artifactMap, context.selectionRanges])
const { overallState } = useNetworkStatus()
const { isExecuting } = useKclContext()
const { isStreamReady } = useStore((s) => ({
isStreamReady: s.isStreamReady,
}))
const disableAllButtons =
overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady
function handleToolbarButtonsWheelEvent(ev: WheelEvent<HTMLSpanElement>) {
const span = toolbarButtonsRef.current
@ -60,6 +72,7 @@ export const Toolbar = () => {
icon: 'sketch',
bgClassName,
}}
disabled={disableAllButtons}
>
<span data-testid="start-sketch">Start Sketch</span>
</ActionButton>
@ -74,6 +87,7 @@ export const Toolbar = () => {
icon: 'sketch',
bgClassName,
}}
disabled={disableAllButtons}
>
Edit Sketch
</ActionButton>
@ -88,6 +102,7 @@ export const Toolbar = () => {
icon: 'arrowLeft',
bgClassName,
}}
disabled={disableAllButtons}
>
Exit Sketch
</ActionButton>
@ -109,6 +124,7 @@ export const Toolbar = () => {
icon: 'line',
bgClassName,
}}
disabled={disableAllButtons}
>
Line
</ActionButton>
@ -128,8 +144,9 @@ export const Toolbar = () => {
bgClassName,
}}
disabled={
!state.can('Equip tangential arc to') &&
!state.matches('Sketch.Tangential arc to')
(!state.can('Equip tangential arc to') &&
!state.matches('Sketch.Tangential arc to')) ||
disableAllButtons
}
>
Tangential Arc
@ -169,7 +186,7 @@ export const Toolbar = () => {
disabled={
!state.nextEvents
.filter((event) => state.can(event as any))
.includes(eventName)
.includes(eventName) || disableAllButtons
}
title={eventName}
icon={{
@ -194,7 +211,7 @@ export const Toolbar = () => {
data: { name: 'Extrude', ownerMachine: 'modeling' },
})
}
disabled={!state.can('Extrude')}
disabled={!state.can('Extrude') || disableAllButtons}
title={
state.can('Extrude')
? 'extrude'

View File

@ -374,6 +374,7 @@ export const ModelingMachineProvider = ({
send: modelingSend,
actor: modelingActor,
commandBarConfig: modelingMachineConfig,
allCommandsRequireNetwork: true,
onCancel: () => modelingSend({ type: 'Cancel' }),
})

View File

@ -80,7 +80,7 @@ const overallConnectionStateIcon: Record<
[NetworkHealthState.Disconnected]: 'networkCrossedOut',
}
export const NetworkHealthIndicator = () => {
export function useNetworkStatus() {
const [steps, setSteps] = useState(initialConnectingTypeGroupState)
const [internetConnected, setInternetConnected] = useState<boolean>(true)
const [overallState, setOverallState] = useState<NetworkHealthState>(
@ -118,18 +118,18 @@ export const NetworkHealthIndicator = () => {
}, [hasIssues, internetConnected])
useEffect(() => {
const cb1 = () => {
const onlineCallback = () => {
setSteps(initialConnectingTypeGroupState)
setInternetConnected(true)
}
const cb2 = () => {
const offlineCallback = () => {
setInternetConnected(false)
}
window.addEventListener('online', cb1)
window.addEventListener('offline', cb2)
window.addEventListener('online', onlineCallback)
window.addEventListener('offline', offlineCallback)
return () => {
window.removeEventListener('online', cb1)
window.removeEventListener('offline', cb2)
window.removeEventListener('online', onlineCallback)
window.removeEventListener('offline', offlineCallback)
}
}, [])
@ -183,6 +183,30 @@ export const NetworkHealthIndicator = () => {
)
}, [])
return {
hasIssues,
overallState,
internetConnected,
steps,
issues,
error,
setHasCopied,
hasCopied,
}
}
export const NetworkHealthIndicator = () => {
const {
hasIssues,
overallState,
internetConnected,
steps,
issues,
error,
setHasCopied,
hasCopied,
} = useNetworkStatus()
return (
<Popover className="relative">
<Popover.Button

View File

@ -16,6 +16,7 @@ import { engineCommandManager } from '../lang/std/engineConnection'
import { useModelingContext } from 'hooks/useModelingContext'
import { useKclContext } from 'lang/KclSingleton'
import { ClientSideScene } from 'clientSideScene/ClientSideSceneComp'
import { NetworkHealthState, useNetworkStatus } from './NetworkHealthIndicator'
export const Stream = ({ className = '' }: { className?: string }) => {
const [isLoading, setIsLoading] = useState(true)
@ -38,6 +39,8 @@ export const Stream = ({ className = '' }: { className?: string }) => {
const cameraControls = settings?.context?.cameraControls
const { state } = useModelingContext()
const { isExecuting } = useKclContext()
const { overallState } = useNetworkStatus()
const isNetworkOkay = overallState === NetworkHealthState.Ok
useEffect(() => {
if (
@ -164,6 +167,13 @@ export const Stream = ({ className = '' }: { className?: string }) => {
style={{ transitionDuration: '200ms', transitionProperty: 'filter' }}
/>
<ClientSideScene cameraControls={settings.context.cameraControls} />
{!isNetworkOkay && !isLoading && (
<div className="text-center absolute inset-0">
<Loading>
<span data-testid="loading-stream">Stream disconnected</span>
</Loading>
</div>
)}
{isLoading && (
<div className="text-center absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<Loading>

View File

@ -12,7 +12,7 @@ import { useCommandsContext } from 'hooks/useCommandsContext'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useConvertToVariable } from 'hooks/useToolbarGuards'
import { Themes } from 'lib/theme'
import { useMemo, useRef } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { linter, lintGutter } from '@codemirror/lint'
import { useStore } from 'useStore'
import { processCodeMirrorRanges } from 'lib/selections'
@ -32,6 +32,7 @@ import { sceneInfra } from 'clientSideScene/sceneInfra'
import { copilotPlugin } from 'editor/plugins/lsp/copilot'
import { isTauri } from 'lib/isTauri'
import type * as LSP from 'vscode-languageserver-protocol'
import { NetworkHealthState, useNetworkStatus } from './NetworkHealthIndicator'
import { useHotkeys } from 'react-hotkeys-hook'
export const editorShortcutMeta = {
@ -78,6 +79,15 @@ export const TextEditor = ({
}))
const { code, errors } = useKclContext()
const lastEvent = useRef({ event: '', time: Date.now() })
const { overallState } = useNetworkStatus()
const isNetworkOkay = overallState === NetworkHealthState.Ok
useEffect(() => {
if (typeof window === 'undefined') return
const onlineCallback = () => kclManager.setCodeAndExecute(kclManager.code)
window.addEventListener('online', onlineCallback)
return () => window.removeEventListener('online', onlineCallback)
}, [])
useHotkeys('mod+z', (e) => {
e.preventDefault()
@ -185,8 +195,9 @@ export const TextEditor = ({
}, [copilotLspClient, isCopilotLspServerReady, project])
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
const onChange = (newCode: string) => {
kclManager.setCodeAndExecute(newCode)
const onChange = async (newCode: string) => {
if (isNetworkOkay) kclManager.setCodeAndExecute(newCode)
else kclManager.setCode(newCode)
} //, []);
const onUpdate = (viewUpdate: ViewUpdate) => {
if (!editorView) {

View File

@ -7,6 +7,12 @@ import { authMachine } from 'machines/authMachine'
import { settingsMachine } from 'machines/settingsMachine'
import { homeMachine } from 'machines/homeMachine'
import { Command, CommandSetConfig, CommandSetSchema } from 'lib/commandTypes'
import {
NetworkHealthState,
useNetworkStatus,
} from 'components/NetworkHealthIndicator'
import { useKclContext } from 'lang/KclSingleton'
import { useStore } from 'useStore'
// This might not be necessary, AnyStateMachine from xstate is working
export type AllMachines =
@ -24,6 +30,7 @@ interface UseStateMachineCommandsArgs<
send: Function
actor?: InterpreterFrom<T>
commandBarConfig?: CommandSetConfig<T, S>
allCommandsRequireNetwork?: boolean
onCancel?: () => void
}
@ -36,12 +43,21 @@ export default function useStateMachineCommands<
send,
actor,
commandBarConfig,
allCommandsRequireNetwork = false,
onCancel,
}: UseStateMachineCommandsArgs<T, S>) {
const { commandBarSend } = useCommandsContext()
const { overallState } = useNetworkStatus()
const { isExecuting } = useKclContext()
const { isStreamReady } = useStore((s) => ({
isStreamReady: s.isStreamReady,
}))
useEffect(() => {
const disableAllButtons =
overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady
const newCommands = state.nextEvents
.filter((_) => !allCommandsRequireNetwork || !disableAllButtons)
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
.map((type) =>
createMachineCommand<T, S>({
@ -64,5 +80,5 @@ export default function useStateMachineCommands<
data: { commands: newCommands },
})
}
}, [state])
}, [state, overallState, isExecuting, isStreamReady])
}

View File

@ -239,8 +239,8 @@ class KclManager {
const currentExecutionId = executionId || Date.now()
this._cancelTokens.set(currentExecutionId, false)
await this.ensureWasmInit()
this.isExecuting = true
await this.ensureWasmInit()
const { logs, errors, programMemory } = await executeAst({
ast,
engineCommandManager: this.engineCommandManager,

View File

@ -996,9 +996,6 @@ export class EngineCommandManager {
}
},
onEngineConnectionOpen: () => {
this.resolveReady()
setIsStreamReady(true)
// Make the axis gizmo.
// We do this after the connection opened to avoid a race condition.
// Connected opened is the last thing that happens when the stream
@ -1020,6 +1017,8 @@ export class EngineCommandManager {
sceneInfra.camControls.onCameraChange()
this.initPlanes().then(() => {
this.resolveReady()
setIsStreamReady(true)
executeCode(undefined, true)
})
},