mirror of
https://github.com/astral-sh/ruff.git
synced 2025-07-07 13:15:06 +00:00
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:
parent
dcf31c9348
commit
c027979851
45 changed files with 2347 additions and 491 deletions
5
.github/workflows/ci.yaml
vendored
5
.github/workflows/ci.yaml
vendored
|
@ -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
|
||||
|
|
6
.github/workflows/publish-playground.yml
vendored
6
.github/workflows/publish-playground.yml
vendored
|
@ -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
2
Cargo.lock
generated
|
@ -2644,6 +2644,8 @@ dependencies = [
|
|||
"ruff_db",
|
||||
"ruff_notebook",
|
||||
"ruff_python_ast",
|
||||
"ruff_source_file",
|
||||
"ruff_text_size",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
]
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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`");
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
**.md
|
||||
ruff/dist
|
||||
ruff/ruff_wasm
|
||||
knot/dist
|
||||
knot/red_knot_wasm
|
|
@ -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`.
|
||||
|
|
947
playground/api/package-lock.json
generated
947
playground/api/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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": {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
39
playground/knot/index.html
Normal file
39
playground/knot/index.html
Normal 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>
|
35
playground/knot/package.json
Normal file
35
playground/knot/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
BIN
playground/knot/public/Astral.png
Normal file
BIN
playground/knot/public/Astral.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
BIN
playground/knot/public/apple-touch-icon.png
Normal file
BIN
playground/knot/public/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.8 KiB |
BIN
playground/knot/public/favicon-16x16.png
Normal file
BIN
playground/knot/public/favicon-16x16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
BIN
playground/knot/public/favicon-32x32.png
Normal file
BIN
playground/knot/public/favicon-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2 KiB |
BIN
playground/knot/public/favicon.ico
Normal file
BIN
playground/knot/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.3 KiB |
587
playground/knot/src/Editor/Chrome.tsx
Normal file
587
playground/knot/src/Editor/Chrome.tsx
Normal 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 };
|
||||
}
|
99
playground/knot/src/Editor/Diagnostics.tsx
Normal file
99
playground/knot/src/Editor/Diagnostics.tsx
Normal 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>
|
||||
);
|
||||
}
|
134
playground/knot/src/Editor/Editor.tsx
Normal file
134
playground/knot/src/Editor/Editor.tsx
Normal 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: [],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
180
playground/knot/src/Editor/Files.tsx
Normal file
180
playground/knot/src/Editor/Files.tsx
Normal 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>
|
||||
);
|
||||
}
|
78
playground/knot/src/Editor/SecondaryPanel.tsx
Normal file
78
playground/knot/src/Editor/SecondaryPanel.tsx
Normal 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>;
|
||||
}
|
||||
}
|
||||
}
|
31
playground/knot/src/Editor/SecondarySideBar.tsx
Normal file
31
playground/knot/src/Editor/SecondarySideBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
38
playground/knot/src/Editor/api.ts
Normal file
38
playground/knot/src/Editor/api.ts
Normal 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();
|
||||
}
|
54
playground/knot/src/Editor/persist.ts
Normal file
54
playground/knot/src/Editor/persist.ts
Normal 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);
|
||||
}
|
||||
}
|
123
playground/knot/src/index.css
Normal file
123
playground/knot/src/index.css
Normal 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;
|
||||
}
|
10
playground/knot/src/main.tsx
Normal file
10
playground/knot/src/main.tsx
Normal 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
6
playground/knot/src/third-party.d.ts
vendored
Normal 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
1
playground/knot/src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
8
playground/knot/vite.config.ts
Normal file
8
playground/knot/vite.config.ts
Normal 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()],
|
||||
});
|
29
playground/package-lock.json
generated
29
playground/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
],
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { Icons } from "shared";
|
||||
import SideBar, { SideBarEntry } from "./SideBar";
|
||||
import { Icons, SideBar, SideBarEntry } from "shared";
|
||||
|
||||
type Tool = "Settings" | "Source";
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -16,6 +16,6 @@
|
|||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["ruff/src"],
|
||||
"include": ["ruff/src", "knot/src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
|
|
@ -5,5 +5,5 @@
|
|||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["ruff/vite.config.ts"]
|
||||
"include": ["ruff/vite.config.ts", "knot/vite.config.ts"]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue