diff --git a/playground/knot/src/Editor/Chrome.tsx b/playground/knot/src/Editor/Chrome.tsx index 7a9c02f3cf..969e0e0dd8 100644 --- a/playground/knot/src/Editor/Chrome.tsx +++ b/playground/knot/src/Editor/Chrome.tsx @@ -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); - const [workspace, setWorkspace] = useState(null); - const [updateError, setUpdateError] = useState(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; + 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( null, ); const editorRef = useRef(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 ( -
-
- - {workspace != null && files.selected != null ? ( + <> + {files.selected != null ? ( <> onFileAdded(workspace, name)} onRename={handleFileRenamed} - onSelected={handleFileClicked} - onRemove={handleFileRemoved} + onSelected={onFileSelected} + onRemove={(id) => onFileRemoved(workspace, id)} /> onFileChanged(workspace, content)} /> + {checkResult.error ? ( +
+ {checkResult.error} +
+ ) : null}
) : null} - - {error ? ( -
- {error} -
- ) : null} -
+ ); } -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, - 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)}`); - } -} diff --git a/playground/knot/src/Editor/Files.tsx b/playground/knot/src/Editor/Files.tsx index e68fbd4acd..314684cedb 100644 --- a/playground/knot/src/Editor/Files.tsx +++ b/playground/knot/src/Editor/Files.tsx @@ -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 diff --git a/playground/knot/src/Playground.tsx b/playground/knot/src/Playground.tsx new file mode 100644 index 0000000000..5fabe8386b --- /dev/null +++ b/playground/knot/src/Playground.tsx @@ -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("0.0.0"); + const [error, setError] = useState(null); + const workspacePromiseRef = useRef | 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 ( +
+
+ + }> + + + {error ? ( +
+ {error} +
+ ) : null} +
+ ); +} + +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; + +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, + 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 { + 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
Loading...
; +} diff --git a/playground/knot/src/main.tsx b/playground/knot/src/main.tsx index fbe0181a4d..d285d8abfb 100644 --- a/playground/knot/src/main.tsx +++ b/playground/knot/src/main.tsx @@ -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( - + , );