diff --git a/CHANGELOG.md b/CHANGELOG.md index e10c5248a..b3d7fb495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +## [0.30.0] - 2025-04-15 + +### Added + +- `editor-projectLoadFailed` custom event that fires when a project completely fails to load (#1201) +- Added runnerBeingLoaded state to prevent race condition overwrites (#1205) + +### Fixed + +- Bugs in append mode for writing to files in python (#1200) +- `turtle` bug that did not display output on first code run (#1203) + +## [0.29.1] - 2025-02-21 + ### Fixed - Fixed sidebar not correctly reopening (#1196) @@ -1066,7 +1080,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Events in Web Component indicating whether Mission Zero criteria have been met (#113) -[unreleased]: https://github.com/RaspberryPiFoundation/editor-ui/compare/v0.29.0...HEAD +[unreleased]: https://github.com/RaspberryPiFoundation/editor-ui/compare/v0.29.1...HEAD +[0.30.0]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.30.0 +[0.29.1]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.29.1 [0.29.0]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.29.0 [0.28.14]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.28.14 [0.28.13]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.28.13 diff --git a/cypress/e2e/spec-wc-skulpt.cy.js b/cypress/e2e/spec-wc-skulpt.cy.js index 5b733ca8a..db79cf1b4 100644 --- a/cypress/e2e/spec-wc-skulpt.cy.js +++ b/cypress/e2e/spec-wc-skulpt.cy.js @@ -38,7 +38,7 @@ describe("Running the code with skulpt", () => { .shadow() .find(".skulptrunner") .contains(".react-tabs__tab", "Visual output") - .should("not.exist"); + .should("not.be.visible"); cy.get("editor-wc") .shadow() .find(".skulptrunner") diff --git a/package.json b/package.json index af4712906..63d3a2617 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@raspberrypifoundation/editor-ui", - "version": "0.29.0", + "version": "0.30.0", "private": true, "dependencies": { "@apollo/client": "^3.7.8", diff --git a/src/PyodideWorker.js b/src/PyodideWorker.js index 200c742b0..811467bb3 100644 --- a/src/PyodideWorker.js +++ b/src/PyodideWorker.js @@ -116,7 +116,7 @@ const PyodideWorker = () => { self.content += content if len(self.content) > MAX_FILE_SIZE: raise OSError(f"File '{self.filename}' exceeds maximum file size of {MAX_FILE_SIZE} bytes") - with _original_open(self.filename, "w") as f: + with _original_open(self.filename, mode) as f: f.write(self.content) basthon.kernel.write_file({ "filename": self.filename, "content": self.content, "mode": mode }) diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx index 50594e250..0d39afe80 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx @@ -223,7 +223,7 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { updatedContent = content; } else if (mode === "a") { updatedContent = - (componentToUpdate ? componentToUpdate.content + "\n" : "") + content; + (componentToUpdate ? componentToUpdate.content : "") + content; } if (componentToUpdate) { diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js index bea6c9264..fef816ebe 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js @@ -307,7 +307,7 @@ describe("When file write event is received", () => { worker.postMessageFromWorker({ method: "handleFileWrite", filename: "existing_file.txt", - content: "new content", + content: "\nnew content", mode: "a", }); expect(dispatchSpy).toHaveBeenCalledWith({ @@ -364,7 +364,7 @@ describe("When file write event is received", () => { worker.postMessageFromWorker({ method: "handleFileWrite", filename: "existing_file.txt", - content: "new content", + content: "\nnew content", mode: "a", }); expect(dispatchSpy).toHaveBeenCalledWith({ diff --git a/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx b/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx index 85baefbbf..60ede272e 100644 --- a/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx @@ -6,6 +6,7 @@ import { useDispatch, useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; import { loadingRunner } from "../../../../redux/EditorSlice"; +import { getPythonImports } from "../../../../utils/getPythonImports"; const SKULPT_ONLY_MODULES = [ "p5", @@ -45,33 +46,11 @@ const PythonRunner = ({ outputPanels = ["text", "visual"] }) => { setSkulptFallback(true); return; } - const getImports = (code) => { - const codeWithoutMultilineStrings = code.replace( - /'''[\s\S]*?'''|"""[\s\S]*?"""/gm, - "", - ); - const importRegex = - /(?<=^\s*)(from\s+([a-zA-Z0-9_.]+)(\s+import\s+([a-zA-Z0-9_.]+))?)|(?<=^\s*)(import\s+([a-zA-Z0-9_.]+))/gm; - const matches = codeWithoutMultilineStrings.match(importRegex); - const imports = matches - ? matches.map( - (match) => - match - .split(/from|import/) - .filter(Boolean) - .map((s) => s.trim())[0], - ) - : []; - if (code.includes(`# ${t("input.comment.py5")}`)) { - imports.push("py5_imported"); - } - return imports; - }; for (const component of project.components || []) { if (component.extension === "py" && !codeRunTriggered) { try { - const imports = getImports(component.content); + const imports = getPythonImports(component.content, t); const hasSkulptOnlyModules = imports.some((name) => SKULPT_ONLY_MODULES.includes(name), ); diff --git a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx index 99c548d78..3fbe45cfe 100644 --- a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx @@ -24,6 +24,7 @@ import OutputViewToggle from "../OutputViewToggle"; import { SettingsContext } from "../../../../../utils/settings"; import RunnerControls from "../../../../RunButton/RunnerControls"; import { MOBILE_MEDIA_QUERY } from "../../../../../utils/mediaQueryBreakpoints"; +import { getPythonImports } from "../../../../../utils/getPythonImports"; const externalLibraries = { "./pygal/__init__.js": { @@ -55,6 +56,15 @@ const externalLibraries = { }, }; +const VISUAL_LIBRARIES = [ + "pygal", + "py5", + "py5_imported", + "p5", + "sense_hat", + "turtle", +]; + const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { const loadedRunner = useSelector((state) => state.editor.loadedRunner); const projectCode = useSelector((state) => state.editor.project.components); @@ -83,10 +93,31 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { const settings = useContext(SettingsContext); const isMobile = useMediaQuery({ query: MOBILE_MEDIA_QUERY }); + const project = useSelector((state) => state.editor.project); + + const testForVisualImports = (project) => { + for (const component of project.components || []) { + if (component.extension === "py") { + try { + const imports = getPythonImports(component.content, t); + const hasVisualImports = imports.some((name) => + VISUAL_LIBRARIES.includes(name), + ); + if (hasVisualImports) { + return true; + } + } catch (error) { + console.error("Error occurred while getting imports:", error); + } + } + } + return false; + }; + const [codeHasVisualOutput, setCodeHasVisualOutput] = useState( - senseHatAlwaysEnabled, + !!senseHatAlwaysEnabled || testForVisualImports(project), ); - const [showVisualOutput, setShowVisualOutput] = useState(true); + const [showVisualOutput, setShowVisualOutput] = useState(codeHasVisualOutput); const getInput = () => { const pageInput = document.getElementById("input"); @@ -96,6 +127,14 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { return pageInput || webComponentInput; }; + useEffect(() => { + if (!codeRunTriggered) { + setCodeHasVisualOutput( + !!senseHatAlwaysEnabled || testForVisualImports(project), + ); + } + }, [project, codeRunTriggered, senseHatAlwaysEnabled, t]); + useEffect(() => { if (active && loadedRunner !== "skulpt") { dispatch(setLoadedRunner("skulpt")); @@ -136,15 +175,6 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { } }, [drawTriggered, codeRunTriggered]); - const visualLibraries = [ - "./pygal/__init__.js", - "./py5/__init__.js", - "./py5_imported/__init__.js", - "./p5/__init__.js", - "./_internal_sense_hat/__init__.js", - "src/builtin/turtle/__init__.js", - ]; - const outf = (text) => { if (text !== "") { const node = output.current; @@ -169,10 +199,6 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { // TODO: Handle pre-importing py5_imported when refactored py5 shim imported - if (visualLibraries.includes(library)) { - setCodeHasVisualOutput(true); - } - let localProjectFiles = projectCode .filter((component) => component.name !== "main") .map((component) => `./${component.name}.py`); @@ -436,6 +462,18 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { }); }; + const [tabbedViewSelectedIndex, setTabbedViewSelectedIndex] = useState( + showVisualOutput ? 0 : 1, + ); + + useEffect(() => { + if (showVisualOutput) { + setTabbedViewSelectedIndex(0); + } else { + setTabbedViewSelectedIndex(1); + } + }, [showVisualOutput]); + return (
{ > {isSplitView || singleOutputPanel ? ( <> - {showVisualOutput && showVisualOutputPanel && ( -
+ {showVisualOutputPanel && ( +
{
- {showVisualOutput ? ( - - - {t("output.visualOutput")} - - - ) : null} + + + {t("output.visualOutput")} + + {t("output.textOutput")} @@ -523,11 +564,9 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { {!isEmbedded && isMobile && }
{!isOutputOnly && } - {showVisualOutput ? ( - - - - ) : null} + + +
 {
             {
               name: "main",
               extension: "py",
-              content: "import py5_imported",
+              content: "# input.comment.py5",
             },
           ],
           image_list: [],
@@ -563,7 +563,7 @@ describe("When in split view, sense_hat imported and code run", () => {
             {
               name: "main",
               extension: "py",
-              content: "import _internal_sense_hat",
+              content: "import sense_hat",
             },
           ],
           image_list: [],
@@ -675,7 +675,7 @@ describe("When in tabbed view, py5 imported and code run", () => {
     ));
   });
 
-  test("Output view toggle not shown", () => {
+  test("Output view toggle is shown", () => {
     expect(
       screen.queryByText("outputViewToggle.buttonSplitLabel"),
     ).toBeInTheDocument();
@@ -704,7 +704,7 @@ describe("When in tabbed view, py5_imported imported and code run", () => {
             {
               name: "main",
               extension: "py",
-              content: "import py5_imported",
+              content: "# input.comment.py5",
             },
           ],
           image_list: [],
@@ -724,7 +724,7 @@ describe("When in tabbed view, py5_imported imported and code run", () => {
     ));
   });
 
-  test("Output view toggle not shown", () => {
+  test("Output view toggle is shown", () => {
     expect(
       screen.queryByText("outputViewToggle.buttonSplitLabel"),
     ).toBeInTheDocument();
@@ -769,7 +769,7 @@ describe("When in tabbed view, pygal imported and code run", () => {
     ));
   });
 
-  test("Output view toggle not shown", () => {
+  test("Output view toggle is shown", () => {
     expect(
       screen.queryByText("outputViewToggle.buttonSplitLabel"),
     ).toBeInTheDocument();
@@ -814,7 +814,8 @@ describe("When in tabbed view, turtle imported and code run", () => {
     ));
   });
 
-  test("Output view toggle not shown", () => {
+  test("Output view toggle is shown", () => {
+    screen.debug();
     expect(
       screen.queryByText("outputViewToggle.buttonSplitLabel"),
     ).toBeInTheDocument();
@@ -839,7 +840,7 @@ describe("When in tabbed view, sense_hat imported and code run", () => {
             {
               name: "main",
               extension: "py",
-              content: "import _internal_sense_hat",
+              content: "import sense_hat",
             },
           ],
           image_list: [],
@@ -859,7 +860,7 @@ describe("When in tabbed view, sense_hat imported and code run", () => {
     ));
   });
 
-  test("Output view toggle not shown", () => {
+  test("Output view toggle is shown", () => {
     expect(
       screen.queryByText("outputViewToggle.buttonSplitLabel"),
     ).toBeInTheDocument();
diff --git a/src/containers/WebComponentLoader.jsx b/src/containers/WebComponentLoader.jsx
index d6cae9700..adaee80ba 100644
--- a/src/containers/WebComponentLoader.jsx
+++ b/src/containers/WebComponentLoader.jsx
@@ -26,7 +26,10 @@ import externalStyles from "../assets/stylesheets/ExternalStyles.scss";
 import editorStyles from "../assets/stylesheets/index.scss";
 import "../assets/stylesheets/Notifications.scss";
 import Style from "style-it";
-import { projectOwnerLoadedEvent } from "../events/WebComponentCustomEvents";
+import {
+  projectLoadFailed,
+  projectOwnerLoadedEvent,
+} from "../events/WebComponentCustomEvents";
 
 const WebComponentLoader = (props) => {
   const {
@@ -112,6 +115,12 @@ const WebComponentLoader = (props) => {
     }
   }, [loading, project]);
 
+  useEffect(() => {
+    if (loading === "failed" && !remixLoadFailed) {
+      document.dispatchEvent(projectLoadFailed);
+    }
+  }, [loading, remixLoadFailed]);
+
   useEffect(() => {
     if (justLoaded) {
       document.dispatchEvent(projectOwnerLoadedEvent(projectOwner));
diff --git a/src/containers/WebComponentLoader.test.js b/src/containers/WebComponentLoader.test.js
index c8a7df9d0..ed42a94c0 100644
--- a/src/containers/WebComponentLoader.test.js
+++ b/src/containers/WebComponentLoader.test.js
@@ -535,7 +535,7 @@ describe("When user is in state", () => {
       const mockStore = configureStore(middlewares);
       const initialState = {
         editor: {
-          loading: "idle",
+          loading: "failed",
           project: {
             components: [],
           },
@@ -581,6 +581,16 @@ describe("When user is in state", () => {
           reactAppApiEndpoint: "http://localhost:3009/",
         });
       });
+
+      test("Does not trigger project load failed event", () => {
+        expect(document.dispatchEvent).not.toHaveBeenCalledWith(
+          new CustomEvent("editor-projectLoadFailed", {
+            bubbles: true,
+            cancelable: false,
+            composed: true,
+          }),
+        );
+      });
     });
 
     afterEach(async () => {
@@ -588,4 +598,52 @@ describe("When user is in state", () => {
       await act(async () => cookies.remove("theme"));
     });
   });
+
+  describe("when a project fails to load", () => {
+    beforeEach(() => {
+      localStorage.setItem(authKey, JSON.stringify(user));
+      const middlewares = [];
+      const mockStore = configureStore(middlewares);
+      const initialState = {
+        editor: {
+          loading: "failed",
+          project: {
+            components: [],
+          },
+          openFiles: [],
+          focussedFileIndices: [],
+          hasShownSavePrompt: false,
+          remixLoadFailed: false,
+          justLoaded: false,
+          saveTriggered: false,
+        },
+        instructions: {},
+        auth: { user },
+      };
+      store = mockStore(initialState);
+      cookies = new Cookies();
+      render(
+        
+          
+            
+          
+        ,
+      );
+    });
+
+    test("triggers project load failed event", () => {
+      expect(document.dispatchEvent).toHaveBeenCalledWith(
+        new CustomEvent("editor-projectLoadFailed", {
+          bubbles: true,
+          cancelable: false,
+          composed: true,
+        }),
+      );
+    });
+  });
 });
diff --git a/src/events/WebComponentCustomEvents.js b/src/events/WebComponentCustomEvents.js
index 6378b294d..b79b52d20 100644
--- a/src/events/WebComponentCustomEvents.js
+++ b/src/events/WebComponentCustomEvents.js
@@ -15,6 +15,10 @@ export const navigateToProjectsPageEvent = webComponentCustomEvent(
 export const projectIdentifierChangedEvent = (detail) =>
   webComponentCustomEvent("editor-projectIdentifierChanged", detail);
 
+export const projectLoadFailed = webComponentCustomEvent(
+  "editor-projectLoadFailed",
+);
+
 export const projectOwnerLoadedEvent = (detail) =>
   webComponentCustomEvent("editor-projectOwnerLoaded", detail);
 
diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js
index be193b1f1..60f47acd1 100644
--- a/src/redux/EditorSlice.js
+++ b/src/redux/EditorSlice.js
@@ -140,6 +140,7 @@ export const editorInitialState = {
   sidebarShowing: true,
   modals: {},
   errorDetails: {},
+  runnerBeingLoaded: null | "pyodide" | "skulpt",
 };
 
 export const EditorSlice = createSlice({
@@ -323,12 +324,15 @@ export const EditorSlice = createSlice({
       state.drawTriggered = false;
     },
     loadingRunner: (state, action) => {
+      state.runnerBeingLoaded = action.payload;
       state.activeRunner = action.payload;
       state.codeRunLoading = true;
     },
     setLoadedRunner: (state, action) => {
-      state.loadedRunner = action.payload;
-      state.codeRunLoading = false;
+      if (state.runnerBeingLoaded === action.payload) {
+        state.loadedRunner = action.payload;
+        state.codeRunLoading = false;
+      }
     },
     resetRunner: (state) => {
       state.activeRunner = null;
@@ -339,6 +343,7 @@ export const EditorSlice = createSlice({
       state.codeRunLoading = false;
       state.codeRunTriggered = false;
       state.codeRunStopped = false;
+      state.runnerBeingLoaded = null;
     },
     closeAccessDeniedWithAuthModal: (state) => {
       state.accessDeniedWithAuthModalShowing = false;
diff --git a/src/utils/getPythonImports.js b/src/utils/getPythonImports.js
new file mode 100644
index 000000000..e06c4f383
--- /dev/null
+++ b/src/utils/getPythonImports.js
@@ -0,0 +1,22 @@
+export const getPythonImports = (code, t) => {
+  const codeWithoutMultilineStrings = code.replace(
+    /'''[\s\S]*?'''|"""[\s\S]*?"""/gm,
+    "",
+  );
+  const importRegex =
+    /(?<=^\s*)(from\s+([a-zA-Z0-9_.]+)(\s+import\s+([a-zA-Z0-9_.]+))?)|(?<=^\s*)(import\s+([a-zA-Z0-9_.]+))/gm;
+  const matches = codeWithoutMultilineStrings.match(importRegex);
+  const imports = matches
+    ? matches.map(
+        (match) =>
+          match
+            .split(/from|import/)
+            .filter(Boolean)
+            .map((s) => s.trim())[0],
+      )
+    : [];
+  if (code.includes(`# ${t("input.comment.py5")}`)) {
+    imports.push("py5_imported");
+  }
+  return imports;
+};