aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--subprojects/frontend/src/editor/ConnectButton.tsx68
-rw-r--r--subprojects/frontend/src/editor/ConnectionStatusNotification.tsx108
-rw-r--r--subprojects/frontend/src/editor/EditorButtons.tsx4
-rw-r--r--subprojects/frontend/src/editor/EditorPane.tsx2
-rw-r--r--subprojects/frontend/src/editor/EditorStore.ts20
-rw-r--r--subprojects/frontend/src/editor/GenerateButton.tsx1
-rw-r--r--subprojects/frontend/src/index.tsx4
-rw-r--r--subprojects/frontend/src/xtext/ContentAssistService.ts7
-rw-r--r--subprojects/frontend/src/xtext/HighlightingService.ts4
-rw-r--r--subprojects/frontend/src/xtext/OccurrencesService.ts11
-rw-r--r--subprojects/frontend/src/xtext/UpdateService.ts4
-rw-r--r--subprojects/frontend/src/xtext/ValidationService.ts4
-rw-r--r--subprojects/frontend/src/xtext/XtextClient.ts16
-rw-r--r--subprojects/frontend/src/xtext/XtextWebSocketClient.ts12
14 files changed, 260 insertions, 5 deletions
diff --git a/subprojects/frontend/src/editor/ConnectButton.tsx b/subprojects/frontend/src/editor/ConnectButton.tsx
new file mode 100644
index 00000000..52e7b854
--- /dev/null
+++ b/subprojects/frontend/src/editor/ConnectButton.tsx
@@ -0,0 +1,68 @@
1import CloudIcon from '@mui/icons-material/Cloud';
2import CloudOffIcon from '@mui/icons-material/CloudOff';
3import SyncIcon from '@mui/icons-material/Sync';
4import SyncProblemIcon from '@mui/icons-material/SyncProblem';
5import IconButton from '@mui/material/IconButton';
6import { keyframes, styled } from '@mui/material/styles';
7import { observer } from 'mobx-react-lite';
8import React from 'react';
9
10import type EditorStore from './EditorStore';
11
12const rotateKeyframe = keyframes`
13 0% {
14 transform: rotate(0deg);
15 }
16 100% {
17 transform: rotate(-360deg);
18 }
19`;
20
21const AnimatedSyncIcon = styled(SyncIcon)`
22 animation: ${rotateKeyframe} 1.4s linear infinite;
23`;
24
25export default observer(function ConnectButton({
26 editorStore,
27}: {
28 editorStore: EditorStore | undefined;
29}): JSX.Element {
30 if (
31 editorStore !== undefined &&
32 (editorStore.opening || editorStore.opened)
33 ) {
34 return (
35 <IconButton
36 onClick={() => editorStore.disconnect()}
37 aria-label="Disconnect"
38 color="inherit"
39 >
40 {editorStore.opening ? (
41 <AnimatedSyncIcon fontSize="small" />
42 ) : (
43 <CloudIcon fontSize="small" />
44 )}
45 </IconButton>
46 );
47 }
48
49 let disconnectedIcon: JSX.Element;
50 if (editorStore === undefined) {
51 disconnectedIcon = <SyncIcon fontSize="small" />;
52 } else if (editorStore.connectionErrors.length > 0) {
53 disconnectedIcon = <SyncProblemIcon fontSize="small" />;
54 } else {
55 disconnectedIcon = <CloudOffIcon fontSize="small" />;
56 }
57
58 return (
59 <IconButton
60 disabled={editorStore === undefined}
61 onClick={() => editorStore?.connect()}
62 aria-label="Connect"
63 color="inherit"
64 >
65 {disconnectedIcon}
66 </IconButton>
67 );
68});
diff --git a/subprojects/frontend/src/editor/ConnectionStatusNotification.tsx b/subprojects/frontend/src/editor/ConnectionStatusNotification.tsx
new file mode 100644
index 00000000..e402e296
--- /dev/null
+++ b/subprojects/frontend/src/editor/ConnectionStatusNotification.tsx
@@ -0,0 +1,108 @@
1import Button from '@mui/material/Button';
2import { observer } from 'mobx-react-lite';
3import { type SnackbarKey, useSnackbar } from 'notistack';
4import React, { useEffect } from 'react';
5
6import { ContrastThemeProvider } from '../theme/ThemeProvider';
7
8import type EditorStore from './EditorStore';
9
10const CONNECTING_DEBOUNCE_TIMEOUT = 250;
11
12export default observer(function ConnectionStatusNotification({
13 editorStore,
14}: {
15 editorStore: EditorStore;
16}): null {
17 const { opened, opening, connectionErrors } = editorStore;
18 const { enqueueSnackbar, closeSnackbar } = useSnackbar();
19
20 useEffect(() => {
21 if (opening) {
22 let key: SnackbarKey | undefined;
23 let timeout: number | undefined = setTimeout(() => {
24 timeout = undefined;
25 key = enqueueSnackbar('Connecting to Refinery', {
26 persist: true,
27 action: (
28 <Button onClick={() => editorStore.disconnect()} color="inherit">
29 Cancel
30 </Button>
31 ),
32 });
33 }, CONNECTING_DEBOUNCE_TIMEOUT);
34 return () => {
35 if (timeout !== undefined) {
36 clearTimeout(timeout);
37 }
38 if (key !== undefined) {
39 closeSnackbar(key);
40 }
41 };
42 }
43
44 if (connectionErrors.length >= 1) {
45 const key = enqueueSnackbar(
46 <div>
47 Connection error: <b>{connectionErrors[0]}</b>
48 {connectionErrors.length >= 2 && (
49 <>
50 {' '}
51 and <b>{connectionErrors.length - 1}</b> more{' '}
52 {connectionErrors.length >= 3 ? 'errors' : 'error'}
53 </>
54 )}
55 </div>,
56 {
57 persist: !opened,
58 variant: 'error',
59 action: opened ? (
60 <ContrastThemeProvider>
61 <Button onClick={() => editorStore.disconnect()} color="inherit">
62 Disconnect
63 </Button>
64 </ContrastThemeProvider>
65 ) : (
66 <ContrastThemeProvider>
67 <Button onClick={() => editorStore.connect()} color="inherit">
68 Reconnect
69 </Button>
70 <Button onClick={() => editorStore.disconnect()} color="inherit">
71 Cancel
72 </Button>
73 </ContrastThemeProvider>
74 ),
75 },
76 );
77 return () => closeSnackbar(key);
78 }
79
80 if (!opened) {
81 const key = enqueueSnackbar(
82 <div>
83 <b>Not connected to Refinery:</b> Some editing features might be
84 degraded
85 </div>,
86 {
87 action: (
88 <ContrastThemeProvider>
89 <Button onClick={() => editorStore.connect()}>Reconnect</Button>
90 </ContrastThemeProvider>
91 ),
92 },
93 );
94 return () => closeSnackbar(key);
95 }
96
97 return () => {};
98 }, [
99 editorStore,
100 opened,
101 opening,
102 connectionErrors,
103 closeSnackbar,
104 enqueueSnackbar,
105 ]);
106
107 return null;
108});
diff --git a/subprojects/frontend/src/editor/EditorButtons.tsx b/subprojects/frontend/src/editor/EditorButtons.tsx
index d2273c6c..fd046d46 100644
--- a/subprojects/frontend/src/editor/EditorButtons.tsx
+++ b/subprojects/frontend/src/editor/EditorButtons.tsx
@@ -15,6 +15,7 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
15import { observer } from 'mobx-react-lite'; 15import { observer } from 'mobx-react-lite';
16import React from 'react'; 16import React from 'react';
17 17
18import ConnectButton from './ConnectButton';
18import type EditorStore from './EditorStore'; 19import type EditorStore from './EditorStore';
19 20
20// Exhastive switch as proven by TypeScript. 21// Exhastive switch as proven by TypeScript.
@@ -93,13 +94,14 @@ export default observer(function EditorButtons({
93 </ToggleButton> 94 </ToggleButton>
94 </ToggleButtonGroup> 95 </ToggleButtonGroup>
95 <IconButton 96 <IconButton
96 disabled={editorStore === undefined} 97 disabled={editorStore === undefined || !editorStore.opened}
97 onClick={() => editorStore?.formatText()} 98 onClick={() => editorStore?.formatText()}
98 aria-label="Automatic format" 99 aria-label="Automatic format"
99 color="inherit" 100 color="inherit"
100 > 101 >
101 <FormatPaint fontSize="small" /> 102 <FormatPaint fontSize="small" />
102 </IconButton> 103 </IconButton>
104 <ConnectButton editorStore={editorStore} />
103 </Stack> 105 </Stack>
104 ); 106 );
105}); 107});
diff --git a/subprojects/frontend/src/editor/EditorPane.tsx b/subprojects/frontend/src/editor/EditorPane.tsx
index 2651726c..079ebcdc 100644
--- a/subprojects/frontend/src/editor/EditorPane.tsx
+++ b/subprojects/frontend/src/editor/EditorPane.tsx
@@ -7,6 +7,7 @@ import React, { useState } from 'react';
7 7
8import { useRootStore } from '../RootStore'; 8import { useRootStore } from '../RootStore';
9 9
10import ConnectionStatusNotification from './ConnectionStatusNotification';
10import EditorArea from './EditorArea'; 11import EditorArea from './EditorArea';
11import EditorButtons from './EditorButtons'; 12import EditorButtons from './EditorButtons';
12import GenerateButton from './GenerateButton'; 13import GenerateButton from './GenerateButton';
@@ -43,6 +44,7 @@ export default observer(function EditorPane(): JSX.Element {
43 <EditorLoading /> 44 <EditorLoading />
44 ) : ( 45 ) : (
45 <> 46 <>
47 <ConnectionStatusNotification editorStore={editorStore} />
46 <SearchPanelPortal editorStore={editorStore} /> 48 <SearchPanelPortal editorStore={editorStore} />
47 <EditorArea editorStore={editorStore} /> 49 <EditorArea editorStore={editorStore} />
48 </> 50 </>
diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts
index 4407376b..3ec33b2c 100644
--- a/subprojects/frontend/src/editor/EditorStore.ts
+++ b/subprojects/frontend/src/editor/EditorStore.ts
@@ -69,6 +69,26 @@ export default class EditorStore {
69 }); 69 });
70 } 70 }
71 71
72 get opened(): boolean {
73 return this.client.webSocketClient.opened;
74 }
75
76 get opening(): boolean {
77 return this.client.webSocketClient.opening;
78 }
79
80 get connectionErrors(): string[] {
81 return this.client.webSocketClient.errors;
82 }
83
84 connect(): void {
85 this.client.webSocketClient.connect();
86 }
87
88 disconnect(): void {
89 this.client.webSocketClient.disconnect();
90 }
91
72 setDarkMode(darkMode: boolean): void { 92 setDarkMode(darkMode: boolean): void {
73 log.debug('Update editor dark mode', darkMode); 93 log.debug('Update editor dark mode', darkMode);
74 this.dispatch({ 94 this.dispatch({
diff --git a/subprojects/frontend/src/editor/GenerateButton.tsx b/subprojects/frontend/src/editor/GenerateButton.tsx
index 5254f6cb..2ffb1a94 100644
--- a/subprojects/frontend/src/editor/GenerateButton.tsx
+++ b/subprojects/frontend/src/editor/GenerateButton.tsx
@@ -46,6 +46,7 @@ export default observer(function GenerateButton({
46 46
47 return ( 47 return (
48 <Button 48 <Button
49 disabled={!editorStore.opened}
49 color={warningCount > 0 ? 'warning' : 'primary'} 50 color={warningCount > 0 ? 'warning' : 'primary'}
50 className="rounded" 51 className="rounded"
51 startIcon={<PlayArrowIcon />} 52 startIcon={<PlayArrowIcon />}
diff --git a/subprojects/frontend/src/index.tsx b/subprojects/frontend/src/index.tsx
index 602a68ef..5760327e 100644
--- a/subprojects/frontend/src/index.tsx
+++ b/subprojects/frontend/src/index.tsx
@@ -1,5 +1,6 @@
1import Box from '@mui/material/Box'; 1import Box from '@mui/material/Box';
2import CssBaseline from '@mui/material/CssBaseline'; 2import CssBaseline from '@mui/material/CssBaseline';
3import Grow from '@mui/material/Grow';
3import { configure } from 'mobx'; 4import { configure } from 'mobx';
4import { SnackbarProvider } from 'notistack'; 5import { SnackbarProvider } from 'notistack';
5import React, { Suspense, lazy } from 'react'; 6import React, { Suspense, lazy } from 'react';
@@ -77,7 +78,8 @@ const app = (
77 <ThemeProvider> 78 <ThemeProvider>
78 <CssBaseline enableColorScheme /> 79 <CssBaseline enableColorScheme />
79 <WindowControlsOverlayColor /> 80 <WindowControlsOverlayColor />
80 <SnackbarProvider> 81 {/* @ts-expect-error -- notistack has problems with `exactOptionalPropertyTypes` */}
82 <SnackbarProvider TransitionComponent={Grow}>
81 <RegisterServiceWorker /> 83 <RegisterServiceWorker />
82 <Box height="100vh" overflow="auto"> 84 <Box height="100vh" overflow="auto">
83 <Suspense fallback={<Loading />}> 85 <Suspense fallback={<Loading />}>
diff --git a/subprojects/frontend/src/xtext/ContentAssistService.ts b/subprojects/frontend/src/xtext/ContentAssistService.ts
index 9e41f57b..101990af 100644
--- a/subprojects/frontend/src/xtext/ContentAssistService.ts
+++ b/subprojects/frontend/src/xtext/ContentAssistService.ts
@@ -115,6 +115,13 @@ export default class ContentAssistService {
115 } 115 }
116 116
117 async contentAssist(context: CompletionContext): Promise<CompletionResult> { 117 async contentAssist(context: CompletionContext): Promise<CompletionResult> {
118 if (!this.updateService.opened) {
119 this.lastCompletion = undefined;
120 return {
121 from: context.pos,
122 options: [],
123 };
124 }
118 const tokenBefore = findToken(context); 125 const tokenBefore = findToken(context);
119 if (!context.explicit && !shouldCompleteImplicitly(tokenBefore, context)) { 126 if (!context.explicit && !shouldCompleteImplicitly(tokenBefore, context)) {
120 return { 127 return {
diff --git a/subprojects/frontend/src/xtext/HighlightingService.ts b/subprojects/frontend/src/xtext/HighlightingService.ts
index f9ab7b7e..a126ee40 100644
--- a/subprojects/frontend/src/xtext/HighlightingService.ts
+++ b/subprojects/frontend/src/xtext/HighlightingService.ts
@@ -31,4 +31,8 @@ export default class HighlightingService {
31 }); 31 });
32 this.store.updateSemanticHighlighting(ranges); 32 this.store.updateSemanticHighlighting(ranges);
33 } 33 }
34
35 onDisconnect(): void {
36 this.store.updateSemanticHighlighting([]);
37 }
34} 38}
diff --git a/subprojects/frontend/src/xtext/OccurrencesService.ts b/subprojects/frontend/src/xtext/OccurrencesService.ts
index c8d6fd7b..9d738d76 100644
--- a/subprojects/frontend/src/xtext/OccurrencesService.ts
+++ b/subprojects/frontend/src/xtext/OccurrencesService.ts
@@ -41,6 +41,15 @@ export default class OccurrencesService {
41 private readonly updateService: UpdateService, 41 private readonly updateService: UpdateService,
42 ) {} 42 ) {}
43 43
44 onReconnect(): void {
45 this.clearOccurrences();
46 this.findOccurrencesLater();
47 }
48
49 onDisconnect(): void {
50 this.clearOccurrences();
51 }
52
44 onTransaction(transaction: Transaction): void { 53 onTransaction(transaction: Transaction): void {
45 if (transaction.docChanged) { 54 if (transaction.docChanged) {
46 // Must clear occurrences asynchronously from `onTransaction`, 55 // Must clear occurrences asynchronously from `onTransaction`,
@@ -91,7 +100,7 @@ export default class OccurrencesService {
91 } 100 }
92 101
93 private async updateOccurrences() { 102 private async updateOccurrences() {
94 if (!this.needsOccurrences) { 103 if (!this.needsOccurrences || !this.updateService.opened) {
95 this.clearOccurrences(); 104 this.clearOccurrences();
96 return; 105 return;
97 } 106 }
diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts
index d7471cdc..63e28652 100644
--- a/subprojects/frontend/src/xtext/UpdateService.ts
+++ b/subprojects/frontend/src/xtext/UpdateService.ts
@@ -82,6 +82,10 @@ export default class UpdateService {
82 } 82 }
83 } 83 }
84 84
85 get opened(): boolean {
86 return this.webSocketClient.opened;
87 }
88
85 private idleUpdate(): void { 89 private idleUpdate(): void {
86 if (!this.webSocketClient.opened || !this.tracker.needsUpdate) { 90 if (!this.webSocketClient.opened || !this.tracker.needsUpdate) {
87 return; 91 return;
diff --git a/subprojects/frontend/src/xtext/ValidationService.ts b/subprojects/frontend/src/xtext/ValidationService.ts
index e78318f7..72414590 100644
--- a/subprojects/frontend/src/xtext/ValidationService.ts
+++ b/subprojects/frontend/src/xtext/ValidationService.ts
@@ -28,4 +28,8 @@ export default class ValidationService {
28 }); 28 });
29 this.store.updateDiagnostics(diagnostics); 29 this.store.updateDiagnostics(diagnostics);
30 } 30 }
31
32 onDisconnect(): void {
33 this.store.updateDiagnostics([]);
34 }
31} 35}
diff --git a/subprojects/frontend/src/xtext/XtextClient.ts b/subprojects/frontend/src/xtext/XtextClient.ts
index 6351c9fd..c02afb3b 100644
--- a/subprojects/frontend/src/xtext/XtextClient.ts
+++ b/subprojects/frontend/src/xtext/XtextClient.ts
@@ -18,7 +18,7 @@ import type { XtextWebPushService } from './xtextMessages';
18const log = getLogger('xtext.XtextClient'); 18const log = getLogger('xtext.XtextClient');
19 19
20export default class XtextClient { 20export default class XtextClient {
21 private readonly webSocketClient: XtextWebSocketClient; 21 readonly webSocketClient: XtextWebSocketClient;
22 22
23 private readonly updateService: UpdateService; 23 private readonly updateService: UpdateService;
24 24
@@ -32,7 +32,8 @@ export default class XtextClient {
32 32
33 constructor(store: EditorStore) { 33 constructor(store: EditorStore) {
34 this.webSocketClient = new XtextWebSocketClient( 34 this.webSocketClient = new XtextWebSocketClient(
35 () => this.updateService.onReconnect(), 35 () => this.onReconnect(),
36 () => this.onDisconnect(),
36 (resource, stateId, service, push) => 37 (resource, stateId, service, push) =>
37 this.onPush(resource, stateId, service, push), 38 this.onPush(resource, stateId, service, push),
38 ); 39 );
@@ -46,6 +47,17 @@ export default class XtextClient {
46 this.occurrencesService = new OccurrencesService(store, this.updateService); 47 this.occurrencesService = new OccurrencesService(store, this.updateService);
47 } 48 }
48 49
50 private onReconnect(): void {
51 this.updateService.onReconnect();
52 this.occurrencesService.onReconnect();
53 }
54
55 private onDisconnect(): void {
56 this.highlightingService.onDisconnect();
57 this.validationService.onDisconnect();
58 this.occurrencesService.onDisconnect();
59 }
60
49 onTransaction(transaction: Transaction): void { 61 onTransaction(transaction: Transaction): void {
50 // `ContentAssistService.prototype.onTransaction` needs the dirty change desc 62 // `ContentAssistService.prototype.onTransaction` needs the dirty change desc
51 // _before_ the current edit, so we call it before `updateService`. 63 // _before_ the current edit, so we call it before `updateService`.
diff --git a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
index eedfa365..b69e1d6c 100644
--- a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
+++ b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
@@ -22,6 +22,8 @@ const log = getLogger('xtext.XtextWebSocketClient');
22 22
23export type ReconnectHandler = () => void; 23export type ReconnectHandler = () => void;
24 24
25export type DisconnectHandler = () => void;
26
25export type PushHandler = ( 27export type PushHandler = (
26 resourceId: string, 28 resourceId: string,
27 stateId: string, 29 stateId: string,
@@ -136,6 +138,7 @@ export default class XtextWebSocketClient {
136 138
137 constructor( 139 constructor(
138 private readonly onReconnect: ReconnectHandler, 140 private readonly onReconnect: ReconnectHandler,
141 private readonly onDisconnect: DisconnectHandler,
139 private readonly onPush: PushHandler, 142 private readonly onPush: PushHandler,
140 ) { 143 ) {
141 this.interpreter 144 this.interpreter
@@ -179,10 +182,18 @@ export default class XtextWebSocketClient {
179 return this.interpreter.state; 182 return this.interpreter.state;
180 } 183 }
181 184
185 get opening(): boolean {
186 return this.state.matches('connection.socketCreated.open.opening');
187 }
188
182 get opened(): boolean { 189 get opened(): boolean {
183 return this.state.matches('connection.socketCreated.open.opened'); 190 return this.state.matches('connection.socketCreated.open.opened');
184 } 191 }
185 192
193 get errors(): string[] {
194 return this.state.context.errors;
195 }
196
186 connect(): void { 197 connect(): void {
187 this.interpreter.send('CONNECT'); 198 this.interpreter.send('CONNECT');
188 } 199 }
@@ -261,6 +272,7 @@ export default class XtextWebSocketClient {
261 } 272 }
262 273
263 private cancelPendingRequests(): void { 274 private cancelPendingRequests(): void {
275 this.onDisconnect();
264 this.pendingRequests.forEach((task) => 276 this.pendingRequests.forEach((task) =>
265 task.reject(new CancelledError('Closing connection')), 277 task.reject(new CancelledError('Closing connection')),
266 ); 278 );