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 {
|
||||
use,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Header,
|
||||
useTheme,
|
||||
setupMonaco,
|
||||
ErrorMessage,
|
||||
HorizontalResizeHandle,
|
||||
Theme,
|
||||
VerticalResizeHandle,
|
||||
} from "shared";
|
||||
import knotSchema from "../../../../knot.schema.json";
|
||||
import initRedKnot, { Diagnostic, FileHandle, Workspace } from "red_knot_wasm";
|
||||
import { loader } from "@monaco-editor/react";
|
||||
import { Diagnostic, Workspace } from "red_knot_wasm";
|
||||
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, {
|
||||
|
|
@ -30,8 +24,7 @@ import SecondaryPanel, {
|
|||
import Diagnostics from "./Diagnostics";
|
||||
import { editor } from "monaco-editor";
|
||||
import IStandaloneCodeEditor = editor.IStandaloneCodeEditor;
|
||||
|
||||
const SETTINGS_FILE = "knot.json";
|
||||
import { FileId, ReadonlyFiles } from "../Playground";
|
||||
|
||||
interface CheckResult {
|
||||
diagnostics: Diagnostic[];
|
||||
|
|
@ -39,168 +32,46 @@ interface CheckResult {
|
|||
secondary: SecondaryPanelResult;
|
||||
}
|
||||
|
||||
export default function Chrome() {
|
||||
const initPromise = useRef<null | Promise<void>>(null);
|
||||
const [workspace, setWorkspace] = useState<null | Workspace>(null);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
const [files, dispatchFiles] = useReducer(filesReducer, {
|
||||
index: [],
|
||||
contents: Object.create(null),
|
||||
handles: Object.create(null),
|
||||
nextId: 0,
|
||||
revision: 0,
|
||||
selected: null,
|
||||
});
|
||||
export interface Props {
|
||||
workspacePromise: Promise<Workspace>;
|
||||
files: ReadonlyFiles;
|
||||
theme: Theme;
|
||||
selectedFileName: string;
|
||||
|
||||
onFileAdded(workspace: Workspace, name: string): void;
|
||||
|
||||
onFileChanged(workspace: Workspace, content: string): void;
|
||||
|
||||
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>(
|
||||
null,
|
||||
);
|
||||
|
||||
const editorRef = useRef<IStandaloneCodeEditor | null>(null);
|
||||
const [version, setVersion] = useState("");
|
||||
const [theme, setTheme] = useTheme();
|
||||
|
||||
const fileName = useMemo(() => {
|
||||
return files.index.find((file) => file.id === files.selected)?.name ?? null;
|
||||
}, [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]);
|
||||
|
||||
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 handleFileRenamed = (file: FileId, newName: string) => {
|
||||
onFileRenamed(workspace, file, newName);
|
||||
editorRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleSecondaryToolSelected = useCallback(
|
||||
(tool: SecondaryTool | null) => {
|
||||
|
|
@ -237,29 +108,19 @@ export default function Chrome() {
|
|||
}, []);
|
||||
|
||||
const checkResult = useCheckResult(files, workspace, secondaryTool);
|
||||
const error = updateError ?? checkResult.error;
|
||||
|
||||
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.selected != null ? (
|
||||
<>
|
||||
<Files
|
||||
files={files.index}
|
||||
theme={theme}
|
||||
selected={files.selected}
|
||||
onAdd={handleFileAdded}
|
||||
onAdd={(name) => onFileAdded(workspace, name)}
|
||||
onRename={handleFileRenamed}
|
||||
onSelected={handleFileClicked}
|
||||
onRemove={handleFileRemoved}
|
||||
onSelected={onFileSelected}
|
||||
onRemove={(id) => onFileRemoved(workspace, id)}
|
||||
/>
|
||||
<PanelGroup direction="horizontal" autoSaveId="main">
|
||||
<Panel
|
||||
|
|
@ -271,16 +132,28 @@ export default function Chrome() {
|
|||
<PanelGroup id="vertical" direction="vertical">
|
||||
<Panel minSize={10} className="my-2" order={0}>
|
||||
<Editor
|
||||
key={fileName}
|
||||
key={selectedFileName}
|
||||
theme={theme}
|
||||
visible={true}
|
||||
fileName={fileName ?? "lib.py"}
|
||||
fileName={selectedFileName}
|
||||
source={files.contents[files.selected]}
|
||||
diagnostics={checkResult.diagnostics}
|
||||
workspace={workspace}
|
||||
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 />
|
||||
</Panel>
|
||||
<Panel
|
||||
|
|
@ -322,83 +195,13 @@ export default function Chrome() {
|
|||
</PanelGroup>
|
||||
</>
|
||||
) : 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(
|
||||
files: FilesState,
|
||||
workspace: Workspace | null,
|
||||
files: ReadonlyFiles,
|
||||
workspace: Workspace,
|
||||
secondaryTool: SecondaryTool | null,
|
||||
): CheckResult {
|
||||
const deferredContent = useDeferredValue(
|
||||
|
|
@ -406,11 +209,7 @@ function useCheckResult(
|
|||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (
|
||||
workspace == null ||
|
||||
files.selected == null ||
|
||||
deferredContent == null
|
||||
) {
|
||||
if (files.selected == null || deferredContent == null) {
|
||||
return {
|
||||
diagnostics: [],
|
||||
error: null,
|
||||
|
|
@ -480,214 +279,9 @@ function useCheckResult(
|
|||
]);
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* 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 {
|
||||
export function formatError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : `${error}`;
|
||||
return message.startsWith("Error: ")
|
||||
? message.slice("Error: ".length)
|
||||
: 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 classNames from "classnames";
|
||||
import { useState } from "react";
|
||||
import { FileId } from "../Playground";
|
||||
|
||||
export interface Props {
|
||||
// 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 ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
import Chrome from "./Editor/Chrome";
|
||||
import Playground from "./Playground";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<Chrome />
|
||||
<Playground />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue