Add tab to Settings dialog to view keyboard shortcuts (#2567)

* Add keyboard custom icon

* Refactor Settings to be more modular

* Add basic keybindings view to settings

* Add more shortcuts

* Add link to see keyboard shortcuts tab

* Little more bottom padding

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* Add keybindings to settings search

* Add a playwright test for opening the the keyboard shortcuts

* fmt

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2024-06-04 13:56:20 -04:00
committed by GitHub
parent 9564890b29
commit e46aca4992
12 changed files with 718 additions and 295 deletions

View File

@ -1042,6 +1042,29 @@ test('Project and user settings can be reset', async ({ page }) => {
await expect(page.locator('select[name="app-theme"]')).toHaveValue('system')
})
test('Keyboard shortcuts can be viewed through the help menu', async ({
page,
}) => {
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await page.waitForURL('**/file/**', { waitUntil: 'domcontentloaded' })
await page
.getByRole('button', { name: 'Start Sketch' })
.waitFor({ state: 'visible' })
// Open the help menu
await page.getByRole('button', { name: 'Help', exact: false }).click()
// Open the keyboard shortcuts
await page.getByRole('button', { name: 'Keyboard Shortcuts' }).click()
// Verify the URL and that you can see a list of shortcuts
await expect(page.url()).toContain('?tab=keybindings')
await expect(
page.getByRole('heading', { name: 'Enter Sketch Mode' })
).toBeAttached()
})
test('Click through each onboarding step', async ({ page }) => {
const u = await getUtils(page)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -257,6 +257,14 @@ const CustomIconMap = {
/>
</svg>
),
keyboard: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16 12V15H13.5M16 12V9M16 12H13.5M4 12V15H6.5M4 12V9M4 12H6.5M4 9V6H6.5M4 9H6.5M16 9V6H13.5M16 9H13.5M6.5 12V15M6.5 12H7.5M6.5 15H13.5M13.5 15V12M13.5 12H12.5M7.5 12V9M7.5 12H10M7.5 9H8.75M7.5 9H6.5M10 12V9M10 12H12.5M10 9H11.25M10 9H8.75M12.5 12V9M12.5 9H13.5M12.5 9H11.25M13.5 9V6M13.5 6H11.25M11.25 9V6M11.25 6H8.75M8.75 9V6M8.75 6H6.5M6.5 9V6"
stroke="currentColor"
/>
</svg>
),
line: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path

View File

@ -81,6 +81,12 @@ export function HelpMenu(props: React.PropsWithChildren) {
>
Release notes
</HelpMenuItem>
<HelpMenuItem
as="button"
onClick={() => navigate('settings?tab=keybindings')}
>
Keyboard shortcuts
</HelpMenuItem>
<HelpMenuItem
as="button"
onClick={() => {

View File

@ -0,0 +1,87 @@
import {
InteractionMapItem,
interactionMap,
sortInteractionMapByCategory,
} from 'lib/settings/initialKeybindings'
import { ForwardedRef, forwardRef } from 'react'
import { useLocation } from 'react-router-dom'
interface AllKeybindingsFieldsProps {}
export const AllKeybindingsFields = forwardRef(
(
props: AllKeybindingsFieldsProps,
scrollRef: ForwardedRef<HTMLDivElement>
) => {
// This is how we will get the interaction map from the context
// in the future whene franknoirot/editable-hotkeys is merged.
// const { state } = useInteractionMapContext()
return (
<div className="relative overflow-y-auto pb-16">
<div ref={scrollRef} className="flex flex-col gap-12">
{Object.entries(interactionMap)
.sort(sortInteractionMapByCategory)
.map(([category, categoryItems]) => (
<div className="flex flex-col gap-4 px-2 pr-4">
<h2
id={`category-${category}`}
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
>
{category}
</h2>
{categoryItems.map((item) => (
<KeybindingField
key={category + '-' + item.name}
category={category}
item={item}
/>
))}
</div>
))}
</div>
</div>
)
}
)
function KeybindingField({
item,
category,
}: {
item: InteractionMapItem
category: string
}) {
const location = useLocation()
return (
<div
className={
'flex gap-16 justify-between items-start py-1 px-2 -my-1 -mx-2 ' +
(location.hash === `#${item.name}`
? 'bg-primary/5 dark:bg-chalkboard-90'
: '')
}
id={item.name}
>
<div>
<h3 className="text-lg font-normal capitalize tracking-wide">
{item.title}
</h3>
<p className="text-xs text-chalkboard-60 dark:text-chalkboard-50">
{item.description}
</p>
</div>
<div className="flex-1 flex flex-wrap justify-end gap-3">
{item.sequence.split(' ').map((chord, i) => (
<kbd
key={`${category}-${item.name}-${chord}-${i}`}
className="py-0.5 px-1.5 rounded bg-primary/10 dark:bg-chalkboard-80"
>
{chord}
</kbd>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,238 @@
import decamelize from 'decamelize'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Setting } from 'lib/settings/initialSettings'
import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes'
import {
shouldHideSetting,
shouldShowSettingInput,
} from 'lib/settings/settingsUtils'
import { Fragment } from 'react/jsx-runtime'
import { SettingsSection } from './SettingsSection'
import { useLocation, useNavigate } from 'react-router-dom'
import { isTauri } from 'lib/isTauri'
import { ActionButton } from 'components/ActionButton'
import { SettingsFieldInput } from './SettingsFieldInput'
import { getInitialDefaultDir, showInFolder } from 'lib/tauri'
import toast from 'react-hot-toast'
import { APP_VERSION } from 'routes/Settings'
import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS'
import { paths } from 'lib/paths'
import { useDotDotSlash } from 'hooks/useDotDotSlash'
import { sep } from '@tauri-apps/api/path'
import { ForwardedRef, forwardRef } from 'react'
interface AllSettingsFieldsProps {
searchParamTab: SettingsLevel
isFileSettings: boolean
}
export const AllSettingsFields = forwardRef(
(
{ searchParamTab, isFileSettings }: AllSettingsFieldsProps,
scrollRef: ForwardedRef<HTMLDivElement>
) => {
const location = useLocation()
const navigate = useNavigate()
const dotDotSlash = useDotDotSlash()
const {
settings: { send, context },
} = useSettingsAuthContext()
const projectPath =
isFileSettings && isTauri()
? decodeURI(
location.pathname
.replace(paths.FILE + '/', '')
.replace(paths.SETTINGS, '')
.slice(0, decodeURI(location.pathname).lastIndexOf(sep()))
)
: undefined
function restartOnboarding() {
send({
type: `set.app.onboardingStatus`,
data: { level: 'user', value: '' },
})
if (isFileSettings) {
navigate(dotDotSlash(1) + paths.ONBOARDING.INDEX)
} else {
createAndOpenNewProject(navigate)
}
}
return (
<div className="relative overflow-y-auto">
<div ref={scrollRef} className="flex flex-col gap-4 px-2">
{Object.entries(context)
.filter(([_, categorySettings]) =>
// Filter out categories that don't have any non-hidden settings
Object.values(categorySettings).some(
(setting) => !shouldHideSetting(setting, searchParamTab)
)
)
.map(([category, categorySettings]) => (
<Fragment key={category}>
<h2
id={`category-${category}`}
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
>
{decamelize(category, { separator: ' ' })}
</h2>
{Object.entries(categorySettings)
.filter(
// Filter out settings that don't have a Component or inputType
// or are hidden on the current level or the current platform
(item: [string, Setting<unknown>]) =>
shouldShowSettingInput(item[1], searchParamTab)
)
.map(([settingName, s]) => {
const setting = s as Setting
const parentValue =
setting[setting.getParentLevel(searchParamTab)]
return (
<SettingsSection
title={decamelize(settingName, {
separator: ' ',
})}
id={settingName}
className={
location.hash === `#${settingName}`
? 'bg-primary/5 dark:bg-chalkboard-90'
: ''
}
key={`${category}-${settingName}-${searchParamTab}`}
description={setting.description}
settingHasChanged={
setting[searchParamTab] !== undefined &&
setting[searchParamTab] !==
setting.getFallback(searchParamTab)
}
parentLevel={setting.getParentLevel(searchParamTab)}
onFallback={() =>
send({
type: `set.${category}.${settingName}`,
data: {
level: searchParamTab,
value:
parentValue !== undefined
? parentValue
: setting.getFallback(searchParamTab),
},
} as SetEventTypes)
}
>
<SettingsFieldInput
category={category}
settingName={settingName}
settingsLevel={searchParamTab}
setting={setting}
/>
</SettingsSection>
)
})}
</Fragment>
))}
<h2 id="settings-resets" className="text-2xl mt-6 font-bold">
Resets
</h2>
<SettingsSection
title="Onboarding"
description="Replay the onboarding process"
>
<ActionButton
Element="button"
onClick={restartOnboarding}
iconStart={{
icon: 'refresh',
size: 'sm',
className: 'p-1',
}}
>
Replay Onboarding
</ActionButton>
</SettingsSection>
<SettingsSection
title="Reset settings"
description={`Restore settings to their default values. Your settings are saved in
${
isTauri()
? ' a file in the app data folder for your OS.'
: " your browser's local storage."
}
`}
>
<div className="flex flex-col items-start gap-4">
{isTauri() && (
<ActionButton
Element="button"
onClick={async () => {
const paths = await getSettingsFolderPaths(
projectPath ? decodeURIComponent(projectPath) : undefined
)
showInFolder(paths[searchParamTab])
}}
iconStart={{
icon: 'folder',
size: 'sm',
className: 'p-1',
}}
>
Show in folder
</ActionButton>
)}
<ActionButton
Element="button"
onClick={async () => {
const defaultDirectory = await getInitialDefaultDir()
send({
type: 'Reset settings',
defaultDirectory,
})
toast.success('Settings restored to default')
}}
iconStart={{
icon: 'refresh',
size: 'sm',
className: 'p-1 text-chalkboard-10',
bgClassName: 'bg-destroy-70',
}}
>
Restore default settings
</ActionButton>
</div>
</SettingsSection>
<h2 id="settings-about" className="text-2xl mt-6 font-bold">
About Modeling App
</h2>
<div className="text-sm mb-12">
<p>
{/* This uses a Vite plugin, set in vite.config.ts
to inject the version from package.json */}
App version {APP_VERSION}.{' '}
<a
href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}
target="_blank"
rel="noopener noreferrer"
>
View release on GitHub
</a>
</p>
<p className="max-w-2xl mt-6">
Don't see the feature you want? Check to see if it's on{' '}
<a
href="https://github.com/KittyCAD/modeling-app/discussions"
target="_blank"
rel="noopener noreferrer"
>
our roadmap
</a>
, and start a discussion if you don't see it! Your feedback will
help us prioritize what to build next.
</p>
</div>
</div>
</div>
)
}
)

View File

@ -0,0 +1,35 @@
import {
interactionMap,
sortInteractionMapByCategory,
} from 'lib/settings/initialKeybindings'
interface KeybindingSectionsListProps {
scrollRef: React.RefObject<HTMLDivElement>
}
export function KeybindingsSectionsList({
scrollRef,
}: KeybindingSectionsListProps) {
return (
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
{Object.entries(interactionMap)
.sort(sortInteractionMapByCategory)
.map(([category]) => (
<button
key={category}
onClick={() =>
scrollRef.current
?.querySelector(`#category-${category}`)
?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
{category}
</button>
))}
</div>
)
}

View File

@ -3,11 +3,23 @@ import { CustomIcon } from 'components/CustomIcon'
import decamelize from 'decamelize'
import Fuse from 'fuse.js'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { interactionMap } from 'lib/settings/initialKeybindings'
import { Setting } from 'lib/settings/initialSettings'
import { SettingsLevel } from 'lib/settings/settingsTypes'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useNavigate } from 'react-router-dom'
type ExtendedSettingsLevel = SettingsLevel | 'keybindings'
export type SettingsSearchItem = {
name: string
displayName: string
description: string
category: string
level: ExtendedSettingsLevel
}
export function SettingsSearchBar() {
const inputRef = useRef<HTMLInputElement>(null)
useHotkeys(
@ -21,29 +33,40 @@ export function SettingsSearchBar() {
const navigate = useNavigate()
const [query, setQuery] = useState('')
const { settings } = useSettingsAuthContext()
const settingsAsSearchable = useMemo(
() =>
Object.entries(settings.state.context).flatMap(
const settingsAsSearchable: SettingsSearchItem[] = useMemo(
() => [
...Object.entries(settings.state.context).flatMap(
([category, categorySettings]) =>
Object.entries(categorySettings).flatMap(([settingName, setting]) => {
const s = setting as Setting
return ['project', 'user']
return (['project', 'user'] satisfies SettingsLevel[])
.filter((l) => s.hideOnLevel !== l)
.map((l) => ({
category: decamelize(category, { separator: ' ' }),
settingName: settingName,
settingNameDisplay: decamelize(settingName, { separator: ' ' }),
setting: s,
level: l,
name: settingName,
description: s.description ?? '',
displayName: decamelize(settingName, { separator: ' ' }),
level: l as ExtendedSettingsLevel,
}))
})
),
...Object.entries(interactionMap).flatMap(
([category, categoryKeybindings]) =>
categoryKeybindings.map((keybinding) => ({
name: keybinding.name,
displayName: keybinding.title,
description: keybinding.description,
category: category,
level: 'keybindings' as ExtendedSettingsLevel,
}))
),
],
[settings.state.context]
)
const [searchResults, setSearchResults] = useState(settingsAsSearchable)
const fuse = new Fuse(settingsAsSearchable, {
keys: ['category', 'settingNameDisplay', 'setting.description'],
keys: ['category', 'displayName', 'description'],
includeScore: true,
})
@ -52,16 +75,8 @@ export function SettingsSearchBar() {
setSearchResults(query.length > 0 ? results : settingsAsSearchable)
}, [query])
function handleSelection({
level,
settingName,
}: {
category: string
settingName: string
setting: Setting<unknown>
level: string
}) {
navigate(`?tab=${level}#${settingName}`)
function handleSelection({ level, name }: SettingsSearchItem) {
navigate(`?tab=${level}#${name}`)
}
return (
@ -87,18 +102,18 @@ export function SettingsSearchBar() {
<Combobox.Options className="absolute top-full mt-2 right-0 w-80 overflow-y-auto z-50 max-h-96 cursor-pointer bg-chalkboard-10 dark:bg-chalkboard-100 border border-solid border-primary dark:border-chalkboard-30 rounded">
{searchResults?.map((option) => (
<Combobox.Option
key={`${option.category}-${option.settingName}-${option.level}`}
key={`${option.category}-${option.name}-${option.level}`}
value={option}
className="flex flex-col items-start gap-2 px-4 py-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90"
>
<p className="flex-grow text-base capitalize m-0 leading-none">
{option.level} ·{' '}
{decamelize(option.category, { separator: ' ' })} ·{' '}
{option.settingNameDisplay}
{option.displayName}
</p>
{option.setting.description && (
{option.description && (
<p className="text-xs leading-tight text-chalkboard-70 dark:text-chalkboard-50">
{option.setting.description}
{option.description}
</p>
)}
</Combobox.Option>

View File

@ -0,0 +1,68 @@
import decamelize from 'decamelize'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Setting } from 'lib/settings/initialSettings'
import { SettingsLevel } from 'lib/settings/settingsTypes'
import { shouldHideSetting } from 'lib/settings/settingsUtils'
interface SettingsSectionsListProps {
searchParamTab: SettingsLevel
scrollRef: React.RefObject<HTMLDivElement>
}
export function SettingsSectionsList({
searchParamTab,
scrollRef,
}: SettingsSectionsListProps) {
const {
settings: { context },
} = useSettingsAuthContext()
return (
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
{Object.entries(context)
.filter(([_, categorySettings]) =>
// Filter out categories that don't have any non-hidden settings
Object.values(categorySettings).some(
(setting: Setting) => !shouldHideSetting(setting, searchParamTab)
)
)
.map(([category]) => (
<button
key={category}
onClick={() =>
scrollRef.current
?.querySelector(`#category-${category}`)
?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
{decamelize(category, { separator: ' ' })}
</button>
))}
<button
onClick={() =>
scrollRef.current?.querySelector(`#settings-resets`)?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
Resets
</button>
<button
onClick={() =>
scrollRef.current?.querySelector(`#settings-about`)?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
About
</button>
</div>
)
}

View File

@ -34,6 +34,15 @@ export function SettingsTabs({
)}
</RadioGroup.Option>
)}
<RadioGroup.Option value="keybindings">
{({ checked }) => (
<SettingsTabButton
checked={checked}
icon="keyboard"
text="Keybindings"
/>
)}
</RadioGroup.Option>
</RadioGroup>
)
}

View File

@ -0,0 +1,182 @@
import { isTauri } from 'lib/isTauri'
export type InteractionMapItem = {
name: string
sequence: string
title: string
description: string
}
/**
* Controls both the available names for interaction map categories
* and the order in which they are displayed.
*/
export const interactionMapCategories = [
'Sketching',
'Modeling',
'Command Palette',
'Settings',
'Panes',
'Code Editor',
'File Tree',
'Miscellaneous',
]
type InteractionMapCategory = (typeof interactionMapCategories)[number]
/**
* A temporary implementation of the interaction map for
* display purposes only.
* @todo Implement a proper interaction map
* that can be edited, saved, and loaded. This is underway in the
* franknoirot/editable-hotkeys branch.
*/
export const interactionMap: Record<
InteractionMapCategory,
InteractionMapItem[]
> = {
Settings: [
{
name: 'toggle-settings',
sequence: isTauri() ? 'Meta+,' : 'Shift+Meta+,',
title: 'Toggle Settings',
description: 'Opens the settings dialog. Always available.',
},
{
name: 'settings-search',
sequence: 'Control+.',
title: 'Settings Search',
description:
'Focus the settings search input. Available when settings are open.',
},
],
'Command Palette': [
{
name: 'toggle-command-palette',
sequence: 'Meta+K',
title: 'Toggle Command Palette',
description: 'Always available. Use Ctrl+/ on Windows/Linux.',
},
],
Panes: [
{
name: 'toggle-code-pane',
sequence: 'Shift+C',
title: 'Toggle Code Pane',
description:
'Available while modeling when not typing in the code editor.',
},
{
name: 'toggle-variables-pane',
sequence: 'Shift+V',
title: 'Toggle Variables Pane',
description:
'Available while modeling when not typing in the code editor.',
},
{
name: 'toggle-logs-pane',
sequence: 'Shift+L',
title: 'Toggle Logs Pane',
description:
'Available while modeling when not typing in the code editor.',
},
{
name: 'toggle-errors-pane',
sequence: 'Shift+E',
title: 'Toggle Errors Pane',
description:
'Available while modeling when not typing in the code editor.',
},
],
Sketching: [
{
name: 'enter-sketch-mode',
sequence: 'S',
title: 'Enter Sketch Mode',
description:
'Available while modeling when not typing in the code editor.',
},
{
name: 'unequip-sketch-tool',
sequence: 'Escape',
title: 'Unequip Sketch Tool',
description:
'Unequips the current sketch tool. Available while sketching.',
},
{
name: 'exit-sketch-mode',
sequence: 'Escape',
title: 'Exit Sketch Mode',
description: 'Available while sketching, if no sketch tool is equipped.',
},
{
name: 'toggle-line-tool',
sequence: 'L',
title: 'Toggle Line Tool',
description:
'Available while sketching, when not typing in the code editor.',
},
{
name: 'toggle-rectangle-tool',
sequence: 'R',
title: 'Toggle Rectangle Tool',
description:
'Available while sketching, when not typing in the code editor.',
},
{
name: 'toggle-arc-tool',
sequence: 'A',
title: 'Toggle Arc Tool',
description:
'Available while sketching, when not typing in the code editor.',
},
],
Modeling: [
{
name: 'extrude',
sequence: 'E',
title: 'Extrude',
description:
'Available while modeling with either a face selected or an empty selection, when not typing in the code editor.',
},
],
'Code Editor': [
{
name: 'format-code',
sequence: 'Shift+Alt+F',
title: 'Format Code',
description:
'Nicely formats the KCL code in the editor, available when the editor is focused.',
},
],
'File Tree': [
{
name: 'rename-file',
sequence: 'Enter',
title: 'Rename File/Folder',
description:
'Available when a file or folder is selected in the file tree.',
},
{
name: 'delete-file',
sequence: 'Meta+Backspace',
title: 'Delete File/Folder',
description:
'Available when a file or folder is selected in the file tree.',
},
],
}
/**
* Sorts interaction map categories by their order in the
* `interactionMapCategories` array.
*/
export function sortInteractionMapByCategory(
[categoryA]: [InteractionMapCategory, InteractionMapItem[]],
[categoryB]: [InteractionMapCategory, InteractionMapItem[]]
) {
return (
interactionMapCategories.indexOf(categoryA) -
interactionMapCategories.indexOf(categoryB)
)
}

View File

@ -1,28 +1,17 @@
import { ActionButton } from '../components/ActionButton'
import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes'
import { SettingsLevel } from 'lib/settings/settingsTypes'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { useHotkeys } from 'react-hotkeys-hook'
import { paths } from 'lib/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useDotDotSlash } from 'hooks/useDotDotSlash'
import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS'
import { sep } from '@tauri-apps/api/path'
import { isTauri } from 'lib/isTauri'
import toast from 'react-hot-toast'
import { Fragment, useEffect, useRef } from 'react'
import { Setting } from 'lib/settings/initialSettings'
import decamelize from 'decamelize'
import { Dialog, Transition } from '@headlessui/react'
import { CustomIcon } from 'components/CustomIcon'
import {
shouldHideSetting,
shouldShowSettingInput,
} from 'lib/settings/settingsUtils'
import { getInitialDefaultDir, showInFolder } from 'lib/tauri'
import { SettingsSearchBar } from 'components/Settings/SettingsSearchBar'
import { SettingsTabs } from 'components/Settings/SettingsTabs'
import { SettingsSection } from 'components/Settings/SettingsSection'
import { SettingsFieldInput } from 'components/Settings/SettingsFieldInput'
import { SettingsSectionsList } from 'components/Settings/SettingsSectionsList'
import { AllSettingsFields } from 'components/Settings/AllSettingsFields'
import { AllKeybindingsFields } from 'components/Settings/AllKeybindingsFields'
import { KeybindingsSectionsList } from 'components/Settings/KeybindingsSectionsList'
export const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown'
@ -33,40 +22,12 @@ export const Settings = () => {
const location = useLocation()
const isFileSettings = location.pathname.includes(paths.FILE)
const searchParamTab =
(searchParams.get('tab') as SettingsLevel) ??
(searchParams.get('tab') as SettingsLevel | 'keybindings') ??
(isFileSettings ? 'project' : 'user')
const projectPath =
isFileSettings && isTauri()
? decodeURI(
location.pathname
.replace(paths.FILE + '/', '')
.replace(paths.SETTINGS, '')
.slice(0, decodeURI(location.pathname).lastIndexOf(sep()))
)
: undefined
const scrollRef = useRef<HTMLDivElement>(null)
const dotDotSlash = useDotDotSlash()
useHotkeys('esc', () => navigate(dotDotSlash()))
const {
settings: {
send,
state: { context },
},
} = useSettingsAuthContext()
function restartOnboarding() {
send({
type: `set.app.onboardingStatus`,
data: { level: 'user', value: '' },
})
if (isFileSettings) {
navigate(dotDotSlash(1) + paths.ONBOARDING.INDEX)
} else {
createAndOpenNewProject(navigate)
}
}
// Scroll to the hash on load if it exists
useEffect(() => {
@ -137,233 +98,24 @@ export const Settings = () => {
gridTemplateRows: '1fr',
}}
>
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
{Object.entries(context)
.filter(([_, categorySettings]) =>
// Filter out categories that don't have any non-hidden settings
Object.values(categorySettings).some(
(setting: Setting) =>
!shouldHideSetting(setting, searchParamTab)
)
)
.map(([category]) => (
<button
key={category}
onClick={() =>
scrollRef.current
?.querySelector(`#category-${category}`)
?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
{decamelize(category, { separator: ' ' })}
</button>
))}
<button
onClick={() =>
scrollRef.current
?.querySelector(`#settings-resets`)
?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
Resets
</button>
<button
onClick={() =>
scrollRef.current
?.querySelector(`#settings-about`)
?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
About
</button>
</div>
<div className="relative overflow-y-auto">
<div ref={scrollRef} className="flex flex-col gap-4 px-2">
{Object.entries(context)
.filter(([_, categorySettings]) =>
// Filter out categories that don't have any non-hidden settings
Object.values(categorySettings).some(
(setting) => !shouldHideSetting(setting, searchParamTab)
)
)
.map(([category, categorySettings]) => (
<Fragment key={category}>
<h2
id={`category-${category}`}
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
>
{decamelize(category, { separator: ' ' })}
</h2>
{Object.entries(categorySettings)
.filter(
// Filter out settings that don't have a Component or inputType
// or are hidden on the current level or the current platform
(item: [string, Setting<unknown>]) =>
shouldShowSettingInput(item[1], searchParamTab)
)
.map(([settingName, s]) => {
const setting = s as Setting
const parentValue =
setting[setting.getParentLevel(searchParamTab)]
return (
<SettingsSection
title={decamelize(settingName, {
separator: ' ',
})}
id={settingName}
className={
location.hash === `#${settingName}`
? 'bg-primary/10 dark:bg-chalkboard-90'
: ''
}
key={`${category}-${settingName}-${searchParamTab}`}
description={setting.description}
settingHasChanged={
setting[searchParamTab] !== undefined &&
setting[searchParamTab] !==
setting.getFallback(searchParamTab)
}
parentLevel={setting.getParentLevel(
searchParamTab
)}
onFallback={() =>
send({
type: `set.${category}.${settingName}`,
data: {
level: searchParamTab,
value:
parentValue !== undefined
? parentValue
: setting.getFallback(searchParamTab),
},
} as SetEventTypes)
}
>
<SettingsFieldInput
category={category}
settingName={settingName}
settingsLevel={searchParamTab}
setting={setting}
{searchParamTab !== 'keybindings' ? (
<>
<SettingsSectionsList
searchParamTab={searchParamTab}
scrollRef={scrollRef}
/>
</SettingsSection>
)
})}
</Fragment>
))}
<h2 id="settings-resets" className="text-2xl mt-6 font-bold">
Resets
</h2>
<SettingsSection
title="Onboarding"
description="Replay the onboarding process"
>
<ActionButton
Element="button"
onClick={restartOnboarding}
iconStart={{
icon: 'refresh',
size: 'sm',
className: 'p-1',
}}
>
Replay Onboarding
</ActionButton>
</SettingsSection>
<SettingsSection
title="Reset settings"
description={`Restore settings to their default values. Your settings are saved in
${
isTauri()
? ' a file in the app data folder for your OS.'
: " your browser's local storage."
}
`}
>
<div className="flex flex-col items-start gap-4">
{isTauri() && (
<ActionButton
Element="button"
onClick={async () => {
const paths = await getSettingsFolderPaths(
projectPath
? decodeURIComponent(projectPath)
: undefined
)
showInFolder(paths[searchParamTab])
}}
iconStart={{
icon: 'folder',
size: 'sm',
className: 'p-1',
}}
>
Show in folder
</ActionButton>
<AllSettingsFields
searchParamTab={searchParamTab}
isFileSettings={isFileSettings}
ref={scrollRef}
/>
</>
) : (
<>
<KeybindingsSectionsList scrollRef={scrollRef} />
<AllKeybindingsFields ref={scrollRef} />
</>
)}
<ActionButton
Element="button"
onClick={async () => {
const defaultDirectory = await getInitialDefaultDir()
send({
type: 'Reset settings',
defaultDirectory,
})
toast.success('Settings restored to default')
}}
iconStart={{
icon: 'refresh',
size: 'sm',
className: 'p-1 text-chalkboard-10',
bgClassName: 'bg-destroy-70',
}}
>
Restore default settings
</ActionButton>
</div>
</SettingsSection>
<h2 id="settings-about" className="text-2xl mt-6 font-bold">
About Modeling App
</h2>
<div className="text-sm mb-12">
<p>
{/* This uses a Vite plugin, set in vite.config.ts
to inject the version from package.json */}
App version {APP_VERSION}.{' '}
<a
href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}
target="_blank"
rel="noopener noreferrer"
>
View release on GitHub
</a>
</p>
<p className="max-w-2xl mt-6">
Don't see the feature you want? Check to see if it's on{' '}
<a
href="https://github.com/KittyCAD/modeling-app/discussions"
target="_blank"
rel="noopener noreferrer"
>
our roadmap
</a>
, and start a discussion if you don't see it! Your
feedback will help us prioritize what to build next.
</p>
</div>
</div>
</div>
</div>
</Dialog.Panel>
</Transition.Child>