From 3dd6d0a4222c4265ea483d494304dbf55a663c9d Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Sun, 6 Apr 2025 19:09:56 +0200 Subject: [PATCH] [playground] Add Reset button (#17236) ## Summary Add a *Reset* button to both Ruff's and Red Knot's playground that resets the playground to its initial state. Closes https://github.com/astral-sh/ruff/issues/17195 ## Test Plan https://github.com/user-attachments/assets/753cca19-155a-44b1-89ba-76744487a55d https://github.com/user-attachments/assets/7d19f04c-70f4-4d9e-b745-0486cb1d4993 --- playground/knot/src/Editor/Chrome.tsx | 34 +++--- playground/knot/src/Editor/Editor.tsx | 15 +-- playground/knot/src/Editor/Files.tsx | 6 +- playground/knot/src/Playground.tsx | 148 ++++++++++++++++++-------- playground/ruff/src/Editor/Chrome.tsx | 10 ++ playground/shared/src/Header.tsx | 69 ++++++++---- playground/shared/src/ShareButton.tsx | 16 ++- playground/shared/src/ThemeButton.tsx | 2 +- 8 files changed, 194 insertions(+), 106 deletions(-) diff --git a/playground/knot/src/Editor/Chrome.tsx b/playground/knot/src/Editor/Chrome.tsx index 2884762d30..4e055015d6 100644 --- a/playground/knot/src/Editor/Chrome.tsx +++ b/playground/knot/src/Editor/Chrome.tsx @@ -40,15 +40,15 @@ export interface Props { theme: Theme; selectedFileName: string; - onFileAdded(workspace: Workspace, name: string): void; + onAddFile(workspace: Workspace, name: string): void; - onFileChanged(workspace: Workspace, content: string): void; + onChangeFile(workspace: Workspace, content: string): void; - onFileRenamed(workspace: Workspace, file: FileId, newName: string): void; + onRenameFile(workspace: Workspace, file: FileId, newName: string): void; - onFileRemoved(workspace: Workspace, file: FileId): void; + onRemoveFile(workspace: Workspace, file: FileId): void; - onFileSelected(id: FileId): void; + onSelectFile(id: FileId): void; } export default function Chrome({ @@ -56,11 +56,11 @@ export default function Chrome({ selectedFileName, workspacePromise, theme, - onFileAdded, - onFileRenamed, - onFileRemoved, - onFileSelected, - onFileChanged, + onAddFile, + onRenameFile, + onRemoveFile, + onSelectFile, + onChangeFile, }: Props) { const workspace = use(workspacePromise); @@ -74,7 +74,7 @@ export default function Chrome({ } | null>(null); const handleFileRenamed = (file: FileId, newName: string) => { - onFileRenamed(workspace, file, newName); + onRenameFile(workspace, file, newName); editorRef.current?.editor.focus(); }; @@ -129,9 +129,9 @@ export default function Chrome({ ?.dispose(); } - onFileRemoved(workspace, id); + onRemoveFile(workspace, id); }, - [workspace, files.index, onFileRemoved], + [workspace, files.index, onRemoveFile], ); const checkResult = useCheckResult(files, workspace, secondaryTool); @@ -144,9 +144,9 @@ export default function Chrome({ files={files.index} theme={theme} selected={files.selected} - onAdd={(name) => onFileAdded(workspace, name)} + onAdd={(name) => onAddFile(workspace, name)} onRename={handleFileRenamed} - onSelected={onFileSelected} + onSelect={onSelectFile} onRemove={handleRemoved} /> @@ -167,8 +167,8 @@ export default function Chrome({ diagnostics={checkResult.diagnostics} workspace={workspace} onMount={handleEditorMount} - onChange={(content) => onFileChanged(workspace, content)} - onFileOpened={onFileSelected} + onChange={(content) => onChangeFile(workspace, content)} + onOpenFile={onSelectFile} /> {checkResult.error ? (
(null); @@ -59,7 +59,7 @@ export default function Editor({ serverRef.current.update({ files, workspace, - onFileOpened, + onOpenFile, }); } @@ -96,7 +96,7 @@ export default function Editor({ const server = new PlaygroundServer(instance, { workspace, files, - onFileOpened, + onOpenFile, }); server.updateDiagnostics(diagnostics); @@ -105,11 +105,12 @@ export default function Editor({ onMount(editor, instance); }, - [files, onFileOpened, workspace, onMount, diagnostics], + [files, onOpenFile, workspace, onMount, diagnostics], ); return ( void; + onOpenFile: (file: FileId) => void; } class PlaygroundServer @@ -333,7 +334,7 @@ class PlaygroundServer if (files.selected !== fileId) { source.setModel(model); - this.props.onFileOpened(fileId); + this.props.onOpenFile(fileId); } if (selectionOrPosition != null) { diff --git a/playground/knot/src/Editor/Files.tsx b/playground/knot/src/Editor/Files.tsx index 19cbfeb93a..215baa2476 100644 --- a/playground/knot/src/Editor/Files.tsx +++ b/playground/knot/src/Editor/Files.tsx @@ -14,7 +14,7 @@ export interface Props { onRemove(id: FileId): void; - onSelected(id: FileId): void; + onSelect(id: FileId): void; onRename(id: FileId, newName: string): void; } @@ -26,7 +26,7 @@ export function Files({ onAdd, onRemove, onRename, - onSelected, + onSelect, }: Props) { const handleAdd = () => { let index: number | null = null; @@ -54,7 +54,7 @@ export function Files({ onSelected(id)} + onClicked={() => onSelect(id)} onRenamed={(newName) => { if (!files.some(({ name }) => name === newName)) { onRename(id, newName); diff --git a/playground/knot/src/Playground.tsx b/playground/knot/src/Playground.tsx index 59e8f81a6d..c3942044cb 100644 --- a/playground/knot/src/Playground.tsx +++ b/playground/knot/src/Playground.tsx @@ -1,4 +1,5 @@ import { + ActionDispatch, Suspense, useCallback, useDeferredValue, @@ -22,6 +23,7 @@ export default function Playground() { const [version, setVersion] = useState("0.0.0"); const [error, setError] = useState(null); const workspacePromiseRef = useRef | null>(null); + const [workspace, setWorkspace] = useState(null); let workspacePromise = workspacePromiseRef.current; if (workspacePromise == null) { @@ -29,43 +31,14 @@ export default function Playground() { (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, - }); - + restoreWorkspace(workspace, fetched.workspace, dispatchFiles, setError); + setWorkspace(workspace); return workspace; }, ); } - const [files, dispatchFiles] = useReducer(filesReducer, { - index: [], - contents: Object.create(null), - handles: Object.create(null), - nextId: 0, - revision: 0, - selected: null, - }); + const [files, dispatchFiles] = useReducer(filesReducer, INIT_FILES_STATE); const fileName = useMemo(() => { return ( @@ -155,6 +128,29 @@ export default function Playground() { dispatchFiles({ type: "selectFile", id: file }); }, []); + const handleReset = useCallback(() => { + if (workspace == null) { + return; + } + + // Close all open files + for (const file of files.index) { + const handle = files.handles[file.id]; + + if (handle != null) { + try { + workspace.closeFile(handle); + } catch (e) { + setError(formatError(e)); + } + } + } + + dispatchFiles({ type: "reset" }); + + restoreWorkspace(workspace, DEFAULT_WORKSPACE, dispatchFiles, setError); + }, [files.handles, files.index, workspace]); + return (
}> @@ -172,11 +169,11 @@ export default function Playground() { workspacePromise={workspacePromise} theme={theme} selectedFileName={fileName} - onFileAdded={handleFileAdded} - onFileRenamed={handleFileRenamed} - onFileRemoved={handleFileRemoved} - onFileSelected={handleFileSelected} - onFileChanged={handleFileChanged} + onAddFile={handleFileAdded} + onRenameFile={handleFileRenamed} + onRemoveFile={handleFileRemoved} + onSelectFile={handleFileSelected} + onChangeFile={handleFileChanged} /> {error ? ( @@ -208,6 +205,14 @@ export const DEFAULT_SETTINGS = JSON.stringify( 4, ); +const DEFAULT_WORKSPACE = { + files: { + "main.py": "import os", + "knot.json": DEFAULT_SETTINGS, + }, + current: "main.py", +}; + /** * Persists the files to local storage. This is done deferred to avoid too frequent writes. */ @@ -254,6 +259,13 @@ interface FilesState { * The revision. Gets incremented every time files changes. */ revision: number; + + /** + * Revision identifying this playground. Gets incremented every time the + * playground is reset. + */ + playgroundRevision: number; + nextId: FileId; } @@ -276,7 +288,18 @@ export type FileAction = id: FileId; } | { type: "selectFile"; id: FileId } - | { type: "selectFileByName"; name: string }; + | { type: "selectFileByName"; name: string } + | { type: "reset" }; + +const INIT_FILES_STATE: ReadonlyFiles = { + index: [], + contents: Object.create(null), + handles: Object.create(null), + nextId: 0, + revision: 0, + selected: null, + playgroundRevision: 0, +}; function filesReducer( state: Readonly, @@ -366,6 +389,14 @@ function filesReducer( selected, }; } + + case "reset": { + return { + ...INIT_FILES_STATE, + playgroundRevision: state.playgroundRevision + 1, + revision: state.revision + 1, + }; + } } } @@ -410,13 +441,7 @@ async function startPlayground(): Promise { const restored = await restore(); - const workspace = restored ?? { - files: { - "main.py": "import os", - "knot.json": DEFAULT_SETTINGS, - }, - current: "main.py", - }; + const workspace = restored ?? DEFAULT_WORKSPACE; return { version: "0.0.0", @@ -457,3 +482,36 @@ function updateFile( function Loading() { return
Loading...
; } + +function restoreWorkspace( + workspace: Workspace, + state: { + files: { [name: string]: string }; + current: string; + }, + dispatchFiles: ActionDispatch<[FileAction]>, + setError: (error: string | null) => void, +) { + let hasSettings = false; + + for (const [name, content] of Object.entries(state.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: state.current, + }); +} diff --git a/playground/ruff/src/Editor/Chrome.tsx b/playground/ruff/src/Editor/Chrome.tsx index 684c01919c..db70b06471 100644 --- a/playground/ruff/src/Editor/Chrome.tsx +++ b/playground/ruff/src/Editor/Chrome.tsx @@ -65,6 +65,15 @@ export default function Chrome() { [pythonSource], ); + const handleResetClicked = useCallback(() => { + const pythonSource = DEFAULT_PYTHON_SOURCE; + const settings = stringify(Workspace.defaultSettings()); + + persistLocal({ pythonSource, settingsSource: settings }); + setPythonSource(pythonSource); + setSettings(settings); + }, []); + const source: Source | null = useMemo(() => { if (pythonSource == null || settings == null) { return null; @@ -82,6 +91,7 @@ export default function Chrome() { version={ruffVersion} onChangeTheme={setTheme} onShare={handleShare} + onReset={handleResetClicked} />
diff --git a/playground/shared/src/Header.tsx b/playground/shared/src/Header.tsx index b1dedf064a..3dfad37852 100644 --- a/playground/shared/src/Header.tsx +++ b/playground/shared/src/Header.tsx @@ -4,6 +4,7 @@ import ThemeButton from "./ThemeButton"; import ShareButton from "./ShareButton"; import { Theme } from "./theme"; import VersionTag from "./VersionTag"; +import AstralButton from "./AstralButton"; export default function Header({ edit, @@ -11,6 +12,7 @@ export default function Header({ logo, version, onChangeTheme, + onReset, onShare, }: { edit: number | null; @@ -18,43 +20,42 @@ export default function Header({ logo: "ruff" | "astral"; version: string | null; onChangeTheme: (theme: Theme) => void; - onShare?: () => void; + onReset?(): void; + onShare: () => void; }) { return (
-
+
{version ? ( -
+
v{version}
) : null} - +
+ +
+
+ +
+
@@ -63,7 +64,16 @@ export default function Header({ function Divider() { return ( -
+
); } @@ -121,3 +131,16 @@ function Logo({ ); } } + +function ResetButton({ onClicked }: { onClicked?: () => void }) { + return ( + + Reset + + ); +} diff --git a/playground/shared/src/ShareButton.tsx b/playground/shared/src/ShareButton.tsx index a69603feeb..65fdca5da7 100644 --- a/playground/shared/src/ShareButton.tsx +++ b/playground/shared/src/ShareButton.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import AstralButton from "./AstralButton"; -export default function ShareButton({ onShare }: { onShare?: () => void }) { +export default function ShareButton({ onShare }: { onShare: () => void }) { const [copied, setCopied] = useState(false); useEffect(() => { @@ -28,15 +28,11 @@ export default function ShareButton({ onShare }: { onShare?: () => void }) { { - setCopied(true); - onShare(); - } - : undefined - } + disabled={copied} + onClick={() => { + setCopied(true); + onShare(); + }} > onChange(theme === "light" ? "dark" : "light")} >