diff --git a/apps/hash-frontend/next.config.js b/apps/hash-frontend/next.config.js index 606f4cd6a5f..d6d6bef9f1d 100644 --- a/apps/hash-frontend/next.config.js +++ b/apps/hash-frontend/next.config.js @@ -219,20 +219,6 @@ export default withSentryConfig( // eslint-disable-next-line no-param-reassign webpackConfig.resolve.alias.canvas = false; - if (!isServer) { - // Stub Node.js built-ins for browser — needed by `typescript` (used by - // @hashintel/petrinaut's in-browser language service) - // eslint-disable-next-line no-param-reassign - webpackConfig.resolve.fallback = { - ...webpackConfig.resolve.fallback, - module: false, - fs: false, - path: false, - os: false, - perf_hooks: false, - }; - } - webpackConfig.plugins.push( new DefinePlugin({ __SENTRY_DEBUG__: false, diff --git a/apps/hash-frontend/src/lib/csp.ts b/apps/hash-frontend/src/lib/csp.ts index 171adfb0a5b..4b327029a49 100644 --- a/apps/hash-frontend/src/lib/csp.ts +++ b/apps/hash-frontend/src/lib/csp.ts @@ -24,9 +24,6 @@ export const buildCspHeader = (nonce: string): string => { "https://apis.google.com", // Vercel toolbar / live preview widget "https://vercel.live", - // @todo FE-488 will make this unnecessary - // Monaco Editor loaded from CDN by @monaco-editor/react (used by petrinaut) - "https://cdn.jsdelivr.net", ], "style-src": [ @@ -34,9 +31,6 @@ export const buildCspHeader = (nonce: string): string => { // Required for Emotion/MUI CSS-in-JS inline style injection. // @todo Use nonce-based approach via Emotion's cache `nonce` option. "'unsafe-inline'", - // @todo FE-488 will make this unnecessary - // Monaco Editor stylesheet loaded from CDN by @monaco-editor/react (used by petrinaut) - "https://cdn.jsdelivr.net", ], "img-src": [ @@ -51,12 +45,7 @@ export const buildCspHeader = (nonce: string): string => { ...(process.env.NODE_ENV === "development" ? ["http:"] : []), ], - "font-src": [ - "'self'", - // @todo FE-488 will make this unnecessary - // Monaco Editor CSS embeds the Codicon icon font as an inline base64 data URI - "data:", - ], + "font-src": ["'self'"], "connect-src": [ "'self'", diff --git a/libs/@hashintel/petrinaut/src/checker/context.ts b/libs/@hashintel/petrinaut/src/checker/context.ts new file mode 100644 index 00000000000..d95dea43201 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/context.ts @@ -0,0 +1,49 @@ +import { createContext } from "react"; + +import type { + CheckerCompletionResult, + CheckerItemDiagnostics, + CheckerQuickInfoResult, + CheckerResult, + CheckerSignatureHelpResult, +} from "./worker/protocol"; + +export interface CheckerContextValue { + /** Result of the last SDCPN validation run. */ + checkResult: CheckerResult; + /** Total number of diagnostics across all items. */ + totalDiagnosticsCount: number; + /** Request completions at a position within an SDCPN item. */ + getCompletions: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; + /** Request quick info (hover) at a position within an SDCPN item. */ + getQuickInfo: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; + /** Request signature help at a position within an SDCPN item. */ + getSignatureHelp: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; +} + +const DEFAULT_CONTEXT_VALUE: CheckerContextValue = { + checkResult: { + isValid: true, + itemDiagnostics: [], + }, + totalDiagnosticsCount: 0, + getCompletions: () => Promise.resolve({ items: [] }), + getQuickInfo: () => Promise.resolve(null), + getSignatureHelp: () => Promise.resolve(null), +}; + +export const CheckerContext = createContext( + DEFAULT_CONTEXT_VALUE, +); diff --git a/libs/@hashintel/petrinaut/src/core/checker/checker.test.ts b/libs/@hashintel/petrinaut/src/checker/lib/checker.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/checker.test.ts rename to libs/@hashintel/petrinaut/src/checker/lib/checker.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/checker/checker.ts b/libs/@hashintel/petrinaut/src/checker/lib/checker.ts similarity index 90% rename from libs/@hashintel/petrinaut/src/core/checker/checker.ts rename to libs/@hashintel/petrinaut/src/checker/lib/checker.ts index 33d170423ae..61a5d27799a 100644 --- a/libs/@hashintel/petrinaut/src/core/checker/checker.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/checker.ts @@ -1,7 +1,10 @@ import type ts from "typescript"; -import type { SDCPN } from "../types/sdcpn"; -import { createSDCPNLanguageService } from "./create-sdcpn-language-service"; +import type { SDCPN } from "../../core/types/sdcpn"; +import { + createSDCPNLanguageService, + type SDCPNLanguageService, +} from "./create-sdcpn-language-service"; import { getItemFilePath } from "./file-paths"; export type SDCPNDiagnostic = { @@ -29,8 +32,12 @@ export type SDCPNCheckResult = { * @param sdcpn - The SDCPN to check * @returns A result object indicating validity and any diagnostics */ -export function checkSDCPN(sdcpn: SDCPN): SDCPNCheckResult { - const languageService = createSDCPNLanguageService(sdcpn); +export function checkSDCPN( + sdcpn: SDCPN, + existingLanguageService?: SDCPNLanguageService, +): SDCPNCheckResult { + const languageService = + existingLanguageService ?? createSDCPNLanguageService(sdcpn); const itemDiagnostics: SDCPNDiagnostic[] = []; // Check all differential equations diff --git a/libs/@hashintel/petrinaut/src/core/checker/create-language-service-host.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts similarity index 80% rename from libs/@hashintel/petrinaut/src/core/checker/create-language-service-host.ts rename to libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts index 3e85556c015..4e22135e8ce 100644 --- a/libs/@hashintel/petrinaut/src/core/checker/create-language-service-host.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts @@ -42,9 +42,12 @@ export type VirtualFile = { * * Creates a TypeScript LanguageServiceHost for virtual SDCPN files */ -export function createLanguageServiceHost( - files: Map, -): ts.LanguageServiceHost { +export function createLanguageServiceHost(files: Map): { + host: ts.LanguageServiceHost; + updateFileContent: (fileName: string, content: string) => void; +} { + const versions = new Map(); + const getFileContent = (fileName: string): string | undefined => { const entry = files.get(fileName); if (entry) { @@ -62,10 +65,18 @@ export function createLanguageServiceHost( return undefined; }; - return { + const updateFileContent = (fileName: string, content: string) => { + const entry = files.get(fileName); + if (entry) { + entry.content = content; + versions.set(fileName, (versions.get(fileName) ?? 0) + 1); + } + }; + + const host: ts.LanguageServiceHost = { getScriptFileNames: () => [...files.keys()], getCompilationSettings: () => COMPILER_OPTIONS, - getScriptVersion: () => "0", + getScriptVersion: (fileName) => String(versions.get(fileName) ?? 0), getCurrentDirectory: () => "/", getDefaultLibFileName: () => "/lib.es2015.core.d.ts", @@ -82,4 +93,6 @@ export function createLanguageServiceHost( return getFileContent(path); }, }; + + return { host, updateFileContent }; } diff --git a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts new file mode 100644 index 00000000000..791b66f2c23 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vitest"; + +import { createSDCPNLanguageService } from "./create-sdcpn-language-service"; +import { getItemFilePath } from "./file-paths"; +import { createSDCPN } from "./helper/create-sdcpn"; + +/** Cursor marker used in test code strings to indicate the completion position. */ +const CURSOR = "∫"; + +/** + * Find the offset of a cursor marker in user code. + * Returns the offset (without the marker) and the clean code. + */ +function parseCursor(codeWithCursor: string): { + offset: number; + code: string; +} { + const offset = codeWithCursor.indexOf(CURSOR); + if (offset === -1) { + throw new Error(`No cursor marker \`${CURSOR}\` found in code`); + } + const code = + codeWithCursor.slice(0, offset) + codeWithCursor.slice(offset + 1); + return { offset, code }; +} + +/** Extract just the completion names from a position. */ +function getCompletionNames( + sdcpnOptions: Parameters[0], + codeWithCursor: string, + target: + | { type: "transition-lambda"; transitionId?: string } + | { type: "transition-kernel"; transitionId?: string } + | { type: "differential-equation"; deId?: string }, +): string[] { + const { offset, code } = parseCursor(codeWithCursor); + + // Patch the SDCPN to inject the clean code at the right item + const patched = { ...sdcpnOptions }; + if ( + target.type === "transition-lambda" || + target.type === "transition-kernel" + ) { + const transitionId = target.transitionId ?? "t1"; + patched.transitions = (patched.transitions ?? []).map((tr) => + (tr.id ?? "t1") === transitionId + ? { + ...tr, + ...(target.type === "transition-lambda" + ? { lambdaCode: code } + : { transitionKernelCode: code }), + } + : tr, + ); + } else { + const deId = target.deId ?? "de_1"; + patched.differentialEquations = (patched.differentialEquations ?? []).map( + (de, index) => + (de.id ?? `de_${index + 1}`) === deId ? { ...de, code } : de, + ); + } + + const sdcpn = createSDCPN(patched); + const ls = createSDCPNLanguageService(sdcpn); + + let filePath: string; + if (target.type === "transition-lambda") { + filePath = getItemFilePath("transition-lambda-code", { + transitionId: target.transitionId ?? "t1", + }); + } else if (target.type === "transition-kernel") { + filePath = getItemFilePath("transition-kernel-code", { + transitionId: target.transitionId ?? "t1", + }); + } else { + filePath = getItemFilePath("differential-equation-code", { + id: target.deId ?? "de_1", + }); + } + + const completions = ls.getCompletionsAtPosition(filePath, offset, undefined); + return (completions?.entries ?? []).map((entry) => entry.name); +} + +describe("createSDCPNLanguageService completions", () => { + const baseSdcpn = { + types: [{ id: "color1", elements: [{ name: "x", type: "real" as const }] }], + places: [ + { id: "place1", name: "Source", colorId: "color1" }, + { id: "place2", name: "Target", colorId: "color1" }, + ], + parameters: [ + { id: "p1", variableName: "alpha", type: "real" as const }, + { id: "p2", variableName: "enabled", type: "boolean" as const }, + ], + transitions: [ + { + id: "t1", + lambdaType: "predicate" as const, + inputArcs: [{ placeId: "place1", weight: 1 }], + outputArcs: [{ placeId: "place2", weight: 1 }], + lambdaCode: "", + transitionKernelCode: "", + }, + ], + }; + + describe("member access completions (dot completions)", () => { + it("returns Number methods after `a.` where a is a number", () => { + const names = getCompletionNames( + baseSdcpn, + `const a = 42;\na.${CURSOR}`, + { + type: "transition-lambda", + }, + ); + + expect(names).toContain("toFixed"); + expect(names).toContain("toString"); + expect(names).toContain("valueOf"); + // Should NOT contain global scope items + expect(names).not.toContain("Array"); + expect(names).not.toContain("Lambda"); + }); + + it("returns parameter names after `parameters.`", () => { + const names = getCompletionNames( + baseSdcpn, + `export default Lambda((input, parameters) => {\n return parameters.${CURSOR};\n});`, + { type: "transition-lambda" }, + ); + + expect(names).toContain("alpha"); + expect(names).toContain("enabled"); + // Should NOT contain globals + expect(names).not.toContain("Array"); + }); + + it("returns place names after `input.`", () => { + const names = getCompletionNames( + baseSdcpn, + `export default Lambda((input, parameters) => {\n return input.${CURSOR};\n});`, + { type: "transition-lambda" }, + ); + + expect(names).toContain("Source"); + // Should NOT contain unrelated globals + expect(names).not.toContain("Array"); + }); + + it("returns token properties after `input.Source[0].`", () => { + const names = getCompletionNames( + baseSdcpn, + `export default Lambda((input, parameters) => {\n const token = input.Source[0];\n return token.${CURSOR};\n});`, + { type: "transition-lambda" }, + ); + + expect(names).toContain("x"); + }); + + it("returns String methods after string expression", () => { + const names = getCompletionNames( + baseSdcpn, + `const s = "hello";\ns.${CURSOR}`, + { + type: "transition-lambda", + }, + ); + + expect(names).toContain("charAt"); + expect(names).toContain("indexOf"); + expect(names).not.toContain("Array"); + }); + }); + + describe("top-level completions (no dot)", () => { + it("returns globals and declared identifiers at top level", () => { + const names = getCompletionNames(baseSdcpn, `const a = 42;\n${CURSOR}`, { + type: "transition-lambda", + }); + + // Should include user-defined and prefix-declared identifiers + expect(names).toContain("a"); + expect(names).toContain("Lambda"); + // Should include globals + expect(names).toContain("Array"); + }); + }); + + describe("updateFileContent", () => { + it("returns completions for updated code, not original code", () => { + // Start with number code + const sdcpn = createSDCPN({ + ...baseSdcpn, + transitions: [ + { + ...baseSdcpn.transitions[0]!, + lambdaCode: "const a = 42;\na.", + }, + ], + }); + + const ls = createSDCPNLanguageService(sdcpn); + const filePath = getItemFilePath("transition-lambda-code", { + transitionId: "t1", + }); + + // Update to string code + const { offset, code } = parseCursor(`const s = "hello";\ns.${CURSOR}`); + ls.updateFileContent(filePath, code); + + const completions = ls.getCompletionsAtPosition( + filePath, + offset, + undefined, + ); + const names = (completions?.entries ?? []).map((entry) => entry.name); + + // Should have String methods + expect(names).toContain("charAt"); + expect(names).toContain("indexOf"); + // Should NOT have Number methods + expect(names).not.toContain("toFixed"); + }); + + it("reflects new content after multiple updates", () => { + const sdcpn = createSDCPN({ + ...baseSdcpn, + transitions: [ + { + ...baseSdcpn.transitions[0]!, + lambdaCode: "", + }, + ], + }); + + const ls = createSDCPNLanguageService(sdcpn); + const filePath = getItemFilePath("transition-lambda-code", { + transitionId: "t1", + }); + + // First update: number + const first = parseCursor(`const a = 42;\na.${CURSOR}`); + ls.updateFileContent(filePath, first.code); + const firstCompletions = ls.getCompletionsAtPosition( + filePath, + first.offset, + undefined, + ); + const firstNames = (firstCompletions?.entries ?? []).map( + (entry) => entry.name, + ); + expect(firstNames).toContain("toFixed"); + + // Second update: string + const second = parseCursor(`const s = "hello";\ns.${CURSOR}`); + ls.updateFileContent(filePath, second.code); + const secondCompletions = ls.getCompletionsAtPosition( + filePath, + second.offset, + undefined, + ); + const secondNames = (secondCompletions?.entries ?? []).map( + (entry) => entry.name, + ); + expect(secondNames).toContain("charAt"); + expect(secondNames).not.toContain("toFixed"); + }); + }); + + describe("differential equation completions", () => { + const deSdcpn = { + types: [ + { + id: "color1", + elements: [{ name: "velocity", type: "real" as const }], + }, + ], + parameters: [ + { id: "p1", variableName: "gravity", type: "real" as const }, + ], + differentialEquations: [ + { + id: "de1", + colorId: "color1", + code: "", + }, + ], + }; + + it("returns token properties after `tokens[0].`", () => { + const names = getCompletionNames( + deSdcpn, + `export default Dynamics((tokens, parameters) => {\n const t = tokens[0];\n return t.${CURSOR};\n});`, + { type: "differential-equation", deId: "de1" }, + ); + + expect(names).toContain("velocity"); + }); + + it("returns parameter names after `parameters.`", () => { + const names = getCompletionNames( + deSdcpn, + `export default Dynamics((tokens, parameters) => {\n return parameters.${CURSOR};\n});`, + { type: "differential-equation", deId: "de1" }, + ); + + expect(names).toContain("gravity"); + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/core/checker/create-sdcpn-language-service.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts similarity index 89% rename from libs/@hashintel/petrinaut/src/core/checker/create-sdcpn-language-service.ts rename to libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts index 87d16b0274e..02064505ca5 100644 --- a/libs/@hashintel/petrinaut/src/core/checker/create-sdcpn-language-service.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts @@ -1,13 +1,15 @@ import ts from "typescript"; -import type { SDCPN } from "../types/sdcpn"; +import type { SDCPN } from "../../core/types/sdcpn"; import { createLanguageServiceHost, type VirtualFile, } from "./create-language-service-host"; import { getItemFilePath } from "./file-paths"; -export type SDCPNLanguageService = ts.LanguageService; +export type SDCPNLanguageService = ts.LanguageService & { + updateFileContent: (fileName: string, content: string) => void; +}; /** * Sanitizes a color ID to be a valid TypeScript identifier. @@ -250,13 +252,15 @@ function adjustDiagnostics( */ export function createSDCPNLanguageService(sdcpn: SDCPN): SDCPNLanguageService { const files = generateVirtualFiles(sdcpn); - const host = createLanguageServiceHost(files); + const { host, updateFileContent } = createLanguageServiceHost(files); const baseService = ts.createLanguageService(host); // Proxy service to adjust positions for injected prefixes return { ...baseService, + updateFileContent, + getSemanticDiagnostics(fileName) { const entry = files.get(fileName); const prefixLength = entry?.prefix?.length ?? 0; @@ -280,5 +284,34 @@ export function createSDCPNLanguageService(sdcpn: SDCPN): SDCPNLanguageService { options, ); }, + + getQuickInfoAtPosition(fileName, position) { + const entry = files.get(fileName); + const prefixLength = entry?.prefix?.length ?? 0; + const info = baseService.getQuickInfoAtPosition( + fileName, + position + prefixLength, + ); + if (!info) { + return undefined; + } + return { + ...info, + textSpan: { + start: info.textSpan.start - prefixLength, + length: info.textSpan.length, + }, + }; + }, + + getSignatureHelpItems(fileName, position, options) { + const entry = files.get(fileName); + const prefixLength = entry?.prefix?.length ?? 0; + return baseService.getSignatureHelpItems( + fileName, + position + prefixLength, + options, + ); + }, }; } diff --git a/libs/@hashintel/petrinaut/src/core/checker/file-paths.ts b/libs/@hashintel/petrinaut/src/checker/lib/file-paths.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/file-paths.ts rename to libs/@hashintel/petrinaut/src/checker/lib/file-paths.ts diff --git a/libs/@hashintel/petrinaut/src/core/checker/helper/create-sdcpn.ts b/libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts similarity index 98% rename from libs/@hashintel/petrinaut/src/core/checker/helper/create-sdcpn.ts rename to libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts index eec76175ef0..644c469521d 100644 --- a/libs/@hashintel/petrinaut/src/core/checker/helper/create-sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts @@ -5,7 +5,7 @@ import type { Place, SDCPN, Transition, -} from "../../types/sdcpn"; +} from "../../../core/types/sdcpn"; type PartialColor = Omit, "elements"> & { elements?: Array>; diff --git a/libs/@hashintel/petrinaut/src/checker/provider.tsx b/libs/@hashintel/petrinaut/src/checker/provider.tsx new file mode 100644 index 00000000000..04ef268eef0 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/provider.tsx @@ -0,0 +1,54 @@ +import { use, useEffect, useState } from "react"; + +import { SDCPNContext } from "../state/sdcpn-context"; +import { CheckerContext } from "./context"; +import type { CheckerResult } from "./worker/protocol"; +import { useCheckerWorker } from "./worker/use-checker-worker"; + +const EMPTY_RESULT: CheckerResult = { + isValid: true, + itemDiagnostics: [], +}; + +export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const { petriNetDefinition } = use(SDCPNContext); + const { setSDCPN, getCompletions, getQuickInfo, getSignatureHelp } = + useCheckerWorker(); + + const [checkResult, setCheckerResult] = useState(EMPTY_RESULT); + + useEffect(() => { + let cancelled = false; + + void setSDCPN(petriNetDefinition).then((result) => { + if (!cancelled) { + setCheckerResult(result); + } + }); + + return () => { + cancelled = true; + }; + }, [petriNetDefinition, setSDCPN]); + + const totalDiagnosticsCount = checkResult.itemDiagnostics.reduce( + (sum, item) => sum + item.diagnostics.length, + 0, + ); + + return ( + + {children} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts new file mode 100644 index 00000000000..caa4b826ca7 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts @@ -0,0 +1,238 @@ +/* eslint-disable no-restricted-globals */ +/** + * Checker WebWorker — runs TypeScript validation off the main thread. + * + * Receives JSON-RPC requests via `postMessage`, delegates to `checkSDCPN`, + * serializes the diagnostics (flatten messageText, strip non-cloneable fields), + * and posts the result back. + * + * The LanguageService is persisted between calls so that `getCompletions` + * can reuse it without re-sending the full SDCPN model. + */ +import ts from "typescript"; + +import { checkSDCPN } from "../lib/checker"; +import { + createSDCPNLanguageService, + type SDCPNLanguageService, +} from "../lib/create-sdcpn-language-service"; +import { getItemFilePath } from "../lib/file-paths"; +import type { + CheckerCompletionItem, + CheckerCompletionResult, + CheckerDiagnostic, + CheckerItemDiagnostics, + CheckerQuickInfoResult, + CheckerResult, + CheckerSignatureHelpResult, + CheckerSignatureInfo, + JsonRpcRequest, + JsonRpcResponse, +} from "./protocol"; + +/** Persisted LanguageService — created on `setSDCPN`, reused by `getCompletions`. */ +let languageService: SDCPNLanguageService | null = null; + +/** Strip `ts.SourceFile` and flatten `DiagnosticMessageChain` for structured clone. */ +function serializeDiagnostic(diag: ts.Diagnostic): CheckerDiagnostic { + return { + category: diag.category, + code: diag.code, + messageText: ts.flattenDiagnosticMessageText(diag.messageText, "\n"), + start: diag.start, + length: diag.length, + }; +} + +/** Map (itemType, itemId) → checker virtual file path. */ +function getCheckerFilePath( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, +): string { + switch (itemType) { + case "transition-lambda": + return getItemFilePath("transition-lambda-code", { + transitionId: itemId, + }); + case "transition-kernel": + return getItemFilePath("transition-kernel-code", { + transitionId: itemId, + }); + case "differential-equation": + return getItemFilePath("differential-equation-code", { id: itemId }); + } +} + +self.onmessage = async ({ data }: MessageEvent) => { + const { id, method } = data; + + try { + switch (method) { + case "setSDCPN": { + const { sdcpn } = data.params; + + languageService = createSDCPNLanguageService(sdcpn); + const raw = checkSDCPN(sdcpn, languageService); + + const result: CheckerResult = { + isValid: raw.isValid, + itemDiagnostics: raw.itemDiagnostics.map( + (item): CheckerItemDiagnostics => ({ + itemId: item.itemId, + itemType: item.itemType, + filePath: item.filePath, + diagnostics: item.diagnostics.map(serializeDiagnostic), + }), + ), + }; + + self.postMessage({ + jsonrpc: "2.0", + id, + result, + } satisfies JsonRpcResponse); + break; + } + + case "getCompletions": { + const { itemType, itemId, offset } = data.params; + + // Wait before requesting completions, to be sure the file content is updated + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + if (!languageService) { + self.postMessage({ + jsonrpc: "2.0", + id, + result: { items: [] }, + } satisfies JsonRpcResponse); + break; + } + + const filePath = getCheckerFilePath(itemType, itemId); + const completions = languageService.getCompletionsAtPosition( + filePath, + offset, + undefined, + ); + + const items: CheckerCompletionItem[] = (completions?.entries ?? []).map( + (entry) => ({ + name: entry.name, + kind: entry.kind, + sortText: entry.sortText, + insertText: entry.insertText, + }), + ); + + self.postMessage({ + jsonrpc: "2.0", + id, + result: { items }, + } satisfies JsonRpcResponse); + break; + } + + case "getQuickInfo": { + const { itemType, itemId, offset } = data.params; + + if (!languageService) { + self.postMessage({ + jsonrpc: "2.0", + id, + result: null, + } satisfies JsonRpcResponse); + break; + } + + const filePath = getCheckerFilePath(itemType, itemId); + const info = languageService.getQuickInfoAtPosition(filePath, offset); + + const result: CheckerQuickInfoResult = info + ? { + displayParts: ts.displayPartsToString(info.displayParts), + documentation: ts.displayPartsToString(info.documentation), + start: info.textSpan.start, + length: info.textSpan.length, + } + : null; + + self.postMessage({ + jsonrpc: "2.0", + id, + result, + } satisfies JsonRpcResponse); + break; + } + + case "getSignatureHelp": { + const { itemType, itemId, offset } = data.params; + + // Wait a bit before requesting completions, to be sure the file content is updated + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + if (!languageService) { + self.postMessage({ + jsonrpc: "2.0", + id, + result: null, + } satisfies JsonRpcResponse); + break; + } + + const filePath = getCheckerFilePath(itemType, itemId); + const help = languageService.getSignatureHelpItems( + filePath, + offset, + undefined, + ); + + const result: CheckerSignatureHelpResult = help + ? { + activeSignature: help.selectedItemIndex, + activeParameter: help.argumentIndex, + signatures: help.items.map( + (item): CheckerSignatureInfo => ({ + label: [ + ...item.prefixDisplayParts, + ...item.parameters.flatMap((param, idx) => [ + ...(idx > 0 ? item.separatorDisplayParts : []), + ...param.displayParts, + ]), + ...item.suffixDisplayParts, + ] + .map((part) => part.text) + .join(""), + documentation: ts.displayPartsToString(item.documentation), + parameters: item.parameters.map((param) => ({ + label: ts.displayPartsToString(param.displayParts), + documentation: ts.displayPartsToString(param.documentation), + })), + }), + ), + } + : null; + + self.postMessage({ + jsonrpc: "2.0", + id, + result, + } satisfies JsonRpcResponse); + break; + } + } + } catch (err) { + self.postMessage({ + jsonrpc: "2.0", + id, + error: { + code: -32603, + message: err instanceof Error ? err.message : String(err), + }, + } satisfies JsonRpcResponse); + } +}; diff --git a/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts b/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts new file mode 100644 index 00000000000..7e52045904c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts @@ -0,0 +1,152 @@ +/** + * JSON-RPC 2.0 protocol types for the checker WebWorker. + * + * These types define the contract between the main thread and the worker. + * Diagnostic types are serializable (no `ts.SourceFile` references) so they + * can cross the postMessage boundary via structured clone. + */ +import type { SDCPN } from "../../core/types/sdcpn"; + +// --------------------------------------------------------------------------- +// Diagnostics — serializable variants of ts.Diagnostic +// --------------------------------------------------------------------------- + +/** A single TypeScript diagnostic, safe for structured clone. */ +export type CheckerDiagnostic = { + /** @see ts.DiagnosticCategory */ + category: number; + /** TypeScript error code (e.g. 2322 for type mismatch). */ + code: number; + /** Human-readable message, pre-flattened from `ts.DiagnosticMessageChain`. */ + messageText: string; + /** Character offset in user code where the error starts. */ + start: number | undefined; + /** Length of the error span in characters. */ + length: number | undefined; +}; + +/** Diagnostics grouped by SDCPN item (one transition function or differential equation). */ +export type CheckerItemDiagnostics = { + /** ID of the transition or differential equation. */ + itemId: string; + /** Which piece of code was checked. */ + itemType: "transition-lambda" | "transition-kernel" | "differential-equation"; + /** Path in the virtual file system used by the TS LanguageService. */ + filePath: string; + /** All diagnostics found in this item's code. */ + diagnostics: CheckerDiagnostic[]; +}; + +/** Result of validating an entire SDCPN model. */ +export type CheckerResult = { + /** `true` when every item compiles without errors. */ + isValid: boolean; + /** Per-item diagnostics (empty when valid). */ + itemDiagnostics: CheckerItemDiagnostics[]; +}; + +// --------------------------------------------------------------------------- +// Completions — serializable variants of ts.CompletionEntry +// --------------------------------------------------------------------------- + +/** A single completion suggestion, safe for structured clone. */ +export type CheckerCompletionItem = { + name: string; + /** @see ts.ScriptElementKind */ + kind: string; + sortText: string; + insertText?: string; +}; + +/** Result of requesting completions at a position. */ +export type CheckerCompletionResult = { + items: CheckerCompletionItem[]; +}; + +// --------------------------------------------------------------------------- +// Quick Info (hover) — serializable variant of ts.QuickInfo +// --------------------------------------------------------------------------- + +/** Result of requesting quick info (hover) at a position. */ +export type CheckerQuickInfoResult = { + /** Type/signature display string. */ + displayParts: string; + /** JSDoc documentation string. */ + documentation: string; + /** Offset in user code where the hovered symbol starts. */ + start: number; + /** Length of the hovered symbol span. */ + length: number; +} | null; + +// --------------------------------------------------------------------------- +// Signature Help — serializable variant of ts.SignatureHelpItems +// --------------------------------------------------------------------------- + +/** A single parameter in a signature. */ +export type CheckerSignatureParameter = { + label: string; + documentation: string; +}; + +/** A single signature (overload). */ +export type CheckerSignatureInfo = { + label: string; + documentation: string; + parameters: CheckerSignatureParameter[]; +}; + +/** Result of requesting signature help at a position. */ +export type CheckerSignatureHelpResult = { + signatures: CheckerSignatureInfo[]; + activeSignature: number; + activeParameter: number; +} | null; + +// --------------------------------------------------------------------------- +// JSON-RPC 2.0 +// --------------------------------------------------------------------------- + +/** A JSON-RPC request sent from the main thread to the worker. */ +export type JsonRpcRequest = + | { + jsonrpc: "2.0"; + id: number; + method: "setSDCPN"; + params: { sdcpn: SDCPN }; + } + | { + jsonrpc: "2.0"; + id: number; + method: "getCompletions"; + params: { + itemType: CheckerItemDiagnostics["itemType"]; + itemId: string; + offset: number; + }; + } + | { + jsonrpc: "2.0"; + id: number; + method: "getQuickInfo"; + params: { + itemType: CheckerItemDiagnostics["itemType"]; + itemId: string; + offset: number; + }; + } + | { + jsonrpc: "2.0"; + id: number; + method: "getSignatureHelp"; + params: { + itemType: CheckerItemDiagnostics["itemType"]; + itemId: string; + offset: number; + }; + }; + +/** A JSON-RPC response sent from the worker back to the main thread. */ +export type JsonRpcResponse = + | { jsonrpc: "2.0"; id: number; result: Result } + | { jsonrpc: "2.0"; id: number; error: { code: number; message: string } }; diff --git a/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts b/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts new file mode 100644 index 00000000000..82d63cbca62 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useRef } from "react"; + +import type { SDCPN } from "../../core/types/sdcpn"; +import type { + CheckerCompletionResult, + CheckerItemDiagnostics, + CheckerQuickInfoResult, + CheckerResult, + CheckerSignatureHelpResult, + JsonRpcRequest, + JsonRpcResponse, +} from "./protocol"; + +type Pending = { + resolve: (result: never) => void; + reject: (error: Error) => void; +}; + +/** Methods exposed by the checker WebWorker. */ +export type CheckerWorkerApi = { + /** Send an SDCPN model to the worker. Persists the LanguageService and returns diagnostics. */ + setSDCPN: (sdcpn: SDCPN) => Promise; + /** Request completions at a position within an SDCPN item. */ + getCompletions: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; + /** Request quick info (hover) at a position within an SDCPN item. */ + getQuickInfo: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; + /** Request signature help at a position within an SDCPN item. */ + getSignatureHelp: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; +}; + +/** + * Spawn a checker WebWorker and return a Promise-based API to interact with it. + * The worker is created on mount and terminated on unmount. + */ +export function useCheckerWorker(): CheckerWorkerApi { + const workerRef = useRef(null); + const pendingRef = useRef(new Map()); + const nextId = useRef(0); + + useEffect(() => { + const worker = new Worker(new URL("./checker.worker.ts", import.meta.url), { + type: "module", + }); + + worker.onmessage = (event: MessageEvent) => { + const response = event.data; + const pending = pendingRef.current.get(response.id); + if (!pending) { + return; + } + pendingRef.current.delete(response.id); + + if ("error" in response) { + pending.reject(new Error(response.error.message)); + } else { + pending.resolve(response.result as never); + } + }; + + workerRef.current = worker; + const pending = pendingRef.current; + + return () => { + worker.terminate(); + workerRef.current = null; + for (const entry of pending.values()) { + entry.reject(new Error("Worker terminated")); + } + pending.clear(); + }; + }, []); + + const sendRequest = useCallback((request: JsonRpcRequest): Promise => { + const worker = workerRef.current; + if (!worker) { + return Promise.reject(new Error("Worker not initialized")); + } + + return new Promise((resolve, reject) => { + pendingRef.current.set(request.id, { + resolve: resolve as (result: never) => void, + reject, + }); + worker.postMessage(request); + }); + }, []); + + const setSDCPN = useCallback( + (sdcpn: SDCPN): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "setSDCPN", + params: { sdcpn }, + }); + }, + [sendRequest], + ); + + const getCompletions = useCallback( + ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "getCompletions", + params: { itemType, itemId, offset }, + }); + }, + [sendRequest], + ); + + const getQuickInfo = useCallback( + ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "getQuickInfo", + params: { itemType, itemId, offset }, + }); + }, + [sendRequest], + ); + + const getSignatureHelp = useCallback( + ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "getSignatureHelp", + params: { itemType, itemId, offset }, + }); + }, + [sendRequest], + ); + + return { setSDCPN, getCompletions, getQuickInfo, getSignatureHelp }; +} diff --git a/libs/@hashintel/petrinaut/src/feature-flags.ts b/libs/@hashintel/petrinaut/src/feature-flags.ts deleted file mode 100644 index 5d4bcdf431d..00000000000 --- a/libs/@hashintel/petrinaut/src/feature-flags.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const FEATURE_FLAGS = { - REORDER_TRANSITION_ARCS: false, -}; diff --git a/libs/@hashintel/petrinaut/src/hooks/use-monaco-global-typings.ts b/libs/@hashintel/petrinaut/src/hooks/use-monaco-global-typings.ts deleted file mode 100644 index 9dbfe496bde..00000000000 --- a/libs/@hashintel/petrinaut/src/hooks/use-monaco-global-typings.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { loader } from "@monaco-editor/react"; -import type * as Monaco from "monaco-editor"; -import { use, useEffect, useState } from "react"; - -import type { - Color, - DifferentialEquation, - Parameter, - Place, - Transition, -} from "../core/types/sdcpn"; -import { EditorContext } from "../state/editor-context"; -import { SDCPNContext } from "../state/sdcpn-context"; - -interface ReactTypeDefinitions { - react: string; - reactJsxRuntime: string; - reactDom: string; -} - -/** - * Fetch React type definitions from unpkg CDN - */ -async function fetchReactTypes(): Promise { - const [react, reactJsxRuntime, reactDom] = await Promise.all([ - fetch("https://unpkg.com/@types/react@18/index.d.ts").then((response) => - response.text(), - ), - fetch("https://unpkg.com/@types/react@18/jsx-runtime.d.ts").then( - (response) => response.text(), - ), - fetch("https://unpkg.com/@types/react-dom@18/index.d.ts").then((response) => - response.text(), - ), - ]); - - return { react, reactJsxRuntime, reactDom }; -} - -/** - * Convert a transition to a TypeScript definition string - */ -function transitionToTsDefinitionString( - transition: Transition, - placeIdToNameMap: Map, - placeIdToTypeMap: Map, -): string { - const input = - transition.inputArcs.length === 0 - ? "never" - : ` - {${transition.inputArcs - // Only include arcs whose places have defined types - .filter((arc) => placeIdToTypeMap.get(arc.placeId)) - .map((arc) => { - const placeTokenType = `SDCPNPlaces['${arc.placeId}']['type']['object']`; - return `"${placeIdToNameMap.get(arc.placeId)!}": [${Array.from({ length: arc.weight }).fill(placeTokenType).join(", ")}]`; - }) - .join(", ")} - }`; - - const output = - transition.outputArcs.length === 0 - ? "never" - : `{ - ${transition.outputArcs - // Only include arcs whose places have defined types - .filter((arc) => placeIdToTypeMap.get(arc.placeId)) - .map((arc) => { - const placeTokenType = `SDCPNPlaces['${arc.placeId}']['type']['object']`; - return `"${placeIdToNameMap.get(arc.placeId)!}": [${Array.from({ length: arc.weight }).fill(placeTokenType).join(", ")}]`; - }) - .join(", ")} - }`; - - return `{ - name: "${transition.name}"; - lambdaType: "${transition.lambdaType}"; - lambdaInputFn: (input: ${input}, parameters: SDCPNParametersValues) => ${transition.lambdaType === "predicate" ? "boolean" : "number"}; - transitionKernelFn: (input: ${input}, parameters: SDCPNParametersValues) => ${output}; - }`; -} - -/** - * Generate TypeScript type definitions for SDCPN types - */ -function generateTypesDefinition(types: Color[]): string { - return `declare interface SDCPNTypes { - ${types - .map( - (type) => `"${type.id}": { - tuple: [${type.elements.map((el) => `${el.name}: ${el.type === "boolean" ? "boolean" : "number"}`).join(", ")}]; - object: { - ${type.elements - .map( - (el) => - `${el.name}: ${el.type === "boolean" ? "boolean" : "number"};`, - ) - .join("\n")} - }; - dynamicsFn: (input: SDCPNTypes["${type.id}"]["object"][], parameters: SDCPNParametersValues) => SDCPNTypes["${type.id}"]["object"][]; - }`, - ) - .join("\n")} - }`; -} - -/** - * Generate TypeScript type definitions for SDCPN places - */ -function generatePlacesDefinition(places: Place[]): string { - return `declare interface SDCPNPlaces { - ${places - .map( - (place) => `"${place.id}": { - name: ${JSON.stringify(place.name)}; - type: ${place.colorId ? `SDCPNTypes["${place.colorId}"]` : "null"}; - dynamicsEnabled: ${place.dynamicsEnabled ? "true" : "false"}; - };`, - ) - .join("\n")}}`; -} - -/** - * Generate TypeScript type definitions for SDCPN transitions - */ -function generateTransitionsDefinition( - transitions: Transition[], - placeIdToNameMap: Map, - placeIdToTypeMap: Map, -): string { - return `declare interface SDCPNTransitions { - ${transitions - .map( - (transition) => - `"${transition.id}": ${transitionToTsDefinitionString(transition, placeIdToNameMap, placeIdToTypeMap)};`, - ) - .join("\n")} - }`; -} - -/** - * Generate TypeScript type definitions for SDCPN differential equations - */ -function generateDifferentialEquationsDefinition( - differentialEquations: DifferentialEquation[], -): string { - return `declare interface SDCPNDifferentialEquations { - ${differentialEquations - .map( - (diffEq) => `"${diffEq.id}": { - name: ${JSON.stringify(diffEq.name)}; - typeId: "${diffEq.colorId}"; - type: SDCPNTypes["${diffEq.colorId}"]; - };`, - ) - .join("\n")} - }`; -} - -function generateParametersDefinition(parameters: Parameter[]): string { - return `{${parameters - .map( - (param) => - `"${param.variableName}": ${param.type === "boolean" ? "boolean" : "number"}`, - ) - .join(", ")}}`; -} - -/** - * Generate complete SDCPN type definitions - */ -function generateSDCPNTypings( - types: Color[], - places: Place[], - transitions: Transition[], - differentialEquations: DifferentialEquation[], - parameters: Parameter[], - currentlySelectedItemId?: string, -): string { - // Generate a map from place IDs to names for easier reference - const placeIdToNameMap = new Map( - places.map((place) => [place.id, place.name]), - ); - const typeIdToTypeMap = new Map(types.map((type) => [type.id, type])); - const placeIdToTypeMap = new Map( - places.map((place) => [ - place.id, - place.colorId ? typeIdToTypeMap.get(place.colorId) : undefined, - ]), - ); - - const parametersDefinition = generateParametersDefinition(parameters); - const globalTypesDefinition = generateTypesDefinition(types); - const placesDefinition = generatePlacesDefinition(places); - const transitionsDefinition = generateTransitionsDefinition( - transitions, - placeIdToNameMap, - placeIdToTypeMap, - ); - const differentialEquationsDefinition = - generateDifferentialEquationsDefinition(differentialEquations); - - return ` -declare type SDCPNParametersValues = ${parametersDefinition}; - -${globalTypesDefinition} - -${placesDefinition} - -${transitionsDefinition} - -${differentialEquationsDefinition} - -// Define Lambda and TransitionKernel functions - -declare type SDCPNTransitionID = keyof SDCPNTransitions; - -${ - currentlySelectedItemId - ? `type __SelectedTransitionID = "${currentlySelectedItemId}"` - : `type __SelectedTransitionID = SDCPNTransitionID` -}; - -declare function Lambda(fn: SDCPNTransitions[TransitionId]['lambdaInputFn']): void; - -declare function TransitionKernel(fn: SDCPNTransitions[TransitionId]['transitionKernelFn']): void; - - -// Define Dynamics function - -type SDCPNDiffEqID = keyof SDCPNDifferentialEquations; - -${ - currentlySelectedItemId - ? `type __SelectedDiffEqID = "${currentlySelectedItemId}"` - : `type __SelectedDiffEqID = SDCPNDiffEqID` -}; - -declare function Dynamics(fn: SDCPNDifferentialEquations[DiffEqId]['type']['dynamicsFn']): void; - - -// Define Visualizer function - -type SDCPNPlaceID = keyof SDCPNPlaces; - -${ - currentlySelectedItemId - ? `type __SelectedPlaceID = "${currentlySelectedItemId}"` - : `type __SelectedPlaceID = SDCPNPlaceID` -}; - -declare function Visualization(fn: (props: { tokens: SDCPNPlaces[PlaceId]['type']['object'][], parameters: SDCPNParametersValues }) => React.JSX.Element): void; - - `.trim(); -} - -/** - * Configure Monaco TypeScript compiler options - */ -function configureMonacoCompilerOptions(monaco: typeof Monaco): void { - const ts = monaco.typescript; - - ts.typescriptDefaults.setCompilerOptions({ - target: ts.ScriptTarget.ES2020, - allowNonTsExtensions: true, - moduleResolution: ts.ModuleResolutionKind.NodeJs, - module: ts.ModuleKind.ESNext, - noEmit: true, - esModuleInterop: true, - jsx: ts.JsxEmit.ReactJSX, - allowJs: false, - checkJs: false, - typeRoots: ["node_modules/@types"], - }); - - ts.javascriptDefaults.setCompilerOptions({ - target: ts.ScriptTarget.ES2020, - allowNonTsExtensions: true, - noEmit: true, - allowJs: true, - checkJs: false, - }); - - ts.typescriptDefaults.setDiagnosticsOptions({ - noSemanticValidation: false, - noSyntaxValidation: false, - }); -} - -/** - * Global hook to update Monaco's TypeScript context with SDCPN-derived typings. - * Should be called once at the app level to avoid race conditions. - */ -export function useMonacoGlobalTypings() { - const { - petriNetDefinition: { - types, - transitions, - parameters, - places, - differentialEquations, - }, - } = use(SDCPNContext); - - const { selectedResourceId: currentlySelectedItemId } = use(EditorContext); - - const [reactTypes, setReactTypes] = useState( - null, - ); - - // Configure Monaco and load React types once at startup - useEffect(() => { - void loader.init().then((monaco: typeof Monaco) => { - // Configure compiler options - configureMonacoCompilerOptions(monaco); - - // Fetch and set React types once - void fetchReactTypes().then((rTypes) => { - setReactTypes(rTypes); - - // Set React types as base extra libs - this is done only once - monaco.typescript.typescriptDefaults.setExtraLibs([ - { - content: rTypes.react, - filePath: "inmemory://sdcpn/node_modules/@types/react/index.d.ts", - }, - { - content: rTypes.reactJsxRuntime, - filePath: - "inmemory://sdcpn/node_modules/@types/react/jsx-runtime.d.ts", - }, - { - content: rTypes.reactDom, - filePath: - "inmemory://sdcpn/node_modules/@types/react-dom/index.d.ts", - }, - ]); - }); - }); - }, []); // Empty deps - run only once at startup - - // Update SDCPN typings whenever the model changes - useEffect(() => { - if (!reactTypes) { - return; // Wait for React types to load first - } - - void loader.init().then((monaco: typeof Monaco) => { - const sdcpnTypings = generateSDCPNTypings( - types, - places, - transitions, - differentialEquations, - parameters, - currentlySelectedItemId ?? undefined, - ); - - // Create or update SDCPN typings model - const sdcpnTypingsUri = monaco.Uri.parse( - "inmemory://sdcpn/sdcpn-globals.d.ts", - ); - const existingModel = monaco.editor.getModel(sdcpnTypingsUri); - - if (existingModel) { - existingModel.setValue(sdcpnTypings); - } else { - monaco.editor.createModel(sdcpnTypings, "typescript", sdcpnTypingsUri); - } - }); - }, [ - reactTypes, - types, - parameters, - places, - transitions, - differentialEquations, - currentlySelectedItemId, - ]); -} diff --git a/libs/@hashintel/petrinaut/src/components/code-editor.tsx b/libs/@hashintel/petrinaut/src/monaco/code-editor.tsx similarity index 63% rename from libs/@hashintel/petrinaut/src/components/code-editor.tsx rename to libs/@hashintel/petrinaut/src/monaco/code-editor.tsx index 2c0a62d349b..8ccef730cb4 100644 --- a/libs/@hashintel/petrinaut/src/components/code-editor.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/code-editor.tsx @@ -1,10 +1,10 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import type { EditorProps, Monaco } from "@monaco-editor/react"; -import MonacoEditor from "@monaco-editor/react"; import type { editor } from "monaco-editor"; -import { useCallback, useRef } from "react"; +import { Suspense, use, useCallback, useRef } from "react"; -import { Tooltip } from "./tooltip"; +import { Tooltip } from "../components/tooltip"; +import { MonacoContext } from "./context"; const containerStyle = cva({ base: { @@ -24,31 +24,34 @@ const containerStyle = cva({ }, }); +const loadingStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "2", + height: "full", + color: "fg.muted", + bg: "bg.subtle", + fontSize: "base", +}); + type CodeEditorProps = Omit & { tooltip?: string; }; -/** - * Code editor component that wraps Monaco Editor. - * - * @param tooltip - Optional tooltip to show when hovering over the editor. - * In read-only mode, the tooltip also appears when attempting to edit. - */ -export const CodeEditor: React.FC = ({ - tooltip, +const CodeEditorInner: React.FC = ({ options, - height, onMount, ...props }) => { - const isReadOnly = options?.readOnly === true; + const { Editor } = use(use(MonacoContext)); + const editorRef = useRef(null); const handleMount = useCallback( - (editorInstance: editor.IStandaloneCodeEditor, monaco: Monaco) => { + (editorInstance: editor.IStandaloneCodeEditor, monacoInstance: Monaco) => { editorRef.current = editorInstance; - // Call the original onMount if provided - onMount?.(editorInstance, monaco); + onMount?.(editorInstance, monacoInstance); }, [onMount], ); @@ -67,19 +70,35 @@ export const CodeEditor: React.FC = ({ ...options, }; + return ( + + ); +}; + +export const CodeEditor: React.FC = ({ + tooltip, + options, + height, + ...props +}) => { + const isReadOnly = options?.readOnly === true; + const editorElement = (
- + Loading editor...
} + > + + ); - // Regular tooltip for non-read-only mode (if tooltip is provided) if (tooltip) { return ( { + const { monaco } = use(use(MonacoContext)); + const { getCompletions } = use(CheckerContext); + + useEffect(() => { + const disposable = monaco.languages.registerCompletionItemProvider( + "typescript", + { + triggerCharacters: ["."], + + async provideCompletionItems(model, position) { + const parsed = parseEditorPath(model.uri.toString()); + if (!parsed) { + return { suggestions: [] }; + } + + const offset = model.getOffsetAt(position); + const result = await getCompletions( + parsed.itemType, + parsed.itemId, + offset, + ); + + const word = model.getWordUntilPosition(position); + const range: Monaco.IRange = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + return { + suggestions: result.items.map((item) => + toMonacoCompletion(item, range, monaco), + ), + }; + }, + }, + ); + + return () => disposable.dispose(); + }, [monaco, getCompletions]); + + return null; +}; + +/** Renders nothing visible — registers a Monaco CompletionItemProvider backed by the checker worker. */ +export const CompletionSync: React.FC = () => ( + + + +); diff --git a/libs/@hashintel/petrinaut/src/monaco/context.ts b/libs/@hashintel/petrinaut/src/monaco/context.ts new file mode 100644 index 00000000000..3075ef10660 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/context.ts @@ -0,0 +1,12 @@ +import type { EditorProps } from "@monaco-editor/react"; +import type * as Monaco from "monaco-editor"; +import { createContext } from "react"; + +export type MonacoContextValue = { + monaco: typeof Monaco; + Editor: React.FC; +}; + +export const MonacoContext = createContext>( + null as never, +); diff --git a/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx new file mode 100644 index 00000000000..9bd907d02d5 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx @@ -0,0 +1,108 @@ +import type * as Monaco from "monaco-editor"; +import { Suspense, use, useEffect, useRef } from "react"; + +import { CheckerContext } from "../checker/context"; +import type { CheckerDiagnostic } from "../checker/worker/protocol"; +import { MonacoContext } from "./context"; +import { getEditorPath } from "./editor-paths"; + +const OWNER = "checker"; + +/** Convert ts.DiagnosticCategory number to Monaco MarkerSeverity. */ +function toMarkerSeverity( + category: number, + monaco: typeof Monaco, +): Monaco.MarkerSeverity { + switch (category) { + case 0: + return monaco.MarkerSeverity.Warning; + case 1: + return monaco.MarkerSeverity.Error; + case 2: + return monaco.MarkerSeverity.Hint; + case 3: + return monaco.MarkerSeverity.Info; + default: + return monaco.MarkerSeverity.Error; + } +} + +/** Convert CheckerDiagnostic[] to IMarkerData[] using the model for offset→position. */ +function diagnosticsToMarkers( + model: Monaco.editor.ITextModel, + diagnostics: CheckerDiagnostic[], + monaco: typeof Monaco, +): Monaco.editor.IMarkerData[] { + return diagnostics.map((diag) => { + const start = model.getPositionAt(diag.start ?? 0); + const end = model.getPositionAt((diag.start ?? 0) + (diag.length ?? 0)); + return { + severity: toMarkerSeverity(diag.category, monaco), + message: diag.messageText, + startLineNumber: start.lineNumber, + startColumn: start.column, + endLineNumber: end.lineNumber, + endColumn: end.column, + code: String(diag.code), + }; + }); +} + +const DiagnosticsSyncInner = () => { + const { monaco } = use(use(MonacoContext)); + const { checkResult } = use(CheckerContext); + const prevPathsRef = useRef>(new Set()); + + useEffect(() => { + const currentPaths = new Set(); + + for (const item of checkResult.itemDiagnostics) { + const path = getEditorPath(item.itemType, item.itemId); + const uri = monaco.Uri.parse(path); + const model = monaco.editor.getModel(uri); + if (model) { + const markers = diagnosticsToMarkers(model, item.diagnostics, monaco); + monaco.editor.setModelMarkers(model, OWNER, markers); + } + currentPaths.add(path); + } + + // Clear markers from models that no longer have diagnostics + for (const path of prevPathsRef.current) { + if (!currentPaths.has(path)) { + const uri = monaco.Uri.parse(path); + const model = monaco.editor.getModel(uri); + if (model) { + monaco.editor.setModelMarkers(model, OWNER, []); + } + } + } + + prevPathsRef.current = currentPaths; + + // Handle models created after diagnostics arrived + const disposable = monaco.editor.onDidCreateModel((model) => { + const modelUri = model.uri.toString(); + const item = checkResult.itemDiagnostics.find( + (i) => + monaco.Uri.parse(getEditorPath(i.itemType, i.itemId)).toString() === + modelUri, + ); + if (item) { + const markers = diagnosticsToMarkers(model, item.diagnostics, monaco); + monaco.editor.setModelMarkers(model, OWNER, markers); + } + }); + + return () => disposable.dispose(); + }, [checkResult, monaco]); + + return null; +}; + +/** Renders nothing visible — syncs CheckerContext diagnostics to Monaco model markers. */ +export const DiagnosticsSync: React.FC = () => ( + + + +); diff --git a/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts b/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts new file mode 100644 index 00000000000..f6f167e5ef6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts @@ -0,0 +1,46 @@ +import type { CheckerItemDiagnostics } from "../checker/worker/protocol"; + +/** Generates the Monaco model path for a given SDCPN item. */ +export function getEditorPath( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, +): string { + switch (itemType) { + case "transition-lambda": + return `inmemory://sdcpn/transitions/${itemId}/lambda.ts`; + case "transition-kernel": + return `inmemory://sdcpn/transitions/${itemId}/kernel.ts`; + case "differential-equation": + return `inmemory://sdcpn/differential-equations/${itemId}.ts`; + } +} + +const TRANSITION_LAMBDA_RE = + /^inmemory:\/\/sdcpn\/transitions\/([^/]+)\/lambda\.ts$/; +const TRANSITION_KERNEL_RE = + /^inmemory:\/\/sdcpn\/transitions\/([^/]+)\/kernel\.ts$/; +const DIFFERENTIAL_EQUATION_RE = + /^inmemory:\/\/sdcpn\/differential-equations\/([^/]+)\.ts$/; + +/** Extract `(itemType, itemId)` from a Monaco model URI string. */ +export function parseEditorPath(uri: string): { + itemType: CheckerItemDiagnostics["itemType"]; + itemId: string; +} | null { + let match = TRANSITION_LAMBDA_RE.exec(uri); + if (match) { + return { itemType: "transition-lambda", itemId: match[1]! }; + } + + match = TRANSITION_KERNEL_RE.exec(uri); + if (match) { + return { itemType: "transition-kernel", itemId: match[1]! }; + } + + match = DIFFERENTIAL_EQUATION_RE.exec(uri); + if (match) { + return { itemType: "differential-equation", itemId: match[1]! }; + } + + return null; +} diff --git a/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx new file mode 100644 index 00000000000..06b3bd14d87 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx @@ -0,0 +1,58 @@ +import type * as Monaco from "monaco-editor"; +import { Suspense, use, useEffect } from "react"; + +import { CheckerContext } from "../checker/context"; +import { MonacoContext } from "./context"; +import { parseEditorPath } from "./editor-paths"; + +const HoverSyncInner = () => { + const { monaco } = use(use(MonacoContext)); + const { getQuickInfo } = use(CheckerContext); + + useEffect(() => { + const disposable = monaco.languages.registerHoverProvider("typescript", { + async provideHover(model, position) { + const parsed = parseEditorPath(model.uri.toString()); + if (!parsed) { + return null; + } + + const offset = model.getOffsetAt(position); + const info = await getQuickInfo(parsed.itemType, parsed.itemId, offset); + + if (!info) { + return null; + } + + const startPos = model.getPositionAt(info.start); + const endPos = model.getPositionAt(info.start + info.length); + const range: Monaco.IRange = { + startLineNumber: startPos.lineNumber, + startColumn: startPos.column, + endLineNumber: endPos.lineNumber, + endColumn: endPos.column, + }; + + const contents: Monaco.IMarkdownString[] = [ + { value: `\`\`\`typescript\n${info.displayParts}\n\`\`\`` }, + ]; + if (info.documentation) { + contents.push({ value: info.documentation }); + } + + return { range, contents }; + }, + }); + + return () => disposable.dispose(); + }, [monaco, getQuickInfo]); + + return null; +}; + +/** Renders nothing visible — registers a Monaco HoverProvider backed by the checker worker. */ +export const HoverSync: React.FC = () => ( + + + +); diff --git a/libs/@hashintel/petrinaut/src/monaco/provider.tsx b/libs/@hashintel/petrinaut/src/monaco/provider.tsx new file mode 100644 index 00000000000..6488438e526 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/provider.tsx @@ -0,0 +1,81 @@ +import type * as Monaco from "monaco-editor"; + +import { CompletionSync } from "./completion-sync"; +import type { MonacoContextValue } from "./context"; +import { MonacoContext } from "./context"; +import { DiagnosticsSync } from "./diagnostics-sync"; +import { HoverSync } from "./hover-sync"; +import { SignatureHelpSync } from "./signature-help-sync"; + +interface LanguageDefaults { + setModeConfiguration(config: Record): void; +} + +interface TypeScriptNamespace { + typescriptDefaults: LanguageDefaults; + javascriptDefaults: LanguageDefaults; +} + +/** + * Disable all built-in TypeScript language worker features. + * Syntax highlighting (Monarch tokenizer) is retained since it runs client-side. + */ +function disableBuiltInTypeScriptFeatures(monaco: typeof Monaco) { + // The `typescript` namespace is marked deprecated in newer type definitions + // but the runtime API still exists and is the only way to control the TS worker. + const ts = monaco.languages.typescript as unknown as TypeScriptNamespace; + + const modeConfiguration: Record = { + completionItems: false, + hovers: false, + documentSymbols: false, + definitions: false, + references: false, + documentHighlights: false, + rename: false, + diagnostics: false, + documentRangeFormattingEdits: false, + signatureHelp: false, + onTypeFormattingEdits: false, + codeActions: false, + inlayHints: false, + }; + + ts.typescriptDefaults.setModeConfiguration(modeConfiguration); + ts.javascriptDefaults.setModeConfiguration(modeConfiguration); +} + +async function initMonaco(): Promise { + // Disable all workers — no worker files will be shipped or loaded. + (globalThis as Record).MonacoEnvironment = { + getWorker: undefined, + }; + + const [monaco, monacoReact] = await Promise.all([ + import("monaco-editor") as Promise, + import("@monaco-editor/react"), + ]); + + // Use local Monaco instance — no CDN fetch. + monacoReact.loader.config({ monaco }); + + disableBuiltInTypeScriptFeatures(monaco); + return { monaco, Editor: monacoReact.default }; +} + +export const MonacoProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + // Stable promise reference — created once, never changes. + const monacoPromise = initMonaco(); + + return ( + + + + + + {children} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx new file mode 100644 index 00000000000..54f71f59f8b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx @@ -0,0 +1,73 @@ +import type * as Monaco from "monaco-editor"; +import { Suspense, use, useEffect } from "react"; + +import { CheckerContext } from "../checker/context"; +import type { CheckerSignatureHelpResult } from "../checker/worker/protocol"; +import { MonacoContext } from "./context"; +import { parseEditorPath } from "./editor-paths"; + +function toMonacoSignatureHelp( + result: NonNullable, +): Monaco.languages.SignatureHelp { + return { + activeSignature: result.activeSignature, + activeParameter: result.activeParameter, + signatures: result.signatures.map((sig) => ({ + label: sig.label, + documentation: sig.documentation || undefined, + parameters: sig.parameters.map((param) => ({ + label: param.label, + documentation: param.documentation || undefined, + })), + })), + }; +} + +const SignatureHelpSyncInner = () => { + const { monaco } = use(use(MonacoContext)); + const { getSignatureHelp } = use(CheckerContext); + + useEffect(() => { + const disposable = monaco.languages.registerSignatureHelpProvider( + "typescript", + { + signatureHelpTriggerCharacters: ["(", ","], + signatureHelpRetriggerCharacters: [","], + + async provideSignatureHelp(model, position) { + const parsed = parseEditorPath(model.uri.toString()); + if (!parsed) { + return null; + } + + const offset = model.getOffsetAt(position); + const result = await getSignatureHelp( + parsed.itemType, + parsed.itemId, + offset, + ); + + if (!result) { + return null; + } + + return { + value: toMonacoSignatureHelp(result), + dispose() {}, + }; + }, + }, + ); + + return () => disposable.dispose(); + }, [monaco, getSignatureHelp]); + + return null; +}; + +/** Renders nothing visible — registers a Monaco SignatureHelpProvider backed by the checker worker. */ +export const SignatureHelpSync: React.FC = () => ( + + + +); diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index c56d600da35..df237136b33 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -1,6 +1,7 @@ import "reactflow/dist/style.css"; import "./index.css"; +import { CheckerProvider } from "./checker/provider"; import type { Color, DifferentialEquation, @@ -11,11 +12,10 @@ import type { SDCPN, Transition, } from "./core/types/sdcpn"; -import { useMonacoGlobalTypings } from "./hooks/use-monaco-global-typings"; +import { MonacoProvider } from "./monaco/provider"; import { NotificationsProvider } from "./notifications/notifications-provider"; import { PlaybackProvider } from "./playback/provider"; import { SimulationProvider } from "./simulation/provider"; -import { CheckerProvider } from "./state/checker-provider"; import { EditorProvider } from "./state/editor-provider"; import { SDCPNProvider } from "./state/sdcpn-provider"; import { EditorView } from "./views/Editor/editor-view"; @@ -33,15 +33,6 @@ export type { Transition, }; -/** - * Internal component to initialize Monaco global typings. - * Must be inside SDCPNProvider to access the store. - */ -const MonacoSetup: React.FC = () => { - useMonacoGlobalTypings(); - return null; -}; - export type PetrinautProps = { /** * Nets other than this one which are available for selection, e.g. to switch to or to link from a transition. @@ -108,16 +99,17 @@ export const Petrinaut = ({ - - - - - - - - + + + + + + + + + diff --git a/libs/@hashintel/petrinaut/src/state/checker-context.ts b/libs/@hashintel/petrinaut/src/state/checker-context.ts deleted file mode 100644 index 1ebf40f6f67..00000000000 --- a/libs/@hashintel/petrinaut/src/state/checker-context.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { createContext } from "react"; - -import type { SDCPNCheckResult } from "../core/checker/checker"; - -export type CheckResult = SDCPNCheckResult; - -export interface CheckerContextValue { - /** The result of the last SDCPN check */ - checkResult: SDCPNCheckResult; - /** Total count of all diagnostics across all items */ - totalDiagnosticsCount: number; -} - -const DEFAULT_CONTEXT_VALUE: CheckerContextValue = { - checkResult: { - isValid: true, - itemDiagnostics: [], - }, - totalDiagnosticsCount: 0, -}; - -export const CheckerContext = createContext( - DEFAULT_CONTEXT_VALUE, -); diff --git a/libs/@hashintel/petrinaut/src/state/checker-provider.tsx b/libs/@hashintel/petrinaut/src/state/checker-provider.tsx deleted file mode 100644 index e4ba7b2c337..00000000000 --- a/libs/@hashintel/petrinaut/src/state/checker-provider.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { use } from "react"; - -import { checkSDCPN } from "../core/checker/checker"; -import { CheckerContext } from "./checker-context"; -import { SDCPNContext } from "./sdcpn-context"; - -export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - const { petriNetDefinition } = use(SDCPNContext); - - const checkResult = checkSDCPN(petriNetDefinition); - - const totalDiagnosticsCount = checkResult.itemDiagnostics.reduce( - (sum, item) => sum + item.diagnostics.length, - 0, - ); - - return ( - - {children} - - ); -}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx index 0fca9f54bce..e82175b8e04 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx @@ -3,7 +3,7 @@ import { refractive } from "@hashintel/refractive"; import { use, useCallback, useEffect } from "react"; import { FaChevronDown, FaChevronUp } from "react-icons/fa6"; -import { CheckerContext } from "../../../../state/checker-context"; +import { CheckerContext } from "../../../../checker/context"; import { EditorContext, type EditorState, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx index 145fb52b49e..648a2b0ffa4 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx @@ -2,7 +2,7 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { FaCheck, FaXmark } from "react-icons/fa6"; -import { CheckerContext } from "../../../../state/checker-context"; +import { CheckerContext } from "../../../../checker/context"; import { ToolbarButton } from "./toolbar-button"; const iconContainerStyle = cva({ diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx index d2c5db0bcfb..6556c3f2712 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx @@ -5,7 +5,6 @@ import { useState } from "react"; import { TbDotsVertical, TbSparkles } from "react-icons/tb"; import { Button } from "../../../../components/button"; -import { CodeEditor } from "../../../../components/code-editor"; import { Input } from "../../../../components/input"; import { Menu } from "../../../../components/menu"; import { Tooltip } from "../../../../components/tooltip"; @@ -19,6 +18,8 @@ import type { DifferentialEquation, Place, } from "../../../../core/types/sdcpn"; +import { CodeEditor } from "../../../../monaco/code-editor"; +import { getEditorPath } from "../../../../monaco/editor-paths"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; const containerStyle = css({ @@ -476,6 +477,7 @@ export const DifferentialEquationProperties: React.FC< )} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx index c80ae9f7119..7758dde9c67 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx @@ -1,6 +1,5 @@ /* eslint-disable id-length */ import { css } from "@hashintel/ds-helpers/css"; -import MonacoEditor from "@monaco-editor/react"; import { use, useEffect, useMemo, useRef, useState } from "react"; import { TbArrowRight, @@ -28,6 +27,7 @@ import type { DifferentialEquation, Place, } from "../../../../core/types/sdcpn"; +import { CodeEditor } from "../../../../monaco/code-editor"; import { PlaybackContext } from "../../../../playback/context"; import { EditorContext } from "../../../../state/editor-context"; import { SDCPNContext } from "../../../../state/sdcpn-context"; @@ -142,12 +142,6 @@ const codeHeaderLabelStyle = css({ fontSize: "[12px]", }); -const editorBorderStyle = css({ - border: "[1px solid rgba(0, 0, 0, 0.1)]", - borderRadius: "[4px]", - overflow: "hidden", -}); - const aiMenuItemStyle = css({ display: "flex", alignItems: "center", @@ -549,33 +543,17 @@ export const PlaceProperties: React.FC = ({ ]} /> -
- { - updatePlace(place.id, (existingPlace) => { - existingPlace.visualizerCode = value ?? ""; - }); - }} - theme="vs-light" - options={{ - minimap: { enabled: false }, - scrollBeyondLastLine: false, - fontSize: 12, - lineNumbers: "off", - folding: true, - glyphMargin: false, - lineDecorationsWidth: 0, - lineNumbersMinChars: 3, - padding: { top: 8, bottom: 8 }, - fixedOverflowWidgets: true, - }} - /> -
+ { + updatePlace(place.id, (existingPlace) => { + existingPlace.visualizerCode = value ?? ""; + }); + }} + /> )} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/sortable-arc-item.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/sortable-arc-item.tsx index d68cb37ac3c..bbfa37c91ce 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/sortable-arc-item.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/sortable-arc-item.tsx @@ -1,12 +1,8 @@ -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { css, cva } from "@hashintel/ds-helpers/css"; -import { MdDragIndicator } from "react-icons/md"; +import { css } from "@hashintel/ds-helpers/css"; import { TbTrash } from "react-icons/tb"; import { IconButton } from "../../../../components/icon-button"; import { NumberInput } from "../../../../components/number-input"; -import { FEATURE_FLAGS } from "../../../../feature-flags"; const containerStyle = css({ display: "flex", @@ -17,28 +13,6 @@ const containerStyle = css({ borderBottom: "[1px solid rgba(0, 0, 0, 0.06)]", }); -const dragHandleStyle = cva({ - base: { - display: "flex", - alignItems: "center", - flexShrink: 0, - }, - variants: { - isDisabled: { - true: { - cursor: "default", - color: "[#ccc]", - pointerEvents: "none", - }, - false: { - cursor: "grab", - color: "[#999]", - pointerEvents: "auto", - }, - }, - }, -}); - const placeNameStyle = css({ flex: "[1]", fontSize: "[14px]", @@ -68,22 +42,16 @@ const weightInputStyle = css({ padding: "[4px 8px]", }); -/** - * SortableArcItem - A draggable arc item that displays place name and weight - */ -interface SortableArcItemProps { - id: string; +interface ArcItemProps { placeName: string; weight: number; disabled?: boolean; - /** Tooltip to show when disabled (e.g., for read-only mode) */ tooltip?: string; onWeightChange: (weight: number) => void; onDelete?: () => void; } -export const SortableArcItem: React.FC = ({ - id, +export const ArcItem: React.FC = ({ placeName, weight, disabled = false, @@ -91,32 +59,8 @@ export const SortableArcItem: React.FC = ({ onWeightChange, onDelete, }) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id, disabled }); - - const transformStyle = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - return ( -
- {FEATURE_FLAGS.REORDER_TRANSITION_ARCS && ( -
- -
- )} +
{placeName}
weight diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx index 22201b754ee..b803b51c5fd 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx @@ -1,25 +1,9 @@ /* eslint-disable id-length */ /* eslint-disable curly */ -import type { DragEndEvent } from "@dnd-kit/core"; -import { - closestCenter, - DndContext, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { TbDotsVertical, TbSparkles, TbTrash } from "react-icons/tb"; -import { CodeEditor } from "../../../../components/code-editor"; import { IconButton } from "../../../../components/icon-button"; import { Input } from "../../../../components/input"; import { Menu } from "../../../../components/menu"; @@ -31,10 +15,12 @@ import { generateDefaultTransitionKernelCode, } from "../../../../core/default-codes"; import type { Color, Place, Transition } from "../../../../core/types/sdcpn"; +import { CodeEditor } from "../../../../monaco/code-editor"; +import { getEditorPath } from "../../../../monaco/editor-paths"; import { EditorContext } from "../../../../state/editor-context"; import { SDCPNContext } from "../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; -import { SortableArcItem } from "./sortable-arc-item"; +import { ArcItem } from "./sortable-arc-item"; const containerStyle = css({ display: "flex", @@ -182,55 +168,6 @@ export const TransitionProperties: React.FC = ({ const isReadOnly = useIsReadOnly(); const { globalMode } = use(EditorContext); - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - const handleInputArcDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - const oldIndex = transition.inputArcs.findIndex( - (arc) => arc.placeId === active.id, - ); - const newIndex = transition.inputArcs.findIndex( - (arc) => arc.placeId === over.id, - ); - - updateTransition(transition.id, (existingTransition) => { - existingTransition.inputArcs = arrayMove( - existingTransition.inputArcs, - oldIndex, - newIndex, - ); - }); - } - }; - - const handleOutputArcDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - const oldIndex = transition.outputArcs.findIndex( - (arc) => arc.placeId === active.id, - ); - const newIndex = transition.outputArcs.findIndex( - (arc) => arc.placeId === over.id, - ); - - updateTransition(transition.id, (existingTransition) => { - existingTransition.outputArcs = arrayMove( - existingTransition.outputArcs, - oldIndex, - newIndex, - ); - }); - } - }; - const handleDeleteInputArc = (placeId: string) => { updateTransition(transition.id, (existingTransition) => { const index = existingTransition.inputArcs.findIndex( @@ -308,43 +245,29 @@ export const TransitionProperties: React.FC = ({
) : (
- - arc.placeId)} - strategy={verticalListSortingStrategy} - > - {transition.inputArcs.map((arc) => { - const place = places.find( - (placeItem) => placeItem.id === arc.placeId, - ); - return ( - { - onArcWeightUpdate( - transition.id, - "input", - arc.placeId, - weight, - ); - }} - onDelete={() => handleDeleteInputArc(arc.placeId)} - /> - ); - })} - - + {transition.inputArcs.map((arc) => { + const place = places.find( + (placeItem) => placeItem.id === arc.placeId, + ); + return ( + { + onArcWeightUpdate( + transition.id, + "input", + arc.placeId, + weight, + ); + }} + onDelete={() => handleDeleteInputArc(arc.placeId)} + /> + ); + })}
)}
@@ -357,43 +280,29 @@ export const TransitionProperties: React.FC = ({
) : (
- - arc.placeId)} - strategy={verticalListSortingStrategy} - > - {transition.outputArcs.map((arc) => { - const place = places.find( - (placeItem) => placeItem.id === arc.placeId, - ); - return ( - { - onArcWeightUpdate( - transition.id, - "output", - arc.placeId, - weight, - ); - }} - onDelete={() => handleDeleteOutputArc(arc.placeId)} - /> - ); - })} - - + {transition.outputArcs.map((arc) => { + const place = places.find( + (placeItem) => placeItem.id === arc.placeId, + ); + return ( + { + onArcWeightUpdate( + transition.id, + "output", + arc.placeId, + weight, + ); + }} + onDelete={() => handleDeleteOutputArc(arc.placeId)} + /> + ); + })}
)} @@ -480,12 +389,12 @@ export const TransitionProperties: React.FC = ({ )} `${a.placeId}:${a.weight}`) .join("-")}`} language="typescript" value={transition.lambdaCode || ""} - path={`inmemory://sdcpn/transitions/${transition.id}/lambda.ts`} height={340} onChange={(value) => { updateTransition(transition.id, (existingTransition) => { @@ -583,14 +492,9 @@ export const TransitionProperties: React.FC = ({ )} `${a.placeId}:${a.weight}`) - .join("-")}-${transition.outputArcs - .map((a) => `${a.placeId}:${a.weight}`) - .join("-")}`} + path={getEditorPath("transition-kernel", transition.id)} language="typescript" value={transition.transitionKernelCode || ""} - path={`inmemory://sdcpn/transitions/${transition.id}/transition-kernel.ts`} height={400} onChange={(value) => { updateTransition(transition.id, (existingTransition) => { diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx index b966ef21413..0eac7e15e5c 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx @@ -1,10 +1,10 @@ import { css } from "@hashintel/ds-helpers/css"; import { use, useCallback, useMemo, useState } from "react"; import { FaChevronDown, FaChevronRight } from "react-icons/fa6"; -import ts from "typescript"; +import { CheckerContext } from "../../../checker/context"; +import type { CheckerDiagnostic } from "../../../checker/worker/protocol"; import type { SubView } from "../../../components/sub-view/types"; -import { CheckerContext } from "../../../state/checker-context"; import { EditorContext } from "../../../state/editor-context"; import { SDCPNContext } from "../../../state/sdcpn-context"; @@ -94,22 +94,6 @@ const positionStyle = css({ marginLeft: "[8px]", }); -// --- Helpers --- - -/** - * Formats a TypeScript diagnostic message to a readable string - */ -function formatDiagnosticMessage( - messageText: string | ts.DiagnosticMessageChain, -): string { - if (typeof messageText === "string") { - return messageText; - } - return ts.flattenDiagnosticMessageText(messageText, "\n"); -} - -// --- Types --- - type EntityType = "transition" | "differential-equation"; interface GroupedDiagnostics { @@ -119,7 +103,7 @@ interface GroupedDiagnostics { errorCount: number; items: Array<{ subType: "lambda" | "kernel" | null; - diagnostics: ts.Diagnostic[]; + diagnostics: CheckerDiagnostic[]; }>; } @@ -269,7 +253,7 @@ const DiagnosticsContent: React.FC = () => { className={diagnosticButtonStyle} > - {formatDiagnosticMessage(diagnostic.messageText)} + {diagnostic.messageText} {diagnostic.start !== undefined && ( (pos: {diagnostic.start}) diff --git a/libs/@hashintel/petrinaut/vite.config.ts b/libs/@hashintel/petrinaut/vite.config.ts index 744ed2004b9..d5b64395b11 100644 --- a/libs/@hashintel/petrinaut/vite.config.ts +++ b/libs/@hashintel/petrinaut/vite.config.ts @@ -21,7 +21,6 @@ export default defineConfig({ "react", "react-dom", "reactflow", - "typescript", "monaco-editor", "@babel/standalone", ], @@ -48,7 +47,17 @@ export default defineConfig({ // This causes crashes in Web Workers, since `window` is not defined there. // To prevent this, we do this resolution on our side. "typeof window": '"undefined"', + // TypeScript's internals reference process, process.versions.pnp, etc. + "typeof process": "'undefined'", + "typeof process.versions.pnp": "'undefined'", }), + // Separate replacePlugin for call-expression replacements: + // 1. Empty end delimiter because \b can't match after `)` (non-word → non-word). + // 2. Negative lookbehind skips the function definition (`function isNodeLikeSystem`). + replacePlugin( + { "isNodeLikeSystem()": "false" }, + { delimiters: ["(?