Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^7.3.9",
"@mui/material": "^7.3.9",
"@rjsf/core": "^6.4.1",
"@rjsf/mui": "^6.4.1",
"@rjsf/utils": "^6.4.1",
"@rjsf/validator-ajv8": "^6.4.1",
"monaco-editor": "^0.56.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,27 @@ export type HistoryEntry = {
details?: string | null;
};

export type ExpressionDiagnostic = {
message: string;
start_line: number;
start_column: number;
end_line: number;
end_column: number;
};

export async function validateExpression(
expression: string,
): Promise<ExpressionDiagnostic[]> {
const r = await fetch("/api/expressions/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expression }),
});
if (!r.ok) throw new Error(`validate: ${r.status}`);
const body = (await r.json()) as { diagnostics: ExpressionDiagnostic[] };
return body.diagnostics;
}

export async function fetchHistory(): Promise<HistoryEntry[]> {
const r = await fetch("/api/history");
if (!r.ok) throw new Error(`history: ${r.status}`);
Expand Down
58 changes: 58 additions & 0 deletions frontend/src/components/ExpressionWidget.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { render, screen } from "@testing-library/react";
import type { WidgetProps } from "@rjsf/utils";
import { describe, expect, it, vi } from "vitest";
import { ExpressionWidget, findToken } from "./ExpressionWidget";

vi.mock("../api", async () => {
const actual = await vi.importActual<typeof import("../api")>("../api");
return {
...actual,
validateExpression: vi.fn().mockResolvedValue([]),
};
});

const tokens = [
{
label: "col",
insert: 'col("$' + '{1:name}")',
kind: "function" as const,
detail: "Rawdata column",
},
{
label: "deg",
insert: "deg",
kind: "constant" as const,
detail: "Named constant",
},
];

function renderWidget(onChange = vi.fn(), value = "") {
const props = {
id: "root_expression",
name: "expression",
label: "Designation expression",
value,
onChange,
options: { tokens },
schema: { type: "string" },
} as unknown as WidgetProps;
return render(<ExpressionWidget {...props} />);
}

describe("ExpressionWidget", () => {
it("renders an editor", () => {
renderWidget();
expect(screen.getByLabelText("Designation expression")).toBeInTheDocument();
});
});

describe("findToken", () => {
it("returns the token matching a hovered word", () => {
expect(findToken("col", tokens)?.detail).toBe("Rawdata column");
expect(findToken("deg", tokens)?.detail).toBe("Named constant");
});

it("returns undefined for unknown words", () => {
expect(findToken("unknown", tokens)).toBeUndefined();
});
});
283 changes: 283 additions & 0 deletions frontend/src/components/ExpressionWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import Editor, { type Monaco } from "@monaco-editor/react";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import type { WidgetProps } from "@rjsf/utils";
import type { editor, Position } from "monaco-editor";
import { useEffect, useRef } from "react";
import { type ExpressionDiagnostic, validateExpression } from "../api";

const LANGUAGE_ID = "hyperleda-expression";
const MARKER_OWNER = "hyperleda-expression";

export type ExpressionToken = {
label: string;
insert: string;
kind: "function" | "constant";
detail: string;
};

let languageRegistered = false;
let currentTokens: ExpressionToken[] = [];

function isExpressionToken(value: unknown): value is ExpressionToken {
if (typeof value !== "object" || value === null) {
return false;
}
const token = value as Record<string, unknown>;
return (
typeof token.label === "string" &&
typeof token.insert === "string" &&
(token.kind === "function" || token.kind === "constant") &&
typeof token.detail === "string"
);
}

function readTokens(options: WidgetProps["options"]): ExpressionToken[] {
const raw = options.tokens;
if (!Array.isArray(raw)) {
return [];
}
return raw.filter(isExpressionToken);
}

export function findToken(
word: string,
tokens: ExpressionToken[],
): ExpressionToken | undefined {
return tokens.find((token) => token.label === word);
}

function toMarkers(
monaco: Monaco,
diagnostics: ExpressionDiagnostic[],
): editor.IMarkerData[] {
return diagnostics.map((diagnostic) => ({
severity: monaco.MarkerSeverity.Error,
message: diagnostic.message,
startLineNumber: diagnostic.start_line,
startColumn: diagnostic.start_column,
endLineNumber: diagnostic.end_line,
endColumn: diagnostic.end_column,
}));
}

function registerExpressionLanguage(
monaco: Monaco,
tokens: ExpressionToken[],
): void {
currentTokens = tokens;
if (languageRegistered) {
return;
}
monaco.languages.register({ id: LANGUAGE_ID });
monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, {
keywords: tokens.map((token) => token.label),
tokenizer: {
root: [
[/"(?:\\.|[^"\\])*"/, "string"],
[/'[^']*'/, "string"],
[/\d+(?:\.\d+)?/, "number"],
[/[+\-*/%=()]/, "delimiter"],
[
/[a-zA-Z_]\w*/,
{
cases: {
"@keywords": "keyword",
"@default": "identifier",
},
},
],
],
},
});
monaco.languages.registerCompletionItemProvider(LANGUAGE_ID, {
triggerCharacters: ["(", '"'],
provideCompletionItems(model: editor.ITextModel, position: Position) {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
return {
suggestions: currentTokens.map((token) => ({
label: token.label,
kind:
token.kind === "function"
? monaco.languages.CompletionItemKind.Function
: monaco.languages.CompletionItemKind.Constant,
insertText: token.insert,
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
detail: token.detail,
documentation: token.detail,
range,
})),
};
},
});
monaco.languages.registerHoverProvider(LANGUAGE_ID, {
provideHover(model: editor.ITextModel, position: Position) {
const word = model.getWordAtPosition(position);
if (!word) {
return null;
}
const token = findToken(word.word, currentTokens);
if (!token) {
return null;
}
return {
range: {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
},
contents: [{ value: `**${token.label}**` }, { value: token.detail }],
};
},
});
languageRegistered = true;
}

