From a2a4696fdbd6440269d576aeba7b25b2ea40d9bf Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Tue, 12 Sep 2023 21:59:50 +0200 Subject: feat: connect model generator to UI --- subprojects/frontend/src/ModelWorkArea.tsx | 198 +++++++++++++++++++++ subprojects/frontend/src/WorkArea.tsx | 12 +- subprojects/frontend/src/editor/EditorStore.ts | 85 +++++++++ subprojects/frontend/src/editor/GenerateButton.tsx | 20 ++- .../frontend/src/editor/GeneratedModelStore.ts | 50 ++++++ subprojects/frontend/src/graph/GraphArea.tsx | 12 +- subprojects/frontend/src/graph/GraphPane.tsx | 10 +- subprojects/frontend/src/index.tsx | 102 +++++++++-- subprojects/frontend/src/table/RelationGrid.tsx | 109 ------------ subprojects/frontend/src/table/TableArea.tsx | 105 +++++++++-- subprojects/frontend/src/table/TablePane.tsx | 9 +- .../frontend/src/xtext/ModelGenerationService.ts | 46 +++++ subprojects/frontend/src/xtext/UpdateService.ts | 39 ++++ subprojects/frontend/src/xtext/XtextClient.ts | 21 ++- subprojects/frontend/src/xtext/xtextMessages.ts | 1 + .../frontend/src/xtext/xtextServiceResults.ts | 24 +++ 16 files changed, 679 insertions(+), 164 deletions(-) create mode 100644 subprojects/frontend/src/ModelWorkArea.tsx create mode 100644 subprojects/frontend/src/editor/GeneratedModelStore.ts delete mode 100644 subprojects/frontend/src/table/RelationGrid.tsx create mode 100644 subprojects/frontend/src/xtext/ModelGenerationService.ts (limited to 'subprojects/frontend') diff --git a/subprojects/frontend/src/ModelWorkArea.tsx b/subprojects/frontend/src/ModelWorkArea.tsx new file mode 100644 index 00000000..3aba31e3 --- /dev/null +++ b/subprojects/frontend/src/ModelWorkArea.tsx @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import CloseIcon from '@mui/icons-material/Close'; +import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied'; +import CircularProgress from '@mui/material/CircularProgress'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import { styled } from '@mui/material/styles'; +import { observer } from 'mobx-react-lite'; + +import DirectionalSplitPane from './DirectionalSplitPane'; +import Loading from './Loading'; +import { useRootStore } from './RootStoreProvider'; +import type GeneratedModelStore from './editor/GeneratedModelStore'; +import GraphPane from './graph/GraphPane'; +import type GraphStore from './graph/GraphStore'; +import TablePane from './table/TablePane'; +import type ThemeStore from './theme/ThemeStore'; + +const SplitGraphPane = observer(function SplitGraphPane({ + graph, + themeStore, +}: { + graph: GraphStore; + themeStore: ThemeStore; +}): JSX.Element { + return ( + } + secondary={} + primaryOnly={!themeStore.showTable} + secondaryOnly={!themeStore.showGraph} + /> + ); +}); + +const GenerationStatus = styled('div', { + name: 'ModelWorkArea-GenerationStatus', + shouldForwardProp: (prop) => prop !== 'error', +})<{ error: boolean }>(({ error, theme }) => ({ + color: error ? theme.palette.error.main : theme.palette.text.primary, + ...(error + ? { + fontWeight: theme.typography.fontWeightBold ?? 600, + } + : {}), +})); + +const GeneratedModelPane = observer(function GeneratedModelPane({ + generatedModel, + themeStore, +}: { + generatedModel: GeneratedModelStore; + themeStore: ThemeStore; +}): JSX.Element { + const { message, error, graph } = generatedModel; + + if (graph !== undefined) { + return ; + } + + return ( + + ({ + maxHeight: '6rem', + height: 'calc(100% - 8rem)', + marginBottom: theme.spacing(1), + padding: error ? 0 : theme.spacing(1), + color: theme.palette.text.secondary, + '.MuiCircularProgress-root, .MuiCircularProgress-svg, .MuiSvgIcon-root': + { + height: '100% !important', + width: '100% !important', + }, + })} + > + {error ? ( + + ) : ( + + )} + + {message} + + ); +}); + +function ModelWorkArea(): JSX.Element { + const { editorStore, themeStore } = useRootStore(); + + if (editorStore === undefined) { + return ; + } + + const { graph, generatedModels, selectedGeneratedModel } = editorStore; + + const generatedModelNames: string[] = []; + const generatedModelTabs: JSX.Element[] = []; + generatedModels.forEach((value, key) => { + generatedModelNames.push(key); + /* eslint-disable react/no-array-index-key -- Key is a string here, not the array index. */ + generatedModelTabs.push( + { + if (event.button === 1) { + editorStore.deleteGeneratedModel(key); + event.preventDefault(); + event.stopPropagation(); + } + }} + />, + ); + /* eslint-enable react/no-array-index-key */ + }); + const generatedModel = + selectedGeneratedModel === undefined + ? undefined + : generatedModels.get(selectedGeneratedModel); + const selectedIndex = + selectedGeneratedModel === undefined + ? 0 + : generatedModelNames.indexOf(selectedGeneratedModel) + 1; + + return ( + + ({ + display: generatedModelNames.length === 0 ? 'none' : 'flex', + alignItems: 'center', + borderBottom: `1px solid ${theme.palette.outer.border}`, + })} + > + { + if (value === 0) { + editorStore.selectGeneratedModel(undefined); + } else if (typeof value === 'number') { + editorStore.selectGeneratedModel(generatedModelNames[value - 1]); + } + }} + sx={{ flexGrow: 1 }} + > + + {generatedModelTabs} + + + editorStore.deleteGeneratedModel(selectedGeneratedModel) + } + sx={{ + display: selectedIndex === 0 ? 'none' : 'flex', + mx: 1, + }} + > + + + + {generatedModel === undefined ? ( + + ) : ( + + )} + + ); +} + +export default observer(ModelWorkArea); diff --git a/subprojects/frontend/src/WorkArea.tsx b/subprojects/frontend/src/WorkArea.tsx index adb29a50..a1fbf7dc 100644 --- a/subprojects/frontend/src/WorkArea.tsx +++ b/subprojects/frontend/src/WorkArea.tsx @@ -7,10 +7,9 @@ import { observer } from 'mobx-react-lite'; import DirectionalSplitPane from './DirectionalSplitPane'; +import ModelWorkArea from './ModelWorkArea'; import { useRootStore } from './RootStoreProvider'; import EditorPane from './editor/EditorPane'; -import GraphPane from './graph/GraphPane'; -import TablePane from './table/TablePane'; export default observer(function WorkArea(): JSX.Element { const { themeStore } = useRootStore(); @@ -18,14 +17,7 @@ export default observer(function WorkArea(): JSX.Element { return ( } - secondary={ - } - secondary={} - primaryOnly={!themeStore.showTable} - secondaryOnly={!themeStore.showGraph} - /> - } + secondary={} primaryOnly={!themeStore.showGraph && !themeStore.showTable} secondaryOnly={!themeStore.showCode} /> diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts index b5989ad1..f9a9a7da 100644 --- a/subprojects/frontend/src/editor/EditorStore.ts +++ b/subprojects/frontend/src/editor/EditorStore.ts @@ -32,6 +32,7 @@ import type XtextClient from '../xtext/XtextClient'; import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; import EditorErrors from './EditorErrors'; +import GeneratedModelStore from './GeneratedModelStore'; import LintPanelStore from './LintPanelStore'; import SearchPanelStore from './SearchPanelStore'; import createEditorState from './createEditorState'; @@ -69,6 +70,10 @@ export default class EditorStore { graph: GraphStore; + generatedModels = new Map(); + + selectedGeneratedModel: string | undefined; + constructor(initialValue: string, pwaStore: PWAStore) { this.id = nanoid(); this.state = createEditorState(initialValue, this); @@ -307,4 +312,84 @@ export default class EditorStore { this.delayedErrors.dispose(); this.disposed = true; } + + startModelGeneration(): void { + this.client + ?.startModelGeneration() + ?.catch((error) => log.error('Could not start model generation', error)); + } + + addGeneratedModel(uuid: string): void { + this.generatedModels.set(uuid, new GeneratedModelStore()); + this.selectGeneratedModel(uuid); + } + + cancelModelGeneration(): void { + this.client + ?.cancelModelGeneration() + ?.catch((error) => log.error('Could not start model generation', error)); + } + + selectGeneratedModel(uuid: string | undefined): void { + if (uuid === undefined) { + this.selectedGeneratedModel = uuid; + return; + } + if (this.generatedModels.has(uuid)) { + this.selectedGeneratedModel = uuid; + return; + } + this.selectedGeneratedModel = undefined; + } + + deleteGeneratedModel(uuid: string | undefined): void { + if (uuid === undefined) { + return; + } + if (this.selectedGeneratedModel === uuid) { + let previous: string | undefined; + let found: string | undefined; + this.generatedModels.forEach((_value, key) => { + if (key === uuid) { + found = previous; + } + previous = key; + }); + this.selectGeneratedModel(found); + } + const generatedModel = this.generatedModels.get(uuid); + if (generatedModel !== undefined && generatedModel.running) { + this.cancelModelGeneration(); + } + this.generatedModels.delete(uuid); + } + + modelGenerationCancelled(): void { + this.generatedModels.forEach((value) => + value.setError('Model generation cancelled'), + ); + } + + setGeneratedModelMessage(uuid: string, message: string): void { + this.generatedModels.get(uuid)?.setMessage(message); + } + + setGeneratedModelError(uuid: string, message: string): void { + this.generatedModels.get(uuid)?.setError(message); + } + + setGeneratedModelSemantics( + uuid: string, + semantics: SemanticsSuccessResult, + ): void { + this.generatedModels.get(uuid)?.setSemantics(semantics); + } + + get generating(): boolean { + let generating = false; + this.generatedModels.forEach((value) => { + generating = generating || value.running; + }); + return generating; + } } diff --git a/subprojects/frontend/src/editor/GenerateButton.tsx b/subprojects/frontend/src/editor/GenerateButton.tsx index 5bac0464..b8dcd531 100644 --- a/subprojects/frontend/src/editor/GenerateButton.tsx +++ b/subprojects/frontend/src/editor/GenerateButton.tsx @@ -5,6 +5,7 @@ */ import CancelIcon from '@mui/icons-material/Cancel'; +import CloseIcon from '@mui/icons-material/Close'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import { observer } from 'mobx-react-lite'; @@ -28,8 +29,10 @@ const GenerateButton = observer(function GenerateButton({ ); } - const { analyzing, errorCount, warningCount, semanticsError } = - editorStore.delayedErrors; + const { + delayedErrors: { analyzing, errorCount, warningCount, semanticsError }, + generating, + } = editorStore; if (analyzing) { return ( @@ -39,6 +42,18 @@ const GenerateButton = observer(function GenerateButton({ ); } + if (generating) { + return ( + editorStore.cancelModelGeneration()} + startIcon={} + > + Cancel + + ); + } + if (semanticsError !== undefined && editorStore.opened) { return ( 0 ? 'warning' : 'primary'} startIcon={} + onClick={() => editorStore.startModelGeneration()} > {summary === '' ? GENERATE_LABEL : `${GENERATE_LABEL} (${summary})`} diff --git a/subprojects/frontend/src/editor/GeneratedModelStore.ts b/subprojects/frontend/src/editor/GeneratedModelStore.ts new file mode 100644 index 00000000..d0181eed --- /dev/null +++ b/subprojects/frontend/src/editor/GeneratedModelStore.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { makeAutoObservable } from 'mobx'; + +import GraphStore from '../graph/GraphStore'; +import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; + +export default class GeneratedModelStore { + title: string; + + message = 'Waiting for server'; + + error = false; + + graph: GraphStore | undefined; + + constructor() { + const time = new Date().toLocaleTimeString(undefined, { hour12: false }); + this.title = `Generated at ${time}`; + makeAutoObservable(this); + } + + get running(): boolean { + return !this.error && this.graph === undefined; + } + + setMessage(message: string): void { + if (this.running) { + this.message = message; + } + } + + setError(message: string): void { + if (this.running) { + this.error = true; + this.message = message; + } + } + + setSemantics(semantics: SemanticsSuccessResult): void { + if (this.running) { + this.graph = new GraphStore(); + this.graph.setSemantics(semantics); + } + } +} diff --git a/subprojects/frontend/src/graph/GraphArea.tsx b/subprojects/frontend/src/graph/GraphArea.tsx index f8f40d22..d5801b9a 100644 --- a/subprojects/frontend/src/graph/GraphArea.tsx +++ b/subprojects/frontend/src/graph/GraphArea.tsx @@ -9,25 +9,17 @@ import { useTheme } from '@mui/material/styles'; import { observer } from 'mobx-react-lite'; import { useResizeDetector } from 'react-resize-detector'; -import Loading from '../Loading'; -import { useRootStore } from '../RootStoreProvider'; - import DotGraphVisualizer from './DotGraphVisualizer'; +import type GraphStore from './GraphStore'; import VisibilityPanel from './VisibilityPanel'; import ZoomCanvas from './ZoomCanvas'; -function GraphArea(): JSX.Element { - const { editorStore } = useRootStore(); +function GraphArea({ graph }: { graph: GraphStore }): JSX.Element { const { breakpoints } = useTheme(); const { ref, width, height } = useResizeDetector({ refreshMode: 'debounce', }); - if (editorStore === undefined) { - return ; - } - - const { graph } = editorStore; const breakpoint = breakpoints.values.sm; const dialog = width === undefined || diff --git a/subprojects/frontend/src/graph/GraphPane.tsx b/subprojects/frontend/src/graph/GraphPane.tsx index c2ef8927..67dbfcbd 100644 --- a/subprojects/frontend/src/graph/GraphPane.tsx +++ b/subprojects/frontend/src/graph/GraphPane.tsx @@ -9,9 +9,15 @@ import { Suspense, lazy } from 'react'; import Loading from '../Loading'; +import type GraphStore from './GraphStore'; + const GraphArea = lazy(() => import('./GraphArea')); -export default function GraphPane(): JSX.Element { +export default function GraphPane({ + graph, +}: { + graph: GraphStore; +}): JSX.Element { return ( }> - + ); diff --git a/subprojects/frontend/src/index.tsx b/subprojects/frontend/src/index.tsx index e8a22e82..4b251a23 100644 --- a/subprojects/frontend/src/index.tsx +++ b/subprojects/frontend/src/index.tsx @@ -16,35 +16,101 @@ import RootStore from './RootStore'; (window as unknown as { fixViteIssue: unknown }).fixViteIssue = styled; const initialValue = `% Metamodel -class Person { - contains Post[] posts opposite author - Person[] friend opposite friend + +abstract class CompositeElement { + contains Region[] regions +} + +class Region { + contains Vertex[] vertices opposite region +} + +abstract class Vertex { + container Region region opposite vertices + contains Transition[] outgoingTransition opposite source + Transition[] incomingTransition opposite target } -class Post { - container Person author opposite posts - Post replyTo +class Transition { + container Vertex source opposite outgoingTransition + Vertex target opposite incomingTransition } +abstract class Pseudostate extends Vertex. + +abstract class RegularState extends Vertex. + +class Entry extends Pseudostate. + +class Exit extends Pseudostate. + +class Choice extends Pseudostate. + +class FinalState extends RegularState. + +class State extends RegularState, CompositeElement. + +class Statechart extends CompositeElement. + % Constraints -error replyToNotFriend(Post x, Post y) <-> - replyTo(x, y), - author(x, xAuthor), - author(y, yAuthor), - xAuthor != yAuthor, - !friend(xAuthor, yAuthor). -error replyToCycle(Post x) <-> replyTo+(x, x). +%% Entry + +pred entryInRegion(Region r, Entry e) <-> + vertices(r, e). + +error noEntryInRegion(Region r) <-> + !entryInRegion(r, _). + +error multipleEntryInRegion(Region r) <-> + entryInRegion(r, e1), + entryInRegion(r, e2), + e1 != e2. + +error incomingToEntry(Transition t, Entry e) <-> + target(t, e). + +error noOutgoingTransitionFromEntry(Entry e) <-> + !source(_, e). + +error multipleTransitionFromEntry(Entry e, Transition t1, Transition t2) <-> + outgoingTransition(e, t1), + outgoingTransition(e, t2), + t1 != t2. + +%% Exit + +error outgoingFromExit(Transition t, Exit e) <-> + source(t, e). + +%% Final + +error outgoingFromFinal(Transition t, FinalState e) <-> + source(t, e). + +%% State vs Region + +pred stateInRegion(Region r, State s) <-> + vertices(r, s). + +error noStateInRegion(Region r) <-> + !stateInRegion(r, _). + +%% Choice + +error choiceHasNoOutgoing(Choice c) <-> + !source(_, c). + +error choiceHasNoIncoming(Choice c) <-> + !target(_, c). % Instance model -friend(a, b). -author(p1, a). -author(p2, b). -!author(Post::new, a). +Statechart(sct). % Scope -scope Post = 10..15, Person += 0. + +scope node = 20..30, Region = 2..*, Choice = 1..*, Statechart += 0. `; configure({ diff --git a/subprojects/frontend/src/table/RelationGrid.tsx b/subprojects/frontend/src/table/RelationGrid.tsx deleted file mode 100644 index 004982c9..00000000 --- a/subprojects/frontend/src/table/RelationGrid.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors - * - * SPDX-License-Identifier: EPL-2.0 - */ - -import Box from '@mui/material/Box'; -import { - DataGrid, - type GridRenderCellParams, - type GridColDef, -} from '@mui/x-data-grid'; -import { observer } from 'mobx-react-lite'; -import { useMemo } from 'react'; - -import type GraphStore from '../graph/GraphStore'; - -import TableToolbar from './TableToolbar'; -import ValueRenderer from './ValueRenderer'; - -interface Row { - nodes: string[]; - value: string; -} - -function RelationGrid({ graph }: { graph: GraphStore }): JSX.Element { - const { - selectedSymbol, - semantics: { nodes, partialInterpretation }, - } = graph; - const symbolName = selectedSymbol?.name; - const arity = selectedSymbol?.arity ?? 0; - - const columns = useMemo[]>(() => { - const defs: GridColDef[] = []; - for (let i = 0; i < arity; i += 1) { - defs.push({ - field: `n${i}`, - headerName: String(i + 1), - valueGetter: (row) => row.row.nodes[i] ?? '', - flex: 1, - }); - } - defs.push({ - field: 'value', - headerName: 'Value', - flex: 1, - renderCell: ({ value }: GridRenderCellParams) => ( - - ), - }); - return defs; - }, [arity]); - - const rows = useMemo(() => { - if (symbolName === undefined) { - return []; - } - const interpretation = partialInterpretation[symbolName] ?? []; - return interpretation.map((tuple) => { - const nodeNames: string[] = []; - for (let i = 0; i < arity; i += 1) { - const index = tuple[i]; - if (typeof index === 'number') { - const node = nodes[index]; - if (node !== undefined) { - nodeNames.push(node.name); - } - } - } - return { - nodes: nodeNames, - value: String(tuple[arity]), - }; - }); - }, [arity, nodes, partialInterpretation, symbolName]); - - return ( - ({ - '.MuiDataGrid-withBorderColor': { - borderColor: - theme.palette.mode === 'dark' - ? theme.palette.divider - : theme.palette.outer.border, - }, - })} - > - row.nodes.join(',')} - /> - - ); -} - -export default observer(RelationGrid); diff --git a/subprojects/frontend/src/table/TableArea.tsx b/subprojects/frontend/src/table/TableArea.tsx index cf37b96a..166b8adf 100644 --- a/subprojects/frontend/src/table/TableArea.tsx +++ b/subprojects/frontend/src/table/TableArea.tsx @@ -4,21 +4,106 @@ * SPDX-License-Identifier: EPL-2.0 */ +import Box from '@mui/material/Box'; +import { + DataGrid, + type GridRenderCellParams, + type GridColDef, +} from '@mui/x-data-grid'; import { observer } from 'mobx-react-lite'; +import { useMemo } from 'react'; -import Loading from '../Loading'; -import { useRootStore } from '../RootStoreProvider'; +import type GraphStore from '../graph/GraphStore'; -import RelationGrid from './RelationGrid'; +import TableToolbar from './TableToolbar'; +import ValueRenderer from './ValueRenderer'; -function TablePane(): JSX.Element { - const { editorStore } = useRootStore(); +interface Row { + nodes: string[]; + value: string; +} + +function TableArea({ graph }: { graph: GraphStore }): JSX.Element { + const { + selectedSymbol, + semantics: { nodes, partialInterpretation }, + } = graph; + const symbolName = selectedSymbol?.name; + const arity = selectedSymbol?.arity ?? 0; + + const columns = useMemo[]>(() => { + const defs: GridColDef[] = []; + for (let i = 0; i < arity; i += 1) { + defs.push({ + field: `n${i}`, + headerName: String(i + 1), + valueGetter: (row) => row.row.nodes[i] ?? '', + flex: 1, + }); + } + defs.push({ + field: 'value', + headerName: 'Value', + flex: 1, + renderCell: ({ value }: GridRenderCellParams) => ( + + ), + }); + return defs; + }, [arity]); - if (editorStore === undefined) { - return ; - } + const rows = useMemo(() => { + if (symbolName === undefined) { + return []; + } + const interpretation = partialInterpretation[symbolName] ?? []; + return interpretation.map((tuple) => { + const nodeNames: string[] = []; + for (let i = 0; i < arity; i += 1) { + const index = tuple[i]; + if (typeof index === 'number') { + const node = nodes[index]; + if (node !== undefined) { + nodeNames.push(node.name); + } + } + } + return { + nodes: nodeNames, + value: String(tuple[arity]), + }; + }); + }, [arity, nodes, partialInterpretation, symbolName]); - return ; + return ( + ({ + '.MuiDataGrid-withBorderColor': { + borderColor: + theme.palette.mode === 'dark' + ? theme.palette.divider + : theme.palette.outer.border, + }, + })} + > + row.nodes.join(',')} + /> + + ); } -export default observer(TablePane); +export default observer(TableArea); diff --git a/subprojects/frontend/src/table/TablePane.tsx b/subprojects/frontend/src/table/TablePane.tsx index 01442c3a..8b640217 100644 --- a/subprojects/frontend/src/table/TablePane.tsx +++ b/subprojects/frontend/src/table/TablePane.tsx @@ -8,14 +8,19 @@ import Stack from '@mui/material/Stack'; import { Suspense, lazy } from 'react'; import Loading from '../Loading'; +import type GraphStore from '../graph/GraphStore'; const TableArea = lazy(() => import('./TableArea')); -export default function TablePane(): JSX.Element { +export default function TablePane({ + graph, +}: { + graph: GraphStore; +}): JSX.Element { return ( }> - + ); diff --git a/subprojects/frontend/src/xtext/ModelGenerationService.ts b/subprojects/frontend/src/xtext/ModelGenerationService.ts new file mode 100644 index 00000000..1e9f837a --- /dev/null +++ b/subprojects/frontend/src/xtext/ModelGenerationService.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import type EditorStore from '../editor/EditorStore'; + +import type UpdateService from './UpdateService'; +import { ModelGenerationResult } from './xtextServiceResults'; + +export default class ModelGenerationService { + constructor( + private readonly store: EditorStore, + private readonly updateService: UpdateService, + ) {} + + onPush(push: unknown): void { + const result = ModelGenerationResult.parse(push); + if ('status' in result) { + this.store.setGeneratedModelMessage(result.uuid, result.status); + } else if ('error' in result) { + this.store.setGeneratedModelError(result.uuid, result.error); + } else { + this.store.setGeneratedModelSemantics(result.uuid, result); + } + } + + onDisconnect(): void { + this.store.modelGenerationCancelled(); + } + + async start(): Promise { + const result = await this.updateService.startModelGeneration(); + if (!result.cancelled) { + this.store.addGeneratedModel(result.data.uuid); + } + } + + async cancel(): Promise { + const result = await this.updateService.cancelModelGeneration(); + if (!result.cancelled) { + this.store.modelGenerationCancelled(); + } + } +} diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts index 1ac722e1..d1246d5e 100644 --- a/subprojects/frontend/src/xtext/UpdateService.ts +++ b/subprojects/frontend/src/xtext/UpdateService.ts @@ -22,6 +22,7 @@ import { FormattingResult, isConflictResult, OccurrencesResult, + ModelGenerationStartedResult, } from './xtextServiceResults'; const UPDATE_TIMEOUT_MS = 500; @@ -341,4 +342,42 @@ export default class UpdateService { } return { cancelled: false, data: parsedOccurrencesResult }; } + + async startModelGeneration(): Promise< + CancellableResult + > { + try { + await this.updateOrThrow(); + } catch (error) { + if (error instanceof CancelledError || error instanceof TimeoutError) { + return { cancelled: true }; + } + throw error; + } + log.debug('Starting model generation'); + const data = await this.webSocketClient.send({ + resource: this.resourceName, + serviceType: 'modelGeneration', + requiredStateId: this.xtextStateId, + start: true, + }); + if (isConflictResult(data)) { + return { cancelled: true }; + } + const parsedResult = ModelGenerationStartedResult.parse(data); + return { cancelled: false, data: parsedResult }; + } + + async cancelModelGeneration(): Promise> { + log.debug('Cancelling model generation'); + const data = await this.webSocketClient.send({ + resource: this.resourceName, + serviceType: 'modelGeneration', + cancel: true, + }); + if (isConflictResult(data)) { + return { cancelled: true }; + } + return { cancelled: false, data }; + } } diff --git a/subprojects/frontend/src/xtext/XtextClient.ts b/subprojects/frontend/src/xtext/XtextClient.ts index 87778084..77980d35 100644 --- a/subprojects/frontend/src/xtext/XtextClient.ts +++ b/subprojects/frontend/src/xtext/XtextClient.ts @@ -16,6 +16,7 @@ import getLogger from '../utils/getLogger'; import ContentAssistService from './ContentAssistService'; import HighlightingService from './HighlightingService'; +import ModelGenerationService from './ModelGenerationService'; import OccurrencesService from './OccurrencesService'; import SemanticsService from './SemanticsService'; import UpdateService from './UpdateService'; @@ -40,6 +41,8 @@ export default class XtextClient { private readonly semanticsService: SemanticsService; + private readonly modelGenerationService: ModelGenerationService; + constructor( private readonly store: EditorStore, private readonly pwaStore: PWAStore, @@ -58,6 +61,10 @@ export default class XtextClient { this.validationService = new ValidationService(store, this.updateService); this.occurrencesService = new OccurrencesService(store, this.updateService); this.semanticsService = new SemanticsService(store, this.validationService); + this.modelGenerationService = new ModelGenerationService( + store, + this.updateService, + ); } start(): void { @@ -75,6 +82,7 @@ export default class XtextClient { this.highlightingService.onDisconnect(); this.validationService.onDisconnect(); this.occurrencesService.onDisconnect(); + this.modelGenerationService.onDisconnect(); } onTransaction(transaction: Transaction): void { @@ -101,7 +109,7 @@ export default class XtextClient { ); return; } - if (stateId !== xtextStateId) { + if (stateId !== xtextStateId && service !== 'modelGeneration') { log.error( 'Unexpected xtext state id: expected:', xtextStateId, @@ -122,6 +130,9 @@ export default class XtextClient { case 'semantics': this.semanticsService.onPush(push); return; + case 'modelGeneration': + this.modelGenerationService.onPush(push); + return; default: throw new Error('Unknown service'); } @@ -131,6 +142,14 @@ export default class XtextClient { return this.contentAssistService.contentAssist(context); } + startModelGeneration(): Promise { + return this.modelGenerationService.start(); + } + + cancelModelGeneration(): Promise { + return this.modelGenerationService.cancel(); + } + formatText(): void { this.updateService.formatText().catch((e) => { log.error('Error while formatting text', e); diff --git a/subprojects/frontend/src/xtext/xtextMessages.ts b/subprojects/frontend/src/xtext/xtextMessages.ts index 971720e1..15831c5a 100644 --- a/subprojects/frontend/src/xtext/xtextMessages.ts +++ b/subprojects/frontend/src/xtext/xtextMessages.ts @@ -38,6 +38,7 @@ export const XtextWebPushService = z.enum([ 'highlight', 'validate', 'semantics', + 'modelGeneration', ]); export type XtextWebPushService = z.infer; diff --git a/subprojects/frontend/src/xtext/xtextServiceResults.ts b/subprojects/frontend/src/xtext/xtextServiceResults.ts index caf2cf0b..e473bd48 100644 --- a/subprojects/frontend/src/xtext/xtextServiceResults.ts +++ b/subprojects/frontend/src/xtext/xtextServiceResults.ts @@ -126,6 +126,14 @@ export const FormattingResult = DocumentStateResult.extend({ export type FormattingResult = z.infer; +export const ModelGenerationStartedResult = z.object({ + uuid: z.string().nonempty(), +}); + +export type ModelGenerationStartedResult = z.infer< + typeof ModelGenerationStartedResult +>; + export const NodeMetadata = z.object({ name: z.string(), simpleName: z.string(), @@ -171,3 +179,19 @@ export const SemanticsResult = z.union([ ]); export type SemanticsResult = z.infer; + +export const ModelGenerationResult = z.union([ + z.object({ + uuid: z.string().nonempty(), + status: z.string(), + }), + z.object({ + uuid: z.string().nonempty(), + error: z.string(), + }), + SemanticsSuccessResult.extend({ + uuid: z.string().nonempty(), + }), +]); + +export type ModelGenerationResult = z.infer; -- cgit v1.2.3-54-g00ecf