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 ( + + ); +} 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" } }}