mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-03 10:22:24 +00:00
Add diagnostics panel and navigation features to playground (#13357)
This commit is contained in:
parent
47e9ea2d5d
commit
489dbbaadc
7 changed files with 399 additions and 27 deletions
|
@ -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": {
|
||||
|
|
92
playground/src/Editor/Diagnostics.tsx
Normal file
92
playground/src/Editor/Diagnostics.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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" } }}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue