[red-knot] Add run panel (#17002)

## Summary

This PR adds a new secondary panel to the red knot playground that
allows running the python code (current file) with
[pyodide](https://pyodide.org/en/stable/index.html) (currently Python
3.12 only).



## Test Plan


https://github.com/user-attachments/assets/7bda8ef7-19fb-4c2f-8e62-8e49a1416be1
This commit is contained in:
Micha Reiser 2025-03-26 22:32:07 +01:00 committed by GitHub
parent 338fed98a4
commit 43ca85a351
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 414 additions and 22 deletions

View file

@ -19,6 +19,7 @@
"classnames": "^2.5.1",
"lz-string": "^1.5.0",
"monaco-editor": "^0.52.2",
"pyodide": "^0.27.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-resizable-panels": "^2.1.7",
@ -31,5 +32,8 @@
"react": "$react",
"react-dom": "$react-dom"
}
},
"devDependencies": {
"vite-plugin-static-copy": "^2.3.0"
}
}

View file

@ -12,7 +12,7 @@ import {
Theme,
VerticalResizeHandle,
} from "shared";
import { Diagnostic, Workspace } from "red_knot_wasm";
import type { Diagnostic, Workspace } from "red_knot_wasm";
import { Panel, PanelGroup } from "react-resizable-panels";
import { Files } from "./Files";
import SecondarySideBar from "./SecondarySideBar";
@ -181,6 +181,7 @@ export default function Chrome({
minSize={10}
>
<SecondaryPanel
files={files}
theme={theme}
tool={secondaryTool}
result={checkResult.secondary}
@ -247,6 +248,13 @@ function useCheckResult(
content: workspace.tokens(currentHandle),
};
break;
case "Run":
secondary = {
status: "ok",
content: "",
};
break;
}
} catch (error: unknown) {
secondary = {

View file

@ -1,9 +1,14 @@
import MonacoEditor from "@monaco-editor/react";
import { Theme } from "shared";
import { AstralButton, Theme } from "shared";
import { ReadonlyFiles } from "../Playground";
import { Suspense, use, useState } from "react";
import { loadPyodide, PyodideInterface } from "pyodide";
import classNames from "classnames";
export enum SecondaryTool {
"AST" = "AST",
"Tokens" = "Tokens",
"Run" = "Run",
}
export type SecondaryPanelResult =
@ -12,6 +17,7 @@ export type SecondaryPanelResult =
| { status: "error"; error: string };
export interface SecondaryPanelProps {
files: ReadonlyFiles;
tool: SecondaryTool;
result: SecondaryPanelResult;
theme: Theme;
@ -20,23 +26,32 @@ export interface SecondaryPanelProps {
export default function SecondaryPanel({
tool,
result,
files,
theme,
}: SecondaryPanelProps) {
return (
<div className="flex flex-col h-full">
<div className="flex-grow">
<Content tool={tool} result={result} theme={theme} />
</div>
<Content
tool={tool}
result={result}
theme={theme}
files={files}
revision={files.revision}
/>
</div>
);
}
function Content({
files,
tool,
result,
theme,
revision,
}: {
tool: SecondaryTool;
files: ReadonlyFiles;
revision: number;
result: SecondaryPanelResult;
theme: Theme;
}) {
@ -54,25 +69,124 @@ function Content({
case "Tokens":
language = "RustPythonTokens";
break;
case "Run":
return <Run theme={theme} files={files} key={`${revision}`} />;
}
return (
<MonacoEditor
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 14,
roundedSelection: false,
scrollBeyondLastLine: false,
contextmenu: false,
}}
language={language}
value={result.content}
theme={theme === "light" ? "Ayu-Light" : "Ayu-Dark"}
/>
<div className="flex-grow">
<MonacoEditor
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 14,
roundedSelection: false,
scrollBeyondLastLine: false,
contextmenu: false,
}}
language={language}
value={result.content}
theme={theme === "light" ? "Ayu-Light" : "Ayu-Dark"}
/>
</div>
);
case "error":
return <code className="whitespace-pre-wrap">{result.error}</code>;
return (
<div className="flex-grow">
<code className="whitespace-pre-wrap">{result.error}</code>
</div>
);
}
}
}
let pyodidePromise: Promise<PyodideInterface> | null = null;
function Run({ files, theme }: { files: ReadonlyFiles; theme: Theme }) {
if (pyodidePromise == null) {
pyodidePromise = loadPyodide();
}
return (
<Suspense fallback={<div className="text-center">Loading</div>}>
<RunWithPyiodide
theme={theme}
files={files}
pyodidePromise={pyodidePromise}
/>
</Suspense>
);
}
function RunWithPyiodide({
files,
pyodidePromise,
theme,
}: {
files: ReadonlyFiles;
theme: Theme;
pyodidePromise: Promise<PyodideInterface>;
}) {
const pyodide = use(pyodidePromise);
const [output, setOutput] = useState<string | null>(null);
if (output == null) {
const handleRun = () => {
let stdout = "";
pyodide.setStdout({
batched(output) {
stdout += output + "\n";
},
});
const main = files.selected == null ? "" : files.contents[files.selected];
for (const file of files.index) {
pyodide.FS.writeFile(file.name, files.contents[file.id]);
}
try {
// Patch up reveal types
pyodide.runPython(`
import builtins
builtins.reveal_type = print`);
pyodide.runPython(main);
setOutput(stdout);
} catch (e) {
setOutput(`Failed to run Python script: ${e}`);
}
};
return (
<div className="flex flex-auto flex-col justify-center items-center">
<AstralButton
type="button"
className="flex-none leading-6 py-1.5 px-3 shadow-xs"
onClick={handleRun}
>
<span
className="inset-0 flex items-center justify-center"
aria-hidden="false"
>
Run...
</span>
</AstralButton>
</div>
);
}
return (
<pre
className={classNames(
"m-2",
"text-sm",
"whitespace-pre",
theme === "dark" ? "text-white" : null,
)}
>
{output}
</pre>
);
}

