Skip to content

Commit f63926e

Browse files
loiswells97sra405
andauthored
Limit editor file size (#1138)
Co-authored-by: Scott Adams <[email protected]>
1 parent 1455d32 commit f63926e

16 files changed

+315
-24
lines changed

.github/workflows/deploy.yml

+4-3
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,10 @@ jobs:
125125

126126
- name: Build WC bundle
127127
run: |
128-
if [[ "${{ inputs.environment }}" != "production" ]]; then
129-
yarn build:dev
130-
fi
128+
# TODO: Reinitialise when storybook build is fixed
129+
# if [[ "${{ inputs.environment }}" != "production" ]]; then
130+
# yarn build:dev
131+
# fi
131132
yarn build
132133
env:
133134
PUBLIC_URL: ${{ needs.setup-environment.outputs.public_url }}

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1111
### Fixed
1212

1313
- Bug causing py-enigma code to disable stop button
14+
- Crashing caused by excessive file sizes (#1138)
1415

1516
### Changed
1617

cypress/e2e/spec-wc-skulpt.cy.js

+10-4
Original file line numberDiff line numberDiff line change
@@ -121,25 +121,31 @@ describe("Running the code with skulpt", () => {
121121
runCode("import sense_hat");
122122
cy.get("editor-wc")
123123
.shadow()
124-
.find("#root")
124+
.find(".skulptrunner")
125125
.should("contain", "Visual output");
126126
});
127127

128128
it("does not render astro pi component on page load", () => {
129-
cy.get("editor-wc").shadow().find("#root").should("not.contain", "yaw");
129+
cy.get("editor-wc")
130+
.shadow()
131+
.find(".skulptrunner")
132+
.should("not.contain", "yaw");
130133
});
131134

132135
it("renders astro pi component if sense hat imported", () => {
133136
runCode("import sense_hat");
134137
cy.get("editor-wc").shadow().contains("Visual output").click();
135-
cy.get("editor-wc").shadow().find("#root").should("contain", "yaw");
138+
cy.get("editor-wc").shadow().find(".skulptrunner").should("contain", "yaw");
136139
});
137140

138141
it("does not render astro pi component if sense hat unimported", () => {
139142
runCode("import sense_hat");
140143
runCode("import p5");
141144
cy.get("editor-wc").shadow().contains("Visual output").click();
142-
cy.get("editor-wc").shadow().find("#root").should("not.contain", "yaw");
145+
cy.get("editor-wc")
146+
.shadow()
147+
.find(".skulptrunner")
148+
.should("not.contain", "yaw");
143149
});
144150

145151
it("runs a simple turtle program", () => {

package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
"@hello-pangea/dnd": "^16.2.0",
1616
"@juggle/resize-observer": "^3.3.1",
1717
"@lezer/highlight": "^1.0.0",
18-
"@raspberrypifoundation/design-system-core": "^0.1.9",
19-
"@raspberrypifoundation/design-system-react": "^0.1.5",
18+
"@raspberrypifoundation/design-system-core": "^1.6.0",
19+
"@raspberrypifoundation/design-system-react": "^1.6.0",
2020
"@react-three/drei": "9.114.3",
2121
"@react-three/fiber": "^8.0.13",
2222
"@reduxjs/toolkit": "^1.6.2",
@@ -45,6 +45,7 @@
4545
"js-convert-case": "^4.2.0",
4646
"jszip": "^3.10.1",
4747
"jszip-utils": "^0.1.0",
48+
"material-symbols": "^0.27.0",
4849
"mime-types": "^2.1.35",
4950
"node-html-parser": "^6.1.5",
5051
"oidc-client": "^1.11.5",

src/assets/stylesheets/EditorPanel.scss

+4
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,7 @@
3939
@include font-size-2(regular);
4040
}
4141
}
42+
43+
.rpf-alert {
44+
margin: 0;
45+
}

src/assets/stylesheets/ExternalStyles.scss

+2
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@
22
@use "../../../node_modules/react-toggle/style.css";
33
@use "../../../node_modules/prismjs/plugins/line-numbers/prism-line-numbers.css";
44
@use "../../../node_modules/prismjs/plugins/line-highlight/prism-line-highlight.css";
5+
@use "../../../node_modules/@raspberrypifoundation/design-system-core/scss/components/alert.scss";
6+
@use "../../../node_modules/material-symbols/sharp.scss";

src/assets/stylesheets/Tabs.scss

+2
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
padding-inline-end: 0;
7373
}
7474
}
75+
7576
&__tab-close-btn {
7677
block-size: 100%;
7778
padding: $space-0-25;
@@ -112,6 +113,7 @@
112113
&__tab-panel--selected {
113114
flex: 1;
114115
display: flex;
116+
flex-direction: column;
115117
overflow-y: auto;
116118
}
117119
}

