Skip to content

Commit

Permalink
Merge pull request #129 from posit-dev/update-url
Browse files Browse the repository at this point in the history
Update browser URL hash when user presses play button
  • Loading branch information
wch committed May 7, 2024
2 parents ada86e3 + 0e5a614 commit b8911ae
Show file tree
Hide file tree
Showing 9 changed files with 294 additions and 17 deletions.
30 changes: 28 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -142,6 +143,5 @@
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
},
"packageManager": "yarn@3.2.3"
}
}
1 change: 1 addition & 0 deletions scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ const buildmap = {
"src/pyodide-worker.ts",
"src/load-shinylive-sw.ts",
"src/run-python-blocks.ts",
"src/lzstring-worker.ts",
],
outdir: `${BUILD_DIR}/shinylive`,
// See note in esbuild.build() call above about why these are external.
Expand Down
9 changes: 8 additions & 1 deletion site_template/editor/index.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
Expand All @@ -18,6 +18,13 @@
allowCodeUrl: true,
allowGistUrl: true,
allowExampleUrl: true,
// This option causes shinylive to update the URL hash when the user
// clicks on the re-run button in the editor. It is false by default.
// It should be set to true only when the editor and viewer are used
// in a full-window configuration. If you are using the editor and
// viewer embedded in a larger page, it does not make sense to set
// this to true.
updateUrlHashOnRerun: true,
};

const appRoot = document.getElementById("root");
Expand Down
24 changes: 18 additions & 6 deletions site_template/examples/index.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
Expand All @@ -15,11 +15,23 @@
// reasons, if you enable any of these, then this site should be hosted on
// a separate domain or subdomain from other content. Otherwise the
// running of arbitrary code could be used, for example, to steal cookies.
runApp(appRoot, "examples-editor-terminal-viewer", {
allowCodeUrl: true,
allowGistUrl: true,
allowExampleUrl: true,
}, "{{APP_ENGINE}}");
runApp(
appRoot,
"examples-editor-terminal-viewer",
{
allowCodeUrl: true,
allowGistUrl: true,
allowExampleUrl: true,
// This option causes shinylive to update the URL hash when the user
// clicks on the re-run button in the editor. It is false by default.
// It should be set to true only when the editor and viewer are used
// in a full-window configuration. If you are using the editor and
// viewer embedded in a larger page, it does not make sense to set
// this to true.
updateUrlHashOnRerun: true,
},
"{{APP_ENGINE}}",
);
</script>
<link rel="stylesheet" href="../shinylive/style-resets.css" />
<link rel="stylesheet" href="../shinylive/shinylive.css" />
Expand Down
9 changes: 9 additions & 0 deletions src/Components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ type AppOptions = {

// In Viewer-only mode, should the header bar be shown?
showHeaderBar?: boolean;

// When the app is re-run from the Editor, should the URL hash be updated with
// the encoded version of the app?
updateUrlHashOnRerun?: boolean;
};

export type ProxyHandle = PyodideProxyHandle | WebRProxyHandle;
Expand Down Expand Up @@ -353,6 +357,7 @@ export function App({
file.name === "app.R" ||
file.name === "server.R",
)}
updateUrlHashOnRerun={appOptions.updateUrlHashOnRerun}
appEngine={appEngine}
/>
</React.Suspense>
Expand Down Expand Up @@ -400,6 +405,7 @@ export function App({
file.name === "app.R" ||
file.name === "server.R",
)}
updateUrlHashOnRerun={appOptions.updateUrlHashOnRerun}
appEngine={appEngine}
/>
</React.Suspense>
Expand Down Expand Up @@ -433,6 +439,7 @@ export function App({
terminalMethods={terminalMethods}
utilityMethods={utilityMethods}
runOnLoad={false}
updateUrlHashOnRerun={appOptions.updateUrlHashOnRerun}
appEngine={appEngine}
/>
</React.Suspense>
Expand All @@ -458,6 +465,7 @@ export function App({
lineNumbers={false}
showHeaderBar={false}
floatingButtons={true}
updateUrlHashOnRerun={appOptions.updateUrlHashOnRerun}
appEngine={appEngine}
/>
</React.Suspense>
Expand Down Expand Up @@ -495,6 +503,7 @@ export function App({
terminalMethods={terminalMethods}
utilityMethods={utilityMethods}
viewerMethods={viewerMethods}
updateUrlHashOnRerun={appOptions.updateUrlHashOnRerun}
appEngine={appEngine}
/>
</React.Suspense>
Expand Down
115 changes: 109 additions & 6 deletions src/Components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -36,7 +37,15 @@ import { languageServerExtensions } from "./codeMirror/language-server/lsp-exten
import { useTabbedCodeMirror } from "./codeMirror/useTabbedCodeMirror";
import * as cmUtils from "./codeMirror/utils";
import type { FileContent } from "./filecontent";
import { editorUrlPrefix, fileContentsToUrlString } from "./share";
import {
editorUrlPrefix,
fileContentsToUrlString,
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 =
| {
Expand Down Expand Up @@ -77,6 +86,7 @@ export default function Editor({
lineNumbers = true,
showHeaderBar = true,
floatingButtons = false,
updateUrlHashOnRerun = false,
appEngine,
}: {
currentFilesFromApp: FileContent[];
Expand All @@ -93,6 +103,7 @@ export default function Editor({
lineNumbers?: boolean;
showHeaderBar?: boolean;
floatingButtons?: boolean;
updateUrlHashOnRerun?: boolean;
appEngine: AppEngine;
}) {
// In the future, instead of directly instantiating the PyrightClient, it
Expand All @@ -114,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<boolean>(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<boolean>(false);

// Given a FileContent object, figure out which editor extensions to use.
// Use a memoized function to maintain referentially stablity.
const inferEditorExtensions = React.useCallback(
Expand All @@ -130,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),
Expand All @@ -139,7 +177,7 @@ export default function Editor({
),
];
},
[lineNumbers, setFilesHaveChanged, lspClient, lspPathPrefix],
[lineNumbers, setFilesHaveChangedCombined, lspClient, lspPathPrefix],
);

const [cmView, setCmView] = React.useState<EditorView>();
Expand All @@ -148,7 +186,7 @@ export default function Editor({
currentFilesFromApp,
cmView,
inferEditorExtensions,
setFilesHaveChanged,
setFilesHaveChanged: setFilesHaveChangedCombined,
lspClient,
lspPathPrefix,
});
Expand Down Expand Up @@ -182,12 +220,43 @@ export default function Editor({
if (!viewerMethods || !viewerMethods.ready) return;

syncActiveFileState();
const fileContents = editorFilesToFileContents(files);

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(editorFilesToFileContents(files));
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(() => {
Expand Down Expand Up @@ -558,6 +627,13 @@ export default function Editor({
</div>
) : null}
<div className="editor-container" ref={cmDivRef}></div>
<Toaster
toastOptions={{
duration: 5000,
position: "top-center",
style: { fontFamily: "var(--font-face)" },
}}
/>
{floatingButtons ? (
<div className="floating-buttons">{runButton}</div>
) : null}
Expand Down Expand Up @@ -640,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
// =============================================================================
Expand Down Expand Up @@ -714,3 +806,14 @@ function keyBindings({
},
];
}
/**
* Update the browser URL hash with the current contents of the Editor.
*/
async function updateBrowserUrlHash(
fileContents: FileContent[],
): Promise<void> {
const encodedFileContents =
await fileContentsToUrlStringInWebWorker(fileContents);
const hash = "#code=" + encodedFileContents;
history.replaceState(null, "", hash);
}
Loading

0 comments on commit b8911ae

Please sign in to comment.