[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;
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}
/>
<PanelGroup direction="horizontal" autoSaveId="main">
@ -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 ? (
<div

View File

@ -38,7 +38,7 @@ type Props = {
workspace: Workspace;
onChange(content: string): void;
onMount(editor: IStandaloneCodeEditor, monaco: Monaco): void;
onFileOpened(file: FileId): void;
onOpenFile(file: FileId): void;
};
export default function Editor({
@ -51,7 +51,7 @@ export default function Editor({
workspace,
onChange,
onMount,
onFileOpened,
onOpenFile,
}: Props) {
const serverRef = useRef<PlaygroundServer | null>(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 (
<Moncao
key={files.playgroundRevision}
onMount={handleMount}
options={{
fixedOverflowWidgets: true,
@ -133,7 +134,7 @@ export default function Editor({
interface PlaygroundServerProps {
workspace: Workspace;
files: ReadonlyFiles;
onFileOpened: (file: FileId) => 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) {

View File

@ -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({
<FileEntry
selected={selected === id}
name={name}
onClicked={() => onSelected(id)}
onClicked={() => onSelect(id)}
onRenamed={(newName) => {
if (!files.some(({ name }) => name === newName)) {
onRename(id, newName);

View File

@ -1,4 +1,5 @@
import {
ActionDispatch,
Suspense,
useCallback,
useDeferredValue,
@ -22,6 +23,7 @@ export default function Playground() {
const [version, setVersion] = useState<string>("0.0.0");
const [error, setError] = useState<string | null>(null);
const workspacePromiseRef = useRef<Promise<Workspace> | null>(null);
const [workspace, setWorkspace] = useState<Workspace | null>(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 (
<main className="flex flex-col h-full bg-ayu-background dark:bg-ayu-background-dark">
<Header
@ -164,6 +160,7 @@ export default function Playground() {
version={version}
onChangeTheme={setTheme}
onShare={handleShare}
onReset={workspace == null ? undefined : handleReset}
/>
<Suspense fallback={<Loading />}>
@ -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}
/>
</Suspense>
{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<FilesState>,
@ -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<InitializedPlayground> {
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 <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],
);
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}
/>
<div className="flex grow">

View File

@ -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 (
<div
className={classNames(
"w-full",
"flex",
"justify-between",
"pl-5",
"sm:pl-1",
"pr-4",
"lg:pr-6",
"z-10",
"top-0",
"left-0",
"-mb-px",
"antialiased",
"border-b",
"border-gray-200",
"dark:border-b-radiate",
"dark:bg-galaxy",
)}
className="
w-full
flex
justify-between
antialiased
border-b
border-gray-200
dark:border-b-radiate
dark:bg-galaxy
"
>
<div className="py-4 pl-2">
<Logo name={logo} className="fill-galaxy dark:fill-radiate" />
</div>
<div className="flex items-center min-w-0">
<div className="flex items-center min-w-0 gap-4 mx-2">
{version ? (
<div className="hidden sm:flex items-center">
<div className="hidden sm:flex">
<VersionTag>v{version}</VersionTag>
</div>
) : null}
<Divider />
<RepoButton />
<Divider />
<div className="max-sm:hidden flex">
<ResetButton onClicked={onReset} />
</div>
<div className="max-sm:hidden flex">
<ShareButton key={edit} onShare={onShare} />
</div>
<Divider />
<ThemeButton theme={theme} onChange={onChangeTheme} />
</div>
</div>
@ -63,7 +64,16 @@ export default function Header({
function Divider() {
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 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 }) {
<AstralButton
type="button"
className="relative flex-none leading-6 py-1.5 px-3 shadow-xs disabled:opacity-50"
disabled={!onShare || copied}
onClick={
onShare
? () => {
disabled={copied}
onClick={() => {
setCopied(true);
onShare();
}
: undefined
}
}}
>
<span
className="absolute inset-0 flex items-center justify-center"

View File

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