Red Knot Playground (#12681)

## Summary

This PR adds a playground for Red Knot

[Screencast from 2024-08-14
10-33-54.webm](https://github.com/user-attachments/assets/ae81d85f-74a3-4ba6-bb61-4a871b622f05)

Sharing does work 😆 I just forgot to start wrangler. 


It supports:

* Multiple files
* Showing the AST
* Showing the tokens
* Sharing
* Persistence to local storage

Future extensions:

* Configuration support: The `pyproject.toml` would *just* be another
file.
* Showing type information on hover

## Blockers

~~Salsa uses `catch_unwind` to break cycles, which Red Knot uses
extensively when inferring types in the standard library.
However, WASM (at least `wasm32-unknown-unknown`) doesn't support
`catch_unwind` today, so the playground always crashes when the type
inference encounters a cycle.~~

~~I created a discussion in the [salsa
zulip](https://salsa.zulipchat.com/#narrow/stream/333573-salsa-3.2E0/topic/WASM.20support)
to see if it would be possible to **not** use catch unwind to break
cycles.~~

~~[Rust tracking issue for WASM catch unwind
support](https://github.com/rust-lang/rust/issues/118168)~~

~~I tried to build the WASM with the nightly compiler option but ran
into problems because wasm-bindgen doesn't support WASM-exceptions. We
could try to write the binding code by hand.~~

~~Another alternative is to use `wasm32-unknown-emscripten` but it's
rather painful to build~~
This commit is contained in:
Micha Reiser 2025-03-18 17:17:11 +01:00 committed by GitHub
parent dcf31c9348
commit c027979851
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 2347 additions and 491 deletions

View file

@ -736,10 +736,9 @@ jobs:
- name: "Install Node dependencies"
run: npm ci
working-directory: playground
- name: "Build Ruff playground"
run: npm run dev:build --workspace ruff-playground
- name: "Build playgrounds"
run: npm run dev:wasm
working-directory: playground
# Requires a build for ruff_wasm to exist
- name: "Run TypeScript checks"
run: npm run check
working-directory: playground

View file

@ -38,12 +38,12 @@ jobs:
- name: "Install Node dependencies"
run: npm ci
working-directory: playground
- name: "Build Ruff playground"
run: npm run build --workspace ruff-playground
working-directory: playground
- name: "Run TypeScript checks"
run: npm run check
working-directory: playground
- name: "Build Ruff playground"
run: npm run build --workspace ruff-playground
working-directory: playground
- name: "Deploy to Cloudflare Pages"
if: ${{ env.CF_API_TOKEN_EXISTS == 'true' }}
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1

2
Cargo.lock generated
View file

@ -2644,6 +2644,8 @@ dependencies = [
"ruff_db",
"ruff_notebook",
"ruff_python_ast",
"ruff_source_file",
"ruff_text_size",
"wasm-bindgen",
"wasm-bindgen-test",
]

View file

@ -22,8 +22,10 @@ default = ["console_error_panic_hook"]
red_knot_project = { workspace = true, default-features = false, features = ["deflate"] }
ruff_db = { workspace = true, default-features = false, features = [] }
ruff_python_ast = { workspace = true }
ruff_notebook = { workspace = true }
ruff_python_ast = { workspace = true }
ruff_source_file = { workspace = true }
ruff_text_size = { workspace = true }
console_error_panic_hook = { workspace = true, optional = true }
console_log = { workspace = true }

View file

@ -1,20 +1,23 @@
use std::any::Any;
use js_sys::Error;
use wasm_bindgen::prelude::*;
use js_sys::{Error, JsString};
use red_knot_project::metadata::options::{EnvironmentOptions, Options};
use red_knot_project::metadata::value::RangedValue;
use red_knot_project::watch::{ChangeEvent, ChangedKind, CreatedKind, DeletedKind};
use red_knot_project::ProjectMetadata;
use red_knot_project::{Db, ProjectDatabase};
use ruff_db::diagnostic::{DisplayDiagnosticConfig, OldDiagnosticTrait};
use ruff_db::files::{system_path_to_file, File};
use ruff_db::source::{line_index, source_text};
use ruff_db::system::walk_directory::WalkDirectoryBuilder;
use ruff_db::system::{
CaseSensitivity, DirectoryEntry, GlobError, MemoryFileSystem, Metadata, PatternError, System,
SystemPath, SystemPathBuf, SystemVirtualPath,
};
use ruff_db::Upcast;
use ruff_notebook::Notebook;
use ruff_source_file::SourceLocation;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn run() {
@ -36,6 +39,7 @@ pub fn run() {
pub struct Workspace {
db: ProjectDatabase,
system: WasmSystem,
options: Options,
}
#[wasm_bindgen]
@ -47,34 +51,49 @@ impl Workspace {
let mut workspace =
ProjectMetadata::discover(SystemPath::new(root), &system).map_err(into_error)?;
workspace.apply_cli_options(Options {
let options = Options {
environment: Some(EnvironmentOptions {
python_version: Some(RangedValue::cli(settings.python_version.into())),
..EnvironmentOptions::default()
}),
..Options::default()
});
};
workspace.apply_cli_options(options.clone());
let db = ProjectDatabase::new(workspace, system.clone()).map_err(into_error)?;
Ok(Self { db, system })
Ok(Self {
db,
system,
options,
})
}
#[wasm_bindgen(js_name = "openFile")]
pub fn open_file(&mut self, path: &str, contents: &str) -> Result<FileHandle, Error> {
let path = SystemPath::new(path);
self.system
.fs
.write_file_all(path, contents)
.map_err(into_error)?;
self.db.apply_changes(
vec![ChangeEvent::Created {
path: path.to_path_buf(),
kind: CreatedKind::File,
}],
Some(&self.options),
);
let file = system_path_to_file(&self.db, path).expect("File to exist");
file.sync(&mut self.db);
self.db.project().open_file(&mut self.db, file);
Ok(FileHandle {
file,
path: SystemPath::new(path).to_path_buf(),
path: path.to_path_buf(),
})
}
@ -89,7 +108,19 @@ impl Workspace {
.write_file(&file_id.path, contents)
.map_err(into_error)?;
file_id.file.sync(&mut self.db);
self.db.apply_changes(
vec![
ChangeEvent::Changed {
path: file_id.path.to_path_buf(),
kind: ChangedKind::FileContent,
},
ChangeEvent::Changed {
path: file_id.path.to_path_buf(),
kind: ChangedKind::FileMetadata,
},
],
Some(&self.options),
);
Ok(())
}
@ -104,32 +135,30 @@ impl Workspace {
.remove_file(&file_id.path)
.map_err(into_error)?;
file.sync(&mut self.db);
self.db.apply_changes(
vec![ChangeEvent::Deleted {
path: file_id.path.to_path_buf(),
kind: DeletedKind::File,
}],
Some(&self.options),
);
Ok(())
}
/// Checks a single file.
#[wasm_bindgen(js_name = "checkFile")]
pub fn check_file(&self, file_id: &FileHandle) -> Result<Vec<String>, Error> {
pub fn check_file(&self, file_id: &FileHandle) -> Result<Vec<Diagnostic>, Error> {
let result = self.db.check_file(file_id.file).map_err(into_error)?;
let display_config = DisplayDiagnosticConfig::default().color(false);
Ok(result
.into_iter()
.map(|diagnostic| diagnostic.display(&self.db, &display_config).to_string())
.collect())
Ok(result.into_iter().map(Diagnostic::wrap).collect())
}
/// Checks all open files
pub fn check(&self) -> Result<Vec<String>, Error> {
pub fn check(&self) -> Result<Vec<Diagnostic>, Error> {
let result = self.db.check().map_err(into_error)?;
let display_config = DisplayDiagnosticConfig::default().color(false);
Ok(result
.into_iter()
.map(|diagnostic| diagnostic.display(&self.db, &display_config).to_string())
.collect())
Ok(result.into_iter().map(Diagnostic::wrap).collect())
}
/// Returns the parsed AST for `path`
@ -185,6 +214,124 @@ impl Settings {
}
}
#[wasm_bindgen]
pub struct Diagnostic {
#[wasm_bindgen(readonly)]
inner: Box<dyn OldDiagnosticTrait>,
}
#[wasm_bindgen]
impl Diagnostic {
fn wrap(diagnostic: Box<dyn OldDiagnosticTrait>) -> Self {
Self { inner: diagnostic }
}
#[wasm_bindgen]
pub fn message(&self) -> JsString {
JsString::from(&*self.inner.message())
}
#[wasm_bindgen]
pub fn id(&self) -> JsString {
JsString::from(self.inner.id().to_string())
}
#[wasm_bindgen]
pub fn severity(&self) -> Severity {
Severity::from(self.inner.severity())
}
#[wasm_bindgen]
pub fn text_range(&self) -> Option<TextRange> {
self.inner
.span()
.and_then(|span| Some(TextRange::from(span.range()?)))
}
#[wasm_bindgen]
pub fn to_range(&self, workspace: &Workspace) -> Option<Range> {
self.inner.span().and_then(|span| {
let line_index = line_index(workspace.db.upcast(), span.file());
let source = source_text(workspace.db.upcast(), span.file());
let text_range = span.range()?;
Some(Range {
start: line_index
.source_location(text_range.start(), &source)
.into(),
end: line_index.source_location(text_range.end(), &source).into(),
})
})
}
#[wasm_bindgen]
pub fn display(&self, workspace: &Workspace) -> JsString {
let config = DisplayDiagnosticConfig::default().color(false);
self.inner
.display(workspace.db.upcast(), &config)
.to_string()
.into()
}
}
#[wasm_bindgen]
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub struct Range {
pub start: Position,
pub end: Position,
}
impl From<SourceLocation> for Position {
fn from(location: SourceLocation) -> Self {
Self {
line: location.row.to_zero_indexed(),
character: location.column.to_zero_indexed(),
}
}
}
#[wasm_bindgen]
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub struct Position {
pub line: usize,
pub character: usize,
}
#[wasm_bindgen]
#[derive(Copy, Clone, Hash, PartialEq, Eq)]
pub enum Severity {
Info,
Warning,
Error,
Fatal,
}
impl From<ruff_db::diagnostic::Severity> for Severity {
fn from(value: ruff_db::diagnostic::Severity) -> Self {
match value {
ruff_db::diagnostic::Severity::Info => Self::Info,
ruff_db::diagnostic::Severity::Warning => Self::Warning,
ruff_db::diagnostic::Severity::Error => Self::Error,
ruff_db::diagnostic::Severity::Fatal => Self::Fatal,
}
}
}
#[wasm_bindgen]
pub struct TextRange {
pub start: u32,
pub end: u32,
}
impl From<ruff_text_size::TextRange> for TextRange {
fn from(value: ruff_text_size::TextRange) -> Self {
Self {
start: value.start().into(),
end: value.end().into(),
}
}
}
#[wasm_bindgen]
#[derive(Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum PythonVersion {

View file

@ -2,7 +2,7 @@
use wasm_bindgen_test::wasm_bindgen_test;
use red_knot_wasm::{PythonVersion, Settings, Workspace};
use red_knot_wasm::{Position, PythonVersion, Settings, Workspace};
#[wasm_bindgen_test]
fn check() {
@ -17,17 +17,17 @@ fn check() {
let result = workspace.check().expect("Check to succeed");
assert_eq!(result.len(), 1);
let diagnostic = &result[0];
assert_eq!(diagnostic.id(), "lint:unresolved-import");
assert_eq!(
result,
vec![
"\
error: lint:unresolved-import
--> /test.py:1:8
|
1 | import random22
| ^^^^^^^^ Cannot resolve import `random22`
|
",
],
diagnostic.to_range(&workspace).unwrap().start,
Position {
line: 0,
character: 7
}
);
assert_eq!(diagnostic.message(), "Cannot resolve import `random22`");
}

View file

@ -1,3 +1,5 @@
**.md
ruff/dist
ruff/ruff_wasm
knot/dist
knot/red_knot_wasm

View file

@ -4,12 +4,16 @@ In-browser playground for Ruff. Available [https://play.ruff.rs/](https://play.r
## Getting started
Install the NPM dependencies with `npm install`, and run, and run the development server with `npm start`.
You may need to restart the server after making changes to Ruff to re-build the WASM module.
Install the NPM dependencies with `npm install`, and run, and run the development server with
`npm start --workspace ruff-playground` or `npm start --workspace knot-playground`.
You may need to restart the server after making changes to Ruff or Red Knot to re-build the WASM
module.
To run the datastore, which is based on [Workers KV](https://developers.cloudflare.com/workers/runtime-apis/kv/),
To run the datastore, which is based
on [Workers KV](https://developers.cloudflare.com/workers/runtime-apis/kv/),
install the [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/),
then run `npx wrangler dev --local` from the `./playground/api` directory. Note that the datastore is
then run `npx wrangler dev --local` from the `./playground/api` directory. Note that the datastore
is
only required to generate shareable URLs for code snippets. The development datastore does not
require Cloudflare authentication or login, but in turn only persists data locally.
@ -20,8 +24,17 @@ The playground is implemented as a single-page React application powered by
[Monaco](https://github.com/microsoft/monaco-editor).
The playground stores state in `localStorage`, but supports persisting code snippets to
a persistent datastore based on [Workers KV](https://developers.cloudflare.com/workers/runtime-apis/kv/)
and exposed via a [Cloudflare Worker](https://developers.cloudflare.com/workers/learning/how-workers-works/).
a persistent datastore based
on [Workers KV](https://developers.cloudflare.com/workers/runtime-apis/kv/)
and exposed via
a [Cloudflare Worker](https://developers.cloudflare.com/workers/learning/how-workers-works/).
The playground design is originally based on [Tailwind Play](https://play.tailwindcss.com/), with
additional inspiration from the [Biome Playground](https://biomejs.dev/playground/).
## Known issues
### Stack overflows
If you see stack overflows in the playground, build the WASM module in release mode:
`npm run --workspace knot-playground build:wasm`.

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@
"@cloudflare/workers-types": "^4.20230801.0",
"miniflare": "^3.20230801.1",
"typescript": "^5.1.6",
"wrangler": "3.111.0"
"wrangler": "^4.1.0"
},
"private": true,
"scripts": {

View file

@ -41,6 +41,10 @@ export default {
const headers = DEV ? DEVELOPMENT_HEADERS : PRODUCTION_HEADERS;
if (!DEV && request.url.startsWith("https://playknot.ruff.rs")) {
headers["Access-Control-Allow-Origin"] = "https://playknot.ruff.rs";
}
switch (request.method) {
case "GET": {
// Ex) `https://api.astral-1ad.workers.dev/<key>`
@ -55,7 +59,7 @@ export default {
}
const playground = await PLAYGROUND.get(key);
if (playground === null) {
if (playground == null) {
return new Response("Not Found", {
status: 404,
headers,

View file

@ -29,6 +29,7 @@ export default tseslint.config(
"@typescript-eslint/no-explicit-any": "off",
// Handled by typescript. It doesn't support shared?
"import/no-unresolved": "off",
"no-console": "error",
},
},
{

View file

@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer-when-downgrade" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<meta name="msapplication-TileColor" content="#d7ff64" />
<meta name="theme-color" content="#ffffff" />
<title>Playground | Red Knot</title>
<meta
name="description"
content="An in-browser playground for Red Knot, an extremely fast Python type-checker written in Rust."
/>
<meta name="keywords" content="ruff, python, rust, webassembly, wasm" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@astral_sh" />
<meta property="og:title" content="Playground | Ruff" />
<meta
property="og:description"
content="An in-browser playground for Ruff, an extremely fast Python type-checker written in Rust."
/>
<meta property="og:url" content="https://play.ruff.rs" />
<meta property="og:image" content="/Astral.png" />
<link rel="canonical" href="https://playknot.ruff.rs" />
<link rel="icon" href="/favicon.ico" />
<script
src="https://cdn.usefathom.com/script.js"
data-site="XWUDIXNB"
defer
></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,35 @@
{
"name": "knot-playground",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"prebuild": "npm run build:wasm",
"build": "vite build",
"build:wasm": "wasm-pack build ../../crates/red_knot_wasm --target web --out-dir ../../playground/knot/red_knot_wasm",
"dev:wasm": "wasm-pack build ../../crates/red_knot_wasm --dev --target web --out-dir ../../playground/knot/red_knot_wasm",
"predev:build": "npm run dev:wasm",
"dev:build": "vite build",
"prestart": "npm run dev:wasm",
"start": "vite",
"preview": "vite preview"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"classnames": "^2.5.1",
"lz-string": "^1.5.0",
"monaco-editor": "^0.52.2",
"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"
},
"overrides": {
"@monaco-editor/react": {
"react": "$react",
"react-dom": "$react-dom"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1,587 @@
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import {
Header,
useTheme,
setupMonaco,
ErrorMessage,
HorizontalResizeHandle,
VerticalResizeHandle,
} from "shared";
import initRedKnot, {
Diagnostic,
FileHandle,
Settings,
PythonVersion,
Workspace,
} from "red_knot_wasm";
import { loader } from "@monaco-editor/react";
import { Panel, PanelGroup } from "react-resizable-panels";
import { Files } from "./Files";
import { persist, persistLocal, restore } from "./persist";
import SecondarySideBar from "./SecondarySideBar";
import Editor from "./Editor";
import SecondaryPanel, {
SecondaryPanelResult,
SecondaryTool,
} from "./SecondaryPanel";
import Diagnostics from "./Diagnostics";
import { editor } from "monaco-editor";
import IStandaloneCodeEditor = editor.IStandaloneCodeEditor;
interface CheckResult {
diagnostics: Diagnostic[];
error: string | null;
secondary: SecondaryPanelResult;
}
export default function Chrome() {
const initPromise = useRef<null | Promise<void>>(null);
const [workspace, setWorkspace] = useState<null | Workspace>(null);
const [files, dispatchFiles] = useReducer(filesReducer, {
index: [],
contents: Object.create(null),
handles: Object.create(null),
nextId: 0,
revision: 0,
selected: null,
});
const [secondaryTool, setSecondaryTool] = useState<SecondaryTool | null>(
null,
);
const editorRef = useRef<IStandaloneCodeEditor | null>(null);
const [version, setVersion] = useState("");
const [theme, setTheme] = useTheme();
usePersistLocally(files);
const handleShare = useCallback(() => {
const serialized = serializeFiles(files);
if (serialized != null) {
persist(serialized).catch((error) => {
// eslint-disable-next-line no-console
console.error("Failed to share playground", error);
});
}
}, [files]);
if (initPromise.current == null) {
initPromise.current = startPlayground()
.then(({ version, workspace: fetchedWorkspace }) => {
const settings = new Settings(PythonVersion.Py312);
const workspace = new Workspace("/", settings);
setVersion(version);
setWorkspace(workspace);
for (const [name, content] of Object.entries(fetchedWorkspace.files)) {
const handle = workspace.openFile(name, content);
dispatchFiles({ type: "add", handle, name, content });
}
dispatchFiles({
type: "selectFileByName",
name: fetchedWorkspace.current,
});
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error("Failed to initialize playground.", error);
});
}
const handleSourceChanged = useCallback(
(source: string) => {
if (files.selected == null) {
return;
}
dispatchFiles({
type: "change",
id: files.selected,
content: source,
});
},
[files.selected],
);
const handleFileClicked = useCallback(
(file: FileId) => {
if (workspace != null && files.selected != null) {
workspace.updateFile(
files.handles[files.selected],
files.contents[files.selected],
);
}
dispatchFiles({ type: "selectFile", id: file });
},
[workspace, files.contents, files.handles, files.selected],
);
const handleFileAdded = useCallback(
(name: string) => {
if (workspace == null) {
return;
}
if (files.selected != null) {
workspace.updateFile(
files.handles[files.selected],
files.contents[files.selected],
);
}
const handle = workspace.openFile(name, "");
dispatchFiles({ type: "add", name, handle, content: "" });
},
[workspace, files.handles, files.contents, files.selected],
);
const handleFileRemoved = useCallback(
(file: FileId) => {
if (workspace != null) {
workspace.closeFile(files.handles[file]);
}
dispatchFiles({ type: "remove", id: file });
},
[workspace, files.handles],
);
const handleFileRenamed = useCallback(
(file: FileId, newName: string) => {
if (workspace == null) {
return;
}
workspace.closeFile(files.handles[file]);
const newHandle = workspace.openFile(newName, files.contents[file]);
editorRef.current?.focus();
dispatchFiles({ type: "rename", id: file, to: newName, newHandle });
},
[workspace, files.handles, files.contents],
);
const handleSecondaryToolSelected = useCallback(
(tool: SecondaryTool | null) => {
setSecondaryTool((secondaryTool) => {
if (tool === secondaryTool) {
return null;
}
return tool;
});
},
[],
);
const handleEditorMount = useCallback((editor: IStandaloneCodeEditor) => {
editorRef.current = editor;
}, []);
const handleGoTo = useCallback((line: number, column: number) => {
const editor = editorRef.current;
if (editor == null) {
return;
}
const range = {
startLineNumber: line,
startColumn: column,
endLineNumber: line,
endColumn: column,
};
editor.revealRange(range);
editor.setSelection(range);
}, []);
const checkResult = useCheckResult(files, workspace, secondaryTool);
return (
<main className="flex flex-col h-full bg-ayu-background dark:bg-ayu-background-dark">
<Header
edit={files.revision}
theme={theme}
logo="astral"
version={version}
onChangeTheme={setTheme}
onShare={handleShare}
/>
{workspace != null && files.selected != null ? (
<>
<Files
files={files.index}
theme={theme}
selected={files.selected}
onAdd={handleFileAdded}
onRename={handleFileRenamed}
onSelected={handleFileClicked}
onRemove={handleFileRemoved}
/>
<PanelGroup direction="horizontal" autoSaveId="main">
<Panel
id="main"
order={0}
className="flex flex-col gap-2 my-4"
minSize={10}
>
<PanelGroup id="vertical" direction="vertical">
<Panel minSize={10} className="my-2" order={0}>
<Editor
theme={theme}
visible={true}
onMount={handleEditorMount}
source={files.contents[files.selected]}
onChange={handleSourceChanged}
diagnostics={checkResult.diagnostics}
workspace={workspace}
/>
<VerticalResizeHandle />
</Panel>
<Panel
id="diagnostics"
minSize={3}
order={1}
className="my-2 flex grow"
>
<Diagnostics
diagnostics={checkResult.diagnostics}
workspace={workspace}
onGoTo={handleGoTo}
theme={theme}
/>
</Panel>
</PanelGroup>
</Panel>
{secondaryTool != null && (
<>
<HorizontalResizeHandle />
<Panel
id="secondary-panel"
order={1}
className={"my-2"}
minSize={10}
>
<SecondaryPanel
theme={theme}
tool={secondaryTool}
result={checkResult.secondary}
/>
</Panel>
</>
)}
<SecondarySideBar
selected={secondaryTool}
onSelected={handleSecondaryToolSelected}
/>
</PanelGroup>
</>
) : null}
{checkResult.error ? (
<div
style={{
position: "fixed",
left: "10%",
right: "10%",
bottom: "10%",
}}
>
<ErrorMessage>{checkResult.error}</ErrorMessage>
</div>
) : null}
</main>
);
}
// Run once during startup. Initializes monaco, loads the wasm file, and restores the previous editor state.
async function startPlayground(): Promise<{
version: string;
workspace: { files: { [name: string]: string }; current: string };
}> {
await initRedKnot();
const monaco = await loader.init();
setupMonaco(monaco);
const restored = await restore();
const workspace = restored ?? {
files: { "main.py": "import os" },
current: "main.py",
};
return {
version: "0.0.0",
workspace,
};
}
/**
* Persists the files to local storage. This is done deferred to avoid too frequent writes.
*/
function usePersistLocally(files: FilesState): void {
const deferredFiles = useDeferredValue(files);
useEffect(() => {
const serialized = serializeFiles(deferredFiles);
if (serialized != null) {
persistLocal(serialized);
}
}, [deferredFiles]);
}
function useCheckResult(
files: FilesState,
workspace: Workspace | null,
secondaryTool: SecondaryTool | null,
): CheckResult {
const deferredContent = useDeferredValue(
files.selected == null ? null : files.contents[files.selected],
);
return useMemo(() => {
if (
workspace == null ||
files.selected == null ||
deferredContent == null
) {
return {
diagnostics: [],
error: null,
secondary: null,
};
}
const currentHandle = files.handles[files.selected];
// Update the workspace content but use the deferred value to avoid too frequent updates.
workspace.updateFile(currentHandle, deferredContent);
try {
const diagnostics = workspace.checkFile(currentHandle);
let secondary: SecondaryPanelResult = null;
try {
switch (secondaryTool) {
case "AST":
secondary = {
status: "ok",
content: workspace.parsed(currentHandle),
};
break;
case "Tokens":
secondary = {
status: "ok",
content: workspace.tokens(currentHandle),
};
break;
}
} catch (error: unknown) {
secondary = {
status: "error",
error: error instanceof Error ? error.message : error + "",
};
}
return {
diagnostics,
error: null,
secondary,
};
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
return {
diagnostics: [],
error: (e as Error).message,
secondary: null,
};
}
}, [
deferredContent,
workspace,
files.selected,
files.handles,
secondaryTool,
]);
}
export type FileId = number;
interface FilesState {
/**
* The currently selected file that is shown in the editor.
*/
selected: FileId | null;
/**
* The files in display order (ordering is sensitive)
*/
index: ReadonlyArray<{ id: FileId; name: string }>;
/**
* The database file handles by file id.
*/
handles: Readonly<{ [id: FileId]: FileHandle }>;
/**
* The content per file indexed by file id.
*/
contents: Readonly<{ [id: FileId]: string }>;
/**
* The revision. Gets incremented everytime files changes.
*/
revision: number;
nextId: FileId;
}
type FileAction =
| {
type: "add";
handle: FileHandle;
/// The file name
name: string;
content: string;
}
| {
type: "change";
id: FileId;
content: string;
}
| { type: "rename"; id: FileId; to: string; newHandle: FileHandle }
| {
type: "remove";
id: FileId;
}
| { type: "selectFile"; id: FileId }
| { type: "selectFileByName"; name: string };
function filesReducer(
state: Readonly<FilesState>,
action: FileAction,
): FilesState {
switch (action.type) {
case "add": {
const { handle, name, content } = action;
const id = state.nextId;
return {
...state,
selected: id,
index: [...state.index, { id, name }],
handles: { ...state.handles, [id]: handle },
contents: { ...state.contents, [id]: content },
nextId: state.nextId + 1,
revision: state.revision + 1,
};
}
case "change": {
const { id, content } = action;
return {
...state,
contents: { ...state.contents, [id]: content },
revision: state.revision + 1,
};
}
case "remove": {
const { id } = action;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [id]: _content, ...contents } = state.contents;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [id]: _handle, ...handles } = state.handles;
let selected = state.selected;
if (state.selected === id) {
const index = state.index.findIndex((file) => file.id === id);
selected =
index > 0 ? state.index[index - 1].id : state.index[index + 1].id;
}
return {
...state,
selected,
index: state.index.filter((file) => file.id !== id),
contents,
handles,
revision: state.revision + 1,
};
}
case "rename": {
const { id, to, newHandle } = action;
const index = state.index.findIndex((file) => file.id === id);
const newIndex = [...state.index];
newIndex.splice(index, 1, { id, name: to });
return {
...state,
index: newIndex,
handles: { ...state.handles, [id]: newHandle },
};
}
case "selectFile": {
const { id } = action;
return {
...state,
selected: id,
};
}
case "selectFileByName": {
const { name } = action;
const selected =
state.index.find((file) => file.name === name)?.id ?? null;
return {
...state,
selected,
};
}
}
}
function serializeFiles(files: FilesState): {
files: { [name: string]: string };
current: string;
} | null {
const serializedFiles = Object.create(null);
let selected = null;
for (const { id, name } of files.index) {
serializedFiles[name] = files.contents[id];
if (files.selected === id) {
selected = name;
}
}
if (selected == null) {
return null;
}
return { files: serializedFiles, current: selected };
}

View file

@ -0,0 +1,99 @@
import { Diagnostic, Workspace } from "red_knot_wasm";
import classNames from "classnames";
import { Theme } from "shared";
import { useMemo } from "react";
interface Props {
diagnostics: Diagnostic[];
workspace: Workspace;
theme: Theme;
onGoTo(line: number, column: number): void;
}
export default function Diagnostics({
diagnostics: unsorted,
workspace,
theme,
onGoTo,
}: Props) {
const diagnostics = useMemo(() => {
const sorted = [...unsorted];
sorted.sort((a, b) => {
return (a.text_range()?.start ?? 0) - (b.text_range()?.start ?? 0);
});
return sorted;
}, [unsorted]);
return (
<div
className={classNames(
"flex grow flex-col overflow-hidden",
theme === "dark" ? "text-white" : null,
)}
>
<div
className={classNames(
"border-b border-gray-200 px-2 py-1",
theme === "dark" ? "border-rock" : null,
)}
>
File diagnostics ({diagnostics.length})
</div>
<div className="flex grow p-2 overflow-hidden">
<Items
diagnostics={diagnostics}
onGoTo={onGoTo}
workspace={workspace}
/>
</div>
</div>
);
}
function Items({
diagnostics,
onGoTo,
workspace,
}: {
diagnostics: Array<Diagnostic>;
workspace: Workspace;
onGoTo(line: number, column: number): void;
}) {
if (diagnostics.length === 0) {
return (
<div className={"flex flex-auto flex-col justify-center items-center"}>
Everything is looking good!
</div>
);
}
return (
<ul className="space-y-0.5 grow overflow-y-scroll">
{diagnostics.map((diagnostic, index) => {
const position = diagnostic.to_range(workspace);
const start = position?.start;
const id = diagnostic.id();
const startLine = (start?.line ?? 0) + 1;
const startColumn = (start?.character ?? 0) + 1;
return (
<li key={`${diagnostic.text_range()?.start ?? 0}-${id ?? index}`}>
<button
onClick={() => onGoTo(startLine, startColumn)}
className="w-full text-start cursor-pointer"
>
{diagnostic.message()}
<span className="text-gray-500">
{id != null && `(${id})`} [Ln {startLine}, Col {startColumn}]
</span>
</button>
</li>
);
})}
</ul>
);
}

View file

@ -0,0 +1,134 @@
/**
* Editor for the Python source code.
*/
import Moncao, { Monaco, OnMount } from "@monaco-editor/react";
import { editor, MarkerSeverity } from "monaco-editor";
import { useCallback, useEffect, useRef } from "react";
import { Theme } from "shared";
import { Diagnostic, Severity, Workspace } from "red_knot_wasm";
import IStandaloneCodeEditor = editor.IStandaloneCodeEditor;
type Props = {
visible: boolean;
source: string;
diagnostics: Diagnostic[];
theme: Theme;
workspace: Workspace;
onChange(content: string): void;
onMount(editor: IStandaloneCodeEditor): void;
};
type MonacoEditorState = {
monaco: Monaco;
};
export default function Editor({
visible,
source,
theme,
diagnostics,
workspace,
onChange,
onMount,
}: Props) {
const monacoRef = useRef<MonacoEditorState | null>(null);
// Update the diagnostics in the editor.
useEffect(() => {
const editorState = monacoRef.current;
if (editorState == null) {
return;
}
updateMarkers(editorState.monaco, workspace, diagnostics);
}, [workspace, diagnostics]);
const handleChange = useCallback(
(value: string | undefined) => {
onChange(value ?? "");
},
[onChange],
);
const handleMount: OnMount = useCallback(
(editor, instance) => {
updateMarkers(instance, workspace, diagnostics);
monacoRef.current = {
monaco: instance,
};
onMount(editor);
},
[onMount, workspace, diagnostics],
);
return (
<Moncao
onMount={handleMount}
options={{
fixedOverflowWidgets: true,
readOnly: false,
minimap: { enabled: false },
fontSize: 14,
roundedSelection: false,
scrollBeyondLastLine: false,
contextmenu: false,
}}
language={"python"}
wrapperProps={visible ? {} : { style: { display: "none" } }}
theme={theme === "light" ? "Ayu-Light" : "Ayu-Dark"}
value={source}
onChange={handleChange}
/>
);
}
function updateMarkers(
monaco: Monaco,
workspace: Workspace,
diagnostics: Array<Diagnostic>,
) {
const editor = monaco.editor;
const model = editor?.getModels()[0];
if (!model) {
return;
}
editor.setModelMarkers(
model,
"owner",
diagnostics.map((diagnostic) => {
const mapSeverity = (severity: Severity) => {
switch (severity) {
case Severity.Info:
return MarkerSeverity.Info;
case Severity.Warning:
return MarkerSeverity.Warning;
case Severity.Error:
return MarkerSeverity.Error;
case Severity.Fatal:
return MarkerSeverity.Error;
}
};
const range = diagnostic.to_range(workspace);
return {
code: diagnostic.id(),
startLineNumber: (range?.start?.line ?? 0) + 1,
startColumn: (range?.start?.character ?? 0) + 1,
endLineNumber: (range?.end?.line ?? 0) + 1,
endColumn: (range?.end?.character ?? 0) + 1,
message: diagnostic.message(),
severity: mapSeverity(diagnostic.severity()),
tags: [],
};
}),
);
}

View file

@ -0,0 +1,180 @@
import { FileId } from "./Chrome";
import { Icons, Theme } from "shared";
import classNames from "classnames";
import { useState } from "react";
export interface Props {
// The file names
files: ReadonlyArray<{ id: FileId; name: string }>;
theme: Theme;
selected: FileId;
onAdd(name: string): void;
onRemove(id: FileId): void;
onSelected(id: FileId): void;
onRename(id: FileId, newName: string): void;
}
export function Files({
files,
selected,
theme,
onAdd,
onRemove,
onRename,
onSelected,
}: Props) {
const handleAdd = () => {
let index: number | null = null;
let fileName = "module.py";
while (files.some(({ name }) => name === fileName)) {
index = (index ?? 0) + 1;
fileName = `module${index}.py`;
}
onAdd(fileName);
};
const lastFile = files.length === 1;
return (
<ul
className={classNames(
"flex flex-wrap border-b border-gray-200",
theme === "dark" ? "text-white border-rock" : null,
)}
>
{files.map(({ id, name }) => (
<ListItem key={id} selected={selected === id} theme={theme}>
<FileEntry
selected={selected === id}
name={name}
onClicked={() => onSelected(id)}
onRenamed={(newName) => {
if (!files.some(({ name }) => name === newName)) {
onRename(id, newName);
}
}}
/>
<button
disabled={lastFile}
onClick={lastFile ? undefined : () => onRemove(id)}
className={"inline-block disabled:opacity-50"}
title="Close file"
>
<span className="sr-only">Close</span>
<Icons.Close />
</button>
</ListItem>
))}
<ListItem selected={false} theme={theme}>
<button onClick={handleAdd} title="Add file" className="inline-block">
<span className="sr-only">Add file</span>
<Icons.Add />
</button>
</ListItem>
</ul>
);
}
interface ListItemProps {
selected: boolean;
children: React.ReactNode;
theme: Theme;
}
function ListItem({ children, selected, theme }: ListItemProps) {
const activeBorderColor =
theme === "light" ? "border-galaxy" : "border-radiate";
return (
<li
aria-selected={selected}
className={classNames(
"flex",
"px-4",
"gap-2",
"text-sm",
"items-center",
selected
? ["active", "border-b-2", "pb-0", activeBorderColor]
: ["pb-0.5"],
)}
>
{children}
</li>
);
}
interface FileEntryProps {
selected: boolean;
name: string;
onClicked(): void;
onRenamed(name: string): void;
}
function FileEntry({ name, onClicked, onRenamed, selected }: FileEntryProps) {
const [newName, setNewName] = useState<string | null>(null);
if (!selected && newName != null) {
setNewName(null);
}
const handleRenamed = (newName: string) => {
setNewName(null);
if (name !== newName) {
onRenamed(newName);
}
};
return (
<button
onClick={() => {
if (selected) {
setNewName(name);
} else {
onClicked();
}
}}
className="flex gap-2 items-center py-4"
>
<span className="inline-block flex-none" aria-hidden>
<Icons.Python width={12} height={12} />
</span>
{newName == null ? (
<span className="inline-block">{name}</span>
) : (
<input
className="inline-block"
autoFocus={true}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onBlur={() => handleRenamed(newName)}
onKeyDown={(event) => {
if (event.metaKey || event.altKey || event.shiftKey) {
return;
}
switch (event.key) {
case "Enter":
handleRenamed(newName);
return;
case "Escape":
setNewName(null);
return;
case "\\":
event.preventDefault();
}
}}
/>
)}
</button>
);
}

View file

@ -0,0 +1,78 @@
import MonacoEditor from "@monaco-editor/react";
import { Theme } from "shared";
export enum SecondaryTool {
"AST" = "AST",
"Tokens" = "Tokens",
}
export type SecondaryPanelResult =
| null
| { status: "ok"; content: string }
| { status: "error"; error: string };
export interface SecondaryPanelProps {
tool: SecondaryTool;
result: SecondaryPanelResult;
theme: Theme;
}
export default function SecondaryPanel({
tool,
result,
theme,
}: SecondaryPanelProps) {
return (
<div className="flex flex-col h-full">
<div className="flex-grow">
<Content tool={tool} result={result} theme={theme} />
</div>
</div>
);
}
function Content({
tool,
result,
theme,
}: {
tool: SecondaryTool;
result: SecondaryPanelResult;
theme: Theme;
}) {
if (result == null) {
return "";
} else {
let language;
switch (result.status) {
case "ok":
switch (tool) {
case "AST":
language = "RustPythonAst";
break;
case "Tokens":
language = "RustPythonTokens";
break;
}
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"}
/>
);
case "error":
return <code className="whitespace-pre-wrap">{result.error}</code>;
}
}
}

View file

@ -0,0 +1,31 @@
import { Icons, SideBar, SideBarEntry } from "shared";
import { SecondaryTool } from "./SecondaryPanel";
interface Props {
selected: SecondaryTool | null;
onSelected(tool: SecondaryTool): void;
}
export default function SecondarySideBar({ selected, onSelected }: Props) {
return (
<SideBar position="right">
<SideBarEntry
title="AST"
position={"right"}
selected={selected === SecondaryTool.AST}
onClick={() => onSelected(SecondaryTool.AST)}
>
<Icons.Structure />
</SideBarEntry>
<SideBarEntry
title="Tokens"
position={"right"}
selected={selected === SecondaryTool.Tokens}
onClick={() => onSelected(SecondaryTool.Tokens)}
>
<Icons.Token />
</SideBarEntry>
</SideBar>
);
}

View file

@ -0,0 +1,38 @@
const API_URL = import.meta.env.PROD
? "https://api.astral-1ad.workers.dev"
: "http://0.0.0.0:8787";
export type Playground = {
files: { [name: string]: string };
/// the name of the current file
current: string;
};
/**
* Fetch a playground by ID.
*/
export async function fetchPlayground(id: string): Promise<Playground | null> {
const response = await fetch(`${API_URL}/${encodeURIComponent(id)}`);
if (!response.ok) {
throw new Error(`Failed to fetch playground ${id}: ${response.status}`);
}
return await response.json();
}
/**
* Save a playground and return its ID.
*/
export async function savePlayground(playground: Playground): Promise<string> {
const response = await fetch(API_URL, {
method: "POST",
body: JSON.stringify(playground),
});
if (!response.ok) {
throw new Error(`Failed to save playground: ${response.status}`);
}
return await response.text();
}

View file

@ -0,0 +1,54 @@
import { fetchPlayground, savePlayground } from "./api";
interface Workspace {
files: { [name: string]: string };
// Name of the current file
current: string;
}
/**
* Persist the configuration to a URL.
*/
export async function persist(workspace: Workspace): Promise<void> {
const id = await savePlayground(workspace);
await navigator.clipboard.writeText(
`${window.location.origin}/${encodeURIComponent(id)}`,
);
}
/**
* Restore the workspace by fetching the data for the ID specified in the URL
* or by restoring from local storage.
*/
export async function restore(): Promise<Workspace | null> {
// URLs stored in the database, like:
// https://play.ruff.rs/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed
const id = window.location.pathname.slice(1);
if (id !== "") {
const playground = await fetchPlayground(id);
if (playground == null) {
return null;
}
return playground;
}
// If no URL is present, restore from local storage.
return restoreLocal();
}
export function persistLocal(workspace: Workspace) {
localStorage.setItem("workspace", JSON.stringify(workspace));
}
function restoreLocal(): Workspace | null {
const workspace = localStorage.getItem("workspace");
if (workspace == null) {
return null;
} else {
return JSON.parse(workspace);
}
}

View file

@ -0,0 +1,123 @@
@import "tailwindcss";
@source "../../shared/";
@custom-variant dark (&:is(.dark *));
@theme {
--color-ayu-accent: #ffac2f;
--color-ayu-background: #f8f9fa;
--color-ayu-background-dark: #0b0e14;
--color-black: #261230;
--color-white: #ffffff;
--color-radiate: #d7ff64;
--color-flare: #6340ac;
--color-rock: #78876e;
--color-galaxy: #261230;
--color-space: #30173d;
--color-comet: #6f5d6f;
--color-cosmic: #de5fe9;
--color-sun: #ffac2f;
--color-electron: #46ebe1;
--color-aurora: #46eb74;
--color-constellation: #5f6de9;
--color-neutron: #cff3cf;
--color-proton: #f6afbc;
--color-nebula: #cdcbfb;
--color-supernova: #f1aff6;
--color-starlight: #f4f4f1;
--color-lunar: #fbf2fc;
--color-asteroid: #e3cee3;
--color-crater: #f0dfdf;
--font-heading:
Alliance Platt, system-ui, -apple-system, Segoe UI, Roboto, Helvetica,
Arial, monospace, Apple Color Emoji, Segoe UI Emoji;
--font-body:
Alliance Text, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
sans-serif, Apple Color Emoji, Segoe UI Emoji;
--font-mono: Roboto Mono;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
}
* {
box-sizing: border-box;
}
body,
html,
#root {
margin: 0;
height: 100%;
width: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.shadow-copied {
--tw-shadow: 0 0 0 1px var(--color-white), inset 0 0 0 1px var(--color-white);
--tw-shadow-colored:
0 0 0 1px var(--tw-shadow-color), inset 0 0 0 1px var(--tw-shadow-color);
box-shadow:
var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000),
var(--tw-shadow);
}
@font-face {
font-family: "Alliance Text";
src:
url("https://static.astral.sh/fonts/Alliance-TextRegular.woff2")
format("woff2"),
url("https://static.astral.sh/fonts/Alliance-TextRegular.woff")
format("woff");
font-weight: normal;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Alliance Text";
src:
url("https://static.astral.sh/fonts/Alliance-TextMedium.woff2")
format("woff2"),
url("https://static.astral.sh/fonts/Alliance-TextMedium.woff")
format("woff");
font-weight: 500;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Alliance Platt";
src:
url("https://static.astral.sh/fonts/Alliance-PlattMedium.woff2")
format("woff2"),
url("https://static.astral.sh/fonts/Alliance-PlattMedium.woff")
format("woff");
font-weight: 500;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Alliance Platt";
src:
url("https://static.astral.sh/fonts/Alliance-PlattRegular.woff2")
format("woff2"),
url("https://static.astral.sh/fonts/Alliance-PlattRegular.woff")
format("woff");
font-weight: normal;
font-style: normal;
font-display: block;
}

View file

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import Chrome from "./Editor/Chrome";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<Chrome />
</React.StrictMode>,
);

6
playground/knot/src/third-party.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
declare module "lz-string" {
function decompressFromEncodedURIComponent(
input: string | null,
): string | null;
function compressToEncodedURIComponent(input: string | null): string;
}

1
playground/knot/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react-swc";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
});

View file

@ -8,6 +8,7 @@
"name": "playground",
"version": "0.0.0",
"workspaces": [
"knot",
"ruff",
"shared"
],
@ -29,6 +30,26 @@
"wasm-pack": "^0.13.1"
}
},
"knot": {
"name": "knot-playground",
"version": "0.0.0",
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"classnames": "^2.5.1",
"lz-string": "^1.5.0",
"monaco-editor": "^0.52.2",
"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"
}
},
"knot/red_knot_wasm": {
"version": "0.0.0",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz",
@ -3947,6 +3968,10 @@
"json-buffer": "3.0.1"
}
},
"node_modules/knot-playground": {
"resolved": "knot",
"link": true
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -4808,6 +4833,10 @@
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/red_knot_wasm": {
"resolved": "knot/red_knot_wasm",
"link": true
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",

View file

@ -4,13 +4,16 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"check": "npm run lint && npm run tsc",
"check": "npm run dev:wasm && npm run lint && npm run tsc",
"dev:wasm": "npm run dev:wasm --workspace knot-playground && npm run dev:wasm --workspace ruff-playground",
"dev:build": "npm run dev:build --workspace knot-playground && npm run dev:build --workspace ruff-playground",
"fmt": "prettier --cache -w .",
"fmt:check": "prettier --cache --check .",
"lint": "eslint --cache --ext .ts,.tsx ruff/src",
"lint": "eslint --cache --ext .ts,.tsx ruff/src knot/src",
"tsc": "tsc"
},
"workspaces": [
"knot",
"ruff",
"shared"
],

View file

@ -21,6 +21,7 @@ export default function Chrome() {
}
persist(settings, pythonSource).catch((error) =>
// eslint-disable-next-line no-console
console.error(`Failed to share playground: ${error}`),
);
}, [pythonSource, settings]);
@ -34,6 +35,7 @@ export default function Chrome() {
setRevision(1);
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error("Failed to initialize playground.", error);
});
}
@ -75,6 +77,7 @@ export default function Chrome() {
<Header
edit={revision}
theme={theme}
logo="ruff"
version={ruffVersion}
onChangeTheme={setTheme}
onShare={handleShare}

View file

@ -242,7 +242,7 @@ export default function Editor({
<PrimarySideBar onSelectTool={(tool) => setTab(tool)} selected={tab} />
<Panel id="main" order={0} minSize={10}>
<PanelGroup id="main" direction="vertical">
<PanelGroup id="vertical" direction="vertical">
<Panel minSize={10} className="my-2" order={0}>
<SourceEditor
visible={tab === "Source"}

View file

@ -1,5 +1,4 @@
import { Icons } from "shared";
import SideBar, { SideBarEntry } from "./SideBar";
import { Icons, SideBar, SideBarEntry } from "shared";
type Tool = "Settings" | "Source";

View file

@ -1,5 +1,4 @@
import SideBar, { SideBarEntry } from "./SideBar";
import { Icons } from "shared";
import { Icons, SideBar, SideBarEntry } from "shared";
import { SecondaryTool } from "./SecondaryPanel";
interface RightSideBarProps {

View file

@ -91,6 +91,7 @@ export default function SettingsEditor({
model.setValue(JSON.stringify(cleansed, null, 4));
} catch (e) {
// Turned out to not be TOML after all.
// eslint-disable-next-line no-console
console.warn("Failed to parse settings as TOML", e);
}
});

View file

@ -10,12 +10,14 @@ export type Tab = "Source" | "Settings";
export default function Header({
edit,
theme,
logo,
version,
onChangeTheme,
onShare,
}: {
edit: number | null;
theme: Theme;
logo: "ruff" | "astral";
version: string | null;
onChangeTheme: (theme: Theme) => void;
onShare?: () => void;
@ -42,19 +44,7 @@ export default function Header({
)}
>
<div className="py-4 pl-2">
<svg
width="136"
height="32"
viewBox="0 0 272 64"
className="fill-galaxy dark:fill-radiate"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M61.5 0C62.8807 0 64 1.11929 64 2.5V32.06C64 33.4407 62.8807 34.56 61.5 34.56H51.2V39.68H64V64H34.56V44.8H29.44V64H0V0H61.5ZM39.68 29.44V24.32H24.32V29.44H39.68ZM69.12 0H98.56V41.6H103.68V0H133.12V61.5C133.12 62.8807 132.001 64 130.62 64H71.62C70.2393 64 69.12 62.8807 69.12 61.5V0ZM202.24 0H145.86C144.479 0 143.36 1.11929 143.36 2.5V29.44H138.24V53.76H143.36V64H172.8V53.76H199.74C201.121 53.76 202.24 52.6407 202.24 51.26V29.44H172.8V24.32H202.24V0ZM214.98 0H271.36V24.32H241.92V29.44H271.36V51.26C271.36 52.6407 270.241 53.76 268.86 53.76H241.92V64H212.48V53.76H207.36V29.44H212.48V2.5C212.48 1.11929 213.599 0 214.98 0Z"
/>
</svg>
<Logo name={logo} className="fill-galaxy dark:fill-radiate" />
</div>
<div className="flex items-center min-w-0">
{version ? (
@ -78,3 +68,70 @@ function Divider() {
<div className="hidden sm:block mx-6 lg:mx-4 w-px h-8 bg-gray-200 dark:bg-gray-700" />
);
}
function Logo({
name,
className,
}: {
name: "ruff" | "astral";
className: string;
}) {
switch (name) {
case "ruff":
return (
<svg
height={32}
viewBox="0 0 272 64"
className={className}
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M61.5 0C62.8807 0 64 1.11929 64 2.5V32.06C64 33.4407 62.8807 34.56 61.5 34.56H51.2V39.68H64V64H34.56V44.8H29.44V64H0V0H61.5ZM39.68 29.44V24.32H24.32V29.44H39.68ZM69.12 0H98.56V41.6H103.68V0H133.12V61.5C133.12 62.8807 132.001 64 130.62 64H71.62C70.2393 64 69.12 62.8807 69.12 61.5V0ZM202.24 0H145.86C144.479 0 143.36 1.11929 143.36 2.5V29.44H138.24V53.76H143.36V64H172.8V53.76H199.74C201.121 53.76 202.24 52.6407 202.24 51.26V29.44H172.8V24.32H202.24V0ZM214.98 0H271.36V24.32H241.92V29.44H271.36V51.26C271.36 52.6407 270.241 53.76 268.86 53.76H241.92V64H212.48V53.76H207.36V29.44H212.48V2.5C212.48 1.11929 213.599 0 214.98 0Z"
/>
</svg>
);
case "astral":
return (
<svg
height={32}
viewBox="0 0 640 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M431.998 9.98526C431.998 4.47055 436.469 0 441.984 0H522.013C527.528 0 531.998 4.47056 531.998 9.98526V100H485.998V70H477.998V100H431.998V9.98526ZM493.998 46V38H469.998V46H493.998Z"
fill="#30173D"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 9.98526C0 4.47055 4.47055 0 9.98526 0H90.0147C95.5294 0 99.9999 4.47056 99.9999 9.98526V100H54V70H46V100H0V9.98526ZM62 46V38H38V46H62Z"
fill="#30173D"
/>
<path
d="M107.998 9.98526C107.998 4.47055 112.469 0 117.983 0H198.013C203.527 0 207.998 4.47055 207.998 9.98526V30H161.998V22H153.998V38H198.013C203.527 38 207.998 42.4706 207.998 47.9853V90.0147C207.998 95.5294 203.527 100 198.013 100H117.983C112.469 100 107.998 95.5294 107.998 90.0147V70L153.998 70V78H161.998V62L117.983 62C112.469 62 107.998 57.5294 107.998 52.0147V9.98526Z"
fill="#30173D"
/>
<path
d="M315.998 16H269.998V0H223.998V16H215.998V54H223.998V90.0147C223.998 95.5294 228.469 100 233.983 100H315.998V62H269.998V54H306.013C311.527 54 315.998 49.5294 315.998 44.0147V16Z"
fill="#30173D"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M423.998 9.98526C423.998 4.47055 419.528 0 414.013 0H323.998V100H369.998V70H377.998V100H423.998V62H403.998V54H414.013C419.528 54 423.998 49.5294 423.998 44.0147V9.98526ZM385.998 38V46H361.998V38H385.998Z"
fill="#30173D"
/>
<path
d="M585.999 62L639.998 62V100H539.999V2.18557e-06L585.999 0L585.999 62Z"
fill="#30173D"
/>
</svg>
);
}
}

View file

@ -140,3 +140,63 @@ export function Comments() {
</svg>
);
}
export function Add() {
return (
<svg
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
className="fill-current"
width={16}
height={16}
>
<path d="M14.0004 7V8H8.00037V14H7.00037V8H1.00037V7H7.00037V1H8.00037V7H14.0004Z" />
</svg>
);
}
export function Close() {
return (
<svg
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
className="fill-current"
width={16}
height={16}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.00028 8.70711L11.6467 12.3536L12.3538 11.6465L8.70739 8.00001L12.3538 4.35356L11.6467 3.64645L8.00028 7.2929L4.35384 3.64645L3.64673 4.35356L7.29317 8.00001L3.64673 11.6465L4.35384 12.3536L8.00028 8.70711Z"
/>
</svg>
);
}
// https://github.com/material-extensions/vscode-material-icon-theme/blob/main/icons/python.svg
// or use https://github.com/vscode-icons/vscode-icons/blob/master/icons/file_type_python.svg?short_path=677f216
export function Python({
height = 24,
width = 24,
}: {
height?: number;
width?: number;
}) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
>
<path
fill="#0288D1"
d="M9.86 2A2.86 2.86 0 0 0 7 4.86v1.68h4.29c.39 0 .71.57.71.96H4.86A2.86 2.86 0 0 0 2 10.36v3.781a2.86 2.86 0 0 0 2.86 2.86h1.18v-2.68a2.85 2.85 0 0 1 2.85-2.86h5.25c1.58 0 2.86-1.271 2.86-2.851V4.86A2.86 2.86 0 0 0 14.14 2zm-.72 1.61c.4 0 .72.12.72.71s-.32.891-.72.891c-.39 0-.71-.3-.71-.89s.32-.711.71-.711z"
/>
<path
fill="#fdd835"
d="M17.959 7v2.68a2.85 2.85 0 0 1-2.85 2.859H9.86A2.85 2.85 0 0 0 7 15.389v3.75a2.86 2.86 0 0 0 2.86 2.86h4.28A2.86 2.86 0 0 0 17 19.14v-1.68h-4.291c-.39 0-.709-.57-.709-.96h7.14A2.86 2.86 0 0 0 22 13.64V9.86A2.86 2.86 0 0 0 19.14 7zM8.32 11.513l-.004.004c.012-.002.025-.001.038-.004zm6.54 7.276c.39 0 .71.3.71.89a.71.71 0 0 1-.71.71c-.4 0-.72-.12-.72-.71s.32-.89.72-.89z"
/>
</svg>
);
}

View file

@ -7,3 +7,8 @@ export { default as ShareButton } from "./ShareButton";
export { type Theme, useTheme } from "./theme";
export { HorizontalResizeHandle, VerticalResizeHandle } from "./ResizeHandle";
export { setupMonaco } from "./setupMonaco";
export {
default as SideBar,
SideBarEntry,
type SideBarEntryProps,
} from "./SideBar";

View file

@ -16,6 +16,6 @@
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["ruff/src"],
"include": ["ruff/src", "knot/src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -5,5 +5,5 @@
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["ruff/vite.config.ts"]
"include": ["ruff/vite.config.ts", "knot/vite.config.ts"]
}