export function ExpressionWidget(props: WidgetProps) {
const theme = useTheme();
const tokens = readTokens(props.options);
const value = typeof props.value === "string" ? props.value : "";
const isDark = theme.palette.mode === "dark";
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const monacoRef = useRef<Monaco | null>(null);
const requestIdRef = useRef(0);
const timeoutRef = useRef<number | null>(null);

useEffect(
() => () => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
requestIdRef.current += 1;
},
[],
);

function applyMarkers(diagnostics: ExpressionDiagnostic[]) {
const editorInstance = editorRef.current;
const monaco = monacoRef.current;
const model = editorInstance?.getModel();
if (!editorInstance || !monaco || !model) {
return;
}
monaco.editor.setModelMarkers(
model,
MARKER_OWNER,
toMarkers(monaco, diagnostics),
);
}

function scheduleValidation(text: string) {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
const requestId = ++requestIdRef.current;
validateExpression(text)
.then((diagnostics) => {
if (requestId !== requestIdRef.current) {
return;
}
applyMarkers(diagnostics);
})
.catch(() => {
if (requestId !== requestIdRef.current) {
return;
}
applyMarkers([]);
});
}, 200);
}

function handleBeforeMount(monaco: Monaco) {
registerExpressionLanguage(monaco, tokens);
}

function handleMount(
editorInstance: editor.IStandaloneCodeEditor,
monaco: Monaco,
) {
editorRef.current = editorInstance;
monacoRef.current = monaco;
editorInstance.onKeyDown((e) => {
if (e.keyCode === monaco.KeyCode.Enter) {
e.preventDefault();
e.stopPropagation();
}
});
applyMarkers([]);
scheduleValidation(value.replace(/\r?\n/g, ""));
}

return (
<Box
sx={{
border: 1,
borderColor: "divider",
borderRadius: 1,
overflow: "hidden",
bgcolor: isDark ? "#1e1e1e" : "#ffffff",
"&:hover": {
borderColor: "text.primary",
},
"&:focus-within": {
borderColor: "primary.main",
},
}}
>
<Editor
height="42px"
language={LANGUAGE_ID}
theme={isDark ? "vs-dark" : "light"}
value={value.replace(/\r?\n/g, "")}
onChange={(next) => {
const singleLine = (next ?? "").replace(/\r?\n/g, "");
props.onChange(singleLine);
scheduleValidation(singleLine);
}}
beforeMount={handleBeforeMount}
onMount={handleMount}
options={{
ariaLabel: props.label,
readOnly: props.disabled || props.readonly,
minimap: { enabled: false },
lineNumbers: "off",
folding: false,
glyphMargin: false,
lineDecorationsWidth: 12,
lineNumbersMinChars: 0,
scrollBeyondLastLine: false,
wordWrap: "off",
fontSize: 14,
lineHeight: 22,
padding: { top: 10, bottom: 10 },
overviewRulerLanes: 0,
hideCursorInOverviewRuler: true,
renderLineHighlight: "none",
scrollbar: {
vertical: "hidden",
horizontal: "auto",
alwaysConsumeMouseWheel: false,
},
quickSuggestions: { other: true, comments: false, strings: true },
wordBasedSuggestions: "off",
suggestOnTriggerCharacters: true,
acceptSuggestionOnEnter: "off",
tabCompletion: "on",
automaticLayout: true,
contextmenu: false,
fixedOverflowWidgets: true,
}}
loading={<Box sx={{ height: 42 }} />}
/>
</Box>
);
}
Loading
Loading