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"