Replace lower-right controls with a full status bar (#7443)
* Resurrect this branch with an initial commit * Add telemetry to global default items * Add credit progress bar to status bar * Add selection info to status bar * Add help menu to "local" side * Rename statusBarItem utils * Delete LowerRightControls, now unused * fix lints * Update snapshots * Add test-id to network health indicator, which all E2E tests rely on * Update src/components/StatusBar/StatusBar.tsx Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * Fix a couple little issues with the e2e tests * Fix gizmo e2e tests (it moved) and network health test (it should not be on home page) * More tweaks to accomodate changes to network health indicator We made use of two test ID's to make Playwright aware of the state, one of which was on the icon. Now that we want to normalize usage of the status bar along a more limited API, that became a not possible. This just tweaks some test code that relied on that fact. * Fix lints * Update snapshots * Re-run CI * Update snapshots * Update snapshots * Test fixes, label logic tweaks * Update snapshots * Update snapshots * Fix up last few tests hopefully. Relative path syntax failed on windows * Relative paths are behaving badly on Windows, use the old code here * Update snapshots * Update snapshots * Tweak y-value to work on all platforms, ubuntu didn't like 438 * Fix tooltip and popover alignment on NetworkMachineIndicator * Remove dire warning comment * Update src/components/StatusBar/defaultStatusBarItems.tsx Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * @lee-at-zoo-corp feedback, pull hooks out of UI code * Re-run CI --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
@ -575,7 +575,7 @@ extrude002 = extrude(profile002, length = 150)
|
||||
name: 'Projects',
|
||||
})
|
||||
const projectLink = page.getByRole('link', { name: 'bracket' })
|
||||
const networkHealthIndicator = page.getByTestId('network-toggle')
|
||||
const networkHealthIndicator = page.getByTestId(/network-toggle/)
|
||||
|
||||
await test.step('Check the home page', async () => {
|
||||
await expect(projectsHeading).toBeVisible()
|
||||
|
@ -798,7 +798,7 @@ test('theme persists', async ({ page, context, homePage }) => {
|
||||
|
||||
await page.getByTestId('settings-close-button').click()
|
||||
|
||||
const networkToggle = page.getByTestId('network-toggle')
|
||||
const networkToggle = page.getByTestId(/network-toggle/)
|
||||
|
||||
// simulate network down
|
||||
await u.emulateNetworkConditions({
|
||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 55 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 74 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 134 KiB |
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 134 KiB |
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 67 KiB |
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 70 KiB |
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 66 KiB |
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
@ -24,16 +24,15 @@ test.describe('Test network related behaviors', () => {
|
||||
|
||||
await homePage.goToModelingScene()
|
||||
|
||||
const networkToggle = page.getByTestId('network-toggle')
|
||||
const networkToggle = page.getByTestId(/network-toggle/)
|
||||
|
||||
// This is how we wait until the stream is online
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Start Sketch' })
|
||||
).not.toBeDisabled({ timeout: 15000 })
|
||||
|
||||
const networkWidget = page.locator('[data-testid="network-toggle"]')
|
||||
await expect(networkWidget).toBeVisible()
|
||||
await networkWidget.hover()
|
||||
await expect(networkToggle).toBeVisible()
|
||||
await networkToggle.hover()
|
||||
|
||||
const networkPopover = page.locator('[data-testid="network-popover"]')
|
||||
await expect(networkPopover).not.toBeVisible()
|
||||
@ -44,7 +43,7 @@ test.describe('Test network related behaviors', () => {
|
||||
).toBeVisible()
|
||||
|
||||
// Click the network widget
|
||||
await networkWidget.click()
|
||||
await networkToggle.click()
|
||||
|
||||
// Check the modal opened.
|
||||
await expect(networkPopover).toBeVisible()
|
||||
@ -65,8 +64,8 @@ test.describe('Test network related behaviors', () => {
|
||||
// Expect the network to be down
|
||||
await expect(networkToggle).toContainText('Network health (Offline)')
|
||||
|
||||
// Click the network widget
|
||||
await networkWidget.click()
|
||||
// Click the network toggle
|
||||
await networkToggle.click()
|
||||
|
||||
// Check the modal opened.
|
||||
await expect(networkPopover).toBeVisible()
|
||||
@ -99,7 +98,7 @@ test.describe('Test network related behaviors', () => {
|
||||
'Engine disconnect & reconnect in sketch mode',
|
||||
{ tag: '@skipLocalEngine' },
|
||||
async ({ page, homePage, toolbar, scene, cmdBar }) => {
|
||||
const networkToggle = page.getByTestId('network-toggle')
|
||||
const networkToggle = page.getByTestId(/network-toggle/)
|
||||
const networkToggleConnectedText = page.getByText(
|
||||
'Network health (Strong)'
|
||||
)
|
||||
@ -286,7 +285,7 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34])
|
||||
'Paused stream freezes view frame, unpause reconnect is seamless to user',
|
||||
{ tag: ['@desktop', '@skipLocalEngine'] },
|
||||
async ({ page, homePage, scene, cmdBar, toolbar, tronApp }) => {
|
||||
const networkToggle = page.getByTestId('network-toggle')
|
||||
const networkToggle = page.getByTestId(/network-toggle/)
|
||||
const networkToggleConnectedText = page.getByText(
|
||||
'Network health (Strong)'
|
||||
)
|
||||
|
@ -37,7 +37,7 @@ export const headerMasks = (page: Page) => [
|
||||
]
|
||||
|
||||
export const lowerRightMasks = (page: Page) => [
|
||||
page.getByTestId('network-toggle'),
|
||||
page.getByTestId(/network-toggle/),
|
||||
page.getByTestId('billing-remaining-bar'),
|
||||
]
|
||||
|
||||
|
@ -8,37 +8,37 @@ test.describe('Testing Gizmo', () => {
|
||||
const cases = [
|
||||
{
|
||||
testDescription: 'top view',
|
||||
clickPosition: { x: 951, y: 385 },
|
||||
clickPosition: { x: 951, y: 402 },
|
||||
expectedCameraPosition: { x: 800, y: -152, z: 4886.02 },
|
||||
expectedCameraTarget: { x: 800, y: -152, z: 26 },
|
||||
},
|
||||
{
|
||||
testDescription: 'bottom view',
|
||||
clickPosition: { x: 951, y: 429 },
|
||||
clickPosition: { x: 951, y: 449 },
|
||||
expectedCameraPosition: { x: 800, y: -152, z: -4834.02 },
|
||||
expectedCameraTarget: { x: 800, y: -152, z: 26 },
|
||||
},
|
||||
{
|
||||
testDescription: 'right view',
|
||||
clickPosition: { x: 929, y: 417 },
|
||||
clickPosition: { x: 929, y: 435 },
|
||||
expectedCameraPosition: { x: 5660.02, y: -152, z: 26 },
|
||||
expectedCameraTarget: { x: 800, y: -152, z: 26 },
|
||||
},
|
||||
{
|
||||
testDescription: 'left view',
|
||||
clickPosition: { x: 974, y: 397 },
|
||||
clickPosition: { x: 974, y: 417 },
|
||||
expectedCameraPosition: { x: -4060.02, y: -152, z: 26 },
|
||||
expectedCameraTarget: { x: 800, y: -152, z: 26 },
|
||||
},
|
||||
{
|
||||
testDescription: 'back view',
|
||||
clickPosition: { x: 967, y: 421 },
|
||||
clickPosition: { x: 967, y: 441 },
|
||||
expectedCameraPosition: { x: 800, y: 4708.02, z: 26 },
|
||||
expectedCameraTarget: { x: 800, y: -152, z: 26 },
|
||||
},
|
||||
{
|
||||
testDescription: 'front view',
|
||||
clickPosition: { x: 935, y: 393 },
|
||||
clickPosition: { x: 935, y: 413 },
|
||||
expectedCameraPosition: { x: 800, y: -5012.02, z: 26 },
|
||||
expectedCameraTarget: { x: 800, y: -152, z: 26 },
|
||||
},
|
||||
|
93
src/App.tsx
@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import ModalContainer from 'react-modal-promise'
|
||||
@ -12,7 +12,6 @@ import {
|
||||
import { AppHeader } from '@src/components/AppHeader'
|
||||
import { EngineStream } from '@src/components/EngineStream'
|
||||
import Gizmo from '@src/components/Gizmo'
|
||||
import { LowerRightControls } from '@src/components/LowerRightControls'
|
||||
import { useLspContext } from '@src/components/LspProvider'
|
||||
import { ModelingSidebar } from '@src/components/ModelingSidebar/ModelingSidebar'
|
||||
import { UnitsMenu } from '@src/components/UnitsMenu'
|
||||
@ -29,6 +28,7 @@ import {
|
||||
codeManager,
|
||||
kclManager,
|
||||
settingsActor,
|
||||
getSettings,
|
||||
} from '@src/lib/singletons'
|
||||
import { maybeWriteToDisk } from '@src/lib/telemetry'
|
||||
import type { IndexLoaderData } from '@src/lib/types'
|
||||
@ -53,6 +53,17 @@ import {
|
||||
} from '@src/lib/constants'
|
||||
import { isPlaywright } from '@src/lib/isPlaywright'
|
||||
import { VITE_KC_SITE_BASE_URL } from '@src/env'
|
||||
import { useNetworkHealthStatus } from '@src/components/NetworkHealthIndicator'
|
||||
import { useNetworkMachineStatus } from '@src/components/NetworkMachineIndicator'
|
||||
import {
|
||||
defaultLocalStatusBarItems,
|
||||
defaultGlobalStatusBarItems,
|
||||
} from '@src/components/StatusBar/defaultStatusBarItems'
|
||||
import { StatusBar } from '@src/components/StatusBar/StatusBar'
|
||||
import { useModelingContext } from '@src/hooks/useModelingContext'
|
||||
import { xStateValueToString } from '@src/lib/xStateValueToString'
|
||||
import { getSelectionTypeDisplayText } from '@src/lib/selections'
|
||||
import type { StatusBarItemType } from '@src/components/StatusBar/statusBarTypes'
|
||||
|
||||
// CYCLIC REF
|
||||
sceneInfra.camControls.engineStreamActor = engineStreamActor
|
||||
@ -62,6 +73,7 @@ maybeWriteToDisk()
|
||||
.catch(() => {})
|
||||
|
||||
export function App() {
|
||||
const { state: modelingState } = useModelingContext()
|
||||
useQueryParamEffects()
|
||||
const { project, file } = useLoaderData() as IndexLoaderData
|
||||
const [nativeFileMenuCreated, setNativeFileMenuCreated] = useState(false)
|
||||
@ -70,9 +82,10 @@ export function App() {
|
||||
const navigate = useNavigate()
|
||||
const filePath = useAbsoluteFilePath()
|
||||
const { onProjectOpen } = useLspContext()
|
||||
const networkHealthStatus = useNetworkHealthStatus()
|
||||
const networkMachineStatus = useNetworkMachineStatus()
|
||||
// We need the ref for the outermost div so we can screenshot the app for
|
||||
// the coredump.
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Stream related refs and data
|
||||
const [searchParams] = useSearchParams()
|
||||
@ -220,24 +233,62 @@ export function App() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex flex-col" ref={ref}>
|
||||
<AppHeader
|
||||
className="transition-opacity transition-duration-75"
|
||||
project={{ project, file }}
|
||||
enableMenu={true}
|
||||
nativeFileMenuCreated={nativeFileMenuCreated}
|
||||
>
|
||||
<CommandBarOpenButton />
|
||||
<ShareButton />
|
||||
</AppHeader>
|
||||
<ModalContainer />
|
||||
<ModelingSidebar />
|
||||
<EngineStream pool={pool} authToken={authToken} />
|
||||
{/* <CamToggle /> */}
|
||||
<LowerRightControls navigate={navigate}>
|
||||
<UnitsMenu />
|
||||
<Gizmo />
|
||||
</LowerRightControls>
|
||||
<div className="h-screen flex flex-col overflow-hidden select-none">
|
||||
<div className="relative flex flex-1 flex-col">
|
||||
<AppHeader
|
||||
className="transition-opacity transition-duration-75"
|
||||
project={{ project, file }}
|
||||
enableMenu={true}
|
||||
nativeFileMenuCreated={nativeFileMenuCreated}
|
||||
>
|
||||
<CommandBarOpenButton />
|
||||
<ShareButton />
|
||||
</AppHeader>
|
||||
<ModalContainer />
|
||||
<ModelingSidebar />
|
||||
<EngineStream pool={pool} authToken={authToken} />
|
||||
{/* <CamToggle /> */}
|
||||
<section className="absolute bottom-2 right-2 flex flex-col items-end gap-3 pointer-events-none">
|
||||
<UnitsMenu />
|
||||
<Gizmo />
|
||||
</section>
|
||||
</div>
|
||||
<StatusBar
|
||||
globalItems={[
|
||||
networkHealthStatus,
|
||||
networkMachineStatus,
|
||||
...defaultGlobalStatusBarItems({ location, filePath }),
|
||||
]}
|
||||
localItems={[
|
||||
...(getSettings().app.showDebugPanel.current
|
||||
? ([
|
||||
{
|
||||
id: 'modeling-state',
|
||||
element: 'text',
|
||||
label:
|
||||
modelingState.value instanceof Object
|
||||
? (xStateValueToString(modelingState.value) ?? '')
|
||||
: modelingState.value,
|
||||
toolTip: {
|
||||
children: 'The current state of the modeler',
|
||||
},
|
||||
},
|
||||
] satisfies StatusBarItemType[])
|
||||
: []),
|
||||
{
|
||||
id: 'selection',
|
||||
element: 'text',
|
||||
label:
|
||||
getSelectionTypeDisplayText(
|
||||
modelingState.context.selectionRanges
|
||||
) ?? 'No selection',
|
||||
toolTip: {
|
||||
children: 'Currently selected geometry',
|
||||
},
|
||||
},
|
||||
...defaultLocalStatusBarItems,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -89,20 +89,19 @@ export const BillingRemaining = (props: BillingRemainingProps) => {
|
||||
const isFlex = props.mode === BillingRemainingMode.ProgressBarStretch
|
||||
const cssWrapper = [
|
||||
'bg-ml-green',
|
||||
'dark:bg-transparent',
|
||||
'select-none',
|
||||
'cursor-pointer',
|
||||
'py-1',
|
||||
'rounded',
|
||||
'!no-underline',
|
||||
'text-xs',
|
||||
'!text-chalkboard-100',
|
||||
'dark:!text-chalkboard-0',
|
||||
'dark:!text-ml-green',
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="billing-remaining"
|
||||
className={[isFlex ? 'flex flex-col gap-1' : 'px-2']
|
||||
className={[isFlex ? 'flex flex-col gap-1' : 'px-2 flex items-stretch']
|
||||
.concat(cssWrapper)
|
||||
.join(' ')}
|
||||
>
|
||||
|
@ -2,12 +2,12 @@ import { useSelector } from '@xstate/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { StateFrom } from 'xstate'
|
||||
|
||||
import type { Artifact } from '@src/lang/std/artifactGraph'
|
||||
import type { CommandArgument } from '@src/lib/commandTypes'
|
||||
import {
|
||||
canSubmitSelectionArg,
|
||||
getSelectionCountByType,
|
||||
getSelectionTypeDisplayText,
|
||||
getSemanticSelectionType,
|
||||
type Selections,
|
||||
} from '@src/lib/selections'
|
||||
import { engineCommandManager, kclManager } from '@src/lib/singletons'
|
||||
@ -16,29 +16,6 @@ import { toSync } from '@src/lib/utils'
|
||||
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
|
||||
import type { modelingMachine } from '@src/machines/modelingMachine'
|
||||
|
||||
const semanticEntityNames: {
|
||||
[key: string]: Array<Artifact['type'] | 'defaultPlane'>
|
||||
} = {
|
||||
face: ['wall', 'cap'],
|
||||
profile: ['solid2d'],
|
||||
edge: ['segment', 'sweepEdge', 'edgeCutEdge'],
|
||||
point: [],
|
||||
plane: ['defaultPlane'],
|
||||
}
|
||||
|
||||
function getSemanticSelectionType(selectionType: Array<Artifact['type']>) {
|
||||
const semanticSelectionType = new Set()
|
||||
selectionType.forEach((type) => {
|
||||
Object.entries(semanticEntityNames).forEach(([entity, entityTypes]) => {
|
||||
if (entityTypes.includes(type)) {
|
||||
semanticSelectionType.add(entity)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(semanticSelectionType)
|
||||
}
|
||||
|
||||
const selectionSelector = (snapshot?: StateFrom<typeof modelingMachine>) =>
|
||||
snapshot?.context.selectionRanges
|
||||
|
||||
|
@ -748,6 +748,16 @@ const CustomIconMap = {
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
loading: (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.5001 6.25839C11.76 5.76392 10.89 5.5 10 5.5V4.5C11.0878 4.5 12.1512 4.82257 13.0556 5.42692C13.9601 6.03126 14.6651 6.89025 15.0813 7.89524C15.4976 8.90023 15.6065 10.0061 15.3943 11.073C15.1821 12.1399 14.6583 13.1199 13.8891 13.8891C13.1199 14.6583 12.1399 15.1821 11.073 15.3943C10.0061 15.6065 8.90023 15.4976 7.89524 15.0813C6.89025 14.6651 6.03126 13.9601 5.42692 13.0556C4.82257 12.1512 4.5 11.0878 4.5 10H5.5C5.5 10.89 5.76392 11.76 6.25839 12.5001C6.75285 13.2401 7.45566 13.8169 8.27792 14.1575C9.10019 14.4981 10.005 14.5872 10.8779 14.4135C11.7508 14.2399 12.5526 13.8113 13.182 13.182C13.8113 12.5526 14.2399 11.7508 14.4135 10.8779C14.5872 10.005 14.4981 9.10019 14.1575 8.27792C13.8169 7.45566 13.2401 6.75285 12.5001 6.25839Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
lockClosed: (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
|
@ -562,7 +562,7 @@ export const EngineStream = (props: {
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
ref={videoWrapperRef}
|
||||
className="absolute inset-0 z-0"
|
||||
className="fixed inset-0 z-0"
|
||||
id="stream"
|
||||
data-testid="stream"
|
||||
onMouseUp={handleMouseUp}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Popover } from '@headlessui/react'
|
||||
import { type NavigateFunction, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { CustomIcon } from '@src/components/CustomIcon'
|
||||
import Tooltip from '@src/components/Tooltip'
|
||||
import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
|
||||
@ -21,11 +20,8 @@ const HelpMenuDivider = () => (
|
||||
<div className="h-[1px] bg-chalkboard-110 dark:bg-chalkboard-80" />
|
||||
)
|
||||
|
||||
export function HelpMenu({
|
||||
navigate = () => {},
|
||||
}: {
|
||||
navigate?: NavigateFunction
|
||||
}) {
|
||||
export function HelpMenu() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const filePath = useAbsoluteFilePath()
|
||||
|
||||
@ -49,15 +45,12 @@ export function HelpMenu({
|
||||
useMenuListener(cb)
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<Popover className="relative flex items-stretch">
|
||||
<Popover.Button
|
||||
className="grid p-0 m-0 border-none rounded-full place-content-center"
|
||||
className="flex items-stretch px-2 py-1 m-0 border-none !bg-chalkboard-110 dark:!bg-chalkboard-80 text-chalkboard-10 rounded-none"
|
||||
data-testid="help-button"
|
||||
>
|
||||
<CustomIcon
|
||||
name="questionMark"
|
||||
className="rounded-full w-7 h-7 bg-chalkboard-110 dark:bg-chalkboard-80 text-chalkboard-10"
|
||||
/>
|
||||
<CustomIcon name="questionMark" className="w-5 h-5" />
|
||||
<span className="sr-only">Help and resources</span>
|
||||
<Tooltip position="top-right" wrapperClassName="ui-open:hidden">
|
||||
Help and resources
|
||||
|
@ -1,127 +0,0 @@
|
||||
import { Link, type NavigateFunction, useLocation } from 'react-router-dom'
|
||||
import { Popover } from '@headlessui/react'
|
||||
import {
|
||||
BillingRemaining,
|
||||
BillingRemainingMode,
|
||||
} from '@src/components/BillingRemaining'
|
||||
import { BillingDialog } from '@src/components/BillingDialog'
|
||||
|
||||
import { CustomIcon } from '@src/components/CustomIcon'
|
||||
import { HelpMenu } from '@src/components/HelpMenu'
|
||||
import { NetworkHealthIndicator } from '@src/components/NetworkHealthIndicator'
|
||||
import { NetworkMachineIndicator } from '@src/components/NetworkMachineIndicator'
|
||||
import Tooltip from '@src/components/Tooltip'
|
||||
import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
|
||||
import { PATHS } from '@src/lib/paths'
|
||||
import { APP_VERSION, getReleaseUrl } from '@src/routes/utils'
|
||||
|
||||
import { billingActor } from '@src/lib/singletons'
|
||||
import { ActionButton } from '@src/components/ActionButton'
|
||||
import { isDesktop } from '@src/lib/isDesktop'
|
||||
import { VITE_KC_SITE_BASE_URL } from '@src/env'
|
||||
import { APP_DOWNLOAD_PATH } from '@src/lib/constants'
|
||||
|
||||
export function LowerRightControls({
|
||||
children,
|
||||
navigate = () => {},
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
navigate?: NavigateFunction
|
||||
}) {
|
||||
const location = useLocation()
|
||||
const filePath = useAbsoluteFilePath()
|
||||
|
||||
const linkOverrideClassName =
|
||||
'!text-chalkboard-70 hover:!text-chalkboard-80 dark:!text-chalkboard-40 dark:hover:!text-chalkboard-30'
|
||||
|
||||
return (
|
||||
<section className="fixed bottom-2 right-2 flex flex-col items-end gap-3 pointer-events-none">
|
||||
{children}
|
||||
<menu className="flex items-center justify-end gap-3 pointer-events-auto">
|
||||
<Popover className="relative">
|
||||
<Popover.Button
|
||||
className="p-0 !border-transparent"
|
||||
data-testid="billing-remaining-bar"
|
||||
>
|
||||
<BillingRemaining
|
||||
mode={BillingRemainingMode.ProgressBarFixed}
|
||||
billingActor={billingActor}
|
||||
/>
|
||||
<Tooltip position="top" contentClassName="text-xs">
|
||||
Text-to-CAD credits
|
||||
</Tooltip>
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="absolute right-0 left-auto bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch rounded-lg shadow-lg text-sm">
|
||||
<BillingDialog billingActor={billingActor} />
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
{isDesktop() ? (
|
||||
<ActionButton
|
||||
Element="externalLink"
|
||||
to={getReleaseUrl()}
|
||||
className={
|
||||
'!no-underline !border-none !bg-transparent font-mono text-xs' +
|
||||
linkOverrideClassName
|
||||
}
|
||||
>
|
||||
v{APP_VERSION}
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton
|
||||
Element="externalLink"
|
||||
to={`${VITE_KC_SITE_BASE_URL}/${APP_DOWNLOAD_PATH}`}
|
||||
className={
|
||||
'!no-underline !border-none !bg-transparent font-mono text-xs' +
|
||||
linkOverrideClassName
|
||||
}
|
||||
iconStart={{
|
||||
icon: 'download',
|
||||
className: `w-5 h-5 !bg-transparent`,
|
||||
}}
|
||||
>
|
||||
Download the app
|
||||
</ActionButton>
|
||||
)}
|
||||
<Link
|
||||
to={
|
||||
location.pathname.includes(PATHS.FILE)
|
||||
? filePath + PATHS.TELEMETRY + '?tab=project'
|
||||
: PATHS.HOME + PATHS.TELEMETRY
|
||||
}
|
||||
data-testid="telemetry-link"
|
||||
>
|
||||
<CustomIcon
|
||||
name="stopwatch"
|
||||
className={`w-5 h-5 ${linkOverrideClassName}`}
|
||||
/>
|
||||
<span className="sr-only">Telemetry</span>
|
||||
<Tooltip position="top" contentClassName="text-xs">
|
||||
Telemetry
|
||||
</Tooltip>
|
||||
</Link>
|
||||
<Link
|
||||
to={
|
||||
location.pathname.includes(PATHS.FILE)
|
||||
? filePath + PATHS.SETTINGS + '?tab=project'
|
||||
: PATHS.HOME + PATHS.SETTINGS
|
||||
}
|
||||
data-testid="settings-link"
|
||||
>
|
||||
<CustomIcon
|
||||
name="settings"
|
||||
className={`w-5 h-5 ${linkOverrideClassName}`}
|
||||
/>
|
||||
<span className="sr-only">Settings</span>
|
||||
<Tooltip position="top" contentClassName="text-xs">
|
||||
Settings
|
||||
</Tooltip>
|
||||
</Link>
|
||||
<NetworkMachineIndicator className={linkOverrideClassName} />
|
||||
{!location.pathname.startsWith(PATHS.HOME) && (
|
||||
<NetworkHealthIndicator />
|
||||
)}
|
||||
<HelpMenu navigate={navigate} />
|
||||
</menu>
|
||||
</section>
|
||||
)
|
||||
}
|
@ -1,13 +1,11 @@
|
||||
import { Popover } from '@headlessui/react'
|
||||
|
||||
import type { ActionIconProps } from '@src/components/ActionIcon'
|
||||
import { ActionIcon } from '@src/components/ActionIcon'
|
||||
import Tooltip from '@src/components/Tooltip'
|
||||
import { useNetworkContext } from '@src/hooks/useNetworkContext'
|
||||
import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
|
||||
import type { ConnectingTypeGroup } from '@src/lang/std/engineConnection'
|
||||
import { reportRejection } from '@src/lib/trap'
|
||||
import { toSync } from '@src/lib/utils'
|
||||
import type { StatusBarItemType } from '@src/components/StatusBar/statusBarTypes'
|
||||
|
||||
export const NETWORK_HEALTH_TEXT: Record<NetworkHealthState, string> = {
|
||||
[NetworkHealthState.Ok]: 'Strong',
|
||||
@ -66,143 +64,125 @@ const overallConnectionStateColor: Record<NetworkHealthState, IconColorConfig> =
|
||||
},
|
||||
}
|
||||
|
||||
const overallConnectionStateIcon: Record<
|
||||
NetworkHealthState,
|
||||
ActionIconProps['icon']
|
||||
> = {
|
||||
const overallConnectionStateIcon = {
|
||||
[NetworkHealthState.Ok]: 'network',
|
||||
[NetworkHealthState.Weak]: 'network',
|
||||
[NetworkHealthState.Issue]: 'networkCrossedOut',
|
||||
[NetworkHealthState.Disconnected]: 'networkCrossedOut',
|
||||
} as const
|
||||
|
||||
export const useNetworkHealthStatus = (): StatusBarItemType => {
|
||||
const { overallState } = useNetworkContext()
|
||||
|
||||
return {
|
||||
id: 'network-health',
|
||||
'data-testid': `network-toggle-${
|
||||
overallState === NetworkHealthState.Ok ? 'ok' : 'other'
|
||||
}`,
|
||||
label: `Network health (${NETWORK_HEALTH_TEXT[overallState]})`,
|
||||
hideLabel: true,
|
||||
element: 'popover',
|
||||
className: overallConnectionStateColor[overallState].icon,
|
||||
icon: overallConnectionStateIcon[overallState],
|
||||
popoverContent: <NetworkHealthPopoverContent />,
|
||||
}
|
||||
}
|
||||
|
||||
export const NetworkHealthIndicator = () => {
|
||||
function NetworkHealthPopoverContent() {
|
||||
const {
|
||||
hasIssues,
|
||||
overallState,
|
||||
internetConnected,
|
||||
steps,
|
||||
issues,
|
||||
error,
|
||||
ping,
|
||||
setHasCopied,
|
||||
hasCopied,
|
||||
ping,
|
||||
} = useNetworkContext()
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<Popover.Button
|
||||
className={
|
||||
'p-0 border-none bg-transparent dark:bg-transparent relative ' +
|
||||
(hasIssues
|
||||
? 'focus-visible:outline-destroy-80'
|
||||
: 'focus-visible:outline-succeed-80')
|
||||
}
|
||||
data-testid="network-toggle"
|
||||
<div
|
||||
className="absolute left-2 bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm"
|
||||
data-testid="network-popover"
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-between p-2 rounded-t-sm ${overallConnectionStateColor[overallState].bg} ${overallConnectionStateColor[overallState].icon}`}
|
||||
>
|
||||
<ActionIcon
|
||||
icon={overallConnectionStateIcon[overallState]}
|
||||
data-testid={`network-toggle-${
|
||||
overallState == NetworkHealthState.Ok ? 'ok' : 'other'
|
||||
}`}
|
||||
className="p-1"
|
||||
iconClassName={overallConnectionStateColor[overallState].icon}
|
||||
bgClassName={
|
||||
'rounded-sm ' + overallConnectionStateColor[overallState].bg
|
||||
}
|
||||
/>
|
||||
<Tooltip position="top-right" wrapperClassName="ui-open:hidden">
|
||||
Network health ({NETWORK_HEALTH_TEXT[overallState]})
|
||||
</Tooltip>
|
||||
</Popover.Button>
|
||||
<Popover.Panel
|
||||
className="absolute right-0 left-auto bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm"
|
||||
data-testid="network-popover"
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-between p-2 rounded-t-sm ${overallConnectionStateColor[overallState].bg} ${overallConnectionStateColor[overallState].icon}`}
|
||||
<h2 className="text-sm font-sans font-normal">Network health</h2>
|
||||
<p
|
||||
data-testid="network"
|
||||
className="font-bold text-xs uppercase px-2 py-1 rounded-sm"
|
||||
>
|
||||
<h2 className="text-sm font-sans font-normal">Network health</h2>
|
||||
<p
|
||||
data-testid="network"
|
||||
className="font-bold text-xs uppercase px-2 py-1 rounded-sm"
|
||||
>
|
||||
{NETWORK_HEALTH_TEXT[overallState]}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`flex items-center justify-between p-2 rounded-t-sm`}>
|
||||
<h2
|
||||
className={`text-xs font-sans font-normal ${overallConnectionStateColor[overallState].icon}`}
|
||||
>
|
||||
Ping
|
||||
</h2>
|
||||
<p
|
||||
data-testid="network"
|
||||
className={`font-bold text-xs uppercase px-2 py-1 rounded-sm ${overallConnectionStateColor[overallState].icon}`}
|
||||
>
|
||||
{ping ?? 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="divide-y divide-chalkboard-20 dark:divide-chalkboard-80">
|
||||
{Object.keys(steps).map((name) => (
|
||||
<li
|
||||
key={name}
|
||||
className={'flex flex-col px-2 py-4 gap-1 last:mb-0 '}
|
||||
>
|
||||
<div className="flex items-center text-left gap-1">
|
||||
<p className="flex-1">{name}</p>
|
||||
{internetConnected ? (
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
icon={
|
||||
hasIssueToIcon[
|
||||
String(issues[name as ConnectingTypeGroup])
|
||||
]
|
||||
}
|
||||
iconClassName={
|
||||
hasIssueToIconColors[
|
||||
String(issues[name as ConnectingTypeGroup])
|
||||
].icon
|
||||
}
|
||||
bgClassName={
|
||||
'rounded-sm ' +
|
||||
hasIssueToIconColors[
|
||||
String(issues[name as ConnectingTypeGroup])
|
||||
].bg
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ActionIcon
|
||||
icon={hasIssueToIcon.true}
|
||||
bgClassName={hasIssueToIconColors.true.bg}
|
||||
iconClassName={hasIssueToIconColors.true.icon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{issues[name as ConnectingTypeGroup] && (
|
||||
<button
|
||||
onClick={toSync(async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
JSON.stringify(error, null, 2) || ''
|
||||
)
|
||||
setHasCopied(true)
|
||||
setTimeout(() => setHasCopied(false), 5000)
|
||||
}, reportRejection)}
|
||||
className="flex w-fit gap-2 items-center bg-transparent text-sm p-1 py-0 my-0 -mx-1 text-destroy-80 dark:text-destroy-10 hover:bg-transparent border-transparent dark:border-transparent hover:border-destroy-80 dark:hover:border-destroy-80 dark:hover:bg-destroy-80"
|
||||
>
|
||||
{hasCopied ? 'Copied' : 'Copy Error'}
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
icon={hasCopied ? 'clipboardCheckmark' : 'clipboardPlus'}
|
||||
iconClassName="text-inherit dark:text-inherit"
|
||||
bgClassName="!bg-transparent"
|
||||
/>
|
||||
</button>
|
||||
{NETWORK_HEALTH_TEXT[overallState]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-t-sm">
|
||||
<h2
|
||||
className={`text-xs font-sans font-normal ${overallConnectionStateColor[overallState].icon}`}
|
||||
>
|
||||
Ping
|
||||
</h2>
|
||||
<p
|
||||
data-testid="network"
|
||||
className={`font-bold text-xs uppercase px-2 py-1 rounded-sm ${overallConnectionStateColor[overallState].icon}`}
|
||||
>
|
||||
{ping ?? 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="divide-y divide-chalkboard-20 dark:divide-chalkboard-80">
|
||||
{Object.keys(steps).map((name) => (
|
||||
<li key={name} className={'flex flex-col px-2 py-4 gap-1 last:mb-0 '}>
|
||||
<div className="flex items-center text-left gap-1">
|
||||
<p className="flex-1">{name}</p>
|
||||
{internetConnected ? (
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
icon={
|
||||
hasIssueToIcon[String(issues[name as ConnectingTypeGroup])]
|
||||
}
|
||||
iconClassName={
|
||||
hasIssueToIconColors[
|
||||
String(issues[name as ConnectingTypeGroup])
|
||||
].icon
|
||||
}
|
||||
bgClassName={`rounded-sm ${
|
||||
hasIssueToIconColors[
|
||||
String(issues[name as ConnectingTypeGroup])
|
||||
].bg
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<ActionIcon
|
||||
icon={hasIssueToIcon.true}
|
||||
bgClassName={hasIssueToIconColors.true.bg}
|
||||
iconClassName={hasIssueToIconColors.true.icon}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
</div>
|
||||
{issues[name as ConnectingTypeGroup] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toSync(async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
JSON.stringify(error, null, 2) || ''
|
||||
)
|
||||
setHasCopied(true)
|
||||
setTimeout(() => setHasCopied(false), 5000)
|
||||
}, reportRejection)}
|
||||
className="flex w-fit gap-2 items-center bg-transparent text-sm p-1 py-0 my-0 -mx-1 text-destroy-80 dark:text-destroy-10 hover:bg-transparent border-transparent dark:border-transparent hover:border-destroy-80 dark:hover:border-destroy-80 dark:hover:bg-destroy-80"
|
||||
>
|
||||
{hasCopied ? 'Copied' : 'Copy Error'}
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
icon={hasCopied ? 'clipboardCheckmark' : 'clipboardPlus'}
|
||||
iconClassName="text-inherit dark:text-inherit"
|
||||
bgClassName="!bg-transparent"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import { MachineManagerContext } from '@src/components/MachineManagerProvider'
|
||||
import Tooltip from '@src/components/Tooltip'
|
||||
import { isDesktop } from '@src/lib/isDesktop'
|
||||
import type { components } from '@src/lib/machine-api'
|
||||
import { capitaliseFC } from '@src/lib/utils'
|
||||
import type { StatusBarItemType } from '@src/components/StatusBar/statusBarTypes'
|
||||
|
||||
export const NetworkMachineIndicator = ({
|
||||
className,
|
||||
@ -22,68 +22,95 @@ export const NetworkMachineIndicator = ({
|
||||
return isDesktop() ? (
|
||||
<Popover className="relative">
|
||||
<Popover.Button
|
||||
className={
|
||||
'flex items-center p-0 border-none bg-transparent dark:bg-transparent relative ' +
|
||||
(className || '')
|
||||
}
|
||||
className={`flex items-center p-0 border-none bg-transparent dark:bg-transparent relative ${className || ''}`}
|
||||
data-testid="network-machine-toggle"
|
||||
>
|
||||
<CustomIcon name="printer3d" className="w-5 h-5" />
|
||||
{machineCount > 0 && (
|
||||
<p aria-hidden className="flex items-center justify-center text-xs">
|
||||
{machineCount}
|
||||
</p>
|
||||
)}
|
||||
<Tooltip position="top-right" wrapperClassName="ui-open:hidden">
|
||||
<NetworkMachinesIcon machineCount={machineCount} />
|
||||
<Tooltip position="top-left" wrapperClassName="ui-open:hidden">
|
||||
Network machines ({machineCount}) {reason && `: ${reason}`}
|
||||
</Tooltip>
|
||||
</Popover.Button>
|
||||
<Popover.Panel
|
||||
className="absolute right-0 left-auto bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm"
|
||||
className="absolute left-0 bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm"
|
||||
data-testid="network-popover"
|
||||
>
|
||||
<div className="flex items-center justify-between p-2 rounded-t-sm bg-chalkboard-20 dark:bg-chalkboard-80">
|
||||
<h2 className="text-sm font-sans font-normal">Network machines</h2>
|
||||
<p
|
||||
data-testid="network"
|
||||
className="font-bold text-xs uppercase px-2 py-1 rounded-sm"
|
||||
>
|
||||
{machineCount}
|
||||
</p>
|
||||
</div>
|
||||
{machineCount > 0 && (
|
||||
<ul className="divide-y divide-chalkboard-20 dark:divide-chalkboard-80">
|
||||
{machines.map(
|
||||
(machine: components['schemas']['MachineInfoResponse']) => {
|
||||
return (
|
||||
<li key={machine.id} className={'px-2 py-4 gap-1 last:mb-0 '}>
|
||||
<p className="">{machine.id.toUpperCase()}</p>
|
||||
<p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs">
|
||||
{machine.make_model.model}
|
||||
</p>
|
||||
{machine.extra &&
|
||||
machine.extra.type === 'bambu' &&
|
||||
machine.extra.nozzle_diameter && (
|
||||
<p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs">
|
||||
Nozzle Diameter: {machine.extra.nozzle_diameter}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs">
|
||||
{`Status: ${capitaliseFC(machine.state.state)}`}
|
||||
{machine.state.state === 'failed' && machine.state.message
|
||||
? ` (${machine.state.message})`
|
||||
: ''}
|
||||
{machine.state.state === 'running' && machine.progress
|
||||
? ` (${Math.round(machine.progress)}%)`
|
||||
: ''}
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<NetworkMachinesPopoverContent machines={machines} />
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
) : null
|
||||
}
|
||||
|
||||
export const useNetworkMachineStatus = (): StatusBarItemType => {
|
||||
return {
|
||||
id: 'network-machines',
|
||||
component: NetworkMachineIndicator,
|
||||
}
|
||||
}
|
||||
|
||||
function NetworkMachinesIcon({ machineCount }: { machineCount: number }) {
|
||||
return (
|
||||
<>
|
||||
<CustomIcon name="printer3d" className="w-5 h-5" />
|
||||
{machineCount > 0 && (
|
||||
<p aria-hidden className="flex items-center justify-center text-xs">
|
||||
{machineCount}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NetworkMachinesPopoverContent({
|
||||
machines,
|
||||
}: { machines: components['schemas']['MachineInfoResponse'][] }) {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-2 bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm"
|
||||
data-testid="network-popover"
|
||||
>
|
||||
<div className="flex items-center justify-between p-2 rounded-t-sm bg-chalkboard-20 dark:bg-chalkboard-80">
|
||||
<h2 className="text-sm font-sans font-normal">Network machines</h2>
|
||||
<p
|
||||
data-testid="network"
|
||||
className="font-bold text-xs uppercase px-2 py-1 rounded-sm"
|
||||
>
|
||||
{machines.length}
|
||||
</p>
|
||||
</div>
|
||||
{machines.length > 0 && (
|
||||
<ul className="divide-y divide-chalkboard-20 dark:divide-chalkboard-80">
|
||||
{machines.map(
|
||||
(machine: components['schemas']['MachineInfoResponse']) => {
|
||||
return (
|
||||
<li key={machine.id} className={'px-2 py-4 gap-1 last:mb-0 '}>
|
||||
<p className="">{machine.id.toUpperCase()}</p>
|
||||
<p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs">
|
||||
{machine.make_model.model}
|
||||
</p>
|
||||
{machine.extra &&
|
||||
machine.extra.type === 'bambu' &&
|
||||
machine.extra.nozzle_diameter && (
|
||||
<p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs">
|
||||
Nozzle Diameter: {machine.extra.nozzle_diameter}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs">
|
||||
{`Status: ${machine.state.state
|
||||
.charAt(0)
|
||||
.toUpperCase()}${machine.state.state.slice(1)}`}
|
||||
{machine.state.state === 'failed' && machine.state.message
|
||||
? ` (${machine.state.message})`
|
||||
: ''}
|
||||
{machine.state.state === 'running' && machine.progress
|
||||
? ` (${Math.round(machine.progress)}%)`
|
||||
: ''}
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
161
src/components/StatusBar/StatusBar.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { ActionButton } from '@src/components/ActionButton'
|
||||
import type { StatusBarItemType } from '@src/components/StatusBar/statusBarTypes'
|
||||
import Tooltip, { type TooltipProps } from '@src/components/Tooltip'
|
||||
import { ActionIcon } from '@src/components/ActionIcon'
|
||||
import { Popover } from '@headlessui/react'
|
||||
|
||||
export function StatusBar({
|
||||
globalItems,
|
||||
localItems,
|
||||
}: {
|
||||
globalItems: StatusBarItemType[]
|
||||
localItems: StatusBarItemType[]
|
||||
}) {
|
||||
return (
|
||||
<footer
|
||||
id="statusbar"
|
||||
className="relative z-10 flex justify-between items-center bg-chalkboard-20 dark:bg-chalkboard-90 text-chalkboard-80 dark:text-chalkboard-30 border-t border-t-chalkboard-30 dark:border-t-chalkboard-80"
|
||||
>
|
||||
<menu id="statusbar-globals" className="flex items-stretch">
|
||||
{globalItems.map((item) => (
|
||||
<StatusBarItem key={item.id} {...item} position="left" />
|
||||
))}
|
||||
</menu>
|
||||
<menu id="statusbar-locals" className="flex items-stretch">
|
||||
{localItems.map((item) => (
|
||||
<StatusBarItem key={item.id} {...item} position="right" />
|
||||
))}
|
||||
</menu>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBarItem(
|
||||
props: StatusBarItemType & { position: 'left' | 'middle' | 'right' }
|
||||
) {
|
||||
const defaultClassNames =
|
||||
'flex items-center px-2 py-1 text-xs text-chalkboard-80 dark:text-chalkboard-30 rounded-none border-none hover:bg-chalkboard-30 dark:hover:bg-chalkboard-80 focus:bg-chalkboard-30 dark:focus:bg-chalkboard-80 hover:text-chalkboard-100 dark:hover:text-chalkboard-10 focus:text-chalkboard-100 dark:focus:text-chalkboard-10 focus:outline-none focus-visible:ring-2 focus:ring-primary focus:ring-opacity-50'
|
||||
const tooltipPosition: TooltipProps['position'] =
|
||||
props.position === 'middle' ? 'top' : `top-${props.position}`
|
||||
|
||||
// If the consumer used `component`, just render that
|
||||
if ('component' in props) {
|
||||
return props.component({})
|
||||
}
|
||||
|
||||
switch (props.element) {
|
||||
case 'button':
|
||||
return (
|
||||
<ActionButton
|
||||
Element="button"
|
||||
iconStart={
|
||||
'icon' in props
|
||||
? props.icon && {
|
||||
icon: props.icon,
|
||||
iconClassName: props.icon === 'loading' ? 'animate-spin' : '',
|
||||
bgClassName: 'bg-transparent dark:bg-transparent',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`${defaultClassNames} ${props.className}`}
|
||||
data-testid={props['data-testid']}
|
||||
>
|
||||
{'label' in props && props.label && !props.hideLabel && (
|
||||
<span>{props.label}</span>
|
||||
)}
|
||||
{(props.toolTip || (props.label && props.hideLabel)) && (
|
||||
<Tooltip
|
||||
{...(props.toolTip || { children: props.label })}
|
||||
position={tooltipPosition}
|
||||
/>
|
||||
)}
|
||||
</ActionButton>
|
||||
)
|
||||
case 'popover':
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<Popover.Button
|
||||
as={ActionButton}
|
||||
Element="button"
|
||||
iconStart={
|
||||
'icon' in props
|
||||
? props.icon && {
|
||||
icon: props.icon,
|
||||
iconClassName:
|
||||
props.icon === 'loading' ? 'animate-spin' : '',
|
||||
bgClassName: 'bg-transparent dark:bg-transparent',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`${defaultClassNames} ${props.className}`}
|
||||
data-testid={props['data-testid']}
|
||||
>
|
||||
{'label' in props && props.label && !props.hideLabel && (
|
||||
<span>{props.label}</span>
|
||||
)}
|
||||
{(props.toolTip || (props.label && props.hideLabel)) && (
|
||||
<Tooltip
|
||||
{...(props.toolTip || { children: props.label })}
|
||||
wrapperClassName={`${
|
||||
props.toolTip?.wrapperClassName || ''
|
||||
} ui-open:hidden`}
|
||||
position={tooltipPosition}
|
||||
/>
|
||||
)}
|
||||
</Popover.Button>
|
||||
<Popover.Panel>{props.popoverContent}</Popover.Panel>
|
||||
</Popover>
|
||||
)
|
||||
case 'text':
|
||||
return (
|
||||
<div
|
||||
role="tooltip"
|
||||
className={`${defaultClassNames} ${props.className}`}
|
||||
>
|
||||
{'icon' in props && props.icon && (
|
||||
<ActionIcon
|
||||
icon={props.icon}
|
||||
iconClassName={props.icon === 'loading' ? 'animate-spin' : ''}
|
||||
bgClassName="bg-transparent dark:bg-transparent"
|
||||
/>
|
||||
)}
|
||||
{'label' in props && props.label && !props.hideLabel && (
|
||||
<span>{props.label}</span>
|
||||
)}
|
||||
{(props.toolTip || (props.label && props.hideLabel)) && (
|
||||
<Tooltip
|
||||
{...(props.toolTip || { children: props.label })}
|
||||
position={tooltipPosition}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<ActionButton
|
||||
Element={props.element}
|
||||
to={props.href}
|
||||
iconStart={
|
||||
'icon' in props
|
||||
? props.icon && {
|
||||
icon: props.icon,
|
||||
bgClassName: 'bg-transparent dark:bg-transparent',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`${defaultClassNames} ${props.className}`}
|
||||
data-testid={props['data-testid']}
|
||||
>
|
||||
{'label' in props && props.label && !props.hideLabel && (
|
||||
<span>{props.label}</span>
|
||||
)}
|
||||
{(props.toolTip || (props.label && props.hideLabel)) && (
|
||||
<Tooltip
|
||||
{...(props.toolTip || { children: props.label })}
|
||||
position={tooltipPosition}
|
||||
/>
|
||||
)}
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
}
|
95
src/components/StatusBar/defaultStatusBarItems.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import type { StatusBarItemType } from '@src/components/StatusBar/statusBarTypes'
|
||||
import type { Location } from 'react-router-dom'
|
||||
import { PATHS } from '@src/lib/paths'
|
||||
import { APP_VERSION } from '@src/routes/utils'
|
||||
import {
|
||||
BillingRemaining,
|
||||
BillingRemainingMode,
|
||||
} from '@src/components/BillingRemaining'
|
||||
import { billingActor } from '@src/lib/singletons'
|
||||
import { BillingDialog } from '@src/components/BillingDialog'
|
||||
import { Popover } from '@headlessui/react'
|
||||
import Tooltip from '@src/components/Tooltip'
|
||||
import { HelpMenu } from '@src/components/HelpMenu'
|
||||
|
||||
export const defaultGlobalStatusBarItems = ({
|
||||
location,
|
||||
filePath,
|
||||
}: {
|
||||
location: Location
|
||||
filePath?: string
|
||||
}): StatusBarItemType[] => [
|
||||
{
|
||||
id: 'version',
|
||||
element: 'externalLink',
|
||||
label: `v${APP_VERSION}`,
|
||||
href: `https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`,
|
||||
toolTip: {
|
||||
children: 'View the release notes on GitHub',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'telemetry',
|
||||
element: 'link',
|
||||
icon: 'stopwatch',
|
||||
href: location.pathname.includes(PATHS.FILE)
|
||||
? filePath + PATHS.TELEMETRY + '?tab=project'
|
||||
: PATHS.HOME + PATHS.TELEMETRY,
|
||||
'data-testid': 'telemetry-link',
|
||||
label: 'Telemetry',
|
||||
hideLabel: true,
|
||||
toolTip: {
|
||||
children: 'Telemetry',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
element: 'link',
|
||||
icon: 'settings',
|
||||
href: location.pathname.includes(PATHS.FILE)
|
||||
? filePath + PATHS.SETTINGS + '?tab=project'
|
||||
: PATHS.HOME + PATHS.SETTINGS,
|
||||
'data-testid': 'settings-link',
|
||||
label: 'Settings',
|
||||
},
|
||||
{
|
||||
id: 'credits',
|
||||
'data-testid': 'billing-remaining-bar',
|
||||
component: BillingStatusBarItem,
|
||||
},
|
||||
]
|
||||
|
||||
function BillingStatusBarItem() {
|
||||
return (
|
||||
<Popover className="relative flex items-stretch">
|
||||
<Popover.Button
|
||||
className="m-0 p-0 border-0 flex items-stretch"
|
||||
data-testid="billing-remaining-bar"
|
||||
>
|
||||
<BillingRemaining
|
||||
mode={BillingRemainingMode.ProgressBarFixed}
|
||||
billingActor={billingActor}
|
||||
/>
|
||||
<Tooltip
|
||||
position="top"
|
||||
contentClassName="text-xs"
|
||||
hoverOnly
|
||||
wrapperClassName="ui-open:!hidden"
|
||||
>
|
||||
Text-to-CAD credits
|
||||
</Tooltip>
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="absolute left-0 bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch rounded-lg shadow-lg text-sm">
|
||||
<BillingDialog billingActor={billingActor} />
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export const defaultLocalStatusBarItems: StatusBarItemType[] = [
|
||||
{
|
||||
id: 'help',
|
||||
'data-testid': 'help-button',
|
||||
component: HelpMenu,
|
||||
},
|
||||
]
|
34
src/components/StatusBar/statusBarTypes.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { CustomIconName } from '@src/components/CustomIcon'
|
||||
import type { TooltipProps } from '@src/components/Tooltip'
|
||||
|
||||
export type StatusBarItemType = {
|
||||
id: string
|
||||
'data-testid'?: string
|
||||
} & (
|
||||
| ({
|
||||
label: string
|
||||
hideLabel?: boolean
|
||||
className?: string
|
||||
toolTip?: Omit<TooltipProps, 'position'>
|
||||
icon?: CustomIconName
|
||||
} & (
|
||||
| {
|
||||
element: 'button'
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
| {
|
||||
element: 'popover'
|
||||
popoverContent: React.ReactNode
|
||||
}
|
||||
| {
|
||||
element: 'link' | 'externalLink'
|
||||
href: string
|
||||
}
|
||||
| {
|
||||
element: 'text'
|
||||
}
|
||||
))
|
||||
| {
|
||||
component: React.FC
|
||||
}
|
||||
)
|
@ -8,7 +8,7 @@ type LeftOrRight = 'left' | 'right'
|
||||
type Corner = `${TopOrBottom}-${LeftOrRight}`
|
||||
type TooltipPosition = TopOrBottom | LeftOrRight | Corner
|
||||
|
||||
interface TooltipProps extends React.PropsWithChildren {
|
||||
export interface TooltipProps extends React.PropsWithChildren {
|
||||
position?: TooltipPosition
|
||||
wrapperClassName?: string
|
||||
contentClassName?: string
|
||||
|
@ -779,3 +779,27 @@ export function updateSelections(
|
||||
otherSelections: prevSelectionRanges.otherSelections,
|
||||
}
|
||||
}
|
||||
|
||||
const semanticEntityNames: {
|
||||
[key: string]: Array<Artifact['type'] | 'defaultPlane'>
|
||||
} = {
|
||||
face: ['wall', 'cap'],
|
||||
profile: ['solid2d'],
|
||||
edge: ['segment', 'sweepEdge', 'edgeCutEdge'],
|
||||
point: [],
|
||||
plane: ['defaultPlane'],
|
||||
}
|
||||
|
||||
/** Convert selections to a human-readable format */
|
||||
export function getSemanticSelectionType(selectionType: Artifact['type'][]) {
|
||||
const semanticSelectionType = new Set()
|
||||
for (const type of selectionType) {
|
||||
for (const [entity, entityTypes] of Object.entries(semanticEntityNames)) {
|
||||
if (entityTypes.includes(type)) {
|
||||
semanticSelectionType.add(entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(semanticSelectionType)
|
||||
}
|
||||
|
23
src/lib/xStateValueToString.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import type { AnyStateMachine, StateFrom } from 'xstate'
|
||||
|
||||
/**
|
||||
* Convert an XState state value to a pretty string,
|
||||
* with nested states separated by slashes
|
||||
*/
|
||||
export function xStateValueToString(
|
||||
stateValue: StateFrom<AnyStateMachine>['value']
|
||||
) {
|
||||
const sep = ' / '
|
||||
let output = ''
|
||||
let remainingValues = stateValue
|
||||
let isFirstStep = true
|
||||
while (remainingValues instanceof Object) {
|
||||
const key: keyof typeof remainingValues = Object.keys(remainingValues)[0]
|
||||
output += (isFirstStep ? '' : sep) + key
|
||||
remainingValues = remainingValues[key]
|
||||
isFirstStep = false
|
||||
}
|
||||
if (typeof remainingValues === 'string' && remainingValues.trim().length) {
|
||||
return output + sep + remainingValues.trim()
|
||||
}
|
||||
}
|
@ -12,7 +12,6 @@ import {
|
||||
import { ActionButton } from '@src/components/ActionButton'
|
||||
import { AppHeader } from '@src/components/AppHeader'
|
||||
import Loading from '@src/components/Loading'
|
||||
import { LowerRightControls } from '@src/components/LowerRightControls'
|
||||
import ProjectCard from '@src/components/ProjectCard/ProjectCard'
|
||||
import {
|
||||
ProjectSearchBar,
|
||||
@ -61,6 +60,12 @@ import {
|
||||
import { CustomIcon } from '@src/components/CustomIcon'
|
||||
import Tooltip from '@src/components/Tooltip'
|
||||
import { ML_EXPERIMENTAL_MESSAGE } from '@src/lib/constants'
|
||||
import { StatusBar } from '@src/components/StatusBar/StatusBar'
|
||||
import { useNetworkMachineStatus } from '@src/components/NetworkMachineIndicator'
|
||||
import {
|
||||
defaultLocalStatusBarItems,
|
||||
defaultGlobalStatusBarItems,
|
||||
} from '@src/components/StatusBar/defaultStatusBarItems'
|
||||
|
||||
type ReadWriteProjectState = {
|
||||
value: boolean
|
||||
@ -75,6 +80,7 @@ const Home = () => {
|
||||
const readWriteProjectDir = useCanReadWriteProjectDirectory()
|
||||
const [nativeFileMenuCreated, setNativeFileMenuCreated] = useState(false)
|
||||
const apiToken = useToken()
|
||||
const networkMachineStatus = useNetworkMachineStatus()
|
||||
|
||||
// Only create the native file menus on desktop
|
||||
useEffect(() => {
|
||||
@ -216,7 +222,7 @@ const Home = () => {
|
||||
nativeFileMenuCreated={nativeFileMenuCreated}
|
||||
showToolbar={false}
|
||||
/>
|
||||
<div className="overflow-hidden self-stretch w-full flex-1 home-layout max-w-4xl lg:max-w-5xl xl:max-w-7xl mb-12 px-4 mx-auto mt-8 lg:mt-24 lg:px-0">
|
||||
<div className="overflow-hidden self-stretch w-full flex-1 home-layout max-w-4xl lg:max-w-5xl xl:max-w-7xl px-4 mx-auto mt-8 lg:mt-24 lg:px-0">
|
||||
<HomeHeader
|
||||
setQuery={setQuery}
|
||||
sort={sort}
|
||||
@ -225,7 +231,7 @@ const Home = () => {
|
||||
readWriteProjectDir={readWriteProjectDir}
|
||||
className="col-start-2 -col-end-1"
|
||||
/>
|
||||
<aside className="lg:row-start-1 -row-end-1 grid sm:grid-cols-2 lg:flex flex-col justify-between">
|
||||
<aside className="lg:row-start-1 -row-end-1 grid sm:grid-cols-2 md:mb-12 lg:flex flex-col justify-between">
|
||||
<ul className="flex flex-col">
|
||||
{needsToOnboard(location, onboardingStatus) && (
|
||||
<li className="flex group">
|
||||
@ -394,8 +400,14 @@ const Home = () => {
|
||||
sort={sort}
|
||||
className="flex-1 col-start-2 -col-end-1 overflow-y-auto pr-2 pb-24"
|
||||
/>
|
||||
<LowerRightControls navigate={navigate} />
|
||||
</div>
|
||||
<StatusBar
|
||||
globalItems={[
|
||||
networkMachineStatus,
|
||||
...defaultGlobalStatusBarItems({ location, filePath: undefined }),
|
||||
]}
|
||||
localItems={defaultLocalStatusBarItems}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|