[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
This commit is contained in:
Micha Reiser 2025-04-06 19:09:56 +02:00 committed by GitHub
parent 4571c5f56f
commit 3dd6d0a422
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 194 additions and 106 deletions

View File

@ -40,15 +40,15 @@ export interface Props {
theme: Theme; theme: Theme;
selectedFileName: string; 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({ export default function Chrome({
@ -56,11 +56,11 @@ export default function Chrome({
selectedFileName, selectedFileName,
workspacePromise, workspacePromise,
theme, theme,
onFileAdded, onAddFile,
onFileRenamed, onRenameFile,
onFileRemoved, onRemoveFile,
onFileSelected, onSelectFile,
onFileChanged, onChangeFile,
}: Props) { }: Props) {
const workspace = use(workspacePromise); const workspace = use(workspacePromise);
@ -74,7 +74,7 @@ export default function Chrome({
} | null>(null); } | null>(null);
const handleFileRenamed = (file: FileId, newName: string) => { const handleFileRenamed = (file: FileId, newName: string) => {
onFileRenamed(workspace, file, newName); onRenameFile(workspace, file, newName);
editorRef.current?.editor.focus(); editorRef.current?.editor.focus();
}; };
@ -129,9 +129,9 @@ export default function Chrome({
?.dispose(); ?.dispose();
} }
onFileRemoved(workspace, id); onRemoveFile(workspace, id);
}, },
[workspace, files.index, onFileRemoved], [workspace, files.index, onRemoveFile],
); );
const checkResult = useCheckResult(files, workspace, secondaryTool); const checkResult = useCheckResult(files, workspace, secondaryTool);
@ -144,9 +144,9 @@ export default function Chrome({
files={files.index} files={files.index}
theme={theme} theme={theme}
selected={files.selected} selected={files.selected}
onAdd={(name) => onFileAdded(workspace, name)} onAdd={(name) => onAddFile(workspace, name)}
onRename={handleFileRenamed} onRename={handleFileRenamed}
onSelected={onFileSelected} onSelect={onSelectFile}
onRemove={handleRemoved} onRemove={handleRemoved}
/> />
<PanelGroup direction="horizontal" autoSaveId="main"> <PanelGroup direction="horizontal" autoSaveId="main">
@ -167,8 +167,8 @@ export default function Chrome({
diagnostics={checkResult.diagnostics} diagnostics={checkResult.diagnostics}
workspace={workspace} workspace={workspace}
onMount={handleEditorMount} onMount={handleEditorMount}
onChange={(content) => onFileChanged(workspace, content)} onChange={(content) => onChangeFile(workspace, content)}
onFileOpened={onFileSelected} onOpenFile={onSelectFile}
/> />
{checkResult.error ? ( {checkResult.error ? (
<div <div

View File

@ -38,7 +38,7 @@ type Props = {
workspace: Workspace; workspace: Workspace;
onChange(content: string): void; onChange(content: string): void;
onMount(editor: IStandaloneCodeEditor, monaco: Monaco): void; onMount(editor: IStandaloneCodeEditor, monaco: Monaco): void;
onFileOpened(file: FileId): void; onOpenFile(file: FileId): void;
}; };
export default function Editor({ export default function Editor({
@ -51,7 +51,7 @@ export default function Editor({
workspace, workspace,
onChange, onChange,
onMount, onMount,
onFileOpened, onOpenFile,
}: Props) { }: Props) {
const serverRef = useRef<PlaygroundServer | null>(null); const serverRef = useRef<PlaygroundServer | null>(null);
@ -59,7 +59,7 @@ export default function Editor({
serverRef.current.update({ serverRef.current.update({
files, files,
workspace, workspace,
onFileOpened, onOpenFile,
}); });
} }
@ -96,7 +96,7 @@ export default function Editor({
const server = new PlaygroundServer(instance, { const server = new PlaygroundServer(instance, {
workspace, workspace,
files, files,
onFileOpened, onOpenFile,
}); });
server.updateDiagnostics(diagnostics); server.updateDiagnostics(diagnostics);
@ -105,11 +105,12 @@ export default function Editor({
onMount(editor, instance); onMount(editor, instance);
}, },
[files, onFileOpened, workspace, onMount, diagnostics], [files, onOpenFile, workspace, onMount, diagnostics],
); );
return ( return (
<Moncao <Moncao
key={files.playgroundRevision}
onMount={handleMount} onMount={handleMount}
options={{ options={{
fixedOverflowWidgets: true, fixedOverflowWidgets: true,
@ -133,7 +134,7 @@ export default function Editor({
interface PlaygroundServerProps { interface PlaygroundServerProps {
workspace: Workspace; workspace: Workspace;
files: ReadonlyFiles; files: ReadonlyFiles;
onFileOpened: (file: FileId) => void; onOpenFile: (file: FileId) => void;
} }
class PlaygroundServer class PlaygroundServer
@ -333,7 +334,7 @@ class PlaygroundServer
if (files.selected !== fileId) { if (files.selected !== fileId) {
source.setModel(model); source.setModel(model);
this.props.onFileOpened(fileId); this.props.onOpenFile(fileId);
} }
if (selectionOrPosition != null) { if (selectionOrPosition != null) {

View File

@ -14,7 +14,7 @@ export interface Props {
onRemove(id: FileId): void; onRemove(id: FileId): void;
onSelected(id: FileId): void; onSelect(id: FileId): void;
onRename(id: FileId, newName: string): void; onRename(id: FileId, newName: string): void;
} }
@ -26,7 +26,7 @@ export function Files({
onAdd, onAdd,
onRemove, onRemove,
onRename, onRename,
onSelected, onSelect,
}: Props) { }: Props) {
const handleAdd = () => { const handleAdd = () => {
let index: number | null = null; let index: number | null = null;
@ -54,7 +54,7 @@ export function Files({
<FileEntry <FileEntry
selected={selected === id} selected={selected === id}
name={name} name={name}
onClicked={() => onSelected(id)} onClicked={() => onSelect(id)}
onRenamed={(newName) => { onRenamed={(newName) => {
if (!files.some(({ name }) => name === newName)) { if (!files.some(({ name }) => name === newName)) {
onRename(id, newName); onRename(id, newName);

View File

@ -1,4 +1,5 @@
import { import {
ActionDispatch,
Suspense, Suspense,
useCallback, useCallback,
useDeferredValue, useDeferredValue,
@ -22,6 +23,7 @@ export default function Playground() {
const [version, setVersion] = useState<string>("0.0.0"); const [version, setVersion] = useState<string>("0.0.0");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const workspacePromiseRef = useRef<Promise<Workspace> | null>(null); const workspacePromiseRef = useRef<Promise<Workspace> | null>(null);
const [workspace, setWorkspace] = useState<Workspace | null>(null);
let workspacePromise = workspacePromiseRef.current; let workspacePromise = workspacePromiseRef.current;
if (workspacePromise == null) { if (workspacePromise == null) {
@ -29,43 +31,14 @@ export default function Playground() {
(fetched) => { (fetched) => {
setVersion(fetched.version); setVersion(fetched.version);
const workspace = new Workspace("/", {}); const workspace = new Workspace("/", {});
restoreWorkspace(workspace, fetched.workspace, dispatchFiles, setError);
let hasSettings = false; setWorkspace(workspace);
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; return workspace;
}, },
); );
} }
const [files, dispatchFiles] = useReducer(filesReducer, { const [files, dispatchFiles] = useReducer(filesReducer, INIT_FILES_STATE);
index: [],
contents: Object.create(null),
handles: Object.create(null),
nextId: 0,
revision: 0,
selected: null,
});
const fileName = useMemo(() => { const fileName = useMemo(() => {
return ( return (
@ -155,6 +128,29 @@ export default function Playground() {
dispatchFiles({ type: "selectFile", id: file }); 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 ( return (
<main className="flex flex-col h-full bg-ayu-background dark:bg-ayu-background-dark"> <main className="flex flex-col h-full bg-ayu-background dark:bg-ayu-background-dark">
<Header <Header
@ -164,6 +160,7 @@ export default function Playground() {
version={version} version={version}
onChangeTheme={setTheme} onChangeTheme={setTheme}
onShare={handleShare} onShare={handleShare}
onReset={workspace == null ? undefined : handleReset}
/> />
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
@ -172,11 +169,11 @@ export default function Playground() {
workspacePromise={workspacePromise} workspacePromise={workspacePromise}
theme={theme} theme={theme}
selectedFileName={fileName} selectedFileName={fileName}
onFileAdded={handleFileAdded} onAddFile={handleFileAdded}
onFileRenamed={handleFileRenamed} onRenameFile={handleFileRenamed}
onFileRemoved={handleFileRemoved} onRemoveFile={handleFileRemoved}
onFileSelected={handleFileSelected} onSelectFile={handleFileSelected}
onFileChanged={handleFileChanged} onChangeFile={handleFileChanged}
/> />
</Suspense> </Suspense>
{error ? ( {error ? (
@ -208,6 +205,14 @@ export const DEFAULT_SETTINGS = JSON.stringify(
4, 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. * 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. * The revision. Gets incremented every time files changes.
*/ */
revision: number; revision: number;
/**
* Revision identifying this playground. Gets incremented every time the
* playground is reset.
*/
playgroundRevision: number;
nextId: FileId; nextId: FileId;
} }
@ -276,7 +288,18 @@ export type FileAction =
id: FileId; id: FileId;
} }
| { type: "selectFile"; 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( function filesReducer(
state: Readonly<FilesState>, state: Readonly<FilesState>,
@ -366,6 +389,14 @@ function filesReducer(
selected, selected,
}; };
} }
case "reset": {
return {
...INIT_FILES_STATE,
playgroundRevision: state.playgroundRevision + 1,
revision: state.revision + 1,
};
}
} }
} }
@ -410,13 +441,7 @@ async function startPlayground(): Promise<InitializedPlayground> {
const restored = await restore(); const restored = await restore();
const workspace = restored ?? { const workspace = restored ?? DEFAULT_WORKSPACE;
files: {
"main.py": "import os",
"knot.json": DEFAULT_SETTINGS,
},
current: "main.py",
};
return { return {
version: "0.0.0", version: "0.0.0",
@ -457,3 +482,36 @@ function updateFile(
function Loading() { function Loading() {
return <div className="align-middle text-center my-2">Loading...</div>; return <div className="align-middle text-center my-2">Loading...</div>;
} }
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,
});
}

View File

@ -65,6 +65,15 @@ export default function Chrome() {
[pythonSource], [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(() => { const source: Source | null = useMemo(() => {
if (pythonSource == null || settings == null) { if (pythonSource == null || settings == null) {
return null; return null;
@ -82,6 +91,7 @@ export default function Chrome() {
version={ruffVersion} version={ruffVersion}
onChangeTheme={setTheme} onChangeTheme={setTheme}
onShare={handleShare} onShare={handleShare}
onReset={handleResetClicked}
/> />
<div className="flex grow"> <div className="flex grow">

View File

@ -4,6 +4,7 @@ import ThemeButton from "./ThemeButton";
import ShareButton from "./ShareButton"; import ShareButton from "./ShareButton";
import { Theme } from "./theme"; import { Theme } from "./theme";
import VersionTag from "./VersionTag"; import VersionTag from "./VersionTag";
import AstralButton from "./AstralButton";
export default function Header({ export default function Header({
edit, edit,
@ -11,6 +12,7 @@ export default function Header({
logo, logo,
version, version,
onChangeTheme, onChangeTheme,
onReset,
onShare, onShare,
}: { }: {
edit: number | null; edit: number | null;
@ -18,43 +20,42 @@ export default function Header({
logo: "ruff" | "astral"; logo: "ruff" | "astral";
version: string | null; version: string | null;
onChangeTheme: (theme: Theme) => void; onChangeTheme: (theme: Theme) => void;
onShare?: () => void; onReset?(): void;
onShare: () => void;
}) { }) {
return ( return (
<div <div
className={classNames( className="
"w-full", w-full
"flex", flex
"justify-between", justify-between
"pl-5", antialiased
"sm:pl-1", border-b
"pr-4", border-gray-200
"lg:pr-6", dark:border-b-radiate
"z-10", dark:bg-galaxy
"top-0", "
"left-0",
"-mb-px",
"antialiased",
"border-b",
"border-gray-200",
"dark:border-b-radiate",
"dark:bg-galaxy",
)}
> >
<div className="py-4 pl-2"> <div className="py-4 pl-2">
<Logo name={logo} className="fill-galaxy dark:fill-radiate" /> <Logo name={logo} className="fill-galaxy dark:fill-radiate" />
</div> </div>
<div className="flex items-center min-w-0"> <div className="flex items-center min-w-0 gap-4 mx-2">
{version ? ( {version ? (
<div className="hidden sm:flex items-center"> <div className="hidden sm:flex">
<VersionTag>v{version}</VersionTag> <VersionTag>v{version}</VersionTag>
</div> </div>
) : null} ) : null}
<Divider /> <Divider />
<RepoButton /> <RepoButton />
<Divider /> <Divider />
<ShareButton key={edit} onShare={onShare} /> <div className="max-sm:hidden flex">
<ResetButton onClicked={onReset} />
</div>
<div className="max-sm:hidden flex">
<ShareButton key={edit} onShare={onShare} />
</div>
<Divider /> <Divider />
<ThemeButton theme={theme} onChange={onChangeTheme} /> <ThemeButton theme={theme} onChange={onChangeTheme} />
</div> </div>
</div> </div>
@ -63,7 +64,16 @@ export default function Header({
function Divider() { function Divider() {
return ( return (
<div className="hidden sm:block mx-6 lg:mx-4 w-px h-8 bg-gray-200 dark:bg-gray-700" /> <div
className={classNames(
"max-sm:hidden",
"visible",
"w-px",
"h-8",
"bg-gray-200",
"dark:bg-gray-700",
)}
/>
); );
} }
@ -121,3 +131,16 @@ function Logo({
); );
} }
} }
function ResetButton({ onClicked }: { onClicked?: () => void }) {
return (
<AstralButton
type="button"
className="relative flex-none leading-6 py-1.5 px-3 shadow-xs disabled:opacity-50"
disabled={onClicked == null}
onClick={onClicked}
>
Reset
</AstralButton>
);
}

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import AstralButton from "./AstralButton"; import AstralButton from "./AstralButton";
export default function ShareButton({ onShare }: { onShare?: () => void }) { export default function ShareButton({ onShare }: { onShare: () => void }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
useEffect(() => { useEffect(() => {
@ -28,15 +28,11 @@ export default function ShareButton({ onShare }: { onShare?: () => void }) {
<AstralButton <AstralButton
type="button" type="button"
className="relative flex-none leading-6 py-1.5 px-3 shadow-xs disabled:opacity-50" className="relative flex-none leading-6 py-1.5 px-3 shadow-xs disabled:opacity-50"
disabled={!onShare || copied} disabled={copied}
onClick={ onClick={() => {
onShare setCopied(true);
? () => { onShare();
setCopied(true); }}
onShare();
}
: undefined
}
> >
<span <span
className="absolute inset-0 flex items-center justify-center" className="absolute inset-0 flex items-center justify-center"

View File

@ -14,7 +14,7 @@ export default function ThemeButton({
return ( return (
<AstralButton <AstralButton
type="button" type="button"
className="ml-4 sm:ml-0 dark:shadow-copied" className="sm:ml-0 dark:shadow-copied"
onClick={() => onChange(theme === "light" ? "dark" : "light")} onClick={() => onChange(theme === "light" ? "dark" : "light")}
> >
<span className="sr-only"> <span className="sr-only">