Add menu to code editor, put "Format code" and "Convert to variable" buttons in it (#426)

* Move format code button to menu item
by extending CollapsiblePanel to take an optional
menu React element.

Signed-off-by: Frank Noirot <frank@kittycad.io>

* Style tweaks

* Add shortcuts for format and cmd bar to codemirror

* Move convert to variable into code menu

Signed off by Frank Noirot <frank@kittycad.io>

* Add keyboard shortcut to convert to variable

* Remove convert to variable from toolbar

* Refactor: move TextEditor to its own component

* Set a better convertToVar shortcut

* Style and ergonomic polish for convertToVar modal

* Use named constants for shortcuts 😇

* Try yet another keyboard shortcut

* Fix formatting

* remove isShiftDown from app.tsx

---------

Signed-off-by: Frank Noirot <frank@kittycad.io>
Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>
This commit is contained in:
Frank Noirot
2023-09-09 01:38:36 -04:00
committed by GitHub
parent 7c9aaeafa2
commit 1be9b2612c
11 changed files with 500 additions and 350 deletions

View File

@ -2,7 +2,6 @@ import {
useRef, useRef,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useCallback, useCallback,
MouseEventHandler, MouseEventHandler,
} from 'react' } from 'react'
@ -10,30 +9,20 @@ import { DebugPanel } from './components/DebugPanel'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { asyncParser } from './lang/abstractSyntaxTree' import { asyncParser } from './lang/abstractSyntaxTree'
import { _executor } from './lang/executor' import { _executor } from './lang/executor'
import CodeMirror, { Extension } from '@uiw/react-codemirror' import { PaneType, useStore } from './useStore'
import { linter, lintGutter } from '@codemirror/lint'
import { ViewUpdate, EditorView } from '@codemirror/view'
import {
lineHighlightField,
addLineHighlight,
} from './editor/highlightextension'
import { PaneType, Selections, useStore } from './useStore'
import Server from './editor/lsp/server'
import Client from './editor/lsp/client'
import { Logs, KCLErrors } from './components/Logs' import { Logs, KCLErrors } from './components/Logs'
import { CollapsiblePanel } from './components/CollapsiblePanel' import { CollapsiblePanel } from './components/CollapsiblePanel'
import { MemoryPanel } from './components/MemoryPanel' import { MemoryPanel } from './components/MemoryPanel'
import { useHotKeyListener } from './hooks/useHotKeyListener' import { useHotKeyListener } from './hooks/useHotKeyListener'
import { Stream } from './components/Stream' import { Stream } from './components/Stream'
import ModalContainer from 'react-modal-promise' import ModalContainer from 'react-modal-promise'
import { FromServer, IntoServer } from './editor/lsp/codec'
import { import {
EngineCommand, EngineCommand,
EngineCommandManager, EngineCommandManager,
} from './lang/std/engineConnection' } from './lang/std/engineConnection'
import { isOverlap, throttle } from './lib/utils' import { throttle } from './lib/utils'
import { AppHeader } from './components/AppHeader' import { AppHeader } from './components/AppHeader'
import { KCLError, kclErrToDiagnostic } from './lang/errors' import { KCLError } from './lang/errors'
import { Resizable } from 're-resizable' import { Resizable } from 're-resizable'
import { import {
faCode, faCode,
@ -41,57 +30,42 @@ import {
faSquareRootVariable, faSquareRootVariable,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { TEST } from './env'
import { getNormalisedCoordinates } from './lib/utils' import { getNormalisedCoordinates } from './lib/utils'
import { Themes, getSystemTheme } from './lib/theme'
import { isTauri } from './lib/isTauri' import { isTauri } from './lib/isTauri'
import { useLoaderData, useParams } from 'react-router-dom' import { useLoaderData } from 'react-router-dom'
import { writeTextFile } from '@tauri-apps/api/fs'
import { PROJECT_ENTRYPOINT } from './lib/tauriFS'
import { IndexLoaderData } from './Router' import { IndexLoaderData } from './Router'
import { toast } from 'react-hot-toast'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext' import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { onboardingPaths } from 'routes/Onboarding' import { onboardingPaths } from 'routes/Onboarding'
import { LanguageServerClient } from 'editor/lsp'
import kclLanguage from 'editor/lsp/language'
import { CSSRuleObject } from 'tailwindcss/types/config'
import { cameraMouseDragGuards } from 'lib/cameraControls' import { cameraMouseDragGuards } from 'lib/cameraControls'
import { CameraDragInteractionType_type } from '@kittycad/lib/dist/types/src/models' import { CameraDragInteractionType_type } from '@kittycad/lib/dist/types/src/models'
import { CodeMenu } from 'components/CodeMenu'
import { TextEditor } from 'components/TextEditor'
import { Themes, getSystemTheme } from 'lib/theme'
export function App() { export function App() {
const { code: loadedCode, project } = useLoaderData() as IndexLoaderData const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
const pathParams = useParams()
const streamRef = useRef<HTMLDivElement>(null) const streamRef = useRef<HTMLDivElement>(null)
useHotKeyListener() useHotKeyListener()
const { const {
editorView,
setEditorView,
setSelectionRanges,
selectionRanges,
addLog, addLog,
addKCLError, addKCLError,
code,
setCode, setCode,
setAst, setAst,
setError, setError,
setProgramMemory, setProgramMemory,
resetLogs, resetLogs,
resetKCLErrors, resetKCLErrors,
selectionRangeTypeMap,
setArtifactMap, setArtifactMap,
engineCommandManager, engineCommandManager,
setEngineCommandManager, setEngineCommandManager,
highlightRange, highlightRange,
setHighlightRange, setHighlightRange,
setCursor2, setCursor2,
sourceRangeMap,
setMediaStream, setMediaStream,
setIsStreamReady, setIsStreamReady,
isStreamReady, isStreamReady,
isLSPServerReady,
setIsLSPServerReady,
buttonDownInStream, buttonDownInStream,
formatCode,
openPanes, openPanes,
setOpenPanes, setOpenPanes,
didDragInStream, didDragInStream,
@ -99,40 +73,25 @@ export function App() {
streamDimensions, streamDimensions,
setIsExecuting, setIsExecuting,
defferedCode, defferedCode,
defferedSetCode,
} = useStore((s) => ({ } = useStore((s) => ({
editorView: s.editorView,
setEditorView: s.setEditorView,
setSelectionRanges: s.setSelectionRanges,
selectionRanges: s.selectionRanges,
setGuiMode: s.setGuiMode,
addLog: s.addLog, addLog: s.addLog,
code: s.code,
defferedCode: s.defferedCode, defferedCode: s.defferedCode,
setCode: s.setCode, setCode: s.setCode,
defferedSetCode: s.defferedSetCode,
setAst: s.setAst, setAst: s.setAst,
setError: s.setError, setError: s.setError,
setProgramMemory: s.setProgramMemory, setProgramMemory: s.setProgramMemory,
resetLogs: s.resetLogs, resetLogs: s.resetLogs,
resetKCLErrors: s.resetKCLErrors, resetKCLErrors: s.resetKCLErrors,
selectionRangeTypeMap: s.selectionRangeTypeMap,
setArtifactMap: s.setArtifactNSourceRangeMaps, setArtifactMap: s.setArtifactNSourceRangeMaps,
engineCommandManager: s.engineCommandManager, engineCommandManager: s.engineCommandManager,
setEngineCommandManager: s.setEngineCommandManager, setEngineCommandManager: s.setEngineCommandManager,
highlightRange: s.highlightRange, highlightRange: s.highlightRange,
setHighlightRange: s.setHighlightRange, setHighlightRange: s.setHighlightRange,
isShiftDown: s.isShiftDown,
setCursor: s.setCursor,
setCursor2: s.setCursor2, setCursor2: s.setCursor2,
sourceRangeMap: s.sourceRangeMap,
setMediaStream: s.setMediaStream, setMediaStream: s.setMediaStream,
isStreamReady: s.isStreamReady, isStreamReady: s.isStreamReady,
setIsStreamReady: s.setIsStreamReady, setIsStreamReady: s.setIsStreamReady,
isLSPServerReady: s.isLSPServerReady,
setIsLSPServerReady: s.setIsLSPServerReady,
buttonDownInStream: s.buttonDownInStream, buttonDownInStream: s.buttonDownInStream,
formatCode: s.formatCode,
addKCLError: s.addKCLError, addKCLError: s.addKCLError,
openPanes: s.openPanes, openPanes: s.openPanes,
setOpenPanes: s.setOpenPanes, setOpenPanes: s.setOpenPanes,
@ -147,13 +106,7 @@ export function App() {
context: { token }, context: { token },
}, },
settings: { settings: {
context: { context: { showDebugPanel, onboardingStatus, cameraControls, theme },
showDebugPanel,
theme,
onboardingStatus,
textWrapping,
cameraControls,
},
}, },
} = useGlobalStateContext() } = useGlobalStateContext()
@ -194,80 +147,6 @@ export function App() {
} }
}, [loadedCode, setCode]) }, [loadedCode, setCode])
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
const onChange = (value: string, viewUpdate: ViewUpdate) => {
defferedSetCode(value)
if (isTauri() && pathParams.id) {
// Save the file to disk
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
writeTextFile(pathParams.id + '/' + PROJECT_ENTRYPOINT, value).catch(
(err) => {
// TODO: add Sentry per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254)
console.error('error saving file', err)
toast.error('Error saving file, please check file permissions')
}
)
}
if (editorView) {
editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
}
} //, []);
const onUpdate = (viewUpdate: ViewUpdate) => {
if (!editorView) {
setEditorView(viewUpdate.view)
}
const ranges = viewUpdate.state.selection.ranges
const isChange =
ranges.length !== selectionRanges.codeBasedSelections.length ||
ranges.some(({ from, to }, i) => {
return (
from !== selectionRanges.codeBasedSelections[i].range[0] ||
to !== selectionRanges.codeBasedSelections[i].range[1]
)
})
if (!isChange) return
const codeBasedSelections: Selections['codeBasedSelections'] = ranges.map(
({ from, to }) => {
if (selectionRangeTypeMap[to]) {
return {
type: selectionRangeTypeMap[to],
range: [from, to],
}
}
return {
type: 'default',
range: [from, to],
}
}
)
const idBasedSelections = codeBasedSelections
.map(({ type, range }) => {
const hasOverlap = Object.entries(sourceRangeMap).filter(
([_, sourceRange]) => {
return isOverlap(sourceRange, range)
}
)
if (hasOverlap.length) {
return {
type,
id: hasOverlap[0][0],
}
}
})
.filter(Boolean) as any
engineCommandManager?.cusorsSelected({
otherSelections: [],
idBasedSelections,
})
setSelectionRanges({
otherSelections: [],
codeBasedSelections,
})
}
const streamWidth = streamRef?.current?.offsetWidth const streamWidth = streamRef?.current?.offsetWidth
const streamHeight = streamRef?.current?.offsetHeight const streamHeight = streamRef?.current?.offsetHeight
@ -445,64 +324,6 @@ export function App() {
} }
} }
// So this is a bit weird, we need to initialize the lsp server and client.
// But the server happens async so we break this into two parts.
// Below is the client and server promise.
const { lspClient } = useMemo(() => {
const intoServer: IntoServer = new IntoServer()
const fromServer: FromServer = FromServer.create()
const client = new Client(fromServer, intoServer)
if (!TEST) {
Server.initialize(intoServer, fromServer).then((lspServer) => {
lspServer.start()
setIsLSPServerReady(true)
})
}
const lspClient = new LanguageServerClient({ client })
return { lspClient }
}, [setIsLSPServerReady])
// Here we initialize the plugin which will start the client.
// When we have multi-file support the name of the file will be a dep of
// this use memo, as well as the directory structure, which I think is
// a good setup becuase it will restart the client but not the server :)
// We do not want to restart the server, its just wasteful.
const kclLSP = useMemo(() => {
let plugin = null
if (isLSPServerReady && !TEST) {
// Set up the lsp plugin.
const lsp = kclLanguage({
// When we have more than one file, we'll need to change this.
documentUri: `file:///we-just-have-one-file-for-now.kcl`,
workspaceFolders: null,
client: lspClient,
})
plugin = lsp
}
return plugin
}, [lspClient, isLSPServerReady])
const editorExtensions = useMemo(() => {
const extensions = [lineHighlightField] as Extension[]
if (kclLSP) extensions.push(kclLSP)
// These extensions have proven to mess with vitest
if (!TEST) {
extensions.push(
lintGutter(),
linter((_view) => {
return kclErrToDiagnostic(useStore.getState().kclErrors)
})
)
if (textWrapping === 'On') extensions.push(EditorView.lineWrapping)
}
return extensions
}, [kclLSP, textWrapping])
return ( return (
<div <div
className="h-screen overflow-hidden relative flex flex-col cursor-pointer select-none" className="h-screen overflow-hidden relative flex flex-col cursor-pointer select-none"
@ -546,31 +367,9 @@ export function App() {
icon={faCode} icon={faCode}
className="open:!mb-2" className="open:!mb-2"
open={openPanes.includes('code')} open={openPanes.includes('code')}
menu={<CodeMenu />}
> >
<div className="px-2 py-1"> <TextEditor theme={editorTheme} />
<button
// disabled={!shouldFormat}
onClick={formatCode}
// className={`${!shouldFormat && 'text-gray-300'}`}
>
format
</button>
</div>
<div
id="code-mirror-override"
className="full-height-subtract"
style={{ '--height-subtract': '4.25rem' } as CSSRuleObject}
>
<CodeMirror
className="h-full"
value={code}
extensions={editorExtensions}
onChange={onChange}
onUpdate={onUpdate}
theme={editorTheme}
onCreateEditor={(_editorView) => setEditorView(_editorView)}
/>
</div>
</CollapsiblePanel> </CollapsiblePanel>
<section className="flex flex-col"> <section className="flex flex-col">
<MemoryPanel <MemoryPanel

View File

@ -8,7 +8,6 @@ import { EqualAngle } from './components/Toolbar/EqualAngle'
import { Intersect } from './components/Toolbar/Intersect' import { Intersect } from './components/Toolbar/Intersect'
import { SetHorzVertDistance } from './components/Toolbar/SetHorzVertDistance' import { SetHorzVertDistance } from './components/Toolbar/SetHorzVertDistance'
import { SetAngleLength } from './components/Toolbar/setAngleLength' import { SetAngleLength } from './components/Toolbar/setAngleLength'
import { ConvertToVariable } from './components/Toolbar/ConvertVariable'
import { SetAbsDistance } from './components/Toolbar/SetAbsDistance' import { SetAbsDistance } from './components/Toolbar/SetAbsDistance'
import { SetAngleBetween } from './components/Toolbar/SetAngleBetween' import { SetAngleBetween } from './components/Toolbar/SetAngleBetween'
import { Fragment, useEffect } from 'react' import { Fragment, useEffect } from 'react'
@ -164,7 +163,6 @@ export const Toolbar = () => {
</button> </button>
) )
})} })}
<ConvertToVariable />
<HorzVert horOrVert="horizontal" /> <HorzVert horOrVert="horizontal" />
<HorzVert horOrVert="vertical" /> <HorzVert horOrVert="vertical" />
<EqualLength /> <EqualLength />

