mirror of https://github.com/astral-sh/ruff
[red-knot] Use React suspense to show loading spinner (#16986)
## Summary Use React's suspense feature to show a loading spinner while the WASM module is initializing.
This commit is contained in:
parent
d70a3e6753
commit
338fed98a4
|
|
@ -1,26 +1,20 @@
|
||||||
import {
|
import {
|
||||||
|
use,
|
||||||
useCallback,
|
useCallback,
|
||||||
useDeferredValue,
|
useDeferredValue,
|
||||||
useEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useReducer,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Header,
|
|
||||||
useTheme,
|
|
||||||
setupMonaco,
|
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
HorizontalResizeHandle,
|
HorizontalResizeHandle,
|
||||||
|
Theme,
|
||||||
VerticalResizeHandle,
|
VerticalResizeHandle,
|
||||||
} from "shared";
|
} from "shared";
|
||||||
import knotSchema from "../../../../knot.schema.json";
|
import { Diagnostic, Workspace } from "red_knot_wasm";
|
||||||
import initRedKnot, { Diagnostic, FileHandle, Workspace } from "red_knot_wasm";
|
|
||||||
import { loader } from "@monaco-editor/react";
|
|
||||||
import { Panel, PanelGroup } from "react-resizable-panels";
|
import { Panel, PanelGroup } from "react-resizable-panels";
|
||||||
import { Files } from "./Files";
|
import { Files } from "./Files";
|
||||||
import { persist, persistLocal, restore } from "./persist";
|
|
||||||
import SecondarySideBar from "./SecondarySideBar";
|
import SecondarySideBar from "./SecondarySideBar";
|
||||||
import Editor from "./Editor";
|
import Editor from "./Editor";
|
||||||
import SecondaryPanel, {
|
import SecondaryPanel, {
|
||||||
|
|
@ -30,8 +24,7 @@ import SecondaryPanel, {
|
||||||
import Diagnostics from "./Diagnostics";
|
import Diagnostics from "./Diagnostics";
|
||||||
import { editor } from "monaco-editor";
|
import { editor } from "monaco-editor";
|
||||||
import IStandaloneCodeEditor = editor.IStandaloneCodeEditor;
|
import IStandaloneCodeEditor = editor.IStandaloneCodeEditor;
|
||||||
|
import { FileId, ReadonlyFiles } from "../Playground";
|
||||||
const SETTINGS_FILE = "knot.json";
|
|
||||||
|
|
||||||
interface CheckResult {
|
interface CheckResult {
|
||||||
diagnostics: Diagnostic[];
|
diagnostics: Diagnostic[];
|
||||||
|
|
@ -39,168 +32,46 @@ interface CheckResult {
|
||||||
secondary: SecondaryPanelResult;
|
secondary: SecondaryPanelResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Chrome() {
|
export interface Props {
|
||||||
const initPromise = useRef<null | Promise<void>>(null);
|
workspacePromise: Promise<Workspace>;
|
||||||
const [workspace, setWorkspace] = useState<null | Workspace>(null);
|
files: ReadonlyFiles;
|
||||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
theme: Theme;
|
||||||
const [files, dispatchFiles] = useReducer(filesReducer, {
|
selectedFileName: string;
|
||||||
index: [],
|
|
||||||
contents: Object.create(null),
|
onFileAdded(workspace: Workspace, name: string): void;
|
||||||
handles: Object.create(null),
|
|
||||||
nextId: 0,
|
onFileChanged(workspace: Workspace, content: string): void;
|
||||||
revision: 0,
|
|
||||||
selected: null,
|
onFileRenamed(workspace: Workspace, file: FileId, newName: string): void;
|
||||||
});
|
|
||||||
|
onFileRemoved(workspace: Workspace, file: FileId): void;
|
||||||
|
|
||||||
|
onFileSelected(id: FileId): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Chrome({
|
||||||
|
files,
|
||||||
|
selectedFileName,
|
||||||
|
workspacePromise,
|
||||||
|
theme,
|
||||||
|
onFileAdded,
|
||||||
|
onFileRenamed,
|
||||||
|
onFileRemoved,
|
||||||
|
onFileSelected,
|
||||||
|
onFileChanged,
|
||||||
|
}: Props) {
|
||||||
|
const workspace = use(workspacePromise);
|
||||||
|
|
||||||
const [secondaryTool, setSecondaryTool] = useState<SecondaryTool | null>(
|
const [secondaryTool, setSecondaryTool] = useState<SecondaryTool | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const editorRef = useRef<IStandaloneCodeEditor | null>(null);
|
const editorRef = useRef<IStandaloneCodeEditor | null>(null);
|
||||||
const [version, setVersion] = useState("");
|
|
||||||
const [theme, setTheme] = useTheme();
|
|
||||||
|
|
||||||
const fileName = useMemo(() => {
|
const handleFileRenamed = (file: FileId, newName: string) => {
|
||||||
return files.index.find((file) => file.id === files.selected)?.name ?? null;
|
onFileRenamed(workspace, file, newName);
|
||||||
}, [files.index, files.selected]);
|
editorRef.current?.focus();
|
||||||
|
};
|
||||||
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 workspace = new Workspace("/", {});
|
|
||||||
|
|
||||||
setVersion(version);
|
|
||||||
setWorkspace(workspace);
|
|
||||||
|
|
||||||
let hasSettings = false;
|
|
||||||
|
|
||||||
for (const [name, content] of Object.entries(fetchedWorkspace.files)) {
|
|
||||||
let handle = null;
|
|
||||||
if (name === SETTINGS_FILE) {
|
|
||||||
updateOptions(workspace, content, setUpdateError);
|
|
||||||
hasSettings = true;
|
|
||||||
} else {
|
|
||||||
handle = workspace.openFile(name, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchFiles({ type: "add", handle, name, content });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasSettings) {
|
|
||||||
updateOptions(workspace, null, setUpdateError);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handle = files.handles[files.selected];
|
|
||||||
|
|
||||||
if (handle != null) {
|
|
||||||
updateFile(workspace, handle, source, setUpdateError);
|
|
||||||
} else if (fileName === SETTINGS_FILE) {
|
|
||||||
updateOptions(workspace, source, setUpdateError);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[files.selected, files.handles, fileName, workspace],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFileClicked = useCallback((file: FileId) => {
|
|
||||||
dispatchFiles({ type: "selectFile", id: file });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFileAdded = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
if (workspace == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let handle = null;
|
|
||||||
|
|
||||||
if (name === SETTINGS_FILE) {
|
|
||||||
updateOptions(workspace, "{}", setUpdateError);
|
|
||||||
} else {
|
|
||||||
handle = workspace.openFile(name, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchFiles({ type: "add", name, handle, content: "" });
|
|
||||||
},
|
|
||||||
[workspace],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFileRemoved = useCallback(
|
|
||||||
(file: FileId) => {
|
|
||||||
if (workspace != null) {
|
|
||||||
const handle = files.handles[file];
|
|
||||||
if (handle == null) {
|
|
||||||
updateOptions(workspace, null, setUpdateError);
|
|
||||||
} else {
|
|
||||||
workspace.closeFile(handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchFiles({ type: "remove", id: file });
|
|
||||||
},
|
|
||||||
[workspace, files.handles],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFileRenamed = useCallback(
|
|
||||||
(file: FileId, newName: string) => {
|
|
||||||
if (workspace == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handle = files.handles[file];
|
|
||||||
let newHandle: FileHandle | null = null;
|
|
||||||
if (handle == null) {
|
|
||||||
updateOptions(workspace, null, setUpdateError);
|
|
||||||
} else {
|
|
||||||
workspace.closeFile(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newName === SETTINGS_FILE) {
|
|
||||||
updateOptions(workspace, files.contents[file], setUpdateError);
|
|
||||||
} else {
|
|
||||||
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(
|
const handleSecondaryToolSelected = useCallback(
|
||||||
(tool: SecondaryTool | null) => {
|
(tool: SecondaryTool | null) => {
|
||||||
|
|
@ -237,29 +108,19 @@ export default function Chrome() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const checkResult = useCheckResult(files, workspace, secondaryTool);
|
const checkResult = useCheckResult(files, workspace, secondaryTool);
|
||||||
const error = updateError ?? checkResult.error;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col h-full bg-ayu-background dark:bg-ayu-background-dark">
|
<>
|
||||||
<Header
|
{files.selected != null ? (
|
||||||
edit={files.revision}
|
|
||||||
theme={theme}
|
|
||||||
logo="astral"
|
|
||||||
version={version}
|
|
||||||
onChangeTheme={setTheme}
|
|
||||||
onShare={handleShare}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{workspace != null && files.selected != null ? (
|
|
||||||
<>
|
<>
|
||||||
<Files
|
<Files
|
||||||
files={files.index}
|
files={files.index}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
selected={files.selected}
|
selected={files.selected}
|
||||||
onAdd={handleFileAdded}
|
onAdd={(name) => onFileAdded(workspace, name)}
|
||||||
onRename={handleFileRenamed}
|
onRename={handleFileRenamed}
|
||||||
onSelected={handleFileClicked}
|
onSelected={onFileSelected}
|
||||||
onRemove={handleFileRemoved}
|
onRemove={(id) => onFileRemoved(workspace, id)}
|
||||||
/>
|
/>
|
||||||
<PanelGroup direction="horizontal" autoSaveId="main">
|
<PanelGroup direction="horizontal" autoSaveId="main">
|
||||||
<Panel
|
<Panel
|
||||||
|
|
@ -271,16 +132,28 @@ export default function Chrome() {
|
||||||
<PanelGroup id="vertical" direction="vertical">
|
<PanelGroup id="vertical" direction="vertical">
|
||||||
<Panel minSize={10} className="my-2" order={0}>
|
<Panel minSize={10} className="my-2" order={0}>
|
||||||
<Editor
|
<Editor
|
||||||
key={fileName}
|
key={selectedFileName}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
visible={true}
|
visible={true}
|
||||||
fileName={fileName ?? "lib.py"}
|
fileName={selectedFileName}
|
||||||
source={files.contents[files.selected]}
|
source={files.contents[files.selected]}
|
||||||
diagnostics={checkResult.diagnostics}
|
diagnostics={checkResult.diagnostics}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
onMount={handleEditorMount}
|
onMount={handleEditorMount}
|
||||||
onChange={handleSourceChanged}
|
onChange={(content) => onFileChanged(workspace, content)}
|
||||||
/>
|
/>
|
||||||
|
{checkResult.error ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
left: "10%",
|
||||||
|
right: "10%",
|
||||||
|
bottom: "10%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ErrorMessage>{checkResult.error}</ErrorMessage>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<VerticalResizeHandle />
|
<VerticalResizeHandle />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel
|
<Panel
|
||||||
|
|
@ -322,83 +195,13 @@ export default function Chrome() {
|
||||||
</PanelGroup>
|
</PanelGroup>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
{error ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
left: "10%",
|
|
||||||
right: "10%",
|
|
||||||
bottom: "10%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ErrorMessage>{error}</ErrorMessage>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = JSON.stringify(
|
|
||||||
{
|
|
||||||
environment: {
|
|
||||||
"python-version": "3.13",
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"division-by-zero": "error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
4,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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, {
|
|
||||||
uri: "https://raw.githubusercontent.com/astral-sh/ruff/main/knot.schema.json",
|
|
||||||
fileMatch: ["knot.json"],
|
|
||||||
schema: knotSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
const restored = await restore();
|
|
||||||
|
|
||||||
const workspace = restored ?? {
|
|
||||||
files: {
|
|
||||||
"main.py": "import os",
|
|
||||||
"knot.json": DEFAULT_SETTINGS,
|
|
||||||
},
|
|
||||||
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(
|
function useCheckResult(
|
||||||
files: FilesState,
|
files: ReadonlyFiles,
|
||||||
workspace: Workspace | null,
|
workspace: Workspace,
|
||||||
secondaryTool: SecondaryTool | null,
|
secondaryTool: SecondaryTool | null,
|
||||||
): CheckResult {
|
): CheckResult {
|
||||||
const deferredContent = useDeferredValue(
|
const deferredContent = useDeferredValue(
|
||||||
|
|
@ -406,11 +209,7 @@ function useCheckResult(
|
||||||
);
|
);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (
|
if (files.selected == null || deferredContent == null) {
|
||||||
workspace == null ||
|
|
||||||
files.selected == null ||
|
|
||||||
deferredContent == null
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
error: null,
|
error: null,
|
||||||
|
|
@ -480,214 +279,9 @@ function useCheckResult(
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileId = number;
|
export function formatError(error: unknown): string {
|
||||||
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* Files without a file handle are well-known files that are only handled by the
|
|
||||||
* playground (e.g. knot.json)
|
|
||||||
*/
|
|
||||||
handles: Readonly<{ [id: FileId]: FileHandle | null }>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The content per file indexed by file id.
|
|
||||||
*/
|
|
||||||
contents: Readonly<{ [id: FileId]: string }>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The revision. Gets incremented every time files changes.
|
|
||||||
*/
|
|
||||||
revision: number;
|
|
||||||
nextId: FileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileAction =
|
|
||||||
| {
|
|
||||||
type: "add";
|
|
||||||
handle: FileHandle | null;
|
|
||||||
/// The file name
|
|
||||||
name: string;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "change";
|
|
||||||
id: FileId;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
| { type: "rename"; id: FileId; to: string; newHandle: FileHandle | null }
|
|
||||||
| {
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatError(error: unknown): string {
|
|
||||||
const message = error instanceof Error ? error.message : `${error}`;
|
const message = error instanceof Error ? error.message : `${error}`;
|
||||||
return message.startsWith("Error: ")
|
return message.startsWith("Error: ")
|
||||||
? message.slice("Error: ".length)
|
? message.slice("Error: ".length)
|
||||||
: message;
|
: message;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateOptions(
|
|
||||||
workspace: Workspace | null,
|
|
||||||
content: string | null,
|
|
||||||
setError: (error: string | null) => void,
|
|
||||||
) {
|
|
||||||
if (workspace == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
content = content ?? DEFAULT_SETTINGS;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const settings = JSON.parse(content);
|
|
||||||
workspace?.updateOptions(settings);
|
|
||||||
setError(null);
|
|
||||||
} catch (error) {
|
|
||||||
setError(`Failed to update 'knot.json' options: ${formatError(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFile(
|
|
||||||
workspace: Workspace | null,
|
|
||||||
handle: FileHandle,
|
|
||||||
content: string,
|
|
||||||
setError: (error: string | null) => void,
|
|
||||||
) {
|
|
||||||
if (workspace == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
workspace.updateFile(handle, content);
|
|
||||||
setError(null);
|
|
||||||
} catch (error) {
|
|
||||||
setError(`Failed to update file: ${formatError(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { FileId } from "./Chrome";
|
|
||||||
import { Icons, Theme } from "shared";
|
import { Icons, Theme } from "shared";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { FileId } from "../Playground";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
// The file names
|
// The file names
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,459 @@
|
||||||
|
import {
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useDeferredValue,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { ErrorMessage, Header, setupMonaco, useTheme } from "shared";
|
||||||
|
import { FileHandle, Workspace } from "red_knot_wasm";
|
||||||
|
import { persist, persistLocal, restore } from "./Editor/persist";
|
||||||
|
import initRedKnot from "../red_knot_wasm";
|
||||||
|
import { loader } from "@monaco-editor/react";
|
||||||
|
import knotSchema from "../../../knot.schema.json";
|
||||||
|
import Chrome, { formatError } from "./Editor/Chrome";
|
||||||
|
|
||||||
|
export const SETTINGS_FILE_NAME = "knot.json";
|
||||||
|
|
||||||
|
export default function Playground() {
|
||||||
|
const [theme, setTheme] = useTheme();
|
||||||
|
const [version, setVersion] = useState<string>("0.0.0");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const workspacePromiseRef = useRef<Promise<Workspace> | null>(null);
|
||||||
|
|
||||||
|
let workspacePromise = workspacePromiseRef.current;
|
||||||
|
if (workspacePromise == null) {
|
||||||
|
workspacePromiseRef.current = workspacePromise = startPlayground().then(
|
||||||
|
(fetched) => {
|
||||||
|
setVersion(fetched.version);
|
||||||
|
const workspace = new Workspace("/", {});
|
||||||
|
|
||||||
|
let hasSettings = false;
|
||||||
|
|
||||||
|
for (const [name, content] of Object.entries(fetched.workspace.files)) {
|
||||||
|
let handle = null;
|
||||||
|
if (name === SETTINGS_FILE_NAME) {
|
||||||
|
updateOptions(workspace, content, setError);
|
||||||
|
hasSettings = true;
|
||||||
|
} else {
|
||||||
|
handle = workspace.openFile(name, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchFiles({ type: "add", handle, content, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasSettings) {
|
||||||
|
updateOptions(workspace, null, setError);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchFiles({
|
||||||
|
type: "selectFileByName",
|
||||||
|
name: fetched.workspace.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
return workspace;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [files, dispatchFiles] = useReducer(filesReducer, {
|
||||||
|
index: [],
|
||||||
|
contents: Object.create(null),
|
||||||
|
handles: Object.create(null),
|
||||||
|
nextId: 0,
|
||||||
|
revision: 0,
|
||||||
|
selected: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileName = useMemo(() => {
|
||||||
|
return (
|
||||||
|
files.index.find((file) => file.id === files.selected)?.name ?? "lib.py"
|
||||||
|
);
|
||||||
|
}, [files.index, files.selected]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const handleFileAdded = (workspace: Workspace, name: string) => {
|
||||||
|
let handle = null;
|
||||||
|
|
||||||
|
if (name === SETTINGS_FILE_NAME) {
|
||||||
|
updateOptions(workspace, "{}", setError);
|
||||||
|
} else {
|
||||||
|
handle = workspace.openFile(name, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchFiles({ type: "add", name, handle, content: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChanged = (workspace: Workspace, content: string) => {
|
||||||
|
if (files.selected == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchFiles({
|
||||||
|
type: "change",
|
||||||
|
id: files.selected,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = files.handles[files.selected];
|
||||||
|
|
||||||
|
if (handle != null) {
|
||||||
|
updateFile(workspace, handle, content, setError);
|
||||||
|
} else if (fileName === SETTINGS_FILE_NAME) {
|
||||||
|
updateOptions(workspace, content, setError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileRenamed = (
|
||||||
|
workspace: Workspace,
|
||||||
|
file: FileId,
|
||||||
|
newName: string,
|
||||||
|
) => {
|
||||||
|
const handle = files.handles[file];
|
||||||
|
let newHandle: FileHandle | null = null;
|
||||||
|
if (handle == null) {
|
||||||
|
updateOptions(workspace, null, setError);
|
||||||
|
} else {
|
||||||
|
workspace.closeFile(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newName === SETTINGS_FILE_NAME) {
|
||||||
|
updateOptions(workspace, files.contents[file], setError);
|
||||||
|
} else {
|
||||||
|
newHandle = workspace.openFile(newName, files.contents[file]);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchFiles({ type: "rename", id: file, to: newName, newHandle });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileRemoved = (workspace: Workspace, file: FileId) => {
|
||||||
|
const handle = files.handles[file];
|
||||||
|
if (handle == null) {
|
||||||
|
updateOptions(workspace, null, setError);
|
||||||
|
} else {
|
||||||
|
workspace.closeFile(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchFiles({ type: "remove", id: file });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelected = useCallback((file: FileId) => {
|
||||||
|
dispatchFiles({ type: "selectFile", id: file });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<Chrome
|
||||||
|
files={files}
|
||||||
|
workspacePromise={workspacePromise}
|
||||||
|
theme={theme}
|
||||||
|
selectedFileName={fileName}
|
||||||
|
onFileAdded={handleFileAdded}
|
||||||
|
onFileRenamed={handleFileRenamed}
|
||||||
|
onFileRemoved={handleFileRemoved}
|
||||||
|
onFileSelected={handleFileSelected}
|
||||||
|
onFileChanged={handleFileChanged}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
{error ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
left: "10%",
|
||||||
|
right: "10%",
|
||||||
|
bottom: "10%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ErrorMessage>{error}</ErrorMessage>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS = JSON.stringify(
|
||||||
|
{
|
||||||
|
environment: {
|
||||||
|
"python-version": "3.13",
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"division-by-zero": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileId = number;
|
||||||
|
|
||||||
|
export type ReadonlyFiles = Readonly<FilesState>;
|
||||||
|
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* Files without a file handle are well-known files that are only handled by the
|
||||||
|
* playground (e.g. knot.json)
|
||||||
|
*/
|
||||||
|
handles: Readonly<{ [id: FileId]: FileHandle | null }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The content per file indexed by file id.
|
||||||
|
*/
|
||||||
|
contents: Readonly<{ [id: FileId]: string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The revision. Gets incremented every time files changes.
|
||||||
|
*/
|
||||||
|
revision: number;
|
||||||
|
nextId: FileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileAction =
|
||||||
|
| {
|
||||||
|
type: "add";
|
||||||
|
handle: FileHandle | null;
|
||||||
|
/// The file name
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "change";
|
||||||
|
id: FileId;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
| { type: "rename"; id: FileId; to: string; newHandle: FileHandle | null }
|
||||||
|
| {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitializedPlayground {
|
||||||
|
version: string;
|
||||||
|
workspace: { files: { [name: string]: string }; current: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run once during startup. Initializes monaco, loads the wasm file, and restores the previous editor state.
|
||||||
|
async function startPlayground(): Promise<InitializedPlayground> {
|
||||||
|
await initRedKnot();
|
||||||
|
const monaco = await loader.init();
|
||||||
|
|
||||||
|
setupMonaco(monaco, {
|
||||||
|
uri: "https://raw.githubusercontent.com/astral-sh/ruff/main/knot.schema.json",
|
||||||
|
fileMatch: ["knot.json"],
|
||||||
|
schema: knotSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restored = await restore();
|
||||||
|
|
||||||
|
const workspace = restored ?? {
|
||||||
|
files: {
|
||||||
|
"main.py": "import os",
|
||||||
|
"knot.json": DEFAULT_SETTINGS,
|
||||||
|
},
|
||||||
|
current: "main.py",
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: "0.0.0",
|
||||||
|
workspace,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOptions(
|
||||||
|
workspace: Workspace | null,
|
||||||
|
content: string | null,
|
||||||
|
setError: (error: string | null) => void,
|
||||||
|
) {
|
||||||
|
content = content ?? DEFAULT_SETTINGS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = JSON.parse(content);
|
||||||
|
workspace?.updateOptions(settings);
|
||||||
|
setError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setError(`Failed to update 'knot.json' options: ${formatError(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFile(
|
||||||
|
workspace: Workspace,
|
||||||
|
handle: FileHandle,
|
||||||
|
content: string,
|
||||||
|
setError: (error: string | null) => void,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
workspace.updateFile(handle, content);
|
||||||
|
setError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setError(`Failed to update file: ${formatError(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return <div className="align-middle text-center my-2">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import Chrome from "./Editor/Chrome";
|
import Playground from "./Playground";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<Chrome />
|
<Playground />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue