Compare commits
10 Commits
kcl-83
...
franknoiro
Author | SHA1 | Date | |
---|---|---|---|
f70847c407 | |||
9510849a3a | |||
5b208356b4 | |||
84209e764e | |||
2b85e7abd6 | |||
9155a5efc8 | |||
2ccc27112a | |||
ee160b67f4 | |||
5117b6f5d6 | |||
bc8a7a364d |
65
src/App.tsx
65
src/App.tsx
@ -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,7 +22,14 @@ 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'
|
||||
import { maybeWriteToDisk } from 'lib/telemetry'
|
||||
import { useNetworkMachineStatus } from 'components/NetworkMachineIndicator'
|
||||
maybeWriteToDisk()
|
||||
.then(() => {})
|
||||
.catch(() => {})
|
||||
@ -31,11 +38,10 @@ 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, streamRef } = useModelingContext()
|
||||
|
||||
const projectName = project?.name || null
|
||||
const projectPath = project?.path || null
|
||||
@ -77,21 +83,44 @@ 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 flex flex-col overflow-hidden select-none">
|
||||
<div className="relative flex flex-1 flex-col" ref={streamRef}>
|
||||
<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(),
|
||||
useNetworkMachineStatus(),
|
||||
...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>
|
||||
)
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -1,141 +1,14 @@
|
||||
import { APP_VERSION } from 'routes/Settings'
|
||||
import { CustomIcon } from 'components/CustomIcon'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { PATHS } from 'lib/paths'
|
||||
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,
|
||||
coreDumpManager,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
coreDumpManager?: CoreDumpManager
|
||||
}) {
|
||||
const location = useLocation()
|
||||
const filePath = useAbsoluteFilePath()
|
||||
|
||||
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.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 />
|
||||
</menu>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -99,6 +99,7 @@ type MachineContext<T extends AnyStateMachine> = {
|
||||
state: StateFrom<T>
|
||||
context: ContextFrom<T>
|
||||
send: Prop<Actor<T>, 'send'>
|
||||
streamRef: React.RefObject<HTMLDivElement>
|
||||
}
|
||||
|
||||
export const ModelingMachineContext = createContext(
|
||||
@ -1205,13 +1206,10 @@ export const ModelingMachineProvider = ({
|
||||
state: modelingState,
|
||||
context: modelingState.context,
|
||||
send: modelingSend,
|
||||
streamRef,
|
||||
}}
|
||||
>
|
||||
{/* 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}>
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</ModelingMachineContext.Provider>
|
||||
)
|
||||
}
|
||||
|
@ -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: 'popover',
|
||||
className: overallConnectionStateColor[overallState].icon,
|
||||
toolTip: {
|
||||
children: `Network health (${NETWORK_HEALTH_TEXT[overallState]})`,
|
||||
},
|
||||
icon: overallConnectionStateIcon[overallState],
|
||||
popoverContent: <NetworkHealthPopoverContent />,
|
||||
}
|
||||
}
|
||||
|
||||
export const NetworkHealthIndicator = () => {
|
||||
@ -109,81 +124,95 @@ export const NetworkHealthIndicator = () => {
|
||||
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"
|
||||
>
|
||||
{NETWORK_HEALTH_TEXT[overallState]}
|
||||
</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>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Popover.Panel>
|
||||
<NetworkHealthPopoverContent />
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const NetworkHealthPopoverContent = () => {
|
||||
const {
|
||||
hasIssues,
|
||||
overallState,
|
||||
internetConnected,
|
||||
steps,
|
||||
issues,
|
||||
error,
|
||||
setHasCopied,
|
||||
hasCopied,
|
||||
} = useNetworkContext()
|
||||
|
||||
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 ${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"
|
||||
>
|
||||
{NETWORK_HEALTH_TEXT[overallState]}
|
||||
</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>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import { isDesktop } from 'lib/isDesktop'
|
||||
import { components } from 'lib/machine-api'
|
||||
import { MachineManagerContext } from 'components/MachineManagerProvider'
|
||||
import { CustomIcon } from './CustomIcon'
|
||||
import { StatusBarItemType } from './statusBar/statusBarTypes'
|
||||
|
||||
export const NetworkMachineIndicator = ({
|
||||
className,
|
||||
@ -27,12 +28,7 @@ export const NetworkMachineIndicator = ({
|
||||
}
|
||||
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>
|
||||
)}
|
||||
<NetworkMachinesIcon machineCount={machineCount} />
|
||||
<Tooltip position="top-right" wrapperClassName="ui-open:hidden">
|
||||
Network machines ({machineCount}) {reason && `: ${reason}`}
|
||||
</Tooltip>
|
||||
@ -41,50 +37,92 @@ export const NetworkMachineIndicator = ({
|
||||
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 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: ${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>
|
||||
)}
|
||||
<NetworkMachinesPopoverContent machines={machines} />
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
) : null
|
||||
}
|
||||
|
||||
export const useNetworkMachineStatus = (): StatusBarItemType => {
|
||||
const {
|
||||
noMachinesReason,
|
||||
machines,
|
||||
machines: { length: machineCount },
|
||||
} = useContext(MachineManagerContext)
|
||||
const reason = noMachinesReason()
|
||||
|
||||
return {
|
||||
id: 'network-machines',
|
||||
label: `Network machines (${machineCount}) ${reason && `: ${reason}`}`,
|
||||
hideLabel: true,
|
||||
element: 'popover',
|
||||
toolTip: {
|
||||
children: `Network machines (${machineCount}) ${reason && `: ${reason}`}`,
|
||||
},
|
||||
icon: 'printer3d',
|
||||
popoverContent: <NetworkMachinesPopoverContent machines={machines} />,
|
||||
}
|
||||
}
|
||||
|
||||
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="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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -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, PACKAGE_NAME } from 'routes/Settings'
|
||||
import { APP_VERSION } from 'lib/appVersion'
|
||||
import { PATHS } from 'lib/paths'
|
||||
import {
|
||||
createAndOpenNewTutorialProject,
|
||||
@ -25,6 +25,7 @@ import { useLspContext } from 'components/LspProvider'
|
||||
import { toSync } from 'lib/utils'
|
||||
import { reportRejection } from 'lib/trap'
|
||||
import { openExternalBrowserIfDesktop } from 'lib/openWindow'
|
||||
import { PACKAGE_NAME } from 'routes/Settings'
|
||||
|
||||
interface AllSettingsFieldsProps {
|
||||
searchParamTab: SettingsLevel
|
||||
|
148
src/components/StatusBar.tsx
Normal file
148
src/components/StatusBar.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { useEffect } from 'react'
|
||||
import { ActionButton } from './ActionButton'
|
||||
import { StatusBarItemType } from './statusBar/statusBarTypes'
|
||||
import Tooltip, { TooltipProps } from './Tooltip'
|
||||
import { ActionIcon } from './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 = `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 'popover':
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<Popover.Button
|
||||
as={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}
|
||||
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}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
96
src/components/statusBar/homeDefaultStatusBarItems.ts
Normal file
96
src/components/statusBar/homeDefaultStatusBarItems.ts
Normal 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/appVersion'
|
||||
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',
|
||||
icon: 'bug',
|
||||
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',
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
28
src/components/statusBar/statusBarTypes.ts
Normal file
28
src/components/statusBar/statusBarTypes.ts
Normal file
@ -0,0 +1,28 @@
|
||||
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: 'popover'
|
||||
popoverContent: React.ReactNode
|
||||
}
|
||||
| {
|
||||
element: 'link' | 'externalLink'
|
||||
href: string
|
||||
}
|
||||
| {
|
||||
element: 'text'
|
||||
}
|
||||
)
|
12
src/lib/appVersion.ts
Normal file
12
src/lib/appVersion.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { NODE_ENV } from 'env'
|
||||
import { isDesktop } from './isDesktop'
|
||||
import { isTestEnv } from './isTestEnv'
|
||||
|
||||
/** Version number of the app */
|
||||
export const APP_VERSION =
|
||||
isTestEnv && NODE_ENV === 'development'
|
||||
? '11.22.33'
|
||||
: isDesktop()
|
||||
? // @ts-ignore
|
||||
window.electron.packageJson.version
|
||||
: 'main'
|
@ -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/appVersion'
|
||||
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
4
src/lib/isTestEnv.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { IS_PLAYWRIGHT_KEY } from '../../e2e/playwright/storageStates'
|
||||
|
||||
export const isTestEnv =
|
||||
globalThis.window?.localStorage.getItem(IS_PLAYWRIGHT_KEY) === 'true'
|
22
src/lib/xStateValueToString.ts
Normal file
22
src/lib/xStateValueToString.ts
Normal 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()
|
||||
}
|
@ -13,18 +13,6 @@ 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 PACKAGE_NAME = isDesktop()
|
||||
? window.electron.packageJson.name
|
||||
|
@ -5,11 +5,11 @@ import { Themes, getSystemTheme } from '../lib/theme'
|
||||
import { PATHS } from 'lib/paths'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { APP_NAME } from 'lib/constants'
|
||||
import { APP_VERSION } from 'lib/appVersion'
|
||||
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'
|
||||
|
Reference in New Issue
Block a user