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:
		
							
								
								
									
										223
									
								
								src/App.tsx
									
									
									
									
									
								
							
							
						
						
									
										223
									
								
								src/App.tsx
									
									
									
									
									
								
							@ -2,7 +2,6 @@ import {
 | 
			
		||||
  useRef,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useLayoutEffect,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  MouseEventHandler,
 | 
			
		||||
} from 'react'
 | 
			
		||||
@ -10,30 +9,20 @@ import { DebugPanel } from './components/DebugPanel'
 | 
			
		||||
import { v4 as uuidv4 } from 'uuid'
 | 
			
		||||
import { asyncParser } from './lang/abstractSyntaxTree'
 | 
			
		||||
import { _executor } from './lang/executor'
 | 
			
		||||
import CodeMirror, { Extension } from '@uiw/react-codemirror'
 | 
			
		||||
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 { PaneType, useStore } from './useStore'
 | 
			
		||||
import { Logs, KCLErrors } from './components/Logs'
 | 
			
		||||
import { CollapsiblePanel } from './components/CollapsiblePanel'
 | 
			
		||||
import { MemoryPanel } from './components/MemoryPanel'
 | 
			
		||||
import { useHotKeyListener } from './hooks/useHotKeyListener'
 | 
			
		||||
import { Stream } from './components/Stream'
 | 
			
		||||
import ModalContainer from 'react-modal-promise'
 | 
			
		||||
import { FromServer, IntoServer } from './editor/lsp/codec'
 | 
			
		||||
import {
 | 
			
		||||
  EngineCommand,
 | 
			
		||||
  EngineCommandManager,
 | 
			
		||||
} from './lang/std/engineConnection'
 | 
			
		||||
import { isOverlap, throttle } from './lib/utils'
 | 
			
		||||
import { throttle } from './lib/utils'
 | 
			
		||||
import { AppHeader } from './components/AppHeader'
 | 
			
		||||
import { KCLError, kclErrToDiagnostic } from './lang/errors'
 | 
			
		||||
import { KCLError } from './lang/errors'
 | 
			
		||||
import { Resizable } from 're-resizable'
 | 
			
		||||
