diff --git a/playground/.eslintrc b/playground/.eslintrc
index d0529c7260..987e93306e 100644
--- a/playground/.eslintrc
+++ b/playground/.eslintrc
@@ -14,7 +14,7 @@
"rules": {
// Disable some recommended rules that we don't want to enforce.
"@typescript-eslint/no-explicit-any": "off",
- "@typescript-eslint/no-empty-function": "off"
+ "eqeqeq": ["error","always", { "null": "never"}]
},
"settings": {
"react": {
diff --git a/playground/src/Editor/Diagnostics.tsx b/playground/src/Editor/Diagnostics.tsx
new file mode 100644
index 0000000000..c5c5652f40
--- /dev/null
+++ b/playground/src/Editor/Diagnostics.tsx
@@ -0,0 +1,92 @@
+import { Diagnostic } from "../pkg";
+import classNames from "classnames";
+import { Theme } from "./theme";
+import { useMemo } from "react";
+
+interface Props {
+ diagnostics: Diagnostic[];
+ theme: Theme;
+ onGoTo(line: number, column: number): void;
+}
+
+export default function Diagnostics({
+ diagnostics: unsorted,
+ theme,
+ onGoTo,
+}: Props) {
+ const diagnostics = useMemo(() => {
+ const sorted = [...unsorted];
+ sorted.sort((a, b) => {
+ if (a.location.row === b.location.row) {
+ return a.location.column - b.location.column;
+ }
+
+ return a.location.row - b.location.row;
+ });
+
+ return sorted;
+ }, [unsorted]);
+
+ return (
+
+
+ Diagnostics ({diagnostics.length})
+
+
+
+
+
+
+ );
+}
+
+function Items({
+ diagnostics,
+ onGoTo,
+}: {
+ diagnostics: Array;
+ onGoTo(line: number, column: number): void;
+}) {
+ if (diagnostics.length === 0) {
+ return (
+
+ Everything is looking good!
+
+ );
+ }
+
+ return (
+
+ {diagnostics.map((diagnostic) => {
+ return (
+ -
+
+
+ );
+ })}
+
+ );
+}
diff --git a/playground/src/Editor/Editor.tsx b/playground/src/Editor/Editor.tsx
index dcf744221a..1a69ae0f24 100644
--- a/playground/src/Editor/Editor.tsx
+++ b/playground/src/Editor/Editor.tsx
@@ -1,9 +1,15 @@
-import { useDeferredValue, useMemo, useState } from "react";
+import {
+ useCallback,
+ useDeferredValue,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { Panel, PanelGroup } from "react-resizable-panels";
import { Diagnostic, Workspace } from "../pkg";
import { ErrorMessage } from "./ErrorMessage";
import PrimarySideBar from "./PrimarySideBar";
-import { HorizontalResizeHandle } from "./ResizeHandle";
+import { HorizontalResizeHandle, VerticalResizeHandle } from "./ResizeHandle";
import SecondaryPanel, {
SecondaryPanelResult,
SecondaryTool,
@@ -12,6 +18,9 @@ import SecondarySideBar from "./SecondarySideBar";
import SettingsEditor from "./SettingsEditor";
import SourceEditor from "./SourceEditor";
import { Theme } from "./theme";
+import Diagnostics from "./Diagnostics";
+import { editor } from "monaco-editor";
+import IStandaloneCodeEditor = editor.IStandaloneCodeEditor;
type Tab = "Source" | "Settings";
@@ -40,6 +49,7 @@ export default function Editor({
onSourceChanged,
onSettingsChanged,
}: Props) {
+ const editorRef = useRef(null);
const [tab, setTab] = useState("Source");
const [secondaryTool, setSecondaryTool] = useState(
() => {
@@ -53,6 +63,7 @@ export default function Editor({
}
},
);
+ const [selection, setSelection] = useState(null);
// Ideally this would be retrieved right from the URL... but routing without a proper
// router is hard (there's no location changed event) and pulling in a router
@@ -75,6 +86,83 @@ export default function Editor({
setSecondaryTool(tool);
};
+ const handleGoTo = useCallback((line: number, column: number) => {
+ const editor = editorRef.current;
+
+ if (editor == null) {
+ return;
+ }
+
+ const range = {
+ startLineNumber: line,
+ startColumn: column,
+ endLineNumber: line,
+ endColumn: column,
+ };
+ editor.revealRange(range);
+ editor.setSelection(range);
+ }, []);
+
+ const handleSourceEditorMount = useCallback(
+ (editor: IStandaloneCodeEditor) => {
+ editorRef.current = editor;
+
+ editor.addAction({
+ contextMenuGroupId: "navigation",
+ contextMenuOrder: 0,
+ id: "reveal-node",
+ label: "Reveal node",
+ precondition: "editorTextFocus",
+
+ run(editor: editor.ICodeEditor): void | Promise {
+ const position = editor.getPosition();
+ if (position == null) {
+ return;
+ }
+
+ const offset = editor.getModel()!.getOffsetAt(position);
+
+ setSelection(
+ charOffsetToByteOffset(editor.getModel()!.getValue(), offset),
+ );
+ },
+ });
+ },
+ [],
+ );
+
+ const handleSelectByteRange = useCallback(
+ (startByteOffset: number, endByteOffset: number) => {
+ const model = editorRef.current?.getModel();
+
+ if (model == null || editorRef.current == null) {
+ return;
+ }
+
+ const startCharacterOffset = byteOffsetToCharOffset(
+ source.pythonSource,
+ startByteOffset,
+ );
+ const endCharacterOffset = byteOffsetToCharOffset(
+ source.pythonSource,
+ endByteOffset,
+ );
+
+ const start = model.getPositionAt(startCharacterOffset);
+ const end = model.getPositionAt(endCharacterOffset);
+
+ const range = {
+ startLineNumber: start.lineNumber,
+ startColumn: start.column,
+ endLineNumber: end.lineNumber,
+ endColumn: end.column,
+ };
+ editorRef.current?.revealRange(range);
+ editorRef.current?.setSelection(range);
+ },
+ [source.pythonSource],
+ );
+
const deferredSource = useDeferredValue(source);
const checkResult: CheckResult = useMemo(() => {
@@ -149,20 +237,43 @@ export default function Editor({
<>
setTab(tool)} selected={tab} />
-
-
-
+
+
+
+
+
+
+
+ {tab === "Source" && (
+ <>
+
+
+
+
+ >
+ )}
+
{secondaryTool != null && (
<>
@@ -177,6 +288,8 @@ export default function Editor({
theme={theme}
tool={secondaryTool}
result={checkResult.secondary}
+ selectionOffset={selection}
+ onSourceByteRangeClicked={handleSelectByteRange}
/>
>
@@ -210,3 +323,25 @@ function parseSecondaryTool(tool: string): SecondaryTool | null {
return null;
}
+
+function byteOffsetToCharOffset(content: string, byteOffset: number): number {
+ // Create a Uint8Array from the UTF-8 string
+ const encoder = new TextEncoder();
+ const utf8Bytes = encoder.encode(content);
+
+ // Slice the byte array up to the byteOffset
+ const slicedBytes = utf8Bytes.slice(0, byteOffset);
+
+ // Decode the sliced bytes to get a substring
+ const decoder = new TextDecoder("utf-8");
+ const decodedString = decoder.decode(slicedBytes);
+ return decodedString.length;
+}
+
+function charOffsetToByteOffset(content: string, charOffset: number): number {
+ // Create a Uint8Array from the UTF-8 string
+ const encoder = new TextEncoder();
+ const utf8Bytes = encoder.encode(content.substring(0, charOffset));
+
+ return utf8Bytes.length;
+}
diff --git a/playground/src/Editor/PrimarySideBar.tsx b/playground/src/Editor/PrimarySideBar.tsx
index de4db82b0b..c2f7e8b366 100644
--- a/playground/src/Editor/PrimarySideBar.tsx
+++ b/playground/src/Editor/PrimarySideBar.tsx
@@ -18,7 +18,7 @@ export default function PrimarySideBar({
title="Source"
position={"left"}
onClick={() => onSelectTool("Source")}
- selected={selected == "Source"}
+ selected={selected === "Source"}
>
@@ -27,7 +27,7 @@ export default function PrimarySideBar({
title="Settings"
position={"left"}
onClick={() => onSelectTool("Settings")}
- selected={selected == "Settings"}
+ selected={selected === "Settings"}
>
diff --git a/playground/src/Editor/SecondaryPanel.tsx b/playground/src/Editor/SecondaryPanel.tsx
index d6e28adc17..ad60885ea5 100644
--- a/playground/src/Editor/SecondaryPanel.tsx
+++ b/playground/src/Editor/SecondaryPanel.tsx
@@ -1,5 +1,8 @@
-import MonacoEditor from "@monaco-editor/react";
import { Theme } from "./theme";
+import { useCallback, useEffect, useState } from "react";
+import { editor, Range } from "monaco-editor";
+import IStandaloneCodeEditor = editor.IStandaloneCodeEditor;
+import MonacoEditor from "@monaco-editor/react";
export enum SecondaryTool {
"Format" = "Format",
@@ -18,17 +21,27 @@ export type SecondaryPanelProps = {
tool: SecondaryTool;
result: SecondaryPanelResult;
theme: Theme;
+ selectionOffset: number | null;
+ onSourceByteRangeClicked(start: number, end: number): void;
};
export default function SecondaryPanel({
tool,
result,
theme,
+ selectionOffset,
+ onSourceByteRangeClicked,
}: SecondaryPanelProps) {
return (
);
@@ -38,11 +51,135 @@ function Content({
tool,
result,
theme,
+ selectionOffset,
+ onSourceByteRangeClicked,
}: {
tool: SecondaryTool;
result: SecondaryPanelResult;
theme: Theme;
+ selectionOffset: number | null;
+ onSourceByteRangeClicked(start: number, end: number): void;
}) {
+ const [editor, setEditor] = useState(null);
+ const [prevSelection, setPrevSelection] = useState(null);
+ const [ranges, setRanges] = useState<
+ Array<{ byteRange: { start: number; end: number }; textRange: Range }>
+ >([]);
+
+ if (
+ editor != null &&
+ selectionOffset != null &&
+ selectionOffset !== prevSelection
+ ) {
+ const range = ranges.findLast(
+ (range) =>
+ range.byteRange.start <= selectionOffset &&
+ range.byteRange.end >= selectionOffset,
+ );
+
+ if (range != null) {
+ editor.revealRange(range.textRange);
+ editor.setSelection(range.textRange);
+ }
+ setPrevSelection(selectionOffset);
+ }
+
+ useEffect(() => {
+ const model = editor?.getModel();
+ if (editor == null || model == null) {
+ return;
+ }
+
+ const handler = editor.onMouseDown((event) => {
+ if (event.target.range == null) {
+ return;
+ }
+
+ const range = model
+ .getDecorationsInRange(
+ event.target.range,
+ undefined,
+ true,
+ false,
+ false,
+ )
+ .map((decoration) => {
+ const decorationRange = decoration.range;
+ return ranges.find((range) =>
+ Range.equalsRange(range.textRange, decorationRange),
+ );
+ })
+ .find((range) => range != null);
+
+ if (range == null) {
+ return;
+ }
+
+ onSourceByteRangeClicked(range.byteRange.start, range.byteRange.end);
+ });
+
+ return () => handler.dispose();
+ }, [editor, onSourceByteRangeClicked, ranges]);
+
+ const handleDidMount = useCallback((editor: IStandaloneCodeEditor) => {
+ setEditor(editor);
+
+ const model = editor.getModel();
+ const collection = editor.createDecorationsCollection([]);
+
+ function updateRanges() {
+ if (model == null) {
+ setRanges([]);
+ collection.set([]);
+ return;
+ }
+
+ const matches = model.findMatches(
+ String.raw`(\d+)\.\.(\d+)`,
+ false,
+ true,
+ false,
+ ",",
+ true,
+ );
+
+ const ranges = matches
+ .map((match) => {
+ const startByteOffset = parseInt(match.matches![1] ?? "", 10);
+ const endByteOffset = parseInt(match.matches![2] ?? "", 10);
+
+ if (Number.isNaN(startByteOffset) || Number.isNaN(endByteOffset)) {
+ return null;
+ }
+
+ return {
+ byteRange: { start: startByteOffset, end: endByteOffset },
+ textRange: match.range,
+ };
+ })
+ .filter((range) => range != null);
+
+ setRanges(ranges);
+
+ const decorations = ranges.map((range) => {
+ return {
+ range: range.textRange,
+ options: {
+ inlineClassName:
+ "underline decoration-slate-600 decoration-1 cursor-pointer",
+ },
+ };
+ });
+
+ collection.set(decorations);
+ }
+
+ updateRanges();
+ const handler = editor.onDidChangeModelContent(updateRanges);
+
+ return () => handler.dispose();
+ }, []);
+
if (result == null) {
return "";
} else {
@@ -81,6 +218,7 @@ function Content({
scrollBeyondLastLine: false,
contextmenu: false,
}}
+ onMount={handleDidMount}
language={language}
value={result.content}
theme={theme === "light" ? "Ayu-Light" : "Ayu-Dark"}
diff --git a/playground/src/Editor/SettingsEditor.tsx b/playground/src/Editor/SettingsEditor.tsx
index d32ca7d13f..b97c6bae94 100644
--- a/playground/src/Editor/SettingsEditor.tsx
+++ b/playground/src/Editor/SettingsEditor.tsx
@@ -70,7 +70,7 @@ export default function SettingsEditor({
await navigator.clipboard.writeText(tomlSettings);
},
});
- editor.onDidPaste((event) => {
+ const didPaste = editor.onDidPaste((event) => {
const model = editor.getModel();
if (model == null) {
@@ -97,6 +97,8 @@ export default function SettingsEditor({
}
}
});
+
+ return () => didPaste.dispose();
}, []);
return (
@@ -123,7 +125,7 @@ function stripToolRuff(settings: object) {
const { tool, ...nonToolSettings } = settings as any;
// Flatten out `tool.ruff.x` to just `x`
- if (typeof tool == "object" && !Array.isArray(tool)) {
+ if (typeof tool === "object" && !Array.isArray(tool)) {
if (tool.ruff != null) {
return { ...nonToolSettings, ...tool.ruff };
}
diff --git a/playground/src/Editor/SourceEditor.tsx b/playground/src/Editor/SourceEditor.tsx
index a60211fed1..b5eacfa5fc 100644
--- a/playground/src/Editor/SourceEditor.tsx
+++ b/playground/src/Editor/SourceEditor.tsx
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useRef } from "react";
import { Diagnostic } from "../pkg";
import { Theme } from "./theme";
import CodeActionProvider = languages.CodeActionProvider;
+import IStandaloneCodeEditor = editor.IStandaloneCodeEditor;
type MonacoEditorState = {
monaco: Monaco;
@@ -28,12 +29,14 @@ export default function SourceEditor({
theme,
diagnostics,
onChange,
+ onMount,
}: {
visible: boolean;
source: string;
diagnostics: Diagnostic[];
theme: Theme;
- onChange: (pythonSource: string) => void;
+ onChange(pythonSource: string): void;
+ onMount(editor: IStandaloneCodeEditor): void;
}) {
const monacoRef = useRef(null);
@@ -70,7 +73,7 @@ export default function SourceEditor({
);
const handleMount: OnMount = useCallback(
- (_editor, instance) => {
+ (editor, instance) => {
const ruffActionsProvider = new RuffCodeActionProvider(diagnostics);
const disposeCodeActionProvider =
instance.languages.registerCodeActionProvider(
@@ -85,9 +88,11 @@ export default function SourceEditor({
codeActionProvider: ruffActionsProvider,
disposeCodeActionProvider,
};
+
+ onMount(editor);
},
- [diagnostics],
+ [diagnostics, onMount],
);
return (
@@ -100,7 +105,7 @@ export default function SourceEditor({
fontSize: 14,
roundedSelection: false,
scrollBeyondLastLine: false,
- contextmenu: false,
+ contextmenu: true,
}}
language={"python"}
wrapperProps={visible ? {} : { style: { display: "none" } }}