@adecore/editor-react 0.22.1 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/dist/CallHierarchyPanel.d.ts +6 -0
  2. package/dist/CallHierarchyPanel.js +92 -0
  3. package/dist/CompletionPopup.js +11 -3
  4. package/dist/DiffView.d.ts +22 -0
  5. package/dist/DiffView.js +203 -0
  6. package/dist/EditorContextMenu.js +1 -0
  7. package/dist/EditorView.js +18 -1
  8. package/dist/FindReplace.d.ts +1 -1
  9. package/dist/FindReplace.js +41 -11
  10. package/dist/HoverCard.js +2 -2
  11. package/dist/LanguagePopups.js +4 -1
  12. package/dist/Markdown.d.ts +2 -1
  13. package/dist/Markdown.js +8 -4
  14. package/dist/PanelResizeHandle.d.ts +8 -0
  15. package/dist/PanelResizeHandle.js +71 -0
  16. package/dist/PeekPanel.d.ts +3 -0
  17. package/dist/PeekPanel.js +7 -4
  18. package/dist/PickPopup.js +15 -2
  19. package/dist/RenameCard.js +10 -5
  20. package/dist/RenderedDocs.d.ts +9 -0
  21. package/dist/RenderedDocs.js +84 -0
  22. package/dist/SplitDiffView.d.ts +9 -0
  23. package/dist/SplitDiffView.js +276 -0
  24. package/dist/SymbolPicker.d.ts +3 -0
  25. package/dist/SymbolPicker.js +4 -3
  26. package/dist/call-hierarchy-model.d.ts +52 -0
  27. package/dist/call-hierarchy-model.js +93 -0
  28. package/dist/call-hierarchy.d.ts +42 -0
  29. package/dist/call-hierarchy.js +398 -0
  30. package/dist/code-actions-model.d.ts +2 -0
  31. package/dist/code-actions-model.js +12 -10
  32. package/dist/code-actions.js +3 -1
  33. package/dist/completion-model.d.ts +14 -4
  34. package/dist/completion-model.js +40 -33
  35. package/dist/completion.d.ts +4 -0
  36. package/dist/completion.js +257 -42
  37. package/dist/diagnostics.js +8 -6
  38. package/dist/diff-expansion.d.ts +9 -0
  39. package/dist/diff-expansion.js +41 -0
  40. package/dist/diff-overlays.d.ts +15 -0
  41. package/dist/diff-overlays.js +28 -0
  42. package/dist/diff-syntax.d.ts +16 -0
  43. package/dist/diff-syntax.js +49 -0
  44. package/dist/diff-wordmarks.d.ts +5 -0
  45. package/dist/diff-wordmarks.js +26 -0
  46. package/dist/doc-comment-model.d.ts +23 -0
  47. package/dist/doc-comment-model.js +291 -0
  48. package/dist/editor-language.d.ts +13 -1
  49. package/dist/editor-language.js +15 -5
  50. package/dist/editor-react.css +79 -0
  51. package/dist/format-on-type.d.ts +14 -0
  52. package/dist/format-on-type.js +96 -0
  53. package/dist/host-types.d.ts +19 -0
  54. package/dist/hover.d.ts +4 -0
  55. package/dist/hover.js +15 -2
  56. package/dist/index.d.ts +19 -5
  57. package/dist/index.js +9 -1
  58. package/dist/inlay-hints.d.ts +47 -2
  59. package/dist/inlay-hints.js +353 -16
  60. package/dist/language-ids.js +4 -2
  61. package/dist/locales/en.json +117 -3
  62. package/dist/locales/nl.json +117 -3
  63. package/dist/models.d.ts +6 -2
  64. package/dist/models.js +5 -1
  65. package/dist/panel-size.d.ts +6 -0
  66. package/dist/panel-size.js +25 -0
  67. package/dist/patch-decorations.d.ts +27 -0
  68. package/dist/patch-decorations.js +182 -0
  69. package/dist/patch-layout.d.ts +40 -0
  70. package/dist/patch-layout.js +304 -0
  71. package/dist/peek.d.ts +3 -1
  72. package/dist/peek.js +6 -1
  73. package/dist/pick.d.ts +1 -0
  74. package/dist/pick.js +11 -1
  75. package/dist/popups.d.ts +17 -0
  76. package/dist/popups.js +1 -0
  77. package/dist/project-language.d.ts +12 -0
  78. package/dist/project-language.js +52 -2
  79. package/dist/refresher.d.ts +1 -0
  80. package/dist/refresher.js +5 -0
  81. package/dist/rename-model.d.ts +1 -1
  82. package/dist/rename-model.js +3 -7
  83. package/dist/rename.js +5 -8
  84. package/dist/rendered-docs.d.ts +64 -0
  85. package/dist/rendered-docs.js +374 -0
  86. package/dist/row-host.d.ts +2 -0
  87. package/dist/row-host.js +11 -3
  88. package/dist/semantic-delta.d.ts +2 -0
  89. package/dist/semantic-delta.js +27 -0
  90. package/dist/semantic-model.js +16 -0
  91. package/dist/semantic-tokens.d.ts +26 -1
  92. package/dist/semantic-tokens.js +174 -14
  93. package/dist/shortcut-keys.d.ts +2 -0
  94. package/dist/shortcut-keys.js +6 -2
  95. package/dist/signature.js +1 -1
  96. package/dist/snippet-field-transforms.d.ts +30 -0
  97. package/dist/snippet-field-transforms.js +116 -0
  98. package/dist/snippet-indent.d.ts +2 -0
  99. package/dist/snippet-indent.js +49 -0
  100. package/dist/snippet-range-order.d.ts +3 -0
  101. package/dist/snippet-range-order.js +19 -0
  102. package/dist/snippet-session.d.ts +42 -6
  103. package/dist/snippet-session.js +650 -53
  104. package/dist/snippet-transform-client.d.ts +19 -0
  105. package/dist/snippet-transform-client.js +66 -0
  106. package/dist/snippet-transform-format.d.ts +15 -0
  107. package/dist/snippet-transform-format.js +302 -0
  108. package/dist/snippet-transform-protocol.d.ts +45 -0
  109. package/dist/snippet-transform-protocol.js +8 -0
  110. package/dist/snippet-transform-queue.d.ts +32 -0
  111. package/dist/snippet-transform-queue.js +173 -0
  112. package/dist/snippet-transform-worker.d.ts +10 -0
  113. package/dist/snippet-transform-worker.js +41 -0
  114. package/dist/snippet-variable-names.d.ts +1 -0
  115. package/dist/snippet-variable-names.js +36 -0
  116. package/dist/snippet-variables.d.ts +7 -0
  117. package/dist/snippet-variables.js +66 -0
  118. package/dist/snippet.d.ts +30 -3
  119. package/dist/snippet.js +402 -87
  120. package/dist/split-alignment.d.ts +6 -0
  121. package/dist/split-alignment.js +38 -0
  122. package/dist/split-scroll.d.ts +14 -0
  123. package/dist/split-scroll.js +45 -0
  124. package/dist/symbols.d.ts +2 -1
  125. package/dist/symbols.js +39 -7
  126. package/dist/testing.d.ts +5 -1
  127. package/dist/testing.js +12 -0
  128. package/dist/timers.d.ts +1 -0
  129. package/dist/timers.js +4 -0
  130. package/dist/use-panel-body.d.ts +6 -0
  131. package/dist/use-panel-body.js +13 -0
  132. package/dist/workspace-edit.d.ts +25 -3
  133. package/dist/workspace-edit.js +282 -143
  134. package/package.json +10 -5
  135. package/src/CallHierarchyPanel.tsx +244 -0
  136. package/src/CompletionPopup.tsx +12 -1
  137. package/src/DiffView.tsx +262 -0
  138. package/src/EditorContextMenu.tsx +1 -0
  139. package/src/EditorView.tsx +21 -2
  140. package/src/FindReplace.tsx +51 -13
  141. package/src/HoverCard.tsx +23 -21
  142. package/src/LanguagePopups.tsx +5 -0
  143. package/src/Markdown.tsx +10 -3
  144. package/src/PanelResizeHandle.tsx +104 -0
  145. package/src/PeekPanel.tsx +11 -6
  146. package/src/PickPopup.tsx +18 -2
  147. package/src/RenameCard.tsx +18 -0
  148. package/src/RenderedDocs.tsx +155 -0
  149. package/src/SplitDiffView.tsx +337 -0
  150. package/src/SymbolPicker.tsx +4 -3
  151. package/src/call-hierarchy-model.ts +151 -0
  152. package/src/call-hierarchy.ts +440 -0
  153. package/src/code-actions-model.ts +17 -11
  154. package/src/code-actions.ts +3 -1
  155. package/src/completion-model.ts +61 -35
  156. package/src/completion.ts +302 -42
  157. package/src/diagnostics.ts +8 -6
  158. package/src/diff-expansion.ts +49 -0
  159. package/src/diff-overlays.tsx +69 -0
  160. package/src/diff-syntax.ts +67 -0
  161. package/src/diff-wordmarks.ts +31 -0
  162. package/src/doc-comment-model.ts +336 -0
  163. package/src/editor-language.ts +22 -6
  164. package/src/editor-react.css +79 -0
  165. package/src/format-on-type.ts +111 -0
  166. package/src/host-types.ts +23 -0
  167. package/src/hover.ts +18 -3
  168. package/src/index.ts +27 -4
  169. package/src/inlay-hints.ts +394 -18
  170. package/src/language-ids.ts +4 -2
  171. package/src/locales/en.json +117 -3
  172. package/src/locales/nl.json +117 -3
  173. package/src/models.ts +40 -1
  174. package/src/panel-size.ts +28 -0
  175. package/src/patch-decorations.ts +224 -0
  176. package/src/patch-layout.ts +388 -0
  177. package/src/peek.ts +6 -1
  178. package/src/pick.ts +12 -1
  179. package/src/popups.ts +24 -0
  180. package/src/project-language.ts +63 -2
  181. package/src/refresher.ts +6 -0
  182. package/src/rename-model.ts +4 -8
  183. package/src/rename.ts +5 -8
  184. package/src/rendered-docs.ts +430 -0
  185. package/src/row-host.ts +12 -3
  186. package/src/semantic-delta.ts +29 -0
  187. package/src/semantic-model.ts +17 -0
  188. package/src/semantic-tokens.ts +202 -19
  189. package/src/shortcut-keys.ts +6 -2
  190. package/src/signature.ts +1 -1
  191. package/src/snippet-field-transforms.ts +144 -0
  192. package/src/snippet-indent.ts +53 -0
  193. package/src/snippet-range-order.ts +19 -0
  194. package/src/snippet-session.ts +797 -56
  195. package/src/snippet-transform-client.ts +84 -0
  196. package/src/snippet-transform-format.ts +325 -0
  197. package/src/snippet-transform-protocol.ts +24 -0
  198. package/src/snippet-transform-queue.ts +199 -0
  199. package/src/snippet-transform-worker.ts +58 -0
  200. package/src/snippet-variable-names.ts +36 -0
  201. package/src/snippet-variables.ts +74 -0
  202. package/src/snippet.ts +452 -89
  203. package/src/split-alignment.ts +45 -0
  204. package/src/split-scroll.ts +54 -0
  205. package/src/symbols.ts +43 -7
  206. package/src/testing.ts +25 -0
  207. package/src/timers.ts +5 -0
  208. package/src/use-panel-body.ts +17 -0
  209. package/src/workspace-edit.ts +347 -169
