diff --git a/package.json b/package.json index 46d716d48..f6e839210 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@juggle/resize-observer": "^3.3.1", "@lezer/highlight": "^1.0.0", "@raspberrypifoundation/design-system-react": "^2.7.0", + "@raspberrypifoundation/python-friendly-error-messages": "^0.1.6", "@react-three/drei": "9.114.3", "@react-three/fiber": "^8.0.13", "@reduxjs/toolkit": "^1.6.2", diff --git a/src/assets/stylesheets/ErrorMessage.scss b/src/assets/stylesheets/ErrorMessage.scss index fbbf14acb..eb560bc7f 100644 --- a/src/assets/stylesheets/ErrorMessage.scss +++ b/src/assets/stylesheets/ErrorMessage.scss @@ -22,3 +22,13 @@ @include font-size-2(regular); } } + +.error-explanation__content { + margin: 0.5rem; + padding: 1rem; + background-color: #f8f8f8; + + div { + margin-bottom: 0.5rem; + } +} diff --git a/src/components/Editor/ErrorMessage/ErrorMessage.jsx b/src/components/Editor/ErrorMessage/ErrorMessage.jsx index 1d50af989..af8307b3d 100644 --- a/src/components/Editor/ErrorMessage/ErrorMessage.jsx +++ b/src/components/Editor/ErrorMessage/ErrorMessage.jsx @@ -1,21 +1,68 @@ -import React, { useContext, useEffect, useRef } from "react"; +import React, { useContext, useEffect, useRef, useState } from "react"; + import "../../../assets/stylesheets/ErrorMessage.scss"; + import { useSelector } from "react-redux"; + import { SettingsContext } from "../../../utils/settings"; +import { + loadCopydeckFor, + registerAdapter, + pyodideAdapter, + friendlyExplain, +} from "@raspberrypifoundation/python-friendly-error-messages"; + const ErrorMessage = () => { const message = useRef(); + const errorExplanation = useRef(); const error = useSelector((state) => state.editor.error); + const errorLine = useSelector((state) => state.editor.errorLine); + const code = useSelector((state) => state.editor.code); + console.log("ErrorMessage render", { error: error, code: code }); + // TODO: highlight the error line in the code editor + // const errorLineNumber = useSelector((state) => state.editor.errorLineNumber); const settings = useContext(SettingsContext); + const [isReady, setIsReady] = useState(false); useEffect(() => { - if (message.current) { + loadCopydeckFor(navigator.language).then(() => { + // TODO: adapt based on runner + // state.editor.activeRunner (and/or loadedRunner) will provide either "pyodide" or "skulpt" + registerAdapter("pyodide", pyodideAdapter); + setIsReady(true); + }); + }, []); + + useEffect(() => { + if (!message.current || !error || !isReady) return; + try { + const explanation = friendlyExplain({ + error: error, + code: code || errorLine, + runtime: "pyodide", + }); + const explained = explanation.html || explanation.summary; + message.current.innerHTML = error; + if (explained) { + errorExplanation.current.innerHTML += `${explained}`; + } + } catch { message.current.innerHTML = error; } - }, [error]); + }, [error, errorLine, isReady]); + return error ? (