View file

@ -3,6 +3,7 @@ import { SecondaryTool } from "./SecondaryPanel";
interface Props {
selected: SecondaryTool | null;
onSelected(tool: SecondaryTool): void;
}
@ -26,6 +27,14 @@ export default function SecondarySideBar({ selected, onSelected }: Props) {
>
<Icons.Token />
</SideBarEntry>
<SideBarEntry
title="Run"
position={"right"}
selected={selected === SecondaryTool.Run}
onClick={() => onSelected(SecondaryTool.Run)}
>
<Icons.Run />
</SideBarEntry>
</SideBar>
);
}

View file

@ -11,7 +11,6 @@ import {
import { ErrorMessage, Header, setupMonaco, useTheme } from "shared";
import { FileHandle, Workspace } from "red_knot_wasm";
import { persist, persistLocal, restore } from "./Editor/persist";
import initRedKnot from "../red_knot_wasm";
import { loader } from "@monaco-editor/react";
import knotSchema from "../../../knot.schema.json";
import Chrome, { formatError } from "./Editor/Chrome";
@ -399,7 +398,8 @@ export interface InitializedPlayground {
// Run once during startup. Initializes monaco, loads the wasm file, and restores the previous editor state.
async function startPlayground(): Promise<InitializedPlayground> {
await initRedKnot();
const red_knot = await import("../red_knot_wasm");
await red_knot.default();
const monaco = await loader.init();
setupMonaco(monaco, {

View file

@ -1,8 +1,31 @@
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react-swc";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { viteStaticCopy } from "vite-plugin-static-copy";
const PYODIDE_EXCLUDE = [
"!**/*.{md,html}",
"!**/*.d.ts",
"!**/*.whl",
"!**/node_modules",
];
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [react(), tailwindcss(), viteStaticCopyPyodide()],
optimizeDeps: { exclude: ["pyodide"] },
});
export function viteStaticCopyPyodide() {
const pyodideDir = dirname(fileURLToPath(import.meta.resolve("pyodide")));
return viteStaticCopy({
targets: [
{
src: [join(pyodideDir, "*"), ...PYODIDE_EXCLUDE],
dest: "assets",
},
],
});
}

View file

@ -38,12 +38,16 @@
"classnames": "^2.5.1",
"lz-string": "^1.5.0",
"monaco-editor": "^0.52.2",
"pyodide": "^0.27.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-resizable-panels": "^2.1.7",
"red_knot_wasm": "file:red_knot_wasm",
"shared": "0.0.0",
"smol-toml": "^1.3.1"
},
"devDependencies": {
"vite-plugin-static-copy": "^2.3.0"
}
},
"knot/red_knot_wasm": {
@ -1806,6 +1810,20 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -2014,6 +2032,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/binary-install": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/binary-install/-/binary-install-1.1.0.tgz",
@ -2130,6 +2161,44 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
@ -3065,6 +3134,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fs-extra": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
"integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
@ -3522,6 +3606,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-boolean-object": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
@ -3942,6 +4039,19 @@
"json5": "lib/cli.js"
}
},
"node_modules/jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@ -4416,6 +4526,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -4617,6 +4737,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-map": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz",
"integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -4774,6 +4907,18 @@
"node": ">=6"
}
},
"node_modules/pyodide": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.4.tgz",
"integrity": "sha512-2y3ySHCBmyzYDUlB939SaU3n7RxYQxwnGHgdakW/CPrNFX2L9fC+4nfJWQJH8a0ruQa8bBZSKCImMt/cq15RiQ==",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.5.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -4833,6 +4978,19 @@
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/red_knot_wasm": {
"resolved": "knot/red_knot_wasm",
"link": true
@ -5630,6 +5788,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@ -5712,6 +5880,26 @@
}
}
},
"node_modules/vite-plugin-static-copy": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-2.3.0.tgz",
"integrity": "sha512-LLKwhhHetGaCnWz4mas4qqjjguDka6/6b4+SeIohRroj8aCE7QTfiZECfPecslFQkWZ3HdQuq5kOPmWZjNYlKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.3",
"fast-glob": "^3.2.11",
"fs-extra": "^11.1.0",
"p-map": "^7.0.3",
"picocolors": "^1.0.0"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0"
}
},
"node_modules/wasm-pack": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/wasm-pack/-/wasm-pack-0.13.1.tgz",
@ -5848,6 +6036,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View file

@ -46,6 +46,31 @@ export function File({
);
}
export function Run({
width = 24,
height = 24,
}: {
width?: number;
height?: number;
}) {
return (
<svg
width={width}
height={height}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.00024 2V14.4805L12.9149 8.24024L4.00024 2ZM11.1812 8.24024L4.99524 12.5684V3.91209L11.1812 8.24024Z"
fill="#ffffff"
/>
</svg>
);
}
export function Settings() {
return (
<svg