├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── src ├── README.md ├── chunk.ts ├── deco.ts ├── diff.ts ├── index.ts ├── merge.ts ├── mergeview.ts ├── theme.ts └── unified.ts └── test ├── test-chunk.ts └── test-diff.ts /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | package-lock.json 3 | /dist 4 | /test/*.js 5 | /test/*.d.ts 6 | /test/*.d.ts.map 7 | .tern-* 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /src 2 | /test 3 | /node_modules 4 | .tern-* 5 | rollup.config.js 6 | tsconfig.json 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 6.10.1 (2025-05-14) 2 | 3 | ### Bug fixes 4 | 5 | Fix an issue in `presentableDiff` where it sometimes doesn't expand changes over words with multiple individual changes in them. 6 | 7 | ## 6.10.0 (2025-03-06) 8 | 9 | ### New features 10 | 11 | The new `allowInlineDiffs` option to `unifiedMergeView` will display chunks with only limited inline changes inline in the code. 12 | 13 | ## 6.9.0 (2025-03-03) 14 | 15 | ### New features 16 | 17 | The new diff option `timeout` can be used to make the algorithm bail out after a given amount of milliseconds. 18 | 19 | Chunks now have a `precise` property that is false when the diff that the chunk is based on fell back to imprecise diffing (because of a scan depth limit or timeout). 20 | 21 | ## 6.8.0 (2024-12-30) 22 | 23 | ### Bug fixes 24 | 25 | Limit the size of highlighted chunks in the unified view, to prevent freezing the interface highlighting huge amounts of code. 26 | 27 | Fix a regression that caused deleted chunks in the unified view to be rendered with strike-through style by default. 28 | 29 | ### New features 30 | 31 | Export the `uncollapseUnchanged` effect that is used to uncollapse sections of code. 32 | 33 | ## 6.7.5 (2024-12-17) 34 | 35 | ### Bug fixes 36 | 37 | Fix a bug that hid the accept/reject buttons for insertions in the unified merge view. 38 | 39 | The lines shown around collapsed unchanged lines are now css `:before`/`:after` elements, so that they can be customized more easily. 40 | 41 | Render deleted lines in the unified merge view as block elements, for easier styling. 42 | 43 | ## 6.7.4 (2024-11-13) 44 | 45 | ### Bug fixes 46 | 47 | In the unified diff view, fix an issue where empty deleted lines were rendered for chunks that deleted nothing. 48 | 49 | Fix a bug that made the diff algorithm miss some obvious opportunities to align changes on line boundaries. 50 | 51 | ## 6.7.3 (2024-11-05) 52 | 53 | ### Bug fixes 54 | 55 | Fix an issue where the last line of a deleted chunk, if there is no text on it, was collapsed by the browser and not visible. 56 | 57 | ## 6.7.2 (2024-10-10) 58 | 59 | ### Bug fixes 60 | 61 | Fix a bug in `presentableDiff` that could cause it to produce corrupted diffs. 62 | 63 | ## 6.7.1 (2024-09-17) 64 | 65 | ### Bug fixes 66 | 67 | Improve the way `presentableDiff` aligns changes to line boundaries when possible. 68 | 69 | ## 6.7.0 (2024-08-18) 70 | 71 | ### New features 72 | 73 | `unifiedMergeView` now supports the `collapseUnchanged` option the way the split view does. 74 | 75 | ## 6.6.7 (2024-07-31) 76 | 77 | ### Bug fixes 78 | 79 | Fix a bug in the way spacers were inserted at the top of the viewport. 80 | 81 | ## 6.6.6 (2024-07-30) 82 | 83 | ### Bug fixes 84 | 85 | Improve vertical alignment of huge unchanged chunks in the side-by-side view. 86 | 87 | ## 6.6.5 (2024-07-18) 88 | 89 | ### Bug fixes 90 | 91 | Fix an issue that would corrupt the text displayed for some deleted lines in the unified merge view. 92 | 93 | ## 6.6.4 (2024-07-18) 94 | 95 | ### Bug fixes 96 | 97 | Fix syntax and change highlighting in deleted lines in the unified merge view. 98 | 99 | ## 6.6.3 (2024-06-07) 100 | 101 | ### Bug fixes 102 | 103 | Fix `originalDocChangeEffect` to apply the changes to the appropriate document. 104 | 105 | ## 6.6.2 (2024-05-17) 106 | 107 | ### Bug fixes 108 | 109 | Restore the default scan limit when diffing for chunks, which looks like it was accidentally dropped when it was made configurable in 6.3.0. 110 | 111 | ## 6.6.1 (2024-03-08) 112 | 113 | ### Bug fixes 114 | 115 | Fix a bug that could cause the set of changed chunks to be updated incorrectly on some types of changes. 116 | 117 | ## 6.6.0 (2024-01-25) 118 | 119 | ### Bug fixes 120 | 121 | Fix a bug where big deletions could corrupt the merge state. 122 | 123 | ### New features 124 | 125 | The state effect used to change the original document in a unified merge view is now available to client code as `updateOriginalDoc`. 126 | 127 | ## 6.5.0 (2024-01-04) 128 | 129 | ### New features 130 | 131 | The new `changeOriginalDocEffect` function can be used to update the reference document in a unified merge editor. 132 | 133 | ## 6.4.0 (2023-12-14) 134 | 135 | ### New features 136 | 137 | The `getOriginalDoc` function extracts the original document from a unified merge editor. 138 | 139 | ## 6.3.1 (2023-12-03) 140 | 141 | ### Bug fixes 142 | 143 | Add a `userEvent` annotation to transactions that accept a change in the unified merge view. 144 | 145 | Fix CSS selectors in the merge view base theme to avoid affecting the style of non-merge view editors. 146 | 147 | ## 6.3.0 (2023-11-16) 148 | 149 | ### New features 150 | 151 | Merge views (and `Chunk` building methods) now take an optional diff config object to allow precision to be configured. 152 | 153 | ## 6.2.0 (2023-10-06) 154 | 155 | ### New features 156 | 157 | The package now exports `goToNextChunk` and `goToPreviousChunk` commands that allow by-changed-chunk document navigation. 158 | 159 | ## 6.1.3 (2023-09-28) 160 | 161 | ### Bug fixes 162 | 163 | Create alignment spacers for the whole document, not just the viewport, to avoid scroll position popping and misalignment. 164 | 165 | ## 6.1.2 (2023-08-18) 166 | 167 | ### Bug fixes 168 | 169 | Fall back to treating entire documents as changed when they are too large to compute a diff in a reasonable timeframe. 170 | 171 | ## 6.1.1 (2023-06-05) 172 | 173 | ### Bug fixes 174 | 175 | Fix a crash when `unifiedMergeView` is added to an existing state by reconfiguration. 176 | 177 | ## 6.1.0 (2023-05-06) 178 | 179 | ### Bug fixes 180 | 181 | Add ``/`` tags around inserted and deleted lines to give screen readers a chance to communicate their role. 182 | 183 | ### New features 184 | 185 | The new `unifiedMergeView` extension can be used to display a diff inside a single editor, by inserting deleted content as widgets in the document. 186 | 187 | ## 6.0.2 (2023-04-18) 188 | 189 | ### Bug fixes 190 | 191 | Fix a bug that could cause `diff` to loop endlessly when the input contains astral characters in specific positions. 192 | 193 | ## 6.0.1 (2023-03-28) 194 | 195 | ### Bug fixes 196 | 197 | Fix a bug that would cause diffing to loop infinitely with some inputs. 198 | 199 | ## 6.0.0 (2023-03-22) 200 | 201 | ### Bug fixes 202 | 203 | Improve performance of the merge view when the inputs are almost entirely different. 204 | 205 | ### New features 206 | 207 | `diff` and `presentableDiff` now take an optional `scanLimit` option that can be used to trade speed for accuracy on very different inputs. 208 | 209 | ## 0.1.6 (2023-02-28) 210 | 211 | ### Bug fixes 212 | 213 | Fix a bug where changed chunks could be found in equal documents. 214 | 215 | ## 0.1.5 (2023-02-26) 216 | 217 | ### New features 218 | 219 | `Chunk` now exposes static methods for building an updating sets of chunks directly. 220 | 221 | ## 0.1.4 (2023-02-17) 222 | 223 | ### Bug fixes 224 | 225 | Fix a bug that caused an extra stray newline to be inserted when pressing the merge button for a change at the end of the document. 226 | 227 | Avoid generating incorrect chunks for insertions or deletions at the end of the document. 228 | 229 | ## 0.1.3 (2022-12-09) 230 | 231 | ### New features 232 | 233 | It is now possible to change the configuration of a merge view with its `reconfigure` method. 234 | 235 | ## 0.1.2 (2022-12-05) 236 | 237 | ### New features 238 | 239 | The `Chunk` data structure, and the set of chunks kept by the merge view, are now part of the public interface. 240 | 241 | The new `orientation` option makes it possible to show editor B first. 242 | 243 | ## 0.1.1 (2022-11-14) 244 | 245 | ### Bug fixes 246 | 247 | Color changed chunks in editor A red by default. 248 | ## 0.1.0 (2022-11-10) 249 | 250 | ### Breaking changes 251 | 252 | First numbered release. 253 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2018-2022 by Marijn Haverbeke and others 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # @codemirror/merge [![NPM version](https://img.shields.io/npm/v/@codemirror/merge.svg)](https://www.npmjs.org/package/@codemirror/merge) 4 | 5 | [ [**WEBSITE**](https://codemirror.net/) | [**ISSUES**](https://github.com/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/c/next/) | [**CHANGELOG**](https://github.com/codemirror/merge/blob/main/CHANGELOG.md) ] 6 | 7 | This package implements a merge interface for the 8 | [CodeMirror](https://codemirror.net/) code editor. 9 | 10 | The [project page](https://codemirror.net/) has more information, a 11 | number of [examples](https://codemirror.net/examples/) and the 12 | [documentation](https://codemirror.net/docs/). 13 | 14 | This code is released under an 15 | [MIT license](https://github.com/codemirror/merge/tree/main/LICENSE). 16 | 17 | We aim to be an inclusive, welcoming community. To make that explicit, 18 | we have a [code of 19 | conduct](http://contributor-covenant.org/version/1/1/0/) that applies 20 | to communication around the project. 21 | 22 | ## Usage 23 | 24 | A split merge view can be created like this: 25 | 26 | ```javascript 27 | import {MergeView} from "@codemirror/merge" 28 | import {EditorView, basicSetup} from "codemirror" 29 | import {EditorState} from "@codemirror/state" 30 | 31 | let doc = `one 32 | two 33 | three 34 | four 35 | five` 36 | 37 | const view = new MergeView({ 38 | a: { 39 | doc, 40 | extensions: basicSetup 41 | }, 42 | b: { 43 | doc: doc.replace(/t/g, "T") + "\nSix", 44 | extensions: [ 45 | basicSetup, 46 | EditorView.editable.of(false), 47 | EditorState.readOnly.of(true) 48 | ] 49 | }, 50 | parent: document.body 51 | }) 52 | ``` 53 | 54 | Or a unified view like this: 55 | 56 | ```javascript 57 | import {EditorView, basicSetup} from "codemirror" 58 | import {unifiedMergeView} from "@codemirror/merge" 59 | 60 | const view = new EditorView({ 61 | parent: document.body, 62 | doc: "one\ntwo\nthree\nfour", 63 | extensions: [ 64 | basicSetup, 65 | unifiedMergeView({ 66 | original: "one\n...\nfour" 67 | }) 68 | ] 69 | }) 70 | ``` 71 | 72 | ## API Reference 73 | 74 | ### Side-by-side Merge View 75 | 76 |
77 |
78 |

79 | interface 80 | MergeConfig

81 |
82 | 83 |

Configuration options to MergeView that can be provided both 84 | initially and to reconfigure.

85 |
86 | orientation⁠?: "a-b" | "b-a"
87 | 88 |

Controls whether editor A or editor B is shown first. Defaults 89 | to "a-b".

90 |
91 | revertControls⁠?: "a-to-b" | "b-to-a"
92 | 93 |

Controls whether revert controls are shown between changed 94 | chunks.

95 |
96 | renderRevertControl⁠?: fn() → HTMLElement
97 | 98 |

When given, this function is called to render the button to 99 | revert a chunk.

100 |
101 | highlightChanges⁠?: boolean
102 | 103 |

By default, the merge view will mark inserted and deleted text 104 | in changed chunks. Set this to false to turn that off.

105 |
106 | gutter⁠?: boolean
107 | 108 |

Controls whether a gutter marker is shown next to changed lines.

109 |
110 | collapseUnchanged⁠?: {margin⁠?: number, minSize⁠?: number}
111 | 112 |

When given, long stretches of unchanged text are collapsed. 113 | margin gives the number of lines to leave visible after/before 114 | a change (default is 3), and minSize gives the minimum amount 115 | of collapsible lines that need to be present (defaults to 4).

116 |
117 | diffConfig⁠?: DiffConfig
118 | 119 |

Pass options to the diff algorithm. By default, the merge view 120 | sets scanLimit to 500.

121 |
122 | 123 |
124 |
125 |

126 | interface 127 | DirectMergeConfig extends MergeConfig

128 |
129 | 130 |

Configuration options given to the MergeView 131 | constructor.

132 |
133 | a: EditorStateConfig
134 | 135 |

Configuration for the first editor (the left one in a 136 | left-to-right context).

137 |
138 | b: EditorStateConfig
139 | 140 |

Configuration for the second editor.

141 |
142 | parent⁠?: Element | DocumentFragment
143 | 144 |

Parent element to append the view to.

145 |
146 | root⁠?: Document | ShadowRoot
147 | 148 |

An optional root. Only necessary if the view is mounted in a 149 | shadow root or a document other than the global document 150 | object.

151 |
152 | 153 |
154 |
155 |

156 | class 157 | MergeView

158 |
159 | 160 |

A merge view manages two editors side-by-side, highlighting the 161 | difference between them and vertically aligning unchanged lines. 162 | If you want one of the editors to be read-only, you have to 163 | configure that in its extensions.

164 |

By default, views are not scrollable. Style them (.cm-mergeView) 165 | with a height and overflow: auto to make them scrollable.

166 |
167 | new MergeView(configDirectMergeConfig)
168 | 169 |

Create a new merge view.

170 |
171 | a: EditorView
172 | 173 |

The first editor.

174 |
175 | b: EditorView
176 | 177 |

The second editor.

178 |
179 | dom: HTMLElement
180 | 181 |

The outer DOM element holding the view.

182 |
183 | chunks: readonly Chunk[]
184 | 185 |

The current set of changed chunks.

186 |
187 | reconfigure(configMergeConfig)
188 | 189 |

Reconfigure an existing merge view.

190 |
191 | destroy()
192 | 193 |

Destroy this merge view.

194 |
195 | 196 |
197 |
198 | uncollapseUnchanged: StateEffectType<number>
199 | 200 |

A state effect that expands the section of collapsed unchanged 201 | code starting at the given position.

202 |
203 |
204 |

Unified Merge View

205 |
206 |
207 | unifiedMergeView(config: Object) → Extension[]
208 | 209 |

Create an extension that causes the editor to display changes 210 | between its content and the given original document. Changed 211 | chunks will be highlighted, with uneditable widgets displaying the 212 | original text displayed above the new text.

213 |
214 | config
215 | 216 |
217 | original: Text | string
218 | 219 |

The other document to compare the editor content with.

220 |
221 | highlightChanges⁠?: boolean
222 | 223 |

By default, the merge view will mark inserted and deleted text 224 | in changed chunks. Set this to false to turn that off.

225 |
226 | gutter⁠?: boolean
227 | 228 |

Controls whether a gutter marker is shown next to changed lines.

229 |
230 | syntaxHighlightDeletions⁠?: boolean
231 | 232 |

By default, deleted chunks are highlighted using the main 233 | editor's language. Since these are just fragments, not full 234 | documents, this doesn't always work well. Set this option to 235 | false to disable syntax highlighting for deleted lines.

236 |
237 | syntaxHighlightDeletionsMaxLength⁠?: number
238 | 239 |

Deleted blocks larger than this size do not get 240 | syntax-highlighted. Defaults to 3000.

241 |
242 | mergeControls⁠?: boolean
243 | 244 |

Controls whether accept/reject buttons are displayed for each 245 | changed chunk. Defaults to true.

246 |
247 | diffConfig⁠?: DiffConfig
248 | 249 |

Pass options to the diff algorithm. By default, the merge view 250 | sets scanLimit to 500.

251 |
252 | collapseUnchanged⁠?: {margin⁠?: number, minSize⁠?: number}
253 | 254 |

When given, long stretches of unchanged text are collapsed. 255 | margin gives the number of lines to leave visible after/before 256 | a change (default is 3), and minSize gives the minimum amount 257 | of collapsible lines that need to be present (defaults to 4).

258 |
259 |
260 | acceptChunk(viewEditorView, pos⁠?: number) → boolean
261 | 262 |

In a unified merge view, accept the 263 | chunk under the given position or the cursor. This chunk will no 264 | longer be highlighted unless it is edited again.

265 |
266 |
267 | rejectChunk(viewEditorView, pos⁠?: number) → boolean
268 | 269 |

In a unified merge view, reject the 270 | chunk under the given position or the cursor. Reverts that range 271 | to the content it has in the original document.

272 |
273 |
274 | getOriginalDoc(stateEditorState) → Text
275 | 276 |

Get the original document from a unified merge editor's state.

277 |
278 |
279 | originalDocChangeEffect(stateEditorState, changesChangeSet) → StateEffect<{doc: Text, changes: ChangeSet}>
280 | 281 |

Create an effect that, when added to a transaction on a unified 282 | merge view, will update the original document that's being compared against.

283 |
284 |
285 | updateOriginalDoc: StateEffectType<{doc: Text, changes: ChangeSet}>
286 | 287 |

The state effect used to signal changes in the original doc in a 288 | unified merge view.

289 |
290 |
291 |

Chunks

292 |
293 |
294 |

295 | class 296 | Chunk

297 |
298 | 299 |

A chunk describes a range of lines which have changed content in 300 | them. Either side (a/b) may either be empty (when its to is 301 | equal to its from), or points at a range starting at the start 302 | of the first changed line, to 1 past the end of the last changed 303 | line. Note that to positions may point past the end of the 304 | document. Use endA/endB if you need an end position that is 305 | certain to be a valid document position.

306 |
307 | new Chunk(changes: readonly Change[], fromAnumber, toAnumber, fromBnumber, toBnumber, precise⁠?: boolean = true)
308 | 309 |
310 | changes: readonly Change[]
311 | 312 |

The individual changes inside this chunk. These are stored 313 | relative to the start of the chunk, so you have to add 314 | chunk.fromA/fromB to get document positions.

315 |
316 | fromA: number
317 | 318 |

The start of the chunk in document A.

319 |
320 | toA: number
321 | 322 |

The end of the chunk in document A. This is equal to fromA 323 | when the chunk covers no lines in document A, or is one unit 324 | past the end of the last line in the chunk if it does.

325 |
326 | fromB: number
327 | 328 |

The start of the chunk in document B.

329 |
330 | toB: number
331 | 332 |

The end of the chunk in document A.

333 |
334 | precise: boolean
335 | 336 |

This is set to false when the diff used to compute this chunk 337 | fell back to fast, imprecise diffing.

338 |
339 | endA: number
340 | 341 |

Returns fromA if the chunk is empty in A, or the end of the 342 | last line in the chunk otherwise.

343 |
344 | endB: number
345 | 346 |

Returns fromB if the chunk is empty in B, or the end of the 347 | last line in the chunk otherwise.

348 |
349 | static build(aText, bText, conf⁠?: DiffConfig) → readonly Chunk[]
350 | 351 |

Build a set of changed chunks for the given documents.

352 |
353 | static updateA(chunks: readonly Chunk[], aText, bText, changesChangeDesc, conf⁠?: DiffConfig) → readonly Chunk[]
354 | 355 |

Update a set of chunks for changes in document A. a should 356 | hold the updated document A.

357 |
358 | static updateB(chunks: readonly Chunk[], aText, bText, changesChangeDesc, conf⁠?: DiffConfig) → readonly Chunk[]
359 | 360 |

Update a set of chunks for changes in document B.

361 |
362 | 363 |
364 |
365 | getChunks(stateEditorState) → {chunks: readonly Chunk[], side: "a" | "b" | null} | null
366 | 367 |

Get the changed chunks for the merge view that this editor is part 368 | of, plus the side it is on if it is part of a MergeView. Returns 369 | null if the editor doesn't have a merge extension active or the 370 | merge view hasn't finished initializing yet.

371 |
372 |
373 | goToNextChunk: StateCommand
374 | 375 |

Move the selection to the next changed chunk.

376 |
377 |
378 | goToPreviousChunk: StateCommand
379 | 380 |

Move the selection to the previous changed chunk.

381 |
382 |
383 |

Diffing Utilities

384 |
385 |
386 |

387 | class 388 | Change

389 |
390 | 391 |

A changed range.

392 |
393 | new Change(fromAnumber, toAnumber, fromBnumber, toBnumber)
394 | 395 |
396 | fromA: number
397 | 398 |

The start of the change in document A.

399 |
400 | toA: number
401 | 402 |

The end of the change in document A. This is equal to fromA 403 | in case of insertions.

404 |
405 | fromB: number
406 | 407 |

The start of the change in document B.

408 |
409 | toB: number
410 | 411 |

The end of the change in document B. This is equal to fromB 412 | for deletions.

413 |
414 | 415 |
416 |
417 | diff(astring, bstring, config⁠?: DiffConfig) → readonly Change[]
418 | 419 |

Compute the difference between two strings.

420 |
421 |
422 | presentableDiff(astring, bstring, config⁠?: DiffConfig) → readonly Change[]
423 | 424 |

Compute the difference between the given strings, and clean up the 425 | resulting diff for presentation to users by dropping short 426 | unchanged ranges, and aligning changes to word boundaries when 427 | appropriate.

428 |
429 |
430 |

431 | interface 432 | DiffConfig

433 |
434 | 435 |

Options passed to diffing functions.

436 |
437 | scanLimit⁠?: number
438 | 439 |

When given, this limits the depth of full (expensive) diff 440 | computations, causing them to give up and fall back to a faster 441 | but less precise approach when there is more than this many 442 | changed characters in a scanned range. This should help avoid 443 | quadratic running time on large, very different inputs.

444 |
445 | timeout⁠?: number
446 | 447 |

When set, this makes the algorithm periodically check how long 448 | it has been running, and if it has taken more than the given 449 | number of milliseconds, it aborts detailed diffing in falls back 450 | to the imprecise algorithm.

451 |
452 | 453 |
454 |
455 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codemirror/merge", 3 | "version": "6.10.1", 4 | "description": "A diff/merge view for CodeMirror", 5 | "scripts": { 6 | "test": "cm-runtests", 7 | "prepare": "cm-buildhelper src/index.ts" 8 | }, 9 | "keywords": [ 10 | "editor", 11 | "code", 12 | "diff", 13 | "merge" 14 | ], 15 | "author": { 16 | "name": "Marijn Haverbeke", 17 | "email": "marijn@haverbeke.berlin", 18 | "url": "http://marijnhaverbeke.nl" 19 | }, 20 | "type": "module", 21 | "main": "dist/index.cjs", 22 | "exports": { 23 | "import": "./dist/index.js", 24 | "require": "./dist/index.cjs" 25 | }, 26 | "types": "dist/index.d.ts", 27 | "module": "dist/index.js", 28 | "sideEffects": false, 29 | "license": "MIT", 30 | "dependencies": { 31 | "@codemirror/language": "^6.0.0", 32 | "@codemirror/state": "^6.0.0", 33 | "@codemirror/view": "^6.17.0", 34 | "@lezer/highlight": "^1.0.0", 35 | "style-mod": "^4.1.0" 36 | }, 37 | "devDependencies": { 38 | "@codemirror/buildhelper": "^1.0.0" 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "https://github.com/codemirror/merge.git" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # @codemirror/merge [![NPM version](https://img.shields.io/npm/v/@codemirror/merge.svg)](https://www.npmjs.org/package/@codemirror/merge) 4 | 5 | [ [**WEBSITE**](https://codemirror.net/) | [**ISSUES**](https://github.com/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/c/next/) | [**CHANGELOG**](https://github.com/codemirror/merge/blob/main/CHANGELOG.md) ] 6 | 7 | This package implements a merge interface for the 8 | [CodeMirror](https://codemirror.net/) code editor. 9 | 10 | The [project page](https://codemirror.net/) has more information, a 11 | number of [examples](https://codemirror.net/examples/) and the 12 | [documentation](https://codemirror.net/docs/). 13 | 14 | This code is released under an 15 | [MIT license](https://github.com/codemirror/merge/tree/main/LICENSE). 16 | 17 | We aim to be an inclusive, welcoming community. To make that explicit, 18 | we have a [code of 19 | conduct](http://contributor-covenant.org/version/1/1/0/) that applies 20 | to communication around the project. 21 | 22 | ## Usage 23 | 24 | A split merge view can be created like this: 25 | 26 | ```javascript 27 | import {MergeView} from "@codemirror/merge" 28 | import {EditorView, basicSetup} from "codemirror" 29 | import {EditorState} from "@codemirror/state" 30 | 31 | let doc = `one 32 | two 33 | three 34 | four 35 | five` 36 | 37 | const view = new MergeView({ 38 | a: { 39 | doc, 40 | extensions: basicSetup 41 | }, 42 | b: { 43 | doc: doc.replace(/t/g, "T") + "\nSix", 44 | extensions: [ 45 | basicSetup, 46 | EditorView.editable.of(false), 47 | EditorState.readOnly.of(true) 48 | ] 49 | }, 50 | parent: document.body 51 | }) 52 | ``` 53 | 54 | Or a unified view like this: 55 | 56 | ```javascript 57 | import {EditorView, basicSetup} from "codemirror" 58 | import {unifiedMergeView} from "@codemirror/merge" 59 | 60 | const view = new EditorView({ 61 | parent: document.body, 62 | doc: "one\ntwo\nthree\nfour", 63 | extensions: [ 64 | basicSetup, 65 | unifiedMergeView({ 66 | original: "one\n...\nfour" 67 | }) 68 | ] 69 | }) 70 | ``` 71 | 72 | ## API Reference 73 | 74 | ### Side-by-side Merge View 75 | 76 | @MergeConfig 77 | 78 | @DirectMergeConfig 79 | 80 | @MergeView 81 | 82 | @uncollapseUnchanged 83 | 84 | ### Unified Merge View 85 | 86 | @unifiedMergeView 87 | 88 | @acceptChunk 89 | 90 | @rejectChunk 91 | 92 | @getOriginalDoc 93 | 94 | @originalDocChangeEffect 95 | 96 | @updateOriginalDoc 97 | 98 | ### Chunks 99 | 100 | @Chunk 101 | 102 | @getChunks 103 | 104 | @goToNextChunk 105 | 106 | @goToPreviousChunk 107 | 108 | ### Diffing Utilities 109 | 110 | @Change 111 | 112 | @diff 113 | 114 | @presentableDiff 115 | 116 | @DiffConfig 117 | -------------------------------------------------------------------------------- /src/chunk.ts: -------------------------------------------------------------------------------- 1 | import {Text, ChangeDesc} from "@codemirror/state" 2 | import {Change, presentableDiff, DiffConfig, diffIsPrecise} from "./diff" 3 | 4 | /// A chunk describes a range of lines which have changed content in 5 | /// them. Either side (a/b) may either be empty (when its `to` is 6 | /// equal to its `from`), or points at a range starting at the start 7 | /// of the first changed line, to 1 past the end of the last changed 8 | /// line. Note that `to` positions may point past the end of the 9 | /// document. Use `endA`/`endB` if you need an end position that is 10 | /// certain to be a valid document position. 11 | export class Chunk { 12 | constructor( 13 | /// The individual changes inside this chunk. These are stored 14 | /// relative to the start of the chunk, so you have to add 15 | /// `chunk.fromA`/`fromB` to get document positions. 16 | readonly changes: readonly Change[], 17 | /// The start of the chunk in document A. 18 | readonly fromA: number, 19 | /// The end of the chunk in document A. This is equal to `fromA` 20 | /// when the chunk covers no lines in document A, or is one unit 21 | /// past the end of the last line in the chunk if it does. 22 | readonly toA: number, 23 | /// The start of the chunk in document B. 24 | readonly fromB: number, 25 | /// The end of the chunk in document A. 26 | readonly toB: number, 27 | /// This is set to false when the diff used to compute this chunk 28 | /// fell back to fast, imprecise diffing. 29 | readonly precise = true 30 | ) {} 31 | 32 | /// @internal 33 | offset(offA: number, offB: number) { 34 | return offA || offB 35 | ? new Chunk(this.changes, this.fromA + offA, this.toA + offA, this.fromB + offB, this.toB + offB, this.precise) 36 | : this 37 | } 38 | 39 | /// Returns `fromA` if the chunk is empty in A, or the end of the 40 | /// last line in the chunk otherwise. 41 | get endA() { return Math.max(this.fromA, this.toA - 1) } 42 | /// Returns `fromB` if the chunk is empty in B, or the end of the 43 | /// last line in the chunk otherwise. 44 | get endB() { return Math.max(this.fromB, this.toB - 1) } 45 | 46 | /// Build a set of changed chunks for the given documents. 47 | static build(a: Text, b: Text, conf?: DiffConfig): readonly Chunk[] { 48 | let diff = presentableDiff(a.toString(), b.toString(), conf) 49 | return toChunks(diff, a, b, 0, 0, diffIsPrecise()) 50 | } 51 | 52 | /// Update a set of chunks for changes in document A. `a` should 53 | /// hold the updated document A. 54 | static updateA(chunks: readonly Chunk[], a: Text, b: Text, changes: ChangeDesc, conf?: DiffConfig) { 55 | return updateChunks(findRangesForChange(chunks, changes, true, b.length), chunks, a, b, conf) 56 | } 57 | 58 | /// Update a set of chunks for changes in document B. 59 | static updateB(chunks: readonly Chunk[], a: Text, b: Text, changes: ChangeDesc, conf?: DiffConfig) { 60 | return updateChunks(findRangesForChange(chunks, changes, false, a.length), chunks, a, b, conf) 61 | } 62 | } 63 | 64 | function fromLine(fromA: number, fromB: number, a: Text, b: Text) { 65 | let lineA = a.lineAt(fromA), lineB = b.lineAt(fromB) 66 | return lineA.to == fromA && lineB.to == fromB && fromA < a.length && fromB < b.length 67 | ? [fromA + 1, fromB + 1] : [lineA.from, lineB.from] 68 | } 69 | 70 | function toLine(toA: number, toB: number, a: Text, b: Text) { 71 | let lineA = a.lineAt(toA), lineB = b.lineAt(toB) 72 | return lineA.from == toA && lineB.from == toB ? [toA, toB] : [lineA.to + 1, lineB.to + 1] 73 | } 74 | 75 | function toChunks(changes: readonly Change[], a: Text, b: Text, offA: number, offB: number, precise: boolean) { 76 | let chunks = [] 77 | for (let i = 0; i < changes.length; i++) { 78 | let change = changes[i] 79 | let [fromA, fromB] = fromLine(change.fromA + offA, change.fromB + offB, a, b) 80 | let [toA, toB] = toLine(change.toA + offA, change.toB + offB, a, b) 81 | let chunk = [change.offset(-fromA + offA, -fromB + offB)] 82 | while (i < changes.length - 1) { 83 | let next = changes[i + 1] 84 | let [nextA, nextB] = fromLine(next.fromA + offA, next.fromB + offB, a, b) 85 | if (nextA > toA + 1 && nextB > toB + 1) break 86 | chunk.push(next.offset(-fromA + offA, -fromB + offB)) 87 | ;[toA, toB] = toLine(next.toA + offA, next.toB + offB, a, b) 88 | i++ 89 | } 90 | chunks.push(new Chunk(chunk, fromA, Math.max(fromA, toA), fromB, Math.max(fromB, toB), precise)) 91 | } 92 | return chunks 93 | } 94 | 95 | const updateMargin = 1000 96 | 97 | type UpdateRange = {fromA: number, toA: number, fromB: number, toB: number, diffA: number, diffB: number} 98 | 99 | // Finds the given position in the chunks. Returns the extent of the 100 | // chunk it overlaps with if it overlaps, or a position corresponding 101 | // to that position on both sides otherwise. 102 | function findPos( 103 | chunks: readonly Chunk[], pos: number, isA: boolean, start: boolean 104 | ): [number, number] { 105 | let lo = 0, hi = chunks.length 106 | for (;;) { 107 | if (lo == hi) { 108 | let refA = 0, refB = 0 109 | if (lo) ({toA: refA, toB: refB} = chunks[lo - 1]) 110 | let off = pos - (isA ? refA : refB) 111 | return [refA + off, refB + off] 112 | } 113 | let mid = (lo + hi) >> 1, chunk = chunks[mid] 114 | let [from, to] = isA ? [chunk.fromA, chunk.toA] : [chunk.fromB, chunk.toB] 115 | if (from > pos) hi = mid 116 | else if (to <= pos) lo = mid + 1 117 | else return start ? [chunk.fromA, chunk.fromB] : [chunk.toA, chunk.toB] 118 | } 119 | } 120 | 121 | function findRangesForChange(chunks: readonly Chunk[], changes: ChangeDesc, isA: boolean, otherLen: number) { 122 | let ranges: UpdateRange[] = [] 123 | changes.iterChangedRanges((cFromA, cToA, cFromB, cToB) => { 124 | let fromA = 0, toA = isA ? changes.length : otherLen 125 | let fromB = 0, toB = isA ? otherLen : changes.length 126 | if (cFromA > updateMargin) 127 | [fromA, fromB] = findPos(chunks, cFromA - updateMargin, isA, true) 128 | if (cToA < changes.length - updateMargin) 129 | [toA, toB] = findPos(chunks, cToA + updateMargin, isA, false) 130 | let lenDiff = (cToB - cFromB) - (cToA - cFromA), last 131 | let [diffA, diffB] = isA ? [lenDiff, 0] : [0, lenDiff] 132 | if (ranges.length && (last = ranges[ranges.length - 1]).toA >= fromA) 133 | ranges[ranges.length - 1] = {fromA: last.fromA, fromB: last.fromB, toA, toB, 134 | diffA: last.diffA + diffA, diffB: last.diffB + diffB} 135 | else 136 | ranges.push({fromA, toA, fromB, toB, diffA, diffB}) 137 | }) 138 | return ranges 139 | } 140 | 141 | function updateChunks(ranges: readonly UpdateRange[], chunks: readonly Chunk[], 142 | a: Text, b: Text, conf?: DiffConfig): readonly Chunk[] { 143 | if (!ranges.length) return chunks 144 | let result = [] 145 | for (let i = 0, offA = 0, offB = 0, chunkI = 0;; i++) { 146 | let range = i == ranges.length ? null : ranges[i] 147 | let fromA = range ? range.fromA + offA : a.length, fromB = range ? range.fromB + offB : b.length 148 | while (chunkI < chunks.length) { 149 | let next = chunks[chunkI] 150 | if (next.toA + offA > fromA || next.toB + offB > fromB) break 151 | result.push(next.offset(offA, offB)) 152 | chunkI++ 153 | } 154 | if (!range) break 155 | let toA = range.toA + offA + range.diffA, toB = range.toB + offB + range.diffB 156 | let diff = presentableDiff(a.sliceString(fromA, toA), b.sliceString(fromB, toB), conf) 157 | for (let chunk of toChunks(diff, a, b, fromA, fromB, diffIsPrecise())) result.push(chunk) 158 | offA += range.diffA 159 | offB += range.diffB 160 | while (chunkI < chunks.length) { 161 | let next = chunks[chunkI] 162 | if (next.fromA + offA > toA && next.fromB + offB > toB) break 163 | chunkI++ 164 | } 165 | } 166 | return result 167 | } 168 | 169 | export const defaultDiffConfig = {scanLimit: 500} 170 | -------------------------------------------------------------------------------- /src/deco.ts: -------------------------------------------------------------------------------- 1 | import {EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate, 2 | WidgetType, GutterMarker, gutter} from "@codemirror/view" 3 | import {EditorState, RangeSetBuilder, Text, StateField, StateEffect, RangeSet, Prec} from "@codemirror/state" 4 | import {Chunk} from "./chunk" 5 | import {ChunkField, mergeConfig} from "./merge" 6 | 7 | export const decorateChunks = ViewPlugin.fromClass(class { 8 | deco: DecorationSet 9 | gutter: RangeSet | null 10 | 11 | constructor(view: EditorView) { 12 | ({deco: this.deco, gutter: this.gutter} = getChunkDeco(view)) 13 | } 14 | 15 | update(update: ViewUpdate) { 16 | if (update.docChanged || update.viewportChanged || chunksChanged(update.startState, update.state) || 17 | configChanged(update.startState, update.state)) 18 | ({deco: this.deco, gutter: this.gutter} = getChunkDeco(update.view)) 19 | } 20 | }, { 21 | decorations: d => d.deco 22 | }) 23 | 24 | export const changeGutter = Prec.low(gutter({ 25 | class: "cm-changeGutter", 26 | markers: view => view.plugin(decorateChunks)?.gutter || RangeSet.empty 27 | })) 28 | 29 | function chunksChanged(s1: EditorState, s2: EditorState) { 30 | return s1.field(ChunkField, false) != s2.field(ChunkField, false) 31 | } 32 | 33 | function configChanged(s1: EditorState, s2: EditorState) { 34 | return s1.facet(mergeConfig) != s2.facet(mergeConfig) 35 | } 36 | 37 | const changedLine = Decoration.line({class: "cm-changedLine"}) 38 | export const changedText = Decoration.mark({class: "cm-changedText"}) 39 | const inserted = Decoration.mark({tagName: "ins", class: "cm-insertedLine"}) 40 | const deleted = Decoration.mark({tagName: "del", class: "cm-deletedLine"}) 41 | 42 | const changedLineGutterMarker = new class extends GutterMarker { 43 | elementClass = "cm-changedLineGutter" 44 | } 45 | 46 | function buildChunkDeco(chunk: Chunk, doc: Text, isA: boolean, highlight: boolean, 47 | builder: RangeSetBuilder, 48 | gutterBuilder: RangeSetBuilder | null) { 49 | let from = isA ? chunk.fromA : chunk.fromB, to = isA ? chunk.toA : chunk.toB 50 | let changeI = 0 51 | if (from != to) { 52 | builder.add(from, from, changedLine) 53 | builder.add(from, to, isA ? deleted : inserted) 54 | if (gutterBuilder) gutterBuilder.add(from, from, changedLineGutterMarker) 55 | for (let iter = doc.iterRange(from, to - 1), pos = from; !iter.next().done;) { 56 | if (iter.lineBreak) { 57 | pos++ 58 | builder.add(pos, pos, changedLine) 59 | if (gutterBuilder) gutterBuilder.add(pos, pos, changedLineGutterMarker) 60 | continue 61 | } 62 | let lineEnd = pos + iter.value.length 63 | if (highlight) while (changeI < chunk.changes.length) { 64 | let nextChange = chunk.changes[changeI] 65 | let nextFrom = from + (isA ? nextChange.fromA : nextChange.fromB) 66 | let nextTo = from + (isA ? nextChange.toA : nextChange.toB) 67 | let chFrom = Math.max(pos, nextFrom), chTo = Math.min(lineEnd, nextTo) 68 | if (chFrom < chTo) builder.add(chFrom, chTo, changedText) 69 | if (nextTo < lineEnd) changeI++ 70 | else break 71 | } 72 | pos = lineEnd 73 | } 74 | } 75 | } 76 | 77 | function getChunkDeco(view: EditorView) { 78 | let chunks = view.state.field(ChunkField) 79 | let {side, highlightChanges, markGutter, overrideChunk} = view.state.facet(mergeConfig), isA = side == "a" 80 | let builder = new RangeSetBuilder() 81 | let gutterBuilder = markGutter ? new RangeSetBuilder() : null 82 | let {from, to} = view.viewport 83 | for (let chunk of chunks) { 84 | if ((isA ? chunk.fromA : chunk.fromB) >= to) break 85 | if ((isA ? chunk.toA : chunk.toB) > from) { 86 | if (!overrideChunk || !overrideChunk(view.state, chunk, builder, gutterBuilder)) 87 | buildChunkDeco(chunk, view.state.doc, isA, highlightChanges, builder, gutterBuilder) 88 | } 89 | } 90 | return {deco: builder.finish(), gutter: gutterBuilder && gutterBuilder.finish()} 91 | } 92 | 93 | class Spacer extends WidgetType { 94 | constructor(readonly height: number) { super() } 95 | 96 | eq(other: Spacer) { return this.height == other.height } 97 | 98 | toDOM() { 99 | let elt = document.createElement("div") 100 | elt.className = "cm-mergeSpacer" 101 | elt.style.height = this.height + "px" 102 | return elt 103 | } 104 | 105 | updateDOM(dom: HTMLElement) { 106 | dom.style.height = this.height + "px" 107 | return true 108 | } 109 | 110 | get estimatedHeight() { return this.height } 111 | 112 | ignoreEvent() { return false } 113 | } 114 | 115 | export const adjustSpacers = StateEffect.define({ 116 | map: (value, mapping) => value.map(mapping) 117 | }) 118 | 119 | export const Spacers = StateField.define({ 120 | create: () => Decoration.none, 121 | update: (spacers, tr) => { 122 | for (let e of tr.effects) if (e.is(adjustSpacers)) return e.value 123 | return spacers.map(tr.changes) 124 | }, 125 | provide: f => EditorView.decorations.from(f) 126 | }) 127 | 128 | const epsilon = .01 129 | 130 | function compareSpacers(a: DecorationSet, b: DecorationSet) { 131 | if (a.size != b.size) return false 132 | let iA = a.iter(), iB = b.iter() 133 | while (iA.value) { 134 | if (iA.from != iB.from || 135 | Math.abs((iA.value.spec.widget as Spacer).height - (iB.value!.spec.widget as Spacer).height) > 1) 136 | return false 137 | iA.next(); iB.next() 138 | } 139 | return true 140 | } 141 | 142 | export function updateSpacers(a: EditorView, b: EditorView, chunks: readonly Chunk[]) { 143 | let buildA = new RangeSetBuilder(), buildB = new RangeSetBuilder() 144 | let spacersA = a.state.field(Spacers).iter(), spacersB = b.state.field(Spacers).iter() 145 | let posA = 0, posB = 0, offA = 0, offB = 0, vpA = a.viewport, vpB = b.viewport 146 | chunks: for (let chunkI = 0;; chunkI++) { 147 | let chunk = chunkI < chunks.length ? chunks[chunkI] : null 148 | let endA = chunk ? chunk.fromA : a.state.doc.length, endB = chunk ? chunk.fromB : b.state.doc.length 149 | // A range at posA/posB is unchanged, must be aligned. 150 | if (posA < endA) { 151 | let heightA = a.lineBlockAt(posA).top + offA 152 | let heightB = b.lineBlockAt(posB).top + offB 153 | let diff = heightA - heightB 154 | if (diff < -epsilon) { 155 | offA -= diff 156 | buildA.add(posA, posA, Decoration.widget({ 157 | widget: new Spacer(-diff), 158 | block: true, 159 | side: -1 160 | })) 161 | } else if (diff > epsilon) { 162 | offB += diff 163 | buildB.add(posB, posB, Decoration.widget({ 164 | widget: new Spacer(diff), 165 | block: true, 166 | side: -1 167 | })) 168 | } 169 | } 170 | // If the viewport starts inside the unchanged range (on both 171 | // sides), add another sync at the top of the viewport. That way, 172 | // big unchanged chunks with possibly inaccurate estimated heights 173 | // won't cause the content to misalign (#1408) 174 | if (endA > posA + 1000 && posA < vpA.from && endA > vpA.from && posB < vpB.from && endB > vpB.from) { 175 | let off = Math.min(vpA.from - posA, vpB.from - posB) 176 | posA += off; posB += off 177 | chunkI-- 178 | } else if (!chunk) { 179 | break 180 | } else { 181 | posA = chunk.toA; posB = chunk.toB 182 | } 183 | while (spacersA.value && spacersA.from < posA) { 184 | offA -= (spacersA.value.spec.widget as Spacer).height 185 | spacersA.next() 186 | } 187 | while (spacersB.value && spacersB.from < posB) { 188 | offB -= (spacersB.value.spec.widget as Spacer).height 189 | spacersB.next() 190 | } 191 | } 192 | while (spacersA.value) { 193 | offA -= (spacersA.value.spec.widget as any).height 194 | spacersA.next() 195 | } 196 | while (spacersB.value) { 197 | offB -= (spacersB.value.spec.widget as any).height 198 | spacersB.next() 199 | } 200 | let docDiff = (a.contentHeight + offA) - (b.contentHeight + offB) 201 | if (docDiff < epsilon) { 202 | buildA.add(a.state.doc.length, a.state.doc.length, Decoration.widget({ 203 | widget: new Spacer(-docDiff), 204 | block: true, 205 | side: 1 206 | })) 207 | } else if (docDiff > epsilon) { 208 | buildB.add(b.state.doc.length, b.state.doc.length, Decoration.widget({ 209 | widget: new Spacer(docDiff), 210 | block: true, 211 | side: 1 212 | })) 213 | } 214 | 215 | let decoA = buildA.finish(), decoB = buildB.finish() 216 | if (!compareSpacers(decoA, a.state.field(Spacers))) 217 | a.dispatch({effects: adjustSpacers.of(decoA)}) 218 | if (!compareSpacers(decoB, b.state.field(Spacers))) 219 | b.dispatch({effects: adjustSpacers.of(decoB)}) 220 | } 221 | 222 | /// A state effect that expands the section of collapsed unchanged 223 | /// code starting at the given position. 224 | export const uncollapseUnchanged = StateEffect.define({ 225 | map: (value, change) => change.mapPos(value) 226 | }) 227 | 228 | class CollapseWidget extends WidgetType { 229 | constructor(readonly lines: number) { super() } 230 | 231 | eq(other: CollapseWidget) { return this.lines == other.lines } 232 | 233 | toDOM(view: EditorView) { 234 | let outer = document.createElement("div") 235 | outer.className = "cm-collapsedLines" 236 | outer.textContent = view.state.phrase("$ unchanged lines", this.lines) 237 | outer.addEventListener("click", e => { 238 | let pos = view.posAtDOM(e.target as HTMLElement) 239 | view.dispatch({effects: uncollapseUnchanged.of(pos)}) 240 | let {side, sibling} = view.state.facet(mergeConfig) 241 | if (sibling) sibling().dispatch({effects: uncollapseUnchanged.of(mapPos(pos, view.state.field(ChunkField), side == "a"))}) 242 | }) 243 | return outer 244 | } 245 | 246 | ignoreEvent(e: Event) { return e instanceof MouseEvent } 247 | 248 | get estimatedHeight() { return 27 } 249 | 250 | get type() { return "collapsed-unchanged-code" } 251 | } 252 | 253 | function mapPos(pos: number, chunks: readonly Chunk[], isA: boolean) { 254 | let startOur = 0, startOther = 0 255 | for (let i = 0;; i++) { 256 | let next = i < chunks.length ? chunks[i] : null 257 | if (!next || (isA ? next.fromA : next.fromB) >= pos) return startOther + (pos - startOur) 258 | ;[startOur, startOther] = isA ? [next.toA, next.toB] : [next.toB, next.toA] 259 | } 260 | } 261 | 262 | const CollapsedRanges = StateField.define({ 263 | create(state) { return Decoration.none }, 264 | update(deco, tr) { 265 | deco = deco.map(tr.changes) 266 | for (let e of tr.effects) if (e.is(uncollapseUnchanged)) 267 | deco = deco.update({filter: from => from != e.value}) 268 | return deco 269 | }, 270 | provide: f => EditorView.decorations.from(f) 271 | }) 272 | 273 | export function collapseUnchanged({margin = 3, minSize = 4}: {margin?: number, minSize?: number}) { 274 | return CollapsedRanges.init(state => buildCollapsedRanges(state, margin, minSize)) 275 | } 276 | 277 | function buildCollapsedRanges(state: EditorState, margin: number, minLines: number) { 278 | let builder = new RangeSetBuilder() 279 | let isA = state.facet(mergeConfig).side == "a" 280 | let chunks = state.field(ChunkField) 281 | let prevLine = 1 282 | for (let i = 0;; i++) { 283 | let chunk = i < chunks.length ? chunks[i] : null 284 | let collapseFrom = i ? prevLine + margin : 1 285 | let collapseTo = chunk ? state.doc.lineAt(isA ? chunk.fromA : chunk.fromB).number - 1 - margin : state.doc.lines 286 | let lines = collapseTo - collapseFrom + 1 287 | if (lines >= minLines) { 288 | builder.add(state.doc.line(collapseFrom).from, state.doc.line(collapseTo).to, Decoration.replace({ 289 | widget: new CollapseWidget(lines), 290 | block: true 291 | })) 292 | } 293 | if (!chunk) break 294 | prevLine = state.doc.lineAt(Math.min(state.doc.length, isA ? chunk.toA : chunk.toB)).number 295 | } 296 | return builder.finish() 297 | } 298 | -------------------------------------------------------------------------------- /src/diff.ts: -------------------------------------------------------------------------------- 1 | // This algorithm was heavily inspired by Neil Fraser's 2 | // diff-match-patch library. See https://github.com/google/diff-match-patch/ 3 | 4 | /// A changed range. 5 | export class Change { 6 | constructor( 7 | /// The start of the change in document A. 8 | readonly fromA: number, 9 | /// The end of the change in document A. This is equal to `fromA` 10 | /// in case of insertions. 11 | readonly toA: number, 12 | /// The start of the change in document B. 13 | readonly fromB: number, 14 | /// The end of the change in document B. This is equal to `fromB` 15 | /// for deletions. 16 | readonly toB: number 17 | ) {} 18 | 19 | /// @internal 20 | offset(offA: number, offB: number = offA) { 21 | return new Change(this.fromA + offA, this.toA + offA, this.fromB + offB, this.toB + offB) 22 | } 23 | } 24 | 25 | function findDiff(a: string, fromA: number, toA: number, b: string, fromB: number, toB: number): Change[] { 26 | if (a == b) return [] 27 | 28 | // Remove identical prefix and suffix 29 | let prefix = commonPrefix(a, fromA, toA, b, fromB, toB) 30 | let suffix = commonSuffix(a, fromA + prefix, toA, b, fromB + prefix, toB) 31 | fromA += prefix; toA -= suffix 32 | fromB += prefix; toB -= suffix 33 | let lenA = toA - fromA, lenB = toB - fromB 34 | // Nothing left in one of them 35 | if (!lenA || !lenB) return [new Change(fromA, toA, fromB, toB)] 36 | 37 | // Try to find one string in the other to cover cases with just 2 38 | // deletions/insertions. 39 | if (lenA > lenB) { 40 | let found = a.slice(fromA, toA).indexOf(b.slice(fromB, toB)) 41 | if (found > -1) return [ 42 | new Change(fromA, fromA + found, fromB, fromB), 43 | new Change(fromA + found + lenB, toA, toB, toB) 44 | ] 45 | } else if (lenB > lenA) { 46 | let found = b.slice(fromB, toB).indexOf(a.slice(fromA, toA)) 47 | if (found > -1) return [ 48 | new Change(fromA, fromA, fromB, fromB + found), 49 | new Change(toA, toA, fromB + found + lenA, toB) 50 | ] 51 | } 52 | 53 | // Only one character left on one side, does not occur in other 54 | // string. 55 | if (lenA == 1 || lenB == 1) return [new Change(fromA, toA, fromB, toB)] 56 | 57 | // Try to split the problem in two by finding a substring of one of 58 | // the strings in the other. 59 | let half = halfMatch(a, fromA, toA, b, fromB, toB) 60 | if (half) { 61 | let [sharedA, sharedB, sharedLen] = half 62 | return findDiff(a, fromA, sharedA, b, fromB, sharedB) 63 | .concat(findDiff(a, sharedA + sharedLen, toA, b, sharedB + sharedLen, toB)) 64 | } 65 | 66 | // Fall back to more expensive general search for a shared 67 | // subsequence. 68 | return findSnake(a, fromA, toA, b, fromB, toB) 69 | } 70 | 71 | let scanLimit = 1e9 72 | let timeout = 0 73 | let crude = false 74 | 75 | // Implementation of Myers 1986 "An O(ND) Difference Algorithm and Its Variations" 76 | function findSnake(a: string, fromA: number, toA: number, b: string, fromB: number, toB: number): Change[] { 77 | let lenA = toA - fromA, lenB = toB - fromB 78 | if (scanLimit < 1e9 && Math.min(lenA, lenB) > scanLimit * 16 || 79 | timeout > 0 && Date.now() > timeout) { 80 | if (Math.min(lenA, lenB) > scanLimit * 64) return [new Change(fromA, toA, fromB, toB)] 81 | return crudeMatch(a, fromA, toA, b, fromB, toB) 82 | } 83 | let off = Math.ceil((lenA + lenB) / 2) 84 | frontier1.reset(off) 85 | frontier2.reset(off) 86 | let match1 = (x: number, y: number) => a.charCodeAt(fromA + x) == b.charCodeAt(fromB + y) 87 | let match2 = (x: number, y: number) => a.charCodeAt(toA - x - 1) == b.charCodeAt(toB - y - 1) 88 | let test1 = (lenA - lenB) % 2 != 0 ? frontier2 : null, test2 = test1 ? null : frontier1 89 | for (let depth = 0; depth < off; depth++) { 90 | if (depth > scanLimit || timeout > 0 && !(depth & 63) && Date.now() > timeout) 91 | return crudeMatch(a, fromA, toA, b, fromB, toB) 92 | let done = frontier1.advance(depth, lenA, lenB, off, test1, false, match1) || 93 | frontier2.advance(depth, lenA, lenB, off, test2, true, match2) 94 | if (done) return bisect(a, fromA, toA, fromA + done[0], b, fromB, toB, fromB + done[1]) 95 | } 96 | // No commonality at all. 97 | return [new Change(fromA, toA, fromB, toB)] 98 | } 99 | 100 | class Frontier { 101 | vec: number[] = [] 102 | declare len: number 103 | declare start: number 104 | declare end: number 105 | 106 | reset(off: number) { 107 | this.len = off << 1 108 | for (let i = 0; i < this.len; i++) this.vec[i] = -1 109 | this.vec[off + 1] = 0 110 | this.start = this.end = 0 111 | } 112 | 113 | advance(depth: number, lenX: number, lenY: number, vOff: number, other: Frontier | null, 114 | fromBack: boolean, match: (a: number, b: number) => boolean) { 115 | for (let k = -depth + this.start; k <= depth - this.end; k += 2) { 116 | let off = vOff + k 117 | let x = k == -depth || (k != depth && this.vec[off - 1] < this.vec[off + 1]) 118 | ? this.vec[off + 1] : this.vec[off - 1] + 1 119 | let y = x - k 120 | while (x < lenX && y < lenY && match(x, y)) { x++; y++ } 121 | this.vec[off] = x 122 | if (x > lenX) { 123 | this.end += 2 124 | } else if (y > lenY) { 125 | this.start += 2 126 | } else if (other) { 127 | let offOther = vOff + (lenX - lenY) - k 128 | if (offOther >= 0 && offOther < this.len && other.vec[offOther] != -1) { 129 | if (!fromBack) { 130 | let xOther = lenX - other.vec[offOther] 131 | if (x >= xOther) return [x, y] 132 | } else { 133 | let xOther = other.vec[offOther] 134 | if (xOther >= lenX - x) return [xOther, vOff + xOther - offOther] 135 | } 136 | } 137 | } 138 | } 139 | return null 140 | } 141 | } 142 | 143 | // Reused across calls to avoid growing the vectors again and again 144 | const frontier1 = new Frontier, frontier2 = new Frontier 145 | 146 | // Given a position in both strings, recursively call `findDiff` with 147 | // the sub-problems before and after that position. Make sure cut 148 | // points lie on character boundaries. 149 | function bisect(a: string, fromA: number, toA: number, splitA: number, 150 | b: string, fromB: number, toB: number, splitB: number) { 151 | let stop = false 152 | if (!validIndex(a, splitA) && ++splitA == toA) stop = true 153 | if (!validIndex(b, splitB) && ++splitB == toB) stop = true 154 | if (stop) return [new Change(fromA, toA, fromB, toB)] 155 | return findDiff(a, fromA, splitA, b, fromB, splitB).concat(findDiff(a, splitA, toA, b, splitB, toB)) 156 | } 157 | 158 | function chunkSize(lenA: number, lenB: number) { 159 | let size = 1, max = Math.min(lenA, lenB) 160 | while (size < max) size = size << 1 161 | return size 162 | } 163 | 164 | // Common prefix length of the given ranges. Because string comparison 165 | // is so much faster than a JavaScript by-character loop, this 166 | // compares whole chunks at a time. 167 | function commonPrefix(a: string, fromA: number, toA: number, b: string, fromB: number, toB: number): number { 168 | if (fromA == toA || fromA == toB || a.charCodeAt(fromA) != b.charCodeAt(fromB)) return 0 169 | let chunk = chunkSize(toA - fromA, toB - fromB) 170 | for (let pA = fromA, pB = fromB;;) { 171 | let endA = pA + chunk, endB = pB + chunk 172 | if (endA > toA || endB > toB || a.slice(pA, endA) != b.slice(pB, endB)) { 173 | if (chunk == 1) return pA - fromA - (validIndex(a, pA) ? 0 : 1) 174 | chunk = chunk >> 1 175 | } else if (endA == toA || endB == toB) { 176 | return endA - fromA 177 | } else { 178 | pA = endA; pB = endB 179 | } 180 | } 181 | } 182 | 183 | // Common suffix length 184 | function commonSuffix(a: string, fromA: number, toA: number, b: string, fromB: number, toB: number): number { 185 | if (fromA == toA || fromB == toB || a.charCodeAt(toA - 1) != b.charCodeAt(toB - 1)) return 0 186 | let chunk = chunkSize(toA - fromA, toB - fromB) 187 | for (let pA = toA, pB = toB;;) { 188 | let sA = pA - chunk, sB = pB - chunk 189 | if (sA < fromA || sB < fromB || a.slice(sA, pA) != b.slice(sB, pB)) { 190 | if (chunk == 1) return toA - pA - (validIndex(a, pA) ? 0 : 1) 191 | chunk = chunk >> 1 192 | } else if (sA == fromA || sB == fromB) { 193 | return toA - sA 194 | } else { 195 | pA = sA; pB = sB 196 | } 197 | } 198 | } 199 | 200 | // a assumed to be be longer than b 201 | function findMatch( 202 | a: string, fromA: number, toA: number, b: string, fromB: number, toB: number, 203 | size: number, divideTo: number 204 | ): [number, number, number] | null { 205 | let rangeB = b.slice(fromB, toB) 206 | 207 | // Try some substrings of A of length `size` and see if they exist 208 | // in B. 209 | let best: [number, number, number] | null = null 210 | for (;;) { 211 | if (best || size < divideTo) return best 212 | for (let start = fromA + size;;) { 213 | if (!validIndex(a, start)) start++ 214 | let end = start + size 215 | if (!validIndex(a, end)) end += end == start + 1 ? 1 : -1 216 | if (end >= toA) break 217 | let seed = a.slice(start, end) 218 | let found = -1 219 | while ((found = rangeB.indexOf(seed, found + 1)) != -1) { 220 | let prefixAfter = commonPrefix(a, end, toA, b, fromB + found + seed.length, toB) 221 | let suffixBefore = commonSuffix(a, fromA, start, b, fromB, fromB + found) 222 | let length = seed.length + prefixAfter + suffixBefore 223 | if (!best || best[2] < length) best = [start - suffixBefore, fromB + found - suffixBefore, length] 224 | } 225 | start = end 226 | } 227 | if (divideTo < 0) return best 228 | size = size >> 1 229 | } 230 | } 231 | 232 | // Find a shared substring that is at least half the length of the 233 | // longer range. Returns an array describing the substring [startA, 234 | // startB, len], or null. 235 | function halfMatch( 236 | a: string, fromA: number, toA: number, b: string, fromB: number, toB: number 237 | ): [number, number, number] | null { 238 | let lenA = toA - fromA, lenB = toB - fromB 239 | if (lenA < lenB) { 240 | let result = halfMatch(b, fromB, toB, a, fromA, toA) 241 | return result && [result[1], result[0], result[2]] 242 | } 243 | // From here a is known to be at least as long as b 244 | if (lenA < 4 || lenB * 2 < lenA) return null 245 | return findMatch(a, fromA, toA, b, fromB, toB, Math.floor(lenA / 4), -1) 246 | } 247 | 248 | function crudeMatch( 249 | a: string, fromA: number, toA: number, b: string, fromB: number, toB: number 250 | ): Change[] { 251 | crude = true 252 | let lenA = toA - fromA, lenB = toB - fromB 253 | let result 254 | if (lenA < lenB) { 255 | let inv = findMatch(b, fromB, toB, a, fromA, toA, Math.floor(lenA / 6), 50) 256 | result = inv && [inv[1], inv[0], inv[2]] 257 | } else { 258 | result = findMatch(a, fromA, toA, b, fromB, toB, Math.floor(lenB / 6), 50) 259 | } 260 | if (!result) return [new Change(fromA, toA, fromB, toB)] 261 | let [sharedA, sharedB, sharedLen] = result 262 | return findDiff(a, fromA, sharedA, b, fromB, sharedB) 263 | .concat(findDiff(a, sharedA + sharedLen, toA, b, sharedB + sharedLen, toB)) 264 | } 265 | 266 | function mergeAdjacent(changes: Change[], minGap: number) { 267 | for (let i = 1; i < changes.length; i++) { 268 | let prev = changes[i - 1], cur = changes[i] 269 | if (prev.toA > cur.fromA - minGap && prev.toB > cur.fromB - minGap) { 270 | changes[i - 1] = new Change(prev.fromA, cur.toA, prev.fromB, cur.toB) 271 | changes.splice(i--, 1) 272 | } 273 | } 274 | } 275 | 276 | // Reorder and merge changes 277 | function normalize(a: string, b: string, changes: Change[]) { 278 | for (;;) { 279 | mergeAdjacent(changes, 1) 280 | let moved = false 281 | // Move unchanged ranges that can be fully moved across an 282 | // adjacent insertion/deletion, to simplify the diff. 283 | for (let i = 0; i < changes.length; i++) { 284 | let ch = changes[i], pre, post 285 | // The half-match heuristic sometimes produces non-minimal 286 | // diffs. Strip matching pre- and post-fixes again here. 287 | if (pre = commonPrefix(a, ch.fromA, ch.toA, b, ch.fromB, ch.toB)) 288 | ch = changes[i] = new Change(ch.fromA + pre, ch.toA, ch.fromB + pre, ch.toB) 289 | if (post = commonSuffix(a, ch.fromA, ch.toA, b, ch.fromB, ch.toB)) 290 | ch = changes[i] = new Change(ch.fromA, ch.toA - post, ch.fromB, ch.toB - post) 291 | let lenA = ch.toA - ch.fromA, lenB = ch.toB - ch.fromB 292 | // Only look at plain insertions/deletions 293 | if (lenA && lenB) continue 294 | let beforeLen = ch.fromA - (i ? changes[i - 1].toA : 0) 295 | let afterLen = (i < changes.length - 1 ? changes[i + 1].fromA : a.length) - ch.toA 296 | if (!beforeLen || !afterLen) continue 297 | let text = lenA ? a.slice(ch.fromA, ch.toA) : b.slice(ch.fromB, ch.toB) 298 | if (beforeLen <= text.length && 299 | a.slice(ch.fromA - beforeLen, ch.fromA) == text.slice(text.length - beforeLen)) { 300 | // Text before matches the end of the change 301 | changes[i] = new Change(ch.fromA - beforeLen, ch.toA - beforeLen, ch.fromB - beforeLen, ch.toB - beforeLen) 302 | moved = true 303 | } else if (afterLen <= text.length && 304 | a.slice(ch.toA, ch.toA + afterLen) == text.slice(0, afterLen)) { 305 | // Text after matches the start of the change 306 | changes[i] = new Change(ch.fromA + afterLen, ch.toA + afterLen, ch.fromB + afterLen, ch.toB + afterLen) 307 | moved = true 308 | } 309 | } 310 | if (!moved) break 311 | } 312 | return changes 313 | } 314 | 315 | // Process a change set to make it suitable for presenting to users. 316 | function makePresentable(changes: Change[], a: string, b: string) { 317 | for (let posA = 0, i = 0; i < changes.length; i++) { 318 | let change = changes[i] 319 | let lenA = change.toA - change.fromA, lenB = change.toB - change.fromB 320 | // Don't touch short insertions or deletions. 321 | if (lenA && lenB || lenA > 3 || lenB > 3) { 322 | let nextChangeA = i == changes.length - 1 ? a.length : changes[i + 1].fromA 323 | let maxScanBefore = change.fromA - posA, maxScanAfter = nextChangeA - change.toA 324 | let boundBefore = findWordBoundaryBefore(a, change.fromA, maxScanBefore) 325 | let boundAfter = findWordBoundaryAfter(a, change.toA, maxScanAfter) 326 | let lenBefore = change.fromA - boundBefore, lenAfter = boundAfter - change.toA 327 | // An insertion or deletion that falls inside words on both 328 | // sides can maybe be moved to align with word boundaries. 329 | if ((!lenA || !lenB) && lenBefore && lenAfter) { 330 | let changeLen = Math.max(lenA, lenB) 331 | let [changeText, changeFrom, changeTo] = lenA ? [a, change.fromA, change.toA] : [b, change.fromB, change.toB] 332 | if (changeLen > lenBefore && 333 | a.slice(boundBefore, change.fromA) == changeText.slice(changeTo - lenBefore, changeTo)) { 334 | change = changes[i] = new Change(boundBefore, boundBefore + lenA, change.fromB - lenBefore, change.toB - lenBefore) 335 | boundBefore = change.fromA 336 | boundAfter = findWordBoundaryAfter(a, change.toA, nextChangeA - change.toA) 337 | } else if (changeLen > lenAfter && 338 | a.slice(change.toA, boundAfter) == changeText.slice(changeFrom, changeFrom + lenAfter)) { 339 | change = changes[i] = new Change(boundAfter - lenA, boundAfter, change.fromB + lenAfter, change.toB + lenAfter) 340 | boundAfter = change.toA 341 | boundBefore = findWordBoundaryBefore(a, change.fromA, change.fromA - posA) 342 | } 343 | lenBefore = change.fromA - boundBefore; lenAfter = boundAfter - change.toA 344 | } 345 | if (lenBefore || lenAfter) { 346 | // Expand the change to cover the entire word 347 | change = changes[i] = new Change(change.fromA - lenBefore, change.toA + lenAfter, 348 | change.fromB - lenBefore, change.toB + lenAfter) 349 | } else if (!lenA) { 350 | // Align insertion to line boundary, when possible 351 | let first = findLineBreakAfter(b, change.fromB, change.toB), len 352 | let last = first < 0 ? -1 : findLineBreakBefore(b, change.toB, change.fromB) 353 | if (first > -1 && (len = first - change.fromB) <= maxScanAfter && 354 | b.slice(change.fromB, first) == b.slice(change.toB, change.toB + len)) 355 | change = changes[i] = change.offset(len) 356 | else if (last > -1 && (len = change.toB - last) <= maxScanBefore && 357 | b.slice(change.fromB - len, change.fromB) == b.slice(last, change.toB)) 358 | change = changes[i] = change.offset(-len) 359 | } else if (!lenB) { 360 | // Align deletion to line boundary 361 | let first = findLineBreakAfter(a, change.fromA, change.toA), len 362 | let last = first < 0 ? -1 : findLineBreakBefore(a, change.toA, change.fromA) 363 | if (first > -1 && (len = first - change.fromA) <= maxScanAfter && 364 | a.slice(change.fromA, first) == a.slice(change.toA, change.toA + len)) 365 | change = changes[i] = change.offset(len) 366 | else if (last > -1 && (len = change.toA - last) <= maxScanBefore && 367 | a.slice(change.fromA - len, change.fromA) == a.slice(last, change.toA)) 368 | change = changes[i] = change.offset(-len) 369 | } 370 | } 371 | posA = change.toA 372 | } 373 | 374 | mergeAdjacent(changes, 3) 375 | return changes 376 | } 377 | 378 | let wordChar: RegExp | null 379 | try { wordChar = new RegExp("[\\p{Alphabetic}\\p{Number}]", "u") } catch (_) {} 380 | 381 | function asciiWordChar(code: number) { 382 | return code > 48 && code < 58 || code > 64 && code < 91 || code > 96 && code < 123 383 | } 384 | 385 | function wordCharAfter(s: string, pos: number) { 386 | if (pos == s.length) return 0 387 | let next = s.charCodeAt(pos) 388 | if (next < 192) return asciiWordChar(next) ? 1 : 0 389 | if (!wordChar) return 0 390 | if (!isSurrogate1(next) || pos == s.length - 1) return wordChar.test(String.fromCharCode(next)) ? 1 : 0 391 | return wordChar.test(s.slice(pos, pos + 2)) ? 2 : 0 392 | } 393 | 394 | function wordCharBefore(s: string, pos: number) { 395 | if (!pos) return 0 396 | let prev = s.charCodeAt(pos - 1) 397 | if (prev < 192) return asciiWordChar(prev) ? 1 : 0 398 | if (!wordChar) return 0 399 | if (!isSurrogate2(prev) || pos == 1) return wordChar.test(String.fromCharCode(prev)) ? 1 : 0 400 | return wordChar.test(s.slice(pos - 2, pos)) ? 2 : 0 401 | } 402 | 403 | const MAX_SCAN = 8 404 | 405 | function findWordBoundaryAfter(s: string, pos: number, max: number) { 406 | if (pos == s.length || !wordCharBefore(s, pos)) return pos 407 | for (let cur = pos, end = pos + max, i = 0; i < MAX_SCAN; i++) { 408 | let size = wordCharAfter(s, cur) 409 | if (!size || cur + size > end) return cur 410 | cur += size 411 | } 412 | return pos 413 | } 414 | 415 | function findWordBoundaryBefore(s: string, pos: number, max: number) { 416 | if (!pos || !wordCharAfter(s, pos)) return pos 417 | for (let cur = pos, end = pos - max, i = 0; i < MAX_SCAN; i++) { 418 | let size = wordCharBefore(s, cur) 419 | if (!size || cur - size < end) return cur 420 | cur -= size 421 | } 422 | return pos 423 | } 424 | 425 | function findLineBreakBefore(s: string, pos: number, stop: number) { 426 | for (; pos != stop; pos--) if (s.charCodeAt(pos - 1) == 10) return pos 427 | return -1 428 | } 429 | 430 | function findLineBreakAfter(s: string, pos: number, stop: number) { 431 | for (; pos != stop; pos++) if (s.charCodeAt(pos) == 10) return pos 432 | return -1 433 | } 434 | 435 | const isSurrogate1 = (code: number) => code >= 0xD800 && code <= 0xDBFF 436 | const isSurrogate2 = (code: number) => code >= 0xDC00 && code <= 0xDFFF 437 | 438 | // Returns false if index looks like it is in the middle of a 439 | // surrogate pair. 440 | function validIndex(s: string, index: number) { 441 | return !index || index == s.length || !isSurrogate1(s.charCodeAt(index - 1)) || !isSurrogate2(s.charCodeAt(index)) 442 | } 443 | 444 | /// Options passed to diffing functions. 445 | export interface DiffConfig { 446 | /// When given, this limits the depth of full (expensive) diff 447 | /// computations, causing them to give up and fall back to a faster 448 | /// but less precise approach when there is more than this many 449 | /// changed characters in a scanned range. This should help avoid 450 | /// quadratic running time on large, very different inputs. 451 | scanLimit?: number 452 | /// When set, this makes the algorithm periodically check how long 453 | /// it has been running, and if it has taken more than the given 454 | /// number of milliseconds, it aborts detailed diffing in falls back 455 | /// to the imprecise algorithm. 456 | timeout?: number 457 | } 458 | 459 | /// Compute the difference between two strings. 460 | export function diff(a: string, b: string, config?: DiffConfig): readonly Change[] { 461 | scanLimit = (config?.scanLimit ?? 1e9) >> 1 462 | timeout = config?.timeout ? Date.now() + config.timeout : 0 463 | crude = false 464 | return normalize(a, b, findDiff(a, 0, a.length, b, 0, b.length)) 465 | } 466 | 467 | // Return whether the last diff fell back to the imprecise algorithm. 468 | export function diffIsPrecise() { return !crude } 469 | 470 | /// Compute the difference between the given strings, and clean up the 471 | /// resulting diff for presentation to users by dropping short 472 | /// unchanged ranges, and aligning changes to word boundaries when 473 | /// appropriate. 474 | export function presentableDiff(a: string, b: string, config?: DiffConfig): readonly Change[] { 475 | return makePresentable(diff(a, b, config) as Change[], a, b) 476 | } 477 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {Change, diff, presentableDiff, DiffConfig} from "./diff" 2 | 3 | export {getChunks, goToNextChunk, goToPreviousChunk} from "./merge" 4 | 5 | export {MergeConfig, DirectMergeConfig, MergeView} from "./mergeview" 6 | 7 | export {unifiedMergeView, acceptChunk, rejectChunk, getOriginalDoc, 8 | originalDocChangeEffect, updateOriginalDoc} from "./unified" 9 | 10 | export {uncollapseUnchanged} from "./deco" 11 | 12 | export {Chunk} from "./chunk" 13 | -------------------------------------------------------------------------------- /src/merge.ts: -------------------------------------------------------------------------------- 1 | import {EditorView, Decoration, GutterMarker} from "@codemirror/view" 2 | import {EditorState, EditorSelection, Facet, StateEffect, StateField, StateCommand, RangeSetBuilder} from "@codemirror/state" 3 | import {Chunk} from "./chunk" 4 | 5 | type Config = { 6 | sibling?: () => EditorView, 7 | highlightChanges: boolean, 8 | markGutter: boolean, 9 | syntaxHighlightDeletions?: boolean, 10 | syntaxHighlightDeletionsMaxLength?: number, 11 | mergeControls?: boolean, 12 | overrideChunk?: (( 13 | state: EditorState, 14 | chunk: Chunk, 15 | builder: RangeSetBuilder, 16 | gutterBuilder: RangeSetBuilder | null 17 | ) => boolean) | undefined, 18 | side: "a" | "b" 19 | } 20 | 21 | export const mergeConfig = Facet.define({ 22 | combine: values => values[0] 23 | }) 24 | 25 | 26 | export const setChunks = StateEffect.define() 27 | 28 | export const ChunkField = StateField.define({ 29 | create(state) { 30 | return null as any 31 | }, 32 | update(current, tr) { 33 | for (let e of tr.effects) if (e.is(setChunks)) current = e.value 34 | return current 35 | } 36 | }) 37 | 38 | 39 | /// Get the changed chunks for the merge view that this editor is part 40 | /// of, plus the side it is on if it is part of a `MergeView`. Returns 41 | /// null if the editor doesn't have a merge extension active or the 42 | /// merge view hasn't finished initializing yet. 43 | export function getChunks(state: EditorState) { 44 | let field = state.field(ChunkField, false) 45 | if (!field) return null 46 | let conf = state.facet(mergeConfig) 47 | return {chunks: field, side: conf ? conf.side : null} 48 | } 49 | 50 | let moveByChunk = (dir: -1 | 1): StateCommand => ({state, dispatch}) => { 51 | let chunks = state.field(ChunkField, false), conf = state.facet(mergeConfig) 52 | if (!chunks || !chunks.length || !conf) return false 53 | let {head} = state.selection.main, pos = 0 54 | for (let i = chunks.length - 1; i >= 0; i--) { 55 | let chunk = chunks[i] 56 | let [from, to] = conf.side == "b" ? [chunk.fromB, chunk.toB] : [chunk.fromA, chunk.toA] 57 | if (to < head) { pos = i + 1; break } 58 | if (from <= head) { 59 | if (chunks.length == 1) return false 60 | pos = i + (dir < 0 ? 0 : 1) 61 | break 62 | } 63 | } 64 | let next = chunks[(pos + (dir < 0 ? chunks.length - 1 : 0)) % chunks.length] 65 | let [from, to] = conf.side == "b" ? [next.fromB, next.toB] : [next.fromA, next.toA] 66 | dispatch(state.update({ 67 | selection: {anchor: from}, 68 | userEvent: "select.byChunk", 69 | effects: EditorView.scrollIntoView(EditorSelection.range(to, from)) 70 | })) 71 | return true 72 | } 73 | 74 | /// Move the selection to the next changed chunk. 75 | export const goToNextChunk = moveByChunk(1) 76 | 77 | /// Move the selection to the previous changed chunk. 78 | export const goToPreviousChunk = moveByChunk(-1) 79 | -------------------------------------------------------------------------------- /src/mergeview.ts: -------------------------------------------------------------------------------- 1 | import {EditorView} from "@codemirror/view" 2 | import {EditorStateConfig, Transaction, EditorState, StateEffect, Prec, Compartment, ChangeSet} from "@codemirror/state" 3 | import {Chunk, defaultDiffConfig} from "./chunk" 4 | import {DiffConfig} from "./diff" 5 | import {setChunks, ChunkField, mergeConfig} from "./merge" 6 | import {decorateChunks, updateSpacers, Spacers, adjustSpacers, collapseUnchanged, changeGutter} from "./deco" 7 | import {baseTheme, externalTheme} from "./theme" 8 | 9 | /// Configuration options to `MergeView` that can be provided both 10 | /// initially and to [`reconfigure`](#merge.MergeView.reconfigure). 11 | export interface MergeConfig { 12 | /// Controls whether editor A or editor B is shown first. Defaults 13 | /// to `"a-b"`. 14 | orientation?: "a-b" | "b-a", 15 | /// Controls whether revert controls are shown between changed 16 | /// chunks. 17 | revertControls?: "a-to-b" | "b-to-a" 18 | /// When given, this function is called to render the button to 19 | /// revert a chunk. 20 | renderRevertControl?: () => HTMLElement, 21 | /// By default, the merge view will mark inserted and deleted text 22 | /// in changed chunks. Set this to false to turn that off. 23 | highlightChanges?: boolean, 24 | /// Controls whether a gutter marker is shown next to changed lines. 25 | gutter?: boolean, 26 | /// When given, long stretches of unchanged text are collapsed. 27 | /// `margin` gives the number of lines to leave visible after/before 28 | /// a change (default is 3), and `minSize` gives the minimum amount 29 | /// of collapsible lines that need to be present (defaults to 4). 30 | collapseUnchanged?: {margin?: number, minSize?: number}, 31 | /// Pass options to the diff algorithm. By default, the merge view 32 | /// sets [`scanLimit`](#merge.DiffConfig.scanLimit) to 500. 33 | diffConfig?: DiffConfig 34 | } 35 | 36 | /// Configuration options given to the [`MergeView`](#merge.MergeView) 37 | /// constructor. 38 | export interface DirectMergeConfig extends MergeConfig { 39 | /// Configuration for the first editor (the left one in a 40 | /// left-to-right context). 41 | a: EditorStateConfig 42 | /// Configuration for the second editor. 43 | b: EditorStateConfig 44 | /// Parent element to append the view to. 45 | parent?: Element | DocumentFragment 46 | /// An optional root. Only necessary if the view is mounted in a 47 | /// shadow root or a document other than the global `document` 48 | /// object. 49 | root?: Document | ShadowRoot 50 | } 51 | 52 | const collapseCompartment = new Compartment, configCompartment = new Compartment 53 | 54 | /// A merge view manages two editors side-by-side, highlighting the 55 | /// difference between them and vertically aligning unchanged lines. 56 | /// If you want one of the editors to be read-only, you have to 57 | /// configure that in its extensions. 58 | /// 59 | /// By default, views are not scrollable. Style them (`.cm-mergeView`) 60 | /// with a height and `overflow: auto` to make them scrollable. 61 | export class MergeView { 62 | /// The first editor. 63 | a: EditorView 64 | /// The second editor. 65 | b: EditorView 66 | 67 | /// The outer DOM element holding the view. 68 | dom: HTMLElement 69 | private editorDOM: HTMLElement 70 | private revertDOM: HTMLElement | null = null 71 | private revertToA = false 72 | private revertToLeft = false 73 | private renderRevert: (() => HTMLElement) | undefined 74 | private diffConf: DiffConfig | undefined 75 | 76 | /// The current set of changed chunks. 77 | chunks: readonly Chunk[] 78 | 79 | private measuring = -1 80 | 81 | /// Create a new merge view. 82 | constructor(config: DirectMergeConfig) { 83 | this.diffConf = config.diffConfig || defaultDiffConfig 84 | 85 | let sharedExtensions = [ 86 | Prec.low(decorateChunks), 87 | baseTheme, 88 | externalTheme, 89 | Spacers, 90 | EditorView.updateListener.of(update => { 91 | if (this.measuring < 0 && (update.heightChanged || update.viewportChanged) && 92 | !update.transactions.some(tr => tr.effects.some(e => e.is(adjustSpacers)))) 93 | this.measure() 94 | }), 95 | ] 96 | 97 | let configA = [mergeConfig.of({ 98 | side: "a", 99 | sibling: () => this.b, 100 | highlightChanges: config.highlightChanges !== false, 101 | markGutter: config.gutter !== false 102 | })] 103 | if (config.gutter !== false) configA.push(changeGutter) 104 | let stateA = EditorState.create({ 105 | doc: config.a.doc, 106 | selection: config.a.selection, 107 | extensions: [ 108 | config.a.extensions || [], 109 | EditorView.editorAttributes.of({class: "cm-merge-a"}), 110 | configCompartment.of(configA), 111 | sharedExtensions 112 | ] 113 | }) 114 | 115 | let configB = [mergeConfig.of({ 116 | side: "b", 117 | sibling: () => this.a, 118 | highlightChanges: config.highlightChanges !== false, 119 | markGutter: config.gutter !== false 120 | })] 121 | if (config.gutter !== false) configB.push(changeGutter) 122 | let stateB = EditorState.create({ 123 | doc: config.b.doc, 124 | selection: config.b.selection, 125 | extensions: [ 126 | config.b.extensions || [], 127 | EditorView.editorAttributes.of({class: "cm-merge-b"}), 128 | configCompartment.of(configB), 129 | sharedExtensions 130 | ] 131 | }) 132 | this.chunks = Chunk.build(stateA.doc, stateB.doc, this.diffConf) 133 | let add = [ 134 | ChunkField.init(() => this.chunks), 135 | collapseCompartment.of(config.collapseUnchanged ? collapseUnchanged(config.collapseUnchanged) : []) 136 | ] 137 | stateA = stateA.update({effects: StateEffect.appendConfig.of(add)}).state 138 | stateB = stateB.update({effects: StateEffect.appendConfig.of(add)}).state 139 | 140 | this.dom = document.createElement("div") 141 | this.dom.className = "cm-mergeView" 142 | this.editorDOM = this.dom.appendChild(document.createElement("div")) 143 | this.editorDOM.className = "cm-mergeViewEditors" 144 | let orientation = config.orientation || "a-b" 145 | let wrapA = document.createElement("div") 146 | wrapA.className = "cm-mergeViewEditor" 147 | let wrapB = document.createElement("div") 148 | wrapB.className = "cm-mergeViewEditor" 149 | this.editorDOM.appendChild(orientation == "a-b" ? wrapA : wrapB) 150 | this.editorDOM.appendChild(orientation == "a-b" ? wrapB : wrapA) 151 | this.a = new EditorView({ 152 | state: stateA, 153 | parent: wrapA, 154 | root: config.root, 155 | dispatchTransactions: trs => this.dispatch(trs, this.a) 156 | }) 157 | this.b = new EditorView({ 158 | state: stateB, 159 | parent: wrapB, 160 | root: config.root, 161 | dispatchTransactions: trs => this.dispatch(trs, this.b) 162 | }) 163 | this.setupRevertControls(!!config.revertControls, config.revertControls == "b-to-a", config.renderRevertControl) 164 | if (config.parent) config.parent.appendChild(this.dom) 165 | this.scheduleMeasure() 166 | } 167 | 168 | private dispatch(trs: readonly Transaction[], target: EditorView) { 169 | if (trs.some(tr => tr.docChanged)) { 170 | let last = trs[trs.length - 1] 171 | let changes = trs.reduce((chs, tr) => chs.compose(tr.changes), ChangeSet.empty(trs[0].startState.doc.length)) 172 | this.chunks = target == this.a ? Chunk.updateA(this.chunks, last.newDoc, this.b.state.doc, changes, this.diffConf) 173 | : Chunk.updateB(this.chunks, this.a.state.doc, last.newDoc, changes, this.diffConf) 174 | target.update([...trs, last.state.update({effects: setChunks.of(this.chunks)})]) 175 | let other = target == this.a ? this.b : this.a 176 | other.update([other.state.update({effects: setChunks.of(this.chunks)})]) 177 | this.scheduleMeasure() 178 | } else { 179 | target.update(trs) 180 | } 181 | } 182 | 183 | /// Reconfigure an existing merge view. 184 | reconfigure(config: MergeConfig) { 185 | if ("diffConfig" in config) { 186 | this.diffConf = config.diffConfig 187 | } 188 | if ("orientation" in config) { 189 | let aB = config.orientation != "b-a" 190 | if (aB != (this.editorDOM.firstChild == this.a.dom.parentNode)) { 191 | let domA = this.a.dom.parentNode as HTMLElement, domB = this.b.dom.parentNode as HTMLElement 192 | domA.remove() 193 | domB.remove() 194 | this.editorDOM.insertBefore(aB ? domA : domB, this.editorDOM.firstChild) 195 | this.editorDOM.appendChild(aB ? domB : domA) 196 | this.revertToLeft = !this.revertToLeft 197 | if (this.revertDOM) this.revertDOM.textContent = "" 198 | } 199 | } 200 | if ("revertControls" in config || "renderRevertControl" in config) { 201 | let controls = !!this.revertDOM, toA = this.revertToA, render = this.renderRevert 202 | if ("revertControls" in config) { 203 | controls = !!config.revertControls 204 | toA = config.revertControls == "b-to-a" 205 | } 206 | if ("renderRevertControl" in config) render = config.renderRevertControl 207 | this.setupRevertControls(controls, toA, render) 208 | } 209 | let highlight = "highlightChanges" in config, gutter = "gutter" in config, collapse = "collapseUnchanged" in config 210 | if (highlight || gutter || collapse) { 211 | let effectsA: StateEffect[] = [], effectsB: StateEffect[] = [] 212 | if (highlight || gutter) { 213 | let currentConfig = this.a.state.facet(mergeConfig) 214 | let markGutter = gutter ? config.gutter !== false : currentConfig.markGutter 215 | let highlightChanges = highlight ? config.highlightChanges !== false : currentConfig.highlightChanges 216 | effectsA.push(configCompartment.reconfigure([ 217 | mergeConfig.of({side: "a", sibling: () => this.b, highlightChanges, markGutter}), 218 | markGutter ? changeGutter : [] 219 | ])) 220 | effectsB.push(configCompartment.reconfigure([ 221 | mergeConfig.of({side: "b", sibling: () => this.a, highlightChanges, markGutter}), 222 | markGutter ? changeGutter : [] 223 | ])) 224 | } 225 | if (collapse) { 226 | let effect = collapseCompartment.reconfigure( 227 | config.collapseUnchanged ? collapseUnchanged(config.collapseUnchanged) : []) 228 | effectsA.push(effect) 229 | effectsB.push(effect) 230 | } 231 | this.a.dispatch({effects: effectsA}) 232 | this.b.dispatch({effects: effectsB}) 233 | } 234 | this.scheduleMeasure() 235 | } 236 | 237 | private setupRevertControls(controls: boolean, toA: boolean, render: (() => HTMLElement) | undefined) { 238 | this.revertToA = toA 239 | this.revertToLeft = this.revertToA == (this.editorDOM.firstChild == this.a.dom.parentNode) 240 | this.renderRevert = render 241 | if (!controls && this.revertDOM) { 242 | this.revertDOM.remove() 243 | this.revertDOM = null 244 | } else if (controls && !this.revertDOM) { 245 | this.revertDOM = this.editorDOM.insertBefore(document.createElement("div"), this.editorDOM.firstChild!.nextSibling) 246 | this.revertDOM.addEventListener("mousedown", e => this.revertClicked(e)) 247 | this.revertDOM.className = "cm-merge-revert" 248 | } else if (this.revertDOM) { 249 | this.revertDOM.textContent = "" 250 | } 251 | } 252 | 253 | private scheduleMeasure() { 254 | if (this.measuring < 0) { 255 | let win = (this.dom.ownerDocument.defaultView || window) 256 | this.measuring = win.requestAnimationFrame(() => { 257 | this.measuring = -1 258 | this.measure() 259 | }) 260 | } 261 | } 262 | 263 | private measure() { 264 | updateSpacers(this.a, this.b, this.chunks) 265 | if (this.revertDOM) this.updateRevertButtons() 266 | } 267 | 268 | private updateRevertButtons() { 269 | let dom = this.revertDOM!, next = dom.firstChild as HTMLElement | null 270 | let vpA = this.a.viewport, vpB = this.b.viewport 271 | for (let i = 0; i < this.chunks.length; i++) { 272 | let chunk = this.chunks[i] 273 | if (chunk.fromA > vpA.to || chunk.fromB > vpB.to) break 274 | if (chunk.fromA < vpA.from || chunk.fromB < vpB.from) continue 275 | let top = this.a.lineBlockAt(chunk.fromA).top + "px" 276 | while (next && +(next.dataset.chunk!) < i) next = rm(next) 277 | if (next && next.dataset.chunk! == String(i)) { 278 | if (next.style.top != top) next.style.top = top 279 | next = next.nextSibling as HTMLElement | null 280 | } else { 281 | dom.insertBefore(this.renderRevertButton(top, i), next) 282 | } 283 | } 284 | while (next) next = rm(next) 285 | } 286 | 287 | private renderRevertButton(top: string, chunk: number) { 288 | let elt 289 | if (this.renderRevert) { 290 | elt = this.renderRevert() 291 | } else { 292 | elt = document.createElement("button") 293 | let text = this.a.state.phrase("Revert this chunk") 294 | elt.setAttribute("aria-label", text) 295 | elt.setAttribute("title", text) 296 | elt.textContent = this.revertToLeft ? "⇜" : "⇝" 297 | } 298 | elt.style.top = top 299 | elt.setAttribute("data-chunk", String(chunk)) 300 | return elt 301 | } 302 | 303 | private revertClicked(e: MouseEvent) { 304 | let target = e.target as HTMLElement | null, chunk 305 | while (target && target.parentNode != this.revertDOM) target = target.parentNode as HTMLElement | null 306 | if (target && (chunk = this.chunks[target.dataset.chunk as any])) { 307 | let [source, dest, srcFrom, srcTo, destFrom, destTo] = this.revertToA 308 | ? [this.b, this.a, chunk.fromB, chunk.toB, chunk.fromA, chunk.toA] 309 | : [this.a, this.b, chunk.fromA, chunk.toA, chunk.fromB, chunk.toB] 310 | let insert = source.state.sliceDoc(srcFrom, Math.max(srcFrom, srcTo - 1)) 311 | if (srcFrom != srcTo && destTo <= dest.state.doc.length) insert += source.state.lineBreak 312 | dest.dispatch({ 313 | changes: {from: destFrom, to: Math.min(dest.state.doc.length, destTo), insert}, 314 | userEvent: "revert" 315 | }) 316 | e.preventDefault() 317 | } 318 | } 319 | 320 | /// Destroy this merge view. 321 | destroy() { 322 | this.a.destroy() 323 | this.b.destroy() 324 | if (this.measuring > -1) 325 | (this.dom.ownerDocument.defaultView || window).cancelAnimationFrame(this.measuring) 326 | this.dom.remove() 327 | } 328 | } 329 | 330 | function rm(elt: HTMLElement) { 331 | let next = elt.nextSibling 332 | elt.remove() 333 | return next as HTMLElement | null 334 | } 335 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import {EditorView} from "@codemirror/view" 2 | import {StyleModule} from "style-mod" 3 | 4 | export const externalTheme = EditorView.styleModule.of(new StyleModule({ 5 | ".cm-mergeView": { 6 | overflowY: "auto", 7 | }, 8 | ".cm-mergeViewEditors": { 9 | display: "flex", 10 | alignItems: "stretch", 11 | }, 12 | ".cm-mergeViewEditor": { 13 | flexGrow: 1, 14 | flexBasis: 0, 15 | overflow: "hidden" 16 | }, 17 | ".cm-merge-revert": { 18 | width: "1.6em", 19 | flexGrow: 0, 20 | flexShrink: 0, 21 | position: "relative" 22 | }, 23 | ".cm-merge-revert button": { 24 | position: "absolute", 25 | display: "block", 26 | width: "100%", 27 | boxSizing: "border-box", 28 | textAlign: "center", 29 | background: "none", 30 | border: "none", 31 | font: "inherit", 32 | cursor: "pointer" 33 | } 34 | })) 35 | 36 | export const baseTheme = EditorView.baseTheme({ 37 | ".cm-mergeView & .cm-scroller, .cm-mergeView &": { 38 | height: "auto !important", 39 | overflowY: "visible !important" 40 | }, 41 | 42 | "&.cm-merge-a .cm-changedLine, .cm-deletedChunk": { 43 | backgroundColor: "rgba(160, 128, 100, .08)" 44 | }, 45 | "&.cm-merge-b .cm-changedLine, .cm-inlineChangedLine": { 46 | backgroundColor: "rgba(100, 160, 128, .08)" 47 | }, 48 | 49 | "&light.cm-merge-a .cm-changedText, &light .cm-deletedChunk .cm-deletedText": { 50 | background: "linear-gradient(#ee443366, #ee443366) bottom/100% 2px no-repeat", 51 | }, 52 | 53 | "&dark.cm-merge-a .cm-changedText, &dark .cm-deletedChunk .cm-deletedText": { 54 | background: "linear-gradient(#ffaa9966, #ffaa9966) bottom/100% 2px no-repeat", 55 | }, 56 | 57 | "&light.cm-merge-b .cm-changedText": { 58 | background: "linear-gradient(#22bb22aa, #22bb22aa) bottom/100% 2px no-repeat", 59 | }, 60 | 61 | "&dark.cm-merge-b .cm-changedText": { 62 | background: "linear-gradient(#88ff88aa, #88ff88aa) bottom/100% 2px no-repeat", 63 | }, 64 | 65 | "&.cm-merge-b .cm-deletedText": { 66 | background: "#ff000033" 67 | }, 68 | 69 | ".cm-insertedLine, .cm-deletedLine, .cm-deletedLine del": { 70 | textDecoration: "none" 71 | }, 72 | 73 | ".cm-deletedChunk": { 74 | paddingLeft: "6px", 75 | "& .cm-chunkButtons": { 76 | position: "absolute", 77 | insetInlineEnd: "5px" 78 | }, 79 | "& button": { 80 | border: "none", 81 | cursor: "pointer", 82 | color: "white", 83 | margin: "0 2px", 84 | borderRadius: "3px", 85 | "&[name=accept]": { background: "#2a2" }, 86 | "&[name=reject]": { background: "#d43" } 87 | }, 88 | }, 89 | 90 | ".cm-collapsedLines": { 91 | padding: "5px 5px 5px 10px", 92 | cursor: "pointer", 93 | "&:before": { 94 | content: '"⦚"', 95 | marginInlineEnd: "7px" 96 | }, 97 | "&:after": { 98 | content: '"⦚"', 99 | marginInlineStart: "7px" 100 | }, 101 | }, 102 | "&light .cm-collapsedLines": { 103 | color: "#444", 104 | background: "linear-gradient(to bottom, transparent 0, #f3f3f3 30%, #f3f3f3 70%, transparent 100%)" 105 | }, 106 | "&dark .cm-collapsedLines": { 107 | color: "#ddd", 108 | background: "linear-gradient(to bottom, transparent 0, #222 30%, #222 70%, transparent 100%)" 109 | }, 110 | 111 | ".cm-changeGutter": { width: "3px", paddingLeft: "1px" }, 112 | "&light.cm-merge-a .cm-changedLineGutter, &light .cm-deletedLineGutter": { background: "#e43" }, 113 | "&dark.cm-merge-a .cm-changedLineGutter, &dark .cm-deletedLineGutter": { background: "#fa9" }, 114 | "&light.cm-merge-b .cm-changedLineGutter": { background: "#2b2" }, 115 | "&dark.cm-merge-b .cm-changedLineGutter": { background: "#8f8" }, 116 | ".cm-inlineChangedLineGutter": { background: "#75d" } 117 | }) 118 | -------------------------------------------------------------------------------- /src/unified.ts: -------------------------------------------------------------------------------- 1 | import {EditorView, Decoration, DecorationSet, WidgetType, gutter, GutterMarker} from "@codemirror/view" 2 | import {EditorState, Text, Prec, RangeSetBuilder, StateField, StateEffect, 3 | Range, RangeSet, ChangeSet} from "@codemirror/state" 4 | import {language, highlightingFor} from "@codemirror/language" 5 | import {highlightTree} from "@lezer/highlight" 6 | import {Chunk, defaultDiffConfig} from "./chunk" 7 | import {setChunks, ChunkField, mergeConfig} from "./merge" 8 | import {Change, DiffConfig} from "./diff" 9 | import {decorateChunks, collapseUnchanged, changedText} from "./deco" 10 | import {baseTheme} from "./theme" 11 | 12 | interface UnifiedMergeConfig { 13 | /// The other document to compare the editor content with. 14 | original: Text | string 15 | /// By default, the merge view will mark inserted and deleted text 16 | /// in changed chunks. Set this to false to turn that off. 17 | highlightChanges?: boolean 18 | /// Controls whether a gutter marker is shown next to changed lines. 19 | gutter?: boolean 20 | /// By default, deleted chunks are highlighted using the main 21 | /// editor's language. Since these are just fragments, not full 22 | /// documents, this doesn't always work well. Set this option to 23 | /// false to disable syntax highlighting for deleted lines. 24 | syntaxHighlightDeletions?: boolean 25 | /// When enabled (off by default), chunks that look like they 26 | /// contain only inline changes will have the changes displayed 27 | /// inline, rather than as separate deleted/inserted lines. 28 | allowInlineDiffs?: boolean 29 | /// Deleted blocks larger than this size do not get 30 | /// syntax-highlighted. Defaults to 3000. 31 | syntaxHighlightDeletionsMaxLength?: number 32 | /// Controls whether accept/reject buttons are displayed for each 33 | /// changed chunk. Defaults to true. 34 | mergeControls?: boolean 35 | /// Pass options to the diff algorithm. By default, the merge view 36 | /// sets [`scanLimit`](#merge.DiffConfig.scanLimit) to 500. 37 | diffConfig?: DiffConfig 38 | /// When given, long stretches of unchanged text are collapsed. 39 | /// `margin` gives the number of lines to leave visible after/before 40 | /// a change (default is 3), and `minSize` gives the minimum amount 41 | /// of collapsible lines that need to be present (defaults to 4). 42 | collapseUnchanged?: {margin?: number, minSize?: number}, 43 | } 44 | 45 | const deletedChunkGutterMarker = new class extends GutterMarker { 46 | elementClass = "cm-deletedLineGutter" 47 | } 48 | 49 | const unifiedChangeGutter = Prec.low(gutter({ 50 | class: "cm-changeGutter", 51 | markers: view => view.plugin(decorateChunks)?.gutter || RangeSet.empty, 52 | widgetMarker: (view, widget) => widget instanceof DeletionWidget ? deletedChunkGutterMarker : null 53 | })) 54 | 55 | /// Create an extension that causes the editor to display changes 56 | /// between its content and the given original document. Changed 57 | /// chunks will be highlighted, with uneditable widgets displaying the 58 | /// original text displayed above the new text. 59 | export function unifiedMergeView(config: UnifiedMergeConfig) { 60 | let orig = typeof config.original == "string" ? Text.of(config.original.split(/\r?\n/)) : config.original 61 | let diffConf = config.diffConfig || defaultDiffConfig 62 | return [ 63 | Prec.low(decorateChunks), 64 | deletedChunks, 65 | baseTheme, 66 | EditorView.editorAttributes.of({class: "cm-merge-b"}), 67 | EditorState.transactionExtender.of(tr => { 68 | let updateDoc = tr.effects.find(e => e.is(updateOriginalDoc)) 69 | if (!tr.docChanged && !updateDoc) return null 70 | let prev = tr.startState.field(ChunkField) 71 | let chunks = updateDoc ? Chunk.updateA(prev, updateDoc.value.doc, tr.newDoc, updateDoc.value.changes, diffConf) 72 | : Chunk.updateB(prev, tr.startState.field(originalDoc), tr.newDoc, tr.changes, diffConf) 73 | return {effects: setChunks.of(chunks)} 74 | }), 75 | mergeConfig.of({ 76 | highlightChanges: config.highlightChanges !== false, 77 | markGutter: config.gutter !== false, 78 | syntaxHighlightDeletions: config.syntaxHighlightDeletions !== false, 79 | syntaxHighlightDeletionsMaxLength: 3000, 80 | mergeControls: config.mergeControls !== false, 81 | overrideChunk: config.allowInlineDiffs ? overrideChunkInline : undefined, 82 | side: "b" 83 | }), 84 | originalDoc.init(() => orig), 85 | config.gutter !== false ? unifiedChangeGutter : [], 86 | config.collapseUnchanged ? collapseUnchanged(config.collapseUnchanged) : [], 87 | ChunkField.init(state => Chunk.build(orig, state.doc, diffConf)) 88 | ] 89 | } 90 | 91 | /// The state effect used to signal changes in the original doc in a 92 | /// unified merge view. 93 | export const updateOriginalDoc = StateEffect.define<{doc: Text, changes: ChangeSet}>() 94 | 95 | /// Create an effect that, when added to a transaction on a unified 96 | /// merge view, will update the original document that's being compared against. 97 | export function originalDocChangeEffect(state: EditorState, changes: ChangeSet): StateEffect<{doc: Text, changes: ChangeSet}> { 98 | return updateOriginalDoc.of({doc: changes.apply(getOriginalDoc(state)), changes}) 99 | } 100 | 101 | const originalDoc = StateField.define({ 102 | create: () => Text.empty, 103 | update(doc, tr) { 104 | for (let e of tr.effects) if (e.is(updateOriginalDoc)) doc = e.value.doc 105 | return doc 106 | } 107 | }) 108 | 109 | /// Get the original document from a unified merge editor's state. 110 | export function getOriginalDoc(state: EditorState): Text { 111 | return state.field(originalDoc) 112 | } 113 | 114 | const DeletionWidgets: WeakMap = new WeakMap 115 | 116 | class DeletionWidget extends WidgetType { 117 | dom: HTMLElement | null = null 118 | constructor(readonly buildDOM: (view: EditorView) => HTMLElement) { super() } 119 | eq(other: DeletionWidget) { return this.dom == other.dom } 120 | toDOM(view: EditorView) { return this.dom || (this.dom = this.buildDOM(view)) } 121 | } 122 | 123 | function deletionWidget(state: EditorState, chunk: Chunk, hideContent: boolean) { 124 | let known = DeletionWidgets.get(chunk.changes) 125 | if (known) return known 126 | 127 | let buildDOM = (view: EditorView) => { 128 | let {highlightChanges, syntaxHighlightDeletions, syntaxHighlightDeletionsMaxLength, mergeControls} = 129 | state.facet(mergeConfig) 130 | let dom = document.createElement("div") 131 | dom.className = "cm-deletedChunk" 132 | if (mergeControls) { 133 | let buttons = dom.appendChild(document.createElement("div")) 134 | buttons.className = "cm-chunkButtons" 135 | let accept = buttons.appendChild(document.createElement("button")) 136 | accept.name = "accept" 137 | accept.textContent = state.phrase("Accept") 138 | accept.onmousedown = e => { e.preventDefault(); acceptChunk(view, view.posAtDOM(dom)) } 139 | let reject = buttons.appendChild(document.createElement("button")) 140 | reject.name = "reject" 141 | reject.textContent = state.phrase("Reject") 142 | reject.onmousedown = e => { e.preventDefault(); rejectChunk(view, view.posAtDOM(dom)) } 143 | } 144 | if (hideContent || chunk.fromA >= chunk.toA) return dom 145 | 146 | let text = view.state.field(originalDoc).sliceString(chunk.fromA, chunk.endA) 147 | let lang = syntaxHighlightDeletions && state.facet(language) 148 | let line: HTMLElement = makeLine() 149 | let changes = chunk.changes, changeI = 0, inside = false 150 | function makeLine() { 151 | let div = dom.appendChild(document.createElement("div")) 152 | div.className = "cm-deletedLine" 153 | return div.appendChild(document.createElement("del")) 154 | } 155 | function add(from: number, to: number, cls: string) { 156 | for (let at = from; at < to;) { 157 | if (text.charAt(at) == "\n") { 158 | if (!line.firstChild) line.appendChild(document.createElement("br")) 159 | line = makeLine() 160 | at++ 161 | continue 162 | } 163 | let nextStop = to, nodeCls = cls + (inside ? " cm-deletedText" : ""), flip = false 164 | let newline = text.indexOf("\n", at) 165 | if (newline > -1 && newline < to) nextStop = newline 166 | if (highlightChanges && changeI < changes.length) { 167 | let nextBound = Math.max(0, inside ? changes[changeI].toA : changes[changeI].fromA) 168 | if (nextBound <= nextStop) { 169 | nextStop = nextBound 170 | if (inside) changeI++ 171 | flip = true 172 | } 173 | } 174 | if (nextStop > at) { 175 | let node = document.createTextNode(text.slice(at, nextStop)) 176 | if (nodeCls) { 177 | let span = line.appendChild(document.createElement("span")) 178 | span.className = nodeCls 179 | span.appendChild(node) 180 | } else { 181 | line.appendChild(node) 182 | } 183 | at = nextStop 184 | } 185 | if (flip) inside = !inside 186 | } 187 | } 188 | 189 | if (lang && chunk.toA - chunk.fromA <= syntaxHighlightDeletionsMaxLength!) { 190 | let tree = lang.parser.parse(text), pos = 0 191 | highlightTree(tree, {style: tags => highlightingFor(state, tags)}, (from, to, cls) => { 192 | if (from > pos) add(pos, from, "") 193 | add(from, to, cls) 194 | pos = to 195 | }) 196 | add(pos, text.length, "") 197 | } else { 198 | add(0, text.length, "") 199 | } 200 | if (!line.firstChild) line.appendChild(document.createElement("br")) 201 | return dom 202 | } 203 | let deco = Decoration.widget({ 204 | block: true, 205 | side: -1, 206 | widget: new DeletionWidget(buildDOM) 207 | }) 208 | DeletionWidgets.set(chunk.changes, deco) 209 | return deco 210 | } 211 | 212 | /// In a [unified](#merge.unifiedMergeView) merge view, accept the 213 | /// chunk under the given position or the cursor. This chunk will no 214 | /// longer be highlighted unless it is edited again. 215 | export function acceptChunk(view: EditorView, pos?: number) { 216 | let {state} = view, at = pos ?? state.selection.main.head 217 | let chunk = view.state.field(ChunkField).find(ch => ch.fromB <= at && ch.endB >= at) 218 | if (!chunk) return false 219 | let insert = view.state.sliceDoc(chunk.fromB, Math.max(chunk.fromB, chunk.toB - 1)) 220 | let orig = view.state.field(originalDoc) 221 | if (chunk.fromB != chunk.toB && chunk.toA <= orig.length) insert += view.state.lineBreak 222 | let changes = ChangeSet.of({from: chunk.fromA, to: Math.min(orig.length, chunk.toA), insert}, orig.length) 223 | view.dispatch({ 224 | effects: updateOriginalDoc.of({doc: changes.apply(orig), changes}), 225 | userEvent: "accept" 226 | }) 227 | return true 228 | } 229 | 230 | /// In a [unified](#merge.unifiedMergeView) merge view, reject the 231 | /// chunk under the given position or the cursor. Reverts that range 232 | /// to the content it has in the original document. 233 | export function rejectChunk(view: EditorView, pos?: number) { 234 | let {state} = view, at = pos ?? state.selection.main.head 235 | let chunk = state.field(ChunkField).find(ch => ch.fromB <= at && ch.endB >= at) 236 | if (!chunk) return false 237 | let orig = state.field(originalDoc) 238 | let insert = orig.sliceString(chunk.fromA, Math.max(chunk.fromA, chunk.toA - 1)) 239 | if (chunk.fromA != chunk.toA && chunk.toB <= state.doc.length) insert += state.lineBreak 240 | view.dispatch({ 241 | changes: {from: chunk.fromB, to: Math.min(state.doc.length, chunk.toB), insert}, 242 | userEvent: "revert" 243 | }) 244 | return true 245 | } 246 | 247 | function buildDeletedChunks(state: EditorState) { 248 | let builder = new RangeSetBuilder() 249 | for (let ch of state.field(ChunkField)) { 250 | let hide = state.facet(mergeConfig).overrideChunk && chunkCanDisplayInline(state, ch) 251 | builder.add(ch.fromB, ch.fromB, deletionWidget(state, ch, !!hide)) 252 | } 253 | return builder.finish() 254 | } 255 | 256 | const deletedChunks = StateField.define({ 257 | create: state => buildDeletedChunks(state), 258 | update(deco, tr) { 259 | return tr.state.field(ChunkField, false) != tr.startState.field(ChunkField, false) ? buildDeletedChunks(tr.state) : deco 260 | }, 261 | provide: f => EditorView.decorations.from(f) 262 | }) 263 | 264 | const InlineChunkCache = new WeakMap[] | null>() 265 | 266 | function chunkCanDisplayInline(state: EditorState, chunk: Chunk): readonly Range[] | null { 267 | let result = InlineChunkCache.get(chunk) 268 | if (result !== undefined) return result 269 | 270 | result = null 271 | let a = state.field(originalDoc), b = state.doc 272 | let linesA = a.lineAt(chunk.endA).number - a.lineAt(chunk.fromA).number + 1 273 | let linesB = b.lineAt(chunk.endB).number - b.lineAt(chunk.fromB).number + 1 274 | abort: if (linesA == linesB && linesA < 10) { 275 | let deco: Range[] = [], deleteCount = 0 276 | let bA = chunk.fromA, bB = chunk.fromB 277 | for (let ch of chunk.changes) { 278 | if (ch.fromA < ch.toA) { 279 | deleteCount += ch.toA - ch.fromA 280 | let deleted = a.sliceString(bA + ch.fromA, bA + ch.toA) 281 | if (/\n/.test(deleted)) break abort 282 | deco.push(Decoration.widget({widget: new InlineDeletion(deleted), side: -1}).range(bB + ch.fromB)) 283 | } 284 | if (ch.fromB < ch.toB) { 285 | deco.push(changedText.range(bB + ch.fromB, bB + ch.toB)) 286 | } 287 | } 288 | if (deleteCount < (chunk.endA - chunk.fromA - linesA * 2)) result = deco 289 | } 290 | 291 | InlineChunkCache.set(chunk, result) 292 | return result 293 | } 294 | 295 | class InlineDeletion extends WidgetType { 296 | constructor(readonly text: string) { super() } 297 | 298 | eq(other: InlineDeletion) { return this.text == other.text } 299 | 300 | toDOM(view: EditorView) { 301 | let elt = document.createElement("del") 302 | elt.className = "cm-deletedText" 303 | elt.textContent = this.text 304 | return elt 305 | } 306 | } 307 | 308 | const inlineChangedLineGutterMarker = new class extends GutterMarker { 309 | elementClass = "cm-inlineChangedLineGutter" 310 | } 311 | const inlineChangedLine = Decoration.line({class: "cm-inlineChangedLine"}) 312 | 313 | function overrideChunkInline( 314 | state: EditorState, 315 | chunk: Chunk, 316 | builder: RangeSetBuilder, 317 | gutterBuilder: RangeSetBuilder | null 318 | ) { 319 | let inline = chunkCanDisplayInline(state, chunk), i = 0 320 | if (!inline) return false 321 | for (let line = state.doc.lineAt(chunk.fromB);;) { 322 | if (gutterBuilder) gutterBuilder.add(line.from, line.from, inlineChangedLineGutterMarker) 323 | builder.add(line.from, line.from, inlineChangedLine) 324 | while (i < inline.length && inline[i].to <= line.to) { 325 | let r = inline[i++] 326 | builder.add(r.from, r.to, r.value) 327 | } 328 | if (line.to >= chunk.endB) break 329 | line = state.doc.lineAt(line.to + 1) 330 | } 331 | return true 332 | } 333 | -------------------------------------------------------------------------------- /test/test-chunk.ts: -------------------------------------------------------------------------------- 1 | import {Text, EditorState} from "@codemirror/state" 2 | import {Chunk} from "@codemirror/merge" 3 | import ist from "ist" 4 | 5 | function byJSON(a: any, b: any) { return JSON.stringify(a) == JSON.stringify(b) } 6 | 7 | let linesA = [] 8 | for (let i = 1; i <= 1000; i++) linesA.push("line " + i) 9 | let linesB = linesA.slice() 10 | linesB[499] = "line D" 11 | linesB.splice(699, 50, "line ??", "line !!") 12 | let docA = Text.of(linesA), docB = Text.of(linesB) 13 | 14 | describe("chunks", () => { 15 | it("enumerates changed chunks", () => { 16 | let chunks = Chunk.build(docA, docB) 17 | ist(chunks.length, 2) 18 | 19 | let [ch1, ch2] = chunks 20 | ist([ch1.fromA, ch1.toA], [docA.line(500).from, docA.line(501).from], byJSON) 21 | ist([ch1.fromB, ch1.toB], [docB.line(500).from, docB.line(501).from], byJSON) 22 | 23 | ist([ch2.fromA, ch2.toA], [docA.line(700).from, docA.line(750).from], byJSON) 24 | ist([ch2.fromB, ch2.toB], [docB.line(700).from, docB.line(702).from], byJSON) 25 | 26 | ist(ch2.changes.length, 2) 27 | let [c1, c2] = ch2.changes 28 | ist([c1.fromA, c1.fromB, c1.toB], [5, 5, 7], byJSON) 29 | ist([c2.toA, c2.fromB, c2.toB], [docA.line(749).to - ch2.fromA, 13, 15], byJSON) 30 | }) 31 | 32 | it("handles changes at end of document", () => { 33 | let [ch1] = Chunk.build(Text.of(["one", ""]), Text.of(["one", "", ""])) 34 | ist([ch1.fromA, ch1.toA], [4, 4], byJSON) 35 | ist([ch1.fromB, ch1.toB], [4, 5], byJSON) 36 | }) 37 | 38 | it("can update chunks for changes", () => { 39 | let stateA = EditorState.create({doc: docA}), stateB = EditorState.create({doc: docB}) 40 | let chunks = Chunk.build(stateA.doc, stateB.doc) 41 | 42 | let tr1 = stateA.update({changes: {from: 0, insert: "line NULL\n"}}) 43 | let chunks1 = Chunk.updateA(chunks, tr1.newDoc, stateB.doc, tr1.changes) 44 | ist(chunks1.length, 3) 45 | let [ch1, ch2] = chunks1 46 | ist([ch1.fromA, ch1.toA, ch1.fromB, ch1.toB], [0, 10, 0, 0], byJSON) 47 | ist([ch2.fromA, ch2.fromB], [tr1.newDoc.line(501).from, stateB.doc.line(500).from], byJSON) 48 | stateA = tr1.state 49 | 50 | let tr2 = stateB.update({changes: [ 51 | {from: stateB.doc.line(600).from + 1, insert: "---"}, 52 | {from: stateB.doc.length, insert: "\n???"} 53 | ]}) 54 | let chunks2 = Chunk.updateB(chunks1, stateA.doc, tr2.newDoc, tr2.changes) 55 | ist(chunks2.length, 5) 56 | let [, , ch3, , ch5] = chunks2 57 | ist([ch3.fromA, ch3.toA], [stateA.doc.line(601).from, stateA.doc.line(602).from], byJSON) 58 | ist([ch3.fromB, ch3.toB], [tr2.newDoc.line(600).from, tr2.newDoc.line(601).from], byJSON) 59 | ist([ch5.fromA, ch5.toA], [stateA.doc.length - 9, stateA.doc.length + 1], byJSON) 60 | ist([ch5.fromB, ch5.toB], [tr2.newDoc.length - 13, tr2.newDoc.length + 1], byJSON) 61 | }) 62 | 63 | it("can handle deleting updates", () => { 64 | let stateA = EditorState.create({doc: docA}) 65 | let chunks = Chunk.build(stateA.doc, docB) 66 | 67 | let tr = stateA.update({changes: {from: 0, to: 100}}) 68 | let chunks1 = Chunk.updateA(chunks, tr.newDoc, docB, tr.changes) 69 | ist(chunks1.length, 3) 70 | ist(chunks1.map(c => c.fromA), [0, 4283, 6083], byJSON) 71 | ist(chunks1.map(c => c.fromB), [0, 4383, 6181], byJSON) 72 | }) 73 | 74 | it("clears chunks when a is set to equal b", () => { 75 | let sA = EditorState.create({doc: ""}), sB = EditorState.create({doc: "foo\n"}) 76 | let chs = Chunk.build(sA.doc, sB.doc) 77 | let tr = sA.update({changes: {from: 0, insert: sB.doc}}) 78 | ist(Chunk.updateA(chs, tr.newDoc, sB.doc, tr.changes).length, 0) 79 | }) 80 | 81 | it("drops old chunks when a doc is cleared", () => { 82 | let sA = EditorState.create({doc: "A\nb\nC\nd\nE"}), sB = EditorState.create({doc: "a\nb\nc\nd\ne"}) 83 | let chs = Chunk.build(sA.doc, sB.doc) 84 | let tr = sA.update({changes: {from: 0, to: sA.doc.length}}) 85 | let updated = Chunk.updateA(chs, tr.newDoc, sB.doc, tr.changes) 86 | ist(updated.length, 1) 87 | ist(updated[0].toB, sB.doc.length + 1) 88 | }) 89 | 90 | it("works for changes inside changed code", () => { 91 | let sA = EditorState.create({doc: "A\nb\nc\nD\nE"}), sB = EditorState.create({doc: "A\nD\nE"}) 92 | let chs = Chunk.build(sA.doc, sB.doc) 93 | let tr = sA.update({changes: {from: 3, to: 3, insert: "!"}}) 94 | let updated = Chunk.updateA(chs, tr.newDoc, sB.doc, tr.changes) 95 | ist(updated.map(ch => `${ch.fromA}-${ch.toA}/${ch.fromB}-${ch.toB}`).join(" "), 96 | "2-7/2-2") 97 | }) 98 | 99 | it("handles changes that overlap with the start of chunks", () => { 100 | let big = "2".repeat(1100) 101 | let sA = EditorState.create({doc: [1, 2, 3, big, 5, 6, 7, 8].join("\n")}) 102 | let sB = EditorState.create({doc: [1, big, 5, 8].join("\n")}) 103 | let chs = Chunk.build(sA.doc, sB.doc) 104 | let tr = sB.update({changes: {from: 2, to: 2, insert: "2\n3\n"}}) 105 | let updated = Chunk.updateB(chs, sA.doc, tr.newDoc, tr.changes) 106 | ist(updated.map(ch => `${ch.fromA}-${ch.toA}/${ch.fromB}-${ch.toB}`).join(" "), 107 | "1109-1113/1109-1109") 108 | }) 109 | 110 | it("tracks chunk precision", () => { 111 | let head = "---\n".repeat(500) 112 | let sA = EditorState.create({doc: head + "a\n".repeat(1000)}) 113 | let sB = EditorState.create({doc: "///" + head + "b\n"}) 114 | let chs = Chunk.build(sA.doc, sB.doc) 115 | ist(chs.length, 2) 116 | ist(chs.every(ch => ch.precise)) 117 | let tr = sB.update({changes: {from: sB.doc.length, insert: "b\n".repeat(999)}}) 118 | let updated = Chunk.updateB(chs, sA.doc, tr.newDoc, tr.changes, {scanLimit: 100}) 119 | ist(updated.length, 2) 120 | ist(updated[0].precise) 121 | ist(!updated[1].precise) 122 | }) 123 | }) 124 | -------------------------------------------------------------------------------- /test/test-diff.ts: -------------------------------------------------------------------------------- 1 | import {diff, presentableDiff, Change} from "@codemirror/merge" 2 | import ist from "ist" 3 | 4 | function apply(diff: readonly Change[], orig: string, changed: string) { 5 | let pos = 0, result = "" 6 | for (let ch of diff) { 7 | result += orig.slice(pos, ch.fromA) 8 | result += changed.slice(ch.fromB, ch.toB) 9 | pos = ch.toA 10 | } 11 | result += orig.slice(pos) 12 | return result 13 | } 14 | 15 | function checkShape(diff: readonly Change[], shape: string) { 16 | let posA = 0, posB = 0, changes = [] 17 | for (let part of shape.split(" ")) { 18 | let ch = /(\d+)\/(\d+)/.exec(part) 19 | if (ch) { 20 | let toA = posA + +ch[1], toB = posB + +ch[2] 21 | changes.push({fromA: posA, toA, fromB: posB, toB}) 22 | posA = toA; posB = toB 23 | } else { 24 | let len = +part 25 | posA += len; posB += len 26 | } 27 | } 28 | ist(JSON.stringify(diff), JSON.stringify(changes)) 29 | } 30 | 31 | describe("diff", () => { 32 | it("produces close to minimal diffs", () => { 33 | for (let i = 0; i < 1000; i++) { 34 | let len = Math.ceil(Math.sqrt(i)) * 5 + 5 35 | let str = "" 36 | for (let j = 0; j < len; j++) str += " abcdefghij"[Math.floor(Math.random() * 12)] 37 | let changed = "", skipped = 0, inserted = 0 38 | for (let pos = 0;;) { 39 | if (pos >= len) break 40 | let skip = Math.floor(Math.random() * 10) + 1 41 | skipped += Math.min(skip, len - pos) 42 | changed += str.slice(pos, pos + skip) 43 | pos += skip 44 | if (pos >= len) break 45 | let insert = Math.floor(Math.random() * 5) 46 | inserted += insert 47 | changed += "X".repeat(insert) 48 | pos += Math.floor(Math.random() * 5) 49 | } 50 | let d = diff(str, changed) 51 | let dSkipped = len - d.reduce((l, ch) => l + (ch.toA - ch.fromA), 0) 52 | let dInserted = d.reduce((l, ch) => l + (ch.toB - ch.fromB), 0) 53 | let margin = Math.round(len / 10) 54 | if (dSkipped < skipped - margin || dInserted > inserted + margin) { 55 | console.log("failure for", JSON.stringify(str), JSON.stringify(changed)) 56 | ist(dSkipped, skipped) 57 | ist(dInserted, inserted) 58 | } 59 | ist(apply(d, str, changed), changed) 60 | } 61 | }) 62 | 63 | it("doesn't cut in the middle of surrogate pairs", () => { 64 | for (let [a, b, shape] of [ 65 | ["🐶", "🐯", "2/2"], 66 | ["👨🏽", "👩🏽", "2/2 2"], 67 | ["👩🏼", "👩🏽", "2 2/2"], 68 | ["🍏🍎", "🍎", "2/0 2"], 69 | ["🍎", "🍏🍎", "0/2 2"], 70 | ["x🍎", "x🍏🍎", "1 0/2 2"], 71 | ["🍎x", "🍏🍎x", "0/2 3"], 72 | ]) { 73 | let d = diff(a, b) 74 | checkShape(d, shape) 75 | ist(apply(d, a, b), b) 76 | } 77 | }) 78 | 79 | it("handles random input", () => { 80 | let alphabet = "AAACGTT" 81 | function word(len: number) { 82 | let w = "" 83 | for (let l = 0; l < 100; l++) w += alphabet[Math.floor(Math.random() * alphabet.length)] 84 | return w 85 | } 86 | for (let i = 0; i <= 1000; i++) { 87 | let a = word(50), b = word(50), d = diff(a, b) 88 | ist(apply(d, a, b), b) 89 | } 90 | }) 91 | 92 | it("can limit scan depth", () => { 93 | let t0 = Date.now() 94 | diff("a".repeat(10000), "b".repeat(10000), {scanLimit: 500}) 95 | ist(Date.now() < t0 + 100) 96 | }) 97 | 98 | it("can time out diffs", () => { 99 | let t0 = Date.now() 100 | diff("a".repeat(10000), "b".repeat(10000), {timeout: 50}) 101 | ist(Date.now() < t0 + 100) 102 | }) 103 | }) 104 | 105 | function parseDiff(d: string) { 106 | let change = /\[(.*?)\/(.*?)\]/g 107 | return {a: d.replace(change, (_, a) => a), 108 | b: d.replace(change, (_, _a, b) => b)} 109 | } 110 | 111 | function serializeDiff(diff: readonly Change[], a: string, b: string) { 112 | let posA = 0, result = "" 113 | for (let ch of diff) { 114 | result += a.slice(posA, ch.fromA) + "[" + a.slice(ch.fromA, ch.toA) + "/" + b.slice(ch.fromB, ch.toB) + "]" 115 | posA = ch.toA 116 | } 117 | return result + a.slice(posA) 118 | } 119 | 120 | describe("presentableDiff", () => { 121 | function test(name: string, diff: string) { 122 | it(name, () => { 123 | let {a, b} = parseDiff(diff) 124 | let result = presentableDiff(a, b) 125 | ist(serializeDiff(result, a, b), diff) 126 | ist(apply(result, a, b), b) 127 | }) 128 | } 129 | 130 | test("grows changes to word start", "one [two/twi] three") 131 | test("grows changes to word end", "one [iwo/two] three") 132 | test("grows changes from both sides", "[drop/drip]") 133 | 134 | test("doesn't grow short insertions", "blo[/o]p") 135 | test("doesn't grow short deletions", "blo[o/]p") 136 | test("does grow long insertions", "[oaks/oaktrees]") 137 | test("does grow long deletions", "[oaktrees/oaks]") 138 | 139 | test("covers words that contain other changes", "[Threepwood/three]") 140 | 141 | test("aligns to the end of words", "fromA[/ + offA]") 142 | test("aligns to the start of words", "[offA + /]fromA") 143 | 144 | test("removes small unchanged ranges", "[one->two/a->b]") 145 | 146 | test("moves indentation after a change", "x\n[ foo/]\n bar\n baz") 147 | 148 | test("aligns insertions to line boundaries", " x,\n[/ y,]\n z,\n") 149 | 150 | test("aligns deletions to line boundaries", " x,\n[ y,/]\n z,\n") 151 | }) 152 | --------------------------------------------------------------------------------