src/components/Editor/EditorPanel/EditorPanel.jsx

+29-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable react-hooks/exhaustive-deps */
22
import "../../../assets/stylesheets/EditorPanel.scss";
3-
import React, { useRef, useEffect, useContext } from "react";
3+
import React, { useRef, useEffect, useContext, useState } from "react";
44
import { useSelector, useDispatch } from "react-redux";
55
import { updateProjectComponent } from "../../../redux/EditorSlice";
66
import { useCookies } from "react-cookie";
@@ -11,16 +11,20 @@ import { EditorState } from "@codemirror/state";
1111
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
1212
import { indentationMarkers } from "@replit/codemirror-indentation-markers";
1313
import { indentUnit } from "@codemirror/language";
14+
import "material-symbols";
1415

1516
import { html } from "@codemirror/lang-html";
1617
import { css } from "@codemirror/lang-css";
1718
import { python } from "@codemirror/lang-python";
1819
import { javascript } from "@codemirror/lang-javascript";
1920

21+
import { Alert } from "@raspberrypifoundation/design-system-react";
2022
import { editorLightTheme } from "../../../assets/themes/editorLightTheme";
2123
import { editorDarkTheme } from "../../../assets/themes/editorDarkTheme";
2224
import { SettingsContext } from "../../../utils/settings";
2325

26+
const MAX_CHARACTERS = 8500000;
27+
2428
const EditorPanel = ({ extension = "html", fileName = "index" }) => {
2529
const editor = useRef();
2630
const project = useSelector((state) => state.editor.project);
@@ -29,6 +33,7 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => {
2933
const dispatch = useDispatch();
3034
const { t } = useTranslation();
3135
const settings = useContext(SettingsContext);
36+
const [characterLimitExceeded, setCharacterLimitExceeded] = useState(false);
3237

3338
const updateStoredProject = (content) => {
3439
dispatch(
@@ -86,6 +91,16 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => {
8691
customIndentUnit = " ";
8792
}
8893

94+
const limitCharacters = EditorState.transactionFilter.of((transaction) => {
95+
const newDoc = transaction.newDoc;
96+
if (newDoc.length > MAX_CHARACTERS) {
97+
setCharacterLimitExceeded(true);
98+
return [];
99+
}
100+
setCharacterLimitExceeded(false);
101+
return transaction;
102+
});
103+
89104
const startState = EditorState.create({
90105
doc: code,
91106
extensions: [
@@ -98,6 +113,7 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => {
98113
indentationMarkers(),
99114
indentUnit.of(customIndentUnit),
100115
EditorView.editable.of(!readOnly),
116+
limitCharacters,
101117
],
102118
});
103119

@@ -123,7 +139,18 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => {
123139
}, [cookies]);
124140

125141
return (
126-
<div className={`editor editor--${settings.fontSize}`} ref={editor}></div>
142+
<>
143+
<div className={`editor editor--${settings.fontSize}`} ref={editor}></div>
144+
{characterLimitExceeded && (
145+
<Alert
146+
title={t("editorPanel.characterLimitError")}
147+
type="error"
148+
text={t("editorPanel.characterLimitExplanation", {
149+
maxCharacters: MAX_CHARACTERS,
150+
})}
151+
/>
152+
)}
153+
</>
127154
);
128155
};
129156

src/components/Editor/EditorPanel/EditorPanel.test.js

+46-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import configureStore from "redux-mock-store";
22
import { Provider } from "react-redux";
33
import { SettingsContext } from "../../../utils/settings";
4-
import { render, screen } from "@testing-library/react";
4+
import { fireEvent, render, screen } from "@testing-library/react";
55
import { axe, toHaveNoViolations } from "jest-axe";
66
import EditorPanel from "./EditorPanel";
77

@@ -88,3 +88,48 @@ describe("When read only", () => {
8888
expect(editorInputArea).toHaveAttribute("contenteditable", "false");
8989
});
9090
});
91+
92+
describe("When excessive file content is pasted into the editor", () => {
93+
beforeEach(() => {
94+
renderEditorPanel({ readOnly: false });
95+
const editorInputArea = screen.getByLabelText("editorPanel.ariaLabel");
96+
const massiveFileContent = "mango".repeat(2000000);
97+
fireEvent.paste(editorInputArea, {
98+
clipboardData: {
99+
getData: () => massiveFileContent,
100+
},
101+
});
102+
});
103+
104+
test("It does not display the file content", () => {
105+
expect(screen.queryByText(/mango/)).not.toBeInTheDocument();
106+
});
107+
108+
test("Character limit exceeded message is displayed", () => {
109+
expect(
110+
screen.getByText("editorPanel.characterLimitError"),
111+
).toBeInTheDocument();
112+
});
113+
114+
test("It allows the user to input text below the limit", () => {
115+
const editorInputArea = screen.getByLabelText("editorPanel.ariaLabel");
116+
fireEvent.paste(editorInputArea, {
117+
clipboardData: {
118+
getData: () => "mango",
119+
},
120+
});
121+
expect(screen.getByText("mango")).toBeInTheDocument();
122+
});
123+
124+
test("It removes the character limit exceeded message when the user inputs text below the limit", () => {
125+
const editorInputArea = screen.getByLabelText("editorPanel.ariaLabel");
126+
fireEvent.paste(editorInputArea, {
127+
clipboardData: {
128+
getData: () => "mango",
129+
},
130+
});
131+
expect(
132+
screen.queryByText("editorPanel.characterLimitError"),
133+
).not.toBeInTheDocument();
134+
});
135+
});

src/hooks/useProjectPersistence.js

+10-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
} from "../redux/EditorSlice";
99
import { showLoginPrompt, showSavePrompt } from "../utils/Notifications";
1010