View File

@ -198,29 +198,25 @@ export const CreateNewVariable = ({
isNewVariableNameUnique, isNewVariableNameUnique,
setNewVariableName, setNewVariableName,
shouldCreateVariable, shouldCreateVariable,
setShouldCreateVariable, setShouldCreateVariable = () => {},
showCheckbox = true, showCheckbox = true,
}: { }: {
isNewVariableNameUnique: boolean isNewVariableNameUnique: boolean
newVariableName: string newVariableName: string
setNewVariableName: (a: string) => void setNewVariableName: (a: string) => void
shouldCreateVariable: boolean shouldCreateVariable?: boolean
setShouldCreateVariable: (a: boolean) => void setShouldCreateVariable?: (a: boolean) => void
showCheckbox?: boolean showCheckbox?: boolean
}) => { }) => {
return ( return (
<> <>
<label <label htmlFor="create-new-variable" className="block mt-3 font-mono">
htmlFor="create-new-variable"
className="block text-sm font-medium text-gray-700 mt-3 font-mono"
>
Create new variable Create new variable
</label> </label>
<div className="mt-1 flex flex-1"> <div className="mt-1 flex gap-2 items-center">
{showCheckbox && ( {showCheckbox && (
<input <input
type="checkbox" type="checkbox"
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md font-mono pl-1 flex-shrink"
checked={shouldCreateVariable} checked={shouldCreateVariable}
onChange={(e) => { onChange={(e) => {
setShouldCreateVariable(e.target.checked) setShouldCreateVariable(e.target.checked)
@ -232,7 +228,10 @@ export const CreateNewVariable = ({
disabled={!shouldCreateVariable} disabled={!shouldCreateVariable}
name="create-new-variable" name="create-new-variable"
id="create-new-variable" id="create-new-variable"
className={`shadow-sm font-[monospace] focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md font-mono pl-1 flex-shrink-0 ${ autoFocus={true}
autoCapitalize="off"
autoCorrect="off"
className={`font-mono flex-1 sm:text-sm px-2 py-1 rounded-sm bg-chalkboard-10 dark:bg-chalkboard-90 text-chalkboard-90 dark:text-chalkboard-10 ${
!shouldCreateVariable ? 'opacity-50' : '' !shouldCreateVariable ? 'opacity-50' : ''
}`} }`}
value={newVariableName} value={newVariableName}

View File

@ -0,0 +1,19 @@
.button {
@apply flex justify-between items-center gap-2 px-2 py-1 text-left border-none rounded-sm;
@apply font-mono text-xs font-bold select-none text-chalkboard-90;
@apply ui-active:bg-liquid-10/50 ui-active:text-liquid-90;
@apply transition-colors ease-out;
}
:global(.dark) .button {
@apply text-chalkboard-30;
@apply ui-active:bg-chalkboard-80 ui-active:text-liquid-10;
}
.button small {
@apply text-chalkboard-60;
}
:global(.dark) .button small {
@apply text-chalkboard-40;
}

View File

@ -0,0 +1,59 @@
import { Menu } from '@headlessui/react'
import { PropsWithChildren } from 'react'
import { faEllipsis } from '@fortawesome/free-solid-svg-icons'
import { ActionIcon } from './ActionIcon'
import { useStore } from 'useStore'
import styles from './CodeMenu.module.css'
import { useConvertToVariable } from 'hooks/useToolbarGuards'
import { editorShortcutMeta } from './TextEditor'
export const CodeMenu = ({ children }: PropsWithChildren) => {
const { formatCode } = useStore((s) => ({
formatCode: s.formatCode,
}))
const { enable: convertToVarEnabled, handleClick: handleConvertToVarClick } =
useConvertToVariable()
return (
<Menu>
<div
className="relative"
onClick={(e) => {
if (e.eventPhase === 3) {
e.stopPropagation()
e.preventDefault()
}
}}
>
<Menu.Button className="p-0 border-none relative">
<ActionIcon
icon={faEllipsis}
bgClassName={
'bg-chalkboard-20 dark:bg-chalkboard-110 hover:bg-liquid-10/50 hover:dark:bg-chalkboard-90 ui-active:bg-chalkboard-80 ui-active:dark:bg-chalkboard-90 rounded'
}
iconClassName={'text-chalkboard-90 dark:text-chalkboard-40'}
/>
</Menu.Button>
<Menu.Items className="absolute right-0 left-auto w-72 flex flex-col gap-1 divide-y divide-chalkboard-20 dark:divide-chalkboard-70 align-stretch px-0 py-1 bg-chalkboard-10 dark:bg-chalkboard-90 rounded-sm shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50">
<Menu.Item>
<button onClick={() => formatCode()} className={styles.button}>
<span>Format code</span>
<small>{editorShortcutMeta.formatCode.display}</small>
</button>
</Menu.Item>
{convertToVarEnabled && (
<Menu.Item>
<button
onClick={handleConvertToVarClick}
className={styles.button}
>
<span>Convert to Variable</span>
<small>{editorShortcutMeta.convertToVariable.display}</small>
</button>
</Menu.Item>
)}
</Menu.Items>
</div>
</Menu>
)
}

View File

@ -9,7 +9,7 @@
.header { .header {
@apply sticky top-0 z-10 cursor-pointer; @apply sticky top-0 z-10 cursor-pointer;
@apply flex items-center gap-2 w-full p-2; @apply flex items-center justify-between gap-2 w-full p-2;
@apply font-mono text-xs font-bold select-none text-chalkboard-90; @apply font-mono text-xs font-bold select-none text-chalkboard-90;
@apply bg-chalkboard-20; @apply bg-chalkboard-20;
} }

View File

@ -8,6 +8,7 @@ export interface CollapsiblePanelProps
title: string title: string
icon?: IconDefinition icon?: IconDefinition
open?: boolean open?: boolean
menu?: React.ReactNode
iconClassNames?: { iconClassNames?: {
bg?: string bg?: string
icon?: string icon?: string
@ -18,9 +19,11 @@ export const PanelHeader = ({
title, title,
icon, icon,
iconClassNames, iconClassNames,
menu,
}: CollapsiblePanelProps) => { }: CollapsiblePanelProps) => {
return ( return (
<summary className={styles.header}> <summary className={styles.header}>
<div className="flex gap-2 align-center flex-1">
<ActionIcon <ActionIcon
icon={icon} icon={icon}
bgClassName={ bgClassName={
@ -33,6 +36,10 @@ export const PanelHeader = ({
} }
/> />
{title} {title}
</div>
<div className="group-open:opacity-100 opacity-0 group-open:pointer-events-auto pointer-events-none">
{menu}
</div>
</summary> </summary>
) )
} }
@ -43,6 +50,7 @@ export const CollapsiblePanel = ({
children, children,
className, className,
iconClassNames, iconClassNames,
menu,
...props ...props
}: CollapsiblePanelProps) => { }: CollapsiblePanelProps) => {
return ( return (
@ -50,7 +58,12 @@ export const CollapsiblePanel = ({
{...props} {...props}
className={styles.panel + ' group ' + (className || '')} className={styles.panel + ' group ' + (className || '')}
> >
<PanelHeader title={title} icon={icon} iconClassNames={iconClassNames} /> <PanelHeader
title={title}
icon={icon}
iconClassNames={iconClassNames}
menu={menu}
/>
{children} {children}
</details> </details>
) )

View File

@ -1,6 +1,9 @@
import { Dialog, Transition } from '@headlessui/react' import { Dialog, Transition } from '@headlessui/react'
import { Fragment } from 'react' import { Fragment } from 'react'
import { useCalc, CreateNewVariable } from './AvailableVarsHelpers' import { useCalc, CreateNewVariable } from './AvailableVarsHelpers'
import { ActionButton } from './ActionButton'
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import { toast } from 'react-hot-toast'
export const SetVarNameModal = ({ export const SetVarNameModal = ({
isOpen, isOpen,
@ -19,21 +22,23 @@ export const SetVarNameModal = ({
return ( return (
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onReject}> <Dialog
as="div"
className="fixed inset-0 z-40 overflow-y-auto p-4 pt-[25vh]"
onClose={onReject}
>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0 translate-y-4"
enterTo="opacity-100" enterTo="opacity-100 translate-y-0"
leave="ease-in duration-200" leave="ease-in duration-75"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 bg-black bg-opacity-25" /> <Dialog.Overlay className="fixed inset-0 bg-chalkboard-10/70 dark:bg-chalkboard-110/50" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
@ -43,43 +48,39 @@ export const SetVarNameModal = ({
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all"> <Dialog.Panel className="rounded relative mx-auto px-4 py-8 bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-xl w-full shadow-lg">
<Dialog.Title <form
as="h3" onSubmit={(e) => {
className="text-lg font-medium leading-6 text-gray-900 capitalize" e.preventDefault()
onResolve({
variableName: newVariableName,
})
toast.success(`Added variable ${newVariableName}`)
}}
> >
Set {valueName}
</Dialog.Title>
<CreateNewVariable <CreateNewVariable
setNewVariableName={setNewVariableName} setNewVariableName={setNewVariableName}
newVariableName={newVariableName} newVariableName={newVariableName}
isNewVariableNameUnique={isNewVariableNameUnique} isNewVariableNameUnique={isNewVariableNameUnique}
shouldCreateVariable={true} shouldCreateVariable={true}
setShouldCreateVariable={() => {}} showCheckbox={false}
/> />
<div className="mt-4"> <div className="mt-8 flex justify-between">
<button <ActionButton
type="button" Element="button"
type="submit"
disabled={!isNewVariableNameUnique} disabled={!isNewVariableNameUnique}
className={`inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 ${ icon={{ icon: faPlus }}
!isNewVariableNameUnique
? 'opacity-50 cursor-not-allowed'
: ''
}`}
onClick={() =>
onResolve({
variableName: newVariableName,
})
}
> >
Add variable Add variable
</button> </ActionButton>
<ActionButton Element="button" onClick={() => onReject(false)}>
Cancel
</ActionButton>
</div> </div>
</form>
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>
</div>
</div>
</Dialog> </Dialog>
</Transition> </Transition>
) )

View File

@ -0,0 +1,267 @@
import ReactCodeMirror, {
Extension,
ViewUpdate,
keymap,
} from '@uiw/react-codemirror'
import { FromServer, IntoServer } from 'editor/lsp/codec'
import Server from '../editor/lsp/server'
import Client from '../editor/lsp/client'
import { TEST } from 'env'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useConvertToVariable } from 'hooks/useToolbarGuards'
import { Themes } from 'lib/theme'
import { useMemo } from 'react'
import { linter, lintGutter } from '@codemirror/lint'
import { Selections, useStore } from 'useStore'
import { LanguageServerClient } from 'editor/lsp'
import kclLanguage from 'editor/lsp/language'
import { isTauri } from 'lib/isTauri'
import { useParams } from 'react-router-dom'
import { writeTextFile } from '@tauri-apps/api/fs'
import { PROJECT_ENTRYPOINT } from 'lib/tauriFS'
import { toast } from 'react-hot-toast'
import {
EditorView,
addLineHighlight,
lineHighlightField,
} from 'editor/highlightextension'
import { isOverlap } from 'lib/utils'
import { kclErrToDiagnostic } from 'lang/errors'
import { CSSRuleObject } from 'tailwindcss/types/config'
export const editorShortcutMeta = {
formatCode: {
codeMirror: 'Alt-Shift-f',
display: 'Alt + Shift + F',
},
convertToVariable: {
codeMirror: 'Ctrl-Shift-c',
display: 'Ctrl + Shift + C',
},
}
export const TextEditor = ({
theme,
}: {
theme: Themes.Light | Themes.Dark
}) => {
const pathParams = useParams()
const {
code,
defferedSetCode,
editorView,
engineCommandManager,
formatCode,
isLSPServerReady,
selectionRanges,
selectionRangeTypeMap,
setEditorView,
setIsLSPServerReady,
setSelectionRanges,
sourceRangeMap,
} = useStore((s) => ({
code: s.code,
defferedCode: s.defferedCode,
defferedSetCode: s.defferedSetCode,
editorView: s.editorView,
engineCommandManager: s.engineCommandManager,
formatCode: s.formatCode,
isLSPServerReady: s.isLSPServerReady,
selectionRanges: s.selectionRanges,
selectionRangeTypeMap: s.selectionRangeTypeMap,
setCode: s.setCode,
setEditorView: s.setEditorView,
setIsLSPServerReady: s.setIsLSPServerReady,
setSelectionRanges: s.setSelectionRanges,
sourceRangeMap: s.sourceRangeMap,
}))
const {
settings: {
context: { textWrapping },
},
} = useGlobalStateContext()
const { setCommandBarOpen } = useCommandsContext()
const { enable: convertEnabled, handleClick: convertCallback } =
useConvertToVariable()
// So this is a bit weird, we need to initialize the lsp server and client.
// But the server happens async so we break this into two parts.
// Below is the client and server promise.
const { lspClient } = useMemo(() => {
const intoServer: IntoServer = new IntoServer()
const fromServer: FromServer = FromServer.create()
const client = new Client(fromServer, intoServer)
if (!TEST) {
Server.initialize(intoServer, fromServer).then((lspServer) => {
lspServer.start()
setIsLSPServerReady(true)
})
}
const lspClient = new LanguageServerClient({ client })
return { lspClient }
}, [setIsLSPServerReady])
// Here we initialize the plugin which will start the client.
// When we have multi-file support the name of the file will be a dep of
// this use memo, as well as the directory structure, which I think is
// a good setup becuase it will restart the client but not the server :)
// We do not want to restart the server, its just wasteful.
const kclLSP = useMemo(() => {
let plugin = null
if (isLSPServerReady && !TEST) {
// Set up the lsp plugin.
const lsp = kclLanguage({
// When we have more than one file, we'll need to change this.
documentUri: `file:///we-just-have-one-file-for-now.kcl`,
workspaceFolders: null,
client: lspClient,
})
plugin = lsp
}
return plugin
}, [lspClient, isLSPServerReady])
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
const onChange = (value: string, viewUpdate: ViewUpdate) => {
defferedSetCode(value)
if (isTauri() && pathParams.id) {
// Save the file to disk
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
writeTextFile(pathParams.id + '/' + PROJECT_ENTRYPOINT, value).catch(
(err) => {
// TODO: add Sentry per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254)
console.error('error saving file', err)
toast.error('Error saving file, please check file permissions')
}
)
}
if (editorView) {
editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
}
} //, []);
const onUpdate = (viewUpdate: ViewUpdate) => {
if (!editorView) {
setEditorView(viewUpdate.view)
}
const ranges = viewUpdate.state.selection.ranges
const isChange =
ranges.length !== selectionRanges.codeBasedSelections.length ||
ranges.some(({ from, to }, i) => {
return (
from !== selectionRanges.codeBasedSelections[i].range[0] ||
to !== selectionRanges.codeBasedSelections[i].range[1]
)
})
if (!isChange) return
const codeBasedSelections: Selections['codeBasedSelections'] = ranges.map(
({ from, to }) => {
if (selectionRangeTypeMap[to]) {
return {
type: selectionRangeTypeMap[to],
range: [from, to],
}
}
return {
type: 'default',
range: [from, to],
}
}
)
const idBasedSelections = codeBasedSelections
.map(({ type, range }) => {
const hasOverlap = Object.entries(sourceRangeMap).filter(
([_, sourceRange]) => {
return isOverlap(sourceRange, range)
}
)
if (hasOverlap.length) {
return {
type,
id: hasOverlap[0][0],
}
}
})
.filter(Boolean) as any
engineCommandManager?.cusorsSelected({
otherSelections: [],
idBasedSelections,
})
setSelectionRanges({
otherSelections: [],
codeBasedSelections,
})
}
const editorExtensions = useMemo(() => {
const extensions = [
lineHighlightField,
keymap.of([
{
key: 'Meta-k',
run: () => {
setCommandBarOpen(true)
return false
},
},
{
key: editorShortcutMeta.formatCode.codeMirror,
run: () => {
formatCode()
return true
},
},
{
key: editorShortcutMeta.convertToVariable.codeMirror,
run: () => {
if (convertEnabled) {
convertCallback()
return true
}
return false
},
},
]),
] as Extension[]
if (kclLSP) extensions.push(kclLSP)
// These extensions have proven to mess with vitest
if (!TEST) {
extensions.push(
lintGutter(),
linter((_view) => {
return kclErrToDiagnostic(useStore.getState().kclErrors)
})
)
if (textWrapping === 'On') extensions.push(EditorView.lineWrapping)
}
return extensions
}, [kclLSP, textWrapping])
return (
<div
id="code-mirror-override"
className="full-height-subtract"
style={{ '--height-subtract': '4.25rem' } as CSSRuleObject}
>
<ReactCodeMirror
className="h-full"
value={code}
extensions={editorExtensions}
onChange={onChange}
onUpdate={onUpdate}
theme={theme}
onCreateEditor={(_editorView) => setEditorView(_editorView)}
/>
</div>
)
}

View File

@ -1,61 +0,0 @@
import { useState, useEffect } from 'react'
import { create } from 'react-modal-promise'
import { useStore } from '../../useStore'
import { isNodeSafeToReplace } from '../../lang/queryAst'
import { SetVarNameModal } from '../SetVarNameModal'
import { moveValueIntoNewVariable } from '../../lang/modifyAst'
const getModalInfo = create(SetVarNameModal as any)
export const ConvertToVariable = () => {
const { guiMode, selectionRanges, ast, programMemory, updateAst } = useStore(
(s) => ({
guiMode: s.guiMode,
ast: s.ast,
updateAst: s.updateAst,
selectionRanges: s.selectionRanges,
programMemory: s.programMemory,
})
)
const [enableAngLen, setEnableAngLen] = useState(false)
useEffect(() => {
if (!ast) return
const { isSafe, value } = isNodeSafeToReplace(
ast,
selectionRanges.codeBasedSelections?.[0]?.range || []
)
const canReplace = isSafe && value.type !== 'Identifier'
const isOnlyOneSelection = selectionRanges.codeBasedSelections.length === 1
const _enableHorz = canReplace && isOnlyOneSelection
setEnableAngLen(_enableHorz)
}, [guiMode, selectionRanges])
return (
<button
onClick={async () => {
if (!ast) return
try {
const { variableName } = await getModalInfo({
valueName: 'var',
} as any)
const { modifiedAst: _modifiedAst } = moveValueIntoNewVariable(
ast,
programMemory,
selectionRanges.codeBasedSelections[0].range,
variableName
)
updateAst(_modifiedAst)
} catch (e) {
console.log('e', e)
}
}}
disabled={!enableAngLen}
>
ConvertToVariable
</button>
)
}

View File

@ -0,0 +1,56 @@
import { SetVarNameModal } from 'components/SetVarNameModal'
import { moveValueIntoNewVariable } from 'lang/modifyAst'
import { isNodeSafeToReplace } from 'lang/queryAst'
import { useEffect, useState } from 'react'
import { create } from 'react-modal-promise'
import { useStore } from 'useStore'
const getModalInfo = create(SetVarNameModal as any)
export function useConvertToVariable() {
const { guiMode, selectionRanges, ast, programMemory, updateAst } = useStore(
(s) => ({
guiMode: s.guiMode,
ast: s.ast,
updateAst: s.updateAst,
selectionRanges: s.selectionRanges,
programMemory: s.programMemory,
})
)
const [enable, setEnabled] = useState(false)
useEffect(() => {
if (!ast) return
const { isSafe, value } = isNodeSafeToReplace(
ast,
selectionRanges.codeBasedSelections?.[0]?.range || []
)
const canReplace = isSafe && value.type !== 'Identifier'
const isOnlyOneSelection = selectionRanges.codeBasedSelections.length === 1
const _enableHorz = canReplace && isOnlyOneSelection
setEnabled(_enableHorz)
}, [guiMode, selectionRanges])
const handleClick = async () => {
if (!ast) return
try {
const { variableName } = await getModalInfo({
valueName: 'var',
} as any)
const { modifiedAst: _modifiedAst } = moveValueIntoNewVariable(
ast,
programMemory,
selectionRanges.codeBasedSelections[0].range,
variableName
)
updateAst(_modifiedAst)
} catch (e) {
console.log('e', e)
}
}
return { enable, handleClick }
}