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>
This commit is contained in:
Frank Noirot
2025-06-17 16:29:27 -04:00
committed by GitHub
parent b301fbba22
commit fe581ff1d2
46 changed files with 646 additions and 388 deletions

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

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

View File

@ -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'),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

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

View File

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