diff --git a/package-lock.json b/package-lock.json index 793b9677..7e2f5a34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shinylive", - "version": "0.2.8", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shinylive", - "version": "0.2.8", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@codemirror/autocomplete": "^6.4.2", @@ -61,6 +61,7 @@ "prettier-plugin-organize-imports": "^3.2.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hot-toast": "^2.4.1", "tsx": "^4.7.0", "typescript": "^5.3.3", "vscode-languageserver-protocol": "^3.17.5", @@ -4982,6 +4983,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", + "integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", + "dev": true, + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -8439,6 +8449,22 @@ "react": "^18.2.0" } }, + "node_modules/react-hot-toast": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", + "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", + "dev": true, + "dependencies": { + "goober": "^2.1.10" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-icons": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz", diff --git a/package.json b/package.json index aab249ba..5935706f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "prettier-plugin-organize-imports": "^3.2.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hot-toast": "^2.4.1", "tsx": "^4.7.0", "typescript": "^5.3.3", "vscode-languageserver-protocol": "^3.17.5", @@ -142,6 +143,5 @@ "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn" } - }, - "packageManager": "yarn@3.2.3" + } } diff --git a/src/Components/Editor.tsx b/src/Components/Editor.tsx index f55c87bb..988226f6 100644 --- a/src/Components/Editor.tsx +++ b/src/Components/Editor.tsx @@ -11,6 +11,7 @@ import "balloon-css"; import type { Zippable } from "fflate"; import { zipSync } from "fflate"; import * as React from "react"; +import toast, { Toaster } from "react-hot-toast"; import type * as LSP from "vscode-languageserver-protocol"; import * as fileio from "../fileio"; import { createUri } from "../language-server/client"; @@ -42,6 +43,10 @@ import { fileContentsToUrlStringInWebWorker, } from "./share"; +// If the file contents are larger than this value, then don't automatically +// update the URL hash when re-running the app. +const UPDATE_URL_SIZE_THRESHOLD = 250000; + export type EditorFile = | { name: string; @@ -120,6 +125,33 @@ export default function Editor({ // the Viewer component. const lspPathPrefix = `editor${editorInstanceId}/`; + // This tracks whether the files have changed since the the last time the user + // has run the app/code. This is used to determine whether to update the URL. + // It is different from `setFilesHaveChanged` which is passed in, because that + // tracks whether the files have changed since they were passed into the + // Editor component. + // + // If the Editor starts with a file, then you change it and re-run, then both + // the external `filesHaveChanged` and `filesHaveChangedSinceLastRun` will be + // true. But if you re-run it again without making changes, then + // `filesHaveChanged` will still be true, and `filesHaveChangedSinceLastRun` + // will be false. + const [filesHaveChangedSinceLastRun, setFilesHaveChangedSinceLastRun] = + React.useState(false); + + // This is a shortcut to indicate that the files have changed. See the comment + // for `setFilesHaveChangedSinceLastRun` to understand why this is needed. + const setFilesHaveChangedCombined = React.useCallback( + (value: boolean) => { + setFilesHaveChanged(value); + setFilesHaveChangedSinceLastRun(value); + }, + [setFilesHaveChanged, setFilesHaveChangedSinceLastRun], + ); + + const [hasShownUrlTooLargeMessage, setHasShownUrlTooLargeMessage] = + React.useState(false); + // Given a FileContent object, figure out which editor extensions to use. // Use a memoized function to maintain referentially stablity. const inferEditorExtensions = React.useCallback( @@ -136,7 +168,7 @@ export default function Editor({ getLanguageExtension(language), EditorView.updateListener.of((u: ViewUpdate) => { if (u.docChanged) { - setFilesHaveChanged(true); + setFilesHaveChangedCombined(true); } }), languageServerExtensions(lspClient, lspPathPrefix + file.name), @@ -145,7 +177,7 @@ export default function Editor({ ), ]; }, - [lineNumbers, setFilesHaveChanged, lspClient, lspPathPrefix], + [lineNumbers, setFilesHaveChangedCombined, lspClient, lspPathPrefix], ); const [cmView, setCmView] = React.useState(); @@ -154,7 +186,7 @@ export default function Editor({ currentFilesFromApp, cmView, inferEditorExtensions, - setFilesHaveChanged, + setFilesHaveChanged: setFilesHaveChangedCombined, lspClient, lspPathPrefix, }); @@ -190,17 +222,41 @@ export default function Editor({ syncActiveFileState(); const fileContents = editorFilesToFileContents(files); - if (updateUrlHashOnRerun) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - updateBrowserUrlHash(fileContents); + if (updateUrlHashOnRerun && filesHaveChangedSinceLastRun) { + const filesSize = fileContentsSize(fileContents); + + if ( + !hasShownUrlTooLargeMessage && + filesSize > UPDATE_URL_SIZE_THRESHOLD + ) { + toast( + "Auto-updating the app link is disabled because the app is very large. " + + "If you want the sharing URL, click the Share button.", + ); + setHasShownUrlTooLargeMessage(true); + } else { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + updateBrowserUrlHash(fileContents); + } } + setFilesHaveChangedCombined(false); + // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { await viewerMethods.stopApp(); await viewerMethods.runApp(fileContents); })(); - }, [viewerMethods, syncActiveFileState, files]); + }, [ + viewerMethods, + syncActiveFileState, + updateUrlHashOnRerun, + filesHaveChangedSinceLastRun, + setFilesHaveChangedCombined, + hasShownUrlTooLargeMessage, + setHasShownUrlTooLargeMessage, + files, + ]); // Run the entire current file in the terminal. const runAllCode = React.useCallback(() => { @@ -571,6 +627,13 @@ export default function Editor({ ) : null}
+ {floatingButtons ? (
{runButton}
) : null} @@ -653,6 +716,22 @@ function editorFilesToFflateZippable(files: EditorFile[]): Zippable { return res; } +// Get the size in bytes of the contents of a FileContent array. Note that this +// isn't exactly the size in bytes -- for text files, it counts the number of +// characters, but some could be multi-byte (and the size could vary depending +// on the encoding). But it's close enough for our purposes. +function fileContentsSize(files: FileContent[]): number { + let size = 0; + for (const file of files) { + if (file.type === "binary") { + size += file.content.length; + } else { + size += file.content.length; + } + } + return size; +} + // ============================================================================= // Misc utility functions // =============================================================================