Franknoirot/cmd bar (#328)

* Add XState and naive ActionBar

* Add basic dialog and combobox

* Selectable commands in command bar

* Add a few (broken) file actions

* Home commands

* Add subcommand descriptions, cleanup on navigate

* Refactor: move command creation and types to lib

* Refactor to allow any machine to add commands

* Add auth to command bar, add ability to hide cmds

* Refactor: consolidate theme utilities

* Add settings as machine and command set

* Fix: type tweaks

* Fix: only allow auth to navigate from signin

* Remove zustand-powered settings

* Fix: remove zustand settings from App

* Fix: browser infinite redirect

* Feature: allow commands to be hidden per-platform

* Fix: tsc errors

* Fix: hide default project directory from cmd bar

* Polish: transitions, css tweaks

* Feature: label current value in options settings

* Fix broken debug panel UI

* Refactor: move settings toasts to actions

* Tweak: css rounding

* Fix: set default directory recursion and reload 🐞

* Refactor: move machines to their own directory

* Fix formatting

* @Irev-Dev clean-up catches, import cleanup
This commit is contained in:
Frank Noirot
2023-08-28 20:31:49 -04:00
committed by GitHub
parent 6f0fae625f
commit 32d928ae0c
33 changed files with 1556 additions and 475 deletions

View File

@ -8,6 +8,7 @@
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.13",
"@headlessui/tailwindcss": "^0.2.0",
"@kittycad/lib": "^0.0.34",
"@react-hook/resize-observer": "^1.2.6",
"@tauri-apps/api": "^1.3.0",
@ -22,6 +23,7 @@
"@xstate/react": "^3.2.2",
"crypto-js": "^4.1.1",
"formik": "^2.4.3",
"fuse.js": "^6.6.2",
"http-server": "^14.1.1",
"re-resizable": "^6.9.9",
"react": "^18.2.0",

21
src-tauri/Cargo.lock generated
View File

@ -1628,6 +1628,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "933dca44d65cdd53b355d0b73d380a2ff5da71f87f036053188bf1eab6a19881"
[[package]]
name = "miniz_oxide"
version = "0.6.2"
@ -3022,6 +3028,7 @@ checksum = "d42ba3a2e8556722f31336a0750c10dbb6a81396a1c452977f515da83f69f842"
dependencies = [
"anyhow",
"attohttpc",
"base64 0.21.2",
"cocoa",
"dirs-next",
"embed_plist",
@ -3034,6 +3041,7 @@ dependencies = [
"heck 0.4.1",
"http",
"ignore",
"minisign-verify",
"objc",
"once_cell",
"open",
@ -3055,12 +3063,14 @@ dependencies = [
"tauri-utils",
"tempfile",
"thiserror",
"time",
"tokio",
"url",
"uuid",
"webkit2gtk",
"webview2-com",
"windows 0.39.0",
"zip",
]
[[package]]
@ -4228,3 +4238,14 @@ checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc"
dependencies = [
"libc",
]
[[package]]
name = "zip"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [
"byteorder",
"crc32fast",
"crossbeam-utils",
]

View File

@ -19,7 +19,7 @@ anyhow = "1"
oauth2 = "4.4.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tauri = { version = "1.3.0", features = [ "path-all", "dialog-all", "fs-all", "http-request", "shell-open", "shell-open-api"] }
tauri = { version = "1.3.0", features = [ "updater", "path-all", "dialog-all", "fs-all", "http-request", "shell-open", "shell-open-api"] }
tokio = { version = "1.29.1", features = ["time"] }
toml = "0.6.0"
tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }

View File

@ -5,6 +5,7 @@ import {
useMemo,
useCallback,
MouseEventHandler,
useContext,
} from 'react'
import { DebugPanel } from './components/DebugPanel'
import { v4 as uuidv4 } from 'uuid'
@ -18,7 +19,7 @@ import {
lineHighlightField,
addLineHighlight,
} from './editor/highlightextension'
import { PaneType, Selections, Themes, useStore } from './useStore'
import { PaneType, Selections, useStore } from './useStore'
import { Logs, KCLErrors } from './components/Logs'
import { CollapsiblePanel } from './components/CollapsiblePanel'
import { MemoryPanel } from './components/MemoryPanel'
@ -41,7 +42,7 @@ import {
import { useHotkeys } from 'react-hotkeys-hook'
import { TEST } from './env'
import { getNormalisedCoordinates } from './lib/utils'
import { getSystemTheme } from './lib/getSystemTheme'
import { Themes, getSystemTheme } from './lib/theme'
import { isTauri } from './lib/isTauri'
import { useLoaderData, useParams } from 'react-router-dom'
import { writeTextFile } from '@tauri-apps/api/fs'
@ -49,6 +50,7 @@ import { PROJECT_ENTRYPOINT } from './lib/tauriFS'
import { IndexLoaderData } from './Router'
import { toast } from 'react-hot-toast'
import { useAuthMachine } from './hooks/useAuthMachine'
import { SettingsContext } from 'components/SettingsCommandProvider'
export function App() {
const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
@ -83,11 +85,8 @@ export function App() {
cmdId,
setCmdId,
formatCode,
debugPanel,
theme,
openPanes,
setOpenPanes,
onboardingStatus,
didDragInStream,
setDidDragInStream,
setStreamDimensions,
@ -122,17 +121,17 @@ export function App() {
cmdId: s.cmdId,
setCmdId: s.setCmdId,
formatCode: s.formatCode,
debugPanel: s.debugPanel,
addKCLError: s.addKCLError,
theme: s.theme,
openPanes: s.openPanes,
setOpenPanes: s.setOpenPanes,
onboardingStatus: s.onboardingStatus,
didDragInStream: s.didDragInStream,
setDidDragInStream: s.setDidDragInStream,
setStreamDimensions: s.setStreamDimensions,
streamDimensions: s.streamDimensions,
}))
const { showDebugPanel, theme, onboardingStatus } =
useContext(SettingsContext)
const [token] = useAuthMachine((s) => s?.context?.token)
const editorTheme = theme === Themes.System ? getSystemTheme() : theme
@ -510,7 +509,7 @@ export function App() {
</div>
</Resizable>
<Stream className="absolute inset-0 z-0" />
{debugPanel && (
{showDebugPanel && (
<DebugPanel
title="Debug"
className={

View File

@ -24,7 +24,16 @@ import {
} from './lib/tauriFS'
import { metadata, type Metadata } from 'tauri-plugin-fs-extra-api'
import DownloadAppBanner from './components/DownloadAppBanner'
import { GlobalStateProvider } from './hooks/useAuthMachine'
import {
AuthMachineCommandProvider,
GlobalStateProvider,
} from './hooks/useAuthMachine'
import SettingsCommandProvider from './components/SettingsCommandProvider'
import {
SETTINGS_PERSIST_KEY,
settingsMachine,
} from './machines/settingsMachine'
import { ContextFrom } from 'xstate'
const prependRoutes =
(routesObject: Record<string, string>) => (prepend: string) => {
@ -68,7 +77,15 @@ const addGlobalContextToElements = (
'element' in route
? {
...route,
element: <GlobalStateProvider>{route.element}</GlobalStateProvider>,
element: (
<GlobalStateProvider>
<AuthMachineCommandProvider>
<SettingsCommandProvider>
{route.element}
</SettingsCommandProvider>
</AuthMachineCommandProvider>
</GlobalStateProvider>
),
}
: route
)
@ -95,26 +112,23 @@ const router = createBrowserRouter(
request,
params,
}): Promise<IndexLoaderData | Response> => {
const store = localStorage.getItem('store')
if (store === null) {
return redirect(paths.ONBOARDING.INDEX)
} else {
const status = JSON.parse(store).state.onboardingStatus || ''
const notEnRouteToOnboarding =
!request.url.includes(paths.ONBOARDING.INDEX) &&
request.method === 'GET'
// '' is the initial state, 'done' and 'dismissed' are the final states
const hasValidOnboardingStatus =
(status !== undefined && status.length === 0) ||
!(status === 'done' || status === 'dismissed')
const shouldRedirectToOnboarding =
notEnRouteToOnboarding && hasValidOnboardingStatus
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
ContextFrom<typeof settingsMachine>
>
if (shouldRedirectToOnboarding) {
return redirect(
makeUrlPathRelative(paths.ONBOARDING.INDEX) + status
)
}
const status = persistedSettings.onboardingStatus || ''
const notEnRouteToOnboarding = !request.url.includes(
paths.ONBOARDING.INDEX
)
// '' is the initial state, 'done' and 'dismissed' are the final states
const hasValidOnboardingStatus =
status.length === 0 || !(status === 'done' || status === 'dismissed')
const shouldRedirectToOnboarding =
notEnRouteToOnboarding && hasValidOnboardingStatus
if (shouldRedirectToOnboarding) {
return redirect(makeUrlPathRelative(paths.ONBOARDING.INDEX) + status)
}
if (params.id && params.id !== 'new') {
@ -164,9 +178,23 @@ const router = createBrowserRouter(
if (!isTauri()) {
return redirect(paths.FILE + '/new')
}
const projectDir = await initializeProjectDirectory()
const projectsNoMeta = (await readDir(projectDir.dir)).filter(
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
ContextFrom<typeof settingsMachine>
>
const projectDir = await initializeProjectDirectory(
persistedSettings.defaultDirectory || ''
)
if (projectDir !== persistedSettings.defaultDirectory) {
localStorage.setItem(
SETTINGS_PERSIST_KEY,
JSON.stringify({
...persistedSettings,
defaultDirectory: projectDir,
})
)
}
const projectsNoMeta = (await readDir(projectDir)).filter(
isProjectDirectory
)
const projects = await Promise.all(

View File

@ -8,11 +8,13 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
const iconSizes = {
sm: 12,
md: 14.4,
lg: 18,
lg: 20,
xl: 28,
}
export interface ActionIconProps extends React.PropsWithChildren {
icon?: SolidIconDefinition | BrandIconDefinition
className?: string
bgClassName?: string
iconClassName?: string
size?: keyof typeof iconSizes
@ -20,6 +22,7 @@ export interface ActionIconProps extends React.PropsWithChildren {
export const ActionIcon = ({
icon = faCircleExclamation,
className,
bgClassName,
iconClassName,
size = 'md',
@ -28,7 +31,9 @@ export const ActionIcon = ({
return (
<div
className={
'p-1 w-fit inline-grid place-content-center ' +
`p-${
size === 'xl' ? '2' : '1'
} w-fit inline-grid place-content-center ${className} ` +
(bgClassName ||
'bg-chalkboard-100 group-hover:bg-chalkboard-90 hover:bg-chalkboard-90 dark:bg-liquid-20 dark:group-hover:bg-liquid-10 dark:hover:bg-liquid-10')
}
@ -40,7 +45,7 @@ export const ActionIcon = ({
height={iconSizes[size]}
className={
iconClassName ||
'text-liquid-20 group-hover:text-liquid-10 hover:text-liquid-10 dark:text-liquid-100 dark:group-hover:text-liquid-100 dark:hover:text-liquid-100'
'text-liquid-20 h-auto group-hover:text-liquid-10 hover:text-liquid-10 dark:text-liquid-100 dark:group-hover:text-liquid-100 dark:hover:text-liquid-100'
}
/>
)}

View File

@ -0,0 +1,258 @@
import { Combobox, Dialog, Transition } from '@headlessui/react'
import {
Dispatch,
Fragment,
SetStateAction,
createContext,
useContext,
useState,
} from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { ActionIcon } from './ActionIcon'
import { faSearch } from '@fortawesome/free-solid-svg-icons'
import Fuse from 'fuse.js'
import { Command, SubCommand } from '../lib/commands'
export type SortedCommand = {
item: Partial<Command | SubCommand> & { name: string }
}
export const CommandsContext = createContext(
{} as {
commands: Command[]
addCommands: (commands: Command[]) => void
removeCommands: (commands: Command[]) => void
commandBarOpen: boolean
setCommandBarOpen: Dispatch<SetStateAction<boolean>>
}
)
const CommandBar = () => {
const { commands, commandBarOpen, setCommandBarOpen } =
useContext(CommandsContext)
useHotkeys('meta+k', () => {
if (commands.length === 0) return
setCommandBarOpen(!commandBarOpen)
})
const [selectedCommand, setSelectedCommand] = useState<SortedCommand | null>(
null
)
// keep track of the current subcommand index
const [subCommandIndex, setSubCommandIndex] = useState<number>()
const [subCommandData, setSubCommandData] = useState<{
[key: string]: string
}>({})
// if the subcommand index is null, we're not in a subcommand
const inSubCommand =
selectedCommand &&
'meta' in selectedCommand.item &&
selectedCommand.item.meta?.args !== undefined &&
subCommandIndex !== undefined
const currentSubCommand =
inSubCommand && 'meta' in selectedCommand.item
? selectedCommand.item.meta?.args[subCommandIndex]
: undefined
const [query, setQuery] = useState('')
const availableCommands =
inSubCommand && currentSubCommand
? currentSubCommand.type === 'string'
? query
? [{ name: query }]
: currentSubCommand.options
: currentSubCommand.options
: commands
const fuse = new Fuse(availableCommands || [], {
keys: ['name', 'description'],
})
const filteredCommands = query
? fuse.search(query)
: availableCommands?.map((c) => ({ item: c } as SortedCommand))
function clearState() {
setQuery('')
setCommandBarOpen(false)
setSelectedCommand(null)
setSubCommandIndex(undefined)
setSubCommandData({})
}
function handleCommandSelection(entry: SortedCommand) {
// If we have subcommands and have not yet gathered all the
// data required from them, set the selected command to the
// current command and increment the subcommand index
if (selectedCommand === null && 'meta' in entry.item && entry.item.meta) {
setSelectedCommand(entry)
setSubCommandIndex(0)
setQuery('')
return
}
const { item } = entry
// If we have just selected a command with no subcommands, run it
const isCommandWithoutSubcommands =
'callback' in item && !('meta' in item && item.meta)
if (isCommandWithoutSubcommands) {
if (item.callback === undefined) return
item.callback()
setCommandBarOpen(false)
return
}
// If we have subcommands and have not yet gathered all the
// data required from them, set the selected command to the
// current command and increment the subcommand index
if (
selectedCommand &&
subCommandIndex !== undefined &&
'meta' in selectedCommand.item
) {
const subCommand = selectedCommand.item.meta?.args[subCommandIndex]
if (subCommand) {
const newSubCommandData = {
...subCommandData,
[subCommand.name]: item.name,
}
const newSubCommandIndex = subCommandIndex + 1
// If we have subcommands and have gathered all the data required
// from them, run the command with the gathered data
if (
selectedCommand.item.callback &&
selectedCommand.item.meta?.args.length === newSubCommandIndex
) {
selectedCommand.item.callback(newSubCommandData)
setCommandBarOpen(false)
} else {
// Otherwise, set the subcommand data and increment the subcommand index
setSubCommandData(newSubCommandData)
setSubCommandIndex(newSubCommandIndex)
setQuery('')
}
}
}
}
function getDisplayValue(command: Command) {
if (command.meta?.displayValue === undefined || !command.meta.args)
return command.name
return command.meta?.displayValue(
command.meta.args.map((c) =>
subCommandData[c.name] ? subCommandData[c.name] : `<${c.name}>`
)
)
}
return (
<Transition.Root
show={
commandBarOpen &&
availableCommands?.length !== undefined &&
availableCommands.length > 0
}
as={Fragment}
afterLeave={() => clearState()}
>
<Dialog
onClose={() => {
setCommandBarOpen(false)
clearState()
}}
className="fixed inset-0 overflow-y-auto p-4 pt-[25vh]"
>
<Transition.Child
enter="duration-100 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="duration-75 ease-in"
leaveFrom="opacity-100"
leaveTo="opacity-0"
as={Fragment}
>
<Dialog.Overlay className="fixed z-40 inset-0 bg-chalkboard-10/70 dark:bg-chalkboard-110/50" />
</Transition.Child>
<Transition.Child
enter="duration-100 ease-out"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-75 ease-in"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
as={Fragment}
>
<Combobox
value={selectedCommand}
onChange={handleCommandSelection}
className="rounded relative mx-auto z-40 p-2 bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-xl w-full shadow-lg"
as="div"
>
<div className="flex gap-2 items-center">
<ActionIcon icon={faSearch} size="xl" className="rounded-sm" />
<div>
{inSubCommand && (
<p className="text-liquid-70 dark:text-liquid-30">
{selectedCommand.item &&
getDisplayValue(selectedCommand.item as Command)}
</p>
)}
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
className="bg-transparent focus:outline-none w-full"
onKeyDown={(event) => {
if (event.metaKey && event.key === 'k')
setCommandBarOpen(false)
if (
inSubCommand &&
event.key === 'Backspace' &&
!event.currentTarget.value
) {
setSubCommandIndex(subCommandIndex - 1)
setSelectedCommand(null)
}
}}
displayValue={(command: SortedCommand) =>
command !== null ? command.item.name : ''
}
placeholder={
inSubCommand
? `Enter <${currentSubCommand?.name}>`
: 'Search for a command'
}
value={query}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
</div>
</div>
<Combobox.Options static className="max-h-96 overflow-y-auto">
{filteredCommands?.map((commandResult) => (
<Combobox.Option
key={commandResult.item.name}
value={commandResult}
className="my-2 first:mt-4 last:mb-4 ui-active:bg-liquid-10 dark:ui-active:bg-liquid-90 py-1 px-2"
>
<p>{commandResult.item.name}</p>
{(commandResult.item as SubCommand).description && (
<p className="mt-0.5 text-liquid-70 dark:text-liquid-30 text-sm">
{(commandResult.item as SubCommand).description}
</p>
)}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
</Transition.Child>
</Dialog>
</Transition.Root>
)
}
export default CommandBar

View File

@ -1,7 +1,8 @@
import ReactJson from 'react-json-view'
import { useEffect } from 'react'
import { Themes, useStore } from '../useStore'
import { useStore } from '../useStore'
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
import { Themes } from '../lib/theme'
const ReactJsonTypeHack = ReactJson as any

View File

@ -1,8 +1,9 @@
import ReactJson from 'react-json-view'
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
import { Themes, useStore } from '../useStore'
import { useStore } from '../useStore'
import { useMemo } from 'react'
import { ProgramMemory } from '../lang/executor'
import { Themes } from '../lib/theme'
interface MemoryPanelProps extends CollapsiblePanelProps {
theme?: Exclude<Themes, Themes.System>

View File

@ -0,0 +1,76 @@
import {
SETTINGS_PERSIST_KEY,
settingsCommandBarMeta,
settingsMachine,
} from '../machines/settingsMachine'
import { useMachine } from '@xstate/react'
import { CommandsContext } from './CommandBar'
import { createContext, useContext, useEffect, useRef } from 'react'
import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { setThemeClass } from '../lib/theme'
import { ContextFrom, InterpreterFrom, Prop } from 'xstate'
import { toast } from 'react-hot-toast'
export const SettingsContext = createContext(
{} as {
send: Prop<InterpreterFrom<typeof settingsMachine>, 'send'>
} & ContextFrom<typeof settingsMachine>
)
export default function SettingsCommandProvider({
children,
}: React.PropsWithChildren) {
const retrievedSettings = useRef(
localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}'
)
const persistedSettings = Object.assign(
settingsMachine.initialState.context,
JSON.parse(retrievedSettings.current) as Partial<
(typeof settingsMachine)['context']
>
)
const [state, send] = useMachine(settingsMachine, {
context: persistedSettings,
actions: {
toastSuccess: (context, event) => {
const truncatedNewValue =
'data' in event && event.data instanceof Object
? (context[Object.keys(event.data)[0] as keyof typeof context]
.toString()
.substring(0, 28) as any)
: undefined
toast.success(
event.type +
(truncatedNewValue
? ` to "${truncatedNewValue}${
truncatedNewValue.length === 28 ? '...' : ''
}"`
: '')
)
},
},
})
const { commands } = useContext(CommandsContext)
useStateMachineCommands({
state,
send,
commands,
owner: 'settings',
commandBarMeta: settingsCommandBarMeta,
})
useEffect(() => setThemeClass(state.context.theme), [state.context.theme])
return (
<SettingsContext.Provider
value={{
send,
...state.context,
}}
>
{children}
</SettingsContext.Provider>
)
}

View File

@ -120,7 +120,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
</ActionButton>
<ActionButton
Element="button"
onClick={() => send('logout')}
onClick={() => send('Log out')}
icon={{
icon: faSignOutAlt,
bgClassName: 'bg-destroy-80',

View File

@ -1,8 +1,16 @@
import { createActorContext } from '@xstate/react'
import { useNavigate } from 'react-router-dom'
import { paths } from '../Router'
import { authMachine, TOKEN_PERSIST_KEY } from '../lib/authMachine'
import {
authCommandBarMeta,
authMachine,
TOKEN_PERSIST_KEY,
} from '../machines/authMachine'
import withBaseUrl from '../lib/withBaseURL'
import React, { useContext, useState } from 'react'
import CommandBar, { CommandsContext } from '../components/CommandBar'
import { Command } from '../lib/commands'
import useStateMachineCommands from './useStateMachineCommands'
export const AuthMachineContext = createActorContext(authMachine)
@ -11,7 +19,19 @@ export const GlobalStateProvider = ({
}: {
children: React.ReactNode
}) => {
const [commands, internalSetCommands] = useState([] as Command[])
const [commandBarOpen, setCommandBarOpen] = useState(false)
const navigate = useNavigate()
const addCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) => [...newCommands, ...prevCommands])
}
const removeCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) =>
prevCommands.filter((command) => !newCommands.includes(command))
)
}
return (
<AuthMachineContext.Provider
machine={() =>
@ -21,12 +41,27 @@ export const GlobalStateProvider = ({
navigate(paths.SIGN_IN)
logout()
},
goToIndexPage: () => navigate(paths.INDEX),
goToIndexPage: () => {
if (window.location.pathname.includes(paths.SIGN_IN)) {
navigate(paths.INDEX)
}
},
},
})
}
>
{children}
<CommandsContext.Provider
value={{
commands,
addCommands,
removeCommands,
commandBarOpen,
setCommandBarOpen,
}}
>
{children}
<CommandBar />
</CommandsContext.Provider>
</AuthMachineContext.Provider>
)
}
@ -52,3 +87,18 @@ export function logout() {
credentials: 'include',
})
}
export function AuthMachineCommandProvider(props: React.PropsWithChildren<{}>) {
const [state, send] = AuthMachineContext.useActor()
const { commands } = useContext(CommandsContext)
useStateMachineCommands({
state,
send,
commands,
commandBarMeta: authCommandBarMeta,
owner: 'auth',
})
return <>{props.children}</>
}

View File

@ -0,0 +1,42 @@
import { useContext, useEffect } from 'react'
import { AnyStateMachine, StateFrom } from 'xstate'
import { Command, CommandBarMeta, createMachineCommand } from '../lib/commands'
import { CommandsContext } from '../components/CommandBar'
interface UseStateMachineCommandsArgs<T extends AnyStateMachine> {
state: StateFrom<T>
send: Function
commandBarMeta?: CommandBarMeta
commands: Command[]
owner: string
}
export default function useStateMachineCommands<T extends AnyStateMachine>({
state,
send,
commandBarMeta,
owner,
}: UseStateMachineCommandsArgs<T>) {
const { addCommands, removeCommands } = useContext(CommandsContext)
useEffect(() => {
const newCommands = state.nextEvents
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
.map((type) =>
createMachineCommand<T>({
type,
state,
send,
commandBarMeta,
owner,
})
)
.filter((c) => c !== null) as Command[]
addCommands(newCommands)
return () => {
removeCommands(newCommands)
}
}, [state])
}

View File

@ -1,67 +0,0 @@
import { useEffect } from 'react'
import { useStore } from '../useStore'
import { parse } from 'toml'
import {
createDir,
BaseDirectory,
readDir,
readTextFile,
} from '@tauri-apps/api/fs'
export const useTauriBoot = () => {
const { defaultDir, setDefaultDir, setHomeMenuItems } = useStore((s) => ({
defaultDir: s.defaultDir,
setDefaultDir: s.setDefaultDir,
setHomeMenuItems: s.setHomeMenuItems,
}))
useEffect(() => {
const isTauri = (window as any).__TAURI__
if (!isTauri) return
const run = async () => {
if (!defaultDir.base) {
createDir('puffin-projects/example', {
dir: BaseDirectory.Home,
recursive: true,
})
setDefaultDir({
base: BaseDirectory.Home,
dir: 'puffin-projects',
})
} else {
const directoryResult = await readDir(defaultDir.dir, {
dir: defaultDir.base,
recursive: true,
})
const puffinProjects = directoryResult.filter(
(file) =>
!file?.name?.startsWith('.') &&
file?.children?.find((child) => child?.name === 'wax.toml')
)
const tomlFiles = await Promise.all(
puffinProjects.map(async (file) => {
const parsedToml = parse(
await readTextFile(`${file.path}/wax.toml`, {
dir: defaultDir.base,
})
)
const mainPath = parsedToml?.package?.main
const projectName = parsedToml?.package?.name
return {
file,
mainPath,
projectName,
}
})
)
setHomeMenuItems(
tomlFiles.map(({ file, mainPath, projectName }) => ({
name: projectName,
path: mainPath ? `${file.path}/${mainPath}` : file.path,
}))
)
}
}
run()
}, [])
}

View File

@ -2,23 +2,10 @@ import ReactDOM from 'react-dom/client'
import './index.css'
import reportWebVitals from './reportWebVitals'
import { Toaster } from 'react-hot-toast'
import { Themes, useStore } from './useStore'
import { Router } from './Router'
import { HotkeysProvider } from 'react-hotkeys-hook'
import { getSystemTheme } from './lib/getSystemTheme'
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
function setThemeClass(state: Partial<{ theme: Themes }>) {
const systemTheme = state.theme === Themes.System && getSystemTheme()
if (state.theme === Themes.Dark || systemTheme === Themes.Dark) {
document.body.classList.add('dark')
} else {
document.body.classList.remove('dark')
}
}
const { theme } = useStore.getState()
setThemeClass({ theme })
useStore.subscribe(setThemeClass)
root.render(
<HotkeysProvider>

126
src/lib/commands.ts Normal file
View File

@ -0,0 +1,126 @@
import { AnyStateMachine, EventFrom, StateFrom } from 'xstate'
import { isTauri } from './isTauri'
type InitialCommandBarMetaArg = {
name: string
type: 'string' | 'select'
description?: string
defaultValue?: string
options: string | Array<{ name: string }>
}
type Platform = 'both' | 'web' | 'desktop'
export type CommandBarMeta = {
[key: string]:
| {
displayValue: (args: string[]) => string
args: InitialCommandBarMetaArg[]
hide?: Platform
}
| {
hide?: Platform
}
}
export type Command = {
owner: string
name: string
callback: Function
meta?: {
displayValue(args: string[]): string | string
args: SubCommand[]
}
}
export type SubCommand = {
name: string
type: 'select' | 'string'
description?: string
options?: Partial<{ name: string }>[]
}
interface CommandBarArgs<T extends AnyStateMachine> {
type: EventFrom<T>['type']
state: StateFrom<T>
commandBarMeta?: CommandBarMeta
send: Function
owner: string
}
export function createMachineCommand<T extends AnyStateMachine>({
type,
state,
commandBarMeta,
send,
owner,
}: CommandBarArgs<T>): Command | null {
const lookedUpMeta = commandBarMeta && commandBarMeta[type]
if (lookedUpMeta && 'hide' in lookedUpMeta) {
const { hide } = lookedUpMeta
if (hide === 'both') return null
else if (hide === 'desktop' && isTauri()) return null
else if (hide === 'web' && !isTauri()) return null
}
let replacedArgs
if (lookedUpMeta && 'args' in lookedUpMeta) {
replacedArgs = lookedUpMeta.args.map((arg) => {
const optionsFromContext = state.context[
arg.options as keyof typeof state.context
] as { name: string }[] | string | undefined
const defaultValueFromContext = state.context[
arg.defaultValue as keyof typeof state.context
] as string | undefined
console.log(arg.name, { defaultValueFromContext })
const options =
arg.options instanceof Array
? arg.options.map((o) => ({
...o,
description:
defaultValueFromContext === o.name ? '(current)' : '',
}))
: !optionsFromContext || typeof optionsFromContext === 'string'
? [
{
name: optionsFromContext,
description: arg.description || '',
},
]
: optionsFromContext.map((o) => ({
name: o.name || '',
description: arg.description || '',
}))
return {
...arg,
options,
}
}) as any[]
}
// We have to recreate this object every time,
// otherwise we'll have stale state in the CommandBar
// after completing our first action
const meta = lookedUpMeta
? {
...lookedUpMeta,
args: replacedArgs,
}
: undefined
return {
name: type,
owner,
callback: (data: EventFrom<T, typeof type>) => {
if (data !== undefined && data !== null) {
send(type, { data })
} else {
send(type)
}
},
meta: meta as any,
}
}

View File

@ -1,9 +0,0 @@
import { Themes } from '../useStore'
export function getSystemTheme(): Exclude<Themes, 'system'> {
return typeof window !== 'undefined' &&
'matchMedia' in window &&
window.matchMedia('(prefers-color-scheme: dark)').matches
? Themes.Dark
: Themes.Light
}

64
src/lib/sorting.ts Normal file
View File

@ -0,0 +1,64 @@
import {
faArrowDown,
faArrowUp,
faCircleDot,
} from '@fortawesome/free-solid-svg-icons'
import { ProjectWithEntryPointMetadata } from '../Router'
const DESC = ':desc'
export function getSortIcon(currentSort: string, newSort: string) {
if (currentSort === newSort) {
return faArrowUp
} else if (currentSort === newSort + DESC) {
return faArrowDown
}
return faCircleDot
}
export function getNextSearchParams(currentSort: string, newSort: string) {
if (currentSort === null || !currentSort)
return { sort_by: newSort + (newSort !== 'modified' ? DESC : '') }
if (currentSort.includes(newSort) && !currentSort.includes(DESC))
return { sort_by: '' }
return {
sort_by: newSort + (currentSort.includes(DESC) ? '' : DESC),
}
}
export function getSortFunction(sortBy: string) {
const sortByName = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (a.name && b.name) {
return sortBy.includes('desc')
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
}
return 0
}
const sortByModified = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (
a.entrypoint_metadata?.modifiedAt &&
b.entrypoint_metadata?.modifiedAt
) {
return !sortBy || sortBy.includes('desc')
? b.entrypoint_metadata.modifiedAt.getTime() -
a.entrypoint_metadata.modifiedAt.getTime()
: a.entrypoint_metadata.modifiedAt.getTime() -
b.entrypoint_metadata.modifiedAt.getTime()
}
return 0
}
if (sortBy?.includes('name')) {
return sortByName
} else {
return sortByModified
}
}

View File

@ -1,6 +1,11 @@
import { FileEntry, createDir, exists, writeTextFile } from '@tauri-apps/api/fs'
import {
FileEntry,
createDir,
exists,
readDir,
writeTextFile,
} from '@tauri-apps/api/fs'
import { documentDir } from '@tauri-apps/api/path'
import { useStore } from '../useStore'
import { isTauri } from './isTauri'
import { ProjectWithEntryPointMetadata } from '../Router'
import { metadata } from 'tauri-plugin-fs-extra-api'
@ -12,35 +17,31 @@ const INDEX_IDENTIFIER = '$n' // $nn.. will pad the number with 0s
export const MAX_PADDING = 7
// Initializes the project directory and returns the path
export async function initializeProjectDirectory() {
export async function initializeProjectDirectory(directory: string) {
if (!isTauri()) {
throw new Error(
'initializeProjectDirectory() can only be called from a Tauri app'
)
}
const { defaultDir: projectDir, setDefaultDir } = useStore.getState()
if (projectDir && projectDir.dir.length > 0) {
const dirExists = await exists(projectDir.dir)
if (directory) {
const dirExists = await exists(directory)
if (!dirExists) {
await createDir(projectDir.dir, { recursive: true })
await createDir(directory, { recursive: true })
}
return projectDir
return directory
}
const appData = await documentDir()
const docDirectory = await documentDir()
const INITIAL_DEFAULT_DIR = {
dir: appData + PROJECT_FOLDER,
}
const INITIAL_DEFAULT_DIR = docDirectory + PROJECT_FOLDER
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR.dir)
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR)
if (!defaultDirExists) {
await createDir(INITIAL_DEFAULT_DIR.dir, { recursive: true })
await createDir(INITIAL_DEFAULT_DIR, { recursive: true })
}
setDefaultDir(INITIAL_DEFAULT_DIR)
return INITIAL_DEFAULT_DIR
}
@ -51,6 +52,25 @@ export function isProjectDirectory(fileOrDir: Partial<FileEntry>) {
)
}
// Read the contents of a directory
// and return the valid projects
export async function getProjectsInDir(projectDir: string) {
const readProjects = (
await readDir(projectDir, {
recursive: true,
})
).filter(isProjectDirectory)
const projectsWithMetadata = await Promise.all(
readProjects.map(async (p) => ({
entrypoint_metadata: await metadata(p.path + '/' + PROJECT_ENTRYPOINT),
...p,
}))
)
return projectsWithMetadata
}
// Creates a new file in the default directory with the default project name
// Returns the path to the new file
export async function createNewProject(

22
src/lib/theme.ts Normal file
View File

@ -0,0 +1,22 @@
export enum Themes {
Light = 'light',
Dark = 'dark',
System = 'system',
}
export function getSystemTheme(): Exclude<Themes, 'system'> {
return typeof window !== 'undefined' &&
'matchMedia' in window &&
window.matchMedia('(prefers-color-scheme: dark)').matches
? Themes.Dark
: Themes.Light
}
export function setThemeClass(theme: Themes) {
const systemTheme = theme === Themes.System && getSystemTheme()
if (theme === Themes.Dark || systemTheme === Themes.Dark) {
document.body.classList.add('dark')
} else {
document.body.classList.remove('dark')
}
}

View File

@ -1,6 +1,7 @@
import { createMachine, assign } from 'xstate'
import { Models } from '@kittycad/lib'
import withBaseURL from '../lib/withBaseURL'
import { CommandBarMeta } from '../lib/commands'
export interface UserContext {
user?: Models['User_type']
@ -9,16 +10,22 @@ export interface UserContext {
export type Events =
| {
type: 'logout'
type: 'Log out'
}
| {
type: 'tryLogin'
type: 'Log in'
token?: string
}
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
export const authCommandBarMeta: CommandBarMeta = {
'Log in': {
hide: 'both',
},
}
export const authMachine = createMachine<UserContext, Events>(
{
id: 'Auth',
@ -50,7 +57,7 @@ export const authMachine = createMachine<UserContext, Events>(
loggedIn: {
entry: ['goToIndexPage'],
on: {
logout: {
'Log out': {
target: 'loggedOut',
},
},
@ -58,10 +65,10 @@ export const authMachine = createMachine<UserContext, Events>(
loggedOut: {
entry: ['goToSignInPage'],
on: {
tryLogin: {
'Log in': {
target: 'checkIfLoggedIn',
actions: assign({
token: (context, event) => {
token: (_, event) => {
const token = event.token || ''
localStorage.setItem(TOKEN_PERSIST_KEY, token)
return token
@ -71,7 +78,7 @@ export const authMachine = createMachine<UserContext, Events>(
},
},
},
schema: { events: {} as { type: 'logout' } | { type: 'tryLogin' } },
schema: { events: {} as { type: 'Log out' } | { type: 'Log in' } },
predictableActionArguments: true,
preserveActionOrder: true,
context: { token: persistedToken },

218
src/machines/homeMachine.ts Normal file
View File

@ -0,0 +1,218 @@
import { assign, createMachine } from 'xstate'
import { ProjectWithEntryPointMetadata } from '../Router'
import { CommandBarMeta } from '../lib/commands'
export const homeCommandMeta: CommandBarMeta = {
'Create project': {
displayValue: (args: string[]) => `Create project "${args[0]}"`,
args: [
{
name: 'name',
type: 'string',
description: '(default)',
options: 'defaultProjectName',
},
],
},
'Open project': {
displayValue: (args: string[]) => `Open project "${args[0]}"`,
args: [
{
name: 'name',
type: 'select',
options: 'projects',
},
],
},
'Delete project': {
displayValue: (args: string[]) => `Delete project "${args[0]}"`,
args: [
{
name: 'name',
type: 'select',
options: 'projects',
},
],
},
'Rename project': {
displayValue: (args: string[]) =>
`Rename project "${args[0]}" to "${args[1]}"`,
args: [
{
name: 'oldName',
type: 'select',
options: 'projects',
},
{
name: 'newName',
type: 'string',
description: '(default)',
options: 'defaultProjectName',
},
],
},
assign: {
hide: 'both',
},
}
export const homeMachine = createMachine(
{
/** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAHTK6xampYAOATqgFZj4AusAxAMLMwuLthbtOXANoAGALqJQjVLGJdiqUopAAPRAHYAbPooAWABwBGUwE5zAJgeGArM-MAaEAE9EN0wGYKGX97GX1nGVNDS0MbfwBfeM80TBwCEnIqGiZWDm4+ACUwUlxU8TzpeW1lVXVNbT0EcJNg02d-fzt7fU77Tx8EQ0iKCPtnfUsjGRtLGXtE5IxsPCIySmpacsk+QWFRHIluWQUkEBq1DS1TxqN7ChjzOxtXf0t7a37EcwsRibH-ZzRezA8wLEApZbpNZZTa5ba8AAiYAANmB9lsjlVTuc6ldQDdDOYKP5bm0os5TDJDJ8mlEzPpzIZHA4bO9umCIWlVpkNgcKnwAPKMYp8yTHaoqC71a6IEmBUz6BkWZzWDq2Uw0qzOIJAwz+PXWfSmeZJcFLLkZSi7ERkKCi7i8CCaShkABuqAA1pR8EIRGAALQYyonJSS3ENRDA2wUeyvd6dPVhGw0-RhGOp8IA8xGFkc80rS0Ua3qUh2oO8MDMVjMCiMZEiABmqGY6AoPr2AaD4uxYcuEYQoQpQWNNjsMnMgLGKbT3TC7TcOfsNjzqQL0KKJXQtvtXEdzoobs9lCEm87cMxIbOvel+MQqtMQRmS5ks31sZpAUsZkcIX+cQZJIrpC3KUBupTbuWlbVrW9ZcE2LYUCepRnocwYSrUfYyggbzvBQ+jMq49imLYwTUt4iCft+5i-u0-7UfoQEWtCSKoiWZbnruTqZIeXoUBAKJoihFTdqGGE3rod7UdqsQTI8hiGAqrIauRA7RvYeoqhO1jtAqjFrpkLFohBHEVlWzYwY2zatvxrFCWKWKiVKeISdh4yBJE-jGs4fhhA4zg0kRNgxhplhaW0nn4XpUKZEUuAQMZqF8FxLqkO6vG+hAgYcbAIlXmJzmNERdy0RYNiKgpthxDSEU6q8MSTJYjWGFFIEULF8WljuSX7jxx7CJlQY5ZYl44pht4IP61gyPc8njt0lIuH51UKrVVITEyMy2C1hbtQl-KmdBdaWQhGVZYluWjeJjSTf402shMEyuEyljPAFL0UNmMiuN86lWHMiSmvQ-HwKcnL6WA6FOf2k3mESMRDA4RpUm4U4qf6gSEt0QIvvqfjOCaiyrtF6zZPQXWQ+GWFlUEsbmNMf1TV9NLeXDcqRIySnNaaYPEzC5M9vl-b+IyFCjupryPF9jKWP5Kks-cbMWLERHRNt0LFntkgU2NLk4dqsz43YsTK++Kk2C+MbTOOcxzOMrhqzFxTgZ1Qba1dd6BUE1jGsLMxxK9KlDNqm3tMLUQvqYlgO5QhlsTubsFXesTTUuPTfHExshDS0RftRftGgEnTZtHbX9Zr+QJ-2S4Y3qnmTC+4tMyp1EfeOnmeQqdOhyXQrFOXXCV1hCkmLDOnBJYvRRDSsyRzGjiKj0lKdAkANAA */
id: 'Home machine',
initial: 'Reading projects',
context: {
projects: [] as ProjectWithEntryPointMetadata[],
defaultProjectName: '',
defaultDirectory: '',
},
on: {
assign: {
actions: assign((_, event) => ({
...event.data,
})),
target: '.Reading projects',
},
},
states: {
'Has no projects': {
on: {
'Create project': {
target: 'Creating project',
},
},
},
'Has projects': {
on: {
'Rename project': {
target: 'Renaming project',
},
'Create project': {
target: 'Creating project',
},
'Delete project': {
target: 'Deleting project',
},
'Open project': {
target: 'Opening project',
},
},
},
'Creating project': {
invoke: {
id: 'create-project',
src: 'createProject',
onDone: [
{
target: 'Reading projects',
actions: ['toastSuccess'],
},
],
onError: [
{
target: 'Reading projects',
actions: ['toastError'],
},
],
},
},
'Renaming project': {
invoke: {
id: 'rename-project',
src: 'renameProject',
onDone: [
{
target: '#Home machine.Reading projects',
actions: ['toastSuccess'],
},
],
onError: [
{
target: '#Home machine.Reading projects',
actions: ['toastError'],
},
],
},
},
'Deleting project': {
invoke: {
id: 'delete-project',
src: 'deleteProject',
onDone: [
{
actions: ['toastSuccess'],
target: '#Home machine.Reading projects',
},
],
onError: {
actions: ['toastError'],
target: '#Home machine.Has projects',
},
},
},
'Reading projects': {
invoke: {
id: 'read-projects',
src: 'readProjects',
onDone: [
{
cond: 'Has at least 1 project',
target: 'Has projects',
actions: ['setProjects'],
},
{
target: 'Has no projects',
actions: ['setProjects'],
},
],
onError: [
{
target: 'Has no projects',
actions: ['toastError'],
},
],
},
},
'Opening project': {
entry: ['navigateToProject'],
},
},
schema: {
events: {} as
| { type: 'Open project'; data: { name: string } }
| { type: 'Rename project'; data: { oldName: string; newName: string } }
| { type: 'Create project'; data: { name: string } }
| { type: 'Delete project'; data: { name: string } }
| { type: 'navigate'; data: { name: string } }
| {
type: 'done.invoke.read-projects'
data: ProjectWithEntryPointMetadata[]
}
| { type: 'assign'; data: { [key: string]: any } },
},
predictableActionArguments: true,
preserveActionOrder: true,
tsTypes: {} as import('./homeMachine.typegen').Typegen0,
},
{
actions: {
setProjects: assign((_, event) => {
return { projects: event.data as ProjectWithEntryPointMetadata[] }
}),
},
}
)

View File

@ -0,0 +1,99 @@
// This file was automatically generated. Edits will be overwritten
export interface Typegen0 {
'@@xstate/typegen': true
internalEvents: {
'done.invoke.create-project': {
type: 'done.invoke.create-project'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'done.invoke.delete-project': {
type: 'done.invoke.delete-project'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'done.invoke.read-projects': {
type: 'done.invoke.read-projects'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'done.invoke.rename-project': {
type: 'done.invoke.rename-project'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'error.platform.create-project': {
type: 'error.platform.create-project'
data: unknown
}
'error.platform.delete-project': {
type: 'error.platform.delete-project'
data: unknown
}
'error.platform.read-projects': {
type: 'error.platform.read-projects'
data: unknown
}
'error.platform.rename-project': {
type: 'error.platform.rename-project'
data: unknown
}
'xstate.init': { type: 'xstate.init' }
}
invokeSrcNameMap: {
createProject: 'done.invoke.create-project'
deleteProject: 'done.invoke.delete-project'
readProjects: 'done.invoke.read-projects'
renameProject: 'done.invoke.rename-project'
}
missingImplementations: {
actions: 'navigateToProject' | 'toastError' | 'toastSuccess'
delays: never
guards: 'Has at least 1 project'
services:
| 'createProject'
| 'deleteProject'
| 'readProjects'
| 'renameProject'
}
eventsCausingActions: {
navigateToProject: 'Open project'
setProjects: 'done.invoke.read-projects'
toastError:
| 'error.platform.create-project'
| 'error.platform.delete-project'
| 'error.platform.read-projects'
| 'error.platform.rename-project'
toastSuccess:
| 'done.invoke.create-project'
| 'done.invoke.delete-project'
| 'done.invoke.rename-project'
}
eventsCausingDelays: {}
eventsCausingGuards: {
'Has at least 1 project': 'done.invoke.read-projects'
}
eventsCausingServices: {
createProject: 'Create project'
deleteProject: 'Delete project'
readProjects:
| 'assign'
| 'done.invoke.create-project'
| 'done.invoke.delete-project'
| 'done.invoke.rename-project'
| 'error.platform.create-project'
| 'error.platform.rename-project'
| 'xstate.init'
renameProject: 'Rename project'
}
matchesStates:
| 'Creating project'
| 'Deleting project'
| 'Has no projects'
| 'Has projects'
| 'Opening project'
| 'Reading projects'
| 'Renaming project'
tags: never
}

View File

@ -0,0 +1,194 @@
import { assign, createMachine } from 'xstate'
import { BaseUnit, baseUnitsUnion } from '../useStore'
import { CommandBarMeta } from '../lib/commands'
import { Themes } from '../lib/theme'
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
export const settingsCommandBarMeta: CommandBarMeta = {
'Set Theme': {
displayValue: (args: string[]) => 'Change the app theme',
args: [
{
name: 'theme',
type: 'select',
defaultValue: 'theme',
options: Object.values(Themes).map((v) => ({ name: v })) as {
name: string
}[],
},
],
},
'Set Default Project Name': {
displayValue: (args: string[]) => 'Set a new default project name',
hide: 'web',
args: [
{
name: 'defaultProjectName',
type: 'string',
description: '(default)',
defaultValue: 'defaultProjectName',
options: 'defaultProjectName',
},
],
},
'Set Default Directory': {
hide: 'both',
},
'Set Unit System': {
displayValue: (args: string[]) => 'Set your default unit system',
args: [
{
name: 'unitSystem',
type: 'select',
defaultValue: 'unitSystem',
options: [{ name: 'imperial' }, { name: 'metric' }],
},
],
},
'Set Base Unit': {
displayValue: (args: string[]) => 'Set your default base unit',
args: [
{
name: 'baseUnit',
type: 'select',
defaultValue: 'baseUnit',
options: Object.values(baseUnitsUnion).map((v) => ({ name: v })),
},
],
},
'Set Onboarding Status': {
hide: 'both',
},
}
export const settingsMachine = createMachine(
{
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */
id: 'Settings',
predictableActionArguments: true,
context: {
theme: Themes.System,
defaultProjectName: '',
unitSystem: 'imperial' as 'imperial' | 'metric',
baseUnit: 'in' as BaseUnit,
defaultDirectory: '',
showDebugPanel: false,
onboardingStatus: '',
},
initial: 'idle',
states: {
idle: {
on: {
'Set Theme': {
actions: [
assign({
theme: (_, event) => event.data.theme,
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Default Project Name': {
actions: [
assign({
defaultProjectName: (_, event) => event.data.defaultProjectName,
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Default Directory': {
actions: [
assign({
defaultDirectory: (_, event) => event.data.defaultDirectory,
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Unit System': {
actions: [
assign({
unitSystem: (_, event) => event.data.unitSystem,
baseUnit: (_, event) =>
event.data.unitSystem === 'imperial' ? 'in' : 'mm',
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Base Unit': {
actions: [
assign({ baseUnit: (_, event) => event.data.baseUnit }),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Toggle Debug Panel': {
actions: [
assign({
showDebugPanel: (context) => {
return !context.showDebugPanel
},
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Onboarding Status': {
actions: [
assign({
onboardingStatus: (_, event) => event.data.onboardingStatus,
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
},
},
},
tsTypes: {} as import('./settingsMachine.typegen').Typegen0,
schema: {
events: {} as
| { type: 'Set Theme'; data: { theme: Themes } }
| {
type: 'Set Default Project Name'
data: { defaultProjectName: string }
}
| { type: 'Set Default Directory'; data: { defaultDirectory: string } }
| {
type: 'Set Unit System'
data: { unitSystem: 'imperial' | 'metric' }
}
| { type: 'Set Base Unit'; data: { baseUnit: BaseUnit } }
| { type: 'Set Onboarding Status'; data: { onboardingStatus: string } }
| { type: 'Toggle Debug Panel' },
},
},
{
actions: {
persistSettings: (context) => {
try {
localStorage.setItem(SETTINGS_PERSIST_KEY, JSON.stringify(context))
} catch (e) {
console.error(e)
}
},
},
}
)

View File

@ -0,0 +1,38 @@
// This file was automatically generated. Edits will be overwritten
export interface Typegen0 {
'@@xstate/typegen': true
internalEvents: {
'xstate.init': { type: 'xstate.init' }
}
invokeSrcNameMap: {}
missingImplementations: {
actions: 'toastSuccess'
delays: never
guards: never
services: never
}
eventsCausingActions: {
persistSettings:
| 'Set Base Unit'
| 'Set Default Directory'
| 'Set Default Project Name'
| 'Set Onboarding Status'
| 'Set Theme'
| 'Set Unit System'
| 'Toggle Debug Panel'
toastSuccess:
| 'Set Base Unit'
| 'Set Default Directory'
| 'Set Default Project Name'
| 'Set Onboarding Status'
| 'Set Theme'
| 'Set Unit System'
| 'Toggle Debug Panel'
}
eventsCausingDelays: {}
eventsCausingGuards: {}
eventsCausingServices: {}
matchesStates: 'idle'
tags: never
}

View File

@ -1,93 +1,135 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { readDir, removeDir, renameFile } from '@tauri-apps/api/fs'
import { FormEvent, useContext, useEffect } from 'react'
import { removeDir, renameFile } from '@tauri-apps/api/fs'
import {
createNewProject,
getNextProjectIndex,
interpolateProjectNameWithIndex,
doesProjectNameNeedInterpolated,
isProjectDirectory,
PROJECT_ENTRYPOINT,
getProjectsInDir,
} from '../lib/tauriFS'
import { ActionButton } from '../components/ActionButton'
import {
faArrowDown,
faArrowUp,
faCircleDot,
faPlus,
} from '@fortawesome/free-solid-svg-icons'
import { useStore } from '../useStore'
import { faArrowDown, faPlus } from '@fortawesome/free-solid-svg-icons'
import { toast } from 'react-hot-toast'
import { AppHeader } from '../components/AppHeader'
import ProjectCard from '../components/ProjectCard'
import { useLoaderData, useSearchParams } from 'react-router-dom'
import { useLoaderData, useNavigate, useSearchParams } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
import Loading from '../components/Loading'
import { metadata } from 'tauri-plugin-fs-extra-api'
const DESC = ':desc'
import { useMachine } from '@xstate/react'
import { homeCommandMeta, homeMachine } from '../machines/homeMachine'
import { ContextFrom, EventFrom } from 'xstate'
import { paths } from '../Router'
import {
getNextSearchParams,
getSortFunction,
getSortIcon,
} from '../lib/sorting'
import { CommandsContext } from '../components/CommandBar'
import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { SettingsContext } from '../components/SettingsCommandProvider'
// This route only opens in the Tauri desktop context for now,
// as defined in Router.tsx, so we can use the Tauri APIs and types.
const Home = () => {
const [searchParams, setSearchParams] = useSearchParams()
const sort = searchParams.get('sort_by') ?? 'modified:desc'
const { commands, setCommandBarOpen } = useContext(CommandsContext)
const navigate = useNavigate()
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
const [isLoading, setIsLoading] = useState(true)
const [projects, setProjects] = useState(loadedProjects || [])
const { defaultDir, defaultProjectName } = useStore((s) => ({
defaultDir: s.defaultDir,
defaultProjectName: s.defaultProjectName,
}))
const { defaultDirectory, defaultProjectName } = useContext(SettingsContext)
const modifiedSelected = sort?.includes('modified') || !sort || sort === null
const [state, send] = useMachine(homeMachine, {
context: {
projects: loadedProjects,
defaultProjectName,
defaultDirectory,
},
actions: {
navigateToProject: (
context: ContextFrom<typeof homeMachine>,
event: EventFrom<typeof homeMachine>
) => {
if (event.data && 'name' in event.data) {
setCommandBarOpen(false)
navigate(
`${paths.FILE}/${encodeURIComponent(
context.defaultDirectory + '/' + event.data.name
)}`
)
}
},
toastSuccess: (_, event) => toast.success((event.data || '') + ''),
toastError: (_, event) => toast.error((event.data || '') + ''),
},
services: {
readProjects: async (context: ContextFrom<typeof homeMachine>) =>
getProjectsInDir(context.defaultDirectory),
createProject: async (
context: ContextFrom<typeof homeMachine>,
event: EventFrom<typeof homeMachine, 'Create project'>
) => {
let name =
event.data && 'name' in event.data
? event.data.name
: defaultProjectName
if (doesProjectNameNeedInterpolated(name)) {
const nextIndex = await getNextProjectIndex(name, projects)
name = interpolateProjectNameWithIndex(name, nextIndex)
}
const refreshProjects = useCallback(
async (projectDir = defaultDir) => {
const readProjects = (
await readDir(projectDir.dir, {
await createNewProject(context.defaultDirectory + '/' + name)
return `Successfully created "${name}"`
},
renameProject: async (
context: ContextFrom<typeof homeMachine>,
event: EventFrom<typeof homeMachine, 'Rename project'>
) => {
const { oldName, newName } = event.data
let name = newName ? newName : context.defaultProjectName
if (doesProjectNameNeedInterpolated(name)) {
const nextIndex = await getNextProjectIndex(name, projects)
name = interpolateProjectNameWithIndex(name, nextIndex)
}
await renameFile(
context.defaultDirectory + '/' + oldName,
context.defaultDirectory + '/' + name
)
return `Successfully renamed "${oldName}" to "${name}"`
},
deleteProject: async (
context: ContextFrom<typeof homeMachine>,
event: EventFrom<typeof homeMachine, 'Delete project'>
) => {
await removeDir(context.defaultDirectory + '/' + event.data.name, {
recursive: true,
})
).filter(isProjectDirectory)
const projectsWithMetadata = await Promise.all(
readProjects.map(async (p) => ({
entrypoint_metadata: await metadata(
p.path + '/' + PROJECT_ENTRYPOINT
),
...p,
}))
)
setProjects(projectsWithMetadata)
return `Successfully deleted "${event.data.name}"`
},
},
[defaultDir, setProjects]
)
guards: {
'Has at least 1 project': (_, event: EventFrom<typeof homeMachine>) => {
if (event.type !== 'done.invoke.read-projects') return false
return event?.data?.length ? event.data?.length >= 1 : false
},
},
})
const { projects } = state.context
const [searchParams, setSearchParams] = useSearchParams()
const sort = searchParams.get('sort_by') ?? 'modified:desc'
const isSortByModified = sort?.includes('modified') || !sort || sort === null
useStateMachineCommands<typeof homeMachine>({
commands,
send,
state,
commandBarMeta: homeCommandMeta,
owner: 'home',
})
useEffect(() => {
refreshProjects(defaultDir).then(() => {
setIsLoading(false)
})
}, [setIsLoading, refreshProjects, defaultDir])
async function handleNewProject() {
let projectName = defaultProjectName
if (doesProjectNameNeedInterpolated(projectName)) {
const nextIndex = await getNextProjectIndex(defaultProjectName, projects)
projectName = interpolateProjectNameWithIndex(
defaultProjectName,
nextIndex
)
}
await createNewProject(defaultDir.dir + '/' + projectName).catch((err) => {
console.error('Error creating project:', err)
toast.error('Error creating project')
})
await refreshProjects()
toast.success('Project created')
}
send({ type: 'assign', data: { defaultProjectName, defaultDirectory } })
}, [defaultDirectory, defaultProjectName, send])
async function handleRenameProject(
e: FormEvent<HTMLFormElement>,
@ -96,85 +138,14 @@ const Home = () => {
const { newProjectName } = Object.fromEntries(
new FormData(e.target as HTMLFormElement)
)
if (newProjectName && project.name && newProjectName !== project.name) {
const dir = project.path?.slice(0, project.path?.lastIndexOf('/'))
await renameFile(project.path, dir + '/' + newProjectName).catch(
(err) => {
console.error('Error renaming project:', err)
toast.error('Error renaming project')
}
)
await refreshProjects()
toast.success('Project renamed')
}
send('Rename project', {
data: { oldName: project.name, newName: newProjectName },
})
}
async function handleDeleteProject(project: ProjectWithEntryPointMetadata) {
if (project.path) {
await removeDir(project.path, { recursive: true }).catch((err) => {
console.error('Error deleting project:', err)
toast.error('Error deleting project')
})
await refreshProjects()
toast.success('Project deleted')
}
}
function getSortIcon(sortBy: string) {
if (sort === sortBy) {
return faArrowUp
} else if (sort === sortBy + DESC) {
return faArrowDown
}
return faCircleDot
}
function getNextSearchParams(sortBy: string) {
if (sort === null || !sort)
return { sort_by: sortBy + (sortBy !== 'modified' ? DESC : '') }
if (sort.includes(sortBy) && !sort.includes(DESC)) return { sort_by: '' }
return {
sort_by: sortBy + (sort.includes(DESC) ? '' : DESC),
}
}
function getSortFunction(sortBy: string) {
const sortByName = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (a.name && b.name) {
return sortBy.includes('desc')
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
}
return 0
}
const sortByModified = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (
a.entrypoint_metadata?.modifiedAt &&
b.entrypoint_metadata?.modifiedAt
) {
return !sortBy || sortBy.includes('desc')
? b.entrypoint_metadata.modifiedAt.getTime() -
a.entrypoint_metadata.modifiedAt.getTime()
: a.entrypoint_metadata.modifiedAt.getTime() -
b.entrypoint_metadata.modifiedAt.getTime()
}
return 0
}
if (sortBy?.includes('name')) {
return sortByName
} else {
return sortByModified
}
send('Delete project', { data: { name: project.name || '' } })
}
return (
@ -191,9 +162,9 @@ const Home = () => {
? 'text-chalkboard-80 dark:text-chalkboard-40'
: ''
}
onClick={() => setSearchParams(getNextSearchParams('name'))}
onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))}
icon={{
icon: getSortIcon('name'),
icon: getSortIcon(sort, 'name'),
bgClassName: !sort?.includes('name')
? 'bg-liquid-50 dark:bg-liquid-70'
: '',
@ -207,17 +178,19 @@ const Home = () => {
<ActionButton
Element="button"
className={
!modifiedSelected
!isSortByModified
? 'text-chalkboard-80 dark:text-chalkboard-40'
: ''
}
onClick={() => setSearchParams(getNextSearchParams('modified'))}
onClick={() =>
setSearchParams(getNextSearchParams(sort, 'modified'))
}
icon={{
icon: sort ? getSortIcon('modified') : faArrowDown,
bgClassName: !modifiedSelected
icon: sort ? getSortIcon(sort, 'modified') : faArrowDown,
bgClassName: !isSortByModified
? 'bg-liquid-50 dark:bg-liquid-70'
: '',
iconClassName: !modifiedSelected
iconClassName: !isSortByModified
? 'text-liquid-80 dark:text-liquid-30'
: '',
}}
@ -230,11 +203,11 @@ const Home = () => {
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
Are being saved at{' '}
<code className="text-liquid-80 dark:text-liquid-30">
{defaultDir.dir}
{defaultDirectory}
</code>
, which you can change in your <Link to="settings">Settings</Link>.
</p>
{isLoading ? (
{state.matches('Reading projects') ? (
<Loading>Loading your Projects...</Loading>
) : (
<>
@ -256,7 +229,7 @@ const Home = () => {
)}
<ActionButton
Element="button"
onClick={handleNewProject}
onClick={() => send('Create project')}
icon={{ icon: faPlus }}
>
New file

View File

@ -1,32 +1,23 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
import { baseUnits, useStore } from '../../useStore'
import { BaseUnit, baseUnits } from '../../useStore'
import { ActionButton } from '../../components/ActionButton'
import { SettingsSection } from '../Settings'
import { Toggle } from '../../components/Toggle/Toggle'
import { useState } from 'react'
import { useContext, useState } from 'react'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { SettingsContext } from '../../components/SettingsCommandProvider'
export default function Units() {
const dismiss = useDismiss()
const next = useNextClick(onboardingPaths.CAMERA)
const {
defaultUnitSystem: ogDefaultUnitSystem,
setDefaultUnitSystem: saveDefaultUnitSystem,
defaultBaseUnit: ogDefaultBaseUnit,
setDefaultBaseUnit: saveDefaultBaseUnit,
} = useStore((s) => ({
defaultUnitSystem: s.defaultUnitSystem,
setDefaultUnitSystem: s.setDefaultUnitSystem,
defaultBaseUnit: s.defaultBaseUnit,
setDefaultBaseUnit: s.setDefaultBaseUnit,
}))
const [defaultUnitSystem, setDefaultUnitSystem] =
useState(ogDefaultUnitSystem)
const [defaultBaseUnit, setDefaultBaseUnit] = useState(ogDefaultBaseUnit)
const { send, unitSystem, baseUnit } = useContext(SettingsContext)
const [tempUnitSystem, setTempUnitSystem] = useState(unitSystem)
const [tempBaseUnit, setTempBaseUnit] = useState(baseUnit)
function handleNextClick() {
saveDefaultUnitSystem(defaultUnitSystem)
saveDefaultBaseUnit(defaultBaseUnit)
send({ type: 'Set Unit System', data: { unitSystem: tempUnitSystem } })
send({ type: 'Set Base Unit', data: { baseUnit: tempBaseUnit } })
next()
}
@ -42,9 +33,9 @@ export default function Units() {
offLabel="Imperial"
onLabel="Metric"
name="settings-units"
checked={defaultUnitSystem === 'metric'}
checked={tempUnitSystem === 'metric'}
onChange={(e) =>
setDefaultUnitSystem(e.target.checked ? 'metric' : 'imperial')
setTempUnitSystem(e.target.checked ? 'metric' : 'imperial')
}
/>
</SettingsSection>
@ -55,10 +46,10 @@ export default function Units() {
<select
id="base-unit"
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
value={defaultBaseUnit}
onChange={(e) => setDefaultBaseUnit(e.target.value)}
value={tempBaseUnit}
onChange={(e) => setTempBaseUnit(e.target.value as BaseUnit)}
>
{baseUnits[defaultUnitSystem].map((unit) => (
{baseUnits[unitSystem].map((unit) => (
<option key={unit} value={unit}>
{unit}
</option>

View File

@ -1,13 +1,12 @@
import { useHotkeys } from 'react-hotkeys-hook'
import { Outlet, useNavigate } from 'react-router-dom'
import { useStore } from '../../useStore'
import Introduction from './Introduction'
import Units from './Units'
import Camera from './Camera'
import Sketching from './Sketching'
import { useCallback } from 'react'
import { useCallback, useContext } from 'react'
import makeUrlPathRelative from '../../lib/makeUrlPathRelative'
import { SettingsContext } from '../../components/SettingsCommandProvider'
export const onboardingPaths = {
INDEX: '/',
@ -36,29 +35,31 @@ export const onboardingRoutes = [
]
export function useNextClick(newStatus: string) {
const { setOnboardingStatus } = useStore((s) => ({
setOnboardingStatus: s.setOnboardingStatus,
}))
const { send } = useContext(SettingsContext)
const navigate = useNavigate()
return useCallback(() => {
setOnboardingStatus(newStatus)
send({
type: 'Set Onboarding Status',
data: { onboardingStatus: newStatus },
})
navigate((newStatus !== onboardingPaths.UNITS ? '..' : '.') + newStatus)
}, [newStatus, setOnboardingStatus, navigate])
}, [newStatus, send, navigate])
}
export function useDismiss() {
const { setOnboardingStatus } = useStore((s) => ({
setOnboardingStatus: s.setOnboardingStatus,
}))
const { send } = useContext(SettingsContext)
const navigate = useNavigate()
return useCallback(
(path: string) => {
setOnboardingStatus('dismissed')
send({
type: 'Set Onboarding Status',
data: { onboardingStatus: 'dismissed' },
})
navigate(path)
},
[setOnboardingStatus, navigate]
[send, navigate]
)
}

View File

@ -6,59 +6,41 @@ import {
import { ActionButton } from '../components/ActionButton'
import { AppHeader } from '../components/AppHeader'
import { open } from '@tauri-apps/api/dialog'
import { Themes, baseUnits, useStore } from '../useStore'
import { useRef } from 'react'
import { toast } from 'react-hot-toast'
import { BaseUnit, baseUnits } from '../useStore'
import { useContext } from 'react'
import { Toggle } from '../components/Toggle/Toggle'
import { useNavigate, useRouteLoaderData } from 'react-router-dom'
import { useHotkeys } from 'react-hotkeys-hook'
import { IndexLoaderData, paths } from '../Router'
import { Themes } from '../lib/theme'
import { SettingsContext } from '../components/SettingsCommandProvider'
export const Settings = () => {
const loaderData = useRouteLoaderData(paths.FILE) as IndexLoaderData
const navigate = useNavigate()
useHotkeys('esc', () => navigate('../'))
const {
defaultDir,
setDefaultDir,
defaultProjectName,
setDefaultProjectName,
defaultUnitSystem,
setDefaultUnitSystem,
defaultBaseUnit,
setDefaultBaseUnit,
setDebugPanel,
debugPanel,
setOnboardingStatus,
showDebugPanel,
defaultDirectory,
unitSystem,
baseUnit,
theme,
setTheme,
} = useStore((s) => ({
defaultDir: s.defaultDir,
setDefaultDir: s.setDefaultDir,
defaultProjectName: s.defaultProjectName,
setDefaultProjectName: s.setDefaultProjectName,
defaultUnitSystem: s.defaultUnitSystem,
setDefaultUnitSystem: s.setDefaultUnitSystem,
defaultBaseUnit: s.defaultBaseUnit,
setDefaultBaseUnit: s.setDefaultBaseUnit,
setDebugPanel: s.setDebugPanel,
debugPanel: s.debugPanel,
setOnboardingStatus: s.setOnboardingStatus,
theme: s.theme,
setTheme: s.setTheme,
}))
const ogDefaultDir = useRef(defaultDir)
const ogDefaultProjectName = useRef(defaultProjectName)
send,
} = useContext(SettingsContext)
async function handleDirectorySelection() {
const newDirectory = await open({
directory: true,
defaultPath: (defaultDir.base || '') + (defaultDir.dir || paths.INDEX),
defaultPath: defaultDirectory || paths.INDEX,
title: 'Choose a new default directory',
})
if (newDirectory && newDirectory !== null && !Array.isArray(newDirectory)) {
setDefaultDir({ base: defaultDir.base, dir: newDirectory })
send({
type: 'Set Default Directory',
data: { defaultDirectory: newDirectory },
})
}
}
@ -102,18 +84,8 @@ export const Settings = () => {
<div className="w-full flex gap-4 p-1 rounded border border-chalkboard-30">
<input
className="flex-1 px-2 bg-transparent"
value={defaultDir.dir}
onChange={(e) => {
setDefaultDir({
base: defaultDir.base,
dir: e.target.value,
})
}}
onBlur={() => {
ogDefaultDir.current.dir !== defaultDir.dir &&
toast.success('Default directory updated')
ogDefaultDir.current.dir = defaultDir.dir
}}
value={defaultDirectory}
disabled
/>
<ActionButton
Element="button"
@ -137,15 +109,15 @@ export const Settings = () => {
>
<input
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
value={defaultProjectName}
onChange={(e) => {
setDefaultProjectName(e.target.value)
}}
onBlur={() => {
ogDefaultProjectName.current !== defaultProjectName &&
toast.success('Default project name updated')
ogDefaultProjectName.current = defaultProjectName
defaultValue={defaultProjectName}
onBlur={(e) => {
send({
type: 'Set Default Project Name',
data: { defaultProjectName: e.target.value },
})
}}
autoCapitalize="off"
autoComplete="off"
/>
</SettingsSection>
</>
@ -158,12 +130,13 @@ export const Settings = () => {
offLabel="Imperial"
onLabel="Metric"
name="settings-units"
checked={defaultUnitSystem === 'metric'}
checked={unitSystem === 'metric'}
onChange={(e) => {
const newUnitSystem = e.target.checked ? 'metric' : 'imperial'
setDefaultUnitSystem(newUnitSystem)
setDefaultBaseUnit(baseUnits[newUnitSystem][0])
toast.success('Unit system set to ' + newUnitSystem)
send({
type: 'Set Unit System',
data: { unitSystem: newUnitSystem },
})
}}
/>
</SettingsSection>
@ -174,13 +147,15 @@ export const Settings = () => {
<select
id="base-unit"
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
value={defaultBaseUnit}
value={baseUnit}
onChange={(e) => {
setDefaultBaseUnit(e.target.value)
toast.success('Base unit changed to ' + e.target.value)
send({
type: 'Set Base Unit',
data: { baseUnit: e.target.value as BaseUnit },
})
}}
>
{baseUnits[defaultUnitSystem].map((unit) => (
{baseUnits[unitSystem as keyof typeof baseUnits].map((unit) => (
<option key={unit} value={unit}>
{unit}
</option>
@ -193,12 +168,9 @@ export const Settings = () => {
>
<Toggle
name="settings-debug-panel"
checked={debugPanel}
checked={showDebugPanel}
onChange={(e) => {
setDebugPanel(e.target.checked)
toast.success(
'Debug panel toggled ' + (e.target.checked ? 'on' : 'off')
)
send('Toggle Debug Panel')
}}
/>
</SettingsSection>
@ -211,12 +183,10 @@ export const Settings = () => {
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
value={theme}
onChange={(e) => {
setTheme(e.target.value as Themes)
toast.success(
'Theme changed to ' +
e.target.value.slice(0, 1).toLocaleUpperCase() +
e.target.value.slice(1)
)
send({
type: 'Set Theme',
data: { theme: e.target.value as Themes },
})
}}
>
{Object.entries(Themes).map(([label, value]) => (
@ -233,7 +203,10 @@ export const Settings = () => {
<ActionButton
Element="button"
onClick={() => {
setOnboardingStatus('')
send({
type: 'Set Onboarding Status',
data: { onboardingStatus: '' },
})
navigate('..' + paths.ONBOARDING.INDEX)
}}
icon={{ icon: faArrowRotateBack }}

View File

@ -1,19 +1,16 @@
import { faSignInAlt } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../components/ActionButton'
import { isTauri } from '../lib/isTauri'
import { Themes, useStore } from '../useStore'
import { invoke } from '@tauri-apps/api/tauri'
import { useNavigate } from 'react-router-dom'
import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env'
import { getSystemTheme } from '../lib/getSystemTheme'
import { Themes, getSystemTheme } from '../lib/theme'
import { paths } from '../Router'
import { useAuthMachine } from '../hooks/useAuthMachine'
import { useContext } from 'react'
import { SettingsContext } from 'components/SettingsCommandProvider'
const SignIn = () => {
const navigate = useNavigate()
const { theme } = useStore((s) => ({
theme: s.theme,
}))
const { theme } = useContext(SettingsContext)
const [_, send] = useAuthMachine()
const appliedTheme = theme === Themes.System ? getSystemTheme() : theme
const signInTauri = async () => {
@ -22,7 +19,7 @@ const SignIn = () => {
const token: string = await invoke('login', {
host: VITE_KC_API_BASE_URL,
})
send({ type: 'tryLogin', token })
send({ type: 'Log in', token })
} catch (error) {
console.error('login button', error)
}

View File

@ -13,7 +13,6 @@ import {
} from './lang/executor'
import { recast } from './lang/recast'
import { EditorSelection } from '@codemirror/state'
import { BaseDirectory } from '@tauri-apps/api/fs'
import {
ArtifactMap,
SourceRangeMap,
@ -95,22 +94,14 @@ export type GuiModes =
position: Position
}
type UnitSystem = 'imperial' | 'metric'
export enum Themes {
Light = 'light',
Dark = 'dark',
System = 'system',
}
export const baseUnits: Record<UnitSystem, string[]> = {
export const baseUnits = {
imperial: ['in', 'ft'],
metric: ['mm', 'cm', 'm'],
}
} as const
interface DefaultDir {
base?: BaseDirectory
dir: string
}
export type BaseUnit = 'in' | 'ft' | 'mm' | 'cm' | 'm'
export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v)
export type PaneType = 'code' | 'variables' | 'debug' | 'kclErrors' | 'logs'
@ -181,21 +172,8 @@ export interface StoreState {
streamHeight: number
}) => void
// tauri specific app settings
defaultDir: DefaultDir
setDefaultDir: (dir: DefaultDir) => void
defaultProjectName: string
setDefaultProjectName: (defaultProjectName: string) => void
defaultUnitSystem: UnitSystem
setDefaultUnitSystem: (defaultUnitSystem: UnitSystem) => void
defaultBaseUnit: string
setDefaultBaseUnit: (defaultBaseUnit: string) => void
showHomeMenu: boolean
setHomeShowMenu: (showMenu: boolean) => void
onboardingStatus: string
setOnboardingStatus: (status: string) => void
theme: Themes
setTheme: (theme: Themes) => void
isBannerDismissed: boolean
setBannerDismissed: (isBannerDismissed: boolean) => void
openPanes: PaneType[]
@ -205,8 +183,6 @@ export interface StoreState {
path: string
}[]
setHomeMenuItems: (items: { name: string; path: string }[]) => void
debugPanel: boolean
setDebugPanel: (debugPanel: boolean) => void
}
let pendingAstUpdates: number[] = []
@ -385,18 +361,6 @@ export const useStore = create<StoreState>()(
defaultDir: {
dir: '',
},
setDefaultDir: (dir) => set({ defaultDir: dir }),
defaultProjectName: 'new-project-$nnn',
setDefaultProjectName: (defaultProjectName) =>
set({ defaultProjectName }),
defaultUnitSystem: 'imperial',
setDefaultUnitSystem: (defaultUnitSystem) => set({ defaultUnitSystem }),
defaultBaseUnit: 'in',
setDefaultBaseUnit: (defaultBaseUnit) => set({ defaultBaseUnit }),
onboardingStatus: '',
setOnboardingStatus: (onboardingStatus) => set({ onboardingStatus }),
theme: Themes.System,
setTheme: (theme) => set({ theme }),
isBannerDismissed: false,
setBannerDismissed: (isBannerDismissed) => set({ isBannerDismissed }),
openPanes: ['code'],
@ -405,25 +369,13 @@ export const useStore = create<StoreState>()(
setHomeShowMenu: (showHomeMenu) => set({ showHomeMenu }),
homeMenuItems: [],
setHomeMenuItems: (homeMenuItems) => set({ homeMenuItems }),
debugPanel: false,
setDebugPanel: (debugPanel) => set({ debugPanel }),
}),
{
name: 'store',
partialize: (state) =>
Object.fromEntries(
Object.entries(state).filter(([key]) =>
[
'code',
'defaultDir',
'defaultProjectName',
'defaultUnitSystem',
'defaultBaseUnit',
'debugPanel',
'onboardingStatus',
'theme',
'openPanes',
].includes(key)
['code', 'openPanes'].includes(key)
)
),
}

View File

@ -39,5 +39,7 @@ module.exports = {
},
},
darkMode: 'class',
plugins: [],
plugins: [
require('@headlessui/tailwindcss'),
],
}

View File

@ -1642,6 +1642,11 @@
dependencies:
client-only "^0.0.1"
"@headlessui/tailwindcss@^0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@headlessui/tailwindcss/-/tailwindcss-0.2.0.tgz#2c55c98fd8eee4b4f21ec6eb35a014b840059eec"
integrity sha512-fpL830Fln1SykOCboExsWr3JIVeQKieLJ3XytLe/tt1A0XzqUthOftDmjcCYLW62w7mQI7wXcoPXr3tZ9QfGxw==
"@humanwhocodes/config-array@^0.11.10":
version "0.11.10"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2"
@ -3827,6 +3832,11 @@ functions-have-names@^1.2.2, functions-have-names@^1.2.3:
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
fuse.js@^6.6.2:
version "6.6.2"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111"
integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==
gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"