import {
 | 
			
		||||
  faCode,
 | 
			
		||||
@ -41,57 +30,42 @@ import {
 | 
			
		||||
  faSquareRootVariable,
 | 
			
		||||
} from '@fortawesome/free-solid-svg-icons'
 | 
			
		||||
import { useHotkeys } from 'react-hotkeys-hook'
 | 
			
		||||
import { TEST } from './env'
 | 
			
		||||
import { getNormalisedCoordinates } from './lib/utils'
 | 
			
		||||
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'
 | 
			
		||||
import { PROJECT_ENTRYPOINT } from './lib/tauriFS'
 | 
			
		||||
import { useLoaderData } from 'react-router-dom'
 | 
			
		||||
import { IndexLoaderData } from './Router'
 | 
			
		||||
import { toast } from 'react-hot-toast'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
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 { 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() {
 | 
			
		||||
  const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
 | 
			
		||||
  const pathParams = useParams()
 | 
			
		||||
 | 
			
		||||
  const streamRef = useRef<HTMLDivElement>(null)
 | 
			
		||||
  useHotKeyListener()
 | 
			
		||||
  const {
 | 
			
		||||
    editorView,
 | 
			
		||||
    setEditorView,
 | 
			
		||||
    setSelectionRanges,
 | 
			
		||||
    selectionRanges,
 | 
			
		||||
    addLog,
 | 
			
		||||
    addKCLError,
 | 
			
		||||
    code,
 | 
			
		||||
    setCode,
 | 
			
		||||
    setAst,
 | 
			
		||||
    setError,
 | 
			
		||||
    setProgramMemory,
 | 
			
		||||
    resetLogs,
 | 
			
		||||
    resetKCLErrors,
 | 
			
		||||
    selectionRangeTypeMap,
 | 
			
		||||
    setArtifactMap,
 | 
			
		||||
    engineCommandManager,
 | 
			
		||||
    setEngineCommandManager,
 | 
			
		||||
    highlightRange,
 | 
			
		||||
    setHighlightRange,
 | 
			
		||||
    setCursor2,
 | 
			
		||||
    sourceRangeMap,
 | 
			
		||||
    setMediaStream,
 | 
			
		||||
    setIsStreamReady,
 | 
			
		||||
    isStreamReady,
 | 
			
		||||
    isLSPServerReady,
 | 
			
		||||
    setIsLSPServerReady,
 | 
			
		||||
    buttonDownInStream,
 | 
			
		||||
    formatCode,
 | 
			
		||||
    openPanes,
 | 
			
		||||
    setOpenPanes,
 | 
			
		||||
    didDragInStream,
 | 
			
		||||
@ -99,40 +73,25 @@ export function App() {
 | 
			
		||||
    streamDimensions,
 | 
			
		||||
    setIsExecuting,
 | 
			
		||||
    defferedCode,
 | 
			
		||||
    defferedSetCode,
 | 
			
		||||
  } = useStore((s) => ({
 | 
			
		||||
    editorView: s.editorView,
 | 
			
		||||
    setEditorView: s.setEditorView,
 | 
			
		||||
    setSelectionRanges: s.setSelectionRanges,
 | 
			
		||||
    selectionRanges: s.selectionRanges,
 | 
			
		||||
    setGuiMode: s.setGuiMode,
 | 
			
		||||
    addLog: s.addLog,
 | 
			
		||||
    code: s.code,
 | 
			
		||||
    defferedCode: s.defferedCode,
 | 
			
		||||
    setCode: s.setCode,
 | 
			
		||||
    defferedSetCode: s.defferedSetCode,
 | 
			
		||||
    setAst: s.setAst,
 | 
			
		||||
    setError: s.setError,
 | 
			
		||||
    setProgramMemory: s.setProgramMemory,
 | 
			
		||||
    resetLogs: s.resetLogs,
 | 
			
		||||
    resetKCLErrors: s.resetKCLErrors,
 | 
			
		||||
    selectionRangeTypeMap: s.selectionRangeTypeMap,
 | 
			
		||||
    setArtifactMap: s.setArtifactNSourceRangeMaps,
 | 
			
		||||
    engineCommandManager: s.engineCommandManager,
 | 
			
		||||
    setEngineCommandManager: s.setEngineCommandManager,
 | 
			
		||||
    highlightRange: s.highlightRange,
 | 
			
		||||
    setHighlightRange: s.setHighlightRange,
 | 
			
		||||
    isShiftDown: s.isShiftDown,
 | 
			
		||||
    setCursor: s.setCursor,
 | 
			
		||||
    setCursor2: s.setCursor2,
 | 
			
		||||
    sourceRangeMap: s.sourceRangeMap,
 | 
			
		||||
    setMediaStream: s.setMediaStream,
 | 
			
		||||
    isStreamReady: s.isStreamReady,
 | 
			
		||||
    setIsStreamReady: s.setIsStreamReady,
 | 
			
		||||
    isLSPServerReady: s.isLSPServerReady,
 | 
			
		||||
    setIsLSPServerReady: s.setIsLSPServerReady,
 | 
			
		||||
    buttonDownInStream: s.buttonDownInStream,
 | 
			
		||||
    formatCode: s.formatCode,
 | 
			
		||||
    addKCLError: s.addKCLError,
 | 
			
		||||
    openPanes: s.openPanes,
 | 
			
		||||
    setOpenPanes: s.setOpenPanes,
 | 
			
		||||
@ -147,13 +106,7 @@ export function App() {
 | 
			
		||||
      context: { token },
 | 
			
		||||
    },
 | 
			
		||||
    settings: {
 | 
			
		||||
      context: {
 | 
			
		||||
        showDebugPanel,
 | 
			
		||||
        theme,
 | 
			
		||||
        onboardingStatus,
 | 
			
		||||
        textWrapping,
 | 
			
		||||
        cameraControls,
 | 
			
		||||
      },
 | 
			
		||||
      context: { showDebugPanel, onboardingStatus, cameraControls, theme },
 | 
			
		||||
    },
 | 
			
		||||
  } = useGlobalStateContext()
 | 
			
		||||
 | 
			
		||||
@ -194,80 +147,6 @@ export function App() {
 | 
			
		||||
    }
 | 
			
		||||
  }, [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 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 (
 | 
			
		||||
    <div
 | 
			
		||||
      className="h-screen overflow-hidden relative flex flex-col cursor-pointer select-none"
 | 
			
		||||
@ -546,31 +367,9 @@ export function App() {
 | 
			
		||||
            icon={faCode}
 | 
			
		||||
            className="open:!mb-2"
 | 
			
		||||
            open={openPanes.includes('code')}
 | 
			
		||||
            menu={<CodeMenu />}
 | 
			
		||||
          >
 | 
			
		||||
            <div className="px-2 py-1">
 | 
			
		||||
              <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>
 | 
			
		||||
            <TextEditor theme={editorTheme} />
 | 
			
		||||
          </CollapsiblePanel>
 | 
			
		||||
          <section className="flex flex-col">
 | 
			
		||||
            <MemoryPanel
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,6 @@ import { EqualAngle } from './components/Toolbar/EqualAngle'
 | 
			
		||||
import { Intersect } from './components/Toolbar/Intersect'
 | 
			
		||||
import { SetHorzVertDistance } from './components/Toolbar/SetHorzVertDistance'
 | 
			
		||||
import { SetAngleLength } from './components/Toolbar/setAngleLength'
 | 
			
		||||
import { ConvertToVariable } from './components/Toolbar/ConvertVariable'
 | 
			
		||||
import { SetAbsDistance } from './components/Toolbar/SetAbsDistance'
 | 
			
		||||
import { SetAngleBetween } from './components/Toolbar/SetAngleBetween'
 | 
			
		||||
import { Fragment, useEffect } from 'react'
 | 
			
		||||
@ -164,7 +163,6 @@ export const Toolbar = () => {
 | 
			
		||||
              </button>
 | 
			
		||||
            )
 | 
			
		||||
          })}
 | 
			
		||||
        <ConvertToVariable />
 | 
			
		||||
        <HorzVert horOrVert="horizontal" />
 | 
			
		||||
        <HorzVert horOrVert="vertical" />
 | 
			
		||||
        <EqualLength />
 | 
			
		||||
 | 
			
		||||
@ -198,29 +198,25 @@ export const CreateNewVariable = ({
 | 
			
		||||
  isNewVariableNameUnique,
 | 
			
		||||
  setNewVariableName,
 | 
			
		||||
  shouldCreateVariable,
 | 
			
		||||
  setShouldCreateVariable,
 | 
			
		||||
  setShouldCreateVariable = () => {},
 | 
			
		||||
  showCheckbox = true,
 | 
			
		||||
}: {
 | 
			
		||||
  isNewVariableNameUnique: boolean
 | 
			
		||||
  newVariableName: string
 | 
			
		||||
  setNewVariableName: (a: string) => void
 | 
			
		||||
  shouldCreateVariable: boolean
 | 
			
		||||
  setShouldCreateVariable: (a: boolean) => void
 | 
			
		||||
  shouldCreateVariable?: boolean
 | 
			
		||||
  setShouldCreateVariable?: (a: boolean) => void
 | 
			
		||||
  showCheckbox?: boolean
 | 
			
		||||
}) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <label
 | 
			
		||||
        htmlFor="create-new-variable"
 | 
			
		||||
        className="block text-sm font-medium text-gray-700 mt-3 font-mono"
 | 
			
		||||
      >
 | 
			
		||||
      <label htmlFor="create-new-variable" className="block mt-3 font-mono">
 | 
			
		||||
        Create new variable
 | 
			
		||||
      </label>
 | 
			
		||||
      <div className="mt-1 flex flex-1">
 | 
			
		||||
      <div className="mt-1 flex gap-2 items-center">
 | 
			
		||||
        {showCheckbox && (
 | 
			
		||||
          <input
 | 
			
		||||
            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}
 | 
			
		||||
            onChange={(e) => {
 | 
			
		||||
              setShouldCreateVariable(e.target.checked)
 | 
			
		||||
@ -232,7 +228,10 @@ export const CreateNewVariable = ({
 | 
			
		||||
          disabled={!shouldCreateVariable}
 | 
			
		||||
          name="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' : ''
 | 
			
		||||
          }`}
 | 
			
		||||
          value={newVariableName}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										19
									
								
								src/components/CodeMenu.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/components/CodeMenu.module.css
									
									
									
									
									
										Normal 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;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										59
									
								
								src/components/CodeMenu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								src/components/CodeMenu.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@ -9,7 +9,7 @@
 | 
			
		||||
 | 
			
		||||
.header {
 | 
			
		||||
  @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 bg-chalkboard-20;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -8,6 +8,7 @@ export interface CollapsiblePanelProps
 | 
			
		||||
  title: string
 | 
			
		||||
  icon?: IconDefinition
 | 
			
		||||
  open?: boolean
 | 
			
		||||
  menu?: React.ReactNode
 | 
			
		||||
  iconClassNames?: {
 | 
			
		||||
    bg?: string
 | 
			
		||||
    icon?: string
 | 
			
		||||
@ -18,21 +19,27 @@ export const PanelHeader = ({
 | 
			
		||||
  title,
 | 
			
		||||
  icon,
 | 
			
		||||
  iconClassNames,
 | 
			
		||||
  menu,
 | 
			
		||||
}: CollapsiblePanelProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <summary className={styles.header}>
 | 
			
		||||
      <ActionIcon
 | 
			
		||||
        icon={icon}
 | 
			
		||||
        bgClassName={
 | 
			
		||||
          'bg-chalkboard-30 dark:bg-chalkboard-90 group-open:bg-chalkboard-80 rounded ' +
 | 
			
		||||
          (iconClassNames?.bg || '')
 | 
			
		||||
        }
 | 
			
		||||
        iconClassName={
 | 
			
		||||
          'text-chalkboard-90 dark:text-chalkboard-40 group-open:text-liquid-10 ' +
 | 
			
		||||
          (iconClassNames?.icon || '')
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      {title}
 | 
			
		||||
      <div className="flex gap-2 align-center flex-1">
 | 
			
		||||
        <ActionIcon
 | 
			
		||||
          icon={icon}
 | 
			
		||||
          bgClassName={
 | 
			
		||||
            'bg-chalkboard-30 dark:bg-chalkboard-90 group-open:bg-chalkboard-80 rounded ' +
 | 
			
		||||
            (iconClassNames?.bg || '')
 | 
			
		||||
          }
 | 
			
		||||
          iconClassName={
 | 
			
		||||
            'text-chalkboard-90 dark:text-chalkboard-40 group-open:text-liquid-10 ' +
 | 
			
		||||
            (iconClassNames?.icon || '')
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
        {title}
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="group-open:opacity-100 opacity-0 group-open:pointer-events-auto pointer-events-none">
 | 
			
		||||
        {menu}
 | 
			
		||||
      </div>
 | 
			
		||||
    </summary>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@ -43,6 +50,7 @@ export const CollapsiblePanel = ({
 | 
			
		||||
  children,
 | 
			
		||||
  className,
 | 
			
		||||
  iconClassNames,
 | 
			
		||||
  menu,
 | 
			
		||||
  ...props
 | 
			
		||||
}: CollapsiblePanelProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
@ -50,7 +58,12 @@ export const CollapsiblePanel = ({
 | 
			
		||||
      {...props}
 | 
			
		||||
      className={styles.panel + ' group ' + (className || '')}
 | 
			
		||||
    >
 | 
			
		||||
      <PanelHeader title={title} icon={icon} iconClassNames={iconClassNames} />
 | 
			
		||||
      <PanelHeader
 | 
			
		||||
        title={title}
 | 
			
		||||
        icon={icon}
 | 
			
		||||
        iconClassNames={iconClassNames}
 | 
			
		||||
        menu={menu}
 | 
			
		||||
      />
 | 
			
		||||
      {children}
 | 
			
		||||
    </details>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,9 @@
 | 
			
		||||
import { Dialog, Transition } from '@headlessui/react'
 | 
			
		||||
import { Fragment } from 'react'
 | 
			
		||||
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 = ({
 | 
			
		||||
  isOpen,
 | 
			
		||||
@ -19,67 +22,65 @@ export const SetVarNameModal = ({
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <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
 | 
			
		||||
          as={Fragment}
 | 
			
		||||
          enter="ease-out duration-300"
 | 
			
		||||
          enterFrom="opacity-0"
 | 
			
		||||
          enterTo="opacity-100"
 | 
			
		||||
          leave="ease-in duration-200"
 | 
			
		||||
          enterFrom="opacity-0 translate-y-4"
 | 
			
		||||
          enterTo="opacity-100 translate-y-0"
 | 
			
		||||
          leave="ease-in duration-75"
 | 
			
		||||
          leaveFrom="opacity-100"
 | 
			
		||||
          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>
 | 
			
		||||
 | 
			
		||||
        <div className="fixed inset-0 overflow-y-auto">
 | 
			
		||||
          <div className="flex min-h-full items-center justify-center p-4 text-center">
 | 
			
		||||
            <Transition.Child
 | 
			
		||||
              as={Fragment}
 | 
			
		||||
              enter="ease-out duration-300"
 | 
			
		||||
              enterFrom="opacity-0 scale-95"
 | 
			
		||||
              enterTo="opacity-100 scale-100"
 | 
			
		||||
              leave="ease-in duration-200"
 | 
			
		||||
              leaveFrom="opacity-100 scale-100"
 | 
			
		||||
              leaveTo="opacity-0 scale-95"
 | 
			
		||||
        <Transition.Child
 | 
			
		||||
          as={Fragment}
 | 
			
		||||
          enter="ease-out duration-300"
 | 
			
		||||
          enterFrom="opacity-0 scale-95"
 | 
			
		||||
          enterTo="opacity-100 scale-100"
 | 
			
		||||
          leave="ease-in duration-200"
 | 
			
		||||
          leaveFrom="opacity-100 scale-100"
 | 
			
		||||
          leaveTo="opacity-0 scale-95"
 | 
			
		||||
        >
 | 
			
		||||
          <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">
 | 
			
		||||
            <form
 | 
			
		||||
              onSubmit={(e) => {
 | 
			
		||||
                e.preventDefault()
 | 
			
		||||
                onResolve({
 | 
			
		||||
                  variableName: newVariableName,
 | 
			
		||||
                })
 | 
			
		||||
                toast.success(`Added variable ${newVariableName}`)
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <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.Title
 | 
			
		||||
                  as="h3"
 | 
			
		||||
                  className="text-lg font-medium leading-6 text-gray-900 capitalize"
 | 
			
		||||
              <CreateNewVariable
 | 
			
		||||
                setNewVariableName={setNewVariableName}
 | 
			
		||||
                newVariableName={newVariableName}
 | 
			
		||||
                isNewVariableNameUnique={isNewVariableNameUnique}
 | 
			
		||||
                shouldCreateVariable={true}
 | 
			
		||||
                showCheckbox={false}
 | 
			
		||||
              />
 | 
			
		||||
              <div className="mt-8 flex justify-between">
 | 
			
		||||
                <ActionButton
 | 
			
		||||
                  Element="button"
 | 
			
		||||
                  type="submit"
 | 
			
		||||
                  disabled={!isNewVariableNameUnique}
 | 
			
		||||
                  icon={{ icon: faPlus }}
 | 
			
		||||
                >
 | 
			
		||||
                  Set {valueName}
 | 
			
		||||
                </Dialog.Title>
 | 
			
		||||
 | 
			
		||||
                <CreateNewVariable
 | 
			
		||||
                  setNewVariableName={setNewVariableName}
 | 
			
		||||
                  newVariableName={newVariableName}
 | 
			
		||||
                  isNewVariableNameUnique={isNewVariableNameUnique}
 | 
			
		||||
                  shouldCreateVariable={true}
 | 
			
		||||
                  setShouldCreateVariable={() => {}}
 | 
			
		||||
                />
 | 
			
		||||
                <div className="mt-4">
 | 
			
		||||
                  <button
 | 
			
		||||
                    type="button"
 | 
			
		||||
                    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 ${
 | 
			
		||||
                      !isNewVariableNameUnique
 | 
			
		||||
                        ? 'opacity-50 cursor-not-allowed'
 | 
			
		||||
                        : ''
 | 
			
		||||
                    }`}
 | 
			
		||||
                    onClick={() =>
 | 
			
		||||
                      onResolve({
 | 
			
		||||
                        variableName: newVariableName,
 | 
			
		||||
                      })
 | 
			
		||||
                    }
 | 
			
		||||
                  >
 | 
			
		||||
                    Add variable
 | 
			
		||||
                  </button>
 | 
			
		||||
                </div>
 | 
			
		||||
              </Dialog.Panel>
 | 
			
		||||
            </Transition.Child>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
                  Add variable
 | 
			
		||||
                </ActionButton>
 | 
			
		||||
                <ActionButton Element="button" onClick={() => onReject(false)}>
 | 
			
		||||
                  Cancel
 | 
			
		||||
                </ActionButton>
 | 
			
		||||
              </div>
 | 
			
		||||
            </form>
 | 
			
		||||
          </Dialog.Panel>
 | 
			
		||||
        </Transition.Child>
 | 
			
		||||
      </Dialog>
 | 
			
		||||
    </Transition>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										267
									
								
								src/components/TextEditor.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										267
									
								
								src/components/TextEditor.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@ -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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										56
									
								
								src/hooks/useToolbarGuards.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/hooks/useToolbarGuards.ts
									
									
									
									
									
										Normal 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 }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user