+      
+
+
) : null; }; diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx index 0d39afe80..468d2d099 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx @@ -6,6 +6,8 @@ import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { setError, + setErrorLine, + setErrorLineNumber, codeRunHandled, setLoadedRunner, updateProjectComponent, @@ -192,6 +194,8 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { const handleError = (file, line, mistake, type, info) => { let errorMessage; + let errorLine = ""; + let errorLineNumber = null; if (type === "KeyboardInterrupt") { errorMessage = t("output.errors.interrupted"); @@ -203,6 +207,25 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { errorMessage += `:\n${mistake}`; } + if (line && file) { + errorLineNumber = line; + const lastDotIndex = file.lastIndexOf("."); + const fileName = + lastDotIndex > 0 ? file.substring(0, lastDotIndex) : file; + const fileExtension = + lastDotIndex > 0 ? file.substring(lastDotIndex + 1) : ""; + const component = projectCode.find( + (item) => item.name === fileName && item.extension === fileExtension, + ); + if (component && component.content) { + const lines = component.content.split("\n"); + // line numbers are 1-indexed, array is 0-indexed + if (line > 0 && line <= lines.length) { + errorLine = lines[line - 1]; + } + } + } + const { createError } = ApiCallHandler({ reactAppApiEndpoint, }); @@ -210,6 +233,8 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { } dispatch(setError(errorMessage)); + dispatch(setErrorLine(errorLine)); + dispatch(setErrorLineNumber(errorLineNumber)); disableInput(); }; @@ -256,6 +281,8 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { const handleRun = async () => { output.current.innerHTML = ""; dispatch(setError("")); + dispatch(setErrorLine("")); + dispatch(setErrorLineNumber(null)); setVisuals([]); stdinClosed.current = false; diff --git a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx index 0e19c63c7..a0abf4439 100644 --- a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx @@ -10,6 +10,8 @@ import classNames from "classnames"; import { setError, setErrorDetails, + setErrorLine, + setErrorLineNumber, codeRunHandled, stopDraw, setSenseHatEnabled, @@ -161,6 +163,8 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { input.removeAttribute("id"); input.removeAttribute("contentEditable"); dispatch(setError(t("output.errors.interrupted"))); + dispatch(setErrorLine("")); + dispatch(setErrorLineNumber(null)); dispatch(codeRunHandled()); } }, [codeRunStopped]); @@ -329,6 +333,9 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { let errorDetails = {}; let errorMessage; let explanation; + let errorLine = ""; + let errorLineNumber = null; + if (err.message === t("output.errors.interrupted")) { errorMessage = err.message; errorDetails = { @@ -343,6 +350,25 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { const lineNumber = err.traceback[0].lineno; const fileName = err.traceback[0].filename.replace(/^\.\//, ""); + if (lineNumber && fileName) { + errorLineNumber = lineNumber; + const lastDotIndex = fileName.lastIndexOf("."); + const name = + lastDotIndex > 0 ? fileName.substring(0, lastDotIndex) : fileName; + const extension = + lastDotIndex > 0 ? fileName.substring(lastDotIndex + 1) : ""; + const component = projectCode.find( + (item) => item.name === name && item.extension === extension, + ); + if (component && component.content) { + const lines = component.content.split("\n"); + // line numbers are 1-indexed, array is 0-indexed + if (lineNumber > 0 && lineNumber <= lines.length) { + errorLine = lines[lineNumber - 1]; + } + } + } + if (errorType === "ImportError" && window.crossOriginIsolated) { const articleLink = `https://help.editor.raspberrypi.org/hc/en-us/articles/30841379339924-What-Python-libraries-are-available-in-the-Code-Editor`; const moduleName = errorDescription.replace(/No module named /, ""); @@ -375,6 +401,8 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { dispatch(setError(errorMessage)); dispatch(setErrorDetails(errorDetails)); + dispatch(setErrorLine(errorLine)); + dispatch(setErrorLineNumber(errorLineNumber)); dispatch(stopDraw()); if (getInput()) { const input = getInput(); @@ -387,6 +415,8 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { // clear previous output dispatch(setError("")); dispatch(setErrorDetails({})); + dispatch(setErrorLine("")); + dispatch(setErrorLineNumber(null)); if (output.current) { output.current.innerHTML = ""; } diff --git a/src/components/Editor/Runners/PythonRunner/VisualOutputPane.jsx b/src/components/Editor/Runners/PythonRunner/VisualOutputPane.jsx index 86e405b2d..c75e4a2be 100644 --- a/src/components/Editor/Runners/PythonRunner/VisualOutputPane.jsx +++ b/src/components/Editor/Runners/PythonRunner/VisualOutputPane.jsx @@ -4,7 +4,12 @@ import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; import Sk from "skulpt"; import AstroPiModel from "../../../AstroPiModel/AstroPiModel"; -import { codeRunHandled, setError } from "../../../../redux/EditorSlice"; +import { + codeRunHandled, + setError, + setErrorLine, + setErrorLineNumber, +} from "../../../../redux/EditorSlice"; const VisualOutputPane = () => { const codeRunTriggered = useSelector( @@ -68,6 +73,8 @@ const VisualOutputPane = () => { if (error === "") { dispatch(setError(t("output.errors.interrupted"))); + dispatch(setErrorLine("")); + dispatch(setErrorLineNumber(null)); } dispatch(codeRunHandled()); } diff --git a/src/components/Modals/ErrorModal.jsx b/src/components/Modals/ErrorModal.jsx index 9a3918b97..5a299e683 100644 --- a/src/components/Modals/ErrorModal.jsx +++ b/src/components/Modals/ErrorModal.jsx @@ -5,7 +5,12 @@ import { useTranslation } from "react-i18next"; import PropTypes from "prop-types"; import Button from "../Button/Button"; -import { closeErrorModal, setError } from "../../redux/EditorSlice"; +import { + closeErrorModal, + setError, + setErrorLine, + setErrorLineNumber, +} from "../../redux/EditorSlice"; import "../../assets/stylesheets/Modal.scss"; const ErrorModal = ({ errorType, additionalOnClose }) => { @@ -21,6 +26,8 @@ const ErrorModal = ({ errorType, additionalOnClose }) => { additionalOnClose(); } dispatch(setError(null)); + dispatch(setErrorLine("")); + dispatch(setErrorLineNumber(null)); }; return ( diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js index c53751b09..920d7dc91 100644 --- a/src/redux/EditorSlice.js +++ b/src/redux/EditorSlice.js @@ -93,6 +93,17 @@ export const loadProjectList = createAsyncThunk( }, ); +// Helper function to extract all Python code from components +const getAllPythonCode = (components) => { + if (!components || !Array.isArray(components)) { + return ""; + } + return components + .filter((component) => component.extension === "py") + .map((component) => component.content || "") + .join("\n"); +}; + export const editorInitialState = { project: {}, cascadeUpdate: false, @@ -137,6 +148,9 @@ export const editorInitialState = { sidebarShowing: true, modals: {}, errorDetails: {}, + errorLine: "", + errorLineNumber: null, + code: "", runnerBeingLoaded: null | "pyodide" | "skulpt", }; @@ -199,6 +213,7 @@ export const EditorSlice = createSlice({ content: action.payload.content || "", }); state.saving = "idle"; + state.code = getAllPythonCode(state.project.components); }, setPage: (state, action) => { state.page = action.payload; @@ -235,6 +250,7 @@ export const EditorSlice = createSlice({ state.openFiles[firstPanelIndex].push("main.py"); } state.justLoaded = true; + state.code = getAllPythonCode(state.project.components); }, setProjectInstructions: (state, action) => { state.project.instructions = action.payload; @@ -283,6 +299,7 @@ export const EditorSlice = createSlice({ }); state.project.components = mapped; state.cascadeUpdate = cascadeUpdate; + state.code = getAllPythonCode(state.project.components); }, updateProjectName: (state, action) => { state.project.name = action.payload; @@ -303,6 +320,7 @@ export const EditorSlice = createSlice({ state.openFiles[panelIndex][fileIndex] = `${name}.${extension}`; } state.saving = "idle"; + state.code = getAllPythonCode(state.project.components); }, setCascadeUpdate: (state, action) => { state.cascadeUpdate = action.payload; @@ -310,6 +328,12 @@ export const EditorSlice = createSlice({ setError: (state, action) => { state.error = action.payload; }, + setErrorLine: (state, action) => { + state.errorLine = action.payload; + }, + setErrorLineNumber: (state, action) => { + state.errorLineNumber = action.payload; + }, triggerCodeRun: (state) => { state.codeRunTriggered = true; state.codeHasBeenRun = true; @@ -452,6 +476,8 @@ export const { setBrowserPreview, setCascadeUpdate, setError, + setErrorLine, + setErrorLineNumber, setIsSplitView, setNameError, setHasShownSavePrompt, diff --git a/src/utils/externalLinkHelper.js b/src/utils/externalLinkHelper.js index 9730b9574..ac8e23cf6 100644 --- a/src/utils/externalLinkHelper.js +++ b/src/utils/externalLinkHelper.js @@ -1,7 +1,12 @@ import { useState } from "react"; import { useDispatch } from "react-redux"; -import { setError, triggerCodeRun } from "../redux/EditorSlice"; +import { + setError, + setErrorLine, + setErrorLineNumber, + triggerCodeRun, +} from "../redux/EditorSlice"; const domain = "https://rpf.io/"; const host = process.env.PUBLIC_URL || "http://localhost:3011"; @@ -27,6 +32,8 @@ const useExternalLinkState = (showModal) => { const handleExternalLinkError = () => { dispatch(setError("externalLink")); + dispatch(setErrorLine("")); + dispatch(setErrorLineNumber(null)); showModal(); }; diff --git a/webpack.config.js b/webpack.config.js index fdfdb5ca7..5680eb3aa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -82,6 +82,12 @@ module.exports = { ], }, resolve: { + alias: { + "@raspberrypifoundation/python-friendly-error-messages": path.resolve( + __dirname, + "node_modules/@raspberrypifoundation/python-friendly-error-messages/dist/index.browser.js", + ), + }, extensions: [".*", ".js", ".jsx", ".css"], fallback: { stream: require.resolve("stream-browserify"), diff --git a/yarn.lock b/yarn.lock index 0167075a7..62622d978 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2963,6 +2963,7 @@ __metadata: "@lezer/highlight": ^1.0.0 "@pmmmwh/react-refresh-webpack-plugin": 0.4.3 "@raspberrypifoundation/design-system-react": ^2.7.0 + "@raspberrypifoundation/python-friendly-error-messages": ^0.1.6 "@react-three/drei": 9.114.3 "@react-three/fiber": ^8.0.13 "@react-three/test-renderer": 8.2.1 @@ -3117,6 +3118,13 @@ __metadata: languageName: unknown linkType: soft +"@raspberrypifoundation/python-friendly-error-messages@npm:^0.1.6": + version: 0.1.6 + resolution: "@raspberrypifoundation/python-friendly-error-messages@npm:0.1.6" + checksum: 5270aefc908c5c78cc01ab820f45d19ab6d035081b2a5465ba577103486bb4392d1cd42fec04cf3834b9ff5c6af4a1c22c271d691a2f3cf367ba32cbfc01df9f + languageName: node + linkType: hard + "@react-spring/animated@npm:~9.6.1": version: 9.6.1 resolution: "@react-spring/animated@npm:9.6.1"