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": {
// 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": {

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 { 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<IStandaloneCodeEditor | null>(null);
const [tab, setTab] = useState<Tab>("Source");
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
// 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<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 checkResult: CheckResult = useMemo(() => {
@ -149,20 +237,43 @@ export default function Editor({
<>
<PanelGroup direction="horizontal" autoSaveId="main">
<PrimarySideBar onSelectTool={(tool) => setTab(tool)} selected={tab} />
<Panel id="main" order={0} className="my-2" minSize={10}>
<SourceEditor
visible={tab === "Source"}
source={source.pythonSource}
theme={theme}
diagnostics={checkResult.diagnostics}
onChange={onSourceChanged}
/>
<SettingsEditor
visible={tab === "Settings"}
source={source.settingsSource}
theme={theme}
onChange={onSettingsChanged}
/>
<Panel id="main" order={0} minSize={10}>
<PanelGroup id="main" direction="vertical">
<Panel minSize={10} className="my-2" order={0}>
<SourceEditor
visible={tab === "Source"}
source={source.pythonSource}
theme={theme}
diagnostics={checkResult.diagnostics}
onChange={onSourceChanged}
onMount={handleSourceEditorMount}
/>
<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>
{secondaryTool != null && (
<>
@ -177,6 +288,8 @@ export default function Editor({
theme={theme}
tool={secondaryTool}
result={checkResult.secondary}
selectionOffset={selection}
onSourceByteRangeClicked={handleSelectByteRange}
/>
</Panel>
</>
@ -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;
}

View file

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

View file

@ -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 (
<div className="flex flex-col h-full">
<div className="flex-grow">
<Content tool={tool} result={result} theme={theme} />
<Content
tool={tool}
result={result}
theme={theme}
selectionOffset={selectionOffset}
onSourceByteRangeClicked={onSourceByteRangeClicked}
/>
</div>
</div>
);
@ -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<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) {
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"}

View file

@ -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 };
}

View file

@ -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<MonacoEditorState | null>(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" } }}