Nadro/adhoc/system io machine (#6352)

* chore: saving off skeleton

* fix: saving skeleton

* chore: skeleton for loading projects from project directory path

* chore: cleaning up useless state transition to be an on event direct to action state

* fix: new structure for web vs desktop vs react machine provider code

* chore: saving off skeleton

* fix: skeleton logic for react? going to move it from a string to obj.string

* fix: trying to prevent error element unmount on global react components. This is bricking JS state

* fix: we are so back

* chore: implemented navigating to specfic KCL file

* chore: implementing renaming project

* chore: deleting project

* fix: auto fixes

* fix: old debug/testing file oops

* chore: generic create new file

* chore: skeleton for web create file provide

* chore: basic machine vitest... need to figure out how to get window.electron implemented in vitest?

* chore: save off progress before deleting other project implementation, a few missing features still

* chore: trying a different init skeleton? most likely will migrate

* chore: first attempt of purging projects context provider

* chore: enabling toast for some machine state

* chore: enabling more toast success and error

* chore: writing read write state to the system io based on the project path

* fix: tsc fixes

* fix: use file system watcher, navigate to project after creation via the requestProjectName

* chore: open project command, hooks vs snapshot context helpers

* chore: implemented open and create project for e2e testing. They are hard coded in poor spot for now.

* fix: codespell fixes

* chore: implementing more project commands

* chore: PR improvements for root.tsx

* chore: leaving comment about new Router.tsx layout

* fix: removing debugging code

* fix: rewriting component for readability

* fix: improving web initialization

* chore: implementing import file from url which is not actually that?

* fix: clearing search params on import file from url

* fix: fixed two e2e tests, forgot needsReview when making new command

* fix: fixing some import from url business logic to pass e2e tests

* chore: script for diffing circular deps +/-

* fix: formatting

* fix: massive fix for circular depsga!

* fix: trying to fix some errors and auto fmt

* fix: updating deps

* fix: removing debugging code

* fix: big clean up

* fix: more deletion

* fix: tsc cleanup

* fix: TSC TSC TSC TSC!

* fix: typo fix

* fix: clear query params on web only, desktop not required

* fix: removing unused code

* fmt

* Bring back `trap` removed in merge

* Use explicit types instead of `any`s on arg configs

* Add project commands directly to command palette

* fix: deleting debugging code, from PR review

* fix: this got added back(?)

* fix: using referred type

* fix: more PR clean up

* fix: big block comment for xstate architecture decision

* fix: more pr comment fixes

* fix: merge conflict just added them back why dude

* fix: more PR comments

* fix: big ciruclar deps fix, commandBarActor in appActor

---------

Co-authored-by: Frank Noirot <frankjohnson1993@gmail.com>
This commit is contained in:
Kevin Nadro
2025-04-24 13:32:49 -05:00
committed by GitHub
parent 95f2caacab
commit 305d613d40
93 changed files with 1704 additions and 1250 deletions

View File

@ -3,14 +3,13 @@
> dpdm --no-warning --no-tree -T --skip-dynamic-imports=circular src/index.tsx
• Circular Dependencies
01) src/lang/std/sketch.ts -> src/lang/modifyAst.ts -> src/lang/modifyAst/addEdgeTreatment.ts
02) src/lang/std/sketch.ts -> src/lang/modifyAst.ts
03) src/lang/std/sketch.ts -> src/lang/modifyAst.ts -> src/lang/std/sketchcombos.ts
04) src/lib/singletons.ts -> src/editor/manager.ts -> src/lib/selections.ts
05) src/lib/singletons.ts -> src/editor/manager.ts -> src/lib/selections.ts -> src/machines/appMachine.ts -> src/machines/engineStreamMachine.ts
06) src/lib/singletons.ts -> src/editor/manager.ts -> src/lib/selections.ts -> src/machines/appMachine.ts -> src/machines/settingsMachine.ts
07) src/machines/appMachine.ts -> src/machines/settingsMachine.ts -> src/machines/commandBarMachine.ts -> src/lib/commandBarConfigs/authCommandConfig.ts
08) src/lib/singletons.ts -> src/lang/codeManager.ts
09) src/lib/singletons.ts -> src/clientSideScene/sceneEntities.ts -> src/clientSideScene/segments.ts -> src/components/Toolbar/angleLengthInfo.ts
10) src/hooks/useModelingContext.ts -> src/components/ModelingMachineProvider.tsx -> src/components/Toolbar/Intersect.tsx -> src/components/SetHorVertDistanceModal.tsx -> src/lib/useCalculateKclExpression.ts
11) src/routes/Onboarding/index.tsx -> src/routes/Onboarding/Camera.tsx -> src/routes/Onboarding/utils.tsx
1) src/lang/std/sketch.ts -> src/lang/modifyAst.ts -> src/lang/modifyAst/addEdgeTreatment.ts
2) src/lang/std/sketch.ts -> src/lang/modifyAst.ts
3) src/lang/std/sketch.ts -> src/lang/modifyAst.ts -> src/lang/std/sketchcombos.ts
4) src/lib/singletons.ts -> src/editor/manager.ts -> src/lib/selections.ts
5) src/lib/singletons.ts -> src/editor/manager.ts -> src/lib/selections.ts
6) src/lib/singletons.ts -> src/lang/codeManager.ts
7) src/lib/singletons.ts -> src/clientSideScene/sceneEntities.ts -> src/clientSideScene/segments.ts -> src/components/Toolbar/angleLengthInfo.ts
8) src/lib/singletons.ts -> src/clientSideScene/sceneEntities.ts -> src/clientSideScene/segments.ts
9) src/hooks/useModelingContext.ts -> src/components/ModelingMachineProvider.tsx -> src/components/Toolbar/Intersect.tsx -> src/components/SetHorVertDistanceModal.tsx -> src/lib/useCalculateKclExpression.ts
10) src/routes/Onboarding/index.tsx -> src/routes/Onboarding/Camera.tsx -> src/routes/Onboarding/utils.tsx

View File

@ -114,6 +114,7 @@
"circular-deps": "dpdm --no-warning --no-tree -T --skip-dynamic-imports=circular src/index.tsx",
"circular-deps:overwrite": "npm run circular-deps | sed '$d' | grep -v '^npm run' > known-circular.txt",
"circular-deps:diff": "./scripts/diff-circular-deps.sh",
"circular-deps:diff:nodejs": "npm run circular-deps:diff || node ./scripts/diff.js",
"files:set-version": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json",
"files:set-notes": "./scripts/set-files-notes.sh",
"files:flip-to-nightly": "./scripts/flip-files-to-nightly.sh",

59
scripts/diff.js Normal file
View File

@ -0,0 +1,59 @@
const fs = require('fs')
const latestRun = fs.readFileSync('/tmp/circular-deps.txt','utf-8')
const knownCircular = fs.readFileSync('./known-circular.txt','utf-8')
function parseLine (line) {
let num = null
let depPath = null
const res = line.split(")",2)
if (res.length === 2) {
// should be a dep line
num = parseInt(res[0])
depPath = res[1]
}
return {
num,
depPath
}
}
function makeDependencyHash (file) {
const deps = {}
file.split("\n").forEach((line)=>{
const {num, depPath} = parseLine(line)
if (depPath && !isNaN(num)) {
deps[depPath] = 1
}
})
return deps
}
const latestRunDepHash = makeDependencyHash(latestRun)
const knownDepHash = makeDependencyHash(knownCircular)
const dup1 = JSON.parse(JSON.stringify(latestRunDepHash))
const dup2 = JSON.parse(JSON.stringify(knownDepHash))
Object.keys(knownDepHash).forEach((key)=>{
delete dup1[key]
})
Object.keys(latestRunDepHash).forEach((key)=>{
delete dup2[key]
})
console.log(" ")
console.log("diff.js - line item diff")
console.log(" ")
console.log("Added(+)")
Object.keys(dup1).forEach((dep, index)=>{
console.log(`${index+1}) ${dep}`)
})
console.log(" ")
console.log("Removed(-)")
if (Object.keys(dup2).length === 0) {
console.log("None")
}
Object.keys(dup2).forEach((dep, index)=>{
console.log(`${index+1}) ${dep}`)
})

View File

@ -28,13 +28,9 @@ import { PATHS } from '@src/lib/paths'
import { takeScreenshotOfVideoStreamCanvas } from '@src/lib/screenshot'
import { sceneInfra } from '@src/lib/singletons'
import { maybeWriteToDisk } from '@src/lib/telemetry'
import type { IndexLoaderData } from '@src/lib/types'
import {
engineStreamActor,
useSettings,
useToken,
} from '@src/machines/appMachine'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { type IndexLoaderData } from '@src/lib/types'
import { engineStreamActor, useSettings, useToken } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons'
import { EngineStreamTransition } from '@src/machines/engineStreamMachine'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { CommandBarOpenButton } from '@src/components/CommandBarOpenButton'

View File

