Improves error reporting in code editor

This commit is contained in:
Steve Ruiz 2021-06-27 11:37:42 +01:00
parent d1a3860bb1
commit 35b0ba27e6
7 changed files with 169 additions and 82 deletions

View file

@ -1,20 +1,20 @@
import Editor, { Monaco } from '@monaco-editor/react' import Editor, { Monaco } from '@monaco-editor/react'
import useTheme from 'hooks/useTheme' import useTheme from 'hooks/useTheme'
import prettier from 'prettier/standalone'
import parserTypeScript from 'prettier/parser-typescript'
// import codeAsString from './code-as-string'
import typesImport from './types-import' import typesImport from './types-import'
import React, { useCallback, useEffect, useRef } from 'react' import React, { useCallback, useEffect, useRef } from 'react'
import styled from 'styles' import styled from 'styles'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api' import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'
import { getFormattedCode } from 'utils/code'
export type IMonaco = typeof monaco export type IMonaco = typeof monaco
export type IMonacoEditor = monaco.editor.IStandaloneCodeEditor export type IMonacoEditor = monaco.editor.IStandaloneCodeEditor
const modifierKeys = ['Escape', 'Meta', 'Control', 'Shift', 'Option', 'Alt']
interface Props { interface Props {
value: string value: string
error: { line: number } error: { line: number; column: number }
fontSize: number fontSize: number
monacoRef?: React.MutableRefObject<IMonaco> monacoRef?: React.MutableRefObject<IMonaco>
editorRef?: React.MutableRefObject<IMonacoEditor> editorRef?: React.MutableRefObject<IMonacoEditor>
@ -92,13 +92,7 @@ export default function CodeEditor({
monaco.languages.registerDocumentFormattingEditProvider('typescript', { monaco.languages.registerDocumentFormattingEditProvider('typescript', {
async provideDocumentFormattingEdits(model) { async provideDocumentFormattingEdits(model) {
try { try {
const text = prettier.format(model.getValue(), { const text = getFormattedCode(model.getValue())
parser: 'typescript',
plugins: [parserTypeScript],
singleQuote: true,
trailingComma: 'es5',
semi: false,
})
return [ return [
{ {
@ -126,6 +120,8 @@ export default function CodeEditor({
editor.updateOptions({ editor.updateOptions({
fontSize, fontSize,
fontFamily: "'Recursive', monospace",
fontWeight: '420',
wordBasedSuggestions: false, wordBasedSuggestions: false,
minimap: { enabled: false }, minimap: { enabled: false },
lightbulb: { lightbulb: {
@ -141,8 +137,8 @@ export default function CodeEditor({
const handleKeydown = useCallback( const handleKeydown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => { (e: React.KeyboardEvent<HTMLDivElement>) => {
onKey && onKey()
e.stopPropagation() e.stopPropagation()
!modifierKeys.includes(e.key) && onKey?.()
const metaKey = navigator.platform.match('Mac') ? e.metaKey : e.ctrlKey const metaKey = navigator.platform.match('Mac') ? e.metaKey : e.ctrlKey
if (e.key === 's' && metaKey) { if (e.key === 's' && metaKey) {
const editor = rEditor.current const editor = rEditor.current

View file

@ -1,9 +1,9 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/ban-ts-comment */
import styled from 'styles' import styled from 'styles'
import { useStateDesigner } from '@state-designer/react' import { useStateDesigner } from '@state-designer/react'
import React, { useEffect, useRef } from 'react' import React, { useCallback, useEffect, useRef } from 'react'
import state, { useSelector } from 'state' import state, { useSelector } from 'state'
import { CodeFile } from 'types' import { CodeError, CodeFile, CodeResult } from 'types'
import CodeDocs from './code-docs' import CodeDocs from './code-docs'
import { generateFromCode } from 'state/code/generate' import { generateFromCode } from 'state/code/generate'
import * as Panel from '../panel' import * as Panel from '../panel'
@ -19,16 +19,9 @@ import {
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
const CodeEditor = dynamic(() => import('./code-editor')) const CodeEditor = dynamic(() => import('./code-editor'))
const getErrorLineAndColumn = (e: any) => { const increaseCodeSize = () => state.send('INCREASED_CODE_FONT_SIZE')
if ('line' in e) { const decreaseCodeSize = () => state.send('DECREASED_CODE_FONT_SIZE')
return { line: Number(e.line), column: e.column } const toggleCodePanel = () => state.send('TOGGLED_CODE_PANEL_OPEN')
}
const result = e.stack.match(/:([0-9]+):([0-9]+)/)
if (result) {
return { line: Number(result[1]) - 1, column: result[2] }
}
}
export default function CodePanel(): JSX.Element { export default function CodePanel(): JSX.Element {
const rContainer = useRef<HTMLDivElement>(null) const rContainer = useRef<HTMLDivElement>(null)
@ -43,7 +36,7 @@ export default function CodePanel(): JSX.Element {
const local = useStateDesigner({ const local = useStateDesigner({
data: { data: {
code: file.code, code: file.code,
error: null as { message: string; line: number; column: number } | null, error: null as CodeError | null,
}, },
on: { on: {
MOUNTED: 'setCode', MOUNTED: 'setCode',
@ -53,13 +46,23 @@ export default function CodePanel(): JSX.Element {
states: { states: {
editingCode: { editingCode: {
on: { on: {
RAN_CODE: ['saveCode', 'runCode'], RAN_CODE: { do: 'saveCode', to: 'evaluatingCode' },
SAVED_CODE: ['saveCode', 'runCode'], SAVED_CODE: { do: 'saveCode', to: 'evaluatingCode' },
CHANGED_CODE: { secretlyDo: 'setCode' }, CHANGED_CODE: { secretlyDo: 'setCode' },
CLEARED_ERROR: { if: 'hasError', do: 'clearError' }, CLEARED_ERROR: { if: 'hasError', do: 'clearError' },
TOGGLED_DOCS: { to: 'viewingDocs' }, TOGGLED_DOCS: { to: 'viewingDocs' },
}, },
}, },
evaluatingCode: {
async: {
await: 'evalCode',
onResolve: {
do: ['clearError', 'sendResultToGlobalState'],
to: 'editingCode',
},
onReject: { do: 'setErrorFromResult', to: 'editingCode' },
},
},
viewingDocs: { viewingDocs: {
on: { on: {
TOGGLED_DOCS: { to: 'editingCode' }, TOGGLED_DOCS: { to: 'editingCode' },
@ -78,22 +81,6 @@ export default function CodePanel(): JSX.Element {
setCode(data, payload: { code: string }) { setCode(data, payload: { code: string }) {
data.code = payload.code data.code = payload.code
}, },
runCode(data) {
let error = null
try {
generateFromCode(state.data, data.code).then(
({ shapes, controls }) => {
state.send('GENERATED_FROM_CODE', { shapes, controls })
}
)
} catch (e) {
console.error('Got an error!', e)
error = { message: e.message, ...getErrorLineAndColumn(e) }
}
data.error = error
},
saveCode(data) { saveCode(data) {
const { code } = data const { code } = data
state.send('SAVED_CODE', { code }) state.send('SAVED_CODE', { code })
@ -101,6 +88,25 @@ export default function CodePanel(): JSX.Element {
clearError(data) { clearError(data) {
data.error = null data.error = null
}, },
setErrorFromResult(data, payload, result: CodeResult) {
data.error = result.error
},
sendResultToGlobalState(data, payload, result: CodeResult) {
state.send('GENERATED_FROM_CODE', result)
},
},
asyncs: {
evalCode(data) {
return new Promise((resolve, reject) => {
generateFromCode(state.data, data.code).then((result) => {
if (result.error !== null) {
reject(result)
} else {
resolve(result)
}
})
})
},
}, },
}) })
@ -115,6 +121,17 @@ export default function CodePanel(): JSX.Element {
} }
}, []) }, [])
const handleCodeChange = useCallback(
(code: string) => local.send('CHANGED_CODE', { code }),
[local]
)
const handleSave = useCallback(() => local.send('SAVED_CODE'), [local])
const handleKey = useCallback(() => local.send('CLEARED_ERROR'), [local])
const toggleDocs = useCallback(() => local.send('TOGGLED_DOCS'), [local])
const { error } = local.data const { error } = local.data
return ( return (
@ -131,7 +148,7 @@ export default function CodePanel(): JSX.Element {
<IconButton <IconButton
bp={{ '@initial': 'mobile', '@sm': 'small' }} bp={{ '@initial': 'mobile', '@sm': 'small' }}
size="small" size="small"
onClick={() => state.send('TOGGLED_CODE_PANEL_OPEN')} onClick={toggleCodePanel}
> >
<X /> <X />
</IconButton> </IconButton>
@ -142,14 +159,14 @@ export default function CodePanel(): JSX.Element {
bp={{ '@initial': 'mobile', '@sm': 'small' }} bp={{ '@initial': 'mobile', '@sm': 'small' }}
size="small" size="small"
disabled={!local.isIn('editingCode')} disabled={!local.isIn('editingCode')}
onClick={() => state.send('INCREASED_CODE_FONT_SIZE')} onClick={increaseCodeSize}
> >
<ChevronUp /> <ChevronUp />
</IconButton> </IconButton>
<IconButton <IconButton
size="small" size="small"
disabled={!local.isIn('editingCode')} disabled={!local.isIn('editingCode')}
onClick={() => state.send('DECREASED_CODE_FONT_SIZE')} onClick={decreaseCodeSize}
> >
<ChevronDown /> <ChevronDown />
</IconButton> </IconButton>
@ -157,7 +174,7 @@ export default function CodePanel(): JSX.Element {
<IconButton <IconButton
bp={{ '@initial': 'mobile', '@sm': 'small' }} bp={{ '@initial': 'mobile', '@sm': 'small' }}
size="small" size="small"
onClick={() => local.send('TOGGLED_DOCS')} onClick={toggleDocs}
> >
<Info /> <Info />
</IconButton> </IconButton>
@ -165,7 +182,7 @@ export default function CodePanel(): JSX.Element {
bp={{ '@initial': 'mobile', '@sm': 'small' }} bp={{ '@initial': 'mobile', '@sm': 'small' }}
size="small" size="small"
disabled={!local.isIn('editingCode')} disabled={!local.isIn('editingCode')}
onClick={() => local.send('SAVED_CODE')} onClick={handleSave}
> >
<PlayCircle /> <PlayCircle />
</IconButton> </IconButton>
@ -177,24 +194,20 @@ export default function CodePanel(): JSX.Element {
readOnly={isReadOnly} readOnly={isReadOnly}
value={file.code} value={file.code}
error={error} error={error}
onChange={(code) => local.send('CHANGED_CODE', { code })} onChange={handleCodeChange}
onSave={() => local.send('SAVED_CODE')} onSave={handleSave}
onKey={() => local.send('CLEARED_ERROR')} onKey={handleKey}
/> />
<CodeDocs isHidden={!local.isIn('viewingDocs')} /> <CodeDocs isHidden={!local.isIn('viewingDocs')} />
</Panel.Content> </Panel.Content>
<Panel.Footer>
{error && {error && <Panel.Footer>{error.message}</Panel.Footer>}
(error.line
? `(${Number(error.line) - 2}:${error.column}) ${error.message}`
: error.message)}
</Panel.Footer>
</Panel.Layout> </Panel.Layout>
) : ( ) : (
<IconButton <IconButton
bp={{ '@initial': 'mobile', '@sm': 'small' }} bp={{ '@initial': 'mobile', '@sm': 'small' }}
size="small" size="small"
onClick={() => state.send('TOGGLED_CODE_PANEL_OPEN')} onClick={toggleCodePanel}
> >
<Code /> <Code />
</IconButton> </IconButton>

View file

@ -19,9 +19,11 @@ import {
ColorStyle, ColorStyle,
FontSize, FontSize,
SizeStyle, SizeStyle,
CodeError,
} from 'types' } from 'types'
import { getPage, getShapes } from 'utils' import { getPage, getShapes } from 'utils'
import { transform } from 'sucrase' import { transform } from 'sucrase'
import { getErrorWithLineAndColumn, getFormattedCode } from 'utils/code'
const baseScope = { const baseScope = {
Dot, Dot,
@ -54,6 +56,7 @@ export async function generateFromCode(
): Promise<{ ): Promise<{
shapes: Shape[] shapes: Shape[]
controls: CodeControl[] controls: CodeControl[]
error: CodeError
}> { }> {
codeControls.clear() codeControls.clear()
codeShapes.clear() codeShapes.clear()
@ -63,29 +66,41 @@ export async function generateFromCode(
const { currentPageId } = data const { currentPageId } = data
const scope = { ...baseScope, controls, currentPageId } const scope = { ...baseScope, controls, currentPageId }
const transformed = transform(code, { let generatedShapes: Shape[] = []
transforms: ['typescript'], let generatedControls: CodeControl[] = []
}).code let error: CodeError | null = null
new Function(...Object.keys(scope), `${transformed}`)(...Object.values(scope)) try {
const formattedCode = getFormattedCode(code)
const startingChildIndex = const transformedCode = transform(formattedCode, {
getShapes(data) transforms: ['typescript'],
.filter((shape) => shape.parentId === data.currentPageId) })?.code
.sort((a, b) => a.childIndex - b.childIndex)[0]?.childIndex || 1
const generatedShapes = Array.from(codeShapes.values()) new Function(...Object.keys(scope), `${transformedCode}`)(
.sort((a, b) => a.shape.childIndex - b.shape.childIndex) ...Object.values(scope)
.map((instance, i) => ({ )
...instance.shape,
isGenerated: true,
parentId: getPage(data).id,
childIndex: startingChildIndex + i,
}))
const generatedControls = Array.from(codeControls.values()) const startingChildIndex =
getShapes(data)
.filter((shape) => shape.parentId === data.currentPageId)
.sort((a, b) => a.childIndex - b.childIndex)[0]?.childIndex || 1
return { shapes: generatedShapes, controls: generatedControls } generatedShapes = Array.from(codeShapes.values())
.sort((a, b) => a.shape.childIndex - b.shape.childIndex)
.map((instance, i) => ({
...instance.shape,
isGenerated: true,
parentId: getPage(data).id,
childIndex: startingChildIndex + i,
}))
generatedControls = Array.from(codeControls.values())
} catch (e) {
error = getErrorWithLineAndColumn(e)
}
return { shapes: generatedShapes, controls: generatedControls, error }
} }
/** /**

View file

@ -1,7 +1,14 @@
@import url('https://fonts.googleapis.com/css2?family=Recursive:wght@500;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Recursive:wght@500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Recursive:wght,MONO@420,1&display=swap');
@font-face { @font-face {
font-family: 'Verveine Regular'; font-family: 'Verveine Regular';
font-style: normal; font-style: normal;
font-weight: normal; font-weight: normal;
src: local('Verveine Regular'), url('/VerveineRegular.woff') format('woff'); src: local('Verveine Regular'), url('/VerveineRegular.woff') format('woff');
} }
.margin {
user-select: none;
}

View file

@ -23,6 +23,7 @@ const { styled, global, css, theme, getCssString } = createCss({
muted: '#777', muted: '#777',
input: '#f3f3f3', input: '#f3f3f3',
inputBorder: '#ddd', inputBorder: '#ddd',
lineError: 'rgba(255, 0, 0, .1)',
}, },
space: {}, space: {},
fontSizes: { fontSizes: {

View file

@ -227,12 +227,6 @@ export type Shape = Readonly<MutableShape>
export type ShapeByType<T extends ShapeType> = Shapes[T] export type ShapeByType<T extends ShapeType> = Shapes[T]
export interface CodeFile {
id: string
name: string
code: string
}
export enum Decoration { export enum Decoration {
Arrow = 'Arrow', Arrow = 'Arrow',
} }
@ -249,6 +243,24 @@ export interface ShapeHandle {
point: number[] point: number[]
} }
export interface CodeFile {
id: string
name: string
code: string
}
export interface CodeError {
message: string
line: number
column: number
}
export interface CodeResult {
shapes: Shape[]
controls: CodeControl[]
error: CodeError
}
/* -------------------------------------------------- */ /* -------------------------------------------------- */
/* Editor UI */ /* Editor UI */
/* -------------------------------------------------- */ /* -------------------------------------------------- */

43
utils/code.ts Normal file
View file

@ -0,0 +1,43 @@
import prettier from 'prettier/standalone'
import parserTypeScript from 'prettier/parser-typescript'
import { CodeError } from 'types'
/**
* Format code with prettier
* @param code
*/
export function getFormattedCode(code: string): string {
return prettier.format(code, {
parser: 'typescript',
plugins: [parserTypeScript],
singleQuote: true,
trailingComma: 'es5',
semi: false,
})
}
/**
* Get line and column from error.
* @param e
*/
export const getErrorWithLineAndColumn = (e: Error | any): CodeError => {
if ('line' in e) {
return { message: e.message, line: Number(e.line), column: e.column }
}
const result = e.stack.split('/n')[0].match(/(.*)\(([0-9]+):([0-9]+)/)
if (result) {
return {
message: result[1],
line: Number(result[2]) + 1,
column: result[3],
}
} else {
return {
message: e.message,
line: null,
column: null,
}
}
}