Add diagnostics panel and navigation features to playground (#13357)

This commit is contained in:
Micha Reiser 2024-09-16 09:34:46 +02:00 committed by GitHub
parent 47e9ea2d5d
commit 489dbbaadc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 399 additions and 27 deletions

View File

@ -14,7 +14,7 @@
"rules": { "rules": {
// Disable some recommended rules that we don't want to enforce. // Disable some recommended rules that we don't want to enforce.
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-empty-function": "off" "eqeqeq": ["error","always", { "null": "never"}]
}, },
"settings": { "settings": {
"react": { "react": {

View File

@ -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 (
<div
className={classNames(
"flex flex-grow flex-col overflow-hidden",
theme === "dark" ? "text-white" : null,
)}
>
<div
className={classNames(
"border-b border-gray-200 px-2 py-1",
theme === "dark" ? "border-rock" : null,
)}
>
Diagnostics ({diagnostics.length})
</div>
<div className="flex flex-grow p-2 overflow-hidden">
<Items diagnostics={diagnostics} onGoTo={onGoTo} />
</div>
</div>
);
}
function Items({
diagnostics,
onGoTo,
}: {
diagnostics: Array<Diagnostic>;
onGoTo(line: number, column: number): void;
}) {
if (diagnostics.length === 0) {
return (
<div className={"flex flex-auto flex-col justify-center items-center"}>
Everything is looking good!
</div>
);
}
return (
<ul className="space-y-0.5 flex-grow overflow-y-scroll">
{diagnostics.map((diagnostic) => {
return (
<li
key={`${diagnostic.location.row}:${diagnostic.location.column}-${diagnostic.code}`}
>
<button
onClick={() =>
onGoTo(diagnostic.location.row, diagnostic.location.column)
}
className="w-full text-start"
>
{diagnostic.message}{" "}
<span className="text-gray-500">
{diagnostic.code != null && `(${diagnostic.code})`} [Ln{" "}
{diagnostic.location.row}, Col {diagnostic.location.column}]
</span>
</button>
</li>
);
})}
</ul>
);
}

View File

@ -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 { Panel, PanelGroup } from "react-resizable-panels";
import { Diagnostic, Workspace } from "../pkg"; import { Diagnostic, Workspace } from "../pkg";
import { ErrorMessage } from "./ErrorMessage"; import { ErrorMessage } from "./ErrorMessage";
import PrimarySideBar from "./PrimarySideBar"; import PrimarySideBar from "./PrimarySideBar";
import { HorizontalResizeHandle } from "./ResizeHandle"; import { HorizontalResizeHandle, VerticalResizeHandle } from "./ResizeHandle";
import SecondaryPanel, { import SecondaryPanel, {
SecondaryPanelResult, SecondaryPanelResult,
SecondaryTool, SecondaryTool,
@ -12,6 +18,9 @@ import SecondarySideBar from "./SecondarySideBar";
import SettingsEditor from "./SettingsEditor"; import SettingsEditor from "./SettingsEditor";
import SourceEditor from "./SourceEditor"; import SourceEditor from "./SourceEditor";
import { Theme } from "./theme"; import { Theme } from "./theme";
import Diagnostics from "./Diagnostics";
import { editor } from "monaco-editor";
import IStandaloneCodeEditor = editor.IStandaloneCodeEditor;
type Tab = "Source" | "Settings"; type Tab = "Source" | "Settings";
@ -40,6 +49,7 @@ export default function Editor({
onSourceChanged, onSourceChanged,
onSettingsChanged, onSettingsChanged,
}: Props) { }: Props) {
const editorRef = useRef<IStandaloneCodeEditor | null>(null);
const [tab, setTab] = useState<Tab>("Source"); const [tab, setTab] = useState<Tab>("Source");
const [secondaryTool, setSecondaryTool] = useState<SecondaryTool | null>( const [secondaryTool, setSecondaryTool] = useState<SecondaryTool | null>(
() => { () => {
@ -53,6 +63,7 @@ export default function Editor({
} }
}, },
); );
const [selection, setSelection] = useState<number | null>(null);
// Ideally this would be retrieved right from the URL... but routing without a proper // 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 // router is hard (there's no location changed event) and pulling in a router
@ -75,6 +86,83 @@ export default function Editor({
setSecondaryTool(tool); 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<void> {
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 deferredSource = useDeferredValue(source);
const checkResult: CheckResult = useMemo(() => { const checkResult: CheckResult = useMemo(() => {
@ -149,20 +237,43 @@ export default function Editor({
<> <>
<PanelGroup direction="horizontal" autoSaveId="main"> <PanelGroup direction="horizontal" autoSaveId="main">
<PrimarySideBar onSelectTool={(tool) => setTab(tool)} selected={tab} /> <PrimarySideBar onSelectTool={(tool) => setTab(tool)} selected={tab} />
<Panel id="main" order={0} className="my-2" minSize={10}>
<SourceEditor <Panel id="main" order={0} minSize={10}>
visible={tab === "Source"} <PanelGroup id="main" direction="vertical">
source={source.pythonSource} <Panel minSize={10} className="my-2" order={0}>
theme={theme} <SourceEditor
diagnostics={checkResult.diagnostics} visible={tab === "Source"}
onChange={onSourceChanged} source={source.pythonSource}
/> theme={theme}
<SettingsEditor diagnostics={checkResult.diagnostics}
visible={tab === "Settings"} onChange={onSourceChanged}
source={source.settingsSource} onMount={handleSourceEditorMount}
theme={theme} />
onChange={onSettingsChanged} <SettingsEditor
/> visible={tab === "Settings"}
source={source.settingsSource}
theme={theme}
onChange={onSettingsChanged}
/>
</Panel>
{tab === "Source" && (
<>
<VerticalResizeHandle />
<Panel
id="diagnostics"
minSize={3}
order={1}
className="my-2 flex flex-grow"
>
<Diagnostics
diagnostics={checkResult.diagnostics}
onGoTo={handleGoTo}
theme={theme}
/>
</Panel>
</>
)}
</PanelGroup>
</Panel> </Panel>
{secondaryTool != null && ( {secondaryTool != null && (
<> <>
@ -177,6 +288,8 @@ export default function Editor({
theme={theme} theme={theme}
tool={secondaryTool} tool={secondaryTool}
result={checkResult.secondary} result={checkResult.secondary}
selectionOffset={selection}
onSourceByteRangeClicked={handleSelectByteRange}
/> />
</Panel> </Panel>
</> </>
@ -210,3 +323,25 @@ function parseSecondaryTool(tool: string): SecondaryTool | null {
return 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;
}

View File

@ -18,7 +18,7 @@ export default function PrimarySideBar({
title="Source" title="Source"
position={"left"} position={"left"}
onClick={() => onSelectTool("Source")} onClick={() => onSelectTool("Source")}
selected={selected == "Source"} selected={selected === "Source"}
> >
<FileIcon /> <FileIcon />
</SideBarEntry> </SideBarEntry>
@ -27,7 +27,7 @@ export default function PrimarySideBar({
title="Settings" title="Settings"
position={"left"} position={"left"}
onClick={() => onSelectTool("Settings")} onClick={() => onSelectTool("Settings")}
selected={selected == "Settings"} selected={selected === "Settings"}
> >
<SettingsIcon /> <SettingsIcon />
</SideBarEntry> </SideBarEntry>

View File

@ -1,5 +1,8 @@
import MonacoEditor from "@monaco-editor/react";
import { Theme } from "./theme"; 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 { export enum SecondaryTool {
"Format" = "Format", "Format" = "Format",
@ -18,17 +21,27 @@ export type SecondaryPanelProps = {
tool: SecondaryTool; tool: SecondaryTool;
result: SecondaryPanelResult; result: SecondaryPanelResult;
theme: Theme; theme: Theme;
selectionOffset: number | null;
onSourceByteRangeClicked(start: number, end: number): void;
}; };
export default function SecondaryPanel({ export default function SecondaryPanel({
tool, tool,
result, result,
theme, theme,
selectionOffset,
onSourceByteRangeClicked,
}: SecondaryPanelProps) { }: SecondaryPanelProps) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex-grow"> <div className="flex-grow">
<Content tool={tool} result={result} theme={theme} /> <Content
tool={tool}
result={result}
theme={theme}
selectionOffset={selectionOffset}
onSourceByteRangeClicked={onSourceByteRangeClicked}
/>
</div> </div>
</div> </div>
); );
@ -38,11 +51,135 @@ function Content({
tool, tool,
result, result,
theme, theme,
selectionOffset,
onSourceByteRangeClicked,
}: { }: {
tool: SecondaryTool; tool: SecondaryTool;
result: SecondaryPanelResult; result: SecondaryPanelResult;
theme: Theme; theme: Theme;
selectionOffset: number | null;
onSourceByteRangeClicked(start: number, end: number): void;
}) { }) {
const [editor, setEditor] = useState<IStandaloneCodeEditor | null>(null);
const [prevSelection, setPrevSelection] = useState<number | null>(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) { if (result == null) {
return ""; return "";
} else { } else {
@ -81,6 +218,7 @@ function Content({
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
contextmenu: false, contextmenu: false,
}} }}
onMount={handleDidMount}
language={language} language={language}
value={result.content} value={result.content}
theme={theme === "light" ? "Ayu-Light" : "Ayu-Dark"} theme={theme === "light" ? "Ayu-Light" : "Ayu-Dark"}

View File

@ -70,7 +70,7 @@ export default function SettingsEditor({
await navigator.clipboard.writeText(tomlSettings); await navigator.clipboard.writeText(tomlSettings);
}, },
}); });
editor.onDidPaste((event) => { const didPaste = editor.onDidPaste((event) => {
const model = editor.getModel(); const model = editor.getModel();
if (model == null) { if (model == null) {
@ -97,6 +97,8 @@ export default function SettingsEditor({
} }
} }
}); });
return () => didPaste.dispose();
}, []); }, []);
return ( return (
@ -123,7 +125,7 @@ function stripToolRuff(settings: object) {
const { tool, ...nonToolSettings } = settings as any; const { tool, ...nonToolSettings } = settings as any;
// Flatten out `tool.ruff.x` to just `x` // 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) { if (tool.ruff != null) {
return { ...nonToolSettings, ...tool.ruff }; return { ...nonToolSettings, ...tool.ruff };
} }

View File

@ -15,6 +15,7 @@ import { useCallback, useEffect, useRef } from "react";
import { Diagnostic } from "../pkg"; import { Diagnostic } from "../pkg";
import { Theme } from "./theme"; import { Theme } from "./theme";
import CodeActionProvider = languages.CodeActionProvider; import CodeActionProvider = languages.CodeActionProvider;
import IStandaloneCodeEditor = editor.IStandaloneCodeEditor;
type MonacoEditorState = { type MonacoEditorState = {
monaco: Monaco; monaco: Monaco;
@ -28,12 +29,14 @@ export default function SourceEditor({
theme, theme,
diagnostics, diagnostics,
onChange, onChange,
onMount,
}: { }: {
visible: boolean; visible: boolean;
source: string; source: string;
diagnostics: Diagnostic[]; diagnostics: Diagnostic[];
theme: Theme; theme: Theme;
onChange: (pythonSource: string) => void; onChange(pythonSource: string): void;
onMount(editor: IStandaloneCodeEditor): void;
}) { }) {
const monacoRef = useRef<MonacoEditorState | null>(null); const monacoRef = useRef<MonacoEditorState | null>(null);
@ -70,7 +73,7 @@ export default function SourceEditor({
); );
const handleMount: OnMount = useCallback( const handleMount: OnMount = useCallback(
(_editor, instance) => { (editor, instance) => {
const ruffActionsProvider = new RuffCodeActionProvider(diagnostics); const ruffActionsProvider = new RuffCodeActionProvider(diagnostics);
const disposeCodeActionProvider = const disposeCodeActionProvider =
instance.languages.registerCodeActionProvider( instance.languages.registerCodeActionProvider(
@ -85,9 +88,11 @@ export default function SourceEditor({
codeActionProvider: ruffActionsProvider, codeActionProvider: ruffActionsProvider,
disposeCodeActionProvider, disposeCodeActionProvider,
}; };
onMount(editor);
}, },
[diagnostics], [diagnostics, onMount],
); );
return ( return (
@ -100,7 +105,7 @@ export default function SourceEditor({
fontSize: 14, fontSize: 14,
roundedSelection: false, roundedSelection: false,
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
contextmenu: false, contextmenu: true,
}} }}
language={"python"} language={"python"}
wrapperProps={visible ? {} : { style: { display: "none" } }} wrapperProps={visible ? {} : { style: { display: "none" } }}