@ -1,5 +1,5 @@
import Loading from '@src/components/Loading'
import { useAuthState } from '@src/machines/appMachine'
import { useAuthState } from '@src/lib/singletons'
// Wrapper around protected routes, used in src/Router.tsx
export const Auth = ({ children }: React.PropsWithChildren) => {

35
src/Root.tsx Normal file
View File

@ -0,0 +1,35 @@
import { AppStateProvider } from '@src/AppState'
import LspProvider from '@src/components/LspProvider'
import { MachineManagerProvider } from '@src/components/MachineManagerProvider'
import { OpenInDesktopAppHandler } from '@src/components/OpenInDesktopAppHandler'
import { SystemIOMachineLogicListenerDesktop } from '@src/components/Providers/SystemIOProviderDesktop'
import { SystemIOMachineLogicListenerWeb } from '@src/components/Providers/SystemIOProviderWeb'
import { RouteProvider } from '@src/components/RouteProvider'
import { KclContextProvider } from '@src/lang/KclProvider'
import { Outlet } from 'react-router-dom'
import { isDesktop } from '@src/lib/isDesktop'
// Root component will live for the entire applications runtime
function RootLayout() {
return (
<OpenInDesktopAppHandler>
<RouteProvider>
<LspProvider>
<KclContextProvider>
<AppStateProvider>
<MachineManagerProvider>
{isDesktop() ? (
<SystemIOMachineLogicListenerDesktop />
) : (
<SystemIOMachineLogicListenerWeb />
)}
<Outlet />
</MachineManagerProvider>
</AppStateProvider>
</KclContextProvider>
</LspProvider>
</RouteProvider>
</OpenInDesktopAppHandler>
)
}
export default RootLayout

View File

@ -9,22 +9,15 @@ import {
} from 'react-router-dom'
import { App } from '@src/App'
import { AppStateProvider } from '@src/AppState'
import { Auth } from '@src/Auth'
import { CommandBar } from '@src/components/CommandBar/CommandBar'
import DownloadAppBanner from '@src/components/DownloadAppBanner'
import { ErrorPage } from '@src/components/ErrorPage'
import FileMachineProvider from '@src/components/FileMachineProvider'
import LspProvider from '@src/components/LspProvider'
import { MachineManagerProvider } from '@src/components/MachineManagerProvider'
import ModelingMachineProvider from '@src/components/ModelingMachineProvider'
import { OpenInDesktopAppHandler } from '@src/components/OpenInDesktopAppHandler'
import { ProjectsContextProvider } from '@src/components/ProjectsContextProvider'
import { RouteProvider } from '@src/components/RouteProvider'
import { WasmErrBanner } from '@src/components/WasmErrBanner'
import { NetworkContext } from '@src/hooks/useNetworkContext'
import { useNetworkStatus } from '@src/hooks/useNetworkStatus'
import { KclContextProvider } from '@src/lang/KclProvider'
import { coreDump } from '@src/lang/wasm'
import {
ASK_TO_OPEN_QUERY_PARAM,
@ -42,7 +35,8 @@ import {
rustContext,
} from '@src/lib/singletons'
import { reportRejection } from '@src/lib/trap'
import { useToken } from '@src/machines/appMachine'
import { useToken } from '@src/lib/singletons'
import RootLayout from '@src/Root'
import Home from '@src/routes/Home'
import Onboarding, { onboardingRoutes } from '@src/routes/Onboarding'
import { Settings } from '@src/routes/Settings'
@ -54,27 +48,13 @@ const createRouter = isDesktop() ? createHashRouter : createBrowserRouter
const router = createRouter([
{
id: PATHS.INDEX,
element: (
<OpenInDesktopAppHandler>
<RouteProvider>
<LspProvider>
<ProjectsContextProvider>
<KclContextProvider>
<AppStateProvider>
<MachineManagerProvider>
<Outlet />
</MachineManagerProvider>
</AppStateProvider>
</KclContextProvider>
</ProjectsContextProvider>
</LspProvider>
</RouteProvider>
</OpenInDesktopAppHandler>
),
errorElement: <ErrorPage />,
element: <RootLayout />,
// Gotcha: declaring errorElement on the root will unmount the element causing our forever React components to unmount.
// Leave errorElement on the child components, this allows for the entire react context on error pages as well.
children: [
{
path: PATHS.INDEX,
errorElement: <ErrorPage />,
loader: async ({ request }) => {
const onDesktop = isDesktop()
const url = new URL(request.url)
@ -95,6 +75,7 @@ const router = createRouter([
loader: fileLoader,
id: PATHS.FILE,
path: PATHS.FILE + '/:id',
errorElement: <ErrorPage />,
element: (
<Auth>
<FileMachineProvider>
@ -141,6 +122,7 @@ const router = createRouter([
},
{
path: PATHS.HOME,
errorElement: <ErrorPage />,
element: (
<Auth>
<Outlet />
@ -169,6 +151,7 @@ const router = createRouter([
},
{
path: PATHS.SIGN_IN,
errorElement: <ErrorPage />,
element: <SignIn />,
},
],

View File

@ -26,7 +26,7 @@ import type {
} from '@src/lib/toolbar'
import { isToolbarItemResolvedDropdown, toolbarConfig } from '@src/lib/toolbar'
import { isArray } from '@src/lib/utils'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { commandBarActor } from '@src/lib/singletons'
export function Toolbar({
className = '',

View File

@ -40,8 +40,8 @@ import {
} from '@src/lib/singletons'
import { err, reportRejection, trap } from '@src/lib/trap'
import { throttle, toSync } from '@src/lib/utils'
import type { useSettings } from '@src/machines/appMachine'
import { commandBarActor } from '@src/machines/commandBarMachine'
import type { useSettings } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons'
import type { SegmentOverlay } from '@src/machines/modelingMachine'
function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } {

View File

@ -84,7 +84,7 @@ import { getThemeColorForThreeJs } from '@src/lib/theme'
import { err } from '@src/lib/trap'
import { isClockwise, normaliseAngle, roundOff } from '@src/lib/utils'
import { getTangentPointFromPreviousArc } from '@src/lib/utils2d'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { commandBarActor } from '@src/lib/singletons'
import type {
SegmentOverlay,
SegmentOverlayPayload,

View File

@ -4,7 +4,7 @@ import ProjectSidebarMenu from '@src/components/ProjectSidebarMenu'
import UserSidebarMenu from '@src/components/UserSidebarMenu'
import { isDesktop } from '@src/lib/isDesktop'
import { type IndexLoaderData } from '@src/lib/types'
import { useUser } from '@src/machines/appMachine'
import { useUser } from '@src/lib/singletons'
import styles from './AppHeader.module.css'

View File

@ -1,7 +1,7 @@
import { Switch } from '@headlessui/react'
import { useEffect, useState } from 'react'
import { settingsActor, useSettings } from '@src/machines/appMachine'
import { settingsActor, useSettings } from '@src/lib/singletons'
export function CameraProjectionToggle() {
const settings = useSettings()

View File

@ -8,10 +8,7 @@ import type {
CommandArgument,
CommandArgumentOption,
} from '@src/lib/commandTypes'
import {
commandBarActor,
useCommandBarState,
} from '@src/machines/commandBarMachine'
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
const contextSelector = (snapshot: StateFrom<AnyStateMachine> | undefined) =>
snapshot?.context

View File

@ -11,10 +11,7 @@ import { useNetworkContext } from '@src/hooks/useNetworkContext'
import { EngineConnectionStateType } from '@src/lang/std/engineConnection'
import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
import { engineCommandManager } from '@src/lib/singletons'
import {
commandBarActor,
useCommandBarState,
} from '@src/machines/commandBarMachine'
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
import toast from 'react-hot-toast'
export const COMMAND_PALETTE_HOTKEY = 'mod+k'

View File

@ -7,10 +7,7 @@ import CommandBarSelectionInput from '@src/components/CommandBar/CommandBarSelec
import CommandBarSelectionMixedInput from '@src/components/CommandBar/CommandBarSelectionMixedInput'
import CommandBarTextareaInput from '@src/components/CommandBar/CommandBarTextareaInput'
import type { CommandArgument } from '@src/lib/commandTypes'
import {
commandBarActor,
useCommandBarState,
} from '@src/machines/commandBarMachine'
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
function CommandBarArgument({ stepBack }: { stepBack: () => void }) {
const commandBarState = useCommandBarState()

View File

@ -3,10 +3,7 @@ import { useEffect, useMemo, useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import type { CommandArgument } from '@src/lib/commandTypes'
import {
commandBarActor,
useCommandBarState,
} from '@src/machines/commandBarMachine'
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
import type { AnyStateMachine, SnapshotFrom } from 'xstate'
// TODO: remove the need for this selector once we decouple all actors from React

View File

@ -11,10 +11,7 @@ import type {
import type { Selections } from '@src/lib/selections'
import { getSelectionTypeDisplayText } from '@src/lib/selections'
import { roundOff } from '@src/lib/utils'
import {
commandBarActor,
useCommandBarState,
} from '@src/machines/commandBarMachine'
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
function CommandBarHeader({ children }: React.PropsWithChildren<object>) {
const commandBarState = useCommandBarState()

View File

@ -29,11 +29,8 @@ import { err } from '@src/lib/trap'
import { useCalculateKclExpression } from '@src/lib/useCalculateKclExpression'
import { roundOff } from '@src/lib/utils'
import { varMentions } from '@src/lib/varCompletionExtension'
import { useSettings } from '@src/machines/appMachine'
import {
commandBarActor,
useCommandBarState,
} from '@src/machines/commandBarMachine'
import { useSettings } from '@src/lib/singletons'
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
import styles from './CommandBarKclInput.module.css'

View File

@ -5,10 +5,7 @@ import { ActionButton } from '@src/components/ActionButton'
import type { CommandArgument } from '@src/lib/commandTypes'
import { reportRejection } from '@src/lib/trap'
import { isArray, toSync } from '@src/lib/utils'
import {
commandBarActor,
useCommandBarState,
} from '@src/machines/commandBarMachine'
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
import { useSelector } from '@xstate/react'
import type { AnyStateMachine, SnapshotFrom } from 'xstate'

View File

@ -1,10 +1,7 @@
import { useHotkeys } from 'react-hotkeys-hook'
import CommandBarHeader from '@src/components/CommandBar/CommandBarHeader'
import {
commandBarActor,
useCommandBarState,
} from '@src/machines/commandBarMachine'
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
function CommandBarReview({ stepBack }: { stepBack: () => void }) {
const commandBarState = useCommandBarState()

View File

@ -12,10 +12,7 @@ import {
import { engineCommandManager, kclManager } from '@src/lib/singletons'
import { reportRejection } from '@src/lib/trap'
import { toSync } from '@src/lib/utils'
import {
commandBarActor,
useCommandBarState,
} from '@src/machines/commandBarMachine'
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
import type { modelingMachine } from '@src/machines/modelingMachine'
const semanticEntityNames: {

View File

@ -8,10 +8,7 @@ import {
getSelectionCountByType,
} from '@src/lib/selections'
import { kclManager } from '@src/lib/singletons'
import {
commandBarActor,
useCommandBarState,
} from '@src/machines/commandBarMachine'
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
const selectionSelector = (snapshot: any) => snapshot?.context.selectionRanges

View File

@ -3,10 +3,7 @@ import { useEffect, useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import type { CommandArgument } from '@src/lib/commandTypes'
import {
commandBarActor,
useCommandBarState,
} from '@src/machines/commandBarMachine'
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
function CommandBarTextareaInput({
arg,

View File

@ -1,7 +1,7 @@
import { COMMAND_PALETTE_HOTKEY } from '@src/components/CommandBar/CommandBar'
import usePlatform from '@src/hooks/usePlatform'
import { hotkeyDisplay } from '@src/lib/hotkeyWrapper'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { commandBarActor } from '@src/lib/singletons'
import { CustomIcon } from '@src/components/CustomIcon'
export function CommandBarOpenButton() {

View File

@ -6,7 +6,7 @@ import { CustomIcon } from '@src/components/CustomIcon'
import type { Command } from '@src/lib/commandTypes'
import { sortCommands } from '@src/lib/commandUtils'
import { getActorNextEvents } from '@src/lib/utils'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { commandBarActor } from '@src/lib/singletons'
function CommandComboBox({
options,

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import { ActionButton } from '@src/components/ActionButton'
import { CREATE_FILE_URL_PARAM } from '@src/lib/constants'
import { useSettings } from '@src/machines/appMachine'
import { useSettings } from '@src/lib/singletons'
import { useSearchParams } from 'react-router-dom'
const DownloadAppBanner = () => {

View File

@ -18,8 +18,8 @@ import { REASONABLE_TIME_TO_REFRESH_STREAM_SIZE } from '@src/lib/timings'
import { err, reportRejection, trap } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types'
import { uuidv4 } from '@src/lib/utils'
import { engineStreamActor, useSettings } from '@src/machines/appMachine'
import { useCommandBarState } from '@src/machines/commandBarMachine'
import { engineStreamActor, useSettings } from '@src/lib/singletons'
import { useCommandBarState } from '@src/lib/singletons'
import {
EngineStreamState,
EngineStreamTransition,

View File

@ -33,8 +33,8 @@ import { markOnce } from '@src/lib/performance'
import { codeManager, kclManager } from '@src/lib/singletons'
import { err, reportRejection } from '@src/lib/trap'
import { type IndexLoaderData } from '@src/lib/types'
import { useSettings, useToken } from '@src/machines/appMachine'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { useSettings, useToken } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons'
import { fileMachine } from '@src/machines/fileMachine'
import { modelingMenuCallbackMostActions } from '@src/menu/register'

View File

@ -29,7 +29,7 @@ import { reportRejection } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types'
import { ToastInsert } from '@src/components/ToastInsert'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { commandBarActor } from '@src/lib/singletons'
import toast from 'react-hot-toast'
import styles from './FileTree.module.css'

View File

@ -27,7 +27,7 @@ import { useModelingContext } from '@src/hooks/useModelingContext'
import { AxisNames } from '@src/lib/constants'
import { sceneInfra } from '@src/lib/singletons'
import { reportRejection } from '@src/lib/trap'
import { useSettings } from '@src/machines/appMachine'
import { useSettings } from '@src/lib/singletons'
const CANVAS_SIZE = 80
const FRUSTUM_SIZE = 0.5

View File

@ -10,7 +10,7 @@ import { createAndOpenNewTutorialProject } from '@src/lib/desktopFS'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
import { PATHS } from '@src/lib/paths'
import { reportRejection } from '@src/lib/trap'
import { settingsActor } from '@src/machines/appMachine'
import { settingsActor } from '@src/lib/singletons'
import type { WebContentSendPayload } from '@src/menu/channels'
const HelpMenuDivider = () => (

View File

@ -27,7 +27,7 @@ import { PATHS } from '@src/lib/paths'
import type { FileEntry } from '@src/lib/project'
import { codeManager } from '@src/lib/singletons'
import { err } from '@src/lib/trap'
import { useToken } from '@src/machines/appMachine'
import { useToken } from '@src/lib/singletons'
function getWorkspaceFolders(): LSP.WorkspaceFolder[] {
return []

View File

@ -5,7 +5,7 @@ import type { components } from '@src/lib/machine-api'
import { engineCommandManager } from '@src/lib/singletons'
import { reportRejection } from '@src/lib/trap'
import { toSync } from '@src/lib/utils'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { commandBarActor } from '@src/lib/singletons'
export type MachinesListing = Array<
components['schemas']['MachineInfoResponse']

View File

@ -111,8 +111,8 @@ import { submitAndAwaitTextToKcl } from '@src/lib/textToCad'
import { err, reject, reportRejection, trap } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types'
import { platform, uuidv4 } from '@src/lib/utils'
import { useSettings, useToken } from '@src/machines/appMachine'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { useSettings, useToken } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons'
import { kclEditorActor } from '@src/machines/kclEditorMachine'
import {
getPersistedContext,

View File

@ -5,7 +5,7 @@ import { ActionButton } from '@src/components/ActionButton'
import { ActionIcon } from '@src/components/ActionIcon'
import type { CustomIconName } from '@src/components/CustomIcon'
import Tooltip from '@src/components/Tooltip'
import { useSettings } from '@src/machines/appMachine'
import { useSettings } from '@src/lib/singletons'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import styles from './ModelingPane.module.css'

View File

@ -2,7 +2,7 @@ import { Menu } from '@headlessui/react'
import type { PropsWithChildren } from 'react'
import { ActionIcon } from '@src/components/ActionIcon'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { commandBarActor } from '@src/lib/singletons'
import styles from './KclEditorMenu.module.css'

View File

@ -9,7 +9,7 @@ import { useConvertToVariable } from '@src/hooks/useToolbarGuards'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
import { kclManager } from '@src/lib/singletons'
import { reportRejection } from '@src/lib/trap'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { commandBarActor } from '@src/lib/singletons'
import styles from './KclEditorMenu.module.css'

View File

@ -47,7 +47,7 @@ import { codeManagerHistoryCompartment } from '@src/lang/codeManager'
import { codeManager, editorManager, kclManager } from '@src/lib/singletons'
import { Themes, getSystemTheme } from '@src/lib/theme'
import { onMouseDragMakeANewNumber, onMouseDragRegex } from '@src/lib/utils'
import { useSettings } from '@src/machines/appMachine'
import { useSettings } from '@src/lib/singletons'
import {
editorIsMountedSelector,
kclEditorActor,

View File

@ -22,8 +22,8 @@ import { useKclContext } from '@src/lang/KclProvider'
import { EngineConnectionStateType } from '@src/lang/std/engineConnection'
import { SIDEBAR_BUTTON_SUFFIX } from '@src/lib/constants'
import { isDesktop } from '@src/lib/isDesktop'
import { useSettings } from '@src/machines/appMachine'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { useSettings } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { reportRejection } from '@src/lib/trap'
import { refreshPage } from '@src/lib/utils'

View File

@ -23,8 +23,8 @@ import {
kclManager,
} from '@src/lib/singletons'
import { type IndexLoaderData } from '@src/lib/types'
import { useToken } from '@src/machines/appMachine'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { useToken } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons'
const ProjectSidebarMenu = ({
project,

View File

@ -1,492 +0,0 @@
import { useMachine } from '@xstate/react'
import { createContext, useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import type { Actor, AnyStateMachine, Prop, StateFrom } from 'xstate'
import { fromPromise } from 'xstate'
import { useLspContext } from '@src/components/LspProvider'
import { useFileSystemWatcher } from '@src/hooks/useFileSystemWatcher'
import { useProjectsLoader } from '@src/hooks/useProjectsLoader'
import useStateMachineCommands from '@src/hooks/useStateMachineCommands'
import { newKclFile } from '@src/lang/project'
import { projectsCommandBarConfig } from '@src/lib/commandBarConfigs/projectsCommandConfig'
import {
CREATE_FILE_URL_PARAM,
FILE_EXT,
PROJECT_ENTRYPOINT,
} from '@src/lib/constants'
import {
createNewProjectDirectory,
listProjects,
renameProjectDirectory,
} from '@src/lib/desktop'
import {
doesProjectNameNeedInterpolated,
getNextFileName,
getNextProjectIndex,
getUniqueProjectName,
interpolateProjectNameWithIndex,
} from '@src/lib/desktopFS'
import { isDesktop } from '@src/lib/isDesktop'
import { PATHS } from '@src/lib/paths'
import type { Project } from '@src/lib/project'
import { codeManager, kclManager } from '@src/lib/singletons'
import { err } from '@src/lib/trap'
import { useSettings } from '@src/machines/appMachine'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { projectsMachine } from '@src/machines/projectsMachine'
type MachineContext<T extends AnyStateMachine> = {
state?: StateFrom<T>
send: Prop<Actor<T>, 'send'>
}
export const ProjectsMachineContext = createContext(
{} as MachineContext<typeof projectsMachine>
)
/**
* Watches the project directory and provides project management-related commands,
* like "Create project", "Open project", "Delete project", etc.
*
* If in the future we implement full-fledge project management in the web version,
* we can unify these components but for now, we need this to be only for the desktop version.
*/
export const ProjectsContextProvider = ({
children,
}: {
children: React.ReactNode
}) => {
return isDesktop() ? (
<ProjectsContextDesktop>{children}</ProjectsContextDesktop>
) : (
<ProjectsContextWeb>{children}</ProjectsContextWeb>
)
}
/**
* We need some of the functionality of the ProjectsContextProvider in the web version
* but we can't perform file system operations in the browser,
* so most of the behavior of this machine is stubbed out.
*/
const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => {
const [searchParams, setSearchParams] = useSearchParams()
const clearImportSearchParams = useCallback(() => {
// Clear the search parameters related to the "Import file from URL" command
// or we'll never be able cancel or submit it.
searchParams.delete(CREATE_FILE_URL_PARAM)
searchParams.delete('code')
searchParams.delete('name')
searchParams.delete('units')
setSearchParams(searchParams)
}, [searchParams, setSearchParams])
const settings = useSettings()
const [state, send, actor] = useMachine(
projectsMachine.provide({
actions: {
navigateToProject: () => {},
navigateToProjectIfNeeded: () => {},
navigateToFile: () => {},
toastSuccess: ({ event }) =>
toast.success(
('data' in event && typeof event.data === 'string' && event.data) ||
('output' in event &&
'message' in event.output &&
typeof event.output.message === 'string' &&
event.output.message) ||
''
),
toastError: ({ event }) =>
toast.error(
('data' in event && typeof event.data === 'string' && event.data) ||
('output' in event &&
typeof event.output === 'string' &&
event.output) ||
''
),
},
actors: {
readProjects: fromPromise(async () => [] as Project[]),
createProject: fromPromise(async () => ({
message: 'not implemented on web',
})),
renameProject: fromPromise(async () => ({
message: 'not implemented on web',
oldName: '',
newName: '',
})),
deleteProject: fromPromise(async () => ({
message: 'not implemented on web',
name: '',
})),
createFile: fromPromise(async ({ input }) => {
// Browser version doesn't navigate, just overwrites the current file
clearImportSearchParams()
const codeToWrite = newKclFile(
input.code,
settings.modeling.defaultUnit.current
)
if (err(codeToWrite)) return Promise.reject(codeToWrite)
codeManager.updateCodeStateEditor(codeToWrite)
await codeManager.writeToFile()
await kclManager.executeCode()
return {
message: 'File overwritten successfully',
fileName: input.name,
projectName: '',
}
}),
},
}),
{
input: {
projects: [],
defaultProjectName: settings.projects.defaultProjectName.current,
defaultDirectory: settings.app.projectDirectory.current,
hasListedProjects: false,
},
}
)
// register all project-related command palette commands
useStateMachineCommands({
machineId: 'projects',
send,
state,
commandBarConfig: projectsCommandBarConfig,
actor,
onCancel: clearImportSearchParams,
})
return (
<ProjectsMachineContext.Provider
value={{
state,
send,
}}
>
{children}
</ProjectsMachineContext.Provider>
)
}
const ProjectsContextDesktop = ({
children,
}: {
children: React.ReactNode
}) => {
const navigate = useNavigate()
const location = useLocation()
const [searchParams, setSearchParams] = useSearchParams()
const clearImportSearchParams = useCallback(() => {
// Clear the search parameters related to the "Import file from URL" command
// or we'll never be able cancel or submit it.
searchParams.delete(CREATE_FILE_URL_PARAM)
searchParams.delete('code')
searchParams.delete('name')
searchParams.delete('units')
setSearchParams(searchParams)
}, [searchParams, setSearchParams])
const { onProjectOpen } = useLspContext()
const settings = useSettings()
const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0)
const { projectPaths, projectsDir } = useProjectsLoader([
projectsLoaderTrigger,
])
const [state, send, actor] = useMachine(
projectsMachine.provide({
actions: {
navigateToProject: ({ context, event }) => {
const nameFromEventData =
'data' in event &&
event.data &&
'name' in event.data &&
event.data.name
const nameFromOutputData =
'output' in event &&
event.output &&
'name' in event.output &&
event.output.name
const name = nameFromEventData || nameFromOutputData
if (name) {
let projectPath =
context.defaultDirectory + window.electron.path.sep + name
onProjectOpen(
{
name,
path: projectPath,
},
null
)
commandBarActor.send({ type: 'Close' })
const newPathName = `${PATHS.FILE}/${encodeURIComponent(
projectPath
)}`
navigate(newPathName)
}
},
navigateToProjectIfNeeded: ({ event }) => {
if (
event.type.startsWith('xstate.done.actor.') &&
'output' in event
) {
const isInAProject = location.pathname.startsWith(PATHS.FILE)
const isInDeletedProject =
event.type === 'xstate.done.actor.delete-project' &&
isInAProject &&
decodeURIComponent(location.pathname).includes(event.output.name)
if (isInDeletedProject) {
navigate(PATHS.HOME)
return
}
const isInRenamedProject =
event.type === 'xstate.done.actor.rename-project' &&
isInAProject &&
decodeURIComponent(location.pathname).includes(
event.output.oldName
)
if (isInRenamedProject) {
// TODO: In future, we can navigate to the new project path
// directly, but we need to coordinate with
// @lf94's useFileSystemWatcher in SettingsAuthProvider.tsx:224
// Because it's beating us to the punch and updating the route
// const newPathName = location.pathname.replace(
// encodeURIComponent(event.output.oldName),
// encodeURIComponent(event.output.newName)
// )
// navigate(newPathName)
return
}
}
},
navigateToFile: ({ context, event }) => {
if (event.type !== 'xstate.done.actor.create-file') return
// For now, the browser version of create-file doesn't need to navigate
// since it just overwrites the current file.
if (!isDesktop()) return
let projectPath = window.electron.join(
context.defaultDirectory,
event.output.projectName
)
let filePath = window.electron.join(
projectPath,
event.output.fileName
)
onProjectOpen(
{
name: event.output.projectName,
path: projectPath,
},
null
)
const pathToNavigateTo = `${PATHS.FILE}/${encodeURIComponent(
filePath
)}`
navigate(pathToNavigateTo)
},
toastSuccess: ({ event }) =>
toast.success(
('data' in event && typeof event.data === 'string' && event.data) ||
('output' in event &&
'message' in event.output &&
typeof event.output.message === 'string' &&
event.output.message) ||
''
),
toastError: ({ event }) =>
toast.error(
('data' in event && typeof event.data === 'string' && event.data) ||
('output' in event &&
typeof event.output === 'string' &&
event.output) ||
('error' in event &&
event.error instanceof Error &&
event.error.message) ||
''
),
},
actors: {
readProjects: fromPromise(() => {
return listProjects()
}),
createProject: fromPromise(async ({ input }) => {
let name = (
input && 'name' in input && input.name
? input.name
: settings.projects.defaultProjectName.current
).trim()
const uniqueName = getUniqueProjectName(name, input.projects)
await createNewProjectDirectory(uniqueName)
return {
message: `Successfully created "${uniqueName}"`,
name: uniqueName,
}
}),
renameProject: fromPromise(async ({ input }) => {
const {
oldName,
newName,
defaultProjectName,
defaultDirectory,
projects,
} = input
let name = newName ? newName : defaultProjectName
if (doesProjectNameNeedInterpolated(name)) {
const nextIndex = getNextProjectIndex(name, projects)
name = interpolateProjectNameWithIndex(name, nextIndex)
}
// Toast an error if the project name is taken
if (projects.find((p) => p.name === name)) {
return Promise.reject(
new Error(`Project with name "${name}" already exists`)
)
}
await renameProjectDirectory(
window.electron.path.join(defaultDirectory, oldName),
name
)
return {
message: `Successfully renamed "${oldName}" to "${name}"`,
oldName: oldName,
newName: name,
}
}),
deleteProject: fromPromise(async ({ input }) => {
await window.electron.rm(
window.electron.path.join(input.defaultDirectory, input.name),
{
recursive: true,
}
)
return {
message: `Successfully deleted "${input.name}"`,
name: input.name,
}
}),
createFile: fromPromise(async ({ input }) => {
let projectName =
(input.method === 'newProject' ? input.name : input.projectName) ||
settings.projects.defaultProjectName.current
let fileName =
input.method === 'newProject'
? PROJECT_ENTRYPOINT
: input.name.endsWith(FILE_EXT)
? input.name
: input.name + FILE_EXT
let message = 'File created successfully'
const needsInterpolated = doesProjectNameNeedInterpolated(projectName)
if (needsInterpolated) {
const nextIndex = getNextProjectIndex(projectName, input.projects)
projectName = interpolateProjectNameWithIndex(
projectName,
nextIndex
)
}
// Create the project around the file if newProject
let fileLoaded = false
if (input.method === 'newProject') {
await createNewProjectDirectory(projectName, input.code)
fileLoaded = true
message = `Project "${projectName}" created successfully with link contents`
} else {
message = `File "${fileName}" created successfully`
}
// Create the file
let baseDir = window.electron.join(
settings.app.projectDirectory.current,
projectName
)
const { name, path } = getNextFileName({
entryName: fileName,
baseDir,
})
fileName = name
if (!fileLoaded) {
const codeToWrite = newKclFile(
input.code,
settings.modeling.defaultUnit.current
)
if (err(codeToWrite)) return Promise.reject(codeToWrite)
await window.electron.writeFile(path, codeToWrite)
}
// TODO: Return the project's file name if one was created.
return {
message,
fileName,
projectName,
}
}),
},
}),
{
input: {
projects: projectPaths,
defaultProjectName: settings.projects.defaultProjectName.current,
defaultDirectory: settings.app.projectDirectory.current,
hasListedProjects: false,
},
}
)
useFileSystemWatcher(
async () => {
// Gotcha: Chokidar is buggy. It will emit addDir or add on files that did not get created.
// This means while the application initialize and Chokidar initializes you cannot tell if
// a directory or file is actually created or they are buggy signals. This means you must
// ignore all signals during initialization because it is ambiguous. Once those signals settle
// you can actually start listening to real signals.
// If someone creates folders or files during initialization we ignore those events!
if (!actor.getSnapshot().context.hasListedProjects) {
return
}
return setProjectsLoaderTrigger(projectsLoaderTrigger + 1)
},
projectsDir ? [projectsDir] : []
)
// Gotcha: Triggers listProjects() on chokidar changes
// Gotcha: Load the projects when the projectDirectory changes.
const projectDirectory = settings.app.projectDirectory.current
useEffect(() => {
send({ type: 'Read projects', data: {} })
}, [projectPaths, projectDirectory])
// register all project-related command palette commands
useStateMachineCommands({
machineId: 'projects',
send,
state,
commandBarConfig: projectsCommandBarConfig,
actor,
onCancel: clearImportSearchParams,
})
return (
<ProjectsMachineContext.Provider
value={{
state,
send,
}}
>
{children}
</ProjectsMachineContext.Provider>
)
}

View File

@ -0,0 +1,107 @@
import { useFileSystemWatcher } from '@src/hooks/useFileSystemWatcher'
import { PATHS } from '@src/lib/paths'
import { systemIOActor, useSettings } from '@src/lib/singletons'
import {
useHasListedProjects,
useProjectDirectoryPath,
useRequestedFileName,
useRequestedProjectName,
} from '@src/machines/systemIO/hooks'
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
import { useNavigate } from 'react-router-dom'
import { useEffect } from 'react'
export function SystemIOMachineLogicListenerDesktop() {
const requestedProjectName = useRequestedProjectName()
const requestedFileName = useRequestedFileName()
const projectDirectoryPath = useProjectDirectoryPath()
const hasListedProjects = useHasListedProjects()
const navigate = useNavigate()
const settings = useSettings()
const useGlobalProjectNavigation = () => {
useEffect(() => {
if (!requestedProjectName.name) {
return
}
let projectPathWithoutSpecificKCLFile =
projectDirectoryPath +
window.electron.path.sep +
requestedProjectName.name
const requestedPath = `${PATHS.FILE}/${encodeURIComponent(
projectPathWithoutSpecificKCLFile
)}`
navigate(requestedPath)
}, [requestedProjectName])
}
const useGlobalFileNavigation = () => {
useEffect(() => {
if (!requestedFileName.file || !requestedFileName.project) {
return
}
const projectPath = window.electron.join(
projectDirectoryPath,
requestedFileName.project
)
const filePath = window.electron.join(projectPath, requestedFileName.file)
const requestedPath = `${PATHS.FILE}/${encodeURIComponent(filePath)}`
navigate(requestedPath)
}, [requestedFileName])
}
const useApplicationProjectDirectory = () => {
useEffect(() => {
systemIOActor.send({
type: SystemIOMachineEvents.setProjectDirectoryPath,
data: {
requestedProjectDirectoryPath:
settings.app.projectDirectory.current || '',
},
})
}, [settings.app.projectDirectory.current])
}
const useDefaultProjectName = () => {
useEffect(() => {
systemIOActor.send({
type: SystemIOMachineEvents.setDefaultProjectFolderName,
data: {
requestedDefaultProjectFolderName:
settings.projects.defaultProjectName.current || '',
},
})
}, [settings.projects.defaultProjectName.current])
}
const useWatchingApplicationProjectDirectory = () => {
useFileSystemWatcher(
async () => {
// Gotcha: Chokidar is buggy. It will emit addDir or add on files that did not get created.
// This means while the application initialize and Chokidar initializes you cannot tell if
// a directory or file is actually created or they are buggy signals. This means you must
// ignore all signals during initialization because it is ambiguous. Once those signals settle
// you can actually start listening to real signals.
// If someone creates folders or files during initialization we ignore those events!
if (!hasListedProjects) {
return
}
systemIOActor.send({
type: SystemIOMachineEvents.readFoldersFromProjectDirectory,
})
},
settings.app.projectDirectory.current
? [settings.app.projectDirectory.current]
: []
)
}
useGlobalProjectNavigation()
useGlobalFileNavigation()
useApplicationProjectDirectory()
useDefaultProjectName()
useWatchingApplicationProjectDirectory()
return null
}

View File

@ -0,0 +1,29 @@
import { useEffect, useCallback } from 'react'
import { useClearURLParams } from '@src/machines/systemIO/hooks'
import { useSearchParams } from 'react-router-dom'
import { CREATE_FILE_URL_PARAM } from '@src/lib/constants'
export function SystemIOMachineLogicListenerWeb() {
const clearURLParams = useClearURLParams()
const [searchParams, setSearchParams] = useSearchParams()
const clearImportSearchParams = useCallback(() => {
// Clear the search parameters related to the "Import file from URL" command
// or we'll never be able cancel or submit it.
searchParams.delete(CREATE_FILE_URL_PARAM)
searchParams.delete('code')
searchParams.delete('name')
searchParams.delete('units')
setSearchParams(searchParams)
}, [searchParams, setSearchParams])
const useClearQueryParams = () => {
useEffect(() => {
if (clearURLParams.value) {
clearImportSearchParams()
}
}, [clearURLParams])
}
useClearQueryParams()
return null
}

View File

@ -18,7 +18,7 @@ import { markOnce } from '@src/lib/performance'
import { loadAndValidateSettings } from '@src/lib/settings/settingsUtils'
import { trap } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types'
import { settingsActor, useSettings } from '@src/machines/appMachine'
import { settingsActor, useSettings } from '@src/lib/singletons'
export const RouteProviderContext = createContext({})

View File

@ -28,7 +28,7 @@ import {
} from '@src/lib/settings/settingsUtils'
import { reportRejection } from '@src/lib/trap'
import { toSync } from '@src/lib/utils'
import { settingsActor, useSettings } from '@src/machines/appMachine'
import { settingsActor, useSettings } from '@src/lib/singletons'
import { APP_VERSION, IS_NIGHTLY, getReleaseUrl } from '@src/routes/utils'
import { waitFor } from 'xstate'

View File

@ -9,7 +9,7 @@ import type {
WildcardSetEvent,
} from '@src/lib/settings/settingsTypes'
import { getSettingInputType } from '@src/lib/settings/settingsUtils'
import { settingsActor, useSettings } from '@src/machines/appMachine'
import { settingsActor, useSettings } from '@src/lib/singletons'
interface SettingsFieldInputProps {
// We don't need the fancy types here,

View File

@ -8,7 +8,7 @@ import { useNavigate } from 'react-router-dom'
import { CustomIcon } from '@src/components/CustomIcon'
import { interactionMap } from '@src/lib/settings/initialKeybindings'
import type { SettingsLevel } from '@src/lib/settings/settingsTypes'
import { useSettings } from '@src/machines/appMachine'
import { useSettings } from '@src/lib/singletons'
type ExtendedSettingsLevel = SettingsLevel | 'keybindings'

View File

@ -3,7 +3,7 @@ import decamelize from 'decamelize'
import type { Setting } from '@src/lib/settings/initialSettings'
import type { SettingsLevel } from '@src/lib/settings/settingsTypes'
import { shouldHideSetting } from '@src/lib/settings/settingsUtils'
import { useSettings } from '@src/machines/appMachine'
import { useSettings } from '@src/lib/singletons'
interface SettingsSectionsListProps {
searchParamTab: SettingsLevel

View File

@ -2,7 +2,7 @@ import { CustomIcon } from '@src/components/CustomIcon'
import Tooltip from '@src/components/Tooltip'
import usePlatform from '@src/hooks/usePlatform'
import { hotkeyDisplay } from '@src/lib/hotkeyWrapper'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { commandBarActor } from '@src/lib/singletons'
import { useHotkeys } from 'react-hotkeys-hook'
const shareHotkey = 'mod+alt+s'

View File

@ -33,7 +33,7 @@ import { codeManager, kclManager } from '@src/lib/singletons'
import { sendTelemetry } from '@src/lib/textToCadTelemetry'
import type { Themes } from '@src/lib/theme'
import { reportRejection } from '@src/lib/trap'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { commandBarActor } from '@src/lib/singletons'
import type { fileMachine } from '@src/machines/fileMachine'
const CANVAS_SIZE = 128

View File

@ -11,7 +11,7 @@ import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
import usePlatform from '@src/hooks/usePlatform'
import { isDesktop } from '@src/lib/isDesktop'
import { PATHS } from '@src/lib/paths'
import { authActor } from '@src/machines/appMachine'
import { authActor } from '@src/lib/singletons'
type User = Models['User_type']

View File

@ -12,7 +12,7 @@ import type { AxisNames } from '@src/lib/constants'
import { VIEW_NAMES_SEMANTIC } from '@src/lib/constants'
import { sceneInfra } from '@src/lib/singletons'
import { reportRejection } from '@src/lib/trap'
import { useSettings } from '@src/machines/appMachine'
import { useSettings } from '@src/lib/singletons'
export function useViewControlMenuItems() {
const { state: modelingState, send: modelingSend } = useModelingContext()

View File

@ -2,7 +2,7 @@ import { useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { PATHS } from '@src/lib/paths'
import { useAuthState } from '@src/machines/appMachine'
import { useAuthState } from '@src/lib/singletons'
/**
* A simple hook that listens to the auth state of the app and navigates

View File

@ -9,7 +9,7 @@ import { CREATE_FILE_URL_PARAM, DEFAULT_FILE_NAME } from '@src/lib/constants'
import { isDesktop } from '@src/lib/isDesktop'
import type { FileLinkParams } from '@src/lib/links'
import { PATHS } from '@src/lib/paths'
import { useSettings } from '@src/machines/appMachine'
import { useSettings } from '@src/lib/singletons'
// For initializing the command arguments, we actually want `method` to be undefined
// so that we don't skip it in the command palette.

View File

@ -27,7 +27,7 @@ import {
} from '@src/lib/singletons'
import { err, reportRejection } from '@src/lib/trap'
import { getModuleId } from '@src/lib/utils'
import { engineStreamActor } from '@src/machines/appMachine'
import { engineStreamActor } from '@src/lib/singletons'
import { EngineStreamState } from '@src/machines/engineStreamMachine'
import type {
EdgeCutInfo,

View File

@ -2,7 +2,7 @@ import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
import { isDesktop } from '@src/lib/isDesktop'
import type { ToolbarModeName } from '@src/lib/toolbar'
import { reportRejection } from '@src/lib/trap'
import { useCommandBarState } from '@src/machines/commandBarMachine'
import { useCommandBarState } from '@src/lib/singletons'
import type { MenuLabels, WebContentSendPayload } from '@src/menu/channels'
import { useEffect } from 'react'

View File

@ -1,7 +0,0 @@
import { useContext } from 'react'
import { ProjectsMachineContext } from '@src/components/ProjectsContextProvider'
export const useProjectsContext = () => {
return useContext(ProjectsMachineContext)
}

View File

@ -1,5 +1,5 @@
import { Themes, getSystemTheme } from '@src/lib/theme'
import { useSettings } from '@src/machines/appMachine'
import { useSettings } from '@src/lib/singletons'
/**
* Resolves the current theme based on the theme setting

View File

@ -12,9 +12,8 @@ import type {
} from '@src/lib/commandTypes'
import { createMachineCommand } from '@src/lib/createMachineCommand'
import type { authMachine } from '@src/machines/authMachine'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { commandBarActor } from '@src/lib/singletons'
import type { modelingMachine } from '@src/machines/modelingMachine'
import type { projectsMachine } from '@src/machines/projectsMachine'
import type { settingsMachine } from '@src/machines/settingsMachine'
// This might not be necessary, AnyStateMachine from xstate is working
@ -22,7 +21,6 @@ export type AllMachines =
| typeof modelingMachine
| typeof settingsMachine
| typeof authMachine
| typeof projectsMachine
interface UseStateMachineCommandsArgs<
T extends AllMachines,

View File

@ -13,7 +13,7 @@ import { initializeWindowExceptionHandler } from '@src/lib/exceptions'
import { isDesktop } from '@src/lib/isDesktop'
import { markOnce } from '@src/lib/performance'
import { reportRejection } from '@src/lib/trap'
import { appActor } from '@src/machines/appMachine'
import { appActor } from '@src/lib/singletons'
import reportWebVitals from '@src/reportWebVitals'
markOnce('code/willAuth')

View File

@ -1,26 +1,32 @@
import type { Command } from '@src/lib/commandTypes'
import { authActor } from '@src/machines/appMachine'
import { ACTOR_IDS } from '@src/machines/machineConstants'
import { refreshPage } from '@src/lib/utils'
import { reportRejection } from '@src/lib/trap'
import type { ActorRefFrom } from 'xstate'
import type { authMachine } from '@src/machines/authMachine'
export const authCommands: Command[] = [
{
groupId: ACTOR_IDS.AUTH,
name: 'log-out',
displayName: 'Log out',
icon: 'arrowLeft',
needsReview: false,
onSubmit: () => authActor.send({ type: 'Log out' }),
},
{
groupId: ACTOR_IDS.AUTH,
name: 'refresh',
displayName: 'Refresh app',
icon: 'arrowRotateRight',
needsReview: false,
onSubmit: () => {
refreshPage('Command palette').catch(reportRejection)
export function createAuthCommands({
authActor,
}: { authActor: ActorRefFrom<typeof authMachine> }) {
const authCommands: Command[] = [
{
groupId: ACTOR_IDS.AUTH,
name: 'log-out',
displayName: 'Log out',
icon: 'arrowLeft',
needsReview: false,
onSubmit: () => authActor.send({ type: 'Log out' }),
},
},
]
{
groupId: ACTOR_IDS.AUTH,
name: 'refresh',
displayName: 'Refresh app',
icon: 'arrowRotateRight',
needsReview: false,
onSubmit: () => {
refreshPage('Command palette').catch(reportRejection)
},
},
]
return authCommands
}

View File

@ -12,7 +12,7 @@ import type { Command, CommandArgumentOption } from '@src/lib/commandTypes'
import { engineCommandManager } from '@src/lib/singletons'
import { err, reportRejection } from '@src/lib/trap'
import { uuidv4 } from '@src/lib/utils'
import { getSettings, settingsActor } from '@src/machines/appMachine'
import { getSettings, settingsActor } from '@src/lib/singletons'
function isWorldCoordinateSystemType(
x: string

View File

@ -1,23 +1,10 @@
import { CommandBarOverwriteWarning } from '@src/components/CommandBarOverwriteWarning'
import type { StateMachineCommandSetConfig } from '@src/lib/commandTypes'
import { isDesktop } from '@src/lib/isDesktop'
import type { projectsMachine } from '@src/machines/projectsMachine'
import type { Command, CommandArgumentOption } from '@src/lib/commandTypes'
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
import type { ActorRefFrom } from 'xstate'
import type { systemIOMachine } from '@src/machines/systemIO/systemIOMachine'
export type ProjectsCommandSchema = {
'Read projects': Record<string, unknown>
'Create project': {
name: string
}
'Open project': {
name: string
}
'Delete project': {
name: string
}
'Rename project': {
oldName: string
newName: string
}
'Import file from URL': {
name: string
code?: string
@ -26,43 +13,101 @@ export type ProjectsCommandSchema = {
}
}
export const projectsCommandBarConfig: StateMachineCommandSetConfig<
typeof projectsMachine,
ProjectsCommandSchema
> = {
'Open project': {
export function createProjectCommands({
systemIOActor,
}: {
systemIOActor: ActorRefFrom<typeof systemIOMachine>
}) {
/**
* Helper functions instead of importing these due to circular deps.
* unable to resolve this in a cleaner way at the moment.
* This is safe in terms of logic but visually ugly.
* TODO: https://github.com/KittyCAD/modeling-app/issues/6032
*/
const folderSnapshot = () => {
const { folders } = systemIOActor.getSnapshot().context
return folders
}
const defaultProjectFolderNameSnapshot = () => {
const { defaultProjectFolderName } = systemIOActor.getSnapshot().context
return defaultProjectFolderName
}
const openProjectCommand: Command = {
icon: 'arrowRight',
name: 'Open project',
displayName: `Open project`,
description: 'Open a project',
status: isDesktop() ? 'active' : 'inactive',
groupId: 'projects',
needsReview: false,
onSubmit: (record) => {
if (record) {
systemIOActor.send({
type: SystemIOMachineEvents.navigateToProject,
data: { requestedProjectName: record.name },
})
}
},
args: {
name: {
required: true,
inputType: 'options',
required: true,
options: (_, context) =>
context?.projects.map((p) => ({
name: p.name,
value: p.name,
})) || [],
options: () => {
const folders = folderSnapshot()
const options: CommandArgumentOption<string>[] = []
folders.forEach((folder) => {
options.push({
name: folder.name,
value: folder.name,
isCurrent: false,
})
})
return options
},
},
},
},
'Create project': {
icon: 'folderPlus',
}
const createProjectCommand: Command = {
icon: 'folder',
name: 'Create project',
displayName: `Create project`,
description: 'Create a project',
status: isDesktop() ? 'active' : 'inactive',
groupId: 'projects',
needsReview: false,
onSubmit: (record) => {
if (record) {
systemIOActor.send({
type: SystemIOMachineEvents.createProject,
data: { requestedProjectName: record.name },
})
}
},
args: {
name: {
inputType: 'string',
required: true,
defaultValueFromContext: (context) => context.defaultProjectName,
inputType: 'string',
defaultValue: defaultProjectFolderNameSnapshot,
},
},
},
'Delete project': {
icon: 'close',
}
const deleteProjectCommand: Command = {
icon: 'folder',
name: 'Delete project',
displayName: `Delete project`,
description: 'Delete a project',
status: isDesktop() ? 'active' : 'inactive',
groupId: 'projects',
needsReview: true,
onSubmit: (record) => {
if (record) {
systemIOActor.send({
type: SystemIOMachineEvents.deleteProject,
data: { requestedProjectName: record.name },
})
}
},
reviewMessage: ({ argumentsToSubmit }) =>
CommandBarOverwriteWarning({
heading: 'Are you sure you want to delete?',
@ -72,41 +117,83 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
name: {
inputType: 'options',
required: true,
options: (_, context) =>
context?.projects.map((p) => ({
name: p.name,
value: p.name,
})) || [],
options: () => {
const folders = folderSnapshot()
const options: CommandArgumentOption<string>[] = []
folders.forEach((folder) => {
options.push({
name: folder.name,
value: folder.name,
isCurrent: false,
})
})
return options
},
},
},
},
'Rename project': {
}
const renameProjectCommand: Command = {
icon: 'folder',
name: 'Rename project',
displayName: `Rename project`,
description: 'Rename a project',
groupId: 'projects',
needsReview: true,
status: isDesktop() ? 'active' : 'inactive',
onSubmit: (record) => {
if (record) {
systemIOActor.send({
type: SystemIOMachineEvents.renameProject,
data: {
requestedProjectName: record.newName,
projectName: record.oldName,
},
})
}
},
args: {
oldName: {
inputType: 'options',
required: true,
options: (_, context) =>
context?.projects.map((p) => ({
name: p.name,
value: p.name,
})) || [],
options: () => {
const folders = folderSnapshot()
const options: CommandArgumentOption<string>[] = []
folders.forEach((folder) => {
options.push({
name: folder.name,
value: folder.name,
isCurrent: false,
})
})
return options
},
},
newName: {
inputType: 'string',
required: true,
defaultValueFromContext: (context) => context.defaultProjectName,
defaultValue: defaultProjectFolderNameSnapshot,
},
},
},
'Import file from URL': {
}
const importFileFromURL: Command = {
name: 'Import file from URL',
groupId: 'projects',
icon: 'file',
description: 'Create a file',
needsReview: true,
status: 'active',
onSubmit: (record) => {
if (record) {
systemIOActor.send({
type: SystemIOMachineEvents.importFileFromURL,
data: {
requestedProjectName: record.projectName,
requestedCode: record.code,
requestedFileName: record.name,
},
})
}
},
args: {
method: {
inputType: 'options',
@ -134,11 +221,18 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
isDesktop() &&
commandsContext.argumentsToSubmit.method === 'existingProject',
skip: true,
options: (_, context) =>
context?.projects.map((p) => ({
name: p.name,
value: p.name,
})) || [],
options: (_, context) => {
const folders = folderSnapshot()
const options: CommandArgumentOption<string>[] = []
folders.forEach((folder) => {
options.push({
name: folder.name,
value: folder.name,
isCurrent: false,
})
})
return options
},
},
name: {
inputType: 'string',
@ -168,5 +262,18 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
}".`
: `Will overwrite the contents of the current file with the contents from the URL.`
},
},
}
/** No disk-writing commands are available in the browser */
const projectCommands = isDesktop()
? [
openProjectCommand,
createProjectCommand,
deleteProjectCommand,
renameProjectCommand,
importFileFromURL,
]
: [importFileFromURL]
return projectCommands
}

View File

@ -85,10 +85,23 @@ export async function ensureProjectDirectoryExists(
return projectDir
}
export async function mkdirOrNOOP(directoryPath: string) {
try {
await window.electron.stat(directoryPath)
} catch (e) {
if (e === 'ENOENT') {
await window.electron.mkdir(directoryPath, { recursive: true })
}
}
return directoryPath
}
export async function createNewProjectDirectory(
projectName: string,
initialCode?: string,
configuration?: DeepPartial<Configuration> | Error
configuration?: DeepPartial<Configuration> | Error,
initialFileName?: string
): Promise<Project> {
if (!configuration) {
configuration = await readAppSettingsFile()
@ -114,7 +127,8 @@ export async function createNewProjectDirectory(
}
}
const projectFile = window.electron.path.join(projectDir, PROJECT_ENTRYPOINT)
const kclFileName = initialFileName || PROJECT_ENTRYPOINT
const projectFile = window.electron.path.join(projectDir, kclFileName)
// When initialCode is present, we're loading existing code. If it's not
// present, we're creating a new project, and we want to incorporate the
// user's settings.

View File

@ -20,7 +20,7 @@ import type {
HomeLoaderData,
IndexLoaderData,
} from '@src/lib/types'
import { settingsActor } from '@src/machines/appMachine'
import { settingsActor } from '@src/lib/singletons'
export const telemetryLoader: LoaderFunction = async ({
params,

View File

@ -43,7 +43,7 @@ import {
isOverlap,
uuidv4,
} from '@src/lib/utils'
import { engineStreamActor } from '@src/machines/appMachine'
import { engineStreamActor } from '@src/lib/singletons'
import type { ModelingMachineEvent } from '@src/machines/modelingMachine'
import { showUnsupportedSelectionToast } from '@src/components/ToastUnsupportedSelection'

View File

@ -191,7 +191,7 @@ export function readLocalStorageAppSettingsFile():
}
}
function readLocalStorageProjectSettingsFile():
export function readLocalStorageProjectSettingsFile():
| DeepPartial<ProjectConfiguration>
| Error {
// TODO: Remove backwards compatibility after a few releases.
@ -456,7 +456,8 @@ export function getSettingInputType(setting: Setting) {
export const jsAppSettings = async () => {
let jsAppSettings = default_app_settings()
if (!TEST) {
const settings = await import('@src/machines/appMachine').then((module) =>
// TODO: https://github.com/KittyCAD/modeling-app/issues/6445
const settings = await import('@src/lib/singletons').then((module) =>
module.getSettings()
)
if (settings) {

View File

@ -9,6 +9,26 @@ import { SceneEntities } from '@src/clientSideScene/sceneEntities'
import { SceneInfra } from '@src/clientSideScene/sceneInfra'
import type { BaseUnit } from '@src/lib/settings/settingsTypes'
import { useSelector } from '@xstate/react'
import type { SnapshotFrom } from 'xstate'
import { createActor, setup, assign } from 'xstate'
import { isDesktop } from '@src/lib/isDesktop'
import { createSettings } from '@src/lib/settings/initialSettings'
import { authMachine } from '@src/machines/authMachine'
import {
engineStreamContextCreate,
engineStreamMachine,
} from '@src/machines/engineStreamMachine'
import { ACTOR_IDS } from '@src/machines/machineConstants'
import { settingsMachine } from '@src/machines/settingsMachine'
import { systemIOMachineDesktop } from '@src/machines/systemIO/systemIOMachineDesktop'
import { systemIOMachineWeb } from '@src/machines/systemIO/systemIOMachineWeb'
import type { AppMachineContext } from '@src/lib/types'
import { createAuthCommands } from '@src/lib/commandBarConfigs/authCommandConfig'
import { commandBarMachine } from '@src/machines/commandBarMachine'
import { createProjectCommands } from '@src/lib/commandBarConfigs/projectsCommandConfig'
export const codeManager = new CodeManager()
export const engineCommandManager = new EngineCommandManager()
export const rustContext = new RustContext(engineCommandManager)
@ -90,3 +110,122 @@ if (typeof window !== 'undefined') {
},
})
}
const { AUTH, SETTINGS, SYSTEM_IO, ENGINE_STREAM, COMMAND_BAR } = ACTOR_IDS
const appMachineActors = {
[AUTH]: authMachine,
[SETTINGS]: settingsMachine,
[SYSTEM_IO]: isDesktop() ? systemIOMachineDesktop : systemIOMachineWeb,
[ENGINE_STREAM]: engineStreamMachine,
[COMMAND_BAR]: commandBarMachine,
} as const
const appMachine = setup({
types: {} as {
context: AppMachineContext
},
actors: appMachineActors,
}).createMachine({
id: 'modeling-app',
context: {
codeManager: codeManager,
kclManager: kclManager,
engineCommandManager: engineCommandManager,
sceneInfra: sceneInfra,
sceneEntitiesManager: sceneEntitiesManager,
},
entry: [
/**
* We originally wanted to use spawnChild but the inferred type blew up. The more children we
* created the type complexity went through the roof. This functionally should act the same.
* the system and parent internals are tracked properly. After reading the documentation
* it suggests either method but this method requires manual clean up as described in the gotcha
* comment block below. If this becomes an issue we can always move this spawn into createActor functions
* in javascript above and reference those directly but the system and parent internals within xstate
* will not work.
*/
assign({
// Gotcha, if you use spawn, make sure you remove the ActorRef from context
// to prevent memory leaks when the spawned actor is no longer needed
authActor: ({ spawn }) => spawn(AUTH, { id: AUTH, systemId: AUTH }),
settingsActor: ({ spawn }) =>
spawn(SETTINGS, {
id: SETTINGS,
systemId: SETTINGS,
input: createSettings(),
}),
systemIOActor: ({ spawn }) =>
spawn(SYSTEM_IO, { id: SYSTEM_IO, systemId: SYSTEM_IO }),
engineStreamActor: ({ spawn }) =>
spawn(ENGINE_STREAM, {
id: ENGINE_STREAM,
systemId: ENGINE_STREAM,
input: engineStreamContextCreate(),
}),
commandBarActor: ({ spawn }) =>
spawn(COMMAND_BAR, {
id: COMMAND_BAR,
systemId: COMMAND_BAR,
input: {
commands: [],
},
}),
}),
],
})
export const appActor = createActor(appMachine, {
systemId: 'root',
})
/**
* GOTCHA: the type coercion of this actor works because it is spawned for
* the lifetime of {appActor}, but would not work if it were invoked
* or if it were destroyed under any conditions during {appActor}'s life
*/
export const authActor = appActor.getSnapshot().context.authActor!
export const useAuthState = () => useSelector(authActor, (state) => state)
export const useToken = () =>
useSelector(authActor, (state) => state.context.token)
export const useUser = () =>
useSelector(authActor, (state) => state.context.user)
/**
* GOTCHA: the type coercion of this actor works because it is spawned for
* the lifetime of {appActor}, but would not work if it were invoked
* or if it were destroyed under any conditions during {appActor}'s life
*/
export const settingsActor = appActor.getSnapshot().context.settingsActor!
export const getSettings = () => {
const { currentProject: _, ...settings } = settingsActor.getSnapshot().context
return settings
}
export const useSettings = () =>
useSelector(settingsActor, (state) => {
// We have to peel everything that isn't settings off
const { currentProject, ...settings } = state.context
return settings
})
export const systemIOActor = appActor.getSnapshot().context.systemIOActor!
export const engineStreamActor =
appActor.getSnapshot().context.engineStreamActor!
export const commandBarActor = appActor.getSnapshot().context.commandBarActor!
const cmdBarStateSelector = (state: SnapshotFrom<typeof commandBarActor>) =>
state
export const useCommandBarState = () => {
return useSelector(commandBarActor, cmdBarStateSelector)
}
// Initialize global commands
commandBarActor.send({
type: 'Add commands',
data: {
commands: [
...createAuthCommands({ authActor }),
...createProjectCommands({ systemIOActor }),
],
},
})

View File

@ -4,7 +4,7 @@ import type { EventFrom, StateFrom } from 'xstate'
import type { CustomIconName } from '@src/components/CustomIcon'
import { createLiteral } from '@src/lang/create'
import { isDesktop } from '@src/lib/isDesktop'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { commandBarActor } from '@src/lib/singletons'
import type { modelingMachine } from '@src/machines/modelingMachine'
import {
isEditingExistingSketch,

View File

@ -1,4 +1,15 @@
import type { FileEntry, Project } from '@src/lib/project'
import type CodeManager from '@src/lang/codeManager'
import type { EngineCommandManager } from '@src/lang/std/engineConnection'
import type { KclManager } from '@src/lang/KclSingleton'
import type { SceneInfra } from '@src/clientSideScene/sceneInfra'
import type { SceneEntities } from '@src/clientSideScene/sceneEntities'
import type { engineStreamMachine } from '@src/machines/engineStreamMachine'
import type { authMachine } from '@src/machines/authMachine'
import type { settingsMachine } from '@src/machines/settingsMachine'
import type { systemIOMachine } from '@src/machines/systemIO/systemIOMachine'
import type { ActorRefFrom } from 'xstate'
import type { commandBarMachine } from '@src/machines/commandBarMachine'
export type IndexLoaderData = {
code: string | null
@ -111,3 +122,16 @@ export type AsyncFn<F extends (...args: any[]) => any> = WithReturnType<
F,
Promise<unknown>
>
export type AppMachineContext = {
codeManager: CodeManager
kclManager: KclManager
engineCommandManager: EngineCommandManager
sceneInfra: SceneInfra
sceneEntitiesManager: SceneEntities
authActor?: ActorRefFrom<typeof authMachine>
settingsActor?: ActorRefFrom<typeof settingsMachine>
systemIOActor?: ActorRefFrom<typeof systemIOMachine>
engineStreamActor?: ActorRefFrom<typeof engineStreamMachine>
commandBarActor?: ActorRefFrom<typeof commandBarMachine>
}

View File

@ -1,78 +0,0 @@
import { useSelector } from '@xstate/react'
import { createActor, setup, spawnChild } from 'xstate'
import { createSettings } from '@src/lib/settings/initialSettings'
import { authMachine } from '@src/machines/authMachine'
import type { EngineStreamActor } from '@src/machines/engineStreamMachine'
import {
engineStreamContextCreate,
engineStreamMachine,
} from '@src/machines/engineStreamMachine'
import { ACTOR_IDS } from '@src/machines/machineConstants'
import { settingsMachine } from '@src/machines/settingsMachine'
const { AUTH, SETTINGS, ENGINE_STREAM } = ACTOR_IDS
const appMachineActors = {
[AUTH]: authMachine,
[SETTINGS]: settingsMachine,
[ENGINE_STREAM]: engineStreamMachine,
} as const
const appMachine = setup({
types: {} as {
children: {
auth: typeof AUTH
settings: typeof SETTINGS
}
},
actors: appMachineActors,
}).createMachine({
id: 'modeling-app',
entry: [
spawnChild(AUTH, { id: AUTH, systemId: AUTH }),
spawnChild(SETTINGS, {
id: SETTINGS,
systemId: SETTINGS,
input: createSettings(),
}),
spawnChild(ENGINE_STREAM, {
id: ENGINE_STREAM,
systemId: ENGINE_STREAM,
input: engineStreamContextCreate(),
}),
],
})
export const appActor = createActor(appMachine)
/**
* GOTCHA: the type coercion of this actor works because it is spawned for
* the lifetime of {appActor}, but would not work if it were invoked
* or if it were destroyed under any conditions during {appActor}'s life
*/
export const authActor = appActor.getSnapshot().children.auth!
export const useAuthState = () => useSelector(authActor, (state) => state)
export const useToken = () =>
useSelector(authActor, (state) => state.context.token)
export const useUser = () =>
useSelector(authActor, (state) => state.context.user)
/**
* GOTCHA: the type coercion of this actor works because it is spawned for
* the lifetime of {appActor}, but would not work if it were invoked
* or if it were destroyed under any conditions during {appActor}'s life
*/
export const settingsActor = appActor.getSnapshot().children.settings!
export const getSettings = () => {
const { currentProject: _, ...settings } = settingsActor.getSnapshot().context
return settings
}
export const useSettings = () =>
useSelector(settingsActor, (state) => {
// We have to peel everything that isn't settings off
const { currentProject, ...settings } = state.context
return settings
})
export const engineStreamActor = appActor.system.get(
ENGINE_STREAM
) as EngineStreamActor

View File

@ -1,10 +1,6 @@
import { useSelector } from '@xstate/react'
import toast from 'react-hot-toast'
import type { SnapshotFrom } from 'xstate'
import { assign, createActor, fromPromise, setup } from 'xstate'
import { assign, fromPromise, setup } from 'xstate'
import type { MachineManager } from '@src/components/MachineManagerProvider'
import { authCommands } from '@src/lib/commandBarConfigs/authCommandConfig'
import type {
Command,
CommandArgument,
@ -658,16 +654,3 @@ function sortCommands(a: Command, b: Command) {
if (a.groupId === 'settings' && !(b.groupId === 'settings')) return 1
return a.name.localeCompare(b.name)
}
export const commandBarActor = createActor(commandBarMachine, {
input: {
commands: [...authCommands],
},
}).start()
/** Basic state snapshot selector */
const cmdBarStateSelector = (state: SnapshotFrom<typeof commandBarActor>) =>
state
export const useCommandBarState = () => {
return useSelector(commandBarActor, cmdBarStateSelector)
}

View File

@ -1,7 +1,7 @@
import { engineCommandManager, sceneInfra } from '@src/lib/singletons'
import type { MutableRefObject } from 'react'
import type { ActorRefFrom } from 'xstate'
import { assign, fromPromise, setup } from 'xstate'
import type { AppMachineContext } from '@src/lib/types'
export enum EngineStreamState {
Off = 'off',
@ -79,9 +79,13 @@ export const engineStreamMachine = setup({
actors: {
[EngineStreamTransition.Play]: fromPromise(
async ({
input: { context, params },
input: { context, params, rootContext },
}: {
input: { context: EngineStreamContext; params: { zoomToFit: boolean } }
input: {
context: EngineStreamContext
params: { zoomToFit: boolean }
rootContext: AppMachineContext
}
}) => {
const canvas = context.canvasRef.current
if (!canvas) return false
@ -98,7 +102,7 @@ export const engineStreamMachine = setup({
return
}
await sceneInfra.camControls.restoreRemoteCameraStateAndTriggerSync()
await rootContext.sceneInfra.camControls.restoreRemoteCameraStateAndTriggerSync()
video.style.display = 'block'
canvas.style.display = 'none'
@ -108,9 +112,9 @@ export const engineStreamMachine = setup({
),
[EngineStreamTransition.Pause]: fromPromise(
async ({
input: { context },
input: { context, rootContext },
}: {
input: { context: EngineStreamContext }
input: { context: EngineStreamContext; rootContext: AppMachineContext }
}) => {
const video = context.videoRef.current
if (!video) return
@ -123,7 +127,7 @@ export const engineStreamMachine = setup({
await holdOntoVideoFrameInCanvas(video, canvas)
video.style.display = 'none'
await sceneInfra.camControls.saveRemoteCameraState()
await rootContext.sceneInfra.camControls.saveRemoteCameraState()
// Make sure we're on the next frame for no flickering between canvas
// and the video elements.
@ -138,16 +142,20 @@ export const engineStreamMachine = setup({
context.mediaStream = null
video.srcObject = null
engineCommandManager.tearDown({ idleMode: true })
rootContext.engineCommandManager.tearDown({ idleMode: true })
})()
)
}
),
[EngineStreamTransition.StartOrReconfigureEngine]: fromPromise(
async ({
input: { context, event },
input: { context, event, rootContext },
}: {
input: { context: EngineStreamContext; event: any }
input: {
context: EngineStreamContext
event: any
rootContext: AppMachineContext
}
}) => {
if (!context.authToken) return
@ -172,10 +180,10 @@ export const engineStreamMachine = setup({
...event.settings,
}
engineCommandManager.settings = settingsNext
rootContext.engineCommandManager.settings = settingsNext
window.requestAnimationFrame(() => {
engineCommandManager.start({
rootContext.engineCommandManager.start({
setMediaStream: event.onMediaStream,
setIsStreamReady: (isStreamReady: boolean) => {
event.setAppState({ isStreamReady })
@ -225,7 +233,12 @@ export const engineStreamMachine = setup({
reenter: true,
invoke: {
src: EngineStreamTransition.StartOrReconfigureEngine,
input: (args) => args,
input: (args) => ({
context: args.context,
rootContext: args.self.system.get('root').getSnapshot().context,
params: { zoomToFit: args.context.zoomToFit },
event: args.event,
}),
},
on: {
// Transition requested by engineConnection
@ -246,6 +259,7 @@ export const engineStreamMachine = setup({
src: EngineStreamTransition.Play,
input: (args) => ({
context: args.context,
rootContext: args.self.system.get('root').getSnapshot().context,
params: { zoomToFit: args.context.zoomToFit },
}),
},
@ -261,7 +275,11 @@ export const engineStreamMachine = setup({
[EngineStreamState.Reconfiguring]: {
invoke: {
src: EngineStreamTransition.StartOrReconfigureEngine,
input: (args) => args,
input: (args) => ({
context: args.context,
rootContext: args.self.system.get('root').getSnapshot().context,
event: args.event,
}),
onDone: {
target: EngineStreamState.Playing,
},
@ -270,7 +288,10 @@ export const engineStreamMachine = setup({
[EngineStreamState.Paused]: {
invoke: {
src: EngineStreamTransition.Pause,
input: (args) => args,
input: (args) => ({
context: args.context,
rootContext: args.self.system.get('root').getSnapshot().context,
}),
},
on: {
[EngineStreamTransition.StartOrReconfigureEngine]: {
@ -282,7 +303,11 @@ export const engineStreamMachine = setup({
reenter: true,
invoke: {
src: EngineStreamTransition.StartOrReconfigureEngine,
input: (args) => args,
input: (args) => ({
context: args.context,
rootContext: args.self.system.get('root').getSnapshot().context,
event: args.event,
}),
},
on: {
// The stream can be paused as it's resuming.

View File

@ -20,7 +20,7 @@ import {
} from '@src/lib/operations'
import { kclManager } from '@src/lib/singletons'
import { err } from '@src/lib/trap'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { commandBarActor } from '@src/lib/singletons'
type FeatureTreeEvent =
| {

View File

@ -1,5 +1,7 @@
export const ACTOR_IDS = {
AUTH: 'auth',
SETTINGS: 'settings',
SYSTEM_IO: 'systemIO',
ENGINE_STREAM: 'engine_stream',
COMMAND_BAR: 'command_bar',
} as const

View File

@ -1,337 +0,0 @@
import { assign, fromPromise, setup } from 'xstate'
import type { ProjectsCommandSchema } from '@src/lib/commandBarConfigs/projectsCommandConfig'
import type { Project } from '@src/lib/project'
import { isArray } from '@src/lib/utils'
export const projectsMachine = setup({
types: {
context: {} as {
projects: Project[]
defaultProjectName: string
defaultDirectory: string
hasListedProjects: boolean
},
events: {} as
| { type: 'Read projects'; data: ProjectsCommandSchema['Read projects'] }
| { type: 'Open project'; data: ProjectsCommandSchema['Open project'] }
| {
type: 'Rename project'
data: ProjectsCommandSchema['Rename project']
}
| {
type: 'Create project'
data: ProjectsCommandSchema['Create project']
}
| {
type: 'Delete project'
data: ProjectsCommandSchema['Delete project']
}
| {
type: 'Import file from URL'
data: ProjectsCommandSchema['Import file from URL']
}
| { type: 'navigate'; data: { name: string } }
| {
type: 'xstate.done.actor.read-projects'
output: Project[]
}
| {
type: 'xstate.done.actor.delete-project'
output: { message: string; name: string }
}
| {
type: 'xstate.done.actor.create-project'
output: { message: string; name: string }
}
| {
type: 'xstate.done.actor.rename-project'
output: { message: string; oldName: string; newName: string }
}
| {
type: 'xstate.done.actor.create-file'
output: { message: string; projectName: string; fileName: string }
}
| { type: 'assign'; data: { [key: string]: any } },
input: {} as {
projects: Project[]
defaultProjectName: string
defaultDirectory: string
hasListedProjects: boolean
},
},
actions: {
setProjects: assign({
projects: ({ context, event }) =>
'output' in event && isArray(event.output)
? event.output
: context.projects,
}),
setHasListedProjects: assign({
hasListedProjects: () => true,
}),
toastSuccess: () => {},
toastError: () => {},
navigateToProject: () => {},
navigateToProjectIfNeeded: () => {},
navigateToFile: () => {},
},
actors: {
readProjects: fromPromise(() => Promise.resolve([] as Project[])),
createProject: fromPromise(
(_: { input: { name: string; projects: Project[] } }) =>
Promise.resolve({ message: '' })
),
renameProject: fromPromise(
(_: {
input: {
oldName: string
newName: string
defaultProjectName: string
defaultDirectory: string
projects: Project[]
}
}) =>
Promise.resolve({
message: '',
oldName: '',
newName: '',
})
),
deleteProject: fromPromise(
(_: { input: { defaultDirectory: string; name: string } }) =>
Promise.resolve({
message: '',
name: '',
})
),
createFile: fromPromise(
(_: {
input: ProjectsCommandSchema['Import file from URL'] & {
projects: Project[]
}
}) => Promise.resolve({ message: '', projectName: '', fileName: '' })
),
},
guards: {
'Has at least 1 project': ({ event }) => {
if (event.type !== 'xstate.done.actor.read-projects') return false
return event.output.length ? event.output.length >= 1 : false
},
},
}).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAMS6yzFSkDaADALqKgAOqtALsaqXYgAHogAsAJgA0IAJ6IAjAHYAbADoArBJVMFTCQA4mTAMwmxAXwsy0mHARLkKASXRcATjywAzYgBtsb3cMLABVACUAGWY2JBAuXn5BONEESRl5BAUFFQM1HQUxJSYATmyFAyUTKxsMbDwiMjA1ZGosUlQsDmCAKzB8HlgKcLBcCC7e-sGYoQTiPgEhVIUy9SKSiQ0NEwkclRyMxGKJNRKlMRVDU23zpRqQW3qHJpa2jonUPoGhgGF3UZ42G6nymMzicwWyVAyzKJzECg0OiYGjERhK+kOWUMSlOGiUlTEJlMxRKBnuj3sjXIr1gHy+g2Go3GwPpsDBnG48ySS0QGj0+Q0JTOGkqBg2JhKmNRJy2OyUEn0Kml5LqlMczVatJZUyGI1IuDs2oG7PinMhPIQfKYAqFShF+PFkrkRwkYjU8OUShKKhMKg0pUs1geqoa6ppdJ1FD+AKBk2NrFmZu5KV5-L9tvtYokEsxelRagUCoMiMumyUdpVdlDL01Ee+FAAImAAoC6zwTRDk9DU9b08LRY7MWd1AZcoS-aoLiYFJWnlSNW0jQyAPIcMCkNsdpOLFOWtOC-sO7NOzLbGWFbY6acmFESWdql7R3B8UhQNsUCACZpkABuqAA1s0+D-M+YAALRLluiQ7t2WRMGIbryjeBgGBIEglNsCi5sY6hMOchQqEoCLFCh97VtST4vm+S4UGA7jBO4agcH4z7eKg7joGowExhBcbtgm4LblCIiKPBiHZiKqHoZhuaFhoBbGMYBhiPBCgStUQYUuRzR6gaZDUXxH5fmov4Ac0-z6pgvEgvGsQctBwnLGJahIZJaEYdOmKEfJuQSoRRKSRcZHPNSunoPp750QxTEsTwbEcWoFkGuBkECfZXIwSJcEIS5Ekoe5MnOggXqIaUqFiiUhIInemkhiFzRNi2EU0Z+1KmYBagQM2YCAtZ9JQRljmiTlrn5dJnlFShOLwcpZjKBs+KBrUVb1WojU9c1hlRexMWsexnFdS2KV8QN5q7laNqHlmOaTcpmjoWc8L6HoYrBfOagjGMm02QyrXfqQf4dSBEB9Tqp1dllebichUkeVhRXTiU+QecUKhCps4pvWGn0QN9rJGW1ANmYlTKg98DAKHZpoORanoyqh8oImU3pKJifJ5Ohdr7CYqjIvKWMvDjeORttjHMXtCXA2T0xpdTg20+W9MSIzgorIRXlpr6ORbPs+jwQLFEgVRPj+JQf0mUTHXcaBYG+AE4OZcsxbuts5hbCK+zFmImJgSc5zPV6BQVD6RQG80lERXblCi7tcX7VxRvgVHDtDQgaE4vK2gXKiTA6ItubmJo8IoqOylERUVhBh0XXwHEWn1YmNO7mBKg+2KpzK2IpIBUwBhqUtwYre9tbvEutfpWdsFFnkPpVJnQqz15Ozur36FoypREbGH4Zj438u7tO1qIu5qK5PBGyYjebpEkS44e9oVTbxHr5tnvk9ZeXajTuW+zaHNuzYRUmoeCEp-RKlUHJbeYVhYDDfhDVIxR5IGGnISQk6E+6EkxMUBQX9c66EMMg3Q5Zt7rWNkuOBjsjilDUAqQsZQzzgOkJNUk7o7SmF-tiXOUCmQwMGBQ1OF4TgVGzFUG8yIxQGDZiYPI4oqh+mPsrDSy05xhmfm+KO-CLT+iRucEUuwFQqWzMWXM2YTAuTtL6ZBylkRcMrkAA */
id: 'Home machine',
initial: 'Reading projects',
context: ({ input }) => ({
...input,
}),
on: {
assign: {
actions: assign(({ event }) => ({
...event.data,
})),
},
'Import file from URL': '.Creating file',
},
states: {
'Has no projects': {
on: {
'Read projects': {
target: 'Reading projects',
},
'Create project': {
target: 'Creating project',
},
},
},
'Has projects': {
on: {
'Read projects': {
target: 'Reading projects',
},
'Rename project': {
target: 'Renaming project',
},
'Create project': {
target: 'Creating project',
},
'Delete project': {
target: 'Deleting project',
},
'Open project': {
target: 'Reading projects',
actions: 'navigateToProject',
reenter: true,
},
},
},
'Creating project': {
invoke: {
id: 'create-project',
src: 'createProject',
input: ({ event, context }) => {
if (
event.type !== 'Create project' &&
event.type !== 'Import file from URL'
) {
return {
name: '',
projects: context.projects,
}
}
return {
name: event.data.name,
projects: context.projects,
}
},
onDone: [
{
target: 'Reading projects',
actions: ['toastSuccess', 'navigateToProject'],
},
],
onError: [
{
target: 'Reading projects',
actions: ['toastError'],
},
],
},
},
'Renaming project': {
invoke: {
id: 'rename-project',
src: 'renameProject',
input: ({ event, context }) => {
if (event.type !== 'Rename project') {
// This is to make TS happy
return {
defaultProjectName: context.defaultProjectName,
defaultDirectory: context.defaultDirectory,
oldName: '',
newName: '',
projects: context.projects,
}
}
return {
defaultProjectName: context.defaultProjectName,
defaultDirectory: context.defaultDirectory,
oldName: event.data.oldName,
newName: event.data.newName,
projects: context.projects,
}
},
onDone: [
{
target: '#Home machine.Reading projects',
actions: ['toastSuccess', 'navigateToProjectIfNeeded'],
},
],
onError: [
{
target: '#Home machine.Reading projects',
actions: ['toastError'],
},
],
},
},
'Deleting project': {
invoke: {
id: 'delete-project',
src: 'deleteProject',
input: ({ event, context }) => {
if (event.type !== 'Delete project') {
// This is to make TS happy
return {
defaultDirectory: context.defaultDirectory,
name: '',
}
}
return {
defaultDirectory: context.defaultDirectory,
name: event.data.name,
}
},
onDone: [
{
actions: ['toastSuccess', 'navigateToProjectIfNeeded'],
target: '#Home machine.Reading projects',
},
],
onError: {
actions: ['toastError'],
target: '#Home machine.Has projects',
},
},
},
'Reading projects': {
invoke: {
id: 'read-projects',
src: 'readProjects',
onDone: [
{
guard: 'Has at least 1 project',
target: 'Has projects',
actions: ['setProjects', 'setHasListedProjects'],
},
{
target: 'Has no projects',
actions: ['setProjects', 'setHasListedProjects'],
},
],
onError: [
{
target: 'Has no projects',
actions: ['toastError'],
},
],
},
},
'Creating file': {
invoke: {
id: 'create-file',
src: 'createFile',
input: ({ event, context }) => {
if (event.type !== 'Import file from URL') {
return {
code: '',
name: '',
method: 'existingProject',
projects: context.projects,
}
}
return {
code: event.data.code || '',
name: event.data.name,
method: event.data.method,
projectName: event.data.projectName,
projects: context.projects,
}
},
onDone: {
target: 'Reading projects',
actions: ['navigateToFile', 'toastSuccess'],
},
onError: {
target: 'Reading projects',
actions: 'toastError',
},
},
},
},
})

View File

@ -35,13 +35,6 @@ import {
saveSettings,
setSettingsAtLevel,
} from '@src/lib/settings/settingsUtils'
import {
codeManager,
engineCommandManager,
kclManager,
sceneEntitiesManager,
sceneInfra,
} from '@src/lib/singletons'
import {
Themes,
darkModeMatcher,
@ -50,7 +43,6 @@ import {
setThemeClass,
} from '@src/lib/theme'
import { reportRejection } from '@src/lib/trap'
import { commandBarActor } from '@src/machines/commandBarMachine'
type SettingsMachineContext = SettingsType & {
currentProject?: Project
@ -95,13 +87,14 @@ export const settingsMachine = setup({
doNotPersist: boolean
context: SettingsMachineContext
toastCallback?: () => void
rootContext: any
}
>(async ({ input }) => {
// Without this, when a user changes the file, it'd
// create a detection loop with the file-system watcher.
if (input.doNotPersist) return
codeManager.writeCausedByAppCheckedInFileTreeFileSystemWatcher = true
input.rootContext.codeManager.writeCausedByAppCheckedInFileTreeFileSystemWatcher = true
const { currentProject, ...settings } = input.context
const val = await saveSettings(settings, currentProject?.path)
@ -147,7 +140,9 @@ export const settingsMachine = setup({
registerCommands: fromCallback<
{ type: 'update' },
{ settings: SettingsType; actor: AnyActorRef }
>(({ input, receive }) => {
>(({ input, receive, self }) => {
const commandBarActor = self.system.get('root').getSnapshot()
.context.commandBarActor
// If the user wants to hide the settings commands
//from the command bar don't add them.
if (settings.commandBar.includeSettings.current === false) return
@ -190,20 +185,28 @@ export const settingsMachine = setup({
}),
},
actions: {
setEngineTheme: ({ context }) => {
setEngineTheme: ({ context, self }) => {
const rootContext = self.system.get('root').getSnapshot().context
const engineCommandManager = rootContext.engineCommandManager
if (engineCommandManager && context.app.theme.current) {
engineCommandManager
.setTheme(context.app.theme.current)
.catch(reportRejection)
}
},
setClientTheme: ({ context }) => {
setClientTheme: ({ context, self }) => {
const rootContext = self.system.get('root').getSnapshot().context
const sceneInfra = rootContext.sceneInfra
const sceneEntitiesManager = rootContext.sceneEntitiesManager
if (!sceneInfra || !sceneEntitiesManager) return
const opposingTheme = getOppositeTheme(context.app.theme.current)
sceneInfra.theme = opposingTheme
sceneEntitiesManager.updateSegmentBaseColor(opposingTheme)
},
setAllowOrbitInSketchMode: ({ context }) => {
setAllowOrbitInSketchMode: ({ context, self }) => {
const rootContext = self.system.get('root').getSnapshot().context
const sceneInfra = rootContext.sceneInfra
if (!sceneInfra.camControls) return
sceneInfra.camControls._setting_allowOrbitInSketchMode =
context.app.allowOrbitInSketchMode.current
@ -232,7 +235,9 @@ export const settingsMachine = setup({
id: `${event.type}.success`,
})
},
'Execute AST': ({ context, event }) => {
'Execute AST': ({ context, event, self }) => {
const rootContext = self.system.get('root').getSnapshot().context
const kclManager = rootContext.kclManager
try {
const relevantSetting = (s: typeof settings) => {
return (
@ -345,8 +350,10 @@ export const settingsMachine = setup({
currentTheme === Themes.System ? getSystemTheme() : currentTheme
)
},
setEngineCameraProjection: ({ context }) => {
setEngineCameraProjection: ({ context, self }) => {
const newCurrentProjection = context.modeling.cameraProjection.current
const rootContext = self.system.get('root').getSnapshot().context
const sceneInfra = rootContext.sceneInfra
sceneInfra.camControls.setEngineCameraProjection(newCurrentProjection)
},
sendThemeToWatcher: sendTo('watchSystemTheme', ({ context }) => ({
@ -532,7 +539,7 @@ export const settingsMachine = setup({
console.error('Error persisting settings')
},
},
input: ({ context, event }) => {
input: ({ context, event, self }) => {
if (
event.type === 'set.app.namedViews' &&
'toastCallback' in event.data
@ -541,12 +548,14 @@ export const settingsMachine = setup({
doNotPersist: event.doNotPersist ?? false,
context,
toastCallback: event.data.toastCallback,
rootContext: self.system.get('root').getSnapshot().context,
}
}
return {
doNotPersist: event.doNotPersist ?? false,
context,
rootContext: self.system.get('root').getSnapshot().context,
}
},
},

View File

@ -0,0 +1,21 @@
import { systemIOActor } from '@src/lib/singletons'
import { useSelector } from '@xstate/react'
export const useRequestedProjectName = () =>
useSelector(systemIOActor, (state) => state.context.requestedProjectName)
export const useRequestedFileName = () =>
useSelector(systemIOActor, (state) => state.context.requestedFileName)
export const useProjectDirectoryPath = () =>
useSelector(systemIOActor, (state) => state.context.projectDirectoryPath)
export const useFolders = () =>
useSelector(systemIOActor, (state) => state.context.folders)
export const useState = () => useSelector(systemIOActor, (state) => state)
export const useCanReadWriteProjectDirectory = () =>
useSelector(
systemIOActor,
(state) => state.context.canReadWriteProjectDirectory
)
export const useHasListedProjects = () =>
useSelector(systemIOActor, (state) => state.context.hasListedProjects)
export const useClearURLParams = () =>
useSelector(systemIOActor, (state) => state.context.clearURLParams)

View File

@ -0,0 +1,11 @@
import { systemIOActor } from '@src/lib/singletons'
export const folderSnapshot = () => {
const { folders } = systemIOActor.getSnapshot().context
return folders
}
export const defaultProjectFolderNameSnapshot = () => {
const { defaultProjectFolderName } = systemIOActor.getSnapshot().context
return defaultProjectFolderName
}

View File

@ -0,0 +1,78 @@
import { DEFAULT_PROJECT_NAME } from '@src/lib/constants'
import { systemIOMachineDesktop } from '@src/machines/systemIO/systemIOMachineDesktop'
import {
NO_PROJECT_DIRECTORY,
SystemIOMachineEvents,
SystemIOMachineStates,
} from '@src/machines/systemIO/utils'
import path from 'node:path'
import { createActor, waitFor } from 'xstate'
describe('systemIOMachine - XState', () => {
describe('desktop', () => {
describe('when initializied', () => {
it('should contain the default context values', () => {
const actor = createActor(systemIOMachineDesktop).start()
const context = actor.getSnapshot().context
expect(context.folders).toStrictEqual([])
expect(context.defaultProjectFolderName).toStrictEqual(
DEFAULT_PROJECT_NAME
)
expect(context.projectDirectoryPath).toBe(NO_PROJECT_DIRECTORY)
expect(context.hasListedProjects).toBe(false)
expect(context.requestedProjectName).toStrictEqual({
name: NO_PROJECT_DIRECTORY,
})
expect(context.requestedFileName).toStrictEqual({
project: NO_PROJECT_DIRECTORY,
file: NO_PROJECT_DIRECTORY,
})
})
it('should be in idle state', () => {
const actor = createActor(systemIOMachineDesktop).start()
const state = actor.getSnapshot().value
expect(state).toBe(SystemIOMachineStates.idle)
})
})
describe('when reading projects', () => {
it('should exit early when project directory is empty string', async () => {
const actor = createActor(systemIOMachineDesktop).start()
actor.send({
type: SystemIOMachineEvents.readFoldersFromProjectDirectory,
})
await waitFor(actor, (state) =>
state.matches(SystemIOMachineStates.readingFolders)
)
await waitFor(actor, (state) =>
state.matches(SystemIOMachineStates.idle)
)
const context = actor.getSnapshot().context
expect(context.folders).toStrictEqual([])
})
})
describe('when setting project directory path', () => {
it('should set new project directory path', async () => {
const kclSamplesPath = path.join('public', 'kcl-samples')
const actor = createActor(systemIOMachineDesktop).start()
actor.send({
type: SystemIOMachineEvents.setProjectDirectoryPath,
data: { requestedProjectDirectoryPath: kclSamplesPath },
})
let context = actor.getSnapshot().context
expect(context.projectDirectoryPath).toBe(kclSamplesPath)
})
})
describe('when setting default project folder name', () => {
it('should set a new default project folder name', async () => {
const expected = 'coolcoolcoolProjectName'
const actor = createActor(systemIOMachineDesktop).start()
actor.send({
type: SystemIOMachineEvents.setDefaultProjectFolderName,
data: { requestedDefaultProjectFolderName: expected },
})
let context = actor.getSnapshot().context
expect(context.defaultProjectFolderName).toBe(expected)
})
})
})
})

View File

@ -0,0 +1,444 @@
import { DEFAULT_PROJECT_NAME } from '@src/lib/constants'
import type { Project } from '@src/lib/project'
import type { SystemIOContext } from '@src/machines/systemIO/utils'
import {
NO_PROJECT_DIRECTORY,
SystemIOMachineActions,
SystemIOMachineActors,
SystemIOMachineEvents,
SystemIOMachineStates,
} from '@src/machines/systemIO/utils'
import toast from 'react-hot-toast'
import { assertEvent, assign, fromPromise, setup } from 'xstate'
import type { AppMachineContext } from '@src/lib/types'
/**
* Handles any system level I/O for folders and files
* This machine will be initializes once within the applications runtime
* and exist for the entire life cycle of the application and able to be access
* at a global level.
*/
export const systemIOMachine = setup({
types: {
context: {} as SystemIOContext,
events: {} as
| {
type: SystemIOMachineEvents.readFoldersFromProjectDirectory
}
| {
type: SystemIOMachineEvents.done_readFoldersFromProjectDirectory
output: Project[]
}
| {
type: SystemIOMachineEvents.done_checkReadWrite
output: { value: boolean; error: unknown }
}
| {
type: SystemIOMachineEvents.setProjectDirectoryPath
data: { requestedProjectDirectoryPath: string }
}
| {
type: SystemIOMachineEvents.navigateToProject
data: { requestedProjectName: string }
}
| {
type: SystemIOMachineEvents.navigateToFile
data: { requestedProjectName: string; requestedFileName: string }
}
| {
type: SystemIOMachineEvents.createProject
data: { requestedProjectName: string }
}
| {
type: SystemIOMachineEvents.renameProject
data: { requestedProjectName: string; projectName: string }
}
| {
type: SystemIOMachineEvents.deleteProject
data: { requestedProjectName: string }
}
| {
type: SystemIOMachineEvents.createKCLFile
data: {
requestedProjectName: string
requestedFileName: string
requestedCode: string
}
}
| {
type: SystemIOMachineEvents.importFileFromURL
data: {
requestedProjectName: string
requestedFileName: string
requestedCode: string
}
}
| {
type: SystemIOMachineEvents.setDefaultProjectFolderName
data: { requestedDefaultProjectFolderName: string }
},
},
actions: {
[SystemIOMachineActions.setFolders]: assign({
folders: ({ event }) => {
assertEvent(
event,
SystemIOMachineEvents.done_readFoldersFromProjectDirectory
)
return event.output
},
}),
[SystemIOMachineActions.setProjectDirectoryPath]: assign({
projectDirectoryPath: ({ event }) => {
assertEvent(event, SystemIOMachineEvents.setProjectDirectoryPath)
return event.data.requestedProjectDirectoryPath
},
}),
[SystemIOMachineActions.setRequestedProjectName]: assign({
requestedProjectName: ({ event }) => {
assertEvent(event, SystemIOMachineEvents.navigateToProject)
return { name: event.data.requestedProjectName }
},
}),
[SystemIOMachineActions.setRequestedFileName]: assign({
requestedFileName: ({ event }) => {
assertEvent(event, SystemIOMachineEvents.navigateToFile)
return {
project: event.data.requestedProjectName,
file: event.data.requestedFileName,
}
},
}),
[SystemIOMachineActions.setDefaultProjectFolderName]: assign({
defaultProjectFolderName: ({ event }) => {
assertEvent(event, SystemIOMachineEvents.setDefaultProjectFolderName)
return event.data.requestedDefaultProjectFolderName
},
}),
[SystemIOMachineActions.toastSuccess]: ({ event }) => {
toast.success(
('data' in event && typeof event.data === 'string' && event.data) ||
('output' in event &&
'message' in event.output &&
typeof event.output.message === 'string' &&
event.output.message) ||
''
)
},
[SystemIOMachineActions.toastError]: ({ event }) => {
toast.error(
('data' in event && typeof event.data === 'string' && event.data) ||
('output' in event &&
typeof event.output === 'string' &&
event.output) ||
('error' in event &&
event.error instanceof Error &&
event.error.message) ||
''
)
},
[SystemIOMachineActions.setReadWriteProjectDirectory]: assign({
canReadWriteProjectDirectory: ({ event }) => {
assertEvent(event, SystemIOMachineEvents.done_checkReadWrite)
return event.output
},
}),
},
actors: {
[SystemIOMachineActors.readFoldersFromProjectDirectory]: fromPromise(
async ({ input: context }: { input: SystemIOContext }) => {
const folders: Project[] = []
return folders
}
),
[SystemIOMachineActors.createProject]: fromPromise(
async ({
input: { context, requestedProjectName },
}: {
input: { context: SystemIOContext; requestedProjectName: string }
}) => {
return { message: '', name: '' }
}
),
[SystemIOMachineActors.deleteProject]: fromPromise(
async ({
input: { context, requestedProjectName },
}: {
input: { context: SystemIOContext; requestedProjectName: string }
}) => {
return { message: '', name: '' }
}
),
[SystemIOMachineActors.renameProject]: fromPromise(
async ({
input: { context, requestedProjectName, projectName },
}: {
input: {
context: SystemIOContext
requestedProjectName: string
projectName: string
}
}): Promise<{ message: string; newName: string; oldName: string }> => {
return { message: '', newName: '', oldName: '' }
}
),
[SystemIOMachineActors.createKCLFile]: fromPromise(
async ({
input,
}: {
input: {
context: SystemIOContext
requestedProjectName: string
requestedFileName: string
requestedCode: string
rootContext: AppMachineContext
}
}): Promise<{
message: string
fileName: string
projectName: string
}> => {
return { message: '', fileName: '', projectName: '' }
}
),
[SystemIOMachineActors.checkReadWrite]: fromPromise(
async ({
input: { context, requestedProjectDirectoryPath },
}: {
input: {
context: SystemIOContext
requestedProjectDirectoryPath: string
}
}): Promise<{ value: boolean; error: unknown }> => {
return { value: true, error: undefined }
}
),
},
}).createMachine({
initial: SystemIOMachineStates.idle,
// Remember, this machine and change its projectDirectory at any point
// '' will be no project directory, aka clear this machine out!
// To be the absolute root of someones computer we should take the string of path.resolve() in node.js which is different for each OS
context: () => ({
folders: [],
defaultProjectFolderName: DEFAULT_PROJECT_NAME,
projectDirectoryPath: NO_PROJECT_DIRECTORY,
hasListedProjects: false,
requestedProjectName: { name: NO_PROJECT_DIRECTORY },
requestedFileName: {
project: NO_PROJECT_DIRECTORY,
file: NO_PROJECT_DIRECTORY,
},
canReadWriteProjectDirectory: { value: true, error: undefined },
clearURLParams: { value: false },
}),
states: {
[SystemIOMachineStates.idle]: {
on: {
// on can be an action
[SystemIOMachineEvents.readFoldersFromProjectDirectory]: {
target: SystemIOMachineStates.readingFolders,
},
[SystemIOMachineEvents.setProjectDirectoryPath]: {
target: SystemIOMachineStates.checkingReadWrite,
actions: [SystemIOMachineActions.setProjectDirectoryPath],
},
[SystemIOMachineEvents.navigateToProject]: {
actions: [SystemIOMachineActions.setRequestedProjectName],
},
[SystemIOMachineEvents.navigateToFile]: {
actions: [SystemIOMachineActions.setRequestedFileName],
},
[SystemIOMachineEvents.createProject]: {
target: SystemIOMachineStates.creatingProject,
},
[SystemIOMachineEvents.renameProject]: {
target: SystemIOMachineStates.renamingProject,
},
[SystemIOMachineEvents.deleteProject]: {
target: SystemIOMachineStates.deletingProject,
},
[SystemIOMachineEvents.createKCLFile]: {
target: SystemIOMachineStates.creatingKCLFile,
},
[SystemIOMachineEvents.setDefaultProjectFolderName]: {
actions: [SystemIOMachineActions.setDefaultProjectFolderName],
},
[SystemIOMachineEvents.importFileFromURL]: {
target: SystemIOMachineStates.importFileFromURL,
},
},
},
[SystemIOMachineStates.readingFolders]: {
invoke: {
id: SystemIOMachineActors.readFoldersFromProjectDirectory,
src: SystemIOMachineActors.readFoldersFromProjectDirectory,
input: ({ context }) => {
return context
},
onDone: {
target: SystemIOMachineStates.idle,
actions: [
SystemIOMachineActions.setFolders,
assign({ hasListedProjects: true }),
],
},
onError: {
target: SystemIOMachineStates.idle,
},
},
},
[SystemIOMachineStates.creatingProject]: {
invoke: {
id: SystemIOMachineActors.createProject,
src: SystemIOMachineActors.createProject,
input: ({ context, event }) => {
assertEvent(event, SystemIOMachineEvents.createProject)
return {
context,
requestedProjectName: event.data.requestedProjectName,
}
},
onDone: {
target: SystemIOMachineStates.readingFolders,
actions: [
assign({
requestedProjectName: ({ event }) => {
return { name: event.output.name }
},
}),
SystemIOMachineActions.toastSuccess,
],
},
onError: {
target: SystemIOMachineStates.idle,
actions: [SystemIOMachineActions.toastError],
},
},
},
[SystemIOMachineStates.renamingProject]: {
invoke: {
id: SystemIOMachineActors.renameProject,
src: SystemIOMachineActors.renameProject,
input: ({ context, event }) => {
assertEvent(event, SystemIOMachineEvents.renameProject)
return {
context,
requestedProjectName: event.data.requestedProjectName,
projectName: event.data.projectName,
}
},
onDone: {
target: SystemIOMachineStates.readingFolders,
actions: [SystemIOMachineActions.toastSuccess],
},
onError: {
target: SystemIOMachineStates.idle,
actions: [SystemIOMachineActions.toastError],
},
},
},
[SystemIOMachineStates.deletingProject]: {
invoke: {
id: SystemIOMachineActors.deleteProject,
src: SystemIOMachineActors.deleteProject,
input: ({ context, event }) => {
assertEvent(event, SystemIOMachineEvents.deleteProject)
return {
context,
requestedProjectName: event.data.requestedProjectName,
}
},
onDone: {
target: SystemIOMachineStates.readingFolders,
actions: [SystemIOMachineActions.toastSuccess],
},
onError: {
target: SystemIOMachineStates.idle,
actions: [SystemIOMachineActions.toastError],
},
},
},
[SystemIOMachineStates.creatingKCLFile]: {
invoke: {
id: SystemIOMachineActors.createKCLFile,
src: SystemIOMachineActors.createKCLFile,
input: ({ context, event, self }) => {
assertEvent(event, SystemIOMachineEvents.createKCLFile)
return {
context,
requestedProjectName: event.data.requestedProjectName,
requestedFileName: event.data.requestedFileName,
requestedCode: event.data.requestedCode,
rootContext: self.system.get('root').getSnapshot().context,
}
},
onDone: {
target: SystemIOMachineStates.idle,
},
onError: {
target: SystemIOMachineStates.idle,
actions: [SystemIOMachineActions.toastError],
},
},
},
[SystemIOMachineStates.importFileFromURL]: {
invoke: {
id: SystemIOMachineActors.importFileFromURL,
src: SystemIOMachineActors.createKCLFile,
input: ({ context, event, self }) => {
assertEvent(event, SystemIOMachineEvents.importFileFromURL)
return {
context,
requestedProjectName: event.data.requestedProjectName,
requestedFileName: event.data.requestedFileName,
requestedCode: event.data.requestedCode,
rootContext: self.system.get('root').getSnapshot().context,
}
},
onDone: {
target: SystemIOMachineStates.readingFolders,
// Clear on web? not desktop
actions: [
assign({
requestedFileName: ({ context, event }) => {
assertEvent(event, SystemIOMachineEvents.done_importFileFromURL)
// Not the entire path
return {
project: event.output.projectName,
file: event.output.fileName + '.kcl',
}
},
}),
assign({ clearURLParams: { value: true } }),
],
},
onError: {
target: SystemIOMachineStates.idle,
actions: [SystemIOMachineActions.toastError],
},
},
},
[SystemIOMachineStates.checkingReadWrite]: {
invoke: {
id: SystemIOMachineActors.checkReadWrite,
src: SystemIOMachineActors.checkReadWrite,
input: ({ context, event }) => {
assertEvent(event, SystemIOMachineEvents.setProjectDirectoryPath)
return {
context,
requestedProjectDirectoryPath:
event.data.requestedProjectDirectoryPath,
}
},
onDone: {
target: SystemIOMachineStates.readingFolders,
},
onError: {
target: SystemIOMachineStates.readingFolders,
actions: [SystemIOMachineActions.toastError],
},
},
},
},
})

View File

@ -0,0 +1,232 @@
import {
createNewProjectDirectory,
getProjectInfo,
mkdirOrNOOP,
readAppSettingsFile,
renameProjectDirectory,
} from '@src/lib/desktop'
import {
doesProjectNameNeedInterpolated,
getNextFileName,
getNextProjectIndex,
getUniqueProjectName,
interpolateProjectNameWithIndex,
} from '@src/lib/desktopFS'
import type { Project } from '@src/lib/project'
import { systemIOMachine } from '@src/machines/systemIO/systemIOMachine'
import type { SystemIOContext } from '@src/machines/systemIO/utils'
import {
NO_PROJECT_DIRECTORY,
SystemIOMachineActors,
} from '@src/machines/systemIO/utils'
import { fromPromise } from 'xstate'
import type { AppMachineContext } from '@src/lib/types'
export const systemIOMachineDesktop = systemIOMachine.provide({
actors: {
[SystemIOMachineActors.readFoldersFromProjectDirectory]: fromPromise(
async ({ input: context }: { input: SystemIOContext }) => {
const projects = []
const projectDirectoryPath = context.projectDirectoryPath
if (projectDirectoryPath === NO_PROJECT_DIRECTORY) {
return []
}
await mkdirOrNOOP(projectDirectoryPath)
// Gotcha: readdir will list all folders at this project directory even if you do not have readwrite access on the directory path
const entries = await window.electron.readdir(projectDirectoryPath)
const { value: canReadWriteProjectDirectory } =
await window.electron.canReadWriteDirectory(projectDirectoryPath)
for (let entry of entries) {
// Skip directories that start with a dot
if (entry.startsWith('.')) {
continue
}
const projectPath = window.electron.path.join(
projectDirectoryPath,
entry
)
// if it's not a directory ignore.
// Gotcha: statIsDirectory will work even if you do not have read write permissions on the project path
const isDirectory = await window.electron.statIsDirectory(projectPath)
if (!isDirectory) {
continue
}
const project: Project = await getProjectInfo(projectPath)
if (
project.kcl_file_count === 0 &&
project.readWriteAccess &&
canReadWriteProjectDirectory
) {
continue
}
projects.push(project)
}
return projects
}
),
[SystemIOMachineActors.createProject]: fromPromise(
async ({
input,
}: {
input: { context: SystemIOContext; requestedProjectName: string }
}) => {
const folders = input.context.folders
const requestedProjectName = input.requestedProjectName
const uniqueName = getUniqueProjectName(requestedProjectName, folders)
await createNewProjectDirectory(uniqueName)
return {
message: `Successfully created "${uniqueName}"`,
name: uniqueName,
}
}
),
[SystemIOMachineActors.renameProject]: fromPromise(
async ({
input,
}: {
input: {
context: SystemIOContext
requestedProjectName: string
projectName: string
}
}) => {
const folders = input.context.folders
const requestedProjectName = input.requestedProjectName
const projectName = input.projectName
let newProjectName: string = requestedProjectName
if (doesProjectNameNeedInterpolated(requestedProjectName)) {
const nextIndex = getNextProjectIndex(requestedProjectName, folders)
newProjectName = interpolateProjectNameWithIndex(
requestedProjectName,
nextIndex
)
}
// Toast an error if the project name is taken
if (folders.find((p) => p.name === newProjectName)) {
return Promise.reject(
new Error(`Project with name "${newProjectName}" already exists`)
)
}
await renameProjectDirectory(
window.electron.path.join(
input.context.projectDirectoryPath,
projectName
),
newProjectName
)
return {
message: `Successfully renamed "${projectName}" to "${newProjectName}"`,
oldName: projectName,
newName: newProjectName,
}
}
),
[SystemIOMachineActors.deleteProject]: fromPromise(
async ({
input,
}: {
input: { context: SystemIOContext; requestedProjectName: string }
}) => {
await window.electron.rm(
window.electron.path.join(
input.context.projectDirectoryPath,
input.requestedProjectName
),
{
recursive: true,
}
)
return {
message: `Successfully deleted "${input.requestedProjectName}"`,
name: input.requestedProjectName,
}
}
),
[SystemIOMachineActors.createKCLFile]: fromPromise(
async ({
input,
}: {
input: {
context: SystemIOContext
requestedProjectName: string
requestedFileName: string
requestedCode: string
rootContext: AppMachineContext
}
}) => {
const requestedProjectName = input.requestedProjectName
const requestedFileName = input.requestedFileName
const requestedCode = input.requestedCode
const folders = input.context.folders
let newProjectName = requestedProjectName
if (!newProjectName) {
newProjectName = getUniqueProjectName(
input.context.defaultProjectFolderName,
input.context.folders
)
}
const needsInterpolated =
doesProjectNameNeedInterpolated(newProjectName)
if (needsInterpolated) {
const nextIndex = getNextProjectIndex(newProjectName, folders)
newProjectName = interpolateProjectNameWithIndex(
newProjectName,
nextIndex
)
}
const baseDir = window.electron.join(
input.context.projectDirectoryPath,
newProjectName
)
const { name: newFileName } = getNextFileName({
entryName: requestedFileName,
baseDir,
})
const configuration = await readAppSettingsFile()
// Create the project around the file if newProject
await createNewProjectDirectory(
newProjectName,
requestedCode,
configuration,
newFileName
)
return {
message: 'File created successfully',
fileName: input.requestedFileName,
projectName: newProjectName,
}
}
),
[SystemIOMachineActors.checkReadWrite]: fromPromise(
async ({
input,
}: {
input: {
context: SystemIOContext
requestedProjectDirectoryPath: string
}
}) => {
const requestProjectDirectoryPath = input.requestedProjectDirectoryPath
if (!requestProjectDirectoryPath) {
return { value: true, error: undefined }
}
const result = await window.electron.canReadWriteDirectory(
requestProjectDirectoryPath
)
return result
}
),
},
})

View File

@ -0,0 +1,50 @@
import { systemIOMachine } from '@src/machines/systemIO/systemIOMachine'
import type { SystemIOContext } from '@src/machines/systemIO/utils'
import { SystemIOMachineActors } from '@src/machines/systemIO/utils'
import { fromPromise } from 'xstate'
import { newKclFile } from '@src/lang/project'
import { readLocalStorageProjectSettingsFile } from '@src/lib/settings/settingsUtils'
import { err } from '@src/lib/trap'
import { DEFAULT_DEFAULT_LENGTH_UNIT } from '@src/lib/constants'
import type { AppMachineContext } from '@src/lib/types'
export const systemIOMachineWeb = systemIOMachine.provide({
actors: {
[SystemIOMachineActors.createKCLFile]: fromPromise(
async ({
input,
}: {
input: {
context: SystemIOContext
requestedProjectName: string
requestedFileName: string
requestedCode: string
rootContext: AppMachineContext
}
}) => {
// Browser version doesn't navigate, just overwrites the current file
// clearImportSearchParams()
const projectSettings = readLocalStorageProjectSettingsFile()
if (err(projectSettings)) {
return Promise.reject(
'Unable to read project settings from local storage'
)
}
const codeToWrite = newKclFile(
input.requestedCode,
projectSettings?.settings?.modeling?.base_unit ||
DEFAULT_DEFAULT_LENGTH_UNIT
)
if (err(codeToWrite)) return Promise.reject(codeToWrite)
input.rootContext.codeManager.updateCodeStateEditor(codeToWrite)
await input.rootContext.codeManager.writeToFile()
await input.rootContext.kclManager.executeCode()
return {
message: 'File overwritten successfully',
fileName: input.requestedFileName,
projectName: '',
}
}
),
},
})

View File

@ -0,0 +1,73 @@
import type { Project } from '@src/lib/project'
export enum SystemIOMachineActors {
readFoldersFromProjectDirectory = 'read folders from project directory',
setProjectDirectoryPath = 'set project directory path',
createProject = 'create project',
renameProject = 'rename project',
deleteProject = 'delete project',
createKCLFile = 'create kcl file',
checkReadWrite = 'check read write',
importFileFromURL = 'import file from URL',
}
export enum SystemIOMachineStates {
idle = 'idle',
readingFolders = 'readingFolders',
settingProjectDirectoryPath = 'settingProjectDirectoryPath',
creatingProject = 'creatingProject',
renamingProject = 'renamingProject',
deletingProject = 'deletingProject',
creatingKCLFile = 'creatingKCLFile',
checkingReadWrite = 'checkingReadWrite',
importFileFromURL = 'importFileFromURL',
}
const donePrefix = 'xstate.done.actor.'
export enum SystemIOMachineEvents {
readFoldersFromProjectDirectory = 'read folders from project directory',
done_readFoldersFromProjectDirectory = donePrefix +
'read folders from project directory',
setProjectDirectoryPath = 'set project directory path',
navigateToProject = 'navigate to project',
navigateToFile = 'navigate to file',
createProject = 'create project',
renameProject = 'rename project',
deleteProject = 'delete project',
createKCLFile = 'create kcl file',
setDefaultProjectFolderName = 'set default project folder name',
done_checkReadWrite = donePrefix + 'check read write',
importFileFromURL = 'import file from URL',
done_importFileFromURL = donePrefix + 'import file from URL',
}
export enum SystemIOMachineActions {
setFolders = 'set folders',
setProjectDirectoryPath = 'set project directory path',
setRequestedProjectName = 'set requested project name',
setRequestedFileName = 'set requested file name',
setDefaultProjectFolderName = 'set default project folder name',
toastSuccess = 'toastSuccess',
toastError = 'toastError',
setReadWriteProjectDirectory = 'set read write project directory',
}
export const NO_PROJECT_DIRECTORY = ''
export type SystemIOContext = {
/** Only store folders under the projectDirectory, do not maintain folders outside this directory */
folders: Project[]
/** For this machines runtime, this is the default string when creating a project
* A project is defined by creating a folder at the one level below the working project directory */
defaultProjectFolderName: string
/** working project directory that stores all the project folders */
projectDirectoryPath: string
/** has the application gone through the initialization of systemIOMachine at least once.
* this is required to prevent chokidar from spamming invalid events during initialization. */
hasListedProjects: boolean
requestedProjectName: { name: string }
requestedFileName: { project: string; file: string }
canReadWriteProjectDirectory: { value: boolean; error: unknown }
clearURLParams: { value: boolean }
}

View File

@ -10,8 +10,8 @@ import {
} from '@src/lib/singletons'
import { reportRejection } from '@src/lib/trap'
import { uuidv4 } from '@src/lib/utils'
import { authActor, settingsActor } from '@src/machines/appMachine'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { authActor, settingsActor } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons'
import type { WebContentSendPayload } from '@src/menu/channels'
import type { NavigateFunction } from 'react-router-dom'

View File

@ -1,5 +1,5 @@
import type { FormEvent } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef } from 'react'
import { toast } from 'react-hot-toast'
import { useHotkeys } from 'react-hotkeys-hook'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
@ -15,7 +15,6 @@ import {
} from '@src/components/ProjectSearchBar'
import { useCreateFileLinkQuery } from '@src/hooks/useCreateFileLinkQueryWatcher'
import { useMenuListener } from '@src/hooks/useMenu'
import { useProjectsContext } from '@src/hooks/useProjectsContext'
import { isDesktop } from '@src/lib/isDesktop'
import { PATHS } from '@src/lib/paths'
import { markOnce } from '@src/lib/performance'
@ -27,21 +26,24 @@ import {
getSortIcon,
} from '@src/lib/sorting'
import { reportRejection } from '@src/lib/trap'
import { authActor, useSettings } from '@src/machines/appMachine'
import { commandBarActor } from '@src/machines/commandBarMachine'
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'
// 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 { state, send } = useProjectsContext()
const [readWriteProjectDir, setReadWriteProjectDir] = useState<{
value: boolean
error: unknown
}>({
value: true,
error: undefined,
})
const state = useSystemIOState()
const readWriteProjectDir = useCanReadWriteProjectDirectory()
// Only create the native file menus on desktop
useEffect(() => {
@ -156,40 +158,13 @@ const Home = () => {
}
)
const ref = useRef<HTMLDivElement>(null)
const projects = state?.context.projects ?? []
const projects = useFolders()
const [searchParams, setSearchParams] = useSearchParams()
const { searchResults, query, setQuery } = useProjectSearch(projects)
const sort = searchParams.get('sort_by') ?? 'modified:desc'
const isSortByModified = sort?.includes('modified') || !sort || sort === null
// Update the default project name and directory in the home machine
// when the settings change
useEffect(() => {
send({
type: 'assign',
data: {
defaultProjectName: settings.projects.defaultProjectName.current,
defaultDirectory: settings.app.projectDirectory.current,
},
})
// Must be a truthy string, not '' or null or undefined
if (settings.app.projectDirectory.current) {
window.electron
.canReadWriteDirectory(settings.app.projectDirectory.current)
.then((res) => {
setReadWriteProjectDir(res)
})
.catch(reportRejection)
}
}, [
settings.app.projectDirectory.current,
settings.projects.defaultProjectName.current,
send,
])
async function handleRenameProject(
e: FormEvent<HTMLFormElement>,
project: Project
@ -204,17 +179,20 @@ const Home = () => {
}
if (newProjectName !== project.name) {
send({
type: 'Rename project',
data: { oldName: project.name, newName: newProjectName as string },
systemIOActor.send({
type: SystemIOMachineEvents.renameProject,
data: {
requestedProjectName: String(newProjectName),
projectName: project.name,
},
})
}
}
async function handleDeleteProject(project: Project) {
send({
type: 'Delete project',
data: { name: project.name || '' },
systemIOActor.send({
type: SystemIOMachineEvents.deleteProject,
data: { requestedProjectName: project.name },
})
}
/** Type narrowing function of unknown error to a string */
@ -246,9 +224,6 @@ const Home = () => {
data: {
groupId: 'projects',
name: 'Create project',
argDefaultValues: {
name: settings.projects.defaultProjectName.current,
},
},
})
}
@ -345,7 +320,7 @@ const Home = () => {
data-testid="home-section"
className="flex-1 overflow-y-auto pr-2 pb-24"
>
{state?.matches('Reading projects') ? (
{state?.matches(SystemIOMachineStates.readingFolders) ? (
<Loading>Loading your Projects...</Loading>
) : (
<>

View File

@ -1,7 +1,7 @@
import { SettingsSection } from '@src/components/Settings/SettingsSection'
import type { CameraSystem } from '@src/lib/cameraControls'
import { cameraMouseDragGuards, cameraSystems } from '@src/lib/cameraControls'
import { settingsActor, useSettings } from '@src/machines/appMachine'
import { settingsActor, useSettings } from '@src/lib/singletons'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import {

View File

@ -13,7 +13,7 @@ import { codeManager, kclManager } from '@src/lib/singletons'
import { Themes, getSystemTheme } from '@src/lib/theme'
import { reportRejection } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types'
import { useSettings } from '@src/machines/appMachine'
import { useSettings } from '@src/lib/singletons'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'

View File

@ -1,7 +1,7 @@
import { bracketThicknessCalculationLine } from '@src/lib/exampleKcl'
import { isDesktop } from '@src/lib/isDesktop'
import { Themes, getSystemTheme } from '@src/lib/theme'
import { useSettings } from '@src/machines/appMachine'
import { useSettings } from '@src/lib/singletons'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'

View File

@ -3,7 +3,7 @@ import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '@src/components/ActionButton'
import { SettingsSection } from '@src/components/Settings/SettingsSection'
import { type BaseUnit, baseUnitsUnion } from '@src/lib/settings/settingsTypes'
import { settingsActor, useSettings } from '@src/machines/appMachine'
import { settingsActor, useSettings } from '@src/lib/singletons'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { useDismiss, useNextClick } from '@src/routes/Onboarding/utils'

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useUser } from '@src/machines/appMachine'
import { useUser } from '@src/lib/singletons'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { OnboardingButtons } from '@src/routes/Onboarding/utils'

View File

@ -14,7 +14,7 @@ import makeUrlPathRelative from '@src/lib/makeUrlPathRelative'
import { PATHS } from '@src/lib/paths'
import { codeManager, editorManager, kclManager } from '@src/lib/singletons'
import { reportRejection, trap } from '@src/lib/trap'
import { settingsActor } from '@src/machines/appMachine'
import { settingsActor } from '@src/lib/singletons'
import { onboardingRoutes } from '@src/routes/Onboarding'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { parse, resultIsOk } from '@src/lang/wasm'

View File

@ -14,7 +14,7 @@ import { PATHS } from '@src/lib/paths'
import { Themes, getSystemTheme } from '@src/lib/theme'
import { reportRejection } from '@src/lib/trap'
import { toSync } from '@src/lib/utils'
import { authActor, useSettings } from '@src/machines/appMachine'
import { authActor, useSettings } from '@src/lib/singletons'
import { APP_VERSION, IS_NIGHTLY } from '@src/routes/utils'
const subtleBorder =