@neo4j-cypher/react-codemirror 2.0.0-next.36 → 2.0.0-next.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neo4j-cypher/react-codemirror",
3
- "version": "2.0.0-next.36",
3
+ "version": "2.0.0-next.37",
4
4
  "keywords": [
5
5
  "codemirror",
6
6
  "codemirror 6",
@@ -34,6 +34,7 @@
34
34
  "@codemirror/commands": "^6.8.1",
35
35
  "@codemirror/language": "^6.11.2",
36
36
  "@codemirror/lint": "^6.8.5",
37
+ "@codemirror/merge": "^6.12.1",
37
38
  "@codemirror/search": "^6.5.11",
38
39
  "@codemirror/state": "^6.5.2",
39
40
  "@codemirror/view": "^6.38.1",
@@ -47,8 +48,8 @@
47
48
  "style-mod": "^4.1.2",
48
49
  "vscode-languageserver-types": "^3.17.3",
49
50
  "workerpool": "^9.3.3",
50
- "@neo4j-cypher/language-support": "2.0.0-next.33",
51
- "@neo4j-cypher/lint-worker": "1.10.1-next.10"
51
+ "@neo4j-cypher/language-support": "2.0.0-next.34",
52
+ "@neo4j-cypher/lint-worker": "1.10.1-next.11"
52
53
  },
53
54
  "devDependencies": {
54
55
  "@neo4j-ndl/base": "^3.2.10",
@@ -18,6 +18,7 @@ import {
18
18
  type InlinePanelCallbacks,
19
19
  type InlinePanelController,
20
20
  } from './inlinePanel';
21
+ import { createDiffExtension, type DiffProps } from './diffView';
21
22
  import {
22
23
  formatQuery,
23
24
  CypherLanguageService,
@@ -190,6 +191,11 @@ export interface CypherEditorProps {
190
191
  * The widget DOM is only rebuilt when `pos` or `placement` change
191
192
  */
192
193
  inlinePanel?: InlinePanelProps | null;
194
+ /**
195
+ * Render a unified diff of the current document against `diff.original`.
196
+ * Deleted lines are shown as uneditable widgets.
197
+ */
198
+ diff?: DiffProps | null;
193
199
  }
194
200
 
195
201
  export type InlinePanelProps = {
@@ -291,6 +297,7 @@ const lineNumbersCompartment = new Compartment();
291
297
  const readOnlyCompartment = new Compartment();
292
298
  const placeholderCompartment = new Compartment();
293
299
  const domEventHandlerCompartment = new Compartment();
300
+ const diffCompartment = new Compartment();
294
301
 
295
302
  const formatLineNumber =
296
303
  (prompt?: string) => (a: number, state: EditorState) => {
@@ -565,6 +572,9 @@ export class CypherEditor extends Component<
565
572
  })
566
573
  : [],
567
574
  this.inlinePanelController.extension,
575
+ diffCompartment.of(
576
+ this.props.diff ? createDiffExtension(this.props.diff) : [],
577
+ ),
568
578
  ],
569
579
  doc: this.props.value,
570
580
  });
@@ -734,6 +744,14 @@ export class CypherEditor extends Component<
734
744
  }
735
745
  }
736
746
 
