Status bar initial commit

This commit is contained in:
Frank Noirot
2024-10-11 19:05:59 -04:00
parent b99b2d9a96
commit bc8a7a364d
17 changed files with 397 additions and 131 deletions

View File

@ -3,7 +3,7 @@ import { useHotKeyListener } from './hooks/useHotKeyListener'
import { Stream } from './components/Stream'
import { AppHeader } from './components/AppHeader'
import { useHotkeys } from 'react-hotkeys-hook'
import { useLoaderData, useNavigate } from 'react-router-dom'
import { useLoaderData, useLocation, useNavigate } from 'react-router-dom'
import { type IndexLoaderData } from 'lib/types'
import { PATHS } from 'lib/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
@ -22,16 +22,24 @@ import Gizmo from 'components/Gizmo'
import { CoreDumpManager } from 'lib/coredump'
import { UnitsMenu } from 'components/UnitsMenu'
import { CameraProjectionToggle } from 'components/CameraProjectionToggle'
import { homeDefaultStatusBarItems } from 'components/statusBar/homeDefaultStatusBarItems'
import { StatusBar } from 'components/StatusBar'
import { useModelStateStatus } from 'components/ModelStateIndicator'
import { useNetworkHealthStatus } from 'components/NetworkHealthIndicator'
import { useModelingContext } from 'hooks/useModelingContext'
import { xStateValueToString } from 'lib/xStateValueToString'
export function App() {
const { project, file } = useLoaderData() as IndexLoaderData
useRefreshSettings(PATHS.FILE + 'SETTINGS')
const navigate = useNavigate()
const location = useLocation()
const filePath = useAbsoluteFilePath()
const { onProjectOpen } = useLspContext()
// We need the ref for the outermost div so we can screenshot the app for
// the coredump.
const ref = useRef<HTMLDivElement>(null)
const { state: modelingState } = useModelingContext()
const projectName = project?.name || null
const projectPath = project?.path || null
@ -73,21 +81,43 @@ export function App() {
useEngineConnectionSubscriptions()
return (
<div className="relative h-full flex flex-col" ref={ref}>
<AppHeader
className={'transition-opacity transition-duration-75 ' + paneOpacity}
project={{ project, file }}
enableMenu={true}
<div className="h-screen w-full flex flex-col">
<div className="relative flex flex-1 flex-col" ref={ref}>
<AppHeader
className={'transition-opacity transition-duration-75 ' + paneOpacity}
project={{ project, file }}
enableMenu={true}
/>
<ModalContainer />
<ModelingSidebar paneOpacity={paneOpacity} />
<Stream />
{/* <CamToggle /> */}
<LowerRightControls coreDumpManager={coreDumpManager}>
<UnitsMenu />
<Gizmo />
<CameraProjectionToggle />
</LowerRightControls>
</div>
<StatusBar
globalItems={[
useNetworkHealthStatus(),
...homeDefaultStatusBarItems({ coreDumpManager, location }),
]}
localItems={[
{
id: 'modeling-state',
element: 'text',
label:
modelingState.value instanceof Object
? xStateValueToString(modelingState.value) ?? ''
: modelingState.value,
toolTip: {
children: 'The current state of the modeler',
},
},
useModelStateStatus(),
]}
/>
<ModalContainer />
<ModelingSidebar paneOpacity={paneOpacity} />
<Stream />
{/* <CamToggle /> */}
<LowerRightControls coreDumpManager={coreDumpManager}>
<UnitsMenu />
<Gizmo />
<CameraProjectionToggle />
</LowerRightControls>
</div>
)
}

View File

@ -636,6 +636,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

@ -1,4 +1,3 @@
import { APP_VERSION } from 'routes/Settings'
import { CustomIcon } from 'components/CustomIcon'
import Tooltip from 'components/Tooltip'
import { PATHS } from 'lib/paths'
@ -6,13 +5,8 @@ import { NetworkHealthIndicator } from 'components/NetworkHealthIndicator'
import { HelpMenu } from './HelpMenu'
import { Link, useLocation } from 'react-router-dom'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import { coreDump } from 'lang/wasm'
import toast from 'react-hot-toast'
import { CoreDumpManager } from 'lib/coredump'
import openWindow, { openExternalBrowserIfDesktop } from 'lib/openWindow'
import { NetworkMachineIndicator } from './NetworkMachineIndicator'
import { ModelStateIndicator } from './ModelStateIndicator'
import { reportRejection } from 'lib/trap'
export function LowerRightControls({
children,
@ -26,97 +20,11 @@ export function LowerRightControls({
const linkOverrideClassName =
'!text-chalkboard-70 hover:!text-chalkboard-80 dark:!text-chalkboard-40 dark:hover:!text-chalkboard-30'
function reportbug(event: {
preventDefault: () => void
stopPropagation: () => void
}) {
event?.preventDefault()
event?.stopPropagation()
if (!coreDumpManager) {
// open default reporting option
openWindow(
'https://github.com/KittyCAD/modeling-app/issues/new/choose'
).catch(reportRejection)
} else {
toast
.promise(
coreDump(coreDumpManager, true),
{
loading: 'Preparing bug report...',
success: 'Bug report opened in new window',
error: 'Unable to export a core dump. Using default reporting.',
},
{
success: {
// Note: this extended duration is especially important for Playwright e2e testing
// default duration is 2000 - https://react-hot-toast.com/docs/toast#default-durations
duration: 6000,
},
}
)
.catch((err: Error) => {
if (err) {
openWindow(
'https://github.com/KittyCAD/modeling-app/issues/new/choose'
).catch(reportRejection)
}
})
}
}
return (
<section className="fixed bottom-2 right-2 flex flex-col items-end gap-3 pointer-events-none">
<section className="absolute 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">
{!location.pathname.startsWith(PATHS.HOME) && <ModelStateIndicator />}
<a
onClick={openExternalBrowserIfDesktop(
`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`
)}
href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}
target="_blank"
rel="noopener noreferrer"
className={'!no-underline font-mono text-xs ' + linkOverrideClassName}
>
v{APP_VERSION}
</a>
<a
onClick={reportbug}
href="https://github.com/KittyCAD/modeling-app/issues/new/choose"
target="_blank"
rel="noopener noreferrer"
>
<CustomIcon
name="bug"
className={`w-5 h-5 ${linkOverrideClassName}`}
/>
<Tooltip position="top" contentClassName="text-xs">
Report a bug
</Tooltip>
</a>
<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 />
</menu>
</section>
)

View File

@ -1,6 +1,39 @@
import { useEngineCommands } from './EngineCommands'
import { Spinner } from './Spinner'
import { CustomIcon } from './CustomIcon'
import { StatusBarItemType } from './statusBar/statusBarTypes'
export const useModelStateStatus = (): StatusBarItemType => {
const [commands] = useEngineCommands()
const lastCommandType = commands[commands.length - 1]?.type
let icon: StatusBarItemType['icon'] = 'loading'
const baseDataTestId = 'model-state-indicator'
let dataTestId = baseDataTestId
if (lastCommandType === 'receive-reliable') {
icon = 'checkmark'
dataTestId = `${baseDataTestId}-receive-reliable`
} else if (lastCommandType === 'execution-done') {
icon = 'checkmark'
dataTestId = `${baseDataTestId}-execution-done`
} else if (lastCommandType === 'export-done') {
icon = 'checkmark'
dataTestId = `${baseDataTestId}-export-done`
}
return {
id: 'model-state-indicator',
label: '',
icon,
toolTip: {
children: 'Model state indicator',
},
element: 'button',
onClick: () => {},
'data-testid': dataTestId,
}
}
export const ModelStateIndicator = () => {
const [commands] = useEngineCommands()

View File

@ -1085,7 +1085,10 @@ export const ModelingMachineProvider = ({
>
{/* TODO #818: maybe pass reff down to children/app.ts or render app.tsx directly?
since realistically it won't ever have generic children that isn't app.tsx */}
<div className="h-screen overflow-hidden select-none" ref={streamRef}>
<div
className="flex flex-col h-screen overflow-hidden select-none"
ref={streamRef}
>
{children}
</div>
</ModelingMachineContext.Provider>

View File

@ -6,6 +6,7 @@ import { useNetworkContext } from '../hooks/useNetworkContext'
import { NetworkHealthState } from '../hooks/useNetworkStatus'
import { toSync } from 'lib/utils'
import { reportRejection } from 'lib/trap'
import { StatusBarItemType } from './statusBar/statusBarTypes'
export const NETWORK_HEALTH_TEXT: Record<NetworkHealthState, string> = {
[NetworkHealthState.Ok]: 'Connected',
@ -64,14 +65,28 @@ 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',
label: `Network health (${NETWORK_HEALTH_TEXT[overallState]})`,
hideLabel: true,
element: 'button',
className: overallConnectionStateColor[overallState].icon,
onClick: () => {},
toolTip: {
children: 'View the health of your network connections',
},
icon: overallConnectionStateIcon[overallState],
}
}
export const NetworkHealthIndicator = () => {

View File

@ -13,7 +13,7 @@ import { isDesktop } from 'lib/isDesktop'
import { ActionButton } from 'components/ActionButton'
import { SettingsFieldInput } from './SettingsFieldInput'
import toast from 'react-hot-toast'
import { APP_VERSION } from 'routes/Settings'
import { APP_VERSION } from 'lib/constants'
import { PATHS } from 'lib/paths'
import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/desktopFS'
import { useDotDotSlash } from 'hooks/useDotDotSlash'

View File

@ -0,0 +1,123 @@
import { useEffect } from 'react'
import { ActionButton } from './ActionButton'
import { StatusBarItemType } from './statusBar/statusBarTypes'
import Tooltip, { TooltipProps } from './Tooltip'
import { ActionIcon } from './ActionIcon'
export function StatusBar({
globalItems,
localItems,
}: {
globalItems: StatusBarItemType[]
localItems: StatusBarItemType[]
}) {
useEffect(() => {
console.log('items', {
globalItems,
localItems,
})
}, [])
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, index) => (
<StatusBarItem key={item.id} {...item} position={'left'} />
))}
</menu>
<menu id="statusbar-locals" className="flex items-stretch">
{localItems.map((item, index) => (
<StatusBarItem
key={item.id}
{...item}
position={index === localItems.length - 1 ? 'right' : 'middle'}
/>
))}
</menu>
</footer>
)
}
function StatusBarItem(
props: StatusBarItemType & { position: 'left' | 'middle' | 'right' }
) {
const defaultClassNames = `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 focustext-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}`
switch (props.element) {
case 'button':
return (
<ActionButton
Element="button"
iconStart={
props.icon && {
icon: props.icon,
iconClassName: props.icon === 'loading' ? 'animate-spin' : '',
bgClassName: 'bg-transparent dark:bg-transparent',
}
}
className={defaultClassNames + ' ' + props.className}
data-testid={props['data-testid']}
>
{props.label && (
<span className={props.hideLabel ? 'sr-only' : ''}>
{props.label}
</span>
)}
{props.toolTip && (
<Tooltip {...props.toolTip} position={tooltipPosition} />
)}
</ActionButton>
)
case 'text':
return (
<div
role="tooltip"
className={defaultClassNames + ' ' + props.className}
>
{props.icon && (
<ActionIcon
icon={props.icon}
iconClassName={props.icon === 'loading' ? 'animate-spin' : ''}
bgClassName="bg-transparent dark:bg-transparent"
/>
)}
{props.label && (
<span className={props.hideLabel ? 'sr-only' : ''}>
{props.label}
</span>
)}
{props.toolTip && (
<Tooltip {...props.toolTip} position={tooltipPosition} />
)}
</div>
)
default:
return (
<ActionButton
Element={props.element}
to={props.href}
iconStart={
props.icon && {
icon: props.icon,
bgClassName: 'bg-transparent dark:bg-transparent',
}
}
className={defaultClassNames + ' ' + props.className}
data-testid={props['data-testid']}
>
{props.label && (
<span className={props.hideLabel ? 'sr-only' : ''}>
{props.label}
</span>
)}
{props.toolTip && (
<Tooltip {...props.toolTip} position={tooltipPosition} />
)}
</ActionButton>
)
}
}

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

@ -0,0 +1,96 @@
import openWindow from 'lib/openWindow'
import { StatusBarItemType } from './statusBarTypes'
import { reportRejection } from 'lib/trap'
import { CoreDumpManager } from 'lib/coredump'
import toast from 'react-hot-toast'
import { coreDump } from 'lang/wasm'
import { APP_VERSION } from 'lib/constants'
import { Location } from 'react-router-dom'
import { PATHS } from 'lib/paths'
export const homeDefaultStatusBarItems = ({
coreDumpManager,
location,
}: {
coreDumpManager?: CoreDumpManager
location: Location
}): 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: 'report-bug',
element: 'button',
label: 'Report a bug',
onClick: (event) => reportBug(event, { coreDumpManager }),
toolTip: {
children: 'Send your current app state to the developers for debugging',
},
},
{
id: 'settings',
element: 'link',
icon: 'settings',
href:
'.' +
PATHS.SETTINGS +
(location.pathname.includes(PATHS.FILE) ? '?tab=project' : ''),
'data-testid': 'settings-link',
label: 'Settings',
hideLabel: true,
toolTip: {
children: 'Settings',
},
},
]
function reportBug(
event: {
preventDefault: () => void
stopPropagation: () => void
},
dependencies: {
coreDumpManager: CoreDumpManager | undefined
}
) {
event?.preventDefault()
event?.stopPropagation()
const { coreDumpManager } = dependencies
if (!coreDumpManager) {
// open default reporting option
openWindow(
'https://github.com/KittyCAD/modeling-app/issues/new/choose'
).catch(reportRejection)
} else {
toast
.promise(
coreDump(coreDumpManager, true),
{
loading: 'Preparing bug report...',
success: 'Bug report opened in new window',
error: 'Unable to export a core dump. Using default reporting.',
},
{
success: {
// Note: this extended duration is especially important for Playwright e2e testing
// default duration is 2000 - https://react-hot-toast.com/docs/toast#default-durations
duration: 6000,
},
}
)
.catch((err: Error) => {
if (err) {
openWindow(
'https://github.com/KittyCAD/modeling-app/issues/new/choose'
).catch(reportRejection)
}
})
}
}

View File

@ -0,0 +1,24 @@
import { CustomIconName } from 'components/CustomIcon'
import { TooltipProps } from 'components/Tooltip'
export type StatusBarItemType = {
id: string
label: string
icon?: CustomIconName
hideLabel?: boolean
toolTip?: Omit<TooltipProps, 'position'>
className?: string
['data-testid']?: string
} & (
| {
element: 'button'
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
}
| {
element: 'link' | 'externalLink'
href: string
}
| {
element: 'text'
}
)

View File

@ -1,4 +1,16 @@
import { NODE_ENV } from 'env'
import { isTestEnv } from './isTestEnv'
import { isDesktop } from './isDesktop'
export const APP_NAME = 'Modeling App'
/** Version number of the app */
export const APP_VERSION =
isTestEnv && NODE_ENV === 'development'
? '11.22.33'
: isDesktop()
? // @ts-ignore
window.electron.packageJson.version
: 'main'
/** Search string in new project names to increment as an index */
export const INDEX_IDENTIFIER = '$n'
/** The maximum number of 0's to pad a default project name's index with */

View File

@ -2,7 +2,7 @@ import { CommandLog, EngineCommandManager } from 'lang/std/engineConnection'
import { WebrtcStats } from 'wasm-lib/kcl/bindings/WebrtcStats'
import { OsInfo } from 'wasm-lib/kcl/bindings/OsInfo'
import { isDesktop } from 'lib/isDesktop'
import { APP_VERSION } from 'routes/Settings'
import { APP_VERSION } from 'lib/constants'
import { UAParser } from 'ua-parser-js'
import screenshot from 'lib/screenshot'
import { VITE_KC_API_BASE_URL } from 'env'

4
src/lib/isTestEnv.ts Normal file
View File

@ -0,0 +1,4 @@
import { IS_PLAYWRIGHT_KEY } from '../../e2e/playwright/storageStates'
export const isTestEnv =
globalThis.window?.localStorage.getItem(IS_PLAYWRIGHT_KEY) === 'true'

View File

@ -0,0 +1,22 @@
import { 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,19 +12,6 @@ import { SettingsSectionsList } from 'components/Settings/SettingsSectionsList'
import { AllSettingsFields } from 'components/Settings/AllSettingsFields'
import { AllKeybindingsFields } from 'components/Settings/AllKeybindingsFields'
import { KeybindingsSectionsList } from 'components/Settings/KeybindingsSectionsList'
import { isDesktop } from 'lib/isDesktop'
import { IS_PLAYWRIGHT_KEY } from '../../e2e/playwright/storageStates'
import { NODE_ENV } from 'env'
const isTestEnv = window?.localStorage.getItem(IS_PLAYWRIGHT_KEY) === 'true'
export const APP_VERSION =
isTestEnv && NODE_ENV === 'development'
? '11.22.33'
: isDesktop()
? // @ts-ignore
window.electron.packageJson.version
: 'main'
export const Settings = () => {
const navigate = useNavigate()

View File

@ -4,12 +4,11 @@ import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env'
import { Themes, getSystemTheme } from '../lib/theme'
import { PATHS } from 'lib/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { APP_NAME } from 'lib/constants'
import { APP_NAME, APP_VERSION } from 'lib/constants'
import { CSSProperties, useCallback, useState } from 'react'
import { Logo } from 'components/Logo'
import { CustomIcon } from 'components/CustomIcon'
import { Link } from 'react-router-dom'
import { APP_VERSION } from './Settings'
import { openExternalBrowserIfDesktop } from 'lib/openWindow'
import { toSync } from 'lib/utils'
import { reportRejection } from 'lib/trap'