Skip to content

Commit b616748

Browse files
Brow 326 add merge view (#693)
* add codemirror merge diff view * enable gutter for mergeView * use ndl token colors for diff view * add react codemirror demo for diff view * add changeset * suggested modification to demo * remove unnecessary reanchor for inline panel when diff view is rendered * fix reanchor when line is deleted * update react-codemirror playground * fix the panel position when prev position is higher than the current number of lines in the new updated query --------- Co-authored-by: Isak <isak.nilsson@neo4j.com>
1 parent 4da8f7d commit b616748

10 files changed

Lines changed: 279 additions & 42 deletions

File tree

.changeset/fuzzy-boxes-kneel.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@neo4j-cypher/react-codemirror-playground": patch
3+
"@neo4j-cypher/react-codemirror": patch
4+
---
5+
6+
Add diff view to the Cypher editor using @codemirror/merge

packages/react-codemirror-playground/src/App.tsx

Lines changed: 119 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { DbSchema, testData } from '@neo4j-cypher/language-support';
22
import {
33
CypherEditor,
4+
type DiffProps,
45
type InlinePanelProps,
56
} from '@neo4j-cypher/react-codemirror';
67
import { useCallback, useMemo, useRef, useState } from 'react';
@@ -33,15 +34,15 @@ function InlinePanelDemo({ onClose }: { onClose: () => void }) {
3334
}
3435

3536
const demos = {
36-
allTokenTypes: `MATCH (variable :Label)-[:REL_TYPE]->()
37-
WHERE variable.property = "String"
37+
allTokenTypes: `MATCH (variable :Label)-[:REL_TYPE]->()
38+
WHERE variable.property = "String"
3839
OR namespaced.function() = false
3940
// comment
40-
OR $parameter > 2
41+
OR $parameter > 2
4142
RETURN variable;`,
4243
basic: `MATCH (n:Person)
43-
WHERE n.name = "Steve"
44-
RETURN n
44+
WHERE n.name = "Steve"
45+
RETURN n
4546
LIMIT 12;`,
4647
subqueries: `UNWIND range(1,100) as _
4748
CALL {
@@ -50,14 +51,43 @@ CALL {
5051
MATCH path = (source)-[*1..10]->(target)
5152
WITH path, reduce(weight = 0, r IN relationships(path) | weight + r.weight) as Weight
5253
ORDER BY Weight LIMIT 3
53-
RETURN length(path) as l, Weight
54-
}
54+
RETURN length(path) as l, Weight
55+
}
5556
RETURN count(*)`,
5657
createDatabase: `CREATE DATABASE testdb OPTIONS {existingData: 'use', seedURI:'s3://bucketpath', seedConfig: 'region=eu-west-1', seedCredentials: 'foo;bar'};`,
5758
} as const;
5859

5960
type DemoName = keyof typeof demos;
6061

62+
const diffExamples: Record<DemoName, { before: string; after: string }> = {
63+
allTokenTypes: {
64+
before: demos.allTokenTypes,
65+
after: demos.allTokenTypes.replace(
66+
'WHERE variable.property = "String"',
67+
'WHERE variable.property = "Changed String"',
68+
),
69+
},
70+
basic: {
71+
before: demos.basic,
72+
after: `MATCH (n:Person)
73+
WHERE n.name = "Steve"
74+
WITH n
75+
ORDER BY n.age DESC
76+
RETURN n
77+
LIMIT 12;`,
78+
},
79+
subqueries: {
80+
before: demos.subqueries,
81+
after: demos.subqueries
82+
.replace('[*1..10]', '[*1..5]')
83+
.replace('RETURN count(*)', 'RETURN count(*) LIMIT 1000'),
84+
},
85+
createDatabase: {
86+
before: demos.createDatabase,
87+
after: `CREATE DATABASE testdb OPTIONS {existingData: 'ignore', seedURI:'s3://bucketpath', seedConfig: 'region=eu-west-1', seedCredentials: 'foo;bar', storeFormat: 'aligned'};`,
88+
},
89+
};
90+
6191
export function App() {
6292
const [selectedDemoName, setSelectedDemoName] = useState<DemoName>('basic');
6393
const [value, setValue] = useState<string>(demos[selectedDemoName]);
@@ -74,35 +104,62 @@ export function App() {
74104
);
75105
const [schemaError, setSchemaError] = useState<string | null>(null);
76106

77-
const [isInlinePanelOpen, setIsInlinePanelOpen] = useState(false);
107+
const editorRef = useRef<CypherEditor>(null);
108+
109+
const [inlinePanel, setInlinePanel] = useState<InlinePanelProps | null>(null);
78110
const [inlinePanelContainer, setInlinePanelContainer] =
79111
useState<HTMLElement | null>(null);
80112

81-
const closeInlinePanel = useCallback(() => setIsInlinePanelOpen(false), []);
113+
const openInlinePanel = useCallback(() => {
114+
const view = editorRef.current?.editorView.current;
115+
if (!view || inlinePanel !== null) return;
116+
const stableContainer = document.createElement('div');
117+
setInlinePanel({
118+
pos: view.state.selection.main.from,
119+
onMount: (widgetDiv) => {
120+
widgetDiv.appendChild(stableContainer);
121+
setInlinePanelContainer(stableContainer);
122+
},
123+
onUnmount: () => {},
124+
});
125+
}, [inlinePanel]);
126+
127+
const closeInlinePanel = useCallback(() => {
128+
setInlinePanelContainer(null);
129+
setInlinePanel(null);
130+
}, []);
131+
132+
const [diff, setDiff] = useState<DiffProps | null>(null);
133+
let beforeValue: string = '';
134+
const showDiff = useCallback(() => {
135+
beforeValue =
136+
editorRef.current?.editorView.current?.state.doc.toString() ?? '';
137+
setDiff({ original: beforeValue });
138+
}, []);
139+
const acceptDiff = useCallback(() => setDiff(null), []);
140+
const rejectDiff = useCallback(() => {
141+
setValue(beforeValue);
142+
setDiff(null);
143+
}, []);
144+
145+
const loadDemoContent = useCallback(() => {
146+
const example = diffExamples[selectedDemoName];
147+
setValue(example.after);
148+
setDiff({ original: example.before });
149+
}, [selectedDemoName]);
82150

83151
const extraKeybindings = [
84152
{
85153
key: 'Ctrl-Shift-f',
86154
mac: 'Alt-Shift-f',
87155
preventDefault: true,
88156
run: () => {
89-
editorRef.current.format();
157+
editorRef.current?.format();
90158
return true;
91159
},
92160
},
93161
];
94162

95-
const editorRef = useRef<CypherEditor>(null);
96-
97-
const inlinePanel: InlinePanelProps | null = isInlinePanelOpen
98-
? {
99-
pos:
100-
editorRef.current?.editorView.current?.state.selection.main.from ?? 0,
101-
onMount: setInlinePanelContainer,
102-
onUnmount: () => setInlinePanelContainer(null),
103-
}
104-
: null;
105-
106163
const treeData = useMemo(() => {
107164
return getDebugTree(value);
108165
}, [value]);
@@ -141,14 +198,16 @@ export function App() {
141198
</button>
142199
</div>
143200
<div className="flex gap-1">
144-
{Object.keys(demos).map((demoName: DemoName) => (
201+
{(Object.keys(demos) as DemoName[]).map((demoName) => (
145202
<button
146203
key={demoName}
147-
className={`hover:bg-blue-600 text-white font-bold py-1 px-3 rounded
204+
className={`hover:bg-blue-600 text-white font-bold py-1 px-3 rounded
148205
${selectedDemoName === demoName ? 'bg-blue-600' : 'bg-blue-400'}`}
149206
onClick={() => {
150207
setSelectedDemoName(demoName);
151208
setValue(demos[demoName]);
209+
setDiff(null);
210+
closeInlinePanel();
152211
}}
153212
>
154213
{demoName}
@@ -174,6 +233,7 @@ export function App() {
174233
}}
175234
ariaLabel="Cypher Editor"
176235
inlinePanel={inlinePanel}
236+
diff={diff}
177237
/>
178238
{inlinePanelContainer &&
179239
createPortal(
@@ -182,7 +242,7 @@ export function App() {
182242
)}
183243
<div className="flex gap-4">
184244
<p
185-
onClick={() => editorRef.current.format()}
245+
onClick={() => editorRef.current?.format()}
186246
className="text-blue-500 cursor-pointer hover:text-blue-700"
187247
title={
188248
window.navigator.userAgent.includes('Mac')
@@ -193,11 +253,44 @@ export function App() {
193253
Format Query
194254
</p>
195255
<p
196-
onClick={() => setIsInlinePanelOpen((prevState) => !prevState)}
256+
onClick={() =>
257+
inlinePanel ? closeInlinePanel() : openInlinePanel()
258+
}
197259
className="text-blue-500 cursor-pointer hover:text-blue-700"
198260
>
199-
{isInlinePanelOpen ? 'Close inline panel' : 'Open inline panel'}
261+
{inlinePanel ? 'Close inline panel' : 'Open inline panel'}
200262
</p>
263+
{diff ? (
264+
<>
265+
<p
266+
onClick={acceptDiff}
267+
className="text-green-600 cursor-pointer hover:text-green-800"
268+
>
269+
Accept diff
270+
</p>
271+
<p
272+
onClick={rejectDiff}
273+
className="text-red-600 cursor-pointer hover:text-red-800"
274+
>
275+
Reject diff
276+
</p>
277+
</>
278+
) : (
279+
<>
280+
<p
281+
onClick={showDiff}
282+
className="text-blue-500 cursor-pointer hover:text-blue-700"
283+
>
284+
Start diff
285+
</p>
286+
<p
287+
onClick={loadDemoContent}
288+
className="text-blue-500 cursor-pointer hover:text-blue-700"
289+
>
290+
Load demo diff
291+
</p>
292+
</>
293+
)}
201294
</div>
202295
{commandRanCount > 0 && (
203296
<span className="text-gray-400">

packages/react-codemirror/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@codemirror/commands": "^6.8.1",
4545
"@codemirror/language": "^6.11.2",
4646
"@codemirror/lint": "^6.8.5",
47+
"@codemirror/merge": "^6.12.1",
4748
"@codemirror/search": "^6.5.11",
4849
"@codemirror/state": "^6.5.2",
4950
"@codemirror/view": "^6.38.1",

packages/react-codemirror/src/CypherEditor.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type InlinePanelCallbacks,
1919
type InlinePanelController,
2020
} from './inlinePanel';
21+
import { createDiffExtension, type DiffProps } from './diffView';
2122
import {
2223
formatQuery,
2324
CypherLanguageService,
@@ -190,6 +191,11 @@ export interface CypherEditorProps {
190191
* The widget DOM is only rebuilt when `pos` or `placement` change
191192
*/
192193
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;
193199
}
194200

195201
export type InlinePanelProps = {
@@ -291,6 +297,7 @@ const lineNumbersCompartment = new Compartment();
291297
const readOnlyCompartment = new Compartment();
292298
const placeholderCompartment = new Compartment();
293299
const domEventHandlerCompartment = new Compartment();
300+
const diffCompartment = new Compartment();
294301

295302
const formatLineNumber =
296303
(prompt?: string) => (a: number, state: EditorState) => {
@@ -565,6 +572,9 @@ export class CypherEditor extends Component<
565572
})
566573
: [],
567574
this.inlinePanelController.extension,
575+
diffCompartment.of(
576+
this.props.diff ? createDiffExtension(this.props.diff) : [],
577+
),
568578
],
569579
doc: this.props.value,
570580
});
@@ -734,6 +744,14 @@ export class CypherEditor extends Component<
734744
}
735745
}
736746

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+
737755
if (prevProps.domEventHandlers !== this.props.domEventHandlers) {
738756
this.editorView.current.dispatch({
739757
effects: domEventHandlerCompartment.reconfigure(
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * as LanguageSupport from '@neo4j-cypher/language-support';
22
export { CypherEditor } from './CypherEditor';
33
export type { InlinePanelProps } from './CypherEditor';
4+
export type { DiffProps } from './diffView';
45
export { cypher } from './lang-cypher/langCypher';
56
export { darkThemeConstants, lightThemeConstants } from './themes';

0 commit comments

Comments
 (0)