11+
const COMBINED_FILE_SIZE_SOFT_LIMIT = 1000000;
12+
1113
export const useProjectPersistence = ({
1214
user,
1315
project = {},
@@ -18,6 +20,13 @@ export const useProjectPersistence = ({
1820
}) => {
1921
const dispatch = useDispatch();
2022

23+
const combinedFileSize = project.components?.reduce(
24+
(sum, component) => sum + component.content.length,
25+
0,
26+
);
27+
const autoSaveInterval =
28+
combinedFileSize > COMBINED_FILE_SIZE_SOFT_LIMIT ? 10000 : 2000;
29+
2130
const saveToLocalStorage = (project) => {
2231
localStorage.setItem(
2332
project.identifier || "project",
@@ -90,7 +99,7 @@ export const useProjectPersistence = ({
9099
}
91100
}
92101
}
93-
}, 2000);
102+
}, autoSaveInterval);
94103

95104
return () => clearTimeout(debouncer);
96105
}, [dispatch, project, user, hasShownSavePrompt]); // eslint-disable-line react-hooks/exhaustive-deps

src/hooks/useProjectPersistence.test.js

+28
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,34 @@ describe("When logged in", () => {
314314
});
315315
});
316316

317+
test("Increases save interval for large projects", async () => {
318+
const largeProject = {
319+
...project,
320+
components: [
321+
{
322+
name: "main",
323+
extension: "py",
324+
content: "mango".repeat(200001),
325+
},
326+
],
327+
};
328+
renderHook(() =>
329+
useProjectPersistence({
330+
user: user1,
331+
project: largeProject,
332+
saveTriggered: false,
333+
}),
334+
);
335+
jest.advanceTimersByTime(2500);
336+
expect(saveProject).not.toHaveBeenCalled();
337+
jest.runAllTimers();
338+
expect(saveProject).toHaveBeenCalledWith({
339+
project: largeProject,
340+
accessToken: user1.access_token,
341+
autosave: true,
342+
});
343+
});
344+
317345
test("Saves project to database if save triggered", async () => {
318346
renderHook(() =>
319347
useProjectPersistence({

src/utils/i18n.js

+3
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ i18n
123123
},
124124
editorPanel: {
125125
ariaLabel: "editor text input",
126+
characterLimitError: "Error: Character limit reached",
127+
characterLimitExplanation:
128+
"Files in the editor are limited to {{maxCharacters}} characters",
126129
viewOnly: "View only",
127130
},
128131
filePanel: {

storybook/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
"build-storybook": "build-storybook"
88
},
99
"dependencies": {
10+
"@raspberrypifoundation/design-system-core": "^1.6.0",
11+
"@raspberrypifoundation/design-system-react": "^1.6.0",
1012
"@reduxjs/toolkit": "^1.8.3",
1113
"@storybook/addon-actions": "6.5.10",
1214
"@storybook/addon-essentials": "6.5.10",

0 commit comments

Comments
 (0)