Files
modeling-app/src/routes/Home.tsx
Frank Noirot 7ab879a94f Show "experimental" badges around ML functionality (#6648)
* Make "experimental" a valid status for toolbar and commands

* Wire up status through createMachineCommand

* Add beaker icon

* Show UI elements if status is experimental

* Make ML operations experimental, powered by a flag

* Update command descriptions

* Add tooltip to home page Text-to-CAD button

* Splelnig erorrs

* 🧹lints

* Oopsie daisy Add KCL file isn't experimental

* Add warning message element to text area arg input

* Update message to common named constant
2025-05-05 15:36:22 +00:00

587 lines
20 KiB
TypeScript

import type { FormEvent, HTMLProps } from 'react'
import { useEffect } from 'react'
import { toast } from 'react-hot-toast'
import { useHotkeys } from 'react-hotkeys-hook'
import {
Link,
useLocation,
useNavigate,
useSearchParams,
} from 'react-router-dom'
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,
useProjectSearch,
} from '@src/components/ProjectSearchBar'
import { useCreateFileLinkQuery } from '@src/hooks/useCreateFileLinkQueryWatcher'
import { useMenuListener } from '@src/hooks/useMenu'
import { isDesktop } from '@src/lib/isDesktop'
import { PATHS } from '@src/lib/paths'
import { markOnce } from '@src/lib/performance'
import type { Project } from '@src/lib/project'
import { codeManager, kclManager } from '@src/lib/singletons'
import {
getNextSearchParams,
getSortFunction,
getSortIcon,
} from '@src/lib/sorting'
import { reportRejection } from '@src/lib/trap'
import { authActor, systemIOActor, useSettings } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons'
import {
useCanReadWriteProjectDirectory,
useFolders,
useState as useSystemIOState,
} from '@src/machines/systemIO/hooks'
import {
SystemIOMachineEvents,
SystemIOMachineStates,
} from '@src/machines/systemIO/utils'
import type { WebContentSendPayload } from '@src/menu/channels'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
import {
acceptOnboarding,
needsToOnboard,
onDismissOnboardingInvite,
} from '@src/routes/Onboarding/utils'
import { CustomIcon } from '@src/components/CustomIcon'
import Tooltip from '@src/components/Tooltip'
import { ML_EXPERIMENTAL_MESSAGE } from '@src/lib/constants'
type ReadWriteProjectState = {
value: boolean
error: unknown
}
// This route only opens in the desktop context for now,
// as defined in Router.tsx, so we can use the desktop APIs and types.
const Home = () => {
const readWriteProjectDir = useCanReadWriteProjectDirectory()
// Only create the native file menus on desktop
useEffect(() => {
if (isDesktop()) {
window.electron.createHomePageMenu().catch(reportRejection)
}
}, [])
// Keep a lookout for a URL query string that invokes the 'import file from URL' command
useCreateFileLinkQuery((argDefaultValues) => {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Import file from URL',
argDefaultValues,
},
})
})
const location = useLocation()
const navigate = useNavigate()
const settings = useSettings()
const onboardingStatus = settings.app.onboardingStatus.current
// Menu listeners
const cb = (data: WebContentSendPayload) => {
if (data.menuLabel === 'File.Create project') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Create project',
argDefaultValues: {
name: settings.projects.defaultProjectName.current,
},
},
})
} else if (data.menuLabel === 'File.Open project') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Open project',
},
})
} else if (data.menuLabel === 'Edit.Rename project') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Rename project',
},
})
} else if (data.menuLabel === 'Edit.Delete project') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Delete project',
},
})
} else if (data.menuLabel === 'File.Import file from URL') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Import file from URL',
},
})
} else if (data.menuLabel === 'File.Preferences.User settings') {
navigate(PATHS.HOME + PATHS.SETTINGS)
} else if (data.menuLabel === 'File.Preferences.Keybindings') {
navigate(PATHS.HOME + PATHS.SETTINGS_KEYBINDINGS)
} else if (data.menuLabel === 'File.Preferences.User default units') {
navigate(`${PATHS.HOME}${PATHS.SETTINGS_USER}#defaultUnit`)
} else if (data.menuLabel === 'Edit.Change project directory') {
navigate(`${PATHS.HOME}${PATHS.SETTINGS_USER}#projectDirectory`)
} else if (data.menuLabel === 'File.Sign out') {
authActor.send({ type: 'Log out' })
} else if (
data.menuLabel === 'View.Command Palette...' ||
data.menuLabel === 'Help.Command Palette...'
) {
commandBarActor.send({ type: 'Open' })
} else if (data.menuLabel === 'File.Preferences.Theme') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'settings',
name: 'app.theme',
},
})
} else if (data.menuLabel === 'File.Preferences.Theme color') {
navigate(`${PATHS.HOME}${PATHS.SETTINGS_USER}#themeColor`)
} else if (data.menuLabel === 'File.Add file to project') {
commandBarActor.send({
type: 'Find and select command',
data: {
name: 'add-kcl-file-to-project',
groupId: 'application',
},
})
} else if (data.menuLabel === 'Design.Create with Zoo Text-To-CAD') {
commandBarActor.send({
type: 'Find and select command',
data: {
name: 'Text-to-CAD',
groupId: 'application',
argDefaultValues: {
method: 'newProject',
newProjectName: settings.projects.defaultProjectName.current,
},
},
})
}
}
useMenuListener(cb)
// Cancel all KCL executions while on the home page
useEffect(() => {
markOnce('code/didLoadHome')
kclManager.cancelAllExecutions()
}, [])
useHotkeys('backspace', (e) => {
e.preventDefault()
})
useHotkeys(
isDesktop() ? 'mod+,' : 'shift+mod+,',
() => navigate(PATHS.HOME + PATHS.SETTINGS),
{
splitKey: '|',
}
)
const projects = useFolders()
const [searchParams, setSearchParams] = useSearchParams()
const { searchResults, query, setQuery } = useProjectSearch(projects)
const sort = searchParams.get('sort_by') ?? 'modified:desc'
const sidebarButtonClasses =
'flex items-center p-2 gap-2 leading-tight border-transparent dark:border-transparent enabled:dark:border-transparent enabled:hover:border-primary/50 enabled:dark:hover:border-inherit active:border-primary dark:bg-transparent hover:bg-transparent'
return (
<div className="relative flex flex-col items-stretch h-screen w-screen overflow-hidden">
<AppHeader 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">
<HomeHeader
setQuery={setQuery}
sort={sort}
setSearchParams={setSearchParams}
settings={settings}
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">
<ul className="flex flex-col">
{needsToOnboard(location, onboardingStatus) && (
<li className="flex group">
<ActionButton
Element="button"
onClick={() => {
acceptOnboarding({
onboardingStatus,
navigate,
codeManager,
kclManager,
}).catch(reportRejection)
}}
className={`${sidebarButtonClasses} !text-primary flex-1`}
iconStart={{
icon: 'play',
bgClassName: '!bg-primary rounded-sm',
iconClassName: '!text-white',
}}
data-testid="home-tutorial-button"
>
{onboardingStatus === '' ? 'Start' : 'Continue'} tutorial
</ActionButton>
<ActionButton
Element="button"
onClick={onDismissOnboardingInvite}
className={`${sidebarButtonClasses} hidden group-hover:flex flex-none ml-auto`}
iconStart={{
icon: 'close',
bgClassName: '!bg-transparent rounded-sm',
}}
data-testid="onboarding-dismiss"
>
<Tooltip>Dismiss tutorial</Tooltip>
</ActionButton>
</li>
)}
<li className="contents">
<ActionButton
Element="button"
onClick={() =>
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Create project',
},
})
}
className={sidebarButtonClasses}
iconStart={{
icon: 'plus',
bgClassName: '!bg-transparent rounded-sm',
}}
data-testid="home-new-file"
>
Create project
</ActionButton>
</li>
<li className="contents">
<ActionButton
Element="button"
onClick={() =>
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'application',
name: 'Text-to-CAD',
argDefaultValues: {
method: 'newProject',
newProjectName:
settings.projects.defaultProjectName.current,
},
},
})
}
className={sidebarButtonClasses}
iconStart={{
icon: 'sparkles',
bgClassName: '!bg-transparent rounded-sm',
}}
data-testid="home-text-to-cad"
>
Generate with Text-to-CAD
<Tooltip position="bottom-left">
<div className="text-sm flex flex-col max-w-xs">
<div className="text-xs flex justify-center item-center gap-1 pb-1 border-b border-chalkboard-50">
<CustomIcon name="beaker" className="w-4 h-4" />
<span>Experimental</span>
</div>
<p className="pt-2 text-left">{ML_EXPERIMENTAL_MESSAGE}</p>
</div>
</Tooltip>
</ActionButton>
</li>
<li className="contents">
<ActionButton
Element="button"
onClick={() =>
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'application',
name: 'add-kcl-file-to-project',
argDefaultValues: {
source: 'kcl-samples',
method: 'newProject',
newProjectName:
settings.projects.defaultProjectName.current,
},
},
})
}
className={sidebarButtonClasses}
iconStart={{
icon: 'importFile',
bgClassName: '!bg-transparent rounded-sm',
}}
data-testid="home-create-from-sample"
>
Create from a sample
</ActionButton>
</li>
</ul>
<ul className="flex flex-col">
<li className="contents">
<ActionButton
Element="externalLink"
to="https://zoo.dev/docs"
onClick={openExternalBrowserIfDesktop(
'https://zoo.dev/account'
)}
className={sidebarButtonClasses}
iconStart={{
icon: 'person',
bgClassName: '!bg-transparent rounded-sm',
}}
data-testid="home-account"
>
View your account
</ActionButton>
</li>
<li className="contents">
<ActionButton
Element="externalLink"
to="https://zoo.dev/blog"
onClick={openExternalBrowserIfDesktop('https://zoo.dev/blog')}
className={sidebarButtonClasses}
iconStart={{
icon: 'glasses',
bgClassName: '!bg-transparent rounded-sm',
}}
data-testid="home-blog"
>
Read the Zoo blog
</ActionButton>
</li>
</ul>
</aside>
<ProjectGrid
searchResults={searchResults}
projects={projects}
query={query}
sort={sort}
className="flex-1 col-start-2 -col-end-1 overflow-y-auto pr-2 pb-24"
/>
<LowerRightControls navigate={navigate} />
</div>
</div>
)
}
interface HomeHeaderProps extends HTMLProps<HTMLDivElement> {
setQuery: (query: string) => void
sort: string
setSearchParams: (params: Record<string, string>) => void
settings: ReturnType<typeof useSettings>
readWriteProjectDir: ReadWriteProjectState
}
function HomeHeader({
setQuery,
sort,
setSearchParams,
settings,
readWriteProjectDir,
...rest
}: HomeHeaderProps) {
const isSortByModified = sort?.includes('modified') || !sort || sort === null
return (
<section {...rest}>
<div className="flex flex-col md:flex-row gap-4 justify-between md:items-center select-none">
<div className="flex gap-8 items-center">
<h1 className="text-3xl font-bold">Projects</h1>
</div>
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
<ProjectSearchBar setQuery={setQuery} />
<div className="flex gap-2 items-center">
<small>Sort by</small>
<ActionButton
Element="button"
data-testid="home-sort-by-name"
className={`text-xs border-primary/10 ${
!sort.includes('name')
? 'text-chalkboard-80 dark:text-chalkboard-40'
: ''
}`}
onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))}
iconStart={{
icon: getSortIcon(sort, 'name'),
bgClassName: 'bg-transparent',
iconClassName: !sort.includes('name')
? '!text-chalkboard-90 dark:!text-chalkboard-30'
: '',
}}
>
Name
</ActionButton>
<ActionButton
Element="button"
data-testid="home-sort-by-modified"
className={`text-xs border-primary/10 ${
!isSortByModified
? 'text-chalkboard-80 dark:text-chalkboard-40'
: ''
}`}
onClick={() =>
setSearchParams(getNextSearchParams(sort, 'modified'))
}
iconStart={{
icon: sort ? getSortIcon(sort, 'modified') : 'arrowDown',
bgClassName: 'bg-transparent',
iconClassName: !isSortByModified
? '!text-chalkboard-90 dark:!text-chalkboard-30'
: '',
}}
>
Last Modified
</ActionButton>
</div>
</div>
</div>
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
Loaded from{' '}
<Link
data-testid="project-directory-settings-link"
to={`${PATHS.HOME + PATHS.SETTINGS_USER}#projectDirectory`}
className="text-chalkboard-90 dark:text-chalkboard-20 underline underline-offset-2"
>
{settings.app.projectDirectory.current}
</Link>
.
</p>
{!readWriteProjectDir.value && (
<section>
<div className="flex items-center select-none">
<div className="flex gap-8 items-center justify-between grow bg-destroy-80 text-white py-1 px-4 my-2 rounded-sm">
<p className="">{errorMessage(readWriteProjectDir.error)}</p>
<Link
data-testid="project-directory-settings-link"
to={`${PATHS.HOME + PATHS.SETTINGS_USER}#projectDirectory`}
className="py-1 text-white underline underline-offset-2 text-sm"
>
Change Project Directory
</Link>
</div>
</div>
</section>
)}
</section>
)
}
interface ProjectGridProps extends HTMLProps<HTMLDivElement> {
searchResults: Project[]
projects: Project[]
query: string
sort: string
}
function ProjectGrid({
searchResults,
projects,
query,
sort,
...rest
}: ProjectGridProps) {
const state = useSystemIOState()
return (
<section data-testid="home-section" {...rest}>
{state.matches(SystemIOMachineStates.readingFolders) ? (
<Loading>Loading your Projects...</Loading>
) : (
<>
{searchResults.length > 0 ? (
<ul className="grid w-full sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{searchResults.sort(getSortFunction(sort)).map((project) => (
<ProjectCard
key={project.name}
project={project}
handleRenameProject={handleRenameProject}
handleDeleteProject={handleDeleteProject}
/>
))}
</ul>
) : (
<p className="p-4 my-8 border border-dashed rounded border-chalkboard-30 dark:border-chalkboard-70">
No projects found
{projects.length === 0
? ', ready to make your first one?'
: ` with the search term "${query}"`}
</p>
)}
</>
)}
</section>
)
}
/** Type narrowing function of unknown error to a string */
function errorMessage(error: unknown): string {
if (error !== undefined && error instanceof Error) {
return error.message
}
if (error && typeof error === 'object') {
return JSON.stringify(error)
}
if (typeof error === 'string') {
return error
}
return 'Unknown error'
}
async function handleRenameProject(
e: FormEvent<HTMLFormElement>,
project: Project
) {
const { newProjectName } = Object.fromEntries(
new FormData(e.target as HTMLFormElement)
)
if (typeof newProjectName === 'string' && newProjectName.startsWith('.')) {
toast.error('Project names cannot start with a dot (.)')
return
}
if (newProjectName !== project.name) {
systemIOActor.send({
type: SystemIOMachineEvents.renameProject,
data: {
requestedProjectName: String(newProjectName),
projectName: project.name,
},
})
}
}
async function handleDeleteProject(project: Project) {
systemIOActor.send({
type: SystemIOMachineEvents.deleteProject,
data: { requestedProjectName: project.name },
})
}
export default Home