diff --git a/package.json b/package.json index 7c806d062..93a57364f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b56402012..ac81a9e97 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 95b8b40f3..364786f91 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" } diff --git a/src/App.tsx b/src/App.tsx index 8b0244cf8..84d56e598 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { - {debugPanel && ( + {showDebugPanel && ( ) => (prepend: string) => { @@ -68,7 +77,15 @@ const addGlobalContextToElements = ( 'element' in route ? { ...route, - element: {route.element}, + element: ( + + + + {route.element} + + + + ), } : route ) @@ -95,26 +112,23 @@ const router = createBrowserRouter( request, params, }): Promise => { - 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 + > - 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 + > + 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( diff --git a/src/components/ActionIcon.tsx b/src/components/ActionIcon.tsx index 4078429f4..62d486ec5 100644 --- a/src/components/ActionIcon.tsx +++ b/src/components/ActionIcon.tsx @@ -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 ( )} diff --git a/src/components/CommandBar.tsx b/src/components/CommandBar.tsx new file mode 100644 index 000000000..c187b0f47 --- /dev/null +++ b/src/components/CommandBar.tsx @@ -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 & { name: string } +} + +export const CommandsContext = createContext( + {} as { + commands: Command[] + addCommands: (commands: Command[]) => void + removeCommands: (commands: Command[]) => void + commandBarOpen: boolean + setCommandBarOpen: Dispatch> + } +) + +const CommandBar = () => { + const { commands, commandBarOpen, setCommandBarOpen } = + useContext(CommandsContext) + useHotkeys('meta+k', () => { + if (commands.length === 0) return + setCommandBarOpen(!commandBarOpen) + }) + + const [selectedCommand, setSelectedCommand] = useState( + null + ) + // keep track of the current subcommand index + const [subCommandIndex, setSubCommandIndex] = useState() + 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 ( + 0 + } + as={Fragment} + afterLeave={() => clearState()} + > + { + setCommandBarOpen(false) + clearState() + }} + className="fixed inset-0 overflow-y-auto p-4 pt-[25vh]" + > + + + + + + + + + {inSubCommand && ( + + {selectedCommand.item && + getDisplayValue(selectedCommand.item as Command)} + + )} + 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" + /> + + + + {filteredCommands?.map((commandResult) => ( + + {commandResult.item.name} + {(commandResult.item as SubCommand).description && ( + + {(commandResult.item as SubCommand).description} + + )} + + ))} + + + + + + ) +} + +export default CommandBar diff --git a/src/components/Logs.tsx b/src/components/Logs.tsx index fa55e4db3..c91ca45a5 100644 --- a/src/components/Logs.tsx +++ b/src/components/Logs.tsx @@ -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 diff --git a/src/components/MemoryPanel.tsx b/src/components/MemoryPanel.tsx index b8545142d..f0deef3c9 100644 --- a/src/components/MemoryPanel.tsx +++ b/src/components/MemoryPanel.tsx @@ -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 diff --git a/src/components/SettingsCommandProvider.tsx b/src/components/SettingsCommandProvider.tsx new file mode 100644 index 000000000..96ef404c8 --- /dev/null +++ b/src/components/SettingsCommandProvider.tsx @@ -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, 'send'> + } & ContextFrom +) + +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 ( + + {children} + + ) +} diff --git a/src/components/UserSidebarMenu.tsx b/src/components/UserSidebarMenu.tsx index 9134c949c..b11c8fc5c 100644 --- a/src/components/UserSidebarMenu.tsx +++ b/src/components/UserSidebarMenu.tsx @@ -120,7 +120,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => { send('logout')} + onClick={() => send('Log out')} icon={{ icon: faSignOutAlt, bgClassName: 'bg-destroy-80', diff --git a/src/hooks/useAuthMachine.tsx b/src/hooks/useAuthMachine.tsx index 11c07c458..02d59b991 100644 --- a/src/hooks/useAuthMachine.tsx +++ b/src/hooks/useAuthMachine.tsx @@ -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 ( @@ -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} + + {children} + + ) } @@ -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}> +} diff --git a/src/hooks/useStateMachineCommands.ts b/src/hooks/useStateMachineCommands.ts new file mode 100644 index 000000000..12ae60ab6 --- /dev/null +++ b/src/hooks/useStateMachineCommands.ts @@ -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 { + state: StateFrom + send: Function + commandBarMeta?: CommandBarMeta + commands: Command[] + owner: string +} + +export default function useStateMachineCommands({ + state, + send, + commandBarMeta, + owner, +}: UseStateMachineCommandsArgs) { + const { addCommands, removeCommands } = useContext(CommandsContext) + + useEffect(() => { + const newCommands = state.nextEvents + .filter((e) => !['done.', 'error.'].some((n) => e.includes(n))) + .map((type) => + createMachineCommand({ + type, + state, + send, + commandBarMeta, + owner, + }) + ) + .filter((c) => c !== null) as Command[] + + addCommands(newCommands) + + return () => { + removeCommands(newCommands) + } + }, [state]) +} diff --git a/src/hooks/useTauriBoot.ts b/src/hooks/useTauriBoot.ts deleted file mode 100644 index 859c53545..000000000 --- a/src/hooks/useTauriBoot.ts +++ /dev/null @@ -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() - }, []) -} diff --git a/src/index.tsx b/src/index.tsx index b7b1d2347..4130f8191 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( diff --git a/src/lib/commands.ts b/src/lib/commands.ts new file mode 100644 index 000000000..a51315e78 --- /dev/null +++ b/src/lib/commands.ts @@ -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 { + type: EventFrom['type'] + state: StateFrom + commandBarMeta?: CommandBarMeta + send: Function + owner: string +} + +export function createMachineCommand({ + type, + state, + commandBarMeta, + send, + owner, +}: CommandBarArgs): 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) => { + if (data !== undefined && data !== null) { + send(type, { data }) + } else { + send(type) + } + }, + meta: meta as any, + } +} diff --git a/src/lib/getSystemTheme.ts b/src/lib/getSystemTheme.ts deleted file mode 100644 index 003ea31e9..000000000 --- a/src/lib/getSystemTheme.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Themes } from '../useStore' - -export function getSystemTheme(): Exclude { - return typeof window !== 'undefined' && - 'matchMedia' in window && - window.matchMedia('(prefers-color-scheme: dark)').matches - ? Themes.Dark - : Themes.Light -} diff --git a/src/lib/sorting.ts b/src/lib/sorting.ts new file mode 100644 index 000000000..48637a7cd --- /dev/null +++ b/src/lib/sorting.ts @@ -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 + } +} diff --git a/src/lib/tauriFS.ts b/src/lib/tauriFS.ts index 2f47d7fee..9c7782c6a 100644 --- a/src/lib/tauriFS.ts +++ b/src/lib/tauriFS.ts @@ -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) { ) } +// 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( diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 000000000..81d9df65c --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,22 @@ +export enum Themes { + Light = 'light', + Dark = 'dark', + System = 'system', +} + +export function getSystemTheme(): Exclude { + 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') + } +} diff --git a/src/lib/authMachine.ts b/src/machines/authMachine.ts similarity index 87% rename from src/lib/authMachine.ts rename to src/machines/authMachine.ts index fc37fa22b..2d691aebb 100644 --- a/src/lib/authMachine.ts +++ b/src/machines/authMachine.ts @@ -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( { id: 'Auth', @@ -50,7 +57,7 @@ export const authMachine = createMachine( loggedIn: { entry: ['goToIndexPage'], on: { - logout: { + 'Log out': { target: 'loggedOut', }, }, @@ -58,10 +65,10 @@ export const authMachine = createMachine( 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( }, }, }, - schema: { events: {} as { type: 'logout' } | { type: 'tryLogin' } }, + schema: { events: {} as { type: 'Log out' } | { type: 'Log in' } }, predictableActionArguments: true, preserveActionOrder: true, context: { token: persistedToken }, diff --git a/src/machines/homeMachine.ts b/src/machines/homeMachine.ts new file mode 100644 index 000000000..d822a439b --- /dev/null +++ b/src/machines/homeMachine.ts @@ -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[] } + }), + }, + } +) diff --git a/src/machines/homeMachine.typegen.ts b/src/machines/homeMachine.typegen.ts new file mode 100644 index 000000000..18f4794f4 --- /dev/null +++ b/src/machines/homeMachine.typegen.ts @@ -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 +} diff --git a/src/machines/settingsMachine.ts b/src/machines/settingsMachine.ts new file mode 100644 index 000000000..1284d21ae --- /dev/null +++ b/src/machines/settingsMachine.ts @@ -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) + } + }, + }, + } +) diff --git a/src/machines/settingsMachine.typegen.ts b/src/machines/settingsMachine.typegen.ts new file mode 100644 index 000000000..599603432 --- /dev/null +++ b/src/machines/settingsMachine.typegen.ts @@ -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 +} diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 0db1a582c..fabcaa7f3 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -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, + event: EventFrom + ) => { + 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) => + getProjectsInDir(context.defaultDirectory), + createProject: async ( + context: ContextFrom, + event: EventFrom + ) => { + 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, + event: EventFrom + ) => { + 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, + event: EventFrom + ) => { + 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) => { + 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({ + 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, @@ -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 = () => { 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 = () => { Are being saved at{' '} - {defaultDir.dir} + {defaultDirectory} , which you can change in your Settings. - {isLoading ? ( + {state.matches('Reading projects') ? ( Loading your Projects... ) : ( <> @@ -256,7 +229,7 @@ const Home = () => { )} send('Create project')} icon={{ icon: faPlus }} > New file diff --git a/src/routes/Onboarding/Units.tsx b/src/routes/Onboarding/Units.tsx index e9ac6e270..2ed758582 100644 --- a/src/routes/Onboarding/Units.tsx +++ b/src/routes/Onboarding/Units.tsx @@ -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') } /> @@ -55,10 +46,10 @@ export default function Units() { setDefaultBaseUnit(e.target.value)} + value={tempBaseUnit} + onChange={(e) => setTempBaseUnit(e.target.value as BaseUnit)} > - {baseUnits[defaultUnitSystem].map((unit) => ( + {baseUnits[unitSystem].map((unit) => ( {unit} diff --git a/src/routes/Onboarding/index.tsx b/src/routes/Onboarding/index.tsx index f89343ccd..efdd940cc 100644 --- a/src/routes/Onboarding/index.tsx +++ b/src/routes/Onboarding/index.tsx @@ -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] ) } diff --git a/src/routes/Settings.tsx b/src/routes/Settings.tsx index bf41e9988..b212580e9 100644 --- a/src/routes/Settings.tsx +++ b/src/routes/Settings.tsx @@ -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 = () => { { - 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 /> { > { - 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" /> > @@ -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 }, + }) }} /> @@ -174,13 +147,15 @@ export const Settings = () => { { - 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) => ( {unit} @@ -193,12 +168,9 @@ export const Settings = () => { > { - setDebugPanel(e.target.checked) - toast.success( - 'Debug panel toggled ' + (e.target.checked ? 'on' : 'off') - ) + send('Toggle Debug Panel') }} /> @@ -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 = () => { { - setOnboardingStatus('') + send({ + type: 'Set Onboarding Status', + data: { onboardingStatus: '' }, + }) navigate('..' + paths.ONBOARDING.INDEX) }} icon={{ icon: faArrowRotateBack }} diff --git a/src/routes/SignIn.tsx b/src/routes/SignIn.tsx index 843f247a5..708ad41d1 100644 --- a/src/routes/SignIn.tsx +++ b/src/routes/SignIn.tsx @@ -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) } diff --git a/src/useStore.ts b/src/useStore.ts index a8ecb1d52..82ada9dfa 100644 --- a/src/useStore.ts +++ b/src/useStore.ts @@ -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 = { +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()( 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()( 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) ) ), } diff --git a/tailwind.config.js b/tailwind.config.js index 70e04e8cd..0509de0ad 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -39,5 +39,7 @@ module.exports = { }, }, darkMode: 'class', - plugins: [], + plugins: [ + require('@headlessui/tailwindcss'), + ], } diff --git a/yarn.lock b/yarn.lock index 9e2f97ab7..b9f42c735 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"
+ {selectedCommand.item && + getDisplayValue(selectedCommand.item as Command)} +
{commandResult.item.name}
+ {(commandResult.item as SubCommand).description} +
Are being saved at{' '} - {defaultDir.dir} + {defaultDirectory} , which you can change in your Settings.
- {defaultDir.dir} + {defaultDirectory}