747
+ if (prevProps.diff?.original !== this.props.diff?.original) {
748
+ this.editorView.current.dispatch({
749
+ effects: diffCompartment.reconfigure(
750
+ this.props.diff ? createDiffExtension(this.props.diff) : [],
751
+ ),
752
+ });
753
+ }
754
+
737
755
  if (prevProps.domEventHandlers !== this.props.domEventHandlers) {
738
756
  this.editorView.current.dispatch({
739
757
  effects: domEventHandlerCompartment.reconfigure(
@@ -0,0 +1,24 @@
1
+ import { unifiedMergeView } from '@codemirror/merge';
2
+ import type { Extension } from '@codemirror/state';
3
+
4
+ /**
5
+ * Props for rendering an inline diff in the editor.
6
+ *
7
+ * The diff is computed between {@link DiffProps.original} and the *current*
8
+ * editor document, so streaming/external updates to the document re-diff
9
+ * automatically against the same original.
10
+ */
11
+ export type DiffProps = {
12
+ /** The baseline document the current editor content is compared against. */
13
+ original: string;
14
+ };
15
+
16
+ export function createDiffExtension({ original }: DiffProps): Extension {
17
+ return unifiedMergeView({
18
+ original,
19
+ highlightChanges: true,
20
+ syntaxHighlightDeletions: true,
21
+ mergeControls: false,
22
+ gutter: true,
23
+ });
24
+ }
@@ -131,11 +131,6 @@ test('Semantic errors are correctly accumulated', async ({ page, mount }) => {
131
131
 
132
132
  await mount(<CypherEditor value={query} />);
133
133
 
134
- await editorPage.checkErrorMessage(
135
- 'MATCH (n)',
136
- 'Query cannot conclude with MATCH (must be a RETURN clause, a FINISH clause, an update clause, a unit subquery call, or a procedure call with no YIELD).',
137
- );
138
-
139
134
  await editorPage.checkErrorMessage(
140
135
  '-1',
141
136
  "Invalid input. '-1' is not a valid value. Must be a positive integer.",
@@ -151,11 +146,6 @@ test('Multiline errors are correctly placed', async ({ page, mount }) => {
151
146
 
152
147
  await mount(<CypherEditor value={query} />);
153
148
 
154
- await editorPage.checkErrorMessage(
155
- 'MATCH (n)',
156
- 'Query cannot conclude with MATCH (must be a RETURN clause, a FINISH clause, an update clause, a unit subquery call, or a procedure call with no YIELD).',
157
- );
158
-
159
149
  await editorPage.checkErrorMessage(
160
150
  '-1',
161
151
  "Invalid input. '-1' is not a valid value. Must be a positive integer.",
@@ -171,11 +161,6 @@ test('Validation errors are correctly overlapped', async ({ page, mount }) => {
171
161
 
172
162
  await mount(<CypherEditor value={query} />);
173
163
 
174
- await editorPage.checkErrorMessage(
175
- '-1',
176
- 'Query cannot conclude with CALL (must be a RETURN clause, a FINISH clause, an update clause, a unit subquery call, or a procedure call with no YIELD).',
177
- );
178
-
179
164
  await editorPage.checkErrorMessage(
180
165
  '-1',
181
166
  "Invalid input. '-1' is not a valid value. Must be a positive integer.",
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * as LanguageSupport from '@neo4j-cypher/language-support';
2
2
  export { CypherEditor } from './CypherEditor';
3
3
  export type { InlinePanelProps } from './CypherEditor';
4
+ export type { DiffProps } from './diffView';
4
5
  export { cypher } from './lang-cypher/langCypher';
5
6
  export { darkThemeConstants, lightThemeConstants } from './themes';
@@ -79,32 +79,57 @@ export function createInlinePanelController(): InlinePanelController {
79
79
  }
80
80
  }
81
81
 
82
+ const buildDecoration = (options: InlinePanelShowOptions): DecorationSet =>
83
+ Decoration.set([
84
+ Decoration.widget({
85
+ widget: new InlinePanelWidget(options),
86
+ block: true,
87
+ side: options.placement === 'below' ? 1 : -1,
88
+ }).range(options.pos),
89
+ ]);
90
+
91
+ const readOptions = (
92
+ decorations: DecorationSet,
93
+ ): InlinePanelShowOptions | null => {
94
+ const widget = decorations.iter().value?.spec?.widget;
95
+ return widget instanceof InlinePanelWidget ? widget.options : null;
96
+ };
97
+
82
98
  const field = StateField.define<DecorationSet>({
83
99
  create() {
84
100
  return Decoration.none;
85
101
  },
86
102
  update(decorations, transaction) {
87
- let next: DecorationSet | null = null;
88
103
  for (const effect of transaction.effects) {
89
104
  if (effect.is(showEffect)) {
90
- if (effect.value === null) {
91
- next = Decoration.none;
92
- } else {
93
- const side = effect.value.placement === 'below' ? 1 : -1;
94
- next = Decoration.set([
95
- Decoration.widget({
96
- widget: new InlinePanelWidget(effect.value),
97
- block: true,
98
- side,
99
- }).range(effect.value.pos),
100
- ]);
101
- }
105
+ return effect.value === null
106
+ ? Decoration.none
107
+ : buildDecoration(effect.value);
102
108
  }
103
109
  }
104
- if (next !== null) {
105
- return next;
110
+
111
+ // Nothing to do when the panel is closed, or when the document is unchanged
112
+ if (decorations.size === 0 || !transaction.docChanged) {
113
+ return decorations.map(transaction.changes);
114
+ }
115
+
116
+ // Re-anchor manually because CodeMirror's default mapping silently drops
117
+ // block-widget decorations when their anchor line is deleted
118
+ const options = readOptions(decorations);
119
+ if (options === null) {
120
+ return decorations.map(transaction.changes);
106
121
  }
107
- return decorations.map(transaction.changes);
122
+ const side = options.placement === 'below' ? 1 : -1;
123
+ const lineNumber = Math.min(
124
+ transaction.startState.doc.lineAt(options.pos).number,
125
+ transaction.newDoc.lines,
126
+ );
127
+ const line = transaction.newDoc.line(lineNumber);
128
+
129
+ return buildDecoration({
130
+ ...options,
131
+ pos: side === 1 ? line.to : line.from,
132
+ });
108
133
  },
109
134
  provide: (f) => EditorView.decorations.from(f),
110
135
  });
@@ -18,6 +18,17 @@ import {
18
18
  upArrowSvg,
19
19
  } from './themeIcons';
20
20
 
21
+ export interface DiffColors {
22
+ /** Background fill for the whole inserted/changed line. */
23
+ insertedLine: string;
24
+ /** Highlight for the exact inserted text within a changed line. */
25
+ insertedText: string;
26
+ /** Background fill for the whole deleted line widget. */
27
+ deletedLine: string;
28
+ /** Highlight for the exact deleted text within a deleted chunk. */
29
+ deletedText: string;
30
+ }
31
+
21
32
  export interface ThemeOptions {
22
33
  dark: boolean;
23
34
  editorSettings: {
@@ -40,6 +51,7 @@ export interface ThemeOptions {
40
51
  };
41
52
  highlightStyles: Partial<Record<HighlightedCypherTokenTypes, string>>;
42
53
  inheritBgColor?: boolean;
54
+ diffColors?: DiffColors;
43
55
  }
44
56
 
45
57
  export const createCypherTheme = ({
@@ -47,6 +59,7 @@ export const createCypherTheme = ({
47
59
  editorSettings: settings,
48
60
  highlightStyles,
49
61
  inheritBgColor,
62
+ diffColors,
50
63
  }: ThemeOptions): Extension => {
51
64
  const themeOptions: Record<string, StyleSpec> = {
52
65
  '&': {
@@ -218,6 +231,26 @@ export const createCypherTheme = ({
218
231
  },
219
232
  },
220
233
  },
234
+ ...(diffColors && {
235
+ '&.cm-merge-b .cm-changedLine': {
236
+ backgroundColor: diffColors.insertedLine,
237
+ },
238
+ '&.cm-merge-b .cm-changedText': {
239
+ background: diffColors.insertedText,
240
+ },
241
+ '&.cm-merge-b .cm-deletedChunk': {
242
+ backgroundColor: diffColors.deletedLine,
243
+ },
244
+ '&.cm-merge-b .cm-deletedChunk .cm-deletedText': {
245
+ background: diffColors.deletedText,
246
+ },
247
+ // Hide the empty deletion widget that unifiedMergeView renders
248
+ // when the original document is empty (its only line is an empty <del>).
249
+ '&.cm-merge-b .cm-deletedChunk:has(> .cm-deletedLine:only-child > del > br:only-child)':
250
+ {
251
+ display: 'none',
252
+ },
253
+ }),
221
254
  };
222
255
 
223
256
  const themeExtension = EditorView.theme(themeOptions, { dark });