diff --git a/src/NemoWorkerContext/NemoWorkerContext.ts b/src/NemoWorkerContext/NemoWorkerContext.ts new file mode 100644 index 0000000..d389a8b --- /dev/null +++ b/src/NemoWorkerContext/NemoWorkerContext.ts @@ -0,0 +1,10 @@ +import { createContext, MutableRefObject, useContext } from "react"; +import { NemoWorker } from "../nemoWorker/NemoWorker"; + +export const NemoWorkerContext = createContext | null>(null); + +export function useNemoWorkerRef() { + return useContext(NemoWorkerContext); +} diff --git a/src/NemoWorkerContext/NemoWorkerContextProvider.tsx b/src/NemoWorkerContext/NemoWorkerContextProvider.tsx new file mode 100644 index 0000000..d1b181b --- /dev/null +++ b/src/NemoWorkerContext/NemoWorkerContextProvider.tsx @@ -0,0 +1,17 @@ +import { useRef } from "react"; +import { NemoWorker } from "../nemoWorker/NemoWorker"; +import { NemoWorkerContext } from "./NemoWorkerContext"; + +interface NemoWorkerProviderProps { + children: React.ReactNode; +} + +export function NemoWorkerProvider({ children }: NemoWorkerProviderProps) { + const workerRef = useRef(undefined); + + return ( + + {children} + + ); +} diff --git a/src/NevBroadcastChannel.ts b/src/NevBroadcastChannel.ts new file mode 100644 index 0000000..ecfe96b --- /dev/null +++ b/src/NevBroadcastChannel.ts @@ -0,0 +1,97 @@ +import { useContext, useEffect } from "react"; +import { NemoSessionIdContext } from "./nemoSessionIdContext"; + +let singletonNevChannel: BroadcastChannel | undefined = undefined; + +function initChannel() { + if (!singletonNevChannel) { + singletonNevChannel = new BroadcastChannel("NemoVisualization"); + } +} + +export type NevBroadcastChannelHandler = ( + payload: RequestPayload, +) => Promise; +type CloseCallback = () => void; + +function addNevListener( + nemoSessionId: string, + eventName: string, + handler: NevBroadcastChannelHandler, +): CloseCallback { + // create connection to channel if none exist yet + // NOTE: We never close the connection since there is no sensible time to do so. + // During normal operations, once reasoning has been performed, there will always be listeners on the channel. + initChannel(); + const bc = singletonNevChannel!; + + const onMessage = async (event: MessageEvent) => { + // Don't listen on own messages (which might be filtered by default by the channel, I did not check this...). + if (!!event.data.error || !!event.data.responseType) { + return; + } + + // Ignore messages that are not meant for this nemo session. + if (event.data.nemoId !== nemoSessionId) { + return; + } + + const id = event.data.id; + + if (!event.data.queryType || !event.data.payload) { + bc.postMessage({ + nemoId: nemoSessionId, + id, + error: "Expected an object with queryType and payload.", + }); + return; + } + + const { queryType, payload } = event.data; // data should consist of queryType and payload + + // ignore everything but the target eventName + if (queryType !== eventName) { + return; + } + + try { + const response = await handler(payload); + // if the handler has a proper response, then we send the response + // (some handler might just perform an action and don't respond) + if (response !== undefined) { + bc.postMessage({ + nemoId: nemoSessionId, + id, + responseType: eventName, + payload: response, + }); + } + } catch (error) { + console.error(error); + bc.postMessage({ + nemoId: nemoSessionId, + id, + error, + }); + } + }; + + bc.addEventListener("message", onMessage); + + return () => { + bc.removeEventListener("message", onMessage); + }; +} + +export function useNevBroadcastChannelListener( + eventName: string, + handler: NevBroadcastChannelHandler, +) { + const nemoSessionId = useContext(NemoSessionIdContext); + + useEffect(() => { + const removeListener = addNevListener(nemoSessionId, eventName, handler); + + return () => removeListener(); + }, [nemoSessionId, eventName, handler]); +} diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 2b00d88..04484e7 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -1,3 +1,4 @@ +import { useCallback, useEffect, useRef } from "react"; import { useAppDispatch, useAppSelector } from "../../store"; import "./Editor.css"; import { programInfoSlice } from "../../store/programInfo"; @@ -5,6 +6,12 @@ import { selectDarkMode } from "../../store/preferences/selectors/selectDarkMode import { selectProgramText } from "../../store/programInfo/selectors/selectProgramText"; import { LazyMonacoWrapper } from "./LazyMonacoWrapper"; import { DefaultSuspense } from "../DefaultSuspense"; +import { + NevBroadcastChannelHandler, + useNevBroadcastChannelListener, +} from "../../NevBroadcastChannel"; +import { useNemoWorkerRef } from "../../NemoWorkerContext/NemoWorkerContext"; +import { MonacoWrapperImperativeHandle } from "./MonacoWrapper"; export function Editor() { const dispatch = useAppDispatch(); @@ -12,9 +19,64 @@ export function Editor() { const darkMode = useAppSelector(selectDarkMode); const programText = useAppSelector(selectProgramText); + const workerRef = useNemoWorkerRef(); + + const editorRef = useRef(null); + + // request notification permission + useEffect(() => { + if (Notification.permission === "default") { + Notification.requestPermission(); + } + }, []); + + // Jump to line in editor when Nev requests it + const jumpToRuleHandler: NevBroadcastChannelHandler<{ id: number }, string> = + useCallback( + async ({ id: ruleId }) => { + if (!workerRef?.current) { + throw "Cannot process message. Reasoning was not performed."; + } + + // get line number from rule id using the Nemo backend + const lineToJumpTo = + await workerRef.current.getLineNumberFromRuleId(ruleId); + + if (lineToJumpTo === undefined) { + throw "The target Rule Id was not found in the program."; + } + + // briefly change window title + const originalTitle = document.title; + document.title = `[!] ${originalTitle}`; + setTimeout(() => { + document.title = originalTitle; + }, 2000); + + // send notification if allowed + if (Notification.permission === "granted") { + const notification = new Notification("Rule Highlighted", { + body: "The rule has been highlighted in the editor.", + }); + notification.addEventListener("click", () => window.focus()); + } + + editorRef.current?.jumpToLine(lineToJumpTo); + + // Nev expects some kind of response... + return "Successfully jumped to line"; + }, + [workerRef], + ); + useNevBroadcastChannelListener<{ id: number }, string>( + "jumpToRule", + jumpToRuleHandler, + ); + return ( { diff --git a/src/components/editor/MonacoWrapper.tsx b/src/components/editor/MonacoWrapper.tsx index 0d82cbe..9560b9c 100644 --- a/src/components/editor/MonacoWrapper.tsx +++ b/src/components/editor/MonacoWrapper.tsx @@ -1,8 +1,12 @@ import * as monaco from "monaco-editor"; -import { useEffect, useRef } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; import "./MonacoWrapper.css"; import { createEditor, changeTheme } from "./createMonacoEditor"; +export interface MonacoWrapperImperativeHandle { + jumpToLine: (lineToJumoTo: number) => void; +} + export interface MonacoWrapperProps { additionalMonacoOptions?: monaco.editor.IStandaloneEditorConstructionOptions; darkMode: boolean; @@ -14,105 +18,126 @@ export interface MonacoWrapperProps { * See https://microsoft.github.io/monaco-editor/typedoc * See https://microsoft.github.io/monaco-editor/playground.html */ -export function MonacoWrapper({ - additionalMonacoOptions, - darkMode, - programText, - onProgramTextChange, -}: MonacoWrapperProps) { - const containerRef = useRef(null); - const editorRef = useRef(null); - - useEffect(() => { - // Create editor if container is available - if (containerRef === null || containerRef.current === null) { - return; - } - - const child = document.createElement("div"); - containerRef.current.replaceChildren(child); - - let changeEvent: any | undefined; - let editor: monaco.editor.IStandaloneCodeEditor | undefined; - let cleanedUp = false; - - const timeout = setTimeout(async function () { - console.debug("[Editor] Creating Monaco editor"); - editor = await createEditor( - child, - darkMode, - programText, - additionalMonacoOptions, - ); - console.debug("[Editor] Created Monaco editor", editor); +export const MonacoWrapper = forwardRef( + ( + { + additionalMonacoOptions, + darkMode, + programText, + onProgramTextChange, + }: MonacoWrapperProps, + ref, + ) => { + const containerRef = useRef(null); + const editorRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + jumpToLine(lineToJumpTo: number) { + editorRef.current?.revealLineInCenter(lineToJumpTo); + editorRef.current?.setSelection({ + startLineNumber: lineToJumpTo, + startColumn: 0, + endLineNumber: lineToJumpTo + 1, + endColumn: 0, + }); + }, + }), + [], + ); - if (cleanedUp) { - editor.dispose(); + useEffect(() => { + // Create editor if container is available + if (containerRef === null || containerRef.current === null) { return; } - editorRef.current = editor!; - - // Update Redux state when editor contents change - changeEvent = editor!.onDidChangeModelContent(() => { - if (onProgramTextChange !== undefined) { - onProgramTextChange(editor!.getValue()); + const child = document.createElement("div"); + containerRef.current.replaceChildren(child); + + let changeEvent: any | undefined; + let editor: monaco.editor.IStandaloneCodeEditor | undefined; + let cleanedUp = false; + + const timeout = setTimeout(async function () { + console.debug("[Editor] Creating Monaco editor"); + editor = await createEditor( + child, + darkMode, + programText, + additionalMonacoOptions, + ); + console.debug("[Editor] Created Monaco editor", editor); + + if (cleanedUp) { + editor.dispose(); + return; } - }); - }, 0); - - return () => { - // Clean up the editor - console.debug("[Editor] Disposing Monaco editor"); - - clearTimeout(timeout); - cleanedUp = true; - changeEvent?.dispose(); - editor?.dispose(); - editorRef.current = null; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Update editor theme when Redux state changes - useEffect(() => { - const editor = editorRef.current; - if (editor === null) { - return; - } - - changeTheme(darkMode); - }, [darkMode]); - - // Update editor contents when Redux state changes - useEffect(() => { - const editor = editorRef.current; - if (editor === null) { - return; - } - - const model = editor.getModel(); - if (model === null) { - return; - } - - if (editor.getValue() == programText) { - return; - } - - // Add new text to undo history - editor.pushUndoStop(); - model.pushEditOperations( - editor.getSelections(), - [ - { - range: model.getFullModelRange(), - text: programText, - }, - ], - () => null, - ); - }, [programText]); - return
; -} + editorRef.current = editor!; + + // Update Redux state when editor contents change + changeEvent = editor!.onDidChangeModelContent(() => { + if (onProgramTextChange !== undefined) { + onProgramTextChange(editor!.getValue()); + } + }); + }, 0); + + return () => { + // Clean up the editor + console.debug("[Editor] Disposing Monaco editor"); + + clearTimeout(timeout); + cleanedUp = true; + changeEvent?.dispose(); + editor?.dispose(); + editorRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Update editor theme when Redux state changes + useEffect(() => { + const editor = editorRef.current; + if (editor === null) { + return; + } + + changeTheme(darkMode); + }, [darkMode]); + + // Update editor contents when Redux state changes + useEffect(() => { + const editor = editorRef.current; + if (editor === null) { + return; + } + + const model = editor.getModel(); + if (model === null) { + return; + } + + if (editor.getValue() == programText) { + return; + } + + // Add new text to undo history + editor.pushUndoStop(); + model.pushEditOperations( + editor.getSelections(), + [ + { + range: model.getFullModelRange(), + text: programText, + }, + ], + () => null, + ); + }, [programText]); + + return
; + }, +); diff --git a/src/components/executionPanel/ExecutionPanel.tsx b/src/components/executionPanel/ExecutionPanel.tsx index 9e8f6d8..3fa146b 100644 --- a/src/components/executionPanel/ExecutionPanel.tsx +++ b/src/components/executionPanel/ExecutionPanel.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useEffect, useContext } from "react"; +import { useCallback, useState } from "react"; import { Card, Button, @@ -10,21 +10,27 @@ import { Badge, Offcanvas, } from "react-bootstrap"; -import { useAppSelector } from "../../store/index"; +import { useAppSelector } from "../../store"; import { selectProgramText } from "../../store/programInfo/selectors/selectProgramText"; import { Icon } from "../Icon"; import { TextTooltip } from "../TextTooltip"; -import { - createNemoWorker, - NemoProgramInfo, - NemoWorker, -} from "../../nemoWorker/NemoWorker"; +import { createNemoWorker, NemoProgramInfo } from "../../nemoWorker/NemoWorker"; import { PredicateResults } from "./results/PredicateResults"; import { FactCounts } from "../../nemoWorker/NemoRunner"; import "./ExecutionPanel.css"; import { chooseFile } from "../../chooseFile"; import { downloadPredicate } from "./downloadPredicate"; -import { NemoSessionIdContext } from "../../store/nemoSessionIdContext"; +import { + NevBroadcastChannelHandler, + useNevBroadcastChannelListener, +} from "../../NevBroadcastChannel"; +import { + TableEntriesForTreeNodesQuery, + TableEntriesForTreeNodesResponseInner, + TreeForTableQuery, + TreeForTableResponse, +} from "../../nemoWorker/models"; +import { useNemoWorkerRef } from "../../NemoWorkerContext/NemoWorkerContext"; function convertFileSize(size: number) { let index = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1000)); @@ -39,11 +45,9 @@ function convertFileSize(size: number) { } export function ExecutionPanel() { - const nemoSessionId = useContext(NemoSessionIdContext); const [inputs, setInputs] = useState<{ resource: string; file: File }[]>([]); const [showInputs, setShowInputs] = useState(false); const [activeKey, setActiveKey] = useState(undefined); - const workerRef = useRef(undefined); const [programInfo, setProgramInfo] = useState( undefined, ); @@ -57,8 +61,10 @@ export function ExecutionPanel() { const programText = useAppSelector(selectProgramText); + const workerRef = useNemoWorkerRef(); + const stopProgram = () => { - if (workerRef.current !== undefined) { + if (workerRef?.current) { // Terminate web worker workerRef.current.stop(); workerRef.current = undefined; @@ -77,7 +83,7 @@ export function ExecutionPanel() { try { const worker = await createNemoWorker(setIsWorkerActive); - workerRef.current = worker; + workerRef!.current = worker; console.debug("[ExecutionPanel] Created Nemo worker", worker); const programInfo = await worker.setupNemoEngine( @@ -111,81 +117,41 @@ export function ExecutionPanel() { setIsProgramRunning(false); }; - useEffect(() => { - const bc = new BroadcastChannel("NemoVisualization"); - - const onMessage = async (event: MessageEvent) => { - // Don't listen on own messages (which might be filtered by default by the channel, I did not check this...). - if (!!event.data.error || !!event.data.responseType) { - return; - } - - // Ignore messages that are not meant for this nemo session. - if (event.data.nemoId !== nemoSessionId) { - return; - } - - const id = event.data.id; - - if (workerRef.current === undefined) { - bc.postMessage({ - nemoId: nemoSessionId, - id, - error: "Cannot process message. Reasoning was not performed.", - }); - return; - } - - if (!event.data.queryType || !event.data.payload) { - bc.postMessage({ - nemoId: nemoSessionId, - id, - error: "Expected an object with queryType and payload.", - }); - return; + // add treeForTable listener + const treeForTableHandler: NevBroadcastChannelHandler< + TreeForTableQuery, + TreeForTableResponse + > = useCallback( + async (payload) => { + if (!workerRef?.current) { + throw "Cannot process message. Reasoning was not performed."; } + return await workerRef.current.traceTreeForTable(payload); + }, + [workerRef], + ); + useNevBroadcastChannelListener( + "treeForTable", + treeForTableHandler, + ); - const { queryType, payload } = event.data; // data should consist of queryType and payload - - let response; - switch (queryType) { - case "treeForTable": - response = await workerRef.current.traceTreeForTable(payload); - bc.postMessage({ - nemoId: nemoSessionId, - id, - responseType: "treeForTable", - payload: response, - }); - break; - case "tableEntriesForTreeNodes": - response = - await workerRef.current.traceTableEntriesForTreeNodes(payload); - bc.postMessage({ - nemoId: nemoSessionId, - id, - responseType: "tableEntriesForTreeNodes", - payload: response, - }); - break; - default: - bc.postMessage({ - nemoId: nemoSessionId, - id, - error: - "Invalid Query Type, expected treeForTable or tableEntriesForTreeNodes.", - }); - break; + // add tableEntriesForTreeNodes listener + const tableEntriesForTreeNodesHandler: NevBroadcastChannelHandler< + TableEntriesForTreeNodesQuery, + TableEntriesForTreeNodesResponseInner[] + > = useCallback( + async (payload) => { + if (!workerRef?.current) { + throw "Cannot process message. Reasoning was not performed."; } - }; - - bc.addEventListener("message", onMessage); - - return () => { - bc.removeEventListener("message", onMessage); - bc.close(); - }; - }, [nemoSessionId]); + return await workerRef.current.traceTableEntriesForTreeNodes(payload); + }, + [workerRef], + ); + useNevBroadcastChannelListener< + TableEntriesForTreeNodesQuery, + TableEntriesForTreeNodesResponseInner[] + >("tableEntriesForTreeNodes", tableEntriesForTreeNodesHandler); return ( <> @@ -356,7 +322,7 @@ export function ExecutionPanel() { title="Download Table as CSV" className="fst-italic text-decoration-none" onClick={async () => { - if (workerRef.current === undefined) { + if (!workerRef?.current) { return; } try { @@ -391,7 +357,6 @@ export function ExecutionPanel() { {factCounts !== undefined && predicate in factCounts.outputPredicates ? ( ; predicate: string; numberOfRows: number; } export function PredicateResults({ - workerRef, predicate, numberOfRows, }: PredicateResultsProps) { const nemoSessionId = useContext(NemoSessionIdContext); + const workerRef = useNemoWorkerRef(); const rowStoreRef = useRef(undefined); const [rows, setRows] = useState<{ values: any[]; id: number }[]>([]); const [page, setPage] = useState(0); @@ -34,7 +26,7 @@ export function PredicateResults({ numberOfRows <= 0 ? 1 : Math.ceil(numberOfRows / rowsPerPage); useEffect(() => { - if (workerRef.current === undefined) { + if (!workerRef?.current) { return; } @@ -100,8 +92,11 @@ export function PredicateResults({ { + window.open( + `./ev/?predicate=${predicate}&query=[${row.id}]&nemoId=${nemoSessionId}`, + ); + }} style={{ color: "inherit" }} > diff --git a/src/main.tsx b/src/main.tsx index c36b678..60a7078 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,7 +8,8 @@ import { Provider } from "react-redux"; import { createStore } from "./store"; import { programInfoSlice } from "./store/programInfo"; import { selectProgramText } from "./store/programInfo/selectors/selectProgramText"; -import { NemoSessionIdContext } from "./store/nemoSessionIdContext"; +import { NemoSessionIdContext } from "./nemoSessionIdContext"; +import { NemoWorkerProvider } from "./NemoWorkerContext/NemoWorkerContextProvider"; import "./i18n/i18n"; const store = createStore(); @@ -42,9 +43,11 @@ const nemoSessionId = nanoid(); createRoot(document.getElementById("root") as HTMLElement).render( - - - + + + + + , ); diff --git a/src/store/nemoSessionIdContext.ts b/src/nemoSessionIdContext.ts similarity index 100% rename from src/store/nemoSessionIdContext.ts rename to src/nemoSessionIdContext.ts diff --git a/src/nemoWorker/NemoRunner.ts b/src/nemoWorker/NemoRunner.ts index bfb82ff..4a8541c 100644 --- a/src/nemoWorker/NemoRunner.ts +++ b/src/nemoWorker/NemoRunner.ts @@ -160,6 +160,10 @@ export class NemoRunner { return this.engine!.getOutputPredicates(); } + public async getLineNumberFromRuleId(ruleId: number) { + return this.engine!.getLineNumberFromRuleId(ruleId); + } + public async traceTreeForTable( tree_for_table_query: TreeForTableQuery, ): Promise {