package/src/completion.ts CHANGED
@@ -1,9 +1,13 @@
1
- import { StaleResultError, type CompletionContext, type CompletionItem } from '@adecore/lsp';
1
+ import { SnippetTransformQueueError } from './snippet-transform-queue.ts';
2
+ import { SnippetTransformError } from './snippet-transform-client.ts';
3
+ import { MAX_TRANSFORM_JOBS, MAX_TRANSFORM_WORK, type SnippetTransformJob } from './snippet-transform-protocol.ts';
4
+ import { fileUriToPath, StaleResultError, type CompletionContext, type CompletionItem } from '@adecore/lsp';
2
5
  import type { EditorContentChange, EditorPosition, EditorRange, EditorTextChange } from '@adecore/editor';
3
6
  import { comparePositions, shiftPosition } from './diagnostics-model.ts';
4
7
  import type { EditorLanguage } from './editor-language.ts';
5
8
  import {
6
9
  completionDocsOf,
10
+ editRangeOf,
7
11
  identifierPrefix,
8
12
  insertionOf,
9
13
  isIdentifierCharacter,
@@ -18,6 +22,9 @@ import {
18
22
  type Ranked
19
23
  } from './completion-model.ts';
20
24
  import { PARAMETER_HINTS_COMMAND, isCallItem, planCall, withParentheses, type CallPlan } from './completion-call.ts';
25
+ import { compileSnippet } from './snippet.ts';
26
+ import { createSnippetResolver } from './snippet-variables.ts';
27
+ import { wordRangeAt } from './rename-model.ts';
21
28
  import { RecentChoices, recentChoicesOf } from './recent-choices.ts';
22
29
  import { shikiLanguageOf } from './language-ids.ts';
23
30
  import { textBeforeOf } from './line-text.ts';
@@ -34,6 +41,45 @@ const RESOLVE_DELAY_MS = 120;
34
41
  const PAGE = 8;
35
42
  const LINES_BACK = 60;
36
43
  const TOAST_ID = 'language-completion';
44
+ const MAX_COMPLETION_CHARACTERS = 2_000_000;
45
+ const MAX_COMPLETION_RANGES = 1_000;
46
+
47
+ class CompletionLimitError extends Error {}
48
+
49
+ function mapCompletionEdits(
50
+ item: CompletionItem,
51
+ changes: readonly EditorContentChange[],
52
+ mapRange: (range: EditorRange) => EditorRange
53
+ ): CompletionItem | null {
54
+ // Changes apply one after another, so each is checked against the range as the earlier ones left it.
55
+ const overlapsChange = (range: EditorRange): boolean => {
56
+ let { start, end } = range;
57
+ for (const change of changes) {
58
+ if (comparePositions(start, change.range.end) < 0 && comparePositions(change.range.start, end) < 0) {
59
+ return true;
60
+ }
61
+ start = shiftPosition(start, change);
62
+ end = shiftPosition(end, change);
63
+ }
64
+ return false;
65
+ };
66
+ let valid = true;
67
+ const map = (range: EditorRange): EditorRange => {
68
+ valid &&= !overlapsChange(range);
69
+ return mapRange(range);
70
+ };
71
+ const edit = item.textEdit;
72
+ const mapped = {
73
+ ...item,
74
+ ...(edit === undefined
75
+ ? {}
76
+ : { textEdit: 'range' in edit ? { ...edit, range: map(edit.range) } : { ...edit, insert: map(edit.insert), replace: map(edit.replace) } }),
77
+ ...(item.additionalTextEdits === undefined
78
+ ? {}
79
+ : { additionalTextEdits: item.additionalTextEdits.map((edit) => ({ ...edit, range: map(edit.range) })) })
80
+ };
81
+ return valid ? mapped : null;
82
+ }
37
83
 
38
84
  /*
39
85
  * The suggestions that open while a word is typed. A single typed identifier character or a trigger
@@ -60,6 +106,9 @@ export class CompletionFeature {
60
106
  private requestTimer: unknown;
61
107
  private refreshTimer: unknown;
62
108
  private resolveTimer: unknown;
109
+ private textRevision = 0;
110
+ private acceptSequence = 0;
111
+ private disposed = false;
63
112
  private readonly resolved = new Map<CompletionItem, CompletionItem>();
64
113
 
65
114
  constructor(language: EditorLanguage, timers: Timers = realTimers) {
@@ -70,9 +119,13 @@ export class CompletionFeature {
70
119
  editor.onKeyDown((event) => this.key(event)),
71
120
  editor.onTextChange((change) => this.edited(change)),
72
121
  editor.onCaret((position) => this.caretMoved(position)),
73
- editor.onBlur(() => this.close())
122
+ editor.onBlur(() => {
123
+ this.acceptSequence++;
124
+ this.close();
125
+ })
74
126
  ];
75
127
  language.onDispose(() => {
128
+ this.disposed = true;
76
129
  for (const off of offs) {
77
130
  off();
78
131
  }
@@ -86,6 +139,7 @@ export class CompletionFeature {
86
139
 
87
140
  /* Asks for suggestions at the caret now, as Ctrl+Space does. */
88
141
  invoke(): void {
142
+ this.language.pick.close();
89
143
  this.explicit = true;
90
144
  this.beginRequest({ triggerKind: 1 }, 0);
91
145
  }
@@ -108,7 +162,38 @@ export class CompletionFeature {
108
162
  if (entry === undefined) {
109
163
  return;
110
164
  }
111
- const { editor, project, uri } = this.language;
165
+ const { editor, project, uri, snippets } = this.language;
166
+ const acceptSequence = ++this.acceptSequence;
167
+ const snippetToken = snippets.token;
168
+ const inSnippet = snippets.isActive;
169
+ const exitChanges: { changes: readonly EditorContentChange[]; map: (range: EditorRange) => EditorRange }[] = [];
170
+ if (snippets.hasPendingTransforms) {
171
+ this.close();
172
+ if (
173
+ !(await snippets.settleTransforms(
174
+ () => !this.disposed && acceptSequence === this.acceptSequence && snippets.token === snippetToken,
175
+ (changes, map) => exitChanges.push({ changes, map })
176
+ ))
177
+ ) {
178
+ return;
179
+ }
180
+ }
181
+ const acceptedSelections = editor.getSelections();
182
+ const revision = this.textRevision;
183
+ const current = (): boolean => {
184
+ if (this.disposed || acceptSequence !== this.acceptSequence || revision !== this.textRevision || (inSnippet && snippets.token !== snippetToken)) {
185
+ return false;
186
+ }
187
+ const selections = editor.getSelections();
188
+ return (
189
+ selections.length === acceptedSelections.length &&
190
+ selections.every(
191
+ (range, index) =>
192
+ comparePositions(range.start, acceptedSelections[index]!.start) === 0 &&
193
+ comparePositions(range.end, acceptedSelections[index]!.end) === 0
194
+ )
195
+ );
196
+ };
112
197
  recentChoicesOf(this.language.languageId).record(entry.item);
113
198
  let item = this.resolved.get(entry.item) ?? entry.item;
114
199
  const needsResolve = !this.resolved.has(entry.item) && project.service.providerOptions('completionItem/resolve', uri) !== undefined;
@@ -117,44 +202,214 @@ export class CompletionFeature {
117
202
  // Resolving can add the import an item needs; an item that cannot be resolved is still inserted as it is.
118
203
  item = await this.resolveNow(entry.item);
119
204
  }
205
+ if (!current()) {
206
+ return;
207
+ }
208
+ for (const { changes, map } of exitChanges) {
209
+ const mapped = mapCompletionEdits(item, changes, map);
210
+ if (mapped === null) {
211
+ return;
212
+ }
213
+ item = mapped;
214
+ }
120
215
  const caret = editor.getCaret();
121
216
  const lineBefore = textBeforeOf(editor, caret);
122
217
  const lineAfter = editor.textInRange({ start: caret, end: { line: caret.line, character: Number.MAX_SAFE_INTEGER } });
123
- // A character that commits is typed after the name, so a call is not added before it.
124
- const planned = insertionOf(item, caret, lineBefore, replace, commit === undefined ? lineAfter : '(');
125
- const { before, rest } = this.surroundings(planned, caret, lineAfter);
126
- const plan: CallPlan =
127
- commit === undefined ? planCall({ item, languageId: this.language.languageId, insertion: planned, before, rest, replace }) : { action: 'none' };
128
- const main = plan.action === 'add' ? withParentheses(planned, plan.inside) : planned;
129
- const extras = (item.additionalTextEdits ?? []).map((edit): EditorContentChange => ({ range: edit.range, text: edit.newText }));
130
- // An import above the insertion moves it, and the tab stops are measured from where it ends up.
131
- const start = extras
132
- .filter((extra) => comparePositions(extra.range.end, main.range.start) <= 0)
133
- .sort((left, right) => comparePositions(right.range.start, left.range.start))
134
- .reduce((place, extra) => shiftPosition(place, extra), main.range.start);
135
- // A name typed at several carets is completed at all of them, and the other carets end behind what was put in.
136
218
  const carets = editor.getSelections();
137
- const mirrored = mirroredInsertions(carets, editor.getSelection(), caret, (range) => editor.textInRange(range), main);
138
- editor.applyEdits([{ range: main.range, text: main.text }, ...mirrored, ...extras]);
139
- const landed = editor.getSelections();
140
- if (commit === undefined) {
141
- this.language.snippets.begin(start, main.text, main.stops);
142
- if (plan.action === 'add' && plan.inside && mirrored.length > 0) {
143
- this.placeMirroredCarets(carets, mirrored, landed);
219
+ const source = item.textEdit?.newText ?? item.insertText ?? item.label;
220
+ if (source.length * carets.length > MAX_COMPLETION_CHARACTERS) {
221
+ this.refuseLargeCompletion();
222
+ return;
223
+ }
224
+ let characters = 0;
225
+ let ranges = 0;
226
+ const now = new Date();
227
+ const template = item.insertTextFormat === 2 ? compileSnippet(item.textEdit?.newText ?? item.insertText ?? item.label) : undefined;
228
+ const finishes = new Map<Insertion, (values: readonly string[]) => Insertion>();
229
+ const renderAt = (position: EditorPosition, selection: EditorRange): Insertion => {
230
+ const line = editor.textInRange({ start: { line: position.line, character: 0 }, end: { line: position.line, character: Number.MAX_SAFE_INTEGER } });
231
+ const word = wordRangeAt(line, position);
232
+ const resolver = createSnippetResolver(
233
+ {
234
+ uri,
235
+ languageId: this.language.languageId,
236
+ position,
237
+ selection,
238
+ selectedText: editor.textInRange(selection),
239
+ currentLine: line,
240
+ currentWord: word === null ? '' : editor.textInRange(word),
241
+ cursorIndex: Math.max(
242
+ 0,
243
+ carets.findIndex((range) => comparePositions(range.start, selection.start) === 0 && comparePositions(range.end, selection.end) === 0)
244
+ ),
245
+ cursorCount: carets.length,
246
+ folder: project.host.folder,
247
+ path: fileUriToPath(uri) ?? project.host.pathOfUri?.(uri) ?? undefined
248
+ },
249
+ project.host.resolveSnippetVariable?.bind(project.host),
250
+ { now }
251
+ );
252
+ const atItem =
253
+ position === caret || item.textEdit === undefined
254
+ ? item
255
+ : { ...item, textEdit: undefined, insertText: item.textEdit?.newText ?? item.insertText };
256
+ const editStart = editRangeOf(atItem, replace)?.start;
257
+ // A commit character follows the name, so accepting must not also add a call.
258
+ const finish = (variableTransforms?: readonly string[]): Insertion => {
259
+ const insertion = insertionOf(
260
+ atItem,
261
+ position,
262
+ line.slice(0, position.character),
263
+ replace,
264
+ commit === undefined ? line.slice(position.character) : '(',
265
+ {
266
+ template,
267
+ variableTransforms,
268
+ resolve: resolver,
269
+ indentationBefore:
270
+ editStart === undefined ? undefined : editor.textInRange({ start: { line: editStart.line, character: 0 }, end: editStart })
271
+ }
272
+ );
273
+ return commit === undefined ? insertion : { ...insertion, text: insertion.text + commit };
274
+ };
275
+ const rendered = finish();
276
+ finishes.set(rendered, finish);
277
+ characters += rendered.text.length;
278
+ ranges +=
279
+ (rendered.variableTransforms?.length ?? 0) +
280
+ (rendered.fields?.reduce((count, field) => count + field.ranges.length + (field.linked?.length ?? 0), 1) ?? rendered.stops.length);
281
+ if (rendered.limited || characters > MAX_COMPLETION_CHARACTERS || ranges > MAX_COMPLETION_RANGES) {
282
+ throw new CompletionLimitError();
283
+ }
284
+ return rendered;
285
+ };
286
+ try {
287
+ const planned = renderAt(caret, editor.getSelection());
288
+ const { before, rest } = this.surroundings(planned, caret, lineAfter);
289
+ const plan: CallPlan =
290
+ commit === undefined ? planCall({ item, languageId: this.language.languageId, insertion: planned, before, rest, replace }) : { action: 'none' };
291
+ let main = plan.action === 'add' ? withParentheses(planned, plan.inside) : planned;
292
+ const extras = (item.additionalTextEdits ?? []).map((edit): EditorContentChange => ({ range: edit.range, text: edit.newText }));
293
+ const instances = new Map<EditorContentChange, Insertion>();
294
+ const continuing = inSnippet && snippets.isActive && snippetToken === snippets.token;
295
+ const mirrored = mirroredInsertions(carets, editor.getSelection(), caret, (range) => editor.textInRange(range), main, continuing).map(
296
+ (insertion) => {
297
+ const selection = carets.find(
298
+ (range) =>
299
+ range.start.line === insertion.range.start.line &&
300
+ range.start.character >= insertion.range.start.character &&
301
+ range.start.character <= insertion.range.end.character
302
+ )!;
303
+ const position = selection[continuing ? 'end' : 'start'];
304
+ const own = renderAt(position, selection);
305
+ const rendered = plan.action === 'add' ? withParentheses(own, plan.inside) : own;
306
+ const edit = { ...insertion, text: rendered.text };
307
+ const instance = { ...rendered, range: insertion.range };
308
+ instances.set(edit, instance);
309
+ const finish = finishes.get(own);
310
+ if (finish !== undefined) {
311
+ finishes.set(instance, (values) => ({ ...finish(values), range: insertion.range }));
312
+ }
313
+ return edit;
314
+ }
315
+ );
316
+ const drafts = [...mirrored.map((edit) => instances.get(edit)!), main];
317
+ const jobs: SnippetTransformJob[] = [];
318
+ const keys = new Map<string, number>();
319
+ const slots = drafts.map((draft) =>
320
+ (draft.variableTransforms ?? []).map((job) => {
321
+ const key = JSON.stringify(job);
322
+ let slot = keys.get(key);
323
+ if (slot === undefined) {
324
+ slot = jobs.length;
325
+ keys.set(key, slot);
326
+ jobs.push(job);
327
+ }
328
+ return slot;
329
+ })
330
+ );
331
+ if (jobs.length > MAX_TRANSFORM_JOBS || jobs.reduce((sum, job) => sum + job.value.length, 0) > MAX_TRANSFORM_WORK) {
332
+ throw new CompletionLimitError();
333
+ }
334
+ if (jobs.length > 0 && project.host.snippetTransformWorker !== undefined) {
335
+ const values = await snippets.transformVariables(jobs, current);
336
+ if (!current()) {
337
+ return;
338
+ }
339
+ if (values !== null) {
340
+ const transformed = drafts.map((draft, index) =>
341
+ slots[index]!.length === 0 ? draft : finishes.get(draft)!(slots[index]!.map((slot) => values[slot]!))
342
+ );
343
+ if (
344
+ transformed.some((draft) => draft.limited) ||
345
+ transformed.reduce((sum, draft) => sum + draft.text.length, 0) > MAX_COMPLETION_CHARACTERS
346
+ ) {
347
+ throw new CompletionLimitError();
348
+ }
349
+ main = transformed.at(-1)!;
350
+ mirrored.forEach((edit, index) => {
351
+ edit.text = transformed[index]!.text;
352
+ instances.set(edit, transformed[index]!);
353
+ });
354
+ }
144
355
  }
145
- if (plan.action === 'enter') {
146
- const end = editor.getCaret();
147
- editor.setCaret({ line: end.line, character: end.character + plan.shift });
356
+ const primary = { range: main.range, text: main.text };
357
+ const changes = [primary, ...mirrored, ...extras];
358
+ if (changes.reduce((total, edit) => total + edit.text.length, 0) > MAX_COMPLETION_CHARACTERS) {
359
+ throw new CompletionLimitError();
148
360
  }
149
- } else {
150
- const end = editor.getCaret();
151
- editor.applyEdits([{ range: { start: end, end }, text: commit }]);
152
- if (this.triggerCharacters().includes(commit)) {
153
- this.beginRequest({ triggerKind: 2, triggerCharacter: commit }, 0);
361
+ // Imports and earlier carets move each insertion independently; the primary instance stays last.
362
+ const starts = [...mirrored, primary].map((insertion) =>
363
+ changes
364
+ .filter((change) => change !== insertion && comparePositions(change.range.end, insertion.range.start) <= 0)
365
+ .sort((left, right) => comparePositions(right.range.start, left.range.start))
366
+ .reduce((place, change) => shiftPosition(place, change), insertion.range.start)
367
+ );
368
+ const applied = inSnippet ? snippets.applyEdits(changes, snippetToken, extras) : editor.applyEdits(changes);
369
+ if (!applied) {
370
+ return;
371
+ }
372
+ const landed = editor.getSelections();
373
+ if (commit === undefined) {
374
+ this.language.snippets.beginInstances(
375
+ [...mirrored, primary].map((insertion, index) => {
376
+ const rendered = insertion === primary ? main : instances.get(insertion)!;
377
+ return { start: starts[index]!, text: rendered.text, stops: rendered.stops, fields: rendered.fields };
378
+ }),
379
+ { nested: continuing && snippets.isActive }
380
+ );
381
+ if (plan.action === 'add' && plan.inside && mirrored.length > 0 && main.fields === undefined) {
382
+ this.placeMirroredCarets(carets, mirrored, landed);
383
+ }
384
+ if (plan.action === 'enter') {
385
+ const end = editor.getCaret();
386
+ editor.setCaret({ line: end.line, character: end.character + plan.shift });
387
+ }
388
+ } else {
389
+ if (this.triggerCharacters().includes(commit)) {
390
+ this.beginRequest({ triggerKind: 2, triggerCharacter: commit }, 0);
391
+ }
392
+ }
393
+ this.afterInsert(item, commit, lineBefore);
394
+ editor.focus();
395
+ } catch (error) {
396
+ if (!current()) {
397
+ return;
398
+ }
399
+ if (error instanceof SnippetTransformQueueError) {
400
+ project.host.notify?.({ id: TOAST_ID, kind: 'error', title: project.i18n.t('editor:language.completion.transformBusy') });
401
+ } else if (error instanceof CompletionLimitError || (error instanceof SnippetTransformError && error.kind === 'range')) {
402
+ this.refuseLargeCompletion();
403
+ } else if (error instanceof SnippetTransformError) {
404
+ project.host.notify?.({ id: TOAST_ID, kind: 'error', title: project.i18n.t('editor:language.completion.transformFailed') });
405
+ } else {
406
+ throw error;
154
407
  }
155
408
  }
156
- this.afterInsert(item, commit, lineBefore);
157
- editor.focus();
409
+ }
410
+
411
+ private refuseLargeCompletion(): void {
412
+ this.language.project.host.notify?.({ id: TOAST_ID, kind: 'error', title: this.language.project.i18n.t('editor:language.completion.tooLarge') });
158
413
  }
159
414
 
160
415
  /* The carets that got the same `()` go inside it like the primary one, which the snippet already placed. */
@@ -212,7 +467,7 @@ export class CompletionFeature {
212
467
  private async resolveNow(item: CompletionItem): Promise<CompletionItem> {
213
468
  const { editor, project, uri } = this.language;
214
469
  try {
215
- return await project.service.resolveCompletion(uri, item);
470
+ return { ...item, ...(await project.service.resolveCompletion(uri, item)) };
216
471
  } catch (error) {
217
472
  if (!(error instanceof StaleResultError)) {
218
473
  return item;
@@ -222,7 +477,7 @@ export class CompletionFeature {
222
477
  const fresh = itemsOf(await project.service.completion(uri, editor.getCaret(), { triggerKind: 1 })).items.find(
223
478
  (candidate) => candidate.label === item.label && candidate.kind === item.kind
224
479
  );
225
- return fresh === undefined ? item : await project.service.resolveCompletion(uri, fresh).catch(() => fresh);
480
+ return fresh === undefined ? item : { ...fresh, ...(await project.service.resolveCompletion(uri, fresh).catch(() => fresh)) };
226
481
  } catch {
227
482
  return item;
228
483
  }
@@ -300,6 +555,7 @@ export class CompletionFeature {
300
555
  }
301
556
 
302
557
  private edited(change: EditorTextChange): void {
558
+ this.textRevision++;
303
559
  if (change.source !== 'input' || change.changes.length !== 1) {
304
560
  this.close();
305
561
  return;
@@ -372,16 +628,20 @@ export class CompletionFeature {
372
628
  this.controller?.abort();
373
629
  const controller = new AbortController();
374
630
  this.controller = controller;
375
- let result;
631
+ let normalized: ReturnType<typeof itemsOf>;
376
632
  try {
377
- result = await project.service.completion(uri, position, context, { signal: controller.signal });
633
+ const result = await project.service.completion(uri, position, context, { signal: controller.signal });
634
+ if (request !== this.request || this.controller !== controller || controller.signal.aborted) {
635
+ return;
636
+ }
637
+ normalized = itemsOf(result);
378
638
  } catch {
639
+ if (request === this.request && this.controller === controller && !controller.signal.aborted) {
640
+ this.close();
641
+ }
379
642
  return;
380
643
  }
381
- if (request !== this.request) {
382
- return;
383
- }
384
- const { items, incomplete } = itemsOf(result);
644
+ const { items, incomplete } = normalized;
385
645
  this.pool = items;
386
646
  this.incomplete = incomplete;
387
647
  this.resolved.clear();
@@ -463,7 +723,7 @@ export class CompletionFeature {
463
723
  void project.service
464
724
  .resolveCompletion(uri, entry.item)
465
725
  .then((item) => {
466
- this.resolved.set(entry.item, item);
726
+ this.resolved.set(entry.item, { ...entry.item, ...item });
467
727
  if (this.ranked[this.active]?.item === entry.item) {
468
728
  this.publish();
469
729
  }
@@ -2,7 +2,7 @@ import type { Diagnostic } from '@adecore/lsp';
2
2
  import type { EditorContentChange, EditorPosition, EditorReveal } from '@adecore/editor';
3
3
  import type { EditorLanguage } from './editor-language.ts';
4
4
  import { isShortcut } from './shortcut-keys.ts';
5
- import { comparePositions, markerOf, neighborProblem, problemCountsOf, problemsAt, shiftRange, type Problem } from './diagnostics-model.ts';
5
+ import { comparePositions, markerOf, neighborProblem, problemCountsOf, problemsAt, severityOf, shiftRange, type Problem } from './diagnostics-model.ts';
6
6
 
7
7
  export interface ProblemCounts {
8
8
  readonly error: number;
@@ -69,7 +69,7 @@ export class DiagnosticsFeature {
69
69
  return false;
70
70
  }
71
71
  const { start } = target.diagnostic.range;
72
- this.reveal(start, comparePositions(start, caret) > 0 ? 'centerDown' : 'centerUp');
72
+ this.reveal(target, comparePositions(start, caret) > 0 ? 'centerDown' : 'centerUp');
73
73
  return true;
74
74
  }
75
75
 
@@ -79,7 +79,7 @@ export class DiagnosticsFeature {
79
79
  if (target === null) {
80
80
  return false;
81
81
  }
82
- this.reveal(target.diagnostic.range.start, 'center');
82
+ this.reveal(target, 'center');
83
83
  return true;
84
84
  }
85
85
 
@@ -90,11 +90,13 @@ export class DiagnosticsFeature {
90
90
  };
91
91
  }
92
92
 
93
- private reveal(position: EditorPosition, reveal: EditorReveal): void {
94
- const { editor } = this.language;
95
- editor.setCaret(position, reveal);
93
+ private reveal(problem: Problem, reveal: EditorReveal): void {
94
+ const { editor, project } = this.language;
95
+ editor.setCaret(problem.diagnostic.range.start, reveal);
96
96
  editor.focus();
97
97
  this.language.hover.showProblemsAt(editor.getCaret());
98
+ const severity = project.i18n.t(`editor:language.announce.severity.${severityOf(problem.diagnostic)}`);
99
+ editor.announce(project.i18n.t('editor:language.announce.problem', { severity, message: problem.diagnostic.message }));
98
100
  }
99
101
 
100
102
  private shift(changes: readonly EditorContentChange[]): void {
@@ -0,0 +1,49 @@
1
+ import { diffRows, type DiffDocument } from '@adecore/merge';
2
+
3
+ export interface DiffLimits {
4
+ readonly rows: number;
5
+ readonly characters: number;
6
+ readonly lineLength: number;
7
+ }
8
+
9
+ export const DEFAULT_UNFOLD_LIMITS: DiffLimits = { rows: 20000, characters: 2000000, lineLength: 10000 };
10
+
11
+ export function fitsDiff(document: DiffDocument, unfolded: ReadonlyMap<number, number>, limits: DiffLimits, maxRows = limits.rows): boolean {
12
+ let rows = 0;
13
+ let characters = 0;
14
+ for (const row of diffRows(document, 'unified', unfolded)) {
15
+ if (row.kind === 'gap') {
16
+ continue;
17
+ }
18
+ const text = row.new === undefined ? document.sides.old[row.old!.index]! : document.sides.new[row.new.index]!;
19
+ characters += text.length + (rows > 0 ? 1 : 0);
20
+ if (++rows > maxRows || characters > limits.characters || text.length > limits.lineLength) {
21
+ return false;
22
+ }
23
+ }
24
+ return true;
25
+ }
26
+
27
+ export function expandDiff(document: DiffDocument, unfolded: ReadonlyMap<number, number>, id: number, limits: DiffLimits): ReadonlyMap<number, number> {
28
+ const rows = diffRows(document, 'unified', unfolded);
29
+ const gap = rows.find((row) => row.kind === 'gap' && row.id === id);
30
+ if (gap?.kind !== 'gap' || !gap.unfoldable) {
31
+ return unfolded;
32
+ }
33
+ const visible = rows.filter((row) => row.kind === 'line').length;
34
+ const maxRows = Math.min(limits.rows, visible + 500);
35
+ const previous = unfolded.get(id) ?? 0;
36
+ let low = 0;
37
+ let high = Math.min(500, gap.oldCount);
38
+ // Between-hunk gaps reveal from both ends; search the per-end count within the shared row budget.
39
+ while (low < high) {
40
+ const count = Math.ceil((low + high) / 2);
41
+ const candidate = new Map(unfolded).set(id, previous + count);
42
+ if (fitsDiff(document, candidate, limits, maxRows)) {
43
+ low = count;
44
+ } else {
45
+ high = count - 1;
46
+ }
47
+ }
48
+ return low === 0 ? unfolded : new Map(unfolded).set(id, previous + low);
49
+ }
@@ -0,0 +1,69 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { Button } from '@adecore/ui';
5
+ import { formatNumber } from '@adecore/ui/format';
6
+ import type { Editor, EditorEngine } from '@adecore/editor';
7
+ import type { DiffDisplay } from '@adecore/merge';
8
+ import { RowHost, LineActionHost } from './row-host.ts';
9
+
10
+ export interface MountedDiff {
11
+ engine: EditorEngine;
12
+ editor: Editor;
13
+ rows: RowHost;
14
+ actions: LineActionHost;
15
+ }
16
+
17
+ export function DiffOverlays({
18
+ mounted,
19
+ display,
20
+ unfold,
21
+ blocked
22
+ }: {
23
+ mounted: MountedDiff;
24
+ display: DiffDisplay;
25
+ unfold(id: number): void;
26
+ blocked?: ReadonlySet<number>;
27
+ }) {
28
+ const { t } = useTranslation('editor');
29
+ useSyncExternalStore(mounted.rows.subscribe, mounted.rows.getVersion, mounted.rows.getVersion);
30
+ useSyncExternalStore(mounted.actions.subscribe, mounted.actions.getVersion, mounted.actions.getVersion);
31
+ return (
32
+ <>
33
+ {display.gaps.map(({ row }) => {
34
+ const container = mounted.rows.container(`gap-${row.id}`);
35
+ if (container === undefined) {
36
+ return null;
37
+ }
38
+ const text = t('diff.hidden', { count: formatNumber(Math.max(row.oldCount, row.newCount)) });
39
+ return createPortal(
40
+ <div className="adecore-editor-diff-gap">
41
+ {row.unfoldable ? (
42
+ <Button
43
+ size="xs"
44
+ disabled={blocked?.has(row.id)}
45
+ title={blocked?.has(row.id) ? t('diff.contextLimit') : undefined}
46
+ onClick={() => unfold(row.id)}
47
+ >
48
+ {text}
49
+ </Button>
50
+ ) : (
51
+ <span>{text}</span>
52
+ )}
53
+ </div>,
54
+ container,
55
+ `gap-${row.id}`
56
+ );
57
+ })}
58
+ {display.rows.map((row, index) => {
59
+ if (row.noNewline === undefined) {
60
+ return null;
61
+ }
62
+ const container = mounted.actions.container(`no-newline-${index}`);
63
+ return container === undefined
64
+ ? null
65
+ : createPortal(<span className="adecore-editor-diff-no-newline">{t('diff.noNewline')}</span>, container, `no-newline-${index}`);
66
+ })}
67
+ </>
68
+ );
69
+ }